前端工程师的自我修养:React Fiber 是如何实现更新过程可控的

前言

从 React 16 开始,React 采用了 Fiber 机制替代了原先基于原生执行栈递归遍历 VDOM 的方案,提高了页面渲染性能和用户体验。乍一听 Fiber 好像挺神秘,在原生执行栈都还没搞懂的情况下,又整出个 Fiber,还能不能愉快的写代码了。别慌,老铁!下面就来唠唠关于 Fiber 那点事儿。

什么是 Fiber

Fiber 的英文含义是“纤维”,它是比线程(Thread)更细的线,比线程(Thread)控制得更精密的执行模型。在广义计算机科学概念中,Fiber 又是一种协作的(Cooperative)编程模型,帮助开发者用一种【既模块化又协作化】的方式来编排代码。


7aa5dbd6665b91a50992445f5188f677.png


简单点说,Fiber 就是 React 16 实现的一套新的更新机制,让 React 的更新过程变得可控,避免了之前一竿子递归到底影响性能的做法。

关于 Fiber 你需要知道的基础知识

1 浏览器刷新率(帧)

页面的内容都是一帧一帧绘制出来的,浏览器刷新率代表浏览器一秒绘制多少帧。目前浏览器大多是 60Hz(60帧/s),每一帧耗时也就是在 16ms 左右。原则上说 1s 内绘制的帧数也多,画面表现就也细腻。那么在这一帧的(16ms) 过程中浏览器又干了啥呢?


0950183f12c9226a7b41b98f68feb9f5.png


通过上面这张图可以清楚的知道,浏览器一帧会经过下面这几个过程:

  1. 接受输入事件
  2. 执行事件回调
  3. 开始一帧
  4. 执行 RAF (RequestAnimationFrame)
  5. 页面布局,样式计算
  6. 渲染
  7. 执行 RIC (RequestIdelCallback)

第七步的 RIC 事件不是每一帧结束都会执行,只有在一帧的 16ms 中做完了前面 6 件事儿且还有剩余时间,才会执行。这里提一下,如果一帧执行结束后还有时间执行 RIC 事件,那么下一帧需要在事件执行结束才能继续渲染,所以 RIC 执行不要超过 30ms,如果长时间不将控制权交还给浏览器,会影响下一帧的渲染,导致页面出现卡顿和事件响应不及时。

2. JS 原生执行栈

React Fiber 出现之前,React 通过原生执行栈递归遍历 VDOM。当浏览器引擎第一次遇到 JS 代码时,会产生一个全局执行上下文并将其压入执行栈,接下来每遇到一个函数调用,又会往栈中压入一个新的上下文。比如:

function A(){B();C();
}
function B(){}
function C(){}
A();

引擎在执行的时候,会形成如下这样的执行栈:

aca097dc4584053869f173253dc612a1.png


浏览器引擎会从执行栈的顶端开始执行,执行完毕就弹出当前执行上下文,开始执行下一个函数,直到执行栈被清空才会停止。然后将执行权交还给浏览器。由于 React 将页面视图视作一个个函数执行的结果。每一个页面往往由多个视图组成,这就意味着多个函数的调用。

如果一个页面足够复杂,形成的函数调用栈就会很深。每一次更新,执行栈需要一次性执行完成,中途不能干其他的事儿,只能"一心一意"。结合前面提到的浏览器刷新率,JS 一直执行,浏览器得不到控制权,就不能及时开始下一帧的绘制。如果这个时间超过 16ms,当页面有动画效果需求时,动画因为浏览器不能及时绘制下一帧,这时动画就会出现卡顿。不仅如此,因为事件响应代码是在每一帧开始的时候执行,如果不能及时绘制下一帧,事件响应也会延迟。

3. 时间分片(Time Slicing)

时间分片指的是一种将多个粒度小的任务放入一个时间切片(一帧)中执行的一种方案,在 React Fiber 中就是将多个任务放在了一个时间片中去执行。

4. 链表

在 React Fiber 中用链表遍历的方式替代了 React 16 之前的栈递归方案。在 React 16 中使用了大量的链表。例如:

  • 使用多向链表的形式替代了原来的树结构

例如下面这个组件:

<div id="id">A1<div id="B1">B1<div id="C1"></div></div><div id="B2">B2</div>
</div>

