Skip to content

React 基础巩固

作者:Atom
字数统计:14.2k 字
阅读时长:50 分钟

React 的生态万紫千红, 可谓是繁花似锦; 从这些框架及工具中衍生了各种的设计模式及理论知识, 本文就来盘点记录下相关的知识点

组件库设计

设计一个前端组件库的流程包括需求分析、技术选型、开发、测试、文档和发布等步骤。以下是各个步骤的详细流程和开发通用组件的考虑

一、需求分析

  • 业务需求:明确组件库的目标用户和主要业务场景,确保组件的功能符合实际需求。
  • 组件规范:根据用户需求制定组件的设计规范和开发标准,如UI风格、交互规范等。

二、技术选型

  • 框架选择:根据技术栈选择框架(如React、Vue或原生JS),考虑到组件库的易用性和性能。
  • 构建工具:选择合适的构建工具,如Webpack、Rollup、Vite 等,以实现代码分包、按需加载和Tree Shaking等优化。
  • 样式管理:使用CSS Modules、SASS/LESS或CSS-in-JS等方案来组织组件样式,并考虑样式隔离和按需加载。

三、组件设计与开发

  • 分层设计:将组件划分为基础组件、业务组件和高阶组件,基础组件一般为Button、Input等,业务组件通常是复杂的UI模块。
  • API设计:提供一致的API接口,注意属性的命名和传参方式的规范化,使其易读且可预测。
  • 国际化支持:考虑语言的扩展性,提供国际化方案。
  • 主题支持:设计组件时提供主题变量,让用户可以定制主题,或者采用 CSS 变量来动态切换主题。

四、开发流程

  • 组件封装:按照单一职责原则,确保组件的职责简单明确。
  • 代码复用:将重复功能抽象成工具函数或公共逻辑。
  • 测试:为组件编写单元测试和快照测试,确保组件行为的一致性;可以使用Jest和React Testing Library等工具。
  • 无障碍(Accessibility):确保组件兼容辅助技术(如屏幕阅读器),提高易用性。

五、文档与示例

  • 自动化文档生成:结合Storybook等工具,自动生成文档和组件示例,方便开发者理解和使用组件。
  • 组件展示:为每个组件提供实际使用场景的示例代码和效果展示,并写明API和使用注意事项。
  • 版本管理:采用语义化版本(Semantic Versioning),便于组件库的迭代升级。

六、发布与维护

  • 打包与发布:将组件库打包为多个模块,并支持Tree Shaking;发布到npm等公共仓库。
  • 持续集成:通过CI/CD工具(如GitHub Actions、Jenkins等)自动化构建和发布流程,减少人为操作带来的风险。
  • 更新日志和版本迭代:在发布新版本时,提供更新日志,记录修复和新功能,方便用户跟进版本更新。

开发通用组件的考虑

  • 单一职责原则: 良好的设计中, 一个组件只干一件事情; 单一职责在组件中决定了组件如何拆分; 保持组件的单一职责将会非常重要。

  • 组件的命名规范: 良好的组件设计中, 组件名应该是能够见名知意的, 特别征对组件库来说统一命名规则是必须的

  • 组件的分层和结构性: 根据组件的职能进行分类, 将组件分为容器组件、基础组件、业务组件。将他们放到不同的组件文件夹中,这块就决定了组件的结构性

  • 组件的健壮性和封装性: 这块涉及到组件的参数和属性设计、输入校验检查等,确保组件的健壮性,提供错误边界,避免出现异常导致页面崩溃。而封装性决定了组件如何组织代码结构;

  • 组件的可组合性: 这块设计到整个组件如何拼接整个应用, 也就是组件的可复用性和扩展性;

  • 组件的可测试性: 良好的组件设计会让自动化测试变得异常简单, 这也涉及到良好的组件拆分

  • 组件的性能优化: 减少重渲染、优化事件处理等,确保组件在高频率使用下的表现。

  • 组件的响应式支持: 对于适配PC和移动端的组件,提供响应式布局或自适应设计。

通过上述流程和考虑,可以打造出一个高质量、易维护且实用的组件库。

状态管理原则

现在各种主流框架都会有各种的状态管理库, 而且框架中亦能自己管理状态; 所以问题就来了: 到底什么样的状态用框架自带的状态来管理(局部状态),什么情况使用状态管理类库呢?

其实在历经这么多年的发展, 早已有最佳实践, 现在笔者来总结下:

状态拆分原则

  1. 公共通用数据放状态类库 (redux/vuex), 否则私有数据放框架局部 (react/vue) 中管理

  2. 分散虚复用数据放状态类库, 频繁变动的原子级别数据放局部管理

  3. 刷新需要持久化的放在 localstorage 中

类组件的生命周期

React 的生命周期

组件的逻辑复用

在React中,可以通过以下几种方式实现逻辑复用

Hooks

自定义Hooks是React中最推荐的逻辑复用方式。可以将组件的通用逻辑抽离为自定义Hooks,并在不同组件中调用。例如,可以将数据获取、表单处理等逻辑封装为Hooks。

主要缺点: 复杂嵌套的Hook逻辑可能导致代码难以追踪和调试。

jsx
// useFetch.js
import { useState, useEffect } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(url)
      .then(response => response.json())
      .then(data => {
        setData(data);
        setLoading(false);
      });
  }, [url]);

  return { data, loading };
}

export default useFetch;

此外, 还可以组合多个内置Hooks来复用逻辑。例如,useStateuseEffect 可以组合处理复杂逻辑,再通过自定义Hook进行复用。

Render Props

Render Props是一种复用逻辑的模式,通过向子组件传递一个渲染函数(Render Prop)来动态生成子组件。Render Props模式通常用于解决UI逻辑复用,但由于其语法复杂度较高,使用率逐渐被自定义Hooks替代。

主要缺点: 语法复杂,容易造成“嵌套地狱”。会引入额外的渲染层,影响代码可读性。

jsx
// MouseTracker.js
import { useState } from 'react';

function MouseTracker({ render }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  function handleMouseMove(event) {
    setPosition({
      x: event.clientX,
      y: event.clientY
    });
  }

  return <div onMouseMove={handleMouseMove}>{render(position)}</div>;
}

// 使用时
<MouseTracker render={({ x, y }) => <p>Mouse position: {x}, {y}</p>} />

高阶组件(Higher-Order Components, HOC)

高阶组件是一种接受组件并返回新组件的函数。它通过包裹组件来复用逻辑,并能在组件外部增强或修改组件的行为。虽然这种模式在Class组件中较常见,但在函数组件中也可应用,不过在现代React开发中逐渐被自定义Hooks替代。

主要缺点: 容易引入“包裹地狱”,影响代码结构和调试。不适用于复杂的状态共享,在现代React中使用较少。

jsx
// withLoading.js
import React from 'react';

function withLoading(Component) {
  return function WithLoadingComponent({ isLoading, ...props }) {
    if (isLoading) return <p>Loading...</p>;
    return <Component {...props} />;
  };
}

export default withLoading;

// 使用时
const UserListWithLoading = withLoading(UserList);

Context API

如果多个组件需要共享状态或逻辑,可以使用Context API。Context在组件树中创建一个共享的状态,避免将逻辑一层层传递下去。对于需要全局共享的逻辑或数据,Context是非常合适的选择。

主要缺点: 状态变化会导致依赖Context的所有组件重新渲染,需注意性能。复杂的Context逻辑可能影响代码的清晰度和可维护性。

javascript
// ThemeContext.js
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  return useContext(ThemeContext);
}

// 使用时
import { useTheme } from './ThemeContext';

function ThemedComponent() {
  const { theme } = useTheme();
  return <div className={theme}>This is a {theme} themed component!</div>;
}

