前端面试---Vue双向绑定原理(3.0和2.0优缺点对比)

5,130 阅读9分钟

Vue2.0实现双向绑定

所谓的双向数据绑定主要是mvvm设计模式中数据层(m)和视图层(v)的同步应用,在写入数据的同时,视图层也会自动同步更新,我们可以通过下面一张图来进行观察这种绑定原理:

经过查资料我们可以看到网上的各种原理,最多的答案就是下面这样的解释:

vue.js是采用的数据劫持结合发布者-订阅者模式的方式,通过object.defineProperty()来劫持各个属性的setter/getter
在数据变动时,发布消息给订阅者,触发相应的监听回调

具体步骤:
    1)需要observe(观察者)的数据对象进行遍历,包括子属性对象的属性,都加上setter和getter,这样的话,
    给这个对象的某个值赋值,就会触发setter,那么就能监听到数据的变化
    2)compile(解析)解析模版指令,将模版中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,
    添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图
    3)watcher(订阅者)是observer和compile之间通信的桥梁,主要做的事情是
        1>在实例化时往属性订阅器(dep)里面添加自己
        2>自身必须有一个update()方法
        3>待属性变动dep.notice()通知时,能够调用自身的update()方法,并触发compile中绑定的回调,
    4)mvvm作为数据绑定的入口,整合observer,compile和watcher来监听自己的model数据变化,通过compile来解析编译模版,
    最终利用watcher搭起observer和compile之间的通信桥梁,达到数据变化->更新视图:视图交互变化->数据model变更的双向绑定效果

补充:

ECMAScript中有两种属性: 数据属性和访问器属性, 数据属性一般用于存储数据数值, 访问器属性对应的是set/get操作, 不能直接存储数据值, 存储的一般是一个函数形式的数据

Object.defineProperty(), 这个方法接收三个参数:

   属性所在对象,
   
   属性的名字,
   
   描述符对象; 

为对象定义多个属性的话,就用函数的复数写法:Object.defineProperties();

实现代码步骤如下: 第一步(observer实现对vue各个属性进行监听):

function observer(obj, vm){
    //通过Object.key对属性进行遍历
      Object.keys(obj).forEach(function(key){
          defineReactive(vm, key, obj[key])
  })
}
// Object.defineProperty改写各个属性
function defineReactive( obj, key, val ) {
    // 每个属性建立个依赖收集对象,get中收集依赖,set中触发依赖,调用更新函数 
    var dep = new Dep();
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function() {
          // 收集依赖  Dep.target标志
          Dep.target && dep.addSub(Dep.target)
          return val
        },
        set: function(newVal){
            if(newVal === val) return
            // 触发依赖
            dep.notify()
            val = newVal
      }
  })
}

第二步(dep实现):

function Dep(){
    this.subs = []
}
Dep.prototype = {
    constructor: Dep,
    addSub: function(sub){
        this.subs.push(sub)
    },
    notify: function(){
        this.subs.forEach(function(sub){
            sub.update() // 调用的Watcher的update方法
      })
    }
}

第三步compiler实现对vue各个指令模板的解析器,生成抽象语法树,编译成Virtual Dom,渲染视图

// 编译器
function compiler(node, vm){
    var reg = /\{\{(.*)\}\}/;
    // 节点类型为元素
    if(node.nodeType ===1){
        var attr = node.attributes;
        // 解析属性
        for(var i=0; i< attr.length;i++){
                if(attr[i].nodeName == 'v-model'){
                var  _value = attr[i].nodeValue
                 node.addEventListener('input', function(e){
                    //给相应的data属性赋值,触发修改属性的setter
                    vm[_value] = e.target.value
                })
                node.value = vm[_value] // 将data的值赋值给node
                node.removeAttribute('v-model')
            }
        }
        new Watcher(vm,node,_value,'input')
    }
    // 节点类型为text
    if(node.nodeType ===3){
       if(reg.test(node.nodeValue)){
           var name = RegExp.$1; 
            name = name.trim()
            new Watcher(vm,node,name,'input')
       }
    }

第四步:Watcher 连接observer和compiler,接受每个属性变动的通知,绑定更新函数,更新视图

function Watcher(vm,node,name, nodeType){
    Dep.target = this; // this为watcher实例
    this.name = name
    this.node = node 
    this.vm = vm
    this.nodeType = nodeType
    this.update() // 绑定更新函数
    Dep.target = null //绑定完后注销 标志
}
Watcher.prototype = {
    get: function(){
        this.value = this.vm[this.name] //触发observer中的getter监听
  },
   update: function(){
      this.get()
      if(this.nodeType == 'text'){
        this.node.nodeValue = this.value
      }   
      if(this.nodeType == 'input') {
          this.node.value = this.value
    }
  }
}

完整实现代码:

function Vue(options){
    this.date = options.data
    var data = this.data
    observer(data, this) // 监测
    var id = options.el
    var dom = nodeToFragment(document.getElmentById(id),this) //生成Virtual Dom
  // 编译完成后,生成视图
    document.getElementById(id).appendChild(dom)    
 }
function nodeToFragment(node, vm){
    var flag = document.createDocumentFragment()
    var child
    while(child = node.firstChild){
        compiler(cild, vm)
        flag.appendChild(child)
  }
  return flag
}

// 调用
  var vm = new Vue({
    el: "app",
    data: {
        msg: "hello word"
  }
})

Vue3.0实现双向绑定

但是上述的解释只是在Vue2.0的时候进行的检测,由于Object.defineProperty本身存在一定的缺陷,比如说是不支持:

Object.defineProperty的缺陷:
1)无法检测到对象属性的新增或删除
    由于js的动态性,可以为对象追加新的属性或者删除其中某个属性,
    这点对经过Object.defineProperty方法建立的响应式对象来说,
    只能追踪对象已有数据是否被修改,无法追踪新增属性和删除属性,
    这就需要另外处理。
