useState到底如何实现?Nerv Hooks源码原理详解(React双向链表的轻量替代)
【免费下载链接】nervA blazing fast React alternative, compatible with IE8 and React 16.项目地址: https://gitcode.com/NervJS/nerv
你用过 React 的 useState 吗?那你知道它背后藏着一条双向链表吗?Nerv 是一个闪电般快速、兼容 React 16 生态并支持 IE8 的轻量级 React 替代框架,它对 Hooks 的实现出人意料地"直白":用一个普通数组 + 一个全局指针,就换掉了 React 16 里复杂的双向链表机制。本文将带你深入 Nerv 的 Hooks 源码,3 个核心步骤讲透 useState 的实现原理,看完你会明白"轻量"到底轻在哪里。
为什么 Nerv 不用链表?先看懂 React 16 的痛点
在 React 16 中,组件每次渲染时,每个 Hook 的状态都挂在一个双向链表的节点上,链表按调用顺序串联:
- 每次调用
CurrentHook.next都要手动串接prev、next指针 - 新增或删除链表节点时,指针维护逻辑一旦写错就会出现诡异 bug
- 链表结构对 GC 不友好,且无法随机访问第 n 个 Hook
Nerv 的思路更简单:既然 Hook 调用顺序就是固定的,那直接用一个数组按下标存取,何必链表?这个改动贯穿了整个 hooks.ts 文件,也是 Nerv 被称为"轻量替代"的核心原因之一。
核心第一步:一个全局指针定位"当前组件"
Hooks 没有 this,那框架怎么知道useState是谁调的?答案在 current-owner.ts 里,整个文件只有一个对象:
Current.current:指向正在渲染的组件实例Current.index:当前是第几个 Hook(从 0 开始自增)
组件开始渲染时,框架在 lifecycle.ts 的renderComponent函数中做了两件事:先把指针指向当前组件、把计数清零,然后才调用component.render()(也就是你的函数组件体),渲染结束立刻把指针置回null。
renderComponent: CurrentOwner.current = component // 指针指向当前组件 CurrentOwner.index = 0 // Hook 计数器归零 component.render() // 你的组件函数在这里执行 CurrentOwner.current = null // 渲染结束,指针清空这就是"隐式作用域"——不需要传任何参数,所有 Hook 函数天然知道自己在服务哪个组件。
核心第二步:getHooks——用数组下标替代链表遍历
再看 hooks.ts 中的getHooks,它是所有 Hook 的公共入口:
- 校验
Current.current是否为null,在组件外调用 Hook 直接抛错(对应 React 的 "Hooks can only be called inside..." 报错) - 取出当前组件的
hooks数组(声明于 component.ts) - 若下标越界(新 Hook),往数组
push一个空对象占位 - 返回
hooks[index],O(1) 随机访问,对比 React 16 要沿着next指针逐个走到第 n 个节点
数组元素是一个"大杂烩"对象,同时容纳 state、deps、effect、ref 等字段(见 Hook 类型定义)。首次渲染时各字段为空,之后每个 Hook 只关心自己那一份——Hook 状态与组件实例同生命周期,重渲染时天然持久。
核心第三步:useState 的完整执行链
现在主角登场。useState 的实现只有十几行,流程如下:
| 步骤 | 做了什么 | 对应代码位置 |
|---|---|---|
| ① 惰性求值 | initialState是函数就先执行一次(懒初始化) | hooks.ts 第 68-70 行 |
| ② 领取槽位 | getHooks(Current.index++)拿到当前槽位并自增 | hooks.ts 第 71 行 |
| ③ 首次初始化 | 若槽位无 state,存入[初始值, setState函数]并记住组件引用 | hooks.ts 第 72-82 行 |
| ④ 返回状态 | 后续渲染直接返回已有的hook.state | hooks.ts 第 83 行 |
注意第 ③ 步的巧思:setState函数在闭包里直接改写hook.state[0],状态更新不经过 props,渲染时读到的就是新值。
首次渲染: hooks[0] = {} → 写入 state = [value, setFn] 第 2 次渲染: hooks[0].state 已存在 → 直接返回,value 保持上次结果setState 之后发生了什么?一次完整的更新链路
调用setCount(count + 1)时,闭包做了三件事(hooks.ts 第 76-80 行):
- 同步更新
hook.state[0](支持函数式更新:传函数就用action(hook.state[0])) - 把组件的
_disable标志置为false(解除渲染期间的更新拦截) - 调用
component.setState({}),进入 Nerv 的标准批处理流程
真正触发重绘的是 render-queue.ts 的enqueueRender:
- 用
_dirty脏标记去重,同一组件多次 setState 只入队一次 - 首个入队时通过
nextTick调度rerender,把散落的更新合并成一次批量重渲染——这就是 Hooks 在事件回调里多次 setState 也只刷新一次的底层原因
完整链路一句话总结:setState → 改数组槽位 → 入脏队列 → nextTick 批量重渲染 → 重新执行组件函数 → 按 index 顺序取回状态。
同一套地基,撑起所有 Hooks
理解了 useState,其余 Hooks 都是"换皮",全部共享Current + 数组下标这套地基:
- 🪝useReducer:和 useState 结构一致,只是槽位里存的是
[state, dispatch],dispatch 内部走reducer(state, action)(hooks.ts 第 86-107 行) - 🪝useRef:槽位里存一个
{ current }对象,永不触发重渲染(hooks.ts 第 178-186 行) - 🪝useMemo / useCallback:靠
areDepsChanged逐位比较依赖数组(用Object.is),没变化就复用上次的值,useCallback直接复用 useMemo 实现 - 🪝useEffect / useLayoutEffect:依赖变化时把 effect 挂到组件的
effects/layoutEffects列表,前者经nextTick + setTimeout(0)延后执行,保证不阻塞渲染
这也解释了 React 社区的经典铁律——不要在条件语句里调 Hook:Nerv 的Current.index++是顺序计数,一旦顺序变化,下标就和槽位错位,useState拿到的是隔壁useRef的槽位,直接报错。
新手实战:克隆仓库跑通测试
想亲手验证以上原理?只需 3 步:
git clone https://gitcode.com/NervJS/nerv cd nerv npm install && npm test重点看两个文件即可:
- 实现:packages/nerv/src/hooks.ts(全文不到 240 行,可一口气读完)
- 测试用例:packages/nerv/tests/hooks.spec.js,覆盖了状态更新、依赖比较、cleanup 等典型场景
总结:轻量不等于简陋
| 维度 | React 16 | Nerv |
|---|---|---|
| Hook 状态容器 | 双向链表 | 普通数组 |
| 取第 n 个 Hook | 沿指针遍历 O(n) | 下标访问 O(1) |
| 组件定位 | 全局 CurrentHook | 全局 Current(组件 + index) |
| 额外收益 | — | 体积更小、逻辑更直白、天然兼容 IE8 |
Nerv 用"数组 + 全局指针 + 计数器"三件套,实现了与 React 完全一致的 Hooks 语义,却砍掉了链表维护的全部复杂度。对于追求小包体积、IE8 兼容以及想读懂整个框架的新手来说,这正是 Nerv 作为轻量 React 替代框架最有价值的地方。
下一步建议:顺着 render.ts 和 render-queue.ts 把批处理队列读完,你会发现 Nerv 的整个更新机制同样"一眼见底"。
【免费下载链接】nervA blazing fast React alternative, compatible with IE8 and React 16.项目地址: https://gitcode.com/NervJS/nerv
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考