React服务端渲染(代码分割和数据预取)

6,079 阅读7分钟

前几节已经把项目基本骨架和路由搭建好了,但作为实际项目开发,这些还是不够的。随着业务的增大,应用层序代码也随之增大,如果把所有代码都打包到一个文件里面,首次加载会导致时间相对变长,增加流量(对移动端来说)。应用程序包含很多页面,某一时刻用户只会访问一个页面,用户未访问的页面代码在访问之前不应该被加载,只有在用户访问时才应改加载页面所需资源。之前搭建好的项目暂不涉及数据交互,业务最核心的东西就是数据,本节将会介绍基于路由的代码分割、数据交互和同步

上一节:前后端路由同构

源码地址见文章末尾

本节部分代码已进行重写,详情请戳这里

代码分割

路由懒加载

在做代码分割的时候有很多解决方案,如react-loadablereact-async-componentloadable-components,三者都支持Code Splitting和懒加载,而且都支持服务端渲染。react-loadable和react-async-component在做服务端渲染时,步骤十分繁琐,loadable-components提供了简单的操作来支持服务端渲染,这里选用loadable-components

如果你使用webpack4,loadable-components请使用新的版本这里是使用新版本重写的完整例子

安装loadable-components

npm install loadable-components

将路由配置中的组件改成动态导入

src/router/index.js

import Loadable from "loadable-components";

const router = [
  {
    path: "/bar",
    component: Loadable(() => import("../views/Bar"))
  },
  {
    path: "/baz",
    component: Loadable(() => import("../views/Baz"))
  },
  {
    path: "/foo",
    component: Loadable(() => import("../views/Foo"))
  },
  {
    path: "/top-list",
    component: Loadable(() => import("../views/TopList")),
    exact: true
  }
];

import()动态导入是从Webpack2开始支持的语法,本质上是使用了promise,如果要在老的浏览器中运行需要es6-promisepromise-polyfill

为了解析import()语法,需要配置babel插件syntax-dynamic-import,然后单页面应用中就可以工作了。这里使用loadable-components来做服务端渲染,babel配置如下

"plugins": [
  "loadable-components/babel"
]

注意:这里使用babel6.x的版本

在客户端使用loadComponents方法加载组件然后进行挂载。客户端入口修改如下

src/entry-client.js

import { loadComponents } from "loadable-components";
import App from "./App";

// 开始渲染之前加载所需的组件
loadComponents().then(() => {
  ReactDOM.hydrate(<App />, document.getElementById("app"));
});

服务端调用getLoadableState()然后将状态插入到html片段中

src/server.js

const { getLoadableState } = require("loadable-components/server");

...

let component = createApp(context, req.url);
// 提取可加载状态
getLoadableState(component).then(loadableState => {
  let html = ReactDOMServer.renderToString(component);

  if (context.url) {  // 当发生重定向时,静态路由会设置url
    res.redirect(context.url);
    return;
  }

  if (!context.status) {  // 无status字段表示路由匹配成功
    // 获取组件内的head对象,必须在组件renderToString后获取
    let head = component.type.head.renderStatic();
    // 替换注释节点为渲染后的html字符串
    let htmlStr = template
    .replace(/<title>.*<\/title>/, `${head.title.toString()}`)
    .replace("<!--react-ssr-head-->", `${head.meta.toString()}\n${head.link.toString()}`)
    .replace("<!--react-ssr-outlet-->", `<div id='app'>${html}</div>\n${loadableState.getScriptTag()}`);
    // 将渲染后的html字符串发送给客户端
    res.send(htmlStr);
  } else {
    res.status(context.status).send("error code:" + context.status);
  }
});

调用getLoadableState()传入根组件,等待状态加载完成后进行渲染并调用loadableState.getScriptTag()把返回的脚本插入到html模板中

服务端渲染需要modules选项

const AsyncComponent = loadable(() => import('./MyComponent'), {
  modules: ['./MyComponent'],
})

这个选项不需要手动编写,使用loadable-components/babel插件即可。import()语法在node中并不支持,所以服务端还需要配置一个插件dynamic-import-node

安装dynamic-import-node

npm install babel-plugin-dynamic-import-node --save-dev

客户端不需要这个插件,接下来修改webpack配置,客户端使用.babelrc文件,服务端通过loader的options选项指定babel配置

webpack.config.base.js中的以下配置移到webpack.config.client.js

