Code Monkey home page Code Monkey logo

javascript-wiki's Introduction

  • 👋 Hi, I’m lightlin
  • 🎓 Graduated from Neusoft Institute Guangdong,majoring in software engineering
  • 💻 React, Next.js, Vue, Uniapp, Wechat Mini Program & Serverless, A little Golang
  • 💞 I'm looking for a job in front-end development
  • 📫 Concat me: [email protected]

javascript-wiki's People

Contributors

mylightlin avatar

Stargazers

 avatar  avatar  avatar

Watchers

 avatar  avatar

Forkers

yin-xiong

javascript-wiki's Issues

实现一个 EventEmitter

前端的设计模式里,发布-订阅模式 可以说是应用最广泛也经常在实践中看见它们的身影。发布-订阅模式的主要**是:A 设置一个订阅器,提供不同的 type 供 B,C,D 等订阅,然后当某种 type 的内容有更新时,A 就会通知到所有订阅了该内容的 B,C,D 等人。发布订阅模式主要有以下几个方法:

  • on: 注册事件监听的回调函数
  • emit: 触发事件,通知所有回调执行
  • off: 移除事件监听
  • once: 注册事件监听,但是只监听一次。这个方法可以用来解决缓存雪崩问题:当大量请求涌入数据库,可以通过状态锁来保证只执行一个查询请求,但是状态锁会导致后续进来的查询请求无法应用。因此可以引入事件队列,利用 once 只触发一次的效果来实现请求去重。

代码

// 实现 EventEmiter
class EventEmitter {
  constructor() {
    this.events = {}
  }
  
  emit(type, ...args) {
    this.events[type].forEach(fn => {
      fn(...args)
    })
    return true
  }
  
  on(type, handler) {
    if (!this.events[type]) {
      this.events[type] = []
    }
    this.events[type].push(handler)
    return this
  }
  
  off(type, handler) {
    const lis = this.events[type]
    if (!lis) return this
    for (let i = lis.length; i > 0; i--) {
      if (lis[i] === handler) {
        lis.splice(i, 1)
        break
      }
    }
    return this
  }
  
  once(type, handler) {
    this.events[type] = this.events[type] || []
    const onceWrapper = () => {
      handler()
      this.off(type, onceWrapper)
    }
    this.events[type].push(onceWrapper)
    return this
  }
}

计算机网络之 WebSocket

什么是 WebSocket

WebSocket 是一个双向、全双工的即时通信协议,它与 HTTP 协议一样基于 TCP,在客户端和服务器之间建立连接。WebSocket 是有状态的,这意味着一旦建立连接,就能一直保持连接打开,类似 Keep Alive 的能力,直到其中一方主动关闭断开连接。

那为什么需要 WebSocket 呢?这就要从 HTTP 说起,很早以前网络传输都是使用的 http,这是典型的 客户端-服务器 模式。客户端发起一个 http 请求,服务器响应资源,随后连接被关闭。这是一个单向的过程,而且控制权在 客户端 这边。通常是由客户端去发起请求,然后服务端作应答。那有没办法让服务端也可以向客户端发送消息呢?实际上 HTTP2 已经支持 Server Push 的功能,但这只是服务端去主动发送附带资源给客户端,客户端并没有一个明确的事件可以监听到服务端发来的数据。所以想实现真正的双向通信,还需要有专门的机制才行。于是 HTML5 新增了 WebSocket 协议,它支持双向、全双工通信,客户端和服务端都可以收发消息,而且是实时通信,端到端接收。

如何建立 WebSocket 连接

客户端和服务器需要进行 握手,通常是客户端先发送一个 http 请求,在请求头有个 Upgrade 字段, 表示希望升级成 WebSocket 连接。请求示例如下:

GET ws://websocket.example.com/ HTTP/1.1
Origin: http://example.com
Connection: Upgrade
Host: websocket.example.com
Upgrade: websocket

服务端如果同意,就会响应 101 状态码,告诉客户端说可以切换使用 WebSocket 协议,响应 payload 示例如下:

HTTP/1.1 101 Switching Protocols
Date: Wed, 25 Oct 2017 10:07:34 GMT
Connection: Upgrade
Upgrade: WebSocket

当连接建立的时候,就会触发 open 事件,可以通过监听该事件来执行操作:

var socket = new WebSocket('ws://websocket.example.com');

socket.onopen = function(event) {
  console.log('WebSocket is connected.')
}

这时 socket 连接已经建立完毕,接下来客户端和服务端都可以发送消息了,可以通过 send 方法来发送消息,通过监听 message 事件来接收消息。对于错误处理,可以监听 error 事件来处理。对于关闭连接,通过监听 close 事件。

socket.onmessage = function(event) {
  console.log('接收的数据:', event.data)
}

socket.onclose = function(reasonCode, description) {
  console.log('close: ', reasonCode, description)
}

socket.onerror = function() {
  console.log('error')
}

WebSocket 的心跳机制

心跳机制 是指连接建立后,客户端和服务端两方都可以通过发送 ping 报文,然后另一方接收到后,必须回复一个 pong 报文。这样做的目的是为了探测其中一方是否还处于连接状态。由于 WebSocket 是双向传输,任意一方都可以发送消息及断开连接,如果出现一些意外情况比如网络错误导致其中一方断开了,那如何让另一方发现呢?这就要有一个探测机制,来实时检查对方的连接状态,避免无谓等待所带来的花销。所以心跳机制就是让双方都确认对方的在线状态。

WebSocket 的应用场景

  • 实时网络通信应用程序,比如 QQ 聊天,对话框里互相发送消息,群发消息就是使用的 WebSocket
  • 游戏 APP:平时我们玩的游戏,对实时性要求非常高,因为数据不断地被服务器接受处理,但是我们看屏幕 UI 是自动刷新的,这就使用了 WebSocket 技术

WebSocket 与 HTTP 的区别

  • HTTP 是单向通信,比如客户端发送,服务端响应;而 WebSocket 是双向通信,实时收发消息
  • HTTP 是无状态的,协议以 http(s):// 开头,而 WebSocket 是有状态的,以 ws(s):// 开头
  • WebSocket 是长连接的,除非一方主动关闭否则一直保持连接;而 HTTP 连接数据传输完就会被关闭
  • WebSocket 传输数据比 HTTP 连接快

image

即时通信的其它解决方案

除了 WebSocket 外,以下技术也可以实现即时通信:

  • 短轮询:前端每隔一段时间发起 ajax 请求,获取服务器更新内容
  • 长轮询:是对 短轮询 的改进,客户端发起请求到服务器后,一直保持打开状态,等服务器有内容更新就响应一次数据。
  • SSE:Server Sent Event,基于流的服务端推送技术。它允许服务端异步的向客户端推送数据,它有点类似 发布-订阅 模式,服务端可以决定向客户端发送哪些数据,客户端可以通过一套 API 来订阅内容的获取。
  • Socket.IO: 基于 WebSocket 做了一些兼容性工作,封装了一些更好用的接口。

Webpack

Webpack 是一个 JavaScript 及其周边的模块打包工具。它所做的工作其实很简单:将项目中的资源文件进行打包,生成一个大的 bundle 文件,然后交由服务器返回给客户端解析展现。

为什么需要 Webpack

在以前,传统的 Web 开发页面都是 DOM 直出的,客户端发起请求,服务器进行一系列逻辑处理,返回 HTML 和 CSS 资源文件。这种模式就是每次请求都要刷新一次页面来获取最新的资源文件,体验并不好。后来出现了 单页面应用(SPA),它允许我们在不刷新的情况下更新页面获取新资源,单页面应用的主要技术就是 Ajax,它的特点就是自始至终只有一个 URL,很多处理工作都是在前端完成的,也就是所谓前后端分离。这里顺便提下,由于单页面一个 url ,意味着很难感知用户的操作行为,为此又有了 前端路由 的解决方案。具体可以参考 这篇

前面说了,单页面应用很多工作都是在前端完成的,具体来说就是前端开始往工程化发展了,我们需要承担一些以前在服务端才处理的逻辑,这样项目就开始庞大起来了,这就意味着需要划分模块,而 JS 在模块化这方面支持不够,所以才涌现出了诸如立即执行函数,AMD,CMD,UMD,ES6 module 等模块化解决方案。可以说,这些模块化方案解决了代码如何组织的问题,但是它们没有解决我们项目多个模块之间的处理引入问题。于是 Webpack ,Gulp 这种模块化打包工具就出现了,它们解决了以下问题:

  • 针对不同模块标准所带来的环境兼容问题
  • 由于项目细分了很多模块,意味着网络文件请求增多,需要解决效率问题
  • 不仅 JS 代码可以模块化,HTML ,CSS 和图片资源也可以模块化

Loader 机制

Loader 简单来说就是针对不同类型资源的一个加载器,因为 webpack 默认是把所有文件当做 JS 来解析处理的,假如你需要处理 CSS 或其他资源,那就需要加载器来辅助做这项工作,比如针对 css,有 css-loader;针对 sass,有 sass-loader。针对 ES6 转 ES5 代码,有 babel-laoder 等等。

plugin 机制

插件是 webpack 提供的另一种能力,可以让我们方便地做一些自动化处理操作。比如:

  • 打包构建前清空 dist 目录
  • 自动生成根 html 文件
  • 压缩打包的文件
  • 将静态目录下的资源文件复制到 dist 中

插件的原理:钩子。webpack 会在打包构建的过程每个环节预留一些钩子,在这些钩子里就可以执行我们自定义的逻辑了。

class Test {
  apply (compiler) {
    compiler.hooks.emit.tap('函数', compilation => {
      // 执行逻辑
    })
  }
}

webpack 打包的过程

初始化阶段

  • 由 Webpack CLI 启动打包流程,合并命令行参数和用户的配置文件
  • 载入 Webpack 核心模块,创建 Compiler 对象
  • 初始化编译环境:注入内置插件,注册各种模块工厂等

构建阶段:

  • 执行 Compiler 对象的 run 方法,创建 Compilation 对象
  • 进入 entryOption 阶段,读取配置的 Entry ,递归遍历入口文件,调用 Compilation.addEntry 将文件转换为 Dependency 对象
  • 调用 normalModule 的 build 开始构建,从 entry 入口开始,调用各种 loader 进行转译处理,使用 JS 解释器 Acorn 将代码转换为 AST 对象,递归分析依赖,依次处理所有文件
  • 最终得到模块编译产物和模块依赖关系图

生成阶段:

  • 根据模块依赖关系,生成不同的 chunk,再把这些 chunk 转换成 asset 加入到输出列表中
  • 最后写入文件系统

与 Vite 的对比

在开发阶段:
Webpack 的思路是先打包整个应用文件,然后生成 bundle 文件,再启动开发服务器。当有文件修改时,有 HMR 热替换机制,需要找到对应模块的依赖,然后依次重新编译,如果随着文件越来越多的话也需要很长时间重新编译。

而 Vite 的话,它分为依赖和源码两大模块,思路是先分析,使用 ESBuild 预构建依赖,把 CommonJS/UMD 转换为 ESM,还会使用 Cache-Control 进行强缓存请求。

JS 实现一个简单的权重抽奖算法

平时工作中,小程序业务经常遇到抽奖场景,一般这类抽奖不会在前端做,而是由后端同学来写抽奖逻辑外加各种资格校验。虽说是后端的活,但前端也可以自己实现一个简易的抽奖算法。

抽奖的实现过程

抽奖算法有简单的,有复杂的各种实现,本文介绍的是根据权重生成区间,然后 random 随机数来获取抽中的奖品。具体过程如下:

  1. 首先根据奖品配置的权重,计算出权重区间,保存在数组里
  2. 接着执行 Math.random() 获取 minmax 范围的随机数
  3. 判断 2 中的随机数落在哪个权重区间,拿到区间索引 index
  4. 根据索引 index 去奖品配置里拿到对应中奖奖品

代码实现

约定奖品的配置是如下格式:

const prizes = [
  {
    name: "抽奖券",
    weight: 50,
  },
  {
    name: "大奖",
    weight: 70,
  },
  {
    name: "二奖",
    weight: 80,
  }
]

接下来实现抽奖函数 draw ,代码如下:

function draw(prizes) {
   // 根据每个奖品的权重,生成区间 [[0, 50], [50, 100], ...]
    const intervals = prizes.reduce((acc, curr, idx) => {
        const weight = curr.weight
        const [preStart, preEnd] = acc[acc.length-1] || [0, 0]
        acc.push([preEnd, preEnd + weight])
        return acc
    }, [])

    // 找到区间的最小和最大值
    const [min, max] = intervals.reduce((acc, curr) => {
        if (curr && curr.length) {
            acc[0] = Math.min(acc[0], curr[0])
            acc[1] = Math.max(acc[1], curr[1])
        }
        return acc
    }, [Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER])
    
    // 随机一个数
    const luckyNumber = random(min, max)
    // 看落在哪个区间
    const luckPrizeIndex = intervals.findIndex(item => item[0] <= luckyNumber && item[1] > luckyNumber)
    if (luckPrizeIndex === -1) {
      // 当做未中奖来处理
    }
    // 找到中奖奖品
    const luckyPirze = prizes[luckPrizeIndex]

    return luckyPirze
}

function random(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min)
}

测试

以这份数据为例:

const prizes = [
  {
    name: "抽奖券",
    weight: 50,
  },
  {
    name: "大奖",
    weight: 70,
  },
  {
    name: "二奖",
    weight: 80,
  }
]

编写测试函数:

function testDraw() {
    const countMap = {}
    for (let i = 0; i < 10000; i++) {
        const prize = draw(prizes)
        if (prize) {
            countMap[prize.name] = (countMap[prize.name] || 0) + 1
        }
    }
    return countMap
}

执行 10000 次抽奖,得到如下结果:
image

核算下来,二奖的中奖率约为 35%,大奖的中奖率约为 39%,抽奖券的中奖率约为 24%。与我们奖品配置的权重相比,略高一点点。

浏览器之前端路由

名词解释

在互联网世界里,有两个名词 URI(统一资源标识符)和 URL (统一资源定位符)。
URI:Uniform Resource Identifier,一串字符特征序列,用来唯一标识某个资源,可以标识任何事物。
URL:Uniform Resource Locator,由协议/域名/端口/路径/参数/锚点组成的字符序列,浏览器用它来寻找 web 资源。
我们平时在浏览器里输入的各种地址,都属于 URL。

背景

路由简单理解就是导航,你打开浏览器,想访问 ×× 网站,于是你输入这个网站的地址,然后浏览器给你导航到对应网站的主页去。在很久很久以前,jQuery 盛行那会,使用的是服务端路由,前端代码都包含在后端里,通常是由后端通过 JSP/PHP/.NET 等技术把前端的页面拼接上后端的数据,然后展示在页面上。在这种情况下,你每点击一个页面,就要刷新一次 URL 以便获取新的资源,这种被打断的浏览用户体验并不好。

后来,SPA 单页面应用出现了,它允许在不刷新页面的情况下更新页面内容,利用的是 Ajax 技术。但是单页面应用有两个很大问题:

  • 它只有一个 url ,这意味着你在页面上做了几步操作,是无法记住的,也就是说,你可能在页面里点首页进详情页,但是一刷新就重来显示首页了,所以用户体验依然不好
  • 正是因为只有一个 url,所以 SEO 不友好,搜索引擎无法采集分析到页面信息

为了解决如何在单页面里记住用户的行为步骤,前端路由出现了。

前端路由

路由要解决记住用户操作的问题,解决方式就是通过 url 的变化来展示不同资源页面。对于 url 怎么变化的问题,业界给出了两种解决方案:

  • hash 模式
  • history 模式

hash 模式

这种模式的特点是改变 url # 后面的内容来更新资源,因为更改 # 内容不会引起网页重载,却可以在浏览器正常添加一条历史记录,比如下面这两个对应不同的资源页面:

  • https://www.baidu.com/#/index
  • https:///www.baidu.com/#/search

hash 值可以通过 window.location.hash = '×××' 来改变,另外就是通过监听 hashchange 事件来捕捉 url 的变化,进而决定页面内容的跳转更新,如下:

window.addEventListener('hashchange', function(event){ 
  console.log(event)
}, false)

hash 模式的优点在于兼容性好,无需额外的服务端配置;但缺点也是服务端无法感知 hash 后面的内容。

history 模式

history 路由格式如下:

  • https://www.baidu.com/index
  • https:///www.baidu.com/search

特点是通过浏览器提供的 history API 来操作。HTML 新增了 pushStatereplaceState 两个 API 可以对浏览器历史记录进行新增和替换。以下是 API 列表:

  • history.back(): 返回到上一个网址,相当于你在浏览器顶部栏左侧点击后退按钮
  • history.forward(): 前进到下一个网址,相当于你在浏览器顶部栏左侧点击前进按钮
  • history.go():根据参数前进几个页面,参数是一个整数值,如果为 0 ,就是刷新当前页面
  • history.pushState():用于在浏览器历史中添加一条记录,注意这个方法不会触发页面刷新,只是导致地址栏和历史记录发生变化
  • history.replaceState():修改当前 history 对象的记录,用新的 state 替换它

相对应 hash 模式的 hashchange 事件,history 模式下可以通过监听 popstate 事件来感知 url 的修改,如下代码:

window.addEventListener('popstate', function(e) {
  console.log(e)
})

history 模式的优点在于服务端可以完整的获取链接和参数,对于前端监控也更友好;而缺点则在于需要服务端配置指向同一个 HTML 页面路径

前端路由的应用

现在各大前端框架都有自己的路由解决方案,如 Vue 是 vue-router ,React 有 react-router 。这些库说到底都是对这两种路由解决方案的封装。对于我们来说,更重要的是了解前端路由解决了什么问题,它解决了记住用户访问页面操作的问题,通过无刷新切换内容的方式,带来了更好的用户体验。

聊聊 setState 这个 API

React 是一个 MVVM 框架,倡导数据驱动视图的**。页面数据的变化会引起 UI 重新渲染,并且页面中的数据流是单向的。在 React 里我们想触发 UI 渲染有两种方式:setState 以及 forceUpdate,当然你还可以借助第三方库 redux 中的 API 等。今天就来认识下 setState 这个 API。

基本用法

setState 接收的参数,可以是一个对象,也可以是函数,函数里返回更新对象:

setState(updater[, callback])

如果是对象形式更新,类似这样:

this.setState({
  count: this.state.count + 1
})

如果是函数形式更新,则函数返回值就是待更新的数据:

this.setState((prevsState) => {
  return {count: prevsState.count + 1}
})

setState 是同步还是异步的?

通常情况下,setState 是异步的。从生命周期的角度看,每一次 setState 执行可能就触发了 UI 重新渲染,假如 setState 是同步的,那这样不停地渲染,可能页面就卡死了,因此把 setState 异步化,是为了避免频繁的重复渲染。实际上,每一次调用 setState,React 都会把更新操作放入一个队列,然后合并队列里相同的 state 做最终的状态更新,这也就是 批量更新。也就是说,即使你在代码里,写个 100 次循环的 setState,React 也不会傻傻地一次次渲染,而是把操作都积攒起来,等到合适的时候再合并批量更新。

setState 内部是怎么工作的?

React 源码中 setState 的调用链路:

  • setState
  • enqueueSetState:将新的 state 放进一个状态队列,然后调用 enqueueUpdate 处理传入的待更新实例对象
  • enqueueUpState:有一个 batchingStrategy 对象,判断该对象的 isBatchingUpdates 是否为 false,是则安排组件更新;否则将组件推入 dirtyComponents 里,暂缓更新。
  • isBatchingUpdates ?
    • Yes ,Enqueu dirtyComponents
    • No,取出 dirtyComponents 所有组件,安排更新

当调用 batchUpdates 方法安排更新时,就会把 isBatchingUpdates 这个变量上锁,然后执行完更新后,再恢复回来。这个执行的过程涉及到 事务机制,所谓事务机制,是 React 封装的一个" 盒子",这个盒子把某个方法包装起来,然后提供了统一的 performclose 等 API 来执行。

当批量更新的事务执行时,首先上锁 isBatchingUpdates 为 true,然后循环调用 dirtyComponents 里所有组件,执行 updateComponent 来触发每个组件的生命周期执行,从而实现组件更新。最后解锁 isBatchingUpdates 置为 false。

总结

  • 为了避免组件频繁渲染,setState 更新的过程是异步的,并且会合并批量更新
  • 每次调用 setState 时,React 会先上锁,一旦有锁,所有 setState 操作都会进入队列,不会立即执行更新;React 会在合适的时候放开锁,进而执行渲染数据更新

浅谈 React 逻辑复用的几种方式

平时写 React,一旦发现业务逻辑有重复比较多的地方,我们就需要考虑拆分复用。在 React Hooks 没出现前,复用组件逻辑通常有 高阶组件render props 以及 有状态和无状态 几种方式。

高阶组件

所谓高阶组件(Higher Order Function),就是在函数里接收组件作为参数,然后包装一些自定义逻辑后返回新的组件。比如下面这个:

import utils from './utils'
const withDataProps = (WrappedComponent) => {
  const data = utils.getData()
  return (props) => (
      <WrappedComponent data={data} />
  )
}

可以看到,高阶组件能做的事情就是,接收一个组件,然后添加需要复用的逻辑作为 props 传给组件,最后返回一个新的包装后的组件。比如你现在同时有 A,B,C 三个组件需要用到上面这个 data,那你就可以直接调用 withDataProps 这个高阶组件:

const newComponentA = withDataProps(A)
const newComponentB = withDataProps(B)
const newComponentC = withDataProps(C)

render props

render props 跟高阶组件的区别在于 它是组件里接收 props 函数并作为子组件渲染。它的基本**是在 React 组件里通过调用 props 里某个属性,这个属性必须是个函数,然后函数必须返回一个 React 组件,通过 props 实现组件间的通信。比如下面这样:

import utils from './utils'
const withDataProps = (props) => {
  const data = utils.getData()
  return (
    <React.Fragment>
      {props.renderData({...props, data})}
    </React.Fragment>
  )
}

然后这样使用它:

<withDataProps
  renderData={(props) => {
    const {data} = props
    return <A data={renderData} />
  }}
/>

相比高阶组件,render props 更加灵活,因为它是通过 props 接收逻辑,这意味着组件可以选择性地接收一部分数据。

划分有状态和无状态组件

自定义 hooks

JS 之 bind 的模拟实现

