21 如何利用 JavaScript 实现经典数据结构?
前面几讲我们从编程思维的角度分析了软件设计哲学。从这一讲开始,我们将深入数据结构这个话题。
数据结构是计算机中组织和存储数据的特定方式,它的目的是方便且高效地对数据进行访问和修改。数据结构体现了数据之间的关系,以及操作数据的一系列方法。数据又是程序的基本单元,因此无论哪种语言、哪种领域,都离不开数据结构;另一方面,数据结构是算法的基础,其本身也包含了算法的部分内容。也就是说,想要掌握算法,有一个坚固的数据结构基础是必要条件。
下面我们用 JavaScript 实现几个常见的数据结构。
数据结构介绍
我通常将数据结构分为八大类。
-
数组:Array
-
堆栈:Stack
-
队列:Queue
-
链表:Linked Lists
-
树:Trees
-
图:Graphs
-
字典树:Trie
-
散列表(哈希表):Hash Tables
我们可以先大体感知一下各种数据结构之间的关系:
-
栈和队列是类似数组的结构,非常多的初级题目要求用数组实现栈和队列,它们在插入和删除的方式上和数组有所差异,但是实现还是非常简单的;
-
链表、树和图这种数据结构的特点是,其节点需要引用其他节点,因此在增/删时,需要注意对相关前驱和后继节点的影响;
-
可以从堆栈和队列出发,构建出链表;
-
树和图最为复杂,但它们本质上扩展了链表的概念;
-
散列表的关键是理解散列函数,明白依赖散列函数实现保存和定位数据的过程;
-
直观上认为,链表适合记录和存储数据;哈希表和字典树在检索数据以及搜索方面有更大的应用场景。
以上这些“直观感性”的认知并不是“恒等式”,我们将在下面的学习中去印证这些“认知”,这两讲中,你将会看到熟悉的 React、Vue 框架的部分实现,将会看到典型的算法场景,也请你做好相关基础知识的储备。
堆栈和队列
栈和队列是一种操作受限的线性结构,它们非常简单,虽然 JavaScript 并没有原生内置这样的数据结构,但是我们可以轻松地模拟出来。
栈的实现,后进先出 LIFO(Last in、First out):
class Stack {
constructor(...args) {
// 使用数组进行模拟
this.stack = [...args]
}
push(...items) {
// 入栈
return this.stack.push(... items)
}
pop() {
// 出栈,从数组尾部弹出一项
return this.stack.pop()
}
peek() {
return this.isEmpty()
? undefined
: this.stack[this.size() - 1]
}
isEmpty() {
return this.size() == 0
}
size() {
return this.stack.length
}
}
队列的实现,先进先出 FIFO(First in、First out),“比葫芦画瓢”即可:
class Queue {
constructor(...args) {
// 使用数组进行模拟
this.queue = [...args]
}
enqueue(...items) {
// 入队
return this.queue.push(... items)
}
dequeue() {
// 出队
return this.queue.shift()
}
front() {
return this.isEmpty()
? undefined
: this.queue[0]
}
back() {
return this.isEmpty()
? undefined
: this.queue[this.size() - 1]
}
isEmpty() {
return this.size() == 0
}
size() {
return this.queue.length
}
}
我们可以看到不管是栈还是队列,都是用数组进行模拟的。数组是最基本的数据结构,但是它的价值是惊人的。我们会在下一讲,进一步介绍栈和队列的应用场景。
链表(单向链表和双向链表)
堆栈和队列都可以利用数组实现,链表和数组一样,也实现了按照一定的顺序存储元素,不同的地方在于链表不能像数组一样通过下标访问,而是每一个元素都能够通过“指针”指向下一个元素。我们可以直观地得出结论:链表不需要一段连续的存储空间,“指向下一个元素”的方式能够更大限度地利用内存。
根据上述内容,我们可以总结出链表的优点在于:
-
链表的插入和删除操作的时间复杂度是常数级的,我们只需要改变相关节点的指针指向即可;
-
链表可以像数组一样顺序访问,查找元素的时间复杂度是线性的。
要想实现链表,我们需要先对链表进行分类,常见的有单链表和双向链表。
-
单链表:单链表是维护一系列节点的数据结构,其特点是:每个节点包含了数据,同时包含指向链表中下一个节点的指针。
-
双向链表:不同于单链表,双向链表特点:每个节点分支除了包含其数据以外,还包含了分别指向其前驱和后继节点的指针。
首先,根据双向链表的特点,我们实现一个节点构造函数(节点类),如下代码:
class Node {
constructor(data) {
// data 为当前节点储存的数据
this.data = data
// next 指向下一个节点
this.next = null
// prev 指向前一个节点
this.prev = null
}
}
有了节点类,我们来初步实现双向链表类,如下代码:
class DoublyLinkedList {
constructor() {
// 双向链表开头
this.head = null
// 双向链表结尾
this.tail = null
}
// ...
}
接下来,需要实现双向链表原型上的一些方法,这些方法包括以下几种。
-
add:在链表尾部添加一个新的节点,实现如下代码:
add(item) {
// 实例化一个节点
let node = new Node(item)
// 如果当前链表还没有头
if(!this.head) {
this.head = node
this.tail = node
}
// 如果当前链表已经有了头,只需要在尾部加上该节点
else {
// 把当前的尾部作为新节点的 prev
node.prev = this.tail
// 把当前的尾部的 next 指向为新节点 node
this.tail.next = node
this.tail = node
}
}
-
addAt:在链表指定位置添加一个新的节点,实现如下代码:
addAt(index, item) {
let current = this.head
// 维护查找时当前节点的索引
let counter = 1
let node = new Node(item)
// 如果在头部插入
if (index === 0) {
this.head.prev = node
node.next = this.head
this.head = node
}
// 非头部插入,需要从头开始,找寻插入位置
else {
while(current) {
current = current.next
if( counter === index) {
node.prev = current.prev
current.prev.next = node
node.next = current
current.prev = node
}
counter++
}
}
}
-
remove:删除链表指定数据项节点,实现如下代码:
remove(item) {
let current = this.head
while (current) {
// 找到了目标节点
if (current.data === item ) {
// 目标链表只有当前目标项,即目标节点即是链表头又是链表尾
if (current == this.head && current == this.tail) {
this.head = null
this.tail = null
}
// 目标节点为链表头
else if (current == this.head ) {
this.head = this.head.next
this.head.prev = null
}
// 目标节点为链表尾部
else if (current == this.tail ) {
this.tail = this.tail.prev;
this.tail.next = null;
}
// 目标节点在链表首尾之间,中部
else {
current.prev.next = current.next;
current.next.prev = current.prev;
}
}
current = current.next
}
}
-
removeAt:删除链表指定位置节点,实现如下代码:
removeAt(index) {
// 都是从“头”开始遍历
let current = this.head
let counter = 1
// 删除链表头部
if (index === 0 ) {
this.head = this.head.next
this.head.prev = null
}
else {
while(current) {
current = current.next
// 如果目标节点在链表尾
if (current == this.tail) {
this.tail = this.tail.prev
this.tail.next = null
}
else if (counter === index) {
current.prev.next = current.next
current.next.prev = current.prev
break
}
counter++
}
}
}
-
reverse:翻转链表,实现如下代码:
reverse() {
let current = this.head
let prev = null
while (current) {
let next = current.next
// 前后倒置
current.next = prev
current.prev = next
prev = current
current = next
}
this.tail = this.head
this.head = prev
}
-
swap:交换两个节点数据,实现如下代码:
swap(index1, index2) {
// 使 index1 始终小于 index2,方便后面查找交换
if (index1 > index2) {
return this.swap(index2, index1)
}
let current = this.head
let counter = 0
let firstNode
while(current !== null) {
// 找到第一个节点,先存起来
if (counter === index1 ){
firstNode = current
}
// 找到第二个节点,进行数据交换
else if (counter === index2) {
// ES 提供了更为简洁的交换数据的方式,这里我们用传统方式实现,更为直观
let temp = current.data
current.data = firstNode.data
firstNode.data = temp
}
current = current.next
counter++
}
return true
}
-
isEmpty:查询链表是否为空,实现如下代码:
isEmpty() {
return this.length() < 1
}
-
length:查询链表长度,实现如下代码:
length() {
let current = this.head
let counter = 0
// 完整遍历一遍链表
while(current !== null) {
counter++
current = current.next
}
return counter
}
-
traverse:遍历链表,实现如下代码:
traverse(fn) {
let current = this.head
while(current !== null) {
// 执行遍历时回调
fn(current)
current = current.next
}
return true
}
如上代码,有了上面 length 方法的遍历实现,traverse 也就不难理解了,它接受一个遍历执行函数,在 while 循环中进行调用。
-
find:查找某个节点的索引,实现如下代码:
find(item) {
let current = this.head
let counter = 0
while( current ) {
if( current.data == item ) {
return counter
}
current = current.next
counter++
}
return false
}
至此,我们就实现了所有双向链表(DoublyLinkedList)的方法。仔细分析整个实现过程,你可以发现:双向链表的实现并不复杂,在手写过程中,需要开发者做到心中有“表”,考虑到当前节点的 next 和 prev 取值,逻辑上还是很简单的。
树
前端开发者应该对树这个数据结构丝毫不陌生,不同于之前介绍的所有数据结构,树是非线性的。因为树决定了其存储的数据直接有明确的层级关系,因此对于维护具有层级特性的数据,树是一个天然良好的选择。
事实上,树有很多种分类,但是它们都具有以下特性:
-
除了根节点以外,所有的节点都有一个父节点;
-
每一个节点都可以有若干子节点,如果没有子节点,则称此节点为叶子节点;
-
一个节点所拥有的叶子节点的个数,称之为该节点的度,因此叶子节点的度为 0;
-
所有节点中,最大的度为整棵树的度;
-
树的最大层次称为树的深度。
我们这里对二叉搜索树展开分析。二叉树算是最基本的树,因为它的结构最简单,每个节点至多包含两个子节点。二叉树又非常有用,因为根据二叉树,我们可以延伸出二叉搜索树(BST)、平衡二叉搜索树(AVL)、红黑树(R/B Tree)等。
二叉搜索树有以下特性:
-
左子树上所有结点的值均小于或等于它的根结点的值;
-
右子树上所有结点的值均大于或等于它的根结点的值;
-
左、右子树也分别为二叉搜索树。
根据其特性,我们实现二叉搜索树还是应该先构造一个节点类,如下代码:
class Node {
constructor(data) {
this.left = null
this.right = null
this.value = data
}
}
然后我们实现二叉搜索树的以下方法。
-
insertNode:根据一个父节点,插入一个子节点,如下代码:
insertNode(root, newNode) {
// 根据待插入节点的值的大小,递归调用 this.insertNode
if (newNode.value < root.value) {
(!root.left) ? root.left = newNode : this.insertNode(root.left, newNode)
} else {
(!root.right) ? root.right = newNode : this.insertNode(root.right, newNode)
}
}
-
insert:插入一个新节点,如下代码:
insert(value) {
let newNode = new Node(value)
// 判读是否是根节点
if (!this.root) {
this.root = newNode
} else {
// 不是根结点,则直接调用 this.insertNode 方法
this.insertNode(this.root, newNode)
}
}
理解这两个方法是理解二叉搜索树的关键,如果你理解了这两个方法,下面的其他方法也就“不在话下”。
我们可以看到,insertNode 方法先判断目标父节点和插入节点的值,如果插入节点的值更小,则考虑放到父节点的左边,接着递归调用 this.insertNode(root.left, newNode);如果插入节点的值更大,以此类推即可。insert 方法只是多了一步构造 Node 节点实例,接下来区分有无父节点的情况,调用 this.insertNode 方法即可。
-
removeNode:根据一个父节点,移除一个子节点,如下代码:
removeNode(root, value) {
if (!root) {
return null
}
if (value < root.value) {
root.left = this.removeNode(root.left, value)
return root
} else if (value > root.value) {
root.right = tis.removeNode(root.right, value)
return root
} else {
// 找到了需要删除的节点
// 如果当前 root 节点无左右子节点
if (!root.left && !root.right) {
root = null
return root
}
// 只有左节点
if (root.left && !root.right) {
root = root.left
return root
}
// 只有右节点
else if (root.right) {
root = root.right
return root
}
// 有左右两个子节点
let minRight = this.findMinNode(root.right)
root.value = minRight.value
root.right = this.removeNode(root.right, minRight.value)
return root
}
}
-
remove:移除一个节点,如下代码:
remove(value) {
if (this.root) {
this.removeNode(this.root, value)
}
}
// 找到最小的节点
// 该方法不断递归,直到找到最左叶子节点即可
findMinNode(root) {
if (!root.left) {
return root
} else {
return this.findMinNode(root.left)
}
}
上述代码不难理解,唯一需要说明的是:当需要删除的节点含有左右两个子节点时,因为我们要把当前节点删除,就需要找到合适的“补位”节点,这个“补位”节点一定在该目标节点的右侧树当中,因为这样才能保证“补位”节点的值一定大于该目标节点的左侧树所有节点,而该目标节点的左侧树不需要调整;同时为了保证“补位”节点的值一定要小于该目标节点的右侧树值,因此要找的“补位”节点其实就是该目标节点的右侧树当中最小的那个节点。
-
searchNode:根据一个父节点,查找子节点,如下代码:
searchNode(root, value) {
if (!root) {
return null
}
if (value < root.value) {
return this.searchNode(root.left, value)
} else if (value > root.value) {
return this.searchNode(root.right, value)
}
return root
}
-
search:查找节点,如下代码:
search(value) {
if (!this.root) {
return false
}
return Boolean(this.searchNode(this.root, value))
}
-
preOrder:前序遍历,如下代码:
preOrder(root) {
if (root) {
console.log(root.value)
this.preOrder(root.left)
this.preOrder(root.right)
}
}
-
InOrder:中序遍历,如下代码:
inOrder(root) {
if (root) {
this.inOrder(root.left)
console.log(root.value)
this.inOrder(root.right)
}
}
-
PostOrder:后续遍历,如下代码:
postOrder(root) {
if (root) {
this.postOrder(root.left)
this.postOrder(root.right)
console.log(root.value)
}
}
上述前、中、后序遍历的区别其实就在于console.log(root.value) 方法执行的位置。
图
图是由具有边的节点集合组成的数据结构,图可以是定向的或不定向的。图也是应用最广泛的数据结构之一,真实场景中处处有图。当然更多概念还是需要你先进行了解,尤其是图的几种基本元素。
-
节点:Node
-
边:Edge
-
|V|:图中顶点(节点)的总数
-
|E|:图中的连接总数(边)
这里我们主要实现一个有向图,Graph 类,如下代码:
class Graph {
constructor() {
// 使用 Map 数据结构表述图中顶点关系
this.AdjList = new Map()
}
}
我们先通过创建节点,来创建一个图,如下代码:
let graph = new Graph();
graph.addVertex('A')
graph.addVertex('B')
graph.addVertex('C')
graph.addVertex('D')
-
添加顶点:addVertex,如下代码:
addVertex(vertex) {
if (!this.AdjList.has(vertex)) {
this.AdjList.set(vertex, [])
} else {
throw 'vertex already exist!'
}
}
这时候,A、B、C、D 顶点都对应一个数组,如下代码所示:
'A' => [],
'B' => [],
'C' => [],
'D' => []
数组将用来存储边。我们设计图预计得到如下关系:
Map {
'A' => ['B', 'C', 'D'],
'B' => [],
'C' => ['B'],
'D' => ['C']
}
根据以上描述,其实已经可以把图画出来了。addEdge 需要两个参数:一个是顶点,一个是连接对象 Node。我们看看添加边是如何实现的。
-
添加边:addEdge,如下代码:
addEdge(vertex, node) {
if (this.AdjList.has(vertex)) {
if (this.AdjList.has(node)){
let arr = this.AdjList.get(vertex)
if (!arr.includes(node)){
arr.push(node)
}
} else {
throw `Can't add non-existing vertex ->'${node}'`
}
} else {
throw `You should add '${vertex}' first`
}
}
理清楚数据关系,我们就可以打印图了,其实就是一个很简单的 for…of 循环:
-
打印图:print,如下代码:
print() {
// 使用 for of 遍历并打印 this.AdjList
for (let [key, value] of this.AdjList) {
console.log(key, value)
}
}
剩下的内容就是遍历图了。遍历分为广度优先算法(BFS)和深度优先搜索算法(DFS)。我们先来看下广度优先算法(BFS)。
广度优先算法遍历,如下代码:
createVisitedObject() {
let map = {}
for (let key of this.AdjList.keys()) {
arr[key] = false
}
return map
}
bfs (initialNode) {
// 创建一个已访问节点的 map
let visited = this.createVisitedObject()
// 模拟一个队列
let queue = []
// 第一个节点已访问
visited[initialNode] = true
// 第一个节点入队列
queue.push(initialNode)
while (queue.length) {
let current = queue.shift()
console.log(current)
// 获得该节点的其他节点关系
let arr = this.AdjList.get(current)
for (let elem of arr) {
// 如果当前节点没有访问过
if (!visited[elem]) {
visited[elem] = true
queue.push(elem)
}
}
}
}
如上代码所示,我们来进行简单总结。广度优先算法(BFS),是一种利用队列实现的搜索算法。对于图来说,就是从起点出发,对于每次出队列的点,都要遍历其四周的点。
因此 BFS 的实现步骤:
-
起始节点作为起始,并初始化一个空对象——visited;
-
初始化一个空数组,该数组将模拟一个队列;
-
将起始节点标记为已访问;
-
将起始节点放入队列中;
-
循环直到队列为空。
深度优先算法,如下代码:
createVisitedObject() {
let map = {}
for (let key of this.AdjList.keys()) {
arr[key] = false
}
return map
}
// 深度优先算法
dfs(initialNode) {
let visited = this.createVisitedObject()
this.dfsHelper(initialNode, visited)
}
dfsHelper(node, visited) {
visited[node] = true
console.log(node)
let arr = this.AdjList.get(node)
// 遍历节点调用 this.dfsHelper
for (let elem of arr) {
if (!visited[elem]) {
this.dfsHelper(elem, visited)
}
}
}
}
如上代码,对于深度优先搜索算法(DFS),我把它总结为:“不撞南墙不回头”,从起点出发,先把一个方向的点都遍历完才会改变方向。换成程序语言就是:“DFS 是利用递归实现的搜索算法”。因此 DFS 的实现过程:
-
起始节点作为起始,创建访问对象;
-
调用辅助函数递归起始节点。
BFS 的实现重点在于队列,而 DFS 的重点在于递归,这是它们的本质区别。
总结
这一讲我们介绍了和前端最为贴合的几种数据结构,事实上数据结构更重要的是应用,我希望你能够做到:在需要的场景,能够想到最为适合的数据结构处理问题。请你务必掌握好这些内容,接下来的几讲都需要对数据结构有一个较为熟练的掌握和了解。我们马上进入数据结构的应用学习。
本讲内容总结如下:
随着需求的复杂度上升,前端工程师越来越离不开数据结构。是否能够掌握这个难点内容,将是进阶的重要考量。下一讲,我们将解析数据结构在前端中的具体应用场景,来帮助你加深理解,做到灵活应用。
22 剖析前端中的数据结构应用场景
上一讲我们使用 JavaScript 实现了几种常见的数据结构。事实上,前端领域到处体现着数据结构的应用,尤其随着需求的复杂度上升,前端工程师越来越离不开数据结构。React、Vue 这些设计精巧的框架,在线文档编辑系统、大型管理系统,甚至一个简单的检索需求,都离不开数据结构的支持。是否能够掌握这个难点内容,将是进阶的重要考量。
这一讲,我们就来解析数据结构在前端中的应用场景,以此来帮助大家加深理解,做到灵活应用。
堆栈和队列的应用
关于栈和队列的实际应用比比皆是:
-
浏览器的历史记录,因为回退总是回退“上一个”最近的页面,它需要遵循栈的原则;
-
类似浏览器的历史记录,任何 Undo/Redo 都是一个栈的实现;
-
在代码中,广泛应用的递归产生的调用栈,同样也是栈思想的体现,想想我们常说的“栈溢出”就是这个道理;
-
同上,浏览器在抛出异常时,常规都会抛出调用栈信息;
-
在计算机科学领域应用广泛,如进制转换、括号匹配、栈混洗、表达式求值等;
-
队列的应用更为直观,我们常说的宏任务/微任务都是队列,不管是什么类型的任务,都是先进先执行;
-
后端也应用广泛,如消息队列、RabbitMQ、ActiveMQ 等,能起到延迟缓冲的功效。
另外,与性能话题相关,HTTP 1.1 有一个队头阻塞的问题,而原因就在于队列这样的数据结构的特点。具体来说,在 HTTP 1.1 中,每一个链接都默认是长链接,因此对于同一个 TCP 链接,HTTP 1.1 规定:服务端的响应返回顺序需要遵循其接收到相应的顺序。但这样存在一个问题:如果第一个请求处理需要较长时间,响应较慢,将会“拖累”其他后续请求的响应,这是一种队头阻塞。
HTTP 2 采用了二进制分帧和多路复用等方法,同域名下的通信都在同一个连接上完成,在这个连接上可以并行请求和响应,而互不干扰。
在框架层面,堆栈和队列的应用更是比比皆是。比如 React 的 Context 特性,参考以下代码:
import React from "react";
const ContextValue = React.createContext();
export default function App() {
return (
<ContextValue.Provider value={1}>
<ContextValue.Consumer>
{(value1) => (
<ContextValue.Provider value={2}>
<ContextValue.Consumer>
{(value2) => (
<span>
{value1}-{value2}
</span>
)}
</ContextValue.Consumer>
</ContextValue.Provider>
)}
</ContextValue.Consumer>
</ContextValue.Provider>
);
}
对于以上代码,React 内部就是通过一个栈结构,在构造 Fiber 树时的 beginWork 阶段,将 Context.Provider 数据状态入栈(此时 value1:1 和 value2:2 分别入栈),在 completeWork 阶段,将栈中的数据状态出栈,以供给 Context.Consumer 消费。关于 React 源码中,栈的实现,你可以参考这部分源码。
链表的应用
React 的核心算法Fiber 的实现就是链表。React 最早开始使用大名鼎鼎的 Stack Reconciler 调度算法,Stack Reconciler 调度算法最大的问题在于:它就像函数调用栈一样,递归地、自顶向下进行 diff 和 render 相关操作,在 Stack Reconciler 执行的过程中,该调度算法始终会占据浏览器主线程。也就是说在此期间,用户的交互所触发的布局行为、动画执行任务都不会得到立即响应,从而影响用户体验。
因此 React Fiber 将渲染和更新过程进行了拆解,简单来说,就是每次检查虚拟 DOM 的一小部分,在检查间隙会检查“是否还有时间继续执行下一个虚拟 DOM 树上某个分支任务”,同时观察是否有更优先的任务需要响应。如果“没有时间执行下一个虚拟 DOM 树上某个分支任务”,且某项任务有更高优先级,React 就会让出主线程,直到主线程“不忙”的时候继续执行任务。
React Fiber 的实现也很简单,它将 Stack Reconciler 过程分成块,一次执行一块,执行完一块需要将结果保存起来,根据是否还有空闲的响应时间(requestIdleCallback)来决定下一步策略。当所有的块都已经执行完,就进入提交阶段,这个阶段需要更新 DOM,它是一口气完成的。
以上是比较主观的介绍,下面我们来看更具体的实现。
为了达到“随意中断调用栈并手动操作调用栈”,React Fiber 专门用于 React 组件堆栈调用的重新实现,也就是说一个 Fiber 就是一个虚拟堆栈帧,一个 Fiber 的结构类似:
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// Instance
// ...
this.tag = tag;
// Fiber
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
// Effects
// ...
this.alternate = null;
}
这么看Fiber 就是一个对象,通过 parent、children、sibling 维护一个树形关系,同时 parent、children、sibling 也都是一个 Fiber 结构,FiberNode.alternate 这个属性来存储上一次渲染过的结果,事实上整个 Fiber 模式就是一个链表。React 也借此,从依赖于内置堆栈的同步递归模型,变为具有链表和指针的异步模型了。
具体的渲染过程:
function renderNode(node) {
// 判断是否需要渲染该节点,如果 props 发生变化,则调用 render
if (node.memoizedProps !== node.pendingProps) {
render(node)
}
// 是否有子节点,进行子节点渲染
if (node.child !== null) {
return node.child
// 是否有兄弟节点,进行兄弟点渲染
} else if (node.sibling !== null){
return node.sibling
// 没有子节点和兄弟节点
} else if (node.return !== null){
return node.return
} else {
return null
}
}
function workloop(root) {
nextNode = root
while (nextNode !== null && (no other high priority task)) {
nextNode = renderNode(nextNode)
}
}
注意在 Workloop 当中,while 条件nextNode !== null && (no other high priority task)
,这是描述 Fiber 工作原理的关键伪代码。
下面我们换个角度再次说明。
在 Fiber 之前,React 递归遍历虚拟 DOM,在遍历过程中找到前后两颗虚拟 DOM 的差异,并生成一个 Mutation。这种递归遍历有一个局限性:每次递归都会在栈中添加一个同步帧,因此无法将遍历过程拆分为粒度更小的工作单元,也就无法暂停组件的更新,并在未来的某段时间恢复更新。
如何不通过递归的形式去遍历呢?基于链表的 Fiber 模型应运而生。最早的原始模型你可以在 2016 年的 issue 中找到。另外,React 中的 Hooks,也是通过链表这个数据结构实现的。
树的应用
从应用上来看,我们前端开发离不开的 DOM 就是一个树状结构;同理,不管是 React 还是 Vue 的虚拟 DOM 也都是树。
上文中我们提到了 React Element 树和 Fiber 树,React Element 树其实就是各级组件渲染,调用 React.createElement 返回 React Element 之后(每一个 React 组件,不管是 class 组件或 functional 组件,调用一次 render 或执行一次 function,就会生成 React Element 节点)的总和。
React Element 树和 Fiber 树是在 reconciler 过程中,相互交替,逐级构造进行的。这个生成过程,就采用了 DFS 遍历,主要源码位于 ReactFiberWorkLoop.js 中。我这里进行简化,你可以清晰看到 DFS 过程:
function workLoopSync() {
// 开始循环
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
let next;
// beginWork 阶段,向下遍历子孙组件
next = beginWork(current, unitOfWork, subtreeRenderLanes);
if (next === null) {
// completeUnitOfWork 是向上回溯树阶段
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
另外,React 中,当 context 数据状态改变时,需要找出依赖该 context 数据状态的所有子节点,以进行状态变更和渲染。这个过程,也是一个 DFS,源码你可以参考 ReactFiberNewContext.js。
继续树的应用这个话题,上一讲中我们介绍了二叉搜索树,这里我们来介绍字典树这个概念,并说明其应用场景。
字典树(Trie)是针对特定类型的搜索而优化的树数据结构。典型的例子是 AutoComplete(自动填充),也就是说它适合实现“通过部分值得到完整值”的场景。因此字典树也是一种搜索树,我们有时候也叫作前缀树,因为任意一个节点的后代都存在共同的前缀。当然,更多基础概念需要你提前了解。
我们总结一下它的特点:
-
字典树能做到高效查询和插入,时间复杂度为 O(k),k 为字符串长度;
-
但是如果大量字符串没有共同前缀,就很耗内存,你可以想象一下最极端的情况,所有单词都没有共同前缀时,这颗字典树会是什么样子;
-
字典树的核心就是减少不必要的字符比较,提高查询效率,也就是说用空间换时间,再利用共同前缀来提高查询效率。
除了我们刚刚提到的 AutoComplete 自动填充的情况,字典树还有很多其他应用场景:
-
搜索
-
分类
-
IP 地址检索
-
电话号码检索
字典树的实现也不复杂,我们可以一步步来,首先实现一个字典树上的节点,如下代码:
class PrefixTreeNode {
constructor(value) {
// 存储子节点
this.children = {}
this.isEnd = null
this.value = value
}
}
一个字典树继承 PrefixTreeNode 类,如下代码:
class PrefixTree extends PrefixTreeNode {
constructor() {
super(null)
}
}
我们可以通过下述方法实现:
-
addWord:创建一个字典树节点,如下代码:
addWord(str) {
const addWordHelper = (node, str) => {
// 当前 node 不含当前 str 开头的目标
if (!node.children[str[0]]) {
// 以当前 str 开头的第一个字母,创建一个 PrefixTreeNode 实例
node.children[str[0]] = new PrefixTreeNode(str[0])
if (str.length === 1) {
node.children[str[0]].isEnd = true
}
else if (str.length > 1) {
addWordHelper(node.children[str[0]], str.slice(1))
}
}
}
addWordHelper(<span class="hljs-keyword">this</span>, str)
}
-
predictWord:给定一个字符串,返回字典树中以该字符串开头的所有单词,如下代码:
predictWord(str) {
let getRemainingTree = function(str, tree) {
let node = tree
while (str) {
node = node.children[str[0]]
str = str.substr(1)
}
return node
}
// 该数组维护所有以 str 开头的单词
let allWords = []
let allWordsHelper = function(stringSoFar, tree) {
for (let k in tree.children) {
const child = tree.children[k]
let newString = stringSoFar + child.value
if (child.endWord) {
allWords.push(newString)
}
allWordsHelper(newString, child)
}
}
let remainingTree = getRemainingTree(str, this)
if (remainingTree) {
allWordsHelper(str, remainingTree)
}
return allWords
}
至此,我们实现了一个字典树的数据结构。
总结
这一讲,我们针对上一讲中的经典数据结构,结合前端应用场景进行了逐一分析。我们能够看到,无论是框架还是业务代码,都离不开数据结构的支持。数据结构也是计算机编程领域中一个最基础也是最重要的概念,它既是重点,也是难点。
本讲内容总结如下:
说到底,数据结构的真正意义在于应用,这里给大家留一个思考题,你还在哪些场景看见过数据结构呢?欢迎在留言区和我分享你的观点。
下一讲,我们就正式进入前端架构设计的实战部分,这也是本专栏的核心环节,是对之前所学知识的综合运用和设计,请继续保持学习!