mini-react 学习记录

200 阅读6分钟

JXS 是如何转换成 dom 的

JSX 是 JavaScript XML 的缩写,它是 React 中用于编写组件结构的一种语法糖。通过使用 JSX,我们可以以声明式的方式描述组件的结构,使代码更易读和维护。

浏览器无法直接解析 JSX 代码,需要使用 vite 等打包工具进行转换

// JSX代码
const element = <h1>Hello, world!</h1>;

// 转换后的代码
const element = React.createElement("h1", null, "Hello, world!");

转换之后就可以使用 MiniReact 中的 js 代码逻辑,生成描述元素的数据结构

// mini-react
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) => {
        return typeof child === "string" ? createTextNode(child) : child;
      }),
    },
  };
}

最后可以统一把数据结构,渲染成 dom

function render(el, container) {
  // 创建dom
  const dom =
    el.type === "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(el.type);
  // 绑定属性
  Object.keys(el.props).forEach((key) => {
    if (key !== "children") {
      dom[key] = el.props[key];
    }
  });
  // 递归生成child节点
  const children = el.props.children;
  children.forEach((child) => {
    render(child, dom);
  });
  // 挂载到上一级dom
  container.append(dom);
}

如何防止线程卡顿

因为 js 是单线程,如果createElement()render()耗时太久,会造成页面卡顿。

mini-react 通过使用requestIdleCallbackAPI 进行优化处理。简单调用逻辑如实例代码。其中 deadline.timeRemaining()可以动态的计算出当前帧的剩余时间,如果时间不够,就等待下一次空闲。

// 定义一个任务函数
function myTask(deadline) {
  // 当浏览器空闲时,会调用这个函数,并传入 deadline 参数
  // 检查任务是否可以执行
  if (deadline.timeRemaining() > 1) {
    // 执行任务
    doWork();
  } else {
    // 如果当前帧没有剩余时间,可以选择延迟任务到下一次空闲时段
    requestIdleCallback(myTask);
  }
}
// 发起请求空闲回调
requestIdleCallback(myTask);

如何将树形结构转换为链表结构

因为使用requestIdleCallback必须将任务拆解成可以中断/继续的子任务,所以需要我们把元素的树形结构,转换为链表结构。

