11

本文首发于CSDN网站,下面的版本又经过进一步的修订。
原文:webpack与browser-sync热更新原理深度讲解

开发环境页面热更新早已是主流,我们不光要吃着火锅唱着歌,享受热更新高效率的快感,更要深入下去探求其原理。

要知道,触类则旁通,常见的需求如赛事网页推送比赛结果、网页实时展示投票或点赞数据、在线评论或弹幕、在线聊天室等,都需要借助热更新功能,才能达到实时的端对端的极致体验。

刚好,最近解决webpack-hot-middleware热更新延迟问题的过程中,我深入接触了EventSource技术。遂本文由此开篇,进一步讲解webpack-hot-middlewarebrowser-sync背后的技术。

webpack-hot-middleware

webpack-hot-middleware中间件是webpack的一个plugin,通常结合webpack-dev-middleware一起使用。借助它可以实现浏览器的无刷新更新(热更新),即webpack里的HMR(Hot Module Replacement)。如何配置请参考 webpack-hot-middleware,如何理解其相关插件请参考 手把手深入理解 webpack dev middleware 原理與相關 plugins

webpack加入webpack-hot-middleware后,内存中的页面将包含HMR相关js,加载页面后,Network栏可以看到如下请求:

__webpack_hmr

__webpack_hmr是一个type为EventSource的请求, 从Time栏可以看出:默认情况下,服务器每十秒推送一条信息到浏览器。

hmr每10秒推送一条信息

如果此时关闭开发服务器,浏览器由于重连机制,将持续抛出类似GET http://www.test.com/__webpack_hmr 502 (Bad Gateway) 这样的错误。重新启动开发服务器后,重连将会成功,此时便会刷新页面。

以上这些便是我们使用时感受到的最初的印象。当然,停留在使用层面不是我们的目标,接下来我们将跳出该中间件,讲解其所使用到的EventSource技术。

EventSource

EventSource 不是一个新鲜的技术,它早就随着H5规范提出了,正式一点应该叫Server-sent events,即SSE

鉴于传统的通过ajax轮训获取服务器信息的技术方案已经过时,我们迫切需要一个高效的节省资源的方式去获取服务器信息,一旦服务器资源有更新,能够及时地通知到客户端,从而实时地反馈到用户界面上。EventSource就是这样的技术,它本质上还是HTTP,通过response流实时推送服务器信息到客户端。

新建一个EventSource对象非常简单。

const es = new EventSource('/message');// /message是服务端支持EventSource的接口

新创建的EventSource对象拥有如下属性:

属性 描述
url(只读) es对象请求的服务器url
readyState(只读) es对象的状态,初始为0,包含CONNECTING (0),OPEN (1),CLOSED (2)三种状态
withCredentials 是否允许带凭证等,默认为false,即不支持发送cookie

服务端实现/message接口,需要返回类型为 text/event-stream的响应头。

var http = require('http');
http.createServer(function(req,res){
  if(req.url === '/message'){
    res.writeHead(200,{
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive'
    });
    setInterval(function(){
      res.write('data: ' + +new Date() + '\n\n');
    }, 1000);
  }
}).listen(8888);

我们注意到,为了避免缓存,Cache-Control 特别设置成了 no-cache,为了能够发送多个response, Connection被设置成了keep-alive.。发送数据时,请务必保证服务器推送的数据以 data:开始,以\n\n结束,否则推送将会失败(原因就不说了,这是约定的)。

以上,服务器每隔1s主动向客户端发送当前时间戳,为了接受这个信息,客户端需要监听服务器。如下:

es.onmessage = function(e){
  console.log(e.data); // 打印服务器推送的信息
}

如下是消息推送的过程:

response size不断增加

接收消息

你以为es只能监听message事件吗?并不是,message只是缺省的事件类型。实际上,它可以监听任何指定类型的事件。

es.addEventListener("####", function(e) {// 事件类型可以随你定义
  console.log('####:', e.data);
},false);

服务器发送不同类型的事件时,需要指定event字段。

res.write('event: ####\n');
res.write('data: 这是一个自定义的####类型事件\n');
res.write('data: 多个data字段将被解析成一个字段\n\n');

如下所示:

####消息

