前言:软件公司经常会用到一些公共库,甚至是自己私有部署的库,那么如何设计一个好的公共库呢?设计一个好的公共库的标准又是什么呢?如何使用一个公共库呢?这就带你去探索...
从一个公共库处理的问题谈起
以一篇网红文章报告总裁,我们的H5页面在iOS11系统上白屏了开始,概述下文章内容:
作者发现某些机型上出现页面白屏的情况,而报错页面上的信息指向了当前浏览器不支持扩展运算符,进一步追查到了出错的代码是某个公共库的代码,没有使用babel插件对扩展运算符进行降级处理为ES5代码,因此导致源码中直接出现了扩展运算法,而不被宿主环境所识别,导致报错。
解决之路
因为文章中的项目使用的是vue-cli项目,所以需要在vue.config.js中去配置babel进行制定编译,如下:
transpileDependencies: [ 'module-name/library-name' // 出现问题的那个库 ],
而vue-cli 对transpileDependencies 也有如下说明:
默认情况下 babel-loader 会忽略所有
node_modules中的文件。如果你想要通过 Babel 显式转译一个依赖,可以在这个选项中列出来。
按照上述操作,却得到了新的报错:Uncaught TypeError: Cannot assign to read only property 'exports' of object '#<Object>'。
原因如下:
- 在编译过程中,plugin-transform-runtime需要根据sourceType选项的值来选择注入import或者require, 默认注入import。
- 因为webpack不会处理包含import/export的文件内的mudule.exports导出,所以需要通过babel配置sourceType的值来指定注入策略(根据文件内是否存在import/export).
为了适配上述问题,babel配置文件做了如下设定,
module.exports = { ... // 省略的配置 sourceType: 'unambiguous', ... // 省略的配置 }
其中,sourceType: 'unambiguous'表示 Babel 会根据文件上下文是否含有import/export来决定是否按照 ESM 语法处理文件。
但是这种做法有两个不合理的地方:
-
上述方式对所有编译文件有效,增加了编译成本。
-
并不是所有使用ESNext特性的文件中都含有import/export, 可能引发误判而错误地注入require(本应注入import)
简单来说就是不严谨且浪费。最好是单独针对目标第三方依赖库去做个别设置,可以在babel配置文件中使用overrides属性进行设置,如下:
module.exports = {
... // 省略的配置
overrides: [
{ include: './node_modules/module-name/library-name/name.common.js', // 使用的第三方库
sourceType: 'unambiguous'
}
],
... // 省略的配置
};
至此,这个“iOS 11 系统白屏”问题就算告一段落了。过程梳理如下:
出现线上问题 => 由于某个公共库没有处理扩展运算符特性(没有实现编译降级) => 在vue.config.js中使用transpileDependencies选项,用babel编译该公开库(默认情况下babel-loader不编译node_modules中的文件)=> 该公共库输出commonJS代码,但未被webpack正确地识别和处理 => 对该公共库单独使用Babel的overrides属性设置sourceType: 'unambiguous'进行最佳处理。
带来的启示
- 对于公共库,如何构建编译代码让业务方可以放心地使用?
- 对于使用者,如何正确地使用公共库? 需要做额外的编译和处理吗?
需要搞清楚的:应用项目构建和公共库构建的差异
对于一个应用项目来说,能在项目所需环境比如浏览器中跑起来就可以了。 而对于一个公共库来说,需要适配兼容所有可能的宿主环境。所以需要同时兼顾性能和易用性。
制定一个企业级公共库的设计原则
-
对于开发者,要最大化确保开发体验。也就是要最快地搭建开发和调试环境,能够丝滑地发版迭代。
-
对于使用者,要最大化确保使用体验。也就是说文档要完善,质量有保障,上手成本最小。
基于上面的设计原则,设计一个公共库需要肩负一定的工程化使命:
-
如果社区有合适的"轮子", 使用即可。
-
如果没有,要考虑公共库运行的宿主环境,这直接决定我们的编译构建目标。比如是浏览器,nodejs或者同构环境等,不同环境有不同的编译和打包标准。如果是浏览器环境,如何实现性能最优,比如,如何帮助业务方实现tree-shaking等进行性能调优。
-
公共库是业务耦合的还是业务解耦的,这直接决定我们编译的边界和范围。如果是业务耦合的,为降低业务使用成本,可以为公共库和对应的应用项目,使用统一的babel-preset,以保证编译产出的统一。这样,业务方就可以以统一的方式来接入公共库了。
制定一个统一标准化 babel-preset
企业中,所有公共库或应用项目都使用一套 @lucas/babel-xxx-preset,按照 @lucas/babel-xxx-preset 的编译要求进行编译,以保证业务使用时的接入标准统一化。 这样的统一化能够有效避免上面的“线上问题”。
@lucas/babel-preset 应该能够适应各种项目需求,比如使用 TypeScript/Flow/ESNext 等扩展语法/新特性的项目。
这里给出一份设计方案,以供参考:
优化
- 支持 NODE_ENV = 'development' | 'production' | 'test' 三种环境,并有对应的优化。
- 配置插件默认不开启 Babel
loose: true配置,让插件的行为尽可能地遵循规范,但对有较严重性能损耗或有兼容性问题的情况保留修改入口。
落地
这份设计方案落地后产出,应该支持应用编译和公共库编译,即可以按照 @lucas/babel-preset/app,@lucas/babel-preset/dependencies 和 @lucas/babel-preset/library,@lucas/babel-preset/library/compact 进行区分使用,如下:
-
@lucas/babel-preset/app 负责编译除
node_modules外的业务代码 -
@lucas/babel-preset/dependencies 编译
node_modules第三方代码 -
@lucas/babel-preset/library 按照当前 Node 环境编译输出代码
-
@lucas/babel-preset/library/compact 则编译降级为 ES5
具体细节
对于企业级公共库
-
建议使用标准 ES 特性发布;对 tree-shaking 有强烈需求的库,需要同时发布 ES module 格式代码(因为tree-shaking是基于ES module实现的)。
-
发布的代码不包含 polyfills,由使用方统一处理。
对于应用编译
-
使用 @babel/preset-env 同时编译应用代码与第三方库代码。为
node_modules配置sourceType: 'unambiguous',以确保第三方依赖包中的 CommonJS 模块能够被正确处理 -
应启用 plugin-transform-runtime,避免同样的 helper 代码被重复注入多个文件,以缩减打包后文件的体积。
注意点
由于应用可能有自己直接依赖的 @babel/runtime 包,而应用依赖的第三方库也可能有依赖的 @babel/runtime 包,它们的版本不一定相同,而 @babel/runtime 包的不同版本之间不一定兼容,为了保证使用正确,最好使用绝对路径引入 @babel/runtime 包。
设计一个公共库的最佳实践参考(以实际情况为准):
基于以上设计,对于CSR/客户端应用的 Babel 编译流程,预计业务方使用预设为:
// webpack.config.js
module.exports = {
presets: ['@lucas/babel-preset/app'],
}
// 相关 webpack 配置
module.exports = {
module: {
rules: [
{
test: /\.js$/,
oneOf: [
{
exclude: /node_modules/,
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
},
{
loader: 'babel-loader',
options: {
cacheDirectory: true,
configFile: false,
// 使用我们的 preset
presets: ['@lucas/babel-preset/dependencies'],
compact: false,
},
},
],
},
],
},
}
其中,`@lucas/babel-preset/dependencies内容如下:
const path = require('path')
const {declare} = require('@babel/helper-plugin-utils')
const getAbsoluteRuntimePath = () => {
return path.dirname(require.resolve('@babel/runtime/package.json'))
}
module.exports = ({
targets,
ignoreBrowserslistConfig = false,
forceAllTransforms = false,
transformRuntime = true,
absoluteRuntime = false,
supportsDynamicImport = false,
} = {}) => {
return declare(
(
api,
{modules = 'auto', absoluteRuntimePath = getAbsoluteRuntimePath()},
) => {
api.assertVersion(7)
// 返回配置内容
return {
// https://github.com/webpack/webpack/issues/4039#issuecomment-419284940
sourceType: 'unambiguous',
exclude: /@babel\/runtime/,
presets: [
[
require('@babel/preset-env').default,
{
// 统一 @babel/preset-env 配置
useBuiltIns: false,
modules,
targets,
ignoreBrowserslistConfig,
forceAllTransforms,
exclude: ['transform-typeof-symbol'],
},
],
],
plugins: [
transformRuntime && [
require('@babel/plugin-transform-runtime').default,
{
absoluteRuntime: absoluteRuntime ? absoluteRuntimePath : false,
},
],
require('@babel/plugin-syntax-dynamic-import').default,
!supportsDynamicImport &&
!api.caller(caller => caller && caller.supportsDynamicImport) &&
require('babel-plugin-dynamic-import-node'),
[
require('@babel/plugin-proposal-object-rest-spread').default,
{loose: true, useBuiltIns: true},
],
].filter(Boolean),
env: {
test: {
presets: [
[
require('@babel/preset-env').default,
{
useBuiltIns: false,
targets: {node: 'current'},
ignoreBrowserslistConfig: true,
exclude: ['transform-typeof-symbol'],
},
],
],
plugins: [
[
require('@babel/plugin-transform-runtime').default,
{
absoluteRuntime: absoluteRuntimePath,
},
],
require('babel-plugin-dynamic-import-node'),
],
},
},
}
},
)
}
要注意的是useBuiltIns这个选项,定义了项目中引入polyfill的策略。有三个值,分别为entry,usage和false。entry表示需要在入口文件import对应的依赖,据此引入对应的polyfill,usage表示按需引入polyfill,false就是全量引入polyfill。
基于以上设计,对于 SSR 应用的编译流程(需要编译适配 Node.js 环境)预计业务方使用预设为:
// webpack.config.js
const target = process.env.BUILD_TARGET // 'web' | 'node'
module.exports = {
target,
module: {
rules: [
{
test: /\.js$/,
oneOf: [
{
exclude: /node_modules/,
loader: 'babel-loader',
options: {
cacheDirectory: true,
// 根据不同的targe值做不同的编译处理
presets: [['@lucas/babel-preset/app', {target}]],
},
},
{
loader: 'babel-loader',
options: {
cacheDirectory: true,
configFile: false,
// 根据不同的targe值做不同的编译处理
presets: [['@lucas/babel-preset/dependencies', {target}]],
compact: false,
},
},
],
},
],
},
}
@lucas/babel-preset/app内容为:
const path = require('path')
const {declare} = require('@babel/helper-plugin-utils')
const getAbsoluteRuntimePath = () => {
return path.dirname(require.resolve('@babel/runtime/package.json'))
}
module.exports = ({
targets,
ignoreBrowserslistConfig = false,
forceAllTransforms = false,
transformRuntime = true,
absoluteRuntime = false,
supportsDynamicImport = false,
} = {}) => {
return declare(
(
api,
{
modules = 'auto',
absoluteRuntimePath = getAbsoluteRuntimePath(),
react = true,
presetReactOptions = {},
},
) => {
api.assertVersion(7)
return {
presets: [
[
require('@babel/preset-env').default,
{
useBuiltIns: false,
modules,
targets,
ignoreBrowserslistConfig,
forceAllTransforms,
exclude: ['transform-typeof-symbol'],
},
],
react && [
require('@babel/preset-react').default,
{useBuiltIns: true, runtime: 'automatic', ...presetReactOptions},
],
].filter(Boolean),
plugins: [
transformRuntime && [
require('@babel/plugin-transform-runtime').default,
{
useESModules: 'auto',
absoluteRuntime: absoluteRuntime ? absoluteRuntimePath : false,
},
],
// https://github.com/facebook/create-react-app/issues/4263
[
require('@babel/plugin-proposal-class-properties').default,
{loose: true},
],
require('@babel/plugin-syntax-dynamic-import').default,
!supportsDynamicImport &&
!api.caller(caller => caller && caller.supportsDynamicImport) &&
require('babel-plugin-dynamic-import-node'),
[
require('@babel/plugin-proposal-object-rest-spread').default,
{loose: true, useBuiltIns: true},
],
require('@babel/plugin-proposal-nullish-coalescing-operator').default,
require('@babel/plugin-proposal-optional-chaining').default,
].filter(Boolean),
env: {
development: {
presets: [
react && [
require('@babel/preset-react').default,
{
useBuiltIns: true,
development: true,
runtime: 'automatic',
...presetReactOptions,
},
],
].filter(Boolean),
},
test: {
presets: [
[
require('@babel/preset-env').default,
{
useBuiltIns: false,
targets: {node: 'current'},
ignoreBrowserslistConfig: true,
exclude: ['transform-typeof-symbol'],
},
],
react && [
require('@babel/preset-react').default,
{
useBuiltIns: true,
development: true,
runtime: 'automatic',
...presetReactOptions,
},
],
].filter(Boolean),
plugins: [
[
require('@babel/plugin-transform-runtime').default,
{
useESModules: 'auto',
absoluteRuntime: absoluteRuntimePath,
},
],
require('babel-plugin-dynamic-import-node'),
],
},
},
}
},
)
}
如何使用一个公共库?
// babel.config.js module.exports = { presets: ['@lucas/babel-preset/library'], }
对应@lucas/babel-preset/library内容为:
const create = require('../app/create')
module.exports = create({
targets: {node: 'current'},
ignoreBrowserslistConfig: true,
supportsDynamicImport: true,
})
这里的../app/create.js即为上述@lucas/babel-preset/app内容。可以看到,这里做到了公共库设计和使用标准的统一(都使用了app预设作为基座)。
而如果需要将该公共库编译降级到 ES5,需要使用@lucas/babel-preset/library/compact内容为:
const create = require('../app/create') module.exports = create({ ignoreBrowserslistConfig: true, supportsDynamicImport: true, })
注意点
@lucas/babel-preset/app为应用项目使用,用来编译项目本身的代码,支持最新语法特性和语法提案以及JSX语法,方便我们使用最新的语法特性编码
@lucas/babel-preset/dependencies:应用项目使用,编译 node_modules,不支持最新语法特性和语法提案以及JSX语法,只支持当前ES规范的语法。需要自己引入polyfill.
@lucas/babel-preset/library: 公共库项目使用, 和@lucas/babel-preset/app相同,支持最新语法特性和语法提案以及JSX语法,方便我们使用最新的语法特性编码
总结:本文从一个“线上问题”作为切入点,引入了企业级公共库和应用项目构建的差异,最终深入了企业级公共库的设计标准和最佳实践参考,以及如何通过babel-preset来达成企业级公共库设计和使用的标准化统一这件事情。