跳到主要内容

· 阅读需 20 分钟

引子

在编写代码时,我们应该有一些方法将程序像连接水管一样连接起来 -- 当我们需要获取一些数据时,可以去通过"拧"其他的部分来达到目的。这也应该是 IO 应有的方式。 -- Doug McIlroy. October 11, 1964

早先的 unix开始,stream 便开始进入了人们的视野,在过去的几十年的时间里,它被证明是一种可依赖的编程方式,它可以将一个大型的系统拆成一些很小的部分,并且让这些部分之间完美地进行合作。在 unix 中,我们可以使用|符号来实现流。在 node 中,node 内置的stream 模块已经被多个核心模块使用,同时也可以被用户自定义的模块使用。和 unix 类似,node 中的流模块的基本操作符叫做.pipe(),同时你也可以使用一个后压机制来应对那些对数据消耗较慢的对象。

在 node 中,流可以帮助我们将事情的重点分为几份,因为使用流可以帮助我们将实现接口的部分分割成一些连续的接口,这些接口都是可重用的。接着,你可以将一个流的输出口接到另一个流的输入口,然后使用使用一些库来对流实现高级别的控制。

对于小型程序设计(small-program design)以及 unix 哲学来说,流都是一个重要的组成部分,但是除此之外还有一些重要的事情值得我们思考。永远要记得:双鸟在林不如一鸟在手。

为什么应该使用流

在 node 中,I/O 都是异步的,所以在和硬盘以及网络的交互过程中会涉及到传递回调函数的过程。你之前可能会写出这样的代码:

var http = require("http");
var fs = require("fs");

var server = http.createServer(function(req, res) {
fs.readFile(__dirname + "/data.txt", function(err, data) {
res.end(data);
});
});
server.listen(8000);

上面的这段代码并没有什么问题,但是在每次请求时,我们都会把整个data.txt文件读入到内存中,然后再把结果返回给客户端。想想看,如果data.txt文件非常大,在响应大量用户的并发请求时,程序可能会消耗大量的内存,这样很可能会造成用户连接缓慢的问题。

其次,上面的代码可能会造成很不好的用户体验,因为用户在接收到任何的内容之前首先需要等待程序将文件内容完全读入到内存中。

所幸的是,(req,res)参数都是流对象,这意味着我们可以使用一种更好的方法来实现上面的需求:

var http = require("http");
var fs = require("fs");

var server = http.createServer(function(req, res) {
var stream = fs.createReadStream(__dirname + "/data.txt");
stream.pipe(res);
});
server.listen(8000);

在这里,.pipe()方法会自动帮助我们监听dataend事件。上面的这段代码不仅简洁,而且data.txt文件中每一小段数据都将源源不断的发送到客户端。

除此之外,使用.pipe()方法还有别的好处,比如说它可以自动控制后端压力,以便在客户端连接缓慢的时候 node 可以将尽可能少的缓存放到内存中。

想要将数据进行压缩?我们可以使用相应的流模块完成这项工作!

var http = require("http");
var fs = require("fs");
var oppressor = require("oppressor");

var server = http.createServer(function(req, res) {
var stream = fs.createReadStream(__dirname + "/data.txt");
stream.pipe(oppressor(req)).pipe(res);
});
server.listen(8000);

通过上面的代码,我们成功的将发送到浏览器端的数据进行了 gzip 压缩。我们只是使用了一个 oppressor 模块来处理这件事情。

一旦你学会使用流 api,你可以将这些流模块像搭乐高积木或者像连接水管一样拼凑起来,从此以后你可能再也不会去使用那些没有流 API 的模块获取和推送数据了。

流模块基础

在 node 中,一共有五种类型的流:readable,writable,transform,duplex 以及"classic"

pipe

无论哪一种流,都会使用.pipe()方法来实现输入和输出。

.pipe()函数很简单,它仅仅是接受一个源头src并将数据输出到一个可写的流dst中:

src.pipe(dst);

.pipe(dst)将会返回dst因此你可以链式调用多个流:

a.pipe(b)
.pipe(c)
.pipe(d);

上面的代码也可以等价为:

a.pipe(b);
b.pipe(c);
c.pipe(d);

这和你在 unix 中编写流代码很类似:

a | b | c | d

只不过此时你是在 node 中编写而不是在 shell 中!

readable 流

Readable 流可以产出数据,你可以将这些数据传送到一个 writable,transform 或者 duplex 流中,只需要调用pipe()方法:

readableStream.pipe(dst);

创建一个 readable 流

现在我们就来创建一个 readable 流!

var Readable = require("stream").Readable;

var rs = new Readable();
rs.push("beep ");
rs.push("boop\n");
rs.push(null);

rs.pipe(process.stdout);

下面运行代码:

$ node read0.js
beep boop

在上面的代码中rs.push(null)的作用是告诉rs输出数据应该结束了。

需要注意的一点是我们在将数据输出到process.stdout之前已经将内容推送进 readable 流rs中,但是所有的数据依然是可写的。

这是因为在你使用.push()将数据推进一个 readable 流中时,一直要到另一个东西来消耗数据之前,数据都会存在一个缓存中。

然而,在更多的情况下,我们想要的是当需要数据时数据才会产生,以此来避免大量的缓存数据。

我们可以通过定义一个._read函数来实现按需推送数据:

var Readable = require("stream").Readable;
var rs = Readable();

var c = 97;
rs._read = function() {
rs.push(String.fromCharCode(c++));
if (c > "z".charCodeAt(0)) rs.push(null);
};

rs.pipe(process.stdout);

代码的运行结果如下所示:

$ node read1.js
abcdefghijklmnopqrstuvwxyz

在这里我们将字母az推进了 rs 中,但是只有当数据消耗者出现时,数据才会真正实现推送。

_read函数也可以获取一个size参数来指明消耗者想要读取多少比特的数据,但是这个参数是可选的。

需要注意到的是你可以使用util.inherit()来继承一个 Readable 流。

为了说明只有在数据消耗者出现时,_read函数才会被调用,我们可以将上面的代码简单的修改一下:

var Readable = require("stream").Readable;
var rs = Readable();

var c = 97 - 1;

rs._read = function() {
if (c >= "z".charCodeAt(0)) return rs.push(null);

setTimeout(function() {
rs.push(String.fromCharCode(++c));
}, 100);
};

rs.pipe(process.stdout);

process.on("exit", function() {
console.error("\n_read() called " + (c - 97) + " times");
});
process.stdout.on("error", process.exit);

运行上面的代码我们可以发现如果我们只请求 5 比特的数据,那么_read只会运行 5 次:

$ node read2.js | head -c5
abcde
_read() called 5 times

在上面的代码中,setTimeout很重要,因为操作系统需要花费一些时间来发送程序结束信号。

另外,process.stdout.on('error',fn)处理器也很重要,因为当head不再关心我们的程序输出时,操作系统将会向我们的进程发送一个SIGPIPE信号,此时process.stdout将会捕获到一个EPIPE错误。

上面这些复杂的部分在和操作系统相关的交互中是必要的,但是如果你直接和 node 中的流交互的话,则可有可无。

如果你创建了一个 readable 流,并且想要将任何的值推送到其中的话,确保你在创建流的时候指定了 objectMode 参数,Readable({ objectMode: true })

消耗一个 readable 流

大部分时候,将一个 readable 流直接 pipe 到另一种类型的流或者使用 through 或者 concat-stream 创建的流中,是一件很容易的事情。但是有时我们也会需要直接来消耗一个 readable 流。

process.stdin.on("readable", function() {
var buf = process.stdin.read();
console.dir(buf);
});

代码运行结果如下所示:

$ (echo abc; sleep 1; echo def; sleep 1; echo ghi) | node consume0.js
<Buffer 61 62 63 0a>
<Buffer 64 65 66 0a>
<Buffer 67 68 69 0a>
null

当数据可用时,readable事件将会被触发,此时你可以调用.read()方法来从缓存中获取这些数据。

当流结束时,.read()将返回null,因为此时已经没有更多的字节可以供我们获取了。

你也可以告诉.read()方法来返回n个字节的数据。虽然所有核心对象中的流都支持这种方式,但是对于对象流来说这种方法并不可用。

下面是一个例子,在这里我们制定每次读取 3 个字节的数据:

process.stdin.on("readable", function() {
var buf = process.stdin.read(3);
console.dir(buf);
});

运行上面的例子,我们将获取到不完整的数据:

$ (echo abc; sleep 1; echo def; sleep 1; echo ghi) | node consume1.js
<Buffer 61 62 63>
<Buffer 0a 64 65>
<Buffer 66 0a 67>

这是因为多余的数据都留在了内部的缓存中,因此这个时候我们需要告诉 node 我们还对剩下的数据感兴趣,我们可以使用.read(0)来完成这件事:

process.stdin.on("readable", function() {
var buf = process.stdin.read(3);
console.dir(buf);
process.stdin.read(0);
});

到现在为止我们的代码和我们所期望的一样了!

$ (echo abc; sleep 1; echo def; sleep 1; echo ghi) | node consume2.js
<Buffer 61 62 63>
<Buffer 0a 64 65>
<Buffer 66 0a 67>
<Buffer 68 69 0a>

我们也可以使用.unshift()方法来放置多余的数据。

使用unshift()方法能够防止我们进行不必要的缓存拷贝。在下面的代码中我们将创建一个分割新行的可读解析器:

var offset = 0;

process.stdin.on("readable", function() {
var buf = process.stdin.read();
if (!buf) return;
for (; offset < buf.length; offset++) {
if (buf[offset] === 0x0a) {
console.dir(buf.slice(0, offset).toString());
buf = buf.slice(offset + 1);
offset = 0;
process.stdin.unshift(buf);
return;
}
}
process.stdin.unshift(buf);
});

代码的运行结果如下所示:

$ tail -n +50000 /usr/share/dict/american-english | head -n10 | node lines.js
'hearties'
'heartiest'
'heartily'
'heartiness'
'heartiness\'s'
'heartland'
'heartland\'s'
'heartlands'
'heartless'
'heartlessly'

当然,已经有很多这样的模块比如 split 来帮助你完成这件事情,你完全不需要自己写一个。

writable 流

一个 writable 流指的是只能流进不能流出的流:

src.pipe(writableStream);

创建一个 writable 流

只需要定义一个._write(chunk,enc,next)函数,你就可以将一个 readable 流的数据释放到其中:

var Writable = require("stream").Writable;
var ws = Writable();
ws._write = function(chunk, enc, next) {
console.dir(chunk);
next();
};

process.stdin.pipe(ws);

代码运行结果如下所示:

$ (echo beep; sleep 1; echo boop) | node write0.js
<Buffer 62 65 65 70 0a>
<Buffer 62 6f 6f 70 0a>

第一个参数,chunk代表写进来的数据。

第二个参数enc代表编码的字符串,但是只有在opts.decodeStringfalse的时候你才可以写一个字符串。

第三个参数,next(err)是一个回调函数,使用这个回调函数你可以告诉数据消耗者可以写更多的数据。你可以有选择性的传递一个错误对象error,这时会在流实体上触发一个emit事件。

在从一个 readable 流向一个 writable 流传数据的过程中,数据会自动被转换为Buffer对象,除非你在创建 writable 流的时候制定了decodeStrings参数为false,Writable({decodeStrings: false})

如果你需要传递对象,需要指定objectMode参数为trueWritable({ objectMode: true })

向一个 writable 流中写东西

如果你需要向一个 writable 流中写东西,只需要调用.write(data)即可。

process.stdout.write('beep boop\n');

为了告诉一个 writable 流你已经写完毕了,只需要调用.end()方法。你也可以使用.end(data)在结束前再写一些数据。

var fs = require("fs");
var ws = fs.createWriteStream("message.txt");

ws.write("beep ");

setTimeout(function() {
ws.end("boop\n");
}, 1000);

运行结果如下所示:

$ node writing1.js
$ cat message.txt
beep boop

如果你需要调整内部缓冲区大小,那么需要在创建可写流对象时设置highWaterMark。在调用.write()方法返回 false 时,说明写入的数据大小超过了该值。

为了避免读写速率不匹配而造成内存上涨,可以监听drain事件,等待可写流内部缓存被清空再继续写入。

transform 流  

你可以将 transform 流想象成一个流的中间部分,它可以读也可写,但是并不保存数据,它只负责处理流经它的数据。

duplex 流

Duplex 流是一个可读也可写的流,就好像一个电话,可以接收也可以发送语音。一个 rpc 交换是一个 duplex 流的最好的例子。如果你看到过下面这样的代码:

a.pipe(b).pipe(a);

那么你需要处理的就是一个 duplex 流对象。

classic 流

Classic 流是一个古老的接口,最早出现在 node 0.4 中。虽然现在不怎么用,但是我们最好还是来了解一下它的工作原理。

无论何时,只要一个流对象注册了一个data监听器,它就会自动的切换到classic模式,并且根据旧 API 的方式运行。

classic readable 流

Classic readable 流只是一个事件发射器,当有数据消耗者出现时发射emit事件,当输出数据完毕时发射end事件。

我们可以同构检查stream.readable来检查一个 classic 流对象是否可读。

下面是一个简单的 readable 流对象的例子,程序的运行结果将会输出AJ

var Stream = require("stream");
var stream = new Stream();
stream.readable = true;

var c = 64;
var iv = setInterval(function() {
if (++c >= 75) {
clearInterval(iv);
stream.emit("end");
} else stream.emit("data", String.fromCharCode(c));
}, 100);

stream.pipe(process.stdout);

运行结果如下所示:

$ node classic0.js
ABCDEFGHIJ

为了从一个 classic readable 流中读取数据,你可以注册dataend监听器。下面是一个使用旧 readable 流方式从process.stdin中读取数据的例子:

process.stdin.on("data", function(buf) {
console.log(buf);
});
process.stdin.on("end", function() {
console.log("**END**");
});

运行结果如下所示:

$ (echo beep; sleep 1; echo boop) | node classic1.js
<Buffer 62 65 65 70 0a>
<Buffer 62 6f 6f 70 0a>
**END**

需要注意的一点是当你在一个流对象上注册了一个data监听器,你就将这个流放在了兼容模式下,此时你不能使用两个 stream2 的 api。

如果你自己创建流对象,永远不要绑定dataend监听器。如果你需要和旧版本的流兼容,最好使用第三方库来实现.pipe()方法。

例如,你可以使用 through 模块来避免显式的使用dataend监听器:

var through = require("through");
process.stdin.pipe(through(write, end));

function write(buf) {
console.log(buf);
}
function end() {
console.log("**END**");
}

程序运行结果如下所示:

$ (echo beep; sleep 1; echo boop) | node through.js
<Buffer 62 65 65 70 0a>
<Buffer 62 6f 6f 70 0a>
**END**

你也可以使用 concat-stream 模块来将整个流的内容缓存起来:

var concat = require("concat-stream");
process.stdin.pipe(
concat(function(body) {
console.log(JSON.parse(body));
})
);