组合(Composition)

通过组合的方式,将多个组件的逻辑进行组合,形成更高层次的复用。例如,可以将重复的布局结构抽离成单独的组件,然后通过组合的方式复用。

主要缺点: 仅适用于UI结构复用,不适合用于业务逻辑复用。需要合理的分层设计,否则容易产生复杂的组合嵌套。

javascript
function Layout({ header, sidebar, content }) {
  return (
    <div>
      <header>{header}</header>
      <aside>{sidebar}</aside>
      <main>{content}</main>
    </div>
  );
}

// 使用时
<Layout
  header={<Header />}
  sidebar={<Sidebar />}
  content={<Content />}
/>

Utility Functions

把与UI无关的通用逻辑提取到独立的工具函数中。例如,数据处理、数学计算等与组件状态无关的逻辑可以提取到 utils.js 文件中,以纯函数的形式实现复用。

主要缺点: 只能用于与UI无关的逻辑,不适用于React状态和生命周期的复用。功能单一,难以满足复杂的状态逻辑复用需求。

React 与 Vue 的区别

React 18 和 Vue 3 各有优势,React 倾向于函数式编程,拥有灵活、功能多样的社区生态,而 Vue 更接近于MVVM模式,组件状态管理和响应式设计直观、易用。选择框架时,开发团队的技术背景、项目需求和性能要求都是需要考量的因素。

React 18 和 Vue 3 是前端开发领域中广泛使用的两个框架,它们在设计思想、响应式系统、性能优化、生态系统以及源码实现等方面都有显著差异。以下是它们的主要区别:

设计思想

React 18

  • 声明式 UI:React通过声明式语法,关注“组件应该是什么样子”,而不是如何更新状态来达到目标。
  • 函数式编程:推崇无状态、不可变数据和函数式编程思维,强调纯函数组件(Pure Function)。
  • “只关注视图”:React核心专注于视图层,不强制规定状态管理和路由等配套库的使用。

Vue 3

  • MVVM 模式:Vue 采用双向数据绑定设计思维,更加贴近MVVM模式,适合构建双向绑定的复杂UI。
  • 灵活组合式 API:Vue 3 推出的 Composition API 让逻辑复用更加直观,开发体验更接近函数式编程。
  • 全家桶理念:Vue 提供包括 Vuex、Vue Router 等官方解决方案,形成相对完整的前端生态系统。

响应式原理

React 18

  • 不可变数据与状态驱动:React 的响应式由组件的状态和不可变数据驱动,状态变化会触发组件重新渲染。
  • Virtual DOM diff:React 通过 Virtual DOM 和 Diff 算法,找到数据变化点,进行最小化的 DOM 更新。
  • Fiber 架构:React 18 的 Fiber 架构可以更细粒度地处理更新,支持并发模式(Concurrent Mode),使UI渲染和交互更加流畅。

Vue 3

  • 依赖追踪与 Proxy:Vue 3 使用 Proxy 进行响应式追踪,实现了更高效的依赖追踪和更灵活的响应式系统。
  • 模板自动更新:Vue 的响应式系统会追踪数据依赖,自动更新受影响的模板部分,而无需整体重新渲染。
  • 细粒度更新:相比 React 的 Virtual DOM,Vue 采用细粒度的依赖追踪,可以避免不必要的重渲染,更加高效。

性能

React 18

  • Concurrent Mode:React 18 引入的并发模式,通过分阶段渲染使复杂应用在高负载下保持流畅的用户体验。
  • 自动批处理更新:在事件处理函数和异步代码中自动批量更新,减少渲染次数,优化性能。
  • 服务端渲染(SSR)优化:React 18 支持并发式 SSR,减少了 TTFB(首字节时间),优化了首屏渲染体验。

Vue 3

  • 响应式 Proxy:Vue 3 的响应式 Proxy 提高了性能,避免了 Vue 2 中 Object.defineProperty 的性能问题。
  • Tree Shaking:Vue 3 更好地支持 Tree Shaking,通过按需引入和优化打包减小文件体积。
  • 静态提升与编译优化:Vue 3 会在编译过程中将不变的内容静态提升,从而减少重渲染时的性能开销。

生态系统

React 18

  • 灵活生态:React 本身是轻量的视图库,状态管理、路由和测试框架都有丰富的社区选择(如 Redux、React Router、Jest 等)。
  • 服务端渲染和静态生成:React 社区有 Next.js 等热门框架,提供强大的 SSR 和静态站点生成能力。
  • React Native:React 的生态延伸至跨平台开发,React Native 实现了原生应用开发的跨平台支持。

Vue 3

  • 官方全家桶:Vue 提供 Vuex、Vue Router 等官方库,形成完整的前端应用开发生态。
  • Nuxt.js:Vue 的 SSR 和静态站点生成框架Nuxt.js 在Vue社区内非常流行,用于高性能的服务端渲染。
  • 移动端支持:Vue 生态支持Weex、Uni-app等移动端框架,可以跨平台开发移动应用。

源码层面

React 18

  • Fiber 架构:Fiber 是 React 重新实现的协调器,使得渲染任务可以中断和恢复,带来流畅的并发渲染体验。
  • Hooks API:Hooks 是在 React 16.8 引入的,以闭包的方式实现了组件内部状态管理和逻辑复用。
  • Virtual DOM 与 Diff 算法:React 的 Virtual DOM 和 Diff 算法基于树结构的对比更新,优化了整体渲染流程。

Vue 3

  • 响应式系统:Vue 3 使用 Proxy 替代 Vue 2 的 Object.defineProperty,更高效地实现响应式追踪和依赖管理。
  • 模板编译器:Vue 内置的模板编译器在构建时将模板编译成原生 JavaScript 代码,从而优化了运行时性能。
  • Composition API:Vue 3 的 Composition API 提供了逻辑复用的实现,通过独立的函数管理状态和生命周期,便于组合与扩展。

社区与学习曲线

React 18

  • 学习曲线较陡:React 的灵活性使得它学习曲线较陡,尤其是对状态管理和路由的选择较多,入门时需要额外了解。
  • 强大的社区支持:React 社区活跃,相关资源和文档丰富,第三方库和工具种类繁多,便于扩展。

Vue 3

  • 学习曲线平缓:Vue的设计更贴近传统前端开发,入门容易,特别是选项式API对初学者友好。
  • 社区生态稳定:Vue 社区相对集中,官方全家桶提供的功能齐全,有助于统一的开发规范和降低学习成本。

虚拟DOM

虚拟DOM在第一次渲染页面的时候, 并没有什么优势, 速度肯定比直接操作原生DOM API 要慢一些, 它的真正价值体现在更新阶段。在更新阶段可以通过DIFF算法最小化找出差异, 然后修改变化部分的虚拟DOM

虚拟DOM存在的最大意义在于对多平台的支持, 通过关键变量的变化引起虚拟DOM的重新计算UI, 然后根据UI的变化执行具体各自平台宿主环境的API

状态管理

Redux

设计理念

使用简单数组和对象来表示状态, 使用对象来描述状态的改变, 状态的改变逻辑必须是纯函数, 规范了状态管理的思想, 其主要有三个特点:

  • 单一数据源
  • 所有数据都是只读的(只能通过 dispath('name',action)来触发)
  • 处理 action 只是新生成对象而不修改原状态

其实状态管理的本质就两点:

  • 单例对象存储状态: Store 持有状态/数据,并且对外提供增删改查
  • 观察者模式通知更新: 即Store的状态变化了,通过事件通知告诉使用者最新的状态

因此保证了 Redux 的数据可靠性, 可测试性, 无副作用, 数据的来源是清晰的, 数据的修改也能追溯

