普通视图

发现新文章,点击刷新页面。
昨天以前首页

React 19.x 的 lazy 与 Suspense

作者 米丘
2026年5月28日 18:26

React.lazy

在构建大型 React 应用时,打包体积过大往往会影响首屏加载速度。React.lazy 正是为了解决这一问题而诞生的内置函数,它让你可以将组件动态导入(code splitting),并按需加载,从而显著提升应用性能。

为何使用 React.lazy?

  1. 减少初始包体积:应用的首屏可能不需要所有组件。通过代码分割,只加载当前路由或交互所需的组件。
  2. 提升首屏加载速度:更少的 JavaScript 意味着更快的解析和执行时间,改善用户体验。
  3. 优化缓存与带宽:用户可能只使用部分功能,懒加载未使用的代码可节省流量。
  4. 与 Suspense 天然集成:React.lazy 配合 Suspense 可以优雅地显示加载状态(如 Loading 动画)。

渲染阶段流程

React.lazy 组件加载完成的触发机制依赖于 Promise 的 resolve 回调 和 React 内部的 Suspense 重试(ping)机制

一、 初始化

调用 React.lazy(() => import('./Component')) 会生成一个特殊的“懒加载对象”,内部包含 _status(初始为 Uninitialized)和 _result(存储加载器函数)。

二、 首次渲染

当 React 遇到这个懒加载对象时,会调用内部函数 lazyInitializer

  • 执行 _result()(即 import()),得到一个 Promise(thenable)。
  • 将 _status 更新为 Pending_result 指向该 Promise。
  • 抛出该 Promise 以触发最近的 <Suspense> 边界.

三、 suspense 捕获与监听

React 捕获抛出的 Promise,向上查找 Suspense 边界:

  • 调用 attachPingListener 为该 Promise 添加一个 then 回调(即 ping 函数)。
  • 该边界立即渲染 fallback UI。

四、 加载完成(Promise resolve)

当动态导入的模块成功加载后,Promise 被 resolve,模块对象作为结果返回。Promise 的回调执行:

  • 将 lazy 对象的 _status 更新为 Resolved_result 替换为模块对象。
  • 调用之前附加的 ping 回调(实际上是 pingSuspendedRoot)。

五、 触发重新渲染

pingSuspendedRoot 会标记对应根节点的优先级车道(pingedLanes),并调用 ensureRootIsScheduled,重新调度整个应用的渲染(或仅重试该 Suspense 边界)。

六、 二次渲染

React 再次执行该组件的渲染逻辑,此时 lazyInitializer 发现 _status === Resolved,直接返回 _result.default(真正的组件),从而正常完成渲染,替换 fallback。

注意事项

  1. 避免在渲染函数内动态调用 lazylazy 应在模块顶层定义,确保每次渲染都得到相同的引用。
  2. 重复导入优化:同一个 lazy 组件在多处使用时,内部会共享相同的 Promise,不会重复加载。
  3. 错误处理:懒加载可能因网络问题失败,建议结合错误边界(Error Boundary)捕获加载失败错误。
  4. 命名导出问题:默认导出是 lazy 的约定,非默认导出需手动转换。
  5. 避免在 Suspense 外部调用 lazy 组件:否则无法捕获挂起。

示例 懒加载组件

lazy 接收一个函数,该函数必须返回一个动态 import() 调用(返回 Promise,其 resolve 值为包含 React 组件的模块)。Suspense 用于包裹懒加载组件,并在等待期间渲染 fallback 内容。

import { lazy, Suspense, useState } from "react";

const Card = lazy(() => import("./Card"));
const SuspenseB = () => {
  const [num, setNum] = useState(0);
  return (
    <div className="suspense-b">
      <p>num: {num}</p>
      <button onClick={() => setNum(num + 1)}>click</button>
      <Suspense fallback={<div className="suspense-b-fallback">Loading...</div>}>
        <Card />
      </Suspense>
    </div>
  );
};
export default SuspenseB;

懒加载组件开始到成功的 三个阶段

初始化 状态 -1

image.png

加载中 状态 0

image.png

加载完成 状态 1

image.png

import {  useState, } from "react";

const Card = () => {
  const [count, setCount] = useState(0);
  return (
    <div className="card">
      <p>Count: {count}</p>
      <button onClick={() => setCount((val) => val + 1)}>Click me</button>
    </div>
  );
};

export default Card;

调用 React.lazy(() => import('./Component')) 会生成一个特殊的“懒加载对象”,内部包含 _status(初始为 Uninitialized)和 _result(存储加载器函数)。

image.png

image.png

beginWork

image.png

fiber.elementType

image.png

resolveLazy 解析 lazy 组件

image.png

lazy._init(lazy._payload) 执行初始化函数

image.png

回到 resolveLazy 解析 lazy 组件

全局变量 suspendedThenable 为 promise pending状态

image.png

 const SuspenseException: mixed = new Error(
  "Suspense Exception: This is not a real error! It's an implementation " +
    'detail of `use` to interrupt the current render. You must either ' +
    'rethrow it immediately, or move the `use` call outside of the ' +
    '`try/catch` block. Capturing without rethrowing will lead to ' +
    'unexpected behavior.\n\n' +
    'To handle async errors, wrap your component in an error boundary, or ' +
    "call the promise's `.catch` method and pass the result to `use`.",
);

handleThrow 负责处理渲染过程中抛出的各种异常(包括普通错误和 Suspense 挂起)

image.png

getSuspendedThenable

全局变量重置 为 null ,返回 promise pending

image.png

回到 handleThrow

image.png

renderRootSync

全局变量 workInProgressSuspendedReason 为 3, 代表 SuspendedOnImmediate 因任务立即挂起

image.png

找到边界

image.png

// 未挂起,正常渲染
const NotSuspended: SuspendedReason = 0;
// 渲染过程中抛出异常
const SuspendedOnError: SuspendedReason = 1;
// 等待异步数据
const SuspendedOnData: SuspendedReason = 2;
// 因立即任务挂起
const SuspendedOnImmediate: SuspendedReason = 3;
// 因实例挂起
const SuspendedOnInstance: SuspendedReason = 4;
// 因实例挂起但准备继续
const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
// 因废弃的 Promise 挂起
const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 6;
// 挂起准备继续
const SuspendedAndReadyToContinue: SuspendedReason = 7;
// 因 hydration 挂起
const SuspendedOnHydration: SuspendedReason = 8;
// 因 action 挂起
const SuspendedOnAction: SuspendedReason = 9;

throwAndUnwindWorkLoop 处理渲染过程中的异常/挂起,展开栈并找到处理边界

image.png

throwException 处理渲染阶段的异常和 Suspense

image.png

attachPingListener 为 Suspense 边界的挂起 Promise(wakeable)添加“ping”监听器

image.png

渲染 fallback

再次进入 ,lazy组件还是加载中

image.png

懒加载组件加载完成

image.png

加载完毕是一个函数组件

image.png

示例 命名导出组件的懒加载

const InfoCard = lazy(() =>
  import("./Card").then((mod) => ({ default: mod.InfoCard })),
);
export const InfoCard = () => {
  const [count, setCount] = useState(0);
  return (
    <div className="info-card">
      <p>Info Card</p>
      <button onClick={() => setCount((val) => val + 1)}>
        InfoCard-Click me
      </button>
      <p>InfoCard Count: {count}</p>
    </div>
  );
};

beginWork 阶段

fiber.tag = 16 , 代表 LazyComponent

case LazyComponent: {
  const elementType = workInProgress.elementType;
  return mountLazyComponent(
    current,
    workInProgress,
    elementType,
    renderLanes,
  );
}

image.png

completeWork 阶段

case LazyComponent:
case SimpleMemoComponent:
case FunctionComponent:
case ForwardRef:
case Fragment:
case Mode:
case Profiler:
case ContextConsumer:
case MemoComponent:
  bubbleProperties(workInProgress);
  return null;

源码

const Uninitialized = -1; // 未初始化,未调用
const Pending = 0; // 加载中
const Resolved = 1; // 加载成功
const Rejected = 2; // 加载失败
function lazy<T>(
  ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {

  // 创建 payload 对象
  const payload: Payload<T> = {
    // We use these fields to store the result.
    _status: Uninitialized, // 初始化,未调用
    _result: ctor, // 存储工厂函数
  };

  // 创建 lazyType 对象   
  const lazyType: LazyComponent<T, Payload<T>> = {
    $$typeof: REACT_LAZY_TYPE, // 标识是 lazy 组件
    _payload: payload, // 存储 payload 对象,加载信息
    _init: lazyInitializer, // 初始化函数
  };

  return lazyType;
}
  • 未初始化状态(_status === Uninitialized),状态变为 Pending,执行 throw payload._result;,抛出 thenable。这正是 React Suspense 的触发点。
  • 成功回调:当模块加载成功时,将 payload._status 设置为 Resolvedpayload._result 设置为模块对象。
  • 失败回调:将 payload._status 设置为 Rejectedpayload._result 设置为错误对象。如果是 Rejected,抛出错误,由最近的错误边界(Error Boundary)捕获
function lazyInitializer<T>(payload: Payload<T>): T {
  // 未初始化处理
  if (payload._status === Uninitialized) {
    let resolveDebugValue: (void | T) => void = (null: any);
    let rejectDebugValue: mixed => void = (null: any);
    const ctor = payload._result; // 加载器函数 () => import("")
    const thenable = ctor(); // 加载器函数返回的 Thenable 对象

    // 监听 Promise 状态变化
    thenable.then(
      moduleObject => { // 加载成功
        // 正在加载、未初始化
        if (
          (payload: Payload<T>)._status === Pending ||
          payload._status === Uninitialized
        ) {
          // Transition to the next state.
          const resolved: ResolvedPayload<T> = (payload: any);
          resolved._status = Resolved; // 设置状态为加载成功
          resolved._result = moduleObject; // 设置结果为模块对象


          if (thenable.status === undefined) {
            const fulfilledThenable: FulfilledThenable<{default: T, ...}> =
              (thenable: any);
            fulfilledThenable.status = 'fulfilled'; // 设置状态为加载成功
            fulfilledThenable.value = moduleObject; // 设置值为模块对象
          }
        }
      },
      // 加载失败
      error => {
        if (
          (payload: Payload<T>)._status === Pending ||
          payload._status === Uninitialized
        ) {
          // Transition to the next state.
          const rejected: RejectedPayload = (payload: any);
          rejected._status = Rejected; // 设置状态为加载失败
          rejected._result = error; // 设置结果为错误对象
 
          if (thenable.status === undefined) {
            const rejectedThenable: RejectedThenable<{default: T, ...}> =
              (thenable: any);
            rejectedThenable.status = 'rejected';
            rejectedThenable.reason = error;
          }
        }
      },
    );


    // 未初始化
    if (payload._status === Uninitialized) {
      const pending: PendingPayload = (payload: any);
      pending._status = Pending;
      pending._result = thenable;
    }
  }
  // 加载成功
  if (payload._status === Resolved) {
    const moduleObject = payload._result;
    return moduleObject.default; // 返回模块对象的默认导出
    
  } else {
   // 抛出 thenable。这正是 React Suspense 的触发点
    throw payload._result;
  }
}

Suspense

Suspense 是 React 内置的组件,用于包裹那些可能“挂起”(Suspend)的子组件。当子组件抛出 Promise(或 React 内部的 Suspense 异常)时,Suspense 会捕获并渲染 fallback 属性指定的占位内容,直到 Promise 解决后重新渲染子组件。

suspense 能够实现:

  • 并行等待多个资源。
  • 避免加载闪烁(快速加载时不显示 fallback)。
  • 与错误边界(Error Boundary)无缝集成。

注意事项

  1. 避免在 fallback 中再使用 Suspense
  2. Suspense 不能捕获错误 。它只处理 Promise 挂起,普通错误(如运行时错误)需要 Error Boundary。

beginWork

Suspense 组件会根据是否已捕获挂起(DidCapture 标记)或需要停留在 fallback 状态,决定本次渲染显示 fallback 还是 primary 内容:

  • 若需显示 fallback,则创建 fallback 子树并将 primary 子树包裹为隐藏的 Offscreen 组件以保留状态。
  • 否则正常渲染 primary 子树。
case SuspenseComponent:
      return updateSuspenseComponent(current, workInProgress, renderLanes);

completeWork

Suspense 组件负责完成水合收尾(处理 SSR 脱水节点)、处理 DidCapture 标记以触发重新渲染 fallback、调度重试队列(为等待的 Promise 附加 ping 监听器),并标记因 fallback/primary 切换而产生的副作用(如添加 Visibility 或 Passive 标记),最后向上冒泡 childLanes

commit 阶段

Mutation 子阶段

  • 通过 Offscreen 组件的 Visibility 标记,对 primary 树执行 display: none(隐藏)或恢复显示。
  • 处理边界删除时的清理工作(解绑 ref、调用 componentWillUnmount)。
  • 清空已完成的重试队列。

Layout 子阶段

  • 执行 scheduleRetryEffect 中调度的重试回调:为 retryQueue 中的每个 Promise 附加 ping 监听器(pingSuspendedRoot)。
  • 允许子组件(primary 或 fallback)正常执行 useLayoutEffect 和 componentDidMount/Update

Passive 阶段(异步):

  • 执行 Offscreen 子树上因可见性变化而挂起的 useEffect 清理和回调。

示例 代码分割

import { lazy, Suspense, useState } from "react";

const Card = lazy(() => import("./Card"));
const SuspenseB = () => {
  const [num, setNum] = useState(0);
  return (
    <div className="suspense-b">
      <p>num: {num}</p>
      <button onClick={() => setNum(num + 1)}>click</button>
      <Suspense fallback={<div className="suspense-b-fallback">Loading...</div>}>
        <Card />
      </Suspense>
    </div>
  );
};
export default SuspenseB;

beginWork updateSuspenseComponent

当前正在处理 workInprocess 是 suspense 组件,有属性pendingProps包含children 和 fallback,current为 null

image.png

image.png

mountSuspensePrimaryChildren

直接渲染 primary fiber

mountWorkInProgressOffscreenFiber 创建 Offscreen fiber

image.png

这里return,结束当前的beginWork

image.png

来到 beginWork updateOffscreenComponent

此时 workInProcess 为 Offscreen fiber,(之前 suspense要渲染的primary fiber),current 为 null

image.png

首次挂载创建 Offscreen 实例,用于存储 Offscreen 的可见性、待处理的标记、重试缓存及相关 transitions

image.png

image.png

reconcileChildren 子节点

createFiberFromTypeAndProps 创建 lazy fiber

return,又结束此次 beginWork

image.png

来到 beginWrok lazy fiber

image.png

在解析懒加载组件时,会 有微任务产生

进入 beginWork tag 为13 suspense

image.png

支持显示 fallback

挂载 primary fiber,mode 为隐藏状态

image.png

创建 fallback fiber,类型为 Fragment

image.png

关系

workInProcess.childprimary fiber

primary fibersiblingfallback fiber

image.png

image.png

image.png

beginWork 结束,再次进入beginWork 处理 fallback fiber

当 lazy 加载完成后,继续处理

示例 数据获取 use

import { use, useState, Suspense } from "react";

const fetchData = (async () => {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
  return response.json();
})();

const SuspenseC = () => {
  const result = use(fetchData);
  const [count, setCount] = useState(0);

  console.log("state-a-render-----");

  return (
    <div className="state-a">
      <h3>StateA</h3>
      <p>{result?.title}</p>
      <p>当前count: {count}</p>
      <button onClick={() => setCount(count + 1)}>SuspenseC -点击增加</button>
    </div>
  );
};

const App = () => {
  return (
    <Suspense fallback={<div className="suspense-c-fallback">Loading...</div>}>
      <SuspenseC />
    </Suspense>
  );
};
export default App;

最后

手写虚拟DOM后,我反问面试官:key为什么不能用index?

作者 kyriewen
2026年5月28日 18:12

前言

虚拟DOM和diff算法是React面试的“进阶题”,一般不会让手写完整实现,但一旦遇到,就是区分“会用React”和“懂React”的分水岭。大部分前端能说出虚拟DOM的好处,但真要写一个mini版,很多人会卡在diff的key逻辑上。

今天我就还原那次面试:AI生成的虚拟DOM核心代码、我是如何解释diff的、以及为什么“key不能用index”这个问题能让我反客为主。最后附完整代码,你可以直接拿去跑,也可以用来准备面试。

一、AI生成的虚拟DOM核心代码

我在Cursor里输入:

用原生JavaScript实现一个简易虚拟DOM库,包含:

  • h(type, props, ...children) 创建虚拟节点
  • render(vnode) 将虚拟节点转为真实DOM
  • patch(oldVnode, newVnode) 对比并更新真实DOM,支持key属性,实现最小化更新

AI输出的核心结构如下(精简后):

// 创建虚拟节点
function h(type, props, ...children) {
  return { type, props: props || {}, children: children.flat() };
}

// 渲染虚拟DOM到真实DOM
function render(vnode) {
  if (typeof vnode === 'string') return document.createTextNode(vnode);
  const el = document.createElement(vnode.type);
  for (let key in vnode.props) {
    el.setAttribute(key, vnode.props[key]);
  }
  vnode.children.forEach(child => el.appendChild(render(child)));
  return el;
}

// 简易diff(带key优化)
function patch(oldVnode, newVnode, parent = oldVnode.parentNode) {
  // 如果是文本节点
  if (typeof oldVnode === 'string' || typeof newVnode === 'string') {
    if (oldVnode !== newVnode) {
      parent.replaceChild(render(newVnode), oldVnode);
    }
    return;
  }
  // 不同类型,直接替换
  if (oldVnode.type !== newVnode.type) {
    parent.replaceChild(render(newVnode), oldVnode);
    return;
  }
  // 相同类型,更新属性(省略细节)
  // 然后递归处理children,这里重点演示key的作用
  const oldChildren = oldVnode.children;
  const newChildren = newVnode.children;
  const keyedOld = new Map();
  // 将旧节点按key建立索引
  oldChildren.forEach((child, idx) => {
    if (child.props && child.props.key) keyedOld.set(child.props.key, { child, idx });
  });
  // 遍历新节点,复用key相同的节点
  newChildren.forEach((newChild, newIdx) => {
    if (newChild.props && newChild.props.key) {
      const matched = keyedOld.get(newChild.props.key);
      if (matched) {
        // 复用该DOM节点,递归更新子内容
        patch(matched.child, newChild, parent);
        // 移动位置(这里省略,示意核心)
        return;
      }
    }
    // 没有匹配,插入新节点
    parent.appendChild(render(newChild));
  });
}

二、我反问了面试官一个问题

等代码展示完,面试官还没开口,我说:“这个diff算法里用key来匹配节点。很多前端都用过key,但有一个经典误区——把数组索引当key用。您知道为什么这样会有问题吗?”

他来了兴趣:“你说说看。”

我解释:

  • diff算法通过key判断节点是否“相同”。如果用索引,比如列表顺序变了,索引0可能原来对应A,现在对应B,但key相同(都是0),React会认为这两个节点相同,不重新创建,只是更新内容。这样本应销毁A、创建B的场景,变成了复用A并修改内容。如果组件有复杂状态(比如动画、输入框焦点),就会出现状态错乱。
  • 更严重的是,在列表头部插入一个元素,所有后续节点的索引都变了,每个节点都会被“原地修改”,性能反而比不用key还差。
  • 正确做法是用数据中唯一稳定的标识(如id)作为key。

他点头:“这才是我想听到的答案。”

三、为什么面试官认可这种“反客为主”?

他后来告诉我:“你能自己生成正确的diff逻辑,还能主动抛出常见的误区,说明你不仅会写,还真的思考过生产中的坑。这种深度,比背代码有价值。”

所以这道题的关键不是完美写出所有diff逻辑,而是理解key的真实作用。AI帮你搭了骨架,你用自己的理解填充了灵魂。

四、完整可运行的迷你虚拟DOM代码

我把面试中使用的完整代码放在这里,你可以在浏览器控制台运行测试:

// 完整示例(带简版diff和key复用)
function h(type, props, ...children) {
  return { type, props: props || {}, children: children.flat() };
}
function render(vnode) {
  if (typeof vnode === 'string') return document.createTextNode(vnode);
  const el = document.createElement(vnode.type);
  for (let k in vnode.props) el.setAttribute(k, vnode.props[k]);
  vnode.children.forEach(c => el.appendChild(render(c)));
  return el;
}
function patch(oldVnode, newVnode, parent = oldVnode.parentNode) {
  if (oldVnode === newVnode) return;
  // 文本节点
  if (typeof oldVnode === 'string' || typeof newVnode === 'string') {
    if (oldVnode !== newVnode) parent.replaceChild(render(newVnode), oldVnode);
    return;
  }
  if (oldVnode.type !== newVnode.type) {
    parent.replaceChild(render(newVnode), oldVnode);
    return;
  }
  // 更新属性(略)
  // 处理children(简易版:只演示替换,不移动)
  const oldChildren = oldVnode.children;
  const newChildren = newVnode.children;
  const maxLen = Math.max(oldChildren.length, newChildren.length);
  for (let i = 0; i < maxLen; i++) {
    if (i < oldChildren.length && i < newChildren.length) {
      patch(oldChildren[i], newChildren[i], parent.childNodes[i]);
    } else if (i < newChildren.length) {
      parent.appendChild(render(newChildren[i]));
    } else {
      parent.removeChild(parent.childNodes[i]);
    }
  }
}

你可以用这段代码测试列表渲染,尝试改变顺序或插入头节点,观察不用key vs 用index vs 用id的区别。

五、写在最后

虚拟DOM和diff是React的根基,手写一遍能让你对性能优化有更深的体感。AI能帮你快速生成模板,但真正拉开差距的,是对“为什么key不能用index”这种问题的思考深度。

Redux 中间件作用(redux-thunk/redux-saga)

作者 光影少年
2026年5月28日 16:59

Redux 中间件(Middleware)本质上是:
dispatch(action) 到达 reducer 之前,对 action 做增强处理的一层机制。

它主要解决:

  • 异步请求
  • 日志打印
  • 权限校验
  • 接口调用
  • 延迟 dispatch
  • 副作用管理

一、Redux 默认的问题

Redux 原生规定:

store.dispatch({
  type: 'ADD'
})

dispatch 只能发送:

  • 普通对象 action

而且:

  • reducer 必须是纯函数
  • reducer 不能写异步

所以:

setTimeout()
axios()
fetch()

这些都不能直接写进 reducer。

这时候就需要:

中间件 Middleware


二、中间件执行流程

Redux 数据流:

dispatch(action)
   ↓
middleware
   ↓
reducer
   ↓
store 更新
   ↓
view 更新

多个中间件:

dispatch
  ↓
thunk
  ↓
logger
  ↓
saga
  ↓
reducer

三、redux-thunk

1. thunk 是什么

Redux Thunk

Thunk 是 Redux 最常用的异步中间件。

它允许:

dispatch(function)

而不是只能:

dispatch(object)

四、redux-thunk 核心思想

普通 Redux:

dispatch({
  type: 'GET_USER'
})

Thunk:

dispatch(async function(dispatch){
   const res = await axios.get('/user')

   dispatch({
      type:'SET_USER',
      payload: res.data
   })
})

也就是:

dispatch 一个函数

函数内部:

  • 可以写异步
  • 可以再次 dispatch
  • 可以拿到 store

五、thunk 工作原理

内部核心思想:

const thunk = store => next => action => {

   if(typeof action === 'function'){
      return action(store.dispatch, store.getState)
   }

   return next(action)
}

意思:

  • 如果 dispatch 的是函数

    • 就执行它
  • 如果是普通对象

    • 继续传给 reducer

六、thunk 使用流程

1. 安装

npm install redux-thunk

2. 注册 middleware

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'

const store = createStore(
  reducer,
  applyMiddleware(thunk)
)

3. 编写异步 action

export const getUser = () => {

  return async (dispatch) => {

    const res = await axios.get('/api/user')

    dispatch({
      type:'SET_USER',
      payload:res.data
    })
  }
}

4. 页面调用

dispatch(getUser())

七、redux-thunk 优缺点

优点

简单易学

适合:

  • 小项目
  • 中型项目
  • 简单异步

缺点

大型项目容易:

  • 回调地狱
  • action 逻辑混乱
  • 难维护
  • 副作用分散

例如:

dispatch(async ()=>{
   await api1()
   await api2()
   await api3()
})

会越来越复杂。


八、redux-saga

Redux-Saga

Saga 是:

更强大的异步流程管理方案

核心思想:

把异步逻辑单独管理

类似:

  • 后台任务
  • 事件监听
  • 协程
  • generator

九、saga 最大特点

它使用:

Generator

例如:

function* getUserSaga() {

   const res = yield call(api.getUser)

   yield put({
      type:'SET_USER',
      payload:res
   })
}

十、saga 工作流程

dispatch(action)
    ↓
saga监听
    ↓
执行异步任务
    ↓
put(action)
    ↓
reducer

十一、核心 API

takeEvery

监听每次 action

yield takeEvery('GET_USER', getUserSaga)

takeLatest

只保留最后一次请求

适合搜索:

yield takeLatest('SEARCH', searchSaga)

put

等于 dispatch

yield put({
  type:'SET_USER'
})

call

调用异步函数

yield call(api.getUser)

select

获取 store 数据

const state = yield select()

十二、saga 使用流程

1. 安装

npm install redux-saga

2. 创建 sagaMiddleware

import createSagaMiddleware from 'redux-saga'

const sagaMiddleware = createSagaMiddleware()

3. 注册

const store = createStore(
  reducer,
  applyMiddleware(sagaMiddleware)
)

4. 启动 saga

sagaMiddleware.run(rootSaga)

5. 编写 saga

function* getUserSaga(){

   const res = yield call(api.getUser)

   yield put({
      type:'SET_USER',
      payload:res
   })
}

function* rootSaga(){
   yield takeEvery('GET_USER', getUserSaga)
}

十三、thunk vs saga

对比 thunk saga
学习成本
异步方式 函数 Generator
复杂流程 一般
维护性
适合项目 小中型 大型
取消请求 不方便 容易
并发控制
副作用管理 分散 集中

十四、实际项目怎么选

小项目

直接:

  • Redux Toolkit
  • thunk

现在主流:

Redux Toolkit

因为 RTK 默认内置 thunk。


大型项目

复杂场景:

  • websocket
  • mqtt
  • 长连接
  • 多请求编排
  • 权限流
  • 工作流

适合:

  • saga

十五、你现在前端开发里更应该学什么

结合你现在 React + 平台开发经验:

建议优先级:

Redux Toolkit
    ↓
RTK Query
    ↓
redux-thunk
    ↓
redux-saga

因为现在很多公司:

  • 已经不用传统 redux
  • 更偏 RTK

十六、现代 Redux 已经变成这样

以前:

redux
redux-thunk
action
reducer
constants
types

现在:

Redux Toolkit
createSlice
createAsyncThunk
RTK Query

代码量减少很多。


十七、现代写法(推荐)

import { createAsyncThunk } from '@reduxjs/toolkit'

export const getUser = createAsyncThunk(
  'user/getUser',
  async ()=>{

     const res = await axios.get('/user')

     return res.data
  }
)

React 首页秒开优化:用 KeepAlive 实现丝滑的页面缓存

作者 Lee川
2026年5月8日 10:32

React 首页秒开优化:用 KeepAlive 实现丝滑的页面缓存

你是否经历过这样的场景:用户辛辛苦苦滚动了好几屏内容,点进一篇文章看完返回,首页又从头加载,滚动位置全丢了。这种体验对用户来说就像刚到手的冰淇淋掉在了地上——瞬间兴致全无。

本文将带你一步步实现 React 首页 KeepAlive 缓存,让用户在页面间来回切换时保持组件状态、滚动位置,体验接近原生 App。


为什么需要 KeepAlive?

React 的路由切换本质上是卸载旧组件、挂载新组件。这意味着:

问题 表现
状态丢失 useStateuseReducer 全部重置
数据重载 useEffect 再次执行,重复请求 API
滚动丢失 页面回到顶部,用户需要重新翻找
加载白屏 大组件重新渲染,出现短暂 loading

以一个典型的内容流首页为例:用户滚动了 5 页无限滚动内容、浏览了 30+ 篇文章卡片,然后点进去看了一篇详情。返回后,以上全部白费。

KeepAlive 的核心思想:将组件的 DOM 节点和内部状态缓存起来,路由切走时不销毁,切回来时直接复用。


技术选型:react-activation

社区中有几个 KeepAlive 方案,本项目选择 react-activationv0.13.4),原因如下:

  • API 设计友好:对标 Vue 的 <keep-alive>,学习成本极低
  • 滚动位置恢复:内置 saveScrollPosition 属性,开箱即用
  • React 18/19 兼容:基于 Portals 实现,生命周期管理完善
  • 轻量无侵入:包裹现有组件即可,不需要重构路由结构
