前言

在学习一个语法或框架的时候,总有一些关键的概念或认知,需要牢记于心。
这些概念看起来很简单,寥寥数语,但却往往隐含着优秀的设计理念和实践精髓;
缺少认知,虽然不妨碍正常开发,但在遇到疑难问题或开发提效上,就要多走很多的弯路, 作为一个有追求的开发者,怎甘于停留表面呢?

那么对于 React ,有哪些概念是我们必须知道的呢?今天就让我们来剖析一番。

说明:本文档知识基于React17;
**

一、映入眼帘

首先打开React中文官方文档
image.png
你能看到映入眼帘的是这么几个关键词:
用于构建用户界面的JavaScript库**声明式****组件化****一次学习,随处编写**
名词已不再新鲜、但却同样至关重要。

二、用于构建用户界面的JavaScript库

1. 理解概念

这个很简单,也很好理解:

  • 方便开发者便捷快速地构建用户界面-引入前端框架的真正意义所在;
  • React是采用JavaScript语法编写的函数库,可以引入到项目中直接使用,而无需额外转换或编译;

官方文档博客:「我们为什么要构建 React?

它起源于 FaceBook的内部项目,在2013年5月对外推出并开源;

2. React 版本号管理

React的版本号管理也挺有意思的:
看代码库的历史更新记录,你会发现:从2013年5月React推出时,虽然在生产环境就是稳定且高效的。但在很长一段时间内,React团队却一直以(0.3.0、0.4.0、…、0.14.0) 只升级次版本的方式在更新迭代;一直未推出开发者期待的V1.0版本。

第一个对外发布版本v0.3.0:image.png
直到2016年4月才正式转移到主要semver版本 来维系稳定性的保证;不过却不是V1版本,而是从V0.14.8 直接跳到V15.0.0 ; 之所以这么做的一点,就是React团队想表明:虽然之前发布的都是次版本,但却是完全保证跟主版本一样稳定的

React主要版本图谱:
image.png

对我们开发来说,影响最大的版本是:2019年2月 V16.8.0版本中 React Hook 稳定版本正式发布;

三、一次学习,随处编写

这个主要包含以下几点:

  1. 不用重构现有项目架构,可以随时引入React承载部分UI;
  2. React语法支持多端开发;

1. 不用重构现有项目代码,可以随时引入React构建部分UI

React是渐进式框架,它从一开始就被设计为逐步采用,并且我们可以根据需要选择性地使用 React。它的核心库只关注视图层,便于与第三方库或既有项目整合。

  1. 根据需要选择性地使用 React,只需要把HTML某节点交于React组件,绑定关系即可;「传送门」**
  2. 我们也可以借助常用工具链及支持类库,从零构建全由React接管的复杂的应用;以获得最好的开发体验;传送门

**

2. React语法支持多端开发

React语法不止可以用来开发web页面,还可以结合Node进行服务器渲染,或使用React Native开发原生移动应用;

2.1 那么React是如何做到支持多端语法统一的呢?

要回答这个问题,其实就是理解 React 和 React-Dom 库的关系。
React内部在实现核心功能上把库分为语法API库和渲染库;

  1. React库只是定义React语法的Api封装,它不知道这些特性是如何实现的;
  2. 绝大多数的实现都存在与“渲染器”中。比如react-domreact-dom/serverreact-native都是常见的渲染器;
    渲染器包暴露针对特定平台的API及实现;

React通过这种api定义与实现细节分离的方式,让我们可以专注于React特性带来的开发体验,不用考虑平台的差异, 甚至开发者可以自定义实现特定平台的渲染器来支持更多的平台;

这也是为什么当我们想要使用React新版本语法新特性时,react 和 react-dom都需要被更新的原因。

再延伸探索一下,

2.2 诸如setState等API定义在React包里,通过什么方式触发渲染器去渲染UI?

react-dom渲染器中会将诸如setState之类的功能的实现“注入”到通用的React包中,其实就是依赖注入的具体应用,使组件更具声明性。
代码运行时,渲染器会在包含的组件上设置一个特殊的对象(updater),setState的内部实现上会调用这个updater对象上的方法来回应渲染器,让React DOM安排并处理更新;

React库 setState定义:

  1. // react库 部分源码
  2. Component.prototype.setState = function(partialState, callback) {
  3. // ...
  4. // 调用被注入的updater对象上的enqueueSetState方法,响应渲染器
  5. this.updater.enqueueSetState(this, partialState, callback, 'setState');
  6. };

