一子定乾坤 -- babel-preset在企业级公共库设计和使用中发挥的作用

643 阅读9分钟

前言:软件公司经常会用到一些公共库,甚至是自己私有部署的库,那么如何设计一个好的公共库呢?设计一个好的公共库的标准又是什么呢?如何使用一个公共库呢?这就带你去探索...

从一个公共库处理的问题谈起

以一篇网红文章报告总裁,我们的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>'

原因如下:

  1. 在编译过程中,plugin-transform-runtime需要根据sourceType选项的值来选择注入import或者require, 默认注入import。
  2. 因为webpack不会处理包含import/export的文件内的mudule.exports导出,所以需要通过babel配置sourceType的值来指定注入策略(根据文件内是否存在import/export).

为了适配上述问题,babel配置文件做了如下设定,

module.exports = { ... // 省略的配置 sourceType: 'unambiguous', ... // 省略的配置 }

其中,sourceType: 'unambiguous'表示 Babel 会根据文件上下文是否含有import/export来决定是否按照 ESM 语法处理文件。

但是这种做法有两个不合理的地方:

  1. 上述方式对所有编译文件有效,增加了编译成本。

  2. 并不是所有使用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'进行最佳处理。

带来的启示

  1. 对于公共库,如何构建编译代码让业务方可以放心地使用?
  2. 对于使用者,如何正确地使用公共库? 需要做额外的编译和处理吗?

需要搞清楚的:应用项目构建和公共库构建的差异

对于一个应用项目来说,能在项目所需环境比如浏览器中跑起来就可以了。 而对于一个公共库来说,需要适配兼容所有可能的宿主环境。所以需要同时兼顾性能和易用性。

制定一个企业级公共库的设计原则

  1. 对于开发者,要最大化确保开发体验。也就是要最快地搭建开发和调试环境,能够丝滑地发版迭代。

  2. 对于使用者,要最大化确保使用体验。也就是说文档要完善,质量有保障,上手成本最小。

基于上面的设计原则,设计一个公共库需要肩负一定的工程化使命:

  1. 如果社区有合适的"轮子", 使用即可。

  2. 如果没有,要考虑公共库运行的宿主环境,这直接决定我们的编译构建目标。比如是浏览器,nodejs或者同构环境等,不同环境有不同的编译和打包标准。如果是浏览器环境,如何实现性能最优,比如,如何帮助业务方实现tree-shaking等进行性能调优。

  3. 公共库是业务耦合的还是业务解耦的,这直接决定我们编译的边界和范围。如果是业务耦合的,为降低业务使用成本,可以为公共库和对应的应用项目,使用统一的babel-preset,以保证编译产出的统一。这样,业务方就可以以统一的方式来接入公共库了。

制定一个统一标准化 babel-preset

企业中,所有公共库或应用项目都使用一套 @lucas/babel-xxx-preset,按照 @lucas/babel-xxx-preset 的编译要求进行编译,以保证业务使用时的接入标准统一化。 这样的统一化能够有效避免上面的“线上问题”。

@lucas/babel-preset 应该能够适应各种项目需求,比如使用 TypeScript/Flow/ESNext 等扩展语法/新特性的项目。

这里给出一份设计方案,以供参考:

优化

  1. 支持 NODE_ENV = 'development' | 'production' | 'test' 三种环境,并有对应的优化。
  2. 配置插件默认不开启 Babelloose: true配置,让插件的行为尽可能地遵循规范,但对有较严重性能损耗或有兼容性问题的情况保留修改入口。

落地

这份设计方案落地后产出,应该支持应用编译和公共库编译,即可以按照 @lucas/babel-preset/app,@lucas/babel-preset/dependencies 和 @lucas/babel-preset/library,@lucas/babel-preset/library/compact 进行区分使用,如下:

  1. @lucas/babel-preset/app 负责编译除node_modules外的业务代码

  2. @lucas/babel-preset/dependencies 编译node_modules第三方代码

  3. @lucas/babel-preset/library 按照当前 Node 环境编译输出代码

  4. @lucas/babel-preset/library/compact 则编译降级为 ES5

具体细节

对于企业级公共库
  1. 建议使用标准 ES 特性发布;对 tree-shaking 有强烈需求的库,需要同时发布 ES module 格式代码(因为tree-shaking是基于ES module实现的)。

  2. 发布的代码不包含 polyfills,由使用方统一处理。

对于应用编译
  1. 使用 @babel/preset-env 同时编译应用代码与第三方库代码。为node_modules配置sourceType: 'unambiguous',以确保第三方依赖包中的 CommonJS 模块能够被正确处理

  2. 应启用 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来达成企业级公共库设计和使用的标准化统一这件事情。