前端进阶必须懂得TCP/IP

TCP/IP四层协议

一往无前虎山行,拨开云雾见光明。梦里花落牡丹亭,幻想成真歌舞升平。 —— Gai

计算机网络的结构分层

  • 物理层,相当于是那些物流公司里的送货汽车
  • 数据链路层,可以看作是物流公司的司机,他们驾驶着汽车,将打包好的货物(数据帧)从一个城市(物理节点)运输到另一个城市。
  • 网络层,这好比是物流公司的路线规划者。物流公司有很多集散中心,根据集散中心的情况(是否拥堵),找出一条经过n个集散中心的路径将货物(数据)沿路运过去。
  • 传输层,可以将其看作是物流公司的跟单员。负责任的跟单员(使用 TCP 协议)会保证数据送到客户手上,如果送不到(需要三次握手,四次挥手)就让公司再发一次。不负责任的跟单员(使用 UDP 协议)层只管将快递送到客户指定的地方,不管快递是否送到客户手上。
  • 会话层,可看作是物流公司的调度员。他管理着这次物流的相关的信息。例如这次客户要发很多数据,发到哪,到底是一车一车发、还是用轮船一次运过去。这些都是他的职责。而运完之后,相关信息(连接)也可以被销毁了,这也是调度员的职责。
  • 表示层,相当于物流公司的打包员。如果快递(数据)太臃肿,他会在不破坏快递的情况下压扁(压缩)它。如果客户注重安全线,全能的快递公司还能用密码箱( SSL/TLS)打包快递再快送。当然,打包员会确定,目的地快递站的拆包员,能无损地拆开包裹,将快递交给用户。
  • 应用层,可以想象成物流公司的收件员,当客户(应用)打电话(发起请求)给收件员(应用层)时,收件员可以根据用户的不同需求提供不同的服务(不同协议),比如隔天送达、指定时间送达等等。

TCP/IP协议族

  • TCP/IP从上往下走的时候,会分层的传输进行通讯。
  • 发送端是从应用层链路层,接收端是从链路层往上到应用层取数据。
  • 其中通过各种首部的包装来让相应的层进行识别
  • 这里我拿了网上copy的一张通用的图来看一下。

  • 每层协议需要实现的功能:
    • 将上层协议传递的数据包包装为满足该层协议的数据包
    • 将下层协议传递的数据包解析为满足上层协议的数据包
    • 处理其他层的交互

  • 这和上图要表达的意思差不多,需要注意的是
    • 从底往上传的二进制包,只要该层能处理就不需要按照严格意义上的分层解析
    • 比如说,网络层往上传二进制包的时候,你可以在传输层包一层tcp头,也可以不包,直接交给应用层解析

数据包

帧、数据包、段、消息(传输数据单位)

  • 帧:用于表示数据链路层中包的单位;
  • 数据包: IP 和 UDP 等网络层以上的分层中包的单位;
  • 段:表示 TCP 数据流中的信息;
  • 消息:指应用协议中数据的单位。

数据在每个分层中的体现

  • 每个分层中,都会对所发送的数据附加一个首部
  • 在这个首部中包含了该层必要的信息,如发送的目标地址以及协议相关信息。
  • 通常,为协议提供的信息为包首部,所要发送的内容为数据。在下一层的角度看,从上一层收到的包全部都被认为是本层的数据。

在上面我们就能直观地看到,每一层的数据之外,其实包了很多层协议的首部

数据链路层

探究:数据链路层到底干了什么?

  • 主要是做了接受IP数据报,并发送IP数据报至IP模块
  • 与ARP及RARP模块交互
  • 它的结构可以分为如下
    • 业务区:最底下的那些物理机构成的区域,是物理层流出的起点。
    • 接入层:业务区往上的千兆交换机,这里的物流机如果想互相通信,就必须通过这个交换机。
    • 核心交换机:由一组千兆交换机组成。
    • IDC路由骨干:路由器组成。

Wireshark抓包,看看数据链路层在传输时变成了什么?

  • 首先这个是Wireshark给我们解析出来的TCP/IP协议族的四层。
  • 从上至下,分别是链路网络传输应用
  • 我们着重看链路层,因为链路层在网络层之下,此时的地址都为硬件地址。
  • 只有通过硬件地址,物理层才能将数据包通过路由传至目标地址。
  • 这里就会有一个问题:怎么通过IP地址去获取硬件地址呢?

ARP协议

作用:

  • 将32bit的IP地址,转换为48bit的以太网地址。
  • 发送ARP广播,获得目的端的IP地址和硬件地址。

要注意的问题

  • 访问不存在的主机地址超过时间一般为75s,这个时间其实一般就是TCP连接请求的超时时间。
  • 如果ARP回答未返回,那么任何TCP报文段都不会发送。

