使用 vite 开发和构建 nodejs 应用

本文最后更新于:2024年3月25日 凌晨

场景

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

参考:

vite-node

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

  • 支持 ts/tsx 文件,也可以运行 esm/cjs 模块
  • 在 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

解决热更新时 server 没有重启的问题

例如下面这个简单的 http server 示例

1
2
3
4
5
6
7
8
9
10
import express from 'express'
import { createServer } from 'http'

const app = express().use(express.json())
app.get('/', (_req, res) => {
res.send('Hello World')
})
const server = createServer(app)
server.listen(8080)
console.log(`Server listening on http://localhost:8080`)

当使用 vite-node -w 监视模式运行时就可能出现丑陋的错误,因为 vite hmr 时并未杀死之前启动的服务器。

1
2
[vite-node] Failed to execute file:
Error: listen EADDRINUSE: address already in use :::8080

解决方法很简单,手动监视 hmr 并杀死之前的 server 即可。

1
2
3
4
5
if (import.meta.hot) {
const close = () => server.close()
import.meta.hot.accept(close)
import.meta.hot.dispose(close)
}

vite

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

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

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

构建时针对 cjs 特性做 polyfill

在 nodejs 中,可能会使用 __dirname 之类的全局变量,遗憾的是,esm 并不支持它。nodejs 推荐的做法是

1
2
3
4
5
import path from 'path'
import { fileURLToPath } from 'url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

可以使用 vite 插件在构建之后的代码最前面添加一些额外的代码,自动创建这些变量。

具体实现

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

    1
    pnpm i -D magic-string
  2. 然后在钩子 renderChunk 中添加 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',
    }
    }

正确捆绑依赖项

由于 nodejs 应用中引入 fs 等 nodejs 模块很常见,而 vite 默认也会尝试捆绑它们,所以需要将它们作为外部依赖项。幸运的是已经有插件 rollup-plugin-node-externals,它可以排除 nodejs 和 dependencies 声明的依赖项,但需要针对 vite 做一些小的兼容性修改。

  1. 安装依赖

    1
    pnpm i -D rollup-plugin-node-externals
  2. 简单封装一下

    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', // 关键是要在 vite 默认的依赖解析插件之前运行
    apply: 'build',
    }
    }

添加默认配置

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

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
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)),
},
},
resolve: {
// 修改解析方式默认为 node 而非 browser
mainFields: ['module', 'jsnext:main', 'jsnext'],
conditions: ['node'],
},
}
},
apply: 'build',
}
}

合并插件

最终,我们将这些插件合并到一起,产生一个新的插件

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 应用,项目的主要目标也不是它 – 至少可以说 vitest/nuxt 都在 node 层面依赖着 vite
  • vite-plugin-node 还有许多问题,例如没有自动 polyfill __dirname 之类的 – 已实现
  • vite 相比 esbuild 的性能仍然差了一个数量级 – 函数库已经大多数应用性能问题不大,人类感知不太能感知到
  • zx 构建之后无法运行 – chalk 没有正确的配置 – 无法识别 imports 中的 node 字段 – 维护者不在乎,需要考虑更换为 ansi-colors,ref: https://github.com/chalk/chalk/issues/535
  • koa-bodyparser 构建之后存在问题 – 没有正确支持 esm – 等待 https://github.com/koajs/bodyparser/pull/152 合并

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

未来的目标

  • 多入口点支持
  • 生成类型定义

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