React-CRA 多页面配置(npm run eject)

更新时间:2019-01-05
版本信息:CRA v2.1.1 + Webpack v4.19.1

一、create-react-app 多页面配置

为什么要进行多页面配置

在使用 React 进行开发的过程中,我们通常会使用 create-react-app 脚手架命令来搭建项目,避免要自己配置 webpack,提高我们的开发效率。但是使用 create-react-app 搭建的项目是单页面应用,如果我们是做中后台管理页面或 SPA,这样是满足要求的,但如果项目有多入口的需求,就需要我们进行一些配置方面的修改。

一般有以下两种方式将脚手架搭建的项目改造为多页面入口编译:

  1. 执行 npm run eject 命令,暴露配置文件,进行自定义配置。
  2. 使用 react-app-rewired 修改脚手架配置,在项目中安装 react-app-rewired 后,可以通过创建一个 config-overrides.js 文件来对 webpack 配置进行扩展。请参见:React-CRA 多页面配置(react-app-rewired)

本文对第 1 种方法给出具体配置方案。

webpack 基础

本文对 React 多页面应用配置的探讨是基于使用 create-react-app 脚手架命令构建的项目,并不是 webpack 多页面配置教程。但上述两种方案都应该具有一定的 webpack 的基础,实际上当你决定要改造或增强项目的构建打包配置的时候,你应该先对 webpack 进行一定的了解。

本项目使用的是 webpack v4.19.1 版本,对于 webpack v3.x 版本并不完全适用,主要区别在于 optimize.splitChunksmini-css-extract-plugin 的使用,这里附上 webpack v4.0.0 更新日志 以及 webpack4升级完全指南

方案说明

通过执行 npm run eject 命令暴露脚手架项目的配置文件,然后根据项目需要进行自定义配置,从而实现扩展 webpack 配置,增加对 less 文件的支持、增加 antd 组件的按需加载、处理 html 文档中的图片路径问题等,甚至可以将单页面入口编译修改为多页面入口编译的方式。

当然,我们也可以通过使用 react-app-rewired 在不暴露配置文件的情况下达到扩展项目配置的目的,原则上通过执行 npm run eject 命令可以实现的配置改造,通过使用 react-app-rewired 也同样可以实现,包括多页面配置的改造。我们在上文中已经给出了使用 react-app-rewired 进行多页面配置的方案:React-CRA 多页面配置(react-app-rewired)

但是,我们并不建议使用 react-app-rewired 这种方式进行多页面入口编译的配置,如果只是对脚手架项目进行配置扩展,比如增加 less 文件的支持或增加 antd 组件的按需加载,这种不暴露配置文件的方式是比较合适的,但如果是需要进行多页面配置,最好还是使用 npm run eject 暴露配置文件的方式。

主要原因是,通过 react-app-rewired 来实现多页面配置,是需要对脚手架原来的配置具有一定了解的,相较于 npm run eject 暴露配置文件的方式来说,这种方式是不太具有透明度的,后面维护的难度较大。实际上,React-CRA 多页面配置(react-app-rewired)方案本身就是基于 npm run eject 这种方案来完成的。

本文方案主要实现了两个方面的功能,一是将项目改造成多页面入口编译,二是在脚手架项目原有基础上扩展常用配置,我们的测试方案包含 index.htmladmin.html 两个页面(测试多页面打包),是一个使用了 Ant Design、Redux、Less、echarts-for-react(数据可视化)、react-intl(多语言)、react-lazyload(延迟加载)等 npm 包的多页面项目,也对这些主要功能实现过程中出现的错误在 五、错误排查 章节中进行了罗列,比如 Redux 版本问题、proxy 本地代理设置方法等。

版本的变动

使用 CRA 脚手架命令生成的项目免去了我们自己配置 webpack 的麻烦,其内置的各种配置所需要的插件和依赖包的版本都是确定的,是经过了检验的成熟配置,不会因为其中某个依赖包版本的问题造成构建出错。但当我们决定要自己动手配置 webpack 的时候,就意味着我们要自己根据需要安装一些 plugin 或 npm 依赖包,而这些插件和依赖包的版本可能不适用于当前 CRA 的版本,从而造成构建过程中出现一些不可预期的错误。

因此,我们需要查看 CRA 脚手架项目的 package.json 文件,对于其中已经列出的 dependencies 依赖包,我们不应该改变这些依赖包的版本,而对于未列出的 npm 包,我们在使用的过程中需要逐个验证,不要一次性安装很多个 npm 包,否则执行构建命令的时候如果出错就会很难排查,最好是根据我们需要的功能逐个的安装相应的 npm 包,确定没有问题,再进行下一个功能的扩展。

正是由于版本的变动会对配置产生很大的影响,因此当我们确定了一个配置方案之后,不要再轻易去改动其中涉及到的 npm 包的版本,通过 package-lock.json 文件锁定版本,防止配置方案错乱。

在本文编辑的时候(2019-01-05),CRA 的最新版本为 v2.1.2,从这个版本的脚手架项目开始,其配置文件已经发生了本质的改变,在这之前的版本中,其配置文件包含 webpack.config.dev.js(开发环境)和 webpack.config.prod.js(生产环境)两个配置文件,但最新版本的脚手架项目中配置文件只有 webpack.config.js 一个文件。从这个版本开始,react-app-rewired v1.6.2 已经无法使用,具体信息可以参见 CRA >=2.1.2 breaking issue 2.1.2,至少在本文编辑的时候(2019.01.05),是无法适用于 CRA >=2.1.2 版本的。

前面我们已经提到,本文配置的多页面入口编译方案是两种方案中的其中一种,两种方案是基于同一版本的 CRA 项目改造的,从而可以相互印证。因此本方案是基于最后一个可以通用的 CRA v2.1.1 版本来做的,方案的 package.json 文件会附在文末。

2019-01-11 补充:上面提到的版本基本都是指 package.json 文件中列出的依赖包的版本,但是严格来讲还应包含一些构建配置中使用的 node.js 工具包,比如 globbydir-glob等,这些工具包的版本更新也有可能会造成构建出错。这种情况的出现往往是无法预期的,它们造成的影响一般比较广泛,而不仅仅是出现在我们方案配置的过程中,这种错误基本上都会在 Github 上有相应的 issue 以及解决方法或修复措施,本文中也列出了遇到的一个这种类型的错误,详见 五、错误排查 章节中的内容。

二、准备工作

关于 Nodejs 及 Webpack 基础

  1. 了解 Node.js 相关工具模块,我们修改的配置文件都是在 node 环境中执行的,其中涉及到一些 node 模块和 npm 上的一些包,比如 path 路径模块globby (增强版的 glob)等。这些模块和库可以帮助我们处理文件路径方面的问题,在开始之前,可以先粗略了解一下它们的用法。
  2. 了解 webpack 入口(entry)概念HtmlWebpackPlugin 插件,这有助于理解接下来的对页面入口配置的修改。如前所述,如果要进行多页面的配置,应该已经对于这些有了一定的了解了。

如何创建 CRA 脚手架项目

我们的配置方案是基于 CRA v2.1.1 脚手架项目进行改造的,因此首先我们要先创建一个 CRA 项目,详见官方文档:Create React App

但是这样创建出来的项目默认是最新版本的 CRA 项目,我们在上文中已经说明,我们的配置方案是要求特定版本的 CRA 项目的,那么如何创建特定的 CRA v2.1.1 版本项目?

