本文不会拉出Vue的源码出来剖析一番,也不会挂一大段代码去笼统地讲,尽可能会从逻辑角度一步步来梳理html
若是你跟以前的我同样,据说过MVVM,也对Virtual Dom有所耳闻,可是说不出个大概
那么但愿这篇文章能对你有所帮助前端
都说前端框架运用了MVVM的思想,那么MVVM是什么vue
M:Model(数据)node
V:View(视图)git
VM:ViewModelgithub
其中VM就是解放生产力的核心
它让咱们再也不须要去手动操做Dom更新视图,一切都是自动完成的,咱们只需专一于数据逻辑以及页面呈现web
既然要自动更新,那么这个中间人VM至少作了三件事正则表达式
1 监听到了数据变化算法
2 通知视图编程
3 视图执行更新
Vue目前用到的是Object.defineProperty()这个方法
未来Vue3.0会换成Proxy,不过是有点相似的,因此不用担忧到时候须要从新学,理解了思想以后一切都很快
Object.defineProperty()接收三个参数
一、实例
二、属性
三、属性描述符
好比说咱们如今想要监听data对象身上的a属性
data:{a:val,b:2, c:3}
var val=1
Object.defineProperty(data, key, {
writable: true, // 可枚举
enumerable: true, // 可枚举
configurable: false, // 不能再define
get: function () {
dep.bind()
return val;
},
set: function (newVal) {
if (newVal === val) return;
console.log('监听到值变化了: ', val, '==>', newVal);
val = newVal;
dep.notify()
}
});
复制代码
咱们把原来单纯的一个值,拆分红一个getter和一个setter
这样不管值被获取仍是重写,咱们都能知道
上面只是console了一下,咱们实际须要去通知视图
因而咱们给每个属性都设一个传声筒dep,由他来负责通知
同时,咱们也得让它知道到底去通知谁
新建一个类Dep,在每一次调用Object.defineProperty()的时候,顺便new一个dep实例出来
它身上设定两个方法,bind()和notify()
视图第一次渲染会调用属性的get去取值,咱们就能够用bind()让dep绑定要通知的对象
而修改数据的时候,就触发dep.notity()去通知
咱们先无论这两个方法具体是怎么实现的
定义一个defineProperty()方法,把骨架搭出来
defineValue(data, key, val) {
var dep = new Dep() //给这个属性创建一个传声筒
Object.defineProperty(data, key, {
..............
get: function () {
dep.bind()
return val;
},
set: function (newVal) {
if (newVal === val) return;
val = newVal;
dep.notify()
}
});
}
复制代码
如今把目光转移到视图这一边,假设如今html长这样
<div>
<p>{{ a }}</p>
<p>{{ b }}</p>
<p>{{ c }}</p>
</div>
<div>
<p>{{ c }}</p>
</div>
复制代码
这串html在关心a,b,c三个数据
但不一样p标签关心的数据不同
若是数据c变了,咱们确定但愿c的传声筒只去通知后面两个p标签,别的p标签不用知道
因此在视图这边,也须要给每个引用到数据的地方,设立一个经纪人watcher,区分开来,这样dep就知道去通知谁了
如今咱们假设每一个watcher身上都有一个update()方法
因此dep的notify方法,就是调用全部与dep相关的watcher身上的update()
话讲到这里,忽然就在原来的v和m的基础上,多出来两个角色dep和watcher,感受愈来愈乱了
等等,dep和watcher,那这两我的就是VM的真身吗
没错,他们一块儿组成了VM核心
而这个模式就是大名鼎鼎的“发布-订阅模式”
-- 想一想Vue是怎么建立实例的
var app=new Vue({
el:'#app',
data:{
a:1,
b:2,
c:3,
d:{
e:4
}
}
})
复制代码
咱们模仿Vue,建立一个Mvvm类,获取用户传进来的参数,并把data赋给自身的$data属性
class Mvvm {
constructor(options = {}) {
//将全部属性挂载到$options
this.$options = options;
// 将data数据取出来赋给$data
this.$data = this.$options.data;
// 数据劫持
this.observe(this.$data);
//数据代理
this.proxyData(this.$data)
//编译页面
this.$compiler = new Compiler(this, this.$options.el || document.body)
}
observe(){}
defineProperty()
proxyData(){}
}
复制代码
在Mvvm实例的初始化中,赋值完以后,依次作了三件事
数据劫持,数据代理,编译页面
数据劫持就是咱们上面定义过的defineProperty,不过这里须要补充一个嵌套劫持,让对象属性中的属性也能被监听
重复部分就省略了,重点关注实现嵌套的代码
observe(data) {
if (!data || typeof data !== 'object') return;
Object.keys(data).forEach(key => {
this.defineValue(data, key, data[key]);
});
}
defineValue(data, key, val) {
...............
_this.observe(val); // 监听子属性
Object.defineProperty(data, key, {
................
set: function (newVal) {
....................
_this.observe(val) //对新值进行监听,由于它多是个对象
dep.notify()
}
});
复制代码
在vue中,咱们获取一个数据不是经过vm.data.a这样的形式的,是直接vm.a进行读写,因此咱们还要进行一下数据代理
至关于把$data给镜像过来,暴露给用户
proxyData(data) {
//由于只是为了省略$data,因此只须要遍历第一层,不用深度遍历
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
configurable: false,
enumerable: true,
get: function () {
return this.$data[key]
},
set: function (newVal) {
this.$data[key] = newVal
}
}
)
})
}
复制代码
给咱们一段html,咱们须要分析出里面哪些地方引用了数据,哪些地方用到了指令,进行第一次的数据更新渲染
同时还有就是给用到数据的地方分配watcher
实际操做Dom是很慢的,因此咱们这里用到了fragment(文档碎片),把Dom都拷到这里面进行操做
固然这个实际上是vue1.x时候使用的,不过对咱们理解mvvm很是有帮助
定义一个转移Dom到fragment的方法
node2fragemt(el) {
var fragment = document.createDocumentFragment()
var child
while (child = el.firstChild) {
fragment.appendChild(child)
}
return fragment
}
复制代码
由于节点不能有两个父亲,因此调用appendChild的时候,就至关于把Dom节点抢了过来
操做完了之后,再把fragment丢给真实Dom便可
定义一些判断节点的方法
//是不是节点
isElement(node) {
return node.nodeType == 1;
}
//是不是指令
isDirective(node) {
return node.substring(0, 2) === 'v-';
}
//是不是事件指令
isEventDirevtive(dir) {
return dir.indexOf('on') === 0;
}
//是不是文本节点
isTextElement(node) {
return node.nodeType == 3
}
复制代码
而后定义Compiler类
class Compiler {
constructor(vm, el) {
this.$vm = vm
this.$el = this.isElement(el) ? el : document.querySelector(el)
if (this.$el) {
this.$fragment = this.node2fragemt(this.$el)
this.compile(this.$fragment)
this.$el.appendChild(this.$fragment)
}
}
复制代码
compiler类的核心是compile函数
分类去解析节点
compile(el) {
var nodes = el.childNodes
Array.from(nodes).forEach(node => {
if (this.isElement(node)) {
//普通节点
....................................
}
else if (this.isTextElement(node)) {
//文本节点
...................................
}
//先进行上面的解析,若是发现node还有子节点,就递归地进行子节点的解析
if (node.childNodes && node.childNodes.length)
this.compile(node)
})
}
复制代码
递归解析每一个节点,分为两类处理,一个是普通节点,一个是文本节点
普通节点上可能有指令,文本节点上可能有{{}}
经过attribute属性取到指令,先判断一下格式是否正确,是不是v-开头的
而后这里面又分为事件指令(on:click之类)或者普通指令(v-text,v-model,v-html)
本文先只介绍普通指令
var attrs = node.attributes
Array.from(attrs).forEach(attr => {
if (!this.isDirective(attr.name)) return; //若是不是以v-开头的指令,直接返回不处理
var exp = attr.value.trim() //是string类型,因此还要去除一下两边的空格
var dir = attr.name //例如v-text
if (this.isEventDirevtive(dir)) {
//若是是事件处理函数
} else {
//普通指令
updateFn[dir] && updateFn[dir](node, this.getVal(exp), this.$vm, exp)
new Watcher(this.$vm, exp, (value) => {
updateFn[dir] && updateFn[dir](node, value, this.$vm, exp);
});
}
})
复制代码
能够看到在普通指令中,用dir去取到了一个方法进行执行,同时新建了一个Watcher,它的回调函数也是这个方法
Watcher先放一放,咱们看看在执行什么方法
//指令函数
var updateFn = {
"v-text": function (node, val) {
node.textContent = val === undefined ? '' : val
},
"v-html": function (node, val) {
node.innerHTML = val === undefined ? '' : val
},
"v-model": function (node, val, vm, exps) {
node.value = val === undefined ? '' : val
node.addEventListener('input', e => {
exp = exps.split('.')
var len = exp.length
if (len == 1) {
return vm[exp] = e.target.value
}
var data = vm
for (let i = 0; i < len - 1; i++) {
data = data[exp[i]]
console.log(exp[i])
}
data[exp[len - 1]] = e.target.value
})
}
}
复制代码
前两个很好理解,就是纯粹去用取到的数据值更新节点的值
而v-model,也就是咱们大名鼎鼎的双向绑定,其实很简单
它就只是比上面两个多一个监听事件,去更新实例上的值罢了
处理完普通节点,来处理咱们的文本节点
其实就是处理一个模板语法{{}}
咱们想要把其中的变量取出来,因此就要用到正则表达式的捕获组,用括号去捕捉
而后用RegExp.$1去取到捕获的值
不过若是处理相似{{a}},{{b}}这种的话,捕获的时候,后一个会覆盖前一个,RegExp.$1就只能取到b
因此咱们先用match把他们拆分出来,再分别捕获
var exps = node.textContent.match(/\{\{.*?\}\}/g) //先进行拆分
if (!exps) return;
Array.from(exps).forEach(item => {
item.match(/\{\{(.*?)\}\}/g)
this.compileText(node, item, RegExp.$1.trim()) //经过正则的括号进行捕获,trim()用来去除空格
})
复制代码
compileText其实就是把{{}}替换成值
compileText(node, exp, content) {
var val = this.getVal(content)
if (val === undefined) val = "";
var text = node.textContent //保留一份原来的格式以供更新
node.textContent = node.textContent.replace(exp, val)
new Watcher(this.$vm, content, (value) => {
if (value === undefined) value = "";
node.textContent = text.replace(exp, value)
});
}
复制代码
这里也建立了一个watcher 因此在编译的时候,每一个用到数据的地方,都建立了一个wacther
再来回顾一下这张图
首先解开Dep的面纱
还记得咱们以前卖了个关子,没有说dep.bind()是怎么实现的
下面咱们就来看看dep.bind()在干吗
class Dep {
constructor() {
this.subs = new Set(); //为了保证不重复添加
}
bind() {
//注册当前活跃的用户为订阅者,并让对方添加本身
this.subs.add(Dep.target)
Dep.target.addDep(this)
}
}
复制代码
每一个dep实例都有一个subs,保存本身要通知的那些watcher,为了避免重复,使用了Set结构、 在第一次渲染视图的时候,有向实例拿过数据,就在那时已经触发了实例属性的get方法,进而触发了bind函数了
那这个Dep.target又是哪来的?
答案就是在wathcer实例初始化的时候
Wacher类
class Watcher {
constructor(vm, exp, cb) {
this.$vm = vm
this.$cb = cb
this.$deps = new Set()
if (typeof exp === 'function') {
this.getter = exp
}
else {
this.getter = this.createGetter(exp)
}
this.$value = this.runGetter()
}
addDep(dep) {
this.$deps.add(dep)
}
runGetter() {
if (!this.getter) return;
Dep.target = this
var value = this.getter.call(this.$vm, this.$vm);
Dep.target = null
return value;
}
createGetter(exp) {
var exps = exp.split('.')
return function (vm) {
var val = vm
exps.forEach(key => {
val = val[key]
});
return val;
}
}
复制代码
}
在watcher初始化的时候,根据exp(也就是咱们以前编译时辛辛苦苦取到的变量)的类型建立了一个getter方法
若是exp是函数,getter就是直接运行它,不然就是去实例身上取值,为了能经过相似“a.b.c”这样的字符串取到值,咱们用了代码中那个层层遍历递进的方法
有了getter以后,咱们就在初始化的时候执行一下,把值保存下来,以便未来作比对
同时制定Dep.target为本身
因此整个运行的顺序是
最后一步,当数据更新的时候,要让dep去通知watcher执行update
还记得属性set()里面的dep.notify()方法么
class Dep{
...............
notify() {
//通知全部订阅者执行更新函数
this.subs.forEach(item => {
item.update()
})
}
}
复制代码
很简单,就是通知全部绑定的watcher去执行update
那么watcher的update()长啥样呢
class Wacther{
................
update() {
var oldVal = this.$value
var newVal = this.runGetter()
if (newVal === oldVal) return;
this.$value = newVal
this.$cb.call(this.$vm, newVal, oldVal)
}
}
复制代码
就是调用getter去获取数据最新的值,而后调用以前保存的回调函数更新视图
这里你们可能有个疑问,为何dep的notify不带上新的值做为参数告诉watcher,而让watcher再本身取一次?
由于其实dep不知道手下的每一个watcher究竟在观察什么,好比dep管理着a,而a={b:'dsd',c:'dsads'},这个watcher可能只是在关心a.b变化了没,另外一个watcher在关心a.c,可是dep并不知道
因此Dep不作传值,只是在数据变化的时候通知全部相关订阅者,本身去看看数据变成啥样了
至此,咱们的Mvvm就告一段落了,其实还有很多功能,好比computed,watch,事件指令
不过,如今这些东西,都只是Vue.1x
除了咱们以前提到的,Vue3.0会把Object.defineProperty()换成proxy之外
渲染和更新如今用的也不是fragment了,而是下面这一位
大名鼎鼎的Virtual Dom 并不是React发明,但由React发扬光大
曾经的Vue用的是咱们上文介绍的这种依赖收集,而后局部更新的方法,但从Vue2.x开始,使用的也是Virtual Dom了
如何把数据的更新投射到视图上,三大框架曾经各抒己见
Angular使用的是脏检查,当咱们触发了某些事件(定时,异步请求,事件触发等),执行完事件以后,Angular会遍历全部“注册”过的值,判断是否和以前的一致
因此它的更新复杂度稳定在O(watcher count) + 必要的DOM更新 O(DOM change)
Vue曾经使用的是咱们本文讲解的依赖收集,每一次更新数据会针对新数据从新收集一次依赖
因此复杂度为 O(data change) + 必要 DOM 更新 O(DOM change)
React用的的Virtual Dom,它的本质其实相似离线Dom,每次操做以后,与原来的Virtual\ Dom进行diff比对,把patch投射到真实Dom上,在diff比对上,原本两棵Dom树的比对会达到O(n^3)的复杂度,可是React团队用了取巧的方法,考虑到web应用中不多会出现跨层移动Dom节点,因此只进行两棵树的同层比对,强行把复杂度下降到了O(n)
因此最后总的复杂度是O(template size) + 必要的 DOM 更新 O(DOM change)
不少地方都会大肆宣扬Virtual Dom的快速,确实Virtual Dom很快,但是那也得看跟谁比
若是是跟很粗暴地把整棵真实Dom树直接更新了,也就是直接设置node.innerHTML相比,确定Virtual Dom要快多了
由于操做Dom是很耗时间的,而Virtual Dom实际上是JS对象,操做JS可快多了
但是实际上没有人会一有数据更新,就把整棵真实的Dom树给更新了的
一来,若是你去手动优化,精准地更新Dom,那确定要比Virtual Dom快
固然框架是要追求普适性的,不过即便上面那两个一样具备普适性的方法,脏检查和依赖收集相比,那也是互有胜场
借用尤大大的回答 www.zhihu.com/question/31…
既然在速度上互有胜场,那Virtual Dom必定还有更增强大的优势,能促使Vue去使用它
仍是看尤大大本身的讲解,能够看下这段视频
www.bilibili.com/video/av621…
Vue在改用Virtual Dom后,流程又是怎么走的呢 借用这篇文章的图片 github.com/wangfupeng1…
能够看到新出来两个概念,AST和Render
与html转为Dom树的过程相似,Vue会先把模板给解析成抽象语法树,而后优化AST,找到其中静态和动态的部分,在优化方面Vue3.0会有更好的突破,目前还不是很完善,总之目标就是肯定哪些部分是可能会变化的,哪些是静态的不会变的,最后生成一个render函数
若是是在开发环境下,会在运行时把模板根据上面的步骤解析为render函数,而若是是生产环境,这一步是发生在编译打包阶段的,因此最后的文件中直接就是render函数
那这个render函数就是用来返回Virtrual Dom的
若是是初次渲染,就会把这个Virtual Dom树直接投射生成真实Dom
若是是数据更新,就相似本文介绍的那个流程,触发通知,执行update函数,触发render函数的从新执行,生成一个新的Virtual Dom树,与原来的进行diff比对,并把最小差别patch到真实Dom上进行局部从新渲染
这个渲染是异步的,也就是说一次渲染会集合多个数据的变化
在渲染的过程当中,有一个很重要的概念,就是就地复用
数据的角度来讲,一个列表的数据变了,那就应该销毁实例从新建立
可是Virtual Dom是基于Dom进行变更检查的,若是最终的渲染结果没有变化,就不该该有这种额外的劳动
好比若是数据项的顺序被改变,Vue 将不会移动 DOM 元素来匹配数据项的顺序, 而是简单复用此处每一个元素
不过这种机制在没有key的状况下会形成一些问题,好比按删除按钮,但被删除的元素不是你想指定的那个
因此要为每一项肯定一个惟一的id,从Virtual Dom的角度也能更好的作恰当的优化
再来借用一次那张流程图
但愿本文对于你们理清Vue的运做原理有所帮助
尤大大对于Virtual Dom的介绍
www.bilibili.com/video/av621…
Mvvm视频教程
www.bilibili.com/video/av240…
尤大大讲解Vue源码
www.bilibili.com/video/av514…
Object.defineProperty()和proxy的区别
www.fly63.com/article/det…
数据双向绑定系列教程
www.chuchur.com/article/vue…
收藏好这篇,别再只说“数据劫持”了
juejin.im/post/5af198…
很差意思!耽误你的十分钟,让MVVM原理还给你
juejin.im/post/5abdd6…
DOM和Virtual DOM之间的区别
www.jianshu.com/p/620b0435d…
vue核心之虚拟DOM(vdom)
www.jianshu.com/p/af0b39860…
虚拟DOM与DIFF算法学习
segmentfault.com/a/119000001…
为何虚拟DOM更优胜一筹
www.cnblogs.com/rubylouvre/…
谈谈Vue/React中的虚拟DOM(vDOM)与Key值
juejin.im/post/5cff1b…
尤大大关于Virtual DOM的知乎回答
www.zhihu.com/question/31…
Vue源码学习笔记
jiongks.name/blog/vue-co…
Vue技术揭秘
ustbhuangyi.github.io/vue-analysi…
Vue源码分析
github.com/liutao/vue2…
入口文件开始,分析Vue源码实现
juejin.im/post/5adead…
Vue源码学习
hcysun.me/2017/03/03/…
快速了解 Vue2 MVVM
github.com/wangfupeng1…
其中最推荐的是看最后这一篇,我的认为是讲的最清楚的,流程图也是借用这位做者的
这是个人我的网站,记录下前端学习的点滴,欢迎你们参观
www.ssevenk.com