8

前言

最近在学习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.js

index.js是整个框架的入口,比如我给这个框架起了个名字叫Toy,入口文件导出的其实就是Toy的构造函数:

//引入其它模块
import { observe } from './observer.js'
import { Compiler } from './compiler.js'
import { Watcher } from './watcher.js'

//具体实现
class Toy {
    constructor(options){
        //...
    }
}

//导出模块
export { Toy }

初始化的过程分两步:

  1. 劫持监听所有属性,通过Object.defineProperty将数据变成响应式的,同时在getset上做一些手脚。

  2. 编译html模板,事实上我们在使用框架时写的html已经填充了很多框架自己的指令,语法,所以要先进行编译替换才能正确展示视图。

实现所有属性的监听就是通过Object.defineProperty递归地定义所以属性。每一个对象都会有一个对应的Observer实例,其中的每一个属性都对应有一个Dep的实例depdep使用自增的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自身添加至depsubs中时会用到,exp则是当前Watcher实例监听的表达式,即数据的keycb则是更新数据的回调。
    vm的数据改变后,会触发对应的set方法,这个属性对应的dep会通知所有的subs去执行自身的update方法,而这个update方法的内容其实只是this.cb.call(this.vm, value, oldValue)cb实际上是调用了updateFn(在compiler.js中绑定的),这时才将DOM的数据真正更新。

  • compiler.js 编辑DOM模板,并为每个node节点通过new Watcher的方式将属性表达式expupdateFn(真正更新DOM的函数)node关联,然后配合响应式数据就做到了viewmodel的双向绑定。

所以整个框架的运行过程是这样的:

  1. observe所有数据,改写了每个数据的get和set方法,并为每个数据关联了一个dep(通过闭包实现)。

  2. new Compiler开始编译模板,编译过程中,可以提取出指令,v-text,v-html等,可以分析出事件函数v-click和绑定的表达式,这时通过self.compileText(node, RegExp.$1),self.compile(node)将DOM节点和表达式建立关联。

  3. 建立的关联,是DOM节点和数据表达式的关联,这一步是通过new Watcher实现的

  4. 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.jsnode2Fragment方法:

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一文已经很详细了,我也是按照这个去学习的,所以我记录的是我个人的一些思想上的总结,所以可能要先看代码才能了解。分享出来,希望能有人从中受益 :)


Showonne
2.8k 声望1.2k 粉丝