新闻资讯

新闻资讯 媒体报道

不夸张,这真的是本Vue.js宝藏书!360前端工程师Vue.js源码解析

编辑:011     时间:2022-01-20

这些年来,前端行业一直在飞速发展。行业的进步,导致对从业人员的要求不断攀升。放眼未来,虽然仅仅会用某些框架还可以找到工作,但仅仅满足于会用,一定无法走得更远。随着越来越多“聪明又勤奋”的人加入前端行列,能否洞悉前沿框架的设计和实现已经成为高级人才与普通人才的“分水岭”。

本文将通过探究Vue.js渲染中变化侦测的实现原理,来解读Github上最流行Web框架Vue.js源码背后的思想,让你亲身体验从“知其然”到“知其所以然”的蜕变!

变化侦测的实现原理

Vue.js 最独特的特性之一是看起来并不显眼的响应式系统。数据模型仅仅是普通的JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单、直接。不过理解其工作原理同样重要,这样你可以回避一些常见的问题。
——官方文档

从状态生成DOM,再输出到用户界面显示的一整套流程叫作渲染,应用在运行时会不断地进行重新渲染。而响应式系统赋予框架重新渲染的能力,其重要组成部分是变化侦测。变化侦测是响应式系统的核心,没有它,就没有重新渲染。框架在运行时,视图也就无法随着状态的变化而变化。

简单来说,变化侦测的作用是侦测数据的变化。当数据变化时,会通知视图进行相应的更新。正如文档中所说,深入理解变化侦测的工作原理,既可以帮助我们在开发应用时回避一些很常见的问题,也可以在应用程序出问题时,快速调试并修复问题。

本文中,我们将针对变化侦测的实现原理做一个详细介绍,并且会带着你一步一步从0 到1实现一个变化侦测的逻辑。

什么是变化侦测

Vue.js 会自动通过状态生成DOM,并将其输出到页面上显示出来,这个过程叫渲染。Vue.js的渲染过程是声明式的,我们通过模板来描述状态与DOM之间的映射关系。

通常,在运行时应用内部的状态会不断发生变化,此时需要不停地重新渲染。这时如何确定状态中发生了什么变化?

变化侦测就是用来解决这个问题的,它分为两种类型:一种是“推”(push),另一种是“拉”(pull)。

Angular 和React 中的变化侦测都属于“拉”,这就是说当状态发生变化时,它不知道哪个状态变了,只知道状态有可能变了,然后会发送一个信号告诉框架,框架内部收到信号后,会进行一个暴力比对来找出哪些DOM 节点需要重新渲染。这在Angular 中是脏检查的流程,在React中使用的是虚拟DOM。

而Vue.js 的变化侦测属于“推”。当状态发生变化时,Vue.js 立刻就知道了,而且在一定程度上知道哪些状态变了。因此,它知道的信息更多,也就可以进行更细粒度的更新。

所谓更细粒度的更新,就是说:假如有一个状态绑定着好多个依赖,每个依赖表示一个具体的DOM节点,那么当这个状态发生变化时,向这个状态的所有依赖发送通知,让它们进行DOM更新操作。相比较而言,“拉”的粒度是最粗的。

但是它也有一定的代价,因为粒度越细,每个状态所绑定的依赖就越多,依赖追踪在内存上的开销就会越大。因此,从Vue.js 2.0 开始,它引入了虚拟DOM,将粒度调整为中等粒度,即一个状态所绑定的依赖不再是具体的DOM 节点,而是一个组件。这样状态变化后,会通知到组件,组件内部再使用虚拟DOM 进行比对。这可以大大降低依赖数量,从而降低依赖追踪所消耗的内存。

Vue.js 之所以能随意调整粒度,本质上还要归功于变化侦测。因为“推”类型的变化侦测可以随意调整粒度。

如何追踪变化

关于变化侦测,首先要问一个问题,在JavaScript(简称JS)中,如何侦测一个对象的变化?

其实这个问题还是比较简单的。学过JavaScript 的人都知道,有两种方法可以侦测到变化:使用Object.defineProperty 和ES6 的Proxy。

