用swc替代tsc,我的nodejs项目编译效率提升100倍!

1. 前言

前段时间在web端项目构建的时候使用swc与esbuild来优化构建速度取得了不错的效果,在多个web项目运行一段时间了,目前没有发现什么问题,具体文章可以查看swc与esbuild-将你的构建提速翻倍

最近正好在看nodejs写的bff项目有没有优化的空间,当前的bff项目在线上运行模式是用tsc编译成js,然后在使用nodejs运行js,普通bff项目每次用tsc将ts转换成js大概需要10s以上,如下图所示

于是想到了两种优化思路

  • 不编译ts,直接在生产环境使用ts-node,这样就省略了编译的这个过程
  • 使用swc or esbuild来编译ts,用更快的编译工具来替换tsc,这样就压缩了编译时间

本篇讲述的是第二种方式使用swc or esbuild来编译生成js,然后在使用nodejs运行,看下在我们的bff项目内能不能使用swc or esbuild替换tsc

公司的bff项目是基于基于koa + routing-controllers + typedi技术栈,具体示例如下所示

控制器代代码示例

typescript 复制代码
import { Get, Inject, JsonController, QueryParam } from 'xxx'
import TestService from '../service/TestService'

/**
 * @export
 * @class TestController
 */
@JsonController('/hello')
export default class HelloController {
  @Inject() private testService: TestService

  /**
   * @param {string} name
   * @returns {Promise<string>}
   * @memberof ExampleController
   */
  @Get('/world')
  hello(): any {
    return 'hello world'
  }

  @Get('/grpc')
  async grpcHello(@QueryParam('name') name: string): Promise<any> {
    const result = await this.testService.TestSomething({ name: name || 'world' })
    return result.message
  }
}

服务代码示例

typescript 复制代码
import { Service } from 'typedi'
// service
import { greeter } from '../server-test/helloworld/Greeter'

@Service()
export default class TestService {
  async TestSomething(request?: any, metadata?: any): Promise<any> {
    const { error, error_details, response }: any = await greeter.SayHello({
      request,
      metadata,
    })
    if (error) {
      throw error_details
    }
    return response
  }
}

所以我们的bff框架强依赖ts及ts中的装饰器功能

json 复制代码
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
  },
}

对于Decorator与DecoratorMetadata不了解的,可以查看深入浅出Typescript装饰器与Reflect元数据这篇文章

下面开始自己的尝试过程

2. esbuild编译ts

为什么先试esbuild而不是swc,原因是在我的使用经验中esbuild是比swc更快的,所以每次我都喜欢先验证esbuild

2.1 安装依赖

shell 复制代码
pnpm add esbuild -D

2.2 编译ts

typescript 复制代码
require('esbuild').build({
  entryPoints: ['./src/index.ts', './src/util.ts'],
  bundle: false,
  platform: 'node',
  outdir: 'build',
  sourcemap: false,
  target: 'es2015',
  format: 'cjs',
});
diff 复制代码
{
  "scripts": {
-    "compile": "tsc -p .",
+    "compile": "node buildts.js"
  }
}

本地的tsc编译项目,平均15s 本地的esbuild编译项目, 平均122ms

构建速度提升100倍以上

2.3 运行编译产物

构建过程没有报错,但是运行的时候,访问接口报错Cannot determine a class of the requesting service \"undefined\",如下图所示

搜了下这个错误出处,来自于typedi这个包,那看样子是装饰器语法生成的有问题

对比下esbuild与tsc生成的产物,发现esbuild少了对DecoratorMetadata的处理,如下图所示

这时候自然是找esbuild文档,然后我们会在文档中找到不支持emitDecoratorMetadata特性,此时心里有句mmp 不知当讲不当讲

esbuild为什么不支持emitDecoratorMetadata呢?在这个issues Support emitting typescript decorator metadata里面找到了答案

The emitDecoratorMetadata flag is intentionally not supported. It relies on running the TypeScript type checker, which relies on running the TypeScript compiler, which is really slow. Given that esbuild's primary purpose is speed, I will not be integrating the TypeScript compiler into esbuild. I will also not be rewriting the TypeScript type checker in Go since that would be a massive ongoing maintenance burden. It's possible that you could write an esbuild plugin for this when esbuild's plugin API materializes (see #111) but that would likely eliminate most of the speed advantage of using esbuild in the first place. You're probably better off using another tool instead of esbuild if you need to do this.

说白了esbuild作者是有意不支持的,原因是主要就是自己写一个维护成本提高,用typescript本身的又会拖慢速度,所以不支持

