用React+Redux写一个RubyChina山寨版(一)

代码地址

https://github.com/hql123/reactJS-ruby-china


Demo

https://hql123.github.io/reactJS-ruby-china/


相关

用React+Redux写一个RubyChina山寨版(二)


项目简介

项目不断更新完善中,目前实现的功能不多,是一边写代码一边写的文档,每个人搭建React项目的时候习惯都不一样,我只是希望把我自己在学习React中的经验分享出来,如果觉得我的项目对你的初学有帮助的话,可以拜托给个start咩?求轻拍~

使用之前请先阅读Redux中文文档


步骤一:启动项目并初始化

全局安装create-react-app

npm install -g create-react-app

初始化项目(ruby-china是文件夹名称)

create-react-app ruby-china
安装成功以后会有以下内容

Paste_Image.png

进入文件目录
cd ruby-china
输入ls可查看目录内容包括以下文件

Paste_Image.png

其中node_modules是第三方的安装包,在.gitignore中是默认忽略,package.json是第三方库安装配置文件,public内存储静态html或图片等,src是应用目录,js或jsx文件、css文件、打包文件等写在里面。这是使用create-react-app启动的默认目录结构,当然也可以自定义。

现在你已经拥有一个最简单的“Welcome to React”的项目,下面我们正式开始。
首先我们先确保使用create-react-app已经安装reactreact-dom,如果没有请手动执行以下命令
npm init
这个命令之后会要求填写一些配置选项,包括入口文件、git地址等,根据个人需求填写就行,我基本都默认
npm install --save react react-dom

建议翻墙,没有翻墙的建议修改npm的镜像,如:
npm config set registry https://registry.npm.taobao.orgnpm
以上地址有可能有修改,以最新版本的镜像为主

另外react-script自带打包构建的命令,可以直接执行
npm start
默认是localhost:3000端口且自动打开

Paste_Image.png

如果打算自定义webpack或者gulp构建打包项目,可以在package.json中自定义启动命令,如:

"scripts": {
    "start": "node server.js",
    "build": "node build.js",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }

在这里我直接使用webpack来构建,当然也可以gulp+webpack结合使用,可以达到分任务流的效果,我尝试过,但是目前为止总体来说其实webpack就够用了,开发过程中如果遇到webpack效率低不得不用gulp来解决的情况大概才需要结合起来使用,目前我发现的webpack缺陷有:

  1. 在监听文件变化的时候把index.html排除了,需要我们手动刷新,也就是说只有修改src目录下的文件才能生效

由于我们是使用create-react-app来初始化项目的,项目本身已经包含了react-script下所有的第三方,所以可以不用另外安装webpack的第三方包(:зゝ∠)

下一步我们安装相关的库(再启动服务之前需要安装,不然会报错:Uncaught SyntaxError: Unexpected token import):

npm install --save-dev babel-cli babel-preset-es2015 babel-preset-react
npm install --save-dev babel-eslint eslint eslint-loader eslint-plugin-react eslint-config-react-app
npm install --save-dev babel-loader style-loader less less-loader file-loader url-loader css-loader

创建.babelrc文件
touch .babelrc并且添加以下代码:

{
  "presets": ["es2015", "react"]
}

创建.eslintrc文件
touch .eslintrc并且添加以下代码:

{
  "extends": "react-app"
}

那么我们开始配置webpack吧,首先我们要新建一个config文件夹来存储配置文件:

mkdir config
touch config/webpack.config.dev.js //开发环境配置
touch config/webpack.config.prod.js//生产环境配置
touch config/paths.js //文件路径配置
touch server.js //启动文件

webpack.config.prod.js和server.js我参考react-scripts的配置文件做出一些小的修改
ruby-china/config/webpack.config.dev.js

