灯盏细辛

灯盏细辛 查看完整档案

北京编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

灯盏细辛 收藏了文章 · 2019-07-30

为什么我们要熟悉这些通信协议? 【精读】

打个广告,欢迎加入我们的前端开发交流群:

  • 微信群:

clipboard.png

  • QQ群

clipboard.png

前端的最重要的基础知识点是什么?

  • 原生javaScriptHTML,CSS.
  • Dom操作
  • EventLoop和渲染机制
  • 各类工程化的工具原理以及使用,根据需求定制编写插件和包。(webpack的plugin和babel的预设包)
  • 数据结构和算法(特别是IM以及超大型高并发网站应用等,例如B站
  • 最后便是通信协议
在使用某个技术的时候,一定要去追寻原理和底层的实现,长此以往坚持,只要自身底层的基础扎实,无论技术怎么变化,学习起来都不会太累,总的来说就是拒绝5分钟技术

从输入一个url地址,到显示页面发生了什么出发:

  • 1.浏览器向 DNS 服务器请求解析该 URL 中的域名所对应的 IP 地址;
  • 2.建立TCP连接(三次握手);
  • 3.浏览器发出读取文件(URL 中域名后面部分对应的文件)的HTTP 请求,该请求报文作为 TCP 三次握手的第三个报文的数据发送给服务器;
  • 4.服务器对浏览器请求作出响应,并把对应的 html 文本发送给浏览器;
  • 5.浏览器将该 html 文本并显示内容;
  • 6.释放 TCP连接(四次挥手);

目前常见的通信协议都是建立在TCP链接之上

那么什么是TCP

TCP是因特网中的传输层协议,使用三次握手协议建立连接。当主动方发出SYN连接请求后,等待对方回答

TCP三次握手的过程如下:
  • 客户端发送SYN报文给服务器端,进入SYN_SEND状态。
  • 服务器端收到SYN报文,回应一个SYN(SEQ=y)ACK(ACK=x+1)报文,进入SYN_RECV状态。
  • 客户端收到服务器端的SYN报文,回应一个ACK(ACK=y+1)报文,进入Established状态。
  • 三次握手完成,TCP客户端和服务器端成功地建立连接,可以开始传输数据了。
如图所示:

clipboard.png

TCP的四次挥手:
  • 建立一个连接需要三次握手,而终止一个连接要经过四次握手,这是由TCP的半关闭(half-close)造成的。具体过程如下图所示。
  • 某个应用进程首先调用close,称该端执行“主动关闭”(active close)。该端的TCP于是发送一个FIN分节,表示数据发送完毕。
  • 接收到这个FIN的对端执行 “被动关闭”(passive close),这个FIN由TCP确认。

注意:FIN的接收也作为一个文件结束符(end-of-file)传递给接收端应用进程,放在已排队等候该应用进程接收的任何其他数据之后,因为,FIN的接收意味着接收端应用进程在相应连接上再无额外数据可接收。

  • 一段时间后,接收到这个文件结束符的应用进程将调用close关闭它的套接字。这导致它的TCP也发送一个FIN。
  • 接收这个最终FIN的原发送端TCP(即执行主动关闭的那一端)确认这个FIN。 [3]

既然每个方向都需要一个FIN和一个ACK,因此通常需要4个分节。

特别提示: SYN报文用来通知,FIN报文是用来同步的

clipboard.png

以上就是面试官常问的三次握手,四次挥手,但是这不仅仅面试题,上面仅仅答到了一点皮毛,学习这些是为了让我们后续方便了解他的优缺点。

TCP连接建立后,我们可以有多种协议的方式通信交换数据:

最古老的方式一:http 1.0

  • 早先1.0的HTTP版本,是一种无状态、无连接的应用层协议。
  • HTTP1.0规定浏览器和服务器保持短暂的连接,浏览器的每次请求都需要与服务器建立一个TCP连接,服务器处理完成后立即断开TCP连接(无连接),服务器不跟踪每个客户端也不记录过去的请求(无状态)。
  • 这种无状态性可以借助cookie/session机制来做身份认证和状态记录。而下面两个问题就比较麻烦了。
  • 首先,无连接的特性导致最大的性能缺陷就是无法复用连接。每次发送请求的时候,都需要进行一次TCP的连接,而TCP的连接释放过程又是比较费事的。这种无连接的特性会使得网络的利用率非常低。
  • 其次就是队头阻塞(headoflineblocking)。由于HTTP1.0规定下一个请求必须在前一个请求响应到达之前才能发送。假设前一个请求响应一直不到达,那么下一个请求就不发送,同样的后面的请求也给阻塞了。
Http 1.0的致命缺点,就是无法复用TCP连接和并行发送请求,这样每次一个请求都需要三次握手,而且其实建立连接和释放连接的这个过程是最耗时的,传输数据相反却不那么耗时。还有本地时间被修改导致响应头expires的缓存机制失效的问题~(后面会详细讲)
  • 常见的请求报文~

clipboard.png

于是出现了Http 1.1,这也是技术的发展必然结果~

  • Http 1.1出现,继承了Http1.0的优点,也克服了它的缺点,出现了keep-alive这个头部字段,它表示会在建立TCP连接后,完成首次的请求,并不会立刻断开TCP连接,而是保持这个连接状态~进而可以复用这个通道
  • Http 1.1并且支持请求管道化,“并行”发送请求,但是这个并行,也不是真正意义上的并行,而是可以让我们把先进先出队列从客户端(请求队列)迁移到服务端(响应队列)
例如:客户端同时发了两个请求分别来获取html和css,假如说服务器的css资源先准备就绪,服务器也会先发送html再发送css。
  • B站首页,就有keep-alive,因为他们也有IM的成分在里面。需要大量复用TCP连接~

clipboard.png

  • HTTP1.1好像还是无法解决队头阻塞的问题
实际上,现阶段的浏览器厂商采取了另外一种做法,它允许我们打开多个TCP的会话。也就是说,上图我们看到的并行,其实是不同的TCP连接上的HTTP请求和响应。这也就是我们所熟悉的浏览器对同域下并行加载6~8个资源的限制。而这,才是真正的并行!

Http 1.1的致命缺点:

  • 1.明文传输
  • 2.其实还是没有解决无状态连接的
  • 3.当有多个请求同时被挂起的时候 就会拥塞请求通道,导致后面请求无法发送
  • 4.臃肿的消息首部:HTTP/1.1能压缩请求内容,但是消息首部不能压缩;在现今请求中,消息首部占请求绝大部分(甚至是全部)也较为常见.
我们也可以用dns-prefetch和 preconnect tcp来优化~
<link rel="preconnect" href="//example.com" crossorigin>
<link rel="dns=prefetch" href="//example.com">
  • Tip: webpack可以做任何事情,这些都可以用插件实现

基于这些缺点,出现了Http 2.0

相较于HTTP1.1,HTTP2.0的主要优点有采用二进制帧封装,传输变成多路复用,流量控制算法优化,服务器端推送,首部压缩,优先级等特点。

HTTP1.x的解析是基于文本的,基于文本协议的格式解析存在天然缺陷,文本的表现形式有多样性,要做到健壮性考虑的场景必然很多。而HTTP/2会将所有传输的信息分割为更小的消息和帧,然后采用二进制的格式进行编码,HTTP1.x的头部信息会被封装到HEADER frame,而相应的RequestBody则封装到DATAframe里面。不改动HTTP的语义,使用二进制编码,实现方便且健壮。

多路复用

  • 所有的请求都是通过一个 TCP 连接并发完成。HTTP/1.x 虽然通过 pipeline 也能并发请求,但是多个请求之间的响应会被阻塞的,所以 pipeline 至今也没有被普及应用,而 HTTP/2 做到了真正的并发请求。同时,流还支持优先级和流量控制。当流并发时,就会涉及到流的优先级和依赖。即:HTTP2.0对于同一域名下所有请求都是基于流的,不管对于同一域名访问多少文件,也只建立一路连接。优先级高的流会被优先发送。图片请求的优先级要低于 CSS 和 SCRIPT,这个设计可以确保重要的东西可以被优先加载完

流量控制

  • TCP协议通过sliding window的算法来做流量控制。发送方有个sending window,接收方有receive window。http2.0的flow control是类似receive window的做法,数据的接收方通过告知对方自己的flow window大小表明自己还能接收多少数据。只有Data类型的frame才有flow control的功能。对于flow control,如果接收方在flow window为零的情况下依然更多的frame,则会返回block类型的frame,这张场景一般表明http2.0的部署出了问题。

服务器端推送

  • 服务器端的推送,就是服务器可以对一个客户端请求发送多个响应。除了对最初请求的响应外,服务器还可以额外向客户端推送资源,而无需客户端明确地请求。当浏览器请求一个html,服务器其实大概知道你是接下来要请求资源了,而不需要等待浏览器得到html后解析页面再发送资源请求。

首部压缩

  • HTTP 2.0 在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键-值对,对于相同的数据,不再通过每次请求和响应发送;通信期间几乎不会改变的通用键-值对(用户代理、可接受的媒体类型,等等)只 需发送一次。事实上,如果请求中不包含首部(例如对同一资源的轮询请求),那么 首部开销就是零字节。此时所有首部都自动使用之前请求发送的首部。
  • 如果首部发生变化了,那么只需要发送变化了数据在Headers帧里面,新增或修改的首部帧会被追加到“首部表”。首部表在 HTTP 2.0 的连接存续期内始终存在,由客户端和服务器共同渐进地更新 。
  • 本质上,当然是为了减少请求啦,通过多个js或css合并成一个文件,多张小图片拼合成Sprite图,可以让多个HTTP请求减少为一个,减少额外的协议开销,而提升性能。当然,一个HTTP的请求的body太大也是不合理的,有个度。文件的合并也会牺牲模块化和缓存粒度,可以把“稳定”的代码or 小图 合并为一个文件or一张Sprite,让其充分地缓存起来,从而区分开迭代快的文件。
Demo的性能对比:

clipboard.png

Http的那些致命缺陷,并没有完全解决,于是有了https,也是目前应用最广的协议之一

HTTP+ 加密 + 认证 + 完整性保护 =HTTPS ?

可以这样认为~HTTP 加上加密处理和认证以及完整性保护后即是 HTTPS

  • 如果在 HTTP 协议通信过程中使用未经加密的明文,比如在 Web 页面中输入信用卡号,如果这条通信线路遭到窃听,那么信用卡号就暴露了。
  • 另外,对于 HTTP 来说,服务器也好,客户端也好,都是没有办法确认通信方的。

因为很有可能并不是和原本预想的通信方在实际通信。并且还需要考虑到接收到的报文在通信途中已经遭到篡改这一可能性。

  • 为了统一解决上述这些问题,需要在 HTTP 上再加入加密处理和认证等机制。我们把添加了加密及认证机制的 HTTP 称为 HTTPS
不加密的重要内容被wireshark这类工具抓到包,后果很严重~

HTTPS 是身披 SSL 外壳的 HTTP

  • HTTPS 并非是应用层的一种新协议。只是 HTTP 通信接口部分用 SSL(SecureSocket Layer)和 TLS(Transport Layer Security)协议代替而已。

通常,HTTP 直接和 TCP 通信。

  • 当使用 SSL 时,则演变成先和 SSL 通信,再由 SSL和 TCP 通信了。简言之,所谓 HTTPS,其实就是身披 SSL 协议这层外壳的HTTP。
  • 在采用 SSL 后,HTTP 就拥有了 HTTPS 的加密、证书和完整性保护这些功能。SSL 是独立于 HTTP 的协议,所以不光是 HTTP 协议,其他运行在应用层的 SMTP和 Telnet 等协议均可配合 SSL 协议使用。可以说 SSL 是当今世界上应用最为广泛的网络安全术。

clipboard.png

相互交换密钥的公开密钥加密技术 -----对称加密

clipboard.png

  • 在对 SSL 进行讲解之前,我们先来了解一下加密方法。SSL 采用一种叫做公开密钥加密(Public-key cryptography)的加密处理方式。
  • 近代的加密方法中加密算法是公开的,而密钥却是保密的。通过这种方式得以保持加密方法的安全性。
加密和解密都会用到密钥。没有密钥就无法对密码解密,反过来说,任何人只要持有密钥就能解密了。如果密钥被攻击者获得,那加密也就失去了意义。

HTTPS 采用混合加密机制

  • HTTPS 采用共享密钥加密和公开密钥加密两者并用的混合加密机制。
  • 但是公开密钥加密与共享密钥加密相比,其处理速度要慢。所以应充分利用两者各自的优势,将多种方法组合起来用于通信。在交换密钥环节使用公开密钥加密方式,之后的建立通信交换报文阶段则使用共享密钥加密方式。

HTTPS虽好,非对称加密虽好,但是不要滥用

HTTPS 也存在一些问题,那就是当使用 SSL 时,它的处理速度会变慢。

SSL 的慢分两种。一种是指通信慢。另一种是指由于大量消耗 CPU 及内存等资源,导致处理速度变慢。

  • 和使用 HTTP 相比,网络负载可能会变慢 2 到 100 倍。除去和 TCP 连接、发送 HTTP 请求 ? 响应以外,还必须进行 SSL 通信,因此整体上处理通信量不可避免会增加。
  • 另一点是 SSL 必须进行加密处理。在服务器和客户端都需要进行加密和解密的运算处理。因此从结果上讲,比起 HTTP 会更多地消耗服务器和客户端的硬件资源,导致负载增强。

针对速度变慢这一问题,并没有根本性的解决方案,我们会使用 SSL 加速器这种(专用服务器)硬件来改善该问题。该硬件为 SSL 通信专用硬件,相对软件来讲,能够提高数倍 SSL 的计算速度。仅在 SSL 处理时发挥 SSL加速器的功效,以分担负载。

为什么不一直使用 HTTPS

  • 既然 HTTPS 那么安全可靠,那为何所有的 Web 网站不一直使用 HTTPS?

其中一个原因是,因为与纯文本通信相比,加密通信会消耗更多的 CPU 及内存资源。如果每次通信都加密,会消耗相当多的资源,平摊到一台计算机上时,能够处理的请求数量必定也会随之减少。

  • 因此,如果是非敏感信息则使用 HTTP 通信,只有在包含个人信息等敏感数据时,才利用 HTTPS 加密通信。

特别是每当那些访问量较多的 Web 网站在进行加密处理时,它们所承担着的负载不容小觑。在进行加密处理时,并非对所有内容都进行加密处理,而是仅在那些需要信息隐藏时才会加密,以节约资源。

  • 除此之外,想要节约购买证书的开销也是原因之一。

要进行 HTTPS 通信,证书是必不可少的。而使用的证书必须向认证机构(CA)购买。证书价格可能会根据不同的认证机构略有不同。通常,一年的授权需要数万日元(现在一万日元大约折合 600 人民币)。那些购买证书并不合算的服务以及一些个人网站,可能只会选择采用HTTP 的通信方式。

clipboard.png

复习完了基本的协议,介绍下报文格式:

  • 请求报文格式

clipboard.png

  • 响应报文格式

clipboard.png

所谓响应头,请求头,其实都可以自己添加字段,只要前后端给对应的处理机制即可

Node.js代码实现响应头的设置


  if (config.cache.expires) {
                        res.setHeader("expries", new Date(Date.now() + (config.cache.maxAge * 1000)))
                    }
                    if (config.cache.lastModified) {
                        res.setHeader("last-modified", stat.mtime.toUTCString())
                    }
                    if (config.cache.etag) {
                        res.setHeader('Etag', etagFn(stat))
                    }
}

响应头的详解:

clipboard.png

本人的开源项目,手写的Node.js静态资源服务器,https://github.com/JinJieTan/...,欢迎 star~

浏览器的缓存策略:

  • 首次请求:

clipboard.png

  • 非首次请求:

clipboard.png

  • 用户行为与缓存:

clipboard.png

不能缓存的请求:

无法被浏览器缓存的请求如下:

  • HTTP信息头中包含Cache-Control:no-cache,pragma:no-cache(HTTP1.0),或Cache-Control:max-age=0等告诉浏览器不用缓存的请求
  • 需要根据Cookie,认证信息等决定输入内容的动态请求是不能被缓存的
  • 经过HTTPS安全加密的请求(有人也经过测试发现,ie其实在头部加入Cache-Control:max-age信息,firefox在头部加入Cache-Control:Public之后,能够对HTTPS的资源进行缓寸)
  • 经过HTTPS安全加密的请求(有人也经过测试发现,ie其实在头部加入Cache-Control:max-age信息,firefox在头部加入Cache-Control:Public之后,能够对HTTPS的资源进行缓存,参考《HTTPS的七个误解》)
  • POST请求无法被缓存
  • HTTP响应头中不包含Last-Modified/Etag,也不包含Cache-Control/Expires的请求无法被缓存