与 call,apply 不同,bind 调用并没有立即执行函数,而是返回一个新的函数,而且在调用 bind 的时候可以传参,调用 bind 返回的函数时也可以传参(真让人头大,更复杂的是 bind 返回的函数还要考虑当做构造函数使用的场景,也就是 new 调用。所以实现 bind 比 call 和 apply 稍微复杂些

实现思路

bind 的基本语法如下:

func.bind(thisArg[, arg1[, arg2[, ...]]])

实现的流程如下:

  • 校验调用方是不是函数
  • 校验传入 this 参数是否为空
  • 构造一个函数
  • 判断是否为 new 调用(通过 instanceof)来决定绑定 this 的值
  • 在构造的函数里执行调用,将初始参数和二次传入参数合并成数组,通过 apply 执行
  • 返回函数

代码

Function.prototype.myBind = function(thisArg) {
  if (typeof this !== 'function') {
    throw new TypeError('it must be invoke by function')
  }

  if (thisArg == undefined) {
    thisArg = window
  } else {
    thisArg = Object(thisArg)
  }
  
  const thisFn = this
  const args = Array.prototype.slice.call(arguments, 1)
  
  const fBound = function() {
    const bindArgs = Array.prototype.slice.call(arguments)
    const isNew = this instanceof fBound
    return thisFn.apply(isNew ? this : thisArg, args.concat(bindArgs))
  }

  // 绑定返回函数的原型
  const fNOP = function() {}
  fNOP.prototype = this.prototype
  fBound.prototype = new fNOP()
  
  return fBound
}

测试一下:

const obj = {
  name: '张三'
}
function foo(age, sex) {
  console.log(this.name)
  console.log(age)
  console.log(sex)
}
foo.myBind(obj, 21)('男')

小程序的用户体系

几个重要术语

  • OAuth2.0 规范
  • 客户端
  • 开发者服务器
  • 微信服务器

理解小程序登录 code, appid,appsecret

理解 OAuth2.0

  • Resource Owner:资源所有者
  • Resource Server:资源服务器
  • Third-party application(第三方应用程序/又称客户端)
  • User Agent(用户代理)
  • Authorization server(认证服务器)
  • HTTP Service(HTTP 服务提供商)
    OAuth 通过颁发临时令牌,授权第三方应用获取某种资源的权限

浏览器之跨域

平时工作中,在与后端同事对接接口时,经常会遇到跨域问题。当请求产生跨域的时候,实际上是被浏览器拦截了,并没有到达服务端,所以遇到跨域别急着找后端大哥说接口出问题了~ 实际上请求还没到服务端呢。那为啥网络请求会跨域呢?这就要从浏览器的同源策略说起。

同源策略

一个 url 大概长这样子: https://www.baidu.com:443,主要由 协议域名 还有 端口号 组成,其中如果是默认端口号,那可以省略不写, http 的默认端口号是 80 ,https 的默认端口号是 443。当两个 url 以上三个信息全部相同时,我们就称这两个 url 是 同源 的。

浏览器的 同源策略 规定只有同一个源下的文档及脚本才有权限读取访问一些信息。比如:

  • cookie 、localStorage 和 IndexDB ,每个源域名下的存储信息是独立的
  • DOM 的获取
  • AJAX 请求的发送

那浏览器为什么要实行这个策略?为了 安全。如果不限制同源请求访问,那相当于一个源下的信息可以被任意的获取,这在信息安全方面毫无疑问是极大的隐患,容易被黑客利用攻击。

跨域的表现

当我们在前端发起一个 Ajax 请求时,如果源请求和目标请求 协议域名 还有 端口号 有任意一个不同,那么这个请求就违反了浏览器的同源策略,于是就产生了跨域。

请求又分为简单请求和复杂请求。简单请求 可以直接发送,它必须符合以下条件:

  • 请求方法是 GETHEAD, POST 其中之一
  • 请求头只包含 Accept Accept-LanguageContent-LanguageContent-Type
  • Content-Type 的值为 text/plainmultipart/form-dataapplication/x-www-form-urlencoded 其中之一

不符合以上条件的请求就是复杂请求,复杂请求在发送之前,浏览器需要先发一个预检请求,请求方法为 OPTIONS,询问服务器是否支持跨域,内容大概是这样:

OPTIONS /resource/foo
Access-Control-Request-Method: GET
Access-Control-Request-Headers: Content-Type, x-requested-with
Origin: https://foo.bar.org

服务器会在响应 header 的 Access-Control-Allow-* 系列字段里包含它支持的源信息,大概是下面这样:

HTTP/1.1 200 OK
Content-Length: 0
Connection: keep-alive
Access-Control-Allow-Origin: https://foo.bar.org
Access-Control-Allow-Methods: POST, GET, OPTIONS, DELETE
Access-Control-Allow-Headers: Content-Type, x-requested-with
Access-Control-Max-Age: 86400

浏览器判断当前请求是否符合,如果不符合,则跨域报错信息如下:

Access to XMLHttpRequest at '×××' from origin '×××' has been blocked by CORS policy: 
No 'Access-Control-Allow-Origin' header is present on the requested resource.

遇到这样的错误提示,建议找后端同学,先检查下服务端有没配置好 Access-Control-Allow-Origin 这个字段,特别注意环境区分,有时特定环境下忘记配了也是常有的事。通常你的本机 ip : http://localhost:8080 要添加进允许源里去。

跨域的解决方案

  • document.domain = '×××' 适用于站点和服务器享有相同的基本域名,比如 a.example.comb.example.com 这时可以设置 document.domain = 'example.com' 来规避跨域
  • CORS 跨域资源共享,有了这个机制,浏览器可以向跨域服务器发出请求,只是要先预检校验。
  • JSONP 利用 script 标签没有跨域限制的特性,通过 src 属性指向要访问的地址并提供一个回调函数来接收数据
// JSONP 的简单实现
<script type="text/javascript">
  var handleData = function (data) {
    console.log(data)
  }
 var url = 'https://example.com?callback=handleData'
 var script = document.createElement('script')
 script.setAttribute('src', url)
 document.getElementsByTagName('head')[0].appendChild(script)
</script>
  • postMessage window.postMessage 这个 API 可以跟另一个页面通信,传递信息,而不会有跨域问题
  • 使用 nginx 反向代理 通常是服务端同学去操作

跨域时 cookie 的携带问题:

  • 必须在初始化 Ajax 请求时,设置 withCredentialstrue
  • 检查下 samesite 属性值,确保不会限制 cookie,值为 none 则可以携带
  • 服务端要设置首部字段 Access-Control-Allow-Credentialstrue

总结

  • 为了保证信息安全,浏览器规定了同源策略
  • 请求违反了同源策略(协议、域名、端口有一个不同),就是跨域
  • 跨域的解决方案有 document.domain、JSONP 、postMessage 和 nginx 反向代理
  • 跨域时注意 cookie 的携带问题

JS 之浅拷贝与深拷贝

日常业务开发中,经常会遇到拷贝数据的情况。在 JS 基本数据类型与引用类型 一文里介绍了 JS 的几种数据类型,对于基本数据类型,直接拷贝栈中存放的值即可;而对于引用类型,栈中存放的只是一个指针,这个指针指向堆内存中真正存放的数据。

对于引用类型的拷贝,分为浅拷贝和深拷贝。所谓浅拷贝,就是指复制栈中的指针,复制后,两个指针指向的是同一块内存地址,因此修改其中一方数据会影响到另外一方;而深拷贝是在内存中开辟一块新区域,它复制的是完整数据,因此两份数据是完全独立,互不影响的。

浅拷贝对象的几种方式

第一种,通过 Object.assign

const obj = {
    name: '张三',
    friends: ['A', 'B', 'C']
}
const copy = Object.assign(obj)
obj.name = '李四'
obj.friends.push('D')

console.log(copy.name)  // 李四
console.log(copy.friends)  // ['A', 'B', 'C', 'D']

第二种,通过 ES6 的扩展运算符 ...

const obj = {
    name: '张三',
    friends: ['A', 'B', 'C']
}
const copy = {...obj}
obj.name = '李四'
obj.friends.push('D')

console.log(copy.name)  // 注意,这里依旧打印 '张三',对基本类型复制的是独立的值
console.log(copy.friends)  // ['A', 'B', 'C', 'D']

深拷贝的方式

第一种(推荐),使用 lodashlicia 等库提供的方法。

// lodash
_.cloneDeep(obj)

// licia
cloneDeep(obj)

第二种,使用 JSON.parse(JSON.stringify())

const obj = {
    name: '张三',
    friends: ['A', 'B', 'C']
}
const copy = JSON.parse(JSON.stringify(obj))
console.log(copy)

第三种,自己实现一个,处理对象、数组、set、map 以及循环引用问题:

const mapTag = '[object Map]'
const setTag = '[object Set]'
const arrayTag = '[object Array]'
const objectTag = '[object Object]'

const deepTag = [mapTag, setTag, arrayTag, objectTag]

function isObject(target) {
  const type = typeof target
  return target !== null && (type === 'object' || type === 'function')
}

function getType(target) {
  return Object.prototype.toString.call(target)
}

function getInstance(target) {
  const Ctor = target.constructor
  return new Ctor()
}

function cloneDeep(target, map = new WeakMap()) {
  // 原始类型直接返回
  if (!isObject(target)) {
    return target
  }

  const type = getType(target)
  let cloneTarget
  if (deepTag.includes(type)) {
    cloneTarget = getInstance(target)
  }

  // 处理循环引用
  if (map.get(target)) {
    return map.get(target)
  }
  map.set(target, cloneTarget)

  // 处理 Set
  if (type === setTag) {
    target.forEach(val => {
      cloneTarget.add(cloneDeep(val, map))
    })
    return cloneTarget
  }

  // 处理 Map
  if (type === mapTag) {
    target.forEach((val, key) => {
      cloneTarget.set(key, cloneDeep(val, map))
    })
    return cloneTarget
  }

  // 处理 Array 和 Object
  for (const key in target) {
    cloneTarget[key] = cloneDeep(target[key], map)
  }

  return cloneTarget
}

export default cloneDeep

总结

  • 浅拷贝对基本数据类型拷贝的是值,对引用类型拷贝的是指针
  • 深拷贝是在堆内存中新开辟一块区域,拷贝出来的数据完全独立,互不影响

聊聊 React Hooks

React Hooks 是 React16.8 之后添加的特性,它是一组「钩子」,为函数式组件增加了不少能力,使得我们不必再写繁琐的类组件,能够用函数组件来精简地表达业务逻辑。那为什么需要 React Hooks 呢?这要从类组件和函数式组件说起。

类组件 VS 函数式组件

所谓类组件,是指如下所示的 React 代码:

class HelloWorld extends React.Component {
  constructor() {
    this.state = {text: 'hello world'}
  }

  componentDidMount() {
    console.log('mounted')
  }

  componentDidUpdate() {
    console.log('update')
  }

  render() {
    return (
      <div>
        <p>{this.state.text}</p>
      </div>
    )
  }
}

类组件是面向对象**下的产物,所谓面向对象的特点,就是 封装继承多态。通常我们将一组属性和方法封装到一个 class 里,然后定义新的 class 去继承,这样就可以在获得原来的功能上继续实现新的功能。当我们编写类组件时,实际上就是继承了 React.Component 这个基类,里面就包含了许多生命周期的方法供我们使用。
所谓函数式组件,在 React Hooks 出现前,是指无状态组件,例如:

function HelloWorld(props) {
  const {text} = props
  return (
    <div>
      <p>{text}</p>
    </div>
  )
}

从代码量对比上看,我们就明显的发现,类组件显得太 "重" 了,相比之下,函数式组件就轻量的多。类组件虽然 “重”,但是它功能齐全,通过 this 访问各种状态和方法,还可以有多个生命周期钩子可以实现业务逻辑,反观函数式组件,它是“无状态的”,数据只能通过 props 由上级组件传入。我们来简单对比下这两种组件的差异:

类组件 函数组件
状态 有状态 无状态
this
生命周期方法
继承 需要 不需要

平时写一个类组件,业务逻辑其实是很分散的,比如你在 componentDidMount 定义了一个计时器 timer ,你就要在 componentUnMount 里做清理 timer 的逻辑,类组件固然功能齐全,但业务逻辑强绑定,难以实现拆分和复用,如果要实现复用,就得动用一些高级技巧,比如 render props高阶组件 等等,这些都增大了开发者的学习成本。所以 React 团队在思考一种新的开发模式,给函数式组件加上状态管理,一段逻辑可以拆分为一个函数,函数之间可以实现复用,这样就把分散的逻辑聚合起来了,形式上也更灵活,于是 React Hooks 出现了。

React Hooks 介绍

React 一直推崇函数式编程的理念,有个经典公式 UI = render(data),渲染函数接收一组数据,然后吐出 UI。这个过程里,函数发挥着重要作用。

React Hooks 是一组 API 钩子,它把类组件提供的一些功能,移植到函数组件里来,让函数组件也可以承担一些逻辑开发。常用的 hooks 有:

  • useState
  • useEffect
  • useCallback
  • ...

这些具体的 API 使用,官方文档说得很清楚,照着文档仔细读一遍,很快就能掌握应用到业务中。我们来看 React Hooks 解决了哪些问题:

  • 繁重的 class 类编写
  • 业务逻辑拆分、复用问题
  • 更方便的状态管理
  • 从设计理念上,函数式编程更符合 React 理念

React Hooks 也不是万能的,任何设计都是权衡的艺术,你说类组件繁重,但它功能齐全;你说函数组件轻量,那就意味着一些复杂逻辑函数组件可能胜任不了。React Hooks 还是有它的局限性:

  • 目前还不能完全覆盖类组件的所有生命周期函数,有一些比如 getSnapshotBeforeUpdate 还是得依靠类组件
  • 如何定义拆分业务逻辑,实现函数复用,对开发者水平是一个挑战
  • hooks 有严格的规则约束,在既定规则下编程。

React Hook 原理

React 官方文档强调,React Hooks 只能在函数组件使用,并且不要在循环,条件或嵌套中使用 Hook。为什么 React Hooks 的执行顺序如此重要?因为它底层使用的数据存储结构是 顺序链表

React Hooks 的调用链路分为 初始渲染更新阶段。初始渲染阶段的流程如下:

  • 定义 useState
  • 通过 resolveDispatcher 获取 dispatcher
  • 调用 dispatcher.useState
  • 调用 mountState
  • 返回目标数组 [state, setState]

这其中,关键步骤在 mountState,这个函数主要是初始化 hooks ,这一步调用了 mountWorkInProgressHook 方法,这个函数新建了一个 hooks 对象,这个对象有个 next 指针,把其它 hooks 对象串联起来了。

以上是初始化阶段,接下来看更新阶段:

  • 从 useState 开始
  • 通过 resolveDispatcher 获取 dispatcher
  • 调用 dispatcher.useState
  • 调用 updateState
  • 调用 updateReducer
  • 返回目标数组 [state, setState]

在更新阶段,updateState 会去依次遍历之前构建好的链表,然后把值更新到链表对应的 hooks 位置。

现在可以来回答为什么不能在 if 语句里调用 hook。假如代码是下面这样:

let flag = false
const [name, setName] = useState('张三')
if (!flag) {
  const [age, setAge] = useState(20)
  flag = true
}
const [sex, setSex] = useState(0)

初始化 flag 为 false,所以有三个 hooks 存在链表里,它们都绑定了各自的变量和 set××× 方法:

name  -  setName
   |
age     -  setAge
   |
 sex     -  setSex

等到二次渲染时,此时 if 已经是 false 了,所以只会有两个 hooks :

name  -  setName
   |
 sex     -  setSex

这个时候不确定性就出现了,假如此时你要更改年龄,调用了 setAge。React Hooks 是按链表顺序查找,它会认为你要修改第二个 hooks ,但此时第二个 hooks 已经由 age 变成 sex 了,所以就会出现更新错位的情况。

总结

  • React Hooks 的出现是为了更好更轻便的复用业务逻辑,抛弃类组件那种偏「重」的书写方式
  • 为此,提供了诸如 useStateuseEffect 的钩子,可以在函数组件里实现类组件诸如生命周期之类的逻辑
  • Hooks 底层依赖 顺序链表,因此在使用上,严格遵守顺序,不能嵌套或条件语句里使用。

JS 之 reduce 的模拟实现

reduce 的语法

基本使用如下:

arr.reduce(callbackFn, initialValue)

其中 callbackFn 接收四个参数,分别是执行结果,当前迭代项,当前索引以及原数组。

reduce 的模拟实现

const orignalReduce = Array.prorotype.reduce
Array.prototype.reduce = orignalReduce || function(cb, initValue) {
  const array = this
  const startIndex = initValue !== undefined ? 0 : 1
  let pre = initValue ?? array[0]
  for (let i = startIndex; i < array.length; i++) {
    const curr = array[i]
    pre = cb(pre, curr, i, array)
  }
  return pre
}

计算机网络之 HTTPS 协议

为什么要有 HTTPS

因为 HTTP 在传输数据的时候,是明文传输的,一旦请求被截获,数据就泄露了。正因为 HTTP 的不安全,于是网景公司在 1994 年搞了一个协议,叫 SSL(Secure Sockets Layer)安全套接层,有 v2 和 v3 两个版本,这个协议可以对传输数据进行加密。后来 IETF 组织在 1999 年把它标准化了,改名为 TLS(Transport Layer Security)传输层安全,版本号从 1.0 重新算。

HTTPS 实现安全的基础就是 TLS 协议,S 是 Secure 的意思,可以这么理解:HTTPS = HTTP + TLS。HTTPS 的特性有:

  • 机密性:对称加密和非对称加密,TLS 握手
  • 完整性:摘要算法 SHA256,保证内容不被篡改
  • 数字签名:身份认证
  • 数字证书和 CA:网站认证

TLS1.2 握手过程

客户端和服务器在传输数据的时候,有两种加密方式:对称加密非对称加密。对称加密就是双方都使用相同的密钥来传输,因而它的效率也比较高,但问题是客户端和服务端两方怎么来约定这个密钥呢?如果在互联网上传输,那密钥被截获了,那相当于白费,黑客可以静静地看着你们传输数据,自己偷偷用截获的密钥来查看数据。所以这时候就需要非对称加密了。非对称加密就是加密和解密使用不同的密钥,称为公钥和私钥。公钥加密的信息,只有私钥才能解密。私钥加密的信息,只有公钥才能解密,公钥是可以公开传播的,但私钥是存储在特定一方的,不会在互联网上传输,所以即使黑客截获了请求,也有公钥,但是黑客没有私钥,无法解密到信息,这样就保证了安全。

前面介绍了对称加密和非对称加密的特点,对称加密传输效率高,但是无法保证密钥的安全;而非对称加密安全性高,但是传输效率低。于是两相权衡下,HTTPS 把这两种算法结合起来:先使用 非对称加密 来传输一个密钥,这个密钥后续用于客户端服务端加解密数据使用,然后正式传输数据时,使用 对称加密 来通信,因此此时双方都有密钥了,且这个密钥是通过非对称加密获得的,保证了安全性。

非对称加密获取密钥的过程,就是 TLS 握手,它的流程如下:

  1. 客户端发送问候消息,包含自己支持的 TLS 版本,密码套件以及一串客户端随机数
  2. 服务器回复问候消息,包含服务器的数字证书,所选择的密码套件以及一串服务端随机数
  3. 客户端验证服务器的证书,这是为了确认服务器的身份。然后使用公钥加密一个随机数,称为 premaster secret(预主密钥),这个密钥只有服务器的私钥能解密
  4. 现在两方都拿到了三个参数(客户端随机数,服务端随机数以及预主密钥),客户端和服务端根据之前约定的算法生成会话密钥,这个密钥就是用来最终传输的
  5. 客户端使用会话密钥加密一条消息「Finished」,发给服务器
  6. 服务器也使用会话密钥加密「Finished」,发给客户端
  7. 握手完成,接下来双方都使用这个密钥来传输数据

这其中有一个步骤,就是服务器发送证书给客户端,然后客户端验证证书的合法性,为什么要这么做呢?是服务器为了向浏览器证明「我就是我」,否则如果网址被黑客劫持,那浏览器并不知道自己访问的其实是黑客的服务器。通常来说,网站需要向 CA 机构提交一系列本网站的信息,然后 CA 机构验证信息后,首先使用 hash 函数计算网站提交的明文信息,得到信息摘要。接着 CA 用自己的私钥加密摘要,得到该证书的数字签名。当客户端接收到证书后,会按证书上的信息使用相同的 hash 函数计算得到信息摘要 A,再使用 CA 机构的公钥解密数字签名,得到摘要 B,然后对比 A 和 B 两个摘要是否相同,从而验证服务器的身份。

接着问题又来了,客户端在验签的时候怎么保证这个证书是真的,而不是别人假冒权威机构颁发的?所以需要有多级权威机构
也就是你要校验某个 CA 机构的真实性,那就看它上一级 CA 机构的公钥,能不能解开它的签名,这样一级级往上校验,直到 root CA 也就是最顶级权威的机构。这个过程可以这样类比:你想知道区公安局有没骗人,你就打电话去问市公安局,再打电话去省公安厅,再打电话去公安部,这样层层往上校验,确保了证书的合法性,也就确认了服务端身份的合法性。

TLS1.3 特性

前面的 TLS 握手流程也不是绝对的,在特定的环境下,TLS 握手流程可以缩短,并不是严格像上面这步骤客户端服务端一来一回。比如在 TLS1.3 版本中,就压缩了握手过程,将握手时间减少到了 1 个 RTT 往返。除此之外,TLS1.3 还改进了密码套件,提升了安全性。

TLS1.3 握手过程:

  1. 客户端首先发送一个包含协议版本、一个随机生成的数字(客户端随机数)和一系列加密方式的列表信息。TLS1.3 取消了一些不够安全的加密方式,所以这个密码套件列表比之前简短很多。这个问候信息还包括一些用于生成重要的安全码(预主密钥)的参数。客户端通常会猜测服务器偏好的密钥交换方式,这次猜测在 TLS1.3 中很可能是正确的。这种方式有效减少了握手的步骤,这也是 TLS1.3 与早期版本(如 TLS1.0、1.1、1.2)的主要区别之一。
  2. 服务器收到客户端的随机数和参数后,结合自己生成的另一个随机数(服务器随机数),就能生成一个会话密钥。
  3. 服务器会回复一个包含证书、数字签名、服务器随机数和选择的加密方式的信息。由于它已经生成了主密钥,所以还会发送一个表示握手流程即将结束的「finished」消息。
  4. 客户端接着验证服务器的签名和证书,生成与服务器相同的会话密钥,并发送自己的「finished」消息。
    至此,握手就完成了。

0-RTT 模式快速恢复会话

TLS 1.3引入了一种更加迅速的握手机制,它消除了客户端与服务器间的任何等待时间。简而言之,如果用户之前访问过某个网站,他们的设备和网站服务器就能够利用上次连接时建立的一种特殊安全码——我们称之为“恢复主密钥”。在首次连接时,服务器还会给客户端一个“会话票证”。下次用户再访问该网站时,他们的设备就可以使用这个安全码和会话票证,立即在发送的第一条信息中与服务器建立一个加密的通信连接,从而实现即时的会话恢复。这样一来,TLS连接就能在客户端与服务器之间迅速重启。

浏览器缓存机制

前言

现代浏览器里,我们打开一个网页,都会发起 http 请求,浏览器请求下载完资源后,会对数据进行缓存,以保证更快的响应速度。浏览器存放缓存有以下几个地方:

  • Service Worker 一个独立线程,可以用做离线缓存
  • Memory Cache 内存缓存
  • Disk Cache 磁盘缓存
  • Push Cache 推送缓存,与 HTTP2 有关

缓存分类

分为 强缓存协商缓存

强缓存

有两个属性:ExpiresCache-Control。前者是 http1.0 的产物,表示资源的过期时间;后者是 http1.1 新增的更全的属性控制,优先级 Cache-Control > Expires。
Cache-Control 有如下属性:

  • max-age: 缓存会在多少秒后过期
  • s-maxage: 与 max-age 的区别在于,它只适用于公共缓存服务器
  • public: 表示该资源可以被任意节点缓存(客户端,代理服务器等等)
  • private: 表示该资源不会被代理服务器缓存
  • no-cache: 客户端可以缓存资源,但每次使用前,必须向服务器确认其有效性
  • no-store: 顾名思义,不进行缓存

协商缓存

相关属性:Last-ModifiedETag
Last-Modified 表示文件的最后修改时间,每次请求时,把它的值作为 If-Modified-Since 发送给服务器,服务器会验证在该时间后资源是否有更新,有就返回新的资源并更新 Last-Modified;没有就返回 304。
ETag 类似文件指纹,每次请求会将 ETag 作为 If-None-Match 发送给服务器,服务器就会验证这个资源对应的 ETag 是否有变动,有就返回新资源;没有就返回 304。
由于 Last-Modified 受限于本地时间,即修改本地时间后可能会影响到缓存的有效性;而 ETag 是对文件内容生成哈希值,就像指纹一一样有唯一标识,因此服务器会优先使用 ETag ,然后才是 Last-Modified 。

启发式缓存

如果请求头啥都没设置怎么办?浏览器默认会采用一种启发式缓存的算法。具体就是用响应头的 Date 减去 Last-Modified 值的 10% 作为缓存时间。

缓存过程

  1. 初次发起请求,服务器响应 200 状态码,下载拿到资源后,对 response 进行缓存;
  2. 当再次加载该资源时,浏览器优先判断强缓存,比较当前时间与上次返回 200 的时间差,是否超过了 cache-control 设置的 max-age。如果没有超过,命中强缓存就不用去发请求了,直接读取缓存结果返回;如果超过了,那就发起请求,这时请求头中会带上 If-None-Match 和 If-Modifed-Since 的标识
  3. 服务器收到了浏览器请求后,开始协商缓存。
    3.1 优先根据 Etag 的值判断文件资源是否被修改,如果没有,则命中协商缓存,返回 304;如果被修改了,那就生成新的 Etag 值,然后返回新的资源文件,状态码是 200
    3.2 如果没有 Etag,那就看 If-Modifed-Since ,跟资源文件的最后修改时间做比对,如果相同,命中协商缓存返回 304;否则更新 last-modifed 并返回新的资源文件,状态码是 200

算法:将列表还原为树状结构

题目

给定一个列表,数据格式如下:

const list = [
  { pid: null, id: 1, data: "1" },
  { pid: 1, id: 2, data: "2-1" },
  { pid: 1, id: 3, data: "2-2" },
  { pid: 2, id: 4, data: "3-1" },
  { pid: 3, id: 5, data: "3-2" },
  { pid: 4, id: 6, data: "4-1" },
]

请你写一个函数,将列表转换成树状结构,转换后的格式如下:

[
  {
    pid: null,
    id: 1,
    data: '1',
    children: [
      {id: 2, pid: 1, data: '2-1'},
      {id: 3, pid: 1, data: '2-2'},
    ]
  }
]

解法

第一种:采用递归来做

function listToTree(list, id = null) {
  return list.reduce((acc, item) => {
    if (item.pid === id) {
      const children = listToTree(list, item.id)
      if (children.length) {
        item.children = children
      }
      return [...acc, item]
    }
    return acc
  }, [])
}

第二种:采用迭代,先用一个哈希表,以 id 为 key 存储对应的项,再申请一个 res 数组,找到根节点 push,同时根据 pid 找到哈希表的键插入 children。因为 JS 引用类型属性是共享的,所以对哈希表的项修改,也会反应在 res 数组中

function listToTree(list) {
    let res = []
    let map = {}
    for (let i = 0; i < list.length; i++) {
        map[list[i].id] = list[i]
        list[i].children = []
    }
    for (let i = 0; i < list.length; i++) {
        if (list[i].pid === null) {
            res.push(list[i])
        } else {
            map[list[i].pid].children.push(list[i])
        }
    }
    return res
}

理解 React 中的 JSX

JSX 是什么

引用 React 官网对 JSX 的定义:

JSX 是 JavaScript 的一种语法扩展,它和模板语言很接近,但是它充分具备 JavaScript 的能力

在 React 里,JSX 大概长这样:

function App(props) {
  return (
    <div>
      <div className="title">我是标题</div>
      <div className="content">我是内容</div>
    </div>
  )
}

上面这段代码,会经过 Babel 编译,然后转换为 React.createElement() 函数调用。所以接下来我们要探究 createElement 这个函数。

React.createElement

React.createElement 函数的源码如下:

/**
 * Create and return a new ReactElement of the given type.
 * See https://reactjs.org/docs/react-api.html#createelement
 */
export function createElement(type, config, children) {
  let propName;

  // Reserved names are extracted
  const props = {};

  let key = null;
  let ref = null;
  let self = null;
  let source = null;

  if (config != null) {
    if (hasValidRef(config)) {
      ref = config.ref;

      if (__DEV__) {
        warnIfStringRefCannotBeAutoConverted(config);
      }
    }
    if (hasValidKey(config)) {
      if (__DEV__) {
        checkKeyStringCoercion(config.key);
      }
      key = '' + config.key;
    }

    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source;
    // Remaining properties are added to a new props object
    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName];
      }
    }
  }

  // Children can be more than one argument, and those are transferred onto
  // the newly allocated props object.
  const childrenLength = arguments.length - 2;
  if (childrenLength === 1) {
    props.children = children;
  } else if (childrenLength > 1) {
    const childArray = Array(childrenLength);
    for (let i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    if (__DEV__) {
      if (Object.freeze) {
        Object.freeze(childArray);
      }
    }
    props.children = childArray;
  }

  // Resolve default props
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }
  if (__DEV__) {
    if (key || ref) {
      const displayName =
        typeof type === 'function'
          ? type.displayName || type.name || 'Unknown'
          : type;
      if (key) {
        defineKeyPropWarningGetter(props, displayName);
      }
      if (ref) {
        defineRefPropWarningGetter(props, displayName);
      }
    }
  }
  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    ReactCurrentOwner.current,
    props,
  );
}

