ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零手写React:函数组件渲染实现与原理

从零手写React:函数组件渲染实现与原理 这是“关闭AI从零手搓React”系列的第 8 篇。上一期已经打通了从createElement到render的基础链路这次要做的事情很明确让这个迷你 React 认识函数组件。函数组件是 React 里最常用的组件形态。类组件、Hooks、服务端组件、RSC 这些概念都建立在“组件是一个函数”这个基础上。如果只支持原生标签render 函数里的App /根本没法解析。所以这一步做完整个迷你 React 才具备组件化的雏形。这篇文章会直接讲清楚函数组件在 vnode 里长什么样、render 阶段怎么拿到函数体返回的子树、commit 阶段怎么把子树插入真实 DOM。代码量不大但理解了这个过程后面加 Hooks、加 Fiber 都会顺很多。1. 核心能力速览能力项说明项目定位手写 React 内核教学实现非生产级框架本次目标支持 function component 的渲染与更新前置知识上一个版本的 createElement、render、vnode 结构代码规模约 60 到 100 行核心逻辑运行环境浏览器环境直接通过 HTML 引入或构建工具测试是否依赖第三方库否是否支持类组件本次不做是否支持 Hooks本次不做后续章节扩展是否支持 key 协调本次不做适合读者正在手写 React、准备理解组件化原理的前端开发者这次实现的关键点只有一个判断 vnode 的 type 是字符串还是函数。字符串走原生标签逻辑函数走组件调用逻辑。后面所有组件能力都从这个分支延展出去。2. 为什么先做 function componentReact 的组件体系里函数组件是最轻量的一种表达方式。它接收props返回一个描述 UI 的 vnode 树。没有生命周期没有实例没有this。这个教学实现从函数组件切入是因为它能把“组件”这个概念剥离到最小一个普通函数。这里先明确一个问题为什么不能直接把函数组件当做一个黑盒调用然后递归渲染结果从功能上可以。真正的问题是后续扩展。你一旦想支持 Hooks就需要知道“当前正在渲染哪个组件”想支持更新就需要知道“上一次组件输出的 vnode 对比结果是什么”。所以这个实现里会把函数组件作为一个独立的分支处理而不是在createElement阶段提前展开。从架构上讲函数组件在 vnode 树里会留下一个标记。后面的 diff 阶段顺着这个标记往下走才能知道哪些 DOM 节点是组件渲染出来的、哪些是原生标签直接对应的。这个设计对应真实 React 里reconcileChildFibers的核心判断vnode 的 type 是字符串创建宿主组件。vnode 的 type 是函数创建函数组件对应的 Fiber 节点。虽然这个系列还没有写 Fiber但判断逻辑雏形会在这里出现。3. 环境准备与项目结构3.1 环境准备这一步不需要特殊环境仍然沿用前几篇的迷你 React 工程结构。建议用 Vite 搭一个空项目方便改代码后自动刷新。npm create vitelatest mini-react-function-component -- --template vanilla cd mini-react-function-component npm install整个项目只需要两个核心文件src/ mini-react.js # 迷你 React 核心createElement render main.js # 测试入口如果不想用构建工具直接新建 HTML 文件通过script typemodule导入写好的模块也可以。3.2 项目结构说明这个阶段不引入 JSX 编译。直接在main.js里用createElement描述 UI代码路径最短方便看清调用关系。// 后续所有测试代码都基于这个入口 import { createElement, render } from ./mini-react.js;等这个版本跑通再引入babel/plugin-transform-react-jsx或者 esbuild 的 JSX 转换把App /语法映射到createElement。4. 迷你 React 的 vnode 结构设计在实现函数组件之前先把当前这个迷你 React 的 vnode 结构固定下来。// mini-react.js function createElement(type, props, ...children) { return { type, props: { ...props, children: children.map((child) typeof child object ? child : createTextElement(child) ), }, }; } function createTextElement(text) { return { type: TEXT_ELEMENT, props: { nodeValue: text, children: [], }, }; }目前这个结构里type只有两种可能字符串类型名如div、span或者TEXT_ELEMENT。函数组件加入后type会多出第三种可能一个函数。这带来一个直接问题createElement里传给type的是函数但函数在 JavaScript 里也是对象。区分的方式只能是typeof type function。关键点在这里createElement不需要对函数组件做任何特殊处理它只负责把 type 原样放进 vnode。真正处理函数组件的是render阶段。5. 核心实现函数组件渲染逻辑5.1 render 入口与组件类型判断实现函数组件核心改动在 render 的递归过程。先看改动后的完整代码// mini-react.js function render(vnode, container) { const dom createDom(vnode); container.appendChild(dom); } function createDom(vnode) { if (vnode.type TEXT_ELEMENT) { return document.createTextNode(vnode.props.nodeValue); } if (typeof vnode.type function) { // 函数组件调用函数拿到子 vnode 树继续递归 const childVNode vnode.type(vnode.props); return createDom(childVNode); } // 原生标签 const dom document.createElement(vnode.type); // 处理属性 Object.keys(vnode.props) .filter((key) key ! children) .forEach((name) { dom[name] vnode.props[name]; }); // 处理子节点 vnode.props.children.forEach((child) { dom.appendChild(createDom(child)); }); return dom; } export { createElement, render };这段代码的核心就是typeof vnode.type function这个分支。当 vnode.type 是函数时直接调用vnode.type(vnode.props)。函数返回值是一个新的 vnode再对新的 vnode 继续执行createDom。这里面的逻辑可以用几个例子验证。5.2 简单函数组件的渲染链路假设有如下组件function App(props) { return createElement(div, { id: app }, props.title); }调用链createElement(App, { title: hello }) - vnode.type 是 App 函数 - createDom(vnode) - 发现 type 是函数 - 调用 App({ title: hello }) - App 返回 { type: div, props: { id: app, children: [...] } } - 对返回的 vnode 继续 createDom - 创建 div 元素 - 递归处理子节点整个过程是两个阶段叠加组件调用阶段函数负责把 props 变成 vnode。DOM 创建阶段递归遍历 vnode创建真实 DOM。5.3 多级组件嵌套函数组件嵌套也直接支持。因为每次遇到函数类型 vnode都会再次进入函数调用分支。function Child() { return createElement(span, null, child); } function Parent() { return createElement(div, null, Child()); }这里有一个常见错误写法在createElement里直接写Child()而不是传Child。// 错误提前调用函数 return createElement(div, null, Child()); // 正确传入函数引用 return createElement(div, null, createElement(Child, null));第一种写法把 Child 的调用结果当子节点传入本质上只是普通 vnode 嵌套组件调用过程在createElement之前就提前发生了。第二种写法保留函数引用让 render 阶段统一处理。进入真正的 JSX 写法后Child /会被 Babel 编译为createElement(Child, null)所以项目中很少需要手写第二种。但在这个教学实现里需要手动保持这个习惯。5.4 组件更新与容器清空真实 React 的 update 流程要经过 diff 算法。这个阶段先实现一个最简单的更新每次 render 之前清空容器然后重新构建整棵 DOM 树。function render(vnode, container) { container.innerHTML ; const dom createDom(vnode); container.appendChild(dom); }这个写法能保证重复调用 render 时组件函数会重新执行props 也会拿到最新值。function App(props) { return createElement(div, null, props.title); } render(createElement(App, { title: first }), root); render(createElement(App, { title: second }), root);第二次 render 时会重新调用App({ title: second })页面最终显示 second。这个方案性能很差但逻辑正确。后面写到 Fiber 和新旧 vnode 对比时会替换掉innerHTML 的逻辑。6. 属性处理补齐props 透传函数组件常用的场景是外部传入属性。上面代码里原生标签属性直接赋值到dom[name]。对函数组件分支来说props 全部传给组件函数本身。如果一个组件内部返回多个原生标签要注意子节点递归的位置。看一个稍复杂的例子function Item({ text, index }) { return createElement( div, { className: item, data-index: index }, text ); } function List({ items }) { return createElement( div, { className: list }, items.map((item, index) createElement(Item, { text: item, index })) ); }这条链路会这样执行List 函数被调用入参{ items: [...] }。List 返回一个 div 的 vnode。递归创建 div 元素。遍历 items 时每一项都生成一个 type 为 Item 的 vnode。遇到 Item vnode发现 type 是函数调用 Item 函数传入{ text, index }。Item 返回 div 的 vnode继续递归创建。这个过程中每个组件只负责“收到 props返回 vnode”。DOM 创建完全由外层 createDom 统一管理。7. 功能测试与效果验证7.1 测试用例一基础函数组件渲染测试代码// main.js import { createElement, render } from ./mini-react.js; function App() { return createElement(div, { id: root }, hello function component); } const container document.getElementById(root); render(createElement(App, null), container);预期结果div idroothello function component/div判断成功的标准页面出现 divid 为 root文本内容正确。如果页面空白检查 createDom 里函数分支是否提前 return。7.2 测试用例二props 传递与多级嵌套function Avatar({ name }) { return createElement(span, { className: avatar }, name); } function Card({ title, name }) { return createElement( div, { className: card }, createElement(h2, null, title), createElement(Avatar, { name }) ); } render( createElement(Card, { title: card title, name: zhangsan }), container );预期结构div classcard h2card title/h2 span classavatarzhangsan/span /div这里最容易出现的问题是Avatar的 props 丢失。如果写成createElement(Avatar, null)函数内部props.name就是 undefined最终渲染出空 span。7.3 测试用例三组件重复渲染let count 0; function Counter() { return createElement(div, null, count: ${count}); } render(createElement(Counter, null), container); count 1; render(createElement(Counter, null), container);预期结果页面显示count: 1。因为这次 render 实现做了container.innerHTML 重新执行了 Counter 函数所以能拿到最新闭包变量。如果后续要优化可以只更新变化部分但这是 diff 算法的任务。7.4 测试用例四多组件实例并存同一个组件函数通过 createElement 多次引用会创建多个独立实例。function Button({ label }) { return createElement(button, null, label); } render( createElement(div, null, createElement(Button, { label: ok }), createElement(Button, { label: cancel }) ), container );预期结果页面出现两个 button文本分别是 ok 和 cancel。这里要理解“组件实例”在这里的含义不是像类组件那样有一个对象实例而是每次调用组件函数时独立执行一次作用域互不干扰。8. 这段实现与真实 React 的差距每次实现一个功能后作对比能帮助理解 React 设计的边界。下面是当前实现和真实 React 的关键差异能力点当前实现真实 React组件函数执行时机render 时递归执行render 阶段由 Scheduler 调度执行更新方式整树清空重建基于 Fiber 的增量更新组件单元函数调用Fiber 节点状态存储无Hooks 链表兄弟节点协调无按顺序 appendChild基于 key 和 diff 算法复用节点中断渲染不支持支持时间切片和优先级调度事件系统原生事件直接绑合成事件委托到根容器这些差异不是缺陷是教学实现逐步逼近真实 React 的路线图。这一版完成后下一步自然是给函数组件加“记忆能力”也就是 useState 的雏形。9. 常见问题与排查方法问题现象可能原因排查方式解决方案页面空白无报错render 未调用或容器未获取在 render 前打印 container确认 DOMContentLoaded 后执行函数组件渲染结果为 [object Object]子节点直接传入了函数调用结果检查 createElement 里是否写成Child()改成createElement(Child, null)props 里的属性没有传递到组件createElement 第二个参数传成 null检查调用处 props 实参传入正确的 props 对象嵌套组件的 props 丢失子组件 createElement 时漏传 props打印组件函数入参逐层透传 props重复 render 后页面出现旧内容残留没有清空 container检查 render 开头是否有 innerHTML 清理增加容器清理逻辑组件内返回多个根节点报错或只显示一个函数组件只能返回单一 vnode检查 return 是否返回数组或并列节点用一个父节点包裹10. 最佳实践与后续扩展方向10.1 本阶段编码建议写这个阶段时建议保持函数组件分支的独立性。不要试图在createElement阶段把函数组件展开成 DOM 结构这不是正确方向。函数组件的处理必须发生在 render 阶段。这个设计保证后续支持 Hooks 时能通过“当前执行到哪个组件”来控制逻辑。另外这一版的 props 没有做默认值处理。如果后续要用defaultProps可以在函数调用前从组件函数的静态属性上读取function createDom(vnode) { if (typeof vnode.type function) { const { type: Component, props } vnode; const mergedProps { ...(Component.defaultProps || {}), ...props, }; const childVNode Component(mergedProps); return createDom(childVNode); } // ... }这个写法只在当前简单结构下成立真实 React 的 props 合并发生在 createElement 阶段而且默认值处理有专门的规则。10.2 后续章节建议路径这一章做完迷你 React 已经具备以下能力原生标签渲染。文本节点渲染。函数组件渲染。props 传递。全量更新。接下来最自然的扩展点是响应式更新。建议方向是先在 render 函数外面包一层调度函数每次更新时重新执行组件树而不是清空整棵树。进一步再引入单组件级别的重新执行也就是 useState 需要的最小模型。也可以先补一个简化版 diff对比子节点顺序、类型和 props尝试复用已有 DOM。这一步虽然复杂但会让更新的效率提升一个量级。10.3 测试与调试技巧这个教学实现最直观的调试手段是打印 vnode 结构。在createDom的入口加一行日志function createDom(vnode) { console.log(createDom:, vnode.type); // ... }打印结果会清晰显示递归顺序。以嵌套组件为例createDom: function App() {} createDom: div createDom: h2 createDom: function Avatar() {} createDom: span观察这个日志就能验证函数组件调用和原生标签创建的顺序是否符合预期。如果日志里出现连续两个函数类型说明某个组件返回了没有展开的 vnode。11. 写在最后这次实现的函数组件是整个系列从“渲染器”走向“组件框架”的关键一步。它把 vnode 的 type 从字符串扩展到了函数让 UI 描述具备了组合能力。代码不长但这段递归逻辑是 React 组件渲染模型的地基。建议动手跑一遍重点观察函数组件的执行时机和嵌套展开方式。如果能把上面四个测试用例全部跑通就说明这个迷你 React 的函数组件支持已经稳定了。下一步可以尝试给组件加一个最简单的状态更新机制这是 React Hooks 学习的起点。把这篇文章收藏起来后续章节会回到这版代码上继续扩展 Fiber 结构和 Hooks 实现。
返回列表