2)不能监听数组的变化(对数组基于下标的修改、对于 .length 修改的监测)
   vue在实现数组的响应式时,它使用了一些hack,
   把无法监听数组的情况通过重写数组的部分方法来实现响应式,
   这也只限制在数组的push/pop/shift/unshift/splice/sort/reverse七个方法,
   其他数组方法及数组的使用则无法检测到, 
解决方法主要是使用proxy属性,这个proxy属性是ES6中新增的一个属性,
    proxy属性也是一个构造函数,他也可以通过new的方式创建这个函数,
    表示修改某些操作的默认行为,等同于在语言层面做出修改,所以属于一种元编程
    proxy可以理解为在目标对象之前架设一层拦截,外界对该对象的访问,都必须经过这层拦截,
    因此提出了一种机制,可以对外界的网文进行过滤和改写,proxy这个词是代理,
    用来表示由他代理某些操作,可以译为代理器
    
    Proxy,字面意思是代理,是ES6提供的一个新的API,用于修改某些操作的默认行为,
    可以理解为在目标对象之前做一层拦截,外部所有的访问都必须通过这层拦截,
    通过这层拦截可以做很多事情,比如对数据进行过滤、修改或者收集信息之类。
    借用proxy的巧用的一幅图,它很形象的表达了Proxy的作用。
proxy代理的特点:
    proxy直接代理的是整个对象而非对象属性,
    proxy的代理针对的是整个对象而不是像object.defineProperty针对某个属性,
    只需要做一层代理就可以监听同级结构下的所有属性变化,
    包括新增的属性和删除的属性
proxy代理身上定义的方法共有13种,其中我们最常用的就是set和get,但是他本身还有其他的13种方法

proxy的劣势:
    兼容性问题,虽然proxy相对越object.defineProperty有很有优势,但是并不是说proxy,就是完全的没有劣势,主要表现在以下的两个方面:
        1)proxy有兼容性问题,无完全的polyfill:
            proxy为ES6新出的API,浏览器对其的支持情况可在w3c规范中查到,通过查找我们可以知道,
            虽然大部分浏览器支持proxy特性,但是一些浏览器或者低版本不支持proxy,
            因此proxy有兼容性问题,那能否像ES6其他特性有polyfill解决方案呢?,
            这时我们通过查询babel文档,发现在使用babel对代码进行降级处理的时候,并没有合适的polyfill
        2)第二个问题就是性能问题,proxy的性能其实比promise还差,
        这就需要在性能和简单实用上进行权衡,例如vue3使用proxy后,
        其对对象及数组的拦截很容易实现数据的响应式,尤其是数组
        
        虽然proxy有性能和兼容性处理,但是proxy作为新标准将受到浏览器厂商重点持续的性能优化,
        性能这块会逐步得到改善

Vue算法及模型比较

之前在北森面试的时候,被问到Vue的模型建立及他们是怎么实现的,问完之后真的是一脸懵,但是面试官说这里是必问的,当时也是没回答出来,以为面试要凉凉了,没想到后来又打电话,给出了几道算法题,后来查了一下,怎么实现,才知道面试官可能想问的是AST模型和diff算法,还说在Vue的官方文档上,我是真心没找到,在网上查找了好多资料,在这里总结一下

AST模型

AST就是抽象语法树,他是js代码另一种结构映射,可以将js拆解成AST,也可以把AST转成源代码。这中间的过程就是我们的用武之地。 利用 抽象语法树(AST) 可以对你的源代码进行修改、优化,甚至可以打造自己的编译工具。其实有点类似babel的功能。

AST也是一种数据结构,这种数据结构其实就是一个大的json对象,json我们都熟悉,他就像一颗枝繁叶茂的树。

[
    {
        "name": 12,
        "children": [
            {
                "name": 4,
                "children": [
                    {
                        "name": 1,
                        "children": [
                            {
                                "name": 0
                            },
                            {
                                "name": 2
                            }
                        ]
                    },
                    {
                        "name": 8,
                        "children": [
                            {
                                "name": 7
                            },
                            {
                                "name": 9
                            }
                        ]
                    }
                ]
            },
            {
                "name": 18,
                "children": [
                    {
                        "name": 16,
                       "children": [
                            { 
                                "name": 15
                            },
                            { 
                                "name": 17
                            }
                        ]
                    },
                    {
                        "name": 20,
                        "children": [
                            { 
                                "name": 19
                            },
                            { 
                                "name": 24
                            }
                        ]
                    }
                ]
            }
        ]
    }
]

大概就是这种形式的树状结构,对AST语法树进行深度优先和广度优先遍历

 // 深度遍历, 使用递归
        function getName(data) {
            const result = [];
            data.forEach(item => {
                const map = data => {
                    result.push(data.name);
                    data.children && data.children.forEach(child => map(child));
                }
                map(item);
            })
            return result.join(',');
        }
// 广度遍历, 创建一个执行队列, 当队列为空的时候则结束
        function getName2(data) {
            let result = [];
            let queue = data;
            while (queue.length > 0) {
                [...queue].forEach(child => {
                    queue.shift();
                    result.push(child.name);
                    child.children && (queue.push(...child.children));
                });
            }
            return result.join(',');
        }

Vue算法diff

Diff算法的作用是用来计算出Virtual DOM中被改变的部分,然后针对该部分进行原生DOM操作,而不用重新渲染整个页面,diff算法其实就是深度优先算法

算法策略

diff有三大策略:

    Tree Diff:
      是对树每一层进行遍历,找出不同  
    Component Diff
        是数据层面的差异比较
    Element Diff
        真实DOM渲染,结构差异的比较