Better

Ethan的博客,欢迎访问交流

前端拆包实践

虽然项目本身是基于 monorepo 设计,考虑到子包初期开发改动频繁,因此只是作为消费者项目的独立子目录存在,通过 README 进行说明。本身直接依赖于消费者项目的构建能力,由于业务需要,需要把相关子模块进行单独发包,方便复用。

背景

由于脱离宿主项目,你的源代码是作为 node_modules 的一部分存在,你编写的 TypeScript 要想直接被使用,则必须经过转译,因为几乎所有的脚手架创建的项目,出于性能考虑,都是是跳过对 node_modules 的转译,因此子包必须可以独立打包。与此同时,为保证代码质量,单测是不可缺少的,必须可以单独跑单测。因此我们关注的最核心的问题为

  • 独立构建
  • 独立测试
  • 版本发布

测试

我们先来看看测试,测试的技术选型是 jest,号称零配置,结果一跑就报错。

其实报错的原因也好理解,因为 jest 原本是为 node 而生测试工具,因此默认仅支持 cjs 语法,用到前端还是要加些配置的。

  • 支持 esm
  • 支持 typescript

注意:jest 如果发现 babel 配置,会默认使用 babel 进行转换

先了解常用的相关参数

  • watch vs watchAll
    • watch 仅运行相关改动文件单测
    • watchAll 运行所有单测
  • rootDir
    • 默认值:包含 jest 配置文件或 package.json 文件的根目录,如果均无,那就是 pwd 值
    • 有些场景,你可能想要设置为 src 或 lib,对应于你代码的位置
    • 配置路径时使用 <rootDir> 返回都是该值
  • roots
    • 定义一个路径列表,指定 jest 需要寻找文件的地方,默认值 [<rootDir>]
  • jest 高级配置其实非常的多,可选择参考 cra 的配置

有两种方式解决这个问题

  • 配置各种 babel,安装 babel-jest、@babel/core、@babel/preset-env,要支持 TypeScript 的话,还需要 @babel/preset-typescript
  • 直接使用 ts-jest,它且会做类型校验,因为 babel 对于 typescript 的支持只是把类型信息抹除,但不会做类型检查

我们测试一下上面两种方式运行单测的时间差别大不大

  • 使用 ts-jest,运行时间接近 35-40s
  • 仅使用 babel,速度直接到了 14-18s

为了速度考虑,选用 babel/preset-typescript,如果需要类型检查,再通过 tsc 命令检查。

在 monorepo 中使用 jest,你需要用到 projects 属性

  • 通过指定 projects 属性,你可以一次性跑所有包里的单测
  • 推荐给每个包中的配置指定 displayName 属性,这样日志更清晰

构建

打包工具选用 rollup,我们需要考虑的问题

  • 依赖关系:是否需要将依赖打包进源码,还是说作为同级依赖存在
  • 语法转译:babel 和 typescript 转译
  • 输出格式
  • 其他问题
    • @ 别名支持
    • css modules 样式问题、动态 import 问题
    • 是否生成 sourcemap

关于打包输出格式

  • 输出什么格式的产物呢?umd、commonjs、es,或者都提供,类似 antd(dist/es/lib)
  • 内部自身项目,提高构建速度,同时为避免丢失 tree-shaking,直接仅支持 esm 即可

ES 模块允许进行静态分析,从而实现像 tree-shaking 的优化,并提供诸如循环引用和动态绑定等高级功能。

关于输出目录:比较严谨的做法有,应用打包结果 build,库打包结果 dist。

rollup 本身的配置十分简单,只是我们需要搭配很多的 plugin 才能完成我们想要的效果

  • @rollup/plugin-node-resolve:node_modules 找包
  • @rollup/plugin-commonjs:增加对于 commonjs 支持
  • @rollup/plugin-babel:增加对于 babel 的支持
  • @rollup/plugin-typescript、rollup-plugin-typescript2、rollup-plugin-ts、rollup-plugin-dts、@rollup/plugin-sucrase
  • rollup-plugin-peer-deps-external:自动将 peer 依赖 external 化
  • rollup-plugin-styles:处理样式问题
  • 其他更多
    • @rollup/plugin-alias 别名设置
    • @rollup/plugin-url 静态资源路径处理
    • @rollup/plugin-image:使用 base64 对图片资源进行编码,大小可能会变大 33%,适用于针对小图处理
    • @rollup/plugin-json:json 文件转 js 对象
    • @rollup/plugin-strip:移除 log 代码
    • rollup-plugin-clear:编译前资源清除
    • rollup-plugin-delete:编译时资源清除
    • rollup-plugin-copy:复制资源

