一、老项目升级Webpack5踩坑:自定义Loader的“水土不服”
不少人接手老项目时,都会有个执念:把Webpack从3、4升级到5。毕竟Webpack5改了缓存机制、压缩逻辑,还加了一堆优化,跑起来能快不少。但升级过程中最容易卡壳的,就是老项目里自己写的自定义Loader——本来在Webpack4里跑得好好的,升级后要么直接报错,要么输出的东西完全不对。
先给大家说个真实场景:我之前接手的一个2019年的后台项目,用的是Webpack4,里面有个自定义Loader叫prefix-loader,作用是把所有业务代码里的相对路径(比如./components/Button)自动补成项目的绝对路径(比如src/components/Button),当时是为了避免开发时路径写错。结果升级到Webpack5后,一打包直接报Cannot read property 'request' of undefined,找了半天才发现是Webpack5对Loader的参数传递逻辑改了。
1.1 先搞懂:什么是自定义Loader?
很多人可能对自定义Loader没概念,先给大家用大白话讲:Webpack就像个快递分拣中心,每个文件(比如JS、CSS)就是快递,自定义Loader就是分拣员自己写的“特殊分拣规则”——比如有的快递要贴特殊标签、有的要拆包检查,这些规则Webpack自带的分拣员(内置Loader)没有,就得自己写。
举个最简单的自定义Loader例子(Webpack4兼容版),这个Loader的作用是把JS代码里的console.log全部替换成console.debug,方便开发时统一日志级别:
// 【Webpack4兼容版】custom-log-loader.js
// 这个Loader的作用:把JS代码里的console.log替换成console.debug
module.exports = function(source) {
// source是Webpack传进来的当前文件的源码字符串
// 把所有console.log替换成console.debug
const modifiedSource = source.replace(/console\.log/g, 'console.debug');
// 返回修改后的源码给Webpack
return modifiedSource;
};
这个Loader在Webpack4里用的话,只要在webpack.config.js里加配置就行:
// 【Webpack4】webpack.config.js 配置
module.exports = {
module: {
rules: [
{
test: /\.js$/, // 匹配所有JS文件
use: [
// 用我们写的自定义Loader
{
loader: require.resolve('./custom-log-loader.js'),
},
],
},
],
},
};
这在Webpack4里完全没问题,但升级到Webpack5后,一打包就会报错:Error: The 'this' object used by custom loaders is not available in Webpack 5 without context。
1.2 核心矛盾:Webpack5对Loader的“底层规则”改了
Webpack5改了两个关键逻辑,直接导致老自定义Loader不兼容:
第一个是this上下文的变化。Webpack4里,Loader函数里的this(比如this.request、this.resourcePath)是Webpack自动绑定的,能直接用;但Webpack5为了优化性能,把Loader的this改成了“可选绑定”——如果你的Loader没有明确说要用到Webpack的上下文,就不会给你传,直接是undefined。
第二个是参数传递的变化。Webpack4里,Loader函数可以接收两个参数:source(源码)和map(SourceMap);但Webpack5如果Loader用了this上下文,就必须返回一个对象,或者用this.callback来传结果,不能直接返回字符串了。
还是拿刚才的custom-log-loader.js举例,Webpack5里直接用这个Loader,会报this不存在的错,因为我们没声明要用到Webpack的上下文。
二、先解决:自定义Loader的“临时兼容”方案
如果老项目里的自定义Loader特别多,或者依赖了很多第三方的Loader(比如之前写的Loader用到了某个Webpack4的API),直接全改太费时间,这时候可以用“临时兼容”的办法,先让项目能跑起来,再慢慢改。
2.1 临时兼容的核心:用loader-utils的getOptions适配
Webpack5里有个专门的工具包loader-utils,里面的getOptions方法可以帮我们获取Loader的配置,同时能适配Webpack4和Webpack5的this上下文。我们先把刚才的custom-log-loader.js改成临时兼容版:
// 【Webpack5临时兼容版】custom-log-loader.js
// 依赖loader-utils,先装:npm install loader-utils
const { getOptions } = require('loader-utils');
module.exports = function(source) {
// 用getOptions获取Loader的配置,同时会自动绑定Webpack的this上下文
const options = getOptions(this);
// 假设配置里有个enable参数,控制是否替换日志
if (options.enable === false) {
return source; // 不替换,直接返回原源码
}
// 替换console.log为console.debug
const modifiedSource = source.replace(/console\.log/g, 'console.debug');
// Webpack5如果用了this,必须用this.callback返回结果,不能直接返回字符串
// this.callback的参数:(err, result, sourceMap, meta)
this.callback(null, modifiedSource);
};
改完后,在Webpack5的配置里用这个Loader,就不会报错了,而且能正常工作。这个方案的好处是改的代码少,能快速让项目跑起来,但缺点是没有用到Webpack5的新特性,比如缓存优化,所以只是临时用的。
2.2 临时兼容的适用场景和注意事项
这个方案适合两种情况:一是老项目里的自定义Loader逻辑特别复杂,依赖了很多Webpack4的API,一时半会儿改不动;二是项目上线时间紧,必须先保证功能正常,再慢慢优化。
注意事项:一是必须装loader-utils,而且版本要选兼容Webpack5的(比如^3.0.0);二是如果Loader里用到了this.resourcePath(当前文件的路径)、this.context(当前文件的目录)这些属性,必须用getOptions(this)来绑定上下文,不然还是会报错。
三、再优化:自定义Loader的“Webpack5原生适配”
临时兼容只是权宜之计,要用到Webpack5的新特性,比如缓存优化、性能提升,必须把自定义Loader改成Webpack5原生适配的版本。Webpack5对Loader的要求其实更规范,改完后反而更稳定。
3.1 原生适配的核心:用this.getOptions替代loader-utils
Webpack5在Loader的this上下文里加了一个原生的getOptions方法,不需要再依赖loader-utils了,而且性能更好。我们把刚才的custom-log-loader.js改成Webpack5原生适配版:
// 【Webpack5原生适配版】custom-log-loader.js
module.exports = function(source) {
// Webpack5原生的this.getOptions,不需要依赖第三方包
const options = this.getOptions();
// 控制是否替换日志
if (options.enable === false) {
// Webpack5原生适配版可以直接返回字符串,也可以用this.callback
return source;
}
// 替换console.log为console.debug
const modifiedSource = source.replace(/console\.log/g, 'console.debug');
// 可以直接返回修改后的源码
return modifiedSource;
};
这个版本的Loader完全适配Webpack5,不需要依赖loader-utils,而且能用到Webpack5的缓存优化——如果源码和配置都没变,Webpack5会直接用缓存,不会再执行这个Loader,打包速度会快很多。
3.2 原生适配的进阶:处理SourceMap
如果你的项目用到了SourceMap(比如开发时调试代码),Webpack5对Loader返回SourceMap的要求也变了。Webpack4里,Loader可以直接返回一个对象,包含source和map;但Webpack5里,必须用this.callback来传SourceMap,或者返回一个包含source、map、meta的对象。
举个处理SourceMap的原生适配Loader例子:
// 【Webpack5原生适配版】custom-log-loader.js(带SourceMap)
const { SourceMapConsumer, SourceMapGenerator } = require('source-map');
module.exports = function(source, map) {
const options = this.getOptions();
if (options.enable === false) {
// 直接返回原源码和原SourceMap
this.callback(null, source, map);
return;
}
// 替换console.log为console.debug
const modifiedSource = source.replace(/console\.log/g, 'console.debug');
// 处理SourceMap:如果原SourceMap存在,就生成新的SourceMap
if (map) {
// 把原SourceMap转换成SourceMapConsumer对象,方便操作
const consumer = new SourceMapConsumer(map);
// 生成新的SourceMapGenerator对象
const generator = new SourceMapGenerator({
file: this.resourcePath, // 新SourceMap对应的文件路径
});
// 把原SourceMap的映射关系复制到新的SourceMap里
consumer.eachMapping((mapping) => {
generator.addMapping(mapping);
});
// 把新SourceMap转换成JSON对象
const newMap = generator.toJSON();
// 用this.callback返回修改后的源码和新SourceMap
this.callback(null, modifiedSource, newMap);
} else {
// 没有原SourceMap,直接返回修改后的源码
this.callback(null, modifiedSource);
}
};
这个Loader能正常处理SourceMap,开发时调试代码不会出问题,而且完全适配Webpack5。
四、最后落地:老项目升级Webpack5的“渐进式迁移步骤”
很多人升级Webpack5时会直接全量替换,结果一出错就找不到问题,其实最好的办法是“渐进式迁移”——先保证项目能跑起来,再慢慢优化,最后完全适配Webpack5。具体步骤如下:
4.1 第一步:先跑通项目,临时兼容所有自定义Loader
这一步的核心是“先能用,再好用”。具体做三件事:
第一,升级Webpack、Webpack Dev Server、相关的Loader和Plugin到Webpack5兼容的版本。比如把webpack从^4.46.0升级到^5.88.0,把webpack-dev-server从^3.11.0升级到^4.15.1。
第二,把所有自定义Loader改成临时兼容版,也就是用loader-utils的getOptions绑定this上下文,用this.callback返回结果。
第三,把Webpack配置里的mode改成development,先跑开发环境,保证功能正常。如果开发环境跑通了,再改成production跑生产环境。
4.2 第二步:逐个优化自定义Loader,改成原生适配版
这一步的核心是“逐个改,逐个测”。具体做三件事:
第一,先选一个逻辑最简单的自定义Loader(比如刚才的custom-log-loader),改成原生适配版,也就是用Webpack5原生的this.getOptions,去掉对loader-utils的依赖。
第二,改完后跑开发环境,测试这个Loader的功能是否正常,比如替换日志是否生效,SourceMap是否正常。
第三,测试没问题后,再改下一个自定义Loader,直到所有自定义Loader都改成原生适配版。
4.3 第三步:开启Webpack5的新特性,优化性能
所有自定义Loader都改成原生适配版后,就可以开启Webpack5的新特性了,比如缓存优化、压缩优化、Tree Shaking等。具体做三件事:
第一,开启Webpack5的缓存优化,在webpack.config.js里加配置:
// 【Webpack5】webpack.config.js 缓存配置
module.exports = {
cache: {
type: 'filesystem', // 用文件系统缓存,下次打包时直接用缓存
buildDependencies: {
config: [__filename], // 配置文件变化时,缓存失效
},
},
};
第二,开启Webpack5的压缩优化,用TerserPlugin替代原来的uglifyjs-webpack-plugin:
// 【Webpack5】webpack.config.js 压缩配置
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
optimization: {
minimize: true,
minimizer: [new TerserPlugin()],
},
};
第三,开启Webpack5的Tree Shaking,把mode改成production,Webpack5会自动开启Tree Shaking,去掉没用的代码。
4.4 第四步:测试生产环境,上线验证
所有配置都改完后,跑生产环境,测试所有功能是否正常,比如页面是否能正常打开,接口是否能正常调用,日志是否正常,SourceMap是否正常。测试没问题后,就可以上线了。
五、方案总结与注意事项
5.1 两种方案的优缺点对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 临时兼容方案 | 改的代码少,能快速让项目跑起来 | 依赖第三方包,没有用到Webpack5的新特性,性能提升有限 | 项目上线时间紧,自定义Loader逻辑复杂 |
| 原生适配方案 | 完全适配Webpack5,性能好,不需要依赖第三方包 | 改的代码多,需要逐个测试 | 项目有足够的时间优化,追求性能 |
5.2 升级过程中的注意事项
第一,升级前一定要备份项目,最好用Git做版本控制,改完后如果出问题,能快速回滚。
第二,升级过程中不要同时改太多东西,最好改一个测一个,比如先改Webpack的版本,测开发环境,再改自定义Loader,测功能,再改配置,测性能。
第三,如果遇到问题,先看Webpack的报错信息,大部分问题都能从报错信息里找到原因,比如this不存在的错,就是因为没有绑定上下文;SourceMap的错,就是因为处理SourceMap的逻辑不对。
第四,升级完后一定要测试生产环境,不要只测开发环境,因为开发环境和生产环境的配置不一样,比如生产环境会压缩代码、混淆代码,可能会出问题。
评论
围绕“老项目升级Webpack5遇到的自定义Loader不兼容问题及渐进式迁移步骤”参与讨论