set/get 的状态管理混乱, 无数据可靠性(不清楚 set 是否会覆盖其他的对象), 无法追溯数据来源(直接 get 太混乱, 导致复用性差), 不便于测试

createStore

Redux中的createStore函数用于创建一个store对象, 下面代码实现了一个最简单的createStore函数, 用于理解原理

js
const getRandomString = (length) => Math.random()
  .toString(36)
  .substr(2, length)
  .split('')
  .join('.')

export const createStore = (reducer, defaultState, enhanced) => {
  let state = defaultState;
  let listeners = [];

  const getState = () => state;

  const dispatch = (action) => {
    state = reducer(state, action);
    listeners.forEach(listener => listener());
  }

  const subscribe = (listener) => {
    listeners.push(listener);
    return () => {
      listeners = listeners.filter(l => l !== listener)
    }
  }

  dispatch({
    type: `@redux/INIT${getRandomString(7)}`
  });

  return {
    getState,
    dispatch,
    subscribe
  }
}

bindActionCreators

Redux中的bindActionCreators函数用于将动作绑定到dispatch函数上。免除每次都需要手动dispatch的繁琐操作

语法

ts
bindActionCreators(actionCreators, dispatch)
  • actionCreators 是一个对象或函数,如果是对象,则对象的每个属性都是一个动作创建器,如果是函数,则函数的返回值是一个动作创建器
  • dispatchReduxdispatch 函数
ts
export function bindActionCreators(actionCreators, dispatch) {
  if (typeof actionCreators == "function") {
    return getAutoDispatchActionCreator(actionCreators, dispatch);
  }
  else if (typeof actionCreators === "object") {
    const result = {};//返回结果
    for (const key in actionCreators) {
      if (actionCreators.hasOwnProperty(key)) {
        const actionCreator = actionCreators[key];//取出对应的属性值
        if (typeof actionCreator === "function") {
          result[key] = getAutoDispatchActionCreator(actionCreator, dispatch)
        }
      }
    }
    return result;
  }
  else {
    throw new TypeError("actionCreators must be an object or function")
  }
}
ts
// 通过Action创建函数返回一个Action
export const createAddUserAction = (infos) => ({
  type: "ADD_USER",
  payload: infos
})
export const createDeleteUserAction = (id) => ({
  type: "DELETE_USER",
  payload: id
})
ts
import { createStore } from 'redux'
import reducer from './reducers'
import { createAddUserAction, createDeleteUserAction } from './actions'

const store = createStore(reducer)

// 创建Action
const addUserAction = createAddUserAction({ id: 1, name: "张三", age: 18 })
const delUserAction = createDeleteUserAction(1)

// 使用方式
store.dispatch(addUserAction)
store.dispatch(delUserAction)
ts
import { createStore, bindActionCreators } from 'redux'
import reducer from './reducers'
import { createAddUserAction, createDeleteUserAction } from './actions'

const store = createStore(reducer)

// 通过bindActionCreators将动作绑定到dispatch函数上
const userActions = bindActionCreators({
  addUser: createAddUserAction
  delUser: createDeleteUserAction
}, store.dispatch)

// 使用方式
userActions.addUser({ id: 1, name: "张三", age: 18 })
userActions.delUser(1)

combineReducers

Redux中的combineReducers函数用于将多个reducer合并为一个reducer

combineReducers函数的作用是将多个reducer合并为一个reducer,以便 Redux 可以轻松地管理多个状态

语法

ts
combineReducers(reducers)

其中,reducers是一个对象,每个属性都对应一个reducer

ts
// 将多个reduces对象传递进去, 然后仅返回一个合并后的reducer

const combineReducers = ( reducers ) => {
  return ( state = {}, action ) => {
    return Object.keys(reducers).reduce(
      ( nextState, key ) => {
          nextState[key] = reducers[key](
              state[key],
              action
          );
          return nextState;
      },
      {}
    );
  };
};

export default combineReducers;
JavaScript
// 例如,假设我们有以下两个`reducer`

const todosReducer = (state = [], action) => {
  switch (action.type) {
    case "ADD_TODO":
      return [...state, action.payload];
    case "REMOVE_TODO":
      return state.filter((todo) => todo.id !== action.payload);
    default:
      return state;
  }
};

const visibilityReducer = (state = "SHOW_ALL", action) => {
  switch (action.type) {
    case "SHOW_COMPLETED":
      return "SHOW_COMPLETED";
    case "SHOW_ACTIVE":
      return "SHOW_ACTIVE";
    default:
      return state;
  }
};

// 要将这两个reducer合并为一个reducer,可以使用以下代码:
const rootReducer = combineReducers({
  todos: todosReducer,
  visibility: visibilityReducer,
});

// 现在,我们可以将rootReducer传递给createStore函数,以创建一个新的store:
const store = createStore(rootReducer);

合并后, store将包含todos和visibility两个状态。

官方combineReducers函数还支持默认值参数。默认值参数用于指定如果reducer没有指定默认值时,应该使用的值

例如,以下代码将todosReducer的默认值设置为空数组

JavaScript
// 在这种情况下,如果todosReducer没有指定默认值,则todos的初始值将为空数组。
const rootReducer = combineReducers({
  todos: todosReducer,
  visibility: visibilityReducer,
}, {
  todos: [],
});

applyMiddleware

ReduxMiddleware 用于拦截 dispatch 函数的调用

中间件的本质

Middleware其本质是一个调用后可以得到dispatcht创建函数的函数

通俗的说就是调用Middleware函数后, 返回一个dispatch函数, 该dispatch函数可以接收一个action对象, 并且可以在dispatch函数内部对action对象进行处理, 然后再调用原始的dispatch函数

以下是中间件使用的两种语法

语法

ts
// 应用中间件方式一
const store = createStore(reducer, applyMiddleware(logger1, logger2))

// 应用中间件方式二
const store = applyMiddleware(logger1, logger2)(createStore)(reducer)

下面我们来对其内部原理做一个简单的实现

ts
export function compose(...funcs) {
  // 如果没有要组合的函数,则返回的函数原封不动的返回参数
  if(funcs.length === 0) return args => args
  else if(funcs.length === 1) return funcs[0]
  return function (...args) {
    let lastReturn = args // 记录上一个函数返回的值,它将作为下一个函数的参
    for (let i = funcs.length - 1; i >= 0; i--) {
      const func = funcs[i]
      lastReturn = func.apply(null, Array.isArray(lastReturn) ? lastReturn : [lastReturn])
    }
    return lastReturn
  }
  // 如果使用reduce, 可以简化为
  // return funcs.reduce((f1, f2) => (...args) => f1(f2(...args)))
}
ts
import { compose } from './compose'

export function applyMiddleware(...middlewares) {
  //给我创建仓库的函数
  return function (createStore) {
    //下面的函数用于创建仓库
    return function (reducer, defaultState) {
      //创建仓库
      const store = createStore(reducer, defaultState);
      let dispatch = () => { throw new Error("目前还不能使用dispatch") }
      const simpleStore = {
        getState: store.getstate,
        dispatch: store.dispatch
      }

      // 给dispatch赋值
      // 根据中间件数组,得到一个dispatch创建函数的数组
      const dispatchProducers = middlewares.map(mid => mid(simpleStore))
      const dispatchProducer = compose(...dispatchProducers)
      dispatch = dispatchProducer(store.dispatch)

      return {
        ...store,
        dispatch
      }
    }
  }
}
ts
import { applyMiddleware } from './applyMiddleware'
import { createStore } from './createStore'
import { reducer } from './reducer'

const logger1 = store => next => action => {
  console.log('dispatching', action)
  const result = next(action)
  console.log('next state', store.getState())
  return result
}
const logger2 = store => next => action => {
  console.log('dispatching', action)
  const result = next(action)
  console.log('next state', store.getState())
  return result
}