即时通讯协议

从最初的没有websocket协议开始:

传统的协议无法服务端主动push数据,于是有了这些骚操作:
  • 轮询,在一个定时器中不停向服务端发送请求。
  • 长轮询,发送请求给服务端,直到服务端觉得可以返回数据了再返回响应,否则这个请求一直挂起~
  • 以上两种都有瑕疵,而且比较明显,这里不再描述。

为了解决实时通讯,数据同步的问题,出现了webSocket.

  • webSockets的目标是在一个单独的持久连接上提供全双工、双向通信。在Javascript创建了Web Socket之后,会有一个HTTP请求发送到浏览器以发起连接。在取得服务器响应后,建立的连接会将HTTP升级从HTTP协议交换为WebSocket协议。
  • webSocket原理: 在TCP连接第一次握手的时候,升级为ws协议。后面的数据交互都复用这个TCP通道。
  • 客户端代码实现:
  const ws = new WebSocket('ws://localhost:8080');
        ws.onopen = function () {
            ws.send('123')
            console.log('open')
        }
        ws.onmessage = function () {
            console.log('onmessage')
        }
        ws.onerror = function () {
            console.log('onerror')
        }
        ws.onclose = function () {
            console.log('onclose')
        }
  • 服务端使用 Node.js语言实现
const express = require('express')
const { Server } = require("ws");
const app = express()
const wsServer = new Server({ port: 8080 })
wsServer.on('connection', (ws) => {
    ws.onopen = function () {
        console.log('open')
    }
    ws.onmessage = function (data) {
        console.log(data)
        ws.send('234')
        console.log('onmessage' + data)
    }
    ws.onerror = function () {
        console.log('onerror')
    }
    ws.onclose = function () {
        console.log('onclose')
    }
});

app.listen(8000, (err) => {
    if (!err) { console.log('监听OK') } else {
        console.log('监听失败')
    }
})

webSocket的报文格式有一些不一样:

![图片上传中...]

  • 客户端和服务端进行Websocket消息传递是这样的:

    • 客户端:将消息切割成多个帧,并发送给服务端。
    • 服务端:接收消息帧,并将关联的帧重新组装成完整的消息。

即时通讯的心跳检测:

pingandpong

  • 服务端Go实现:
package main

import (
    "net/http"
    "time"

    "github.com/gorilla/websocket"
)

var (
    //完成握手操作
    upgrade = websocket.Upgrader{
       //允许跨域(一般来讲,websocket都是独立部署的)
       CheckOrigin:func(r *http.Request) bool {
            return true
       },
    }
)

func wsHandler(w http.ResponseWriter, r *http.Request) {
   var (
         conn *websocket.Conn
         err error
         data []byte
   )
   //服务端对客户端的http请求(升级为websocket协议)进行应答,应答之后,协议升级为websocket,http建立连接时的tcp三次握手将保持。
   if conn, err = upgrade.Upgrade(w, r, nil); err != nil {
        return
   }

    //启动一个协程,每隔5s向客户端发送一次心跳消息
    go func() {
        var (
            err error
        )
        for {
            if err = conn.WriteMessage(websocket.TextMessage, []byte("heartbeat")); err != nil {
                return
            }
            time.Sleep(5 * time.Second)
        }
    }()

   //得到websocket的长链接之后,就可以对客户端传递的数据进行操作了
   for {
         //通过websocket长链接读到的数据可以是text文本数据,也可以是二进制Binary
        if _, data, err = conn.ReadMessage(); err != nil {
            goto ERR
     }
     if err = conn.WriteMessage(websocket.TextMessage, data); err != nil {
         goto ERR
     }
   }
ERR:
    //出错之后,关闭socket连接
    conn.Close()
}

func main() {
    http.HandleFunc("/ws", wsHandler)
    http.ListenAndServe("0.0.0.0:7777", nil)
}

客户端的心跳检测(Node.js实现):

this.heartTimer = setInterval(() => {
      if (this.heartbeatLoss < MAXLOSSTIMES) {
        events.emit('network', 'sendHeart');
        this.heartbeatLoss += 1;
        this.phoneLoss += 1;
      } else {
        events.emit('network', 'offline');
        this.stop();
      }
      if (this.phoneLoss > MAXLOSSTIMES) {
        this.PhoneLive = false;
        events.emit('network', 'phoneDisconnect');
      }
    }, 5000);

自定义即时通信协议:

new Socket开始:

  • 目前即时通讯大都使用现有大公司成熟的SDK接入,但是逼格高些还是自己重写比较好。
  • 打个小广告,我们公司就是自己定义的即时通讯协议~招聘一位高级前端,地点深圳-深南大道,做跨平台IM桌面应用开发的~
  • 客户端代码实现(Node.js):

const {Socket} = require('net') 
const tcp = new Socket()
tcp.setKeepAlive(true);
tcp.setNoDelay(true);
//保持底层tcp链接不断,长连接
指定对应域名端口号链接
tcp.connect(80,166.166.0.0)
建立连接后
根据后端传送的数据类型 使用对应不同的解析
readUInt8 readUInt16LE readUInt32LE readIntLE等处理后得到myBuf 
const myBuf = buffer.slice(start);//从对应的指针开始的位置截取buffer
const header = myBuf.slice(headstart,headend)//截取对应的头部buffer
const body = JSON.parse(myBuf.slice(headend-headstart,bodylength).tostring())
//精确截取数据体的buffer,并且转化成js对象
即时通讯强烈推荐使用Golang,GRPC,Prob传输数据。

上面的一些代码,都在我的开源项目中:

觉得写得不错,可以点个赞支持下,文章也借鉴了一下其他大佬的文章,但是地址都贴上来了~ 欢迎gitHub点个star哦~
查看原文

灯盏细辛 赞了文章 · 2019-07-30

为什么我们要熟悉这些通信协议? 【精读】

打个广告,欢迎加入我们的前端开发交流群:

  • 微信群:

clipboard.png

  • QQ群

clipboard.png

前端的最重要的基础知识点是什么?

  • 原生javaScriptHTML,CSS.
  • Dom操作
  • EventLoop和渲染机制
  • 各类工程化的工具原理以及使用,根据需求定制编写插件和包。(webpack的plugin和babel的预设包)
  • 数据结构和算法(特别是IM以及超大型高并发网站应用等,例如B站
  • 最后便是通信协议
在使用某个技术的时候,一定要去追寻原理和底层的实现,长此以往坚持,只要自身底层的基础扎实,无论技术怎么变化,学习起来都不会太累,总的来说就是拒绝5分钟技术

从输入一个url地址,到显示页面发生了什么出发:

  • 1.浏览器向 DNS 服务器请求解析该 URL 中的域名所对应的 IP 地址;
  • 2.建立TCP连接(三次握手);
  • 3.浏览器发出读取文件(URL 中域名后面部分对应的文件)的HTTP 请求,该请求报文作为 TCP 三次握手的第三个报文的数据发送给服务器;
  • 4.服务器对浏览器请求作出响应,并把对应的 html 文本发送给浏览器;
  • 5.浏览器将该 html 文本并显示内容;
  • 6.释放 TCP连接(四次挥手);

目前常见的通信协议都是建立在TCP链接之上

那么什么是TCP

TCP是因特网中的传输层协议,使用三次握手协议建立连接。当主动方发出SYN连接请求后,等待对方回答

TCP三次握手的过程如下:
  • 客户端发送SYN报文给服务器端,进入SYN_SEND状态。
  • 服务器端收到SYN报文,回应一个SYN(SEQ=y)ACK(ACK=x+1)报文,进入SYN_RECV状态。
  • 客户端收到服务器端的SYN报文,回应一个ACK(ACK=y+1)报文,进入Established状态。
  • 三次握手完成,TCP客户端和服务器端成功地建立连接,可以开始传输数据了。
如图所示:

clipboard.png

TCP的四次挥手:
  • 建立一个连接需要三次握手,而终止一个连接要经过四次握手,这是由TCP的半关闭(half-close)造成的。具体过程如下图所示。
  • 某个应用进程首先调用close,称该端执行“主动关闭”(active close)。该端的TCP于是发送一个FIN分节,表示数据发送完毕。
  • 接收到这个FIN的对端执行 “被动关闭”(passive close),这个FIN由TCP确认。

注意:FIN的接收也作为一个文件结束符(end-of-file)传递给接收端应用进程,放在已排队等候该应用进程接收的任何其他数据之后,因为,FIN的接收意味着接收端应用进程在相应连接上再无额外数据可接收。

  • 一段时间后,接收到这个文件结束符的应用进程将调用close关闭它的套接字。这导致它的TCP也发送一个FIN。
  • 接收这个最终FIN的原发送端TCP(即执行主动关闭的那一端)确认这个FIN。 [3]

既然每个方向都需要一个FIN和一个ACK,因此通常需要4个分节。

特别提示: SYN报文用来通知,FIN报文是用来同步的

clipboard.png

以上就是面试官常问的三次握手,四次挥手,但是这不仅仅面试题,上面仅仅答到了一点皮毛,学习这些是为了让我们后续方便了解他的优缺点。

TCP连接建立后,我们可以有多种协议的方式通信交换数据:

最古老的方式一:http 1.0

  • 早先1.0的HTTP版本,是一种无状态、无连接的应用层协议。
  • HTTP1.0规定浏览器和服务器保持短暂的连接,浏览器的每次请求都需要与服务器建立一个TCP连接,服务器处理完成后立即断开TCP连接(无连接),服务器不跟踪每个客户端也不记录过去的请求(无状态)。
  • 这种无状态性可以借助cookie/session机制来做身份认证和状态记录。而下面两个问题就比较麻烦了。
  • 首先,无连接的特性导致最大的性能缺陷就是无法复用连接。每次发送请求的时候,都需要进行一次TCP的连接,而TCP的连接释放过程又是比较费事的。这种无连接的特性会使得网络的利用率非常低。
  • 其次就是队头阻塞(headoflineblocking)。由于HTTP1.0规定下一个请求必须在前一个请求响应到达之前才能发送。假设前一个请求响应一直不到达,那么下一个请求就不发送,同样的后面的请求也给阻塞了。
Http 1.0的致命缺点,就是无法复用TCP连接和并行发送请求,这样每次一个请求都需要三次握手,而且其实建立连接和释放连接的这个过程是最耗时的,传输数据相反却不那么耗时。还有本地时间被修改导致响应头expires的缓存机制失效的问题~(后面会详细讲)
  • 常见的请求报文~

clipboard.png

于是出现了Http 1.1,这也是技术的发展必然结果~

  • Http 1.1出现,继承了Http1.0的优点,也克服了它的缺点,出现了keep-alive这个头部字段,它表示会在建立TCP连接后,完成首次的请求,并不会立刻断开TCP连接,而是保持这个连接状态~进而可以复用这个通道
  • Http 1.1并且支持请求管道化,“并行”发送请求,但是这个并行,也不是真正意义上的并行,而是可以让我们把先进先出队列从客户端(请求队列)迁移到服务端(响应队列)
例如:客户端同时发了两个请求分别来获取html和css,假如说服务器的css资源先准备就绪,服务器也会先发送html再发送css。
  • B站首页,就有keep-alive,因为他们也有IM的成分在里面。需要大量复用TCP连接~

clipboard.png

  • HTTP1.1好像还是无法解决队头阻塞的问题
实际上,现阶段的浏览器厂商采取了另外一种做法,它允许我们打开多个TCP的会话。也就是说,上图我们看到的并行,其实是不同的TCP连接上的HTTP请求和响应。这也就是我们所熟悉的浏览器对同域下并行加载6~8个资源的限制。而这,才是真正的并行!

Http 1.1的致命缺点:

  • 1.明文传输
  • 2.其实还是没有解决无状态连接的
  • 3.当有多个请求同时被挂起的时候 就会拥塞请求通道,导致后面请求无法发送
  • 4.臃肿的消息首部:HTTP/1.1能压缩请求内容,但是消息首部不能压缩;在现今请求中,消息首部占请求绝大部分(甚至是全部)也较为常见.
我们也可以用dns-prefetch和 preconnect tcp来优化~
<link rel="preconnect" href="//example.com" crossorigin>
<link rel="dns=prefetch" href="//example.com">
  • Tip: webpack可以做任何事情,这些都可以用插件实现

基于这些缺点,出现了Http 2.0

相较于HTTP1.1,HTTP2.0的主要优点有采用二进制帧封装,传输变成多路复用,流量控制算法优化,服务器端推送,首部压缩,优先级等特点。

HTTP1.x的解析是基于文本的,基于文本协议的格式解析存在天然缺陷,文本的表现形式有多样性,要做到健壮性考虑的场景必然很多。而HTTP/2会将所有传输的信息分割为更小的消息和帧,然后采用二进制的格式进行编码,HTTP1.x的头部信息会被封装到HEADER frame,而相应的RequestBody则封装到DATAframe里面。不改动HTTP的语义,使用二进制编码,实现方便且健壮。

多路复用

  • 所有的请求都是通过一个 TCP 连接并发完成。HTTP/1.x 虽然通过 pipeline 也能并发请求,但是多个请求之间的响应会被阻塞的,所以 pipeline 至今也没有被普及应用,而 HTTP/2 做到了真正的并发请求。同时,流还支持优先级和流量控制。当流并发时,就会涉及到流的优先级和依赖。即:HTTP2.0对于同一域名下所有请求都是基于流的,不管对于同一域名访问多少文件,也只建立一路连接。优先级高的流会被优先发送。图片请求的优先级要低于 CSS 和 SCRIPT,这个设计可以确保重要的东西可以被优先加载完

流量控制

  • TCP协议通过sliding window的算法来做流量控制。发送方有个sending window,接收方有receive window。http2.0的flow control是类似receive window的做法,数据的接收方通过告知对方自己的flow window大小表明自己还能接收多少数据。只有Data类型的frame才有flow control的功能。对于flow control,如果接收方在flow window为零的情况下依然更多的frame,则会返回block类型的frame,这张场景一般表明http2.0的部署出了问题。

服务器端推送

  • 服务器端的推送,就是服务器可以对一个客户端请求发送多个响应。除了对最初请求的响应外,服务器还可以额外向客户端推送资源,而无需客户端明确地请求。当浏览器请求一个html,服务器其实大概知道你是接下来要请求资源了,而不需要等待浏览器得到html后解析页面再发送资源请求。

首部压缩

  • HTTP 2.0 在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键-值对,对于相同的数据,不再通过每次请求和响应发送;通信期间几乎不会改变的通用键-值对(用户代理、可接受的媒体类型,等等)只 需发送一次。事实上,如果请求中不包含首部(例如对同一资源的轮询请求),那么 首部开销就是零字节。此时所有首部都自动使用之前请求发送的首部。
  • 如果首部发生变化了,那么只需要发送变化了数据在Headers帧里面,新增或修改的首部帧会被追加到“首部表”。首部表在 HTTP 2.0 的连接存续期内始终存在,由客户端和服务器共同渐进地更新 。
  • 本质上,当然是为了减少请求啦,通过多个js或css合并成一个文件,多张小图片拼合成Sprite图,可以让多个HTTP请求减少为一个,减少额外的协议开销,而提升性能。当然,一个HTTP的请求的body太大也是不合理的,有个度。文件的合并也会牺牲模块化和缓存粒度,可以把“稳定”的代码or 小图 合并为一个文件or一张Sprite,让其充分地缓存起来,从而区分开迭代快的文件。
Demo的性能对比:

clipboard.png

Http的那些致命缺陷,并没有完全解决,于是有了https,也是目前应用最广的协议之一

HTTP+ 加密 + 认证 + 完整性保护 =HTTPS ?

可以这样认为~HTTP 加上加密处理和认证以及完整性保护后即是 HTTPS

  • 如果在 HTTP 协议通信过程中使用未经加密的明文,比如在 Web 页面中输入信用卡号,如果这条通信线路遭到窃听,那么信用卡号就暴露了。
  • 另外,对于 HTTP 来说,服务器也好,客户端也好,都是没有办法确认通信方的。

因为很有可能并不是和原本预想的通信方在实际通信。并且还需要考虑到接收到的报文在通信途中已经遭到篡改这一可能性。

  • 为了统一解决上述这些问题,需要在 HTTP 上再加入加密处理和认证等机制。我们把添加了加密及认证机制的 HTTP 称为 HTTPS
不加密的重要内容被wireshark这类工具抓到包,后果很严重~

HTTPS 是身披 SSL 外壳的 HTTP

  • HTTPS 并非是应用层的一种新协议。只是 HTTP 通信接口部分用 SSL(SecureSocket Layer)和 TLS(Transport Layer Security)协议代替而已。

通常,HTTP 直接和 TCP 通信。

  • 当使用 SSL 时,则演变成先和 SSL 通信,再由 SSL和 TCP 通信了。简言之,所谓 HTTPS,其实就是身披 SSL 协议这层外壳的HTTP。
  • 在采用 SSL 后,HTTP 就拥有了 HTTPS 的加密、证书和完整性保护这些功能。SSL 是独立于 HTTP 的协议,所以不光是 HTTP 协议,其他运行在应用层的 SMTP和 Telnet 等协议均可配合 SSL 协议使用。可以说 SSL 是当今世界上应用最为广泛的网络安全术。

clipboard.png

相互交换密钥的公开密钥加密技术 -----对称加密

clipboard.png

  • 在对 SSL 进行讲解之前,我们先来了解一下加密方法。SSL 采用一种叫做公开密钥加密(Public-key cryptography)的加密处理方式。
  • 近代的加密方法中加密算法是公开的,而密钥却是保密的。通过这种方式得以保持加密方法的安全性。
加密和解密都会用到密钥。没有密钥就无法对密码解密,反过来说,任何人只要持有密钥就能解密了。如果密钥被攻击者获得,那加密也就失去了意义。

HTTPS 采用混合加密机制

  • HTTPS 采用共享密钥加密和公开密钥加密两者并用的混合加密机制。
  • 但是公开密钥加密与共享密钥加密相比,其处理速度要慢。所以应充分利用两者各自的优势,将多种方法组合起来用于通信。在交换密钥环节使用公开密钥加密方式,之后的建立通信交换报文阶段则使用共享密钥加密方式。

HTTPS虽好,非对称加密虽好,但是不要滥用

HTTPS 也存在一些问题,那就是当使用 SSL 时,它的处理速度会变慢。

SSL 的慢分两种。一种是指通信慢。另一种是指由于大量消耗 CPU 及内存等资源,导致处理速度变慢。

  • 和使用 HTTP 相比,网络负载可能会变慢 2 到 100 倍。除去和 TCP 连接、发送 HTTP 请求 ? 响应以外,还必须进行 SSL 通信,因此整体上处理通信量不可避免会增加。
  • 另一点是 SSL 必须进行加密处理。在服务器和客户端都需要进行加密和解密的运算处理。因此从结果上讲,比起 HTTP 会更多地消耗服务器和客户端的硬件资源,导致负载增强。

针对速度变慢这一问题,并没有根本性的解决方案,我们会使用 SSL 加速器这种(专用服务器)硬件来改善该问题。该硬件为 SSL 通信专用硬件,相对软件来讲,能够提高数倍 SSL 的计算速度。仅在 SSL 处理时发挥 SSL加速器的功效,以分担负载。

为什么不一直使用 HTTPS

  • 既然 HTTPS 那么安全可靠,那为何所有的 Web 网站不一直使用 HTTPS?

其中一个原因是,因为与纯文本通信相比,加密通信会消耗更多的 CPU 及内存资源。如果每次通信都加密,会消耗相当多的资源,平摊到一台计算机上时,能够处理的请求数量必定也会随之减少。

  • 因此,如果是非敏感信息则使用 HTTP 通信,只有在包含个人信息等敏感数据时,才利用 HTTPS 加密通信。

特别是每当那些访问量较多的 Web 网站在进行加密处理时,它们所承担着的负载不容小觑。在进行加密处理时,并非对所有内容都进行加密处理,而是仅在那些需要信息隐藏时才会加密,以节约资源。

  • 除此之外,想要节约购买证书的开销也是原因之一。

要进行 HTTPS 通信,证书是必不可少的。而使用的证书必须向认证机构(CA)购买。证书价格可能会根据不同的认证机构略有不同。通常,一年的授权需要数万日元(现在一万日元大约折合 600 人民币)。那些购买证书并不合算的服务以及一些个人网站,可能只会选择采用HTTP 的通信方式。

clipboard.png

复习完了基本的协议,介绍下报文格式:

  • 请求报文格式

clipboard.png

  • 响应报文格式

clipboard.png

所谓响应头,请求头,其实都可以自己添加字段,只要前后端给对应的处理机制即可

Node.js代码实现响应头的设置


  if (config.cache.expires) {
                        res.setHeader("expries", new Date(Date.now() + (config.cache.maxAge * 1000)))
                    }
                    if (config.cache.lastModified) {
                        res.setHeader("last-modified", stat.mtime.toUTCString())
                    }
                    if (config.cache.etag) {
                        res.setHeader('Etag', etagFn(stat))
                    }
}

