使用 Lodash 正确姿势

2,811 阅读7分钟

前言

Lodash 是我们日常开发中使用频率较高的 JavaScript 工具库,它提供了很多工具函数用以操作数组、数字、对象、字符串等等,比如 merge 深拷贝、防抖、节流、空值校验等等,帮助我们节省了很多开发时间。

但是,Lodash 一直有一个令人诟病的问题:如果我们稍微不注意,在项目中直接引入 Lodash,导致我们的项目体积变得巨大无比,甚至构建产物中有些代码可能从始到终都没使用到。体积问题对于中后台应用还好,如果是移动端应用尤其在弱网条件下,可能导致页面性能变得非常差。

Lodash 官网上提供了多种 Lodash 模块方式给我们开发者选择使用,里面也有能让 Lodash 体积减小的方式: image.png 这么多种方式,究竟哪种是最好的呢?🤔

接下来我们来看看使用不同 Lodash 模块格式对我们的代码体积有怎样的影响,最终通过对比得到使用 Lodash 的最佳方式。

由于 lodash/fplodash-amd 使用频率不高,并且与 lodash 模块对项目体积影响是类似的,因此本文不作讨论

前置准备

这里准备了一段代码,代码中使用了两个常用的工具函数: mergemergeWith,并且使用 Webpack 构建工具对我们的源码进行打包,来模拟我们日常项目发布前对源码进行打包构建的过程。最后借助于 Webpack Bundle Analyzer 对最终产物体积、模块引入分布进行分析,进一步对比得出使用 Lodash 模块的正确姿势。

完整的代码示例请见仓库

//  merge 和 mergeWith 函数的导入会在下文中以不同方式导入
const object = {
  'a': [{ 'b': 2 }, { 'd': 4 }]
};

const other = {
  'a': [{ 'c': 3 }, { 'e': 5 }]
};

function customizer(objValue, srcValue) {
  if (Array.isArray(objValue)) {
    return objValue.concat(srcValue);
  }
}

console.log(merge(object, other));
console.log(mergeWith(object, other, customizer));

lodash 使用方式对比

lodash

首先,我们先使用最原始的方式:在源码中直接导入 lodash模块。导入方式如下:

import _ from 'lodash';
const merge = _.merge;
const mergeWith = _.mergeWith;

然后我们使用 webpack-bundler-analyzer 分析最终打进去 bundle 中 lodash 模块的体积有多大: image.png 可以发现,Gzipped 后的体积有 24.1K。我们在源码中只使用了 mergemergeWith两个工具函数,为什么会占用如此的大?难道没有通过 Webpack 提供的 Tree Shaking 把其他没用的工具函数移除掉吗?

翻看 lodash 模块源码,我们能发现,lodash模块使用的是 CommonJS 模块规范: image.png **Tree-shaking 生效的前提是 **ES Modules,以便在构建时候能对源码进行静态分析,不导出未引用的模块,并且结合压缩工具把未引用的代码去除。因此我们直接使用 loadsh模块,其他的工具函数也会被打包进来,导致我们 bundle 体积会非常的大。

lodash 独立方法包

如果我不需要其他 Lodash 工具函数,我只是使用其中某几个工具函数,我能只安装并使用这几个工具函数吗? 答案是可以的。Lodash官方把每个工具函数都分别单独打包成一个 bundle,并分别发了一个 npm 包,我们可以直接安装对应的 npm 包即可。比如:lodash.mergelodash.mergewith

我们引入 lodash 独立方法包:

import merge from 'lodash.merge';
import mergeWith from 'lodash.mergewith';

执行源码构建查看产物体积分布: image.png 可以发现两个模块加起来 Gzipped 后的体积是接近 6.78K 了,虽然与全量导入 lodash模块相比,减少了 17.32K(70%)。但是这里还有问题:lodash.mergelodash.mergewith两个模块内部共同引用了相同的模块: image.pngimage.png 由于 lodash.mergelodash.mergewith也都是 CommonJS 模块,发布 npm 包之前是分别以 merge.jsmergeWith.js文件为入口单独打包出产物,导致无法通过 Tree Shaking 移除掉相同重复的模块,简单来说,就是上面的 _baseMerge_createAssigner被打进去两次了。

Lodash 官方目前已经不推荐使用这种方式了,并且将会在 lodash V5 版本中移除。详细可以查看 Lodash 文档

babel-plugin-lodash

Lodash 官方推出一个 babel 插件:babel-plugin-lodash,它可以将我们的源码进行 transfrom 操作,按需引入模块,进一步减少体积(与社区的 babel-plugin-import 解决思路是类似的)。源码的使用方式和 babel 配置方式如下:

import _ from 'lodash';

console.log(_.merge(object, other));
console.log(_.mergeWith(object, other, customizer));
/**
 * @type {import('webpack').Configuration}.config
 */
