从零手写mini-react

前言

感谢 Build your own React 的作者 Rodrigo Pombo,他用不到 300 行代码加上优雅丝滑的动画效果,把 React 内核讲得清清楚楚。这篇博客沿袭了他的思路,并引用了部分原文内容。遵循真实React代码的架构,但没有所有优化和非必需功能,一步步打造出 MiniReact。

说明:本项目是对 React 极简化版的实现。真实 React 拥有合成事件、优先级调度、并发特性、服务端渲染等工业级能力,MiniReact 仅聚焦最核心的设计思想。

通过手写 MiniReact,理解虚拟 DOM 到真实 DOM 的转换、Fiber 链表如何实现可中断渲染、调和(reconciliation)如何复用节点、函数组件的本质,以及 useState 背后的闭包原理------这些正是真实 React 运行机制的基石。

一、初始化项目

我们使用 Vite 创建一个原生 JS 项目,然后用我们自己写的 mini-react 来驱动页面。

bash 复制代码
pnpm create vite@latest my-react -- --template vanilla
  1. 在根目录下创建 main.jsMiniReact文件夹
  2. 在MiniReact文件夹下创建入口文件index.js
js 复制代码
// 将来这里将会导入所有的函数,比方render、createElement

// MiniReact这个对象用于存放上面导入的函数,将来想要使用直接:MiniReact.render()
const MiniReact = {}; 

// 对外暴露MiniReact的入口文件
export default MiniReact;
  1. index.html改成:
html 复制代码
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Mini React</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./main.js"></script>
  </body>
</html>
  1. 删除Publicsrcmain.js最终项目结构如下:
tcl 复制代码
my-react/
├── MiniReact/
│   └── index.js
├── node_modules/
├── .gitignore
├── index.html
├── main.js
├── package.json
└── pnpm-lock.yaml
  1. 运行 pnpm run dev将初始化项目跑起来

二、核心工作流程

在动手之前,我们先梳理一下 React 的核心工作流程:

  1. JSX 转换<div>hello</div>React.createElement('div', null, 'hello') → 虚拟 DOM 对象
  2. 渲染阶段 (Render Phase) :将虚拟 DOM 转换成 Fiber 树,可中断
  3. 提交阶段 (Commit Phase):将 Fiber 树同步渲染到真实 DOM
  4. 更新阶段 :状态变化触发重新渲染,通过调和 (Reconciliation) 找出最小变更

我们的 mini-react 将分成以下八步(跟Pombo保持一致),完整实现上述流程:

  • createElement
  • render函数
  • Concurrent Mode 并发模式
  • Fiber架构
  • Render and Commit 渲染和提交
  • Reconciliation调和
  • FC函数组件
  • Hooks实现

三、createElement

我们写的 JSX 语法,浏览器无法直接识别。Babel 会在编译阶段,将其转换为 React.createElement 函数调用。

jsx 复制代码
// 我们写的 JSX
const element = <h1 title="hello">Hello MiniReact</h1>

// Babel 编译后的真实代码
const element = React.createElement("h1", { title: "hello" }, "Hello")

React.createElement 的核心作用,就是将入参转换为一个标准的 Element(元素)对象 ------ 这是 React 描述 DOM 结构的基础,本质是一个普通 JS 对象,核心包含两个属性

  • type :一个字符串,用来指定 DOM 节点类型,比如 h1div等。
  • props :一个对象,用来存储JSX的所有属性(比如id、style),同时包含特殊属性 children
    • children 用来描述当前元素的子节点:本质上是字符串或元素数组。因此 Element 天然是一个树形结构,完美对应真实 DOM 的层级关系。

理解了 Element 的本质,我们就可以动手实现自己的 createElement ,生成符合 React 规范的 虚拟DOM对象

js 复制代码
{
  type: "h1",
  props: {
    title: "hello",
    children: "Hello"
  }
}

MiniReact文件夹下创建createElement.js

js 复制代码
/**
 * Element元素分为纯文本和对象,因此需要两个函数来处理
 *  - 1:createTextElement
 *  - 2:createElement
 */

// (1)创建纯文本元素
function createTextElement(text) {
    return {
        type: 'TEXT_ELEMENT',  // 👈== 写死
        props: { nodeValue: text, children: [] },
    }
}

// (2)创建普通元素
function createElement(type, props, ...children) {
    return {
        type,
        props: { ...props, children }
    }
}
export { createTextElement, createElement };
export default createElement;

createElement导入到入口文件index.js

js 复制代码
import createElement, { createTextElement } from "./createElement";
const MiniReact = {
    createElement,
    createTextElement
}
export default MiniReact;

在根目录下的index.js中引入并使用

js 复制代码
import MiniReact from "./MiniReact";
// 这里就跟React.createElement一样了,只不过名字是MiniReact
const element = MiniReact.createElement(
    'h1',
    { id: 'title', style: "background:red" },
    'Hello World',
    MiniReact.createElement('div', { id: 'name', style: "background:blue;" }, '---MiniReact')
)

// 打印看看长啥样
console.log(element)

在浏览器控制台看看结果

💡 思考 :这就是 JSX 被 Babel 编译后的结果。所以 React 可以不使用 JSX,直接手写 createElement

有一个关键细节需要注意:createElement的children属性我们是直接赋值的,但是我们知道children存放的是另外的element元素,而element元素又分为纯文本和对象,而纯文本元素又有单独的创建方法:createTextElement,所以需要统一处理一下:

js 复制代码
// 更新createElement方法
function createElement(type, props, ...children) {
    return {
        type,
        props: {
            ...props,
            // 某一个children元素是对象就直接用,否则创建纯文字节点
            children: children.map(child => {
                if (typeof child === "object") return child;
                else return createTextElement(child);
            })
        }
    }
}

四、render

render作用:将虚拟 DOM渲染成浏览器可识别的真实 DOM,并挂载到页面容器上 。最简单最直观的实现方式是递归渲染

MiniReact文件夹下创建render.js

js 复制代码
function render(element, container) {
    // (1)节点
    const dom = element.type === 'TEXT_ELEMENT'
            ? document.createTextNode(element.props.nodeValue)
            : document.createElement(element.type)

    //(2)属性
    const keys = Object.keys(element.props).filter(key => key !== 'children')
    keys.forEach(key => dom[key] = element.props[key])

    // (3)子节点
    element.props.children.forEach(child => render(child, dom)) // 递归

    // (4)追加到container中
    container.appendChild(dom)
}

