lmk123 / blog

个人技术博客,博文写在 Issues 里。
https://github.com/lmk123/blog/issues
623 stars 35 forks source link

使用 babel 转译 typescript 代码 #119

Open lmk123 opened 1 year ago

lmk123 commented 1 year ago

目前,我的项目没有用到 babel,因为我认为我的目标用户应该都有足够高的浏览器版本,所以我是直接使用 tsc 来编译 ts 代码的,且 tsconfig 的 target 设为了 ESNext,这意味着完全不会有 polyfill 打包进最终的生成的代码里。

但随着我使用的新特性越来越多,对浏览器版本的要求也越来越高,所以我还是决定为项目引入 babel。

TypesScript VS Babel

首先要了解直接用 TypesScript 编译(ts-loadertsc)和用 Babel 编译(babel-loader@babel/preset-typescript)的区别。

直接用 TypeScript 编译的话,TypeScript 做了两件事情:编译代码和检测类型,而用 Babel 编译的话,Babel 只负责编译代码,所以还需要额外使用 tsc --noemit(如果你还想生成声明文件的话,用 tsc --emitDeclarationOnly)命令来检测类型。

另外,用 Babel 编译代码的话,还需要在 tsconfig 中添加 "isolatedModules": true

有关这两者的差异可以查看 TypeScript 的官方文档:Using Babel with TypeScript

现在常见的做法是使用 Babel 编译代码、用 TypeScript 检测类型。@babel/preset-env 可以根据浏览器范围确定输出的代码,这比 TypeScript 自己的 target 选项要灵活的多。

项目背景

划词翻译使用了 monorepos 组织代码,在这个 monorepos 中,项目类型分为两种:lib(即模块)和 app(即实际需要运行起来的项目)。

lib 类型的项目使用了 rollup 来打包,且只提供 cjs / es 两种输出类型,也就是说,如果 app 要使用 lib,则必须使用 webpack 这类模块打包工具,不能直接通过 <script> 标签引入。

app 类型的项目使用了 webpack 来打包。

这次改造会针对这两种类型的项目进行。

对于 lib 类型的项目

lib 类型现在使用了 @rollup/plugin-typescript 来编译。

要想改为使用 babel 来编译代码的话,需要先添加一个 babel.config.js:

module.exports = {
  presets: [
    '@babel/preset-env',
    '@babel/preset-typescript',
  ],
}

然后将下面的这部分 rollup 配置:

import ts from '@rollup/plugin-typescript'

export default {
  plugins: [ts()],
}

改为:

import { nodeResolve } from '@rollup/plugin-node-resolve'
import babel from '@rollup/plugin-babel'
// 由于我的 lib 项目没有用到 commonjs 模块,所以不需要 commonjs 插件
// import commonjs from '@rollup/plugin-commonjs'

// 我的 lib 项目只用到了 .ts 文件
const extensions = ['.ts' /*, '.js', '.jsx', '.tsx'*/]

export default {
  plugins: [nodeResolve({ extensions }), babel({ extensions })],
}

然后就能正常编译了。

@babel/runtime 的问题

由于我们没有使用 browserslist 文件,也没有给 @babel/preset-env 指定 targets,所以 babel 默认将我们的代码转为了 es5 兼容代码,检查 babel 生成的文件的话,会发现 babel 注入了很多 runtime 代码(runtime 代码的介绍,类似于 TypeScript 里的 tslib)。

作为一个 lib 项目,我不希望 runtime 代码注入到最终生成的代码当中,现在我有两个选择:

一,将 runtime 代码作为模块的一个依赖

这样做的话,所有 lib 都可以从 @babel/runtime 模块导入 runtime 代码,能有效减少 app 最终的项目体积。

我参考了 @rollup/plugin-babel 的说明,做了一些改动。

首先是 rollup 配置:

import { nodeResolve } from '@rollup/plugin-node-resolve'
import babel from '@rollup/plugin-babel'
const extensions = ['.ts']

export default {
  plugins: [nodeResolve({ extensions }), babel({ extensions, babelHelper: 'runtime' })],
  external: [/@babel\/runtime/]
}

然后 npm i -D @babel/plugin-transform-runtime 并将它加入 babel config 中:

module.exports = {
  presets: [
    '@babel/preset-env',
    '@babel/preset-typescript',
    '@babel/plugin-transform-runtime'
  ],
}

最后 npm i @babel/runtime 将它作为项目的依赖。

这样就完成配置了,运行 rollup,它报错了

[!] (plugin babel) Error: Cannot find package '@babel/preset-plugin-transform-runtime' imported from workspaces/packages/mylib/babel-virtual-resolve-base.js

这个错报的很奇怪,因为 babel config 里写的明明是 @babel/plugin-transform-runtime,但 rollup babel 插件却在读取 @babel/preset-plugin-transform-runtime

我做了下面三种尝试,均未解决此问题:

但均无效。

所以目前来看,这个方法是行不通了,等下次我再试试看要怎么解决这个问题。

二,不要注入任何 runtime 代码,由 app 负责注入

lib 生成的代码不添加任何 runtime:原样保留 async / awaitString.prototype.matchAll 等现代浏览器才支持的写法,然后当有 app 使用这个 lib 时,由 app 负责注入。