这个方法接收三个入参:

export function createElement(type, config, children)
  • type: 传入的节点类型,比如 div, p 等,也可以是 React 组件
  • config: 配置参数,比如组件的一些 props 等信息
  • children: 嵌套的子组件内容

这个 createElement 函数主要做了如下工作:

  1. 处理 key,ref,source,self 内部参数
  2. 从 config 中提取可以放进 props 的参数
  3. 通过 arguments.length - 2 判断是传入了一个子数组项还是多个,计算得到 props.children
  4. 如果 type 传了 defaultProps,从中提取属性放入到 props 中
  5. 发起 ReactElement 函数调用

ReactElement

接下来要接着追一下 ReactElement 函数,源码如下:

/**
 * Factory method to create a new React element. This no longer adheres to
 * the class pattern, so do not use new to call it. Also, instanceof check
 * will not work. Instead test $$typeof field against Symbol.for('react.element') to check
 * if something is a React Element.
 *
 * @param {*} type
 * @param {*} props
 * @param {*} key
 * @param {string|object} ref
 * @param {*} owner
 * @param {*} self A *temporary* helper to detect places where `this` is
 * different from the `owner` when React.createElement is called, so that we
 * can warn. We want to get rid of owner and replace string `ref`s with arrow
 * functions, and as long as `this` and owner are the same, there will be no
 * change in behavior.
 * @param {*} source An annotation object (added by a transpiler or otherwise)
 * indicating filename, line number, and/or other information.
 * @internal
 */
function ReactElement(type, key, ref, self, source, owner, props) {
  const element = {
    // This tag allows us to uniquely identify this as a React Element
    $$typeof: REACT_ELEMENT_TYPE,

    // Built-in properties that belong on the element
    type: type,
    key: key,
    ref: ref,
    props: props,

    // Record the component responsible for creating this element.
    _owner: owner,
  };

  if (__DEV__) {
    // The validation flag is currently mutative. We put it on
    // an external backing store so that we can freeze the whole object.
    // This can be replaced with a WeakMap once they are implemented in
    // commonly used development environments.
    element._store = {};

    // To make comparing ReactElements easier for testing purposes, we make
    // the validation flag non-enumerable (where possible, which should
    // include every environment we run tests in), so the test framework
    // ignores it.
    Object.defineProperty(element._store, 'validated', {
      configurable: false,
      enumerable: false,
      writable: true,
      value: false,
    });
    // self and source are DEV only properties.
    Object.defineProperty(element, '_self', {
      configurable: false,
      enumerable: false,
      writable: false,
      value: self,
    });
    // Two elements created in two different places should be considered
    // equal for testing purposes and therefore we hide it from enumeration.
    Object.defineProperty(element, '_source', {
      configurable: false,
      enumerable: false,
      writable: false,
      value: source,
    });
    if (Object.freeze) {
      Object.freeze(element.props);
      Object.freeze(element);
    }
  }

  return element;
}

这个 ReactElement 函数看起来非常简短,除去那些 DEV 环境的处理代码,它其实只做了一件事,就是返回下面这个 element 对象:

const element = {
    // This tag allows us to uniquely identify this as a React Element
    $$typeof: REACT_ELEMENT_TYPE,

    // Built-in properties that belong on the element
    type: type,
    key: key,
    ref: ref,
    props: props,

    // Record the component responsible for creating this element.
    _owner: owner,
  };

这就是一个 React 元素节点,到这里来,我们就可以知道 JSX 的真面目了:Babel 把我们写的 JSX 编译为 React.createElement 调用,然后这个函数调用后又返回了一个 ReactElement 元素实例,也就是虚拟 DOM 节点,那这样的虚拟节点是怎么转变为真实 DOM 的呢?这就是 ReactDOM.render 干的活了。

总结

  • JSX 是 JavaScript 的扩展语法,它允许我们使用类 HTML 的语法来编写 UI 模板
  • JSX 会经由 babel 编译为 React.createElement 调用,这个函数二次处理参数,然后返回一个 ReactElement 对象实例
  • ReactElement 对象实例也就是虚拟 DOM 节点,它还要经过 ReactDOM.render 才能变成页面上的 DOM

浏览器的事件机制

浏览器是多线程的,其中渲染线程负责页面 UI 的渲染,生成 HTML 文档页面;而 JS 线程负责执行逻辑代码,那 JS 与 HTML 是怎么交互的?假设我们有一个 html 页面,里面有一个按钮:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <button>点击我</button>
</body>
</html>

现在这个页面是静态的,我们如何实现点击按钮的时候,弹出一个提示文字呢,于是我们想到,给这个按钮绑定一个事件:

<button id="btn" onClick="alert('hello world')">点击我</button>
// 或者用 JS
<script type="text/javascript">
    const btn = document.getElementById('btn')
    btn.addEventListener('click', function() {
      alert('hello world')
    })
</script>

这就是 HTML 与 JS 的交互方式:通过 事件。那现在问题又来了,我们上面只是一个简单的按钮,如果是一个 div 容器,里面包含了多个元素,有列表,有图片还有其它等等,那这时候事件是怎么传播的呢?答案是事件流。

事件流

事件流规定了页面接收事件的一个顺序,针对这个顺序问题,IE 开发团队和 Netscape 团队提出了完全相反的方案:IE 团队支持事件冒泡流,而 Netscape 团队支持事件捕获流。

  • 事件冒泡:当点击一个元素的时候,先从这个元素开始触发事件,然后沿着 DOM 树一层层往上,每经过一个节点依次触发事件,直到最上的 document 对象。它的特点是自底向上。
  • 事件捕获:与事件冒泡相反,当点击事件触发时,首先被 document 捕获,然后沿着 DOM 树依次向下传播,直至到达目标元素,也就是你实际点击的那个元素。它的特点是自顶向下。

DOM 事件流

上面这两家团队各自提出了对文档中事件传播的解决方案,后来 W3C 谁也不得罪,把这两者结合起来了,DOM Level 3 规定了事件传播的三个阶段:事件捕获、目标阶段和事件冒泡。具体过程如下:

  • 点击事件触发时,首先从 document 文档捕获,然后沿着 DOM 树将事件一层层往下传播
  • 这时到达目标元素,在目标元素这触发
  • 接着沿着目标元素往上冒泡,最后回到 document

image

这个过程有点像递归,几乎所有主流浏览器都支持这种 DOM 事件机制。那上面的这个事件传播,有没办法阻止它呢?答案是通过 e.stopPropagation()e.stopImmediatePropagation() 其中 e 是事件绑定回调的 event 参数,这两个方法的区别在于前者只会阻止冒泡和捕获,而后面这个方法还会额外阻止这个元素的其它事件发生,后者清除的更彻底。
另外,还有个 e.preventDefault() 这个方法是为了阻止默认事件的发生,比如你有一个 <a> 标签,默认自带超链接功能,你想阻止掉默认跳转,就可以使用这个方法来实现。

绑定处理事件

了解了事件机制后,接下来就是根据需要绑定事件,来达到我们的效果了。通常绑定事件有以下几种方式:

  • 在 html 标签里直接绑定,比如上面的代码里 <button onClick=""></button>
  • 通过 JS 绑定,比如上面代码里的 document.getElementById('btn').addEventListener('click', fn)
  • 通过全局 document 对象,如 document.addEventListener('click', fn)

addEventListener 方法接收三个参数:

addEventListener(type, listener)
addEventListener(type, listener, options)
addEventListener(type, listener, useCapture)

第一第二个参数就是注册的事件以及对应的处理函数,主要是第三个参数,它可以是个 object,也可以是个布尔值。如果它是 object,它的可选值如下:

{
  capture: false,  // 是否使用捕获模式,默认为 false
  once: false,  // 是否只执行一次,默认为 false
  passive: false,  // 是否阻止 preventDefault 默认行为,默认为 false
  signal: 指定 absort 方法来移除监听器
}

如果第三个参数传的是布尔值,则代表 useCapture,是否使用捕获模式

事件委托

日常工作中,我们应用 DOM 事件流最多的就是事件委托了,它的基本原理就是在父元素绑定处理事件,然后利用冒泡机制来对子元素进行事件处理工作。
比如有个列表:

<ul id="ul">
  <li>item 1</li>
  <li>item 2</li>
  <li>item 3</li>
</ul>

现在我们想对每个 li 元素绑定点击事件,跳转特定的详情页。我们当然不能一个个的在 li 标签上手写事件绑定,正确的做法是在父元素 ul 标签绑定一个事件处理程序,如下:

const ul = document.getElementById('ul')
ul.addEventListener('click', function(event) {
  if (event.target.tagName.toLowerCase() === 'li') {
    // 执行跳转操作
  }
})

这里还要提到 event 的两个属性,分别是 targetcurrentTarget。那它们之间有什么区别呢?我们来浏览器里实验一下

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <ul id="ul">
    <li>item1</li>
    <li>item2</li>
    <li>item3</li>
  </ul>

  <script type="text/javascript">
    const ul = document.getElementById('ul')
    ul.addEventListener('click', function(event) {
      if (event.target.tagName.toLowerCase() === 'li') {
        console.log('event target', event.target)
        console.log('event current target', event.currentTarget)
      }
    })
  </script>
</body>
</html>

在浏览器里打开这个 html 文件,按住 F12 打开 console,然后随便点击一个 li 标签,此时控制台打印如下:
image

可以看到,当我们点击 item1 这个 li 元素时,event.target 指的就是你当前点击的元素;而 event.currentTarget 指的是绑定事件处理程序的目标元素,也就是 ul 标签。

总结

  • 浏览器通过事件来支持 HTML 与 JS 交互
  • 事件流规定页面接收事件的顺序,有 事件冒泡事件捕获 两种
  • 对于 DOM 事件,分为三个阶段:捕获阶段目标阶段冒泡阶段
  • 了解事件委托的原理,以及 event.targetevent.currentTarget 的区别

浅谈 CSS 中的 BFC

BFC 是块级格式上下文的意思,具有 BFC 特性的 HTML 元素,就像拥有 "结界" 一样,内部元素再怎么折腾都不会影响外面的元素。

BFC 的触发条件

  • html根元素
  • float 的值不为 none
  • position 的值不为 staticrelative
  • overflow 的值为 autoscrollhidden
  • display 的值为 inline-blocktable-celltable-caption

BFC 的应用

  • 阻止 margin 重叠
  • 清除浮动,因为浮动元素也是 BFC,两个 BFC 互相独立,所以能修复浮动造成的高度塌陷
  • 实现自适应两栏布局(左浮动 + 右 BFC)

typescript 中 type 和 interface 的区别

区别

  1. 它们都可以用来描述对象的形状
type Point = {
  x: number;
  y: number;
}
interface Point {
  x: number;
  y: number;
}
  1. interface 会进行声明合并,而 type 不行
interface Point {
  x: number;
  y: number;
}

interface Point {
  z: number;
}
type Point = {
  x: number;
  y: number;
}

type Point = {
  z: number;
}
// 编译失败,报错 oops
  1. type 可以定义 primitive type、tuple 、function type 、 union type 等类型;而 interface 则用于定义 object types
  2. type 通过 & 来合并多个属性,而 interface 通过 extends 来继承属性

使用场景

  • 如果 type 和 interface 都可以,优先使用 interface 定义
  • 如果是一些大型项目,使用 interface 定义 API,方便继承
  • 组件的 props 属性使用 type 定义

JS 函数柯里化

概念

柯里化就是将一个多参数的函数转换成单参数调用。比如有个函数 f(a, b, c) ,通过柯里化后变成 f(a)(b)(c)

柯里化可以做什么

柯里化可以方便的在现有函数基础上扩展出新功能函数,比如有个日志记录函数 log(now, type, message),它接收三个参数,第一个参数 now 代表日志的打印时间,第二个参数 type 代表日志的级别,第三个参数代表日志内容。现在我们这样使用它:

log(new Date(), 'error', '这是日志信息')

每次我们调用 log 函数,都需要传入三个完整参数,而我们在写业务逻辑时,大部分时候日志信息都是相似的,比如同一段逻辑代码里的日志时间是一样的,这时我们就可以通过柯里化,实现一个自带当前时间记录的 logNow 函数:

import _ from 'lodash'
const curryLog = _.curry(log)
const logNow = curryLog(new Date())

// 记录日志,时间自动就是当前时间
logNow('error', '这是错误日志')

可以看到,柯里化使得我们可以生成函数的部分调用。柯里化后的函数不会丢失任何东西,只是在调用方式上更灵活了。

柯里化的实现

以下是简单的柯里化实现,更完整的实现可以参考 lodash 源码中的 curry 函数:

function curry(func) {
  return function curried(...args) {
    if (args.length >= func.length) {
      return func.apply(this, args)
    } else {
      return function (...args2) {
        return curried.apply(this, args.concat(args2))
      }
    }
  }
} 

进程和线程

进程 是操作系统分配资源的最小单位,而 线程 是 CPU 调度的最小单位。通俗的理解,一个应用程序执行的时候,就是一个进程,而这个应用程序里有各种各样的调度,这些调度是通过线程来完成的。例如,当你在电脑里打开 Chrome 浏览器,操作系统就开启了一个进程来运行;这时你又打开了微信客户端,系统就又开启一个进程。我们在几个应用程序来回切换,操作系统通过调度 CPU 在进程间快速的切换,来响应我们的操作。

进程与线程的关系

知乎上有个很形象的举例:

进程就是火车,线程就是火车里一节一节车厢

  • 一个进程里可以有多个线程,而一个线程只能属于一个进程
  • 不同进程之间数据无法共享,有专门的进程间通信机制;而同一个进程下的线程可以共享资源,因此有个互斥锁的概念:当一个线程访问某块资源时,上锁,其它线程不能访问
  • 进程比线程消耗更多的计算机资源,线程则是轻量级的
  • 进程使用的内存地址是有限定量的 —— 信号量

计算机网络之 DNS 解析过程

DNS(Domain Name System)域名系统,可以把它比作是电话簿,里面存储的是网络 ip 地址所对应的域名。在网络世界里,计算机识别谁是谁,靠的是 ip 地址,诸如 192.168.31.1(IPv4),如果是 IPv6,则类似 2400:cb00:2048:1::c629:d7a2 ,这些 ip 地址组成复杂,不容易记住。于是就有了域名系统,我们作为用户,访问一个网站的时候,不必去记住一串 ip 地址,而是自己定义一串字母字符,我们在浏览器输入这串字符时,浏览器会使用 DNS 查询,去找到这个域名所对应的实际 ip 地址,然后根据这个地址进行一系列查找资源操作。

DNS 查询过程

下面以 www.google.com 为例,说下 DNS 的解析过程:

  1. 用户在浏览器地址栏输入 www.google.com
  2. 浏览器先查询本地 hosts 文件,看是否有域名对应的 ip 地址映射,有就直接使用该 Ip 地址
  3. 如果没有,接着查询本机电脑 DNS 缓存,如果有缓存直接使用
  4. 如果没有缓存,浏览器向本地 DNS 服务器发送一个 DNS 查询请求,询问 www.google.com 域名的 ip 地址
  5. 如果本地 DNS 服务器缓存有 IP 地址,就直接返回;否则,进入下一步
  6. 本地 DNS 服务器向根 DNS 服务器发送请求,询问 .com 顶级域的 IP 地址
  7. 根 DNS 服务器返回 .com 域名的 ip 地址
  8. 本地 DNS 服务器向 .com 域名服务器发送请求,询问 google.com 名称服务器的 ip 地址
  9. .com 域名服务器返回 google.com 域名服务器的 ip 地址
  10. 本地 DNS 服务器向 google.com 域名服务器发送请求,询问 www.google.com 的 ip 地址
  11. google.com 域名服务器返回 www.google.com 的 A 记录
  12. 本地 DNS 服务器将 A 记录里的 ip 地址缓存起来,并返回给浏览器

