2.2 开发框架——React

2.2.1 介绍

React是用于构建用户界面的JavaScript库,起源于Facebook的内部项目。Facebook对市场上所有JavaScript的MVC框架进行调研以后,发现它们都不能满足自己的需求。于是,Facebook决定自己实现一套全新的框架,用来架设Instagram的网站。React被开发出来以后,经过了内部团队的反复打磨、完善,在2013年5月开源。

React在当时属于革命性创新,因为它的设计思想极其独特。它不仅性能出众,代码逻辑也非常简单。于是越来越多的人开始关注和使用React,认为它可能是将来Web开发的主流框架。随着React获得了越来越多前端开发人员的认可,它的生态也越来越完善,从最早的UI引擎,逐步演变成了一整套前端开发解决方案。同时,由React衍生出来的React Native更是颠覆性的突破,开发人员可以直接使用Web App的开发方式去实现Native App。

React在页面渲染更新上具有很好的表现,主要原因是它引入了虚拟节点(Virtual DOM)。虚拟节点使用JSON描述浏览器中的真实节点,通常至少包含签名(tag)、属性(props)和子元素对象(children)。例如,浏览器中有以下真实节点树。

当该真实节点树使用JSON进行描述时,就得到以下虚拟节点树。

React会将浏览器中的真实节点树转换成虚拟节点树,将页面的状态抽象为JSON对象的形式。当页面需要更新时,React可以利用虚拟节点的特性,把节点树的变动在内存中进行DOM diff,再将多次比较的结果进行合并,最后一次性将变更内容同步到浏览器的真实节点树。该方法可以有效减少页面渲染的次数,最大限度地减少对真实节点树的变更,进而提高渲染效率,实现性能优化。

在实际开发中,如果开发人员直接使用React提供的React.createElement进行开发,其实体验并不是特别友好,比如有以下HTML需要被创建。

使用React.createElement方法创建的代码如下。

如果使用以上方式进行开发,那么反而让代码变得复杂,这显然不符合React的初衷。因此,JSX应运而生了。它是一种JavaScript语法扩展,开发人员可以通过JSX来更加友好地使用React进行开发。同时,JSX本身只是一种语法糖,在编译后它会转化成普通的JavaScript对象。

JSX会使人联想到模板语言,它具有JavaScript的全部功能。有了JSX,开发人员在使用React进行编码时会如同写HTML一样快捷便利,同时具有JavaScript的灵活性、功能性。

以上使用JSX编写的代码在经过打包编译后都会转换成对应的React语法。

在React中,数据流遵循自上而下、单向流动的原则,该原则使得组件之间的依赖关系变得简单,state和props是该原则的具体体现。如果父组件更新了子组件的props,则React会遍历整个组件树,重新渲染所有依赖该props的子组件。如果state更新了,那么只会影响当前组件节点。

2.2.2 快速上手

在设计之初,React就遵循了渐进式开发的理念。即便是对于“石器时代”的Web页面,也可以增量引入React,然后逐步扩展,慢慢进行迁移重构。

首先,开发人员需要在目标页面的HTML里添加一个空标签,把它作为React节点树挂载的DOM容器。该标签需要指定唯一的ID属性,方便React在挂载时查找目标节点。React在首次进行渲染时,会指定将标签里的内容全部清空,替换为新的内容,所以必须保证目标节点的标签为空。因为React在渲染更新时通常不会对容器外的节点产生副作用,所以开发人员可以在一个页面上放置多个独立的DOM容器,同时渲染多个React应用。

React中一个非常重要的组成部分就是组件。组件在概念上类似于JavaScript函数,能够接受任意入参,并返回用于描述页面展示内容的React元素。

定义组件最简单的方式就是编写JavaScript函数。

该函数是一个有效的React组件,由于使用function进行声明,所以也被称为函数组件。开发人员也可以使用ES6的class来定义组件,这种组件被称为类组件。

生命周期是组件的重要组成部分之一,开发人员可以借助生命周期函数在运行过程中的指定阶段执行对应的操作。

类组件具有以下生命周期函数。

(1)当组件实例被创建并插入DOM中时,其生命周期调用顺序如下。

• constructor:在组件被挂载之前,React会调用它的构造函数。一般用于通过为this.state赋值对象来初始化内部state,或者为事件处理函数绑定实例。

• getDerivedStateFromProps:在调用render方法之前被调用,并且在初始挂载及后续更新时都会被调用。它应返回一个对象来更新state,如果返回null则不更新任何内容。

• render:组件中唯一必须实现的方法。在通常情况下,组件每次渲染更新时都会执行该方法。但是当shouldComponentUpdate返回false时,则不会进行调用,这种情况通常是开发人员主动干预渲染流程造成的,一般出现在性能优化的场景中。

• componentDidMount:该方法会在组件挂载后,即渲染结果插入真实节点树时立即被调用。在一般情况下,所有依赖DOM初始化的操作都应该放在这里。例如,通过网络请求获取数据的操作就可以在该生命周期中实现。

(2)当组件的props或state发生变化时会触发更新。组件更新的生命周期调用顺序如下。

• getDerivedStateFromProps:描述同上。

• shouldComponentUpdate:React会根据shouldComponentUpdate的返回值,判断React组件的输出是否受当前state或props更改的影响。默认state每次发生变化组件都会重新渲染,此生命周期是用来进行性能优化的。

• render:描述同上。

• getSnapshotBeforeUpdate:该方法会在最近一次渲染输出(提交到DOM节点)前调用。它使得组件能在发生更改前从DOM中捕获一些信息(例如滚动位置),常用于UI处理。

• componentDidUpdate:首次渲染不会执行此方法,该方法会在组件被更新后被立即调用。例如,开发人员可以在props更新后,在此方法中根据变化的值进行响应操作。如果该方法中执行了重渲染,那么需要保证重渲染的操作必须被包裹在一个条件语句里,否则会导致死循环。如果组件使用getSnapshotBeforeUpdate生命周期,则它的返回值将作为componentDidUpdate的第三个参数snapshot被传递,否则此参数将为undefined。

(3)当从DOM中移除组件时会调用如下方法。

• componentWillUnmount:该方法会在组件卸载及销毁之前被直接调用。一般用于必要的清理操作,例如,清除timer、取消网络请求或清除在componentDidMount中创建的订阅等。在该方法中不应调用setState,因为该组件将永远不会重新渲染。组件实例被卸载后将永远不会再被挂载。

(4)当渲染过程、生命周期,或子组件的构造函数中抛出错误时,会调用以下方法。

• getDerivedStateFromError:该方法会在后代组件抛出错误后被调用。它将抛出的错误作为参数,并返回一个值以更新state。

• componentDidCatch:该方法会在后代组件抛出错误后被调用。它接受两个参数,error代表抛出的错误,info代表带有componentStack key的对象,其中包含相关组件引发错误的栈信息。

以上两种方法通常用于在发生错误时进行UI降级处理,保证页面不会因为局部组件错误而崩溃,官方更推荐使用getDerivedStateFromError。

除了上述生命周期函数,还有UNSAFE_componentWillMount、UNSAFE_componentWillUpdate、UNSAFE_componentWillReceiveProps,官方已经不推荐使用这些方法,在未来的版本中也会逐渐废弃这些方法,因此本书不做讲解,详情可以查看官方文档。

state是组件的另一个重要组成部分。当组件中的某部分需要支持动态展示时,开发人员可以使用props和state。开发人员不能直接修改state,因为React没有办法感知这种修改方式引起的数据变化,所以并不会触发视图更新。只有在constructor中,开发人员才可以通过this.state的方式进行赋值。修改state的正确做法是使用setState方法进行状态更新。

setState有两个参数,当第一个参数为对象时,会将入参作为新的state进行更新;当第一个参数为函数时,会将当前state作为入参传给该函数,并将该函数的返回值作为新的state进行更新。如果第二个参数为回调函数,那么当state完成更新时会调用该函数。

React不仅在视图更新时使用虚拟节点树和DOM diff对性能进行优化,还对状态更新进行了处理。setState更新state是一个异步的过程。在执行完setState之后,开发人员并不能通过this.state得到最新的state结果,但是可以利用setState的第二个参数进行函数回调来取得最新的state结果。

在执行以上代码后,state.count的值是2,而不是3。因为在increment函数第二次调用setState时,前一次的setState还没有被更新到state中,所以在第二次调用setState时this.state.count取到的值依然为1。

如果每次调用setState都进行一次更新,那么意味着render函数会被频繁调用,极大影响React的性能。为了优化性能,React会把多个setState调用合并成一个,直接进行批量更新。

在React Hook出现之前,函数组件仅能覆盖无状态、无生命周期的应用场景。在实际应用中,开发人员不仅需要进行状态管理,还需要依赖生命周期在不同的阶段执行对应的操作,例如网络请求。因此,开发人员不得不使用类组件进行开发。React Hook的出现弥补了函数组件在这方面的缺陷,它可以让开发人员在不编写类组件的情况下使用state以及其他类组件的特性。同时,函数组件也符合React一直大力推行的函数式编程思想。开发人员在使用hook进行开发时需要遵循以下规则。

• hook只能在顶层使用。不要在循环、条件或嵌套函数中调用hook,应该确保hook总是在React函数的顶层被调用。只有遵守这条规则,才能确保hook在每一次渲染中都按照同样的顺序被调用,并且在多次的useState、useEffect等函数的调用之间保持状态的正确性。

• 不要在普通函数中调用hook,hook只能在React函数组件中使用。

• 在命名hook时,应该以use开头,以便与普通函数进行区分。例如,计数器的hook可以命名为useCounter。

React Hook有以下常用方法。

• useState:对应类组件中的state和setState,用于状态管理。

• useEffect:用于执行具有副作用的代码,如绑定事件、模拟生命周期等。

更多的React Hook知识请参考官方文档,这里不再展开讲解。

2.2.3 路由控制

路由的概念来源于服务端,在服务端中路由描述的是URL与处理函数之间的映射关系。

在前端应用中,路由描述的是浏览器URL与页面UI之间的映射关系,前端应用会根据不同的URL展示不同的页面UI。最初,前端应用需要借助后端路由实现以上能力,后端服务会根据对应的URL返回不同的HTML文件给浏览器,然后渲染对应的页面。这种路由控制方式有一个非常致命的问题:当浏览器URL变更时会重新请求新的HTML文件,导致页面重新加载,严重影响用户体验,前端路由便应运而生。

前端路由能够借助浏览器的能力,在不向服务端发起请求的前提下,根据浏览器的URL映射页面UI,实现页面无刷新跳转,这种方法大大改善了用户体验。前端路由已经成为前端应用中的标准配置。

第一种路由方案是哈希路由(Hash Router)。在2014年之前,大家基本都是通过这种方案来实现前端路由的。

在以上代码中,#后面的内容属于hash值,hash值变化不会导致浏览器向服务器发出请求。如果浏览器不发出请求,就不会刷新页面。每次hash值变化都会触发hashchange事件。通过hashchange事件,开发人员可以获取hash值的变化情况,从而根据不同的hash值展示不同的页面UI。

第二种路由方案是浏览器路由(Browser Router)。2014年后,HTML 5发布了新的标准。history新增了pushState和replaceState两个方法,使用这两个方法改变URL的path部分不会引起页面刷新。history提供类似hashchange事件的popstate事件。需要注意的是,调用history.pushState或history.replaceState不会触发popstate事件。只有对浏览器进行操作时,才会触发该事件,例如,单击浏览器的回退按钮(或者在JavaScript代码中调用history.back或history.forward方法)。针对这种情况,开发人员可以通过拦截pushState、replaceState的调用来监测URL变化。

第三种路由方案是内存路由(Memory Router)。该方案较少使用,一般适用于非浏览器环境(例如,React Native、测试等),内存路由会将虚拟URL的访问历史记录保存在对象内存中。内存路由通过模拟浏览器的历史栈将URL和页面UI映射起来。

React Router是一套完整的React路由解决方案,它拥有简单的API与强大的功能,例如,动态路由匹配等。React Router能够帮助开发人员向应用中快速添加视图和数据流,同时能保持页面与URL同步。它拆分出了4个功能包供开发人员单独使用。

• react-router:路由核心功能,提供路由相关的核心API,例如,Router、Route、Switch等。

• react-router-dom:基于react-router,加入了浏览器环境相关的API,例如,BrowserRouter、NavLink等。

• react-router-native:基于react-router,加入了与React Native环境相关的API。

• react-router-config:与react-router相关的静态路由配置。

在React Router中,由三部分来共同决定一个URL匹配的页面。

第一部分是嵌套关系。React Router使用路由嵌套的概念定义页面的嵌套集合,当给定的URL被调用时,整个集合(命中的部分)都会被渲染。嵌套路由被描述成一种树形结构,React Router会深度优先遍历整个路由配置,从所有路由配置中寻找一个与给定的URL匹配的路由。在以下示例中,当URL命中/a时,页面只会渲染区块1、区块2;当URL命中/a/b时,还会额外渲染MainPage。

第二部分是路径语法。路由路径是匹配一个或多个URL的字符串模式,大部分路由路径都是静态字符串。路径语法使用的是开源社区的path-to-regexp方案,在进行动态匹配时,通常会用到以下方式。

• 参数命名:使用:“param”的形式进行参数匹配,在子组件中可以通过props.match.params.[名称]访问对应参数的值。

• 可选配置:使用( )包裹对应的字符串,这部分值会被修饰为可选路径。

• 模糊匹配:使用*可以匹配任意字符。

如果一个路由使用了相对路径,那么完整的路径将由它所有祖先节点的路径,以及路由自身的相对路径拼接而成。使用绝对路径可以使路由匹配行为忽略嵌套关系。

第三部分是优先级,React Router的路由算法会根据定义的顺序自顶向下匹配路由。

常用的路由组件有以下几种。

Router作为最外层的容器,一般被包裹在React应用的顶层,为应用提供组件化的路由响应能力,主要包括HashRouter、BrowserRouter、MemoryRouter、StaticRouter,它们分别以不同的形式描述URL和页面交互的方式。HashRouter使用的是哈希路由,BrowserRouter使用的是浏览器路由。MemoryRouter、StaticRouter使用的都是内存路由方案,不同之处在于MemoryRouter模拟记录了页面的历史栈情况,属于有状态路由;而StaticRouter不记录历史栈,属于无状态路由。内存路由方案一般用于非浏览器环境,例如,服务端渲染、React Native等。

Route用于配置URL和组件渲染的映射关系,主要由path和组件渲染方式两部分组成。path用于描述URL的命中情况,遵循path-to-regexp的匹配规则,具体示例可参照前文。组件渲染的方式由三种属性控制,分别是component、render和children。对于渲染的子组件,Route会将match、location、history三个属性注入该子组件的props,供开发人员按需使用。针对复杂的path情况,Route还提供了exact、strict、sensitive三个属性进行修饰。

Switch会挑选并渲染第一个命中路由路径的Route,此时不管后面的Route是否命中当前URL,都不会进行渲染。结合上文对于优先级的描述,当URL命中多个Route组件时,排序越靠前的组件渲染优先级越高,如果使用了Switch,那么只会渲染优先级最高的;如果没有使用Switch,那么会同时渲染所有Route组件,在实际运用中应该结合具体情况使用。

Redirect是用于重定向的路由组件,它提供了组件级别的导航能力,能够帮助开发人员进行页面重定向。

Link用于路由之间的跳转,开发人员可以使用to参数来描述需要定位的页面。它的值既可以是字符串,也可以是location对象(包含pathname、search、hash和state属性)。如果其值为字符串,那么将被转换为location对象。Link最终会被渲染成a标签,相关的属性会被转化并拼接到href上。

NavLink是Link的高阶组件,不仅具备Link所有的能力,还在此基础上添加了额外的属性,用于在当前路径匹配成功时为组件添加样式属性。

使用以上组件,基本可以搭建一个简单的路由系统来实现前端路由的跳转、刷新等功能。如果开发人员需要实现高阶路由功能,那么可以基于react-router进行二次开发。如果要实现路由切换的过渡动画,那么可以在react-router的基础上借助开源社区的react-transition-group过渡动画库。

2.2.4 状态管理

当页面比较简单时,开发人员可以简单地创建一个对象进行维护。随着业务不断发展,不仅页面上展示的信息越来越多,交互也越来越复杂。最直接的体现就是工程中代码的逻辑分支增多,代码复杂度呈指数级增长。状态管理就是对数据进行更新、维护等操作,增加工程的可维护性。

20世纪80年代,Smalltalk提出了著名的MVC架构设计模式,强制将业务数据(Model)与用户界面(View)隔离,用控制器(Controller)管理逻辑和用户输入。许多前端框架都是基于MVC实现的,比较著名的有AngularJS、BackboneJS等。

虽然MVC在一定程度上将视图和状态管理进行了隔离,但是它有一个非常致命的缺点——数据流混乱。同时,这个缺点会随着项目体量增大、代码复杂度增加而变得越来越明显,如图2-1所示。

图2-1 MVC数据流混乱

混乱的数据流会使项目复杂度变高,代码开发与维护成本增加,所以MVC并不是一个理想的解决方案。为了解决这些问题,Facebook的工程师提出了Flux模型,它解决了MVC数据流混乱的问题。Flux数据流模型如图2-2所示。

图2-2 Flux 数据流模型

Flux是一种基于Dispatcher的前端应用架构模式,其核心思想是单向数据流。action触发数据更新,然后数据依次流经Dispatcher、Store、View,且流转过程不可逆,因此不会像MVC一样出现数据流混乱的情况。随着Flux的流行,开源社区发现了它的一些问题,进而出现了大量对Flux进行优化的方案,Redux脱颖而出。Redux参考了Flux的设计理念,并在其基础上对一些复杂的部分做了简化。Redux数据流模型如图2-3所示。

图2-3 Redux 数据流模型

Redux有三大核心原则:单一数据流、状态只读、状态修改能由纯函数完成。单一数据流能有效确保应用有唯一数据源,可以避免数据源混乱的情况;状态只读确保了只能通过定义的Action进行状态修改;状态修改能由纯函数完成确保函数没有副作用,特定的输入必定对应特定的输出。在三大核心原则的共同作用下,Redux保证了有效且高质量的状态管理。

虽然Redux已经在Flux基础上进行了优化,大幅度提升了开发人员的使用体验。然而,Redux的学习成本依然偏高,对初学者十分不友好。此时,简单版的状态管理工具MobX诞生了。Redux的作者Dan也曾经向大家推荐过MobX,并表示在大多数情况下开发人员可以使用MobX来替代Redux。

MobX是一个通过函数响应式编程使状态管理变得简单和可扩展的状态管理库,它和Redux一样是单向数据流。MobX通过Action修改State,由State的改变触发Computed values的更新,最后执行状态变更引起的Reactions。MobX数据流模型如图2-4所示。

图2-4 Mobx 数据流模型

如果开发人员想在React里使用MobX,则需要借助mobx-react。它作用于Reactions环节,在Action触发State更新时通知React刷新视图。例如,在以下代码中,当开发人员调用onUp方法更新count时,mobx-react会自动通知React刷新视图,开发人员不再需要主动调用setState。因为observer在组件初始化渲染时收集了相关值的依赖,所以当observable的变量被更新后就会自动触发视图刷新。

除了以上状态管理方案,React的state也可以用来进行简单的状态管理。然而,由于state没有范式进行约束,所以开发人员很难对状态的更新过程进行追溯。除了state,React Hook中的useState和useReducer也可以进行状态管理。总之,状态管理方案各有优劣,开发人员应该根据项目的实际情况进行综合评估,选择最合适的状态管理方案。