面试经典:Event Loop

12,094 阅读19分钟

欢迎关注 jsliang 的文档库 —— 一个穷尽一生更新的仓库,查看更多技术、理财、健身文章:github.com/LiangJunron…

一 目录

不折腾的前端,和咸鱼有什么区别

目录
一 目录
二 前言
三 Event Loop
四 浏览器 Event Loop
4.1 示例 1
4.2 示例 2
4.3 示例 3
4.4 小结
五 Node.js Event Loop
5.1 setTimeout & setImmediate
5.2 process.nextTick()
5.3 示例 1
5.4 示例 2
5.5 小结
六 总结
七 参考文献

二 前言

返回目录

Hello 小伙伴们早上好、中午好、下午好、晚上好、凌晨好~

在日常工作中,你有没有碰到过这种疑惑:

  • 疑惑一:为什么这份代码它不按照我的意思走?为啥不是输出 1 2 3
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// console:
// 3
// 3
// 3
  • 疑惑二:为什么这份代码它也不按照我的意思走?为啥不是输出 jsliang
let name;

setTimeout(() => {
  name = '梁峻荣';
  console.log(name);
}, 1000);

if (name) {
  name = 'jsliang';
  console.log(name);
}
// console: '梁峻荣'

孩子没娘,说来话长。

既然说来话长,jsliang 只能尝试长话短说了:

  • 本篇文章从 Event Loop 起因说起,通过探讨 浏览器环境 Event Loop 和 Node.js 环境 Event Loop,从而解惑工作中产生的困扰,扩展你面试知识点。

这么一说,咱也好对文章进行划分了:

  • 第三章 Event Loop:解释 Event Loop 产生原因和代码演示。
  • 第四章 浏览器 Event Loop:解惑工作困扰和扩展必备面试知识点。
  • 第五章 Node.js Event Loop:进一步探索浏览器和 Node.js 中 Event Loop 的不同。

OK,Let's go!

三 Event Loop

返回目录

  • 问:什么是 Event Loop,为什么需要 Event Loop?

答:

首先,我们需要知道的是:JavaScript 是单线程的。

单线程意味着,所有任务都需要排队,前一个任务结束,才会执行后一个任务。

假设 jsliang 和 JavaScript 一样一次只能做一件事,那么大概就是如下图所示。

而这种 主线程从 “任务队列” 中读取执行事件,不断循环重复的过程,就被称为 事件循环(Event Loop)

然后,如果前一个任务耗时很长,后一个任务就不得不一直等着,那么我们肯定要对这种情况做一些特殊处理,毕竟很多时候我们并不是完全希望它如此执行。

所以为了协调事件(event),用户交互(user interaction),脚本(script),渲染(rendering),网络(networking)等,用户代理(user agent)必须使用事件循环(event loops)。

这样,在了解 浏览器 Event LoopNode.js Event Loop 的情况下,我们就可以了解它的执行过程。

通过自身的了解,来处理一些较为棘手的问题。

为了加深小伙伴们的印象,可以看下图:

jsliang 日常中,强制被加上了 “被豆豆妈打”(废话,豆豆那么可爱,你怎么可以打豆豆)。

当然,这个被打的顺序也不一定是在后面,可能打多两次后,“睡觉” 完之后就是 “被豆豆妈打” 了。

通过这个解释,小伙伴们应该知道为啥有 浏览器 Event LoopNode.js Event Loop 了。

等等,你刚才说到了 浏览器 Event LoopNode.js Event Loop,为什么都是关于 JavaScript 的,在这两部分都不一样呢?

  • 简单来说:你的页面放到了浏览器去展示,你的数据放到了后台处理(将 Node.js 看成 PHP、Java 等后端语言),这两者能没有区别么?!

你说了跟没说一样,为什么会这样你没有解释啊!

