手写一个精简版mini-react

382 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情

我的博客网站,学习react后写的,开源了管理系统及后台WebApi,欢迎查看交流 👉 www.weison-zhong.cn

1, 准备工作

  • 使用脚手架新建react项目 --> create-react-app mini-react
  • 删除多余的文件,简化目录结构
  • 也可以直接clone我的仓库github.com/Weison-Zhon…

2, JSX简介

  • JSX在编译时会被Babel编译转换,调用React.createElement方法(所以需要显示的import React from 'react';)
    • const element = <h1 title="foo">Hello</h1>会被自动编译为
      const element = React.createElement(
        "h1",
        { title: "foo" },
        "Hello"
      )
      
    • 该方法的返回值是一个包含type和props的对象(实际上还有其他属性,但目前仅需关注这两个)
        {
        type: "h1",
        props: {
          title: "foo",
          children: "Hello",
        }
      }
    

3, createElement方法

  • 新建一个Didact.js文件,用来保存我们自己的createElement方法。
/* 
...children的扩展运算符保证收集到的参数结果是数组类型
因为children数组中的项可能有原始类型,所以需要createTextElement包一层
*/
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) =>
        typeof child === "object" ? child : createTextElement(child)
      ),
    },
  };
}
function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  };
}
const Didact = {
  createElement,
};
export default Didact;
  • 在index.js中调用createElement方法。
import Didact from "./Didact";
//下面两行用于告诉Babel用我们的Didact.createElement而不是默认的React.createElement
/** @jsxRuntime classic */
/** @jsx Didact.createElement */
const element = (
  <div style="background: salmon">
    <h1>
      <span>Text in span</span>
    </h1>
    <h2 style="text-align:right">from Didact</h2>
    onlyText
  </div>
);
console.log({ element });
  • 调试发现,babel会从最内层的标签开始进入(这里就是span标签),然后自动帮我们收集好入参并调用createElement方法,返回处理好的js对象;
  • 然后继续进入h1标签,并帮我们处理好入参(这时候的...children会有上一步处理好的span标签的js对象);
  • 然后继续进入h2标签直到最后进入最外层div标签完成整个js对象树的创建。

4, render方法

  • 根据传入的标签类型生成一个DOM节点,之后将节点添加至根容器中;
    function render(element, container) {
    const dom = document.createElement(element.type)
    container.appendChild(dom)
    }
    
  • 递归遍历children数组创建DOM并添加到父标签中; element.props.children.forEach((child) => render(child, dom));
//最简单的render方法
function render(element, container) {
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type);
  const isProperty = (key) => key !== "children";
  //遍历React元素的props属性赋值给dom
  Object.keys(element.props)
    .filter(isProperty)
    .forEach((name) => {
      dom[name] = element.props[name];
    });
  //递归出口是没有子元素,即children为空数组
  element.props.children.forEach((child) => render(child, dom));
  container.appendChild(dom);
}
  • 在index.js调用render方法 Didact.render(element, document.getElementById("root"));

5, 同步的更新变为可中断的异步更新

  • 第4步中的render方法有个问题,一旦调用render就会进入递归停不下来直到渲染完成;
  • 如果元素很多就可能会阻塞主线程,导致无法处理如用户输入等操作;
  • 所以需要拆分渲染任务为一个个小单元,每完成一个单元的渲染,如果此时有更高优先级的任务则暂停渲染把主线程控制权交还给浏览器处理;

6, Fiber树--虚拟DOM

  • 1个fiber = 1个element元素 = 1个渲染单元;
    • 例如有下面的元素需要渲染
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>

fiber树

  • 从根节点进入调用performUnitOfWork方法,该方法有三个工作:
    • 创建当前fiber的DOM对象;
    • 创建子fiber;
    • 返回下一个处理单元。
  • 现阶段Didact.js代码如下,可尝试一步步调试理解。
/* 
...children的扩展运算符保证收集到的参数结果是数组类型
因为children数组中的项可能有原始类型,所以需要createTextElement包一层
*/
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) =>
        typeof child === "object" ? child : createTextElement(child)
      ),
    },
  };
}

function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  };
}

let nextUnitOfWork = null;

function render(element, container) {
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element],
    },
  };
  //目前先简单循环调用performUnitOfWork
  while (nextUnitOfWork) {
    workLoop();
  }
}

function workLoop() {
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}

function createDom(fiber) {
  const dom =
    fiber.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type);
  const isProperty = (key) => key !== "children";
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach((name) => {
      dom[name] = fiber.props[name];
    });

  return dom;
}
function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom);
  }
  const elements = fiber.props.children;
  let index = 0;
  let prevSibling = null;
  while (index < elements.length) {
    const element = elements[index];
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    };
    if (index === 0) {
      fiber.child = newFiber;
    } else {
      prevSibling.sibling = newFiber;
    }
    prevSibling = newFiber;
    index++;
  }
  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}

const Didact = {
  createElement,
  render,
};
export default Didact;

image.png

7, commit阶段

  • 第6步的render有个问题,每次调用performUnitOfWork处理一个节点单元时都会操作dom,但我们之前把递归渲染拆分为一个个单元时目的是在遇到更高优先级的任务时浏览器可打断循环优先处理其他任务,那如果现在的方式每处理一个fiber节点就操作dom会在浏览器打断时造成UI渲染不完整的情况;
  • 所以我们需要改进performUnitOfWork方法,去掉每个节点单元中插入dom的部分,改为在整颗fiber树处理完成后提交commitRoot;
  • 现阶段Didact.js代码如下,可尝试一步步调试理解。
/* 
...children的扩展运算符保证收集到的参数结果是数组类型
因为children数组中的项可能有原始类型,所以需要createTextElement包一层
*/
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) =>
        typeof child === "object" ? child : createTextElement(child)
      ),
    },
  };
}

