引子

大约在两个月前,为了做出一款属于自己的产品,我正式开始学习 TypeScript 和 React 开发1。虽然接触 JavaScript 时间不短了,但一直热衷于使用 Vanilla JS,也没有正式做过比较严肃的项目。因此,我把自己定位为一个前端新手,决定从 0 开始学习现代化前端开发。

这两个月来,我在学习实践过程中累计了一些自己的心得和体会,算不上高深,但应该可以为同样在学习中的新手起到参考作用。从这篇文章开始,我会逐一将我认为有价值的部分写成博客,归类于 #frontend-guide 标签下,并在未来的学习中持续更新。作为一个非专业的前端学习者,这些文章中一定会出现错误和疏漏,请读者朋友们不吝指正,如果能从批评中学到什么,将会是我写作的最大收获。

先大致列举一些可写的话题,以作备忘:

  • 如何构建一个最基本的 TypeScript 项目
    • 介绍 package.json 和基本的 devDependencies
    • 介绍 tsconfig.json 中必须了解的选项
    • tsc 和 esbuild
  • 如何使用 jest 进行细粒度的 TDD
  • 从零开始构建自己的 React project template
    • 不使用 create-react-app 有哪些好处
    • webpack
    • babel: modular import and hot reload
    • config and APP_ENV
  • 如何构建一个最基本的 monorepo
    • npm workspace
    • tsconfig reference
    • webpack: resolve alias and tsconfig paths plugin
  • TypeScript caveats and cheatsheet
  • 如何使用 TypeScript 和 React 开发 Chrome Extension
  • Mobx 使用指南
  • 实现一个简单的 useFetch Hook
  • 如何使用 swr 调用 HTTP API
  • 如何使用 Protobuf 和 TypeScript 封装 HTTP API

ts-loader

在现代化前端项目中,TypeScript 因其静态类型的特性,为代码的可维护性、健壮性带来了极大的提升,已逐渐成为前端开发的标准语言。但 TypeScript 不能直接在浏览器中运行,因此在项目构建流程中需要引入 transpiler 来将其编译为浏览器可用的 JavaScript。ts-loader 就是为 Webpack 设计的 transpiler 之一。

过去的开发生涯中,我虽然不是专业的前端,也对 JavaScript 世界中的基石 babel 有所耳闻目见。但在这次从零开始学习 TypeScript 开发时,因为对 babel 有种过于复杂的印象,我选择了 ts-loader 作为第一个学习和上手的插件。

ts-loader 的使用非常简单,文档也足够清晰,如果你的项目本身有正确配置 tsconfig.json 并可以使用 tsc -b 完成编译,那么在 webpack 中引入 ts-loader 后不需要额外的配置即可工作。

下面是一份使用 ts-loader 的 webpack.config.js 示例:

module.exports = {
  entry: {
    index: path.join(srcDir, 'index.tsx'),
  },
  output: {
    path: destDir,
    filename: '[name].bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        loader: 'ts-loader',
        exclude: /node_modules/,
      },
    ]
  }
}