mini-react 使用 fiber 数据结构在原有的[type,props]结构基础上,新增[child,parent,sublings]字段,确定每个元素的父节点,子节点(第一个子节点),兄弟节点。链表顺序为 优先找子节点,直到没有子节点了,找当前的兄弟节点,没有兄弟节点了去找夫元素的兄弟节点,然后不断重复(TODO 补个图,不补图了,直接放大佬的链接React Fiber 架构原理:关于 Fiber 树的一切

所以目前的流程为

  1. 先将 jsx 转换为[type,props]数据结构
  2. 再根据 props 中的 children 字段,递归将[type,props]转为[type,props,child,parent,sublings]的链表数据结构
  3. requestIdleCallback,可以在空闲时,把链表数据一个一个的按顺序执行

如何防止渲染一半的情况发生

参考 render()函数,执行一个子任务的时候,我们需要做 4 件事情

  1. 创建 dom
  2. 绑定属性
  3. 递归生成 child 节点
  4. 挂载到上一级 dom

如果在构建 UI 期间,js 单线程执行了一件耗时任务,导致我们的 dom 挂载了一半,导致 UI 变形。

在 mini-react 中我们可以使用统一提交的思路,就是在执行单个任务中,只处理前三点逻辑,然后当我们的任务链表中的全部任务都处理完以后,统一执行 append 操作。就可以避免这个问题。

如何渲染 function 组件

打印 log,可以发现 function 组件的 type 是一个 function(就是 function 组件本身),调用这个 fucntion 就可以获得一个转换为[type,props](function 组件返回的是 jsx,然后打包工具会将 jsx 调用 React.createElement(),将 jsx 转换为[type,props]结构),这样就与普通的元素数据结构一样了。

ps:当然也不是完全一样,因为 function 组件是没有 dom 的,所以统一提交时,需要做判断。

如何处理事件监听

jsx 中绑定的事件会存在 props 中,所以更新 props 的时候,判断下属性名称是不是以 on 开头的,如果是就使用 addEventListener 绑定事件

function updateProps(dom, props) {
  Object.keys(props).forEach((key) => {
    if (key !== "children") {
      if (key.startsWith("on")) {
        const event = key.slice(2).toLocaleLowerCase();
        dom.addEventListener(event, props[key]);
      } else {
        dom[key] = props[key];
      }
    }
  });
}

如何更新元素

我们还是需要重新遍历一遍全部节点,才能对比出是否需要更新属性。

可以重新通过[type,props]构建新的 fiber 树,并同时新增 alternate 字段指向老的 fiber。

通过对比新老 fiber 的 type 就可以判断是修改属性,还是新增节点了。如果是修改属性,只需要把老的属性删除,并添加新的属性即可。

如何删除子节点

更新流程,遍历全部节点时,等新的 fiber 树构建完成,发现老的 dom 树还有节点时,可以把老的 fiber 收集起来,等到统一提交时,统一 remove 掉。

useState 的原理

function useState(initial) {
  // 当构建fiber树,调用function组件的方法时,会赋值wipFiber为当前函数组件的fiber
  let currentFiber = wipFiber;
  // 如果时更新的话,获取上个组件的hook
  let oldHook = currentFiber.alternate?.stateHooks[stateHooksIndex];
  // 创建一个hook 如果之前有值,就用之前的值,如果没有就用初始化的值
  const stateHook = {
    id: generateRandomString(),
    state: oldHook ? oldHook.state : initial,
    queue: oldHook ? oldHook.queue : [],
  };
  // 如果是更新,则stateHook.queue值不为空,需要遍历执行action,获取到更新后的state值
  stateHook.queue.forEach((action) => {
    stateHook.state = action(stateHook.state);
  });
  stateHook.queue = [];

  // 更新stateHooks数据 stateHooks在调用function组件的方法时,会赋值为[]
  stateHooks.push(stateHook);
  // 当一个函数式组件有多个useState时,通过stateHooksIndex判断对应数据的存储位置
  stateHooksIndex++;

  currentFiber.stateHooks = stateHooks;

  function setState(action) {
    // 使用egaerState判断值有无变化,没有变化就不做处理,优化性能
    const egaerState =
      typeof action === "function" ? action(stateHook.state) : action;
    if (egaerState === stateHook.state) {
      return;
    }
    // 将action存入queue,处理setState调用多次的场景
    stateHook.queue.push(typeof action === "function" ? action : () => action);
    // 通过为wipRoot赋值,执行更新
    wipRoot = {
      ...currentFiber,
      alternate: currentFiber,
    };
    nextWorkOfUnit = wipRoot;
  }
  return [stateHook.state, setState];
}

useEffect的原理

function useEffect(action, deps) {
  // useEffect只是为useEffectHooks赋值,并存在当前的wipFiber上
  effectHooks.push({
    action,
    deps,
    cleanUp: undefined,
  });
  wipFiber.effectHooks = effectHooks;
}
...
// 等统一提交之后,调用commiyEffect,才能确保useEffect的action是在dom生成后调用
function commitEffect() {
  // 需要遍历每个子节点
  function runEffect(fiber) {
    if (!fiber) return;
    if (fiber.effectHooks) {
      if (!fiber.alternate) {
        // 首次渲染,将cleanup收集起来,等更新时统一调用
        fiber.effectHooks.forEach((effectHook) => {
          // 赋值同时调用action,当作初始化回调
          effectHook.cleanUp = effectHook.action();
        });
      } else {
        // 更新时,判断dep属性有没有变化,有变化则调用action
        fiber.effectHooks.forEach((effectHook, index) => {
          if (effectHook.deps.length > 0) {
            const oldEffectHook = fiber.alternate.effectHooks[index];
            const result = effectHook.deps.some((dep, i) => {
              return dep !== oldEffectHook.deps[i];
            });
            if (result) {
              effectHook.cleanUp = effectHook.action();
            }
          }
        });
      }
    }
    runEffect(fiber.child);
    runEffect(fiber.sibling);
  }

  // 组件更新时,会递归调用cleanup
  function runCleanUp(fiber) {
    if (!fiber) return;
    fiber.alternate?.effectHooks?.forEach((effectHook) => {
      if (effectHook.deps.length > 0) {
        effectHook.cleanUp && effectHook.cleanUp();
      }
    });
    runCleanUp(fiber.child);
    runCleanUp(fiber.sibling);
  }
  runCleanUp(wipRoot);
  runEffect(wipRoot);
}

补充

  • 如果对 mini-react 有兴趣可以看看大佬的总结Min React
  • 重构的时间点:最好在完成一个功能点之后再进行重构,既不要一遍写代码一边重构,也不要完成项目后,再统一用 1-2 天进行重构。4 月-5 月计划把《重构》读一遍,并整理读书笔记