CRA 项目版本的核心其实就是 react-scripts 的版本,我们可以先创建一个最新版本的脚手架项目,然后更改为 v2.1.1 版本,具体如下:

  1. 创建一个最新版本的 CRA 项目,参见官方文档:Create React App
  2. 删除 node_modules 文件夹,如果有 package-lock.json 文件或 yarn.lock 文件,也要一并删除,否则重新安装 node_modules 依赖包仍然会被锁定为原来的版本;
  3. 修改 package.json 文件,重新指定 react-scripts 的版本:

      "dependencies": {
        - "react": "^16.7.0",
        + "react": "^16.6.3",
        - "react-dom": "^16.7.0",
        + "react-dom": "^16.6.3",
        - "react-scripts": "2.1.3"
        + "react-scripts": "2.1.1"
      },
  4. 执行 yarn installnpm install 重新安装项目依赖。

这样,我们就创建了一个 CRA v2.1.1 项目,这将作为我们进行多页面入口编译改造的基础。

项目文件组织结构

在开始进行多页面入口配置之前,需要先明确项目的文件组织结构,这关系到我们如何准确获取所有的入口文件,一般有以下几种做法:

  1. 定义一个入口文件名称的数组, 遍历这个数组以获取所有的入口文件。例如:APP_ENTRY=["index","admin"],这样每增加一个入口就要相应的在这个数组里增加一个元素。

    my-app
    └── src
        ├── index
        │   ├── index.js
        │   ├── index.less
        │   ├── components
        │   └── ...
        └── admin
            ├── index.js
            ├── index.less
            ├── components
            └── ...
  2. 在 my-app/public/ 下为所有入口文件新建对应的 .html 文件,通过获取 public 下所有的 .html 文件,确定所有的入口文件。同样的,每增加一个入口就需要在 public 下增加相应的 html 文件。

    my-app
    ├── public
    │   ├── index.html
    │   └── admin.html
    └── src
        ├── index
        │   ├── index.js
        │   ├── index.less
        │   ├── components
        │   └── ...
        └── admin
            ├── index.js
            ├── index.less
            ├── components
            └── ...
  3. 通过遍历 src 下所有的 index.js 文件,确定所有的入口文件,即每个页面所在的子文件夹下都有唯一的一个 index.js 文件,只要遵从这种文件组织规则,就可以不用每次新增入口的时候再去修改配置了(当然,一般来说项目变动不大的情况下配置文件完成之后几乎不用修改)。

    my-app
    ├── public
    │   ├── index.html
    └── src
        ├── index
        │   ├── index.js
        │   ├── index.less
        │   ├── components
        │   └── ...
        └── admin
            ├── index.js
            ├── index.less
            ├── components
            └── ...

当然,文件组织结构还可以有很多其他的方式,只需要确定其中一种文件组织规则,然后按照这个规则去修改和定义配置文件即可,主要就是确定入口文件、指定入口文件对应的 html 模板文件、指定输出文件等。但是,文件组织规则应该项目内保持统一,我们在做项目时需要加一些限制,否则没有哪种配置文件可以完全只能的匹配所有的需求。

“如果你愿意限制做事方式的灵活度,你几乎总会发现可以做得更好。” ——John Carmark

三、具体方案

  1. 创建一个 CRA v2.1.1 版本脚手架项目
    请参照 二、准备工作 中的 如何创建 CRA 脚手架项目 一节,以及官方文档:Create React App
  2. 执行 npm run eject 暴露配置文件
    我们创建好了 CRA 项目之后,执行 npm run eject 命令暴露配置文件,eject 之后文件组织结构如下:

    Note: this is a one-way operation. Once you eject, you can’t go back!
    my-app
    ├── config
    │   ├── jest
    │   ├── env.js
    │   ├── paths.js
    │   ├── webpack.config.dev.js
    │   ├── webpack.config.prod.js
    │   └── webpackDevServer.config.js
    ├── node_modules
    ├── public
    ├── scripts
    │   ├── build.js
    │   ├── start.js
    │   └── test.js
    ├── package.json
    ├── README.md
    └── src

    其中 config 和 scripts 是我们需要重点关注的,我们的修改也主要集中在这两个文件夹中的文件。执行 yarn start 命令,查看 http://localhost:3000/,验证 npm run eject 的操作结果。

  3. 修改文件组织结构
    CRA 项目执行 npm run eject 之后文件组织结构为:

    my-app
    ├── config
    │   ├── jest
    │   ├── env.js
    │   ├── paths.js
    │   ├── webpack.config.dev.js
    │   ├── webpack.config.prod.js
    │   └── webpackDevServer.config.js
    ├── node_modules
    ├── public
    ├── scripts
    │   ├── build.js
    │   ├── start.js
    │   └── test.js
    ├── package.json
    ├── .gitignore
    ├── README.md
    └── src
        ├── App.css
        ├── App.js
        ├── App.test.js
        ├── index.css
        ├── index.js
        ├── logo.svg
        └── serviceWorker.js

    我们按照上文 项目文件组织结构 一节中的第 3 种文件组织方式将其修改为多页面入口编译的文件组织结构:

    // 本方案示例项目有两个页面 index.html & admin.html
    my-app
      ├── config
      ├── node_modules
      ├── package.json
      ├── .gitignore
      ├── README.md
      ├── scripts
      ├── public
      │   ├── favicon.ico
      │   ├── index.html // 作为所有页面的 html 模板文件
      │   └── manifest.json
      └── src
          ├── index.js // 空白文件, 为了避免构建报错, 详见下文
          ├── setupProxy.js // proxy 设置, 详见下文(在当前操作步骤中可以缺失)
          ├── index // index.html 页面对应的文件夹
          │   ├── App.less
          │   ├── App.js
          │   ├── App.test.js
          │   ├── index.less // 使用 less 编写样式文件
          │   ├── index.js
          │   ├── logo.svg
          │   └── serviceWorker.js
          └── admin // admin.html 页面对应的文件夹
              ├── App.less
              ├── App.js
              ├── App.test.js
              ├── index.less // 使用 less 编写样式文件
              ├── index.js
              ├── logo.svg
              └── serviceWorker.js

    在上述这种文件组织结构中,获取所有入口文件的方法如下:

    const globby = require('globby');
    const entriesPath = globby.sync([resolveApp('src') + '/*/index.js']);

    这个示例项目是以 my-app/public/index.html 作为所有页面的 html 模板文件的,当然也可以分别指定不同的 html 模板文件,这是根据项目需要和项目文件组织结构决定的。在这个示例项目中,由于作为模板的 html 文件只需要有个根元素即可,因此将其作为所有入口的 html 模板文件。这样的话,每个页面的 <title></title> 就需要在各自页面中分别指定,一般可以在页面挂载之后进行操作,比如:

    class App extends Component {
      componentDidMount() {
        document.title = 'xxx';
      }
      render() {
        return (
          ...
        );
      }
    }
  4. 修改 Paths.js 文件
    由于入口文件路径在修改开发环境配置和修改生环境配置中都会用到,我们将入口文件路径的获取放在 Paths.js 文件中进行获取和导出,这样开发环境和生产环境就都可以使用了。

    修改 my-app/config/paths.js 文件:

    // 引入 globby 模块
    const globby = require('globby');
    // 入口文件路径
    const entriesPath = globby.sync([resolveApp('src') + '/*/index.js']);
    // 在导出对象中添加 entriesPath
    module.exports = {
      dotenv: resolveApp('.env'),
      appPath: resolveApp('.'),
      appBuild: resolveApp('build'),
      ...
      entriesPath,
    };
  5. 修改开发环境配置
    修改 my-app/config/webpack.config.dev.js 文件

    • 增加以下代码:
    // 获取指定路径下的入口文件
    function getEntries(){
      const entries = {};
      const files = paths.entriesPath;
      files.forEach(filePath => {
        let tmp = filePath.split('/');
        let name = tmp[tmp.length - 2];
        entries[name] = [
          // require.resolve('./polyfills'),
          require.resolve('react-dev-utils/webpackHotDevClient'),
          filePath,
        ];
      });
      return entries;
    }
    
    // 入口文件对象
    const entries = getEntries();
    
    // 配置 HtmlWebpackPlugin 插件, 指定入口生成对应的 html 文件,有多少个页面就需要 new 多少个 HtmllWebpackPlugin
    // webpack配置多入口后,只是编译出多个入口的JS,同时入口的HTML文件由HtmlWebpackPlugin生成,也需做配置。
    // chunks,指明哪些 webpack入口的JS会被注入到这个HTML页面。如果不配置,则将所有entry的JS文件都注入HTML。
    // filename,指明生成的HTML路径,如果不配置就是build/index.html,admin 配置了新的filename,避免与第一个入口的index.html相互覆盖。
    const htmlPlugin = Object.keys(entries).map(item => {
      return new HtmlWebpackPlugin({
        inject: true,
        template: paths.appHtml,
        filename: item + '.html',
        chunks: [item],
      });
    });
    • 修改 module.exports 中的 entry、output 和 plugins:
    module.exports = {
      ...
      // 修改入口
      entry: entries,
      // 修改出口
      output: {
        pathinfo: true,
        // 指定不同的页面模块文件名
        filename: 'static/js/[name].js',
        chunkFilename: 'static/js/[name].chunk.js',
        publicPath: publicPath,
        devtoolModuleFilenameTemplate: info =>
          path.resolve(info.absoluteResourcePath).replace(/\\/g, '/'),
      },
      ...
      plugins: [
        ...
        // 替换 HtmlWebpackPlugin 插件配置
        // new HtmlWebpackPlugin({
        //   inject: true,
        //   template: paths.appHtml,
        // }),
        ...htmlPlugin,
      ],
    };
  6. 修改生产环境配置
    修改 my-app/config/webpack.config.prod.js 文件
    生产环境和开发环境的修改基本相同,只是入口对象和 HtmlWebpackPlugin 插件配置稍有不同(JS、css文件是否压缩等)。

    • 增加以下代码:
    // 获取指定路径下的入口文件
    function getEntries(){
      const entries = {};
      const files = paths.entriesPath;
      files.forEach(filePath => {
        let tmp = filePath.split('/');
        let name = tmp[tmp.length - 2];
        entries[name] = [
          // require.resolve('./polyfills'),
          filePath,
        ];
      });
      return entries;
    }
    
    // 入口文件对象
    const entries = getEntries();
    
    // 配置 HtmlWebpackPlugin 插件, 指定入口文件生成对应的 html 文件
    const htmlPlugin = Object.keys(entries).map(item => {
      return new HtmlWebpackPlugin({
        inject: true,
        template: paths.appHtml,
        filename: item + '.html',
        chunks: [item],
        minify: {
          removeComments: true,
          collapseWhitespace: true,
          removeRedundantAttributes: true,
          useShortDoctype: true,
          removeEmptyAttributes: true,
          removeStyleLinkTypeAttributes: true,
          keepClosingSlash: true,
          minifyJS: true,
          minifyCSS: true,
          minifyURLs: true,
        },
      });
    });
    • 修改 module.exports 中的 entry 和 plugins:
    module.exports = {
      ...
      // 修改入口
      entry: entries,
      ...
      
      plugins: [
        ...
        // 替换 HtmlWebpackPlugin 插件配置
        // new HtmlWebpackPlugin({
        //   inject: true,
        //   template: paths.appHtml,
        //   minify: {
        //     removeComments: true,
        //     collapseWhitespace: true,
        //     removeRedundantAttributes: true,
        //     useShortDoctype: true,
        //     removeEmptyAttributes: true,
        //     removeStyleLinkTypeAttributes: true,
        //     keepClosingSlash: true,
        //     minifyJS: true,
        //     minifyCSS: true,
        //     minifyURLs: true,
        //   },
        // }),
        ...htmlPlugin,
      ],
    };
  7. 修改 webpackDevServer 配置
    上述配置完成后,理论上就已经可以打包出多入口的版本,但是在查找资料的过程中,很多人提到了关于 historyApiFallback 设置的问题,问题描述如下:

    在完成以上配置后,使用 npm start 启动项目,发现无论输入 /index.html 还是 /admin.html,显示的都是 index.html。输入不存在的 /xxxx.html,也显示为 index.html 的内容。

    (这个问题只在开发环境中会出现,生产环境不用考虑。本文示例项目并没有遇到这个问题,不清楚是否与版本有关,或是配置不同的缘故,未深究。)

    本文的示例项目在完成上述配置之后,在开发环境中是可以通过 url 路径访问不同页面的,但是当访问一个不存的地址时,会重定向为 index.html 页面。重定向这个现象与 devServer.historyApiFallback 有关:

    当使用 HTML5 History API 时,任意的 404 响应都可能需要被替代为 index.html。

    如果遇到了上文中描述的关于开发环境中不能通过地址访问不同页面的情况,解决方法如下:
    修改 my-app/config/webpackDevServer.config.js 文件

    • 增加如下代码:
    // 在开发环境中如果要通过地址访问不同的页面, 需要增加以下配置
    const files = paths.entriesPath;
    const rewrites = files.map(v => {
      const fileParse = path.parse(v);
      return {
        from: new RegExp(`^\/${fileParse.base}`),
        to: `/build/${fileParse.base}`,
      };
    });
    • 修改 historyApiFallback
    historyApiFallback: {
      disableDotRule: true,
      rewrites: rewrites,
    },

    如果不希望 404 响应被重定向为 index.html 页面,而是如实的在页面展示 404 Not Found 错误,可以直接修改 historyApiFallback:

    historyApiFallback: {
      disableDotRule: false,
    },

四、扩展配置

