前端工程搭建之开发框架:React

57 阅读22分钟

1. 简介

React是用于构建用户界面的JavaScript库,起源于Facebook的内部项目。Facebook对市场上的基于JavaScript的MVC框架进行调研后,发现它们不能满足需求,于是实现了一套全新的框架,用于架设Instagram的网站,后来于2013年开源,这就是React的起源。

React在当时属于革命性创新,因为它的设计思想很独特,且性能出众,代码逻辑简单,于是迅速流行,生态也越来越繁荣,从UI引擎逐渐变成了一整套前端开发解决方案。而由React衍生出来的React Native更是颠覆性的突破,开发人员可以直接用Web App的方式去实现Native App。

2. React的特性

React在页面渲染上具有很好的表现,主要原因是它引入了虚拟 DOM(Virtual DOM)。虚拟 DOM 是真实 DOM 的 JavaScript 对象表示。例如,浏览器中有如下的真实DOM:

<div id="main"> 
    <h1>主标题</h1> 
    <p style="color: blue;">这是一段段落内容</p> 
</div>

它对应的虚拟DOM用JSON描述为:

{
  tag: "div",
  props: { id: "main" },
  children: [
    { tag: "h1", props: {}, children: "主标题" },
    {
      tag: "p",
      props: { style: { color: "blue" } },
      children: "这是一段段落内容",
    },
  ],
}

当数据发生变化时,React 首先会在虚拟 DOM 上进行操作,例如更新组件的属性或状态。然后,React 会通过比较新旧虚拟 DOM 的差异(这个过程称为 Diffing 算法),计算出最小的 DOM 更新操作,最后将这些更新应用到真实 DOM 上。这样大大减少了不必要的 DOM 操作,从而提高了渲染效率,实现了性能优化。

React还提供了简单灵活的JSX 语法,它是React.createElement方法的语法糖,开发人员可以在JavaScript中直接用HTML语法来描述 UI 结构,它会被编译成 JavaScript 对象,最终用于创建真实的 DOM 元素。比如:

const renderContent = (content: string) => (
    <p style="color: blue;"> {content}</p>
);
renderContent("这是一段段落内容");

在React中,数据流遵循自上而下、单向流动的原则。这意味着数据从父组件流向子组件,子组件不能直接修改父组件的数据,这样使得组件之间的依赖关系变得简单。propsstate就是该原则的具体表现。当父组件更新了子组件的props时,React 会遍历整个组件树,重新渲染依赖该props的子组件。当state更新时,如果这个state作为props传递给了子组件,那么子组件会更新,否则就只会更新当前组件节点。

3. 快速上手

React在设计之初就遵循了渐进式开发的理念,因此对于传统的Web项目也可以增量式地引入React,然后逐步扩展和迁移。

首先,我们在HTML中添加一个空标签,作为React应用的DOM容器。

<body> 
    <div id="root"></div> 
</body>

然后,编写React组件,并且渲染到DOM容器中。

import React from 'react';
import ReactDOM from 'react-dom/client';

// 定义一个函数组件 
const MyComponent = () => { return <div>Hello, React!</div>; };

// 渲染到DOM中
const root = document.getElementById('root');
root.render(<MyComponent />);

这样就实现了一个简单的React应用。

4. 组件

React中最重要的组成部分就是组件。它类似于JavaScript函数,能够接受任意入参,并返回用于描述页面展示内容的React元素(通常是通过JSX语法编写的类似于 HTML 的代码)。

组件可以分为函数组件类组件

4.1 函数组件

函数组件(Function Components)是使用 JavaScript 函数定义的组件。它接收props(属性)作为参数,并返回一个 React 元素。例如:

function Greeting(props) { return <div>Hello, {props.name}!</div>; }

4.2 类组件

类组件(Class Components)是通过继承React.Component类来定义的,它有生命周期方法和状态管理机制。例如:

import React from 'react'; 
class Greeting extends React.Component { 
    render() { return <div>Hello, {this.props.name}!</div>; } 
}

类组件具有多个生命周期方法,用于在组件的不同阶段执行特定的操作。

类组件的生命周期方法
挂载阶段(Mounting)

组件实例被创建并插入DOM时,其生命周期调用顺序如下:

  • constructor:在组件被挂载前,React会调用它的构造函数。它主要用于初始化组件的状态(state)和绑定事件处理函数。

  • static getDerivedStateFromProps:在初始挂载和后续更新时都会调用。它主要用于在props变化时,根据props的值来更新state,使得stateprops之间保持同步。

  • render:它是必需的方法,用于返回要渲染的 React 元素(可以是原生 DOM 元素,也可以是其他组件)。这个方法应该是一个纯函数,即给定相同的输入(stateprops),应该总是返回相同的输出,并且不应该有副作用(如修改stateprops)。通常情况下,组件每次渲染更新都会执行该方法。但是当shouldComponentUpdate返回false时,则不会调用,一般出现在性能优场景。

  • componentDidMount:在组件挂载到 DOM 后立即调用,此时组件已经可以访问 DOM 节点。这个方法通常用于执行一些需要在组件挂载后才能进行的操作,如发送网络请求获取数据、设置定时器、添加事件监听器等。它在组件的整个生命周期中只会执行一次。

更新阶段(Updating)

当组件的propsstate发生变化时会触发更新。组件更新的生命周期方法调用顺序如下:

  • static getDerivedStateFromProps:当组件接收到新的props或者state更新(通过setState等方式)时,这个方法会被再次调用。它根据传入的props的变化更新state,确保stateprops的同步。

  • shouldComponentUpdate:这个方法在组件更新之前调用,用于决定组件是否需要重新渲染。它接收新的propsstate作为参数,并返回一个布尔值。如果返回true,组件会继续更新并重新渲染;如果返回false,组件则不会重新渲染,这可以用于性能优化,避免不必要的渲染。

  • render:如果shouldComponentUpdate返回truerender方法会再次被调用,用于重新计算并返回要渲染的 React 元素。

  • getSnapshotBeforeUpdate:这个方法在render之后、真实 DOM 更新之前调用。它可以获取到 DOM 更新前的一些信息(如滚动位置、元素尺寸等),并返回一个值,这个值会作为第三个参数传递给componentDidUpdate方法。通常用于UI处理。

  • componentDidUpdate:在组件更新后调用。它接收新的props、新的stategetSnapshotBeforeUpdate返回的值作为参数。这个方法通常用于在组件更新后执行一些操作,如根据新的propsstate更新 DOM 元素的样式、继续发送网络请求(如果需要根据新的数据更新)等。如果该方法中需要执行重渲染,那么必须包裹在条件语句中,否则会导致重新渲染的死循环。

卸载阶段(Unmounting)

当从DOM中移除组件时,会依次调用以下生命周期方法:

  • componentWillUnmount:在组件从 DOM 中卸载之前调用。这个方法主要用于清理在componentDidMount或其他生命周期方法中创建的资源,如清除定时器、取消网络请求、移除事件监听器等。如果不清理这些资源,可能会导致内存泄漏或其他性能问题。
错误处理

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

  • static getDerivedStateFromError用于更新状态以响应错误。在渲染阶段如果子组件抛出错误并且被最近的错误边界组件捕获时调用。它可以根据错误来返回一个新的状态值,用于更新组件的state,使得组件能够根据错误情况做出相应的反应,如显示错误消息等。

  • componentDidCatch用于捕获子组件错误。在渲染阶段,如果子组件抛出错误,React 会停止渲染该子组件树,并触发最接近的带有componentDidCatch方法的祖先组件。它用于捕获子组件的 JavaScript 错误,记录错误信息,展示备用 UI(如错误提示页面),以防止整个应用崩溃。

