<返回更多

如何将 Node.js 库转换为 Deno 库(使用 Deno)

2022-06-06    Hugo.S
加入收藏

原文:
www.edgedb.com/blog/how-we…

作者:James Clarke

发布时间:MAY 26, 2022

文章首发于知乎
zhuanlan.zhihu.com/p/524296632 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Deno 是新出的 JAVAScript 的运行时,默认支持 TypeScript 而不需要编译。 Deno 的创作者 Ryan Dahl,同样也是 Node.js 的创作者,解决了很多 Node 的基础设计问题和安全漏洞,更好的支持了 ESM 和 TypeScript。

在 EdgeDB 中,我们开发了 Node.js 下的 EdgeDB client 库 ,并且在 NPM 中发布。然而 Deno 有完全不同的另一套依赖管理机制,imports URL 地址 来引入 在 deno.land/x 上发布的包。我们想找到一条简单的路 “Denoify” 代码库,可以将现有的 Node.js 包 生成 Deno 可以使用的包。这样可以减轻维护的复杂度。

Node.js vs Deno

Node.js 和 Deno 的运行时有几个关键的区别,我们在调整使用 Node.js 编写的库时必须考虑到:

  1. TypeScript 支持:Deno 可以直接执行 TypeScript 文件,Node.js 只能运行 JavaScript 代码。
  2. 模块解析:默认 Node.js 支持 CommonJS 规范的模块引入,使用 require/module.exports 语法。同时 Node.js 有一个复杂的依赖解析算法, 当 从 node_modules 中加载像 react 这样的模块时,对于 react 导出的内容会自动加后缀,比如说增加 .js 或 .json,如果 import 是目录的话,将会直接查找目录下的 index.js 文件。Deno 极大简化了这一过程。Deno 支持 ESM 模块规范,支持 import /export 语法。同时 TypeScript 同样支持这一语法。所有的引入如果不是一个相对路径包含显示文件扩展名就是一个 URL 路径。这表明 Deno 不需要 node_modules 文件或是如 npm 或是 yarn 之类的包管理工具。外部包通过 URL 路径直接导入,如 deno.land/x 或 GitHub。
  3. 标准库:Node.js 有一套内置的标准模块,如 fs path crypto http 等。这些模块可以直接通过 require(’fs’) 导入。Deno 的标准库是通过 URL deno.land/std/ 导入。两个标准库的功能也不同,Deno 放弃了一些旧的或过时的 Node.js api,引入了一个新的标准库(受 Go 的启发),统一支持现代 JavaScript 特性,如 Promises (而许多 Node.js api 仍然依赖于旧的回调风格)。
  4. 内置的全局变量:Deno 将核心 API 都封装在 Deno 变量下,除了这之外 就没有其他暴露的全局变量,没有 Node.js 里的 Buffer 和 process 变量。

因此,我们如何才能解决这些差异,并让我们的 Node.js 库尽可能轻松运行在 Deno ?让我们逐一分析这些变化。

TypeScript 和模块语法

幸运的是,我们不需要太担心将 CommonJS require/module 语法转换为 ESM import/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 的版本。

由于 Deno 标准库提供了 Node.js 兼容模块,这个任务变得更加简单。这个模块在 Deno 的标准库上提供了一个包装器,它试图尽可能忠实地遵守 Node 的 API。

- 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 文件中,然后重新导出

// adapter.node.ts
import * as path from "path";
import * as util from "util";
import * as crypto from "crypto";

export {path,.NET, crypto};
复制代码

同样 我们在 Deno 中使用相同的方式 adapter.deno.ts

// adapter.deno.ts
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 本身来编写这个脚本。

写 Denoify 脚本

首先我们列举下脚本需要实现的功能:

  • 将 Node.js 式 import 转换为更具体的 Deno 式引入。具体是将引用文件都增加 .ts 后缀,给引用目录都增加 /index.ts 文件。
  • 将 adapter.node 文件中的引用都转换到 adapter.deno.ts
  • 将 Node.js 全局变量 如 process 和 Buffer 注入到 Deno-ified code。虽然我们可以简单地从适配器导出这些变量,但我们必须重构 Node.js 文件以显式地导入它们。为了简化,我们将检测在哪里使用了 Node.js 全局变量,并在需要时注入一个导入。
  • 将 src 目录重命名为 _src,表示它是 edgedb-js 的内部文件,不应该直接导入
  • 将 main 目录下的 src/index.node.ts 文件都移动到项目根目录,并重命名为 mod.ts。注意:这里的 index.node.ts 并不表明这是 node 格式的,只是为了区分 index.browser.ts

