JXS 是如何转换成 dom 的
JSX 是 JavaScript XML 的缩写,它是 React 中用于编写组件结构的一种语法糖。通过使用 JSX,我们可以以声明式的方式描述组件的结构,使代码更易读和维护。
浏览器无法直接解析 JSX 代码,需要使用 vite 等打包工具进行转换
// JSX代码
const element = <h1>Hello, world!</h1>;
// 转换后的代码
const element = React.createElement("h1", null, "Hello, world!");
转换之后就可以使用 MiniReact 中的 js 代码逻辑,生成描述元素的数据结构
// mini-react
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map((child) => {
return typeof child === "string" ? createTextNode(child) : child;
}),
},
};
}
最后可以统一把数据结构,渲染成 dom
function render(el, container) {
// 创建dom
const dom =
el.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(el.type);
// 绑定属性
Object.keys(el.props).forEach((key) => {
if (key !== "children") {
dom[key] = el.props[key];
}
});
// 递归生成child节点
const children = el.props.children;
children.forEach((child) => {
render(child, dom);
});
// 挂载到上一级dom
container.append(dom);
}
如何防止线程卡顿
因为 js 是单线程,如果createElement()与render()耗时太久,会造成页面卡顿。
mini-react 通过使用requestIdleCallbackAPI 进行优化处理。简单调用逻辑如实例代码。其中 deadline.timeRemaining()可以动态的计算出当前帧的剩余时间,如果时间不够,就等待下一次空闲。
// 定义一个任务函数
function myTask(deadline) {
// 当浏览器空闲时,会调用这个函数,并传入 deadline 参数
// 检查任务是否可以执行
if (deadline.timeRemaining() > 1) {
// 执行任务
doWork();
} else {
// 如果当前帧没有剩余时间,可以选择延迟任务到下一次空闲时段
requestIdleCallback(myTask);
}
}
// 发起请求空闲回调
requestIdleCallback(myTask);
如何将树形结构转换为链表结构
因为使用requestIdleCallback必须将任务拆解成可以中断/继续的子任务,所以需要我们把元素的树形结构,转换为链表结构。
mini-react 使用 fiber 数据结构在原有的[type,props]结构基础上,新增[child,parent,sublings]字段,确定每个元素的父节点,子节点(第一个子节点),兄弟节点。链表顺序为 优先找子节点,直到没有子节点了,找当前的兄弟节点,没有兄弟节点了去找夫元素的兄弟节点,然后不断重复(TODO 补个图,不补图了,直接放大佬的链接React Fiber 架构原理:关于 Fiber 树的一切)
所以目前的流程为
- 先将 jsx 转换为
[type,props]数据结构 - 再根据 props 中的 children 字段,递归将
[type,props]转为[type,props,child,parent,sublings]的链表数据结构 - requestIdleCallback,可以在空闲时,把链表数据一个一个的按顺序执行
如何防止渲染一半的情况发生
参考 render()函数,执行一个子任务的时候,我们需要做 4 件事情
- 创建 dom
- 绑定属性
- 递归生成 child 节点
- 挂载到上一级 dom
如果在构建 UI 期间,js 单线程执行了一件耗时任务,导致我们的 dom 挂载了一半,导致 UI 变形。
在 mini-react 中我们可以使用统一提交的思路,就是在执行单个任务中,只处理前三点逻辑,然后当我们的任务链表中的全部任务都处理完以后,统一执行 append 操作。就可以避免这个问题。
如何渲染 function 组件
打印 log,可以发现 function 组件的 type 是一个 function(就是 function 组件本身),调用这个 fucntion 就可以获得一个转换为[type,props](function 组件返回的是 jsx,然后打包工具会将 jsx 调用 React.createElement(),将 jsx 转换为[type,props]结构),这样就与普通的元素数据结构一样了。
ps:当然也不是完全一样,因为 function 组件是没有 dom 的,所以统一提交时,需要做判断。
如何处理事件监听
jsx 中绑定的事件会存在 props 中,所以更新 props 的时候,判断下属性名称是不是以 on 开头的,如果是就使用 addEventListener 绑定事件
function updateProps(dom, props) {
Object.keys(props).forEach((key) => {
if (key !== "children") {
if (key.startsWith("on")) {
const event = key.slice(2).toLocaleLowerCase();
dom.addEventListener(event, props[key]);
} else {
dom[key] = props[key];
}
}
});
}
如何更新元素
我们还是需要重新遍历一遍全部节点,才能对比出是否需要更新属性。
可以重新通过[type,props]构建新的 fiber 树,并同时新增 alternate 字段指向老的 fiber。
通过对比新老 fiber 的 type 就可以判断是修改属性,还是新增节点了。如果是修改属性,只需要把老的属性删除,并添加新的属性即可。
如何删除子节点
更新流程,遍历全部节点时,等新的 fiber 树构建完成,发现老的 dom 树还有节点时,可以把老的 fiber 收集起来,等到统一提交时,统一 remove 掉。
useState 的原理
function useState(initial) {
// 当构建fiber树,调用function组件的方法时,会赋值wipFiber为当前函数组件的fiber
let currentFiber = wipFiber;
// 如果时更新的话,获取上个组件的hook
let oldHook = currentFiber.alternate?.stateHooks[stateHooksIndex];
// 创建一个hook 如果之前有值,就用之前的值,如果没有就用初始化的值
const stateHook = {
id: generateRandomString(),
state: oldHook ? oldHook.state : initial,
queue: oldHook ? oldHook.queue : [],
};
// 如果是更新,则stateHook.queue值不为空,需要遍历执行action,获取到更新后的state值
stateHook.queue.forEach((action) => {
stateHook.state = action(stateHook.state);
});
stateHook.queue = [];
// 更新stateHooks数据 stateHooks在调用function组件的方法时,会赋值为[]
stateHooks.push(stateHook);
// 当一个函数式组件有多个useState时,通过stateHooksIndex判断对应数据的存储位置
stateHooksIndex++;
currentFiber.stateHooks = stateHooks;
function setState(action) {
// 使用egaerState判断值有无变化,没有变化就不做处理,优化性能
const egaerState =
typeof action === "function" ? action(stateHook.state) : action;
if (egaerState === stateHook.state) {
return;
}
// 将action存入queue,处理setState调用多次的场景
stateHook.queue.push(typeof action === "function" ? action : () => action);
// 通过为wipRoot赋值,执行更新
wipRoot = {
...currentFiber,
alternate: currentFiber,
};
nextWorkOfUnit = wipRoot;
}
return [stateHook.state, setState];
}
useEffect的原理
function useEffect(action, deps) {
// useEffect只是为useEffectHooks赋值,并存在当前的wipFiber上
effectHooks.push({
action,
deps,
cleanUp: undefined,
});
wipFiber.effectHooks = effectHooks;
}
...
// 等统一提交之后,调用commiyEffect,才能确保useEffect的action是在dom生成后调用
function commitEffect() {
// 需要遍历每个子节点
function runEffect(fiber) {
if (!fiber) return;
if (fiber.effectHooks) {
if (!fiber.alternate) {
// 首次渲染,将cleanup收集起来,等更新时统一调用
fiber.effectHooks.forEach((effectHook) => {
// 赋值同时调用action,当作初始化回调
effectHook.cleanUp = effectHook.action();
});
} else {
// 更新时,判断dep属性有没有变化,有变化则调用action
fiber.effectHooks.forEach((effectHook, index) => {
if (effectHook.deps.length > 0) {
const oldEffectHook = fiber.alternate.effectHooks[index];
const result = effectHook.deps.some((dep, i) => {
return dep !== oldEffectHook.deps[i];
});
if (result) {
effectHook.cleanUp = effectHook.action();
}
}
});
}
}
runEffect(fiber.child);
runEffect(fiber.sibling);
}
// 组件更新时,会递归调用cleanup
function runCleanUp(fiber) {
if (!fiber) return;
fiber.alternate?.effectHooks?.forEach((effectHook) => {
if (effectHook.deps.length > 0) {
effectHook.cleanUp && effectHook.cleanUp();
}
});
runCleanUp(fiber.child);
runCleanUp(fiber.sibling);
}
runCleanUp(wipRoot);
runEffect(wipRoot);
}
补充
- 如果对 mini-react 有兴趣可以看看大佬的总结Min React
- 重构的时间点:最好在完成一个功能点之后再进行重构,既不要一遍写代码一边重构,也不要完成项目后,再统一用 1-2 天进行重构。4 月-5 月计划把《重构》读一遍,并整理读书笔记