你构建的代码为什么这么大?

date
Oct 3, 2022
slug
how-to-reduce-code-volume
status
Published
tags
summary
type
Post

前言

代码体积直接影响浏览器各类性能指标,减慢页面渲染速度,增加用户内存使用消耗。内存的增加又会更频繁的触发V8引擎的GC机制,进而影响页面交互性能。本文从工程化的角度出发,帮助我们找到构建产物体积变大的常见原因和对应的解决思路,减少项目代码构建后的体积

Babel

babel最常见的用途就是代码降级,使构建后的代码能够被低版本浏览器兼容,这块分两部分
  1. API降级
  1. 语法降级
构建后的代码为了适配低版本浏览器通常会比源代码大上几倍,这里面除了源代码外还包含api垫片和语法辅助函数,我们看下如何减少这部分的代码体积

core-js

💡
按照目前最新版本的babel@7,@babel/polyfill已经废弃,我们使用core-js完成api的语法降级
core-js可以为浏览器中可能不兼容的API提供垫片,例如Promise,Map
notion image
在降级api调用前require对应的core-js模块,就可以以污染全区局变量或者原型链的方式实现api降级
手动插入core-js即麻烦又不安全,所以我们可以使用@babel/preset-env帮助我们自动插入core-js模块
notion image
@babel/preset-env根据项目中browserlist定义的用户环境,选择性插入垫片代码,减少垫片代码体积
在配置@babel/preset-env时,useBuiltIns属性非常重要,有两个值"usage"|"entry"

entry

entry非常直接,首先我们需要手动在代码的第一行import 'core-js',在执行编译时,会按照browserlist中定义的环境,把需要降级的api一次性插入并替换到core-js声明的位置
notion image
通过entry的配置开发者不需要手动插入垫片,但这有个问题,即没有使用的api仍然会被打进去,由于Ecmascript标准的不断发展,core-js在g-zip压缩后也有50kb左右的体积

usage

当选择usage时,babel会扫描所有需要编译的js,根据实际使用到的api选择性插入所需垫片,看起来是entry的更优解,但实际过于理想
  1. 通常基于编译速度的考虑,node_modules下的模块不会参与babel编译,仅参与webpack打包,如果此时恰巧某个依赖没有声明所需的垫片,那么线上环境就可能存在 xxx is undefined异常。实际上这种情况在混乱的npm生态中非常普遍,有不少依赖使用tsc打包,除非开发者手动介入,否则构建产物中就会缺少api垫片,遇到这种情况只能在线上发现异常后手动添加依赖到babel.include中进行编译
  1. 并不是所有JS代码都会参与编译,例如通过一些平台动态下发的脚本,这些平台动态下发的代码完全不经过编译,如果使用了未经降级的api也可能会出现xxx is undefined异常
可以看到entry,usage都是存在问题的,所以也就有了平台化的方案,polyfill.io。如果使用最新的现代化浏览器访问该服务,那么返回的js内容则是空的,反之它会响应浏览器所需的降级api,既控制了包体积,也能确保未经编译的js获得降级api。目前polyfill.io的node.js代码是完全开源的,支持自部署,实际落地还需要考虑缓存和异常兜底
notion image

@babel/runtime

core-js是为了解决api降级问题存在的,但是我们显然还有语法降级需要解决,例如class,async 默认情况下babel为了实现class功能会生成一些内联辅助函数,例如下图的createClass,这会产生一个问题,就是当多个模块都使用class语法时则会生成多个相同的辅助函数,辅助函数不能复用
notion image
我们可以通过注册babel插件@babel/plugin-transform-runtime,将硬编码辅助函数的方式改为从@babel/runtime引入辅助函数,实现不同模块间辅助函数的复用
notion image
从下图可以看到createClass函数从硬编码改为require("@babel/runtime/helpers/createClass"),代码大幅缩小
notion image
@babel/plugin-transform-runtime的方案也不是毫无问题,和api降级一样,同样面临各种依赖包构建不标准带来的困扰
最大的问题就是没有办法保证依赖包的产物一定使用了@babel/plugin-transform-runtime进行构建,语法降级使用了内联的辅助函数,又或者使用了老版本的babel-runtime,导致项目最终的构建产物对辅助函数进行了多次打包
以相对常见的依赖包构建工具father-build和tsc为例,他们都没有将语法辅助函数通过@babel/runtime依赖包进行提取,而是都以硬编码的形式存在每个JS模块当中,这个问题社区的npm包我们不好处理,但是可以通过收敛公司内部构建工具的方式,统一处理公司内部维护的依赖包,使它们构建的产物符合应用打包的需求
如下图,@company/app-builder负责构建应用,@company/module-builder负责构建依赖包,然后通过使用封装的babel配置@company/babel-base,统一处理JS编译
notion image
babel-base关闭core-js的api降级,由app-builder开启平台polyfill.io方案,同时babel-base开启@babel/plugin-transform-runtime,为应用和依赖包开启语法辅助函数抽离
通过这种方式,我们就可以实现当下代码降级方案的最佳实践,减少因为降级方案增加的JS代码