好的,说得再仔细点:

  • Node.js:Node.js 的 Event Loop 是基于 libuv。libuv 已经对 Event Loop 作出了实现。
  • 浏览器:浏览器的 Event Loop 是基于 HTML5 规范的。而 HTML5 规范中只是定义了浏览器中的 Event Loop 的模型,具体实现留给了浏览器厂商。

libuv 是一个多平台支持库,主要用于异步 I/O。它最初是为 Node.js 开发的,现在 Luvit、Julia、pyuv 和其他的框架也使用它。Github - libuv 仓库

恍然大悟,的确是不一样的啊!

所以,咱们得将这两个 Event Loop 区分开来,它们是不一样的东东哈~

最后,咱们解疑开头的两个问题,为什么会这样子,有没办法解决?

  • 疑惑一:为什么这份代码它不按照我的意思走?为啥不是输出 1 2 3
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// console:
// 3
// 3
// 3

这道题是面试常备题,它是个很有意思的问题,不仅可以让面试官跟你闲聊到 Event Loop,也可以闲聊下 var let const

为此,jsliang 特意录制了一个 GIF,希望能帮助小伙伴进一步探索这个机制:

软件是 VS Code,调试方式是 Node.js

请仔细观看 GIF 图:

  1. 在执行 for 遍历的时候,它先执行了和 setTimeout 同级的 console,然后往下执行,到 setTimeout 的时候,跳过了(放到某个位置)setTimeout,依次打印了 0, 1, 2
  2. 步骤 1 跳过的三次 setTimeout 开始执行,但是这时候的 i 的值,经过前面的 i++ 后,变成了 3for 中止循环后,i 已经是 3 了)。所以,再依次打印了 3 3 3

就是说,先走了正常的 for,然后碰到 setTimeout 时,将 setTimeout 依次放到了异次元,最后走完 for 后,再将异次元中的的 setTimeout 放出,依次将数字给输出了。

这个执行机制,就是 Event Loop 的影响,恍然大悟有木有~

这个问题的精妙之处在于,它不仅可以问你关于 Event Loop 的部分,还可以考察你对于 ES6 的 let 和 ES5 的 var 的区分,因为它有一个解决方式就是使用了 ES6 的 let

解决这个问题之前,不妨思考下下面的输出:

for (var i = 0; i < 3; i++) {

}
for (let j = 0; j < 3; j++) {

}
console.log(i);
console.log(j);

如果小伙伴对 ES6 有些许了解,应该不难猜出:

3
ReferenceError: j is not defined

是不是有些想法,那么咱们再看下下面的解决方法,再进行总结:

for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// console:
// 0
// 1
// 2

是的,将 var i 改成了 let i 后,输出的结果依次是 0 1 2 了。

为什么呢?简单回复就是:

letfor 中形成了独特的作用域块,当前的 i 只在本轮循环中有效,然后 setTimeout 会找到本轮最接近的 i,从而作出了正确的输出。

而我们通过 var 进行的定义,它会污染全局变量,所以在 for 外层,还可以看到 i 的值。

当然,讲到这里,你可能还是不太清楚更细节的区分,亦或者面试官进一步问你 var let const 的区分了,你要怎么更好回答?

看看阮一峰大佬的 ES6 文档吧:es6.ruanyifeng.com/#docs/let

这里就不哆嗦了,有空我再将 ES6 这块内容整理到我的文档库中,欢迎持续关注 jsliang 的文档库:github.com/LiangJunron…

  • 疑惑二:为什么这份代码它也不按照我的意思走?为啥不是输出 梁峻荣
let name;

setTimeout(() => {
  name = 'jsliang';
  console.log(name);
}, 1000);

if (name) {
  name = '梁峻荣';
  console.log(name);
}
// console: 'jsliang'

当你了解产生疑惑一的原因后,疑惑二也就不破而解了。

我们希望的是 JavaScript 按照我们需要的顺序写,结果它并没有,就是因为受到了 Event Loop 的影响。

JavaScript 在碰到 setTimeout 的时候,会将它封印进异次元,只有等所有正常的语句(iffor……)执行完毕后,才会将它从异次元解封,输出最终结果。

