尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

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

关闭AI从零手搓React:手写函数组件的实现与原理

关闭AI从零手搓React:手写函数组件的实现与原理 关闭AI从零手搓React | 08实现简单的 function component这次我们来看一个特别的系列关闭 AI从零手搓 React。所谓“关闭 AI”指的是全程不用生成式助手代写代码而是靠自己一行一行把 React 的核心机制实现出来。不依赖任何现成框架也不用复制源码完全用手里的原生 JavaScript 把虚拟 DOM、render 流程、组件机制这些核心概念跑通。这个系列做到第 8 篇前面的内容已经覆盖了createElement、虚拟 DOM 结构、原生 DOM 元素的 render、以及节点挂载。也就是说目前我们能渲染的只有原生标签比如div、span、p。而现在到了最关键的一步让 React 支持函数组件function component。这一步为什么关键因为组件化是 React 的灵魂。没有组件React 和一堆模板引擎就没有本质区别。有了函数组件我们才能把 UI 拆成可复用的独立单元才能继续在后续篇章里实现useState、useEffect这些 Hooks。本文会完成这些内容分析函数组件和原生 DOM 元素在虚拟 DOM 结构上的本质区别。用typeof vnode.type function完成组件与原生元素的判别。手写函数组件的调用与递归渲染流程。写一套最小可运行的函数组件 Demo跑通嵌套、props 传递、文本插值。排查函数组件渲染中最容易踩的 6 个坑包括 props 透传、组件名大小写、children 拼接、递归死循环、事件绑定失效、渲染结果未挂载。这篇文章适合已经手动实现过createElement和基础render函数的读者。如果你还不熟悉虚拟 DOM 的构建过程建议先看本系列前 7 篇把基础结构准备好再继续。1. 函数组件到底和原生元素差在哪里先想清楚一个问题在 React 源码层面div和App /本质上是什么先说div。当我们调用createElement(div, { className: box }, hello)得到的是一个这样的对象{ type: div, props: { className: box, children: [ { type: TEXT_ELEMENT, props: { nodeValue: hello } } ] } }type是字符串就是标签名。render 的时候直接document.createElement(div)就能创建真实 DOM。再看App /。假设我们有一个函数组件function App(props) { return createElement(h1, null, props.title); }当 JSX 被编译后App titlehello /实际调用的是createElement(App, { title: hello })得到的虚拟 DOM 对象是{ type: App, // 注意这是一个函数不是字符串 props: { title: hello } }这就是核心区别对比项原生元素函数组件vnode.type 的类型字符串函数render 时的处理方式直接创建真实 DOM 元素先调用函数拿到返回的 vnode再递归渲染props 的传递方式作为 DOM 属性写入元素作为参数传入函数children 的位置props.childrenprops.children从外部传入时同样在 props 中所以实现函数组件的基础逻辑非常简单当 render 发现 vnode.type 是函数时就不再创建 DOM而是调用这个函数用传入的 props 计算出它要返回的 vnode然后继续走原有的 render 流程。2. 核心设计不要让 render 函数知道“组件”的概念这是整个实现里最重要的设计思路render 函数不需要专门维护一套“组件系统”它只需要识别 type 的类型然后决定走哪条分支。也就是说支持函数组件并不是在 React 里加了一个独立的“组件渲染器”而是把函数组件看成一种“延迟展开的节点”。在展开之前它是一个函数。展开之后它返回的还是虚拟节点对象。递归地展开下去直到遇到原生字符串 type才真正创建 DOM。整个 render 流程如下判断 vnode.type 是否为函数。如果是函数调用vnode.type(vnode.props)获取函数返回的 vnode。对返回的 vnode 继续执行 render 流程回到第 1 步。如果 vnode.type 是字符串走原生元素创建分支。如果 vnode.type 是特殊文本类型走文本节点创建分支。把这个流程图对应到代码里就是三个分支的递归函数。3. 手写实现最小可运行的 render 流程我们基于一个最简化的 React-like 实现来改造。先看一下基础结构// 创建文本节点标记 const TEXT_ELEMENT TEXT_ELEMENT; function createElement(type, props, ...children) { return { type, props: { ...props, children: children.map(child { if (typeof child string || typeof child number) { return { type: TEXT_ELEMENT, props: { nodeValue: String(child) } }; } return child; }) } }; }这是前几篇已经实现好的createElement。它负责把 JSX 编译后的调用转成虚拟 DOM 对象。现在我们需要改造的是 render 函数。在改造之前render 大概是这样的function render(vnode, container) { // 文本节点 if (vnode.type TEXT_ELEMENT) { const textNode document.createTextNode(vnode.props.nodeValue); container.appendChild(textNode); return; } // 原生元素 const dom document.createElement(vnode.type); // 非 children 的 props 都作为属性设置 Object.keys(vnode.props).forEach(key { if (key ! children) { dom[key] vnode.props[key]; } }); // 递归渲染子节点 vnode.props.children.forEach(child { render(child, dom); }); container.appendChild(dom); }这段代码只能处理字符串 type。一旦 vnode.type 变成函数document.createElement(vnode.type)会直接报错因为document.createElement(App)是非法的。改造方案是在 render 函数开头增加一个函数类型分支function render(vnode, container) { // 函数组件分支调用函数递归渲染返回值 if (typeof vnode.type function) { const componentVnode vnode.type(vnode.props); render(componentVnode, container); return; } // 文本节点 if (vnode.type TEXT_ELEMENT) { const textNode document.createTextNode(vnode.props.nodeValue); container.appendChild(textNode); return; } // 原生元素 const dom document.createElement(vnode.type); Object.keys(vnode.props).forEach(key { if (key ! children) { dom[key] vnode.props[key]; } }); vnode.props.children.forEach(child { render(child, dom); }); container.appendChild(dom); }核心只有两个新行if (typeof vnode.type function) { const componentVnode vnode.type(vnode.props); render(componentVnode, container); return; }就这么简单。函数组件的第一个实现本质上是在 render 递归过程中把函数类型的节点“消费”掉换成它返回的虚拟节点然后继续原有的渲染逻辑。这里有几个细节vnode.type(vnode.props)就是在调用函数组件传入 props。函数的返回值仍然是一个 vnode这个 vnode 的类型可以是原生元素也可以又是一个函数组件。如果是函数组件下一轮递归会继续展开。container不变。函数组件本身不创建真实 DOM它只是“透传”了挂载目标。真正创建 DOM 的是组件返回的原生元素。函数组件内可以返回一个子树而这棵子树由 render 函数继续递归完成挂载。4. 完整 Demo嵌套函数组件和 props 传递现在写一个完整的例子验证函数组件的渲染能力。const TEXT_ELEMENT TEXT_ELEMENT; function createElement(type, props, ...children) { return { type, props: { ...props, children: children.map(child { if (typeof child string || typeof child number) { return { type: TEXT_ELEMENT, props: { nodeValue: String(child) } }; } return child; }) } }; } function render(vnode, container) { if (typeof vnode.type function) { const componentVnode vnode.type(vnode.props); render(componentVnode, container); return; } if (vnode.type TEXT_ELEMENT) { const textNode document.createTextNode(vnode.props.nodeValue); container.appendChild(textNode); return; } const dom document.createElement(vnode.type); Object.keys(vnode.props).forEach(key { if (key ! children) { dom[key] vnode.props[key]; } }); vnode.props.children.forEach(child { render(child, dom); }); container.appendChild(dom); }定义两个组件其中Inner被App引用function Inner(props) { return createElement(p, { className: inner }, 子组件内容 props.text); } function App(props) { return createElement(div, { className: app }, createElement(h1, null, props.title), createElement(Inner, { text: hello function component }) ); }挂载到页面const root document.getElementById(root); const appVnode createElement(App, { title: 从零手搓 React }); render(appVnode, root);执行流程分析createElement(App, { title: 从零手搓 React })得到一个type: App的 vnode。render 检测到typeof App function调用App(vnode.props)。App 返回一个type: div的 vnode且 children 包含 h1 和 Inner 组件的 vnode。render 继续处理div创建真实 div遍历 children。处理 h1 时创建真实 h1再处理其文本子节点创建文本节点挂载到 h1 下。处理type: Inner的 vnode 时再次走到函数分支调用Inner(vnode.props)。Inner 返回一个type: p的 vnoderender 创建 p并把文本内容挂载进去。所有真实 DOM 创建完成App 的 div 被附加到 root 上。页面最终结构div idroot div classapp h1从零手搓 React/h1 p classinner子组件内容hello function component/p /div /div一个包含嵌套函数组件、props 传递、文本插值的最小 React-like 实现已经完整跑通。5. 函数组件为什么不要直接操作 DOM实现到这里可以发现函数组件在整个流程里是一个纯计算单元。它的职责是接收 props返回 vnode。它不应该直接操作真实 DOM。这一点很重要。在我们的实现里函数组件甚至都没有收到container参数。调用vnode.type(vnode.props)时只传了 props函数内部能做的只有“计算返回结构”。真实 DOM 的创建和挂载全部交给 render 函数完成。这个设计带来三个好处复用性高。同一个函数组件可以在任何地方被使用不依赖具体的挂载容器。便于测试。因为函数组件是纯函数给定 props 就能预测返回值可以直接调用函数组件来断言输出 vnode 结构。后续扩展 Hooks 的基础。第 9 篇实现useState时需要在函数被调用前保存一个“当前正在渲染的组件实例”否则无法知道useState应该把状态存到哪个组件上。如果函数组件直接操作 DOM这个关联关系就很难建立。6. 函数组件返回多个根节点的问题一个容易忽略的细节是在我们的实现中函数组件必须返回单个 vnode。看一下代码const componentVnode vnode.type(vnode.props); render(componentVnode, container);如果组件返回的是数组比如function App() { return [ createElement(p, null, 段落1), createElement(p, null, 段落2) ]; }那么componentVnode是一个数组。render 函数收到数组后会尝试读取vnode.type得到undefined三个分支都不匹配代码直接崩溃。React 16 之前组件必须返回单个根节点。React 16 之后支持返回 Fragment 或者数组。但在我们的手写实现中目前只支持单根节点返回这符合开发阶段的最小化原则。如果确实要支持多根节点可以在 render 函数中增加数组分支if (Array.isArray(vnode.type)) { // 不推荐这里仅示意 }或者对函数组件返回值做判断if (typeof vnode.type function) { const componentVnode vnode.type(vnode.props); if (Array.isArray(componentVnode)) { componentVnode.forEach(child render(child, container)); } else { render(componentVnode, container); } return; }但这是优化项不是本篇必须。建议先保持单根节点的约束等后续实现 Fragment 或列表渲染时再统一处理。7. 函数组件与 props.children 的传递当我们手动调用createElement写嵌套结构时children 会通过createElement的第三个可变参数传入被挂到 props.children 上。比如function Card(props) { return createElement(div, { className: card }, props.children); } function App() { return createElement(Card, null, createElement(h2, null, 卡片标题), createElement(p, null, 卡片内容) ); }在 JSX 编译的视角来看Card h2卡片标题/h2 p卡片内容/p /CardCard组件的 props.children 是一个数组包含 h2 和 p 两个 vnode。在 render 函数处理Card组件时会调用Card(vnode.props)。Card 内部把props.children写在返回结果的 children 中。然后 render 继续处理这些子节点。这里有个容易混淆的概念函数组件的 children 和其他原生元素的 children在结构上没有区别它们都会被放到 props.children 中。区别只在于函数组件的 children 不能直接参与 DOM 创建它们必须经过“函数展开”这个步骤。也就是说如果一个函数组件内部不把props.children渲染出来那个位置的内容就不会出现在页面上。这个行为和 React 完全一致。8. 完成一次函数组件的渲染顺序验证为了确认递归顺序没有问题可以在 render 函数中加入日志打印function render(vnode, container) { console.log(render called, type , vnode.type); if (typeof vnode.type function) { console.log( expanding component); const componentVnode vnode.type(vnode.props); render(componentVnode, container); return; } if (vnode.type TEXT_ELEMENT) { const textNode document.createTextNode(vnode.props.nodeValue); container.appendChild(textNode); return; } const dom document.createElement(vnode.type); Object.keys(vnode.props).forEach(key { if (key ! children) { dom[key] vnode.props[key]; } }); vnode.props.children.forEach(child { render(child, dom); }); container.appendChild(dom); }渲染前面那个 Demo控制台输出的顺序是render called, type App expanding component render called, type div render called, type h1 render called, type TEXT_ELEMENT render called, type Inner expanding component render called, type p render called, type TEXT_ELEMENT这就是 React 函数组件“先展开、后渲染”的递归过程。观察这个输出顺序可以确认组件展开发生在子节点渲染之前整个流程符合预期。9. 边界情况函数组件返回 null实际开发中组件经常需要条件渲染。当条件不满足时组件返回null。React 中组件返回 null 表示什么也不渲染。在我们的最小实现里如果组件返回 nullfunction MaybeRender(props) { if (!props.visible) { return null; } return createElement(p, null, 显示内容); }render 函数的函数分支会执行const componentVnode vnode.type(vnode.props);得到null。然后继续执行render(null, container)程序会尝试读取vnode.type报错。处理方式是在函数分支中增加 null 判断if (typeof vnode.type function) { const componentVnode vnode.type(vnode.props); if (componentVnode null) { return; } render(componentVnode, container); return; }这一步虽然不是最核心的功能但加入之后可以让组件支持条件渲染。建议在实现函数组件时一并处理。10. 常见的 6 个坑与排查方法手写函数组件时最容易遇到下面这 6 个问题。问题现象可能的代码原因排查思路解决方案document.createElement(App) is not a function或创建 DOM 报错render 函数没有判断函数类型直接把函数当字符串标签使用检查 vnode.type 的类型在 render 开头打印 type在 render 开头增加typeof vnode.type function分支组件渲染结果空白函数组件返回了数组或 nullrender 没有处理在函数分支打印组件返回值加 null 判断暂不支持数组时限制单根返回props 拿不到调用组件时没有传 props检查createElement(App, { ... })的第二个参数调用组件时写vnode.type(vnode.props)不能省参数children 没有渲染函数组件内没有把 props.children 放入返回 vnode检查组件内部返回结构将props.children显式放到返回元素中组件内 props 是 undefined调用函数时写的是vnode.type()检查组件调用语句必须写vnode.type(vnode.props)页面渲染了但结构少了一层组件被错误当成原生元素处理打印 vnode.type 是否为函数确保函数组件分支在原生元素分支之前判断其中第 3 个坑是最常见的。很多人实现了函数组件分支但调用时写成了const componentVnode vnode.type();这样组件内部的 props 就是undefined访问props.title会直接抛异常。11. 如何验证你的实现是正确的作为一个从零手写的项目验证结果不能只看“页面显示了”。要额外检查以下 4 个维度。结构正确性。对比最终 DOM 结构是否符合预期。可以打开浏览器开发者工具查看元素面板。组件复用性。把同一个组件放到页面两个位置传入不同 props确认都能正常渲染。如果组件逻辑里不小心混入了内部状态第二次渲染可能出问题。function App() { return createElement(div, null, createElement(Inner, { text: 第一次调用 }), createElement(Inner, { text: 第二次调用 }) ); }嵌套深度。测试 3 层以上的组件嵌套确认递归没有爆栈。function Level3() { return createElement(p, null, 第三层); } function Level2() { return createElement(Level3, null); } function Level1() { return createElement(Level2, null); }文本插值。确认字符串、数字类型的子节点都能正常转换为文本节点。function App() { return createElement(div, null, 字符串文本, createElement(span, null, 42) ); }12. 为什么这一篇是后续 Hooks 实现的地基最后说一个容易被忽略但关键的点函数组件的展开时机决定了 Hooks 状态的关联方式。React 源码中函数组件调用时React 会记录“当前正在渲染的 fiber 节点”。当组件内部调用useState时React 知道这个 state 应该挂在当前 fiber 上。在我们的手写实现里函数调用的核心代码就在这里const componentVnode vnode.type(vnode.props);如果后续要实现useState需要把这一行改成类似这样的结构// 伪代码示意 Hooks 关联组件实例的思路 currentComponent vnode; const componentVnode vnode.type(vnode.props);useState内部就可以读取currentComponent来保存或读取状态。所以理解今天这个展开点的位置比单纯“能跑通”更重要。后续第 9 篇实现useState、第 10 篇实现更新调度时都会回到这个位置上做文章。13. 总结与下一步本篇完成了以下内容手写了一个支持函数组件的 render 函数核心代码只有两行新增逻辑。跑通了嵌套函数组件、props 传递、children 透传、文本插值。展示了函数组件展开与递归渲染的执行顺序。处理了组件返回 null 的条件渲染边界。整理了 6 个最容易踩的坑及对应排查方法。目前这个实现仍然是最小可用的版本还不支持组件状态的更新。useState等 Hooks。事件绑定的持久化。组件复用时的 diff 更新。下一步建议先实现useState。它是函数组件从“静态模板”走向“动态交互”的关键一步。实现时重点思考状态存在哪里组件重渲染时如何拿到上次的状态组件的调用函数在什么位置可以被拦截把这些关键点想清楚React 核心机制的理解会比单纯调 API 深刻得多。建议把今天的实现保存为一个独立文件后续 Hooks 的实现会在这个文件基础上继续扩展。
返回列表