前言
前端框架百花齐放,技术发展亦是瞬息万变,关注框架的底层原理也许有利于我们走的更远,下面我们通过一个简易版的Vue基类来浅析其双向绑定原理
双向绑定的构成
双向绑定非
Vue
一家独有,相比于Angular
的臃肿,vue
的双向绑定更加轻量与便捷。如下图所示,当视图发生改变的时候传递给VM,再让数据得到更新,当数据发生改变的时候传递给VM,视图得以发生改变整个框架由三个部分构成:
Model : 包含了业务和验证逻辑的数据模型
View : 应用的展示效果,各类UI组件,由 template 和 css 组成
ViewModel :扮演“View”和“Model”之间的使者,帮忙处理 View 的全部业务逻辑
ViewModel
主要指责分为:
- 数据更改驱动视图更新
- 视图变化后更新数据
那么它的实现可以划分为两个部分组成
- 解析器(Compiler):编译、解析指令,初始化视图并监察UI,每当视图变化更新数据
- 监听器(Observer):数据劫持,每当数据变化,调用compiler的模版编译方法,更新视图
最后通过watcher将两个部分组合起来,一个简易的Vue双向绑定就实现了
任务分解
<div id='app'> <input type='text' v-model='person.name'/> <div> {{person.hobby}} {{person.name}} </div> </div> <script> let vm = new Vue({ el:"#app", data:{ person:{ name:'卜算子', hobby:"零落成泥碾作尘,唯有香如故" } } }) </script>
class Vue { constructor(options){ this.$el = options.el this.$data = options.data let computed = options.computed let methods = options.methods // 如果有$el,启动模板编译 if( this.$el ) { new Observer(this.$data) // 数据劫持 this.proxyUntil(this.$data) // 数据代理 this.name => this.data.name new Compiter(this.$el,this) // 模版编译 ... // 模板代理 this.data.getValue => computed['getValue'] ... // 方法代理 this.btnClick => methods['btnClick'] } } proxyUntil(data) { ... } }
如上图,一个Vue基类可以划分为:
- 绑定创建实例时传入的参数
- 如果节点传入,数据代理,this.data.person => this.person
- 模版编译
Compiter
,将v-model
、{{}}指令
替换成data
中对应的数据- 数据劫持
Observer
,对data
每个属性绑定对应的getter
和setter
,数据变化时更新视图- 实现
Watcher
,将Observe
r和Compite
r绑定
数据代理
class Vue { constructor(options){ ... } proxyUntil(data) { for( let key in data ) { Object.defineProperty(this,key, { get:() => data[key], set:newVal => data[key]=newVal }) } } }
当我们想要获取
data
定义的属性值时,例如data.person
,我们希望this.person
获取,而不是this.data.person
我们可以通过
Object.defineProperty
给this
添加data
中的所有属性,当我们通过this.person
取值时,代理为this.data.person
那么,从语法和使用上都变的更加简单。
模版编译
/** * @name: 模板编译 * @param{ el }: 根节点 * @param{ vm }:当前实例 */ class Compiter{ constructor(el, vm) { // 获取节点, 如果传入是'#app' el= document.querySelector(el) this.$el = this.isElementNode(el) ? el : document.querySelector(el) this.$vm = vm let framMent = this.nodeFramgment(this.$el) // 根节点中的元素放到文档碎片 this.compile(framMent) // 模版编译:节点指令(v-model|{{}})替换 this.$el.appendChild(framMent) // 把编译好的文档碎片再放到页面重排 } nodeFramgment(node) { ... } // HTML节点放到文档碎片进行统一编译处理 compile(node){ ... } // 模板编译 compileElement(node){ ... } // 编译元素节点(v-model|v-html|v-on:click) compileText(node) { ... } // 编译文本节点 {{}} isDirective = attrName => attrName.startsWith("v-") // 是否以 v- 开头 isElementNode = node => node.nodeType === 1 // 判断是否是元素节点,元素节点:1| 文本节点:3 }
模版编译主要功能划分:
- 获取当前实例绑定的节点,放入文档节点碎片
- 通过
compile
方法将节点指令(v-model|{{}})绑定的数据进行替换- 将编译好的内容appendChild到实例节点下
nodeFramgment创建文档碎片
// HTML节点放到文档碎片进行统一编译处理 nodeFramgment(node) { let frament = document.createDocumentFragment() // 创建文档节点碎片 let firstChild // 利用DOM的映射机制,每次拿到<div id='app> 第一个子节点,放入文档节点碎片直到所有的子节点都添加到内存 while (firstChild = node.firstChild) { frament.appendChild(firstChild) } return frament }
DOM内存映射?
JS 从页面获取到的元素对象,或者自己手动创建的已经插入页面的元素对象,与页面中的
HTML
元素是绑定在一起的。也就是说修改其中一个,另一个也会跟着自动修改。
为什么需要将绑定节点下的元素添加到文档节点编译?
DocumentFragments
是DOM节点,通常的用例是创建文档片段,将元素附加到文档片段,然后将文档片段附加到DOM树。因为文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流(reflow)。因此,使用文档片段通常会起到优化性能的作用。
compile编译内存节点
// 模板编译 compile(node){ let childNodes = node.childNodes Array.from(childNodes).forEach( ele => { if( this.isElementNode(ele)) { // 元素节点 this.compileElement(ele) // 元素节点模版编译方法 this.compile(ele) // 递归处理子节点(子节点存在嵌套层级) }else{ this.compileText(ele) // 文本节点模版编译方法 } }) } // 编译元素节点(v-model|v-html|v-on:click) compileElement(node){ let attributes = node.attributes // 元素节点属性集合[ {name:'v-model',value:'person.name'...}...] Array.from(attributes).forEach(attr =>{ let {name, value:expr} = attr if( this.isDirective(name)) { // 如果属性以 v- 开头 let [,directive] = name.split("-") // [ 'v-','model'],[ 'v-','html'] compileUntil[directive](node,expr,this.$vm) // 使用策略模式,调用不同指令的处理方法 } }) } // 编译文本节点 {{}} compileText(node) { let content = node.textContent if(/\{\{(.+?)\}\}/.test(content)) { // 判断文本节点是否包含{{}} compileUntil['text'](node,content,this.$vm) } }
+? 和 .+ 的区别
(.+)默认是贪婪匹配
(.+?)为惰性匹配
const str='abcba' (.+) 最后往前匹配 str.match(/.+b/) 第一次a不匹配,去掉a,接着匹配,返回 abcb (.+?)从前向后匹配 str.match(/.+?b/) 第一次a不匹配,加入下一字符尝试匹配,返回 ab
compileUntil编译方法
/**@name: 模板工具类{策略模式} * @param {node} 当前处理节点 * @param {expr} 表达式 school.name * @param {vm} 当前实例,vm.$data */ compileUntil = { getValue:(vm,expr) => expr.split(".").reduce( (pre,cur) => pre[cur],vm.$data), model(node,expr,vm){ let value = this.getValue(vm,expr) this.updater['modelUpdater'](node,value) }, text(node,expr,vm){ let content = expr.replace(/\{\{(.+?)\}\}/g,(...args)=>{ return this.getValue(vm,args[1]) }) this.updater['textUpdater'](node,content) }, // 替换节点对应的指令 updater:{ modelUpdater: (node,value) => node.value=value, textUpdater: (node,value) => node.textContent = value } }
getValue():
例如获取
person.name
的值,可以将person.name => ['person','name'],通过reduce函数,先拿到this.data.person的值,再拿到this.data.person.name的值text ():
以
{{person.name}}{{person.hobby}}
为例,通过replace
函数将{{person.name}}{{person.hobby}}
替换为this.data.person.name\this.data.hobby.hobby,然后通过textContent 函数将模版指令替换replace第二个参数为函数,其参数有四个
- 匹配到的结果 “{{person.name}}”
- 匹配{{}},提取到的结果person.name
- 匹配开始位置
- 匹配到的原字符串
数据劫持
class Observer { constructor(data){ this.observer(data) } observer(data) { if( data && typeof data === 'object') { // 如果是对象,为每一项添加set和get for( let key in data ) { this.defineReative( data,key,data[key]) } } } defineReative(obj,key,value) { this.observer(value) // 如果对象的属性也是对象,递归调用observer Object.defineProperty(obj,key,{ get: () => value, set: newVal => { if( newVal !== value){ this.observer(newVal) // 如果设置的值为对象,observer进行监听 value = newVal } } }) } }
初次调用传入data对象,利用
Object.defineProperty
将data中的数据全部转换成getter/setter
,如果代理的属性为对象,通过递归调用observer
方法,当某个属性的值发生改变时触发setter
,就能监听到数据的变化
绑定Observer和Compiter
发布订阅模式
/**
* @description: 发布者
* @param {vm} 当前实例
* @param {expr} 表达式 school.name
* @param {callback} 回调函数
**/
class Watcher{
constructor(vm,expr,callback) {
this.vm = vm
this.expr = expr
this.callback = callback
this.oldValue = this.get() // 上一次状态
}
// 根据表达式获取值
get() {
let value = compileUntil.getValue(this.vm,this.expr)
return value
}
// 数据变化后,调用发布者的 update 方法
update() {
let newValue = compileUntil.getValue(this.vm,this.expr)
if( newValue !== this.oldValue ) {
this.callback(newValue)
}
}
}
// 发布者
class Dep{
constructor(){
this.subs = []
}
// 订阅事件
addSubs(watcher){
this.subs.push(watcher)
}
// 发布事件
notify() {
this.subs.forEach(watcher => watcher.update())
}
}
如何通过发布订阅模式将Observer与Compiter绑定呢?
- 我们需要初始化模版编译时,对插值表达式
{{xx}}
或指令v-xx
编译时,创建一个Watcher
- 当我们创建
Watcher
时,会获取上一次状态触发getter,此时调用Dep
的addSubs
,将发布者添加进栈subs(此时我们用数组模拟栈结构) - 当数据状态改变,触发setter,调用
Dep
的notify
,依次出发watcher
的更新方法
Observer:
如图所示:
- 新建订阅者时,需要获取上一次状态(
get
方法),取值之前,将this
绑定给Dep.target
属性,取值时,触发Observer
的getter
,如果有Dep
有target
属性,将订阅者添加到队列subs
中,取值后,将Dep.target
设置为null
,浏览器将Dep.target
回收- 当数据更改时,触发
observer
的setter
,调用dep
的notify
方法,循环队列subs
中的订阅者,依次触发订阅者的update
方法,完成数据驱动视图
绑定订阅者
v-model:
compileUntil = { getValue:(vm,expr) => expr.split(".").reduce( (pre,cur) => pre[cur],vm.$data), setValue(vm,expr,value){ expr.split(".").reduce( (pre,cur,index,arr) => { if( index === arr.length-1) pre[cur] = value // 如果是最后一项,更新该属性值 return pre[cur] },vm.$data) }, model(node,expr,vm){ let value = this.getValue(vm,expr), fn = this.updater['modelUpdater'] new Watcher( vm,expr,newValue=>{ fn(node,newValue) }) node.addEventListener('input',e => { let value = e.target.value this.setValue(vm,expr,value) }) fn(node,value) }, updater:{ modelUpdater: (node,value) => node.value=value, } }
模版编译
v-model
指令,new Watcher
时传入回调函数(此函数执行更新数据的操作)同时监听
input
的事件,当input
框输入时,将data
中对应的key
值更新,更新时出发setter
事件,执行发布者的update
方法(即new Watcehr
时传入的回调函数),完成视图驱动数据
插值表达式{{}}
compileUntil = { getValue:(vm,expr) => expr.split(".").reduce( (pre,cur) => pre[cur],vm.$data), getContext(vm,expr){ return expr.replace(/\{\{(.+?)\}\}/g,(...args)=>{ return this.getValue(vm,args[1]) }) }, text(node,expr,vm){ const fn = this.updater['textUpdater'] let content = expr.replace(/\{\{(.+?)\}\}/g,(...args)=>{ // 匹配到的每个 {{}} 都加上发布者 {{person.hobby}} {{person.name}} new Watcher( vm,args[1],()=>{ fn(node,this.getContext(vm,expr)) }) return this.getValue(vm,args[1]) }) fn(node,content) }, updater:{ modelUpdater: (node,value) => node.value=value, htmlUpdater: (node,value) => node.innerHTML = value, textUpdater: (node,value) => node.textContent = value } }
当文本内容为
{{person.name}}{{person.hobby}}
时,name&hobby
数据更改,name/hobby
对应文本节点的内容应该是{{person.name}}{{person.hobby}}
对应的内容,而不单独是{{person.name}}
或者{{person.hobby}}
,所以new Watcher
回调函数更新值时,需要根据表达式expr
获取全部的值,再替换文本节点,这里我们用到getContext
函数
v-html
<div v-html="msg"></div> <script> data:{ msg:"<h1>沁园春.雪</h1>" } </script>
Com
compileUntil = { html(node,expr,vm){ const fn = this.updater['htmlUpdater'] new Watcher( vm,expr,newValue=>{ fn(node,newValue) }) let value = this.getValue(vm,expr) fn(node,value) }, updater:{ htmlUpdater(node,value){ node.innerHTML = value } } }
计算属性
接下来我们实现计算属性的操作,例如{{getName}}
,当初始化模版编译Compiter
时,触发文本节点函数,去获取this.data.getName
的值,这时我们只需要给this.data
添加代理,当获取this.data.getName
时,代理到computed[getName]
,通过call
触发模版编译方法,当name
变化,该文本节点会对应的值更新,methods
同理
<div id='app'> <div>{{getName}}</div> </div> <script> let vm = new Vue({ el:"#app", data:{person:{ name:'卜算子'}}, computed: { getName() { return this.person.name + '.咏梅' } } }) </script>
class Vue { constructor(options){ this.$el = options.el this.$data = options.data let computed = options.computed let methods = options.methods // 如果有$el,启动模板编译 if( this.$el ) { new Observer(this.$data) // 数据劫持 this.proxyUntil(this.$data) // 数据代理 this.name => this.data.name for( let key in computed) { // 模板代理 this.data.getValue => computed['getValue'] Object.defineProperty(this.$data,key,{ get:()=>{ return computed[key].call(this) } }) } for( let key in methods) { // 方法代理 this.btnClick => methods['btnClick'] Object.defineProperty(this,key,{ get:()=>{ return methods[key].call(this) } }) } new Compiter(this.$el,this) // 模版编译 } } proxyUntil(data) { 。。。} }
Methods
<input type='text' v-model='name'/> <div>{{hobby}}</div> <button v-on:click="change">click me</button> <script> let vm = new Vue({ el:"#app", data:{name:'canfoo',hobby:"hello world"}, methods: { change(){this.name="canfoo"} } }) </script>
- 第一步我们需要对
methods
里的方法代理,上文已经完成- 我们需要对
Compiter
元素节点编译方法进行重写,v-on:click
不同于v-model
,v-html
compileUntil
新增对应的on
事件
// 编译元素节点 compileElement(node){ let attributes = node.attributes // 元素节点属性集合 Array.from(attributes).forEach(attr =>{ let {name, value:expr} = attr // ['v-model','person.name']、['v-on:click','btnClick'] // 如果属性以 v- 开头 if( this.isDirective(name)) { let [,directive] = name.split("-") // [v,'model']、[v,'on:click'] let [direName,direEvent] = directive.split(":") // ['model',] ['on','click'] // 调用策略对象不同指令调用的方法 compileUntil[direName](node,expr,this.$vm,direEvent) } }) }
compileUntil = { /** * @param {node} 当前处理节点 * @param {expr} 表达式 change * @param {vm} 当前实例 * @param {eventName} 事件名 click * */ on(node,expr,vm,eventName){ // v-on:click="change" node.addEventListener(eventName,(e)=>{ vm[expr].call(vm,e) }) }, }