阅读视图

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

🔍 React 面试官眼中的“秘密武器”:深度剖析 useRef

🔍 React 面试官眼中的“秘密武器”:深度剖析 useRef

在 React 的面试战场上,useRef 绝对是那个看似不起眼,实则能决定你能否进入下一轮的“关键先生”。很多候选人能熟练使用 useStateuseEffect,但当被问及“如何在不触发重渲染的情况下保存数据”或“为什么我的定时器关不掉”时,往往哑口无言。

今天,我们就通过两段典型的实战代码,揭开 useRef 的神秘面纱,让你在面试中不仅能写出代码,更能讲出原理。

📌 核心考点:useRef 是什么?

在深入代码前,你需要向面试官传达两个核心概念:

  1. 它是一个“盒子”useRef 返回一个普通的 JavaScript 对象,这个对象在组件的整个生命周期内保持引用稳定(始终是同一个对象)。
  2. 它是“非响应式”的:修改 .current 属性不会触发组件的重新渲染。这是它与 useState 最根本的区别。

💡 场景一:DOM 的直接掌控者

面试官提问: “如何在组件挂载后,让输入框自动获得焦点?”

这通常是考察 useRef 最经典的入门题。我们来看第一段代码:

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

export default function App() {
  const [count, setCount] = useState(0);
  console.log('变了'); // 每次count变化,组件重渲染,这句都会执行
  
  const inputRef = useRef(null); // 创建一个ref容器
  
  useEffect(() => {
    // 在这里操作DOM
    inputRef.current.focus(); // 让input获得焦点
    console.log('222', inputRef.current); // 打印真实的DOM节点
  }, [])
  
  // 注意看这里:在渲染函数体中,inputRef.current 是 null
  console.log('111', inputRef.current); 
  
  return (
    <>
      <input ref={inputRef}/> {/* 将ref绑定到DOM上 */}
      <button type="button" onClick={() => setCount(count+1)}>count ++</button>
    </>
  )
}

面试解析要点:

  1. 同步与异步:请留意代码中的 console.log
    • 111:在函数体直接打印,此时 React 还在“画”界面,DOM 节点尚未生成,所以值为 null
    • 222:在 useEffect 中打印,此时 React 已经把界面“挂载”到页面上,inputRef.current 已经被赋值为真实的 DOM 元素。
  2. 执行时机useEffect 在浏览器完成渲染后执行,这保证了我们操作的 DOM 是真实存在的。
  3. 价值体现:这是 useRef 最直观的用途——打破 React 的虚拟 DOM 抽象层,直接操作真实 DOM

⏳ 场景二:跨越渲染的“持久化”存储

面试官提问: “React 函数组件每次渲染都会重新执行函数,如果我在一个定时器里需要保存状态,或者想在点击按钮时清除上一次的定时器,该怎么办?”

这时候,如果只用普通变量,每次重渲染变量都会被重置;如果用 useState,清除定时器的逻辑会变得复杂且容易出错。第二段代码展示了 useRef 的高级用法:

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

export default function App() {
    let intervalId = useRef(null); // 用ref来保存定时器ID
    const [count, setCount] = useState(0);
    
    function start() {
        // 即使组件多次渲染,intervalId 这个“盒子”始终是同一个
        intervalId.current = setInterval(() => {
            console.log('tick~~~');
        }, 1000);
        console.log(intervalId); // 打印 { current: 123 }
    }
    
    function stop() {
        // 从“盒子”里取出ID并清除
        clearInterval(intervalId.current);
    }

    // 仅用于演示:当count变化时,查看ref的值
    useEffect(() => {
        console.log(intervalId.current); // 只要定时器开着,这里会一直打印ID
    }, [count])
    
    return (
        <>
           <button onClick={start}>开始</button>
           <button onClick={stop}>停止</button>
           <button type="button" onClick={() => setCount(count+1)}>count ++</button>
        </>
    )
}

面试解析要点:

  1. 闭包陷阱的解药:在 start 函数中,我们将 setInterval 返回的 ID 存入了 intervalId.current。无论组件因为 count 变化重渲染多少次,intervalId 对象本身不会变,变的只是它里面的 current 值。
  2. 清理资源stop 函数能够准确获取到最新的定时器 ID 并清除它。如果不用 useRef,而用普通变量,stop 函数将无法访问到 start 函数内部的变量(作用域隔离)。
  3. 非响应式优势:我们将定时器 ID 存入 ref,并不希望它触发页面刷新。useRef 完美地充当了一个“默默奉献的存储柜”角色。

📝 总结:面试官想听到什么?

当被问及 useRef 时,请务必构建以下回答框架:

  1. 定义:它是用来创建一个在组件生命周期内持久化且引用稳定的对象。
  2. 两大用途
    • 访问 DOM:通过 ref 属性附加到元素上。
    • 存储可变值:存储定时器 ID、上一次的 props、第三方库实例等不需要触发视图更新的数据。
  3. useState 的区别ref 的变化是同步的且不触发重渲染;state 的变化是异步的且一定会触发重渲染。
  4. 避坑指南:不要试图用 useRef 替代状态管理,因为它的变化 React “看不见”,UI 不会自动更新。

掌握这些,你就能在面试中自信地告诉面试官:useRef** 不仅仅是一个获取 DOM 的工具,它是连接函数组件渲染周期与可变状态的桥梁。**

MutationObserver:DOM界的“卧底”,暗中观察每个风吹草动

你想知道页面上的某个元素什么时候被偷偷改了吗?比如有个熊孩子脚本悄悄改了你的广告位,或者某个懒加载图片终于加载完了?今天我们就来请一位“卧底”——MutationObserver,让它24小时盯着DOM树,任何变化都逃不过它的眼睛。

前言

假设你开了一家便利店,店里装了监控。你想知道:什么时候有人进来?什么时候货架上的商品被拿走了?什么时候价格标签被换了?普通的监控只能录像,但你需要的是“智能警报”——一有变化就通知你。

这就是MutationObserver的活。它是浏览器提供的一个API,专门用来监听DOM树的变化:节点增删、属性修改、文本内容改变……统统能抓到。而且它不会像setInterval那样一直轮询,性能好得多。

一、MutationObserver是啥?

MutationObserver是一个构造函数,用来创建一个观察者对象。你可以给它指定一个回调函数,然后让它去“盯”某个DOM节点。一旦这个节点或它的子孙节点发生变化,回调函数就会被触发。

// 创建一个观察者实例,传入回调
const observer = new MutationObserver((mutationsList, observer) => {
  for (let mutation of mutationsList) {
    console.log(mutation.type, '发生了变化');
  }
});

// 指定要观察的节点
const targetNode = document.getElementById('watch-me');

// 开始观察
observer.observe(targetNode, {
  attributes: true,    // 观察属性变化
  childList: true,     // 观察子节点增删
  subtree: true,       // 观察所有后代节点
  characterData: true  // 观察文本内容变化
});

// 某天不想观察了
// observer.disconnect();

二、能观察到哪些变化?

配置选项决定了你关心哪些“风吹草动”:

  • attributes:属性变了(比如classstylesrc被改)
  • childList:子节点被增删(添加或删除元素、文本节点)
  • characterData:文本节点的内容变了
  • subtree:是否监听后代节点(默认false,只监听目标节点)
  • attributeFilter:只监听特定属性,比如['class', 'src']
  • attributeOldValue:是否记录旧属性值
  • characterDataOldValue:是否记录旧文本值

三、实战:监听广告位有没有被篡改

很多网站会在页面上放广告,但有些恶意脚本会偷偷把广告位换成自己的内容。用MutationObserver可以第一时间发现并报警。

<div id="ad-container">
  <img src="real-ad.jpg" alt="官方广告">
</div>
const adContainer = document.getElementById('ad-container');

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList') {
      // 子节点被改了
      console.warn('⚠️ 广告位内容被篡改!');
      // 可以上报服务器,或者恢复内容
    } else if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
      console.warn('⚠️ 广告图片被替换了!');
    }
  });
});

observer.observe(adContainer, {
  childList: true,
  subtree: true,
  attributes: true,
  attributeFilter: ['src', 'href']
});

四、实战:监听输入框内容变化(代替input事件?)

input事件已经能监听输入框变化,但MutationObserver可以监听更底层的文本节点变化,比如通过JS直接修改.valueinput事件可能不触发,但MutationObserver可以。

<input id="username" type="text">
const input = document.getElementById('username');

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
      console.log('输入框的值被改了,新值:', input.value);
    }
  });
});

observer.observe(input, {
  attributes: true,
  attributeFilter: ['value']
});

注意:这种方式监听value属性变化,只对通过JS设置.value有效,用户手动输入不会触发(因为用户输入不改变value属性,而是改变元素的defaultValue和内部状态)。所以实际中监听输入框还是input事件更合适。这里只是演示能力。

五、实战:监听动态加载的图片,做懒加载

很多懒加载库用IntersectionObserver,但如果你想知道图片什么时候被添加到DOM,可以用MutationObserver。

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    mutation.addedNodes.forEach((node) => {
      if (node.nodeType === 1 && node.tagName === 'IMG') {
        console.log('新图片出现了:', node.src);
        // 可以在这里做懒加载初始化
      }
    });
  });
});

observer.observe(document.body, {
  childList: true,
  subtree: true
});

六、性能注意事项

MutationObserver虽然比轮询好,但也不能滥用。以下几点要注意:

  1. 不要观察整个document:如果你observe(document.body, { subtree: true, childList: true, attributes: true }),那页面上的任何变化都会触发回调,频繁执行可能影响性能。尽量把观察范围缩小到具体容器。

  2. 回调里不要做太重的操作:MutationObserver的回调是在微任务中执行的,如果里面操作DOM或者计算太多,会阻塞后续渲染。

  3. 及时disconnect:如果不再需要观察,记得调用disconnect()释放资源。

  4. 使用takeRecords():在disconnect之前,可以调用observer.takeRecords()取出尚未处理的变化记录。

七、与旧API对比:Mutation Events的悲惨往事

很久以前,浏览器有一套Mutation Events(比如DOMNodeInsertedDOMAttrModified等)。它们的问题很多:

  • 性能差,每次变化都同步触发,容易导致重入和崩溃
  • 不支持批量观察
  • 被标记为废弃

MutationObserver是它们的完美替代,异步、批量、性能好。

八、总结:MutationObserver就是你的“鹰眼”

  • 它能监听DOM树的各种变化:属性、子节点、文本内容。
  • 配置灵活,可以精确到特定属性或是否包含后代。
  • 异步回调,批量返回变化记录,性能优秀。
  • 应用场景:监听动态内容加载、检测第三方脚本篡改、实现数据绑定(比如某些MVVM库的底层)、与React/Vue的虚拟DOM配合调试等。

有了MutationObserver,你就可以在DOM变化时第一时间响应,像一个隐形的守护者。明天我们将进入Web Storage的世界,看看localStorage、sessionStorage和IndexedDB怎么帮你把数据存到用户浏览器里。

如果你觉得今天的“卧底”够犀利,点个赞让更多人看到。我们明天见!

不用 WebSocket 库,在 React 中构建实时功能

一提到"实时",开发者就会想到 WebSocket 库。Socket.IO、Pusher、Ably -- 生态中有太多选择了。但很多实时功能根本不需要双向通信。股票行情、通知推送、部署日志、实时比分 -- 这些都是服务器到客户端的单向数据流。对于这类场景,浏览器有一个更简单、更轻量、还能自动重连的内置协议:Server-Sent Events(SSE)

将 SSE 与用于连接感知的 Network Information API 和用于跨标签页协调的 BroadcastChannel API 结合起来,你就拥有了一套完整的实时工具包 -- 不需要任何 WebSocket 库。本文将先从零开始手动构建每个部分,看看手动实现在哪里会遇到瓶颈,然后用 ReactUse 的 Hooks 替换,只需几行代码就能处理所有边缘情况。

1. 使用 useEventSource 接入 Server-Sent Events

什么是 Server-Sent Events?

Server-Sent Events(SSE)是一个标准协议,允许服务器通过普通 HTTP 连接向浏览器推送更新。与 WebSocket 不同,SSE 是单向的 -- 服务器发送,客户端接收。浏览器原生的 EventSource API 开箱即用,自动处理连接管理、自动重连和事件解析。

// 一个基本的 SSE 端点(服务端,仅供参考)
// GET /api/notifications
// Content-Type: text/event-stream
//
// data: {"message": "新的部署已启动"}
// id: 1
//
// data: {"message": "部署完成"}
// id: 2

手动实现

让我们在不使用任何库的情况下,在 React 中连接 SSE 端点。

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

function useManualEventSource(url: string) {
  const [data, setData] = useState<string | null>(null);
  const [status, setStatus] = useState<
    "CONNECTING" | "CONNECTED" | "DISCONNECTED"
  >("DISCONNECTED");
  const [error, setError] = useState<Event | null>(null);
  const esRef = useRef<EventSource | null>(null);
  const retriesRef = useRef(0);

  useEffect(() => {
    const connect = () => {
      setStatus("CONNECTING");
      const es = new EventSource(url);
      esRef.current = es;

      es.onopen = () => {
        setStatus("CONNECTED");
        setError(null);
        retriesRef.current = 0;
      };

      es.onmessage = (event) => {
        setData(event.data);
      };

      es.onerror = (err) => {
        setError(err);
        setStatus("DISCONNECTED");
        es.close();
        esRef.current = null;

        // 手动重连逻辑
        retriesRef.current += 1;
        if (retriesRef.current < 5) {
          setTimeout(connect, 1000 * retriesRef.current);
        }
      };
    };

    connect();

    return () => {
      esRef.current?.close();
      esRef.current = null;
    };
  }, [url]);

  return { data, status, error };
}

大约 45 行代码,而且已经存在不少问题:

  • 不支持命名事件。 SSE 支持自定义事件类型(如 event: deploy-status),但 onmessage 只能捕获未命名的消息。要支持命名事件,需要对每种事件类型调用 addEventListener,并在卸载时逐一清理。
  • 重连策略过于简陋。 代码最多重试 5 次,使用线性退避,但无法配置重试次数、延迟时间或失败回调。
  • 无法手动关闭/重新打开。 如果用户导航离开又返回,或者你想在标签页隐藏时暂停数据流,还需要更多的状态跟踪。
  • SSR 会崩溃。 EventSource 在服务端不存在。

使用 useEventSource

ReactUse 的 useEventSource Hook 把这些问题全部解决了。

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

function DeploymentLog() {
  const { data, status, error, event, lastEventId, close, open } =
    useEventSource("/api/deployments/stream", ["deploy-start", "deploy-end"], {
      autoReconnect: {
        retries: 5,
        delay: 2000,
        onFailed: () => console.error("SSE 连接彻底失败"),
      },
    });

  return (
    <div>
      <div>
        状态:{status}
        {status === "DISCONNECTED" && (
          <button onClick={open}>重新连接</button>
        )}
        {status === "CONNECTED" && (
          <button onClick={close}>断开连接</button>
        )}
      </div>

      {error && <div className="error">连接发生错误</div>}

      <div className="log-entry">
        <span className="event-type">{event}</span>
        <span className="event-id">#{lastEventId}</span>
        <pre>{data}</pre>
      </div>
    </div>
  );
}

看看你免费获得了什么:

  • 命名事件支持。 第二个参数传入事件名数组,Hook 会监听每一个。event 返回值告诉你触发的是哪种事件类型。
  • 可配置的自动重连。 设置重试次数、重试间隔,以及所有重试耗尽时的回调。
  • 手动关闭和重新打开。 调用 close() 断开连接,open() 重新连接 -- 非常适合在后台标签页中暂停数据流。
  • SSR 安全。 Hook 会防范服务端 EventSource 未定义的情况。
  • Last Event ID 追踪。 lastEventId 让你可以从上次断开的位置继续接收(如果服务器支持的话)。

实际示例:实时通知流

import { useEventSource } from "@reactuses/core";
import { useState, useEffect } from "react";

interface Notification {
  id: string;
  title: string;
  body: string;
  severity: "info" | "warning" | "error";
}

function NotificationFeed() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const { data, status, event } = useEventSource(
    "/api/notifications/stream",
    ["info", "warning", "error"],
    {
      autoReconnect: {
        retries: -1, // 无限重试
        delay: 3000,
      },
    }
  );

  useEffect(() => {
    if (data) {
      try {
        const notification: Notification = {
          ...JSON.parse(data),
          severity: event as Notification["severity"],
        };
        setNotifications((prev) => [notification, ...prev].slice(0, 50));
      } catch {
        // 数据格式错误,忽略
      }
    }
  }, [data, event]);

  return (
    <div>
      <h2>
        实时通知
        <span className={`status-dot status-${status.toLowerCase()}`} />
      </h2>
      {notifications.map((n) => (
        <div key={n.id} className={`notification notification-${n.severity}`}>
          <strong>{n.title}</strong>
          <p>{n.body}</p>
        </div>
      ))}
    </div>
  );
}

Hook 管理 SSE 的整个生命周期,你的组件只需要关心数据解析和 UI 渲染。

2. 使用 useFetchEventSource 接入需要认证的 SSE 流

原生 EventSource 的局限

原生 EventSource API 有一个重大限制:无法设置自定义请求头。这意味着不能发送 Authorization: Bearer <token>,不能添加自定义 X-Request-ID,也不能发起带 body 的 POST 请求。如果你的 SSE 端点需要认证,EventSource 就不够用了。

常见的变通方案是把 token 放到查询参数中(/api/stream?token=abc),但这会将凭证泄露到服务器日志、浏览器历史记录和 referrer 头中。这是一种安全反模式。

手动实现

要在 SSE 风格的连接中发送自定义请求头,你需要使用 fetch 配合可读流 -- 然后自己处理分块解析、重连和 abort 信号。

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

function useManualFetchSSE(url: string, token: string) {
  const [data, setData] = useState<string | null>(null);
  const [status, setStatus] = useState<string>("DISCONNECTED");
  const abortRef = useRef<AbortController | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    abortRef.current = controller;
    setStatus("CONNECTING");

    const connect = async () => {
      try {
        const response = await fetch(url, {
          headers: {
            Authorization: `Bearer ${token}`,
            Accept: "text/event-stream",
          },
          signal: controller.signal,
        });

        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        if (!response.body) throw new Error("No response body");

        setStatus("CONNECTED");
        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        let buffer = "";

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          buffer += decoder.decode(value, { stream: true });
          const lines = buffer.split("\n\n");
          buffer = lines.pop() || "";

          for (const chunk of lines) {
            const dataLine = chunk
              .split("\n")
              .find((l) => l.startsWith("data: "));
            if (dataLine) {
              setData(dataLine.slice(6));
            }
          }
        }
      } catch (err) {
        if (!controller.signal.aborted) {
          setStatus("DISCONNECTED");
          // 重连逻辑写在这里...
        }
      }
    };

    connect();
    return () => controller.abort();
  }, [url, token]);

  return { data, status };
}

已经超过 55 行了,而且还不完整。它不处理命名事件、事件 ID、带退避的重连,也不支持 POST 请求。手动解析 SSE 文本协议容易出错。

使用 useFetchEventSource

ReactUse 的 useFetchEventSource Hook 封装了 @microsoft/fetch-event-source 库,提供了 React 友好的 API。它支持自定义请求头、POST 请求体,以及你需要的所有重连逻辑。

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

function AuthenticatedStream() {
  const { data, status, event, error, close, open } = useFetchEventSource(
    "/api/private/stream",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${getAccessToken()}`,
        "X-Request-ID": crypto.randomUUID(),
      },
      body: JSON.stringify({
        channels: ["deployments", "alerts"],
      }),
      autoReconnect: {
        retries: 10,
        delay: 2000,
        onFailed: () => {
          // Token 可能已过期 -- 重定向到登录页
          window.location.href = "/login";
        },
      },
      onOpen: () => console.log("数据流已连接"),
      onError: (err) => {
        console.error("数据流错误:", err);
        return 5000; // 5 秒后重试
      },
    }
  );

  return (
    <div>
      <div>连接状态:{status}</div>
      {error && <div className="error">{error.message}</div>}
      <pre>{data}</pre>
    </div>
  );
}

两个 Hook 的核心区别:

特性 useEventSource useFetchEventSource
自定义请求头 不支持 支持
POST 请求 不支持 支持
请求体 不支持 支持
底层技术 原生 EventSource fetch API
自动重连 支持 支持
命名事件 支持(通过数组) 支持(通过 event 字段)

当端点是公开的或使用 cookie 认证时,用 useEventSource。当你需要 token 认证、自定义请求头或 POST 请求时,用 useFetchEventSource

实际示例:AI 聊天流式响应

SSE 是流式 AI 响应的标准协议(OpenAI、Anthropic 等都在使用)。以下是如何用认证构建流式聊天 UI。

import { useFetchEventSource } from "@reactuses/core";
import { useState, useEffect, useCallback } from "react";

function AIChatStream() {
  const [messages, setMessages] = useState<
    Array<{ role: string; content: string }>
  >([]);
  const [input, setInput] = useState("");
  const [streamedResponse, setStreamedResponse] = useState("");

  const { data, status, open, close } = useFetchEventSource(
    "/api/chat/completions",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${getApiKey()}`,
      },
      body: JSON.stringify({
        messages,
        stream: true,
      }),
      immediate: false, // 不在挂载时连接
      onOpen: () => setStreamedResponse(""),
    }
  );

  // 累积流式传输的 token
  useEffect(() => {
    if (data) {
      try {
        const parsed = JSON.parse(data);
        const token = parsed.choices?.[0]?.delta?.content;
        if (token) {
          setStreamedResponse((prev) => prev + token);
        }
      } catch {
        // 忽略 [DONE] 或格式错误的数据块
      }
    }
  }, [data]);

  const sendMessage = useCallback(() => {
    if (!input.trim()) return;
    setMessages((prev) => [...prev, { role: "user", content: input }]);
    setInput("");
    open(); // 启动 SSE 数据流
  }, [input, open]);

  return (
    <div className="chat">
      {messages.map((msg, i) => (
        <div key={i} className={`message message-${msg.role}`}>
          {msg.content}
        </div>
      ))}
      {streamedResponse && (
        <div className="message message-assistant">{streamedResponse}</div>
      )}
      <div className="input-row">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && sendMessage()}
          placeholder="输入消息..."
        />
        <button onClick={sendMessage} disabled={status === "CONNECTING"}>
          发送
        </button>
      </div>
    </div>
  );
}

这里 immediate: false 选项至关重要 -- 我们不希望在组件挂载时就打开连接,而是在用户发送消息时显式调用 open()

3. 使用 useNetwork 和 useOnline 检测网络状态

如果用户离线了,实时功能就毫无用处。更糟糕的是,它们会静默失败 -- SSE 连接断开,fetch 请求挂起,UI 显示过时数据,却没有任何提示。好的实时 UI 应该具备网络感知能力。

手动实现

import { useState, useEffect } from "react";

function useManualNetworkStatus() {
  const [isOnline, setIsOnline] = useState(
    typeof navigator !== "undefined" ? navigator.onLine : true
  );
  const [connectionType, setConnectionType] = useState<string | undefined>();

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

    // Network Information API(并非所有浏览器都支持)
    const conn = (navigator as any).connection;
    if (conn) {
      const handleChange = () => {
        setConnectionType(conn.effectiveType);
      };
      conn.addEventListener("change", handleChange);
      handleChange();

      return () => {
        window.removeEventListener("online", handleOnline);
        window.removeEventListener("offline", handleOffline);
        conn.removeEventListener("change", handleChange);
      };
    }

    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);

  return { isOnline, connectionType };
}

大约 35 行代码只获取了两条信息,而且不追踪下行速度、往返时间、数据节省模式或上次状态变化的时间戳。Network Information API 还使用了带厂商前缀的属性(mozConnectionwebkitConnection),这段代码也没有处理。

使用 useNetwork

useNetwork Hook 返回完整的网络信息。

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

