【Vue面试题】响应式原理和数据双向绑定

217 阅读7分钟

前言

首先,相当一部分人没有把响应式原理和双向数据绑定区分开。响应式原理双向数据绑定并不是完全相同的概念。响应式原理是Vue的一大特性,官网的描述数据模型仅仅是普通的JavaScript对象。而当你修改它们时,视图会进行更新。双向数据绑定是通过V-model指令来实现的。v-model指令可以用于在表单元素(如input、textarea等)和组件上创建双向数据绑定。

响应式原理

响应式简介

响应式原理是Vue的一大特性,官网的描述数据模型仅仅是普通的JavaScript对象。而当你修改它们时,视图会进行更新。这就是响应式。

MVVM模型

我们都知道MVVM是基于一下三个部分

  • 数据层(Model):应用的数据及业务逻辑
  • 视图层(View):应用的展示效果,各类UI组件
  • 业务逻辑层(ViewModel):框架封装的核心,它负责将数据与视图关联起来 我们需要知道这三个部分真正对应Vue中的哪些部分
  • 数据层(Model):指的是data部分
  • 视图层(View):指的是template部分
  • 业务逻辑层(ViewModel):指的是VUE实例 更重要的是知道VM如何讲Model和View连接。

VUE响应式组成

![[Pasted image 20230401201214.png]]

Observer

我们常说的Object.defineproperty()就是在这里进行的。 Observer会递归遍历对象的所有属性,对每个属性使用Object.defineProperty()方法进行重写,给每个属性添加getter和setter方法,当数据发生变化时setter方法会通知Dep(依赖)对象进行更新。同时,在getter方法中会进行依赖收集,即将观察者对象添加到当前属性的Dep中,当数据发生变化时,Dep对象就可以通知所有观察者进行更新操作了

Observer实现

class Observer {
  constructor(value) {
    this.value = value
    this.dep = new Dep() // 依赖收集器
    this.walk(value)
  }

  // 遍历对象的所有属性,将它们转化为getter/setter形式
  walk(obj) {
    Object.keys(obj).forEach(key => {
      defineReactive(obj, key, obj[key])
    })
  }
}

// 将对象的属性转化为getter/setter形式
function defineReactive(obj, key, val) {
  const dep = new Dep() // 依赖收集器
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      // 将观察者对象添加到属性的依赖中
      if (Dep.target) {
        dep.depend()
      }
      return val
    },
    set(newVal) {
      if (val === newVal) {
        return
      }
      val = newVal
      // 通知依赖进行更新
      dep.notify()
    }
  })
}

// 依赖收集器
class Dep {
  constructor() {
    this.subs = []
  }

  // 添加一个观察者对象
  addSub(sub) {
    this.subs.push(sub)
  }

  // 移除一个观察者对象
  removeSub(sub) {
    remove(this.subs, sub)
  }

  // 将当前的Dep与观察者对象关联
  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  // 通知所有观察者对象进行更新
  notify() {
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

// 移除数组中指定的元素
function remove(arr, item) {
  if (arr.length) {
    const index = arr.indexOf(item)
    if (index > -1) {
      return arr.splice(index, 1)
    }
  }
}

// 将观察者对象与Dep关联起来
Dep.target = null
const targetStack = []

function pushTarget(target) {
  targetStack.push(target)
  Dep.target = target
}

function popTarget() {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

Dep

Dep是发布订阅模式的实现,在set时将依赖收集到Dep的subs数组中,在get时更新依赖。不了解发布订阅模式的建议去看看有关JS手写发布订阅的文章。

Compile

compile函数也就是vue的编译过程,主要是将vue模板(template)转化为可执行的JavaScript代码。这个过程由Vue的编译器(compiler)完成。

编译器会将Vue模板解析成一颗抽象语法树(AST),然后通过AST进行静态分析,并且对一些静态节点进行标记,从而确定模板中需要进行动态数据绑定的部分。如{{value}}这样的插值文本就会在这时被解析标记。

接着,编译器会根据AST生成渲染函数,这个渲染函数是由render函数和静态节点数组组成的。在执行渲染函数时,Vue会根据渲染函数中的数据和静态节点数组生成虚拟DOM,并将虚拟DOM渲染成真实DOM。

最后,编译器会将生成的渲染函数和一些必要的工具函数封装在一个JavaScript模块中,并返回这个模块的引用。

总结来说,Vue的编译过程将模板转化为可执行的JavaScript代码,其中包括将模板解析成抽象语法树、进行静态分析、生成渲染函数和静态节点数组,并最终将它们封装在一个JavaScript模块中。这个过程主要是为了提高Vue的性能和开发效率,使得Vue能够更加高效地处理数据绑定和DOM渲染。

Compile实现

真正的Compile函数很复杂,这里只简单写个开头

class Compile {  
  constructor(el, vm) {  
    this.$vm = vm;  
    this.$el = document.querySelector(el);  // 获取dom  
    if (this.$el) {  
      this.compile(this.$el);  
    }  
  }  
  compile(el) {  
    const childNodes = el.childNodes;   
    Array.from(childNodes).forEach((node) => { // 遍历子元素  
      if (this.isElement(node)) {   // 判断是否为节点  
        console.log("编译元素" + node.nodeName);  
      } else if (this.isInterpolation(node)) {  
        console.log("编译插值⽂本" + node.textContent);  // 判断是否为插值文本 {{}}  
      }  
      if (node.childNodes && node.childNodes.length > 0) {  // 判断是否有子元素  
        this.compile(node);  // 对子元素进行递归遍历  
      }  
    });  
  }  
  isElement(node) {  
    return node.nodeType == 1;  
  }  
  isInterpolation(node) {  
    return node.nodeType == 3 && /\{\{(.*)\}\}/.test(node.textContent);  
  }  
}  
  

Watcher

Watcher类是用来监听数据变化并执行相应回调函数的一个核心类。Watcher实例会将自己添加到响应式数据的依赖列表中,并在数据变化时执行更新操作。同时,Watcher实例还支持异步更新和其他一些配置选项,从而满足不同场景的需求。

watcher实现

class Watcher {
  constructor(data, key, cb) {
    this.data = data; // 监听的数据对象
    this.key = key; // 监听的数据属性名
    this.cb = cb; // 数据变化时执行的回调函数

    // 在这里立即调用一次get方法,可以将Watcher实例添加到Dep类中
    // 从而在数据发生变化时能够及时通知到Watcher实例
    this.value = this.get();
  }

  // 获取监听的数据属性值,并将当前Watcher实例添加到Dep类中
  get() {
    Dep.target = this; // 设置Dep.target静态属性为当前Watcher实例
    const value = this.data[this.key]; // 获取监听的数据属性值
    Dep.target = null; // 获取完成后将Dep.target静态属性清空
    return value;
  }

  // 数据变化时触发的更新操作
  update() {
    const oldValue = this.value;
    const newValue = this.data[this.key];
    if (oldValue !== newValue) {
      this.value = newValue;
      this.cb(newValue, oldValue); // 执行回调函数
    }
  }
}

// Dep类用于管理所有的Watcher实例
class Dep {
  constructor() {
    this.subs = [];
  }

  // 添加一个Watcher实例
  addSub(sub) {
    this.subs.push(sub);
  }

  // 通知所有的Watcher实例进行更新
  notify() {
    this.subs.forEach(sub => {
      sub.update();
    });
  }
}

// 在getter方法中添加依赖
function defineReactive(obj, key, value) {
  const dep = new Dep();

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      if (Dep.target) {
        dep.addSub(Dep.target);
      }
      return value;
    },
    set(newValue) {
      if (value !== newValue) {
        value = newValue;
        dep.notify(); // 数据变化时通知所有的Watcher实例进行更新
      }
    }
  });
}