这些方法可以用于ErrorBoundary错误边界组件中,在发生错误时进行UI降级处理,保证页面不会因为局部组件错误而崩溃,官方更推荐使用getDerivedStateFromError

除了上述生命周期方法之外,还有UNSAFE_componentWillMountUNSAFE_componentWillReceivePropsUNSAFE_componentWillUpdate,官方已经不推荐这些方法,在未来的版本中也会逐渐废弃。

类组件的state和setState
state

在类组件中,state是一个包含组件内部数据的对象,用于存储组件的状态信息。它通过在类的构造函数constructor中进行初始化,通常是通过给this.state赋值来定义初始状态。

state的值决定了组件的渲染输出。在render方法中,可以通过this.state来访问状态值,并根据这些值返回不同的 React 元素。

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
      name: 'John'
    };
  }
  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <p>Name: {this.state.name}</p>
      </div>
    );
  }
}
setState

setState是一个用于更新组件state的方法。它可以接收两个参数:第一个参数可以是更新后的对象或更新方法,第二个参数是更新完成的回调方法。例如:

this.setState(state => ({
  count: state.count + 1
}), () => {
  console.log('Count updated:', this.state.count);
});

在更新状态时,有以下几点需要注意:

  • 不要直接修改state:直接修改this.state(如this.state.count = newCount)不会触发组件的重新渲染,并且这违反了 React 的设计原则。应该总是使用setState来更新状态。因为setState不仅会更新状态,还会触发 React 的更新机制,包括重新计算render输出和更新 DOM 等操作。

  • 状态更新的合并和覆盖setState是浅合并状态对象。如果新状态对象中的属性在旧状态对象中已经存在,那么会覆盖同名属性。而如果新状态对象中有旧状态对象不存在的属性,那么这个新属性会被添加到更新后的状态对象中。但是,对于嵌套对象或数组等复杂数据结构,浅合并不会递归地合并内部的子属性。因此,更新状态时,需要注意浅合并引起的覆盖问题。比如:

    this.state = {
      user: { id: 1, name: 'Alice' }
    };
    // 错误:会导致state.id丢失
    this.setState({
      user: {
        name: 'Bob'
      }
    });
    
    // 正确:基于当前状态进行更新
    this.setState((prevState) => ({
      user: {
       ...prevState.user,
        name: 'Bob'
      }
    }));
    
  • setState在 React 中的更新是异步的:如果每次调用setState都进行一次更新,那么意味着render函数会被频繁调用,极大地影响React的性能。所以React 会将多个setState调用合并为一个更新批次,以提高性能。这意味着当调用setState时,组件的state不会立即更新。例如:

    this.state = {
      count: 1,
    };
    
    this.setState({ count: this.state.count + 1 });
    this.setState({ count: this.state.count + 1 });
    

    执行上述代码后,state.count的值是2而不是3。这是由于异步更新,第2次调用setState方法时,获取到的this.state.count依然是旧的值。

  • 使用setState的回调函数获取更新后的状态setState可以接受一个可选的回调函数作为第二个参数。这个回调函数会在状态更新完成并且组件重新渲染后被调用。当需要在状态更新后执行一些操作,并且这些操作依赖于更新后的状态时,就可以在这个回调函数中处理。

4.3 React Hook

函数组件非常符合React一直大力推行的函数式编程思想。然而,在React Hook出现之前,函数组件只能用于无状态、无生命周期的场景。React Hook弥补了函数组件在这方面的限制,使得开发人员可以在不使用类组件的情况下使用状态管理以及其他类组件特性。

常用的状态管理hook有:

  • useState:用于在函数组件中添加状态。它返回一个数组,其中第一个元素是当前状态的值,第二个元素是用于更新该状态的函数。
  • useMemo:用于缓存计算结果,只有在依赖项发生变化时才会重新计算。通常用于在组件重新渲染时,避免重复计算一些比较复杂或者开销较大的函数结果,从而提高组件的性能。

