跳到主要内容

9 篇博文 含有标签「JavaScript」

查看所有标签

· 阅读需 2 分钟

前言

在本文中,我们将探讨 JavaScript 中一些缺失的数学方法,以及我们如何为它们编写函数。

JavaScript Math 对象包含一些非常有用且功能强大的数学运算,可用于 Web 开发,但它缺少大多数其他语言提供的许多重要运算(例如 Haskell,其中有大量运算)。

JavaScript 中缺少数学方法:求和

你可能记得在学校里,“sum”是“add”的同义词。例如,如果我们将数字 1、2 和 3 相加,它实际上意味着1 + 2 + 3。

我们的sum函数将涉及对数组中的所有值求和。

编写这个函数有两种方法:我们可以使用for循环,或者我们可以使用reduce函数。如果您想重新熟悉该reduce函数,可以阅读有关在 JavaScript 中使用 map()reduce() 的信息。

使用for循环:

function sum(array){
let total = 0
for(let count = 0; count < array.length; count++){
total = total + array[count]
}
return total
}

· 阅读需 13 分钟

前言

之前我们已经研究了树数据结构的实现以及它的一些变体,例如 trie。在这篇文章中,我们将深入研究堆。它也称为优先级队列。

什么是堆?

堆是树数据结构的一种变体,具有两个附加属性: 1.它是一棵完全二叉树:一棵完全二叉树的每一层都包含最大数量的节点,可能最后一层除外,它必须从左到右填充。完整的二叉树总是通过它的定义来平衡的。作为参考下图显示了什么时候可以将树称为完全二叉树: 2.每个节点都满足“堆属性”:堆属性本质上意味着对于任何给定的节点 C,如果 P 是 C 的父节点,则:

  • 对于最大堆:P 的键应该大于或等于 C 的键。
  • 对于最小堆:P 的键应该小于或等于 C 的键。

如何表示堆?

我们通常从节点的类表示开始实现,然后将其与实际数据结构本身的类表示联系起来。我们也可以对堆做同样的事情。但是,有一种更简单的方法可以解决此问题,这是因为所有堆都必须遵守以下两个属性之一:

所有堆必须是完全二叉树 由于所有堆都必须是完全二叉树,并且我们知道完全二叉树中除最后一层外的所有级别都必须完全填充。此外,对于最后一层,所有子项必须从左到右方向填充,没有任何间隙。这个定义确保了一个由 n 个节点组成的完全二叉树只能有 1 种可能的形状。反过来,它允许我们使用数组来表示完整的二叉树。这意味着,堆也可以使用数组来表示。例如,我们可以将一个简单的堆表示为一个数组,如下图所示: 这里要注意的关键是父节点和子节点之间的关系。如果仔细观察上图,我们可以推断出以下内容:

  1. 如果节点位于数组中的索引 i 处,则假设结果索引位于数组的长度内:
  • 它的左孩子将在第 (2i+1) 个位置
  • 左孩子将在 (2i+2) 位置
  1. 如果一个节点被放置在数组中的索引 i 处,它的父节点将位于第 ((i-1)/2) 个索引处。 下图可以更轻松地使用上述信息:

具体实现

注意:在整个实现过程中,我们只会讨论最小堆。稍后我们将看到如何将相同的想法轻松扩展到最大堆。

在我们已经涵盖了表示的细节,让我们想出一个使用数据结构的接口。在堆数据结构的帮助下,我们希望能够实现三个关键点:

  • 向堆中添加一个新键
  • 从堆中删除最大或最小键(取决于它是最小堆还是最大堆)
  • 从堆中获取最小键的最大值(取决于是最小堆还是最大堆)

第三个操作很简单。我们知道对于最小堆,数组中的第一项将是最小键,同样对于最大堆,数组中的第一项将是最大键。所以我们剩下两个操作的实现:

// adds the provided newKey into the min-heap named "heap"
function heappush(heap, newKey){}

// removes the smallest key from the min-heap named "heap"
function heappop(heap){}

实现heappush()

我们如何向堆中添加一个新键?假设我们首先将新键推送到数组中。推送新键仍然让我们遵守堆的第一个要求,即它必须是一个完整的二叉树。但是,我们需要确保它也遵守“堆属性”。 我们可以通过将push的项与其父项进行比较来做到这一点。如果父项大于的push的项,那么我们知道堆属性被违反,因此我们可以交换。我们可以继续进行这种交换,直到找到一个合法的父节点或者我们已经到达堆的顶部。 代码如下

function heappush(heap, newKey){
// push the new key
heap.push(newKey);

// get the current index of pushed key
let curr = heap.length-1;

// keep comparing till root is reached or we terminate in middle
while(curr > 0){
let parent = Math.floor((curr-1)/2)
if( heap[curr] < heap[parent] ){
// quick swap
[ heap[curr], heap[parent] ] = [ heap[parent], heap[curr] ]
// update the index of newKey
curr = parent
} else{
// if no swap, break, since we heap is stable now
break
}
}
}

实现 heappop()

使用 heappop() 我们需要删除堆的最顶层元素。意思是,对于最小堆,最小键将被删除,而对于最大堆,最大键将被删除。从数组的角度来看,它只是意味着我们应该删除数组的第一项。但是哪个节点应该成为根?如果我们随机选择被移除节点的左孩子或右孩子作为新的根节点,则不能保证遵循堆属性。我们可以按照以下步骤(对于最小堆):

  1. 将根节点与最后一个节点交换(数组中的第一个元素与最后一个元素)
  2. 通过从数组中弹出最后一项来删除根节点
  3. 将新根节点的键与其子节点进行比较:
  • 如果键小于它的两个子键,则堆是稳定的
  • 否则,将Key与较小的子Key交换
  1. 重复步骤 3,直到到达最后一个子节点或建立堆属性。

本质上,我们遵循与 heappush() 类似的过程,除了我们试图以从上到下的方式建立堆属性,即从根开始并一直持续到最后一个孩子。在 heappush() 中,我们遵循相反的顺序,即从最后一个孩子开始,一直到根。 代码实现:

function heappop(heap){
// swap root with last node
const n = heap.length;
[heap[0], heap[n-1]] = [ heap[n-1], heap[0]]

// remove the root i.e. the last item (because of swap)
const removedKey = heap.pop();

let curr = 0;

// keep going till atleast left child is possible for current node
while(2*curr + 1 < heap.length){
const leftIndex = 2*curr+1;
const rightIndex = 2*curr+2;
const minChildIndex = (rightIndex < heap.length && heap[rightIndex] < heap[leftIndex] ) ? rightIndex :leftIndex;
if(heap[minChildIndex] < heap[curr]){
// quick swap, if smaller of two children is smaller than the parent (min-heap)
[heap[minChildIndex], heap[curr]] = [heap[curr], heap[minChildIndex]]
curr = minChildIndex
} else {
break
}
}

// finally return the removed key
return removedKey;
}

使用现有数组创建堆

从现有数组创建堆看起来非常简单。只需创建一个空堆,然后遍历数组的所有项并执行 heappush():

function heapify(arr){
const heap = []
for(let item of arr){
heappush(heap, item)
}
return heap;
}

但是我们可以在这里做得更好吗?是的。首先,我们可以完全避免为新堆使用额外的空间。为什么不重新排列数组本身的元素,使其满足堆属性?为此,我们可以遵循与堆弹出类似的逻辑。我们可以查看第一个节点并与它的子节点进行比较,看看它是否是最小的节点,如果不是与较小的子节点交换。下面我们来实现一下:

// follows pretty much the same logic as heappush, except minor modifications
function percolateDown(heap, index){
let curr = index;
// keep going down till heap property is established
while(2*curr + 1 < heap.length){
const leftIndex = 2*curr+1;
const rightIndex = 2*curr+2;
const minChildIndex = (rightIndex < heap.length && heap[rightIndex] < heap[leftIndex] ) ? rightIndex :leftIndex;
if(heap[minChildIndex] < heap[curr]){
// quick swap, if smaller of two children is smaller than the parent (min-heap)
[heap[minChildIndex], heap[curr]] = [heap[curr], heap[minChildIndex]]
curr = minChildIndex
} else {
break
}
}

我们可以对数组中的所有元素使用 percolateDown() 函数,按照堆属性将所有元素按正确顺序排列:

function heapify(heap){
for(let i in heap){
percolateDown(heap, i)
}
return heap
}

这样就为我们节省了一个额外的数组。但是我们能做些什么来改善所花费的时间吗?是的。如果你仔细观察,我们实际上是在做一些重复的工作。假设堆中有 n 个节点,其中 x 是叶节点,那么这意味着我们只需要对 n-x 个节点执行 percolateDown(),因为到那时最后 x 个节点将在正确的位置。

那么在堆的数组表示中,我们应该执行 percolateDown() 操作到哪个索引? 直到最后一个节点的父节点所在的索引。因为一旦最后一个节点的父节点被过滤掉,它也会处理最后一个节点。所以:

  • 如果数组长度为 n
  • 最后一个节点的索引是:n-1
  • 它的父节点的索引是:Math.floor((n-1) - 1 / 2) = Math.floor(n/2 - 1)
function heapify(heap){
const last = Math.floor(heap.length/2 - 1);
for(let i = 0; i <= last; i++){
percolateDown(heap, i)
}
return heap
}

时间和空间复杂度

查看 heappush() 和 heapop() 操作,很明显我们在尝试添加或删除键时正在遍历树的高度。由于堆是一棵平衡树,因此高度是 log(n),其中 n 是节点总数。因此,对于堆的推送和弹出操作,时间复杂度为 O(log(n))。 heapify() 操作的时间复杂度可能看起来像 Onlog(n),因为每次调用都需要 O(log(n))。这个观察结果对于推导 heapify() 的时间复杂度的上限是正确的,但是,渐近(平均)时间复杂度为 O(n)。更多细节在这里。就空间复杂度而言,它是恒定的,因为额外的空间仅被诸如 curr、leftIndex 等大小恒定的变量占用。

最大堆

如果我们有 minHeap 的实现,我们也可以轻松地将它用作最大堆。我们只需要确保在向堆添加值时我们插入键的负数。它将确保堆充当所有键的负数的最小堆,这相当于所有实际键的最大堆。例如:

  • 假设我们有一个数组 const x = [23, 454, 54, 29];
  • 可以使用以下方法创建最小堆:
const heap = [];
for(let el of x) heappush(heap, el);
// min value
const min = heappop(heap)

最大堆可以使用

const heap = [];
for(let el of x) heappush(heap, -el);

// max value const max = -heappop(heap)


· 阅读需 7 分钟

前言

JavaScript 中的数组非常易于使用。但是,你应该注意一个细微差别:某些数组中可能存在漏洞。 在这篇文章中,我将描述 JavaScript 中稀疏数组和密集数组之间的区别。此外,你将找到创建稀疏数组的常用方法。

密集数组

JavaScript 中的数组是一个对象,表示元素的有序集合。数组中的元素有一个确切的顺序。你可以使用索引访问数组的第 n 项。

const names = ['Batman', 'Joker', 'Bane'];
console.log(names[0]); // logs 'Batman'
console.log(names[1]); // logs 'Joker'
console.log(names[2]); // logs 'Bane'
console.log(names.length); // logs 3
  • names[0] 访问索引 0(第一个元素)处的数组项。
  • 数组还有一个属性长度,它表示数组中的项数。在前面的示例中,names.length 为 3,因为数组中的元素个数为 3。
  • 上面创建的名称数组是一个密集数组:这意味着它包含每个索引处的元素,从 0 开始,直到 names.length - 1。 我们定义这是一个函数 isDense(array) ,用于确定数组是否在每个索引处都有元素:
    function isDense(array) {
    for (let index = 0; index < array.length; index++) {
    if (!(index in array)) {
    return false;
    }
    }
    return true;
    }
    const names = ['Batman', 'Joker', 'Bane'];
    console.log(isDense(names)); // logs true
    其中 index in array 确定数组是否在索引位置有一个元素。

这是一个有趣的问题:JavaScript 中的所有数组都是密集的吗?或者当 isDense(array) 返回 false 时可能有数组?

稀疏数组

有些情况下 JavaScript 数组中可能存在漏洞。这样的数组被命名为稀疏数组。 例如,如果你使用数组字面量但省略指示项就会创建了一个稀疏数组:

const names = ['Batman', , 'Bane'];
console.log(names[0]); // logs 'Batman'
console.log(names[1]); // logs undefined
console.log(names[2]); // logs 'Bane'
console.log(isDense(names)); // logs false

['Batman', , 'Bane'] 数组文字创建一个稀疏数组,在 1 索引处有一个缺失。如果你访问这个位置的值——names[1]——它的计算结果是 undefined

要明确检查特定索引处是否有空缺,你可以这样写index in names中:

const names = ['Batman', , 'Bane'];
// No hole
console.log(0 in names); // logs true
// Hole
console.log(1 in names); // logs false

当然,如果你在稀疏数组上运行 isDense() 它将返回 false:

const names = ['Batman', , 'Bane'];
console.log(isDense(names)); // logs false

现在你对稀疏数组有所了解。但是创建稀疏数组的常用方法是什么?

创建稀疏数组的方法

数组字面量

在使用数组字面量时省略一个值会创建一个稀疏数组(注意记录器数组中的空词):

const names = ['Batman', , 'Bane'];
console.log(names); // logs ['Batman', empty, 'Bane']

Array() 构造函数

调用 Array(length)new Array(length)(带有一个数字参数)会创建一个完全稀疏的数组:

const array = Array(3);
console.log(isDense(array)); // logs false
console.log(array); // logs [empty, empty, empty]

删除操作符

在数组上使用 delete array[index] 运算符时:

const names = ['Batman', 'Joker', 'Bane'];
delete names[1];
console.log(isDense(names)); // logs false
console.log(names); // logs ['Batman', empty, 'Bane']

最初,names数组是密集的。 但是执行 delete names[1] 会删除索引 1 处的元素并使 names 数组变得稀疏。

增加length属性

如果你增加数组的长度属性,那么你也会在数组中创建空缺:

const names = ['Batman', 'Joker', 'Bane'];
names.length = 5;
console.log(isDense(names)); // logs false
console.log(names); // logs ['Batman', 'Joker', 'Bane', empty, empty]

最初names数组有3个元素,是一个密集数组。 但是,将names.length 增加到 5 个元素会在 3 和 4 个索引处创建 2 个孔。

附带说明一下,减少 length 属性不会创建稀疏数组,而是从数组末尾删除元素。

数组方法和稀疏数组

稀疏数组的一个问题是许多数组内置方法只是跳过稀疏数组中的空缺。 例如, array.forEach(eachFunc) 不会在孔上调用 eachFunc

const names = ['Batman', , 'Bane'];
names.forEach(name => {
console.log(name);
});
// logs 'Batman'
// logs 'Bane'

以同样的方式 array.map(mapperFunc)array.filter(predicateFunc) 和更多函数跳过这些空缺位置。如果你不小心创建了一个稀疏数组,可能很难理解为什么数组方法不能按预期工作。

总结

在 JavaScript 中,数组可以是密集的或稀疏的。

如果每个索引处都有从 0 开始直到 array.length - 1 的元素,则数组是密集的。否则,如果任何索引处至少缺少一项,则数组是稀疏的。

虽然你不会过多地处理稀疏数组,但你应该了解可以创建一个数组的情况:

  • 跳过数组 [1, , 3] 中的值时
  • 使用 Array(length)
  • 使用delete array[index]
  • 当增加 array.length 属性时

稀疏数组的问题在于某些 JavaScript 函数(如 array.forEach()array.map() 等)在迭代数组项时会跳过空缺值。

拓展

稀疏数组在访问元素的速度上比密集数组慢

const arr = new Array(200000)
arr[19999] = 88
console.time('using[]')
arr[19999]
console.timeEnd('using[]')
// using[]: 0.031982421875ms

const ddd = [...new Array(200000)]
ddd[19999] = 88
console.time('using[]')
ddd[19999]
console.timeEnd('using[]')
// using[]: 0.010009765625ms

具体原因是,对于稀疏数组 V8 引擎访问对象是使用 散列表模式的,该种模式在访问时需要计算一遍哈希值,所以会比较慢,但散列表对于空间利用来说,效率更高。而密集数组,它是申请一段连续的内存空间,访问时可以直接通过「索引」来访问,所以速度比较快。

· 阅读需 8 分钟

前言

我们已经在三篇文章中介绍了树数据结构的基础知识。如果你还没有读过这些,我强烈建议先阅读前三篇文章:

介绍

Trie 是树数据结构的一种变体。它也被称为前缀树或搜索树的变体。就像 n 叉树数据结构一样,trie 可以有 n 个来自单亲的孩子。通常,trie 中的所有节点都会存储一些字符。假设我们只处理英语单词,下面是一个简单的 trie 可能看起来像: 需要注意的事项:

  1. 我们正在尝试使用树来尽可能高效地表示英语单词。

  2. 在上图中,从根节点到任何绿色节点的路径表示一个英文单词。例如:

    • NULL->C->A->T: CAT
    • NULL->D->O: DO
    • NULL->D->O->G: DOG
    • NULL->D->A->R->K: DARK
    • NULL->A: A
    • NULL->A->N: AN
  3. 每个节点最多可以有 26 个子节点(如果我们只处理英文字母)。我们有一个 NULL 节点作为根节点,因为一个单词可以以 26 个字母中的任何一个开头,因此我们需要一个虚拟节点,它可以将任何潜在的第一个字母作为子节点。

  4. 绿色节点,本质上代表“词尾”,同时从根遍历到该节点。

实现节点

现在,让我们尝试提出 Trie 节点的表示。回到树节点,这就是我们呈现它的方式:

function Node(value){
this.value = value
this.left = null
this.right = null
}

因此,我们可以对 Trie 遵循类似的想法,同时确保它满足我们在介绍部分讨论的要求。要了解 Trie 节点的要求,让我们放大任何节点: 所以现在更有意义了。这是最终的代码:

function Node(value){
this.value = value
this.isEndOfWord = false // false by default, a green node means this flag is true
this.children = {} // children are stored as Map, where key is the letter and value is a TrieNode for that letter
}

实现 Trie 数据结构

我们可以使用一个简单的 ES6 类来表示:

class Trie{
constructor(){
this.root = new Node(null)
}

insert(word){
// TODO
}

search(word){
// TODO
}

}

所以我们已经准备好了大概。作为初始化的一部分,每个trie 都会创建它自己的根节点(NULL)。那么我们可以实现这两个方法如下:

  • insert(word):我们可以将单词拆分为字母,并为每个字母创建一个 Node()。然后我们可以开始将这些 Trie 节点中的每一个链接到根节点,以插入单词。最后,我们将最后插入的节点的 isEndOfWord 属性标记为 true。
  • search(word):我们可以将单词拆分为字母。然后我们可以从根开始一个一个地寻找这些字母中的每一个。如果我们能够按顺序找到所有字母,那么我们可以返回 true 否则 false。

让我们直观地理解这两个操作以获得更好的上下文:

  • 首先insert(CAR)然后insert(CAN):
  • 首先search(CAR)然后search(CAN):

实现如下:

class Trie{
constructor(){
this.root = new Node(null)
}

insert(word){
let current = this.root
// iterate through all the characters of word
for(let character of word){
// if node doesn't have the current character as child, insert it
if(current.children[character] === undefined){
current.children[character] = new Node(character)
}
// move down, to insert next character
current = current.children[character]
}
// mark the last inserted character as end of the word
current.isEndOfWord = true
}

search(word){
let current = this.root
// iterate through all the characters of word
for(let character of word){
if(current.children[character] === undefined){
// could not find this character in sequence, return false
return false
}
// move down, to match next character
current = current.children[character]
}
// found all characters, return true if last character is end of a word
return current.isEndOfWord
}
}

使用 Trie

const trie = new Trie();

// insert few words
trie.insert("CAT");
trie.insert("DOG");

// search something
trie.search("MAT") // false
trie.search("DOG") // true

空间复杂度

在最坏的情况下,所有插入单词的每个字符都可以占用 Trie 中的单个节点。所以这意味着最坏的空间复杂度可以是 (W*n),其中 W 是每个单词的平均字符数,n 是 Trie 中的单词总数。

时间复杂度

  • 插入:插入一个有n个字符的单词,只需要遍历n个字符,所以时间复杂度为O(n)
  • 搜索:与插入类似,我们只需要遍历单词的所有字符即可进行搜索。所以时间复杂度是 O(n),其中 n 是单词中的字符数。

现在,想一想,你还能如何在庞大的单词列表中搜索某个单词? -可能使用数组?时间复杂度为 O(m),其中 m 是单词总数,这很糟糕。

  • 如何使用Map(或 JavaScript 中的对象)?这会将时间复杂度降低到 O(1),但是找到具有特定前缀的单词列表有多快?它将是 O(m)。

Trie 不仅将时间复杂度降低到 O(n)(n = 单词中的字符数),而且您还可以有效地搜索具有前缀的单词列表,这对于任何以上两种方法。

应用

  • 自动完成和预先输入:如果您在文本框中键入内容,并且看到具有相同前缀的潜在搜索列表,即自动完成小部件,那么这可能是由后台的 Trie 处理的。同样,Typeahead 也可以使用 Trie 来实现。
  • 拼写检查器:我们可以使用 trie 创建拼写检查器,即给定一个单词列表,我们可以检查给定单词的拼写是否正确。
  • IP 路由(最长前缀匹配):Internet 由多个路由器节点组成,它们决定应该发送的目标数据包。 Internet 上的每个路由器都需要将数据包发送到由给定 IP 目的地决定的适当目标节点。但是每个路由器如何使用给定的 IP 地址决定下一个目标路由器呢?这个问题可以使用IP路由来解决。这是一篇深入探讨这个主题的好文章

· 阅读需 7 分钟

前言

到目前为止,我们已经了解了一些二叉树遍历的方法:

  • 使用递归和迭代算法遍历二叉树
  • 使用父指针遍历二叉树 在本文中,我们将把这些知识用于 n 叉树,即 DOM。我们将看到如何使用各种 CSS 选择器定位 DOM 元素,而无需使用内置 API,如 getElementById、getElementsByClassname 或 querySelector/querySelectorAll。因此,本文将阐明这些 API 可能如何在幕后工作。

DOM 遍历

借用 使用递归和迭代算法遍历二叉树的思路,我们来得出DOM的前序遍历算法:

function walkPreOrder(node){
if(!node) return

// do something here
console.log(node)

for(let child of node.children){
walkPreOrder(child)
}
}

我们可以修改这个算法使之来返回一个迭代器:

function* walkPreOrder(node){
if(!node) return

// do something here
yield node
for(let child of node.children){
yield* walkPreOrder(child)
}
}

// USAGE
for(let node of walkPreOrder(root)){
console.log(node)
}

我们可以使用任何广度优先或深度优先算法(在之前的文章中讨论过)来遍历 DOM。 我们还假设正在处理具有以下 HTML 的文档:

<html>
<head>
<title>DOM selection algorithm</title>
</head>
<body>

<div class="container">
<div class="body">
<div class="row">
<img id="profile" src="xyz.jpg" alt="">
</div>
<div class="row"></div>
<div class="row"></div>
</div>
</div>

</body>
</html>

通过 ID 定位节点

function locateById(nodeId){
// iterate through all nodes in depth first (preOrder) fashion
// return the node as soon as it's found
for(let node of walkPreOrder(document.body)){
if(node.id === nodeId){
return node
}
}
return null
}

我们可以使用 locateById() 函数如下:

const img = locateById('profile')
// returns the image node

通过ClassName 定位节点

浏览器提供 document.getElementsByClassName() API 来实现此结果。我们如何实现类似的东西:

function locateAllByClassName(className){
const result = []
for(let node of walkPreOrder(document.body)){
if(node.classList.contains(className)){
result.push(node)
}
}
return result
}

// USAGE
const elements = locateAllByClassName('row')

浏览器如何优化选择查询

选择 DOM 节点是 Web 应用程序相当常见的操作。为同一个选择器多次遍历树似乎不是最佳选择。浏览器通过使用记忆优化选择。 查看 mozilla 解析器的源代码,即函数 startTag 的摘录:

 // ID uniqueness
@IdType String id = attributes.getId();
if (id != null) {
LocatorImpl oldLoc = idLocations.get(id);
if (oldLoc != null) {
err("Duplicate ID \u201C" + id + "\u201D.");
errorHandler.warning(new SAXParseException(
"The first occurrence of ID \u201C" + id
+ "\u201D was here.", oldLoc));
} else {
idLocations.put(id, new LocatorImpl(tokenizer));
}
}

我们可以看到这些节点 ID 保存在一个简单的哈希映射中。我们可以使用类似的方法来确保对同一 ID 的重复查询不需要完全遍历,相反,我们可以从 hashMap 中查找并返回它。 以下是我们的解决方案:

function getSelectors(){
const idLocations = {}
const classLocations = {}

// updated selector functions
function locateById(nodeId){
if(idLocations.hasOwnProperty(nodeId))
return idLocations[nodeId]

for(let node of walkPreOrder(document.body)){
if(node.id === nodeId){
idLocations[nodeId]= node //memoize
return node
}
}
idLocations[nodeId]= null // memoize
return null
}

function locateAllByClassName(className){
if(classLocations.hasOwnProperty(className))
return classLocations[className]

const result = []
for(let node of walkPreOrder(document.body)){
if(node.classList.contains(className)){
result.push(node)
}
}
classLocations[nodeId]= result
return result
}

return {
locateById,
locateAllByClassName
}

}

// USAGE
const {locateById, locateAllByClassName} = getSelectors();
const result = locateAllByClassName('row') // returns array of elements
const img = locateById('profile') // returns an element, if found

处理更复杂的选择器

让我们尝试实现类似 element.querySelector 的方法。以下是 MDN 的描述:

The querySelector() method of the Element interface returns the first element that is a descendant of the element on which it is invoked that matches the specified group of selectors.

样例

const firstRow = document.querySelector('.container .row:first-child')

在这种情况下,我们可以将任何 CSS 选择器传递给函数,它应该能够遍历 DOM 为我们找到该元素。让我们看看它是如何实现的:

// given a selector and root node, find that selector within the root node
function select(selector, root){
for(let node of walkPreOrder(root)){
if(node.matches(selector)){
return node
}
}
return null;
}

function myQuerySelector(path, node){ // if path is empty, nothing to find if(path.length === 0) return null;

// if node is not provided, let's assume user wants to search within document.body let root = node || document.body;
const selector = path[0];

// if there's only one selector in the path, just traverse using select function above if(path.length === 1) return select(selector, root);

// else, either the current node matches the first selector in path or not // if first selector matches with current node, look through it's children for subsequent selectors only // else, look through it's children for the whole path const newPath = root.matches(selector) ? path.slice(1): path; for(let child of root.children){ const ans = myQuerySelector(newPath, child); if(ans) return ans }

// nothing found return null; }

// USAGE: const firstRow = myQuerySelector([".container", ".row"])

myQuerySelectorAll 的实现(类似于 element.querySelectorAll)也遵循相同的方法,稍作修改:
```javascript
function selectAll(selector, root){
let result = []
for(let node of walkPreOrder(root)){
if(node.matches(selector)){
result.push(node)
}
}
return result;
}

function myQuerySelectorAll(path, node){
let result = [];
if(path.length === 0) return result;

let root = node || document.body;
const selector = path[0];

if(path.length === 1) return selectAll(selector, root);

const newPath = root.matches(selector) ? path.slice(1): path;
for(let child of root.children){
result = [...result, ...myQuerySelectorAll(newPath, child)]

}

return result;
}

进阶

我们可以使用本文开头描述的递归前序遍历方法来克隆任何树。让我们看看我们如何使用它来克隆任何 DOM 树,类似于 element.cloneNode(true) 所做的:

  • 通过创建具有相同 tagName 的新节点然后复制属性来创建源节点的克隆。
  • 对源节点的所有子节点递归调用 cloneTree 方法,并将返回的节点作为子节点附加到克隆节点。
function cloneTree(node){
if(!node) return

const clonedNode = document.createElement(node.tagName.toLowerCase())
const attributes = node.getAttributeNames()

attributes.forEach(attribute => {
clonedNode.setAttribute(attribute, node.getAttribute(attribute))
})

for(const child of node.children){
clonedNode.append(cloneTree(child))
}

return clonedNode
}

· 阅读需 9 分钟

前言

在本系列的第一部分中,我们研究了遍历二叉树的递归和迭代方法。 在实际应用中,树节点有一个父节点是很常见的:一个指向父节点的指针,因此也称为父指针。让我们以浏览器中的 DOM 为例。假设我们使用以下命令选择任何节点:

const element = document.querySelector("#id")

在本文中,我们将研究如何使用这些父指针来提高遍历效率。我稍后会解释我所说的“更高效”是什么意思。在下一篇文章中,我们还将了解如何使用此处学到的经验教训从头开始创建 myQuery 库。

更新节点定义

首先,我们需要更新Node 函数

function Node(value){
this.value = value
this.left = null
this.right = null
this.parent = null // added parent field
}

现在让我们看看如何使用这个新的 Node 定义来创建一个类似的树,就像我们在上一篇文章中所做的那样。

const root = new Node(2)
const left = new Node(1)
root.left = left
left.parent = root

const right = new Node(3)
root.right = right
right.parent = root

我们只需要确保父指针指向父节点。这是我们使用上述代码获得的最终树的视觉参考:

##寻找后继节点

前序后继

假设每个节点都有一个parent指针,如何找出二叉树中任何节点的 前序 后继? 让我们试着分析一下这个问题:

  1. 首先,我们在这里处理前序,这意味着我们正在寻找以下顺序:
root -> left -> right
  1. 这意味着如果我们已经在当前节点,我们想寻找左子节点作为后继节点。
  2. 如果根本没有左子节点怎么办?那么在这种情况下,我们会寻找合适的节点,如果在有左子节点,那就是后继节点。
  3. 如果没有左子节点或右子节点,那么我们需要回溯(继续向上走向父节点)。我们一直回溯,直到通过它的右子节点到达父级(因为这意味着 前序 对于父级下的整个子树是完整的,根据 #1 的定义)。

最终算法实现就是这样:

function preOrderSuccessor(node){
if(!node) return

if(node.left) return node.left
if(node.right) return node.right

let parent = node.parent

while(parent && parent.right === node) {
node = node.parent
parent = parent.parent
}

if(!parent) return null // we backtracked till root, so no successor

return parent.right
}

可以根据下图更好的理解

中序后继

  1. 首先,我们在这里处理中序遍历,这意味着我们正在寻找以下顺序:
root -> left -> right
  1. 如果我们在当前节点,并且它右边有右子节点,那么我们可以通过在右子树上找到最左边的节点来获得后继节点。
  2. 如果没有右子节点,那么我们需要回溯(向上移动)。我们一直向上移动,直到通过它的右子节点到达父节点,因为这意味着已经遍历了整个子树(根据 #1 中的定义)。
  3. 一旦我们找到最近的父节点,它是通过它的左子节点找到的,它就会作为后继节点返回。为什么?因为这意味着它是一个已经探索了左树的节点,所以根据 #1 中的定义,节点本身现在是后继节点。 实现如下:
function inOrderSuccessor(node){
if(!node) return

if(node.right){
let current = node.right
while(current && current.left) current = current.left
return current
}

let parent = node.parent

while(parent && parent.right === node) {
root = node.parent
parent = parent.parent
}

if(!parent) return null

return parent
}

后序后继

  1. 首先,我们在这里处理后序遍历,这意味着我们正在寻找以下顺序:
left -> right -> root
  1. 所以,如果我们在任何节点上,就意味着它的左右子树已经被访问过了。这意味着我们需要查看父级的继任者。
  2. 如果我们从它的右子节点到达父母,这意味着父母本身就是继任者,根据#1 中的定义
  3. 如果我们从它的左子节点到达父母,这意味着接下来要探索父母的右子节点(根据#1 中的定义)。所以现在我们需要简单地返回父节点右子节点中最左边的节点作为后继节点。 实现如下:
function postOrderSuccessor(node){
if(!node) return

let parent = node.parent
if(!parent) return null

if(parent.right === node). return parent

let current = parent.right
while(current && (current.left || current.right)){
current = (current.left || current.right)
}

return current
}

使用后继算法更好地遍历

为什么我们需要使用父指针来提出遍历算法呢?这是一个值得思考的问题,因为我们已经提出了遍历树的递归和迭代方法,而且不需要父指针。

我们这样做的原因是因为我们之前的方法增加了空间复杂性。如果你还记得上一篇文章中我们需要使用一个或两个堆栈(取决于遍历方法)来使任何遍历算法工作。即使在递归方法中,虽然我们不直接使用堆栈,但递归本身基于调用堆栈,因此那里也使用了隐藏的内存中堆栈。问题是这个堆栈的大小会随着我们树的深度而增加,因此这不是最好的解决方案,因为我们有办法在花费更少空间的情况下完成相同的任务。通过使用父指针,我们可以完全摆脱这些堆栈,为我们节省大量空间,即从 O(logN) 的空间复杂度(其中 N 表示平衡树的大小)到 O(1)。让我们看看如何实现。

前序遍历

对于 前序遍历,我们从树的根部开始。之后,我们可以使用上面的算法继续获取前序后继以遍历整棵树:

function preOrder(root){
// first node
console.log(root.value);

let current = root
while(true){
const next = preOrderSuccessor(current)
if(!next) break

// do something
console.log(next.value)

current = next
}
}

中序遍历

对于 中序遍历,起始节点将是树的最左侧节点。此后,我们可以使用上述算法继续获取后继以遍历整棵树:

function inOrder(root){
// start at the left most node
while(root && root.left){
root = root.left
}

// first node
console.log(root.value);

let current = node
while(true){
const next = inOrderSuccessor(current)
if(!next) break

// do something
console.log(current.value)

current = next
}
}

后序遍历

非常类似于上面的中序遍历的方法:

function postOrder(root){
// start at the left most node
while(root && root.left){
root = root.left
}

// first node
console.log(root.value);

let current = node
while(true){
const next = postOrderSuccessor(current)
if(!next) break

// do something
console.log(current.value)

current = next
}
}

进阶

如果每个节点都有一个父指针,你能想出算法来寻找前任(inOrder、preOrder 和 postOrder)吗?

· 阅读需 9 分钟

前言

Tree是一种有趣的数据结构。它在各个领域都有广泛的应用。例如:

  • DOM 是一种Tree状数据结构
  • 我们操作系统中的目录和文件可以表示为Tree
  • 家庭层次结构可以表示为Tree。 Tree的许多变体(如堆、BST 等)可用于解决与调度、图像处理、数据库等相关的问题。许多复杂的问题乍一看似乎与Tree无关,但可以被表示为一个Tree的问题。我们也会(在本系列的后面部分)解决这些问题,从而了解Tree如何使看似复杂的问题更容易理解和解决。

简介

二叉树实现节点非常简单

function Node(value){
this.value = value
this.left = null
this.right = null
}
// usage
const root = new Node(2)
root.left = new Node(1)
root.right = new Node(3)

所以这几行代码将为我们创建一个二叉树,如下所示:

           2  
/ \
/ \
1 3
/ \ / \
null null null null

遍历

让我们从尝试遍历这些连接的树节点(或一棵树)开始。正如我们可以遍历数组一样,如果我们也可以“遍历”树节点。然而,树不是像数组那样的线性数据结构,所以遍历这些的方法不止一种。我们可以将遍历方法大致分为以下几类:

  • 广度优先遍历
  • 深度优先遍历

广度优先遍历(BFS)

在这种方法中,我们逐层遍历树。我们将从根开始,然后覆盖它的所有子级,然后覆盖所有 2 级子级,依此类推。例如对于上面的树,遍历会导致这样的结果:

           2  
/ \
/ \
1 3
/ \ / \
null null null null
2,1,3

下面是一个稍微复杂的树的插图,使这更容易理解:

为了实现这种形式的遍历,我们可以使用队列(先进先出)数据结构。以下是整个算法的过程:

  1. 初始化一个包含 root 的队列
  2. 从队列中删除第一项
  3. 将弹出项的左右节点推入队列
  4. 重复步骤 2 和 3,直到队列为空 下面是这个算法在实现后的样子:
function walkBFS(root){
if(root === null) return

const queue = [root]
while(queue.length){
const item = queue.shift()
// do something
console.log(item)

if(item.left) queue.push(item.left)
if(item.right) queue.push(item.right)
}
}

我们可以稍微修改上面的算法实现为:

function walkBFS(root){
if(root === null) return

const queue = [root], ans = []

while(queue.length){
const len = queue.length, level = []
for(let i = 0; i < len; i++){
const item = queue.shift()
level.push(item)
if(item.left) queue.push(item.left)
if(item.right) queue.push(item.right)
}
ans.push(level)
}
return ans
}

深度优先遍历(DFS)

在 DFS 中,我们取一个节点并继续探索它的子节点,直到深度耗尽为止。它可以通过以下方式之一完成:

 root node -> left node -> right node // pre-order traversal
left node -> root node -> right node // in-order traversal
left node -> right node -> root node // post-order traversal

所有这些遍历技术都可以递归和迭代实现。让我们进入实现细节:

前序遍历(Pre-Order traversal)

分析
 root node -> left node -> right node

技巧:

我们可以使用这个简单的技巧来手动找出任何树的前序遍历:从根节点开始遍历整棵树,保持自己在左边。

实现
  • 递归
function walkPreOrder(root){
if(root === null) return

// do something here
console.log(root.val)

// recurse through child nodes
if(root.left) walkPreOrder(root.left)
if(root.right) walkPreOrder(root.right)
}

  • 迭代 前序遍历的迭代方法与 BFS 非常相似,不同之处在于我们使用堆栈而不是队列,并且我们首先将右节点推入堆栈:
function walkPreOrder(root){
if(root === null) return

const stack = [root]
while(stack.length){
const item = stack.pop()

// do something
console.log(item)

// Left child is pushed after right one, since we want to print left child first hence it must be above right child in the stack
if(item.right) stack.push(item.right)
if(item.left) stack.push(item.left)
}
}

中序遍历(In-Order traversal)

分析

下面是一棵树的中序遍历的过程:

left node -> root node -> right node

技巧

我们可以使用这个简单的技巧来手动找出任何树的中序遍历:在树的底部水平放置一个平面镜,并获取所有节点的投影

实现
  • 递归

    function walkInOrder(root){
    if(root === null) return

    if(root.left) walkInOrder(root.left)

    // do something here
    console.log(root.val)

    if(root.right) walkInOrder(root.right)
    }
  • 迭代 这个算法乍一看可能有点神秘。但它相当直观。让我们这样看:在中序遍历中,最左边的孩子节点首先被打印,然后是根,然后是孩子节点。所以首先想到的是想出这样的东西:

    const curr = root

while(curr){ while(curr.left){ curr = curr.left // get to leftmost child }

console.log(curr) // print it

curr = curr.right // now move to right child }

在上述方法中,我们无法回溯,即返回导致最左侧节点的父节点。所以我们需要一个堆栈来记录这些。因此,我们修订后的方法可能如下所示:
```javascript
const stack = []
const curr = root

while(stack.length || curr){
while(curr){
stack.push(curr) // keep recording the trail, to backtrack
curr = curr.left // get to leftmost child
}
const leftMost = stack.pop()
console.log(leftMost) // print it

curr = leftMost.right // now move to right child
}

现在我们可以使用上面的方法来制定最终的迭代算法:

function walkInOrder(root){
if(root === null) return

const stack = []
let current = root

while(stack.length || current){
while(current){
stack.push(current)
current = current.left
}
const last = stack.pop()

// do something
console.log(last)

current = last.right
}
}

后序遍历(Post-Order traversal)

分析

下面是一棵树的中序遍历的过程:

 left node -> right node -> root node

技巧

对于任何树的快速手动后序遍历:一个接一个地提取所有最左边的孩子节点。

实现

让我们深入研究这种遍历的实际实现。

  • 递归

      function walkPostOrder(root){
    if(root === null) return

    if(root.left) walkPostOrder(root.left)
    if(root.right) walkPostOrder(root.right)

    // do something here
    console.log(root.val)

    }
  • 迭代 我们已经有了用于前序遍历的迭代算法。我们可以用那个吗?因为后序遍历似乎只是前序遍历的反向。让我们来看看:

// PreOrder:
root -> left -> right

// Reverse of PreOrder:
right -> left -> root

// But PostOrder is:
left -> right -> root

从上面分析可见有细微的差别。我们可以通过稍微修改我们的 前序遍历算法然后反转它应该给出 后序遍历结果来适应这一点。总体算法将是:

// record result using 
root -> right -> left

// reverse result
left -> right -> root
  • 使用与上述迭代前序遍历算法类似的方法,使用临时堆栈。
    • 唯一的区别是 root -> right -> left 而不是 root -> left -> right
  • 结果将遍历序列记录在一个array
  • 结果的反转就是后序遍历
function walkPostOrder(root){
if(root === null) return []

const tempStack = [root], result = []

while(tempStack.length){
const last = tempStack.pop()

result.push(last)

if(last.left) tempStack.push(last.left)
if(last.right) tempStack.push(last.right)
}

return result.reverse()
}

· 阅读需 15 分钟

前言

JavaScript 是一项令人印象深刻的技术。不是因为它设计得特别好,也不是因为世界上几乎所有可以访问互联网的设备都执行 JavaScript 程序。相反,JavaScript 令人印象深刻,是因为它的几乎每一个特性都使它成为优化的噩梦,但是它速度很快。

javascript 为什么会执行速度很快呢?这就是我们需要去深入探究的问题。

在本文中,我们将仔细研究不同 JavaScript 引擎用于实现良好运行时性能的一些技术,在研究过程中省略了一些细节,并简化了事情。本文的目标不是让您了解事物的确切运作方式,而是让您了解并理解引擎如何提升其运行时的一些基本知识。

执行模型

当您的浏览器下载 JavaScript 时,其首要任务是让它尽快运行。它通过将代码转换为字节码、虚拟机指令,然后将其移交给理解如何执行它们的解释器或虚拟机来实现。

您可能会问为什么浏览器会将 JavaScript 转换为虚拟机指令而不是实际的机器指令?这是个好问题。事实上,直到最近,V8(Chrome 的 JavaScript 引擎)还一直在做直接转换为机器指令的工作。

特定编程语言的虚拟机通常是更容易编译的目标,因为它与源语言的关系更密切。实际的机器有一个更通用的指令集,因此需要更多的工作来翻译编程语言以很好地处理这些指令。这种困难意味着编译需要更长的时间,同时也意味着 JavaScript 开始执行需要更长的时间。

例如,理解 JavaScript 的虚拟机也可能理解 JavaScript 对象。因此,执行像 object.x 这样的语句所需的虚拟指令可能是一两条指令。一台不了解 JavaScript 对象如何工作的实际机器需要更多的指令来确定 .x 在内存中的位置以及如何获取它。

虚拟机的问题在于它是虚拟的, 它是不存在的。指令不能直接执行,必须在运行时解释。解释代码总是比直接执行代码慢。

这里有一个问题需要权衡。需要在更快的编译时间与更快的运行时间中做一个选择。在许多情况下,更快的编译是一个很好的权衡。用户不太可能关心单个按钮的点击是否需要 20 或 40 毫秒的执行时间,尤其是当按钮只被按下一次时。快速编译 JavaScript,即使生成的代码执行速度较慢,也会让用户更快地查看页面并与页面交互。

有些情况在计算上是昂贵的。诸如游戏、语法高亮之类的场景。在这种情况下,编译和执行机器指令的时间加起来可能会减少总执行时间。那么 JavaScript 是如何处理这些情况的呢?

经常被执行的代码

每当 JavaScript 引擎检测到某个函数执行了很多次时,它就会将该函数交给优化编译器。该编译器将虚拟机指令翻译成实际的机器指令。更重要的是,由于该函数已经运行了多次,优化编译器可以根据之前的运行做出一些假设。换句话说,它可以执行推测优化以生成更快的代码。

如果这些推测后来被证明是错误的,会发生什么? JavaScript引擎可以简单地删除错误的函数,并还原为使用未优化版本。一旦该函数再运行几次,它就可以尝试再次将其传递给优化编译器,这一次它会提供更多可用于推测优化的信息。

既然我们知道频繁运行的函数在优化过程中使用来自先前执行的信息,接下来要探索的是这是什么类型的信息。

翻译问题

JavaScript 中的几乎所有东西都是对象。不幸的是,JavaScript 对象很难让机器处理。让我们看看下面的代码:

function addFive(obj) {
return obj.method() + 5;
}

将函数转换为机器指令非常简单,就像从函数返回一样。但是机器不知道对象是什么,比如访问obj的method属性需要怎么翻译呢?

如果知道 obj 是什么样子会很有帮助,但在 JavaScript 中我们永远无法确定。任何对象都可以添加或删除方法属性。即使method确实存在,我们实际上也不能确定它是否是一个函数,更不用说调用它之后的返回值了。

让我们尝试将上述代码转换为没有对象的 JavaScript 子集,来了解转换为机器指令可能是什么样的。

首先,我们需要一种表示对象的方法。我们还需要一种从其中检索值的方法。在机器代码中支持数组是比较的,所以我们可能会使用这样的表示:

// An object like { method: function() {} }
// could be represented as:
// [ [ "method" ], // property names
// [ function() {} ] ] // property values

function lookup(obj, name) {
for (var i = 0; i < obj[0].length; i++) {
if (obj[0][i] === name) return i;
}
return -1;
}

参考上述的表示,我们可以尝试对 addFive 进行一个简单的实现

function addFive(obj) {
var propertyIndex = lookup(obj, "method");
var property = propertyIndex < 0
? undefined
: obj[1][propertyIndex];

if (typeof(property) !== "function") {
throw NotAFunction(obj, "method");
}
var callResult = property(/* this */ obj);
return callResult + 5;
}

当然,这在 obj.method() 返回的不是数字的情况下不能运行,所以我们需要稍微调整一下实现:

function addFive(obj) {
var propertyIndex = lookup(obj, "method");
var property = propertyIndex < 0
? undefined
: obj[1][propertyIndex];

if (typeof(property) !== "function") {
throw NotAFunction(obj, "method");
}
var callResult = property(/* this */ obj);
if (typeof(callResult) === "string") {
return stringConcat(callResult, "5");
} else if (typeof(callResult !== "number") {
throw NotANumber(callResult);
}

return callResult + 5;
}

这是能运行的,但我希望很明显,如果我们能提前知道 obj 的结构是什么,以及方法的类型是什么,那么这段代码可以跳过几个步骤。

隐藏类

主流的 JavaScript 引擎都以某种方式跟踪对象是什么样的呢?在 Chrome 中,这个概念被称为隐藏类。

让我们从以下代码片段开始:

var obj = {}; // empty object
obj.x = 1; // shape has now changed to include a `x` property
obj.toString = function() { return "TODO"; }; // shape changes
delete obj.x; // shape changes again

如果我们将其转换为机器指令,我们将如何在添加和删除新属性时跟踪对象的样子?如果我们使用上一个示例将对象表示为数组的想法,它可能看起来像这样:

var emptyObj__Class = [ 
null, // No parent hidden class
[], // Property names
[] // Property types
];

var obj = [
emptyObj__Class, // Hidden class of `obj`
[] // Property values
];

var obj_X__Class = [
emptyObj__Class, // Contains same properties as empty object
["x"], // As well as one property called `x`
["number"] // Where `x` is a number
];

obj[0] = obj_X__Class; // Shape changes
obj[1].push(1); // value of `x`

var obj_X_ToString__Class = [
obj_X__Class, // Contains same properties as previous shape
["toString"], // And one property called `toString`
["function"] // Where `toString` is a function
];

obj[0] = obj_X_ToString__Class; // shape change
obj[1].push(function() { return "TODO"; }); // `toString` value

var obj_ToString__Class = [
null, // Starting from scratch when deleting `x`
["toString"],
["function"]
];

obj[0] = obj_ToString__Class;
obj[1] = [obj[1][1]];

如果我们要生成这样的虚拟机指令,我们现在就有了一种方法来跟踪对象在任何给定时间的样子。然而,这本身并不能真正帮助我们。我们需要将这些信息存储在有价值的地方。

内联缓存

每当 JavaScript 代码对对象执行属性访问时,JavaScript 引擎都会将该对象的隐藏类以及查找结果(属性名称到索引的映射)存储在缓存中。这些缓存被称为内联缓存,它们有两个重要目的:

  • 在执行字节码时,如果所涉及的对象具有缓存中的隐藏类,它们会加速属性访问。
  • 在优化期间,它们包含有关访问对象属性时所涉及的对象类型的信息,这有助于优化编译器生成特别适合这些类型的代码。

内联缓存对它们存储信息的隐藏类的数量有限制。这可以保留内存,但也确保在缓存中执行查找速度很快。如果从内联缓存中检索索引比从隐藏类中检索索引花费的时间更长,则缓存没有任何用处。

据我所知, Chrome在中,内联缓存最多会跟踪 4 个隐藏类。在此之后,内联缓存将被禁用,信息将存储在全局缓存中。全局缓存的大小也有限制,一旦达到限制,新条目将覆盖旧条目。

为了最好地利用内联缓存并帮助优化编译器,应该尝试编写仅对单一类型的对象执行属性访问的函数。不仅如此,生成的代码的性能将是次优的

内联

一种单独且重要的优化是内联。简而言之,这种优化用被调用函数的实现代替了函数调用。举个例子:

function map(fn, list) {
var newList = [];
for (var i = 0; i < list.length; i++) {
newList.push(fn(list[i]));
}

return newList;
}

function incrementNumbers(list) {
return map(function(n) { return n + 1; }, list);
}

incrementNumbers([1, 2, 3]); // returns [2, 3, 4]

内联后,代码最终可能看起来像这样:

function incrementNumbers(list) {
var newList = [];
var fn = function(n) { return n + 1; };
for (var i = 0; i < list.length; i++) {
newList.push(fn(list[i]));
}
return newList;
}

incrementNumbers([1, 2, 3]); // returns [2, 3, 4]

这样做的一个好处是删除了函数调用。更大的好处是 JavaScript 引擎现在可以更深入地了解函数的实际作用。基于这个新版本,JavaScript 引擎可能会决定再次执行内联:

function incrementNumbers(list) {
var newList = [];
for (var i = 0; i < list.length; i++) {
newList.push(list[i] + 1);
}

return newList;
}

incrementNumbers([1, 2, 3]); // returns [2, 3, 4]

另一个函数调用已被删除。更重要的是,优化器现在可能会推测 incrementNumbers 只会以数字列表作为参数被调用。它还可能决定内联 incrementNumbers([1, 2, 3]) 调用本身,并发现 list.length 为 3,这又可能导致:

var list = [1, 2, 3];
var newList = [];
newList.push(list[0] + 1);
newList.push(list[1] + 1);
newList.push(list[2] + 1);
list = newList;

简而言之,内联可以实现跨函数边界无法执行的优化。

但是,可以内联的内容是有限的。由于代码重复,内联会导致更大的函数,这需要额外的内存。 JavaScript 引擎对一个函数在完全跳过内联之前可以达到的大小有一个预算。

一些函数调用也很难内联。特别是当一个函数作为参数传入时。

此外,作为参数传递的函数很难内联,除非它总是同一个函数。虽然这可能会让您觉得这是一件奇怪的事情,但由于内联,最终可能会出现这种情况。

结论

JavaScript 引擎有许多提高运行时性能的技巧,比这里介绍的要多得多。但是,本文中描述的优化适用于大多数浏览器,并且很容易验证它们是否被应用。因此,当我们尝试提高 Elm 的运行时性能时,我们将主要关注这些优化。

参考

What’s up with monomorphism Shapes and inline caches Optimizing prototypes

· 阅读需 8 分钟

前言

编写简短、简洁和干净的 JavaScript 代码的技巧😎 JavaScript 有很多很酷的特性,大多数初学者和中级开发人员都不知道。本节挑选了 10 个在日常 JavaScript 项目中经常使用的技巧。

1. 有条件的向对象中添加属性

我们可以使用扩展运算符 ... 来有条件地向 JavaScript 对象快速添加属性。

const condition = true;
const person = {
id: 1,
name: 'John Doe',
...(condition && { age: 16 }),
};

如果每个操作数的计算结果都为true, && 运算符将返回最后计算的表达式。因此返回一个对象 { age: 16 },然后将其作为 person 对象的一部分。

如果条件为 false,则 JavaScript 将执行以下操作:

const person = {
id: 1,
name: 'John Doe',
...(false), // evaluates to false
};
// spreading false has no effect on the object
console.log(person); // { id: 1, name: 'John Doe' }

2. 检查一个属性是否存在于一个对象中

我们可以使用in关键字来检查 JavaScript 对象中是否存在属性

const person = { name: 'John Doe', salary: 1000 };
console.log('salary' in person); // returns true
console.log('age' in person); // returns false

3. 对象中的动态属性名称

使用动态键设置对象属性很简单。只需使用 ['key_name'] 符号添加属性

const dynamic = 'flavour';
var item = {
name: 'Biscuit',
[dynamic]: 'Chocolate'
}
console.log(item); // { name: 'Biscuit', flavour: 'Chocolate' }

同样的技巧也可用于使用动态键引用对象属性:

const keyName = 'name';
console.log(item[keyName]); // returns 'Biscuit'

4. 使用动态键进行对象解构

你可能知道你可以解构一个变量并立即用 : 符号重命名它。但是你知道当你不知道键名或键名是动态的时,你也可以解构对象的属性吗? 首先,让我们看看如何在解构(用别名解构)时重命名变量。

const person = { id: 1, name: 'John Doe' };
const { name: personName } = person;
console.log(personName); // returns 'John Doe'

现在,让我们使用动态键来解构属性:

const templates = {
'hello': 'Hello there',
'bye': 'Good bye'
};
const templateName = 'bye';
const { [templateName]: template } = templates;
console.log(template) // returns 'Good bye'

5. ?? 运算符

??当你要检查变量是 null 还是 undefined 时,运算符很有用。当其左侧操作数为空或未定义时,它返回右侧操作数,否则返回其左侧操作数。

const foo = null ?? 'Hello';
console.log(foo); // returns 'Hello'
const bar = 'Not null' ?? 'Hello';
console.log(bar); // returns 'Not null'
const baz = 0 ?? 'Hello';
console.log(baz); // returns 0

在第三个示例中,返回 0 是因为即使 0 在 JavaScript 中被认为是假的,但它不是 null 或未定义的。你可能认为我们可以使用 ||运算符在这里,但这两者之间存在差异:

const cannotBeZero = 0 || 5;
console.log(cannotBeZero); // returns 5
const canBeZero = 0 ?? 5;
console.log(canBeZero); // returns 0

6. ?. 可选链

我们都可能曾经遇到过TypeError:无法读取 null 的属性“foo”之类的错误。这对每个 JavaSript 开发人员来说都是头疼的问题。引入了可选链就是为了解决这个问题。让我们来看看:

const book = { id:1, title: 'Title', author: null };
// normally, you would do this
console.log(book.author.age) // throws error
console.log(book.author && book.author.age); // returns null (no error)
// with optional chaining
console.log(book.author?.age); // returns undefined
// or deep optional chaining
console.log(book.author?.address?.city); // returns undefined

你还可以使用具有以下功能的可选链:

const person = {
firstName: 'Haseeb',
lastName: 'Anwar',
printName: function () {
return `${this.firstName} ${this.lastName}`;
},
};
console.log(person.printName()); // returns 'Haseeb Anwar'
console.log(persone.doesNotExist?.()); // returns undefined

7. 使用 !! 的布尔转换符

!! 运算符可用于将表达式的结果快速转换为布尔值 truefalse。就是这样:

const greeting = 'Hello there!';
console.log(!!greeting) // returns true
const noGreeting = '';
console.log(!!noGreeting); // returns false

8. 字符串和整数转换

使用 + 运算符快速将字符串转换为数字,如下所示:

const stringNumer = '123';
console.log(+stringNumer); // returns integer 123
console.log(typeof +stringNumer); // returns 'number'

要将数字快速转换为字符串,请使用 + 运算符后跟空字符串 "":

const myString = 25 + '';
console.log(myString); // returns '25'
console.log(typeof myString); // returns 'string'

这些类型转换非常方便,但它们的清晰度和代码可读性较差。因此,在生产中使用它们之前,你可能需要考虑一下。不过可以用才code golf中。

9. 检查数组中的假值

你熟悉 filter、some 和 every 数组方法。但你也应该知道,你可以仅使用布尔方法来测试真值:

const myArray = [null, false, 'Hello', undefined, 0];
// filter falsy values
const filtered = myArray.filter(Boolean);
console.log(filtered); // returns ['Hello']
// check if at least one value is truthy
const anyTruthy = myArray.some(Boolean);
console.log(anyTruthy); // returns true
// check if all values are truthy
const allTruthy = myArray.every(Boolean);
console.log(allTruthy); // returns false

这是它的工作原理。众所周知,这些数组方法采用回调函数,因此我们将布尔值作为回调函数传递。 Boolean 本身接受一个参数并根据参数的真实性返回truefalse。所以我们可以这样说:

myArray.filter(val => Boolean(val));

是不是和这个一样:

myArray.filter(Boolean);

10.展平数组

原型 Array 上有一个方法 flat 可以让你从数组的数组中创建一个数组:

const myArray = [{ id: 1 }, [{ id: 2 }], [{ id: 3 }]];
const flattedArray = myArray.flat();
// returns [ { id: 1 }, { id: 2 }, { id: 3 }

你还可以定义一个深度级别,指定嵌套数组结构应展平的深度。例如:

const arr = [0, 1, 2, [[[3, 4]]]];
console.log(arr.flat(2)); // returns [0, 1, 2, [3,4]]

结语

感谢你阅读到最后。希望这些技巧对你日常开发有用。