像个侦探一样阅读源码

「声动派」,专注互联网价值传播,为你分享大连接时代的一切!

本文大约 11000字 阅读需要 12分钟

第一部分

写在前面

“我会用就行了,不用知道源码。”

互联网时代,各式各样的互联网产品顺势而生。支撑着互联网产品快速迭代的是,各种互联网的技术。其实,在开发工程师的眼里,各式各样的开源技术、框架也在如雨后春笋般不断被创造。

只会用别人的插件、框架就满足了吗?难道你就不想自己动手写一个框架吗?

要知道如何写一个框架,第一步,就是要先阅读一些大牛的框架源码。也许很多开发工程师会认为,“我会用就行了,不用知道源码”。实际上,各种类似的框架有很多共通之处,如果你能理解其中某个框架的核心思想,那么你在学习其他框架的用法的时候,也会更加容易上手,更加得心应手。

总之,学习源码有百利而无一害。不如在2018年,给自己定一个小目标,就是让自己能够完完全全的弄懂某个框架的源码,并且仿一个框架(而不是仿京东,仿手淘哦~)。

“我想阅读源码,可我不知道从何看起?”

有一颗阅读源码的心是好事,但是无从下手,不知道从何看起的人也一定占了大多数。

接下来,我会以 Vue1.0 (下文中简称 Vue) 的核心代码为例,教大家如何根据线索,一步步看懂源码的核心功能。

几乎是所有开发工程师都知道 Vue 的大名,这是一个火遍大江南北的 MVVM 的前端框架。这个框架对数据进行的双向绑定的处理,使得开发工程师从 jQuery 的时代脱离出来,大踏步的向前迈进,大量解放了生产力。

那么,这个双向绑定 的技术核心到底是如何实现的呢?我们能不能也实现一个和它一样的功能呢?

接下来,请把自己扮演成一个侦探的角色,从毫无线索的 “犯罪现场” 中找寻任何可疑的蛛丝马迹。

本文中可能会使用【可疑】这个字眼,代表这个函数很值得关注,以便模仿侦探破案的气氛,别无他意。

第二部分

探案开始

第一步,打开你的搜索引擎

这个世界上的侦探不止你一个,这个世界上想破案的人肯定大有人在。那么,你要做的第一件事就是谷歌一下 “Vue 1.0 是如何实现双向绑定的?”。最重要的就是收集现成的情报。

你会发现,所有的结果都告诉了你一个关键字, defineProperty。大家都说 Vue 是利用 defineProperty 来实现双向绑定的。

那么,我们直接带着这个“线索” (关键字 defineProperty),去 “犯罪现场” (源码)中寻找答案。

先看看 Vue 是在哪里使用了defineProperty?

侦探一般都有一个放大镜在查看现场的蛛丝马迹。同样的,我们有 ctrl + F 全局搜索的 “放大镜” 来查找,接下来也会一直频繁的用到。

在源码中,搜索到了一个这样的函数,def()。这个函数里面包裹着我们最重要的api -- defineProperty。

/** * Define a property. * * @param {Object} obj * @param {String} key * @param {*} val * @param {Boolean} [enumerable] */functiondef(obj,key,val,enumerable){Object.defineProperty(obj,key,{value:val,enumerable:!!enumerable,writable:true,configurable:true});}

回过头来,完善一下 defineProperty 的知识

一个合格的侦探,必须拥有渊博的学识,那么我们就来完善一下我们的基础知识。同样的,打开谷歌,搜索defineProperty来收集情报。

在api宝典 -- mdn上看到了最正统的解释:

Object.defineProperty(obj,prop,descriptor)// obj: 需要定义的对象// prop: obj对象中,可能需要被定义(get)或修改(set)的属性名字// descriptor: 要定义(get)或修改(set)的obj的属性描述符// return : 这个方法 return 一个被传递给函数的对象,即 obj

其中,descriptor 中特别复杂:属性描述符 descriptor有两种主要形式:数据描述符存取描述符

数据描述符是指一个具有值 (任意js的数据类型、数组或函数) 的属性,该值可能是可写的,也可能是不可写的。如何记忆呢?其实很简单,顾名思义,数据描述符就是通过 直接设定 value 的值,直接使得 obj 的某个属性有了值。

存取描述符是指用 getter 或 setter 函数来定义的属性。如何记忆呢?其实很简单,顾名思义,存取描述符就是通过存(set)取(get) ,使得 obj 的某个属性有了值。

描述符必须是这两种形式之一,但两者不能同时存在。

粗略了解过后,我们知道了,他最重要的作用就是存(set) 和 取(get)

通过gettersetter能够实时更新数据,并且获得最新数据

回到 def 源码,重新认识 defineProperty