咦,这就有意思了,浏览器的异次元和 Node.js 的异次元都是怎样的呢?我们一起往下看。

四 浏览器 Event Loop

返回目录

在讲解浏览器的 Event Loop 前,我们需要先了解一下 JavaScript 的运行机制:

  1. 所有同步任务都在主线程上执行,形成一个 “执行栈”(execution context stack)。
  2. 主线程之外,存在一个 “任务队列”(task queue),在走主流程的时候,如果碰到异步任务,那么就在 “任务队列” 中放置这个异步任务。
  3. 一旦 “执行栈” 中所有同步任务执行完毕,系统就会读取 “任务队列”,看看里面存在哪些事件。那些对应的异步任务,结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面三个步骤。

而 JavaScript 的异步任务,还细分两种任务:

  • 宏任务(Macrotask)script(整体代码)、setTimeoutsetIntervalXMLHttpRequest.prototype.onloadI/O、UI 渲染
  • 微任务(Microtask)PromiseMutationObserver

这么讲是不太容易理解的,咱们上图:

图较大,如果是公众号看的小伙伴,可以点击【阅读原文】看全图

好的,如果小伙伴们看不清楚,那么咱们还是通过代码来进行讲解,毕竟以上属于 jsliang 个人理解,是从 15 篇以上文章和自己观察代码运行总结出来的。

4.1 示例 1

返回目录

那么,上代码~

示例 1

// 位置 1
setTimeout(function () {
  console.log('timeout1');
}, 1000);

// 位置 2
console.log('start');

// 位置 3
Promise.resolve().then(function () {
  // 位置 5
  console.log('promise1');
  // 位置 6
  Promise.resolve().then(function () {
    console.log('promise2');
  });
  // 位置 7
  setTimeout(function () {
    // 位置 8
    Promise.resolve().then(function () {
      console.log('promise3');
    });
    // 位置 9
    console.log('timeout2')
  }, 0);
});

// 位置 4
console.log('done');

提问:请指出上面代码的输出结果?

回答

这是经典的面试题型,所以咱们看到不用慌,先拿我们上面的点,区分下分宏任务和微任务:

  • 宏任务(Macrotask)script(整体代码)、setTimeoutsetIntervalXMLHttpRequest.prototype.onloadI/O、UI 渲染
  • 微任务(Microtask)PromiseMutationObserver

OK,开始走流程:

如果你觉得文字不好理解,请往下翻,有 GIF 图演示!!!

  1. 首先碰到的是 script(整体代码),先看【位置 1】,属于宏任务 setTimeout 下的,所以做个标记,待会回来执行。
  2. 接着碰到【位置 2】,这是 script(整体代码)下的无阻碍代码,直接执行即可。
  3. 再来碰到【位置 3】,它现在是 script(整体代码)下的微任务,所以咱们做个标记,走完文件所有代码后,优先执行微任务,再执行宏任务。
  4. 最后碰到【位置 4】,它是 script(整体代码)下的无阻碍代码,直接执行即可。

这样,第一波步骤,我们输出的是【位置 2】的 start 和【位置 4】的 done

我们接着走:

  1. 上面我们走完了第一遍代码,然后现在这一步先走 script(整体代码)下的微任务,即【位置 3】
    1. 先碰到【位置 5】,这是无阻碍代码,直接执行。
    2. 再碰到【位置 6】,这是微任务,标记一下,等下执行完【位置 3】内所有代码后,优先执行它。
    3. 最后碰到【位置 7】,这是宏任务,丢入任务队列,看它和【位置 1】谁先走了。
  2. 走完一遍【位置 3】后,发现还有微任务【位置 6】,所以执行【位置 6】,进行打印输出。

到这一步,我们就走完了 script(整体代码)及之下的所有微任务了。

这时候,我们会说,【位置 1】和【位置 7】都被丢到任务队列了,是不是【位置 1】先走呢?

答案为:不是的。