pnpm add react-activation

实现步骤

第一步:在路由根部挂载 AliveScope

AliveScope 是 KeepAlive 的全局上下文容器,维护一个 DOM 缓存池。它必须包裹在路由组件的最外层:

// src/router/index.tsx
import ReactActivation from 'react-activation'
const { AliveScope } = ReactActivation as any

export default function RouterConfig() {
    return (
        <Router>
            <AliveScope>                    {/* 缓存容器 */}
                <Suspense fallback={<Loading />}>
                    <Routes>
                        <Route path='/' element={<MainLayout />}>
                            <Route index element={<Home />} />
                            <Route path='order' element={<Order />} />
                            <Route path='chat' element={<Chat />} />
                            <Route path='mine' element={<Mine />} />
                        </Route>
                        <Route path='/login' element={<Login />} />
                        <Route path='/post/:id' element={<PostDetail />} />
                    </Routes>
                </Suspense>
            </AliveScope>
        </Router>
    )
}

注意react-activation 是 CommonJS 模块,在 Vite 的 ESM 环境下,需要默认导入后解构取组件。详见文末"踩坑记录"。

AliveScope 的原理是在内存中维护一个 DOM 缓存池(一个隐藏的 <div>)。当被 KeepAlive 包裹的组件"卸载"时,其真实 DOM 被移入缓存池而非销毁;"重新激活"时,DOM 从缓存池移回原位。

第二步:用 KeepAlive 包裹需要缓存的组件

创建一个 KeepAliveHome 组件,将首页包裹起来:

// src/components/KeepAliveHome.tsx
import ReactActivation from 'react-activation'
import Home from '@/pages/Home'

const { KeepAlive } = ReactActivation as any

const KeepAliveHome = () => {
    return (
        <KeepAlive name='home' saveScrollPosition='screen'>
            <Home />
        </KeepAlive>
    )
}

export default KeepAliveHome

这里两个属性是关键:

  • name='home':给缓存起一个唯一名称。同一个 name 的缓存实例会被复用,不同 name 的缓存互不干扰。如果你有多个页面需要缓存(如首页和订单页),给不同的 name 即可。

  • saveScrollPosition='screen':自动保存和恢复滚动位置。'screen' 表示按屏幕视口维度记忆,你也可以传 true 使用默认行为。

第三步:懒加载 + 路由配置

结合 React.lazy 实现代码分割,让首页的 KeepAlive 逻辑按需加载:

// src/router/index.tsx
import { lazy } from 'react'

const Home = lazy(() => import('@/components/KeepAliveHome'))

在路由中使用时,Home 就是包裹了 KeepAlive 的首页组件:

<Route index element={<Home />} />

当用户从首页切到 /post/:id 详情页时:

  1. Home 组件的真实 DOM 被 AliveScope 移入缓存池(不销毁)
  2. 组件内部的 useState、Zustand store、useRef 全部保持原样
  3. 滚动位置被记录

当用户从详情页返回时:

  1. DOM 从缓存池移回页面原位
  2. 组件状态原封不动地恢复
  3. 滚动位置瞬间还原到离开时的位置

整个过程没有 loading 闪烁,没有重复的网络请求。


无限滚动 + KeepAlive 的协同效应

首页使用了 IntersectionObserver 实现的无限滚动(InfiniteScroll 组件):

用户滚动 → 哨兵元素进入视口 → onLoadMore 触发 → fetchPosts(page) → posts 追加到 Zustand
场景 无 KeepAlive 有 KeepAlive
用户滚到第 3 页 20 条帖子已渲染 20 条帖子已渲染
点进详情页 组件卸载,posts 重置为空数组 组件缓存,posts 保持 20 条
返回首页 重新加载第 1 页,用户要重新滚 直接展示 20 条,停留在第 3 页

KeepAlive 缓存了整个组件树,Zustand store 的状态也一并保留——posts 数组、page 计数、hasMore 标记全部完好。用户返回时,连 useEffect 都不会重新执行(因为组件没有重新挂载)。

这里有一个值得注意的细节:InfiniteScrolluseEffect cleanup 函数在组件卸载时会调用 observer.unobserve(),但在 KeepAlive 模式下组件并没有真正卸载——react-activation 通过 HOC 机制让生命周期钩子(useActivate / useUnactivate)来区分"缓存隐藏"和"真正卸载"。


数据流全景

                  ┌──────────────────────────┐
                         AliveScope         
                     (DOM 缓存池容器)        
                                            
  Route: /  ───▶    ┌────────────────────┐  
                      KeepAlive(name='home')│
                      ┌────────────────┐   
                           Home          
                        - Header         
                        - SlideShow      
                        - InfiniteScroll  
                        - PostItem[]     
                      └────────────────┘   
                    └────────────────────┘  
                                            
  Route: /post/:id│   (Home DOM 移入缓存池)   
                  └──────────────────────────┘
                         
                    Zustand Store
                  ┌─────────────────┐
                   posts: Post[]      数据不丢失
                   page: 3            分页状态保留
                   hasMore: true   
                   loading: false  
                  └─────────────────┘

你可能遇到的坑与解法

1. Vite + CJS 模块:导入为 undefined

react-activation 是 CommonJS 模块,在 Vite 的纯 ESM 环境下,命名导入 import { KeepAlive } 会得到 undefined,默认导入 import KeepAlive from 会得到整个 module.exports 对象。

解法:默认导入后解构:

import ReactActivation from 'react-activation'
const { KeepAlive } = ReactActivation as any

as any 是为了绕过 TypeScript 对 CJS 导入类型的限制。

2. 缓存命名冲突

如果多个页面的 KeepAlive 使用了相同的 name,它们会互相覆盖。确保每个需要缓存的页面有唯一的 name

<KeepAlive name='home'>    <Home />    </KeepAlive>
<KeepAlive name='order'>   <Order />   </KeepAlive>
<KeepAlive name='chat'>    <Chat />    </KeepAlive>

3. 不需要缓存的页面不要包裹

像登录页、纯静态页这类不需要缓存的页面,直接用原始组件,不要用 KeepAlive 包裹。过度缓存反而占用内存。

4. 内存考量

被缓存的组件 DOM 一直存在于内存中。对于首页这种核心流量入口,缓存是值得的;但如果你的页面包含大量图片或视频,建议配合虚拟列表或图片懒加载来平衡内存占用。


总结

KeepAlive 不需要改变任何业务代码,只用在路由层做两件事:

  1. 外层套 AliveScope — 提供缓存能力
  2. 目标组件套 KeepAlive — 启用缓存

成本极低,收益显著:页面秒切、状态不丢、请求不重发、滚动位置精准还原。对于内容流、列表页这类"浏览 → 点进 → 返回"的典型场景,KeepAlive 是投入产出比最高的优化手段之一。


实现环境:React 19 + Vite 8 + react-activation 0.13.4 + Zustand 5 + react-router-dom 7

面试手写 KeepAlive:React 组件缓存的实现原理

作者 Lee川
2026年5月8日 20:16

面试手写 KeepAlive:React 组件缓存的实现原理

面试官:"用过 Vue 的 <keep-alive> 吗?如果让你在 React 中手写一个,你会怎么实现?"

这看似是一道框架 API 题,实际上考察的是你对 React 组件渲染机制DOM 复用策略 的理解深度。本文将带你从零手写一个 KeepAlive 组件,把每一步的设计决策讲透彻。


先搞懂本质:KeepAlive 解决什么问题?

看一个具体场景。我们的 App 有两个 Tab:

// App.jsx
const App = () => {
    const [activeTab, setActiveTab] = useState('A')

    return (
        <div>
            <button onClick={() => setActiveTab('A')}>显示A组件</button>
            <button onClick={() => setActiveTab('B')}>显示B组件</button>

            <KeepAlive activeId={activeTab}>
                {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
            </KeepAlive>
        </div>
    )
}

Counter 组件内部有一个 count 状态:

const Counter = ({ name }) => {
    const [count, setCount] = useState(0)
    // 挂载/卸载的生命周期日志
    useEffect(() => {
        console.log('挂载', name)
        return () => console.log('卸载', name)
    }, [])

    return (
        <div>
            <h3>{name}视图</h3>
            <p>当前计数:{count}</p>
            <button onClick={() => setCount(count + 1)}>加1</button>
        </div>
    )
}

没有 KeepAlive 时,用户在 A 组件把 count 点到 5,切换到 B,再切回 A:

切换 BA 组件卸载(state 销毁,count 归零,DOM 移除)
切回 AA 组件重新挂载(count 重新从 0 开始,useEffect 再次执行)

用户体验:辛辛苦苦点的数全白费了。


核心思路:把 JSX 元素存进一个对象里

React 的组件渲染本质上就是把 JSX 转成 Virtual DOM,再映射到真实 DOM。那如果我们不销毁这个 JSX 对应的 DOM,而是把它"藏起来"呢?

关键认知:JSX 本质上就是一个 JavaScript 对象引用。 只要引用不被 GC 回收,React 内部维护的 Fiber 节点和对应的真实 DOM 就不会被销毁。

设计数据结构:

// cache 对象的结构
{
    'A': <Counter name="A" />,     // JSX 对象引用
    'B': <OtherCounter name="B" />,
}
  • key:用 activeId 作为缓存键,唯一标识每个需要缓存的视图
  • value:存储该视图对应的 JSX 元素(注意:是首次渲染时的那个 JSX 对象,不是每次都创建新的)

一步步写出来

第一版:能跑就行的朴素实现

import { useState, useEffect } from 'react'

const KeepAlive = ({ activeId, children }) => {
    const [cache, setCache] = useState({})

    useEffect(() => {
        if (!cache[activeId]) {
            setCache(prev => ({
                ...prev,
                [activeId]: children
            }))
        }
    }, [activeId, children, cache])

    return (
        <>
            {Object.entries(cache).map(([id, component]) => (
                <div
                    key={id}
                    style={{ display: id === activeId ? 'block' : 'none' }}
                >
                    {component}
                </div>
            ))
            }
        </>
    )
}

export default KeepAlive

逐行解析

1. 缓存状态:const [cache, setCache] = useState({})

用一个对象存储所有被缓存过的视图。为什么用 useState 而不是 useRef?因为我们需要在状态更新时触发重新渲染——新的 children 被存入缓存后,必须让 React 重新执行 render 才能把新 DOM 渲染出来。

2. 缓存时机:if (!cache[activeId])
useEffect(() => {
    if (!cache[activeId]) {
        setCache(prev => ({
            ...prev,
            [activeId]: children
        }))
    }
}, [activeId, children, cache])

这是整个组件的灵魂。判断逻辑是:

场景 cache[activeId] 是否存在 行为
首次切换到某个 Tab 不存在 保存 children 到缓存
再次切换回已缓存的 Tab 已存在 什么都不做,复用旧缓存

注意:这里保存的是第一次渲染时的 children 引用。一旦保存,后续即使 children 变化(其他 Tab 的 JSX),已缓存的引用不会被覆盖。这就是状态得以保留的根源——React 始终渲染的是最初那个 Fiber 节点。

3. 显示策略:display: block / none
{Object.entries(cache).map(([id, component]) => (
    <div key={id} style={{ display: id === activeId ? 'block' : 'none' }}>
        {component}
    </div>
))}

所有被缓存过的组件全部渲染在 DOM 树中,但只把当前激活的那个设为可见:

  • 激活的 Tab:display: block(正常显示)
  • 隐藏的 Tab:display: none(DOM 存在但不可见)

这是整个方案最巧妙的地方:React 看到 {component} 引用没变,不会重新执行函数组件,不会触发 Hooks 重新计算,不会触发 useEffect。Fiber 节点一直挂在树上,状态完好无损。

当你从 B 切回 A 时,控制台不会打印"挂载 A",因为 A 组件的 Fiber 从未被卸载过。这就是 KeepAlive 的本质——DOM 存在但不显示,而非销毁后重建。


运行效果:对比控制台日志

// 初始加载
挂载 A              ← useEffect 触发

// 切换到 B
挂载 BB 首次进入缓存,执行挂载
// 注意:没有 "卸载 A"!

// 切回 A
// 没有 "挂载 A"!    ← A 从未卸载,缓存命中

// 再次切到 B
// 没有 "挂载 B"!    ← B 也从未卸载

A 组件切走时,控制台没有打印"卸载 A",因为 display: none 只是隐藏,React 的 cleanup 函数不会执行。切回来时也没有"挂载 A",计数仍然保持离开时的数字。


面试进阶:面试官可能会追问什么

Q1:为什么用 children 而不是让 KeepAlive 自己去渲染?

// ❌ 不好的设计:KeepAlive 内部 import 组件
<KeepAlive activeId={activeTab} components={{ A: Counter, B: OtherCounter }} />

// ✅ 好的设计:通过 children 让父组件控制渲染
<KeepAlive activeId={activeTab}>
    {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
</KeepAlive>

原因children 模式遵循 React 的组合优于继承原则。父组件完全控制子组件的 props、条件渲染逻辑,KeepAlive 只负责缓存,职责单一。

Q2:所有缓存组件都在 DOM 中,性能会不会有问题?

会有。每个隐藏的组件虽然不可见,但它的 DOM 节点和 Fiber 节点全部真实存在于内存中。如果你的 Tab 内容包含 1000 个列表项,那缓存 10 个 Tab 就是 10000 个 DOM 节点——对内存和首屏渲染性能都是负担。

生产级方案(如 react-activation)会做更精细的优化:通过 React Portal 把隐藏组件的 DOM 移到一个独立的、脱离文档流的容器中挂起。

Q3:useEffect 的依赖数组里有 cache,会不会导致无限循环?

cache[activeId] 不存在时才调用 setCache,更新后的 cacheactiveId 已存在,下次 useEffect 执行时 if (!cache[activeId])false,不会再调用 setCache。所以不会无限循环。

但这里有一个可优化的点:依赖 cache 对象意味着每次缓存更新后 useEffect 都会对整个 cache 重新求值。更好的写法是用函数式 setState + 单独的 useEffect 监听:

useEffect(() => {
    setCache(prev => {
        if (prev[activeId]) return prev  // 已缓存,不更新
        return { ...prev, [activeId]: children }
    })
}, [activeId, children])

这样去掉了对 cache 的依赖,效果一样但更简洁。

Q4:display: none 和条件渲染有什么区别?

display: none 条件渲染 {visible && <Comp />}
DOM 存在 ✅ 存在 ❌ 移除
state 保留 ✅ 保留 ❌ 销毁
useEffect cleanup ❌ 不触发 ✅ 触发
组件函数是否重新执行 ❌ 不执行 ✅ 重新执行

条件渲染的本质是移除 DOM → 销毁 Fiber → 清除 state → 执行 cleanup。display: none 的本质是 DOM 还在 → Fiber 还在 → state 还在 → cleanup 不执行。前者是"删了重建",后者是"藏起来再拿出来"。


从面试代码到生产级方案

这个 25 行的实现抓住了 KeepAlive 的核心思想,但它缺少几个关键能力:

缺失能力 生产级方案(react-activation)
滚动位置恢复 内置 saveScrollPosition 属性
缓存淘汰策略 支持 LRU,限制最大缓存数量
多实例管理 AliveScope 全局缓存池统一调度
生命周期钩子 useActivate / useUnactivate 替代 useEffect
SSR 兼容 提供 SSRKeepAlive 降级方案
动画过渡 切换时可配合 CSS Transition

但面试官要看的不是你会不会用库——而是你是否理解状态保留的本质是保留 JSX 引用,保留引用的本质是不让 Fiber 卸载,不让 Fiber 卸载的本质是 DOM 不离树。


总结

手写 KeepAlive 是一个优质的面试题,它串起了 React 的多个核心概念:

JSX 对象引用 → useState 缓存 → display:none 保活
         ↘        Fiber 持久化       ↙
              状态与 DOM 永不销毁

记住这一条线,你就能在任何面试中把 KeepAlive 的原理讲得明明白白。

一句话版本:KeepAlive = useState 存 JSX 引用 + display: none 隐藏非激活 DOM,让 React 的 Fiber 节点不被卸载,从而保住所有组件内部状态。

react 单向数据流理解

作者 光影少年
2026年5月8日 15:26

在 React 里,“单向数据流(One-Way Data Flow) ” 是最核心的思想之一。

简单理解:

数据只能从父组件流向子组件,不能反过来直接修改。


一、先用一句话理解

React 中:

父组件 state -> 子组件 props -> 页面UI

数据像水流一样:

Parent
  ↓ props
Child
  ↓ props
GrandChild

只能从上往下。


二、为什么叫“单向”?

因为:

  • 父组件可以把数据传给子组件
  • 子组件不能直接修改父组件的数据

例如:

function Parent() {
  const [count, setCount] = React.useState(0);

  return <Child count={count} />;
}

function Child(props) {
  return <h1>{props.count}</h1>;
}

这里:

  • count 在 Parent 中
  • Parent 通过 props 传给 Child
  • Child 只能“使用”
  • 不能:
props.count = 100; // ❌ 错误

因为 props 是只读的。


三、React 为什么这样设计?

因为:

1. 数据变化更容易追踪

你知道:

谁的数据变了
↓
谁重新渲染

不会乱。


2. 组件更稳定

如果子组件可以随便改父组件数据:

A 改一下
B 改一下
C 再改一下

整个应用会非常难维护。


3. 更符合函数式思想

React 组件本质像:

UI = f(state)

即:

输入数据
↓
输出页面

四、单向数据流完整示例


父组件

import React, { useState } from "react";

function App() {
  const [msg, setMsg] = useState("你好");

  return (
    <div>
      <Child message={msg} />
    </div>
  );
}

function Child(props) {
  return <h1>{props.message}</h1>;
}

export default App;

流程:

App state
  ↓
props.message
  ↓
Child UI

五、子组件想修改怎么办?

虽然不能直接改:

props.message = "新值"; // ❌

但可以:

父组件把“修改方法”传下去。


正确方式

function App() {
  const [count, setCount] = useState(0);

  return (
    <Child
      count={count}
      setCount={setCount}
    />
  );
}

function Child({ count, setCount }) {
  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

这里本质还是:

父组件管理数据
子组件通知父组件修改

所以:

数据仍然是单向流动的。


六、非常重要的理解

很多初学者误以为:

子组件调用了 setCount
=
子组件修改了父组件

其实不是。

真正发生的是:

子组件触发事件
↓
调用父组件传来的函数
↓
父组件自己修改 state
↓
重新向下传递 props

所以数据方向始终没变:

->

七、React 单向数据流 vs Vue 双向绑定

Vue 常见:

v-model

看起来:

数据 ↔ UI

而 React 更强调:

state -> UI

React 更偏:

  • 可预测
  • 易维护
  • 数据透明

大型项目优势非常明显。


八、面试标准回答(很重要)

可以这样回答:


React 的单向数据流指的是:

数据只能从父组件通过 props 向子组件传递,子组件不能直接修改父组件的数据。

React 中:

  • state 通常由父组件管理
  • 子组件通过 props 接收数据
  • 如果子组件需要修改数据,需要调用父组件传递的回调函数

这样可以让:

  • 数据变化可追踪
  • 应用状态更稳定
  • 组件职责更清晰
  • 更容易维护大型项目

九、你必须真正理解的本质

React 核心哲学:

状态驱动视图

即:

state 改变
↓
组件重新渲染
↓
UI 更新

而不是:

直接操作DOM

所以:

React 更关注:

数据怎么流动

而不是:

页面怎么改

十、进阶理解(真正高手)

React 单向数据流最终会形成:

State
 ↓
View
 ↓
Action
 ↓
State

这其实就是:

  • Redux
  • Flux
  • Zustand
  • Mobx(部分)
  • Vuex

等状态管理的核心思想。

也叫:

Flux 架构思想

数据单向循环

十一、最经典一句话

记住:

React 中,谁拥有 state,谁才有资格修改 state。

告别“class 命名地狱”:从面向对象 CSS 到原子 CSS(Tailwind) 的思维跃迁

作者 暗不需求
2026年5月8日 14:50

引言:一封来自“传统 CSS”的挑战书

作为一名前端开发者,你是否常常为“这个 div 该叫什么 class”而苦恼?是否在一个大型项目中,面对庞杂的 CSS 文件,修改一个样式都要瞻前顾后,生怕引发其他模块的“雪崩”?

我们先来看一段非常常见的 HTML 代码:

<button class="primary-btn">提交</button>
<button class="default-btn">默认</button>

对应的 CSS 可能是这样的:

.primary-btn {
  padding: 8px 16px;
  background: blue;
  color: white;
  border-radius: 6px;
}
.default-btn {
  padding: 8px 16px;
  background: #ccc;
  color: #000;
  border-radius: 6px;
}

这个写法有问题吗?在它被抛弃之前,没有。 然而当业务扩张,你发现按钮还有危险按钮、文字按钮、超大按钮……于是你开始用“面向对象 CSS”(OOCSS) 的模式来优化。

/* 基础类:封装共性 */
.btn {
  padding: 8px 16px;
  border-radius: 6px;
  cursor: pointer;
}
/* 扩展类:表现多态 */
.btn-primary {
  background: blue;
  color: white;
}
.btn-default {
  background: #ccc;
  color: #000;
}
<button class="btn btn-primary">提交</button>
<button class="btn btn-default">默认</button>

这便是 OOCSS 的核心思想:封装基类,利用多态和组合实现样式复用。这极大地缓解了样式重复的问题。但它就是终点吗?不,因为我们依然在绞尽脑汁地为各种“业务块”命名,而且 .btn-primary 这个名字仍然带着浓厚的业务属性,很难跨项目复用。

那么,有没有一种方式,能让我们抛开给 class 取名的苦恼,直接在 HTML 中像搭积木一样写样式,甚至在未来让 AI 帮我们直接生成 UI?这就引出了本文的主角——原子 CSS 及其代表性框架 Tailwind CSS


一、原子 CSS 的哲学:从“业务命名”到“视觉属性”

原子 CSS (Atomic/Utility-First CSS) 的意思是,将 CSS 规则拆分成一个个不可再分的、单一职责的小类,每个类只代表一种视觉属性(比如 margin-top: 16pxcolor: reddisplay: flex)。通过像堆积木一样,将这些“原子”组合在一个 HTML 元素上,来构建整个界面。

  • Bad 模式:样式带有太多的业务属性,在一个或少数类名里,样式几乎不能复用。
  • 面向对象 CSS:封装(基类)、多态(业务)、组合,这是一大进步。
  • 原子 CSS
    • 大量的基类,具有极高的复用性。
    • 通过组合来构建界面。
    • 代表性的框架就是 Tailwind CSS
    • 另一个巨大优势:与 LLM(大语言模型)结合,通过自然语言 Prompt 描述布局和风格,能极其高效地生成语义化好的 Tailwind CSS 代码。

原子 CSS 没有神秘的“模态框”、“轮播图”组件,只有 flextext-centerbg-whiteshadow 这些最纯粹的视觉原子。那么,在实际代码中,它是什么样的呢?


二、初探 Tailwind CSS:逐行解析你的第一个原子 UI

这是一个典型的 React 组件,但已经完全融入了 Tailwind CSS 的血液。我们来逐行、逐类解析:

const AriticleCard = () => {
  return(
   <div className="p-4 bg-white rounded-xl shadow hover:shadow-lg transition">  
    <h2 className="text-lg font-bold">Tailwindcss</h2>
    <p className="text-gray-500 mt-2">
      用utlity class 快速构建UI
    </p>
   </div>
  )
}

逐行解释 ArticleCard 组件

  • <div className="p-4 bg-white rounded-xl shadow hover:shadow-lg transition">
    • p-4padding: 1rem; (Tailwind 中 1 unit=0.25rem,所以 4 代表 1rem)。控制内边距。
    • bg-whitebackground-color: white;。设置背景色为白色。
    • rounded-xlborder-radius: 0.75rem;。设置 12px 的大圆角,拟物卡片感。
    • shadowbox-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);。添加一个轻盈的阴影。
    • hover:shadow-lg:当鼠标悬停时,box-shadow 变为更大更重的阴影(变体前缀 hover:)。这是交互反馈。
    • transitiontransition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms;。使阴影变化过程平顺过渡,提升体验。
  • <h2 className="text-lg font-bold">
    • text-lgfont-size: 1.125rem; line-height: 1.75rem;。设定标题为大号字体。
    • font-boldfont-weight: 700;。加粗。
  • <p className="text-gray-500 mt-2">
    • text-gray-500color: rgb(107 114 128);。将文字颜色设为灰色(中等灰度),用于次要描述文本,形成对比层次。
    • mt-2margin-top: 0.5rem;。与上方标题拉开一点距离。

小结:我们看到,整个卡片组件没有写一行自定义 CSS,完全通过组合预定义的原子类,就实现了一个带有悬停效果、层次清晰的内容卡片。你不再需要在 HTML 和 CSS 文件之间来回跳转,大脑的上下文切换成本极大降低。


三、移动优先的响应式设计:像说话一样简单

传统 CSS 中写响应式,要用到 @media 查询,往往分散在不同的 CSS 块底部,维护时极其痛苦。Tailwind 把响应式也变成了“原子类”,通过前缀 {屏幕尺寸}: 即可随时应用。

它是一个经典的“主内容 + 侧边栏”布局:

export default function App() {
    return (
     <div className="flex flex-col md:flex-row gap-4">
        <main className="bg-blue-100 p-4 md:w-2/3">
            主内容
        </main>
        <aside className="bg-green-100 p-4 md:w-1/3">侧边栏</aside>
     </div>
    )
}

逐行解析响应式布局

  • <div className="flex flex-col md:flex-row gap-4">
    • flex:声明一个弹性盒容器(display: flex;)。
    • flex-col:弹性盒子主轴方向为垂直(flex-direction: column;)。这是移动端优先的策略,默认(宽度<768px)时,元素上下堆叠。
    • md:flex-row:当屏幕宽度 ≥ 768px(md 断点)时,主轴方向变为水平(flex-direction: row;),这时主内容和侧边栏左右排列。
    • gap-4:子元素之间的间距为 1remgap: 1rem;),无论是水平还是垂直方向都生效。
  • <main className="bg-blue-100 p-4 md:w-2/3">
    • bg-blue-100:非常淡的蓝色背景,视觉区分。
    • p-4:内边距 1rem。
    • md:w-2/3:在桌面端(≥768px)时,该元素宽度占父容器的 2/3。
  • <aside className="bg-green-100 p-4 md:w-1/3">
    • md:w-1/3:在桌面端时,宽度占 1/3。两者配合,一个完美的 2/3 + 1/3 列布局就完成了。

这种“移动优先”(Mobile First)的设计哲学,让你先保证在小屏幕上体验良好,再通过 md:lg: 这样的前缀逐步增强在大屏幕上的布局。这是现代响应式设计的最佳实践。


四、一个被忽视的性能利器:DocumentFragment 与 JSX 片段

在深入 Tailwind 之前,让我们把目光短暂地投向一个看似与 CSS 无关,但思维相通的概念:Fragment(片段)

1. 原生 JavaScript 的 DocumentFragment

这个例子展示了 DOM 操作中的一个重要性能优化:

const container = document.querySelector('.container');
const p1 = document.createElement('p');
p1.textContent = '111';
const p2 = document.createElement('p');
p2.textContent = '222';

// 创建一个文档碎片结点
const fragment = document.createDocumentFragment(); 
fragment.appendChild(p1);
fragment.appendChild(p2);

// 一次性将所有结点添加到真实 DOM,只引发一次回流(Reflow)
container.appendChild(fragment);

DocumentFragment 是一个轻量级的“虚拟容器”,它不会被渲染到页面上。把多个 DOM 操作先在内存中的 Fragment 完成了,最后一次性挂载到真实 DOM,杜绝了因多次操作导致的重复重绘与回流,极大提升性能。同时,它也避免了为包裹元素而引入多余的无意义 <div> 节点。

2. React 中的 Fragment(<></><React.Fragment>

React 受此启发,要求组件返回一个单一根节点。但某些时候,你并不想在 DOM 中增加一个多余的 <div>,因为这会破坏 CSS 弹性盒或栅格布局的父子关系。Fragment 就是解决方案。

export default function App() {
 return (
  // 使用 <> </> 作为包的根节点
  <>
    <h1>111</h1>
    <h2>222</h2>
    <button className="...">提交</button>
    <button className="...">默认</button>
    <AriticleCard/>
  </>
 )
}

这里的 <>...</> 就是 React.Fragment 的语法糖。它和 DocumentFragment 理念一致:一个不渲染到页面的虚拟包裹节点,既满足了“单一根节点”的语法要求,又保持了 DOM 树的清洁,不产生多余标签

这种追求“精简、直接、无多余包装”的设计哲学,与我们将要讲的 Tailwind CSS 的 Utility-First 理念是否有异曲同工之妙?两者都旨在消除不必要的抽象层


五、Tailwind CSS 与传统 CSS 方案的终极对决

为什么我们要放弃已熟悉的传统 CSS 或 OOCSS,转向 Tailwind?我们用你所有的代码文件进行一次全面对比。

维度 传统 CSS / OOCSS Tailwind CSS (原子CSS) 评述
命名与上下文切换 需要在 .css.html 间频繁切换。为无数状态命名(.btn-primary, .sidebar__item--active),低质量命名是技术债。 无需命名。在 HTML 中直接套用视觉原子类,所见即所得,零切换成本。 Tailwind 让你专注于“效果”,而非“叫什么”。
样式复用与冗余 OOCSS 通过继承/组合复用,但基类库仍需自我构建。独特样式仍会导致代码膨胀。(如 App.css 中大量的独立样式块) 天生高复用flexpt-4 等原子类全局通用,项目越大,新增的 CSS 代码越少。最终打包体积通过 Tree-Shaking 变得极小。 Tailwind 避免了“多写一个新类”的冲动,鼓励用工具集解决。
响应式设计 往往采用多文件或分散的 @media 查询,维护时需在代码中跳跃。(如 App.css 中多处 @media (max-width: 1024px) 内联式响应式md:flex-row 将断点样式与基础样式写在一起,直觉且易维护。 查看一个元素时,它的所有表现(含所有断点)都在眼前。
可维护性与风格一致性 文本颜色、间距可能因手误出现 1px 偏差,时间久了产生“样式污染”。 设计系统即代码text-gray-500p-4 等映射到设计令牌(Design Tokens)的值,强制使用预定义规范,UI 天然统一。 Tailwind 自带一个专业的设计系统约束。
代码耦合度 HTML 类名与 CSS 结构强耦合。删除组件时,经常遗留“僵尸 CSS”。 耦合转移到了 HTML 上。删除一个组件,它的所有样式跟随标签一起消失,彻底告别“僵尸代码”。 这是“成本转移”,从管理样式文件依赖,转为直接管理组件本身的属性。
性能与体验 初始加载整个 CSS 文件(可能很大)。 JIT(即时编译)引擎 按需扫描你的模板,仅生成你用到的原子类,CSS 体积通常极小(< 10KB)。 生产环境下的极致轻量。

六、不仅仅是类名:Tailwind CSS 的进阶与扩展

理解了基础后,让我们跳出你给出的文件,看看 Tailwind 在真实项目中还能如何大放异彩。这些都是你必须知道的扩展知识。

1. 主题定制:打造你的设计语言

仅用一行引入了 Tailwind:

@import "tailwindcss";

但 Tailwind 的强大在于可配置性。通过 tailwind.config.js,你可以覆盖或扩展整个设计系统。例如,你可以定义公司品牌色:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        'brand': '#ff7e5f', // 自定义颜色令牌
        'dark-bg': '#1a202c',
      },
      spacing: {
        '128': '32rem', // 一个超大间距原子
      }
    }
  }
}