babel

关于 babel 需要了解的东西。

babel 做了哪些事情

  • 语法转换
  • 通过 polyfill 在目标环境中添加缺失的特性
  • 源码转换

babel 7.x 有很多重大更新,当下了解 7.x 即可,比如

  • proposal 语法特性
    • 之前我们基于 preset-stage 的预设,比如 stage0-3 等
    • 这种 stage 的方式很不稳定,因此废弃了 stage 预设,转而让用户自己选择使用哪个 proposal 特性的插件,带来了更多的明确性
  • es 标准特性
    • 告别对于 preset-es2015/es2016/es2017/latest 预设
    • 直接使用 preset-env 根据环境进行自动配置,关注目标用户平台(兼容哪些浏览器)比关注编译为哪份 es 标准要更易理解。它会根据 compat-table 和你设置的目标用户平台选择正确的插件
  • 所有官方插件和主要模块,都放在 @babel 命名空间下
  • @babel/runtime、@babel/plugin-transform-runtime
    • 将 polyfill 从 babel-transform-runtime 分离出来,@babel/runtime 仅包含 helpers,如果需要 core-js,需要主动安装
    • 你需要 @babel/plugin-transform-runtime 进行帮忙
  • ……

babel plugins

  • 负责代码转换功能,执行顺序:从前往后
  • @babel/plugin-transform-runtime
    • @babel/runtime:包含 babel 模块化运行时的帮助函数以及再生器运行时的一个版本。
    • 用于复用 babel 注入的 helper 函数
    • babel 在生成代码时,会插入一些帮助函数,比如 _extend/_createClass/_classCallCheck 等,这可能在每个文件中都有,这是可以被复用的。使用 @babel/plugin-transform-runtime 可以将这部分代码改为引用 babel/runtime 中的代码
    • 为你的代码创建一个沙盒环境,如果你直接导入 core-js,会污染你的全局作用域。转换器将这些内置的 core-js 进行别名,这样你就可以无缝地使用它们,而不需要 polyfill。
    • 配置
      • corejs:false,2 | 3 | { version: 2 | 3, proposals: boolean },默认 false
      • helpers:是否需要将 inline 的 helper 替换为模块名,默认为 true
      • regenerator:生成器函数的转换为一个不污染全局作用的生成器函数,默认为 true
  • @babel/plugin-external-helpers
    • 和 plugin-transform-runtime 类似,babel 团队计划进行移除

babel 预设

  • 一组 babel 插件或 options 配置的可共享模块
  • 执行顺序:从后往前
  • @babel/preset-env
    • 你可以使用最新的语法,你只需要设置目标环境即可,一切都是智能的,自动确定需要的插件并传给 babel
    • 默认会使用 .browserslistrc 配置,除非显示指定了 targets 或 ignoreBrowserslistConfig
    • targets:指定目标浏览器,这样只会为浏览器中没有的功能加载转换插件
    • esmodules:浏览器是否支持 esm,设置该值会导致 browsers、browserslist 的 targets 被忽略,这和顶层的 esmodules 属性不同
    • node:如果你希望根据当前的 node 版本进行编译,你可以声明为 true 或 current
    • modules:默认值 auto,将 esm 转换成其他模块类型,只有在你想要把本地原生的 esm 发布给浏览器时才需要,通常不需要管。设置为 false 表示禁用模块语句转换,也就是 esm => commonjs 的转换。
    • useBuiltIns:确定如何处理 polyfill,默认值为 false
      • usage:根据配置的浏览器兼容,以及你代码中用到的 API 来进行自动 polyfill 按需添加
      • entry:根据配置的浏览器兼容,引入浏览器不兼容的 polyfill。需要在入口文件手动添加 import '@babel/polyfill'。会自动根据 browserslist 替换成浏览器不兼容的所有 polyfill。这里需要指定 core-js 的版本, 如果 "corejs": 3, 则 import '@babel/polyfill' 需要改成新的语法 core-js/stable 以及 regenerator-runtime/runtime
      • false:如果引入 @babel/polyfill,则无视配置的浏览器兼容,需要手动引入所有的 polyfill
    • corejs 指定 corejs 的版本,只有在 useBuiltIns 设置为 usage 或 entry 时有效。默认只有稳定的 es 版本代码才会被 polyfill,针对提案阶段语法,需要额外配置
    • configPath:默认 process.cwd(),指定开始路径向上寻找 browserslist 配置,直到找到
  • @babel/preset-typescript
  • @babel/preset-react