创建一系列文件

首先,我们需要计算源文件的列表。

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})) {
  // iterate through all files
}
复制代码

⚠️注意:我们这里使用的是 Deno 的 std/fs,而不是 Node 的 std/node/fs。

让我们声明一组重写规则,并初始化一个 Map,它将从源文件路径映射到重写的目标路径。

  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;
+   // Apply all rewrite rules
+   for (const rule of pathRewriteRules) {
+    destPath = destPath.replace(rule.match, rule.replace);
+  }
+  return join(destDir, destPath);
+}
复制代码

以上部分比较简单,下面开始修改源文件。

重写 imports 和 exports

为了重写 import 路径,我们需要知道文件的存放位置。幸运的是 TypeScript 曝露了 编译 API,我们可以用来解析源文件到 AST,并查找 import 声明。

我们需要从 typescript 的 NPM 包中 import 编译 API。幸运的是,Deno 提供了引用 CommonJS 规范的方法,需要在运行时 添加 --unstable 参数

import {createRequire} from "<https://deno.land/std@0.114.0/node/module.ts>";

const require = createRequire(import.meta.url);
const ts = require("typescript");
复制代码

让我们遍历这些文件并依次解析每个文件。

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 file ends with '.deno.ts', copy the file unchanged
  if (destPath.endsWith(".deno.ts")) return Deno.writeTextFile(destPath, file);
  // if file ends with '.node.ts', skip file
  if (destPath.endsWith(".node.ts")) return;

  // parse the source file using the typescript Compiler API
  const parsedSource = ts.createSourceFile(
    basename(sourcePath),
    file,
    ts.ScriptTarget.Latest,
    false,
    ts.ScriptKind.TS
  );
}
复制代码

对于每个已解析的 AST,让我们遍历其顶层节点以查找 import 和 export 声明。我们不需要深入研究,因为 import/export 总是 top-level 语句(除了动态引用 dynamic import(),但我们在 edgedb-js中不使用它们)。

从这些节点中,我们提取源文件中 import/export 路径的开始和结束偏移量。然后,我们可以通过切片当前内容并插入修改后的路径来重写导入。

  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;
+
+    // replace the adapter import with Deno version
+    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 类型的引入改为 Deno 类型的引入 。首先,它检查路径是否对应于磁盘上的实际文件;如果失败了,它会尝试添加 .ts 后缀;如果失败,它尝试添加 /index.ts;如果失败,就会抛出一个错误。

注入 Node.js 全局变量

最后一步是处理 Node.js 全局变量。首先,我们在项目目录中创建一个 global .deno.ts 文件。这个文件应该导出包中使用的所有 Node.js 全局变量的兼容版本。

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 语句。

 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) {  // only run once per file
+    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}";nn`;

+      const injectPos = node.getLeadingTriviaWidth?.(parsedSource) ?? node.pos;
+      rewrittenFile.push(file.slice(cursor, injectPos));
+      rewrittenFile.push(importDecl);
       cursor = injectPos;
     }
   }
复制代码

写文件

最后,我们准备将重写的源文件写入目标目录中的新主目录。首先,我们删除所有现有的内容,然后依次写入每个文件。

+ try {
+   await Deno.remove(destDir, {recursive: true});
+ } catch {}

 const sourceFilePathMap = new Map<string, string>();
 for (const [sourcePath, destPath] of sourceFilePathMap) {
  // rewrite file
+  await Deno.writeTextFile(destPath, rewrittenFile.join(""));
 }
复制代码

持续集成

一个常见的模式是为包的 Deno 版本维护一个单独的自动生成的 repo。在我们的例子中,每当一个新的提交合并到 master 中时,我们就在 GitHub Actions 中生成 edgedb-js 的 Deno 版本。然后,生成的文件被发布到名为 edgedb-deno 的姊妹存储库。下面是我们的工作流文件的简化版本。

# .github/workflows/deno-release.yml
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 发布,发布一个新版本到 deno.land/x。这留给读者作为练习,尽管您可以使用我们的工作流作为起点。

总结

这是一个可广泛应用的模式,用于将现有的 Node.js 模块转换为 Deno 模块。参考 edgedb-js repo获得完整的 Deno 编译脚本,跨工作流。

声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多资讯 >>>