然后你就能在代码里直接使用 bg-brandtext-dark-bgp-128 了。这意味着,Tailwind 是你的设计系统的最佳执行者,而非限制者

2. 与 JS 框架的深度融合(以 React 为例)

在 React、Vue 中,我们可以用工具函数优雅地处理动态类名。例如,根据 isActive 状态切换按钮样式:

function MyButton({ isActive }) {
  return (
    <button className={`
      px-4 py-2 rounded 
      ${isActive ? 'bg-blue-600 text-white' : 'bg-gray-200 text-black'}
    `}>
      提交
    </button>
  );
}

搭配 clsxtailwind-merge 这类极小的库,可以让条件类名拼接像德芙一样丝滑,彻底解决类名字符串拼接的混乱。

3. “不会让 HTML 变得臃肿吗?”——组件化就是答案

这是最常见的问题。当你看到 <div class="flex items-center space-x-2 p-4 bg-white shadow-lg rounded-xl ..."> 这么一长串时,确实会感觉不适。

解决方案:封装成组件。 把卡片提取为 AriticleCard 组件一样。那些长长的原子类字符串,只是该组件的“内部实现细节”。在你的业务页面中,你看到的依然是干净、语义化的 <AriticleCard />

所以,原子 CSS 的冗长类名,不是让你到处复制粘贴,而是驱动你更早、更自然地进行组件化拆分

4. AI 时代的 UI 生成:为什么 Tailwind 是大模型的最爱?

目前有一个非常前瞻的观点:

prompt 描述布局、风格和语义化好的 tailwindcss 更有利于生成

确实如此。对于 LLM(如 GPT-4), 生成一个传统 UI 需要它理解一套自制的 CSS 规则,这是不可能的。但生成 Tailwind UI 是极其高效的,因为:

  • 有限且确定的词汇表:大模型只需要学习一套固定的原子类(如 grid, col-span-2, hover:bg-blue-700),而不是无限的、用户自创的命名。
  • 语法就是语义bg-red-500 本身就是视觉描述。模型的 Prompt:“一个红色背景的按钮” → 生成 bg-red-500 text-white px-4 py-2 rounded,匹配度极高。
  • 上下文准确性:由于没有外部样式表依赖,生成的一个独立 HTML 片段就能完全复现视觉样式,非常适合 AI 驱动的低代码或无代码平台。

你现在写下的每一个 Tailwind 类,都是在用一种与未来 AI 协作的语言来构建 UI。


七、结语:拥抱 Utility-First,追寻开发的“心流”

回顾我们走过的路:

我们从传统的 primary-btn 命名困境出发,经历了 OOCSS 的抽象与组合,最终抵达了原子 CSS 的领地。通过分解你提供的 App.jsxApp2.jsx 等代码,我们不仅理解了 flex, md:flex-row, shadow-lg 这些具体指令的细节,更体会到了一种范式转移:将设计决策从样式表拉回到标记本身

这种转移带来了一种称作 “心流” 的开发体验: 当你构建一个界面时,你的目光不再需要在文件标签页之间跳跃。你盯着 HTML (或 JSX),脑海中设想它的外观——蓝色的背景、水平的布局、鼠标悬停时加深的阴影——然后,你的手指几乎无意识地敲出 bg-blue-100, flex, hover:shadow-lg。UI 就这样在你眼前生长出来,如同乐高拼装,每一个积木的质感都了然于胸。

正如 Fragment 组件消灭了不必要的 DOM 包装、追求树的纯净一样,Tailwind CSS 则致力于消灭不必要的样式抽象,追求所见即所得的极致表达。它不只是一个 CSS 框架,更是一种与组件化、设计系统、乃至未来 AI 开发高度契合的前端哲学。

是时候打开你的终端,执行 npm install -D tailwindcss postcss autoprefixer,然后在你下个项目的根组件里,敲下第一个 flex 了。

Tiptap之标注组件

作者 时光足迹
2026年5月8日 11:00

Tiptap 图片组件

图片节点Image Node:只能控制基础属性,如 src,alt,title,width, height

增强图片节点Image Node Pro:增加了浮动工具栏控件,可以操作图片对齐方式,具有下载及删除功能

npx @tiptap/cli@latest add image-node-pro

但是组件安装时,需要授权,高级功能吧

tiptap-5-1.png

不想付费的话,只能自己写了,加一个 align 属性控制

按钮可以用官方的Image Align Button

addAttributes() {
  align: {
    default: 'center',
    parseHTML: element => element.getAttribute('data-align') || 'center',
    renderHTML: attributes => {
      return {
        'data-align': attributes.align
      }
    }
  }
}

Tiptap 表格组件

官方文档:Table

# 安装
npm install @tiptap/extension-table
import { TableKit } from "@tiptap/extension-table";

// 注册使用
const editor = useEditor({
  extensions: [
    // 表格扩展
    TableKit.configure({
      table: {
        resizable: true, // 启用列宽调整
      },
    }),
  ],
});

样式代码需要自己加,自己定义:

tiptap-5-2.png

目前只是实现了预览,新增/编辑暂未实现,里面操作逻辑太多了,感觉好难搞

不过Tiptap付费功能好像有,可以直接用

tiptap-5-3.png

Tiptap 标注组件

根据高亮组件Color Highlight改造而成。

编辑器效果如下所示:

tiptap-5-4.png

编辑器渲染代码,如下所示:

tiptap-5-5.png

移除标注

最开始使用如下代码移除标注:

editor.chain().focus().unsetAnnotation().run();

问题:unsetAnnotation 命令默认只对当前选区生效。如果未选中内容(光标在标注内但未选中文本),可能无法移除。

解决方案:selectParentNode或者是extendMarkRange("annotation")移除前强制选中整个标注内容(适合光标在标注内的场景)

const handleRemove = React.useCallback(() => {
  if (!editor || !editor.isEditable) return false;
  if (!canSetAnnotation(editor)) return false;

  // 关键:如果选区为空(光标在标注内),自动选中整个标注节点
  const { from, to } = editor.state.selection;
  const isEmptySelection = from === to;

  const chain = editor.chain().focus();
  // 若选区为空,先选中整个标注节点(确保作用范围)
  if (isEmptySelection) {
    // chain.selectParentNode();
    chain.extendMarkRange("annotation");
  }
  // 执行移除(和高亮的 unsetMark 逻辑一致)
  const success = chain.unsetAnnotation().run();

  if (success) {
    setAnnotationState({ type: defaultType, info: "" });
  }
}, [editor]);

更新标注

添加标注:

editor.chain().focus().setAnnotation(data).run();

更新标注:需要处理「旧标记属性覆盖」和「选区范围」的问题

const handleApply = React.useCallback(() => {
    if (!editor) return false;

    const { type, info } = annotationState;
    const typeData =
      ANNOTATION_TYPES.find((item) => item.value === type) ||
      ANNOTATION_TYPES[0];
    const data = { ...typeData, type, info };

    const { from, to } = editor.state.selection;
    // 无选区(光标在文本中间)
    const isEmptySelection = from === to;
    // 检查当前选区是否已有 annotation 标记
    const isActive = editor.isActive("annotation");

    const chain = editor.chain().focus();

    // 若选区为空且光标在标注内,自动选中整个标注
    if (isEmptySelection && isActive) {
      chain.extendMarkRange("annotation");
    }
    // 关键:如果已有标注,先移除旧的,确保新属性能生效
    if (isActive) {
      chain.unsetAnnotation();
    }

    // 应用新的标注属性
    const success = chain.setAnnotation(data).run();
    if (success) {
      onApplied?.(data as AnnotationData);
    }
    return success;
  }, [editor, annotationState, onApplied]);

但是如果旁边也有一个标注,更新时,会把旁边的也同步掉;或者把整行内容都标注了

如果希望改变标注的范围,那么需要先移除原有标注,再在新的选区上设置标注

反之,updateAttributes 只会更新当前选区内已存在的标注,而不会改变标注的范围

但是目前是点击文本,就打开弹框了,而不是选中文本,打开弹框,所以也不太适用

最终,还是得精确当前位置的选区,然后进行操作

import {
  findNodeAtPosition,
  findNodePosition,
  isValidPosition,
} from "@/lib/tiptap-utils";

// 若选区为空且光标在标注内,自动选中整个标注
if (isEmptySelection && isActive) {
  // chain.extendMarkRange("annotation");

  // 1. 验证光标位置有效性
  if (!isValidPosition(from)) return false;

  // 2. 找到光标所在的文本节点(确认在标注内)
  const currentNode = findNodeAtPosition(editor, from);
  if (!currentNode) return false;

  // 3. 找到该文本节点的完整位置范围(避免选中相邻标注)
  const nodePosition = findNodePosition({
    editor,
    node: currentNode,
  });
  if (!nodePosition) return false;

  // 4. 精准选中当前标注的范围
  chain.setTextSelection({
    from: nodePosition.pos,
    to: nodePosition.pos + currentNode.nodeSize, // nodeSize 是节点的长度
  });
}

tiptap-5-6.png

Tiptap之造字组件

作者 时光足迹
2026年5月8日 10:51

Tiptap 自定义扩展

Tiptap 的强大之处在于其扩展机制,开发者可以通过以下方式自定义功能:

  1. 继承现有扩展:通过 extend 方法扩展现有节点或标记
  2. 创建新扩展:定义全新的节点或标记类型
  3. 添加属性:为现有扩展添加自定义属性
  4. 重写方法:覆盖默认的行为实现

造字组件

使用继承现有扩展方式创建造字组件,主要用于在文本流中插入和展示那些无法通过常规输入法输入的特殊字符、图标或自定义图形。它实际上是一个特殊的图片节点,用于在文本中插入一个代表特定字符的图片,并且有替换文本(alt)属性

造字组件扩展

造字组件扩展可以直接继承官方 Image 组件,然后添加自定义属性。

需要多一个 glyph 字段就行,能展示替换文本,其实可以直接使用 alt 属性也行。

目前是有两种方案:

  1. 自定义扩展:直接把 extension-image 拷贝过来,在其基础上更改
  2. 继承官方扩展:继承官方 Image 节点,然后添加自定义属性

我选择了第二种,并且直接复用 alt 属性,减少改动,保证稳定性和兼容性。

import { Image as TiptapImage } from "@tiptap/extension-image";
import "./index.scss";

export const GlyphImage = TiptapImage.extend({
  name: "glyphImage",

  addOptions() {
    return {
      ...super.addOptions?.(),
      inline: true, // 强制设置为行内,确保可以在文字中间显示
      HTMLAttributes: { class: "glyph-image" },
    };
  },

  addCommands() {
    return {
      ...super.addCommands?.(),
      // 新增方法
      setGlyphImage:
        (options) =>
        ({ commands }) => {
          return commands.insertContent({ type: this.name, attrs: options });
        },
    };
  },
});
.glyph-image {
  display: inline !important; /* 强制行内显示 */
  /* width: 1em; */
  border: 1px solid #bae6fd; /* 可视化边界 */
}

造字组件使用

import { GlyphImage } from "@/components/tiptap-ui/glyph-image/extension-glyph-image";
// 在编辑器配置中注册组件
const editor = useEditor({ extensions: [GlyphImage] });

// 使用命令插入造字组件
editor.commands.setGlyphImage({
  src: "https://placehold.co/40x40/6A00F5/white",
  alt: "造字替换文本", // 替换文本
  title: "造字标题", // 标题
});

json 数据展示:

{
  "type": "glyphImage",
  "attrs": {
    "src": "/pdf/1-1-2.png",
    "alt": "造字替换文本",
    "title": "造字标题"
  }
},

tiptap-4-1.png

造字组件弹框

同“脚注组件”一样,参照“链接组件”改造:

  • 图片地址 src:可以直接输入地址,也可以上传图片
  • 替换文本 alt:复用 alt 属性作为替换文本,利用 title 属性提供鼠标悬停提示
// glyph-image-popover.tsx文件
const GlyphImageMain: React.FC<GlyphImageMainProps> = ({
  src,
  setSrc,
  alt,
  setAlt,
  setGlyph,
  glyphUpload,
  isActive,
  uploading,
  uploadProgress,
}) => {
  const fileInputRef = React.useRef < HTMLInputElement > null;

  const handleFileChange = async (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    const file = event.target.files?.[0];
    if (file) {
      try {
        await glyphUpload(file);
      } finally {
        // 清空input,允许重复选择同一文件
        if (fileInputRef.current) {
          fileInputRef.current.value = "";
        }
      }
    }
  };
  const handleUploadClick = () => {
    if (!uploading) {
      fileInputRef.current?.click();
    }
  };

  return (
    <Card>
      <CardBody>
        <CardItemGroup>
          <Input
            type="url"
            placeholder="输入图片地址(src)"
            value={src}
            onChange={(e) => setSrc(e.target.value)}
          />
          <Input
            type="text"
            placeholder="输入替换文本(alt)"
            value={alt}
            onChange={(e) => setAlt(e.target.value)}
          />

          <ButtonGroup orientation="horizontal" className="justify-end mt-2">
            <Button
              type="button"
              onClick={handleUploadClick}
              title="上传图片"
              data-style="outline"
              disabled={uploading}
            >
              {uploading ? `上传中${Math.round(uploadProgress)}%` : "上传图片"}
            </Button>
            {/* 隐藏的文件输入 */}
            <input
              ref={fileInputRef}
              type="file"
              accept="image/*"
              onChange={handleFileChange}
              style={{ display: "none" }}
            />

            <Button
              type="button"
              onClick={setGlyph}
              title="保存造字"
              disabled={!src && !isActive}
              data-style="outline"
              className="ml-2"
            >
              保存
            </Button>
          </ButtonGroup>
        </CardItemGroup>
      </CardBody>
    </Card>
  );
};

tiptap-4-2.png

图片上传时,不使用 base64 保存图片,而是通过 OSS 保存到阿里云服务器,富文本组件中置保存地址即可

  • use-glyph-image-popover.ts文件

tiptap-4-4.png

tiptap-4-5.png

  • /lib/tiptap-utils.ts 文件

tiptap-4-6.png

最终效果,如下图所示:

tiptap-4-3.png

造字组件高亮问题

选中图片的时候,造字组件是高亮的,需要修复。

tiptap-4-8.png

主要是修改canSetGlyph方法:脚注组件也是类似的,修改canSetFootnote即可

// 检查是否可以设置造字
export function canSetGlyph(editor: Editor | null): boolean {
  // 基础校验:编辑器是否存在或者编辑器是否可编辑
  if (!editor || !editor.isEditable) return false;

  // 节点合法性检测
  // - 检查"glyphImage"节点是否在编辑器的schema中注册(确保功能支持)
  // - 检查当前选中的节点是否为"image"类型(避免与普通图片冲突)
  if (
    !isNodeInSchema("glyphImage", editor) || 
    isNodeTypeSelected(editor, ["image"])
  )
    return false;

  // 最终校验:调用编辑器的can方法检查是否可以执行setGlyphImage命令
  return editor.can().setGlyphImage?.() || false;
}

Tiptap 之自定义脚注组件

作者 时光足迹
2026年5月8日 10:51

Tiptap 的强大之处在于其扩展机制,开发者可以通过以下方式自定义功能:

  1. 继承现有扩展:通过 extend 方法扩展现有节点或标记
  2. 创建新扩展:定义全新的节点或标记类型
  3. 添加属性:为现有扩展添加自定义属性
  4. 重写方法:覆盖默认的行为实现

脚注组件Footnote

脚注组件 Footnote 是通过第二种方式,即创建新扩展实现的。总体参照 LinkPopover 组件改造,完成上标及悬浮提示的功能。

创建脚注组件扩展

// extension-footnote.ts
import { Node, mergeAttributes } from "@tiptap/core";

export interface FootnoteOptions {
  HTMLAttributes: Record<string, any>;
}

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    footnote: {
      /** 设置脚注(插入或更新) */
      setFootnote: (attrs: { text: string; content: string }) => ReturnType;
      /** 移除脚注 */
      unsetFootnote: () => ReturnType;
      /** 更新脚注 */
      updateFootnote: (attrs: { text: string; content: string }) => ReturnType;
    };
  }
}

export const Footnote = Node.create<FootnoteOptions>({
  name: "footnote", // 节点唯一标识
  group: "inline", // 属于行内元素组,可嵌入文本中
  inline: true, // 行内节点
  atom: true, // 原子节点,不可拆分
  selectable: true, // 可被选中

  addAttributes() {
    return {
      // 脚注符号(上标显示的内容,如①②③④⑤等)
      text: {
        default: "", // 默认符号
        parseHTML: (element) => element.getAttribute("data-text"),
        renderHTML: (attrs) => ({ "data-text": attrs.text }),
      },
      // 脚注内容(悬浮提示/编辑内容)
      content: {
        default: "",
        parseHTML: (element) => element.getAttribute("data-content"),
        renderHTML: (attrs) => ({ "data-content": attrs.content }),
      },
    };
  },

  // 解析规则:识别带data-footnote属性的sup标签
  parseHTML() {
    return [
      {
        tag: "sup[data-footnote]",
        getAttrs: (dom) => {
          if (typeof dom !== "object") return false;
          const element = dom as HTMLElement;
          return {
            text: element.getAttribute("data-text"),
            content: element.getAttribute("data-content"),
          };
        },
      },
    ];
  },

  // 渲染逻辑:上标标签+自定义符号+内容属性
  renderHTML({ node, HTMLAttributes }) {
    const { text, content } = node.attrs;

    return [
      "sup", // 使用上标标签,符合脚注排版习惯
      mergeAttributes(
        this.options.HTMLAttributes,
        {
          "data-footnote": "", // 标识为脚注节点
          "data-text": text,
          "data-content": content,
          class: "footnote-marker",
          // title: content,
        },
        HTMLAttributes,
      ),
      text, // 显示脚注符号
    ];
  },

  // 插入命令:接收符号和内容参数
  addCommands() {
    return {
      setFootnote:
        (attrs: { text: string; content: string }) =>
        ({ commands }) => {
          return commands.insertContent({
            type: this.name,
            attrs,
          });
        },
      unsetFootnote:
        () =>
        ({ commands }) => {
          return commands.deleteSelection();
        },
    };
  },

  addKeyboardShortcuts() {
    return {
      "Mod-Shift-F": () => {
        return this.editor.commands.setFootnote({
          text: "①",
          content: "请输入脚注内容",
        });
      },
    };
  },
});
  1. 编辑器配置
const editor = useEditor({ extensions: [Footnote] });
  1. 命令创建
// 插入脚注
editor.commands.setFootnote({
  text: "①",
  content: "这是脚注内容",
});

// 移除脚注(需要先选中脚注节点)
editor.commands.unsetFootnote();
  1. JSON 数据初始化
// 上标
{
  "type": "text",
  "marks": [{ "type": "superscript" }],
  "text": "②"
},
// 脚注
{
  "type": "footnote",
  "attrs":
    "text": "②",
    "content": "这是脚注内容"
},
  1. 渲染效果

如下所示:脚注内容是通过 title 属性显示的,使用的浏览器默认样式,需要优化

tiptap-3-1.png

解析源码:

tiptap-3-2.png

脚注弹框组件

整体依照 LinkPopover 组件改造

使用到了文本框组件 TextareaAutosize,需要先安装一下,样式我也调整了一下,参考Input组件对齐:

npx @tiptap/cli@latest add textarea-autosize

tiptap-3-8.png

选中文本初始化标记

默认情况下,脚注标记和脚注内容都是空的;如果选中文本后,再点击脚注组件,则会将选中的文本作为脚注标记,自动填充进去。

tiptap-3-3.png

const setFootnote = React.useCallback(() => {
  if (!text || !editor) return;

  const { selection, doc } = editor.state;
  // 获取选中文本
  const selectedText = doc.textBetween(selection.from, selection.to, "\n");
  // 文本赋值
  const finalText = selectedText || text;

  let chain = editor.chain().focus();

  // 如果已经选中了脚注,就更新它
  if (isFootnoteActive(editor)) {
    chain = chain.updateFootnote({ text: finalText, content });
  } else {
    // 否则插入新的脚注
    chain = chain.setFootnote({ text: finalText, content });
  }

  chain.run();
  onSetFootnote?.();
}, [editor, onSetFootnote, text, content]);
React.useEffect(() => {
  if (!editor) return;

  const updateFootnoteState = () => {
    const { selection, doc } = editor.state;
    // 提取选中的文本
    const selectedText = doc.textBetween(selection.from, selection.to, "\n");

    const { text: curText, content: curContent } =
      editor.getAttributes("footnote");

    // 如果有选中的文本且当前不是编辑已有脚注,自动填充到 text
    if (selectedText && !isFootnoteActive(editor)) {
      setText(selectedText);
    } else {
      setText(curText || "");
    }
    setContent(curContent || "");
  };

  editor.on("selectionUpdate", updateFootnoteState);
  return () => {
    editor.off("selectionUpdate", updateFootnoteState);
  };
}, [editor]);

行首插入问题

父节点是 h1,在行首插入脚注时,会将父节点变成 p 标签,导致类型都变了。

  • 问题原因

当光标位于行首且没有选中任何内容时,insertContent 会尝试在当前块级节点(如 H1)的最开始插入脚注节点。如果 H1 的 schema 约束不够宽松,编辑器可能会为了兼容插入的节点而修改父节点类型。

这种方式在行首空选择时可能会破坏父节点(如 H1)的结构约束,导致编辑器自动将 H1 降级为 P 标签。

  • 问题解决:先插入一个空文本节点

零宽空格 Unicode: \u200B

chain = chain
  .insertContent("") // 解决插入行首时,将h1改成p了
  .setFootnote({ text: finalText, content });

上面代码可以解决,但是每次都插入一个零宽空格,也不好,需要继续优化setFootnote命令。

目前没找到更好的方法,只能这样了。。。。。。

tiptap-3-4.png

提示优化

默认使用的title属性显示脚注内容,但是这样无法实现点击时弹出提示框,需要自定义处理addNodeView

// 修改extension-footnote.tsx代码
const FootnoteView = ({ node }: any) => {
  const { text, content } = node.attrs;
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <sup data-footnote="">{text}</sup>
      </TooltipTrigger>
      <TooltipContent>
        <p>{content}</p>
      </TooltipContent>
    </Tooltip>
  );
};

// 绑定自定义 NodeView:不行,sup都变成span了
addNodeView() {
  return ReactNodeViewRenderer(FootnoteView);
},

上述方法不行,元素都被改变了,sup 变成 span 了

tiptap-3-5.png

还是回归最原始的方法了,更改 鼠标悬浮时title提示的样式

tiptap-3-6.png

tiptap-3-7.png

深入浅出:用 React 打造高性能懒加载无限滚动组件

作者 Lee川
2026年5月7日 16:59

深入浅出:用 React 打造高性能懒加载无限滚动组件

在现代 Web 开发中,性能优化用户体验往往是一对矛盾的统一体。我们既希望一次性给用户展示海量的数据(如社交媒体的动态流),又不希望页面因为加载过重而卡顿。为了解决这一问题,懒加载(Lazy Loading)无限滚动(Infinite Scroll) 应运而生。

今天,我们将深入剖析一个基于 React 构建的高性能无限滚动组件。它利用现代浏览器的 Intersection Observer API,巧妙地替代了传统的滚动监听,实现了既优雅又高效的“按需加载”。


组件内容

import { useRef,useEffect } from 'react';

// load more 通用组件
interface InfiniteScrollProps {
    hasMore: boolean; // 是否所以数据都加载了 分页
    isLoading?: boolean; // 滚动到底部加载更多 避免重复触发
    onLoadMore: () => void; // 更多加载的一个抽象 /api/posts?page=2&limit=10
    children: React.ReactNode; // InfiniteScroll 通用的滚动功能,滚动的具体内容接受定制
}
const InfiniteScroll:React.FC<InfiniteScrollProps> = ({
    hasMore,
    isLoading = false,
    onLoadMore,
    children,
}) => {
    // HTMLDivElement React 前端全局提供
    const sentinelRef = useRef<HTMLDivElement>(null);
    useEffect(() => {
        // dom, 组件挂载后
        if (!hasMore || isLoading) return; // 没有更多数据了 或者 加载中 不触发
        // IntersectionObserver 没有性能问题,不需要防抖节流
        const observer = new IntersectionObserver((entries) => {
            if (entries[0].isIntersecting) { // 是否进入视窗 viewport
                onLoadMore();
            }
        }, {
            threshold: 0, // 视窗进入 0% 就触发
        }
        );
        if(sentinelRef.current) {
            observer.observe(sentinelRef.current);
        }
        // 组件卸载时,断开观察(路由切换时,需要断开观察,否则会重复触发)
        return () => {
            if (sentinelRef.current) {
                observer.unobserve(sentinelRef.current);
            }
        }
    },[onLoadMore,hasMore,isLoading])
    // react 不建议直接访问dom,useRef
    return (
        <>
            {children}
            {/* Intersection Observer 哨兵元素 */}
        <div ref={sentinelRef} className="h-4" />
        {
            isLoading && (
                <div className='text-center py-4 text-sm text-muted-foreground'>
                    加载中...
                </div>
            )
        }
        </>
    )
}

export default InfiniteScroll;

🧩 核心概念:什么是 Intersection Observer?

在深入代码之前,我们需要理解一个关键概念:Intersection Observer(交叉观察器)

传统的无限滚动通常通过监听 windowscroll 事件实现。但这种做法存在性能隐患,因为滚动事件触发频率极高,频繁的 DOM 查询(getBoundingClientRect)会导致页面卡顿(俗称“掉帧”)。

Intersection Observer 是现代浏览器提供的原生 API,它允许我们异步监听目标元素是否进入视口,且完全不阻塞主线程,无需手动防抖(Debounce)。

核心角色:
  1. 目标元素(Target): 我们要观察的 DOM 节点。
  2. 根元素(Root): 观察的容器(通常是视口)。
  3. 阈值(Threshold): 目标元素与根元素相交的比例(0-1),达到该比例时触发回调。

💻 代码深度解析

这段代码实现了一个通用的 React 函数组件,利用 TypeScript 定义了清晰的接口,封装了无限滚动的逻辑。

1. 接口定义:明确的契约

代码首先定义了 InfiniteScrollProps 接口,这是组件与外部交互的“契约”:

  • hasMore: boolean数据开关。指示是否还有更多数据可供加载。如果为 false,则停止一切观察行为。
  • isLoading?: boolean加载锁。标记当前是否正在加载数据。这能有效防止用户在快速滚动时触发重复的请求。
  • onLoadMore: () => void加载回调。当用户滚动到底部时,组件会调用此函数(通常用于发起 API 请求,如 /api/posts?page=2&limit=10)。
  • children: React.ReactNode内容占位。这是组件最灵活的部分,允许父组件传入任何需要展示的列表内容。
2. 核心逻辑:哨兵模式

组件内部使用了经典的“哨兵(Sentinel)”模式:

  • 引用创建 (useRef):

    const sentinelRef = useRef<HTMLDivElement>(null);
    

    这里创建了一个对 DOM 元素的引用,用于后续的观察。

  • 副作用管理 (useEffect):
    这是组件的“大脑”,负责观察器的生命周期管理:

    1. 守门人逻辑: if (!hasMore || isLoading) return;
      如果数据已加载完或正在加载中,直接返回,避免无效的观察器创建。

    2. 观察器实例化:

      const observer = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) {
          onLoadMore(); // 触发加载
        }
      }, { threshold: 0 });
      

      这里创建了一个观察器实例。threshold: 0 意味着只要哨兵元素有 1 像素进入视口,就会触发回调。

    3. 观察与清理:
      组件挂载时开始观察哨兵元素,组件卸载时(return 函数)必须调用 observer.unobserve()。这是为了防止内存泄漏和路由切换后的重复触发。

3. JSX 结构:视图层
return (
  <>
    {children}
    <div ref={sentinelRef} className="h-4" />
    { isLoading && <div>加载中...</div> }
  </>
)
  • {children} :渲染传入的列表内容。
  • 哨兵元素:一个高度为 4px 的空 div,作为观察的目标。
  • 加载反馈:当 isLoading 为真时,展示“加载中...”的 UI,给用户明确的视觉反馈。

📊 传统方案 vs. 本方案对比

为了更直观地理解这种实现的优势,我们可以通过下表进行对比:

特性 传统 scroll 事件监听 本方案 (Intersection Observer)
性能表现 较差,需手动防抖,频繁重排重绘 极佳,浏览器原生异步处理,无性能负担
代码复杂度 高,需计算位置、处理兼容性 ,声明式 API,逻辑清晰
触发机制 主线程同步执行 异步回调,不阻塞渲染
重复请求 容易发生,需手动加锁 易于控制,配合 isLoading 状态即可

📝 总结

这个组件是一个典型的现代前端开发范例。它通过 TypeScript 提供了类型安全,利用 React Hooks 管理状态和副作用,并结合 Intersection Observer API 解决了性能痛点。

它不仅解决了长列表的性能瓶颈,还通过简洁的 API 设计(hasMore, isLoading, onLoadMore),让开发者可以轻松地将其集成到博客文章列表、电商商品流等各种场景中。这种“哨兵模式”是目前实现无限滚动的最佳实践之一。

React Fiber 架构详解

作者 charmson
2026年5月6日 23:18

从问题出发,理解 React 16 重写渲染引擎的底层逻辑


一、背景:旧架构(Stack Reconciler)的痛点

React 15 的渲染流程

在 React 15 及之前,渲染引擎叫做 Stack Reconciler(栈调和器)。它的工作方式类比递归调用栈:

render()
  └─ diff 子树 A
       └─ diff 子树 B
            └─ diff 子树 C(深度优先,一口气跑完)

整个 Virtual DOM diff + DOM 更新过程是同步、不可中断的。

核心问题:长任务阻塞主线程

浏览器的主线程是单线程的,它需要同时负责:

任务类型 典型时间预算
JS 执行
样式计算
布局 (Layout)
绘制 (Paint)
用户交互响应 必须 ≤ 16ms(60fps)

当组件树很深、更新量很大时,Stack Reconciler 一次 diff 可能耗时 50ms、100ms 甚至更长。在此期间:

  • 🚫 用户点击无响应
  • 🚫 动画卡帧、掉帧
  • 🚫 输入框输入延迟

这就是著名的 "掉帧"(Jank) 问题。

根本矛盾

Stack Reconciler 无法区分任务的优先级,也无法暂停/恢复工作。它就像一个打电话中途不能挂断的人——无论多重要的事情发生,都得等它说完。


二、解决思路:把"同步长任务"变成"可中断的增量工作"

React 团队受到浏览器 requestIdleCallback API 的启发,提出了核心设计目标:

  1. 可中断(Interruptible) :把渲染工作切分成小单元,每个单元执行完后可以暂停,把控制权还给浏览器
  2. 可恢复(Resumable) :暂停后能从中断点继续
  3. 可丢弃(Cancellable) :低优先级的更新可以被高优先级更新抢占,旧工作直接丢弃重来
  4. 优先级调度(Priority Scheduling) :不同类型的更新(用户输入 vs 数据拉取)有不同优先级

这套新架构就是 Fiber


三、Fiber 是什么?

3.1 Fiber 作为数据结构

Fiber 节点是一个普通的 JS 对象,代表组件树中的一个工作单元。每个 React 元素(组件/DOM 节点)都对应一个 Fiber 节点。

// 简化的 Fiber 节点结构
{
  // 组件信息
  type: MyComponent,       // 组件类型
  key: null,
  stateNode: instance,     // 对应的真实 DOM 或组件实例

  // 树结构(链表,而非树)
  return: parentFiber,     // 父节点
  child: firstChildFiber,  // 第一个子节点
  sibling: nextSibling,    // 下一个兄弟节点

  // 工作信息
  pendingProps: {},        // 即将处理的 props
  memoizedProps: {},       // 上次渲染的 props
  memoizedState: {},       // 上次渲染的 state
  updateQueue: [],         // 待处理的更新队列

  // 调度信息
  lanes: Lanes,            // 优先级(Lane 模型)
  flags: Flags,            // 副作用标记(需要插入/更新/删除)

  // 双缓冲
  alternate: fiber,        // 指向另一棵树的对应节点
}

关键点:Fiber 把树结构改成了链表(return/child/sibling 三指针),这使得遍历可以在任意节点暂停,并且可以通过保存当前 Fiber 指针来恢复。

3.2 Fiber 作为工作调度机制

Fiber 架构把渲染分为两个阶段:

┌─────────────────────────────────────────────────────┐
│              Render Phase(可中断)                   │
│                                                     │
│  beginWork → completeWork → beginWork → ...         │
│                                                     │
│  • 纯计算,无副作用                                    │
│  • 构建 workInProgress 树                            │
│  • 可被高优先级任务打断                                │
└─────────────────────────────────────────────────────┘
                         ↓ commit
┌─────────────────────────────────────────────────────┐
│              Commit Phase(不可中断)                  │
│                                                     │
│  before mutation → mutation → layout               │
│                                                     │
│  • 真实 DOM 操作                                     │
│  • 执行副作用(useEffect、生命周期)                    │
│  • 必须一次性完成,保证 UI 一致性                       │
└─────────────────────────────────────────────────────┘

四、双缓冲树(Double Buffering)

Fiber 维护两棵树:

current 树(屏幕上正在显示的)
    ↕ alternate 指针互相指向
workInProgress 树(正在构建的下一帧)
  • current 树:对应当前屏幕渲染的内容
  • workInProgress 树:在 Render Phase 中悄悄构建,用户看不到

当 workInProgress 树构建完成并 commit 后,两棵树直接交换身份(指针切换,O(1) 操作),新树变成 current 树。

这就像 GPU 的双缓冲渲染,避免用户看到中间状态。


五、优先级调度:Lane 模型

React 18 引入了 Lane(车道)模型 来表示优先级(React 16/17 用的是 expirationTime,后被替代)。

Lane 用二进制位表示,支持批量操作:

SyncLane           = 0b0000000000000000000000000000001  ← 最高优先级
InputContinuousLane= 0b0000000000000000000000000000100  ← 用户输入
DefaultLane        = 0b0000000000000000000000000010000  ← 普通更新
TransitionLane     = 0b0000000000000000000001000000000  ← startTransition
IdleLane           = 0b0100000000000000000000000000000  ← 最低优先级

典型场景:用户正在输入(高优先级),同时有数据请求回来触发列表更新(低优先级):

  1. 列表更新开始渲染(workInProgress 树构建中)
  2. 用户输入事件到来 → 优先级更高
  3. React 打断列表更新,优先处理输入 → 输入框立即响应
  4. 输入处理完毕 → 从头重新渲染列表(之前的 workInProgress 树丢弃)

六、Scheduler:时间切片的实现

React 内部有一个独立的调度器 Scheduler,核心思路:

// 伪代码:workLoop 的时间切片
function workLoop(deadline) {
  while (nextUnitOfWork && !shouldYield()) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  if (nextUnitOfWork) {
    // 还有工作没做完,让出主线程,下一帧继续
    requestIdleCallback(workLoop); // 实际用 MessageChannel 模拟
  } else {
    // 全部做完,进入 Commit Phase
    commitRoot();
  }
}

function shouldYield() {
  return performance.now() >= deadline; // 超过时间片(~5ms)就让出
}

React 没有直接用 requestIdleCallback,因为它在部分浏览器兼容性差、触发频率不可控。实际使用 MessageChannel 模拟,每帧约 5ms 时间片。


七、Fiber 带来的能力全景

能力 依赖机制 代表 API
时间切片 workLoop + shouldYield 默认开启
并发渲染 Render Phase 可中断 createRoot
优先级调度 Lane 模型 startTransition, useDeferredValue
Suspense 渲染可"挂起"并恢复 <Suspense>, use()
并发特性 多个 workInProgress 树 React 18 Concurrent Mode

八、一句话总结

Fiber 把 React 的渲染从"同步递归调用栈"重构为"基于链表的增量工作单元调度系统",使得渲染工作可中断、可恢复、可按优先级调度,从根本上解决了复杂应用的 UI 卡顿问题,并为并发模式奠定了基础。


参考资料

# 深入 React Todos:从零实现一个状态提升与本地持久化的待办应用

作者 暗不需求
2026年5月6日 18:28

引言

在日常开发中,Todo 应用是学习前端框架的“Hello World”级案例,它浓缩了组件化开发的核心模式:状态管理、父子通信、兄弟组件协作、受控组件以及副作用处理。今天我们将基于一个使用 React + Vite + Stylus 构建的 Todo 项目,逐行解析其源码,并总结出可复用的最佳实践。文章会覆盖入口文件、根组件与三个功能组件,最后用表格对比不同组件的职责与数据流向,帮助大家真正掌握 React 的组件化思维。 完整项目链接:gitee.com/hong-strong…

项目总览:组件树与数据流

整个应用由四个组件构成:

App (根组件)
 ├─ TodoInput   (输入添加)
 ├─ TodoList    (列表展示与操作)
 └─ TodoStats   (统计与批量清除)

数据流原则

  1. 状态提升:共享状态 todos 存储在顶层组件 App 中,并通过 props 向下传递给子组件。
  2. 子→父通信:子组件无法直接修改 todos,而是通过父组件传递的回调函数(如 onAddonDelete)来“上报”修改意图,由父组件执行状态更新。
  3. 兄弟组件通信TodoInputTodoListTodoStats 之间没有直接联系,它们都通过与同一个父组件 App 交互实现间接通信。任何操作引发的状态变化都会自动反映到所有相关组件中。

这种模式保证了单一数据源可预测的状态更新,是 React 哲学的基石。

入口文件 main.jsx:React 18 的渲染方式

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

逐行解读

  • StrictMode:React 的严格模式,仅在开发环境下生效。它会对组件进行额外的检查,例如检测不安全的生命周期、过时的 API 以及意外的副作用。包裹 <App /> 有助于我们在开发阶段提前发现问题。
  • createRoot:React 18 引入的新 API,替代了旧版的 ReactDOM.render。它启用并发特性,为后续使用 Suspense、Transitions 等打下基础。
  • document.getElementById('root'):挂载点,对应 index.html 中的 div#root
  • .render(...):将 React 元素树渲染到真实 DOM 中。整个应用从这里启动。

tips:StrictMode 会让组件函数体、初始化函数等执行两次,所以在开发时会发现 useEffect 运行两次,这是刻意设计的,用于暴露副作用问题。

核心:App.jsx —— 状态管理与业务逻辑

根组件是整个应用的“大脑”,负责持有状态、定义修改方法、计算派生数据,以及处理副作用。

import { useState, useEffect } from 'react'
import './styles/app.styl'
import TodoList from './components/TodoList'
import TodoInput from './components/TodoInput'
import TodoStats from './components/TodoStats'

function App() {
  // 1. 状态初始化
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos');
    return saved ? JSON.parse(saved) : [];
  })
  // ...
}

4.1 状态初始化:惰性读取 localStorage

useState 传入了一个函数,而不是直接传值。这是 惰性初始化(Lazy Initial State):该函数只在组件首次渲染时执行一次。如果直接传值,比如 useState(JSON.parse(localStorage.getItem('todos')) || []),每次渲染都会执行 localStorage.getItemJSON.parse,即使其结果已被忽略,造成不必要的性能开销。惰性初始化避免了重复读取,是应对从外部存储恢复状态的标准写法。

当本地存储中没有 todos 时返回空数组 [],否则解析出已有的待办列表。这样用户刷新页面后数据不会丢失。

4.2 操作方法:不可变更新

所有修改方法都遵循 不可变数据(Immutable) 原则,不直接修改原数组,而是返回一个新数组:

const addTodo = (text) => {
  setTodos([...todos, {
    id: Date.now(),
    text,
    completed: false,
  }])
}
  • 使用展开运算符 ...todos 创建新数组,再附加一个新对象。id 用时间戳生成,保证唯一性;completed 初始为 false
  • 优点:React 通过引用比较来判断状态是否变化,不可变更新确保每次调用都会触发重新渲染。
const deleteTodo = (id) => {
  setTodos(todos.filter(todo => todo.id !== id))
}
  • filter 返回一个新数组,剔除指定 id 的项,实现删除。
const toggleTodo = (id) => {
  setTodos(todos.map(todo => todo.id === id ? {
    ...todo,
    completed: !todo.completed,
  } : todo))
}
  • map 遍历数组,找到匹配 id 的 todo,用对象展开 ...todo 复制其余属性,并翻转 completed 状态。未匹配的项原样返回。
const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed))
}
  • 清除所有已完成项,同样通过 filter 返回新数组。

4.3 派生状态与副作用

const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.filter(todo => todo.completed).length;
  • 这两个变量并非 state,而是派生状态(Derived State):它们完全由 todos 计算得出,无需额外维护。每当 todos 变化,函数组件重新执行,这两个值会自动更新。这避免了数据冗余和同步问题。
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos])
  • 副作用处理:当 todos 变化时,将其序列化后存入 localStorage。依赖数组 [todos] 保证仅在 todos 引用改变时执行,避免无限循环。注意:useEffect 会在 DOM 更新后异步执行,不会阻塞渲染,因此不会影响交互流畅度。

4.4 组合视图

return (
  <div className="todo-app">
    <h1>My Todo List</h1>
    <TodoInput onAdd={addTodo}/>
    <TodoList 
      todos={todos} 
      onDelete={deleteTodo}
      onToggle={toggleTodo}
    />
    <TodoStats 
      total={todos.length}
      active={activeCount}
      completed={completedCount}
      onClearCompleted={clearCompleted}
    />
  </div>
)
  • 通过 props 向子组件传递数据todostotal 等)和修改方法onAddonDelete 等)。这些修改方法就是“自定义事件”,子组件调用时相当于向父组件发送了操作请求。
  • 这种设计保持了组件的纯净性:子组件只负责 UI 和触发行为,不关心状态如何存储与变更,实现了高内聚低耦合。

子组件解析

5.1 TodoInput:受控组件与表单提交

import { useState } from 'react'
const TodoInput = (props) => {
  const { onAdd } = props;
  const [inputValue, setInputValue] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    onAdd(inputValue);
    setInputValue('');
  }

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input 
        type="text"
        value={inputValue}
        onChange={e => setInputValue(e.target.value)}
      />
      <button type="submit">Add</button>
    </form>
  )
}

逐行解析

  • const [inputValue, setInputValue] = useState(''):自有状态,管理输入框的文字。这里采用受控组件(Controlled Component) 模式:value 由 React 状态决定,onChange 更新状态,输入框的视图始终与状态同步。相对于 Vue 的 v-model 双向绑定,React 通过“值 + onChange”的组合实现单向数据流,性能与可预测性更好。
  • handleSubmit:阻止表单默认提交行为(避免页面刷新),调用父组件传入的 onAdd 回调,将当前文本传递给 App 进行添加,然后清空输入框。清空动作由本地 setInputValue 完成,体现了局部状态的自治。
  • 子→父通信onAdd(inputValue) 就是子组件向父组件传递数据的唯一途径。

5.2 TodoList:列表渲染与条件样式

const TodoList = (props) => {
  const { todos, onDelete, onToggle } = props;

  return (
    <ul className="todo-list">
      {todos.length === 0 ? (
        <li className="empty">No todos yet!</li>
      ) : (
        todos.map(todo => (
          <li 
            key={todo.id} 
            className={todo.completed ? 'completed' : ''}
          >
            <label>
              <input 
                type="checkbox" 
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}
              />
              <span>{todo.text}</span>
            </label>
            <button onClick={() => onDelete(todo.id)}>X</button>
          </li>
        ))
      )}
    </ul>
  )
}

逐行解析

  • props 解构出 todos(数据)、onDeleteonToggle(操作回调)。
  • 条件渲染:当 todos.length === 0 时显示空状态提示,否则渲染列表。空状态处理提升了用户体验。
  • 列表渲染:用 map 遍历 todos,给每个 <li> 设置唯一 key(这里使用 todo.id),这是 React 虚拟 DOM Diff 算法优化重排的基础。
  • className={todo.completed ? 'completed' : ''} 动态绑定样式,通过样式类名展示完成/未完成状态。
  • 复选框:使用受控组件模式,checked={todo.completed} 由父组件状态决定,onChange 触发 onToggle(todo.id) 通知父组件切换完成状态。注意这里没有在子组件内修改 todo.completed,完全遵循单一数据流。
  • 删除按钮onClick={() => onDelete(todo.id)},同样通过回调将删除意图上报给父组件。

5.3 TodoStats:统计展示与批量操作

const TodoStats = (props) => {
  const { total, active, completed, onClearCompleted } = props;

  return (
    <div className="todo-stats">
      <p>Total: {total} | Active: {active} | Completed: {completed}</p>
      {completed > 0 && (
        <button 
          onClick={onClearCompleted}
          className="clear-btn"
        >Clear Completed</button>
      )}
    </div>
  )
}

逐行解析

  • 接收四个 propstotalactivecompleted 三个统计数据,以及 onClearCompleted 回调。这些数据完全来自父组件计算的派生状态,体现了数据流自上而下
  • 展示统计信息,用管道符分隔,简洁明了。
  • {completed > 0 && (...)}:短路逻辑实现条件渲染,仅当已完成数量大于 0 时才显示“Clear Completed”按钮。避免无意义操作,UI 更清爽。
  • 点击按钮触发 onClearCompleted,无参数,父组件据此清除所有已完成项。

数据流总结与表格分析

整个应用严格遵循 单向数据流,形成了清晰的数据生命周期:

用户操作 → 子组件调用 props 回调 → 父组件更新 state → React 重新渲染
→ 子组件接收新 props → 视图更新

同时,通过 useEffect 将状态持久化到 localStorage,实现了 数据刷新不丢失

下面用一张表格总结各组件的职责与通信方式:

组件 职责 接收的 Props 自有 State 触发的回调(子→父)
App 持有全局状态、定义修改逻辑、持久化 todos 无(它是顶层)
TodoInput 输入新待办,提交添加 onAdd inputValue onAdd(text)
TodoList 展示待办列表,提供完成/删除交互 todos, onToggle, onDelete onToggle(id), onDelete(id)
TodoStats 显示统计信息,提供批量清除入口 total, active, completed, onClearCompleted onClearCompleted()

关键设计要点

  • 状态提升todos 是唯一数据源,放在公共祖先 App 中,避免多组件状态不一致。
  • 兄弟组件解耦TodoInput 添加事项后,无需直接通知 TodoListTodoStats;只因 todos 变化,这些组件通过接收新 props 自动更新。
  • 不可变更新:所有状态更新都使用新数组,保证 React 能够正确检测变化并触发渲染。
  • 受控组件TodoInput 的文本输入与 TodoList 的复选框都受 React 状态控制,杜绝 DOM 直接操作。
  • 惰性初始化与副作用useState 的函数初始器避免重复读取存储,useEffect 负责同步外部系统。

一些总结

  1. 性能优化:如果 todos 数量很大,可以在 TodoList 中使用 React.memo 包裹,避免无关 props 变化导致的重渲染。另外,可以用 useCallback 包裹回调函数,防止因函数引用变化导致子组件不必要的更新。

  2. 唯一 ID 生成:当前使用 Date.now() 在高并发快速添加时可能产生重复。在生产环境中可以改用 crypto.randomUUID() 或成熟库(如 nanoid)。

  3. 类型安全:加入 TypeScript,为 todosprops 定义接口,能大幅减少拼写错误并提升可维护性。

  4. 状态管理扩展:若应用规模扩大,可以考虑使用 useReducer 重构 App 的状态逻辑,将操作集中在 reducer 中,更便于测试和跟踪状态变化;或者引入 Context API 避免深层 props 传递(prop drilling),但小型 Todo 应用目前的模式已足够清晰。

  5. 自定义 Hook:可以将 useState + useEffect 的持久化逻辑封装成 useLocalStorageState 自定义 Hook,提高复用性。

结语

通过这个 React Todo 应用,我们深入剖析了 组件化设计、状态提升、单向数据流、受控组件以及本地持久化 的核心实践。源码虽然精简,却覆盖了 React 开发中绝大部分的思维范式。掌握这些模式后,无论是构建表单系统、管理后台还是复杂交互页面,都能游刃有余。


Tiptap 简单编辑器模版

作者 时光足迹
2026年5月6日 16:38

上一篇介绍了Tiptap 编辑器的基础使用,本篇主要介绍 Tiptap 的简单编辑器模版Simple template,以及如何改造模版。

Tiptap 是一个基于 ProseMirror 的 Headless 富文本编辑器框架,它采用模块化设计,通过扩展(Extensions)机制来实现各种功能。每个扩展可以定义自己的节点(Nodes)、标记(Marks)、命令(Commands)等。

  1. Editor 实例:Tiptap 的核心,管理整个编辑器的状态和行为
  2. Extensions 扩展:功能模块的基本单位,可以是节点或标记
  3. Nodes 节点:文档结构的基本单位,如段落、标题、列表项等
  4. Marks 标记:应用于文本的样式,如粗体、斜体、链接等
  5. Commands 命令:用于修改编辑器状态的操作方法

simple-editor 模版

Tiptap 的 StarterKit 仅提供基础的富文本功能逻辑,不会自带默认样式(如标题字体大小、段落首行缩进等),需要手动添加 CSS 样式来美化内容。

可以安装一个简单编辑器模版Simple template,其包括常用的开源扩展UI 组件

npx @tiptap/cli@latest add simple-editor
  1. SimpleEditor:主编辑器组件,整合所有功能
    • tiptap-templates 文件夹
  2. Toolbar:工具栏组件,提供各种编辑操作按钮
    • tiptap-ui 文件夹
  3. Extensions:预配置的扩展集合,包括基础文本格式、列表、链接等
    • tiptap-ui-primitive 文件夹
  4. Styles:配套的样式文件,提供美观的默认外观

tiptap-2-1.png

tiptap-2-2.png

操作完成后,会在项目中加入一系列的文件,包括组件、配置文件、样式文件等等:

tiptap-2-3.png

global.css全局样式文件中添加模版的样式文件:

/* tiptap */
@import "./styles/_variables.scss";
@import "./styles/_keyframe-animations.scss";

配置完成后,页面样式乱了呀,影响其他内容样式了,如下图所示:

tiptap-2-4.png

排查后,发现是暗黑模式默认宽高影响的,需要调整一下:

import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor";
import { styled } from "styled-components";

export default function Page() {
  return (
    <Container>
      <SimpleEditor />
    </Container>
  );
}

const Container = styled.div`
  width: 500px;
  margin-top: 30px;
  border: 1px solid #eee;
  .simple-editor-wrapper {
    width: 100%;
    height: 300px;
  }
`;

tiptap-2-5.png

simple-editor 改造

主题

默认主题是暗黑模式,改为明亮模式:

// 修改theme-toggle.tsx文件

// 注释掉下列代码:
React.useEffect(() => {
  const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
  const handleChange = () => {
    // setIsDarkMode(mediaQuery.matches)
  };
  mediaQuery.addEventListener("change", handleChange);
  return () => mediaQuery.removeEventListener("change", handleChange);
}, []);

