前言
通常, vue 给我们的印象是 "小巧易用", 凭借其简洁明了的模板开发方式, 以及强大的指令系统, 我们可以轻轻松松几行代码搞定一个数据双向绑定的页面. 但是, 这背后 Vue 帮我们做了多少工作, 我们是知之甚少的.
Vue 就像一个黑盒子, 我们输入一些数据, 它给我们输出一个渲染好的页面. 对于开发, 这很方便, 我们不需要关心何时去触发更新, 因为 Vue 已经帮我们做了. 但是, 在面对一些棘手问题时, 我们需要去分析数据是何时变化的, 被谁更改的, 为什么会触发更新, 为什么又不能触发更新, 等等问题, 这个时候就很难, 因为这些逻辑都隐藏在黑盒子内部, 我们无法观察, 更无法控制.
所以说, 想要用好 Vue 其实还挺难的.
面对这些问题, 我们往往会基于个人的经验, 个人的理解去分析问题, 会花费大量时间去 debug, 这很低效. 不如我们先抽点时间, 了解一下 Vue 的实现细节吧.
问题分类
其实按照 Vue 的开发方式, 一般都会有如下流程:
先初始化一个 Vue 实例, 然后传入各种配置信息
尝试修改一些数据
期待视图更新
所以, 我们遇到的问题可以归为以下三类:
Vue 的初始化流程是怎样的
数据的更改会不会触发视图的更新
数据的更改何时会触发视图的更新
问题
Q1. 我对 Vue 的配置信息不理解(第一类问题)
通常我们的一个 Vue 实例是这样的:
- const options = {
- props: ...,
- data: ...,
- computed: ...,
- watch: ...,
- methods: ...,
- created: ...,
- mounted: ...
- }
- new Vue(options).$mount('#app')
也许, 我们很清楚每个属性的含义, 但是我们却不知道这些属性是如何被调用的, 是以怎样的顺序来初始化的. 通过这种 "无顺序" 配置对象的方式来构建 Vue 实例, 我们先天性的丢失了一些重要的 "信息", 那就是代码的执行顺序, 这会带来一系列理解上的问题.
其实, 通过文档里的生命周期图, 或者看源码, 我们就会知道, 在 new Vue(opions)的这个过程中, 即 Vue 这个类的构造函数中, Vue 会依次 "同步" 地把 props,methods,data,computed,watch 取出来, 然后分别初始化, 之后再执行我们的 created 方法. 最后, 在我们执行 $mount('#App)之后, 再执行我们的 mounted 方法.
知道了这个顺序, 就能解决很多疑惑比如:
在 watch, created, mounted 里面第一次用计算属性的时候, 计算属性已经初始化了吗?
data 属性能否使用计算属性来初始化
在 created 里修改 watch 监听的属性, 会不会触发 watch 的执行? 如果加了 immediate 又会执行几次呢?
... 等等自己不确定的问题
Q2. 我修改数据为什么没有生效(第二类问题)
我们或多或少都会遇到, 明明自己改了数据, 可是视图就是不更新的问题, 举个例子:
- <template>
- <div>
- <p>{{lib}}</p>
- <p>{{detail.version}}</p>
- <p>{{detail.type}}</p>
- </div>
- </template>
- export default {
- data() {
- return {
- lib: 'vue',
- detail: {
- version: '2.5.1',
- // type: 'fe'
- }
- }
- },
- mounted() {
- this.detail.type = 'fe'
- }
- }
- // 输出
- vue
- 2.5.1
问题很容易看出, 是因为我们没有事先在 data 里声明好 type 这个属性, 所以在 data 的初始化过程中, 并没有 observe(即把属性转变为 getter 和 setter)这个 type 属性, 所以我们的修改是不会触发 setter 的, 也就不会引起视图更新.
接下来, 我们更新一下代码:
- // 把 mounted 改成 created
- created() {
- this.detail.type = 'fe'
- }
我们神奇的发现, 竟然显示出来了, 这跟我们刚才的结论相悖呀. 其实, 我们的结论没错, 之所以这次能显示出来, 纯粹是巧合. 我们知道 created 是在 data 初始化之后调用的, 同时又是在 mounted 之前调用的, 所以 data 在 mounted 之前就被赋予了一个未被 observe 的属性 type, 然后在 $mount 的时候, 顺带显示在页面上了. 也就是说, 这次的显示, 并非 setter 的触发, 而是本来 data 就已经有了 type 属性罢了.
我们再来改一下:
- created() {
- this.detail.type = 'fe'
- },
- mounted() {
- this.detail.type = 'be'
- }
我们会发现, 依旧显示的是 fe, 而不是 be, 这验证了上一个结论.
一句话: 只有被 observe 的数据改变后才会触发视图的更新
Q3:Vue 是如何使用事件循环的(第三类问题)
这个问题比较抽象, 具体一点举几个例子:
怎么准确的判断 watch 的 handler 什么时候执行, 执行几次
我更改了数据, 何时才会触发视图更新
$nextTick 和 setTimeou 区别
可以说, Vue 的本质其实就是一套精心设计的事件循环系统, 要弄懂 Vue 必须弄懂两件事:
事件循环本身
Vue 的事件循环系统
事件循环需要理解到 task 和 microTask 的层次, 而 Vue 的事件循环系统需要读透文档, 理解作者的思想, 另外多看源码. 下面我们举一些例子
Q3-1:watch 的 handler 什么时候执行
- export default {
- data() {
- return {
- lib: 'vue',
- lock: true
- }
- },
- watch: {
- lib(val, old) {
- if(this.lock) {
- console.log(`lib changed from ${old} to ${val}`)
- }
- }
- },
- created() {
- this.lock = false;
- this.lib = 'react'
- this.lock = true;
- }
- }
- // 输出
- lib changed from vue to react
按照我们常规的理解, 当我们执行 this.lib = 'react 的时候, 理应当触发 watch, 而此时 lock 其实是 false, 不应该输出. 这里有一个『陷阱』, 就是我们错误的以为 Vue 的内部的执行机制是同步的, 而事实上, Vue 会充分利用事件循环做一些异步的事情, 比如这里的 handler 执行机制.
根据 Q1, 并结合一定的源码阅读, 我们知道本次示例的初始化顺序为:
先初始化 data, 取出 lib,lock 两个属性, 分别 observe
初始化 watch, 取出 lib 属性的 key 和 value(即 handler), 并用他们初始化一个 watcher, 从而形成一个 watcher 对 lib 属性的监听, 并等待一个『合适的时机』去执行我们给定的 handler
执行 created 声明周期函数, 此时数据的初始化工作已经完成, 接下来先执行 this.lock = false, 这不会影响什么. 再执行 this.lib = 'react', 此时触发了 lib 的 setter 方法, 接着 Vue 会找出所以正在监听 lib 属性的 watcher, 并执行其 update 方法
- update () {
- queueWatcher(this)
- }
我们发现, watcher 并没有立即执行 handler, 而是发起了一个 queueWatcher:
- flushing = false
- waiting = true
- export function queueWatcher (watcher) {
- if (!flushing) {
- queue.push(watcher)
- }
- // queue the flush
- if (!waiting) {
- waiting = true
- // flushSchedulerQueueh 会依次把 queue 中的 watcher 拿出来执行
- nextTick(flushSchedulerQueue)
- }
- }
已知 flushing 和 wating 默认值都是 false, 所以『第一次触发 watch』代码会像这样执行
- queue.push(watcher)
- nextTick(flushSchedulerQueue)
可以看到, 我们的 handler 会在 nextTick 时执行(关于 nextTick 我们后面会讲, 在这里可以暂时理解成 setTimeout), 而等到下一次事件循环, this.lock = true 已经执行, 所以我们 console 了出来.
总结: 我们现在我们对 Vue 的事件循环机制有了一个认知, 即 Vue 中数据的变化所引起的响应, 是依托事件循环就机制来完成的.
Q3-2: 深入 Vue 的事件循环细节
仅知道数据的响应是依托于事件循环还不够, 因为我们的代码会越写越复杂, 常常会有多个异步任务, 此时我们需要准确的知道, 我们的 watch 何时触发, 我们的视图何时更新. 因此, 我们需要更加细致的去研究.
Q3-2-1: 直接修改值
- export default {
- data() {
- return {
- lib: 'vue'
- }
- },
- watch: {
- lib(val, old) {
- console.log(`lib changed from ${old} to ${val}`)
- }
- },
- created() {
- this.lib = 'react'
- this.lib = 'angular'
- }
- }
- // 输出
- lib changed from vue to angular
问题有两个:
为什么 watch 只执行一次
为什么是 angular
虽然这个问题比较蠢, 但是为了帮助我们理解后面的例子, 我们还是得深入的去研究一下.
根据 Q3-1,watch 中的 lib 会创建一个 watcher, 并监听 lib 属性的变化. 当我们第一次 this.lib = 'react', 此时会触发 lib 的 setter, 并找到正在监听的 watcher, 依次执行 watcher 的 update 方法, update 方法会调用 queueWatcher 方法, 现在我们看一下 queueWatcher 更完整一点的代码:
- /**
- * Push a watcher into the watcher queue.
- * Jobs with duplicate IDs will be skipped unless it's
- * pushed when the queue is being flushed.
- */
- export function queueWatcher (watcher: Watcher) {
- const id = watcher.id
- // has 维持着所有 watcher 的 id
- if (has[id] == null) {
- has[id] = true
- if (!flushing) {
- queue.push(watcher)
- } else {
- ...
- }
- // queue the flush
- if (!waiting) {
- waiting = true
- nextTick(flushSchedulerQueue)
- }
- }
- }
可以看到, 这里有一个 if (has[id] == null)的判断, 要知道, 我们 watch 只有一个 lib 属性, 所以只会初始化一个 watcher, 所以当第二次执行 this.lib = 'angular'的时候, queueWatcher 其实什么都没干.
所以结果是, 虽然有两次 lib 的变化, 但是 watcher 会在下一次事件循环, 只执行一次 handler.
Q3-2-2 在 setTimeout 里修改值
- export default {
- data() {
- return {
- lib: 'vue'
- }
- },
- watch: {
- lib(val, old) {
- console.log(`lib changed from ${old} to ${val}`)
- }
- },
- created() {
- setTimeout(() => {
- this.lib = 'react'
- }, 0)
- setTimeout(() => {
- this.lib = 'angular'
- }, 0)
- }
- }
- // 输出
- lib changed from vue to react
- lib changed from react to angular
为什么现在又输出两次了? 再看一遍 queueWatcher 方法:
- export function queueWatcher (watcher: Watcher) {
- const id = watcher.id
- // has 维持着所有 watcher 的 id
- if (has[id] == null) {
- has[id] = true
- if (!flushing) {
- queue.push(watcher)
- } else {
- ...
- }
- // queue the flush
- if (!waiting) {
- waiting = true
- nextTick(flushSchedulerQueue)
- }
- }
- }
还需要看 flushSchedulerQueue 方法
- /**
- * Flush both queues and run the watchers.
- */
- function flushSchedulerQueue () {
- flushing = true
- let watcher, id
- ...
- // do not cache length because more watchers might be pushed
- // as we run existing watchers
- for (index = 0; index <queue.length; index++) {
- watcher = queue[index]
- if (watcher.before) {
- watcher.before()
- }
- id = watcher.id
- has[id] = null
- watcher.run()
- ...
- }
- ...
- }
点我查看预备知识(事件循环中的 task 和 microTask)
当 Vue 执行 created 方法, 会执行两个 setTimeout, 而 setTimeout 又是 task, 所以我们写的两个 setTimeout 函数, 会分别在两次事件循环中执行, 而不是一次, 如下:
第一次事件循环(script 任务, 也是 task):vue 初始化, created 方法发起两个异步任务
第二次事件循环(task): 开始执行第一个 setTimeout 的 callback
第三次事件循环(task): 开始执行第二个 setTimeout 的 callback
在第二次事件循环中, 我们修改了 lib, 同时出发了相应的 watcher, 最终执行 queueWatcher 方法, 于是当前的 watcher 被 push 到 queue 队列, 待到 nextTick 执行, 而 nextTick 是 microTask, 于是我们上面的过程变成了:
第一次事件循环(script 任务):vue 初始化, created 方法发起两个异步任务
第二次事件循环(task): 开始执行第一个 setTimeout 的 callback
第二次事件循环(microTask):
nextTick(flushSchedulerQueue)
中执行 watcher.run(即执行 handler), 并清空 has[id]
第三次事件循环(task): 开始执行第二个 setTimeout 的 callback
注意: 在 JS 的一次事件循环中, 先执行所有同步代码, 之后, 会从 macroTask 队列里取出 1 个 macroTask 执行. 然后, 再取出所有 microTask 队列里的 microTask, 并依次执行. 这整个过程结束后, 便会开启下一次事件循环.
接下来到了第三次事件循环, 我们再次修改 lib, 同样的过程, 因为上一步已经清空了 has[id], 所以本次 lib 的更新其实跟上一次一模一样, 所以过程变成了:
第一次事件循环(script 任务):vue 初始化, created 方法发起两个异步任务
第二次事件循环(task): 开始执行第一个 setTimeout 的 callback
第二次事件循环(microTask):
nextTick(flushSchedulerQueue)
中执行 watcher.run(即执行 handler), 并清空 has[id]
第三次事件循环(task): 开始执行第二个 setTimeout 的 callback
第三次事件循环(microTask):
nextTick(flushSchedulerQueue)
中执行 watcher.run(即执行 handler), 并清空 has[id]
所以, 会打印两次.
Q3-2-3 在 nextTick 里修改值
- export default {
- data() {
- return {
- lib: 'vue'
- }
- },
- watch: {
- lib(val, old) {
- console.log(`lib changed from ${old} to ${val}`)
- }
- },
- created() {
- this.$nextTick(() => {
- this.lib = 'react'
- })
- this.$nextTick(() => {
- this.lib = 'angular'
- })
- }
- }
问题是, 为什么 watch 只执行了一次.
第一次事件循环(script 任务):vue 初始化, created 方法发起两个 microTask 异步任务
第二次事件循环(task): 没有 macroTask
第二次事件循环(microTask): 现在 microTask 任务队列是这样的
[callback1, callback2]
, 从 microTask 队列取出第一个 callback1, 执行, 触发 lib 更新, 从而执行 queueWatcher, 在里面又触发了一个 nextTick(microTask)异步任务, 并 push 到 microTask 任务队列, 队列变成这样
[callback2, callback3]
第二次事件循环 (microTask): 从 micoTask 队列取出 callback2, 执行, 触发 lib 更新, 从而执行 queueWatcher, 而此时 has[id] 已经有值 (因为 callback3 还没执行, 因此 has[id] 还没被清空), 所以直接略过
第二次事件循环(microTask): 从 microTask 队列取出 callback3, 执行
可以看到, 这就是只打印一次的原因了.
总结
其实 Vue 的设计思想就是: 事件循环 + 双向绑定, 只要我们搞明白这两点, 我们就可以真正的掌握 Vue, 写出稳定, 可预测的代码, 轻松的解决使用中遇到的各种问题.
有了设计思想还不够, 还需要工具去实现这种思想, 那就是 compiler 和 vdom 干的事了, 还有很多东西要学呢.
来源: https://juejin.im/post/5c206fb8f265da61141c9c89