Skip to content

本文主要是阅读《vue.js 设计与实现》一书所得。

副作用函数是指该函数的执行会影响其他函数的执行的函数。

🤔字面意思上可以理解这句话,但不知道为什么vue要提出这个概念,我的理解在vue实现中中读取了响应式数据的函数数,都需要在响应式数据变化时自动触发。跟产不产生副作用有什么关联呢?

响应式数据的基本实现

vue中实现响应式数据的基本思想是通过proxy构造目标对象的代理对象,从而拦截目标对象的读/写操作。在读操作时收集副作用函数,写操作时触发这些副作用函数,从而实现响应式。

在vue3中,存储这些副作用函数与目标对象之间的关联的数据结构是WeakMap,如下图所示:WeakMap的键是响应式数据,值是一个Map对象,这个Map对象的键是响应式数据的键,值是与该键相关联的副作用函数集。

画板

下面的代码是响应式数据的基本实现:

javascript
// 存储副作用函数的桶
const bucket = new WeakMap();

// 原始数据
const data = { text: 'Hello World', ok: true };
// 对原始的数据进行代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    track(target, key);
    return target[key];
  },
  // 拦截写入操作
  set(target, key, newVal) {
    // 设置属性值ß
    target[key] = newVal;
    trigger(target, key);
  }
})
// 追踪函数,用于在get拦截操作中把副作用函数收集到"桶"中
function track(target, key) {
  // 没有 activeEffect 则直接 return
  if (!activeEffect) return target[key];
  // 根据target从桶中取得 depsMap,也是一个 Map 类型:key --> effects
  let depsMap = bucket.get(target);
  // 如果不存在 depsMap,就新建一个 Map 并和 target 关联
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  // 根据 key 从 depsMap 中取得 deps, 也是一个 Set 类型
  // 里面存储着所有与当前 key 相关联的副作用函数: effects
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(activeEffect);
}

function trigger(target, key) {
  // 获得对应key的effect set
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  // 将副作用函数 effect 取出并执行
  effects && effects.forEach(effect => effect());
}

// 定义一个全局变量存储被注册的副作用函数
let activeEffect;

function effect(fn) {
  activeEffect = fn;
  fn();
}
  • ``
  • <font style="color:#585A5A;">effect():</font>
  • <font style="color:#585A5A;">bucket</font>
  • <font style="color:#585A5A;">track()</font><font style="color:#585A5A;">trigger()</font>

一些边界条件处理

effect 嵌套

computed 计算属性实现

计算属性 computed 的实现其实是通过对 effect 函数的再次包装。

我们思考下 computed 的作用和特点,computed 可以实现一些计算逻辑, 并在响应式数据变化后重新计算。

javascript
const a = ref(1)
const b = ref(2)

const sum = computed(()=>{
  return a + b 
})

这不就可以理解为是一个副作用函数,只是这个副作用函数返回了一个值 。

我们来看 computed 的代码实现:

typescript
// 本部分实现computed
// 计算属性实现,包括懒执行,effect嵌套问题解决

// 存储副作用函数的桶
const bucket: WeakMap<Data, Map<string, Set<EffectFn>>> = new WeakMap()

// 原始数据
type Data = {
  [key: string | symbol]: number
}
const data: Data = { foo: 1, bar: 1 }
// 对原始数据的代理
export const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
    return true
  }
})

function track(target: Data, key: string) {
  if (!activeEffect) return
  let depsMap = bucket.get(target)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)

  activeEffect.deps.push(deps)
}

function trigger(target: Data, key: string) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

  const effectsToRun: Set<EffectFn> = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => {
    // 如果存在调度器的话,交给调度器去执行副作用函数
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })

  // effects && effects.forEach(effectFn => effectFn())
}

// 用一个全局变量存储当前激活的 effect 函数
interface Fn {
  (): void
}
interface EffectFn {
  (): void;
  options: {
    scheduler?: (fn: Fn) => void,
    lazy?: boolean
  };
  deps: Set<EffectFn>[]
}
let activeEffect: EffectFn;
// effect 栈
const effectStack: EffectFn[] = []

//用来注册副作用函数的函数
export function effect(fn: () => void, options = {}) {
  const effectFn: EffectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    // 在调用副作用函数之前将当前副作用函数压栈
    effectStack.push(effectFn)
    const res = fn()
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
    return res
  }
  // 将options 挂载在
  effectFn.options = options
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  // 新增懒执行
  if (!effectFn.options.lazy) {
    effectFn()
  }
  return effectFn;
}

function cleanup(effectFn: EffectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

// 调度执行
const jobQueue: Set<Fn> = new Set()
const p = Promise.resolve()

let isFlushing = false
function flushJob() {
  if (isFlushing) return
  isFlushing = true
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    isFlushing = false
  })
}

// 计算属性实现,包括懒执行,effect嵌套问题解决
export function computed(getter: () => void) {

  // 缓存上一次计算的值
  let value: any;

  // dirty标志,用来标识是否需要重新计算值
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true,
    // 这里shceduler充当了钩子函数的作用
    scheduler() {
      // 闭包
      if (!dirty) {
        dirty = true
        // 这里做的是当计算属性中任意一个响应式数据变化时,就会触发computerd.value 绑定的副作用函数
        // 补充:这里为什么要手动触发?想想在响应式数据中是访问器属性setter中触发的,而计算属性没有setter的,所以需要手动触发
        trigger(res, 'value')
      }
    }
  })

  const res = {
    get value() {
      if (!dirty) {
        value = effectFn()
        dirty = false
      }
      // 这里其实相当于把计算属性也变成了一个响应式数据, 收集访问res.value的副作用函数
      track(res, 'value')
      return value
    }
  }

  return res
}

watch 监听属性实现

javascript
watch(obj,()=>{
  console.log('数据变化')
})
//
obj.foo++ // 数据变化

watch的实现就是利用effect的可调度性(scheduler选项)。以下watch的简单实现:

javascript
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
  effect(
    // 调用 traverse 递归地读取
    () => traverse(source),
    {
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        cb()
      }
    }
  )
}
// 遍历的读取对象上的所有属性
function traverse(value, seen = new Set()) {
  // 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
  seen.add(value)
  // 暂时不考虑数组等其他结构
  // 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
  for (const k in value) {
    traverse(value[k], seen)
  }

  return value
}

在vue中watch的实现还有一个特色,可以获取新值和旧值,这是怎么实现的哪?这需要充分利用effect函数的**懒执行(lazy选项)**的功能:

javascript
function watch(source, cb) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  // 定义旧值与新值
  let oldValue, newValue
  // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler() {
        // 在 scheduler 中重新执行副作用函数,得到的是新值
        newValue = effectFn()
        // 将旧值和新值作为回调函数的参数
        cb(newValue, oldValue)
        // 更新旧值,不然下一次会得到错误的旧值
        oldValue = newValue
      }
    }
  )
  // 这里第一次执行,手动调用副作用函数,拿到的值就是旧值
  oldValue = effectFn()
}

几个关键: