阅读视图

发现新文章,点击刷新页面。

手把手搭一套前端监控采集 SDK

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

完整的前端监控平台通常分成三块:采集与上报、整理与存储、展示与分析。本文只讲第一块,从 0 搭一个可运行的埋点 SDK,并把指标采集方式对齐到当前浏览器与 Core Web Vitals 的常见做法。

名字会影响记忆和传播。这里把 SDK 叫做"四维",英文 four-dimension,简写 FD,寓意尽量用上帝视角看清页面里发生的事。下文用 TypeScript 写示例,便于类型即文档。

自研采集层还要提前想好几条边界:是否采集可能含个人信息的字段、是否对错误栈与 URL 做脱敏、是否在低端机做采样。这些决定往往比多写一个 observer 更影响能不能上线。

整体结构

采集侧可以拆成四件事:配置、缓存与上报策略、各类 observer 与事件钩子、统一入口类。数据流与模块边界可以对照下图来记,和下面 Mermaid 图表达的是同一条主线。

如下图所示。

20260325075816

从页面事件到内存队列,再到空闲或离开时发往服务端的一整条链路。

20260325080415

配置与入口类

业务侧只需要改上报地址、应用标识等。配置对象建议可合并覆盖,避免散落魔法字符串。可预留 releaseenvironment 字段,方便和后端版本聚类对齐。userId 若涉及合规,建议只传哈希后的业务 id,或默认不传,由登录域自行下发自洽标识。

config.ts 中集中维护默认值,并导出 setConfig,便于在业务入口覆盖:

export interface MonitorConfig {
  reportUrl: string;
  appId: string;
  userId?: string;
  projectName?: string;
  release?: string;
  environment?: "development" | "staging" | "production";
  sampleRate?: number;
}

const config: MonitorConfig = {
  reportUrl: "http://localhost:8000/report",
  appId: "fd-example",
  projectName: "fd-example",
  environment: "development",
  sampleRate: 1,
};

export function setConfig(partial: Partial<MonitorConfig>): void {
  Object.assign(config, partial);
}

export function getConfig(): Readonly<MonitorConfig> {
  return config;
}

FourDimension 负责在构造时拉起各模块。初始化不要依赖构造参数时,可以保持无参构造,只在 init 里注册监听,避免重复调用时重复挂钩子。

import { initPerformance } from "./performance";
import { initBehavior } from "./behavior";
import { initError } from "./error";

export class FourDimension {
  private inited = false;

  init(): void {
    if (this.inited) return;
    this.inited = true;
    initPerformance();
    initError();
    initBehavior();
  }
}

业务里建议异步加载 SDK 脚本,初始化时 new FourDimension().init() 即可。若脚本可能被多次执行,务必保留类似 inited 的幂等守卫,否则 fetch 会被包一层又一层。

上报通道 sendBeacon、图片打点与 XHR

navigator.sendBeacon 适合监控:异步、不抢主线程、在页面卸载时仍有机会发出。注意它发的是 POST,适合带 Blob 指定 Content-Type,而不是假设服务端只收 GET 查询串。

限制也要心里有数:无响应体、旧环境可能不存在、单次 payload 有实际上限(常见讨论量级在数十 KB,宜压 body 体积)。实践里常见优先级是 sendBeacon 优先,其次 1x1 图片 GET(数据需压缩且控制长度),再次带 keepalive: truefetchXMLHttpRequestsendBeacon 返回 false 说明浏览器拒绝排队,应立刻换通道。

下面封装一个带降级的 sendReportsendBeacon 分支用 BlobJSON,图片分支再把数据塞进查询参数(注意浏览器对 URL 长度的限制)。

export function isSupportSendBeacon(): boolean {
  return (
    typeof navigator !== "undefined" &&
    typeof navigator.sendBeacon === "function"
  );
}

export function reportImage(url: string, payload: unknown): void {
  const qs = encodeURIComponent(JSON.stringify(payload));
  const img = new Image();
  img.src = `${url}?reportData=${qs}`;
}

export function reportWithXhr(url: string, body: string): void {
  const xhr = new XMLHttpRequest();
  xhr.open("POST", url);
  xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
  xhr.send(body);
}

export function sendReport(url: string, body: string): void {
  if (isSupportSendBeacon()) {
    const blob = new Blob([body], { type: "application/json" });
    const ok = navigator.sendBeacon(url, blob);
    if (ok) return;
  }
  reportImage(url, JSON.parse(body) as unknown);
}

真实项目里可以在 sendBeacon 返回 false 时再尝试 XHR,把失败样本写入 sessionStorage 下次补发。接收端要核实:网关是否允许 Content-Type: application/jsonPOST,是否对 OPTIONS 预检放行,否则 beacon 在跨域场景会静默失败,需在 Network 面板核对状态码。

上报降级顺序若画成一张小抄,方便和运维对口径。

如下图所示。

20260325075931

三种通道的优先顺序与跨域核对点。

缓存与上报时机

目标是对主线程影响尽量小。常见组合是:

  • 内存里先攒一批,再批量上报
  • requestIdleCallback 在空闲时 flush,不支持时用 setTimeout 兜底
  • 页面离开时把剩余队列一次性发出

离开页面时优先依赖 pagehidevisibilitychange,比单纯 beforeunload 更稳,尤其在移动端后台化场景。visibilitychange 在标签隐藏时就能先 flush 一轮,pagehide 在真正离开时再做最后一跳。两个事件都可能触发 flush 时,要么在 flushQueue 内做"空队列直接返回",要么加发送中锁,避免重复上报同一批。

bfcache 恢复的页面会再走 pageshowpersistedtrue 时会话可能延续,停留时长统计要把可见时间分段累加,不能假设一次进页到一次离开。

type ReportPayload = Record<string, unknown>;

const queue: ReportPayload[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;

export function enqueue(payload: ReportPayload): void {
  queue.push(payload);
}

export function flushQueue(reportUrl: string, immediate = false): void {
  if (!queue.length) return;
  const batch = queue.splice(0, queue.length);
  const body = JSON.stringify({ batch });
  if (immediate) {
    sendReport(reportUrl, body);
    return;
  }
  const run = () => sendReport(reportUrl, body);
  if (typeof requestIdleCallback === "function") {
    requestIdleCallback(run, { timeout: 3000 });
  } else {
    setTimeout(run, 0);
  }
}

export function scheduleFlush(reportUrl: string, delayMs = 2000): void {
  if (flushTimer) clearTimeout(flushTimer);
  flushTimer = setTimeout(() => {
    flushTimer = null;
    flushQueue(reportUrl, false);
  }, delayMs);
}

export function bindLifecycleFlush(reportUrl: string): void {
  const onHide = () => {
    if (document.visibilityState === "hidden") {
      flushQueue(reportUrl, true);
    }
  };
  window.addEventListener("pagehide", () => flushQueue(reportUrl, true));
  document.addEventListener("visibilitychange", onHide);
}

getCache 若要对调用方返回快照,需要深拷贝避免外部改数组。深拷贝实现注意处理循环引用以外的普通 JSON 友好结构即可。

性能指标用最新采集思路

PerformanceObserver 仍是采集绘制与布局类指标的主力,buffered: true 让你晚注入脚本也能拿到已经发生过的条目。导航类指标优先读 PerformanceNavigationTiming,比自己在事件里 performance.now() 更贴近浏览器统计。

在挂 observer 之前可以用静态方法探测当前环境到底支持哪些 entryTypes,避免 observe 直接抛错。下面是一段可放进工具模块的探测逻辑。

export function supportedPerfTypes(): string[] {
  if (typeof PerformanceObserver !== "function") return [];
  return PerformanceObserver.supportedEntryTypes ?? [];
}

export function canObserve(type: string): boolean {
  return supportedPerfTypes().includes(type);
}

Chrome DevToolsPerformanceLighthouse 里跑一遍同页,把面板里的 LCPCLS 与 SDK 打上去的值对比,数量级应一致。若差一个数量级,先查是否重复统计、是否在 iframe 里采集、是否混用了导航时间与绘制时间。

Core Web Vitals 对齐

截至 Google 面向站长的公开说明,Core Web Vitals 核心指标是 LCPINPCLSFID 已被 INP 取代,自研 SDK 仍可同时上报 FID 做历史对比,但产品解读应以 INP 为主。

指标 含义 推荐采集方式
LCP 视口内最大内容绘制完成时刻 PerformanceObservertype: 'largest-contentful-paint',通常取最后一次有效条目
INP 交互到下一帧绘制的延迟分布 PerformanceObservertype: 'interaction'(需较新 Chromium),或引入 web-vitals
CLS 累计布局偏移 PerformanceObservertype: 'layout-shift',且只统计 hadRecentInput === false 的条目并累加 value

FPFCP 仍可通过 type: 'paint' 观察,用于诊断首屏是否"空刷背景"与"首现有意义内容"的差异。

三个核心指标与采集入口的关系,适合印在团队 wiki 首页当速查图。

如下图所示。

20260325080035

LCPINPCLS 与对应 observer 类型名称的对应关系。

paint 与首屏绘制

下面示例合并监听 first-paintfirst-contentful-paint,并在拿到 FCP 后断开,避免重复回调。若你希望两种 paint 都上报,应在两种都见到后再 disconnect,或干脆不断开、由服务端按 paintName 去重。

import { enqueue, scheduleFlush } from "./queue";
import { getConfig } from "./config";

function safeObserverSupported(): boolean {
  return typeof PerformanceObserver !== "undefined";
}

export function observePaint(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (
        entry.name !== "first-paint" &&
        entry.name !== "first-contentful-paint"
      )
        continue;
      const json = entry.toJSON();
      enqueue({
        type: "performance",
        subType: "paint",
        paintName: entry.name,
        startTime: json.startTime,
        pageURL: location.href,
      });
      if (entry.name === "first-contentful-paint") {
        obs.disconnect();
        scheduleFlush(getConfig().reportUrl);
        break;
      }
    }
  });
  obs.observe({ type: "paint", buffered: true });
}

LCP 在页面生命周期内可能更新,规范语义是"最后一个汇报的 LCP 条目代表当前候选"。简单实现可以在回调里每次都上报最新一条,由服务端取同会话最后一次,或在客户端只保留最大 startTime 的那条再上报。注意 LCP 回调触发时 entry.element 可能已被移除,DOM 引用要谨慎,上报 tagName 与资源 URL 即可。

export function observeLcp(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    const entries = list.getEntries() as PerformanceEntry[];
    const last = entries[entries.length - 1] as LargestContentfulPaint &
      PerformanceEntry;
    const json = last.toJSON();
    enqueue({
      type: "performance",
      subType: "lcp",
      startTime: json.startTime,
      element: last.element?.tagName,
      url: "url" in last ? String((last as { url?: string }).url ?? "") : "",
      pageURL: location.href,
    });
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "largest-contentful-paint", buffered: true });
}

上面用到 LargestContentfulPaint 时,若项目 lib.dom 较旧,可把 last 标成 PerformanceEntry 并谨慎读取可选字段。

CLSINP

CLS 需要过滤用户操作附近的偏移,避免把有意交互造成的布局变化算成体验问题。

export function observeCls(): void {
  if (!safeObserverSupported()) return;
  let clsScore = 0;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries() as PerformanceEntry[]) {
      const ls = entry as LayoutShift & {
        hadRecentInput?: boolean;
        value?: number;
      };
      if (ls.hadRecentInput) continue;
      clsScore += ls.value ?? 0;
      enqueue({
        type: "performance",
        subType: "cls",
        value: ls.value,
        cumulativeLayoutShift: clsScore,
        pageURL: location.href,
      });
    }
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "layout-shift", buffered: true });
}

INP 依赖 type: 'interaction'PerformanceObserver,浏览器支持面仍在演进。生产环境若要省心,可直接使用 web-vitals 包,它会在不支持时降级或给出兼容策略。最小接入示意如下,真实项目里把 console.log 换成 enqueue 即可。

import { onINP } from "web-vitals";

onINP((metric) => {
  const v = metric.value;
  console.log("INP ms", v);
});

自研最小实现可以封装为"支持则订阅,不支持则不上报",避免把未定义行为写死进业务。

导航时间与 DOMContentLoadedload

更稳的做法是读取 performance.getEntriesByType('navigation')[0],得到 PerformanceNavigationTiming,用相对 fetchStartstartTime 的各阶段时刻算 DNSTCPTTFBDOM 解析等。字段含义以 MDN 上的 PerformanceNavigationTiming 为准,换公式前用一次 console.tablenav 打出来核对。

export function collectNavigationTiming(): void {
  const [nav] = performance.getEntriesByType(
    "navigation",
  ) as PerformanceNavigationTiming[];
  if (!nav) return;
  enqueue({
    type: "performance",
    subType: "navigation",
    dns: nav.domainLookupEnd - nav.domainLookupStart,
    tcp: nav.connectEnd - nav.connectStart,
    ttfb: nav.responseStart - nav.requestStart,
    domContentLoaded: nav.domContentLoadedEventEnd - nav.fetchStart,
    load: nav.loadEventEnd - nav.fetchStart,
    pageURL: location.href,
  });
  scheduleFlush(getConfig().reportUrl);
}

可在 load 事件触发后再调用一次,确保 loadEventEnd 已非 0。单页应用在客户端路由切换时不会产生新的 navigation 条目,若要监控"软导航",需要结合框架路由钩子或 Performance API 里仍在演进的软导航相关能力单独设计,不能把 PV 和导航耗时混在一条 navigation 记录里硬解释。

资源耗时

资源条目用 type: 'resource'。注意不要在每个 entry 上都 disconnect,否则只会收到第一条资源。更合理的是页面 load 后一次性读取 performance.getEntriesByType('resource'),或长期观察但在 disconnect 前处理完整批次。

跨域资源若没有正确的 Timing-Allow-Origin,多数细粒度时长在浏览器里会被抹成 0,这是安全策略不是 SDK 坏了。核实方式是对比同源静态资源与 CDN 资源的 transferSizedomainLookupStart 等是否突然全 0。

export function observeResources(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries() as PerformanceResourceTiming[]) {
      enqueue({
        type: "performance",
        subType: "resource",
        name: entry.name,
        initiatorType: entry.initiatorType,
        duration: entry.duration,
        dns: entry.domainLookupEnd - entry.domainLookupStart,
        tcp: entry.connectEnd - entry.connectStart,
        ttfb: entry.responseStart - entry.requestStart,
        protocol: entry.nextHopProtocol,
        transferSize: entry.transferSize,
        encodedBodySize: entry.encodedBodySize,
        decodedBodySize: entry.decodedBodySize,
        pageURL: location.href,
      });
    }
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "resource", buffered: true });
}

若担心资源量过大,可在客户端按域名白名单或按耗时阈值过滤后再入队。也可按 config.sampleRate 随机丢弃非错误样本,只保留长尾。

接口耗时:fetchXHR

只劫持 XMLHttpRequest 会漏掉现代代码里大量的 fetch。可以同时包装 window.fetchXMLHttpRequest.prototype。包装 fetch 时不要假设调用方不克隆 Response 去读体,监控侧只读 status 与头即可,避免和消费方抢读同一个 body 流。