配置文件

babel 中配置文件有两种类型

  • 全项目配置(Project-wide)
    • 指的是 babel.config.json,以及其他格式(.js,.cjs,.mjs)
    • monorepo 场景
  • 相对文件配置(File-relative)
    • 指的是 .babelrc.json,以及其他格式(.babelrc,.js,.cjs,.mjs)
    • sub-package 场景

.babelrc.json 的优先级高于 babel.config.json,这里有个合并规则

默认配置查找

  • Project-wide 查找
    • babel 有个 root 的概念呢,默认就是当前工作目录
    • 在根目录下寻找 babel.config.json 文件,引入了 rootMode 选项,以便在必要时进一步查找
    • 用户也可以通过 configFile 为 false 来禁用自动查找,或手动指定
    • 依赖于当前工作目录,对于 monorepo 而言,如果不是工作在 root 下,就会存在问题
  • File-relative 查找
    • 当前编译文件递归向上寻找,还会和 Project-wide 发生合并,存在两个限制
    • 限制1:一旦文件夹中找到了 package.json,就会停止搜索其他配置,babel 用 package.json 文件来划定 package 的范围
    • 限制2:这种搜索行为找到的配置,如 .babelrc 文件,必须位于 babel 运行的 root 目录下,或者是包含在 babelRoots 这个 option 配置的目录下,否则找到的配置会直接被忽略
    • 用户可以通过设置 babelrc 为 false 来禁用

在 monorepo 中使用 babel

  • 关键问题在于 babel 把你的工作目录当做它逻辑上的根目录了,所以当在一个特定子目录运行 babel 时候,会找不到配置
  • 使用 babel.config.json 更符合之前 .babelrc.json 工作方式,但如果工作目录不是 monorepo 根,它们需要启用 rootMode 选项。这很恶心,比如你要修改 jest、webpack 等使用的 babel 的工具链
  • 每个 sub-package 使用 .babelrc.json,此时你还需要在 babel.config.json 中通过 babelrcRoots 指定 sub-package 目录
  • 看完下来,我真是吐了,我宁肯手动执行配置,或者每个 package 都建立一个 babel.config.json。救救孩子吧。

配置解释

  • targets:指定目标浏览器环境,如果未指定,则会假定要支持最老的浏览器,推荐指定以减少输出体积
    • esmodules 指定目标浏览器是否支持 esm,它将会同目标环境做交集,以便将较小的脚本提供给用户
    • node:如果你希望根据当前的 node 版本进行编译,你可以声明为 true 或 current
    • safari
    • browser
  • browserslistConfigFile:自动应用 browserslist 文件或 package.json 中 browserslist 值作为环境
  • browserslistEnv:指定环境,这样不同环境就可以指定不同的配置了

CRA 在开发期间以最新的浏览器进行编译(为了速度),生产中以更大范围的浏览器进行编译(为了兼容)

rollup babel

