如何使用 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';
lodash
在npm
上同时也发布了以lodash.
为前缀的包,他们将lodash
的每个函数,单独作为一个包发布了出去。 显然这些包只包含他们所用到功能的代码而非整个lodash
。
当你正在写一个给外部使用的库,仅需要使用到lodash
中的几个函数,那么这种方法会让库的使用者更快地安装依赖
(2)手动按需引入lodash/*
lodash
的package.json
中,对于每个lodash
中的函数,都通过exports
字段,暴露了对应的入口,因此我们可以改变引入的方式
import debounce from 'lodash/debounce';
如此,在构建的时候仅会引入debounce
函数相关的代码。 这种方法手工的成分过大,对已有项目引入的优化需要变动到较多的代码,且无法使用全局替换完成。
(3)使用lodash-es
import { debounce, throttle, padStart } from 'lodash-es';
比起lodash
,lodash-es
使用了ES module
组织模块,构建工具构建时在做体积优化(tree shaking
)的时候,通过对模块的依赖分析,能将lodash
包中未使用到的模块都移除掉。
此种方法不需要像引入手动按需引入一样改变使用习惯,保留了ES module
按名称引入的写法。
如果是一个新的项目,或没有使用babel
编译源代码(用了swc
, esbuild
, tsc
等等),那么lodash-es
便是最佳选择。
对于已有项目,它需要修改业务代码。不过,这种修改只做简单的全局搜索替换即可完成。
(4)借助 lodash-webpack-plugin
,babel-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
插件做的事,就是在 webpack
的 afterResolve
钩子中,把某些 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
遍历解析模块的时候进行路径替换的,而模块的遍历是有先后顺序的。那么遍历顺序会影响到最终的替换结果吗?
经过简单的测试,发现还真会。
以在我们代码中同时引入 toNumber
和 clamp
为例,toNumber
会被 resolve 两次,一次是来自我们自己的代码,另一次是来自 clamp
. 我们需要构建两种代码结构,控制这两次 resolve
的先后顺序。
构造代码结构的逻辑是很简单的,因为 webpack
是广度优先遍历的,我们需要哪个模版被更靠后 resolve
,把这个模块多套几层 import
就行。
下面的代码在不同的代码结构下会出现两种完全不同的结果:
// 下面两部分代码在不同文件中:
require('lodash/toNumber')('12')
require('lodash/clamp')('123', '1', '20')
可能会分别返回数字 12
和 20
, 或者分别返回字符串 '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 多行的人肉维护的替换配置项,就足够你严肃考虑是否应该在生产环境使用它。
其他建议
- 如果你的项目代码量足够大,或者
lodash
使用得足够多,那么插件带来的优化可能已经不明显了。全量引入(打包到vendor dll
)可能是更优的选择,还能获得打包速度的提升(在使用dll/external
的情况下继续使用babel
插件或者手写lodash/xx
甚至会导致负优化。 - You-Dont-Need-Lodash-Underscore 或者 ramda 等替代方案。
- 等待
lodash 5
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。