程序运行结果如下所示:

$ echo '{"beep":"boop"}' | node concat.js
{ beep: 'boop' }

Classic readable 流拥有.pause().resume()逻辑来暂停一个流,但是这都是可选的。如果你想要使用.pause().resume()方法,你应该使用 through 模块来帮助你处理缓存。

classic writable 流

Classic writable 流非常简单。其中只定义了.write(buf).end(buf),以及.desctory()方法。其中.end(buf)的参数 buf 是可选参数,但是一般来说 node 程序员还是喜欢使用.end(buf)这种写法。

接下来读什么

  • node 核心 stream 模块文档
  • 你可以使用readable-stream模块来确保你的 stream2 代码兼容 node 0.8 及其之前的代码。在你npm install readable-stream之后直接require('readable-stream')而不要require('stream')

本文参考自 stream-handbook,原文地址https://github.com/substack/stream-handbook

· 阅读需 64 分钟

欢迎! 首先您应该知道的三件事情:

1. 当您读到这里,实际上您读了很多关于 Node.js 的优秀文章 - 这是对 Node.js 最佳实践中排名最高的内容的总结和分享

2. 这里是最大的汇集,且每周都在增长 - 当前,超过 50 个最佳实现,样式指南,架构建议已经呈现。每天都有新的 issue 和 PR 被创建,以使这本在线书籍不断更新。我们很乐于见到您能在这里做出贡献,不管是修复一些代码的错误,或是提出绝妙的新想法。请查看我们的milestones

3. 大部分的条目包含额外的信息 - 大部分的最佳实践条目的旁边,您将发现 🔗Read More 链接,它将呈现给您示例代码,博客引用和更多信息




目录

  1. 项目结构实践 (5)
  2. 异常处理实践 (11)
  3. 编码规范实践 (12)
  4. 测试和总体质量实践 (8)
  5. 进入生产实践 (16)
  6. ⭐ 新: 安全实践(23)
  7. Performance Practices (coming soon)



1. 项目结构实践

1.1 组件式构建你的解决方案

TL;DR: 大型项目的最坏的隐患就是维护一个庞大的,含有几百个依赖的代码库 - 当开发人员准备整合新的需求的时候,这样一个庞然大物势必减缓了开发效率。反之,把您的代码拆分成组件,每一个组件有它自己的文件夹和代码库,并且确保每一个组件小而简单。查看正确的项目结构的例子请访问下面的 ‘更多’ 链接。

否则: 当编写新需求的开发人员逐步意识到他所做改变的影响,并担心会破坏其他的依赖模块 - 部署会变得更慢,风险更大。当所有业务逻辑没有被分开,这也会被认为很难扩展

🔗 更多: 组件结构



1.2 分层设计组件,保持 Express 在特定的区域

TL;DR: 每一个组件都应该包含'层级' - 一个专注的用于接入网络,逻辑,数据的概念。这样不仅获得一个清晰的分离考量,而且使仿真和测试系统变得异常容易。尽管这是一个普通的模式,但接口开发者易于混淆层级关系,比如把网络层的对象(Express req, res)传给业务逻辑和数据层 - 这会令您的应用彼此依赖,并且只能通过 Express 使用。

否则: 对于混淆了网络层和其它层的应用,将不易于测试,执行 CRON 的任务,其它非-Express 的调用者无法使用

🔗 更多: 应用分层



1.3 封装公共模块成为 NPM 的包

TL;DR: 由大量代码构成的一个大型应用中,贯彻全局的,比如日志,加密和其它类似的公共组件,应该进行封装,并暴露成一个私有的 NPM 包。这将使其在更多的代码库和项目中被使用变成了可能。

否则: 您将不得不重造部署和依赖的轮子

🔗 更多: 通过需求构建



1.4 分离 Express 'app' and 'server'

TL;DR: 避免定义整个Express应用在一个单独的大文件里, 这是一个不好的习惯 - 分离您的 'Express' 定义至少在两个文件中: API 声明(app.js) 和 网络相关(WWW)。对于更好的结构,是把你的 API 声明放在组件中。

否则: 您的 API 将只能通过 HTTP 的调用进行测试(慢,并且很难产生测试覆盖报告)。维护一个有着上百行代码的文件也不是一个令人开心的事情。

🔗 更多: 分离 Express 'app' and 'server'



1.5 使用易于设置环境变量,安全和分级的配置

TL;DR: 一个完美无瑕的配置安装应该确保 (a) 元素可以从文件中,也可以从环境变量中读取 (b) 密码排除在提交的代码之外 (c) 为了易于检索,配置是分级的。仅有几个包可以满足这样的条件,比如rc, nconfconfig

否则: 不能满足任意的配置要求将会使开发,运维团队,或者两者,易于陷入泥潭。

🔗 更多: 配置最佳实践




2. 错误处理最佳实践

2.1 使用 Async-Await 和 promises 用于异步错误处理

TL;DR: 使用回调的方式处理异步错误可能是导致灾难的最快的方式(a.k.a the pyramid of doom)。对您的代码来说,最好的礼物就是使用规范的 promise 库或 async-await 来替代,这会使其像 try-catch 一样更加简洁,具有熟悉的代码结构。

否则: Node.js 回调特性, function(err, response), 是导致不可维护代码的一个必然的方式。究其原因,是由于混合了随意的错误处理代码,臃肿的内嵌,蹩脚的代码模式。

🔗 更多: 避免回调



2.2 仅使用内建的错误对象

TL;DR: 很多人抛出异常使用字符串类型或一些自定义类型 - 这会导致错误处理逻辑和模块间的调用复杂化。是否您 reject 一个 promise,抛出异常或发出(emit)错误 - 使用内建的错误对象将会增加设计一致性,并防止信息的丢失。

否则: 调用某些模块,将不确定哪种错误类型会返回 - 这将会使恰当的错误处理更加困难。更坏的情况是,使用特定的类型描述错误,会导致重要的错误信息缺失,比如 stack trace!

🔗 更多: 使用内建错误对象



2.3 区分运行错误和程序设计错误

TL;DR: 运行错误(例如, API 接受到一个无效的输入)指的是一些已知场景下的错误,这类错误的影响已经完全被理解,并能被考虑周全的处理掉。同时,程序设计错误(例如,尝试读取未定义的变量)指的是未知的编码问题,影响到应用得当的重启。

否则: 当一个错误产生的时候,您总是得重启应用,但为什么要让 ~5000 个在线用户不能访问,仅仅是因为一个细微的,可以预测的,运行时错误?相反的方案,也不完美 – 当未知的问题(程序问题)产生的时候,使应用依旧可以访问,可能导致不可预测行为。区分两者会使处理更有技巧,并在给定的上下文下给出一个平衡的对策。

🔗 更多: 运行错误和程序设计错误



2.4 集中处理错误,不要在 Express 中间件中处理错误

TL;DR: 错误处理逻辑,比如给管理员发送邮件,日志应该封装在一个特定的,集中的对象当中,这样当错误产生的时候,所有的终端(例如 Express 中间件,cron 任务,单元测试)都可以调用。

否则: 错误处理的逻辑不放在一起将会导致代码重复和非常可能不恰当的错误处理。

🔗 更多: 集中处理错误



2.5 对 API 错误使用 Swagger 文档化

TL;DR: 让你的 API 调用者知道哪种错误会返回,这样他们就能完全的处理这些错误,而不至于系统崩溃。Swagger,REST API 的文档框架,通常处理这类问题。

否则: 任何 API 的客户端可能决定崩溃并重启,仅仅因为它收到一个不能处理的错误。注意:API 的调用者可能是你(在微服务环境中非常典型)。

🔗 更多: 使用 Swagger 记录错误



2.6 当一个特殊的情况产生,停掉服务是得体的

