Vue2的响应式原理解析,看了都懂的流程图

Vue 2响应式原理时序图与部分源码记录如下:

图很直观!

Vue 2 响应式原理时序图

官网对响应式的说明: v2.cn.vuejs.org/v2/guide/re...

当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter。Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。

这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在 property 被访问和修改时通知变更。这里需要注意的是不同浏览器在控制台打印数据对象时对 getter/setter 的格式> 化并不同,所以建议安装 vue-devtools 来获取对检查数据更加友好的用户界面。

每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把"接触"过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

核心点1: Object.defineProperty劫持属性

第一步是对JavaScript对象进行监听,监听使用到的api就是 Object.defineProperty,通过它来重写对象属性的getter和setter,如何重写的,使用到的函数就是observer

主要逻辑如下:

observer.js:

javascript 复制代码
// 将一个对象转换为响应式对象
function observe(obj) {
  if (!obj || typeof obj !== 'object') {
    return;
  }
  
  // 为每个对象创建一个Observer实例
  return new Observer(obj);
}

// Observer类,将对象的所有属性转换为getter/setter
class Observer {
  constructor(value) {
    this.value = value;
    
    if (Array.isArray(value)) {
      // 数组响应式处理...
    } else {
      this.walk(value);
    }
  }
  
  // 遍历对象所有属性并转换为getter/setter
  walk(obj) {
    const keys = Object.keys(obj);
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i]);
    }
  }
}

// 核心方法:将对象的属性转换为响应式属性
function defineReactive(obj, key) {
  // 为每个属性创建一个Dep实例
  const dep = new Dep();
  
  // 获取对象当前属性的值,闭包存储
  let val = obj[key];
  
  // 递归观察属性值(如果是对象)
  let childOb = observe(val);
  
  // 使用Object.defineProperty劫持属性
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    
    get: function reactiveGetter() {
      // 如果当前有正在评估的Watcher实例
      if (Dep.target) {
        // 收集依赖
        dep.depend();
      }
      return val;
    },
    
    set: function reactiveSetter(newVal) {
      if (newVal === val) return;
      val = newVal;
      // 如果新值是对象,继续观察它
      childOb = observe(newVal);
      dep.notify();
    }
  });
}

核心点2: 依赖收集

vue是什么的响应式的原理??

vue是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter和getter,在数据发生改变的时候,发布消息给订阅者,出发响应的监听回调

其中谁是发布者?谁是订阅者?

我觉得数据是发布者,使用数据的地方就是订阅者。但是单独的数据和引用数据不足以实现数据改变,订阅数据的地方监听到数据改变。这就需要维护一个数据和订阅者之间的关系列表。这个关系就是依赖收集器Dep,收集的就是订阅者Watcher

数据劫持已经由 observer.js 中的 defineReactive 完成了,接下来就是依赖收集了,其中核心类:dep.js

javascript 复制代码
/**
 * 依赖收集器类
 */
class Dep {
  constructor() {
    // 存储所有依赖(订阅者watcher)
    this.subs = [];
  }
  
  addSub(sub) {
    this.subs.push(sub);
  }
  
  removeSub(sub) {
    remove(this.subs, sub);
  }
  
  // 收集依赖重点函数!!
  depend() {
    if (Dep.target) {
      Dep.target.addDep(this);
    }
  }
  
  notify() {
    const subs = this.subs.slice();
    for (let i = 0; i < subs.length; i++) {
      subs[i].update();
    }
  }
}

// 当前正在评估的watcher
// 这是一个全局唯一的变量,同一时间只能有一个watcher被评估!!JavaScript是单线程执行的!
Dep.target = null;
const targetStack = [];

// 设置当前watcher
function pushTarget(target) {
  targetStack.push(target);
  Dep.target = target;
}

核心点3: Watcher与数据更新

在上面的依赖收集中,每一个属性里面都定义了一个dep,记录着每一个使用到该属性的watcher实例。watcher的作用是什么?? watcher的作用就是在数据发生变化的时候,通知所有的watcher实例进行更新。watcher是一个中间层,连接着数据和视图

那么它就需要两个功能:

  1. 监听数据变化
  2. 更新视图

妙,太妙了,主逻辑如下:

watcher.js

javascript 复制代码
/**
 * Watcher类是连接Observer和View的中间层
 */
class Watcher {
  constructor(vm, expOrFn, cb, options) {
    this.vm = vm;
    
    // 存储表达式或函数
    if (typeof expOrFn === 'function') {
      // render函数或计算属性的getter或者watch监听函数
      this.getter = expOrFn;
    } else {
      // 表达式解析为函数
      this.getter = parsePath(expOrFn);
    }
    
    this.cb = cb;
    this.deps = [];
    this.depIds = new Set(); // 用于防止依赖重复收集
    
    this.isRenderWatcher = options && options.isRenderWatcher;
    
    // 立即执行getter,触发依赖收集,注意如果是render函数,这个value是undefined,没什么作用。重点是get()的依赖收集
    this.value = this.get();
  }
  
