-
回顾react
我们不妨回顾一下在react中我们是如何书写的
import ReactDOM from 'react-dom/client';
const root = ReactDOM.createRoot(document.getElementById('root'));
import App from './App';
root.render(<App />);
让我们来一行一行解读:
- 首先第一行代码导入了浏览器端负责渲染的模块,实际上关于react和ReactDom分别实现在两个模块我个人理解为react负责核心逻辑的部分并不关注渲染方式,而react-dom更侧重于渲染,这种设计可以让react即可以运行在web端(react-dom/client),也可以运行在服务端(react-dom/server),同时也可以运行在移动端(react Native),这样的设计大大提高了react的灵活性。
- 第二行字面意思就是创建一个根节点。
- 第三行引入app函数。
- 第四行调用了root中的渲染函数,但是这里需要注意的是是jsx的一种写法,浏览器不认识,实际上会由react-scripts转化为下面代码:
root.render(React.createElement(App));
总结:我们实际上最核心的就是实现一个render函数和createElement函数。
function createElement(node){
//下面讲
}
function render(root,node){
//root表示真实dom,node表示虚拟dom
}
-
createElement函数
在下文变量命名中,我们统一将root和dom视为真实dom,node视为虚拟dom,同时为了大家能够更加直观的理解本文的内容,我们拿一个具体的案例去实现
我们暂时(因为暂时不涉及function(函数组件),后面我们做补充)将目标设置为渲染成这样的真实dom
return (
<div id="div1">
<p id="p1">我是p1</p>
<p id="p2">我是p2</p>
</div>
);
那我们的jsx会把以上代码转化为以下内容,并且虚拟dom为node
React.createElement(
"div",
{ id: "div1" },
React.createElement("p", { id: "p1" }, "我是p1"),
React.createElement("p", { id: "p2" }, "我是p2")
);
const node = {
type:"div",
props:{
id:"div1",
children:[
{
type:"p",
props:{
id:"p1",
children:[
{
type:"TEXT_ELEMENT",
props:{
nodeValue:"我是p1",
children:[]
}
}
]
}
},
{
type:"p",
props:{
id:"p2",
children:[
{
type:"TEXT_ELEMENT",
props:{
nodeValue:"我是p1",
children:[]
}
}
]
}
}
]
}
}
接下来我们来看看不同node的结构是怎么样的:
- 普通虚拟dom
const node = {
type:"div",//标签类型
props:{//子属性
children:[a,b,c],//子元素 a,b,c
id:"title"//id属性
}
}
- 子元素是文本的虚拟dom
{
type:"TEXT_ELEMENT",
props:{
nodeValue:"我是p1",
children:[]
}
}
可以看出createElement接受3个参数,第一个是标签类型,第二个是属性,第三个和以后是子节点,那么我们的createElement应该长这样。
function createElement(type,props,...children){
return {
type:type,
props:{
...props,
children:children.map(child=>{
return typeof child ==='object'?child:createTextElement(child)
})
}
}
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
-
Render函数
render函数更简单,我们只需创建虚拟dom,再用创建的虚拟dom创建真实dom,最后添加到container中
function render(container) {
const root = document.getElementById(container);
const nodeElement = createElement(
"div",
{ id: "div1" },
createElement("p", { id: "p1" }, "我是p1"),
createElement("p", { id: "p2" }, "我是p2")
);//创建虚拟dom
const dom = createDom(nodeElement);//创建真实dom
container.appendChild(dom)//添加到真实dom中
}
const isProps = (key) => key !== "children";
function createDom(node){
const dom = node.type==="TEXT_ELEMENT"?document.createTextNode(""):document.createElement(node.type);
Object.keys(node.props)
.filter(isProps)
.forEach((key) => {
dom[key] = node.props[key]
}); //为dom添加属性
node.props.children.forEach(child=>{
dom.appendChild(createDom(child))
})//递归为子元素创建dom
return dom
}
render(document.getElementById("root"));
我们如果不出意外的话就可以看到这样的效果了
-
分时函数与fiber(分片)
当然,我们按照以上内容确实可以实现虚拟dom到真实dom的转变,但是如果dom节点很多,层次又很深的话,那么这样必然会阻塞浏览器的渲染,那么现在我们就需要用到浏览器为我们提供的api requestIdleCallback(请求空闲回调),具体使用请点击链接,我们需要做以下步骤
- 不能像之前一样一次性把所有内容全部创建好,我们需要对当前虚拟dom进行分片,切片后我们一般叫fiber
- 那fiber和我们刚才的虚拟dom有什么区别?答:在原来的基础上多了一些属性,如下
- sliding:兄弟元素fiber;
- parent:父元素fiber;
- child:子元素fiber;
- dom:对应的真实dom引用;
- hooks: 后面写hook的时候我会提到;
浏览器空闲时间处理逻辑如下:
function workLoop(idelDeadline){
let isRemain = true
while(isRemain&&nextWork){
nextWork = performWork(nextWork)//执行当前工作单元
isRemain = idelDeadline.timeRemain()>1//还有剩余时间吗?
}
requestIdleCallback(workLoop)//下次有空闲继续执行
}
requestIdleCallback(workLoop)
function performWork(fiber){
//执行当前fiber并返回下一个工作单元
//1.为当前fiber创建dom;
//2.将当前dom添加到父亲fiber的dom;
//3.为子元素创建新的fiber;
//4.返回下一个需要执行的fiber;
}
框架就这样搭建好了,接下来我们要完善细节,先说一下思路,performWork处理当前工作单元,那执行什么呢?
- 为当前fiber创建dom;
在此之前,我们需要修改我们的createDom函数,传入的不再是node,而是fiber,并且我们不需要递归为子元素创建,我们只需要为当前元素创造
function createDom(fiber) {
const dom =
fiber.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type); //创建当前dom
Object.keys(fiber.props)
.filter(isProps)
.forEach((key) => {
dom[key] = fiber.props[key];
}); //为dom添加属性
return dom;
}
function performWork(fiber){
//执行当前fiber并返回下一个工作单元
//1.创建为当前fiber创建dom;
if(!fiber.dom){
fiber.dom = createElement(fiber)
}
//2.将当前dom添加到父亲fiber的dom;
if(fiber.parent.dom){
fiber.parent.dom.appendChild(fiber.dom)
}
//3.为子元素创建新的fiber;
//4.返回下一个需要执行的fiber;
}
- 将当前dom添加到父亲fiber的dom;
function performWork(fiber){
//执行当前fiber并返回下一个工作单元
//1.创建为当前fiber创建dom;
if(!fiber.dom){
fiber.dom = createElement(fiber)
}
//2.将当前dom添加到父亲fiber的dom;
if(fiber.parent.dom){
fiber.parent.dom.appendChild(fiber.dom)
}
//3.为子元素创建新的fiber;
//4.返回下一个需要执行的fiber;
}
- 为子元素创建新的fiber;
function performWork(fiber) {
//执行当前fiber并返回下一个工作单元
//1.创建为当前fiber创建dom;
if (!fiber.dom) {
fiber.dom = createElement(fiber);
}
//2.将当前dom添加到父亲fiber的dom;
if (fiber.parent.dom) {
fiber.parent.dom.appendChild(fiber.dom);
}
//3.为子元素创建新的fiber;
const elements = fiber.props.children;
const preFiber = null; //上一个fiber,大家可以理解为链表,来串联兄弟节点,也就是这里的p1和p2
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
child: null, //这里我们没给值,是要在子节点中给,也就是i===0的时候,也就是第25行
sliding: null,
dom: null,
};
if (i === 0) {
fiber.child = newFiber;
} else if (i > 0) {
preFiber.sliding = fiber; //串联兄弟节点
}
preFiber = fiber;
}
//上面的流程中,child,sliding,parent,dom,我们全部正确的给值了。
//4.返回下一个需要执行的fiber
if (fiber.child) {
return fiber.child;//优先子节点
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sliding) {
return nextFiber.sliding;//如果有兄弟就是兄弟节点
}
nextFiber = nextFiber.parent;//回到父元素
}
return nextFiber;
}
- 返回下一个需要执行的fiber;
先来说结论,结论是有子节点就优先子节点,没有的话就返回兄弟节点,如果兄弟节点也没有,那就是父亲节点的兄弟节点(也就是叔叔节点)。
有的同学可能会疑问为什么是这样呢?大家不妨回想一下我们最开始createDom的逻辑,虽然我们是通过递归实现的,但是细心的同学应该能注意到递归的顺序其实和我们现在返回fiber的顺序是一致的。这里我给大家画个图理解,图1是fiber理论上的操作顺序,图二是dom结构。
<div id="root">
<div id="div1">
<p id="p1">我是p1</p>
<p id="p2">我是p2</p>
</div>
<div id="div2">
<p id="p3">我是p3</p>
<p id="p4">我是p4</p>
</div>
</div>
function performWork(fiber) {
console.log('fiber',fiber)
//执行当前fiber并返回下一个工作单元
//1.创建为当前fiber创建dom;
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
//2.将当前dom添加到父亲fiber的dom;
if (fiber.parent && fiber.parent.dom) {
fiber.parent.dom.appendChild(fiber.dom);
}
//3.为子元素创建新的fiber;
const elements = fiber.props.children;
let preFiber = null; //上一个fiber,大家可以理解为链表,来串联兄弟节点,也就是这里的p1和p2
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
child: null, //这里我们没给值,是要在子节点中给,也就是i===0的时候
sliding: null,
dom: null,
};
if (i === 0) {
fiber.child = newFiber;
} else if (i > 0) {
preFiber.sliding = newFiber; //串联兄弟节点
}
preFiber = newFiber;
}
//上面的流程中,child,sliding,parent,dom,我们全部正确的给值了。
//4.返回下一个需要执行的fiber
if (fiber.child) {
return fiber.child;//优先子节点
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sliding) {
return nextFiber.sliding;//如果有兄弟就是兄弟节点
}
nextFiber = nextFiber.parent;//回到父元素
}
return nextFiber;
}
最后我们还需要修改render,将下一个待执行单元设定为根节点,与此同时,浏览器就会在空闲时间进行渲染,不出意外的话,我们还是可以得到最终效果。
function render(container,fiber) {
nextWork = {
dom:container,
props:{
children:[fiber]
}
}
}
-
更改操作
虽然现在我们实现了最基本的功能,但是现在如果dom结构有更新的话,我们应该怎么办呢?
- 第一种方案就是我们根据新的fiber,从根节点开始创建新的fiber,创建新的dom
- 第二种方案我们可以保留上一次dom和fiber,只对变化的属性更新相应的属性
很显然,第二种方案是最优的,那我们该怎么做呢?(思考一会后)😂我们当然可以保存上一次状态的fiber,然后更新的时候对这两个fiber进行对比,所以我们的fiber又多了一个属性 alternate 来保存上一个状态的fiber,那么我们应该什么时候去比较呢,没错就是创建fiber的时候,也就是在 performWork 函数中,在比较中,我们主要判断节点类型,其中有以下3种类型,并且用effectTag来标记fiber
- 更新(update),标签类型相同,我们将新创建的fiber,标记为update
- 替换(placement),标签类型不同,我们将新创建的fiber,标记为placement
- 删除(deletion),标签类型不同,我们将老的fiber,标记为deletion
接下来我们来做一些修改
-
render更改
我们知道,对于普通的fiber,我们全部已经有了alternate属性,但是对于根fiber,还没有,所以我们要用一个全局变量(preRootFiber)保存一下上一次的状态,rootFiber保存当前状态
let rootFiber = null
let preRootFiber = null
function render(container, elements) {
rootFiber = {
dom: container,
props: {
children: [elements],
},
alternate:preRootFiber//记录上一次的根节点
};
nextWork =rootFiber //更新
}
-
performWork更改
- 对于1.为当前fiber创建dom的操作,我们放到对比新老fiber的操作中,如果是placement,则新增dom。
- 对于2.将当前dom添加到父亲fiber的dom,我们需要也是同样的,只有effectTag是placement的时候,才需要。
- 对于3.为子元素创建新的fiber,我们需要对比新老fiber,为子元素创建fiber
reconcileChildren函数,下面会讲到,最后操作dom(commitWork),我们将这俩功能分别实现在单独的函数中。 - 第4步保持不变
function performWork(fiber) {
//执行当前fiber并返回下一个工作单元
const elements = fiber.props.children;
//3.比较新老fiber并创建fiber
reconcileChildren(fiber, elements);
commitWork(fiber)
//上面的流程中,child,sliding,parent,dom,我们全部正确的给值了。
//4.返回下一个需要执行的fiber
if (fiber.child) {
return fiber.child; //优先子节点
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sliding) {
return nextFiber.sliding; //如果有兄弟就是兄弟节点
}
nextFiber = nextFiber.parent; //回到父元素
}
return nextFiber;
}
-
reconcileChild函数
本质上还是为子节点创建fiber,只不过多了effectTag(变化类型)属性和alternate(上一个状态的fiber)属性,比较的过程如下图,代码在下面
function reconcileChildren(wipFiber, elements) {
//当前正在操作的fiber和子元素
let preFiber = null;//前一个fiber,作用仍然是链接兄弟节点
let oldFiber = wipFiber.alternate && wipFiber.alternate.child; //去对应elements的第一个元素
for (let i = 0; i < elements.length || oldFiber; i++) {
const element = elements[i];
let newFiber = null;
const isSameType = element && oldFiber && element.type === oldFiber.type;//比较类型
if (isSameType && element) {//类型相同,更新dom即可
newFiber = {
type: element.type,
props: element.props,
parent: wipFiber,
child: null, //这里我们没给值,是要在子节点中给,也就是i===0的时候
sliding: null,
dom: oldFiber.dom,
alternate: oldFiber,//上一个fiber
effectTag: "update",
};
} else if (!isSameType && element) {//类型不同,新增
newFiber = {
type: element.type,
props: element.props,
parent: wipFiber,
child: null, //这里我们没给值,是要在子节点中给,也就是i===0的时候
sliding: null,
dom: null,
alternate: null,
effectTag: "placement",
};
}
if (!isSameType && oldFiber) {//类型不同,需要删除老节点
oldFiber.effectTag = "deletion";
}
if (i === 0) {
wipFiber.child = newFiber;//设置子节点
}
if (i !== 0 && i < elements.length) {
preFiber.sliding = newFiber;//串联兄弟节点
}
oldFiber = oldFiber && oldFiber.sliding;//老节点往下走
preFiber = newFiber;
}
}
-
commitWork(根据effectTag操作dom)
function commitWork(fiber) {
if (fiber.effectTag === "update") {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === "placement") {
fiber.dom = createDom(fiber);//创建后,添加子元素中
updateDom(fiber.dom, {}, fiber.props);
fiber.parent.dom.appendChild(fiber.dom);
} else if (fiber.effectTag === "deletion") {
fiber.parent.dom.removeChild(fiber.dom);//删除当前dom
}
}
function updateDom(dom, preProps, nextProps) {
const isNew = (key) => preProps[key] !== nextProps[key]; //是不是新值
const isPre = (key) => !(key in preProps); //是不是老值
const isEvent = (key) => key.startsWith("on");
Object.keys(nextProps)
.filter(isProps)
.filter(isNew)
.forEach((key) => {
dom[key] = nextProps[key];
}); //更新属性
Object.keys(nextProps)
.filter(isProps)
.filter(isNew)
.filter(isEvent)
.forEach((event) => {
const eventName = event.substring(2).toLowerCase();
dom.addEventListener(eventName, nextProps[event]);
}); //添加事件
Object.keys(preProps)
.filter(isProps)
.filter(isPre)
.forEach((key) => {
dom[key] = "";
}); //删除属性
Object.keys(preProps)
.filter(isProps)
.filter(isPre)
.filter(isEvent)
.forEach((event) => {
const eventName = event.substring(2).toLowerCase();
dom.removeEventListener(eventName, nextProps[event]);
}); //添加事件
//style样式大家可以自己添加
}
于是我们的performWork变成了这样
function performWork(fiber) {
//执行当前fiber并返回下一个工作单元
const elements = fiber.props.children;
//3.比较新老fiber并创建fiber
reconcileChildren(fiber, elements);
commitWork(fiber)
//上面的流程中,child,sliding,parent,dom,我们全部正确的给值了。
//4.返回下一个需要执行的fiber
if (fiber.child) {
return fiber.child; //优先子节点
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sliding) {
return nextFiber.sliding; //如果有兄弟就是兄弟节点
}
nextFiber = nextFiber.parent; //回到父元素
}
return nextFiber;
}
这里请大家思考一个问题,第六行的commitWork能够操作effectTag等于deletion的情况吗?(思考了一会🤔)
答:这里很显然不能处理删除的情况,因为我们是 oldFiber.effectTag = "deletion" ,我们是对旧的fiber更新effectTag,而这里的fiber是刚刚创建的新的fiber,再次提示大家,performWork的作用是处理刚才创建的fiber,并为子节点创建fiber,那我们如何处理?
答:需要用一个全局变量存储需要删除的fiber,然后在比较完全部fiber之后,我们遍历需要删除的oldfiber,删除dom结构,我们需要更改workLoop,在没有可以执行工作单元并且有没删除的oldfiber时,去删除
let deletions = []//全局变量
function workLoop(idelDeadline) {
let isRemain = true;
while (isRemain && nextWork) {
nextWork = performWork(nextWork); //执行当前工作单元,并返回下一个工作单元
isRemain = idelDeadline.timeRemaining() > 1; //还有剩余时间吗?
}
if(!nextWork&&rootFiber){
commitDeletion()//删除
preRootFiber = rootFiber;//保存这一次的根节点
rootFiber = null;//这次为null
}
requestIdleCallback(workLoop); //下次继续执行
}
function commitDeletion(){
deletions.forEach(commitWork)
deletions = []
}
好了,如果大家代码没写错的话,我们来验证一下,当点击整个大容器后,就只会剩余一个我是p3的标签
const node = createElement(
"div",
{
id: "div1",
onClick: () => {
const newNode = createElement(
"div",
{},
createElement("p", { }, "我是p3")
);
render(document.getElementById("root"),newNode);
},
},
createElement("p", { id: "p1" }, "我是p1"),
createElement("p", { id: "p2" }, "我是p2")
);
render(document.getElementById("root"), node);
-
支持Function
在react,我们经常会像1.1这样写,当然,这是jsx写法,实际转化过来是1.2这样
//1.1
import App from './App';
root.render(<App />);
//1.2
import App from './App';
root.render(React.createElement(App,{}));
也就是说,createElement的第一个参数type也有可能是一个函数,而且函数组件没有本事没有dom结构,函数组件的返回值也就是他的children节点,总结一下特点
- 函数组件的createElement的第一个参数type传入的是一个函数
- 函数组件自己没有真实dom,dom来自于返回值是children节点
- 函数组件有hooks属性,后面会提到
那我们要怎么做呢?(我们要明白函数组件是没有dom结构的,所以主要影响的就是commitWork(操作dom)函数)
-
performWork函数
函数组件获取子元素的方法和其他fiber不同,通过调用 [fiber.type(fiber.props)] 来获取组件
function performWork(fiber) {
//执行当前fiber并返回下一个工作单元
const isFunction = fiber.type instanceof Function;
const elements = isFunction
? [fiber.type(fiber.props)]
: fiber.props.children;
//3.比较新老fiber并创建fiber
reconcileChildren(fiber, elements);
commitWork(fiber);
//。。。。。。
}
-
commitWork函数
function commitWork(fiber) {
if (fiber.type instanceof Function &&fiber.effectTag!=="deletion") {
return;
}//对于不是函数组件的fiber,是没有update和placement操作的
if (!fiber.root) {//我们为根节点做了标记,根节点是没有parent的
var parentFiber = fiber.parent;//用var做变量提升
while (!parentFiber.dom) {
parentFiber = parentFiber.parent;
}//因为有了函数组件,我们不能确保每个fiber上全有dom,所以我们要一直往父节点上找真实的dom,placement和deletion全部需要用到父dom
}
if (fiber.effectTag === "update") {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);//更新dom即可
} else if (fiber.effectTag === "placement") {
fiber.dom = createDom(fiber); //创建后,添加子元素中
updateDom(fiber.dom, {}, fiber.props);
parentFiber.dom.appendChild(fiber.dom);
} else if (fiber.effectTag === "deletion") {
if(fiber.dom){//说明不是函数组件
parentFiber.dom.removeChild(fiber.dom); //删除当前dom
}else{//说明是函数组件,我们需要继续往下找,但是后续的子fiber的effectTag不一定是deletion,我们需要手动赋值
fiber.child.effectTag = "deletion"
commitWork(fiber.child)
}
}
}
-
引入jsx写法
后续的内容为了方便大家书写,我在这里引入了babal的cdn文件
注意⚠️ /** @jsx createElement */ 这一行注释很关键,会用我们自己写的createElement函数
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>
//大家在html中导入即可
/** @jsx createElement */
function App() {
return <ul>
<li >
这是li
</li>
</ul>;
}//这样babel会自动将jsx转化为我们自己写的createElement函数形式
-
hooks
在hooks的编写中,我们为fiber新增一个hooks的数组属性,里面保存当前函数组件所有的hooks,并且我们需要一个索引来追踪当前hook,我们声明为hookIndex,因为我们在执行到hooks的组件时,是无法获取到他当前属于的fiber的,所以我们还需要声明一个全局变量,currentFiber来表示我们正在操作的fiber,所以我们需要在performWork中初始化currentFiber,并且在执行函数组件后,将hookIndex重置为0;
let hookIndex = 0
let currentFiber = null
function performWork(fiber) {
console.log(fiber, "currentFiber");
//执行当前fiber并返回下一个工作单元
currentFiber = fiber;//赋值
const isFunction = fiber.type instanceof Function;
if (isFunction) {
fiber.hooks = [];//初始化hooks
}
let elements = isFunction
? [fiber.type(fiber.props)]//实现函数组件
: fiber.props.children;
hookIndex = 0;//重置为0
//...
}
-
useState
官网地址:zh-hans.react.dev/reference/r…
function useState(state) {
let oldFiber =
currentFiber.alternate &&
currentFiber.alternate.hooks &&
currentFiber.alternate; //获取上一个fiber
const hook = {
type: "useState",
};
if(oldFiber){
hook.state = oldFiber.hooks[hookIndex].state //说明是后续渲染,用上一次的值
}else{
hook.state = state //说明是组件初次渲染,用第一次函数传入的值
}
const setState = (callback) => {
const value =
callback instanceof Function ? callback(hook.state) : callback; //用户可能传入值,也有可能传入表达式
hook.state = value; //更新state
rootFiber = {
dom: preRootFiber.dom,
props: preRootFiber.props,
alternate: preRootFiber,
root: "root",
}; //重新渲染
nextWork = rootFiber;
deletions = [];
};
hook.setState = setState;
currentFiber.hooks.push(hook); //保存到当前fiber中
hookIndex++;//索引递增
return [hook.state, setState];
}
-
useEffect
官网地址:zh-hans.react.dev/reference/r…
function useEffect(callback, dependencies) {
let oldFiber =
currentFiber.alternate &&
currentFiber.alternate.hooks &&
currentFiber.alternate;
const hook = {
type: "useEffect", //类型
dependencies: dependencies, //每次拿最新的
destruction:
oldFiber && oldFiber.hooks[hookIndex].destruction
? oldFiber.hooks[hookIndex].destruction
: null, //销毁函数
};
if (dependencies === void 0) {
//安全的返回undefined,防止用户声明undefined
hook.destruction = callback(); //如果不传任何依赖性,每次组件渲染全会更新
}
if (dependencies.length === 0) {
if (!oldFiber) {
hook.destruction = callback(); //当没有依赖项时,只有初始化的时候会执行一次,并且获取销毁回调
}
} else {
if (oldFiber) {
const isChange = oldFiber.hooks[hookIndex].dependencies.some(
(dependency, index) => !Object.is(dependency, dependencies[index])
); //查看依赖项是否变化
if (isChange) {
hook.destruction = callback(); //变化就执行
}
}
}
currentFiber.hooks.push(hook);
hookIndex++;
}
我们记录了当组件销毁时需要执行的函数,但是我们还没有去执行,现在我们需要到commitWork中去执行(28行)
function commitWork(fiber) {
if (fiber.type instanceof Function && fiber.effectTag !== "deletion") {
return;
} //对于不是函数组件的fiber,是没有update和placement操作的
if (!fiber.root) {
//我们为根节点做了标记,根节点是没有parent的
var parentFiber = fiber.parent;
while (!parentFiber.dom) {
parentFiber = parentFiber.parent;
} //因为有了函数组件,我们不能确保每个fiber上全有dom,所以我们要一直往父节点上找真实的dom,placement和deletion全部需要用到父dom
}
if (fiber.effectTag === "update") {
updateDom(fiber.dom, fiber.alternate.props, fiber.props); //更新dom即可
} else if (fiber.effectTag === "placement") {
fiber.dom = createDom(fiber); //创建后,添加子元素中
updateDom(fiber.dom, {}, fiber.props);
parentFiber.dom.appendChild(fiber.dom);
} else if (fiber.effectTag === "deletion") {
if (fiber.dom) {
//说明不是函数组件
parentFiber.dom.removeChild(fiber.dom); //删除当前dom
} else {
//说明是函数组件,我们需要继续往下找,但是后续的子fiber的effectTag不一定是deletion,我们需要手动赋值
fiber.child.effectTag = "deletion";
commitWork(fiber.child);
//如果是函数组件中包含useEffect hooks 那么我们需要执行销毁的回调
fiber.hooks
.filter((item) => item.type === "useEffect")
.forEach((item) => {
if (item.destruction) {
item.destruction(); //执行销毁回调
}
});
}
}
}
-
useMemo
官网地址:zh-hans.react.dev/reference/r…
function useMemo(fn, dependencies) {
let oldFiber =
currentFiber.alternate &&
currentFiber.alternate.hooks &&
currentFiber.alternate;
const hook = {
type: "useMemo",
dependencies: dependencies,
};
if (oldFiber) {//后续渲染
let isChange = oldFiber.hooks[hookIndex].dependencies.some(
(dependency, index) => {
return !Object.is(dependency, dependencies[index]);
}
);
if (isChange) {//对比依赖性是否有变化
hook.result = fn();
} else {
hook.result = oldFiber.hooks[hookIndex].result;//没有变化就用原来的,避免再次计算
}
} else {//初次渲染,我们之间调用返回结果
hook.result = fn();
}
currentFiber.hooks.push(hook);
hookIndex++;
return hook.result;
}
-
useCallback
官网地址:zh-hans.react.dev/reference/r…
function useCallback(fn, dependencies) {
let oldFiber =
currentFiber.alternate &&
currentFiber.alternate.hooks &&
currentFiber.alternate;
const hook = {
type: "useCallback",
dependencies: dependencies,
};
if (oldFiber) {//后续渲染
let isChange = oldFiber.hooks[hookIndex].dependencies.some(
(dependency, index) => {
return !Object.is(dependency, dependencies[index]);
}
);
if (isChange) {
hook.fn = hook.fn.bind(null); //依赖有变化,就返回一个新的函数
} else {
hook.fn = oldFiber.hooks[hookIndex].fn; //没有变化就返回旧的函数
}
} else {
hook.fn = fn; //初次,返回fn
}
currentFiber.hooks.push(hook);
hookIndex++;
return hook.fn;
}
-
useRef
官网地址:zh-hans.react.dev/reference/r…
function useRef(initialValue) {
let oldFiber =
currentFiber.alternate &&
currentFiber.alternate.hooks &&
currentFiber.alternate;
const hook = {
type: "useRef",
};
if (oldFiber) {
const oldHook = oldFiber.hooks[hookIndex];
currentFiber.hooks.push(hook);
hookIndex++;
return oldHook;
} else {
hook.current = initialValue;
}
currentFiber.hooks.push(hook);
hookIndex++;
return hook;
}
我们知道,ref还可以引用dom,我们还需要在updateDom中对于有ref属性的dom,为其添加引用;
function updateDom(dom, preProps, nextProps) {
const isNew = (key) => preProps[key] !== nextProps[key]; //是不是新值
const isPre = (key) => !(key in preProps); //是不是老值
const isEvent = (key) => key.startsWith("on");
const isRef = (key) => key === "ref";
//省略。。。
Object.keys(nextProps)
.filter(isRef)
.filter(isNew)
.forEach((key) => {
nextProps[key].current = dom;
}); //为ref添加dom
//省略。。。
Object.keys(preProps)
.filter(isRef)
.filter(isPre)
.forEach((key) => {
preProps[key].current = null;
}); //为ref删除dom
//style样式大家可以自己添加
}
-
细节问题
- 对于placement的节点,我们真的应该使用在commitWork中真的应该使用appendChild吗?
答:不能,假如遇到以下函数组件
function App2() {
let [show, setShow] = useState(true);
const handleClick = ()=>{
setShow(!show)
}
return (
<div>
<p>我是p1</p>
{show ? <p>我是p2</p> : <div>我是p3</div>}
<button onClick={handleClick}>切换</button>
</div>
);
}
render(document.getElementById("root"), <App2 />);
当点击切换前后对比,很显然,div3的位置是错误的,appendChild会添加到父亲元素的最后,所以这里我们应该改用insertBefore,mdn文档点这里
function commitWork(fiber){
//....
else if (fiber.effectTag === "placement") {
fiber.dom = createDom(fiber); //创建后,添加子元素中
updateDom(fiber.dom, {}, fiber.props);
if (fiber.sliding && fiber.sliding.dom) {//后续渲染
parentFiber.dom.insertBefore(fiber.dom, fiber.sliding.dom);
} else {//首次渲染
parentFiber.dom.appendChild(fiber.dom);
}
}
//.....
}
- 我们知道,react支持map创建子元素,如果我们不做任何修改可以吗?
答:不行,假如遇到以下组件
function App3() {
return (
<div>
{new Array(100).fill(0).map(item=>{
return <p>循环创建的p标签</p>
})}
</div>
);
}
这样的话,div的children属性将会是一个长度只有 1 的数组
显然,我们希望长度是 100,所以我们要对这个数组进行扁平化处理。
function flatObjectArr(arr) {
//对象数组扁平化
return arr.reduce((acc, item) => {
if (item instanceof Array) {
acc.push(...flatObjectArr(item));
} else {
acc.push(item);
}
return acc;
}, []);
}
function performWork(fiber){
//省略
let elements = isFunction
? [fiber.type(fiber.props)]
: fiber.props.children;
elements = flatObjectArr(elements); //扁平化对象数组,有可能遇到map的情况
//省略
}
-
源码
基本上对较难理解的每一行代码全部加了注释。
在线运行点这里:codesandbox.io/p/sandbox/b…
-
react.js
const isProps = (key) => key !== "children" && key !== "style";
let deletions = []; //待删除的fiber
let rootFiber = null; //当前根fiber
let preRootFiber = null; //上一次的根fiber
let currentFiber = null; //正在执行的fiber
let hookIndex = 0; //正在执行的fiber的hook的索引
let nextWork = null; //下一个待执行的fiber
function createElement(type, props, ...children) {
return {
type: type,
props: {
...props,
children: children.map((child) => {
return typeof child === "object" ? child : createTextElement(child);
}),
},
};
}
function createDom(fiber) {
const dom =
fiber.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type); //创建当前dom
Object.keys(fiber.props)
.filter(isProps)
.forEach((key) => {
dom[key] = fiber.props[key];
}); //为dom添加属性
return dom;
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
function render(container, elements) {
rootFiber = {
dom: container,
props: {
children: [elements],
},
alternate: preRootFiber,
root: "root",
};
nextWork = rootFiber;
}
function workLoop(idelDeadline) {
let isRemain = true;
while (isRemain && nextWork) {
nextWork = performWork(nextWork); //执行当前工作单元,并返回下一个工作单元
isRemain = idelDeadline.timeRemaining() > 1; //还有剩余时间吗?
}
if (!nextWork && rootFiber) {
console.log(rootFiber);
preRootFiber = rootFiber;
rootFiber = null;
commitDeletion();
}
requestIdleCallback(workLoop); //下次继续执行
}
requestIdleCallback(workLoop);
function reconcileChildren(wipFiber, elements) {
//当前正在操作的fiber和子元素
let preFiber = null; //前一个fiber,作用仍然是链接兄弟节点
let oldFiber = wipFiber.alternate && wipFiber.alternate.child; //去对应elements的第一个元素
for (let i = 0; i < elements.length || oldFiber; i++) {
const element = elements[i];
let newFiber = null;
const isSameType = element && oldFiber && element.type === oldFiber.type; //比较类型
if (isSameType && element) {
//类型相同,更新dom即可
newFiber = {
type: element.type,
props: element.props,
parent: wipFiber,
child: null, //这里我们没给值,是要在子节点中给,也就是i===0的时候
sliding: null,
dom: oldFiber.dom,
alternate: oldFiber,
effectTag: "update",
};
} else if (!isSameType && element) {
//类型不同,新增
newFiber = {
type: element.type,
props: element.props,
parent: wipFiber,
child: null, //这里我们没给值,是要在子节点中给,也就是i===0的时候
sliding: null,
dom: null,
alternate: null,
effectTag: "placement",
};
}
if (!isSameType && oldFiber) {
//类型不同,需要删除老节点
oldFiber.effectTag = "deletion";
deletions.push(oldFiber);
}
if (i === 0) {
wipFiber.child = newFiber; //设置子节点
}
if (i !== 0 && i < elements.length) {
preFiber.sliding = newFiber; //串联兄弟节点
}
oldFiber = oldFiber && oldFiber.sliding; //老节点往下走
preFiber = newFiber;
}
}
function commitDeletion() {
deletions.forEach(commitWork);
deletions = [];
}
function commitWork(fiber) {
if (fiber.type instanceof Function && fiber.effectTag !== "deletion") {
return;
} //对于不是函数组件的fiber,是没有update和placement操作的
if (fiber.root) {
return; //根节点有dom了,不需要任何操作
}
//我们为根节点做了标记,根节点是没有parent的
var parentFiber = fiber.parent;
while (!parentFiber.dom) {
parentFiber = parentFiber.parent;
} //因为有了函数组件,我们不能确保每个fiber上全有dom,所以我们要一直往父节点上找真实的dom,placement和deletion全部需要用到父dom
if (fiber.effectTag === "update") {
updateDom(fiber.dom, fiber.alternate.props, fiber.props); //更新dom即可
} else if (fiber.effectTag === "placement") {
fiber.dom = createDom(fiber); //创建后,添加子元素中
updateDom(fiber.dom, {}, fiber.props);
if (fiber.sliding && fiber.sliding.dom) {
parentFiber.dom.insertBefore(fiber.dom, fiber.sliding.dom);
} else {
parentFiber.dom.appendChild(fiber.dom);
}
} else if (fiber.effectTag === "deletion") {
if (fiber.dom) {
//说明不是函数组件
parentFiber.dom.removeChild(fiber.dom); //删除当前dom
} else {
//说明是函数组件,我们需要继续往下找,但是后续的子fiber的effectTag不一定是deletion,我们需要手动赋值
fiber.child.effectTag = "deletion";
commitWork(fiber.child);
//如果是函数组件中包含useEffect hooks 那么我们需要执行销毁的回调
fiber.hooks
.filter((item) => item.type === "useEffect")
.forEach((item) => {
if (item.destruction) {
item.destruction(); //执行销毁回调
}
});
}
}
}
function updateDom(dom, preProps, nextProps) {
console.log(arguments,'args')
const isNew = (key) => preProps[key] !== nextProps[key]; //是不是新值
const isPre = (key) => !(key in preProps); //是不是老值
const isEvent = (key) => key.startsWith("on");
const isRef = (key) => key === "ref";
Object.keys(nextProps)
.filter(isProps)
.filter(isNew)
.forEach((key) => {
dom[key] = nextProps[key];
}); //更新属性
Object.keys(nextProps)
.filter(isProps)
.filter(isNew)
.filter(isEvent)
.forEach((event) => {
const eventName = event.substring(2).toLowerCase();
dom.addEventListener(eventName, nextProps[event]);
}); //添加事件
nextProps.style &&
Object.keys(nextProps.style)
.filter((key) => {
if (!preProps.style) {
return true;
} else {
return preProps.style[key] !== nextProps.style[key];
}
})
.forEach((key) => {
dom.style[key] = nextProps.style[key];
});
Object.keys(nextProps)
.filter(isRef)
.filter(isNew)
.forEach((key) => {
nextProps[key].current = dom;
}); //为ref添加dom
Object.keys(preProps)
.filter(isProps)
.filter(isPre)
.forEach((key) => {
dom[key] = "";
}); //删除属性
Object.keys(preProps)
.filter(isProps)
.filter(isPre)
.filter(isEvent)
.forEach((event) => {
const eventName = event.substring(2).toLowerCase();
dom.removeEventListener(eventName, nextProps[event]);
}); //添加事件
Object.keys(preProps)
.filter(isRef)
.filter(isPre)
.forEach((key) => {
preProps[key].current = null;
}); //为ref删除dom
preProps.style &&
Object.keys(preProps.style)
.filter((key) => {
if (!nextProps.style) {
return true;
} else {
return !(key in nextProps.style)
}
})
.forEach((key) => {
dom.style[key] = ''
});
}
function flatObjectArr(arr) {
//对象数组扁平化
return arr.reduce((acc, item) => {
if (item instanceof Array) {
acc.push(...flatObjectArr(item));
} else {
acc.push(item);
}
return acc;
}, []);
}
function performWork(fiber) {
//执行当前fiber并返回下一个工作单元
currentFiber = fiber;
const isFunction = fiber.type instanceof Function;
if (isFunction) {
fiber.hooks = [];
}
let elements = isFunction
? [fiber.type(fiber.props)] //扁平化对象数组,有可能遇到map的情况
: fiber.props.children;
elements = flatObjectArr(elements);
hookIndex = 0;
//3.比较新老fiber并创建fiber
reconcileChildren(fiber, elements);
commitWork(fiber);
//上面的流程中,child,sliding,parent,dom,我们全部正确的给值了。
//4.返回下一个需要执行的fiber
if (fiber.child) {
return fiber.child; //优先子节点
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sliding) {
return nextFiber.sliding; //如果有兄弟就是兄弟节点
}
nextFiber = nextFiber.parent; //回到父元素
}
return nextFiber;
}
function useEffect(callback, dependencies) {
let oldFiber =
currentFiber.alternate &&
currentFiber.alternate.hooks &&
currentFiber.alternate;
const hook = {
type: "useEffect", //类型
dependencies: dependencies, //每次拿最新的
destruction:
oldFiber && oldFiber.hooks[hookIndex].destruction
? oldFiber.hooks[hookIndex].destruction
: null, //销毁函数
};
if (dependencies === void 0) {
//安全的返回undefined,防止用户声明undefined
hook.destruction = callback(); //如果不传任何依赖性,每次组件渲染全会更新
} else if (dependencies.length === 0) {
if (!oldFiber) {
hook.destruction = callback(); //当没有依赖项时,只有初始化的时候会执行一次,并且获取销毁回调
}
} else {
if (oldFiber) {
const isChange = oldFiber.hooks[hookIndex].dependencies.some(
(dependency, index) => !Object.is(dependency, dependencies[index])
); //查看依赖项是否变化
if (isChange) {
hook.destruction = callback(); //变化就执行
}
}
}
currentFiber.hooks.push(hook);
hookIndex++;
}
function useState(state) {
let oldFiber =
currentFiber.alternate &&
currentFiber.alternate.hooks &&
currentFiber.alternate; //获取上一个fiber
const hook = {
type: "useState",
};
if (oldFiber) {
hook.state = oldFiber.hooks[hookIndex].state; //说明是后续渲染,用上一次的值
} else {
hook.state = state; //说明是组件初次渲染,用第一次函数传入的值
}
const setState = (callback) => {
const value =
callback instanceof Function ? callback(hook.state) : callback; //用户可能传入值,也有可能传入表达式
hook.state = value; //更新state
rootFiber = {
dom: preRootFiber.dom,
props: preRootFiber.props,
alternate: preRootFiber,
root: "root",
}; //重新渲染
nextWork = rootFiber;
deletions = [];
};
hook.setState = setState;
currentFiber.hooks.push(hook); //保存到当前fiber中
hookIndex++; //索引递增
return [hook.state, setState];
}
function useCallback(fn, dependencies) {
let oldFiber =
currentFiber.alternate &&
currentFiber.alternate.hooks &&
currentFiber.alternate;
const hook = {
type: "useCallback",
dependencies: dependencies,
};
if (oldFiber) {
let isChange = oldFiber.hooks[hookIndex].dependencies.some(
(dependency, index) => {
return !Object.is(dependency, dependencies[index]);
}
);
if (isChange) {
hook.fn = hook.fn.bind(null); //依赖有变化,就返回一个新的函数
} else {
hook.fn = oldFiber.hooks[hookIndex].fn; //没有变化就返回旧的函数
}
} else {
hook.fn = fn; //初次,返回fn
}
currentFiber.hooks.push(hook);
hookIndex++;
return hook.fn;
}
function useMemo(fn, dependencies) {
let oldFiber =
currentFiber.alternate &&
currentFiber.alternate.hooks &&
currentFiber.alternate;
const hook = {
type: "useMemo",
dependencies: dependencies,
};
if (oldFiber) {
let isChange = oldFiber.hooks[hookIndex].dependencies.some(
(dependency, index) => {
return !Object.is(dependency, dependencies[index]);
}
);
if (isChange) {
hook.result = fn();
} else {
hook.result = oldFiber.hooks[hookIndex].result;
}
} else {
hook.result = fn();
}
currentFiber.hooks.push(hook);
hookIndex++;
return hook.result;
}
function useRef(initialValue) {
let oldFiber =
currentFiber.alternate &&
currentFiber.alternate.hooks &&
currentFiber.alternate;
const hook = {
type: "useRef",
};
if (oldFiber) {
const oldHook = oldFiber.hooks[hookIndex];
currentFiber.hooks.push(hook);
hookIndex++;
return oldHook;
} else {
hook.current = initialValue;
}
currentFiber.hooks.push(hook);
hookIndex++;
return hook;
}
-
index.html
我参考react官网中hooks部分给出的demo和自己写的做了验证,大家可以取消render函数的注释去尝试
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>
<div id="root">
<!-- <div id="div1">
<p id="p1">我是p1</p>
<p id="p2">我是p2</p>
</div>
<div id="div2">
<p id="p3">我是p3</p>
<p id="p4">我是p4</p>
</div> -->
</div>
<script src="./react.js"></script>
<script type="text/babel">
/** @jsx createElement */
function createTodos() {
const todos = [];
for (let i = 0; i < 50; i++) {
todos.push({
id: i,
text: "Todo " + (i + 1),
completed: Math.random() > 0.5,
});
}
return todos;
}
function filterTodos(todos, tab) {
console.log(
"[ARTIFICIALLY SLOW] Filtering " +
todos.length +
' todos for "' +
tab +
'" tab.'
);
let startTime = performance.now();
while (performance.now() - startTime < 500) {
// 在 500 毫秒内不执行任何操作以模拟极慢的代码
}
return todos.filter((todo) => {
if (tab === "all") {
return true;
} else if (tab === "active") {
return !todo.completed;
} else if (tab === "completed") {
return todo.completed;
}
});
}
function TodoList({ todos, theme, tab }) {
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
return (
<div className={theme}>
<p>
<b>
Note: <code>filterTodos</code> is artificially slowed down!
</b>
</p>
<ul>
{visibleTodos.map((todo) => (
<li key={todo.id}>
{todo.completed ? <s>{todo.text}</s> : todo.text}
</li>
))}
</ul>
</div>
);
}
function App() {
const todos = createTodos();
const [tab, setTab] = useState("all");
const [isDark, setIsDark] = useState(false);
return (
<div>
<button onClick={() => setTab("all")}>All</button>
<button onClick={() => setTab("active")}>Active</button>
<button onClick={() => setTab("completed")}>Completed</button>
<br />
<label>
<input
type="checkbox"
checked={isDark}
onChange={(e) => setIsDark(e.target.checked)}
/>
Dark mode
</label>
<hr />
<TodoList
todos={todos}
tab={tab}
theme={isDark ? "dark" : "light"}
/>
</div>
);
}
//render(document.getElementById("root"), <App />);//测试成功 验证useState和useMemo
function Counter() {
//测试ref
let ref = useRef(0);
function handleClick() {
ref.current = ref.current + 1;
alert("You clicked " + ref.current + " times!");
}
return <button onClick={handleClick}>点击!</button>;
}
//render(document.getElementById("root"), <Counter />);//测试成功 验证useRef
function Form() {
//测试ref操作dom
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
</div>
);
}
//render(document.getElementById("root"), <Form />); //测试成功 验证useRef操作dom
function VideoPlayer() {
const [isPlaying, setIsPlaying] = useState(false);
const ref = useRef(null);
function handleClick() {
const nextIsPlaying = !isPlaying;
setIsPlaying(nextIsPlaying);
if (nextIsPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}
return (
<div>
<button onClick={handleClick}>{isPlaying ? "暂停" : "播放"}</button>
<video
width="250"
ref={ref}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
>
<source
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
type="video/mp4"
/>
</video>
</div>
);
}
//render(document.getElementById("root"), <VideoPlayer />); //测试成功 验证useRef操作dom
function createConnection(serverUrl, roomId) {
// 真正的实现会实际连接到服务器
return {
connect() {
console.log(
'✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..."
);
},
disconnect() {
console.log(
'❌ Disconnected from "' + roomId + '" room at ' + serverUrl
);
},
};
}
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState("https://localhost:1234");
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, serverUrl]);
return (
<div>
<label>
Server URL:{" "}
<input
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
/>
</label>
<h1>Welcome to the {roomId} room!</h1>
</div>
);
}
function App1() {
const [roomId, setRoomId] = useState("general");
const [show, setShow] = useState(false);
return (
<div>
<label>
Choose the chat room:{" "}
<select
value={roomId}
onChange={(e) => setRoomId(e.target.value)}
>
<option value="general">general</option>
<option value="travel">travel</option>
<option value="music">music</option>
</select>
</label>
<button onClick={() => setShow(!show)}>
{show ? "Close chat" : "Open chat"}
</button>
{show && <hr />}
{show && <ChatRoom roomId={roomId} />}
</div>
);
}
//render(document.getElementById("root"), <App1 />); //验证成功 useEffect
function DestroyComponent(){
useEffect(()=>{
return ()=>{
console.log("销毁的时候促发")
}
})
return (
<p>我是p2</p>
)
}
function App2() {
let [show, setShow] = useState(false);
const handleClick = ()=>{
setShow(!show)
}
return (
<div>
<p>我是p1</p>
{show ? <DestroyComponent/>: <div>我是div3</div>}
<button onClick={handleClick}>切换</button>
</div>
);
}
//render(document.getElementById("root"), <App2 />); //验证成功 切换后位置正确 useEffect 销毁后的函数执行
function App3() {
return (
<div>
{new Array(100).fill(0).map(item=>{
return <p>循环创建的p标签</p>
})}
</div>
);
}
render(document.getElementById("root"), <App3 />); //验证成功 对map情况做适配
</script>
</body>
</html>
暂时无法在飞书文档外展示此内容