由于Node和Deno的一些差异,一个库要想同时支持Node和Deno是需要一些改造的
本文翻译自EdgeDb博客:https://www.edgedb.com/blog/how-we-converted-our-node-js-library-to-deno-using-deno
如果需要将Deno项目直接迁移为Node项目可参考笔者另一篇文章deno 初体验,实战记录一个node项目迁移到deno需要做什么
Deno是一个新的JavaScript运行时,无需编译即原生支持TypeScript。它是由Node.js作者Ryan Dahl创建的,为了解决Node的一些基本设计、安全漏洞问题并集成了当前的一些开发实践如ES Module和TypeScript
在EdgeDb中,我们建立和维护了一个官方的npm上的Node.js客户端。然而,Deno使用了一套完全不同的实践来处理依赖,即直接从公共包库(如deno.land/x
)import
路径。我们将寻找一种简单的方法来Deno化
我们的代码库。也就是用最简单的重构从现有的Node.js实现中生成一个Deno兼容的模块。这解决维护和同步两个几乎相同的代码库的重复工作带来的问题
我们采用了一种“运行时适配器”模式。这是一种通用的解决方法对其他希望支持Deno库的作者也会有用
Node.js vs Deno
Node.js和Deno有一些重要的区别
TypeScript支持:
Deno可以直接执行TypeScript而Node.js只能运行JavaScript代码
模块解析:
默认情况下,Node.js使用CommonJS导入模块并使用require/module.exports
语法。它也有一个复杂的解析算法,会从node_modules
加载像react
这样的普通模块名,并在无额外扩展名导入时尝试添加.js
或.json
。如果导入路径是一个目录,则导入index.js
文件
Deno模块解析逻辑简化了很多。它使用了ECMAScript模块语法进行导入和导出。该语法也被TypeScript使用。所有导入必须是有显式文件扩展名的相对路径或者是一个URL
这意味着不存在像npm或yarn那样有node_module
或包管理器
。外部模块可以通过URL直接从公开代码库导入,比如deno.land/x
或GitHub
标准库:
Node.js有一些内置的标准模块如fs
、crypto
、http
。这些包名由Node.js保留。相比之下Deno标准库是通过https://deno.land/std/
URL导入的。Node和Deno标准库的功能也不同,Deno放弃了一些旧的或过时的Node.js api,引入了一个新的标准库(受Go的启发),并统一支持现代JavaScript特性如Promise
(而许多Node.js api仍然使用老的回调风格)
内置全局变量:
Deno所有的核心api都在全局变量Deno中,其它全局变量则只有标准的web api。和Node.js不同的是,Deno没有Buffer
或process
这些全局变量
所以需要如何做才能让我们的Node.js库尽可能容易地在Deno中运行呢?下面将一步一步进行改造
TypeScript和模块语法
幸运的是,我们无需考虑将CommonJS的require/module.exports
语法转换到到ESMimport/export
。我们使用用TypeScript编写edgedb-js
,它已经使用了ESM语法。在编译过程中,tsc
将我们的文件转换成普通的=CommonJS语法的JavaScript文件。Node.js可以直接运行编译后的文件
本文下面将讨论如何将TypeScript源文件修改为Deno可以直接使用的格式
依赖
edgedb-js
没有任何第三方依赖,所以这里不必担心任何三方库的Deno兼容性问题。但仍需要将所有从Node.js标准库中导入(例如path
、fs
等)替换为等价的Deno文件
注意:如果你的包确实依赖于外部包,可在deno.land/x
中查看是否有Deno版本
由于Deno标准库提供了Node.js兼容模块,这个改造比较简单。Deno的标准库上提供了一个包装器并尽可能和Node的api保持一致
1 2
| - import * as crypto from "crypto"; + import * as crypto from "https://deno.land/std@0.114.0/node/crypto.ts";
|
为了简化问题,将所有Node.js api导入移到一个名为adapter.node.ts
的文件中,并只重新导出我们需要的功能
1 2 3 4 5 6
| import * as path from "path"; import * as util from "util"; import * as crypto from "crypto";
export {path, net, crypto};
|
然后在一个名为adapter.deno.ts
的文件中为Deno实现相同的适配器
1 2 3 4 5 6
| import * as crypto from "https://deno.land/std@0.114.0/node/crypto.ts"; import path from "https://deno.land/std@0.114.0/node/path.ts"; import util from "https://deno.land/std@0.114.0/node/util.ts";
export {path, util, crypto};
|
当需要使用Node.js的特定功能时,直接从adapter.node.ts
导入这些功能。通过这种方式,可以通过简单地将所有adapter.node.ts
导入重写为adapter.deno.ts
即可使edgedb-js
兼容Deno。只要确保这些文件重新导出相同的功能就能符合预期
但实际上应该如何重写这些导入呢。这里我们需要开发一个简单的codemod脚本。下面将使用Deno来开发这个脚本
开发Deno-ifier
在开发之前,列举下需要做的事情:
将Node.js风格的导入重写为更显式的Deno风格。包括添加.ts
扩展名和目录导入添加/index.ts
将adapter.node.ts
的导入替换成从adapter.deno.ts
的导入
注入Node.js全局变量(如process
和Buffer
)到Deno的代码中。虽然可以简单地从适配器导出这些变量,但我们必须重构Node.js文件以显式地导入它们。为了简化处理,将检测代码中使用了Node.js全局变量的时候注入一个导入
将src
目录重命名为_src
,表示它只被edgedb-js
内部使用不应该被外部直接导入使用
将主入口文件src/index.ts
移动到项目根目录并重命名为mod.ts
。这是Deno中的习惯用法(这里index.node.ts
的命名并不表明它是只能给Node.js使用而是用来区别于index.browser.ts
,index.browser.ts
导出的是edgedb-js
中浏览器兼容的部分代码)
获取所有文件列表
第一步先获取出源文件。Deno原生fs
模块提供了walk
函数可以实现:
1 2 3 4 5 6
| import {walk} from "https://deno.land/std@0.114.0/fs/mod.ts";
const sourceDir = "./src"; for await (const entry of walk(sourceDir, {includeDirs: false})) { }
|
注意:这里使用的是Deno原生的std/fs
模块而不是Node兼容版本的std/node/fs
声明一个重写规则集合并初始化一个Map
对象表示源文件路径到需要重写的目标文件的路径
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const sourceDir = "./src"; const destDir = "./edgedb-deno"; const pathRewriteRules = [ {match: /^src\/index.node.ts$/, replace: "mod.ts"}, {match: /^src\//, replace: "_src/"}, ];
const sourceFilePathMap = new Map<string, string>();
for await (const entry of walk(sourceDir, {includeDirs: false})) { const sourcePath = entry.path; sourceFilePathMap.set(sourcePath, resolveDestPath(sourcePath)); }
function resolveDestPath(sourcePath: string) { let destPath = sourcePath; for (const rule of pathRewriteRules) { destPath = destPath.replace(rule.match, rule.replace); } return join(destDir, destPath); }
|
这非常简单,下面开始开发修改源代码部分
重写import和export
要重写导入路径需要知道它们在文件中的位置。我们将使用TypeScript的Compiler API来将源文件解析为抽象语法树并找到导入语句
为了实现这个功能我们需要用到typescript
NPM包的compile API
。Deno的兼容模块提供了一个直接从CommonJS模块导入的方式。需要在执行Deno代码的时候使用--unstable
标识,对于构建阶段这不是什么问题
1 2 3 4
| import {createRequire} from "https://deno.land/std@0.114.0/node/module.ts";
const require = createRequire(import.meta.url); const ts = require("typescript");
|
下面遍历文件并依次解析
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
| import {walk, ensureDir} from "https://deno.land/std@0.114.0/fs/mod.ts"; import {createRequire} from "https://deno.land/std@0.114.0/node/module.ts";
const require = createRequire(import.meta.url); const ts = require("typescript");
for (const [sourcePath, destPath] of sourceFilePathMap) { compileFileForDeno(sourcePath, destPath); }
async function compileFileForDeno(sourcePath: string, destPath: string) { const file = await Deno.readTextFile(sourcePath); await ensureDir(dirname(destPath));
if (destPath.endsWith(".deno.ts")) return Deno.writeTextFile(destPath, file); if (destPath.endsWith(".node.ts")) return;
const parsedSource = ts.createSourceFile( basename(sourcePath), file, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS ); }
|
对于每个AST,我们通过遍历顶层节点找出import
和export
语句。这里无需深层查找,因为import/export
只会出现在顶级作用域(也无需处理动态import()
,因为edgedb-js
中也没有使用)
从这些节点中,获取源文件中export/import
路径的开始和结束偏移量。然后可以通过切片取代路径内容并插入修改后的路径来重写导入
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
| const parsedSource = ts.createSourceFile();
const rewrittenFile: string[] = []; let cursor = 0; parsedSource.forEachChild((node: any) => { if ( (node.kind === ts.SyntaxKind.ImportDeclaration || node.kind === ts.SyntaxKind.ExportDeclaration) && node.moduleSpecifier ) { const pos = node.moduleSpecifier.pos + 2; const end = node.moduleSpecifier.end - 1; const importPath = file.slice(pos, end);
rewrittenFile.push(file.slice(cursor, pos)); cursor = end;
let resolvedImportPath = resolveImportPath(importPath, sourcePath); if (resolvedImportPath.endsWith("/adapter.node.ts")) { resolvedImportPath = resolvedImportPath.replace( "/adapter.node.ts", "/adapter.deno.ts" ); }
rewrittenFile.push(resolvedImportPath); } });
rewrittenFile.push(file.slice(cursor));
|
这里的关键部分是resolveImportPath
函数。它通过试错查找的方式实现将Node.js风格的引入转化为Deno风格的导入。首先检查路径是否对应于实际文件;如果失败了会尝试添加.ts
;如果再失败则尝试添加/index.ts
;如果再失败则抛出一个错误。
注入Node.js全局变量
最后一步是处理Node.js全局变量。首先在创建一个global.deno.ts
文件。这个文件应该导出包中使用的所有Node.js全局变量的Deno兼容版本
1 2 3
| export {Buffer} from "https://deno.land/std@0.114.0/node/buffer.ts"; export {process} from "https://deno.land/std@0.114.0/node/process.ts";
|
通过编译后的AST可以拿到源文件中所有全局变量的集合。将使用它在任何引用这些全局变量的文件中注入import语句
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 32 33 34 35 36 37 38 39
| const sourceDir = "./src"; const destDir = "./edgedb-deno"; const pathRewriteRules = [ {match: /^src\/index.node.ts$/, replace: "mod.ts"}, {match: /^src\//, replace: "_src/"}, ]; const injectImports = { imports: ["Buffer", "process"], from: "src/globals.deno.ts", };
const rewrittenFile: string[] = []; let cursor = 0; let isFirstNode = true; parsedSource.forEachChild((node: any) => { if (isFirstNode) { isFirstNode = false;
const neededImports = injectImports.imports.filter((importName) => parsedSource.identifiers?.has(importName) );
if (neededImports.length) { const imports = neededImports.join(", "); const importPath = resolveImportPath( relative(dirname(sourcePath), injectImports.from), sourcePath ); const importDecl = `import {${imports}} from "${importPath}";\n\n`;
const injectPos = node.getLeadingTriviaWidth?.(parsedSource) ?? node.pos; rewrittenFile.push(file.slice(cursor, injectPos)); rewrittenFile.push(importDecl); cursor = injectPos; } }
|
写入文件
删除老文件的内容并依次写入每个文件
1 2 3 4 5 6 7 8 9
| try { await Deno.remove(destDir, {recursive: true}); } catch {}
const sourceFilePathMap = new Map<string, string>(); for (const [sourcePath, destPath] of sourceFilePathMap) { await Deno.writeTextFile(destPath, rewrittenFile.join("")); }
|
持续集成
一个常见的做法是为包的Deno版本维护一个单独的自动生成的仓库。在我们的例子中,每当一个新的提交合并到master时,将在GitHub Actions中生成edgedb-js
的Deno版本。然后生成的文件被发布到edgedb-deno
仓库。下面是工作流的简化版本
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
| name: Deno Release on: push: branches: - master jobs: release: runs-on: ubuntu-latest steps: - name: Checkout edgedb-js uses: actions/checkout@v2 - name: Checkout edgedb-deno uses: actions/checkout@v2 with: token: ${{ secrets.GITHUB_TOKEN }} repository: edgedb/edgedb-deno path: edgedb-deno - uses: actions/setup-node@v2 - uses: denoland/setup-deno@v1 - name: Install deps run: yarn install - name: Get version from package.json id: package-version uses: martinbeentjes/npm-get-version-action@v1.1.0 - name: Write version to file run: echo "${{ steps.package-version.outputs.current-version}}" > edgedb-deno/version.txt - name: Compile for Deno run: deno run --unstable --allow-env --allow-read --allow-write tools/compileForDeno.ts - name: Push to edgedb-deno run: cd edgedb-deno && git add . -f && git commit -m "Build from $GITHUB_SHA" && git push
|
edgedb-deno
内部的另一个工作流则会创建一个GitHub release并发布到deno.land/x
。可参考
封装
这就是将现存Node.js模块转换到Deno的通常方法。具体可参考Deno编译脚本
和workflow