React-Dom 源码中对setState处理:

  1. // React Dom 简化代码
  2. let inst; // 当前上下文
  3. let queue = []; // 临时存储队列
  4. const updater = {
  5. ...
  6. // updater对象上 enqueueSetState 的实现:
  7. enqueueSetState: function(publicInstance, currentPartialState) {
  8. if (queue === null) {
  9. warnNoop(publicInstance, 'setState');
  10. return null;
  11. }
  12. queue.push(currentPartialState);
  13. },
  14. };
  15. ...
  16. // isClass 是否class组件
  17. // if (isClass) {
  18. // inst = new Component(element.props, publicContext, updater);
  19. // }
  20. ...
  21. inst.updater = updater;
  22. ...

Hooks中则使用了一个“dispatcher”对象,代替了updater字段。当你调用React.useState()、React.useEffect()、 或者其他内置的Hook时,这些调用被转发给了当前的dispatcher。

四、声明式

React 使创建交互式 UI 变得轻而易举。为你应用的每一个状态设计简洁的视图,当数据改变时 React 能有效地更新并正确地渲染组件。 以声明式编写 UI,可以让你的代码更加可靠,且方便调试。

相信很多人对声明式编程这个词 耳朵都听出茧子了。那么到底什么是声明式编程呢?

1. 命令式编程

说到声明式编程,不得不先提命令式编程。
命令式编程描述了如何做,开发者按步骤编写所有的流程代码。例子有常见面条式代码,流程控制等代码。 是一般最先想到写代码的路子-一步步编写程序需要执行的步骤;

2. 声明式编程

  • 声明式编程则关注的是你要做什么,而不是如何做。
  • 它不显示描述并控制步骤,而是通过组装一个个提前声明、封装好的逻辑单元来构成目标整体;
  • 声明式基于底层良好的封装;在上层做了一层漂亮的“外衣”,在运行时,其实也是被处理成赋值、顺序、条件等面向过程的处理。
  • 封装和抽象化可以复用,可以大大简化程序员的工作和协同开发。

根据框架语法 封装逻辑单元,声明组件、组装组件的编程过程 就是声明式编程;
比如:SQL、各种顶层框架;
最大的特点就是只声明我想要什么,而不说具体要怎么做。

3. 函数式编程

“声明性”是函数式编程的一个重要的特点,

  • 函数式编程是声明式编程的一种范式,但不局限于声明式编程,它还有一些其他的特点:高阶函数等。
  • 函数式编程中的函数就是第一等公民,这意味着函数是数据,你可以像保存变量一样在应用程序中保存、检索和传递这些函数。
    • 函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另外一个函数,或者作为别的函数返回值。

在React16.8之前,在React中用到的纯函数组件、高阶组件就是函数式编程的体现;
React16.8+引入React Hooks之后,则正式全面拥抱函数式编程;

4. 那么如何理解React的声明式UI编程?

在未引入React、Vue等声明式UI框架之前的蛮荒时代,开发一个页面,各个数据和交互操作等互相耦合,不好维护;
借助于Jquery和ejs等页面模板虽然做到了功能划分,但仍然没有解决维护困难、数据渲染麻烦、交互复杂等问题;
而声明式UI框架,则可以做到:

  • 代码简洁、项目代码结构清晰;可以实践高内聚、低耦合;
  • 可靠,对应数据状态对应渲染结果;交互、渲染可预测;
  • 抽象化,不直接操作DOM;
  • 方便调试,且利于持续维护迭代;

五、组件化

React中一切都是组件,组件是一等公民,合理应用各种设计模式,拆分模块,开发出高效且易于维护的代码。
善于进行组件化开发,是使用好React框架必修的一个能力。

1. 组件化带来的好处

  • 组件化就是对声明式UI编程的一种具体体现。它包含上面提到的声明式UI编程的好处:高内聚、低耦合、简洁、清晰、可靠等,这里不再赘述。

  • 此外组件的抽象强化了职责的边界

    • 带来可复用性,提高开发效率

借助社区开源的antd等UI组件库,极大减低了业务开发门槛;此外,基于项目沉淀的业务组件,也能极大提高后续的业务迭代开发效率。

  • 带来确定性,减低维护成本

特别是对于复杂项目或者老旧项目来说,可维护性是个很重要的指标。小的改动或者问题排查,可以沿着组件间的确定性联系来定位,做到影响点最小化。

2. 组件拆分准则

  1. 按数据边界来分割组件;
  • 明确props和state的作用边界、职责范围;
  • props数量不宜过多,明确组件间必要的数据流转;
  • 最小化原则,把state尽量往上层组件提取,尽量避免不必要的派生状态;
  1. 按视觉功能拆分组件,依次拆分:**
  • 基础类组件
  • 业务公用类组件
  • 复杂业务按页面区块拆分,做好区块组件间数据流转;