可以看到,服务端指定event事件名为"####"后,客户端触发了对应的事件回调,同时服务端设置的多个data字段,客户端使用换行符连接成了一个字符串。

不仅如此,事件流中还可以混合多种事件,请看我们是怎么收到消息的,如下:

混合消息

除此之外,es对象还拥有另外3个方法: onopen()onerror()close(),请参考如下实现。

es.onopen = function(e){// 链接打开时的回调
  console.log('当前状态readyState:', es.readyState);// open时readyState===1
}
es.onerror = function(e){// 出错时的回调(网络问题,或者服务下线等都有可能导致出错)
  console.log(es.readyState);// 出错时readyState===0
  es.close();// 出错时,chrome浏览器会每隔3秒向服务器重发原请求,直到成功. 因此出错时,可主动断开原连接.
}

使用EventSource技术实时更新网页信息十分高效。实际使用中,我们几乎不用担心兼容性问题,主流浏览器都了支持EventSource,当然,除了掉队的IE系。对于不支持的浏览器,其PolyFill方案请参考HTML5 Cross Browser Polyfills

CORS

另外,如果需要支持跨域调用,请设置响应头Access-Control-Allow-Origin': '*'

如需支持发送cookie,请设置响应头Access-Control-Allow-Origin': req.headers.originAccess-Control-Allow-Credentials:true,并且创建es对象时,需要明确指定是否发送凭证。如下:

var es = new EventSource('/message', {
  withCredentials: true
}); // 创建时指定配置才是有效的
es.withCredentials = true; // 与ajax不同,这样设置是无效的

以下是主流浏览器对EventSource的CORS的支持:

Firefox Opera Chrome Safari iOS Android
10+ 12+ 26+ 7.0+ 7.0+ 4.4+

nginx配置

既然说到了EventSource,便有必要谈谈遇到的坑,接下来,就说说我遇到的webpack热更新延迟问题。

如我们所知,webpack借助webpack-hot-middleware插件,实现了网页热更新机制,正常情况下,浏览器打开 http://localhost:8080 这样的网页即可开始调试。然而实际开发中,由于远程服务器需要种cookie登录态到特定的域名上等原因,因此本地往往会用nginx做一层反向代理。即把 http://www.test.com 的请求转发到 http://localhost:8080 上(配置过程这里不详述,具体请参考Ajax知识体系大梳理-ajax调试技巧)。转发过后,发现热更新便延迟了。

原因是nginx默认开启的buffer机制缓存了服务器推送的片段信息,缓存达到一定的量才会返回响应内容。只要关闭proxy_buffering即可。配置如下所示:

server {
    listen       80;
    server_name  www.test.company.com;
    location / {
        proxy_pass http://localhost:8080;
        proxy_buffering off;
    }
}

至此,EventSource部分便告一段落。学习讲究由浅入深,循序渐进。后面我将重点讲解的browser-sync热更新机制,请耐心细读。

browser-sync

开发中使用browser-sync插件调试,一个网页里的所有交互动作(包括滚动,输入,点击等等),可以实时地同步到其他所有打开该网页的设备,能够节省大量的手工操作时间,从而带来流畅的开发调试体验。目前browser-sync可以结合GulpGrunt一起使用,其API请参考:Browsersync API

通过上面的了解,我们知道EventSouce的使用是比较便捷的,那为什么browser-sync不使用EventSource技术进行代码推送呢?这是因为browser-sync插件共做了两件事:

  • 开发更新了一段新的逻辑,服务器实时推送代码改动信息。数据流:服务器 —> 浏览器,使用EventSource技术同样能够实现。

  • 用户操作网页,滚动、输入或点击等,操作信息实时发送给服务器,然后再由服务器将操作同步给其他已打开的网页。数据流:浏览器 —> 服务器 —> 浏览器,该部分功能EventSource技术已无能为力。

以上,browser-sync使用WebSocket技术达到实时推送代码改动和用户操作两个目的。至于它是如何计算推送内容,根据不同推送内容采取何种响应策略,不在本次讨论范围之内。下面我们将讲解其核心的WebSocket技术。

WebSocket