整个解析过程就是本地 DNS 服务器 —— 根域名服务器 —— 顶级域名服务器 —— 名称服务器 —— A 记录,这个过程是递归查询的过程。

JavaScript 中的 this

为什么要有 this

在 JS 里,执行上下文包含了全局执行上下文,函数执行上下文和 eval 执行上下文。每个执行上下文里都包含了变量环境、词法环境还有 this。这就是说,this 对应的有全局上下文 this (window),函数中的 this (分多种情况) 还有 eval 里的 this。正是因为执行上下文有多个,所以才需要有一个机制来准确地指向当前代码运行时的上下文,这就是 this。

全局上下文的 this

也就是 window,比如下面这段代码:

function foo() {
  console.log(this)
}
foo()

执行 foo 函数后打印的 this 就是全局 window 对象。

函数中的 this

this 取决于函数是在哪里调用的,看下面这段代码:

var name = '李四'
var bar = {
    name: '张三',
    getName: function() {
      console.log(this.name)
    }
}
var foo = bar.getName
foo()   // 全局环境下调用,this 指向 window,打印李四
bar.getName()  // 通过对象的方法调用,this 指向该对象,打印张三

除此之外,还有构造函数中的 this:

function foo(name) {
  this.name = name
}
var f = new foo('张三')
f.name  // 打印张三

在构造函数里,this 指向对应构造函数的实例

显式指定 this 的指向

通过 callapplybind 方法可以改变函数的 this 指向,注意,如果传入了 null 或 undefined,则值被忽略,this 依然指向全局对象 window

怎么判断分析 this 的指向

优先级:new 构造函数调用 > call,apply, bind > bar.foo() 对象方法调用 > 默认情况下 window

this 的缺陷

嵌套函数中的 this 不会从外层函数继承

比如我们在函数 A 中嵌套了函数 B,对于普通函数来说,B 的 this 是跟 A 不同的,所以经常会在代码里看到 const self = this 来存储当前环境的 this,所幸的是,ES6 的 箭头函数 解决了这个问题。

默认情况下,this 指向全局 window 对象