如何捕获ARP数据?

  • telnet
  • tcpdump(相当于无界面的Wireshark)
  • 命令:
    • arp -a:查看arp高速缓存。(每个已经找到的mac地址都会在arp中缓存
    • tcpdump -i 网络接口arp:监听网络接口的ARP
    • tcpdump -D:查看可监听的网络接口。

MTU是什么?

最大传输单元。

  • IPMTU就是最大的传输单元
  • 每个路由都会有一个规定的MTU大小
  • 如果我们传送的IP数据包大于这个MTU,就会漏传
  • 需要遵循短板原理,根据最小的MTU来设置IP数据包大小。

总结

  • 主要就是通过发送端的IP地址,在内网(局域网)中通过广播的方式,去询问对应IP的以太网地址
  • 如果访问不到,超过75s,就不会继续广播
  • MTU控制我们传输的大小。
  • 如果我们访问的不是局域网,属于公网,那么我们还通过ARP来寻找mac地址吗?如果是这样,那么我们将会给每个公网发送一个广播,那得有多恐怖,所以ARP只存在局域网
  • 上面这种情况,我们先通过DNS解析得到对应的网络IP地址,再通过找上层的路由去寻找,此时路由发现不是局域网,将会在往上寻找ISP(Internet Service Provider),最后找到对应的mac地址

网络层(IP协议)

上面说到的路由,和它息息相关的就是我们的网络层,最为核心的一层。

协议组成(必须严格遵守顺序)

  • 版本:4位
  • 首部长度:4位,⚠️注意,这里的每1位表示10进制x4个字节的数目。因为4位最大的个数为1111,即15,所以首部最长为15x4,60字节
  • 服务类型:8位,大部分实现不支持,无需关注
  • 总长度:16位,所以最大的IP包为65536
  • 标识:16位,唯一标识主机发送的每一份数据报
  • 3位标志+13位片偏移:暂时不关注
  • 生存时间:8位,数据报最多可经过的路由数(如果不设置这个,就会无限回传)。
  • 上层协议:8位,表明该IP数据报对应的上层协议是什么。
  • 首部校验和:16位,保证IP数据报传输正确。
  • 源IP地址:32位。
  • 目的IP地址:32位。

  • 上面是Wireshark抓的包的IP层,可以清楚看到每个协议的组成。
  • 需要注意⚠️
    • 假如我们发的是公网IP,此时我们无法用ARP找到远端mac地址
    • 需要通过发送给路由,所以我们需要路由的mac地址
    • 本来数据链路层以太网首部放的是本机mac地址和目标mac地址
    • 现在放的是本机mac地址和路由mac地址
    • 而我们的目标IP地址源IP地址则是包在IP首部之中,最后交给路由,让路由处理。

路由选择

  • 每一个IP数据报在网络中进行传输的目的,要找到目的IP的主机地址,并最终到达该主机。所以IP数据报经过的每一个节点都相当于是一个中转站,也就是路由
  • IP数据报被路由节点的操作称为路由选择
    • 动态选路:根据当前网络状况权值,选出一条最佳路径。
    • 静态选路:以手工方式为每台路由器的路由选择表添加路由。
  • 常规的路由机制
    • 目的主机与源主机直接相连,或处于共享网络,直接送达目标主机(相同内网下)。
    • 默认转发至该网络存在的路由器上,再由其统一处理。
  • 路由匹配算法
    • 路由表中存在与目的IP地址完全相同的条目,直接发送至下一跳。
    • 路由表中存在与目的IP地址网络号相同的条目,发送至该条目指定的下一跳。
    • 以上条件均不成立,寻找默认条目,发送至该条目指定的下一跳。
    • 均不成立,返回主机或网路不可达。

  • 假如我们此时的目标IP地址为192.168.0.100,此时会匹配第一条,他的Gateway就是目标的mac地址,en0是网卡,我们可以通过ifconfig查看(windows用ipconfig)
  • 假如我们此时目标IP地址为192.168.0.103,此时路由表没有对应IP地址,则会去寻找相同网络号,此时找到第二条,他的Gatewaylink#8相当于广播出去。
  • 假如我们此时目标IP地址为192.168.1.2,此时无法在路由表中匹配,则走default,对应第三条,他的Gateway对应192.168.0.1192.168.0.1其实就是我们的路由器

上面有提到判断网络号相同,这个我们怎么来做呢?


如何判断两个IP地址网络号相同

IP地址的分类

类别 最大网络数 IP地址范围 最大主机数 私有IP地址范围
A 126(2^7-2) 0.0.0.0-127.255.255.255 16777214 10.0.0.0-10.255.255.255
B 16384(2^14) 128.0.0.0-191.255.255.255 65534 172.16.0.0-172.31.255.255
C 2097152(2^21) 192.0.0.0-223.255.255.255 254 192.168.0.0-192.168.255.255
  • IP 地址分为五个级别,分别为A类、B类、C类、D类、E类。它根据 IP 地址中从第 1 位到第 4 位的比特列对其网络标识和主机标识进行区分。D、E类属特殊地址。
  • A 类 IP 地址是首位以 “0” 开头的地址。从第 1 位到第 8 位是它的网络标识。用十进制表示的话,0.0.0.0~127.0.0.0 是 A 类的网络地址。A 类地址的后 24 位相当于主机标识。因此,一个网段内可容纳的主机地址上限为16,777,214个。
  • B 类 IP 地址是前两位 “10” 的地址,一般是公网。从第 1 位到第 16 位是它的网络标识。用十进制表示的话,128.0.0.0~191.255.0.0 是 B 类的网络地址。B 类地址的后 16 位相当于主机标识。因此,一个网段内可容纳的主机地址上限为65,534个。
  • C 类 IP 地址是前三位为 “110” 的地址。从第 1 位到第 24 位是它的网络标识。用十进制表示的话,192.0.0.0~223.255.255.0 是 C 类的网络地址。C 类地址的后 8 位相当于主机标识。因此,一个网段内可容纳的主机地址上限为254个。

子网掩码

  • 为了细分更小力度的网络。
  • 他让A、B、C类地址的主机地址部分用作子网地址,可以将原网络分为多个物理网络的一种机制
  • 每类的都有自己默认的网络地址
类别 子网掩码
A 255.0.0.0
B 255.255.0.0
C 255.255.255.0
  • 通过和IP地址进行运算(1&0=0;0&0=0;1&1=1)来得到网络号,这里的操作需要将IP地址转为二进制在进行

为什么细分了网络?

从上面的图,我们可以看出,每类地址的网络分段

  • 题目
    • 1、10.0.0.1和10.1.1.1的网络号相同吗?
    • 2、172.16.0.1和172.16.1.1的网络号呢?
    • 3、192.168.10.103和192.168.0.102呢?
  • 答案
    • 1、相同。首先我们知道这两个IP地址同属A类,再将他们和默认子网掩码(255.0.0.0),得到结果两个都是10.0.0.0,这就是他们的网络号,也正好符合图中的前8位,10转为二进制就是00001010
    • 2、同理可得,属于B类,相同,并且网络号为172.16.0.0
    • 3、属于C类,网络号不同,分别为192.168.10.0192.168.0.0

上面的题目都是在默认的子网掩码之下,那么如果我们改了子网掩码呢?

  • 题目
    • 1、172.16.0.1和172.16.1.1的子网掩码是255.255.255.0,此时网络号相同吗?
  • 答案
    • 1、不同,此时我们在算一次,把每个IP地址都和255.255.255.0,此时网络号分别为,172.16.0.0172.16.1.0,则不同。
  • 我们可以看到经过子网掩码,把我们本来相同网段IP地址,划为不通网段的地址,这样就细分了网络,有更多的物理网络

IP分片

当我们的MTU最大传输单元(1500字节,去掉以太网首部,就是1480字节)满足不了我们的需求时,我们就需要IP分片(分成一片片传输)。

  • 回顾IP协议组成中的:标志字段、片偏移
    • 16位标识()IP分片时,该值被复制每一个分片中,相当于每个分片的id
    • 三位标志
      • 首位:保留字段,一般为0。
      • 中间位:不进行IP分片时为0,进行时为1。
      • 末位:假如此时有3片IP分片,前两片末位都为1,最后一片末位为0。
    • 13位片偏移:该片偏移原始数据包开始处的位置。假如此时5片IP分片,那么第一片的13位片偏移为1,第二片为2,让路由知道先后顺序

  • 错误处理
    • IP分片时,只丢失一片数据,也需要重传整个IP数据报。
    • 当给定数据报的第一个数据报片到达时,开始定时器过期丢弃所有数据报片。

ICMP协议

  • 属于IP协议的一部分
  • 确认 IP 包是否成功送达目标地址,通知在发送过程当中 IP 包被废弃的具体原因,改善网络设置等。
  • IP包首部有一个字段TTL,每经过一个路由器就会减1,直到减到0时IP报就会被丢弃。
  • 此时,IP路由器将会发送一个ICMP超时的消息给发送端,通知该包已被丢弃
  • traceroute命令的原理就是通过ICMP超时消息来显示发送主机到达特定主机之前经历了多少路由器。

总结:

  • 处理数据报在网络中的行为(路由选择)。
  • 承载端到端的数据交互方式(两个ip的host之间的传递数据)。
  • 缺点:
    • 只是保证数据基本达到,数据可能是无序的。
    • 可能是缺失的。

传输层(TCP协议)

解决IP层的痛点,是一种安全的协议,主要通过连接疏通了整一个通道,安全传输。

三次握手和四次挥手

三次握手

  • 看前三个箭头
    • 客户端发送SYN A1请求建立连接,SYN就是synchronization(同步),而且此时A1是被传在sequence(序列)中的,此时服务端接收到客户端传来的A1信号。
    • 服务端回应客户端我接收到了,所以发送ACK A2(只是确认应答,所以只需在原有的A1+1=A2),这里的ACK就是acknowledge(确认)。并且请求客户端建立连接, 此时发送一个信号SYN B1
    • 客户端接受并确认应答,告知服务端接收到了信号,所以发送ACK B2(因为上次推过来的SYN B1)

四次挥手

  • 看后四个箭头
    • 客户端发送结束信号FIN C1
    • 服务端接收到结束信号,回应接收到了,此时发送信号ACK C2,客户端已经关闭,但是服务器处于半关闭状态(客户端不能向服务器发送请求,服务器还是能接受客户端请求)
    • 服务端向客户端发送结束信号FIN D1
    • 客户端接收到结束信号,回应接收到了,此时发送信号ACK D2,服务器也关闭。

通过序列号与确认应答提高可靠性

  • 序列号是按照顺序给发送数据的每一个字节(8位字节)都标上号码的编号。接收端查询接收数据TCP首部中的序列号数据的长度,将自己下一步应该接收的序列号作为确认应答返送回去。通过序列号确认应答号,TCP能够识别是否已经接收数据,又能够判断是否需要接收,从而实现可靠传输。

重发超时的确定

  • 重发超时是指在重发数据之前,等待确认应答到来的那个特定时间间隔。如果超过这个时间仍未收到确认应答,发送端将进行数据重发。最理想的是,找到一个最小时间,它能保证“确认应答一定能在这个时间内返回”。
  • TCP 要求不论处在何种网络环境下都要提供高性能通信,并且无论网络拥堵情况发生何种变化,都必须保持这一特性。为此,它在每次发包时都会计算往返时间及其偏差。将这个往返时间和偏差时间相加,重发超时的时间就是比这个总和要稍大一点的值。
  • 在 BSD 的 Unix 以及Windows系统中,超时都以0.5秒为单位进行控制,因此重发超时都是0.5秒的整数倍。不过,最初其重发超时的默认值一般设置为6秒左右。
  • 数据被重发之后若还是收不到确认应答,则进行再次发送。此时,等待确认应答的时间将会以2倍、4倍的指数函数延长
  • 此外,数据也不会被无限、反复地重发。达到一定重发次数之后,如果仍没有任何确认应答返回,就会判断为网络或对端主机发生了异常,强制关闭连接。并且通知应用通信异常强行终止

以段为单位发送数据

  • 在建立TCP连接的同时,也可以确定发送数据包的单位,我们也可以称其为“最大消息长度”(MSS)。最理想的情况是,最大消息长度正好是IP中不会被分片处理的最大数据长度。
  • TCP在传送大量数据时,是以MSS的大小将数据进行分割发送。进行重发时也是以MSS为单位。
  • MSS在三次握手的时候,在两端主机之间被计算得出。两端的主机在发出建立连接的请求时,会在TCP首部中写入MSS选项,告诉对方自己的接口能够适应的MSS 的大小。然后会在两者之间选择一个较小的值投入使用。

利用窗口控制提高速度

  • TCP 以1个段为单位,每发送一个段进行一次确认应答的处理。这样的传输方式有一个缺点,就是包的往返时间越长通信性能就越低。
  • 为解决这个问题,TCP引入了窗口这个概念。确认应答不再是以每个分段,而是以更大的单位进行确认,转发时间将会被大幅地缩短。也就是说,发送端主机,在发送了一个段以后不必要一直等待确认应答,而是继续发送
  • 窗口大小就是指无需等待确认应答而可以继续发送数据的最大值。这个机制实现了使用大量的缓冲区,通过对多个段同时进行确认应答的功能。

总结:

  • 抽象出连接的概念,在一个TCP连接中,仅有两方彼此通信
  • 保证了可靠数据传输及正确顺序的数据处理
  • 提供流量控制(Node.js中的流)

应用层(HTTP协议)

我会留到下一篇再讲,同时我会结合node来讲解其中的http的应用和原理,敬请期待。

你真的懂模块化吗?教你CommonJS实现

你真的懂模块化吗

加紧学习,抓住中心,宁精勿杂,宁专勿多。 —— 周恩来

模块简史

  • 早期的 JavaScript 往往作为嵌入到 HTML 页面中的用于控制动画与简单的用户交互的脚本语言,我们习惯这样写。
1
2
3
4
5
<!--html-->
<script type="application/javascript">
// module1 code
// module2 code
</script>
  • 所有的嵌入到网页内的 JavaScript 对象都会使用全局的 window 对象来存放未使用 var 定义的变量。这就会导致一个问题,那就是,最后调用的函数或变量取决于我们引入的先后顺序。

  • 模块化时代。随着单页应用与富客户端的流行,不断增长的代码库也急需合理的代码分割与依赖管理的解决方案,这也就是我们在软件工程领域所熟悉的模块化(Modularity)

  • 直接声明依赖(Directly Defined Dependences)、命名空间(Namespace Pattern)、模块模式(Module Pattern)、依赖分离定义(Detached Dependency Definitions)、沙盒(Sandbox)、依赖注入(Dependency Injection)、CommonJS、AMD、UMD、标签化模块(Labeled Modules)、YModules、ES 2015 Modules。这些都是模块化时代的产物。

  • 问题来了,过度碎片化的模块同样会带来性能的损耗与包体尺寸的增大,这包括了模块加载、模块解析、因为 Webpack 等打包工具包裹模块时封装的过多IIFE 函数导致的 JavaScript 引擎优化失败等。

那么到底什么是模块化?

简而言之,模块化就是将一个大的功能拆分为多个块,每一个块都是独立的,你不需要去担心污染全局变量,命名冲突什么的。

好处

  • 封装功能
  • 封闭作用域
  • 可能解决依赖问题
  • 工作效率更高,重构方便
  • 解决命名冲突

js有模块化吗?

  • JS没有模块系统,不支持封闭的作用域和依赖管理
  • 没有标准库,没有文件系统和IO流API
  • 也没有包管理系统

那怎么实现js的模块化?

  • CommonJS规范,node是在v8引擎上的javascript运行时,作为服务端的,不能没有模块化的功能,于是就创造CommonJS规范,现在的node用的是CommonJS2。CommonJS2和CommonJS1的区别也在下面。属于动态同步加载
1
2
3
4
5
6
7
8
9
10
// CommonJS2也可以通过这种方式导出
module.exports = {
a: 1
}
// CommonJS1只能通过这种方式
exports.a = 1
// b.js
var module = require('./a.js')
module.a // -> log 1
  • AMD && CMD。AMD是RequireJS提出的,主要是依赖前置。CMD是SeaJS提出的,主要是就近依赖(只要用到才会导入),两者用法接近。属于异步加载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// file lib/greeting.js
define(function() {
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
return {
sayHello: function (lang) {
return helloInLang[lang];
}
};
});
// file hello.js
define(['./lib/greeting'], function(greeting) {
var phrase = greeting.sayHello('en');
document.write(phrase);
});
  • UMD。因为AMD中无法使用CommonJS,所以出来了一个UMD,可在UMD中同时使用AMD和CommonJS。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(function(define) {
define(function () {
var helloInLang = 'hello';
return {
sayHello: function (lang) {
return helloInLang[lang];
}
};
});
}(
typeof module === 'object' && module.exports && typeof define !== 'function' ?
function (factory) { module.exports = factory(); } :
define
));

CommonJS实现

  • 首先我们这里说的CommonJS是CommonJS2,我们需要了解到它的特性。
  • 模块引用时会找到绝对路径
  • 模块加载过会有缓存,把文件名作为key,module作为value
  • node实现模块化就是增加了一个闭包,并且自执行这个闭包(runInThisContext)
  • 模块加载时是同步操作
  • 默认会加后缀js,json,…
  • 不同模块下的变量不会相互冲突

闭包实现(其实CommonJS中每个模块都是一个闭包,所以里面的变量互不影响)

  • 我们可以在vscode中创建一个arguments.js项目
1
2
//arguments就是参数列表
console.log(arguments)
  • 此时在node环境下执行该文件,就会输出如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{ '0': {},
'1':
{ [Function: require]
resolve: { [Function: resolve] paths: [Function: paths] },
main:
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/chenxufeng/Desktop/笔记/node/arguments.js',
loaded: false,
children: [],
paths: [Array] },
extensions: { '.js': [Function], '.json': [Function], '.node': [Function] },
cache: { '/Users/chenxufeng/Desktop/笔记/node/arguments.js': [Object] } },
'2':
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/chenxufeng/Desktop/笔记/node/arguments.js',
loaded: false,
children: [],
paths:
[ '/Users/chenxufeng/Desktop/笔记/node/node_modules',
'/Users/chenxufeng/Desktop/笔记/node_modules',
'/Users/chenxufeng/Desktop/node_modules',
'/Users/chenxufeng/node_modules',
'/Users/node_modules',
'/node_modules' ] },
'3': '/Users/chenxufeng/Desktop/笔记/node/arguments.js',
'4': '/Users/chenxufeng/Desktop/笔记/node' }
  • 其实每个模块外面都包了这么一层闭包,所以外面的require才能获取到module.exports的值
1
2
3
4
5
6
7
8
9
10
11
12
13
//exports内存中指向的就是module.exports指向的那块空间
//require一个方法
//Module模块类
//__filename该文件绝对路径
//__dirname该文件父文件夹的绝对路径
(function(exports,require,Module,__filename,__dirname){
module.exports = exports = this = {}
//文件中的所有代码
//不能改变exports指向,因为返回的是module.exports,所以是个{}
return module.exports
})