会使用下面这样的链表表示:

ae58bf33ad4c56acd32b78ad4d062a97.png


  • 副作用单链表


4bd4137576e70ef91f9113b485f51f80.png


  • 状态更新单链表


ac5af27052123420897f44a77e808b6e.png


  • ...

链表是一种简单高效的数据结构,它在当前节点中保存着指向下一个节点的指针,就好像火车一样一节连着一节


27844c442f796d8ef864a3e7f2fb85c3.png


遍历的时候,通过操作指针找到下一个元素。但是操作指针时(调整顺序和指向)一定要小心。

链表相比顺序结构数据格式的好处就是:

  1. 操作更高效,比如顺序调整、删除,只需要改变节点的指针指向就好了。
  2. 不仅可以根据当前节点找到下一个节点,在多向链表中,还可以找到他的父节点或者兄弟节点。

但链表也不是完美的,缺点就是:

  1. 比顺序结构数据更占用空间,因为每个节点对象还保存有指向下一个对象的指针。
  2. 不能自由读取,必须找到他的上一个节点。

React 用空间换时间,更高效的操作可以方便根据优先级进行操作。同时可以根据当前节点找到其他节点,在下面提到的挂起和恢复过程中起到了关键作用。

React Fiber 是如何实现更新过程可控?

前面讲完基本知识,现在正式开始介绍今天的主角 Fiber,看看 React Fiber 是如何实现对更新过程的管控。


92d5d174996737843bd9f037dd3f740c.png


更新过程的可控主要体现在下面几个方面:

  1. 任务拆分
  2. 任务挂起、恢复、终止
  3. 任务具备优先级

1. 任务拆分

前面提到,React Fiber 之前是基于原生执行栈,每一次更新操作会一直占用主线程,直到更新完成。这可能会导致事件响应延迟,动画卡顿等现象。

在 React Fiber 机制中,它采用"化整为零"的战术,将调和阶段(Reconciler)递归遍历 VDOM 这个大任务分成若干小任务,每个任务只负责一个节点的处理。例如:

import React from "react";
import ReactDom from "react-dom"
const jsx = (<div id="A1">A1<div id="B1">B1<div id="C1">C1</div><div id="C2">C2</div></div><div id="B2">B2</div></div>
)
ReactDom.render(jsx,document.getElementById("root"))

这个组件在渲染的时候会被分成八个小任务,每个任务用来分别处理 A1(div)、A1(text)、B1(div)、B1(text)、C1(div)、C1(text)、C2(div)、C2(text)、B2(div)、B2(text)。再通过时间分片,在一个时间片中执行一个或者多个任务。这里提一下,所有的小任务并不是一次性被切分完成,而是处理当前任务的时候生成下一个任务,如果没有下一个任务生成了,就代表本次渲染的 Diff 操作完成。

2. 挂起、恢复、终止

再说挂起、恢复、终止之前,不得不提两棵 Fiber 树,workInProgress tree 和 currentFiber tree。

workInProgress 代表当前正在执行更新的 Fiber 树。在 render 或者 setState 后,会构建一颗 Fiber 树,也就是 workInProgress tree,这棵树在构建每一个节点的时候会收集当前节点的副作用,整棵树构建完成后,会形成一条完整的副作用链。

currentFiber 表示上次渲染构建的 Filber 树。在每一次更新完成后 workInProgress 会赋值给 currentFiber。在新一轮更新时 workInProgress tree 再重新构建,新 workInProgress 的节点通过 alternate 属性和 currentFiber 的节点建立联系。

在新 workInProgress tree 的创建过程中,会同 currentFiber 的对应节点进行 Diff 比较,收集副作用。同时也会复用和 currentFiber 对应的节点对象,减少新创建对象带来的开销。也就是说无论是创建还是更新,挂起、恢复以及终止操作都是发生在 workInProgress tree 创建过程中。workInProgress tree 构建过程其实就是循环的执行任务和创建下一个任务,大致过程如下:


92b0a80d5a93d4325fe06b8d06db600d.png


当没有下一个任务需要执行的时候,workInProgress tree 构建完成,开始进入提交阶段,完成真实 DOM 更新。