这会带来很多不可控的情况,毕竟谁也不想不知不觉就修改了全局对象里的属性或方法(doge。解决方法是开启严格模式,在严格模式下,this 指向 undefined

总结

在 JS 里,this 的指向有以下几种情况:

  • 全局环境下执行,指向 window 对象
  • 通过对象方法调用,指向该对象
  • 通过构造函数调用,指向所在实例
  • 默认情况下,非严格模式指向 window,严格模式下指向 undefined
  • 一个特例,箭头函数没有自己的 this,它的 this 取决于函数定义时的上下文

计算机网络之 OSI 七层模型,TCP/IP 五层模型

OSI 七层模型

OSI 七层网络模型,在 1983 年由当时主要的计算机和电信公司的代表提出,并在 1984 年被 ISO 组织采纳为国际标准。这个模型把网络协议分为七层,分别是:

  • 第七层:应用层,为应用程序提供网络服务,主要协议有:HTTP、FTP、DNS
  • 第六层:表示层,数据的加密解密
  • 第五层:会话层,建立,维护和管理会话连接
  • 第四层:传输层,建立,维护和管理端到端连接,主要协议是 TCP 和 UDP
  • 第三层:网络层,IP 寻址和路由选择,主要协议有 IP、ARP、ICMP、IGMP
  • 第二层:数据链路层,在网络层和物理层之间通信
  • 第一层:物理层,通过物理媒介(如光纤)连接组网,传输比特流

image

七层模型是比较久远的了,目前现代网络使用更多的是 TCP/IP 模型

TCP/IP 五层模型

相比 OSI 七层模型,TCP/IP 模型将 应用层表示层会话层 合并为 应用层。所以简化后如下:

  • 第五层:应用层
  • 第四层:传输层
  • 第三层:网络层
  • 第二层:数据链路层
  • 第一层:物理层

还有所谓的 TCP/IP 四层模型,其实就是在五层的基础上,把 数据链路层物理层 合并为 数据访问层
image

JS 中的模块化发展历程

JS 最初是作为一门脚本语言来设计,它主要用于浏览器里与 DOM 做交互。作者也没想到经过二十几年的发展,JS 已经被用于构建大型应用程序了,而大型项目所要解决的一个重要问题就是 模块化 问题,显然,因为 JS 设计的原因,在模块化这方面一直是一个缺陷,针对这个问题,就涌现出了不同的模块化解决方案。目前以及将来,主流的解决方案应该是 ES6 module ,但是了解模块化的发展历程有助于我们更好的理解模块化带来的革命性作用。

早期阶段

在远古时期,也就是 JS 刚发明出来那几年,通常就是一个 js 文件对应一个模块,全部通过 <script> 标签引入使用。
这种模式的缺点:

  • 命名冲突
  • 模块直接暴露在全局,污染了全局作用域

使用命名空间

大概在 2002 年,出现了 命名空间对象 的解决方案。通过暴露一个全局对象,然后将模块的属性和方法挂载到这个对象作为属性访问。举个例子:

// file app.js
var app = {}

// file hello.js
app.hello = function() {
  console.log('hello')
}

// file greeting.js
app.hello()

缺点:

  • 因为是使用的全局对象,没有解决模块数据和代码分离的问题

IIFE 立即执行函数

大概 2003 年,又出了 立即执行函数 的方案。具体就是利用 闭包 的特性,将模块里的属性和方法都放在一个立即执行函数里,形成私有作用域。举个例子:

var greeting = (function() {
  var module = {}
  
  module.hello = function() {
    console.log('lhelo')
  }

  return module
}())

优点:

  • 解决了污染作用域问题
  • 解决了命名冲突问题
  • 解决了代码的组织问题

缺点:

  • 没有解决模块加载问题

沙盒模式

出现于 2009 年,这种模式的特点是:通过定义一个函数 A,然后往 A 上挂载属性和方法,最后通过 callback 回调把 this 参数传出去。外部在使用模块时,通过 new 实例化,从而访问到 this 里挂载的属性和方法。举个例子:

// file sandbox.js
function Sandbox(callback) {
    var modules = [];

    for (var i in Sandbox.modules) {
        modules.push(i);
    }

    for (var i = 0; i < modules.length; i++) {
        this[modules[i]] = Sandbox.modules[modules[i]]();
    }
    
    callback(this);
}

// file greeting.js
Sandbox.modules = Sandbox.modules || {};

Sandbox.modules.greeting = function () {
    var helloInLang = {
        en: 'Hello world!',
        es: '¡Hola mundo!',
        ru: 'Привет мир!'
    };

    return {
        sayHello: function (lang) {
            return helloInLang[lang];
        }
    };
};

// file app.js
new Sandbox(function(box) {
    document.write(box.greeting.sayHello('es'));
});

这种模式只是在某些库里用到,因此了解即可。

依赖注入模式 DI (2009 年)

Angular 2 里实现模块化就用到了依赖注入。以下是示例代码:

// file greeting.js
angular.module('greeter', [])
    .value('greeting', {
        helloInLang: {
            en: 'Hello world!',
            es: '¡Hola mundo!',
            ru: 'Привет мир!'
        },

        sayHello: function(lang) {
            return this.helloInLang[lang];
        }
    });

// file app.js
angular.module('app', ['greeter'])
    .controller('GreetingController', ['$scope', 'greeting', function($scope, greeting) {
        $scope.phrase = greeting.sayHello('en');
    }]);

CommonJS Modules (2009 年)

在 09 年的时候,由于服务端缺乏统一的 API 来与操作系统做交互,Mozilla 的一位职员因此发起了一个项目,成立了一个委员会来讨论和开发适用于服务端的 JavaScript API,取名叫 ServerJS,半年后改名为 CommonJS。后来随着越来越多的成员加入开发做贡献,CommonJS 发展迅速,最终成为 Node.js 的模块化方案。以下是 CommonJS 的例子:

// file greeting.js
var sayHello = function() {
  return 'hello world'
}

module.exports = {
  sayHello: sayHello,
}

// file hello.js
var {hello} = require('./greeting')
console.log(hello())

通过 require 来导入模块,通过 exports 对象来导出模块。另外,模块自身也有一个 module 对象,exports 就是 module 的一个属性。也许你会疑惑,平时我们写 node 代码时,可以直接使用 export,module,__dirname,__filename 这些变量,这是因为代码最终会被编译在一个这样子的函数中:

(function (exports, require, module, __filename, __dirname) {
    // ...
    // 编写的模块代码
    // ...
});

CommonJS 的特点:

  • 模块查找过程需要经过路径分析,文件定位,编译执行
  • 同步加载模块,加载完成后才能执行后面操作
  • 对引入过的模块会进行缓存

AMD ( Asynchronous Module Definition)2009 年

由 Mozilla 的一位工程师在 2009 年搞出来,是浏览器端异步加载模块的解决方案。

  • 代表库:Require.js
  • 每个模块通过 define 函数定义,接收两个函数,第一个参数是依赖数组,第二个参数是函数,函数参数与前面的依赖对应。
  • 通过函数 return 的方式向外部导出成员,也支持使用 CommonJS 语法来导入导出成员
  • 使用 require 导入一个模块,参数与 define 相同
  • 同期还出现了 CMD 标准,它与 AMD 的区别是 AMD 推崇依赖前置,而 CMD 推崇依赖就近;AMD 是提前执行依赖模块,而 CMD 是延迟执行依赖模块。CMD 的代表实现是 Sea.js,后来也被 Require.js 兼容了

示例代码如下:

// file lib/greeting.js
define(function() {
    var helloInLang = {
        en: 'Hello world!',
        es: '¡Hola mundo!',
        ru: 'Привет мир!'
    };

    return {
        sayHello: function (lang) {
            return helloInLang[lang];
        }
    };
});

// file hello.js
define(['./lib/greeting'], function(greeting) {
    var phrase = greeting.sayHello('en');
    document.write(phrase);
});

优点:

  • 异步
  • 依赖辨别清晰
  • 避免全局污染
  • 可以懒加载

缺点:

  • 使用起来比较复杂
  • 项目一大的时候,会出现同一个页面对 JS 请求次数过多

UMD(Universal Module Definition) 2011 年

AMD 模块格式适用于浏览器端,而 CommonJS 模块格式适用于服务端的 Node.js。这两种格式互不兼容,在一些使用了 CJS 模块格式的项目,加载器是无法识别 AMD 模块语法的;反之亦然。因此前端的黑客们就想有没一种标准来统一这两者呢?于是 UMD 出现了,它允许在 AMD 工具和 CommonJS 里环境里使用相同的模块,也就是一种写法,两个环境都适用。示例代码如下:

(function(define) {
    define(function () {
        var helloInLang = {
            en: 'Hello world!',
            es: '¡Hola mundo!',
            ru: 'Привет мир!'
        };

        return {
            sayHello: function (lang) {
                return helloInLang[lang];
            }
        };
    });
}(
    typeof module === 'object' && module.exports && typeof define !== 'function' ?
    function (factory) { module.exports = factory(); } :
    define
));

优点:跨平台
缺点:AMD 和 CJS 该有的缺点都有
很多流行的库比如 momentlodash 都支持打包成 UMD 的格式。

ES2015 Modules (2015 年)

模块化的终极解决方案,直接从语言层面制定的模块化标准 ESM。它具有如下特性:

  • 通过 export 关键字导出,import 关键字导入
  • 模块静态化分析,编译时输出接口

与 CommonJS 不同,ES6 模块输出的是值的引用,而 CommonJS 输出的是值的拷贝;ES6 模块中顶层的 this 指向undefined;CommonJS 模块的顶层 this 指向当前模块。
示例代码:

// file lib/greeting.js
const helloInLang = {
    en: 'Hello world!',
    es: '¡Hola mundo!',
    ru: 'Привет мир!'
}

export const greeting = {
    sayHello: function (lang) {
        return helloInLang[lang]
    }
}

// file hello.js
import { greeting } from "./lib/greeting"
const phrase = greeting.sayHello("en")
document.write(phrase)

模块化打包工具

模块化解决了代码的组织问题,但是对大型项目来说,如何管理加载模块,也是一个问题,我们一般会借助模块打包工具来做这件事。打包工具所解决的问题是:

  • 环境兼容,可以将代码输出成各种模块格式
  • 模块化划分出来的文件过多,这意味着每次要发起大量网络请求,影响效率,打包工具将其打包成一个入口文件
  • 除了 JS 代码需要模块化,打包工具还支持将 HTML 和 CSS 这些资源也模块化管理

这其中的典型代表就是 Webpack,它是 JavaScript 及其周边的打包工具。

JS 执行上下文

执行上下文是什么

我们所写的代码,JS 并不是一句一句执行的,而是一段一段进行分析执行。JS 引擎首先会编译代码,然后创建这段代码的执行上下文信息,将它推到一个调用栈里,就是我们所说的执行上下文栈。而执行上下文分为三种:全局执行上下文函数上下文 还有 eval 上下文

以 ES2018 版本为例,执行上下文包含以下信息:

  • lexical environment:词法环境,获取变量或 this 使用
  • variable environment:变量环境,声明变量时使用,有一个 outer 指针指向外部的执行上下文
  • code evaluation state:用于恢复代码执行位置。
  • Function:执行的任务是函数时使用,表示正在被执行的函数。
  • ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
  • Realm:使用的基础库和内置对象实例。
  • Generator:仅生成器上下文有这个属性,表示当前生成器。

这里面值得一提的就是变量环境和词法环境,我们平时通过 var 声明的一些变量标识符就会存放于变量环境里,而 ES6 引入了块级作用域后,对应 let const 声明的变量,则是存放于词法环境里。通常,函数内部查找一个变量时,首先会从当前执行上下文的词法环境栈顶里开始找,找不到再去变量环境里找,再找不到就继续往上一层的上下文变量对象里去找,这就是我们所说的作用域链的查找。

下面通过一段代码,来分析下执行上下文的创建过程:

var a = 1
function foo() {
  let b = 2
  console.log(b)
}
var c = 3
foo()

JS 引擎编译执行这段代码时,首先把这段代码当做一个整体,创建一个全局上下文,初始化变量对象,变量对象包含的信息如下:

变量环境:a = 1,foo = function() {},c = 3
词法环境:无

然后这个全局上下文就被推入到调用栈,接着执行后,遇到函数 foo,于是又创建函数 foo 的执行上下文,初始化函数的活动对象,里面包含的信息如下:

变量环境:无
词法环境:b = 2

接着将 foo 函数执行上下文推入到调用栈,然后执行 console.log(b) 语句,发现 b 在函数 foo 的执行上下文里词法环境就能找到,于是打印 2。执行完毕后 foo 函数执行上下文出栈,然后是全局执行上下文出栈,这段代码就执行完毕了。

总结

  • JS 引擎在执行一段代码时,需要编译生成信息,这些信息就是执行上下文
  • JS 代码有多种声明方式,比如全局代码,函数代码,所以相对应的有全局上下文,函数上下文,多个执行上下文通过一个 的数据结构来管理
  • JS 执行代码的过程就是创建上下文 —— 推入执行上下文栈 —— 执行 —— 出栈 —— 清空调用栈,结束

浏览器事件循环

背景

JS 是单线程的,但浏览器却不是,浏览器是多线程的结构。里面包含 UI 渲染线程、JS 线程、网络线程等等。而对 JS 来说,因为是单线程,这意味着同一时间只能执行一个任务,其它的任务要被阻塞排队。当 JS 线程在执行的时候,UI 渲染线程是被阻塞住的,这很好理解,因为 JS 可以修改 DOM,所以必须确保 JS 执行完后才来进行更新渲染。那么这样带来的问题是,如果一个任务执行时间过长,不仅其它任务在后面排队等待,而且会导致页面无法响应用户其它点击事件,从而造成页面卡死的状态。比如经常在浏览器里会见到一个提示说「当前页面无响应」然后让你选择重新加载还是关闭掉。为了解决这个问题,浏览器需要有一个机制,来充分利用各种资源调度执行好这些 JS 任务,这就是事件循环

事件循环的过程

来看一张经典图:
image

上面这张图里有三个信息:

  • JS 内存堆和调用栈,调用栈是先进后出的结构
  • 宿主环境(浏览器)提供的 API:setTimeout、DOM、AJAX ...
  • 事件循环回调队列

在 JS 里,任务分为两种:

  • 同步任务:在主线程上执行,只有前面的任务执行完,后面的才能接着执行
  • 异步任务:执行后先挂起,然后等待回调进入任务队列,任务队列再通知主线程来执行
    我们平时写的每一个 JS 程序,JS 引擎都会分析,按照代码块逐步执行,比如下面这段代码:
console.log('hello')
setTimeout(function() {
  console.log('timeout')
}, 0)
console.log('world')

它的执行过程如下:

  • JS 调用栈初始为空,遇到 console.log('hello'),将它推入调用栈
  • 执行 console.log('hello'),控制台打印 hello,执行完毕,将它出栈
  • 接着碰到 setTimeout(function() {}, 0),将它推入调用栈
  • 开始执行 setTimeout ,发现它是个定时器,于是往 Web API 里添加 timer 计时任务
  • setTimeout 执行完毕,出栈
  • 接着碰到 console.log('world'),将它推入调用栈
  • 执行 console.log('world'),控制台打印 world。执行完毕,出栈
  • 这个时候调用栈为空了,前面不是有个 setTimeout 在计时吗,当它计时完毕后,就会往宏任务队列里添加一个 cb 回调
  • 事件循环发现宏任务队列里还有个 cb 回调,于是将它取出,推入调用栈
  • 开始执行 cb 回调代码,发现里面有一句 console.log('timeout'),于是将它推入调用栈
  • 开始执行 console.log('timeout'),控制台打印 timeout。执行完毕,这句语句出栈
  • cb 执行完毕,出栈
    以上就是事件循环的一个简单过程。

宏任务和微任务

事件循环的过程中,根据任务的特点会将任务放入两个队列,分别是 宏任务微任务
宏任务代码主要有:

  • setTimeout
  • 用户交互事件(鼠标点击,滚动页面)
  • script 块代码

微任务主要有:

  • promise
  • MutationObserver

为什么会出现微任务?与宏任务的关系是什么?
答:一个任务如果是同步执行会影响效率,如果是异步执行,又影响实时性。微任务就是在效率和实时性取得一个平衡。我们把消息队列中的任务称为宏任务,而每个宏任务又有自己的微任务队列,用来存放执行过程中产生的新任务。

主线程最开始会先取出一个宏任务执行,在这个过程中不断往调用栈添加新的代码执行,当执行完一个宏任务后,引擎会去查看微任务队列,看是否有任务需要执行,没有就继续取出宏任务执行,开启下一个事件循环。

事件循环的应用

前面说到,如果一个任务执行时间过长,后面的任务就必须挂起等待。现在有了这个事件循环机制后,我们就可以针对性地对代码做一些优化。具体的优化方式就是通过 setTimeout将大的任务拆分为多个小任务 来避免一个任务耗时过长。

一道经典的执行顺序考察题

function promise1() {
  return new Promise((resolve) => {
    console.log('promise1 start');
    resolve();
  })
}
function promise2() {
  return new Promise((resolve) => {
    console.log('promise2 start');
    resolve();
  })
}
function promise3() {
  return new Promise((resolve) => {
    console.log('promise3 start');
    resolve();
  })
}
function promise4() {
  return new Promise((resolve) => {
    console.log('promise4 start');
    resolve();
  }).then(() => {
    console.log('promise4 end');
  })
}
async function asyncFun() {
  console.log('async1 start');
  await promise2();
  console.log('async1 inner');
  await promise3();
  console.log('async1 end');
}
setTimeout(() => {
  console.log('setTimeout start');
  promise1();
  console.log('setTimeout end');
}, 0);
asyncFun();
promise4();
console.log('script end');

上面这段代码在控制台里会输出什么?可以自己试着分析下。

原型与原型链

JS 最初是作为一门脚本语言来设计,主要就是在浏览器里与 DOM 做交互。因此它并不像 Java,C++ 那种面向对象编程语言一样,有完整的类继承机制。在 JS 里,一个对象继承另一个对象的属性和方法,是通过 原型链委托 的方式来实现。理解原型链,要搞清楚 构造函数prototype__proto__ 这几个概念。

构造函数、prototype 和 proto

JS 里一切皆对象,每个实例对象都有一个私有属性,叫 __proto__,这个属性指向它构造函数的原型对象。所谓构造函数,是指能被 new 操作符调用的函数,比如箭头函数就不能被 new 调用;所谓原型对象,是指每个函数都有一个 prototype 属性,它是个对象,存储一些属性和方法,这些属性和方法能够在构造函数的实例间 共享。举个例子:

function Foo(name) {
  this.name = name
}

Foo.prototype.getName = function() {
  console.log('getName: ', this.name)
}

const foo = new Foo('张三')
foo.getName()

console.log(foo.__proto__ === Foo.prototype)  // true
console.log(Foo.prototype.constructor === Foo) // true

上面这段代码,Foo 就是 构造函数Foo.prototype 就是原型对象,而 foo实例对象,这个实例对象可以访问 Foo 原型对象上的属性和方法,它是怎么访问到的?这是因为 foo.__proto__ 链接到了 Foo.prototype 上,所以在访问 foo.×× 时,首先会查找这个实例对象自身的属性,如果找不到,就去它的原型对象也就是 Foo.prototype 上去找。现在实例对象可以通过 __proto__ 找到原型了,那如果有多个实例呢?怎么知道实例是由哪个构造函数创建的?这就要引出 constructor 属性。每个原型对象例如 Foo.prototype 里有一个 constructor 属性,用于记录实例由谁创建而来,在这里指向的是 Foo 构造函数。

原型链

前面的代码里,foo 是 Foo 构造函数的一个实例对象,那 Foo 是个函数,根据 JS 里一切皆对象的说法,Foo 函数也有它自己的原型,它的原型是啥呢?答案是 Function.prototype,因为当你声明 Foo 函数的时候,就类似 new Function(Foo) 的操作。我们来验证下:

console.log(Foo.__proto__ === Function.prototype)  // true

Function.prototype 是个对象,它的原型又指向啥?还有上面的 Foo.prototype 也是个对象,它的原型又是啥呢?一提到对象,那可以联想到,是不是通过 new Object 创建的呢,如果是的话,那它的原型应该就是构造函数 Object 的 prototype,我们打印出来验证下:

console.log(Function.prototype.__proto__ === Object.prototype)  // true
console.log(Foo.prototype.__proto__ === Object.prototype)  // true

从上面代码可以得出信息:不管是 Function.prototype 还是 Foo.prototype,它们的原型都指向 Object.prototype
那问题又来了,Object.prototype 又是个对象了,这时它的原型该指向谁?ECMAScript 规范规定,Object.prototype 的原型指向 null
现在可以解释 原型链 了:每个对象通过它的内部属性 __proto__ 访问它的构造函数原型对象,原型对象继续访问它上一层的对象,一直到原型链的最顶层,也就是 null。下面这张经典图描述了原型的整个过程:
image

原型的两种检测方法

JS 里可以通过以下方法检测一个对象是否是另一个对象的原型:

  • isPrototypeOf
  • instanceOf

isPrototypeOf 的语法

function Foo() {}
function Bar() {}

Bar.prototype = Object.create(Foo.prototype)

const bar = new Bar()

console.log(Foo.prototype.isPrototypeOf(bar))  // true
console.log(Bar.prototype.isPrototypeOf(bar))  // true

instanceOf 的使用及模拟实现

instanceOf 的原理是去检测构造函数的 prototype 属性是否出现在某个对象实例的原型链上。还是上面那个例子的代码:

function Foo() {}
function Bar() {}

Bar.prototype = Object.create(Foo.prototype)

const bar = new Bar()

console.log(bar instanceof Foo)  // true
console.log(bar instanceof Bar)   // true

以下是 instanceof 的模拟实现:

function mockInstanceof(leftValue, rightValue) {
  let rightProto = rightValue.prototype
  leftValue = leftValue.__proto__
  while (true) {
    if (leftValue === null) return false
    
    if (leftValue === rightProto) return true
   
    leftValue = leftValue.__proto__
  }
}

测试一下:

function Foo() {}
function Bar() {}

Bar.prototype = Object.create(Foo.prototype)

const bar = new Bar()

console.log(mockInstanceof(bar, Foo))  // true
console.log(mockInstanceof(bar, Bar))   // true

v8 下的垃圾回收机制

JS 是一门动态弱类型的高级语言,高级语言的特点就是垃圾回收通常不用程序员自己做,而是由语言引擎去优化实现。本文简单介绍下 Chrome 浏览器 v8 引擎下的垃圾回收机制。

垃圾回收相关概念

JS 代码所在的内存空间分为栈和堆,栈中存放的是基本数据类型,堆中存放的是引用类型。栈就是我们常见到的调用栈,也就是执行上下文,执行上下文栈里会通过一个 ESP 指针来指向当前正在执行的上下文,那么当指针从一个上下文移动到另一个时,上一个执行上下文就销毁回收了,对应的局部活动对象也随之销毁;而对于堆来说,因为存放的是引用类型的数据,这些数据在内存里分配了地址,像这类数据就需要垃圾回收器来做。

理解垃圾回收前,先要了解下垃圾回收领域的一个理论,叫做「代际假说」,它的主要特点是:

  • 大部分对象在内存中存在时间很短,很多对象一经分配,很快就变得不可访问
  • 不死的对象,会活得更久

根据这个理论,v8 将堆分为新生代和老生代两个区域。新生代中存放的是存活时间比较短的对象,其大小大概是 1 ~ 8 M,而老生代中存放的则是存活时间比较长的对象,它的容量可以很大。相对应的,有两个垃圾回收器:主垃圾回收器副垃圾回收器。主垃圾回收器主要负责老生代的垃圾回收,而副垃圾回收器则负责新生代的垃圾回收。

这两个垃圾回收器在回收时,执行流程大致是共通的:

  • 标记空间里的活动对象和非活动对象
  • 回收非活动对象所占据的内存
  • 进行内存整理

副垃圾回收器

副垃圾回收器主要针对新生代区域,在回收时采用 Scavenge 算法,它将空间对半划分为对象区域和空闲区域,新加入的对象首先分配到对象区域里。而当对象区域满时,就需要进行垃圾清理工作,具体过程如下:

  • 首先对对象区域中的垃圾做标记,标记完后进行清理,然后将清理后存活的对象复制到空闲区域中,在这个过程中会有序地排列对象,相当于完成了内存整理的动作,复制完成后,内存碎片也被整理掉了。
  • 接着角色翻转,交换对象区域和空闲区域,垃圾回收完成。

新生代垃圾回收的特点是每次都要将存活对象从对象区域复制到空闲区域,这个复制操作需要时间成本,所以为了执行效率,新生代空间不宜设置过大。同时为了解决新生代空间容易装满的问题,v8 采取对象晋升策略,如果一个对象如果经过两次垃圾回收后依然存活,就会从新生代转移到老生代里。

主垃圾回收器

主要针对老生代区域,采用标记-清除算法。从根元素开始遍历,可达的称为活动对象,不可达的判定为垃圾数据。由于老生代中存放的对象是比较大且存活时间久,而清理的过程是标记一部分,清理另一部分,那这个过程就会产生内存碎片。而如果内存碎片过多的话,会导致大的对象无法分配到足够的连续内存。于是 v8 团队又使用了一种新算法:标记-整理法。它前面的步骤跟标记-清除法是一样的,不一样的是对垃圾数据不直接清除,而是让存活的对象向一端移动,然后清理掉端边界里的垃圾数据。

全停顿

由于 JS 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行,这种行为叫做全停顿(Stop-The-World)。对新生代来说还好,但老生代影响比较大,因为回收需要的时间更长,如果导致主线程暂停等待垃圾回收完毕,肯定会影响应用程序比如卡顿,所以 V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,这个算法被称做「增量标记」(Incremental Marking)算法,它就是把一个完整的垃圾回收任务拆分为多个小任务,这些小的任务执行时间比较短,可以穿插在其他的 JS 任务里执行,所以能做到垃圾回收的同时也不会影响 JS 主线程代码的执行。

小结

  • 对于垃圾回收,先了解「代际假说」的理论
  • v8 引擎把内存分为新生代和老生代区域,新生代存放存活时间短的对象,老生代存放存活时间久的对象
  • 新生代区域空间比较小,大概 1-8 M,而老生代容量可以很大
  • 一个对象经过两次垃圾回收依然存活,则会从新生代转移到老生代去
  • 垃圾回收算法有:Scavenge 算法标记-清除法标记-整理法

浏览器安全之 XSS

作为前端工程师,每天与浏览器打交道是必不可少的。而安全是一个必须重视的话题,今天就来讲讲网络安全攻击之 XSS

XSS

XSS 即 Cross-Site Scripting(跨站脚本攻击),这种攻击发生在用户的浏览器上,它通常是利用漏洞把一些恶意内容存储到网站的服务器上,然后在服务器返回资源,浏览器解析 HTML 文档的过程中,执行恶意代码产生攻击。常见的就是通过 script 标签来进行攻击操作,script 标签可以突破浏览器的同源策略,进而有了很大的操作空间,比如读取网站的 cookie。
通常有三种形式的 XSS 攻击:

  • 存储型
  • 反射型
  • DOM 型

存储型就是前面说的,通过某种形式把恶意代码存储到了数据库里,然后服务端把这些恶意代码取出拼接成 HTML 后返回给浏览器,浏览器执行解析 HTML 从而产生攻击。比如留言板功能,当网站渲染显示用户的留言时,通常是有对应的数据库查询操作,而用户提交的包含恶意代码的留言保存到数据库后,再次读取出来显示,就容易被攻击。

反射型通常是跟 url 有关,比如有这样的一个 url http://www.baidu.com?param=<script>alert(document.cookie)</script>,假设服务器在获取 param 参数时没有做任何过滤,就将其拼接成 HTML 返回给浏览器,那么浏览器在解析时执行时就容易被攻击,恶意脚本可以趁此获取网站的敏感信息如 cookie。这个过程就像,浏览器提交了数据给服务器,服务器响应后返回包含 XSS 的代码给浏览器,浏览器解析执行,如同 "反射" 一样。

DOM 型,这种攻击的特点是 没有服务器的参与,完全是浏览器客户端自己的事,通常是用户使用页面的过程中修改了资源,比如 WiFi 路由器劫持,本地恶意软件劫持。

XSS 攻击带来的危害

  • 通过 document.cookie 盗取用户 cookie
  • 可以监听用户行为,比如通过 addEventListener 监听键盘事件,获取用户的银行卡信息
  • 通过执行恶意代码,可以删除操控用户的数据
  • 通过修改 DOM 生成假的登录窗口,欺骗用户输入敏感信息
  • 生成浮窗广告,影响用户体验

防范 XSS 攻击

  1. 针对恶意代码执行问题,需要在显示的时候过滤转义掉诸如 & < > " ' / 这样的特殊字符
  2. 针对读取 cookie 问题,需要在 http 请求头设置 httpOnly 属性,禁止 JS 脚本读取 cookie
  3. 开启内容安全策略(CSP),对外域做各种限制
  4. 使用预编译模板技术,而不是简单的拼接字符串

那 CSP 做了什么呢?引用 MDN 的描述:

CSP 通过指定有效域——即浏览器认可的可执行脚本的有效来源——使服务器管理者有能力减少或消除 XSS 攻击所依赖的载体。一个 CSP 兼容的浏览器将会仅执行从白名单域获取到的脚本文件,忽略所有的其他脚本 (包括内联脚本和 HTML 的事件处理属性)。

React 有哪些生命周期函数?

前言

现在写 React,通常是 hooks 用得比较多,但 class 组件还是有用到的地方。理解 React 组件的生命周期,有助于我们写出更好的 React 组件和代码。不同版本的生命周期钩子有所不同,主要以 React 16 为分界线,分为 React15 和 React16 之后。

React 15 里的生命周期

初始化挂载更新阶段卸载阶段 来划分。其中初始化挂载的生命周期钩子有:

  • constructor
  • componentWillMount
  • render
  • componentDidMount: 大多数时候,初始化请求页面数据都是在这个钩子里写逻辑

更新阶段 触发的生命周期钩子有:

  • componentWillReceiveProps: 只要是父组件发生了更新,就会触发,即使父组件的 props 并没有变化
  • shouldComponentUpdate: 由组件自身的更新触发,如 setState。这个钩子常用来做性能优化,通过比较 props 来阻止后续生命周期的执行
  • componentWillUpdate
  • render
  • componentDidUpdate: 常用来执行一些更新操作,比如 DOM 修改,通知父组件数据变化等等

卸载阶段 只有一个钩子:

  • componentWillUnmount

以上就是 React15 版本下的生命周期函数

React 16 的生命周期改动

从 React16 开始,生命周期钩子进行了比较大的调整,主要是去掉了一些比较鸡肋的生命周期如 componentWillMountcomponentWillUpdate 等,另外还有几个新的生命周期钩子加入。这个 开源项目 展示了 React16.3 版本的生命周期图:
image

可以看到,在初始化挂载阶段,主要有以下几个生命周期:

  • constructor
  • static getDerivedStateFromProps: 需要返回一个对象,对象里是组件的 state 的数据,React 会差量更新 state 而不是直接覆盖更新
  • render
  • componentDidMount

相比 React15,React16 去掉了 componentWillMount,新增了 getDerivedStateFromProps 钩子,这个钩子的目的主要是为了取代 componentWillReceiveProps,它的基本理念是 实现 props 到 state 的映射,只做这件事,为了限制其使用场景,React 把这个钩子设置成了静态方法,这意味着 this 是获取不到的,可以减少用户的一些错误使用。另外,在 React16.3 中,只有父组件的更新才会触发这个钩子,而 React16.4 之后,任何因素的更新都会触发这个钩子执行。下图是 React16.4 之后的生命周期图,可以和上面那张图对比下:
image

更新阶段,主要有以下几个钩子:

  • static getDerivedStateFromProps
  • shouldComponentUpdate
  • render
  • getSnapshotBeforeUpdate
  • componentDidUpdate

相比 React15,更新阶段新增了 getDerivedStateFromPropsgetSnapshotBeforeUpdate ,移除了 componentWillUpdate

卸载阶段,与 React15 一样,只有一个钩子:

  • componentWillUnmount

Fiber 架构

为什么 React16 要大改生命周期呢?原因就是新的 Fiber 架构。

React 每一次更新组件,都会先拿到用户在 render 方法里的写的 JSX,然后编译生成虚拟 DOM,再使用 diff 算法与上一次的虚拟 DOM 内容做比较,然后定向更新 DOM 。在 React16 之前,这个更新是一个 同步递归 的过程,这意味着一旦进入更新,主线程就会被占住不放,浏览器就无法响应用户的其它交互操作,一直等到递归更新完成。这在 用户体验层面 带来了巨大的风险,可能会造成浏览器假死无法响应的状态。为了解决这个问题,React 团队在 React16 引入了新的 Fiber 架构,Fiber 架构做的事情就是 把原本同步渲染的过程变成可打断的异步更新

具体来说,Fiber 会把原本的同步更新任务,拆分成一个个小任务,并且还有任务优先级调度的概念,这意味着每次更新的时候,一个小任务执行完,是可以把主线程交还出去执行其他任务的。通过把大任务拆分成小任务,这样在渲染更新的时候是可以打断再重新恢复的,那这就意味着有些生命周期可能会被反复执行,所以一些可能导致出问题的生命周期函数就必须废弃掉或换成更合适的来保证渲染过程的安全。因此,componentWillMount 这个生命周期被废弃掉了,componentWillReceiveProps 这个生命周期废弃了,取而代之的是 getDerivedStateFromPropscomponentWillUpdate 也废弃了,换成新的 getSnapshotBeforeUpdate

总结

一个好的框架应该是提供一些简洁易用的 API,引导用户在正确的环境下做正确的事。React16 通过改造生命周期,引入新的 Fiber 架构带给 React 开发者更好的使用体验。

JS 之 call 和 apply 的模拟实现

JS 中的 this 一文中,我们提到可以通过 call, apply, bind 显式指定 this,本文就来介绍下 call 和 apply 的模拟实现

call 的模拟实现

call 的基本用法如下:

func.call(thisArg, arg1, arg2, arg3, ...)

它的基本思路就是针对传入的 this 参数临时挂载一个属性,作为方法执行,这样 this 自然就指向调用这个参数了,函数执行完毕后再删掉临时属性,概括起来就是下面这样:

  • thisArg.fn = this
  • thisArg.fn()
  • delete thisArg.fn

来看代码:

Function.prototype.myCall = function(thisArg, ...args) {
  if (typeof this !== 'function')  throw new TypeError('it must be invoke by function')

  if (thisArg == undefined) {
    thisArg = window
  } else {
    thisArg = Object(thisArg)   // 包装成对象
  }

  const func = Symbol('func')  // 创建一个不重复的属性常量
  thisArg[func] = this
  
  const res = thisArg[func](...args)

  delete thisArg[func]
  return res
}

测试使用:

var obj = {
  name: '张三'
}
var name = '李四'
function foo(age) {
  console.log(age)
  console.log(this.name)
}
foo.myCall(obj, 25)

apply 的模拟实现

apply 语法与 call 的区别在于,它的第二个参数接收的是类数组对象,其余跟 call 一样:

func.apply(thisArg, [argsArray])

实现代码如下:

Function.prototype.myApply = function(thisArg, arr) {
  if (typeof this !== 'function')  throw new TypeError('it must be invoke by function')

  if (thisArg == undefined) {
    thisArg = window
  } else {
    thisArg = Object(thisArg)   // 包装成对象
  }

  function isArrayLike(obj) {
    return obj && typeof obj === 'object' && 'length' in obj
  }

  const func = Symbol('func')  // 创建一个不重复的属性常量
  thisArg[func] = this

  let result
  if (arr) {
    if (!Array.isArray(arr) && !isArrayLike(arr)) throw new Error('the second  params must be array or array-like')
    else {
      const args = Array.from(arr)
      result = thisArg[func](...args)
    }
  } else {
    result = thisArg[func]()
  }
  
  delete thisArg[func]
  
  return result
}

测试代码如下:

var obj = {
  name: '张三'
}
var name = '李四'
function foo() {
  console.log(this.name)
}
foo.myApply(obj)

浏览器存储之 cookie、sessionStorage、localStorage 与 IndexedDB

前言

打开 Chrome 浏览器,按 F12 或鼠标右键审查元素,然后在面板里找到 Application 栏,如下:
image
上面所示的就是客户端数据的几种存储方式,分别是:

  • cookie
  • sessionStorage
  • localStorage
  • IndexedDB

cookie

在了解 cookie 之前,先要知道为什么需要 cookie。这就要从 http 说起,http 是一个无状态协议,这意味着客户端每次发送 http 请求,服务器是无法记住身份状态的。这带来的问题是,你作为一个用户登录某个网站,你肯定是希望登录一次后,短时间内可以直接使用网站而不用二次登录,但是由于 http 无状态的特点,无法实现你这个需求。可能你刚登录完某个网站,过了一会再打开,又要求你再次登录,毫无疑问,这样的用户体验是不行的。

于是 cookie 就出现了,那 cookie 是什么呢?简单的说,cookie 是一个状态标识,它由服务器下发给浏览器,然后浏览器将它存储起来,这样下次再访问某个资源的时候,只需要携带这个 cookie 标识,服务器就可以识别出你是谁,然后返回你的一些相关信息给客户端使用。

cookie 有如下几个特点:

  • 大小只有 4k
  • 可以由服务端和 JS 读写(如果设置了 httpOnly 属性,则禁止 JS 读写)
  • 通常出现在 http 请求头里,随着请求一同发送给服务器,正因如此,cookie 如果太大,会影响传输性能
  • 可以设置过期时间,通过 Expires 和 Max-Age 这两个属性设置,如果设置为负数或 0,则浏览器关闭后直接被销毁
  • 可以在同个域名下的 http 和 https 之间共享(如果设置了 secure 属性,则只能在 https 中携带)
  • 对 cookie 设置 SameSite 属性值,可以限制其在跨域请求中携带,减少 CSRF 攻击

实际应用中,由于 cookie 的大小限制,传输性能等因素,已经越来越不推荐使用 cookie 了。一般存储更加推荐使用的是 sessionStorage 和 localStorage,而用户登录状态标识可以使用 session。session 使用 cookie 作为 key ,与用户数据进行关联,来作为状态标识,通常是由服务器管理。

cookie vs session

  • 从存储位置看,cookie 保存在客户端,而 session 保存在服务端的数据库上
  • 从有效期看,cookie 可以由用户设置过期时间,而 session 则是用户关闭浏览器或登出就被销毁
  • 从存储内容看,cookie 存储的信息受限,大小只有 4 kb;而 session 可以存储任意信息,没有大小限制
  • 从安全性看,cookie 存储的是明文的文本信息,容易泄露;而 session 是加密存储信息,更安全

sessionStorage

sessionStorage 是一个存储对象,它有几个 api 可以调用,使用上比 cookie 更灵活。存储的是会话级别的数据,这意味着浏览器或标签 tab 页关闭后,数据就没了,适合存储一些只在会话期间短暂生效的数据。它的特点如下:

  • 大小取决于浏览器,大多数浏览器都对同个源限制 5MB,相比 cookie 可以存更多数据,并且它不会随着 http 请求一起发送
  • 页面标签或浏览器关闭,数据就被销毁
  • setItem(key, value) API 可以设置对应键值的缓存
  • getItem(key) API 可以获取对应键的缓存
  • removeItem(key) API 可以移除某个键值的缓存
  • clear() API 可以清除所有 sessionStorage 缓存

sessionStorage 的应用:存储分页数据

localStorage

相比 sessionStorage,localStorage 存储的数据 生命周期更长,除非用户主动清除缓存或通过 JavaScript 删除,否则即使关闭窗口、标签页和浏览器,localStorage 的数据也一直在。它的特点如下:

  • 大小取决于浏览器,大多数浏览器都对同个源限制 5MB
  • 与 sessionStorage 一样,拥有 setItemgetItemremoveItemclear API,因为它们都继承自 Storage 对象。
  • 除非主动删除,否则数据可以一直存储在浏览器里

sessionStorage 和 localStorage 的任何更改,都会触发 storage 事件,可以通过 window.addEventListener("storage", handler) 来监听存储变化。

IndexedDB

这类存储很少用到,它是浏览器存储结构化数据的一个方案。类似 SQL 那样操作数据库,它有如下特点:

  • API 都被设计成异步的,使用 JavaScript 来操作,避免阻塞应用程序
  • 可以存储结构化克隆算法支持的任何对象
  • 数据存储是与页面域名、协议和端口绑定的,不能够跨域共享
  • 支持事务操作

React 组件间的通信

在 React 里,UI = render(state),页面是由数据驱动更新的。平时我们写 React 的时候,本质上就是在跟数据打交道,了解清楚数据的流向,对厘清业务是有帮助的。而 React 在数据这方面,推崇的是 单向数据流,也就是数据只能由高层级的组件流向低层级的组件。下面介绍几种 React 组件间的数据通信。

父子组件

对于父子组件来说,通常是由父组件定义数据,然后通过 props 传递给子组件。在这种模式下,子组件就是个无状态组件,它的数据全部由父组件定义和控制。当需要更改自身状态时,通过 事件驱动 的方式来更改,即子组件 emit 发送一个事件出去,并把当前的数据状态传递给父组件,父组件接收到后,调用 setState 更新数据,然后页面 UI 随之刷新。

兄弟组件

对于兄弟组件,数据的传递方式是通过 父组件 做中转。假设有兄弟组件 B 和 C,以及它们的父组件 A。此时 B 要向 C 传递数据,怎么做呢?首先,父组件 A 在子组件 B 里绑定一个事件监听函数 fn,子组件 B 通过 fn 函数入参把想传递的数据交给父组件 A,A 拿到数据后,通过 setState 更改 A 组件当前的 state 数据,然后再把这个数据通过 props 的方式传递给子组件 C。这样就实现了兄弟组件通信。

跨组件通信 —— 发布订阅模式

React 单向数据流的好处是数据来源清晰,方便管理,但坏处是假设组件很多的话,通过一层层 props 传递下去,嵌套太多不是好办法。所以还有一种传递数据的方式: 发布——订阅模式。它通过一方发布事件,另一方订阅事件来实现数据的传递。对于发布订阅模式来说,有以下几个重要方法:

  • on
  • off
  • emit
  • once

一个简易的发布——订阅模式实现代码如下:

class EventEmitter {
  constructor() {
    this.events = {}
  }
  
  emit(type, ...args) {
    this.events[type].forEach(fn => {
      fn(...args)
    })
    return true
  }
  
  on(type, handler) {
    this.events[type] = this.events[type] || []
    this.events[type].push(handler)
    return this
  }
  
  off(type, handler) {
    const lis = this.events[type]
    if (!lis) return this
    for (let i = lis.length; i > 0; i--) {
      if (lis[i] === handler) {
        lis.splice(i, 1)
        break
      }
    }
    return this
  }
  
  once(type, handler) {
    this.events[type] = this.events[type] || []
    const onceWrapper = () => {
      handler()
      this.off(type, onceWrapper)
    }
    this.events[type].push(onceWrapper)
    return this
  }
}

Context API

React 本身提供了一个组件间全局通信的方式,那就是 Context API。这个 API 有三个重要概念:

  • React.createContext: 创建一个上下文对象用来保存数据
  • Provider:把组件包裹在 Provider 里,通过 Provider 的 value 属性获取需要传递的数据
  • Consumer: 消费数据的组件,接收定义在 Provider 的 value 数据

Context API 驾驭起来难度大,因此很少被推荐使用,如果是大型复杂项目,数据管理通常会采用第三方状态库 redux

redux

引用官方的描述:

Redux 是 JavaScript 状态容器,它提供可预测的状态管理。

Redux 并不属于 React,而是提出了一套数据管理的**和规范,React 根据这个**实现了自己的状态管理库 react-redux
对于 Redux 架构,有三个重要概念需要理解:

  • store: 全局唯一的数据源,它是只读的
  • reducer:是一个纯函数,它根据 action 的动作,来对数据进行分发处理,最后返回新的数据状态,更新 store。store 一旦更新,就驱动视图层刷新,UI 也就改变了,这是个 严格的单一数据流
  • action: 因为 store 是只读的,我们如果想改变数据,就需要定义一个改变「动作」,这个动作描述了新的数据状态信息,它会被 reducer 接收处理。

redux 的工作流是这样:首先,我们需要有一个类似 createStore 的方法来创建我们的唯一数据源 store,有了 store 后,我们想改变数据,该怎么做?首先,我们需要定义一个 action 动作,描述你这个数据是新增还是修改,比如 action = {type: 'ADD', payload: '张三'},定义 action 就是解决 你想要什么。然后,我们还要编写 reducer 纯函数,在里面指定如何来响应 action,比如上面的 action 是新增一个叫 张三 的名字,那我们就要在函数里根据这个动作,来创建新的数据,这个数据最终会被处理更新到 store 上。现在我们 store 有了,action 也有了,reducer 处理函数也有了,那怎么实现更新到 store 这个操作呢?这时 dispatch 函数就登场了,当你通过 createStore 方法创建完 store 数据源后,就可以通过 store.dispatch 来触发数据的更新。
总结下 redux 的整个工作流程:

  • 创建唯一数据源 store
  • 定义更改数据的动作 action
  • 定义处理 action 的函数,称作 reducer
  • 通过 store.dispatch 来让 reducer 处理 action

总结

React 遵循单向数据流原则,有以下几种传递数据的方式:

  • props
  • 发布——订阅模式
  • Context API
  • redux

计算机网络之 HTTP 协议

HTTP 的全称是 HyperText Transfer Protocol,中文是超文本传输协议。它是一个应用层协议,底层使用 TCP 协议来传输,这就是说,在使用 HTTP 发起请求前,需要先建立 TCP 链接,也就是三次握手,然后才能传输数据。

HTTP 的发展历程

HTTP/0.9 & HTTP/1.0

1991 年发布了第一个版本 0.9,这个版本就是简单的 request-response 模式,只支持 GET 方法。后来在 1996 年发布了 1.0 版本,自此 HTTP 开始走向规范化,也是比较具书面意义的一个规范版本。主要特性有:

  • 请求行中增加了版本号,如 GET 200 /index.html HTTP/1.1
  • 增加了 HEAD, POST 等方法
  • 增加了响应状态码
  • 引入了 header 头部概念
  • 传输数据不再仅限文本,Content-Type 可以传输其它文件了

但是这个版本有个很大问题,每请求一次资源都要新建 TCP 连接,而且是串行请求。

HTTP/1.1

HTTP/1.1 发布于 1999 年,在 1.0 的基础上,解决了一些网络性能问题,另外增加了新特性:

  • 持久链接:通过设置 keep-alive 来重用 TCP 连接
  • 支持 pipeline 网络传输,维护一个请求队列,第一个请求发出去后,可以接着发第二个请求;响应必须按请求顺序接收
  • 增加了 Cache-Control 缓存控制
  • 协议头注增加了 Language, Encoding, Type 等
  • 数据分块传输,这是因为如果页面内容是动态生成的,浏览器不知道何时才能接收完毕,于是服务器将数据分割成多个 chunk,每次发送时附上上次数据块的长度,最后通过发送零长度的块作为数据发送完毕的标志
  • 强制要求 host 头,以便让服务器知道要请求哪个网站,因为存在多个域名解析到同一个 ip 上,要区分具体域名
  • 增加了 PUT、DELETE、OPTIONS 等方法,其中 OPTIONS 常用于 CORS
  • 引入了客户端 cookie

HTTP/2

了解 HTTP/2 之前,先看 HTTP/1.1 还有哪些缺点:

  • HTTP/1.1 还是存在性能问题,虽然可以重用 TCP 链接了,但是请求还是串行发的,需要保证接收顺序
  • HTTP/1.1 传输数据还是以文本的方式,传输成本较高
  • HTTP/1.1 pipeline 时,如果有一个请求 block 了,那队列后的请求也统统被阻塞住了,这就是队头阻塞问题

所以在 2010 年的时候,Google 就在搞一个实验型的协议:SPDY。这个协议后来成为了 HTTP/2 的基础,HTTP/2 发布于 2015 年,它带来了许多新的特性:

  • 采用二进制编码,利于提高传输效率
  • 多路复用,可以在一个 TCP 链接中并发多个 HTTP 请求,解决了 1.1 的串行请求问题。具体就是通过帧和流
  • 压缩请求头,采用 HPACK 算法。两端都维护一个动态的字典表来分析请求头中哪些是重复的
  • 服务端 push 技术,主动推送一些用得到的内容放在客户端缓存里
  • 安全性提升,加密通信要求 TLS 至少是 1.2 版本

以下是 HTTP/2 与 HTTP/1.1 的对比:
image

HTTP/3

黑客的世界就是不断的折腾,HTTP/2 看起来已经完美了,但还是存在问题:多个 HTTP 请求在复用一个 TCP 链接,如果发生丢包,那所有的 HTTP 请求都必须等待这个被丢了的包重传回来,这还是存在队头阻塞问题,只不过现在是 TCP 的问题。

那既然 TCP 有问题,就干脆放弃掉它!所以 Google 另起炉灶搞了个 QUIC,它抛弃了 HTTP 底层的 TCP,改用 UDP。后台这个协议又又成为了 HTTP/3 的基础。HTTP/3 发布于 2018 年,它的特性如下:

  • 使用 UDP 协议作为底层,自然解决了 HTTP/2 的阻塞问题
  • 在 UDP 的基础上,又加入了 TCP 的丢包重传和拥塞控制功能
  • 换成 UDP 后,直接把 TCP 的三次握手和 TLS 的三次握手合并了,原先是六次网络交互,现在只需三次

可以说,HTTP/3 是在 UDP 上组合了 TCP + TLS + HTTP/2 的功能,由于动了底层协议,它离大规模应用还很遥远。

HTTP 请求的结构

HTTP 是基于客户端-服务器模型的,客户端发送请求,服务端响应请求。请求和响应都由以下部分组成:

  • 请求行/响应行
  • 头部(Header)
  • 主体内容(Body)

请求行

包含请求方法、状态码、路径和版本,如 GET 200 /index.html HTTP/1.1
请求方法包含:

  • GET
  • POST
  • HEAD
  • PUT
  • DELETE
  • OPTIONS
  • CONNECT
  • TRACE

其中 GetPost 的区别有:

  • 从缓存的角度,get 可以被缓存,post 不会
  • 从编码角度,get 只能进行 url 编码,接收 ASCII 字符;post 没有限制
  • 从参数角度,get 请求参数在 url 中;而 post 则是放在 body 里
  • 从幂等性角度,get 是幂等的,post 是不幂等
  • 从 TCP 角度,get 会一次性把请求报文发送出去;而 post 则会分为两个 TCP 包,先发送 header 部分,如果服务器响应 100;再继续发送 body 部分

状态码主要有 1xx2xx3xx4xx5xx 几种
1 xx:请求接收,继续处理

  • 100:请求已被接收,可以继续发送
  • 101:从 HTTP 升级为 websocket ,如果服务器同意变更,返回 101
  • 103:客户端应该在服务器返回 HTML 前开始预加载资源

2xx:成功

  • 200:请求成功
  • 204:与 200 一样,但响应没有 body
  • 206:返回部分内容,它的使用场景是分块,断点续传(content-range)

3xx:重定向相关

  • 301:永久重定向,比如你的网站域名换了,那访问旧的网址就可以重定向到新的
  • 302:临时重定向,比如权限验证失败,跳转登录页
  • 304:资源未修改,可使用协商缓存
  • 307:临时重定向,与 302 区别在于不允许将原本为 POST 的请求重定向到 GET 请求上

4xx: 客户端错误

  • 401:未进行身份认证
  • 403:无权限,禁止访问
  • 404:资源不存在
  • 405:请求方法不被允许

5xx: 服务器错误

  • 500:服务器错误
  • 502:网关或代理出现错误
  • 503:服务不可达
  • 504:网关超时

请求头

包含 request header 和 response header。这里列举几个常见的头部字段:

  • Content-Type
  • Content-Length
  • User-Agent
  • Host
  • accept
  • accept-encoding
  • cookie

请求 body

常见 body 格式:

  • application/json
  • application/x-www-form-urlencoded
  • multipart/form-data 文件上传
  • text/xml

浏览器之资源解析渲染

前言

我们日常用到的浏览器,主要功能就是通过用户输入的网址,然后去请求服务器,拿到资源后解析呈现在我们眼前。这部分涉及很多知识,比如网络 DNS 解析,HTTP 请求,TCP 握手,TLS 握手等等。今天这篇文章不讲网络部分的内容,主要讲浏览器从服务器拿到资源后,怎么进行解析渲染的过程。

主流浏览器的架构

对于浏览器的功能界面,市场上各家浏览器基本都大同小异,主要由以下几个模块组成:

  • 用户界面前端:包含地址栏,前进/后退按钮,书签栏等
  • 浏览器引擎:在 UI 和渲染引擎之间执行操作
  • 渲染引擎:负责界面的渲染展示,比如我们输入网站看到的页面,就是由渲染引擎渲染负责执行的,不同的浏览器使用的渲染引擎有所区别,IE 用 Trident,Firefox 用 Gecko,Safari 用 WebKit,Chrome 跟 Opera 用的是 Blink,这个 Blink 是由 WebKit 分化而来
  • 网络模块:主要负责 http 等网络请求的处理
  • 用户界面后端:用于绘制一些窗口组件之类
  • JS 解析器:执行 JS 代码
  • 数据存储:我们所熟悉的 cookie,localStorage , sessionStorage,IndexDB 的存放地方

这里借用一张来自 web.dev/howbrowserswork 网站的图:
image

了解了浏览器的主要架构后,我们今天要说的浏览器解析资源渲染就是 渲染引擎 干主要的活,其它引擎协助配合。

资源的解析渲染过程

以 Chrome 浏览器为例,借用一张经典的图:
image

整个过程分为:

  • 解析 HTML,构建 DOM 树
  • 解析 CSS,构建 CSS OM 树
  • 合并生成渲染树,进行布局
  • 接着进行绘制
  • 最后呈现

浏览器渲染的过程是 渐进式 的,而不是等文档解析完再呈现,是一边解析一边渲染,为了尽快让用户看到页面。

解析 HTML

这个过程会获取 HTML 文档,然后进行 词法分析(token 标记化) —— 语法分析 —— 生成解析树(有自上而下和自下而上两种算法),接下来将解析树进行翻译,翻译的目标是生成机器码。在解析的过程中,会对标签进行纠错,比如你写了个开始标签,但是漏了结束标签,算法会尝试自动修正。当解析完毕后,document 文档的状态就是 complete,此时就会触发 load 事件。

在解析的时候还会遇到 script 标签,这时会停止解析转而去执行 JS 脚本,如果脚本是外链的,就会去发起网络请求获取内容。由于 JS 会阻塞解析,因此一般推荐将 <script> 写在文档的最底部,或者对 script 脚本设置 asyncdefer 属性。async 属性表示另开一个线程去异步下载脚本,这个时候 HTML 是继续解析的,然后下载完后,HTML 就会停止解析,开始执行 script 脚本,如果有多个 async 的 script 的话,它不保证脚本的执行顺序;而 defer 也会在解析的时候去下载脚本,但它会延迟到 HTML 解析完才执行脚本,它可以保证脚本的执行顺序。
下面这张是 stackoverflow 上一张经典的解释图:
image

解析 CSS

CSS 的解析是根据特定语法规范来做的,主要处理一些选择器、标识符之类。引擎会按照一套预定的规则,对文档里的 stylesheet 进行遍历,然后根据级联规则,确定样式的优先级,最后计算每个元素的最终样式信息,形成 CSS OM 树。

构建渲染树

渲染树就是 DOM 树加上 CSS 规则应用后,最终生成的一颗树,比如在这个过程中。display: none 的元素就不会出现在渲染树了。

布局

这个过程主要是通过渲染树中的信息,以递归的形式计算出每个节点的尺寸大小和在页面中的具体位置。

绘制

浏览器将渲染树中的节点转换成在屏幕上绘制的指令,然后所有层按照一定顺序合并为一个图层并绘制在屏幕上

重排与重绘

上面的布局和绘制过程就涉及到我们经常说的重排重绘。如果页面频繁地进行重排,会导致很大的性能花销,因此平时写代码的时候应注意尽量减少重排重绘。

哪些操作会造成重排?

  • 页面初始渲染的时候,会重排一次
  • 增加修改 DOM 元素
  • 更改元素的 widthheightfont-familyfont-size
  • 更改元素的位置
  • 缩放浏览器窗口
  • CSS 伪类(:hover)
  • display: none 元素隐藏
  • 获取布局信息时,如 offsetWidth 和 offsetHeight 的查询

哪些操作会造成重绘?

  • 修改元素的外观,比如 color,opacity,outline 等
  • visibility: hidden
  • transform: translate

如何减少重绘、重排?

  • 对 DOM 做离线修改,批量写入和读取
  • 对 DOM 节点的引用要存到变量里,不能在 for 循环里频繁获取执行
  • 对样式的修改,应该使用添加 class 类名的方式,而不是直接用 JS 动态修改属性值
  • 合理使用特殊样式属性,如 will-change ,将渲染层提升为合成层,开启 GPU 加速,提高页面性能
  • 划分好 HTML 结构,尽可能修改小范围的 DOM 层级
  • 不要使用 table 布局,随意改变可能就会引起重排

小程序的双线程模型

最近几年小程序是热门技术,平时开发小程序业务比较多,因此有比较了解下小程序的运行原理。

对比浏览器线程模型

简易的双线程模型:一个 worker 线程负责计算,结果通过 postMessage 发送给主线程。worker 线程无法操作 DOM
缺点:性能问题,计算消耗太大

从微信自身生态考虑

  • 小程序不依赖微信版本,独立发版

渲染线程和逻辑线程

渲染线程:负责 UI 渲染,webview 承载
逻辑线程:执行 JS 代码,由客户端提供引擎,iOS 下是 JavaScript Core ,安卓是 X5 内核,模拟器工具是 nwjs
两个线程之间通过 native 层进行媒介转发,也就是事件驱动模式

JS 中的基本类型与引用类型

基本类型

  • null: 表示空值
  • undefined: 表示声明了该变量,但还未赋值
  • boolean: 布尔值,true | false
  • number: 数字类型,包含整数和浮点数
  • string: 字符串类型
  • symbol: 符号类型,可用作唯一标识符
  • bigint: 表示大数

typeof null 问题

使用 typeof 可以检测一个变量的类型,其中 typeof null 会显示 object,这是语言层面的一个 bug,其原因是 JS 底层存储变量的时候,会有个类型标记和类型值,其中 object 对应的类型标记是 0,而 null 被表示为空指针 0x00,结果,null 的类型标记就是 0,所以 typeof 返回 object

0.1 + 0.2 !== 0.3

JS 中 Number 类型采用的是 IEEE 754 标准 来表示整数和浮点数,只要是采用了这个标准的语言,都会遇到 0.1 + 0.2 != 0.3 的问题。这是因为 JS 使用 64 位来存储一个浮点数,计算机底层存储数据都是二进制的,0.1 在转换为 64 位二进制的时候,就发生了 精度丢失,所以就导致了 0.1 不是 0.1 ,实际上是 0.0001100110011001100110011001100110011001100110011001101....。这时候 0.1 + 0.2 就不是 0.3 了,而是 0.3000000000 ... 实际上比 0.3 还大一丢丢,所以这个相等判断就不成立了。

解决方法:

  • parseFloat((0.1 + 0.2).toFixed(1)) === 0.3
  • Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON
  • (0.1 * 1000 + 0.2 * 1000) / 1000 === 0.3

装箱

平时使用基本类型的时候可能会疑惑,为啥一个字符串可以直接访问一些原型上才有的方法?比如下面这些代码:

'aaa'.toUpperCase()
(0.12343).toFixed(2)
...

这是因为 JS 会自动对基本类型进行 装箱 操作,才使得这些方法可以访问,否则基本类型属性是没有这些东西的。

引用类型

引用类型主要就是 ObjectFunctionDate 之类的构造函数。这里就要提一下函数的传参了。函数参数到底是按值传递还是按引用传递?按照红宝书的说法,所有函数的参数都是按值传递。但是如果是按值传递,为啥下面这段代码预期不一样呢?

var obj = {a: 1}
function foo(x) {
  x.a = 2
  console.log(x.a)  // 2
}
foo(obj)
console.log(obj.a)  // 2

说好的按值传递,怎么修改了函数形参的值,实参也被影响到了呢。再略微修改一下,就能发现端倪:

var obj = {a: 1}
function foo(x) {
  x = 2
  console.log(x)  // 2
}
foo(obj)
console.log(obj.a)  // 1

上面这两段代码,一个是直接修改形参的属性,一个是重新赋值形参,对于执行结果可以这么理解:如果是基本类型的值,传递的是值的拷贝;如果是引用类型的值,传递的是对象引用的拷贝。而 ”对象引用的拷贝“ 就是一个 ”值“。

React 性能优化

当我们谈 React 性能优化的时候,通常是在谈对 组件性能 进行优化。像一些普适的性能优化思路,比如资源加载优化,代码压缩,减少重绘与回流,启用 CDN 等等都是通用的。而我们写 React,其实就是在跟组件打交道,其它框架层面的优化,React 都帮我们搞定了。作为开发者,我们需要对自己的 React 组件代码适当运用一些优化手段。那么对组件来说,核心思路只有一个:避免重复渲染。与此有关的 API 或生命周期方法有:useCallbackuseMemoReact.memoReact.PureComponentshouldComponentUpdate。这其中,有些是应用在类组件的,有些是在函数式组件里使用的。

类组件的性能优化

对于类组件来说,主要是 shouldComponentUpdate ,这个生命周期方法返回一个布尔值,React 会根据这个值决定是否执行后续的生命周期方法,进而决定组件是否重新渲染。它默认返回 true,表示无条件渲染;我们可以在这个方法里书写逻辑,当 props 或 state 无变化时返回 false,来阻止掉冗余的渲染。举个例子:

class A extends React.Component {
  state = {
    text1: '1',
    text2: '2'
  }
  render() {
    return (
      <div>
        <B text={this.state.text1} />
        <C text={this.state.text2} />
      </div>
    )
  }
}

现在我们有父组件 A,子组件 B 和 C。其中子组件的 text 数据是由父组件通过 props 传入的。我们知道,在 React 里,一旦父组件更新了,所有关联的子组件也会跟着更新。这带来的问题是:上面的例子我如果只想更新 text1 ,影响其实是 B 组件,但是因为所有组件都刷新了,C 组件的 text 没有发生变化,但它也跟着渲染更新了。所以这时我们就可以在 C 组件里添加 shouldComponentUpdate 的逻辑:

shouldComponentUpdate(nextProps, nextState) {
  if (nextProps.text === this.props.text) {
    return false
  }
  return true
}

通过比较更新前后 props 的 text 属性是否相等,来决定是否重新渲染 C 组件。其实 React 有个类可以专门来做这件事,这就是React.PureComponent ,这个类相当于在 shouldComponentUpdate 里帮我们把优化工作做好了,但是它只会浅比较组件的 props 和 state ,所谓浅比较就是对基本数据类型比较值是否相等,而对引用类型则比较引用是否相等。这会带来问题:数据内容不变,但是引用变了;或者数据引用变了,但是内容没变,这两种情况都会导致浅比较出现异常,导致渲染问题。所以我们一般会借助 Immutable.js 这个库来帮助创建不可变数据,这个库提供的 API 可以创建前后引用不相等的数据,这样就可以确保 PureComponent 的比较逻辑正常工作。

import { List } from 'immutable'

const emptyList = List()
const addList = emptyList.push(1)  // [1]
console.log(emptyList === addList)  // false

class HelloWorld extends React.PureComponent {
  state = {}

  constructor() {}
   
  render() {}
}

函数式组件性能优化

类组件的 shouldComponentUpdate/PureComponent 对应到函数式组件,就是 React.memo API,这个函数是 React 提供一个高阶组件,它接收两个参数:

function MyComponent(props) {
  /* render using props */
}
function areEqual(prevProps, nextProps) {
  /*
  return true if passing nextProps to render would return
  the same result as passing prevProps to render,
  otherwise return false
  */
}
export default React.memo(MyComponent, areEqual);

第一个函数是我们写的组件,第二个参数是比较函数,React 会根据第二个参数的比较结果,决定是否复用函数的渲染缓存结果,如果不传第二个参数,默认会进行 props 的浅比较,效果相当于 PureComponent。值得一提的是,React.memo 只能对 props 进行比较,无法感知组件内部 state 的变化。

React.memo 是对整个组件进行缓存优化,那如果我们只想对函数中的某一段逻辑进行缓存优化呢?这时就要靠 useMemouseCallback 来帮忙了,这两个都是 React Hooks。

useMemo 的用法如下:

const cachedValue = useMemo(calculateValue, dependencies)

它接收两个参数,第一个参数是个计算值,比如你传入函数,函数的返回值会被缓存;第二个参数是依赖项,只有依赖发生变化时才会重新执行逻辑计算新的值。

useCallback 的用法如下:

const cachedFn = useCallback(fn, dependencies)

它接收两个参数:第一个参数是个函数,第二个参数是依赖项。那 useMemouseCallback 有啥区别呢?

  • useMemo 缓存的是,也就是函数返回的任意值都会被缓存
  • useCallback 缓存的是 整个函数,也就是你通过 useCallback 包装,让这个函数有了缓存结果
  • 也就是说,useCallback 是函数版的 useMemo,它作用的是函数。

总结

  • React 性能优化的思路是避免组件重复渲染
  • 对于类组件,可以通过 shouldComponentUpdate 生命周期以及 React.PureComponent 基类优化
  • 对于函数式组件,可以通过 React.memouseMemouseCallback 优化

requestAnimationFrame 可以做什么?

简介

requestAnimationFrame(callback) API 可以在浏览器下一次重绘之前执行回调,它通常会与屏幕的刷新率保持一致。 大部分屏幕的刷新率都是 60Hz,也就是一秒内刷新 60 次,那我们计算一下就可以得到一次执行大概是 16.6ms 左右:1000ms/60 = 16.6666,这也就是说如果你一个任务执行超过了 16.6ms ,就有存在卡顿的风险。

应用

我们可以利用 requestAnimationFrame 来计算每秒帧数 fps(frame per second)。具体思路是:设置一个计数器和一个时间变量,然后执行 requestAnimationFrame 调度给计数器加 1,如果判断到当前时间与上次执行的时间差超过了 1000ms,我们就打印出当前的计数器,这个就是帧数了。

(() => {
  let fps = 0
  let prevTimestamp = Date.now()
  function loop() {
    fps++
    const elapsed = Date.now() - prevTimestamp
    if (elapsed >= 1000) {
      console.log('fps', fps)
      prevTimestamp = Date.now()
      fps = 0
    }
    requestAnimationFrame(loop)
  }
  requestAnimationFrame(loop)
})()

浏览器安全之 CSRF

定义

CSRF (Cross-Site Request Forgery)跨站请求伪造,从字面上看,一个是跨站点,表示恶意攻击的请求是来自不同域的;另一个是伪造,表示这个请求并非用户真实意愿发出的,而是通过某种欺骗手段诱使用户去点击从而产生攻击。

一个例子

现在我们有以下两个站点:

假设 a 站点有个文章列表,点击按钮可以删除文章,对应的请求是 www.a.com/article/del?id=1
那么 CSRF 的攻击思路是什么呢?

  • 首先,既然是跨站,那么在 b 站点构造一个 www.b.com/csrf.html 页面
  • 然后,利用 img 标签没有跨域的限制,在页面里放置一张图片 <img src="http://www.a.com/article/del?id=1" />
  • 接下来,还有一个关键点,就是欺骗已经登录网站 a 的用户,来访问 b 站点构造的这个页面,这个时候有了登录态,img 发出请求就会携带 cookie,然后就可以不知不觉地把这篇文章删掉了😱!

上面这种是 GET 请求,另外还有 POST 请求,思路是一样的:例如要在 a 站点新增一篇文章,那就在 b 站点里伪造页面后,使用 JavaScript 构造一个表单, action 地址指向 a 站点新增文章的 api 地址,用户访问后,请求就会携带 cookie,经过身份认证后,就能不知不觉新增一遍文章了。

CSRF 分类

  • HTML CSRF 攻击
  • JSON 劫持攻击
  • Flash CSRF攻击 (Flash 退出历史舞台了,了解就好)

HTML CSRF 攻击简单来说就是请求是由 HTML 元素发出的,比如 img, link, a 这些自带跨域的标签。
JSON HiJacking 攻击就是对 AJAX 请求返回的 json 数据进行劫持,比如某个接口 url 提供了 callback 回调来处理数据,那么利用这个 callback 伪造出 CSRF 请求,对数据进行操纵攻击。

CSRF 的危害

  • 篡改目标网站上的用户数据
  • 利用用户 cookie 信息做一些恶意操作
  • 传播 CSRF 蠕虫

防范 CSRF

  • 针对跨站问题,可以判断请求来源,具体是优先判断 origin,因为它考虑安全,origin 只包含域名信息;其次是 referrer ,里面包含了详细 path
  • 针对 cookie 被盗用问题,可以对 cookie 设置 Samesite 属性,这个属性支持三个值:
    • Strict:开启严格模式,这种模式下 cookie 在任何时候都不能作为第三方 cookie 来使用
    • Lax:宽松模式,允许部分请求携带 cookie,通常是 GET 请求
    • None:不做啥限制,每次请求都可以携带 cookie 发送
  • CSRF token:既然是通过伪造请求实现攻击,那么可以由服务端下发一个 token 给客户端,客户端妥善保管好它,然后每次请求时携带这个 token ,服务器校验 token 的合法性,以此来区分正常用户请求和非法请求。
  • 对于普通用户来说,不要随便打开一些来路不明的链接,尤其是邮件里收到的一些垃圾邮件

v8 的工作原理

平时我们常用的浏览器有 Chrome 浏览器,Firefox 浏览器等等。不同浏览器对应不同的 JS 执行引擎,这篇文章介绍的是 Chrome 浏览器里的 JS 引擎 —— v8。

v8 的内存模型

我们平时可能听到过,JS 有基本数据类型和引用类型,基本数据类型是存放在栈里,而引用类型是存放在堆里。这里面的「栈」和「堆」指的就是 v8 引擎里的内存模型。v8 将内存空间分为代码空间栈空间堆空间。所谓代码空间就是指可执行代码,栈空间就是我们常看到的调用栈,调用栈里放的是执行上下文。而堆空间里存放引用类型,每个数据都会分配内存,拿到内存地址,然后这个地址再被引用。比如执行上下文里变量环境里的变量数据,如果是基本数据类型,则是值;如果是引用类型,那就是这个内存地址。

那 v8 为什么要这么划分两种数据类型的存放呢,全部放栈空间里不行吗?答案是不行,因为 JS 引擎需要用栈来维护程序执行期间上下文的状态,如果你栈数据太大了的话,那么势必会影响到上下文切换的效率,进而又影响到整个程序的执行效率。所以通常情况下,栈空间都不会很大,而且基本数据类型占用的空间一般也很少。那 v8 是怎么对内存里的数据进行管理的呢?这就要说说垃圾回收机制。

垃圾回收机制

参考前文 v8 下的垃圾回收机制

v8 是如何执行一段 JS 代码的

这里面的执行机制很复杂,首先要了解几个基础概念:编译器解释器字节码抽象语法树即时编译(JIT)

我们平时写的源代码,计算机是看不懂的,所以编译器做的工作就是转译我们的代码,转成什么呢?从人类理解的高级语言转成计算机能理解的机器语言。编译器的工作流程大致是:

  • 通过词法分析,语法分析将源代码转换为 AST 抽象语法树
  • 然后进行词义分析,生成中间代码
  • 接着进行代码优化,将中间代码转为二进制文件
  • 最后变成可执行代码

而说到解释器,众所周知,JS 是一门解释型语言,相对应的另一个概念叫编译型语言,比如 Java,C/C++。解释器就是负责解释执行经过处理的 JS 代码,注意,这里所谓的处理,就是代码已经经由各种转换操作,变成机器能理解的语言了。解释器的工作流程是:

  • 通过词法分析,语法分析将源代码转换为 AST
  • 进行词义分析,生成字节码
  • 最后根据字节码来解释,执行代码

接下来再说说抽象语法树 AST,顾名思义,这是一种树形结构,目的是让解释器和编译器能看懂并进行各种分析工作。可以类比的还有 HTML 代码转成 DOM 树,也是为了方便计算机理解并执行操作。再比如我们所熟知的 Babel 在转换 ES6 新特性时,也是先将代码转为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。总的来说,AST 是一颗树,把一段 JS 代码分析拆分成一个个小的单元组织起来。

然后再说说字节码。字节码的出现是为了解决内存占用的问题,在没有字节码的时候,v8 在生成 AST 后,下一步就是直接生成机器码了,机器码因为是底层语言,执行效率非常高,效率高的代价就是占内存,v8 在转换成机器码的过程中需要大量的内存使用。而随着 Chrome 在手机上的普及,问题就出现了,比如一台只有 512M 内存的手机,直接转换机器码的过程,内存可能就飙升进而带来使用问题。所以 v8 团队重构了引擎架构,引入了字节码。所谓字节码就是介于 AST 和机器码的一种代码,需要通过解释器将其转换为机器码才能执行。可以这么理解,字节码就是在 AST 到机器码中间取一个平衡,它所占用的内存比机器码小,所以先由 AST 过渡到字节码,再通过 JIT 技术变为机器码。

最后还要说下即时编译 JIT。对应到 v8 引擎来说,就是解释器一边解析字节码,然后如果一段代码重复出现,就是所谓的热点代码,此时编译器就会将这段代码编译为机器码,然后保存起来下次使用,以便提高执行效率。这个过程就是即时编译。

理解了编译器,解释器,JIT, AST 还有字节码的概念后,接下来就可以说说 v8 是如何执行一段 JS 代码了。

  1. 首先,v8 会对源代码进行转换,转换的结果是生成 AST 抽象语法树还有我们熟悉的执行上下文。
    AST 的生成过程需要经过两个阶段:
    第一个阶段是分词(tokenize),将源代码拆分成一个个不可再分的 token,比如关键字,标识符,运算符,字符串;
    第二个阶段是解析(parse),将上一步生成的 token 根据语法规则生成 AST。这一步如果存在语法错误,就会抛出语法错误的提示。
    至于执行上下文,可参考前文 JS 中的执行上下文

  2. 有了 AST 后,下一步就要将 AST 转换成字节码。这一阶段的主角就是解释器 Ignition(点火装置) ,解释器会逐步转换 AST ,并生成字节码

  3. 有了字节码后,接下来就进入执行阶段了。执行阶段的主角是解释器 Ignition + 编译器 TurboFan。 通常,如果是第一次执行的字节码,解释器就会逐条解释执行,在这个过程中,如果发现热点代码(也就是重复出现的代码),那么后台的编译器 TurboFan 就会将这段热点代码编译为机器码,这就是代码优化。这样当下次再执行这段被优化过的代码时,就能直接使用高效的机器码,从而大大提升执行效率。那如果不是热点代码呢?那就只能由解释器执行后,再生成机器码了。这种字节码和解释器编译器相互配合执行的过程,就是前面所说的即时编译了。

理解 JS 代码的执行过程对我们有什么好处呢?除了知晓原理外,还有一个关键:性能优化。

性能优化

关于性能优化,网上有很多文章学习。这里提两点跟 v8 执行有关的:

  • JS 是运行在主线程的,那么我们写代码的时候要注意,避免大的耗时任务长时间占用主线程,造成无响应的卡死状态。
  • JS 内联脚本不能太大,因为解析 HTML 的过程中,如果遇到脚本,就会去执行脚本,解析和编译的过程也是占用主线程的。当然有很多方法可以避免,比如给脚本设置 async,defer 属性等。

总结

理解 v8,首先要理解它的内存模型,知道我们平时 JS 里的基本数据类型和引用类型是存放在哪的;然后就是对应的内存是怎么被释放的,也就是垃圾回收的过程;最后就是理解几个重要概念 编译器解释器字节码抽象语法树即时编译(JIT),然后才能深入了解 v8 执行 JS 代码的过程。

JavaScript 之 new 的原理

疑问

现在有以下 JavaScript 代码:

function Foo(name) {
  this.name = name
}
Foo.prototype.getName = function() {
  console.log(this.name)
}

const foo = new Foo('张三')
console.log(foo.name)  // 张三

在这段代码里,当执行 const foo = new Foo('张三') 的时候,new 做了什么事情?

解答

在 JS 里,new 的作用是创建对应构造函数对象的一个实例,这个实例可以访问构造函数原型上的属性和方法。那它是怎么实现这个功能的呢?具体来说,做了以下几件事:

  1. 创建一个空对象,假设是 obj
  2. 获取构造函数,将 1 中对象的原型链接到构造函数的 prototype
  3. 执行构造函数,假设结果是 ret
  4. 判断 ret 是否是对象?如果是,返回 ret ;如果不是,返回第一步创建的 obj

代码实现

function mockNew() {
  const obj = {}   // 创建空对象
  const constructor = [].shift.call(arguments)  // 获取传入的构造函数
  obj.__proto__ = constructor.prototype   // 设置原型
  const ret = constructor.apply(obj, arguments)  // 执行构造函数
  return typeof ret === 'object' ? ret : obj   // 判断返回结果
}

测试

function Foo(name) {
  this.name = name
}
Foo.prototype.getName = function() {
  console.log(this.name)
}

const foo = mockNew(Foo, '张三')
console.log(foo.name)  // 张三

理解虚拟 DOM

什么是虚拟 DOM

虚拟 DOM 是一个 JS 对象,它是对真实 DOM 的描述。它的形式可以是下面这样:

{
  type: 'div',
  key: null,
  ref: null,
  props: {
    className: "app"
  },
  children: [
    {
      type: "p",
      key: null,
      ref: null,
      props: {},
      children: []
    }
  ]
}

上面这段虚拟 DOM 对应描述的是如下格式的 JSX:

<div className="app">
  <p></p>
</div>

在 React 里,虚拟 DOM 的工作流程分为两个阶段:挂载阶段更新阶段

  • 挂载阶段:React 会读取 render 里的 JSX,然后构建虚拟 DOM 树,最后通过 ReactDOM.render 方法渲染出真实 DOM
  • 更新阶段:如果此时 JSX 内容更改了,React 不会直接操作 DOM,而是构建一棵新的虚拟 DOM 树,然后借助 diff 算法来对比更新前后两棵树的差异,然后再把差异部分更新到真实 DOM。

理解虚拟 DOM,首先要知道它是跟操作 DOM 相关的,那我们就会想,DOM 操作有很多种解决方案,比如 jQuery模板引擎 等,为什么最后是虚拟 DOM 胜出呢?任何技术方案都是 trade-off 权衡的艺术,肯定是虚拟 DOM 在综合对比下更胜一筹,所以我们首先要了解,在虚拟 DOM 出现之前,都有哪些处理 DOM 的方式。

操作 DOM 的演化过程

1. 原生 JS 直接操作 DOM

早期 JS 刚出现的时候,它就是作为浏览器的一个脚本语言,主要就是在页面加载完成后,添加一些特效,交互啥的。这时候对 DOM 操作的需求比较简单,因此直接使用原生 DOM API 就行。

2. jQuery 时期

但是由于原生 DOM API 太繁琐太难记了,使用起来不趁手,而且还要处理跨浏览器兼容的问题。当你要完成一个 JS 特效或滑动交互的时候,需要写一大堆 JS 代码实现。有没办法可以简化这些 DOM API 提高编码效率呢?于是 jQuery 横空出世,它提供了一大堆封装好的 API,让我们轻松操作 DOM,也不用管浏览器兼容问题,jQuery 通通帮我们处理好了。当你同时用原生 DOM API 和 jQuery 实现一个需求时,就会发现 jQuery 是多么好用!当年 jQuery 也确实流行,极大地提高我们的开发效率,虽然说现在用得比较少了。

3. 模板引擎 + innerHTML

jQuery 虽然好,但它是 “命令式” 的,命令式比较关注过程,按照 1, 2, 3, 4 一步步实现需求。这种方式跟另一种 声明式 形成对比。声明式比较关注结果,至于过程怎么做它并不关心,这其中比较典型的就是函数式编程:一个 sayHello(person) 函数,它负责根据传入的 person 进行 sayHello,至于它怎么做的我们不关心,我们只管调用这个函数就行。从这两种编程范式看,声明式的编程模式更胜一筹。

于是前端的先驱者就尝试了下模板引擎。模板引擎更关心数据,而不关注 DOM 操作的细节,通过解析模板数据来实现 DOM 渲染。它的工作流程就是:

  • 定义一个 HTML 模板,里面包含一些 JS 变量信息,如 <{% product.name %}>
  • 通过 JS 或 jQuery 提取模板里的变量信息,替换数据
  • 动态拼接 HTML 字符串
  • 将 HTML 字符串赋值给 innerHTML ,触发 DOM 渲染

这种模式在性能方面有很大问题,因为每次执行 innerHTML 都是全量销毁旧的 DOM,再新建 DOM,性能损耗太大。

4. 虚拟 DOM

模板引擎看起来已经很接近 数据驱动视图 的**了,在 HTML 模板里夹杂数据,然后通过 JS 解析模板,再挂载 DOM。只是模版引擎在挂载 DOM 这一步性能消耗大,那既然直接操作 DOM 消耗大,能不能操作个假的 DOM,因为相比修改 DOM,操作一个 JS 对象就快得多。于是就出现了虚拟 DOM:通过一个 JS 对象来描述 DOM,每次有更新变化时,借助 diff 算法来快速找出两个 JS 对象的差异,然后差量更新真实 DOM,这样解决了模板渲染的性能问题。

而虚拟 DOM 除了性能不错外,它还有心智负担小,可维护性强,跨平台的优势。所谓心智负担小,是指我们通过 JS 对象来表示 DOM,不用直接命令式地去维护 DOM 操作的过程;可维护性这个是肯定的,而跨平台是指,通过使用虚拟 DOM 来抽象真实 DOM,我们可以实现一次编码,多端复用。比如同一套虚拟 DOM,在 web 端可以描述 DOM 对象;在 native 端可以表示原生 APP 组件等,这也是 React Native 的应用技术。

虚拟 DOM 的调和过程

我们在 React 写完 JSX 后,会被编译成虚拟 DOM 对象,最后由 ReactDOM.render 渲染成真实 DOM,这个过程有个专门的术语叫 Reconciler (调和)。React 15 采用的是 栈调和,而 React16 之后采用的是 Fiber 调和

React15 栈调和

所谓栈调和,就是借助算法使虚拟 DOM 与真实 DOM 保持一致的过程,这是一个同步递归的过程,其中就用到了 diff 算法。

diff 策略

我们常说的虚拟 DOM,它是一个 JS 对象,从结构上看,它是一棵树。而通常我们对比两棵树的差异,需要 O(n³) 时间复杂度,这个复杂度显然是不可接受的。于是 React 团队想办法降低这个复杂度,其基本思路是:

  • 若组件属于同一类型,通常拥有相同的 DOM 树形结构,因此只在两个节点类型一致,才继续 diff 下去
  • 日常开发中跨层级节点操作很少,因此采用分层对比,对同层级节点进行两两比较
  • 对于同一层级的一组节点,通过设置 key 属性,保持节点在渲染过程中的稳定性,尽可能重用节点

通过以上三个思路,把时间复杂度从 O(n³) 硬是降到了 O(n),所以 diff 算法非常高效。这里再额外说说 key 属性,它的作用到底是什么呢?React 官方是这么定义的:

key 是用来帮助 React 识别哪些内容被更改、添加或者删除。key 需要写在用数组渲染出来的元素内部,并且需要赋予其一个稳定的值。稳定在这里很重要,因为如果 key 值发生了变更,React 则会触发 UI 的重渲染。这是一个非常有用的特性。

如果没有 key 属性,同一层级的节点,如果位置发生了变化,React diff 算法是感知不到的,会把它当做节点新建或删除,然后频繁的销毁重建;而有了 key 属性,相当于给同一层里的每个节点增加了唯一标识,借助这个标识,如果只是交换了两个节点的位置,diff 算法就能识别出来,直接互换节点,而不会去销毁重建节点。

React16 Fiber 调和

既然出现了 Fiber 调和,那就说明栈调和有它的缺陷,它的缺陷就是同步递归过程所带来的不可中断特性。JS 是单线程的,而浏览器是多线程的,浏览器除了 JS 线程外,还有渲染线程,网络线程等等。其中渲染线程与 JS 线程是互斥的,这是因为 JS 可以修改 DOM,所以 JS 主线程在执行的时候,渲染线程是暂停的。这时麻烦就来了,由于栈调和是同步递归,也就是对两颗树进行深度优先遍历,这个过程一旦开始,就停不下来,递归的调用栈会越来越深,那么当面对数据量比较大的虚拟 DOM 时,就会导致主线程长期被霸占着,此时页面 UI 是无法响应的,进而造成卡死状态。

针对栈调和同步递归所带来的问题,React16 引入了新的 Fiber 架构。Fiber 可以理解为「纤维」的意思,它是比进程和线程更小的执行单位。在 Fiber 架构下,一个渲染任务不再是不可中断的一直执行下去,而是分解成多个任务,为此有三个重要概念:可中断, 可恢复优先级

所谓可中断,就是拆分后的任务是一个个的,每个任务有个 优先级 的概念,React16 加入了一个调度器,这个调度器会根据接收到的任务优先级,决定是否推给调和器执行。比如 A 任务来到了调度器,此时 B 任务新加入了,调度器发现 B 任务比 A 任务优先级还高,它就会暂停掉 A 任务,这就是 可中断,然后转而将 B 任务推入执行,当 B 任务执行完后,就会重新讲 A 任务推入执行,这就叫 可恢复

通过这个过程就可以了解到,Fiber 调和异步可中断的,而 栈调和同步递归的,而不管是 Fiber 调和还是栈调和都是在 React 内部的阶段执行,这个阶段对用户来说是无感知的,因此 Fiber 任务才可以中断再恢复执行,这也就是说,某些生命周期可能会被重复执行,比如 componentWillMountcomponentWillUpdatecomponentWillReceivePropsshouldComponentUpdate,所以 Will 开头的生命周期,在 React16 被干掉了,因为这些生命周期可能有 副作用 发生,试想你在里面写了一段付款逻辑,然后由于 Fiber 可中断可恢复的特性,同一段付款逻辑被执行了两次,这肯定是不行的。

总结

  • 虚拟 DOM 是在权衡了多种操作 DOM 方案后,留下的较好方案
  • 虚拟 DOM 到真实 DOM 的过程,叫 调和。调和分为旧的栈调和和 Fiber 架构下的 Fiber 调和。栈调和是同步递归的过程,而 Fiber 调和是异步可中断的最小化渲染。
  • 调和过程有个关键的 diff 算法,这个算法高效在于 分层对比只比较同一类型节点 以及 key 属性
  • 虚拟 DOM 中 key 属性的作用是让 React 识别出节点被修改,添加或删除,目的是为了尽可能地重用节点,避免频繁的销毁重建
  • React 的生命周期分为三个阶段:renderpre-commitcommit ,而调和是发生在 render 阶段,这个阶段对用户来说是无感知的,所以才可以实现中断又恢复执行,这也导致某些生命周期会被重复执行,因此 React16 废弃了一些生命周期,新增了几个更安全的生命周期替代。

JS 之闭包

闭包的定义

引用《你不知道的 JavaScript》中的定义:

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前作用域外执行的。

上面提到了词法作用域,那什么是词法作用域呢?

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

理解闭包,首先要理解执行上下文和作用域链。先从一个例子看起:

function foo() {
    let name = 'hello'
    return function bar() {
        debugger
        console.log(name)
    }
}
const baz = foo()
baz()

打开 Chrome 控制台,执行这段代码,然后点击 Source 面板,会看到:
image

左边圈出来的是调用栈,里面有 bar 执行上下文还有 anoymous 也就是全局执行上下文;
右边圈出来的极速作用域链,其中 Local 就是当前执行函数(baz)的作用域,Closure 就是 foo 函数的闭包了,Script 表示全局词法作用域里含有 baz,最后一个 Global 就是 window 啦。我们知道在全局代码里通过 var 定义的变量会有全局作用域也就是挂载到最后的 Global 里,而 ES6 的 const 和 let 具有块级作用域,所以通过 const 声明的 baz 变量是放在 Script 而非 Global 里。

这个例子很简单,foo 函数返回了一个 bar 函数, bar 里保持了对 foo 中 name 变量的引用。当执行 baz() 这句代码的时候,内层函数 bar 在全局作用域里执行,此时 foo 函数已经返回了,那为啥还能访问到 name 变量呢?

首先要看下这段代码是怎么执行的:首先会初始化全局上下文,里面的变量对象包含 this 还有 foo 标识符。当执行到 foo 函数后,就创建了 foo 函数的执行上下文,推入执行上下文栈顶,然后初始化 foo 函数的活动对象,this 还有作用域链。作用域链是由内部属性 [[scope]] 保存的,此时 foo 函数的作用域链如下:

[foo 的 AO,全局上下文的 VO]

foo 函数执行完后就出栈了,接着就执行 bar 函数,创建 bar 函数的执行上下文,推入执行上下文栈顶,然后初始化 bar 函数的活动对象,创建 bar 函数的作用域链,此时 bar 的作用域链如下:

[bar 的 AO,foo 的 AO,全局上下文的 VO]

接下来引擎遇到 console.log(name),发现要查找 name 变量,于是沿着作用域链,先从当前 bar 当前的执行上下文的活动对象里找,发现没有这个变量,于是往上去到 foo 函数的活动对象里去找,找到了 name 为「张三」。然后 bar 函数也执行完毕出栈,最后全局执行上下文出栈,整段代码就执行完毕了。

上面这个过程里,搜索变量 name 的过程就体现了闭包的特性:虽然此时 foo 函数已经出栈,执行上下文被垃圾回收了,但是它对应的活动对象并不会被销毁,因为内部函数 bar 还保留着对它的引用,所以 JS 引擎依然将 foo 函数的活动对象保存在内存堆中,这就是 foo 函数的闭包。

这里还要提一个词法作用域的概念:词法作用域就是函数的作用域是由它定义的位置决定的,而不是调用时决定。根据词法作用域的规则,内部函数总是可以访问外部函数作用域里的变量,所以即使 bar 函数里没有声明 name,这段代码也不会报错,引擎执行的时候会沿着作用域往上一层去查找。

闭包的应用

基于 JS 闭包的特性,可以说很多地方都不知不觉应用了闭包:定时器,事件监听回调,Ajax 请求等等。在使用闭包的时候要注意 内存泄露 的问题,因为稍不小心,一旦被当做闭包,就会一直保留在内存中,垃圾回收不会处理掉它,这对内存是一种负担。所以在使用全局变量的时候,记得及时解除变量引用,以便让垃圾回收器回收掉它。

一道经典的闭包应用题:

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i)
  }, 1000)
}

