前端响应式系统的心路历程

本文最后更新于:2025年6月26日 晚上

前端响应式系统的心路历程

原生JS到底在写什么?

首先探究一下响应式系统的直接动机。

我们在写JS代码的时候,主要就是做三件事:

// 1. 获取数据(通过网络)
data = getData();

// 2. 对数据进行处理(业务逻辑)
handleData();

// 3. 渲染数据到视图(DOM操作)
showData();

其中获取数据只需一次,数据处理和渲染需要多次,即数据每执行一种handleData()就需要执行showData()。那就需要我们在多个数据处理逻辑中反复调用同一套渲染逻辑。

既然数据处理和渲染步骤这么繁琐,那我们能否在操作数据(事件控制)的同时自动渲染视图?这就引出了框架响应式的初衷。

数据依赖

再分析一下数据处理和渲染的工作量

实际的代码编写量和我们的期望代码量:

1   getData          1 getData
N   handleData  ->   N handleData
N+1 showData         1 showData

那么如何消除N个的showData调用?由于N最初和handleData有关,所以需要决定有多少个handleData,于是问题变成了得到数据被更改的次数。

事实上,handleData就是对目标数据的操作,每次操作就会调用一次渲染函数,更深刻来说就是该渲染函数依赖于该数据。所以我们可以在源数据对象上做文章(使用Object.definePropertyset):

var data = { name: 'rok' }
var internalName = data.name

Object.defineProperty(data, 'name', {
    set: function(val) {
        internalName = val;
        // 执行:自动调用showData()来渲染视图
    }
})
// 当然vue3选择了Proxy来处理

这样,只要每次data有数据操作,就会在set中自动去重新渲染视图,没有多次编写调用showData代码的必要

自行设计的响应式系统

实际上,问题还没有完全解决,如果每次有一个数据都需要写一套Object.defineProperty依然十分繁琐。假如可以将Object.defineProperty这一套单独整理出来变成框架,就更加方便了

上述代码无法变成通用模版的原因是有一个不确定的函数,即showData,这里并不知道要调用哪个渲染函数。不过分析可知,showData也是依赖于目标数据的,并且其会触发数据的get。于是上面的代码可以演变成下面的通用模式:

function observe(obj) {
    for (const key in obj) {
        let internalValue = obj[key];
        Object.defineProperty(obj, key, {
            get: function() {
                // 依赖收集,记录:是哪个showData依赖于该数据
                return internalValue;
            },
            set: function(val) {
                internalValue = val;
                // 派发更新,执行:自动调用showData来渲染视图
            }
        })
        // 当然vue3选择了Proxy来处理
    }
}

实际上把注释位置替换成真实代码即:

function observe(obj) {
    for (const key in obj) {
        let internalValue = obj[key];
        let funcs = new Set()
        Object.defineProperty(obj, key, {
            get: function() {
                funcs.add(funcName); // 记录依赖函数(暂时无法得知函数名)
                return internalValue;
            },
            set: function(val) {
                internalValue = val;
                for (let i = 0; i < funcs.length; i++) {
                    funcs[i](); // 执行渲染
                }
            }
        })
    }
}

现在就差得到渲染函数的函数名了,实际上要解决很简单,就是在第一次调用渲染时给这个函数设置一个标记即可:

function autorun(fn) {
    window.__func = fn;
    fn();
    window.__func = null;
}
function observe(obj) {
    ...
    get: function() {
        funcs.add(window.__func)
        return internalValue;
    },
    ...
}

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!