阅读 209

Vue双向绑定原理浅析

前言

前端框架百花齐放,技术发展亦是瞬息万变,关注框架的底层原理也许有利于我们走的更远,下面我们通过一个简易版的Vue基类来浅析其双向绑定原理

双向绑定的构成

双向绑定非Vue一家独有,相比于Angular的臃肿,vue的双向绑定更加轻量与便捷。如下图所示,当视图发生改变的时候传递给VM,再让数据得到更新,当数据发生改变的时候传递给VM,视图得以发生改变

整个框架由三个部分构成:

Model : 包含了业务和验证逻辑的数据模型

View : 应用的展示效果,各类UI组件,由 template 和 css 组成

ViewModel :扮演“View”和“Model”之间的使者,帮忙处理 View 的全部业务逻辑

ViewModel主要指责分为:

  1. 数据更改驱动视图更新
  2. 视图变化后更新数据

那么它的实现可以划分为两个部分组成

  1. 解析器(Compiler):编译、解析指令,初始化视图并监察UI,每当视图变化更新数据
  2. 监听器(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基类可以划分为:

  1. 绑定创建实例时传入的参数
  2. 如果节点传入,数据代理,this.data.person => this.person
  3. 模版编译Compiter,将v-model{{}}指令替换成data中对应的数据
  4. 数据劫持Observer,对data每个属性绑定对应的gettersetter,数据变化时更新视图
  5. 实现Watcher,将Observer和Compiter绑定

数据代理

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.definePropertythis添加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
}
复制代码

模版编译主要功能划分:

  1. 获取当前实例绑定的节点,放入文档节点碎片
  2. 通过compile方法将节点指令(v-model|{{}})绑定的数据进行替换
  3. 将编译好的内容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第二个参数为函数,其参数有四个

  1. 匹配到的结果 “{{person.name}}”
  2. 匹配{{}},提取到的结果person.name
  3. 匹配开始位置
  4. 匹配到的原字符串

数据劫持

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绑定呢?

  1. 我们需要初始化模版编译时,对插值表达式{{xx}}或指令v-xx编译时,创建一个Watcher
  2. 当我们创建Watcher时,会获取上一次状态触发getter,此时调用DepaddSubs,将发布者添加进栈subs(此时我们用数组模拟栈结构)
  3. 当数据状态改变,触发setter,调用Depnotify,依次出发watcher的更新方法

Observer:

如图所示:

  1. 新建订阅者时,需要获取上一次状态(get方法),取值之前,将this绑定给Dep.target属性,取值时,触发Observergetter,如果有Deptarget属性,将订阅者添加到队列subs中,取值后,将Dep.target设置为null,浏览器将Dep.target回收
  2. 当数据更改时,触发observersetter,调用depnotify方法,循环队列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>
复制代码
  1. 第一步我们需要对methods里的方法代理,上文已经完成
  2. 我们需要对Compiter元素节点编译方法进行重写,v-on:click不同于v-model,v-html
  3. 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)  
        })
    },
}
复制代码

源码附上:gitee.com/sun_jiaxing…

文章分类
前端
文章标签