第1篇(Ref):搞定 Vue3 Reactivity 响应式源码

一步步,用 Proxy + 发布订阅 模式,手写 vue3/core/reactivity

实现 ref

源码中的 Ref 是一个工厂函数:

js 复制代码
export function ref(value) {
    return RefImpl(value)
}

具体的实现在RefImpl里,它有几个特点:

  • value 是 普通值(Primitive) 和 对象(Object) 时,实现有所不同,因为对象有许多属性,有时要单独订阅对某些属性,value 是对象时交给 reactivity 处理
  • 在访问(get )时"收集依赖",在赋值(set)时"触发依赖"
  • 通过 RefImpl.value 访问值
js 复制代码
class RefImpl {
    privite _value
    
    constructor() {
        // 暂时忽略 Object 情况
        this._value = value
    }
    
    // trackRef 和 triggerRef 的实现放在后面
    get value() {
        trackRef(this)
        return _value
    }
    set value(newValue) {
        this._value = newValue
        triggerRef(this)
    }
}

这里要引入一些概念:

  • refDep,即被订阅者依赖
  • effectSub,即订阅者观察者

代码中把这两者抽象成了接口(interface),即 ref 实现了 Depeffect 实现了 Sub; 而在一次重构中,又使用双向链表实现了他俩,所以接口长这样:

js 复制代码
interface Sub {
    // 头
    subs: Link
    // 尾
    subsTail: Link
}

interface Dep {
    // 头
    deps: Link
    // 尾
    depsTail: Link
}

// 链表中 `链` 的抽象
// 直接连接 Dep 和 Sub
export interface Link {
  sub: Sub
  nextSub: Link | undefined
  prevSub: Link | undefined
  dep: Dep
  nextDep: Link | undefined
}

然后,先实现一下 effect,再实现 trackReftriggerRef; 在创建 effect 时,要先执行一遍函数,收集依赖,然后返回一个可以手动执行函数的 handler runner

js 复制代码
function effect(fn) {
    const e = new ReactiveEffect(fn)
    e.run()
    
    const runner = () => e.run()
    runner.effect = e
    
    return runner
}

这里同样是工厂函数,好处是分层解耦,代码很灵活; 接下来实现 ReactiveEffect,有个问题:到底 Dep 要被哪个 Sub 收集?

答案是 当前运行的 Sub,我们用一个变量记录:

js 复制代码
export let activeSub;

export function setActiveSub(sub) {
    activeSub = sub
}


class ReactiveEffect implement Sub {
    deps: Link
    depsTail: Link
    // effect 有停止机制,即停止收集依赖
    active = true
    // 标记,防止递归依赖
    tracking = false

    constructor(public fn) {}
    
    run() {
        // 停止后,不再收集依赖
        if (!this.active) return this.fn()
        
        // effect 递归触发,要用闭包保存上一个
        let prevSub = activeSub
        activeSub = this
        
        startTrack(this)
        try {
            return this.fn()
        } finally {
            endTrack(this)
            activeSub = prevSub
        }
    }
}

这里的 stratTrackendTrack 是为了在收集依赖时,复用实例&垃圾收集(删除不再依赖的项目),先埋个坑; 下面实现 trackReftriggerRef:

js 复制代码
// 这里的 dep 就是 ref
export function trackRef(dep) {
    // 连接 Sub 和 Dep
    if (activeSub) {
        link(dep, activeSub)
    }
}

// 用来存储已经断开连接的 Link 实例,可以复用,避免重复创建 Link
let LinkPool: Link | undefined

export function link(dep, sub) {
    // 当前 sub 的最后一个 Dep
    const currentDep = sub.depsTail
    // 下一个 Dep
    const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
    // 尝试复用链表
    if (nextDep && nextDep === dep) {
        sub.depsTail = nextDep
        return
    }
    
    // 不能复用,创建新的 Link
    // 插入 sub 的 dep链表 中在当前 deps 链表中插入一个节点
    let newLink: Link
    if (LinkPool) {
        newLink = LinkPool
        LinkPool = LinkPool.nextDep
        newLink.nextDep = nextDep
        newLink.dep = dep
        newLink.sub = sub
    } else {
        newLink = {
            sub,
            nextSub: undefined,
            prevSub: undefined,
            dep,
            nextDep,
        }
    }
    
    // 插入 dep 的 sub链表 中
    // 连接到 subsTail 后面
    if (dep.subsTail) {
        dep.subsTail.nextSub = newLink
        newLink.prevSub = dep.subsTail
        dep.subsTail = newLink
    } else {
        // 还没有 sub,这是第一个
        dep.subs = newLink
        dep.subsTail = newLink
    }
    
    // 建立 deps 单向链表关系
    if (sub.depsTail) {
        sub.depsTail.nextDep = newLink
        sub.depsTail = newLink
    } else {
        sub.deps = newLink
        sub.depsTail = newLink
    }
}