所以我们require的时候其实就相当于执行了这么一个闭包,然后返回的就是我们的module.exports

require是怎么样的?

  • 每个模块都会带一个require方法
  • 动态加载(v8执行到这一步才会去加载此模块)
  • 不同模块的类别,有不同的加载方式,一般有三种常用后缀
    • 后缀名为.js的JavaScript脚本文件,需要先读入内存再运行
    • 后缀名为.json的JSON文件,fs 读入内存 转化成JSON对象
    • 后缀名为.node的经过编译后的二进制C/C++扩展模块文件,可以直接使用
  • 查找第三方模块
    • 如果require函数只指定名称则视为从node_modules下面加载文件,这样的话你可以移动模块而不需要修改引用的模块路径。
    • 第三方模块的查询路径包括module.paths和全局目录。

流程图

代码实现

下面我通过步骤讲解require整个的一个实现

根据路径找是否有缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//require方法
function req(moduleId){
//解析绝对路径的方法,返回一个绝对路径
let p = Module._resolveFileName(moduleId)
//查看是否有缓存
if(Module._catcheModule[p]){
//有缓存直接返回对应模块的exports
return Module._catcheModule[p].exports
}
//没有缓存就生成一个
let module = new Module(p)
//把他放入缓存中
Module._catcheModule[p] = module
//加载模块
module.exports = module.load(p)
return module.exports
}

上面有很多方法都还没有,不急,我们慢慢实现

创建Module类,并添加_resolveFileName_catcheModule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//node原生的模块,用来读写文件(fileSystem)
let fs = require('fs')
//node原生的模块,用来解析文件路径
let path = require('path')
//Module类,就相当于我们的模块(因为node环境不支持es6的class,这里用function)
function Module(p){
//当前模块的标识
this.id = p
//没个模块都有一个exports属性
this.exports = {}
//这个模块默认没有加载完
this.loaded = false
//模块加载方法(这个我们到时候再实现)
this.load = function(filepath){
//判断文件是json还是 node还是js
let ext = path.extname(filepath)
//返回一个exports
return Module._extensions[ext](this)
}
}
//以绝对路径为key存储一个module
Module._catcheModule = {}
// 解析绝对路径的方法,返回一个绝对路径
Module._resolveFileName = function(moduleId){
//获取moduleId的绝对路径
let p = path.resolve(moduleId)
try{
//同步地测试 path 指定的文件或目录的用户权限
fs.accessSync(p)
return p
}catch(e){
console.log(e)
}
}

此时会有一个问题,如果我们没有传文件后缀,就会读取不到

给Module添加一个加载策略,并且在_resolveFileName中再加点东西

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//所有的加载策略
Module._extensions = {
'.js': function(module){
//每个文件的加载逻辑不一样,这个我们后面再写
},
'.json': function(module){
},
'.node': 'xxx',
}
Module._resolveFileName = function(moduleId){
//对象中所有的key做成一个数组[]
let arr = Object.keys(Module._extensions)
for(let i=0;i<arr.length;i++){
let file = p+arr[i]
//因为整个模块读取是个同步过程,所以得用sync,这里判断有没有这个文件存在
try{
fs.accessSync(file)
return p
}catch(e){
console.log(e)
}
}
}

此时,我们能够找到文件的绝对路径,并把他丢给Module实例上的load方法

load方法实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//node原生的模块,用来读写文件(fileSystem)
let fs = require('fs')
//node原生的模块,用来解析文件路径
let path = require('path')
//提供了一系列 API 用于在 V8 虚拟机环境中编译和运行代码。
let vm = require('vm')
//Module类,就相当于我们的模块(因为node环境不支持es6的class,这里用function)
function Module(p){
//当前模块的标识
this.id = p
//没个模块都有一个exports属性
this.exports = {}
//这个模块默认没有加载完
this.loaded = false
//模块加载方法
this.load = function(filepath){
//判断文件后缀是json还是 node还是js
let ext = path.extname(filepath)
return Module._extensions[ext](this)
}
}
//js文件加载的包装类
Module._wrapper = ['(function(exports,require,module,__dirname,__filename){','\n})']
//所有的加载策略
Module._extensions = {
//这里的module参数是就是Module的实例
'.js': function(module){
let fn = Module._wrapper[0] + fs.readFileSync(module.id,'utf8') + Module._wrapper[1]
//执行包装后的方法 把js文件中的导出引入module的exports中
//模块中的this === module.exports === {} exports也只是module.exports的别名
//runInThisContext:虚拟机会产生一个干净的作用域来跑其中的代码,类似于沙箱sandbox
vm.runInThisContext(fn).call(module.exports,module.exports,req,module)
return module.exports
},
'.json': function(module){
//同步读取文件中的内容并把它转为JSON对象
return JSON.parse(fs.readFileSync(module.id,'utf8'))
},
'.node': 'xxx',
}

此时我们的代码已经全部完成

  • 我们随便找个文件试一下,当然如果是vscode下的话,req的路径参数需要在根目录下,这是一个坑。
  • 如果是vscode,就可以下一个插件Code Runner,可在vscode右键直接运行js文件,在node环境中。
  • 我们拿之前的arguments.js来实验

  • 成功输出!!

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
//node原生的模块,用来读写文件(fileSystem)
let fs = require('fs')
//node原生的模块,用来解析文件路径
let path = require('path')
//提供了一系列 API 用于在 V8 虚拟机环境中编译和运行代码。
let vm = require('vm')
//Module类,就相当于我们的模块(因为node环境不支持es6的class,这里用function)
function Module(p){
//当前模块的标识
this.id = p
//没个模块都有一个exports属性
this.exports = {}
//这个模块默认没有加载完
this.loaded = false
//模块加载方法
this.load = function(filepath){
//判断文件是json还是 node还是js
let ext = path.extname(filepath)
return Module._extensions[ext](this)
}
}
//js文件加载的包装类
Module._wrapper = ['(function(exports,require,module,__dirname,__filename){','\n})']
//所有的加载策略
Module._extensions = {
'.js': function(module){
let fn = Module._wrapper[0] + fs.readFileSync(module.id,'utf8') + Module._wrapper[1]
//执行包装后的方法 把js文件中的导出引入module的exports中
//模块中的this === module.exports === {} exports也只是module.exports的别名
vm.runInThisContext(fn).call(module.exports,module.exports,req,module)
return module.exports
},
'.json': function(module){
return JSON.parse(fs.readFileSync(module.id,'utf8'))
},
'.node': 'xxx',
}
//以绝对路径为key存储一个module
Module._catcheModule = {}
// 解析绝对路径的方法,返回一个绝对路径
Module._resolveFileName = function(moduleId){
let p = path.resolve(moduleId)
try{
fs.accessSync(p)
return p
}catch(e){
console.log(e)
}
//对象中所有的key做成一个数组[]
let arr = Object.keys(Module._extensions)
for(let i=0;i<arr.length;i++){
let file = p+arr[i]
//因为整个模块读取是个同步过程,所以得用sync,这里判断有没有这个文件存在
try{
fs.accessSync(file)
return file
}catch(e){
console.log(e)
}
}
}
//require方法
function req(moduleId){
let p = Module._resolveFileName(moduleId)
if(Module._catcheModule[p]){
//模块已存在
return Module._catcheModule[p].exports
}
//没有缓存就生成一个
let module = new Module(p)
Module._catcheModule[p] = module
//加载模块
module.exports = module.load(p)
return module.exports
}

React服务端渲染+pm2自动化部署

15315912157248fdea6bb73.jpeg

本文是直接着手SSR部分的并通过实战讲述自己遇到的一些问题和方案,需要大家有一定的React,node和webpack基础能力。skr,skr。

服务端渲染

Server Slide Rendering服务端渲染,又简写为SSR,他一般被用在我们的SPA(Single-Page Application),即单页应用。

为什么要用SSR?

首先我们需要知道SSR对于SPA的好处优势是什么。

  • 更好的SEO(Search Engine Optimization)SEO是搜索引擎优化,简而言之就是针对百度这些搜索引擎,可以让他们搜索到我们的应用。这里可能会有误区,就是我也可以在index.html上写SEO,为什么会不起作用。因为React、Vue的原理是客户端渲染,通过浏览器去加载js、css,有一个时间上的延迟,而搜索引擎不会管你的延迟,他就觉得你如果没加载出来就是没有的,所以是搜不到的。
  • 解决一开始的白屏渲染,上面讲了React的渲染原理,而SSR服务端渲染是通过服务端请求数据,因为服务端内网的请求快,性能好所以会更快的加载所有的文件,最后把下载渲染后的页面返回给客户端。

上面提到了服务端渲染和客户端渲染,那么它们的区别是什么呢?

客户端渲染路线:

  1. 请求一个html
  2. 服务端返回一个html
  3. 浏览器下载html里面的js/css文件
  4. 等待js文件下载完成
  5. 等待js加载并初始化完成
  6. js代码终于可以运行,由js代码向后端请求数据( ajax/fetch )
  7. 等待后端数据返回
  8. react-dom( 客户端 )从无到完整地,把数据渲染为响应页面

服务端渲染路线:

  1. 请求一个html
  2. 服务端请求数据( 内网请求快 )
  3. 服务器初始渲染(服务端性能好,较快)
  4. 服务端返回已经有正确内容的页面
  5. 客户端请求js/css文件
  6. 等待js文件下载完成
  7. 等待js加载并初始化完成
  8. react-dom( 客户端 )把剩下一部分渲染完成( 内容小,渲染快 )

其主要区别就在于,客户端从无到有的渲染,服务端是先在服务端渲染一部分,在再客户端渲染一小部分

我们怎么去做服务端渲染?

我们这里是用express框架,node做中间层进行服务端渲染。通过将首页进行同构处理,让服务端,通过调用ReactDOMServer.renderToNodeStream方法把Virtual DOM转换成HTML字符串返回给客户端,从而达到服务端渲染的目的。

这里项目起步是已经做完前端和后端,是把已经写好的React Demo直接拿来用

服务端渲染开始

既然是首页SSR,首先我们要把首页对应的index.js抽离出来放入我们服务端对应的server.js,那么index.js中组件对应的静态css和js文件我们需要打包出来。

用webpack打包文件到build文件夹

我们来运行npm run build

我们可以看到两个重要的文件夹,一个是js文件夹,一个是css文件夹,他就是我们项目的js和css静态资源文件

将打包后的build文件能在服务端server.js中访问到

因为是服务端,我们需要用到express

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import express from 'express'
import reducers from '../src/reducer';
import userRouter from './routes/user'
import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import model from './model'
import path from 'path'
import https from 'http'
import socketIo from 'socket.io'
const Chat = model.getModel('chat')
//新建app
const app = express()
//work with express
const server = https.Server(app)
const io = socketIo(server)
io.on('connection',function(socket){
socket.on('sendmsg',function(data){
let {from,to,msg} = data
let chatid = [from,to].sort().join('_')
Chat.create({chatid,from,to,content:msg},function(e,d){
io.emit('recvmsg',Object.assign({},d._doc))
})
// console.log(data)
// //广播给全局
// io.emit('recvmsg',data)
})
})
app.use(cookieParser())
app.use(bodyParser.json())
app.use('/user',userRouter)
app.use(function(req,res,next){
if(req.url.startsWith('/user/') || req.url.startsWith('/static/')){
return next()
}
//如果访问url根路径是user或者static就返回打包后的主页面
return res.sendFile(path.resolve('build/index.html'))
})
//映射build文件路径,项目上要使用
app.use('/',express.static(path.resolve('build')))
server.listen(8088, function () {
console.log('开启成功')
})
  • 主要看上面的app.use('/',express.static(path.resolve('build')))res.sendFile(path.resolve('build/index.html'))这两段代码。
  • 他们把打包后的主页放入服务端代码中返回给客户端。
  • 因为上面我用了import代码,所以我们在开发环境中需要用到babel-cli里的babel-node来编译。
  • 安装npm --registry https://registry.npm.taobao.org i babel-cli -S`,大家如果觉得这样切换源麻烦,可以下个nrm,360度无死角切换各种源,好用!
  • 我们需要修改package.json的启动服务器的npm scripts"server": "NODE_ENV=test nodemon --exec babel-node server/server.js"
  • cross-env跨平台设置node环境变量的插件。
  • nodemon和supervisor一样是watch服务端文件,只要一改变就会重新运行,相当于热重载。nodemon更轻量
  • 最后我们来跑一下npm run server,就能看到服务端跑起来了。

ReactDOMServer.renderToString/ReactDOMServer.renderToNodeStream

  • 这里我们先讲一下在浏览器中React.createElement把React的类进行实例化,实例化后的组件可以进行mount,最后通过React.render渲染到我们的客户端浏览器界面。
  • 而在服务器中我们可以通过 renderToString或者renderToNodeStream方法把React实例化的组件,直接渲染生成html标签。那么这俩个有什么区别呢?
  • renderToNodeStream是React 16最新发布的东西,它支持直接渲染到节点流。渲染到流可以减少你的内容的第一个字节(TTFB)的时间,在文档的下一部分生成之前,将文档的开头至结尾发送到浏览器。 当内容从服务器流式传输时,浏览器将开始解析HTML文档。速度是renderToString的三倍,所以我们在这里使用renderToNodeStream
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import express from 'express'
import React from 'react'
import {renderToStaticMarkup,renderToNodeStream} from 'react-dom/server'
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import {StaticRouter} from 'react-router-dom'
import {
createStore,
applyMiddleware,
//组合函数用的
compose
} from 'redux';
import App from '../src/App'
import reducers from '../src/reducer';
import userRouter from './routes/user'
import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import model from './model'
import path from 'path'
import https from 'http'
import socketIo from 'socket.io'
const Chat = model.getModel('chat')
//新建app
const app = express()
//work with express
const server = https.Server(app)
const io = socketIo(server)
io.on('connection',function(socket){
socket.on('sendmsg',function(data){
let {from,to,msg} = data
let chatid = [from,to].sort().join('_')
Chat.create({chatid,from,to,content:msg},function(e,d){
io.emit('recvmsg',Object.assign({},d._doc))
})
// console.log(data)
// //广播给全局
// io.emit('recvmsg',data)
})
})
app.use(cookieParser())
app.use(bodyParser.json())
app.use('/user',userRouter)
app.use(function(req,res,next){
if(req.url.startsWith('/user/') || req.url.startsWith('/static/')){
return next()
}
const store = createStore(reducers,compose(
applyMiddleware(thunk)
))
//这个 context 对象包含了渲染的结果
let context = {}
const root = (<Provider store={store}>
<StaticRouter
location={req.url}
context={context}
>
<App></App>
</StaticRouter>
</Provider>)
const markupStream = renderToNodeStream(root)
markupStream.pipe(res,{end:false})
markupStream.on('end',()=>{
res.end()
})
})
//映射build文件路径,项目上要使用
app.use('/',express.static(path.resolve('build')))
server.listen(8088, function () {
console.log('开启成功')
})

此时将服务端renderToNodeStream后的代码返回给前端,但是这个时候还是不行,我们执行一下npm run server,可以看到报错了。

css-modules-require-hook/asset-require-hook

css-modules-require-hook

  • 因为服务端此时不认识我们的css文件,我们需要安装一个包,来让服务端处理css文件。
  • npm i css-modules-require-hook -S安装在生产环境下。
  • 在项目根目录创建一个crmh.conf.js钩子文件进行配置,看下图。

写入代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// css-modules-require-hook
module.exports = {
generateScopedName: '[name]__[local]___[hash:base64:5]',
//下面的代码在本项目中暂时用不到,但是以下配置在我另一个项目中有用到,我来讲一下他的配置
//扩展名
//extensions: ['.scss','.css'],
//钩子,这里主要做一些预处理的scss或者less文件
//preprocessCss: (data, filename) =>
// require('node-sass').renderSync({
// data,
// file: filename
// }).css,
//是否导出css类名,主要用于CSSModule
//camelCase: true,
};
  • 修改我们的server.js文件,添加import csshook from 'css-modules-require-hook/preset',注意⚠️一定要把这行代码放在导入App模块之前
1
2
3
import csshook from 'css-modules-require-hook/preset'
//我们的首页入口
import App from '../src/App'

此时在运行server.js,会发现又报了个错。

asset-require-hook

  • 这个错误是因为服务端没有处理前端代码需要的图片
  • 需要安装npm i asset-require-hook -S,这个插件用来让服务端处理图片,注意⚠️前提是客户端代码,引用图片都需要require
  • server.js写入代码
1
2
3
4
5
6
7
//解决图片问题,客户端代码引用图片都需要require
import assethook from 'asset-require-hook'
assethook({
extensions:['png'],
//图片大小下于10000的图片会直接base64编码
limit: 10000
})

运行之后发现又报错了,这个很简单,因为我们只有image的引用名字,却没有地址

  • 所以此时要在外面加个壳,把之前build之后的静态js、css文件引入进去,添加html、head这些标签。来看完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import 'babel-polyfill'
import express from 'express'
import React from 'react'
import {renderToString,renderToStaticMarkup,renderToNodeStream} from 'react-dom/server'
//引入css文件和js文件
import staticPath from '../build/asset-manifest.json'
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import {StaticRouter} from 'react-router-dom'
import {
createStore,
applyMiddleware,
//组合函数用的
compose
} from 'redux';
//解决服务端渲染的图片问题 必须放在App之前
import csshook from 'css-modules-require-hook/preset'
//解决图片问题,需要require
import assethook from 'asset-require-hook'
assethook({
extensions:['png'],
limit: 10000
})
import App from '../src/App'
import reducers from '../src/reducer';
import userRouter from './routes/user'
import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import model from './model'
import path from 'path'
import https from 'http'
import socketIo from 'socket.io'
const Chat = model.getModel('chat')
//新建app
const app = express()
//work with express
const server = https.Server(app)
const io = socketIo(server)
io.on('connection',function(socket){
socket.on('sendmsg',function(data){
let {from,to,msg} = data
let chatid = [from,to].sort().join('_')
Chat.create({chatid,from,to,content:msg},function(e,d){
io.emit('recvmsg',Object.assign({},d._doc))
})
// console.log(data)
// //广播给全局
// io.emit('recvmsg',data)
})
})
app.use(cookieParser())
app.use(bodyParser.json())
app.use('/user',userRouter)
app.use(function(req,res,next){
if(req.url.startsWith('/user/') || req.url.startsWith('/static/')){
return next()
}
const store = createStore(reducers,compose(
applyMiddleware(thunk)
))
const obj = {
'/msg':'聊天消息列表',
'/me':'个人中心列表'
}
//这个 context 对象包含了渲染的结果
let context = {}
res.write(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta name="description" content="${obj[req.url]}"/>
<meta name="keywords" content="SSR">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="stylesheet" href="/${staticPath['main.css']}">
<title>React App</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root">`)
const root = (<Provider store={store}>
<StaticRouter
location={req.url}
context={context}
>
<App></App>
</StaticRouter>
</Provider>)
const markupStream = renderToNodeStream(root)
markupStream.pipe(res,{end:false})
markupStream.on('end',()=>{
res.write(`</div>
<script src="/${staticPath['main.js']}"></script>
</body>
</html>`)
res.end()
})
})
//映射build文件路径,项目上要使用
app.use('/',express.static(path.resolve('build')))
server.listen(8088, function () {
console.log('开启成功')
})
  • 这个时候我们可以在html标签里加上SEO的meta<meta name="keywords" content="SSR">
  • 最后还要把客户端的index.js文件中的渲染机制改成hydrate,不用render,他们之间的区别可以看这个(传送门☞render !== hydrate
1
2
3
4
5
6
7
8
ReactDOM.hydrate(
(<Provider store={store}>
<BrowserRouter>
<App></App>
</BrowserRouter>
</Provider>),
document.getElementById('root')
)

到此为止我们开发模式下的SSR搭建完毕,接下来生产模式的坑我来讲一下。

生产环境SSR准备

我们上面所讲的只是开发模式下的SSR,因为我们是通过babel-node编译jsx和es6代码的,只要一脱离babel-node就会全错,所以我们需要webpack打包服务端代码

我们需要创建一个webserver.config.js,用来打包server的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
const path = require('path'),
fs = require('fs'),
webpack = require('webpack'),
autoprefixer = require('autoprefixer'),
HtmlWebpackPlugin = require('html-webpack-plugin'),
ExtractTextPlugin = require('extract-text-webpack-plugin')
cssFilename = 'static/css/[name].[contenthash:8].css';
CleanWebpackPlugin = require('clean-webpack-plugin');
nodeExternals = require('webpack-node-externals');
serverConfig = {
context: path.resolve(__dirname, '..'),
entry: {server: './server/server'},
output: {
libraryTarget: 'commonjs2',
path: path.resolve(__dirname, '../build/server'),
filename: 'static/js/[name].js',
chunkFilename: 'static/js/chunk.[name].js'
},
// target: 'node' 指明构建出的代码是要运行在node环境里.
// 不把 Node.js 内置的模块打包进输出文件中,例如 fs net 模块等
target: 'node',
//指定在node环境中是否要这些模块
node: {
__filename: true,
__dirname: true,
// module:true
},
module: {
loaders: [{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader?cacheDirectory=true',
options: {
presets: ['es2015', 'react-app', 'stage-0'],
plugins: ['add-module-exports',
[
"import",
{
"libraryName": "antd-mobile",
"style": "css"
}
],"transform-decorators-legacy"]
},
},{
test: /\.css$/,
exclude: /node_modules|antd-mobile\.css/,
loader: ExtractTextPlugin.extract(
Object.assign(
{
fallback: {
loader: require.resolve('style-loader'),
options: {
hmr: false,
},
},
use: [
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
minimize: true,
modules: false,
localIdentName:"[name]-[local]-[hash:base64:8]",
// sourceMap: shouldUseSourceMap,
},
},
{
loader: require.resolve('postcss-loader'),
options: {
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
autoprefixer({
browsers: [
'>1%',
'last 4 versions',
'Firefox ESR',
'not ie < 9', // React doesn't support IE8 anyway
],
flexbox: 'no-2009',
}),
],
},
},
],
},
)
),
},
{
test: /\.css$/,
include: /node_modules|antd-mobile\.css/,
use: ExtractTextPlugin.extract({
fallback: require.resolve('style-loader'),
use: [{
loader: require.resolve('css-loader'),
options: {
modules:false
},
}]
})
}, {
test: /\.(jpg|png|gif|webp)$/,
loader: require.resolve('url-loader'),
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
},
}, {
test: /\.json$/,
loader: 'json-loader',
}]
},
// 不把 node_modules 目录下的第三方模块打包进输出文件中,
externals: [nodeExternals()],
resolve: {extensions: ['*', '.js', '.json', '.scss']},
plugins: [
new CleanWebpackPlugin(['../build/server']),
new webpack.optimize.OccurrenceOrderPlugin(),
//把第三方库从js文件中分离出来
new webpack.optimize.CommonsChunkPlugin({
//抽离相应chunk的共同node_module
minChunks(module) {
return /node_modules/.test(module.context);
},
//从要抽离的chunk中的子chunk抽离相同的模块
children: true,
//是否异步抽离公共模块,参数boolean||string
async: false,
}),
new webpack.optimize.CommonsChunkPlugin({
children:true,
//若参数是string即为抽离出来后的文件名
async: 'shine',
//最小打包的文件模块数,即要抽离的公共模块中的公共数,比如三个chunk只有1个用到就不算公共的
//若为Infinity,则会把webpack runtime的代码放入其中(webpack 不再自动抽离公共模块)
minChunks:2
}),
//压缩
new webpack.optimize.UglifyJsPlugin(),
//分离css文件
new ExtractTextPlugin({
filename: cssFilename,
}),
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
],
}
module.exports = serverConfig