TL;DR: 当一个不确定错误产生(一个开发错误,最佳实践条款#3) - 这就意味着对应用运转健全的不确定。一个普通的实践将是建议仔细地重启进程,并使用一些‘启动器’工具,比如 Forever 和 PM2。

否则: 当一个未知的异常被抛出,意味着某些对象包含错误的状态(例如某个全局事件发生器由于某些内在的错误,不在产生事件),未来的请求可能失败或者行为异常。

🔗 更多: 停掉服务



2.7 使用一个成熟的日志工具提高错误的可见性

TL;DR: 一系列成熟的日志工具,比如 Winston,Bunyan 和 Log4J,会加速错误的发现和理解。忘记 console.log 吧。

否则: 浏览 console 的 log,和不通过查询工具或者一个好的日志查看器,手动浏览繁琐的文本文件,会使你忙于工作到很晚。

🔗 更多: 使用好用的日志工具



2.8 使用你最喜欢的测试框架测试错误流

TL;DR: 无论专业的自动化测试或者简单的手动开发测试 - 确保您的代码不仅满足正常的场景,而且处理并且返回正确的错误。测试框架,比如 Mocha & Chai 可以非常容易的处理这些问题(在"Gist popup"中查看代码实例) 。

否则: 没有测试,不管自动还是手动,您不可能依赖代码去返回正确的错误。而没有可以理解的错误,那将毫无错误处理可言。

🔗 更多: 测试错误流向



2.9 使用 APM 产品发现错误和宕机时间

TL;DR: 监控和性能产品 (别名 APM) 先前一步的检测您的代码库和 API,这样他们能自动的,像使用魔法一样的强调错误,宕机和您忽略的性能慢的部分。

否则: 您花了很多的力气在测量 API 的性能和错误,但可能您从来没有意识到真实场景下您最慢的代码块和他们对 UX 的影响。

🔗 更多: 使用 APM 产品



2.10 捕获未处理的 promise rejections

TL;DR: 任何在 promise 中被抛出的异常将被收回和遗弃,除非开发者没有忘记去明确的处理。即使您的代码调用的是 process.uncaughtException!解决这个问题可以注册到事件 process.unhandledRejection。

否则: 您的错误将被回收,无踪迹可循。没有什么可以需要考虑。

🔗 更多: 捕获未处理的 promise rejection



2.11 快速查错,验证参数使用一个专门的库

TL;DR: 这应该是您的 Express 最佳实践中的一部分 – assert API 输入避免难以理解的漏洞,这类漏洞以后会非常难以追踪。而验证代码通常是一件乏味的事情,除非使用一些非常炫酷的帮助库比如 Joi。

否则: 考虑这种情况 – 您的功能期望一个数字参数 “Discount” ,然而调用者忘记传值,之后在您的代码中检查是否 Discount!=0 (允许的折扣值大于零),这样它将允许用户使用一个折扣。OMG,多么不爽的一个漏洞。你能明白吗?

🔗 更多: 快速查错




3. 编码风格实践

3.1 使用 ESLint

TL;DR: ESLint是检查可能的代码错误和修复代码样式的事实上的标准,不仅可以识别实际的间距问题, 而且还可以检测严重的反模式代码, 如开发人员在不分类的情况下抛出错误。尽管 ESlint 可以自动修复代码样式,但其他的工具比如prettierbeautify在格式化修复上功能强大,可以和 Eslint 结合起来使用。

否则: 开发人员将必须关注单调乏味的间距和线宽问题, 并且时间可能会浪费在过多考虑项目的代码样式。



3.2 Node.js 特定的插件

TL;DR: 除了仅仅涉及 vanilla JS 的 ESLint 标准规则,添加 Node 相关的插件,比如eslint-plugin-node, eslint-plugin-mocha and eslint-plugin-node-security

否则: 许多错误的 Node.js 代码模式可能在检测下逃生。例如,开发人员可能需要某些文件,把一个变量作为路径名 (variableAsPath) ,这会导致攻击者可以执行任何 JS 脚本。Node.JS linters 可以检测这类模式,并及早预警。



3.3 在同一行开始一个代码块的大括号

TL;DR: 代码块的第一个大括号应该和声明的起始保持在同一行中。

代码示例

// 建议
function someFunction() {
// 代码块
}

// 避免
function someFunction() {
// 代码块
}

否则: 不遵守这项最佳实践可能导致意外的结果,在 Stackoverflow 的帖子中可以查看到,如下:

🔗 更多: "Why does a results vary based on curly brace placement?" (Stackoverflow)



3.4 不要忘记分号

TL;DR: 即使没有获得一致的认同,但在每一个表达式后面放置分号还是值得推荐的。这将使您的代码, 对于其他阅读代码的开发者来说,可读性,明确性更强。

否则: 在前面的章节里面已经提到,如果表达式的末尾没有添加分号,JavaScript 的解释器会在自动添加一个,这可能会导致一些意想不到的结果。



3.5 命名您的方法

TL;DR: 命名所有的方法,包含闭包和回调, 避免匿名方法。当剖析一个 node 应用的时候,这是特别有用的。命名所有的方法将会使您非常容易的理解内存快照中您正在查看的内容。

否则: 使用一个核心 dump(内存快照)调试线上问题,会是一项非常挑战的事项,因为你注意到的严重内存泄漏问题极有可能产生于匿名的方法。



3.6 变量、常量、函数和类的命名约定

TL;DR: 当命名变量和方法的时候,使用 lowerCamelCase ,当命名类的时候,使用 UpperCamelCase (首字母大写),对于常量,则 UPPERCASE 。这将帮助您轻松地区分普通变量/函数和需要实例化的类。使用描述性名称,但使它们尽量简短。

否则: JavaScript 是世界上唯一一门不需要实例化,就可以直接调用构造函数("Class")的编码语言。因此,类和函数的构造函数由采用 UpperCamelCase 开始区分。

代码示例

  // 使用UpperCamelCase命名类名
class SomeClassExample () {

// 常量使用const关键字,并使用lowerCamelCase命名
const config = {
key: 'value'
};

// 变量和方法使用lowerCamelCase命名
let someVariableExample = 'value';
function doSomething() {

}

}


3.7 使用 const 优于 let,废弃 var

TL;DR: 使用const意味着一旦一个变量被分配,它不能被重新分配。使用 const 将帮助您免于使用相同的变量用于不同的用途,并使你的代码更清晰。如果一个变量需要被重新分配,以在一个循环为例,使用let声明它。let 的另一个重要方面是,使用 let 声明的变量只在定义它的块作用域中可用。 var是函数作用域,不是块级作用域,既然您有 const 和 let 让您随意使用,那么不应该在 ES6 中使用 var

否则: 当经常更改变量时,调试变得更麻烦了。

🔗 更多: JavaScript ES6+: var, let, or const?



3.8 先 require, 而不是在方法内部

TL;DR: 在每个文件的起始位置,在任何函数的前面和外部 require 模块。这种简单的最佳实践,不仅能帮助您轻松快速地在文件顶部辨别出依赖关系,而且避免了一些潜在的问题。

否则: 在 Node.js 中,require 是同步运行的。如果从函数中调用它们,它可能会阻塞其他请求,在更关键的时间得到处理。另外,如果所 require 的模块或它自己的任何依赖项抛出错误并使服务器崩溃,最好尽快查明它,如果该模块在函数中 require 的,则可能不是这样的情况。



3.9 require 文件夹,而不是文件

TL;DR: 当在一个文件夹中开发库/模块,放置一个文件 index.js 暴露模块的 内部,这样每个消费者都会通过它。这将作为您模块的一个接口,并使未来的变化简单而不违反规则。

否则: 更改文件内部结构或签名可能会破坏与客户端的接口。

代码示例

// 建议
module.exports.SMSProvider = require("./SMSProvider");
module.exports.SMSNumberResolver = require("./SMSNumberResolver");

// 避免
module.exports.SMSProvider = require("./SMSProvider/SMSProvider.js");
module.exports.SMSNumberResolver = require("./SMSNumberResolver/SMSNumberResolver.js");


3.10 使用 === 操作符

TL;DR: 对比弱等于 ==,优先使用严格的全等于 =====将在它们转换为普通类型后比较两个变量。在 === 中没有类型转换,并且两个变量必须是相同的类型。

否则:== 操作符比较,不相等的变量可能会返回 true。

代码示例

"" == "0"; // false
0 == ""; // true
0 == "0"; // true

false == "false"; // false
false == "0"; // true

false == undefined; // false
false == null; // false
null == undefined; // true

" \t\r\n " == 0; // true

如果使用===, 上面所有语句都将返回 false。



3.11 使用 Async Await, 避免回调

TL;DR: Node 8 LTS 现已全面支持异步等待。这是一种新的方式处理异步请求,取代回调和 promise。Async-await 是非阻塞的,它使异步代码看起来像是同步的。您可以给你的代码的最好的礼物是用 async-await 提供了一个更紧凑的,熟悉的,类似 try catch 的代码语法。

否则: 使用回调的方式处理异步错误可能是陷入困境最快的方式 - 这种方式必须面对不停地检测错误,处理别扭的代码内嵌,难以推理编码流。

🔗更多: async await 1.0 引导



3.12 使用 (=>) 箭头函数

TL;DR: 尽管使用 async-await 和避免方法作为参数是被推荐的, 但当处理那些接受 promise 和回调的老的 API 的时候 - 箭头函数使代码结构更加紧凑,并保持了根方法上的语义上下文 (例如 'this')。

否则: 更长的代码(在 ES5 方法中)更易于产生缺陷,并读起来很是笨重。

🔗 更多: 这是拥抱箭头函数的时刻




4. 测试和总体的质量实践

4.1 至少,编写 API(组件)测试

TL;DR: 大多数项目只是因为时间表太短而没有进行任何自动化测试,或者测试项目失控而正被遗弃。因此,优先从 API 测试开始,这是最简单的编写和提供比单元测试更多覆盖率的事情(你甚至可能不需要编码而进行 API 测试,像Postman。之后,如果您有更多的资源和时间,继续使用高级测试类型,如单元测试、DB 测试、性能测试等。

否则: 您可能需要花很长时间编写单元测试,才发现只有 20%的系统覆盖率。



4.2 使用一个 linter 检测代码问题

TL;DR: 使用代码 linter 检查基本质量并及早检测反模式。在任何测试之前运行它, 并将其添加为预提交的 git 钩子, 以最小化审查和更正任何问题所需的时间。也可在Section 3中查阅编码样式实践

否则: 您可能让一些反模式和易受攻击的代码传递到您的生产环境中。



4.3 仔细挑选您的持续集成(CI)平台

TL;DR: 您的持续集成平台(cicd)将集成各种质量工具(如测试、lint),所以它应该是一个充满活力的生态系统,包含各种插件。jenkins曾经是许多项目的默认选项,因为它有最大的社区,同时也是一个非常强大的平台,这样的代价是要求一个陡峭的学习曲线。如今,使用 SaaS 工具,比如CircleCI及其他,安装一套 CI 解决方案,相对是一件容易的事情。这些工具允许构建灵活的 CI 管道,而无需管理整个基础设施。最终,这是一个鲁棒性和速度之间的权衡 - 仔细选择您支持的方案。

否则: 一旦您需要一些高级定制,选择一些细分市场供应商可能会让您停滞不前。另一方面,伴随着 jenkins,可能会在基础设施设置上浪费宝贵的时间。

🔗 更多: 挑选 CI 平台



4.4 经常检查易受攻击的依赖

TL;DR: 即使是那些最有名的依赖模块,比如 Express,也有已知的漏洞。使用社区和商业工具,比如 🔗 npm audit ,集成在您的 CI 平台上,在每一次构建的时候都会被调用,这样可以很容易地解决漏洞问题。

否则: 在没有专用工具的情况下,使代码清除漏洞,需要不断地跟踪有关新威胁的在线出版物,相当繁琐。



4.5 测试标签化

TL;DR: 不同的测试必须运行在不同的情景:quick smoke,IO-less,当开发者保存或提交一个文件,测试应该启动;完整的端到端的测试通常运行在一个新的 pull request 被提交之后,等等。这可以通过对测试用例设置标签,比如关键字像#cold #api #sanity,来完成。这样您可以对您的测试集进行 grep,调用需要的子集。例如,这就是您通过Mocha仅仅调用 sanity 测试集所需要做的:mocha --grep 'sanity'。

否则: 运行所有的测试,包括执行数据库查询的几十个测试,任何时候开发者进行小的改动都可能很慢,这使得开发者不愿意运行测试。



4.6 检查测试覆盖率,它有助于识别错误的测试模式

TL;DR: 代码覆盖工具比如 Istanbul/NYC,很好用有 3 个原因:它是免费的(获得这份报告不需要任何开销),它有助于确定测试覆盖率降低的部分,以及最后但非最不重要的是它指出了测试中的不匹配:通过查看颜色标记的代码覆盖报告您可以注意到,例如,从来不会被测到的代码片段像 catch 语句(即测试只是调用正确的路径,而不调用应用程序发生错误时的行为)。如果覆盖率低于某个阈值,则将其设置为失败的构建。

否则: 当你的大部分代码没有被测试覆盖时,就不会有任何自动化的度量指标告诉你了。



4.7 检查过期的依赖包

TL;DR: 使用您的首选工具 (例如 “npm outdated” or npm-check-updates 来检测已安装的过期依赖包, 将此检查注入您的 CI 管道, 甚至在严重的情况下使构建失败。例如, 当一个已安装的依赖包滞后 5 个补丁时 (例如:本地版本是 1.3.1 的, 存储库版本是 1.3.8 的), 或者它被其作者标记为已弃用, 可能会出现严重的情况 - 停掉这次构建并防止部署此版本。

否则: 您的生产环境将运行已被其作者明确标记为有风险的依赖包



4.8 对于 e2e testing,使用 docker-compose

TL;DR: 端对端(e2e)测试包含现场数据,由于它依赖于很多重型服务如数据库,习惯被认为是 CI 过程中最薄弱的环节。Docker-compose 通过制定类似生产的环境,并使用一个简单的文本文件和简单的命令,轻松化解了这个问题。它为了 e2e 测试,允许制作所有相关服务,数据库和隔离网络。最后但并非最不重要的一点是,它可以保持一个无状态环境,该环境在每个测试套件之前被调用,然后立即消失。

否则: 没有 docker-compose,团队必须维护一个测试数据库在每一个测试环境上,包含开发机器,保持所有数据同步,这样测试结果不会因环境不同而不同。




5. 上线实践

5.1. 监控!

TL;DR: 监控是一种在顾客之前发现问题的游戏 – 显然这应该被赋予前所未有的重要性。考虑从定义你必须遵循的基本度量标准开始(我的建议在里面),到检查附加的花哨特性并选择解决所有问题的解决方案。市场已经淹没其中。点击下面的 ‘The Gist’ ,了解解决方案的概述。

否则: 错误 === 失望的客户. 非常简单.

🔗 更多: 监控!



5.2. 使用智能日志增加透明度

TL;DR: 日志可以是调试语句的一个不能说话的仓库,或者表述应用运行过程的一个漂亮仪表板的驱动。从第 1 天计划您的日志平台:如何收集、存储和分析日志,以确保所需信息(例如,错误率、通过服务和服务器等完成整个事务)都能被提取出来。

否则: 您最终像是面对一个黑盒,不知道发生了什么事情,然后你开始重新写日志语句添加额外的信息。

🔗 更多: 使用智能日志增加透明度



5.3. 委托可能的一切(例如:gzip,SSL)给反向代理

TL;DR: Node 处理 CPU 密集型任务,如 gzipping,SSL termination 等,表现糟糕。相反,使用一个 ‘真正’ 的中间件服务像 Nginx,HAProxy 或者云供应商的服务。

否则: 可怜的单线程 Node 将不幸地忙于处理网络任务,而不是处理应用程序核心,性能会相应降低。

🔗 更多: 委托可能的一切(例如:gzip,SSL)给反向代理



5.4. 锁住依赖

TL;DR: 您的代码必须在所有的环境中是相同的,但是令人惊讶的是,NPM 默认情况下会让依赖在不同环境下发生偏移 – 当在不同的环境中安装包的时候,它试图拿包的最新版本。克服这种问题可以利用 NPM 配置文件, .npmrc,告诉每个环境保存准确的(不是最新的)包的版本。另外,对于更精细的控制,使用 NPM “shrinkwrap”。*更新:作为 NPM5,依赖默认锁定。新的包管理工具,Yarn,也默认锁定。

否则: QA 测试通过的代码和批准的版本,在生产中表现不一致。更糟糕的是,同一生产集群中的不同服务器可能运行不同的代码。

🔗 更多: 锁住依赖



5.5. 使用正确的工具保护进程正常运行

TL;DR: 进程必须继续运行,并在失败时重新启动。对于简单的情况下,“重启”工具如 PM2 可能足够,但在今天的“Dockerized”世界 – 集群管理工具也值得考虑

否则: 运行几十个实例没有明确的战略和太多的工具(集群管理,docker,PM2)可能导致一个 DevOps 混乱

🔗 更多: 使用正确的工具保护进程正常运行



5.6. 利用 CPU 多核

TL;DR: 在基本形式上,node 应用程序运行在单个 CPU 核心上,而其他都处于空闲状态。复制 node 进程和利用多核,这是您的职责 – 对于中小应用,您可以使用 Node Cluster 和 PM2. 对于一个大的应用,可以考虑使用一些 Docker cluster(例如 k8s,ECS)复制进程或基于 Linux init system(例如 systemd)的部署脚本

否则: 您的应用可能只是使用了其可用资源中的 25% (!),甚至更少。注意,一台典型的服务器有 4 个或更多的 CPU,默认的 Node.js 部署仅仅用了一个 CPU(甚至使用 PaaS 服务,比如 AWS beanstalk,也一样)。

🔗 更多: 利用所有的 CPU



5.7. 创建一个“维护端点”

TL;DR: 在一个安全的 API 中暴露一组系统相关的信息,比如内存使用情况和 REPL 等等。尽管这里强烈建议依赖标准和作战测试工具,但一些有价值的信息和操作更容易使用代码完成。

否则: 您会发现,您正在执行许多“诊断部署” – 将代码发送到生产中,仅仅只为了诊断目的提取一些信息。

🔗 更多: 创建一个 '维护端点'



5.8. 使用 APM 产品发现错误和宕机时间

TL;DR: 监控和性能的产品(即 APM)先前一步地评估代码库和 API,自动的超过传统的监测,并测量在服务和层级上的整体用户体验。例如,一些 APM 产品可以突显导致最终用户负载过慢的事务,同时指出根本原因。

否则: 你可能会花大力气测量 API 性能和停机时间,也许你永远不会知道,真实场景下哪个是你最慢的代码部分,这些怎么影响用户体验。

🔗 更多: 使用 APM 产品发现错误和宕机时间



5.9. 使您的代码保持生产环境就绪

TL;DR: 在意识中抱着最终上线的想法进行编码,从第 1 天开始计划上线。这听起来有点模糊,所以我编写了一些与生产维护密切相关的开发技巧(点击下面的要点)

否则: 一个世界冠军级别的 IT/运维人员也不能拯救一个编码低劣的系统。

🔗 更多: 使您的代码保持生产环境就绪



5.10. 测量和保护内存使用

TL;DR: Node.js 和内存有引起争论的联系:V8 引擎对内存的使用有稍微的限制(1.4GB),在 node 的代码里面有内存泄漏的很多途径 – 因此监视 node 的进程内存是必须的。在小应用程序中,你可以使用 shell 命令周期性地测量内存,但在中等规模的应用程序中,考虑把内存监控建成一个健壮的监控系统。

否则: 您的内存可能一天泄漏一百兆,就像曾发生在沃尔玛的一样。

🔗 更多: 测量和保护内存使用



5.11. Node 外管理您的前端资源

TL;DR: 使用专门的中间件(nginx,S3,CDN)服务前端内容,这是因为在处理大量静态文件的时候,由于 node 的单线程模型,它的性能很受影响。

否则: 您的单个 node 线程将忙于传输成百上千的 html/图片/angular/react 文件,而不是分配其所有的资源为了其擅长的任务 – 服务动态内容

🔗 更多: Node 外管理您的前端资源



5.12. 保持无状态,几乎每天都要停下服务器

TL;DR: 在外部数据存储上,存储任意类型数据(例如用户会话,缓存,上传文件)。考虑间隔地停掉您的服务器或者使用 ‘serverless’ 平台(例如 AWS Lambda),这是一个明确的强化无状态的行为。

否则: 某个服务器上的故障将导致应用程序宕机,而不仅仅是停用故障机器。此外,由于依赖特定服务器,伸缩弹性会变得更具挑战性。

🔗 更多: 保持无状态,几乎每天都要停下服务器



5.13. 使用自动检测漏洞的工具

TL;DR: 即使是最有信誉的依赖项,比如 Express,会有使系统处于危险境地的已知漏洞(随着时间推移)。通过使用社区的或者商业工具,不时的检查漏洞和警告(本地或者 Github 上),这类问题很容易被抑制,有些问题甚至可以立即修补。

否则: 否则: 在没有专用工具的情况下,使代码清除漏洞,需要不断地跟踪有关新威胁的在线出版物。相当繁琐。

🔗 更多: 使用自动检测漏洞的工具



5.14. 在每一个 log 语句中指明 ‘TransactionId’

TL;DR: 在每一个请求的每一条 log 入口,指明同一个标识符,transaction-id: {某些值}。然后在检查日志中的错误时,很容易总结出前后发生的事情。不幸的是,由于 Node 异步的天性自然,这是不容易办到的,看下代码里面的例子

否则: 在没有上下文的情况下查看生产错误日志,这会使问题变得更加困难和缓慢去解决。

🔗 更多: 在每一个 log 语句中指明 ‘TransactionId’



5.15. 设置 NODE_ENV=production

TL;DR: 设置环境变量 NODE_ENV 为‘production’ 或者 ‘development’,这是一个是否激活上线优化的标志 - 很多 NPM 的包通过它来判断当前的环境,据此优化生产环境代码。

否则: 遗漏这个简单的属性可能大幅减弱性能。例如,在使用 Express 作为服务端渲染页面的时候,如果未设置 NODE_ENV,性能将会减慢大概三分之一!

🔗 更多: 设置 NODE_ENV=production



5.16. 设计自动化、原子化和零停机时间部署

TL;DR: 研究表明,执行许多部署的团队降低了严重上线问题的可能性。不需要危险的手动步骤和服务停机时间的快速和自动化部署大大改善了部署过程。你应该达到使用 Docker 结合 CI 工具,使他们成为简化部署的行业标准。

否则: 长时间部署 -> 线上宕机 & 和人相关的错误 -> 团队部署时不自信 -> 更少的部署和需求




6. 安全最佳实践

53 items

6.1. 拥护 linter 安全准则

TL;DR: 使用安全相关的 linter 插件,比如eslint-plugin-security,尽早捕获安全隐患或者问题,最好在编码阶段。这能帮助察觉安全的问题,比如使用 eval,调用子进程,或者根据字面含义(比如,用户输入)引入模块等等。点击下面‘更多’获得一个安全 linter 可以检测到的代码示例。

Otherwise: 在开发过程中, 可能一个直白的安全隐患, 成为生产环境中一个严重问题。此外, 项目可能没有遵循一致的安全规范, 而导致引入漏洞, 或敏感信息被提交到远程仓库中。

🔗 更多: Lint 规范



6.2. 使用中间件限制并发请求

TL;DR: DOS 攻击非常流行而且相对容易处理。使用外部服务,比如 cloud 负载均衡, cloud 防火墙, nginx, 或者(对于小的,不是那么重要的 app)一个速率限制中间件(比如express-rate-limit),来实现速率限制。

否则: 应用程序可能受到攻击, 导致拒绝服务, 在这种情况下, 真实用户会遭受服务降级或不可用。

🔗 更多: 实施速率限制



6.3 把机密信息从配置文件中抽离出来,或者使用包对其加密

TL;DR: 不要在配置文件或源代码中存储纯文本机密信息。相反, 使用诸如 Vault 产品、Kubernetes/Docker Secrets 或使用环境变量之类的安全管理系统。最后一个结果是, 存储在源代码管理中的机密信息必须进行加密和管理 (滚动密钥(rolling keys)、过期时间、审核等)。使用 pre-commit/push 钩子防止意外提交机密信息。

否则: 源代码管理, 即使对于私有仓库, 也可能会被错误地公开, 此时所有的秘密信息都会被公开。外部组织的源代码管理的访问权限将无意中提供对相关系统 (数据库、api、服务等) 的访问。

🔗 更多: 安全管理



6.4. 使用 ORM/ODM 库防止查询注入漏洞

TL;DR: 要防止 SQL/NoSQL 注入和其他恶意攻击, 请始终使用 ORM/ODM 或 database 库来转义数据或支持命名的或索引的参数化查询, 并注意验证用户输入的预期类型。不要只使用 JavaScript 模板字符串或字符串串联将值插入到查询语句中, 因为这会将应用程序置于广泛的漏洞中。所有知名的 Node.js 数据访问库(例如Sequelize, Knex, mongoose)包含对注入漏洞的内置包含措施。

否则: 未经验证或未脱敏处理的用户输入,可能会导致操作员在使用 MongoDB 进行 NoSQL 操作时进行注入, 而不使用适当的过滤系统或 ORM 很容易导致 SQL 注入攻击, 从而造成巨大的漏洞。

🔗 更多: 使用 ORM/ODM 库防止查询注入



6.5. 通用安全最佳实际集合

TL;DR: 这些是与 Node.js 不直接相关的安全建议的集合-Node 的实现与任何其他语言没有太大的不同。单击 "阅读更多" 浏览。

🔗 更多: 通用安全最佳实际



6.6. 调整 HTTP 响应头以加强安全性

TL;DR: 应用程序应该使用安全的 header 来防止攻击者使用常见的攻击方式,诸如跨站点脚本(XSS)、点击劫持和其他恶意攻击。可以使用模块,比如 helmet轻松进行配置。

否则: 攻击者可以对应用程序的用户进行直接攻击, 导致巨大的安全漏洞

🔗 更多: 在应用程序中使用安全的 header



6.7. 经常自动检查易受攻击的依赖库

TL;DR: 在 npm 的生态系统中, 一个项目有许多依赖是很常见的。在找到新的漏洞时, 应始终将依赖项保留在检查中。使用工具,类似于npm audit 或者 snyk跟踪、监视和修补易受攻击的依赖项。将这些工具与 CI 设置集成, 以便在将其上线之前捕捉到易受攻击的依赖库。

否则: 攻击者可以检测到您的 web 框架并攻击其所有已知的漏洞。

🔗 更多: 安全依赖



6.8. 避免使用 Node.js 的 crypto 库处理密码,使用 Bcrypt

TL;DR: 密码或机密信息(API 密钥)应该使用安全的哈希+salt 函数(如 "bcrypt")来存储, 因为性能和安全原因, 这应该是其 JavaScript 实现的首选。

否则: 在不使用安全功能的情况下,保存的密码或秘密信息容易受到暴力破解和字典攻击, 最终会导致他们的泄露。

🔗 更多: 使用 Bcrypt



6.9. 转义 HTML、JS 和 CSS 输出

TL;DR: 发送给浏览器的不受信任数据可能会被执行, 而不是显示, 这通常被称为跨站点脚本(XSS)攻击。使用专用库将数据显式标记为不应执行的纯文本内容(例如:编码、转义),可以减轻这种问题。

否则: 攻击者可能会将恶意的 JavaScript 代码存储在您的 DB 中, 然后将其发送给可怜的客户端。

🔗 更多: 转义输出



6.10. 验证传入的 JSON schemas

TL;DR: 验证传入请求的 body payload,并确保其符合预期要求, 如果没有, 则快速报错。为了避免每个路由中繁琐的验证编码, 您可以使用基于 JSON 的轻量级验证架构,比如jsonschema or joi

否则: 您疏忽和宽松的方法大大增加了攻击面, 并鼓励攻击者尝试许多输入, 直到他们找到一些组合, 使应用程序崩溃。

🔗 更多: 验证传入的 JSON schemas



6.11. 支持黑名单的 JWT

TL;DR: 当使用 JSON Web Tokens(例如, 通过Passport.js), 默认情况下, 没有任何机制可以从发出的令牌中撤消访问权限。一旦发现了一些恶意用户活动, 只要它们持有有效的标记, 就无法阻止他们访问系统。通过实现一个不受信任令牌的黑名单,并在每个请求上验证,来减轻此问题。

否则: 过期或错误的令牌可能被第三方恶意使用,以访问应用程序,并模拟令牌的所有者。

🔗 更多: 为 JSON Web Token 添加黑名单



6.12. 限制每个用户允许的登录请求

TL;DR: 一类保护暴力破解的中间件,比如express-brute,应该被用在 express 的应用中,来防止暴力/字典攻击;这类攻击主要应用于一些敏感路由,比如/admin 或者 /login,基于某些请求属性, 如用户名, 或其他标识符, 如正文参数等。

否则: 攻击者可以发出无限制的密码匹配尝试, 以获取对应用程序中特权帐户的访问权限。

🔗 更多: 限制登录频率



6.13. 使用非 root 用户运行 Node.js

TL;DR: Node.js 作为一个具有无限权限的 root 用户运行,这是一种普遍的情景。例如,在 Docker 容器中,这是默认行为。建议创建一个非 root 用户,并保存到 Docker 镜像中(下面给出了示例),或者通过调用带有"-u username" 的容器来代表此用户运行该进程。

否则: 在服务器上运行脚本的攻击者在本地计算机上获得无限制的权利 (例如,改变 iptable,引流到他的服务器上)

🔗 更多: 使用非 root 用户运行 Node.js



6.14. 使用反向代理或中间件限制负载大小

TL;DR: 请求 body 有效载荷越大, Node.js 的单线程就越难处理它。这是攻击者在没有大量请求(DOS/DDOS 攻击)的情况下,就可以让服务器跪下的机会。在边缘上(例如,防火墙,ELB)限制传入请求的 body 大小,或者通过配置express body parser仅接收小的载荷,可以减轻这种问题。

否则: 您的应用程序将不得不处理大的请求, 无法处理它必须完成的其他重要工作, 从而导致对 DOS 攻击的性能影响和脆弱性。

🔗 更多: 限制负载大小



6.15. 避免 JavaScript 的 eval 声明

TL;DR: eval 是邪恶的, 因为它允许在运行时执行自定义的 JavaScript 代码。这不仅是一个性能方面的问题, 而且也是一个重要的安全问题, 因为恶意的 JavaScript 代码可能来源于用户输入。应该避免的另一种语言功能是 new Function 构造函数。setTimeoutsetInterval 也不应该传入动态 JavaScript 代码。

否则: 恶意 JavaScript 代码查找传入 eval 或其他实时判断的 JavaScript 函数的文本的方法, 并将获得在该页面上 javascript 权限的完全访问权。此漏洞通常表现为 XSS 攻击。

🔗 更多: 避免 JavaScript 的 eval 声明



6.16. 防止恶意 RegEx 让 Node.js 的单线程过载执行

TL;DR: 正则表达式,在方便的同时,对 JavaScript 应用构成了真正的威胁,特别是 Node.js 平台。匹配文本的用户输入需要大量的 CPU 周期来处理。在某种程度上,正则处理是效率低下的,比如验证 10 个单词的单个请求可能阻止整个 event loop 长达 6 秒,并让 CPU 引火烧身。由于这个原因,偏向第三方的验证包,比如validator.js,而不是采用正则,或者使用safe-regex来检测有问题的正则表达式。

否则: 写得不好的正则表达式可能容易受到正则表达式 DoS 攻击的影响, 这将完全阻止 event loop。例如,流行的moment包在 2017 年的 11 月,被发现使用了错误的 RegEx 用法而易受攻击。

🔗 更多: 防止恶意正则



6.17. 使用变量避免模块加载

TL;DR: 避免通过作为参数的路径 requiring/importing 另一个文件, 原因是它可能源自用户输入。此规则可扩展为访问一般文件(即:fs.readFile())或使用来自用户输入的动态变量访问其他敏感资源。Eslint-plugin-security linter 可以捕捉这样的模式, 并尽早提前警告。

否则: 恶意用户输入可以找到用于获得篡改文件的参数, 例如, 文件系统上以前上载的文件, 或访问已有的系统文件。

🔗 更多: 安全地加载模块



6.18. 在沙箱中运行不安全代码

TL;DR: 当任务执行在运行时给出的外部代码时(例如, 插件), 使用任何类型的沙盒执行环境保护主代码,并隔离开主代码和插件。这可以通过一个专用的过程来实现 (例如:cluster.fork()), 无服务器环境或充当沙盒的专用 npm 包。

否则: 插件可以通过无限循环、内存超载和对敏感进程环境变量的访问等多种选项进行攻击

🔗 更多: 在沙箱中运行不安全代码



6.19. 使用子进程时要格外小心

TL;DR: 尽可能地避免使用子进程,如果您仍然必须这样做,验证和清理输入以减轻 shell 注入攻击。更喜欢使用 "child_process"。execFile 的定义将只执行具有一组属性的单个命令, 并且不允许 shell 参数扩展。倾向于使用child_process.execFile,从定义上来说,它将仅仅执行具有一组属性的单个命令,并且不允许 shell 参数扩展。

否则: 由于将恶意用户输入传递给未脱敏处理的系统命令, 直接地使用子进程可能导致远程命令执行或 shell 注入攻击。

🔗 更多: 处理子进程时要格外小心



6.20. 隐藏客户端的错误详细信息

TL;DR: 默认情况下, 集成的 express 错误处理程序隐藏错误详细信息。但是, 极有可能, 您实现自己的错误处理逻辑与自定义错误对象(被许多人认为是最佳做法)。如果这样做, 请确保不将整个 Error 对象返回到客户端, 这可能包含一些敏感的应用程序详细信息。

否则: 敏感应用程序详细信息(如服务器文件路径、使用中的第三方模块和可能被攻击者利用的应用程序的其他内部工作流)可能会从 stack trace 发现的信息中泄露。

🔗 更多: 隐藏客户端的错误详细信息



6.21. 对 npm 或 Yarn,配置 2FA

TL;DR: 开发链中的任何步骤都应使用 MFA(多重身份验证)进行保护, npm/Yarn 对于那些能够掌握某些开发人员密码的攻击者来说是一个很好的机会。使用开发人员凭据, 攻击者可以向跨项目和服务广泛安装的库中注入恶意代码。甚至可能在网络上公开发布。在 npm 中启用 2 因素身份验证(2-factor-authentication), 攻击者几乎没有机会改变您的软件包代码。

否则: Have you heard about the eslint developer who's password was hijacked?



6.22. 修改 session 中间件设置

TL;DR: 每个 web 框架和技术都有其已知的弱点-告诉攻击者我们使用的 web 框架对他们来说是很大的帮助。使用 session 中间件的默认设置, 可以以类似于X-Powered-Byheader 的方式向模块和框架特定的劫持攻击公开您的应用。尝试隐藏识别和揭露技术栈的任何内容(例如:Nonde.js, express)。

否则: 可以通过不安全的连接发送 cookie, 攻击者可能会使用会话标识来标识 web 应用程序的基础框架以及特定于模块的漏洞。

🔗 更多: cookie 和 session 安全



6.23. 通过显式设置进程应崩溃的情况,以避免 DOS 攻击

TL;DR: 当错误未被处理时, Node 进程将崩溃。即使错误被捕获并得到处理,许多最佳实践甚至建议退出。例如, Express 会在任何异步错误上崩溃 - 除非使用 catch 子句包装路由。这将打开一个非常惬意的攻击点, 攻击者识别哪些输入会导致进程崩溃并重复发送相同的请求。没有即时补救办法, 但一些技术可以减轻苦楚: 每当进程因未处理的错误而崩溃,都会发出警报,验证输入并避免由于用户输入无效而导致进程崩溃,并使用 catch 将所有路由处理包装起来,并在请求中出现错误时, 考虑不要崩溃(与全局发生的情况相反)。

否则: 这只是一个起到教育意义的假设: 给定许多 Node.js 应用程序, 如果我们尝试传递一个空的 JSON 正文到所有 POST 请求 - 少数应用程序将崩溃。在这一点上, 我们可以只是重复发送相同的请求, 就可以轻松地搞垮应用程序。

· 阅读需 3 分钟

fill-available

width:fill-available表示撑满可用空间

举例来说,页面中一个<div>元素,该<div>元素的width表现就是fill-available自动填满剩余的空间

该属性高度也可用

使用场景:

  1. 可以让元素的100%自动填充特性不仅仅在block水平元素上,也可以应用在其他元素,如span
  2. 可以在非flex等高级布局下,轻松实现元素占据剩余空间的效果
  3. 待发掘

兼容性: 20200630201217

值得注意的是绝大部分浏览器只支持-webkit前缀的fill-available

fit-content

width:fit-content表示将元素宽度收缩为内容宽度

高度也可用

使用场景:

  1. 可以赋予block的元素收缩的效果
  2. 配合margin:auto轻松实现向内自适应同时的居中效果

兼容性: 20200630202006

使用时最好也加上-webkit前缀的fit-content

min-content

width:min-content表示采用内部元素最小宽度值最大的那个元素的宽度作为最终容器的宽度

最小宽度值: 替换元素,例如图片的最小宽度值就是图片呈现的宽度,对于文本元素,如果全部是中文,则最小宽度值就是一个中文的宽度值;如果包含英文,因为默认英文单词不换行,所以,最小宽度可能就是里面最长的英文单词的宽度

兼容性: 20200630202556

max-content

width:max-content表示采用内部元素宽度值最大的那个元素的宽度作为最终容器的宽度。如果出现文本,则相当于文本不换行

兼容性: 20200630202625

max

/* property: max(expression [, expression]) */
width: max(10vw, 4em, 80px);

兼容性: 20200630203754

不容乐观,混个眼熟就算了

min

/* property: min(expression [, expression]) */
width: min(1vw, 4em, 80px);

兼容性: 20200630203921

同样不容乐观,看看算了

以上所有属性在IE中都无效

· 阅读需 15 分钟

基础

原始数据类型

  • boolean
  • number
  • string
  • null
  • undefined
  • Symbol
  • void
  • any

任意值

  • any

类型推论

定义变量时如果进行了赋值,那么变量的类型就会被自动设置为值的类型,反之,如果没有进行赋值,则会被设置为any

联合类型

  • type1 | type2
  • 联合类型的变量在被赋值的时候,会根据类型推论的规则推断出一个类型,比如 string|number 类型的变量,当被赋值为 number 类型时不能使用 xxx.length

接口 interface

  • 确定属性 赋值的时候,变量的形状必须和接口的形状保持一致(不能多也不能少属性)
  • 可选属性 key?: type
  • 任意属性 [propName: string]: any
    • 一旦定义任意属性,确定属性和可选属性的类型必须是它的类型的子集
  • 只读属性 readonly key: type

数组类型

  • 「类型 + 方括号」表示法
    • eg: let arr: number[] = [1, 2, 3];
  • 数组泛型
    • eg: let arr: Array<number> = [1,2,3]
  • 用接口表示数组
    • interface NumberArray {
      [index: number]: number;
      }
      let fibonacci: NumberArray = [1, 1, 2, 3, 5];
  • 类数组
    • 类数组不是数组类型,有自己的类型
    • function sum() {
      let args: {
      [index: number]: number;
      length: number;
      callee: Function;
      } = arguments;
      }
    • 常用的类数组都有自己的接口定义,如 IArguments, NodeList, HTMLCollection

函数类型

  • 函数声明

    • function sum(x: number, y: number): number {
      return x + y;
      }
  • 函数表达式

    • let mySum: (x: number, y: number) => number = function(
      x: number,
      y: number
      ): number {
      return x + y;
      };

      // 利用类型推断进行简化
      let mySum = function(x: number, y: number): number {
      return x + y;
      };
  • 接口定义

    • interface SearchFunc {
      (source: string, subString: string): boolean;
      }

      let mySearch: SearchFunc;
      mySearch = function(source: string, subString: string) {
      return source.search(subString) !== -1;
      };
  • 可选参数

    • x?: type
  • 剩余参数

    • 同 es6
  • 函数重载

    • 匹配多种完全不同的入参格式
    • function reverse(x: number): number;
      function reverse(x: string): string;
      function reverse(x: number | string): number | string {
      if (typeof x === "number") {
      return Number(
      x
      .toString()
      .split("")
      .reverse()
      .join("")
      );
      } else if (typeof x === "string") {
      return x
      .split("")
      .reverse()
      .join("");
      }
      }

类型断言

  • 语法
    • 值 as 类型
  • 用途
    • 将一个联合类型断言为其中一个类型
    • 将一个父类断言为更加具体的子类
    • 将任何一个类型断言为 any
    • any 断言为一个具体的类型
    • 要使得 A 能够被断言为 B,只需要 A 兼容 B 或 B 兼容 A 即可
  • 双重断言(不要使用)
  • 关系
    • 与类型转换
    • 与类型声明
    • 与泛型

声明文件

  • 新语法

    • declare var 声明全局变量
    • declare let 声明全局变量
    • declare const 声明不可变全局变量
    • declare function 声明全局方法
    • declare class 声明全局类
    • declare enum 声明全局枚举类型
    • declare namespace 声明(含有子属性的)全局对象
    • interfacetype 声明全局类型
    • export 导出变量
    • export namespace 导出(含有子属性的)对象
    • export default ES6 默认导出
    • export = commonjs 导出模块
    • export as namespace UMD 库声明全局变量
    • declare global 扩展全局变量
    • declare module 扩展模块
    • /// <reference /> 三斜线指令
  • 声明语句

    • 当引入了第三方代码的时候,ts 无法识别第三方代码的全局变量,例如可以通过declare var jQuery: (selector: string) => any;使用 jQuery
  • 声明文件

    • 当把声明语句放到一个单独文件中,就是声明文件
    • 声明文件以.d.ts为后缀
    • 社区已经有不少声明文件了,使用@types 统一管理第三方库的声明文件npm install @types/jquery --save-dev
    • 可以在这个页面搜索
    • 如果没有生效,可以检查下 tsconfig.json 中的 files、include 和 exclude 配置,确保其包含了 xxx.d.ts 文件
    • 只能定义类型,不能在声明语句中定义具体实现
  • declare namespace

    • declare namespace jQuery {
      function ajax(url: string, settings?: any): void;
      const version: number;
      class Event {
      blur(eventType: EventType): void;
      }
      enum EventType {
      CustomClick,
      }
      <!-- 可以进行深层嵌套 -->
      namespace fn {
      function extend(object: any): void;
      }
      }

      <!-- 如果只有extend方法,也可以这样写 -->
      declare namespace jQuery.fn {
      function extend(object: any): void;
      }
    • interface 和 type 可以放在 namespace 下防止命名冲突

    • declare namespace jQuery {
      interface AjaxSettings {
      method?: "GET" | "POST";
      data?: any;
      }
      function ajax(url: string, settings?: AjaxSettings): void;
      }
  • 多个声明可以合并,类似于函数的重载

  • npm 包

    • 倒入的 npm 包的声明文件位置
      • 与 npm 包绑定在一起。package.json 中的 types 字段或者 index.d.ts 文件
      • 如果 npm 包维护者没有提供声明文件,其他人会把声明文件发布到@types 中
      • 如果上面两种都没找到,那么就要自己写了
    • 自己写声明文件
      • tsconfig.json 同级目录下创建 types 目录,管理声明文件
      • 需要配置 tsconfig.json 中的 paths 和 baseUrl 字段
      • {
        "compilerOptions": {
        "module": "commonjs",
        "baseUrl": "./",
        "paths": {
        "*": ["types/*"]
        }
        }
        }
  • 导入导出

    • npm 包中的声明文件中,使用 declare 只会声明一个局部变量
    • 使用 export 导出时,可以不用写 declare
    • 可以使用 declare 声明多个变量,再使用 export 一次性导出
    • commonjs
      • 导出
        • 整体导出 module.exports = foo
        • 单个导出 exports.bar = bar
      • 导入
        • 方式 1
          • const foo = require('foo');
          • const bar = require('foo').bar;
        • 方式 2
          • import * as foo from 'foo';
          • import { bar } from 'foo';
        • 方式 3
          • import foo = require('foo');
          • import bar = foo.bar;
    • UMD 库
      • export as namespace
    • 在 npm 包或 UMD 库中扩展全局变量
      • declare global
    • 模块插件
      • declare module
  • 声明文件中的依赖

    • import

    • 三斜线

      • 场景

        • 当我们在书写一个全局变量的声明文件时
        • 当我们需要依赖一个全局变量的声明文件时
      • // types/jquery-plugin/index.d.ts

        /// <reference types="jquery" />
        /// <reference path="JQuery.d.ts" />

        declare function foo(options: JQuery.AjaxSettings): string;
  • 自动生成声明文件

    • {
      "compilerOptions": {
      "module": "commonjs",
      "outDir": "lib",
      "declaration": true
      "declarationDir": "设置生成 .d.ts 文件的目录",
      "declarationMap": "对每个 .d.ts 文件,都生成对应的 .d.ts.map(sourcemap)文件",
      "emitDeclarationOnly": "仅生成 .d.ts 文件,不生成 .js 文件"
      }
      }
  • 寻找声明文件流程

    1. 寻找package.json中的types或者typing字段指定的地址
    2. 第 1 步没有,就会在项目根目录下寻找是否存在 index.d.ts 文件
    3. 第 2 步没有,就会寻找入口文件(package.json 中的 main 字段指定的入口文件)的目录下的同名不同后缀.d.ts文件
  • 将声明文件发布到 @types 下

内置对象

  • TypeScript 核心库的定义文件
  • ECMAScript 的内置对象
    • Boolean、Error、Date、RegExp 等
  • DOM 和 BOM 的内置对象
    • Document、HTMLElement、Event、NodeList 等
  • Node.js
    • TypeScript 核心库的定义中不包含 Node.js 部分
    • 使用 ts 写 node 时,要引入第三方声明文件 npm install @types/node --save-dev

进阶

类型别名

  • 给一个类型起个新名字
  • type
    • 类型可以是联合类型
type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
if (typeof n === "string") {
return n;
} else {
return n();
}
}

字符串字面量类型

  • 用来约束取值只能是某几个字符串中的一个
  • type
type EventNames = "click" | "scroll" | "mousemove";
function handleEvent(ele: Element, event: EventNames) {
// do something
}

handleEvent(document.getElementById("hello"), "scroll"); // 没问题
handleEvent(document.getElementById("world"), "dblclick"); // 报错,event 不能为 'dblclick'

// index.ts(7,47): error TS2345: Argument of type '"dblclick"' is not assignable to parameter of type 'EventNames'.

元组

  • 数组合并了相同类型的对象,而元组(Tuple)合并了不同类型的对象
  • let tom: [string, number] = ['Tom', 25];

枚举

  • 用于取值被限定在一定范围内的场景
  • 简单例子
enum Days {
Sun,
Mon,
Tue,
Wed,
Thu,
Fri,
Sat,
}
// 枚举成员会被赋值为从 0 开始递增的数字,同时也会对枚举值到枚举名进行反向映射

console.log(Days["Sun"] === 0); // true
console.log(Days["Mon"] === 1); // true
console.log(Days["Tue"] === 2); // true
console.log(Days["Sat"] === 6); // true

console.log(Days[0] === "Sun"); // true
console.log(Days[1] === "Mon"); // true
console.log(Days[2] === "Tue"); // true
console.log(Days[6] === "Sat"); // true
  • 手动赋值
enum Days {
Sun = 7,
Mon = 1,
Tue,
Wed,
Thu,
Fri,
Sat,
}

console.log(Days["Sun"] === 7); // true
console.log(Days["Mon"] === 1); // true
console.log(Days["Tue"] === 2); // true
console.log(Days["Sat"] === 6); // true

// 其实等价于enum Days {Sun = 7, Mon = 1, Tue=2, Wed=3, Thu=4, Fri=5, Sat=6};
// 所以可能会出现手动赋值和自动赋值重复的情况,因此要不全部手动赋值,要不全部自动赋值
  • 常数枚举
    • const enum
const enum Directions {
Up,
Down,
Left,
Right,
}

let directions = [
Directions.Up,
Directions.Down,
Directions.Left,
Directions.Right,
];

  • 基础部分参考 js 的类
  • 访问修饰符
    • public 公有
    • private 私有,不能在类的外部被访问
    • protected 受保护的,只能在类和子类中被访问
  • readonly
    • 只允许出现在属性声明或索引签名或构造函数中
    • 如果 readonly 和其他访问修饰符同时存在的话,需要写在其后面
  • 构造函数参数修饰符
    • 修饰符和 readonly 可以使用在构造函数参数中,等同于类中定义该属性同时给该属性赋值
  • 抽象类
    • 不允许被实例化
    • 抽象类中的抽象方法必须被子类实现

类与接口

  • 类实现接口
    • implements 关键字
    • 一个类可以实现多个接口
  • 接口继承接口
    • extends 关键字
  • 接口继承类
    • 当声明一个类的时候,相当于同时创建了一个对应的类型
    • 接口继承类时只会继承类的实例属性和实例方法

泛型

  • todo

声明合并

  • 函数合并
  • 接口合并
    • 对同一个接口进行多次定义,只要内部属性不冲突,就会合并
    • 接口中方法的合并,与函数合并一样
  • 类合并
    • 同接口合并

工程

eslint

npm install --save-dev eslint
npm install --save-dev typescript @typescript-eslint/parser
npm install --save-dev @typescript-eslint/eslint-plugin
npm install --save-dev prettier
npm install --save-dev eslint-plugin-react

.eslintrc
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
rules: {
// 禁止使用 var
'no-var': "error",
// 优先使用 interface 而不是 type
'@typescript-eslint/consistent-type-definitions': [
"error",
"interface"
]
}
}

vscode setting.json
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"eslint.autoFixOnSave": true,
"eslint.validate": [
"javascript",
"javascriptreact",
{
"language": "typescript",
"autoFix": true
},
{
"language": "typescriptreact",
"autoFix": true
}
],
"typescript.tsdk": "node_modules/typescript/lib"
}


