react代码分割

2019-07-29 10:00:23 hzxOnlineOk 阅读数 203

打包

大多数 React 应用都会使用 Webpack 或 Browserify 这类的构建工具来打包文件。打包是一个将文件引入并合并到一个单独文件的过程,最终形成一个 “bundle”。接着在页面上引入该 bundle,整个应用即可一次性加载。

示例

App文件:

// app.js
import { add } from './math.js';

console.log(add(16, 26)); // 42
// math.js
export function add(a, b) {
  return a + b;
}

打包后文件:

function add(a, b) {
  return a + b;
}

console.log(add(16, 26)); // 42

注意:

最终你的打包文件看起来会和上面的例子区别很大。

如果你正在使用 Create React AppNext.jsGatsby,或者类似的工具,你会拥有一个可以直接使用的 Webpack 配置来进行打包工作。

如果你没有使用这类工具,你就需要自己来进行配置。例如,查看 Webpack 文档上的安装入门教程

代码分割

打包是个非常棒的技术,但随着你的应用增长,你的代码包也将随之增长。尤其是在整合了体积巨大的第三方库的情况下。你需要关注你代码包中所包含的代码,以避免因体积过大而导致加载时间过长。

为了避免搞出大体积的代码包,在前期就思考该问题并对代码包进行分割是个不错的选择。代码分割是由诸如 Webpack(代码分割)和 Browserify(factor-bundle)这类打包器支持的一项技术,能够创建多个包并在运行时动态加载。

对你的应用进行代码分割能够帮助你“懒加载”当前用户所需要的内容,能够显著地提高你的应用性能。尽管并没有减少应用整体的代码体积,但你可以避免加载用户永远不需要的代码,并在初始加载的时候减少所需加载的代码量。

import()

在你的应用中引入代码分割的最佳方式是通过动态 import() 语法。

使用之前:

import { add } from './math';

console.log(add(16, 26));

使用之后:

import("./math").then(math => {
  console.log(math.add(16, 26));
});

注意:

动态 import() 语法目前只是一个 ECMAScript (JavaScript) 提案, 而不是正式的语法标准。预计在不远的将来就会被正式接受。

当 Webpack 解析到该语法时,它会自动地开始进行代码分割。如果你使用 Create React App,该功能已配置好,你能立刻使用这个特性。Next.js 也已支持该特性而无需再配置。

如果你自己配置 Webpack,你可能要阅读下 Webpack 关于代码分割的指南。你的 Webpack 配置应该类似于此

当使用 Babel 时,你要确保 Babel 能够解析动态 import 语法而不是将其进行转换。对于这一要求你需要 babel-plugin-syntax-dynamic-import 插件。

React.lazy

注意:

React.lazy 和 Suspense 技术还不支持服务端渲染。如果你想要在使用服务端渲染的应用中使用,我们推荐 Loadable Components 这个库。它有一个很棒的服务端渲染打包指南

React.lazy 函数能让你像渲染常规组件一样处理动态引入(的组件)。

使用之前:

import OtherComponent from './OtherComponent';

function MyComponent() {
  return (
    <div>
      <OtherComponent />
    </div>
  );
}

使用之后:

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <OtherComponent />
    </div>
  );
}

这个代码将会在渲染组件时,自动导入包含 OtherComponent 组件的包。

React.lazy 接受一个函数,这个函数需要动态调用 import()。它必须返回一个 Promise,该 Promise 需要 resolve 一个 defalut export 的 React 组件。

Suspense

如果在 MyComponent 渲染完成后,包含 OtherComponent 的模块还没有被加载完成,我们可以使用加载指示器为此组件做优雅降级。这里我们使用 Suspense 组件来解决。

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

fallback 属性接受任何在组件加载过程中你想展示的 React 元素。你可以将 Suspense 组件置于懒加载组件之上的任何位置。你甚至可以用一个 Suspense 组件包裹多个懒加载组件。

const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <section>
          <OtherComponent />
          <AnotherComponent />
        </section>
      </Suspense>
    </div>
  );
}

异常捕获边界(Error boundaries)

如果模块加载失败(如网络问题),它会触发一个错误。你可以通过异常捕获边界(Error boundaries)技术来处理这些情况,以显示良好的用户体验并管理恢复事宜。

import MyErrorBoundary from './MyErrorBoundary';
const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));

const MyComponent = () => (
  <div>
    <MyErrorBoundary>
      <Suspense fallback={<div>Loading...</div>}>
        <section>
          <OtherComponent />
          <AnotherComponent />
        </section>
      </Suspense>
    </MyErrorBoundary>
  </div>
);

基于路由的代码分割

决定在哪引入代码分割需要一些技巧。你需要确保选择的位置能够均匀地分割代码包而不会影响用户体验。

一个不错的选择是从路由开始。大多数网络用户习惯于页面之间能有个加载切换过程。你也可以选择重新渲染整个页面,这样您的用户就不必在渲染的同时再和页面上的其他元素进行交互。

这里是一个例子,展示如何在你的应用中使用 React.lazy 和 React Router 这类的第三方库,来配置基于路由的代码分割。

import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import React, { Suspense, lazy } from 'react';

const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Switch>
        <Route exact path="/" component={Home}/>
        <Route path="/about" component={About}/>
      </Switch>
    </Suspense>
  </Router>
);

命名导出(Named Exports)

React.lazy 目前只支持默认导出(default exports)。如果你想被引入的模块使用命名导出(named exports),你可以创建一个中间模块,来重新导出为默认模块。这能保证 tree shaking 不会出错,并且不必引入不需要的组件。

// ManyComponents.js
export const MyComponent = /* ... */;
export const MyUnusedComponent = /* ... */;
// MyComponent.js
export { MyComponent as default } from "./ManyComponents.js";
// MyApp.js
import React, { lazy } from 'react';
const MyComponent = lazy(() => import("./MyComponent.js"));
2019-06-28 09:41:28 qq_25753979 阅读数 280

随着项目功能的扩充、版本迭代,我们与Webpack捆绑起来的的项目越来越大,大到开始影响加载速度了。这时我们就该考虑如何对代码进行拆分了。

这次我们一起学习一下如何对React项目中的代码进行Code-Splitting(代码分割、代码拆分等各种叫法…)。

Code-Splitting的思路有很多,很多人选择的拆分方案是根据不同的路由进行拆分开来实现组件按需加载。存在的弊端就是这样只是根据顶级路由的区别实现了代码的拆分。

而在react官方文档中的“高级指引”部分有提到一个库—React Loadable。对于React Loadable的介绍用一句话就可以说明,route-centric code splitting is shit, component-centric splitting is cool as shit.(自行体会吧233333)。

那么让我们进入正题,说到基于路由拆分和基于组件拆分,git库的readme中给出了图解:

在这里插入图片描述
可以很直观的看出来,根据路由拆分代码的时候还有很多可以细分的地方,比如某一条路由下包含的某些需要特定操作才展现的ui组件,这种组件没有必要在父路由加载的时候就跟着加载。

Loadable的本质是一个高阶组件,他可以很容易的以react的组件为中心对代码进行分割。那么如何使用React Loadable呢?

借用官方的例子,我们分为3步进行了解,假设我们有两个组件,组件MyComponent引入并渲染组件Bar,常规操作如下:

在这里插入图片描述
下面做一下升华,当MyComponent需要渲染Bar的时候再渲染它:
在这里插入图片描述
这其实就已经开始复杂了,而且仔细一推敲,好多场景都没有考虑到,比如import失败的时候、需要做服务端渲染的时候。

这时,就可以拿出我们的Loadable来帮助我们解决这一难题了:
在这里插入图片描述
这样代码清晰明了,需要考虑的场景loadable已经帮我们完成了。

Tip:当我们使用import()配合webpack2+的时候,将会自动进行代码分割,无需额外的配置。这就意味着当import()、webpack2+和React Loadable在一起玩的时候,我们只需要尽可能的考虑到代码的拆分点即可。

和常规的拆分操作对比之后,我们来看一下Loadable为我们提供了哪些api:

Loadable
在渲染模块之前首先渲染它,他将返回一LoadableComponent(后面讲到):
在这里插入图片描述
Loadable.Map
允许您并行加载多个资源的高阶组件,其中loader选项接收一个函数对象,并且需要一个render方法:
在这里插入图片描述
Loadable和Loadable.Map的接收的参数:
loader 传入需要加载的组件
在这里插入图片描述

loading 加载中或加载失败时展示的内容,这个选项为必填项,如果什么都不想展示则传入null
在这里插入图片描述

delay 组件展示的延迟时间,此选项将传递给组件参数props.pastDelay,默认为200(单位毫秒)
在这里插入图片描述
timeout 组件加载的等待时间,此选项将传递给组件参数props.timeOut,默认关闭。
在这里插入图片描述
render 自定义已加载模块的呈现的函数,它接受两个参数:选项loader接收的对象和props。
在这里插入图片描述
webpack 选填,可以将组件变成“弱”依赖(不会将 module 引入到 bundle)中,当使用babel的时候自动执行。
在这里插入图片描述
modules 选填,由要导入模块的可选路径组成的数组。
在这里插入图片描述
LoadableComponent:
这是通过Loadable或Loadable.map返回的组件。
在这里插入图片描述

