vue源码解析之调度原理(响应式原理)
可先看我的前篇会更好理解:vue源码解析之编译过程-含2种模式(及vue-loader作用)
目录大纲
- 测试文件:.html文件
- 测试动作:点击“click me”,触发 qqq函数
- 调度过程总结
- 再谈一下vue的双向绑定v-model原理
测试文件:.html文件
- CDN引入vue的未压缩版,在script标签内,直接使用vue
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script> </head> <body> <div id="app"> {{aa}} --- 1 <div @click="qqq">click me</div> {{C_aa}} </div> <script type="module"> debugger new Vue({ el: '#app', data: { aa: 123 }, watch: { aa (nval, oval) { console.log(nval, oval) } }, computed: { C_aa () { return this.aa + 100 } }, methods: { qqq () { debugger this.aa = this.aa + 1 } } }) </script> </body> </html>
测试动作:点击“click me”,触发 qqq函数
(说明:只截取了主线代码,并略有删减,为的是 更好的关注主线,主线弄明白了,有余力,在去了解支线。 调试方式:debugger一步步往下)
断点在qqq函数内,调试从断点开始,看看 this.aa = this.aa + 1
vue底层到底干了哪些事儿,才能把最新的数据 更新到页面上去?
有几个问题点,可以提前思考一下:
- 如果用户一次同步操作,改变了多个data的值,vue是触发一次update,还是多次update?
- 比如用户在一个click事件内,有个for循环,改变了data的某个属性N次,比如数组push N次,这个属性有一个watch监听它,那么这个watch监听函数只会执行一次还是会执行N次?怎么实现的?
- 用户写的watch: {..} 内的监听函数,是在update前执行,还是update之后?
- watch: {..} 内的回调函数 如果又修改了data,那么还会触发update吗?
开始调试,执行 this.aa = this.aa + 1
-
第一步,拿到this.aa的值。因为是要取值,所以会触发aa的get监听函数
-
在vue中,会对data的做监听(深层对象的话会递归监听,数组会遍历监听),主要是通过Object.defineProperty监听 可以设置get和set的监听函数,取this.aa的值 会触发get函数,设置this.aa=xx 会触发set函数
-
以下get的执行步骤,请看注释 (以下dep部分用到了发布订阅模式)
/** * A dep is an observable that can have multiple * directives subscribing to it. */ var Dep = function Dep () { this.id = uid++; this.subs = []; // 订阅者队列 subscriber }; /** * Define a reactive property on an Object. */ function defineReactive$$1 ( obj, key, val, customSetter, shallow ) { var dep = new Dep(); // 为每个data,绑定一个dep对象(Dep构造函数结构如上) var property = Object.getOwnPropertyDescriptor(obj, key); if (property && property.configurable === false) { return } // cater for pre-defined getter/setters var getter = property && property.get; var setter = property && property.set; if ((!getter || setter) && arguments.length === 2) { val = obj[key]; } var childOb = !shallow && observe(val); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { var value = getter ? getter.call(obj) : val; // data存在getter先执行getter /* 为data收集依赖 (在vue中,每一个data都会绑定一个对象叫dep,会分配唯一的id。 如果有依赖内容 会放到data对应的dep内的this.subs的订阅者队列里面), 依赖内容是:比如:aa有3个依赖 1个watch、1个computed、1个页面渲染的必备的函数"function () { vm._update(vm._render(), hydrating); }" */ if (Dep.target) { dep.depend(); // 为data收集依赖 if (childOb) { // 递归处理child childOb.dep.depend(); if (Array.isArray(value)) { dependArray(value); } } } return value // 拿到值 }, set: function reactiveSetter (newVal) { var value = getter ? getter.call(obj) : val; // data存在getter先执行getter // 新旧值一样 没被修改,直接return停止 if (newVal === value || (newVal !== newVal && value !== value)) { return } // #7981: for accessor properties without setter if (getter && !setter) { return } if (setter) { // 只在vue初始化的时候执行 setter.call(obj, newVal); } else { val = newVal; // 保存一份新值 } childOb = !shallow && observe(newVal); // 递归处理child /* 消息推送,通知订阅者队列 this.subs。实际上会把订阅者队列在处理一遍, 放在全局queue队列里面去,最终真正执行的是queue队列, 会过滤掉computed 因为不是异步的,结果是函数的返回值。在model层取值渲染的时候,会去跑函数,得到返回值 (目前 aa 的订阅者队列this.subs内有:1个watch、1个computed、1个页面渲染的必备的函数"function () { vm._update(vm._render(), hydrating); }") */ dep.notify(); } }); }
-
-
第二步,修改this.aa的值。会触发set监听函数
(代码在上面,详细请看注释) 执行set监听函数 最终会触发 消息推送
dep.notify()
-
dep.notify()
调度的开始消息推送,通知订阅者队列 this.subs。实际上会把订阅者队列在处理一遍,放在全局queue队列里面去,最终真正执行的是queue队列(目前 aa 的订阅者队列this.subs内有:1个watch、1个computed、1个页面渲染的必备的函数"function () { vm._update(vm._render(), hydrating); }")(会过滤掉computed 因为不是异步的,结果是函数的返回值。在model层取值渲染的时候,会去跑computed对应的函数得到返回值)
-
细节0:页面渲染的必备的函数"function () { vm._update(vm._render(), hydrating); }")是什么作用,可以先看我的前篇:vue源码解析之编译过程-含2种模式(及vue-loader作用)
-
细节1:用户写的computed不是异步的,结果是函数的返回值。在model层取值渲染的时候,会去跑computed对应的函数得到返回值值(以下代码暂时没有体现)
- 调度流程只会把this.dirty = true。 把对应的computed改成dirty(脏的)意味着,需要更新。
-
细节2:异步事件(比如用户写的watch)都会放到一个全局的queue队列去,队列的最后一个是关键渲染函数vm._update(vm._render())。
-
细节3:什么时候去执行queue队列?
- 在nextTick后去执行,nextTick(flushSchedulerQueue)
- nextTick原理是一个微任务,等同步任务执行完,在执行 flushSchedulerQueue,最终去run queue队列。
- 好处:用户的一次操作,可能会改动多次或多个data的值,不用每改动一次就去更新页面,可以把一次同步任务内的所有改动,都收集起来,放到queue队列内,然后同步任务结束后 执行微任务nextTick内的回调函数, 去执行run queue队列。
- 在nextTick后去执行,nextTick(flushSchedulerQueue)
-
细节4:举一个例子:比如用户在一个click事件内,有个for循环,改变了data的某个属性N次(这个属性有一个watch监听它),这个watch监听函数只会执行几次?
- 只会执行一次。因为处理subs观察者队列 把watcher加入queue队列时,会有一个名叫has的去重对象(watcher会有自己id),保证watcher只会执行一次
-
细节5: watch: {..} 内的回调函数 如果又修改了data,那么还会触发update吗?
- 不会有多次vm._update(vm._render())
- 会用全局变量flushing控制,确保一次同步任务,只会有一次update 调度过程:
/* 部分非主线代码有删减,为的是 更好的关注主线,主线弄明白了,有余力,在去了解支线 */ Dep.prototype.notify = function notify () { var subs = this.subs.slice(); for (var i = 0, l = subs.length; i < l; i++) { subs[i].update(); } }; /** * Subscriber interface. Will be called when a dependency changes. */ Watcher.prototype.update = function update () { /* istanbul ignore else */ if (this.lazy) { // 用户写的computed会进入这里,不是异步的,结果是函数的返回值。在model层取值渲染的时候,会去跑函数,得到返回值 this.dirty = true; // 把对应的computed改成dirty(脏的)意味着,需要更新 } else if (this.sync) { // 一次同步任务。 比如this.aa=xx触发aa的watch回调函数,回调函数内又非异步的修改了this.bb=xxx,此时是一次同步任务,就会走里面 this.run(); } else { queueWatcher(this); // 用户写的 watch: { aa () {} } 往这里面走 } }; /** * Push a watcher into the watcher queue. 将watcher推入watcher队列。 * Jobs with duplicate IDs will be skipped unless it's pushed when the queue is being flushed. 除非在刷新队列时推送,否则将跳过具有重复 ID 的事件。 */ function queueWatcher (watcher) { var id = watcher.id; if (has[id] == null) { // has是一个去重对象,保证watcher只会执行一次 has[id] = true; if (!flushing) { // 全局变量flushing,确保一次同步任务,不会有多次vm._update(vm._render()),只会有一次update queue.push(watcher); // 把watcher加入queue队列 } else { // 避免重复的 // if already flushing, splice the watcher based on its id // if already past its id, it will be run next immediately. var i = queue.length - 1; while (i > index && queue[i].id > watcher.id) { i--; } queue.splice(i + 1, 0, watcher); } // queue the flush if (!waiting) { waiting = true; // nextTick原理是一个微任务,等同步任务执行完 把所有的watcher加入queue,在执行 flushSchedulerQueue,最终去run queue队列。 nextTick(flushSchedulerQueue); // 用户写的 watch: { aa () {} } 往这里面走。 是异步的 } } } /** * Flush both queues and run the watchers. */ function flushSchedulerQueue () { flushing = true; var watcher, id; // queue 是一个全局的 watcher list,存放了当次同步任务内的所有用户watcher // 此处我们的watcher有2个, 一个是watch: { aa () {} },另一个 关键渲染函数 "function () { vm._update(vm._render(), hydrating); }" for (index = 0; index < queue.length; index++) { watcher = queue[index]; if (watcher.before) { watcher.before(); // 执行vm._update(vm._render(), hydrating) 之前,beforeUpdate 在这里先执行 callHook(vm, 'beforeUpdate'); } id = watcher.id; has[id] = null; watcher.run(); // watcher都在这执行,比如 1.开发者写的 watch: { aa () {} } 监听函数,在此行执行。 2. 页面渲染的必备的函数"function () { vm._update(vm._render(), hydrating); }") } }
watcher.run();
- watcher都在这执行,比如
- 1.开发者写的 watch: { aa () {} } 监听函数,在此行执行。
- 2.页面渲染的必备的函数"function () { vm._update(vm._render(), hydrating); }")
- 会在确保在一次同步任务中的最后执行,因为要避免多次update
/** * Scheduler job interface. Will be called by the scheduler. */ Watcher.prototype.run = function run () { if (this.active) { /* 这一行很重要,有2个作用 1. 正常取data的值,比如在watch: {aa(newVal, oldVal) {}}中,newVal的值,就是从 this.get() 里拿到的 2. 页面渲染的必备的函数"function () { vm._update(vm._render(), hydrating); }") 就是在此处执行。*/ var value = this.get(); if ( value !== this.value || // Deep watchers and watchers on Object/Arrays should fire even // when the value is the same, because the value may // have mutated. isObject(value) || this.deep ) { // set new value var oldValue = this.value; this.value = value; if (this.user) { var info = "callback for watcher \"" + (this.expression) + "\""; // 开发者写的 watch: { aa () {} } 监听函数,在此行执行 invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info); } else { this.cb.call(this.vm, value, oldValue); } } } }; /** * Evaluate the getter, and re-collect dependencies. */ Watcher.prototype.get = function get () { pushTarget(this); var value; var vm = this.vm; try { // 页面渲染的必备的函数"function () { vm._update(vm._render(), hydrating); }") 就是在此处执行。 this.getter 保存了 vm._update(vm._render(), hydrating) value = this.getter.call(vm, vm); } catch (e) { if (this.user) { handleError(e, vm, ("getter for watcher \"" + (this.expression) + "\"")); } else { throw e } } finally { // "touch" every property so they are all tracked as // dependencies for deep watching if (this.deep) { traverse(value); } popTarget(); this.cleanupDeps(); } return value }; // 开发者写的 watch: { aa () {} } 监听函数,在此行执行 function invokeWithErrorHandling ( handler, context, args, vm, info ) { var res; try { // 开发者写的 watch: { aa () {} } 监听函数,在此行执行 res = args ? handler.apply(context, args) : handler.call(context); if (res && !res._isVue && isPromise(res) && !res._handled) { res.catch(function (e) { return handleError(e, vm, info + " (Promise/async)"); }); // avoid catch triggering multiple times when nested calls res._handled = true; } } catch (e) { handleError(e, vm, info); } return res }
nextTick()
- nextTick原理是一个微任务,用了nextTick的 函数存放在全局callbacks里面
function nextTick (cb, ctx) { var _resolve; callbacks.push(function () { // callbacks是全局的,存放 用了nextTick的 函数 if (cb) { try { cb.call(ctx); } catch (e) { handleError(e, ctx, 'nextTick'); } } else if (_resolve) { _resolve(ctx); } }); if (!pending) { pending = true; timerFunc(); // nextTick原理是一个微任务,用了nextTick的 函数存放在callbacks里面 } if (!cb && typeof Promise !== 'undefined') { return new Promise(function (resolve) { _resolve = resolve; }) } } var p = Promise.resolve(); // 微任务 timerFunc = function () { // 微任务 p.then(flushCallbacks); if (isIOS) { setTimeout(noop); } }; function flushCallbacks () { pending = false; var copies = callbacks.slice(0); // callbacks是全局的,存放 用了nextTick的 函数 callbacks.length = 0; for (var i = 0; i < copies.length; i++) { copies[i](); // 执行callbacks } }
-
-
触发好了用户写的watch:{ ... }的回调函数之后,最后要执行 页面渲染的必备的函数"function () { vm._update(vm._render(), hydrating); }"
- 后面就是执行 关键渲染函数vm._update(vm._render(), hydrating),和编译过程里面的渲染是一样的了,可以看我的另一篇这里:vue源码解析之编译过程-含2种模式(及vue-loader作用)
调度过程总结
场景是:修改data里面的数据(假设是修改 this.aa = 123),页面上对应的位子得到update
过程总结:
- 触发aa的set监听函数
- 处理aa的订阅者队列subs(订阅者队列包含:computed,watch,关键渲染函数)
- computed对应的 改成 this.dirty = true,下次生成vnode过程中 在model层取值的时候,就知道对应computed要重新计算了。否则会用缓存
- watch 里面的回调函数会放到全局对象queue队列里面去。并且由nextTick控制,同步任务期间只会一直加入进queue队列 并不会执行,同步任务结束后,才会开始run queue队列
- 不立即执行watch的好处是:比如用户在一个click事件内,有个for循环,改变了data的某个属性N次,比如数组的push N次(这个属性有一个watch监听它),这个watch监听函数只会执行一次,不用执行N次(会有一个名叫has的去重对象(watcher会有自己id),保证watcher只会执行一次 )
- queue队列的最后一个是 关键渲染函数 function () { vm._update(vm._render(), hydrating); }")
- 会有全局变量flushing控制,确保一次同步任务,只会执行一次 关键渲染函数
- nextTick原理是微任务,
- 后面就是执行 关键渲染函数vm._update(vm._render(), hydrating),和编译过程里面的渲染是一样的了,可以看我的另一篇这里:vue源码解析之编译过程-含2种模式(及vue-loader作用)
再谈一下vue的双向绑定v-model原理
实际是一个语法糖(语法糖的意思 可以理解为简写,下面第二行是真实的样子)
<input v-model='abc' /> // 语法糖
<input :value='abc' @input='abc = $event.target.value' /> // 真实的样子
注:value是表单控件的值。以名字/值对的形式随表单一起提交
过程是: DOM Listeners -> Model -> Data Bindings -> render 到页面上
- DOM Listeners 比如 input事件,select事件(所以v-model只支持表单元素)
- Model是Model层(数据层),通过事件,修改this.abc = $event.target.value。然后会触发this.aa的set函数,然后会触发 关键渲染函数vm._update(vm._render(), hydrating)。最终渲染到页面上
码字不易,点赞鼓励