执行后发现输出都为 5。这是因为for 循环同步执行完后,此时全局上下文的变量对象里, i 的值是 5。而 setTimeout 匿名函数上下文里引用了 i 变量,根据作用域的查找规则,匿名函数的活动对象里并没有 i ,所以会去到全局上下文的变量对象里去找,于是找到了 i 的值为 5。
解决方法有几种:

  • 将 var 改为 let,这主要借助 ES6 let 的块级作用域特性,有了 let 后,i 就不是全局变量了,而是每次循环都会创建一个作用域块
  • 使用立即执行函数包裹,将 for 循环里的 i 作为参数传入。这就相当于在全局上下文和 setTimeout 匿名函数上下文里加上了一层立即执行函数作用域,里面可以找到 i

彩蛋

通过下面这个代码片段,理解 JS 中的词法作用域:

function bar() {
    console.log(myName)
}
function foo() {
    var myName = "hello world"
    bar()
}
var myName = "hello JavaScript"
foo()

执行 foo,最终打印的是什么?

CSS flex 布局

flex 布局应该是现在开发中用到最多的了,它是一种一维布局,当设置 display: flex | inline-flex 后,就可以把元素看做是一个容器,容器里的每一项称作 flex 子项。对于 flex 来说,有 主轴交叉轴 两个概念,主轴就是水平方向上的横线,而交叉轴垂直于主轴,因而是垂直方向上的。其中主轴可通过 flex-direction 属性设置。