WebSocket是基于TCP的全双工通讯的协议,它与EventSource有着本质上的不同.(前者基于TCP,后者依然基于HTTP) 该协议于2011年被IETF定为标准RFC6455,后被RFC7936补充. WebSocket api也被W3C定为标准。

WebSocket使用和HTTP相同的TCP端口,默认为80, 统一资源标志符为ws,运行在TLS之上时,默认使用443,统一资源标志符为wss。它通过101 switch protocol进行一次TCP握手,即从HTTP协议切换成WebSocket通信协议。

相对于HTTP协议,WebSocket拥有如下优点:

  • 全双工,实时性更强。

  • 相对于http携带完整的头部,WebSocket请求头部明显减少。

  • 保持连接状态,不用再验权了。

  • 二进制支持更强,Websocket定义了二进制帧,处理更轻松。

  • Websocket协议支持扩展,可以自定义的子协议,如 permessage-deflate 扩展。

支持性

优秀技术的落地,调研兼容性是必不可少的环节。所幸的是,现代浏览器对WebSocket的支持比较友好,如下是PC端兼容性:

IE/Edge Firefox Chrome Safari Opera
10+ 11+ 16+ 7+ 12.1+

如下是mobile端兼容性:

iOS Safari Android Android Chrome Android UC QQ Browser Opera Mini
7.1+ 4.4+ 57+ 11.4+ 1.2+ -

Frame

根据RFC6455文档,WebSocket协议基于Frame而非Stream(EventSource是基于Stream的)。因此其传输的数据都是Frame(帧)。想要了解数据的往返,弄懂协议处理过程,Frame的解读是必不可少。如下便是Frame的结构:

  0                   1                   2                   3
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
 +-+-+-+-+-------+-+-------------+-------------------------------+
 |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
 |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
 |N|V|V|V|       |S|             |   (if payload len==126/127)   |
 | |1|2|3|       |K|             |                               |
 +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
 |     Extended payload length continued,if payload len == 127  |
 + - - - - - - - - - - - - - - - +-------------------------------+
 |                               |Masking-key,if MASK set to 1  |
 +-------------------------------+-------------------------------+
 | Masking-key (continued)       |          Payload Data         |
 +-------------------------------- - - - - - - - - - - - - - - - +
 :                     Payload Data continued ...                :
 + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
 |                     Payload Data continued ...                |
 +---------------------------------------------------------------+

第一个字节包含FIN、RSV、Opcode。

  • FIN:size为1bit,标示是否最后一帧。%x0表示还有后续帧,%x1表示这是最后一帧。

  • RSV1、2、3,每个size都是1bit,默认值都是0,如果没有定义非零值的含义,却出现了非零值,则WebSocket链接将失败。

  • Opcode,size为4bits,表示『payload data』的类型。如果收到未知的opcode,连接将会断开。已定义的opcode值如下:

    %x0:    代表连续的帧
    %x1:    文本帧
    %x2:    二进制帧
    %x3~7:    预留的非控制帧
    %x8:    关闭握手帧
    %x9:    ping帧,后续心跳连接会讲到
    %xA:    pong帧,后续心跳连接会讲到
    %xB~F:    预留的非控制帧

第二个字节包含Mask、Payload len。

  • Mask:size为1bit,标示『payload data』是否添加掩码。所有从客户端发送到服务端的帧都会被置为1,如果置1,Masking-key便会赋值。

    //若server是一个WebSocket服务端实例
    //监听客户端消息
    server.on('message', function(msg, flags) {
      console.log('client say: %s', msg);
      console.log('mask value:', flags.masked);// true,进一步佐证了客户端发送到服务端的Mask帧都会被置为1
    });
    //监听客户端pong帧响应
    server.on('pong', function(msg, flags) {
      console.log('pong data: %s', msg);
      console.log('mask value:', flags.masked);// true,进一步佐证了客户端发送到服务端的Mask帧都会被置为1
    });
  • Payload len:size为7bits,即使是当做无符号整型也只能表示0~127的值,所以它不能表示更大的值,因此规定"Payload data"长度小于或等于125的时候才用来描述数据长度。如果Payload len==126,则使用随后的2bytes(16bits)来存储数据长度。如果Payload len==127,则使用随后的8bytes(64bits)来存储数据长度。

