简单实现 React 的渲染流程(2)

前言

众所周知,React是一个数据驱动视图的框架,在视图渲染更新时,就会涉及到 虚拟DOM、Fiber、Diff算法 之类的内容,这些内容的原理作为前端面试八股文已经屡见不鲜了,但我还从未想过如何从代码层面实现。

最近遇到了一个很有趣的网站:Build your own React,这个网站用简洁的代码讲述、实现了React是如何让视图进行渲染更新的。

我将以我的理解大致翻译该网站的内容,并进行代码层面的实现。

这篇文章,我们接着上次的内容继续,实现对函数式组件的渲染

上一篇文章指路:简单实现 React 的渲染流程(1)

七、函数式组件

函数式组件有两个明显的不同点

1、函数式组件对应的Fiber节点没有DOM节点属性

2、Fiber节点的children属性不通过props直接获取,而是通过运行函数获取

那么我们就需要根据这两点对代码进行修改。

注意:没有DOM节点属性仅是指函数式组件对应的Fiber节点,其子级节点仍有DOM节点属性。也就是说仅type为Function的Fiber节点不存在DOM节点属性,它是用来运行获取子级节点们的。

首先,判断当前的Fiber节点是否为函数式组件,对performUnitOfWork函数进行修改:

js 复制代码
......
const isFunctionComponent = fiber.type instanceof Function;
if (isFunctionComponent) {
  updateFunctionComponent(fiber);
} else {
  updateHostComponent(fiber);
}
......

原先的逻辑我们原封不动地封装到updateHostComponent函数中:

js 复制代码
function updateHostComponent(fiber) {
  // 将元素添加到真实DOM结构中
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }

  // 创建当前元素的子级Fiber节点们,构建Fiber Tree
  const elements = fiber.props.children;
  reconcileChildren(fiber, elements);
}

对于函数式组件我们进行特殊处理:

js 复制代码
function updateFunctionComponent(fiber) {
  const elements = [fiber.type(fiber.props)];
  reconcileChildren(fiber, elements);
}

其实就是上面说的第1点,通过运行函数获取子级元素。获取到子级元素后的逻辑与之前无异。

根据函数式组件Fiber节点没有DOM节点属性的特性,在commitWork函数中,添加和删除操作依赖于DOM节点 ,因此我们要对其进行处理,对于没有DOM节点属性的Fiber节点作跳过处理,继续往上/下寻找存在DOM节点属性的父/子级节点

js 复制代码
function commitWork(fiber) {
  if (!fiber) return;
  
  let domParentFiber = fiber.parent;
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent;
  }
  const domParent = domParentFiber.dom;

  if (fiber.effectTag === "PLACEMENT" && fiber.dom !== null) {
    domParent.appendChild(fiber.dom);
  } else if (fiber.effectTag === "DELETION") {
    commitDeletion(fiber, domParent);
  } else if (fiber.effectTag === "UPDATE" && fiber.dom !== null) {
    updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  }

  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

删除操作中寻找子节点 的逻辑我们将其封装到commitDeletion函数中:

js 复制代码
function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom);
  } else {
    commitDeletion(fiber.child, domParent);
  }
}

至此,我们成功地实现了函数式组件的渲染流程。

八、Hooks

函数式组件的一大特点就是hooks,取代了类式组件中的state和生命周期函数。其中最常用 的hook有useStateuseEffect,这一章节我们将实现useState的底层逻辑

首先,我们初始化一些全局变量 ,将hooks相关的属性挂载在函数式组件对应的Fiber节点上

js 复制代码
let wipFiber = null;
let hookIndex = null;
function updateFunctionComponent(fiber) {
  // 初始化hooks相关全局变量,将其挂载在函数式组件Fiber节点上
  wipFiber = fiber;
  hookIndex = 0;
  wipFiber.hooks = [];

  // 创建当前元素的子级Fiber节点们,构建Fiber Tree
  // 函数式组件的子级节点通过运行函数获取
  const elements = [fiber.type(fiber.props)];
  reconcileChildren(fiber, elements);
}

一个函数式组件中可能有多个state ,涉及到多个useState的初始化,所以我们通过一个全局的数组 来保存各个state的值 ,并且追踪各个state的索引值,以此来获取对应的值。

