前言
不知不觉使用 React 也有一段时间了,最近也在学习 React 的一些相关知识,于是打算整理成文章顺便分享出来,进而巩固自己对 React 整体的认知。如有错误,欢迎指正!
正文
1 React 是什么?
- 讲概念
React 是一个 UI 框架。或者说是一个库。通过组件化的方式解决视图层开发复用的问题,本质是一个组件化的框架。
在 2005 年, 那时候各大浏览器横空出世,浏览器的兼容是前端非常头疼的问题,为了解决兼容性问题,JQuery 诞生了。
但 JQuery 虽然解决了浏览器的兼容问题,但没有很好的解决代码组织问题,随着浏览器的性能越来越高,页面也变得越来越复杂,为了更好的维护代码,Angular诞生了。
Angular 不仅引入了 MVC 的思想,还提供了双向数据绑定、指令、组件等全面的解决方案,但其庞大厚重的概念,让人望而止步。
而开发 React 的思维模式是完全不同的,概念也极其简单
js
View = fn(props)
在 React 中, 只要输入的 props 是相同的,输出的视图便会一致。
在实际编程中, fn 可能是一个类组件,也可能是一个纯函数组件,也有可能在函数中产生影响 UI 生成的副作用,例如直接操作 DOM 或者监听事件
在 React 中,只关心数据和组件,通过组合组件的方式构建视图,方便后续的维护和复用。
-
说用途
- 在 React 的用途是构建视图,由于 React 虚拟 DOM 的关系其适用场景变得非常广泛,无论是 PC 网页还是移动端网页都是完全支持的。
- 因为 React Native 也可以用于开发 IOS 与 Android 应用。
- React 360 可以开发 VR 应用
- 还有一些冷门的如 ink, 可以使用 React 开发命令行应用
-
理思路 React 的核心思路有三点: 声明式、组件化、通用性
官方称之为一次学习随处编写。
-
声明式 声明式编程的优势在于直观,也方便组合 例如 JQuery 的命令式: $('.block').css('color', 'red'); 而在 React 中采用的是声明式的方式编写组件,例如 const Block = (props) =>
-
组件化 降低功能之间的耦合性,提高功能的内聚性
-
通用性
React 和 Vue 一样,不会直接操作 DOM, 而是将 DOM 抽象为虚拟 DOM, 使得 React 不局限于 Web, 提高通用性和可移植性。
-
-
列优缺 React 的核心思路就是 React 的优点(声明式、组件化、通用型)
React 并没有像 Vue 一样有全链路的通用解决方案,例如路由这块,React 交付由社区进行共建,导致技术选型和学习使用上有比较高的成本
总结:
1 React 通过组件化的方式解决视图层开发复用的问题,本质是一个组件化框架。
2 它的核心思路是: 声明式、组件化、通用性
声明式的优势在于直观和方便组合
组件化的优势在于可以降低功能之间耦合、提高功能的内聚性
通用性的优势在于虚拟DOM的实现,保证跨平台和可移植性
3 React 的劣势也十分的明显,它并没有提供完全的全链路解决方案,例如 Router,需要向社区寻找并整合解决方案,开发者在技术选型和学习使用上有比较高的成本
2 React 为什么要使用 JSX?
- 一句话解释 JSX
- 核心概念
- 方案对比
JSX 是一个 JavaScript 的语法拓展,结构类似于 XML,JSX 常用来声明 HTML 元素 而 React 本身并不强制使用 JSX, 在没有 JSX 时, 可以使用 React.createElement 实现一个组件。但相对而言, JSX 语法对组件的描述更具有可读性。
方案对比: 1 使用模版: 以 Angular 为例,使用模块需要引入指令、controller 等相关改变,不利于关注分离,学习成本变高。而 JSX 并不会引入额外的概念,它依旧还是 JavaScript (从侧面也不难看出 React 团队不想引入额外的技术,想通过关注点分离的形式保持组件的纯粹)
2 使用模版字符串: 代码结构复杂,很难维护,代码提示也不佳
3 使用JXON: 代码提示不佳
3 如何避免生命周期的坑?
当聊 生命周期 时, 一定是聊类组件的生命周期,因为函数组件暂无生命周期的说法。
流程梳理
1 挂载阶段
1 constructor 是类通用的构造函数,常用于初始化。
- constructor 不再推荐的原因
- constructor 中并不推荐去处理初始化以外的逻辑
- constructor 不属于 React 的生命周期, 只是 Class 的初始化函数
- 通过移除 construtor, 代码也会变得更加简洁
2 getDerivedStateFromProps 是组件在 props 变化时更新 state 触发的时机有三个:
- 1 props 传入时
- 2 state发生变化
- 3 forceUpdate 被调用时 ⚠️: 1 如果不需要更新 state 时, 返回 null 即可。 2 直接复制 props 到 state (强烈不推荐,违背单一数据源,且容易覆盖当前 state 的值) 3 在 props 变化后修改 state (强烈不推荐, 可能会导致视图不渲染)
- github.com/tcatche/tca...
- blog.csdn.net/lm134201098...
3 UNSAFE_componentWillMount 组件挂载前调用,后面被标记为弃用,因为在 React 异步渲染机制下,该方法可能被调用多次。
4 render 返回 JSX 结构, 用来描述渲染的内容 这里要注意是纯函数,不能进行 setState 或者绑定事件。 因为 render 函数每次渲染都会执行。
5 componentDidMount 挂载函数,主要用于组件挂载完成时调用。
2 更新阶段
指的是 props 传入或 state 发生变化时的阶段。
1 UNSAFE_componentWillReceiveProps (性能问题被弃用) 与 getDerivedStateFromProps 函数互斥
2 shouldComponentUpdate 👉 true-渲染
3 UNSAFE_componentWillUpdate 标记弃用,因为后续 React 异步渲染中可能会暂停、中断渲染
4 Render
5 getSnapshotBeforeUpdate 返回的参数会作为 componentDidUpdate 的第三个参数
3 卸载阶段
componentWillUnmount 主要用于清理工作
4 渲染错误边界
实现一个 ErrorBoundary, 通过 componentDidCatch 捕获、通过 getDerivedStateFromError 更新 state 渲染降级后的 UI。
总结
避免生命周期的坑需要做好 2 件事
- 不在恰当的时机调用不该调用的代码
- 需要调用的时机没有调用代码 1 getDerivedStateFromProps 容易覆盖 state 的值、或导致不更新 2 componentWillMonut 在 React 异步渲染中可能导致重复执行,目前已经被标记弃用。 3 componentWillReceiveProps 有性能问题,被 getDerivedStateFromProps 取代, 也会因为 React 异步渲染重复执行。 4 componentWillUpdate 也因为 React 的异步渲染被标记弃用。 5 shouldComponentUpdate 常用于性能优化,也会因为 React 异步渲染重复执行。 6 componentWillUnmount 无清除定时器、事件工作 7 如果没有进行渲染的错误捕获,当渲染错误时,可能会导致白屏
React 请求一般会放在 ComponentDidMount 中, 因为 componentWillMount 被标记废除,而 contructor 常用于初始化,当随着类属性的流向, 也将慢慢被类属性取代。
4 类组件和函数组件的区别
相同点
从表达效果来看,类组件与函数组件表达效果一致,都能显示出 React 视图组件效果。
不同点
1 从心智模型上看,类组件偏向于 OOP 面向对象模型,主打继承、生命周期等概念 函数组件偏向于 FP 函数式编程,主打组合 2 性能优化上,类组件主要使用 shouldComponentUpdate 控制组件的更新,而函数组件主要通过 React.memo 控制组件更新。 3 从上手组件上看,函数组件更加简单 4 从未来趋势上看,由于类组件 this的模糊性、业务逻辑散落在生命周期中、类组件缺少标准的业务逻辑拆分方式。 再加上官方推荐 组合优于继承 的思想,因此推荐使用函数组件。
5 如何设计 React 组件
从设计分类上看,可以划分为两种,一种是展示组件 ,即没有涉及业务逻辑,只做 UI 展示。 另一种灵巧组件,这类组件主要用于复用业务逻辑,往往包含了业务相关的状态等。
展示组件
展示组件受制于外部的 props 控制,具有极高的通用性和复用性
代理组件
目的是减少重复的代码
代理组件之属性组件
常用于封装常用的属性
如果想要批量的修改基础组件的属性,那么代理组件的设计是一个不错的选择
顺带一提: 代理组件的设计还蛮符合依赖倒置原则的,即原先业务组件的实现直接依赖了基础组件(高层次的类直接依赖于低层次的类),一但基础组件修改了,那么业务组件也需要修改。但如果我们引入了代理组件,由代理组件来实现底层props的接口,而基础组件将通过代理组件约定好的接口进而实现。这样的好处在于如果基础组件调整了,我们也无需对业务组件进行调整。(由之前高层次的业务组件依赖于基础组件,转变成基础组件依赖于它上一层的代理组件,将之前高依赖于低的关系给打破了)
依赖倒置原则: 高层次的类不应该依赖于低层次的类,两者都应该依赖于抽象接口,而抽象接口不依赖于具体实现,具体实现则依赖于抽象接口。
样式组件
常用于封装样式属性
布局组件
常用于封装布局
如果布局确定不变,可以通过 shouldComponentUpdate/ React.memo 设置不更新
灵巧组件
灵巧组件面向业务,功能丰富,复杂度更高,复用性更低。
容器组件
在业务逻辑开发中,往往会把网络请求放到容器组件内部做处理,容器组件也会组件其他组件预留了恰当的空间
高阶组件
React 中复用组件逻辑的高级技术,是基于 React 组合特性形成的设计模式。
高级组件的参数是组件,返回值也是新的组件函数。
比如登录态的判断、公共业务逻辑复用、埋点、渲染劫持
缺陷:
- 丢失组件的静态函数
- refs 属性不能透传
目录划分
总结
6 setState 是同步还是异步?
异步场景
从上到下 console.log 输出 1, 0
从上到下 console.log 输出 1, 1
从上到下 console.log 输出 1, 2
为什么 setState 是异步的?
- 为了保持内部一致性(state、props 都是异步的)
- 为后续架构启用并发更新 - 根据优先级更新
- 性能优化
同步场景
从上到下 console.log 输出 0, 1
setState 并不是单纯的异步函数,它是通过队列延迟实现的,如上图,setState 是同步还是异步取决于 是否处于 batch Update 阶段,是的话则异步,否则则同步。
在 React 的生命周期事件和合成事件中,可拿到 isBatchingUpdates 控制权,将状态放入队列中,控制执行节奏。因此在原生事件、setTimeout、setInterval 中属于同步更新的场景。
7 如何面向组件跨层级通信
对于此类题型,应该通过一个主题多个场景的思路去解答。 主题: 通信 跨层级通信的场景有以下四种:
-
- 父组件与子组件通信 - props
-
- 子组件与父组件通信 - 回调函数
-
- 兄弟组件之间的通信 - 通过父组件中转
-
- 无直接关系的组件之间的通信 - context、订阅发布、状态管理框架
以下是 context 的例子
1 通过 React.createContext 创建一个 ThemeContext 变量
2 通过 ThemeContext.Provider 提供变量
3 通过 useContext 消费 或者通过 ThemeContext.Consumer 消费
8 列举一种你了解的 React 状态管理框架
- Redux
- Mobx
Redux 的整体流向如下
Mobx 类似于 Vue的监听,早期使用 Object.definedProperity 进行监听,后期改成了 proxy
9 解释 React 的渲染流程
9.1 React 16 以前
因为 javascript 线程与渲染线程是互斥的,如果 javascript 线程长期占用着浏览器的主线程,那么界面将长时间不更新,在动画等一些场景下可能给用户带来 "卡顿" 的效果。
因为 stack Reconciler 是一个同步的递归过程,随着业务的不断复杂,stack Reconciler 需要的调和时间会比较长,这就意味着 javascript 将长时间占用浏览器主线程,进而导致页面卡顿。
9.2 React 16 以后
什么是 Fiber
从计算机领域来看,Fiber 是比线程还要纤细的一个过程,也就是所谓的 "纤程" 从架构角度上来看,Fiber 是对 React 核心算法的重写,将 React16以前同步执行的 stack Reconciler, 重写成异步可中断的 Fiber Reconciler 从编码角度上来看,Fiber 是 React 内部所定义的一种数据结构,每一个节点都是一个 FiberNode,保存了组件的类型、对应的 DOM 节点等信息 从工作流的角度上来看,Fiber 节点保存了组件需要更新的状态和副作用
Fiber 架构的应用目的是为了实现任务的可中断、可恢复,并赋予任务优先级,最终达成更加顺滑的用户体验。
在更新时,每个更新任务都会被赋予一个优先级,当任务抵达调度器时,高优先级的任务会更快地抵达协调器,若有新的更高优先级的任务进入调度器时,那么当前处于协调器的任务就会被中断,更高优先级任务将进入 reconciler。
新的架构会导致部分生命周期重复执行,例如:
- conponentWillMount
- componentWillUpdate
- shouldComponentUpdate
- componentWillReceiveProps
Mount 阶段
在执行 ReactDOM.render 时去挂载组件, 会调用创建 reactRootContainer 对象,并赋值给 root,创建 fiberRoot,最后调用 unbatchedUpdates 方法
从下图可以看出 fiberRoot 对象 (FiberRootNode 实例) 的 current 属性指向了 rootFiber 对象 (FiberNode实例)
在 legacyRenderSubtreeIntoContainer 函数中最终返回了将执行 unbatchedUpdates, 在 unbatchedUpdates 中最终将执行 updateContainer 函数。
updateContainer 的函数主要有以下3件事:
- 获取当前节点的优先级 lane
- 结合 lane 创建当前 Fiber 节点的 update 对象,并将其入队
- 调度当前节点
进入 scheduleUpdateOnFiber 函数中,render的首次渲染执行的是 performSyncWorkOnRoot 方法,这个方法属于同步的(ReactDOM.reader下的 legacy 模式)。
当我们使用 ReactDOM.createRoot 时属于异步渲染模式。
这里可能有小伙伴会问,Fiber架构不就是异步渲染的么? 我想说的是,Fiber架构的设计初衷确实是为了异步渲染而设计的,但是 Fiber 架构并不能和异步渲染画上等号,我们不难发现,Fiber 架构同时兼容了同步渲染和异步渲染,如下图,决定同步还是异步取决于 mode
render 阶段
performSyncWorkOnRoot 是 render 阶段的起点,在函数内部会调用 prepareFreshStack 方法用于重置调用栈,其中 createWorkInProgress 方法用于创建 workInProgress 树,但最核心的函数还是 renderRootSync
由上图可知,workInProgress 是由 createFiber 函数返回得到的,由下图可知,createFiber其实就是返回了一个 fiber 节点。
通过上面 2 张图所示,我们不难发现,workInProgress 节点其实就是 current 节点的副本。
执行完 createWorkInProgress 后,我们可以得到以下关系图:
当我们构建完 workInProgress Tree 的根节点时,建立 current tree 和 workInProgess Tree 的关联关系后,将进入 workLoopSync 调和阶段。
workLoopSync 函数反复判断 workInProgress 是否为空,如果不为空,就执行 performUnitOfWork 方法。 performUnitOfWork 方法将触发对 beginWork 的调用,进而实现对新的 Fiber 节点的创建(这里的创建就会用到 diff 算法进行复用)。
js
顺便一提: 为什么需要有俩颗 Fiber 树?
因为在可中断的更新机制中,如果只有一棵树,可能导致中断过程中,页面显示不完整,例如页面可能一半是新的,一半是旧的。
因此通过"双缓冲"模式可以让画面的更新更加连贯、并且能最大限度地实现 Fiber 节点的复用,减少性能开销。
当节点遍历完成后,判断是否还有子节点,如果没有子节点将进入 completeWork节点,completeWork 主要处理 Fiber 节点到 DOM 节点的映射逻辑
关键动作
- 创建 DOM 节点
- 将 DOM 节点插入到其父节点对应的DOM节点里
- 为 DOM 节点更新属性
对应的 Fiber 节点可以通过 stateNode 属性映射创建好的 DOM 节点
而调用方 completeUnitOfWork 的工作内容主要是开启大循环
主要动作
- 针对传入的当前节点,调用 completeWork
- 将当前节点的副作用链插入到其父节点对应的副作用链中
- 以当前节点为起点,循环遍历其兄弟节点(直接return等待下一次循环, 因为这时候 beginWork还没有执行到)及其父节点(进入下一个循环)
注意⚠️: 确认没有待处理的兄弟节点后,才转而处理父节点。
commit 阶段 (绝对同步的过程)
commit 可以分成三个阶段
- before mutation 阶段: 这个阶段 DOM 节点还没有被渲染到界面上
- mutation 阶段: 渲染 DOM 节点
- layout 阶段: DOM 渲染完毕后的收尾逻辑
整体流程
js
1 ReactDOM.render 创建 FiberRootNode 和 FiberNode 对象(也就是常说的 rootFiber)
2 执行 performSyncWorkOnRoot 进入 render 阶段
3 进入 renderRootSync,调用 createWorkInProgress 创建 workInProgress 树 (构建双缓存树)
4 执行 workLoopSync,循环判断 workInProgress 节点是否存在,执行 performUnitOfWork
5 进入 beginwork 创建子节点
6 通过 reconciler 复用 fiber 节点
7 进入 completeWork 阶段 建立Fiber和元素节点的对应关系、以及收集副作用链的父子关系
8 到 commit 阶段(同步阶段),根据副作用链,更新DOM。
10 diff 算法
React 的 diff 算法中可以分成单节点 diff 算法和多节点 diff 算法
单节点 diff
单节点 diff 算法指的是新生成的 ReactElement 只有一个节点。
主要做的事情如下:
- key 和 type 相同,直接复用旧的 fiber 节点,其他 Fiber 节点删除
- 如果 key 相同, type 不同,则不再比较了,直接删除所有节点
- 如果 key 不相同,旧节点标记为删除,继续比较后面的 Fiber
多节点 diff
多节点 diff 算法指的是新生成的 ReactElement 节点不止有一个。 多节点 diff 有双层 for 循环, 第一层 for 循环判断元素是否需要更新,第二层 for 循环判断元素是否需要移动位置
主要做的事情如下:
- 第一层 for 循环比较key和tag, 如果相同则复用, 并用 lastPlacedIndex 标记当前可以复用的节点位置
- 遇到不相同时, 跳出第一层 for 循环, 创建一个 Map 对象, 存储旧节点的 key 和 index
- 在 Map 对象中查找新节点的 key, 如果存在, 则说明新节点可以复用旧节点, 并且判断是否需要移动位置
- 如果 index > lastPlacedIndex, 则不需要移动位置, 更新 lastPlacedIndex
- 如果 index < lastPlacedIndex, 则需要移动位置, 不需要更新 lastIndex
11 React 事件和 DOM 事件有何不同
1 React 合成事件在底层抹平各个浏览器的差异,上层暴露统一的事件进行维护。 2 可以通过 nativeEvent 属性获取到合成事件对应的原生 DOM 事件
React的事件系统可以分成事件绑定和事件触发
事件绑定
而事件绑定是在 completeWork 中完成的。 completeWork 内部关键的三大步骤:
- 创建 DOM 节点
- 将 DOM 节点插入到 DOM 树中
- 为 DOM 节点设置属性
事件触发
对于事件的触发,在 React 中是将所有的事件全部绑定在根节点中,如下图所示
当事件触发时,创建事件的优先级,收集 Fiber 上的相关事件通过冒泡机制到根节点,进而执行。
具体细节可以参考之前写的文章:【React系列】合成事件
React16 时,合成事件放在了 document 的冒泡上,在 document 的冒泡事件之前上进行合成事件的捕获和冒泡事件的触发。
React17 时,为了兼容多个 React 版本将合成事件的捕获放在了根节点的捕获上,合成事件的冒泡放在了根节点的冒泡事件上。
React16: document 捕获 -> 原生事件 -> 合成事件 -> document 冒泡 (原因是事件委托在 document 的冒泡事件上)
React17: document 捕获 -> 合成捕获 -> 原生捕获 -> 原生冒泡 -> 合成冒泡 -> document 冒泡 (原因是合成事件的委托在 root 容器的捕获和冒泡事件上了测试链接
总结
不知不觉使用 React 一年多了,再也不能以 "自己没怎么用过 React" 作为借口为自己的错误买单了,在这个充满竞争的城市,只能不断地充实自己。
如果本文对你有一点点帮助,点个赞支持一下吧,你的每一个【赞
】都是我创作的最大动力 ^_^
同时欢迎大家的建议,希望您在指出的时候,可以指正地清晰一点,这样方便我进行思考和更改,为大家产出更好的文章~
希望本文对你有所帮助!!!