@Lumilio-Photos: React Context Provider

· 4 min read

参考项目 流明集

在使用 React.js 框架的前端应用中,状态管理是至关重要的一部分,完善的状态管理系统也是 React 框架受众多前端开发者青睐的原因之一。React 原生提供了几个比较重要的状态管理钩子,分别为 useStateuseReduceruseContextuseMemouseRef

什么是状态管理?引用 React 官方文档的原文:

State is like a component’s memory. It lets a component keep track of some information and change it in response to interactions. For example, a Button might keep track of isHovered state.

useState

这是 React 框架中最基础、最常用的 hook。从见到它的第一刻起,你就能窥探到整个 React 框架是如何设计 hooks 的。即,暴露一个变量、一个方法,并传入一些起始参数。

const [count, setCount] = useState<number>(0);

这是一个经典的计数状态,通过一个暴露的 setCount 方法来控制 count 的值,这个值的生命周期仅在页面未刷新之前有效。这也是 React 状态管理最简单、最小的范式。

useReducer

useReduceruseState 的一种替代方案。当状态逻辑复杂,或者下一个状态依赖于前一个状态时,useReducer 通常是更好的选择。Simply put,当你写了很多 useState 时,那么你就该考虑将它们封装成 useReducer。与 useState 相同,useReducer 也遵循了 React hooks 的经典设计,暴露了一个 state 变量和一个 dispatch 方法。你需要传入一个 reducer 方法和 initialState 初始变量。该 hook 借鉴了 React 的经典状态管理库 Redux。

function reducer(state: { count: number }, action: { type: string }) {
	switch (action.type) {
		case "increment":
			return { count: state.count + 1 };
		case "decrement":
			return { count: state.count - 1 };
		default:
			throw new Error();
	}
}

const [state, dispatch] = useReducer(reducer, initialState);

useReducer 通过一个 dispatch 方法,可以使用不同的 action 同时接管两个状态,提升了代码整洁性和可维护性。

useContext

useContext 将会为本篇内容的重点。它用于在组件树中进行跨层级状态共享,想象一下下面这种组件情景:

import React, { useState } from "react";

const componentStyle = (color) => ({
  border: `2px solid ${color}`,
  borderRadius: "8px",
  padding: "16px",
  margin: "16px",
  textAlign: "center",
});

function ComponentC({ user }) {
  console.log("ComponentC rendered. I need the 'user' prop.");
  return (
    <div style={componentStyle("red")}>
      <h3>组件 C</h3>
      <p>你好, {user.name}!</p>
    </div>
  );
}

function ComponentB({ user }) {
  console.log("ComponentB rendered. I don't need 'user', but I have to pass it down.");
  return (
    <div style={componentStyle("orange")}>
      <h2>组件 B</h2>
      <p>我只是一个“中间人”。</p>
      <ComponentC user={user} />
    </div>
  );
}

function ComponentA({ user }) {
  console.log("ComponentA rendered. I also don't need 'user', just passing it along.");
  return (
    <div style={componentStyle("blue")}>
      <h1>组件 A</h1>
      <ComponentB user={user} />
    </div>
  );
}

export default function App() {
  const [user, setUser] = useState({ name: "小明" });

  return (
    <div style={{ fontFamily: "sans-serif" }}>
      <h2>属性钻探 (Prop Drilling) 示例</h2>
      <p>
        <code>user</code> 状态从 <strong>App</strong> 组件开始,穿过 <strong>组件A</strong> 和
        <strong>组件B</strong>,最终到达 <strong>组件C</strong>。
      </p>
      <ComponentA user={user} />
    </div>
  );
}

显而易见,数据 user 从最顶层的 App 组件开始,流经 AB,最终到达组件 C。这造成了不必要的数据传递。组件 AB 成了“管道工”,它们的代码因为需要传递一个与自己毫无关系的 user 属性稍显臃肿。当我们同级的子组件更多,或者子组件更深时,每个组件的 props 就被大量无关属性占据,从而使得整体代码变得十分臃肿。

同时,这极大地增加了维护难度,降低了组件的可复用性。当任何子组件需要一个新的属性时,你需要同时更改其所有父组件。其次,因为我们在中间组件硬编码了对某些属性的传递,这使得它们与其他不使用该传递属性的父组件一起使用时变得困难。

我们称这种结构为 Prop Drilling

如何避免?使用 useContext

React Context Provider:状态管理的利器

