翻譯|開啟React,Redux和Immutable之旅:測試驅動教程(part2)

Getting Started with React, Redux and Immutable: a Test-Driven Tutorial (Part 2)

翻譯版本,原文請見

Image由[egghead.io提供](http://egghead.io/)
Image由[egghead.io提供](http://egghead.io/)

這是第二部分的內容.

在第一部分,我們羅列了app的UI,開發和單元測試的基礎.

我們看到了app的state通過React的props向下傳遞到單個的組件,用戶的actions聲明為回調函數,因此app的邏輯和UI分離開來了.

Redux的工作流介紹

在這一點上,我們的UI是沒有交互操作的:盡管我們已經測試了如果一個item如果被設定為completed,它將給文本劃線,但是這里還沒有方法邀請用戶來完成它:

  1. state tree通過props定義了UI和action回調函數.
  2. 用戶的actions,例如點擊,被發送到action creator,action被它范式化.
  3. redux action被傳遞到reducer實現實際的app邏輯
  4. reducer更新state tree,dispatch state到store.
  5. UI根據store里的新state tree來更新UI
Redux working flos
Redux working flos

設定初始化state

這部分的代碼提交在這里

我們的第一個action將會允許我們在Redux store里正確的設置初始化state
,我們將會創建store.

Redux中的action是一個信息的載體(payload).action由一個JSON對象有一個type屬性,描述action到底是做什么的,還有一部分是app需要的信息.在我們的實例中,type被設定為SET_STATE,我們可以添加一個state對象包含需要的state:

{
  type: 'SET_STATE',
  state: {
    todos: [
      {id: 1, text: 'React', status: 'active', editing: false},
      {id: 2, text: 'Redux', status: 'active', editing: false},
      {id: 3, text: 'Immutable', status: 'active', editing: false},
    ],
    filter: 'all'
  }
}

這個action會被dispatch到一個reducer,reducer角色的是識別和實施和action對應的邏輯代碼.

讓我們為reducer來寫單元測試代碼
test/reducer_spec.js

 import {List, Map, fromJS} from 'immutable';
import {expect} from 'chai';

import reducer from '../src/reducer';

describe('reducer', () => {

  it('handles SET_STATE', () => {
    const initialState = Map();
    const action = {
      type: 'SET_STATE',
      state: Map({
        todos: List.of(
          Map({id: 1, text: 'React', status: 'active'}),
          Map({id: 2, text: 'Redux', status: 'active'}),
          Map({id: 3, text: 'Immutable', status: 'completed'})
        )
      })
    };

    const nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'completed'}
      ]
    }));
  });

});

為了方便一點,state使用單純JS對象,而不是使用Immutable數據結構.讓我們的reducer來處理轉變.最后,reducer將會優雅的處理undefined初始化state:
test/reducer_spec.js

 // ...
describe('reducer', () => {
  // ...
  it('handles SET_STATE with plain JS payload', () => {
    const initialState = Map();
    const action = {
      type: 'SET_STATE',
      state: {
        todos: [
          {id: 1, text: 'React', status: 'active'},
          {id: 2, text: 'Redux', status: 'active'},
          {id: 3, text: 'Immutable', status: 'completed'}
        ]
      }
    };
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'completed'}
      ]
    }));
  });

  it('handles SET_STATE without initial state', () => {
    const action = {
      type: 'SET_STATE',
      state: {
        todos: [
          {id: 1, text: 'React', status: 'active'},
          {id: 2, text: 'Redux', status: 'active'},
          {id: 3, text: 'Immutable', status: 'completed'}
        ]
      }
    };
    const nextState = reducer(undefined, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'completed'}
      ]
    }));
  });
});

我們的reducer將會匹配接收的actions的type,如果type是SET_STATE,當前的state和action運載的state融合在一起:
src/reducer.js