在源码中,有一个这样的函数,def()。这个函数里面包裹着我们最重要的api --defineProperty

// 利用了数据描述符的方式来定义一个对象 obj 的 key 属性的值为 val// 并且明确知道这个属性是可以被赋值运算符改变,并且是可删除、可修改的functiondef(obj,key,val,enumerable){Object.defineProperty(obj,key,{value:val,enumerable:!!enumerable,writable:true,configurable:true});}

还有哪里也有 defineProperty

侦探不应该只发现一个线索就停止了脚步,还应该继续认真观察其他相似的线索。所以,我们继续搜索 defineProperty 关键字。

在搜索的过程中,还发现了一个 defineReactive 函数里也有使用到 defineProperty,明显这个函数很可疑,因为它的名字中也有define,这个函数如下。

functiondefineReactive(obj,key,val){// 这里提到了一个 Dep 方法,他的实例 dep 在源码中频繁出现,注意点①vardep=newDep();// .... 很多东西// 这里提到了一个 observe 方法,看上去也是一个重要的监听函数,注意点②varchildOb=observe(val);// 在这里使用了 definePropertyObject.defineProperty(obj,key,{// 定义了对象属性的可枚举,可修改或可删除的属性enumerable:true,configurable:true,// 定义了存取描述符 get 和 set 函数的实现get:functionreactiveGetter(){varvalue=getter?getter.call(obj):val;// .... 一些判断后,最后得到了valuereturnvalue;},set:functionreactiveSetter(newVal){varvalue=getter?getter.call(obj):val;// 如果新值没有改变,则return;if(newVal===value){return;}if(setter){setter.call(obj,newVal);}else{// 把新值赋值给 val    val=newVal;}// 调用了一个可以的名字为【观察】的可疑函数,并把新值传递出去 childOb=observe(newVal);// 这个可疑的实例,调用了一个看上去是通知的方法dep.notify();}});}

寻找注意点①,一个 Dep 构造函数

在源码中找到了 Dep 的实现过程:

varuid$1=0;// 每个 dep 实例都是可以显示观察到实例的变化的// 一个实例可以有多个订阅的指令functionDep(){this.id=uid$1++;// subs 用来记录订阅了这个实例的对象,// 也就是说某个被监听的对象一发生变化,subs 里面的所有订阅者都会收到变化this.subs=[];}// 当前这个的 target 是null,target 是全局的,而且是独一无二的// 可以通过 watcher 随时更新 target 的值Dep.target=null;// 接下来,这个实例有4个重要的方法,addSub  removeSub  depend  notify// 根据大神对方法的命名能够很容易猜测出方法的功能// 实现一个添加订阅者的方法 addSubDep.prototype.addSub=function(sub){this.subs.push(sub);};// 实现一个移除订阅者的方法 removeSubDep.prototype.removeSub=function(sub){this.subs.$remove(sub);};// 为target绑定 this 指向的方法 dependDep.prototype.depend=function(){Dep.target.addDep(this);};// 通知所有订阅者新值更新的方法 notifyDep.prototype.notify=function(){varsubs=toArray(this.subs);for(vari=0,l=subs.length;i

在这里,我注意到了 Dep.target 这个属性。在源码中的注释中,我们了解到“可以通过 watcher 随时更新 target 的值”。所以我们来看看什么是watcher。

线索延伸,寻找 watcher

通过全局搜索 watcher,我发现搜索结果实在是太多了。所以我搜索了function watcher。此时答案只有一个,那就是 Watcher 构造函数。粗略的看了看,大概有一些get、set、beforeGet、addDep、afterGet、update、run 等等方法,相当复杂。但确实发现了 Watcher 能够修改 Dep.target 的方法。

Watcher.prototype.beforeGet=function(){Dep.target=this;};

寻找观察点②,一个观察者的类 Observe

/** *  Observe 类会和每一个需要被观察的对象关联起来,  一旦产生关联,  被观察对象的属性值就会被 getter/setters 获取或更新 * 所以, 我们猜测这个类里一定会调用 Object.defineProperty */functionObserver(value){this.value=value;this.dep=newDep();// 这里调用了 def 函数,应证了我们的猜测,确实调用了 Object.definePropertydef(value,'__ob__',this);// ... 更多处理}// 接下来,这个实例有3个重要的方法,walk  observeArray  convert// walk 遍历对象,并将对象的每个属性关联到 getter/setters,// 这个方法只有在参数是一个对象时才能被正确调用。Observer.prototype.walk=function(obj){varkeys=Object.keys(obj);for(vari=0,l=keys.length;i

但是全局搜索的时候,还发现了一个 observe 函数,也很可疑:

functionobserve(value,vm){//....ob=newObserver(value);// .... 最后return了一个 Observer 类的值returnob;}

缺少一个导火索把一切线索串通起来

到现在为止,可以发现源码里的这些函数相互关联。线索就是按照下面的亮条路线串起来的。

observe方法 --- new ---> Observer类 --- 调用 ---> def方法 --- 使用了---> 描述符类型的 defineProperty

observe方法 --- new ---> Observer类,convert方法 --- 调用 ---> defineReactive方法 --- 使用了---> 存取描述符的defineProperty --- 同时实例化了dep ---> new Dep() ---> 可以被 Watcher 修改

到这里,就把刚刚解读的4段源码串了起来。他们的作用就是:

① observe 负责监听数据的变化

② 数据的获取和更新都使用 defineProperty

③ Dep 负责管理订阅和发布

但还是少点什么,对,就是【数据从哪里来的?】,没有数据来源,有再完美的双向绑定也没用。

所以,我们来看看 Vue 的 data 部分会不会涉及到 observe

侦探有一个小技巧,就是靠自己的本能直觉去做大胆的猜测!我猜,就是 data --- 调用了---> observe方法。接下来要做的就是去验证你的猜测。

找到导火索 data

这里有一个小插曲,当你在 Vue 的文档中全局搜索 “data”, 或者 “ vue” 这样的关键字的时候,你会发现 data 有140个记录,vue 有203个记录。这么找下去,真是无从下手。

由于我们前面预测了,是 data 去引发了线索,所以我推测,data 调用了 observer。所以我决定把搜索条件改成 “observer”。就容易多了,很快发现了一个可疑的函数 initdata。源码如下:

/*** Initialize the data. data的初始化*/Vue.prototype._initData=function(){vardataFn=this.$options.data;vardata=this._data=dataFn?dataFn():{};// ... 很多很多,对组件内外的prop、data做了各种规范和处理    // 重点出现了,调用了observe, 监听 dataobserve(data,this);};

这个 _initData_initState被使用:

/**   * 给实例构造一个作用域,  其中包括:   * - observed data 监听data       * - .....   */Vue.prototype._initState=function(){// ...this._initData();// ...};

这个 _initState  _init 被使用:

Vue.prototype._init=function(options){// ...// 初始化数据监听,并初始化作用域this._initState();}

最后 _init  Vue 调用,

functionVue(options){this._init(options);}

到此为止,我们得到了最终的结论

Vue实例 ---> data ---> observe方法 ---> Observer类 ---> def方法 ---> defineProperty

Vue实例 ---> data ---> observe方法 ---> Observer类-convert方法 ---> defineReactive方法 ---> defineProperty ---> new Dep() 订阅类 ---> 可以被 Watcher 修改

第三部分

模仿思路,实现一个简陋的双向绑定

先模仿 Vue 创建一个构造函数

回忆一下 Vue 是如何实例化的?

varV=newVue({// el 简化为所指定的id    el:'app',data:{...}})

由此可见,在实例化的时候,有两个重要的参数,el 和 data。所以,先虚拟一个构造函数。

functionVue(options){this.data=options.data;varid=options.el;}

构造函数的参数有了,但是构造函数有什么功能呢?第一个功能应该能够解析指令,编译dom。回想一下平时写dom的时候,v-model,v-show,v-for。这些都是最常用的指令,并且直接写在dom上,但是实际渲染的html上并不会出现这些指令,为什么呢?因为被编译了。 谁编译了?Vue的构造函数负责编译

给构造函数增加一个编译的方法

functionVue(options){// ... 一些参数varid=options.el;// 利用 nodeToFragment 生成编译后的domvardom=nodeToFragment(document.getElementById(id),this);// 把生成好的 dom 插入到指定 id 的 dom 中去(这里简化id的处理)document.getElementById(id).appendChild(dom);}

上文中提到了一个 nodeToFragment 方法,这个方法其实是利用createDocumentFragment来创造一个代码片段。不了解 Fragment 的同学可以自行搜索了解一下。

functionnodeToFragment(node,vm){varflag=document.createDocumentFragment();varchild;while(child=node.firstChild){compile(child,vm);// 调用 compile 解析 dom 属性flag.appendChild(child);// flag 不断填充新的 child 子节点}returnflag;}functioncompile(node,vm){if(node.nodeType===1){// 如果 node 是一个元素,解析他的所有属性varattr=node.attributes;for(vari=0;i

对比一下 dom 的编译前后。

根据之前的线索,构造一个 observe 方法

根据前面的结论,我们知道 observe 方法实际上就是一个监听函数。应该在data被确定后调用,所以在 Vue 的构造函数里。

functionVue(options){this.data=options.data;vardata=this.data;// 调用 observe 方法来监听 data 里的数据observe(data,this);// ...}

observe 方法接受两个参数。遍历 data,获得属性,调用 defineReactive

functionobserve(objs,vm){Object.keys(objs).forEach(function(key){defineReactive(vm,key,objs[key]);})}

实现一个 defineReactive 方法

defineReactive 在本文的比较前面提到,这个方法是使用了defineProperty 这个方法的可疑函数。我们的 observe 中调用了它,所以现在也需要实现一下。

functiondefineReactive(obj,key,val){// 这个函数就一个作用,调用了Object.definePropertyObject.defineProperty(obj,key,{get:function(){returnval;},set:function(newVal){if(newVal===val)return;val=newVal;}})}

我们知道,只要 obj 的 key 的值被赋值了,就会触发 set 方法。所以,当一个被 v-model 绑定了的 input 的值在变化时,应该就是出发 set 的最佳时机。那么在编译 dom 的时候,就需要提前给 dom 绑定事件。

functioncompile(node,vm){if(node.nodeType===1){varattr=node.attributes;for(vari=0;i

根据之前的线索,需要一个订阅者的类 Dep

functionDep(){this.subs=[];}// 主要实现两个方法: 新增订阅者 & 通知订阅者Dep.prototype={addSub:function(sub){this.subs.push(sub);},notify:function(){this.subs.forEach(function(sub){sub.update();});},}

需要在 defineProperty 的时候设置订阅者。如果每次新增一个双向绑定的 get,都需要新增订阅者,每一次被双向绑定的 set 一次,就需要通知所有订阅者。所以需要修改一下 defineReactive 方法。

functiondefineReactive(obj,key,val){vardep=newDep();Object.defineProperty(obj,key,{get:function(){// 增加一个订阅者if(Dep.target)dep.addSub(Dep.target);returnval;},set:function(newVal){if(newVal===val)return;val=newVal;// 作为发布者发出通知dep.notify();}})}

此时,我们还需要补充一下 Watcher 类。专门用来改变 Dep.target 的指向。

functionWatcher(vm,node,name,nodeType){Dep.target=this;this.name=name;this.node=node;this.vm=vm;this.nodeType=nodeType;this.update();Dep.target=null;}Watcher.prototype={get:function(){this.value=this.vm[this.name];},update:function(){this.get();// 简化操作,在编译函数中传入写死的参数if(this.nodeType=='text'){this.node.nodeValue=this.value;}if(this.nodeType=='input'){this.node.value=this.value;}}}

这个 Watcher 的作用就是,实际上实现被订阅者的获取订阅者的更新的方法。

functioncompile(node,vm){if(node.nodeType===1){//...            // vm: this 指向; node: dom节点; // name: v-model绑定的属性名字; 'input': 简化操作,写死这个dom的类型newWatcher(vm,node,name,'input');}if(node.nodeType===3){if(/\{\{(.*)\}\}/.test(node.nodeValue)){//...        // 原本给文本节点赋值的方式是利用了 defineProperty 的 get// node.nodeValue = vm[name];  // 将data 赋值给 该文本节点    // 现在改为利用 Watcher,如果被订阅者变化了,直接update// 其中,name: {{}} 指定渲染绑定的属性; 'text': 简化操作,写死文本节点的类型newWatcher(vm,node,name,'text');}}}

第四部分

模仿后的总结

我们的模仿大约经历了以下几个过程

第一步:创建一个构造函数Vue,并在构造函数中定义参数

第二步:构建一个函数nodeToFragment, 能够把带指令的 dom 转化为 html5 的 dom

第三步:nodeToFragment实际上是调用了compile, compile方法解析指令的属性并就进行赋值

第四步:在构造函数Vue中增加一个监听方法observe,它接受构造函数Vue中的data作为参数,并为每个参数实现双向绑定。

第五步:observe中调用了defineReactive,这个方法使用了Object.defineProperty来设置的数据的getter、setter。

第六步:需要在compile触发setter,所以在compile中给输入框绑定事件

第七步:虽然能够触发setter,但是显示的数据并没有触发getter。所以需要构造一个订阅类Dep,主要实现增加订阅者&通知订阅者 两个方法。以便在Object.defineProperty 的 setter 中触发通知函数 notify

第八步:实现Dep的通知订阅者方法(notify),需要借助Watcher类,Watcher 中的 updata方法为每一个订阅者提供更新操作。

第九步:需要在compile的时候为每一个订阅者实例化Watcher,所以,需要在compile中触发Watcher。传入相应的参数,让Watcher能够在update的时候正确赋值。

最后,恭喜你,你已经是一名合格的侦探了。

同时,笔者也在github等平台上分享更多的前端开发技能,欢迎点击访问我的 github 个人博客,与我一起交流探讨。

*本文著作权归作者所有,转载请联系作者获得授权。

推荐阅读更多精彩内容