{
  test: /\.(js|jsx)$/,
  loader: ["babel-loader", "eslint-loader"],
  exclude: /node_modules/
}

webpack.config.client.js

rules: [
  {
    test: /\.(js|jsx)$/,
    loader: ["babel-loader", "eslint-loader"],
    exclude: /node_modules/
  },
  ...util.styleLoaders({
    sourceMap: isProd ? true : false,
    usePostCSS: true,
    extract: isProd ? true : false
  })
]

服务端打包配置修改如下

webpack.config.server.js

rules: [
  {
    test: /\.(js|jsx)$/,
    use: [
      {
        loader: "babel-loader",
        options: {
          babelrc: false,
          presets: [
            "react",
            [
              "env",
              { "targets": { "node": "current" } }
            ]
          ],
          "plugins": [ "dynamic-import-node", "loadable-components/babel" ]
        }
      },
      { loader: "eslint-loader" }
    ],
    exclude: /node_modules/
  },
  ...util.styleLoaders({
    sourceMap: true,
    usePostCSS: true,
    extract: true
  })
]

运行npm run dev,打开浏览器输入http://localhost:3000,在network面板中可以看到先下载app.b73b88f66d1cc5797747.js,然后下载当前bar页面所需的js(下图中的3.b73b88f66d1cc5797747.js

当点击其它路由就会下载对应的js然后执行

Webpack打包优化

实际使用中,随着应用的迭代更新,打包文件后的文件会越来越大,其中主要脚本文件app.xxx.js包含了第三方模块和业务代码,业务代码会随时变化,而第三方模块在一定的时间内基本不变,除非你对目前使用的框架或库进行升级。app.xxx.js中的xxx使用chunkhash命名,chunkhash表示chunk内容的hash,第三方模块的chunk不会变化,我们将其分离出来,便于浏览器缓存

关于output.filename更多信息请戳这里

为了提取第三方模块,需要使用webpack自带的CommonsChunkPlugin插件,同时为了更好的缓存我们将webpack引导模块提取到一个单独的文件中

webpack.config.client.js

plugins: [
  ...
  new webpack.optimize.CommonsChunkPlugin({
    name: "vendor",
    minChunks: function(module) {
      // 阻止.css文件资源打包到vendor chunk中
      if(module.resource && /\.css$/.test(module.resource)) {
        return false;
      }
      // node_modules目录下的模块打包到vendor chunk中
      return module.context && module.context.includes("node_modules");
    }
  }),
  // 分离webpack引导模块
  new webpack.optimize.CommonsChunkPlugin({
    name: "manifest",
    minChunks: Infinity
  })
]

通过以上配置会打包出包含第三方模块的vendor.xxx.jsmanifest.xxx.js

注意:这里使用webpack3.x的版本,CommonsChunkPlugin在webpack4中已移除。webpack4请使用SplitChunksPlugin

项目中在生产模式下才使用了chunkhash,接下来运行npm run build打包

修改src/App.jsx中的代码,再进行打包

可以看到vender.xxx.js文件名没有产生变化,app.xxx.js变化了,4个异步组件打包后的文件名没有变化,mainfest.xxx.js发生了变化

数据预取和同步

服务端渲染需要把页面内容由服务端返回给客户端,如果某些内容是通过调用接口请求获取的,那么就要提前加载数据然后渲染,再调用ReactDOMServer.renderToString()渲染出完整的页面,客户端渲染出来的html内容要和服务端返回的html内容一致,这就需要保证客户端的数据和服务端的数据是一致的

数据管理这里选用Redux,Redux在做服务端渲染时,每次请求都要创建一个新的Store,然后初始化state返回给客户端,客户端拿到这个state创建一个新的Store

Redux服务端渲染示例

加入Redux

安装相关依赖

npm install redux redux-thunk react-redux

首先搭建Redux基本项目结构

actionTypes.js

export const SET_TOP_LIST = "SET_TOP_LIST";

export const SET_TOP_DETAIL = "SET_TOP_DETAIL";

actions.js

import { SET_TOP_LIST, SET_TOP_DETAIL } from "./actionTypes";

export function setTopList(topList) {
  return { type: SET_TOP_LIST, topList };
}

export function setTopDetail(topDetail) {
  return { type: SET_TOP_DETAIL, topDetail };
}

reducers.js

import { combineReducers } from "redux";
import * as ActionTypes from "./actionTypes";

const initialState = {
  topList: [],
  topDetail: {}
}