//../config/webpack.config.dev.js
module.exports = {
  entry: [
    require.resolve('react-dev-utils/webpackHotDevClient'),//去掉就无法监听文件实时变化并刷新
    paths.appIndexJs
  ],
  output: {
    path: path.join(__dirname, 'build'),
    pathinfo: true,
    filename: 'static/js/bundle.js',
    publicPath: publicPath
  },
  devtool: 'cheap-module-source-map',
  plugins: [
    new InterpolateHtmlPlugin({
      PUBLIC_URL: publicUrl
    }),
    // Generates an `index.html` file with the <script> injected.
    new HtmlWebpackPlugin({
      inject: true,
      template: paths.appHtml,
    }),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(
        process.env.NODE_ENV || 'development'
      )
    }),
    new webpack.HotModuleReplacementPlugin(),
    new CaseSensitivePathsPlugin(),
    new WatchMissingNodeModulesPlugin(paths.appNodeModules)
  ],
  ...
  module: {
    preLoaders: [
      {
        test: /\.(js|jsx)$/,
        loader: 'eslint',
        include: paths.appSrc,
      }
    ],
    loaders: [{
      test: /\.(js|jsx)$/,
      include: paths.appSrc,
      loader: 'babel',
      query: {
        babelrc: false,
        presets: [require.resolve('babel-preset-react-app')],
        cacheDirectory: true
      }
    }, {
      test: /\.(jpg|png|svg)$/,
      loader: 'file',
      query: {
        name: 'static/media/[name].[hash:8].[ext]'
      }
    }
    ...
    //代码略
    ...
    ]
  }
}

ruby-china/config/webpack.config.prod.js

//../config/webpack.config.prod.js
...//前后部分代码省略
if (process.env.NODE_ENV !== "production") {
  throw new Error('Production builds must have NODE_ENV=production.');
}
...

ruby-china/server.js(代码略)
ruby/china/build.js(代码略)

到这一步,我们已经配置好基础的web静态服务、热加载自动刷新和生产环境的打包。

启动服务(不要忘记在paths.js设置端口号(:зゝ∠),默认为8890)
npm run start

Paste_Image.png

生产环境下打包:
npm run build

步骤二:添加react-route+redux

我们安装先一下之后需要用到的库:

npm install react-router --save redux 
npm install isomorphic-fetch moment redux-logger react-redux react-router-redux redux-thunk --save-dev 

moment.js可以轻松管理时间和日期,moment.js例子

使用react-css-modules来为每一个 CSS 类在加载 CSS 文档的时候生成一个唯一的名字。
npm install --save-dev react-css-modules

1. redux

首先我们先新建如下目录:

Paste_Image.png

这是一个普通的view层的例子:

import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
var {addToSchedule} = require('../../actions');
class Sessions extends Component{
  constructor(props) {
    super(props);
  }
  render(){
    //代码
  }
}
function select(store, props) {
  return {
    isLoggedIn: store.user.isLoggedIn,
  };
}

