一、老项目升级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.requestthis.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-utilsgetOptions适配

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可以直接返回一个对象,包含sourcemap;但Webpack5里,必须用this.callback来传SourceMap,或者返回一个包含sourcemapmeta的对象。 举个处理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-utilsgetOptions绑定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的逻辑不对。 第四,升级完后一定要测试生产环境,不要只测开发环境,因为开发环境和生产环境的配置不一样,比如生产环境会压缩代码、混淆代码,可能会出问题。