function topList(topList = initialState.topList, action) {
  switch (action.type) {
    case ActionTypes.SET_TOP_LIST:
      return action.topList;
    default:
      return topList;
  }
}

function topDetail(topDetail = initialState.topDetail, action) {
  switch (action.type) {
    case ActionTypes.SET_TOP_DETAIL:
      return action.topDetail;
    default:
      return topDetail;
  }
}

const reducer = combineReducers({
  topList,
  topDetail
});

export default reducer;

store.js

import { createStore, applyMiddleware } from "redux";
import thunkMiddleware from "redux-thunk";
import reducer from "./reducers";

// 导出函数,以便客户端和服务端根据初始state创建store
export default (store) => {
  return createStore(
    reducer,
    store,
    applyMiddleware(thunkMiddleware) // 允许store能dispatch函数
  );
}

这里请求数据需要使用异步Action,默认Store只能dispatch对象,使用redux-thunk中间件就可以dispatch函数了

接下来在action.js中编写异步Action创建函数

import { getTopList, getTopDetail } from "../api";

...

export function fatchTopList() {
  // dispatch由thunkMiddleware传入
  return (dispatch, getState) => {
    return getTopList().then(response => {
      const data = response.data;
      if (data.code === 0) {
        // 获取数据后dispatch,存入store
        dispatch(setTopList(data.data.topList));
      }
    });
  }
}

export function fetchTopDetail(id) {
  return (dispatch, getState) => {
    return getTopDetail(id).then(response => {
      const data = response.data;
      if (data.code === 0) {
        const topinfo = data.topinfo;
        const top = {
          id: topinfo.topID,
          name: topinfo.ListName,
          pic: topinfo.pic,
          info: topinfo.info
        };
        dispatch(setTopDetail(top));
      }
    });
  }
}

上述代码中Action创建函数返回一个带有异步请求的函数,这个函数中可以dispatch其它action。在这里这个函数中调用接口请求,请求完成后把数据通过dispatch存入到state,然后返回Promise,以便异步请求完成后做其他处理。在异步请求中需要同时支持服务端和客户端,你可以使用axios或者在浏览器端使用fetch API,node中使用node-fetch

在这里使用了QQ音乐的接口作为数据来源,服务端使用axios,客户端不支持跨域使用了jsonpsrc/api/index.js中的代码看起来像下面这样

import axios from "axios";
import jsonp from "jsonp";

const topListUrl = "https://c.y.qq.com/v8/fcg-bin/fcg_myqq_toplist.fcg";

if (process.env.REACT_ENV === "server") {
  return axios.get(topListUrl + "?format=json");
} else {
  // 客户端使用jsonp请求
  return new Promise((resolve, reject) => {
    jsonp(topListUrl + "?format=jsonp", {
      param: "jsonpCallback",
      prefix: "callback"
    }, (err, data) => {
      if (!err) {
        const response = {};
        response.data = data;
        resolve(response);
      } else {
        reject(err);
      }
    });
  });
}

如果你想了解更多QQ音乐接口请戳这里

让React展示组件访问state的方法就是使用react-redux模块的connect方法连接到Store,编写容器组件TopList

src/containers/TopList.jsx

import { connect } from "react-redux"
import TopList from "../views/TopList";

const mapStateToProps = (state) => ({
    topList: state.topList
});

export default connect(mapStateToProps)(TopList);

src/router/index.js中把有原来的import("../views/TopList"))改成import("../containers/TopList"))

{
  path: "/top-list",
  component: Loadable(() => import("../containers/TopList")),
  exact: true
}

在展示组件TopList中通过props访问state

class TopList extends React.Component {
  render() {
    const { topList } = this.props;
    return (
      <div>
        ...
        <ul className="list-wrapper">
          {
            topList.map(item => {
              return <li className="list-item" key={item.id}>
                {item.title}
              </li>;
            })
          }
        </ul>
      </div>
    )
  }
}

接下来在服务端入口文件entry-server.js中使用Provider包裹StaticRouter,并导出createStore函数

src/entry-server.js

import createStore from "./redux/store";
...

const createApp = (context, url, store) => {
  const App = () => {
    return (
      <Provider store={store}>
        <StaticRouter context={context} location={url}>
          <Root setHead={(head) => App.head = head}/>  
        </StaticRouter>
      </Provider>
    )
  }
  return <App />;
}

module.exports = {
  createApp,
  createStore
};

server.js中获取createStore函数创建一个没有数据的Store