flex 属性

flex 的属性可以分为两类,一类是作用于整个 容器 的,另一类是作用于容器里的 每一个子项 的。作用于容器的属性主要有:

flex-direction

控制容器的布局方向,是从左往右,还是从右往左或从上往下,主要取值有:

  • row: 默认值,按正常文档流方向是从左往右排列元素
  • row-reverse: 与 row 方向相反
  • column: 把元素显示成列,有点类似 block 的效果
  • column-reverse: 方向与 column 相反

flex-wrap

控制子元素是单行显示还是换行显示。取值有:

  • no-wrap: 默认值,不换行
  • wrap: 换行显示
  • wrap-reverse: 换行显示,且从下往上排列元素

flex-flow

这个属性是flex-directionflex-wrap的缩写,如:flex-flow: row wrap

justify-content

控制水平方向上元素的对齐和排列方式,主要取值有:

  • flex-start: 按正常文档流,表现为左对齐
  • flex-end: 按正常文档流,表现为右对齐
  • center: 居中对齐,这个平时开发最常用
  • space-between: 两端对齐,左右两侧不留间隙
  • space-around: 类似两端对齐,但是左右两侧是留有空间的,且空间是中间空闲部分的一半
  • space-evenly: 每个子元素左右两侧均匀平分

align-items

