我对MVVM的学习笔记
前言
最近在学习MVVM的实现原理,刚好在sf上看到了剖析Vue原理&实现双向绑定MVVM一文,写的非常好,摘出Vue.js中的部分源码,改造后完成了一个简单的MVVM实现。实现了双向数据绑定,我自己在学习的过程中,也照着这篇文章中的源码重新实现了一遍。不同之处在于,我尽量将原来的实现写成了ES6的写法,比如使用class代替构造函数,将observer,dep,watcher,compiler分成不同的模块,然后使用import,export来互相引入,导出,最后使用rollup-babel-lib-bundler打包了一下。所以这篇文章是对上面文章的学习总结,不会写的很细。大家也可以读一下上面的文章,简单易懂。
我重新写过的项目地址在这里,有兴趣的可以看看。
整体结构
这个简易的MVVM总共由index.js(入口文件),compiler.js,dep.js,observer.js,watcher.js几部分组成。
.
├── README.md
├── dest
│ ├── toy.es2015.js
│ ├── toy.js
│ └── toy.umd.js
├── examples
│ └── index.html
├── package.json
├── rollup.config.js
└── src
├── compiler.js
├── dep.js
├── index.js
├── observer.js
└── watcher.jsindex.js是整个框架的入口,比如我给这个框架起了个名字叫Toy,入口文件导出的其实就是Toy的构造函数:
//引入其它模块
import { observe } from './observer.js'
import { Compiler } from './compiler.js'
import { Watcher } from './watcher.js'
//具体实现
class Toy {
constructor(options){
//...
}
}
//导出模块
export { Toy }初始化的过程分两步:
劫持监听所有属性,通过
Object.defineProperty将数据变成响应式的,同时在get和set上做一些手脚。编译html模板,事实上我们在使用框架时写的html已经填充了很多框架自己的指令,语法,所以要先进行编译替换才能正确展示视图。
实现所有属性的监听就是通过Object.defineProperty递归地定义所以属性。每一个对象都会有一个对应的observer实例,其中的每一个属性都对应有一个dep的实例dep,dep使用自增的uid标识,作用是记录这个属性被那些订阅者(watcher的实例)订阅了,好在属性变化时,通过遍历dep.subs去通知所有订阅了这个属性的watcher去做对应的更新。
实现compiler就是对带有框架特殊API的模板进行编译,指令解析。同时将DOM与数据关联起来(其实是通过Watcher实现的)。
本质上说
每个部分负责的事情我是这样理解的:
index.js 框架的入口,提供对外的构造函数。
observer.js 将数据变成响应式,同时通过
dep收集依赖(Watcher实例)。dep.js 收集依赖用的,在
get中收集依赖,在set中通知对应依赖更新。watcher.js 数据的订阅者,一个Watcher的实例由
vm,exp,cb,deps等几部分组成,vm是对ViewModel的引用,触发get方法将watcher自身添加至dep的subs中时会用到,exp则是当前Watcher实例监听的表达式,即数据的key,cb则是更新数据的回调。
当vm的数据改变后,会触发对应的set方法,这个属性对应的dep会通知所有的subs去执行自身的update方法,而这个update方法的内容其实只是this.cb.call(this.vm, value, oldValue),cb实际上是调用了updateFn(在compiler.js中绑定的),这时才将DOM的数据真正更新。compiler.js 编辑DOM模板,并为每个
node节点通过new Watcher的方式将属性表达式exp,updateFn(真正更新DOM的函数)与node关联,然后配合响应式数据就做到了view与model的双向绑定。
所以整个框架的运行过程是这样的:
observe所有数据,改写了每个数据的get和set方法,并为每个数据关联了一个dep(通过闭包实现)。new Compiler开始编译模板,编译过程中,可以提取出指令,v-text,v-html等,可以分析出事件函数v-click和绑定的表达式,这时通过self.compileText(node, RegExp.$1),self.compile(node)将DOM节点和表达式建立关联。建立的关联,是DOM节点和数据表达式的关联,这一步是通过
new Watcher实现的new Watcher的时候,Watcher实例会将Dep.target这个全局属性指向自身,然后出发一下需要监听属性的getter,这时dep会将Watcher实例添加到它的subs中,Watcher实例也会标记一下这个dep已经添加过自己了,防止重复添加。这时dep和Watcher实例已经关联起来了,数据的变化可以通知到对应的Watcher实例,Watcher实例的update方法会正确地更新DOM。
其实到这里,数据的双向绑定就已经实现了。
过程中学习到的一些细节
记录一些在学习过程中遇到的小tips,其实都是很基础的东西。
Node.textContent: 表示一个节点及其内部节点的文本内容。之前一直都是用
innerText的,看了MDN才知道innerText原来是IE私有的,textContent才是标准属性。而且innerText受样式影响,还会触发重排,所以还是用textContent代替吧。Node.appendChild: 这个API有一个很有意思的行为:如果被插入的节点已经存在于当前文档的文档树中,则那个节点会首先从原先的位置移除,然后再插入到新的位置.,当时我在看
compiler.js的node2Fragment方法:
node2Fragment(el){
let fragment = document.createDocumentFragment()
let child
while(child = el.firstChild){
fragment.appendChild(child)
}
return fragment
}当时很不解为什么while循环能成按照预期执行,我在浏览器多次调用el.firstChild拿到的也始终是第一个子节点,看了这个API的文档才发现还有这么个行为!
Node.attributes: 可以方便地获取DOM节点的属性,返回值是一个对象,其中
name是属性名,value是属性值。
最后
终于明白了简易MVVM框架的运作原理,也发现了一些底层API的知识,写成一些总结,这篇文章中没有贴很多代码去说实现,因为剖析Vue原理&实现双向绑定MVVM一文已经很详细了,我也是按照这个去学习的,所以我记录的是我个人的一些思想上的总结,所以可能要先看代码才能了解。分享出来,希望能有人从中受益 :)