Deno 是一个没有外部依赖的单一二进制和多平台(Linux、MacOS、Windows)可执行文件。
由 Ryan Dahl 创建,同时他也是 Node.js 的创建者。
Deno 在 2020 年 5 月发布了 v1.0 版本。
为什么需要另一个 runtime?
在 Deno 之前,已经存在了 Node.js,但是由于 Node.js 中有很多难以解决的问题,所以 Ryan Dahl 决定重新开发一个全新的 runtime,并且解决掉之前存在的所有问题。所以我们可以把 Deno 看作是 Node.js 的升级版。
Deno 可以用更快、更安全的方式去做和 Node.js 相同的事情。
Node.js 非常成功,用户呈指数级增长,但是这些都将会成为 Dahl 改进 Node.js 的障碍。从一个干净的基础开始,Dahl 可以使用很多创新技术和最佳架构实践。
Deno 组成
Deno 构建在 JavaScript 的 V8 引擎和 Rust 的 Tokio 之上。
V8 引擎
V8 是 Google 开源的高性能 JavaScript 和 WebAssembly 引擎,用 C++ 编写。目前主要用于 Chrome 和 Node.js。
他在真正执行之前会编译并执行 JavaScript 来优化机器代码。虽然最初设计只是为了执行浏览器脚本,但是最新的版本已经允许服务端脚本。
Rust
Deno 最初是用 Go 来编写的,但是由于性能问题和缺乏垃圾回收,很快就用 Rust 重写了。Rust 允许我们将数据存储在栈或堆上,并不需要在编译时再进行分配。这种方法确保了访问内存的高效,消除了对持续运行的垃圾回收的需求。通过直接访问硬件,Rust 成为了底层开发的最佳理想语言,在很多领域取代了 C++。除了技术方面,Rust 社区也非常活跃,我们可以很容易找到大量资料来学习 Rust。
Tokio
Tokio 上 Rust 的异步 runtime。它对 Deno 的意义,就像 libuv 对于 Node.js 的意义。由于使用多线程来调度程序,它可以用最小的开销提供卓越的性能。Tokio 还有一组内存安全的 API,帮助我们防止和内存相关的错误。这些功能都为 Deno 提供了坚实可靠的基础。
Deno 的基础功能
Deno 是用 Typescript 和 Rust 来编写的,这两门编程语言都是广泛使用的语言,可以提供许多优势来创建快速和高性能的应用程序。
下面是 Deno 的一些功能列表:
- 原生支持 Typescript,同时仍然可以使用 JavaScript。
- 支持 ES Module。
- 拥有非常多的库。
- 没有标准的包管理器,直接通过 URL 下载库。
- 提供完整的内置工具,包括 bundling、debugging、testing 等。
- 拥有明确的权限系统,默认情况下非常安全。
Deno 的核心功能
Deno 和 Node.js 的主要区别在于 Deno 背后的核心功能。它提供了丰富的功能来保障改进和流畅的开发体验。
Deno 的目标是:Deno aims to be a productive and secure scriptin environment for the modern programmer.(Deno 旨在为现代程序员提供高效且安全的脚本环境)
安装
在学习 Deno 的核心功能之前,我们需要先来安装一下 Deno,以便运行一些示例代码。
安装 Deno 的方式非常简单,下面是不同操作系统的安装方式。
Mac、Linux
bash 方式是最通用的。
curl -fsSL https://deno.land/install.sh | sh
如果你有 Homebrew,也可以这样。
brew install deno
Windows
Windows 推荐使用 PowerSehll 来安装。
iwr https://deno.land/install.ps1 -useb | iex
安全
安全是 Deno 的一个核心功能。
沙盒安全层
默认情况下,任何 Deno 模块都在沙盒安全层中执行,防止访问磁盘、环境、网络或者运行任何外部脚本的可能性,除非通过权限明确允许。在使用命令行运行代码时,我们通过使用一些 flag 向程序授予权限。
示例
我们通过一个简单的例子来更好地理解权限是如何工作的。
下面的命令作用是:执行一个远程脚本文件 cat.ts,并授予它对目标文件的读取权限。
deno run --allow-read https://deno.land/std@0.123.0/examples/cat.ts test.txt
cat.ts 是 Deno 标准库的一部分,我们可以直接从终端调用它。它接受一个或多个文件名作为参数,并且打印它们的内容,以下是 cat.ts 的源码:
// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. import { copy } from "../streams/conversion.ts"; const filenames = Deno.args; for (const filename of filenames) { const file = await Deno.open(filename); await copy(file, Deno.stdout); file.close(); }
下面我们来解释一下它关键的部分。
第 3 行:通过 Deno.args 访问命令行中的参数,并赋值给 filenames。
第 4-8 行:通过循环遍历所有的文件,并调用 Deno.open 方法打开文件,并调用 copy 将文件内容打印到 Deno.stdout。最后调用 file.close 方法关闭文件。
如果将 --allow-read 这个 flag 去掉,那么就会在第 5 行抛出一个错误。因为 Deno 默认是安全的,它要求我们必须提供权限。
Deno 的 flag 必须放在脚本名称之前,否则它会被解析成脚本参数。
缓存模块
与 Node.js 不同的是,Deno 不使用 npm,而是使用 URL 来导入模块。在使用 URL 导入模块时,Deno 会下载模块并将它换存在由环境变量定义的 DENO_DIR 中。默认值为系统的缓存目录,即 $HOME/.cache/deno。模块被缓存之后,在之后每次执行过程中,Deno 都会先去缓存中查找请求的模块是否存在,如果找到模块,就会直接使用这个模块,而不需要进行网络拉取。当我们处于没有网络的开发环境下,这个方式将会非常有用。
现在尝试重新运行上面的命令,可以看到它没有再次下载 cat.ts 文件。
我们可以通过添加 --reload flag 来让 Deno 重新通过网络获取模块。
权限 flag
Deno 的权限 flag 提供了非常好的粒度级别,在需要的时候可以缩小授权范围。
比如我们可以通过 --allow-read=/folder 这个 flag 来限制对目标文件夹的读取访问。
完整的权限列表
- -A/--allow-all:允许所有权限。
- --allow-net:允许访问网络。我们可以通过逗号分隔域名或 IP 制定可以访问的网络列表。单独使用 --allow-net 表示可以访问所有域名或 IP。
bash
复制代码
deno run --allow-net=github.com,gitlab.com code.js
- --allow-env:允许读写环境变量。我们可以通过都好分割指定可以读写的环境变量。
- --allow-hrtime:允许高分辨率时间测量。
- --allow-read:允许文件系统读取权限。可以通过逗号分隔授予多个文件或目录读取权限。
- --allow-write:允许文件系统写入权限。可以通过逗号分隔授予多个文件或目录写入权限。
- --allow-run:允许允许子进程。但子进程不在沙箱中运行,子进程没有安全限制。
- --allow-ffi:允许加载动态库。但是不在沙箱中运行。
TypeScript 支持
Deno 支持使用 JavaScript 或者 TypeScript 编写 Deno 程序,但无论使用哪种方式,它都会自动编译 TypeScript。
TypeScript 有很多好处,比如类型检查和代码提示等。使用 TypeScript 可以让我们规避很多低级错误。
ES Module 支持
Deno 可以从远程 URL 或者本地路径来导入 ES 模块。所以不需要包管理器来导入远程模块。这样意味着我们少了一个需要关心的技术组件。
加载远程模块示例:
import { serve } from 'https://deno.land/std/http/server.ts'
加载本地模块示例:
import { concat, split } from './utils/string-util.ts'
无论采用哪种方式导入文件,都需要提供完整的文件名,包括扩展名。因为 Deno 的模块解析有点类似于浏览器的模块解析。
在导入远程模块时,如果不置顶特定版本,那么就会下载最新版本的模块。如果需要特定版本,可以在模块路径中添加指定版本。
// 导入最新版本 // 不鼓励使用这种方式 import { serve } from 'https://deno.land/std/http/server.ts' // 显式指定了 0.65.0 版本 import { serve } from 'https://deno.land/std@v0.65.0/http/server.ts' const s = serve({ port: 8000 });
因为 Deno 采用分散的方式获取这些库,所以在 Deno 的项目中我们不需要 package.json 文件和 node_modules 文件夹。
显示缓存位置
我们可以使用 deno info 命令来查看不同的缓存位置。
我们还可以使用 deno info TARGET_MODULE 来获取和目标模块相关的依赖模块列表。当我们的项目变得庞大时,可以使用这个命令来确保不会错误的导入模块。
我们可以使用下面的命令来测试。
deno info https://deno.land/std@0.67.0/http/server.ts
分组导入
在多个文件中导入相同的远程模块会显得重复而且低效。
Deno 中使用了类似桶文件的概念。桶文件提供一种将多个模块的导出汇总到单个模块的方法。Deno 使用 deps.ts 当作约定名,而不是 index.ts。
这种想法是将导入分组到一个集中的文件中,再根据需要将依赖导入到不同的文件中。因此,具有多个模块的单个导入,而不是各种导入。
如果我们在导入中针对特定版本,这是一个方便的习惯。因为我们只需要在一个地方更新版本号,确保在任何地方都是用相同的模块版本。
下面是展示桶文件的代码示例:
deps.ts
export { serve } from 'https://deno.land/std@v0.65.0/http/server.ts' // <- Change only here for a new version export { Cookie } from 'https://deno.land/std@v0.65.0/http/cookie.ts' export { SEP } from 'https://deno.land/std@v0.123.0/http/cookie.ts'
file1.ts
import { Cookie, serve, SEP } from './deps.ts'; const myCookie: Cookie; const currentSeparator = SEP; const s = serve({ port: 8000 });
file2.ts
import { Cookie } from './deps.ts'; const myCookie: Cookie;
file3.ts
import { serve } from './deps.ts'; import { dateMod } from './utils/date-util.ts';
遵循这种编码方式,让代码维护更加容易。
如果我们需要将某个模块的版本进行更新,只需要修改 depts.ts 中对应的一行代码即可。而不需要修改多个文件的导入。
如果我们使用本地模块来替换远程模块,我们可以只修改 depts.ts 中的导入路径即可。只要导出的名称和签名与之前保持一致,就不会造成任何影响。
URL 导入的潜在风险
从远程 URL 中导入模块会带来潜在风险。比如存储远程模块代码的服务器可能会出现问题或被攻击而不可用。
在这种情况下,Deno 的开发团队建议将包含缓存模块的文件夹添加到源代码管理中。这样即使远程存储库出现问题,我们也可以确保所有的库都可用。
顶级 await
在 ECMAScript 中,async/await 可以让我们通过更清晰、更易读的方式来编写异步代码,而不需要显式使用 Promise 的链式 then。
关键字 await 可以让代码停止执行,直到它的目标 Promise 被 reject 或 resolve 后才恢复执行。但是我们只能在 async 中使用它,否则会得到一个错误。
Deno 中的顶级 await
Deno 支持顶级 await。
这意味着我们可以在 async 函数之外使用 await 关键字。
下面是示例:
try { const decoder = new TextDecoder("utf-8"); const data = await Deno.readFile("README.md"); console.log(decoder.decode(data)); } catch (e) { console.error("An error occurrred while reading the file: ", e); }
不过需要注意,顶级 await 只能用于模块。
Deno 库
标准库
Deno 的官方模块由核心团队提供支持。这些模块就是标准库。
标准库涵盖了大多数项目中常用的功能,并且在每个版本中都会扩展。
目前常见的有:
- Async:异步任务
- bytes:字节切片操作
- datetime:日期/时间
- flags:解析命令行 flag
- fs:文件系统
- http:HTTP 客户端/服务器功能
- io:I/O 操作
- path:路径操作
- wasi:WebAssembly 接口
导入模块
我们应该使用特定版本的模块,而不是最新版本的模块。因为最新版本可能是不稳定的,并且可能带来意想不到的错误和副作用。
最佳实践是始终导入特定版本来确保稳定性。
不稳定的 API
并不是标准库中的所有模块都是稳定状态,这意味着其中一些模块会使用不稳定的 Deno API。
如果我们使用依赖了不稳定 API 的标准库中的模块,我们需要添加 --unstable flag 来防止在调用脚本时出现 TypeScript 错误。
第三方库
除了官方的标准库外,deno.land/x 还提供了社区开发的库的托管服务。
但是这些库都未经 Deno 团队的审核。所以在使用第三方库时应该小心。
测试
测试可以确保我们的程序在部署到生产环境之前按照预期来工作,即使是在一些特殊情况下也应该如此。
单元测试可以帮助我们在开发过程中保持代码的高质量,同时可以帮助我们检测意外的副作用。
Deno 内置了测试运行器,所以编写测试非常简单。我们不需要引入任何第三方库,或者编写一堆配置。我们可以随时开始编写我们的测试代码。
命名约定
测试文件的命名必须满足以下三点之一:
- 直接命名 test.ts
- 以 .test 结尾,比如 user.test.ts
- 以 _test 结尾,比如 user_test.ts
我们使用 deno test 命令来运行测试。
这条指令可以制定一个跟路径路径,测试运行器会以递归的方式在路径中搜索和执行所有测试文件。如果不指定路径,那么就是运行命令的路径。
测试风格
下面是一段 Deno 的测试代码。
我通过它展示了单元测试的不同风格。
import { assertEquals } from "https://deno.land/std@0.123.0/testing/asserts.ts"; const textToTest = "Test this text!"; // 使用单元测试名字和函数 // 这类似于 Jasmine 的语法 Deno.test("Compact Form Test", () => { assertEquals(textToTest, "Test this text!"); }); // 使用命名函数 Deno.test(function namedFnTest() { assertEquals(textToTest, "Test this text!"); }); // 使用配置对象 Deno.test({ name: "Test definition", fn: () => { assertEquals(textToTest, "Test this text!"); }, }); // 可以添加配置对象作为第二个参数 Deno.test("Additional Configuration", { permissions: { read: true } }, () => { assertEquals(textToTest, "Test this text!"); }); // 可以添加测试函数作为第二个参数 Deno.test( { name: "Test Function as Param", permissions: { read: true } }, () => { assertEquals(textToTest, "Test this text!"); }, ); // 可以传递配置参数和命名函数作为参数 Deno.test({ permissions: { read: true } }, function helloWorld6() { assertEquals(textToTest, "Test this text!"); });
上面的代码风格和很多主流的 Node.js 测试框架很相似。
我们来分析第一个单元测试(第 7-9 行)。
- 第 7 行:我们使用 Deno.test 方法创建单元测试。Compact Form Test 作为测试标题。
- 第 8 行:我们提供了一个断言,这就是我们要测试的代码部分。assertEquals 会比较第一个参数和第二个参数是否相等,会返回一个测试结果,成功或者失败。
运行单元测试的命令如下(注意需要添加权限的 flag):
deno test --allow-read --allow-net test.ts
过滤测试
给单元测试设置一个有意义的名称不仅可以理解它的作用,同时还可以使用 --filter flag 在一个组中执行它们。
比如我们开发了如下不同的单元测试:
Deno.test({ name: "user-creation", fn: testCreation }); Deno.test({ name: "user-account-creation", fn: testAccount1 }); Deno.test({ name: "user-account-deletion", fn: testAccount2 }); Deno.test({ name: "user-account-update", fn: testAccount3 }); Deno.test({ name: "settings", fn: testSettings }); Deno.test({ name: "login", fn: testLogin });
现在我们只想执行用户相关的单元测试,就可以使用 --filter 来和测试名称进行匹配。
deno test --filter "user" test.ts
如果我们只能执行用户测试的子集怎么办?
Deno 还可以接受正则表达式作为 filter 的参数进行匹配特定的测试。
比如我们要运行所有和账户相关的测试,可以使用下面的命令:
$ deno test --filter "/user-account-\*d/" test.ts
单个测试
与 Jasmine 一样,Deno 也提供了一种机制来临时跳过所有其他测试,只运行一个测试。
在配置对象中设置 only 属性为 true,就可以让测试运行器跳过其他所有测试,只运行这一个测试。
Deno.test({ name: 'critical test', only: true, fn() { testComplexFeature() } })
只要在任何测试中存在 only 选项,无论这个单元测试是否成功,整个测试都会失败。因为 only 是一种临时方案,这种做法可以防止我们忘记删除 only 选项。
断言
断言可以帮助我们来定义测试需要满足的要求。Deno 内置了丰富的断言库,我们只需要从 deno.land/std@VERSION… 模块中导入它们。
下面是一些常用的断言方法:
- assert:参数为布尔值,true 为成功,false 为失败
- assertEquals:两个值相等
- assertNotEquals:两个值不相等
- assertExists:验证一个值不是 null 或 undefined
- assertStrictEquals:严格比较两个值是否相等,对于非原始值,会比较引用
- assertStringIncludes:实际字符串中是否包含预期字符串
- assertArrayIncludes:在数组中查找一个值
- assertMatch:参数是否和正则表达式匹配
- assertNotMatch:参数不与正则表达式匹配
- assertObjectMatch:参数对象是否和预期对象的属性匹配
- assertThrows:预期传递的函数抛出异常
- assertRejects:和 assertThrows 类似,但它需要返回一个 Promise 对象