前端知库

怎样写一个 mini React(createElement、render、useState)?

用三个函数搭一个教学模型:createElement 把标签、属性、子节点包成一个普通对象;render 从根开始调用函数组件,把元素树展开成只含普通标签的结果树;useState 按调用顺序把状态存在组件对应的数组里,setState 改完状态就重新渲染。这只是帮助理解的模型,不是 React 源码。

参考答案

简答

写 <button title="点赞">点赞:{count}</button> 时,JSX 在经典转换方式下会被编译成 createElement('button', { title: '点赞' }, '点赞:', count),它返回的只是一个描述界面的对象。mini React 就是从这里开始:先写 createElement 造对象,再写 render 把对象树变成结果,最后写 useState 让组件记住状态。

render 遇到普通标签就原样保留,遇到函数组件就调用它,拿它返回的元素继续往下展开,最后得到一棵只有普通标签的树。真实的 React 会把这棵树同步到 DOM 上,这里为了能在 Node.js 里运行,直接用对象表示渲染结果。

useState 能在函数多次调用之间记住值,是因为状态并不放在函数里,而是放在组件外面的一个数组里。每次渲染前把下标归零,每调用一次 useState 就取下一格。react.dev 用的也是这个说法:React 为每个组件保存一组状态,按调用顺序一一对应。所以 Hooks 不能写在条件和循环里,顺序一变就对错了格。

这是教学模型,不是 React 源码。它没有 diff、没有批处理,setState 一调用就同步重渲染整棵树;真实的 React 会排队更新、只比较变化的部分,还有 Fiber 调度。

先看完整实现

代码分四块:createElement 造元素对象,resolve 把元素树展开,useState 读写状态,createRoot 提供 render 和重渲染。看每个函数组件的状态是怎么按位置存起来的。

JavaScript
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children
        .flat()
        .filter((child) => child != null && child !== false)
        .map((child) =>
          typeof child === 'object' ? child : { type: 'TEXT', props: { value: String(child), children: [] } },
        ),
    },
  };
}

let currentHooks = null;
let hookIndex = 0;

// 把元素树展开成只含普通标签的结果树;函数组件在这里被调用
function resolve(element, path, store) {
  if (typeof element.type === 'function') {
    // 用组件在树里的位置当钥匙,同一位置的组件每次渲染都拿到同一份状态
    if (!store.has(path)) store.set(path, []);
    currentHooks = store.get(path);
    hookIndex = 0;
    const child = element.type(element.props);
    currentHooks = null;
    return resolve(child, `${path}/${element.type.name}`, store);
  }
  const { children, ...rest } = element.props;
  return {
    type: element.type,
    props: rest,
    children: children.map((child, i) => resolve(child, `${path}.${i}`, store)),
  };
}

function useState(initial) {
  const hooks = currentHooks;
  const index = hookIndex++;
  if (hooks.length <= index) hooks.push({ state: initial });
  const hook = hooks[index];
  const setState = (next) => {
    const value = typeof next === 'function' ? next(hook.state) : next;
    if (Object.is(value, hook.state)) return;
    hook.state = value;
    root.rerender();
  };
  return [hook.state, setState];
}

let root = null;
function createRoot() {
  const store = new Map();
  let element = null;
  root = {
    tree: null,
    render(next) {
      element = next;
      root.rerender();
    },
    rerender() {
      root.tree = resolve(element, 'root', store);
    },
  };
  return root;
}

function toText(node) {
  if (node.type === 'TEXT') return node.props.value;
  const attrs = Object.entries(node.props)
    .filter(([, v]) => typeof v !== 'function')
    .map(([k, v]) => ` ${k}="${v}"`)
    .join('');
  return `<${node.type}${attrs}>${node.children.map(toText).join('')}</${node.type}>`;
}

let renders = 0;
function Counter({ label }) {
  renders++;
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);
  return createElement(
    'button',
    { title: label, onClick: () => setCount((c) => c + step), onSetStep: setStep },
    label, ':', count,
  );
}

function App() {
  return createElement('div', null,
    createElement(Counter, { label: '点赞' }),
    createElement(Counter, { label: '收藏' }),
  );
}

const app = createRoot();
app.render(createElement(App, null));
console.log(toText(app.tree)); // <div><button title="点赞">点赞:0</button><button title="收藏">收藏:0</button></div>

const [like, star] = app.tree.children;
like.props.onClick();
like.props.onClick();
star.props.onSetStep(10);
app.tree.children[1].props.onClick();
console.log(toText(app.tree)); // <div><button title="点赞">点赞:2</button><button title="收藏">收藏:10</button></div>

const before = renders;
app.tree.children[1].props.onSetStep(10);
console.log(renders - before); // 0

