阅读 1530

webpack4心得编译篇&体积篇

背景

众所周知,webpack作为主流的前端项目利器,从编译到打包提供了很多方便的功能。本文主要从编译和体积两个篇章阐述笔者总结的实践心得,希望对大家有帮助。

编译篇

vendor文件单独打包

vendor文件即依赖库文件,一般在项目中很少改动。单独打包可以在后续的项目迭代过程中,保证vendor文件可从客户端缓存读取,提升客户端的访问体验。

解决方案:通过在vendor.config.js文件中定义,在webpack.config.{evn}.js中引用来使用。 vendor.config.js示例

module.exports = {
  entry: {
    vendor: ['react', 'react-dom', 'react-redux', 'react-router-dom', 'react-loadable', 'axios'],
  }
};

复制代码

vendor文件预打包

vendor单独打包之后,还是有一个问题。编译的过程中,每次都需要对vendor文件进行打包,其实这一块要是可以提前打包好,那后续编译的时候,就可以节约这部分的时间了。

解决方案:定义webpack.dll.config.js,使用 DLLPlugin 提前执行打包,然后在webpack.config.{evn}.js通过 DLLReferencePlugin 引入打包好的文件,最后使用AddAssetHtmlPlugin往html里注入vendor文件路径 webpack.dll.config.js示例

const TerserPlugin = require('terser-webpack-plugin');
const CleanWebpackPlugin = require("clean-webpack-plugin");
const webpack = require('webpack');
const path = require('path');
const dllDist = path.join(__dirname, 'dist');

module.exports = {
  entry: {
    vendor: ['react', 'react-dom', 'react-redux', 'react-router-dom', 'react-loadable', 'axios', 'moment'],
  },

  output: {
    path: const dllDist = path.join(__dirname, 'dist'),
    filename: '[name]-[hash].js',
    library: '[name]',
  },
  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: false,
      }),
    ],
  },
  plugins: [
    new CleanWebpackPlugin(["*.js"], { // 清除之前的dll文件
      root: dllDist,
    }),
    new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
    new webpack.DllPlugin({
      path: path.join(__dirname, 'dll', '[name]-manifest.json'),
      name: '[name]',
    }),
  ]
};

复制代码

webpack.config.prod.js片段

const manifest = require('./dll/vendor-manifest.json');
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');
...
plugins: [
    // webpack读取到vendor的manifest文件对于vendor的依赖不会进行编译打包
    new webpack.DllReferencePlugin({
      manifest,
    }),
    // 往html中注入vendor js
    new AddAssetHtmlPlugin([{ 
      publicPath: "/view/static/js",  // 注入到html中的路径
      outputPath: "../build/static/js", // 最终输出的目录
      filepath: path.resolve(__dirname, './dist/*.js'),
      includeSourcemap: false,
      typeOfAsset: "js"
    }]),
]
复制代码

js并行编译与压缩

webpack对文件的编译处理是单进程的,但实际上我们的编译机器通常是多核多进程,如果可以充分利用cpu的运算力,可以提升很大的编译速度。

解决方案:使用happypack进行多进程构建,使用webpack4内置的TerserPlugin并行模式进行js的压缩。

说明:happypack原理可参考http://taobaofed.org/blog/2016/12/08/happypack-source-code-analysis/

webpack.config.prod.js片段

const HappyPack = require('happypack');
// 采用多进程,进程数由CPU核数决定
const happThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });
...
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: false,
      }),
    ]
},
module: {
    rules: [
      {
        test: /.css$/,
        oneOf: [
          {
            test: /\.(js|mjs|jsx)$/,
            include: paths.appSrc,
            loader: 'happypack/loader',
            options: {
              cacheDirectory: true,
            },
          },
        ]
      }
    ]
},
plugins: [
    new HappyPack({
      threadPool: happThreadPool,
      loaders: [{
        loader: 'babel-loader',
      }]
    }),
]
复制代码

体积篇

按需加载

当js页面特别多的时候,如果都打包成一个文件,那么很影响访问页面访问的速度。理想的情况下,是到相应页面的时候才下载相应页面的js。

解决方案:使用import('path/to/module') -> Promise。调用 import() 之处,被作为分离的模块起点,意思是,被请求的模块和它引用的所有子模块,会分离到一个单独的 chunk 中。

说明: 老版本使用require.ensure(dependencies, callback)进行按需加载,webpack > 2.4 的版本此方法已经被import()取代

