ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从零手搓React:实现函数组件与渲染流程

从零手搓React:实现函数组件与渲染流程 这一篇是这个“从零手搓 React”系列的第 8 篇。前面我们已经实现了 createElement、render、属性挂载、事件绑定这些最基础的渲染能力但有一个绕不开的问题目前只能渲染div、span、p这类原生 DOM 标签。也就是说我们还没有真正意义上实现“组件”。所以这篇文章要补齐这块关键能力实现简单的 function component。我会先解释函数组件在 React 中的定位和本质再回到我们现有 Mini React 的渲染流程里看看它为什么无法渲染函数组件接着给出完整的改造方案和可直接运行的 Demo最后补充常见问题与工程建议。如果你正在学习 React 源码、想弄清楚 JSX 到底编译成了什么或者打算后面继续研究 hooks、Fiber、diff 算法这篇文章都值得先收藏。1. 背景与核心概念在 React 里页面由组件组成。组件可以是类组件也可以是函数组件。类组件需要继承React.Component并实现render方法函数组件则更轻量本质上就是一个接收props、返回 React 元素的普通函数。早期函数组件有一个别名叫做“无状态组件”因为 React 16.8 之前函数组件不能使用state只能接收props做纯展示。后来 Hooks 诞生函数组件才拥有了useState、useEffect等能力逐渐成为 React 社区的主流写法。要理解我们这篇实现的核心有一个关键点必须明确函数组件本身并不产生 DOM 节点它只负责返回一个虚拟 DOM 结构vnode最终由 React 把这份 vnode 递归渲染成真实 DOM。这句话怎么理解举个例子function Hello(props) { return h1Hello, {props.name}/h1 }这里的Hello函数被调用后并不会直接创建一个h1DOM 元素。它返回的只是{ type: h1, props: { children: [Hello, , 小明] } }而这个对象跟我们手写createElement(h1, null, Hello, 小明)得到的结果没有本质区别。所以从实现角度讲函数组件就是一个“vnode 生成器”。JSX 编译规则也体现了这一点。在 Babel 编译下App /会被编译成createElement(App, null)注意这里的type是App这个函数本身而不是字符串app。而div /会被编译成createElement(div, null)这里的type是字符串div。这个差异正是我们判断“当前 vnode 是原生 DOM 节点还是函数组件”的依据。JavaScript 中typeof可以帮我们区分typeof div string说明是原生 DOM 标签typeof App function说明是函数组件所以在渲染时如果发现vnode.type是一个函数我们不能直接执行document.createElement(vnode.type)正确做法是调用这个函数拿到它返回的 vnode再继续处理。这一个判断就是支持函数组件的最小核心。2. 回顾当前 Mini React 的渲染流程在动手改造之前我们先看看这个系列目前已经实现的渲染逻辑。为了不让没有读过前文的读者断档我把关键代码重新贴一遍。我们的 Mini React 目前由两个核心函数组成。第一个是createElement它负责把 JSX 编译结果转换成统一的 vnode 对象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: [], }, } }第二个是render它负责把 vnode 递归渲染成真实 DOM 并挂载到容器上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) Object.keys(vnode.props) .filter(key key ! children) .forEach(name { if (name className) { dom.setAttribute(class, vnode.props[name]) } else if (name.startsWith(on)) { const eventType name.toLowerCase().substring(2) dom.addEventListener(eventType, vnode.props[name]) } else { dom.setAttribute(name, vnode.props[name]) } }) vnode.props.children.forEach(child { render(child, dom) }) container.appendChild(dom) }这段代码看起来已经能完成很多工作了但有一个致命问题document.createElement(vnode.type)这一行要求vnode.type必须是有效的 HTML 标签名。当我们写下这样的代码function Welcome(props) { return h1Hello, {props.name}/h1 } const vdom Welcome name小明 / render(vdom, document.getElementById(root))Babel 会把Welcome name小明 /编译成createElement(Welcome, { name: 小明 })此时 vnode 长这样{ type: Welcome, // 一个函数 props: { name: 小明, children: [], } }接着render执行到document.createElement(Welcome)浏览器可不知道Welcome是什么标签它只会创建一个不认识的HTMLUnknownElement。页面看起来是空的控制台也没报错问题却真实存在。我们需要的改动其实很小在创建 DOM 之前先判断type是不是函数如果是就调用它拿到函数返回的 vnode然后继续渲染。下面进入核心实现。3. 核心原理如何识别并渲染函数组件既然核心判断只有一行typeof vnode.type function为什么还要单独花一节讲原理因为实际工程里组件不是只有一层它会嵌套使用。我们在处理函数组件时要考虑的很可能是“组件返回的 vnode 里又包含组件”的情况。3.1 单层函数组件怎么渲染先看最简单的情况function App() { return divHello/div }App /编译成 vnode 后type是App函数。渲染时我们只需要做一件事vnode vnode.type(vnode.props)这样就把App()的返回值拿到了{ type: div, props: { children: [Hello], }, }拿到这个对象后原有render逻辑就可以继续工作。所以最朴素的做法就是在render开头加上一层判断function render(vnode, container) { if (typeof vnode.type function) { vnode vnode.type(vnode.props) } // 后续逻辑保持不变 }3.2 为什么需要循环而不是单层 if如果函数组件返回的 vnodetype仍然是函数呢比如function A() { return B / } function B() { return divB 组件/div }当render处理A /转换出的 vnode 时调用A()后得到{ type: B, props: { children: [] }, }如果只用一次if拿到这个 vnode 后继续往下走又会执行document.createElement(B)再次出现未知标签问题。因此需要循环处理直到vnode.type不是函数为止function resolveComponent(vnode) { while (typeof vnode.type function) { vnode vnode.type(vnode.props) } return vnode }这个while会不断解包函数组件直到遇到原生 DOM 标签或文本节点。需要注意这里没有把“一层组件的解包”独立成递归函数而是用循环因为起点很明确根节点是函数组件就一直调到它返回 DOM 标签节点为止。至于函数组件返回的 children 里还有别的组件那会在render递归处理 children 时再次进入resolveComponent所以不会漏掉嵌套层。3.3 为什么函数组件中可以用 props.children在 React 中组件标签内部的内容会作为children传给子组件。例如function Card(props) { return div classNamecard{props.children}/div } function App() { return ( Card p卡片内容/p /Card ) }这里的编译产物是createElement(Card, null, createElement(p, null, 卡片内容))createElement把p卡片内容/p放进children数组里而Card函数的props.children就是这个数组。所以Card内部可以在任意位置渲染props.children。这其实暴露了一个事实函数组件对children的处理是透传的。我们不需要在当前 Mini React 中做特殊处理只要保持原有递归渲染 children 的逻辑不变这个能力就天然具备。3.4 函数组件本身不产生 DOM这是初学者很容易误解的一点。function UserInfo(props) { return ( div h2{props.name}/h2 p{props.job}/p /div ) }渲染UserInfo name小明 job前端 /时最终形成的 DOM 结构是div ├── h2 │ └── 小明 └── p └── 前端也就是说框架不会为UserInfo这个组件本身创建一个UserInfoDOM 标签。它会被“展平”最终呈现的只有组件内部返回的原生 DOM 标签。这一点也可以反推我们实现的resolveComponent循环本质上就是在做展平操作。它不断把组件 vnode 替换成组件返回的 vnode直到遇到原生 DOM 标签。4. 完整实战让 Mini React 支持函数组件理论说完了接下来进入实战。我会给出一份完整可运行的 HTML 文件你不需要安装任何构建工具只需要保存为.html文件用浏览器打开就能看到效果。4.1 创建示例文件先创建一个文件比如minireact-function-component.html把下面代码完整复制进去。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / titleMini React - 函数组件实战/title style .card { border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin: 12px 0; background: #fafafa; } .title { color: #2c3e50; } /style /head body div idroot/div !-- 引入 Babel Standalone让浏览器可以直接解析 JSX -- script srchttps://unpkg.com/babel/standalone/babel.min.js/script script typetext/babel // 1. createElement 与 createTextElement 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: [], }, } } // 2. 支持函数组件的 render function resolveComponent(vnode) { while (typeof vnode.type function) { vnode vnode.type(vnode.props) } return vnode } function render(vnode, container) { // 先解包函数组件 vnode resolveComponent(vnode) // 文本节点直接创建 if (vnode.type TEXT_ELEMENT) { container.appendChild( document.createTextNode(vnode.props.nodeValue) ) return } // 原生 DOM 元素 const dom document.createElement(vnode.type) // 处理 propsclassName、事件、普通属性 Object.keys(vnode.props) .filter(key key ! children) .forEach(name { if (name className) { dom.setAttribute(class, vnode.props[name]) } else if (name.startsWith(on)) { const eventType name.toLowerCase().substring(2) dom.addEventListener(eventType, vnode.props[name]) } else { dom.setAttribute(name, vnode.props[name]) } }) // 递归渲染子节点 vnode.props.children.forEach(child { render(child, dom) }) container.appendChild(dom) } // 3. 定义函数组件 // Card 组件使用 props.children 实现“插槽”效果 function Card(props) { return ( div classNamecard {props.children} /div ) } // UserInfo 组件接收 props 并透传给内部元素 function UserInfo(props) { return ( Card h2 classNametitle{props.name}/h2 p职业{props.job}/p p城市{props.city}/p /Card ) } // App 根组件组合多个 UserInfo function App() { return ( div h1Mini React - 函数组件 DEMO/h1 UserInfo name小明 job前端工程师 city杭州 / UserInfo name小红 job后端工程师 city上海 / /div ) } // 4. 渲染到页面 const container document.getElementById(root) const vdom App / render(vdom, container) /script /body /html4.2 运行与预期结果用浏览器打开这个 HTML 文件页面渲染结果大致如下第一行一级标题Mini React - 函数组件 DEMO下面两张卡片第一张卡片标题小明职业前端工程师城市杭州第二张卡片标题小红职业后端工程师城市上海如果没有看到这些内容多半是浏览器没有联网加载 Babel Standalone或者文件里的babel/standalone地址不可用。可以检查一下网络或者把 JS 部分拆出来本地编译。这套实现里App、UserInfo、Card都是函数组件它们都被我们的resolveComponent成功解包。Card组件则演示了props.children的透传能力UserInfo内部把一段 JSX 传给CardCard又把它渲染到div classNamecard内部。4.3 不用 JSX看看编译产物长什么样对于想深入理解 React 原理的读者我建议你用“手写 createElement 调用”的方式再来一遍。比如把文章开头那份 HTML 中渲染部分改成const vdom createElement(App, null) render(vdom, document.getElementById(root))这个写法和App /完全等价。你会发现createElement的type参数传的是App这个函数引用而不是字符串。再看一个稍微复杂一点的例子const vdom createElement( UserInfo, { name: 小明, job: 前端工程师, city: 杭州 } )等价于 JSXUserInfo name小明 job前端工程师 city杭州 /所以函数组件的本质就是“一段返回 vnode 的函数”。JSX 只是把函数的调用伪装成了类似 HTML 的写法真正干活的是 createElement 和 render。4.4 如果组件只有一层可以直接在 render 里写 if有的读者可能觉得resolveComponent里的while有点复杂那我退一步用最朴素的写法也能实现function render(vnode, container) { if (typeof vnode.type function) { vnode vnode.type(vnode.props) } // ... 后续代码 }这个写法对于单层组件是有效的但一旦出现组件嵌套组件就会失败。因为第一次调用组件后返回值可能还是组件 vnode但此时只做了一次判断后面直接就创建 DOM等于还是没处理干净。所以我更推荐保留while循环代码量并没有增加多少却能覆盖更多场景。5. 常见问题与排查思路实现过程中很容易踩到几个典型的坑。我把它们整理成一张表并逐一说明排查方式。问题现象常见原因解决思路页面空白控制台无报错组件命名小写被当作字符串标签将组件名改成大写开头报错vnode.type is not a function组件内部返回了 undefined 或错误结构检查组件是否有return且返回的是 JSX函数组件嵌套时内容不显示只做了一次if判断没有解包到底使用while循环代替ifprops.children 不显示忘记在组件内部渲染props.children在合适位置使用{props.children}组件渲染出未知自定义标签vnode.type是字符串而非函数检查组件名是否大写JSX 编译规则导致下面挑选几个重点展开。5.1 组件名必须大写开头JSX 编译有一个硬性规则首字母小写的标签会被当作字符串传入createElement首字母大写的标签才会被当作变量引用。例如// 错误写法小写开头 userInfo name小明 /会被编译成createElement(userInfo, { name: 小明 })这里type是字符串userInfo浏览器不认识这个标签最终会创建一个自定义的HTMLUnknownElement样式、内容都会异常。正确的写法是UserInfo name小明 /编译成createElement(UserInfo, { name: 小明 })所以在使用函数组件时请务必保证组件名大写开头。5.2 函数组件必须返回 vnode如果你写了这样的组件function App() { const title Hello }忘了写return那么调用App()时得到的是undefined。随后resolveComponent会返回undefinedrender里访问vnode.type就会直接报错TypeError: Cannot read properties of undefined (reading type)同样如果返回的是字符串或者数字也会导致后续逻辑异常。React 要求函数组件必须返回可渲染的 vnode 结构或者返回null表示不渲染。在我们这个简化版中默认组件必须返回一个 vnode 对象。5.3 组件嵌套后仍然空白如果你写的是function render(vnode, container) { if (typeof vnode.type function) { vnode vnode.type(vnode.props) } // 后续创建 DOM 的逻辑 }并且组件层级是function A() { return B / } function B() { return divB 内容/div }那么第一次调用A()后vnode.type变成了B因为只做了一次判断后面就直接执行document.createElement(B)这显然是不对的。所以对于这种嵌套场景必须使用循环或者借助递归。我们的resolveComponent使用while一次解包到底解决了这个问题。5.4 children 透传导致用户困惑有的读者第一次接触这种写法function Card(props) { return div classNamecard{props.children}/div }会疑惑为什么 Card 自己没有收到 name、job 这些 props原因是这些 props 传给了UserInfo而UserInfo内部把剩余内容作为 children 传给了Card。展开看到的真实编译结果如下createElement( UserInfo, { name: 小明, job: 前端工程师, city: 杭州 } )在函数组件内部props就包含了name、job、city但并没有一个叫children的属性因为标签之间没有写任何子节点。如果写成UserInfo name小明 p额外内容/p /UserInfo那么props.children就是p额外内容/p对应的 vnode。5.5 JSX 语法错误怎么排查使用 Babel Standalone 时如果 JSX 语法有误页面会直接报错通常提示SyntaxError。排查时可以这样做打开浏览器开发者工具的Console看有没有红色报错。把组件代码简化逐步注释定位到出错的组件。检查 JSX 标签是否闭合尤其是自闭合标签img /、input /。Babel 在浏览器里编译 JSX 虽然方便但性能较差只适合学习 Demo。生产环境还是需要构建工具提前编译。6. 最佳实践与工程建议虽然我们只是实现了一个玩具 React但函数组件的写法、边界和设计思路是 React 开发真正会用到的东西。下面几点建议无论是对这个系列还是对你后续阅读源码、写业务组件都有参考价值。6.1 组件拆分遵循“单一职责”App负责页面整体结构UserInfo负责展示用户信息Card负责卡片容器。每一层组件只做自己这一层该做的事。这个原则在处理真实项目时尤其重要组件不会因为需求迭代而迅速膨胀。好的拆分标准是一个组件能够用一句话说清楚它是干什么的。如果说不清楚那说明职责不单一。6.2 props 应当被当作只读数据在 React 中组件不能修改自己的 props。如果你在函数组件内部写props.name x这在 React 严格模式或后续更新机制中会引起不可预期的渲染问题。这一点在真实 React 中有严格限制在我们的简化版中虽然没有强制但你应该养成这个习惯。如果确实需要修改数据应该等后续实现useState时通过 state 来管理。6.3 使用 props.children 做布局组件布局类组件比如卡片、弹窗、页面容器非常适合使用props.children。这样上层组件可以决定传入什么内容而布局组件只负责“放在哪里”。我们的Card组件就是典型例子。它不关心内容具体是什么只负责给内容加一个边框和背景。这种组合模式在真实 React 项目中非常常见。6.4 理解组件渲染过程便于后续学习 Fiber现在我们的render是递归创建 DOM。React 16 之后引入了 Fiber 架构把渲染拆分成可中断的单元任务但核心依然离不开“遇到函数组件就调用函数获取子 vnode”这一步。如果你在真实 React 中遇到渲染问题脑子里要有这条链路触发渲染 - 组件函数执行 - 返回 vnode - 对比上一棵 vnode 树 - 更新真实 DOM在本系列后面我们会逐步引入更新调度、diff、Fiber 链表等机制函数组件就是这条链路的第一个环节。6.5 组件层级过深时考虑组合而不是多层透传如果组件嵌套太深props 一层一层往下传会很痛苦。真实的 React 项目里会用 Context 或状态管理库解决。这个话题在早期可以不展开但要意识到这个设计问题。后续我们也许会在系列中实现一个简化版的 Context 或发布订阅机制。6.6 生产环境不要依赖 Babel Standalone本文为了演示方便在浏览器里直接用babel/standalone编译 JSX。真实项目应该使用 Vite、Webpack 等构建工具把 JSX 编译放在构建阶段。这样做有三个好处浏览器加载更小、更快。编译错误可以在开发阶段暴露。代码拆分和资源压缩都更方便。6.7 事件绑定与属性处理的边界我们在render中处理属性的方式非常粗糙。实际项目中属性处理还应该考虑style对象转成行内样式htmlFor转成forkey、ref这类特殊 prop 不能直接挂到 DOM 上布尔属性如disabled的处理这些内容更适合放到属性处理专题中展开。当前阶段只要能理解函数组件如何解包目标就已经达成了。7. 总结与后续学习路线这篇文章从“函数组件本身不产生 DOM只返回 vnode”这个核心概念出发分析了 React 中函数组件与原生 DOM 标签在 vnode 结构上的区别最终通过一个resolveComponent函数让我们的 Mini React 能够递归解包函数组件从而渲染出真实 DOM。核心要点可以概括为vnode.type是字符串说明是原生 DOM 标签直接创建元素。vnode.type是函数说明是函数组件调用它获取新的 vnode。组件嵌套组件时需要循环解包直到遇到原生 DOM 标签。函数组件通过props接收外部数据通过props.children渲染子内容。这一步做完之后你的 Mini React 已经具备最基本的组件化能力了。但离真实的 React 还有很远后续至少还有这些方向可以继续深入实现useState让函数组件拥有内部状态。实现useEffect处理副作用。引入 Fiber 架构支持可控的更新调度。实现 diff 算法让组件更新时只修改变化部分。支持类组件理解两种组件在渲染流程上的差异。下一篇建议先实现useState。只有拥有了状态函数组件才能真正做到“根据状态改变渲染结果”也才能触达 React 18 中讨论度很高的更新批处理batching机制。理解批处理机制需要先理解单个组件的更新流程而当前这篇文章的函数组件渲染逻辑正是整个更新机制的起点。
返回列表