function NetworkDebugPanel() {
  const {
    online,
    previous,
    since,
    downlink,
    effectiveType,
    rtt,
    saveData,
    type,
  } = useNetwork();

  return (
    <div className="network-panel">
      <div>
        状态:{online ? "在线" : "离线"}
        {previous !== undefined && previous !== online && (
          <span>
            {" "}
            (之前{previous ? "在线" : "离线"},变化于{" "}
            {since?.toLocaleTimeString()})
          </span>
        )}
      </div>
      <div>连接类型:{type ?? "未知"}</div>
      <div>有效类型:{effectiveType ?? "未知"}</div>
      <div>下行速度:{downlink ? `${downlink} Mbps` : "未知"}</div>
      <div>往返时间:{rtt ? `${rtt}ms` : "未知"}</div>
      <div>数据节省:{saveData ? "已启用" : "已关闭"}</div>
    </div>
  );
}

Hook 处理了所有的厂商前缀、事件监听器和 SSR 安全问题。previoussince 字段特别有用 -- 它们让你可以显示"你在 30 秒前离线了",而不仅仅是"离线"。

使用 useOnline

如果你只需要布尔值,useOnline 更加简洁。它是 useNetwork 的轻量封装,只返回 online 值。

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

function OfflineBanner() {
  const isOnline = useOnline();

  if (isOnline) return null;

  return (
    <div className="offline-banner">
      你当前处于离线状态,实时更新已暂停。
    </div>
  );
}

实际示例:自适应质量推送

useNetwork 返回的网络信息让你可以根据用户的连接质量调整应用行为。

import { useNetwork } from "@reactuses/core";
import { useMemo } from "react";

function useAdaptivePolling(baseInterval: number) {
  const { online, effectiveType, saveData } = useNetwork();

  const interval = useMemo(() => {
    if (!online) return null; // 离线时停止轮询
    if (saveData) return baseInterval * 4; // 尊重数据节省设置
    switch (effectiveType) {
      case "slow-2g":
      case "2g":
        return baseInterval * 3;
      case "3g":
        return baseInterval * 2;
      case "4g":
      default:
        return baseInterval;
    }
  }, [online, effectiveType, saveData, baseInterval]);

  return interval;
}

function LiveScoreboard() {
  const pollingInterval = useAdaptivePolling(5000);
  const { online, effectiveType } = useNetwork();

  return (
    <div>
      {!online && (
        <div className="banner">离线中 -- 显示缓存的比分</div>
      )}
      {effectiveType === "slow-2g" && (
        <div className="banner">慢速连接 -- 更新频率已降低</div>
      )}
      {/* 使用 pollingInterval 的记分牌内容 */}
    </div>
  );
}

在快速 4G 连接上,记分牌每 5 秒更新一次。在慢速 2G 连接上,每 15 秒更新一次。离线时完全停止,显示缓存数据。用户获得的是其连接条件所能支持的最佳体验。

4. 使用 useBroadcastChannel 实现跨标签页通信

实时数据通常需要在浏览器标签页之间共享。如果用户在三个标签页中打开了你的仪表盘,当一条新通知通过 SSE 到达时,三个标签页都应该显示它 -- 但只有一个标签页应该维护 SSE 连接。BroadcastChannel API 让这成为可能。

手动实现

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

function useManualBroadcastChannel<T>(channelName: string) {
  const [data, setData] = useState<T | undefined>();
  const channelRef = useRef<BroadcastChannel | null>(null);

  useEffect(() => {
    if (typeof BroadcastChannel === "undefined") return;

    const channel = new BroadcastChannel(channelName);
    channelRef.current = channel;

    const handleMessage = (event: MessageEvent<T>) => {
      setData(event.data);
    };

    const handleError = (event: MessageEvent) => {
      console.error("BroadcastChannel 错误:", event);
    };

    channel.addEventListener("message", handleMessage);
    channel.addEventListener("messageerror", handleError);

    return () => {
      channel.removeEventListener("message", handleMessage);
      channel.removeEventListener("messageerror", handleError);
      channel.close();
    };
  }, [channelName]);

  const post = useCallback((message: T) => {
    channelRef.current?.postMessage(message);
  }, []);

  return { data, post };
}

这对简单场景够用了,但它不追踪 BroadcastChannel 是否被支持、频道是否已关闭、错误状态或用于去重的时间戳。

使用 useBroadcastChannel

useBroadcastChannel Hook 提供了完整的、类型安全的封装。

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

interface DashboardMessage {
  type: "NEW_DATA" | "USER_ACTION" | "TAB_CLOSING";
  payload?: unknown;
  sourceTab: string;
}

function DashboardSync() {
  const { data, post, isSupported, isClosed, error } = useBroadcastChannel<
    DashboardMessage,
    DashboardMessage
  >({ name: "dashboard-sync" });

  const broadcast = (type: DashboardMessage["type"], payload?: unknown) => {
    post({
      type,
      payload,
      sourceTab: sessionStorage.getItem("tab-id") || "unknown",
    });
  };

  useEffect(() => {
    if (data?.type === "NEW_DATA") {
      // 用来自另一个标签页的数据更新本地状态
      console.log("收到来自标签页的数据:", data.sourceTab, data.payload);
    }
  }, [data]);

  if (!isSupported) {
    return <div>当前浏览器不支持跨标签页同步。</div>;
  }

  return (
    <div>
      <button onClick={() => broadcast("NEW_DATA", { count: 42 })}>
        与其他标签页共享数据
      </button>
      {error && <div className="error">同步出错</div>}
      {isClosed && <div className="warning">频道已关闭</div>}
    </div>
  );
}

这个 Hook 提供了:

  • isSupported -- 在渲染依赖同步的 UI 前检查 BroadcastChannel 是否可用。
  • isClosed -- 知道频道何时被关闭(由你或浏览器关闭)。
  • error -- 处理消息序列化错误。
  • timeStamp -- 当相同数据被多次接收时进行去重。
  • 类型安全 -- 泛型参数 <D, P> 分别对应接收数据类型和发送数据类型。

5. 综合实战:实时监控仪表盘

让我们将这五个 Hook 组合成一个生产级别的实时仪表盘。这个仪表盘:

  • 通过 SSE 接收实时指标(带认证)
  • 检测网络状态并相应调整行为
  • 在标签页之间共享数据,只让一个标签页维护 SSE 连接
  • 向用户展示连接健康状况
import {
  useFetchEventSource,
  useNetwork,
  useOnline,
  useBroadcastChannel,
  useEventSource,
} from "@reactuses/core";
import { useState, useEffect, useCallback, useRef } from "react";

// --- 类型定义 ---

interface MetricEvent {
  timestamp: number;
  cpu: number;
  memory: number;
  requests: number;
  errors: number;
}

interface TabMessage {
  type: "METRIC_UPDATE" | "CLAIM_LEADER" | "RELEASE_LEADER" | "HEARTBEAT";
  payload?: MetricEvent;
  tabId: string;
}

// --- 领导者选举 Hook ---

function useTabLeader(channelName: string) {
  const tabId = useRef(crypto.randomUUID()).current;
  const [isLeader, setIsLeader] = useState(false);
  const { data, post } = useBroadcastChannel<TabMessage, TabMessage>({
    name: channelName,
  });

  useEffect(() => {
    // 挂载时,短暂延迟后尝试获取领导权
    const timer = setTimeout(() => {
      post({ type: "CLAIM_LEADER", tabId });
      setIsLeader(true);
    }, Math.random() * 200);

    return () => {
      clearTimeout(timer);
      post({ type: "RELEASE_LEADER", tabId });
    };
  }, [post, tabId]);

  useEffect(() => {
    if (data?.type === "CLAIM_LEADER" && data.tabId !== tabId) {
      if (data.tabId > tabId) {
        setIsLeader(false);
      }
    }
    if (data?.type === "RELEASE_LEADER") {
      // 另一个标签页释放了 -- 尝试获取领导权
      setTimeout(() => {
        post({ type: "CLAIM_LEADER", tabId });
        setIsLeader(true);
      }, Math.random() * 100);
    }
  }, [data, tabId, post]);

  return { isLeader, tabId };
}

// --- 网络感知 SSE Hook ---

function useMetricsStream(enabled: boolean) {
  const { online, effectiveType } = useNetwork();

  const { data, status, error, close, open } = useFetchEventSource(
    "/api/metrics/stream",
    {
      headers: {
        Authorization: `Bearer ${getAccessToken()}`,
      },
      immediate: false,
      autoReconnect: {
        retries: -1,
        delay: effectiveType === "4g" ? 2000 : 5000,
        onFailed: () => console.error("指标数据流彻底失败"),
      },
    }
  );

  // 根据 enabled 标志和在线状态连接/断开
  useEffect(() => {
    if (enabled && online) {
      open();
    } else {
      close();
    }
  }, [enabled, online, open, close]);

  return { data, status, error };
}

// --- 主仪表盘组件 ---

function RealtimeDashboard() {
  const [metrics, setMetrics] = useState<MetricEvent[]>([]);
  const isOnline = useOnline();
  const { online, effectiveType, rtt } = useNetwork();

  // 领导者选举 -- 只有领导者标签页打开 SSE 连接
  const { isLeader, tabId } = useTabLeader("metrics-leader");

  // SSE 数据流 -- 只在当前标签页是领导者时激活
  const { data: sseData, status: sseStatus } = useMetricsStream(isLeader);

  // 跨标签页数据共享
  const { data: tabData, post: broadcastToTabs } = useBroadcastChannel<
    TabMessage,
    TabMessage
  >({ name: "metrics-data" });

  // 当领导者收到 SSE 数据时,广播给其他标签页
  useEffect(() => {
    if (isLeader && sseData) {
      try {
        const metric: MetricEvent = JSON.parse(sseData);
        setMetrics((prev) => [...prev, metric].slice(-100));
        broadcastToTabs({
          type: "METRIC_UPDATE",
          payload: metric,
          tabId,
        });
      } catch {
        // 数据格式错误
      }
    }
  }, [isLeader, sseData, broadcastToTabs, tabId]);

  // 当非领导者标签页收到广播数据时,更新本地状态
  useEffect(() => {
    if (!isLeader && tabData?.type === "METRIC_UPDATE" && tabData.payload) {
      setMetrics((prev) => [...prev, tabData.payload!].slice(-100));
    }
  }, [isLeader, tabData]);

  const latestMetric = metrics[metrics.length - 1];

  return (
    <div className="dashboard">
      {/* 连接状态栏 */}
      <header className="status-bar">
        <div className="status-indicators">
          <span className={`dot ${isOnline ? "green" : "red"}`} />
          <span>
            {isOnline ? "在线" : "离线"}
            {effectiveType && ` (${effectiveType})`}
            {rtt && ` -- ${rtt}ms 往返`}
          </span>
        </div>
        <div className="tab-info">
          {isLeader ? "领导者标签页(SSE 活跃)" : "跟随者标签页(通过广播)"}
          <span className={`dot ${sseStatus === "CONNECTED" ? "green" : "yellow"}`} />
        </div>
      </header>

      {/* 离线提示 */}
      {!isOnline && (
        <div className="offline-banner">
          你当前处于离线状态。正在显示最近 {metrics.length} 条缓存指标。
          连接恢复后数据将自动继续更新。
        </div>
      )}

      {/* 指标网格 */}
      {latestMetric && (
        <div className="metrics-grid">
          <MetricCard
            label="CPU 使用率"
            value={`${latestMetric.cpu.toFixed(1)}%`}
            status={latestMetric.cpu > 80 ? "danger" : "normal"}
          />
          <MetricCard
            label="内存"
            value={`${latestMetric.memory.toFixed(1)}%`}
            status={latestMetric.memory > 90 ? "danger" : "normal"}
          />
          <MetricCard
            label="请求数/秒"
            value={latestMetric.requests.toLocaleString()}
            status="normal"
          />
          <MetricCard
            label="错误数/秒"
            value={latestMetric.errors.toLocaleString()}
            status={latestMetric.errors > 10 ? "danger" : "normal"}
          />
        </div>
      )}

      {/* 迷你图表(最近 100 个数据点) */}
      <div className="chart-section">
        <h3>CPU 变化趋势</h3>
        <div className="sparkline">
          {metrics.map((m, i) => (
            <div
              key={i}
              className="bar"
              style={{
                height: `${m.cpu}%`,
                backgroundColor: m.cpu > 80 ? "#ef4444" : "#22c55e",
              }}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

function MetricCard({
  label,
  value,
  status,
}: {
  label: string;
  value: string;
  status: "normal" | "danger";
}) {
  return (
    <div className={`metric-card metric-${status}`}>
      <div className="metric-label">{label}</div>
      <div className="metric-value">{value}</div>
    </div>
  );
}

每个 Hook 在这个仪表盘中的贡献:

  • useFetchEventSource -- 连接带认证的指标 SSE 端点,自动重连。
  • useEventSource -- 如果端点不需要自定义请求头,可以替换使用(对组件零 API 变更)。
  • useNetwork -- 为状态栏提供连接质量数据(effectiveTypertt),并实现自适应重连延迟。
  • useOnline -- 驱动离线提示,在网络断开时暂停 SSE 连接。
  • useBroadcastChannel -- 实现领导者选举和跨标签页数据共享,只让一个标签页维护 SSE 连接,而所有标签页都显示实时数据。

最终效果:

  1. 所有标签页共享一个 SSE 连接(节省服务器资源)
  2. 根据连接质量自适应退避重连
  3. 向用户展示实时网络状态
  4. 离线时优雅降级
  5. 所有打开的标签页之间即时共享数据

选择哪个 Hook

场景 Hook 原因
公开 SSE 端点 useEventSource 简单,原生 EventSource
带认证头的 SSE useFetchEventSource 通过 fetch 支持自定义请求头
带 POST 请求体的 SSE useFetchEventSource 支持请求体
简单的在线/离线检测 useOnline 返回单个布尔值
详细的连接信息 useNetwork 下行速度、往返时间、有效类型
跨标签页消息 useBroadcastChannel 内存通信,无持久化
跨标签页 + 持久化 useBroadcastChannel + useLocalStorage 两全其美

安装

npm install @reactuses/core

或使用你偏好的包管理器:

pnpm add @reactuses/core
yarn add @reactuses/core

相关 Hooks

  • useEventSource -- 响应式 Server-Sent Events,支持命名事件和自动重连
  • useFetchEventSource -- 基于 fetch 的 SSE,支持自定义请求头、POST 请求和认证
  • useNetwork -- 详细的网络状态,包括连接类型、下行速度和往返时间
  • useOnline -- 简单的在线/离线布尔值检测
  • useBroadcastChannel -- 通过 BroadcastChannel API 实现类型安全的跨标签页消息传递
  • useDocumentVisibility -- 跟踪当前标签页是否可见
  • useLocalStorage -- 具有自动跨标签页同步的持久化状态

ReactUse 提供了 100+ 个 React Hooks。探索全部 →

手撕代码之事件委托

一、题目

请补全 JavaScript 代码,要求如下:

  1. ul 标签添加点击事件
  2. 当点击某 li 标签时,该标签内容拼接 . 符号。如:某 li 标签被点击时,该标签内容为 ..

注意: 必须使用 DOM0 级标准事件(onclick

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        /* 填写样式 */
    </style>
</head>
<body>
    <ul>
        <li>.</li>
        <li>.</li>
        <li>.</li>
    </ul>
    <!-- 填写标签 -->
    <script type="text/javascript">
        // 填写JavaScript
        document.querySelector('ul').onclick = event => {

        }
    </script>
</body>
</html>

二、思路与笔记

1. 事件委托的核心概念

事件冒泡

提到事件委托,首先会先引入一个 事件冒泡 的概念。

事件冒泡 可以去读一读 MDN 的文档,当然在 React 等前端框架的基础下我认为这个可以暂且不提。

核心思想说,事件冒泡在描述一个浏览器对于嵌套元素的事件是如何处理的。

在子元素上面增加事件处理器时,事件会一层层往父级元素冒泡,父级的父级……

MDN 这里有原话是:

在这种情况下:

  • 最先触发按钮上的单击事件
  • 然后是按钮的父元素(<div> 元素)
  • 然后是 <div> 的父元素(<body> 元素)

我们可以这样描述:事件从被点击的最里面的元素 冒泡 而出。

这种行为可能是有用的,也可能引起意想不到的问题。在接下来的章节中,我们将看到它引起的一个问题,并找到解决方案。

对于一些功能我们并不希望冒泡的,会有 stopPropagation(),当然在今天的手撕代码里并不是重点。

事件委托

事件委托

事件冒泡可以实现 事件委托

在这种做法中,当我们想在用户与大量的子元素中的任何一个互动时运行一些代码时,我们在它们的父元素上设置事件监听器,让发生在它们身上的事件冒泡到它们的父元素上,而不必在每个子元素上单独设置事件监听器。

事件委托就是“自己不处理,让祖先元素代为处理”。

事件委托 = 把监听器挂到父级,利用冒泡 + event.target 来统一处理子元素事件。

事件对象中的 target 属性指向 实际触发事件的元素(不是绑定事件的元素)。

具体来说:

你不必给每个子元素单独绑定事件监听器,而是把监听器绑定在它们的 父元素(或更高层祖先) 上。

当子元素上的事件(比如点击)触发后,事件会沿着 DOM 树 向上冒泡,被父元素捕获并执行对应的处理函数。

示例1

假设有一个 <ul> 列表,里面有 1000 个 <li>

<ul id="list">
    <li>项目 1</li>
    <li>项目 2</li>
    <li>项目 3</li>
    ……
</ul>

不用事件委托的做法:

const items = document.querySelectorAll('#list li');
items.forEach(item => {
    item.addEventListener('click', () => {
        console.log('点击了', item.textContent);
    });
});

缺点:

  • 如果动态新增 <li>,新增的项不会有点击事件(除非重新绑定)
  • 性能差(1000 个监听器)

用事件委托的做法:

const parent = document.getElementById('list');
parent.addEventListener('click', (event) => {
    // event.target 是真正被点击的元素
    if (event.target.tagName === 'LI') {
        console.log('点击了', event.target.textContent);
    }
});

优点:

  • 只有 一个 监听器,性能好
  • 动态新增的子元素 自动具备 点击响应
  • 代码更简洁
示例2

场景:一个待办事项列表,我只关心“项目 3”是否被点击

<ul id="list">
    <li id="item-1">项目 1</li>
    <li id="item-2">项目 2</li>
    <li id="item-3">项目 3</li>
    <li id="item-4">项目 4</li>
</ul>

情况1:直接监听(不是事件委托)

const specificLi = document.getElementById('item-3');
specificLi.addEventListener('click', () => {
    console.log('点击了第3项');
});

特征:

  • 监听器直接挂在 item-3 这个 <li>
  • 不依赖冒泡机制
  • 如果 item-3 被删除再重新添加,需要重新绑定
  • 只监听这一个元素

情况2:事件委托(只关心特定子元素)

const parent = document.getElementById('list');
parent.addEventListener('click', (event) => {
    const li = event.target.closest('li');
    if (li && li.id === 'item-3') {
        console.log('点击了第3项');
    }
});

特征:

  • 监听器挂在父元素 <ul>
  • 依赖冒泡机制:子元素点击 → 事件冒泡到 <ul> → 执行回调
  • 即使 item-3 被删除后重新动态添加,仍然自动有效
  • 一个监听器覆盖了所有子元素,但通过条件过滤只处理 item-3

很多人误以为事件委托只能用来批量处理所有子元素(比如给所有 <li> 加点击)。

实际上,事件委托的核心是利用冒泡 + 祖先监听,至于处理哪些子元素,由条件判断灵活控制

// 可以处理特定子元素
if (li.id === 'item-3') { ... }

// 可以处理某一类子元素
if (li.classList.contains('important')) { ... }

// 可以处理所有子元素(最常用)
if (li) { ... }

Event 接口

Event 接口表示在 EventTarget 上出现的事件。

一些事件是由用户触发的,例如鼠标或键盘事件;或者由 API 生成以表示异步任务的进度。事件也可以通过编程方式触发,例如对元素调用 HTMLElement.click() 方法,或者定义一些自定义事件,再使用 EventTarget.dispatchEvent() 方法将自定义事件派发往指定的目标(target)。

有许多不同类型的事件,其中一些使用基于 Event 主接口的其他接口。Event 本身包含适用于所有事件的属性和方法。

很多 DOM 元素可以被设计接收(或者监听)这些事件,并且执行代码去响应(或者处理)它们。通过 EventTarget.addEventListener() 方法可以将事件处理器绑定到不同的 HTML 元素上(比如 <button><div><span> 等等)。这种方式基本替换了老版本中使用 HTML 事件处理器属性的方式。此外,在正确添加后,还可以使用 removeEventListener() 方法移除这些事件处理器。

备注: 一个元素可以绑定多个事件处理器,甚至是对于完全相同的事件。尤其是相互独立的代码模块出于不同的目的附加事件处理器。(比如,一个网页同时有着广告模块和统计模块同时监听视频播放。)

当有很多嵌套的元素,每个元素都有着自己的事件处理器,事件处理过程会变得非常复杂。尤其当一个父元素和子元素绑定完全相同的事件时,因为结构上的重叠,事件在技术层面发生在两个元素中,触发的顺序取决于每个处理器的事件冒泡的设置。


2. Event.target 属性

Event 接口的 target 只读属性是对事件被分派到的对象的引用。当事件处理器在事件的冒泡或捕获阶段被调用时,它与 Event.currentTarget 不同。

event.target 属性可以用于实现 事件委托

  • event.target实际被点击的那个元素,不是绑定事件的元素。
  • ul.onclick,点击 li 时,event.target 就是那个 li,不是 ul。

在 JS 里,对象传递的就是引用(内存地址的拷贝),你可以直接修改它。

MDN 中有这个示例代码:

// 创建列表
const ul = document.createElement("ul");
document.body.appendChild(ul);

const li1 = document.createElement("li");
const li2 = document.createElement("li");
ul.appendChild(li1);
ul.appendChild(li2);

function hide(evt) {
    // evt.target 指向被点击的 <li> 元素
    // 这与 evt.currentTarget 不同,后者在这个上下文中将指向父级 <ul>
    evt.target.style.visibility = "hidden";
}

// 将监听器附加到列表上
// 点击每个 <li> 时都会触发
ul.addEventListener("click", hide, false);

3. Node.textContent 属性

Node 接口的 textContent 属性表示一个节点及其后代的文本内容。

textContent 的 MDN 文档,它 既可以读,也可以写

innerHTML 的区别

正如其名称,Element.innerHTML 返回 HTML。通常,为了在元素中检索或写入文本,人们使用 innerHTML。但是,textContent 通常具有更好的性能,因为文本不会被解析为 HTML。

此外,使用 textContent 可以防止 XSS 攻击

textContent 获取的是元素内的纯文本内容 string,会过滤掉所有 HTML 标签,只返回文字部分。设置时,内容会作为普通文本插入,不会被解析成 HTML 标签。


4. Element.tagName 属性

注意 tagName 获取当前元素标签名。

if (被点击的元素是 li) {
    获取当前文本内容
    新内容 = 当前内容 + "."
    把新内容设置回去
}

自己乱写了一波

if (event.target.tagName === 'li') {
    let text = event.target.texContent ;
    let newText = text + '.';
    // 不知道怎么设置回去
    event.target.textContent = newText;
}

注意这个不知道怎么设置回去就是依赖 event.target.textContent 是可以读也可以写的。

语法

elementName = element.tagName
  • elementName 是一个字符串,包含了 element 元素的标签名。

备注

在 XML(或者其他基于 XML 的语言,比如 XHTML, xul)文档中,tagName 的值会保留原始的大小写。

比如 span 会返回 SPAN,那在判定的时候要写成大写的。

在 HTML 文档中,tagName 会返回其大写形式。对于元素节点来说,tagName 属性的值和 nodeName 属性的值是相同的。


三、解法

1. 思路推导

根据以上笔记,解题思路如下:

  • 使用 DOM0 级事件 onclick 给 ul 绑定点击事件
  • 通过 event.target 获取实际点击的元素
  • 通过 tagName 判断点击的是否为 li 元素
  • 通过 textContent 读取并修改 li 的内容

2. 最终代码

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        /* 填写样式 */
    </style>
</head>
<body>
    <ul>
        <li>.</li>
        <li>.</li>
        <li>.</li>
    </ul>
    <!-- 填写标签 -->
    <script type="text/javascript">
        document.querySelector('ul').onclick = event => {
            let eventName = event.target.tagName;
            if (eventName === 'LI') {
                let eventText = event.target.textContent;
                event.target.textContent = eventText + '.';
            }
        }
    </script>
</body>
</html>

四、小结

还是比较简单的,第一次手撕有点没有习惯这个写法,具体思路很清晰,多查查 API,多看 MDN 的示例。

参考链接

别再手写代码了!2026 前端 5 个 AI 杀招,直接解放 80% 重复劳动(附工具+步骤)

你还在手动搭项目、手写组件、熬夜调 Bug 吗?2026 年的前端开发,AI 已经接管 80% 重复工作——从项目初始化、UI 生成、Bug 修复到代码重构,全流程智能化。

今天这篇,不讲虚的,直接带工具、带步骤、带实战指令,照着做,今天就能少加班 50%。


一、AI 一键搭项目:1 分钟搞定 Vue/React 工程(VS Code + Copilot)

以前搭项目:装依赖、配路由、装状态库、调 ESLint……半天没了。 现在用 GitHub Copilot(VS Code 必装),一句话生成完整工程。

工具安装(5 分钟)

  1. 安装 VS Code(最新版)

image.png

  1. 扩展商店搜:GitHub Copilot + GitHub Copilot Chat(安装)

image.png

  1. 点击左下角 Copilot 图标 → 登录 GitHub → 授权成功(图标变绿)

image.png

实战步骤(1 分钟出项目)

  1. 新建空文件夹 → 用 VS Code 打开
  2. 快捷键 Ctrl+Shift+I(Win)/ Cmd+Shift+I(Mac)打开 Copilot Chat

image.png

  1. 直接发指令(复制可用):

    生成一个 Vue3 + Vite + Pinia + VueRouter + Tailwind CSS 项目,包含:

    • 完整目录结构
    • ESLint + Prettier 规范配置
    • 请求封装(axios)
    • 路由守卫
    • 自适应布局基础
    • 自动安装依赖
  2. 等待 30 秒 → AI 自动生成所有文件、安装依赖、写好 README

image.png

效果对比

  • 以前:1 天工作量
  • 现在:1 分钟,零配置、零报错

二、AI 组件工厂:一句话生成生产级 UI(Cursor 编辑器)

前端最耗时:写页面、调样式、做响应式、加交互。 Cursor(AI 原生编辑器) 是前端 UI 生成神器,比 VS Code 更智能,支持跨文件、自动处理样式依赖。

工具安装

  1. 官网下载:www.cursor.so/

image.png

  1. 安装 → 首次启动用 GitHub 登录 → 导入 VS Code 配置

安装

  1. 设置中文:Ctrl+Shift+P → 搜索 Configure Display Language → 选中文

设置中文

实战步骤(生成电商商品卡片)

  1. 新建 ProductCard.vue
  2. 快捷键 Ctrl+K(Win)/ Cmd+K(Mac)打开 AI 指令

打开 AI

  1. 输入(复制可用):

    用 Vue3 + TS + Tailwind CSS 生成电商商品卡片组件,要求:

    • 包含:商品图、标题、原价、现价、折扣标签、加入购物车按钮
    • hover 上浮动效、过渡动画
    • 移动端响应式(375px 适配)
    • 带 TS 类型定义
    • 支持自定义主题色
    • 加注释、符合 ESLint 规范
  2. 回车 → 直接生成完整代码(复制即用)

完整代码

进阶:Figma 转代码

  1. 打开 Figma 设计稿 → 复制链接
  2. Cursor 指令:

    把这个 Figma 设计稿转成 Vue3 代码:[粘贴链接],带响应式、TS 类型、可直接运行


三、AI 自动改 Bug:秒定位+修复,告别熬夜(Copilot Chat)

前端最痛:白屏、样式错乱、报错、兼容问题。 Copilot Chat 能直接读代码+报错,自动定位根因+给修复方案

实战步骤(修复白屏 Bug)

  1. 遇到报错:Uncaught TypeError: Cannot read properties of undefined (reading 'xxx')
  2. 选中报错代码 → 右键 → Copilot → Explain This Error

修改Bug

  1. 或直接在聊天框发:

    分析这段代码和报错,找出根因,给修复代码+解释: 【粘贴报错】 【粘贴代码】

  2. AI 秒回:

  • 错误原因(如:变量未初始化、异步时序问题)
  • 完整修复代码
  • 优化建议(如:加可选链、错误捕获)

image

常见前端 Bug 指令(直接复制)

  • 样式兼容:修复 iOS 微信浏览器样式错乱问题
  • 性能卡顿:分析页面滚动卡顿,优化 FPS,给代码方案
  • 接口报错:修复 axios 跨域+超时+错误重试

四、AI 代码重构:老项目一键升级(文心快码)

维护 jQuery/老 Vue2 项目?手动重构太痛苦。 文心快码(国产 AI,前端重构最强) 能批量升级、补 TS、优化性能。

工具安装(VS Code 插件)

  1. 扩展商店搜:文心快码(Baidu Comate) → 安装

Baidu Comate

  1. 用百度账号登录 → 免费额度够用

实战步骤(jQuery 转 Vue3)

  1. 打开老代码文件

  2. 打开文心快码聊天 → 发指令:

    把这段 jQuery 代码重构成 Vue3 组合式 API + TS,要求:

    • 保留原功能
    • 加类型定义
    • 用 Pinia 管理状态
    • 优化性能、移除冗余
    • 符合团队规范
  3. AI 自动生成新代码 → 对比确认 → 直接替换

进阶:批量重构

分析整个项目,把所有 Vue2 组件升级到 Vue3,统一 TS 规范


五、AI 全链路工程化:从接口到部署一条龙(v0 + Copilot)

不止写代码,接口、类型、测试、部署 AI 全包。 v0(Vercel 出品)+ Copilot 前端全链路最强组合。

1. 接口 + TS 类型自动生成

Copilot 指令:

根据这份接口文档,生成:

  • axios 请求封装
  • TS 接口类型定义
  • Mock 数据
  • API 调用示例

2. UI 生成(v0 最强)

  1. 打开:v0.dev/

image

  1. 输入:生成一个后台管理系统列表页,带筛选、分页、操作按钮,用 React + Tailwind
  2. 10 秒出页面 → 复制代码到项目

3. 自动写测试 + 部署

Copilot 指令:

为这个组件写 Vitest 单元测试,覆盖:渲染、交互、边界情况 再生成 Dockerfile + CI/CD 部署脚本


2026 前端 AI 工具选型表(直接抄)

场景 最佳工具 价格 上手难度
日常编码、补全 GitHub Copilot $19/月
UI 组件、页面生成 Cursor、v0 $20/月(Cursor) ⭐⭐
老项目重构、升级 文心快码 免费额度+付费
Bug 修复、调试 Copilot Chat 含在 Copilot 内
全栈项目、原型 Bolt.new 免费试用 ⭐⭐

最后:AI 不淘汰前端,淘汰不用 AI 的人

2026 年的前端竞争:

  • ❶ 不会 AI:天天手写、加班、被淘汰
  • ❷ 会用 AI:少写 80% 重复代码、早下班、涨薪更快

今天就行动

  1. 装 VS Code + Copilot + Cursor
  2. 把本文指令复制试用
  3. 把重复工作丢给 AI,专注架构、业务、价值

别再手写代码了,AI 时代,拼的是会不会用工具,不是手速!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端工程师必备的 10 个 AI 万能提示词(Prompt),复制直接用,效率再翻倍!

你是不是也有这种困扰? 用 Copilot、Cursor 写代码,明明想让 AI 帮你省时间,结果指令发出去,AI 瞎编代码、答非所问,反而更费劲儿?

不是 AI 不好用,是你没找对“说话方式”——前端 AI 高效开发的核心,从来不是“让 AI 写代码”,而是“让 AI 精准懂你的需求”。

很多前端每天用 AI,却不知道:一句好的 Prompt(提示词),能让效率直接翻 3 倍,少写 80% 重复代码、少踩 90% 的坑。

今天这篇,不搞虚的,直接给大家整理了 10 个前端专属 AI 万能提示词,覆盖前端开发全场景——组件开发、Bug 修复、代码重构、样式优化、工程化配置,全部复制就能用,不用自己琢磨,新手也能轻松上手。

不管你用的是 Copilot、Cursor、文心快码,还是 Claude Code,这些 Prompt 都通用,今天用,今天就能省时间、少加班!

先划重点:前端 AI Prompt 万能公式(记牢更省心)

所有好用的前端 Prompt,都离不开这 4 个核心要素,记下来,以后自己也能自定义:

明确场景 + 技术栈 + 具体需求 + 输出要求

举个反例:“帮我写个按钮组件”(模糊,AI 易瞎编) 举个正例:“用 Vue3 + TS + Tailwind CSS 写一个按钮组件,包含默认/禁用/高亮三种状态,hover 有过渡动画,带类型定义和注释,符合 ESLint 规范”(精准,AI 直接出可用代码)

下面这 10 个 Prompt,全部按照这个公式编写,复制粘贴,替换括号里的内容,就能直接用!

一、组件开发类(最常用,每天都能用到)

前端每天都要写组件,这 2 个 Prompt,覆盖 80% 的组件开发场景,不用再手动写样式、写逻辑。

Prompt 1:基础组件生成(复制即用)

用【Vue3/React】+【TS】+【Tailwind CSS/Element Plus/Ant Design】生成【组件名称,如:登录表单/商品卡片/分页组件】,要求:
1. 包含【具体功能,如:表单校验/分页切换/hover 动效】;
2. 支持【自定义属性,如:自定义颜色/尺寸/回调函数】;
3. 带完整 TS 类型定义、详细注释,符合 ESLint 规范;
4. 适配移动端响应式,兼容主流浏览器;
5. 输出完整可运行代码,复制就能直接导入项目。

示例替换:用 Vue3 + TS + Element Plus 生成登录表单,要求:1. 包含账号密码校验、记住密码、忘记密码功能;2. 支持自定义提交按钮文本;3. 带完整 TS 类型定义、详细注释,符合 ESLint 规范;4. 适配移动端响应式,兼容主流浏览器;5. 输出完整可运行代码,复制就能直接导入项目。

Prompt 2:复杂组件封装(复制即用)

帮我封装一个【复杂组件名称,如:树形表格/弹窗表单/下拉搜索选择器】,技术栈【Vue3/React + TS】,要求:
1. 核心功能:【详细描述功能,如:树形表格支持勾选、展开/折叠、搜索筛选;弹窗表单支持表单联动、提交校验】;
2. 性能优化:【如:懒加载、防抖节流、避免重复渲染】;
3. 可扩展性:支持插槽、自定义事件、Props 传参,方便后续二次开发;
4. 附带使用示例、TS 类型说明、常见问题备注;
5. 代码结构清晰,分模块编写,便于维护。

二、Bug 修复类(前端救星,告别熬夜改 Bug)

遇到 Bug 不用慌,不用再翻 Stack Overflow、不用瞎试代码,这 2 个 Prompt,让 AI 秒定位、秒修复,还能告诉你问题根源。

Prompt 3:报错快速修复(复制即用)

帮我分析以下前端报错和对应代码,要求:
1. 报错信息:【粘贴完整报错信息,如:Uncaught TypeError: Cannot read properties of undefined (reading 'value')】;
2. 对应代码:【粘贴报错相关的完整代码片段】;
3. 请找出报错根因,给出详细解释,然后提供完整的修复代码;
4. 补充优化建议,避免以后再出现类似问题;
5. 修复后的代码要符合项目技术栈【Vue3/React + TS】规范,可直接替换使用。

Prompt 4:兼容性/Bug 排查(复制即用)

我遇到一个前端问题:【详细描述问题,如:iOS 微信浏览器样式错乱、页面滚动卡顿、接口请求跨域失败、组件渲染异常】;
项目技术栈:【Vue3/React + TS + 具体框架/工具】;
请帮我:
1. 分析可能的问题原因,列出所有可能性;
2. 给出每一种原因的解决方案和完整代码;
3. 提供预防措施,避免后续出现类似兼容性/性能问题;
4. 方案要简单易操作,不用复杂配置,直接能落地。

三、代码重构类(老项目救星,提升代码质量)

维护老项目、接手烂代码,手动重构太费时间?这 2 个 Prompt,让 AI 帮你优化代码、升级版本,不用自己逐行修改。

Prompt 5:代码优化/重构(复制即用)

帮我重构以下前端代码,项目技术栈【Vue3/React + TS】,要求:
1. 原始代码:【粘贴需要重构的代码片段】;
2. 重构目标:优化代码结构、移除冗余代码、修复潜在 Bug、提升代码可读性和可维护性;
3. 保留原有的所有功能,不改变业务逻辑;
4. 加入 TS 类型定义(如果没有),补充必要注释,符合 ESLint 规范;
5. 给出重构前后的对比说明,解释优化的原因和好处。

Prompt 6:版本升级迁移(复制即用)

帮我将【旧版本技术,如:Vue2 组件/Vue3 旧语法/jQuery 代码】迁移到【新版本技术,如:Vue3 组合式 API/TS/React 函数组件】,要求:
1. 原始代码:【粘贴需要迁移的代码片段/文件】;
2. 迁移要求:完全保留原业务功能,兼容原有项目配置,不引入新的依赖;
3. 遵循新版本的最佳实践,如:Vue3 组合式 API 规范、React Hooks 规范;
4. 补充迁移说明,列出需要注意的细节和可能出现的问题及解决方案;
5. 输出完整的迁移后代码,可直接替换使用。

四、样式/交互类(告别调样式的痛苦)

调样式、做交互,最费时间还容易出错?这 2 个 Prompt,让 AI 帮你写样式、做动效,不用再反复调试。

Prompt 7:样式快速生成/优化(复制即用)

帮我写/优化【元素/组件】的样式,技术栈【Tailwind CSS/CSS3/SCSS】,要求:
1. 样式需求:【详细描述,如:居中显示、圆角、阴影、hover 动效、响应式适配(375px/768px/1200px)、深色模式兼容】;
2. 样式规范:符合项目设计规范,避免样式冲突,代码简洁可复用;
3. 优化要求:减少冗余样式,提升样式加载速度,兼容主流浏览器;
4. 输出完整的样式代码,可直接复制到项目中使用,并给出使用说明。

Prompt 8:交互效果实现(复制即用)

帮我实现【交互效果,如:下拉菜单动画、弹窗淡入淡出、滚动加载、拖拽排序、表单联动】,技术栈【Vue3/React + JS/TS】,要求:
1. 交互细节:【详细描述,如:弹窗点击遮罩关闭、下拉菜单hover展开、拖拽时显示提示、滚动加载到底部自动请求数据】;
2. 性能要求:避免卡顿、防抖节流处理,不影响页面其他功能;
3. 兼容性:适配移动端和PC端,兼容主流浏览器;
4. 输出完整的代码(HTML/CSS/JS/TS),复制就能用,附带使用说明和注意事项。

五、工程化/工具类(提升全链路效率)

除了写代码,工程化配置、接口请求、测试用例也能让 AI 帮你做,这 2 个 Prompt,覆盖前端全链路开发。

Prompt 9:接口请求/类型生成(复制即用)

根据以下接口文档,生成【Vue3/React】项目的接口请求代码,要求:
1. 接口信息:【粘贴接口文档,包含请求地址、请求方式、参数、返回值】;
2. 技术栈:【Axios + TS】;
3. 输出内容:
   - 完整的接口请求函数封装(包含请求拦截、响应拦截、错误处理);
   - 所有接口参数和返回值的 TS 类型定义;
   - Mock 数据生成(用于本地调试);
   - 接口调用示例;
4. 代码符合项目规范,可直接导入项目使用。

Prompt 10:测试用例/工程化配置(复制即用)

帮我生成【组件/函数】的测试用例,或【工程化配置文件】,要求:
1. 目标:【如:为登录组件写单元测试、生成 ESLint 配置、生成 Vitest 配置、生成 Dockerfile】;
2. 技术栈:【Vitest/Jest/ESLint/Docker】;
3. 具体要求:【如:测试用例覆盖渲染、交互、边界情况;配置文件适配 Vue3/React + TS 项目,包含常用配置】;
4. 输出完整的代码/配置文件,可直接复制到项目中使用,并给出配置说明和使用方法。

关键提醒:这 3 个小技巧,让 Prompt 效果再翻倍

  1. 越具体,AI 越精准:不要说“帮我写个表单”,要明确技术栈、功能、样式,甚至是兼容要求,避免 AI 瞎编;
  2. 分场景使用:不同的 AI 工具(Copilot/Cursor)适配性略有差异,但以上 Prompt 全部通用,复制后可根据工具微调;
  3. 善用追问:如果 AI 输出不符合预期,直接追问“修改一下,让组件支持自定义颜色”“修复这个代码里的语法错误”,不用重新发指令。

写在最后:AI 提效,Prompt 是关键

2026 年的前端开发,拼的不是手速,是“用 AI 的能力”——同样是用 AI,会写 Prompt 的人,每天能多省 1-2 小时,少加很多班;不会写的人,反而被 AI 拖累。

以上 10 个 Prompt,覆盖了前端开发的全场景,不管你是新手还是资深前端,复制就能用,不用自己琢磨、不用记复杂语法。

建议你 收藏本文,转发给身边还在瞎用 AI、天天加班的前端同事,一起省时间、提效率、早下班。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端进阶之路:从性能优化到响应式布局的实战指南(Tailwindcss)

前端进阶之路:从性能优化到响应式布局的实战指南(Tailwindcss)

在现代前端开发的宏大叙事中,我们往往容易迷失在纷繁复杂的框架和库中。然而,剥开React、Vue或Tailwind CSS的外衣,其核心往往回归到对DOM操作的深刻理解、对性能的极致追求以及对用户体验的细腻把控。今天,我们将串联起三个看似独立却内在逻辑紧密相连的知识点:原生DOM操作的性能基石——DocumentFragment,React组件化开发的“隐形斗篷”——Fragment,以及现代CSS布局的利器——Tailwind CSS的响应式哲学。

一、性能的基石:理解DocumentFragment

在JavaScript直接操作DOM的时代,性能优化是一个绕不开的话题。浏览器渲染页面的过程是昂贵的,每一次DOM的增删改查都可能触发重排和重绘。让我们通过两段代码的对比,来窥探原生性能优化的奥秘。

假设我们需要向页面中的一个容器内插入两个段落元素。

直接追加的“笨重”方式:

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

container.appendChild(p1); // 触发一次重排/重绘
container.appendChild(p2); // 再次触发重排/重绘

这种方式虽然直观,但效率极低。每执行一次appendChild,浏览器就需要重新计算样式、布局,并更新画面。如果你需要插入成百上千个节点,页面就会出现明显的卡顿甚至闪烁。

使用DocumentFragment的“聪明”方式:

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

const fragment = document.createDocumentFragment(); // 创建内存中的碎片
fragment.appendChild(p1); // 内存操作,无渲染消耗
fragment.appendChild(p2); // 内存操作,无渲染消耗
container.appendChild(fragment); // 一次性插入,仅触发一次重排/重绘

DocumentFragment就像一个存在于内存中的“隐形容器”。它不属于DOM树,因此对它的操作不会触发页面的渲染更新。我们可以把所有的子节点先组装到这个碎片中,最后一次性将其内容“倾倒”进真实的DOM树。这就像搬家时,与其每拿一个箱子就跑一趟新车,不如先把所有箱子装上一辆大卡车(Fragment),然后一次性运达目的地。这种批量操作的思维,是现代前端性能优化的原点。

二、React的“隐形斗篷”:Fragment组件

随着React等声明式框架的普及,我们不再直接操作DOM,但DocumentFragment的思想在React中以另一种形式得到了升华——那就是Fragment组件。

在React中,组件的render函数或函数组件本身必须返回一个单一的根元素。这是由React内部虚拟DOM树的协调机制决定的。

痛点场景:

//  错误写法:返回了多个根节点,React会报错
function MyComponent() {
  return (
    <h1>标题</h1>
    <p>内容</p>
  );
}

为了解决这个问题,新手往往会用一个无意义的div包裹起来:

// ️ 不理想的写法:引入了多余的DOM节点
function MyComponent() {
  return (
    <div>
      <h1>标题</h1>
      <p>内容</p>
    </div>
  );
}

这种做法虽然能跑通,但会带来“DOM污染”。多余的div会破坏CSS布局(比如Flexbox的父子关系),增加DOM树的深度,甚至导致HTML结构语义错误(例如在table的tr中插入div)。

Fragment的解决方案:

React提供了Fragment(简写为<>...</>),它就像一个“隐形斗篷”。它满足了React对单一根节点的要求,但在最终生成的HTML中,它自身会消失,只留下它的子元素。

//  完美写法:使用Fragment,DOM结构纯净
function MyComponent() {
  return (
    <>
      <h1>标题</h1>
      <p>内容</p>
    </>
  );
}

在列表渲染中,Fragment更是不可或缺。它允许我们将一组相关的元素(如术语dt和描述dd)组合在一起,而不破坏父级列表的结构。

// 在列表中组合多个元素,保持语义化
{items.map(item => (
  <Fragment key={item.id}>
    <dt>{item.term}</dt>
    <dd>{item.description}</dd>
  </Fragment>
))}

虽然React的Fragment和原生的DocumentFragment在实现机制上有所不同(前者是虚拟DOM层面的概念,后者是真实DOM API),但它们的精神内核是一致的:高效组织节点,避免冗余,拒绝不必要的渲染开销。

三、布局的艺术:Tailwind CSS与移动端优先

当我们构建好纯净的DOM结构后,如何高效地为其赋予样式?Tailwind CSS提供了一种原子化的解决方案,而“移动端优先”则是其响应式布局的核心哲学。

让我们看一段典型的Tailwind代码:

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

这段代码精妙地展示了如何适配不同设备。

第一阶段:移动端(默认样式) 当我们在手机上(屏幕宽度小于768px)浏览时,Tailwind会忽略所有带前缀(如md:)的类名。

  • flex flex-col:容器启用Flex布局,且方向为垂直堆叠。
  • gap-4:元素间保持间距。 此时,主内容和侧边栏是上下排列的单列布局,非常适合手机阅读。

第二阶段:PC端(md:断点生效) 当屏幕变宽(≥768px),md:前缀的样式被激活。

  • md:flex-row:布局方向瞬间切换为水平排列。
  • md:w-2/3md:w-1/3:主内容占据2/3宽度,侧边栏占据1/3。 页面平滑地过渡为经典的两栏布局。

这种“先写死移动端,再修饰大屏”的策略,避免了复杂的媒体查询嵌套,让响应式逻辑变得清晰可见。它要求开发者首先关注核心内容的呈现(移动端),然后再考虑在大屏幕上如何利用富余空间(PC端),这是一种非常健康的设计思维。

结语

从原生JavaScript中利用DocumentFragment减少重排,到React中使用Fragment保持DOM树的语义化和纯净,再到利用Tailwind CSS的原子类快速构建响应式布局,这三者共同构成了一个现代前端开发者的核心素养。

它们分别解决了结构、逻辑和表现层面的关键问题:

  • DocumentFragment教会我们敬畏浏览器的渲染性能
  • React Fragment教会我们追求代码结构的逻辑纯净
  • Tailwind CSS教会我们以高效的方式驾驭复杂的UI设计

掌握这些工具背后的原理,而不仅仅是语法,才是通往高级前端工程师的必经之路。

前端面试必问 Git 通关指南:常用命令速查 + merge/rebase 深度辨析,看完再也不慌

本文面向前端面试场景,覆盖从日常开发高频命令,到面试官必问的核心原理辨析,所有内容均来自面试实战考点,无冗余废话,面试前刷一遍直接通关。

开篇前言

Git 是前端开发的必备工具,也是几乎所有公司一面必问的基础考点。但很多同学日常开发只会用add/commit/push/pull,一被问到mergerebase的区别、代码回滚方案、分支管理规范就卡壳。

本文基于我备战前端实习的面试笔记整理,先补全优化前端开发必背的全量高频命令,再深度拆解面试最高频的merge vs rebase考点,最后补充面试常问的附加题,帮大家彻底搞定 Git 面试。


一、前端开发 & 面试必背 Git 常用命令大全

我将所有命令按开发场景做了模块拆分,保留了基础核心用法,同时补全了面试常考的进阶参数和注意事项,可直接当作面试速查手册。

1. 仓库初始化与远程关联

表格

命令 核心作用 面试注意事项
git init 在当前目录初始化一个全新的 Git 本地仓库 初始化后会生成.git隐藏目录,存储 Git 所有的版本和配置信息
git clone <远程仓库地址> 克隆远程仓库到本地,自动完成远程关联 面试常考:支持 HTTPS 和 SSH 两种地址,SSH 需提前配置密钥
git remote add origin <远程仓库地址> 给本地仓库关联远程仓库,origin 是远程仓库的默认别名 必用场景:本地 init 的仓库首次推送到远程前,必须先执行此命令
git remote -v 查看当前仓库关联的远程仓库地址详情 排查远程仓库配置问题的核心命令

2. 分支管理核心命令(面试超高频)

表格

命令 核心作用 面试注意事项
git branch 查看所有本地分支,带*的是当前所在分支 基础必背,面试官常以此为起点延伸分支相关问题
git branch -r 查看所有远程分支
git branch -a 查看所有分支(本地 + 远程)
git checkout <分支名> 切换到已存在的本地分支 高频快捷操作:git checkout - 一键切换到上一个分支
git checkout -b <新分支名> 创建并立即切换到新分支 等价于git branch <新分支名> + git checkout <新分支名>,开发最高频用法
git checkout -b <新分支名> <起点> 基于指定起点(远程分支 / 历史提交 / 标签)创建新分支 示例:git checkout -b hotfix/v1.0 origin/main,面试常考场景化用法
git branch -d <分支名> 安全删除本地分支 仅能删除已经合并到当前分支的分支,未合并的分支会报错,防止误删代码
git branch -D <分支名> 强制删除本地分支 无论分支是否合并,都会直接删除,仅用于废弃的功能分支,面试常问-d-D的区别
git push origin --delete <远程分支名> 删除远程分支

补充:Git 2.23+ 官方推出语义更清晰的git switch替代checkout的分支操作功能,面试可提:

  • 切换分支:git switch <分支名>
  • 创建并切换新分支:git switch -c <新分支名>解决了checkout功能过载、容易误操作的问题。

3. 代码提交与暂存核心命令

表格

命令 核心作用 面试注意事项
git status 查看当前工作目录和暂存区的状态,显示未跟踪、已修改、已暂存的文件 开发必用,切换分支、提交代码前建议必执行,避免误操作
git add <文件路径/文件名> 将指定文件的修改 / 新增添加到暂存区
git add . 将当前目录所有修改、新增的文件添加到暂存区 面试常考:不会处理已删除的文件,仅新增和修改
git add -u 仅将已跟踪文件的修改、删除添加到暂存区,不包含新增文件 高频场景:只想提交已有文件的改动,不想提交新增的临时文件
git commit -m "提交描述信息" 将暂存区的内容提交到本地仓库,生成一条提交记录 核心要求:提交信息必须语义化,面试常问提交规范
git commit -am "提交描述信息" 等价于git add -u + git commit -m,一步完成已跟踪文件的提交 注意:对新增的未跟踪文件无效
git commit --amend 修改上一次的提交信息,或补充漏提交的文件,不会生成新的提交 面试高频考点:仅适用于未推送到远程的本地提交,已推送的提交修改后会重写历史,需要强制推送,有极高风险

4. 代码拉取与推送核心命令

表格

命令 核心作用 面试注意事项
git push 将本地当前分支的提交推送到已关联的远程分支 首次推送必须加-u参数设置上游分支:git push -u origin <分支名>,后续可直接用git push
git push -f 强制推送本地分支覆盖远程分支 高危操作!仅能在自己的私有分支使用,绝对禁止在公共分支执行,会直接覆盖远程历史,导致团队代码丢失
git pull 拉取远程当前分支的最新代码,并自动合并到本地分支 面试核心考点:git pull = git fetch + git merge,默认用 merge 方式合并,会生成合并提交
git pull --rebase 拉取远程最新代码,并用 rebase 方式合并到本地分支 多人协作高频用法,避免生成多余的合并提交,保持本地历史线性
git fetch 仅拉取远程仓库的所有最新提交到本地,不会自动合并 面试常问和git pull的区别:更安全,可先查看远程改动,再手动决定是否合并,不会直接修改本地工作区

5. 临时存储 stash 全命令(面试高频)

表格

命令 核心作用 面试注意事项
git stash 将当前分支未提交的改动(工作区 + 暂存区)临时保存到堆栈中,清空工作区 高频场景:正在开发功能,突然需要切换分支改 bug,又不想提交半成品代码
git stash save "存储备注信息" 给 stash 记录添加备注,方便后续识别 多个 stash 记录时必用,避免分不清存储的内容
git stash list 查看堆栈中所有的 stash 存储记录
git stash pop 恢复堆栈中最新的一条 stash 记录,并删除该条记录 恢复后会自动从堆栈中移除,对应git stash的反向操作
git stash apply 恢复最新的 stash 记录,但不会从堆栈中删除 场景:需要把同一份改动恢复到多个分支
git stash drop 删除堆栈中最新的一条 stash 记录
git stash clear 清空堆栈中所有的 stash 记录

6. 提交历史查看命令

表格

命令 核心作用 面试注意事项
git log 查看当前分支的完整提交日志记录,包含提交哈希、作者、时间、提交信息
git log --oneline 一行显示一条提交记录,仅展示简短提交哈希和提交信息 高频用法,快速查看提交历史,面试必提
git log --graph 图形化展示分支的合并历史和分叉情况 配合--oneline使用效果极佳,直观看到分支合并轨迹
git log -p 查看提交日志的同时,显示每次提交的具体代码改动内容 排查问题、代码溯源高频用法

7. 工作区修改撤销与文件恢复

表格

命令 核心作用 面试注意事项
git checkout -- <文件路径/文件名> 用暂存区的版本覆盖工作区的文件,撤销未暂存的修改 ⚠️ 高危操作:修改不可逆,本地未暂存的改动会永久丢失
git checkout -- . 撤销当前目录所有未暂存的修改
git checkout <提交哈希/分支名> -- <文件路径> 用指定提交 / 分支的文件版本,覆盖当前工作区和暂存区的对应文件 场景:恢复某个文件到历史版本,不影响其他文件

补充:Git 2.23+ 官方推出git restore替代checkout的文件恢复功能,语义更清晰:

  • 撤销工作区未暂存修改:git restore <文件名>
  • 撤销暂存区的修改:git restore --staged <文件名>

二、前端面试最高频考点:git merge vs git rebase 深度辨析

这是 Git 面试的必问题,90% 的面试官都会问到,很多同学只能答出 “一个会生成合并提交,一个不会”,但想要拿到高分,必须从原理、区别、优缺点、场景、禁忌全维度讲透。

1. 核心相同点

git mergegit rebase核心目标完全一致:将一个分支的代码变更,整合到另一个分支中,是 Git 中最核心的两种分支合并方案。

2. 核心原理(面试答题先讲原理,直接拉开差距)

我们用一个最常见的开发场景举例:

主分支main有提交记录 A→B→C,我们从C切出功能分支feature开发,提交了D→E;此时main分支有了新的提交F→G,现在需要把main的最新代码合并到feature,或者把feature合并到main

git merge 原理

git merge采用三方合并策略,执行git merge feature时会做 3 件事:

  1. 找到两个分支的最近共同祖先 C
  2. 基于共同祖先,将两个分支的变更做三方合并对比;
  3. 最终生成一个全新的合并提交 H,这个提交有两个父提交,分别指向两个分支的最新提交GE,同时完整保留两个分支的所有原始提交历史。

最终合并后的main分支历史:A→B→C→F→G→H(合并提交)feature分支的D、E提交完整保留,时间线是分叉的。

git rebase 原理

rebase直译是变基,核心是改变分支的基准,执行git rebase main时会做 4 件事:

  1. 找到两个分支的最近共同祖先 C
  2. 提取feature分支上从C之后的所有提交D、E,临时保存起来;
  3. feature分支的基准指针,直接指向main分支的最新提交G
  4. 按顺序将临时保存的D、E,逐个重放应用到新的基准G上,生成新的提交D'、E'

最终变基后的feature分支历史:A→B→C→F→G→D'→E',形成了完全线性的提交记录,没有任何合并提交,原始的D、E提交会被废弃,提交历史被重写。

3. 全维度对比表(面试分点答,逻辑拉满)

表格

对比维度 git merge git rebase
核心逻辑 三方合并,生成全新的合并提交 变基重放,逐个应用提交,重写提交历史
提交历史 完整保留所有分支的原始提交,时间线分叉,上下文完整 重写提交历史,形成线性记录,无多余合并提交,原始上下文丢失
冲突处理 合并时仅需解决 1 次冲突,解决后生成合并提交即可,成本极低 变基过程中,每个提交重放时都可能产生冲突,需要逐个解决,提交越多成本越高
操作安全性 极高,不会修改现有提交历史,所有操作都有记录可追溯,不会丢失代码 高危,会重写提交历史,操作失误极易丢失提交,可通过git reflog恢复,但有门槛
代码溯源 完整的合并轨迹,可精准定位 bug 是哪个分支、哪次提交引入的,排查问题效率高 提交历史被重写,原始提交的上下文丢失,问题溯源难度大幅提升
公共分支兼容性 完全兼容,是公共分支合并的标准方案 绝对禁止在公共分支使用,会导致团队成员分支历史不一致,引发灾难性冲突

4. 优缺点详解

git merge 优缺点

✅ 优点:

  1. 操作简单、上手门槛低,符合 Git 分布式设计的初衷,全程安全无风险;
  2. 完整保留所有分支的开发上下文和提交历史,方便后续代码审计、问题回溯、版本回滚;
  3. 冲突处理简单,仅需解决一次冲突,不会出现重复处理冲突的情况;
  4. 支持快进合并(Fast-Forward),当目标分支无新提交时,可直接移动分支指针,无需生成合并提交。

❌ 缺点:

  1. 多人协作频繁合并时,会产生大量的合并提交,导致提交历史分叉严重,可读性变差;
  2. 对于追求简洁线性提交历史的团队,多余的合并提交会显得冗余,不利于版本管理。

git rebase 优缺点

✅ 优点:

  1. 最终会形成干净、无分叉的线性提交历史,提交日志可读性极强,方便版本迭代回溯;
  2. 支持交互式变基git rebase -i,可在合并前整理本地提交(合并零散提交、修改提交信息、删除无用提交),让提交记录更规范;
  3. git pull --rebase拉取远程代码,可避免生成多余的合并提交,保持本地分支的线性历史。

❌ 缺点:

  1. 操作风险高,重写提交历史的操作不可逆,一旦失误极易丢失代码;
  2. 冲突处理成本高,多个提交重放时需要逐个解决冲突,重复操作多;
  3. 重写历史后,原始提交的上下文丢失,出现问题时很难精准定位 bug 引入的节点;
  4. 在公共分支使用会给团队带来灾难性后果,所有成员都需要强制同步重写后的历史,极易出现代码丢失、冲突爆炸。

5. 最佳实践 & 使用场景(面试必答,体现你的实战经验)

git merge 推荐使用场景

  1. 将功能分支合并到公共主分支(main/master、develop)时,必须使用 git merge,建议搭配--no-ff参数(禁用快进合并),强制生成合并提交,完整保留分支合并的上下文,方便后续追溯和回滚;
  2. 多人协作的公共分支之间的合并,保证所有团队成员的提交历史一致,不会出现历史混乱;
  3. 合并到上线分支、生产分支时,必须使用 merge,保证所有操作可追溯,出问题可快速回滚;
  4. 需要完整保留分支开发上下文,用于代码审计、合规检查的场景。

git rebase 推荐使用场景

  1. 本地私有功能分支的提交历史整理,比如自己开发的 feature 分支,在合并到公共分支之前,用git rebase -i HEAD~n整理零散的提交,让提交记录语义化、规范化;
  2. 本地分支拉取远程公共分支的最新代码时,用git pull --rebase代替默认的git pull,避免生成多余的合并提交,保持本地分支的线性历史;
  3. 给开源项目提交 PR/MR 时,绝大多数开源项目要求提交历史是线性的,需要用 rebase 基于上游最新分支整理提交,避免合并冲突和冗余的合并提交;
  4. 个人独立开发的项目,想要保持干净的线性提交历史,可自由使用 rebase。

6. 黄金法则(面试答出来直接加分)

永远不要在已经推送到远程的公共分支上,执行 git rebase 操作!

公共分支是所有团队成员的开发基准,你 rebase 之后会重写公共分支的提交历史,其他成员的本地分支还是基于原来的历史,当他们拉取代码时,会出现两个版本的历史,合并后会产生大量重复的提交和无法解决的冲突,最终导致代码仓库的历史彻底混乱,甚至丢失核心代码。


三、前端面试 Git 高频附加题

除了核心的 merge/rebase,这些考点也是面试官常问的,补充在这里帮大家全面通关:

1. git reset 和 git revert 的区别?

核心区别:是否重写提交历史,是否可逆

  • git revert:生成一个新的提交,反向撤销目标提交的所有改动,不会修改现有提交历史,安全,适合公共分支的代码回滚,所有操作可追溯;
  • git reset:直接移动分支指针,删除目标提交之后的所有提交,会重写提交历史,分为--soft(保留改动到暂存区)、--mixed(默认,保留改动到工作区)、--hard(彻底丢弃所有改动,高危),仅适合本地私有分支的回滚,绝对禁止在已推送的公共分支使用。

2. 什么是分离头指针(detached HEAD)?有什么风险?

  • 定义:执行git checkout <提交哈希/标签名>时,HEAD 指针不再指向任何一个命名分支,而是直接指向一个具体的提交记录,此时就进入了分离头指针状态;
  • 风险:此状态下的提交,属于匿名分支上的提交,一旦切换到其他分支,这些提交会被 Git 的垃圾回收机制清理,极易丢失;
  • 解决方案:如果需要在此状态下保留修改,立即执行git checkout -b <新分支名>,创建新分支保存这些提交。

3. .gitignore 文件不生效怎么办?

  • 根本原因:.gitignore只能忽略未被跟踪的文件,如果文件已经被提交到 Git 仓库,就不会被 ignore 规则匹配;

  • 解决方案:

    1. 先把本地需要忽略的文件备份,避免丢失;
    2. 执行git rm -r --cached .,清除所有文件的本地跟踪缓存;
    3. 重新执行git add .,此时.gitignore规则会生效,忽略指定文件;
    4. 提交修改到仓库即可。

4. 不小心把账号密码、密钥等敏感信息提交到 Git 仓库了,怎么办?

  • 第一步:立即修改敏感信息的密码 / 密钥,杜绝泄露风险;

  • 第二步:清理 Git 仓库中的敏感信息:

    • 如果是仅本地提交、未推送到远程:用git reset --soft HEAD~1回滚提交,修改后重新提交即可;
    • 如果已经推送到远程公共仓库:用git filter-repo(官方推荐)或 BFG 工具彻底清理历史记录,清理后需要强制推送重写历史;
  • 第三步:如果是开源仓库,建议联系平台仓库管理员,彻底清理缓存记录。


结尾总结

Git 作为前端开发的必备工具,面试考察的核心从来不是你背了多少命令,而是你是否理解每个操作背后的原理,是否知道不同操作的风险和最佳实践。

核心记住两点:

  1. 公共分支永远用merge,保证安全和可追溯;私有分支可以用rebase整理提交历史,保持简洁;
  2. 所有会重写提交历史的操作(rebasereset --hardcommit --amend),绝对不要用在已经推送到远程的公共分支上。

这篇文章整理了我备战前端实习面试的 Git 核心笔记,希望能帮到同样在找工作的同学。如果觉得有用,欢迎点赞、收藏、评论,我会持续更新前端面试的干货内容~

面试爱问底层时,我是怎么读大型前端源码的❓❓❓

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

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

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

image.png

网上类似的源码长文不少,我最近也在写 React 源码。作者往往写得尽兴、覆盖面也广,读者却不一定对得上自己的节奏:你想抠的那一点未必落在文章的主线上,而仓库一直在演进,成稿稍一搁置,对照现版就容易对不上号。

也正因如此,很多同学更倾向于亲自读源码。带着问题找答案,节奏和技术栈都更贴自己。

这篇想分享的是读大型前端开源项目(例如 ReactVueWebpackBabel)源码时怎么切入、怎么少迷路。目标很简单:授人以渔,让你在遇到新机制、底层实现或 Bug 时,能自己钻进去看清楚。

为什么读源码要先有问题

读之前先想清楚:为什么要打开仓库?

我的看法是,首要目的是解决实际问题。没有目标地"逛"仓库,像大海捞针,效率低也容易泄气。反过来,从一个具体问题出发,更容易把设计和实现串起来。

例如你可能会问:为什么在 React 里调用 setState 后,状态不会立刻改掉,而是走一批调度?这个问题会把你带到更新队列和调度相关代码上,比空读文件快得多。

如下图所示。

20260401084026

一图说清这条路径:先有问题,再定入口,沿调用栈往下跟,最后把理解收成自己的模型。

读新版别从第一个 commit 啃起

有人说从第一个 commit 顺着读能看懂演进。对极少数人可行,对大多数人来说性价比很低。以 React 为例,提交量极大,早期设计不少已废弃,啃旧代码对理解当下版本帮助有限。

更稳妥的做法是盯住当前主流版本:社区文章、视频、讨论多,卡住了好搜;API 和你项目里用的是同一世代;可以先读二手资料抓思路,再回仓库对细节。"资料 + 源码"比一行行硬读省时间。等你对现版熟了,再针对某个功能去翻 commit 和 PR,会更有数。

如下图所示。

20260401084233

一图把两种读法摆开:一种以手头在用的主版本为主线,资料帮着搭骨架,演进历史等站稳了再补;另一种是从第一个 commit 起顺序硬啃。取舍在哪,看图就明白。

读源码不是上来就梭哈

React 这种体量的仓库是进阶活。基础不够会越看越懵。建议先具备下面这些块,再往里钻(哪块弱就补哪块,本身也是正经学习)。

  • 语言:ES6+ 常用语法要熟,闭包、原型、异步和事件循环要真的用过,不然 Hooks 和调度相关代码很难读顺。
  • 框架:组件、propsstate、常用 HooksReact 18 里和并发、批更新、Suspense 相关的东西,至少用过再对照源码。
  • 调度直觉:时间片、优先级队列这类概念有个印象即可,读 FiberScheduler 时会轻松些。
  • 基础数据结构:树、链表、堆、 Diff 在干什么,知道个大概即可定位章节。
  • 浏览器与帧:FPSrequestAnimationFrame、为何怕长任务占主线程,有助于理解为什么要切片和中断渲染。

如下图所示。

20260401084431

该备的五块底子和边读边补哪弱补哪,下图一笔带过,正文就不摊开长清单了。

先把源码跑起来再说

第一步不是乱翻文件,而是按 READMECONTRIBUTING.md 把仓库构建起来、能断点。前端框架尽量用本地编出来的 development 包,别拿压缩过的生产包硬读。可以写最小 demo,或用 link 把本地包挂进项目里,贴近真实用法。CONTRIBUTING.md 里往往写了怎么跑测试、怎么编包,这部分本身就是读源码的序章。

如下图所示。

20260401084716

从克隆到能下断点的一圈步骤,对应正文不再逐句展开。

我那边的协同文档 Docflow 里也写了贡献说明和架构笔记,道理一样:先能构建、能跑,再谈读。

理清目录结构再看实现

大仓多是 Monorepo,packages/ 里一块一块职责分明。先当看地图,再进文件,不容易盲人摸象。

以 React 为例,常见分包包括:react(对外 API)、react-dom(对接 DOM)、react-reconciler(调和与更新)、scheduler(优先级与调度)、shared(公共工具)。心里有了这张表,搜到符号时才知道该进哪个包。

如下图所示。

20260401084924

React 各包各管一摊,读源码时先认准该进哪个包再翻文件,下面的版式把分工和这个习惯叠在一眼能扫开的地方。

如何调试 React 源码

调试前要会编开发包。示例流程:git clone React 仓库,yarn install,再 yarn build react/index,react-dom/index --type=NODE(或需要浏览器时用 --type=UMD_DEV)。更贴近日常的做法是建一个小项目,用 yarn link 把本地构建产物链进去。

搭建调试环境

具体命令随仓库文档变动,以官方 CONTRIBUTING.md 为准。下面只记思路:依赖装好、开发包产出、demo 或 link 接上。

如下图所示。

20260401085429

构建与 link、再在浏览器里下的那一套,和正文里的命令说明互补。

调试 useState 的执行流程

在业务组件里写一个最小 useState 示例。源码里对外声明多在 packages/react/src/ReactHooks.js,实现落在 packages/react-reconciler/src/ReactFiberHooks.jsmountStateupdateState。在几处入口加 debugger,重建后刷新页面,看调用栈:useStatedispatcher.useStatemountStateupdateState,再单步看链表与更新对象如何挂到 fiber 上。

调试 useEffect 的执行时机

useEffect 跨阶段更多:在 ReactFiberHooks.js 里看 mountEffectupdateEffect 如何在 render 阶段挂 effect,再到 ReactFiberCommitWork.js 里跟 commitLayoutEffectsflushPassiveEffects,能看清 passive 为何在布局后异步跑、为何不挡绘制。

调试技巧与注意事项

频繁触发的路径用条件断点(例如只在某个 fiber.type.name 上停)。if (__DEV__) 和纯告警逻辑可先跳过。Call StackScopeWatch 里盯住 fiber.memoizedStatefiber.updateQueue 等字段。双缓存时要分清当前在 current 还是 workInProgress 上,可配合 fiber.alternate 对照。

debugger 与全局搜索一起用

问题驱动的一个完整例子:搞清楚类组件里调用 setState 之后内部大致怎么走。先用全局搜索在 packages/react/src/ReactBaseClasses.js 找到入口,在本地加断点(下例仅示意,与仓库真实实现一致处请以你检出版本为准)。

// 示意:类组件 setState 入口会委托 updater(真实代码以仓库为准)
Component.prototype.setState = function (
  partialState: object | ((prev: any, props: any) => object) | null,
  callback?: () => void,
): void {
  this.updater.enqueueSetState(this, partialState, callback, "setState");
};

触发断点后跟栈,会进入 react-reconciler 里的 enqueueSetState、更新入队,再到 scheduleUpdateOnFiber 一类调度入口。下面用 Mermaid 收束主链路,细节仍靠你在关键函数上停。

20260401085625

Performance 面板适合观察并发下任务切片、长任务是否让出主线程;和源码里的时间片策略对照着看,比纯文字描述直观。

断点不要从入口无脑单步。beginWorkcompleteWorkcommitRoot、各生命周期与 Hooks 关键函数,按问题选挂。Node 工具链则多看插件注册与钩子调用处。主流程外的 __DEV__ 分支、冗长报错拼装,知道存在即可,不必逐行啃。

宏观上,React 一次更新可以粗分为 render(生成或调和 Fiber 树)与 commit(提交到 DOM)。render 里又可记 beginWork 向下、completeWork 向上;commit 里再分 beforeMutationmutationlayout 等子阶段。先记住这张骨架,再按需钻 reconcileChildrenflushPassiveEffects 之类细节。

如下图所示。

20260401085840

render 与 commit 的分段记忆图,和上面的 Mermaid 互补。

官方一手资料别浪费

维护者在博客、GitHub、演讲里解释"为什么这样设计"的句子,往往比第三手摘要靠谱。Issue 里长讨论、RFC 仓库里的提案与反对意见,都是源码的"旁白",代码告诉你是什么,这些文字告诉你为什么。按关键词搜 schedulerFiber、你关心的特性名,常能挖到设计取舍。

如下图所示。

20260401090038

博客、演讲、Issue、PR、RFC、发布说明,六类一手材料与"代码加为什么"的对照。

借助大模型但要自己验证

把难读的片段贴给模型,请它讲控制流和字段含义,能省大量初读时间。长 Issue、RFC 可先让模型摘要,再挑段落精读。仓库级助手(例如 Copilot 一类)适合问"谁调用了这个符号"。输出要当草稿,和本地断点、官方文档交叉验证,思维模型还是要自己搭。

如下图所示。

20260401090157

下面六道顺手用法之外,单独压一条硬底线:模型说得再顺,也要用本地断点和官方文档对一遍,不必在正文里一条条摊开。

总结

读大型源码,不必把全文再背一遍,抓住几条习惯就够把前面的方法串起来。

带着问题进门,版本对准你日常在用的主线,基础薄就先补。仓库能构建、能下断点,再谈细读。packages 当地图,先认包再走文件。debugger 配合全局搜索沿调用栈往下跟,官方讨论、Issue、RFC、发布说明补上代码里看不见的为什么。大模型可以加速梳理,最后一步仍要落回本机跑一遍,和官方文档对上。

如下图所示。

20260401090432

习惯之间的层次和先后,用上面这张比把前文再拉长复述更省事。

源码不玄,只是别人把取舍写进了可运行的形式里。节奏对了,会越读越轻。别指望一次吃透,那是慢功夫。路径熟了,换一套框架也能沿用同一套钻法。

🔥前端跨域封神解法:Vite Proxy + Express CORS,一篇搞定所有跨域坑!

🔥前端跨域封神解法:Vite Proxy + Express CORS,一篇搞定所有跨域坑!

全网最通俗跨域教程|前端 Vue/React 通用|后端仅 Express|开发 / 生产全覆盖

前言

做前端开发,跨域绝对是新手最崩溃的拦路虎!浏览器同源策略一拦,接口请求直接报错 No 'Access-Control-Allow-Origin',调试半天毫无头绪。

今天直接给你两套绝杀方案,全程只用到 Vite 代理 + Express 后端:✅ 本地开发用 Vite Proxy 代理(零后端改动,秒解决)✅ 线上生产用 Express CORS 配置(标准规范,永久生效)一文吃透,从此跨域再也不是问题!


一、先搞懂:到底什么是跨域?

浏览器同源策略:协议、域名、端口任意一个不同,就是跨域

举个例子:

  • 前端:http://localhost:5173(Vite 默认端口)
  • 后端:http://localhost:3000(Express 服务)端口不同 → 直接跨域,接口被浏览器拦截!

典型跨域报错:No 'Access-Control-Allow-Origin' header is present on the requested resource.


二、方案 1:本地开发神器 ✨ Vite Proxy 代理

核心原理

前端不直接请求后端,交给Vite 开发服务器做中间人转发,绕过浏览器同源限制,纯前端配置,后端零改动

完整配置(Vue / React 二选一)

1. Vue 版本
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  // Vue 编译插件
  plugins: [vue()],
  // 开发服务器配置
  server: {
    // 跨域代理核心配置
    proxy: {
      // 匹配所有 /api 开头的接口
      '/api': {
        target: 'http://localhost:3000', // Express 后端真实地址
        changeOrigin: true, // 🔥 关键:伪装来源,解决跨域
        pathRewrite: {
          '^/api': '' // 路径重写,前端 /api/login → 后端 /login
        }
      }
    }
  }
})
2. React 版本
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  // React 编译插件
  plugins: [react()],
  // 代理配置和 Vue 完全一致!
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        pathRewrite: {
          '^/api': ''
        }
      }
    }
  }
})

