《深入浅出React和Redux》读书笔记

前言

这本由程墨编写的《深入浅出React和Redux》是从我入门React之初,在翻阅官方文档之后开始看的,第一次看时一头雾水,仅为了了解React怎么用;

再翻一次开始看得懂性能优化和Redux这类状态应用管理的使用。

随着对React不断的深入,课余的拓展阅读,发现即使是17年年初出版,在React版本更新迅速、API不断变动的情况下,这本书依旧可以带来不一样的阅读体验。

本书的总共12章节,1-2章讲了React的核心理念,3-4章重点讲解状态管理的演化过程,第5章讲解性能优化,提出了虚拟DOM和调和过程,第6章提出了高阶组件(HOC),注重于抽象和组合,第7,9章立足于第3章引入的Redux,对副作用的处理引入了解决方案,第8章讲解了单元测试,第10章及之后,除去第11章对于路由的使用,其余的动画、服务器同构等因为暂未实践理解不到位就不予讨论了。

如果要对React一步一步分析下来,它可以讲解的知识点怕是可以出好几本动物书。但是React的重要理念或是思想是简单的,主要是以下两点:

  • 数据驱动的响应式编程思想,概况为UI = render(data)
  • 一切基于组件

数据驱动的响应式编程

书中拿React与前端利器jQuery作对比,实现一个ClickCounter功能举例如下:

  • html代码:

  • jQuery代码:

可以看出html仅作为展示,没有任何交互功能。

在jQuery的解决方案中,首先根据id选择器找到了ClickMe按钮,绑定上一个click点击的匿名事件处理函数,在事件处理函数中,选中需要被修改的DOM元素,读取其值并作出修改,最后再修改这个DOM元素。这种做法直观且容易理解,一出世就获得了普遍认可(其中还需要考虑到jQuery解决了浏览器兼容问题)。

但是,对于庞大项目,繁重的DOM获取,DOM操作直接导致代码结构复杂,难以维护。而对于React来说,这只需要一个组件即可完成,不需要做过多的DOM操作,计数状态可以仅存在组件中,随着状态的变化带动界面的变化。

如何带动界面变化,其中发生了什么,这就涉及到React的声明式渲染了。

jQuery可以看做是c语言这类命令式编程,我们需要控制一件事情具体的每一个步骤,告诉系统怎么这么做,比如之前,我们要找到按钮的DOM,再找到显示计数的DOM,然后绑定事件,最后显示等详细的运行流程。而React则是声明式编程,它告诉系统要做什么,具体怎么实现则由系统自行完成。例如:

这种命令式写法,遍历了整个numbers数组,取出后乘以2再放入新的数组。
而声明式写法则如下:map将整个数组的过程归纳抽离,专注于描述我们想做什么(每个值乘以二)。

React中的响应式编程思想体现在它的声明式渲染之中,作为开发者,我们只需要维护一个组件的props和state,React会帮我们处理相应的DOM操作,通过不停的检测和重复渲染来实现之前所描述的一个公式:UI=render(data)。

不停的渲染必然会导致性能的下降,毕竟在jQuery的实现方式中,我们可以清楚地看到每次只有需要变化的那一个DOM被修改了,即使遇到大量的事件变动,如resize,scroll也可以使用节流来控制;可是在React的实现方法中,看起来每次render函数被调用,都要把整个组件重新描绘一次,看起来十分浪费性能。

虚拟DOM

React当然有考虑过多次重复渲染的问题:利用虚拟DOM,让每次渲染都只重新渲染最少的DOM元素。

要了解虚拟DOM,首先要了解DOM,DOM是结构化文本的抽象表达形式,特定于WEB环境中,这个结构化文本就是HTML文本,HTML中的每个元素都对应DOM中某个节点,这样,因为HMTL元素的逐级包含关系,DOM节点自然就构成了一个树形结构。浏览器在渲染网页时,会先将HTML文本解析以构建DOM树,将CSS文本构建为样式树,接着将两树合并成渲染树,最后绘制到网页上。

尽量减少DOM操作,减少回流和重汇,是Web前端开发的性能优化中的一项重要原则。React的虚拟DOM正是对DOM树的抽象。比如上文中Counter组件的编写,React会将它先转换成虚拟的DOM,虚拟DOM并不会触及浏览器的部分而只是存在于内存中,每次渲染之时,React都会对比这一次与上一次渲染的虚拟DOM,如果有差别,仅仅修改有差别的部分即可。

调和(diff算法)

React在更新阶段会使用调和(Reconciliation)过程来找出原有的虚拟DOM和新生成的虚拟DOM的不同之处。按照现有的计算机科学算法研究结果,对比两个N个节点的树形结构的算法时间复杂度是O(N3)[1]。考虑到运算能力,DOM的复用程度,React实际采用的算法需要的时间复杂度是O(N)。

当React要对比两个虚拟DOM的树形结构的时候,从根节点开始递归向下对比,会遇到三种不同的情况:

  1. 节点类型不同的情况
  2. 节点类型相同的情况
  3. 多个子组件的情况

对于第一种节点类型不同的情况,也不用考虑是否复用它的子组件了,可以直接替换掉原有的树形结构,原有的树形结构上的React组件会经历Unmount过程。
对于第二种节点类型相同的情况,此时会区分节点的类型:一类是DOM元素类型,对应的是HTML直接支持的比如p、div、span等,此时只需要对属性和内容对比然后只更新修改的部分。例如:

改变之后变成:

React可以对比发现这些属性(选中部分)的变化,在操作DOM树上节点的时候,只去修改这些变化的部分。

另一类是React组件,对应的是React库定制的类型。这类节点的diff就会引发组件实例的更新过程,按照顺序引发组件的生命周期:

  • componentWillReceiveProps (UNSAFE)
  • shouldComponentUpdate
  • componentWillUpdate (UNSAFE)
  • render
  • componentDidUpdate

在这个过程中,如果shouldComponentUpdate函数返回false的话,那么更新过程就不在继续,它的子节点也不会参与更新。截至目前,React 16.3.2版本将原来的两个生命周期标注为UNSAFE并将在未来剔除。为了迎接新的Fiber架构和async rendering(异步渲染)避免主线程阻塞。

对于第三种拥有多个子组件的情况,React会直接挨个比较子组件,采用的方式则是前面描述的两种情况的解决办法。这时则会出现这种情况:

仅仅是在ul标签下unshift新增一个li.0,React会把逐个对比,把li.0的增加当作是li.1的修改,紧接着把li.2修改为li.1,最后增加了li.2。看起来的确很傻,但一个简单的算法就只能用这种方式处理问题。

此时React引入了key明确地标识每个组件,解决了这一问题。

值得注意的是,这里有一种反模式就是将元素在数组中的下标作为key(或是map时将index作为key),这是一种错误的使用key的方法。

一切基于组件

React的首要思想是通过组件(Component)来开发应用。所谓组件,简单说,指的是能够完成某个特定功能的独立的、可重用的代码。

基于组件的应用开发是广泛使用的软件开发模式,用分而治之的方法,把一个大的应用分解成若干个小的组件,每个组件只关注某个小范围的特定功能,但是把组件组合起来,就能够构成一个功能庞大的应用。如果分解功能的过程足够巧妙,那么每个组件可以在不同的场景下重复使用。在React的官方文档中,Component-Based是被标注出来的一个特性之一。

在使用过的React全家桶中,Redux就是创建了一个顶层的Provider组件,利用React的Context作为全局存储store而避免到处引用store。React-Router(V4)就是利用路径匹配,将路径映射为组件render出来。可以说React中的大部分拓展与实现,都是以组件为核心。

在单一职责原则下(SRP),组件可以被拥有两种职责:与数据(state)打交道和渲染用户界面。业界对于这两种的拆分具有多种叫法,如前者可以叫做容器组件,后者叫做展示组件;或是前者叫聪明组件,后者叫傻瓜组件。
前者一般处于外层,保存状态或者处理数据,拥有完整的生命周期;后者由于不管理状态,输出(界面)完全依赖于输入,可以看做是一个纯函数。

高阶组件

高阶组件(HOC)是使用React的一种模式,用于增强现有组件的功能。简单来说,高阶组件就是一个接受函数,返回函数的函数。定义高阶组件的意义何在呢?首先可以重用代码,比如react-redux中容器组件的部分。其次可以修改现有的React组件的行为,比如运用高阶组件方式对antd中组件包裹,对原有的组件没有任何侵害。

根据返回的新组件和传入组件参数的关系,高阶组件的实现方式可以分为两大类:

  • 代理方式的高阶组件

  • 继承方式的高阶组件

代理方式和继承方式各有特点,这里我将书上的讲解对比列成表格

代理方式 继承方式
操纵props 操纵props
访问ref 操纵生命周期函数*
抽取状态 -
包装组件 -

书中更推荐代理的方式创建高阶组件,更加容易实现和控制;继承方式唯一的优势是可以操纵特定组件的声明周期函数。

以函数为子组件

高阶函数并不是唯一可用于提高React组件代码重用的方法。高阶组件拓展现有组件功能的方式主要通过props。以代理方式为例,说到底两个组件是父子关系,两者的通讯关系也就props。每个组件通过propTypes声明自身支持的props,利用原组件的props来拓展功能,并且支持lint检查。但这也是高阶组件的缺点:要求统一接口。如果组件不能接受高阶组件传递的props,就没法使用这个高阶组件。

以函数为子组件就是为了克服高阶函数的这个局限性,举一个书上的例子:

使用这个AddUserProp的灵活之处在于它没有对被增强组件有任何props要求,只是传递一个参数过去,至于如何使用完全由子组件的函数决定。例如:

从上面2个使用样例可以看得出来,利用这种以函数为连接桥梁的方式十分灵活。

如果关注React16中新的Context API就可以发现,新的Context就是用这种方式来导入的。以下是我参考新API写的一个例子:

可以看到Mycontxt.Consumer中包裹的就是一个函数,函数的形参则是在MyContext.Provider中传入的props。

组件之间的通信

  • 父组件至子组件的通信

通过props一层一层传递到子组件。

  • 子组件至父组件的通信

将状态提升至父组件,父组件声明一个修改此状态的函数通过props传递到子组件,子组件需要传递信息时调用这个回调函数。

  • 多组件通信

抽取一个组件,声明context,把需要通讯的组件作为子组件。各个组件可以通过context来共享数据。

数据管理

本书中仅讨论了Flux到Redux这数据管理层的技术栈,在社区中还有一些例如Mobx。限于对Mobx的理解不足,本文就暂时不予对比讨论。

参考文献

[1] Bille P. A survey on tree edit distance and related problems[M]. Elsevier Science Publishers Ltd. 2005.