applyMiddleware(logger1, logger2)(createStore)(reducer)

connect

Reduxconnect 函数用于将组件与 Redux 状态绑定。它接收两个参数:mapStateToPropsmapDispatchToProps

  • mapStateToProps 函数用于将 Redux 状态映射到组件的 props。它返回一个对象,该对象的属性对应组件的 props

  • mapDispatchToProps 函数用于将 Redux 动作映射到组件的 props。它返回一个对象,该对象的属性对应组件可以使用的动作

connect 函数的实现原理

  • 创建一个新的组件类
  • mapStateToPropsmapDispatchToProps 函数作为 props 传递给新的组件类
  • 在新的组件类的构造函数中,将 Redux 状态和动作注入到组件中
  • 在新的组件类的 render 函数中,使用 mapStateToProps 函数将 Redux 状态映射到组件的 props
  • 在新的组件类的 render 函数中,使用 mapDispatchToProps 函数将 Redux 动作映射到组件的 props

下面是自己实现的一个简单的connect函数

tsx
// 函数式写法
export const connect = (mapStateToProps: any, mapDispatchToProps: any) =>
  (WrappedComponent: any) => {
    // 1. 检查 WrappedComponent 是否为 React 组件
    if (typeof WrappedComponent !== 'function') {
      throw new Error('WrappedComponent 必须是一个 React 组件。')
    }

    // 2. 创建一个新的组件
    const ConnectedComponent = (props: any) => {
      // 4. 渲染 WrappedComponent
      return (
        <WrappedComponent
          // 3. 将 Redux 状态和动作映射到 props 上
          {...props}
          {...mapStateToProps(props)}
          {...mapDispatchToProps(props.dispatch)}
        />
      )
    }

    ConnectedComponent.displayName = 'Connected' + WrappedComponent.displayName

    // 5. 返回新的组件
    return ConnectedComponent
  }
tsx
import React from "react";
import { connect } from "./connect";

class TodoList extends React.Component {
  render() {
    return (
      <ul>
        {this.props.todos.map((todo) => (
          <li key={todo.id}>
            {todo.text}
            <button onClick={() => this.props.onRemoveTodo(todo.id)}>删除 </button>
          </li>
        ))}
      </ul>
    );
  }
}

const mapStateToProps = (state) => ({
  todos: state.todos,
});

const mapDispatchToProps = (dispatch) => ({
  onRemoveTodo: (id) => dispatch({ type: "REMOVE_TODO", payload: id }),
});

const ConnectedTodoList = connect(mapStateToProps, mapDispatchToProps)(TodoList);

export default ConnectedTodoList;

SetState

React经过多个版本的迭代, setState同步和异步的场景也发生了变化, 作者通过React-setState同步还是异步的测试Demo验证后, 最终得出如下结论

⭐️ 经过测试React@18以下版本:

  • 不受控的事件回调中, setState是同步更新的, 即界面值和js中的log值是一致的
  • 在受控的回调中, setState是异步批量更新的, js中的同步输出的日志log值是上一次的值
  • 不受控的事件回调包括setTimeout/setInterval/Promise.then/addEventListener

⭐️ 在React@18以上的版本:

  • setState均是异步批量更新的

⭐️ 问题: setState既然是异步, 如何获取最新的状态值

  • setState(oldv => { console.log(oldv); return newv }) 通过回调方式获取旧值,返回新值
  • useEffect中的回调也是最新值
  • useRef() 通过这个api来存储变化的值, 随时都是最新值

⭐️ useEffectuseLayoutEffect区别

  • useLayoutEffect等里面的代码执行完后才更新视图,会忽略掉setState()的那次更新, DOM操作一般可以放进这个回调
  • useEffect会先更新初始值再更新改变后的随机数。有种一闪而过的感觉

通信方式

  • props + callback
  • ref 方式
  • 状态管理库ReduxMobx
  • context上下文
  • 事件总线event-bus

合成事件

React@18中, 下面代码的事件顺序如下:

  • document原生捕获
  • 父元素React事件捕获
  • 子元素React事件捕获
  • 父元素原生捕获
  • 子元素原生捕获
  • 子元素原生冒泡
  • 父元素原生冒泡
  • 子元素React事件冒泡
  • 父元素React事件冒泡
  • document原生冒泡
tsx
const EventOrder = () => {
  const divRef = useRef();
  const pRef = useRef();

  const parentBubble = () => {
    console.log("父元素React事件冒泡");
  };
  const childBubble = () => {
    console.log("子元素React事件冒泡");
  };
  const parentCapture = () => {
    console.log("父元素React事件捕获");
  };
  const childCapture = () => {
    console.log("子元素React事件捕获");
  };

  useEffect(() => {
    divRef.current.addEventListener(
      "click",
      () => {
        console.log("父元素原生捕获");
      },
      true
    );
    divRef.current.addEventListener("click", () => {
      console.log("父元素原生冒泡");
    });

    pRef.current.addEventListener(
      "click",
      () => {
        console.log("子元素原生捕获");
      },
      true
    );
    pRef.current.addEventListener("click", () => {
      console.log("子元素原生冒泡");
    });

    document.addEventListener(
      "click",
      () => {
        console.log("document原生捕获");
      },
      true
    );
    document.addEventListener("click", () => {
      console.log("document原生冒泡");
    });
  }, []);

  return (
    <div ref={divRef} onClick={parentBubble} onClickCapture={parentCapture}>
      <p ref={pRef} onClick={childBubble} onClickCapture={childCapture}>
        事件执行顺序
      </p>
    </div>
  );
};

闭包陷阱

外层函数outer, 创建了内层函数inside, 内层函数执行时访问了外层函数的AO(活动对象)中的value属性, 因此形成了一个闭包

ts
function outer(value) {
  // 形成了一个闭包
  return function inside() {
    console.log(value)
  }
}

// 尝试多次调用outer外层函数
const first = outer(1)
const second = outer(2)

first() // 1
second() // 2

为了避免每次执行outer函数, 都创建新的闭包函数inside,我们将内部的inside缓存了起来

ts
const cache = { current: null }
function outer(value) {
  // 形成了一个闭包
  if (!cache.current) {
    cache.current = () => {
      console.log(value)
    }
  }
  return cache.current
}

// 尝试多次调用outer外层函数
const first = outer(1)
const second = outer(2)
first() // 1
second() // 1

虽然现在不再创建新闭包, 但是却始终返回第一次创建的闭包。 而第一次创建的闭包只能访问当时的AO(活动对象),而不能访问最新创建的AO。那么缓存的函数也就是一个过时的闭包

为了修复我们案例中的闭包陷阱, 只需要在每次值发生变化时就重新创建闭包函数就能解决

ts
let prev = null
const cache = { current: null }
function outer(value) {
  // 形成了一个闭包
  if (!cache.current || value !== prev) {
    cache.current = () => {
      console.log(value)
    }
  }
  prev = value
  return cache.current
}

React的函数式组件中, hooks以及各种回调都是闭包, 而每次状态的变化都会触发重新执行该函数式组件。与上面的小例子原理相同,就非常容易形成闭包陷阱

主要原因是由于依赖项的更新不及时,就会导致函数内部保持了对上一次组件刷新时定义的作用域,从而导致闭包陷阱。也就会导致组件渲染时,state 或其他依赖项的值不正确

注意

使用React函数式组件时, Hooks和回调中一定要添加依赖的数组项, 避免闭包陷阱导致的各种问题

ReactHooks

ReactHooksReact的一套新的特性, 通过Hooks可以让函数组件具有类组件的能力