关键配置解读

  • target:Express 后端接口真实地址
  • changeOrigin: true:伪装请求来源,让后端认为是同源请求
  • pathRewrite:路径重写,简化前端接口书写

适用场景

仅限本地开发环境上线打包后代理失效,生产环境必须用 CORS!


三、方案 2:生产环境标配 🚀 Express CORS 配置

核心原理

后端在响应头中添加跨域允许规则,明确告诉浏览器:允许这个前端域名访问我的接口。

需要配置的三个核心响应头:

Access-Control-Allow-Origin: 允许的前端域名
Access-Control-Allow-Methods: 允许的请求方法
Access-Control-Allow-Headers: 允许的请求头

完整 Express 配置(直接复制可用)

// 1. 初始化项目:npm init -y
// 2. 安装依赖:npm install express cors
const express = require('express')
const cors = require('cors')
const app = express()

// 解析 JSON 请求体
app.use(express.json())

// 🔥 CORS 核心配置(生产环境必写)
app.use(cors({
  // 允许访问的前端域名(本地开发/线上替换即可)
  origin: 'http://localhost:5173',
  // 允许的请求方式
  methods: ['GET', 'POST'],
  // 允许的请求头
  allowedHeaders: ['Content-Type'],
  // 允许携带Cookie(登录场景必开)
  credentials: true
}))