响应头的详解:

clipboard.png

本人的开源项目,手写的Node.js静态资源服务器,https://github.com/JinJieTan/...,欢迎 star~

浏览器的缓存策略:

  • 首次请求:

clipboard.png

  • 非首次请求:

clipboard.png

  • 用户行为与缓存:

clipboard.png

不能缓存的请求:

无法被浏览器缓存的请求如下:

  • HTTP信息头中包含Cache-Control:no-cache,pragma:no-cache(HTTP1.0),或Cache-Control:max-age=0等告诉浏览器不用缓存的请求
  • 需要根据Cookie,认证信息等决定输入内容的动态请求是不能被缓存的
  • 经过HTTPS安全加密的请求(有人也经过测试发现,ie其实在头部加入Cache-Control:max-age信息,firefox在头部加入Cache-Control:Public之后,能够对HTTPS的资源进行缓寸)
  • 经过HTTPS安全加密的请求(有人也经过测试发现,ie其实在头部加入Cache-Control:max-age信息,firefox在头部加入Cache-Control:Public之后,能够对HTTPS的资源进行缓存,参考《HTTPS的七个误解》)
  • POST请求无法被缓存
  • HTTP响应头中不包含Last-Modified/Etag,也不包含Cache-Control/Expires的请求无法被缓存

即时通讯协议

从最初的没有websocket协议开始:

传统的协议无法服务端主动push数据,于是有了这些骚操作:
  • 轮询,在一个定时器中不停向服务端发送请求。
  • 长轮询,发送请求给服务端,直到服务端觉得可以返回数据了再返回响应,否则这个请求一直挂起~
  • 以上两种都有瑕疵,而且比较明显,这里不再描述。

为了解决实时通讯,数据同步的问题,出现了webSocket.

  • webSockets的目标是在一个单独的持久连接上提供全双工、双向通信。在Javascript创建了Web Socket之后,会有一个HTTP请求发送到浏览器以发起连接。在取得服务器响应后,建立的连接会将HTTP升级从HTTP协议交换为WebSocket协议。
  • webSocket原理: 在TCP连接第一次握手的时候,升级为ws协议。后面的数据交互都复用这个TCP通道。
  • 客户端代码实现:
  const ws = new WebSocket('ws://localhost:8080');
        ws.onopen = function () {
            ws.send('123')
            console.log('open')
        }
        ws.onmessage = function () {
            console.log('onmessage')
        }
        ws.onerror = function () {
            console.log('onerror')
        }
        ws.onclose = function () {
            console.log('onclose')
        }
  • 服务端使用 Node.js语言实现
const express = require('express')
const { Server } = require("ws");
const app = express()
const wsServer = new Server({ port: 8080 })
wsServer.on('connection', (ws) => {
    ws.onopen = function () {
        console.log('open')
    }
    ws.onmessage = function (data) {
        console.log(data)
        ws.send('234')
        console.log('onmessage' + data)
    }
    ws.onerror = function () {
        console.log('onerror')
    }
    ws.onclose = function () {
        console.log('onclose')
    }
});

app.listen(8000, (err) => {
    if (!err) { console.log('监听OK') } else {
        console.log('监听失败')
    }
})

webSocket的报文格式有一些不一样:

![图片上传中...]

  • 客户端和服务端进行Websocket消息传递是这样的:

    • 客户端:将消息切割成多个帧,并发送给服务端。
    • 服务端:接收消息帧,并将关联的帧重新组装成完整的消息。

即时通讯的心跳检测:

pingandpong

  • 服务端Go实现:
package main

import (
    "net/http"
    "time"

    "github.com/gorilla/websocket"
)

var (
    //完成握手操作
    upgrade = websocket.Upgrader{
       //允许跨域(一般来讲,websocket都是独立部署的)
       CheckOrigin:func(r *http.Request) bool {
            return true
       },
    }
)

func wsHandler(w http.ResponseWriter, r *http.Request) {
   var (
         conn *websocket.Conn
         err error
         data []byte
   )
   //服务端对客户端的http请求(升级为websocket协议)进行应答,应答之后,协议升级为websocket,http建立连接时的tcp三次握手将保持。
   if conn, err = upgrade.Upgrade(w, r, nil); err != nil {
        return
   }

    //启动一个协程,每隔5s向客户端发送一次心跳消息
    go func() {
        var (
            err error
        )
        for {
            if err = conn.WriteMessage(websocket.TextMessage, []byte("heartbeat")); err != nil {
                return
            }
            time.Sleep(5 * time.Second)
        }
    }()

   //得到websocket的长链接之后,就可以对客户端传递的数据进行操作了
   for {
         //通过websocket长链接读到的数据可以是text文本数据,也可以是二进制Binary
        if _, data, err = conn.ReadMessage(); err != nil {
            goto ERR
     }
     if err = conn.WriteMessage(websocket.TextMessage, data); err != nil {
         goto ERR
     }
   }
ERR:
    //出错之后,关闭socket连接
    conn.Close()
}

func main() {
    http.HandleFunc("/ws", wsHandler)
    http.ListenAndServe("0.0.0.0:7777", nil)
}

客户端的心跳检测(Node.js实现):

this.heartTimer = setInterval(() => {
      if (this.heartbeatLoss < MAXLOSSTIMES) {
        events.emit('network', 'sendHeart');
        this.heartbeatLoss += 1;
        this.phoneLoss += 1;
      } else {
        events.emit('network', 'offline');
        this.stop();
      }
      if (this.phoneLoss > MAXLOSSTIMES) {
        this.PhoneLive = false;
        events.emit('network', 'phoneDisconnect');
      }
    }, 5000);

自定义即时通信协议:

new Socket开始:

  • 目前即时通讯大都使用现有大公司成熟的SDK接入,但是逼格高些还是自己重写比较好。
  • 打个小广告,我们公司就是自己定义的即时通讯协议~招聘一位高级前端,地点深圳-深南大道,做跨平台IM桌面应用开发的~
  • 客户端代码实现(Node.js):

const {Socket} = require('net') 
const tcp = new Socket()
tcp.setKeepAlive(true);
tcp.setNoDelay(true);
//保持底层tcp链接不断,长连接
指定对应域名端口号链接
tcp.connect(80,166.166.0.0)
建立连接后
根据后端传送的数据类型 使用对应不同的解析
readUInt8 readUInt16LE readUInt32LE readIntLE等处理后得到myBuf 
const myBuf = buffer.slice(start);//从对应的指针开始的位置截取buffer
const header = myBuf.slice(headstart,headend)//截取对应的头部buffer
const body = JSON.parse(myBuf.slice(headend-headstart,bodylength).tostring())
//精确截取数据体的buffer,并且转化成js对象
即时通讯强烈推荐使用Golang,GRPC,Prob传输数据。

上面的一些代码,都在我的开源项目中:

觉得写得不错,可以点个赞支持下,文章也借鉴了一下其他大佬的文章,但是地址都贴上来了~ 欢迎gitHub点个star哦~
查看原文

赞 180 收藏 143 评论 0

灯盏细辛 赞了文章 · 2019-03-21

白话es6系列一:Array.of()和Array.from()

es6新增了二种方法:Array.of()和Array.from(),它们有什么用途呢?在平时的开发中能给我们带来什么方便呢?本篇将从一个创建数组的小问题开始,逐步揭开它们的面纱。

一个问题

首先,我们来看一个问题,我需要创建一个共81项的数组,有9行,每行9个数(从1-9),在页面上进行展示,如下:
clipboard.png

怎么做呢?可以这样:

let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9]
arr = arr.concat(arr)
arr = arr.concat(arr)
arr = arr.concat(arr)
arr = arr.concat([1, 2, 3, 4, 5, 6, 7, 8, 9])

console.table(arr)

真的不优雅!一看就是菜鸡程序员写的,但不管怎么样,的确实现了功能。
那规模加大,需要的是810项的数组呢?那就需要换一种实现方式了,如下:

let arr = Array.apply(null, { length: 810 })
  .map((item, index) => {
    return {
      id: index,
      number: index % 9 + 1
    }
  })

看上去是不错,但容易让人困惑,阅读起来也不直观,又是apply,又是null的,有点让人抓狂。
可能有人会说,用es6的Array.from()来做啊:

let arr = Array.from({ length: 810 }, (item, index) => ({
  id: index,
  number: index % 9 + 1
}))

不错哦,代码量变少了。更重要的是,代码意图也直观:数组长度810,每一项按照约定的规则进行初始化。

看完了问题,接下来具体说说这二个方法的诞生背景和用法,先来看Array.of

Array.of

es6之前,最常用的创建数组有如下两种方式,一种是字面量方式,还一种是new Array()方式:

var arr1 = [1, 3, 5]
var arr2 = new Array(1, 3, 5)

这里要重点提一下new Array()方式,因为它有一个缺陷,如果要用这种方式创建一个数组,其中就只有一个数字3,它居然无法做到!

var arr = new Array(3)

上面只会得到一个长度为3的数组(各项都是empty),而不是一个长度为1的数组(其中的项为数字3)。
我给你一个数字3,让你给我存到数组中,你给我一个长度为3的数组?惊不惊喜,意不意外?

还好,ES6引入了Array.of()方法来解决这个问题。

let arr = Array.of(3)

上面代码创建了一个长度为1的数组(其中的项为数字3)。
相比new Array()这种方式,Array.of()的方式显得更明了,我需要存什么,扔给它,它就给我存到数组中,不会给我意外和惊吓。

Array.from

可能会有人问,既然已经新增了Array.of()这种方式,还需要Array.from()方式吗?他们有区别呢?
简单点说,Array.from()适用于将非数组对象转换为数组的场景,它的初衷就是为了解决将非数组对象转换为数组的问题。
我们来看一个例子:

function makeArray() {
  return Array.from(arguments);
}
let arr = makeArray('a', 'b', 'c');

我们知道,JS函数中有一个arguments对象,arguments是一个类数组对象。
上面代码中,用Array.from()将arguments转换成真正的数组并返回。多方便啊,就一个方法搞定。
而在es6之前,如果要将arguments转换为数组,你得自己写一个类似的转换方法,多麻烦啊。

Array.from()的强大不止于此,它还能接受一个映射函数:

function cube() {
  return Array.from(arguments, value => value ** 3);
}
let arr = cube(1, 3, 5);

上面代码中,arguments被直接传递给Array.from()方法,从而将它包含的值转换成了数组。
映射函数对每个数都进行了立方运算,因此目标数组的内容就是[ 1, 27, 125 ],Array.from()不仅能够将非数组对象转换为数组,还能按照我们想要的方式进行转换,强大!

注意:Array.from()方法不仅可用于类数组对象,也可用于可迭代对象,更多请参考Array.from()

总结

准确来说,Array.from()并不能算创建数组的一种方式,毕竟它的初衷是为了解决将非数组对象转换为数组的问题。
但话说回来,既然它能将非数组对象转换为数组,所以也可以说,它算创建数组的一种方式。
恭喜!读完本篇,你知道了至少四种创建数组的方式了。😱

查看原文

赞 21 收藏 12 评论 2

灯盏细辛 赞了文章 · 2019-03-20

React SSR 技术摘要

单页面应用(SPA)模式被越来越多的站点所采用,这种模式意味着使用 JavaScript 直接在浏览器中渲染页面。所有逻辑,数据获取,模板和路由都在客户端处理,势必面临着首次有效绘制(FMP)耗时较长和不利于搜索引擎优化(SEO)的问题。

“同构(Universal)” 是指一套代码可以在服务端和客户端两种环境下运行,通过用这种灵活性,可以在服务端渲染初始内容输出到页面,后续工作交给客户端来完成,最终来解决SEO的问题并提升性能。“同构应用” 就像是精灵,可以游刃有余的穿梭在服务端与客户端之间各尽其能。

