【React 的理解】
谈一谈你对 React 的理解
对待这类概念题,讲究一个四字口诀“概用思优”,即“讲概念,说用途,理思路,优缺点,列一遍” 。
React 是一个网页 UI 框架,通过组件化的方式解决视图层开发复用的问题,本质是一个组件化框架。
它的核心设计思路有三点,分别是声明式、组件化与 通用性。
- 声明式的优势在于直观与组合。
- 组件化的优势在于视图的拆分与模块复用,可以更容易做到高内聚低耦合。
- 通用性在于一次学习,随处编写。比如 React Native,React 360 等, 这里主要靠虚拟 DOM 来保证实现。这使得 React 的适用范围变得足够广,无论是 Web、Native、VR,甚至 Shell 应用都可以进行开发。这也是 React 的优势。
- 但作为一个视图层的框架,React 的劣势也十分明显。它并没有提供完整的一揽子解决方案,在开发大型前端应用时,需要向社区寻找并整合解决方案。虽然一定程度上促进了社区的繁荣,但也为开发者在技术选型和学习适用上造成了一定的成本。
为什么 React 要用 JSX
为什么采用该技术方案”这一类问题其实是考察你的技术广度和技术方案能力,使用“一句话解释,核心概念,方案对比”的解题思路
- 在回答问题之前,我首先解释下什么是 JSX 吧。JSX 是一个 JavaScript 的语法扩展,结构类似 XML。
- 所以从这里可以看出,React 团队并不想引入 JavaScript 本身以外的开发体系。而是希望通过合理的关注点分离保持组件开发的纯粹性。
接下来与 JSX 以外的三种技术方案进行对比。
- 首先是模板,React 团队认为模板不应该是开发过程中的关注点,因为引入了模板语法、模板指令等概念,是一种不佳的实现方案。
- 其次是模板字符串,模板字符串编写的结构会造成多次内部嵌套,使整个结构变得复杂,并且优化代码提示也会变得困难重重。
- 最后是 JXON,同样因为代码提示困难的原因而被放弃。
所以 React 最后选用了 JSX,因为 JSX 与其设计思想贴合,不需要引入过多新的概念,对编辑器的代码提示也极为友好。
JSX 主要用于声明 React 元素,但 React 中并不强制使用 JSX。即使使用了 JSX,也会在构建过程中,通过 Babel 插件编译为 React.createElement。所以 JSX 更像是 React.createElement 的一种语法糖。
Babel 插件如何实现 JSX 到 JS 的编译?
它的实现原理是这样的。Babel 读取代码并解析,生成 AST,再将 AST 传入插件层进行转换,在转换时就可以将 JSX 的结构转换为 React.createElement 的函数。
如何避免生命周期中的坑?
“如何避免坑?”换种思维思考也就是“为什么会有坑?”在代码编写中,遇到的坑往往会有两种: 在不恰当的时机调用了不合适的代码; 在需要调用时,却忘记了调用。
通过梳理生命周期,明确周期函数职责,确认什么时候该做什么事儿,以此来避免坑。
当我们在讨论 React 组件生命周期的时候,一定是在讨论类组件(Class Component)
类组件与函数组件有什么区别呢?
作为组件而言,类组件与函数组件在使用与呈现上没有任何不同,性能上在现代浏览器中也不会有明显差异。
它们在开发时的心智模型上却存在巨大的差异。类组件是基于面向对象编程的,它主打的是继承、生命周期等核心概念;而函数组件内核是函数式编程,主打的是 immutable、没有副作用、引用透明等特点。
之前,在使用场景上,如果存在需要使用生命周期的组件,那么主推类组件;设计模式上,如果需要使用继承,那么主推类组件。
但现在由于 React Hooks 的推出,生命周期概念的淡出,函数组件可以完全取代类组件。
其次继承并不是组件最佳的设计模式,官方更推崇“组合优于继承”的设计概念,所以类组件在这方面的优势也在淡出。
性能优化上,类组件主要依靠 shouldComponentUpdate 阻断渲染来提升性能,而函数组件依靠React.memo 缓存渲染结果来提升性能。
从上手程度而言,类组件更容易上手,从未来趋势上看,由于React Hooks 的推出,函数组件成了社区未来主推的方案。
类组件在未来时间切片与并发模式中,由于生命周期带来的复杂度,并不易于优化。而函数组件本身轻量简单,且在 Hooks 的基础上提供了比原先更细粒度的逻辑组织与复用,更能适应 React 的未来发展。
如何设计 React 组件?
React 组件应从设计与工程实践两个方向进行探讨。
从设计上而言,社区主流分类的方案是展示组件与灵巧组件。
展示组件内部没有状态管理,仅仅用于最简单的展示表达。展示组件中最基础的一类组件称作代理组件。代理组件常用于封装常用属性、减少重复代码。很经典的场景就是引入 Antd 的 Button 时,你再自己封一层。如果未来需要替换掉 Antd 或者需要在所有的 Button 上添加一个属性,都会非常方便。基于代理组件的思想还可以继续分类,分为样式组件与布局组件两种,分别是将样式与布局内聚在自己组件内部。
灵巧组件由于面向业务,其功能更为丰富,复杂性更高,复用度低于展示组件。最经典的灵巧组件是容器组件。在开发中,我们经常会将网络请求与事件处理放在容器组件中进行。容器组件也为组合其他组件预留了一个恰当的空间。还有一类灵巧组件是高阶组件。高阶组件被 React 官方称为 React 中复用组件逻辑的高级技术,它常用于抽取公共业务逻辑或者提供某些公用能力。常用的场景包括检查登录态,或者为埋点提供封装,减少样板代码量。高阶组件可以组合完成链式调用,如果基于装饰器使用,就更为方便了。高阶组件中还有一个经典用法就是反向劫持,通过重写渲染函数的方式实现某些功能,比如场景的页面加载圈等。但高阶组件也有两个缺陷,第一个是静态方法不能被外部直接调用,需要通过向上层组件复制的方式调用,社区有提供解决方案,使用 hoist-non-react-statics 可以解决;第二个是 refs 不能透传,使用 React.forwardRef API 可以解决。
从工程实践而言,通过文件夹划分的方式切分代码。我初步常用的分割方式是将页面单独建立一个目录,将复用性略高的 components 建立一个目录,在下面分别建立 basic、container 和 hoc 三类。这样可以保证无法复用的业务逻辑代码尽量留在 Page 中,而可以抽象复用的部分放入 components 中。其中 basic 文件夹放展示组件,由于展示组件本身与业务关联性较低,所以可以使用 Storybook 进行组件的开发管理,提升项目的工程化管理能力。
setState 是同步更新还是异步更新?
setState 并非真异步,只是看上去像异步。在源码中,通过 isBatchingUpdates 来判断
setState 是先存进 state 队列还是直接更新,如果值为 true 则执行异步操作,为 false 则直接更新。
那么什么情况下 isBatchingUpdates 会为 true 呢?在 React 可以控制的地方,就为 true,比如在 React 生命周期事件和合成事件中,都会走合并操作,延迟更新的策略。
但在 React 无法控制的地方,比如原生事件,具体就是在 addEventListener 、setTimeout、setInterval 等事件中,就只能同步更新。
一般认为,做异步设计是为了性能优化、减少渲染次数,React 团队还补充了两点。
- 保持内部一致性。如果将 state 改为同步更新,那尽管 state 的更新是同步的,但是 props不是。
- 启用并发更新,完成异步渲染。
如何面向组件跨层级通信?
在跨层级通信中,主要分为一层或多层的情况。
如果只有一层,那么按照 React 的树形结构进行分类的话,主要有以下三种情况:父组件向子组件通信,子组件向父组件通信以及平级的兄弟组件间互相通信。
- 在父与子的情况下,因为 React 的设计实际上就是传递 Props 即可。那么场景体现在容器组件与展示组件之间,通过 Props 传递 state,让展示组件受控。
- 在子与父的情况下,有两种方式,分别是回调函数与实例函数。回调函数,比如输入框向父级组件返回输入内容,按钮向父级组件传递点击事件等。实例函数的情况有些特别,主要是在父组件中通过 React 的 ref API 获取子组件的实例,然后是通过实例调用子组件的实例函数。这种方式在过去常见于 Modal 框的显示与隐藏。这样的代码风格有着明显的 jQuery 时代特征,在现在的 React 社区中已经很少见了,因为流行的做法是希望组件的所有能力都可以通过 Props 控制。
多层级间的数据通信,有两种情况。第一种是一个容器中包含了多层子组件,需要最底部的子组件与顶部组件进行通信。在这种情况下,如果不断透传 Props 或回调函数,不仅代码层级太深,后续也很不好维护。第二种是两个组件不相关,在整个 React 的组件树的两侧,完全不相交。那么基于多层级间的通信一般有三个方案。
- 第一个是使用 React 的 Context API,最常见的用途是做语言包国际化。
- 第二个是使用全局变量与事件。全局变量通过在 Windows 上挂载新对象的方式实现,这种方式一般用于临时存储值,这种值用于计算或者上报,缺点是渲染显示时容易引发错误。全局事件就是使用 document 的自定义事件,因为绑定事件的操作一般会放在组件的 componentDidMount 中,所以一般要求两个组件都已经在页面中加载显示,这就导致了一定的时序依赖。如果加载时机存在差异,那么很有可能导致两者都没能对应响应事件。
- 第三个是使用状态管理框架,比如 Flux、Redux 及 Mobx。优点是由于引入了状态管理,使得项目的开发模式与代码结构得以约束,缺点是学习成本相对较高。
列举一种你了解的 React 状态管理框架
首先介绍 Flux,Flux 是一种使用单向数据流的形式来组合 React 组件的应用架构。
Flux 包含了 4 个部分,分别是 Dispatcher、 Store、View、Action。Store 存储了视图层所有的数据,当 Store 变化后会引起 View 层的更新。如果在视图层触发一个 Action,就会使当前的页面数据值发生变化。Action 会被 Dispatcher 进行统一的收发处理,传递给 Store 层,Store 层已经注册过相关 Action 的处理逻辑,处理对应的内部状态变化后,触发 View 层更新。
Flux 的优点是单向数据流,解决了 MVC 中数据流向不清的问题,使开发者可以快速了解应用行为。从项目结构上简化了视图层设计,明确了分工,数据与业务逻辑也统一存放管理,使在大型架构的项目中更容易管理、维护代码。
其次是 Redux,Redux 本身是一个 JavaScript 状态容器,提供可预测化状态的管理。社区通常认为 Redux 是 Flux 的一个简化设计版本,但它吸收了 Elm 的架构思想,更像一个混合产物。它提供的状态管理,简化了一些高级特性的实现成本,比如撤销、重做、实时编辑、时间旅行、服务端同构等。
Redux 的核心设计包含了三大原则:单一数据源、纯函数 Reducer、State 是只读的。
Redux 中整个数据流的方案与 Flux 大同小异。
Redux 中的另一大核心点是处理“副作用”,AJAX 请求等异步工作,或不是纯函数产生的第三方的交互都被认为是 “副作用”。这就造成在纯函数设计的 Redux 中,处理副作用变成了一件至关重要的事情。社区通常有两种解决方案:
第一类是在 Dispatch 的时候会有一个 middleware 中间件层,拦截分发的 Action 并添加额外的复杂行为,还可以添加副作用。第一类方案的流行框架有 Redux-thunk、Redux-Promise、Redux-Observable、Redux-Saga 等。
第二类是允许 Reducer 层中直接处理副作用,采取该方案的有 React Loop,React Loop 在实现中采用了 Elm 中分形的思想,使代码具备更强的组合能力。
除此以外,社区还提供了更为工程化的方案,比如 rematch 或 dva,提供了更详细的模块架构能力,提供了拓展插件以支持更多功能。
Redux 的优点很多:结果可预测;代码结构严格易维护;模块分离清晰且小函数结构容易编写单元测试;Action 触发的方式,可以在调试器中使用时间回溯,定位问题更简单快捷;单一数据源使服务端同构变得更为容易;社区方案多,生态也更为繁荣。
最后是 Mobx,Mobx 通过监听数据的属性变化,可以直接在数据上更改触发UI 的渲染。在使用上更接近 Vue,比起 Flux 与 Redux 的手动挡的体验,更像开自动挡的汽车。Mobx 的响应式实现原理与 Vue 相同,以 Mobx 5 为分界点,5 以前采用 Object.defineProperty 的方案,5 及以后使用 Proxy 的方案。它的优点是样板代码少、简单粗暴、用户学习快、响应式自动更新数据让开发者的心智负担更低。
Virtual DOM 的工作原理是什么?
虚拟 DOM 的工作原理是通过 JS 对象模拟 DOM 的节点。在 Facebook 构建 React 初期时,考虑到要提升代码抽象能力、避免人为的 DOM 操作、降低代码整体风险等因素,所以引入了虚拟 DOM。
虚拟 DOM 在实现上通常是 Plain Object,以 React 为例,在 render 函数中写的 JSX 会在 Babel 插件的作用下,编译为 React.createElement 执行 JSX 中的属性参数。
React.createElement 执行后会返回一个 Plain Object,它会描述自己的 tag 类型、props 属性以及 children 情况等。这些 Plain Object 通过树形结构组成一棵虚拟 DOM 树。当状态发生变更时,将变更前后的虚拟 DOM 树进行差异比较,这个过程称为 diff,生成的结果称为 patch。计算之后,会渲染 Patch 完成对真实 DOM 的操作。
虚拟 DOM 的优点主要有三点:改善大规模 DOM 操作的性能、规避 XSS 风险、能以较低的成本实现跨平台开发。
虚拟 DOM 的缺点在社区中主要有两点。
内存占用较高,因为需要模拟整个网页的真实 DOM。
高性能应用场景存在难以优化的情况,类似像 Google Earth 一类的高性能前端应用在技术选型上往往不会选择 React。
与其他框架相比,React 的 diff 算法有何不同?
在回答有何不同之前,首先需要说明下什么是 diff 算法。
diff 算法是指生成更新补丁的方式,主要应用于虚拟 DOM 树变化后,更新真实 DOM。所以 diff 算法一定存在这样一个过程:触发更新 → 生成补丁 → 应用补丁。
React 的 diff 算法,触发更新的时机主要在 state 变化与 hooks 调用之后。此时触发虚拟 DOM 树变更遍历,采用了深度优先遍历算法。但传统的遍历方式,效率较低。为了优化效率,使用了分治的方式。将单一节点比对转化为了 3 种类型节点的比对,分别是树、组件及元素,以此提升效率。
树比对:由于网页视图中较少有跨层级节点移动,两株虚拟 DOM 树只对同一层次的节点进行比较。
组件比对:如果组件是同一类型,则进行树比对,如果不是,则直接放入到补丁中。
元素比对:主要发生在同层级中,通过标记节点操作生成补丁,节点操作对应真实的 DOM 剪裁操作。
以上是经典的 React diff 算法内容。自 React 16 起,引入了 Fiber 架构。为了使整个更新过程可随时暂停恢复,节点与树分别采用了 FiberNode 与 FiberTree 进行重构。fiberNode 使用了双链表的结构,可以直接找到兄弟节点与子节点。
整个更新过程由 current 与 workInProgress 两株树双缓冲完成。workInProgress 更新完成后,再通过修改 current 相关指针指向新节点。
然后拿 Vue 和 Preact 与 React 的 diff 算法进行对比。
Preact 的 Diff 算法相较于 React,整体设计思路相似,但最底层的元素采用了真实 DOM 对比操作,也没有采用 Fiber 设计。Vue 的 Diff 算法整体也与 React 相似,同样未实现 Fiber 设计。
然后进行横向比较,React 拥有完整的 Diff 算法策略,且拥有随时中断更新的时间切片能力,在大批量节点更新的极端情况下,拥有更友好的交互体验。
Preact 可以在一些对性能要求不高,仅需要渲染框架的简单场景下应用。
Vue 的整体 diff 策略与 React 对齐,虽然缺乏时间切片能力,但这并不意味着 Vue 的性能更差,因为在 Vue 3 初期引入过,后期因为收益不高移除掉了。除了高帧率动画,在 Vue 中其他的场景几乎都可以使用防抖和节流去提高响应性能。
如何解释 React 的渲染流程?
React 的渲染过程大致一致,但协调并不相同,以 React 16 为分界线,分为 Stack Reconciler 和 Fiber Reconciler。这里的协调从狭义上来讲,特指 React 的 diff 算法,广义上来讲,有时候也指 React 的 reconciler 模块,它通常包含了 diff 算法和一些公共逻辑。
回到 Stack Reconciler 中,Stack Reconciler 的核心调度方式是递归。调度的基本处理单位是事务,它的事务基类是 Transaction,这里的事务是 React 团队从后端开发中加入的概念。在 React 16 以前,挂载主要通过 ReactMount 模块完成,更新通过 ReactUpdate 模块完成,模块之间相互分离,落脚执行点也是事务。
在 React 16 及以后,协调改为了 Fiber Reconciler。它的调度方式主要有两个特点,第一个是协作式多任务模式,在这个模式下,线程会定时放弃自己的运行权利,交还给主线程,通过requestIdleCallback 实现。第二个特点是策略优先级,调度任务通过标记 tag 的方式分优先级执行,比如动画,或者标记为 high 的任务可以优先执行。Fiber Reconciler的基本单位是 Fiber,Fiber 基于过去的 React Element 提供了二次封装,提供了指向父、子、兄弟节点的引用,为 diff 工作的双链表实现提供了基础。
在新的架构下,整个生命周期被划分为 Render 和 Commit 两个阶段。Render 阶段的执行特点是可中断、可停止、无副作用,主要是通过构造 workInProgress 树计算出 diff。以 current 树为基础,将每个 Fiber 作为一个基本单位,自下而上逐个节点检查并构造 workInProgress 树。这个过程不再是递归,而是基于循环来完成。
在执行上通过 requestIdleCallback 来调度执行每组任务,每组中的每个计算任务被称为 work,每个 work 完成后确认是否有优先级更高的 work 需要插入,如果有就让位,没有就继续。优先级通常是标记为动画或者 high 的会先处理。每完成一组后,将调度权交回主线程,直到下一次 requestIdleCallback 调用,再继续构建 workInProgress 树。
在 commit 阶段需要处理 effect 列表,这里的 effect 列表包含了根据 diff 更新 DOM 树、回调生命周期、响应 ref 等。
但一定要注意,这个阶段是同步执行的,不可中断暂停,所以不要在 componentDidMount、componentDidUpdate、componentWiilUnmount 中去执行重度消耗算力的任务。
如果只是一般的应用场景,比如管理后台、H5 展示页等,两者性能差距并不大,但在动画、画布及手势等场景下,Stack Reconciler 的设计会占用占主线程,造成卡顿,而 fiber reconciler 的设计则能带来高性能的表现。
React 的渲染异常会造成什么后果?
React 渲染异常的时候,在没有做任何拦截的情况下,会出现整个页面白屏的现象。它的成型原因是在渲染层出现了 JavaScript 的错误,导致整个应用崩溃。这种错误通常是在 render 中没有控制好空安全,使值取到了空值。
所以在治理上,我的方案是这样的,从预防与兜底两个角度去处理。
在预防策略上,引入空安全相关的方案,在做技术选型时,我主要考虑了三个方案:第一个是引入外部函数,比如 Facebook 的 idx 或者 Lodash.get;第二个是引入 Babel 插件,使用 ES 2020 的标准——可选链操作符;第三个是 TypeScript,它在 3.7 版本以后可以直接使用可选链操作符。最后我选择了引入 Babel 插件的方案,因为这个方案外部依赖少,侵入性小,而且团队内没有 TS 的项目。
在兜底策略上,因为考虑到团队内部和我存在一样的问题,就抽取了兜底的公共高阶组件,封装成了 NPM 包供团队内部使用。
从最终的数据来看,预防与治理方案覆盖了团队内 100% 的 React 项目,头三个月兜底组件统计到了日均 10 次的报警信息,其中有 10% 是公司关键业务。那么经过分析与统计,首先是为关键的 UI 组件添加兜底组件进行拦截,然后就是做内部培训,对易错点的代码进行指导,加强 Code Review。后续到现在,线上只收到过 1 次报警。
如何分析和调优性能瓶颈?
我负责的业务是 CRM 管理后台,用户付费进入操作使用,有一套非常标准的业务流程。在我做完性能优化后,整个付费率一下提升了 17%,效果还可以。
前期管理后台的基础性能数据是没有的,我接手后接入了一套 APM 工具,才有了基础的性能数据。然后我对指标观察了一周多,思考了业务形态,发现其实用户对后台系统的加载速度要求并不高,但对系统的稳定性要求比较高。我也发现静态资源的加载成功率并不高,TP99 的成功率大约在 91%,这是因为静态资源直接从服务器拉取,服务器带宽形成了瓶颈,导致加载失败。我对 Webpack 的构建工作流做了改造,支持发布到 CDN,改造后 TP99 提升到了 99.9%。
如何避免重复渲染?
如何避免重复渲染分为三个步骤:选择优化时机、定位重复渲染的问题、引入解决方案。
优化时机需要根据当前业务标准与页面性能数据分析,来决定是否有必要。如果卡顿的情况在业务要求范围外,那确实没有必要做;如果有需要,那就进入下一步——定位。
定位问题首先需要复现问题,通常采用还原用户使用环境的方式进行复现,然后使用 Performance 与 React Profiler 工具进行分析,对照卡顿点与组件重复渲染次数及耗时排查性能问题。
通常的解决方案是加 PureComponent 或者使用 React.memo 等组件缓存 API,减少重新渲染。但错误的使用方式会使其完全无效,比如在 JSX 的属性中使用箭头函数,或者每次都生成新的对象,那基本就破防了。
针对这样的情况有三个解决方案:
- 缓存,通常使用 reselect 缓存函数执行结果,来避免产生新的对象;
- 不可变数据,使用数据 ImmutableJS 或者 immerjs 转换数据结构;
- 手动控制,自己实现 shouldComponentUpdate 函数,但这类方案一般不推荐,因为容易带来意想不到的 Bug,可以作为保底手段使用。
通过以上的手段就可以避免无效渲染带来的性能问题了
如何提升 React 代码可维护性
如何提升 React 代码的可维护性,究其根本是考虑如何提升 React 项目的可维护性。从软件工程的角度出发,可维护性包含了可分析性、可改变性、稳定性、易测试性与可维护性的依从性,接下来我从这五个方面对相关工作进行梳理。
-
可分析性的目标在于快速定位线上问题,可以从预防与兜底两个维度展开工作,预防主要依靠 Lint 工具与团队内部的 Code Review。Lint 工具重在执行代码规划,力图减少不合规的代码;而 Code Review 的重心在于增强团队内部的透明度,做好业务逻辑层的潜在风险排查。兜底主要是在流水线中加入 sourcemap,能够通过线上报错快速定位源码。
-
可改变性的目标在于使代码易于拓展,业务易于迭代。工作主要从设计模式与架构设计展开。设计模式主要指组件设计模式,通过容器组件与展示组件划分模块边界,隔绝业务逻辑。整体架构设计,采用了 rematch 方案,rematch 中可以设计的 model 概念可以很好地收敛 action、reducer 及副作用,同时支持动态引入 model,保障业务横向拓展的能力。Rematch 的插件机制非常利于做性能优化,这方面后续可以展开聊一下。
-
接下来是稳定性,目标在于避免修改代码引起不必要的线上问题。在这方面,主要通过提升核心业务代码的测试覆盖率来完成。因为业务发展速度快、UI 变化大,所以基于 UI 的测试整体很不划算,但背后沉淀的业务逻辑,比如购物车计算价格等需要长期复用,不时修改,那么就得加测试。举个个人案例,在我自己的项目中,核心业务测试覆盖率核算是 91%,虽然没完全覆盖,但基本解决了团队内部恐惧线上出错的心理障碍。
-
然后是易测试性,目标在于发现代码中的潜在问题。在我个人负责的项目中,采用了 Rematch 的架构完成模块分离,整体业务逻辑挪到了 model 中,且 model 自身是一个 Pure Object,附加了多个纯函数。纯函数只要管理好输入与输出,在测试上就很容易。
-
最后是可维护性的依从性,目标在于建立团队规范,遵循代码约定,提升代码可读性。这方面的工作就是引入工具,减少人为犯错的概率。其中主要有检查 JavaScript 的 ESLint,检查样式的 stylelint,检查提交内容的 commitlint,配置编辑器的 editorconfig,配置样式的 prettier。总体而言,工具的效果优于文档,团队内的项目整体可保持一致的风格,阅读代码时的切入成本相对较低。
React Hook 的使用限制有哪些?
React Hooks 的限制主要有两条:
-
不要在循环、条件或嵌套函数中调用 Hook;
-
在 React 的函数组件中调用 Hook。
那为什么会有这样的限制呢?就得从 Hooks 的设计说起。Hooks 的设计初衷是为了改进 React 组件的开发模式。在旧有的开发模式下遇到了三个问题。
-
组件之间难以复用状态逻辑。过去常见的解决方案是高阶组件、render props 及状态管理框架。
-
复杂的组件变得难以理解。生命周期函数与业务逻辑耦合太深,导致关联部分难以拆分。
-
人和机器都很容易混淆类。常见的有 this 的问题,但在 React 团队中还有类难以优化的问题,他们希望在编译优化层面做出一些改进。
这三个问题在一定程度上阻碍了 React 的后续发展,所以为了解决这三个问题,Hooks 基于函数组件开始设计。然而第三个问题决定了 Hooks 只支持函数组件。
那为什么不要在循环、条件或嵌套函数中调用 Hook 呢?因为 Hooks 的设计是基于数组实现。在调用时按顺序加入数组中,如果使用循环、条件或嵌套函数很有可能导致数组取值错位,执行错误的 Hook。当然,实质上 React 的源码里不是数组,是链表。
这些限制会在编码上造成一定程度的心智负担,新手可能会写错,为了避免这样的情况,可以引入 ESLint 的 Hooks 检查插件进行预防。
useEffect 与 useLayoutEffect 区别在哪里?
useEffect 与 useLayoutEffect 的区别在哪里?这个问题可以分为两部分来回答,共同点与不同点。
它们的共同点很简单,底层的函数签名是完全一致的,都是调用的 mountEffectImpl,在使用上也没什么差异,基本可以直接替换,也都是用于处理副作用。
那不同点就很大了,useEffect 在 React 的渲染过程中是被异步调用的,用于绝大多数场景,而 LayoutEffect 会在所有的 DOM 变更之后同步调用,主要用于处理 DOM 操作、调整样式、避免页面闪烁等问题。也正因为是同步处理,所以需要避免在 LayoutEffect 做计算量较大的耗时任务从而造成阻塞。
在未来的趋势上,两个 API 是会长期共存的,暂时没有删减合并的计划,需要开发者根据场景去自行选择。React 团队的建议非常实用,如果实在分不清,先用 useEffect,一般问题不大;如果页面有异常,再直接替换为 useLayoutEffect 即可。
谈谈 React Hook 的设计模式
React Hooks 并没有权威的设计模式,很多工作还在建设中,在这里我谈一下自己的一些看法。
首先用 Hooks 开发需要抛弃生命周期的思考模式,以 effects 的角度重新思考。过去类组件的开发模式中,在 componentDidMount 中放置一个监听事件,还需要考虑在 componentWillUnmount 中取消监听,甚至可能由于部分值变化,还需要在其他生命周期函数中对监听事件做特殊处理。在 Hooks 的设计思路中,可以将这一系列监听与取消监听放置在一个 useEffect 中,useEffect 可以不关心组件的生命周期,只需要关心外部依赖的变化即可,对于开发心智而言是极大的减负。这是 Hooks 的设计根本。
在这样一个认知基础上,我总结了一些在团队内部开发实践的心得,做成了开发规范进行推广。
第一点就是 React.useMemo 取代 React.memo,因为 React.memo 并不能控制组件内部共享状态的变化,而 React.useMemo 更适合于 Hooks 的场景。
第二点就是常量,在类组件中,我们很习惯将常量写在类中,但在组件函数中,这意味着每次渲染都会重新声明常量,这是完全无意义的操作。其次就是组件内的函数每次会被重新创建,如果这个函数需要使用函数组件内部的变量,那么可以用 useCallback 包裹下这个函数。
第三点就是 useEffect 的第二个参数容易被错误使用。很多同学习惯在第二个参数放置引用类型的变量,通常的情况下,引用类型的变量很容易被篡改,难以判断开发者的真实意图,所以更推荐使用值类型的变量。当然有个小技巧是 JSON 序列化引用类型的变量,也就是通过 JSON.stringify 将引用类型变量转换为字符串来解决。但不推荐这个操作方式,比较消耗性能。
这是开发实践上的一些操作。那么就设计模式而言,还需要顾及 Hooks 的组合问题。在这里,我的实践经验是采用外观模式,将业务逻辑封装到各自的自定义 Hook 中。比如用户信息等操作,就把获取用户、增加用户、删除用户等操作封装到一个 Hook 中。而组件内部是抽空的,不放任何具体的业务逻辑,它只需要去调用单个自定义 Hook 暴露的接口就行了,这样也非常利于测试关键路径下的业务逻辑。
以上就是我在设计上的一些思考。
React-Router 的实现原理及工作方式分别是什么?
React Router 路由的基础实现原理分为两种,如果是切换 Hash 的方式,那么依靠浏览器 Hash 变化即可;如果是切换网址中的 Path,就要用到 HTML5 History API 中的 pushState、replaceState 等。在使用这个方式时,还需要在服务端完成 historyApiFallback 配置。
在 React Router 内部主要依靠 history 库完成,这是由 React Router 自己封装的库,为了实现跨平台运行的特性,内部提供两套基础 history,一套是直接使用浏览器的 History API,用于支持 react-router-dom;另一套是基于内存实现的版本,这是自己做的一个数组,用于支持 react-router-native。
React Router 的工作方式可以分为设计模式与关键模块两个部分。从设计模式的角度出发,在架构上通过 Monorepo 进行库的管理。Monorepo 具有团队间透明、迭代便利的优点。其次在整体的数据通信上使用了 Context API 完成上下文传递。
在关键模块上,主要分为三类组件:第一类是 Context 容器,比如 Router 与 MemoryRouter;第二类是消费者组件,用以匹配路由,主要有 Route、Redirect、Switch 等;第三类是与平台关联的功能组件,比如 Link、NavLink、DeepLinking 等。
React 中你常用的工具库有哪些?
常用的工具库都融入了前端开发工作流中,所以接下来我以初始化、开发、构建、检查及发布的顺序进行描述。
首先是初始化。初始化工程项目一般用官方维护的 create-react-app,这个工具使用起来简单便捷,但 create-react-app 的配置隐藏比较深,修改配置时搭配 react-app-rewired 更为合适。国内的话通常还会用 dva 或者 umi 初始化项目,它们提供了一站式解决方案。dva 更关心数据流领域的问题,而 umi 更关心前端工程化。其次是初始化库,一般会用到 create-react-library,也基本是零配置开始开发,底层用 rollup 进行构建。如果是维护大规模组件的话,通常会使用 StoryBook,它的交互式开发体验可以降低组件库的维护成本。
再者是开发,开发通常会有路由、样式、基础组件、功能组件、状态管理等五个方面需要处理。路由方面使用 React Router 解决,它底层封装了 HTML5 的 history API 实现前端路由,也支持内存路由。样式方面主要有两个解决方案,分别是 CSS 模块化和 CSS in JS。CSS 模块化主要由 css-loader 完成,而 CSS in JS 比较流行的方案有 emotion 和 styled-components。emotion 提供 props 接口消灭内联样式;styled-components 通过模板字符串提供基础的样式组件。基础组件库方面,一般管理后台使用 Antd,因为用户基数庞大,稳定性好;面向 C 端的话,主要靠团队内部封装组件。功能组件就比较杂了,比如用于实现拖拽的有 react-dnd 和 react-draggable,react-dnd 相对于 react-draggable,在拖放能力的抽象与封装上做得更好,下层差异屏蔽更完善,更适合做跨平台适配;PDF 预览用过 react-pdf-viewer;视频播放用过 Video-React;长列表用过 react-window 与 react-virtualized,两者的作者是同一个人,react-window 相对于 react-virtualized 体积更小,也被作者推荐。最后是状态管理,主要是 Redux 与 Mobx,这两者的区别就很大了,Redux 主要基于全局单一状态的思路,Mobx 主要是基于响应式的思路,更像 Vue。
然后是构建,构建主要是 webpack、Rollup 与 esBuild。webpack 久经考验,更适合做大型项目的交付;Rollup 常用于打包小型的库,更干净便捷;esBuild 作为新起之秀,性能十分优异,与传统构建器相比,性能最大可以跑出 100 倍的差距,值得长期关注,尤其是与 webpack 结合使用这点,便于优化 webpack 构建性能。
其次是检查。检查主要是代码规范与代码测试编写。代码规范检查一般是 ESLint,再装插件,属于常规操作。编写代码测试会用到 jest、enzyme、react-testing-library、react-hooks-testing-library:jest 是 Facebook 大力推广的测试框架;enzyme 是 Aribnb 大力推广的测试工具库,基本完整包含了大部分测试场景;react-testing-library 与 react-hooks-testing-library 是由社区主推的测试框架,功能上与 enzyme 部分有所重合。
最后是发布,我所管理的工程静态资源主要托管在 CDN 上,所以需要在 webpack 中引入上传插件。这里我使用的是 s3-plugin-webpack,主要是识别构建后的静态文件进行上传。