import {Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

export default function(state = Map(), action) {
  switch (action.type) {
    case 'SET_STATE':
      return setState(state, action.state);
  }
  return state;
}

現在我們不得不把reducer連接到我們的app,所以當app啟動初始化state.這里實際是第一次使用Redux庫,安裝一下
npm install —save redux@3.3.1 react-redux@4.4.1

src/index.jsx

 import React from 'react';
import ReactDOM from 'react-dom';
import {List, Map} from 'immutable';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import {TodoAppContainer} from './components/TodoApp';

// We instantiate a new Redux store
const store = createStore(reducer);
// We dispatch the SET_STATE action holding the desired state
store.dispatch({
  type: 'SET_STATE',
  state: {
    todos: [
      {id: 1, text: 'React', status: 'active', editing: false},
      {id: 2, text: 'Redux', status: 'active', editing: false},
      {id: 3, text: 'Immutable', status: 'active', editing: false},
    ],
    filter: 'all'
  }
});

require('../node_modules/todomvc-app-css/index.css');

ReactDOM.render(
  // We wrap our app in a Provider component to pass the store down to the components
  <Provider store={store}>
    <TodoAppContainer />
  </Provider>,
  document.getElementById('app')
);

如果你看看上面的代碼段,你可以注意到我們的TodoApp組件實際是被TodoAppContainer代替.在Redux里,有兩種類型的組件:展示組件和容器.我推薦你閱讀一下由Dan Abramov(Redux的作者)寫作的高信息量的文章,強調了展示組件和容器的差異性.

如果我想總結得快一點,我將引用Redux 文檔的內容:

“展示組件是關于事件的樣子(模板和樣式),容器組件是關于事情是怎么工作的(數據獲取,state更新)”.

所以我們創建store,傳遞給TodoAppContainer.然而為了子組件可以使用store,我們把state映射成為React組件TodoAppprops.
src/components/TodoApp.jsx

 // ...
import {connect} from 'react-redux';

export class TodoApp extends React.Component {
// ...
}
function mapStateToProps(state) {
  return {
    todos: state.get('todos'),
    filter: state.get('filter')
  };
}

export const TodoAppContainer = connect(mapStateToProps)(TodoApp);

如果你在瀏覽器中重新加載app,你應該可以看到它初始化和之前一樣,不過現在使用Redux tools.

Redux dev 工具

這一部分的提交代碼

現在我們已經配置了redux store和reducer.我們可以配置Redux dev tools來展現數據流開發.

首先,獲取Redux dev tools Chrome extension

dev tools可以在Store創建的時候可以加載.

src/index.jsx

 // ...
import {compose, createStore} from 'redux';

const createStoreDevTools = compose(
  window.devToolsExtension ? window.devToolsExtension() : f => f
)(createStore);
const store = createStoreDevTools(reducer);
// ...
Redux dev tools
Redux dev tools

重新加載app,點擊Redux圖標,有了.

有三個不同的監視器可以使用:Diff監視器,日志監視器,Slider監視器.

使用Action Creators配置我們的actions

切換item的不同狀態.

這部分的提交代碼在這里

下一步是允許用戶在activecompleted之前切換狀態:
test/reducer_spec.js

 import {List, Map, fromJS} from 'immutable';
import {expect} from 'chai';

import reducer from '../src/reducer';

describe('reducer', () => {
// ...
  it('handles TOGGLE_COMPLETE by changing the status from active to completed', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'completed'}
      ]
    });
    const action = {
      type: 'TOGGLE_COMPLETE',
      itemId: 1
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'completed'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'completed'}
      ]
    }));
  });

  it('handles TOGGLE_COMPLETE by changing the status from completed to active', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'completed'}
      ]
    });
    const action = {
      type: 'TOGGLE_COMPLETE',
      itemId: 3
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'active'}
      ]
    }));
  });
});

為了通過這些測試,我們更新reducer:
src/reducer.js