ReactHooks解决了类组件的一些历史问题

  • 状态难以复用(HOC 导致嵌套层级过多)

  • 复杂组件难以维护(逻辑分散且生命周期太多)

  • this 指向问题

下面来浅析一些官方内置Hooks的使用方式

useState

语法

ts
const [state, setState] = useState(initData)
  • initData: 默认初始值,有两种情况:函数和非函数,如果是函数,则函数的返回值作为初始值
  • state: 数据源,用于渲染UI 层的数据源
  • setState: 改变数据源的函数,可以理解为类组件的 this.setState

如果 initData 需要通过复杂计算获得,则需要传入一个函数,在函数中计算并返回 initData,此函数只会在初始渲染时被调用一次

应该特别注意useState初始值为函数的这种情况

tsx
import { useState } from "react";

const Index: React.FC<any> = (props) => {
  // 这种方式跟直接传入一个对象是不一样的, 这种方式只会在初始化时执行一次
  const getInitState = () => ({ number: props.number })
  // 此时, getInitState是一个更新函数,所以React尝试调用它并存储结果作为初始值
  const [count, setCount] = useState(getInitState)

  return (
    <>
      <div>number: {count.number}</div>
       <button onClick={() => setCount({ number: count.number + 1 })}>+</button>
      <button onClick={() => setCount(count)}>setState</button>
    </>
  );
};

export default Index;

useImperativeHandle、forwardRef

React中, 父组件如果需要调用子组件的方法时, 可以将ref传递到子组件。 然后通过forwardRef进行包装后, 子组件就能获取到ref。 然后通过useImperativeHandle暴露出自己的API供父组件调用

在实际的开发中, 应用场景一般是表单的校验, 例如:

tsx
import React, { useRef } from "react";

const Parent = () => {
  const childRef = useRef();

  const getFocus = () => {
    childRef.current.focus();
  };

  const validate = () => {
    console.log(childRef.current.validate());
  };

  return (
    <div>
      <Child ref={childRef} />
      <button onClick={getFocus}>获取焦点</button>
      <button onClick={validate}>校验</button>
    </div>
  );
};
tsx
import React, { forwardRef, useImperativeHandle, useRef } from "react";

// react中限制了ref无法作为props来直接获取, 因此需要借助forwardRef来获取父ref属性
const Child = forwardRef((props, ref) => {
  const inputRef = useRef();

  useImperativeHandle(ref,
  () => ({
    focus: () => {
      inputRef.current.focus();
    },
    validate: () => {
      return inputRef.current.value.length > 0;
    },
  }),
  []);

  return <input ref={inputRef} />;
});

useContext、useReducer

useContext

上下文,类似于 Context,其本意就是设置全局共享数据,使所有组件可跨层级实现共享。

useContext 的参数一般是由 createContext 创建,或者是父级上下文 context传递的,通过 CountContext.Provider 包裹的组件,才能通过 useContext 获取对应的值。我们可以简单理解为 useContext 代替 context.Consumer 来获取 Provider 中保存的 value

在组件的顶层作用域调用 useReducer 以创建一个用于管理状态的 reducer, 可以利用Hooks实现小型状态管理。再搭配useContext可以实现跨组件的状态管理

本示例demo请移步stackblitz.com

注意

在 reducer 中,如果返回的 state 和之前的 state 值相同,那么组件将不会更新

tsx
// store仓库
// reducer.ts
export const initialState = { count: 0 };
export function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}
tsx
// 顶层容器组件
// provider.tsx
import React, { useReducer, createContext, useContext } from 'react';
import { initialState, reducer } from './reducer';

// 使用initialState初始化
export const StateContext = createContext(initialState);
export const useStateValue = () => useContext(StateContext);

function CounterProvider(props) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <StateContext.Provider value={{ state, dispatch }}>
      {props.children}
    </StateContext.Provider>
  );
}
tsx
// 后代子组件, 消费context
import { useStateValue } from './provider'

const ChangeCount= () => {
  const { state, dispatch } = useStateValue()

  return (
    <div>
      <h1>Counter: {state.count}</h1>
      <button onClick={() => dispatch({ type: 'increment' })}>
        increment
      </button>
      <button onClick={() => dispatch({ type: 'decrement' })}>
        decrement
      </button>
    </div>
  )
}
tsx
// App.tsx
import { CounterProvider } from './provider'
import ChangeCount from './ChangeCount'

const App = ()=>{
  return (
    <CounterProvider>
      <ChangeCount />
    </CounterProvider>
  )
}

应用场景

著名React状态管理库Zustand中就使用了useReducer, 用来做了一个强制更新的hook

ts
const [, forceUpdate] = useReducer((c) => c + 1, 0)

useMemo、React.memo、useCallback

这几个API都常用于性能优化, 作用也都是来缓存数据,避免子组件的无效重复渲染

  • 当父子组件之间不需要传值通信时,可以选择用React.memo来避免子组件的无效重复渲染

  • 当父子组件之间需要进行传值通信时,需要React.memouseMemo两者配合使用

  • 当引起变化的数据是函数的引用时, 可以使用useCallback来缓存函数

React.memo

React.memo是一个高阶组件,常用于包裹整个子组件,当子组件的 props 没有改变的情况下会跳过重复的渲染

语法

ts
const MemoizedComponent = memo(Component, arePropsEqual?)
  • Component:要缓存的组件。memo不会修改此组件,而是返回一个新的缓存组件。任何有效的 React 组件,包括函数forwardRef组件,都能接受

  • arePropsEqual(可选):接受两个参数的函数, 组件的先前属性及其新属性。如果新旧属性相等应该返回true, 组件将渲染相同的输出。否则它应该返回false。默认情况下,React 会通过Object.is来比较每个prop

useMemo

useMemo是一个 React Hook,使用它定义的变量,只会在useMemo的第二个依赖参数发生修改时才会发生修改。

使用场景关注于值的变化,常用于包裹变化的值或属性Props,把需要传递给子组件的参数用useMemo进行处理,从而实现了子组件的更新只发生在传递给子组件的参数发生变化的时候

useCallback

useCallbackuseMemo 极其高度类似, 但两者的返回值是不同的。 useMemo 返回的是值,而 useCallback 返回的是函数

useLayoutEffect

useLayoutEffectuseEffect 基本一致,不同点在于它是同步执行的

  • useLayoutEffect 是在 DOM 更新之后同步执行,浏览器绘制之前的操作

具体执行时机

组件更新后,会立即执行 useLayoutEffect,在浏览器完成绘制前。

因为是在绘制之前执行,所以 useLayoutEffect 的代码能在用户看到页面之前修改 DOM 布局和样式。

如果某些操作依赖于当前 DOM 的状态,比如测量 DOM 尺寸或位置、修改布局,useLayoutEffect 是更合适的选择。所以useLayoutEffect 的执行顺序在 useEffect 之前

  • useLayoutEffect 相当于有一层防抖效果

  • useLayoutEffectcallback 中会阻塞浏览器绘制

通过下面的例子来了解两者的执行顺序

ts
import { useState, useEffect, useLayoutEffect } from "react";

const Index: React.FC<any> = () => {
  let [count, setCount] = useState(0);
  let [count1, setCount1] = useState(0);

  useEffect(() => {
    if(count === 0){
      setCount(10 + Math.random() * 100)
    }
  }, [count])

  useLayoutEffect(() => {
    if(count1 === 0){
      setCount1(10 + Math.random() * 100)
    }
  }, [count1])

  return (
    <>
      <div>useEffect vs useLayoutEffect 执行顺序对比</div>
      <div>useEffect的count:{count}</div>
      <div>useLayoutEffect的count:{count1}</div>
    </>
  );
};

export default Index;

执行顺序

