首先感谢崔大提供的课程。
下面是我的 mini-react 学习笔记。
记录了一些实现思路。
JavaScript pragma
一行注释修改 React.createElement
通过 /**@jsx HReact.createElement */ 一行注释即可将 React 变量名修改为 HReact 等其他名字。
// App.tsx
// highlight-start
/**@jsx HReact.create */
import HReact from "./core/React";
// highlight-end
// highlight-start
function AppOne() {
return <div id="app">hi, mini-react</div>;
}
console.log(AppOne);
// HReact.create function is called
// highlight-end
... ...
什么是 JavaScript pragma
在 JavaScript 语境中,"pragma" 是一种注释或者指令,通常用于告知编译器或解释器如何处理代码的某些部分。
在 JavaScript 和相关技术中,pragma 最常见的形式是在文件或代码段的顶部使用特殊的注释。
这些注释虽然不会改变代码的执行,但会影响代码的编译或转换过程。
两个常见的 pragma
"use strict"
"use strict" 是一个指示浏览器或 JavaScript 引擎进入严格模式(strict mode)的 pragma。
JSX pragma
JSX pragma 是用于指定当使用 JSX 时应该使用哪个函数来转换 JSX 表达式。
在 React 中,默认的 JSX pragma 是 React.createElement。
但也可以像这样,通过指定不同的 pragma 来改变这一行为。
利用浏览器空闲时间
之前 render 函数存在的问题
最初的 render 函数是通过递归实现的。
当处理一个非常大且深的虚拟 DOM 树时,单线程的 JavaScript 会不断递归地执行 render 操作,这最终可能导致浏览器后续渲染过程的阻塞。
window.requestIdleCallback API
通过浏览器提供的 requestIdleCallback 接口,可以利用浏览器空闲时间。
requestIdleCallback(callback, options) 函数可以插入一个函数,使其在浏览器空闲时间被调用。
参数 options 里只有执行超时时间 timeout 一个属性。
requestIdleCallback 能在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。
函数一般会按先进先调用的顺序执行。
然而,如果指定了执行超时时间 timeout,则有可能为了在超时前执行函数而打乱执行顺序。
具体而言,如果指定了 timeout,并且有一个正值,而回调在 timeout 毫秒过后还没有被调用,那么回调任务将放入事件循环中排队,即使这样做有可能对性能产生负面影响。
mini-react 里,直接按照先进先调用的顺序执行就可以。
IdleDeadline 是在调用 Window.requestIdleCallback(callback) 时创建的闲置回调 callback 的输入参数。
它提供了两个方法:
timeRemaining()方法,用来判断预计还剩余多少闲置时间;didTimeout属性,用来判断当前的回调函数是否因超时而被执行,是的话,didTimeout为true。
知道浏览器的空闲时间有多久后,就能利用空闲时间执行特定任务。
requestIdleCallback 与宏任务和微任务的关系
requestIdleCallback 计划的任务不属于宏任务或微任务的范畴,其在事件循环的空闲期执行。
这通常发生在当前帧的宏任务和微任务执行完毕后,且在浏览器认为有足够空闲时间可以用于处理低优先级工作时。
相比于宏任务和微任务,requestIdleCallback 计划的任务的优先级更低。
它们是在浏览器空闲时才执行,因此不会阻塞或延迟关键的用户界面更新和响应。
在实践中,
requestIdleCallback通常用于执行后台或低优先级的任务,如日志记录、数据分析、更新非关键的用户界面元素等。
在每个宏任务中都执行特定任务
下列代码中,workLoop 函数只会在第一个宏任务(T1)结束后被执行。
requestIdleCallback(workLoop);
需要在 workLoop 中继续调用 requestIdleCallback(workLoop) ,
才能保证在每个宏任务结束后,浏览器都会去执行特定任务。
function workLoop() {
...
requestIdleCallback(workLoop)
}
完整代码:
function workLoop(idleDeadline) {
let shouldYield = false;
while (!shouldYield) {
// do something
shouldYield = idleDeadline.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
实现 Fiber 架构
将 VDom 树转换成链表的结构来处理。
构建链表
按 child => sibling => uncle 的优先顺序去查找节点,
将 VDom 一部分一部分地转换成链表,再生成 DOM,
如果空闲时间不够,就下次空闲时间再接着进行渲染。
统一提交
统一提交的好处
在最后统一提交之前,进行 VDom 数据处理和 DOM 的生成。
数据处理和 DOM 生成都结束后,再一起提交到根结点。
由于最耗时的创建 DOM 和设置 DOM 属性都已经在提交前完成,
最后仅将生成好的 DOM append 进根结点就可以,
而这是耗时很小的。
想比之前处理完一部分 VDom 数据后,接着生成 DOM,并 append 到父节点,一口气做完三步。
统一提交提升框架的渲染性能,同时也避免了页面渲染到一半卡住的情况。
之前的方案可能会出现页面渲染到一半的时候,在当前帧浏览器没有空闲时间了,然后渲染就卡住了,导致页面渲染不流畅。
实现函数组件
函数组件其实就是一个函数。
而函数组件在被解析成 VDom 的时候,是直接将函数赋值给 VDom.type。
而 Dom 元素被解析成 VDom 的时候,是将 tagName 赋值给 VDom.type。
这样,如果是函数组件的话,我们需要先执行 VDom.type 这个函数,才能拿到其返回的带有 tagName 的 VDom。
这样就依据是否是 typeof x === "function" 来分别处理
- 创建 DOM
- 处理 Children
即可。
更新 VDom
先创建一个 update 函数,让它重新赋值 wipRoot 进行渲染。
在 fiber 上创建一个 alternate 属性指向老节点。
这样就可以通过这个指针拿到老的节点,从而实现新老对比,再根据其结果处理不同的情况。
更新 Children
删除节点策略:把要删除的节点都收集到一个数组里面,之后在 commitRoot 的时候,一起删除。
实现 useState 和 useEffect
useState 和 useEffect 都是将 state 和 effect & deps 等存在 fiber 节点上。
对于多个 state 使用一个数组来存储,取值的时候再用一个全局的 index 变量来取。每次重新渲染组件时重置 index。
useEffect 则根据 deps 来分情况处理 effects 即可。