然后在这个issue里面还能看到两个信息

  • 有esbuild插件支持emitDecoratorMetadata
  • swc支持emitDecoratorMetadata

先看esbuild插件支持emitDecoratorMetadata,提到了esbuild-plugin-tsc插件,我们看下这个插件是怎么处理emitDecoratorMetadata

typescript 复制代码
const typescript = require('typescript');
const esbuildPluginTsc = ({
  tsconfigPath = path.join(process.cwd(), './tsconfig.json'),
  force: forceTsc = false,
  tsx = true,
} = {}) => ({
  name: 'tsc',
  setup(build) {
    let parsedTsConfig = null;

    build.onLoad({ filter: tsx ? /\.tsx?$/ : /\.ts$/ }, async (args) => {

      // Just return if we don't need to search the file.
      if (
        !forceTsc &&
        (!parsedTsConfig ||
          !parsedTsConfig.options ||
          !parsedTsConfig.options.emitDecoratorMetadata)
      ) {
        return;
      }

      const ts = await fs
        .readFile(args.path, 'utf8')
        .catch((err) => printDiagnostics({ file: args.path, err }));

      // Find the decorator and if there isn't one, return out
      const hasDecorator = findDecorators(ts);
      if (!hasDecorator) {
        return;
      }

      const program = typescript.transpileModule(ts, {
        compilerOptions: parsedTsConfig.options,
        fileName: path.basename(args.path),
      });
      return { contents: program.outputText };
    });
  },
});

代码简单粗暴,esbuild插件onLoad构建内,也就是在esbuild处理之前,先读取ts文件内容,然后判断代码内是否包含装饰器,最终实际上也是通过tsc处理了一次

2.4 小结

  • esbuild不支持emitDecoratorMetadata
  • 有esbuild插件支持emitDecoratorMetadata,但是实际上里面还是用的typescript

3. swc编译ts

3.1 安装依赖

shell 复制代码
pnpm add @swc/core -D

现在使用的是最新版本,版本号是1.3.95

3.2 swc编译ts

创建编译脚本build.js,内容如下所示

typescript 复制代码
const swc = require("@swc/core");
const glob = require('glob');
const fs = require('fs-extra');

const files = glob.sync('src/**/*.ts');

function transfrom(file) {
    return swc
    .transformFile(file, {
        // Some options cannot be specified in .swcrc
        sourceMaps: false,
        // Input files are treated as module by default.
        // isModule: false,
        module: {
            type: 'commonjs'
        },
    
        // All options below can be configured via .swcrc
        jsc: {
            parser: {
                syntax: "typescript",
                decorators: true,
            },
            transform: {
                "legacyDecorator": true,
                "decoratorMetadata": true
            },
            target: 'es2017'
        },
        // "keepClassNames": true,
        // "loose": true
    })
  .then((output) => {
    // console.log(output.code); // transformed code
    return {
        file,
        output
    }
  });
}

(async () => {
    const result = await Promise.all(files.map((file) => {
        return transfrom(file)
    }));

    await Promise.all(result.map((item) => {
        return fs.outputFile(item.file.replace('src', 'build').replace('.ts', '.js'), item.output.code)
    }));
    console.timeEnd('swc build');
})()

本地的tsc编译项目,平均15s 本地的esbuild编译项目, 平均122ms 本地的swc编译项目,平均171 ms,相对于tsc编译过程提升将近100

3.3 运行编译产物

运行的时候,直接抛错,错误信息为TypeError: this.targetType.toLowerCase is not a function,如下所示 从错误栈,可以看出错误信息来自routing-controller 通过源码可以推测出与装饰器的metadata有关,那么问题还是出现在swc转换后的产物与tsc转换后的产物不一致导致,下面对比一下swc与tsc生成的产物

对比tsc与swc转换的产物 源代码

typescript 复制代码
@Get('/world')
hello(
  @QueryParam('method') method?: AuthenticationMethod,
  @QueryParam('type', { type: 'string' }) type?: timeTypeEnum,
  @QueryParam('page') page?: Page2,
): any {
  return 'hello world' + method + type + page
}
typescript 复制代码
export enum AuthenticationMethod {
  /** 未使用 */
  NotUsed = 0,
  /** 短信登录 */
  SMSLogin = 1,
  /** 账号登录 */
  AccountLogin = 2,
  /** 微信登录 */
  WeChatScanCodeLogin = 3,
}

export enum timeTypeEnum {
  TIME_TODAY = 'today',
  TIME_YESTERDAY = 'yesterday',
  TIME_7D = '7d',
  TIME_30D = '30d',
  TIME_ALL = 'all',
}

export enum Page2 {
  'big' = 'big',
  'small' = 'small',
  'middle' = 'middle'
}