以上,扩展的Payload len可能占据第三至第四个或第三至第十个字节。紧随其后的是"Mask-key"。

  • Mask-key:size为0或4bytes(32bits),默认为0,与前面Mask呼应,从客户端发送到服务端的帧都包含4bytes(32bits)的掩码,一旦掩码被设置,所有接收到的"payload data"都必须与该值以一种算法做异或运算来获取真实值。

  • Payload data:size为"Extension data" 和 "Application data" 的总和,一般"Extension data"数据为空。

  • Extension data:默认为0,如果扩展被定义,扩展必须指定"Extension data"的长度。

  • Application data:占据"Extension data"之后剩余帧的空间。

关于Frame的更多理论介绍不妨读读 学习WebSocket协议—从顶层到底层的实现原理(修订版)

关于Frame的数据帧解析不妨读读 WebSocket(贰) 解析数据帧 及其后续文章。

建立连接

了解了Frame的数据结构后,我们来实际练习下。浏览器上,新建一个ws对象十分简单。如下:

let ws = new WebSocket('ws://127.0.0.1:10103/');// 本地使用10103端口进行测试

新建的WebSocket对象如下所示:

Websocket对象

这中间包含了一次Websocket握手的过程,我们分两步来理解。

第一步,客户端请求。

Websocket Request

这是一个GET请求,主要字段如下:

Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key:61x6lFN92sJHgzXzCHfBJQ==
Sec-WebSocket-Version:13

Connection字段指定为Upgrade,表示客户端希望连接升级。

Upgrade字段设置为websocket,表示希望升级至Websocket协议。

Sec-WebSocket-Key字段是随机字符串,服务器根据它来构造一个SHA-1的信息摘要。

Sec-WebSocket-Version表示支持的Websocket版本。RFC6455要求使用的版本是13。

甚至我们可以从请求截图里看出,Origin是file://,而Host是127.0.0.1:10103,明显不是同一个域下,但依然可以请求成功,说明Websocket协议是不受同源策略限制的(同源策略限制的是http协议)。

第二步,服务端响应。

Websocket Response

Status Code: 101 Switching Protocols 表示Websocket协议通过101状态码进行握手。

Sec-WebSocket-Accept字段是由Sec-WebSocket-Key字段加上特定字符串"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",计算SHA-1摘要,然后再base64编码之后生成的. 该操作可避免普通http请求,被误认为Websocket协议。

Sec-WebSocket-Extensions字段表示服务端对Websocket协议的扩展。

以上,WebSocket构造器不止可以传入url,还能传入一个可选的协议名称字符串或数组。

ws = new WebSocket('ws://127.0.0.1:10103/', ['abc','son_protocols']);

服务端实现

等等,我们慢一点,上面好像漏掉了一步,似乎没有提到服务端是怎么实现的。请继续往下看:

先做一些准备。ws是一个nodejs版的WebSocketServer实现。使用 npm install ws 即可安装。

var WebSocketServer = require('ws').Server,
    server = new WebSocketServer({port: 10103});
server.on('connection', function(s) {
  s.on('message', function(msg) { //监听客户端消息
    console.log('client say: %s', msg);
  });
  s.send('server ready!');// 连接建立好后,向客户端发送一条消息
});

以上,new WebSocketServer()创建服务器时如需权限验证,请指定verifyClient为验权的函数。

server = new WebSocketServer({
  port: 10103,
  verifyClient: verify
});
function verify(info){
  console.log(Object.keys(info));// [ 'origin', 'secure', 'req' ]
  console.log(info.orgin);// "file://"
  return true;// 返回true时表示验权通过,否则客户端将抛出"HTTP Authentication failed"错误
}

以上,verifyClient指定的函数只有一个形参,若为它显式指定两个形参,那么第一个参数同上info,第二个参数将是一个cb回调函数。该函数用于显式指定拒绝时的HTTP状态码等,它默认拥有3个形参,依次为:

  • result,布尔值类型,表示是否通过权限验证。

  • code,数值类型,若result值为false时,表示HTTP的错误状态码。

  • name,字符串类型,若result值为false时,表示HTTP状态码的错误信息。

// 若verify定义如下
function verify(info, cb){
  //一旦拥有第二个形参,如果不调用,默认将通过验权
  cb(false, 401, '权限不够');// 此时表示验权失败,HTTP状态码为401,错误信息为"权限不够"
  return true;// 一旦拥有第二个形参,响应就被cb接管了,返回什么值都不会影响前面的处理结果
}

除了portverifyClient设置外,其它设置项及更多API,请参考文档 ws-doc

发送和监听消息

接下来,我们来实现消息收发。如下是客户端发送消息。

ws.onopen = function(e){
  // 可发送字符串,ArrayBuffer 或者 Blob数据
  ws.send('client ready!);
};

客户端监听信息。

ws.onmessage = function(e){
  console.log('server say:', e.data);
};

如下是浏览器的运行截图。

message

消息的内容都在Frames栏,第一条彩色背景的信息是客户端发送的,第二条是服务端发送的。两条消息的长度都是13。

如下是Timing栏,不止是WebSocket,包括EventSource,都有这样的黄色高亮警告。

Websocket Request

该警告说明:请求还没完成。实际上,直到一方连接close掉,请求才会完成。

关闭连接

说到close,ws的close方法比es的略复杂。

语法:close(short code,string reason);

close默认可传入两个参数。code是数字,表示关闭连接的状态号,默认是1000,即正常关闭。(code取值范围从0到4999,其中有些是保留状态号,正常关闭时只能指定为1000或者3000~4999之间的值,具体请参考CloseEvent - Web APIs)。reason是UTF-8文本,表示关闭的原因(文本长度需小于或等于123字节)。

由于code 和 reason都有限制,因此该方法可能抛出异常,建议catch下.

try{
  ws.close(1001, 'CLOSE_GOING_AWAY');
}catch(e){
  console.log(e);
}

ws对象还拥有onclose和onerror监听器,分别监听关闭和错误事件。(注:EventSource没有onclose监听)

拥有的属性

ws的readyState属性拥有4个值,比es的readyState的多一个CLOSING的状态。

常量 描述 EventSource(值) WebSocket(值)
CONNECTING 连接未初始化 0 0
OPEN 连接已就绪 1 1
CLOSING 连接正在关闭 - 2
CLOSED 连接已关闭 2 3

另外,除了两种都有的url属性外,WebSocket对象还拥有更多的属性。

属性 描述
binaryType 被传输二进制内容的类型,有blob,arraybuffer两种
bufferedAmount 待传输的数据的长度
extensions 表示服务器选用的扩展
protocol 指的是构造器第二个参数传入的子协议名称

文件上传

以前一直是使用ajax做文件上传,实际上,Websocket上传文件也是一把好刀. 其send方法可以发送String,ArrayBuffer,Blob共三种数据类型,发送二进制文件完全不在话下。

由于各个浏览器对Websocket单次发送的数据有限制,所以我们需要将待上传文件切成片段去发送。如下是实现。

1) html。

<input type="file" id="file"/>

2) js。

const ws = new WebSocket('ws://127.0.0.1:10103/');// 连接服务器
const fileSelect = document.getElementById('file');
const size = 1024 * 128;// 分段发送的文件大小(字节)
let curSize, total, file, fileReader;

fileSelect.onchange = function(){
  file = this.files[0];// 选中的待上传文件
  curSize = 0;// 当前已发送的文件大小
  total = file.size;// 文件大小
  ws.send(file.name);// 先发送待上传文件的名称
  fileReader = new FileReader();// 准备读取文件
  fileReader.onload = loadAndSend;
  readFragment();// 读取文件片段
};

function loadAndSend(){
  if(ws.bufferedAmount > size * 5){// 若发送队列中的数据太多,先等一等
    setTimeout(loadAndSend,4);
    return;
  }
  ws.send(fileReader.result);// 发送本次读取的片段内容
  curSize += size;// 更新已发送文件大小
  curSize < total ? readFragment() : console.log('upload successed!');// 下一步操作
}

function readFragment(){
  const blob = file.slice(curSize, curSize + size);// 获取文件指定片段
  fileReader.readAsArrayBuffer(blob);// 读取文件为ArrayBuffer对象
}

3) server(node)。

var WebSocketServer = require('ws').Server,
    server = new WebSocketServer({port: 10103}),// 启动服务器
    fs = require('fs');
