MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Vue数据响应式 视图自动更新的底层逻辑

2021-08-092.4k 阅读

Vue 数据响应式的基本概念

在 Vue 应用中,数据响应式是其核心特性之一。简单来说,当 Vue 实例的数据发生变化时,视图会自动更新以反映这些变化。这一特性极大地提升了开发效率,让开发者可以专注于数据逻辑,而不必手动操作 DOM 来更新视图。

举个简单的例子,我们创建一个 Vue 实例:

<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <title>Vue 数据响应式示例</title>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>

<body>
  <div id="app">
    <p>{{ message }}</p>
    <button @click="changeMessage">改变消息</button>
  </div>
  <script>
    new Vue({
      el: '#app',
      data: {
        message: '初始消息'
      },
      methods: {
        changeMessage: function () {
          this.message = '新的消息';
        }
      }
    });
  </script>
</body>

</html>

在上述代码中,我们定义了一个 Vue 实例,在 data 选项中定义了 message 数据。模板中通过 {{ message }} 来显示这个数据,同时有一个按钮,点击按钮调用 changeMessage 方法来改变 message 的值。当我们点击按钮时,视图会自动更新,显示新的消息内容。这就是 Vue 数据响应式的直观体现。

数据劫持与 Observer 模式

Vue 实现数据响应式的核心原理是数据劫持结合 Observer 模式。

数据劫持

数据劫持是指在访问或修改对象的属性时,进行拦截并执行额外的操作。在 JavaScript 中,ES5 提供了 Object.defineProperty() 方法,Vue 利用这个方法来实现数据劫持。

let obj = {};
let value = 123;
Object.defineProperty(obj, 'prop', {
  get() {
    console.log('获取属性值');
    return value;
  },
  set(newValue) {
    console.log('设置属性值');
    value = newValue;
  }
});
console.log(obj.prop); 
obj.prop = 456; 

在上述代码中,通过 Object.defineProperty() 方法为 obj 对象定义了 prop 属性,并重写了它的 getset 方法。当获取 prop 值时,会执行 get 方法中的逻辑,打印“获取属性值”;当设置 prop 值时,会执行 set 方法中的逻辑,打印“设置属性值”。

Vue 在初始化数据时,会遍历 data 对象的所有属性,并使用 Object.defineProperty() 将这些属性转换为“响应式”数据。这样,当这些属性被访问或修改时,Vue 就能进行相应的操作,比如通知依赖该数据的视图进行更新。

Observer 模式

Observer 模式也被称为发布 - 订阅模式。在 Vue 中,被劫持的数据就是“发布者”,而依赖这些数据的视图就是“订阅者”。当数据发生变化时,“发布者”会通知所有的“订阅者”进行更新。

在 Vue 的实现中,每一个响应式数据都对应一个 Dep(依赖)对象。Dep 对象负责收集依赖(即订阅者),当数据变化时,它会通知所有依赖进行更新。

class Dep {
  constructor() {
    this.subscribers = [];
  }
  addSubscriber(subscriber) {
    if (subscriber && typeof subscriber.update === 'function') {
      this.subscribers.push(subscriber);
    }
  }
  notify() {
    this.subscribers.forEach(subscriber => subscriber.update());
  }
}
class Subscriber {
  constructor(name) {
    this.name = name;
  }
  update() {
    console.log(`${this.name} 收到更新通知`);
  }
}
let dep = new Dep();
let subscriber1 = new Subscriber('订阅者1');
let subscriber2 = new Subscriber('订阅者2');
dep.addSubscriber(subscriber1);
dep.addSubscriber(subscriber2);
dep.notify(); 

在上述代码中,Dep 类表示发布者,Subscriber 类表示订阅者。Dep 类通过 addSubscriber 方法收集订阅者,通过 notify 方法通知所有订阅者更新。这里简单模拟了 Vue 中数据变化通知视图更新的机制。

Vue 数据响应式的实现流程

初始化 Vue 实例

当我们创建一个 Vue 实例时,Vue 会执行一系列初始化操作。其中,对 data 选项的处理是关键步骤之一。

