背景
大家都在使用React,之前呢,也给大家分享过一次主题为“浅谈Hooks&&生命周期”的内容。今天呢,作为延伸,来继续给大家介绍一些React的Advanced Topics,也就是一些进阶的知识点。
简介
- High Order Components - 高阶组件
- Portals - 门户
- Error Boundaries - 错误边界
- Fiber Architecture - 光纤结构
1. High Order Components
高阶组件(HOC)是React中用于复用组件逻辑的一种高级技巧。HOC自身不是React API的一部分,它是一种基于React的组合特性而形成的设计模式。
具体而言,高阶组件就是接收一个组件为参数,然后返回一个新的组件的函数。
在React官网中有提到,使用HOC解决横切关注点问题。
横切关注点(cross-cutting concerns)指的是两个非常不一样的组件存在一些类似的功能。
官网中:在这有提到之前建议使用mixins解决横切关注点相关的问题。我们已经意识到 mixins 会产生更多麻烦。阅读更多 以了解我们为什么要抛弃 mixins 以及如何转换现有组件。
那么我们来稍微看下mixins和HOC。
借用《深入React技术栈》一书中的图:
image.png
mixins 就像打补丁一样。
高阶组件属于函数式编程(functional programming)思想,对于被包裹的组件时不会感知到高阶组件的存在,而高阶组件返回的组件会在原来的组件之上具有功能增强的效果。而Mixin这种混入的模式,会给组件不断增加新的方法和属性,组件本身不仅可以感知,甚至需要做相关的处理(例如命名冲突、状态维护),一旦混入的模块变多时,整个组件就变的难以维护,也就是为什么如此多的React库都采用高阶组件的方式进行开发。
说到“高阶组件”,咱们就不得不提一下“高阶函数”。
在数学和计算科学上,一个高阶函数应该具备下面至少一个特点:
- 将一个或者多个函数作为形参
- 返回一个函数作为其结果
那为什么叫高阶呢?
因为这种函数可以被调用很多次,你想想看,我在高阶函数中如果返回一个函数,那么你又可以调用这个函数,如果你返回的函数中又返回一个函数,那么如此下去就可以调用N多次。类似的在高等数学中有高阶导数(指的是两阶以上的导数),求导之后返回的结果可以再次被求导。
在Js这门语言中最常用的高阶函数想必是map和reduce。如下面的例子:var array = [1, 2, 3, 4, 5] var newArray = array.map(item => item*2); // reduce() 方法接收一个函数作为累加器,数组中的每个值(从左到右)开始> 缩减,最终计算为一个值。 var result = newArray.reduce((sum, item) => { return sum += item }) console.log(newArray, result);
map和reduce函数都使用函数作为参数,所以满足我们的特征,还有一个也很常见的函数就是sort。更多高阶函数大家自行搜索。
而说到“高阶函数”,咱们就不得不提一下另外两个概念:“柯里化”和“组合”。
柯里化:
在计算机科学中,柯里化(英语:Currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。
其实柯里化通常也称部分求值(partial application),其含义是给函数分步传递参数,每次传递参数中部分应用参数,并返回一个更具体的函数接受剩下的参数,这中间可嵌套多层这样的接受部分参数函数,直至返回最后结果。
因此柯里化的过程是逐步传参,逐步缩小函数的适用范围,逐步求解的过程。
组合:
函数组合的概念也是函数式编程的一部分,顾名思义,组合多个函数得到一个新的函数,类似于高等数学中的表达式:z = g(f(x)。
高阶函数作为函数式编程的一部分,我们今天就先说到这里。感兴趣的话可以点这里详细了解一下。
插一句,之前我经常提到的3w方法
image.png
还有一个5W1H法则:
- 对象 (What)——什么事情
- 场所 (Where)——什么地点
- 时间和程序 (When)——什么时候
- 人员 (Who)——责任人
- 为什么(Why)——原因
- 方式 (How)——如何
what - (本质)
高阶组件就是一个React组件包裹着另外一个React组件。
why - (为什么要这么做)
组件是 React 中代码复用的基本单元。但你会发现某些模式并不适合传统组件。
我们有多个功能类似的组件,但是有些许差别,我们需要一个抽象,允许我们在一个地方定义这个逻辑,并在许多组件之间共享它。这正是高阶组件擅长的地方。
how - (怎么做比较好)
- 不要改变原始组件,使用组合。
HOC既不会修改输入的组件,也不会使用使用继承性去拷贝输入组件的行为,相反HOC通过包裹它在一个容器组件来组合原始组件,HOC是一个纯函数没有任何副作用。也就是说HOC可以往被扩展的组件注入自己的东西,但是不允许去改动被扩展组件原有的一切东西。
纯函数指的是如果一个函数执行完后不会对全局变量以及入参产生任何修改,此时认为该函数是纯函数,并且没有任何副作用。比如:
function pureFunc (input) {
return 100
}
// 但是如果这样的函数就不是纯函数:
function notPureFunction (input) {
input = {}
window.xxx = 'I overwrite'
}
不要试图在 HOC 中修改组件原型(或以其他方式改变它)。
function logProps(InputComponent) {
InputComponent.prototype.componentDidUpdate = function(prevProps) {
console.log('Current props: ', this.props);
console.log('Previous props: ', prevProps);
};
// 返回原始的 input 组件,暗示它已经被修改。
return InputComponent;
}
// 每次调用 logProps 时,增强组件都会有 log 输出。
const EnhancedComponent = logProps(InputComponent);
HOC 不应该修改传入组件,而应该使用组合的方式,通过将组件包装在容器组件中实现功能:
function logProps(WrappedComponent) {
return class extends React.Component {
componentDidUpdate(prevProps) {
console.log('Current props: ', this.props);
console.log('Previous props: ', prevProps);
}
render() {
// 将 input 组件包装在容器中,而不对其进行修改。Good!
return <WrappedComponent {...this.props} />;
}
}
}
该 HOC 与上文中修改传入组件的 HOC 功能相同,同时避免了出现冲突的情况。它同样适用于 class 组件和函数组件。而且因为它是一个纯函数,它可以与其他 HOC 组合,甚至可以与其自身组合。
- 约定:将不相关的props传递给被包裹的组件。
- 约定:最大化可组合性
有时候它仅接受一个参数,也就是被包裹的组件;
const lazyC = lazyLoad(AppList)
HOC 通常可以接收多个参数。比如:
const lazyC = lazyLoadC(AppList, {config: {..}})
我们比较常见的HOC的签名如下:
// React Redux 的 `connect` 函数
const ConnectedBillSet = connect(mapStateToProps, mapDispatchToProps)(BillSet);
刚刚发生了什么?!如果你把它分开,就会更容易看出发生了什么。
// connect 是一个函数,它的返回值为另外一个函数。
const enhance = connect(mapStateToProps, mapDispatchToProps);
// 返回值为 HOC,它会返回已经连接 Redux store 的组件
const ConnectedBillSet = enhance(BillSet);
换句话说,connect 是一个返回高阶组件的高阶函数!
注意事项
- 不要在render方法中使用HOC
- Refs不会被传递
不要在render方法中使用HOC
React 的 diff 算法(称为协调)使用组件标识来确定它是应该更新现有子树还是将其丢弃并挂载新子树。 如果从 render 返回的组件与前一个渲染中的组件相同(===),则 React 通过将子树与新子树进行区分来递归更新子树。 如果它们不相等,则完全卸载前一个子树。
通常,你不需要考虑这点。但对 HOC 来说这一点很重要,因为这代表着你不应在组件的 render 方法中对一个组件应用 HOC。
这不仅仅是性能问题 - 重新挂载组件会导致该组件及其所有子组件的状态丢失。
如果在组件之外创建 HOC,这样一来组件只会创建一次。因此,每次 render 时都会是同一个组件。一般来说,这跟你的预期表现是一致的。
Refs不会被传递
虽然高阶组件的约定是将所有 props 传递给被包装组件,但这对于 refs 并不适用。那是因为 ref 实际上并不是一个 prop - 就像 key 一样,它是由 React 专门处理的。如果将 ref 添加到 HOC 的返回组件中,则 ref 引用指向容器组件,而不是被包装组件。
2. Portals
Portal提供了一种将子节点渲染到存在于父组件以外的DOM节点的优秀的方案。
ReactDOM.createPortal(child, container)
第一个参数(child
)是任何可渲染的 React 子元素,例如一个元素,字符串或 fragment。第二个参数(container
)是一个 DOM 元素。
How?
一个 portal 的典型用例是当父组件有 overflow: hidden 或 z-index 样式时,但你需要子组件能够在视觉上“跳出”其容器。例如,对话框、悬浮卡以及提示框。
我们来看一个简单的例子。
我们来看下antd中的modal弹框是不是也是使用ReactDOM.createPortal来做的呢?
寻找路径:antd/modal->rc-dialog -> rc-util/Portal.js -> 位置:antd/config-provider/getPopupContainer
3. Error Boundaries(错误边界)
部分 UI 的 JavaScript 错误不应该导致整个应用崩溃,为了解决这个问题,React 16 引入了一个新的概念 —— 错误边界。
错误边界是一种 React 组件,这种组件可以捕获并打印发生在其子组件树任何位置的 JavaScript 错误,并且,它会渲染出备用 UI,而不是渲染那些崩溃了的子组件树。错误边界在渲染期间、生命周期方法和整个组件树的构造函数中捕获错误。
注意
错误边界无法捕获一下场景中产生的错误:
- 事件处理
- 异步代码(例如 setTimeout 或 requestAnimationFrame 回调函数)
- 服务端渲染
- 它自身拍出来的错误(并非它的子组件)
How?
如果一个 class 组件中定义了 static getDerivedStateFromError()
或 componentDidCatch()
这两个生命周期方法中的任意一个(或两个)时,那么它就变成一个错误边界。当抛出错误后,请使用 static getDerivedStateFromError()
渲染备用 UI ,使用 componentDidCatch()
打印错误信息。
我们看下这个例子。
错误边界的工作方式类似于 JavaScript 的 catch {}
,不同的地方在于错误边界只针对 React 组件。只有 class 组件才可以成为错误边界组件。大多数情况下, 你只需要声明一次错误边界组件, 并在整个应用中使用它。
注意错误边界仅可以捕获其子组件的错误,它无法捕获其自身的错误。如果一个错误边界无法渲染错误信息,则错误会冒泡至最近的上层错误边界,这也类似于 JavaScript 中 catch {} 的工作机制。
Where?
错误边界的粒度由你来决定,可以将其包装在最顶层的路由组件并为用户展示一个 “Something went wrong” 的错误信息,就像服务端框架经常处理崩溃一样。你也可以将单独的部件包装在错误边界以保护应用其他部分不崩溃。
关于事件处理器
错误边界无法捕获事件处理器内部的错误。
React不需要错误边界来捕获事件处理器中的错误。与render方法和生命周期方法不同,事件处理器不会再渲染期间触发。因为,如果它们抛出异常,React仍然能够知道需要在屏幕上显示什么。
如果你需要在事件处理器内部捕获错误,使用普通的JavaScript的try/cathc
语句即可。
4. Fiber Architecture
讲解过程中,要说下背景,不要上来就说基础概念,这样才能让人知道你在说什么?
背景
我们会遇到这样的业务场景,有个下拉select框,需要一下渲染上千条数据的下拉选择数据,就会出现卡顿的情况。基于这个现实,fiber本质上是为了解决react更新低效率的问题。
在了解React Fiber架构的实现机制之前,我们先来了解几个基础概念,以便我们更好地理解它。
1. 基础概念
1.1 Reconciliation(协调)
* reconciliation
React使用该算法将一棵树与另一棵树进行比较以确定需要更改的部分。
* update
用于呈现React应用程序的数据中的更改。通常是setState的结果。最终导致重新渲染。
协调是通常被称为“虚拟DOM”的算法。一个高级描述是这样的:渲染React应用程序时,将生成描述该应用程序的节点树并将其保存在内存中。然后将该树刷新到渲染环境中-例如,对于浏览器应用程序,将其转换为一组DOM操作。更新应用程序后(通常通过setState),会生成一棵新树。新树与前一棵树进行比较,以计算更新呈现的应用程序需要执行哪些操作。
尽管Fiber是协调器的基础性重写,但React文档中描述的高级算法将基本相同。关键点是:
- 假定不同的组件类型生成实质上不同的树。React不会尝试区分它们,而是完全替换旧树。
- 列表的区分是使用键进行的。密钥应“稳定,可预测且唯一”。
在这多说一句:有关协调器的。详情请点击这里。
在某一时间节点调用 React 的 render() 方法,会创建一棵由 React 元素组成的树。在下一次 state 或 props 更新时,相同的 render() 方法会返回一棵不同的树。React 需要基于这两棵树之间的差别来判断如何有效率的更新 UI 以保证当前 UI 与最新的树保持同步。
这个算法问题有一些通用的解决方案,即生成将一棵树转换成另一棵树的最小操作数。 然而,即使在最前沿的算法中,该算法的复杂程度为 O(n 3 ),其中 n 是树中元素的数量。
如果在 React 中使用了该算法,那么展示 1000 个元素所需要执行的计算量将在十亿的量级范围。这个开销实在是太过高昂。于是 React 在以下两个假设的基础之上提出了一套 O(n) 的启发式算法:
- 两个不同类型的元素会产生出不同的树;
- 开发者可以通过
key
prop 来暗示哪些子元素在不同的渲染下能保持稳定;
在实践中,我们发现以上假设在几乎所有实用的场景下都成立。
针对序号2,我们必须保证key值的唯一性。现实场景中,产生一个 key 并不困难。你要展现的元素可能已经有了一个唯一 ID,于是 key 可以直接从你的数据中提取:<li key={item.id}>{item.name}</li>
。
当以上情况不成立时,你可以新增一个 ID 字段到你的模型中,或者利用一部分内容作为哈希值来生成一个 key。这个 key 不需要全局唯一,但在列表中需要保持唯一。
由于 React 依赖探索的算法,因此当以下假设没有得到满足,性能会有所损耗。
- 该算法不会尝试匹配不同组件类型的子树。如果你发现你在两种不同类型的组件中切换,但输出非常相似的内容,建议把它们改成同一类型。在实践中,我们没有遇到这类问题。
- Key 应该具有稳定,可预测,以及列表内唯一的特质。不稳定的 key(比如通过 Math.random() 生成的)会导致许多组件实例和 DOM 节点被不必要地重新创建,这可能导致性能下降和子组件中的状态丢失。
1.2 Reconciliation versus rendering(协调与渲染)
DOM只是React可以渲染的渲染环境之一,其他主要目标是通过React Native的本地iOS和Android视图。(这就是为什么“虚拟DOM”有点用词不当的原因。)
它可以支持这么多目标的原因是因为React被设计为协调和渲染是独立的阶段。协调器负责计算树的哪些部分已更改;然后,渲染器使用该信息来实际更新渲染的应用程序。
这种分离意味着React DOM和React Native可以使用自己的渲染器,同时共享由React core提供的相同协调器。
Fiber重新实现了协调器。尽管渲染器将需要更改以支持(并利用)新体系结构,但它基本上与渲染无关。
1.3 Scheduling(调度)
* scheduling
确定何时应执行工作的过程。
* work
必须执行的任何计算。工作通常是更新的结果(例如setState)。
React的Design Principles文档在这个主题上非常出色,我在这里引用一下:
在当前的实现中,React递归地遍历树,并在一个滴答中调用整个更新后的树的render函数。但是,将来可能会开始延迟一些更新以避免丢失帧。
这是React设计中的常见主题。一些流行的库实现了“推送”方法,该方法在有新数据可用时执行计算。但是,React坚持采用“拉”方法,在这种方法中,可以将计算延迟到必要的时候。
React不是通用的数据处理库。它是用于构建用户界面的库。我们认为它唯一地位于应用程序中,以了解哪些计算现在相关,哪些不相关。
如果超出屏幕范围,我们可以延迟与此相关的任何逻辑。如果数据到达速度快于帧速率,我们可以合并和批量更新。我们可以将用户交互(例如,由按钮单击引起的动画)的工作优先于次要的后台工作(例如,渲染刚从网络加载的新内容),以避免丢帧。
关键点是:
- 在用户界面中,不必立即应用每个更新。实际上,这样做可能是浪费的,导致帧下降并降低用户体验。
- 不同类型的更新具有不同的优先级-动画更新需要比数据存储中的更新更快。
- 基于推送的方法要求应用程序(您,程序员)决定如何安排工作。基于拉的方法使框架(React)变得智能,并为您做出那些决定。
目前,React并未充分利用调度的优势。更新导致立即重新渲染整个子树。彻底革新React的核心算法以利用调度是Fiber背后的驱动思想。
Fiber
1. What is a fiber?
Fiber 是 React 16 中新的协调引擎。它的主要目的是使 Virtual DOM 可以进行增量式渲染。
什么是 Virtual DOM?
Virtual DOM是一种编程概念。在这个概念里,UI以一种理想化,或者说“虚拟的”表现形式被保存在内存中,并通知如ReactDOM等类库使之与“真实的”DOM同步。这一过程叫做“协调”。
我们已经确定,Fiber的主要目标是使React能够利用调度的优势。致力于解决stack reconciler 中固有的问题,同时解决一些历史遗留问题。Fiber 从 React 16 开始变成了默认的 reconciler。
它的主要目标是:
- 能够把可中断的任务切片处理。
- 能够调整优先级,重置并复用任务。
- 能够在父元素与子元素之间交错处理,以支持 React 中的布局。
- 能够在 render() 中返回多个元素。
- 更好地支持错误边界。
- 快速响应用户操作和输入,提升用户交互体验
- 让动画更加流畅,通过调度,可以让应用保持高帧率
- 利用好I/O 操作空闲期或者CPU空闲期,进行一些预渲染。比如离屏(offscreen)不可见的内容,优先级最低,可以让 React 等到CPU空闲时才去渲染这部分内容。这和浏览器的preload等预加载技术差不多。
- 用Suspense 降低加载状态(load state)的优先级,减少闪屏。比如数据很快返回时,可以不必显示加载状态,而是直接显示出来,避免闪屏;如果超时没有返回才显式加载状态。
为了做到这一点,我们首先需要一种将工作分解成多个单元的方法。从某种意义上讲,这就是Fiber。Fiber代表工作单位。
我们知道,Stack Reconciler是React v15及之前版本使用的协调算法。而React Fiber则是从v16版本开始对Stack Reconciler进行重写,是v16版本的核心算法实现。
Stack Reconciler的实现使用了同步递归模型,该模型依赖内置堆栈来遍历。React团队Andrew之前有提到:
如果只依赖内置调用堆栈,那么它将一直工作,直到堆栈为空,如果我们可以随意终端调用堆栈并手动操作堆栈帧,这不是很好吗?这就是React Fiber的目标。Fiber是内置堆栈的重新实现,专门用于React组件,可以将一个fiber看做是一个虚拟堆栈帧。
其根本原因,是大量的同步计算任务阻塞了浏览器的 UI 渲染。默认情况下,JS 运算、页面布局和页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系。如果 JS 运算持续占用主线程,页面就没法得到及时的更新。当我们调用setState
更新页面的时候,React 会遍历应用的所有节点,计算出差异,然后再更新 UI。整个过程是一气呵成,不能被打断的。如果页面元素很多,整个过程占用的时机就可能超过 16 毫秒,就容易出现掉帧的现象。
正是由于其内置Stack Reconciler天生带来的局限性,使得DOM更新过程是同步的。也就是说,在虚拟DOM的对比过程中,如果发现一个元素实例有更新,则会立即同步执行操作,提交到真是DOM的更改。折在动画、布局以及手势等领域,可能会带来非常糟糕的用户体验。因此,为了解决这个问题,React实现了一个虚拟堆栈帧。实际上,这个所谓的虚拟堆栈帧本质上是建立了多个包含节点和指针的链表数据结构。每一个节点就是一个fiber基本单元,这个对象存储了一定的组件相关的数据域信息。而指针的指向,则是串联起整个fibers树。重新自定义堆栈带来显而易见的优点是,可以将堆栈保留在内存中,在需要执行的时候执行它们,这使得暂停遍历和停止堆栈递归成为可能。
Fiber的主要目的是实现虚拟DOM的增量渲染,能够将渲染工作拆分成块并将其分散到多个帧的能力。在新的更新到来时,能够暂停、中止和复用工作,能为不同类型的更新分配优先级顺序的能力。
2. How?
Fiber reconciler已经在 React 16 中启用了,但是 async 特性还没有默认开启。
网友评论