根据页面区域结构或功能点拆分成一个个自成体系、拥有各自状态的小组件,再由这些组件构成更加复杂的 UI。

当然,设计准则是个指导思想,往往实际复杂项目中需要考量的维度更多,并不能一概而论。
组件化的拆分与装配是个需要实践探索,才能越做越好的过程。优秀的组件化开发还透露着 设计的艺术美学
我们需要通过真实项目的持续优化探索和阅读优秀的开源项目 中逐步培养。

六、组件设计模式-高阶组件(HOC)

要明确:高阶组件和高阶函数本质上就是同一个东西。
我们实现一个函数,传入一个组件,然后在函数内部再实现一个函数去扩展传入的组件,最后返回一个新的组件,这就是高阶组件的概念,作用就是为了更好的复用代码。

七、组件通信-单向数据流

React中通过 单向数据流的方式 实现数据状态流转。

1. 父子通信
  • 父组件通过 props 传递数据给子组件,
  • 子组件不能直接修改 props, 而是必须通过调用父组件函数的方式告知父组件修改数据。

这种父子通信方式也就是典型的单向数据流。

2. 兄弟组件通信

对于这种情况可以通过上升法解决,即提升数据状态到共同的父组件中,在父组件中维护状态和事件函数。
比如说其中一个兄弟组件调用父组件传递过来的事件函数修改父组件中的状态,然后父组件将状态传递给另一个兄弟组件。

3. 跨多层次组件通信

在16.3版本之后,我们可以使用Context API解决跨多层次组件的通信问题。

Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法。「传送门

4. 任意组件

任意组件间的通信则可以借助 诸如 Redux、Mobx、Dva 等全局状态管理 或者 Event Bus 解决。
当然,还可以只借助Redux等全局状态管理器解决上述所有的数据通信情况。不过个人不是很推荐,徒增项目复杂度,且全局状态管理器更应该只用在全局状态的维护上。

八、数据驱动

我觉得数据驱动是实际开发中使用React,需要牢记于心并付诸行动的核心概念;
在团队开发过程中,我还经常会遇到童鞋贯彻不到位,比如:监听滚动、设置动画等交互,直接操作真实DOM;或者不信任数据驱动的渲染结果和流转过程,进行强行干预;

但其实都是完全可以避免的:
React界面完全由数据驱动:你不需要直接操作DOM,而是借助React的能力通过数据状态来驱动Dom渲染及交互;后续的页面变更等,直接操作数据状态即可做到同步变更。
并且React会通过下面会提到的Virtual DOM技术只会进行必要的更新,来达到渲染优化的目的;

九、Virtual DOM

1. 什么是 Virtual DOM?

Virtual DOM 是一种编程概念。在这个概念里, UI 以一种理想化的,或者说“虚拟的”表现形式被保存于内存中,并通过如 ReactDOM 等类库使之与“真实的” DOM 同步。这一过程叫做协调。 ——React官网

基于Virtual DOM 技术,通过JavaScript实现的类库(在对DOM操作的基础上建立了一个抽象层,用JavaScript对象来代替DOM节点,承载与真实DOM的交互),帮忙开发者避免直接进行真实及繁琐的DOM操作和UI渲染。
同时,它也是一种抽象的编程模式,衍生出的声明式UI编程、DOM Diff算法、React Fiber引擎等都是“Virtual DOM”的一部分具体体现;

2. Virtual DOM优势?

  • 将 Virtual DOM 作为一个兼容层,让我们还能对接非 Web 端的系统,实现跨端开发。
  • 同样的,通过 Virtual DOM 我们可以在其他的平台渲染,比如实现 SSR、同构渲染等等。
  • 为函数式的 UI 编程方式打开了大门,实现组件的高度抽象化。
  • 隐藏DOM操作细节,配合框架提高项目可维护性;
  • 在频繁操作DOM、频繁小量数据更新的应用场景,有很好的性能优势。

3. Virtual DOM 比原生操作DOM快?

关于这一点,具体可以参考几年前「尤大的见解」。

这个说法是很片面的。Virtual DOM最后还是会解析成原生DOM,进行原生DOM操作。
Virtual DOM 在数据Change时才能有优势,如果页面第一次展现出来以后都不用变就没优势了。

不要天真地以为 Virtual DOM 就是快,Diff 不是免费的,且最终还是要进行原生API调用渲染。真正的价值不止是性能,它带来的优势还在于:

  1. 通过框架封装,提高了可维护性,并不是把性能优化放到首位。框架给你的保证是,你在不需要手动优化的情况下,我依然可以给你提供过得去的性能。
  2. 和 DOM 操作比起来,js 计算是极其便宜的。

