Webpack 5 升级实验

575 阅读6分钟
原文链接: zhuanlan.zhihu.com

今天尝试把我们团队的通用构建工具reskript做了webpack 5的升级,使用最新的5.0.0-alpha.23版本。

先发一个实测的构建性能数据,测试环境为我自己的MacBook Pro 13寸高配:

项目脚手架,构建后产出544KB的脚本,首次构建用时39.62秒,第二次构建用时22.84秒。
一个经典的单页应用,构建后产出3.5MB的脚本,首次构建用时299.33秒,第二次构建用时82.70秒。

总结果来看,Webpack 5的长效缓存效果非常明显

这本不是一个容易的事情,我们在构建上做了大量的定制化的工作,包括:

  1. 使用一大堆插件,比如case-sensitive-paths-webpack-plugin等。
  2. 通过DefinePlugin进行了大量的“动态”内容的处理,包括整个process.env、构建时可声明的Feature Matrix信息,以及构建时的git版本、时间等内容。
  3. 封装了core-js版本、react-hot-loader版本等,有很多包的路径不在产品开发者想象的位置,以及包的版本固定的问题。

由于升级的过程比较枯燥,无非就是调试、修改、继续调试,所以这边只简单的罗列几个升级时的典型问题。

插件不兼容

case-sensitive-paths-webpack-plugin插件并不兼容新版本,在GitHub上已经有相应的Issue。好在这个功能并不影响实际的构建,纯粹是一个防御性的措施,直接去掉就好了。

优化配置不同

  • optimization. occurrenceOrder已经废弃了,如有配置可以直接删掉。
  • HashedModuleIdsPlugin也已经没用了,新的模块ID生成算法应该是优于这个的,具体稳定性没喊待测试

HTML插件不可用

html-webpack-plugin用不了,可以参考这个Issue。这个问题可大了,总不能构建完没有HTML页面吧。

不过好在这问题不难修复,具体代码是index.js中的145行,把这一行中的compilation.compilationDependencies.add修改为compilation.fileDependencies.add就可以正常运行了。

考虑到就是升级做个实验,所以没有把修改后的版本发包,等官方修改吧。

复杂规则失效

我们有一个规则是这样的:

{
    test: /\.[jt]sx?$/,
    oneOf: [
        {
            resource: {
                and: [
                    {include: projectDirectory},
                    {exclude: projectDirectory + 'externals'},
                    {exclude: /\/node_modules\//},
                ],
            }
            oneOf: [
                {
                    test: /\.worker\.[jt]sx?$/,
                    use: use('worker', 'babel', 'eslint'),
                },
                {
                    use: use('babel', styleName && usage === 'devServer' && 'styleName', 'eslint'),
                },
            ],
        },
        {
            test: testOfPackages(sourceCompilePackages, cwd),
            use: use('babel', 'eslint'),
        },
        {
            test: thirdParties,
            exclude: /\/node_modules\/monaco-editor\//,
            use: use(usage === 'devServer' && hot === 'all' && 'hot', 'sourceMap'),
        },
    ],
}

在Webpack 5中,oneOf.resource.and下面不能用includeexclude了(不知道为什么,没有查到任何和这有关的变更记录)。解决的方法是把resource改成函数:

{
    resource(resource) {
        return resource.includes(projectDirectory)
            && !resource.includes(projectDirectory + '/externals')
            && !resource.includes('/node_modules/');
}

没有Node兼容

Webpack 5中移除了Node模块的兼容

webpack 5 stops automatically polyfilling these core modules and focuses on frontend-compatible modules.

在实际构建时,如果遇到类似于const {Buffer} = require('buffer')的代码,会提示新版本不会再对它进行自动的兼容,由你来选择是否安装相应的库并通过resolve.alias配置:

{
    resolve: {
        alias: {
            buffer: 'buffer',
        },
    },
}

但是,Webpack只解决引入模块的代码, 不解决全局变量的检测,这是和之前版本最大的区别。比如有代码是这样的:

exports.isBuffer = Buffer.isBuffer;

Webpack 5并不会认为这里用到了Buffer这个对象需要处理兼容性,而是正常地进行打包,也不提示开发者。直到系统运行时,才会出现Buffer is not defined这样的错误。

同时,由于内置的NodeSourcePlugin已经修改了实现,现在只会处理global这一个变量,所以即便把这个插件找回来也不会再帮你修复这些全局变量的使用了,我们只能自己想办法去处理。

在这里推荐babel-plugin-import-globals这个babel插件,它可以找到相关的全局变量并进行处理。我们至今发现的只有Bufferprocess这两个被使用,所以配置是这样的:

{
    loader: require.resolve('babel-loader'),
    options: {
        sourceType: 'unambiguous', // 这个一定要配,自动处理es和js模块
        compact: false, // 这个建议配,能提升性能
        plugins: [
            [
                require.resolve('babel-plugin-import-globals'),
                {
                    'process': require.resolve('process'),
                    'Buffer': {moduleName: require.resolve('buffer'), exportName: 'Buffer'},
                },
            ],
        ],
    },
}

将这个配置加到node_modules下的JavaScript文件上就行,如:

{
    test: /\/jsx?$/,
    include: /node_modules/,
    use: [loader],
}

当然这会让所有第三方的代码也过babel的处理(虽然只有一个插件),会被babel解析,一定程度上会影响构建的速度。babel处理的速度与原来的NodeSourcePlugin的处理孰优孰劣我也无法再做比较了。

除此之外,在resolve.alias下也需要配上对应的一些兼容库:

{
    crypto: 'crypto-browserify',
    stream: 'stream-browserify',
    vm: 'vm-browserify',
}

配置缓存

Webpack 5最令我期待的功能就是长效缓存,通过相关的配置打开:

{
    cache: {
        type: 'filesystem'
    }
}

但这样打开后,缓存会过于固定,引起一系列问题:

  • mode之类的变化无法响应,缓存不会变。
  • 如果根据不同的场景,有不同的babel配置等,也同样不会感知,依然会用旧的缓存。
  • 使用DefinePlugin注入的动态内容,全部不会变化。

而要处理这些“动态性”,我们需要2个东西。

第一个是cache.version的配置,这个配置可以告诉webpack内容有了变化,需要重新处理缓存,如mode或babel配置之类的,可以通过不同的version隔离开来。

最简单的cache.version的算法是webpack.config.jsnode_modules/.yarn-integrity做一下哈希,但我们封装了webpack的能力,所以并不存在一个固定的webpack.config.js,就必须手动实现它,我们当前的算法是:

const computeCacheKey = (entry: BuildEntry): string => {
    const hash = crypto.createHash('sha1');
    hash.update(entry.usage); // 使用场景,如build、dev等
    hash.update(entry.mode);
    hash.update(entry.hostPackageName); // 主包名,会用在一些import语句上
    hash.update(fs.readFileSync(path.join(entry.cwd, 'settings.js'))); // 用户的定制化配置
    hash.update(fs.readFileSync(path.join(entry.cwd, 'node_modules', '.yarn-integrity'))); // 依赖信息
    return hash.digest('hex');
};

要保持这个算法稳定,并且在动态的信息变化时一定会发生改变。

cache.version不能用来处理注入的内容,如果把DefinePlugin消费的东西都放进去,比如我们的构建还有当时的时间戳,这就会让版本号就会永远变化,起不了缓存的作用。

解决的办法是使用DefinePlugin.runtimeValue函数。这个函数其实V4的时候就有,藏得挺隐蔽的,甚至@types/webpack的定义信息中都没有它,以至于为了用它我们不得不这么搞:

const runtimeValue: (compute: () => string, dynamic: boolean) => any = (DefinePlugin as any).runtimeValue;

使用的方法是这样:

const defines = {
  'process.env.foo': DefinePlugin.runtimeValue(() => JSON.stringify(process.env.foo), true),
};

new DefinePlugin(defines);

注意runtimeValue调用的第2个参数,此处用true表示“这是一个始终会变的值”,它也可以传一些文件的路径来让值和文件是否变化建立关系。这样做了以后,有遇到process.env.foo的文件会在构建时排除在缓存外,而其它上下游的文件依然可以缓存,这里不会出现“因为入口文件有一个动态内容,所以下面其它文件都不能缓存”这样尴尬的情况,并不怎么影响缓存的使用和性能。

总结

Webpack 5的升级并不难,一些细节和插件的兼容性是主要问题,也可以将插件兼容性的修复再反馈回社区,与社区一起成长。

当做现在Webpack 5还存在一些问题,比如构建我们一个单页系统用掉了2.5GB内存,不设置--max-old-space-size参数都跑不下去。因此建议做一下升级的尝试,处理好兼容问题,以便正式版发布的时候能快速迁移,但不要直接用在生产环境上。