使用 vite 开发和构建 nodejs 应用

本文最后更新于:2023年6月19日 下午

场景

vite 作为现代 web 应用的构建工具,许多开发者将之用于开发 web 应用(react、vue),由于其本身的易用性和高性能,许多 web 框架甚至官方编写了插件(solid、astro),可以称得上是近年来 webpack 的成功的挑战者。但实际上,发展至今,vite 已经远不仅仅是一个 web 层的工具,它的周边生态正在蓬勃发展,衍生除了一系列周边的工具,在之前的 vite-不仅仅是一个构建工具 有所提及,这里专门针对开发 nodejs 应用来说明。

动机

vite 为什么适合开发 nodejs 应用?

首先,即便不使用 vite,你可能也需要使用 vitest 之类的工具进行单元测试,使用 tsx/ts-node 运行源代码调试,使用 tsup/esbuild 将代码 bundle 为最终要运行的 js。那么,如果使用了 vite,这些事情将在同一个生态中完成。

  • vitest: 单元测试工具,支持 esm,支持 ts
  • vite-node: ts 代码的运行工具,支持 vite 的各种特性,例如 ?raw 之类的
  • vite: 将 nodejs 应用打包最终运行的 js,可以选择性的捆绑依赖

开发流程.drawio.svg

使用

vitest 和 vite-node 都是现成的,可以开箱即用,所以下面重点关注 vite 构建相关的事情。

首先安装依赖

1
pnpm i -D vite vite-node vitest

vitest

创建一个单元测试文件,例如 src/__tests__/index.test.ts

1
2
3
4
5
import { it } from 'vitest'

it('hello world', () => {
expect(1 + 1).eq(2)
})

使用下面的命令运行 vitest

1
pnpm vitest src/__tests__/index.test.ts

vite-node

可以这样说,你可以使用它替代 node 命令运行任何文件,它只是比 node 命令更加强大,包括

  • 支持 ts/tsx/esm
  • 在 esm 中有 cjs 的 polyfill,__dirname 什么的可以直接使用
  • 支持监视模式运行
  • 支持 vite 本身的特性,例如 ?raw
  • 支持使用 vite 插件

例如创建一个 src/main.ts 文件

1
2
3
import { readFile } from 'fs/promises'

console.log(await readFile(__filename, 'utf-8'))

然后使用 vite-node 运行

1
pnpm vite-node src/main.ts

vite

vite 想要构建 nodejs 应用,确实需要修改一些配置和插件,主要解决几个问题

  1. 为 esm 代码实现 cjs polyfill,包括 __dirname/__filename/require/self
  2. 正确捆绑 devDependencies 的依赖项,但排除 node/dependencies 的依赖
  3. 提供一个开箱即用的默认配置

然后我们分别解决这几个问题

构建时针对 cjs 特性做 polyfill

安装 magic-string,用于修改代码保持 sourcemap

1
pnpm i -D magic-string

然后再 renderChunk hook 中添加 polyfill 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import MagicString from 'magic-string'

function shims(): Plugin {
return {
name: 'node-shims',
renderChunk(code, chunk) {
if (!chunk.fileName.endsWith('.js')) {
return
}
// console.log('transform', chunk.fileName)
const s = new MagicString(code)
s.prepend(`
import __path from 'path'
import { fileURLToPath as __fileURLToPath } from 'url'
import { createRequire as __createRequire } from 'module'

const __getFilename = () => __fileURLToPath(import.meta.url)
const __getDirname = () => __path.dirname(__getFilename())
const __dirname = __getDirname()
const __filename = __getFilename()
const self = globalThis
const require = __createRequire(import.meta.url)
`)
return {
code: s.toString(),
map: s.generateMap(),
}
},
apply: 'build',
}
}

正确捆绑依赖项

这里为了简化使用现有的 rollup-plugin-node-externals 插件,它可以排除 node 的依赖,也会自动根据 package.json 中的 dependencies 和 devDependencies 进行排除,但需要针对 vite 做一些小的兼容性修改。

安装依赖

1
pnpm i -D rollup-plugin-node-externals

简单代理封装一下

1
2
3
4
5
6
7
8
9
10
import { nodeExternals } from 'rollup-plugin-node-externals'

function externals(): Plugin {
return {
...nodeExternals(),
name: 'node-externals',
enforce: 'pre',
apply: 'build',
}
}

添加默认配置

由于吾辈有很多项目,所以不希望每次都填写配置,而是通过约定 + 支持配置的方式来解决这个问题,所以简单实现一个 vite 插件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import path from 'path'

function config(options?: { entry?: string }): Plugin {
const entry = options?.entry ?? 'src/main.ts'
return {
name: 'node-config',
config() {
return {
build: {
lib: {
entry,
formats: ['es'],
fileName: path.basename(entry, path.extname(entry)),
},
},
}
},
apply: 'build',
}
}

合并插件

最终,我们将这些插件合并到一起,就可以使用 vite 构建 nodejs 应用了。

1
2
3
export function node(): Plugin[] {
return [shims(), externals(), config()]
}

然后在 vite.config.ts 中使用

1
2
3
export default defineConfig({
plugins: [node()],
})

现在,我们可以使用 vite 构建 nodejs 应用了

1
pnpm vite build

享受 vite 带来的一切吧!

吾辈发布了一个 vite 插件 @liuli-util/vite-plugin-node,已经解决了上面的几个问题。

局限性

好吧,这里其实仍然存在一些问题,包括

  • vite 没有官方支持构建 node 应用,项目的主要目标也不是它
  • vite-plugin-node 还有许多问题,例如没有自动 polyfill __dirname 之类的
  • vite 相比 esbuild 的性能仍然差了一个数量级

没什么选择是尽善尽美的,但吾辈现在选择相信 vite。

遇到的问题

未来的目标

  • 多入口点支持

使用 vite 开发和构建 nodejs 应用
https://blog.rxliuli.com/p/49fb661c297b4544a208ea898c77e5a0/
作者
rxliuli
发布于
2023年6月6日
许可协议