  // 获取值并收集依赖
  get() {
    // 设置当前活动的Watcher
    pushTarget(this);
    let value;
    
    try {
      // 执行getter函数,触发响应式数据的getter
      value = this.getter.call(this.vm, this.vm);
    } finally {
      // 清除当前Watcher
      popTarget();
    }
    
    return value;
  }
  
  // 添加依赖
  addDep(dep) {
    // dep是Dep实例在每一个响应式属性中都有
    const id = dep.id;
    if (!this.depIds.has(id)) {
      this.depIds.add(id);
      this.deps.push(dep);
      dep.addSub(this);
    }
  }
  
  // 更新方法,被Dep调用
  update() {
    // 简单实现:直接执行run
    // 实际Vue中会将watcher推入队列,进行异步更新
    queueWatcher(this);
  }
  
  // 实际执行更新的方法
  run() {
    const oldValue = this.value;
    this.value = this.get();
    
    // 调用回调,如组件的更新函数、计算属性的setter或watch的回调
    if (this.cb) {
      this.cb.call(this.vm, this.value, oldValue);
    }
  }
}

到这儿的时候有一个强烈的疑惑:

  • this.value = this.get() 这行代码的作用是什么?value会是是什么东西?watcher有几个??

在文末会有额外的疑惑解决


先看到下面,然后来回滚动着看源码,马上就能看完,非常简单,非常妙啊

已经看这么多了,已经很棒了,晚上必加鸡腿🍗

核心点4: 异步更新队列(nextTick)

照着逻辑到了,数据更新的核心内容,在watcher中是如何更新的?为什么我更新完数据之后,如果直接去访问数据,拿到的还是旧值??

这就是nextTick的作用了,nextTick的作用就是将回调函数放入一个队列中,等到下一个事件循环的时候再执行这个回调函数。这样就可以避免多次更新造成的性能浪费

watcher中同样会去调用nextTick将更新推入异步更新队列,很好的解释了下面vue中常见的逻辑👇🏻:

javascript 复制代码
// vue里面的一个逻辑
  data() {
    return {
      msg: ''
    }
  },
  methods: {
    handleClick() {
      this.msg = 'Hello World';
      this.$nextTick(() => {
        console.log(this.$el.innerHTML); // 'Hello World'
      });
    }
  }

实现的核心逻辑如下:

javascript 复制代码
/**
 * 将回调函数缓存起来,一起执行
 */
function queueWatcher(watcher) {
  // ...
  nextTick(flushSchedulerQueue);
}

/**
 * 实际批量处理队列的函数
 */
function flushSchedulerQueue() {
  // ...
  // 依次执行watcher的run方法
  for (let i = 0; i < queue.length; i++) {
    const watcher = queue[i];
    watcher.run();
  }
}

/**
 * nextTick实现原理
 * 将回调添加到任务队列,在下一个tick执行
 */
const callbacks = [];
let pending = false;
function nextTick(cb, ctx) {
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx);
      } catch (e) {
        console.error(e);
      }
    }
  });
  
  // 如果还没有处理队列,则启动处理
  if (!pending) {
    pending = true;
    // 在下一个tick执行回调队列
    // vue2 优先使用Promise,其次是MutationObserver,然后是setImmediate,最后是setTimeout,浏览器兼容问题
    Promise.resolve().then(flushCallbacks);
  }
}

/**
 * 执行所有回调
 */
function flushCallbacks() {
  // 重置状态,为下一次回调执行做准备
  pending = false;
  const copies = callbacks.slice(0);
  callbacks.length = 0;
  
  for (let i = 0; i < copies.length; i++) {
    copies[i]();
  }
}

完整响应式流程

javascript 复制代码
// 示例:Vue实例初始化并响应数据变化
function initData(vm) {
  let data = vm.$options.data;
  
  data = vm._data = data.call(vm)
  
  const keys = Object.keys(data);
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i];
    proxy(vm, '_data', key);
  }
  // 响应式化数据
  observe(data);
}

function proxy(target, sourceKey, key) {
  Object.defineProperty(target, key, {
    get() {
      return target[sourceKey][key]
    },
    set(val) {
      target[sourceKey][key] = val
    }
  })
}

// 组件渲染时 创建 渲染Watcher
function mountComponent(vm, el) {
  // 更新组件模版内容的函数
  const updateComponent = () => {
    vm._update(vm._render());
  };
  
  // 创建 渲染Watcher
  new Watcher(
    vm, 
    updateComponent, 
    noop,
    { isRenderWatcher: true } // 标识是渲染的watcher
  );
  
  return vm;
}