export function patchFetch(): void {
  const orig = window.fetch.bind(window);
  window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
    const start = performance.now();
    const req = input instanceof Request ? input : new Request(input, init);
    try {
      const res = await orig(req);
      const end = performance.now();
      enqueue({
        type: "performance",
        subType: "fetch",
        url: req.url,
        method: req.method,
        status: res.status,
        duration: end - start,
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
      return res;
    } catch (err) {
      const end = performance.now();
      enqueue({
        type: "error",
        subType: "fetch",
        url: req.url,
        method: req.method,
        duration: end - start,
        message: err instanceof Error ? err.message : String(err),
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
      throw err;
    }
  };
}

XHR 劫持仍可用 opensend 包装,在 loadend 上打点时间戳,与上文思路一致,此处不重复贴全。

错误上报

资源错误与 JS 运行时错误要分开通道。window.addEventListener('error', …, true) 在捕获阶段能拿到 scriptlinkimg 等加载失败,event.target 指向元素。纯 JS 语法与运行时错误同一事件里 target 往往为空,可配合 window.onerror 或同一监听里分支处理。ErrorEvent 上的 message 在跨域脚本且未正确配置 crossorigin 时可能是统一口令,需要和源站 CORS 配置一起核实。

Promise 未处理拒绝用 unhandledrejection。上报体里尽量带 reason 的栈信息,字符串化时注意大对象。

事件路径不要用已弃用的 event.path,改用 event.composedPath()

错误从页面钻进队列前,按类型分流,便于后端路由到不同看板。

如下图所示。

20260325080152

资源、脚本、Promise 三类错误进入同一条上报管道前的分流意象。

function elementPath(ev: Event): string[] {
  const path = typeof ev.composedPath === "function" ? ev.composedPath() : [];
  return path
    .filter((n): n is Element => n instanceof Element)
    .map((el) => el.tagName);
}

export function initGlobalErrorHandlers(): void {
  window.addEventListener(
    "error",
    (ev) => {
      const t = ev.target;
      if (
        t &&
        t instanceof HTMLElement &&
        (t instanceof HTMLImageElement ||
          t instanceof HTMLScriptElement ||
          t instanceof HTMLLinkElement)
      ) {
        const url =
          "src" in t && t.src ? t.src : "href" in t && t.href ? t.href : "";
        enqueue({
          type: "error",
          subType: "resource",
          url,
          tag: t.tagName,
          paths: elementPath(ev),
          pageURL: location.href,
        });
        scheduleFlush(getConfig().reportUrl);
        return;
      }
      if (!ev.message) return;
      enqueue({
        type: "error",
        subType: "js",
        message: ev.message,
        filename: ev.filename,
        lineno: ev.lineno,
        colno: ev.colno,
        stack: ev.error instanceof Error ? ev.error.stack : "",
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
    },
    true,
  );

  window.addEventListener("unhandledrejection", (ev) => {
    const reason = ev.reason;
    enqueue({
      type: "error",
      subType: "promise",
      stack: reason instanceof Error ? reason.stack : String(reason),
      pageURL: location.href,
    });
    scheduleFlush(getConfig().reportUrl);
  });
}

若担心第三方脚本堆栈污染,可在入口做采样或域名过滤。生产环境应上传 source map 到私有桶,由服务端按 release 解析栈,而不是把完整文件路径暴露给前端库。

行为数据:PV、停留时长、点击

PV 在每次路由或首屏进入时打一条,带上 document.referrer 与本地生成的会话或设备标识。UV 必须在服务端用 cookie、登录 id 或可信指纹聚合,客户端只能提供匿名 id。单页应用要在路由变化时手动调一次 reportPv,仅依赖首屏加载会严重低估。

停留时长用 visibilitychange 记录可见累计时间,比只在 beforeunload 减一次更准,尤其是后台标签与 bfcache 场景。离开页面时再发一条汇总,字段里带 visibleMs 即可。下面是一段与队列解耦的计时思路,需与上文的 enqueueflushQueuegetConfig 同模块配合使用。

import { enqueue, flushQueue } from "./queue";
import { getConfig } from "./config";

let visibleAccum = 0;
let lastVisibleStart = performance.now();

document.addEventListener("visibilitychange", () => {
  const now = performance.now();
  if (document.visibilityState === "visible") {
    lastVisibleStart = now;
  } else {
    visibleAccum += now - lastVisibleStart;
  }
});

window.addEventListener("pagehide", () => {
  if (document.visibilityState === "visible") {
    visibleAccum += performance.now() - lastVisibleStart;
  }
  enqueue({
    type: "behavior",
    subType: "dwell",
    visibleMs: Math.round(visibleAccum),
    pageURL: location.href,
  });
  flushQueue(getConfig().reportUrl, true);
});

点击监听建议防抖,避免长按或滑动误触暴风上报。坐标与 outerHTML 体积要限长,防止队列爆炸。敏感页面不要上传完整 outerHTML,可只保留 data- 业务埋点键名。

下面用 sessionStorage 存会话 id,首次访问时用 crypto.randomUUID() 生成。若需兼容极老环境,可再降级到时间戳加长随机串。

function createSessionId(): string {
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
    return crypto.randomUUID();
  }
  return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

let sessionId = sessionStorage.getItem("fd_sid") ?? "";
if (!sessionId) {
  sessionId = createSessionId();
  sessionStorage.setItem("fd_sid", sessionId);
}

export function reportPv(): void {
  enqueue({
    type: "behavior",
    subType: "pv",
    pageURL: location.href,
    referrer: document.referrer,
    sessionId,
  });
  scheduleFlush(getConfig().reportUrl);
}

export function reportClickDebounced(delayMs = 500): void {
  let timer: ReturnType<typeof setTimeout> | null = null;
  window.addEventListener("pointerdown", (ev) => {
    if (!(ev.target instanceof Element)) return;
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      const el = ev.target;
      const r = el.getBoundingClientRect();
      enqueue({
        type: "behavior",
        subType: "click",
        tag: el.tagName,
        x: r.left,
        y: r.top,
        paths: elementPath(ev),
        pageURL: location.href,
        sessionId,
      });
      scheduleFlush(getConfig().reportUrl);
    }, delayMs);
  });
}

上线前建议核对的一张表

把下面几项当成发布前 checklist,在 Chrome 与一种目标内核(如 Safari 或内置浏览器)各测一遍。

核对项 怎么核实 常见坑
sendBeacon 是否到达 Network 里看 report 请求体与状态码 跨域未放行 POST413 体积过大
LCP 是否合理 Lighthouse 与 SDK 数值同页对比 iframe、影子根、元素已移除
资源耗时是否全 0 挑一条 CDN 资源看 responseStart Timing-Allow-Origin
软导航 PV 手动点路由后看是否产生新 pv 事件 只监听了首次 load
重复 flush 快速切换标签看上报条数是否翻倍 visibilitypagehide 未去重

小结

把上报做成"队列加空闲 flush 加离开兜底",用 sendBeacon 携带 JSON Blob,性能侧用 PerformanceObserverPerformanceNavigationTiming 对齐现代指标,并补上 CLSINP 的采集意识,错误侧区分资源与脚本并改用 composedPath,行为侧把 PV、软导航与可见停留时间说清楚,就是一个可演进的最小监控采集层。存储与查询、告警与大盘属于下一篇文章。

使用 Hooks 构建无障碍 React 组件

无障碍不是上线前才需要检查的清单,而是从第一行代码开始就需要贯彻的设计约束。谈到 React 中的无障碍,大多数开发者会想到 ARIA 属性、语义化 HTML 和屏幕阅读器支持。这些确实重要。但还有一个完整的无障碍类别很少受到关注:尊重用户在操作系统层面已经设置好的偏好。

每个主流操作系统都允许用户配置减少动画、高对比度、深色模式和文本方向等偏好。这些不是装饰性的选择。启用”减少动画”的用户可能患有前庭功能障碍,动画过渡会让他们感到身体不适。启用高对比度的用户可能视力低下。当你的 React 应用忽略这些信号时,这不仅仅是功能缺失——而是一道屏障。

本文将向你展示如何使用 ReactUse 的 hooks 在 React 中检测和响应这些操作系统级别的偏好。我们将覆盖减少动画、对比度偏好、颜色方案检测、焦点管理和文本方向——然后将所有内容整合到一个实际的组件中。

手动监听媒体查询的问题

浏览器通过 CSS 媒体查询(如 prefers-reduced-motionprefers-contrast 和 prefers-color-scheme)暴露操作系统级别的偏好。你可以在 JavaScript 中使用 window.matchMedia 来读取这些值。手动实现的方式如下:

import { useState, useEffect } from "react";

function useManualReducedMotion(): boolean {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  useEffect(() => {
    const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
    setPrefersReducedMotion(mediaQuery.matches);

    const handler = (event: MediaQueryListEvent) => {
      setPrefersReducedMotion(event.matches);
    };

    mediaQuery.addEventListener("change", handler);
    return () => mediaQuery.removeEventListener("change", handler);
  }, []);

  return prefersReducedMotion;
}

这段代码能工作,但存在问题。你需要处理 SSR(window 不存在的情况)、管理事件监听器的清理,并且需要为每个想要跟踪的媒体查询重复这个模式。将这个模式乘以减少动画、对比度、颜色方案和其他查询,你最终会得到大量容易出错的样板代码。

ReactUse 提供的 hooks 封装了这个模式,包含正确的 SSR 处理、适当的清理逻辑,以及当用户更改系统偏好时的实时更新。

useReducedMotion:尊重动画偏好

useReducedMotion hook 检测用户是否在设备上启用了”减少动画”设置。这是你能使用的最具影响力的无障碍 hooks 之一,因为动画可能会给前庭功能障碍的用户带来真实的身体不适。

import { useReducedMotion } from "@reactuses/core";

function AnimatedCard({ children }: { children: React.ReactNode }) {
  const prefersReducedMotion = useReducedMotion();

  return (
    <div
      style={{
        transition: prefersReducedMotion
          ? "none"
          : "transform 0.3s ease, opacity 0.3s ease",
        animation: prefersReducedMotion ? "none" : "fadeIn 0.5s ease-in",
      }}
    >
      {children}
    </div>
  );
}

这里的关键不是简单地禁用动画——而是在没有动画的情况下提供等价的体验。对于大多数用户需要 500ms 淡入的卡片,对于偏好减少动画的用户应该立即显示。内容相同,只是呈现方式不同。

你还可以使用这个 hook 在不同的动画策略之间切换:

import { useReducedMotion } from "@reactuses/core";

function PageTransition({ children }: { children: React.ReactNode }) {
  const prefersReducedMotion = useReducedMotion();

  if (prefersReducedMotion) {
    // 即时过渡——没有动画,但仍然有视觉变化
    return <div style={{ opacity: 1 }}>{children}</div>;
  }

  // 为未选择减少动画的用户提供完整的滑入动画
  return (
    <div
      style={{
        animation: "slideInFromRight 0.4s ease-out",
      }}
    >
      {children}
    </div>
  );
}

usePreferredContrast:适应对比度需求

usePreferredContrast hook 读取 prefers-contrast 媒体查询,告诉你用户想要更多对比度、更少对比度,还是没有偏好。这对视力低下的用户至关重要。

import { usePreferredContrast } from "@reactuses/core";

function ThemedButton({ children, onClick }: {
  children: React.ReactNode;
  onClick: () => void;
}) {
  const contrast = usePreferredContrast();

  const getButtonStyles = () => {
    switch (contrast) {
      case "more":
        return {
          backgroundColor: "#000000",
          color: "#FFFFFF",
          border: "3px solid #FFFFFF",
          fontWeight: 700 as const,
        };
      case "less":
        return {
          backgroundColor: "#E8E8E8",
          color: "#333333",
          border: "1px solid #CCCCCC",
          fontWeight: 400 as const,
        };
      default:
        return {
          backgroundColor: "#3B82F6",
          color: "#FFFFFF",
          border: "2px solid transparent",
          fontWeight: 500 as const,
        };
    }
  };

  return (
    <button onClick={onClick} style={getButtonStyles()}>
      {children}
    </button>
  );
}

当用户请求更高对比度时,你应该增大前景和背景颜色之间的差异、使用更粗的字体粗细、让边框更明显。当他们请求更低对比度时,柔化视觉强度。默认分支处理未设置偏好的用户。

usePreferredColorScheme:系统主题检测

usePreferredColorScheme hook 告诉你用户的操作系统是设置为浅色模式、深色模式,还是没有偏好。这是构建主题感知组件的基础。

import { usePreferredColorScheme } from "@reactuses/core";

function AdaptiveCard({ title, body }: { title: string; body: string }) {
  const colorScheme = usePreferredColorScheme();

  const isDark = colorScheme === "dark";

  return (
    <div
      style={{
        backgroundColor: isDark ? "#1E293B" : "#FFFFFF",
        color: isDark ? "#E2E8F0" : "#1E293B",
        border: `1px solid ${isDark ? "#334155" : "#E2E8F0"}`,
        borderRadius: "8px",
        padding: "24px",
      }}
    >
      <h3 style={{ marginTop: 0 }}>{title}</h3>
      <p>{body}</p>
    </div>
  );
}

如果你只需要一个简单的布尔值判断,ReactUse 还提供了 usePreferredDark,当用户偏好深色方案时返回 true。如果你需要一个完整的深色模式切换并持久化用户的选择,useDarkMode 可以开箱即用。

对于更细粒度的媒体查询控制,useMediaQuery 让你订阅任何 CSS 媒体查询字符串并获得实时更新。

useFocus:键盘导航和焦点管理

键盘导航是核心无障碍要求。无法使用鼠标的用户依赖 Tab 键在交互元素之间移动。useFocus hook 提供了对焦点的编程控制,这对于模态对话框、下拉菜单和动态内容至关重要。

import { useRef } from "react";
import { useFocus } from "@reactuses/core";

function SearchBar() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [focused, setFocused] = useFocus(inputRef);

  return (
    <div>
      <input
        ref={inputRef}
        type="search"
        placeholder="Search..."
        style={{
          outline: focused ? "2px solid #3B82F6" : "1px solid #D1D5DB",
          padding: "8px 12px",
          borderRadius: "6px",
          width: "100%",
        }}
      />
      <button onClick={() => setFocused(true)}>
        Focus Search (Ctrl+K)
      </button>
    </div>
  );
}

这个 hook 同时返回当前焦点状态和一个设置函数。你可以使用焦点状态来应用视觉指示器(超出浏览器默认样式),并使用设置函数来编程式地移动焦点——例如,当模态框打开时或当触发键盘快捷键时。

将此与 useActiveElement 配合使用,可以跟踪整个应用中当前拥有焦点的元素,这对于构建焦点陷阱和跳过导航链接非常有用。

useTextDirection:RTL 和 LTR 支持

国际化和无障碍有很大的重叠。useTextDirection hook 检测和管理文档的文本方向,支持从左到右(LTR)和从右到左(RTL)布局。

import { useTextDirection } from "@reactuses/core";

function NavigationMenu() {
  const [dir, setDir] = useTextDirection();

  return (
    <nav
      style={{
        display: "flex",
        flexDirection: dir === "rtl" ? "row-reverse" : "row",
        gap: "16px",
        padding: "12px 24px",
      }}
    >
      <a href="/">Home</a>
      <a href="/about">About</a>
      <a href="/contact">Contact</a>
      <button onClick={() => setDir(dir === "rtl" ? "ltr" : "rtl")}>
        Toggle Direction
      </button>
    </nav>
  );
}

RTL 支持影响的不仅仅是文本对齐。导航顺序、图标位置和 margin/padding 方向都需要翻转。通过使用 useTextDirection 作为唯一数据源,你可以构建自动适应的布局逻辑。

综合示例:无障碍通知组件

下面是一个将多个无障碍 hooks 整合到单个组件中的实际示例——一个尊重动画偏好、适应对比度设置、跟随系统颜色方案并正确管理焦点的通知提示:

import { useRef, useEffect } from "react";
import {
  useReducedMotion,
  usePreferredContrast,
  usePreferredColorScheme,
  useFocus,
} from "@reactuses/core";

interface NotificationProps {
  message: string;
  type: "success" | "error" | "info";
  visible: boolean;
  onDismiss: () => void;
}

function AccessibleNotification({
  message,
  type,
  visible,
  onDismiss,
}: NotificationProps) {
  const prefersReducedMotion = useReducedMotion();
  const contrast = usePreferredContrast();
  const colorScheme = usePreferredColorScheme();
  const dismissRef = useRef<HTMLButtonElement>(null);
  const [, setFocused] = useFocus(dismissRef);

  const isDark = colorScheme === "dark";
  const isHighContrast = contrast === "more";

  // 通知出现时将焦点移至关闭按钮
  useEffect(() => {
    if (visible) {
      setFocused(true);
    }
  }, [visible, setFocused]);

  if (!visible) return null;

  const colors = {
    success: {
      bg: isDark ? "#064E3B" : "#ECFDF5",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#10B981" : "#6EE7B7",
      text: isDark ? "#A7F3D0" : "#065F46",
    },
    error: {
      bg: isDark ? "#7F1D1D" : "#FEF2F2",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#EF4444" : "#FCA5A5",
      text: isDark ? "#FECACA" : "#991B1B",
    },
    info: {
      bg: isDark ? "#1E3A5F" : "#EFF6FF",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#3B82F6" : "#93C5FD",
      text: isDark ? "#BFDBFE" : "#1E40AF",
    },
  };

  const scheme = colors[type];

  return (
    <div
      role="alert"
      aria-live="assertive"
      style={{
        position: "fixed",
        top: "16px",
        right: "16px",
        backgroundColor: scheme.bg,
        color: scheme.text,
        border: `${isHighContrast ? "3px" : "1px"} solid ${scheme.border}`,
        borderRadius: "8px",
        padding: "16px 20px",
        maxWidth: "400px",
        display: "flex",
        alignItems: "center",
        gap: "12px",
        fontWeight: isHighContrast ? 700 : 400,
        // 尊重动画偏好
        animation: prefersReducedMotion ? "none" : "slideIn 0.3s ease-out",
        transition: prefersReducedMotion ? "none" : "opacity 0.2s ease",
      }}
    >
      <span style={{ flex: 1 }}>{message}</span>
      <button
        ref={dismissRef}
        onClick={onDismiss}
        aria-label="关闭通知"
        style={{
          background: "none",
          border: `1px solid ${scheme.text}`,
          color: scheme.text,
          cursor: "pointer",
          borderRadius: "4px",
          padding: "4px 8px",
          fontWeight: isHighContrast ? 700 : 500,
        }}
      >
        关闭
      </button>
    </div>
  );
}

这个组件展示了几个无障碍原则的协同工作:

  1. role="alert" 和 aria-live="assertive"  确保屏幕阅读器立即播报通知。
  2. useReducedMotion 为偏好减少动画的用户禁用滑入动画。
  3. usePreferredContrast 为需要更高对比度的用户增加边框宽度和字体粗细。
  4. usePreferredColorScheme 根据用户的浅色或深色主题适配所有颜色。
  5. useFocus 将键盘焦点移至关闭按钮,使用户无需使用鼠标就能操作通知。

为什么 Hooks 是无障碍的正确抽象

Hooks 具有可组合性。每个无障碍关注点都封装在自己的 hook 中,你可以按需组合它们。一个简单的按钮可能只使用 usePreferredContrast。一个复杂的模态框可能使用我们介绍的全部五个 hooks。这些 hooks 互相独立,这意味着你可以逐步采用它们,无需重构现有代码。

Hooks 还能实时响应变化。如果用户在你的应用打开时从浅色切换到深色模式,hooks 会更新,你的组件会使用新的偏好重新渲染。这是仅使用 CSS 的方案(依赖静态类名)难以实现的。

安装

通过包管理器安装 ReactUse:

npm install @reactuses/core

然后导入你需要的 hooks:

import {
  useReducedMotion,
  usePreferredContrast,
  usePreferredColorScheme,
  useFocus,
  useTextDirection,
} from "@reactuses/core";

相关 Hooks

ReactUse 提供了 100 多个 React hooks。探索全部 →

把 JavaScript 原型讲透:从 `[[Prototype]]`、`prototype` 到 `constructor` 的完整心智模型

目录

  • 引言:为什么原型是前端工程师绕不过去的一课
  • 一、先建立统一认知:对象原型到底是什么
  • 二、prototype[[Prototype]] 不是一回事
  • 三、从 new 和内存视角理解实例、构造函数与原型
  • 四、函数原型上的高频知识点:共享属性与 constructor
  • 五、重写原型对象时,为什么最容易踩坑
  • 六、创建对象的推荐姿势:实例数据放 this,共享方法放 prototype
  • 实战建议
  • 总结:关键结论与团队落地建议

引言:为什么原型是前端工程师绕不过去的一课

很多团队在日常开发里已经很少手写“构造函数 + 原型”这套模式了,更多时候我们写的是 class、对象字面量、组合式函数,甚至直接用框架帮我们屏蔽底层细节。于是原型这件事,常常只在面试里出现,看起来像“八股”,但一旦线上排查问题,它又会突然变得非常真实:

  • 为什么两个实例的方法地址相同?
  • 为什么给对象赋值后没有覆盖到原型上的值?
  • 为什么重写 prototype 之后,constructor 看起来“不对了”?
  • 为什么控制台里 __proto__ 看起来什么都有,但代码里又不建议用它?
  • 为什么 class 最终仍然离不开原型链?

如果对这些问题没有统一心智模型,工程上就会出现两类常见问题:一类是“会用但讲不清”,另一类是“改得动但不敢改”。而原型真正的价值,不在于背定义,而在于帮助我们理解 JavaScript 的对象系统、继承机制、方法共享、内存结构,以及很多框架设计背后的语言基础。

这篇文章的目标很明确:不是把概念堆给你,而是把“对象、函数、构造函数、实例、原型、构造器”这几者之间的关系,一次性串起来。读完之后,你至少应该能建立起一个稳定的判断标准:什么应该挂在实例上,什么应该挂在原型上,什么时候可以重写原型,重写后又要补什么。


一、先建立统一认知:对象原型到底是什么

在 JavaScript 中,几乎每个对象都带着一个隐藏的内部链接,这个内部链接在规范里叫 [[Prototype]]。它会指向另一个对象,而这个“被指向的对象”,就是当前对象的原型对象。

你可以把它理解成:当前对象在找不到某个属性时,下一站该去哪里找。

1. 原型最核心的作用:兜底查找

当我们访问一个对象属性时,会触发内部的 [[Get]] 过程;当我们给对象设置属性时,会触发 [[Set]] 过程。

操作 触发时机 原型参与方式
[[Get]] 读取属性时 先查对象自身,找不到再沿原型向上查
[[Set]] 设置属性时 优先看当前对象及属性描述符,再决定是否在当前对象创建新属性

下面这个例子最能说明问题:

function A() {}
A.prototype.x = 10

const obj = new A()

console.log(obj.x) // 10,obj 自身没有 x,沿原型找到 A.prototype.x

obj.x = 20
console.log(obj.x) // 20,此时 obj 自身已经有了 x

这里发生了两件事:

  1. 第一次读 obj.x,对象自身没有,沿着原型找到 A.prototype.x
  2. 第二次写 obj.x = 20,是在实例自身新增了一个同名属性,而不是改掉原型上的 x

这也是很多人第一次理解“共享”和“遮蔽(shadowing)”的关键入口。

2. 对象字面量创建出来的对象,也有原型

很多人以为只有通过构造函数创建出来的对象才有原型,这其实不对。只要是普通对象,通常都有 [[Prototype]]

const obj = { name: 'XiaoWu' }
const foo = {}

console.log(obj.__proto__)
console.log(foo.__proto__)

图:隐式原型在浏览器与终端中的表现

控制台里你看到的结果,和真实运行时的内部结构并不完全等价。浏览器控制台为了方便调试,会把一些继承来的内容也展开给你看;Node 的输出则更接近“对象本身 + 原型关系”的表现。

从理解层面,可以先把它抽象成下面这样:

const obj = { name: 'XiaoWu', __proto__: {} }
const foo = { __proto__: {} }

当然,真正的 [[Prototype]] 不是你字面量里真的写出来的这个字段,而是引擎内部维护的链接关系。

3. __proto__[[Prototype]]Object.getPrototypeOf 到底什么关系?

这是高频混淆点,必须一次说清:

  • [[Prototype]]:规范层面的内部槽,真实存在,但你不能直接写代码访问这个名字
  • __proto__:历史遗留的访问器属性,调试方便,但不推荐作为正式代码依赖
  • Object.getPrototypeOf(obj):标准 API,推荐在正式代码里使用
const obj = { name: '小吴' }

console.log(Object.getPrototypeOf(obj))

调试场景里,obj.__proto__ 确实更顺手;工程代码里,优先使用 Object.getPrototypeOf(obj)。原因很简单:

  • 语义标准、跨环境更稳定
  • 可维护性更高
  • 降低“我在操作语言底层 hack 口子”的心智负担

顺手补一句:今天的引擎几乎都支持 __proto__,但“能用”不等于“应该作为主路径使用”。

本章小结

  • 每个对象的核心原型关系,体现在内部的 [[Prototype]]
  • 读取属性找不到时,会沿原型继续查找
  • 给实例赋值,不等于改原型;很多时候只是“在实例自身新增同名属性”
  • __proto__ 更适合调试,正式代码优先 Object.getPrototypeOf
  • 理解原型,本质是在理解 JavaScript 如何做“属性查找”和“能力复用”

二、prototype[[Prototype]] 不是一回事

聊原型最容易踩的第一个坑,就是把 prototype[[Prototype]] 混为一谈。它们名字很像,但角色完全不同。

1. prototype 是函数身上的属性,不是所有对象都有

先看例子:

function foo() {}

const obj = {}

console.log(foo.prototype) // 普通函数默认有 prototype
console.log(obj.prototype) // undefined,普通对象没有 prototype

这里有一个非常重要的判断标准:

  • prototype 是函数对象上的一个属性,主要给“作为构造函数使用”时服务
  • [[Prototype]] 是对象内部的原型链接,普通对象、函数对象都可能有

也就是说:

  • 函数是对象,所以函数也有 [[Prototype]]
  • 但普通对象不是函数,所以普通对象没有 prototype

2. 这两个概念各自负责什么?

可以直接用一句最工程化的话来理解:

  • prototype定义将来由这个构造函数创建出来的实例,应该共享什么
  • [[Prototype]]当前这个对象,实际沿哪条链路去查找属性

它们的职责并不重复:

  1. 归属不同
    prototype 属于函数;[[Prototype]] 属于对象

  2. 作用不同
    prototype 用来定义共享能力;[[Prototype]] 用来参与查找路径

  3. 时机不同
    prototype 通常在定义阶段配置;[[Prototype]] 通常在对象创建时被确定

3. 纠正一个特别容易出现的误区

很多人在刚学到这里时,会误以为:

“函数自己的隐式原型会指向它自己的显式原型”

这是错误的。

准确关系应该是:

  • foo.prototype:给将来 new foo() 出来的实例用
  • Object.getPrototypeOf(foo):函数对象 foo 自己的原型,通常是 Function.prototype

也就是说:

function foo() {}

console.log(Object.getPrototypeOf(foo) === Function.prototype) // true

而实例和构造函数之间的正确关系,是下一节的重点:

const f1 = new foo()
console.log(Object.getPrototypeOf(f1) === foo.prototype) // true

4. new 到底做了什么?

理解原型,绕不开 new。把它拆开看,会清晰很多。

new Foo() 大致会做下面几步:

  1. 创建一个全新的空对象
  2. 把这个对象的 [[Prototype]] 指向 Foo.prototype
  3. 用这个新对象作为 this 执行构造函数
  4. 如果构造函数没有显式返回对象,就返回这个新对象

所以,实例为什么能访问构造函数原型上的方法?答案就在第 2 步。

function Foo() {}

const f1 = new Foo()
const f2 = new Foo()

console.log(Object.getPrototypeOf(f1) === Foo.prototype) // true
console.log(Object.getPrototypeOf(f2) === Foo.prototype) // true

这就是为什么不同实例可以“共享一套方法定义”,却又拥有各自不同的数据。

本章小结

  • prototype[[Prototype]] 名字相似,但职责完全不同
  • 普通对象没有 prototype,函数通常有
  • 实例的 [[Prototype]] 会在 new 时指向构造函数的 prototype
  • 函数对象自己的原型通常是 Function.prototype,不是它自己的 prototype
  • 只要把“定义共享能力”和“参与属性查找”分开理解,很多混乱都会消失

三、从 new 和内存视角理解实例、构造函数与原型

如果只停留在语法层,原型会越学越抽象。真正把它看懂,最有效的方式是换成“引用关系”和“内存指向”的视角。

1. Person、实例对象和原型对象之间是什么关系?

先看一个最简单的例子:

function Person() {}

console.log(Person.prototype)

很多人看到这里会困惑:Person 是函数,Person.prototype 是对象,那实例和它们之间是怎么连起来的?

关键结论只有一个:

同一个构造函数创建出来的实例,默认会共享同一个原型对象。

这也是后面方法复用的基础。

图:从控制台结果理解构造函数与原型对象的关系

这张图适合帮助我们建立第一个直觉:构造函数不是孤立存在的,它天然带着一个 prototype 对象。

2. 为什么 p1p2 可以访问同一套原型内容?

function Person() {}

const p1 = new Person()
const p2 = new Person()

这里最值得记住的不是“创建了两个实例”,而是“这两个实例的原型指向同一个地方”。

console.log(Object.getPrototypeOf(p1) === Object.getPrototypeOf(p2)) // true
console.log(Object.getPrototypeOf(p1) === Person.prototype) // true
console.log(Object.getPrototypeOf(p2) === Person.prototype) // true

图:p1p2 实例对象共享同一个原型对象

这就解释了一个很重要的工程现象:

  • Person.prototype.xxx
  • 实际上影响的是所有还指向这个原型对象的实例
function Person() {}

const p1 = new Person()
const p2 = new Person()

console.log(Object.getPrototypeOf(p1) === Person.prototype) // true
console.log(Object.getPrototypeOf(p2) === Person.prototype) // true

图:通过相等比较验证实例原型是否一致

3. 一个很适合面试和排错的思考题:p1.name 到底能从哪里拿到?

假设 p1 自身没有 name,那 p1.name 还能不能拿到值?

答案是能,而且方式不止一种。本质上,这些方式最终都在改同一个共享原型对象。

function Person() {}

const p1 = new Person()
const p2 = new Person()

Object.getPrototypeOf(p1).name = '小吴'
console.log(p1.name) // 小吴

Person.prototype.name = 'XiaoWu'
console.log(p1.name) // XiaoWu

Object.getPrototypeOf(p2).name = 'why'
console.log(p1.name) // why

为什么第三种改 p2 的原型,也会影响 p1

因为:

Object.getPrototypeOf(p1) === Object.getPrototypeOf(p2) === Person.prototype

它们最终都指向同一个共享对象。

把这个关系进一步抽象成“内存地址”,就更容易理解了。你可以把上面的变化想成:

// 假设共享原型对象就像一个地址 0x100
0x100.name = '小吴'
console.log(0x100.name) // 小吴

0x100.name = 'XiaoWu'
console.log(0x100.name) // XiaoWu

0x100.name = 'why'
console.log(0x100.name) // why

图:从“内存指向”视角理解实例、构造函数与原型的关系

这个视角非常关键,因为后面理解“共享方法”“重写原型”“原型链继承”时,本质都是在理解引用关系,而不是背结论。

本章小结

  • 同一个构造函数创建的实例,默认共享同一个原型对象
  • Object.getPrototypeOf(p1) === Person.prototype 是原型学习中的第一条黄金验证公式
  • 改共享原型,相当于影响所有还连接到它的实例
  • 原型问题一旦抽象成“引用地址”,很多现象都会变得很好解释
  • 面试里问“为什么改 p2 的原型会影响 p1”,本质在考你是否理解“共享引用”

四、函数原型上的高频知识点:共享属性与 constructor

前面讲的是“为什么原型存在”,这一节讲“原型上通常放什么”。

1. 原型上放的是“共享能力”

在 JavaScript 中,函数的 prototype 对象,本质上就是给实例共享用的。

function Person() {}

Person.prototype.name = 'why'
Person.prototype.age = 18

const p1 = new Person()
const p2 = new Person()

console.log(p1.name, p2.age) // why 18

这意味着:

  • nameage 不在 p1p2 自身上
  • 它们来自共享原型
  • 所有实例都能访问,但并不各自拷贝一份

图:往原型上添加共享属性后的结构示意

这里顺便给一个工程建议:
如果一个值会因实例不同而不同,就不要放原型上;如果一段行为对所有实例都一致,就优先考虑放原型上。

2. constructor 是什么?为什么平时看不见?

默认情况下,函数的原型对象上会有一个 constructor 属性,它指回构造函数本身。

function Foo() {}

console.log(Foo.prototype.constructor === Foo) // true

但很多同学在控制台直接打印 Foo.prototype 时,看见的是个空对象,于是误以为它什么都没有。其实不是没有,而是:

constructor 默认是不可枚举的。

所以直接打印、遍历时看不明显,但你可以通过属性描述符把它“看见”。

function Foo() {}

console.log(Foo.prototype) // 看起来像 {}
console.log(Object.getOwnPropertyDescriptors(Foo.prototype))

图:在 Node 中查看 constructor 的真实属性描述符

3. constructor 存在的意义是什么?

constructor 的工程意义,不是“让你炫技”,而是帮我们保留一条从原型对象追溯回构造函数的路径。

function Foo() {}

console.log(Foo.prototype.constructor.name) // Foo

这相当于让原型系统形成了一个闭环:

  • 实例通过 [[Prototype]] 指向原型对象
  • 原型对象通过 constructor 指回构造函数

这条关系能帮助我们做理解、调试和某些类型判断。但也要注意一点:

constructor 可以被改写,所以它不是绝对可靠的类型判断依据。

在工程里,如果你想做类型判断:

  • 优先考虑 instanceof
  • 或者基于更稳定的品牌判断方式
  • 不要把 constructor 当成唯一真理

4. 一个有意思但不建议滥用的闭环验证

function Foo() {}

console.log(
  Foo.prototype.constructor.prototype.constructor.prototype.constructor.name
) // Foo

这段代码能跑通,不是因为 JavaScript 神秘,而是因为这条引用关系本来就存在。
不过知道就好,别把它写进业务代码里。

本章小结

  • 原型对象最适合承载共享属性和共享方法
  • constructor 默认存在于函数原型对象上,只是不可枚举
  • Foo.prototype.constructor === Foo 是默认成立的
  • constructor 适合理解原型结构,但不适合作为唯一类型判断依据
  • 共享逻辑放原型,是 JavaScript 节省内存、复用能力的关键设计

五、重写原型对象时,为什么最容易踩坑

前面讲的是“给现有原型追加内容”,这一节讲的是另一种更激进的操作:直接重写整个原型对象。

1. 什么叫“重写原型对象”?

不是这样:

Person.prototype.name = '小吴'
Person.prototype.age = 20

而是这样:

function Person() {}

Person.prototype = {
  name: '小吴',
  age: 20,
  learn() {
    console.log(this.name + '在学习')
  }
}

这种写法在属性比较多时很常见,结构也更集中。

先看原始的“构造函数与原型相互关联”视角:

图:默认原型对象与构造函数之间的关联

当你执行 Person.prototype = { ... } 时,本质上是让 Person.prototype 指向了一个全新的对象

图:重写原型后,构造函数指向了新的原型对象

继续把内容填进去之后,新的结构才完整:

图:新的原型对象被填充内容后的状态

2. 这里最容易掉的坑:constructor 丢了

看下面的代码:

function Person() {}

Person.prototype = {
  name: '小吴',
  age: 18,
  height: 1.88
}

const f1 = new Person()
console.log(f1.name + '今年' + f1.age) // 小吴今年18

功能看起来没问题,但有一个隐藏变化:

console.log(Person.prototype.constructor === Person) // false
console.log(Person.prototype.constructor === Object) // true

原因并不复杂:

  • 默认创建函数时,引擎会为它生成一个带 constructor 的原型对象
  • 但你手动赋值的新对象只是一个普通对象字面量
  • 它自己的 constructor 并不是 Person
  • 查找时会沿着这个新对象的原型往上找到 Object.prototype.constructor

图:重写原型后,实例仍能访问属性,但 constructor 关系已发生变化

3. 正确做法:手动把 constructor 补回去

最常见的补法如下:

function Foo() {}

Foo.prototype = {
  name: '小吴',
  age: 18,
  height: 1.88
}

Object.defineProperty(Foo.prototype, 'constructor', {
  enumerable: false,
  writable: true,
  configurable: true,
  value: Foo
})

const f1 = new Foo()
console.log(f1.name + '今年' + f1.age)

为什么不用下面这种简单写法?

Foo.prototype = {
  constructor: Foo,
  name: '小吴'
}

因为这样写出来的 constructor 默认是可枚举的,而原生默认行为里,这个属性应该是不可枚举的。
如果你想尽量保持和原生行为一致,Object.defineProperty 更合适。

图:补回 constructor 后,构造函数与新原型对象重新闭合

4. 再补一个容易忽略的边界条件

很多人以为“重写原型后,旧原型会立即消失”,这其实不严谨。

更准确的说法是:

  • 如果旧原型对象已经没有任何可达引用,后续才可能被垃圾回收
  • 如果已有实例还指向旧原型,那旧原型仍然活着

例如:

function Person() {}

const oldP = new Person()

Person.prototype = {
  sayHello() {
    console.log('hello')
  }
}

const newP = new Person()

console.log(Object.getPrototypeOf(oldP) === Object.getPrototypeOf(newP)) // false

这在排查“为什么新老实例行为不一致”时非常关键。

本章小结

  • prototype 追加属性,和直接重写整个 prototype,是两种不同操作
  • 重写原型后,默认的 constructor 关联会丢失
  • 推荐用 Object.definePropertyconstructor 补回去
  • 重写原型不会自动“更新”旧实例的原型指向
  • 原型对象是否回收,取决于是否还有引用,而不是“看起来不用了”

六、创建对象的推荐姿势:实例数据放 this,共享方法放 prototype

这是原型章节里最重要的工程落点。

1. 一个典型错误:把实例数据塞进共享原型

下面这段代码看似“想省事”,实则会制造共享数据污染:

function Person(name, age, sex, address) {
  Person.prototype.name = name
  Person.prototype.age = age
  Person.prototype.sex = sex
  Person.prototype.address = address
}

const p1 = new Person('小吴', 18, '男', '福建')
console.log(p1.name) // 小吴

const p2 = new Person('why', 35, '男', '广州')
console.log(p1.name) // why

为什么 p1.name 最后变成了 why

因为你不是把数据放进 p1p2 自身,而是放进了它们共享的 Person.prototype
这等于让所有实例共用一份可变数据,自然后创建的实例会覆盖前一个实例的结果。

这类问题在工程里很致命,因为它会造成一种非常糟糕的现象:对象看起来是独立的,实际状态却是串联的。

2. 正确做法:实例数据归实例,共享方法归原型

function Person(name, age, sex, address) {
  this.name = name
  this.age = age
  this.sex = sex
  this.address = address
}

Person.prototype.eating = function () {
  console.log(this.name + '今天吃烤地瓜了')
}

Person.prototype.running = function () {
  console.log(this.name + '今天跑了五公里')
}

const p1 = new Person('小吴', 18, '男', '福建')
const p2 = new Person('why', 35, '男', '广州')

console.log(p1.name) // 小吴
console.log(p2.name) // why
console.log(p1.eating === p2.eating) // true

这套写法有三个直接收益:

  1. 实例数据隔离
    每个对象维护自己的状态,不会相互覆盖

  2. 方法共享
    所有实例共用同一个方法引用,减少重复创建

  3. 结构清晰
    一眼能分清“对象自己的数据”和“对象共享的行为”

3. 为什么不要把原型方法写进构造函数内部?

有些代码会这么写:

function Person(name) {
  this.name = name
  this.eating = function () {
    console.log(this.name + '在吃东西')
  }
}

它不是不能运行,而是有明显代价:每次 new Person() 都会重新创建一个新的函数对象。

如果实例特别多,这就是实打实的重复内存占用和不必要的函数分配。

更合理的方式还是:

function Person(name) {
  this.name = name
}

Person.prototype.eating = function () {
  console.log(this.name + '在吃东西')
}

4. 这套模式和 class 有什么关系?

如果你已经在写 class,那更应该理解这部分。因为:

class Person {
  constructor(name) {
    this.name = name
  }

  eating() {
    console.log(this.name + '在吃东西')
  }
}

本质上仍然是:

  • constructor 里放实例数据
  • 方法定义在原型上

class 改变的是写法,不是底层原理。

本章小结

  • 实例间不同的数据,放 this
  • 所有实例共享的行为,放 prototype
  • 不要把可变实例数据放到共享原型上
  • 不要在构造函数里重复创建所有实例都相同的方法
  • 理解这条原则后,再看 class 会非常顺手

实战建议

1. 代码评审时重点看这几件事

  • 是否把实例级数据错误地挂到了原型上
  • 是否把共享方法错误地定义在构造函数内部
  • 是否在重写 prototype 后忘了补 constructor
  • 是否在正式代码里依赖 __proto__ 而不是标准 API
  • 是否出现“旧实例”和“新实例”指向不同原型的潜在风险

2. 调试原型问题时,建议这样验证

console.log(Object.getPrototypeOf(obj))
console.log(Object.getPrototypeOf(obj) === Foo.prototype)
console.log(obj.hasOwnProperty('xxx'))
console.log('xxx' in obj)
console.log(Object.getOwnPropertyDescriptors(Foo.prototype))

这一组排查动作,足够覆盖大多数原型相关问题:

  • 属性是自己的,还是继承来的
  • 当前实例到底连到哪个原型对象
  • 原型对象上的属性描述符是否符合预期
  • constructor 是否被改坏了

3. 团队内可以落地的约束

  • 约定:实例状态一律放 this / 类字段
  • 约定:共享方法统一放原型 / 类方法
  • 约定:禁止在业务代码里直接依赖 __proto__
  • 约定:重写 prototype 必须同步恢复 constructor
  • 约定:在 Code Review Checklist 中加入“原型污染”和“共享引用”检查项

4. 性能与可维护性的权衡

  • 小量对象场景下,差异可能不明显
  • 大量实例场景下,方法是否共享会带来真实内存差异
  • 动态改原型虽然灵活,但会明显增加维护成本
  • 原型越“魔法化”,后续新人接手成本越高

总结:关键结论与团队落地建议

JavaScript 的原型并不神秘,它本质上解决的是两个问题:

  1. 对象找不到属性时,去哪里继续找
  2. 多个实例如何共享同一套行为定义

把这两件事想清楚,原型就不再是零散知识点,而是一套完整的对象模型。

最后用几条结论收尾:

  • [[Prototype]] 是对象的查找链路,prototype 是构造函数为实例准备的共享模板
  • new 的关键一步,是把实例的 [[Prototype]] 指向构造函数的 prototype
  • constructor 默认存在于原型对象上,只是不可枚举
  • 重写 prototype 会改变后续实例的继承来源,同时可能破坏 constructor
  • 最稳妥的工程实践是:实例数据放 this,共享方法放 prototype

如果要在团队内部继续往下沉淀,建议下一步把下面几个主题串起来学习:

  • 原型链完整查找过程
  • instanceof 的底层判断逻辑
  • Object.create 与显式指定原型
  • 组合继承、寄生组合继承
  • class extends 背后的原型链本质

当你把这些知识连起来之后,JavaScript 的对象系统就不再是“记忆题”,而会变成你分析框架、阅读源码、设计抽象时的一套底层能力。

React 拖拽:无需第三方库的完整方案

拖拽是用户期望"理所当然能用"的交互之一。无论是对任务看板重新排序、通过拖动文件上传,还是让用户在仪表盘中重新排列小组件,抓取并移动的操作都让人感觉自然流畅。然而大多数 React 教程一上来就引入像 react-dnddnd-kit 这样的重量级库——它们功能强大,但对许多常见场景来说增加了过多的包体积和概念负担。

如果只需一次 Hook 调用就能获得流畅、可用于生产的拖拽行为呢?本文将从原生浏览器 API 出发,分析它们为何难用,然后用 ReactUse 中的两个轻量 Hook:useDraggableuseDropZone 来解决同样的问题。

手动实现:自行处理指针事件

让元素可拖拽的最基本方式是手动监听 pointerdownpointermovepointerup 事件。通常的写法如下:

import { useEffect, useRef, useState } from "react";

function ManualDraggable() {
  const ref = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isDragging, setIsDragging] = useState(false);
  const delta = useRef({ x: 0, y: 0 });

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const onPointerDown = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      delta.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
      setIsDragging(true);
    };

    const onPointerMove = (e: PointerEvent) => {
      if (!isDragging) return;
      setPosition({
        x: e.clientX - delta.current.x,
        y: e.clientY - delta.current.y,
      });
    };

    const onPointerUp = () => setIsDragging(false);

    el.addEventListener("pointerdown", onPointerDown);
    window.addEventListener("pointermove", onPointerMove);
    window.addEventListener("pointerup", onPointerUp);

    return () => {
      el.removeEventListener("pointerdown", onPointerDown);
      window.removeEventListener("pointermove", onPointerMove);
      window.removeEventListener("pointerup", onPointerUp);
    };
  }, [isDragging]);

  return (
    <div
      ref={ref}
      style={{
        position: "fixed",
        left: position.x,
        top: position.y,
        cursor: isDragging ? "grabbing" : "grab",
        padding: 16,
        background: "#4f46e5",
        color: "#fff",
        borderRadius: 8,
      }}
    >
      拖动我
    </div>
  );
}