// 用于初始化Watcher实例并触发依赖添加过程
function observe(data) {
  if (!data || typeof data !== 'object') {
    return;
  }

  Object.keys(data).forEach(key => {
    defineReactive(data, key, data[key]);
  });
}

// 示例代码
const data = { count: 0 };
observe(data);
new Watcher(data, 'count', (newValue, oldValue) => {
  console.log(`count changed from ${oldValue} to ${newValue}`);
});

data.count = 1; // count changed from 0 to 1
data.count = 2; // count changed from 1 to 2

理解Observer、Dep、Watcher 这篇文章对Watcher的理解就很不错,Watcher是实现响应式的核心类,理解它的实现与作用很重要。 每个组件至少都有一个watcher它的回调用来通知执行render函数,watcher的第三个参数还可以用来执行用户自定义的操作如watch和computed中的代码。

数据双向绑定

双向数据绑定是通过V-model指令来实现的。v-model指令可以用于在表单元素(如input、textarea等)和组件上创建双向数据绑定。

实现原理

  1. 对于<input>元素,v-model会在其上添加一个value属性和一个input事件监听器,用于实现双向绑定。
  2. 对于<textarea>元素,v-model会在其上添加一个value属性和一个input事件监听器,用于实现双向绑定。
  3. 对于<select>元素,v-model会将其value绑定到选中的<option>元素的value上,并添加一个change事件监听器,用于实现双向绑定。

v-model实现

Vue.directive('model', {
  bind: function (el, binding, vnode) {
    var value = binding.value;
    var vm = vnode.context;

    // 根据标签类型分别绑定不同的事件
    if (el.tagName === 'INPUT') {
      el.addEventListener('input', function () {
        vm[value] = el.value;
      });
    } else if (el.tagName === 'SELECT') {
      el.addEventListener('change', function () {
        vm[value] = el.value;
      });
    } else if (el.tagName === 'TEXTAREA') {
      el.addEventListener('input', function () {
        vm[value] = el.value;
      });
    }

    // 在元素上存储指令相关信息,便于后续更新
    el._vModel = {
      value: value,
      callback: function (value) {
        if (el.tagName === 'INPUT') {
          el.value = value;
        } else if (el.tagName === 'SELECT') {
          // 根据value值选中对应的选项
          var options = el.options;
          for (var i = 0, l = options.length; i < l; i++) {
            var option = options[i];
            if (option.value === value) {
              option.selected = true;
              break;
            }
          }
        } else if (el.tagName === 'TEXTAREA') {
          el.value = value;
        }
      }
    };
  },

  update: function (el, binding) {
    var value = binding.value;
    el._vModel.callback(value);
  },

  unbind: function (el) {
    // 移除事件监听和相关信息
    var info = el._vModel;
    if (el.tagName === 'INPUT') {
      el.removeEventListener('input', info.callback);
    } else if (el.tagName === 'SELECT') {
      el.removeEventListener('change', info.callback);
    } else if (el.tagName === 'TEXTAREA') {
      el.removeEventListener('input', info.callback);
    }
    delete el._vModel;
  }
});