本系列文章会以实现Vue的各个核心功能为标杆(初始化、相应式、编译、虚拟dom、更新、组件原理等等), 不会去纠结于非重点或者非本次学习目标的细节, 从头开始实现简化版Vue, 但是, 即使是简化, 也需要投入一定的时间和精力去学习, 而不可能毫不费力地学习到相对复杂的知识; 所有简化代码都会附上原版源码的路径, 简化版仅仅实现了基本功能, 如需了解更多细节, 可以去根据源码路径去阅读对应的原版源码;
同步渲染问题
前面, 我们完成了视图更新的最后一部分, 知道了其更新渲染流程为: 数据变更-> setter-> dep.notify-> watcher.update-> watcher.run-> render-> update-> 页面渲染并呈现; 我们来做一次更新:
import Vue from "/lib/Vue/platforms/web/runtime-with-compiler.js";
const vm = new Vue({
el: '#app',
template: '<div>{{name}}</div>',
data () {
return {
name: 'jack'
}
}
})
vm.name = 1
再把关键节点都给打印出来看看
不错一切都和我们的猜想一样, 那如果我们连续两次给name赋值会怎样?
const vm = new Vue({
el: '#app',
template: '<div>{{name}}</div>',
data () {
return {
name: 'jack'
}
}
})
vm.name = 1
vm.name = 2
我们会发现, 最后的render、update竟然每次都走了, name = 1 其实没必要展示在页面上的, 为了一个不需要展示的数据, 更新整个页面, 这似乎有些不妥, 根据前面对update的学习, 我们知道, 虽然diff算法做了很多优化, 但它终究还是存在一定的性能消耗的, 那么vue又是如何优化的呢? 大家在写vue的时候一定见过这种现象:
<template>
<div ref="name">{{name}}</div>
</template>
<script>
export default {
data () {
return {
name: '小明'
}
},
methods: {
getData () {
this.name = '大明'
console.log(this.$refs.name) // 小明
}
}
}
<script>
没错, 我们明明已经更新name为'大明'了, 可节点还是'小明', 这是因为我们的代码是同步执行的, 而Vue的更新是异步的! 而这个异步, 正是Vue解决反复渲染的方案! 来看看vue是如何实现的吧、
异步渲染
我们刚才的渲染顺序中, watcher.update后面就直接watcher.run了, 这其实是之前为了更好理解响应式而做的简化,现在这里应该是这样的
// 源码路径: /src/core/observer/watcher.ts
import { isFunction } from '../../shared/util'
import { pushTarget } from './dep'
import { queueWatcher } from './scheduler' // 增加
let uid = 0
export default class Watcher {
constructor(vm, expOrFn) {
this.vm = vm
this.depIds = new Set()
this.id = ++uid
if (isFunction(expOrFn)) {
this.getter = expOrFn
}
this.get()
}
get () {
const vm = this.vm
pushTarget(this)
let value = ''
try {
value = this.getter.call(vm, vm)
} catch (e) {
console.log(e.message)
}
return value
}
addDep (dep) {
const id = dep.id
if (!this.depIds.has(id)) {
this.depIds.add(id)
dep.addSub(this)
}
}
update () {
console.log('watcher.update')
// this.run()
queueWatcher(this) // 增加
}
run () {
console.log('watcher.run')
this.get()
}
}
queueWatcher
我们将udpate中直接run, 改为了调用queueWatcher, 即watcher队列方法
// 源码路径: /src/core/observer/scheduler.ts
import { nextTick } from "../util/next-tick"
const queue = []
let has = {}
//
let waitting = false
// 是否正在更新中
let flushing = false
// 当前更新到的watcher的下标
let index = 0
// 更新队列
function flushSchedulerQueue () {
flushing = true
let watcher, id
for (index = 0; index < queue.length; ++index) {
watcher = queue[index]
id = watcher.id
// 置为null, 说明这个watcher的依赖如果再有变化,则可以更新
has[id] = null
// 最终执行更新
watcher.run()
}
resetSchedulerState()
}
// 重置队列
function resetSchedulerState () {
queue.length = 0
waitting = flushing = false
}
// 添加watcher
export function queueWatcher (watcher) {
const id = watcher.id
// 如果已经记录了这个watcher, 则不再重复记录
if (has[id]) {
return
}
has[id] = true
// 如果不是正在执行队列中的方法
if (!flushing) {
// 添加到watcher的队列中
queue.push(watcher)
// 如果正在执行队列中的watcher
} else {
const i = queue.length - 1
// 后续的逻辑可以理解为:
// 如果i已经是正在执行的watcher的下标了
// 或者下标为i的watcher的id小于或等于当前传入的watcher.id
// 则执行splice替换
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
// 这里的waitting其实就是为了防止nextTick被重复执行
// flushSchedulerQueue被执行后, 才会被重新置为false
// 此处才能重新再执行一次nextTick
if (!waitting) {
waitting = true
nextTick(flushSchedulerQueue)
}
}
代码小节:
- 首先判断传入的这个watcher.id是否存在于has, 如果已存在, 则说明该watcher已经进入待执行队列, 无需再重新去执行了; 这里我们要清楚, 一个watcher 其实就是一段更新逻辑, 比如, 我们的视图部分的更新, 那么最后负责这项工作的其实就是一个watcher, 所以无论你的视图上有多少响应式变量, 它们对应的watcher, 都是同一个; 所以这里要防止在同一批次的更新中, watcher被重复加入;
- 注意flushing和waitting这两个变量, flushing=true表示flushSchedulerQueue表示是否正在执行; 而waitting=true表示nextTick在本轮更新中是否有被调用且未结束,它们是很完美的配合:
-
- 首次进入, 执行一次nextTick, waitting=true, 那么在本轮后续在更新完成前, 不得再执行nextTick
- 后续进入, 如果, 如果flushing为false, 则直接queque.push(watcher); 如果flushing为true, 则说明队列已经开始执行, 则通过queue.splice的方式, 将当前watcher插入对列;
- 更新完成, 执行resetSchedulerState, 将flushing和waitting都设置为false, 状态重置;
nextTick
接着来看下nextTick方法:
import { isIE, isIOS, isNative } from "./env"
import { noop } from "../../shared/util"
const callbacks = []
let pending = false
let timerFunc
// 初始化定义异步方法
// 注意, 这里的逻辑总体是微任务优先
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
// 在ios环境下, Promise.then的回调方法被推入微任务队列后, 微任务队列会出现不刷新的问题
// 所以, 强制执行一个setTimeout, 其回调为一个空的方法
if (isIOS) setTimeout(noop)
}
// 如果Promise不存在, 则退而求其次使用MutationObserver
// IE11不支持MutationObserver
} else if (!isIE
&& typeof MutationObserver !== 'undefined' &&
(isNative(MutationObserver) ||
// PhantomJS 和 IOS7.x环境下
MutationObserver.toString() === '[object MutationObserverConstructor]')) {
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
// 如果mutationObserver在当前环境也不支持, 则使用setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks)
}
// 如果以上都不存在, 则使用setTimeout这个宏任务
} else {
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
// 执行callbacks中的所有方法
function flushCallbacks () {
pending = false
// 对数组进行浅拷贝, 这样, 即使callbacks被指为空, copies中的函数引用也仍然存在
const copies = callbacks.slice(0)
// 置空callbacks
callbacks.length = 0
for (let i = 0; i < copies.length; ++i) {
copies[i]()
}
}
export function nextTick (cb, ctx) {
let _resolve
// 将所有传入的方法放入callbacks队列中
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
console.log(e.message)
}
} else if (_resolve) {
_resolve(ctx)
}
})
// 如果此时已经不是pending状态了, 则可继续
if (!pending) {
pending = true
// 执行异步方法
timerFunc()
}
// 如果没有传入第一个参数, 则返回一个promise
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
从上面的timerFunc可以看出, Vue的更新队列, 是以micro task(微任务)为主; 而为何要以微任务为主呢? 其实在Vue2.5之前, 使用的是微任务, 但是, 由于微任务优先级过高, 很容易导致其在两个冒泡事件之间触发, 详情见issue #6866, 但是如果采用采用全是macro task宏任务, 则会导致动画过程中出现异常, 详情见 issue #6813, 另外还有7109, #7153, #7546, #7834, #8109等问题; 说白了, 宏任务/微任务虽然都存在缺陷, 但现实中, 宏任务出现问题的频率还是更高; 所以在Vue2.5之后, 虽然仍旧保持微任务优先,但是对事件部分做了一些处理, 保证了了事件不受更新; 具体做了哪些操作, 后续事件章节中会进行介绍;
往期回顾