钩子执行时机适用场景
useEffect异步,DOM 更新和绘制完成后执行数据获取、事件监听、不影响布局的操作
useLayoutEffect同步,DOM 更新后但绘制前执行操作布局、测量 DOM、修改样式等

useDebugValue

可用于在 React 开发者工具中显示自定义 Hook 的标签。这个 Hooks 目的就是检查自定义 Hooks

useSyncExternalStore

语法

ts
const state = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot
)
  • subscribe:订阅函数,用于注册一个回调函数,当存储值发生更改时被调用。 此外,useSyncExternalStore 会通过带有记忆性的 getSnapshot 来判断数据是否发生变化,如果发生变化,那么会强制更新数据

  • getSnapshot:返回当前存储值的函数。必须返回缓存的值。如果 getSnapshot 连续多次调用,则必须返回相同的确切值,除非中间有存储值更新

  • getServerSnapshot:返回服务端(hydration 模式下)渲染期间使用的存储值的函数

useSyncExternalStore 是一个 React Hooks,它允许你订阅一个外部存储。外部存储可以是一个 API数据库或任何其他可以提供数据的地方

useSyncExternalStore 将确保 React 组件始终与外部存储保持同步

应用场景包括

  • 从 API 获取数据
  • 从数据库获取数据
  • 从其他 React 组件获取数据

平时使用 useSyncExternalStore 时,需要注意以下几点:

  • 外部存储必须是同步的。如果外部存储是异步的,则 useSyncExternalStore 可能会导致组件重渲染
  • 外部存储必须是可靠的。如果外部存储不可靠,则 useSyncExternalStore 可能会导致组件出现错误

这个Hooks能够让 React 组件在 Concurrent(并发) 模式下安全、有效地读取外接数据源,在组件渲染过程中能够检测到变化,并且在数据源发生变化的时候,能够调度更新

tsx
import { combineReducers, createStore } from "redux";

const reducer = (state: number = 1, action: any) => {
  switch (action.type) {
    case "ADD":
      return state + 1;
    case "DEL":
      return state - 1;
    default:
      return state;
  }
};

/* 注册reducer,并创建store */
const rootReducer = combineReducers({ count: reducer });
export const store = createStore(rootReducer, { count: 1 });
tsx
import { store } from './store.ts'
import { useSyncExternalStore } from "react";

const Index: React.FC<any> = () => {
  //订阅
  const state = useSyncExternalStore(
    store.subscribe,
    () => store.getState().count
  );

  // 当我们点击按钮后,会触发 store.subscribe(订阅函数),执行 getSnapshot 后得到新的 count,此时 count 发生变化,就会触发更新
  return (
    <>
      <div>数据源: {state}</div>
      <button onClick={() => store.dispatch({ type: "ADD" })}>
        加1
      </button>
      <button
        style={{ marginLeft: 8 }}
        onClick={() => store.dispatch({ type: "DEL" })}
      >
        减1
      </button>
    </>
  );
};

export default Index;

useTransition

useTransition 允许开发者将某些非紧急的状态更新标记为“并发”任务,React 会优先渲染高优先级任务(如用户交互、点击、输入、动画),并将低优先级的任务延后执行。这种分阶段的渲染方式,使得界面在执行较重的更新(比如数据过滤、大量列表渲染、分页)操作时不会阻塞用户的交互体验

它会返回一个状态值表示过渡(延迟)更新任务的等待状态,以及一个启动该过渡(延迟)更新任务的函数。 其本质是将紧急更新任务变为过渡(延迟)任务

语法

ts
const [isPending, startTransition] = useTransition();
  • isPending 布尔值,过渡状态的标志,为 true 时表示等待状态;
  • startTransition 可以将里面的任务变成过渡更新任务。
tsx
import React, { useState, useTransition } from 'react';