以上的操作已经完成了多页面入口编译的配置,但在实际项目中我们还需要扩展一些功能,比如增加对 less 的支持、设置别名路径、更改输出的文件名、使用 babel-plugin-import 按需加载组件等,这里对一些常用功能给出具体配置方法。

  1. 使用 babel-plugin-import 按需加载组件
    在 React 项目中我们通常会使用 Ant Design,这时我们就需要设置按需加载,antd 官方文档也给出了按需加载的方法:antd 按需加载

    // babel-loader option
    {
      plugins: [
        [
          require.resolve('babel-plugin-named-asset-import'),
          {
            loaderMap: {
              svg: {
                ReactComponent: '@svgr/webpack?-prettier,-svgo![path]',
              },
            },
          },
        ],
        // 按需加载
        ["import", {
          "libraryName": "antd",
          "libraryDirectory": "es",
          "style": true // `style: true` 会加载 less 文件
        }],
      ],
    }
  2. 增加 less 支持(安装 less、less-loader)

    // style files regexes
    // const cssRegex = /\.css$/;
    const cssRegex = /\.(css|less)$/; // 增加对 less 的正则匹配
    const getStyleLoaders = (cssOptions, preProcessor) => {
      const loaders = [
        require.resolve('style-loader'),
        {
          loader: require.resolve('css-loader'),
          options: cssOptions,
        },
        {
          loader: require.resolve('postcss-loader'),
          options: {
            ident: 'postcss',
            plugins: () => [
              require('postcss-flexbugs-fixes'),
              require('postcss-preset-env')({
                autoprefixer: {
                  flexbox: 'no-2009',
                },
                stage: 3,
              }),
            ],
          },
        },
        // 编译 less 文件
        {
          loader: require.resolve('less-loader'),
        }
      ];
      if (preProcessor) {
        loaders.push(require.resolve(preProcessor));
      }
      return loaders;
    };
  3. 设置别名路径
    在开发过程中,有些文件的路径较深,当其引入一些公共模块时,路径嵌套就会比较多,并且当这些工具函数模块或其他公共模块路径变更的时候,涉及到的修改比较多,因此可以通过设置别名路径的方式减少这些工作。关于别名路径,请参考:webpack-resolve.alias

    // 增加别名路径
    alias: {
      'react-native': 'react-native-web',
      '@src': paths.appSrc, // 在使用中有些 Eslint 规则会报错, 禁用这部分代码的 Eslint 检测即可
    },
  4. 增加对 html 文档中图片路径的处理(安装 html-withimg-loader)

    // 在 file-loader 下方添加以下代码
    {
      // 处理 html 文档中图片路径问题
      test: /\.html$/,
      loader: 'html-withimg-loader'
    },
  5. 辅助分析打包内容(安装 webpack-bundle-analyzer)
    webpack-bundle-analyzer 是 webpack 可视化工具,它可以将打包后的内容展示为直观的可交互树状图,让我们了解构建包中真正引入的内容,以及各个文件由哪些模块组成,从而帮助我们优化项目打包策略,提升页面性能。

    // 引入 BundleAnalyzerPlugin
    const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
    
    // 在 module.exports.plugins 中添加 BundleAnalyzerPlugin 插件实例
    // 辅助分析打包内容
    new BundleAnalyzerPlugin(),
  6. 更改生产模式输出的文件名(build 版本)
    当我们执行 yarn start 命令构建生产版本的时候,会发现构建出的文件比较多或者命名不符合我们的要求,比如我们会看到类似于 index.32837849.chunk.jsstyles.3b14856c.chunk.cssprecache-manifest.f4cdb7773e8c0f750c8d9d2e5166d629.js 这种形式的文件,我们可以根据项目的需要对相关的配置进行更改。以下只是给出一个示例,开发中应该根据项目的需要进行配置。

    // 更改输出的脚本文件名
    output: {
      // filename: 'static/js/[name].[chunkhash:8].js',
      // chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
      filename: 'static/js/[name].js?_v=[chunkhash:8]',
      chunkFilename: 'static/js/[name].chunk.js?_v=[chunkhash:8]',
    },
    
    // 更改输出的样式文件名
    new MiniCssExtractPlugin({
      // filename: 'static/css/[name].[contenthash:8].css',
      // chunkFilename: 'static/css/[name].[contenthash:8].chunk.css',
      filename: 'static/css/[name].css?_v=[contenthash:8]',
      chunkFilename: 'static/css/[name].chunk.css?_v=[contenthash:8]',
    }),
    
    // 更改 precacheManifestFilename
    new WorkboxWebpackPlugin.GenerateSW({
      clientsClaim: true,
      exclude: [/\.map$/, /asset-manifest\.json$/],
      importWorkboxFrom: 'cdn',
      navigateFallback: publicUrl + '/index.html',
      navigateFallbackBlacklist: [
        new RegExp('^/_'),
        new RegExp('/[^/]+\\.[^/]+$'),
      ],
      // 更改输出的文件名
      precacheManifestFilename: 'precache-manifest.js?_v=[manifestHash]',
    }),
  7. 更改代码拆分规则(build 版本)

    // 修改代码拆分规则,详见 webpack 文档:https://webpack.js.org/plugins/split-chunks-plugin/#optimization-splitchunks
    // 这里只是给出一个示例,开发中应该根据项目的需要进行配置
    splitChunks: {
      // chunks: 'all',
      // name: false,
      cacheGroups: {
        // 通过正则匹配,将 react react-dom echarts-for-react 等公共模块拆分为 vendor
        // 这里仅作为示例,具体需要拆分哪些模块需要根据项目需要进行配置
        // 可以通过 BundleAnalyzerPlugin 帮助确定拆分哪些模块包
        vendor: {
          test: /[\\/]node_modules[\\/](react|react-dom|echarts-for-react)[\\/]/,
          name: 'vendor',
          chunks: 'all', // all, async, and initial
        },
    
        // 将 css|less 文件合并成一个文件, mini-css-extract-plugin 的用法请参见文档:https://www.npmjs.com/package/mini-css-extract-plugin
        // MiniCssExtractPlugin 会将动态 import 引入的模块的样式文件也分离出去,将这些样式文件合并成一个文件可以提高渲染速度
        // 其实如果可以不使用 mini-css-extract-plugin 这个插件,即不分离样式文件,可能更适合本方案,但是我没有找到方法去除这个插件
        styles: {            
          name: 'styles',
          test: /\.css|less$/,
          chunks: 'all',    // merge all the css chunk to one file
          enforce: true
        }
      },
    },
    // runtimeChunk: true,
    runtimeChunk: false, // 构建文件中不产生 runtime chunk

五、错误排查

这里对配置过程中可能会出现的错误或异常进行记录,这里主要有两类错误,一类是多页面入口改造中可以预期的常规错误,另一类是由于基础工具版本的变动造成的不确定错误。

常规错误

  1. Could not find a required file.

    Could not find a required file.
      Name: index.js
      Searched in: C:\Users\xxx\my-app\src
    error Command failed with exit code 1.
    info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

    通过 create-react-app 脚手架搭建的项目以 my-app/src/index.js 作为应用入口,当执行构建脚本时如果检测到缺失了这个必要文件,node 进程会退出。在本方案中,根据我们设定的文件组织结构的规则,现在 src 下不会直接存在 index.js 文件,但每个独立页面的子文件夹下会存在 index.js 文件,如 my-app/src/index/index.js my-app/src/admin/index.js。解决方法如下:

    • 方法一:将关于这个必要文件的检测语句注释掉
      修改 my-app/scripts/start.js 文件(开发环境)、my-app/scripts/build.js 文件(生产环境)

      // Warn and crash if required files are missing
      // if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
      //   process.exit(1);
      // }
    • 方法二:在 my-app/src 下保留一个空白的 index.js 文件
  2. Failed to load resource: net::ERR_FILE_NOT_FOUND(build 版本)
    错误描述:执行 yarn buildnpm run build 构建生产版本时,构建出的页面未能正确加载样式和脚本文件,chrome 检查工具报路径错误。
    解决方法:修改 package.json 文件,指定 homepage 字段的值,本项目这里指定为相对路径。

      "homepage": "./",
  3. When specified, "proxy" in package.json must be a string.

    When specified, "proxy" in package.json must be a string.
    Instead, the type of "proxy" was "object".
    Either remove "proxy" from package.json, or make it a string.

    错误描述:我们在开发过程中一般会在 package.json 文件中配置 proxy 代理服务器,但是在 CRA 2.x 升级以后对 proxy 的设置做了修改,具体请参见官方升级文档:Move advanced proxy configuration to src/setupProxy.js
    解决方法:移除 package.json 文件中有关 proxy 的设置,使用 http-proxy-middleware,在 src 目录下创建 setupProxy.js 文件。详细方法请参见上述文档。

其他错误

