We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
由于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.land/x
import
Deno化
我们采用了一种“运行时适配器”模式。这是一种通用的解决方法对其他希望支持Deno库的作者也会有用
Node.js和Deno有一些重要的区别
TypeScript支持: Deno可以直接执行TypeScript而Node.js只能运行JavaScript代码
模块解析: 默认情况下,Node.js使用CommonJS导入模块并使用require/module.exports语法。它也有一个复杂的解析算法,会从node_modules加载像react这样的普通模块名,并在无额外扩展名导入时尝试添加.js或.json。如果导入路径是一个目录,则导入index.js文件
require/module.exports
node_modules
react
.js
.json
index.js
Deno模块解析逻辑简化了很多。它使用了ECMAScript模块语法进行导入和导出。该语法也被TypeScript使用。所有导入必须是有显式文件扩展名的相对路径或者是一个URL
这意味着不存在像npm或yarn那样有node_module或包管理器。外部模块可以通过URL直接从公开代码库导入,比如deno.land/x或GitHub
node_module
包管理器
标准库: 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仍然使用老的回调风格)
fs
crypto
http
https://deno.land/std/
Promise
内置全局变量: Deno所有的核心api都在全局变量Deno中,其它全局变量则只有标准的web api。和Node.js不同的是,Deno没有Buffer或process这些全局变量
Buffer
process
所以需要如何做才能让我们的Node.js库尽可能容易地在Deno中运行呢?下面将一步一步进行改造
幸运的是,我们无需考虑将CommonJS的require/module.exports语法转换到到ESMimport/export。我们使用用TypeScript编写edgedb-js,它已经使用了ESM语法。在编译过程中,tsc将我们的文件转换成普通的=CommonJS语法的JavaScript文件。Node.js可以直接运行编译后的文件
import/export
edgedb-js
tsc
本文下面将讨论如何将TypeScript源文件修改为Deno可以直接使用的格式
edgedb-js没有任何第三方依赖,所以这里不必担心任何三方库的Deno兼容性问题。但仍需要将所有从Node.js标准库中导入(例如path、fs等)替换为等价的Deno文件
path
注意:如果你的包确实依赖于外部包,可在deno.land/x中查看是否有Deno版本
由于Deno标准库提供了Node.js兼容模块,这个改造比较简单。Deno的标准库上提供了一个包装器并尽可能和Node的api保持一致
- import * as crypto from "crypto"; + import * as crypto from "https://deno.land/[email protected]/node/crypto.ts";
为了简化问题,将所有Node.js api导入移到一个名为adapter.node.ts的文件中,并只重新导出我们需要的功能
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};
然后在一个名为adapter.deno.ts的文件中为Deno实现相同的适配器
adapter.deno.ts
// adapter.deno.ts import * as crypto from "https://deno.land/[email protected]/node/crypto.ts"; import path from "https://deno.land/[email protected]/node/path.ts"; import util from "https://deno.land/[email protected]/node/util.ts"; export {path, util, crypto};
当需要使用Node.js的特定功能时,直接从adapter.node.ts导入这些功能。通过这种方式,可以通过简单地将所有adapter.node.ts导入重写为adapter.deno.ts即可使edgedb-js兼容Deno。只要确保这些文件重新导出相同的功能就能符合预期
但实际上应该如何重写这些导入呢。这里我们需要开发一个简单的codemod脚本。下面将使用Deno来开发这个脚本
在开发之前,列举下需要做的事情:
将Node.js风格的导入重写为更显式的Deno风格。包括添加.ts扩展名和目录导入添加/index.ts
.ts
/index.ts
将adapter.node.ts的导入替换成从adapter.deno.ts的导入
注入Node.js全局变量(如process和Buffer)到Deno的代码中。虽然可以简单地从适配器导出这些变量,但我们必须重构Node.js文件以显式地导入它们。为了简化处理,将检测代码中使用了Node.js全局变量的时候注入一个导入
将src目录重命名为_src,表示它只被edgedb-js内部使用不应该被外部直接导入使用
src
_src
将主入口文件src/index.ts移动到项目根目录并重命名为mod.ts。这是Deno中的习惯用法(这里index.node.ts的命名并不表明它是只能给Node.js使用而是用来区别于index.browser.ts,index.browser.ts导出的是edgedb-js中浏览器兼容的部分代码)
src/index.ts
mod.ts
index.node.ts
index.browser.ts
第一步先获取出源文件。Deno原生fs模块提供了walk函数可以实现:
walk
import {walk} from "https://deno.land/[email protected]/fs/mod.ts"; const sourceDir = "./src"; for await (const entry of walk(sourceDir, {includeDirs: false})) { // 遍历源文件 }
注意:这里使用的是Deno原生的std/fs模块而不是Node兼容版本的std/node/fs
std/fs
std/node/fs
声明一个重写规则集合并初始化一个Map对象表示源文件路径到需要重写的目标文件的路径
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; // 使用重写规则 for (const rule of pathRewriteRules) { destPath = destPath.replace(rule.match, rule.replace); } return join(destDir, destPath); }
这非常简单,下面开始开发修改源代码部分
要重写导入路径需要知道它们在文件中的位置。我们将使用TypeScript的Compiler API来将源文件解析为抽象语法树并找到导入语句
为了实现这个功能我们需要用到typescript NPM包的compile API。Deno的兼容模块提供了一个直接从CommonJS模块导入的方式。需要在执行Deno代码的时候使用--unstable标识,对于构建阶段这不是什么问题
typescript
compile API
--unstable
import {createRequire} from "https://deno.land/[email protected]/node/module.ts"; const require = createRequire(import.meta.url); const ts = require("typescript");
下面遍历文件并依次解析
import {walk, ensureDir} from "https://deno.land/[email protected]/fs/mod.ts"; import {createRequire} from "https://deno.land/[email protected]/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)); // 如果文件以'.deno.ts'结尾则直接复制无需修改 if (destPath.endsWith(".deno.ts")) return Deno.writeTextFile(destPath, file); // 如果文件以`.node.ts`结尾则跳过 if (destPath.endsWith(".node.ts")) return; // 使用typescript Compiler API解析源文件 const parsedSource = ts.createSourceFile( basename(sourcePath), file, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS ); }
对于每个AST,我们通过遍历顶层节点找出import和export语句。这里无需深层查找,因为import/export只会出现在顶级作用域(也无需处理动态import(),因为edgedb-js中也没有使用)
export
import()
从这些节点中,获取源文件中export/import路径的开始和结束偏移量。然后可以通过切片取代路径内容并插入修改后的路径来重写导入
export/import
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; // 使用Deno版本的导入替换导入适配文件 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;如果再失败则抛出一个错误。
resolveImportPath
最后一步是处理Node.js全局变量。首先在创建一个global.deno.ts文件。这个文件应该导出包中使用的所有Node.js全局变量的Deno兼容版本
global.deno.ts
// global.deno.ts export {Buffer} from "https://deno.land/[email protected]/node/buffer.ts"; export {process} from "https://deno.land/[email protected]/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) { // 每个文件只执行一次 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; } }
删除老文件的内容并依次写入每个文件
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仓库。下面是工作流的简化版本
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/[email protected] - 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
Deno编译脚本
workflow
The text was updated successfully, but these errors were encountered:
No branches or pull requests
由于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标准库提供了Node.js兼容模块,这个改造比较简单。Deno的标准库上提供了一个包装器并尽可能和Node的api保持一致
为了简化问题,将所有Node.js api导入移到一个名为
adapter.node.ts
的文件中,并只重新导出我们需要的功能然后在一个名为
adapter.deno.ts
的文件中为Deno实现相同的适配器当需要使用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
函数可以实现:声明一个重写规则集合并初始化一个
Map
对象表示源文件路径到需要重写的目标文件的路径这非常简单,下面开始开发修改源代码部分
重写import和export
要重写导入路径需要知道它们在文件中的位置。我们将使用TypeScript的Compiler API来将源文件解析为抽象语法树并找到导入语句
为了实现这个功能我们需要用到
typescript
NPM包的compile API
。Deno的兼容模块提供了一个直接从CommonJS模块导入的方式。需要在执行Deno代码的时候使用--unstable
标识,对于构建阶段这不是什么问题下面遍历文件并依次解析
对于每个AST,我们通过遍历顶层节点找出
import
和export
语句。这里无需深层查找,因为import/export
只会出现在顶级作用域(也无需处理动态import()
,因为edgedb-js
中也没有使用)从这些节点中,获取源文件中
export/import
路径的开始和结束偏移量。然后可以通过切片取代路径内容并插入修改后的路径来重写导入这里的关键部分是
resolveImportPath
函数。它通过试错查找的方式实现将Node.js风格的引入转化为Deno风格的导入。首先检查路径是否对应于实际文件;如果失败了会尝试添加.ts
;如果再失败则尝试添加/index.ts
;如果再失败则抛出一个错误。注入Node.js全局变量
最后一步是处理Node.js全局变量。首先在创建一个
global.deno.ts
文件。这个文件应该导出包中使用的所有Node.js全局变量的Deno兼容版本通过编译后的AST可以拿到源文件中所有全局变量的集合。将使用它在任何引用这些全局变量的文件中注入import语句
写入文件
删除老文件的内容并依次写入每个文件
持续集成
一个常见的做法是为包的Deno版本维护一个单独的自动生成的仓库。在我们的例子中,每当一个新的提交合并到master时,将在GitHub Actions中生成
edgedb-js
的Deno版本。然后生成的文件被发布到edgedb-deno
仓库。下面是工作流的简化版本edgedb-deno
内部的另一个工作流则会创建一个GitHub release并发布到deno.land/x
。可参考封装
这就是将现存Node.js模块转换到Deno的通常方法。具体可参考
Deno编译脚本
和workflow
The text was updated successfully, but these errors were encountered: