yliu

时来天地皆同力,运去英雄不自由

使用 TypeScript 来编写 cli 程序


新的一年已经开始,之前在知乎刷到一篇新年展望贴 2022 前端技术领域会有哪些新的变化?,里面的小伙伴回答了很多,这里稍微归纳一下回答:

  • Monorepo
  • TypeScript
  • ESBuild
  • ESM 化
  • pnpm
  • ...

上面的回答中 TypeScript 提及的次数很多,原因很是随着项目复杂程度的日趋增加,需要对代码进行一个严格管理,且 TypeScript 对编辑器的智能提示太香了,用完之后根本没办法拒绝。且越多框架和生态都在往其迁徙,作为 er 的一份子,我们也应当进行拥抱。

这里以 node cli 的开发场景进行举例,因为它的使用随处可见,例如 vitereact 或者内部自己使用的脚手架。

如何开发命令行

这里推荐一下阮一峰的 Node.js 命令行程序开发教程 文章,因为篇幅的问题,这里不过多讲解无关内容。

在编写一个命令行程序之前通常会做两件事情:

  1. 在入口文件第一行添加 #!/usr/bin/env node,它的意思是告诉系统如何正确处理这个脚本;
  2. package.json的 bin 字段下添加脚本的名称跟路径,例如:
bin: {
    "create": "xxx"
}

上面 bin 字段会在安装 package 的时候将 bin 命令 放置到 node_modules 下的 bin 目录

平时使用的 webpack 之所以在 scripts 执行的 webpack xxx 可以通过,就是因为查找 bin 目录 存在 webpack 的命令。

回到主题,上面说了使用 TypeScript 的原因是因为程序的复杂程度在日趋增加,那如果我们想使用 TypeScript 来开发一个 cli 程序有正确的姿势是什么呢?

直接在 TypeScript 的文件上添加 #!/usr/bin/env node

#!/usr/bin/env node
const obj: Obj = {};
// xxx

上面的做法肯定不行,因为 node 并不认识这些语法,下面就来列举一下日常使用的方式。

tsc

tsc 是安装 TypeScript 提供给我们的一个命令,它的作用就是编译 TypeScript 文件,例如

tsc index.ts

如果目录存在 tsconfig.json 文件,运行 tsc 会将所有符合的 TypeScript 文件输出到 outDir 目录下。

不过使用 tsc 来完成构建有两点问题:

  • 不能清理死代码;
  • 输出的 outDir没有跟资源文件进行绑定;

第一点很好理解,我们使用 rollup 等工具,在指定 input 文件的时候会进行一个依赖收集,如果没有执行到的代码就是一个死代码,不会被最终 build 出来,不过使用 tsc 很显然它是没有这个能力的。

而第二点说的则是,如果 cli 工具需要完成拉取目录的操作,例如

- index.ts
- template-modules
- template-server
- template-react

上面 index.ts 是程序入口文件,而其他的 template 文件夹 只是静态资源,平时使用 JavaScript 通过 __dirname + 文件夹名称 即可确定目录,而现在使用 tsc,它只会将 index.ts 输出到 outDir 下,假设 outDir'./dist'

- dist
-  index.ts
- template-modules
- template-server
- template-react

如果还按照 __dirname + 文件夹名称 形式运行代码是获取不到 template 等文件夹的。

综合来看,如果项目很简单可以使用 tsc 的形式,而如果包含 __dirname 语法或者静态资源的项目则需要注意路径问题。

rollup

rollup 是下一代的打包器,vue 和 react 都使用此进行打包,因为它的使用很简单其次生成的代码比 webpack 更可读体积更小。

使用 rollup 同样也可以完成 cli 打包,只需要在 rollup.config.js 进行相关的配置即可,下面以我最常用的配置为例

/* eslint-disable @typescript-eslint/indent */

import fs from "fs-extra";
import { defineConfig } from "rollup";
import { babel } from "@rollup/plugin-babel";
import commonjs from "@rollup/plugin-commonjs";
import path from "path";
import json from "@rollup/plugin-json";
import { preserveShebangs } from "rollup-plugin-preserve-shebangs";
import { terser } from "rollup-plugin-terser";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import { dependencies } from "../package.json";
import { cwd } from "process";
import copyPlugin from "rollup-plugin-copy";
import { string } from "rollup-plugin-string";
import config from "./config";

