前言
MVVM实际上是MVC的改进版,其立足于MVP框架。使用Vue时,我们会体会到其与React的区别,绑定表单数据时react对输入框读写需要input事件设置state,以及value绑定,而vue则只需将数据与model绑定即可,这种数据驱动视图却与视图解耦的编程方式使用起来很方便。以前面试官问vue原理,若能说出双向绑定实现和Object.defineProperty就已经够了,现在随着对vue深入的学习,面试官已经不仅仅局限于此。所以,为了深入体验mvvm模式,我实现了一个mini-vue。
MVVM
在开始前,我们先试着参照下图实现一个简单的双向绑定案例
DOM通过eventListener修改Model,Model通过修改data驱动视图
在html>body中添加以下代码就可以实现
<input id="input-box" type="text"> <div id="show-text"></div> <script> const showText = document.querySelector('#show-text') const inputBox = document.querySelector('#input-box') class VM { data = { value: '' } constructor() { Object.defineProperty(this.data, 'value', { set(v) { showText.textContent = v }, get() { return showText.textContent } }) } } const vm = new VM() inputBox.addEventListener('input', function (e) { vm.data.value = e.target.value console.log(vm.data.value) }) </script>
在上述代码中,我们可以使用Object.defineProperty将data和textContent的值绑定,从而达到数据驱动视图的效果,那么这样就够了吗?
mini-vue实现
下面是双向绑定的流程
通过上图我们可以得知:new MVVM后会进行两步操作,一是compile指令解析,将v-if,@click,{ { } }解析出来,获取data中的数据,并且都与watcher绑定,第一次初始化和watcher监听到数据变化时会执行updater,重新渲染页面,二是observer数据劫持,将data中的数据通过defineProperty添加读写监听,并将数据变化与watcher绑定在一起,那么此时watcher就是连接数据变化和视图更新的枢纽。
下面我们一步一步实现上述代码
Compile(指令解析)
我们回顾一下vue是如何使用的,标签中各种v-if,v-show,v-html,以及@click等等属性,绑定着data中的属性和methods中的函数
<div id="app"> <span v-text='title.name'></span> <div v-if='isRender'> <span>1</span> <span>2</span> <span>3</span> </div> <ul> <li v-if='isRender'>{{info.name}}---{{info.age}}---{{modelData}}---{{inputVal.item.value}}</li> <li v-show='isShow'>{{info.age}}</li> <li v-if='isRender'>{{modelData}}</li> <li v-show='isShow'>{{inputVal.item.value}}</li> </ul> <span v-text='inputVal.item.value'></span> <div v-html='htmlTemp'></div> <div v-show='isShow'>world</div> <button v-on:click='handlerShow'>点击显示</button> <button @click='handlerRender'>点击渲染</button> <input v-model='modelData' type="text"> <input v-model='inputVal.item.value' type="text"> </div>
而实例化vue则是将数据和函数初始化到vue中
let vm = new Vue({ el: '#app', data: { title: { name: 'hello' }, info: { name: '张三', age: 23, }, isShow: true, isRender: true, modelData: 123, htmlTemp: '<span style="color:red;">html</span>', inputVal: { item: { value: 'abc' } } }, methods: { handlerShow() { this.isShow = !this.isShow }, handlerRender() { this.isRender = !this.isRender } }, })
那么,我们要如何去让js识别这些指令并渲染视图呢
首先,创建标签碎片,将Dom元素获取到DocumentFragment中,以便于解析指令及根据指令对视图响应,其次,将标签属性分离,每种指令对应一种响应方式(updater)。最后绑定watcher监听到数据变化时,再次触发updater
以下是compile.js,用来解析标签内容和属性
// 指令解析器 const textRegex = /\{\{(.+?)\}\}/g //解析{{}}的正则 class Compile { constructor(elem, vm) { this.elem = isElemNode(elem) === '1' ? elem : document.querySelector(elem) this.vm = vm const fragment = this.createFragment(this.elem) this.getTemp(fragment, this.vm) this.elem.appendChild(fragment); } // 递归子元素,查找所有元素 getTemp(fragment, vm) { const fragmentChild = Array.from(fragment.childNodes) fragmentChild.forEach(item => { this.filterElem(item, vm) item.childNodes && item.childNodes.length && this.getTemp(item, vm) }) } // 创建标签碎片,将dom元素添加到标签碎片中 createFragment(elem) { const fragment = document.createDocumentFragment(); while (elem.firstChild) { fragment.append(elem.firstChild) } return fragment } // 针对不同元素节点进行分离 filterElem(elem, vm) { switch (isElemNode(elem)) { case 1: //元素节点 this.renderNode(elem, vm) break; case 3: //文本节点 this.renderText(elem, vm) break; } } // 渲染文本,主要解析‘{{}}’及多个‘{{}}’ renderText(elem, vm) { textRegex.test(elem.textContent) && updater(elem, vm, elem.textContent, 'text-content') } // 渲染标签 renderNode(elem, vm) { //取出所有属性和值 Array.from(elem.attributes).forEach(attr => { const { name, value } = attr; // 过滤‘v-’和‘@’操作,并移除标签属性 name.startsWith('v-') ? (this.compileV_Command(elem, vm, name, value), removeAttr(elem, name)) : name.startsWith('@') ? (this.compileEventComment(elem, vm, name.split('@')[1], value), removeAttr(elem, name)) : null }) } // v- 指令解析,指令 compileV_Command(elem, vm, name, value) { const key = name.split('v-') const eventCommand = key[1] && key[1].split(':')[1] // v-model事件 key[1] === 'model' && this.compileEventComment(elem, vm, 'input', value, e => { setDeepData(vm, value, e.target.value) }) // 过滤指令是否为事件 eventCommand ? this.compileEventComment(elem, vm, eventCommand, value) : updater(elem, vm, value, key[1]) } // @ 指令解析,事件 compileEventComment(elem, vm, name, value, fn) { !fn && elem.addEventListener(name, vm.options.methods[value].bind(vm)) fn && elem.addEventListener(name, fn.bind(vm)) } }
Updater(视图更新)
指令解析完后自然需要updater.js,对当前元素进行下一步渲染,在此之前,我们的值需要从vue.data中取,这样才能将data数据绑定到标签中,lodash有两个函数一个是_.get(),另一个是_.set(),作用是获取和设置对象某一层某个值,所以我们需要在utils(工具函数)中实现一下
utils.js
//lodash中的 _.get(),获取对象多级属性 function getDeepData(object, path, defaultValue) { const paths = path.split('.') for (const i of paths) { //逐层遍历path object = object[i] if (object === undefined) { //不能用 '!object' null,0,false等等会等于false return defaultValue } } return object } //lodash中的 _.set(),赋值对象某级属性 function setDeepData(object, path, value) { const paths = path.split('.') const last = paths[paths.length - 1]//为何要在length - 1时赋值:因为object的引用关系使得我们可以一级一级赋值,而当最后一项是基本类型时,无法将引用的值赋给原始的object let _obj = object for (const i of paths) { last === i && (_obj[last] = value) _obj = _obj[i] } } // 移除属性值 function removeAttr(elem, key) { elem.removeAttribute(key) } // 获取标签类型 function isElemNode(elem) { return elem.nodeType }
updater.js
// 更新视图,标签中指令属性处理 function updater(elem, vm, value, type) { switch (type) { case 'text': elem.textContent = getDeepData(vm.data, value) break; case 'text-content': elem.textContent = value.replace(textRegex, (..._) => getDeepData(vm.data, _[1])) break; case 'html': elem.innerHTML = getDeepData(vm.data, value) break; case 'model': elem.value = getDeepData(vm.data, value) break; case 'if': const temp = document.createTextNode('') elem.parentNode.insertBefore(temp, elem); getDeepData(vm.data, value) ? temp.parentNode.insertBefore(elem, temp) : temp.parentNode.removeChild(elem) break; case 'show': elem.hidden = !getDeepData(vm.data, value) break; } }
完成这一步后,我们在vue.js中调用
class VueDemo { constructor(options) { this.options = options //配置信息 this.data = options.data; // 判断options.el是否存在 (this.el = options.el) && Object.defineProperties(this, { compile: { value: new Compile(options.el, this) //指令解析器 } }) } }
效果出来了,指令被解析出来并且在页面中显示
Proxy(代理data)
我们虽然将vue.data中的数据渲染到了页面,但是还是需要通过this.data来获取数据,而vue可以中直接通过this来拿到数据,此时我们需要新建一个proxy.js将this.data代理到this上
// data数据代理到vue class DataProxy { constructor(data, vm) { for (const key in data) { Object.defineProperty(vm, key, { get() { return data[key]; }, set(val) { data[key] = val; } }) } return data } }
在vue.js中调用,并将updater.js 中的vm.data改成vm
class VueDemo { constructor(options) { this.options = options //配置信息 this.$data = options.data; // 判断options.el是否存在 (this.el = options.el) && Object.defineProperties(this, { proxy: { value: new DataProxy(options.data, this) //data代理到this }, compile: { value: new Compile(options.el, this) //指令解析器 } }) } }
写到这里,compile和updater已经实现了,接下来将是数据劫持的实现方式
Observer(数据劫持)
这一步的作用是将data中的数据都加上读写响应控制,给所有数据绑定可以更新视图的函数
// 发布模式 class Observer { constructor(data) { this.initObserver(data) } // 劫持所有数据 initObserver(data) { if (data && typeof data === 'object') { for (const key in data) { this.defineReactive(data, key, data[key]) } } } // 响应拦截器,递归监听所有层级 defineReactive(data, key, val) { this.initObserver(val) //劫持子项 Object.defineProperty(data, key, { enumerable: true, // 允许枚举 configurable: false, // 不能被定义 get: _ => val,//初始化获取值时对dep绑定 set: newVal => val = newVal }) } }
Dep(调度中心)
watcher的作用是将上面的observer与视图的刷新函数updater进行连接,当observer监测到数据变化时会通过dep告诉watcher,watcher就会执行updater更新视图,于是,我们需要先实现observer与watcher之间的观察者dep,我们先假定watcher中更新视图的函数名字叫compareVal,将watcher注册到调度中心中
// 调度中心(观察者模式) class Dep { observerList = [] //调度中心,存放与属性绑定的事件 //触发所有与该属性绑定的事件 fireEvent() { this.observerList.forEach(target => { target.compareVal() }) } //注册事件 subscribe(target) { target.compareVal && this.observerList.push(target) } }
Watcher(数据观察)
watcher的作用是连接observer和compile,使数据和视图绑定
以下是watcher.js的实现
// 订阅模式(比较绑定值的变化) class Watcher { constructor(vm, val, update) { this.vm = vm this.val = val; this.update = update this.oldVal = getDeepData(this.vm, this.val) update() //首次渲染初始化 } // 对比数据,更新视图 compareVal() { const newVal = getDeepData(this.vm, this.val); newVal !== this.oldVal && (this.update(), this.oldVal = newVal) //更新视图后将新值赋到oldVal上 } }
函数的连接
我们来回顾一下以上功能的实现
整个流程中的函数部分已经全部实现,只剩下如何将他们联系在一起,这时如果你对整个功能实现还有些模糊,那请认真分析一下这张流程图,并继续看下去吧
首先我们把watcher和指令解析以及updater之间的关系实现。
在updater中给予每一个指令一个watcher,将更新视图操作绑定到watcher中,由compareVal来更新视图
// 更新视图,标签中指令属性处理 function updater(elem, vm, value, type) { switch (type) { case 'text': new Watcher(vm, value, _ => { elem.textContent = getDeepData(vm, value) }) break; case 'text-content': value.replace(textRegex, (..._) => { //外面的content.replace获取所有{{}}中的属性 new Watcher(vm, _[1], _ => { //里面的content.replace获取data中绑定的值 elem.textContent = value.replace(textRegex, (..._) => getDeepData(vm, _[1])) }) }) break; case 'html': new Watcher(vm, value, _ => { elem.innerHTML = getDeepData(vm, value) }) break; case 'model': new Watcher(vm, value, _ => { elem.value = getDeepData(vm, value) }) break; case 'if': const temp = document.createTextNode('') elem.parentNode.insertBefore(temp, elem); new Watcher(vm, value, _ => { getDeepData(vm, value) ? temp.parentNode.insertBefore(elem, temp) : temp.parentNode.removeChild(elem) }) break; case 'show': new Watcher(vm, value, _ => { elem.hidden = !getDeepData(vm, value) }) break; } }
那么如何告诉watcher数据发生了改变呢?
在watcher中我们获取oldvalue时采用this.oldVal = getDeepData(this.vm, this.val)
这个操作会使observer中data属性的get被触发,此时如果我们将watcher注册到dep中即可对所有数据变化进行监听,然鹅,在实现的时候,发现了一些问题,由于defineReactive将data所有属性都监听了,导致取属性时使用{{info.name}}时,data.info和data.info.name都会被劫持,而我们只需要info.name,所以,当dep注册watcher时需要设置一个开关,并且在observer中根据开关添加监听,修改的watcher和observer如下:
watcher.js
// 订阅模式(比较绑定值的变化) class Watcher { constructor(vm, val, update) { this.vm = vm this.val = val; this.update = update this.oldVal = this.getOldVal() //获取初始值,触发observer中属性的get update() //首次渲染初始化 } getOldVal() { Dep.target = this //将watcher暂存到Dep上,在Observer中通过dep.subscribe将watcher传到dep的observerList(调度中心)中,后续当值发送修改时通过fireEvent触发watcher.compareVal来更新视图 const oldVal = getDeepData(this.vm, this.val) //触发Observer中的getter,将watcher注册到dep中 Dep.target = null return oldVal } // 对比数据,更新视图 compareVal() { const newVal = getDeepData(this.vm, this.val); newVal !== this.oldVal && (this.update(), this.oldVal = newVal) //更新视图后将新值赋到oldVal上 } }
observer.js中的defineReactive
// 响应拦截器,递归监听所有层级 defineReactive(data, key, val) { this.initObserver(val) //劫持子项 const dep = new Dep() //将observer与watcher连接,当watcher触发数据变化后,将watcher中的回调函数注册到dep中 Object.defineProperty(data, key, { enumerable: true, // 允许枚举 configurable: false, // 不能被定义 get: _ => { Dep.target && dep.subscribe(Dep.target); //获取属性值时,将watcher中的回调函数注册到dep中(在页面初始化时调用) return val }, set: newVal => newVal !== val && (val = newVal) //设置属性时,对比新值和旧值有无差别 }) }
现在,我们只剩下当数据发生改变时,如何通知watcher,因为上述的defineReactive中已经将watcher注册到了dep,此时我们只需在数据变化时也就是defineReactive的set中对数据更新进行响应,当某条数据被设置时,我们将dep中watcher触发即可
// 响应拦截器,递归监听所有层级 defineReactive(data, key, val) { this.initObserver(val) //劫持子项 const dep = new Dep() //将observer与watcher连接,当watcher触发数据变化后,将watcher中的回调函数注册到dep中 Object.defineProperty(data, key, { enumerable: true, // 允许枚举 configurable: false, // 不能被定义 get: _ => { Dep.target && dep.subscribe(Dep.target); //获取属性值时,将watcher中的回调函数注册到dep中(在页面初始化时调用) return val }, set: newVal => newVal !== val && (val = newVal, this.initObserver(newVal), dep.fireEvent()) //设置属性时,对比新值和旧值有无差别,若修改的值是引用型时,将属性重新注册到dep中,并更新视图 }) }
至此,流程图中的所有功能均已实现,让我们在vue.js中实例化observer试试效果
class VueDemo { constructor(options) { this.options = options //配置信息 this.$data = options.data; // 判断options.el是否存在 (this.el = options.el) && Object.defineProperties(this, { //observer和compile的顺序不要错,否则监听不到compile中的数据 observer: { value: new Observer(options.data) // 数据监听器 }, proxy: { value: new DataProxy(options.data, this) //data代理到this }, compile: { value: new Compile(options.el, this) //指令解析器 } }) } }
写在最后
感谢你看到了最后,希望文章能对你有帮助,同时也欢迎你提出宝贵的建议