即使不是因为上面的报错,我也更倾向于这种做法,毕竟不同 app 对浏览器的支持要求不尽相同:面向 C 端用户的网站可能要尽可能兼容老浏览器,但企业内部网站、基于 Electron 的应用就不需要这么严格,使用固定的 browserslist 配置无法满足所有项目的要求。

如果使用这种方式,lib 只需要将 TypeScript 的 target 设为 ESNext,然后直接用 TypeScript 编译即可,但 app 需要做一些额外配置。

以在 Webpack 里用到的 babel-loader 为例,为了加快 babel-loader 速度,我们一般会 exclude: /node_modules/,即告诉 babel 不要处理 node_modules 里的代码,但如果我们需要 babel 来处理 node_modules 里的一些代码的时候,就需要这么写了:

(以下配置来自 (babel-loader 项目主页)[https://www.npmjs.com/package/babel-loader]的“Some files in my node_modules are not transpiled for ie 11” 一节)

{
  test: /\.m?js$/,
    exclude: {
      and: [/node_modules/], // Exclude libraries in node_modules ...
      not: [
        // Except for a few of them that needs to be transpiled because they use modern syntax
        /unfetch/,
        /d3-array|d3-scale/,
        /@hapi[\\/]joi-date/,
      ]
    },
    use: {
      loader: 'babel-loader',
      options: {
        presets: [
          ['@babel/preset-env', { targets: "ie 11" }]
        ]
      }
    }
}

对于 app 类型的项目

需要做如下改动:

babel.config.js 文件内容:

module.exports = {
  presets: [
    [
      '@babel/preset-env',
        // 测试代码时只需要满足当前 Node.js 就行了
      process.env.NODE_ENV === 'test'
        ? { targets: { node: 'current' } }
        : { bugfixes: true },
    ],
    [
      '@babel/preset-react',
      {
          // https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html
        runtime: 'automatic',
      },
    ],
    '@babel/preset-typescript',
  ],
}

webpack.config.js 中 babel-loader 相关配置:

{
  test: /\.(tsx?|jsx?|mjs|cjs|js)$/,
  exclude: {
    and: [/node_modules/],
    not: [
      // 所有 @hcfyapp 域下的 node_modules 都要经过 babel 处理
      /@hcfyapp[\\/]/,
    ]
  },
  use: {
    loader: 'babel-loader',
    options: {
      cacheDirectory: true
    },
  },
}

然后运行 webpack,发现报了一个错:

Module build failed (from ../../node_modules/babel-loader/lib/index.js):
TypeError: Duplicate declaration "MyApp"

export default function MyApp() {

看了一下出错的文件,发现文件开头有这么一行代码:

import { MyApp } from './module'

但是这里 import 的 MyApp 是一个 TypeScript Interface,我猜测是启用了 isolatedModules 之后导致 TypeScript 没法判断这个 MyApp 是不是类型。不过这个问题也好解决,改个名字就可以了。

对改造结果进行确认

改完之后,webpack 就可以正常运行了,但是还有一些事情需要确认。

确认 React JSX Transform

React 17 引入了新的 JSX Transform,详情见官网介绍 Introducing the New JSX Transform

我要确保的就是:babel 在开发环境下引用的是 react/jsx-dev-runtime,在生产环境下引用的是 react/jsx-runtime

我确认的方法是使用一个 webpack 插件 Webpack Bundle Analyzer,完成打包后,这个插件会弹出来一个网页,包含 webpack 处理的所有模块的信息。

在启动了 webpack 的生产环境打包后,我搜了一下 react,就能看到我的代码里使用的是 react-jsx-runtime.production.min.js,这是符合预期的。

在启动 webpack 的开发环境后,搜到的是 react-jsx-runtime.development.js,理想状态是开发环境应该使用 react-jsx-dev-runtime.development.js

但神奇的是我找不到让 babel 引入 jsx-dev-runtime 的方法,谷歌搜到的都是对官方介绍的解读;@babel/preset-react 虽然有 development 选项,但是设为 true 之后引用的还是 jsx-runtime;@babel/plugin-transform-react-jsx的文档示例里用的也是 jsx-runtime。

在 TypeScript 里可以用 jsx 选项选择使用哪一个,但 babel 似乎无法做到,先放一放吧。

确认 polyfills 代码引用方式

polyfills 指的是由 core-js 提供的现代浏览器的特性如 PromiseString.prototype.includes 等。

给 @babel/preset-env 添加 debug: true,会打印出我们使用的所有插件和 polyfill,但看不到 runtime 代码的情况,先略过。

注意:只有当 webpack 以开发模式运行时才会打印出来这些信息。

由于没有给 @babel/preset-env 配置 useBuiltIns 选项,所以目前项目没有加入任何 polyfill。

我根据文档使用了 useBuiltIns: "entry", corejs: "3.26" 并安装了 core-js v3.26.1,然后就能在控制台看到每个文件使用到的 core-js 代码。

Webpack Bundle Analyzer 插件里也能搜到 core-js 的使用情况。

确认 runtime 代码引用方式

runtime 代码是指 babel 在转换语法时用到的辅助函数,例如 _extends

在 Webpack Bundle Analyzer 弹出的分析报告中搜索 @babel/runtime,能看到 babel 是统一从 @babel/runtime 里引用辅助函数的,例如 ./../node_modules/@babel/runtime/helpers/esm/extends.js,这是符合我的预期的。

换句话说,只要不是给每个文件都单独注入了类似 _extends 这样的辅助函数就行。

总结