重点⚠️

  • 指定target,打包出来的代码运行在哪里
  • 指定externals不要把node_modules包打包,因为此项目运行在服务端,直接用外面的node_modules就行。不然打包后会很大。
  • loader中用babel对js的处理

ok,现在来我们改一下package.json的npm scripts,添加一个packServer,顺便改一下build的scripts

1
2
3
4
5
6
7
8
9
10
"scripts": {
"clean": "rm -rf build/",
"dev": "node scripts/start.js",
"start": "cross-env NODE_ENV=development npm run server & npm run dev",
"build": "npm run clean && node scripts/build.js && npm run packServer",
"test": "nodemon scripts/test.js --env=jsdom",
"server": "cross-env NODE_ENV=test nodemon --exec babel-node server/server.js",
"gulp": "cross-env NODE_ENV=production gulp",
"packServer": "cross-env NODE_ENV=production webpack --config ./config/webserver.config.js"
},
  • packServer指定了生产环境,这在之后会用到。
  • build是先clean掉build文件夹,在去打包客户端的代码,打包完之后再去打包服务端的代码

那么到这里为止我们差不多可以自己试试了

  • npm run build,会生成打包后的build文件夹,里面包含了我们的服务端和客户端代码
  • 找到打包后的node文件运行它,在build/server/static/js目录下,可直接node文件启动。这就解决了我们生产环境下的问题。

pm2,服务器自动部署

现在我们要把我们的项目部署到服务器上,并用pm2守护进程。

  • 首先我们得有一台云服务器,这里我是在阿里云买的一台ubuntu 14.04
  • 需要一个已经备案后的域名,域名也可以在阿里云买。当然也可以不用,可以直接服务器地址访问。
  • ok让我们开始吧。

服务器部署

  • 在部署到服务器之前我们代码中还有些东西需要修改,修改mongod的连接地址.
1
2
3
const env = process.env.NODE_ENV || 'development'
//当生产环境时,需要改变mongodb的连接端口,根据你服务器的mongodb端口来,我这里是19999
const BASE_URL = env == 'development'?"mongodb://localhost:27017/chat":"mongodb://127.0.0.1:19999/chat";
  • 修改客户端socket.io的链接地址const socket = io('ws://host:port'),改成你自己的服务器地址和端口号
  • 我们需要将自己的项目上传至码云。这里我使用码云,主要是因为码云的私仓是免费的。
  • 我们需要进入服务器的ssh目录下复制id_rsa.pub里的公钥放在码云的ssh公钥中,可进入设置,具体看图

  • 我们也要把自己电脑上的ssh公钥在码云中设置,我这里是mac,在自己的用户目录下,可以按cmd+shift+.看隐藏文件(如果你设置过了,这一步就不要了)。
  • 服务器安装git,mongodb,pm2,nginx(如果服务器已经安装过了,就不需要了)
  • 需要开启mongodb
  • 我们在项目根目录新建一个ecosystem.json文件,这个文件是pm2的配置文件,具体的我就不说了,大家如果感兴趣可以去官网看看,(传送门☞pm2官网
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
"apps": [
{
//应用名称
"name": "chat",
//执行文件的路径
"script": "./build/server/static/js/server.js",
"env": {
"COMMON_VARIABLE": "true"
},
"env_production": {
"NODE_ENV": "production"
}
}
],
"deploy": {
"production": {
//服务器用户
"user": "xxx",
//服务器地址
"host": ["xxx"],
//服务器端口
"port": "xxx",
"ref": "origin/master",
//这里填你的项目git ssh
"repo": "xxx",
//服务器的存放项目路径
"path": "/www/chat/production",
"ssh_options": "StrictHostKeyChecking=no",
//钩子
"post-deploy": "npm --registry https://registry.npm.taobao.org install && npm run build && pm2 startOrRestart ecosystem.json --env production",
"env": {
//环境
"NODE_ENV": "production"
}
}
}
}
  • 在服务器新建项目目录新建/www/chat/文件夹。
  • 在本地电脑执行 pm2 deploy ecosystem.json production setup
  • 这里大家肯定会报错,这是我故意埋的坑,因为chat文件夹的权限不够,需要进入服务器的www文件夹,执行sudo chmod 777 chat
  • 进入服务器的.bashrc文件,注视掉上面的几行代码
  • source .bashrc重新载入一下.bashrc文件
  • 开启pm2服务 pm2 deploy ecosystem.json production
  • 这里可能有的人会报错,主要原因是本地电脑的pm2的权限问题,需要找到pm2文件夹,chmod 666 pm2
  • 如果上述问题都解决了最后会如图所示

  • 最后我们可以进入服务器,pm2 list,看到成功跑起来了

  • 如果应用在不断的重启,说明开启失败了,需要pm2 logs看看日志

  • 我们可以访问服务器地址:8088,并看到应用跑起来了

域名代理

  • 我们进入阿里云控制台解析自己的域名(传送门☞阿里云

  • 添加一条记录

  • 回到服务器,我们修改nginx配置文件,通过反向代理,让我们通过域名也可以访问他
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
upstream chat {
server 127.0.0.1:8088;
}
server {
listen 80;
server_name www.webman.vip;
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forward-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Nginx-Proxy true;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_pass http://chat;
proxy_redirect off;
}
# 静态文件地址
location ~* ^.+\.(jpg|jpeg|gif|png|ico|css|js|pdf|txt){
root /www/website/production/current/build;
}
}
  • 在服务器执行sudo nginx -s reload,重启nginx。此时我们就可以通过我们的域名地址访问到我们的应用了。

  • 这里可能访问会404,这个时候我们需要看一下我们服务器的防火墙,sudo vi /etc/iptables.up.rules,修改mongodb的对外端口,并且重启防火墙sudo iptables-restore < /etc/iptables.up.rules

1
2
-A INPUT -s 127.0.0.1 -p tcp --destination-port 8088 -m state --state NEW,ESTABLISHED -j ACCEPT
-A OUTPUT -d 127.0.0.1 -p tcp --source-port 8088 -m state --state ESTABLISHED -j ACCEPT
  • 查看阿里云控制台的安全组是否开了对应的端口

  • 最后最后!!!,终于成功了。可以点击链接查看一下。 走你!

  • 当然下次如果你想直接更新项目,可以在项目对应的路径提交到git上,然后再使用pm2 deploy ecosystem.json production即可在服务器上自动部署

渴望力量吗?少年!流的原理

流(stream),看一个人流不流逼,就看你对流的理解了

学习本无底,前进莫徬徨

今天跟大家分享的是node.js中的流(stream)。它的作用大家应该都在平常使用node的时候看到过,比如:

  • gulp中的pipe就是流的一种方法,通过可写流和可读流的配合,达到不占用多余缓存的一种读写方式。
  • express和koa中的res和req也是流,res是可写流,req是可读流,他们都是通过封装node中的net模块的socket(双工流,即可写、可读流)而来的。
  • 。。。

可能很多时候大家都知道怎么用,但不了解它的原理,很尴尬,就像这样

何谓流?

  • 流是一组有序的,有起点和终点的字节数据传输手段。
  • 它不关心文件的整体内容,只关注是否从文件中读到了数据,以及读到数据之后的处理。
  • 流是一个抽象接口,被 Node 中的很多对象所实现。比如HTTP 服务器request和response对象都是流。
  • 流被分为Readable(可读流)、Writable(可写流)、Duplex(双工流)、Transform(转换流)

流中的是什么?

  • 二进制模式:每个分块都是buffer、string对象。
  • 对象模式:流内部处理的是一系列普通对象。

可读流

可读流分为flowingpaused两种模式

参数

  • path:读取的文件的路径
  • option:
    • highWaterMark:水位线,一次可读的字节,一般默认是64k
    • flags:标识,打开文件要做的操作,默认是r
    • encoding:编码,默认为buffer
    • start:开始读取的索引位置
    • end:结束读取的索引位置(包括结束位置)
    • autoClose:读取完毕是否关闭,默认为true
1
2
3
4
5
6
7
8
9
10
let ReadStream = require('./ReadStream')
//读取的时候默认读64k
let rs = new ReadStream('./a.txt',{
highWaterMark: 2,//一次读的字节 默认64k
flags: 'r', //标示 r为读 w为写
autoClose: true, //默认读取完毕后自动关闭
start: 0,
end: 5, //流是闭合区间包start,也包end 默认是读完
encoding: 'utf8' //默认编码是buffer
})

方法

data:切换到流动模式,可以流出数据

1
2
3
rs.on('data', function (data) {
console.log(data);
});

open:流打开文件的时候会触发此监听

1
2
3
rs.on('open', function () {
console.log('文件被打开');
});

error:流出错的时候,监听错误信息

1
2
3
rs.on('error', function (err) {
console.log(err);
});

end:流读取完成,触发end

1
2
3
rs.on('end', function (err) {
console.log('读取完成');
});

close:关闭流,触发

1
2
3
rs.on('close', function (err) {
console.log('关闭');
});

pause:暂停流(改变流的flowing,不读取数据了);resume:恢复流(改变流的flowing,继续读取数据)

1
2
3
4
5
6
7
8
//流通过一次后,停止流动,过了2s后再动
rs.on('data', function (data) {
rs.pause();
console.log(data);
});
setTimeout(function () {
rs.resume();
},2000);

fs.read():可读流底层调用的就是这个方法,最原生的读方法

1
2
3
4
5
6
7
8
9
//fd文件描述符,一般通过fs.open中获取
//buffer是读取后的数据放入的缓存目标
//0,从buffer的0位置开始放入
//BUFFER_SIZE,每次放BUFFER_SIZE这么长的长度
//index,每次从文件的index的位置开始读
//bytesRead,真实读到的个数
fs.read(fd,buffer,0,BUFFER_SIZE,index,function(err,bytesRead){
})

那让我们自己来实现一个可爱的读流吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
let fs = require('fs')
let EventEmitter = require('events')
class ReadStream extends EventEmitter{
constructor(path,options = {}){
super()
this.path = path
this.highWaterMark = options.highWaterMark || 64*1024
this.flags = options.flags || 'r'
this.start = options.start || 0
this.pos = this.start //会随着读取的位置改变
this.autoClose = options.autoClose || true
this.end = options.end || null
//默认null就是buffer
this.encoding = options.encoding || null
//参数的问题
this.flowing = null //非流动模式
//创建个buffer用来存储每次读出来的数据
this.buffer = Buffer.alloc(this.highWaterMark)
//打开这个文件
this.open()
//此方法默认同步调用 每次设置on监听事件时都会调用之前所有的newListener事件
this.on('newListener',(type)=>{// 等待着他监听data事件
if(type === 'data'){
this.flowing = true
//开始读取 客户已经监听的data事件
this.read()
}
})
}
//默认第一次调用read方法时fd还没获取 所以不能直接读
read(){
if(typeof this.fd != 'number'){
//等待着触发open事件后fd肯定拿到了 再去执行read方法
return this.once('open',()=>{this.read()})
}
//每次读的时候都要判断一下下次读几个 如果没有end就根据highWaterMark来(读所有的) 如果有且大于highWaterMark就根据highWaterMark来 如果小于highWaterMark就根据end来
let howMuchToRead = this.end?Math.min(this.end - this.pos + 1,this.highWaterMark):this.highWaterMark
fs.read(this.fd,this.buffer,0,howMuchToRead,this.pos,(err,byteRead)=>{
this.pos += byteRead
let b = this.encoding?this.buffer.slice(0,byteRead).toString(this.encoding):this.buffer.slice(0,byteRead)
this.emit('data',b)
//如果读取到的数量和highWaterMark一样 说明还得继续读
if((byteRead === this.highWaterMark)&&this.flowing){
this.read()
}
if(byteRead < this.highWaterMark){
this.emit('end')
this.destory()
}
})
}
destory(){
if(typeof this.fd != 'number'){
return this.emit('close')
}
//如果文件被打开过 就关闭文件并且触发close事件
fs.close(this.fd,()=>{
this.emit('close')
})
}
pause(){
this.flowing = false
}
resume(){
this.flowing = true
this.read()
}
open(){
//fd表示的就是当前this.path的这个文件,从3开始(number类型)
fs.open(this.path,this.flags,(err,fd)=>{
//有可能fd这个文件不存在 需要做处理
if(err){
//如果有自动关闭 则帮他销毁
if(this.autoClose){
//销毁(关闭文件,触发关闭文件事件)
this.destory()
}
//如果有错误 就会触发error事件
this.emit('error',err)
return
}
//保存文件描述符
this.fd = fd
//当文件打开成功时触发open事件
this.emit('open',this.fd)
})
}
}