我们在上文 版本的变动 一节中已经对此有所提及,这类错误主要是由 npm 包版本的升级造成的,这些错误一般是不可预期的,也无法在这里全部涵盖,只能就当前遇到的问题进行简要记录,可能随着时间的推移,还会出现其他的类似问题,也可能这些错误已经在后续的版本中被修复了,因此请勿纠结于这里记录的错误,如果遇到了这类错误,就查阅资料进行修正,如果没有遇到,则无须理会。

  1. TypeError: Expected cwd to be of type string but received type undefined

    C:xxxmy-appnode_modulesdir-globindex.js:59
    throw new TypeError(Expected \cwd` to be of type `string` but received type `${typeof opts.cwd}``);
    TypeError: Expected cwd to be of type string but received type undefined

    错误描述:本文的写作开始于 2019-01-05,在 2019-01-15 重新审核本文方案的时候,遇到了这个错误,主要是由于 dir-glob 版本的升级造成的,我们在配置脚本中使用了 globby 的 sync 方法,dir-glob 版本升级之后,这个方法的调用会使得 dir-glob 抛出上述错误。详细信息参见:Broken build do to major change from 2.0 to 2.2 以及 globby will pass opts.cwd = undefined to dir-glob, which leads to TypeError.
    解决方法:这里给出的解决方法是限定于当前时间的,因为在本文编辑的时候(2019-01-15)这个 issue 还没有给出最终的解决方案,个人觉得可能会由 globby 进行修复。

    /* paths.js */
    // 修改获取入口文件路径的代码
    - const entriesPath = globby.sync([resolveApp('src') + '/*/index.js']);
    + const entriesPath = globby.sync([resolveApp('src') + '/*/index.js'], {cwd: process.cwd()});
  2. Redux 版本错误:TypeError: Cannot read property 'state' of undefined(页面报错)

    错误描述:编译构建过程没有报错,但页面报错:TypeError: Cannot read property 'state' of undefined。
    解决方法:redux 版本错误,在本文的配置方案中,应当使用 redux <=3.7.2 版本。

  3. Inline JavaScript is not enabled. Is it set in your options?

    // https://github.com/ant-design...
    .bezierEasingMixin();^ Inline JavaScript is not enabled. Is it set in your options?
    in C:xxxsrcmy-appnode_modulesantdesstylecolorbezierEasing.less (line 110, column 0)

    错误描述:less、less-loader 配置问题,提示需要允许行内 js 的执行,比如当我们在项目中使用 antd 组件时,如果引入的样式文件是 less 文件,构建时就会报上述错误。
    解决方法:在增加 less 文件支持时,设置 javascriptEnabled 为 true。

    /* webpack.config.dev.js & webpack.config.prod.js */
    // 编译 less 文件
    {
      loader: require.resolve('less-loader'),
      options: {
        // 解决报错: Inline JavaScript is not enabled. Is it set in your options?
        javascriptEnabled: true,
      },
    },

六、源码附录