// .prettierrc.js
module.exports = {
// 一行最多 100 字符
printWidth: 100,
// 使用 4 个空格缩进
tabWidth: 4,
// 不使用缩进符,而使用空格
useTabs: false,
// 行尾需要有分号
semi: true,
// 使用单引号
singleQuote: true,
// 对象的 key 仅在必要时用引号
quoteProps: 'as-needed',
// jsx 不使用单引号,而使用双引号
jsxSingleQuote: false,
// 末尾不需要逗号
trailingComma: 'none',
// 大括号内的首尾需要空格
bracketSpacing: true,
// jsx 标签的反尖括号需要换行
jsxBracketSameLine: false,
// 箭头函数,只有一个参数的时候,也需要括号
arrowParens: 'always',
// 每个文件格式化的范围是文件的全部内容
rangeStart: 0,
rangeEnd: Infinity,
// 不需要写文件开头的 @prettier
requirePragma: false,
// 不需要自动在文件开头插入 @prettier
insertPragma: false,
// 使用默认的折行标准
proseWrap: 'preserve',
// 根据显示样式决定 html 要不要折行
htmlWhitespaceSensitivity: 'css',
// 换行符使用 lf
endOfLine: 'lf'
};

编译选项

参考文档

· 阅读需 9 分钟

Why HTTPS 为什么会产生