在前端开发中,尤其是在像 Lumilio-Photos 这样复杂的应用中,状态管理是一个核心挑战。当组件层级较深时,通过 props 一层层地传递数据会变得非常繁琐且难以维护。React Context API 正是为了解决这一痛点而生,它提供了一种在组件树中共享数据的方式,而无需显式地通过 props 传递。

为什么选择 Context API?

想象一下 Lumilio-Photos 的文件上传功能:多个组件例如文件选择器、拖拽区域、上传进度条、错误提示都需要访问和修改上传状态,例如文件列表、上传进度、WASM 模块是否就绪等。如果每次都通过 props 传递,代码会变得非常臃肿。

Context API 的优势在于:

  1. 简化数据传递:避免了 props drilling,让组件可以更直接地访问所需数据。
  2. 全局状态管理:适用于那些在整个应用中都需要访问的“全局”数据,例如用户认证信息、主题设置,或者像我们这里的文件上传状态。
  3. 解耦组件:消费者组件不再需要关心数据从何而来,只需从 Context 中获取即可,从而降低了组件间的耦合度。

Context API 的核心概念

Context API 主要由三个部分组成:

  • React.createContext:创建一个 Context 对象。当 React 渲染一个订阅了这个 Context 对象的组件时,它会从组件树中离自身最近的那个 Provider 中读取到当前 Context 的值。
  • Context.Provider:一个 React 组件,它允许消费组件订阅 Context 的变化。Provider 接收一个 value prop,这个 value 将会被传递给所有后代消费组件。一个 Provider 可以有多个后代消费组件。
  • useContext Hook:在函数组件中订阅 Context 的方式。它接收一个 Context 对象作为参数,并返回该 Context 的当前值。

手把手构建 UploadContext

现在,让我们结合 Lumilio-Photos 项目中的 UploadContext.tsx 示例,一步步剖析如何构建一个功能完善的 React Context。

1. 定义状态接口与动作类型

首先,我们需要明确上传功能所需的所有状态,以及可以对这些状态执行的所有操作。这有助于我们清晰地规划数据结构和状态更新逻辑。

interface UploadState {
  files: File[];
  previews: (string | null)[];
  filesCount: number;
  isDragging: boolean;
  wasmReady: boolean;
  readonly maxPreviewFiles: number;
}

type UploadAction =
  | { type: "SET_DRAGGING"; payload: boolean }
  | { type: "SET_FILES"; payload: { files: File[]; previews: (string | null)[] } }
  | { type: "SET_WASM_READY"; payload: boolean }
  | { type: "CLEAR_FILES" };

2. 初始化状态与 Reducer

为了更好地管理复杂的状态逻辑,我们通常会结合 useReducer hook。useReducer 类似于 Redux,它接收一个 reducer 函数和一个初始状态,并返回当前状态以及一个 dispatch 函数。

const initialState: UploadState = {
  files: [],
  previews: [],
  filesCount: 0,
  isDragging: false,
  wasmReady: false,
  maxPreviewFiles: 30,
};

const uploadReducer = (
  state: UploadState,
  action: UploadAction,
): UploadState => {
  switch (action.type) {
    case "SET_DRAGGING":
      return { ...state, isDragging: action.payload };
    case "SET_FILES":
      state.previews.forEach((url) => url && URL.revokeObjectURL(url));
      return {
        ...state,
        files: action.payload.files,
        previews: action.payload.previews,
        filesCount: action.payload.files.length,
      };
    case "SET_WASM_READY":
      return { ...state, wasmReady: action.payload };
    case "CLEAR_FILES":
      state.previews.forEach((url) => url && URL.revokeObjectURL(url));
      return { ...state, files: [], previews: [], filesCount: 0 };
    default:
      return state;
  }
};

3. 创建 Context 对象

使用 React.createContext 创建 UploadContext 对象。

interface UploadContextValue {
  state: UploadState;
  dispatch: Dispatch<UploadAction>;
  workerClientRef: React.RefObject<WasmWorkerClient | null>;
  handleDragOver: (e: DragEvent) => void;
  handleDragLeave: (e: DragEvent) => void;
  handleDrop: (e: DragEvent, handleFiles?: (files: FileList) => void) => void;
  clearFiles: (fileInputRef: RefObject<HTMLInputElement | null>) => void;
  BatchUpload: (selectedFiles: FileList) => Promise<void>;
  isProcessing: boolean;
  resetUploadStatus: () => void;
  uploadProgress: number;
  hashcodeProgress: {
    numberProcessed?: number;
    total?: number;
    error?: string;
    failedAt?: number;
  } | null;
  isGeneratingHashCodes: boolean;
}

export const UploadContext = createContext<UploadContextValue | undefined>(
  undefined,
);

