手写Vue2源码(八)—— 计算属性

1,145 阅读6分钟

前言

通过手写Vue2源码,更深入了解Vue;

在项目开发过程,一步一步实现Vue核心功能,我会将不同功能放到不同分支,方便查阅;

另外我会编写一些开发文档,阐述编码细节及实现思路;

源码地址:手写Vue2源码

计算属性的用法及功能

先看一下Vue中计算属性的用法及功能,然后我们一一实现这些功能。 用法:

new Vue({
    data() {
        return {
            firstName: 'wu',
            lastName: 'yanzu'
        }
    },
    template: `<div class="home" id="main" style="font-size:12px;color:red">我的名字是:{{fullName}}</div>`,
    computed: {
        fullName() {
            return this.firstName + this.lastName
        },
        fullName: {
            get() {
                return this.firstName + this.lastName
            },
            set(newValue) {
                var names = newValue.split()
                this.firstName = names[0]
                this.lastName = names[1] + names[2]
            }
        }
    }
})

computed特性:

  1. 可以同时设置多个计算属性
  2. 计算属性有两种写法:
    1. 第一种为函数,返回计算的值;
    2. 第二种为对象,里面有get和set两个函数,get函数返回计算的值,set用来更新依赖项;
  3. 模板中的计算属性没有定义在data中,但可以直接通过vm.xxx的方式获取
  4. 计算属性具有缓存功能,只有当依赖项发生改变时才重新计算
  5. 依赖项改变会触发重新计算,以及页面的重新渲染

根据这些特性,我们考虑一下实现思路:

  1. 使用Object.defineProperty()将computed中的计算属性直接代理到vm实例上
  2. 遍历计算属性创建计算属性watcher,对计算属性的依赖项收集相关计算属性watcher
    1. 当依赖项发生改变时通知计算属性watcher重新计算
    2. 当依赖项没有改变时,直接获取watcher.value(缓存的值)
  3. 依赖项收集完计算属性watcher后,还要收集渲染watcher,当依赖项发生改变时,通知渲染watcher更新视图

下面我们一步步实现这些功能。

计算属性初始化

// src/state.js
export function initState(vm) {
  const opts = vm.$options;
  // ...

  // 初始化computed
  if (opts.computed) {
    initComputed(vm);
  }

  // ...
}

// 初始化computed
function initComputed(vm) {
  const computed = vm.$options.computed
  const watchers = (vm._computedWatchers = {}) // 用watchers和vm._computedWatchers 存放 computed watcher

  // 遍历computed,每个计算属性创建一个watcher
  for (let k in computed) {
    const userDef = computed[k] // userDef可能是函数或对象(内部有get()、set()函数)
    // 获取计算属性的getter函数
    let getter = typeof userDef === 'function' ? userDef : userDef.get
    // 创建watcher;lazy: true 表示 computed watcher
    watchers[k] = new Watcher(vm, getter, () => {}, { lazy: true })
    // 将computed中的属性直接代理到vm下,并对计算属性依赖项进行依赖收集相关操作
    defineComputed(vm, k, userDef)
  }
}

简而言之,先遍历computed,对每个计算属性创建一个computed watcher,然后对计算属性进行代理及收集依赖操作 —— defineComputed(vm, k, userDef)

对计算属性进行代理

// src/state.js
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: () => {},
  set: () => {},
};
function defineComputed(vm, key, userDef) {
  if (typeof userDef === "function") {    
    sharedPropertyDefinition.get = createComputedGetter(key);
  } else {
    sharedPropertyDefinition.get = createComputedGetter(key);
    sharedPropertyDefinition.set = userDef.set;
  }
  // 将计算属性直接代理到vm实例上
  Object.defineProperty(vm, key, sharedPropertyDefinition);
}

代理的目的是为了能在vm上通过vm.xxx直接访问到计算属性。 代理的过程中需要进行依赖收集、计算属性缓存等操作,核心方法是 —— createComputedGetter(key)

缓存与依赖收集

对于computed属性的getter,我们需要进行缓存;另外依赖项需要收集计算属性watcher,当依赖项改变时,通知计算属性watcher重新计算;还需要收集渲染watcher,当依赖项改变时,更新视图。

// src/state.js
function createComputedGetter(key) {
  return function () {
    const watcher = this._computedWatchers[key];
    if(watcher) {
      // 根据dirty属性,判断是否需要重新计算(脏就是要调用getter,不脏就是直接取watcher.value)
      if (watcher.dirty) {
        // 执行Watcher.get()重新计算 计算属性 的值,触发依赖项的收集依赖;完成之后将watcher.dirty设置为false
        watcher.evaluate();

        // 如果还存在Dep.target,我们对依赖项收集当前的watcher(一般为渲染watcher)
        if (Dep.target) {
          watcher.depend()
        }
      }
      
      return watcher.value;
    }
  };
}