HTTPS 产生的原因在于 HTTP 本身的不安全性

HTTP 的不安全主要体现在三个方面

  1. 通信使用不加密的明文,通信的内容容易被窃听
  2. 无法验证通信双方的身份,有可能遭遇伪装
  3. 无法验证报文内容的完整性,有可能获取到的内容已被篡改

因为上述三个原因,使用 HTTP 的互联网是完全不可靠不安全的,直至 HTTPS 的诞生

学习 HTTPS 前置知识

对称加密

加密解密都使用同一个密钥,该密钥我们称为共享密钥

非对称加密

加密和解密使用不同的密钥,取其中一个作为公钥可传播到公开场合,另一个作为私钥自己保管,两个密钥地位完全等同,其神奇之处在于经过一个密钥加密的内容,只有使用另一个密钥才能解密 非对称加密有两种场景

  1. 公钥加密,私钥解密:只有私钥的拥有者才能获取到真正的内容
  2. 私钥加密,公钥解密:内容接收者用于验证内容发送者的身份

非对称加密对比对称加密,安全性更高,但是同时带来的副作用是资源消耗变大,因为公钥和私钥的产生需要大量的计算,加解密也需要大量的计算

RSA

常见的非对称加密算法之一

ECDHE

常见的非对称加密算法之一

