233333(一)

231 阅读4分钟

先简单配置一下环境

项目结构

依赖

正文

我们都知道我们平时写jsx会转化为createElement函数,这件事情是babel帮我们做到的。那既然我们要做简易react那么就需要用自己的createElement函数。

那么怎么让babel编译时使用我们自己的createElement函数呢

通过/** @jsx myReact.createElement */来告诉babel编译jsx时使用我们的方法

第一步:createElement

我们先定义一个自己类, 先有createElement方法

const myReact = {
  createElement,
  render
}

把children节点分成两种情况, 1.children是text文本 2.children是jsx

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) =>
        typeof child === "object" ? child : createTextElement(child)
      )
    }
  }
}

text节点:

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

那么现在我们试一下,jsx 经过我们的createTextElement函数的返回值

const vdom = <h1>233</h1>

第二步:requestIdleCallback

React 16调度策略(Fiber)的异步、可中断自己实现了一套类似于requestIdleCallback和requestAnimationFrame组合的复杂机制(我也不知道)

这里我们就用window.requestIdleCallback这个api来简单模拟下

先简单的介绍下 requestIdleCallback 函数:该函数接收一个回调函数作为参数,简单的来说就是当浏览器渲染出现空闲桢的时候,将回调执行。这样在用户输入或者和页面交互时渲染不会出现卡顿(想象一下特别复杂的页面,主线程需要等待fiber绘制完成然后对比出差异,再去绘制页面,用户就会感觉到明显卡顿)。

mdn介绍

我们先定义一下nextUnitOfWork,先不用管这个是干啥的

let nextUnitOfWork = null

然后简单的看下参数函数workLoop,workLoop作为参数接受一个deadline参数,deadline.timeRemaining()可以获取到当前帧剩余时间

IdleDeadline介绍

requestIdleCallback(workLoop)

function workLoop(deadline) {
    let shouldYield = false;
    while (nextUnitOfWork && !shouldYield) {
        // ... 要做的事情
        shouldYield = deadline.timeRemaining() < 1; // 判断当前祯是否空闲
    }
    // 等浏览器空闲时再执行nextUnitOfWork
    requestIdleCallback(workLoop)
}

第三步:render

先获取一下要挂载的节点

const container = document.getElementById("root")
const element = <h2>2333</h2>
let wipRoot = null
function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element]
    }
  };
  nextUnitOfWork = wipRoot;
}
render(element, container)

render函数接受两个参数(vdom,要挂载的节点),我们先将wipRoot设置为根节点,并将nextUnitOfWork设置为根节点。还记得我们上一步while循环吗,我们调用render方法后,浏览器空闲的时候我们就可以开始挂载节点了,现在我们在requestIdleCallback函数中加一些逻辑。

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

performUnitOfWork函数接受一个节点作为参数,并返回下一个要挂载的节点。

performUnitOfWork函数主要就是要做3件事情

  • 在wipRoot的dom属性上添加真实的dom

  • 通过reconcileChildren函数构建传入节点的fiber

  • 确定 nextUnitOfWork

    function performUnitOfWork(fiber) {
      if (!fiber.dom) {
      	fiber.dom = createDom(fiber);
      }
      const elements = fiber.props.children;
      reconcileChildren(fiber, elements); // 通过vdom 构建fiber
      // 确定 nextFiber (nextUnitOfWork)
      if (fiber.child) {
        return fiber.child;
      }
      let nextFiber = fiber;
      while (nextFiber) {
        if (nextFiber.sibling) {
          return nextFiber.sibling;
        }
        nextFiber = nextFiber.parent;
      }
    }
    

那么现在让我们康康createDom函数和reconcileChildren函数吧

createDom分为2步

1.创建dom

function createDom(fiber) {
  const dom =
    fiber.type == "text"
      ? document.createTextNode("")
      : document.createElement(fiber.type); //创建真实dom
  updateDom(dom, {}, fiber.props); // 给dom 加上属性和事件
  return dom;
}

2.为dom加上属性和监听事件

const isEvent = (key) => key.startsWith("on"); // 以on 开头的 一般都是监听事件
const isProperty = (key) => key !== "children" && !isEvent(key); // 排除children和event 选出属性
const isNew = (prev, next) => (key) => prev[key] !== next[key]; // 判断是否是新属性(先不要看,后面更新会用到)
const isGone = (prev, next) => (key) => !(key in next); // 判断要删除的属性(先不要看,后面更新会用到)
// 为真实dom挂上属性和事件
function updateDom(dom, prevProps, nextProps) {
  //Remove old or changed event listeners
  Object.keys(prevProps)
    .filter(isEvent)
    .filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.removeEventListener(eventType, prevProps[name]);
    });
  // Remove old properties
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach((name) => {
      dom[name] = "";
    });
  // Set new or changed properties
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      dom[name] = nextProps[name];
    });
  // Add event listeners
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, nextProps[name]);
    });
}

其实不用管上面这段又臭又长的代码 总之最后就是把下面👇这样不含children的props挂到dom上

reconcileChildren函数

上面说过这个函数的作用是用来构建当前传入节点(elements)的fiber

function reconcileChildren(wipFiber, elements) {
  let index = 0;
  let prevSibling = null;

  while (index < elements.length) {
    const element = elements[index];
    let newFiber = null;
    newFiber = {
      type: element.type,
      props: element.props,
      dom: null,
      parent: wipFiber,
      alternate: null,
      effectTag: "add-element",
    };
    if (index === 0) {
      wipFiber.child = newFiber;
    } else if (element) {
      prevSibling.sibling = newFiber;
    }
    prevSibling = newFiber;
    index++;
  }
}

