简单实现一个Vue,还原一些基本功能。当然,实现之前需要先了解它的实现原理。
其实 Vue 的源码确实很清楚了,这里可能讲的也就一知半解,也就是个大概思路。
要想实现一个 Vue,还是应该先来理解一下MVVM。(以下源码均为2.6版本,讲解较简单,大概思路)
什么是MVVM?
MVVM是Model View ViewModel
的简写,与MVVM相似的还有MVC、MVP等,主要是为了分离视图(View)和模型(Model)。
主要优点是:低耦合、可重用性、独立开发、可测试。
在前端页面里,Model指的是纯JavaScript对象,View为视图界面,而ViewModel则负责将Model和View关联起来,把Model中的数据同步到View,View中的修改同步给Model。
在Vue中,也是为了View和Model分离之后的数据绑定,构建一个观察者模式,实现响应式,每当Model数据发生改变,自动的修改View,而不是像从前使用jQuery时,获取到数据,手动更新到DOM上。
想要详细的可以去了解一下。
MVC,MVP 和 MVVM 的图示-阮一峰、
界面之下:还原真实的MV*模式
实现Vue的主要要素
- 响应式:观察者模式动态更新View
- 模板解析:解析
.vue
文件中的html代码片段 - 虚拟 DOM:使用diff算法计算需要更新的DOM
响应式
想了解响应式可以先看看 Vue 文档中的介绍,深入响应式原理。
Object.defineProperty
当然,说到响应式,第一个应该说的便是 Object.defineProperty,
因为目前的Vue依旧是通过数据绑定来实现的,利用get set来实现读写操作。
说实话,defineProperty简直就是老生常谈,我也面过不少求职者,大多都能说出来个1234。
1 | // Object.defineProperty(obj, prop, descriptor) obj: 要在其上定义属性的对象 prop: 要定义或修改的属性的名称。 descriptor: 将被定义或修改的属性描述符。 |
使用过上面的例子后大概能想到,在 Vue 中修改了model中的数据后,视图view也会进行相应变化,使用defineProperty也就是为了监听数据变化,当数据发生变化后收到通知,便能进行视图更新。
虽然说defineProperty可以监听数据变化,但是当我们实际使用时,会发现一个另外的问题。
如何监听数组的方法(push pop shift unshift …)?。
那该怎么办呢?
Vue 的实现办法也比较简单,修改数据的原型方法。当然,并不是修改Array.prototype的方法,而是数组本身的方法。
1 | // 以下为基本原理,并不是源码实现 |
Proxy
defineProperty虽然可行,但也并不是最优秀的实现,在ES的不断进化后,Proxy亦是一种可取代defineProperty的方法。当然尤大大自然也知道,所以在 Vue 3.0后的版本都会使用Proxy来替代defineProperty。
MDN上的解释其实也是言简意赅Proxy 对象用于定义基本操作的自定义行为(如属性查找,赋值,枚举,函数调用等)。
The Proxy object is used to define custom behavior for fundamental operations (e.g. property lookup, assignment, enumeration, function invocation, etc).
所以说Proxy到底是啥呢,可以干嘛呢?
1 | var person = { |
可见,在使用了 Proxy 后,通过 Proxy 的实例对象操作 person,可以达到预期的效果,但是操作原来的 person ,设置的拦截功能是无效的。
当然,其实 Proxy 对数组的方法也是可以监听的,这也是它的一大优点。
1 | var person = [{ |
可以看出,数组中的 push 方法已经被监听了。使用Proxy可以实现defineProperty能实现的功能,并且能更好的解决defineProperty不能解决的问题,更是能监听 Set Map 等
实现 Vue 的 Observer
现在已经知道了如何监听数据变化,可以做一些更多的事情了。
使用 Vue 时,需要这样定义示例。
1 | var vm = new Vue({ |
Vue
在接收到 data(Model)
后,便会使用data
创建一个Observer实例。以下是 Observer 的部分源码。
1 | export class Observer { |
observeArray 与 walk
其实一看就明白,遍历所有的value
,然后把数据交给defineReactive
,其中 walk
用于对象,另一个用于数组。
其实大概想想也能理解,这里其实是为了注册 Model 数据,以便进行数据监听。
defineReactive
那么就来看一下,在walk
中调用的 defineReactive
方法,到底有什么用。
1 | /** |
可以看出,在2.6的版本里面,使用的是Object.defineProperty来进行监听非数组类型数据。注释也写到 Define a reactive property on an Object.
在 文件110行 也能看出,observe
方法其实也大同小异,只是遍历了数组的每个 item 继续使用 Observer
Dep
在定义数据的get set时,前者调用了一个 dep.depend()
后者调用了一个dep.notify()
,
在看之前的Observer代码,有一行 this.dep = new Dep()
接下来可以追寻到 Dep文件 中
1 | /** |
大家都知道,Vue 使用了观察者模式,其实 Dep 就是这个观察者模式的实践,dep.depend()
用来订阅依赖,而dep.notify()
用于触发依赖。
数据使用了 set 后便会订阅依赖,使用 get 便会触发依赖。当然,如果没有触发 set,也就代表了没有进行订阅依赖。其实这也是 MVVM 中 View 与 Model 之间的联系。
在进行 get 时,还有一行childOb.dep.depend()
,其实对于对象数据类型,对其子属性也有处理(当然是万能的递归啦)。
也可以看一眼官网文档中的介绍图,数据通过 getter setter 来通知 Watcher 来进行组件重新渲染。
Watcher
其实看到这里,还是会有很多东西不知道是干嘛的,比如 Watcher,subs[i].update()
Dep.target.addDep(this)
,
这时候可以看一下 Watcher的源码
也可以找一下在哪里用到过 Watcher lifecycle.js
1 | import Dep, { pushTarget, popTarget } from './dep' |
最后结合上下文,pushTarget(this)
控制了Dep.target
的值,传递了 Watcher 的实例过去,在之前 Object.defineProperty
中调用的Dep.target
也就是有了值,也就是绑定了 Watcher,所以触发 set 时,才会触发 notify 之后的操作。其实也就是更新视图了。
之前的dep.notify()
也知道是做什么了
1 | export default class Dep { |
总结一下,基本上流程就是:
- 组件挂载时,会触发
new Wathcer
,从而触发Wathcer
中的this.get()
,使Dep.target
获得Watcher
的实例 - 编译虚拟DOM时,遇到需要展示
this.data
上的数据时(_s(person.name)
),就会获取this.data
上的数据从而触发get
方法,从而触发dep.depend()
,推入Wathcer
- 更新时,触发
dep.notify()
,触发Wathcer
,再触发新一次的计算、渲染
模板引擎
虽然说数据现在已经能够进行响应式了,数据响应式之后视图 View 又是如何更新呢?
模版
在 Vue 中的模版其实就是
1 | <div id="root"> |
这段代码对于 Vue 来说,这就它他的模版语法,但是对于浏览器来说,只是一段 Html 代码。
而对于Vue来说,其实是js的中的数据,而对于浏览器只是一个字符串。
Vue 所做的事情其实就是将模版转化为浏览器最终展示的 View 视图。
在 Vue 源码中其实也是有迹可循,在 compiler 中有如下代码
1 | import { parse } from './parser/index' |
生成 AST
上面的模版通过 AST 语法生成出来的对象如下(使用创建一个 html 引入vue.js,修改源码10935行左右,console出来就可以了)
1 | { |
对于实际的项目中,模版中会有很多不同的形式,各种各样的写法,在 parser 中都有做相应的处理。
Vue 中的 html-parser 使用了 simplehtmlparsers 将 html 生成了结构化的数据,具体怎么处理的,其实就是分析出每个 tag 的头尾以及的类型和属性,然后生成一个 AST 的 JSON 对象。
使用 js 的大家一眼就能看出,expression
中的 _s(person.name)
肯定是在执行一个 js 函数,虽然expression
的属性其实只是一个字符串 '"person的名字" + _s(person.name)'
, 但是使用 new Function()
就能创建出一个函数用以调用。
如果你已经知道了 AST ,其实现在也能很好理解了,因为浏览器和 js 本身 都不能解析 Vue 模版,通过 AST ,便能将其转化为 js 可是别的数据,在处理成可渲染的数据发送给浏览器,浏览器便能渲染出来。
optimize 优化
如果在上面的模版中在添加一些静态节点,具体实现可参看 optimizer.js
1 | <div id="root"> |
此时生成的 AST 数据为
1 | { |
标记 static 属性是为了告诉 Vue,这是一个后期不需要改变的静态节点,今后的 Model 变更导致的 View 更新,与这些 静态节点 不相干。也就是为了在后期进行 diff 的时候,节约成本提升效率。
生成 js 代码 生成 render 函数
以上代码会生成一个render字符串,利用new Function
便能实例化出一个函数
1 | var data = { |
Vue 通过这些操作,最终将模版转换成了一个 render 函数,函数中的_c _v
等,其实在源码中也可以找到,其实就是创建节点的方法,每种类型的节点都会使用不同的函数。并且指令越多,编译出来的数据也会跟多更复杂。
其实在回想上文中,在render函数执行后,调用Model中的被Observe处理过属性时,也会触发 set,从而调用dep.depend()
,进行第一次的订阅依赖。
虚拟 DOM
大家都知道,在 Vue 中有着虚拟 DOM 的概念,其实 Vue 中的虚拟 DOM 来源于 snabbdom,可见于 patch.js,大概也就是结合 AST 和 DOM,再增加一个 diff 算法,用于对比节点树。
Virtual DOM 其实就是 JS 和 DOM 之间的一个缓存。
首先先用 JS 的对象结构表示出一个 DOM 树的结构(跟上文一样,标签、属性、文本等),并利用这个树形对象结构来创建一个真实的 DOM 结构,添加到文档中去。(初次构建)
当 Model 中的数据发生变化,需要影响到 View 时,构造一棵新的对象树,然后与旧的树进行对比,并且记录差异。(diff)
最后,把记录的差异实际变更到最开始构建的真实 DOM 树上,也就更新了视图 View。(数据变更)
先来了解一下snabbdom
在 snabbdom 的 Inline example中也可以看出是如何构建虚拟 DOM 的
1 | var snabbdom = require('snabbdom'); |
了解了虚拟 DOM,在 Vue 中的应用其实也一目了然。
Vue 通过解析模版生成了 render
函数,调用 render
函数其实就可以生成 vnode,在 vnode 时会获取并调用 Model 中的数据,便会订阅依赖。之后通过patch
创建到 html 中,渲染出真实的 DOM。
当 Model 中的数据发生变化后,便会触发依赖,重新执行 render 函数,生成新的 vnode,在patch
中进行 diff,最后更新需要变更的部分。
在上文 Watcher 中介绍的 Watcher 时的 lifecycle.js 中,也可以发现vm._render() vm._update()
的影子,其实 Vue 就是在这里绑定的监听,用于调用 render 函数进行更新视图。
在想一下之前的 Watcher 与 Dep ,结合来看,也就大概了解Vue的运行顺序及其原理了,点到为止。
正式造 Vue 可见下篇 简易实现一个 Vue(2)