HTTPS 内容

为了规避 HTTP 的不安全性,HTTPS 在 HTTP 协议之下,增设了一层 SSL/TLS 协议,在该层中,进行了数据的加密 20200517155416

HTTPS 的数据传输使用了对称加密的方式,客户端和服务端都使用客户端生成的同一个密钥,但是问题关键是客户端如何把密钥安全的送到服务端手中。这里,HTTPS 使用了非对称加密,将共享密钥作为内容发送过去,因此 HTTPS 总体上使用了对称加密和非对称加密混合的加密机制。 那么,为什么不在数据传输的过程中也使用非对称加密的方式呢?答案是考虑到非对称加密需要大量的计算,占用资源多,且连接繁琐,耗时长,因此采用对称加密的方式。

在使用非对称加密的过程中,必然涉及到公钥的发送与接收,那么 HTTPS 又如何保证公钥的真实性呢? 这里,HTTPS 引入了由数字证书认证机构(CA,Certificate Authority)和其相关机关颁发的公开密钥证书

与证书相关的流程见下图: 20200517161711 这里使用了前置知识中的私钥加密,公钥解密,内容接收者用于验证内容发送者的身份的知识,CA 使用自己的私钥对服务器的公钥进行了加密得到证书,之后客户端使用 CA 的公钥对证书解密,从而验证 CA 的真实性和服务器公钥的真实性

下图是 HTTPS 的通信步骤 20200517163743

  1. 客户端发送 ClientHello 报文开始 SSL 通信,包含一个随机数 client_random,SSL 版本,加密组件列表等信息
  2. 服务器发送 ServerHello 作为回应,包含一个随机数 server_random,SSL 版本,加密组件等信息
  3. 服务器发送 Certificate 报文,包含公钥证书
  4. 服务器发送 Server Hello Done 报文通知客户端,最初阶段的 SSL 握手协商部分结束
  5. SSL 第一次握手结束之后,客户端以 Client Key Exchange 报 文作为回应。报文中包含通信加密中使用的一种被称为 Pre-master secret 的随机密码串。该报文已用步骤 3 中的公开密钥进行加密。
  6. 接着客户端继续发送 Change Cipher Spec 报文。该报文会提 示服务器,在此报文之后的通信会采用 Pre-master secret 密钥加密。
  7. 客户端发送 Finished 报文。该报文包含连接至今全部报文的 整体校验值。这次握手协商是否能够成功,要以服务器是否能够正确 解密该报文作为判定标准。
  8. 服务器同样发送 Change Cipher Spec 报文
  9. 服务器同样发送 Finished 报文
  10. 服务器和客户端的 Finished 报文交换完毕之后,SSL 连接 就算建立完成。当然,通信会受到 SSL 的保护。从此处开始进行应用 层协议的通信,即发送 HTTP 请求
  11. 应用层协议通信,即发送 HTTP 响应。
  12. 最后由客户端断开连接。断开连接时,发送 close_notify 报文。

大家可能看了上面的这些步骤一脸懵 其实总的来说,步骤 1-4 这一个 TTL 中,主要目的是服务器把配对的公钥发送给客户端; 步骤 5-9 这一个 TTL 中,目的是客户端使用刚刚获得的公钥,把之后对称加密用的密钥进行加密,发送给服务端; 之后就是双方使用对称加密进行数据通信,直至连接断开

以上是 TSL 的传统握手,当然随着时代发展,TSL 也进行了升级,现在主流的版本是 TLS/1.2, 之前的 TLS1.0、TLS1.1 都被认为是不安全的,在不久的将来会被完全淘汰。

安全性上,传统的 TSL 通信使用 RSA 算法作为非对称加密算法, TSL1.2 中使用了 ECDHE参考 1 参考 2作为加密算法,性能较 RSA 更好,使用 RSA 算法时服务器的私钥是固定的,一旦中间人拿到了服务器私钥,并且截获之前所有报文的时候,那么就能拿到 pre_random、server_random 和 client_random 并根据对应的随机数函数生成 secret,也就是拿到了 TLS 最终的会话密钥,每一个历史报文都能通过这样的方式进行破解。 但 ECDHE 在每次握手时都会生成临时的密钥对,即使私钥被破解,之前的历史消息并不会收到影响。这种一次破解并不影响历史信息的性质也叫前向安全性。 RSA 算法不具备前向安全性,而 ECDHE 具备,因此在 TLS1.3 中彻底取代了 RSA。

性能上,TSL1.3 通过 Session-ticket 进行会话复用减少重新生成密钥的时间,以及使用 PSK(Pre-Shared Key,在发送 Session-ticket 同时带上应用数据)做到了 0-RTT 连接

· 阅读需 5 分钟

记录一次代码回滚导致的加班

2020年5月9日下午五点。 滕某人本来今天高高兴兴,心想着又是可以下早班的一天,只要把这些小改动发上线就OK了,还不是轻轻松松。可是一顿操作之后把代码提审通过,发到预发环境一看,???怎么改的东西有的生效了有的没生效,会不会是代码提得不对,嗯,一定是这样,再提一遍。gitlab上一看,呐尼,no commit,可是本地代码和线上代码的确是不一样呀,怎么会no commit呢?一番冥思苦想后,脑海中浮现了两个小时前的场景。

时间回溯到2020年5月9日下午三点。 打开gitlab,source branch填上我的分支,target branch填上今天的hotfix分支(这里其实没有点上,默认master了),回车,指定代码审核人,OK,走你!!!五分钟后,回gitlab一看,嗯,审核通过了,可是。。。,怎么有种奇怪的赶脚,艹了,我怎么把代码直接提到master上去了,他(指审核人)竟然还给我通过了,这么盲目信任我的吗,可是我好菜的。。。我这次的改动依赖后端,可千万不能发线上,得赶紧把master代码回滚一下, -那个谁,刚刚提的merge request,提错分支了,你赶紧把master回滚一下。 -xxxxxx(祖安语),好了,我给你revert一下。 作为一个平时只会push操作的git菜鸟,面对revert这样的陌生词汇,一脸蒙蔽的点了一下头,又忙起了手上的活,之后又提了做了一些改动,提了几个commit。

OK,事情的经过回溯完毕,问过大佬后,原因明了了:master代码回滚是通过revert方式的,从而导致我之后提交的时候显示no commit,为什么呢。

revert操作,中文意思是反转,顾名思义,是将之前有问题的commit操作的逆操作,作为一个新的commit,提交了上去,比如之前我加了一行有问题的代码,通过revert,我相当于又进行了一次提交,提交的内容是删除了那行有问题的代码,从而达到恢复代码的目的。可是,在revert操作下, 有问题的那个commit和revert的commit都是保留在了commit记录中的,git分支都是向前走而非回退,导致了之后我想把代码提上去,是提不上去的,因为那个commit之前已经提过了(在回滚的时候那个commit是有问题的commit,可是之后走正常流程了,该commit就是正常的commit了,可是git无法判断有没有问题),因此显示的是no commit。

当时的解决方法是对那个revert的commit,我再revert一次,从而抵消了revert带来的影响。

从发现问题,到找原因,到解决问题,再到重新发预发,确认,再发线上,确认,时间也不知不觉地到了晚上八点,哎,都八点了,直接等到九点打车吧(公司九点后打车免费)--又被996了 --其实互联网人的996,就是能力不行导致的瞎折腾,还是得学习进步呀 -- END

· 阅读需 7 分钟

new

new 关键字作为 JS 中通过正常途径实例化一个类的唯一方法,经常出现在我们的视线中,那么其原理又是如何呢?它到底做了什么事情?

先看一段代码

// 定义类 类名字是 classA
function classA() {
this.name = 1;
}
classA.prototype.show = function() {
alert(this.name);
};
// 用new实例化
var b = new classA();
b.show();

这是一个常见的使用 new 创建实例的例子

new 其实干了如下几件事

  1. 创建了一个对象 a(a 是随便取的)
  2. 将该对象的proto属性设置为 classA 的 prototype
  3. 将 classA 中的 this 指向对象 a,也就是之后的this.name=1等同于a.name=1
  4. 执行 classA 函数
  5. 将对象 a 返回出去

接下来通过 JS 模拟一下 new 的操作

function classA() {
this.name = "start";
}
classA.prototype.sayName = function() {
console.log(this.name);
};

function NEW(className) {
let res = {}; // 新建一个对象
res.__proto__ = className.prototype; // 通过原型链继承classA的方法
className.call(res); // 通过call修改classA的this指向,并且调用classA这个函数,在这个过程中内部的name属性会被赋值
return res;
}
let a = NEW(classA);
a.sayName();
a.name = "a";
a.sayName();

以上就是new的作用,如果在实例化的时候忘记使用new了,其实就是简单地调用了一下那个函数,函数内部的 this 其实指向的是外部,

function classA() {
this.name = "start";
}
classA.prototype.sayName = function() {
console.log(this.name);
};
let a = classA();
try {
a.sayName();
} catch (e) {
console.log(e);
}
console.log(name);

上述代码运行后会发生报错,打印的namestart,可见this默认被绑到了外部(浏览器端为 window,nodejs 端为 global)

注意以上是在函数体内,如果在全局环境中浏览器的依然为window,但是 nodejs 端为{}

由以上的所讲的new的原理,进一步,我们可以解释如下现象

function classA() {
this.name = "start";
let obj = { a: 1 };
return obj;
}
classA.prototype.sayName = function() {
console.log(this.name);
};
let a = classA();
console.log(a.a); // 1
a.sayName(); // 报错

因为在return最后的res之前,使用call调用了classA这个函数,结果直接返回出来了,因此所获得的并不是我们想要的结果

构造函数中绝对不能 return 数据

浅拷贝VS深拷贝

function isReferenceType (o) {
return o instanceof Object
}

function deepClone (obj) {
if (!isReferenceType(obj)) {
return obj
}

if (obj instanceof RegExp) return new RegExp(obj)
if (obj instanceof Function) return obj.bind({})

let newObj = new obj.constructor()
Reflect.ownKeys(obj).forEach(key => {
newObj[key] = deepClone(obj[key])
})

return newObj
}

let arr = [1, 2, 3];
arr.push(arr);
var obj = {
a: 1,
arr,
func: () => {
console.log("aaa");
},
date: new Date(),
reg: new RegExp(/test/),
none: undefined,
};
obj.obj = obj;
console.log(deepClone(obj));
// return JSON.parse(JSON.stringify(obj))
// }
// console.log(jsonDeepClone({
// a: 1,
// func: ()=>{
// console.log('aaa');
// },
// date: new Date(),
// reg: new RegExp(/test/),
// none: undefined
// }));

callapply

Function.prototype.call = function() {
let [context, ...args] = [...arguments];
if (!context) {
context = typeof window === "undefined" ? global : window;
}
// 注意,这里必须要context.func 不能是一个简单的变量,因为需要给函数一个运行的上下文,即context
context.func = this;
let res = context.func(...args);
delete context.func;
// context中实际并无func这个key,要删除恢复原样
return res;
};
Function.prototype.apply = function() {
let [context, args] = arguments;
if (!context) {
context = typeof window === "undefined" ? global : window;
}
context.func = this;
let res;
if (!args) res = context.func();
else res = context.func(...args);
delete context.func;
return res;
};

function getName(age, address) {
console.log(this.name);
console.log({ age, address });
}
let obj = {
name: "teefing",
};
getName.apply(obj, [22, "hangzhou"]);
getName.call(obj, 22, "hangzhou");

函数柯里化

const curry = (fn, ...args) =>
args.length < fn.length
? (...arguments) => curry(fn, ...args, ...arguments)
: fn(...args);
function sumFn(a, b, c) {
return a + b + c;
}
var sum = curry(sumFn);
console.log(sum(1)(2)(3)); //6

let add1 = sum(1);
let add2 = add1(2);
let res = add2(3);
console.log(res); //6

注: Function.length返回一个函数的参数个数,但是剩余参数不会算上,还有第一个默认参数及其之后的参数不会被算上

函数柯里化主要作用

  1. 参数复用
  2. 提前返回 – 返回接受余下的参数且返回结果的新函数
  3. 延迟执行 – 返回新函数,等待执行

flat

let flattenDeep1 = function(arr, deepLength) {
return arr.flat(deepLength, Math.pow(2, 53) - 1);
};

let flattenDeep2 = (arr) =>
arr.reduce(
(acc, val) =>
Array.isArray(val) ? acc.concat(flattenDeep2(val)) : acc.concat(val),
[]
);

let flattenDeep3 = (arr) => {
let stack = [...arr];
let res = [];
while (stack.length) {
let next = stack.shift();
if (Array.isArray(next)) {
res = res.concat(flattenDeep4(next));
} else {
res.push(next);
}
}
return res;
};

let flattenDeep4 = (arr) => {
let stack = [...arr];
let res = [];
while (stack.length) {
const next = stack.shift();
if (Array.isArray(next)) {
stack.unshift(...next);
} else {
res.push(next);
}
}
return res;
};
[flattenDeep1, flattenDeep2, flattenDeep3, flattenDeep4].forEach(
(flattenDeep, index) => {
try {
console.log(flattenDeep([1, 2, [3, 4, 5, [6, 7, 8]]]));
} catch (e) {
console.log(`method-${index} not supported`);
}
}
);

uniq函数

let uniq1 = (arr) => [...new Set(arr)];

let uniq2 = (arr) => {
let res = [];
for (let i = 0; i < arr.length; i++) {
if (res.indexOf(arr[i]) === -1) res.push(arr[i]);
}
return res;
};