转换后的产物对比 可以看到针对枚举类型,design:paramtypes这一项的转换结果是不一样的

typescript 复制代码
// tsc是Number, String, String
__metadata("design:paramtypes", [Number, String, String]),

// 而swc则是本身 or Object
_ts_metadata("design:paramtypes", [
      typeof _commontype.AuthenticationMethod === "undefined" ? Object : _commontype.AuthenticationMethod,
      typeof _commontype.timeTypeEnum === "undefined" ? Object : _commontype.timeTypeEnum,
      typeof _commontype.Page2 === "undefined" ? Object : _commontype.Page2
  ])

tsc是将枚举类型识别成了对应的简单类型,而swc则直接做的传入 or 识别成了Object

继续往下看 源代码

typescript 复制代码
@Get('/list')
list(
  @QueryParam('page') page?: Page,
): any {
  console.log('method', page);
  return 'hello world' + page
}
typescript 复制代码
export type Page = 'big' | 'small' | 'middle'

可以看到针对联合类型,design:paramtypes这一项的转换结果是不一样的

typescript 复制代码
export type Page = 'big' | 'small' | 'middle'

// 联合类型,在tsc下识别成了简单类型
__metadata("design:paramtypes", [String]),

// swc,则是本身 or Object
_ts_metadata("design:paramtypes", [
    typeof _commontype.Page === "undefined" ? Object : _commontype.Page
])

tsc是将联合类型识别成了对应的简单类型,而swc则直接做的传入 or 识别成了Object

继续往下看 源代码

typescript 复制代码
@Post('/save-world')
saveHello(
  @BodyParam('data') data?: dateSource
): any {
  return 'hello world' + JSON.stringify(data)
}
typescript 复制代码
export interface dateSource {
  id?: string;
  name?: string;
  code?: number;
  app_id?: string
}

可以看到针对接口类型,design:paramtypes这一项的转换结果也是不一样的

typescript 复制代码
// ts将interface识别成了Object
__metadata("design:paramtypes", [Object]),

// swc则是本身 or Object
_ts_metadata("design:paramtypes", [
    typeof _commontype.dateSource === "undefined" ? Object : _commontype.dateSource
])

tsc是将接口类型识别成了Object,而swc则直接做的传入 or 识别成了Object

继续往下看 源代码

typescript 复制代码
@Get('/test')
test(
  @QueryParam('type') type?: newTimeTypeEnum,
  @QueryParam('isTure') isTure = true,
): any {
  console.log('method', type, isTure);
  return 'hello test' + type + isTure
}
typescript 复制代码
export enum timeTypeEnum {
  TIME_TODAY = 'today',
  TIME_YESTERDAY = 'yesterday',
  TIME_7D = '7d',
  TIME_30D = '30d',
  TIME_ALL = 'all',
}
export type newTimeTypeEnum = timeTypeEnum

可以看到针对复杂类型及未定义类型,design:paramtypes这一项的转换结果也是不一样的

typescript 复制代码
// ts能够准确识别复杂类型,而未定义的类型则认为是Object
__metadata("design:paramtypes", [String, Object]),

// swc则不能识别复杂类型,及未定义的类型则认为是undefined
_ts_metadata("design:paramtypes", [
    typeof _commontype.newTimeTypeEnum === "undefined" ? Object : _commontype.newTimeTypeEnum,
    void 0
])

所以从上面的代码转换结果看,tsc输出design:paramtypes,与swc输出design:paramtypes有差异,差异表现在

  • 枚举
  • 联合类型
  • 接口
  • 复杂类型
  • 未定义类型

而这种差异,导致依赖design:paramtypes的库,运行结果就不一致了,这也就是上面routing-controller内的报错的原因

那么放弃swc替换tsc了吗?当然不是,做两步

  • 统计tsc运行时design:paramtypes的值,统计swc运行时design:paramtypes的值,然后对比差异
  • 劫持Reflect.metadata方法,自动修正swc场景下不对的类型,自动修复不了的就需要手动修复

统计差异,自动修复示例代码如下所示

typescript 复制代码
require("reflect-metadata");

process.paramtypes = {
  params: {},
  types: {}
}

let idx = 0;

const originMetadata = Reflect.metadata.bind(Reflect);

function getType (value) {
  if (value) {
      if (value instanceof Function && value.name) {
          return value.name.toLowerCase();
      }
      else if (typeof value === "string") {
          return value.toLowerCase();
      }
      try {
          return value instanceof Function || value.toLowerCase() === "object";
      } catch (error) {
          return 'undefined'
      }
  }
  return null
}