4. 构建 Provider 组件

UploadProvider 是 Context 的核心,它负责管理状态、初始化 WASM 模块、处理拖拽事件、协调上传操作,并将所有这些通过 value prop 传递给子组件。

export default function UploadProvider({ children }: UploadProviderProps) {
  const [state, dispatch] = useReducer(uploadReducer, initialState);
  const { wasmReady, previews } = state;

  const showMessage = useMessage();
  const workerClientRef = useRef<WasmWorkerClient | null>(null);
  const uploadProcess = useUploadProcess(workerClientRef, wasmReady);

  const handleDragOver = useCallback((e: DragEvent) => { /* ... */ }, []);
  const handleDragLeave = useCallback((e: DragEvent) => { /* ... */ }, []);
  const handleDrop = useCallback((e: DragEvent, handleFiles?: (files: FileList) => void) => { /* ... */ }, []);
  const clearFiles = useCallback((fileInputRef: RefObject<HTMLInputElement | null>) => { /* ... */ }, []);

  useEffect(() => {
    if (!workerClientRef.current) {
      workerClientRef.current = new WasmWorkerClient();
    }
    const initWasm = async () => {
      try {
        await workerClientRef.current?.initGenThumbnailWASM();
        await workerClientRef.current?.initGenHashWASM();
        dispatch({ type: "SET_WASM_READY", payload: true });
        console.log("WASM module initialized successfully");
      } catch (error) {
        console.error("Failed to initialize WASM:", error);
      }
    };
    initWasm();

    return () => {
      previews.forEach((url) => url && URL.revokeObjectURL(url));
    };
  }, [previews]);

  const BatchUpload = useCallback(
    async (selectedFiles: FileList) => {
      if (!wasmReady || !selectedFiles.length) {
        showMessage(
          "error",
          "Cannot upload: WASM not initialized or no files selected",
        );
        return;
      }
      try {
        await uploadProcess.processFiles(selectedFiles);
      } catch (error: any) {
        showMessage("error", `Upload process failed: ${error.message}`);
      }
      uploadProcess.resetStatus();
    },
    [wasmReady, uploadProcess, showMessage],
  );

  const contextValue = useMemo(
    () => ({
      state,
      dispatch,
      workerClientRef,
      handleDragOver,
      handleDragLeave,
      handleDrop,
      clearFiles,
      BatchUpload,
      isProcessing: uploadProcess.isGeneratingHashCodes || uploadProcess.isUploading,
      resetUploadStatus: uploadProcess.resetStatus,
      uploadProgress: uploadProcess.uploadProgress,
      hashcodeProgress: uploadProcess.hashcodeProgress,
      isGeneratingHashCodes: uploadProcess.isGeneratingHashCodes,
    }),
    [
      state,
      handleDragOver,
      handleDragLeave,
      handleDrop,
      clearFiles,
      BatchUpload,
      uploadProcess.isGeneratingHashCodes,
      uploadProcess.isUploading,
      uploadProcess.resetStatus,
      uploadProcess.uploadProgress,
      uploadProcess.hashcodeProgress,
    ],
  );

  return (
    <UploadContext.Provider value={contextValue}>
      {children}
    </UploadContext.Provider>
  );
}

5. 创建自定义 Hook 消费 Context

为了方便组件消费 Context,我们通常会创建一个自定义 hook。这不仅提供了类型安全,还能在 Context 未正确提供时抛出错误。

export function useUploadContext() {
  const context = useContext(UploadContext);
  if (!context) {
    throw new Error("useUploadContext must be used within an UploadProvider");
  }
  return context;
}

如何使用 UploadContext

  1. 在应用根部包裹 UploadProvider,确保所有需要访问上传状态的组件都在 UploadProvider 的子树中。
function App() {
  return (
    <UploadProvider>
      <YourComponents />
    </UploadProvider>
  );
}
  1. 在子组件中使用 useUploadContext
function FileUploadComponent() {
  const { state, BatchUpload, clearFiles } = useUploadContext();

  const handleFileSelect = async (files: FileList) => {
    await BatchUpload(files);
  };

  return (
    <div>
      <p>Files selected: {state.filesCount}</p>
      <button onClick={() => clearFiles(fileInputRef)}>Clear</button>
    </div>
  );
}

总结

一个典型的 Context 包括以下内容:

interface State {}
type Action {}
const initialState = ()
const Reducer = ()
interface ContextValue {}
interface ProviderProps {}
export const Context = createContext<ContextValue | undefined>(undefined);
export default function Provider() {}
function useContext() {}