同样的 setTimeoutjsliang 在测试的时候,就发现它们的输出结果在各个环境都有自己的流程,有时候先走【位置 7】,再走【位置 1】;而有时候先走【位置 1】,再走【位置 7】。

当然,如果你指定是在 Chrome 的控制台输出一下上面的代码,那就是先【位置 7】,再【位置 1】~

  • point:不要主观臆断某个代码会怎么走,最好还是直接实况运行走一波!
  1. 先走【位置 7】。碰到【位置 8】,将其添加到【位置 7】的微任务中,等【位置 7】所有代码执行完毕回来优先走微任务;碰到【位置 9】,这是无阻碍代码,直接输出即可。
  2. 执行【位置 7】的微任务【位置 8】,输出对应文本。
  3. 最后走【位置 1】,输出对应文本。

所以答案是:

start
done
promise1
promise2
timeout2
promise3
timeout1

你猜对没有?

没有可以看下 GIF 图加深印象:

4.2 示例 2

返回目录

在上面,jsliang 花费了许多口水,讲了一些繁杂冗余的步骤,所以下面这个示例,请小伙伴们先自行猜设,得出结论后再翻看答案和调试 GIF~

示例 2

console.log("script start");

setTimeout(function() {
  console.log("setTimeout---0");
}, 0);

setTimeout(function() {
  console.log("setTimeout---200");
  setTimeout(function() {
    console.log("inner-setTimeout---0");
  });
  Promise.resolve().then(function() {
    console.log("promise5");
  });
}, 200);

Promise.resolve()
  .then(function() {
    console.log("promise1");
  })
  .then(function() {
    console.log("promise2");
  });
Promise.resolve().then(function() {
  console.log("promise3");
});
console.log("script end");



  • 输出结果
script start
script end
promise1
promise3
promise2
setTimeout---0
setTimeout---200
promise5
inner-setTimeout---0
  • GIF 演示

4.3 示例 3

返回目录

最后再看一个示例:

示例 3

setTimeout(function() {
  console.log(4);
}, 0);

const promise = new Promise(function executor(resolve) {
  console.log(1);
  for (var i = 0; i < 10000; i++) {
    i == 9999 && resolve();
  }
  console.log(2);
}).then(function() {
  console.log(5);
});

console.log(3);



  • 输出结果
1
2
3
5
4

如果不常用 Promise 的小伙伴,可能对此感到疑惑,为啥不是:3 1 2 5 4

手动滑稽,别问,问就是进一步探索 Promise

当然,还没将所有探索结果更新,如果有小伙伴催更会加快速度,欢迎留言或者私聊催更,哈哈~

4.4 小结

返回目录

这样,我们就通过 3 个示例,大致了解了浏览器的 Event Loop。

当然,实际应用中的代码,何止这么简单,甚至有时候,面试官给你的面试题,也会让你瞠目结舌。

所以,这里咱们废话两点:

  1. 你可以了解宏任务和微任务的大体执行,例如先走 if...else...,再走 Promise……但是,详细到每个 point 都记下来,这里不推荐。大人,时代在进步,记住死的不如多在业务实践中尝试,取最新的知识。
  2. 浏览器的 Event Loop 和 Node.js 的 Event Loop 不同,万一哪天 XX 小程序搞另类,有自己的 Event Loop,你要一一记住吗?

碰到问题不要慌,程序员,折腾就对了~

五 Node.js Event Loop

返回目录

那么,下面咱们吐槽下 Node.js 的 Event Loop。

说实话,看完 Node 官网和大佬们关于 Node.js 的 Event Loop 讲解,让我想起了 Vue、React、微信小程序 的【生命周期】,再联想到我们的人生仿佛就像被写死的程序一样周期性、事件性运行,非常可恶,哈哈~

上面我们讲解过:Node.js 的 Event Loop 是基于 libuv。libuv 已经对 Event Loop 作出了实现。

那么其机制是怎样子的呢?看图:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