逻辑分析:

  1. 根据 watcher.dirty 判断是否有缓存,没缓存则:
    1. 重新计算 计算属性 的值,触发依赖项收集 computed watcher,完成之后将watcher.dirty设为false;当依赖项更新时,将dirty设置为true;
    2. 依赖项收集当前的渲染watcher (保证依赖项改变触发视图更新)
  2. 有缓存则直接获取缓存值 —— watcher.value

这里我们需要了解一个事情,就是同一时间可能同时存在两个watcher:

  1. 执行$mount -> mountComponent时,会创建一个渲染watcher(此时Dep.target为渲染watcher),将它推入一个栈中
  2. 解析到计算属性时,会创建一个computed watcher(此时Dep.target为 computed watcher),将它推入同一栈中
  3. 解析完计算属性后,将computed watcher 移除栈,此时Dep.target 又是 渲染watcher
  4. 整个模板渲染结束时,栈为空,Dep.target为null

所以我们需要对Dep改造一下:

// src/observer/dep

// 栈结构用来存众多watcher
const targetStack = [];
// Dep.target 为 dep 当前所对应的watcher(即栈顶的watcher),默认为null
Dep.target = null;

export function pushTarget(watcher) {
  targetStack.push(watcher);
  Dep.target = watcher; // Dep.target指向当前watcher
}

export function popTarget() {
  // targetStack可能同时存在多个watcher(比如渲染watcher处于栈底,上面有computed watcher)
  targetStack.pop(); // 当前watcher出栈 拿到上一个watcher
  Dep.target = targetStack[targetStack.length - 1];
}

针对computed watcher,我们还需要对watcher进行改造:

// src/observer/watcher.js
export default class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    
    this.lazy = !!options.lazy; // 表示是不是computed watcher
    this.dirty = this.lazy; // dirty可变,默认为true;表示计算watcher是否需要重新计算-执行用户定义的方法。

    // 当是渲染watcher 或 computed watcher时
    if (typeof exprOrFn === "function") {
      this.getter = exprOrFn;
    } else {
      // 当是用户自定义watcher时
      // ...
    }

    // 如果是计算属性watcher,则创建watcher的时候,什么都不执行(计算属性的getter经过了代理,获取计算属性时调用它的getter进行计算)
    this.value = this.lazy ? undefined : this.get();
  }

  get() {
    pushTarget(this);
    const res = this.getter.call(this.vm);
    popTarget();
    return res;
  }

  addDep(dep) {
    let id = dep.id;
    if (!this.depsId.has(id)) {
      this.depsId.add(id);
      this.deps.push(dep);
      dep.addSub(this);
    }
  }

  // 当计算属性的依赖项发生改变,会触发依赖项相关 watcher(一般依赖项会收集computed watcher和渲染watcher,所以下面if、else都会走) 的update方法
  update() {
    // 这里做个判断,如果是计算属性watcher,则将dirty设置成true,下次访问计算属性时就会重新计算(在computed代理中进行判断的)。
    if (this.lazy) {
      this.dirty = true;
    } else {
      // 每次watcher进行更新的时候,可以让他们先缓存起来,之后再一起调用
      // 异步队列机制
      queueWatcher(this);
    }
  }

  // 在计算属性的代理中,当dirty为true时会执行evaluate
  evaluate() {
    this.value = this.get();    // 计算新值,并对依赖项收集computed watcher
    this.dirty = false;
  }
  depend() {
    // 计算属性的watcher存储了依赖项的dep
    let i = this.deps.length;
    while (i--) {
      this.deps[i].depend(); // 调用依赖项的dep去收集渲染watcher
    }
  }

  run() {
    // 执行this.getter,更新视图/获取新值
    // ...
  }
}

两个核心方法:

  1. evaluate():执行watcher.get(),重新计算值,依赖项收集当前computed watcher;最后将dirty设为false
  2. depend():遍历当前computed watcher的deps(计算属性的依赖项),收集Dep.target(渲染watcher)

小结

  1. 计算属性不是定义在data中的,不会进行依赖收集,收集的计算属性的依赖项
  2. 计算属性需要进行代理,以便通过this可以直接访问到
  3. 每个计算属性创建一个 computed watcher
  4. 计算属性需要缓存
  5. 计算属性的依赖项需要收集相关的computed watcherwatcher.evaluate 方法),以及渲染watcher(watcher.depend 方法)。
  6. 同一时间可能会存在多个watcher(例如渲染watcher及computed watcher),用栈存储watchers,Dep.target指向当前watcher

--

系列文章