【译】JavaScript的工作原理:事件循环及异步编程的出现和 5 种更好的 async/await 编程方式

此篇是JavaScript的工作原理的第四篇,其它三篇可以看这里:

这次我们将通过回顾在单线程环境中编程的缺点以及如何克服它们来构建令人惊叹的JavaScript UI来扩展我们的第一篇文章。按照传统,在文章的最后,我们将分享有关如何使用async / await编写更清晰代码的5个技巧。

为什么单线程有局限性?

第一篇文章中,我们提到过一个问题:当调用栈中含有需要长时间运行的函数调用的时候会发生什么。
想象一下,例如,当浏览器中运行着一个复杂的图片转换算法。
在这个时候,堆栈中正好有函数在执行,浏览器此时不能做任何事情。此时,他被阻塞了。这意味着它不能渲染,不能运行其他代码,他被卡住了,没有任何响应。这就带来了一个问题,你的程序不再是高效的了。
你的程序没有相应了。
在某些情况下,这没有什么大不了的,但是这可能会造成更加严重的问题。一旦浏览器在调用栈中同时运行太多的任务的时候,浏览器会很长时间停止响应。在那个时候,大多数浏览器会抛出一个错误,询问是否终止网页。

这很丑陋且它完全摧毁了程序的用户体验。

Javascript程序的构建模块

你可能会在单一的 .js 文件中书写 JavaScript 程序,但是程序是由多个代码块组成的,当前,只有一个代码块在运行,其它代码块将在随后运行。最常见的块状单元是函数。
许多 JavaScript 新的开发者可能需要理解的问题是之后运行表示的是并不是必须立即在现在之后就执行。换句话说即,根据定义,现在不能够运行完毕的任务将会异步完成,这样你就不会不经意间遇到以上提及的 UI 阻塞。
看下面的代码:

// ajax 为一个库提供的任意 ajax 函数
var response = ajax('https://example.com/api');
console.log(response);
// `response` 将不会有数据返回
复制代码

可能你已经知道标准的 ajax 请求不会完全同步执行完毕,意即在代码运行阶段,ajax(..) 函数不会返回任何值给 response 变量

获得异步函数返回值的一个简单方法是使用回调函数。