由于ES6 在浏览器中的支持度并不理想,到目前为止Vue.js 还是使用Object.define-Property 来实现的,所以文中也会使用它来介绍变化侦测的原理。

由于使用Object.defineProperty 来侦测变化会有很多缺陷,所以Vue.js 的作者尤雨溪说日后会使用Proxy 重写这部分代码。好在本文讲的是原理和思想,所以即便以后用Proxy 重写了这部分代码,文中介绍的原理也不会变。

知道了Object.defineProperty 可以侦测到对象的变化,那么我们可以写出这样的代码:

01 function defineReactive (data, key, val) {
02   Object.defineProperty(data, key, {
03     enumerable: true,
04     configurable: true,
05     get: function () {
06       return val
07     },
08     set: function (newVal) {
09       if(val === newVal){
10         return
11       }
12       val = newVal
13     }
14   })
15 }

这里的函数defineReactive 用来对Object.defineProperty 进行封装。从函数的名字可以看出,其作用是定义一个响应式数据。也就是在这个函数中进行变化追踪,封装后只需要传递data、key 和val 就行了。

封装好之后,每当从data 的key 中读取数据时,get 函数被触发;每当往data 的key 中设置数据时,set 函数被触发。

如何收集依赖

如果只是把Object.defineProperty 进行封装,那其实并没什么实际用处,真正有用的是收集依赖。

现在我要问第二个问题:如何收集依赖?

思考一下,我们之所以要观察数据,其目的是当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。

举个例子:

01 <template>
02   <h1>{{ name }}</h1>
03 </template>

该模板中使用了数据name,所以当它发生变化时,要向使用了它的地方发送通知。

注意:在Vue.js 2.0 中,模板使用数据等同于组件使用数据,所以当数据发生变化时,会将通知发送到组件,然后组件内部再通过虚拟DOM重新渲染。

对于上面的问题,我的回答是,先收集依赖,即把用到数据name 的地方收集起来,然后等属性发生变化时,把之前收集好的依赖循环触发一遍就好了。

总结起来,其实就一句话,在getter 中收集依赖,在setter 中触发依赖。

依赖收集在哪里

现在我们已经有了很明确的目标,就是要在getter 中收集依赖,那么要把依赖收集到哪里去呢?

思考一下,首先想到的是每个key 都有一个数组,用来存储当前key 的依赖。假设依赖是一个函数,保存在window.target 上,现在就可以把defineReactive 函数稍微改造一下:

01 function defineReactive (data, key, val) {
02   let dep = [] // 新增
03   Object.defineProperty(data, key, {
04     enumerable: true,
05     configurable: true,
06     get: function () {
07       dep.push(window.target) // 新增
08       return val
09     },
10     set: function (newVal) {
11       if(val === newVal){
12         return
13       }
14       // 新增
15       for (let i = 0; i < dep.length; i++) {
16         dep[i](newVal, val)
17       }
18       val = newVal
19     }
20     })
21 }

这里我们新增了数组dep,用来存储被收集的依赖。

然后在set 被触发时,循环dep 以触发收集到的依赖。

但是这样写有点耦合,我们把依赖收集的代码封装成一个Dep 类,它专门帮助我们管理依赖。使用这个类,我们可以收集依赖、删除依赖或者向依赖发送通知等。其代码如下:

01 export default class Dep {
02   constructor () {
03     this.subs = []
04   }
05
06   addSub (sub) {
07     this.subs.push(sub)
08   }
09
10   removeSub (sub) {
11     remove(this.subs, sub)
12   }
13
14   depend () {
15     if (window.target) {
16       this.addSub(window.target)
17     }
18   }
19
20   notify () {
21     const subs = this.subs.slice()
22     for (let i = 0, l = subs.length; i < l; i++) {
23       subs[i].update()
24     }
25   }
26 }
27
28 function remove (arr, item) {
29   if (arr.length) {
30     const index = arr.indexOf(item)
31     if (index > -1) {
32       return arr.splice(index, 1)
33     }
34   }
35 }

之后再改造一下defineReactive:

01 function defineReactive (data, key, val) {
02   let dep = new Dep() // 修改
03   Object.defineProperty(data, key, {
04     enumerable: true,
05     configurable: true,
06     get: function () {
07       dep.depend() // 修改
08       return val
09     },
10     set: function (newVal) {
11       if(val === newVal){
12         return
13       }
14       val = newVal
15       dep.notify() // 新增
16     }
17   })
18 }

此时代码看起来清晰多了,这也顺便回答了上面的问题,依赖收集到哪儿?收集到Dep 中。

依赖是谁

在上面的代码中,我们收集的依赖是window.target,那么它到底是什么?我们究竟要收集谁呢?

收集谁,换句话说,就是当属性发生变化后,通知谁。

我们要通知用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个watch,这时需要抽象出一个能集中处理这些情况的类。然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个。接着,它再负责通知其他地方。所以,我们要抽象的这个东西需要先起一个好听的名字。嗯,就叫它Watcher 吧。

现在就可以回答上面的问题了,收集谁?Watcher!

什么是Watcher

Watcher 是一个中介的角色,数据发生变化时通知它,然后它再通知其他地方。

关于Watcher,先看一个经典的使用方式:

01 // keypath
02 vm.$watch('a.b.c', function (newVal, oldVal) {
03   // 做点什么
04 })

这段代码表示当data.a.b.c 属性发生变化时,触发第二个参数中的函数。

思考一下,怎么实现这个功能呢?好像只要把这个watcher 实例添加到data.a.b.c 属性的Dep 中就行了。然后,当data.a.b.c 的值发生变化时,通知Watcher。接着,Watcher 再执行参数中的这个回调函数。

好,思考完毕,写出如下代码:

01 export default class Watcher {
02   constructor (vm, expOrFn, cb) {
03     this.vm = vm
04     // 执行this.getter(),就可以读取data.a.b.c 的内容
05     this.getter = parsePath(expOrFn)
06     this.cb = cb
07     this.value = this.get()
08   }
09
10   get() {
11     window.target = this
12     let value = this.getter.call(this.vm, this.vm)
13     window.target = undefined
14     return value
15   }
16
17   update () {
18     const oldValue = this.value
19     this.value = this.get()
20     this.cb.call(this.vm, this.value, oldValue)
21   }
22 }

这段代码可以把自己主动添加到data.a.b.c 的Dep 中去,是不是很神奇?

因为我在get 方法中先把window.target 设置成了this,也就是当前watcher 实例,然后再读一下data.a.b.c 的值,这肯定会触发getter。

触发了getter,就会触发收集依赖的逻辑。而关于收集依赖,上面已经介绍了,会从window.target 中读取一个依赖并添加到Dep 中。

这就导致,只要先在window.target 赋一个this,然后再读一下值,去触发getter,就可以把this 主动添加到keypath 的Dep 中。有没有很神奇的感觉啊?

依赖注入到Dep 中后,每当data.a.b.c 的值发生变化时,就会让依赖列表中所有的依赖循环触发update 方法,也就是Watcher 中的update 方法。而update 方法会执行参数中的回调函数,将value 和oldValue 传到参数中。

所以,其实不管是用户执行的vm.$watch('a.b.c', (value, oldValue) => {}),还是模板中用到的data,都是通过Watcher 来通知自己是否需要发生变化。

这里有些小伙伴可能会好奇上面代码中的parsePath 是怎么读取一个字符串的keypath 的,下面用一段代码来介绍其实现原理:

01 /**
02  * 解析简单路径
03  */
04 const bailRE = /[^w.$]/
05 export function parsePath (path) {
06   if (bailRE.test(path)) {
07     return
08   }
09   const segments = path.split('.')
10   return function (obj) {
11     for (let i = 0; i < segments.length; i++) {
12       if (!obj) return
13       obj = obj[segments[i]]
14     }
15     return obj
16   }
17 }

可以看到,这其实并不复杂。先将keypath 用 . 分割成数组,然后循环数组一层一层去读数据,最后拿到的obj 就是keypath 中想要读的数据。

递归侦测所有key