康康每个节点(element)都会通过这个函数生成fiber

 const Element = (
  <div>
    <input />
    <h2 onClick={() => {}} style={{ color: "#333333" }}>
      Hello
    </h2>
    <p>23333</p>
  </div>
)

遍历整个vdom的顺序是向下遍历,有chlidren先遍历chlidren,没有chlidren的话,寻找兄弟节点(sibling),都没有的话就返回父节点,最后返回根节点(上 => 下 => 上)。

从上面打印出的fiber也可以看出,performUnitOfWork函数中下面这段代码就是这个意思。

if (fiber.child) {
    return fiber.child;
  }
  // 确定 nextFiber (nextUnitOfWork)
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }

再康康最后的wipRoot(整棵fiber tree) 可以看到每个fiber之间都建立了关联关系

挂载dom

从上面的performUnitOfWork函数可以看出当该函数没有返回值也就是return undefined 的时候,也就意味这我们返回了最上层的节点。那么这时候我们就得到了整个fiber tree。(此时wipRoot是完整的tree) 那么我们就开始挂载dom啦!

给workLoop函数添加一行代码,增加commitRoot函数。

function workLoop(deadline) {
  let shouldYield = false;
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // 接受一个节点作为参数,并返回下一个要挂载的节点
    shouldYield = deadline.timeRemaining() < 1;
  }
+  if (!nextUnitOfWork && wipRoot) {
+   commitRoot(); //将fiber.dom 挂到真实的dom上
+  }
  requestIdleCallback(workLoop);
  console.log(wipRoot);
}

来看一下commitRoot函数

function commitRoot() {
  commitWork(wipRoot.child); // 挂dom
  wipRoot = null;
}

function commitWork(fiber) {
  if (!fiber) {
    return;
  }
  const domParent = fiber.parent.dom;
  if (fiber.effectTag === "add-element" && fiber.dom != null) {
    domParent.appendChild(fiber.dom);
  }
  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

就是简单的一层层向下遍历,然后一层层appendChild

好啦现在我们可以用自己的render方法将jsx挂载到dom上了。

myReact.render(Element, container)

那么我们的createElement和render方法就用最简单的方式实现了。

const myReact = { createElement, render }

但是现在我们只能够往上挂,也就是只能加不能更新和删除。接下来我们实现update,那么如何更新我们的dom呢,重新render的时候把原来的dom全部删除再append一次?这太丐了

于是我们需要将下一次要render的fiber tree和上一次render的fiber tree做比较。

我们需要记录下上一次render的wipRoot,我们叫它currenRoot吧

我们在每个fiber节点中增加一个alternate属性,这个属性值指向了oldfiber tree

let currentRoot = null
let deletions = null
function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
+    alternate: currentRoot //用于保存上一次渲染的fiber tree
  };
+ deletions = []; //用于收集被删除的节点
  nextUnitOfWork = wipRoot;
}

修改下reconcileChildren function, 通过wipRoot上的alternate属性也就是currentRoot,每次构建fiber的时候,获得上一次渲染的fiber,那么在生成新节点的时候就可以做比较。通过type和element来判断增删改,把要删除的fiber收集起来,在下一次生成dom前提前删除。

function reconcileChildren(wipFiber, elements) {
  let index = 0;
  let prevSibling = null;
+ let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
  while (index < elements.length || oldFiber != null) {
    const element = elements[index];
    let newFiber = null;

+    const sameType = oldFiber && element && element.type == oldFiber.type;

    // update
    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "update-element",
      };
    }
    // add
    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "add-element",
      };
    }
    // delete
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "delete-element";
      deletions.push(oldFiber);
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }
    if (index === 0) {
      wipFiber.child = newFiber;
    } else if (element) {
      prevSibling.sibling = newFiber; // 给上一个fiber添加sibling(elements.length>1)
    }
    prevSibling = newFiber;
    index++;
  }
}

在来修改下commitRoot函数

function commitRoot() {
+ deletions.forEach(commitWork); //再次挂载前先把要删除的节点先删掉
  commitWork(wipRoot.child); // 挂dom
+ currentRoot = wipRoot; // 记录上一次render的fiber tree, 下一次render做比较
  wipRoot = null;
}

commitWork函数

 function commitWork(fiber) {
  if (!fiber) {
    return;
  }
  const domParent = fiber.parent.dom;
  if (fiber.effectTag === "add-element" && fiber.dom != null) {
    domParent.appendChild(fiber.dom);
  } else if (fiber.effectTag === "update-element" && fiber.dom != null) {
+   updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  } else if (fiber.effectTag === "delete-element") {
+   domParent.removeChild(fiber.dom);
  }
  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

至此我们的render方法也已经完善了,现在来试一下。

let flag = true
const updateValue = (e) => {
  myRender(e.target.value);
};
const changeDom = (value) => {
  flag = false;
  myRender(value);
};
const myRender = (value) => {
  const Element = (
    <div>
      <input onInput={updateValue} value={value} />
      <h2 onClick={() => changeDom(value)}>Hello {value}</h2>
      {flag ? <p>123</p> : <span>321</span>}
    </div>
  );
  myReact.render(Element, container);
};

gif不会搞,想象一下吧。至此第一阶段完成。

现在我们的element都是正常的node节点,接下来我们实现function component和hook

算了放到下一篇文章