export default render;

render导入到入口文件index.js中,然后在main.js中使用

js 复制代码
import { createElement, createTextElement } from "./createElement";
import render from "./render";

const MiniReact = {
    createTextElement,
    createElement,
    render,
}

export { createElement, render, createTextElement };
export default MiniReact;
js 复制代码
import MiniReact from "./MiniReact";

const element = MiniReact.createElement(
    'h1',
    { id: 'title', style: "background:red" },
    MiniReact.createTextElement('hello world'), // 👈变更:这个改成文字节点
    MiniReact.createElement('div', { id: 'name', style: "background:blue;" }, '---MiniReact')
)

const container = document.querySelector('#root');
MiniReact.render(element, container); // 将虚拟dom转成真实dom并挂载到 root 容器中

运行npm run dev,打开浏览器看看效果:

五、Concurrent Mode

递归 render 有一个致命问题:一旦开始,整棵树必须一次性渲染完,中途无法停止

如果组件树很大,渲染期间,主线程会被长时间占用,用户操作、动画等高优先级任务得不到及时响应造成页面卡顿。这在真实项目中无法接受。

React 16 重构方案:将渲染工作拆分成多个小单元,每次只处理一个单元,处理完后就把主线程交还给浏览器 。如果有紧急任务(点击/动画)就暂停渲染。等浏览器空闲后,再继续处理下一个工作单元。这就是 Concurrent Mode(并发模式) 的核心思想。

实现思路

  • 把渲染拆成一个个小工作单元(Unit of Work)
  • 用浏览器的 requestIdleCallback 执行任务
  • 每次干完一个单元,就把主线程还给浏览器
  • 时间不够就暂停,下次空闲继续

注意:React 现在用 Scheduler 包代替了 requestIdleCallback,但思想完全一样

Scheduler在我往期文章有实现:从零实现React Scheduler调度器

js 复制代码
// 下一个要执行工作单元
let nextUnitOfWork = null  

// 执行每一个工作单元
function performUnitOfWork() {}

// 工作循环  -- 这里的deadline是requestIdleCallBack传给回调函数的
function workLoop(deadline) {
    let shouldYield = false
    while (nextUnitOfWork && !shouldYield) {
        // 执行完一个工作单元后要返回下一个工作单元
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
        // 检查是否需要交出主线程
        shouldYield = deadline.timeRemaining() < 1 
    }
    // 告诉浏览器,下一次你空闲了请继续执行我的工作循环
    requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop) // 第一次请求

六、⭐Fiber

为了让 Concurrent Mode 真正落地,我们需要一个能承载「工作单元」的数据结构 ------ 它就是 Fiber

Fiber 是 React 最核心的设计:把每一个Element节点,变成一个可拆分、可暂停、可恢复的工作单元 ,从而实现可中断渲染

Fiber数据结构:

js 复制代码
{
  type: 'div',
  props: { children:[] },
  dom: null,           // 对应的真实 DOM
  parent: null,        // 父 Fiber
  child: null,         // 第一个子 Fiber
  sibling: null        // 兄弟 Fiber
}

对比虚拟 DOM(Elemen) 和 Fiber 的区别:Element 只描述 DOM 结构,Fiber 不仅描述结构,还记录遍历关系、工作状态、DOM 实例,让渲染可以随时中断、继续

我们需要对代码进行迁移和重构:

在MiniReact文件夹下创建 createDOM.js - 基于fiber节点创建真实DOM

js 复制代码
// 将创建真实DOM的逻辑从render函数中抽离
function createDOM(fiber) {
    // (1)创造DOM节点
    const dom =
        fiber.type === 'TEXT_ELEMENT'
            ? document.createTextNode(fiber.props.nodeValue)
            : document.createElement(fiber.type)

    //(2)赋值属性
    const keys = Object.keys(fiber.props).filter(key => key !== 'children')
    keys.forEach(key => dom[key] = fiber.props[key])

    // (3)返回DOM
    return dom;
}

export default createDOM;
  1. 将调度机制放到render.js

In the render we'll create the root fiber and set it as the nextUnitOfWork

pomb提到,在render函数中,需要创建root fiber并且将他设置成下一个工作单元

js 复制代码
let nextUnitOfWork = null

function render(element, container) {
    // 这个就是root的fiber
    nextUnitOfWork = {
        dom: container,
        props: { children: [element] },
        sibling: null,
        parent: null
    }
}

// 执行一个工作单元
function performUnitOfWork(fiber) {}

// 工作循环
function workLoop(deadLine) {
    let shouldYield = false
    while (nextUnitOfWork && !shouldYield) {
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
        shouldYield = deadLine.timeRemaining() < 1
    }
    requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop) // 第一次请求

export default render;
  1. 实现performUnitOfWork

The rest of the work will happen on the performUnitOfWork function, there we will do three things for each fiber:

  1. add the element to the DOM
  2. create the fibers for the element's children
  3. select the next unit of work

接下来的工作就是主要围绕 performUnitOfWork 函数的实现展开

js 复制代码
import createDOM from '../createDOM'

function performUnitOfWork(fiber) {
    // ======== 1.创建DOM并添加到parentFiber的Dom上 =========
    if (!fiber.dom) {
        fiber.dom = createDOM(fiber)
    }
    if (fiber.parent) {
        fiber.parent.dom.appendChild(fiber.dom)
    }

    // ========= 2.为每一个child生成fiber并构建关系 ==========
    let prevSibling = null // 记录

    // 把每一个child元素,弄成一个fiber
    for (let childrenElement of fiber.props.children) {
        const newFiber = {
            type: childrenElement.type,
            props: childrenElement.props,
            parent: fiber,  // child的父亲就是当前fiber
            child: null,
            dom: null,
            sibling: null
        }
 
		// 构建fiber关系  -- 下文解释
        if (!fiber.child) fiber.child = newFiber
        else prevSibling.sibling = newFiber
        
        // 更新引用
        prevSibling = newFiber
    }


    // ============ 3. 返回下一个工作单元(fiber) ===========
    if (fiber.child) return fiber.child
    let nextFiber = fiber
    while (nextFiber) {
        if (nextFiber.sibling) return nextFiber.sibling
        nextFiber = nextFiber.parent
    }
    
    // 找不到说明下一个工作单元为空,那么workLoop就会停止
    return undefined;
}