server.on('connection', function(wsServer){
  var fileName, i = 0;// 变量定义不可放在全局,因每个连接都不一样,这里才是私有作用域
  server.on('message', function(data, flags){// 监听客户端消息
    if(flags.binary){// 判断是否二进制数据
      var method = i++ ? 'appendFileSync' : 'writeFileSync';
      // 当前目录下写入或者追加写入文件(建议加上try语句捕获可能的错误)
      fs[method]('./' + fileName, data,'utf-8');
    }else{// 非二进制数据则认为是文件名称
      fileName = data;
    }
  });
  wsServer.send('server ready!');// 告知客户端服务器已就绪
});

运行效果如下:

Websocket upload

上述测试代码中没有过多涉及服务器的存储过程。通常,服务器也会有缓存区上限,如果客户端单次发送的数据量超过服务端缓存区上限,那么服务端也需要多次读取。

心跳连接

生产环境下上传一个文件远比本地测试来得复杂。实际上,从客户端到服务端,中间存在着大量的网络链路,如路由器,防火墙等等。一份文件的上传要经过中间的层层路由转发,过滤。这些中间链路可能会认为一段时间没有数据发送,就自发切断两端的连接。这个时候,由于TCP并不定时检测连接是否中断,而通信的双方又相互没有数据发送,客户端和服务端依然会一厢情愿的信任之前的连接,长此以往,将使得大量的服务端资源被WebSocket连接占用。

正常情况下,TCP的四次挥手完全可以通知两端去释放连接。但是上述这种普遍存在的异常场景,将使得连接的释放成为梦幻。

为此,早在websocket协议实现时,设计者们便提供了一种 Ping/Pong Frame的心跳机制。一端发送Ping Frame,另一端以 Pong Frame响应。这种Frame是一种特殊的数据包,它只包含一些元数据,能够在不影响原通信的情况下维持住连接。

根据规范RFC 6455,Ping Frame包含一个值为9的opcode,它可能携带数据。收到Ping Frame后,Pong Frame必须被作为响应发出。Pong Frame包含一个值为10的opcode,它将包含与Ping Frame中相同的数据。

借助ws包,服务端可以这么来发送Ping Frame。

wsServer.ping();

同时,需要监听客户端响应的pong Frame.

wsServer.on('pong', function(data, flags) {
  console.log(data);// ""
  console.log(flags);// { masked: true,binary: true }
});

以上,由于Ping Frame 不带数据,因此作为响应的Pong Frame的data值为空串。遗憾的是,目前浏览器只能被动发送Pong Frame作为响应(Sending websocket ping/pong frame from browser),无法通过JS API主动向服务端发送Ping Frame。因此对于web服务,可以采取服务端主动ping的方式,来保持住链接。实际应用中,服务端还需要设置心跳的周期,以保证心跳连接可以一直持续。同时,还应该有重发机制,若连续几次没有收到心跳连接的回复,则认为连接已经断开,此时便可以关闭Websocket连接了。

Socket.IO

WebSocket出世已久,很多优秀的大神基于此开发出了各式各样的库。其中Socket.IO是一个非常不错的开源WebSocke库,旨在抹平浏览器之间的兼容性问题。它基于Node.js,支持以下方式优雅降级:

  • Websocket

  • Adobe® Flash® Socket

  • AJAX long polling

  • AJAX multipart streaming

  • Forever Iframe

  • JSONP Polling

如何在项目中使用Socket.IO,请参考第一章 socket.io 简介及使用

小结

EventSource,本质依然是HTTP,它仅提供服务端到客户端的单向文本数据传输,不需要心跳连接,连接断开会持续触发重连。

WebSocket协议,基于TCP协议,它提供双向数据传输,支持二进制,需要心跳连接,连接断开不会重连。

EventSource更轻量和简单,WebSocket支持性更好(因其支持IE10+)。通常来说,使用EventSource能够完成的功能,使用WebSocket一样能够做到,反之却不行,使用时若遇到连接断开或抛错,请及时调用各自的close方法主动释放资源。


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论。

本文作者: louis

本文链接: http://louiszhai.github.io/20...

参考文章


louiszhai
1.4k 声望159 粉丝

前端攻城狮, 知识的二手贩子