let uniq3 = (arr) => {
let res = [];
for (let i = 0; i < arr.length; i++) {
if (!res.includes(arr[i])) {
res.push(arr[i]);
}
}
return res;
};

let uniq4 = (arr) =>
arr.reduce((acc, val) => (acc.includes(val) ? acc : [...acc, val]), []);

let uniq5 = (arr) => {
let m = new Map();
let res = [];
for (let i = 0; i < arr.length; i++) {
if (m.has(arr[i])) m.set(arr[i], true);
// true和false可以判断是否只存在一次
else {
m.set(arr[i], false);
res.push(arr[i]);
}
}
return res;
};
[uniq1, uniq2, uniq3, uniq4, uniq5].forEach((uniq, index) => {
console.log(uniq([1, 1, 2, 2, 3, 3, 4]));
});

· 阅读需 5 分钟

JS整洁之道

代码整洁之道

代码整洁之道思维导图

主要思想

测试

有意义的命名

可重用性

体现设计理念

实体少

依赖关系少

代码逻辑清晰

分层次

性能调优

命名

要有意义

  • 除非是单纯用来遍历的变量或者数,否则单独设置一个有意义的变量或者常量
  • 读得出来
  • 可搜索

意义要明确

类class替换复杂的数据结构

命名差异要明显

命名的区分也要有意义

  • 使用数字系列来区分
  • 使用近义词或者多余词来区分

避免使用编码

  • 成员前缀
  • 接口和实现

类名

  • 类名、对象名使用名词或名词短语

  • 要有明确意义

    • 类名Data

方法名使用动词或动词短语

  • 属性访问器get前缀
  • 属性修改器set前缀
  • 断言is前缀

使用语境或者类来定义模块变量

使用对领域人士一看就懂的专业名称

函数

短小

只做好一件事

  • 判断是否只做了一件事---没办法继续拆分出函数来
  • 多个步骤在同一个抽象层上

参数最多3个,超过3个使用参数对象

函数名要名副其实

  • 不要做函数名以外的事情

错误异常

  • 使用异常代替返回错误码
  • 抽离try/catch代码块

消灭重复

注释

无法用代码来表达意图时才使用注释

值得写的注释

  • 法律信息
  • 无法从代码中可以获得的信息
  • 警示
  • TODO
  • 解释某个巧妙的地方

要删除的注释

  • 能够从代码中获取信息的注释
  • 被注释的代码

格式

垂直方向

  • 紧密相关的代码靠近

  • 不紧密相关的代码用空格隔开

  • 同一作用域的变量统统放在顶部

  • 代码太长就要拆分

    • 不仅以函数为单位进行拆分,甚至以类、文件为单位

水平方向

  • 紧密相关事物靠近
  • 不紧密相关的代码用空格隔开
  • 使用空格强调前面的运算符

缩进

while、for语句体为空

错误处理

使用异常而非返回码

先写try...catch

当异常种类较多时打包成一个单独的异常类

使用特例模式

不返回、传递空值(null、undefined)

边界

调用外部接口时有所封装

  • 语言本身的接口
  • 外部框架、插件

为未来可能的功能接口留出位置

避免自己系统的代码过度使用第三方代码中的特定信息

单元测试

在编写生产代码前编写单元测试

可读性

每个测试一个断言

FIRST原则

  • Fast快速

    • 测试速度要快
  • Independent独立

    • 每个测试独立运行
  • Repeatable可重复

    • 可以在任何环境中重复通过
  • Self-Validating自足验证

    • 要有布尔值输出
  • Timely及时

    • 应该及时编写

测试先行

短小

  • 单一权责

  • 类名称应该描述其权责

    • 如果无法为某个类命名精确的名称,那么这个类就太长了

保持内聚

依赖于抽象(抽象类)而非具体细节(实现类)

XMind: ZEN - Trial Version

· 阅读需 36 分钟

译注:这是 3.0 最重要的 RFC,因此特意翻译成中文。

概要

将 2.x 中与组件逻辑相关的选项以 API 函数的形式重新设计。

基本例子

import { value, computed, watch, onMounted } from 'vue' const App = { template:
`
<div>
<span>count is {{ count }}</span>
<span>plusOne is {{ plusOne }}</span>
<button @click="increment">count++</button>
</div>
`, setup() { // reactive state const count = value(0) // computed state const
plusOne = computed(() => count.value + 1) // method const increment = () => {
count.value++ } // watch watch(() => count.value * 2, val => {
console.log(`count * 2 is ${val}`) }) // lifecycle onMounted(() => {
console.log(`mounted`) }) // expose bindings on render context return { count,
plusOne, increment } } }

设计动机

逻辑组合与复用

组件 API 设计所面对的核心问题之一就是如何组织逻辑,以及如何在多个组件之间抽取和复用逻辑。基于 Vue 2.x 目前的 API 我们有一些常见的逻辑复用模式,但都或多或少存在一些问题。这些模式包括:

  • Mixins
  • 高阶组件 (Higher-order Components, aka HOCs)
  • Renderless Components (基于 scoped slots / 作用域插槽封装逻辑的组件)

网络上关于这些模式的介绍很多,这里就不再赘述细节。总体来说,以上这些模式存在以下问题:

  • 模版中的数据来源不清晰。举例来说,当一个组件中使用了多个 mixin 的时候,光看模版会很难分清一个属性到底是来自哪一个 mixin。HOC 也有类似的问题。
  • 命名空间冲突。由不同开发者开发的 mixin 无法保证不会正好用到一样的属性或是方法名。HOC 在注入的 props 中也存在类似问题。
  • 性能。HOC 和 Renderless Components 都需要额外的组件实例嵌套来封装逻辑,导致无谓的性能开销。

Function-based API 受 React Hooks 的启发,提供了一个全新的逻辑复用方案,且不存在上述问题。使用基于函数的 API,我们可以将相关联的代码抽取到一个 "composition function"(组合函数)中 —— 该函数封装了相关联的逻辑,并将需要暴露给组件的状态以响应式的数据源的方式返回出来。这里是一个用组合函数来封装鼠标位置侦听逻辑的例子:

function useMouse() { const x = value(0) const y = value(0) const update = e =>
{ x.value = e.pageX y.value = e.pageY } onMounted(() => {
window.addEventListener('mousemove', update) }) onUnmounted(() => {
window.removeEventListener('mousemove', update) }) return { x, y } } //
在组件中使用该函数 const Component = { setup() { const { x, y } = useMouse() //
与其它函数配合使用 const { z } = useOtherLogic() return { x, y, z } }, template:
`
<div>{{ x }} {{ y }} {{ z }}</div>
` }

从以上例子中可以看到:

  • 暴露给模版的属性来源清晰(从函数返回);
  • 返回值可以被任意重命名,所以不存在命名空间冲突;
  • 没有创建额外的组件实例所带来的性能损耗。

文末附录中有与 React Hooks 的一些细节对比。

类型推导

3.0 的一个主要设计目标是增强对 TypeScript 的支持。原本我们期望通过 Class API 来达成这个目标,但是经过讨论和原型开发,我们认为 Class 并不是解决这个问题的正确路线,基于 Class 的 API 依然存在类型问题。

基于函数的 API 天然对类型推导很友好,因为 TS 对函数的参数、返回值和泛型的支持已经非常完备。更值得一提的是基于函数的 API 在使用 TS 或是原生 JS 时写出来的代码几乎是完全一样的。下文会提供新 API 类型推导的更多细节,此外文末附录中有关于 Class API 类型问题的更多细节。

打包尺寸

基于函数的 API 每一个函数都可以作为 named ES export 被单独引入,这使得它们对 tree-shaking 非常友好。没有被使用的 API 的相关代码可以在最终打包时被移除。同时,基于函数 API 所写的代码也有更好的压缩效率,因为所有的函数名和 setup 函数体内部的变量名都可以被压缩,但对象和 class 的属性 / 方法名却不可以。

设计细节

setup() 函数

我们将会引入一个新的组件选项,setup()。顾名思义,这个函数将会是我们 setup 我们组件逻辑的地方,它会在一个组件实例被创建时,初始化了 props 之后调用。setup() 会接收到初始的 props 作为参数:

const MyComponent = { props: { name: String }, setup(props) {
console.log(props.name) } }

需要留意的是这里传进来的 props 对象是响应式的 —— 它可以被当作数据源去观测,当后续 props 发生变动时它也会被框架内部同步更新。但对于用户代码来说,它是不可修改的(会导致警告)。

setup 内部可以使用 this,但你大部分时候不会需要它。

组件状态

类似 data()setup() 可以返回一个对象 —— 这个对象上的属性将会被暴露给模版的渲染上下文:

const MyComponent = { props: { name: String }, setup(props) { return { msg:
`hello ${props.name}!` } }, template: `
<div>{{ msg }}</div>
` }

上面这个例子跟 data() 一模一样:msg 可以在模版中被直接使用,它甚至可以被模版中的内联函数修改。但如果我们想要创建一个可以在 setup() 内部被管理的值,可以使用 value 函数:

import { value } from 'vue' const MyComponent = { setup(props) { const msg =
value('hello') const appendName = () => { msg.value = `hello ${props.name}` }
return { msg, appendName } }, template: `
<div @click="appendName">{{ msg }}</div>
` }

value() 返回的是一个 value wrapper (包装对象)。一个包装对象只有一个属性:.value ,该属性指向内部被包装的值。在上面的例子中,msg 包装的是一个字符串。包装对象的值可以被直接修改:

// 读取 console.log(msg.value) // 'hello' // 修改 msg.value = 'bye'

为什么需要包装对象?

我们知道在 JavaScript 中,原始值类型如 string 和 number 是只有值,没有引用的。如果在一个函数中返回一个字符串变量,接收到这个字符串的代码只会获得一个值,是无法追踪原始变量后续的变化的。

因此,包装对象的意义就在于提供一个让我们能够在函数之间以引用的方式传递任意类型值的容器。这有点像 React Hooks 中的 useRef —— 但不同的是 Vue 的包装对象同时还是响应式的数据源。有了这样的容器,我们就可以在封装了逻辑的组合函数中将状态以引用的方式传回给组件。组件负责展示(追踪依赖),组合函数负责管理状态(触发更新):

setup() { const valueA = useLogicA() // valueA 可能被 useLogicA()
内部的代码修改从而触发更新 const valueB = useLogicB() return { valueA, valueB }
}

包装对象也可以包装非原始值类型的数据,被包装的对象中嵌套的属性都会被响应式地追踪。用包装对象去包装对象或是数组并不是没有意义的:它让我们可以对整个对象的值进行替换 —— 比如用一个 filter 过的数组去替代原数组:

const numbers = value([1, 2, 3]) // 替代原数组,但引用不变 numbers.value =
numbers.value.filter(n => n > 1)

如果你依然想创建一个没有包装的响应式对象,可以使用 stateAPI(和 2.x 的 Vue.observable()等同):

import { state } from 'vue' const object = state({ count: 0 }) object.count++

Value Unwrapping(包装对象的自动展开)

在上面的一个例子中你可能注意到了,虽然 setup()返回的 msg是一个包装对象,但在模版中我们直接用了 {{ msg }}这样的绑定,没有用 .value。这是因为当包装对象被暴露给模版渲染上下文,或是被嵌套在另一个响应式对象中的时候,它会被自动展开 (unwrap) 为内部的值。

比如一个包装对象的绑定可以直接被模版中的内联函数修改:

const MyComponent = { setup() { return { count: value(0) } }, template: `<button
@click="count++"
>
{{ count }}</button
>` }

当一个包装对象被作为另一个响应式对象的属性引用的时候也会被自动展开:

const count = value(0) const obj = state({ count }) console.log(obj.count) // 0
obj.count++ console.log(obj.count) // 1 console.log(count.value) // 1
count.value++ console.log(obj.count) // 2 console.log(count.value) // 2

以上这些关于包装对象的细节可能会让你觉得有些复杂,但实际使用中你只需要记住一个基本的规则:只有当你直接以变量的形式引用一个包装对象的时候才会需要用 .value 去取它内部的值 —— 在模版中你甚至不需要知道它们的存在。

配合手写 Render 函数使用

如果你的组件不使用模版,你也可以选择在 setup() 中直接返回一个渲染函数:

import { value, createElement as h } from 'vue' const MyComponent = {
setup(initialProps) { const count = value(0) const increment = () => {
count.value++ } return (props, slots, attrs, vnode) => ( h('button', { onClick:
increment }, count.value) ) } }

返回的函数应当遵循 RFC#28 中提出的函数签名。你可能注意到了 setup() 和其返回的渲染函数的第一个参数都是 props —— 它们的行为是一样的,但是渲染函数接收到的 props 在生产模式下将会是一个普通对象,因此它的性能会更好些。

和 2.x 一样的 render 选项也可以使用,但如果用了 setup(),就应该尽量使用内联返回的渲染函数,因为这样可以避免先返回一堆绑定然后再在另一个函数里解构出来,同时类型推导也会更简单直接一些。

Computed Value (计算值)

除了直接包装一个可变的值,我们也可以包装通过计算产生的值:

import { value, computed } from 'vue' const count = value(0) const countPlusOne
= computed(() => count.value + 1) console.log(countPlusOne.value) // 1
count.value++ console.log(countPlusOne.value) // 2

计算值的行为跟计算属性 (computed property) 一样:只有当依赖变化的时候它才会被重新计算。

computed() 返回的是一个只读的包装对象,它可以和普通的包装对象一样在 setup() 中被返回 ,也一样会在渲染上下文中被自动展开。默认情况下,如果用户试图去修改一个只读包装对象,会触发警告。

双向计算值可以通过传给 computed 第二个参数作为 setter 来创建:

const count = value(0) const writableComputed = computed( // read () =>
count.value + 1, // write val => { count.value = val - 1 } )

Watchers

watch() API 提供了基于观察状态的变化来执行副作用的能力。

watch() 接收的第一个参数被称作 “数据源”,它可以是:

  • 一个返回任意值的函数
  • 一个包装对象
  • 一个包含上述两种数据源的数组

第二个参数是回调函数。回调函数只有当数据源发生变动时才会被触发:

watch( // getter () => count.value + 1, // callback (value, oldValue) => {
console.log('count + 1 is: ', value) } ) // -> count + 1 is: 1 count.value++ //
-> count + 1 is: 2

和 2.x 的 $watch 有所不同的是,watch() 的回调会在创建时就执行一次。这有点类似 2.x watcher 的 immediate: true 选项,但有一个重要的不同:默认情况下 watch() 的回调总是会在当前的 renderer flush 之后才被调用 —— 换句话说,watch()的回调在触发时,DOM 总是会在一个已经被更新过的状态下。 这个行为是可以通过选项来定制的。

在 2.x 的代码中,我们经常会遇到同一份逻辑需要在 mounted 和一个 watcher 的回调中执行(比如根据当前的 id 抓取数据),3.0 的 watch() 默认行为可以直接表达这样的需求。