4. React 的DOM Diff算法原理是什么?

React组件在首次渲染后,会创建一棵对应的虚拟DOM树,当组件的 props 或 state 更新时,React 将会构建一棵新的 虚拟DOM树,React 需要基于这两棵树之间的差别来判断真正变化的部分 进而有效率地更新 UI 以保证当前 UI 与最新的树保持同步。 这就涉及Diff算法的优劣问题。

传统Diff算法:通过循环递归对节点进行依次对比,算法复杂度达到 O(n),效率十分低下;

React的Diff算法

React的diff算法:是将Virtual DOM树转换成实际 DOM树的最少操作化的过程。

React用 三大策略 将O(n3)复杂度 转化为 O(n)复杂度:

1. Tree Diff (层级比较)

对两棵Virtual DOM树进行分层比较,只对同一层级进行比较。如果比较发现,对应的节点无法匹配或不存在,则该节点及其子节点会被完全删除,不会再进一步比较。
这样只需遍历一次,就能完成整棵DOM树的比较。
如此,Diff算法只简单考虑同层级的节点位置变换,如果是跨层级的话,只有创建新节点和删除旧节点的操作。

2. Component Diff (组件比较)

拥有相同类型的两个组件按层级比较,将会生成相似的树形结构;
拥有不同类型的两个组件会被判定为脏组件,从而替换整个组件的所有节点,将会生成不同的树形结构。

3. Element Diff (节点元素比较)

当节点处于同一层级时,则提供三种节点操作:删除、插入、移动
插入: 对于新的节点,直接插入;
删除:对于不能复用和实际移除的节点,则删除旧的,重新创建新的;
移动:对于添加唯一key进行区分的同一组子节点,可以做到移动位置即可;

5. react 和 vue 的 diff 过程有什么区别

  • Vue 在渲染过程中,会跟踪每一个组件的依赖关系,不需要重新渲染整个组件树;

React 每当应用的顶部组件状态被改变时,全部子组件都会触发执行及重新渲染(不过这一点可以通过 shouldComponentUpdate 生命周期方法或React.memo等其他途径来进行控制)。

  • React Diff的是 Dom,而 Vue Diff 的是数据;

    6. 为什么React中,任何在顶层的更新只会触发协调而不是局部更新那些受影响的组件?

    这样的设计是有意而为之的。对于 web 应用来说交互时间是一个关键指标,而通过遍历整个模型去设置细粒度的监听器只会浪费宝贵的时间。此外,在很多应用中交互往往会导致或小(按钮悬停)或大(页面转换)的更新,因此细粒度的订阅只会浪费内存资源。

十、React Fiber

React在16.0版本中引入了全新的一个底层架构-Fiber。它作为一个核心算法重构后全新的协调引擎,主要目的是使Virtual DOM可以进行增量式渲染:将渲染工作分成多个块并将其分布到多个帧中的能力。

1. 为什么要引入Fiber

其实就是为了解决原来同步渲染、且无法打断的问题。在React16之前,在渲染很大很深的 React 组件树结构时,同步渲染会带来性能问题。
具体表现为:在同步渲染的模式下,如果最上层的组件数据变更触发渲染,会同步引发渲染子组件,再同步渲染子组件的子组件……最后完成整个组件树的渲染。 调用栈会非常长,过程中又有大量复杂更新逻辑,diff计算过程就可能导致长时间阻塞主线程,而JS运行环境是单线程的,长期占用,就会导致浏览器渲染引擎无法及时(大于16ms)进行UI绘制,会有掉帧的性能问题;且还会导致用户的交互操作以及页面动画得不到响应,影响用户体验。
Fiber 就是为了解决该问题而生。纤维增强机理

解决方式就是:
Fiber 把这个耗时长的同步渲染任务进行切片,分成多个任务块。在完成每个任务快之后,给主线程的其他任务一个可以执行的机会。 这样主线程就不会被Virtual DOM计算及渲染独占而阻塞。这个过程也被成为协调,Fiber调度器会按照优先级自由调度这些小的任务块。达到在不影响体验的情况下去分段计算增量更新渲染的目的。

对于如何区别优先级,React 有自己的一套计算逻辑,值得好好深入探究。比如:对于动画这种实时性很高的东西,React 会每 16 ms 暂停一下更新,优先保证动画执行,之后继续渲染调度,从而保证动画不卡顿。

2. Fiber 任务有两个执行阶段:“渲染阶段”和“提交阶段”:

  1. Reconcile(render) - 渲染协调:

渲染阶段 是Fiber进行协调的阶段。属于Virtual DOM操作阶段,在调度diff算法中把新老的 Virtual DOM 进行比较,找出要更新的内容,生成更新树。Fiber Reconciler 调度把这一过程设计成个单纯的js计算过程,且被设计成可缓存、可以被打断、以及可以恢复执行;

  1. Commit - 提交更新 :

提交阶段 就是 实际操作宿主树的时候。属于界面绘制阶段,上一步拿到需要更新的内容后,提交更新,会调用对应的渲染器(比如React-DOM)进行UI绘制。这一过程是同步且不能被打断的,直至完成这个组件的本次执行过程。
原因就是防止在渲染过程中,消除所谓的中间状态,造成页面结构抖动,影响用户体验。

引入Fiber后对生命周期函数的影响?

image.png
React16.4之后的版本中,组件声明周期函数的执行被分成了两个阶段:Render阶段 和 Commit阶段 。前者过程是可以被暂停、中止或重新启动的;后者不能中止,会一直更新界面直到完成。
Render 阶段

  • componentWillMount
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate

Commit 阶段

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

因为 Render 阶段是可以被打断的,所以 Render 阶段会执行的生命周期函数就可能会出现调用多次的情况,从而引起 Bug。由此对于 Render 阶段调用的几个函数,除了 shouldComponentUpdate 以外,其他都应该避免去使用,并且 V16.4 中也引入了新的 API 来解决这个问题:
getDerivedStateFromProps 用于替换 componentWillReceiveProps ,该函数会在初始化和 update 时被调用。
getSnapshotBeforeUpdate 用于替换 componentWillUpdate ,该函数会在 update 后 DOM 更新前被调用,用于读取最新的 DOM 数据。

在我自己刚学习React的一段时间内,跟很多开发者一样有一个误区:把Vue的生命周期规则硬搬套用,总是想把任务往靠前的生命周期函数去提,在componentWillMount 中做AJAX请求。

3. 那么为什么不在 componentWillMount 里去做AJAX?

一个组件的 componentWillMount 比 componentDidMount 也早调用不了几微秒,性能没啥提高;

  1. 从上面的渲染过程,我们知道,Render 阶段 的componentWillMount 可能被中途打断,中断之后渲染又要重做一遍,会出现接口请求调用N次的情况。相反,若把 AJAX 放在 componentDidMount,因为 componentDidMount 在第二阶段,所以不会多次重复调用。
  2. 此外,在服务端同构渲染的模式下,如果在componentWillMount里面获取数据,fetch data会执行两次,一次在服务器端一次在客户端。在componentDidMount中则不会。

现在我们知道正确的做法是根据各函数的语义来放置代码,并不是越往前越好。

十一、React的事件机制? 合成事件/原生事件

React的合成事件与浏览器的原生事件不同,也不会直接映射到原生事件。

1. 什么意思呢?

意思是:React事件注册后并没有绑定在对应的真实 DOM 上,而是通过事件代理的方式,将所有的事件都统一绑定在了 Document 上。这样的方式不仅减少了内存消耗,还能在组件挂载销毁时统一订阅和移除事件。
另外冒泡到 Document 上的事件也不是原生浏览器事件,而是 React 自己实现的合成事件(SyntheticEvent)。
它是浏览器原生事件的跨浏览器包装器。它还是拥有和浏览器原生事件相同的接口,包括阻止事件传递的 stopPropagation()preventDefault()等接口。

2.那么React实现合成事件的目的是什么呢?

  1. 赋予了React跨浏览器开发的能力;合成事件是一个跨浏览器原生事件包装器,抹平了浏览器之间的兼容问题;
  2. 不仅减少了内存消耗,还能在组件挂载销毁时统一订阅和移除事件

对于原生浏览器事件来说,浏览器会给每个监听器创建一个事件对象。如果你有很多的事件监听,那么就需要分配很多的事件对象,造成高额的内存分配问题。
但是对于合成事件来说,有一个事件池专门来管理它们的创建和销毁,当事件需要被使用时,就会从池子中复用对象,事件回调结束后,就会销毁事件对象上的属性,从而便于下次复用事件对象。
不过在最新的React 17中却去除了事件池。

后语

前言要搭后语 —名人

概念理解的越透彻,使用就越顺畅。

个人每一次学习官网、每一次阅读源码,都有新的理解和收获。
我目前在做的就是在持续学习当中总结自己在实践React过程中的所见所学。

本篇文章未涉及重要的React Hooks相关知识,下一篇会专项探讨;