但是想驾驭 “同构应用” 往往会面临一系列的问题,下面针对一个示例进行一些细节介绍。

示例代码:https://github.com/xyyjk/reac...

构建配置

选择一个灵活的脚手架为项目后续的自定义功能及配置是十分有利的,Neutrino 提供了一些常用的 Webpack 预设配置,这些预设中包含了开发过程中常见的一些插件及配置使初始化和构建项目的过程更加简单。

下面基于预设做一些自定义配置,你可以随时通过运行 node_modules/.bin/neutrino --inspect 来了解最终完整的 Webpack 配置。

客户端配置

这里基于 @neutrinojs/react 预设做一些定义用于开发

.neutrinorc.js
const isDev = process.env.NODE_ENV !== 'production';
const isSSR = process.argv.includes('--ssr');

module.exports = {
  use: [
    ['@neutrinojs/react', {
      devServer: {
        port: isSSR ? 3000 : 5000,
        host: '0.0.0.0',
        disableHostCheck: true,
        contentBase: `${__dirname}/src`,
        before(app) { if(isSSR) { require('./src/server')(app); } },
      },
      manifest: true,
      html: isSSR ? false: {},
      clean: { paths: ['./node_modules/.cache']},
    }],

    ({ config }) => {
      if (isDev) { return; }

      config
        .output
          .filename('assets/[name].[chunkhash].js')
          .chunkFilename('assets/chunk.[chunkhash].js')
          .end()
        .optimization
          .minimize(false)
          .end();
    },
  ],
};

为了达到开发环境下可以选择 SSR(服务端渲染)、CSR(客户端渲染) 任意一种渲染模式,通过定义变量 isDevisSSR 用以做差异配置:

devServer.before 方法可以在服务内部的所有其他中间件之前,提供执行自定义中间件的功能。

SSR 模式 下加入一个中间件,稍后用于进行处理服务端组件内容渲染,同时很好的利用到了 devServer.hot 热更新功能。

SSR 模式 下使用动态定义 html 模板(src/server/template.js),这里把底层使用的 html-webpack-plugin 去掉。

启用 manifest 插件,打包后生成资源映射文件用于服务端渲染时模板中引入。

服务端配置

构建用于服务端运行的配置项稍有不同,由于 SSR 模式 最终代码要运行在 node 环境,这里需要对配置再做一些调整:

  • target 调整为 node,编译为类 Node 环境可用
  • libraryTarget 调整为 commonjs2,使用 Node 风格导出模块
  • @babel/preset-env 运行环境调整为 node,编译结果为 ES6 代码
  • 排除组件中 css/sass 资源的引用,生产环境直接使用通过 manifest 插件构建出的映射文件来读取资源

在打包的时候通过 webpack-node-externals 排除 node_modules 依赖模块,可以使服务器构建速度更快,并生成较小的 bundle 文件。

webpack.server.config.js
const Neutrino = require('neutrino/Neutrino');
const nodeExternals = require('webpack-node-externals');
const NormalPlugin = require('webpack/lib/NormalModuleReplacementPlugin');
const babelMerge = require('babel-merge');
const config = require('./.neutrinorc');

const neutrino = new Neutrino();

neutrino.use(config);

neutrino.config
  .target('node')

  .entryPoints
    .delete('index')
    .end()

  .entry('server')
    .add(`${__dirname}/src/server`)
    .end()

  .output
    .path(`${__dirname}/build`)
    .filename('server.js')
    .libraryTarget('commonjs2')
    .end()

  .externals([nodeExternals()])

  .plugins
    .delete('clean')
    .delete('manifest')
    .end()

  .plugin('normal')
    .use(NormalPlugin, [/\.css$/, 'lodash/noop'])
    .end()

  .optimization
    .minimize(false)
    .runtimeChunk(false)
    .end()

  .module
    .rule('compile')
    .use('babel')
    .tap(options => babelMerge(options, {
      presets: [
        ['@babel/preset-env', {
          targets: { node: true },
        }],
      ],
    }));

module.exports = neutrino.config.toConfig();

环境差异

由于运行环境和平台 API 的差异,当运行在不同环境中时,我们的代码将不会完全相同。

Webpack 全局对象中定义了 process.browser,可以在开发环境中来判断当前是客户端还是服务端。

自定义中间件

开发环境 SSR 模式 下,如果我们在组件中引入了图片或样式资源,不经过 webpack-loader 进行编译,Node 环境下是无法直接运行的。在 Node 环境下,通过 ignore-styles 可以把这些资源进行忽略。

此外,为了让 Node 环境下能够运行 ES6 模块的组件,需要引入 @babel/register 来做一些转换:

src/server/register.js
require('ignore-styles');

require('@babel/register')({
  presets: [
    ['@babel/preset-env', {
      targets: { node: true },
    }],
    '@babel/preset-react',
  ],
  plugins: [
    '@babel/plugin-proposal-class-properties',
  ],
});

如果 Webpack 中配置了 resolve.alias,与之对应的还需要增加 babel-plugin-module-resolver 插件来做解析。

清除模块缓存

由于 require() 引入方式模块将会被缓存, 为了使组件内的修改实时生效,通过 decache 模块从 require() 缓存中删除模块后再次重新引用:

src/server/dev.js
require('./register');

const decache = require('decache');
const routes = require('./routes');
let render = require('./render');

const handler = async (req, res, next) => {
  decache('./render');
  render = require('./render');
  res.send(await render({ req, res }));
  next();
};

module.exports = (app) => {
  app.get(routes, handler);
};

服务端渲染

在服务端通过 ReactDOMServer.renderToString() 方法将组件渲染为初始 HTML 字符串。

获取数据往往需要从 querycookie 中取一些内容作为接口参数,
Node 环境下没有 windowdocument 这样的浏览器对象,可以借助 Express 的 req 对象来拿到一些信息:

  • href: ${req.protocol}://${req.headers.host}${req.url}
  • cookie: req.headers.cookie
  • userAgent: req.headers['user-agent']
src/server/render.js
const React = require('react');
const { renderToString } = require('react-dom/server');

...

module.exports = async ({ req, res }) => {
  const locals = {
    data: await fetchData({ req, res }),
    href: `${req.protocol}://${req.headers.host}${req.url}`,
    url: req.url,
  };

  const markup = renderToString(<App locals={locals} />);
  const helmet = Helmet.renderStatic();

  return template({ markup, helmet, assets, locals });
};

入口文件

前端调用 ReactDOM.hydrate() 方法把服务端返回的静态 HTML 与事件相融合绑定。

src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

const renderMethod = ReactDOM[module.hot ? 'render' : 'hydrate'];
renderMethod(<App />, document.getElementById('root'));

根组件

在服务端使用 StaticRouter 组件,通过 location 属性设置服务器收到的URL,并在 context 属性中存入渲染期间所需要的数据。

src/App.jsx
import React from 'react';
import { BrowserRouter, StaticRouter, Route } from 'react-router-dom';
import { hot } from 'react-hot-loader/root';

...

const Router = process.browser ? BrowserRouter : StaticRouter;

const App = ({ locals = {} }) => (
  <Router location={locals.url} context={locals}>
    <Layout>
      <Route exact path="/" component={Home}/>
      <Route path="/about" component={About}/>
      <Route path="/contact" component={Contact}/>
      <Route path="/character/:key" component={Character}/>
    </Layout>
  </Router>
);

export default hot(App);

内容数据

通过 constructor 接收 StaticRouter 组件传入的数据,客户端 URL 与服务端请求地址相一致时直接使用传入的数据,否则再进行客户端数据请求。

src/comps/Content.jsx
import React from 'react';
import { withRouter } from 'react-router-dom';
import fetchData from '../utils/fetchData';

function isCurUrl() {
  if (!window.__INITIAL_DATA__) { return false; }
  return document.location.href === window.__INITIAL_DATA__.href;
}

class Content extends React.Component {
  constructor(props) {
    super(props);

    const { staticContext = {} } = props;
    let { data = {} } = staticContext;

    if (process.browser && isCurUrl()) {
      data = window.__INITIAL_DATA__.data;
    }

    this.state = { data };
  }

  async componentDidMount() {
    if (isCurUrl()) { return; }
    
    const { match } = this.props;
    const data = await fetchData({ match });

    this.setState({ data });
  }

  
  render() {
    return this.props.render(this.state);
  }
}

export default withRouter(Content);

自定义标记

通常在不同页面中需要输出不同的页面标题、页面描述,HTML 属性等,可以借助 react-helmet 来处理此类问题:

模板设置

const markup = ReactDOMServer.renderToString(<Handler />);
const helmet = Helmet.renderStatic();

const template = `
<!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()}>
  <head>
    <meta charset="UTF-8">
    ${helmet.title.toString()}
    ${helmet.meta.toString()}
    ${helmet.link.toString()}
  </head>
  <body ${helmet.bodyAttributes.toString()}>
    <div id="root">${markup}</div>
  </body>
</html>
`;

组件中的使用

import React from 'react';
import Helmet from 'react-helmet';

const Contact = () => (
  <>
    <h2>This is the contact page</h2>
    <Helmet>
      <title>Contact Page</title>
      <meta name="description" content="This is a proof of concept for React SSR" />
    </Helmet>
  </>
);

总结

想要做好 “同构应用” 并不简单,需要了解非常多的概念。好消息是目前 React 社区有一些比较著名的同构方案 Next.jsRazzleElectrode 等,如果你想快速入手 React SSR 这些或许是不错的选择。如果面对复杂应用,自定义完整的体系将会更加灵活。

查看原文

赞 12 收藏 9 评论 0

灯盏细辛 评论了文章 · 2019-03-20

白话es6系列一:Array.of()和Array.from()

es6新增了二种方法:Array.of()和Array.from(),它们有什么用途呢?在平时的开发中能给我们带来什么方便呢?本篇将从一个创建数组的小问题开始,逐步揭开它们的面纱。

一个问题

首先,我们来看一个问题,我需要创建一个共81项的数组,有9行,每行9个数(从1-9),在页面上进行展示,如下:
clipboard.png

怎么做呢?可以这样:

let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9]
arr = arr.concat(arr)
arr = arr.concat(arr)
arr = arr.concat(arr)
arr = arr.concat([1, 2, 3, 4, 5, 6, 7, 8, 9])

console.table(arr)

真的不优雅!一看就是菜鸡程序员写的,但不管怎么样,的确实现了功能。
那规模加大,需要的是810项的数组呢?那就需要换一种实现方式了,如下:

let arr = Array.apply(null, { length: 810 })
  .map((item, index) => {
    return {
      id: index,
      number: index % 9 + 1
    }
  })

看上去是不错,但容易让人困惑,阅读起来也不直观,又是apply,又是null的,有点让人抓狂。
可能有人会说,用es6的Array.from()来做啊:

let arr = Array.from({ length: 810 }, (item, index) => ({
  id: index,
  number: index % 9 + 1
}))

不错哦,代码量变少了。更重要的是,代码意图也直观:数组长度810,每一项按照约定的规则进行初始化。

看完了问题,接下来具体说说这二个方法的诞生背景和用法,先来看Array.of

Array.of

es6之前,最常用的创建数组有如下两种方式,一种是字面量方式,还一种是new Array()方式:

var arr1 = [1, 3, 5]
var arr2 = new Array(1, 3, 5)

这里要重点提一下new Array()方式,因为它有一个缺陷,如果要用这种方式创建一个数组,其中就只有一个数字3,它居然无法做到!

var arr = new Array(3)

上面只会得到一个长度为3的数组(各项都是empty),而不是一个长度为1的数组(其中的项为数字3)。
我给你一个数字3,让你给我存到数组中,你给我一个长度为3的数组?惊不惊喜,意不意外?

还好,ES6引入了Array.of()方法来解决这个问题。

let arr = Array.of(3)

上面代码创建了一个长度为1的数组(其中的项为数字3)。
相比new Array()这种方式,Array.of()的方式显得更明了,我需要存什么,扔给它,它就给我存到数组中,不会给我意外和惊吓。

Array.from

可能会有人问,既然已经新增了Array.of()这种方式,还需要Array.from()方式吗?他们有区别呢?
简单点说,Array.from()适用于将非数组对象转换为数组的场景,它的初衷就是为了解决将非数组对象转换为数组的问题。
我们来看一个例子:

function makeArray() {
  return Array.from(arguments);
}
let arr = makeArray('a', 'b', 'c');

我们知道,JS函数中有一个arguments对象,arguments是一个类数组对象。
上面代码中,用Array.from()将arguments转换成真正的数组并返回。多方便啊,就一个方法搞定。
而在es6之前,如果要将arguments转换为数组,你得自己写一个类似的转换方法,多麻烦啊。

Array.from()的强大不止于此,它还能接受一个映射函数:

function cube() {
  return Array.from(arguments, value => value ** 3);
}
let arr = cube(1, 3, 5);

上面代码中,arguments被直接传递给Array.from()方法,从而将它包含的值转换成了数组。
映射函数对每个数都进行了立方运算,因此目标数组的内容就是[ 1, 27, 125 ],Array.from()不仅能够将非数组对象转换为数组,还能按照我们想要的方式进行转换,强大!

注意:Array.from()方法不仅可用于类数组对象,也可用于可迭代对象,更多请参考Array.from()

总结

准确来说,Array.from()并不能算创建数组的一种方式,毕竟它的初衷是为了解决将非数组对象转换为数组的问题。
但话说回来,既然它能将非数组对象转换为数组,所以也可以说,它算创建数组的一种方式。
恭喜!读完本篇,你知道了至少四种创建数组的方式了。😱

查看原文

灯盏细辛 收藏了文章 · 2019-01-29

大前端时代安全性如何做

之前在上家公司的时候做过一些爬虫的工作,也帮助爬虫工程师解决过一些问题。然后我写过一些文章发布到网上,之后有一些人就找我做一些爬虫的外包,内容大概是爬取小红书的用户数据和商品数据,但是我没做。我觉得对于国内的大数据公司没几家是有真正的大数据量,而是通过爬虫工程师团队不断的去各地爬取数据,因此不要以为我们的数据没价值,对于内容型的公司来说,数据是可信竞争力。那么我接下来想说的就是网络和数据的安全性问题。
对于内容型的公司,数据的安全性很重要。对于内容公司来说,数据的重要性不言而喻。比如你一个做在线教育的平台,题目的数据很重要吧,但是被别人通过爬虫技术全部爬走了?如果核心竞争力都被拿走了,那就是凉凉。再比说有个独立开发者想抄袭你的产品,通过抓包和爬虫手段将你核心的数据拿走,然后短期内做个网站和 App,短期内成为你的劲敌。

背景

目前通过 App 中的 网页分析后,我们的数据安全性做的较差,有以下几个点存在问题:

  1. 网站的数据通过最早期的前后端分离来实现。稍微学过 Web 前端的工程师都可以通过神器 Chrome 分析网站,进而爬取需要的数据。打开 「Network」就可以看到网站的所有网络请求了,哎呀,不小心我看到了什么?没错就是网站的接口信息都可以看到了。比如 “detail.json?itemId=141529859”。或者你的网站接口有些特殊的判断处理,将一些信息存储到 sessionStorage、cookie、localStorage 里面,有点前端经验的爬虫工程师心想”嘿嘿嘿,这不是在裸奔数据么“。或者有些参数是通过 JavaScript 临时通过函数生成的。问题不大,工程师也可以对网页元素进行查找,找到关键的 id、或者 css 类名,然后在 "Search“ 可以进行查找,找到对应的代码 JS 代码,点击查看代码,如果是早期前端开发模式那么代码就是裸奔的,跟开发者在自己的 IDE 里面看到的内容一样,有经验的爬虫就可以拿这个做事情,因此安全性问题亟待解决。

想知道 Chrome 更多的调试使用技巧,看看这篇文章

Chrome调试1
Chrome调试2
Chrome调试3

  1. App 的数据即使采用了 HTTPS,但是对于专业的抓包工具也是可以直接拿到数据的,因此 App 的安全问题也可以做一些提高,具体的策略下文会讲到。

想知道 Charles 的更多使用技巧,可以看看这篇文章

爬虫手段

  • 目前爬虫技术都是从渲染好的 html 页面直接找到感兴趣的节点,然后获取对应的文本
  • 有些网站安全性做的好,比如列表页可能好获取,但是详情页就需要从列表页点击对应的 item,将 itemId 通过 form 表单提交,服务端生成对应的参数,然后重定向到详情页(重定向过来的地址后才带有详情页的参数 detailID),这个步骤就可以拦截掉一部分的爬虫开发者

解决方案

制定出Web 端反爬技术方案

