Advanced-Frontend / Daily-Interview-Question

我是依扬(木易杨),公众号「高级前端进阶」作者,每天搞定一道前端大厂面试题,祝大家天天进步,一年后会看到不一样的自己。

Home Page:https://muyiy.cn/question/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

第 139 题:谈一谈 nextTick 的原理

yygmind opened this issue · comments

第 139 题:谈一谈 nextTick 的原理
<template>
  <div>
    <div>{{number}}</div>
    <div @click="handleClick">click</div>
  </div>
</template>
export default {
    data () {
        return {
            number: 0
        };
    },
    methods: {
        handleClick () {
            for(let i = 0; i < 1000; i++) {
                this.number++;
            }
        }
    }
}

当我们按下 click 按钮的时候,number 会被循环增加1000次。

那么按照之前的理解,每次 number 被 +1 的时候,都会触发 number 的 setter 方法,从而根据上面的流程一直跑下来最后修改真实 DOM。那么在这个过程中,DOM 会被更新 1000 次!那怎么办?

Vue.js中的 nextTick 函数,会传入一个 cb ,这个 cb 会被存储到一个队列中,在下一个 tick 时触发队列中的所有 cb 事件。

Vue.js 肯定不会以如此低效的方法来处理。Vue.js在默认情况下,每次触发某个数据的 setter 方法后,对应的 Watcher 对象其实会被 push 进一个队列 queue 中,在下一个 tick 的时候将这个队列 queue 全部拿出来 run( Watcher 对象的一个方法,用来触发 patch 操作) 一遍。

因为目前浏览器平台并没有实现 nextTick 方法,所以 Vue.js 源码中分别用 Promise、setTimeout、setImmediate 等方式在 microtask(或是task)中创建一个事件,目的是在当前调用栈执行完毕以后(不一定立即)才会去执行这个事件。

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

首先定义一个 callbacks 数组用来存储 nextTick,在下一个 tick 处理这些回调函数之前,所有的 cb 都会被存在这个 callbacks 数组中。pending 是一个标记位,代表一个等待的状态。
这里用setTimeout做描述(真实源码里更复杂):
setTimeout 会在 task 中创建一个事件 flushCallbacks ,flushCallbacks 则会在执行时将 callbacks 中的所有 cb 依次执行。

watcher

上面例子中,当我们将 number 增加 1000 次时,先将对应的 Watcher 对象给 push 进一个队列 queue 中去,等下一个 tick 的时候再去执行,这样做是对的。但是有没有发现,另一个问题出现了?

因为 number 执行 ++ 操作以后对应的 Watcher 对象都是同一个,我们并不需要在下一个 tick 的时候执行 1000 个同样的 Watcher 对象去修改界面,而是只需要执行一个 Watcher 对象,使其将界面上的 0 变成 1000 即可。

那么,我们就需要执行一个过滤的操作,同一个的 Watcher 在同一个 tick 的时候应该只被执行一次,也就是说队列 queue 中不应该出现重复的 Watcher 对象。

那么我们可以用 id 来标记每一个 Watcher 对象,让他们看起来不太一样。

我们再回过头聊一下第一个例子, number 会被不停地进行 ++ 操作,不断地触发它对应的 Dep 中的 Watcher 对象的 update 方法。然后最终 queue 中因为对相同 id 的 Watcher 对象进行了筛选,从而 queue 中实际上只会存在一个 number 对应的 Watcher 对象。在下一个 tick 的时候(此时 number 已经变成了 1000),触发 Watcher 对象的 run 方法来更新视图,将视图上的 number 从 0 直接变成 1000。

Event Loop(宏任务/微任务)的应用

commented

Event Loop(宏任务/微任务)的应用

这个nextTick和Node里的nextTick是一回事吗

EventLoop的主要阶段有timers loop check

Node.js里的nextTick是在EventLoop的当前阶段结束后立即执行,即上面三阶段的任一阶段

commented

主要是批处理把,然后判断是否支持promise,不支持就用settimeout(() => {} , 0)

@pagemarks 文章写的非常不错 👍

commented

同步修改状态,异步操作Dom。
异步优先采用微任务microTask(Promise),如果浏览器不支持则用宏任务macroTask(优先setImmediate ,不支持则用settimeout(()=> {}, 0))

  • 涉及到js线程中的微任务和宏任务.在一次事件循环中,先执行微任务,再执行宏任务
  • 宏任务: setTimeout,setInterval,...
  • 微任务: promise,requestAnimation,MutationObserve.......