import { DEFAULT_EXTENSIONS } from "@babel/core";

const extensions = [...DEFAULT_EXTENSIONS, ".ts"];

const dist = path.join(cwd(), "dist");
fs.removeSync(dist);

export default defineConfig(
  config.map((item) => {
    const { name } = path.parse(item.src);
    // copy文件
    const copy = item.copy || [];

    return defineConfig({
      input: item.src,
      // 禁止打包外部依赖
      external: [...Object.keys(dependencies), "@babel/runtime"],
      plugins: [
        nodeResolve({ extensions, rootDir: __dirname }),
        commonjs(),
        json(),
        babel({
          babelHelpers: "runtime",
          exclude: /exclude/,
          extensions,
          presets: [["@babel/preset-env"], "@babel/preset-typescript"],
          // 禁止打包重复模块
          plugins: ["@babel/plugin-transform-runtime"],
        }),
        terser(),
        copyPlugin({
          targets: copy.map((item) => {
            return { src: item, dest: dist };
          }),
        }),
        preserveShebangs(),
        // 导入非js资源
        string({
          include: "**/*.{md,html}",
          exclude: [],
        }),
      ],
      output: {
        file: path.join(
          dist,
          item.format === "cjs" ? `${name}.js` : `${name}.${item.format}.js`
        ),
        format: item.format,
        sourcemap: true,
        exports: "auto",
      },
    });
  })
);

指定 inout 运行 rollup 即可完成打包,不过使用 rollup 打包同样也有优缺点

优点

  • 可以清除无效代码;
  • 可以进行代码压缩;

缺点

  • 书写代码需要配置,以及每次发布之前都需要 build(虽然可以 watch,但是也是占用资源;
  • 导入静态资源,例如上面 tsc 举例拉取静态资源的时候,需要配置插件将资源复制到 build 目录下,之后通过 __dirname 形式来使用

ts-node

除了上面介绍的两种,还可以使用 ts-node 来完成,最开头的时候讲了开发 cli 不能直接使用 TypeScript 的原因就是 node 不识别语法,那如果可以做到运行时编译到 JavaScript 不久可以了吗?

入口文件还是一个普通的 JavaScript 文件,后续的文件通过 ts-node 来解析语法运行。

#!/usr/bin/env node
require("ts-node").register({
  /* options */
});
// 后面可以直接加载ts文件
require("./index.ts");

更多选项可以参考 https://typestrong.org/ts-node/api/interfaces/RegisterOptions.html

优点

  • 改动很小,跟原生 TypeScript 写法没区别;

缺点

  • 不支持死代码去除;
  • 运行速度很慢;

使用 ts-node 最致命的缺点还是加载很慢,因为是运行时解析,且还会校验 TypeScript 文件。

es-build

这是基于 ts-node 思路来的变异方法,es-build 是基于 go 语言开发的打包器,它去除了运行时校验,支持并发。

这里贴一张官方的对比图来进行展示,如果想了解更多内容请查看 官方image

es-build 会将代码编译,不过这里我们需要的是直接运行,es-build 没有提供相关的方法。我们取巧一下在将入口文件 require 导入的 TypeScript 文件全部编译,之后写到TypeScript文件目录下运行,运行程序结束之后删除文件。

幸运的是,不需要手动来完成这样一件事情,社区已经有相关的库 esbuild-register,我们直接使用即可。

#!/usr/bin/env node
require("esbuild-register/dist/node");
// 后面可以直接加载ts文件
require("./index.ts");

当前优缺点跟 ts-node 基本相同,运行速度慢得到了改变。

最后

综合来看,如果你想享受 TypeScript 语法不想做太多的改动,es-build 的方案绝对是最佳实践,虽然不支持 rollup 等这样的生态,但是结合 eslint 的工具对代码进行检查,还是可以很大程度改善。

如果文章有什么错别字或者讲解不对地方欢迎指出,如果对你有帮助可以 star 支持一下。