function Vue(options) {
  this.$options = options;
  let data = this._data = this.$options.data;
  observe(data);
}
function observe(data) {
  if (!data || typeof data!== 'object') {
    return;
  }
  Object.keys(data).forEach(key => {
    defineReactive(data, key, data[key]);
  });
}
function defineReactive(obj, key, value) {
  let dep = new Dep();
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) {
        dep.addSubscriber(Dep.target);
      }
      return value;
    },
    set(newValue) {
      if (newValue!== value) {
        value = newValue;
        dep.notify();
      }
    }
  });
}
Dep.target = null;

在上述代码中,Vue 函数是 Vue 实例的构造函数。在构造函数中,首先获取 data 选项,然后调用 observe 函数。observe 函数会遍历 data 对象的所有属性,并调用 defineReactive 函数将每个属性转换为响应式数据。

defineReactive 函数内部创建了一个 Dep 对象,并通过 Object.defineProperty() 方法定义属性的 getset 方法。在 get 方法中,如果 Dep.target 存在(Dep.target 指向当前正在渲染的 watcher,后面会详细介绍),则将 Dep.target 添加为依赖;在 set 方法中,如果新值与旧值不同,则更新值并通知所有依赖进行更新。

依赖收集

在 Vue 中,依赖收集发生在视图渲染过程中。当 Vue 实例首次渲染视图时,会读取模板中使用到的数据。在读取数据的过程中,get 方法会被触发,从而将当前正在渲染的 watcher 添加到 Dep 中。

我们来看一个简单的例子:

<div id="app">
  <p>{{ message }}</p>
</div>
<script>
  new Vue({
    el: '#app',
    data: {
      message: '初始消息'
    }
  });
</script>

在这个例子中,当 Vue 实例渲染模板时,会读取 message 属性。此时,message 属性的 get 方法会被触发,由于 Dep.target 此时指向当前渲染的 watcher,message 对应的 Dep 对象就会将这个 watcher 添加为依赖。这样,当 message 数据发生变化时,这个 watcher 就会收到通知并更新视图。

视图更新

当响应式数据发生变化时,set 方法会被触发。在 set 方法中,会调用 dep.notify() 方法通知所有依赖进行更新。

// 假设已经有一个 Vue 实例 vm
vm.message = '新的消息'; 

当执行 vm.message = '新的消息'; 时,message 属性的 set 方法被触发。在 set 方法中,首先判断新值与旧值不同,然后更新 value,接着调用 dep.notify()dep.notify() 会遍历 subscribers 数组,调用每个 watcher 的 update 方法。watcher 的 update 方法会重新渲染视图,从而实现视图的自动更新。

Watcher 与虚拟 DOM

Watcher 的作用

Watcher 是 Vue 中一个非常重要的概念。它负责收集依赖、触发视图更新。每个 Vue 实例在渲染视图时,都会创建一个 watcher。这个 watcher 会在视图渲染过程中,将自己添加到所依赖数据的 Dep 中。

我们来看一个简化的 Watcher 类实现:

class Watcher {
  constructor(vm, expOrFn, cb) {
    this.vm = vm;
    this.getter = parsePath(expOrFn);
    this.cb = cb;
    this.value = this.get();
  }
  get() {
    Dep.target = this;
    let value = this.getter.call(this.vm, this.vm);
    Dep.target = null;
    return value;
  }
  update() {
    let oldValue = this.value;
    this.value = this.get();
    this.cb.call(this.vm, this.value, oldValue);
  }
}
function parsePath(path) {
  if (/[.$]/.test(path)) {
    let segments = path.split('.');
    return function (obj) {
      for (let i = 0; i < segments.length; i++) {
        if (!obj) return;
        obj = obj[segments[i]];
      }
      return obj;
    }
  } else {
    return function (obj) {
      return obj && obj[path];
    }
  }
}

在上述代码中,Watcher 类的构造函数接收 vm(Vue 实例)、expOrFn(要观察的数据路径或函数)和 cb(回调函数)。在 get 方法中,首先将 Dep.target 设置为当前 watcher,然后调用 getter 函数获取数据值,在获取数据值的过程中会触发数据的 get 方法,从而完成依赖收集。最后将 Dep.target 设为 null