再来看看 rollup babel 插件相关配置

  • 支持 babel 的配置项,且自动应用已有的配置文件,新增如下几项配置
  • exclude:排除文件,不会覆盖 babel 原有配置,只是额外添加
  • include:包含文件,不能包含已经被 babel 移除的文件
  • filter:函数形式进行文件筛选
  • extensions:注意 extensions 如果是针对 ts,则必须进行 ts/tsx 的添加,否则根本没处理,因为默认值不包括 ts
  • babelHelper
    • bundled:直接将源码打包仅结果代码
    • runtime:运行时添加
    • inline:不推荐,每个文件都会插入一遍,导致代码重复
    • external:谨慎使用,因为他会通过全局 babelHelpers 对象找 helper

rollup typescript

关于 typescript 的支持,我累计发现了 5 个包了

  • @rollup/plugin-typescript
    • 官方包,提到之前的版本使用 transpileModule API,速度更快,但没有类型检查,且某些语法缺失,具体见 @rollup/plugin-sucrase
    • @rollup/plugin-typescript 依赖 typescript 和 tslib
  • rollup-plugin-typescript2
    • 依赖 typescript 和 tslib
    • 文档中没有提到为什么会有这个包,我决定直接忽略
  • rollup-plugin-ts
    • 支持类型声明,尊重 browserslists,且支持无缝集成 babel、swc
    • 这个插件还算有点意思,文档非常详细且易懂,可以关注一下,旨在更方便的接入 ts,以及其他工具,以实现具有最小可能的计算开销的最佳行为。
  • rollup-plugin-dts
    • 本质是用于打包声明文件,且文档中说最好的使用方式是基于 typescript 编译器生成的 .d.ts 文件进行生成,虽然支持直接基于 ts(x) 文件进行生成,我只能说有点懵
    • 基于自己的模块解析,是 node-resolve 包一起使用,可能会导致问题,再次有点懵

tslib 是什么:类似于 @babel/runtime,包含一些运行时需要的 helper,可以避免重复声明,对于使用 TypeScript 的优化包,你绝对应该考虑使用 tslib 和 --importHelpers。

速度问题

同时配置了 ts 和 babel 之后,耗时竟然高达 19.3 秒,而我代码量很少,这实在是让人难以接受了。

  • 如果只用 ts 呢,约 8.4 秒
  • 如果只用 babel,约 8.7 秒,这还是没有生成 types 的结果
  • ts 确实十分耗时,没办法,最终选择在开发环境仅用 babel,在生产打包时才同时使用

why babel

现在 typescript 可以指定 target 为 es5,那为什么还需要 babel 吗?

  • 背景:在 babel7 之前,将两个独立的编译器链接在一起很困难,编译流程变为 TS > TS Compiler > JS > Babel > JS (again),在 babel7 之后,babel 支持了对于 typescript 转换,不做类型语法检查,直接抹除 typescript 类型信息,转换为常规的 javascript,继续自己的工作流
  • typescript 只能通过 target 进行设定 es5、es6 等,但 babel 的 babel-preset-env 支持根据目标环境转译语法,而不是锁定一组 JavaScript 功能
  • babel 能够根据目标环境自动添加 polyfill,而 typescript 是不支持的
  • typescript 不支持很多还在草案阶段的语法,这些需要通过 babel 插件来支持

发布

关于发布,我们需要进一步了解 package.json 相关字段

  • main:确定入口文件,但最早是服务于 node 环境的,因此指定的文件可能是 commonjs 格式的。于是有了下面的新字段
  • module:webpack 和 rollup 联合推出一个 module 字段,用于专门对应 es6 模块的入口文件
  • types:指定类型文件
  • files:指定上传哪些文件
  • directories:不会被 npm 直接使用,用于显示表达目录结构
  • scripts.prepare:当依赖被安装时,运行在 prepublish 之后,prepublishOnly 之前,常用于启动时编译代码(lerna/npm run build)
  • scripts.prepublish:在指定 publish 之前运行,同时无参数使用 npm i 本地安装依赖时运行,常用于发包时编译代码(npm run build)
  • scripts.prepublishOnly:仅仅在执行 publish 时运行
  • dependencies、devDependencies、peerDependencies