let store = createStore({});

// 存放组件内部路由相关属性,包括状态码,地址信息,重定向的url
let context = {};
let component = createApp(context, req.url, store);

客户端同样使用Provider包裹,创建一个没有数据的Store并传入

src/App.jsx

import createStore from "./redux/store";
...

let App;
if (process.env.REACT_ENV === "server") {
  // 服务端导出Root组件
  App = Root;
} else {
  const Provider = require("react-redux").Provider;
  const store = createStore({});
  App = () => {
    return (
      <Provider store={store}>
        <Router>
          <Root />
        </Router>
      </Provider>
    );
  };
}
export default App;

预取数据

获取数据有两种做法第一种是把加载数据的方法放到路由上,就像下面这样

const routes = [
  {
    path: "/",
    component: Root,
    loadData: () => getSomeData()
  }
  ...
];

另一种做法就是把加载数据的方法放到对应的组件上定义成静态方法,这种做法更直观

本例采用第二种做法在TopList组件中定义一个静态方法asyncData,传入store用来dispatch异步Action,这里定义成静态方法是因为组件渲染之前还没有被实例化无法访问this

static asyncData(store) {
  return store.dispatch(fatchTopList());
}

fatchTopList返回的函数被redux-thunk中间件调用,redux-thunk中间件会把调用函数的返回值当作dispatch方法的返回值传递

现在需要在请求的时候获取路由组件的asyncData方法并调用,react-router在react-router-config模块中为我们提供了matchRoutes方法,根据路由配置来匹配路由

为了在服务端使用路由匹配,路由配置要从entry-server.js中导出

src/entry-server.js

import { router } from "./router";
...

module.exports = {
  createApp,
  createStore,
  router
};

server.js中获取router路由配置,当所有异步组件加载完成后调用matchRoutes()进行路由匹配,调用所有匹配路由的asyncData方法后进行渲染