function actions(dispatch, props) {
  let id = props.session.id;
  return {
    addToSchedule: () => dispatch(addToSchedule(id)),
}

module.exports = connect(select, actions)(Sessions);


1. Action

新建src/actions/index.js用来组合各个action:

//../src/actions/index.js
'use strict';

// const loginActions = require('./login');
// const scheduleActions = require('./schedule');
// const filterActions = require('./filter');
// const notificationActions = require('./notifications');
// const configActions = require('./config');

module.exports = {
  //...loginActions,
  // ...scheduleActions,
  // ...filterActions,
  // ...notificationActions,
  // ...configActions,
};

Action是把数据从应用传到store的载体,是store数据的唯一来源,栗子:

//常见action
export function skipLogin(): Action {
  return {
    type: 'SKIPPED_LOGIN',
  };
}
...
//异步处理数据的action
export function logIn(): ThunkAction {
  return (dispatch) => {
    //登录接口操作回调代码
    
    // TODO: Make sure reducers clear their state
    return dispatch({
      type: 'LOGGED_IN',
      data: []
    });
  };
}

当然我们可以吧 action.type 写一个配置文件初始化返回数据的数据结构,如:

// ..src/config/types.js
'use strict';
export type Action =
    { type: 'LOGGED_IN', data: { id: string; name: string; } }
  | { type: 'SKIPPED_LOGIN' }
  | { type: 'LOGGED_OUT' }
  ;

提前想好对象的数据结构是个好习惯(:з っ )っ,虽然我每次也挺懒的。

需要注意的是,在异步处理数据的时候,我们最好将请求数据、接收数据、刷新数据、显示数据分开不同的action,减少请求数据和特定的 UI 事件耦合。

connect帮助器例子:

function mapStateToProps(state, props) {
  return {
    isLoggedIn: store.user.isLoggedIn,
  };
}

function mapDispatchToProps(dispatch, props) {
  let id = props.session.id;
  return {
    addToSchedule: () => dispatch(addToSchedule(id)),
}

module.exports = connect(mapStateToProps, mapDispatchToProps)(Sessions);

我们这里只了解connect() 接收的两个参数selectactions
mapStateToProps就是将store数据作为props绑定到组件上的函数,store作为这个函数方法的参数传入。
mapDispatchToProps是将action中的方法通过dispatch序列化后作为props绑定到组件中。
mapStateToProps 中 store 能直接通过 store.dispatch() 调用 dispatch() 方法,但是多数情况下我们会使用mapDispatchToProps方法直接接受 dispatch 参数。bindActionCreators() 可以自动把多个 action 创建函数 绑定到 dispatch() 方法上。

function mapDispatchToProps(dispatch, props) {
let id = props.session.id;
  return bindActionCreators({
    addToSchedule: () => action.addToSchedule(id),
  });
}
2. Reducer

Action 对数据进行处理之后,需要更新到组件中,这个时候我们需要 Reducer 更新state。
首先我们可以造一个初始化的state来决定需要这棵对象树(Object tree)里面的某个reducer分支需要操作的有哪些数据:

//reducers/users.js
const initialState = {
  isLoggedIn: false,
  hasSkippedLogin: false,
  id: null,
  name: null,
};

如果我们是通过网络请求获取的数据对象,那么在初始化的 state 中我们可以规定保留如下字段:isFetching 来显示数据的获取进度, didInvalidate来标记数据是否过期, lastUpdated 来存放数据的最后更新时间,还有使用 data来存放数据数组,在实际应用中我们需要用到类似分页的 fetchedPageCountnextPageUrl

假设我们获取的是分 Tab 的列表数据,建议将这些列表数据分开存储,保证用户来回切换可以立即更新。

**Action ** 和 Reducer 有明确的分工,Reducer里面需要尽量保持整洁,永远不在 Reducer 里面执行以下操作:

  • 修改传入参数;
  • 执行有副作用的操作,如 API 请求和路由跳转;
  • 调用非纯函数,如 Date.now()Math.random()
    Redux 文档中有明确提示:

只要传入参数相同,返回计算得到的下一个 state 就一定相同。没有特殊情况、没有副作用,没有 API 请求、没有变量修改,单纯执行计算。

栗子:

//reducers/users.js
function user(state = initialState, action) {
  switch (action.type) {
    case 'LOGGED_IN':
      return {
        ...state, 
        isLoggedIn: true,
        hasSkippedLogin: false,
        action.data
      };
    case 'SKIPPED_LOGIN':
      return {
        ...state,
        hasSkippedLogin: true,
      };
    case 'LOGGED_OUT':
      return initialState;
    default:
      return state;
  }
}

我并没有使用文档推荐的 Object.assign() 来新建state的副本,用以上方式也可以避免直接修改 state ,我主要是为了减少缩进和看起来好像复杂了的写法。详情请看ES7对象展开运算符

**每个 Reducer 都有专属管理的 State **, 拆分之后用 combineReducers()工具类将各个 reducers 整合到 reducers/index.js 文件中。如:
然后我们在reducers中新建一个index.js文件,用来组合多个reducer

//../src/reducers/index.js
'use strict';

var { combineReducers } = require('redux');

module.exports = combineReducers({
  // sessions: require('./sessions'),
  // user: require('./user'),
  // topics: require('./topics'),
});

当然你也可以这样写:

// ../reducers/index.js
import { combineReducers } from 'redux'
import * as reducers from './reducers'

module.exports = combineReducers(reducers)

前提是每一个reducer里面的函数都使用 export 将所有函数暴露出来:

//../reducers/user.js

...
module.exports = user;

/*或者用这种方式返回多个函数
*/
module.exports = {
  user1,
  user2,
};
3. Store

Store 就是把ActionReducer联系到一起的对象。Store 有以下职责:

维持应用的 state
提供 getState()方法获取 state;
提供 dispatch(action) 方法更新 state;
通过 subscribe(listener) 注册监听器;
通过 subscribe(listener) 返回的函数注销监听器。
再次强调一下 Redux 应用只有一个单一的store。当需要拆分数据处理逻辑时,你应该使用 reducer 组合 而不是创建多个store

以上是官方解释。

下面以这个例子解释一下 Store 的配置:

这一步是为了将 根 reducer 返回的完整 state 树 保存到 单一Store 中。

// ../store/configStore.js
var reducers = require('../reducers');
import {createStore} from 'redux';

let store = createStore(reducers)

重构 store

为了方便我们更好得处理接下来的编写,我们对代码目录结构进行一下小的调整,大致如下:


如图所示,我们将 configureStore.js拆分成三个文件以响应不同环境下的配置:

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./configureStore.prod')
} else {
  module.exports = require('./configureStore.dev')
}

上面说我们已经将 reducer 返回的 state 树挂到 store 中,接下来我们为了之后处理稍微复杂一点的逻辑需要再挂个 middlewares (中间件),middlewares 配置如下:

const logger = createLogger();
const middlewares = [thunk, logger];
var createRubyChinaStore = applyMiddleware(...middlewares)(createStore);

middlewares中中包含了 react-thunk 异步加载插件 和 react-logger 的状态树跟踪记录插件。
整合之后:

//../src/store/configureStore.dev.js
/**
方法包含在const configureStore = (initialState) => {}函数体内
*/
...
const store = createStore(
      reducers,
      initialState,
      compose(
        applyMiddleware(...middlewares),
        DevTools.instrument()
      )
  )
...

接下来我们开始配置 router


2. react-router和react-router-redux

我们要做的是一个网站,既然是网站就要有路由
以上是废话。
我们先需要了解一下为啥我们要用到这个路由配置,首先是 React-Router :"它通过管理 URL,实现组件的切换和状态的变化,开发复杂的应用几乎肯定会用到。"
我们可以看到在 React-Route官方手册 中对于路由的基础配置有详细的描写,这里我们不做赘述,由于我们使用的是 Redux 来作为状态管理器,那么我这边就直接上手配置react-router-redux。这里有官方示例:react-router-redux
关于为啥要用react-router-redux而不用 Redux + React-Route,我知道反正没有人想知道原因,做就是了!

配置WebpackDevServer
// server.js
var historyApiFallback    = require('connect-history-api-fallback');
devServer: {
  historyApiFallback: true,
}

配置入口文件

我们在阅读官方文档的时候看到这样一段话来解释 history : "React Router是建立在 history 之上的。 简而言之,一个 history 知道如何去监听浏览器地址栏的变化, 并解析这个 URL 转化为 location 对象, 然后 router 使用它匹配到路由,最后正确地渲染对应的组件。"如:

import { browserHistory } from 'react-router';
import { syncHistoryWithStore } from 'react-router-redux';
const store = configureStore();
const history = syncHistoryWithStore(browserHistory, store);

//然后将它们传递给<Router>

<Root store={store} history={history} />
// ./src/index.js
import { browserHistory } from 'react-router'
import { syncHistoryWithStore } from 'react-router-redux'
import Root from './containers/root'
import configureStore from './store/configureStore'

const store = configureStore()
const history = syncHistoryWithStore(browserHistory, store)

render(
  <Root store={store} history={history} />,
  document.getElementById('root')
);

/** 
./src/containers/root.dev.js */
import routes from '../config/route'
import { Router } from 'react-router'

const Root = ({ store, history }) => (
  <Provider store={store}>
    <div>
      <Router history={history} routes={routes} />
    </div>
  </Provider>
);

Root.propTypes = {
  store: PropTypes.object.isRequired,
  history: PropTypes.object.isRequired
};

// .src/config/route.js
import React from 'react'
import { Route } from 'react-router'
import App from '../containers/app'

const RouteConfig = (
  <Route path="/" component={App}>
    
  </Route>
);
export default RouteConfig;

配置reducers的根文件

// ./src/reducers/index.js
var { combineReducers } = require('redux');
import { routerReducer } from 'react-router-redux';
module.exports = combineReducers({
  routing: routerReducer,
});

路由基本已经配置完成了,下面我们可以尝试在首页获取 RubyChina 的帖子数据并且渲染出来,实现异步加载的方式。


3. 异步获取数据

前面提到我们在进行异步加载数据的时候最好将各种情况分开封装起来,减少耦合。
在调用 API 的时候我们需要对数据进行以下三种判断:

  • 一种通知 reducer 请求开始的 action
  • 一种通知 reducer 请求成功结束的 action。
  • 一种通知 reducer 请求失败的 action。

我们先写一个通用的 fetch 函数:

import fetch from 'isomorphic-fetch';

//跨域的反向代理已经设置,注意,当使用这个 CORS 跨域处理的时候,webpackDevServer 会出现不正常连接的错误导致有些文件无法实现热加载自动刷新,所以如果请求的服务端并不要求跨域才能访问,用原始地址如:https://ruby-china.org/api/v3/ 即可。
const url = 'https://localhost:8890/api/v3/'

const urlTranslate = (tag) => {
  switch(tag) {
    case 'jobs': //招聘节点是node_id=25的topics
      return 'topics?node_id=25'
    default :
      return 'topics'
  }
}
//获取数据
const fetchData = (tag, method = 'get', params = null): Promise<Action>  => {
  const api = url + urlTranslate(tag);
  console.log(decodeURI(api));
  return fetch(api, { method: method, body: params})
  .then(response =>{
    if (!response.ok) {
      return Promise.reject(response);
    }
    return Promise.resolve(response.json());
  }).catch(error => {
    return Promise.reject("服务器异常,请稍后再试");
  })
}

这段代码对 fetch 数据做了封装回调,接下来我们只要在 action 中进行调用就可以了,我将请求成功和请求失败分开两个 action type写,这个可以看个人习惯,一般保持团队内部人员规范就行了。

const fetchTopics = (tab) => dispatch => {
  dispatch(requestTopics(tab))
  return fetchData(tab).then(response => {
    dispatch(receiveTopics(tab, response))
  }).catch(error => {
    //请求数据失败
    dispatch({
      type: 'RECEIVE_TOPICS_FAILURE',
      error: error,
    })
  })
  
}
const receiveTopics = (tab, json) => ({
  type: 'RECEIVE_TOPICS_SUCCESS',
  tab,
  topics: json.topics,
  receivedAt: Date.now()
})

当我们需要请求数据的时候会发起一个 action :

//请求帖子列表开始
const requestTopics = tab => ({
  type: 'REQUEST_TOPICS',
  tab
})

这个时候我们在 reducer 中就开始更新 isFetching 的状态, 显示开始加载数据,用户在这段时间再次请求的时候就会先判断是否处于 isFetching == true ,如果处于这个条件,那么就不会重复请求。

//.src/reducers/topics.js
const initialState = {
  isFetching: false,
  didInvalidate: false,
  items: []
}

//更新选择的标签页
const selectedTab = (state = 'topics', action) => {
  switch (action.type) {
    case 'SELECT_TAB':
      return action.tab
    default:
      return state
  }
}
const topics = (state = initialState, action) => {
  switch (action.type) {
    case 'REQUEST_TOPICS':
      return {
        ...state,
        isFetching: true,
      }
    case 'RECEIVE_TOPICS_SUCCESS':
      return {
        ...state,
        isFetching: false,
        items: action.topics,
        lastUpdated: action.receivedAt
      }
    case 'RECEIVE_TOPICS_FAILURE':
      return {
        ...state,
        isFetching: false,
        err: action.error
      }
    default:
      return state
  }
}

我们在更新数据之前需要先确定是否满足更新的条件:

/**
./src/actions/topic.js
*/
//是否需要更新帖子
const fetchTopicsIfNeeded = tab => (dispatch, getState) => {
  if (shouldFetchTopics(getState(), tab)) {
    return dispatch(fetchTopics(tab))
  }
}
const shouldFetchTopics = (state, tab) => {
  //当前状态树中挂着一个topicsByTab的分支
  //解析这个topicsByTab的分支对象,对象中作为tab的key是个变量,其value是另一个state对象
  const topics = state.topicsByTab[tab]
  if (!topics) {
    return true
  }
  //对象存在且正在获取新数据中
  if (topics.isFetching) {
    return false
  }
  return topics.didInvalidate
}

/**
.src/reducers/topics.js

*/
const topicsByTab = (state = { }, action) => {
  switch (action.type) {
    case 'INVALIDATE_TAB':
    case 'RECEIVE_TOPICS_SUCCESS':
    case 'RECEIVE_TOPICS_FAILURE':
    case 'REQUEST_TOPICS':
      return {
        ...state,
        [action.tab]: topics(state[action.tab], action)
      }
    default:
      return state
  }
}

以上就是我们异步获取数据的主要步骤。


到这一步我们已经对项目的基本框架有了一定的了解,下面我们就开始正式开发山寨版 RubyChina~!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容