main vs module vs unpkg

  • main:官方字段,用于指定入口,编辑器提供导包路径提示也是基于该字段
  • module:非官方字段,rollup 提出,webpack2 开始提供同样的支持,如果指定了 module 字段,打包构建工具会从 module 中进行导入
  • unpkg:应该是并不标准也不通用,我目前仅发现 antd 使用该方式,用于指向存放在 dist 目录下直接供浏览器使用的 umd 格式,貌似是服务于 UNPKG cdn 的。

module 将指向一个具有 ES2015 模块语法的模块,但除此之外,该模块只具有目标环境支持的语法特性。简单来说只具有 import 和 export 语法,针对的一些新的语法,该转译还是得转译

为什么不直接将 module 内容指定到 main 字段呢,而是要新增一个字段,具体见 pkg.module

  • 主要原因是,有些项目是不具有构建能力的,尤其在 node 项目中更是常见,如果你将 main 指向 esm 模块,很有可能导致其报错
  • 新增该字段,一方面可以保证传统项目正常工作,第二方面如果项目中使用了构建工具,如 rollup 或 webpack2,则也不必妥协,双赢

type vs typings vs types

  • 声明模块类型:commonjs、module,这个对于外部包是选择 main 还是 module 没有帮助,主要是指模块源码采用那种类型,具体如下
  • types 和 typings:没啥好说的,用于声明 typescript 类型声明文件的位置

nodejs 将以下模块会被视为 esm 模块

  • 以 mjs 结尾的文件
  • 以 js 结尾的文件,且 type 值为 module

nodejs 将以下模块会被视为 commonjs 模块

  • 以 cjs 结尾的文件
  • 以 js 结尾的文件,且 type 值为 commonjs

pkg 其他新增字段

  • exports
    • main 的替代品,它既可以定义包的主入口,又封闭了包,防止其他未被定义的内容被访问。这种封闭允许模块作者为他们的包定义公共接口。
    • 定义了 exports 字段后,所有的子入口都将被封闭,不再对使用者开放
    • 不仅为工具提供了更可靠的接口保证,也为处理包的升级提供了保证
    • 条件导出,比如针对 require 和 import 提供不同模块的导出
  • imports
    • 定义内部包导入映射,这些映射只适用于在包内部导入指定内容
    • 定义的入口必须一直以 # 开头,以确保它们与包的对外指定内容相区别
    • 可以做内部模块的条件导出
  • sideEffects
    • 非官方字段,Webpack 为了更好实现 tree shaking 所提出的配置项(Webpack4+)。
    • sideEffects 可以是 false 表示无副作用,也可以是数组,明确有副作用的文件。
    • 如果 sideEffects 明确了某些文件,Webpack 打包时,即使这些文件中的有副作用的那部分代码没被使用,也会保留
    • 生产模式会删除无副作用且没有导入的代码,开发模式都会保留,只是会标注

文档化

拆包之后,会对原本的工作流有所改变,尽量做到无感知,实在没办法的,那就输出文档,进行告知。

通过在 prepublish 命令,在进行依赖安装和发布时,自动对所有子包进行一次 lerna run build 进行打包,确保项目可以正常工作。对原本的工作流没有影响。

唯一有影响时,当你修改子包时,需要对子包启用开发模式,否则代码不会生效,

问题

某个模块利用 typescript interface 同名自动合并的特性,扩展了 axios 的语法,导致编译报错。

  1. rename shims.axios.ts to shims.axios.d.ts
  2. react-app-env.d.ts reference shims.axios.d.ts

ui 组件库相关问题

  • less 文件处理:使用 rollup-plugin-styles 插件即可
  • 如何支持主题定制呢:使用 css variable,灵活且方便
  • 图标库地址问题:通过环境变量的方式进行指定

热更新会受到影响吗

  • 我只能说很强大,当你模块更新时,create-react-app 的热更新会自动触发实在是太棒了
  • 发现一个新问题:由于我开发模式对环境要求没那么高,因此打包出来的代码会用到新语法,但这些新语法导致 cra 报错了。经查阅,cra issue 有讨论类似的问题,好像是 cra 本身的 bug,因此先放一放,直接走 production mode。TODO