function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  };
}

let nextUnitOfWork = null;
let wipRoot = null;
function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
  };
  nextUnitOfWork = wipRoot;
  //目前先简单循环调用performUnitOfWork
  while (nextUnitOfWork) {
    workLoop();
  }
}

function workLoop(deadline) {
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }
}

function createDom(fiber) {
  const dom =
    fiber.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type);
  const isProperty = (key) => key !== "children";
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach((name) => {
      dom[name] = fiber.props[name];
    });

  return dom;
}
function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  // if (fiber.parent) {
  //   fiber.parent.dom.appendChild(fiber.dom);
  // }
  const elements = fiber.props.children;
  let index = 0;
  let prevSibling = null;
  while (index < elements.length) {
    const element = elements[index];
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    };
    if (index === 0) {
      fiber.child = newFiber;
    } else {
      prevSibling.sibling = newFiber;
    }
    prevSibling = newFiber;
    index++;
  }
  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}

function commitRoot() {
  commitWork(wipRoot.child);
  wipRoot = null;
}
function commitWork(fiber) {
  if (!fiber) {
    return;
  }
  const domParent = fiber.parent.dom;
  domParent.appendChild(fiber.dom);
  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

const Didact = {
  createElement,
  render,
};

export default Didact;

8, 协调器

  • 截至目前,我们只是往DOM中添加节点,接下来要做到可更新和删除节点;
  • 为此我们需要currentRoot指针保存最后一次更新时的fiber树(即当前页面看到的),用于在更新时和新生成的fiber树比较差异;
  • 接下来实现一个简化的mini diff算法,思路是对比新旧fiber之间的差异:
    • 若type相同,则可复用旧节点,必要时更新下props;
    • 若type不同,且有新元素时则新建1个fiber节点;
    • 若type不同,且没新元素时则删除旧的fiber节点;
  • 下面是局部代码,建议先理解这部分
function performUnitOfWork(fiber) {
  //1,创建当前fiber的DOM;
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  //2,创建子fiber;
  const elements = fiber.props.children;
  reconcileChildren(fiber, elements); //与旧的fiber对比差异再决定如果处理子fiber
  //3,返回下一个处理单元;
  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}

function reconcileChildren(wipFiber, elements) {
  /*   此方法任务是对比正在创建的新fiber和已有旧fiber的差异,
  来决定是新增子fiber,还是更新子fiber的props,亦或是删除子fiber */
  let index = 0;
  let prevSibling = null;
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child; //旧的子fiber节点
  //当为删除子fiber时elements为空数组, oldFiber != null 成立
  while (index < elements.length || oldFiber != null) {
    const element = elements[index];
    let newFiber = null;
    const sameType = oldFiber && element && element.type === oldFiber.type;
    //type相同,直接复用旧fiber的dom,并更新下props
    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      };
    }
    //新增子fiber节点
    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      };
    }
    //删除旧fiber节点
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION";
      deletions.push(oldFiber);
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }
    if (index === 0) {
      wipFiber.child = newFiber;
    } else if (element) {
      //仅新增或更新子fiber时才改变兄弟节点指向
      prevSibling.sibling = newFiber;
    }
    prevSibling = newFiber;
    index++;
  }
}

9, 函数组件

  • 函数组件被babel转译的js对象是和原生html元素不同的;(函数组件type是function)
  • 为此我们要改造performUnitOfWork对于不同类型的组件要区别对待;
function performUnitOfWork(fiber) {
  //1,创建当前fiber的DOM;
  const isFunctionComponent = fiber.type instanceof Function;
  if (isFunctionComponent) {
    updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }
  省略...
  • 对于函数组件我们需要单独的方法updateFunctionComponent;
function updateFunctionComponent(fiber) {
  //fiber.type是函数,运行它会获得返回值,该返回值即children数组
  const children = [fiber.type(fiber.props)];
  reconcileChildren(fiber, children);
}
function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  reconcileChildren(fiber, fiber.props.children);
}
  • 改造commitWork方法
  • 为了确定函数组件的父DOM节点,我们需要在fiber数中向上找,直到找到一个有DOM节点的纤维。
let domParentFiber = fiber.parent
while(!domParentFiber.dom){
	domParentFiber = domParentFiber.parent
}
const domParent = domParentFiber.dom
  • 其次,当需要移除一个节点是,我们需要向下找直到找到一个具有DOM节点的子元素。
function commitDeletion(fiber, domParent){
	if(fiber.dom){
		domParent.removeChild(fiber.dom)
	}else{
		commitDeletion(fiber.child, domParent)
	}
}

10,Hooks

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.forEach((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];
}
let wipFiber = null; //work in progress fiber 当前在内存处理中的fiber树
let hookIndex = null;//hooks数组保存当在同一个函数组件中调用多次setState的state,hookIndex保存着当前hook的索引
/* 当一个函数组件里面有不止一个state时,hooks数组和hookIndex就发挥作用了
const [state, setState] = Didact.useState(1);
const [state2, setState2] = Didact.useState(2);
在updateFunctionComponent中,进入执行函数(从上往下走),分别调用两次useState,而hooks数组则保存着这些state,hookIndex用于
在下次进入updateFunctionComponent时在hooks数组中对应读取旧的state用的索引
 */
function updateFunctionComponent(fiber){
	wipFiber = fiber
	hookIndex = 0
	wipFiber.hooks = []
//	const children = [fiber.type(fiber.props)]
//	reconcileChildren(fiber, children)
}

源码:github.com/Weison-Zhon…

参考资料:

Build your own React

上面的demo是精简版的react实现,下面的专栏是真实源码学习笔记

React源码学习笔记专栏