上面有几个点注意:

  1. dep 是单链表,sub 链表是双链表,因为:dep 清理时只需要从某一点开始删除到末尾,而 sub 需要删除特定的节点;
  2. 贪心策略:尝试直接使用 sub.depsTail.nextDep
  3. 实例收集策略:使用 LinkPool 收集可以重用的 Link

然后是 triggerRef

js 复制代码
export function triggerRef(dep) {
    // 仅当有订阅者时执行
    if (dep.subs) {
        // 委托
        propagate(dep.subs)
    }
}

// 传播更新,收集将要触发的 effect,
export propagate(subs: Link) {
    let link = subs
    const queuedEffect = []
    while (link) {
        const sub = link.sub
        // 避免递归触发
        if (!sub.tracking) {
            queuedEffect.push(sub)
        }
        link = link.nextSub
    }
    
    // 触发所有 sub
    queuedEffect.forEach(effect => effect.notify())
}

最后再填坑 startTrackendTrack

js 复制代码
export function startTrack(sub: sub) {
    // 标记正在收集依赖
    sub.tracking = true
    sub.depsTail = undefined
}

开始追踪:

  1. tracking 标记当前 sub 正在收集依赖,避免循环依赖问题;
  2. sub.depsTail 设置为空,为后面清理依赖做准备
js 复制代码
export endTrack(sub: Sub) {
    sub.tracking = false
    const depsTail = sub.depsTail
    
    // 重新收集依赖后,depsTail 会指向新的最后一个依赖
    if (depsTail && depsTail.next) {
        // 部份清理,新的最后一个依赖后的,都是不再依赖的
        clearTracking(depsTail.nextDep)
        depsTail.nextDep = undefined
    } else if (sub.deps) {
        // depsTail 依旧是空,表示没有任何新的依赖
        clearTracking(sub.deps)
        sub.deps = undefined
    }
}

// 清理包括 link 在内的节点
export function clearTracking(link: Link) {
    while(link) {
        const { prevSub, nextSub, dep, nextDep } = link
        
        // 把 prevSub 接到 link.nextSub
        if (prevSub) {
            prevSub.nextSub = link.nextSub
            link.nextSub = undefined
        } else {
            dep.subsTail = prevSub
        }
        
        // 把 nextSub.prevSub 接到 link.prevSub
        if (nextSub) {
            nextSub.prevSub = link.prevSub
            link.prevSub = undefined
        } else {
            dep.subsTail = prevSub
        }
        
        // 清空 dep/sub
        link.dep = link.sub = undefined
        // 存入复用池
        link.nextDep = LinkPool
        LinkPool = link
        
        link = nextDep
    }
}

几个需要注意的点:

  1. 增量更新:只清理不再需要的,保留仍需要的
  2. 内存优化:复用 Link 实例
  3. 双向链表:支持 O(1) 时间复杂度
  4. 精准清理:通过 depsTail 来精准定位需要清理的范围
相关推荐
葡萄城技术团队3 小时前
基于 SpreadJS 的百万级数据在线数据透视表解决方案:技术解析与实践
前端
爱隐身的官人3 小时前
XSS平台xssplatform搭建
前端·xss
jiangzhihao05153 小时前
升级到webpack5
前端·javascript·vue.js
哆啦A梦15883 小时前
36 注册
前端·javascript·html
华仔啊3 小时前
面试官:说说async/await?我差点翻车!原来还可以这么用
前端
菥菥爱嘻嘻4 小时前
输出---修改ant样式
前端·react.js·anti-design-vue
该用户已不存在4 小时前
这6个网站一旦知道就离不开了
前端·后端·github
Ai行者心易4 小时前
10天!前端用coze,后端用Trae IDE+Claude Code从0开始构建到平台上线
前端·后端
东东2334 小时前
前端开发中如何取消Promise操作
前端·javascript·promise