React+Webpack性能优化

4,743 阅读3分钟

本文主要讲下React配合Webpack的一些优化,原项目在这里,有空会持续更新,欢迎关注和start,另外还有个无法使用HtmlWebpackPlugin插入chunks的issues请求哪位大佬帮忙解决下,谢谢~

构建优化

loaders

  • 尽量少使用不同的loaders/plugins
  • 使用 include 字段指明要转换的目录,使用exclude排除目录:
module.exports = {
  ...
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve(__dirname, 'src'),
        exclude: path.resolve(__dirname, 'node_modules'),
        loader: 'babel-loader'
      }
    ]
  }
};

resolve

  • 尽量减少resolve.modules, resolve.extensions, resolve.mainFiles, resolve.descriptionFiles的值的数量

  • resolve.modules:

    使用resolve.modules指定模块目录的路径:

    module.exports = {
        ...
        resolve: {
          modules: [path.resolve(__dirname, 'node_modules')]
        }
    };
    
  • resolve.alias:

    resolve.alias使Webpack直接使用库的压缩版本,不再对库进行解析,还可以使用别名方便引用文件:

    module.exports = {
        ...
        resolve: {
          alias: {
            Components: path.resolve(__dirname, 'src/components/'),
            Utils: path.resolve(__dirname, 'src/utils/'),
            react: patch.resolve(__dirname, './node_modules/react/dist/react.min.js')
          }
        }
    };
    

    例如这样就可以直接使用React的压缩版本,每次构建时不必再次解析。还可以通过别名引用文件,而不必再打长长的引用路径:

    import ReactComponent from 'Components/ReactComponent';
    

    但这样的缺点是会无法使用Tree-Shaking,所以一般对React这种整体性比较强的库使用比较好,而像lodash这样的工具库还是使用Tree-Shaking去除多余代码。

  • resolve.extensions:

    设置要解析文件后缀,默认值为:

    module.exports = {
        ...
        resolve: {
          extensions: ['.wasm', '.mjs', '.js', '.json']
        }
    };
    

    可以设置为自己要解析的文件类型,加快寻找速度:

    module.exports = {
        ...
        resolve: {
          extensions: ['.js', '.json', 'jsx']
        }
    };
    

externals

使用externals可以防止某些库被打包,而通过其他方式引用库(如CDN),这样做的好处是当更新代码时不会影响库代码的缓存,用户只需下载新的代码即可。当然我们也可以使用chunk来把不常更新的库打包在另一个文件,我们下面再讲。

例如,从CDN引入React:

<script crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js" defer></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js" defer></script>
<script src="./dist/index.js" defer></script>
module.exports = {
    ...
    externals: {
      react: 'React',
      'react-dom': 'ReactDOM'
    },
}

devtool

使用devtool是很耗性能的,如果不需要用到它的话就不要设置它,如果需要用到且质量要很好可设为source-map,不过这是非常耗时的,如果可以接受质量比较差的话,可使用cheap-source-map,官方推荐使用的是性能比较好质量比较差的cheap-module-eval-source-map

splitChunks

Webpack 4之后把公共代码提取工具从CommonChunksPlugin换成更好的SplitChunksPlugin。下面这个例子不使用externals,而是把React和ReactDOM提取到公共模块代码。

module.exports = {
  ...
  // externals: {
  //   react: 'React',
  //   'react-dom': 'ReactDOM'
  // },
  optimization: {
    ...
    splitChunks: {
      chunks: 'all',
      name: true,
      automaticNameDelimiter: '-',  // 模块间的连接符,默认为"~"
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10  // 优先级,越小优先级越高
        },
        default: {  // 默认设置,可被重写
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true  // 如果本来已经把代码提取出来,则重用存在的而不是重新产生
        }
      }
    }
  },
}

mode

mode可取值有:

  • production:构建模式,会自动启用一些构建相关的插件,如压缩代码。
module.exports = {
+  mode: 'production',
-  plugins: [
-    new UglifyJsPlugin(/* ... */),
-    new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify("production") }),
-    new webpack.optimize.ModuleConcatenationPlugin(),
-    new webpack.NoEmitOnErrorsPlugin()
-  ]
}
  • development:开发模式,会启动一些开发相关的优化插件。
module.exports = {
+ mode: 'development'
- devtool: 'eval',
- plugins: [
-   new webpack.NamedModulesPlugin(),
-   new webpack.NamedChunksPlugin(),
-   new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify("development") }),
- ]
}
  • node

babel、Tree-Shaking

这里使用的版本为babel 7。因为现在大多数浏览器都已经支持ES6的语法,所以如果所有代码都转为ES5的话可能会产生大量的多余代码,所以这里只转换部分代码,那要兼容低版本的浏览器怎么办呢,别急,下面会讲到一些解决办法,我们先来看下babel配置:

{
    "presets":  [
        [
            "@babel/react",
            {
                "modules": false  // 关闭babel的模块转换,才能使用Webpack的Tree-Shaking功能
            }
        ]
    ],
    "plugins": [
        "@babel/plugin-proposal-class-properties",  // class,这个要放在前面,否则可能会报错
        "@babel/plugin-transform-classes",  // class
        "@babel/plugin-transform-arrow-functions",  // 箭头函数
        "@babel/plugin-transform-template-literals"  // 字符串模板
    ]
}

当一些库的package.jsonsideEffects有设置时,就可以很好地支持Tree-Shaking,如lodash:

{
  "name": "lodash",
  "sideEffects": false
}

happypack

使用happypack可开启多线程来加速处理loader:

var HappyPack = require('happypack');

module.exports = {
    ...
    rules: [
      {
        test: /\.(js|jsx)$/,
        include: path.resolve(__dirname, 'src'),
        exclude: path.resolve(__dirname, 'node_modules'),
        use: 'happypack/loader?id=babel'
      },
    ],
    plugins: [
      new HappyPack({
        id: 'babel',
        loaders:['babel-loader?cacheDirectory']
      }),
    ],
}

其他

把代码构建到ES6+

上面说到转换代码到ES5的话会很耗时且可能有很多多余代码,因为现在大多数浏览器都已经支持ES6语法,现在我们来看看如何兼容较低版本的浏览器。

  1. modulenomodule:

可以使用<script type="module" src="index.js"></script>来加载ES6+的代码,因为支持这个属性的浏览器必定会支持async/awaitPromiseclass这些属性,而不支持的浏览器则会选择忽略它,不进行加载。

所以也还需要一份ES5的脚本来兼容低版本的浏览器,使用<script nomodule src="index.es5.js"></script>来加载ES5代码,可以识别nomodule的浏览器会忽略它,而不能识别它的低版本浏览器则会加载它。这样就可以做到兼容到低版本的浏览器而较新的浏览器使用代码量少很多的ES6+代码。

但是这个方法也有缺点:当使用splitChunks把代码分为较多的模块时,需要产生大量两个版本的代码。

  1. 动态polyfill
<script src="https://cdn.polyfill.io/v2/polyfill.min.js"></script>

它会通过分析请求头信息中的 UserAgent 实现自动加载浏览器所需的 polyfills。如果你使用较新的版本访问上面的连接会发现没有多少代码,而用IE则会产生很多。这样我们就可以使用ES6+的代码和动态polyfill来兼容低版本浏览器,但是动态polyfill不能支持class和箭头函数等等这些特性,所以就需要按上面那样配置babel来把这些转换成ES5的。想知道更多动态polyfill可以点这里

开发优化

避免使用构建时才使用到的工具

有一些工具在开发时是不需要用到的,如果用了可能会大大减慢生成代码的速度,如UglifyJsPlugin,在开发时不需要将代码进行压缩,还有以下工具也避免在开发时用到:

  • UglifyJsPlugin
  • ExtractTextPlugin
  • [hash]/[chunkhash]
  • AggressiveSplittingPlugin
  • AggressiveMergingPlugin
  • ModuleConcatenationPlugin

不要输出路径信息

module.exports = {
  // ...
  output: {
    pathinfo: false
  }
};

关闭部分构建优化

module.exports = {
  ...
  optimization: {
    removeAvailableModules: false,
    removeEmptyChunks: false,
    splitChunks: false,
  }
};

React优化

因为React的HTML元素都是写在JS文件中,所以一般导致构建出的JS文件非常大,而在加载和执行JS的漫长过程中,用户的浏览器一直显示的都是白屏状态,首屏渲染的时间变得非常的长,不使用服务端渲染的话可以按以下方法进行一些改善。

添加首屏loading

可通过使用HtmlWebpackPlugin插件来为html文件添加loading,而不至于白屏。

var loading = {
  ejs: fs.readFileSync(path.resolve(__dirname, 'template/loading.ejs')),
  css: fs.readFileSync(path.resolve(__dirname, 'template/loading.css')),
};

module.exports = {
    ...
    plugins: [
        new HtmlWebpackPlugin({
          template: path.resolve(__dirname, 'template/index.ejs'),
          hash: true,
          loading: loading,  // 在React渲染完前添加loading
        }),
        new ScriptExtHtmlWebpackPlugin({  // 给script标签加上defer
          defaultAttribute: 'defer'
        }), 
    ]
}

具体的模板代码看这里

prerender-spa-plugin

prerender-spa-plugin可以生成单页面应用的首屏到HTML,原理是通过puppeteer访问相应路径抓取相应的内容,这里因为我一直装不上puppeteer,所以就不深入讲了。

module.exports = {
    ...
    new PrerenderSpaPlugin(
      // Absolute path to compiled SPA
      path.resolve(__dirname, '../dist'),
      // List of routes to prerender
      ['/']
    )
}

React Loadable

可以使用它来动态import React的组件,可以把一些不是那么重要的组件先分离到chunks,然后再动态引入,可以提升渲染首屏的速度:

import Loading from './src/components/Loading';
import ReactDOM from 'react-dom';
import Loadable from 'react-loadable';

const LoadableApp = Loadable({
  loader: () => import('./src/App'),
  loading: Loading,
});

ReactDOM.render(LoadableApp, document.querySelector('#root'));

暂时就写这么多优化的地方,以后有空会持续更新,有什么问题欢迎一起讨论~