代码分片

默认情况下,当在项目根路径下执行 npm run build 时,create-react-app 内部使用 webpack 将 src/ 路径下的所有代码打包成一个 JS 文件和一个 CSS 文件。命令执行完成后,控制台有类似如下输出信息(两个文件名中的哈希值部分可能会与这里的输出有所不同):

$ react-scripts build

当项目代码量不多时,把所有代码打包到一个文件的做法并不会有什么影响。但是,对于一个大型应用,如果还把所有的代码都打包到一个文件中,显然就不合适了。试想,当用户访问登录页面时,浏览器加载的 JS 文件还包含其他页面的代码,这会延长网页的加载时间,给用户带来不好的体验。理想情况下,当用户访问一个页面时,该页面应该只加载自己使用到的代码。解决这个问题的方案就是代码分片,将 JS 代码分片打包到多个文件中,然后在访问页面时按需加载。

create-react-app 支持通过动态 import() 的方式实现代码分片。import() 接收一个模块的路径作为参数,然后返回一个 Promise 对象,Promise 对象的值就是待导入的模块对象。例如:

// moduleA.js
const moduleA = 'Hello';
export { moduleA };

// App.js
import React, { Component } from 'react';

class App extends Component {
    handleClick = () => {
        import('./moduleA').then(({ moduleA }) => {
            // 使用 moduleA
        }).catch(err => {
            // 处理错误
        });
    };

    render() {
        return (
            <div>
                <button onClick={this.handleClick}>加载moduleA</button>
            </div>
        )
    }
}

export default App;

上面的代码会将 moduleA.js 和它所依赖的其他模块单独打包到一个 chunk 文件中,只有当用户点击了加载按钮,才开始加载这个 chunk 文件。

当项目中使用 React Router 时,一般会根据路由信息将项目代码分片,每个路由依赖的代码单独打包成一个 chunk 文件。我们创建一个函数统一处理这个逻辑:

import React, {Component} from "react";

// importComponent 是使用了 import() 的函数
export default function asyncComponent(importComponent) {
    class AsyncComponent extends Component {
        constructor(props) {
            super(props);
            this.state = {
                component: null  // 动态加载的组件
            };
        }

        componentDidMount() {
            importComponent().then((mod) => {
                this.setState({
                    // 同时兼容ES6和CommonJS的模块
                    component: mod.default ? mod.default : mod
                });
            });
        }

        render() {
            // 渲染动态加载的组件
            const C = this.state.component;
            return C ? <C {...this.props} /> : null;
        }
    }

    return AsyncComponent;
}

asyncComponent 接收一个函数参数 importComponent,importComponent 内通过 import() 语法动态导入模块。在 AsyncComponent 被挂载后,importComponent 就会被调用,进而触发动态导入模块的动作。

下面我们利用 asyncComponent 改造 BBS 项目,使其支持按照路由进行代码分片。本节项目源代码的目录为 /chapter-07/bbs-router-code-splitting。在 App.js 中,删除使用 import 静态导入的 Login 和 Home 组件,改为使用 asyncComponent 动态导入,代码如下:

import React, {Component} from "react";
import {BrowserRouter as Router, Route, Switch} from "react-router-dom";
import asyncComponent from "./AsyncComponent";
// 通过 asyncComponent 导入组件,创建代码分片点
const AsyncHome = asyncComponent(() => import("./components/Home"));
const AsyncLogin = asyncComponent(() => import("./components/Login"));

class App extends Component {
    render() {
        return (
            <Router>
                <Switch>
                    <Route exact path="/" component={AsyncHome}/>
                    <Route path="/login" component={AsyncLogin}/>
                    <Route path="/posts" component={AsyncHome}/>
                </Switch>
            </Router>
        );
    }
}

export default App;

这样,只有当路由匹配时,对应的组件才会被导入,实现按需加载的效果。同样,Home.js 中使用的 Route 也进行相同改造,关键代码如下:

// Home.js
const AsyncPost = asyncComponent(() => import("./Post"));
const AsyncPostList = asyncComponent(() => import("./PostList"));

class Home extends Component {
    /** 省略其余代码 **/

    render() {
        const {match, location} = this.props;
        const {userId, username} = this.state;
        return (
            <div>
                <Header
                    username={username}
                    onLogout={this.handleLogout}
                    location={location}
                />
                <Route
                    path={match.url}
                    exact
                    render={props => <AsyncPostList userId={userId} {...props} />}
                />
                <Route
                    path={`${match.url}/:id`}
                    render={props => <AsyncPost userId={userId} {...props} />}
                />
            </div>
        );
    }
}

看到这里,有些读者可能会有这样的疑问,为什么 asyncComponent 不直接接收一个代表组件路径的字符串作为参数,然后在 AsyncComponent 组件内部使用 import() 动态导入该组件呢?这种情况下,实现代码如下:

export default function asyncComponent(importComponent) {
    class AsyncComponent extends Component {
        /** 省略其余代码 **/

        componentDidMount() {
            importComponent().then((mod) => {
                this.setState({
                    // 同时兼容ES6和CommonJS的模块
                    component: mod.default ? mod.default : mod
                });
            });
        }
    }

    return AsyncComponent;
}

// 使用 asyncComponent
const AsyncHome = asyncComponent("./components/Home");

如上修改后,重新编译打包,代码并没有被成功分片,控制台上还会有这样一句警告信息:Critical dependency: the request of a dependency is an expression。这是因为在使用 import() 时,必须显式地声明要导入的组件路径,webpack 在打包时,会根据这些显式的声明拆分代码,否则,webpack 无法获得足够的关于拆分代码的信息。

现在对改造后的项目再次执行 npm run build,将会生成 1 个 main.js 文件和 4 个 chunk.js 文件。每一个 import() 语法都会打包出一个 chunk 文件,项目中共使用了 4 次 import(),因此最终有 4 个 chunk.js 文件。控制台有类似如下输出信息:

$ react-scripts build

这里还有一个需要注意的地方,打包后没有单独的 CSS 文件了。这是因为 CSS 样式被打包到各个 chunk 文件中,当 chunk 文件被加载执行时,会动态地把 CSS 样式插入页面中。如果希望把 chunk 中的 CSS 打包到一个单独的文件中,就需要修改 webpack 使用的 ExtractTextPlugin 插件的配置,但 create-react-app 并没有直接把 webpack 的配置文件暴露给用户,为了修改相应配置,需要将 create-react-app 管理的配置文件 “弹射” 出来,在项目根路径下执行:

npm run eject

项目中会多出两个文件夹:config 和 scripts,scripts 中包含项目启动、编译和测试的脚本,config 中包含项目使用的配置文件,webpack 的配置文件就在这个路径下,如图7-4所示。

image 2024 04 24 13 26 57 306
Figure 1. 图7-4

打包 webpack.config.prod.js,找到配置 ExtractTextPlugin 的地方,添加 allChunks: true 这项配置:

new ExtractTextPlugin({
    filename: cssFilename,
    allChunks: true,  // 新加配置项
}),

然后重新编译项目,各个 chunk 文件使用的 CSS 样式又会统一打包到 main.css 中。

npm run eject 是一个不可逆操作,一旦将配置 “弹射” 出,就不能再回到之前的状态,配置的维护和修改工作将全权交给用户。不过,create-react-app 官方已经计划在 2.0 版本中添加 allChunks: true 这一配置项,届时将不再需要通过 “弹射” 的方式添加这个配置。