Readable

这个方法是可读流的一种暂停模式,他的模式可以参考为读流是往水杯倒水的人,Readable是喝水的人,他们之间存在着一种联系,只要Readable喝掉一点水,读流就会继续往里倒

Readable是什么?

  • 他会在刚开始监听Readable的时候就触发流的,此时流就会读取一次数据,之后流会监听,如果有人读过流(喝过水),并且减少,就会再去读一次(倒点水)
  • 主要可以用来做行读取器(LineReader)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let fs = require('fs')
let read = require('./ReadableStream')
let rs = fs.createReadStream('./a.txt', {
//每次读7个
highWaterMark: 7
})
//如果读流第一次全部读下来并且小于highWaterMark,就会再读一次(再触发一次readable事件)
//如果rs.read()不加参数,一次性读完,会从缓存区再读一次,为null
//如果readable每次都刚好读完(即rs.read()的参数刚好和highWaterMark相等),就会一直触发readable事件,如果最后不足他想喝的数,他就会先触发一次null,最后把剩下的喝完
//一开始缓存区为0的时候也会默认调一次readable事件
rs.on('readable', () => {
let result = rs.read(2)
console.log(result)
})

实战:行读取器(平常我们的文件可能有回车、换行,此时如果要每次想读一行的数据,就得用到readable)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
let EventEmitter = require('events')
//如果要将内容全部读出就用on('data'),精确读取就用on('readable')
class LineReader extends EventEmitter {
constructor(path) {
super()
this.rs = fs.createReadStream(path)
//回车符的十六进制
let RETURN = 0x0d
//换行符的十六进制
let LINE = 0x0a
let arr = []
this.on('newListener', (type) => {
if (type === 'newLine') {
this.rs.on('readable', () => {
let char
//每次读一个,当读完的时候会返回null,终止循环
while (char = this.rs.read(1)) {
switch (char[0]) {
case RETURN:
break;
//Mac下只有换行符,windows下是回车符和换行符,需要根据不同的转换。因为我这里是Mac
case LINE:
//如果是换行符就把数组转换为字符串
let r = Buffer.from(arr).toString('utf8')
//把数组清空
arr.length = 0
//触发newLine事件,把得到的一行数据输出
this.emit('newLine', r)
break;
default:
//如果不是换行符,就放入数组中
arr.push(char[0])
}
}
})
}
})
//以上只能取出之前的换行符前的代码,最后一行的后面没有换行符,所以需要特殊处理。当读流读完需要触发end事件时
this.rs.on('end', () => {
//取出最后一行数据,转成字符串
let r = Buffer.from(arr).toString('utf8')
arr.length = 0
this.emit('newLine', r)
})
}
}
let lineReader = new LineReader('./a.txt')
lineReader.on('newLine', function (data) {
console.log(data)
})

那么Readable到底是怎样的存在呢?我们接下来实现他的源码,看看内部到底怎么回事

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
let fs = require('fs')
let EventEmitter = require('events')
class ReadStream extends EventEmitter{
constructor(path,options = {}){
super()
this.path = path
this.highWaterMark = options.highWaterMark || 64*1024
this.flags = options.flags || 'r'
this.start = options.start || 0
this.pos = this.start //会随着读取的位置改变
this.autoClose = options.autoClose || true
this.end = options.end || null
//默认null就是buffer
this.encoding = options.encoding || null
//参数的问题
this.reading = false //非流动模式
//创建个buffer用来存储每次读出来的数据
this.buffers = []
//缓存区长度
this.len = 0
//是否要触发readable事件
this.emittedReadable = false
//触发open获取文件的fd标识符
this.open()
//此方法默认同步调用 每次设置on监听事件时都会调用之前所有的newListener事件
this.on('newListener',(type)=>{// 等待着他监听data事件
if(type === 'readable'){
//开始读取 客户已经监听的data事件
this.read()
}
})
}
//readable真正的源码中的方法,计算出和n最接近的2的幂次数
computeNewHighWaterMark(n) {
n--;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
n++;
return n;
}
read(n){
//当读的数量大于水平线,会通过取2的幂次取比他大和最接近的数
if(this.len < n){
this.highWaterMark = this.computeNewHighWaterMark(n)
//重新触发readbale的callback,所以第一次会触发null
this.emittedReadable = true
//重新读新的水位线
this._read()
}
//真正读取到的
let buffer = null
//说明缓存里有这么多,取出来
if(n>0 && n<=this.len){
//定义一个buffer
buffer = Buffer.alloc(n)
let buf
let flag = true
let index = 0
//[buffer<1,2,3,4>,buffer<1,2,3,4>,buffer<1,2,3,4>]
//每次取出缓存前的第一个buffer
while(flag && (buf = this.buffers.shift())){
for(let i=0;i<buf.length;i++){
//把取出的一个buffer中的数据放入新定义的buffer中
buffer[index++] = buf[i]
//当buffer的长度和n(参数)长度一样时,停止循环
if(index === n){
flag = false
//维护缓存,因为可能缓存中的buffer长度大于n,当取出n的长度时,还会剩下其余的buffer,我们需要切割buf并且放到缓存数组之前
this.len -= n
let r = buf.slice(i+1)
if(r.length){
this.buffers.unshift(r)
}
break
}
}
}
}
//如果缓存区没有东西,等会读完需要触发readable事件
//这里会有一种状况,就是如果每次Readable读取的数量正好等于highWaterMark(流读取到缓存的长度),就会每次都等于0,每次都触发Readable事件,就会每次读,读到没有为止,最后还会触发一下null
if(this.len === 0){
this.emittedReadable = true
}
if(this.len < this.highWaterMark){
//默认,一开始的时候开始读取
if(!this.reading){
this.reading = true
//真正多读取操作
this._read()
}
}
return buffer&&buffer.toString()
}
_read(){
if(typeof this.fd != 'number'){
//等待着触发open事件后fd肯定拿到了 再去执行read方法
return this.once('open',()=>{this._read()})
}
//先读这么多buffer
let buffer = Buffer.alloc(this.highWaterMark)
fs.read(this.fd,buffer,0,buffer.length,this.pos,(err,byteRead)=>{
if(byteRead > 0){
//当第一次读到数据后,改变reading的状态,如果触发read事件,可能还会在触发第二次_read
this.reading = false
//每次读到数据增加缓存取得长度
this.len += byteRead
//每次读取之后,会增加读取的文件的读取开始位置
this.pos += byteRead
//将读到的buffer放入缓存区buffers中
this.buffers.push(buffer.slice(0,byteRead))
//触发readable
if(this.emittedReadable){
this.emittedReadable = false
//可以读取了,默认开始的时候杯子填满了
this.emit('readable')
}
}else{
//没读到就出发end事件
this.emit('end')
}
})
}
destory(){
if(typeof this.fd != 'number'){
return this.emit('close')
}
//如果文件被打开过 就关闭文件并且触发close事件
fs.close(this.fd,()=>{
this.emit('close')
})
}
open(){
//fd表示的就是当前this.path的这个文件,从3开始(number类型)
fs.open(this.path,this.flags,(err,fd)=>{
//有可能fd这个文件不存在 需要做处理
if(err){
//如果有自动关闭 则帮他销毁
if(this.autoClose){
//销毁(关闭文件,触发关闭文件事件)
this.destory()
}
//如果有错误 就会触发error事件
this.emit('error',err)
return
}
//保存文件描述符
this.fd = fd
//当文件打开成功时触发open事件
this.emit('open',this.fd)
})
}
}
  • Readable和读流的data的区别就是,Readable可以控制自己从缓存区读多少和控制读的次数,而data是每次读取都清空缓存,读多少输出多少
  • 我们可以看一下下面这个例子
1
2
3
4
5
6
7
8
9
let rs = fs.createReadStream('./a.txt')
rs.on('data',(data)=>{
console.log(data)
})
//因为上面的data事件把数据读了,清空缓存区。所以导致下面的readable读出为null
rs.on('readable',()=>{
let result = r.read(1)
console.log(result)
})

自定义可读流

因为createReadStream内部调用了ReadStream类,ReadStream又实现了Readable接口,ReadStream实现了_read()方法,所以我们通过自定义一个类继承stream模块的Readable,并在原型上自定义一个_read()就可以自定义自己的可读流

1
2
3
4
5
6
7
8
9
10
11
let { Readable } = require('stream')
class MyRead extends Readable{
//流需要一个_read方法,方法中push什么,外面就接收什么
_read(){
//push方法就是上面_read方法中的push一样,把数据放入缓存区中
this.push('100')
//如果push了null就表示没有东西可读了,停止(如果不写,就会一直push上面的值,死循环)
this.push(null)
}
}

可写流

  • 如果文件不存在会创建,如果有内容会被清空
  • 读取到highWaterMark的时候就会输出
  • 第一次是真的写到文件 后面就是写入缓存区 再从缓存区里面去取

参数(和可读流的类似)

  • path:写入的文件的路径
  • option:
    • highWaterMark:水位线,一次可写入缓存中的字节,一般默认是64k
    • flags:标识,写入文件要做的操作,默认是w
    • encoding:编码,默认为buffer
    • start:开始写入的索引位置
    • end:结束写入的索引位置(包括结束位置)
    • autoClose:写入完毕是否关闭,默认为true
1
2
3
4
5
6
7
8
9
10
let ReadStream = require('./ReadStream')
//读取的时候默认读64k
let rs = new ReadStream('./a.txt',{
highWaterMark: 2,//一次读的字节 默认64k
flags: 'r', //标示 r为读 w为写
autoClose: true, //默认读取完毕后自动关闭
start: 0,
end: 5, //流是闭合区间包start,也包end 默认是读完
encoding: 'utf8' //默认编码是buffer
})

方法

write

1
2
3
4
5
6
7
8
9
10
11
12
13
let fs = require('fs')
let ws = fs.createWriteStream('./d.txt',{
flags: 'w',
encoding: 'utf8',
start: 0,
//write的highWaterMark只是用来触发是不是干了
highWaterMark: 3 //写是默认16k
})
//返回boolean 每当write一次都会在ws中吃下一个馒头 当吃下的馒头数量达到highWaterMark时 就会返回false 吃不下了会把其余放入缓存 其余状态返回true
//write只能放string或者buffer
flag = ws.write('1','utf8',()=>{
console.log(i)
})

drain

1
2
3
4
//drain只有嘴塞满了 吃完(包括内存中的,就是地下的)才会触发 这里是两个条件 一个是必须是吃下highWaterMark个馒头 并且在吃完的时候才会callback
ws.on('drain',()=>{
console.log('干了')
})

fs.write():可读流底层调用的就是这个方法,最原生的读方法

1
2
3
4
5
6
7
8
9
//wfd文件描述符,一般通过fs.open中获取
//buffer,要取数据的缓存源
//0,从buffer的0位置开始取
//BUFFER_SIZE,每次取BUFFER_SIZE这么长的长度
//index,每次写入文件的index的位置
//bytesRead,真实写入的个数
fs.write(wfd,buffer,0,bytesRead,index,function(err,bytesWrite){
})

