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

栏目: IOS · Android · 发布时间: 6年前

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

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

上一节:前后端路由同构

源码地址见文章末尾

代码分割

路由懒加载

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

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

当点击其它路由就会下载对应的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 打包

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

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

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

可以看到 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基本项目结构

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

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 ,客户端不支持跨域使用了jsonp, src/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 又会执行一次数据加载,这一次数据加载是多余的,看下图

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

访问 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 ,客户端少了一次数据请求。如下图

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

总结

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

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


以上所述就是小编给大家介绍的《React服务端渲染(代码分割和数据预取)》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

JavaScript: The Definitive Guide, 5th Edition

JavaScript: The Definitive Guide, 5th Edition

David Flanagan / O'Reilly Media / 2006-08-01 / USD 49.99

This Fifth Edition is completely revised and expanded to cover JavaScript as it is used in today's Web 2.0 applications. This book is both an example-driven programmer's guide and a keep-on-your-desk ......一起来看看 《JavaScript: The Definitive Guide, 5th Edition》 这本书的介绍吧!

HTML 压缩/解压工具
HTML 压缩/解压工具

在线压缩/解压 HTML 代码

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具