以下附录 package.json 文件信息、开发环境及生产环境配置文件,配置文件中已将原有的英文注释删除,只保留了我们改动处的中文注释。

  1. package.json

    {
      "name": "my-app",
      "version": "0.1.0",
      "private": true,
      "dependencies": {
        "@babel/core": "7.1.0",
        "@svgr/webpack": "2.4.1",
        "antd": "^3.12.3",
        "babel-core": "7.0.0-bridge.0",
        "babel-eslint": "9.0.0",
        "babel-jest": "23.6.0",
        "babel-loader": "8.0.4",
        "babel-plugin-import": "^1.11.0",
        "babel-plugin-named-asset-import": "^0.2.3",
        "babel-preset-react-app": "^6.1.0",
        "bfj": "6.1.1",
        "case-sensitive-paths-webpack-plugin": "2.1.2",
        "chalk": "2.4.1",
        "css-loader": "1.0.0",
        "dotenv": "6.0.0",
        "dotenv-expand": "4.2.0",
        "echarts": "^4.2.0-rc.2",
        "echarts-for-react": "^2.0.15-beta.0",
        "eslint": "5.6.0",
        "eslint-config-react-app": "^3.0.5",
        "eslint-loader": "2.1.1",
        "eslint-plugin-flowtype": "2.50.1",
        "eslint-plugin-import": "2.14.0",
        "eslint-plugin-jsx-a11y": "6.1.2",
        "eslint-plugin-react": "7.11.1",
        "file-loader": "2.0.0",
        "fork-ts-checker-webpack-plugin-alt": "0.4.14",
        "fs-extra": "7.0.0",
        "html-webpack-plugin": "4.0.0-alpha.2",
        "html-withimg-loader": "^0.1.16",
        "http-proxy-middleware": "^0.19.1",
        "identity-obj-proxy": "3.0.0",
        "jest": "23.6.0",
        "jest-pnp-resolver": "1.0.1",
        "jest-resolve": "23.6.0",
        "less": "^3.9.0",
        "less-loader": "^4.1.0",
        "mini-css-extract-plugin": "0.4.3",
        "optimize-css-assets-webpack-plugin": "5.0.1",
        "pnp-webpack-plugin": "1.1.0",
        "postcss-flexbugs-fixes": "4.1.0",
        "postcss-loader": "3.0.0",
        "postcss-preset-env": "6.0.6",
        "postcss-safe-parser": "4.0.1",
        "react": "^16.6.3",
        "react-app-polyfill": "^0.1.3",
        "react-dev-utils": "^6.1.1",
        "react-dom": "^16.6.3",
        "react-intl": "^2.8.0",
        "react-lazyload": "^2.3.0",
        "react-loadable": "^5.5.0",
        "react-redux": "^6.0.0",
        "redux": "3.7.2",
        "redux-promise-middleware": "^5.1.1",
        "resolve": "1.8.1",
        "sass-loader": "7.1.0",
        "style-loader": "0.23.0",
        "terser-webpack-plugin": "1.1.0",
        "url-loader": "1.1.1",
        "webpack": "4.19.1",
        "webpack-bundle-analyzer": "^3.0.3",
        "webpack-dev-server": "3.1.9",
        "webpack-manifest-plugin": "2.0.4",
        "workbox-webpack-plugin": "3.6.3"
      },
      "scripts": {
        "start": "node scripts/start.js",
        "build": "node scripts/build.js",
        "test": "node scripts/test.js"
      },
      "eslintConfig": {
        "extends": "react-app"
      },
      "browserslist": [
        ">0.2%",
        "not dead",
        "not ie <= 11",
        "not op_mini all"
      ],
      "jest": {
        "collectCoverageFrom": [
          "src/**/*.{js,jsx,ts,tsx}",
          "!src/**/*.d.ts"
        ],
        "resolver": "jest-pnp-resolver",
        "setupFiles": [
          "react-app-polyfill/jsdom"
        ],
        "testMatch": [
          "<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
          "<rootDir>/src/**/?(*.)(spec|test).{js,jsx,ts,tsx}"
        ],
        "testEnvironment": "jsdom",
        "testURL": "http://localhost",
        "transform": {
          "^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
          "^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
          "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
        },
        "transformIgnorePatterns": [
          "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$",
          "^.+\\.module\\.(css|sass|scss)$"
        ],
        "moduleNameMapper": {
          "^react-native$": "react-native-web",
          "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
        },
        "moduleFileExtensions": [
          "web.js",
          "js",
          "web.ts",
          "ts",
          "web.tsx",
          "tsx",
          "json",
          "web.jsx",
          "jsx",
          "node"
        ]
      },
      "babel": {
        "presets": [
          "react-app"
        ]
      },
      "homepage": "./"
    }
  2. webpack.config.dev.js

    const fs = require('fs');
    const path = require('path');
    const resolve = require('resolve');
    const webpack = require('webpack');
    const PnpWebpackPlugin = require('pnp-webpack-plugin');
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
    const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
    const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
    const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
    const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
    const getClientEnvironment = require('./env');
    const paths = require('./paths');
    const ManifestPlugin = require('webpack-manifest-plugin');
    const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin');
    const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin-alt');
    const typescriptFormatter = require('react-dev-utils/typescriptFormatter');
    const publicPath = '/';
    const publicUrl = '';
    const env = getClientEnvironment(publicUrl);
    const useTypeScript = fs.existsSync(paths.appTsConfig);
    
    // style files regexes
    // const cssRegex = /\.css$/;
    const cssRegex = /\.(css|less)$/;
    const cssModuleRegex = /\.module\.css$/;
    const sassRegex = /\.(scss|sass)$/;
    const sassModuleRegex = /\.module\.(scss|sass)$/;
    
    const getStyleLoaders = (cssOptions, preProcessor) => {
      const loaders = [
        require.resolve('style-loader'),
        {
          loader: require.resolve('css-loader'),
          options: cssOptions,
        },
        {
          loader: require.resolve('postcss-loader'),
          options: {
            ident: 'postcss',
            plugins: () => [
              require('postcss-flexbugs-fixes'),
              require('postcss-preset-env')({
                autoprefixer: {
                  flexbox: 'no-2009',
                },
                stage: 3,
              }),
            ],
          },
        },
        // 编译 less 文件
        {
          loader: require.resolve('less-loader'),
          options: {
            // 解决报错: Inline JavaScript is not enabled. Is it set in your options?
            javascriptEnabled: true,
          },
        }
      ];
      if (preProcessor) {
        loaders.push(require.resolve(preProcessor));
      }
      return loaders;
    };
    
    // 获取指定路径下的入口文件
    function getEntries(){
      const entries = {};
      const files = paths.entriesPath;
      files.forEach(filePath => {
        let tmp = filePath.split('/');
        let name = tmp[tmp.length - 2];
        entries[name] = [
          // require.resolve('./polyfills'),
          require.resolve('react-dev-utils/webpackHotDevClient'),
          filePath,
        ];
      });
      return entries;
    }
    
    // 入口文件对象
    const entries = getEntries();
    
    // 配置 HtmlWebpackPlugin 插件, 指定入口生成对应的 html 文件,有多少个页面就需要 new 多少个 HtmllWebpackPlugin
    // webpack配置多入口后,只是编译出多个入口的JS,同时入口的HTML文件由HtmlWebpackPlugin生成,也需做配置。
    // chunks,指明哪些 webpack入口的JS会被注入到这个HTML页面。如果不配置,则将所有entry的JS文件都注入HTML。
    // filename,指明生成的HTML路径,如果不配置就是build/index.html,admin 配置了新的filename,避免与第一个入口的index.html相互覆盖。
    const htmlPlugin = Object.keys(entries).map(item => {
      return new HtmlWebpackPlugin({
        inject: true,
        template: paths.appHtml,
        filename: item + '.html',
        chunks: [item],
      });
    });
    
    module.exports = {
      mode: 'development',
      devtool: 'cheap-module-source-map',
      // 修改入口
      entry: entries,
      // 修改出口
      output: {
        pathinfo: true,
        // 指定不同的页面模块文件名
        filename: 'static/js/[name].js',
        chunkFilename: 'static/js/[name].chunk.js',
        publicPath: publicPath,
        devtoolModuleFilenameTemplate: info =>
          path.resolve(info.absoluteResourcePath).replace(/\\/g, '/'),
      },
      optimization: {
        splitChunks: {
          chunks: 'all',
          name: false,
        },
        runtimeChunk: true,
      },
      resolve: {
        modules: ['node_modules'].concat(
          process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
        ),
        extensions: paths.moduleFileExtensions
          .map(ext => `.${ext}`)
          .filter(ext => useTypeScript || !ext.includes('ts')),
        // 增加别名路径
        alias: {
          'react-native': 'react-native-web',
          '@src': paths.appSrc, // 在使用中有些 Eslint 规则会报错, 禁用这部分代码的 Eslint 检测即可
        },
        plugins: [
          PnpWebpackPlugin,
          new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson]),
        ],
      },
      resolveLoader: {
        plugins: [
          PnpWebpackPlugin.moduleLoader(module),
        ],
      },
      module: {
        strictExportPresence: true,
        rules: [
          { parser: { requireEnsure: false } },
          {
            test: /\.(js|mjs|jsx)$/,
            enforce: 'pre',
            use: [
              {
                options: {
                  formatter: require.resolve('react-dev-utils/eslintFormatter'),
                  eslintPath: require.resolve('eslint'),
                },
                loader: require.resolve('eslint-loader'),
              },
            ],
            include: paths.appSrc,
          },
          {
            oneOf: [
              {
                test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
                loader: require.resolve('url-loader'),
                options: {
                  limit: 10000,
                  name: 'static/media/[name].[hash:8].[ext]',
                },
              },
              {
                test: /\.(js|mjs|jsx|ts|tsx)$/,
                include: paths.appSrc,
                loader: require.resolve('babel-loader'),
                options: {
                  customize: require.resolve(
                    'babel-preset-react-app/webpack-overrides'
                  ),
                  plugins: [
                    [
                      require.resolve('babel-plugin-named-asset-import'),
                      {
                        loaderMap: {
                          svg: {
                            ReactComponent: '@svgr/webpack?-prettier,-svgo![path]',
                          },
                        },
                      },
                    ],
                    // 按需加载
                    ["import", {
                      "libraryName": "antd",
                      "libraryDirectory": "es",
                      "style": true // `style: true` 会加载 less 文件
                    }],
                  ],
                  cacheCompression: false,
                },
              },
              {
                test: /\.(js|mjs)$/,
                exclude: /@babel(?:\/|\\{1,2})runtime/,
                loader: require.resolve('babel-loader'),
                options: {
                  babelrc: false,
                  configFile: false,
                  compact: false,
                  presets: [
                    [
                      require.resolve('babel-preset-react-app/dependencies'),
                      { helpers: true },
                    ],
                  ],
                  cacheDirectory: true,
                  cacheCompression: false,
                  sourceMaps: false,
                },
              },
              {
                test: cssRegex,
                exclude: cssModuleRegex,
                use: getStyleLoaders({
                  importLoaders: 1,
                }),
              },
              {
                test: cssModuleRegex,
                use: getStyleLoaders({
                  importLoaders: 1,
                  modules: true,
                  getLocalIdent: getCSSModuleLocalIdent,
                }),
              },
              {
                test: sassRegex,
                exclude: sassModuleRegex,
                use: getStyleLoaders({ importLoaders: 2 }, 'sass-loader'),
              },
              {
                test: sassModuleRegex,
                use: getStyleLoaders(
                  {
                    importLoaders: 2,
                    modules: true,
                    getLocalIdent: getCSSModuleLocalIdent,
                  },
                  'sass-loader'
                ),
              },
              {
                exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
                loader: require.resolve('file-loader'),
                options: {
                  name: 'static/media/[name].[hash:8].[ext]',
                },
              },
              {
                // 处理 html 文档中图片路径问题
                test: /\.html$/,
                loader: 'html-withimg-loader'
              },
            ],
          },
        ],
      },
      plugins: [
        // 替换 HtmlWebpackPlugin 插件配置
        // new HtmlWebpackPlugin({
        //   inject: true,
        //   template: paths.appHtml,
        // }),
        ...htmlPlugin,
        new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
        new ModuleNotFoundPlugin(paths.appPath),
        new webpack.DefinePlugin(env.stringified),
        new webpack.HotModuleReplacementPlugin(),
        new CaseSensitivePathsPlugin(),
        new WatchMissingNodeModulesPlugin(paths.appNodeModules),
        new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
        new ManifestPlugin({
          fileName: 'asset-manifest.json',
          publicPath: publicPath,
        }),
        useTypeScript &&
          new ForkTsCheckerWebpackPlugin({
            typescript: resolve.sync('typescript', {
              basedir: paths.appNodeModules,
            }),
            async: false,
            checkSyntacticErrors: true,
            tsconfig: paths.appTsConfig,
            compilerOptions: {
              module: 'esnext',
              moduleResolution: 'node',
              resolveJsonModule: true,
              isolatedModules: true,
              noEmit: true,
              jsx: 'preserve',
            },
            reportFiles: [
              '**',
              '!**/*.json',
              '!**/__tests__/**',
              '!**/?(*.)(spec|test).*',
              '!src/setupProxy.js',
              '!src/setupTests.*',
            ],
            watch: paths.appSrc,
            silent: true,
            formatter: typescriptFormatter,
          }),
      ].filter(Boolean),
    
      node: {
        dgram: 'empty',
        fs: 'empty',
        net: 'empty',
        tls: 'empty',
        child_process: 'empty',
      },
      performance: false,
    };
  3. webpack.config.prod.js

    const fs = require('fs');
    const path = require('path');
    const webpack = require('webpack');
    const resolve = require('resolve');
    const PnpWebpackPlugin = require('pnp-webpack-plugin');
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
    const TerserPlugin = require('terser-webpack-plugin');
    const MiniCssExtractPlugin = require('mini-css-extract-plugin');
    const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
    const safePostCssParser = require('postcss-safe-parser');
    const ManifestPlugin = require('webpack-manifest-plugin');
    const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
    const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
    const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
    const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
    const paths = require('./paths');
    const getClientEnvironment = require('./env');
    const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin');
    const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin-alt');
    const typescriptFormatter = require('react-dev-utils/typescriptFormatter');
    const publicPath = paths.servedPath;
    const shouldUseRelativeAssetPaths = publicPath === './';
    const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
    const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
    const publicUrl = publicPath.slice(0, -1);
    const env = getClientEnvironment(publicUrl);
    // const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
    
    if (env.stringified['process.env'].NODE_ENV !== '"production"') {
      throw new Error('Production builds must have NODE_ENV=production.');
    }
    
    const useTypeScript = fs.existsSync(paths.appTsConfig);
    
    // style files regexes
    // const cssRegex = /\.css$/;
    const cssRegex = /\.(css|less)$/;
    const cssModuleRegex = /\.module\.css$/;
    const sassRegex = /\.(scss|sass)$/;
    const sassModuleRegex = /\.module\.(scss|sass)$/;
    
    // common function to get style loaders
    const getStyleLoaders = (cssOptions, preProcessor) => {
      const loaders = [
        {
          loader: MiniCssExtractPlugin.loader,
          options: Object.assign(
            {},
            shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined
          ),
        },
        {
          loader: require.resolve('css-loader'),
          options: cssOptions,
        },
        {
          loader: require.resolve('postcss-loader'),
          options: {
            ident: 'postcss',
            plugins: () => [
              require('postcss-flexbugs-fixes'),
              require('postcss-preset-env')({
                autoprefixer: {
                  flexbox: 'no-2009',
                },
                stage: 3,
              }),
            ],
            sourceMap: shouldUseSourceMap,
          },
        },
        // 编译 less 文件
        {
          loader: require.resolve('less-loader'),
          options: {
            // 解决报错: Inline JavaScript is not enabled. Is it set in your options?
            javascriptEnabled: true,
          },
        }
      ];
      if (preProcessor) {
        loaders.push({
          loader: require.resolve(preProcessor),
          options: {
            sourceMap: shouldUseSourceMap,
          },
        });
      }
      return loaders;
    };
    
    // 获取指定路径下的入口文件
    function getEntries(){
      const entries = {};
      const files = paths.entriesPath;
      files.forEach(filePath => {
        let tmp = filePath.split('/');
        let name = tmp[tmp.length - 2];
        entries[name] = [
          // require.resolve('./polyfills'),
          filePath,
        ];
      });
      return entries;
    }
    
    // 入口文件对象
    const entries = getEntries();
    
    // 配置 HtmlWebpackPlugin 插件, 指定入口文件生成对应的 html 文件
    const htmlPlugin = Object.keys(entries).map(item => {
      return new HtmlWebpackPlugin({
        inject: true,
        template: paths.appHtml,
        filename: item + '.html',
        chunks: [item],
        minify: {
          removeComments: true,
          collapseWhitespace: true,
          removeRedundantAttributes: true,
          useShortDoctype: true,
          removeEmptyAttributes: true,
          removeStyleLinkTypeAttributes: true,
          keepClosingSlash: true,
          minifyJS: true,
          minifyCSS: true,
          minifyURLs: true,
        },
      });
    });
    
    module.exports = {
      mode: 'production',
      bail: true,
      devtool: shouldUseSourceMap ? 'source-map' : false,
      // 修改入口
      // entry: [paths.appIndexJs],
      entry: entries,
      output: {
        path: paths.appBuild,
        // 更改输出的脚本文件名
        // filename: 'static/js/[name].[chunkhash:8].js',
        // chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
        filename: 'static/js/[name].js?_v=[chunkhash:8]',
        chunkFilename: 'static/js/[name].chunk.js?_v=[chunkhash:8]',
        publicPath: publicPath,
        devtoolModuleFilenameTemplate: info =>
          path
            .relative(paths.appSrc, info.absoluteResourcePath)
            .replace(/\\/g, '/'),
      },
      optimization: {
        minimizer: [
          new TerserPlugin({
            terserOptions: {
              parse: {
                ecma: 8,
              },
              compress: {
                ecma: 5,
                warnings: false,
                comparisons: false,
                inline: 2,
              },
              mangle: {
                safari10: true,
              },
              output: {
                ecma: 5,
                comments: false,
                ascii_only: true,
              },
            },
            parallel: true,
            cache: true,
            sourceMap: shouldUseSourceMap,
          }),
          new OptimizeCSSAssetsPlugin({
            cssProcessorOptions: {
              parser: safePostCssParser,
              map: shouldUseSourceMap
                ? {
                    inline: false,
                    annotation: true,
                  }
                : false,
            },
          }),
        ],
        // 修改代码拆分规则,详见 webpack 文档:https://webpack.js.org/plugins/split-chunks-plugin/#optimization-splitchunks
        splitChunks: {
          // chunks: 'all',
          // name: false,
          cacheGroups: {
            // 通过正则匹配,将 react react-dom echarts-for-react 等公共模块拆分为 vendor
            // 这里仅作为示例,具体需要拆分哪些模块需要根据项目需要进行配置
            // 可以通过 BundleAnalyzerPlugin 帮助确定拆分哪些模块包
            vendor: {
              test: /[\\/]node_modules[\\/](react|react-dom|echarts-for-react)[\\/]/,
              name: 'vendor',
              chunks: 'all', // all, async, and initial
            },
    
            // 将 css|less 文件合并成一个文件, mini-css-extract-plugin 的用法请参见文档:https://www.npmjs.com/package/mini-css-extract-plugin
            // MiniCssExtractPlugin 会将动态 import 引入的模块的样式文件也分离出去,将这些样式文件合并成一个文件可以提高渲染速度
            // 其实如果可以不使用 mini-css-extract-plugin 这个插件,即不分离样式文件,可能更适合本方案,但是我没有找到方法去除这个插件
            styles: {            
              name: 'styles',
              test: /\.css|less$/,
              chunks: 'all',    // merge all the css chunk to one file
              enforce: true
            }
          },
        },
        // runtimeChunk: true,
        runtimeChunk: false, // 构建文件中不产生 runtime chunk
      },
      resolve: {
        modules: ['node_modules'].concat(
          process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
        ),
        extensions: paths.moduleFileExtensions
          .map(ext => `.${ext}`)
          .filter(ext => useTypeScript || !ext.includes('ts')),
        // 增加别名路径
        alias: {
          'react-native': 'react-native-web',
          '@src': paths.appSrc, // 在使用中有些 Eslint 规则会报错, 禁用这部分代码的 Eslint 检测即可
        },
        plugins: [
          PnpWebpackPlugin,
          new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson]),
        ],
      },
      resolveLoader: {
        plugins: [
          PnpWebpackPlugin.moduleLoader(module),
        ],
      },
      module: {
        strictExportPresence: true,
        rules: [
          { parser: { requireEnsure: false } },
          {
            test: /\.(js|mjs|jsx)$/,
            enforce: 'pre',
            use: [
              {
                options: {
                  formatter: require.resolve('react-dev-utils/eslintFormatter'),
                  eslintPath: require.resolve('eslint'),
                },
                loader: require.resolve('eslint-loader'),
              },
            ],
            include: paths.appSrc,
          },
          {
            oneOf: [
              {
                test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
                loader: require.resolve('url-loader'),
                options: {
                  limit: 10000,
                  name: 'static/media/[name].[hash:8].[ext]',
                },
              },
              {
                test: /\.(js|mjs|jsx|ts|tsx)$/,
                include: paths.appSrc,
                loader: require.resolve('babel-loader'),
                options: {
                  customize: require.resolve(
                    'babel-preset-react-app/webpack-overrides'
                  ),
                  plugins: [
                    [
                      require.resolve('babel-plugin-named-asset-import'),
                      {
                        loaderMap: {
                          svg: {
                            ReactComponent: '@svgr/webpack?-prettier,-svgo![path]',
                          },
                        },
                      },
                    ],
                    // 按需加载
                    ["import", {
                      "libraryName": "antd",
                      "libraryDirectory": "es",
                      "style": true // `style: true` 会加载 less 文件
                    }],
                  ],
                  cacheDirectory: true,
                  cacheCompression: true,
                  compact: true,
                },
              },
              {
                test: /\.(js|mjs)$/,
                exclude: /@babel(?:\/|\\{1,2})runtime/,
                loader: require.resolve('babel-loader'),
                options: {
                  babelrc: false,
                  configFile: false,
                  compact: false,
                  presets: [
                    [
                      require.resolve('babel-preset-react-app/dependencies'),
                      { helpers: true },
                    ],
                  ],
                  cacheDirectory: true,
                  cacheCompression: true,
                  sourceMaps: false,
                },
              },
              {
                test: cssRegex,
                exclude: cssModuleRegex,
                loader: getStyleLoaders({
                  importLoaders: 1,
                  sourceMap: shouldUseSourceMap,
                }),
                sideEffects: true,
              },
              {
                test: cssModuleRegex,
                loader: getStyleLoaders({
                  importLoaders: 1,
                  sourceMap: shouldUseSourceMap,
                  modules: true,
                  getLocalIdent: getCSSModuleLocalIdent,
                }),
              },
              {
                test: sassRegex,
                exclude: sassModuleRegex,
                loader: getStyleLoaders(
                  {
                    importLoaders: 2,
                    sourceMap: shouldUseSourceMap,
                  },
                  'sass-loader'
                ),
                sideEffects: true,
              },
              {
                test: sassModuleRegex,
                loader: getStyleLoaders(
                  {
                    importLoaders: 2,
                    sourceMap: shouldUseSourceMap,
                    modules: true,
                    getLocalIdent: getCSSModuleLocalIdent,
                  },
                  'sass-loader'
                ),
              },
              {
                loader: require.resolve('file-loader'),
                exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
                options: {
                  name: 'static/media/[name].[hash:8].[ext]',
                },
              },
              {
                // 处理 html 文档中图片路径问题
                test: /\.html$/,
                loader: 'html-withimg-loader'
              },
            ],
          },
        ],
      },
      plugins: [
        // new HtmlWebpackPlugin({
        //   inject: true,
        //   template: paths.appHtml,
        //   minify: {
        //     removeComments: true,
        //     collapseWhitespace: true,
        //     removeRedundantAttributes: true,
        //     useShortDoctype: true,
        //     removeEmptyAttributes: true,
        //     removeStyleLinkTypeAttributes: true,
        //     keepClosingSlash: true,
        //     minifyJS: true,
        //     minifyCSS: true,
        //     minifyURLs: true,
        //   },
        // }),
        // 替换 HtmlWebpackPlugin 插件配置
        ...htmlPlugin,
        shouldInlineRuntimeChunk &&
          new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime~.+[.]js/]),
        new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
        new ModuleNotFoundPlugin(paths.appPath),
        new webpack.DefinePlugin(env.stringified),
        // 更改输出的样式文件名
        new MiniCssExtractPlugin({
          // filename: 'static/css/[name].[contenthash:8].css',
          // chunkFilename: 'static/css/[name].[contenthash:8].chunk.css',
          filename: 'static/css/[name].css?_v=[contenthash:8]',
          chunkFilename: 'static/css/[name].chunk.css?_v=[contenthash:8]',
        }),
        new ManifestPlugin({
          fileName: 'asset-manifest.json',
          publicPath: publicPath,
        }),
        new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
        new WorkboxWebpackPlugin.GenerateSW({
          clientsClaim: true,
          exclude: [/\.map$/, /asset-manifest\.json$/],
          importWorkboxFrom: 'cdn',
          navigateFallback: publicUrl + '/index.html',
          navigateFallbackBlacklist: [
            new RegExp('^/_'),
            new RegExp('/[^/]+\\.[^/]+$'),
          ],
          // 更改输出的文件名
          precacheManifestFilename: 'precache-manifest.js?_v=[manifestHash]',
        }),
        // 辅助分析打包内容
        // new BundleAnalyzerPlugin(),
        fs.existsSync(paths.appTsConfig) &&
          new ForkTsCheckerWebpackPlugin({
            typescript: resolve.sync('typescript', {
              basedir: paths.appNodeModules,
            }),
            async: false,
            checkSyntacticErrors: true,
            tsconfig: paths.appTsConfig,
            compilerOptions: {
              module: 'esnext',
              moduleResolution: 'node',
              resolveJsonModule: true,
              isolatedModules: true,
              noEmit: true,
              jsx: 'preserve',
            },
            reportFiles: [
              '**',
              '!**/*.json',
              '!**/__tests__/**',
              '!**/?(*.)(spec|test).*',
              '!src/setupProxy.js',
              '!src/setupTests.*',
            ],
            watch: paths.appSrc,
            silent: true,
            formatter: typescriptFormatter,
          }),
      ].filter(Boolean),
      node: {
        dgram: 'empty',
        fs: 'empty',
        net: 'empty',
        tls: 'empty',
        child_process: 'empty',
      },
      performance: false,
    };
阅读 9.2k

推荐阅读