控制垂直方向上元素的对齐和排列方式,主要取值有:

  • stretch: 子项高度拉伸
  • flex-start: 按正常文档流,表现为顶部对齐
  • flex-end: 按正常文档流,表现为底部对齐
  • center: 居中对齐,常用于垂直居中
  • baseline: 所有子元素相对于基线对齐,所谓基线就是指字母 x 的下边缘

align-content

这个属性与 align-items 的区别在于 多行,它控制的是垂直方向上每一行子项的对齐和排列方式。主要取值有:

  • stretch: 默认值,每一行都等比例拉伸
  • flex-start: 多个元素顶部对齐
  • flex-end: 多个元素底部对齐
  • center: 整体垂直居中
  • space-between: 上下两行两端对齐,中间元素平分
  • space-around: 每一行元素上下都有独立不重叠的空间
  • space-evenly: 每一行元素上下平分

作用于 flex 子项的属性有:

order

这个属性主要是改变子元素的排列位置,取值是整数,默认为 0,排序按从小到大来排。比如 order 为 -1 要比 order 为 1 靠前。

order: 0
order: -1

flex-grow

这个属性用来规定每个子项是否扩展剩余空间,取值可以是小数也可以是整数,默认是 0,表示即使存在剩余空间也不扩展。

flex-grow: 1

flex-shrink

规定当剩余空间不足的时候,子项是否收缩。它的取值是数值,默认是 1,表示收缩,如果设置 0 ,那它就显示这个子项应有的宽度,不会收缩。

flex-shrink: 0

flex-basis

定义每个子元素的初始大小,默认取值是 auto,意思是有设置宽度就按设置的来,没有就按元素实际占据的空间显示。

flex

这个属性是flex-growflex-shrinkflex-basis的缩写,也是平时开发经常用的,一般推荐使用缩写属性,让浏览器去自动计算其它值,而不是显式指定每一个 flex 属性。flex 的几个取值参考下方 "应用" 一节

align-self

控制某个子项垂直方向单独的对齐方式,它的取值与 align-items 一样,只不过一个是针对所有 items,一个是针对单独的 item。

align-self: auto | flex-start | flex-end | center | baseline | stretch

flex 应用

使用 flex ,主要就是对 flex 这个属性的几个值了解熟悉。下面列举了 flex 的几个常用值:

  • flex: initial: 等同于 flex: 0 1 auto 是 flex 属性的默认值。它表示有剩余空间也不扩展元素,但是空间不足时会收缩元素,至于元素尺寸,则自适应实际占据的内容宽度。适用于一侧宽度固定,另一侧内容自动的两栏自适应布局
  • flex: 0:等同于 flex: 0 1 0%,表现为元素不会扩展,但空间不足时会收缩,尺寸大小为最小内容宽度,效果就是文字竖排成一列,挤在一起。
  • flex: none 等同于 flex: 0 0 auto,表现为元素既不会收缩也不会扩展,元素尺寸为最大内容宽度。设置后的效果是:元素会无视容器宽度显示,不换行的一直显示到底,因此可能会溢出容器。它比较适合一些失去弹性的场景,比如一个列表,左侧是文字图片,右侧是一个按钮,我们希望这个按钮里的文字不要换行,且不被左边积压,就可以对按钮设置 flex: none
  • flex: 1:等同于 flex: 1 1 0%,表现为元素既可以扩展,也可以收缩,元素尺寸大小表现为最小内容宽度,也就是当宽度不足时,文字会换行显示。这个适用于 等分布局
  • flex: auto: 等同于 flex: 1 1 auto,表现为元素既可以扩展,也可以收缩,当内容空间不足时,元素会向外扩展,占据更多空间,也就是会抢占其它子项的空间。它的应用场景:网页导航栏,因为导航栏经常是几个子项,但是每个子项里的文字数量不固定,使用 flex:auto 就可以实现按照内容自动分配宽度。

JS 实现继承的几种方式

在《JS 高级程序设计》这本书里列举了 JS 实现继承的多种方式以及对应的优缺点。

原型链继承

原型链继承通过 new 操作符将子类的原型和父类的原型链接起来。代码示例如下:

function Parent() {
  this.name = 'parent'
}
Parent.prototype.getName = function() {
  console.log(this.name)
}
function Child() {
}
Child.prototype = new Parent()
var child = new Child()
console.log(child.getName)  // parent

缺点:

  • 引用类型的属性在所有实例间共享,修改一个实例值影响其它
  • 创建子类实例时不能向父类传参

构造函数继承

通过在子类构造函数借用 call 调父类来实现,代码如下:

function Parent() {
  this.name = 'parent'
}

function Child() {
  Parent.call(this)
}
var child = new Child()
console.log(child.name)  // parent

缺点:

  • 每次创建实例都会创建一遍构造函数中的方法
  • 不能做到属性方法共享

组合继承

把原型链继承和构造函数继承组合起来了:

function Parent(name) {
  this.name = name
}
Parent.prototype.getName = function() {
  console.log(this.name)
}
function Child(name, age) {
  Parent.call(this)
  this.age = age
}
Child.prototype = new Parent()
Child.prototype.constructor = Child
var child = new Child('child', 18)
console.log(child.name)  // child
console.log(child.age)  // 18

缺点:

  • 调用了两次父类

原型式继承

这种继承就是 ES5 Object.create 的模拟实现:

function create(o) {
  function F() {}
  F.prototype = 0
  return new F()
}

缺点:

  • 与原型链一样,引用类型属性的值始终会在实例间共享

寄生式继承

创建一个用于实现继承的函数,函数内部新建一个对象,以某种形式增强对象后返回它,代码如下:

function createObj(o) {
  var clone = Object.create(o)
  clone.sayName = function() {
    console.log('say hi')
  }
  return clone
}

缺点:

  • 每次调用都会创建一遍方法

寄生组合式继承

这是对 组合继承 的改进,是一种完美实现 JS 继承的解决方案。组合继承最大的缺点在于调用了两次父类构造函数:

// 第一次
Child.prototype = new Parent()
// 第二次
Parent.call(this)

这带来的问题是子类实例和子类构造函数的 prototype 上会有多余的属性。为了精益求精,避免两次调用所带来的浪费,解决方法是不让子类原型与父类实例挂钩,而是让子类原型间接地访问到父类原型,这其中的关键就是前面的 原型式继承:通过一个空函数来中转,让子类原型直接去 new 空函数,而空函数的原型又是父类,这样就间接访问到了父类原型。寄生组合式的代码示例如下:

function Parent(name) {
  this.name = name
}
Parent.prototype.getName = function () {
  console.log(this.name)
}

function Child(name, age) {
  Parent.call(this, name)
  this.age = age
}

function inherit(child, parent) {
  function F() {}
  F.prototype = parent.prototype
  child.prototype = new F()
}
inherit(Child, Parent)
Child.prototype.constuctor = Child

var child = new Child('child', 18)
console.log(child)   // {name: 'child',  age: 18}

寄生组合式继承是比较完美的继承方式。

计算机网络之 TCP 与 UDP

定义

这两个都是传输层的协议,位于 OSI 七层协议的第四层。
TCP (Transmission Control Protocol )传输控制协议,是面向连接的,保证数据可靠传输的协议,具有超时重传、拥塞控制等功能。
image

UDP(User Datagram Protocol)用户报文协议,是面向报文的无连接传输层协议。
image

TCP 三次握手

  • 首先,客户端发送一个 SYN 报文给服务器,里面包含初始序列号(例如:x),此时客户端处于 SYN_SEND 状态
  • 服务端收到报文后,如果同意建立连接,就回复 SYN 包,设置 ACK 标志位,给客户端的序列号 x + 1,并选择一个初始序列号 y,此时服务端处于 SYN_RECV 状态
  • 客户端收到后,回复一个 ACK 标志的报文给服务端。这个报文里会给服务端的初始序列号 y + 1。此时客户端处于 ESTABLISHED 状态。服务端收到 ACK 报文后,也会进入 ESTABLISHED 状态,三次握手完成。

由此延伸出来的问题:

  • 为什么需要三次握手,两次不可以吗?
    三次握手是为了保证客户端和服务端两方都知道对方既能收又能发。
    如果只有两次的话,服务端收到客户端的报文后,回一个 ack 给客户端,此时服务端认为请求已经建立了,可以发送数据了。但是如果网络出问题,服务端的应答报文并没有到客户端那边,此时客户端不知道服务端的情况,它会忽略服务端发来的其它报文,一直等待确认报文,而服务端以为已经建立连接了,一直发送数据,此时就可能产生死锁。
  • 第三次握手如果客户端发送的 ACK 报文因为网络问题丢包,服务端一直没有收到怎么办?
    服务端会有一个超时重传计时器,如果在一定时间内没有收到客户端发的报文,服务端会重传自己的 SYN + ACK 给客户端,客户端这边收到后,就会重发 ACK 给服务端,直到服务端这边收到 ACK 报文为止。

TCP 四次挥手

  • 首先,客户端发送 Fin 报文给服务端
  • 服务端收到后,回复一个 ACK 报文给客户端,表示服务端已经知道客户端要关闭了,但此时服务端可能还有待发送数据。
  • 等服务端处理完后,就发送一个 FIN 报文给客户端
  • 最后客户端再回复一个 ACK 给服务端,此时客户端需要等待 2MSL 的报文生存时间,然后才断开。服务端收到客户端的报文后就断开了连接
    由此延伸出来的问题:
  • 为什么要等待 2MSL 才进入 close 状态?
    因为我们必须假设网络是不可靠的,可能最后一个 ack 报文丢失了。TIME_WAIT 状态就是为了出现错误,重发丢失的报文。比如服务端一直没有收到 ack,那它就会一直重发 FIN 报文,而客户端收到后,就会回复 ACK ,然后继续等待 2MSL 的报文生存时间,如果过了后没有收到服务端的报文,客户端就推断服务端已经成功接收了,然后就可以进入 close 状态了。
  • 如果连接建立了,但是中途客户端出现故障了怎么办?
    TCP 有设置一个计时器,服务端每次收到客户端的报文都会复位这个计时器,如果发现一直没有客户端的报文,服务端会发送一个探测报文段,每隔 75s 发送一次,如果连续 10 次都没有回应,服务端就认为客户端出问题了,然后就可以断开连接。

TCP 的拥塞控制

拥塞控制就是当网络出现拥堵时,通过调整发往网络中的数据包来减少拥堵,它作用的是整个网络。拥塞控制的关键是控制发送方的发送速率——拥塞窗口大小,主要有以下四个算法:

  1. 慢启动: 刚开始发送时,拥塞窗口大小(cwnd)设置为一个较小的值(通常是初始大小,1-3个最大报文段),随后每收到一个 ACK 报文,拥塞窗口大小会加倍,这样发送速率是呈指数增长的,当到达慢启动闸值时,就进入拥塞避免算法。
  2. 拥塞避免:在这个阶段,为了避免窗口的快速扩张,每经过一个往返时间 RTT,拥塞窗口大小增加 1 个最大报文段,相当于将发送速率由指数增长调整为线性增长,这样保持直到网络拥塞或者数据传输完成。
  3. 快重传:这个算法用于网络拥塞时快速检测丢失的报文段,它规定当发送方连续收到三个相同的确认报文段时,客户端就会立即重传丢失的报文段,而不用等待超时重传计时器到期。这个机制一定程度上可以减少报文段重传的延迟。
  4. 快恢复:当发送方处于快重传模式时,它会将慢启动闸值设置为当前拥塞窗口大小的一半,并将拥塞窗口大小设置为新的慢启动闸值加上三个最大报文段。此时发送方开始重新传输丢失报文段,并根据收到的重复确认报文段来调整拥塞窗口的大小。当发送方收到新的报文段时,就退出快恢复模式,重新进入拥塞避免阶段。

TCP 的流量控制

流量控制的目的在于防止发送方速率过快,接收端处理不过来,是为了平衡发送方和接收方的数据处理能力。实现流量控制的关键是滑动窗口

  1. 接收窗口(Receive Window):接收方为每个连接分配一个接收缓冲区,并通过 TCP 报文段的窗口字段(Window Field)告知发送方当前可用的缓冲区大小。这个值被称为接收窗口(rwnd),用于限制发送方发送数据的速率。接收窗口的大小可以根据接收方处理能力和网络状况进行动态调整。
  2. 滑动窗口(Sliding Window):发送方需要根据接收方提供的接收窗口大小(rwnd)和本地拥塞窗口大小(cwnd)来确定实际的发送窗口。实际发送窗口大小取 rwnd 和 cwnd 中的较小值。发送方只能发送发送窗口内未确认的数据。当发送方收到确认报文时,滑动窗口会向前移动,从而允许发送更多数据。
  3. 窗口更新:当接收方处理完接收缓冲区中的数据后,它需要发送一个新的确认报文,更新窗口字段,以通知发送方可以发送更多数据。这个过程被称为窗口更新。接收方可以根据自己的处理速度和网络状况,动态调整接收窗口大小,从而影响发送方的发送速率。
  4. 零窗口探测:在某些情况下,接收方的处理速度较慢,导致接收窗口变为零。这意味着发送方需要暂停数据发送。当接收方处理完缓冲区内的数据后,它会发送一个窗口更新报文,通知发送方恢复数据发送。为了防止发送方长时间处于停止发送状态,TCP 引入了零窗口探测机制。当发送方收到零窗口更新后,它会周期性地发送探测报文,以检测接收窗口是否恢复。

TCP 的应用

  • FTP 文件传输
  • HTTP/HTTPS

UDP 的特点和应用

  • 无连接,面向报文
  • 不保证消息交付(没有重传超时机制)
  • 不保证交付顺序(意味着不会发生队头阻塞)
  • 不跟踪连接状态(不必像 TCP 那样三次握手)
  • 没有拥塞控制(有啥发啥,不管结果)

UDP 因为以上特点,适用于一些对延迟要求较高的场景,比如直播、视频和音频等多媒体通信

总结

TCP 和 UDP 的对比:

区别 TCP UDP
连接 需要三次握手建立连接 无连接
数据传输方式 字节流 报文
可靠性 ack 确认机制,超时重传,拥塞控制 不可靠,以恒定的速度发数据
连接数 一对一 一对一,一对多,多对多
首部大小 大概 20 ~ 60 字节 8 字节,开销小
使用场景 文件传输 实时通讯,视频会议,直播等

React 和 Vue 的比较

React 的特点

  • Facebook 出品,偏运行时的框架
  • 使用 JSX 语法来书写模板
  • 虚拟 DOM
  • 单向数据流
  • 类组件和函数式组件,React Hooks
  • 周边生态丰富,React 全家桶

从学习曲线看,React 需要有完备的 JavaScript (ES6) 基础知识,同时要了解函数式编程的概念,以及学习 React 配套的一系列库才能更好的发挥 React 的优势。

Vue 的特点

  • 尤雨溪 个人开发,目前有个官方团队在维护,运行时 + 编译时的框架
  • 使用类 HTML 模板来描述 UI
  • 虚拟 DOM
  • 响应式系统非常强大
  • 单文件组件, js 逻辑,样式和模板都在同一文件里
  • 轻量,整个 Vuejs 打包后体积非常小,20 几 k 左右

从学习曲线看,Vue 对前端新手来说更友好,直接可以上手开发,语法简单,实现业务功能方便快捷。

React VS Vue

从框架设计层面上看:

  • React 背靠 Facebook 这样的大公司,它的诞生就是提出 UI 开发的新思路,更像是打好了地基,然后引导用户在这基础上去创造延伸出更多玩法。这点从 JSX 语法描述 UI 可以看出,它需要你掌握一定的 JS 基础。
  • Vue 的定位就是尽可能降低前端开发门槛,用户只需掌握基本的 HTML,CSS 和 JavaScript 就可以快速搭建出应用。Vue 在设计上更清晰于「多为用户做些事情」,例如数据改变时,UI 会自动刷新;而在 React 里,你需要手动调用 setState 等 API 来告诉 React 需要重新渲染了。

从数据逻辑层面:
两者都是采用单向数据流,只不过 Vue 在此基础上搞了个 v-model 的语法糖来实现数据双向绑定。Vue 的思路是去拦截数据的读写操作,来实现更好更快地渲染;而 React 推崇函数式,UI = render(data) ,你给一份数据,然后自己调用 API 进行局部刷新。

在逻辑复用层面,这两者都是从 Mixins --> Hoc --> Render props --> Hooks 的过程

React Vue
UI 描述 JSX 类 HTML 的模板语法
数据流 单向 单向 + 数据双绑的语法糖
逻辑复用 Hoc & Render props & Hooks Mixins & Slot & Hooks
渲染 虚拟 DOM + dff 虚拟 DOM + diff
定位 有 JS 基础的开发者 前端小白

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.