typescript 相关问题

  • 本来是想开发模式不运行 typescript plugin 的,因为速度很慢,但这不可行,因为会导致项目报错检查不到类型,而且这种报错很有可能还需要手动重启服务才能恢复!
  • 老老实实把 ts 开启了,但它实在是耗时,而且其实我只是需要它的声明文件而已,还是要想想有没有更好的办法。TODO
  • tsc 生成的代码,直接运行在浏览器上竟然报错!原因是:如果指定 target 为 es5,那么 ts 会将整个类编译成 es5 的写法,这种方式需要将整个类层次结构编译成 ES5 语法,此时如果继承的父类是未转译的(原生类、ES6 类、babel 转换后的类),将不能工作,因为 es6 要求使用 new 的方式进行构造,但 es5 使用 Parent.call 的方式。修改 target 为 es6 即可,剩下的交给 babel 去处理。

create-react-app 发布了 v5.0.0 版本,支持了 webpack5。尝试升级了一下,报错很多,应该是自定义修改 webpack 配置导致的问题,后面专项弄。

拆包之后发现代码不符合 lint 规则,可我的 lint 规则是全局配置的,可见宿主项目应该是做了什么。原因是(2022/02/11)

  • cra 创建的项目通过 eslintconfig 字段声明了比较宽松的 eslint 规则
  • 由于项目使用 monorepo 的方式,根级别的 eslint 配置没有生效
  • 解决办法:删除项目自身的 eslintconfig,此时会自动应用 root 级别的配置,从而保持一致

react-error-overlay 由于发布小版本的破坏性更新,导致项目异常,真的是搞死人,恶心到想吐。但也不是一无所获,了解到一个之前不知道的命令,去查看指定包的安装情况

yarn/npm why <package_name>
yarn why react-error-overlay

再引入 antd 后,测试使用了 antd 的模块时,jest 报错(update at 20220213)

  • 根据 jest 给的提示,报错位置位于 antd 组件内部 import less 文件时,尝试了各种方法,比如将 antd 加入 transform,自定义 cssTransform 都无济于事,本质原因时是 jest 作用域 esm 模块了
  • 最终定位的原因在于 babel-plugin-import,我使用的是 jest 会默认使用 babel 相关的 config 能力,由于启用 antd 按需加载,因此使用了 babel-plugin-import,但这会导致单测报错,解决办法就是在单测中不启用该插件。jest 在运行单测时,会将 NODE_ENV 环境变量写为 test,这可以为我所用,代码如下
    // test 启用 import,会导致 jest 运行单测报错
    if (process.env.NODE_ENV !== 'test') {
    config.plugins.push([
      'import',
      {
        libraryName: 'antd',
        libraryDirectory: 'es',
        style: true,
      },
    ]);
    }
    

扩展:前端工程

前端工程优化

  • 基本步骤:编译 => 打包 => 压缩
  • code splitting:比如将重复使用模块单独打包
  • tree shaking:移除僵尸代码
  • 静态导入:必要元素静态导入,静态导入的模块可以进行静态分析和摇树分析。
  • 动态导入:某些元素一开始是不可见的,需要交互才可见,可考虑动态导入,减少包的大小
  • 可见性导入:常见于滚动元素,当滚到可见区域时再动态导入,使用 Observer API 或使用三方包,入 react-intersection-observer
  • 基于路由的拆分
  • 资源加载优化
    • Prefetch:link 标签的 rel 属性指定为 prefetch,预先加载即将用到的资源(浏览器空闲时),当真正需要时,就可以从缓存中拉取
    • Preload:link 标签的 rel 属性指定为 preload, 用于获取对当前导航至关重要的资源,不同于 prefetch,无论如何都会加载,不考虑浏览器是否空闲等

展望

更多完善点

  • 构建提速:esbuild、swc、vite
  • 文档补充:storybook、jsdoc、TypeDoc

TODO

  • ui 组件库:如何支持主题定制呢,使用 css variable,灵活且方便
  • 开发模式对环境要求没那么高,打包出来的代码会用到新语法,新语法导致 cra 报错
  • 支持仅生成 typescript 声明文件,减少由于 typescript 编译导致的耗时


留言