update 方法在数据变化时被调用,它会重新获取数据值,并调用回调函数 cb,这个回调函数通常会触发视图更新。

虚拟 DOM

虚拟 DOM 是 Vue 实现高效更新视图的关键技术之一。简单来说,虚拟 DOM 是真实 DOM 的一种抽象表示,它是一个轻量级的 JavaScript 对象。

当 Vue 实例的数据发生变化时,Vue 并不会直接操作真实 DOM 来更新视图。而是先通过虚拟 DOM 算法,计算出变化前后虚拟 DOM 的差异,然后只对真实 DOM 中变化的部分进行更新。

我们来看一个简单的虚拟 DOM 示例:

// 创建虚拟 DOM
function createElement(tag, props, children) {
  return {
    tag,
    props,
    children
  };
}
// 渲染虚拟 DOM 到真实 DOM
function render(vnode) {
  let el = document.createElement(vnode.tag);
  if (vnode.props) {
    Object.keys(vnode.props).forEach(key => {
      el.setAttribute(key, vnode.props[key]);
    });
  }
  vnode.children.forEach(child => {
    let childEl = typeof child === 'object'? render(child) : document.createTextNode(child);
    el.appendChild(childEl);
  });
  return el;
}
// 比较两个虚拟 DOM 的差异
function diff(oldVnode, newVnode) {
  let patches = {};
  let index = 0;
  dfsWalk(oldVnode, newVnode, index, patches);
  return patches;
}
function dfsWalk(oldVnode, newVnode, index, patches) {
  let currentPatch = [];
  if (!newVnode) {
    return;
  }
  if (typeof oldVnode === 'string' && typeof newVnode === 'string') {
    if (oldVnode!== newVnode) {
      currentPatch.push({ type: 'TEXT', content: newVnode });
    }
  } else if (oldVnode.tag === newVnode.tag) {
    let propsPatches = diffProps(oldVnode.props, newVnode.props);
    if (propsPatches) {
      currentPatch.push({ type: 'PROPS', props: propsPatches });
    }
    diffChildren(oldVnode.children, newVnode.children, index, patches);
  } else {
    currentPatch.push({ type: 'REPLACE', newNode: newVnode });
  }
  if (currentPatch.length > 0) {
    patches[index] = currentPatch;
  }
}
function diffProps(oldProps, newProps) {
  let changeProps = {};
  Object.keys(oldProps).forEach(key => {
    if (oldProps[key]!== newProps[key]) {
      changeProps[key] = newProps[key];
    }
  });
  Object.keys(newProps).forEach(key => {
    if (!oldProps.hasOwnProperty(key)) {
      changeProps[key] = newProps[key];
    }
  });
  return Object.keys(changeProps).length > 0? changeProps : null;
}
function diffChildren(oldChildren, newChildren, index, patches) {
  oldChildren.forEach((child, i) => {
    dfsWalk(child, newChildren[i], index + i, patches);
  });
}
// 应用差异到真实 DOM
function patch(el, patches) {
  Object.keys(patches).forEach(key => {
    let currentPatches = patches[key];
    currentPatches.forEach(patch => {
      switch (patch.type) {
        case 'TEXT':
          el.textContent = patch.content;
          break;
        case 'PROPS':
          let props = patch.props;
          Object.keys(props).forEach(key => {
            el.setAttribute(key, props[key]);
          });
          break;
        case 'REPLACE':
          let newEl = render(patch.newNode);
          el.parentNode.replaceChild(newEl, el);
          el = newEl;
          break;
      }
    });
  });
  return el;
}
// 使用示例
let oldVnode = createElement('div', { id: 'app' }, [
  createElement('p', null, '旧的文本')
]);
let newVnode = createElement('div', { id: 'app' }, [
  createElement('p', null, '新的文本')
]);
let el = render(oldVnode);
document.body.appendChild(el);
let patches = diff(oldVnode, newVnode);
patch(el, patches);

在上述代码中,createElement 函数用于创建虚拟 DOM 节点。render 函数将虚拟 DOM 渲染为真实 DOM。diff 函数比较两个虚拟 DOM 的差异,并返回差异对象 patchespatch 函数将差异应用到真实 DOM 上,从而实现高效的视图更新。

数组的响应式处理

