1

如何使用 webpack 优化 lodash

lodash提供了很多可用的方法供我们使用,绝对是一个很好用且用起来得心应手的工具库。但是同时,lodash的体积也不小,我们项目中使用的大概522K,可能只是使用了几个方法,但是却把整个lodash库引入了。为了吃几条鱼,就承包了整个鱼塘,代价有点大呀!

lodash库结构目录

|-- lodash
        |-- fp  // lodash Functional Programming
                |-- debounce.js // CommonJS
                |-- ...
        |-- debounce.js // CommonJS
        |-- endsWith.js
        |-- ...
        |-- lodash.js // OOP CommonJS
        |-- lodash.min.js
        |-- uniqBy.js
        |-- ...

lodash/fp

(1)使用lodash.*

import debounce from 'lodash.debounce';

lodashnpm上同时也发布了以lodash.为前缀的包,他们将lodash的每个函数,单独作为一个包发布了出去。 显然这些包只包含他们所用到功能的代码而非整个lodash

当你正在写一个给外部使用的库,仅需要使用到lodash中的几个函数,那么这种方法会让库的使用者更快地安装依赖

(2)手动按需引入lodash/*

lodashpackage.json中,对于每个lodash中的函数,都通过exports字段,暴露了对应的入口,因此我们可以改变引入的方式

import debounce from 'lodash/debounce';

如此,在构建的时候仅会引入debounce函数相关的代码。 这种方法手工的成分过大,对已有项目引入的优化需要变动到较多的代码,且无法使用全局替换完成。

(3)使用lodash-es

import { debounce, throttle, padStart } from 'lodash-es';

比起lodashlodash-es使用了ES module组织模块,构建工具构建时在做体积优化(tree shaking)的时候,通过对模块的依赖分析,能将lodash包中未使用到的模块都移除掉。

此种方法不需要像引入手动按需引入一样改变使用习惯,保留了ES module按名称引入的写法。

如果是一个新的项目,或没有使用babel编译源代码(用了swc, esbuild, tsc等等),那么lodash-es便是最佳选择。

对于已有项目,它需要修改业务代码。不过,这种修改只做简单的全局搜索替换即可完成。

(4)借助 lodash-webpack-pluginbabel-plugin-lodash插件优化

如果你倾向于 import { head } from 'lodash' 的写法,而且想继续使用 lodash,又需要按需加载的能力,可以引入 babel-plugin-lodash 插件,它的作用就是帮你把你的 import 写法自动转化成按需加载的形式

babel-plugin-lodash 做了什么

这个 babel 插件做的事件从结果上说比较简单,就是做了下面的转化:

import { head } from 'lodash';
// 或者
import _ from 'lodash';
// +
_.head(...)

// 转化为↓↓↓

import head from 'lodash/head';
head(...)

似乎还不够小

然而,现实往往不是很理想,在实际使用中你会发现,只引入了 lodash 的几个方法,从数量上看还不到总量的零头,但是好像 lodash 的一大半都被打包进去了。

这并不是错觉,以大家都熟悉的 map 为例(但是并不推荐使用,请使用语言内置的 map):

import map from 'lodash/map'
map()

压缩后体积 32K,快一半 lodash 的大小了。简单分析一下,会发现它一共打包了 121 个 lodash 模块。

刚看到这个结果的时候肯定会非常惊讶,因为在大家印象中 map 的实现应该很简单,几行代码就能搞一个出来。

进一步查看文档和源码,会发现 _.map 的功能远比我们想象的复杂,这里举几个例子:

_.map({ key1: 1, key2: 2 }, x => x) // [1, 2]

_.map([
  { id: 1, age: 12 }, 
  { id: 1, age: 13 }, 
  { id: 1, age: 12 },
], { age: 12 }) // [true, false, true]

_.map([{a: {b: 11}}, {a: {b: 22}}], 'a.b')

121 个模块,就是为了实现这些奇奇怪怪的需求而引入的。

lodash-webpack-plugin

考虑到大多数人在使用时,并不会用到这些特殊的用法,但是不得不为大量的冗余代码买单。lodash 就提供了 lodash-webpack-plugin 这个神奇的插件,在引入插件之后,打包的代码量从 32K 降低到了 1K,去除 Webpack 的运行时代码,只剩下不到 200 字节,减少了超过 99%。

而引入 lodash-webpack-plugin 后,map 方法的其他各种奇奇怪怪的用法就失效了,只剩下最基本的类似 Array map 的用法。

另一个例子,是 _.clamp 方法,它的功能是把某个数限制在某个区间。在正常情况下,这个方法能接收字符串并自动转化成数字,所以下面的代码会返回 20:

clamp('123', '1', '20') // 20
// 等价于 clamp(123, 1, 20) // 20

而一旦引入 lodash-webpack-plugin 后,它的返回值就变成了字符串 '123'

另一个例子是:

const sortBy = require('lodash/sortBy');
sortBy([{id: 3}, {id: 1}, {id: 2}], x => x.id);

类似的使用前后差异,还有很多。

lodash-webpack-plugin 做了什么

整体来说这个 Plugin 的代码量并不多,稍微花一些时间多多少少就能知道它做了哪些事情。

首先,需要简单了解下 Webpack 的基本流程:

Webpack 在打包前,会从入口模块开始,针对每个模块使用 loader 处理得到标准 js 文件内容,再对这个文件进行语法树分析,拿到它的依赖,然后解析(resolve)出依赖的真实路径,然后对这个依赖进行一样的处理(广度优先遍历),最后得到一个依赖图(以及每个依赖压缩前的内容)。

lodash-webpack-plugin 插件做的事,就是在 webpackafterResolve 钩子中,把某些 lodash 模块的资源路径替换掉,牺牲一些不常用的接口用法,达到见效打包体积的目的。

比如 map.js 会被替换成 _arrayMap ,顾名思义,替换之后它只能用来处理数组(或者类数组)。

更多的情况是,某模块 A 依赖的内部模块 _B 会被替换,这会导致 A 的核心逻辑可用,但是涉及到和 _B 相关的那一部分特性不被支持。

一个简单的例子,是 clamp 模块会依赖 toNumber 进行参数处理,也就是说它支持传入字符串参数,并在内部先处理成数字。但是使用 Plugin 后, Plugin 会把 toNumber 替换成 identity(即a => a),导致 clamp 不再支持字符串参数。如果传入的是字符串,返回的结果将发生变化。

Plugin 会默认移除(即替换成假模块)一大堆特性,同时提供了一个配置项让用户可以指定保留某些特性。相关的替换规则维护在 lodash/lodash-webpack-plugin/src/mapping.js 中。

也就是说,使用 lodash-webpack-plugin 之后,你的 lodash 就相当于变成了 严格模式 + 精简模式。和标准的 Webpack 并不完全一样。

你可能会问:「我只使用基础的 Webpack 功能,而且我使用之后肯定有测过,插件只是约束了我的用法而已,应该不会产生什么问题吧?」

大部分人可能都会这么认为(甚至我觉得插件作者也是这么想的),然而这种想法是错的。接下来你会知道,lodash-webpack-plugin 存在严重的隐患,不建议在任何项目中使用它。

lodash-webpack-plugin 的坑

坑一:影响第三方模块的行为

如果第三方模块中也使用了 lodash 模块,而且用到了某些非常规用法,一旦使用了 Plugin 后,这个第三方模块使用的 lodash 的执行逻辑就可能发生变化。产生的后果可能是立即报错,也可能产生更严重的后果,即返回了和预期不一致的值,这个错误值在一系列流转之后,在另一个地方产生了 BUG。一旦出现了这种情况,因为这是一个第三方模块,问题的排查可能会非常困难。

仅凭这一点,就完全有充足的理由拒绝使用 lodash-webpack-plugin 了。毕竟为了区区几十 K 的代码大小,给自己的项目埋下一个雷,并不是一个明智的选择。

然而,lodash-webpack-plugin 的坑不只如此。

试想一下,如果你在 A 页面引入了一个 lodash 模块,甚至只是引入了一个第三方库,(或者是删除),导致功能逻辑完全不相干的 B 页面出现了 BUG,你会是怎样的心情?

对,如果你在使用 lodash-webpack-plugin,就是存在这样的可能。下面会分析,在文章结尾会给出一些示例代码。

坑二:自动检测并配置特性

Plugin 很「贴心」地为用户提供了「自动保留特性」的能力,拿 clamp 的例子来说,如果你的是引入 clamp ,那么它依赖的 toNumber 模块会被替换成 identity。而如果你直接引入并使用 toNumber,则 toNumber 不会被替换成 identity

那么,如果我们既引入了 clamp,又引入了 toNumber ,结果会怎么样?

结论是:一般情况下,toNumber 会被替换成 identify

这样会导致一个结果:

原本你使用的 clamp 是不支持处理字符串的,但是在你引入 toNumber 后,它变得支持字符串了。

也许你会说,这看上去貌似不太会产生问题,因为 clamp 的功能是向后兼容的。但是万一是反过来的,你原本正常使用字符串参数,然后又删掉了 toNumber 呢?

一句话描述:就是在你引入/删除某个第三方模块(或者 lodash 模块),你的另一个不相干的代码逻辑(或者第三方模块)可能发生变化。

坑三:插件内在缺陷,导致常规使用也会产生 BUG

前面提到,Plugin 是在 Webpack 遍历解析模块的时候进行路径替换的,而模块的遍历是有先后顺序的。那么遍历顺序会影响到最终的替换结果吗?

经过简单的测试,发现还真会。

以在我们代码中同时引入 toNumberclamp 为例,toNumber 会被 resolve 两次,一次是来自我们自己的代码,另一次是来自 clamp. 我们需要构建两种代码结构,控制这两次 resolve 的先后顺序。

构造代码结构的逻辑是很简单的,因为 webpack 是广度优先遍历的,我们需要哪个模版被更靠后 resolve,把这个模块多套几层 import 就行。

下面的代码在不同的代码结构下会出现两种完全不同的结果:

// 下面两部分代码在不同文件中:
require('lodash/toNumber')('12')
require('lodash/clamp')('123', '1', '20')

可能会分别返回数字 1220, 或者分别返回字符串 '12''123'.

后面这种结构的结果很显然是错误的。这里列一下文件结构:

// 文件 index.js 
require('lodash/clamp')
require('./a')

// 文件 a.js
require('./b')

// 文件 b.js
const toNumber = require('lodash/toNumber')
console.log([
  `toNumber('123')`,
  toNumber('123'),
]) // 这个 toNumber 是错的,它返回了字符串 '123'

总结

是否应该继续使用 lodash-webpack-plugin,结论已经很明显了。

我想说的是,就算现在没有发现上面这些问题,只看 lodash-webpack-plugin 那 100 多行的人肉维护的替换配置项,就足够你严肃考虑是否应该在生产环境使用它。

其他建议

  1. 如果你的项目代码量足够大,或者 lodash 使用得足够多,那么插件带来的优化可能已经不明显了。全量引入(打包到 vendor dll)可能是更优的选择,还能获得打包速度的提升(在使用 dll/external 的情况下继续使用 babel 插件或者手写 lodash/xx 甚至会导致负优化。
  2. You-Dont-Need-Lodash-Underscore 或者 ramda 等替代方案。
  3. 等待 lodash 5

为什么你应该立即停止使用 lodash-webpack-plugin


记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。