不夸张,这真的是本Vue.js宝藏书!
Posted 图灵教育
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了不夸张,这真的是本Vue.js宝藏书!相关的知识,希望对你有一定的参考价值。
优秀源码背后的思想是永恒的、普适的
这些年来,前端行业一直在飞速发展。行业的进步,导致对从业人员的要求不断攀升。放眼未来,虽然仅仅会用某些框架还可以找到工作,但仅仅满足于会用,一定无法走得更远。
随着越来越多“聪明又勤奋”的人加入前端行列,能否洞悉前沿框架的设计和实现已经成为高级人才与普通人才的“分水岭”。
本文将通过探究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之所以能随意调整粒度,本质上还要归功于变化侦测。因为“推”类型的变化侦测可以随意调整粒度。
▶▶ 如何追踪变化
关于变化侦测,首先要问一个问题,在JS中,如何侦测一个对象的变化?
其实这个问题还是比较简单的。学过JS的人都知道,有两种方法可以侦测到变化:使用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将每一个属性转换成gettertter的形式来侦测变化。
最后,在defineReactive中新增new Observer(val)来递归子属性,这样我们就可以把data中的所有属性(包括子属性)都转换成gettertter的形式来侦测变化。
当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将属性转换成gettertter的形式来追踪变化。读取数据时会触发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并没有提供元编程的能力,所以在对象上新增属性和删除属性都无法被追踪到。
下图中给出了Data、Observer、Dep和Watcher之间的关系。
Data通过Observer转换成了gettertter的形式来追踪变化。
当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到依赖中。
当数据发生了变化时,会触发setter,从而向Dep中的依赖(Watcher)发送通知。
Watcher接收到通知后,会向外界发送通知,变化通知到外界后可能会触发视图更新,也有可能触发用户的某个回调函数等。
——本文选自《深入浅出Vue.js》
360资深前端工程师刘博文精心打造
360 奇舞团团长月影和《JavaScript高级程序设计》译者李松峰作序推荐
——
本书当当正在参加满100减50的活动
——
同样在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章 过滤器的奥秘
文末畅聊
一本好技术书,一定会让你爱上学习!
前端圈好书太多,你最先想到了哪本呢?
是
《JavaScript高级程序设计(第3版)》
《你不知道的JavaScript》
还是
《精通CSS(第3版)》
《深入浅出Node.js》?
前端圈还有哪些
你读过以后觉得特别有收获的技术书呢?
......
写出来跟大家分享一下吧!
精选留言中随机挑选3位小伙伴,
赠送
《深入浅出Vue.js》纸质书一本。
活动截止到4月30日14:00。
↓ 查看更多Web相关图书
以上是关于不夸张,这真的是本Vue.js宝藏书!的主要内容,如果未能解决你的问题,请参考以下文章