Tree-shaking

tree-shaking 是减少构建产物体积最有效的方式,以常用lodash为例,g-zip后的体积24kb,但是项目中使用到的函数并不多,如果能够为它启用tree-shaking,代码体积能控制在1kb以内
如何为依赖代码启用tree-shaking?
  1. package.json 声明module字段,地址指向ESM规范的构建产物
  1. package.json 声明sideEffects:false,告诉Webpack整个依赖包没有存在副作用,或者指明存在副作用模块的地址

ESM

ESM相比commonjs具备静态分析能力, 这是tree-shaking的前置依赖条件,所以我们需要babel构建我们的源代码时保留import语法,不要编译成commonjs
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules: false // 保留import语法
      }
    ]
  ]
}
💡
这部分逻辑可以放在我们之前说的babel-base和module-builder中处理

sideEffects

为什么依赖包的package.json需要声明sideEffects?
这里需要引申出自函数式编程中的纯函数副作用函数概念,如果我们的代码都是没有副作用的纯函数,tree-shaking确实可以不需要类似sideEffects的副作用声明,但实际上副作用普遍存在我们的代码中,如果只依据函数是否被引用过作为DCE的条件,很容易影响程序运行的正确性
通过css-loader引入css文件是很典型的例子
import './button.css'
对于webpack来说button.css同样是一个模块,这里没有引用任何的具名函数,但是引入css模块是会为我们带来一个副作用,它会为html插入一个style标签。如果webpack认为他是没有副作用的,那么在minify阶段webpack会删除这行代码,最终导致样式错乱
为了告诉webpack这个css文件是存在副作用的,不能删除,sideEffects就可以怎么写
{
	 "sideEffects": [
	    "*.css",
	    "*.less",
	 ]
}
公司内部维护的依赖相比开源社区,很容易忽略sideEffects的声明,如果存在公司内部的依赖构建工具,可以将sideEffects添加到相关的模板代码中,默认为依赖包开启tree-shaking
回到社区现状我们再来看tree-shaking,lodash推出了支持tree-shaking的lodash-es,antd@4也不再需要安装babel-plugin-import插件,通过tree-shaking的方式原生支持代码按需加载,从而大幅缩小构建体积

package duplication

依赖重复打包是前端开发中非常常见的问题
当我们的项目中存在Root→C→D@2.0.0,Root→B→D@3.0.0类似的依赖关系时,node_module结构如下
node_modules
  -- C
  -- D@2.0.0
  -- B
    -- node_modules
      -- D@2.0.0
  
可以看到在node_modules下嵌套安装了2个版本的依赖D,即D@2.0.0D@3.0.0。这可能导致在构建的产物中也同样存在两份相同依赖不同版本的代码
解决方式就是升级B的依赖D@2.0.0→D@3.0.0,此时重新安装后node_modules的嵌套结构会恢复扁平
node_modules
  -- C
  -- D@3.0.0
  -- B
  
我可以使用find-duplicate-dependencieswebpack-bundle-analyzer这些工具辅助我们排查依赖重复打包的问题

总结

本文从构建工具的角度,阐述了如何减少构建产物的体积。可以看到仅仅处理应用的构建是不够的,为了实现最佳效果,我们还需要介入公司内部依赖包的构建,使依赖包的构建产物符合应用构建的需求。只有具备全场景的构建能力才能最大程度降低代码的构建体积。

© 文西 2021 - 2022