blog
blog copied to clipboard
使用 babel 转译 typescript 代码
目前,我的项目没有用到 babel,因为我认为我的目标用户应该都有足够高的浏览器版本,所以我是直接使用 tsc 来编译 ts 代码的,且 tsconfig 的 target 设为了 ESNext
,这意味着完全不会有 polyfill 打包进最终的生成的代码里。
但随着我使用的新特性越来越多,对浏览器版本的要求也越来越高,所以我还是决定为项目引入 babel。
TypesScript VS Babel
首先要了解直接用 TypesScript 编译(ts-loader
、tsc
)和用 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
。
我做了下面三种尝试,均未解决此问题:
- 加入 commonjs 插件
- 给 babel 插件添加
skipPreflightCheck: true
配置(来源) - 给 node resolve 插件添加 rootDir 配置,将它设为 workspaces 的根目录:
rootDir: path.join(process.cwd(), '../..')
但均无效。
所以目前来看,这个方法是行不通了,等下次我再试试看要怎么解决这个问题。
二,不要注入任何 runtime 代码,由 app 负责注入
lib 生成的代码不添加任何 runtime:原样保留 async / await
、String.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 类型的项目
需要做如下改动:
- 添加一个
.browserslistrc
文件来确定要支持的浏览器范围。 - 给 tsconfig 添加
"isolatedModules": true
- 添加 babel config 文件
- 将 webpack 配置里的 ts-loader 替换为 babel-loader
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 提供的现代浏览器的特性如 Promise
、String.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
这样的辅助函数就行。
总结
无