对于步骤 2、3做以下解释:

感兴趣的话可以阅读一下这篇文章:[React Fiber 入门------React 背后的算法](https://www.velotio.com/engineering-blog/react-fiber-algorithm#:\~:text=React Fiber is a completely,node of the D0M tree.)

运行npm run dev打开浏览器dom成功渲染出来了,并且可以插入调试代码使得在控制台打印出来整颗Fiber树结构:

七、Render and Commit Phases

在 Fiber 实现中,我们存在一个关键问题:每处理一个 Fiber 工作单元,就立刻把对应的 DOM 节点添加到页面中:

js 复制代码
function performUnitOfWork(fiber) {
    if (!fiber.dom) {
        fiber.dom = createDOM(fiber)
    }
    if (fiber.parent) {
        fiber.parent.dom.appendChild(fiber.dom) // ⚡立刻追加到页面上去
    }
	// ...其他代码不变
}

浏览器的 requestIdleCallback 随时可能中断我们的渲染工作,这就会导致用户看到不完整的 UI ------ 比如只渲染了一半的列表、残缺的按钮...

React 解决这个问题的核心方案是:把构建 Fiber 树 和 更新真实 DOM 这两个过程 分离

  1. Render 阶段(可中断):只构建 Fiber 树、计算 DOM 变更,不操作真实 DOM;即使被中断,也不会影响页面展示。
  2. Commit 阶段(不可中断):当整个 Fiber 树构建完成后,一次性把所有 DOM 变更同步到页面,用户只会看到完整的 UI 变化。

具体做法如下:

  • wipRoot 记录整棵 Fiber 树的根
  • workLoop 里,当没有下一个工作单元时,调用 commitRoot
  • commitRoot递归地把所有 Fiber 的 DOM 一次性挂载到页面上
js 复制代码
// 更新一下 render.js
let nextUnitOfWork = null      // 下一个工作单元
let workInProgressRoot = null  // 👈新增:工作中的根节点

function render(element, container) {
    workInProgressRoot = {
        dom: container,
        props: { children: [element] },
        sibling: null,
        parent: null
    }
    nextUnitOfWork = workInProgressRoot  // 下一个工作单元就是 root Fiber
}

function performUnitOfWork(fiber) {
	// ...其他代码不变
    
    // -------------✂删掉这部分代码✂-------------
    // if (fiber.parent) {
    //    fiber.parent.dom.appendChild(fiber.dom)
    // }
    // ---------✂不在构建Fiber树的时候提交✂-------
    
  // ...其他代码不变
}


function workLoop(deadLine) {
    let shouldYield = false
    while (nextUnitOfWork && !shouldYield) {
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
        shouldYield = deadLine.timeRemaining() < 1
    }
    requestIdleCallback(workLoop)

    // 当没有下一个工作单元并且workInProgress树已经构建完毕,就开始提交阶段
    if (!nextUnitOfWork && workInProgressRoot) {
        commitRoot()
    }
}

接下来,工作重点就是 commitRoot函数

js 复制代码
// -----------------------(同步)提交阶段----------------------

// 提交根Fiber
function commitRoot() {
    commitWork(workInProgressRoot.child)
    workInProgressRoot = null // 清空
}

// 从 fiber 开始提交
function commitWork(fiber) {
    if (!fiber) return;

    const parentDOM = fiber.parent.dom
    parentDOM.appendChild(fiber.dom)
    // 递归 child 和 sibling
    commitWork(fiber.child)
    commitWork(fiber.sibling)
}
// --------------------------------------------------------------

为了测试一下commit 过程,我们临时把代码改一下

js 复制代码
// 提交根Fiber
function commitRoot() {
    const now = performance.now()
    commitWork(workInProgressRoot.child)
    workInProgressRoot = null  
    console.log(`commit阶段耗时:${performance.now() - now}ms`);
}

运行 npm run dev ,打开浏览器查看:

八、⭐Reconciliation

到目前为止,我们的 MiniReact 只能新增 DOM 节点 ,但真实场景中,组件状态变化会触发重新渲染 ,需要对 DOM 进行更新或删除

React 通过调和(Reconciliation) 机制,对比新旧 Fiber 树,找出差异并给Fiber打上标签,最后在Commit阶段把最小变更批量的同步到真实 DOM上 ------ 这也是 React 性能优势的核心

  1. 核心思路

    • 保存上一次的 Fiber 树 ------ 用 currentRoot 记录上次提交的根
    • 每个 Fiber 增加 alternate 属性,指向上一棵树中对应的旧 Fiber
    • render ,把 currentRoot 作为旧树,和新传入的 element 进行比较
    • 比较规则 (基于 type):
      • 相同 type → 复用旧 DOM,更新 props(UPDATE
      • 不同 type 且有新元素 → 创建新 DOM(PLACEMENT
      • 不同 type 且有旧元素 → 删除旧 DOM(DELETION
    • 打上 effectTag ,在 commit 阶段根据 tag 执行对应操作
    • 删除节点 需要单独记录到 deletion 数组,因为旧树不在 workInProgress
  2. 保存 currentRoot 和 alternate

js 复制代码
// 改造render.js

// 注意:这里虽然叫做currentRoot,但是它保存的是前一个完成的fiber root节点
let currentRoot = null 	// 👈新增:上次提交的根
let deletion = []   	// 👈新增:待删除的 Fiber 列表

function commitRoot() {
    commitWork(workInProgressRoot.child)
    currentRoot = workInProgressRoot     // 👈新增:保存旧 fiber root节点
    workInProgressRoot = null    
}

function render(element, container) {
    workInProgressRoot = {
        dom: container,
        props: { children: [element] },
        sibling: null,
        parent: null,
        child: null,
        alternate: currentRoot           // 👈新增:指向旧 Fiber root节点
    }
    nextUnitOfWork = workInProgressRoot
}

抽离 reconcileChildren

把原来 performUnitOfWork创建子 Fiber 的逻辑,抽取到 reconcileChildren 函数中,并加入新旧比较:

js 复制代码
// ---------(4)reconcile函数--------------- 👈新增
function reconcileChildren(wipFiber, elements) {
    // wipFiber == workInProgressFiber
    let index = 0
    let prevSibling = null
    // 这里需要判断一下alternate是否存在,因为初始阶段wipFiber.alternate(旧Fiber)为null
    let oldFiber = wipFiber.alternate ? wipFiber.alternate.child : null

    while (index < elements.length || oldFiber != null) {
        const element = elements[index]
        // 两颗DOM一样:新元素存在 并且 老元素也存在 并且 新元素的type跟老元素的type一样
        const sameType = element && oldFiber && element.type === oldFiber.type
        let newFiber = null

        // ---------------针对 更新、新建、删除三种情况分别处理---------------
        // (1)更新:类型一样
        if (sameType) {
            newFiber = {
                type: oldFiber.type,
                props: element.props,   //👈== 类型一样,直接用新元素的props就行
                dom: oldFiber.dom,      //👈== 复用老 dom
                parent: wipFiber,
                alternate: oldFiber,
                effectTag: 'UPDATE',    //👈== 标记为更新
            }
        }

        // (2)新建:新元素存在 老元素不存在
        if (!sameType && element) {
            newFiber = {
                type: element.type,
                props: element.props,
                dom: null,              //👈== 新建的,所以还没有DOM
                parent: wipFiber,
                alternate: null,        //👈== 没有老Fiber,所以为null
                effectTag: 'PLACEMENT', //👈== 标记为新建
            }
        }

        // (3)删除:老元素存在 新元素不存在
        if (!sameType && oldFiber) {
            oldFiber.effectTag = 'DELETION' //👈 标记为删除
            deletion.push(oldFiber)         //👈 收集待删除的Fiber(后续Commit阶段处理)
        }
        // ------------------------------end----------------------------

        // 将performUnitOfWork中建立fiber之间链条的逻辑写道这里来
        if(newFiber) {
            if (index === 0) wipFiber.child = newFiber // 第一个为children
            else prevSibling.sibling = newFiber        // 后续为sibling
            prevSibling = newFiber            
        }
        
        // 更新状态
        if (oldFiber) oldFiber = oldFiber.sibling
        index++
    }  
} 
// -------------------------------------------
  1. performUnitOfWork中接入调和逻辑,用来创建newFiber
js 复制代码
//----------(5)performUnitOfWork执行工作单元------
function performUnitOfWork(fiber) {
    // ...其他代码不变
  
    const elements = fiber.props.children
    reconcileChildren(fiber, elements)  // 👈将比较+创建fiber的逻辑抽离到这个里面来
    
    // -----------------✂删掉这部分代码✂--------------- 
    // let prevSibling = null
    // 创建子fiber并构建fiber之间的关系
    // for (let childrenElement of elements) {
    //    const newFiber = {
    //        type: childrenElement.type,
    //        props: childrenElement.props,
    //        parent: fiber,
    //        child: null,
    //        dom: null,
    //        sibling: null
    //    }
    //    if (!fiber.child) fiber.child = newFiber
    //    else prevSibling.sibling = newFiber
    //    prevSibling = newFiber
    // }
    //-------------------✂删掉这部分代码✂---------------
    
    // ...其他代码不变
}
  1. 完善 Commit 阶段,处理变更标签

现在 Commit 阶段需要根据effectTag执行不同 DOM 操作:

  • PLACEMENT:新增节点(原逻辑);
  • DELETION:删除节点;
  • UPDATE:更新节点 props。

5.1更新commit函数

js 复制代码
// -----------------------(3)同步提交阶段-----------------------
// 提交根Fiber
function commitRoot() {
    deletion.forEach(commitWork) 		// 👈 提交的时候,统一删除收集的fiber
    commitWork(workInProgressRoot.child)
    
    // 重置工作
    deletion = []					   // 清空,避免重复删除
    currentRoot = workInProgressRoot	// 本次渲染的树设置为旧树等下一次渲染 	
    workInProgressRoot = null 		   // 清空workInProgressRoot
}

// 从 fiber 开始提交
function commitWork(fiber) {
    if (!fiber) return;
    const parentDOM = fiber.parent.dom

    // -------------------🆕根据effectTag做不同的DOM操作🆕-------------------
    // (1)如果是替换,则替换老的DOM节点
    if (fiber.effectTag === 'PLACEMENT' && fiber.dom) {
        parentDOM.appendChild(fiber.dom)
    }
    // (2)如果是删除,则从DOM中删除
    else if (fiber.effectTag === 'DELETION' && fiber.dom) {
        parentDOM.removeChild(fiber.dom)
    }
    // (3)如果是更新,则单独处理
    else if (fiber.effectTag === 'UPDATE' && fiber.dom) {
        updateDOM(fiber.dom, fiber.alternate.props, fiber.props)  // 👈单独处理
    }
    // -------------------------🆕operation end🆕--------------------------

    // 递归 child 和 sibling
    commitWork(fiber.child)
    commitWork(fiber.sibling)
}
// --------------------------------------------------------------

5.2 实现updateDOM函数,处理节点更新:

js 复制代码
// ---------------- (4)updateDOM ----------------
function updateDOM(dom, prevProps, nextProps) {
    /**
     * 举个例子
     *  prevProps = {id: 'a', className: 'red', onClick, children: []}
     *  nextProps = {className: 'blue', style: 'bold', onChange,children: []}
     * 
     * (1)删掉旧属性:id
     * (2)更新props:className, style
     */
    // (1)移除旧属性
    Object.keys(prevProps)
        .filter(key => key !== 'children')       // ① → {id, className}
        .filter(key => !(key in nextProps))      // ② → {id}
        .forEach(key => delete dom[key])         // ③ → 删除id属性

    // (2)设置新属性
    Object.keys(nextProps)
        .filter(key => key !== 'children') // ① → {className, style}
        .filter(key => !(key in prevProps) || prevProps[key] !== nextProps[key])  
    	// ② → {className,style}
        .forEach(key => dom[key] = nextProps[key]) // ③ → red更新为blue,新增style
}
// ---------------------------------------------------------------

5.3 upDateDOM补充

到目前为止,updateDOM 只更新了普通属性(如 idclassName)。但 React 中大量使用 onClickonChange 这类事件监听器,当函数发生变化时,我们需要先解绑旧的监听器 ,再绑定新的,否则内存会泄漏,且交互逻辑不会更新。

js 复制代码
// ---------------- (4)updateDOM ----------------
function updateDOM(dom, prevProps, nextProps) {
    // 在updateDOM顶层新增
    //-------------------🆕事件监听器处理🆕-------------------
    const isEvent = key => key.startsWith('on')                // 是否以on开头
    const getEventName = key => key.slice(2).toLowerCase()   // onClick -> click
    
    // [1] 移除旧的事件函数
    Object.keys(prevProps)
        .filter(isEvent)
    	.filter(key => !(key in nextProps) || prevProps[key] !== nextProps[key])
    	.forEach(key=>{
        	const eventName = getEventName(key)
            dom.removeEventListener(eventName,prevProps[key])
    	})
    
    // [2] 添加新的事件函数
    Object.keys(nextProps)
    	.filter(isEvent)
    	.filter(key => prevProps[key] !== nextProps[key])
    	.forEach(key=>{
			const eventName = getEventName(key)
             dom.addEventListener(eventName,nextProps[key])
    	})
    //-------------------🆕事件监听器处理end🆕-----------------
    
    // ... 其余代码保持不变
}

5.4 对于新创建的节点,也需要注册事件监听器

问题:我们只是在upDataDOM的时候对事件绑定函数做了处理,然而当我们首次渲染的时候呢?此时也要将事件函数绑定到dom节点上,此时只需要修改一点点代码

js 复制代码
function commitWork(fiber) {
    // ...其他代码
  
    //--------------只改这一点点------------------
    // (1)如果是替换,则替换老的DOM节点
    if (fiber.effectTag === 'PLACEMENT' && fiber.dom) {
        parentDOM.appendChild(fiber.dom)
        updateDOM(fiber.dom, {}, fiber.props) //👈新增:新创建的节点,也需要注册事件监听器
    }
    // -------------------------------------------
    
    // ...其他代码
}

5.5见证奇迹

基本核心的逻辑已经写完了,可以测试一下:

  1. 更新dom的props,看看页面是否更新
  2. 新增一个dom节点,看看页面便没变化
  3. 给dom绑定一个事件函数看看是否生效
  4. 修改刚刚的dom事件函数看看是否生效
  5. 删除dom看看页面变化

5.6 总结一下:

到这里,我们已经完整实现了 React 最核心的更新机制 ------调和(Reconciliation) ,也就是大家常说的 Diff 算法 (真实 React 还额外用 key 做了列表优化)

回顾完整渲染流程

  1. render 初始化 :调用 render() 生成新的虚拟 DOM,创建 wipRoot,启动渲染任务。

  2. Concurrent ModeworkLoop 借助 requestIdleCallback,在浏览器空闲时逐个执行 Fiber 工作单元,实现可中断渲染。

  3. Fiber 构建与 Diff 调和performUnitOfWork 调用 reconcileChildren同层对比新旧 Fiber 树

    • 类型相同 → 复用 DOM,打 UPDATE 标签

    • 新节点存在、类型不同 → 新建 Fiber,打 PLACEMENT 标签

    • 旧节点存在、类型不同 → 标记 DELETION,收集待删除节点

  4. Render 阶段结束 :整棵新 Fiber 树构建完成,nextUnitOfWork 为空,进入 Commit 阶段。

  5. Commit 阶段批量更新 DOMcommitRoot 统一执行所有 DOM 操作:

    • 先处理 DELETION:从 DOM 中移除废弃节点

    • 再处理子节点:PLACEMENT 执行 appendChildUPDATE 调用 updateDom 同步属性 / 事件

  6. 收尾重置 :将 currentRoot 更新为本次渲染的 wipRoot,清空 wipRoot,等待下一次渲染。

九、Function Components

函数组件是 React 核心特性之一,它和普通 DOM 元素的核心差异在于:

  1. 函数组件的 Fiber 节点没有对应的 DOM 节点(因为函数组件本身只是逻辑容器,不是真实 DOM 节点);
  2. 函数组件的 children 不是直接从 props 读取,而是执行函数的返回值

我们将基于 Pombo 的思路,改造 MiniReact 以支持函数组件:

  • We check if the fiber type is a function, and depending on that we go to a different update function.

  • In updateHostComponent we do the same as before.

  1. 更改main.js
js 复制代码
import { createElement, render } from "./MiniReact";

function APP(props) {
    return createElement(
        "h1",
        null,
        "Hi ",
        props.name // 这个实际上是 <h1> 的 child
    )
}

const container = document.querySelector('#root');
/**
 * 这里跟着 pomb 做,他将这个函数直接传递给了 createElement(type,props,...children)
 * 这就意味着,后续的这个fiber节点的type
 *  1. fiber.type就直接为 APP 函数
 *  2. type都为函数了,那么我们就不能根据type来创造dom了,因此这个额fiber也没有dom节点
 */
const element = createElement(APP, { name: 'foo' })
render(element, container);
  1. render阶段构造Fiber时区分函数组件和原生组件:

函数组件有两个关键不同:

  1. 没有对应的 DOM 节点 ------ 它的 Fiber 上 domnull
  2. children 来自执行函数 ,而不是直接读取 props.children

performUnitOfWork 中,我们根据 fiber.type 是否为函数来分流:

js 复制代码
function performUnitOfWork(fiber) {
    const isFunctionComponent = fiber.type instanceof Function   // 是否是函数组件
 	// ①:函数组件 
 	if (isFunctionComponent) updateFunctionComponent(fiber)
    // ②:普通的元素节点
 	else updateHostComponent(fiber)

    // ... 其他代码不变
    //  3. 返回下一个工作单元(fiber) ...
}

实现updateFunctionComponentupdateHostComponent

js 复制代码
// (1) updateHostComponent
function updateHostComponent(fiber) {
    /**
     * 原生组件保持原样
     * 1.创建DOM并记录到当前fiber节点中
     * 2.创建子级Fiber以及建立关系
     */
    if (!fiber.dom) fiber.dom = createDOM(fiber)
    const elements = fiber.props.children
    reconcileChildren(fiber, elements)
}

// (2) updateFunctionComponent
function updateFunctionComponent(fiber) {
    // 运行函数获取 children,然后调用 reconcileChildren
    const functionComponent = fiber.type
    const children = [functionComponent(fiber.props)]
    reconcileChildren(fiber, children)
}
  1. commit阶段处理无 DOM 的 Fiber:

commitWork 之前假设每个 Fiber 都有 domparent.dom。现在函数组件的 Fiber 没有 DOM,需要调整:

  • 找父 DOM 节点 :向上遍历,直到找到带有 dom 的 Fiber。
  • 删除节点 :向下遍历,找到真正有 dom 的子 Fiber 来移除。
js 复制代码
// 从 fiber 开始提交
function commitWork(fiber) {
    if (!fiber) return;

    // 👈函数式组件的fiber没有dom节点,需要一直往上找直到找到带有dom的fiber
    let domParentFiber = fiber.parent;
    while (!domParentFiber.dom) {
        domParentFiber = domParentFiber.parent;
    }
    const parentDOM = domParentFiber.dom;

    // -------------------根据effectTag做不同的DOM操作-------------------
    // (1)如果是替换,则替换老的DOM节点
    if (fiber.effectTag === 'PLACEMENT' && fiber.dom) {
        parentDOM.appendChild(fiber.dom)
        // 对于新创建的节点,也需要注册事件监听器
        updateDOM(fiber.dom, {}, fiber.props)
    }
    // (2)如果是删除,则从DOM中删除
    else if (fiber.effectTag === 'DELETION' && fiber.dom) {
        commitDeletion(fiber, parentDOM);  // 👈删除的是dom,针对函数组件没有dom,需要单独处理
    }
    // (3)如果是更新,则单独处理
    else if (fiber.effectTag === 'UPDATE' && fiber.dom) {
        updateDOM(fiber.dom, fiber.alternate.props, fiber.props)
    }
    // ----------------------------operation end--------------------------

    // 递归 child 和 sibling
    commitWork(fiber.child)
    commitWork(fiber.sibling)
}

删除逻辑单独抽出来,递归找到实际 DOM 节点:

js 复制代码
function commitDeletion(fiber, parentDOM) {
    // 如果是函数组件fiber没有dom,需要一直往上找直到找到带有dom的fiber
    if (fiber.dom) {
        parentDOM.removeChild(fiber.dom)
    } else {
        commitDeletion(fiber.child, parentDOM)
    }
}

看看结果:

尝试更多的可能:

js 复制代码
import { createElement, render } from "./MiniReact";

// Clock时钟组件 - 接收时间作为参数
function Clock(props) {
    const title = "当前时间: ";
    return createElement(
        "div",
        { style: "border: 1px solid blue; padding: 10px; margin: 10px;" },
        title,
        props.time
    );
}

// APP组件
function APP(props) {
    return createElement(
        "div",
        { style: "font-family: Arial; padding: 20px;" },
        createElement("h1", null, "MiniReact Demo"),
        createElement("p", null, "Hello, ", props.name),
        createElement(Clock, { time: props.currentTime })
    );
}

// 渲染应用并实现实时更新
function renderApp() {
    const currentTime = new Date().toLocaleTimeString();
    const element = createElement(APP, {
        name: 'World',
        currentTime: currentTime
    });
    const container = document.querySelector('#root');
    render(element, container);
}
renderApp(); // 首次渲染
setInterval(renderApp, 1000); // 每秒更新一次时间

小结

  • 函数组件在 Fiber 树中存在,在 DOM 树中不存在。这是必须单独处理的根本原因。
  • Render 阶段:函数组件不创建 DOM,children 来自执行函数。
  • Commit 阶段:操作 DOM 时要跳过函数组件,向上找到真正的宿主节点;删除时要向下找到真正的 DOM 节点。
  • 这些处理逻辑是 React Fiber 架构能支持函数组件的关键设计。

理解了这一点,你就能明白为什么 React 函数组件不能直接挂载 ref(需要 forwardRef),以及为什么组件卸载时的清理函数能正确执行------因为清理函数也是沿着这个逻辑找到真实 DOM 后触发的。

十、 Hooks

在支持函数组件后,我们终于来到最后一步:实现 React 最核心的 Hooks ------ useState。这也是理解 React 状态管理底层逻辑的关键。

  1. 为什么函数组件需要 Hooks?

函数组件本身只是一个普通函数,每次渲染都会重新执行。普通变量在函数执行完就消失了,无法保存状态。那 React 是怎么记住 count 的呢?

答案 :状态存储在 Fiber 节点 上。每个函数组件的 Fiber 节点会挂一个 hooks 数组,数组里每个元素对应一个 useState 调用。每次组件重新执行时,useState 从对应的老 Fiber 上读取上次的状态,然后返回给组件。这样,虽然函数重新执行了,但状态被"记忆"在了 Fiber 上。

  1. hooks在Fiber上长啥样?
  1. Hooks 如何配合 Fiber 实现更新?

  2. 首次渲染 :执行 Counter(),遇到 useState(0),发现当前 Fiber 上没有 hooks 数组,就新建一个 hook 对象,state 设为初始值 0queue 为空。然后返回 [0, setCount]

  3. 用户点击按钮 :调用 setCount(c => c+1),这个 setCount 会把 (c) => c+1 这个动作推入当前 hook.queue 中,然后触发重新渲染 (创建一个新的 wipRoot,启动 workLoop)。

  4. 重新渲染 :再次执行 Counter(),此时 useState 会拿到旧的 hook(通过 alternate.hooks[hookIndex]),然后依次执行 queue 中的所有动作,计算出新的 state,并清空 queue。最后返回新状态和同一个 setCount

这就是 Hooks 的秘密 :状态并不存储在闭包变量里,而是存储在 Fiber 节点上。每次渲染都是重新执行函数,但通过 alternate 保留了上一次的状态,并通过 queue 实现了批量更新。

Pombo 在原教程中,通过以下核心思路实现 useState

  1. 为函数组件的 Fiber 节点新增 hooks 数组,存储多次调用 useState 的状态;
  2. 用全局变量追踪「当前工作中的 Fiber」和「当前 hook 索引」;
  3. 状态更新时,将更新动作存入队列,触发新的渲染流程;
  4. 重新渲染时,批量执行更新队列中的动作,更新状态并同步到视图。

1. 修改示例

js 复制代码
// 将 main.js 改为经典的计数器:
import { createElement, render } from "./MiniReact";
import { useState } from "./MiniReact/render"; // 后续将useState写到render中

function Counter({ name }) {
    const [count, setCount] = useState(0);
    return (
        createElement(
            "div",
            { style: "display:flex;gap:5px;background:pink; padding:5px" },
            createElement("div", null, 'counter组件:'),
            createElement("button", { onClick: () => setCount(pre => pre + 1) }, "增加"),
            createElement("p", null, `${name}:${count}`),
            createElement("button", { onClick: () => setCount(pre => pre - 1) }, "减少")
        )
    )
}

const container = document.querySelector('#root');
const element = createElement(Counter, { name: 'Counter组件' })
render(element, container);

2 在函数组件中支持 Hooks

Hooks 的实现依赖 当前正在渲染的函数组件 Fiber 。我们需要在 updateFunctionComponent 中记录这个 Fiber,并为它初始化一个 hooks 数组,以及当前 hook 的索引。

js 复制代码
// 在render.js的全局变量中:
// -------------------(1)全局引用-------------------------
   
let nextUnitOfWork = null  	    // 下一个要执行的工作单元
let workInProgressRoot = null   // 工作中的根节点
let currentRoot = null          // 前一个完成的root
let deletion = []               // 记录要删除的fiber节点
let wipFiber = null;      	    // 👈新增:当前正在工作的函数组件 Fiber
let hookIndex = null;     	    // 👈新增:当前 hook 在 hooks 数组中的索引
// ---------------------------------------------------------
js 复制代码
// 改写 updateFunctionComponent:
function updateFunctionComponent(fiber) {
    wipFiber = fiber;		// 当前正在处理的函数组件fiber
    hookIndex = 0;			// 从hooks链表的 0 索引开始
    wipFiber.hooks = [];     // 存储该组件所有的 hook 对象

    const children = [fiber.type(fiber.props)];
    reconcileChildren(fiber, children);
}

实现 useState 的第一步:读取/创建当前 hook

现在我们来写 useState 函数。它要做什么?

  • 旧的 Fiber(fiber.alternate) 上找到对应的旧 hook(如果有)。

  • 创建一个新的 hook 对象,包含 statequeue

  • 将这个新 hook 放入当前 Fiber 的 hooks 数组,并递增索引。

  • 返回 [state, setState]

js 复制代码
// 将useState写到render.js中,并导出,因为useState用到了render.js中的全局彼岸两
export function useState(initialState) {
    //  1. 尝试从旧的 Fiber(alternate)中获取同位置的 hook
    const oldHook = wipFiber.alternate?.hooks?.[hookIndex];
     
    // 2. 创建新的 hook 对象
    const hook = {
        state: oldHook ? oldHook.state : initial,
        queue: []      // 暂留,后续用来存放更新动作
    };

    // 3. 存入当前 Fiber 的 hooks 数组
    wipFiber.hooks.push(hook);
    hookIndex++;

    // 4. 返回状态(暂时没有 setState)
    return [hook.state];
}
export { useState };

此时,如果我们在组件里写 const [count, setCount] = useState(0)count 就能拿到旧状态(首次为 0)。但点击按钮调用 setCount 还不会做任何事情。

实现 setState:推入更新队列 + 触发重新渲染

setState 不应该直接修改 hook.state(因为可能有多次更新需要合并),而是把"更新动作"存入 hook.queue。然后,我们需要启动一次新的渲染,让整个 MiniReact 重新执行,并在重新执行时应用这些更新。

如何启动新的渲染?回忆一下,我们的 render 函数创建了一个 workInProgressRoot 并设置了 nextUnitOfWork,然后 workLoop 就会开始工作。在 setState 中,我们也要做类似的事情:基于当前的 currentRoot 创建一个新的 workInProgressRoot,并让它成为下一个工作单元

js 复制代码
// useState中,创建setState函数
const setState = (action) => {
    // 将action存入到 hook的queue中
    hook.queue.push(action);
    // 跟render类似,触发重新渲染
    wipFiber = {
        dom: currentRoot.dom,
        props: currentRoot.props,
        alternate: currentRoot,
    }
    nextUnitOfWork = wipFiber;
    deletions = []
}

现在,当 setState 被调用后,会创建一个新的工作单元,workLoop 会重新开始整个 Fiber 树的构建。当再次执行到同一个函数组件时,useState 会重新执行,此时我们需要把旧 hook 的 queue 里的所有 action 依次应用到新 hook 的 state 上

修改 useState 中创建 hook 的部分,在拿到 oldHook 之后,如果有旧的 queue,就遍历执行它们:

js 复制代码
// 重点:如果有待执行的更新,就依次应用到 state 上
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
    // 如果 action 是函数,就调用它并传入旧状态;否则直接使用 action 作为新值
    if (typeof action === 'function') {
        hook.state = action(hook.state);
    } else {
        hook.state = action;
    }
});
  1. 为什么需要 queue 而不直接修改 state?

考虑一个场景:在同一个事件循环中,连续调用三次 setCount(c => c + 1)。如果不使用队列,可能只有最后一次生效,或者会触发三次渲染。使用队列后:

  • 第一次 setCountc => c+1 推入队列,调度渲染。
  • 第二次、第三次也把动作推入同一个队列。
  • 当重新渲染执行组件时,useState 会依次执行队列中的三个函数,0 -> 1 -> 2 -> 3,最终 state 变成 3,然后清空队列。
  • 只触发了一次渲染,性能更好,且结果正确。

这就是 React 中 批量更新 的简化实现。

  1. useState完整代码:
js 复制代码
// render.js中
export function useState(initialState) {
    //  1. 尝试从旧的 Fiber(alternate)中获取同位置的 hook
    const oldHook = wipFiber.alternate?.hooks?.[hookIndex];

    // 2. 创建新的 hook 对象
    const hook = {
        state: oldHook ? oldHook.state : initialState,
        queue: []
    };

    // 3. 如果有待执行的更新,就依次应用到 state 上
    const actions = oldHook ? oldHook.queue : []
    actions.forEach(action => {
        // 如果 action 是函数,就调用它并传入旧状态;否则直接使用 action 作为新值
        if (typeof action === 'function') {
            hook.state = action(hook.state);
        } else {
            hook.state = action;
        }
    });

    // 4. 创建setState函数
    const setState = (action) => {
        // 将action存入到 hook的queue中
        hook.queue.push(action);
        // 跟render类似,触发重新渲染
        workInProgressRoot = {
            dom: currentRoot.dom,
            props: currentRoot.props,
            alternate: currentRoot,
        }
        nextUnitOfWork = workInProgressRoot;
        deletion = []
    }

    // 5. 存入当前 Fiber 的 hooks 数组
    wipFiber.hooks.push(hook);
    hookIndex++;

    // 6. 返回状态(暂时没有 setState)
    return [hook.state, setState];
}
  1. 检验效果:

这就是 Hooks 最核心的机制。虽然我们的实现忽略了优先级调度、批量合并的优化,但它完全体现了 React Hooks 的设计思想:状态存储在 Fiber 上,通过队列实现更新,用调度器触发重渲染

十一、支持JSX语法

在前面的实现过程中,我们一直手动调用 MiniReact.createElement() 来创建虚拟 DOM 对象,比如这样:

js 复制代码
const element = MiniReact.createElement(
    'h1',
    { id: 'title', style: "background:red" },
    'Hello World',
    MiniReact.createElement('div', { id: 'name' }, '---MiniReact')
)

之所以不直接用更简洁的 JSX 语法,核心目的是让大家彻底理解 JSX 的本质 :JSX并不是什么新的设计,本身也并不能被浏览器识别,它只是 React.createElement() 的「语法糖」------ 所有写的 JSX 代码,最终都会被 Babel/ESBuild 等编译工具转译为 createElement 函数调用。

js 复制代码
const element = (
  <h1 id="title" style="background:red">
    Hello World
    <div id="name">---MiniReact</div>
  </h1>
);

经过编译后,会被转换成和我们手动调用 MiniReact.createElement() 完全一致的代码。前面手动写 createElement,是为了跳过「语法糖」的包装,直接看到虚拟 DOM 构建的底层逻辑;当我们理解了核心原理后,就可以配置工具,让项目支持 JSX 语法,回归更简洁的开发体验。

  1. 项目根目录下新建 vite.config.js 文件:
js 复制代码
import { defineConfig } from 'vite'

export default defineConfig({
  esbuild: {
    // 开启 JSX 转换模式
    jsx: 'transform',
    // 指定 JSX 编译后的工厂函数(对应我们的 createElement)
    jsxFactory: 'createElement',
    // 自动注入 createElement 导入,避免每个文件手动 import
    jsxInject: `import { createElement } from './MiniReact/createElement.js'`
  },
})
  1. 修改文件后缀并使用 JSX(Vite 会识别 .jsx 后缀文件并触发 JSX 编译)
js 复制代码
// main.jsx
import { render } from "./MiniReact";
import { useState } from "./MiniReact/render";

const Counter = ({ theme }) => {
  const [count, setCount] = useState(0);
  return (
    <div
      style={`
        display:flex;
        gap:5px;
        background:${theme};
        padding:10px;
        border-radius: 8px;
        margin: 10px;
        transition: background 1s ease;`
      }
    >
      <div>counter组件:</div>
      <button onClick={() => setCount(pre => pre + 1)}>增加</button>
      <p>count:{count}</p>
      <button onClick={() => setCount(pre => pre - 1)}>减少</button>
    </div>
  );
}

const APP = () => {
  const [color, setColor] = useState('pink');
  const changeColor = () => setColor(pre => pre === 'pink' ? 'lightblue' : 'pink')
  return (
    <div
      style={`
        background:${color === 'pink' ? 'skyblue' : 'pink'}; 
        padding: 10px; 
        border-radius: 8px; 
        margin: 10px;
        transition: background 0.8s ease`
      }
    >
      APP组件
      <Counter theme={color} />
      <button onClick={changeColor}>切换主题</button>
    </div>
  )
}

const container = document.querySelector("#root");
render(<APP />, container);

注:我们现在的MiniReact,没有对style属性进行处理,正常的JSX它的style是一个对象格式,我们暂时保留字符串格式

  1. 官方:style={{ background-color: ${color}; padding: 10px }}
  2. 我们:style={`background-color: ${color}; padding: 10px`}

十二、后记

写到这里,我们从零实现的 MiniReact 已经跑完了 React 最核心的全程:从 JSX 到虚拟 DOM、从 Fiber 架构到两阶段渲染,再到调和机制、函数组件与 useState,整个骨架已经清晰可见。

整个过程,我们始终遵循 Rodrigo PomboBuild your own React 中展示的思路------用不到 300 行核心代码,理解React 最核心的设计思想。

当然,我们的 MiniReact 只是「骨架版」,真实 React 还做了大量工业级的优化和拓展,比如:

  • 渲染阶段,React 会通过启发式规则跳过「确定无变更」的子树,而不是遍历整棵树;
  • 提交阶段,React 维护了「仅包含有副作用的 Fiber」的链表,而非递归遍历所有节点;
  • React 会复用旧 Fiber 树的节点,而非每次都创建新对象;
  • React 给更新打上「过期时间戳」实现优先级调度,而非简单丢弃未完成的工作;
  • 还有合成事件、并发渲染、Suspense、服务端渲染...... 这些都是 React 庞大生态的一部分。

但这恰恰是「极简实现」的价值:我们剥离了所有非核心的优化和兼容逻辑,只留下 React 最本质的骨架 ------ 虚拟 DOM 转 Fiber、可中断的工作循环、基于 Diff 的调和机制、函数组件与 Hooks 的闭包本质。

最后,再次由衷感谢 Rodrigo Pombo,这篇博客完全站在他的肩膀上,只是做了更贴合中文语境的拆解、补充和验证。

相关推荐
A923A4 小时前
【从零开始学 React | 第一章】React 基础与 JSX 核心语法
前端·react.js·前端框架·jsx
米丘4 小时前
Vite 代理跨域全解析:从 server.proxy 到请求转发的实现原理
javascript·node.js·vite
早點睡3904 小时前
ReactNative项目OpenHarmony三方库集成实战:react-native-vector-icons
javascript·react native·react.js
梵得儿SHI4 小时前
Vue 3 工程化实战:Axios 高阶封装与样式解决方案深度指南
前端·javascript·vue3·axios·样式解决方案·api请求管理·统一请求处理
暗不需求4 小时前
深入 JavaScript 核心:用原生 JavaScript 打造就地编辑组件
前端·javascript
江湖行骗老中医4 小时前
Vue 3 的父子组件传值主要遵循单向数据流的原则:父传子 和 子传父。
前端·javascript·vue.js
RPGMZ4 小时前
RPGMakerMZ 游戏引擎 野外采集点制作
javascript·游戏·游戏引擎·rpgmz·野外采集点
时寒的笔记4 小时前
js基础05_js类、原型对象、原型链&案例(解决无限debugger)
开发语言·javascript·原型模式
CyrusCJA4 小时前
Nodejs自定义脚手架
javascript·node.js·js