function FilterComponent({ items }) {
  const [query, setQuery] = useState('');
  const [filteredItems, setFilteredItems] = useState(items);

  const [isPending, startTransition] = useTransition();

  const handleFilterChange = (e) => {
    const newQuery = e.target.value;
    setQuery(newQuery);

    // 标记筛选任务为低优先级
    startTransition(() => {
      const filtered = items.filter(item => item.includes(newQuery));
      setFilteredItems(filtered);
    });
  };

  return (
    <div>
      <input type="text" value={query} onChange={handleFilterChange} placeholder="Filter items..." />
      {isPending ? <p>Loading...</p> : null}
      <ul>
        {filteredItems.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}


export default FilterComponent;

useDeferredValue

语法

ts
const deferredValue = useDeferredValue(value);
  • value:接受一个可变的值,如useState所创建的值
  • deferredValue:返回一个延迟状态的值

useDeferredValue 用于延迟更新值,帮助控制高频状态更新带来的性能压力。它允许你在不影响用户交互的情况下,将某些状态的更新推迟到更适合的时机执行,适合在需要避免频繁渲染的场景中使用。

useDeferredValue 最常用的场景是需要实时响应用户输入的情况下,比如搜索功能、输入表单等。当用户在输入过程中,组件会因为频繁更新状态而频繁渲染,这样可能会导致卡顿或性能问题。useDeferredValue 可以将输入的即时状态更新和渲染分离,从而在用户快速输入时,延迟一些计算或渲染工作,以提供流畅的交互体验。

但是, 它与传统的节流和防抖却不同。 它不需要选择任何固定延迟。如果用户的设备速度很快(例如配置强大的电脑或笔记本),则延迟的重新渲染几乎会立即发生并且不会被注意到。如果用户的设备速度很慢,则列表将“落后于”输入,与设备的速度成比例。

此外,与去抖或节流不同,延迟重新渲染useDeferredValue默认情况下是可中断的。这意味着如果 React 正在重新渲染一个大列表,但用户再次进行点击,React 将放弃该重新渲染,处理点击任务,然后再次开始在后台渲染。相比之下,去抖动和节流仍然会产生卡顿的体验,因为它们会阻塞-它们只是推迟渲染阻塞击键的时刻

注意

  • 您传递给的值useDeferredValue应该是原始类型值(如字符串和数字)或在渲染之外创建的对象。如果您在渲染期间创建一个引用类型的新对象并立即将其传递给useDeferredValue,则每次渲染时它都会有所不同,从而导致不必要的重新渲染

  • useDeferredValue接收到不同的值(与 相比Object.is)时,除了当前渲染(当它仍然使用以前的值时)之外,它还会使用新值在后台安排重新渲染。后台重新渲染是可中断的:如果有另一个更新valueReact 将从头开始重新启动后台重新渲染。例如,如果用户输入内容的速度快于接收其延迟值的图表重新呈现的速度,则图表只会在用户停止输入后重新呈现

  • useDeferredValue与 集成<Suspense>。如果新值引起的后台更新暂停了 UI,用户将看不到回退。他们将看到旧的延迟值,直到数据加载

  • useDeferredValue本身不会阻止额外的网络请求

  • 本身不存在固定延迟useDeferredValue。一旦 React 完成原始重新渲染,React 将立即开始使用新的延迟值进行后台重新渲染。由事件(例如打字)引起的任何更新都会中断后台重新渲染并优先于它

  • useDeferredValue引起的后台重新渲染在提交到屏幕之前不会触发效果。如果后台重新渲染暂停,其效果将在数据加载和 UI 更新后运行

与useTransition的区别

useDeferredValueuseTransition 从本质上都是标记成了过渡更新任务,不同点在于 useDeferredValue 是将原值通过过渡任务得到新的值(更关注值), 而 useTransition 是将紧急更新任务变为过渡任务(更关注函数)

useDeferredValue 用来处理数据本身,useTransition 用来处理更新函数

tsx
import { useState, useDeferredValue } from "react";
import { Input } from "antd";

const getList = (key: any) => {
  const arr = [];
  for (let i = 0; i < 10000; i++) {
    if (String(i).includes(key)) {
      arr.push(<li key={i}>{i}</li>);
    }
  }
  return arr;
};

const Index: React.FC<any> = () => {
  //订阅
  const [input, setInput] = useState("");
  const deferredValue = useDeferredValue(input);
  console.log("value:", input);
  console.log("deferredValue:", deferredValue);

  return (
    <>
      <div>useDeferredValue example</div>
      <Input value={input} onChange={(e: any) => setInput(e.target.value)} />
      <div>
        <ul>{deferredValue ? getList(deferredValue) : null}</ul>
      </div>
    </>
  );
};

export default Index;

useInsertionEffect

useInsertionEffectReact18 中引入的一个特殊 Hook,主要用于在渲染前插入一些 DOM 操作,比如在渲染前插入样式或执行 DOM 修改。这一 Hook 的执行时机非常早,在任何 DOM 渲染和更新之前,特别适合样式库或需要紧密控制 DOM 插入顺序的场景。它是一个适用范围相对狭窄的 Hook,并不建议在常规的业务逻辑中使用。

使用限制(不通用)

useInsertionEffect 主要用于需要在渲染前操作样式的场景,确保样式在浏览器绘制内容前就插入完成,通常用于 CSS-in-JS 或动态样式的库中。例如,当我们使用 CSS-in-JS 库动态生成样式时,需要在组件的渲染过程之前将样式插入到 DOM 中,以保证样式生效的顺序。

在实际的项目中优先考虑使用 useEffectuseLayoutEffect

这个钩子是为了解决 CSS-In-JS 在渲染中注入样式的性能问题而出现的,所以在我们日常的开发中并不会用到这个钩子,但我们要知道如何去使用它

至此, 三个副作用钩子分别为useEffectuseLayoutEffectuseInsertionEffect, 那他们的调用顺序如何?

tsx
import { useEffect, useLayoutEffect, useInsertionEffect } from "react";

const Index: React.FC<any> = () => {
  useEffect(() => console.log("useEffect"), []);

  useLayoutEffect(() => console.log("useLayoutEffect"), []);

  useInsertionEffect(() => console.log("useInsertionEffect"), []);

  return <div>副作用钩子 useEffect 、useLayoutEffect、useInsertionEffect 执行顺序比较</div>;
};

export default Index;

从先到后执行顺序

  • useInsertionEffect
  • useLayoutEffect
  • useEffect

useId

语法

ts
const id = useId();
  • id: 生成一个服务端和客户端统一的id

为什么需要这个useId的钩子函数?

是因为React@18streaming renderer(流式渲染)中 id 的稳定性

React@18服务端渲染(SSR)的hydrate过程中, 遇到大模块或者大组件, 就需要局部渲染。局部渲染的过程中, 将模块进行拆分,让加载快的小模块先进行渲染,大的模块挂起,再逐步加载出大模块。 此时就需要id来标记这些组件或模块, 以便于hydrate过程中能够找到对应的组件

ReactFiber

React组件更新是自上而下的全量更新,如果有某个节点发生变化,全量diff 就会给主线程带来巨大的压力,无法正常渲染和响应用户的事件。导致页面的掉帧卡顿无法响应等诸多严重的问题

为了解决这个问题,React团队在v16中重写了核心算法Reconciliation。同时为了区分新旧版本的区别,将早期的调和器Reconciler称为Stack Reconciler,新版本之后的调和器称为Fiber Reconciler,简称Fiber

什么是Fiber

实际它是一种核心算法,为了解决中断任务和更新树庞大的问题,也可认为 Fiber 就是 v16 之后的虚拟 DOM

Element、Fiber、DOM

这三种有什么关系?

  • Element 对象就是我们的 jsx 代码,上面保存了 propskeychildren 等信息
  • DOM 元素就是最终呈现给用户展示的效果
  • Fiber 就是充当 ElementDOM 元素的桥梁,简单来说,只要 Element 发生改变,就会通过 Fiber 做一次调和,使对应的 DOM 元素发生改变

在源码中, 通过BeginWork函数中的switch语句, 可以看到Fiber的类型有很多种, 例如HostComponentHostRootHostTextClassComponentFunctionComponent等等

Element的类型与Fiber的类型是相对应的, 例如HostComponent对应的Element类型就是divspan等等。以下是对应关系表

fiberelement
FunctionComponent = 0函数组件
ClassComponent = 1类组件
IndeterminateComponent = 2初始化的时候不知道是函数组件还是类组件
HostRoot = 3根元素,通过reactDom.render()产生的根元素
HostPortal = 4ReactDOM.createPortal 产生的 Portal
HostComponent = 5dom 元素(如<div>
HostText = 6文本节点
Fragment = 7<React.Fragment>
Mode = 8<React.StrictMode>
ContextConsumer = 9<Context.Consumer>
ContextProvider = 10<Context.Provider>
ForwardRef = 11React.ForwardRef
Profiler = 12<Profiler>
SuspenseComponent = 13<Suspense>
MemoComponent = 14React.memo 返回的组件
SimpleMemoComponent = 15React.memo 没有制定比较的方法,所返回的组件
LazyComponent = 16<lazy />

以下是源码片段

ts
// packages/react-reconciler/src/ReactFiberBeginWork.js

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {

  workInProgress.lanes = NoLanes;

  switch (workInProgress.tag) {
    case IndeterminateComponent: {
    }
    case LazyComponent: {
    }
    case FunctionComponent: {
    }
    case ClassComponent: {
    }
    case HostRoot:
      return updateHostRoot(current, workInProgress, renderLanes);
    case HostHoistable:
      if (enableFloat && supportsResources) {
        return updateHostHoistable(current, workInProgress, renderLanes);
      }
    // Fall through
    case HostSingleton:
      if (supportsSingletons) {
        return updateHostSingleton(current, workInProgress, renderLanes);
      }
    // Fall through
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
    case HostText:
      return updateHostText(current, workInProgress);
    case SuspenseComponent:
      return updateSuspenseComponent(current, workInProgress, renderLanes);
    case HostPortal:
      return updatePortalComponent(current, workInProgress, renderLanes);
    case ForwardRef: {
    }
    case Fragment:
      return updateFragment(current, workInProgress, renderLanes);
    case Mode:
      return updateMode(current, workInProgress, renderLanes);
    case Profiler:
      return updateProfiler(current, workInProgress, renderLanes);
    case ContextProvider:
      return updateContextProvider(current, workInProgress, renderLanes);
    case ContextConsumer:
      return updateContextConsumer(current, workInProgress, renderLanes);
    case MemoComponent: {
    }
    case SimpleMemoComponent: {
    }
    case IncompleteClassComponent: {
    }
    case SuspenseListComponent: {
    }
    case ScopeComponent: {
    }
    case OffscreenComponent: {
    }
    case LegacyHiddenComponent: {
    }
    case CacheComponent: {
    }
    case TracingMarkerComponent: {
    }
  }
}

Fiber节点的组成

FiberNode 内容主要分为四个部分,分别是 InstanceFiberEffectPriority

  • Instance:这个部分是用来存储一些对应 element 元素的属性
  • Fiber:这部分内容存储的是关于 Fiber 链表相关的内容和相关的 propsstate
  • Effect:副作用相关的内容
  • Priority:优先级相关的内容
ts
// packages/react-reconciler/src/ReactInternalTypes.js

export type Fiber = {
  // 以下是Instance部分
  tag: WorkTag,  // 组件的类型,判断函数式组件、类组件等(上述的tag)
  key: null | string, // key
  elementType: any, // 元素的类型
  type: any, // 与fiber关联的功能或类,如<div>,指向对应的类或函数
  stateNode: any, // 真实的DOM节点

  // 以下是Fiber部分
  return: Fiber | null, // 指向父节点的fiber
  child: Fiber | null, // 指向第一个子节点的fiber
  sibling: Fiber | null, // 指向下一个兄弟节点的fiber
  index: number, // 索引,是父节点fiber下的子节点fiber中的下表
  ref:
    | null
    | (((handle: mixed) => void) & {_stringRef: ?string, ...})
    | RefObject,  // ref的指向,可能为null、函数或对象
  pendingProps: any,  // 本次渲染所需的props
  memoizedProps: any,  // 上次渲染所需的props
  updateQueue: mixed,  // 类组件的更新队列(setState),用于状态更新、DOM更新
  memoizedState: any, // 类组件保存上次渲染后的state,函数组件保存的hooks信息
  dependencies: Dependencies | null,  // contexts、events(事件源) 等依赖
  mode: TypeOfMode, // 类型为number,用于描述fiber的模式

  // 以下是Effect部分
  flags: Flags, // 用于记录fiber的状态(删除、新增、替换等)
  subtreeFlags: Flags, // 当前子节点的副作用状态
  deletions: Array<Fiber> | null, // 删除的子节点的fiber
  nextEffect: Fiber | null, // 指向下一个副作用的fiber
  firstEffect: Fiber | null, // 指向第一个副作用的fiber
  lastEffect: Fiber | null, // 指向最后一个副作用的fiber

  // 以下是Priority部分
  lanes: Lanes, // 优先级,用于调度
  childLanes: Lanes,
  alternate: Fiber | null,
  actualDuration?: number,
  actualStartTime?: number,
  selfBaseDuration?: number,
  treeBaseDuration?: number,
};

Fiber的链表结构

Fiber 中通过 returnchildsibling 这三个参数来进行连接,它们分别指向父级、子级、兄弟,也就是说每个 element 通过这三个属性进行连接,同时通过 tag 的值来判断对应的 element 是什么

例如有一段代码如下:

tsx
const Index = (props)=> {
  return (
    <div>
      Hello Fiber
      <div>了解Fiber的数据结构</div>
      <p>更好的了解Hooks</p>
    </div>
  );
}

Fiber 结构的创建和更新都是深度优先遍历,上述代码遍历顺序依次为:

  • 首先会判断当前组件是类组件还是函数式组件,类组件 tag 为 1(ClassComponent),函数式为 0(FunctionComponent)
  • 然后发现 div 标签,标记 tag 为 5(HostComponent)
  • 发现 div 下包含三个部分,分别是文本Hello Fiberdiv标签、p标签
  • 遍历文本Hello Fiber,下面无节点,标记 tag 为 6(HostText)
  • 在遍历 div 标签,标记 tag 为 5(HostComponent),此时下面有节点,所以对节点进行遍历,也就是文本 知悉 fiber,标记 tag 为 6(HostText)
  • 同理最后遍历p标签, 标记 tag 为 5(HostComponent)

react-fiber

整个的流程就是这样,通过 tag 标记属于哪种类型,然后通过 returnchildsibling 这三个参数来判断节点的位置

React的并发

React v18中默认开启了并发模式(Concurrent), React 的并发模式是基于 Fiber 的。其中 useTransitionuseDeferredValue 的内部原理都是基于并发的,可见并发的重要性

并发与并行

并发是指多个任务在极短的时间内同时进行, 而并行是指多个任务在同一时刻同时进行

概念

在React早期版本的调和器Reconciler是同步的。也就是说在React的更新过程中,如果当前耗时任务未完成,那么后续的任务就将被延后(阻塞)无法执行

而在React v16之后,Reconciler被重写为Fiber Reconciler,它是异步的。也就是说在React的更新过程中,如果当前耗时任务(例如Diff)即使没有完成,后续的任务也可以被执行,这样就可以实现任务的中断和恢复

Fiber Reconciler的核心算法是Fiber,它是一种数据结构,用于描述组件树的一种数据结构,它的本质是一种链表结构,通过链表结构可以实现任务的中断和恢复,从而实现并发

优先级

优先级是 React 中非常重要的模块,分为两种方式:

  • 紧急更新(Urgent updates): 用户的交互(例如点击、输入等),直接影响用户体验的行为都属于紧急情况
  • 过渡更新(Transition updates): 页面跳转等操作属于非紧急情况

实现原理

并发模式的实现,整体可分为三步,分别是:

  • 每个更新,都会分配一个优先级(lane),用于区分紧急程度
  • 将不紧急的更新拆解成多段,并通过宏任务的方式将其合理分配到浏览器的帧当中,使得紧急任务可以插入进来
  • 优先级高的更新任务会打断优先级低的更新,直到优先级高的更新任务执行完成后,再执行优先级低的任务

单元测试

React官方推荐的单元测试工具是Jest,它是一个基于Jasmine的测试框架,内置了断言库、测试覆盖率报告等功能

打包器如果是Vite可以使用vite-jest做单元测试。反之,直接使用 Jest

对应的基础环境配置请参考作者的另一篇文章 CRA项目迁移指南 中的第八部分-修复单元测试

@testing-library/react

React的单元测试中, 需要使用到@testing-library/react中提供的扩展来完成

renderHook

  • renderHook:因为Hooks只能在React组件内使用, 该函数用来在测试文件中渲染 Hooks

renderHook定义

ts
function renderHook<Result, Props>(
  render: (props: Props) => Result,
  options?: RenderHookOptions<Props>,
): RenderHookResult<Result, Props>

入参:

  • render: callBack 函数,这个函数会在 目标组件 每次被重新渲染的时候调用,所以这个函数放入我们想测试的 Hooks 就行
  • options:可选的 options,有两个属性,分别是 initialProps 和 wrapper

options 的参数:

  • initialProps:目标组件 初始的 props
  • wrapper:用来指定 目标组件 的父级组件(Wrapper Component),这个组件可以是一些 ContextProvider 等用来为 目标组件 的 Hooks 提供测试数据的东西

出参:renderHook,共返回三个参数,分别是:

  • result:结果,是一个对象结构,包含 current(保存 目标组件 返回的 callback 值)和 error(所有错误存放的值)
  • render:用来重新渲染 目标组件,并且可以接受一个 newProps(参数)传递给 目标组件
  • unmount:用来卸载 目标组件,主要用来覆盖一些 useEffect cleanup 函数的场景

act

  • act: 这个函数和 React 自带的 test-utilsact 函数是同一个函数,通过这个函数,我们可以将所有会更新到组件状态的操作封装在它的 callback 下,简单来说,我们如果对组件有操作,改变 result 的值,就需要放到 act

render

render 主要返回三类,三类基本一致, 分别是:getBy*queryBy*findBy*

  • getBy*:定位页面已经存在的 DOM 元素,如果不存在,则抛出异常
  • queryBy*:定位页面不存在的 DOM 元素,如果不存在,则返回 null,不会抛出异常
  • findBy*:定位页面中的异常元素,如果不存在,则抛出异常
方法含义
getByText按元素查找文本内容
getByRole按角色去查找
getByLabelText按标签或aria标签文本内容查找
getByPlaceholderText按输入placeholder查找
getByAltText按img的alt属性查找
getByTitle按标题属性或svg标题标记查找
getByDisplayValue按表单元素查找当前值
getByTestId按数据测试查找属性

fireEvent

fireEvent用于实际的操作,也就是模拟点击、键盘、表单等操作, 首先来看语法

语法

ts
// 第一种 内部创建event事件
fireEvent(node: HTMLElement, event: Event)

// 第二种 推荐这种写法
fireEvent[eventName](node: HTMLElement, eventProperties: Object)

参考资料