ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

手写迷你React:实现函数组件的渲染与更新机制

手写迷你React:实现函数组件的渲染与更新机制 先把前面的进度串一下。在这个“从零手搓 React”系列里我们到目前已经实现了createElement生成虚拟节点、首次渲染、以及最基本的更新逻辑。但如果仔细观察之前的代码会发现一个很明显的问题我们一直在用createElement(div, ...)这种“原生标签字符串”的方式描述 UI也就是只能写类似 HTML 的节点还从来没有支持过真正的“组件”。组件是 React 应用里最基础的代码组织方式其中 Function Component函数组件更是当前 React 生态里绝对的主流。这一篇我们就来补上这块核心拼图在自研 mini React 中实现一个简单的 Function Component 渲染机制。读完本文你会理解为什么函数组件在底层只是一个“返回 VNode 的普通函数”也会掌握组件挂载、组件更新、props 传递、事件绑定以及嵌套组件的完整处理思路。1. 背景与核心概念1.1 什么是 Function ComponentFunction Component 从字面理解就是“用函数定义的组件”。它接收一个props对象作为参数返回一份 React 元素树也就是 VNode例如function Welcome(props) { return h1Hello, {props.name}/h1; }这段 JSX 在经过编译之后实际等价于function Welcome(props) { return createElement(h1, null, Hello, ${props.name}); }所以你会发现函数组件本质上并不是什么黑魔法。它就是“一个输入 props、输出 VNode 的纯函数”。React 在渲染的时候发现某个 VNode 的type是一个函数时就不再把它当作原生 DOM 标签去创建元素而是去调用这个函数拿到它返回的子 VNode然后再继续递归渲染。至于函数组件到底比类组件多了什么、少了什么这里不展开讲。你只需要先抓住两点函数组件能把 UI 拆分成可复用的片段。函数组件本身不产生真实 DOM它只是“中间层”。真正创建 DOM 的还是最底层的原生标签。1.2 为什么要在迷你 React 中实现函数组件如果你只实现了原生标签的渲染虽然能跑通页面但代码里会充满大量重复的createElement调用。例如实现一个计数器你可能每次要在渲染函数里把div、span、button全部写一遍。这显然不是我们熟悉的那种 React 开发体验。更重要的是函数组件是 React 一切上层能力的载体props 从父组件向子组件传递组件复用的最小单元hooksuseState、useEffect等的宿主性能优化React.memo的入口。即使我们这次只是“手搓”一个简化版也必须先把函数组件跑通后面再继续升级才有意义。1.3 手写实现中的关键设计我们当前规划的自研 React 不依赖 Babel也不使用 JSX 运行时因此所有节点都通过createElement手动创建。VNode 的数据结构沿用一个普通对象{ type: div | function | TEXT, props: { ... }, children: [ ...VNode ] }当type是字符串时表示原生 DOM 节点当type是函数时表示这是一个函数组件。整个渲染流程的判断逻辑就很清晰了字符串 type创建真实 DOM并递归处理 children函数 type调用type(props)把返回的 VNode 当作新的子树来处理。这个判断贯穿挂载和更新两个阶段也就是本文后面代码的核心。2. 环境准备与项目结构2.1 开发环境本文示例代码不依赖任何第三方框架只使用原生 JavaScriptES6编写因此环境要求非常低。建议使用现代浏览器进行测试例如 Chrome、Edge 或 Firefox。本地测试可以直接新建一个 HTML 文件通过script typemodule引入 JS 文件如果你想更方便地修改和调试用 Vite 创建一个纯静态项目也可以。这里不绑定具体环境重点看代码逻辑。如果你的 Node.js 环境已经安装可以运行一个简单的静态服务npx serve .然后打开浏览器访问对应地址。如果不想装任何工具直接把 HTML 文件拖进浏览器也能运行。需要注意本文示例中的代码是为了演示 React 核心原理而写的简化版本不是生产可用代码。函数组件的状态管理会用全局变量配合手动刷新实现真正的useState等 Hooks 实现后面文章再专门展开。2.2 项目文件结构为了便于阅读我把示例拆分成了三个文件mini-react/ ├── index.html // 页面入口 ├── mini-react.js // 核心渲染逻辑 └── app.js // 组件定义与使用示例其中mini-react.js承担 VNode 创建、挂载、更新等底层能力app.js编写需要渲染的组件index.html提供一个挂载点#root并依次引入脚本。!-- 文件路径mini-react/index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleMini React - Function Component/title /head body div idroot/div script typemodule src./app.js/script /body /htmlapp.js中会调用render方法因此需要先从mini-react.js中导出相应函数。我们在模块方案上使用 ESM方便浏览器直接运行。3. 核心原理拆解3.1 VNode 的三种形态在手写实现里VNode 的type类型直接决定了渲染分支。为了统一管理我建议把子节点中的纯文本也包装成一种特殊 VNodetype固定为字符串TEXT。这样所有节点对象都具备type、props、children三个字段后续 diff 和更新都会简单很多。function createTextElement(text) { return { type: TEXT, props: { nodeValue: String(text) }, children: [] }; }文本节点在真实 DOM 中用TextNode表示nodeValue就是文本内容。之所以不直接把字符串塞进 children是为了让每一个子节点都是结构统一的 VNode避免在挂载和更新时反复判断“这个节点是对象还是字符串”。3.2 createElement 的扩展我们之前很可能已经写过一版createElement但为了支持函数组件需要把 children 规范化放到前面来设计清楚。function createElement(type, props, ...children) { const normalizedChildren children .flat() .filter(child child ! null child ! undefined child ! false) .map(child typeof child object child.type ? child : createTextElement(child) ); return { type, props: props || {}, children: normalizedChildren }; }几个关键点children.flat()处理数组嵌套的场景。比如createElement(div, null, [a, b])不需要调用方手动展开。filter把null、undefined、false过滤掉。false在条件渲染中很常见例如{flag span内容/span}此时空内容不影响渲染。如果 child 本身是带type的对象说明已经是一个 VNode直接复用否则转成文本 VNode。函数组件的props会通过这种方式原样传给组件函数因此组件内部可以通过props.xxx读取属性。3.3 挂载阶段mount挂载是指“初次渲染”阶段也就是把一份全新的 VNode 转换成真实 DOM 插入页面。核心逻辑如下function mount(vnode) { if (typeof vnode.type function) { return mountFunctionComponent(vnode); } return mountHostComponent(vnode); } function mountHostComponent(vnode) { if (vnode.type TEXT) { const dom document.createTextNode(); dom.nodeValue vnode.props.nodeValue; vnode.dom dom; return dom; } const dom document.createElement(vnode.type); vnode.dom dom; updateDomProperties(dom, {}, vnode.props); vnode.children.forEach(child { const childDom mount(child); dom.appendChild(childDom); }); return dom; } function mountFunctionComponent(vnode) { const { type, props } vnode; const childVNode type(props); const dom mount(childVNode); vnode.dom dom; vnode._child childVNode; return dom; }这段代码的要点函数组件在挂载时调用type(props)得到子 VNode然后递归mount。函数组件本身不创建 DOM因此它需要把子 VNode 对应的 DOM 作为自己的vnode.dom这样外层统一取dom才能顺利插入。vnode._child保存本次渲染出的子 VNode。更新时需要拿它和新的子 VNode 做 diff。要注意的是mountHostComponent里的updateDomProperties我们会在下一节实现它负责把 props 里的属性、事件等应用到真实 DOM 上。3.4 属性与事件更新updateDomProperties(dom, prevProps, nextProps)的作用是将新的 props 同步到已有 DOM 节点上同时清理掉旧的、没有出现在新 props 中的属性。简化版本可以这样写const IS_EVENT_PROPERTY /^on/; function updateDomProperties(dom, prevProps, nextProps) { const prevKeys Object.keys(prevProps); const nextKeys Object.keys(nextProps); // 移除旧的、新的里面没有的 props prevKeys .filter(key !nextKeys.includes(key)) .forEach(key { if (IS_EVENT_PROPERTY.test(key)) { const eventName key.toLowerCase().substring(2); dom.removeEventListener(eventName, prevProps[key]); } else { dom[key] ; } }); // 更新或新增 props nextKeys.forEach(key { if (IS_EVENT_PROPERTY.test(key)) { const eventName key.toLowerCase().substring(2); dom.removeEventListener(eventName, prevProps[key]); dom.addEventListener(eventName, nextProps[key]); } else if (key className) { dom.setAttribute(class, nextProps[key]); } else if (key style typeof nextProps[key] object) { Object.assign(dom.style, nextProps[key]); } else { dom[key] nextProps[key]; } }); }这段代码有几点值得说明把onClick、onChange等事件利用addEventListener绑定到真实 DOM。事件更新时先移除旧监听再添加新监听。这样即使 onClick 对应的函数每次都是新建的也不会出现监听函数堆叠的问题。className转为class这是 JS 里常见的属性名差异。普通属性直接通过dom[key] value赋值比如id、disabled、value等。3.5 更新阶段patch更新是 React 渲染系统的重头戏。在函数组件场景下更新时组件函数会被重新调用生成一份新的子树然后与旧子树做对比尽可能复用已有 DOM。这里提供一个按“位置 类型”进行最简单对比的 diff 思路function patch(prevVNode, nextVNode, parent) { // 如果两个节点类型不同直接重建 if (prevVNode.type ! nextVNode.type) { const newDom mount(nextVNode); parent.replaceChild(newDom, prevVNode.dom); return; } if (typeof nextVNode.type function) { patchFunctionComponent(prevVNode, nextVNode, parent); } else { patchHostComponent(prevVNode, nextVNode); } }patchHostComponent负责更新原生节点自身以及它的子节点function patchHostComponent(prevVNode, nextVNode) { const dom nextVNode.dom prevVNode.dom; // 文本节点特殊处理 if (prevVNode.type TEXT) { if (prevVNode.props.nodeValue ! nextVNode.props.nodeValue) { dom.nodeValue nextVNode.props.nodeValue; } return; } updateDomProperties(dom, prevVNode.props, nextVNode.props); // 子节点数量不一致时简化处理全部重建 const prevChildren prevVNode.children; const nextChildren nextVNode.children; if (prevChildren.length ! nextChildren.length) { dom.innerHTML ; nextChildren.forEach(child { dom.appendChild(mount(child)); }); return; } prevChildren.forEach((prevChild, i) { patch(prevChild, nextChildren[i], dom); }); }patchFunctionComponent是函数组件的更新入口function patchFunctionComponent(prevVNode, nextVNode, parent) { const dom nextVNode.dom prevVNode.dom; const nextChildVNode nextVNode.type(nextVNode.props); patch(prevVNode._child, nextChildVNode, dom); nextVNode._child nextChildVNode; }这里最关键的一点是函数组件更新时也要重新调用type(props)得到新的子树然后与上一次保存的_child进行 diff。由于prevVNode._child里保存的还是旧子树DOM 引用还在因此只要新旧子树的类型相同就能复用之前的 DOM 节点。从这个角度看函数组件相当于是“动态生成子 VNode 的工厂”它让 UI 描述变得可组合、可复用同时又没有给底层 DOM 带来额外负担。3.6 render 入口render是面向外部暴露的渲染方法。第一次渲染时容器节点没有旧 VNode直接 mount第二次渲染时用旧 VNode 和新的 VNode 做 patch。function render(vnode, container) { if (!container._vnode) { const dom mount(vnode); container.appendChild(dom); } else { patch(container._vnode, vnode, container); } container._vnode vnode; }需要特别说明的是这里为了简化把_vnode直接存在容器 DOM 上。真实 React 中会通过 Fiber 树来管理节点关系并且支持优先级调度、打断恢复等复杂能力。但就理解函数组件而言这种简化已经足够清晰。4. 完整实战案例函数组件计数器4.1 创建项目结构按前面的规划我们创建三个文件mini-react/ ├── index.html ├── mini-react.js └── app.jsindex.html的内容已经在第 2 节给出这里不再重复。你只需要保留一个div idroot/div挂载点即可。4.2 编写 mini-react.js 核心渲染逻辑下面是mini-react.js的完整代码我们把上节讲到的所有函数整合到一起最后统一导出。// 文件路径mini-react/mini-react.js function createTextElement(text) { return { type: TEXT, props: { nodeValue: String(text) }, children: [] }; } function createElement(type, props, ...children) { const normalizedChildren children .flat() .filter(child child ! null child ! undefined child ! false) .map(child typeof child object child.type ? child : createTextElement(child) ); return { type, props: props || {}, children: normalizedChildren }; } const IS_EVENT_PROPERTY /^on/; function updateDomProperties(dom, prevProps, nextProps) { const prevKeys Object.keys(prevProps); const nextKeys Object.keys(nextProps); prevKeys .filter(key !nextKeys.includes(key)) .forEach(key { if (IS_EVENT_PROPERTY.test(key)) { const eventName key.toLowerCase().substring(2); dom.removeEventListener(eventName, prevProps[key]); } else { dom[key] ; } }); nextKeys.forEach(key { if (IS_EVENT_PROPERTY.test(key)) { const eventName key.toLowerCase().substring(2); dom.removeEventListener(eventName, prevProps[key]); dom.addEventListener(eventName, nextProps[key]); } else if (key className) { dom.setAttribute(class, nextProps[key]); } else if (key style typeof nextProps[key] object) { Object.assign(dom.style, nextProps[key]); } else { dom[key] nextProps[key]; } }); } function mount(vnode) { if (typeof vnode.type function) { return mountFunctionComponent(vnode); } return mountHostComponent(vnode); } function mountFunctionComponent(vnode) { const { type, props } vnode; const childVNode type(props); const dom mount(childVNode); vnode.dom dom; vnode._child childVNode; return dom; } function mountHostComponent(vnode) { if (vnode.type TEXT) { const dom document.createTextNode(); dom.nodeValue vnode.props.nodeValue; vnode.dom dom; return dom; } const dom document.createElement(vnode.type); vnode.dom dom; updateDomProperties(dom, {}, vnode.props); vnode.children.forEach(child { const childDom mount(child); dom.appendChild(childDom); }); return dom; } function patch(prevVNode, nextVNode, parent) { if (prevVNode.type ! nextVNode.type) { const newDom mount(nextVNode); parent.replaceChild(newDom, prevVNode.dom); return; } if (typeof nextVNode.type function) { patchFunctionComponent(prevVNode, nextVNode, parent); } else { patchHostComponent(prevVNode, nextVNode); } } function patchFunctionComponent(prevVNode, nextVNode, parent) { const dom nextVNode.dom prevVNode.dom; const nextChildVNode nextVNode.type(nextVNode.props); patch(prevVNode._child, nextChildVNode, dom); nextVNode._child nextChildVNode; } function patchHostComponent(prevVNode, nextVNode) { const dom nextVNode.dom prevVNode.dom; if (prevVNode.type TEXT) { if (prevVNode.props.nodeValue ! nextVNode.props.nodeValue) { dom.nodeValue nextVNode.props.nodeValue; } return; } updateDomProperties(dom, prevVNode.props, nextVNode.props); const prevChildren prevVNode.children; const nextChildren nextVNode.children; if (prevChildren.length ! nextChildren.length) { dom.innerHTML ; nextChildren.forEach(child { dom.appendChild(mount(child)); }); return; } prevChildren.forEach((prevChild, i) { patch(prevChild, nextChildren[i], dom); }); } function render(vnode, container) { if (!container._vnode) { const dom mount(vnode); container.appendChild(dom); } else { patch(container._vnode, vnode, container); } container._vnode vnode; } export { createElement, render, mount };4.3 编写 app.js 组件示例接下来写一个带有“组件嵌套 交互事件”的函数组件示例。为了演示函数组件的复用能力我们设计三层组件Header显示标题Counter显示按钮和计数内部接收事件回调App作为根组件通过 props 把 state 和回调传给Counter。由于这一阶段还没有实现useState我们用最直接的方式模拟状态更新一个全局state对象每次更新后手动调用renderApp重新渲染整棵树。// 文件路径mini-react/app.js import { createElement, render } from ./mini-react.js; const root document.getElementById(root); let state { count: 0 }; function Header({ title }) { return createElement(h1, { className: header }, title); } function Counter({ count, onIncrease, onDecrease }) { return createElement( div, { id: counter, style: { padding: 12px, border: 1px solid #ccc } }, createElement(span, null, 当前计数${count}), createElement(button, { onClick: onIncrease }, 增加), createElement(button, { onClick: onDecrease }, 减少) ); } function App(props) { return createElement( div, null, createElement(Header, { title: Mini React Function Component }), createElement(Counter, props) ); } function renderApp() { render( createElement(App, { count: state.count, onIncrease: () { state.count 1; renderApp(); }, onDecrease: () { state.count - 1; renderApp(); } }), root ); } renderApp();这个示例的运行过程是这样的首次调用renderApp时root._vnode不存在因此走 mount 流程。App是函数组件调用后得到Counter组件的 VNode。Counter是函数组件调用后得到div原生节点 VNode。div是原生节点创建真实 DOM并把 children 递归挂载。点击“增加”或“减少”按钮时修改全局 state再次调用renderApp。第二次调用render时root._vnode已经存在走 patch 流程尽可能复用旧 DOM。4.4 运行与验证打开浏览器加载index.html你会看到页面里出现一个标题、一行当前计数的文字以及“增加”“减少”两个按钮。点击“增加”时计数从 0 变成 1点击“减少”时计数减 1。整个过程页面不会整页刷新更新前后也只有一个数字节点发生了内容变化。为了验证 patch 确实复用了 DOM可以在浏览器控制台执行document.querySelector(span) document.querySelector(span)这个表达式没有实际意义但你可以打开开发者工具的 Elements 面板观察点击按钮前后span节点是否被替换。如果复用成功DOM 节点不会经历“移除再创建”而只是在原地更新文本内容。4.5 一个易忽略的细节children 数量变化当前的 diff 对子节点数量不一致的情况处理得很粗暴直接清空容器重新挂载全部子节点。这意味着如果函数组件返回的子节点数量发生变化整个子树都会被重建。例如下面的组件function ConditionDemo({ showTip }) { return createElement( div, null, createElement(p, null, 固定内容), showTip ? createElement(span, null, 额外提示) : null ); }当showTip从true变成false时子节点数组长度从 2 变成 1我们的 diff 就会走到“长度不一致”分支将整个div内部清空后重建。这在功能上没问题但性能上不如真实 React 的 keyed diff。真实的 React 通过 key 来识别相同节点即使数量和顺序变化也能精准复用。不过这里不用急着实现 key先保证“函数组件 组件更新”这条主链路跑通后面再继续优化列表渲染。5. 常见问题与排查思路下面列出手写函数组件阶段最容易遇到的几类问题供你对照排查。问题现象常见原因解决思路页面一片空白控制台没有报错函数组件没有return导致childVNode为undefined检查组件函数是否显式 return箭头函数用圆括号包裹返回对象时不要漏写页面报错Cannot read properties of undefined函数组件返回了undefinedmount内部继续递归时无 VNode 可处理在mount入口先判断 childVNode 是否存在同时排查组件分支条件点击按钮后事件不生效更新时事件绑定没有刷新或事件名转换错误检查updateDomProperties中事件判断和监听移除逻辑打印nextProps确认回调已传入更新后整个 DOM 被替换新旧 VNode 的 type 不同或 children 数量不一致触发了重建分支打印新旧 type 和 children 长度检查是否错误地把组件写成了字符串标签组件接收不到 propscreateElement时没有把 props 传给 type 函数检查mountFunctionComponent和patchFunctionComponent中调用type(props)是否正确数字更新后变成了字符串拼接createTextElement内部已经做了 String 转换但你在组件里直接用了count 1事件回调里显式使用数字运算例如state.count 1避免隐式类型转换组件内写了数组作为子节点我们的createElement没有对嵌套数组继续递归处理先调用flat()拍平再交给组件如果不支持数组 children建议在组件内手动展开如果你在调试时不确定是哪一步出了问题最有效的方式是在mount、patch、updateDomProperties三个函数内部打console.log打印 VNode 的type、props和关键执行顺序。手写 React 的价值也正在于此所有步骤都可以放到自己的代码里观察不需要猜测框架内部发生了什么。6. 最佳实践与工程建议6.1 组件命名使用大写开头在 JavaScript 里普通函数和函数组件并没有语言层面的区别。但在 React 生态的约定里组件名必须以大写字母开头。这样做不仅是为了可读性更重要的是在未来升级 JSX 编译、Babel 插件时编译器能通过大小写区分“原生标签”和“组件引用”。我们当前用createElement手动创建节点看起来大小写不影响运行但建议从一开始就养成大写开头的习惯例如Counter、App。6.2 确保函数组件是纯函数函数组件内部不应该修改 props也不应该直接修改外部变量来影响渲染结果。同一个 props 传入多次渲染应该返回结构一致的 UI 树。在增加组件数量后你可能会忍不住在组件内部直接写props.count 1。这是非常危险的写法。更新外部状态的逻辑应该通过事件回调向上传递由上层统一修改状态后触发重新渲染。6.3 事件回调在更新时尽量保持稳定虽然我们的简化版每次渲染都会重新创建匿名回调并且通过removeEventListener和addEventListener完成了事件监听替换但频繁移除和添加监听器并不是性能最优解。从工程上讲如果回调稳定性差真实 React 中也会导致子组件重新渲染、memo 失效等问题。后面我们会引入调度器和状态管理机制届时可以让事件回调保持稳定引用减少不必要的更新。6.4 性能优化不急于一时当前实现里子节点数量不一致就会清空重建父组件更新时所有子组件也会一起重新执行函数。对于学习项目来说正确性优先于性能。先跑通链路再逐步优化下面的点子节点增加 key 识别组件类型不变但 props 未变时跳过子组件渲染引入 Fiber 架构让渲染可中断加入调度器支持高优先级任务插队。6.5 保持“最小可运行”心态手写框架类项目最忌讳一上来就想实现所有特性。如果一开始就把函数组件、类组件、hooks、diff、Fiber 全塞进一个版本排错难度会直线上升。本文只让你完成“函数组件 基础更新”的能力已经足够支撑很多 UI 场景接下来再逐步叠加状态管理和 hooks 会顺利很多。7. 总结与下一步这一篇的核心收获可以总结为四句话函数组件本质上是一个“接收 props、返回 VNode”的纯函数。挂载阶段通过typeof type function识别组件调用它得到子树后继续递归渲染。更新阶段再次调用组件函数得到新子树并与旧子树做 diff尽可能复用 DOM。props 负责向组件传递数据事件回调通过 props 下发给组件内部形成完整的数据流闭环。完成这篇代码后你的 mini React 已经具备渲染函数组件的能力。接下来值得继续深入的方向首先是useState的实现。它需要解决两个问题如何在组件多次调用之间保存状态以及状态变化后如何触发最小范围的重新渲染。这恰好是 React hooks 体系的核心起点。你可以尝试在现有代码上先做一个粗糙版本给每个函数组件维护一个内部的 state 队列在mountFunctionComponent和patchFunctionComponent时分别读取或更新状态。如果这一步能跑通你对 React 为什么选择“函数组件 hooks”这种组合方式认识会比看市面上任何教程都深。如果这篇文章对你有帮助建议直接打开编辑器照着敲一遍。只有亲手把“点击按钮改变 DOM”的完整链路跑通才算真正掌握了函数组件的底层原理。后续文章我们继续完善这个 mini React下一站就是状态管理与 hooks。
返回列表