// 测试接口
app.get('/user', (req, res) => {
  res.send({ 
    code: 200, 
    msg: '请求成功',
    data: { name: '前端开发者' } 
  })
})

// 启动 Express 服务
app.listen(3000, () => {
  console.log('Express 服务启动:http://localhost:3000')
})

极简原生写法(不依赖 cors 包)

如果不想安装第三方包,直接手动设置响应头:

const express = require('express')
const app = express()
app.use(express.json())

// 手动配置 CORS 响应头
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'http://localhost:5173')
  res.header('Access-Control-Allow-Methods', 'GET,POST')
  res.header('Access-Control-Allow-Headers', 'Content-Type')
  res.header('Access-Control-Allow-Credentials', 'true')
  next()
})

// 接口
app.get('/user', (req, res) => {
  res.send({ code: 200, msg: '请求成功' })
})

app.listen(3000)

适用场景

生产环境正式上线Express 专属标准解决方案,全网通用。


四、Proxy vs CORS 到底怎么选?

表格

方案 适用环境 优点 缺点
Vite Proxy 本地开发 零后端改动,配置简单 上线失效
Express CORS 生产环境 标准规范,永久生效 需要后端配置

最佳实践开发用 Proxy,上线用 CORS,两套方案无缝衔接!


五、高频踩坑总结

  1. changeOrigin: true 忘记写 → 跨域依然报错
  2. 路径重写错误 → 接口 404
  3. CORS 域名配置错误 → 线上依然跨域
  4. 开发 / 生产配置混用 → 线上接口异常
  5. 请求方式超出允许范围 → 预检请求失败

结语

跨域根本不是难题,只是没找对方法!Proxy 搞定开发,CORS 搞定生产,照着这篇配置,从此和跨域报错说拜拜~

需要完整 Demo 源码的小伙伴,评论区扣「跨域」直接发你!

💡 关注我,持续输出前端硬核干货,Vue/React/Express 一站式学习!

Map / Set / WeakMap / WeakSet,一次给你讲透

面试中经常被问:你了解 WeakMap / WeakSet 吗?
实际开发中也常有人困惑:我什么时候该用 Map,而不是 Object?Weak 到底弱在哪?

这篇文章,我会从最熟悉的 Object 讲起,一步步到 Map、Set,最后深入 WeakMap 和 WeakSet。

一、从 Object 说起:我们最熟悉,也最容易踩坑

在 JavaScript 里,对象几乎无处不在:

const person = { name: "张三" };
console.log(person.name); // 张三

for (const key in person) {
  console.log(key, person[key]); // name 张三
}

delete person.name;
console.log(person.name); // undefined

我们对 Object 已经非常熟悉了:

  • 可以通过 .[] 访问属性
  • 可以用 for...in 遍历
  • 可以用 delete 删除属性

但 Object 天生就不是为了「做集合」设计的。

一个真实的小坑

假设你想做一个“字典”,key 可以是任意值:

const obj = {};
const a = {};
const b = {};

obj[a] = 'A';
obj[b] = 'B';

console.log(obj); // { "[object Object]": "B" }

你以为是两个 key,实际上:

  • 对象的 key 只能是字符串或 Symbol
  • 非字符串会被隐式转换成字符串

这也是 Map 诞生的原因之一

二、Map:为“键值对集合”而生

可以把 Map 理解成:一个“升级版 Object”,但专门用来存键值对

1. 创建和添加数据

const map = new Map();
map.set('name', '张三');
map.set('phone', 'iPhone');

特点很明确:

  • set(key, value) 添加数据
  • key 可以是任意类型(对象、函数、基本类型)
  • 同一个 key 只会存在一份
map.set('phone', 'Galaxy'); // 覆盖

2. 读取、判断、长度

map.get('phone'); // Galaxy
map.has('phone'); // true
map.size; // 2

3. Map 是可迭代的

这是它和 Object 的一个重要区别

for (const [key, value] of map) {
  console.log(key, value);
}
// name 张三
// phone Galaxy

要仅获取键或值,还有一些方法可供使用

map.keys() // MapIterator {'name', 'phone'}
map.values() // MapIterator {'张三', 'Galaxy'}
map.entries() // MapIterator {'name' => '张三', 'phone' => 'Galaxy'}
map.forEach(item => {})

甚至可以直接展开:

[...map]; // [['name', '张三'],['phone', 'Galaxy']]

4. 删除与清空

map.delete('phone'); // true
// 清空所有
map.clear(); // Map(0) {}

三、WeakMap:真正让人迷惑的地方来了

WeakMap起源于Map,因此它们彼此非常相似。但是,WeakMap 具有很大的不同

弱?弱在哪里?

核心一句话

WeakMap 的 key 是“弱引用”,不会阻止垃圾回收

1. key 只能是对象

const wm = new WeakMap();
wm.set({}, 'data'); // ✅
wm.set('a', 1);    // ❌ TypeError

原因很简单:

  • WeakMap 的设计目标:绑定对象的“附加信息”
  • 如果 key 是基本类型,就谈不上 GC

2. 为什么不能遍历?

想象这样一个场景:

let user = { name: 'John' };
const wm = new WeakMap();
wm.set(user, 'meta');

user = null; // 断开引用

这时候:

  • 垃圾回收 随时可能发生
  • WeakMap 中的数据 可能突然消失

如果还能遍历,那结果就是不稳定的

所以 ES 规范直接规定:

  • ❌ 不可遍历
  • ❌ 没有 size
  • ✅ 只有 get / set / has / delete

3. WeakMap 的真实使用场景

一个非常经典的例子:

const wm = new WeakMap();

function process(obj) {
  if (!wm.has(obj)) {
    wm.set(obj, { count: 0 });
  }
  wm.get(obj).count++;
}
  • 不污染原对象
  • 对象销毁后,数据自动释放
  • 不会内存泄漏

这也是 WeakMap 最大的价值。

四、Set:只关心“值是否存在”

如果说 Map 是 Object 的替代品,

Set 更像是“升级版数组”

1. 成员唯一

const set = new Set();
set.add(1);
set.add(1);
set.add(NaN);
set.add(NaN);

结果:

Set { 1, NaN }

规则总结:

  • 基本类型:值相同 → 只存一个
  • 引用类型:地址相同 → 只存一个
  • NaN 在 Set 中被认为是“相等的”

2. 可遍历

for (const val of set) {}
set.forEach(val => {})

3. 实战:数组去重、交并差集

[...new Set([1,1,2,3])]; // [1,2,3]

Set 在这类场景下,简洁又高效

五、WeakSet:存在,但很低调

WeakSet 和 WeakMap 的理念是一样的:

  • 成员是对象
  • 成员是弱引用
  • 不可遍历
let obj = { a: 1 };
const ws = new WeakSet();
ws.add(obj);

obj = null; // 被 GC

你永远不知道它什么时候“少了一个成员”,

所以:

WeakSet 适合做“对象存在性标记”,而不是数据容器


六、一张表彻底记住它们

类型 key/value 限制 是否可遍历 GC 影响
Object key 只能是字符串 可遍历 强引用
Map key 任意 强引用
WeakMap key 只能是对象 弱引用
Set value 任意 强引用
WeakSet value 只能是对象 弱引用

如何一句话答 WeakMap / WeakSet区别

WeakMap / WeakSet 的核心在于“弱引用 + 不可遍历”,
它们不会阻止垃圾回收,适合存放与对象生命周期绑定的附加数据,用来避免内存泄漏。

如果这篇文章对你有帮助,欢迎点赞、收藏,

彻底吃透 Promise:从状态、链式到手写实现,再到 async/await 底层原理

彻底吃透 Promise:从状态、链式到手写实现,再到 async/await 底层原理

面试必考,源码必问,日常必用 —— Promise 是 JavaScript 异步编程的基石。本文带你完整梳理 Promise 的核心知识,并深入 async/await 的底层实现。

一、为什么需要 Promise?

在 Promise 出现之前,我们靠回调函数处理异步。回调模式有三个致命问题:

  1. 回调地狱:异步任务层层嵌套,代码横向发展(金字塔结构),难以阅读和维护。
  2. 错误处理混乱:每个回调必须单独处理错误,容易遗漏;try/catch 无法捕获异步回调中的异常。
  3. 并发组合困难:并行执行多个任务并在全部完成后执行逻辑,需要手动计数器,极易出错。
  4. 信任问题(控制反转):将回调交给第三方库后,无法保证它会被正确调用(次数、时机、参数等)。

Promise 应运而生,它通过状态机 + 链式调用 + 统一错误处理 + 组合工具,彻底改变了异步编程的体验。


二、Promise 核心概念速览

2.1 三种状态

  • pending(进行中):初始状态。
  • fulfilled(已成功):调用 resolve 后到达此状态,并拥有一个最终 value
  • rejected(已失败):调用 reject 后到达此状态,并拥有一个最终 reason

重要规则:状态一旦定型(settled)就不可再变,且只能从 pending 转换为 fulfilledrejected

2.2 链式调用

thencatchfinally返回一个新 Promise,从而实现链式。

  • then(onFulfilled, onRejected):接收成功/失败回调。返回值决定新 Promise 的状态:

    • 返回普通值 → 新 Promise 用该值 resolve
    • 返回 Promise → 新 Promise 的状态与该 Promise 一致。
    • 抛出异常 → 新 Promise 用该错误 reject
    • 如果 onFulfilledonRejected 不是函数,会发生值穿透(原值直接传递)。
  • catch(onRejected):语法糖 then(undefined, onRejected)

  • finally(onFinally):无论成功失败都会执行,不接收参数,返回值被忽略(除非回调内抛出异常或返回 rejected Promise,则会中断链并传递新错误)。适合做清理工作。

2.3 静态方法一览

方法 行为 典型场景
Promise.all 全部成功才成功,任一失败则立即失败 多个接口数据都成功后才渲染页面
Promise.allSettled 等待所有定型,永不失败;返回结果状态数组 记录所有任务结果,即使部分失败
Promise.race 最快定型的 Promise 胜出(成功或失败) 设置超时计时
Promise.any 最快成功的 Promise 胜出;全部失败才失败 多个备用接口,取最快成功的响应
Promise.resolve 包装值为 resolved Promise 将 thenable 转换为真正 Promise
Promise.reject 包装值为 rejected Promise 快速返回失败

三、面试高频考点:事件循环与微任务

理解微任务(microtask)是写出正确 Promise 代码的前提。

  • 宏任务setTimeoutsetInterval、I/O、UI 渲染。
  • 微任务Promise.then/catch/finallyqueueMicrotaskMutationObserver

执行顺序:当前宏任务 → 所有微任务 → 下一个宏任务

经典例题

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

解释:先执行同步代码(1,4),然后清空微任务队列(3),最后执行下一个宏任务(2)。


四、手写一个符合 Promise/A+ 规范的简化版 Promise

面试中常要求手写简易 Promise,核心包含:构造函数、thencatchresolvereject,支持异步与链式调用。

下面是一个符合规范的实现(重点注释):

class MyPromise {
  constructor(executor) {
    this.state = 'pending';   // 'fulfilled' | 'rejected'
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      if (this.state !== 'pending') return;
      this.state = 'fulfilled';
      this.value = value;
      this.onFulfilledCallbacks.forEach(fn => fn());
    };

    const reject = (reason) => {
      if (this.state !== 'pending') return;
      this.state = 'rejected';
      this.reason = reason;
      this.onRejectedCallbacks.forEach(fn => fn());
    };

    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    // 值穿透处理
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
    onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err; };

    const promise2 = new MyPromise((resolve, reject) => {
      const fulfilledMicrotask = () => {
        queueMicrotask(() => {
          try {
            const x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (err) {
            reject(err);
          }
        });
      };

      const rejectedMicrotask = () => {
        queueMicrotask(() => {
          try {
            const x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (err) {
            reject(err);
          }
        });
      };

      if (this.state === 'fulfilled') {
        fulfilledMicrotask();
      } else if (this.state === 'rejected') {
        rejectedMicrotask();
      } else if (this.state === 'pending') {
        this.onFulfilledCallbacks.push(fulfilledMicrotask);
        this.onRejectedCallbacks.push(rejectedMicrotask);
      }
    });

    return promise2;
  }

  catch(onRejected) {
    return this.then(null, onRejected);
  }

  static resolve(value) {
    if (value instanceof MyPromise) return value;
    return new MyPromise(resolve => resolve(value));
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }
}

// 辅助函数:处理 then 返回的 x(可能是普通值、Promise 或 thenable)
function resolvePromise(promise2, x, resolve, reject) {
  if (promise2 === x) {
    return reject(new TypeError('Chaining cycle detected'));
  }
  if (x && (typeof x === 'object' || typeof x === 'function')) {
    let called = false;   // 防止多次调用 resolve/reject
    try {
      const then = x.then;
      if (typeof then === 'function') {
        then.call(
          x,
          y => {
            if (called) return;
            called = true;
            resolvePromise(promise2, y, resolve, reject);
          },
          r => {
            if (called) return;
            called = true;
            reject(r);
          }
        );
      } else {
        resolve(x);
      }
    } catch (err) {
      if (called) return;
      called = true;
      reject(err);
    }
  } else {
    resolve(x);
  }
}

关键点说明

  • 使用 queueMicrotask 模拟原生 Promise 的微任务行为。
  • 支持异步:当状态为 pending 时将回调存入队列,等待 resolve/reject 后执行。
  • 支持链式:then 返回新 Promise,并通过 resolvePromise 解包返回值。
  • 实现值穿透、错误冒泡、循环引用检测。

五、深入理解 async/await 的底层原理

async/await 是 ES2017 引入的语法糖,其底层基于 Promise + 生成器(Generator)

5.1 生成器 + Promise 模拟 async/await

生成器函数可以暂停(yield)和恢复(next),并且可以向外部传递值。利用这一点,我们可以编写一个执行器来自动驱动生成器,每次遇到 yield 就等待 Promise 完成,然后将结果传回生成器继续执行。

以下是一个简化版的执行器 run

function run(generatorFn) {
  const gen = generatorFn();

  function handle(result) {
    if (result.done) return Promise.resolve(result.value);
    return Promise.resolve(result.value).then(
      value => handle(gen.next(value)),
      error => handle(gen.throw(error))
    );
  }

  try {
    return handle(gen.next());
  } catch (err) {
    return Promise.reject(err);
  }
}

// 使用示例
function fetchData(url) {
  return new Promise(resolve => setTimeout(() => resolve(`数据来自 ${url}`), 1000));
}

const genAsync = function* () {
  const data1 = yield fetchData('https://api.example.com/user');
  console.log(data1);
  const data2 = yield fetchData('https://api.example.com/orders');
  console.log(data2);
  return '完成';
};

run(genAsync).then(console.log);

这段代码的行为与 async/await 完全一致:

async function asyncFunc() {
  const data1 = await fetchData('https://api.example.com/user');
  console.log(data1);
  const data2 = await fetchData('https://api.example.com/orders');
  console.log(data2);
  return '完成';
}
asyncFunc().then(console.log);

5.2 编译转换(Babel 视角)

当使用 Babel 将 async/await 编译到 ES5 时,会将其转换为生成器 + 执行器(或 Promise 链)。例如:

// 源代码
async function foo() {
  const a = await bar();
  return a;
}

// Babel 简化输出(类似)
function foo() {
  return _asyncToGenerator(function* () {
    const a = yield bar();
    return a;
  })();
}

其中 _asyncToGenerator 就是一个类似于上面 run 的执行器。

5.3 总结:async/await 的本质

层级 实现机制
最上层 async/await 语法(开发者编写)
转译/编译层 转换为生成器 + 执行器 或 Promise 链
执行层 生成器的 yield 暂停能力 + Promise 的异步通知
底层运行时 微任务(Microtask) + 事件循环

因此,理解 async/await 的关键在于掌握:

  1. Promise 提供了异步结果的标准表示和组合能力。
  2. 生成器 提供了函数执行的可暂停、可恢复能力。
  3. 执行器 将两者粘合,自动处理 Promise 的完成和拒绝,驱动生成器继续执行。

这也解释了为什么 async 函数总是返回 Promise,以及 await 只能出现在 async 函数中——因为生成器模式需要外部执行器驱动,而 async 函数正是这个执行器的容器。


六、高频面试题精选(附解答要点)

1. Promise 有哪几种状态?状态之间如何转换?

  • 三种:pendingfulfilledrejected
  • 转换:pending → fulfilled(调用 resolve),pending → rejected(调用 reject)。状态一旦定型不可逆。

2. then 方法返回的是什么?如何实现链式调用?

  • 返回一个新 Promise。新 Promise 的状态由回调的返回值决定。通过返回新 Promise 实现链式。

3. 什么是 Promise 的“值穿透”?举例。

  • 如果 then 传入非函数,则忽略该参数,原值直接传递下去。
Promise.resolve(42).then(null).then(v => console.log(v)); // 42

4. finally 能改变返回值吗?

  • 不能。返回值被忽略,原 Promise 的值或原因会继续传递。除非 finally 回调抛出异常或返回 rejected Promise,则会传递新错误。

5. Promise.allPromise.allSettled 的区别?

  • all:全部成功才成功,任一失败则立即失败(短路)。
  • allSettled:等待所有定型,总是成功,返回每个结果的状态对象数组。

6. 如何捕获 Promise 链中的错误?

  • 使用链尾的 .catch(),它会捕获链中任何地方抛出的错误(包括 then 回调中抛出的错误)。

7. 简述 Promise 的实现原理(手写简化版)。

  • 状态机 + 回调队列 + then 返回新 Promise + 微任务调度。详见上文实现。

8. 什么是微任务?为什么 Promise 的回调是微任务?

  • 微任务在当前宏任务执行完毕后、下一个宏任务之前执行。Promise 回调设为微任务是为了让异步结果尽快被处理,同时保持顺序可预测。

9. async/await 的底层实现是什么?

  • 基于 Promise 和生成器(Generator)的语法糖。通过执行器自动驱动生成器,每次 yield 一个 Promise,等待完成后恢复执行。

10. 如何将 Node.js 回调风格 API 转换为 Promise?

  • 使用 util.promisify 或手动 new Promise 包装。

七、实战:使用 Promise.race 实现请求超时

function fetchWithTimeout(url, timeoutMs = 5000) {
  const fetchPromise = fetch(url).then(res => res.json());
  const timeoutPromise = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('请求超时')), timeoutMs)
  );
  return Promise.race([fetchPromise, timeoutPromise]);
}

// 使用
fetchWithTimeout('https://api.example.com/data', 3000)
  .then(data => console.log(data))
  .catch(err => console.error(err.message));

注意Promise.race 不会取消未完成的请求,但可以控制超时后的行为。如果需要真正取消请求,可结合 AbortController


八、总结

Promise 的出现统一了 JavaScript 的异步模式,解决了回调地狱、错误处理和组合难的问题。掌握 Promise 是理解现代前端异步编程的基石,而 async/await 则是在 Promise 之上的优雅语法糖,其底层依赖生成器与执行器。希望本文能帮助你彻底吃透 Promise,并在面试和实战中游刃有余。

如果觉得有帮助,欢迎点赞、收藏、评论交流!

从一道前端面试题,聊到朋友做实时通信时的心跳检测

大家好~这篇算是上一篇「前端倒计时不准怎么优化」的延伸。本来只是吃透一道面试题,结果发现同一个思路,居然能用到实时通信里,而且还是朋友做项目时真实踩过的坑,今天用大白话跟大家分享一下。

一、先快速回顾:那道面试题的核心

上一篇我们聊到:用 setInterval 做倒计时为什么不准?因为它是靠 “执行了多少次” 来计时,页面一卡、一切后台,定时器就会偷懒少跑,时间就偏了。

真正靠谱的方案是: 别靠次数,靠时间戳差值 不管定时器怎么延迟,用「目标时间 - 当前时间」算出来的结果永远是准的。

后来我发现,这个思路在WebSocket 心跳检测里简直是一模一样的用法。

二、WebSocket 到底是个啥?(人话版)

平时我们上网,都是浏览器问一句、服务器答一句,叫 HTTP。但像聊天、弹幕、实时数据这种场景,需要服务器主动推消息,HTTP 就不太合适了。

所以会用到 WebSocket:浏览器和服务器建立一条 “长连接”,一直保持通话,服务器有消息就直接推过来。 传感器那边一有新数据,服务器直接推给前端,前端不用傻傻地一遍遍问:“有新数据吗?有新数据吗?”

这就是实时通信

聊天、弹幕、股票、传感器数据,基本都靠它。

而且它不是插件、不是库,是浏览器原生自带的 API,直接写就能用。

三、用上 WebSocket 就万事大吉了?并没有

以为连上就完事,结果踩了一堆坑:

1. 连接会莫名其妙断掉

  • 网络假死

    • 连接表面还在,实际已经断了(WiFi 切换、路由器重启、弱网),WebSocket 不会自动感知。
  • 服务器踢人

    • 网关会自动断开 “长时间不说话” 的空闲连接,心跳就是用来 “刷存在感”。
  • 及时发现异常

    • 有时候断了前端都不知道,导致消息发不出去、用户体验极差。

2. 不知道连接到底还活不活着

网络有时候会 “假死”:看着连着,其实早就断了,前端还在傻傻等数据。

3. 断了之后不能自动重连

总不能让用户手动刷新页面吧?


四、解决办法:心跳检测 + 断线重连

这时候,最开始那道倒计时面试题的思路就用上了:

什么是心跳检测?

就像两个人打电话,每隔一会儿说一句:“我还在哦。”对方回:“我也在。”

  • 前端每隔几十秒发一个小包(心跳包)
  • 服务器收到后回复一下
  • 一段时间没回复,就认为连接挂了

先明确:WebSocket 自带心跳吗?

结论:不带!必须开发者自己写!

WebSocket 只负责建立连接、收发数据,心跳、保活、断线重连、超时判断,全都要自己写

这里刚好用到面试题的技巧:

不用 “定时器跑了多少次” 来判断超时,而是用:当前时间 - 最后一次收到回复的时间只要差值超过某个时间,就判定断开,直接重连。

完美复用了倒计时那套 “用时间差值,不靠次数” 的思想。

额外一个小细节:

浏览器切到后台、锁屏或休眠时,WebSocket 可能被系统冻结,表面不断开实则已失效。

可以在页面切回前台时,主动检查一次连接状态:

js

document.addEventListener('visibilitychange', () => {
  if (!document.hidden && ws) {
    // 回到前台,检查是否还在线
    if (!ws.isConnected) {
      ws.reconnect();
    }
  }
});

五、WebSocket 上线后还会遇到哪些难点?(深度拓展)

WebSocket 只解决了 “实时推送” 的基础问题,真正到生产环境落地,还会遇到一大堆工程化和稳定性的难点,我按开发→上线→运维的顺序,用大白话给大家拆解开,小白也能看懂:

1️⃣ 数据可靠性痛点

痛点 1:消息会丢失

场景:网络闪断瞬间,正在传输的传感器数据直接消失,用户看不到完整数据。详细解决方法

  1. 消息确认机制(ACK)

    • 前端发消息时,给每条消息加唯一 msgId,并启动一个超时定时器(比如 5 秒)。
    • 服务端收到后,必须回复 { type: 'ack', msgId: 'xxx' } 确认。
    • 前端如果在超时时间内没收到 ACK,就重新发送这条消息(最多重发 3 次,避免无限循环)。

js

// 封装一个完整的 WebSocket 客户端(带心跳 + 重连)
class WebSocketClient {
  // 构造函数:初始化所有配置
  constructor(url) {
    this.url = url; // WebSocket 服务端地址
    this.ws = null; // 存放 WebSocket 实例
    this.isConnected = false; // 标记是否连接成功

    // ==================== 心跳配置 ====================
    // 心跳发送间隔:3秒发一次
    this.heartBeatInterval = 3000;
    // 记录最后一次收到心跳回复的时间(核心:用时间戳判断)
    this.lastHeartBeatAckTime = Date.now();
    // 心跳定时器
    this.heartBeatTimer = null;

    // ==================== 重连配置 ====================
    this.reconnectTimer = null; // 重连定时器
    this.reconnectDelay = 3000; // 断开后 3 秒重连
  }

  // 初始化 WebSocket 连接
  connect() {
    this.ws = new WebSocket(this.url);

    // ==================== 连接成功触发 ====================
    this.ws.onopen = () => {
      console.log("✅ WebSocket 连接成功");
      this.isConnected = true;
      this.startHeartBeat(); // 连接成功 → 立刻启动心跳
    };

    // ==================== 收到服务端消息 ====================
    this.ws.onmessage = (evt) => {
      const data = JSON.parse(evt.data);

      // 如果是心跳响应 → 更新最后收到心跳的时间
      if (data.type === "heartbeat_ack") {
        this.lastHeartBeatAckTime = Date.now();
        return;
      }

      // 普通业务数据(比如传感器/实时消息)
      console.log("📡 收到实时数据:", data);
    };

    // ==================== 连接断开触发 ====================
    this.ws.onclose = () => {
      console.log("🔌 连接断开,准备重连...");
      this.isConnected = false;
      this.stopHeartBeat(); // 断开 → 停止心跳
      this.reconnect(); // 自动重连
    };

    // ==================== 连接报错触发 ====================
    this.ws.onerror = (err) => {
      console.error("❌ 连接异常", err);
    };
  }

  // ==================== 心跳检测核心方法 ====================
  startHeartBeat() {
    this.heartBeatTimer = setInterval(() => {
      // 向服务端发送心跳包
      this.ws.send(JSON.stringify({ type: "heartbeat" }));

      // ==================== 重点:用时间差判断是否超时 ====================
      // 和倒计时面试题同一个思路:不用计数,用时间戳差值
      const now = Date.now();
      // 超过 2 个心跳周期没回复 → 判断断开
      if (now - this.lastHeartBeatAckTime > this.heartBeatInterval * 2) {
        console.log("💀 心跳超时,开始重连");
        this.close(); // 关闭旧连接
        this.reconnect(); // 触发重连
      }
    }, this.heartBeatInterval);
  }

  // 停止心跳
  stopHeartBeat() {
    clearInterval(this.heartBeatTimer);
  }

  // ==================== 断线自动重连 ====================
  reconnect() {
    // 防止重复重连
    if (this.reconnectTimer) return;
    this.reconnectTimer = setTimeout(() => {
      this.connect(); // 重新创建连接
      this.reconnectTimer = null;
    }, this.reconnectDelay);
  }

  // 关闭连接 + 清理心跳
  close() {
    this.ws?.close();
    this.stopHeartBeat();
  }
}

// ==================== 使用方式 ====================
// 创建客户端实例
const ws = new WebSocketClient("ws://localhost:8080/sensor");
// 启动连接
ws.connect();

我们把整个流程拆成「正常运行」和「异常断连」两个场景,用大白话描述:

场景 1:正常连接 & 心跳保活

  1. 建立连接:前端调用 connect(),和服务端建立 WebSocket 连接,连接成功后触发 onopen

  2. 启动心跳:连接成功后立刻调用 startHeartBeat(),开启一个每 3 秒执行一次的定时器。

  3. 发送心跳:定时器每 3 秒向服务端发送 {type: "heartbeat"} 心跳包。

  4. 服务端响应:服务端收到心跳后,回复 {type: "heartbeat_ack"} 心跳响应包。

  5. 更新时间戳:前端收到 heartbeat_ack 后,立刻更新 lastHeartBeatAckTime = 当前时间

  6. 超时判断:每次发心跳时,都会计算「当前时间 - 最后心跳响应时间」:

    • 如果差值 ≤ 6 秒(2 个心跳周期):说明连接正常,继续循环。
    • 如果差值 > 6 秒:说明服务端没回应,判定连接假死

场景 2:连接异常 & 自动重连

  1. 触发超时:连续 2 个心跳周期(6 秒)没收到 heartbeat_ack,判定连接断开。

  2. 关闭旧连接:调用 close() 主动关闭当前无效连接,同时停止心跳定时器。

  3. 触发重连:调用 reconnect(),等待 3 秒后(避免重连风暴)重新执行 connect()

  4. 重新连接:新的 connect() 尝试和服务端建立连接:

    • 连接成功:回到「正常连接 & 心跳保活」流程,继续发心跳。
    • 连接失败:触发 onclose,再次进入重连逻辑,直到连接恢复。

后续场景:

离线消息缓存

-   服务端给每个连接维护一个「待推送消息队列」,当客户端断开时,消息暂存队列。
-   客户端重连成功后,服务端先把队列里的未读消息全部推送过去,再推送新消息。

痛点 2:消息乱序 / 重复

场景:重连后消息顺序打乱,或者同一条消息被重复推送,导致页面展示错误。详细解决方法

  1. 消息序号 + 时间戳

    • 服务端推送消息时,必须带上自增 seq(序号)和 timestamp(时间戳)。
    • 前端维护一个 lastSeq 变量,只处理 seq > lastSeq 的消息,保证顺序。
  • lastSeq 是前端维护的一个变量,用来记录最后一次成功处理的消息序号

    • 初始值一般设为 0(表示还没处理过任何消息)
    • 每次处理完一条新消息,就把 lastSeq 更新为这条消息的序号 data.seq
    • 作用:记住 “我已经处理到哪条消息了”
  • if (data.seq > lastSeq)消息去重 + 保证顺序的核心判断逻辑:

    • data.seq:服务端推送过来的当前消息的序号(自增,比如 1、2、3、4...)

    • 条件 data.seq > lastSeq

      • ✅ 如果当前消息序号 大于 上次处理的序号 → 说明是新消息、顺序正确,可以渲染 / 处理
      • ❌ 如果当前消息序号 小于等于 上次处理的序号 → 说明是旧消息 / 重复消息 / 乱序消息,直接丢弃,不处理

    js

    let lastSeq = 0;
    ws.onmessage = (e) => {
      const data = JSON.parse(e.data);
      if (data.seq > lastSeq) {
        renderData(data); // 只渲染顺序正确的消息
        lastSeq = data.seq;
      }
    };
    
  1. 去重机制

    • 前端维护一个 Set 存储已处理的 msgId,收到消息先判断是否存在,存在则直接丢弃。

    js

    const processedMsgIds = new Set();
    ws.onmessage = (e) => {
      const data = JSON.parse(e.data);
      if (processedMsgIds.has(data.msgId)) return;
      renderData(data);
      processedMsgIds.add(data.msgId);
    };
    

痛点 3:大数据传不了

场景:WebSocket 单条消息有大小限制(通常 64KB 左右),传大文件 / 海量传感器数据会直接失败。详细解决方法

  1. 分片传输 + 前端重组

    • 把大数据拆成固定大小的分片(比如 16KB / 片),每个分片带上 chunkId(分片序号)、totalChunks(总分片数)、msgId(所属消息 ID)。
    • 前端收到所有分片后,按 chunkId 顺序拼接成完整数据。

    js

    // 前端分片重组示例
    const chunkMap = new Map(); // key: msgId, value: { chunks: [], total: number }
    ws.onmessage = (e) => {
      const chunk = JSON.parse(e.data);
      if (!chunkMap.has(chunk.msgId)) {
        chunkMap.set(chunk.msgId, {
            chunks: new Array(chunk.totalChunks), total: chunk.totalChunks 
        });
      }
      const entry = chunkMap.get(chunk.msgId);
      entry.chunks[chunk.chunkId] = chunk.data;
      
      // 所有分片都收到了,开始重组
      if (entry.chunks.every(c => c != null)) {
        const fullData = entry.chunks.join('');
        renderData(fullData);
        chunkMap.delete(chunk.msgId);
      }
    };
    

为什么 every(c => c!= null) 能代表接收完毕?

  • 这是 “前端分片重组” 的约定:
    • 背后有一个硬性前提(这是大文件上传 / 大消息传输的通用标准):

    • 服务端(后端)在发送分片时,必须按顺序编号!

为什么不会有空分片的情况?

1. 后端不会发空包
  • 在 “分片传输” 场景下,空的分片(null)是没有业务意义的。

    • 一个完整的大文件,被切分成了 10 块,每一块都有内容。
    • 后端不可能只发了 9 块,第 10 块发一个 null
    • 规则:每一个 chunkId 对应的,必须是一段真实的数据。
2. null 代表的是 “未收到”,不是 “空数据”

在这段代码里:

  • entry.chunks = new Array(chunk.totalChunks)

    • 这行先创建了一个空数组,长度是总片数。
    • 此时数组里全是 empty(空槽),但这还不是 null
  • 当收到第 0 片时,entry.chunks[0] = chunk.data

    • 这一格被填满了。
  • 如果网络丢包了:比如第 2 片没收到。

    • entry.chunks[2] 就永远是 empty(或者被你初始化为 null)。
    • 此时 every(c => c!= null) 就会返回 false
    • 代码就不会拼接,会继续等待,直到补全了第 2 片。

如果网络丢包了,前端必须要做的处理

你不能让它无限等下去,通常要加这些机制:

  • 超时机制:给每个分片集合设置一个等待超时时间(比如 30s),超时后主动抛出错误或重试。
  • 重传机制:检测到丢包后,向服务端请求重传丢失的分片。
  • 兜底策略:如果多次重传仍失败,给用户提示 “网络不稳定,部分内容加载失败”,而不是一直转圈。
  • 进度反馈:告诉用户当前已收到多少分片、还在等待哪几片,避免用户以为页面卡死。

js

// 超时后处理
if (isTimeout(entry)) { 
    if (retryCount < MAX_RETRY) { 
        retryCount++; requestMissingChunks(entry); // 重传丢失的分片 
    } else {
        showError("加载失败,请检查网络"); } return; 
    }
}

2️⃣ 业务与性能痛点

痛点 1:百万级连接扛不住

场景:上千个传感器同时连接,服务器内存暴涨、连接数过载,甚至崩溃。详细解决方法

  1. 服务端高性能框架

    • 用 Netty(Java)、Node.js Cluster、Go 等高性能框架,利用多核心 CPU 处理连接,避免单线程瓶颈。
    • 开启连接复用、内存池优化,减少每个连接的内存占用。
  2. 负载均衡 + 水平扩展

    • 用 Nginx 或云服务商负载均衡器,把连接分发到多台服务器。
    • 服务器之间通过共享存储(如 Redis)同步用户连接状态,实现水平扩容。

痛点 2:不知道消息推送给谁

场景:多个传感器分组、不同用户看不同设备数据,推送混乱、浪费资源。详细解决方法

  1. Pub/Sub(发布 - 订阅)模式

    • 把每个传感器 / 用户组抽象成一个频道(Channel)
    • 客户端连接后,订阅自己需要的频道(比如 sensor:temp:room1)。
    • 服务端只往有订阅者的频道推送消息,避免无效推送。
    • 可以用 Redis Pub/Sub、MQTT、Kafka 等现成组件实现。

    js

    // 前端订阅示例
    ws.send(JSON.stringify({ type: 'subscribe', channel: 'sensor:temp:room1' }));
    

痛点 3:前端页面卡顿

场景:传感器每秒推 100 条数据,前端频繁渲染 DOM 导致页面卡死、崩溃。详细解决方法

  1. Web Worker 处理数据

    • 把数据解析、计算逻辑放到 Web Worker 里,不和主线程抢资源,避免阻塞 UI 渲染。

    js

    // main.js-页面主线程
    // 主线程(页面)只负责渲染和收消息,所有耗时计算都扔给 Web Worker 去做,不让页面卡顿
    // 1. 创建一个后台工作线程
    const worker = new Worker('data-worker.js');
    
    // 2. 监听 Worker 算完后发回来的结果
    worker.onmessage = (e) => {
      renderData(e.data); // 只做一件事:渲染页面
    };
    
    // 3.  websocket 收到数据 → 直接扔给 Worker,不自己算
    ws.onmessage = (e) => {
      worker.postMessage(e.data); 
    };
    
    // data-worker.js -后台独立线程,专门算东西,不影响页面
    // 监听主线程发来的数据
    self.onmessage = (e) => {
      // 这里做耗时计算!!!
      const processedData = parseAndCalculate(e.data); 
    
      // 算完 → 发回给主线程
      self.postMessage(processedData);
    };
    
  2. 节流渲染

    • setTimeoutrequestAnimationFrame 做节流,比如 100ms 内只渲染一次最新数据。

    js

    let lastRenderTime = 0;
    let pendingData = null;
    ws.onmessage = (e) => {
      pendingData = JSON.parse(e.data);
      requestAnimationFrame(() => {
        const now = performance.now();
        if (now - lastRenderTime > 100) {
          renderData(pendingData);
          lastRenderTime = now;
        }
      });
    };
    

3️⃣ 安全与合规痛点

痛点 1:谁都能连,数据不安全

场景:未做身份验证,任何人都能连接窃取传感器数据。

详细解决方法

  1. Token 身份验证

    • WebSocket 握手时,在 URL 或 Header 里带上 Token(比如 wss://xxx.com?token=xxx)。
    • 服务端先校验 Token 有效性,无效则直接拒绝连接。

    js

    // 前端连接示例
    const ws = new WebSocket(
    `wss://xxx.com/sensor?token=${localStorage.getItem('token')}`
    );
    
  2. 细粒度权限控制

    • 服务端根据 Token 对应用户的权限,只允许订阅 / 发送自己有权限的设备数据,比如普通用户只能看自己的传感器,管理员才能看所有。

痛点 2:数据会被窃听、篡改

场景:明文传输时,数据在网络中可能被截获、修改。

详细解决方法

  1. 必须用 wss:// 协议

    • wss:// 是基于 TLS 加密的 WebSocket,和 https:// 一样,数据在传输过程中会被加密,防止窃听和篡改。
    • 绝对不要在生产环境用 ws://(明文)。
  2. 敏感数据额外加密

    • 对特别敏感的数据(比如用户隐私、设备核心参数),在发送前用 AES 等对称加密算法加密,接收后再解密,进一步提升安全性。

痛点 3:恶意攻击耗尽服务器资源

场景:攻击者建立大量虚假连接,或疯狂发送消息,导致正常设备无法接入。

详细解决方法

  1. 连接 / 频率限制

    • 限制单个 IP 最多只能建立 10 个连接,超过则拒绝。
    • 限制单个连接每秒最多发送 10 条消息,超过则断开连接。
  2. 消息大小限制

    • 服务端设置单条消息最大长度(比如 64KB),超过则直接丢弃,防止超大消息占用带宽。

4️⃣ 调试与监控痛点

痛点 1:出问题找不到原因

场景:断连、丢消息等问题很难复现,日志分散,排查效率极低。

详细解决方法

  1. 全链路追踪

    • 接入 OpenTelemetry 等工具,给每个连接、每条消息生成唯一 Trace ID,记录从客户端→服务端→数据库的完整调用链路。
    • 出问题时,通过 Trace ID 就能快速定位是哪一步出了问题。
  2. 消息日志留存

    • 服务端记录所有消息的收发日志(包含 msgIdseq、时间戳、发送 / 接收方),方便回溯问题发生时的上下文。

痛点 2:不知道服务运行状态

场景:服务器连接数、消息延迟、断连率等指标无监控,异常时无法及时发现。

详细解决方法

  1. 核心指标监控

    • 用 Prometheus + Grafana 监控以下指标:

      • 在线连接数
      • 消息吞吐量(条 / 秒)
      • 平均消息延迟(毫秒)
      • 断连率(断开连接数 / 总连接数)
      • 消息丢失率
  2. 告警规则配置

    • 当连接数突增 50%、延迟超过 200ms、断连率超过 10% 时,自动通过钉钉 / 企业微信 / 邮件通知运维人员。

痛点 3:环境不兼容,功能用不了

场景:旧浏览器(如 IE11)、特殊网络(如企业防火墙)不支持 WebSocket,用户无法使用功能。

详细解决方法

  1. 自动降级方案

    • 前端先检测浏览器是否支持 WebSocket,不支持则自动切换为 长轮询(Long Polling)

      js

      if (window.WebSocket) {
        // 用WebSocket
      } else {
        // 用长轮询:前端发请求,服务端hold住请求,有新数据时再返回,然后前端立刻发起下一次请求
        function longPoll() {
          fetch('/api/long-poll')
            .then(res => res.json())
            .then(data => {
              renderData(data);
              longPoll(); // 立刻发起下一次请求
            });
        }
        longPoll();
      }
      
  2. 友好 Fallback UI

    • 降级时给用户提示:「当前环境不支持实时通信,已切换为普通模式,数据每 30 秒自动刷新」,避免用户困惑。

六、最后聊聊

从一道倒计时面试题,意外挖到 WebSocket 心跳的通用思路,还挺有意思的。

很多时候我们觉得实时通信复杂,其实拆开看,无非就是:保证连接活着、保证消息不丢、保证页面不卡。

真正上线后你会发现,WebSocket 本身不难,难的是各种网络异常、弱网、断连、重复消息、卡顿……能把这些 “边角情况” 都兜住,才算一个能用在生产里的稳定方案。

如果你也在做聊天、大屏、传感器数据这类实时需求,欢迎在评论区说说你遇到过什么奇奇怪怪的坑,我们一起交流~

React 滚动效果:告别第三方库

滚动是 Web 上最基础的用户交互。随阅读进度填充的进度条、滑动后缩小并吸顶的导航栏、打开弹窗时锁定背后页面的滚动、点击按钮平滑跳转到指定区域——这些效果几乎出现在每个现代网站上。然而在 React 中正确实现它们,意味着你要同时处理 addEventListenerIntersectionObserveroverflow 样式以及一大堆意想不到的边界情况。大多数开发者要么引入一个沉重的动画库,要么花几个小时写出脆弱的命令式代码。

本文选择另一条路。我们将逐一攻克六个常见的滚动场景,每个场景先展示手动实现,让你理解底层原理,然后用 ReactUse@reactuses/core)中对应的 Hook 替换。ReactUse 是一个开源的 React Hook 集合,提供 100 多个封装了常见浏览器和元素交互的 Hook。读完之后,你将拥有一组可组合、SSR 安全的 Hook 工具箱,涵盖滚动追踪、滚动锁定、平滑滚动、吸顶检测、可见性检测和交叉观察——全程不需要任何外部动画或滚动库。

1. 追踪滚动位置

手动实现

追踪用户的滚动距离看起来很简单,但一旦要考虑节流、方向检测以及判断用户是否滚到了边缘,复杂度就上来了。

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

function ManualScrollTracker() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [scrollY, setScrollY] = useState(0);
  const [direction, setDirection] = useState<"up" | "down">("down");
  const lastY = useRef(0);

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

    const onScroll = () => {
      const y = el.scrollTop;
      setDirection(y > lastY.current ? "down" : "up");
      lastY.current = y;
      setScrollY(y);
    };

    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, []);

  const progress = containerRef.current
    ? scrollY /
      (containerRef.current.scrollHeight - containerRef.current.clientHeight)
    : 0;

  return (
    <div>
      <div
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          height: 4,
          width: `${progress * 100}%`,
          background: "#4f46e5",
          transition: "width 0.1s",
        }}
      />
      <div
        ref={containerRef}
        style={{ height: "100vh", overflow: "auto" }}
      >
        {/* 长内容 */}
      </div>
    </div>
  );
}

对于一个简单的进度条来说够用了,但它无法告诉你用户是否已经滚到底部,不支持横向滚动追踪,方向检测也很粗糙——惯性滚动中一个像素的反弹就会翻转方向。如果还要加上"到达边缘"的阈值判断,状态管理和计算量会更多。

用 useScroll

useScroll 返回当前的 xy 偏移量、双轴滚动方向,以及 isScrollingarrivedState 布尔值,后者会告诉你用户是否到达了上、下、左、右边缘。

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

function ScrollTracker() {
  const containerRef = useRef<HTMLDivElement>(null);

  const [position, direction, arrivedState, isScrolling] = useScroll(
    containerRef,
    { throttle: 50 }
  );

  const el = containerRef.current;
  const progress = el
    ? position.y / (el.scrollHeight - el.clientHeight)
    : 0;

  return (
    <div>
      {/* 进度条 */}
      <div
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          height: 4,
          width: `${Math.min(progress * 100, 100)}%`,
          background: "#4f46e5",
          zIndex: 50,
        }}
      />

      {/* 滚动信息浮层 */}
      <div
        style={{
          position: "fixed",
          bottom: 16,
          right: 16,
          padding: "8px 16px",
          background: "#1e293b",
          color: "#fff",
          borderRadius: 8,
          fontSize: 14,
          zIndex: 50,
        }}
      >
        <div>Y: {Math.round(position.y)}px</div>
        <div>方向: {direction.y ?? "无"}</div>
        <div>
          {arrivedState.bottom
            ? "已到达底部!"
            : isScrolling
              ? "滚动中..."
              : "空闲"}
        </div>
      </div>

      <div
        ref={containerRef}
        style={{ height: "100vh", overflow: "auto" }}
      >
        {Array.from({ length: 100 }, (_, i) => (
          <p key={i} style={{ padding: "8px 16px" }}>
            第 {i + 1} 段
          </p>
        ))}
      </div>
    </div>
  );
}

一次 Hook 调用就替代了所有手动事件绑定、方向追踪和边缘检测。内置的 throttle 选项保证即使在高频 scroll 事件下也能保持流畅。

2. 弹窗滚动锁定

手动实现

打开弹窗时,你需要阻止弹窗背后的页面继续滚动。经典做法是给 body 加上 overflow: hidden

import { useEffect, useState } from "react";

function ManualModal() {
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    if (isOpen) {
      const scrollY = window.scrollY;
      document.body.style.position = "fixed";
      document.body.style.top = `-${scrollY}px`;
      document.body.style.width = "100%";
      document.body.style.overflow = "hidden";

      return () => {
        document.body.style.position = "";
        document.body.style.top = "";
        document.body.style.width = "";
        document.body.style.overflow = "";
        window.scrollTo(0, scrollY);
      };
    }
  }, [isOpen]);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>打开弹窗</button>
      {isOpen && (
        <div
          style={{
            position: "fixed",
            inset: 0,
            background: "rgba(0,0,0,0.5)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            zIndex: 100,
          }}
        >
          <div
            style={{
              background: "#fff",
              padding: 24,
              borderRadius: 12,
              maxWidth: 400,
            }}
          >
            <h2>弹窗标题</h2>
            <p>背后的页面无法滚动。</p>
            <button onClick={() => setIsOpen(false)}>关闭</button>
          </div>
        </div>
      )}
    </>
  );
}

桌面浏览器上没问题,但 position: fixed 这个技巧在 iOS Safari 上会导致页面跳动——除非你小心保存和恢复滚动位置。它也没有处理多层弹窗叠加的情况。

用 useScrollLock

useScrollLock 帮你处理了所有这些边界情况。传入要锁定的元素引用(通常是 document.body)和一个控制锁定状态的布尔值。

import { useScrollLock } from "@reactuses/core";
import { useState } from "react";

function Modal() {
  const [isOpen, setIsOpen] = useState(false);

  useScrollLock(
    typeof document !== "undefined" ? document.body : null,
    isOpen
  );

  return (
    <>
      <button onClick={() => setIsOpen(true)}>打开弹窗</button>
      {isOpen && (
        <div
          style={{
            position: "fixed",
            inset: 0,
            background: "rgba(0,0,0,0.5)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            zIndex: 100,
          }}
        >
          <div
            style={{
              background: "#fff",
              padding: 24,
              borderRadius: 12,
              maxWidth: 400,
            }}
          >
            <h2>弹窗标题</h2>
            <p>滚动已锁定,试试滑动背后的页面。</p>
            <button onClick={() => setIsOpen(false)}>关闭</button>
          </div>
        </div>
      )}
    </>
  );
}

一行代码锁定滚动,组件卸载时自动解锁,SSR 环境下也安全无虞。滚动位置在所有浏览器上都能正确保留。

3. 平滑滚动到指定区域

手动实现

落地页上常见的"滚动到某区域"按钮,命令式的写法如下:

import { useRef } from "react";

function ManualScrollTo() {
  const sectionRef = useRef<HTMLDivElement>(null);

  const scrollToSection = () => {
    sectionRef.current?.scrollIntoView({
      behavior: "smooth",
      block: "start",
    });
  };

  return (
    <div>
      <nav style={{ position: "fixed", top: 0, padding: 16, zIndex: 10 }}>
        <button onClick={scrollToSection}>跳转到功能介绍</button>
      </nav>

      <div style={{ height: "100vh", background: "#f1f5f9" }}>
        <h1 style={{ paddingTop: 80 }}>首屏区域</h1>
      </div>

      <div ref={sectionRef} style={{ padding: 40 }}>
        <h2>功能介绍</h2>
        <p>功能详情…</p>
      </div>
    </div>
  );
}

scrollIntoView 对基本场景够用,但它无法控制缓动曲线、滚动轴和偏移量(当你有一个固定头部时,偏移量就很重要了)。同时也没有办法知道滚动动画何时完成。

用 useScrollIntoView

useScrollIntoView 提供了对滚动动画的精细控制,包括自定义时长、缓动函数、滚动轴、偏移量和完成回调。

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

function SmoothScrollPage() {
  const targetRef = useRef<HTMLDivElement>(null);

  const { scrollIntoView } = useScrollIntoView(targetRef, {
    duration: 800,
    offset: 80, // 为固定头部留出空间
  });

  return (
    <div>
      <nav
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          right: 0,
          height: 64,
          background: "#1e293b",
          display: "flex",
          alignItems: "center",
          padding: "0 24px",
          zIndex: 50,
        }}
      >
        <button
          onClick={() => scrollIntoView({ alignment: "start" })}
          style={{
            background: "#4f46e5",
            color: "#fff",
            border: "none",
            padding: "8px 16px",
            borderRadius: 6,
            cursor: "pointer",
          }}
        >
          跳转到定价
        </button>
      </nav>

      <div style={{ height: "150vh", paddingTop: 80 }}>
        <h1>首屏</h1>
        <p>向下滚动或点击上方按钮。</p>
      </div>

      <div ref={targetRef} style={{ padding: 40, background: "#eef2ff" }}>
        <h2>定价方案</h2>
        <p>详细的套餐和价格信息…</p>
      </div>

      <div style={{ height: "100vh" }} />
    </div>
  );
}

offset 选项确保目标区域出现在固定头部下方,而不是被遮挡。平滑滚动动画使用可配置的缓动函数,如果组件在滚动过程中卸载,Hook 也会正确清理。

4. 吸顶检测

手动实现

一个常见的交互模式是:当 header 吸顶后改变外观,比如加上阴影、缩小高度。手动检测需要借助 IntersectionObserver 和一个哨兵元素:

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

function ManualStickyHeader() {
  const sentinelRef = useRef<HTMLDivElement>(null);
  const [isStuck, setIsStuck] = useState(false);

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsStuck(!entry.isIntersecting);
      },
      { threshold: 0 }
    );

    observer.observe(sentinel);
    return () => observer.disconnect();
  }, []);

  return (
    <div>
      <div ref={sentinelRef} style={{ height: 1 }} />
      <header
        style={{
          position: "sticky",
          top: 0,
          padding: isStuck ? "8px 24px" : "16px 24px",
          background: isStuck ? "rgba(255,255,255,0.95)" : "#fff",
          boxShadow: isStuck ? "0 2px 8px rgba(0,0,0,0.1)" : "none",
          transition: "all 0.2s",
          zIndex: 40,
        }}
      >
        <h1 style={{ margin: 0, fontSize: isStuck ? 18 : 24 }}>
          我的应用
        </h1>
      </header>
      <main style={{ padding: 24 }}>
        {Array.from({ length: 80 }, (_, i) => (
          <p key={i}>内容段落 {i + 1}</p>
        ))}
      </main>
    </div>
  );
}

哨兵方案能用但很脆弱:你需要精确地放置哨兵元素,管理观察者的生命周期,并在 DOM 结构变化时保持同步。

用 useSticky

useSticky 干净利落地解决了吸顶检测问题,返回一个布尔值,当元素进入吸顶状态时翻转为 true

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

function StickyHeader() {
  const headerRef = useRef<HTMLElement>(null);
  const [isStuck] = useSticky(headerRef);

  return (
    <div>
      <header
        ref={headerRef}
        style={{
          position: "sticky",
          top: 0,
          padding: isStuck ? "8px 24px" : "16px 24px",
          background: isStuck
            ? "rgba(255,255,255,0.95)"
            : "#fff",
          boxShadow: isStuck
            ? "0 2px 8px rgba(0,0,0,0.1)"
            : "none",
          transition: "all 0.2s",
          zIndex: 40,
        }}
      >
        <h1 style={{ margin: 0, fontSize: isStuck ? 18 : 24 }}>
          我的应用
        </h1>
      </header>
      <main style={{ padding: 24 }}>
        {Array.from({ length: 80 }, (_, i) => (
          <p key={i}>内容段落 {i + 1}</p>
        ))}
      </main>
    </div>
  );
}

不需要哨兵元素,不需要手动设置观察者。Hook 在内部完成检测,给你一个简单的响应式布尔值来驱动样式。

5. 滚动进入视口时的渐显效果

用 useElementVisibility

useElementVisibilityIntersectionObserver 封装成一个布尔值返回。搭配 useState 标记位即可实现单次渐显效果:

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

function RevealOnScroll({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [visible] = useElementVisibility(ref);
  const [hasRevealed, setHasRevealed] = useState(false);

  useEffect(() => {
    if (visible && !hasRevealed) {
      setHasRevealed(true);
    }
  }, [visible, hasRevealed]);

  return (
    <div
      ref={ref}
      style={{
        opacity: hasRevealed ? 1 : 0,
        transform: hasRevealed ? "translateY(0)" : "translateY(30px)",
        transition: "opacity 0.6s ease, transform 0.6s ease",
      }}
    >
      {children}
    </div>
  );
}

function FeaturePage() {
  return (
    <div style={{ padding: "100vh 24px 24px" }}>
      <RevealOnScroll>
        <h2>功能一</h2>
        <p>滚动到视口内时淡入显示。</p>
      </RevealOnScroll>
      <div style={{ height: 200 }} />
      <RevealOnScroll>
        <h2>功能二</h2>
        <p>每个区域独立动画。</p>
      </RevealOnScroll>
      <div style={{ height: 200 }} />
      <RevealOnScroll>
        <h2>功能三</h2>
        <p>只动画一次——回滚时不会闪烁。</p>
      </RevealOnScroll>
    </div>
  );
}

6. 高级交叉观察:滚动进度指示

useIntersectionObserver 以声明式的方式暴露完整的 IntersectionObserver API,让你直接获取 IntersectionObserverEntry,包括 intersectionRatioisIntersectingboundingClientRect

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

function SectionProgress() {
  const sectionRef = useRef<HTMLDivElement>(null);
  const [ratio, setRatio] = useState(0);

  useIntersectionObserver(
    sectionRef,
    ([entry]) => {
      setRatio(entry.intersectionRatio);
    },
    {
      threshold: Array.from({ length: 101 }, (_, i) => i / 100),
    }
  );

  return (
    <div>
      <div style={{ height: "100vh" }} />
      <div ref={sectionRef} style={{ minHeight: "100vh", padding: 40 }}>
        <div
          style={{
            position: "sticky",
            top: 20,
            width: 200,
            height: 8,
            background: "#e2e8f0",
            borderRadius: 4,
          }}
        >
          <div
            style={{
              height: "100%",
              width: `${ratio * 100}%`,
              background: "#4f46e5",
              borderRadius: 4,
              transition: "width 0.1s",
            }}
          />
        </div>
        <h2>长篇区域</h2>
        {Array.from({ length: 20 }, (_, i) => (
          <p key={i}>区域中的第 {i + 1} 段。</p>
        ))}
      </div>
      <div style={{ height: "100vh" }} />
    </div>
  );
}

Hook 负责管理观察者的生命周期,在选项变化时重新连接,在卸载时自动清理。

安装

npm i @reactuses/core

相关 Hook


ReactUse 提供了 100 多个 React Hook。浏览全部 →

如果想转 AI 全栈?推荐你学一下 Langchain!

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

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

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

image.png

上个月月底,我去参加了一场在深圳举办的线下聚会,现场人很多,几乎称得上爆满,分享具体讲了什么我其实没有认真听完,但有一个现象让我印象特别深。

我发现,现场已经有很多并非技术出身的人在真实地使用 AI 做开发,有人是产品经理,有人甚至没有完整的软件工程背景,但他们一样能借助 Claude CodeCursor 这类 AI 编辑器,把一个产品从想法推进到可运行的形态。

只要你真正用过这类工具,你就会知道它们强在哪里,很多时候你不必先把所有代码写完,只要把问题、目标和约束说清楚,模型就能替你完成相当大一部分工作,它不光是在替你补几行代码,更是在把你的想法翻译成可执行的过程。

这件事带来的冲击其实很直接,不是只有程序员才能做产品了,而是谁更会拆问题、谁更会组织上下文、谁更会调度 AI,谁就更有机会把事情做成。

所以,真正需要警惕的从来不是 AI 会不会写代码,而是你是否还停留在只会发一个 chat.completions 请求、然后等它吐一段文本的阶段,因为当 AI 开始参与真实任务时,竞争点已经不再只是会不会调模型,而是你能不能把模型接进系统、接进流程、接进业务,最后让它稳定地把事做完。

也正因为如此,这套文档不会停留在教你调用一下 LLM API 这一层,它想解决的是更往前一步的问题,当 AI 不再只是聊天,而是真正进入你的产品、流程和工程系统里时,你到底该怎么设计它、约束它、组织它、编排它。

从会调模型到能改整条 Agent 链路

理想状态大概是,你不再满足于发完请求就收一段文本,而是能把一条真正可执行的 Agent 链路说清楚,别人问起来,你也知道该动哪一层、从哪下手改。

这里不会拿概念填空来凑篇幅,那些词你多半已经见过。更值得花时间的是落地之后一定会撞上的事,比如上下文该留什么、该砍什么,模型才既记得住关键信息,又不会被历史拖垮。工具怎么写、Function Call 怎么接,才能少空转、少胡编,多把事办完。结构化输出怎么定,业务里才能当真数据用,而不是靠正则和运气硬接。

再往后,中间件、护栏、运行时、上下文工程各自兜的是哪一类坑,MCP 这类协议又该摆在协作架构的哪一层。人机协同、多 AgentSubagentsHandoffsSkillsRouter、自定义工作流,听起来多,其实都是在不同复杂度下选一条路。至于 CoTToTGoTReActPlan-and-ExecuteReflexionSelf-CriticLATS 这些名字,背下来没多大用,有用的是它们背后控制流怎么画、推理预算该多给还是该省。

章节一路跟下来,术语和框架名自然会熟,但更值得带走的是一种手感。某类任务该用简单的 Agent 循环还是上图式编排,某段流程要不要上人审、要不要拆角色,某一步老是失败时,该补护栏、补记忆、补工具描述,还是干脆换一套推理策略。能分清这些,比多记十个 API 名字实在得多。

真正花时间的是把系统搭稳

网上讲 AI 开发的内容已经很多,常见的却两头偏,一头概念讲得热闹,回到工程里不知道该动哪只手,另一头 demo 复制粘贴能跑,一进真实业务就开始散。

第一次把结果跑出来的时候,你往往还觉得挺顺。你很快会发现,真正难的从来不是让它第一次跑起来,而是:

  • 为什么这个 Agent 一到复杂任务就开始乱
  • 为什么多轮之后上下文越来越脏
  • 为什么工具明明接了,模型还是不会正确调用
  • 为什么结构化输出看起来像 JSON,实际上却根本不稳定
  • 为什么接了很多能力,系统却越来越难控、越来越难测、越来越难上线

这套文档想把这一串问号拆开来看。重点不单是让模型答得更聪明,而是让你看清一个能进生产环境的系统底下有几层、每层在扛什么,出事该往哪一层摸,而不是遇事就把锅甩给模型不够聪明。

如何学习

按章节顺序读就行,不是要你迷信目录,而是后面的例子会默认你已经看过前面的概念,跳太狠容易半路卡住。

开头一大段都在打基础,裸调模型哪里别扭、LangChain 在补什么、Function Call、消息结构、工具怎么接、先跑一个最简单的 Agent、再加上会话记忆和结构化输出。拆开看是很多篇,合起来就是在说一件事,模型是怎么被接进一条可执行的链路里的。

再往后会硬一些,主要对付"能跑"和"敢上线"之间的差距,中间件、护栏、运行时、上下文工程、MCP、人机协同、多 Agent,以及 SubagentsHandoffsSkillsRouter、自定义工作流之类。名字多,你不用全记住,先有个印象,知道这些多半是在管权限、管边界、管出事以后谁来兜底。

后面才轮到规划、反思、试探、回退这类话题。CoTToTGoTReActPlan-and-ExecuteReflexionSelf-CriticLATS 当几种不同的走法看就好,定义背了也没多大用。有用的是下面这些判断,心里过一遍比抄名词强:

  • 什么场景下值得多给一点推理预算
  • 什么场景下应该尽快落工具、少走内耗
  • 什么任务适合先规划后执行
  • 什么任务反而应该边做边修正
  • 什么情况下多想一步是收益,什么情况下只是成本

快收尾的时候会把长期记忆和 harness 拉出来,把执行、状态、持久化、审计、可观测性这些零散提过的东西并到一块,方便你对照真实环境里一般长什么样。

20260329233412

整体就是这样,先把基础概念和常见拼法摸熟,再啃工程和协作里那些让人心里发虚的部分,最后在控制流和收尾方式上收个口。

适合谁、怎么读

你若是写 React、做业务、跟需求,模型 API 也碰过,却越来越觉得卡不在页面上,而在模型怎么接、工具怎么配、多步任务怎么串,这一路的写法就是按这个感觉排的。

做过聊天框、demo,想再往"能办事"那边挪一步,也会对上号。别人做出来的像助手,自己的还在一问一答里打转,这类落差在这里会当成工程问题拆,而不是甩一句模型不够聪明。

还有一种情况,文章东一篇西一篇看过,记忆、工具调用、Agent 都见过词,就是拼不出一张图。按章节往下翻,多半能把那些散点接回一条线。

读法上可以松一点,不必一次啃完。过完一章,想想自己项目里有没有同款糟心事,有的话最小改动可以先动哪一步。理论不用第一遍就全吃透,能慢慢把问题和章节里的招对上,就已经在读对路了。

🚨别再滥用 useEffect 了!90% React Bug 的根源就在这

你有没有发现一个现象:

  • 只要写 React,就离不开 useEffect
  • 数据变了 → 加 useEffect
  • 不知道逻辑放哪 → 塞 useEffect
  • 页面不更新 → 再加一层 useEffect

写到最后:

  • 组件里一半代码都是 useEffect
  • 无限循环、重复请求、莫名其妙重渲染、闭包陷阱满天飞
  • 改 Bug 比写功能还累

这篇文章只讲一件事:

useEffect 到底是什么?以及它为什么被 90% 的人用错?

先讲背景:useEffect 到底是干嘛的?

早期 React 组件,有一堆生命周期: componentDidMountcomponentDidUpdatecomponentWillUnmount… 逻辑散得到处都是,维护巨痛苦。

Hook 出来后,React 想解决一个问题:

把“跟渲染无关、跟外部交互”的逻辑,统一收拢。

于是有了 useEffect

它的定位非常清晰:处理副作用(Side Effect)

什么是副作用?就是跳出 React 渲染逻辑、去跟外部打交道的操作:

  • 请求 API 接口
  • 操作真实 DOM(比如聚焦第三方库)
  • 定时器、延时
  • 局事件监听(resize、keydown)
  • 本地存储、document.title
  • 同步外部系统(日志、埋点)

一句话总结:只有需要和“外部世界”同步时,才需要 useEffect。

致命误解:你把它当成了 “监听器”?

这是 React 新手最大的误区。

你以为它是:监听某个变量变化,然后执行逻辑。 但 React 的核心模型是:UI = f (state)(纯函数)

请死死记住这句话:useEffect 不是 “监听变量变化”,而是 “处理副作用”。

一旦滥用,React 内部发生了什么?

你写了一个逻辑,React 执行了一条死循环:

render (渲染) → effect (执行副作用) → setState (更新状态) → render (再次渲染) → effect ...

你以为只写了几行代码,其实你在 React 里开了一条高速公路,车多了自然堵车。

滥用 useEffect 的三大灾难

  1. 多余渲染暴增(性能杀手):一次逻辑触发多次渲染,页面卡顿、掉帧。
  2. 依赖链混乱(Bug 温床):依赖数组稍微不严谨,就陷入无限循环,或者闭包陷阱数据对不上。
  3. 逻辑碎片化(维护灾难):一个功能拆碎在多个不同的 useEffect 里,逻辑碎片化,谁敢动?

典型灾难链:

A 改 B → B 改 C → C 再改 A

你以为你在写逻辑,其实你在堆 Bug

这 4 种场景,绝对别用 useEffect

1. 计算状态 → 直接算,别存状态

// ❌ 错误:多此一举,引发重复渲染 
const [a, setA] = useState(1) 
const [b, setB] = useState(0) 

useEffect(() => { 
  setB(a * 2) 
}, [a])
// ✅ 正确:直接计算
const a = 1 
const b = a * 2 // 直接计算

能通过现有状态直接算出来的,就不要单独存状态,避免多余的渲染和逻辑。

2. 交互逻辑 → 写在事件处理函数里,不是 useEffect

// ❌ 错误:为了弹个提示,监听整个count
useEffect(() => {
  if (count === 10) alert('够了')
}, [count])
// ✅ 正确:点击时直接判断
const handleClick = () => {
  const newCount = count + 1
  setCount(newCount)
  if (newCount === 10) alert('够了')
}

用户主动触发的行为,不属于副作用同步,理应写在对应的事件处理函数中。

3. 初始化数据 → useState 初始值就能搞定

// ❌ 错误:多一次render
const [user, setUser] = useState(null)
useEffect(() => {
  setUser(currentUser)
}, [])
// ✅ 正确:一步到位,直接初始化
const [user] = useState(currentUser)

4. Props 同步 → 直接用 props,不要本地状态+effect

// ❌ 错误:典型反模式,数据来源不单一
useEffect(() => {
  setValue(props.value)
}, [props.value])
// ✅ 直接用 props
const { value } = props

🎯 useEffect 的唯一合法使用场景

只记 5 种合法场景,多一个都不用:

  1. 接口请求(记得必须带 AbortController 清理)
  2. 定时器 / 延时(必须 clear)
  3. 手动操作 DOM
  4. 全局事件监听(addEventListener 必须 remove)
  5. 同步外部系统(localStorage、title、埋点)

除此之外,能不用就不用。

结尾

很多人以为问题在 useEffect,其实问题在这里:

你有没有把组件当成“纯函数”?

通俗来讲,React 组件本该是纯函数:固定的 Props 和 State,就输出固定的 UI,不掺杂多余的副作用。

滥用 useEffect 就是强行打破这个规则,在渲染中乱加状态修改、异步逻辑,才引发各种 Bug 和性能问题。

你认为呢?Vue 的 Watch 是不是也是这个道理?欢迎在评论区一起讨论 ~~

【LeetCode 刷题系列|第 3 篇】详解大数相加:从模拟竖式到简洁写法的优化之路🔢

🔢 前言

Hello~大家好,我是秋天的一阵风

今天要攻克的是 LeetCode 上的经典 大数计算 题 ——「字符串相加」(题号 415)。

这道题的核心场景是「大数相加」:输入的两个非负整数以字符串形式存储(长度最长可达 5100 位),根本无法直接转成 Number 或 BigInt 类型计算,本质是考察手动模拟大数竖式加法的能力。

它和前两篇的「盛最多水」「接雨水」不同,重点不是算法复杂度优化,而是处理「进位、长度对齐、末尾残留进位」这些大数计算的关键细节,非常适合夯实字符串操作和边界处理思维。

话不多说,咱们一步步拆解,让你彻底掌握大数相加的核心逻辑~

一、LeetCode 大数相加(字符串版)题目详情

1. 题目描述

给定两个非负整数 num1num2,它们以字符串形式表示(即大数),返回它们的和也以字符串形式表示。说明

  • 你不能使用任何内置的 BigInteger 库或直接将输入转换为整数形式(核心限制,凸显大数场景);
  • num1num2 的长度都小于 5100(明确大数规模);
  • num1num2 都只包含数字 0-9
  • num1num2 都不包含前导零(除了数字 0 本身)。

题目链接415. 字符串相加 - 力扣(LeetCode)

2. 示例演示

  • 输入:num1 = "11", num2 = "123"
  • 输出:"134"
  • 解释:11 + 123 = 134,模拟竖式相加:个位 1+3=4,十位 1+2=3,百位 0+1=1,拼接结果为 "134"(小型大数场景,理解基础逻辑)。
  • 输入:num1 = "456", num2 = "77"
  • 输出:"533"
  • 解释:个位 6+7=13(留 3 进 1),十位 5+7+1=13(留 3 进 1),百位 4+0+1=5,结果为 "533"(含进位的典型场景)。
  • 输入:num1 = "999999999999999999", num2 = "1"
  • 输出:"1000000000000000000"
  • 解释:超长大数相加,末尾进位贯穿所有位,最终需在最前方补 1(大数计算核心边界场景)。
  • 输入:num1 = "0", num2 = "0"
  • 输出:"0"
  • 解释:两个零相加,结果仍为零,注意不能返回 "00" 这类前导零(特殊边界场景)。

3. 难度级别

🟢 简单 → 🔵 中等(实际考察):题目逻辑本身不复杂,但大数场景下的「进位传递」「长度对齐补零」「末尾残留进位」这三个点极易出错,核心是复刻竖式加法的完整流程,确保覆盖所有大数计算的边界情况。

二、解题思路大剖析

1. 基础解法:模拟大数竖式相加

基础解法的核心思路就是复刻大数竖式加法的手工流程:因为是大数,无法直接转数字计算,所以从两个字符串的「末尾(个位)」开始,逐位提取数字相加,同步记录当前位结果和进位,最后将结果反转(因计算顺序是从低位到高位)。

核心步骤:

  1. 指针初始化:i 指向 num1 末尾(个位),j 指向 num2 末尾(个位),适配大数的低位到高位计算逻辑;

  2. 进位初始化:carry = 0(初始无进位,大数相加的进位可能贯穿多位);

  3. 结果容器:用数组 res 存储每一位结果(大数拼接频繁,数组比字符串高效);

  4. 循环计算(覆盖大数所有位 + 残留进位):只要 i >= 0(num1 未处理完)、j >= 0(num2 未处理完)或 carry > 0(仍有进位),就继续:

    • 提取当前位数字:num1 当前位为 i >= 0 ? num1[i] - '0' : 0(大数长度不一致时,短数高位补 0),num2 同理;
    • 计算当前位总和:sum = 位1 + 位2 + carry(必须包含前一位进位,大数进位不可遗漏);
    • 提取当前位结果:sum % 10(取个位,如 sum=13 则当前位为 3);
    • 更新进位:carry = Math.floor(sum / 10)(取十位,如 sum=13 则进位为 1,可能传递到下一位);
    • 存入结果:将当前位结果推入 res 数组;
    • 指针左移:i--j--,处理大数的更高位;
  5. 结果整理:res 中是「个位→高位」的顺序,反转后拼接成字符串(大数的高位在前、低位在后)。

分步拆解演示(以大数输入 num1="9999", num2="123" 为例):

  • 初始状态:i=3(num1[3]='9'),j=2(num2[2]='3'),carry=0res=[]

  • 第 1 轮(个位):

    • 位 1=9,位 2=3 → sum=9+3+0=12;
    • 当前位:12%10=2 → res=[2];
    • 进位:12/10=1 → carry=1;
    • 指针:i=2,j=1;
  • 第 2 轮(十位):

    • 位 1=9(num1 [2]='9'),位 2=2(num2 [1]='2') → sum=9+2+1=12;
    • 当前位:12%10=2 → res=[2,2];
    • 进位:12/10=1 → carry=1;
    • 指针:i=1,j=0;
  • 第 3 轮(百位):

    • 位 1=9(num1 [1]='9'),位 2=1(num2 [0]='1') → sum=9+1+1=11;
    • 当前位:11%10=1 → res=[2,2,1];
    • 进位:11/10=1 → carry=1;
    • 指针:i=0,j=-1;
  • 第 4 轮(千位):

    • 位 1=9(num1 [0]='9'),位 2=0(j<0 补 0) → sum=9+0+1=10;
    • 当前位:10%10=0 → res=[2,2,1,0];
    • 进位:10/10=1 → carry=1;
    • 指针:i=-1,j=-1;
  • 第 5 轮(残留进位):

    • 位 1=0,位 2=0 → sum=0+0+1=1;
    • 当前位:1%10=1 → res=[2,2,1,0,1];
    • 进位:1/10=0 → carry=0;
    • 指针:i=-1,j=-1;
  • 循环终止:i<0、j<0 且 carry=0;

  • 反转 res:[2,2,1,0,1] → [1,0,1,2,2] → 拼接成字符串 "10122"(9999+123=10122,符合大数计算预期)。

JavaScript 代码实现(基础解法):

/**
 * @param {string} num1
 * @param {string} num2
 * @return {string}
 */
var addStrings = function(num1, num2) {
    let i = num1.length - 1; // 指向num1末尾(大数个位)
    let j = num2.length - 1; // 指向num2末尾(大数个位)
    let carry = 0; // 进位,大数相加可能跨多位传递
    const res = []; // 存储每一位结果,避免大数字符串频繁拼接
    
    // 循环条件:覆盖大数所有位 + 残留进位
    while (i >= 0 || j >= 0 || carry > 0) {
        // 提取当前位数字(大数长度不一致时补0),字符转数字(减'0')
        const digit1 = i >= 0 ? num1[i] - '0' : 0;
        const digit2 = j >= 0 ? num2[j] - '0' : 0;
        
        // 计算当前位总和(含前一位进位)
        const sum = digit1 + digit2 + carry;
        // 当前位结果:sum的个位数
        const currentDigit = sum % 10;
        // 更新进位:sum的十位数(向下取整,可能为0或1)
        carry = Math.floor(sum / 10);
        
        // 推入结果数组(大数低位→高位顺序)
        res.push(currentDigit);
        
        // 指针左移,处理大数更高位
        i--;
        j--;
    }
    
    // 反转数组→拼接字符串(大数高位→低位顺序)
    return res.reverse().join('');
};

// 测试用例验证(覆盖大数、进位、边界场景)
console.log(addStrings("11", "123")); // 输出"134",符合预期
console.log(addStrings("9999", "123")); // 输出"10122",符合预期
console.log(addStrings("999999999999999999", "1")); // 输出"1000000000000000000",符合预期
console.log(addStrings("0", "0")); // 输出"0",符合预期

基础解法的优缺点:

  • 优点:完全贴合大数竖式加法逻辑,步骤清晰,覆盖所有大数场景的边界(超长长度、跨位进位、残留进位),面试中写出来稳定性高,不易出错;
  • 缺点:代码有少量冗余变量(如 digit1 digit2),但不影响可读性,大数计算场景下时间和空间已接近最优,无明显可优化点。

2. 优化解法:代码简洁化

优化解法的核心逻辑和基础解法完全一致(仍是模拟大数竖式),仅在代码写法上精简,减少冗余变量,让代码更紧凑(面试中能体现对大数计算逻辑的熟练掌握)。

优化点:

  • 合并变量声明:将 i j carry 合并声明,减少代码行数;
  • 嵌入位计算:将 digit1 digit2 的提取直接嵌入 sum 计算中,避免冗余变量;
  • 简化循环条件:carry 为 0 时会自动终止,无需写 carry > 0(因 0 为 falsy 值)。

JavaScript 代码实现(优化解法):

/**
 * @param {string} num1
 * @param {string} num2
 * @return {string}
 */
var addStrings = function(num1, num2) {
    let i = num1.length - 1, j = num2.length - 1, carry = 0;
    const res = [];
    
    while (i >= 0 || j >= 0 || carry) {
        // 直接计算当前位总和(嵌入大数位提取+补0逻辑)
        const sum = (i >= 0 ? num1[i] - '0' : 0) + (j >= 0 ? num2[j] - '0' : 0) + carry;
        res.push(sum % 10); // 当前位结果
        carry = Math.floor(sum / 10); // 更新进位
        i--;
        j--;
    }
    
    return res.reverse().join('');
};

// 测试用例验证
console.log(addStrings("456", "77")); // 输出"533",符合预期
console.log(addStrings("999999999999999999", "1")); // 输出"1000000000000000000",符合预期

优化解法的特点:

  • 逻辑不变:完全遵循大数竖式加法规则,覆盖所有边界场景;
  • 代码精炼:行数减少,无冗余变量,面试时书写速度更快;
  • 可读性强:变量名自解释,面试官能快速理解大数计算逻辑;
  • 复杂度不变:时间和空间复杂度与基础解法一致,属于「写法优化」而非「算法优化」。

三、总结

1. 核心逻辑

大数相加(字符串版)的本质是「模拟手工竖式加法」,核心要点有三个,缺一不可:

  1. 「从后往前算」:大数的低位在字符串末尾,需从末尾开始逐位处理;
  2. 「补零对齐」:大数长度不一致时,短数的高位补 0,避免索引越界,确保每一位都能对应相加;
  3. 「进位不遗漏」:每一步相加必须带上前一位的进位,且循环结束前需检查是否有残留进位(如 999+1 的最后进位 1)。

2. 最后

今天的「大数相加(字符串版)」就讲解到这里啦!相信大家已经吃透了「模拟竖式 + 进位传递」的核心逻辑,不管是基础解法还是优化解法,都能轻松应对面试中的大数场景。如果在测试超长大数、全 9 数字相加等特殊情况时遇到问题,或者有更巧妙的实现思路,欢迎在评论区留言讨论~

下一篇,咱们会继续攻克 LeetCode 高频题(「三数之和」),关注我,刷题路上不迷路!咱们下期再见~ 👋

写这需求快崩溃了,幸好我会装饰器模式

目的

装饰器模式(Decorator Pattern) 的目的非常简单,那就是:在不修改原有代码的情况下增加逻辑。 这句话听起来可能有些矛盾,既然都要增加逻辑了,怎么可能不去修改原有的代码?但 SOLID (向对象设计5大重要原则)的开放封闭原则就是在试图解决这个问题,其内容是不去改动已经写好的核心逻辑,但又能够扩充新逻辑,也就是对扩展开放,对修改关闭。

举个例子,假如产品的需求是实现一个专门在浏览器的控制台中输出文本的功能,你可能会这样做:

class Printer {  
  print(text) {  
    console.log(text);  
  }  
}  
  
const printer = new Printer();  
printer.print('something'); // something

在你满意的看着自己的成果时,产品过来说了一句:“我觉得颜色不够突出,还是把它改成黄色的吧!”

小菜一碟!你自信的打开百度一通操作之后,把代码改成了下面这样子:

class Printer {  
  print(text) {  
    console.log(`%c${text}`,'color: yellow;');  
  }  
}

image.png

但产品看了看又说:“这个字体有点太小了,再大一点,最好是高端大气上档次那种。

”好吧。。。“你强行控制着自己拿刀的冲动,一边琢磨多大的字体才是高端大气上档次,一边修改 print 的代码:

image.png

class Printer {  
  print(text) {  
    console.log(`%c${text}`,'color: yellow;font-size: 36px;');  
  }  
}

image.png

这次改完你之后你心中已经满是 mmp 了,而且偷偷给产品贴了个标签:

image.png

你无法保证这次是最后的修改,而且也可能会不只一个产品来对你指手划脚。你呆呆的看着显示器,直到电脑进入休眠模式,屏幕中映出你那张苦大仇深的脸,想着不断变得乱七八糟的 print 方法,不知道该怎么去应付那些永无休止的需求。。。

image.png

在上面的例子中,最开始的 Printer 按照需求写出它应该要有的逻辑,那就是在控制台中输出一些文本。换句话说,当写完“在控制台中输出一些文本”这段逻辑后,就能将 Printer 结束了,因为它就是 Printer 的全部逻辑了。那在这个情况下该如何改变字体或是颜色的逻辑呢?

这时你该需要装饰器模式了。

Decorator Pattern(装饰器模式)

首先修改原来的 Printer,使它可以支持扩充样式:

class Printer {  
  print(text = '', style = '') {  
    console.log(`%c${text}`, style);  
  }  
}

之后分别创建改变字体和颜色的装饰器:

const yellowStyle = (printer) => ({  
  ...printer,  
  print(text = '', style = '') => {  
    printer.print(text, `${style}color: yellow;`);  
  }  
});  
  
const boldStyle = (printer) => ({  
  ...printer,  
  print(text = '', style = '') => {  
    printer.print(text, `${style}font-weight: bold;`);  
  }  
});  
  
const bigSizeStyle = (printer) => ({  
  ...printer,  
  print(text = '', style = '') => {  
    printer.print(text, `${style}font-size: 36px;`);  
  }  
});

代码中的 yellowStyleboldStyle 和 bigSizeStyle 分别是给 print 方法的装饰器,它们都会接收 printer,并以 printer 为基础复制出一个一样的对象出来并返回,而返回的 printer 与原来的区别是,各自 Decorator 都会为 printer 的 print 方法加上各自装饰的逻辑(例如改变字体、颜色或字号)后再调用 printer 的 print

使用方式如下:

image.png

只要把所有装饰的逻辑抽出来,就能够自由的搭配什么时候要输出什么样式,加入要再增加一个斜体样式,也只需要再新增一个装饰器就行了,不需要改动原来的 print 逻辑。

image.png

不过要注意的是上面的代码只是简单的把 Object 用解构复制,如果在 prototype 上存在方法就有可能会出错,所以要深拷贝一个新对象的话,还需要另外编写逻辑:

const copyObj = (originObj) => {  
  const originPrototype = Object.getPrototypeOf(originObj);  
  let newObj = Object.create(originPrototype);  
     
  const originObjOwnProperties = Object.getOwnPropertyNames(originObj);  
  originObjOwnProperties.forEach((property) => {  
    const prototypeDesc = Object.getOwnPropertyDescriptor(originObj, property);  
     Object.defineProperty(newObj, property, prototypeDesc);  
  });  
    
  return newObj;  
}

然后装饰器内改使上面代码中的 copyObj,就能正确复制相同的对象了:

const yellowStyle = (printer) => {  
  const decorator = copyObj(printer);  
  
  decorator.print = (text = '', style = '') => {  
    printer.print(text, `${style}color: yellow;`);  
  };  
  
  return decorator;  
};

其他案例

因为我们用的语言是 JavaScript,所以没有用到类,只是简单的装饰某个方法,比如下面这个用来发布文章的 publishArticle

const publishArticle = () => {  
  console.log('发布文章');  
};

如果你想要再发布文章之后在 微博或QQ空间之类的平台上发个动态,那又该怎么处理呢?是像下面的代码这样吗?

const publishArticle = () => {  
  console.log('发布文章');  
  
  console.log('发 微博 动态');  
  console.log('发 QQ空间 动态');  
};

这样显然不好!publishArticle 应该只需要发布文章的逻辑就够了!而且如果之后第三方服务平台越来越多,那 publishArticle 就会陷入一直加逻辑一直爽的情况,在明白了装饰器模式后就不能再这样做了!

所以把这个需求套上装饰器:

const publishArticle = () => {  
  console.log('发布文章');  
};  
  
const publishWeibo = (publish) => (...args) => {  
  publish(args);  
  console.log('发 微博 动态');  
};  
  
const publishQzone = (publish) => (...args) => {  
  publish(args);  
  console.log('发 QQ空间 动态');  
};  
  
  
const publishArticleAndWeiboAndQzone = publishWeibo(publishQzone(publishArticle));

前面 Printer 的例子是复制一个对象并返回,但如果是方法就不用复制了,只要确保每个装饰器都会返回一个新方法,然后会去执行被装饰的方法就行了。

image.png

总结

装饰器模式是一种非常有用的设计模式,在项目中也会经常用到,当需求变动时,觉得某个逻辑很多余,那么直接不装饰它就行了,也不需要去修改实现逻辑的代码。每一个装饰器都做他自己的事情,与其他装饰器互不影响。

前端预检请求是什么?

前言

本文谈到的前端预检请求其实就是解决跨域方案其中之一的安全机制,可以理解为你想进一个小区,门口有一个保安“拦截器”,通过了保安“拦截器”检查,就可以进入了。

关于跨域(端口、协议、域名),想必大家都不陌生吧,回想下跨域的相关知识以及解决方案,就知道前端预检请求的来源了。

知其然知其所以然

一、浏览器的‌同源策略

跨域问题主要源于浏览器的‌同源策略(Same-Origin Policy) ‌。该策略是浏览器最核心的安全机制之一,用于防止不同源之间的恶意行为。

同源的定义‌:
当一个请求的 URL 的协议、域名、端口三者中任意一个与当前页面的 URL 不同时,就称为跨域请求。

例如:

  • 协议不同:http://example.com 和 https://example.com
  • 域名不同:http://www.example.com 和 http://www.other.com
  • 端口不同:http://example.com:8080 和 http://example.com:8081

即使两个域名指向同一个 IP 地址,也属于跨域。

由于浏览器出于安全考虑,同源策略限制了以下行为:

  • 无法读取非同源网页的 Cookie、LocalStorage 和 IndexedDB;
  • 无法访问非同源网页的 DOM;
  • 无法向非同源地址发送 AJAX 请求。

二、解决跨域的方法

1. CORS(跨域资源共享)

CORS 是目前最常用且推荐的跨域解决方案。它通过在服务器端设置响应头来允许特定源访问资源。

  • 服务器通过设置 Access-Control-Allow-Origin 响应头来指定允许访问的源。
  • 可以设置为具体域名或 *(表示允许所有源)。
  • 支持所有类型的 HTTP 请求,功能完善。 ‌

2. JSONP(JSON with Padding)

JSONP 是一种利用 <script> 标签不受同源策略限制的特性实现跨域请求的方式。

  • 仅支持 GET 请求;
  • 存在安全风险,容易受到 XSS 攻击;
  • 目前已被 CORS 取代。

3. 代理服务器(正向/反向代理)

通过在本地搭建一个代理服务器,前端请求先发送到代理服务器,再由代理服务器转发到目标服务器。

  • 在开发环境中常用前端脚手架配置代理;
  • 生产环境则可以使用 Nginx 等反向代理工具。

4. WebSocket

WebSocket 协议不遵循同源策略,适用于需要实时通信的场景。

5. postMessage API

用于不同窗口或 iframe 之间传递消息,常用于跨域通信。

6. document.domain + iframe

适用于主域名相同但子域名不同的情况。

7. window.name + iframe

通过 iframe 的 window.name 属性实现跨域数据传递。

前端预检请求是什么?

前端预检请求(Preflight Request)是浏览器在发起某些跨域请求前,自动发送的一个 ‌OPTIONS‌ 请求,用于确认服务器是否允许实际的跨域请求。这种机制是为了保证安全性,防止未经允许的跨域请求对服务器数据造成影响。

CORS(跨域资源共享)机制在特定条件下会触发预检请求(Preflight Request) ‌。

注意‌:如果请求满足“简单请求”的所有条件(如使用 GETPOSTHEAD 方法,且 Content-Type 为 application/x-www-form-urlencodedmultipart/form-data 或 text/plain),则不会触发预检请求。

何时会触发预检请求?

当请求不满足“简单请求”的条件时,浏览器就会自动触发一个预检请求。简单请求包括以下几种情况:

  • 使用 ‌GET、HEAD 或 POST‌ 方法;
  • 请求头仅包含以下字段:AcceptAccept-LanguageContent-LanguageContent-Type(且值为 text/plainmultipart/form-data 或 application/x-www-form-urlencoded)。

如果请求中包含以下任意一种情况,则会被视为“非简单请求”,从而触发预检请求:

  1. 使用了非简单 HTTP 方法,如 ‌PUT、DELETE、PATCH‌ 等;
  2. 请求头中包含了自定义字段,例如 AuthorizationX-Custom-Header 等;
  3. Content-Type 设置为 application/jsonapplication/xml 等非简单类型;
  4. 请求中携带了凭证(如 Cookie),需设置 withCredentials = true

预检请求的内容

预检请求是一个 ‌OPTIONS‌ 方法的请求,它会携带以下关键请求头:

  • Access-Control-Request-Method:表示实际请求将使用的 HTTP 方法;
  • Access-Control-Request-Headers:列出实际请求中使用的自定义头部。

服务器如何响应预检请求?

服务器需要返回一系列 CORS 响应头来表明其是否允许该跨域请求:

  • Access-Control-Allow-Origin:指定允许访问的源;
  • Access-Control-Allow-Methods:列出允许的 HTTP 方法;
  • Access-Control-Allow-Headers:声明允许的请求头部;
  • Access-Control-Allow-Credentials:是否允许携带凭证(如 Cookie);
  • Access-Control-Max-Age:指定预检请求结果的缓存时间,减少重复预检。

为什么需要预检请求?

预检请求本质上是一种安全机制,确保服务器明确知道并同意来自某个源的请求。这可以避免一些潜在的安全风险,比如在未授权的情况下向服务器发送敏感操作。

如何优化预检请求?

预检请求是现代浏览器为保障跨域请求安全而设计的一种机制,虽然会带来额外的网络开销,但在必要时是不可或缺的。

站在开发者角度,性能优化还是少不了的。全面认识了预检请求,优化方案减少必要自己也就出来了。为什么平时开发项目大部分是简单请求GET、HEAD 或 POST‌?这个问题答案也自己出来了吧。

总结下为了减少不必要的预检请求,可以采取以下策略:

  1. 尽量使用简单请求‌:避免使用非标准方法或自定义头部;
  2. ‌**合理设置 Access-Control-Max-Age**‌:通过设置较长的缓存时间,减少重复的 OPTIONS 请求;
  3. 使用代理服务器‌:将跨域请求转发到同源接口,绕过浏览器的 CORS 检查。

本文思维导图

前言 (1).png

写在最后

我是凉城a,一个前端,热爱技术也热爱生活。

与你相逢,我很开心。

如果你想了解更多,请点这里,期待你的小⭐⭐

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于掘金,未经许可禁止转载💌
❌