在 Vue 中,数组的响应式处理与对象略有不同。由于 JavaScript 无法直接对数组的索引赋值和长度变化进行拦截(通过 Object.defineProperty() 方法),Vue 采用了一种巧妙的方法来实现数组的响应式。

Vue 对数组的原型方法进行了拦截。它创建了一个新的数组构造函数 observeArray,这个构造函数继承自 Array.prototype,并对一些会改变数组自身的方法(如 pushpopshiftunshiftsplicesortreverse)进行了重写。

const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);
const methodsToPatch = [
  'push',
  'pop',
 'shift',
  'unshift',
 'splice',
 'sort',
 'reverse'
];
methodsToPatch.forEach(method => {
  const original = arrayProto[method];
  Object.defineProperty(arrayMethods, method, {
    value: function mutator(...args) {
      const result = original.apply(this, args);
      const ob = this.__ob__;
      let inserted;
      switch (method) {
        case 'push':
        case 'unshift':
          inserted = args;
          break;
        case'splice':
          inserted = args.slice(2);
          break;
      }
      if (inserted) ob.observeArray(inserted);
      ob.dep.notify();
      return result;
    },
    enumerable: false,
    writable: true,
    configurable: true
  });
});
function observeArray(items) {
  items.forEach(item => observe(item));
}

在上述代码中,arrayMethods 继承自 Array.prototype,并对 methodsToPatch 中的方法进行了重写。在重写的方法中,首先调用原始的数组方法获取结果,然后判断是否有新插入的元素(如果有,则对新插入的元素进行响应式处理),最后通知依赖进行更新。

当我们在 Vue 实例的 data 中定义数组时,Vue 会将这个数组的原型替换为 arrayMethods。这样,当调用数组的这些方法时,就会触发依赖更新,从而实现数组的响应式。

<div id="app">
  <ul>
    <li v-for="(item, index) in list" :key="index">{{ item }}</li>
  </ul>
  <button @click="addItem">添加项目</button>
</div>
<script>
  new Vue({
    el: '#app',
    data: {
      list: ['项目1', '项目2']
    },
    methods: {
      addItem: function () {
        this.list.push('新项目');
      }
    }
  });
</script>

在这个例子中,当我们点击按钮调用 addItem 方法时,list 数组调用 push 方法。由于 push 方法被重写,它会通知依赖进行更新,视图中的列表就会自动添加新的项目。

深度响应式与递归处理

在 Vue 中,对于对象嵌套多层的情况,Vue 会进行深度响应式处理。当我们定义一个包含多层嵌套对象的 data 时,Vue 会递归地将每一层的属性都转换为响应式数据。

let data = {
  a: {
    b: {
      c: 123
    }
  }
};
function observe(data) {
  if (!data || typeof data!== 'object') {
    return;
  }
  Object.keys(data).forEach(key => {
    defineReactive(data, key, data[key]);
  });
}
function defineReactive(obj, key, value) {
  observe(value);
  let dep = new Dep();
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) {
        dep.addSubscriber(Dep.target);
      }
      return value;
    },
    set(newValue) {
      if (newValue!== value) {
        value = newValue;
        dep.notify();
      }
    }
  });
}
observe(data);

在上述代码中,observe 函数会递归调用自身,对 value(如果是对象)进行处理,从而确保每一层的属性都是响应式的。这样,当我们修改深层嵌套对象的属性时,视图也能自动更新。

// 修改深层嵌套对象的属性
data.a.b.c = 456; 

当执行上述代码时,由于 c 属性是响应式的,其 set 方法会被触发,进而通知依赖更新,视图也会相应更新。

总结

Vue 的数据响应式和视图自动更新机制是其强大功能的核心。通过数据劫持、Observer 模式、Watcher、虚拟 DOM 等技术的结合,Vue 实现了高效、简洁的响应式编程模型。了解这些底层逻辑,不仅有助于我们更好地使用 Vue 进行开发,还能在遇到性能问题或复杂业务逻辑时,更加得心应手地进行调试和优化。无论是对于前端初学者深入理解框架原理,还是对于有经验的开发者优化项目性能,掌握 Vue 数据响应式的底层逻辑都具有重要意义。