不定期更新
2022.01.10
JSX
JSX是什么?
JSX 是 JavaScript 的一种语法扩展,它和模板语言很接近,但是它充分具备 JavaScript 的能力。
class App extends React.Component {
...
render() {
return (
<div id="li">
Hello
<p>world</p>
</div>
);
}
}
组件中return的内容就是JSX
浏览器天然支持JSX吗?
不支持
Babel会把JSX转译成一个名为 React.createElement() 函数调用,生成对应的DOM
class App extends React.Component {
render() {
return React.createElement(
"div",
{ id: "li" },
"Hello",
React.createElement(
"p",
null,
"world"
)
);
}
}
虚拟DOM
什么是虚拟DOM?
- 本质上是JS 和 DOM 之间的一个映射缓存
- 形态上表现为一个能够描述 DOM 结构及其属性信息的 JS 对象
虚拟DOM和真实DOM
| Real DOM | Virtual DOM |
|---|---|
| 1. 更新缓慢。 | 1. 更新更快。 |
| 2. 可以直接更新 HTML。 | 2. 无法直接更新 HTML。 |
| 3. 如果元素更新,则创建新DOM。 | 3. 如果元素更新,则更新 JSX 。 |
| 4. DOM操作代价很高。 | 4. DOM 操作非常简单。 |
| 5. 消耗的内存较多。 | 5. 很少的内存消耗。 |
虚拟 DOM 大致是如何工作的
- 挂载阶段 React 将结合 JSX 的描述,构建出虚拟 DOM 树,然后通过 ReactDOM.render 实现虚拟 DOM 到真实 DOM 的映射(触发渲染流水线)
- 更新阶段 页面的变化在作用于真实 DOM 之前,会先作用于虚拟 DOM,虚拟 DOM 将在 JS 层借助算法先对比出具体有哪些真实 DOM 需要被改变,然后再将这些改变作用于真实 DOM
虚拟 DOM 工作流
图中的 diff 和 patch 其实都是函数名,这些函数取材于一个独立的虚拟 DOM 库
虚拟 DOM 和 Redux 一样,不依附于任何具体的框架;也就是说,并不是只有React里才有虚拟DOM
虚拟DOM性能真的更好吗
能够在提供更爽、更高效的研发模式(也就是函数式的 UI 编程方式)的同时,仍然保持一个还不错的性能
它的优势是在以补丁的形式更少量的操作真实DOM 但这并不是绝对的。
虚拟 DOM 的构建和 diff 过程逻辑则相对复杂,它不可避免地涉及递归、遍历等耗时操作
如果数据内容变化非常大(或者说整个发生了改变),促使差量更新计算出来的结果和全量更新极为接近(或者说完全一样),使用虚拟DOM反而会增加开销
但这可能性不大。
因为虚拟 DOM 的劣势主要在于 JS 计算的耗时,而 DOM 操作的能耗和 JS 计算的能耗根本不在一个量级,极少量的 DOM 操作耗费的性能足以支撑大量的 JS 计算
虚拟DOM的价值在于其性能吗
其实不是的,或者说关键价值不是
虚拟 DOM 解决的关键问题有以下两个:
-
研发体验/研发效率的问题:这一点上述也提到过,DOM 操作模式的每一次革新,背后都是前端对效率和体验的进一步追求。虚拟 DOM 的出现,为数据驱动视图这一思想提供了高度可用的载体,使得前端开发能够基于函数式 UI 的编程方式实现高效的声明式编程。
-
跨平台的问题:虚拟 DOM 是对真实渲染内容的一层抽象。若没有这一层抽象,那么视图层将和渲染平台紧密耦合在一起,为了描述同样的视图内容,你可能要分别在 Web 端和 Native 端写完全不同的两套甚至多套代码。但现在中间多了一层描述性的虚拟 DOM,它描述的东西可以是真实 DOM,也可以是iOS 界面、安卓界面、小程序......同一套虚拟 DOM,可以对接不同平台的渲染逻辑,从而实现“一次编码,多端运行”。
虚拟dom的实现就在差量更新(补丁)上吗
除了差量更新以外,“批量更新”也是虚拟 DOM 在性能方面所做的一个重要努力: “批量更新”在通用虚拟 DOM 库里是由 batch 函数来处理的。在差量更新速度非常快的情况下(比如极短的时间里多次操作同一个 DOM),用户实际上只能看到最后一次更新的效果。这种场景下,前面几次的更新动作虽然意义不大,但都会触发重渲染流程,带来大量不必要的高耗能操作
batch 的作用是缓冲每次生成的补丁集,它会把收集到的多个补丁集暂存到队列中,再将最终的结果交给渲染函数,最终实现集中化的 DOM 批量更新
React特点
- 它使用虚拟DOM 而不是真正的DOM。
- 它可以进行服务器端渲染。
- 它遵循单向数据流或数据绑定。
React优点?
- 提高了应用的性能
- 可以方便地在客户端和服务器端使用
- 由于 JSX,代码的可读性很好
- React 很容易与 Meteor,Angular 等其他框架集成
- 使用React,编写UI测试用例变得非常容易
React vs Angular
| 主题 | React | Angular |
|---|---|---|
| 1. 体系结构 | 只有 MVC 中的 View | 完整的 MVC |
| 2. 渲染 | 可以进行服务器端渲染 | 客户端渲染 |
| 3. DOM | 使用 virtual DOM | 使用 real DOM |
| 4. 数据绑定 | 单向数据绑定 | 双向数据绑定 |
| 5. 调试 | 编译时调试 | 运行时调试 |
生命周期
componentDidMount
- 真实 DOM 已经挂载,可以执行真实 DOM 相关的操作
- 可以执行异步请求
- 可以执行数据初始化
getDerivedStateFromProps
- 它是静态方法,不依赖组件实例而存在,无法访问this
- 该方法可以接收两个参数:props(自父组件的 props ) 和 state(当前组件自身的 state)
- 需要一个对象格式的返回值或者null
- 该方法在React 16.3和16.4(及以后)在更新流程上有不同
在 React 16.4 中,任何因素触发的组件更新流程(包括由 this.setState 和 forceUpdate 触发的更新流程)都会触发 getDerivedStateFromProps
在 v 16.3 版本时,只有父组件的更新会触发该生命周期。
- 它是componentWillReceiveProps的替代品吗?
不完全是。相对于componentWillReceiveProps,这个API做了合理的减法,从它被定义成static方法就可以看出
static 方法内部拿不到组件实例的 this,这就导致你无法在 getDerivedStateFromProps 里面做任何类似于 this.fetch()、不合理的 this.setState(会导致死循环的那种)这类可能会产生副作用的操作
getSnapshotBeforeUpdate
- getSnapshotBeforeUpdate 的返回值会作为第三个参数给到 componentDidUpdate
- 它的执行时机是在 render 方法之后,真实 DOM 更新之前
- 在这个阶段里,我们可以同时获取到更新前的真实 DOM 和更新前后的 state&props 信息
- 对于这个生命周期,需要重点把握的是它与 componentDidUpdate 间的通信过程
为什么废弃componentWillMount、componentWillUpdate、componentWillReceiveProps
React更新
在 React 16 之前(后续描述都为 React 15),每当我们触发一次组件的更新,React 都会构建一棵新的虚拟 DOM 树,通过与上一次的虚拟 DOM 树进行 diff,实现对 DOM 的定向更新。这个过程,是一个递归的过程。下面这张图形象地展示了这个过程的特征:
如图所示,同步渲染的递归调用栈是非常深的,只有最底层的调用返回了,整个渲染过程才会开始逐层返回。
这个漫长且不可打断的更新过程,将会带来用户体验层面的巨大风险:同步渲染一旦开始,便会牢牢抓住主线程不放,直到递归彻底完成。
在这个过程中,浏览器没有办法处理任何渲染之外的事情,会进入一种无法处理用户交互的状态。因此若渲染时间稍微长一点,页面就会面临卡顿甚至卡死的风险
React 16引入了Fiber。Fiber会使原本同步的渲染过程变成异步的
- Fiber 会将一个大的更新任务拆解为许多个小任务
- 每当执行完一个小任务时,渲染线程都会把主线程交回去,看看有没有优先级更高的工作要处理,进而避免同步渲染带来的卡顿
这里再次祭出生命周期大图
可以生命周期被分为三个阶段
- render 阶段:纯净且没有副作用,可能会被 React 暂停、终止或重新启动。
- pre-commit 阶段:可以读取 DOM。
- commit 阶段:可以使用 DOM,运行副作用,安排更新 render 阶段在执行过程中允许被打断,而 commit 阶段则总是同步执行的
因为 render 阶段的操作对用户来说是“不可见”的,所以就算打断再重启,对用户来说也是零感知。而 commit 阶段的操作则涉及真实 DOM 的渲染,会更改视图让用户感知
那么我们再次理解一下,由于在 Fiber 机制下,render 阶段是允许暂停、终止和重启的,当一个任务执行到一半被打断后,下一次渲染线程抢回主动权时,这个任务被重启的形式是“重复执行一遍整个任务”而非“接着上次执行到的那行代码往下走”。这就导致 render 阶段的生命周期都是有可能被重复执行的
危害
带着上述结论,我们来看看这三个生命周期,它们都处于render阶段,都可能被重复执行;如果它们被滥用,那么可能导致不可小觑的风险,例如:
- setState()
- fetch 发起异步请求
- 操作真实 DOM 那么为什么上述操作会有风险呢?
- 可以转移到其他生命周期(尤其是 componentDidxxx)里去做
这个不是危害,但显得这几个方法的鸡肋。典型例子,在 componentWillMount 里发起异步请求,妄想在render渲染前就获取数据,避免页面白屏。
实际上render速度极快,根本不可能先完成异步请求 - 在 Fiber 带来的异步渲染机制下,可能会导致非常严重的 Bug 假如你在 componentWillUpdate 里发起了一个付款请求。由于 render 阶段里的生命周期都可以重复执行,在 componentWillUpdate 被打断重启多次后,就会发出多个付款请求
- 在 componentWillReceiveProps 和 componentWillUpdate 里滥用 setState 导致重复渲染死循环的
setState
同步,还是异步?
“setState 是一个异步的方法”,这意味着当我们执行完 setState 后,state 本身并不会立刻发生改变。 因此紧跟在 setState 后面输出的 state 值,仍然会维持在它的初始状态。在同步代码执行完毕后的某个“神奇时刻”,setState 才会“恰恰好”执行
setState 发生了什么?
从图上我们可以看出,一个完整的更新流程,涉及了包括 re-render(重渲染) 在内的多个步骤。re-render 本身涉及对 DOM 的操作,它会带来较大的性能开销。假如说“一次 setState 就触发一个完整的更新流程”这个结论成立,那么每一次 setState 的调用都会触发一次 re-render,我们的视图很可能没刷新几次就卡死了
在实际的 React 运行时中,setState 异步的实现方式有点类似于微任务:每来一个 setState,就把它塞进一个队列里“攒起来”。等同步代码执行完成,再把“攒起来”的 state 结果做合并,最后只针对最新的 state 值走一次更新流程。这个过程,叫作“批量更新”
为什么有时候 setState 同步?
上述可知,setState 压栈的过程,会在同步任务结束时也结束掉,开始读取栈执行 setState。也就是说,如果你的代码本身不是同步代码,那么自然也不会有这个压栈过程,在效果上就像是同步执行的。如 setTimeout 里执行的 setState
Context
三要素
- 创建一个 context 对象,选择性地传入一个defaultValue
const AppContext = React.createContext(defaultValue)
const { Provider, Consumer } = AppContext // 从创建出的 context 对象中,我们可以读取到 Provider 和 Consumer
- 使用 Provider 对组件树中的根组件进行包裹,然后传入名为“value”的属性,这个 value 就是后续在组件树中流动的“数据”,它可以被 Consumer 消费
<Provider value={title: this.state.title, content: this.state.content}>
<Title />
<Content />
</Provider>
- 数据消费者
<Consumer>
{value => <div>{value.title}</div>}
</Consumer>
新的 Context API 解决了什么问题?
要知道,上述的Context是新API,getChildContext 等旧API已被废弃,因为:
如果组件提供的一个Context发生了变化,而中间父组件的 shouldComponentUpdate 返回 false,那么使用到该值的后代组件不会进行更新。使用了 Context 的组件则完全失控,所以基本上没有办法能够可靠的更新 Context 而新API改进了这点 即便组件的 shouldComponentUpdate 返回 false,它仍然可以“穿透”组件继续向后代组件进行传播,进而确保了数据生产者和数据消费者之间数据的一致性
React 事件
原生 DOM 事件
- DOM 事件流 当事件被触发时,首先经历的是一个捕获过程:事件会从最外层的元素开始“穿梭”,逐层“穿梭”到最内层元素,这个过程会持续到事件抵达它目标的元素(也就是真正触发这个事件的元素)为止;此时事件流就切换到了“目标阶段”——事件被目标元素所接收;然后事件会被“回弹”,进入到冒泡阶段——它会沿着来时的路“逆流而上”,一层一层再走回去
- 优化
利用事件的冒泡特性,把多个子元素的同一类型的监听逻辑,合并到父元素上通过一个监听函数来管理,也就是事件委托。
通过事件委托,我们可以减少内存开销、简化注册步骤,大大提高开发效率
React 事件如何工作的?
在 React 中,除了少数特殊的不可冒泡的事件(比如媒体类型的事件)无法被事件系统处理外,绝大部分的事件都不会被绑定在具体的元素上,而是统一被绑定在页面的 document 上。当事件在具体的 DOM 节点上被触发后,最终都会冒泡到 document 上,document 上所绑定的统一事件处理程序会将事件分发到具体的组件实例。
在分发事件之前,React 首先会对事件进行包装,把原生 DOM 事件包装成合成事件
合成事件是什么?
合成事件是 React 自定义的事件对象,它符合W3C规范,在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的、稳定的、与 DOM 原生事件相同的事件接口
虽然合成事件并不是原生 DOM 事件,但它保存了原生 DOM 事件的引用。当你需要访问原生 DOM 事件对象时,可以通过合成事件对象的 e.nativeEvent 属性获取到它
事件的绑定
事件的绑定是在组件的挂载过程中完成的,具体来说,是在 completeWork 中完成的。
completeWork 内部有三个关键动作:创建 DOM 节点(createInstance)、将 DOM 节点插入到 DOM 树中(appendAllChildren)、为 DOM 节点设置属性(finalizeInitialChildren)。
设置属性这个环节,会遍历 FiberNode 的 props key。当遍历到事件相关的 props 时,就会触发事件的注册链路。
- 事件注册链路
事件的注册过程是由 ensureListeningTo 函数开启的。在 ensureListeningTo 中,会尝试获取当前 DOM 结构中的根节点(这里指的是 document 对象),然后通过调用 legacyListenToEvent,将统一的事件监听函数注册到 document 上面
- legacyListenToEvent
legacyListenToEvent 中,实际上是通过调用 legacyListenToTopLevelEvent 来处理事件和 document 之间的关系
在 legacyListenToTopLevelEvent 逻辑的起点,调用 listenerMap.has(topLevelType) 判断是否为 true
若 listenerMap.has(topLevelType) 为 true,也就是当前这个事件 document 已经监听过了,那么就会直接跳过对这个事件的处理,否则才会进入具体的事件监听逻辑。如此一来,在 React 项目中多次调用了对同一个事件的监听,也只会在 document 上触发一次注册 其中listenerMap 是在 legacyListenToEvent 里创建/获取的一个数据结构,它将记录当前 document 已经监听了哪些事件。topLevelType 在 legacyListenToTopLevelEvent 的函数上下文中代表事件的类型,如 click
- 一次注册
为什么针对同一个事件,即便可能会存在多个回调,document 监听也只注册一次?因为 React最终注册到 document 上的并不是某一个 DOM 节点上对应的具体回调逻辑,而是一个统一的事件分发函数
参数中 eventType 则是监听的事件类型,而listener就是那个分发函数
- 分发函数 listener根据情况不同,可以是 dispatchDiscreteEvent、dispatchUserBlockingUpdate、dispatchEvent。
dispatchDiscreteEvent 和 dispatchUserBlockingUpdate 的不同主要体现在对优先级的处理上,它们最后都是通过调用 dispatchEvent 来执行事件分发的。因此,最后绑定到 document 上的这个统一的事件分发函数,其实就是 dispatchEvent。
- dispatchEvent
事件触发的本质是对 dispatchEvent 函数的调用。dispatchEvent 触发的调用链路较长,这里直接来看核心工作流:
事件触发,冒泡到 document -> 执行dispatchEvent -> 创建对应的合成事件对象 -> 收集事件在捕获阶段涉及的回调函数和对应的节点实例 -> 收集事件在冒泡阶段涉及的回调函数和对应的节点实例 -> 将上述收集到的回调按顺序执行,当中合成事件对象会传入回调
事件的触发
从上文,事件的触发其实就是dispatchEvent 函数的调用;这节我们重点分析后面几步,即收集和回调
收集过程
我们来看看源码逻辑
function traverseTwoPhase(inst, fn, arg) {
// 定义一个 path 数组
var path = [];
while (inst) {
// 将当前节点收集进 path 数组
path.push(inst);
// 向上收集 tag===HostComponent 的父节点
inst = getParent(inst); }
var i;
// 从后往前,收集 path 数组中会参与捕获过程的节点与对应回调
for (i = path.length; i-- > 0;) {
fn(path[i], 'captured', arg);
}
// 从前往后,收集 path 数组中会参与冒泡过程的节点与对应回调
for (i = 0; i < path.length; i++) {
fn(path[i], 'bubbled', arg);
}
}
这个核心函数做了以下几件事:
- 循环收集符合条件的父节点,存进 path 数组中
traverseTwoPhase会以当前节点(触发事件的目标节点)为起点,不断向上寻找 tag===HostComponent 的父节点,并将这些节点按顺序收集进 path 数组中。其中 tag===HostComponent 这个条件是在 getParent() 函数中管控的。
为什么一定要求 tag===HostComponent 呢?因为HostComponent 是 DOM 元素对应的 Fiber 节点类型。此处限制,也就是说只收集 DOM 元素对应的 Fiber 节点。之所以这样做,是因为浏览器只认识 DOM 节点,浏览器事件也只会在 DOM 节点之间传播,收集其他节点是没有意义的。
这里注意数组 path 的顺序,是从子节点逐步往上的 - 模拟事件在捕获阶段的传播顺序,收集捕获阶段相关的节点实例与回调函数
接下来,traverseTwoPhase 会从后往前遍历 path 数组,模拟事件的捕获顺序,收集事件在捕获阶段对应的回调与实例。
由于 path 数组中子节点在前,祖先节点在后从后往前遍历 path 数组,其实就是从父节点往下遍历子节点,直至遍历到目标节点的过程,与捕获阶段的传播顺序一致。在遍历的过程中,fn 函数会对每个节点的回调情况进行检查,若该节点上对应当前事件的捕获回调不为空,那么节点实例会被收集到合成事件的 _dispatchInstances 属性(也就是 SyntheticEvent._dispatchInstances)中去,事件回调则会被收集到合成事件的 _dispatchListeners 属性(也就是 SyntheticEvent._dispatchListeners) 中去,等待后续的执行。 - 模拟事件在冒泡阶段的传播顺序,收集冒泡阶段相关的节点实例与回调函数
捕获阶段的工作完成后,traverseTwoPhase 会从后往前遍历 path 数组,模拟事件的冒泡顺序,收集事件在捕获阶段对应的回调与实例。
既然倒序遍历模拟的是捕获阶段的事件传播顺序,那么正序遍历自然模拟的就是冒泡阶段的事件传播顺序。在正序遍历的过程中,同样会对每个节点的回调情况进行检查,若该节点上对应当前事件的冒泡回调不为空,那么节点实例和事件回调同样会分别被收集到 SyntheticEvent._dispatchInstances 和 SyntheticEvent._dispatchListeners 中去。
需要注意的是,当前事件对应的 SyntheticEvent 实例有且仅有一个,因此在模拟捕获和模拟冒泡这两个过程中,收集到的实例会被推入同一个 SyntheticEvent._dispatchInstances,收集到的事件回调也会被推入同一个 SyntheticEvent._dispatchListeners。
这样一来,在事件回调的执行阶段,只需要按照顺序执行 SyntheticEvent._dispatchListeners 数组中的回调函数,就能够一口气模拟出整个完整的 DOM 事件流,也就是 “捕获-目标-冒泡”这三个阶段
Ref
Ref 什么用
React中的ref表示引用refreference。 使用场景:直接使用dom元素的某个方法,或者直接使用自定义组件中的某个方法
- 当 ref 属性用于 HTML 元素时,构造函数中使用 React.createRef() 创建的 ref 接收底层 DOM 元素作为其 current 属性。
- 当 ref 属性用于自定义 class 组件时,ref 对象接收组件的挂载实例作为其 current 属性。
- 默认情况下,你不能在函数组件上使用 ref 属性(可以在函数组件内部使用),因为它们没有实例:
- 如果要在函数组件中使用 ref,你可以使用 forwardRef(可与 useImperativeHandle 结合使用)
- 或者可以将该组件转化为 class 组件。
Ref 怎么用
在 React v16.3 之前,ref 通过字符串(string ref)或者回调函数(callback ref)的形式进行获取,在 v16.3 中,引入了新的 React.createRef API,在 Hooks 中,又提供了 useRef
// string ref
class MyComponent extends React.Component {
componentDidMount() {
this.refs.myRef.focus();
}
render() {
return <input ref="myRef" />;
}
}
// callback ref
class MyComponent extends React.Component {
componentDidMount() {
this.myRef.focus();
}
render() {
return <input ref={(ele) => {
this.myRef = ele;
}} />;
}
}
// React.createRef
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
}
componentDidMount() {
this.myRef.current.focus();
}
render() {
return <input ref={this.myRef} />;
}
}
// useRef
function MyComponent() {
const myRef = useRef(null);
const onButtonClick = () => {
// `current` 指向已挂载到 DOM 上的文本输入元素
myRef.current.focus();
};
return (
<>
<input ref={myRef} type="text" />
<button onClick={onButtonClick}>聚焦</button>
</>
);
}
string ref
string ref被诟病已久,官方吐槽最为致命:"如果你目前还在使用 this.refs.textInput 这种方式访问 refs ,我们建议用回调函数或 createRef API 的方式代替。",为何如此糟糕?
- string ref 不可组合。 例如一个第三方库的父组件已经给子组件传递了 ref,那么我们就无法在在子组件上添加 ref 了
/** string ref **/
class Parent extends React.Component {
componentDidMount() {
// 可获取到 this.refs.childRef
// 上述的 第三方库父组件已经给子组件传递了 ref
console.log(this.refs);
}
render() {
const { children } = this.props;
return React.cloneElement(children, {
ref: 'childRef',
});
}
}
class App extends React.Component {
componentDidMount() {
// this.refs.child 无法获取到
// 实际调用者无法再控制 ref
console.log(this.refs);
}
render() {
return (
<Parent>
<Child ref="child" />
</Parent>
);
}
}
callback 方案可以解决这个问题
/** callback ref **/
class Parent extends React.Component {
componentDidMount() {
// 可以获取到 child ref
console.log(this.childRef);
}
render() {
const { children } = this.props;
return React.cloneElement(children, {
ref: (child) => {
this.childRef = child;
children.ref && children.ref(child);
}
});
}
}
class App extends React.Component {
componentDidMount() {
// 可以获取到 child ref
console.log(this.child);
}
render() {
return (
<Parent>
<Child ref={(child) => {
this.child = child;
}} />
</Parent>
);
}
}
2. string ref 的所有者由当前执行的组件确定,造成挂载位置歧义
class MyComponent extends Component {
renderRow = (index) => {
// string ref 会挂载在 DataTable this 上
return <input ref={'input-' + index} />;
// callback ref 会挂载在 MyComponent this 上
return <input ref={input => this['input-' + index] = input} />;
}
render() {
return <DataTable data={this.props.data} renderRow={this.renderRow} />
}
}
3. string ref 强制React跟踪当前正在执行的组件。 这是有问题的,因为它使react模块处于有状态,并在捆绑中复制react模块时导致奇怪的错误。在 reconciliation 阶段,React Element 创建和更新的过程中,ref 会被封装为一个闭包函数,等待 commit 阶段被执行,这会对 React 的性能产生一些影响。
function coerceRef(
returnFiber: Fiber,
current: Fiber | null,
element: ReactElement,
) {
...
const stringRef = '' + element.ref;
// 从 fiber 中得到实例
let inst = ownerFiber.stateNode;
// ref 闭包函数
const ref = function(value) {
const refs = inst.refs === emptyObject ? (inst.refs = {}) : inst.refs;
if (value === null) {
delete refs[stringRef];
} else {
refs[stringRef] = value;
}
};
ref._stringRef = stringRef;
return ref;
...
}
- string ref 不适用于Flow之类的静态分析。 Flow不能猜测框架可以使字符串ref“出现”在react上的神奇效果,以及它的类型(可能有所不同)。 当使用 string ref 时,必须显式声明 refs 的类型,无法完成自动推导
- 在根组件上使用无法生效
ReactDOM.render(<App ref="app" />, document.getElementById('root'));
callback ref
React 将在组件挂载时,会调用 ref 回调函数并传入 DOM 元素,当卸载时调用它并传入 null。在 componentDidMount 或 componentDidUpdate 触发前,React 会保证 refs 一定是最新的。
如果 ref 回调函数是以内联函数的方式定义的,在更新过程中它会被执行两次,第一次传入参数 null,然后第二次会传入参数 DOM 元素。这是因为在每次渲染时会创建一个新的函数实例,所以 React 清空旧的 ref 并且设置新的。通过将 ref 的回调函数定义成 class 的绑定函数的方式可以避免上述问题,但是大多数情况下它是无关紧要的。
相较于 string ref 优势,在上文顺带讲过
createRef
对比新的 createRef 与 callback ref,并没有压倒性的优势,只是显得更加直观,避免了 callback ref 的一些理解问题。然而它性能上往往略弱于 callback ref
useRef
useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。并且 useRef 可以很方便地保存任何可变值,其类似于在 class 中使用实例字段的方式,它可以获取到最新的值
React.forwardRef
除了 createRef 以外,React16 还另外提供了一个关于 ref 的 React.forwardRef,主要用于穿过父元素直接获取子元素的 ref。在 React.forwardRef 之前,可以通过将 ref 作为特殊名字的 prop 直接传递
function CustomTextInput(props) {
return (
<div>
<input ref={props.inputRef} />
</div>
);
}
class Parent extends React.Component {
constructor(props) {
super(props);
this.inputElement = React.createRef();
}
render() {
return (
<CustomTextInput inputRef={this.inputElement} />
);
}
}
forwardRef 就是解决这一困境
const FancyButton = React.forwardRef((props, ref) => (
<button ref={ref} className="FancyButton">
{props.children}
</button>
));
// 你可以直接获取 DOM button 的 ref:
const ref = React.createRef();
<FancyButton ref={ref}>Click me!</FancyButton>;
- 我们通过调用 React.createRef 创建了一个 React ref 并将其赋值给 ref 变量。
- 我们通过指定 ref 为 JSX 属性,将其向下传递给
<FancyButton ref={ref}>。 - React 传递 ref 给 forwardRef 内函数 (props, ref) => ...,作为其第二个参数。
- 我们向下转发该 ref 参数到
<button ref={ref}>,将其指定为 JSX 属性。 - 当 ref 挂载完成,ref.current 将指向
<button>DOM 节点。
React.forwardRef 的原理其实非常简单,forwardRef 会生成 react 内部一种较为特殊的 Component。当进行创建更新操作时,会将 forwardRef 组件上的 props 与 ref 直接传递给提前注入的 render 函数,来生成 children。
const nextChildren = render(workInProgress.pendingProps, workInProgress.ref);
复制代码
Hooks
函数式组件是什么?
函数式组件就是以函数的形态存在的 React 组件;与之相对应的类组件通过继承 React.Component 得来的 React 组件
在Hooks出现之前,他俩有如下差别:
类组件需要继承 class;
类组件可以访问生命周期方法;
类组件中可以获取到实例化后的 this,并基于这个 this 做各种各样的事情,而函数组件不可以;
类组件中可以定义并维护 state(状态);
函数组件会捕获 render 内部的状态;
函数组件学习成本更低;
...
为什么需要 Hooks ?
- 更加契合 React 的理念
函数组件能够捕获 render 内部的状态,更加契合 React 框架的设计理念--UI= F(state),更加匹配其设计理念、也更有利于逻辑拆分与重用;
但经过上述对比,函数式组件虽然更贴近React设计概念,但它本身缺少一些“功能”,Hooks 是为了补齐这些功能 - class难以理解
主要指 this 和 生命周期 两个痛点:
this 本身可被改变,具有不确定性和欺骗性
生命周期学习成本过高 - 解决业务逻辑难以拆分的问题
为什么这么说呢?以为在class组件里,逻辑曾经一度与生命周期耦合在一起。它的体积过于庞大,做的事情过于复杂,会给阅读和维护者带来很多麻烦。最重要的是,这些事情之间看上去毫无关联,逻辑就像是被“打散”进生命周期里了一样
而在 Hooks 的帮助下,我们可以把这些繁杂的操作按照逻辑上的关联拆分进不同的函数组件里:我们可以有专门管理订阅的函数组件、专门处理 DOM 的函数组件、专门获取数据的函数组件等。Hooks 能够帮助我们实现业务逻辑的聚合,避免复杂的组件和冗余的代码
API
useState
给函数式组件定义内部 state ;这里需要注意,使用 useState 是关联了一个状态,而不是一批状态。这一点是相对于类组件中的 state 来说的。在类组件中,我们定义的 state 通常是一个对象(一批状态)
而在 useState 这个钩子的使用背景下,state 就是单独的一个状态,它可以是任 JS 类型
useEffect
允许函数组件执行副作用操作。过去在 componentDidMount、componentDidUpdate 和 componentWillUnmount 生命周期里来做的事,现在可以放在 useEffect 里来做
useEffect(callBack, [])
- useEffect 可以接收两个参数,分别是回调函数与依赖数组。
- callBack 依赖数组变量,当变量时改变时,callBack会执行;
- 若 [] 为空,那么 callBack 只在挂载的时候执行;
- 若 callBack 需返回一个函数,那么该函数被称为“清除函数”;当 React 识别到清除函数时,会在卸载时执行清除函数内部的逻辑。这个规律不会受第二个参数或者其他因素的影响,只要你在 useEffect 回调中返回了一个函数,它就会被作为清除函数来处理
为什么不要在循环、条件或嵌套函数中调用 Hook?
具体源码比较复杂,本篇文章不做讨论,以useState为例,大致注意以下几点:
- Hooks 的本质其实是链表
- React-Hooks 的调用链路在首次渲染和更新阶段是不同的
- 首次渲染时,useState 触发的一系列操作最后会落到 mountState 里,mounState 的主要工作是初始化 Hooks —— 将 hook 相关的所有信息收敛在一个 hook 对象里,而 hook 对象之间以单向链表的形式相互串联;最后进行渲染
- 更新渲染时,按顺序去遍历之前构建好的链表,取出对应的数据信息进行渲染
- hooks 的渲染是通过“依次遍历”来定位每个 hooks 内容的。如果前后两次读到的链表在顺序上出现差异,那么渲染的结果自然是不可控的
栈调和
这是 React 15 的概念,虽然已经过时,但了解也没坏处
什么是调和
虚拟DOM 是一种编程概念。在这个概念里,UI 以一种理想化的,或者说“虚拟的”表现形式被保存于内存中,并通过如 ReactDOM 等类库使之与“真实的” DOM 同步,这一过程叫作协调(调和)。简单来说,调和指的是将虚拟 DOM映射到真实 DOM 的过程
什么是Diff
Diff是调和过程中的一个重要的环节,即找不同的环节
根据 Diff 实现形式的不同,调和过程被划分为了以 React 15 为代表的“栈调和”以及 React 16 以来的“Fiber 调和”
接下来主要针对 Diff( React15 )进行分析
Diff 怎么做的
传统的找树之间的不同,它通过循环递归进行树节点一一对比的,其算法复杂度为O(n3)
React 对其的优化点如下:
- 分层对比
结合“DOM 节点之间的跨层级操作并不多,同层级操作是主流”这一规律,React 的 Diff 过程直接放弃了跨层级的节点比较,它只针对相同层级的节点作对比,只需要从上到下的一次遍历,就可以完成对整棵树的对比
如果真的发生了跨节点操作会怎么办?React会“愚蠢”的认为该节点已经不需要了,会进行销毁操作,在真正出现的节点又重新创建树
销毁 + 重建的代价是昂贵的,因此 React 官方也建议开发者不要做跨层级的操作,尽量保持 DOM 结构的稳定性 - 类型一致才Diff
大部分情况下,不同类型的组件 DOM 结构不同。而不同的 DOM 树无Diff需要,所以 React 认为,只有同类型的组件,才有进一步对比的必要性;若参与 Diff 的两个组件类型不同,那么直接放弃比较,原地替换掉旧的节点 - 使用Key来跟踪节点
key试图解决的是同一层级下节点的重用/调换顺序的问题。它需要写在用数组渲染出来的元素内部,并且需要赋予其一个稳定且唯一的值,充当元素的ID。稳定在这里很重要,因为如果 key 值发生了变更,React 则会触发 UI 的重渲染。
有了这个key之后,当元素位置被移动时,React 并不会再认为节点需要被重建——它会通过识别 ID,意识到节点并没有发生变化,而只是被调整了顺序而已。接着,React 便能够轻松地重用它“追踪”到旧的节点,将它转移到新的位置。这样一来,同层级下元素的操作成本便大大降低。 其缺点如下:
由于Stack Reconciler是一个同步的递归过程,它是不可以被打断的。当处理结构相对复杂的虚拟 DOM 树时,Stack Reconciler 需要的调和/diff 时间会很长,这就意味着 JS 线程将长时间地霸占主线程。而 JS 线程与渲染线程是互斥的,进而导致页面渲染卡顿/卡死、交互长时间无响应等问题。
Fiber
由于栈调和有上述性能问题,React 便在16里推出了新的调和方式,即 Fiber
什么是Fiber
从架构角度来看,Fiber 是对 React 核心算法(即调和过程)的重写;从编码角度来看,Fiber 是 React 内部所定义的一种数据结构,它是 Fiber 树结构的节点单位,也就是 React 16 新架构下的“虚拟 DOM”;从工作流的角度来看,Fiber 节点保存了组件需要更新的状态和副作用,一个 Fiber 同时也对应着一个工作单元 由于篇幅过长,麻烦动动小手查看另一篇文章 // todo