先简单配置一下环境
项目结构
依赖
正文
我们都知道我们平时写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绘制完成然后对比出差异,再去绘制页面,用户就会感觉到明显卡顿)。
我们先定义一下nextUnitOfWork,先不用管这个是干啥的
let nextUnitOfWork = null
然后简单的看下参数函数workLoop,workLoop作为参数接受一个deadline参数,deadline.timeRemaining()可以获取到当前帧剩余时间
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
算了放到下一篇文章