关于这 6 个阶段,官网描述为:

  • 定时器(timers):本阶段执行已经被 setTimeout()setInterval() 的调度回调函数。
  • 待定回调(pending callbacks):执行延迟到下一个循环迭代的 I/O 回调。
  • idle, prepare:仅系统内部使用。
  • 轮询(poll):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 Node 将在适当的时候在此阻塞。
  • 检测(check)setImmediate() 回调函数在这里执行。
  • 关闭的回调函数(close callbacks):一些关闭的回调函数,如:socket.on('close', ...)

当然,这里 jsliang 并不想画蛇添足,将官网或者其他大佬的文章照搬过来说是自己的,推荐小伙伴们阅读官网关于 Event Loop 的各个阶段的描述,以期在工作中有所使用:

Node.js 在不停的探索中,也会有所更新,所以正应了 jsliang 在浏览器 Event Loop 中的小结所说:不要限定死自己的知识点,与时俱进才是王道

Node.js v9.5.0 Event Loop

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

但是,迫于生活所需,有些时候,前端面试官还是会跟你扯 setTimeout & setImmediateprocess.nextTice()

5.1 setTimeout & setImmediate

返回目录

  • setTimeout:众所周知,这是一个定时器,指定 n 毫秒后执行定时器里面的内容。
  • setImmediate:Node.js 发现使用 setTimeoutsetInterval 有些小弊端,所以设计了个 setImmediate,该方法被设计为一旦在当前轮询阶段完成,就执行这个脚本。

当然,光说无益,看代码:

index.js

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

猜测下在 VS Code 中执行 node index.js 命令会发生什么?

结局 1

immediate
timeout

结局 2

timeout
immediate

事实上这两个结局都是会存在的,看似 happy ending,但是有的小伙伴可能心里闹翻天。

按照官网的解释:

  • 执行计时器的顺序将根据调用它们的上下文而异。
  • 如果两则都从主模块内调用,则计时器将受到进程性能的约束(这可能会受到计算机上其他正在运行应用程序的影响)。
  • 如果你将这两个函数放入一个 I/O 循环内调用,setImmediate 总是被有限调用。
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

虽然官方解释的很 巧妙,但是不管你懂不懂,反正我觉得有点扯淡。

最后再来句官方总结:

  • 使用 setImmediate() 相对于 setTimeout 的主要优势是:如果 setImmediate() 是在 I/O 周期内被调度的,那么它将会在任何的定时器之前执行,跟这里存在多少个定时器无关。

enm...后面如果我具体使用 Node.js 的时候,我再进一步观察吧,至于现在,我还是先了解下即可。

5.2 process.nextTick()

返回目录

nextTick 比较特殊,它存有自己的队列。

并且,它独立于 Event Loop,无论 Event Loop 处于何种阶段,都会在阶段结束的时候清空 nextTick 队列。

还有需要注意的是:process.nextTick() 优先于其他的微任务(microtask)执行。

当然,如果你对此有所兴趣,你可以进一步探索源码,或者观察大佬们探索源码:

没有使用就没有发言权,作为一个 Node.js 菜鸡,这里就不妄加评论分析了。

5.3 示例 1

返回目录

下面开始示例,我们看下 Node.js 的 Event Loop 有何差异:

示例 1

setTimeout(() => {
  console.log("timer1");
  Promise.resolve().then(function() {
    console.log("promise1");
  });
});

setTimeout(() => {
  console.log("timer2");
  Promise.resolve().then(function() {
    console.log("promise2");
  });
});

如果你还记得上面讲解的浏览器的 Event Loop,你可能会将答案直接写成:

浏览器 Event Loop 输出:

timer1
promise1
timer2
promise2

是的你是对的,那就是浏览器的 Event Loop,到了 Node.js 这块,就有不同变化了:

Node.js Event Loop 输出:

timer1
timer2
promise1
promise2

尝试接受它!

然后大声默念:根据具体环境进行对应观察和得出结论

5.4 示例 2

返回目录

下面咱们再看一个示例:

示例 2