常用的副作用hook有:

  • useEffect:用于在函数组件中执行副作用操作,比如数据获取、订阅事件、手动修改 DOM 等。它可以接收一个副作用函数以及可选的依赖项数组作为参数。副作用函数会在首次渲染后以及依赖项发生变化时执行,通过依赖项参数可以控制副作用函数的执行时机:

    • 不传依赖项:在每次组件渲染后都执行,类似于类组件中的componentDidMountcomponentDidUpdate生命周期方法的组合
    • 依赖项是空数组:只在首次渲染后执行,类似于类组件中的componentDidMount生命周期方法
    • 依赖项是非空数组:只在当首次渲染和指定的依赖项发生变化时执行 此外,副作用函数还可以返回一个函数,它用于清理副作用,比如取消网络请求或者清除定时器等,类似于类组件中的componentWillUnmount生命周期方法
  • useLayoutEffect:与useEffect类似,都是用于在函数组件中处理副作用。不过,useLayoutEffect会在所有的 DOM 变更之后同步调用,它是在浏览器进行布局和绘制之前执行的。这意味着可以使用它来读取 DOM 布局并进行同步的重新渲染。通常用于在需要在浏览器进行绘制之前读取 DOM 布局信息,并且这些操作可能会影响到组件最终的渲染输出。

useLayoutEffectuseEffect的区别

  • useEffect是在浏览器完成布局和绘制之后异步执行的,它不会阻塞浏览器的渲染。
  • useLayoutEffect是在 DOM 变更后同步执行的,会阻塞浏览器的渲染,直到其内部的操作完成。这意味着如果useLayoutEffect内部的操作比较复杂或者耗时,可能会导致页面出现短暂的卡顿。

一般情况下,如果副作用操作不依赖于 DOM 布局信息或者不会影响到组件的初始渲染输出,应该优先使用useEffect。只有当需要在浏览器绘制之前读取 DOM 布局信息、进行同步的状态更新或者布局协调等操作时,才使用useLayoutEffect

使用hook时有一些注意事项:

  • 只能在函数组件的顶层调用 Hook:Hook 必须在函数组件的顶层被调用,不能在循环、条件判断或者嵌套函数中调用。这是因为 React 依靠 Hook 调用的顺序来确定每个 Hook 的状态。如果在条件语句中调用 Hook,那么 Hook 的调用顺序就可能在不同的渲染中发生变化,导致 React 无法正确地跟踪组件的状态和更新逻辑。

  • 不要在普通函数中使用 Hook(非函数组件):如果在普通的 JavaScript 函数(非函数组件)中使用 Hook,React 无法识别和处理这些 Hook,会导致错误。因为普通函数没有组件的生命周期和状态管理机制,无法像函数组件那样正确地与 React 的内部系统交互。

  • 注意 Hook 的依赖项:如果依赖项没有包含所有影响 Hook 内部操作的变量,可能会导致使用过期的缓存结果(如useMemo)或者遗漏更新(如useEffect)。同时,要避免在依赖项中包含在 Hook 内部更新的变量,否则可能会导致无限循环。

  • 使用 useXXX 来命名 Hook:以便与普通函数进行区分。

5. 路由控制

5.1 前端路由

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

在前端应用中,路由描述的是浏览器URL与页面UI之间的映射关系。起初,Web应用的路由控制需要借助后端路由实现,后端服务根据浏览器URL返回不同的HTML文件,然后浏览器接收并选对应的页面。这种路由方式最大的问题是:当浏览器URL变更时需要请求新的HTML文件,整个页面要重新加载,严重影响用户体验。

于是,前端路由应运而生。前端路由能够借助浏览器的能力,在不向服务端发起请求的前提下,根据浏览器的URL变更来映射不同的页面UI,实现页面无刷新跳转,从而大大提升用户体验。前端路由已经成为单页面应用(SPA)的标配。