查询浏览器支持的程度,先后执行
1. promise
2. MutationObserve
3. setTimeout
  • nextTick好处: 碰到太频繁的js操作,只需要显示最后一次的数据的视图,如果每次都实时更新视图,会消耗太多性能

当数据发生变化的时候,将需要通知更新的watcher收集到一个队列中,然后在nextTick函数里会遍历执行watcher的更新,nextTick相当于创建了一个异步任务(可能是异步微任务也可能是异步宏任务),然后在下一个event loop执行这些异步任务.我的理解,就是讲同步代码里的所有数据更改需要通知更新的操作都收集起来,放到一个异步任务中,统一处理.避免了频繁的更新视图这样耗费性能的操作.

Vue的数据异步更新机制Vue.nextTick

浏览器从服务器请求请求静态资源,到页面显示出来这个过程,大致分为5步:

  1. HTML转换为DOM树
  2. CSS转换为CSSOM(CSS Object Model)
  3. 结合DOM树和CSSOM构建渲染树
  4. 在内存中布局(Layout),计算出各个元素的大小、颜色、位置等
  5. 把布局绘制(Painting)到屏幕上

减少布局(Layout)和渲染(Painting)

用户和网页交互的过程中会不断触发重新布局(Layout)和渲染(Painting),布局和渲染是最消耗性能的,因此要尽可能减少触发它们。为了减少布局和渲染,Vue把DOM更新设计为异步更新,每次侦听到数据变化,将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。然后在下一个的事件循环tick中,Vue才会真正执行队列中的数据变更,然后页面才会重新渲染。相当于把多个地方的DOM更新放到一个地方一次性全部更新。

同步更新DOM的情况下,以下代码会触发多次布局和渲染

<button id="app" :style="style">text</button>
let app = document.querySelector('#app');

app.innerHTML = '1'
// ……其他代码……
app.innerHTML = '2'
// ……其他代码……
app.innerHTML = '1'
// ……其他代码……
app.innerHTML = 'text'

以上代码在不同地方修改了元素文本内容,尽管有两次修改值是一样的,浏览器依然会认为元素被修改了4次,也就会触发4次布局和渲染。改为异步之后,布局和渲染一次都不会触发。

<button id="app" :style="style">{{text}}</button>
new Vue({
  el: '#app',
  data: {
    text: 'text'
  },
  mounted(){
    this.text = '1'
    // ……其它代码……
    this.text = '2'
    // ……其它代码……
    this.text = '1'
    // ……其它代码……
    this.text = 'text'
  }
})

以上代码对同一个数据做多次修改,去重之后,只取最后一次修改this.text = 'text',发现最终修改结果,和原有文本一样,diff算法得出的结果是没有差异,不需要重新布局和渲染。

数据变更时机:下一个的事件循环tick

上面说过,Vue数据变更队列会在下一个的事件循环tick中执行,“下一个的事件循环tick”指的是什么时候?是下一次事件循环吗?分情况,绝大部分情况下不是的。

事实上,每次事件循环之后会都触发UI渲染,一次事件循环指的是一个(宏)任务(task)和一个微任务(microtask)队列执行完成,像这样:

UI渲染在微任务队列执行完之后,要赶在UI渲染之前做完数据变更,就需要生成一个task或者microtask来做这件事。优先使用microtask,为什么呢?因为不管你在(宏)任务还是微任务里修改state,接下来执行的都是微任务(如果还有待执行的微任务的话),新生成的microtask会push到当前这一次事件循环的microtask队列末尾,它会在这一次事件循环结束前执行到,如果是task则会push到task队列末尾,可能需要等待多次事件循环才后执行(取决于task队列的长度),等待时间较长。

异步不是目的,只是手段,想要数据变更尽快执行就用微任务。Vue.nextTick(callback)就是遵循这个原则,优先使用生会成microtask的Promise.thenMutationObserver,如果浏览器不支持,才用setImmediate或者setTimeout代替。当然,没有首选task的另一个原因是,它会导致各种奇奇怪怪的问题,例如#7109#7153#7546#7834#8109

总结:浏览器支持Promise.thenMutationObserver的情况下,“下一个的事件循环tick”指的是本次事件循环最后一个microtask,否则,指的是task队列中的最后一个task。

https://github.com/vuejs/vue/blob/dev/src/core/util/next-tick.js
vue实现nextTick的代码就这么短,花10分钟看一下就好了,看那么多分析还不如看源码

核心概念:UI Render 与 DOM 更新是两码事~