// ...
function toggleComplete(state, itemId) {
  // We find the index associated with the itemId
  const itemIndex = state.get('todos').findIndex(
    (item) => item.get('id') === itemId
  );
  // We update the todo at this index
  const updatedItem = state.get('todos')
    .get(itemIndex)
    .update('status', status => status === 'active' ? 'completed' : 'active');

  // We update the state to account for the modified todo
  return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

export default function(state = Map(), action) {
  switch (action.type) {
    case 'SET_STATE':
      return setState(state, action.state);
    case 'TOGGLE_COMPLETE':
      return toggleComplete(state, action.itemId);
  }
  return state;
}

SET_STATE的action同一個地方,我們需要讓TodoAppContainer組件感知到action,所以toggleComplete回調函數會被傳遞到TodoItem組件(實際調用函數的地方).

在Redux中,有標準的方法來做這件事:Action Creators.

action creators是簡單的函數,返回合適的action,這些韓式是React的props的一些映射之一.
讓我們創建第一個action creator:
src/action_creators.js

export function toggleComplete(itemId) {
  return {
    type: 'TOGGLE_COMPLETE',
    itemId
  }
}

現在,盡管TodoAppcontainer組件中的connect函數的調用可以用來獲取store,我們告訴組件使用映射props的回調函數:
src/components/TodoApp.jsx

// ...
import * as actionCreators from '../action_creators';
export class TodoApp extends React.Component {
  // ...
  render() {
    return <div>
      // ...
        // We use the spread operator for better lisibility
        <TodoList  {...this.props} />
      // ...
    </div>
  }
};

export const TodoAppContainer = connect(mapStateToProps, actionCreators)(TodoApp);

重啟你的webserver,刷新一下你的瀏覽器:當當.在條目上點擊現在可以切換它的狀態.如果你查看Redux dev tools,你可以看到觸發的action和后繼的更新.

改變目前的過濾器

相關代碼在在這里

現在每件事情都已經配置完畢,寫其他的action是件小事.我們繼續創建你希望的CHANGE_FILTERaction,改變當前state的filter,由此僅僅顯示過濾過的條目.
開始創建action creator:
src/action_creators.js

 // ...
export function changeFilter(filter) {
  return {
    type: 'CHANGE_FILTER',
    filter
  }
}

現在寫reducer的單元測試:
test/reducer_spec.js

// ...
describe('reducer', () => {
  // ...
  it('handles CHANGE_FILTER by changing the filter', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
      ],
      filter: 'all'
    });
    const action = {
      type: 'CHANGE_FILTER',
      filter: 'active'
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
      ],
      filter: 'active'
    }));
  });
});

關聯的reducer函數:
src/reducer.js

 // ...
function changeFilter(state, filter) {
  return state.set('filter', filter);
}

export default function(state = Map(), action) {
  switch (action.type) {
    case 'SET_STATE':
      return setState(state, action.state);
    case 'TOGGLE_COMPLETE':
      return toggleComplete(state, action.itemId);
    case 'CHANGE_FILTER':
      return changeFilter(state, action.filter);
  }
  return state;
}

最后我們把changeFilter回調函數傳遞給TodoTools組件:
TodoApp.jsx

// ...
export class TodoApp extends React.Component {
  // ...
  render() {
    return <div>
      <section className="todoapp">
        // ...
        <TodoTools changeFilter={this.props.changeFilter}
                   filter={this.props.filter}
                   nbActiveItems={this.getNbActiveItems()} />
      </section>
      <Footer />
    </div>
  }
};

完成了,第一個filter selector工作完美

Item編輯

代碼在這里
當用戶編輯一個條目,實際上是兩個actions觸發的三個可能性:

  • 用戶輸入編輯模式:EDIT_ITEM
  • 用戶退出編輯模式(不保存變化):CANCEL_EDITING
  • 用戶驗證他的編輯(保存變化):DONE_EDITING

我們可以為三個actions編寫action creators:
src/action_creators.js

// ...
export function editItem(itemId) {
  return {
    type: 'EDIT_ITEM',
    itemId
  }
}

export function cancelEditing(itemId) {
  return {
    type: 'CANCEL_EDITING',
    itemId
  }
}

export function doneEditing(itemId, newText) {
  return {
    type: 'DONE_EDITING',
    itemId,
    newText
  }
}

現在為這些actions編寫單元測試:
test/reducer_spec.js

// ...
describe('reducer', () => {
  // ...
  it('handles EDIT_ITEM by setting editing to true', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active', editing: false},
      ]
    });
    const action = {
      type: 'EDIT_ITEM',
      itemId: 1
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active', editing: true},
      ]
    }));
  });

  it('handles CANCEL_EDITING by setting editing to false', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active', editing: true},
      ]
    });
    const action = {
      type: 'CANCEL_EDITING',
      itemId: 1
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active', editing: false},
      ]
    }));
  });

  it('handles DONE_EDITING by setting by updating the text', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active', editing: true},
      ]
    });
    const action = {
      type: 'DONE_EDITING',
      itemId: 1,
      newText: 'Redux',
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'Redux', status: 'active', editing: false},
      ]
    }));
  });
});

現在我們可以開發reducer函數,實際操作三個actions:
src/reducer.js

function findItemIndex(state, itemId) {
  return state.get('todos').findIndex(
    (item) => item.get('id') === itemId
  );
}

