前言
在上一篇文章中我们成功通过沙箱模式实现了对数据的代理,再通过发布订阅模式实现了数据的响应式,再结合我们前面所学的知识成功把我们的数据响应式系统应用到了 React 上。可能有细心的同学就会发现了我们的所谓的手写响应式状态库,其实就是 SolidJS 的数据响应式的实现原理。
在这里我们就可以总结出几点,首先是所谓数据响应式并不一定需要像 Vue 那样通过 Object.defineProperty 或者 Proxy 来实现,其次 Vue、Mobx、Solid 的数据响应式的实现都应用了发布订阅模式,使用发布订阅模式是为了实现依赖追踪,这就又佐证了我们前面所说的,依赖追踪的基础是发布订阅模式。
此外我们这里学习 SolidJS,并不会像学习 Vue 或者 React 那样全面而深入地学习每一个 API 的实现原理,我们学习的目的是,首先是理解 SolidJS 的数据响应式原理,其次则是它是怎么不通过虚拟DOM实现更新的,为我们后续 Vue Vapor 的全面学习打下基础。
再者绝大部分框架的实现也遵循八二原理,即核心实现只有 20% 的代码,其余 80% 的代码都是在处理各种性能问题和边界问题。所以我们只要学习了 SolidJS 的数据响应式原理和怎么不通过虚拟DOM实现更新这两块知识就基本理解了 SolidJS 的实现原理了。
我们学习 SolidJS 可以通过对比学习,从而加深我们对 Vue 和 React 的实现原理的理解。
createSignal 实现
其实我们在上一篇文章中已经基本实现了 createSignal 的原理,但为了更接近 SolidJS 源码结构,我们这里按源码结构重新实现一遍,代码如下:
javascript
// 全局订阅者中间变量
let Listener
function createSignal(value) {
const s = {
value,
observers: null,
};
const setter = (value) => {
return writeSignal(s, value)
};
// 返回 s 对象的读取和设置函数也就是 getter 和 setter
return [readSignal.bind(s), setter]
}
// getter 读取方法,在这里进行依赖收集,从发布订阅模式的角度来看这里就是订阅
function readSignal() {
if (Listener) {
if (!this.observers) {
this.observers = [Listener]
} else {
this.observers.push(Listener)
}
}
return this.value
}
// setter 设置方法,在这里进行依赖触发,从发布订阅模式的角度来看这里就是发布
function writeSignal(node, value,) {
node.value = value
if (node.observers && node.observers.length) {
for (let i = 0; i < node.observers.length; i += 1) {
const o = node.observers[i]
o.fn()
}
}
return value
}
// 创建依赖追踪的副作用函数
function createEffect(fn) {
Listener = { fn }
fn()
Listener = null
}
接着我们就可以测试了。
测试代码如下:
javascript
// 我们可以根据场景,通过解构随意命名通过 createSignal 创建返回的两个数组元素,这里我们把 getter 命名为 count,setter 命名为 setCount
const [count, setCount] = createSignal(0)
createEffect(() => {
console.log('计算结果:', count())
})
setCount(520)
我们可以根据场景,通过解构随意命名通过 createSignal 创建返回的两个数组元素,这里我们把 getter 命名为 count,setter 命名为 setCount。上述代码就是 SolidJS 的 createSignal 的基本实现原理。
为什么 signal 变量使用 count() 不能写成 count?
如果大家之前有关注过 SolidJS 的话,可能看过黄子毅大佬的这篇文章 精读《SolidJS》,其中有提到一个疑问 为什么 signal 变量使用 count() 不能写成 count?,那么通过我们上面 SolidJS 的 createSignal 方法的实现,我们可以知道因为 SolidJS 并没有采用 Object.defineProperty 或者 Proxy 来实现数据响应式,所以 signal 变量不能写成 count,而通过 Object.defineProperty 或者 Proxy 来实现的数据响应式,当通过 singal 变量来读取响应式数据的时候,本质是通过底层的 getter 来实现的,这个我们在前面对 Vue 和 Mobx 的数据响应式实现的原理的学习中是深有体会的,在 SolidJS 中通过 createSignal 函数返回数组中的两个元素,本质上就是 getter 和 setter,所以 signal 的变量需要使用 count() 来读取数据。至于 SolidJS 为什么不采用 Object.defineProperty 或者 Proxy 来实现数据响应式,这是因为 SolidJS 的设计原则还是希望像 React 那样更好地遵循单向数据流,我们通过前面的文章知道 Vue 通过 Object.defineProperty 或者 Proxy 来实现数据响应式是很容易打破单向数据流的限制的,虽然 Mobx 也是通过 Object.defineProperty 或者 Proxy 来实现数据响应式的,但 Mobx 是花了很大的力气来限制单向数据流被打破的,所以同样是通过 Object.defineProperty 或者 Proxy 来实现数据响应式的 Mobx 的源码是要比 Vue 的对应数据响应式部分的实现是要复杂的。而 SolidJS 在设计上就直接不采用 Object.defineProperty 或者 Proxy 来实现数据响应式,转而采用沙箱模式来实现数据的代理,代价就是使用习惯有所改变,即 signal 变量需要使用 count() 而不能写成 count,因为 count 的本质是一个 getter 函数而不是值本身,所以需要像函数那样执行,才能触发依赖的收集并返回数据。
细粒度响应式的实现
我们知道 React 是应用级的响应式,Vue2、Vue3 则是组件级的响应式,而 SolidJS 则跟 Vue1 相同是元素级的响应式。如果有了解过 Vue1 的实现原理的话,就很清楚所谓元素级的响应式实现原理了。简单来说就是每一个需要更新的元素都单独创建一个依赖跟踪的副作用,这样将来该元素所依赖的状态发生了改变,则该元素的副作用函数则重新执行。
而在 React 或者 Vue2、Vue3 中当有一个数据列表,其中一条数据发生改变了,那么需要重新生产新的虚拟DOM并对每一条数据进行差异对比,然后进行 DOM 的创建抑或更新抑或删除,即便只有一个元素的情况下也依然需要进行重新生成新的虚拟DOM和比对,很明显这样是很浪费性能的,只有一条数据发生了改变,那么我们应该只更新对应的 DOM 即可。
我们通过原生 JavaScript 创建一个按钮元素,并且把它挂载到 id 为 app 的根元素上,代码实现如下:
javascript
const [count, setCount] = createSignal(0)
const el = document.createElement('button')
el.textContent = count()
const root = document.getElementById('app')
root.appendChild(el)
现在我们希望点击按钮就更改按钮中的数字,所以我们应该要给按钮添加点击事件,并且更改按钮元素中的数字。
diff
const [count, setCount] = createSignal(0)
const el = document.createElement('button')
+ el.addEventListener('click', () => {
+ setCount(520)
+ el.textContent = count()
+ })
el.textContent = count()
const root = document.getElementById('app')
root.appendChild(el)
我们可以看到我们通过手动操作 DOM 的方式实现点击按钮后修改按钮元素中的数字。我们知道操作 DOM 这些底层操作应该交给框架自动帮我们干,而不应该我们自己家手动操作,所以我们希望点击按钮的时候,我们只需要执行 setCount(520) 即可,设置之后应该自动执行 el.textContent = count(),那么这个就是数据响应式框架需要做的事情。
那么根据我们上面实现的 createEffect 函数,我们很容易实现这个功能,代码实现如下:
diff
const [count, setCount] = createSignal(0)
const el = document.createElement('button')
el.addEventListener('click', () => {
setCount(520)
- el.textContent = count()
})
- el.textContent = count()
+ createEffect(() => {
+ el.textContent = count()
+ })
const root = document.getElementById('app')
root.appendChild(el)
实现结果如下:
通过上述代码实现,我们可以初步看清 SolidJS 的基本原理了,在上述代码中,我们并没有使用虚拟 DOM 技术,也实现了数据发生改变后视图进行了自动更新。如果使用虚拟 DOM 技术,我们的更新需要经历非常多繁琐的步骤,首先是创建虚拟 DOM,而且更新的时候也需要重新生成虚拟 DOM,然后还需要进行比对,而采用无虚拟 DOM 后,这些繁琐的步骤都不需要了。
小迭代:具体到通用的实现
我们上述代码的实现只是一个具体的实现,我们需要把改成一个通用的实现。
首先按钮相关的功能通常会写在一个组件中,所以我们把抽离成一个组件。
javascript
const App = () => {
const [count, setCount] = createSignal(0)
const el = document.createElement('button')
el.addEventListener('click', () => {
setCount(520)
})
createEffect(() => {
el.textContent = count()
})
return el
}
有了组件之后,我们需要把该组件挂载到一个目标元素下,通常是根元素,所以我们需要实现一个 render 函数。
javascript
function render(component, parent) {
parent.appendChild(component)
}
接着进行挂载,代码如下:
javascript
const root = document.getElementById('app')
render(App(), root)
此外组件 App 中通过 createEffect 实现更新,目前还是跟 App 组件中的功能强关联的,我们也需要把它进行封装变成一个弱关联,我们把这一动作行为命名 insert,所以我们就封装一个 insert 函数。
javascript
function insert(parent, accessor) {
createEffect(() => {
parent.textContent = accessor()
})
}
那么 App 组件中的调用就变成如下:
diff
const App = () => {
const [count, setCount] = createSignal(0)
const el = document.createElement('button')
el.addEventListener('click', () => {
setCount(520)
})
- createEffect(() => {
- el.textContent = count()
- })
+ insert(el, count)
return el
}
这样我们就把我们的代码结构进行了梳理,初步搭建了我们的 mini-SolidJS 的架构。我们可以看到相对于 Vue 和 React 的组件,SolidJS 的组件只是在初始化的时候执行一次,在更新的时候则不再执行,而 Vue 和 React 的组件则会在更新的时候重新执行。在 SolidJS 中组件的存在是为了组织你的代码,而不是其他。SolidJS 的组件更像 Vue3 中的 setup 方法,在整个生命周期只执行一次。
总结
通过本文的逐步推演,我们亲手实现了一个极简但五脏俱全的 SolidJS 风格响应式系统。回顾整个过程,可以提炼出几个关键认知:
-
响应式的本质是"发布-订阅"
无论 Vue 的
Proxy、React 的setState,还是 SolidJS 的createSignal,底层都离不开发布-订阅模式。依赖收集(订阅)与触发更新(发布)是响应式系统的核心骨架。SolidJS 选择用显式的getter/setter函数而非对象代理,恰恰证明了"响应式"不完全等同于"数据劫持",而是一种可自由实现的设计模式。 -
无虚拟 DOM 不等于放弃性能
SolidJS 通过
createEffect将更新粒度精确到具体的 DOM 节点(元素级响应式)。状态变化时,只有直接依赖该状态的副作用函数会重新运行,从而直接修改对应 DOM。这绕过了虚拟 DOM 的 diff 开销,尤其适合高频更新或大型列表的场景。Vue 的 Vapor 模式也正在探索类似思路,理解 SolidJS 是为后续学习 Vue Vapor 打下坚实基础。 -
组件只执行一次,代码组织更纯粹
SolidJS 的组件函数仅在初始化时执行一次,用于声明静态结构、绑定事件、创建响应式副作用。更新时组件不会重新运行,这避免了 React 中因组件重渲染而引发的心智负担(如
useCallback、useMemo等)。这种设计更接近"声明式 + 细粒度订阅"的范式,让开发者更关注数据流的变化而非组件的生命周期。 -
八二原理的实践印证
我们仅用数十行代码就实现了
createSignal、createEffect和基本的 DOM 插入更新,证明了框架核心逻辑并不复杂。真正的工程复杂度体现在边缘情况(如循环引用、异步批量更新、错误处理等)和跨平台适配。学框架时先抓住这 20% 的核心,就能快速理解其余 80% 的设计意图。 -
对比学习,融会贯通
- Vue :依靠
Proxy自动收集依赖,开发体验最接近普通变量,但 Proxy 无法 polyfill 到旧版浏览器。 - React:不可变数据 + 虚拟 DOM,通过 Hooks 定义依赖数组,手动控制粒度,适合大规模团队的统一范式。
- SolidJS:显式函数调用 + 编译时优化(后续可展开),兼顾直观的响应式与极致性能,但读写需要习惯函数语法。
- Vue :依靠
最后,学习 SolidJS 的意义不仅在于掌握一个新框架,更在于拓宽对"响应式"和"更新策略"的认知边界。当理解了不同框架的设计取舍,我们在实际项目中就能更从容地选择合适的技术方案,甚至自己设计出精简高效的状态管理工具。
我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。