一般例子

按需加载demo,在非本地的环境下开启监控上报

if (process.env.APP_ENV !== 'local') {
  import("./utils/emonitor").then(({emonitorReport}) => {
    emonitorReport();
  });
}
复制代码

react例子

react页面按需加载,可参考http://react.html.cn/docs/code-splitting.html,里面提到的React.lazy,React.Suspense是在react 16.6版本之后才有的新特性,对于老版本,官方依然推荐使用react-loadable实现路由懒加载

react-loadable示例

import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import Loadable from 'react-loadable';
import React, { Component } from 'react';
// 经过包装的组件会在访问相应的页面时才异步地加载相应的js
const Home = Loadable({
  loader: () => import('./page/Home'),
  loading: (() => null),
  delay: 1000,
});
import NotFound from '@/components/pages/NotFound';

class CRouter extends Component {

  render() {
    return (
      <Switch>
          <Route exact path='/' component={Home}/>
          {/* 如果没有匹配到任何一个Route, <NotFound>会被渲染*/}
          <Route component={NotFound}/>
      </Switch>
    )
  }
}

export default CRouter

复制代码

vue例子

vue页面按需加载,可参考https://router.vuejs.org/zh/guide/advanced/lazy-loading.html

示例

// 下面2行代码,没有指定webpackChunkName,每个组件打包成一个js文件。
const ImportFuncDemo1 = () => import('../components/ImportFuncDemo1')
const ImportFuncDemo2 = () => import('../components/ImportFuncDemo2')
// 下面2行代码,指定了相同的webpackChunkName,会合并打包成一个js文件。
// const ImportFuncDemo = () => import(/* webpackChunkName: 'ImportFuncDemo' */ '../components/ImportFuncDemo')
// const ImportFuncDemo2 = () => import(/* webpackChunkName: 'ImportFuncDemo' */ '../components/ImportFuncDemo2')
export default new Router({
    routes: [
        {
            path: '/importfuncdemo1',
            name: 'ImportFuncDemo1',
            component: ImportFuncDemo1
        },
        {
            path: '/importfuncdemo2',
            name: 'ImportFuncDemo2',
            component: ImportFuncDemo2
        }
    ]
})
复制代码

css预加载

做完按需加载之后,假如定义的分离点里包含了css文件,那么相关css样式也会被打包进js chunk里,并通过URL.createObjectURL(blob)的方式加载到页面中。

假如n个页面引用了共同的css样式,无形中也增加n倍的 css in js体积。通过css预加载,把共同css提炼到html link标签里,可以优化这部分的体积。

解决方案:把分离点里的页面css引用(包括less和sass)提炼到index.less中,在index.js文件中引用。假如使用到库的less文件特别多,可以定义一个cssVendor.js,在index.js中引用,并在webpack config中添加一个entry以配合MiniCssExtractPlugin做css抽离。

P.S. 假如用到antd或其他第三方UI库,按需加载的时候记得把css引入选项取消,把 style: true选项删掉

示例

cssVendor片段

// 全局引用的组件的样式预加载,按需引用,可优化异步加载的chunk js体积
// Row
import 'antd/es/row/style/index.js';
// Col
import 'antd/es/col/style/index.js';
// Card
import 'antd/es/card/style/index.js';
// Icon
import 'antd/es/icon/style/index.js';
// Modal
import 'antd/es/modal/style/index.js';
// message
import 'antd/es/message/style/index.js';
...
复制代码

webpack.config.production片段

  entry:
   {
      main: [paths.appIndexJs, paths.cssVendorJs]
   },
  plugins: [
    new HappyPack({
      threadPool: happThreadPool,
      loaders: [{
        loader: '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' },
            ],
        
          ],
          cacheDirectory: true,
          cacheCompression: true,
          compact: true,
        },
      }],
    })]
复制代码

按需打包

我们在项目的开发中经常会引用一些第三方库,例如antd,lodash。这些库在我们的项目中默认是全量引入的,但其实我们只用到库里的某些组件或者是某些函数,那么按需只打包我们引用的组件或函数就可以减少js相当大一部分的体积。

解决方案:使用babel-plugin-import插件来实现按需打包,具体使用方式可参考https://github.com/ant-design/babel-plugin-import

示例

{
    test: /\.(js|jsx)$/,
    include: paths.appSrc,
    loader: require.resolve('babel-loader'),
    exclude: /node_modules/,
    options: {
      plugins: [
        ['import', [
          { libraryName: 'lodash', libraryDirectory: '', "camel2DashComponentName": false,  },
          { libraryName: 'antd', style: true }
        ]
        ],
      ],
      compact: true,
    },
}
复制代码

忽略不必要的文件

有些包含多语言的库会将所有本地化内容和核心功能一起打包,于是打包出来的js里会包含很多多语言的配置文件,这些配置文件如果不打包进来,也可以减少js的体积。

解决方案:使用IgnorePlugin插件忽略指定资源路径的打包

示例

new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
复制代码

压缩

压缩是一道常规的生产工序,前端项目编译出来的文件经过压缩混淆,可以把体积进一步缩小。

解决方案:使用TerserPlugin插件进行js压缩,使用OptimizeCSSAssetsPlugin插件css压缩

说明:webpack4之前js压缩推荐使用ParalleUglifyPlugin插件,它在UglifyJsPlugin的基础上做了多进程并行处理的优化,速度更快;css压缩推荐使用cssnano,它基于PostCSS。因为css-loader已经将其内置了,要开启cssnano去压缩代码只需要开启css-loader的minimize选项。

示例

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,
    },
  }),
]
复制代码

抽离共同文件

在很多chunks里,有相同的依赖,把这些依赖抽离为一个公共的文件,则可以有效地减少资源的体积,并可以充分利用浏览器缓存。

解决方案:使用SplitChunksPlugin抽离共同文件

P.S. webpack4使用SplitChunksPlugin代替了CommonsChunkPlugin 示例

optimization: {
  splitChunks: {
    chunks: 'all',
    name: false
  }
}

复制代码

SplitChunksPlugin的具体配置可参考 juejin.im/post/5af15e…

开启Scope Hoisting(作用域提升)

Scope Hoisting 是webpack3中推出的新功能,可以把依赖的代码直接注入到入口文件里,减少了函数作用域的声明,也减少了js体积和内存开销

举个栗子 假如现在有两个文件分别是 util.js:

export default 'Hello,Webpack';
复制代码

和入口文件 main.js:

import str from './util.js';
console.log(str);
复制代码

以上源码用 Webpack 打包后输出中的部分代码如下:

[
  (function (module, __webpack_exports__, __webpack_require__) {
    var __WEBPACK_IMPORTED_MODULE_0__util_js__ = __webpack_require__(1);
    console.log(__WEBPACK_IMPORTED_MODULE_0__util_js__["a"]);
  }),
  (function (module, __webpack_exports__, __webpack_require__) {
    __webpack_exports__["a"] = ('Hello,Webpack');
  })
]

复制代码

在开启 Scope Hoisting 后,同样的源码输出的部分代码如下:

[
  (function (module, __webpack_exports__, __webpack_require__) {
    var util = ('Hello,Webpack');
    console.log(util);
  })
]
复制代码

从中可以看出开启 Scope Hoisting 后,函数申明由两个变成了一个,util.js 中定义的内容被直接注入到了 main.js 对应的模块中。

解决方案:webpack4 production mode会自动开启ModuleConcatenationPlugin,实现作用域提升。

Tree Shaking

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)

有的时候,代码里或者引用的模块里包含里一些没被使用的代码块,打包的时候也被打包到最终的文件里,增加了体积。这种时候,我们可以使用tree shaking技术来安全地删除文件中未使用的部分。

使用方法:

  • 使用 ES2015 模块语法(即 import 和 export)。
  • 在项目 package.json 文件中,添加一个 "sideEffects" 属性。
  • 引入一个能够删除未引用代码(dead code)的压缩工具(minifier)(例如 UglifyJSPlugin)。

分析工具

在体积优化的路上,我们可以使用工具来分析我们打包出的体积最终优化成怎样的效果。 常用的工具有两个:

  • webpack-bundle-analyzer 使用需要在webpack.config.js中配置
plugins: [
  new BundleAnalyzerPlugin()
]
复制代码

执行完build后会打开网页,效果图如下:

  • source-map-explorer source-map-explorer是根据source-map去分析出打包文件的体积大小,在本地调试是时设置 devtool: true,然后执行source-map-explorer build/static/js/main.js则可以去分析指定js的体积。效果图如下:
关注下面的标签,发现更多相似文章
评论