能跑起来——但看看你需要管理多少状态。而这还只是最简单的版本。实际需求会迅速叠加更多复杂性。

为什么手动实现拖拽很难

上面的代码片段有几个不足之处,一旦超出 Demo 级别就会立刻暴露出来:

  1. 容器边界。 如果你想让元素保持在父容器内部,就需要在每次移动时读取容器尺寸并限制位置。这意味着每帧都要在两个元素上调用 getBoundingClientRect

  2. 指针类型。 上面的代码处理了鼠标事件,但触控和手写笔呢?PointerEvent API 统一了它们,但按指针类型过滤(例如禁止手写笔拖动)需要额外的条件判断。

  3. 拖拽手柄。 有时可拖拽的触发区域只是卡片内部的一个标题栏。你需要将"触发"元素和"移动"元素分离,并相应地连接事件。

  4. 事件清理。 忘记移除监听器——或者在 useEffect 中使用了错误的依赖——会导致诸如松开鼠标后元素仍在移动之类的隐蔽 Bug。

  5. 放置区域。 HTML5 拖放 API 引入了 dragenterdragoverdragleavedrop 事件。协调这些事件——尤其是子元素上臭名昭著的 dragenter/dragleave 闪烁问题——非常容易出错。

这些正是 useDraggableuseDropZone 开箱即用要解决的问题。

useDraggable:一个 Hook,完全掌控

useDraggable 接受一个目标元素的 ref 和一个可选的配置对象。它返回当前的 xy 位置、一个表示元素是否正在被拖拽的布尔值,以及一个 setter(用于程序化地移动元素)。

import { useDraggable } from "@reactuses/core";
import { useRef } from "react";

function DraggableCard() {
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(el, {
    initialValue: { x: 100, y: 100 },
  });

  return (
    <div
      ref={el}
      style={{
        position: "fixed",
        left: x,
        top: y,
        cursor: isDragging ? "grabbing" : "grab",
        padding: 16,
        background: isDragging ? "#4338ca" : "#4f46e5",
        color: "#fff",
        borderRadius: 8,
        transition: isDragging ? "none" : "box-shadow 0.2s",
        boxShadow: isDragging ? "0 8px 24px rgba(0,0,0,0.2)" : "none",
        userSelect: "none",
        touchAction: "none",
      }}
    >
      随意拖动我
    </div>
  );
}

这就是整个组件。无需手动事件监听器。无需清理逻辑。触控、鼠标和手写笔默认都能工作。

限制在容器内

传入一个 containerElement ref,Hook 会自动夹紧位置,使元素不会离开容器:

import { useDraggable } from "@reactuses/core";
import { useRef } from "react";

function BoundedDrag() {
  const container = useRef<HTMLDivElement>(null);
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(el, {
    containerElement: container,
    initialValue: { x: 0, y: 0 },
  });

  return (
    <div
      ref={container}
      style={{
        position: "relative",
        width: 400,
        height: 300,
        border: "2px dashed #cbd5e1",
        borderRadius: 8,
      }}
    >
      <div
        ref={el}
        style={{
          position: "absolute",
          left: x,
          top: y,
          width: 80,
          height: 80,
          background: "#4f46e5",
          borderRadius: 8,
          cursor: isDragging ? "grabbing" : "grab",
          touchAction: "none",
        }}
      />
    </div>
  );
}

无需手动的夹紧计算。Hook 会读取容器的滚动和客户端尺寸,自动限制元素位置。

使用拖拽手柄

通常你只想让元素的特定部分——比如一个标题栏——触发拖拽。传入 handle ref 即可:

import { useDraggable } from "@reactuses/core";
import { useRef } from "react";

function DraggablePanel() {
  const panel = useRef<HTMLDivElement>(null);
  const handle = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(panel, {
    handle,
    initialValue: { x: 200, y: 150 },
  });

  return (
    <div
      ref={panel}
      style={{
        position: "fixed",
        left: x,
        top: y,
        width: 280,
        background: "#fff",
        borderRadius: 8,
        boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
        overflow: "hidden",
        touchAction: "none",
      }}
    >
      <div
        ref={handle}
        style={{
          padding: "8px 12px",
          background: "#4f46e5",
          color: "#fff",
          cursor: isDragging ? "grabbing" : "grab",
          userSelect: "none",
        }}
      >
        从这里拖动
      </div>
      <div style={{ padding: 12 }}>
        <p>此内容区域不会触发拖拽。</p>
      </div>
    </div>
  );
}

面板的主体仍然是可交互的——你可以选择文本、点击按钮或滚动——而只有标题栏是拖拽触发器。

useDropZone:轻松实现文件拖放

useDropZone 解决拖放的另一半:接收放置。它处理全部四个拖拽事件(dragenterdragoverdragleavedrop),阻止浏览器默认打开文件的行为,并通过内部计数器解决了 dragleave 闪烁问题。

import { useDropZone } from "@reactuses/core";
import { useRef, useState } from "react";

function FileUploader() {
  const dropRef = useRef<HTMLDivElement>(null);
  const [files, setFiles] = useState<File[]>([]);

  const isOver = useDropZone(dropRef, (droppedFiles) => {
    if (droppedFiles) {
      setFiles((prev) => [...prev, ...droppedFiles]);
    }
  });

  return (
    <div
      ref={dropRef}
      style={{
        padding: 40,
        border: `2px dashed ${isOver ? "#4f46e5" : "#cbd5e1"}`,
        borderRadius: 8,
        background: isOver ? "#eef2ff" : "#f8fafc",
        textAlign: "center",
        transition: "all 0.15s",
      }}
    >
      {isOver ? (
        <p>松开以上传</p>
      ) : (
        <p>将文件拖到这里上传</p>
      )}
      {files.length > 0 && (
        <ul style={{ textAlign: "left", marginTop: 16 }}>
          {files.map((f, i) => (
            <li key={i}>
              {f.name} ({(f.size / 1024).toFixed(1)} KB)
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

isOver 布尔值让你在文件进入时立即重新设置区域样式,给用户清晰的视觉反馈。无需 e.preventDefault() 样板代码,不用和闪烁的 dragleave 事件斗争。

构建看板风格的卡片拖动

让我们在一个更贴近实际的例子中结合两个 Hook——一个可拖拽的卡片,松开时弹回原位,以及一个接受它的放置区域。我们还将使用 useElementBounding 来读取区域位置以做视觉反馈。

import { useDraggable, useDropZone, useElementBounding } from "@reactuses/core";
import { useRef, useState } from "react";

interface Task {
  id: string;
  title: string;
}

function KanbanBoard() {
  const [todo, setTodo] = useState<Task[]>([
    { id: "1", title: "设计原型" },
    { id: "2", title: "编写 API 规范" },
  ]);
  const [done, setDone] = useState<Task[]>([
    { id: "3", title: "搭建 CI 流水线" },
  ]);

  const doneZoneRef = useRef<HTMLDivElement>(null);
  const todoZoneRef = useRef<HTMLDivElement>(null);

  const isOverDone = useDropZone(doneZoneRef, (files) => {
    // 此示例忽略文件拖放
  });

  const isOverTodo = useDropZone(todoZoneRef, (files) => {
    // 此示例忽略文件拖放
  });

  const doneBounds = useElementBounding(doneZoneRef);

  return (
    <div style={{ display: "flex", gap: 24, padding: 24 }}>
      <div>
        <h3>待办</h3>
        <div
          ref={todoZoneRef}
          style={{
            minHeight: 200,
            padding: 12,
            background: isOverTodo ? "#fef3c7" : "#f1f5f9",
            borderRadius: 8,
          }}
        >
          {todo.map((task) => (
            <TaskCard
              key={task.id}
              task={task}
              onDrop={() => {
                setTodo((prev) => prev.filter((t) => t.id !== task.id));
                setDone((prev) => [...prev, task]);
              }}
              targetBounds={doneBounds}
            />
          ))}
        </div>
      </div>
      <div>
        <h3>完成</h3>
        <div
          ref={doneZoneRef}
          style={{
            minHeight: 200,
            padding: 12,
            background: isOverDone ? "#d1fae5" : "#f1f5f9",
            borderRadius: 8,
          }}
        >
          {done.map((task) => (
            <div
              key={task.id}
              style={{
                padding: 12,
                marginBottom: 8,
                background: "#fff",
                borderRadius: 6,
                boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
              }}
            >
              {task.title}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

function TaskCard({
  task,
  onDrop,
  targetBounds,
}: {
  task: Task;
  onDrop: () => void;
  targetBounds: ReturnType<typeof useElementBounding>;
}) {
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging, setPosition] = useDraggable(el, {
    initialValue: { x: 0, y: 0 },
    onEnd: (pos) => {
      // 检查卡片是否在"完成"列上方释放
      if (
        targetBounds &&
        pos.x >= targetBounds.left &&
        pos.x <= targetBounds.right &&
        pos.y >= targetBounds.top &&
        pos.y <= targetBounds.bottom
      ) {
        onDrop();
      }
      // 弹回原始位置
      setPosition({ x: 0, y: 0 });
    },
  });

  return (
    <div
      ref={el}
      style={{
        position: "relative",
        left: x,
        top: y,
        padding: 12,
        marginBottom: 8,
        background: isDragging ? "#e0e7ff" : "#fff",
        borderRadius: 6,
        boxShadow: isDragging
          ? "0 8px 24px rgba(0,0,0,0.15)"
          : "0 1px 3px rgba(0,0,0,0.1)",
        cursor: isDragging ? "grabbing" : "grab",
        zIndex: isDragging ? 50 : 1,
        touchAction: "none",
        userSelect: "none",
        transition: isDragging ? "none" : "all 0.2s ease",
      }}
    >
      {task.title}
    </div>
  );
}

几个值得注意的关键点:

  • useElementBounding 为我们提供了"完成"列的实时 leftrighttopbottom 值,以便在拖拽结束时进行碰撞检测。
  • onEnd 回调在未落在目标上时将卡片弹回 { x: 0, y: 0 }。配合 CSS transition 产生令人满意的橡皮筋效果。
  • 无需外部状态库。React 的 useState 对于这个复杂度完全够用。

配合其他 Hook 增强体验

ReactUse 的 Hook 天然可组合。以下是扩展上述示例的几种方式:

  • useMouse ——全局追踪光标位置,在拖拽过程中显示自定义拖拽光标或跟随指针的浮动提示。
  • useEventListener ——附加一个 keydown 监听器,在用户按下 Escape 时取消拖拽。
  • useElementSize ——动态读取容器的宽高以计算网格对齐位置(例如将 x 舍入到单元格宽度的最近倍数)。

例如,使用 useEventListener 添加 Escape 取消只需几行代码:

import { useDraggable, useEventListener } from "@reactuses/core";
import { useRef } from "react";

function CancelableDrag() {
  const el = useRef<HTMLDivElement>(null);
  const [x, y, isDragging, setPosition] = useDraggable(el);

  useEventListener("keydown", (e: KeyboardEvent) => {
    if (e.key === "Escape" && isDragging) {
      setPosition({ x: 0, y: 0 });
    }
  });

  return (
    <div
      ref={el}
      style={{
        position: "fixed",
        left: x,
        top: y,
        padding: 16,
        background: "#4f46e5",
        color: "#fff",
        borderRadius: 8,
        cursor: isDragging ? "grabbing" : "grab",
        touchAction: "none",
      }}
    >
      拖动我(按 Esc 重置)
    </div>
  );
}

什么时候仍然需要完整的库

useDraggableuseDropZone 用最少的代码覆盖了绝大多数拖放场景。然而,如果你的需求包含复杂的可排序列表(带动画过渡)、具有键盘无障碍访问的多容器排序,或包含上千项的虚拟化列表,像 dnd-kit 这样的专用库仍然是更好的选择。关键在于,你并不需要在每种情况下都引入一个——对许多项目来说,一对 Hook 就足够了。

安装

npm i @reactuses/core

相关 Hook


ReactUse 提供了 100+ 个 React Hook。探索所有 Hook →


本文最初发布于 ReactUse 博客

深入浅出 AST:解密 Vite、Babel编译的底层“黑盒”

前言

在前端开发中,我们每天都在写 JSX、TypeScript、Vue SFC,但浏览器其实根本看不懂这些。是谁把这些高级语法翻译成了浏览器能执行的 JS?答案就是 AST(Abstract Syntax Tree,抽象语法树) 。它是所有前端构建工具(Vite、Webpack、ESBuild、Babel)的灵魂。

一、 核心概念:什么是 AST?

AST(Abstract Syntax Tree,抽象语法树) ,是代码的结构化数据表示。简单来说,就是把原本一行行纯文本形式的代码,剥离无关的格式、空格、注释等冗余信息,转换成一棵有层级、有嵌套、有明确语法逻辑的树状对象。

  • 转换的核心意义:让计算机能够真正读懂代码的含义,而不是把代码当成普通字符串处理。有了AST,机器才能精准分析代码结构、修改代码逻辑、实现各类编译构建功能。

  • 例子const a = 1 在 AST 中会被拆解为:一个变量声明节点、一个标识符 a 和一个数字字面量 1


二、 AST的编译与生成流程

代码转换通常经历以下四个标准阶段:

  1. 词法分析 (Tokenization) :将长字符串拆解为最小语法单元(Tokens)。例如把 const a = 1 拆成 consta=1

  2. 语法分析 (Parsing) :在通过词法分析得到零散的Tokens后,语法分析会根据对应的语言规范(JS规范、Vue模板规范等),将这些无序的Tokens按照语法规则,组装成具有嵌套依赖关系的节点树,也就是最终的AST。这一步会确立代码的语法结构,比如声明语句、赋值语句、函数定义等节点的层级关系。

  3. 转换 (Transformation) :这是各类编译工具的核心工作区,比如Babel、ESBuild、Rollup的关键逻辑都在这一步。工具会深度遍历AST上的每一个节点,根据需求对节点进行修改、新增、删除操作,比如语法降级、代码替换、依赖处理等,改造出符合目标要求的新AST。

  4. 代码生成 (Code Generation) :完成AST的修改后,最后一步就是逆向操作:把改造后的树状AST,重新转换回纯文本形式的可执行代码,完成整个编译构建流程。


三、AST的核心应用场景

AST是前端工程化的底层基石,几乎所有主流的构建、转译、优化工具,都是基于AST实现的,核心应用场景包括:

  • 代码转译(ES6+转ES5、TS转JS、Vue/React编译)
  • 依赖预构建与依赖分析
  • Tree Shaking(无用代码剔除)
  • 代码压缩、混淆、格式化
  • 静态代码检查(ESLint)
  • 框架单文件组件编译(Vue SFC、React JSX)

四、 AST 在 Vite 中的降维打击

Vite作为新一代前端构建工具,凭借超快的启动速度和构建效率出圈,而这一切高效能力的底层,都离不开AST的支撑。下面详解AST在Vite四大核心场景中的具体作用。

1. 依赖预构建 (Pre-bundling)

依赖预构建是Vite启动速度远超Webpack的核心秘诀,而AST则是依赖预构建的核心底层支撑,具体执行流程:

  1. Vite会深度解析第三方依赖包代码(比如lodash-es、axios等),先将代码文本转换为AST;
  2. 遍历AST节点,精准识别出所有 import/export 语句(或CommonJS的 require 语句),梳理清楚第三方包的内部依赖关系;
  3. 修改AST节点:将不兼容浏览器的CommonJS语法,转换成浏览器原生支持的ESM模块化语法;
  4. 继续优化AST,把零散的多个依赖文件,合并成少数几个文件,减少网络请求;
  5. 将修改后的AST重新生成代码文本,缓存到 node_modules/.vite 目录下,供浏览器直接加载。

2. ESBuild 转译

Vite在开发阶段选用Go 编写的 ESBuild 进行快如闪电的转译,实现TS转JS、ES6+语法降级等能力,而ESBuild的核心工作原理就是基于AST处理

  1. ESBuild读取TS/TSX源码,将其解析生成标准AST;
  2. 遍历AST节点,剔除TS特有的语法节点(比如类型注解const a: number = 1),保留纯JS逻辑;
  3. 对ES6+高阶语法节点(箭头函数、解构赋值、可选链等)进行转换,替换为ES5兼容的AST节点;
  4. 将转换后的AST生成纯JS代码文本,返回给浏览器加载执行。

3. 按需导入与 Tree Shaking

Vite生产环境打包底层基于Rollup,而Tree Shaking(剔除无用代码、实现按需引入)完全依赖AST实现:

  1. Rollup解析项目源码,生成完整的AST;
  2. 深度遍历AST,跟踪代码的引用关系,精准识别出未被调用、未被引用的无用代码节点(比如未使用的函数、变量、模块);
  3. 从AST中直接删除这些无用节点,精简AST结构;
  4. 将精简后的AST重新生成代码文本,大幅减少打包体积,实现代码瘦身。

4. Vue SFC 单文件组件编译

在Vite+Vue项目中,@vitejs/plugin-vue 插件负责解析.vue单文件组件,AST是整个编译流程的核心:

  1. 插件先将.vue文件拆分为 <template><script><style> 三大核心模块;
  2. 针对 <template> 模板:生成专属的Vue模板AST(结构类似JS AST,针对模板语法优化),再将模板AST进一步转换成渲染函数(render函数)对应的JS AST;
  3. 针对 <script setup> 脚本:解析JS AST,处理 definePropsdefineEmitsdefineExpose 等Vue语法糖,将其转换为浏览器可识别的普通JS代码;
  4. 最后合并所有模块的AST,生成浏览器可直接运行的完整JS代码,完成Vue组件编译。

📝 总结与启发

AST 是前端工程化的“上帝视角”。掌握了它,你就掌握了编写 Lint 工具、代码加密、自动重构脚本 以及 自定义 Babel/Vite 插件 的能力。

前端模块化:CommonJS、AMD、ES Module三大规范全解析

前言

在前端工程化日益庞大的今天,模块化已成为基石。从最初的“全局变量污染”到如今的“万物皆可模块”,前端社区经历了漫长的探索。本文将深度解析业界主流的三大模块规范:CommonJSAMDES Module

一、 CommonJS:服务端的先行者

CommonJS 是最早正式提出的 JavaScript 模块规范,伴随着 Node.js 的诞生而风靡。

1. 核心语法

  • 导出:使用 module.exportsexports
  • 导入:使用 require
// a.js
const add = (a, b) => a + b;
module.exports = { add };

// main.js
const { add } = require('./a.js');
console.log(add(1, 2));

2. 局限性与挑战

  • 环境依赖:模块加载器由 Node.js 提供,高度依赖运行时环境。
  • 同步阻塞:CommonJS 规定模块加载是同步的。在服务端(磁盘读取)这没问题,但在浏览器端(网络请求),同步加载会导致 JS 解析阻塞,造成页面假死。

二、 AMD:浏览器的异步解法

为了解决 CommonJS 在浏览器端的同步阻塞问题,AMD (Asynchronous Module Definition) 应运而生。

1. 核心语法

AMD规范依赖第三方库(如RequireJS)实现,通过 define() 函数定义模块:第一个参数声明依赖模块数组,第二个参数为回调函数,依赖加载完成后执行;模块导出通过return实现。

// print.js 定义无依赖的模块
define(function () {
  // 模块内部逻辑
  function print(msg) {
    console.log("print " + msg);
  }
  // return 导出模块成员
  return {
    print
  };
});

// main.js 定义有依赖的模块
// 第一个参数:依赖模块列表;第二个参数:依赖加载完成后的回调
define(["./print"], function (printModule) {
  // 使用依赖模块的方法
  printModule.print("main");
});

2. 存在的不足

  • 非原生支持:需要引入第三方的 loader(如著名的 RequireJS)。
  • 开发成本:书写格式相对复杂,代码逻辑被包裹在回调函数中,阅读和维护成本较高。

三、 ES Module (ESM):终极统一方案

ES Module(ESM) 是ECMAScript官方推出的模块化标准,也是目前现代前端工程化的唯一标准,浏览器和Node.js均已原生支持,完美解决了前两种规范的缺陷。

1. 核心语法

  • 导出exportexport default
  • 导入import
// lib.js
export const version = '1.0.0';
export default function MyFunc() {}

// main.js
import MyFunc, { version } from './lib.js';

2. 为什么它是最优解?

  • 编译时加载(静态分析) :ESM 在代码执行前就能确定模块依赖关系,这使得 Tree-shaking(摇树优化) 成为可能。
  • 原生支持:现代浏览器通过 <script type="module"> 即可直接运行,无需转换。
  • 异步加载:天然支持异步,不会阻塞页面渲染。

四、 核心对比:CommonJS vs AMD vs ESM

维度 CommonJS AMD ES Module
加载方式 同步加载 异步加载 静态编译/异步加载
运行环境 主要用于服务端 (Node.js) 浏览器端 (需 Loader) 浏览器/服务端通用
典型代表 Node.js RequireJS Vite, Webpack, 现代浏览器

五、 总结与趋势

  1. CommonJS 依然是 Node.js 生态的基石,但在向 ESM 过渡。
  2. AMD 已逐渐退出历史舞台,基本被打包工具(如 Webpack)内部处理。
  3. ESM 是未来,无论是前端框架(Vue3/React)还是构建工具(Vite),都在全面拥抱 ESM。

异步编程:从“回调地狱”到“async/await”的救赎之路

JavaScript是单线程的,但它却能同时处理很多事情。这是怎么做到的?今天我们就来聊聊异步编程,看看JS是怎么一边听歌一边刷网页的。从最原始的回调函数,到Promise,再到优雅的async/await,这不仅是技术的演进,更是一场“程序员不熬夜”的运动。

前言

你有没有经历过这种绝望:写了一个网络请求,结果后面的代码先执行了,请求的数据还没回来,页面已经渲染完了,一片空白。或者你见过这样的代码:

getUser(function(user) {
  getOrders(user.id, function(orders) {
    getOrderDetails(orders[0].id, function(details) {
      getProductInfo(details.productId, function(product) {
        console.log(product);
      });
    });
  });
});

这就是传说中的回调地狱——代码像楼梯一样往右歪,看得人头晕眼花。

今天我们就来走一遍JS异步编程的进化史,看看前辈们是怎么从地狱里爬出来的。

一、为什么需要异步?

JavaScript是单线程的,也就是说同一时间只能做一件事。如果所有事情都排队等着,那遇到一个耗时操作(比如网络请求、读取文件),整个页面就得卡住,用户点哪儿都没反应。

异步就是解决方案:遇到耗时操作,先丢给浏览器或Node去“慢慢做”,JS主线程继续执行后面的代码。等耗时操作完成了,再通知JS:“嘿,我完事了,你处理一下结果吧。”

这就好比你点外卖:你不会站在店门口干等一小时,而是该干嘛干嘛,等外卖小哥打电话叫你,你再去取餐。异步就是这种“不干等”的机制。

二、回调函数:异步的原始形态

回调函数是最早的异步解决方案:把一个函数作为参数传给另一个函数,等异步操作完成后调用这个函数。

function fetchData(callback) {
  setTimeout(() => {
    callback('数据来了');
  }, 1000);
}

fetchData(function(data) {
  console.log(data); // 一秒后输出:数据来了
});

看起来还行,对吧?但一旦有多个依赖的异步操作,就出事了。

回调地狱长什么样?

// 先获取用户
getUser(function(user) {
  // 再根据用户ID获取订单
  getOrders(user.id, function(orders) {
    // 再获取第一个订单的详情
    getOrderDetails(orders[0].id, function(details) {
      // 再根据商品ID获取商品信息
      getProductInfo(details.productId, function(product) {
        // 终于拿到了
        console.log(product);
      });
    });
  });
});

代码往右飞,一眼看不到头。这还没算错误处理——每个回调都要处理错误,代码量直接翻倍。这种代码别说维护了,写的时候自己都要绕晕。

回调的痛点

  • 嵌套太深,代码可读性差
  • 错误处理困难,每个回调都要try-catch
  • 难以并行执行多个异步操作

三、Promise:打破地狱的“链式反应”

ES6引入了Promise,它像是一个“承诺”:现在还没有结果,但将来一定会有(要么成功,要么失败)。

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('数据来了');
    // 如果出错:reject('错误信息')
  }, 1000);
});

promise
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error(error);
  });

Promise最大的好处是链式调用,可以把嵌套的异步操作拍平:

getUser()
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => getProductInfo(details.productId))
  .then(product => console.log(product))
  .catch(error => console.error(error));

看,从“右飞”变成了“下飞”,代码清晰多了。

Promise的几个关键点

  1. 状态不可逆:Promise有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。一旦从pending变成fulfilled或rejected,就不能再变了。

  2. 链式传递then返回的是一个新的Promise,所以可以一直链下去。

  3. 错误冒泡:只要链尾有一个catch,前面任何一个环节出错都会落进来。

  4. 并行操作Promise.all等待所有完成,Promise.race等待最快的一个。

// 并行请求
Promise.all([fetchUser(), fetchOrders(), fetchProduct()])
  .then(([user, orders, product]) => {
    console.log('全部完成', user, orders, product);
  });

Promise解决了回调地狱的问题,但还是有些繁琐——你需要写很多.then.catch,而且处理复杂的逻辑时,还是有点绕。

四、async/await:异步代码同步写

ES2017推出的async/await,是Promise的语法糖,让异步代码看起来像同步代码一样直观。

async function getProductInfo() {
  try {
    const user = await getUser();
    const orders = await getOrders(user.id);
    const details = await getOrderDetails(orders[0].id);
    const product = await getProductInfo(details.productId);
    console.log(product);
  } catch (error) {
    console.error(error);
  }
}

关键点

  • async标记的函数返回一个Promise
  • await后面跟一个Promise,它会“暂停”函数执行,直到Promise出结果
  • 错误处理直接用try/catch,和同步代码一模一样

这感觉就像:终于可以用写同步代码的姿势写异步了!不用再管什么then、catch,代码一下子就清爽了。

但注意:await会阻塞函数内部,但不阻塞外部

async function test() {
  console.log('1');
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log('2'); // 一秒后才输出
}
console.log('3');
test();
console.log('4');
// 输出顺序:1,3,4,(一秒后)2

await只阻塞它所在的async函数,外面的代码照常执行。这正是异步的精髓:不干等。

五、事件循环:异步背后的幕后黑手

说了这么多,你有没有想过一个问题:异步操作完成之后,回调是怎么被调用的?这就要提到**事件循环(Event Loop)**了。

JS的执行机制大概是这样的:

  1. 主线程执行同步代码,遇到异步任务(比如setTimeout、网络请求)就交给Web APIs(浏览器)或libuv(Node)去处理。
  2. 异步任务完成后,回调函数被放入任务队列
  3. 主线程的同步代码执行完后,会不断从任务队列里取回调来执行。
  4. 这个过程不断重复,就是事件循环。

任务队列还分宏任务微任务

  • 宏任务:setTimeout、setInterval、I/O操作、UI渲染
  • 微任务:Promise.then、MutationObserver、queueMicrotask

执行顺序是:一个宏任务 → 所有微任务 → 渲染(如果有) → 下一个宏任务。

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出:1,4,3,2

为什么?同步代码先执行(1,4)→ 微任务Promise.then(3)→ 下一个宏任务setTimeout(2)。

六、实战:封装一个带超时的fetch

我们来用async/await封装一个实用的网络请求函数:

async function fetchWithTimeout(url, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      throw new Error('请求超时');
    }
    throw error;
  }
}

// 使用
try {
  const data = await fetchWithTimeout('https://api.example.com/data', 3000);
  console.log(data);
} catch (error) {
  console.error(error.message);
}

这个函数既支持超时控制,又有完善的错误处理,用起来就像同步代码一样简单。

七、异步编程的最佳实践

  1. 能用async/await就用:比原生Promise更易读,错误处理也更自然。

  2. 避免“忘掉await”:忘记await会得到一个Promise对象,而不是实际值,这个bug很难找。

  3. 并行任务用Promise.all:如果多个异步任务互不依赖,用Promise.all并行执行,而不是挨个await。

// 慢:串行执行,总耗时2秒
const user = await getUser();
const orders = await getOrders();

// 快:并行执行,总耗时1秒(如果每个请求1秒)
const [user, orders] = await Promise.all([getUser(), getOrders()]);
  1. 错误处理要完整:async/await用try/catch,Promise用.catch(),不要漏掉。

  2. 避免在循环里用await:除非你确实需要串行执行,否则可以用Promise.all或for...of配合异步。

// 这样会串行执行,很慢
for (const id of ids) {
  const item = await fetchItem(id);
  items.push(item);
}

// 并行执行,快很多
const items = await Promise.all(ids.map(id => fetchItem(id)));

八、总结:从地狱到天堂

JS异步编程的演进史,就是一部程序员与复杂性抗争的历史:

  • 回调函数:原始但容易陷入地狱
  • Promise:链式调用打破嵌套
  • async/await:让异步代码回归同步的直觉

现在,你应该能理解为什么异步这么重要,以及怎么优雅地处理异步了。记住:不要在回调里写回调,不要在地狱里挣扎,用Promise和async/await解救自己。

明天我们将深入JS的另一座大山——事件循环(Event Loop),彻底搞懂微任务、宏任务、渲染时机这些核心概念。到时候你会发现,那些让人头疼的异步面试题,不过是一层窗户纸。

如果你觉得今天的异步进化史讲得通透,点个赞让更多人看到。有疑问评论区见,我们明天见!

# 手把手教你从零搭建 AI 对话系统 - React + Spring Boot 实战(二)

一个完整的类 ChatGPT 对话系统,支持流式输出、打断,会话历史,前后端分离架构,非常适合拿来练手熟悉技术实现或者面试使用,接上一篇前端

基于 Spring Boot 2.7.18 + MyBatis-Plus + JWT 的 AI 对话系统后端服务。

技术栈

  • Spring Boot 2.7.18 - 核心框架
  • MyBatis-Plus 3.5.5 - ORM 框架
  • JWT - 身份认证
  • MySQL 8.0 - 数据存储
  • DeepSeek API - AI 对话能力
  • Knife4j - API 文档

核心功能

1. 用户认证体系

采用 JWT Token 实现无状态认证:

// JwtUtil.java - Token 生成与验证
@Component
public class JwtUtil {
    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    public String generateToken(Long userId, String username) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .setSubject(String.valueOf(userId))
                .claim("username", username)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }
}

2. 用户级 API Key 管理

每个用户独立绑定自己的 DeepSeek API Key,存储于 user.api_key 字段:

// UserServiceImpl.java - 注册时保存用户 API Key
@Override
@Transactional
public void register(RegisterRequest request) {
    User user = new User();
    user.setUsername(request.getUsername());
    user.setPassword(passwordEncoder.encode(request.getPassword()));
    user.setApiKey(request.getApiKey());  // 用户专属 API Key
    user.setStatus(1);
    save(user);
}

3. 流式对话实现

通过 SSE (Server-Sent Events) 实现流式响应:

// AiChatServiceImpl.java - 流式对话核心逻辑
@Override
public void streamChat(ChatRequest request, Long userId, HttpServletResponse response) {
    // 1. 从用户获取 API Key
    User user = userService.getById(userId);
    String apiKey = user.getApiKey();

    // 2. 设置 SSE 响应头
    response.setContentType("text/event-stream");
    response.setCharacterEncoding("UTF-8");

    // 3. 调用 DeepSeek API
    HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
    conn.setRequestProperty("Authorization", "Bearer " + apiKey);

    // 4. 流式转发响应
    try (BufferedReader reader = new BufferedReader(
            new InputStreamReader(conn.getInputStream()))) {
        String line;
        while ((line = reader.readLine()) != null) {
            writer.write(line + "\n");
            writer.flush();

            // 解析内容保存到数据库
            if (line.startsWith("data: ") && !line.equals("data: [DONE]")) {
                parseAndSaveContent(line, aiMessage);
            }
        }
    }
}

4. 会话与消息管理

  • 会话表 (chat_session): 存储对话元数据
  • 消息表 (chat_message): 存储对话内容,支持 reasoning_content 深度思考

5. 打断功能实现

前端通过 AbortController 中断请求,后端检测连接状态:

// 检测客户端是否断开连接
private boolean isClientConnected(HttpServletResponse response, PrintWriter writer) {
    try {
        writer.write("");
        writer.flush();
        return !writer.checkError();
    } catch (Exception e) {
        return false;
    }
}

项目结构

src/main/java/com/webseek/
├── common/          # 通用工具类
│   ├── JwtUtil.java
│   ├── CurrentUser.java
│   └── Result.java
├── config/          # 配置类
│   ├── WebConfig.java
│   └── JwtInterceptor.java
├── controller/      # 控制器层
│   ├── AuthController.java
│   ├── ChatController.java
│   ├── SessionController.java
│   └── UserController.java
├── service/         # 服务层
│   ├── AiChatService.java
│   ├── UserService.java
│   └── impl/
├── entity/          # 实体类
│   ├── User.java
│   ├── ChatSession.java
│   └── ChatMessage.java
├── dto/             # 数据传输对象
│   ├── request/
│   └── response/
└── mapper/          # MyBatis Mapper

数据库表结构

-- 用户表
CREATE TABLE `user` (
    `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
    `username` VARCHAR(50) NOT NULL UNIQUE,
    `password` VARCHAR(100) NOT NULL,
    `nickname` VARCHAR(50),
    `api_key` VARCHAR(500),        -- DeepSeek API Key
    `status` TINYINT DEFAULT 1,
    `deleted` TINYINT DEFAULT 0,
    `create_time` DATETIME,
    `update_time` DATETIME
);

-- 会话表
CREATE TABLE `chat_session` (
    `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
    `session_id` VARCHAR(64) NOT NULL UNIQUE,
    `user_id` BIGINT NOT NULL,
    `title` VARCHAR(200) DEFAULT '新对话',
    `model` VARCHAR(50),
    `deleted` TINYINT DEFAULT 0,
    `create_time` DATETIME,
    `update_time` DATETIME
);

-- 消息表
CREATE TABLE `chat_message` (
    `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
    `message_id` VARCHAR(64) NOT NULL UNIQUE,
    `session_id` VARCHAR(64) NOT NULL,
    `user_id` BIGINT NOT NULL,
    `role` VARCHAR(20) NOT NULL,   -- user/assistant
    `content` TEXT,
    `reasoning_content` TEXT,      -- 深度思考内容
    `deleted` TINYINT DEFAULT 0,
    `create_time` DATETIME
);

配置说明

修改 application.yml 中的数据库配置:

spring:
  datasource:
    url: jdbc:mysql://your-host:3306/webseek?useUnicode=true&characterEncoding=utf-8
    username: your-username
    password: your-password

启动方式

# 开发环境
mvn spring-boot:run

# 打包
mvn clean package

# 运行
java -jar target/webseek-backend-1.0.0.jar

API 文档

启动后访问:http://localhost:8090/doc.html

核心设计亮点

  1. 用户级 API Key: 每个用户独立配置,安全隔离
  2. 流式响应: SSE 实现打字机效果,支持实时打断
  3. JWT 认证: 无状态设计,支持水平扩展
  4. 逻辑删除: MyBatis-Plus 自动处理软删除
  5. 深度思考: 支持 DeepSeek-R1 推理模型

源码地址[gitee.com/SongTaoo/re…]

手写一个精简版 Zustand:深入理解 React 状态管理的核心原理

“读源码不是为了造轮子,而是为了更好地驾驭轮子。”
本文将带你从零实现一个功能完整、结构清晰的 Zustand 精简版,并深入剖析其设计哲学与性能优化秘诀。


🌟 为什么是 Zustand?

在 React 生态中,状态管理方案层出不穷。Redux 曾长期占据主流,但其样板代码多、学习曲线陡峭的问题饱受诟病。而 Zustand 凭借极简 API、零模板、自动优化渲染等特性,迅速成为开发者的新宠(GitHub ⭐ 超 30k)。

它的核心优势在于:

  • 无需 Provider,直接 import 使用;
  • 天然支持按需订阅,避免无效重渲染;
  • API 极简,一个 create 搞定一切;
  • 轻量(仅 ~1KB),无依赖。

但你是否想过:Zustand 是如何做到这一切的?

今天,我们就来手写一个精简版 Zustand,揭开它高性能、易用背后的秘密。


🔧 第一步:构建最基础的状态容器

状态管理的核心无非三件事:存、取、改

我们先实现一个最简 Store:

const createStore = (createState) => {
  let state;
  const listeners = new Set();

  // 获取当前状态
  const getState = () => state;

  // 修改状态
  const setState = (partial) => {
    const nextState = typeof partial === 'function' ? partial(state) : partial;
    state = Object.assign({}, state, nextState);
    // 通知所有监听者
    listeners.forEach(listener => listener());
  };

  // 订阅状态变化
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener); // 返回取消订阅函数
  };

  // 初始化状态
  state = createState(setState, getState);

  return { getState, setState, subscribe };
};

关键点解析

  • createState 是用户传入的初始化函数,接收 setget
  • setState 支持传入对象或函数(类似 React 的 useState);
  • 使用 Set 存储监听器,避免重复订阅;
  • 状态变更后,通知所有订阅者 —— 这就是“发布-订阅”模式。

🎣 第二步:让 React 组件能“感知”状态变化

光有 Store 不够,React 组件需要在状态变化时自动重渲染。这就需要一个自定义 Hook。

import { useState, useEffect } from 'react';

const useStore = (api, selector = (state) => state) => {
  const [, forceUpdate] = useState(0);

  useEffect(() => {
    const unsubscribe = api.subscribe(() => {
      forceUpdate(Math.random()); // 强制更新组件
    });
    return unsubscribe;
  }, []);

  return selector(api.getState());
};

⚠️ 问题来了:这个实现会导致所有使用该 Store 的组件在任意状态变化时都重渲染!这显然违背了 Zustand 的“按需更新”原则。


🚀 第三步:实现“精准订阅”——只在关心的状态变化时更新

Zustand 的核心性能优势在于:组件只订阅自己需要的状态片段

改进思路:

  • 比较 selector 前后的值;
  • 只有当选中的值发生变化时,才触发重渲染。
const useStore = (api, selector) => {
  const [, forceUpdate] = useState(0);

  useEffect(() => {
    const unsubscribe = api.subscribe((newState, oldState) => {
      const newSelected = selector(newState);
      const oldSelected = selector(oldState);
      // 使用 Object.is 进行严格相等比较(处理 NaN、-0 等边界)
      if (!Object.is(newSelected, oldSelected)) {
        forceUpdate(Math.random());
      }
    });
    return unsubscribe;
  }, [selector]); // 注意:selector 应为稳定函数(通常用 useCallback 包裹)

  return selector(api.getState());
};

💡 为什么有效?

  • CountDisplayselector: state => state.count
  • text 变化时,count 未变 → newSelected === oldSelected不重渲染
  • 完美实现细粒度更新

🏗️ 第四步:封装 create 高阶函数,提供开发者友好的 API

Zustand 的魔法入口是 create。它返回一个 既是 Hook 又是 Store API 对象 的函数。

export const create = (createState) => {
  const api = createStore(createState);

  const useBoundStore = (selector) => {
    return useStore(api, selector);
  };

  // 将 Store 的方法(setState, getState 等)挂载到 Hook 上
  Object.assign(useBoundStore, api);

  return useBoundStore;
};

这样设计的好处

  • 在组件中:const count = useStore(state => state.count)
  • 在非组件中(如工具函数、事件回调):useStore.setState({ count: 10 })
  • 一套 API,两种用法,无缝切换

🧪 完整 Demo:验证局部更新效果

const useCounterStore = create((set) => ({
  count: 0,
  text: '初始文本',
  increment: () => set(state => ({ count: state.count + 1 })),
  updateText: (text) => set({ text })
}));

// 只订阅 count
const CountDisplay = () => {
  console.log('CountDisplay 渲染了');
  const count = useCounterStore(state => state.count);
  const increment = useCounterStore(state => state.increment);
  return <div>Count: {count} <button onClick={increment}>+</button></div>;
};

// 只订阅 text
const TextDisplay = () => {
  console.log('TextDisplay 渲染了');
  const text = useCounterStore(state => state.text);
  const updateText = useCounterStore(state => state.updateText);
  return <input value={text} onChange={e => updateText(e.target.value)} />;
};

打开控制台你会发现

  • 点击 “+” 按钮 → 只有 CountDisplay 重新渲染;
  • 修改输入框 → 只有 TextDisplay 重新渲染;
  • 完美隔离,性能拉满!

💡 深度思考:Zustand 为何如此优秀?

  1. 去中心化设计
    无需 Provider 嵌套,状态即模块,天然支持代码分割。
  2. 响应式粒度控制
    通过 selector 实现状态切片订阅,比 Context + useReducer 更高效。
  3. 函数式 + 响应式融合
    set 接收函数支持状态派生,get 支持跨字段计算,灵活又安全。
  4. 极致简洁
    核心代码不足 100 行,却覆盖 90% 场景,体现“少即是多”的哲学。

📌 总结

通过手写 Zustand,我们不仅掌握了:

  • 发布-订阅模式在状态管理中的应用;
  • React 自定义 Hook 与状态同步的技巧;
  • 如何实现精准渲染以提升性能;

更重要的是,理解了优秀库的设计思想简单、专注、可组合

“当你能手写一个库,你就真正拥有了它。”

下次面试被问到 Zustand 原理时,不妨自信地说:
“我不仅用过,我还写过。”

AI协同写作应用-TipTap基础功能

前言

系列教程和源码在飞书文档编写。

本章概述

在本章中,我们将快速上手 Tiptap,从零开始创建一个功能完整的富文本编辑器。你将学会如何安装、配置和使用 Tiptap 的基础功能。

学习目标:

  • 创建一个新的前端项目
  • 安装 Tiptap 及其依赖
  • 创建第一个可用的编辑器
  • 使用 StarterKit 快速添加功能
  • 理解基本配置选项
  • 添加简单的工具栏

前置知识:

  • Node.js 和 npm/pnpm 基础
  • HTML、CSS、JavaScript 基础
  • 基础的命令行操作

预计学习时间: 30-45 分钟


1. 环境准备

1.1 检查 Node.js 版本

Tiptap 需要 Node.js 16+ 版本。

# 检查 Node.js 版本
node --version
# 应该显示 v16.0.0 或更高版本

# 检查 npm 版本
npm --version

如果版本过低,请访问 nodejs.org 下载最新的 LTS 版本。

1.2 选择包管理器

本教程推荐使用 pnpm,它比 npm 更快、更节省磁盘空间。

# 安装 pnpm(如果还没有)
npm install -g pnpm

# 验证安装
pnpm --version

当然,你也可以使用 npm 或 yarn:

# 使用 npm
npm install

# 使用 yarn
yarn add

💡 提示: 本教程的所有命令都使用 pnpm,如果你使用其他包管理器,请相应替换命令。


2. 创建项目

2.1 使用 Vite 创建项目

我们使用 Vite 创建一个 React + TypeScript 项目。

# 创建项目
pnpm create vite tiptap-demo --template react-ts

# 进入项目目录
cd tiptap-demo

# 安装依赖
pnpm install

为什么选择 Vite?

  • ⚡ 极快的启动速度
  • 🔥 热更新(HMR)快速
  • 📦 开箱即用的 TypeScript 支持
  • 🛠️ 现代化的构建工具

2.2 项目结构

创建完成后,项目结构如下:

tiptap-demo/
├── node_modules/
├── public/
├── src/
│   ├── App.css
│   ├── App.tsx
│   ├── main.tsx
│   └── vite-env.d.ts
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.ts

2.3 启动开发服务器

pnpm dev

打开浏览器访问 http://localhost:5173,你应该能看到 Vite 的欢迎页面。


3. 安装 Tiptap

3.1 安装核心包

pnpm add @tiptap/react @tiptap/pm @tiptap/starter-kit

包的说明:

包名 版本 大小 说明
@tiptap/react ^2.x ~15KB React 集成包,提供 Hooks 和组件
@tiptap/pm ^2.x ~200KB ProseMirror 核心依赖
@tiptap/starter-kit ^2.x ~30KB 常用扩展集合(15+ 扩展)

总大小: ~245KB(未压缩),~80KB(gzip 压缩后)

3.2 验证安装

检查 package.json 文件,应该能看到:

{
  "dependencies": {
    "@tiptap/pm": "^2.x.x",
    "@tiptap/react": "^2.x.x",
    "@tiptap/starter-kit": "^2.x.x",
    "react": "^18.x.x",
    "react-dom": "^18.x.x"
  }
}

4. 创建第一个编辑器

4.1 清理默认代码

首先,清理 Vite 生成的默认代码。

修改 src/App.tsx

// src/App.tsx
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
    </div>
  )
}

export default App

修改 src/App.css

/* src/App.css */
.app {
  max-width: 900px;
  margin: 0 auto;
  padding: 2rem;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

h1 {
  margin-bottom: 2rem;
  color: #333;
}

4.2 创建编辑器组件

创建 src/Tiptap.tsx 文件:

// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'

function Tiptap() {
  const editor = useEditor({
    extensions: [
      StarterKit,
    ],
    content: '<p>Hello World! 🌍</p>',
  })

  return <EditorContent editor={editor} />
}

export default Tiptap

代码解析:

  1. 导入必要的模块

    import { useEditor, EditorContent } from '@tiptap/react'
    import StarterKit from '@tiptap/starter-kit'
    
    • useEditor: React Hook,用于创建编辑器实例
    • EditorContent: React 组件,用于渲染编辑器
    • StarterKit: 包含 15+ 个常用扩展
  2. 创建编辑器实例

    const editor = useEditor({
      extensions: [StarterKit],
      content: '<p>Hello World! 🌍</p>',
    })
    
    • extensions: 配置编辑器使用的扩展
    • content: 初始内容(HTML 格式)
  3. 渲染编辑器

    return <EditorContent editor={editor} />
    
    • EditorContent 组件接收编辑器实例并渲染

4.3 在 App 中使用

修改 src/App.tsx

// src/App.tsx
import Tiptap from './Tiptap'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
      <Tiptap />
    </div>
  )
}

export default App

4.4 添加基础样式

src/App.css 中添加编辑器样式:

/* src/App.css */

/* ... 之前的样式 ... */

/* 编辑器容器样式 */
.tiptap {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  padding: 1rem;
  min-height: 200px;
  outline: none;
  background-color: white;
}

/* 编辑器获得焦点时的样式 */
.tiptap:focus {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

/* 段落样式 */
.tiptap p {
  margin: 0.75rem 0;
  line-height: 1.6;
}

/* 第一个段落不需要上边距 */
.tiptap p:first-child {
  margin-top: 0;
}

/* 最后一个段落不需要下边距 */
.tiptap p:last-child {
  margin-bottom: 0;
}

4.5 测试编辑器

保存所有文件,浏览器应该自动刷新。你应该能看到:

  • 一个带边框的编辑区域
  • 初始内容 "Hello World! 🌍"
  • 可以输入、删除文字
  • 可以使用快捷键(Ctrl+B 加粗、Ctrl+Z 撤销等)

测试清单:

  • ✅ 输入文字
  • ✅ 删除文字
  • ✅ 换行(按 Enter)
  • ✅ 撤销(Ctrl+Z)
  • ✅ 重做(Ctrl+Shift+Z)
  • ✅ 加粗(Ctrl+B)
  • ✅ 斜体(Ctrl+I)

5. 理解 StarterKit

5.1 StarterKit 包含的扩展

StarterKit 是一个扩展集合,包含了最常用的 15+ 个扩展:

Nodes(节点):

  • Document - 文档根节点
  • Paragraph - 段落
  • Text - 文本
  • Heading - 标题(H1-H6)
  • Blockquote - 引用块
  • CodeBlock - 代码块
  • BulletList - 无序列表
  • OrderedList - 有序列表
  • ListItem - 列表项
  • HardBreak - 硬换行
  • HorizontalRule - 水平分割线

Marks(标记):

  • Bold - 加粗
  • Italic - 斜体
  • Strike - 删除线
  • Code - 行内代码

Extensions(功能):

  • History - 撤销/重做
  • Dropcursor - 拖放光标
  • Gapcursor - 间隙光标

5.2 测试 StarterKit 功能

让我们测试一下这些功能。修改初始内容:

const editor = useEditor({
  extensions: [StarterKit],
  content: `
    <h1>欢迎使用 Tiptap</h1>
    <p>这是一个<strong>功能强大</strong>的<em>富文本编辑器</em>。</p>
    <h2>主要特性</h2>
    <ul>
      <li>支持多种文本格式</li>
      <li>可扩展的架构</li>
      <li>优秀的性能</li>
    </ul>
    <blockquote>
      <p>Tiptap 让编辑器开发变得简单而有趣。</p>
    </blockquote>
    <pre><code>const editor = useEditor({ ... })</code></pre>
  `,
})

现在你应该能看到:

  • 标题(H1、H2)
  • 加粗和斜体文字
  • 无序列表
  • 引用块
  • 代码块

5.3 自定义 StarterKit

你可以禁用某些扩展或自定义配置:

const editor = useEditor({
  extensions: [
    StarterKit.configure({
      // 禁用某些扩展
      heading: false,
      
      // 自定义扩展配置
      bulletList: {
        HTMLAttributes: {
          class: 'my-bullet-list',
        },
      },
      
      // 自定义标题级别
      heading: {
        levels: [1, 2, 3],  // 只允许 H1、H2、H3
      },
    }),
  ],
  content: '<p>Hello World!</p>',
})

6. 添加工具栏

现在让我们添加一个简单的工具栏,让用户可以点击按钮来格式化文字。

6.1 创建工具栏组件

修改 src/Tiptap.tsx

// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import './Tiptap.css'

function Tiptap() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World! 🌍</p>',
  })

  if (!editor) {
    return null
  }

  return (
    <div className="editor-container">
      {/* 工具栏 */}
      <div className="toolbar">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'is-active' : ''}
        >
          <strong>B</strong>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'is-active' : ''}
        >
          <em>I</em>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleStrike().run()}
          className={editor.isActive('strike') ? 'is-active' : ''}
        >
          <s>S</s>
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
          className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
        >
          H1
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
          className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
        >
          H2
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={editor.isActive('bulletList') ? 'is-active' : ''}
        >
          • 列表
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
          className={editor.isActive('orderedList') ? 'is-active' : ''}
        >
          1. 列表
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().undo().run()}
          disabled={!editor.can().undo()}
        >
          ↶ 撤销
        </button>
        
        <button
          onClick={() => editor.chain().focus().redo().run()}
          disabled={!editor.can().redo()}
        >
          ↷ 重做
        </button>
      </div>
      
      {/* 编辑器 */}
      <EditorContent editor={editor} />
    </div>
  )
}

export default Tiptap

代码解析:

  1. 空值检查

    if (!editor) return null
    

    首次渲染时编辑器可能为 null,需要检查。

  2. Commands 链式调用

    editor.chain().focus().toggleBold().run()
    
    • chain(): 开始链式调用
    • focus(): 让编辑器获得焦点
    • toggleBold(): 切换加粗状态
    • run(): 执行命令链
  3. 检查激活状态

    editor.isActive('bold')
    

    用于高亮当前激活的按钮。

  4. 检查命令可用性

    editor.can().undo()
    

    用于禁用不可用的按钮。

6.2 添加工具栏样式

创建 src/Tiptap.css 文件:

/* src/Tiptap.css */

.editor-container {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  overflow: hidden;
  background-color: white;
}

/* 工具栏样式 */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  padding: 0.75rem;
  background-color: #f9fafb;
  border-bottom: 1px solid #e5e7eb;
}

.toolbar button {
  padding: 0.5rem 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  background-color: white;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
  color: #374151;
  transition: all 0.2s;
}

.toolbar button:hover:not(:disabled) {
  background-color: #f3f4f6;
  border-color: #9ca3af;
}

.toolbar button.is-active {
  background-color: #3b82f6;
  color: white;
  border-color: #3b82f6;
}

.toolbar button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.toolbar .divider {
  width: 1px;
  background-color: #e5e7eb;
  margin: 0 0.25rem;
}

/* 编辑器内容样式 */
.editor-container .tiptap {
  padding: 1rem;
  min-height: 300px;
  outline: none;
  border: none;
}

.editor-container .tiptap:focus {
  box-shadow: none;
}

/* 标题样式 */
.tiptap h1 {
  font-size: 2rem;
  font-weight: 700;
  margin: 1.5rem 0 1rem;
  line-height: 1.2;
}

.tiptap h2 {
  font-size: 1.5rem;
  font-weight: 600;
  margin: 1.25rem 0 0.75rem;
  line-height: 1.3;
}

.tiptap h3 {
  font-size: 1.25rem;
  font-weight: 600;
  margin: 1rem 0 0.5rem;
  line-height: 1.4;
}

/* 列表样式 */
.tiptap ul,
.tiptap ol {
  padding-left: 1.5rem;
  margin: 0.75rem 0;
}

.tiptap li {
  margin: 0.25rem 0;
}

/* 引用块样式 */
.tiptap blockquote {
  border-left: 3px solid #3b82f6;
  padding-left: 1rem;
  margin: 1rem 0;
  color: #6b7280;
  font-style: italic;
}

/* 代码块样式 */
.tiptap pre {
  background-color: #1f2937;
  color: #f9fafb;
  padding: 1rem;
  border-radius: 6px;
  margin: 1rem 0;
  overflow-x: auto;
}

.tiptap code {
  background-color: #f3f4f6;
  color: #ef4444;
  padding: 0.2rem 0.4rem;
  border-radius: 3px;
  font-size: 0.9em;
  font-family: 'Courier New', monospace;
}

.tiptap pre code {
  background-color: transparent;
  color: inherit;
  padding: 0;
}

/* 水平分割线样式 */
.tiptap hr {
  border: none;
  border-top: 2px solid #e5e7eb;
  margin: 2rem 0;
}

6.3 测试工具栏

保存文件后,你应该能看到:

  • 一个漂亮的工具栏
  • 点击按钮可以格式化文字
  • 激活的按钮会高亮显示
  • 不可用的按钮会被禁用

测试步骤:

  1. 选中一些文字
  2. 点击 "B" 按钮,文字应该变粗
  3. 按钮应该高亮显示
  4. 再次点击,文字恢复正常

7. 基本配置选项

7.1 常用配置

const editor = useEditor({
  // 扩展配置
  extensions: [StarterKit],
  
  // 初始内容
  content: '<p>Hello World!</p>',
  
  // 是否可编辑
  editable: true,
  
  // 是否自动获取焦点
  autofocus: false,
  
  // 事件回调
  onUpdate: ({ editor }) => {
    console.log('内容已更新', editor.getHTML())
  },
  
  onCreate: ({ editor }) => {
    console.log('编辑器已创建')
  },
  
  onFocus: ({ editor }) => {
    console.log('编辑器获得焦点')
  },
  
  onBlur: ({ editor }) => {
    console.log('编辑器失去焦点')
  },
})

7.2 配置选项说明

选项 类型 默认值 说明
extensions Extension[] 必需 编辑器使用的扩展数组
content string | JSONContent '' 初始内容(HTML 或 JSON)
editable boolean true 是否可编辑
autofocus boolean | 'start' | 'end' false 自动获取焦点
onUpdate function - 内容更新时触发
onCreate function - 编辑器创建时触发
onFocus function - 获得焦点时触发
onBlur function - 失去焦点时触发

8. 完整源码

📄 src/Tiptap.tsx

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import './Tiptap.css'

function Tiptap() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World! 🌍</p>',
  })

  if (!editor) {
    return null
  }

  return (
    <div className="editor-container">
      <div className="toolbar">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'is-active' : ''}
        >
          <strong>B</strong>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'is-active' : ''}
        >
          <em>I</em>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleStrike().run()}
          className={editor.isActive('strike') ? 'is-active' : ''}
        >
          <s>S</s>
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
          className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
        >
          H1
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
          className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
        >
          H2
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={editor.isActive('bulletList') ? 'is-active' : ''}
        >
          • 列表
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
          className={editor.isActive('orderedList') ? 'is-active' : ''}
        >
          1. 列表
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().undo().run()}
          disabled={!editor.can().undo()}
        >
          ↶ 撤销
        </button>
        
        <button
          onClick={() => editor.chain().focus().redo().run()}
          disabled={!editor.can().redo()}
        >
          ↷ 重做
        </button>
      </div>
      
      <EditorContent editor={editor} />
    </div>
  )
}

export default Tiptap

📄 src/App.tsx

import Tiptap from './Tiptap'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
      <Tiptap />
    </div>
  )
}

export default App

9. 本章总结

在本章中,我们学习了:

✅ 环境准备

  • 检查 Node.js 版本
  • 安装包管理器(pnpm)
  • 创建 Vite 项目

✅ 安装 Tiptap

  • 安装核心包(@tiptap/react、@tiptap/pm、@tiptap/starter-kit)
  • 理解包的作用和大小

✅ 创建编辑器

  • 使用 useEditor Hook
  • 渲染 EditorContent 组件
  • 添加基础样式

✅ StarterKit

  • 包含 15+ 个常用扩展
  • 自定义配置
  • 禁用特定扩展

✅ 添加工具栏

  • Commands 链式调用
  • 检查激活状态
  • 检查命令可用性
  • 添加工具栏样式

✅ 基本配置

  • 常用配置选项
  • 事件回调

🎯 关键知识点

1. useEditor Hook

const editor = useEditor({
  extensions: [StarterKit],
  content: '<p>Hello World!</p>',
})

2. Commands 链式调用

editor.chain().focus().toggleBold().run()

3. 检查状态

editor.isActive('bold')
editor.can().undo()

10. 下一步

现在你已经创建了第一个 Tiptap 编辑器!接下来我们将:

第 3 章:Tiptap 与 React 集成

  • 深入理解 useEditor Hook
  • 使用 EditorProvider
  • 实现自动保存
  • 处理 Next.js SSR

第 4 章:框架集成 - Vue/其他

  • Vue 集成
  • Angular 集成
  • Vanilla JavaScript

准备好继续学习了吗?🚀


11. 练习题

练习 1:添加更多按钮

在工具栏中添加以下按钮:

  • H3 标题
  • 引用块(Blockquote)
  • 代码块(CodeBlock)
  • 水平分割线(HorizontalRule)
💡 提示
<button
  onClick={() => editor.chain().focus().toggleBlockquote().run()}
  className={editor.isActive('blockquote') ? 'is-active' : ''}
>
  引用
</button>

练习 2:添加字符计数

在编辑器下方显示当前字符数。

💡 提示
const characterCount = editor.state.doc.textContent.length

<div className="character-count">
  {characterCount} 字符
</div>

练习 3:实现只读模式

添加一个切换按钮,可以切换编辑器的可编辑状态。

💡 提示
const [editable, setEditable] = useState(true)

useEffect(() => {
  if (editor) {
    editor.setEditable(editable)
  }
}, [editor, editable])

12. 常见问题

Q1: 为什么编辑器是 null?

A: 首次渲染时,编辑器还未初始化。解决方案:

if (!editor) return null

Q2: 如何获取编辑器内容?

A: 使用 getHTML()getJSON() 方法:

const html = editor.getHTML()
const json = editor.getJSON()

Q3: 如何设置编辑器内容?

A: 使用 setContent() 方法:

editor.commands.setContent('<p>新内容</p>')

Q4: 快捷键不工作?

A: 确保编辑器有焦点:

editor.chain().focus().toggleBold().run()

13. 扩展阅读


【JavaScript面试题-算法与数据结构】手写一个 LRU(最近最少使用)缓存类,支持 `get` 和 `put` 操作,要求时间复杂度 O(1)

一、数据结构设计

为了实现 get 和 put 操作的时间复杂度 O(1) ,我们组合了两种数据结构:

  1. 哈希表Map 或普通对象)

    • 存储键到双向链表节点的映射。
    • 作用:通过 key 直接定位到节点,实现 O(1) 的查找。
  2. 双向链表Node 类实现)

    • 维护所有节点的 使用顺序:链表头部(head 之后)是最近使用的节点,链表尾部(tail 之前)是最久未使用的节点。
    • 作用:在 O(1) 时间内完成节点的 移动到头部删除尾部 等操作。

此外,使用两个 哨兵节点(伪头 head 和伪尾 tail),避免处理链表为空或只有一个节点时的边界条件,使插入和删除操作更简洁。

以下是 JavaScript 手写的 LRU 缓存类,使用哈希表 + 双向链表实现,确保 get 和 put 操作时间复杂度为 O(1):

javascript

class LRUCache {
    /**
     * @param {number} capacity 缓存容量
     */
    constructor(capacity) {
        this.capacity = capacity;
        this.cache = new Map(); // 键 → 节点
        // 创建哨兵头尾节点,简化边界处理
        this.head = new Node(0, 0);
        this.tail = new Node(0, 0);
        this.head.next = this.tail;
        this.tail.prev = this.head;
    }

    /**
     * 获取键对应的值,并将该节点移动到链表头部(最近使用)
     * @param {number} key
     * @return {number}
     */
    get(key) {
        if (!this.cache.has(key)) {
            return -1;
        }
        const node = this.cache.get(key);
        this._moveToHead(node);
        return node.value;
    }

    /**
     * 插入或更新键值对,并将节点置于头部(最近使用)
     * 若容量超限,删除尾部节点(最久未使用)
     * @param {number} key
     * @param {number} value
     * @return {void}
     */
    put(key, value) {
        if (this.cache.has(key)) {
            // 已存在:更新值并移到头部
            const node = this.cache.get(key);
            node.value = value;
            this._moveToHead(node);
        } else {
            // 不存在:新建节点
            if (this.cache.size === this.capacity) {
                // 容量已满,删除尾部节点(最久未使用)
                const tailNode = this.tail.prev;
                this._removeNode(tailNode);
                this.cache.delete(tailNode.key);
            }
            const newNode = new Node(key, value);
            this.cache.set(key, newNode);
            this._addToHead(newNode);
        }
    }

    /**
     * 将节点从原位置移除,并添加到头部
     * @param {Node} node
     */
    _moveToHead(node) {
        this._removeNode(node);
        this._addToHead(node);
    }

    /**
     * 从链表中移除节点
     * @param {Node} node
     */
    _removeNode(node) {
        const prev = node.prev;
        const next = node.next;
        prev.next = next;
        next.prev = prev;
    }

    /**
     * 将节点插入到哨兵头节点之后(头部)
     * @param {Node} node
     */
    _addToHead(node) {
        node.prev = this.head;
        node.next = this.head.next;
        this.head.next.prev = node;
        this.head.next = node;
    }
}

/**
 * 双向链表节点
 */
class Node {
    constructor(key, value) {
        this.key = key;
        this.value = value;
        this.prev = null;
        this.next = null;
    }
}

使用示例

javascript

const lru = new LRUCache(2);
lru.put(1, 1);      // 缓存: {1=1}
lru.put(2, 2);      // 缓存: {1=1, 2=2}
console.log(lru.get(1)); // 返回 1,并移动 1 到头部 → 缓存顺序: 2,1
lru.put(3, 3);      // 容量已满,删除尾部 2 → 缓存: {1=1, 3=3}
console.log(lru.get(2)); // 返回 -1 (未找到)
lru.put(4, 4);      // 容量已满,删除尾部 1 → 缓存: {3=3, 4=4}
console.log(lru.get(1)); // 返回 -1
console.log(lru.get(3)); // 返回 3
console.log(lru.get(4)); // 返回 4

复杂度说明

  • get: 哈希表查找 O(1) + 链表移动 O(1) → 总体 O(1)
  • put: 哈希表插入/更新 O(1) + 可能删除尾部 O(1) + 链表操作 O(1) → 总体 O(1)

AJAX vs Fetch API:Promise 与异步 JavaScript 怎么用?

今天在学习promise的时候,看到一些比较早的教程,其中提到有一个重要的概念就是AJAX

尽管也许现代的做法更常见的是用Fetch API ,但是我也可以了解一下旧版实现里的做法,也能够帮助理解早期的异步 API,理解老项目的代码是如何做的。

关于异步JS(Promise)的前置知识,有关细节补充可阅读文档:异步 JavaScript 简介

我理解为promise的出现是异步编程中防止传统回调嵌套函数写法(回调地狱)。promise是现代 JavaScript 异步编程的基础。

常常见到的await async等其实是一种语法糖,使得写法简洁易读,并且有关try catch 错误异常的捕获和管理会比较方便(对比于原先采用catch统一管理错误的办法...)。这样的写法看起来是同步代码的长相,其实底层是异步编程。

早期异步Web API: XMLHttpRequest(AJAX)

AJAX全称为Asynchronous JavaScript and XML(异步JavaScript和XML),是一种在无需重新加载整个网页的情况下,能够更新部分网页的技术。

它通过在后台与服务器进行少量数据交换,使得网页可以实现异步更新。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。

示例:

const log = document.querySelector(".event-log");
document.querySelector("#xhr").addEventListener("click", () => {
  log.textContent = "";
  const xhr = new XMLHttpRequest();
  xhr.addEventListener("loadend", () => {
    log.textContent = `${log.textContent}完成!状态码:${xhr.status}`;
  });
  xhr.open(
    "GET",
    "https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
  );
  xhr.send();
  log.textContent = `${log.textContent}请求已发起\n`;
});
document.querySelector("#reload").addEventListener("click", () => {
  log.textContent = "";
  document.location.reload();
});
<button id="xhr">点击发起请求</button>
<button id="reload">重载</button>

<pre readonly class="event-log"></pre>

点击“点击发起请求”按钮来发送一个请求。我们将创建一个新的 XMLHttpRequest 并监听它的 loadend 事件。loadend 事件在请求完成时总会触发,无论成功还是失败。如果需要区分成功和失败,可以分别监听 load(成功)和 error(失败)事件。

而我们的事件处理程序则会在控制台中输出一个“完成!”的消息和请求的状态代码。

AJAX的工作原理基于一系列现有的互联网标准,主要包括以下几个方面:

  • XMLHttpRequest对象:这是AJAX的核心,它提供了在网页加载后从服务器请求数据的能力。
  • JavaScript/DOM:用于动态显示和交互的信息。
  • CSS:用于定义数据的样式。
  • XML:作为数据传输的格式,尽管现在JSON格式更为常用。

XMLHttpRequest

XMLHttpRequest API 使 web 应用能够通过 JavaScript 向 web 服务器发起 HTTP 请求并接收响应。这使得网站能够仅更新页面中的部分内容(使用服务器返回的数据),而无需跳转至全新页面。这种做法有时也被称为 AJAX

Fetch API 是取代 XMLHttpRequest API 的更灵活、更强大的方案。

Fetch API 使用 promise 替代事件机制处理异步响应,对 service worker 支持良好,并支持 HTTP 的高级特性,如跨源资源共享控制

基于这些优势,现代 web 应用通常采用 Fetch API 替代 XMLHttpRequest

XMLHttpRequest 用于在后台与服务器交换数据。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。XMLHttpRequest(XHR)对象用于与服务器交互。通过 XMLHttpRequest 可以在不刷新页面的情况下请求特定 URL,获取数据。这允许网页在不影响用户操作的情况下,更新页面的局部内容。

AJAX能允许网页在不影响用户操作的情况下,与服务器进行数据交换和更新。例如Google地图、新浪微博等,依托核心还是XMLHttpRequest。

实现AJAX

通常需要以下几个步骤:

  1. 创建XMLHttpRequest对象:这是所有AJAX请求的起点。

  2. 发送请求到服务器:使用*open()send()*方法,可以指定请求的类型(如GET或POST),URL以及是否异步。

  3. 处理服务器响应:通过监听onreadystatechange事件,可以在请求的不同阶段执行不同的操作。当readyState属性变为4,且status属性表示请求成功时,可以处理响应数据。

  4. 更新网页内容:使用JavaScript操作DOM,可以根据服务器的响应更新网页的特定部分。

跨域问题和解决方法

在使用AJAX时,可能会遇到跨域问题,即浏览器出于安全考虑,限制了来自不同源的HTTP请求。解决跨域问题的方法包括:

CORS(Cross-Origin Resource Sharing):通过服务器设置适当的HTTP响应头,可以允许特定的外部域访问资源。

JSONP(JSON with Padding):通过动态创建*

AJAX的优势和注意事项

AJAX的主要优势在于提高了用户体验,通过异步更新可以减少等待时间,使得Web应用程序更加快速和响应。然而,也需要注意一些问题,例如:

浏览器兼容性:不同浏览器对AJAX的支持程度可能不同,需要进行充分的测试。

用户体验:需要合理设计用户界面,以便在数据加载过程中给予用户适当的反馈。

网络延迟:应考虑到网络延迟对用户体验的影响,并采取相应的优化措施。

总的来说,AJAX技术使得Web开发进入了一个新的阶段,它允许开发者创建出更加动态和交互性强的网页应用。


使用Fetch API与Promise

如何使用 Promise

MDN的教程已经讲解的非常好了,我们一起来跟着学一学,现代使用Fetch API 的做法。

在基于 Promise 的 API 中,异步函数会启动操作并返回一个 Promise 对象。

首先,Promise 有三种状态:

  • 待定(pending):初始状态,既没有被兑现,也没有被拒绝。这是调用 fetch() 返回 Promise 时的状态,此时请求还在进行中。
  • 已兑现(fulfilled):意味着操作成功完成。当 Promise 完成时,它的 then() 处理函数被调用。
  • 已拒绝(rejected):意味着操作失败。当一个 Promise 失败时,它的 catch() 处理函数被调用。

注意,这里的“成功”或“失败”的含义取决于所使用的 API:例如,fetch() 认为服务器返回一个错误(如 404 Not Found)时请求成功,但如果网络错误阻止请求被发送,则认为请求失败。

有时我们用已敲定(settled)这个词来同时表示已兑现(fulfilled)和已拒绝(rejected)两种情况。

如果一个 Promise 已敲定,或者如果它被“锁定”以跟随另一个 Promise 的状态,那么它就是已解决(resolved)的。

(关于术语:Let's talk about how to talk about promises


然后,你可以将处理函数附加到 Promise 对象上,当操作完成时(成功或失败),这些处理函数将被执行。

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

console.log(fetchPromise);

fetchPromise.then((response) => {
  console.log(`已收到响应:${response.status}`);
});

console.log("已发送请求……");
  1. 调用 fetch() API,并将返回值赋给 fetchPromise 变量。
  2. 紧接着,输出 fetchPromise 变量,输出结果应该像这样:Promise { <state>: "pending" }。这告诉我们有一个 Promise 对象,它有一个 state属性,值是 "pending""pending" 状态意味着操作仍在进行中。
  3. 将一个处理函数传递给 Promise 的 then() 方法。当(如果)获取操作成功时,Promise 将调用我们的处理函数,传入一个包含服务器的响应的 Response 对象。
  4. 输出一条信息,说明我们已经发送了这个请求。
Promise { <state>: "pending" }
已发送请求……
已收到响应:200

与之前的 XMLHttpRequest 不同的是,事件处理程序并不是添加在 XMLHttpRequest 的对象中,我们这一次将处理程序传递到返回的promise对象的then方法里面。

Promise链

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise.then((response) => {
  const jsonPromise = response.json();
  jsonPromise.then((json) => {
    console.log(json[0].name);
  });
});

等等!还记得上一篇文章吗?我们好像说过,**在回调中调用另一个回调会出现多层嵌套的情况?我们是不是还说过,这种“回调地狱”使我们的代码难以理解?**这不是也一样吗,只不过变成了用 then() 调用而已?

当然如此。但 Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态

官方教程划重点:Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态

所以以上代码等价于:

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => response.json())
  .then((data) => {
    console.log(data[0].name);
  });

我们需要在尝试读取请求之前检查服务器是否接受并处理了该请求。我们将通过检查响应中的状态码来做到这一点,如果状态码不是“OK”,就抛出一个错误:

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    console.log(json[0].name);
  });

错误捕获

const fetchPromise = fetch(
  "bad-scheme://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    console.log(json[0].name);
  })
  .catch((error) => {
    console.error(`无法获取产品列表:${error}`);
  });

catch处理函数的输出错误。

  • 注意fetch() 只有在网络层面失败时才会进入 catch。服务器返回 404 或 500 状态码时,Promise 依然是 fulfilled 状态,需要通过 response.ok 手动判断。

合并使用多个promise

有时你需要所有的 Promise 都得到实现,但它们并不相互依赖。在这种情况下,将它们一起启动然后在它们全部被兑现后得到通知会更有效率。这里需要 Promise.all() 方法。它接收一个 Promise 数组,并返回一个单一的 Promise。

Promise.all()

Promise.all()返回的 Promise:

  • 当且仅当数组中所有的 Promise 都被兑现时,才会通知 then() 处理函数并提供一个包含所有响应的数组,数组中响应的顺序与被传入 all() 的 Promise 的顺序相同。
  • 会被拒绝——如果数组中有任何一个 Promise 被拒绝。此时,catch() 处理函数被调用,并提供被拒绝的 Promise 所抛出的错误。
const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((responses) => {
    for (const response of responses) {
      console.log(`${response.url}${response.status}`);
    }
  })
  .catch((error) => {
    console.error(`获取失败:${error}`);
  });

promise.all用于批量处理不是相互依赖的promise,这样提高了效率,但是弊端是只有全部成功才会成功,如果有一个失败(rejected)则所有all包含在内的promise都不能被兑现。此时错误会用catch抛出。

Promise.any()

有时,你可能需要一组 Promise 中的某一个 Promise 的兑现,而不关心是哪一个。在这种情况下,你需要 Promise.any()

这就像 Promise.all(),不过在 Promise 数组中的任何一个被兑现时它就会被兑现,如果所有的 Promise 都被拒绝,它也会被拒绝。

在这种情况下,我们无法预测哪个获取请求会先被兑现。

const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.any([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((response) => {
    console.log(`${response.url}${response.status}`);
  })
  .catch((error) => {
    console.error(`获取失败:${error}`);
  });

async 和 await

async function fetchProducts() {
  try {
    const response = await fetch(
      "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
    );
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(`无法获取产品列表:${error}`);
  }
}

const promise = fetchProducts();
promise.then((data) => console.log(data[0].name));

这里我们调用 await fetch(),我们的调用者得到的并不是 Promise,而是一个完整的 Response 对象,就好像 fetch() 是一个同步函数一样。

我们甚至可以使用 try...catch 块来处理错误,就像我们在写同步代码时一样。

但请注意,这个写法只在异步函数中起作用。异步函数总是返回一个 Promise。也就意味着async 函数总是返回一个 Promise。即使你返回一个普通值,它也会被自动包装成 Promise。

小结与更多Promise

Promise 是现代 JavaScript 异步编程的基础。它避免了深度嵌套回调,使表达和理解异步操作序列变得更加容易,并且它们还支持一种类似于同步编程中 try...catch 语句的错误处理方式。

asyncawait 关键字使得从一系列连续的异步函数调用中建立一个操作变得更加容易,避免了创建显式 Promise 链,并允许你像编写同步代码那样编写异步代码。

Promise 在所有现代浏览器的最新版本中都可以使用;唯一会出现支持问题的地方是 Opera Mini 和 IE11 及更早的版本。

在这篇文章中,我们没有涉及到所有的 Promise 功能,只是介绍了最有趣和最有用的那一部分。随着你开始学习更多关于 Promise 的知识,你会遇到更多有趣的特性。

许多现代 Web API 是基于 Promise 的,包括 WebRTCWeb Audio API媒体捕捉与媒体流等等。

开源一年,我的 AI 全栈项目 AI 协同编辑器终于有 1.1 k star了 😍😍😍

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

📖 简介

DocFlow 是一款面向团队协作的块级文档编辑器。它融合了 Notion 的灵活性与飞书的协作能力,通过块级内容架构、实时协同编辑和 AI 辅助功能,帮助团队高效完成文档创作与知识管理。

我们希望通过技术手段减少协作摩擦,让文档编辑更接近团队的真实工作流。无论是产品规划文档、技术方案设计,还是会议记录整理,DocFlow 都能提供流畅的创作体验。

✨ 核心特性

DocFlow 参考了 Notion 与飞书的设计理念,将内容以块为单位进行组织。每个块都是独立的编辑单元,可以灵活组合与调整,同时支持实时协作与 AI 辅助。

  • 🧱 块级编辑器:支持文本、标题、列表、代码块、表格、图片、视频等 20+ 种内容类型,通过拖拽即可调整块级元素的顺序与层级关系。

  • ⚡ 实时协作:基于 Yjs CRDT 算法实现多人同步编辑,自动处理编辑冲突。支持实时光标跟踪、成员在线状态与历史版本回溯。

  • 🤖 AI 功能:内置 AI 助手,支持头脑风暴、内容润色、文档续写与智能问答。可根据上下文生成结构化内容建议。

技术选型

DocFlow 采用全栈 TypeScript 架构,前端基于 Next.js 构建,后端使用 NestJS 框架。通过统一的类型系统和现代化的工程实践,保证了代码质量与开发效率。

🎨 前端架构 (Client-side)

Next.js

项目基于 Next.js App Router 架构,利用 React Server Components 优化首屏渲染性能。通过 Server Actions 实现前后端通信,确保类型安全的同时简化了数据流转。

Tiptap

编辑器核心采用 Tiptap 框架,基于 ProseMirror 构建。通过扩展机制实现了丰富的块级编辑能力,支持自定义节点与快捷命令,为用户提供接近 Notion 的编辑体验。

Yjs

协作功能基于 Yjs CRDT 算法实现,能够自动处理多人编辑时的冲突,保证数据最终一致性。配合 Awareness 模块,实现了实时光标追踪与在线状态同步。

⚙️ 后端架构 (Server-side)

NestJS & Prisma

后端使用 NestJS 模块化框架,通过依赖注入实现业务逻辑解耦。Prisma ORM 提供类型安全的数据访问层,支持高效的数据库查询与迁移管理。

Hocuspocus

Hocuspocus 作为 Yjs 的 WebSocket 服务端,负责协调文档协作会话,处理客户端连接与数据同步。通过拦截器机制实现权限控制与数据持久化。

Prometheus & Grafana

集成 Prometheus 进行指标采集,通过 Grafana 可视化展示系统运行状态。监控包括 API 响应时间、数据库查询性能、WebSocket 连接数等核心指标。

20260203091658

Grafana 监控面板实时展示系统各项性能指标,包括请求量、响应时间、错误率等关键数据,帮助快速定位性能瓶颈。

ELK Stack (Elasticsearch & Kibana)

使用 Elasticsearch 存储和检索日志数据,Kibana 提供日志分析与可视化能力。支持全文搜索、日志聚合与异常检测,便于问题排查与系统审计。

日志分析系统

Kibana 日志分析界面,支持按时间、日志级别、服务模块等维度查询和过滤日志,提供结构化的问题排查路径。

MinIO & RabbitMQ

MinIO 提供对象存储服务,用于存储用户上传的图片、视频等文件。RabbitMQ 作为消息队列,处理异步任务如图片压缩、邮件发送等,避免阻塞主业务流程。

功能介绍

DocFlow 将 AI 能力集成到编辑器中,通过理解文档上下文来辅助内容创作。AI 不是简单的文本生成工具,而是能够理解语义、提供决策建议的智能助手。

AI 头脑风暴

当你有一个初步想法但不知如何展开时,AI 头脑风暴可以帮助拓展思路。输入核心概念后,AI 会从不同角度生成 3-6 个结构化方案,每个方案都包含具体的实施思路。

AI 头脑风暴输入界面

在编辑器中输入头脑风暴主题,AI 会基于输入内容理解你的需求场景。

AI 头脑风暴结果展示

AI 生成的多个方案以卡片形式展示,每个方案都有清晰的标题和详细说明。你可以选择任意方案插入到文档中,或者继续优化调整。

这不只是简单的内容生成,AI 会根据上下文理解你的意图。无论是产品功能设计、内容分类规划,还是业务流程优化,AI 都能提供可行的思路参考,帮助快速决策。

AI 文本润色

AI 文本润色功能

选中需要优化的文本段落,AI 会分析文本结构与表达方式,提供更清晰、更专业的改写建议。支持调整语气风格,如正式、简洁、友好等。

AI 续写

AI 续写功能会根据前文内容自然延续写作。当前文内容较长时,系统通过 RAG (检索增强生成) 技术,从文档中检索相关段落,确保续写内容与上下文保持逻辑一致,避免偏离主题。

AI 续写功能演示

AI 续写时会参考前文的写作风格、用词习惯和逻辑结构,生成连贯自然的后续内容。你可以继续编辑生成的文本,或者重新生成。

AI 聊天

目前 AI 聊天功能作为独立页面存在,后续会集成到编辑器侧边栏,与文档内容深度关联。未来计划实现 Agent 模式,类似 Cursor 那样能够自动编辑文档内容。

7a8ba58a4ab3b592bb7fae1b45634648

协同编辑

多人协同编辑

多人同时编辑时,每个用户都有独立的光标颜色标识。文档修改实时同步,冲突自动合并。右侧显示当前在线成员列表与他们的编辑位置。

未来计划

DocFlow 将持续优化协作体验与 AI 能力,同时加强工程化建设,提升系统可扩展性。

🏗️ 工程化体系深度重构

  • 迈向 Monorepo 架构:计划基于 pnpm workspaces 和 Turborepo 将项目重构为 Monorepo。前后端代码分离,共享类型定义与工具函数,提升代码复用率与构建效率。

  • 组件库与插件生态开放:将 Tiptap 自定义扩展(如代码沙箱、交互式图表等)提取为独立 npm 包,开放给社区使用。同时建立插件开发规范,支持第三方开发者扩展编辑器能力。

🎙️ 多维协同体验升级

  • 集成 LiveKit 实时音视频:在文档协作场景中引入实时音视频通话。团队成员可以边看文档边讨论,提升复杂决策场景下的沟通效率。

LiveKit 集成方案

  • 实时群聊系统:在文档侧边栏集成实时聊天功能,支持针对文档内容发起讨论。消息可以关联到具体的文档块,形成完整的协作反馈闭环。

🤖 智能内核的跨越式进化

  • 基于 RAG 的私有知识库:引入 RAG (Retrieval-Augmented Generation) 技术,让 AI 能够检索用户的历史文档。AI 回答问题时会参考团队沉淀的知识资产,提供更精准的决策支持。

  • 从 Copilot 迈向 Agent:探索 AI Agent 在文档场景的应用。未来 AI 将能够自主执行任务,例如从会议纪要中提取待办事项,自动同步到第三方工具,实现从辅助创作到自动化办公的升级。

🚀 快速开始

环境要求

  • Node.js >= 24
  • pnpm >= 10.28.2

本地开发

  1. 克隆仓库
git clone https://github.com/xun082/DocFlow.git
cd DocFlow
  1. 安装依赖
pnpm install
  1. 启动开发服务器
pnpm dev
  1. 打开浏览器访问
http://localhost:3000

🐳 Docker 部署

方式一:使用 Docker Compose(推荐)

# 使用预构建镜像
docker-compose up -d

# 访问应用
http://localhost:3000

方式二:手动构建

  1. 构建镜像
docker build -t docflow:latest .
  1. 运行容器
docker run -d \
  --name docflow \
  -p 3000:3000 \
  -e NODE_ENV=production \
  docflow:latest
  1. 访问应用
http://localhost:3000

健康检查

容器内置健康检查端点:

curl http://localhost:3000/api/health

🤝 贡献指南

欢迎提交 Issue 和 Pull Request!

在提交代码前,请确保:

  • 运行 pnpm type-check 通过类型检查

  • 运行 pnpm lint 通过代码检查

  • 运行 pnpm format 格式化代码

  • 遵循项目的代码规范和提交规范

详见 CONTRIBUTING.md

📬 联系方式

infer,TS 类型系统的手术刀

在 TypeScript 的高级玩法里,infer 经常让初学者感到头大。它长得像关键字,用起来像正则表达式的“捕获组”,还必须寄生在 extends 条件语句里。

要把这东西彻底搞清楚,我们得先拆解它的核心逻辑,再看看它在实战中到底解决了什么问题。

一、 核心概念:infer 到底是什么?

简单来说,infer 就是 “类型系统里的临时变量”

在常规的泛型中,是你告诉 TypeScript 具体的类型;而在使用 infer 的场景下,是 TypeScript 自动推断出某个位置的类型,并把它存到一个变量里供你后续使用。

语法规则:

  1. 只能在 extends 条件类型的“真”分支中使用。
  2. 配合模式匹配使用。 你给出一个“模版”(比如函数结构、数组结构),让 TS 去匹配并提取其中的零件。

二、 语义纠偏:extends 的“变脸”

很多人的困惑源于 extends 这个词。在 Class 里它是“继承”,但在类型定义(尤其是配合 infer)时,它其实是 “模式匹配(Pattern Matching)”

  • Class 中的 extends:我是你的后代,我继承你的基因。
  • 类型中的 extends:我能不能塞进你这个形状的盒子里?

当你在写 T extends (infer R)[] ? R : never 时,你实际上是在对 TS 说:

“帮我看看 T 是不是一个数组。如果是,顺便把数组里装的那个东西的类型抠出来,起个临时名字叫 R。如果匹配成功,我就要这个 R。”


三、 实战场景:它能解决什么痛苦?

如果没有 infer,类型系统就是静态的、死板的。有了它,类型系统就具备了“解剖”和“重组”的能力。

1. 经典的“解包” (Unpacking)

这是最常见的用途。比如从 PromiseArrayMap 中提取内部类型。

// 提取 Promise 内部的类型
type Unbox<T> = T extends Promise<infer U> ? U : T;

type Str = Unbox<Promise<string>>; // 得到 string

2. 函数全家桶 (Function Extraction)

你可以轻松拿到一个函数的返回类型、参数类型,甚至是构造函数的参数。

// 提取函数第一个参数的类型
type FirstParam<T> = T extends (arg1: infer P, ...args: any[]) => any ? P : never;

function saveUser(id: number, name: string) {}
type IDType = FirstParam<typeof saveUser>; // number

3. 字符串模板的“手术刀”

这是 TS 4.1 之后的黑科技。你可以用它来拆分字符串,做一些像“驼峰转下划线”之类的类型转换。

type GetExtension<T> = T extends `${string}.${infer Ext}` ? Ext : never;

type FileExt = GetExtension<"config.json">; // "json"

四、 总结:什么时候该用它?

你不需要在每一处代码都写 infer,但在以下场景,它是无可替代的神器:

  • 处理第三方库:当你拿不到某个库内部定义的具体接口,但你能拿到它的函数或实例时,可以用 infer 反向推导出它的类型。
  • 减少重复定义:不想为了一个返回值再去手动写一遍复杂的 interface
  • 编写通用工具库:它是构建自动化、高适配性类型系统的基石。

虽然 infer 很好用,但它会显著增加类型的理解成本。对于团队协作项目,建议只在底层工具类型(Utils)中使用它,业务代码中还是尽量保持类型声明的直观和显式。

🪝 别再重复造轮子了!教你偷懒:在 React 自定义 Hook

前言

React 组件时,你是不是总感觉有些逻辑似曾相识?

  • 比如,每次都要写一遍判断组件是否挂载的逻辑
  • 又比如,监听元素 hover 状态的代码复制了一次又一次
  • 再比如,组件挂载和卸载时的操作也总是那几行

React官方给出了许多hook供我们使用,比如我们常见的useEffectuseState等等,但光靠这些是不够的,今天分享一些自定义的hook,方便又高效!

🎯 场景一:我只是想知道组件 “活” 没活

你有没有遇到过这种情况:组件里的setTimeout还没跑完,组件就已经被卸载了,控制台立刻给你甩一个警告,仿佛在说 “你操作了一个不存在的组件”

别慌,咱们用useMountedState这个自定义 Hook 就能完美解决。

// useMountedState.js
import { useRef, useEffect } from 'react'

export default function useMountedState() {
    const mounted = useRef(false);
    const get = () => mounted.current;
    useEffect(() => {
        mounted.current = true;
        return () => {
            mounted.current = false;
        }
    }, [])
    return get;
}

在组件里用起来就像给组件装了个 “生命检测仪”

// App.jsx
import React, { useState, useEffect } from 'react'
import useMountedState from './hooks/useMountedState'

export default function App() {
    const isMounted = useMountedState();
    const [num, setNum] = useState(0);
    useEffect(() => {
        setTimeout(() => {
            // 先检查组件是否还活着,再更新状态
            if (isMounted()) {
                setNum(1);
            }
        }, 1000);
    }, []);

    return (
        <div>
            {isMounted() ? '组件挂载完成 🎉' : '组件还在编译 🛠️'}
        </div>
    )
}

刚打开浏览器(显示还在编译):

image.png

过几秒(挂载完成):

image.png

有了它,你再也不用担心在异步操作里更新一个已经 “去世” 的组件了。

🎬 场景二:组件的 “登场” 与 “谢幕” 要仪式感

组件挂载卸载时,我们经常需要做一些初始化和清理工作。

  • 比如页面埋点、订阅事件、定时器清理等
  • 直接用useEffect写虽然也行,但每次都要写return总觉得有点麻烦

这时候useLifecycles就派上用场了,它把组件的 “生命周期” 打包成了一个简单的接口

// useLifecycles.js
import { useEffect } from 'react'

export default function useLifecycles(onMount, onUnmount) {
    useEffect(() => {
        if (onMount) {
            onMount();
        }
       return () => {
            if (onUnmount) {
                onUnmount();
            }
        }
    }, []);
}

用起来就像给组件安排了 “入场” 和 “退场” 的节目单

// App2.jsx
import React, { useState } from 'react'
import useLifecycles from './hooks/useLifecycles';

const Child = () => {
    useLifecycles(
        () => {
            console.log('child组件挂载🎬');
        },
        () => {
            console.log('child组件卸载👋');   
        }
    )
    return <h1>child组件</h1>
}

export default function App2() {
    const [show, setShow] = useState(true);
    return (
        <div>
            <h1 onClick={() => setShow(!show)}>App2</h1>
            {
                show && <Child></Child>
            }
        </div>
    )
}

刚打开浏览器一定会打印child组件挂载🎬

image.png

当点击App2时,child组件消失 (卸载),打印child组件卸载 👋

image.png

✋ 场景三:元素 hover 状态的 “小雷达”

实现元素hover效果是前端的家常便饭,传统写法需要给元素绑定onMouseEnteronMouseLeave事件。

  • 逻辑不复杂,但写多了也烦
  • 咱们可以用useHover把这个逻辑封装成一个 Hook
// useHover.jsx
import { useState, cloneElement } from 'react'

export default function useHover(element) {
    const [state, setState] = useState(false);
    const onMouseEnter = (originalOnMouseEnter) => {
        return (event) => {
            originalOnMouseEnter?.(event);
            setState(true);
        }
    };
    const onMouseLeave = (originalOnMouseLeave) => {
        return (event) => {
            originalOnMouseLeave?.(event);
            setState(false);
        }
    };
    if (typeof element === 'function') {
        element = element(state);
    }
    const el = cloneElement(element, {
        onMouseEnter: onMouseEnter(element.props.onMouseEnter),
        onMouseLeave: onMouseLeave(element.props.onMouseLeave),
    })
    return [el, state];
}

在组件里使用时,就像给元素装了个 “小雷达”

// App3.jsx
import useHover from './hooks/useHover.jsx';

export default function App3() {
    const element = (hovered) => {
        return <div>
            Hover me! {hovered && 'Thanks!'}
        </div>
    }
    const [hoverable, hovered] = useHover(element);
    return (
        <div>
            {hoverable}
            {hovered ? 'yes ✅' : 'no ❌'}
        </div>
    )
}

鼠标不在Hover me!上面的时候(显示no ❌):

image.png

当鼠标🖱️移动到Hover me!上面的时候(显示yes ✅):

image.png

鼠标悬停时,元素会显示 “Thanks!”,下方也会同步显示状态,交互体验直接拉满!

🚀 用别人写好的库

大家应该发现了,上面的组件都是我自己手搓的,其实已经有很多人写好了,我们只需下载然后就可以使用了。我给大家推荐一个:

地址: www.npmjs.com/package/rea…

下载:npm i react-use

里面有许多已经封装好了的hook组件,包括上面介绍的,只需引入即可:

import { useMountedState } from 'react-use';
import { useHover } from 'react-use';
import { useLifecycles } from 'react-use';

结语

自定义 Hook 就像 React 世界里的 “乐高积木”,把零散的逻辑拼成一个个可复用的模块。

  • 它不是什么高大上的魔法,就是把你本来要重复写的代码打包了一下
  • 不仅能让你的代码更干净,还能让你开发时少掉几根头发

下次再遇到重复逻辑时,别再 cv 了,动手写个自定义 Hook 吧!毕竟,优秀的程序员都是会 “偷懒” 的艺术家

🎯 DOM 事件:onclick VS addEventListener('click')区别

🎯 DOM 事件:onclick vs addEventListener('click') 区别

特性 .on 事件(如 onclick addEventListener('click')
绑定数量 只能绑 1 个(后面覆盖前面) 可以绑 多个(按顺序执行)
移除方式 el.onclick = null 需要 removeEventListener,且必须传同一个函数引用
事件阶段 只能在 冒泡阶段 触发 可以选择 捕获 / 冒泡 阶段(第三个参数)
标准级别 DOM 0 级(老写法) DOM 2 级(现代标准推荐)

区别详解

绑定数量

onclick:只能绑 1 个,后面覆盖前面

const btn = document.getElementById('btn');

btn.onclick = function() {
  console.log('第一次点击'); // 不会执行!被覆盖了
};

btn.onclick = function() {
  console.log('第二次点击'); // 只有这个会执行
};

 addEventListener:多个都执行

const btn = document.getElementById('btn');

function fn1() {
  console.log('第一次点击'); // 会执行
}

function fn2() {
  console.log('第二次点击'); // 也会执行!按顺序来
}

btn.addEventListener('click', fn1);
btn.addEventListener('click', fn2);

移除事件

onclick 移除:直接设为 null

btn.onclick = function() { alert('点击了'); };
// 移除
btn.onclick = null; 

addEventListener 移除:必须传同一个函数

⚠️ 注意:如果用匿名函数,是无法移除的!

//✅ 正确写法(用命名函数)
function myClick() {
  console.log('点击了');
}

btn.addEventListener('click', myClick);
// 移除(必须传同一个函数名)
btn.removeEventListener('click', myClick);

//❌ 错误写法(无法移除)
btn.addEventListener('click', function() {
  console.log('匿名函数,删不掉我');
});

// 没用!因为这是两个不同的函数引用
btn.removeEventListener('click', function() {
  console.log('匿名函数,删不掉我');
});

事件阶段

// 第三个参数:
// true → 在捕获阶段触发
// false(默认)→ 在冒泡阶段触发

el.addEventListener('click', fn, true); // 捕获阶段
el.addEventListener('click', fn, false); // 冒泡阶段
简单理解事件流:

假设 HTML 是 body > div > button

  1. 捕获阶段:从外到内(body → div → button
  2. 目标阶段:到达 button
  3. 冒泡阶段:从内到外(button → div → body

onclick 只能在冒泡阶段触发,而 addEventListener 可以自由选择。

所以我用哪个呢?

  1. 90% 的场景:用 addEventListener

    • 更灵活,能绑多个事件
    • 现代标准,功能强大
    • 团队协作推荐
    • 不知道用啥就用它
  2. 简单快速测试 / 临时写个小功能:可以用 onclick

    • 代码少,写得快
    • 移除简单(直接 null

手写 Zustand:从零实现 React 轻量级状态管理库

为什么选择 Zustand?

在 React 开发中,组件间通信一直是个令人头疼的问题。当组件层级复杂时,通过 props 层层传递状态不仅代码冗余,维护成本也直线上升。这时候就需要一个中央状态管理库来解决这个痛点。

相比老牌的 Redux,Zustand 的优势非常明显:

  • 极简 API:没有繁琐的 reducer、action、dispatch 概念
  • 零样板代码:不需要包裹 Provider,直接创建 store 即用
  • 性能优秀:基于订阅机制实现精准更新,避免无效渲染
  • 体积小巧:核心代码仅 1KB 左右

正因如此,Zustand 在 GitHub 上已经收获了 4 万+ Star,成为近年来最受欢迎的 React 状态管理方案之一。

核心原理拆解

要手写 Zustand,首先需要理解其三大核心机制:

1. 状态存储与管理

Zustand 采用闭包方式存储状态,通过 createStore 创建一个独立的状态容器:

javascript

const createStore = (createState) => {
  let state;  // 闭包变量,存储状态
  const getState = () => state;
  // ... 其他方法
}

这种设计让状态完全脱离 React 组件树,既可以在组件内使用,也可以在组件外直接操作。

2. 订阅发布模式

这是 Zustand 的灵魂所在。当状态改变时,如何通知所有使用该状态的组件更新?答案是观察者模式:

  • 发布者(Store) :维护一个订阅者列表 listeners
  • 订阅者(组件) :通过 subscribe 注册监听函数
  • 状态更新时:遍历执行所有订阅者的回调

javascript

const listeners = new Set();

const subscribe = (listener) => {
  listeners.add(listener);
  return () => listeners.delete(listener);  // 返回取消订阅函数
}

const setState = (partial, replace = false) => {
  // 更新状态后通知所有订阅者
  listeners.forEach(listener => listener(state, previousState));
}

3. 选择器(Selector)与精准更新

这是 Zustand 性能优化的关键。通过 selector 函数,组件可以只订阅自己关心的状态切片:

javascript

const count = useCounterStore((state) => state.count);

state.text 改变时,只订阅 count 的组件不会重新渲染。实现原理是在订阅回调中比较 selector 返回值:

javascript

api.subscribe((state, previousState) => {
  const newObj = selector(state);
  const oldObj = selector(previousState);
  if (newObj !== oldObj) {
    forceRender(Math.random());  // 仅当关心的状态变化才强制更新
  }
})

完整实现详解

第一步:创建 Store

createStore 函数负责初始化状态并返回操作 API:

javascript

const createStore = (createState) => {
  let state;
  const listeners = new Set();
  
  const getState = () => state;
  
  const setState = (partial, replace = false) => {
    const nextState = typeof partial === 'function' 
      ? partial(state) 
      : partial;
    
    if (!Object.is(nextState, state)) {
      const previousState = state;
      if (!replace) {
        // 默认浅合并,保留未修改的字段
        state = Object.assign({}, state, nextState);
      } else {
        // replace 模式直接替换整个 state
        state = nextState;
      }
      listeners.forEach(listener => listener(state, previousState));
    }
  }
  
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }
  
  const api = { setState, getState, subscribe };
  state = createState(setState, getState, api);
  return api;
}

关键细节:

  • Object.is() 判断状态是否真正改变,避免无效更新
  • setState 支持传入函数,方便基于旧状态计算新值
  • subscribe 返回取消订阅函数,符合 React useEffect 清理机制

第二步:实现 Hook 适配层

useStore 将订阅机制桥接到 React 组件:

javascript

const useStore = (api, selector) => {
  const [, forceRender] = useState(0);
  
  useEffect(() => {
    const unsubscribe = api.subscribe((state, previousState) => {
      const newObj = selector(state);
      const oldObj = selector(previousState);
      if (newObj !== oldObj) {
        forceRender(Math.random());  // 强制重渲染
      }
    });
    return unsubscribe;  // 组件卸载时自动取消订阅
  }, []);
  
  return selector(api.getState());
}

这里用了一个巧妙的技巧:通过修改 state 触发组件更新,而不是直接操作 DOM。

第三步:暴露便捷的 create API

javascript

export const create = (createState) => {
  const api = createStore(createState);
  
  const useBoundStore = (selector) => useStore(api, selector);
  
  // 将 API 方法挂载到 Hook 上,支持在组件外调用
  Object.assign(useBoundStore, api);
  
  return useBoundStore;
}

Object.assign 这一步很关键,它让我们可以:

  • 组件内:通过 useCounterStore(selector) 使用
  • 组件外:通过 useCounterStore.setState() 直接操作状态

实战验证

基于上面的实现,我们创建一个计数器和文本编辑器共存的案例:

javascript

const useCounterStore = create((set) => ({
  count: 0,
  text: '初始文本',
  increment: () => set((state) => ({ count: state.count + 1 })),
  updateText: (newText) => set({ text: newText }),
}));

CountDisplay 组件只订阅 count:

javascript

const CountDisplay = () => {
  console.log('CountDisplay 渲染了');
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  
  return (
    <div>
      <p>当前计数: {count}</p>
      <button onClick={increment}>增加</button>
    </div>
  );
};

TextDisplay 组件只订阅 text:

javascript

const TextDisplay = () => {
  console.log('TextDisplay 渲染了');
  const text = useCounterStore((state) => state.text);
  const updateText = useCounterStore((state) => state.updateText);
  
  return (
    <div>
      <p>当前文本: {text}</p>
      <input value={text} onChange={(e) => updateText(e.target.value)} />
    </div>
  );
};

验证结果:

  • 修改文本时,控制台只打印 TextDisplay 渲染了
  • 点击计数按钮时,控制台只打印 CountDisplay 渲染了

这证明了精准更新机制生效!没有使用的组件不会重新渲染,性能得到保障。

进阶:API 直接调用

得益于 Object.assign,我们可以在任何地方直接操作状态:

javascript

const handleBatchUpdate = () => {
  useCounterStore.setState((prev) => ({ 
    count: prev.count + 10, 
    text: '批量修改完成!' 
  }));
  
  // 同步读取最新状态(不触发渲染)
  console.log(useCounterStore.getState());
};

这在处理异步逻辑非 React 环境(如 WebSocket 回调)时非常有用。

源码阅读的价值

通过手写 Zustand,我们收获了什么?

1. 设计模式的实战应用

  • 观察者模式:订阅发布机制
  • 闭包:状态隔离与持久化
  • 高阶函数:create 返回定制化 Hook

2. React 性能优化技巧

  • 通过 selector 避免无效渲染
  • Object.is() 精准判断状态变化
  • useEffect 清理函数自动取消订阅

3. 框架设计思路

为什么 Zustand 这么简单?因为它:

  • 没有引入中间件、异步处理等复杂概念
  • 直接利用 JS 闭包和 React Hooks,没有额外抽象
  • API 设计符合直觉,学习成本极低

总结

Zustand 的核心只有 200 行代码,却解决了 React 状态管理的本质问题。通过手写实现,我们深刻理解了:

  • 状态管理 = 存储 + 订阅 + 通知
  • 性能优化 = 精准订阅 + 浅比较
  • 好的 API = 隐藏复杂度 + 暴露灵活性

当你下次在项目中使用 Zustand 时,不妨打开 DevTools 观察组件的渲染次数,你会发现这个 1KB 的小库,背后有着极其精妙的设计哲学。

企业级Claw落地避坑指南:70%项目失败的真实原因

openclaw 吹了这么久,实际落地就卡在两个点,一个是费用确实不低,另一个是安全边界不清晰。很多团队想用,又不敢大规模接入,这也是为什么会出现像 Finclaw 这种更偏企业级方案的原因。

传送门

企业级AI平台的落地需要系统性的技术实施,核心在于建立可控、可靠、可规模化的技术基础。最近也关注到了市面上出现了很多号称企业级Claw的产品,下图我做了一个对比:

下面我们就展开聊聊,怎样避坑,成功落地适合自己企业发展路径的企业级Claw:

评估阶段:需求分析与场景选择

企业部署AI的第一步是明确业务需求技术可行性。错误的起点将导致整个项目偏离方向。评估阶段的目标是回答:我们为什么需要AI?我们能用AI做什么?我们是否有能力做好?

超过70%的AI项目失败源于需求不明确或技术能力评估不足。切勿跳过或简化评估阶段。

业务需求调研

需求调研需要回答几个关键问题:当前业务的核心痛点是什么?哪些痛点可以通过AI解决?AI解决这些问题能带来什么可量化的价值?实施的难度和成本是否在可接受范围内?

调研方法论

  • 深度访谈:与业务部门负责人进行1对1访谈,聚焦具体业务流程和痛点
  • 流程观察:实地观察现有工作流程,记录非结构化操作和重复性劳动
  • 数据分析:分析现有业务数据,识别效率瓶颈和优化机会点
  • 行业对标:参考同行业头部企业的AI应用实践,评估技术成熟度

适用场景识别

高优先级场景特征

  • 流程标准化程度高
  • 数据质量良好且可获取
  • 任务重复性强、耗时多
  • 错误率有明确改进空间
  • ROI(投资回报率)可量化

不适用场景警示

低优先级或高风险场景

  • 业务流程高度非结构化
  • 涉及重大安全或合规风险
  • 数据稀疏或质量极差
  • 决策逻辑复杂且依赖专家经验
  • 实施成本远超预期收益

技术能力评估

在确定业务需求后,必须客观评估企业的技术基础。技术能力评估包括四个维度:基础设施技术团队数据基础集成能力

基础设施评估指标

  • 计算资源:现有服务器的CPU/GPU配置、内存容量、存储IOPS
  • 网络环境:内网带宽、外网出口、延迟要求、安全策略
  • 软件栈:操作系统版本、容器平台、中间件、数据库兼容性
  • 云服务:如采用云部署,评估云厂商的服务等级协议(SLA)和区域覆盖

企业部署AI需要评估技术团队的核心能力,包括云原生架构理解、容器化部署经验、安全治理框架设计能力等。

风险控制考量

AI部署涉及安全风险合规风险业务风险技术风险四类主要风险。必须在评估阶段就进行识别和制定控制策略。

风险识别与评估矩阵

  1. 安全风险:数据泄露、权限滥用、模型投毒、逆向工程

    1. 控制策略:实施四层隔离架构、最小权限原则、输入输出过滤
  2. 合规风险:违反数据保护法规(如GDPR、个人信息保护法)、行业监管要求

    1. 控制策略:数据分类分级、隐私计算、审计追溯、合规性自检
  3. 业务风险:AI决策错误导致业务损失、业务流程中断、用户信任受损

    1. 控制策略:人工复核机制、A/B测试、渐进式上线、回滚预案
  4. 技术风险:系统性能不足、集成复杂度高、技术债务累积

    1. 控制策略:技术选型评估、架构评审、容量规划、技术债管理

规划阶段:架构设计与技术选型

规划阶段将评估阶段的输出转化为可执行的蓝图。核心任务包括确定部署模式、设计技术架构、规划资源需求

部署模式选择标准

企业需要根据业务场景安全合规要求成本预算技术能力综合选择部署模式。

公有云部署

适用场景

  • 互联网业务、初创企业
  • 对成本敏感、需要快速上线
  • 业务流量波动大,需要弹性伸缩

优势

  • 部署速度快,按需付费
  • 免运维基础设施
  • 全球节点,低延迟访问

挑战

  • 数据出境合规风险
  • 厂商锁定风险
  • 定制化程度有限

私有云部署

适用场景

  • 金融、政务、医疗等强监管行业
  • 数据敏感性高,要求完全控制
  • 已有成熟的私有云基础设施

优势

  • 数据完全自主可控
  • 深度定制化能力
  • 符合严格合规要求

挑战

  • 初期投资成本高
  • 运维复杂度高
  • 扩展性受硬件限制

混合云部署

适用场景

  • 既有敏感数据又有弹性需求
  • 全球化业务,需要多地部署
  • 逐步从私有云向公有云迁移

优势

  • 灵活的数据和计算分布
  • 成本与安全的平衡
  • 灾备和业务连续性保障

挑战

  • 架构复杂度最高
  • 网络和安全管理困难
  • 需要专业的跨云管理工具

技术架构设计原则

企业级Claw的技术架构设计遵循安全优先弹性可扩展易于运维三大原则。

多租户架构设计要点

  • 租户隔离:确保不同租户(部门/团队)的数据、资源、配置完全隔离
  • 资源共享:在隔离基础上实现计算、存储、网络资源的池化和动态调度
  • 权限模型:基于RBAC(角色权限控制)的细粒度权限管理,支持组织架构同步
  • 计量计费:按租户、按资源、按时长等多维度使用计量,支持成本分摊

安全架构核心组件

  1. 四层隔离:MicroVM(硬件虚拟化)→ 容器(进程隔离)→ 系统沙箱(系统调用过滤)→ 运行时(语言级沙箱)
  2. 网络策略:默认拒绝所有出口流量,基于白名单的精细化网络控制
  3. 密钥管理:密钥不落日志,审计前自动脱敏,支持硬件安全模块(HSM)集成
  4. 审计追溯:全链路操作日志,不可篡改记录,支持第三方审计系统对接

可扩展性设计策略

  • 水平扩展:无状态服务支持通过增加Pod/节点实现近乎线性的性能扩展
  • 垂直扩展:有状态服务支持硬件升级(CPU/内存/GPU)提升单点性能
  • 弹性伸缩:基于CPU使用率、并发请求数、自定义指标自动扩缩容
  • 服务治理:服务发现、负载均衡、熔断降级、限流控制等微服务治理能力

系统集成方案

AI平台需要与现有企业IT系统无缝集成,才能发挥最大价值。

身份认证集成方案

  1. 单点登录(SSO)集成:支持SAML 2.0、OAuth 2.0、OIDC协议,与企业AD/LDAP/统一身份平台对接
  2. 组织架构同步:定期从HR系统同步组织架构和人员信息,支持增量同步和冲突解决
  3. 权限映射:将企业RBAC权限模型映射到AI平台的权限体系,确保权限一致性
  4. 审计日志对接:将AI平台操作日志推送到企业统一日志平台,支持集中审计和分析

数据源集成模式

  • 直接连接:通过JDBC/ODBC直接连接关系型数据库(MySQL/PostgreSQL/Oracle)
  • API集成:通过RESTful API/gRPC集成业务系统,支持认证、限流、熔断
  • 消息队列:通过Kafka/RabbitMQ集成异步系统,支持发布订阅模式
  • 文件系统:通过NFS/SMB/对象存储接口访问企业文件存储

业务流程集成策略

  1. 工作流触发:在OA/BPM系统中添加AI任务节点,触发AI处理流程
  2. 审批流嵌入:在审批流程中嵌入AI辅助决策,提供数据支持和建议
  3. 通知渠道集成:支持邮件、企业微信、钉钉、飞书等多渠道结果通知
  4. 报表数据对接:将AI处理结果推送到BI系统,生成可视化报表和Dashboard

安全配置要求

安全配置是确保AI平台安全运行合规使用的基础。

网络层安全配置

  • 防火墙规则:遵循最小权限原则,只开放必要的端口和服务
  • 网络分段:生产环境、测试环境、管理网络物理或逻辑隔离
  • 访问控制列表:基于源IP、目的IP、端口、协议的精细化访问控制
  • VPN/零信任:远程访问必须通过VPN或零信任网络,支持多因素认证

应用层安全配置

  1. HTTPS强制:所有Web访问强制使用HTTPS,配置HSTS头,使用TLS 1.3
  2. API安全:API密钥管理、请求签名、频率限制、输入验证、输出过滤
  3. 会话管理:安全的会话Cookie设置(HttpOnly、Secure、SameSite)
  4. CSRF/XSS防护:启用CSRF令牌,配置内容安全策略(CSP)

数据安全配置

  • 加密存储:敏感数据在存储时加密,使用企业密钥管理系统(KMS)
  • 传输加密:所有数据传输使用TLS加密,禁用弱密码套件
  • 数据脱敏:在开发、测试环境使用脱敏数据,防止敏感信息泄露
  • 备份加密:备份数据加密存储,备份介质安全保管

运营阶段:治理与优化

系统上线后进入持续运营阶段。核心目标是通过日常运维安全审计性能优化,确保系统稳定、安全、高效运行。

日常运维流程

日常运维确保系统7×24小时稳定运行,及时发现和处理异常。

监控管理体系

基础设施监控

  • 服务器监控:CPU使用率、内存使用率、磁盘IO、网络流量
  • 容器监控:Pod状态、资源限制、重启次数、就绪检查
  • 服务监控:服务端点健康状态、响应时间、错误率
  • 存储监控:存储容量、IOPS、延迟、可用性

业务层面监控

  • 用户行为监控:活跃用户数、会话时长、功能使用频率
  • AI服务监控:模型推理延迟、准确率、调用次数、成本
  • 业务流程监控:流程完成率、平均处理时间、异常中断率
  • 业务价值监控:ROI指标、效率提升比例、错误减少率

安全审计机制

安全审计确保系统持续合规,及时发现安全威胁

合规性审计框架

  1. 定期合规扫描:每月执行一次全面的合规性检查,包括配置核查、漏洞扫描、权限审计
  2. 合规报告生成:自动生成合规性报告,标注不合规项、风险等级、整改建议
  3. 合规整改跟踪:建立合规问题跟踪表,明确整改责任人、整改期限、验证方法
  4. 合规证据留存:所有合规性证据(扫描报告、整改记录、审计日志)安全存储,保留至少6个月

安全操作审计要点

  • 用户访问审计:记录所有用户的登录时间、IP地址、操作行为、登出时间
  • 特权操作审计:管理员操作、权限变更、配置修改等特权操作必须详细记录
  • 数据访问审计:敏感数据的查询、修改、导出操作需要记录操作者、时间、内容
  • 安全事件审计:所有安全相关事件(登录失败、权限拒绝、异常访问)需要记录和分析

风险持续评估方法

  • 威胁建模:每季度更新一次系统威胁模型,识别新的威胁和脆弱性
  • 渗透测试:每半年委托第三方进行渗透测试,发现深层次安全漏洞
  • 红蓝对抗:每年组织一次内部红蓝对抗演练,检验安全防御体系有效性
  • 风险指标监控:建立安全风险指标体系,持续监控风险变化趋势

性能优化策略

性能优化是一个持续迭代的过程,目标是提升系统效率用户体验

资源使用优化

  1. 资源利用率分析:识别资源使用瓶颈(CPU密集型、IO密集型、内存密集型)
  2. 资源调度优化:调整Kubernetes调度策略,优化Pod放置,减少资源碎片
  3. 弹性伸缩优化:基于实际业务负载调整HPA参数,避免过度伸缩或伸缩不足
  4. 成本优化:分析资源使用模式,采用预留实例、竞价实例等降低成本

AI模型性能优化

  • 模型压缩:使用量化、剪枝、知识蒸馏等技术减少模型大小,提升推理速度
  • 缓存优化:实现多级缓存(内存缓存、分布式缓存),减少重复计算
  • 批处理优化:对小请求进行批处理,提高GPU利用率,降低单次推理成本
  • 模型版本管理:建立模型版本管理流程,支持A/B测试、灰度发布、快速回滚

系统级性能调优

优化方向 具体措施 预期效果
数据库优化 索引优化、查询重写、读写分离、分库分表 查询性能提升30-50%
网络优化 TCP参数调优、连接池优化、CDN加速、协议优化 网络延迟降低20-40%
存储优化 SSD缓存、数据压缩、冷热数据分离、RAID优化 IOPS提升50-100%
应用优化 代码性能剖析、异步处理、内存管理优化、垃圾回收调优 应用响应时间缩短30-60%

扩展阶段:规模化与创新

当核心系统稳定运行后,进入扩展阶段。目标是将AI能力从试点项目扩展到全企业范围,从工具应用升级到平台创新

能力扩展路径

能力扩展遵循从核心到外围从简单到复杂的路径。

功能扩展策略

  1. 核心功能强化:基于用户反馈优化现有功能,提升准确率、响应速度、易用性
  2. 新功能开发:根据业务需求开发新的AI能力,如图像识别、语音处理、文档理解
  3. 第三方集成:集成优秀的第三方AI服务,补充平台能力,快速满足业务需求
  4. 开放平台建设:提供API和SDK,允许业务部门自主开发AI应用,构建生态系统

容量扩展规划

  • 容量预测:基于历史增长数据和业务规划,预测未来6-12个月的容量需求
  • 扩展方案:制定详细的扩展方案,包括硬件采购、软件许可、人员配置时间表
  • 扩展测试:扩展前进行容量测试,验证扩展方案的可行性和性能表现
  • 扩展执行:在业务低峰期执行扩展操作,确保业务连续性

服务扩展方向

横向扩展

  • 服务地域扩展:从单一数据中心扩展到多地域、多可用区部署
  • 服务用户扩展:从试点部门扩展到全公司,从内部员工扩展到合作伙伴
  • 服务时间扩展:从工作时间支持扩展到7×24小时服务
  • 服务渠道扩展:从Web端扩展到移动端、API、消息机器人等多渠道

纵向扩展

  • 服务深度扩展:从简单问答扩展到复杂决策支持、自动化流程
  • 服务智能扩展:从规则驱动升级到机器学习驱动,提升智能化水平
  • 服务集成扩展:从独立系统升级到与业务系统深度集成,嵌入业务流程
  • 服务价值扩展:从效率工具升级到业务创新平台,创造新业务价值

场景扩展方法

将AI能力应用到更多业务场景,最大化AI投资回报。

横向场景扩展模式

  • 部门间复制:将在一个部门验证成功的场景模式复制到其他类似部门
  • 流程链延伸:将AI能力从单个流程环节扩展到整个端到端业务流程
  • 数据类型扩展:从处理结构化数据扩展到处理非结构化数据(文本、图像、语音)
  • 业务领域扩展:从单一业务领域(如客服)扩展到多业务领域(如营销、风控、运营)

纵向场景深化策略

  1. 从辅助到自主:从人工复核的辅助决策升级到完全自主的自动化决策
  2. 从单点到体系:从解决单点问题升级到构建完整的AI解决方案体系
  3. 从执行到优化:从执行既定任务升级到持续优化业务流程和策略
  4. 从工具到伙伴:从被动响应的工具升级到主动建议的智能伙伴

技术实施要点总结

企业级Claw的成功落地需要关注以下核心技术要点

技术要素:架构与集成的基石

合理的架构设计是技术成功的首要条件。企业级Claw必须采用多租户架构,支持海量用户并发访问;必须实现四层安全隔离,确保数据安全和隐私保护;必须提供完整的可观测性,支持故障排查和性能优化。

与现有系统的无缝集成决定了AI平台能否融入企业IT生态。身份认证必须与企业统一身份平台对接,确保单点登录和权限一致;数据源必须支持企业各类数据库和API,确保数据可访问;业务流程必须与现有工作流系统集成,确保AI能力嵌入实际工作。

安全可靠的技术实现是企业信任的基础。系统必须通过严格的安全测试,包括渗透测试、代码审计、漏洞扫描;必须提供完善的容灾备份方案,确保业务连续性;必须实现细粒度的权限控制和审计追溯,满足合规要求。

最终结论:企业级AI平台的落地需要从技术架构安全治理部署模式三个维度进行系统性设计。只有建立可控、可靠、可规模化的技术基础,才能将AI从概念验证转化为可持续的生产力。

最后我准备了一份企业级Claw的技术白皮书, 点击领取

React-彻底搞懂 Redux:从单向数据流到 useReducer 的终极抉择

前言

在 React 生态中,状态管理一直是开发者绕不开的话题。Redux 以其严谨的“单向数据流”闻名,虽然有一定的学习成本,但它为大型项目带来的可预测性和可调试性是无可替代的。本文将带你深度复盘 Redux 的核心逻辑。

一、 Redux 的核心

Redux 的核心思想是将应用的所有状态(State)集中存储在一个唯一的 Store 中,并遵循严格的规则进行更新,让状态变化可追踪、可调试,大幅降低复杂应用的状态维护成本。

1. 三大核心概念

Redux 的运行逻辑完全围绕三大核心模块展开,各司其职、互不干扰,构建了清晰的单向数据流:

1. Store(数据仓库)

  • 定位:应用状态的唯一存储容器,整个应用有且仅有一个 Store
  • 作用:承载全局状态、派发 Action、监听状态变化、整合 Reducer,是连接视图和数据的核心枢纽
  • 特性:独立于组件生命周期,不会随组件销毁而消失,状态持久稳定

2. Action(动作描述)

  • 定位改变 State 的唯一途径,是一个普通的 JavaScript 对象
  • 结构:必须包含 type 属性(字符串类型,描述动作类型),可选携带 payload 属性(传递更新状态所需的数据)
  • 示例{ type: 'UPDATE_USER_NAME', payload: '李四' }
  • 本质:只描述“要做什么”,不负责“怎么做”,属于指令载体

3. Reducer(状态处理器)

  • 定位纯函数(固定输入必然得到固定输出,无副作用、不修改入参)
  • 参数:接收两个参数——当前旧 State(prevState)、派发的 Action 对象
  • 逻辑:根据 Action 的 type 类型,匹配对应的更新逻辑,绝不直接修改旧 State
  • 规则:Redux 强制要求 State 不可变(Immutable),必须返回全新的 State 对象,保证状态变化可回溯、可调试

二、 Redux 的工作流程:闭环的单向流

Redux 的数据流转遵循严格的循环路径,确保了状态变化的可追踪性:

  1. 用户触发操作:用户在页面执行交互行为(点击按钮、输入内容、路由跳转等),组件内触发状态更新需求

  2. 派发 Action:通过 Redux 提供的 dispatch 方法,将封装好的 Action 对象派发出去

  3. Reducer 处理:Store 自动将当前旧 State 和派发的 Action 传递给 Reducer,Reducer 根据 type 执行对应逻辑,返回新 State

  4. Store 更新状态:Store 接收 Reducer 返回的新 State,替换内部旧状态

  5. 组件同步数据:所有订阅了 Store 状态的组件,会自动感知状态变化,重新渲染视图,完成数据同步

注意:禁止直接修改 Store 中的 State,必须通过 dispatch 派发 Action → Reducer 生成新 State 的方式更新,这是 Redux 可预测性的核心保障。


三、 Redux vs useReducer:我该选哪个?

很多开发者会混淆这两者,虽然它们都使用了 action/reducer 模式,但在应用范围上有本质区别。

维度 useReducer Redux
存储位置 组件内部(Local State) 独立的全局 Store(Global State)
作用域 仅限当前组件及其子组件 整个应用,任意组件均可访问
生命周期 随组件销毁而消失 独立于组件,持久存在
跨组件通信 需配合 useContext 并提升组件层级 天然支持,无需透传 Props

场景选择:

  • 使用 useReducer:逻辑复杂(有很多 if/else 或 switch),但只在单个组件或其嵌套子组件中使用。
  • 使用 Redux:数据需要全局共享。例如:用户信息需要同步更新导航栏、侧边栏和个人中心;或者需要将状态持久化到 localStorage 并在刷新后恢复。

四、 总结

Redux 的本质是牺牲了一定的代码简便性,换取了极致的状态可预测性。在处理跨页面同步、复杂业务逻辑以及需要状态回溯的场景下,Redux 依然是前端状态管理的王者。

❌