// 使用示例
function egFn() {
  const vm = new Vue({
    data() {
      return {
        message: 'Hello'
      }
    },
    render(h) {
      // 在这里访问this.message会触发getter,完成依赖收集
      return h('div', this.message);
    }
  });
  
  // 挂载组件,创建渲染watcher并执行首次渲染
  vm.$mount('#app');
  
  // 修改数据,触发更新
  vm.message = 'Updated'; 
  // 这会触发setter -> dep.notify() -> watcher.update() -> queueWatcher -> nextTick -> flushSchedulerQueue -> watcher.run() -> 重新渲染
}

整体流程总结

  1. 初始化阶段
  • Vue示例创建时,通过observe方法将data选项转换为响应式对象
  • 通过Observer类使用Object.defineProperty 劫持每个属性,定义getter和setter
  • 每个属性都创建一个Dep实例用于依赖收集
  1. 依赖收集阶段
  • 组件渲染时创建Watcher实例
  • 渲染过程中访问响应式属性时触发getter
  • Dep.target设为当前Watcher,并通过dep.depend()收集依赖
  • 每个属性的dep记录那些Watcher依赖它
  1. 数据更新阶段
    • 当响应式数据被修改,触发setter
    • setter调用 dep.notify()通知所有依赖该数据的watcher
    • watcher被添加到异步更新队列(queueWatcher)
    • 通过nextTick在下一个事件循环周期统一更新Dom

ok👌,看完啦,非常nice


越学当然问题越多,下面是额外内容:

额外疑惑解决

Watcher部分代码

javascript 复制代码
class Watcher {
  constructor(vm, expOrFn, cb, options) {
    this.vm = vm;
    
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn;
    } else {
      // 如果是字符串表达式,会将其转换为函数
      this.getter = parsePath(expOrFn);  // 返回一个函数,用于从对象中获取属性值
    }
    
    // 立即求值并收集依赖
    this.value = this.get();
  }
}

this.value = this.get() 这行代码的作用是什么?value会是是什么东西?watcher有几个??

Watcher实例中的getter和value

this.value = this.get() 的作用

这行代码主要有两个时机执行:

  1. Watcher初始化时(构造函数中)
  2. 数据更新时(run方法中)

它的作用是:

  • 执行get()方法,触发getter进行依赖收集
  • 将获取到的值保存在this.value中,便于后续比较新旧值的变化
  • 对于渲染watcher,这一步会触发组件的渲染过程
this.getter 是什么?作用是什么

this.getter的来源是创建Watcher时传入的expOrFn参数。根据不同类型的Watcher,它可能是:

  1. 渲染Watcher: 就是组件的render函数,执行的时候会触发模版中的响应式数据的读取
javascript 复制代码
// 渲染Watcher的初始化
const updateComponent = () => {
  vm._update(vm._render());  // 生成VNode并更新DOM
};

// 此时传给Watcher的expOrFn就是updateComponent
new Watcher(vm, updateComponent, noop, { isRenderWatcher: true });

对于渲染watcher,this.getter就是组件的render函数,它会在组件渲染时被调用,触发getter,收集依赖。返回的值是undefined,因为render函数本身不返回值

  1. 计算属性Watcher:
javascript 复制代码
// 计算属性的getter函数
const getter = () => {
  return this.count * 2;  // 例如一个简单的computed属性
};

// 创建计算属性Watcher
new Watcher(vm, getter, noop, { lazy: true });

计算属性watcher,this.getter就是计算属性的getter函数,它会在计算属性被访问时被调用,触发getter,收集依赖,返回的值是计算属性的值

  1. 监听器Watcher:
javascript 复制代码
 watch: {
    // 这会隐式创建一个Watcher
    'user.name': function(newVal, oldVal) {
      console.log('用户名变更为:', newVal);
    }
  }

// 创建Watcher
new Watcher(vm, parsePath('user.name'), callback);

监听器同理,区别在于watch会有新旧两个数据,也就是在run()函数中获取的新值和记录的旧值

结语

又理解了一遍原理,记录下来,方便后续查阅

希望对你有所帮助

祝在前端开发的道路上越走越远,技术精进,问题迎刃而解!💪✨

相关推荐
GISHUB14 分钟前
mapbox开发小技巧
前端·mapbox
几度泥的菜花36 分钟前
使用jQuery实现动态下划线效果的导航栏
前端·javascript·jquery
思茂信息1 小时前
CST直角反射器 --- 距离多普勒(RD图), 毫米波汽车雷达ADAS
前端·人工智能·5g·汽车·无人机·软件工程
星星不打輰1 小时前
Vue入门常见指令
前端·javascript·vue.js
好_快1 小时前
Lodash源码阅读-isNative
前端·javascript·源码阅读
好_快2 小时前
Lodash源码阅读-reIsNative
前端·javascript·源码阅读
好_快2 小时前
Lodash源码阅读-baseIsNative
前端·javascript·源码阅读
好_快2 小时前
Lodash源码阅读-toSource
前端·javascript·源码阅读
Oneforlove_twoforjob2 小时前
volta node npm yarn下载安装
前端·npm·node.js
咖啡の猫2 小时前
npm与包
前端·npm·node.js