toText 只是把结果树打印成类似 HTML 的字符串,方便看结果,函数类型的属性(事件回调)不打印。

createElement 只是在造对象

createElement(type, props, ...children) 返回 { type, props },子节点放进 props.children。react.dev 说明真实的 createElement 返回的元素对象也有 type、props、key、ref 这些属性,children 同样放在 props 里。

这里多做了两件事。字符串和数字被包成 TEXT 类型的节点,后面处理时就不用区分“是对象还是文字”。null、undefined 和 false 被过滤掉,这样 {cond && <span />} 这种写法在条件不成立时什么都不渲染。教学模型省掉了 key 和 ref。

render 怎样处理函数组件

resolve 遇到 type 是函数的元素,就把 props 传进去调用它。组件返回的又是一个元素,于是递归继续展开,直到碰到 'div'、'button' 这样的普通标签。所以 App 和 Counter 都不会出现在结果树里,结果树里只有 div 和两个 button。

每次 setState 都调用 rerender,从根上重新展开一遍。所有组件函数都会重新执行,这跟 React 的心智模型一致:react.dev 说更新状态会让 React 用新的状态再渲染一次组件。不一样的是真实的 React 从触发更新的那个组件开始重新渲染,而不是每次都从根开始,渲染完还要比较新旧两棵树,只把差异提交到 DOM,见《React 的虚拟 DOM 和 diff 是怎样工作的?Fiber 解决了什么问题?》。

useState 为什么靠调用顺序

组件函数每次执行都是一次全新的调用,局部变量不可能留下来。所以状态要存在函数外面。这里用组件在树里的位置当钥匙,比如 root/App.1 就是 App 下面第二个子组件,每个位置对应一个数组。

渲染某个组件之前,currentHooks 指向它的数组,hookIndex 归零。组件里第一次调用 useState 拿第 0 格,第二次拿第 1 格。第一次渲染时格子不存在,就用初始值创建;以后的渲染格子已经有了,初始值就被忽略。react.dev 在讲状态时给的解释几乎一样:React 为每个组件保存一组状态,渲染前把下标设为 0,每调用一次 useState 就给出下一组并把下标加一。

这也是 Hooks 规则的来由。假如把第一个 useState 写进 if,某次渲染条件不成立,原本的第二个 useState 就变成了第一个调用,拿到的是第 0 格,也就是 count 的值。react.dev 的规则是:不要在循环、条件、嵌套函数或 try/catch/finally 里调用 Hooks,只在组件顶层调用。

例子里两个 Counter 位置不同,各有一个数组,所以“点赞”点了两次变成 2,“收藏”改了步长 10 再点一次变成 10,互不影响。

setState 做了什么

setState 先算出新值。传的是函数就把当前状态交给它算,这对应 React 的更新函数写法。然后用 Object.is 比较,新旧相同就直接返回,不重新渲染。例子最后把步长设成已有的 10,renders 没有增加。react.dev 的说法是,新值与当前状态经 Object.is 比较相同时,React 会跳过重新渲染这个组件和它的子组件。

setState 是在 useState 里创建的闭包,它记住了自己那一格 hook。所以不管被拿到哪里调用,改的都是对应组件的对应状态。

和真实 React 差在哪里

这个模型每次 setState 都同步把整棵树重渲染一遍。React 会把更新放进队列,等事件处理函数都执行完再统一渲染,同一个事件里多次 setState 只渲染一次,见《React 的批量更新是什么?React 18 的自动批处理改变了什么?》。

这里用 Map 加数组,只是为了把“按调用顺序取状态”这个核心讲清楚,React 内部的保存方式不是这样,更细的说法见《Hooks 在 React 内部是怎样保存状态的?为什么靠调用顺序区分?》。

常见追问

为什么调用 setState 之后,当前函数里的变量还是旧值?

在这个模型里也能看出来:setState 改的是外面那一格的 hook.state,而组件里的 count 是这次渲染开始时从格子里读出来的一个局部常量,不会跟着变。下一次渲染再调用 useState,才读到新值。React 里也是这样,react.dev 把它叫作“状态是快照”。

怎样在这个模型上加 useEffect?

同样在格子里存一项,记下依赖数组和清理函数。渲染时比较新旧依赖,有变化就把这个 effect 记下来,等整棵树渲染完再统一执行,执行前先调用上一次的清理函数。关键是 effect 要在渲染结束后再跑,不能在组件函数执行时直接调用。

怎么把结果真正显示到页面上?

把 resolve 得到的结果树交给一个函数,按 type 调 document.createElement,把普通属性设到元素上,on 开头的属性用 addEventListener 绑定,TEXT 节点用 document.createTextNode。最简单的做法是每次清空容器重建,再进一步就是比较新旧两棵树,只改变化的节点。