通过代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
let fs = require('fs')
let EventEmitter = require('events')
//只有第一次write的时候直接用_write写入文件 其余都是放到cache中 但是len超过了highWaterMark就会返回false告知需要drain 很占缓存
//从第一次的_write开始 回去一直通过clearBuffer递归_write写入文件 如果cache中没有了要写入的东西 会根据needDrain来判断是否触发干点
class WriteStream extends EventEmitter{
constructor(path,options = {}){
super()
this.path = path
this.highWaterMark = options.highWaterMark || 64*1024
this.flags = options.flags || 'r'
this.start = options.start || 0
this.pos = this.start
this.autoClose = options.autoClose || true
this.mode = options.mode || 0o666
//默认null就是buffer
this.encoding = options.encoding || null
//打开这个文件
this.open()
//写文件的时候需要哪些参数
//第一次写入的时候 是给highWaterMark个馒头 他会硬着头皮写到文件中 之后才会把多余吃不下的放到缓存中
this.writing = false
//缓存数组
this.cache = []
this.callbackList = []
//数组长度
this.len = 0
//是否触发drain事件
this.needDrain = false
}
clearBuffer(){
//取缓存中最上面的一个
let buffer = this.cache.shift()
if(buffer){
//有buffer的情况下
this._write(buffer.chunk,buffer.encoding,()=>this.clearBuffer(),buffer.callback)
}else{
//没有的话 先看看需不需要drain
if(this.needDrain){
//触发drain 并初始化所有状态
this.writing = false
this.needDrain = false
this.callbackList.shift()()
this.emit('drain')
}
this.callbackList.map(v=>{
v()
})
this.callbackList.length = 0
}
}
_write(chunk,encoding,clearBuffer,callback){
//因为write方法是同步调用的 所以可能还没获取到fd
if(typeof this.fd != 'number'){
//直接在open的时间对象上注册一个一次性事件 当open被emit的时候会被调用
return this.once('open',()=>this._write(chunk,encoding,clearBuffer,callback))
}
fs.write(this.fd,chunk,0,chunk.length,this.pos,(err,byteWrite)=>{
this.pos += byteWrite
//每次写完 相应减少内存中的数量
this.len -= byteWrite
if(callback) this.callbackList.push(callback)
//第一次写完
clearBuffer()
})
}
//写入方法
write(chunk,encoding=this.encoding,callback){
//判断chunk必须是字符串或者buffer 为了统一都变成buffer
chunk = Buffer.isBuffer(chunk)?chunk:Buffer.from(chunk,encoding)
//维护缓存的长度 3
this.len += chunk.length
let ret = this.len < this.highWaterMark
if(!ret){
//表示要触发drain事件
this.needDrain = true
}
//正在写入的应该放到内存中
if(this.writing){
this.cache.push({
chunk,
encoding,
callback
})
}else{
//这里是第一次写的时候
this.writing = true
//专门实现写的方法
this._write(chunk,encoding,()=>this.clearBuffer(),callback)
}
// console.log(ret)
//能不能继续写了 false代表下次写的时候更占内存
return ret
}
destory(){
if(typeof this.fd != 'number'){
return this.emit('close')
}
//如果文件被打开过 就关闭文件并且触发close事件
fs.close(this.fd,()=>{
this.emit('close')
})
}
open(){
//fd表示的就是当前this.path的这个文件,从3开始(number类型)
fs.open(this.path,this.flags,(err,fd)=>{
//有可能fd这个文件不存在 需要做处理
if(err){
//如果有自动关闭 则帮他销毁
if(this.autoClose){
//销毁(关闭文件,出发关闭文件事件)
this.destory()
}
//如果有错误 就会触发error事件
this.emit('error',err)
return
}
//保存文件描述符
this.fd = fd
//当文件打开成功时触发open事件
this.emit('open',this.fd)
})
}
}

自定义可写流

因为createWriteStream内部调用了WriteStream类,WriteStream又实现了Writable接口,WriteStream实现了_write()方法,所以我们通过自定义一个类继承stream模块的Writable,并在原型上自定义一个_write()就可以自定义自己的可写流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let { Writable } = require('stream')
class MyWrite extends Writable{
_write(chunk,encoding,callback){
//write()的第一个参数,写入的数据
console.log(chunk)
//这个callback,就相当于我们上面的clearBuffer方法,如果不执行callback就不会继续从缓存中取出写
callback()
}
}
let write = new MyWrite()
write.write('1','utf8',()=>{
console.log('ok')
})

pipe

管道流,是可读流上的方法,至于为什么放到这里,主要是因为需要2个流的基础知识,是可读流配合可写流的一种传输方式。如果用原来的读写,因为写比较耗时,所以会多读少写耗内存,但用了pipe就不会了,始终用规定的内存。

用法

1
2
3
4
5
6
7
8
9
10
11
12
13
let fs = require('fs')
//pipe方法叫管道 可以控制速率
let rs = fs.createReadStream('./d.txt',{
highWaterMark: 4
})
let ws = fs.createWriteStream('./e,txt',{
highWaterMark: 1
})
//会监听rs的on('data')将读取到的数据,通过ws.write的方法写入文件
//调用写的一个方法 返回boolean类型
//如果返回false就调用rs的pause方法 暂停读取
//等待可写流 写入完毕在监听drain resume rs
rs.pipe(ws) //会控制速率 防止淹没可用内存

自己实现一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
let fs = require('fs')
//这两个是上面自己写的ReadStream和WriteStream
let ReadStream = require('./ReadStream')
let WriteStream = require('./WriteStream')
//如果用原来的读写,因为写比较耗时,所以会多读少写,耗内存
ReadStream.prototype.pipe = function(dest){
this.on('data',(data)=>{
let flag = dest.write(data)
//如果写入的时候嘴巴吃满了就不继续读了,暂停
if(!flag){
this.pause()
}
})
//如果写的时候嘴巴里的吃完了,就会继续读
dest.on('drain',()=>{
this.resume()
})
this.on('end',()=>{
this.destory()
//清空缓存中的数据
fs.fsync(dest.fd,()=>{
dest.destory()
})
})
}

双工流

有了双工流,我们可以在同一个对象上同时实现可读和可写,就好像同时继承这两个接口。 重要的是双工流的可读性和可写性操作完全独立于彼此。这仅仅是将两个特性组合成一个对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let { Duplex } = require('stream')
//双工流,可读可写
class MyDuplex extends Duplex{
_read(){
this.push('hello')
this.push(null)
}
_write(chunk,encoding,clearBuffer){
console.log(chunk)
clearBuffer()
}
}
let myDuplex = new MyDuplex()
//process.stdin是node自带的process进程中的可读流,会监听命令行的输入
//process.stdout是node自带的process进程中的可写流,会监听并输出在命令行中
//所以这里的意思就是在命令行先输出hello,然后我们输入什么他就出来对应的buffer(先作为可读流出来)
process.stdin.pipe(myDuplex).pipe(process.stdout)

转换流

转换流的输出是从输入中计算出来的。对于转换流,我们不必实现readwrite的方法,我们只需要实现一个transform方法,将两者结合起来。它有write方法的意思,我们也可以用它来push数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let { Transform } = require('stream')
class MyTransform extends Transform{
_transform(chunk,encoding,callback){
console.log(chunk.toString().toUpperCase())
callback()
}
}
let myTransform = new MyTransform()
class MyTransform2 extends Transform{
_transform(chunk,encoding,callback){
console.log(chunk.toString().toUpperCase())
this.push('1')
// this.push(null)
callback()
}
}
let myTransform2 = new MyTransform2()
//此时myTransform2被作为可写流触发_transform,输出输入的大写字符后,会通过可读流push字符到下一个转换流中
//当写入的时候才会触发transform的值,此时才会push,所以后面的pipe拿到的chunk是前面的push的值
process.stdin.pipe(myTransform2).pipe(myTransform)

总结

可读流

  • 在 flowing 模式下, 可读流自动从系统底层读取数据,并通过 EventEmitter 接口的事件尽快将数据提供给应用。
  • 在 paused 模式下,必须显式调用 stream.read() 方法来从流中读取数据片段。
  • 所有初始工作模式为 paused 的 Readable 流,可以通过下面三种途径切换到 flowing 模式:
    • 监听 ‘data’ 事件
    • 调用 stream.resume() 方法
    • 调用 stream.pipe() 方法将数据发送到 Writable
  • 可读流可以通过下面途径切换到 paused 模式:
    • 如果不存在管道目标(pipe destination),可以通过调用 stream.pause() 方法实现。
    • 如果存在管道目标,可以通过取消 ‘data’ 事件监听,并调用 stream.unpipe() 方法移除所有管道目标来实现。

可写流

  • 需要知道只有在真正的吃满了,并且等到把嘴里的和地上的馒头(缓存中的)都吃下了才会触发drain事件
  • 第一次写入会直接写入文件中,后面会从缓存中一个个取

双工流

  • 只是对可写可读流的一种应用,既可作为可读流,也能作为可写流,并且作为可读或者可写时时隔离

转换流

  • 一般转换流是边输入边输出的,而且一般只有触发了写入操作时才会进入_transform方法中。跟双工流的区别就是,他的可读可写是在一起的。

OK,讲完收工,从此你就是魔王

node基础面试事件环?微任务、宏任务?一篇带你飞

培育能力的事必须继续不断地去做,又必须随时改善学习方法,提高学习效率,才会成功。 —— 叶圣陶

一、我们为什么要使用node,它的好处是什么?

Node的首要目标是提供一种简单的,用于创建高性能服务器的开发工具。还要解决web服务器高并发的用户请求。

解决高并发?

我们这里来举个例子,我们node和java相比,在同样的请求下谁更占优一点。看图

  • 当用户请求量增高时,node相对于java有更好的处理并发性能,它可以快速通过主线程绑定事件。java每次都要创建一个线程,虽然java现在有个线程池的概念,可以控制线程的复用和数量。
  • 异步i/o操作,node可以更快的操作数据库。java访问数据库会遇到一个并行的问题,需要添加一个锁的概念。我们这里可以打个比方,下课去饮水机接水喝,java是一下子有喝多人去接水喝,需要等待,node是每次都只去一个人接水喝。
  • 密集型CPU运算指的是逻辑处理运算、压缩、解压、加密、解密,node遇到CPU密集型运算时会阻塞主线程(单线程),导致其下面的时间无法快速绑定,所以node不适用于大型密集型CPU运算案例,而java却很适合。

node在web端场景?

web端场景主要是用户的请求或者读取静态资源什么的,很适合node开发。应用场景主要有聊天服务器电子商务网站等等这些高并发的应用。

二、node是什么?

Node.js是一个基于 Chrome V8 引擎的JavaScript运行环境(runtime),Node不是一门语言,是让js运行在后端的运行时,并且不包括javascript全集,因为在服务端中不包含DOMBOM,Node也提供了一些新的模块例如http,fs模块等。Node.js 使用了事件驱动、非阻塞式 I/O的模型,使其轻量又高效并且Node.js 的包管理器 npm,是全球最大的开源库生态系统。

总而言之,言而总之,它只是一个运行时,一个运行环境。

node特性

  • 主线程是单线程(异步),将后续的逻辑写成函数,传入到当前执行的函数中,当执行的函数得到了结果后,执行传入的函数(回调函数)
  • 五个人同时吃一碗饭(异步)。
  • 阻塞不能异步(现在假定数据库是厨师,服务员是node,顾客是请求,一般是厨师做菜让一个服务员递给多个用户,如果厨师邀请服务员聊天,就会导致阻塞,并且是针对内核说的)。
  • i/o操作,读写操作,异步读写(能用异步绝不用同步) 非阻塞式i/o,即可以异步读写。
  • event-driven事件驱动(发布订阅)。