setTimeout(function () {
   console.log(1);
});
console.log(2);
process.nextTick(() => {
   console.log(3);
});
new Promise(function (resolve, rejected) {
   console.log(4);
   resolve()
}).then(res=>{
   console.log(5);
})
setImmediate(function () {
   console.log(6)
})
console.log('end');

node index.js

2
4
end
3
5
1
6

这里不打算解析,因为我怕初识 Event Loop 的小伙伴看完解释后懵逼,然后搞混淆了。

实话:我也不敢解析,因为我就是 Node.js 菜鸡

5.5 小结

返回目录

终上所述,我们进行小结:

Node 端事件循环中的异步队列也是这两种:Macrotask(宏任务)队列和 Microtask(微任务)队列。

  • 常见的 Macrotask:setTimeoutsetIntervalsetImmediatescript(整体代码)、 I/O 操作等。
  • 常见的 Microtask:process.nextTicknew Promise().then(回调) 等。

OK,咱们就探索了一遍 Node.js 的 Event Loop 啦,但是因为咱还成就不了 Node.js 工程师,所以咱就不对其进行详细探索,以免和浏览器的 Event Loop 混淆了。

感兴趣的小伙伴可以自行探索咯~

六 总结

返回目录

如果你看到这里,你已经近乎懵逼,那么,还是那个建议:

  • 不管 Event Loop 在浏览器亦或者 Node.js 表现机制,最好的操作还是在对应环境中进行尝试。

你不能完全保证你的记忆力是 OK 的,所以你只需要知道有这个问题,然后在工作中实践解决即可。

enm...所以你看完了一篇水文,唯一的作用是让你面试的时候,能愉快地玩耍一些简单题目~

哈哈,Good luck.

如果你觉得我的文章还不错,想持续关注或者加我微信好友,欢迎前往 github.com/LiangJunron… 进行 star 或者加微信。

七 参考文献

返回目录

感谢以下大佬们的文章,让我受益颇多。

并在他们创作的基础上,基于自己的想法,进行了整合。

  1. 《Tasks, microtasks, queues and schedules》 - Jake
  2. 《彻底搞懂浏览器 Event-loop》 - 刘小夕
  3. 《彻底理解 JS Event Loop(浏览器环境)》 - 93
  4. 《彻底弄懂浏览器端的 Event-Loop》 - 长可
  5. 《什么是浏览器的事件循环(Event Loop)?》 - 鱼子酱
  6. 《理解event loop(浏览器环境与nodejs环境)》 - sugerpocket
  7. 《从 event loop 规范探究 JavaScript 异步及浏览器更新渲染时机》 - 杨敬卓
  8. 《跟着 Event loop 规范理解浏览器中的异步机制》 - fi3ework
  9. 《不要混淆 nodejs 和浏览器中的 event loop》 - youth7
  10. 《浏览器的 event loop 和 node 的 event loop》 - 金大光
  11. 《浏览器与 Node 的事件循环(Event Loop)有何区别?》 - 浪里行舟
  12. 《浏览器和 Node 不同的事件循环(Event Loop)》 - toBeTheLight
  13. 《let 和 const 命令》 - 阮一峰
  14. 《Node.js Event Loop》 - Node.js 官网

不折腾的前端,和咸鱼有什么区别!

jsliang 会每天更新一道 LeetCode 题解,从而帮助小伙伴们夯实原生 JS 基础,了解与学习算法与数据结构。

浪子神剑 会每天更新面试题,以面试题为驱动来带动大家学习,坚持每天学习与思考,每天进步一点!

扫描上方二维码,关注 jsliang 的公众号(左)和 浪子神剑 的公众号(右),让我们一起折腾!

知识共享许可协议
jsliang 的文档库梁峻荣 采用 知识共享 署名-非商业性使用-相同方式共享 4.0 国际 许可协议进行许可。
基于github.com/LiangJunron…上的作品创作。
本许可协议授权之外的使用权限可以从 creativecommons.org/licenses/by… 处获得。