I. 实现事件绑定
1. JSX中的写法
在App.jsx
的Counter
子组件中添加一个button 和 onClick
函数。
js
// App.jsx
function Counter(props) {
function handleClick() {
console.log("click");
}
return (
<div>
count: {props.num}
<button onClick={handleClick}>click</button>
</div>
)
}
2. 在fiber节点中拿到key和value
在processUnitOfWork
中打印每一个fiber 就可以看到button对应的fiber。其中props.onClick
就是我们写的函数。
3. 在为fiber节点创建dom之后的 updateProps 环节添加event listener
在处理props的function updateProps
中拿到这个function 并且用 element.addEventListener 来添加即可。
js
function updateProps(dom, props) {
Object.keys(props).forEach((key) => {
if (key !== "children") {
// 支持的事件命名都形如"on" + "Click", 这样就可以处理多种事件了
if (key.startsWith("on")) {
const eventType = key.slice(2).toLowerCase();
dom.addEventListener(eventType, props[key]);
}
dom[key] = props[key];
}
});
}
II. 更新props
1. props保存在哪里?本身如何更新
在mini-react里为了简便,props是保存在函数组件外面的变量里,通过handleClick
函数直接赋值来更新。先写一个React.update()
函数用来执行一轮props更新。
js
let count = 10;
let props = {id :"1213131"};
function Counter() {
// update props - 后续会用useState来触发props更新
function handleClick() {
console.log("click");
count++;
props = {};
React.update(); // 手动改变props以及调用update
}
return (
<div {...props}>
count: {count}
<button onClick={handleClick}>click</button>
</div>
)
}
由于我们还没有实现hook系统,这里就手动地更新props以及手动调用一个update
函数来模拟props更新。render
函数用来处理第一次的组件渲染,update
函数来处理后续的props更新。
js
function render(el, container) {
// 将vdom的根节点作为children,创建到 root container 上
// 这是第一个入队的任务
wipRoot = {
dom: container, // root container dom是dom树的根节点
props: {
children: [el],
},
};
nextWorkOfUnit = wipRoot;
}
function update() {
wipRoot = {
dom: currentRoot.dom, // root container dom是dom树的根节点
props: currentRoot.props,
alternate: currentRoot,
};
console.log(currentRoot.props, "update");
nextWorkOfUnit = wipRoot;
}
2. props的更新将会反映到DOM上呢?
props的更新,可能会导致两种更新:
- 节点类型不变,但是属性改变。例如
id
从111
变成222
- 节点类型变化。例如从
div
变成TEXT_ELEMENT
回看render
函数执行的第一次渲染,函数组件的渲染包含这些关键步骤:
- 执行函数组件函数得到节点的vdom:每个vdom节点包含props和children
- 根据vdom节点创建前后关联的fiber节点,逐级处理children
- 为children节点创建DOM,设置DOM props
- 执行
commitWork
或commitRoot
,将dom节点append到dom树上。
相对应地,update
函数执行的第二次渲染,和第一次渲染也很相像。区别在于
- 如果节点类型不变:
- 第3步,第一次渲染生成的DOM还在,跳过这一步
- 第4步,基于原有的
fiber.dom
,设置新的props
- 如果节点类型变化:
- 第3步:和第一次渲染一样,创建DOM和设置DOM props
- 第4步,和第一次渲染一样,将dom节点append到dom树上
3. 具体实现
3.1. 如何保存旧的fiber树和DOM树
在commitRoot 中记录一下本轮已经render完的fiber树。每个fiber节点包含了其DOM和函数组件的函数。再次执行这些函数,就能得到新的fiber节点。进而可以通过创建或者更新
js
let currentRoot = null;
function commitRoot() {
commitWork(root.child);
currentRoot = root; // 保存当前的fiber树, 给下次用来做diff
root = null
}
自问自答
问:上面的代码中currentRoot
是通过保存第一轮渲染的root
得到的,里面保存的是旧的fiber树,props更新的时候会更新这个fiber树吗?如果不是,那props保存在哪,旧的fiber树如何使用新的props?
答:props更新的时候并不会直接更新保留的fiber树。currentRoot
虽然是通过保存第一轮渲染的root
得到的,但是里面存储的是App
函数和Counter
函数。当我们手动调用我们自己写的 React.update()
的时候拿到的仍然是这些函数,相关的props是存储在函数外面的变量,执行函数的时候自然会使用新的props 来构建更新后的节点。可想而知,如果Counter函数组件使用了props来进行条件render,那么自然会得到一个新的fiber结构和dom结构。
【自问自答小总结】:包含了函数组件的fiber树并不是一个静态的数据结构,而是会通过执行fiber树中存储的函数加上外部存储的props来得到数据。
3.2. 如何方便地为每个fiber节点找到上一轮的fiber节点以便对比?
在原来fiber树双向链表的基础上,再添加一个链接:每个节点对应的老节点
这个属性命名为alternate
,意思为『备用』
【代码实现】
- 为初次render之后的节点设置
alternate
属性,指向oldFiber
- 对于fiber节点的children们
- 如果是第一个child,
oldFiber = fiber.alternate?.child
- 对于其它的child,与新的fiber节点遍历一样,通过sibling的链接来获得
oldFiber = oldFiber.sibling
- 如果是第一个child,
- 为每个节点标注更新类型是DOM属性更新------
update
------例如div的id发生变化;还是节点类型更新------placement
------需要创建DOM。
js
function reconcileChildren(fiber, children) { // reconcile 包含了init和update
let prevChild = null;
let oldFiber = fiber.alternate?.child;
let newFiber;
children.forEach((child, index) => {
const isSameType = child && oldFiber && child.type === oldFiber.type;;
if (isSameType) {
// update
newFiber = {
type: child.type,
props: child.props,
child: null,
parent: fiber,
sibling: null,
dom: oldFiber.dom,
effectTag: "update", // 标记一下,这个fiber节点是更新老节点得来的
alternate: oldFiber,
};
} else {
//create
newFiber = {
type: child.type,
props: child.props,
child: null,
parent: fiber,
sibling: null,
dom: null,
effectTag: "placement", // 标记一下,这个fiber节点是新建的
};
}
// 本次的fiber树会移动到sibling节点,oldFiber 也移动到兄弟节点。保证diff的时候能找到对应的节点
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
if (index === 0) {
fiber.child = newFiber;
} else {
prevChild.sibling = newFiber;
}
prevChild = newFiber;
});
}
3.3 将fiber节点的更新应用到DOM上
根据props导致的更新是节点类型还是属性更新来做不同的处理:
- 节点类型变化,则append dom
- DOM是通过在reconcileChildren之前的
fiber.dom === null
来判断需要执行createDom
- DOM是通过在reconcileChildren之前的
- 节点属性变化,则执行
updateProps
更新DOM props
js
function commitWork(fiber) {
if (!fiber) return
let fiberParent = fiber.parent;
while (!fiberParent.dom) {
fiberParent = fiberParent.parent
}
if (fiber.dom) {
fiberParent.dom.append(fiber.dom)
}
// 根据props导致的更新是节点类型还是属性更新来做不同的处理 --Start
if (fiber.effectTag === "update" && fiber.dom) {
// 提供新的和旧的props用来做diff
updateProps(fiber.dom, fiber.props, fiber.alternate?.props)
} else if (fiber.effectTag === "placement" && fiber.dom) {
fiberParent.dom.append(fiber.dom)
}
// 根据props导致的更新是节点类型还是属性更新来做不同的处理 --End
commitWork(fiber.child)
commitWork(fiber.sibling)
}
DOM属性更新
对于节点类型不变,仅更新属性值的不同情况进行处理:
- old有,new没有的属性就删除
- new有的,不管old有没有,都通过赋值来处理
js
function updateProps(dom, nextProps, prevProps) {
// 1. old 有,new 没有,删除
Object.keys(prevProps).forEach((key) => {
if (key !== "children") {
if (!(key in nextProps)) {
dom.removeAttribute(key);
}
}
});
// 2. old 有,new 有,更新
// 3. old 没有,new 有,新增
Object.keys(nextProps).forEach((key) => {
if (key !== "children") {
if (prevProps[key] !== nextProps[key]) {
if (key.startsWith("on")) {
const eventType = key.slice(2).toLowerCase();
dom.removeEventListener(eventType, prevProps[key]);
dom.addEventListener(eventType, nextProps[key]);
} else {
dom[key] = nextProps[key];
}
}
}
});
}
小结
至此,我们完成了props更新导致的两种更新的处理。测试一下:
- 一开始,Counter组件的id有值
1. 节点的id
属性更新
- 点击后,更新id属性消失了
2. 节点类型从div变成p
- 可以看到dom中append了新的p元素。
【问题】第一次渲染的div还保留着,这是因为在reconcileChildren
中只针对新的fiber节点做了更新和新建标记。没有对oldFiber做删除。这点可以再优化,但是基本不影响我们理解本节课的内容。