因为函数式组件每一次渲染都会重新执行 ,但state的值不应该每次都初始化,为了获取最新的值,我们将每一次渲染时的state值挂载在函数式组件对应的Fiber节点上。在useState函数中,我们先判断上一次渲染的Fiber节点上是否存在hooks属性,如果有的话就将数据继承下来,否则进行初始化。

js 复制代码
function useState(initial) {
  const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex];
  const hook = { state: oldHook ? oldHook.state : initial };

  wipFiber.hooks.push(hook);
  hookIndex++;

  return [hook.state];
}

useState还应该返回一个setState函数用于修改state 。另外,在state更新时 会触发组件的重新渲染 ,所以在setState中我们还要给wipRoot赋值,触发渲染流程。

js 复制代码
function useState(initial) {
  const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex];
  const hook = { state: oldHook ? oldHook.state : initial, queue: [] };

  const actions = oldHook ? oldHook.queue : [];
  actions.map(action => {
    hook.state = action(hook.state);
  });

  const setState = action => {
    hook.queue.push(action);
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot
    };
    nextUnitOfWork = wipRoot;
    deletions = [];
  }

  wipFiber.hooks.push(hook);
  hookIndex++;

  return [hook.state, setState];
}

setState接收一个参数action,由于一次渲染可能会执行多个action ,所以我们用一个数组 来保存actions,并在下一次渲染时依次进行调用,返回更新后的state值。

这样我们就实现了useState,我们来调用试试看:

js 复制代码
function App(props) {
  const [count, setCount] = Teact.useState(0);
  const [showName, setShowName] = Teact.useState(true);

  return (
    <div id="1" onClick={() => {
      setCount(prevState => prevState + 1);
      setShowName(prevState => !prevState);
    }}>
      <h1>Hi {showName ? props.name : ""} {count}</h1>
    </div>
  );
}

const element = <App name="Toby" />;
const container = document.getElementById("root");
Teact.render(element, container);

设置了两个state,可以正常运行!

至此,我们实现了对函数式组件中useState这个hook的实现。

九、查漏补缺

问题1:函数式组件重新渲染时不会清空上一次的渲染结果

由于函数式组件对应的Fiber节点没有dom节点属性的特殊性,在添加真实DOM节点时,会向上寻找其父级节点 ,这就导致了本该被删除的函数式组件节点的子级元素们被添加到向上一级的父级节点上,所以出现了这个问题(正常情况是子级元素被添加到已被删除的节点中,所以没有影响)。

所以我们对effectTagDELETION的函数式组件节点进行特殊处理,不进行后续真实DOM的构造:

js 复制代码
if (fiber.effectTag === "DELETION" && fiber.type instanceof Function) return;

问题2:新增的节点没有添加监听事件

新增的节点没有添加监听事件,仅更新的节点添加了监听事件。

js 复制代码
function updateListener(dom, props) {
  // 添加监听事件
  Object.keys(props).filter(isEvent).map(name => {
    const eventType = name.toLowerCase().substring(2);
    dom.addEventListener(eventType, props[name]);
  });
}

简单补一下。

小结

在这篇文章中,我们实现了函数式组件的渲染流程 ,并对函数式组件中的hooks 有了一定的了解,实现了useState这个hook。

Git项目地址:Build-Your-Own-React

原文链接:Build your own React

相关推荐
2401_857610032 小时前
深入探索React合成事件(SyntheticEvent):跨浏览器的事件处理利器
前端·javascript·react.js
fighting ~4 小时前
react17安装html-react-parser运行报错记录
javascript·react.js·html
老码沉思录4 小时前
React Native 全栈开发实战班 - 列表与滚动视图
javascript·react native·react.js
老码沉思录4 小时前
React Native 全栈开发实战班 - 状态管理入门(Context API)
javascript·react native·react.js
老码沉思录7 小时前
写给初学者的React Native 全栈开发实战班
javascript·react native·react.js
老码沉思录7 小时前
React Native 全栈开发实战班 - 第四部分:用户界面进阶之动画效果实现
react native·react.js·ui
奔跑草-13 小时前
【前端】深入浅出 - TypeScript 的详细讲解
前端·javascript·react.js·typescript
林太白20 小时前
❤React-React 组件通讯
前端·javascript·react.js
豆华20 小时前
React 中 为什么多个 JSX 标签需要被一个父元素包裹?
前端·react.js·前端框架
前端熊猫20 小时前
React第一个项目
前端·javascript·react.js