现在,其实已经可以实现变化侦测的功能了,但是前面介绍的代码只能侦测数据中的某一个属性,我们希望把数据中的所有属性(包括子属性)都侦测到,所以要封装一个Observer 类。这个类的作用是将一个数据内的所有属性(包括子属性)都转换成getter/setter 的形式,然后去追踪它们的变化:

01 /**
02  * Observer 类会附加到每一个被侦测的object 上。
03  * 一旦被附加上,Observer 会将object 的所有属性转换为getter/setter 的形式
04  * 来收集属性的依赖,并且当属性发生变化时会通知这些依赖
05  */
06 export class Observer {
07   constructor (value) {
08     this.value = value
09
10     if (!Array.isArray(value)) {
11       this.walk(value)
12     }
13   }
14
15   /**
16    * walk 会将每一个属性都转换成getter/setter 的形式来侦测变化
17    * 这个方法只有在数据类型为Object 时被调用
18    */
19   walk (obj) {
20     const keys = Object.keys(obj)
21     for (let i = 0; i < keys.length; i++) {
22       defineReactive(obj, keys[i], obj[keys[i]])
23     }
24   }
25 }
26
27 function defineReactive (data, key, val) {
28   // 新增,递归子属性
29   if (typeof val === 'object') {
30     new Observer(val)
31   }
32   let dep = new Dep()
33   Object.defineProperty(data, key, {
34     enumerable: true,
35     configurable: true,
36     get: function () {
37       dep.depend()
38       return val
39     },
40     set: function (newVal) {
41       if(val === newVal){
42         return
43       }
44
45       val = newVal
46       dep.notify()
47     }
48   })
49 }

在上面的代码中,我们定义了Observer 类,它用来将一个正常的object 转换成被侦测的object。

然后判断数据的类型,只有Object 类型的数据才会调用walk 将每一个属性转换成getter/setter 的形式来侦测变化。

最后,在defineReactive 中新增new Observer(val)来递归子属性,这样我们就可以把data 中的所有属性(包括子属性)都转换成getter/setter 的形式来侦测变化。

当data 中的属性发生变化时,与这个属性对应的依赖就会接收到通知。

也就是说,只要我们将一个object 传到Observer 中,那么这个object 就会变成响应式的object。

关于Object 的问题

前面介绍了Object 类型数据的变化侦测原理,了解了数据的变化是通过getter/setter 来追踪的。也正是由于这种追踪方式,有些语法中即便是数据发生了变化,Vue.js 也追踪不到。

比如,向object 添加属性:

01 var vm = new Vue({
02   el: '#el',
03   template: '#demo-template',
04   methods: {
05     action () {
06       this.obj.name = 'berwin'
07     }
08   },
09   data: {
10   obj: {}
11   }
12 })

在action 方法中,我们在obj 上面新增了name 属性,Vue.js 无法侦测到这个变化,所以不会向依赖发送通知。

再比如,从obj 中删除一个属性:

01 var vm = new Vue({
02   el: '#el',
03   template: '#demo-template',
04   methods: {
05     action () {
06       delete this.obj.name
07     }
08   },
09   data: {
10     obj: {
11       name: 'berwin'
12     }
13   }
14 })

在上面的代码中,我们在action 方法中删除了obj 中的name 属性,而Vue.js 无法侦测到这个变化,所以不会向依赖发送通知。

Vue.js 通过Object.defineProperty 来将对象的key 转换成getter/setter 的形式来追踪变化,但getter/setter 只能追踪一个数据是否被修改,无法追踪新增属性和删除属性,所以才会导致上面例子中提到的问题。

但这也是没有办法的事,因为在ES6 之前,JavaScript 没有提供元编程的能力,无法侦测到一个新属性被添加到了对象中,也无法侦测到一个属性从对象中删除了。为了解决这个问题,Vue.js 提供了两个API——vm.$set 与vm.$delete,本文暂不介绍

总结

变化侦测就是侦测数据的变化。当数据发生变化时,要能侦测到并发出通知。

Object 可以通过Object.defineProperty 将属性转换成getter/setter 的形式来追踪变化。读取数据时会触发getter,修改数据时会触发setter。

