资讯动态

手写迷你React:函数组件的运行时原理与从零实现

发布时间:2026/8/30 4:39:46 来源:尧图企业网站定制
很多 React 学习者都会背这样一道面试题函数组件和类组件有什么区别标准答案通常是函数组件没有生命周期、没有 this、性能更轻、hooks 出现后能替代类组件。但如果你只是背到这里面试官再追问一句“React 在运行时到底是怎么区分函数组件和普通 HTML 标签的”很多人就会卡住。这篇文章不打算从 API 层面讲而是继续“关闭 AI 从零手搓 React”系列的路线从零实现一个能真正渲染 function component 的迷你 React。当你亲手写出typeof vnode.type function这个判断时再看面试题会清楚很多函数组件的本质就是一个接收 props、返回虚拟 DOM 的普通函数React 只是多了一步“调用它”。读完本文你会得到三样东西第一一个基于 Vite 的最小 React 实现支持函数组件、props、children、事件绑定第二函数组件、类组件、原生标签在运行时分别走哪条路的完整认知第三后续实现 hooks 和 Fiber 架构之前必须先想清楚的设计问题。1. 为什么这一篇要单独讲 function component先说结论函数组件不是一个简单的语法选择它是 React 从“类组件时代”走向“函数式时代”的分水岭。类组件通过this.setState和生命周期方法描述 UI 变化函数组件则通过“输入 props、输出 vdom”这个纯函数模型来描述 UI。从 React 16.8 开始函数组件配合 hooks 成为官方推荐写法类组件逐渐被边缘化。如果你现在去搜索 React 面试题几乎每篇都会出现“函数组件和类组件的区别”这类题目可见它在工程中的接受度已经非常高。但为什么一篇“手写 React”的教程要单独把 function component 拿出来讲因为从零实现的难度在这里发生了第一次跳变。前面几集如果只处理createElement(div, { className: app }, hello)这种原生标签render 函数只需要做document.createElement(type)就够了。但一旦遇到createElement(App, { name: Jack })type 不再是字符串div而是一个函数App此时document.createElement(App)会直接报错。所以我们必须给 render 增加一条新的分支判断 type 的类型如果是函数就调用它拿到返回的虚拟 DOM然后继续递归处理。这个分支就是 function component 在运行时层面最重要的代码逻辑。从工程上看理解这个调用链还能解释很多日常疑惑为什么函数组件必须大写开头因为 JSX 编译时小写开头的标签会被当作字符串div处理只有大写开头的标签才会被当作变量也就是函数或类传给 createElement为什么函数组件内部不能条件执行 hooks因为这背后依赖 render 阶段对 hook 顺序的假设一旦条件执行顺序变化后续所有 hook 都会错位。一句话函数组件的“简单”是编译器和运行时的约定共同塑造的。本文要做的就是把这两层约定拆开给你看。1.1 函数组件与类组件、原生标签的对比节点类型type 的值React 运行时处理方式返回内容HTML 标签字符串如divdocument.createElement(type)真实 DOM 节点函数组件普通函数调用type(props)拿返回的 vdom 继续递归虚拟 DOM 节点类组件类new type(props)再调用render()拿 vdom虚拟 DOM 节点这张表看懂了React 渲染的最小模型基本就掌握了一半。后面的 Fiber 架构、hooks、Concurrent 模式都是在“如何拿到最终 vdom 并转成 DOM”这条主线上做增量优化。2. 从零实现 React 的最小架构回顾在写出函数组件支持之前先快速回顾一下我们这个系列目前已经实现的核心模块。因为新代码是在旧代码上扩展不是从零重写。目前我们需要三个基础能力createElement(type, props, ...children)把 JSX 编译后的调用转换成虚拟 DOM 对象。这个对象通常长这样{ type: div, props: { className: app, children: [] } }。createTextElement(text)把字符串和数字类型的孩子转成文本节点 vdomtype 使用TEXT_ELEMENT作为标记。render(vnode, container)接收虚拟 DOM递归创建真实 DOM最后挂载到 container 上。这里有一个关键约定虚拟 DOM 对象里不直接存函数组件本身而是存在type字段上。type可能是字符串也可能是函数。这个约定非常重要因为它是运行时判断分流的基础。2.1 为什么需要虚拟 DOM 这一层很多人会问为什么要先构造虚拟 DOM再递归生成真实 DOM直接写 JS 操作 DOM 不是更快吗从功能上讲如果没有后续的更新机制虚拟 DOM 确实显得多此一举。但 React 的价值不在于首次渲染而在于状态变化后的更新协调。当状态改变时React 需要知道“旧 UI 是什么、新 UI 是什么、哪些地方变了”虚拟 DOM 就是做这个 diff 的输入。函数组件之所以能“重新执行一遍就得到新 UI”靠的也是虚拟 DOM 这个中间层的确定性同样的 props 输入函数组件返回结构相同的 vdomReact 再决定如何最小化更新真实 DOM。所以在第 08 集我们的核心不是实现 diff而是先把“函数组件返回 vdom”这条链路走通。只有链路通了后面做更新协调才有数据源。3. 核心概念函数组件的本质是什么如果现在让你用一句话解释函数组件最好不要说“它是一个返回 JSX 的函数”而应该说它是一个接收 props 对象返回虚拟 DOM 对象的纯函数。这个定义里有三个重点“接收 props 对象”意味着组件永远不应该修改 propsprops 是父组件传给它的快照。“返回虚拟 DOM 对象”意味着组件本身不直接触碰真实 DOM所有 DOM 操作都交给 render 阶段。“纯函数”意味着同样的 props应该得到同样的 vdom这样 React 才能安全地重复执行组件函数。3.1 从真实代码看 JSX 编译结果假如我们写一段 JSXfunction Greeting({ name }) { return div classNamegreetingHello, {name}!/div }在 Vite esbuild 的 JSX 转换配置下这段代码大致会被编译成function Greeting({ name }) { return createElement(div, { className: greeting }, Hello, , name, !) }注意组件函数还是那个函数JSX 只是语法糖真正传给 createElement 的第一个参数type是一个字符串div。也就是说Greeting 函数体内部的 JSX 编译结果仍然是原生标签。再看使用场景const vnode createElement(Greeting, { name: Jack })这里createElement的第一个参数是 Greeting 函数本身。所以最终的 vnode 长这样{ type: Greeting, // 是一个函数不是字符串 props: { name: Jack, children: [] } }这就是函数组件和原生标签在虚拟 DOM 层面的唯一区别type 字段的类型不一样。3.2 新手最容易误解的地方很多新手会误以为函数组件在 JSX 里写Greeting /最终会被编译成div之类的标签。实际上编译器不会展开函数组件它只是把Greeting作为引用传给 createElement。真正“展开”函数组件的是 render 阶段。这就解释了一个很经典的报错如果你在 render 里写死document.createElement(vnode.type)遇到函数组件时浏览器会告诉你 “createElement is not a function” 或者直接报非法标签。因为document.createElement只接受字符串标签名。我们现在的任务就是在 render 的 createDom 流程里增加一个“如果 type 是函数就调用它”的分支。4. 环境准备与项目结构这一节开始进入实操。为了把注意力放在 React 运行时实现上我们用一个尽可能小的工具链Vite 负责开发服务器和 JSX 编译我们不引入任何 react、react-dom 依赖。所有代码手写。版本方面本文使用 Vite 5 Node.js 18 可以跑通Vite 6 同样适用。如果你用的版本不同配置字段可能略有差异以你本地的实际报错提示为准。4.1 项目结构mini-react/ ├── index.html ├── package.json ├── vite.config.js └── src/ ├── main.jsx └── mini-react.jsmini-react.js我们自己实现的 createElement、createTextElement、createDom、render。main.jsx使用函数组件和 JSX 的入口文件。index.html页面入口。vite.config.js配置 JSX 编译时使用的工厂函数。4.2 package.json 与 vite.config.jspackage.json不需要任何第三方运行时依赖只需要 devDependencies 里的 vite。{ name: mini-react, private: true, version: 0.1.0, type: module, scripts: { dev: vite, build: vite build, preview: vite preview }, devDependencies: { vite: ^5.4.0 } }安装依赖时注意本文没有使用 vitejs/plugin-react因为插件会默认注入 react/jsx-runtime把 JSX 编译到 React 自己的运行时里这不是我们想要的。我们希望 JSX 编译后调用的是自己写的 createElement。所以在vite.config.js里需要把 esbuild 的jsxFactory指向我们自己的函数// 文件路径mini-react/vite.config.js import { defineConfig } from vite export default defineConfig({ esbuild: { jsxFactory: createElement } })这个配置告诉 ViteJSX 语法编译后请调用名为createElement的函数。因为我们没有使用自动注入所以需要在每个使用 JSX 的 jsx 文件顶部显式导入 createElement。相对地也可以使用jsxInject自动注入但显式导入在版本变化时更少踩坑。4.3 index.html 入口!-- 文件路径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/src/main.jsx/script /body

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价