本人从这2个角度(网页所见非所得、查接口请求没用)出发,制定了下面的反爬方案。

  • 使用HTTPS 协议
  • 单位时间内限制掉请求次数过多,则封锁该账号
  • 前端技术限制 (接下来是核心技术)
# 比如需要正确显示的数据为“19950220”

1. 先按照自己需求利用相应的规则(数字乱序映射,比如正常的0对应还是0,但是乱序就是 0 <-> 1,1 <-> 9,3 <-> 8,...)制作自定义字体(ttf)
2. 根据上面的乱序映射规律,求得到需要返回的数据 19950220 -> 17730220
3. 对于第一步得到的字符串,依次遍历每个字符,将每个字符根据按照线性变换(y=kx+b)。线性方程的系数和常数项是根据当前的日期计算得到的。比如当前的日期为“2018-07-24”,那么线性变换的 k 为 7,b 为 24。
4. 然后将变换后的每个字符串用“3.1415926”拼接返回给接口调用者。(为什么是3.1415926,因为对数字伪造反爬,所以拼接的文本肯定是数字的话不太会引起研究者的注意,但是数字长度太短会误伤正常的数据,所以用所熟悉的 Π)

​```
1773 -> “1*7+24” + “3.1415926” + “7*7+24” + “3.1415926” + “7*7+24” + “3.1415926” + “3*7+24” -> 313.1415926733.1415926733.141592645
02 -> "0*7+24" + "3.1415926" + "2*7+24" -> 243.141592638
20 -> "2*7+24" + "3.1415926" + "0*7+24" -> 383.141592624
​```

# 前端拿到数据后再解密,解密后根据自定义的字体 Render 页面
1. 先将拿到的字符串按照“3.1415926”拆分为数组
2. 对数组的每1个数据,按照“线性变换”(y=kx+b,k和b同样按照当前的日期求解得到),逆向求解到原本的值。
3. 将步骤2的的到的数据依次拼接,再根据 ttf 文件 Render 页面上。
  • 后端需要根据上一步设计的协议将数据进行加密处理