我们需要在getter 中收集有哪些依赖使用了数据。当setter 被触发时,去通知getter 中收集的依赖数据发生了变化。

收集依赖需要为依赖找一个存储依赖的地方,为此我们创建了Dep,它用来收集依赖、删除依赖和向依赖发送消息等。

所谓的依赖,其实就是Watcher。只有Watcher 触发的getter 才会收集依赖,哪个Watcher触发了getter,就把哪个Watcher 收集到Dep 中。当数据发生变化时,会循环依赖列表,把所有的Watcher 都通知一遍。

Watcher 的原理是先把自己设置到全局唯一的指定位置(例如window.target),然后读取数据。因为读取了数据,所以会触发这个数据的getter。接着,在getter 中就会从全局唯一的那个位置读取当前正在读取数据的Watcher,并把这个Watcher 收集到Dep 中去。通过这样的方式,Watcher 可以主动去订阅任意一个数据的变化。

此外,我们创建了Observer 类,它的作用是把一个object 中的所有数据(包括子数据)都转换成响应式的,也就是它会侦测object 中所有数据(包括子数据)的变化。

由于在ES6 之前JavaScript 并没有提供元编程的能力,所以在对象上新增属性和删除属性都无法被追踪到。

图2-1 给出了Data、Observer、Dep 和Watcher 之间的关系。

图2-1 Data、Observer、Dep 和Watcher 之间的关系

Data 通过Observer 转换成了getter/setter 的形式来追踪变化。

当外界通过Watcher 读取数据时,会触发getter 从而将Watcher 添加到依赖中。

当数据发生了变化时,会触发setter,从而向Dep 中的依赖(Watcher)发送通知。

Watcher 接收到通知后,会向外界发送通知,变化通知到外界后可能会触发视图更新,也有可能触发用户的某个回调函数等。

——本文节选自 《深入浅出Vue.js》

刘博文 著

360前端工程师精心打造!
360 奇舞团团长月影和《JavaScript高级程序设计》译者李松峰作序推荐

同样在360工作的《JavaScript高级程序设计(第3版)》译者李松峰在图灵待过几年,很熟悉什么样的书会更畅销,他早就跟博文说过:要想让技术书畅销,一是读者定位必须是新手,因为新手人数众多;二是要注重实用,书中的例子最好能立即照搬到项目上。

然而,这本书的读者定位显然不是新手,而且书中的源码分析似乎也不能直接套用到项目上。其实这也是没办法的事,因为博文写这本书的初衷就是把自己研究Vue.js 源码的心得分享出来。

Vue.js 是一个优秀的前端框架。一个优秀的前端框架如果没有一本优秀的解读著作,确实是一大缺憾。应该说,本书正是一本优秀的Vue.js 源码解读专著。

全书从一个新颖的“入口点”——“变化侦测”切入,逐步过渡到“虚拟DOM”和“模板编译”,最后展开分析Vue.js的整体架构。如果想读懂这本书,读者不仅要有一些Vue.js 的实际使用经验,而且还要有一些编译原理(比如AST)相关的知识储备,这样才能更轻松地理解模板解析、优化与代码生成的原理。

本书最后几章对Vue.js 的实例方法和全局API,以及生命周期、指令和过滤器的解读,虽然借鉴了Vue.js 官方文档,但作者更注重实现原理的分析,弥补了文档的不足。

早一天读到,早一天受益,仅此而已。

目录

第1章 Vue.js简介

第一篇 变化侦测

第2章 Object的变化侦测

第3章 Array的变化侦测

第4章 变化侦测相关的API实现原理

第二篇 虚拟DOM

第5章 虚拟DOM简介

第6章 VNode

第7章 patch

第三篇 模板编译原理

第8章 模板编译

第9章    解析器 

第10章 优化器

第11章 代码生成器

第四篇 整体流程

第12章 架构设计与项目结构

第13章 实例方法与全局API的实现原理

第14章 生命周期

第15章 指令的奥秘

第16章 过滤器的奥秘


郑重声明:本文版权归原作者所有,转载文章仅为传播更多信息之目的,如作者信息标记有误,请第一时间联系我们修改或删除,多谢。

回复列表

相关推荐