ajax('https://example.com/api'function(response{
    console.log(response); // `response` 现在有值
});
复制代码

只是要注意一点:即使可以也永远不要发起同步 ajax 请求。如果发起同步 ajax 请求,JavaScript 程序的 UI 将会被阻塞-用户不能够点击,输入数据,跳转或者滚动。任何用户交互都会被阻塞。这是非常糟糕。

以下示例代码,但请别这样做,这会毁掉网页:

// 假设你使用 jQuery
jQuery.ajax({
    url'https://api.example.com/endpoint',
    successfunction(response{
        // 成功回调.
    },
    asyncfalse // 同步
});
复制代码

我们以 Ajax 请求为例。你可以异步执行任意代码。

你可以使用 setTimeout(callback, milliseconds) 函数来异步执行代码。setTimeout 函数会在之后的某个时刻触发事件(定时器)。如下代码:

function first({
    console.log('first');
}
function second({
    console.log('second');
}
function third({
    console.log('third');
}
first();
setTimeout(second, 1000); // 1 秒后调用 second 函数
third();
复制代码

控制台输出如下:

first
third
second
复制代码

剖析事件循环

我们这儿从一个奇怪的声明开始——尽管允许异步 JavaScript 代码(就像上例讨论的setTimeout),但在ES6之前,JavaScript本身实际上从来没有任何内置异步的概念,JavaScript引擎在任何给定时刻只执行一个块

对于更多的JavaScript引擎怎么工作的,可以看系列文章的第一篇

那么,是谁告诉JS引擎执行程序的代码块呢?实际上,JS引擎并不是单独运行的——它是在一个宿主环境中运行的,对于大多数开发人员来说,宿主环境就是典型的web浏览器或Node.js。实际上,现在JavaScript被嵌入到各种各样的设备中,从机器人到灯泡,每个设备代表 JS 引擎的不同类型的托管环境。

所有环境中的共同点是一个称为事件循环的内置机制,它每次调用JS引擎时都会处理程序的多个块的执行。

这意味着JS引擎只是任意JS代码的按需执行环境,是宿主环境处理事件运行及结果。

例如,当 JavaScript 程序发出 Ajax 请求从服务器获取一些数据时,在函数(“回调”)中设置“response”代码,JS引擎告诉宿主环境:"我现在要推迟执行,但当完成那个网络请求时,会返回一些数据,请回调这个函数并给数据传给它"。

然后浏览器将侦听来自网络的响应,当监听到网络请求返回内容时,浏览器通过将回调函数插入事件循环来调度要执行的回调函数。以下是示意图:

您可以在我们之前的文章中阅读有关内存堆和调用堆栈的更多信息。

这些Web api是什么?从本质上说,它们是无法访问的线程,只能调用它们。它们是浏览器的并发部分。如果你是一个Node.js开发者,这些就是 c++ 的 Api。

那么事件循环究竟是什么呢?

事件循环有一个简单的工作——监视调用堆栈和回调队列。如果调用堆栈为空,它将从队列中获取第一个事件,并将其推送到调用堆栈,这将有效地运行它。

这样的迭代在事件循环中称为 (tick标记),每个事件只是一个函数回调。

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 5000);
console.log('Bye');
复制代码

让我们执行这份代码看看发生了什么:

1.初始状态都为空,浏览器console面板为空,调用堆栈为空。

2.console.log('Hi')被添加到调用堆栈中。

3.console.log(Hi)被执行。

4.console.log('Hi')从调用堆栈中移除。

5.setTimeout(function cb1() { ... })被添加到调用堆栈当中

6.setTimeout(function cb1() { ... })被执行,浏览器通过它的Web APIS创建了一个计时器,为你的代码计时。

7.这个setTimeout(function cb1() { ... })调用计时器它本身的函数是已经执行完成,从调用堆栈中移除。

8.console.log('Bye')被添加到调用堆栈中。

9.console.log('Bye')被执行。

10.console.log('Bye')从调用堆栈中移除。

11.在至少5000ms后,定时器执行完成后,把cb1回调函数添加到回调队列里面。

12.事件循环把cb1从回调队列中取出,添加到调用堆栈中。

13.cb1被执行,把console.log('cb1')添加调用堆栈中。

14.console.log('cb1') 被执行。

15.console.log('cb1')从调用堆栈中移除。

16.cb1从调用堆栈中移除。

整体过程回顾:

比较值得注意的是,ES6指定了事件循环应该怎么运行。这意味着在技术范围内,他是属于JS引擎的职责范围内,不再仅仅扮演宿主环境的角色。这种变化的一个主要原因是ES6中引入了Promises,因为后者需要对事件循环队列上的调度操作更直接,控制更细粒度(稍后我们将更详细地讨论它们)

setTimeout(...)怎么工作的

需要注意的是,setTimeout(…)不会自动将回调放到事件循环队列中。它设置了一个计时器。当计时器过期时,环境将回调放到回调中,以便将来某个标记(tick)将接收并执行它。请看下面的代码:

setTimeout(myCallback, 1000);
复制代码

这不是意味着myCallback将在1000ms后执行,而是在1000ms后myCallback将被添加到回调队列里面去,这个队列可能也有其他比较早被添加的事件正在等待,这个时候,你的回调就必须要等待。

有不少文章和教程说在JavaScript中开始使用异步编程的时候,都建议使用setTimeout(callback,0),那么现在你知道了事件循环的机制和setTimeout怎么运行的,调用setTimeout 0毫秒作为第二个参数只是推迟回调将它放到回调队列中,直到调用堆栈是空的。

看下下面的代码:

console.log('Hi');
setTimeout(function() {
    console.log('callback');
}, 0);
console.log('Bye');
复制代码

尽管等待时间设置成了0ms,这个浏览器打印的结果如下:

Hi
Bye
callback
复制代码

ES6中的任务队列是什么?

在ES6的介绍中有一个新的叫做“任务队列”的概念,它是事件循环队列上面的一层,最常见的是在promise处理异步方式的时候。 现在只讨论这个概念,以便在讨论带有Promises的异步行为时,能够了解 Promises 是如何调度和处理。

想象一下:这个任务队列是附加到事件循环队列中每个标记(一次从回调队里里面取到数据后,放到调用堆栈执行的过程)末尾的队列,某些异步操作可能发生在事件循环的一个标记期间,不会导致一个全新的事件被添加到事件循环队列中,而是将一个项目(即任务)添加到当前标记的任务队列的末尾。

这意味着可以放心添加另一个功能以便稍后执行,它将在其他任何事情之前立即执行。

一个任务还可能创建更多任务添加到同一队列的末尾。理论上,任务“循环”(不断添加其他任务的任等等)可以无限运行,从而使程序无法获得转移到下一个事件循环标记的必要资源。从概念上讲,这类似于在代码中表示长时间运行或无限循环(如while (true) ..)。

任务有点像 setTimeout(callback, 0) “hack”,但其实现方式是引入一个定义更明确、更有保证的顺序:稍后执行,但越快越好。

回调

正如你已经知道的,回调是到目前为止JavaScript程序中表达和管理异步最常见的方法。实际上,回调是JavaScript语言中最基本的异步模式。无数的JS程序,甚至是非常复杂的程序,除了一些基本都是在回调异步基础上编写的。
但是回调函数还是有一些缺点,开发者们试图探索更好的异步模式。但是,如果不了解底层的过程,就不可能有效地使用任何抽象出来的异步模式。
在下一章中,我们将深入探讨这些抽象,以说明为什么更复杂的异步模式(将在后续文章中讨论)是必要的,甚至是值得推荐的。

嵌套回调

看下下面的代码:

listen('click', function (e){
    setTimeout(function(){
        ajax('https://api.example.com/endpoint', function (text){
            if (text == "hello") {
            doSomething();
        }
        else if (text == "world") {
            doSomethingElse();
            }
        });
    }, 500);
});
复制代码

我们组成了三个函数内嵌到一起的链式嵌套,每一个函数代表在异步系列里面的一步。
这种代码通常被称为“回调地狱”。但是“回调地狱”实际上与嵌套/缩进几乎没有任何关系,这是一个更深层次的问题。
首先,我们等待“单击”事件,然后等待计时器触发,然后等待Ajax响应返回,此时可能会再次重复所有操作。
乍一看,这段代码似乎可以将其异步过程对应到以下多个函数顺序执行的步骤:

listen('click', function (e) {
	// ..
});
复制代码

然后:

setTimeout(function(){
    // ..
}, 500);
复制代码

再然后:

ajax('https://api.example.com/endpoint', function (text){
    // ..
});
复制代码

最后:

if (text == "hello") {
    doSomething();
}
else if (text == "world") {
    doSomethingElse();
}
复制代码

所以这种同步的方式去表达你的异步嵌套代码,是不是更自然一些?一定有这样的方法,对吧?

Promise

看下下面的代码:

var x = 1;
var y = 2;
console.log(x + y);
复制代码

非常的直观,这个xy相加之和通过console.log打印出来。如果,xy的值还没有赋上,仍然需要求值,怎么办?
例如,需要从服务器取回x和y的值,然后才能在表达式中使用它们。假设我们有一个函数loadX和loadY,它们分别从服务器加载xyy的值。然后,一旦xy都被加载,我们有一个函数sum,它对xy的值进行求和。 它可能是这样的:

function sum(getX, getY, callback) {
    var x, y;
    getX(function(result) {
        x = result;
        if (y !== undefined) {
            callback(x + y);
        }
    });
    getY(function(result) {
        y = result;
        if (x !== undefined) {
            callback(x + y);
        }
    });
}
// A sync or async function that retrieves the value of `x`
//获取到x值得方法
function fetchX() {
    // ..
}


// A sync or async function that retrieves the value of `y`
//获取到y值得方法
function fetchY() {
    // ..
}

//调用
sum(fetchX, fetchY, function(result) {
    console.log(result);
});
复制代码

这段代码中有一些非常重要的东西,我们将x和y作为异步获取的值,并且执行了一个函数sum(…)(从外部),它不关心x或y,也不关心它们是否立即可用。

当然,这种基于回调的粗略方法还有很多不足之处。 这只是一个我们不必判断对于异步请求的值的处理方式一个小步骤而已。

Promise Value

简单的看一下,我们怎么用promise表达x+y

function sum(xPromise, yPromise) {

	// `Promise.all([ .. ])` takes an array of promises,
	// and returns a new promise that waits on them
	// all to finish
	//`Promise.all([ .. ])` 传入一个promise数组,
	//通过返回一个新的promise,这个promise将等待所有的返回
	return Promise.all([xPromise, yPromise])

	// when that promise is resolved, let's take the
	// received `X` and `Y` values and add them together.
	//当promise是被resolved了,就返回这个x和y的值,执行加法
	.then(function(values){
		// `values` is an array of the messages from the
		// previously resolved promises
		//`values` 是上一个promise.all执行结果的数组
		return values[0] + values[1];
	} );
}

// `fetchX()` and `fetchY()` return promises for
// their respective values, which may be ready
// *now* or *later*.
//`fetchX()` 和 `fetchY()`返回各自的promise
sum(fetchX(), fetchY())

// we get a promise back for the sum of those
// two numbers.
// now we chain-call `then(...)` to wait for the
// resolution of that returned promise.
//我们得到两个promise之和的值,等待这个promise执行成功
.then(function(sum){
    console.log(sum);
});
复制代码

在这个代码中有两层promise。
fetchX()fetchY() 直接被调用,他们返回的值(promise)传入到了sum(...)。这个promise所代表的基础值无论是现在或者将来都可以准备就绪。但每个promise都会将其行为规范化,我们以与时间无关的方式推理xy的值。某一段时间内,他们是一个将来的值。

这第二层promise是sum(...)创造的(通过 Promise.all([ ... ])),然后返回promise。通过调用then(…)来等待。当 sum(…) 操作完成时,sum 传入的两个 Promise 都执行完后,可以打印出来了。这里隐藏了在sum(…)中等待x和y未来值的逻辑。

注意: 在这个sum(...)里面,这个Promise.all([...])调用创建一个 promise(等待 promiseX 和 promiseY 它们resolve)。然后链式调用 .then(...)方法里再的创建了另一个 Promise,然后把(values[0] + values[1]) 进行求和并返回。

因此,我们在sum(...)末尾调用then(...)方法——实际上是在返回的第二个 Promise 上的运行,而不是由Promise.all([ ... ])创建的Promise。此外,虽然没有在第二个 Promise 结束时再调用 then方法 ,其时这里也创建一个 Promise。

Promise.then(…) 实际上可以使用两个函数,第一个函数用于执行成功的操作,第二个函数用于处理失败的操作:
如果在获取x或y时出现错误,或者在添加过程中出现某种失败,sum(…) 返回的 Promise将被拒绝,传递给then(…)的第二个回调错误处理程序将从 Promise 接收失败的信息。

从外部看,由于 Promise 封装了依赖于时间的状态(等待底层值的完成或拒绝,Promise 本身是与时间无关的),它可以按照可预测的方式组成,不需要开发者关心时序或底层的结果。 Promise一旦resolve,此刻在外部他就成了不可变的值——然后就可以根据需求多次观察。

链式调用对于你来说是真的有用:

function delay(time) {
    return new Promise(function(resolve, reject){
        setTimeout(resolve, time);
    });
}

delay(1000)
.then(function(){
    console.log("after 1000ms");
    return delay(2000);
})
.then(function(){
    console.log("after another 2000ms");
})
.then(function(){
    console.log("step 4 (next Job)");
    return delay(5000);
})
复制代码

调用delay(2000)创建一个2000ms后将被实现(fulfill)的promise,然后通过第一个then(...)来接收回调信号,在这里面也返回一个promise,通过第二个then(...)的promise来等待2000ms的promise。

注意: 因为一个Promise一旦被resolved,在外面看来就成了不可变了,所以现在可以把它安全的传递到程序的任何地方。因为它不能被意外地或恶意地修改,这一点在多个地方观察一个promise时尤其正确。一方不可能影响另一方观察promise结果的能力,不变性听起来像是一个学术话题,但它实际上是promise设计最基本和最重要的方面之一,不应该被随意忽略。

用不用Promise

关于 Promise 的一个重要细节是要确定某个值是否是一个实际的Promise。换句话说,它是否具有像Promise一样行为?

我们知道 Promise 是由new Promise(…)语法构造的,你可能认为p instanceof Promise是一个足够可以判断的类型,嗯,不完全是!

这主要是因为可以从另一个浏览器窗口(例如iframe)接收Promise值,而该窗口或框架具有自己的Promise值,与当前窗口或框架中的Promise 值不同,所以该检查将无法识别 Promise 实例。

此外,库或框架可以选择性的封装自己的Promise,而不使用原生 ES6 的Promise 来实现。事实上,很可能在老浏览器的库中没有 Promise。

捕获错误和异常

如果在 Promise 创建中,出现了一个javascript异常错误(TypeError或者ReferenceError),这个异常会被捕捉,并且使这个 promise 被拒绝。
比如:

var p = new Promise(function(resolve, reject){
    foo.bar();	  // `foo` is not defined, so error!'foo'没有定义
    resolve(374); // never gets here :( 不会到达这儿
});

p.then(
    function fulfilled(){
        // never gets here :(不会到达这儿
    },
    function rejected(err){
        // `err` will be a `TypeError` exception object
	// from the `foo.bar()` line.
    }
);
复制代码

但是,如果在调用 then(…)方法中出现了JS异常错误,那么会发生什么情况呢?即使它不会丢失,你可能会发现它们的处理方式有点令人吃惊,直到你挖得更深一点:

var p = new Promise( function(resolve,reject){
	resolve(374);
});

p.then(function fulfilled(message){
    foo.bar();
    console.log(message);   // never reached不会到达这儿
},
    function rejected(err){
        // never reached 不会到达这儿
    }
);
复制代码

看起来foo.bar()中的异常确实被吞噬了,不过,它不是。然而,还有一些更深层次的问题,我们没有注意到。 p.then(…) 调用本身返回另一个 Promise,该 Promise 将被 TypeError 异常拒绝。

处理未捕获异常

许多人会说,还有其他更好的方法。

一个常见的建议是,Promise 应该添加一个 done(…),这实际上是将 Promise 链标记为 “done”。done(…)不会创建并返回 Promise ,因此传递给 done(..) 的回调显然不会将问题报告给不存在的链接 Promise 。

Promise 对象的回调链,不管以then方法或catch方法结尾,要是最后一个方法抛出错误,都有可能无法捕捉到(因为Promise内部的错误不会冒泡到全局)。因此,我们可以提供一个 done 方法,总是处于回调链的尾端,保证抛出任何可能出现的错误。

var p = Promise.resolve(374);

p.then(function fulfilled(msg){
    // numbers don't have string functions,
    // so will throw an error
    console.log(msg.toLowerCase());
})
.done(null, function() {
    // If an exception is caused here, it will be thrown globally 
});
复制代码

ES8中有什么变化 ?Async/await (异步/等待)

JavaScript ES8引入了async/await,这使得使用Promise的工作更容易。这里将简要介绍async/await 提供的可能性以及如何利用它们编写异步代码。

使用 async 声明异步函数。这个函数返回一个AsyncFunction 对象。AsyncFunction 对象表示该函数中包含的代码是异步函数。

调用使用 async 声明函数时,它返回一个Promise。当这个函数返回一个值时,这个值只是一个普通值而已,这个函数内部将自动创建一个promise,并使用函数返回的值进行解析。当这个函数抛出异常时,Promise 将被抛出的值拒绝。

使用 async 声明函数时可以包含一个await符号,await暂停这个函数的执行并等待传递的 Promise 的解析完成,然后恢复这个函数的执行并返回解析后的值。

async/wait 的目的是简化使用promise的行为

看下下面的列子:

// Just a standard JavaScript function
//标准的js写法
function getNumber1() {
    return Promise.resolve('374');
}
// This function does the same as getNumber1
//这个函数做了相同的事情,返回一个promise
async function getNumber2() {
    return 374;
}
复制代码

类似地,函数抛出异常相当于函数返回的promise被reject了:

//这两个函数一样
function f1() {
    return Promise.reject('Some error');
}
async function f2() {
    throw 'Some error';
}
复制代码

await关键词只能使用在async函数中,允许去同步等待一个promise执行。如果在async外面使用promise,仍然需要使用then回调。

async function loadData() {
    // `rp` is a request-promise function.
    //`rp` 是一个请求promise函数
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // Currently, both requests are fired, concurrently and
    // now we'll have to wait for them to finish
    //现在,两个请求都被执行,必须等到他们执行完成
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}
// Since, we're not in an `async function` anymore
// we have to use `then`.
//由于不再异步函数当中,我们必须使用`then`
loadData().then(() => console.log('Done'));
复制代码

还可以使用“异步函数表达式”定义异步函数。异步函数表达式与异步函数语句非常相似,语法也几乎相同。异步函数表达式和异步函数语句之间的主要区别是函数名,可以在异步函数表达式中省略函数名来创建匿名函数。异步函数表达式可以用作声明(立即调用的函数表达式),一旦定义它就会运行。

就像这样:

var loadData = async function() {
    // `rp` is a request-promise function.
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // Currently, both requests are fired, concurrently and
    // now we'll have to wait for them to finish
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}
复制代码

更重要的是,在所有主流的浏览器都支持 async/await:

最后,重要的是不要盲目选择编写异步代码的“最新”方法。理解异步 JavaScript 的内部结构非常重要,了解为什么异步JavaScript如此关键,并深入理解所选择的方法的内部结构。与编程中的其他方法一样,每种方法都有优点和缺点。

编写高度可维护、稳定的异步代码

1.简化代码

使用 async/await 可以编写更少的代码。每次使用async/await时,都会跳过一些不必·要的步骤:使用.then,创建一个匿名函数来处理响应:

// `rp` is a request-promise function.
rp('https://api.example.com/endpoint1').then(function(data) {
 // …
});
复制代码

与:

// `rp` is a request-promise function.
var response = await rp(‘https://api.example.com/endpoint1');
复制代码

2.错误处理

Async/wait 可以使用相同的代码结构(众所周知的try/catch语句)处理同步和异步错误。看看它是如何与 Promise 结合的:

function loadData() {
    try { // Catches synchronous errors.
        getJSON().then(function(response) {
            var parsed = JSON.parse(response);
            console.log(parsed);
        }).catch(function(e) { // Catches asynchronous errors
            console.log(e); 
        });
    } catch(e) {
        console.log(e);
    }
}
view raw
复制代码

与:

async function loadData() {
    try {
        var data = JSON.parse(await getJSON());
        console.log(data);
    } catch(e) {
        console.log(e);
    }
}
复制代码

3.条件处理

用async/ wait编写条件代码要简单得多:

function loadData() {
  return getJSON()
    .then(function(response) {
      if (response.needsAnotherRequest) {
        return makeAnotherRequest(response)
          .then(function(anotherResponse) {
            console.log(anotherResponse)
            return anotherResponse
          })
      } else {
        console.log(response)
        return response
      }
    })
}
复制代码

与:

async function loadData() {
  var response = await getJSON();
  if (response.needsAnotherRequest) {
    var anotherResponse = await makeAnotherRequest(response);
    console.log(anotherResponse)
    return anotherResponse
  } else {
    console.log(response);
    return response;    
  }
}
复制代码

4.错误堆栈

与 async/await不同,从 Promise 链返回的错误堆栈不提供错误发生在哪里。看看下面这些:

function loadData() {
  return callAPromise()
    .then(callback1)
    .then(callback2)
    .then(callback3)
    .then(() => {
      throw new Error("boom");
    })
}
loadData()
  .catch(function(e) {
    console.log(err);
// Error: boom at callAPromise.then.then.then.then (index.js:8:13)
});
复制代码

与:

async function loadData() {
  await callAPromise1()
  await callAPromise2()
  await callAPromise3()
  await callAPromise4()
  await callAPromise5()
  throw new Error("boom");
}
loadData()
  .catch(function(e) {
    console.log(err);
    // output
    // Error: boom at loadData (index.js:7:9)
});
复制代码

5.调试

如果你使用过 Promise,那么你知道调试它们是一场噩梦。例如,如果在一个程序中设置了一个断点,然后阻塞并使用调试快捷方式(如“停止”),调试器将不会移动到下面,因为它只“逐步”执行同步代码。使用async/wait,您可以逐步完成wait调用,就像它们是正常的同步函数一样。

后续文档翻译会陆续跟进!!

欢迎关注玄说前端公众号:

猜你喜欢

转载自juejin.im/post/5c32b971f265da61407f1057