module.exports = {
  // 省略其他配置
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            plugins: ['lodash'],
            presets: [['env']]
          }
        }
        
      }
    ]
  },
};

然后我们执行 webpack 构建,可以发现 Gzipped 后的体积是 4.1K,非常可观了,相比于全量导入 lodash 模块的方式体积减小了 20K(83%)。但是这种方式需要额外配置 babel-loader 和 babel plugin。 image.png

lodash 子模块

Lodash 官方还提供一种方式,通过 sub-path 的方式引入对应的 Lodash 子模块,比如:

import merge from 'lodash/merge';
import mergeWith from 'lodash/mergewith';

这种使用方式与上面使用 babel-plugin-lodash按需引入是类似的,只不过这个按需引入是我们自己处理而已罢了。体积占用也都是 4.1K。这里就不多分析了。

lodash-es

Lodash 官方提供一个 ES Module 版本的 lodash:lodash-es,给我们导入 lodash 模块。

import { mergeWith, merge } from 'lodash-es';

既然是 ES Module,那这样 Tree Shaking 就可以生效了,其他没用到的代码就可以移除掉了: image.png 可以看到,Gzipped 后的代码体积是 3.76K,相比于全量导入 lodash CommonJS 格式的产物,减小了 20.34K(85%)。

lodash-webpack-plugin

最后,Lodash 官方还提供一个 lodash-webpack-plugin,配合上面提到的 babel-plugin-babel能减小更多体积:

const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');

module.exports = {
  // 省略其他配置
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            plugins: ['lodash'],
            presets: [['env']]
          }
        }
        
      }
    ]
  },
  plugins: [
    new LodashModuleReplacementPlugin(),
  ],
};

查看打包后的体积: image.png 发现 Gzipped 后的体积只有 2.26K 了,体积减小了 21.76K(90%)。

但是查看 lodash-webpack-plugin 文档我们可以发现是,lodash-webpack-plugin 会把一些函数用体积较小的函数代替(比如使用 _nativeKeysIn() 代替 keysIn())。虽然产物体积大大减小了,但是一些功能直接就失效,如果你的三方依赖使用了 lodash 并且使用了对应的功能,由于 lodash-webpack-plugin 替换了部分方法,导致三方依赖无法正常工作,出现意想不到的表现,排查问题的效率也会大大降低的(更多可以参考这篇文章)。因此十分不推荐使用这种方式减小 Lodash 的体积。 image.png

总结

对上面的源码使用 Webpack 进行打包构建,不同 Lodash 使用方式的最后的产物体积之间的对比如下表所示:

Lodash 使用方式体积大小备注推荐指数
lodash24.1K产物体积大,因为是 CommonJS 模块,无用模块无法 Tree Shaking⭐️
lodash 独立方法包(如 lodash.merge)6.78K(↓70%)体积有所减少,但因为还是 CommonJS 模块,重复的模块也无法 Tree Shaking⭐️⭐️
lodash + babel-plugin-lodash4.1K(↓83%)体积减小了很多,不过要额外配置 babel-loader 和 babel plugin⭐️⭐️⭐️
lodash 子模块(如:import merge from 'lodash/merge')4.1K(↓83%)体积也减小了很多,但需要手动按需引入对应的子模块⭐️⭐️⭐️
loadsh-es3.76K(↓85%)产物是 ES Module,Tree Shaking 能生效,体积进一步减小,但一定程度上会影响构建速度⭐️⭐️⭐️
lodash-webpack-plugin2.26K(↓90%)虽然体积最小,但可能导致某些模块无法正常工作⭐️

从上表可以看到,如果我们直接使用 lodash 模块,由于它的产物格式是 CommonJS,无法让 Tree Shaking 生效以剔除未被使用的模块,导致最终产物体积会增大。不推荐直接使用。

使用 lodash 独立方法包的形式虽然体积有 70% 的减小,但是还是有重复的模块被打包进去 bundle 了。

使用 babel-plugin-lodash 插件或者 lodash 子模块的方式,不会把重复模块打包进去,体积进一步减小,不过有额外的配置成本。

目前大部分的前端项目都是使用 ES Module 的格式编写模块代码,并且在发布线上前都会经过打包工具(Webpack、Rollup、esbuild 等)打包构建出产物,而这些打包工具都支持 Tree Shaking。而 lodash-es 的产物就是 ESM,可以让 Tree Shaking 生效去掉未使用的模块,产物体积能进一步减少,使用 ESM 规范也符合目前前端发展的趋势。但是由于 Tree Shaking 是有消耗的,可能会影响构建速度。

lodash-webpack-plugin 虽然能让产物体积变得最小,但是可能让某些模块无法正常工作,并且排查难度较高,十分不推荐使用。

综合,如果在意产物体积或者性能,顺应前端社区使用 ESM 规范的趋势,建议使用 lodash-es;如果在意构建速度,建议使用 babel-plugin-lodash 或者 lodash 子模块的方式导入对应的工具函数。