在构建 workInProgressFiber tree 过程中可以通过挂起、恢复和终止任务,实现对更新过程的管控。下面简化了一下源码,大致实现如下:

let nextUnitWork = null;//下一个执行单元
//开始调度
function shceduler(task){nextUnitWork = task; 
}
//循环执行工作
function workLoop(deadline){let shouldYield = false;//是否要让出时间片交出控制权while(nextUnitWork && !shouldYield){nextUnitWork = performUnitWork(nextUnitWork)shouldYield = deadline.timeRemaining()<1 // 没有时间了,检出控制权给浏览器}if(!nextUnitWork) {conosle.log("所有任务完成")//commitRoot() //提交更新视图}// 如果还有任务,但是交出控制权后,请求下次调度requestIdleCallback(workLoop,{timeout:5000}) 
}
/*
 * 处理一个小任务,其实就是一个 Fiber 节点,如果还有任务就返回下一个需要处理的任务,没有就代表整个
 */
function performUnitWork(currentFiber){....return FiberNode
}

挂起

当第一个小任务完成后,先判断这一帧是否还有空闲时间,没有就挂起下一个任务的执行,记住当前挂起的节点,让出控制权给浏览器执行更高优先级的任务。

恢复

在浏览器渲染完一帧后,判断当前帧是否有剩余时间,如果有就恢复执行之前挂起的任务。如果没有任务需要处理,代表调和阶段完成,可以开始进入渲染阶段。这样完美的解决了调和过程一直占用主线程的问题。

那么问题来了他是如何判断一帧是否有空闲时间的呢?答案就是我们前面提到的 RIC (RequestIdleCallback) 浏览器原生 API,React 源码中为了兼容低版本的浏览器,对该方法进行了 Polyfill。

当恢复执行的时候又是如何知道下一个任务是什么呢?答案在前面提到的链表。在 React Fiber 中每个任务其实就是在处理一个 FiberNode 对象,然后又生成下一个任务需要处理的 FiberNode。顺便提一嘴,这里提到的FiberNode 是一种数据格式,下面是它没有开美颜的样子:

class FiberNode {constructor(tag, pendingProps, key, mode) {// 实例属性this.tag = tag; // 标记不同组件类型,如函数组件、类组件、文本、原生组件...this.key = key; // react 元素上的 key 就是 jsx 上写的那个 key ,也就是最终 ReactElement 上的this.elementType = null; // createElement的第一个参数,ReactElement 上的 typethis.type = null; // 表示fiber的真实类型 ,elementType 基本一样,在使用了懒加载之类的功能时可能会不一样this.stateNode = null; // 实例对象,比如 class 组件 new 完后就挂载在这个属性上面,如果是RootFiber,那么它上面挂的是 FiberRoot,如果是原生节点就是 dom 对象// fiberthis.return = null; // 父节点,指向上一个 fiberthis.child = null; // 子节点,指向自身下面的第一个 fiberthis.sibling = null; // 兄弟组件, 指向一个兄弟节点this.index = 0; //  一般如果没有兄弟节点的话是0 当某个父节点下的子节点是数组类型的时候会给每个子节点一个 index,index 和 key 要一起做 diffthis.ref = null; // reactElement 上的 ref 属性this.pendingProps = pendingProps; // 新的 propsthis.memoizedProps = null; // 旧的 propsthis.updateQueue = null; // fiber 上的更新队列执行一次 setState 就会往这个属性上挂一个新的更新, 每条更新最终会形成一个链表结构,最后做批量更新this.memoizedState = null; // 对应  memoizedProps,上次渲染的 state,相当于当前的 state,理解成 prev 和 next 的关系this.mode = mode; // 表示当前组件下的子组件的渲染方式// effectsthis.effectTag = NoEffect; // 表示当前 fiber 要进行何种更新this.nextEffect = null; // 指向下个需要更新的fiberthis.firstEffect = null; // 指向所有子节点里,需要更新的 fiber 里的第一个this.lastEffect = null; // 指向所有子节点中需要更新的 fiber 的最后一个this.expirationTime = NoWork; // 过期时间,代表任务在未来的哪个时间点应该被完成this.childExpirationTime = NoWork; // child 过期时间this.alternate = null; // current 树和 workInprogress 树之间的相互引用}
}

额…看着好像有点上头,这是开了美颜的样子:


769271876d3db310c2936d1306bbdfc9.png


是不是好看多了?在每次循环的时候,找到下一个执行需要处理的节点。

function performUnitWork(currentFiber){//beginWork(currentFiber) //找到儿子,并通过链表的方式挂到currentFiber上,每一偶儿子就找后面那个兄弟//有儿子就返回儿子if(currentFiber.child){return currentFiber.child;} //如果没有儿子,则找弟弟while(currentFiber){//一直往上找//completeUnitWork(currentFiber);//将自己的副作用挂到父节点去if(currentFiber.sibling){return currentFiber.sibling}currentFiber = currentFiber.return;}
}

在一次任务结束后返回该处理节点的子节点或兄弟节点或父节点。只要有节点返回,说明还有下一个任务,下一个任务的处理对象就是返回的节点。通过一个全局变量记住当前任务节点,当浏览器再次空闲的时候,通过这个全局变量,找到它的下一个任务需要处理的节点恢复执行。就这样一直循环下去,直到没有需要处理的节点返回,代表所有任务执行完成。最后大家手拉手,就形成了一颗 Fiber 树。


dd3d9830a54c3edb4c2c05ee47551751.png


终止

其实并不是每次更新都会走到提交阶段。当在调和过程中触发了新的更新,在执行下一个任务的时候,判断是否有优先级更高的执行任务,如果有就终止原来将要执行的任务,开始新的 workInProgressFiber 树构建过程,开始新的更新流程。这样可以避免重复更新操作。这也是在 React 16 以后生命周期函数 componentWillMount 有可能会执行多次的原因。

3. 任务具备优先级

React Fiber 除了通过挂起,恢复和终止来控制更新外,还给每个任务分配了优先级。具体点就是在创建或者更新 FiberNode 的时候,通过算法给每个任务分配一个到期时间(expirationTime)。在每个任务执行的时候除了判断剩余时间,如果当前处理节点已经过期,那么无论现在是否有空闲时间都必须执行改任务。


0d47a43063df2d51fc7a9a5eb82fd5fa.png


同时过期时间的大小还代表着任务的优先级。

任务在执行过程中顺便收集了每个 FiberNode 的副作用,将有副作用的节点通过 firstEffect、lastEffect、nextEffect 形成一条副作用单链表 AI(TEXT)-B1(TEXT)-C1(TEXT)-C1-C2(TEXT)-C2-B1-B2(TEXT)-B2-A。


0d303069f871df1de13a097807804b33.png


其实最终都是为了收集到这条副作用链表,有了它,在接下来的渲染阶段就通过遍历副作用链完成 DOM 更新。这里需要注意,更新真实 DOM 的这个动作是一气呵成的,不能中断,不然会造成视觉上的不连贯。

关于 React Fiber 的思考

1. 能否使用生成器(generater)替代链表

在 Fiber 机制中,最重要的一点就是需要实现挂起和恢复,从实现角度来说 generator 也可以实现。那么为什么官方没有使用 generator 呢?猜测应该是是性能方面的原因。生成器不仅让您在堆栈的中间让步,还必须把每个函数包装在一个生成器中。一方面增加了许多语法方面的开销,另外还增加了任何现有实现的运行时开销。性能上远没有链表的方式好,而且链表不需要考虑浏览器兼容性。

2. Vue 是否会采用 Fiber 机制来优化复杂页面的更新

这个问题其实有点搞事情,如果 Vue 真这么做了是不是就是变相承认 Vue 是在"集成" Angular 和 React 的优点呢?React 有 Fiber,Vue 就一定要有?

两者虽然都依赖 DOM Diff,但是实现上且有区别,DOM Diff 的目的都是收集副作用。Vue 通过 Watcher 实现了依赖收集,本身就是一种很好的优化。所以 Vue 没有采用 Fiber 机制,也无伤大雅。

总结

React Fiber 的出现相当于是在更新过程中引进了一个中场指挥官,负责掌控更新过程,足球世界里管这叫前腰。抛开带来的性能和效率提升外,这种“化整为零”和任务编排的思想,可以应用到我们平时的架构设计中。

原文链接: 前端工程师的自我修养:React Fiber 是如何实现更新过程可控的
作者:政采云前端团队

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/25879.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

单纯形法笔记

目录 对偶&#xff1a; 单纯形&#xff1a; 0.618法 newton法 最速下降法 F-R 共轭梯度法 K-T 条件 wolfe简约梯度 罚函数 障碍函数 对偶&#xff1a; 得到初始单纯形表之后 若检验数均 < 0&#xff0c;则对偶可行 若b均 < 0,则原始单纯形不可行 选取b中最小…

全球所有科学家影响力排名第五!这位中国院士到底有多厉害?

>>>> 前段时间&#xff0c; 一个重磅消息轰炸了世界学术圈&#xff0c; 来自全球最大学术出版商&#xff0c; Elsevier公布的&#xff0c; 2020年全球所有学科科学家&#xff0c; 排名数据显示&#xff0c; 我中科院王中林院士&#xff0c; 终身科学影响力排名世界…

JUC进阶-NO.3 说说Java锁

文章目录 ⭐NO.3 说说Java锁一. 乐观锁 & 悲观锁1.悲观锁2.伪代码3.乐观锁 二. 通过8种情况演示锁运行案例,看看我们到底锁的是什么1.8种锁案例(1). 标准访问有ab两个线程&#xff0c;请问先打印邮件还是短信(2). sendEmail方法暂停3秒钟&#xff0c;请问先打印邮件还是短信…

运动用品品牌排行榜,双十一运动好物选购清单

健身运动就像打游戏一样&#xff0c;如何区分你和其他玩家的差别呢&#xff1f;有时候靠身材&#xff0c;当然有时候也会拼装备&#xff0c;那么这些运动装备能否增加buff呢&#xff1f;是否值得入手呢&#xff1f;作为一名资深的运动爱好者&#xff0c;下面我就从实用角度聊一…

健身运动装备有哪些?双十一运动健身装备选购指南

近年来&#xff0c;各地的各种运动赛事越来越多&#xff0c;对运动也是非常好的推动。很多名人都开始运动起来&#xff0c;因为运动之后多巴胺分泌&#xff0c;让人觉得神清气爽。隔几天不运动&#xff0c;就让人浑身不自在。当然运动也要注意方式方法、注意姿势&#xff0c;还…

以数字化视角看世界杯,我预测荷兰夺冠

编者按&#xff1a;2022世界杯隆重开幕&#xff01;各路英豪齐聚卡塔尔&#xff01;让我们从一个ITer的角度&#xff0c;用数字化的视角&#xff0c;看看谁能最后夺冠&#xff01; 本文已经得到原作者张戈授权&#xff0c;在此表示感谢&#xff01; 真不是嘚瑟。 我是有40年球龄…

别人熬夜看世界杯 我熬夜改代码 你满意了

2022年卡塔尔世界杯正如火如荼地进行着&#xff0c; 一边是热火朝天的比赛&#xff0c;一边是让人惊掉下巴的爆冷结局&#xff0c; 但正因为这些不确定因素&#xff0c;反倒让世界杯增添了几分魅力和乐趣&#xff01; 小编在看球赛的过程中&#xff0c;不禁起了联想&#xff…

【进度2】从阿里云迁至腾讯云,并添加网站备案号

注&#xff1a;在阿里云备案成功网站域名不可以直接解析到腾讯云服务器&#xff0c;会被腾讯云的DNS拦截并跳转。 腾讯云服务器从2023.2.1-2023.2.15限时优惠&#xff0c;这里我选择的是2核2G这个。 HTML源码和备案号的添加 由于域名之前在阿里云和工信部已经备案过&#xff0c…

浅谈明日方舟游戏系统

主要玩法&#xff1a;敌方阵营从敌方初始点进入战斗并且沿着怪物前进路线行驶到己方保护目标。玩家可以通过部署干员守护己方保护目标&#xff0c;防止敌方阵营进入&#xff1b;当保护目标的生命值为0时&#xff0c;则战斗失败&#xff0c;任务结束。 1 干员系统 1.1 职业分支…

著名球星罗纳尔迪尼奥担任巴西旅游大使

近日&#xff0c;巴西著名球星罗纳尔迪尼奥应巴西旅游局&#xff08;Embratur&#xff09;邀请&#xff0c;担任巴西旅游大使。他志愿将“最好的巴西”推向全世界。罗纳尔迪尼奥是前巴西男足运动员&#xff0c;世界体坛最优秀的球星之一&#xff0c;曾荣获1999年美洲杯冠军、20…

java安装教程win7_Win7系统安装JDK环境变量的配置方法

Win7系统安装JDK环境变量前需要用户先安装JAVA环境&#xff0c;才能继续配置JDK。如果用户曾重装过操作系统&#xff0c;依然是需要重新安装JDK环境的&#xff0c;具体操作不过可以按照下文中的步骤进行处理&#xff0c;但因为版本不一样&#xff0c;细节方面可能有差别。 Win7…

又到一轮德比时

这是杂货铺的第456篇文章 结束了中超联赛两连客&#xff0c;本轮比赛&#xff0c;中赫国安回到了京城&#xff0c;“客场”挑战北京人和。虽然从实力上看&#xff0c;中赫国安占据绝对上风&#xff0c;但不要忘了&#xff0c;上个赛季&#xff0c;丰体0:3的比分&#xff0c;让国…

五连胜?这才刚刚开始!

这是杂货铺的第460篇文章 经历了客场和武里南的亚冠联赛&#xff0c;本周迎来了河南建业的挑战&#xff0c;虽然是主场作战&#xff0c;但相比之下&#xff0c;河南已经歇了两周了(和武汉的比赛&#xff0c;因场地问题延期)&#xff0c;从体能上&#xff0c;河南更占优。就连施…

疫情下,嵌入式er该怎么进行职业规划,难点在哪?

整理&#xff1a;付斌&#xff0c;转自嵌入式ARM&#xff0c;参考已标注至原文 01 嵌入式系统的概念 着重理解“嵌入”的概念 主要从三个方面上来理解&#xff1a; 1、从硬件上&#xff0c;将基于CPU的处围器件&#xff0c;整合到CPU芯片内部。 比如早期基于X86体系结构下的计算…

DSkin控件使用

DSkinCode验证码控件&#xff1a; ClickNewCode 是否可以点击刷新验证码 CodeCount 验证码字数 CodeStr 验证码的值

TouchDesigner学习 TOP与CHOP结合制作小应用

效果预览&#xff08;快速滑动鼠标&#xff0c;闪光效果制作&#xff09;&#xff1a; 官方教程&#xff1a; http://www.touchdesigner.co/beginner OP: 右键 view... 跳出程序预览效果 点下按钮是在程序内预览 特别说明的注意点&#xff1a; 上图中的distance&#xff0c;v…

9款超棒的设计协作工具!值得你收藏

设计协作工具是为团队合作而生的。设计协作工具可以帮助团队成员在一个平台上合作&#xff0c;从而提高设计效率和质量。本文将与大家分享9款设计行业公认好用的设计协作工具。来看看吧&#xff01; 即时设计 即时设计是一种基于云的设计协作工具&#xff0c;可以让设计团队合…

【实战】轻轻松松使用StyleGAN(一):创建令人惊讶的黄种人脸和专属于自己的老婆动漫头像

NVIDIA&#xff08;英伟达&#xff09;开源了StyleGAN&#xff0c;用它可以生成令人惊讶的逼真人脸&#xff1b;也可以像某些人所说的&#xff0c;生成专属于自己的老婆动漫头像。这些生成的人脸或者动漫头像都是此前这个世界上从来没有过的&#xff0c;完全是被“伟大的你”所…

话说软件详细设计工具

在软件设计是需要写软件详细说明书,设计此文档的时候,肯定少不了工具.现在我们就来了解一下软件详细设计的 工具. 1)程序流程图 程序流程图又称为程序框图,它是最古老,应用最广泛且最有争议描述详细设计的工具.它易学,表达算法直观,缺点是 不够规范,特别是使用箭头会使质量受到…

手把手教系列之梳状滤波器设计实现

[导读]:前面一篇文章关于IIR/移动平均滤波器设计的文章。本文来聊一聊陷波滤波器,该滤波器在混入谐波干扰时非常有用,算法简单,实现代价低。本文来一探其在机理、应用场景。 注:尽量在每篇文章写写摘要,方便阅读。信息时代,大家时间都很宝贵,如此亦可节约粉丝们的宝贵…