观察 props

上面提到了 setup() 接收到的 props 对象是一个可观测的响应式对象:

const MyComponent = { props: { id: Number }, setup(props) { const data =
value(null) watch(() => props.id, async (id) => { data.value = await
fetchData(id) }) return { data } } }

观察包装对象

watch()可以直接观察一个包装对象:

// double 是一个计算包装对象 const double = computed(() => count.value * 2)
watch(double, value => { console.log('double the count is: ', value) }) // ->
double the count is: 0 count.value++ // -> double the count is: 2

观察多个数据源

watch() 也可以观察一个包含多个数据源的数组 - 这种情况下,任意一个数据源的变化都会触发回调,同时回调会接收到包含对应值的数组作为参数:

watch( [valueA, () => valueB.value], ([a, b], [prevA, prevB]) => {
console.log(`a is: ${a}`) console.log(`b is: ${b}`) } )

停止观察

watch() 返回一个停止观察的函数:

const stop = watch(...) // stop watching stop()

如果 watch() 是在一个组件的 setup() 或是生命周期函数中被调用的,那么该 watcher 会在当前组件被销毁时也一同被自动停止:

export default { setup() { // 组件销毁时也会被自动停止 watch(/* ... */) } }

清理副作用

有时候当观察的数据源变化后,我们可能需要对之前所执行的副作用进行清理。举例来说,一个异步操作在完成之前数据就产生了变化,我们可能要撤销还在等待的前一个操作。为了处理这种情况,watcher 的回调会接收到的第三个参数是一个用来注册清理操作的函数。调用这个函数可以注册一个清理函数。清理函数会在下属情况下被调用:

  • 在回调被下一次调用前
  • 在 watcher 被停止前
watch(idValue, (id, oldId, onCleanup) => { const token =
performAsyncOperation(id) onCleanup(() => { // id 发生了变化,或是 watcher
即将被停止. // 取消还未完成的异步操作。 token.cancel() }) })

之所以要用传入的注册函数来注册清理函数,而不是像 React 的 useEffect 那样直接返回一个清理函数,是因为 watcher 回调的返回值在异步场景下有特殊作用。我们经常需要在 watcher 的回调中用 async function 来执行异步操作:

const data = value(null) watch(getId, async (id) => { data.value = await
fetchData(id) })

我们知道 async function 隐性地返回一个 Promise - 这样的情况下,我们是无法返回一个需要被立刻注册的清理函数的。除此之外,回调返回的 Promise 还会被 Vue 用于内部的异步错误处理。

Watcher 回调的调用时机

默认情况下,所有的 watcher 回调都会在当前的 renderer flush 之后被调用。这确保了在回调中 DOM 永远都已经被更新完毕。如果你想要让回调在 DOM 更新之前或是被同步触发,可以使用 flush 选项:

watch( () => count.value + 1, () => console.log(`count changed`), { flush:
'post', // default, fire after renderer flush flush: 'pre', // fire right before
renderer flush flush: 'sync' // fire synchronously } )

全部的 watch 选项(TS 类型声明)

interface WatchOptions { lazy?: boolean deep?: boolean flush?: 'pre' | 'post' |
'sync' onTrack?: (e: DebuggerEvent) => void onTrigger?: (e: DebuggerEvent) =>
void } interface DebuggerEvent { effect: ReactiveEffect target: any key: string
| symbol | undefined type: 'set' | 'add' | 'delete' | 'clear' | 'get' | 'has' |
'iterate' }
  • lazy与 2.x 的 immediate 正好相反
  • deep与 2.x 行为一致
  • onTrackonTrigger 是两个用于 debug 的钩子,分别在 watcher 追踪到依赖和依赖发生变化的时候被调用,获得的参数是一个包含了依赖细节的 debugger event。

生命周期函数

所有现有的生命周期钩子都会有对应的 onXXX 函数(只能在 setup() 中使用):

import { onMounted, onUpdated, onUnmounted } from 'vue' const MyComponent = {
setup() { onMounted(() => { console.log('mounted!') }) onUpdated(() => {
console.log('updated!') }) // destroyed 调整为 unmounted onUnmounted(() => {
console.log('unmounted!') }) } }

依赖注入

import { provide, inject } from 'vue' const CountSymbol = Symbol() const
Ancestor = { setup() { // providing a value can make it reactive const count =
value(0) provide({ [CountSymbol]: count }) } } const Descendent = { setup() {
const count = inject(CountSymbol) return { count } } }

如果注入的是一个包装对象,则该注入绑定会是响应式的(也就是说,如果 Ancestor 修改了 count,会触发 Descendent 的更新)。

类型推导

为了能够在 TypeScript 中提供正确的类型推导,我们需要通过一个函数来定义组件:

import { createComponent } from 'vue' const MyComponent = createComponent({ //
props declarations are used to infer prop types props: { msg: String },
setup(props) { props.msg // string | undefined // bindings returned from setup()
can be used for type inference // in templates const count = value(0) return {
count } } })

createComponent 从概念上来说和 2.x 的 Vue.extend 是一样的,但在 3.0 中它其实是单纯为了类型推导而存在的,内部实现是个 noop(直接返回参数本身)。它的返回类型可以用于 TSX 和 Vetur 的模版自动补全。如果你使用单文件组件,则 Vetur 可以自动隐式地帮你添加这个调用。

如果你使用手写 render 函数或是 TSX,那么你可以在 setup() 当中直接返回一个渲染函数(注意这里不需要任何手动的类型声明):

import { createComponent, createElement as h } from 'vue' const MyComponent =
createComponent({ props: { msg: String }, setup(props) { const count = value(0)
return () => h('div', [ h('p', `msg is ${props.msg}`), h('p', `count is
${count.value}`) ]) } })

纯 TypeScript 的 Props 类型声明

3.0 的 props 选项不是必须的,如果你不需要运行时的 props 类型检查,你也可以选择完全在 TypeScript 的类型层面声明 props 的类型:

import { createComponent, createElement as h } from 'vue' interface Props { msg:
string } const MyComponent = createComponent({ setup(props: Props) { return ()
=> h('div', props.msg) } })

如果不需要除了 setup 之外的选项,甚至可以直接传一个函数给 createComponent

const MyComponent = createComponent((props: { msg: string }) => { return () =>
h('div', props.msg) })

这里返回的 MyComponent 也可以在 TSX 中提供正确的 props 补全和推导。

Required Props

Props 默认都是可选的,也就是说它们的类型都可能是 undefined。非可选的 props 需要声明 required: true :

import { createComponent } from 'vue' createComponent({ props: { foo: { type:
String, required: true }, bar: { type: String } } as const, setup(props) {
props.foo // string props.bar // string | undefined } })

这里需要注意我们在 props 选项后面加了一个 as const —— 这是 TS 3.4 提供的一个功能,可以避免 required: true 这样的字面量在推导时被拓宽为 boolean 类型,从而让 Vue 内部可以通过 extends true 来确定 props 是否可选。

注:我们可能应该把 props 改为默认 required,只有当声明 optional: true 时才是可选。

复杂 Props 类型

Vue 提供的 PropType 类型可以用来声明任意复杂度的 props 类型,但需要用 as any 进行一次强制类型转换:

import { createComponent, PropType } from 'vue' createComponent({ props: {
options: (null as any) as PropType<{ msg: string }> }, setup(props) {
props.options // { msg: string } | undefined } })

依赖注入类型

依赖注入的 inject 方法是唯一必须手动声明类型的 API:

import { createComponent, inject, Value } from 'vue' createComponent({ setup() {
const count: Value<number> = inject(CountSymbol) return { count } } }) </number>

这里的 Value 类型即是包装对象的类型 ,通过泛型参数来声明其内部包装的值的类型。

缺点 / 潜在问题

新的 API 使得动态地检视 / 修改一个组件的选项变得更困难(原来是一个对象,现在是一段无法被检视的函数体)。

这可能是一件好事,因为通常在用户代码中动态地检视 / 修改组件是一类比较危险的操作,对于运行时也增加了许多潜在的边缘情况(特别是组件继承和使用 mixin 的情况下)。新 API 的灵活性应该在绝大部分情况下都可以用更显式的代码达成同样的结果。

缺乏经验的用户可能会写出 “面条代码”,因为新 API 不像旧 API 那样强制将组件代码基于选项切分开来。

我们在 Class API RFC 和内部讨论中听到过好几次这样的声音,但我认为这是一种没有必要的担忧。虽然理论上新的 API 确实制约更少,但我认为 “面条代码” 的情况不太可能发生,这里详细解释一下。

基于函数的新 API 和基于选项的旧 API 之间的最大区别,就是新 API 让抽取逻辑变得非常简单 —— 就跟在普通的代码中抽取函数一样。也就是说,我们不必只在需要复用逻辑的时候才抽取函数,也可以单纯为了更好地组织代码去抽取函数。

基于选项的代码只是看上去更整洁。一个复杂的组件往往需要同时处理多个不同的逻辑任务,每个逻辑任务所涉及的代码在选项 API 下是被分散在多个选项之中的。举例来说,从服务端抓取一份数据,可能需要用到 props, data(), mountedwatch。极端情况下,如果我们把一个应用中所有的逻辑任务都放在一个组件里,这个组件必然会变得庞大而难以维护,因为每个逻辑任务的代码都被选项切成了多个碎片分散在各处。 对比之下,基于函数的 API 让我们可以把每个逻辑任务的代码都整理到一个对应的函数中。当我们发现一个组件变得过大时,我们会将它切分成多个更小的组件;同样地,如果一个组件的 setup() 函数变得很复杂,我们可以将它切分成多个更小的函数。而如果是基于选项,则无法做到这样的切分,因为用 mixin 只会让事情变得更糟糕。

从这个角度看,基于选项 vs. 基于函数就好像基于 HTML/CSS/JS 组织代码 vs. 基于单文件组件来组织代码。

升级策略

新的 API 和 2.x 的 API 理论上完全兼容(只是多了一个 setup()选项) 。但是,新 API 的引入实际上会让相当一部分的旧选项长远来说变得没有必要。如果能够去掉对这些旧选项的支持,可以获得相当的代码尺寸和性能提升。

因此,3.0 我们计划提供两个不同的版本:

  • 兼容版本:同时支持新 API 和 2.x 的所有选项;
  • 标准版本:只支持新 API 和部分 2.x 选项。

在兼容版本中,setup() 可以和旧选项(比如 data()) 一起使用,但顺序上 setup() 会比旧选项优先调用。也就是说,在 setup() 中无法使用由旧选项声明的属性,但在旧选项中可以使用由 setup() 声明的属性。

2.x 的用户可以从兼容版本开始逐步地减少对旧选项的使用,直到最终切换到标准版本。

保留的选项

以下选项行为和 2.x 保持一致,并在兼容和标准版本中都会支持。标有 * 的选项可能会有进一步的调整。

  • name
  • props
  • template
  • render
  • components
  • directives
  • filters*
  • delimiters*
  • comments *

由于本提案而不再必须的选项

以下选项将会在标准版本中被移除,只在兼容版本中支持。

  • data(由 setup() + value) + state) 取代)
  • computed(由 computed 取代)
  • methods( 由 setup() 中声明的函数取代)
  • watch (由 watch() 取代)
  • provide/inject(由 provide()inject() 取代)
  • mixins (由组合函数取代)
  • extends (由组合函数取代)
  • 所有的生命周期选项 (由 onXXX 函数取代)

被其它 RFC 提案废弃的选项

以下选项将会在标准版本中被移除,只在兼容版本中支持。

  • el(应用将不再由 new Vue() 来创建,而是通过新的 createApp 来创建,详见 RFC#29
  • propsData(给 root component 的 props 通过新的 createApp API 创建的应用实例来提供。详见 RFC#29)
  • functional(3.0 函数式组件直接用函数来声明 ,详见 RFC#27)
  • model(v-model 指令参数使得该选项不再必要,详见 RFC#31)
  • inhertiAttrs (非 props 属性的继承行为改动使得该选项不再必要,详见 RFC#26)

附录

与 React Hooks 的对比

这里提出的 API 和 React Hooks 有一定的相似性,具有同等的基于函数抽取和复用逻辑的能力,但也有很本质的区别。React Hooks 在每次组件渲染时都会调用,通过隐式地将状态挂载在当前的内部组件节点上,在下一次渲染时根据调用顺序取出。而 Vue 的 setup() 每个组件实例只会在初始化时调用一次 ,状态通过引用储存在 setup() 的闭包内。这意味着基于 Vue 的函数 API 的代码:

  • 整体上更符合 JavaScript 的直觉;
  • 不受调用顺序的限制,可以有条件地被调用;
  • 不会在后续更新时不断产生大量的内联函数而影响引擎优化或是导致 GC 压力;
  • 不需要总是使用 useCallback 来缓存传给子组件的回调以防止过度更新;
  • 不需要担心传了错误的依赖数组给 useEffect/useMemo/useCallback 从而导致回调中使用了过期的值 —— Vue 的依赖追踪是全自动的。

注:React Hooks 的开创性毋庸置疑,也是本提案的灵感来源。Hooks 代码和 JSX 并置使得对值的使用更简洁也是其优点,但其设计确实存在上述问题,而 Vue 的响应式系统恰巧能够让我们绕过这些问题。

Class API 的类型问题

Class API 提案的主要目的是寻找一个能够提供更好的 TypeScript 支持的组件声明方式。但是由于 Vue 需要将来自多个选项的属性混合到同一个渲染上下文上,这使得即使用了 Class,要得到良好的类型推导也不是很容易。

以 props 的类型推导为例。要将 props 的类型 merge 到 class 的 this 上,我们有两个选择:用 class 的泛型参数,或是用 decorator。

这是用泛型参数的例子:

interface Props { message: string } class App extends Component<Props>
{ static props = { message: String } }
</Props>

由于泛型参数是纯类型层面的,所以我们还需要额外地进行一次运行时的 props 选项声明来获得正确的行为。这就导致需要进行双重声明。

使用 decorator 的例子如下:

class App extends Component<Props> { @prop message: string } </Props>

Decorators 存在如下问题:

  • ES 的 decorator 提案仍然在 stage-2 且极其不稳定。过去一年内已经经历了两次彻底大改,且和 TS 现有的实现已经完全脱节。现在引入一个基于 TS decorator 实现的 API 风险太大。
  • Decorator 只能声明 class this 上的属性,却无法将某一类 decorator 声明的属性归并到一个对象上(比如 $props),这就导致 this.$props 无法被推导,且影响 TSX 的使用。
  • 用户很可能会觉得可以用 @prop message: string = 'foo'这样的写法去声明默认值,但事实上技术层面无法做到符合语义的实现。

最后,class 还有一个问题,那就是目前 class method 不支持参数的 contextual typing,也就是说我们无法基于 class 本身的 fields 来推导某个 method 的参数类型,需要用户自己去声明。