LoadableComponent.preload():
用于提前加载(预加载)LoadableComponent,下面假设有一个组件,点击按钮后展示,当鼠标划入按钮上的时候就开始预加载:

在这里插入图片描述
LoadableComponent可以接收的props:
error 加载失败,值为null时代表加载成功,使用案例:
在这里插入图片描述

retry 重新加载(重试),使用案例:

在这里插入图片描述
timeOut 布尔值,请求超时时传递给组件,使用案例:
在这里插入图片描述

postDelay 布尔值,到达时间延迟后返回给组件,使用案例:

在这里插入图片描述

以上参数汇总使用案例:

在这里插入图片描述
以上就是我为大家总结出的React Loadable的功能和用法。

总结一下React Loadable的原理是通过import()返回的promise对象实现了异步的操作。

即便如此,其实代码的拆分工作也不是那么简单,我们要确保选择拆分的位置能够均匀地分割代码包而不会影响用户体验,这也是一大难点。反观本文开头给出的对比图片可以得到些许启发,在基于路由拆分代码的基础上进行基于组件拆分是一个不错的起点。

最后大家可以在评论区探讨一些自己拆分代码的好的方案、心得或者疑惑,让我们一起探讨一下吧~

欢迎大家关注我们的公众号Web前端Talk
在这里插入图片描述

2018-10-19 09:29:02 sinat_17775997 阅读数 669

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

上一节:前后端路由同构

源码地址见文章末尾

代码分割

路由懒加载

在做代码分割的时候有很多解决方案,如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

 

 

 

当点击其它路由就会下载对应的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挂载以后获取数据,而服务端渲染就要在服务端提前加载数据,然后把数据返回给客户端,客户端获取服务端返回的数据,保证前后端数据是一致的

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

最后

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

源码


作者:code_mcx
链接:https://juejin.im/post/5bc59e995188255c352d88e1
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

2018-05-30 10:26:48 qq_35484341 阅读数 6695

众所周知,在使用webpack打包react应用时,webpack将整个应用打包成一个js文件,当用户访问首屏时,会一次性加载整个js文件,当应用的规模变得越来越庞大的时候,首屏渲染速度变慢,影响用户体验。

于是,webpack开发了代码分割的特性, 此特性能够把代码分割为不同的bundle文件,然后可以通过路由按需加载或并行加载这些文件。

代码分割可以用于获取更小的bundle,以及控制资源加载优先级,如果使用合理,会极大影响加载时间。有三种常用的代码分割方法:

1、拆分入口:使用 entry 配置手动地分割代码。

2、防止重复:使用 CommonsChunkPlugin 去重和分离chunk。

3、动态导入:通过模块的内联函数调用来分离代码。本文只讨论动态导入(dynamic imports)的方法。

动态导入

当涉及到动态代码拆分时,webpack提供了两个类似的技术。对于动态导入,第一种,也是优先选择的方式是,使用符合ECMA提案的 import() 语法。第二种,则是使用 webpack 特定的 require.ensure。本文使用第一种方式。

注意:import() 调用会在内部用到promise。如果在旧有版本浏览器中使用 import(),记得使用一个polyfill 库(例如 es6-promise 或 promise-polyfill),来 shim Promise。

下面结合react-router 4来实现react的代码分割。

在React应用中实现

React应用的代码分割需要结合路由库react-router使用,当前react-router的版本是V4,在使用react-router4进行代码分割的路上,社区已经有成熟的第三方库进行了实现,如react-loadable。在此处将介绍如何不借助第三方库实现代码分割。

此处假设你已经对react、react-router4、webpack有基本的了解,可以搭建简单的开发环境。下面是本项目的基本目录结构:

项目入口文件src/index.js:

 

src/App.js:

在App.js中,引入react-router-dom路由模块,以及路由配置文件routes.js,App组件主要负责通过路由配置遍历生成一系列路由组件。

下面是路由配置src/routes.js:

routes.js中配置了路由组件需要的参数,需要注意的是在路由参数中使用了异步组件AsyncComponent,注意这里并没有直接引入组件,而是传递一个函数参数给AsyncComponent,它将在AsyncComponent(() => import('./containers/home'))组件被创建时进行动态引入。

同时,这种传入一个函数作为参数,而非直接传入一个字符串的写法能够让webpack意识到此处需要进行代码分割。

使用import()需要使用Babel预处理器和动态import的语法插件(Syntax Dynamic Import Babel Plugin)。由于 import() 会返回一个 promise,因此它可以和ES7的async函数一起使用,使用acync函数需要安装babel-plugin-transform-runtime插件。

安装babel插件:

本项目使用的其他babel插件还有babel-core、babel-loader、babel-preset-env、babel-preset-react等,主要用于React的jsx语法编译。

下面需要编写babel配置文件.babelrc,在根目录下新建.babelrc,配置如下:

异步组件AsyncComponent

代码分割的核心部分就是实现AsyncComponent,本项目的AsyncComponent放在src/components/async-component/index.js中,代码如下:

整个模块是一个高阶组件,返回一个新的组件,传入两个参数,一个是需要动态加载组件的方法,第二个是动态加载时的占位符,占位符的默认参数为一个字符串,也可以传入一个Loading组件。

在返回的AsyncComponent组件内部,constructor中,初始化一个state为Child,值为null,并定义this.unmount =false,用于表示组件是否被卸载。

使用acync定义异步方法,componentDidMount中,使用await异步执行传入的第一个参数,用于动态加载当前路由的组件。

注意:

当调用ES6模块的import()方法(引入模块)时,必须指向模块的.default值,因为它才是promise 被处理后返回的实际的module对象。

故此处使用ES6的对象解构获取到模块的default并赋值到Child上。

然后判断组件被卸载的状态,被卸载即返回。

下面将Child设置到state上。

在render方法中,从state中获取Child,然后使用三元运算符判断Child是否存在,存在则渲染Child组件,并传入this.props,否则渲染占位符。

组件componentWillUnmount时,设置this.unmout为true。

测试

现在开始编写一些简单的业务组件用于测试,在containers中新建两个文件夹home和detail,在两个文件夹下编写index.js作为两个路由组件。代码如下:

containers/home/index.js:

containers/detail/index.js:

在根目录下package.json配置启动脚本:

然后运行npm start启动项目:

打开浏览器访问localhost:8080

查看右侧network面板,可以看到页面先加载了main.js和0.js,点击详情按钮跳转到http://localhost:8080/detail

随后加载了1.js,这样就实现了代码分割,每个路由都是动态加载的。在大型React应用中,将bundle进行细粒度的拆分,可以极大提升首屏渲染速度,提升用户体验。

2018-11-16 11:46:00 weixin_33725515 阅读数 57

代码分割想要解决的问题是:经打包工具?生成的bundle文件过大,特别是在我们引入第三方库的情况下。

在React中,我们有一下几种解决方案:

1. 动态加载

 1 // math.js
 2 export function add(a, b) {
 3   return a + b
 4 }
 5 
 6 // 未使用动态加载
 7 import { add } from './math.js'
 8 console.log(add(10, 20))
 9 
10 // 使用动态加载
11 import("./math").then(math => {
12   console.log(math.add(5, 16))
13 })

注:如果你使用create-react-app构建程序,则可以直接使用这种语法;如果你使用的是Webpack或者其他打包工具,则需要你按照相应参考文档进行相应配置。使用Babel时,需要babel-plugin-syntax-dynamic-import插件的帮助。

 

2. React.lazy

React.lazy方法可以让我们动态加载组件

1 // 未使用React.lazy
2 import OtherComponent from './OtherComponent'
3 
4 // 使用React.lazy动态引用组件
5 const OtherComponent = React.lazy(() => import('./OtherComponent'))

配合使用Suspense组件(被它包括的组件或元素如果尚未加载,可以先渲染一些加载提示的内容)

 1 const OtherComponent = React.lazy(() => import('./OtherComponent))
 2 
 3 // 使用Suspense
 4 function MyComponent() {
 5   return (
 6     <div>
 7       <Suspense fallback={<div>Loading . . .</div>}>
 8         <OtherComponent />
 9       </Suspense>
10     </div>
11   )
12 }

注:React.lazy和Suspense在SSR中目前还不支持,如果需要在SSR中使用,则需要React Loadable插件的支持。

 

在route过程中使用React.lazy

页面切换和加载通常都需要些时间,所以这里适合使用React.lazy和Suspense的。

 1 import React, { Suspense, lazy } from 'react
 2 import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
 3 
 4 const Home = lazy(() => import('./routes/Home'))
 5 const About = lazy(() => import('./routes/About'))
 6 
 7 const App = () => (
 8   <Router>
 9     <Suspense fallback={<div>Loading . . . </div>}>
10       <Switch>
11         <Route exact path="/" component={Home} />
12         <Route path="/about" component={About} />
13       </Switch>
14     </Suspense>
15   </Router>
16 )

 

详细可参考原文地址:https://reactjs.org/docs/code-splitting.html

转载于:https://www.cnblogs.com/fanqshun/p/fanshun.html