node的进程与线程

进程是操作系统分配资源和调度任务的基本单位,线程是建立在进程上的一次程序运行单位,一个进程上可以有多个线程。

在此之前我们先来看看浏览器的进程机制

自上而下,分别是:

  • 用户界面–包括地址栏、书签菜单等
  • 浏览器引擎–用户界面和渲染引擎之间的传送指令(浏览器的主进程)
  • 渲染引擎–浏览器的内核,如(webkit,Gecko)
  • 其他–网络请求,js线程和ui线程

从我们的角度来看,我们更关心的是浏览器的渲染引擎,让我们往下看。

渲染引擎

  • 渲染引擎是多线程的,包含ui线程和js线程。ui线程和js线程会互斥,因为js线程的运行结果会影响ui线程,ui更新会被保存在队列,直到js线程空闲,则被取出来更新。
  • js单线程是单线程的,为什么呢?假如js是多线程的,那么操作DOM就是多线程操作,那样的话就会很混乱,DOM不知道该听谁的,而这里的单线程指得是主线程是单线程的,他同样可以有异步线程,通过队列存放这些线程,而主线程依旧是单线程,这个我们后面再讲。所以在node中js也是单线程的。
  • 单线程的好处就是节约内存,不需要再切换的时候执行上下文,也不用管锁的概念,因为我们每次都通过一个。

三、浏览器中的Event Loop

这里我先要说一下浏览器的事件环,可能有人会说,你这篇文章明明是讲node的怎么会扯到浏览器。首先他们都是以js为底层语言的不同运行时,有其相似之处,再者多学一点也不怕面试官多问。好了我废话不多说,开始。

首先我们需要知道堆,栈和队列的关系和意义。

  • 堆(heap):堆是存放对象的一个空间(Object、function)
  • 队列(loop):是指存放所有异步请求操作的结果,直到有一个异步操作完成它的使命,就会在loop中添加一个事件,队列是先进先出的,比如下面的图,最先进队列的会先被打出去

隔山打牛!

  • 栈(stack):栈本身是存储基础的变量,比如1,2,3,还有引用的变量,这里可能有人会问你上面的堆不是存放引用类型的对象吗,怎么变栈里去了。这里我要解释一下,因为栈里面的存放的引用变量是指向堆里的引用对象的地址只是一串地址。这里栈代表的是执行栈,我们js的主线程。栈是先进后出的,先进后出就是相当于喝水的水杯,我们倒水进去,理论上喝到的水是最后进水杯的。我们可以看代码,follow me
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function a(){
console.log('a')
function b(){
console.log('b')
function c(){
console.log('c')
}
c()
}
b()
}
a()
//这段代码是输出a,b,c,执行栈中的顺序的c,b,a,如果是遵循先进先出,就是输出c,b,a。所以栈先进后出这个特性大家要牢记。

OK,现在大家已经知道堆,栈和队列的关系,现在我们来看一张图。

我分析一下这张图

  • 我们的同步任务在主线程上运行会形成一个执行栈
  • 如果碰到异步任务,比如setTimeout、onClick等等的一些操作,我们会将他的执行结果放入队列,此期间主线程不阻塞
  • 等到主线程中的所有同步任务执行完毕,就会通过event loop在队列里面从头开始取,在执行栈中执行
  • event loop永远不会断
  • 以上的这一整个流程就是Event Loop(事件循环机制)

微任务、宏任务?

macro-task(宏任务): setTimeout,setImmediate,MessageChannel
micro-task(微任务): 原生Promise(有些实现的promise将then方法放到了宏任务中),Object.observe(已废弃), MutationObserver

微任务和宏任务皆为异步任务,它们都属于一个队列,主要区别在于他们的执行顺序,Event Loop的走向和取值。那么他们之间到底有什么区别呢

每次执行栈的同步任务执行完毕,就会去任务队列中取出完成的异步任务,队列中又分为microtasks queues和宏任务队列等到把microtasks queues所有的microtasks都执行完毕,注意是所有的,他才会从宏任务队列中取事件。等到把队列中的事件取出一个,放入执行栈执行完成,就算一次循环结束,之后event loop还会继续循环,他会再去microtasks queues执行所有的任务,然后再从宏任务队列里面取一个,如此反复循环。

  • 同步任务执行完
  • 去执行microtasks,把所有microtasks queues清空
  • 取出一个macrotasks queues的完成事件,在执行栈执行
  • 再去执行microtasks

我这么说可能大家会有点懵,不慌,我们来看一道题

1
2
3
4
5
6
7
8
9
10
setTimeout(()=>{
console.log('setTimeout1')
},0)
let p = new Promise((resolve,reject)=>{
console.log('Promise1')
resolve()
})
p.then(()=>{
console.log('Promise2')
})

最后输出结果是Promise1,Promise2,setTimeout1

  • Promise参数中的Promise1是同步执行的,Promise还不是很了解的可以看看我另外一篇文章Promise之你看得懂的Promise,
  • 其次是因为Promise是microtasks,会在同步任务执行完后会去清空microtasks queues
  • 最后清空完微任务再去宏任务队列取值
1
2
3
4
5
6
7
8
9
10
11
12
13
Promise.resolve().then(()=>{
console.log('Promise1')
setTimeout(()=>{
console.log('setTimeout2')
},0)
})
setTimeout(()=>{
console.log('setTimeout1')
Promise.resolve().then(()=>{
console.log('Promise2')
})
},0)

这回是嵌套,大家可以看看,最后输出结果是Promise1,setTimeout1,Promise2,setTimeout2

  • 一开始执行栈的同步任务执行完毕,会去microtasks queues
  • 清空microtasks queues,输出Promise1,同时会生成一个异步任务setTimeout1
  • 宏任务队列查看此时队列是setTimeout1在setTimeout2之前,因为setTimeout1执行栈一开始的时候就开始异步执行,所以输出setTimeout1,在执行setTimeout1时会生成Promise2的一个microtasks,放入microtasks queues
  • 接着又是一个循环,去清空microtasks queues,输出Promise2
  • 清空完microtasks queues,就又会去宏任务队列取一个,这回取的是setTimeout2

四、node中的事件环

node的事件环相比浏览器就不一样了,我们先来看一张图,他的工作流程

  • 首先我们能看到我们的js代码(APPLICATION)会先进入v8引擎,v8引擎中主要是一些setTimeout之类的方法。
  • 其次如果我们的代码中执行了nodeApi,比如require('fs').read(),node就会交给libuv库处理,这个libuv库是别人写的,他就是node的事件环。
  • libuv库是通过单线程异步的方式来处理事件,我们可以看到work threads是个多线程的队列,通过外面event loop阻塞的方式来进行异步调用。
  • 等到work threads队列中有执行完成的事件,就会通过EXECUTE CALLBACK回调给EVENT QUEUE队列,把它放入队列中。
  • 最后通过事件驱动的方式,取出EVENT QUEUE队列的事件,交给我们的应用

node中的event loop

node中的event loop是在libuv里面的,libuv里面有个事件环机制,他会在启动node时,初始化事件环

  • 这里的每一个阶段都对应着一个事件队列
  • 每当event loop执行到某个阶段时,都会执行对应的事件队列中的事件,依次执行
  • 当该队列执行完毕或者执行数量超过上限,event loop就会执行下一个阶段
  • 每当event loop切换一个执行队列时,就会去清空microtasks queues,然后再切换到下个队列去执行,如此反复

这里我们要注意setImmediate是属于check队列的,还有poll队列主要是异步的I/O操作,比如node中的fs.readFile()

我们来具体看一下他的用法吧

1
2
3
4
5
6
7
8
9
10
11
12
13
setImmediate(()=>{
console.log('setImmediate1')
setTimeout(()=>{
console.log('setTimeout1')
},0)
})
setTimeout(()=>{
console.log('setTimeout2')
process.nextTick(()=>{console.log('nextTick1')})
setImmediate(()=>{
console.log('setImmediate2')
})
},0)
  • 首先我们可以看到上面的代码先执行的是setImmediate1,此时event loopcheck队列
  • 然后setImmediate1从队列取出之后,输出setImmediate1,然后会将setTimeout1执行
  • 此时event loop执行完check队列之后,开始往下移动,接下来执行的是timers队列
  • 这里会有问题,我们都知道setTimeout1设置延迟为0的话,其实还是有4ms的延迟,那么这里就会有两种情况。先说第一种,此时setTimeout1已经执行完毕
    • 根据node事件环的规则,我们会执行完所有的事件,即取出timers队列中的setTimeout2,setTimeout1
    • 此时根据队列先进先出规则,输出顺序为setTimeout2,setTimeout1,在取出setTimeout2时,会将一个process.nextTick执行(执行完了就会被放入微任务队列),再将一个setImmediate执行(执行完了就会被放入check队列
    • 到这一步,event loop会再去寻找下个事件队列,此时event loop会发现微任务队列有事件process.nextTick,就会去清空它,输出nextTick1
    • 最后event loop找到下个有事件的队列check队列,执行setImmediate,输出setImmediate2
  • 假如这里setTimeout1还未执行完毕(4ms耽误了它的终身大事?)
    • 此时event loop找到timers队列,取出timers队列*中的setTimeout2,输出setTimeout2,把process.nextTick执行,再把setImmediate执行
    • 然后event loop需要去找下一个事件队列,这里大家要注意一下,这里会发生2步操作,1、setTimeout1执行完了,放入timers队列。2、找到微任务队列清空。,所以此时会先输出nextTick1
    • 接下来event loop会找到check队列,取出里面已经执行完的setImmediate2
    • 最后event loop找到timers队列,取出执行完的setTimeout1这种情况下event loop比上面要多切换一次

所以有两种答案

  1. setImmediate1,setTimeout2,setTimeout1,nextTick1,setImmediate2
  2. setImmediate1,setTimeout2,nextTick1,setImmediate2,setTimeout1

这里的图只参考了第一种情况,另一种情况也类似

五、node的同步、异步,阻塞、非阻塞

  • 同步:即为调用者等待被调用者这个过程,如果被调用者一直不反回结果,调用者就会一直等待,这就是同步,同步有返回值
  • 异步:即为调用者不等待被调用者是否返回,被调用者执行完了就会通过状态、通知或者回调函数给调用者,异步没有返回值
  • 阻塞:指代当前线程在结果返回之前会被挂起,不会继续执行下去
  • 非阻塞: 即当前线程不管你返回什么,都会继续往下执行

有些人可能会搞乱他们之间的关系,同步、异步是被调用者的状态,阻塞、非阻塞是调用者的状态、消息

接下来我们来看看他们的组合会是怎么样的

组合 意义
同步阻塞 这就相当于我去饭店吃饭,我需要在厨房等待菜烧好了,才能吃。我是调用者我需要等待上菜于是被阻塞,菜是被调用者做好直接给我是同步
异步阻塞 我去饭店吃饭,我需要等待菜烧好了才能吃,但是厨师有事,希望之后处理完事能做好之后通知我去拿,我作为调用者等待就是阻塞的,而菜作为被调用者是做完之后通知我的,所以是异步的,这种方式一般没用。
同步非阻塞 我去饭店吃饭,先叫了碗热菜,在厨房等厨师做菜,但我很饿,就开始吃厨房冷菜,我是调用者我没等热菜好就开始吃冷菜,是非阻塞的,菜作为被调用者做好直接给我是同步的,这种方式一般也没人用
异步非阻塞 我去饭店吃饭。叫了碗热菜,厨师在做菜,但我很饿,先吃冷菜,厨师做好了通知我去拿,我是调用者我不会等热菜烧好了再吃冷菜,是非阻塞的,菜作为被调用者通知我拿是异步的

结尾

希望大家看了本篇文章都有收获,这样出去面试的时候就不会这样
而是这样。好了,最后希望大家世界杯都能够逢赌必赢,自己喜欢的球队也能够杀进决赛