function validateEnum(item) {
let isEnum = true;
const keys = Object.keys(item);
let stringValue = 0;
let numberValue = 0;
keys.forEach((key) => {
  // 如果对象中有对象值,则判断不是枚举
  if (Object.prototype.toString.call(item[key]) === '[object Object]') {
    isEnum = false
  }
  // 判断数字枚举number,字符串枚举string,混合枚举object
  if (typeof item[key] === 'number') {
    numberValue += 1;
  }

  if (typeof item[key] === 'string') {
    stringValue += 1;
  }
})

return {
  isEnum,
  enumType: stringValue === keys.length ? String : (numberValue === (keys.length)/2 ? Number : Object)
}
}

Reflect.metadata = function (k, v) {
  let file = 'not found';
  try {
      throw new Error('测试')
  } catch (error) {
      // 不要带行号,因为两边转换生成的代码不一致,所以行号是不一样的,带上之后,在编辑器比对全是变化
      file = error.stack.match(/at Object.<anonymous> \\((.*?):.*?\\)/)[1]
  }

  if (k === 'design:paramtypes') {
      v && (v = v.map((item) => {
          if (Object.prototype.toString.call(item) === '[object Object]') {
              // 判断是否是真枚举对象,如果是则返回简单类型
              const result = validateEnum(item)
              if (result.isEnum) {
                return result.enumType
              }
              // 解决interface中包含枚举属性的场景,当interface中包含枚举的时候,整个interface会被转换为对象保留,而不是删除
              return Object
          }
          if (Object.prototype.toString.call(item) === '[object Undefined]') {
              return Object
          }
          return item
      }));
      process.paramtypes.params[idx] = {
          file,
          types: v.map((item) => {
              return getType(item)
              })
      }
      idx += 1

  } else if (k === 'design:type') {
      if (!process.paramtypes.types[file]) {
          process.paramtypes.types[file] = []
      }
      process.paramtypes.types[file].push(getType(v))
  }

  return originMetadata(k, v)
}

对比统计之后的json,这样就可以一目了然的知道差异在哪,然后针对性的来修复

经过上面的自动修正(枚举类型、接口类型)与手动修改部分不能自动修正的类型(复杂类型、联合类型),项目就可以使用swc替换tsc了

3.4 小结

  • swc支持emitDecoratorMetadata,但是目前生成的产物design:paramtypes与tsc有点差异,可能在实际运行的时候会报错,原因是因为一些依赖npm包都依赖design:paramtypes的值,如果碰到这种场景需要做一点改造才可以正常运行

4. 总结

esbuild编译ts速度最快,但是目前不支持装饰器metadata的场景,所以nodejs项目中如果依赖装饰器metadata的,是无法使用esbuild替换tsc

swc编译ts速度其次,目前支持装饰器metadata,但是针对一些枚举、接口等类型输出的design:paramtypes结果与tsc不一致,需要改造一下才可以替换

总结起来就nodejs项目而言

  • 编译速度来看esbuild > swc > tsc
  • 处理非emitDecoratorMetadata的场景,swc、esbuild可以尝试替换tsc
  • 处理emitDecoratorMetadata的场景,esbuild无法替换,而swc要做一些hack才能替换(后续swc可能会进一步优化针对emitDecoratorMetadata的处理,可能就与tsc保持一致了)

另外swc与esbuild目前都无法做ts类型检查,且无法生存.d.ts类型文件

当然以上只是作者自己在项目实践中得出的结论,或者还有一些目前没有碰到的问题,所以结论仅供参考,具体还得以实际项目为准

如果你觉得这篇文章对比有帮助,就动动小手点个👍吧

参考链接

深入浅出Typescript装饰器与Reflect元数据

Support emitting typescript decorator metadata

esbuild文档

swc文档

相关推荐
熊的猫14 分钟前
webpack 核心模块 — loader & plugins
前端·javascript·chrome·webpack·前端框架·node.js·ecmascript
速盾cdn20 分钟前
速盾:vue的cdn是干嘛的?
服务器·前端·网络
四喜花露水1 小时前
Vue 自定义icon组件封装SVG图标
前端·javascript·vue.js
前端Hardy1 小时前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
web Rookie2 小时前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript
Au_ust2 小时前
css:基础
前端·css
帅帅哥的兜兜2 小时前
css基础:底部固定,导航栏浮动在顶部
前端·css·css3
yi碗汤园2 小时前
【一文了解】C#基础-集合
开发语言·前端·unity·c#
就是个名称2 小时前
购物车-多元素组合动画css
前端·css
编程一生2 小时前
回调数据丢了?
运维·服务器·前端