下面以 Node.js 为例讲解后端需要做的事情

  • 首先后端设置接口路由
  • 获取路由后面的参数
  • 根据业务需要根据 SQL 语句生成对应的数据。如果是数字部分,则需要按照上面约定的方法加以转换。
  • 将生成数据转换成 JSON 返回给调用者

    // json
    var JoinOparatorSymbol = "3.1415926";
    function encode(rawData, ruleType) {
      if (!isNotEmptyStr(rawData)) {
        return "";
      }
      var date = new Date();
      var year = date.getFullYear();
      var month = date.getMonth() + 1;
      var day = date.getDate();
    
      var encodeData = "";
      for (var index = 0; index < rawData.length; index++) {
        var datacomponent = rawData[index];
        if (!isNaN(datacomponent)) {
          if (ruleType < 3) {
            var currentNumber = rawDataMap(String(datacomponent), ruleType);
            encodeData += (currentNumber * month + day) + JoinOparatorSymbol;
          }
          else if (ruleType == 4) {
            encodeData += rawDataMap(String(datacomponent), ruleType);
          }
          else {
            encodeData += rawDataMap(String(datacomponent), ruleType) + JoinOparatorSymbol;
          }
        }
        else if (ruleType == 4) {
          encodeData += rawDataMap(String(datacomponent), ruleType);
        }
    
      }
      if (encodeData.length >= JoinOparatorSymbol.length) {
        var lastTwoString = encodeData.substring(encodeData.length - JoinOparatorSymbol.length, encodeData.length);
        if (lastTwoString == JoinOparatorSymbol) {
          encodeData = encodeData.substring(0, encodeData.length - JoinOparatorSymbol.length);
        }
      }
    //字体映射处理
    function rawDataMap(rawData, ruleType) {
    
      if (!isNotEmptyStr(rawData) || !isNotEmptyStr(ruleType)) {
        return;
      }
      var mapData;
      var rawNumber = parseInt(rawData);
      var ruleTypeNumber = parseInt(ruleType);
      if (!isNaN(rawData)) {
        lastNumberCategory = ruleTypeNumber;
        //字体文件1下的数据加密规则
        if (ruleTypeNumber == 1) {
          if (rawNumber == 1) {
            mapData = 1;
          }
          else if (rawNumber == 2) {
            mapData = 2;
          }
          else if (rawNumber == 3) {
            mapData = 4;
          }
          else if (rawNumber == 4) {
            mapData = 5;
          }
          else if (rawNumber == 5) {
            mapData = 3;
          }
          else if (rawNumber == 6) {
            mapData = 8;
          }
          else if (rawNumber == 7) {
            mapData = 6;
          }
          else if (rawNumber == 8) {
            mapData = 9;
          }
          else if (rawNumber == 9) {
            mapData = 7;
          }
          else if (rawNumber == 0) {
            mapData = 0;
          }
        }
        //字体文件2下的数据加密规则
        else if (ruleTypeNumber == 0) {
    
          if (rawNumber == 1) {
            mapData = 4;
          }
          else if (rawNumber == 2) {
            mapData = 2;
          }
          else if (rawNumber == 3) {
            mapData = 3;
          }
          else if (rawNumber == 4) {
            mapData = 1;
          }
          else if (rawNumber == 5) {
            mapData = 8;
          }
          else if (rawNumber == 6) {
            mapData = 5;
          }
          else if (rawNumber == 7) {
            mapData = 6;
          }
          else if (rawNumber == 8) {
            mapData = 7;
          }
          else if (rawNumber == 9) {
            mapData = 9;
          }
          else if (rawNumber == 0) {
            mapData = 0;
          }
        }
        //字体文件3下的数据加密规则
        else if (ruleTypeNumber == 2) {
    
          if (rawNumber == 1) {
            mapData = 6;
          }
          else if (rawNumber == 2) {
            mapData = 2;
          }
          else if (rawNumber == 3) {
            mapData = 1;
          }
          else if (rawNumber == 4) {
            mapData = 3;
          }
          else if (rawNumber == 5) {
            mapData = 4;
          }
          else if (rawNumber == 6) {
            mapData = 8;
          }
          else if (rawNumber == 7) {
            mapData = 3;
          }
          else if (rawNumber == 8) {
            mapData = 7;
          }
          else if (rawNumber == 9) {
            mapData = 9;
          }
          else if (rawNumber == 0) {
            mapData = 0;
          }
        }
        else if (ruleTypeNumber == 3) {
    
          if (rawNumber == 1) {
            mapData = "&#xefab;";
          }
          else if (rawNumber == 2) {
            mapData = "&#xeba3;";
          }
          else if (rawNumber == 3) {
            mapData = "&#xecfa;";
          }
          else if (rawNumber == 4) {
            mapData = "&#xedfd;";
          }
          else if (rawNumber == 5) {
            mapData = "&#xeffa;";
          }
          else if (rawNumber == 6) {
            mapData = "&#xef3a;";
          }
          else if (rawNumber == 7) {
            mapData = "&#xe6f5;";
          }
          else if (rawNumber == 8) {
            mapData = "&#xecb2;";
          }
          else if (rawNumber == 9) {
            mapData = "&#xe8ae;";
          }
          else if (rawNumber == 0) {
            mapData = "&#xe1f2;";
          }
        }
        else{
          mapData = rawNumber;
        }
      } else if (ruleTypeNumber == 4) {
        var sources = ["年", "万", "业", "人", "信", "元", "千", "司", "州", "资", "造", "钱"];
        //判断字符串为汉字
        if (/^[\u4e00-\u9fa5]*$/.test(rawData)) {
    
          if (sources.indexOf(rawData) > -1) {
            var currentChineseHexcod = rawData.charCodeAt(0).toString(16);
            var lastCompoent;
            var mapComponetnt;
            var numbers = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
            var characters = ["a", "b", "c", "d", "e", "f", "g", "h", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"];
    
            if (currentChineseHexcod.length == 4) {
              lastCompoent = currentChineseHexcod.substr(3, 1);
              var locationInComponents = 0;
              if (/[0-9]/.test(lastCompoent)) {
                locationInComponents = numbers.indexOf(lastCompoent);
                mapComponetnt = numbers[(locationInComponents + 1) % 10];
              }
              else if (/[a-z]/.test(lastCompoent)) {
                locationInComponents = characters.indexOf(lastCompoent);
                mapComponetnt = characters[(locationInComponents + 1) % 26];
              }
              mapData = "&#x" + currentChineseHexcod.substr(0, 3) + mapComponetnt + ";";
            }
          } else {
            mapData = rawData;
          }
    
        }
        else if (/[0-9]/.test(rawData)) {
          mapData = rawDataMap(rawData, 2);
        }
        else {
          mapData = rawData;
        }
    
      }
      return mapData;
    }
    //api
    module.exports = {
        "GET /api/products": async (ctx, next) => {
            ctx.response.type = "application/json";
            ctx.response.body = {
                products: products
            };
        },
    
        "GET /api/solution1": async (ctx, next) => {
    
            try {
                var data = fs.readFileSync(pathname, "utf-8");
                ruleJson = JSON.parse(data);
                rule = ruleJson.data.rule;
            } catch (error) {
                console.log("fail: " + error);
            }
    
            var data = {
                code: 200,
                message: "success",
                data: {
                    name: "@杭城小刘",
                    year: LBPEncode("1995", rule),
                    month: LBPEncode("02", rule),
                    day: LBPEncode("20", rule),
                    analysis : rule
                }
            }
    
            ctx.set("Access-Control-Allow-Origin", "*");
            ctx.response.type = "application/json";
            ctx.response.body = data;
        },
    
    
        "GET /api/solution2": async (ctx, next) => {
            try {
                var data = fs.readFileSync(pathname, "utf-8");
                ruleJson = JSON.parse(data);
                rule = ruleJson.data.rule;
            } catch (error) {
                console.log("fail: " + error);
            }
    
            var data = {
                code: 200,
                message: "success",
                data: {
                    name: LBPEncode("建造师",rule),
                    birthday: LBPEncode("1995年02月20日",rule),
                    company: LBPEncode("中天公司",rule),
                    address: LBPEncode("浙江省杭州市拱墅区石祥路",rule),
                    bidprice: LBPEncode("2万元",rule),
                    negative: LBPEncode("2018年办事效率太高、负面基本没有",rule),
                    title: LBPEncode("建造师",rule),
                    honor: LBPEncode("最佳奖",rule),
                    analysis : rule
                }
            }
            ctx.set("Access-Control-Allow-Origin", "*");
            ctx.response.type = "application/json";
            ctx.response.body = data;
        },
    
        "POST /api/products": async (ctx, next) => {
            var p = {
                name: ctx.request.body.name,
                price: ctx.request.body.price
            };
            products.push(p);
            ctx.response.type = "application/json";
            ctx.response.body = p;
        }
    };
    //路由
    const fs = require("fs");
    
    function addMapping(router, mapping){
        for(var url in mapping){
            if (url.startsWith("GET")) {
                var path = url.substring(4);
                router.get(path,mapping[url]);
                console.log(`Register URL mapping: GET: ${path}`);
            }else if (url.startsWith('POST ')) {
                var path = url.substring(5);
                router.post(path, mapping[url]);
                console.log(`Register URL mapping: POST ${path}`);
            } else if (url.startsWith('PUT ')) {
                var path = url.substring(4);
                router.put(path, mapping[url]);
                console.log(`Register URL mapping: PUT ${path}`);
            } else if (url.startsWith('DELETE ')) {
                var path = url.substring(7);
                router.del(path, mapping[url]);
                console.log(`Register URL mapping: DELETE ${path}`);
            } else {
                console.log(`Invalid URL: ${url}`);
            }
    
        }
    }
    
    
    function addControllers(router, dir){
        fs.readdirSync(__dirname + "/" + dir).filter( (f) => {
            return f.endsWith(".js");
        }).forEach( (f) => {
            console.log(`Process controllers:${f}...`);
            let mapping = require(__dirname + "/" + dir + "/" + f);
            addMapping(router,mapping);
        });
    }
    
    module.exports = function(dir){
        let controllers = dir || "controller";
        let router = require("koa-router")();
    
        addControllers(router,controllers);
        return router.routes();
    };
    
    
  • 前端根据服务端返回的数据逆向解密

    $("#year").html(getRawData(data.year,log));
    
    // util.js
    var JoinOparatorSymbol = "3.1415926";
    function isNotEmptyStr($str) {
      if (String($str) == "" || $str == undefined || $str == null || $str == "null") {
        return false;
      }
      return true;
    }
    
    function getRawData($json,analisys) {
      $json = $json.toString();
      if (!isNotEmptyStr($json)) {
        return;
      }
      
      var date= new Date();
      var year = date.getFullYear();
      var month = date.getMonth() + 1;
      var day = date.getDate();
      var datacomponents = $json.split(JoinOparatorSymbol);
      var orginalMessage = "";
      for(var index = 0;index < datacomponents.length;index++){
        var datacomponent = datacomponents[index];
          if (!isNaN(datacomponent) && analisys < 3){
              var currentNumber = parseInt(datacomponent);
              orginalMessage += (currentNumber -  day)/month;
          }
          else if(analisys == 3){
             orginalMessage += datacomponent;
          }
          else{
            //其他情况待续,本 Demo 根据本人在研究反爬方面的技术并实践后持续更新
          }
      }
      return orginalMessage;
    }
    

比如后端返回的是323.14743.14743.1446,根据我们约定的算法,可以的到结果为1773

  • 根据 ttf 文件 Render 页面
    自定义字体文件
    上面计算的到的1773,然后根据ttf文件,页面看到的就是1995
  • 然后为了防止爬虫人员查看 JS 研究问题,所以对 JS 的文件进行了加密处理。如果你的技术栈是 Vue 、React 等,webpack 为你提供了 JS 加密的插件,也很方便处理

    JS混淆工具

  • 个人觉得这种方式还不是很安全。于是想到了各种方案的组合拳。比如

反爬升级版

个人觉得如果一个前端经验丰富的爬虫开发者来说,上面的方案可能还是会存在被破解的可能,所以在之前的基础上做了升级版本

  1. 组合拳1: 字体文件不要固定,虽然请求的链接是同一个,但是根据当前的时间戳的最后一个数字取模,比如 Demo 中对4取模,有4种值 0、1、2、3。这4种值对应不同的字体文件,所以当爬虫绞尽脑汁爬到1种情况下的字体时,没想到再次请求,字体文件的规则变掉了 😂
  2. 组合拳2: 前面的规则是字体问题乱序,但是只是数字匹配打乱掉。比如 1 -> 4, 5 -> 8。接下来的套路就是每个数字对应一个 unicode 码 ,然后制作自己需要的字体,可以是 .ttf、.woff 等等。

网页检察元素得到的效果
接口返回数据

这几种组合拳打下来。对于一般的爬虫就放弃了。

反爬手段再升级

上面说的方法主要是针对数字做的反爬手段,如果要对汉字进行反爬怎么办?接下来提供几种方案

  1. 方案1: 对于你站点频率最高的词云,做一个汉字映射,也就是自定义字体文件,步骤跟数字一样。先将常用的汉字生成对应的 ttf 文件;根据下面提供的链接,将 ttf 文件转换为 svg 文件,然后在下面的“字体映射”链接点进去的网站上面选择前面生成的 svg 文件,将svg文件里面的每个汉字做个映射,也就是将汉字专为 unicode 码(注意这里的 unicode 码不要去在线直接生成,因为直接生成的东西也就是有规律的。我给的做法是先用网站生成,然后将得到的结果做个简单的变化,比如将“e342”转换为 “e231”);然后接口返回的数据按照我们的这个字体文件的规则反过去映射出来。
  2. 方案2: 将网站的重要字体,将 html 部分生成图片,这样子爬虫要识别到需要的内容成本就很高了,需要用到 OCR。效率也很低。所以可以拦截掉一部分的爬虫
  3. 方案3: 看到携程的技术分享“反爬的最高境界就是 Canvas 的指纹,原理是不同的机器不同的硬件对于 Canvas 画出的图总是存在像素级别的误差,因此我们判断当对于访问来说大量的 canvas 的指纹一致的话,则认为是爬虫,则可以封掉它”。

    本人将方案1实现到 Demo 中了。

关键步骤

  1. 先根据你们的产品找到常用的关键词,生成词云
  2. 根据词云,将每个字生成对应的 unicode 码
  3. 将词云包括的汉字做成一个字体库
  4. 将字体库 .ttf 做成 svg 格式,然后上传到 icomoon 制作自定义的字体,但是有规则,比如 “年” 对应的 unicode 码“u5e74” ,但是我们需要做一个 恺撒加密 ,比如我们设置 偏移量 为1,那么经过恺撒加密“年”对应的 unicode 码是“u5e75” 。利用这种规则制作我们需要的字体库
  5. 在每次调用接口的时候服务端做的事情是:服务端封装某个方法,将数据经过方法判断是不是在词云中,如果是词云中的字符,利用规则(找到汉字对应的 unicode 码,再根据凯撒加密,设置对应的偏移量,Demo 中为1,将每个汉字加密处理)加密处理后返回数据
  6. 客户端做的事情:

    • 先引入我们前面制作好的汉字字体库
    • 调用接口拿到数据,显示到对应的 Dom 节点上
    • 如果是汉字文本,我们将对应节点的 css 类设置成汉字类,该类对应的 font-family 是我们上面引入的汉字字体库
//style.css
@font-face {
  font-family: "NumberFont";
  src: url('http://127.0.0.1:8080/Util/analysis');
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

@font-face {
  font-family: "CharacterFont";
  src: url('http://127.0.0.1:8080/Util/map');
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

h2 {
  font-family: "NumberFont";
}

h3,a{
  font-family: "CharacterFont";
}

接口效果
审查元素效果

传送门

字体制作的步骤ttf转svg字体映射规则

实现的效果

  1. 页面上看到的数据跟审查元素看到的结果不一致
  2. 去查看接口数据跟审核元素和界面看到的三者不一致
  3. 页面每次刷新之前得出的结果更不一致
  4. 对于数字和汉字的处理手段都不一致

这几种组合拳打下来。对于一般的爬虫就放弃了。

数字反爬-网页显示效果、审查元素、接口结果情况1
数字反爬-网页显示效果、审查元素、接口结果情况2
数字反爬-网页显示效果、审查元素、接口结果情况3
数字反爬-网页显示效果、审查元素、接口结果情况4
汉字反爬-网页显示效果、审查元素、接口结果情况1
汉字反爬-网页显示效果、审查元素、接口结果情况2


前面的 ttf 转 svg 网站当 ttf 文件太大会限制转换,让你购买,下面贴出个新的链接。

ttf转svg

Demo 地址

效果演示

运行步骤

//客户端。先查看本机 ip 在 Demo/Spider-develop/Solution/Solution1.js 和 Demo/Spider-develop/Solution/Solution2.js  里面将接口地址修改为本机 ip

$ cd Demo
$ ls
REST        Spider-release    file-Server.js
Spider-develop    Util        rule.json
$ node file-Server.js 
Server is runnig at http://127.0.0.1:8080/

//服务端 先安装依赖
$ cd REST/
$ npm install
$ node app.js 

App 端安全的解决方案

  • 目前 App 的网络通信基本都是用 HTTPS 的服务,但是随便一个抓包工具都是可以看到 HTTPS 接口的详细数据,为了做到防止抓包和无法模拟接口的情况,我们采取以下措施:

    1. 中间人盗用数据,我们可以采取 HTTPS 证书的双向认证,这样子实现的效果就是中间人在开启抓包软件分析 App 的网络请求的时候,网络会自动断掉,无法查看分析请求的情况
    2. 对于防止用户模仿我们的请求再次发起请求,我们可以采用 「防重放策略」,用户再也无法模仿我们的请求,再次去获取数据了。
    3. 对于 App 内的 H5 资源,反爬虫方案可以采用上面的解决方案,H5 内部的网络请求可以通过 Hybrid 层让 Native 的能力去完成网络请求,完成之后将数据回调给 JS。这么做的目的是往往我们的 Native 层有完善的账号体系和网络层以及良好的安全策略、鉴权体系等等。
    4. 后期会讨论 App 安全性的更深层次玩法,比如从逆向的角度出发如何保护 App 的安全性。提前给出一篇逆向安全方面的文章

关于 Hybrid 的更多内容,可以看看这篇文章 Awesome Hybrid

  • 比如 JS 需要发起一个网络请求,那么按照上面将网络请求让 Native 去完成,然后回调给 JS

    JS 端代码

    var requestObject = {
      url: arg.Api + "SearchInfo/getLawsInfo",
      params: requestparams,
      Hybrid_Request_Method: 0
    };
    requestHybrid({
      tagname: 'NativeRequest',
      param: requestObject,
      encryption: 1,
      callback: function (data) {
        renderUI(data);
      }
    })

    Native 代码(iOS为例)

    [self.bridge registerHandler:@"NativeRequest" handler:^(id data, WVJBResponseCallback responseCallback) {
          
        NSAssert([data isKindOfClass:[NSDictionary class]], @"H5 端不按套路");
        if ([data isKindOfClass:[NSDictionary class]]) {
            
            NSDictionary *dict = (NSDictionary *)data;
            RequestModel *requestModel = [RequestModel yy_modelWithJSON:dict];
            NSAssert( (requestModel.Hybrid_Request_Method == Hybrid_Request_Method_Post) || (requestModel.Hybrid_Request_Method == Hybrid_Request_Method_Get ), @"H5 端不按套路");
            
            [HybridRequest requestWithNative:requestModel hybridRequestSuccess:^(id responseObject) {
                
                NSDictionary *json = [NSJSONSerialization JSONObjectWithData:responseObject options:NSJSONReadingMutableLeaves error:nil];
                responseCallback([self convertToJsonData:@{@"success":@"1",@"data":json}]);
                
            } hybridRequestfail:^{
                
                LBPLog(@"H5 call Native`s request failed");
                responseCallback([self convertToJsonData:@{@"success":@"0",@"data":@""}]);
            }];
        }
    }];

以上是第一阶段的安全性总结,后期应该会更新(App逆向、防重放、服务端等)。

查看原文

灯盏细辛 赞了文章 · 2018-01-02

JavaScript 优雅的实现方式包含你可能不知道的知识点

有些东西很好用,但是你未必知道;有些东西你可能用过,但是你未必知道原理。

实现一个目的有多种途径,俗话说,条条大路通罗马。很多内容来自平时的一些收集以及过往博客文章底下的精彩评论,收集整理拓展一波,发散一下大家的思维以及拓展一下知识面。

茴字有四种写法,233333..., 文末有彩蛋有惊喜。

1、简短优雅地实现 sleep 函数

很多语言都有 sleep 函数,显然 js 没有,那么如何能简短优雅地实现这个方法?

1.1 普通版

function sleep(sleepTime) {
    for(var start = +new Date; +new Date - start <= sleepTime;) {}
}
var t1 = +new Date()
sleep(3000)
var t2 = +new Date()
console.log(t2 - t1)

优点:简单粗暴,通俗易懂。
缺点:这是最简单粗暴的实现,确实 sleep 了,也确实卡死了,CPU 会飙升,无论你的服务器 CPU 有多么 Niubility。

1.2 Promise 版本

function sleep(time) {
  return new Promise(resolve => setTimeout(resolve, time))
}

const t1 = +new Date()
sleep(3000).then(() => {
  const t2 = +new Date()
  console.log(t2 - t1)
})

优点:这种方式实际上是用了 setTimeout,没有形成进程阻塞,不会造成性能和负载问题。
缺点:虽然不像 callback 套那么多层,但仍不怎么美观,而且当我们需要在某过程中需要停止执行(或者在中途返回了错误的值),还必须得层层判断后跳出,非常麻烦,而且这种异步并不是那么彻底,还是看起来别扭。

1.3 Generator 版本

function sleep(delay) {
  return function(cb) {
    setTimeout(cb.bind(this), delay)
  };
}

function* genSleep() {
  const t1 = +new Date()
  yield sleep(3000)
  const t2 = +new Date()
  console.log(t2 - t1)
}

async(genSleep)

function async(gen) {
  const iter = gen()
  function nextStep(it) {
    if (it.done) return
    if (typeof it.value === "function") {
      it.value(function(ret) {
        nextStep(iter.next(ret))
      })
    } else {
      nextStep(iter.next(it.value))
    }
  }
  nextStep(iter.next())
}

优点:同 Promise 优点,另外代码就变得非常简单干净,没有 then 那么生硬和恶心。
缺点:但不足也很明显,就是每次都要执行 next() 显得很麻烦,虽然有 co(第三方包)可以解决,但就多包了一层,不好看,错误也必须按 co 的逻辑来处理,不爽。

co 之所以这么火并不是没有原因的,当然不是仅仅实现 sleep 这么无聊的事情,而是它活生生的借着generator/yield 实现了很类似 async/await 的效果!这一点真是让我三观尽毁刮目相看。

const co = require("co")
function sleep(delay) {
  return function(cb) {
    setTimeout(cb.bind(this), delay)
  }
}

co(function*() {
  const t1 = +new Date()
  yield sleep(3000)
  const t2 = +new Date()
  console.log(t2 - t1)
})

1.4 Async/Await 版本

function sleep(delay) {
  return new Promise(reslove => {
    setTimeout(reslove, delay)
  })
}

!async function test() {
  const t1 = +new Date()
  await sleep(3000)
  const t2 = +new Date()
  console.log(t2 - t1)
}()

优点:同 Promise 和 Generator 优点。 Async/Await 可以看做是 Generator 的语法糖,Async 和 Await 相较于 * 和 yield 更加语义,另外各个函数都是扁平的,不会产生多余的嵌套,代码更加清爽易读。
缺点: ES7 语法存在兼容性问题,有 babel 一切兼容性都不是问题

至于 Async/AwaitPromiseGenerator 的好处可以参考这两篇文章:
Async/Await 比 Generator 的四个改进点
关于Async/Await替代Promise的6个理由

1.5 不要忘了开源的力量

在 javascript 优雅的写 sleep 等于如何优雅的不优雅,2333

这里有 C++ 实现的模块:https://github.com/ErikDubbel...
const sleep = require("sleep")

const t1 = +new Date()
sleep.msleep(3000)
const t2 = +new Date()
console.log(t2 - t1)

优点:能够实现更加精细的时间精确度,而且看起来就是真的 sleep 函数,清晰直白。
缺点:缺点需要安装这个模块,^_^,这也许算不上什么缺点。

从一个间简简单单的 sleep 函数我们就就可以管中窥豹,看见 JavaScript 近几年来不断快速的发展,不单单是异步编程这一块,还有各种各样的新技术和新框架,见证了 JavaScript 的繁荣。

你可能不知道的前端知识点:Async/Await是目前前端异步书写最优雅的一种方式

2、获取时间戳

上面第一个用多种方式实现 sleep 函数,我们可以发现代码有 +new Date()获取时间戳的用法,这只是其中的一种,下面就说一下其他两种以及 +new Date()的原理。

2.1 普通版

var timestamp=new Date().getTime()

优点:具有普遍性,大家都用这个
缺点:目前没有发现

2.2 进阶版

var timestamp = (new Date()).valueOf()
valueOf 方法返回对象的原始值(Primitive,'Null','Undefined','String','Boolean','Number'五种基本数据类型之一),可能是字符串、数值或 bool 值等,看具体的对象。

优点:说明开发者原始值有一个具体的认知,让人眼前一亮。
缺点: 目前没有发现

2.3 终极版

var timestamp = +new Date()

优点:对 JavaScript 隐式转换掌握的比较牢固的一个表现
缺点:目前没有发现

现在就简单分析一下为什么 +new Date() 拿到的是时间戳。

一言以蔽之,这是隐式转换的玄学,实质还是调用了 valueOf() 的方法。

我们先看看 ECMAScript 规范对一元运算符的规范:

一元+ 运算符

一元 + 运算符将其操作数转换为 Number 类型并反转其正负。注意负的 +0 产生 -0,负的 -0 产生 +0。产生式 UnaryExpression : - UnaryExpression 按照下面的过程执行。

  1. 令 expr 为解释执行 UnaryExpression 的结果 .
  2. 令 oldValue 为 ToNumber(GetValue(expr)).
  3. 如果 oldValue is NaN ,return NaN.
  4. 返回 oldValue 取负(即,算出一个数字相同但是符号相反的值)的结果。

+new Date() 相当于 ToNumber(new Date())

我们再来看看 ECMAScript 规范对 ToNumber 的定义:

我们知道 new Date() 是个对象,满足上面的 ToPrimitive(),所以进而成了 ToPrimitive(new Date())

接着我们再来看看 ECMAScript 规范对 ToPrimitive 的定义,一层一层来,抽丝剥茧。

这个 ToPrimitive 刚开始可能不太好懂,我来大致解释一下吧:

ToPrimitive(obj,preferredType)

JavaScript 引擎内部转换为原始值 ToPrimitive(obj,preferredType) 函数接受两个参数,第一个 obj 为被转换的对象,第二个preferredType 为希望转换成的类型(默认为空,接受的值为 NumberString

在执行 ToPrimitive(obj,preferredType) 时如果第二个参数为空并且 objDate 的实例时,此时 preferredType 会被设置为 String,其他情况下 preferredType 都会被设置为Number 如果 preferredTypeNumberToPrimitive 执行过程如下:

  1. 如果obj为原始值,直接返回;
  2. 否则调用 obj.valueOf(),如果执行结果是原始值,返回之;
  3. 否则调用 obj.toString(),如果执行结果是原始值,返回之;
  4. 否则抛异常。

如果 preferredTypeString,将上面的第2步和第3步调换,即:

  1. 如果obj为原始值,直接返回;
  2. 否则调用 obj.toString(),如果执行结果是原始值,返回之;
  3. 否则调用 obj.valueOf(),如果执行结果是原始值,返回之;
  4. 否则抛异常。

首先我们要明白 obj.valueOf()obj.toString() 还有原始值分别是什么意思,这是弄懂上面描述的前提之一:

toString 用来返回对象的字符串表示。

var obj = {};
console.log(obj.toString());//[object Object]

var arr2 = [];
console.log(arr2.toString());//""空字符串

var date = new Date();
console.log(date.toString());//Sun Feb 28 2016 13:40:36 GMT+0800 (中国标准时间)

valueOf 方法返回对象的原始值,可能是字符串、数值或 bool 值等,看具体的对象。

var obj = {
  name: "obj"
}
console.log(obj.valueOf()) //Object {name: "obj"}

var arr1 = [1]
console.log(arr1.valueOf()) //[1]

var date = new Date()
console.log(date.valueOf())//1456638436303
如代码所示,三个不同的对象实例调用valueOf返回不同的数据

原始值指的是 'Null','Undefined','String','Boolean','Number','Symbol' 6种基本数据类型之一,上面已经提到过这个概念,这里再次申明一下。

最后分解一下其中的过程:+new Date():

  1. 运算符 new 的优先级高于一元运算符 +,所以过程可以分解为:

var time=new Date();
+time

  1. 根据上面提到的规则相当于:ToNumber(time)
  2. time 是个日期对象,根据 ToNumber 的转换规则,所以相当于:ToNumber(ToPrimitive(time))
  3. 根据 ToPrimitive 的转换规则:ToNumber(time.valueOf())time.valueOf() 就是 原始值 得到的是个时间戳,假设 time.valueOf()=1503479124652
  4. 所以 ToNumber(1503479124652) 返回值是 1503479124652 这个数字。
  5. 分析完毕

你可能不知道的前端知识点:隐式转换的妙用

3、数组去重

注:暂不考虑对象字面量,函数等引用类型的去重,也不考虑 NaN, undefined, null等特殊类型情况。

数组样本:[1, 1, '1', '2', 1]
3.1 普通版

无需思考,我们可以得到 O(n^2) 复杂度的解法。定义一个变量数组 res 保存结果,遍历需要去重的数组,如果该元素已经存在在 res 中了,则说明是重复的元素,如果没有,则放入 res 中。

var a = [1, 1, '1', '2', 1]
function unique(arr) {
    var res = []
    for (var i = 0, len = arr.length; i < len; i++) {
        var item = arr[i]
        for (var j = 0, len = res.length; j < jlen; j++) {
            if (item === res[j]) //arr数组的item在res已经存在,就跳出循环
                break
        }
        if (j === jlen) //循环完毕,arr数组的item在res找不到,就push到res数组中
            res.push(item)
    }
    return res
}
console.log(unique(a)) // [1, 2, "1"]

优点: 没有任何兼容性问题,通俗易懂,没有任何理解成本
缺点: 看起来比较臃肿比较繁琐,时间复杂度比较高O(n^2)

3.2 进阶版

var a =  [1, 1, '1', '2', 1]
function unique(arr) {
    return arr.filter(function(ele,index,array){
        return array.indexOf(ele) === index//很巧妙,这样筛选一对一的,过滤掉重复的
    })
}
console.log(unique(a)) // [1, 2, "1"]

优点:很简洁,思维也比较巧妙,直观易懂。
缺点:不支持 IE9 以下的浏览器,时间复杂度还是O(n^2)

3.3 时间复杂度为O(n)

var a =  [1, 1, '1', '2', 1]
function unique(arr) {
    var obj = {}
    return arr.filter(function(item, index, array){
        return obj.hasOwnProperty(typeof item + item) ? 
        false : 
        (obj[typeof item + item] = true)
    })
}

console.log(unique(a)) // [1, 2, "1"]

优点:hasOwnProperty 是对象的属性(名称)存在性检查方法。对象的属性可以基于 Hash 表实现,因此对属性进行访问的时间复杂度可以达到O(1);
filter 是数组迭代的方法,内部还是一个 for 循环,所以时间复杂度是 O(n)
缺点:不兼容 IE9 以下浏览器,其实也好解决,把 filter 方法用 for 循环代替或者自己模拟一个 filter 方法。

3.4 终极版

以 Set 为例,ES6 提供了新的数据结构 Set。它类似于数组,但是成员的值都是唯一的,没有重复的值。
const unique = a => [...new Set(a)]

优点:ES6 语法,简洁高效,我们可以看到,去重方法从原始的 14 行代码到 ES61 行代码,其实也说明了 JavaScript 这门语言在不停的进步,相信以后的开发也会越来越高效。
缺点:兼容性问题,现代浏览器才支持,有 babel 这些都不是问题。

你可能不知道的前端知识点:ES6 新的数据结构 Set 去重

4、数字格式化 1234567890 --> 1,234,567,890

4.1 普通版

function formatNumber(str) {
  let arr = [],
    count = str.length

  while (count >= 3) {
    arr.unshift(str.slice(count - 3, count))
    count -= 3
  }

  // 如果是不是3的倍数就另外追加到上去
  str.length % 3 && arr.unshift(str.slice(0, str.length % 3))

  return arr.toString()

}
console.log(formatNumber("1234567890")) // 1,234,567,890

优点:自我感觉比网上写的一堆 for循环 还有 if-else 判断的逻辑更加清晰直白。
缺点:太普通

4.2 进阶版

function formatNumber(str) {

  // ["0", "9", "8", "7", "6", "5", "4", "3", "2", "1"]
  return str.split("").reverse().reduce((prev, next, index) => {
    return ((index % 3) ? next : (next + ',')) + prev
  })
}

console.log(formatNumber("1234567890")) // 1,234,567,890

优点:把 JS 的 API 玩的了如指掌
缺点:可能没那么好懂,不过读懂之后就会发出我怎么没想到的感觉

4.3 正则版

function formatNumber(str) {
  return str.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}

console.log(formatNumber("123456789")) // 1,234,567,890

下面简单分析下正则/\B(?=(\d{3})+(?!\d))/g

  1. /\B(?=(\d{3})+(?!\d))/g:正则匹配边界\B,边界后面必须跟着(\d{3})+(?!\d);
  2. (\d{3})+:必须是1个或多个的3个连续数字;
  3. (?!\d):第2步中的3个数字不允许后面跟着数字;
  4. (\d{3})+(?!\d):所以匹配的边界后面必须跟着3*n(n>=1)的数字。

最终把匹配到的所有边界换成,即可达成目标。

优点:代码少,浓缩的就是精华
缺点:需要对正则表达式的位置匹配有一个较深的认识,门槛大一点

4.4 API版

(123456789).toLocaleString('en-US')  // 1,234,567,890

如图,你可能还不知道 JavaScripttoLocaleString 还可以这么玩。

还可以使用 Intl对象 - MDN

Intl 对象是 ECMAScript 国际化 API 的一个命名空间,它提供了精确的字符串对比,数字格式化,日期和时间格式化。Collator,NumberFormat 和 DateTimeFormat 对象的构造函数是 Intl 对象的属性。
new Intl.NumberFormat().format(1234567890) // 1,234,567,890

优点:简单粗暴,直接调用 API
缺点:Intl兼容性不太好,不过 toLocaleString的话 IE6 都支持

你可能不知道的前端知识点:Intl对象 和 toLocaleString的方法。

5、交换两个整数

let a = 3,b = 4 变成 a = 4, b = 3

5.1 普通版

首先最常规的办法,引入一个 temp 中间变量

let a = 3,b = 4
let temp = a
a = b
b = temp
console.log(a, b)

优点:一眼能看懂就是最好的优点
缺点:硬说缺点就是引入了一个多余的变量

5.2 进阶版

在不引入中间变量的情况下也能交互两个变量

let a = 3,b = 4
a += b
b = a - b
a -= b
console.log(a, b)

优点:比起楼上那种没有引入多余的变量,比上面那一种稍微巧妙一点
缺点:当然缺点也很明显,整型数据溢出,比如说对于32位字符最大表示有符号数字是2147483647,也就是Math.pow(2,31)-1,如果是2147483645和2147483646交换就失败了。

5.3 终极版

利用一个数异或本身等于0和异或运算符合交换率。

let a = 3,b = 4
  a ^= b
  b ^= a
  a ^= b
console.log(a, b)

下面用竖式进行简单说明:(10进制化为二进制)

    a = 011
(^) b = 100
则  a = 111(a ^ b的结果赋值给a,a已变成了7)
(^) b = 100
则  b = 011(b^a的结果赋给b,b已经变成了3)
(^) a = 111
则  a = 100(a^b的结果赋给a,a已经变成了4)

从上面的竖式可以清楚的看到利用异或运算实现两个值交换的基本过程。

下面从深层次剖析一下:

1.对于开始的两个赋值语句,a = a ^ b,b = b ^ a,相当于b = b ^ (a ^ b) = a ^ b ^ b,而b ^ b 显然等于0。因此b = a ^ 0,显然结果为a。

  1. 同理可以分析第三个赋值语句,a = a ^ b = (a ^ b) ^ a = b

注:

  1. ^ 即”异或“运算符。
它的意思是判断两个相应的位值是否为”异“,为”异"(值不同)就取真(1);否则为假(0)。
  1. ^ 运算符的特点是与0异或,保持原值;与本身异或,结果为0。

优点:不存在引入中间变量,不存在整数溢出
缺点:前端对位操作这一块可能了解不深,不容易理解

5.4 究极版

熟悉 ES6 语法的人当然不会对解构陌生

var a = 3,b = 4;
[b, a] = [a, b]

其中的解构的原理,我暂时还没读过 ES6的规范,不知道具体的细则,不过我们可以看看 babel 是自己编译的,我们可以看出点门路。

哈哈,简单粗暴,不知道有没有按照 ES 的规范,其实可以扒一扒 v8的源码,chrome 已经实现这种解构用法。

这个例子和前面的例子编写风格有何不同,你如果细心的话就会发现这两行代码多了一个分号,对于我这种编码不写分号的洁癖者,为什么加一个分号在这里,其实是有原因的,这里就简单普及一下,建议大家还是写代码写上分号

5.4 ECMAScript 自动分号;插入(作为补充,防止大家以后踩坑)

尽管 JavaScript 有 C 的代码风格,但是它不强制要求在代码中使用分号,实际上可以省略它们。

JavaScript 不是一个没有分号的语言,恰恰相反上它需要分号来就解析源代码。 因此 JavaScript 解析器在遇到由于缺少分号导致的解析错误时,会自动在源代码中插入分号。

5.4.1例子
var foo = function() {
} // 解析错误,分号丢失
test()

自动插入分号,解析器重新解析。

var foo = function() {
}; // 没有错误,解析继续
test()
5.4.2工作原理

下面的代码没有分号,因此解析器需要自己判断需要在哪些地方插入分号。

(function(window, undefined) {
    function test(options) {
        log('testing!')

        (options.list || []).forEach(function(i) {

        })

        options.value.test(
            'long string to pass here',
            'and another long string to pass'
        )

        return
        {
            foo: function() {}
        }
    }
    window.test = test

})(window)

(function(window) {
    window.someLibrary = {}
})(window)

下面是解析器"猜测"的结果。

(function(window, undefined) {
    function test(options) {

        // 没有插入分号,两行被合并为一行
        log('testing!')(options.list || []).forEach(function(i) {

        }); // <- 插入分号

        options.value.test(
            'long string to pass here',
            'and another long string to pass'
        ); // <- 插入分号

        return; // <- 插入分号, 改变了 return 表达式的行为
        { // 作为一个代码段处理
            foo: function() {}
        }; // <- 插入分号
    }
    window.test = test; // <- 插入分号

// 两行又被合并了
})(window)(function(window) {
    window.someLibrary = {}; // <- 插入分号
})(window); //<- 插入分号

解析器显著改变了上面代码的行为,在另外一些情况下也会做出错误的处理。

5.4.3 ECMAScript对自动分号插入的规则

我们翻到7.9章节,看看其中插入分号的机制和原理,清楚只写以后就可以尽量以后少踩坑

必须用分号终止某些 ECMAScript 语句 ( 空语句 , 变量声明语句 , 表达式语句 , do-while 语句 , continue 语句 , break 语句 , return 语句 ,throw 语句 )。这些分号总是明确的显示在源文本里。然而,为了方便起见,某些情况下这些分号可以在源文本里省略。描述这种情况会说:这种情况下给源代码的 token 流自动插入分号。



还是比较抽象,看不太懂是不是,不要紧,我们看看实际例子,总结出几个规律就行,我们先不看抽象的,看着头晕,看看具体的总结说明, 化抽象为具体

首先这些规则是基于两点:

  1. 以换行为基础;
  2. 解析器会尽量将新行并入当前行,当且仅当符合ASI规则时才会将新行视为独立的语句。
5.4.3.1 ASI的规则

1. 新行并入当前行将构成非法语句,自动插入分号。

if(1 < 10) a = 1
console.log(a)
// 等价于
if(1 < 10) a = 1;
console.log(a);

2. 在continue,return,break,throw后自动插入分号

return
{a: 1}
// 等价于
return;
{a: 1};

3. ++、--后缀表达式作为新行的开始,在行首自动插入分号

a
++
c
// 等价于
a;
++c;

4. 代码块的最后一个语句会自动插入分号

function(){ a = 1 }
// 等价于
function(){ a = 1; }
5.4.3.2 No ASI的规则

1. 新行以 ( 开始

var a = 1
var b = a
(a+b).toString()
// 会被解析为以a+b为入参调用函数a,然后调用函数返回值的toString函数
var a = 1
var b =a(a+b).toString()

2. 新行以 [ 开始

var a = ['a1', 'a2']
var b = a
[0,1].slice(1)
// 会被解析先获取a[1],然后调用a[1].slice(1)。
// 由于逗号位于[]内,且不被解析为数组字面量,而被解析为运算符,而逗号运算符会先执
行左侧表达式,然后执行右侧表达式并且以右侧表达式的计算结果作为返回值
var a = ['a1', 'a2']
var b = a[0,1].slice(1)

3. 新行以 / 开始

var a = 1
var b = a
/test/.test(b)
// /会被解析为整除运算符,而不是正则表达式字面量的起始符号。浏览器中会报test前多了个.号
var a = 1
var b = a / test / .test(b)

4. 新行以 + 、 - 、 % 和 * 开始

var a = 2
var b = a
+a
// 会解析如下格式
var a = 2
var b = a + a

5. 新行以 , 或 . 开始

var a = 2
var b = a
.toString()
console.log(typeof b)
// 会解析为
var a = 2
var b = a.toString()
console.log(typeof b)

到这里我们已经对ASI的规则有一定的了解了,另外还有一样有趣的事情,就是“空语句”。

// 三个空语句
;;;

// 只有if条件语句,语句块为空语句。
// 可实现unless条件语句的效果
if(1>2);else
  console.log('2 is greater than 1 always!');

// 只有while条件语句,循环体为空语句。
var a = 1
while(++a < 100);
5.4.4 结论

建议绝对不要省略分号,同时也提倡将花括号和相应的表达式放在一行, 对于只有一行代码的 if 或者 else 表达式,也不应该省略花括号。 这些良好的编程习惯不仅可以提到代码的一致性,而且可以防止解析器改变代码行为的错误处理。
关于JavaScript 语句后应该加分号么?(点我查看)我们可以看看知乎上大牛们对着个问题的看法。

你可能不知道的前端知识点:原来 JavaScript 还有位操作以及分号的使用细则

6、将 argruments 对象(类数组)转换成数组

{0:1,1:2,2:3,length:3}这种形式的就属于类数组,就是按照数组下标排序的对象,还有一个 length 属性,有时候我们需要这种对象能调用数组下的一个方法,这时候就需要把把类数组转化成真正的数组。

6.1 普通版

var makeArray = function(array) {
  var ret = []
  if (array != null) {
    var i = array.length
    if (i == null || typeof array === "string") ret[0] = array
    else while (i) ret[--i] = array[i];
  }
  return ret
}
makeArray({0:1,1:2,2:3,length:3}) //[1,2,3]

优点:通用版本,没有任何兼容性问题
缺点:太普通

6.2 进阶版

var arr = Array.prototype.slice.call(arguments);

这种应该是大家用过最常用的方法,至于为什么可以这么用,很多人估计也是一知半解,反正我看见大家这么用我也这么用,要搞清为什么里面的原因,我们还是从规范和源码说起。

照着规范的流程,自己看看推演一下就明白了:
英文版15.4.4.10 Array.prototype.slice (start, end)
中文版15.4.4.10 Array.prototype.slice (start, end)
如果你想知道 JavaScriptsort 排序的机制,到底是哪种排序好,用的哪种,也可以从规范看出端倪。

在官方的解释中,如[mdn]

The slice() method returns a shallow copy of a portion of an array into a new array object.

简单的说就是根据参数,返回数组的一部分的 copy。所以了解其内部实现才能确定它是如何工作的。所以查看 V8 源码中的 Array.js 可以看到如下的代码:

方法 ArraySlice源码地址,第 660 行,直接添加到 Array.prototype 上的“入口”,内部经过参数、类型等等的判断处理,分支为 SparseSliceSimpleSlice 处理。

slice.call 的作用原理就是,利用 call,将 slice 的方法作用于 arrayLikeslice 的两个参数为空,slice 内部解析使得 arguments.lengt 等于0的时候 相当于处理 slice(0) : 即选择整个数组,slice 方法内部没有强制判断必须是 Array 类型,slice 返回的是新建的数组(使用循环取值)”,所以这样就实现了类数组到数组的转化,call 这个神奇的方法、slice 的处理缺一不可。

直接看 slice 怎么实现的吧。其实就是将 array-like 对象通过下标操作放进了新的 Array 里面:

// This will work for genuine arrays, array-like objects, 
    // NamedNodeMap (attributes, entities, notations),
    // NodeList (e.g., getElementsByTagName), HTMLCollection (e.g., childNodes),
    // and will not fail on other DOM objects (as do DOM elements in IE < 9)
    Array.prototype.slice = function(begin, end) {
      // IE < 9 gets unhappy with an undefined end argument
      end = (typeof end !== 'undefined') ? end : this.length;

      // For native Array objects, we use the native slice function
      if (Object.prototype.toString.call(this) === '[object Array]'){
        return _slice.call(this, begin, end); 
      }

      // For array like object we handle it ourselves.
      var i, cloned = [],
        size, len = this.length;

      // Handle negative value for "begin"
      var start = begin || 0;
      start = (start >= 0) ? start : Math.max(0, len + start);

      // Handle negative value for "end"
      var upTo = (typeof end == 'number') ? Math.min(end, len) : len;
      if (end < 0) {
        upTo = len + end;
      }

      // Actual expected size of the slice
      size = upTo - start;

      if (size > 0) {
        cloned = new Array(size);
        if (this.charAt) {
          for (i = 0; i < size; i++) {
            cloned[i] = this.charAt(start + i);
          }
        } else {
          for (i = 0; i < size; i++) {
            cloned[i] = this[start + i];
          }
        }
      }

      return cloned;
    };

优点:最常用的版本,兼容性较强
缺点:ie 低版本,无法处理 dom 集合的 slice call 转数组。(虽然具有数值键值、length 符合ArrayLike 的定义,却报错)搜索资料得到 :因为 ie 下的 dom 对象是以 com 对象的形式实现的,js 对象与com对象不能进行转换 。

6.3 ES6 版本

使用 Array.from, 值需要对象有 length 属性, 就可以转换成数组

var arr = Array.from(arguments);

扩展运算符

var args = [...arguments];

ES6 中的扩展运算符...也能将某些数据结构转换成数组,这种数据结构必须有便利器接口。
优点:直接使用内置 API,简单易维护
缺点:兼容性,使用 babel 的 profill 转化可能使代码变多,文件包变大

你可能不知道的前端知识点:slice 方法的具体原理

7、数字取整 2.33333 => 2

7.1 普通版

const a = parseInt(2.33333)

parseInt() 函数解析一个字符串参数,并返回一个指定基数的整数 (数学系统的基础)。这个估计是直接取整最常用的方法了。
更多关于 parseInt() 函数可以查看 MDN 文档

7.2 进阶版

const a = Math.trunc(2.33333)

Math.trunc() 方法会将数字的小数部分去掉,只保留整数部分。
特别要注意的是:Internet Explorer 不支持这个方法,不过写个 Polyfill 也很简单:

Math.trunc = Math.trunc || function(x) {
  if (isNaN(x)) {
    return NaN;
  }
  if (x > 0) {
    return Math.floor(x);
  }
  return Math.ceil(x);
};

数学的事情还是用数学方法来处理比较好。

7.3 黑科技版

7.3.1 ~~number

双波浪线 ~~ 操作符也被称为“双按位非”操作符。你通常可以使用它作为代替 Math.trunc() 的更快的方法。

console.log(~~47.11)  // -> 47
console.log(~~1.9999) // -> 1
console.log(~~3)      // -> 3
console.log(~~[])     // -> 0
console.log(~~NaN)    // -> 0
console.log(~~null)   // -> 0

失败时返回0,这可能在解决 Math.trunc() 转换错误返回 NaN 时是一个很好的替代。
但是当数字范围超出 ±2^31−1 即:2147483647 时,异常就出现了:

// 异常情况
console.log(~~2147493647.123) // -> -2147473649 ?
7.3.2 number | 0

| (按位或) 对每一对比特位执行或(OR)操作。

console.log(20.15|0);          // -> 20
console.log((-20.15)|0);       // -> -20
console.log(3000000000.15|0);  // -> -1294967296 ?
7.3.3 number ^ 0

^ (按位异或),对每一对比特位执行异或(XOR)操作。

console.log(20.15^0);          // -> 20
console.log((-20.15)^0);       // -> -20
console.log(3000000000.15^0);  // -> -1294967296 ?
7.3.4 number << 0

<< (左移) 操作符会将第一个操作数向左移动指定的位数。向左被移出的位被丢弃,右侧用 0 补充。

console.log(20.15 < < 0);     // -> 20
console.log((-20.15) < < 0);  //-20
console.log(3000000000.15 << 0);  // -> -1294967296 ?

上面这些按位运算符方法执行很快,当你执行数百万这样的操作非常适用,速度明显优于其他方法。但是代码的可读性比较差。还有一个特别要注意的地方,处理比较大的数字时(当数字范围超出 ±2^31−1 即:2147483647),会有一些异常情况。使用的时候明确的检查输入数值的范围。

8、数组求和

8.1 普通版

let arr = [1, 2, 3, 4, 5]
function sum(arr){
    let x = 0
    for(let i = 0; i < arr.length; i++){
        x += arr[i]
    }
    return x
}
sum(arr) // 15

优点:通俗易懂,简单粗暴
缺点:没有亮点,太通俗

8.2 优雅版

let arr = [1, 2, 3, 4, 5]
function sum(arr) {
return arr.reduce((a, b) => a + b)
}
sum(arr) //15

优点:简单明了,数组迭代器方式清晰直观
缺点:不兼容 IE 9以下浏览器

8.3 终极版

let arr = [1, 2, 3, 4, 5]
function sum(arr) {
return eval(arr.join("+"))
}
sum(arr) //15

优点:让人一时看不懂的就是"好方法"。
缺点:

eval 不容易调试。用 chromeDev 等调试工具无法打断点调试,所以麻烦的东西也是不推荐使用的…

性能问题,在旧的浏览器中如果你使用了eval,性能会下降10倍。在现代浏览器中有两种编译模式:fast path和slow path。fast path是编译那些稳定和可预测(stable and predictable)的代码。而明显的,eval 不可预测,所以将会使用 slow path ,所以会慢。

更多关于 eval 的探讨可以关注这篇文章: JavaScript 为什么不推荐使用 eval?

你可能不知道的前端知识点:eval的使用细则

最后

祝大家圣诞快乐?,欢迎补充和交流。


查看原文

赞 34 收藏 75 评论 14

灯盏细辛 回答了问题 · 2017-10-24

display:table很好用,而且能实现垂直自适应的效果,兼容性强于flex,怎么使用率这么低呢?

table 对结构的限制太死了,必须是 tr td 复杂布局还要算 rowspan 和 colspan,虽然可以用 thead tbody th 这些,但远远不够用阿

如果你用 vue / React 自己拼过表格,就知道这是多么的痛了。。。

关注 9 回答 7

灯盏细辛 赞了回答 · 2017-09-26

解决为什么window.onload比$(document).ready()先执行?

顺着问题,我自己尝试了一下。
应该是jquery3更新的问题。

恰巧也找了一个issues 讨论的这个。
机票:https://github.com/jquery/jqu...

关注 9 回答 9

灯盏细辛 赞了文章 · 2017-09-20

去哪儿网迷你React的研发心得

去哪儿网迷你React是年初立项的新作品,在这前,去哪儿网已经深耕多年,拥有QRN(react-native的公司制定版),HY(基于React的hybird方案), yo(基于React的移动UI库),QRN-web(基于React的三端合一移植方案),此外,像机票等部门也大规模将React用于前台页面,后台页面就更不在话下。

如此广泛地应用React,我们熟晓其优缺点。优点是代码的可维护性大大提高,性能卓然!但缺点也明显,由于体积太大,React.js+React-DOM.js超过3万行,体量过3MB,已经加上immutable.js , redux, redux-react, react-router等全家桶,工程师一行代码没有写,已经好几MB了。这在过去绝对不可想象,要知道,体积意味着流量,流量代表着金钱,越大越烧钱,越大下载速度越慢,用户体验越差,用户就会流失。现在问题摆在我们面前,我们就得解决。虽然webpack官网有各种瘦身方案,但瘦死的大象也比马大。这是一个如此普遍存在的问题,因此外国人肯定也遇到过,思考过,提出什么新点子——所有这一切,都指向一个名词,“迷你React”。

作为一个生态圈成熟的标志,一个库出名了,就各种偏门补丁,闪光效果往上加,库难免会膨胀,不爽的人就会推出迷你化方案。上一个世代是jQuery与zepto。React的迷你方案也有不少,preact, inferno, react-lite, dio, rax……

clipboard.png

但问题不是简单到直接从github找一个迷你React库替换上就能搞定。之所以称为迷你,肯定有所欠缺,如果是内部实现的改良也罢,如果阉割了功能可不是闹着玩。恰恰他们就好这口,因此现有方案不能满足我们。我们需要一个能直接替换,或至少95%的业务代码不用动。这意味着这迷你框架,需要与React的接口完全一致,并且全面支持它的全家桶。当然细化一下来的需来就更多了,早期说只要支持移动,因此取名为react-mobile,后来连iE8也要支持,因此这活不能指望别人,我们自己动手撸了。

clipboard.png

我们先来一趟竞品分析。为了加快产出速度,能借鉴的尽可能借鉴。React推出以后,针对其性能研究衍生出不少库,针对其体积也诞生出大量仿品。它比jQuery更加缤纷多采。市面上竟然拥有100多个虚拟DOM库。虚拟DOM库,就是React出来后的一种新式库,以虚拟DOM与diff算法为核心,屏蔽DOM操作,操作数据即操作视图。这听起来有点像MVVM,MVVM也是屏蔽DOM操作,操作数据即操作视图,但它是以VM为核心。

clipboard.png

React及其他虚拟DOM库已经将虚拟DOM的生成交由JSX与babel处理了,因此不同点是,虚拟DOM的结构与diff算法。虚拟DOM万宗不离其变是三大属性,type, props, children,当然也可以改一下别名,babel可以做相应配置。此外,虚拟DOM可以加入更多冗余标识,以帮diff算法的改良。

React最初推出时也不火,那时的招牌与现在性能不什么两样,也是高性能。但是JSX离经背道,与业界宣扬了多年的前后端分离,数据结构样式分离等教条差太远了,一直默默在角落里画圈。直到RN出来,解决原生编写界面的痛苦才一炮而红。大家才留意它的性能,它的性能背后的diff算法。最早研究React的diff算法是virtual-dom这个库,是基于经典的DFS算法。后来相应的算法就多起来。最后才是从接口进行模拟,就是所谓的React-like框架。因此虚拟DOM库是分为两大派系:算法派与拟态派。

clipboard.png

下面将从这两大派系进行扼要的描述。

将前端回归到算法上的探索是前端框架史上的一个巨大进步。之前是MVVM将数据从繁复的DOM操作分离出来。正因为有了纯数据,并且数据结构可控,那么算法才有发挥的余地。

最开始出现的是 virtual-dom这个库,是大家好奇React为什么这么快而搞鼓出来的。它的实现是非常学院风格,通过深度优先搜索与in-order tree来实现高效的diff。它与React后来公开出来的算法是很不一样。

然后是cito.js的横空出世,它对今后所有虚拟DOM的算法都有重大影响。它采用两端同时进行比较的算法,将diff速度拉高到几个层次。

紧随其后的是kivi.js,在cito.js的基出提出两项优化方案,使用key实现移动追踪及基于key的编辑长度矩离算法应用(算法复杂度 为O(n^2))。

但这样的diff算法太过复杂了,于是后来者snabbdom将kivi.js进行简化,去掉编辑长度矩离算法,调整两端比较算法。速度略有损失,但可读性大大提高。再之后,就是著名的vue2.0 把sanbbdom整个库整合掉了。

当然算法派的老大是inferno,它使用多种优化方案将性能提至最高,因此其作者便邀请进react core team,负责react的性能优化了。这个我后面会详细。

clipboard.png

再看拟态派。React的接口并不多,但是其组件的实现是相当有难度。它的生命周期是如何运作,需要对源码有深刻的理解,因此它们出来得比较晚。我们的学习对象也就是它们几个。

clipboard.png

先说虚拟DOM。虚拟DOM就是一个普通的JS对象,通常拥有三个属性,type, props, children。但无状态组件出来后,children改放到props中。此外,有些元素还有ref属性,可以是字符串与函数。在数组里,为了提高diff速度,又多出了key属性。bable会将JSX这些属性转换为一个VNode对象。这是虚拟DOM的最小单元。所有虚拟DOM会组成一棵树,叫虚拟DOM树。

为了防止每次都是整个树进行diff,需要形成子树的概念,于是出现组件了。组件有render方法,会返回一个虚拟DOM,这个虚拟DOM及其子孙,就形成一棵子树。但render方法不是虚拟DOM的东西,于是我们规定当虚拟DOM为一个函数时,如果这个函数继承于React.Component,这个方法的实例必须有render方法。于是我们就像虚拟DOM与组件统合起来了。或者衍生这两个称呼,原子虚拟DOM与组件虚拟DOM。原子虚拟DOM对应元素节点,而组件则是用来产出原子虚拟DOM。此外,原子虚拟DOM还能包含一些东西,字符串与数字与null。字符串与数字对应文本节点,null对应注释节点。

clipboard.png

一个组件虚拟DOM实例化为组件后,会返回原子虚拟DOM或另一个组件虚拟DOM。这就形成函数式编程上的高阶函数的机制,因此进行出无状态函数组件,就是虚拟DOM的type属性就是一个函数,不继承其他东西了。

组件虚拟DOM的实例化过程是非常复杂,如果能简化这过程,简化其结构,这性能就上去了。

clipboard.png

此外组件的实例本身就巨耗性能,因此官方推荐页面的结构如下,通过最少量的有状态组件(smart component)控制无状态组件(dumb component)的变化,所有状态通过redux在路由进行分发。

clipboard.png

经过一番比较后,我们着手开发自己的迷你React。这个过程也比较坎坷,这还是有前人参照物的情况下,可想而知,当初facebook开发出React这样一个独行特立的框架时,是多么艰辛。我们在这半年总共搞了三个东西,第一还孵化失败。

clipboard.png

最初是基于react-lite,考虑时当时是我们母公司的人搞的,方便交流。但后来发现它的事件系统太鸡肋,难以扩展,最后放弃了。

第二代是基于preact,代号qreact, 国内也有许多公司基于它做自己的迷你化方案,因为官方提供了一个preact-compat的模块。但是preact-compat是使用Object.defineProperty来实现一些属性名映射与同步的,因此不支持IE8,并且使用了Object.defineProperty会严重拖慢速度。preact本身也有不少BUG,最著名的有三个,生命周期的unmount钩子不能保证在mount之前执行,元素节点的重复利用没有清理样式会导致出错,同一组孩子下可能存在同名的key导致排序失败。这些我们都为官方提issue,并且在我们的版本中进行修复了。第二代也公司内部几个项目中试水落地,反映不错。

第三代是我们团队独力开发,a内部代号anu,正式名称仍然是 QReact,不过演进到了 QReact 1.0 了 (现在的版本使用了 1.0 大版本,因为之前这个版本没被占用,所以没跳到 2.0)。在对preact缝缝补补的过程,掌握不少核心知识,新的框架是使用全新的算法,全新的结构。由于不使用高级API,理论上能支持到IE6,但我们公司只需支持到IE8。

Qreact1.0使用requestAnimationFrame来稳定它的运行帧数,保证在60帧每秒的流畅速度。由于bable会对type进行打补丁,内部统一用typeNumber代替type进行类型判定。使用列队保证生命周期钩子按顺序执行。使用__rerender标识一个组件在一次大的更新只会被render一次。凡此种种,经过大量测试,它们的接口与React别无二致,甚至React废弃的接口createClass, PropTypes,我们都有相应的polyfill。

下面是Qreact1.0的测试数据。
从性能上,直追preact。

clipboard.png

从体积上,是官方react的1/4至1/5之间。

clipboard.png

这里透露一下React性能爆表的秘密,除了diff算法外,setState的合并操作也是一个关键。当组件没有插入到DOM树前,用户在componentWillMount方法多次执行setState,这些state是不会触发更新,而是存放到一个数组中,然后在render方法里进行合并与应用。当一个组件进行更新,可能是用户在componentDidMount或者事件回调执行setState,这时更新是即时的,同步的,但这之次再setState,它就不会更新了,它的state会进入列队。此外,如果用户在componentWillReceiveProps多次执行setState,也会产生延迟。React这种行为是保证页面的更新次数最少,同时用户不会察觉它没有更新。它只是将state进行了合并。Qreact1.0是完全遵循了这些规则,从而实现了高性能。

而在体积上,则通过删除一些对线上没用的代码实现迷你化效果。

clipboard.png

但仅是这样不足以大吹特吹了。为了超越React的性能,从inferno与preact借签了不少手段。

clipboard.png

最值得一提的是hydrate机制,通过合并相邻字符串,从而减少文本节点的生成,从而减少diff次数。

clipboard.png

最后还有一个压轴大戏,不做测试不知道,原来高级API是如何耗性能。通过去掉Object.freeze, Object.defineProperty这些es5方法, 框架就有10帧的提升!

clipboard.png

说了这么多,来些实际可运行的例子吧。目前,Qreact跑通内部几套测试,已经在金融与大搜的项目使用。它的第二版也在机票一个拥有820个模块的大项目中试水,在IE8下良好运行。

clipboard.png

如果大家想在项目中使用qreact,可以在webpack或ykit的config中如下配置:


resolve: {
   alias: {
      'react': 'anujs',
      'react-dom': 'anujs',
        // 若要兼容 IE 请使用以下配置
        // 'react': 'anujs/dist/ReactIE',
        // 'react-dom': 'anujs/dist/ReactIE',
    
        // 如果引用了 prop-types 或 create-react-class
        // 需要添加如下别名
        'prop-types': 'anujs/lib/ReactPropTypes',
        'create-react-class': 'anujs/lib/createClass'
        //如果你在移动端用到了onTouchTap事件
        'react-tap-event-plugin': 'anujs/lib/injectTapEventPlugin',  
   }
},

如果大家对qreact在感兴趣,欢迎与我联系,也欢迎大家为我的框架加星。

https://github.com/RubyLouvre/anu/
https://github.com/YMFE/qreact

查看原文

赞 142 收藏 381 评论 27

认证与成就

  • 获得 16 次点赞
  • 获得 8 枚徽章 获得 1 枚金徽章, 获得 1 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2011-07-01
个人主页被 649 人浏览