前端路由方案有以下几种:

  • 哈希路由(Hash Router):哈希路由是通过 URL 的哈希部分(即#后面的内容)来实现路由。当 URL 的哈希值发生变化时,浏览器不会向服务器发送请求,而是会触发hashchange事件。React 应用可以监听这个事件来更新页面显示的内容。

    • 优点:基于浏览器对哈希部分变化的原生支持,兼容性好实现简单,只需要在 JavaScript 中监听hashchange事件,就可以根据哈希值的变化来更新页面内容。
    • 缺点:URL 不美观。搜索引擎(SEO)不友好,对于哈希路由中的内容可能无法正确索引。
  • 浏览器路由(Browser Router):2014年发布的HTML5中,history新增了pushStatereplaceState两个方法,可以改变浏览器的历史记录和 URL,而不会引起页面的刷新。浏览器路由(也称为 History API 路由)就是利用 HTML5 的 History API 来实现路由功能。React 应用可以根据新的 URL 中的path部分来匹配相应的路由并更新页面内容。

    需要注意的是,使用pushStatereplaceState方法时,不会直接触发浏览器的popstate事件。popstate事件通常是在浏览器的历史记录发生后退或者前进时被触发,具体来说,就是点击浏览器的后退/前进按钮,或者调用history.backhistory.forward方法时才会触发。

    • 优点:浏览器路由的 URL 没有哈希值的干扰,更加自然美观。同时,搜索引擎更容易理解和索引页面内容,有利于网站的 SEO。
    • 缺点:浏览器路由基于HTML5 History API实现,对于旧浏览器兼容性稍差,不过随着浏览器的迭代,这个问题的影响范围正在逐渐缩小。另外,由于浏览器路由改变的是真实的 URL 路径,因此需要服务器配置支持,否则直接访问子页面可能出现404错误。
  • 内存路由(Memory Router):内存路由通过模拟浏览器的历史记栈将虚拟URL与页面UI映射起来,它将路由状态保存在内存对象中。这种方案用得比较少,一般用于一些非浏览器的特殊场景,比如React Native、测试等。

5.2 React Router

React Router是一套完整的React路由解决方案,它拥有简单的API和强大的功能。它拆分出4个功能包供开发人员使用:

  • react-router:路由核心功能,包括路由匹配、历史记录管理等基础的路由概念和相关的 API。
  • react-router-dom:为 React Web 应用设计的路由包。基于react-router,加入了在浏览器环境中使用的组件和 API。
  • react-router-native:为 React Native 应用定制的路由包。基于react-router,加入了适合React Native 环境相关的API。
  • react-router-config:静态路由配置,主要用于以配置文件的方式来管理路由。它提供了一种集中式的、声明式的路由配置方法,使得在大型应用中,更方便对路由进行维护和扩展。

路由匹配

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

  • 嵌套关系:React Router 会从根路由开始,按照路由的嵌套关系逐步检查每个路由是否匹配。对于嵌套路由,只有当父路由首先匹配成功后,才会检查子路由。React Router使用路由嵌套的概念来定义页面的嵌套集合,整个集合(命中的部分)都会被渲染。这种嵌套关系允许构建复杂的页面层次结构,就像网站的目录结构一样。

  • 路径语法:路由路径是匹配一个或多个URL的字符串模式,路径语法采用的是开源社区的 path-to-regexp 方案,支持多种形式。路径可以是静态的,如/home/about,也可以是动态的,例如/user/:id,其中:id是一个动态参数。

  • 优先级:路由的优先级是由定义的顺序决定的,先定义的路由具有较高的优先级。当一个 URL 可以匹配多个路由路径时,优先匹配排在前面的路由。这是为了避免模糊的路径匹配导致的不确定性。

路由组件

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

  • Router:作为最外层的容器,一般包裹在React应用的顶层,为应用提供组件化的路由响应能力。根据路由方案的不同,主要有以下几种实现:

    • HashRouter:哈希路由
    • BrowserRouter:浏览器路由(History API路由)
    • MemoryRouter:内存路由,模拟记录了页面的历史栈情况,属于有状态路由。可以用于单元测试或者内部应用。
    • StaticRouter:内存路由,不记录历史栈,属于无状态路由。主要用于服务端渲染场景。
  • Route:用于定义路由规则,主要由path和组件渲染两部分组成。path用于匹配URL部分,组件渲染的方式由三种属性控制:componentrenderchildren。对于渲染的子组件,Route会将matchlocationhistory三个属性注入props,以供子组件获取路由信息。

  • Switch:在 React Router v5 及以前版本中,Switch组件用于包裹一组Route组件,它会从这些Route组件中选择第一个命中路由路径的Route来渲染,然后忽略其余的。如果没有使用Switch,就会渲染所有命中路由路径的Route组件。

  • Redirect:用于重定向的路由组件,提供组件级别的导航能力,进行页面重定向。

  • Link:路由跳转的链接,它类似于 HTML 中的<a>标签,但它不会导致整个页面的刷新。使用to参数来定义要跳转的目标路径

  • NavLink:是Link组件的增强版本,可以根据当前路由是否匹配to属性指定的路径来自动添加或移除样式。这在构建导航栏时非常有用,可以直观地向用户展示当前选中的导航项。

基于以上组件,就可以搭建一个简单的路由系统来实现前端路由功能。如果要实现高阶路由功能,可以基于react-router进行二次开发,以及结合一些比较成熟的开源社区方案。比如要实现路由切换的过渡动画,可以使用react-transition-group过渡动画库。

6. 状态管理

当页面比较简单时,开发人员可以采用简单的状态管理方案。比如直接使用React的 state ,或者React Hook中的useStateuseReducer进行状态管理。

随着应用变得复杂,信息越来越多,交互也越来越复杂,以对数据进行更新、维护的复杂度也大大提升。这时候可能需要引入状态管理解决方案,以增加工程的可维护性。

MVC 架构模式最初是在 Smalltalk-80 编程语言的环境中被广泛应用,它把业务数据模型(Model) 与用户界面 视图(View) 隔离,通过控制器(Controller) 作为桥梁进行管理。它实现了视图与状态管理的隔离,但是有个致命的缺陷:数据流混乱,比如存在多个视图关联多个模型、视图和模型之间可能有双向数据绑定等情况,数据流可能会变得复杂且难以控制。

image.png

为了解决这些问题,Facebook提出了 Flux 模型,它解决了MVC 数据流混乱的问题。它是一种基于Dispatcher的前端应用架构模式,核心思想是单向数据流

image.png

随着Flux的流行,开源社区发现了它的一些问题,并出现了大量的优化方案,其中 Redux 脱颖而出,它有三大原则:

  • 单一数据流:确保应用有唯一数据源,避免数据流混乱
  • 状态只读:只能通过Action进行状态修改
  • 状态修改只能由纯函数完成:确保状态修改没有副作用,特=特定输入必定对应特定输出

image.png

虽然 Redux 提供了有效且高质量的状态管理,但是它的学习成本偏高。后来,简单版的状态管理工具 MobX 诞生了,它同样是单向数据流,使用函数响应式编程,提供更加简单、可扩展的状态管理。

image.png

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

// store.js
import { observable, action } from'mobx';
class CartStore {
  @observable itemCount = 0;
  
  @action
  addItem = () => {
    this.itemCount++;
  }
}
const cartStore = new CartStore();

// app.jsx
import React from'react';
import { observer } from'mobx-react';
import { CartStore } from './store';

@observer
class CartComponent extends React.Component {
  cartStore = new CartStore();
  render() {
    return (
      <div>
        <p>购物车商品数量: {this.cartStore.itemCount}</p>
        <button onClick={this.cartStore.addItem}>添加商品</button>
      </div>
    );
  }
}

总之,状态管理方案各有优劣,开发人员可以根据项目的实际情况进行评估,选择最合适的状态管理方案。