// React.useEffect(() => {
//   const initialDarkMode =
//     !!document.querySelector('meta[name="color-scheme"][content="dark"]') ||
//     window.matchMedia("(prefers-color-scheme: dark)").matches;
//   setIsDarkMode(initialDarkMode);
// }, []);

然后在工具条中去掉该功能即可

工具条样式

默认工具条是横向滚动的,改为自适应

/* 修改simple-editor.css文件 */
.simple-editor-wrapper {
  width: 100%;
  height: 300px;
  border: 1px solid #eee;
  .tiptap-toolbar {
    flex-wrap: wrap;
    /* 或者直接隐藏首个 <Spacer /> 组件 */
    > div:first-of-type {
      flex: 0 !important;
    }
  }
}

调整后效果:

tiptap-2-6.png

工具条汉化

默认工具条是英文的,改为中文:

  1. 操作组件 undo-redo-button
<UndoRedoButton action="undo" />
<UndoRedoButton action="redo" />
// 修改tiptap-ui/undo-redo-button/use-undo-redo.ts文件
export const historyActionLabels: Record<UndoRedoAction, string> = {
  undo: "撤销",
  redo: "重做",
}
  1. 标题组件 heading-dropdown-menu

tiptap-2-7.png

  1. 列表组件 list-dropdown-menu

tiptap-2-8.png

  1. 引用块 blockquote-button

tiptap-2-9.png

  1. 代码块 code-block-button
  2. 文本样式 mark-button
    • 粗体 bold
    • 斜体 italic
    • 删除线 strike
    • 代码 code
    • 下划线 underline
    • 上标 superscript
    • 下标 subscript

tiptap-2-10.png

  1. 高亮组件 color-highlight-button

tiptap-2-11.png

  1. 链接组件 link-popover

tiptap-2-12.png

  1. 对齐组件 align-align-button

tiptap-2-13.png

  1. 图片组件 image-upload-button

改造前: tiptap-2-14.png

改造中: tiptap-2-15.png

改造后:

tiptap-2-16.png

Tiptap编辑器

作者 时光足迹
2026年5月6日 16:37

主要介绍了编辑器的基础使用,包括依赖安装、基础配置,以及 StarterKit 基础扩展、内嵌图片 Image、编辑状态、拖拽手柄、文本样式扩展包等等

基础介绍

ProseMirror:是一款用于在网页端构建富文本编辑器的工具包,核心目标是弥合 “结构化内容编辑”(如 Markdown、XML)与 “经典所见即所得(WYSIWYG)编辑” 之间的差距 —— 既让用户能以直观的 WYSIWYG 方式编辑,又能生成干净、语义明确且符合自定义结构的文档,适用于从简单文本编辑到复杂协作系统的各类场景。

Tiptap 是基于 ProseMirror(经行业验证的网页富文本编辑器工具库)构建的无头(Headless)富文本编辑器框架,核心能力是帮助开发者打造 “完全贴合自身及用户需求” 的定制化编辑器。

其底层依赖三大核心机制实现灵活且强大的编辑功能 API:

  1. 事件(Events):监听编辑器状态变化(如内容修改、光标移动);
  2. 命令(Commands):触发编辑操作(如加粗文本、插入列表);
  3. 扩展(Extensions):扩展编辑器功能(如添加表格、AI 辅助创作)。

核心价值是 “按需构建编辑器”,既提供基础开源编辑功能,也通过云服务和扩展满足复杂场景需求,核心能力分为五大模块,覆盖从基础编辑到高级协作的全场景:Editor(编辑器)、Collaboration(协作)、Content AI(内容 AI)、Comments(评论)、Documents(文档处理)

安装

  1. @tiptap/react:Tiptap 的 React 绑定包,包含核心功能(如 useEditor 钩子、EditorContent 组件)
  2. @tiptap/pm:Tiptap 依赖的 ProseMirror 底层库,是编辑器运行的核心支撑
  3. @tiptap/starter-kit:基础扩展集合,包含段落、标题、加粗、斜体等常用功能,可快速启动项目
pnpm install @tiptap/react @tiptap/pm @tiptap/starter-kit

基础使用

参照官方文档,完成了一个初始 Demo

import { Image } from "antd";
import { styled } from "styled-components";

import { EditorContent, EditorContext, useEditor } from "@tiptap/react";
import { BubbleMenu, FloatingMenu } from "@tiptap/react/menus"; // 导入菜单组件
import StarterKit from "@tiptap/starter-kit"; // 导入基础扩展集合
import { useMemo } from "react";
export default function Page() {
  // 1. 使用 useEditor 钩子初始化编辑器
  const editor = useEditor({
    // 配置扩展(此处使用基础扩展集合)
    extensions: [StarterKit],
    // 编辑器初始内容(HTML 格式)
    content: `
    <h1>第一章原始阶段和商周时期的江南</h1>
    <p>地方特点的青铜器。第一类典型的商代青铜器特别是那些有铭文的铜器,很可能是从中原地区传过来的;第三类具有地方特点的青铜器应是在本地铸造的;第二类青铜器有的可能铸自本地,也有的可能来自中原。</p>
    <p>这些商代铜器除了少数出自遗址和墓葬外,绝大多数出自窖藏,且多出自山顶、山腰、河岸、湖边,很有可能是当时人们祭祀山川、湖泊、日月星辰的遗物。①</p>
    <p>宁乡、湘潭等地出土的商代铜器中,有“己”分裆鼎、“癸母”提梁卤和“戈”卤等少量有铭文的铜器②;在湘潭县青山桥一铜器窖穴中也出土有商末周初的“母”爵,“母”解和“戈”解③。“母”和“戈”本是中原商代铜器中两个常见的族徽,现在江南地区也有铸这两个族徽的铜器出土,说明在商代晚期或末期,他们中的一支曾南迁到了湘中地区。</p>
    <p>湖南境内出土的西周铜器,仍以湘水流域为多,如湘潭、湘乡、浏阳、株洲、望城、衡阳、耒阳、资兴等地都有出土。器形以乐器饶、甬钟和镈为主④,还有湘潭青山桥窖藏出土的爵、解、鼎、凹字形锄等⑤。桃江连河冲出有马簋。⑥</p>
    <p>西周时期的铜饶是紧接着商代晚期的大饶发展而来。商末周初的铜铙为乳钉铙,钲的每面有18个乳钉。这些乳钉的出现可能有两个来源,其乳钉的排列和数量应来源于商代云纹铙上云纹的尾部的上翘,这有江西新干商代大墓中出土的云纹铜饶为证。乳钉的形状应是对于象纹大饶钲边乳钉的承袭。乳钉铙上的乳钉不断升高,在西周初演变为尖锥状和</p>
    `,
  });

  // 缓存 Context 值,避免不必要的重渲染
  const providerValue = useMemo(() => ({ editor }), [editor]);

  return (
    <Container>
      <div className="block-wrap">
        <div className="left">
          <Image src="/pdf/1-1.jpg" />
        </div>
        <div className="right">
          {/* 提供 EditorContext,让子组件可访问编辑器实例 */}
          <EditorContext.Provider value={providerValue}>
            {/* 2. 编辑器内容区域:渲染编辑界面 */}
            <EditorContent editor={editor} />

            {/* 3. 浮动菜单:空行光标定位时显示 */}
            <FloatingMenu editor={editor}>这是浮动菜单</FloatingMenu>

            {/* 4. 气泡菜单:选中文本时显示 */}
            <BubbleMenu editor={editor}>这是气泡菜单</BubbleMenu>
          </EditorContext.Provider>
        </div>
      </div>
    </Container>
  );
}

const Container = styled.div`
  .block-wrap {
    display: flex;
    width: 80%;
    margin: 0 auto;
    > div {
      width: 50%;
    }
  }
`;

显示效果如图 tiptap-1-1 所示:

tiptap-1-1.png

StarterKit 基础扩展集合

StarterKit 默认包含的核心扩展:文档(Document)、段落(Paragraph)、文本(Text)、标题(Heading)、加粗(Bold)、斜体(Italic)、删除线(Strike)、代码(Code)、无序列表(BulletList)、有序列表(OrderedList)等等。

具体入门套件,可以查看官网:StarterKit

在 StarterKit.configure() 中传入配置对象,通过 “扩展名称” 精准定位需修改的模块

import Strike from "@tiptap/extension-strike"; // 导入删除线扩展

new Editor({
  extensions: [
    StarterKit.configure({
      heading: { levels: [1, 2, 3] }, // 调整标题层级
    }),
  ],
});

内嵌图片 Image

具体可查看官方文档:image

# 安装扩展
pnpm install @tiptap/extension-image

使用示例如下所示:

import { Image as TiptapImage } from "@tiptap/extension-image"; // 图片扩展

const editor = useEditor({
  // 配置扩展(此处使用基础扩展集合)
  extensions: [
    StarterKit.configure({
      heading: { levels: [1, 2, 3] }, // 调整标题层级
    }),
    TiptapImage.configure({
      inline: true, // 允许行内
      allowBase64: true, // 允许 base64 格式图片
      HTMLAttributes: {
        class: "tiptap-img-inline", // 自定义样式名
      },
    }),
  ],
  content: `
    <p>宁乡、湘潭等地出土的商代铜器中,有“己<img src="/pdf/1-1-1.png" />”分裆鼎、“癸<img src="https://placehold.co/40x40/6A00F5/white" />”提梁卤和“戈”卤等少量有铭文的铜器②;在湘潭县青山桥一铜器窖穴中也出土有商末周初的“<img src="/pdf/1-1-2.png" />”爵,“<img src="/pdf/1-1-2.png" />”解和“戈”解③。“<img src="/pdf/1-1-2.png" />”和“戈”本是中原商代铜器中两个常见的族徽,现在江南地区也有铸这两个族徽的铜器出土,说明在商代晚期或末期,他们中的一支曾南迁到了湘中地区。</p>
    `,
});

配置了inline: true,设置为行内图片,但是没起作用呀。

排查发现,有个默认样式配置,将图片设置为块元素了,那只能自定义样式处理了呗。

tiptap-1-2.png

只能添加自定义样式HTMLAttributes: {class:"tiptap-img-inline"}

.tiptap-img-inline {
  display: inline; /* 强制行内显示 */
}

tiptap-1-3.png

当然,如果不想要行内显示的话,就不将 img 放到 p 标签内,而是直接放在 p 标签外,这样图片就会块级元素了。

代码展示:

tiptap-1-4.png

渲染效果展示:

tiptap-1-5.png

编辑状态控制

// 控制编辑器可编辑状态
const [isEditable, setIsEditable] = useState(false);
useEffect(() => {
  if (editor) {
    editor.setEditable(isEditable);
  }
}, [isEditable, editor]);

// 开关渲染
<Switch
  checkedChildren="开启"
  unCheckedChildren="关闭"
  checked={isEditable}
  onChange={setIsEditable}
/>;

tiptap-1-6.png

拖拽手柄控制

官方文档:drag-handle-react

文本样式集合包

文本样式集合包含:TextStyle、BackgroundColor(背景色)、Color(颜色)、FontFamily(字体)、FontSize(字号)、LineHeight(行高)

官方文档:text-style-kit

# 安装插件
pnpm install @tiptap/extension-text-style
import { TextStyleKit } from "@tiptap/extension-text-style"; // 文本样式扩展

const editor = useEditor({
  extensions: [
    StarterKit,
    // 添加文本样式功能集合,包含字体、颜色、背景色、行高、字体大小等样式控制
    TextStyleKit,
    TiptapImage,
  ],
});

电子书阅读器之笔记高亮(跨段处理)

作者 时光足迹
2026年5月6日 16:32

文本高亮

之前介绍了电子书阅读器之笔记高亮,主要展示处理逻辑。当时,只能处理同一段内的文字,不能跨段处理。现在就介绍如何处理跨段高亮。

Selection

window.getSelection()返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置。它代表页面中的文本选区,可能横跨多个元素

  • selection.toString():获取选中文本
const selection = window.getSelection();
console.log("[selection]", selection);
const selectedText = selection.toString().trim();
console.log("[选中文本]", selectedText);

highlight-1.png

Range

Range表示一个包含节点与文本节点的一部分的文档片段

  • getRangeAt:获取选区包含的指定区域(Range)的引用
const range = selection.getRangeAt(0);
console.log("[选中Range]", range);

const {
  startContainer, // 起始节点
  startOffset, // 起始节点偏移量
  endContainer, // 终止节点
  endOffset, // 终止节点偏移量
} = range;
console.log("[起始节点]", startContainer, "偏移量", startOffset);
console.log("[终止节点]", endContainer, "偏移量", endOffset);

highlight-2.png

getBoundingClientRect

const rectList = range.getClientRects();
console.log("[边界对象集]", rectList);
const rect = range.getBoundingClientRect();
console.log("[边界矩形]", rect);

highlight-3.png

highlight-4.png

选中位置定位

  1. 使用 rect 的位置,显示效果如下:
const position = { top: rect.top, left: rect.left };
console.log("[选中位置]", position);

highlight-5.png

  1. 使用 rectList 的位置,显示效果如下:
const position = { top: rectList[0].top, left: rectList[0].left };
console.log("[选中位置]", position);

highlight-6.png

我选择是这种方式,展示在选中文本第一个元素的位置。

  1. 滚动条影响

如果内容区域有滚动条,那么选中位置的定位坐标需要加上滚动条的值,即 top 需要加上滚动条的偏移量。

const position = {
  top: rectList[0].top + contentRef.current.scrollTop,
  left: rectList[0].left,
};

文本高亮

监听鼠标抬起(onMouseUp)事件,获取选中的文本,并高亮。

export default function Page() {
  // 处理鼠标抬起事件
  const handleMouseUp = () => {
    // 获取选中文本信息,包括位置信息
    const selectedRange = getSelectionPosition();
    if (selectedRange) {
      console.log(selectedRange);
      highlightText(selectedRange);
    }
  };

  return (
    <div ref={contentRef} onMouseUp={handleMouseUp} onClick={handleClick}>
      {/* 数字教材预览区 */}
      <MPreview list={list} />
      {/* 选中文本操作弹框 */}
      <PopupMenu ref={popupRef} />
      {/* 笔记编辑弹框 */}
      <PopupModify ref={modifyRef} />
    </div>
  );
}

文本节点

// 获取所有文本节点(带位置信息)
const getAllNodes = (contentRef: HTMLDivElement) => {
  if (!contentRef) return [];

  // 获取所有文本节点
  const textNodes: Node[] = [];
  const walker = document.createTreeWalker(contentRef, NodeFilter.SHOW_TEXT, {
    acceptNode: (node) =>
      node.textContent?.trim()
        ? NodeFilter.FILTER_ACCEPT
        : NodeFilter.FILTER_REJECT,
  });

  let node: Node | null = null;
  while (true) {
    node = walker.nextNode();
    if (!node) break;
    textNodes.push(node);
  }

  // 计算累积文本长度和节点位置
  let totalLength = 0;
  return textNodes.map((textNode) => {
    const text = textNode.textContent ?? "";
    const start = totalLength;
    totalLength += text.length;
    return { node: textNode, text, start, end: totalLength };
  });
};

highlight-11.png

高亮数据

const getSelectionPosition = () => {
  const selection = window.getSelection();
  // 1. 获取高亮文本
  const selectedText = selection.toString().trim();

  // 2. 获取高亮文本定位(用于弹框定位显示)
  const range = selection.getRangeAt(0);
  const rectList = range.getClientRects();
  const position = { top: rectList[0].top, left: rectList[0].left };

  // 3. 获取高亮文本位置(相对于全局文本的位置,用于高亮标色)
  let globalStart = -1;
  let globalEnd = -1;
  const nodeMap = getAllNodes();
  for (const { node, start } of nodeMap) {
    // 查找起始节点位置
    if (node === startContainer) globalStart = start + range.startOffset;
    // 查找结束节点位置
    if (node === endContainer) globalEnd = start + range.endOffset;
    // 提前退出
    if (globalStart >= 0 && globalEnd > 0) break;
  }

  if (globalStart >= 0 && globalEnd > 0) {
    console.log(`[选中范围]${globalStart}-${globalEnd}`);
    // 多行选择时,会跨元素,要找到第一个元素的id
    const startElement = getElementWithAttributes(selection.anchorNode);
    console.log("[开始元素]", startElement);

    return {
      startIndex: globalStart,
      endIndex: globalEnd,
      selectedText,
      className: "orange",
      chapterId,
      divId: startElement.id,
      top: position.top + contentRef.current.scrollTop,
      left: position.left,
    };
  }
};

highlight-7.png

高亮文本

const highlightText = (note) => {
  // 1. 先清除现有高亮
  removeHighlights(contentRef);

  // 2. 获取节点映射
  const nodeMap = getAllNodes(contentRef);

  // 3. 创建高亮范围
  const highlights: { range: Range, className: string, id: string }[] = [];

  // 4. 遍历nodeMap,处理每个文本节点
  for (const { node, text, start, end } of nodeMap) {
    const nodeStart = Math.max(0, note.startIndex - start);
    const nodeEnd = Math.min(text.length, note.endIndex - start);

    if (nodeStart >= nodeEnd) continue;
    console.log(`处理节点: "${text}" (${start}-${end})`);
    console.log(`节点高亮范围: ${nodeStart}-${nodeEnd}`);

    // 创建范围并高亮
    const range = document.createRange();
    range.setStart(node, nodeStart);
    range.setEnd(node, nodeEnd);
    highlights.push({
      range,
      className: `highlight-${note.className}`,
      id: note.divId,
    });
  }

  // 按位置从后往前高亮(避免位置偏移)
  highlights.sort((a, b) => b.range.startOffset - a.range.startOffset);
  for (const { range, className, id } of highlights) {
    // 创建高亮元素
    const span = document.createElement("span");
    span.className = className;
    // 添加笔记ID到data属性
    span.dataset.noteId = id;
    // 插入高亮内容
    span.appendChild(range.extractContents());
    range.insertNode(span);
  }
};

创建高亮元素并插入内容:

highlight-10.png

高亮前:

highlight-8.png

高亮后:

highlight-9.png

选中操作

复制

if (!note?.selectedText) return;
navigator.clipboard.writeText(note.selectedText);
toast.success("已复制到剪贴板");

报错提示:Uncaught TypeError: Cannot read properties of undefined (reading 'writeText')

报错原因:navigator.clipboard.writeText() 方法需要浏览器支持,才能正常执行。浏览器会禁用非安全域(http)的 navigator.clipboard 对象,而在 localhosthttps 下不会禁用。

数据的源头 —— JSX

作者 发大财chd
2026年5月5日 05:21

著有《React18 设计原理》《javascript地月星》等多个专栏。 欢迎关注。