经过一段时间的使用,我发现了对 ts-loader 不太满意的几个地方:

  • 偶尔出现莫名的错误

    有的时候,当对 TypeScript 源文件进行了某种变更,或许是大量的类型变化,或许是文件重命名和移动目录,可能会触发 ts-loader 报错,但此时 VSCode 的静态检查器却没有显示出任何问题(所有源文件),重启 webpack serve 也无济于事。这时只能通过删除所有与构建过程和结果有关的文件,如 build, dist, **/*.d.ts, *.tsbuildinfo,重新运行才可以消除这个不存在的错误。

  • 难以 Debug 某些 transpiling 过程中的错误

    如果你的 tsconfig.json 中设置了 noEmit: truenoEmitOnError: true, 那你很有可能会看到 Error: TypeScript emitted no output for… 这样的报错,这是因为 ts-loader 在将 TypeScript 转换成 JavaScript 时无法成功,于是没有输出 js 文件。但具体是什么错误,完全无法搞清楚。我在 GitHub 上跟踪了一个 issue, 目前仍然没有收获。

  • 项目内多个 package 引用必须生成描述文件

    如果想在项目内想要拆分多个 package 并互相引用,需要使用 TypeScript 的 Project References 功能,而 declaration 必须设置为 true,此时 ts-loader 会为被引用的包生成 .d.ts 的描述文件,使文件浏览器变得混乱。

  • 在插件生态中不是一等公民

    一些优化项目开发流程的插件,如 react-refresh-webpack-plugin, 优先支持 babel 而非 ts-loader;babel-plugin-import 的 ts-loader 实现 ts-import-plugin 远不如其本身流行。

于是我决定对 babel-loader 进行一次尝试。

babel-loader

babel-loader 的项目页面有详细的安装配置说明,在此不做赘述。下面是一个在 TypeScript + React 项目中工作的最小化配置示例:

module.exports = {
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: [
                ['@babel/preset-env', { targets: "defaults" }],
                '@babel/preset-typescript',
                ['@babel/preset-react', {'runtime': 'automatic'}]
              ],
            },
          },
        ],
      }
    ]
  }
}

配置完成后,代码顺利编译,随后我开始了对构建配置优化的探索。

webpack-bundle-analyzer

我首先想到的是对构建出的 bundle 的大小进行检查,一看竟然有 7MB 之大,于是安装了 webpack-bundle-analyzer, 对 bundle 中所包含的依赖进行分析。

下面是引入 webpack-bundle-analyzer 之后的 webpack.config.js 文件,通过环境变量 WEBPACK_USE_ANALYZE 判断是否进入分析模式并修改 webpack 配置。

const useAnalyze = !!process.env.WEBPACK_USE_ANALYZE

const config = {...}

if (useAnalyze) {
  const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

  config.mode = 'development'
  config.plugins.push(
    new BundleAnalyzerPlugin({
      analyzerPort: 18888,
    })
  )
}

module.exports = config

运行 WEBPACK_USE_ANALYZE=1 webpack 在浏览器中查看分析结果。

原来 tabler-icons-react 一个库就占了 5.28MB,其他库中 lodash 也不算正常,似乎和 tabler-icons-react 一样被完整包含进来了。

babel-plugin-import

tabler-icons-react 是一个 SVG 图标库,我只是使用了其中一部分图标。lodash 是非常通用的工具函数库,同样的,我只对其中一部分函数进行了引用。那么有没有办法按需加载依赖中的模块,只输出被 import 到的部分呢?答案就是 babel-plugin-import

继续修改 webpack.config.js,为 babel-loader 增加 plugins:

{
  loader: 'babel-loader',
    plugins: [
      ['import', {
        libraryName: 'tabler-icons-react',
        libraryDirectory: 'dist/icons',
      }],
      ['import', {
        libraryName: 'lodash',
        libraryDirectory: '',
        camel2DashComponentName: false,
      }],
    ]
  }
}

再次运行 WEBPACK_USE_ANALYZE=1 webpack ,可以看出 bundle 结构有了显著的改进(红色的部分是 tabler-icons-react 和 lodash):

babel-plugin-lodash 也可以实现 lodash 的按需加载,但它是专为 lodash 开发的,不具备 babel-plugin-import 的通用性。

@babel/preset-env

@babel/preset-env 是 babel 中最重要的一个 preset。所谓 preset,即预设配置的封装,让需求相近的用户可以不用关注细节直接使用,preset-env 提供了非常丰富的选项,让使用者可以快速定制出符合目标需求的编译结果。

首先要关注的是 targets 选项,它决定了 babel 所编译出的 JavaScript 能否在特定平台上运行。targets 支持 browserslist 语法,上文中我们的初始配置 ['@babel/preset-env', { targets: "defaults" }], 代表使用 browserslist 的 defaults 查询2,虽然可以编译,但在浏览器中是无法运行的,会产生 Uncaught ReferenceError: regeneratorRuntime is not defined 错误,这是为什么呢?

为了统一不同浏览器的 JavaScript 实现差异,使 ES2015+ 代码可以正确运行,babel 会根据 targets 决定是否需要在编译结果中注入 polyfill。 最早这一功能由 @babel/polyfill 实现,但它在 babel 7.4.0 之后被废弃,由 core-js 接替。我们的代码之所以运行报错,就是因为没有指定 preset-env 使用 core-js,导致用于模拟 ES2015+ 运行环境的 regeneratorRuntime 缺失。

修改后的 preset-env 配置如下:

["@babel/preset-env", {
  targets: "defaults",
  corejs: 3,
  useBuiltIns: 'usage',
}]

重新编译,代码即可成功运行。并且通过 webpack-bundle-analyzer 可以发现 bundle 中多出了 core-js 的部分。

如果想在对浏览器的支持上激进一些,可以尝试将 targets 设为 {browsers: '> 5%'},即仅支持占有率超过 5% 的浏览器,你会发现 bundle 中 core-js 的部分会再次消失,因为这些浏览器不需要 core-js 就有完整的 ES2015 支持。

react-refresh-webpack-plugin

webpack 提供 HMR 热更新功能,不需要刷新页面即可将改动反映到页面中。在 babel-loader 中,需要通过 react-refresh-webpack-plugin 插件来实现 React JSX 的热更新。

首先要在 devServer 中开启 hot 选项:

module.exports = {
  devServer: {
    // Enable hot reloading
    hot: true,
  }
}

然后为 webpack 添加 ReactRefreshWebpackPlugin 插件:

const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');

module.exports = {
  plugins: [
    new ReactRefreshWebpackPlugin(),
  ],
}

最后为 babel-loader 添加 react-refresh/babel 插件,通过 isDevelopment 决定是否存在:

{
  loader: 'babel-loader',
  options: {
    plugins: [
      isDevelopment && 'react-refresh/babel'
    ].filter(Boolean)
  }
}

Summary

将以上插件和技巧综合起来,最终我的 babel-loader 配置如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: [
                ['@babel/preset-env', {
                  targets: {
                    browsers: '> 5%',
                  },
                  corejs: 3,
                  useBuiltIns: 'usage',
                }],
                '@babel/preset-typescript',
                ['@babel/preset-react', {'runtime': 'automatic'}]
              ],
              plugins: [
                ['import', {
                  libraryName: 'tabler-icons-react',
                  libraryDirectory: 'dist/icons',
                }],
                ['import', {
                  libraryName: 'lodash',
                  libraryDirectory: '',
                  camel2DashComponentName: false,
                }],
                isDevelopment && 'react-refresh/babel'
              ].filter(Boolean),
            },
          },
        ],
      }
    ]
  }
}

它实现了:

  • 输出支持现代主流浏览器的 JavaScript
  • 按需加载 tabler-icons-react 和 lodash 模块
  • React JSX 开发热更新

如果你想看到一个可运行的例子,请参考我的最小化 React 项目模板 reorx/minireact,其中有完整的 webpack.config.js 文件。

Revision

  • 2022-05-18: created

  1. 开始学习时的推文: 学习了俩小时如何开始一个 TypeScript 项目,现在已经不省人事了 ↩︎

  2. 根据 browerslist 文档,defaults 代表 > 0.5%, last 2 versions, Firefox ESR, not dead,是一个非常宽泛的规则,可覆盖全世界所有浏览器中的 90% ↩︎