// We can refactor the toggleComplete function to use findItemIndex
function toggleComplete(state, itemId) {
  const itemIndex = findItemIndex(state, itemId);
  const updatedItem = state.get('todos')
    .get(itemIndex)
    .update('status', status => status === 'active' ? 'completed' : 'active');

  return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

function editItem(state, itemId) {
  const itemIndex = findItemIndex(state, itemId);
  const updatedItem = state.get('todos')
    .get(itemIndex)
    .set('editing', true);

  return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

function cancelEditing(state, itemId) {
  const itemIndex = findItemIndex(state, itemId);
  const updatedItem = state.get('todos')
    .get(itemIndex)
    .set('editing', false);

  return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

function doneEditing(state, itemId, newText) {
  const itemIndex = findItemIndex(state, itemId);
  const updatedItem = state.get('todos')
    .get(itemIndex)
    .set('editing', false)
    .set('text', newText);

  return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

export default function(state = Map(), action) {
  switch (action.type) {
    // ...
    case 'EDIT_ITEM':
      return editItem(state, action.itemId);
    case 'CANCEL_EDITING':
      return cancelEditing(state, action.itemId);
    case 'DONE_EDITING':
      return doneEditing(state, action.itemId, action.newText);
  }
  return state;
}

清除完成,添加和刪除條目

代碼在這里

三個剩下的action是:

  1. CLEAR_COMPLETED,在TodoTools組件中觸發,從列表中清除完成的條目
  2. ADD_ITEM,在TodoHeader中觸發,根據用戶的的輸入文本來添加條目
  3. DELETE_ITEM,相似TodoItem中調用,刪除一個條目

我們現在使用的工作流是:添加action creators,單元測試reducer和代碼邏輯,最終通過props傳遞回調函數:
src/action_creators.js

// ...
export function clearCompleted() {
  return {
    type: 'CLEAR_COMPLETED'
  }
}

export function addItem(text) {
  return {
    type: 'ADD_ITEM',
    text
  }
}

export function deleteItem(itemId) {
  return {
    type: 'DELETE_ITEM',
    itemId
  }
}

test/reducer_spec.js

 // ...
describe('reducer', () => {
  // ...
  it('handles CLEAR_COMPLETED by removing all the completed items', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'completed'},
      ]
    });
    const action = {
      type: 'CLEAR_COMPLETED'
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
      ]
    }));
  });

  it('handles ADD_ITEM by adding the item', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'}
      ]
    });
    const action = {
      type: 'ADD_ITEM',
      text: 'Redux'
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
      ]
    }));
  });

  it('handles DELETE_ITEM by removing the item', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'completed'},
      ]
    });
    const action = {
      type: 'DELETE_ITEM',
      itemId: 2
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
      ]
    }));
  });
});

src/reducer.js

function clearCompleted(state) {
  return state.update('todos',
    (todos) => todos.filterNot(
      (item) => item.get('status') === 'completed'
    )
  );
}

function addItem(state, text) {
  const itemId = state.get('todos').reduce((maxId, item) => Math.max(maxId,item.get('id')), 0) + 1;
  const newItem = Map({id: itemId, text: text, status: 'active'});
  return state.update('todos', (todos) => todos.push(newItem));
}

function deleteItem(state, itemId) {
  return state.update('todos',
    (todos) => todos.filterNot(
      (item) => item.get('id') === itemId
    )
  );
}

export default function(state = Map(), action) {
  switch (action.type) {
    // ...
    case 'CLEAR_COMPLETED':
      return clearCompleted(state);
    case 'ADD_ITEM':
      return addItem(state, action.text);
    case 'DELETE_ITEM':
      return deleteItem(state, action.itemId);
  }
  return state;
}

src/components/TodoApp.jsx

 // ...
export class TodoApp extends React.Component {
  // ...
  render() {
    return <div>
      <section className="todoapp">
        // We pass down the addItem callback
        <TodoHeader addItem={this.props.addItem}/>
        <TodoList {...this.props} />
        // We pass down the clearCompleted callback
        <TodoTools changeFilter={this.props.changeFilter}
                    filter={this.props.filter}
                    nbActiveItems={this.getNbActiveItems()}
                    clearCompleted={this.props.clearCompleted}/>
      </section>
      <Footer />
    </div>
  }
};

我們的TodoMVC app現在完成了.

包裝起來

這我們的測試驅動的React,Redux&Immutable 技術棧

如果你想了解更多內容,有更多的事情等著你去挖掘
例如:

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,967評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,273評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,870評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,742評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,527評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,010評論 1 322
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,108評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,250評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,769評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,656評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,853評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,371評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,103評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,472評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,717評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,487評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,815評論 2 372

推薦閱讀更多精彩內容