创作不易,有帮助别忘了点赞,收藏,评论 ~ 你的鼓励是我继续挖干货的动力。

本文全部都是原创内容,商业转载请联系作者获得授权,非商业转载需注明出处,感谢理解 ~

推荐指数(值得一读):⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️

原文 👉 juejin.cn/post/763192…

React.createElement

创建新的 Fiber,new FiberNode 是没有数据的,对于 Fiber 节点的节点类型、样式、事件、子节点等属性一无所知。这些数据来自我们在 JSX 的声明。看一个的例子。

  • 原组件
//LazyComp.jsx
export default function LazyComp() {
  return <h1>✅Hello !</h1>;
}
//App.jsx
const LazyComp = lazy(() => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(import('./LazyComp'));
    }, 2000); // 模拟 2 秒延迟
  });
});
export default function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading...2</div>}>
        <LazyComp />
      </Suspense>
    </div>
  );
}
  • 编译后
//LazyComp-BtMAPHcf.js
function LazyComp() {
  return /* @__PURE__ */ React.createElement("h1", null, "✅Hello !");
}
//App-BBcglL-c.js
const LazyComp = lazy( () => {
    return new Promise( (resolve) => {
        setTimeout( () => {
            resolve(import("/dist/assets/LazyComp-BtMAPHcf.js")); //上面的LazyComp本体
        }
        , 8e3);
    }
    );
}
function App() {
  return /* @__PURE__ */
    React.createElement("div", null, /* @__PURE__ */
    React.createElement(Suspense, {
        fallback: /* @__PURE__ */
        React.createElement("div", null, "Loading...2")
    }, /* @__PURE__ */
    React.createElement(LazyComp, null)));
}

function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
  ...
  var children = Component(props, secondArg); //function App(){...  return React.createElement('div',{},React.createElement('p',{}, 'xx'))}
  ...
  return children;
}

从内到外的嵌套顺序执行,最里面的 React.createElement(LazyComp, null) 是最先完成的。

按组件单位生成一次 react element。 完成这一步就有信息可以生成这一部分的 Fiber 树了。
Jsx 变成对象后就是 React Element 对象 :

react ele 节点与它的属性 props

function Comp(){
    return (<div 属性/事件/样式/...>子节点...</div>)
}

var element = {
    $$typeof: REACT_ELEMENT_TYPE,
    type: type, //'div'
    key: key, //null
    ref: ref, //null
    props: props, //props是节点的属性。属性/事件/样式/子节点
    _owner: owner
  };

props = {children, style, onXX, 其他属性}
-   props.children // 子级element
-   props.fallback; // 如果是Suspense,有fallback
-   props.style //样式;
-   props.onXXX //事件
-   props.xxx //其他属性

组件也是这样子的:
<MyComp 属性/事件/样式...></MyComp>
不过我们不会在组件上直接写子节点:
<MyComp 属性/事件/样式...>❌子节点...</MyComp> 不会显示这个子节点。
本来就是按照组件为单位生成子节点,父组件不应该生成 MyComp 组件的子节点的。

代码

var ReactElement = function (type, key, ref, self, source, owner, props) {
  var element = {
    $$typeof: REACT_ELEMENT_TYPE,
    type: type,
    key: key,
    ref: ref,
    props: props,
    _owner: owner
  };

  {
  
    element._store = {}; 

    //ele._store.validated  不可配置、不可枚举、可写
    Object.defineProperty(element._store, 'validated', {
      configurable: false,
      enumerable: false,
      writable: true,
      value: false
    }); 
    //ele._self  不可配置、不可枚举、不可写、只读
    Object.defineProperty(element, '_self', {
      configurable: false,
      enumerable: false,
      writable: false,
      value: self
    });
    //ele._source  不可配置、不可枚举、不可写、只读
    Object.defineProperty(element, '_source', {
      configurable: false,
      enumerable: false,
      writable: false,
      value: source
    });
    // react ele、props是只读的
    if (Object.freeze) {
      Object.freeze(element.props);
      Object.freeze(element);
    }
  }

  return element;
};

//React.createElement(Suspense/'div', {fallback:, style:, onXXX:, key:,}, React.createElement())
function createElement(type, config, children) {
  var propName; 

  var props = {};
  var key = null;
  var ref = null;
  var self = null;
  var source = null;

  // react ele的ref和key 、 react ele的props的其他属性
  if (config != null) {
    // ref
    if (hasValidRef(config)) {
      ref = config.ref;

      {
        warnIfStringRefCannotBeAutoConverted(config);
      }
    }
    // key
    if (hasValidKey(config)) {
      {
        checkKeyStringCoercion(config.key);
      }

      key = '' + config.key;
    }

    // react ele的props的其他属性 style/onXXX/fallback
    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source;
    for (propName in config) {
      if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
        props[propName] = config[propName];
      }
    }
  } 

  //react ele的props的children=children/[...]
  //children=children单个子节点、children = []多个子节点
  var childrenLength = arguments.length - 2;
  if (childrenLength === 1) {
    props.children = children;
  } else if (childrenLength > 1) {
    var childArray = Array(childrenLength);

    for (var i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }

    {
      if (Object.freeze) {
        Object.freeze(childArray);
      }
    }

    props.children = childArray;
  } 

  // 例如App.defaultProps
  if (type && type.defaultProps) {
    var defaultProps = type.defaultProps;

    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }

  {
    if (key || ref) {
      var displayName = typeof type === 'function' ? type.displayName || type.name || 'Unknown' : type;

      if (key) {
        defineKeyPropWarningGetter(props, displayName);
      }

      if (ref) {
        defineRefPropWarningGetter(props, displayName);
      }
    }
  }

  return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);
}

hooks 的信息也来自 JSX

function App() {
  // 这些是hooks
  const [isPending, startTransition] = useTransition();
  const [inputValue, setInputValue] = useState("");
  const [list, setList] = useState([]);
  const [show, setShow] = React.useState(false);
  const [show2, setShow2] = React.useState(true);
  
  return /* @__PURE__ */
    React.createElement("div", null, /* @__PURE__ */
    React.createElement(Suspense, {
        fallback: /* @__PURE__ */
        React.createElement("div", null, "Loading...2")
    }, /* @__PURE__ */
    React.createElement(LazyComp, null)));
}

react函数组件、类组件、纯组件、受控/非受控组件

作者 光影少年
2026年5月5日 09:26

一、函数组件 vs 类组件

1️⃣ 函数组件(Function Component)

本质:就是一个函数,接收 props,返回 JSX

function MyComponent(props) {
  return <div>{props.title}</div>;
}

👉 现在主流写法(配合 Hooks)

特点:

  • 更简洁
  • 使用 useStateuseEffect 等 Hooks 管理状态和副作用
  • 没有 this
import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

2️⃣ 类组件(Class Component)

本质:继承 React.Component 的类

class MyComponent extends React.Component {
  render() {
    return <div>{this.props.title}</div>;
  }
}

带状态写法:

class Counter extends React.Component {
  state = { count: 0 };

  render() {
    return (
      <button onClick={() => this.setState({ count: this.state.count + 1 })}>
        {this.state.count}
      </button>
    );
  }
}

特点:

  • 有生命周期(componentDidMount 等)
  • 使用 this
  • 写法相对冗长

✅ 总结

对比 函数组件 类组件
写法 简洁 冗长
状态 Hooks this.state
生命周期 useEffect 生命周期方法
推荐 ✅ 主流 ❌ 已逐渐淘汰

👉 现在基本都用:函数组件 + Hooks


二、普通组件 vs 纯组件(PureComponent)

1️⃣ 普通组件

默认:父组件更新 → 子组件也会重新 render


2️⃣ 纯组件(PureComponent)

类组件写法:

class MyComponent extends React.PureComponent {
  render() {
    return <div>{this.props.name}</div>;
  }
}

👉 自动做 浅比较(shallow compare)

只有 props/state 变化才重新渲染


👉 函数组件对应写法:React.memo

const MyComponent = React.memo(function ({ name }) {
  return <div>{name}</div>;
});

⚠️ 注意点

浅比较意味着:

const obj = { a: 1 };

// ❌ 每次都是新对象,会触发更新
<MyComponent data={{ a: 1 }} />

// ✅ 推荐
const data = useMemo(() => ({ a: 1 }), []);

✅ 总结

类型 作用
PureComponent 类组件性能优化
React.memo 函数组件性能优化

三、受控组件 vs 非受控组件

👉 这个是表单相关最重要的概念


1️⃣ 受控组件(Controlled Component)

👉 数据由 React 控制(state)

function Input() {
  const [value, setValue] = useState("");

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

特点:

  • 数据来源:React state
  • 输入变化 → 更新 state → UI 更新
  • 单向数据流

👉 推荐使用 ✅


2️⃣ 非受控组件(Uncontrolled Component)

👉 数据由 DOM 自己管理

import { useRef } from "react";

function Input() {
  const inputRef = useRef();

  const handleClick = () => {
    console.log(inputRef.current.value);
  };

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>获取值</button>
    </>
  );
}

特点:

  • 使用 ref 获取值
  • 类似原生 JS 操作 DOM

✅ 总结

对比 受控组件 非受控组件
数据来源 React state DOM
可控性
推荐 ⚠️ 少用

四、一句话理解(面试版)

👉 你可以这样说:

  • 函数组件 vs 类组件:函数组件更简洁,配合 Hooks 替代类组件,是当前主流
  • 纯组件:通过浅比较 props/state 来减少不必要渲染(React.memo / PureComponent)
  • 受控组件:表单数据由 React state 管理
  • 非受控组件:表单数据由 DOM 管理,通过 ref 获取

五、给你一个实际开发建议(结合你现在做的项目)

你现在做 React + Ant Design:

👉 推荐组合:

  • 全部用 函数组件
  • 状态管理:Hooks(useState / useReducer)
  • 性能优化:React.memo + useMemo
  • 表单:优先用 受控组件(或 AntD Form 内部已封装)

Hooks-useEffect

作者 郑生zs
2026年5月4日 23:34

前置知识

1. 为什么我们需要学 useEffect?

在 React 的世界里,我们总是追求组件的纯粹性 ——给定相同的 props,永远返回相同的 UI。但现实往往是骨感的,我们需要处理数据获取、订阅消息、手动修改 DOM 等“脏活累活”。

在编程中,这些与外部世界交互、产生额外影响 的操作,就被称为副作用

为了协调纯粹的 UI 渲染与复杂的现实需求,React 提供了 useEffect Hook。在深入它之前,我们需要先厘清两个核心概念。

2. 保持组件纯粹性与副作用

问题:什么是副作用函数,什么是纯函数?(面试题)

纯函数定义:

  1. 相同的输入永远会得到相同的输出。这意味着函数的行为是可预测的。
  2. 只负责自己的任务,无副作用出现:它只关心自己的内部逻辑(第一点),绝不会去修改函数外部的任何状态(比如全局变量、DOM、文件系统等)。
// 纯函数:只负责计算,不依赖也不修改外部状态
const sum = (x: number, y: number): number => x + y;

sum(1, 2); // 始终返回 3

副作用函数定义

一个函数在执行过程中,除了返回值之外,还对外部环境产生了可观察的影响修改,这个函数就是副作用函数。

常见的副作用行为包括:

  1. 修改外部变量:修改全局变量、修改传入的参数对象(引用类型)。
  2. I/O 操作:发起网络请求(AJAX/Fetch)、读写文件、读写数据库。
  3. DOM 操作:修改网页标题、手动更改 DOM 节点结构。
  4. 系统交互:设置定时器(setTimeout)、订阅事件(addEventListener)、打印日志(console.log)。
  5. 非确定性:依赖随机数(Math.random)或当前时间(new Date),导致相同输入得到不同输出。
let total: number = 0; // 外部变量

// 副作用函数:修改了外部的 'total' 变量
function apple(value: number): number {
  total += value;
  return total;
}

3. React 组件应该是纯函数

React 的核心设计理念之一就是:组件是一个纯函数

核心原则

组件的职责非常单一——根据 propsstate 计算并返回 JSX。在渲染阶段,组件不应包含任何副作用。

注意

这里说的“不能有副作用”,是指渲染过程中不能有。渲染阶段只管教一件事——根据 props 和 state 算出 UI。

// 纯组件:相同的 props 总是渲染出相同的结果
function Greeting({ name }) {
  return <h1>你好,欢迎{name}学习React!</h1>;
}

React 依赖这个纯粹的约定来实现很多优化(比如跳过不必要的重新渲染),如果组件在渲染过程中偷偷做了额外的事情,就会打破这个约定,导致难以追踪的 bug。

// 错误示范:在渲染过程中执行副作用
function UserProfile({ userId }) {
  // 每次渲染都会发起请求,而且无法去控制时机和清理
  fetch(`/api/users/${userId}`).then(...)
  return <div>用户资料</div>;
}

为什么这样做是危险的?

如果在渲染阶段直接写副作用,会引发以下严重后果:

  1. 请求失控(发多少次你说了不算)

    • React 可能会因为并发模式或父组件更新而多次调用组件函数。如果在函数体内直接请求,会导致重复请求,浪费资源。
  2. 内存泄漏(关不掉)

    • 如果在渲染时开启了定时器或订阅,当组件被卸载时,这些任务可能仍在后台运行。这不仅浪费性能,还可能试图更新一个不存在的组件状态,导致报错。
  3. 缺乏控制力

    • 渲染的时机由 React 决定。将副作用放在渲染主体中,意味着你无法控制它何时执行执行几次以及依赖什么条件

React 组件应当保持纯粹,只负责 UI 的映射。而处理副作用(如请求数据、定时器)的任务,需要交给专门的机制——也就是我们接下来要学习的 useEffect

4. useEffect 基本用法

useEffect(setup, dependencies?)

参数

  • setup(必选) :Effect处理函数,可以返回一个清理函数(cleanup)。组件挂载时执行setup,依赖项更新时先执行cleanup再执行setup,组件卸载时执行cleanup。
  • dependencies(可选) :setup中使用到的响应式值列表(props、state等)。必须以数组形式编写如[dep1, dep2]。不传则每次重渲染都执行Effect。

React 内部是用 Object.is(类似 ===)来对比依赖项有没有变化的。

useEffect(() => {
  // 副作用代码
}, [/* 依赖项数组 */]);

返回值

useEffect 返回undefined

let a = useEffect(() => {})
console.log('a', a) //undefined

使用步骤:

先从 React 中导入 useEffect Hook

import { useEffect } from 'react';

再在组件顶部调用, 并在其中加入一些代码:(不要在循环或条件判断中调用 Hook。)

function MyComponent() {
  useEffect(() => {
    // 每次渲染后都会执行此处的代码
    // 这里就是你的“副作用”发生的地方
  });

  return <div>我的组件</div>;
}

每当你的组件渲染时,React 会先更新页面,然后再运行 useEffect 中的代码。换句话说,useEffect 会“延迟”一段代码的运行,直到渲染结果反映在页面上。它是异步执行的,不会阻塞浏览器绘制屏幕

执行时机:什么是“渲染后”?

理解 useEffect 的核心在于理解它的执行时机

React 的工作流程是这样的:

  1. 渲染阶段:React 调用你的组件函数,计算出需要更新的 JSX(虚拟 DOM)。
  2. 提交阶段:React 将变更应用到真实的 DOM 上,屏幕上的画面更新了。
  3. 副作用阶段:React 执行你在 useEffect 中定义的代码。

核心概念useEffect 会“延迟”一段代码的运行,直到渲染结果已经反映在页面上

这意味着,如果你的副作用代码需要获取 DOM 节点的尺寸、位置,或者需要触发一个不阻塞浏览器绘制的网络请求,useEffect 是完美的选择。

4. useEffect 的三种执行模式

useEffect 的行为完全取决于第二个参数(依赖项数组)

1. 不传参数:每次渲染都执行

  • 特点:没有任何限制,组件只要更新(无论是哪个 state 变了),它就会跑。
  • 类比componentDidMount + componentDidUpdate
import { useEffect, useState } from "react";

const App = () => {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");

  // 没有第二个参数:只要组件重新渲染(count 或 text 变动),这里都会执行
  useEffect(() => {
    console.log("只要渲染,我就执行");
  });

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <input value={text} onChange={(e) => setText(e.target.value)} />
    </div>
  );
};

2. 空数组 []:只在挂载时执行一次

  • 特点:相当于“初始化”操作,只跑一次,后面不管怎么更新都不管了。
  • 类比componentDidMount
import { useEffect, useState } from "react";

const App = () => {
  const [count, setCount] = useState(0);

  // 空数组:只有组件第一次显示在页面上时执行,后续 count 变化不会触发
  useEffect(() => {
    console.log("仅执行一次(适合初始化、请求接口)");
  }, []);

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

3. 有依赖项 [value]:数据变了才执行

  • 特点:精确监听。只有数组里的变量发生变化,才会触发。
  • 类比:特定的 componentDidUpdate
import { useEffect, useState } from "react";

const App = () => {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");

  // 依赖 count:只有 count 变化时才执行。text 变化不会触发。
  useEffect(() => {
    console.log("只有 count 变了,我才执行");
  }, [count]);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <input value={text} onChange={(e) => setText(e.target.value)} />
    </div>
  );
};

清理函数:善后工作

副作用往往需要“打扫战场”(比如清除定时器、移除监听)。useEffect 允许你返回一个函数,这就是清理函数。

  • 执行时机:

    组件卸载时。

    依赖项变化,下一次 Effect 执行前(先清理旧的,再执行新的)。

场景:定时器清理

import { useEffect, useState } from "react";

const App = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    // 1. 创建副作用(开启定时器)
    const timer = setInterval(() => {
      setCount((c) => c + 1);
    }, 1000);

    // 2. 返回清理函数(清除定时器)
    // 当组件卸载 或 依赖变化重跑前,React 会自动调用这个函数
    return () => {
      clearInterval(timer);
      console.log("定时器已清理");
    };
  }, []); // 空数组保证只开启一次定时器

  return <div>计时:{count}</div>;
};

场景:解决“请求竞态”问题

当用户快速输入时,我们希望取消上一次还没发完的请求,只保留最后一次的请求。

import { useEffect, useState } from "react";

const Search = () => {
  const [keyword, setKeyword] = useState("");

  useEffect(() => {
    // 1. 模拟开启一个定时器(代表网络请求)
    const timer = setTimeout(() => {
      console.log(`发送请求:${keyword}`);
    }, 500);

    // 2. 清理函数:如果用户又输入了新字,这里会先执行,清除上一次的定时器
    return () => {
      clearTimeout(timer);
      console.log("取消上一次过期的请求");
    };
    
  }, [keyword]); // 依赖 keyword,每次输入变化都会触发

  return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
};
❌
❌