JS训练营(一) | 异步编程

439 阅读10分钟

这是我参与更文挑战的第17天,活动详情查看: 更文挑战

面对大厂,熟练掌握JS和深厚的算法基础是必备的,而网络、浏览器相关的知识点也是重点考察的范围,今天开始逐一分析JS基础、V8引擎、Webkit、JSCore等等知识点并深入各个细节。

前言

本文需要复习的基础目录:

  • 1.用MVVM框架时为什么要在列表组件中写key的作用

  • 2.['1', '2', '3'].map(parseInt) what & why

  • 3.什么是防抖和节流?有什么区别?如何实现

  • 4.Set、Map、WeakSet 和 WeakMap 的区别

  • 5.深度优先遍历和广度优先遍历,如何实现

  • 6.ES5/ES6 的继承除了写法以外还有什么区别

  • 7.setTimeout、Promise、Async/Await 的区别

  • 8.Async/Await 如何通过同步的方式实现异步

  • 9.将数组扁平化并去除其中重复数据

  • 10.JS 异步解决方案的发展历程以及优缺点

1.用MVVM框架时为什么要在列表组件中写key的作用

首先,这种提示在小程序也会经常爆出黄字警告,但并不是非带不可,在不带key的情况下,对于简单列表页渲染来说diff节点更快是没有错误的。但是这并不是key的作用。在Vue.js文档也说明了这个默认的模式是高效的,但是只适用于不依赖子组件状态或临时 DOM 状态 (例如:表单输入值) 的列表渲染输出。 key是给每一个vnode的唯一id,可以依靠key,更准确, 更快的拿到oldVnode中对应的vnode节点。

更准确

因为带key就不是就地复用了,在sameNode函数 a.key === b.key对比中可以避免就地复用的情况。所以会更加准确。

更快

利用key的唯一性生成map对象来获取对应节点,比遍历方式更快。(这个观点,就是我最初的那个观点。从这个角度看,map会比遍历更快。)

vue和react都是采用diff算法来对比新旧虚拟节点,从而更新节点。在vue的diff函数中(建议先了解一下diff算法过程)。 在交叉对比中,当新节点跟旧节点头尾交叉对比没有结果时,会根据新节点的key去对比旧节点数组中的key,从而找到相应旧节点(这里对应的是一个key => index 的map映射)。如果没找到就认为是一个新增节点。而如果没有key,那么就会采用遍历查找的方式去找到对应的旧节点。一种一个map映射,另一种是遍历查找。相比而言。map映射的速度更快。 vue部分源码如下:

// vue项目  src/core/vdom/patch.js  -488行
// 以下是为了阅读性进行格式化后的代码

// oldCh 是一个旧虚拟节点数组
if (isUndef(oldKeyToIdx)) {
  oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
if(isDef(newStartVnode.key)) {
  // map 方式获取
  idxInOld = oldKeyToIdx[newStartVnode.key]
} else {
  // 遍历方式获取
  idxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
}

// 创建map函数
function createKeyToOldIdx (children, beginIdx, endIdx) {
  let i, key
  const map = {}
  for (i = beginIdx; i <= endIdx; ++i) {
    key = children[i].key
    if (isDef(key)) map[key] = i
  }
  return map
}

// 遍历寻找 sameVnode 是对比新旧节点是否相同的函数
 function findIdxInOld (node, oldCh, start, end) {
    for (let i = start; i < end; i++) {
      const c = oldCh[i]

      if (isDef(c) && sameVnode(node, c)) return i
    }
  }

这个性能提升的前提有一句“刻意依赖默认行为”,也就是说不是说你不带key就会有性能提升,主要是为了提升diff【同级比较】的效率。自己想一下自己要实现前后列表的diff,如果对列表的每一项增加一个key,即唯一索引,那就可以很清楚的知道两个列表谁少了谁没变。而如果不加key的话,就只能一个个对比了。

2.['1', '2', '3'].map(parseInt) what & why ?

答案是[1, NaN, NaN]。 首先让我们回顾一下,map函数的第一个参数callback:

var new_array = arr.map(function callback(currentValue[, index[, array]]) { 
    // Return element for new_array 
}[, thisArg])

这个callback一共可以接收三个参数,其中第一个参数代表当前被处理的元素,而第二个参数代表该元素的索引。 而parseInt则是用来解析字符串的,使字符串成为指定基数的整数。

parseInt(string, radix)

接收两个参数,第一个表示被处理的值(字符串),第二个表示为解析时的基数。 了解这两个函数后,我们可以模拟一下运行情况

parseInt('1', 0) //radix为0时,且string参数不以“0x”和“0”开头时,按照10为基数处理。这个时候返回1
parseInt('2', 1) //基数为1(1进制)表示的数中,最大值小于2,所以无法解析,返回NaN
parseInt('3', 2) //基数为2(2进制)表示的数中,最大值小于3,所以无法解析,返回NaN

map函数返回的是一个数组,所以最后结果为[1, NaN, NaN]

3.什么是防抖和节流?有什么区别?如何实现?

这个我在之前的文章有专门介绍过: 解读js函数防抖与函数节流

1.防抖

触发高频事件后n秒内函数只会执行一次,如果n秒内高频事件再次被触发,则重新计算时间

思路:

每次触发事件时都取消之前的延时调用方法

function debounce(fn) {
      let timeout = null; // 创建一个标记用来存放定时器的返回值
      return function () {
        clearTimeout(timeout); // 每当用户输入的时候把前一个 setTimeout clear 掉
        timeout = setTimeout(() => { // 然后又创建一个新的 setTimeout, 这样就能保证输入字符后的 interval 间隔内如果还有字符输入的话,就不会执行 fn 函数
          fn.apply(this, arguments);
        }, 500);
      };
    }
    function sayHi() {
      console.log('防抖成功');
    }

    var inp = document.getElementById('inp');
    inp.addEventListener('input', debounce(sayHi)); // 防抖

2.节流

高频事件触发,但在n秒内只会执行一次,所以节流会稀释函数的执行频率

思路:

每次触发事件时都判断当前是否有等待执行的延时函数

function throttle(fn) {
      let canRun = true; // 通过闭包保存一个标记
      return function () {
        if (!canRun) return; // 在函数开头判断标记是否为true,不为true则return
        canRun = false; // 立即设置为false
        setTimeout(() => { // 将外部传入的函数的执行放在setTimeout中
          fn.apply(this, arguments);
          // 最后在setTimeout执行完毕后再把标记设置为true(关键)表示可以执行下一次循环了。当定时器没有执行的时候标记永远是false,在开头被return掉
          canRun = true;
        }, 500);
      };
    }
    function sayHi(e) {
      console.log(e.target.innerWidth, e.target.innerHeight);
    }
    window.addEventListener('resize', throttle(sayHi));

4.Set、Map、WeakSet 和 WeakMap 的区别?

Set

1.成员不能重复

2.只有健值,没有健名,有点类似数组。可以遍历,方法有add, delete,has

weakSet

  • 1.成员都是对象
  • 2.成员都是弱引用,随时可以消失。可以用来保存DOM节点,不容易造成内存泄漏
  • 3.不能遍历,方法有add, delete,has

Map

  • 1.本质上是健值对的集合,类似集合
  • 2.可以遍历,方法很多,可以干跟各种数据格式转换

weakMap

  • 1.直接受对象作为健名(null除外),不接受其他类型的值作为健名
  • 2.健名所指向的对象,不计入垃圾回收机制
  • 3.不能遍历,方法同get,set,has,delete

5.深度优先遍历和广度优先遍历,如何实现?

深度优先遍历(DFS)

深度优先遍历(Depth-First-Search),是搜索算法的一种,它沿着树的深度遍历树的节点,尽可能深地搜索树的分支。当节点v的所有边都已被探寻过,将回溯到发现节点v的那条边的起始节点。这一过程一直进行到已探寻源节点到其他所有节点为止,如果还有未被发现的节点,则选择其中一个未被发现的节点为源节点并重复以上操作,直到所有节点都被探寻完成。 简单的说,DFS就是从图中的一个节点开始追溯,直到最后一个节点,然后回溯,继续追溯下一条路径,直到到达所有的节点,如此往复,直到没有路径为止。 DFS 可以产生相应图的拓扑排序表,利用拓扑排序表可以解决很多问题,例如最大路径问题。一般用堆数据结构来辅助实现DFS算法。 注意:深度DFS属于盲目搜索,无法保证搜索到的路径为最短路径,也不是在搜索特定的路径,而是通过搜索来查看图中有哪些路径可以选择。 步骤:

  • 1.访问顶点v;
  • 2.依次从v的未被访问的邻接点出发,对图进行深度优先遍历;直至图中和v有路径相通的顶点都被访问;
  • 3.若此时途中尚有顶点未被访问,则从一个未被访问的顶点出发,重新进行深度优先遍历,直到所有顶点均被访问过为止;

实现:

Graph.prototype.dfs = function() {
    var marked = []
    for (var i=0; i<this.vertices.length; i++) {
        if (!marked[this.vertices[i]]) {
            dfsVisit(this.vertices[i])
        }
    }

    function dfsVisit(u) {
        let edges = this.edges
        marked[u] = true
        console.log(u)
        var neighbors = edges.get(u)
        for (var i=0; i<neighbors.length; i++) {
            var w = neighbors[i]
            if (!marked[w]) {
                dfsVisit(w)
            }
        }
    }
}

广度优先遍历(BFS)

广度优先遍历(Breadth-First-Search)是从根节点开始,沿着图的宽度遍历节点,如果所有节点均被访问过,则算法终止,BFS 同样属于盲目搜索,一般用队列数据结构来辅助实现BFS BFS从一个节点开始,尝试访问尽可能靠近它的目标节点。本质上这种遍历在图上是逐层移动的,首先检查最靠近第一个节点的层,再逐渐向下移动到离起始节点最远的层 步骤:

  • 创建一个队列,并将开始节点放入队列中

  • 若队列非空,则从队列中取出第一个节点,并检测它是否为目标节点

  • 若是目标节点,则结束搜寻,并返回结果

  • 若不是,则将它所有没有被检测过的字节点都加入队列中

  • 若队列为空,表示图中并没有目标节点,则结束遍历

实现:

Graph.prototype.bfs = function(v) {
    var queue = [], marked = []
    marked[v] = true
    queue.push(v) // 添加到队尾
    while(queue.length > 0) {
        var s = queue.shift() // 从队首移除
        if (this.edges.has(s)) {
            console.log('visited vertex: ', s)
        }
        let neighbors = this.edges.get(s)
        for(let i=0;i<neighbors.length;i++) {
            var w = neighbors[i]
            if (!marked[w]) {
                marked[w] = true
                queue.push(w)
            }
        }
    }
}

6.ES5/ES6 的继承除了写法以外还有什么区别

JavaScript相比于其他面向类的语言,在实现继承时并没有真正对构造类进行复制,当我们使用var children = new Parent()继承父类时,我们理所当然的理解为children ”为parent所构造“。实际上这是一种错误的理解。严格来说,JS才是真正的面向对象语言,而不是面向类语言。它所实现的继承,都是通过每个对象创建之初就存在的prototype属性进行关联、委托,从而建立练习,间接的实现继承,实际上不会复制父类。 ES5最常见的两种继承:原型链继承、构造函数继承

1.原型链继承

    // 定义父类
    function Parent(name) {
        this.name = name;
    }

    Parent.prototype.getName = function() {
        return this.name;
    };

    // 定义子类
    function Children() {
        this.age = 24;
    }

    // 通过Children的prototype属性和Parent进行关联继承

    Children.prototype = new Parent('程序员思语');

    // Children.prototype.constructor === Parent.prototype.constructor = Parent

    var test = new Children();

    // test.constructor === Children.prototype.constructor === Parent

    test.age // 24
    test.getName(); // 

我们可以发现,整个继承过程,都是通过原型链之间的指向进行委托关联,直到最后形成了”由构造函数所构造“的结局。

2.构造函数继承

    // 定义父类
    function Parent(value) {
        this.language = ['javascript', 'react', 'node.js'];
        this.value = value;
    }

    // 定义子类
    function Children() {
        Parent.apply(this, arguments);
    }

    const test = new Children(666);
    test.language // ['javascript', 'react', 'node.js']
    test.value // 666

构造继承关键在于,通过在子类的内部调用父类,即通过使用apply()或call()方法可以在将来新创建的对象上获取父类的成员和方法。

ES6的继承

    // 定义父类
    class Father {
        constructor(name, age) {
            this.name = name;
            this.age = age;
        }

        show() {
            console.log(`我叫:${this.name}, 今年${this.age}岁`);
        }
    };

    // 通过extends关键字实现继承
    class Son extends Father {};
    let son = new Son('程序员思语', 3000);
    son.show(); // 

ES6中新增了class关键字来定义类,通过保留的关键字extends实现了继承。实际上这些关键字只是一些语法糖,底层实现还是通过原型链之间的委托关联关系实现继承。

总结

区别于ES5的继承,ES6的继承实现在于使用super关键字调用父类,反观ES5是通过call或者apply回调方法调用父类。

7.setTimeout、Promise、Async/Await 的区别

注意区分这三者在事件循环中的区别,事件循环中分为宏任务队列和微任务队列。其中settimeout的回调函数放到宏任务队列里,等到执行栈清空以后执行;promise.then里的回调函数会放到相应宏任务的微任务队列里,等宏任务里面的同步代码执行完再执行;async函数表示函数里面可能会有异步方法,await后面跟一个表达式,async方法执行时,遇到await会立即执行表达式,然后把表达式后面的代码放到微任务队列里,让出执行栈让同步代码先执行。

1. setTimeout

console.log('script start')    //1. 打印 script start
setTimeout(function(){
    console.log('settimeout')    // 4. 打印 settimeout
})    // 2. 调用 setTimeout 函数,并定义其完成后执行的回调函数
console.log('script end')    //3. 打印 script start
// 输出顺序:script start->script end->settimeout

2. Promise

Promise本身是同步的立即执行函数, 当在executor中执行resolve或者reject的时候, 此时是异步操作, 会先执行then/catch等,当主栈完成后,才会去调用resolve/reject中存放的方法执行,打印p的时候,是打印的返回结果,一个Promise实例。

console.log('script start')
let promise1 = new Promise(function (resolve) {
    console.log('promise1')
    resolve()
    console.log('promise1 end')
}).then(function () {
    console.log('promise2')
})
setTimeout(function(){
    console.log('settimeout')
})
console.log('script end')
// 输出顺序: script start->promise1->promise1 end->script end->promise2->settimeout

当JS主线程执行到Promise对象时,

  • promise1.then() 的回调就是一个 task

  • promise1 是 resolved或rejected: 那这个 task 就会放入当前事件循环回合的 microtask queue

  • promise1 是 pending: 这个 task 就会放入 事件循环的未来的某个(可能下一个)回合的 microtask queue 中

  • setTimeout 的回调也是个 task ,它会被放入 macrotask queue 即使是 0ms 的情况

3. async/await

async function async1(){
   console.log('async1 start');
    await async2();
    console.log('async1 end')
}
async function async2(){
    console.log('async2')
}

console.log('script start');
async1();
console.log('script end')

// 输出顺序:script start->async1 start->async2->script end->async1 end

async 函数返回一个 Promise 对象,当函数执行的时候,一旦遇到 await 就会先返回,等到触发的异步操作完成,再执行函数体内后面的语句。可以理解为,是让出了线程,跳出了 async 函数体。

await的含义为等待,也就是 async 函数需要等待await后的函数执行完成并且有了返回结果(Promise对象)之后,才能继续执行下面的代码。await通过返回一个Promise对象来实现同步的效果。

8.Async/Await 如何通过同步的方式实现异步

js 是单线程的,可以使用 async await 用于把异步请求变为同步请求的方式,第一个请求的返回值作为后面一个请求的参数,其中每一个参数都是一个promise对象。

例如:这种情况工作中会经常遇到

(async () => {
    var a = await A();
    var b = await B(a);
    var c = await C(b);
    var d = await D(c);
})();

setTimeout 主要用户异步队列的形式,当然其中又分为宏观队列以及微观队列(Promise.then,process.nextTick等),比如隔1000ms执行一段逻辑代码(实际中不一定是1000ms后执行,需要考虑主任务的执行时间)

console.log(1);
setTimeout(() => {
    console.log(2)
}, 0)
setTimeout(() => {
    console.log(3)
}, 1000)
new Promise(resolve => {
    console.log(4)
    resolve()
}).then(() => {
    console.log(5)
})

9.将数组扁平化并去除其中重复数据

Array.from(new Set(arr.flat(Infinity))).sort((a,b)=>{ return a-b})

简单demo:

Array.prototype.flat= function() {
    return [].concat(...this.map(item => (Array.isArray(item) ? item.flat() : [item])));
}

Array.prototype.unique = function() {
    return [...new Set(this)]
}

const sort = (a, b) => a - b;

console.log(arr.flat().unique().sort(sort)); // [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 ]

10.JS 异步解决方案的发展历程以及优缺点

1. 回调函数(callback)

setTimeout(() => {
    // callback 函数体
}, 1000)

缺点:回调地狱,不能用 try catch 捕获错误,不能 return 回调地狱的根本问题在于:

  • 缺乏顺序性:回调地狱导致的调试困难,和大脑的思维方式不符
  • 嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身,即(控制反转)
  • 嵌套函数过多的多话,很难处理错误
ajax('XXX1', () => {
    // callback 函数体
    ajax('XXX2', () => {
        // callback 函数体
        ajax('XXX3', () => {
            // callback 函数体
        })
    })
})

优点:解决了同步的问题(只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。)

2. Promise

Promise就是为了解决callback的问题而产生的。

Promise 实现了链式调用,也就是说每次 then 后返回的都是一个全新 Promise,如果我们在 then 中 return ,return 的结果会被 Promise.resolve() 包装

优点:解决了回调地狱的问题

ajax('XXX1')
  .then(res => {
      // 操作逻辑
      return ajax('XXX2')
  }).then(res => {
      // 操作逻辑
      return ajax('XXX3')
  }).then(res => {
      // 操作逻辑
  })

缺点:无法取消 Promise ,错误需要通过回调函数来捕获

3. Generator

特点:可以控制函数的执行,可以配合 co 函数库使用

function *fetch() {
    yield ajax('XXX1', () => {})
    yield ajax('XXX2', () => {})
    yield ajax('XXX3', () => {})
}
let it = fetch()
let result1 = it.next()
let result2 = it.next()
let result3 = it.next()

4. Async/await

async、await 是异步的终极解决方案

优点:代码清晰,不用像 Promise 写一大堆 then 链,处理了回调地狱的问题

缺点:await 将异步代码改造成同步代码,如果多个异步操作没有依赖性而使用 await 会导致性能上的降低。

async function test() {
  // 以下代码没有依赖性的话,完全可以使用 Promise.all 的方式
  // 如果有依赖性的话,其实就是解决回调地狱的例子了
  await fetch('XXX1')
  await fetch('XXX2')
  await fetch('XXX3')
}

下面来看一个使用 await 的例子:

let a = 0
let b = async () => {
  a = a + await 10
  console.log('2', a) // -> '2' 10
}
b()
a++
console.log('1', a) // -> '1' 1

如果对于以上代码你可能会有疑惑,让我来解释下原因

首先函数 b 先执行,在执行到 await 10 之前变量 a 还是 0,因为 await 内部实现了 generator ,generator 会保留堆栈中东西,所以这时候 a = 0 被保存了下来

因为 await 是异步操作,后来的表达式不返回 Promise 的话,就会包装成 Promise.reslove(返回值),然后会去执行函数外的同步代码同步代码执行完毕后开始执行异步代码,将保存下来的值拿出来使用,这时候 a = 0 + 10。

上述解释中提到了 await 内部实现了 generator,其实 await 就是 generator 加上 Promise的语法糖,且内部实现了自动执行 generator。如果你熟悉 co 的话,其实自己就可以实现这样的语法糖。