let promises;
getLoadableState(component).then(loadableState => {
  // 匹配路由
  let matchs = matchRoutes(router, req.path);
  promises = matchs.map(({ route, match }) => {
    const asyncData = route.component.Component.asyncData;
    // match.params获取匹配的路由参数
    return asyncData ? asyncData(store, Object.assign(match.params, req.query)) : Promise.resolve(null);
  });

  // resolve所有asyncData
  Promise.all(promises).then(() => {
    // 异步数据请求完成后进行服务端render
    handleRender();
  }).catch(error => {
    console.log(error);
    res.status(500).send("Internal server error");
  });
  ...
}

上述代码中使用route.component获取的是loadable-components返回的异步组件,route.component.Component才是真正的路由组件,必须在调用getLoadableState()后才能获取。如果组件存在asyncData方法就放到promises数组中,不存在就返回一个resolve好的Promise,然后将所有Promise resolve。有些url类似/path/:idmatch.params就是用来获取该url中的:id表示的参数,如果某些参数以?形似传递,可以通过req.query获取,合并到match.params中,传给组件处理

注意:matchRoutes中第二个参数请用req.pathreq.path获取的url中不包含query参数,这样才能正确匹配

同步数据

服务端预先请求数据并存入Store中,客户端根据这个state初始化一个Store实例,只要在服务端加载数据后调用getState()获取到state并返回给客户端,客户端取到这个这个state即可

server.js中获取初始的state,通过window.__INITIAL_STATE__保存在客户端

src/server.js

let preloadedState = {};
...

// resolve所有asyncData
Promise.all(promises).then(() => {
  // 获取预加载的state,供客户端初始化
  preloadedState = store.getState();
  // 异步数据请求完成后进行服务端render
  handleRender();
}).catch(error => {
  console.log(error);
  res.status(500).send("Internal server error");
});

...
let htmlStr = template
.replace(/<title>.*<\/title>/, `${head.title.toString()}`)
.replace("<!--react-ssr-head-->", `${head.meta.toString()}\n${head.link.toString()}
  <script type="text/javascript">
    window.__INITIAL_STATE__ = ${JSON.stringify(preloadedState)}
  </script>
`)
.replace("<!--react-ssr-outlet-->", `<div id='app'>${html}</div>\n${loadableState.getScriptTag()}`);

App.jsx中获取window.__INITIAL_STATE__

// 获取服务端初始化的state,创建store
const initialState = window.__INITIAL_STATE__;
const store = createStore(initialState);

此时客户端和服务端数据可以同步了

客户端数据获取

对于客户端路由跳转,是在浏览器上完成的,这个时候客户端也需要请求数据

TopList组件的componentDidMount生命周期函数中dispatch异步Action创建函数fatchTopList的返回值

componentDidMount() {
  this.props.dispatch(fatchTopList());
}

这里组件已经被实例化,所以可以通过this访问Store的dispatch,同时这个函数只会在客户端执行

你可能会想要在componentWillMountdispatch异步Action,官方已经对生命周期函数做了更改(请戳这里),16.x版本中启用对componentWillMountcomponentWillReceivePropscomponentWillUpdate过期警告,17版本中会移除这三个周期函数,推荐在componentDidMount中获取数据(请戳这里

有一种情况如果服务端提前加载了数据,当客户端挂载DOM后执行了componentDidMount又会执行一次数据加载,这一次数据加载是多余的,看下图

访问http://localhost:3000/top-list,服务端已经预取到数据并把结果HTML字符串渲染好了,红色方框中是客户端DOM挂载以后发送的请求。为了避免这种情况,新增一个state叫clientShouldLoad默认值为true,表示客户端是否加载数据,为clientShouldLoad编写好actionType、action创建函数和reducer函数

actionTypes.js

export const SET_CLIENT_LOAD = "SET_CLIENT_LOAD";

actions.js

import { SET_CLIENT_LOAD, SET_TOP_LIST, SET_TOP_DETAIL } from "./actionTypes";

export function setClientLoad(clientShouldLoad) {
  return { type: SET_CLIENT_LOAD, clientShouldLoad };
}

reducers.js

const initialState = {
  clientShouldLoad: true,
  topList: [],
  topDetail: {}
}

function clientShouldLoad(clientShouldLoad = initialState.clientShouldLoad, action) {
  switch (action.type) {
    case ActionTypes.SET_CLIENT_LOAD:
      return action.clientShouldLoad;
    default:
      return clientShouldLoad;
  }
}
...

const reducer = combineReducers({
  clientShouldLoad,
  topList,
  topDetail
});

容器组件TopList中对clientShouldLoad进行映射

src/containers/TopList.jsx

const mapStateToProps = (state) => ({
    clientShouldLoad: state.clientShouldLoad,
    topList: state.topList
});

当服务端预取数据后修改clientShouldLoadfalse,客户端挂载后判断clientShouldLoad是否为true,如果为true就获取数据,为false就将clientShouldLoad改为true,以便客户端跳转到其它路由后获取的clientShouldLoadtrue,进行数据获取

在异步Action创建函数中,当前运行的是服务端数据,请求完成后dispatch

actions.js

export function fatchTopList() {
  // dispatch由thunkMiddleware传入
  return (dispatch, getState) => {
    return getTopList().then(response => {
      const data = response.data;
      if (data.code === 0) {
        // 获取数据后dispatch,存入store
        dispatch(setTopList(data.data.topList));
      }
      if (process.env.REACT_ENV === "server") {
        dispatch(setClientLoad(false));
      }
    });
  }
}

TopList组件中增加判断

TopList.jsx

componentDidMount() {
  // 判断是否需要加载数据
  if (this.props.clientShouldLoad === true) {
    this.props.dispatch(fatchTopList());
  } else {
    // 客户端执行后,将客户端是否加载数据设置为true
    this.props.dispatch(setClientLoad(true));
  }
}

此时访问http://localhost:3000/top-list,客户端少了一次数据请求。如下图

总结

本节利用webpack动态导入的特性对路由进行懒加载,以减少打包后的文件大小,做到按需加载,利用webpack自带的CommonsChunkPlugin插件分离第三方模块,让客户端更好的缓存。一般的客户端都是在DOM挂载以后获取数据,而服务端渲染就要在服务端提前加载数据,然后把数据返回给客户端,客户端获取服务端返回的数据,保证前后端数据是一致的

搭建服务端渲染是一个非常繁琐而又困难的过程,一篇文章是介绍不完实际开发所需要的点,本系列文章从起步再到接近实际项目介绍了如何搭建服务端渲染,其中涉及的技术点非常多。对于服务端渲染官方也没有一套完整的案例,因此做法也不是唯一的

最后

服务端渲染涉及到了后端领域,实际项目中除了客户端优化外,还需要服务端做相应的优化。如果你在生产中使用服务端渲染,用户量大时需要做服务器端负载,选择明智的缓存策略

源码