阅读视图

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

超越 useState:掌握 React 进阶状态模式

useState 是 React 状态管理的主力。处理简单场景绰绰有余——一个控制弹窗的布尔值、一个输入框的字符串、一个计数器的数字。但需求稍微复杂一点——你需要上一次渲染的值、想对搜索词做防抖、要写一个既能受控又能非受控的组件——你就会发现自己反反复复写着同样的模板代码。用 ref 存旧值、清理 setTimeout 的 ID、受控和非受控的协调逻辑很快就变成一堆纠缠不清的 useEffect

本文将带你走过七种超越基础 useState 的状态模式。每种模式我们先展示手动实现,让你看清其中的门道,然后用 ReactUse 中专门的 Hook 替换。最后,我们会把七个 Hook 组合进一个交互式设置面板,展示它们如何无缝协作。

1. 受控 vs 非受控组件:useControlled

痛点

可复用的 UI 组件通常需要支持两种模式:受控(父组件持有状态,传入 value + onChange)和非受控(组件自行管理内部状态,可选接受 defaultValue)。同时支持两种模式是 MUI、Radix 等成熟组件库的标配——但实现起来出乎意料地繁琐。

手动实现

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

interface CustomInputProps {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
}

function CustomInput({ value, defaultValue = "", onChange }: CustomInputProps) {
  const isControlled = value !== undefined;
  const [internalValue, setInternalValue] = useState(defaultValue);

  // 用 ref 始终持有最新的受控值
  const valueRef = useRef(value);
  valueRef.current = value;

  const currentValue = isControlled ? value : internalValue;

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const next = e.target.value;
      if (!isControlled) {
        setInternalValue(next);
      }
      onChange?.(next);
    },
    [isControlled, onChange]
  );

  return (
    <input
      value={currentValue}
      onChange={handleChange}
      style={{
        padding: "8px 12px",
        border: "1px solid #d1d5db",
        borderRadius: 6,
        fontSize: 16,
      }}
    />
  );
}

对于一个简单输入框来说够用了。但当受控值从外部变更时(需要同步)、当你要提醒开发者不要在受控和非受控之间切换时、当值是复杂对象而非基本类型时,这套逻辑就越来越复杂。每个需要双模式的组件都在重复同一段代码。

用 useControlled

useControlled 封装了整套受控/非受控协调逻辑,返回一个 [value, setValue] 元组,无论使用者选择哪种模式都能正常工作。

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

interface CustomInputProps {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
}

function CustomInput({ value, defaultValue = "", onChange }: CustomInputProps) {
  const [currentValue, setCurrentValue] = useControlled({
    value,
    defaultValue,
    onChange,
  });

  return (
    <input
      value={currentValue}
      onChange={(e) => setCurrentValue(e.target.value)}
      style={{
        padding: "8px 12px",
        border: "1px solid #d1d5db",
        borderRadius: 6,
        fontSize: 16,
      }}
    />
  );
}

// 非受控用法——组件自行管理状态
function UncontrolledDemo() {
  return <CustomInput defaultValue="hello" />;
}

// 受控用法——父组件持有状态
function ControlledDemo() {
  const [text, setText] = useState("");
  return <CustomInput value={text} onChange={setText} />;
}

一次 Hook 调用就替代了 ref、isControlled 判断和双路径更新逻辑。组件在两种模式下行为完全一致,即使开发者意外地在受控和非受控之间切换,Hook 也能从容应对。

2. 追踪前一个值:usePrevious

痛点

你经常需要上一次渲染的值——用来比较 prop 是否变化、在新旧值之间做过渡动画、或者显示"从 X 变成了 Y"的 UI 反馈。React 没有内置这个能力。

手动实现

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

function PriceDisplay({ price }: { price: number }) {
  const prevPriceRef = useRef<number | undefined>(undefined);

  useEffect(() => {
    prevPriceRef.current = price;
  });

  const prevPrice = prevPriceRef.current;
  const direction =
    prevPrice === undefined
      ? "neutral"
      : price > prevPrice
        ? "up"
        : price < prevPrice
          ? "down"
          : "neutral";

  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
      <span style={{ fontSize: 32, fontWeight: 700 }}>
        ${price.toFixed(2)}
      </span>
      {direction === "up" && (
        <span style={{ color: "#16a34a", fontSize: 20 }}></span>
      )}
      {direction === "down" && (
        <span style={{ color: "#dc2626", fontSize: 20 }}></span>
      )}
      {prevPrice !== undefined && prevPrice !== price && (
        <span style={{ color: "#6b7280", fontSize: 14 }}>
          之前是 ${prevPrice.toFixed(2)}
        </span>
      )}
    </div>
  );
}

ref 加 effect 的技巧能用,但容易出错。如果把 effect 放在渲染逻辑之前(或者不该用 useLayoutEffect 的地方用了),"前值"可能会变成过期或当前的值。而且每个需要变更检测的组件都要复制这段样板代码。

用 usePrevious

usePrevious 返回上一次渲染的值,时机精确——在当前渲染期间你始终看到的是旧值。

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

function PriceDisplay({ price }: { price: number }) {
  const prevPrice = usePrevious(price);

  const direction =
    prevPrice === undefined
      ? "neutral"
      : price > prevPrice
        ? "up"
        : price < prevPrice
          ? "down"
          : "neutral";

  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
      <span style={{ fontSize: 32, fontWeight: 700 }}>
        ${price.toFixed(2)}
      </span>
      {direction === "up" && (
        <span style={{ color: "#16a34a", fontSize: 20 }}></span>
      )}
      {direction === "down" && (
        <span style={{ color: "#dc2626", fontSize: 20 }}></span>
      )}
      {prevPrice !== undefined && prevPrice !== price && (
        <span style={{ color: "#6b7280", fontSize: 14 }}>
          之前是 ${prevPrice.toFixed(2)}
        </span>
      )}
    </div>
  );
}

function StockTicker() {
  const [price, setPrice] = useState(142.5);

  return (
    <div style={{ padding: 24 }}>
      <PriceDisplay price={price} />
      <div style={{ marginTop: 16, display: "flex", gap: 8 }}>
        <button onClick={() => setPrice((p) => p + Math.random() * 5)}>
          涨价
        </button>
        <button onClick={() => setPrice((p) => p - Math.random() * 5)}>
          跌价
        </button>
      </div>
    </div>
  );
}

不需要 ref,不需要 effect。一行代码就能拿到前值,并且与 React 的渲染周期精确同步。

3. 防抖状态:useDebounce

痛点

搜索输入框、过滤字段、实时预览编辑器都面临同一个问题:每次按键都更新状态会触发昂贵的操作(API 请求、重渲染、复杂过滤),频率远超必要。防抖——等用户停止输入指定时间后再触发——是标准解决方案。

手动实现

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

function ManualDebouncedSearch() {
  const [query, setQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState("");
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    timerRef.current = setTimeout(() => {
      setDebouncedQuery(query);
    }, 300);

    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, [query]);

  // 卸载时清理
  useEffect(() => {
    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, []);

  return (
    <div style={{ padding: 24 }}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
        style={{
          padding: "8px 12px",
          border: "1px solid #d1d5db",
          borderRadius: 6,
          width: 300,
          fontSize: 16,
        }}
      />
      <p style={{ color: "#6b7280", marginTop: 8 }}>
        防抖后的值: <strong>{debouncedQuery}</strong>
      </p>
      <p style={{ color: "#9ca3af", fontSize: 14 }}>
        (这个值会触发 API 请求)
      </p>
    </div>
  );
}

两个状态变量、一个存定时器的 ref、一个调度防抖的 effect、另一个处理卸载清理的 effect。能用,但对于一个几十个组件都需要的功能来说,仪式感太重了。

用 useDebounce

useDebounce 返回任意值的防抖版本。你正常更新源状态,Hook 会产出一个滞后的副本,只在指定的静默期之后才更新。

import { useDebounce } from "@reactuses/core";
import { useState } from "react";

function DebouncedSearch() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);

  return (
    <div style={{ padding: 24 }}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
        style={{
          padding: "8px 12px",
          border: "1px solid #d1d5db",
          borderRadius: 6,
          width: 300,
          fontSize: 16,
        }}
      />
      <p style={{ color: "#6b7280", marginTop: 8 }}>
        防抖后的值: <strong>{debouncedQuery}</strong>
      </p>
      {query !== debouncedQuery && (
        <p style={{ color: "#f59e0b", fontSize: 14 }}>
          等待输入停止...
        </p>
      )}
    </div>
  );
}

一个 Hook,一行代码。定时器管理、清理和同步全部在内部处理。比较 query !== debouncedQuery 还能免费实现"输入中"指示。

4. 节流状态:useThrottle

痛点

节流是防抖的近亲。不同于等待静默,它确保更新在每个时间间隔内最多触发一次——适用于连续触发的事件,比如滚动位置、鼠标移动或实时数据流,你想要的是稳定的更新频率而非末尾的一次性爆发。

手动实现

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

function ManualThrottledSlider() {
  const [value, setValue] = useState(50);
  const [throttledValue, setThrottledValue] = useState(50);
  const lastRun = useRef(Date.now());
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    const now = Date.now();
    const elapsed = now - lastRun.current;
    const delay = 200;

    if (elapsed >= delay) {
      setThrottledValue(value);
      lastRun.current = now;
    } else {
      timerRef.current = setTimeout(() => {
        setThrottledValue(value);
        lastRun.current = Date.now();
      }, delay - elapsed);
    }

    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, [value]);

  return (
    <div style={{ padding: 24 }}>
      <input
        type="range"
        min={0}
        max={100}
        value={value}
        onChange={(e) => setValue(Number(e.target.value))}
        style={{ width: 300 }}
      />
      <div style={{ marginTop: 12 }}>
        <p>原始值: {value}</p>
        <p>节流值: {throttledValue}</p>
      </div>
    </div>
  );
}

节流逻辑很容易写错。你需要追踪上次执行时间、处理末尾调用(保证最终值不丢失)、清理定时器。而且这只是针对单个值——每个需要节流的状态都得重复全部逻辑。

用 useThrottle

useThrottle 返回值的节流版本,在每个间隔内最多更新一次,同时确保最终值始终被捕获。

import { useThrottle } from "@reactuses/core";
import { useState } from "react";

function ThrottledSlider() {
  const [value, setValue] = useState(50);
  const throttledValue = useThrottle(value, 200);

  return (
    <div style={{ padding: 24 }}>
      <input
        type="range"
        min={0}
        max={100}
        value={value}
        onChange={(e) => setValue(Number(e.target.value))}
        style={{ width: 300 }}
      />
      <div style={{ marginTop: 12 }}>
        <p>原始值: {value}</p>
        <p>节流值: {throttledValue}</p>
      </div>
      <div
        style={{
          marginTop: 16,
          height: 20,
          width: `${throttledValue}%`,
          background: "#4f46e5",
          borderRadius: 4,
          transition: "width 0.1s",
        }}
      />
    </div>
  );
}

进度条以 200ms 的间隔平滑更新,而不是在滑块每移动一个像素时都抖动。一行代码搞定所有时序逻辑。

5. 循环选项:useCycleList

痛点

很多 UI 控件需要在一组固定选项中循环:主题切换(亮色 / 暗色 / 跟随系统)、排序方式(升序 / 降序 / 无序)、视图模式(网格 / 列表 / 紧凑)。常规做法是用一个状态变量加一个手动计算下一个值的函数。

手动实现

import { useState } from "react";

type ViewMode = "grid" | "list" | "compact";
const viewModes: ViewMode[] = ["grid", "list", "compact"];

function ManualViewToggle() {
  const [index, setIndex] = useState(0);
  const mode = viewModes[index];

  const next = () => setIndex((i) => (i + 1) % viewModes.length);
  const prev = () =>
    setIndex((i) => (i - 1 + viewModes.length) % viewModes.length);

  const icons: Record<ViewMode, string> = {
    grid: "▦",
    list: "☰",
    compact: "═",
  };

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button onClick={prev} style={{ fontSize: 20, cursor: "pointer" }}></button>
        <div
          style={{
            padding: "8px 16px",
            background: "#f1f5f9",
            borderRadius: 8,
            fontSize: 18,
            minWidth: 120,
            textAlign: "center",
          }}
        >
          {icons[mode]} {mode}
        </div>
        <button onClick={next} style={{ fontSize: 20, cursor: "pointer" }}></button>
      </div>
    </div>
  );
}

单个切换够简单了,但取模运算和独立的索引追踪是每个需要循环行为的地方都会出现的样板代码。它也不支持直接跳转到某个值或响应列表变化。

用 useCycleList

useCycleList 管理数组值的循环,提供 nextprev 以及直接跳转的 go 函数,连同当前值和索引。

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

type ViewMode = "grid" | "list" | "compact";

function ViewToggle() {
  const [mode, { next, prev }] = useCycleList<ViewMode>(
    ["grid", "list", "compact"]
  );

  const icons: Record<ViewMode, string> = {
    grid: "▦",
    list: "☰",
    compact: "═",
  };

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button onClick={prev} style={{ fontSize: 20, cursor: "pointer" }}></button>
        <div
          style={{
            padding: "8px 16px",
            background: "#f1f5f9",
            borderRadius: 8,
            fontSize: 18,
            minWidth: 120,
            textAlign: "center",
          }}
        >
          {icons[mode]} {mode}
        </div>
        <button onClick={next} style={{ fontSize: 20, cursor: "pointer" }}></button>
      </div>
    </div>
  );
}

不用管索引,不用做取模运算。Hook 直接给你当前值和导航函数。用来做主题切换——点击在亮色、暗色、跟随系统之间循环——特别顺手。

6. 数值状态:useCounter

痛点

计数器无处不在——电商的数量选择器、分页控件、步骤指示器、缩放级别。每个都需要递增、递减、重置,通常还需要最小/最大值钳位。每次从头写这些很乏味。

手动实现

import { useCallback, useState } from "react";

function ManualQuantityPicker() {
  const [count, setCount] = useState(1);
  const min = 1;
  const max = 99;

  const increment = useCallback(
    () => setCount((c) => Math.min(c + 1, max)),
    [max]
  );
  const decrement = useCallback(
    () => setCount((c) => Math.max(c - 1, min)),
    [min]
  );
  const reset = useCallback(() => setCount(1), []);

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button
          onClick={decrement}
          disabled={count <= min}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count <= min ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count <= min ? "not-allowed" : "pointer",
          }}
        >
          -
        </button>
        <span style={{ fontSize: 24, fontWeight: 600, minWidth: 40, textAlign: "center" }}>
          {count}
        </span>
        <button
          onClick={increment}
          disabled={count >= max}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count >= max ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count >= max ? "not-allowed" : "pointer",
          }}
        >
          +
        </button>
        <button onClick={reset} style={{ marginLeft: 12, fontSize: 14, color: "#6b7280" }}>
          重置
        </button>
      </div>
    </div>
  );
}

钳位逻辑、禁用状态、记忆化回调——全是标准样板代码,应用里每个计数器都在重复。

用 useCounter

useCounter 开箱即用地提供 countincdecsetreset,并支持可选的最小/最大值边界。

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

function QuantityPicker() {
  const [count, { inc, dec, reset }] = useCounter(1, {
    min: 1,
    max: 99,
  });

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button
          onClick={() => dec()}
          disabled={count <= 1}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count <= 1 ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count <= 1 ? "not-allowed" : "pointer",
          }}
        >
          -
        </button>
        <span style={{ fontSize: 24, fontWeight: 600, minWidth: 40, textAlign: "center" }}>
          {count}
        </span>
        <button
          onClick={() => inc()}
          disabled={count >= 99}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count >= 99 ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count >= 99 ? "not-allowed" : "pointer",
          }}
        >
          +
        </button>
        <button onClick={reset} style={{ marginLeft: 12, fontSize: 14, color: "#6b7280" }}>
          重置
        </button>
      </div>
    </div>
  );
}

Hook 在内部处理钳位。你只需传一次 minmax,递增递减时永远不用担心越界。

7. 类组件风格 setState:useSetState

痛点

React 类组件的 setState 有一个很方便的特性:接受一个部分对象,然后合并到已有状态中。但 hooks 里的 useState 是整体替换。如果你的状态是一个多字段对象,每次更新都得展开:setState(prev => ({ ...prev, name: 'new' }))。对于字段很多的复杂表单或设置对象,这种写法既冗长又容易出错(忘了展开会无声地丢失字段)。

手动实现

import { useCallback, useState } from "react";

interface FormState {
  name: string;
  email: string;
  role: string;
  notifications: boolean;
}

function ManualSettingsForm() {
  const [state, setFullState] = useState<FormState>({
    name: "",
    email: "",
    role: "viewer",
    notifications: true,
  });

  // 每次更新都必须展开上一个状态
  const setState = useCallback(
    (patch: Partial<FormState>) =>
      setFullState((prev) => ({ ...prev, ...patch })),
    []
  );

  return (
    <form style={{ padding: 24, display: "flex", flexDirection: "column", gap: 12, maxWidth: 400 }}>
      <input
        value={state.name}
        onChange={(e) => setState({ name: e.target.value })}
        placeholder="姓名"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <input
        value={state.email}
        onChange={(e) => setState({ email: e.target.value })}
        placeholder="邮箱"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <select
        value={state.role}
        onChange={(e) => setState({ role: e.target.value })}
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      >
        <option value="viewer">查看者</option>
        <option value="editor">编辑者</option>
        <option value="admin">管理员</option>
      </select>
      <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <input
          type="checkbox"
          checked={state.notifications}
          onChange={(e) => setState({ notifications: e.target.checked })}
        />
        邮件通知
      </label>
      <pre style={{ background: "#f8fafc", padding: 12, borderRadius: 6, fontSize: 13 }}>
        {JSON.stringify(state, null, 2)}
      </pre>
    </form>
  );
}

你得自己创建合并用的 setState 包装器。如果团队里其他开发者忘了用这个包装器,直接拿部分对象调 setFullState,字段就会无声消失。

用 useSetState

useSetState 的行为和类组件的 setState 一样——传入部分对象,自动合并到已有状态中。

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

interface FormState {
  name: string;
  email: string;
  role: string;
  notifications: boolean;
}

function SettingsForm() {
  const [state, setState] = useSetState<FormState>({
    name: "",
    email: "",
    role: "viewer",
    notifications: true,
  });

  return (
    <form style={{ padding: 24, display: "flex", flexDirection: "column", gap: 12, maxWidth: 400 }}>
      <input
        value={state.name}
        onChange={(e) => setState({ name: e.target.value })}
        placeholder="姓名"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <input
        value={state.email}
        onChange={(e) => setState({ email: e.target.value })}
        placeholder="邮箱"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <select
        value={state.role}
        onChange={(e) => setState({ role: e.target.value })}
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      >
        <option value="viewer">查看者</option>
        <option value="editor">编辑者</option>
        <option value="admin">管理员</option>
      </select>
      <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <input
          type="checkbox"
          checked={state.notifications}
          onChange={(e) => setState({ notifications: e.target.checked })}
        />
        邮件通知
      </label>
      <pre style={{ background: "#f8fafc", padding: 12, borderRadius: 6, fontSize: 13 }}>
        {JSON.stringify(state, null, 2)}
      </pre>
    </form>
  );
}

Hook 返回的 setState 接受部分对象并自动合并。不需要包装函数,不存在意外替换整个状态的风险。

融会贯通:一个设置面板

这些 Hook 天然可组合。下面是一个综合运用全部七个 Hook 的设置面板:

import {
  useControlled,
  usePrevious,
  useDebounce,
  useThrottle,
  useCycleList,
  useCounter,
  useSetState,
} from "@reactuses/core";
import { useState } from "react";

// 一个受控/非受控搜索输入框
function SearchInput({
  value,
  defaultValue,
  onChange,
}: {
  value?: string;
  defaultValue?: string;
  onChange?: (v: string) => void;
}) {
  const [currentValue, setCurrentValue] = useControlled({
    value,
    defaultValue: defaultValue ?? "",
    onChange,
  });

  return (
    <input
      value={currentValue}
      onChange={(e) => setCurrentValue(e.target.value)}
      placeholder="搜索设置..."
      style={{
        padding: "8px 12px",
        border: "1px solid #d1d5db",
        borderRadius: 6,
        width: "100%",
        fontSize: 14,
      }}
    />
  );
}

function SettingsPanel() {
  // 带防抖的搜索
  const [searchQuery, setSearchQuery] = useState("");
  const debouncedSearch = useDebounce(searchQuery, 300);
  const prevSearch = usePrevious(debouncedSearch);

  // 主题循环切换
  const [theme, { next: nextTheme }] = useCycleList([
    "light",
    "dark",
    "system",
  ]);

  // 带计数器的字体大小
  const [fontSize, { inc: fontUp, dec: fontDown, reset: fontReset }] =
    useCounter(16, { min: 12, max: 24 });

  // 节流的实时预览
  const throttledFontSize = useThrottle(fontSize, 150);

  // 合并式表单状态
  const [settings, setSettings] = useSetState({
    username: "",
    email: "",
    notifications: true,
    language: "zh",
  });

  const themeColors: Record<string, { bg: string; text: string }> = {
    light: { bg: "#ffffff", text: "#1e293b" },
    dark: { bg: "#1e293b", text: "#f8fafc" },
    system: { bg: "#f1f5f9", text: "#334155" },
  };

  const allSettings = [
    "username",
    "email",
    "notifications",
    "language",
    "theme",
    "font size",
  ];

  const filtered = debouncedSearch
    ? allSettings.filter((s) =>
        s.toLowerCase().includes(debouncedSearch.toLowerCase())
      )
    : allSettings;

  return (
    <div
      style={{
        padding: 24,
        maxWidth: 500,
        margin: "0 auto",
        background: themeColors[theme].bg,
        color: themeColors[theme].text,
        borderRadius: 12,
        transition: "all 0.3s",
      }}
    >
      <h2 style={{ marginTop: 0 }}>设置</h2>

      {/* 受控搜索输入 */}
      <SearchInput value={searchQuery} onChange={setSearchQuery} />

      {prevSearch && prevSearch !== debouncedSearch && (
        <p style={{ fontSize: 12, opacity: 0.6, margin: "4px 0" }}>
          从 "{prevSearch}" 变为 "{debouncedSearch}"
        </p>
      )}

      <p style={{ fontSize: 12, opacity: 0.6 }}>
        显示 {filtered.length} / {allSettings.length} 项设置
      </p>

      {/* 主题切换 */}
      {filtered.includes("theme") && (
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center",
            padding: "12px 0",
            borderBottom: "1px solid rgba(128,128,128,0.2)",
          }}
        >
          <span>主题</span>
          <button
            onClick={nextTheme}
            style={{
              padding: "6px 16px",
              borderRadius: 6,
              border: "1px solid rgba(128,128,128,0.3)",
              background: "transparent",
              color: "inherit",
              cursor: "pointer",
            }}
          >
            {theme}
          </button>
        </div>
      )}

      {/* 字体大小计数器 */}
      {filtered.includes("font size") && (
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center",
            padding: "12px 0",
            borderBottom: "1px solid rgba(128,128,128,0.2)",
          }}
        >
          <span>字体大小</span>
          <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <button onClick={() => fontDown()}>-</button>
            <span style={{ fontWeight: 600 }}>{fontSize}px</span>
            <button onClick={() => fontUp()}>+</button>
            <button
              onClick={fontReset}
              style={{ fontSize: 12, color: "inherit", opacity: 0.6 }}
            >
              重置
            </button>
          </div>
        </div>
      )}

      {/* 带节流字体大小的实时预览 */}
      <p
        style={{
          fontSize: throttledFontSize,
          padding: "12px 0",
          transition: "font-size 0.15s",
          borderBottom: "1px solid rgba(128,128,128,0.2)",
        }}
      >
        以 {throttledFontSize}px 预览文本
      </p>

      {/* 合并状态的表单字段 */}
      {filtered.includes("username") && (
        <div style={{ padding: "12px 0" }}>
          <label style={{ display: "block", fontSize: 13, marginBottom: 4 }}>
            用户名
          </label>
          <input
            value={settings.username}
            onChange={(e) => setSettings({ username: e.target.value })}
            style={{
              padding: "6px 10px",
              border: "1px solid rgba(128,128,128,0.3)",
              borderRadius: 4,
              width: "100%",
              background: "transparent",
              color: "inherit",
            }}
          />
        </div>
      )}

      {filtered.includes("notifications") && (
        <label
          style={{
            display: "flex",
            alignItems: "center",
            gap: 8,
            padding: "12px 0",
          }}
        >
          <input
            type="checkbox"
            checked={settings.notifications}
            onChange={(e) =>
              setSettings({ notifications: e.target.checked })
            }
          />
          开启通知
        </label>
      )}
    </div>
  );
}

七个 Hook,零冲突。useControlled 驱动搜索输入框,使其在别处也能以非受控方式使用。useDebounce 避免每次按键都执行过滤。usePrevious 展示搜索词的变化历史。useCycleList 处理主题切换。useCounter 管理带边界的字体大小。useThrottle 平滑实时预览的更新。useSetState 将表单字段保持在一个合并式状态对象中。每个 Hook 负责一个关注点,组合时不需要任何额外的胶水代码。

安装

npm i @reactuses/core

相关 Hook

  • useControlled -- 构建同时支持受控和非受控的组件
  • usePrevious -- 获取上一次渲染的值
  • useDebounce -- 对任意值按指定延迟进行防抖
  • useThrottle -- 对任意值按间隔进行节流
  • useCycleList -- 在数组值之间用 next/prev 循环切换
  • useCounter -- 带 inc/dec/reset 和可选 min/max 的数值状态
  • useSetState -- 像类组件 setState 一样合并部分对象到状态中
  • useBoolean -- 带 toggle、setTrue、setFalse 的布尔状态
  • useToggle -- 在两个值之间切换
  • useLocalStorage -- 将状态持久化到 localStorage 并自动序列化

ReactUse 提供了 100 多个 React Hook。浏览全部 →

CSS 模块化演进之路:从隔离到动态的样式革命

CSS 模块化演进之路:从隔离到动态的样式革命

引言:当 CSS 遇到组件化时代

在现代前端开发的浪潮中,组件化已成为构建复杂应用的标准范式。然而,当我们专注于 JavaScript 组件的逻辑封装时,一个长期被忽视的问题逐渐浮出水面:CSS 如何才能真正实现模块化?

想象这样一个场景:你和团队成员同时开发一个大型 React 应用。你精心编写了一个 .button 样式,满怀信心地提交代码。第二天,测试报告指出按钮样式异常——原来另一位开发者也在他的组件中定义了同名的 .button 类,后加载的样式覆盖了你的设计。这不是假设,而是每个前端开发者都经历过的样式冲突噩梦

本文将通过三个真实项目,深入剖析 CSS 模块化的三种主流解决方案,揭示它们如何从不同维度解决样式隔离问题,以及各自的技术哲学和适用场景。


第一章:CSS Modules —— 静态隔离的艺术

1.1 问题的根源

让我们从 css-demo 项目的一个细节说起。项目中存在两个按钮组件:Button.jsxAnotherButton.jsx。如果没有模块化,它们的 CSS 可能长这样:

/* 传统 CSS */
.button {
    background-color: blue;
    color: white;
}

当两个组件都使用 .button 类名时,后定义的样式会覆盖前者。这就是 CSS 全局命名空间带来的命名污染问题。

1.2 CSS Modules 的解决方案

Button.jsx 中,我们看到这样的代码:

import styles from './Button.module.css';

export default function Button() {
    return (
    <>
        <h1 className={styles.txt}>你好,世界!</h1>
        <button className={styles.button}>按钮</button>
    </>
)
}

关键在于 import styles from './Button.module.css'。这不是普通的 CSS 文件导入,而是 CSS Modules 的语法约定。注意文件名的 .module.css 后缀——这是告诉构建工具(如 Vite、Webpack)将其作为 CSS Module 处理。

对应的 Button.module.css 文件:

.button {
    background-color: blue;
    color: white;
    padding: 10px 20px;
}
.txt {
    color: pink;
    font-size: 20px;
    font-weight: bold;
}

1.3 编译时的魔法

当代码被编译时,CSS Modules 会执行以下转换:

  1. 生成唯一类名:将 .button 转换为类似 .Button_button__a7b3c 的哈希类名
  2. 导出映射对象styles 对象变成 { button: 'Button_button__a7b3c', txt: 'Button_txt__d8e9f' }
  3. 自动作用域隔离:每个组件的样式只影响自身

在控制台日志中可以看到(如代码中的 console.log('111styles:',styles)),styles 是一个 JavaScript 对象,其 key 是 CSS 类名,value 是哈希后的唯一类名。

1.4 多人协作的保障

AnotherButton.jsx 展示了这种方案的核心价值:

import styles from './AnotherButton.module.css';

export default function AnotherButton() {
    return <button className={styles.button}>another 按钮</button>
}

尽管两个组件都使用了 .button 类名,但编译后它们会变成完全不同的哈希值:

  • Button.buttonButton_button__a7b3c
  • AnotherButton.buttonAnotherButton_button__x9y8z

正如 App.jsx 中的注释所说:"多人协作的时候就会有这个 bug,我们怎么做能不影响别人,也不受别人的影响"。CSS Modules 通过编译时的静态隔离,完美解决了这个问题。

1.5 技术特点

优势:

  • 零运行时开销:样式在构建时处理,运行时只是普通的 class 应用
  • 工具链友好:与现有 CSS 语法完全兼容,支持所有 CSS 特性
  • 性能优化:自动提取唯一 CSS 文件,支持代码分割
  • 类型安全:可与 TypeScript 结合,提供类名提示

局限:

  • 动态性不足:难以根据 props 动态调整样式
  • 全局样式依赖:仍需通过 :global 处理全局样式
  • 配置依赖:需要构建工具支持(现代工具已默认支持)

第二章:Styled Components —— 动态样式的哲学

2.1 当样式需要"思考"

如果说 CSS Modules 解决了静态隔离问题,那么 styled-component-demo 项目则展示了更进一步的思考:样式能否根据组件的状态动态变化?

App.jsx 中的代码:

import styled from 'styled-components';

const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  )
}

2.2 CSS-in-JS 的革命

这里没有单独的 CSS 文件,样式直接写在 JavaScript 中,通过 styled-components 库的 styled.button 方法创建。这种范式被称为 CSS-in-JS

关键创新点:

  1. 样式即组件Button 既是 React 组件,也是样式定义
  2. 动态插值:通过 ${props => ...} 语法,样式可以响应 props 变化
  3. 自动作用域:每个 styled 组件生成唯一类名,天然隔离

2.3 动态样式的力量

两个按钮实例展示了动态能力:

  • <Button>primary 为 false,背景白色,文字蓝色
  • <Button primary>primary 为 true,背景蓝色,文字白色

同样的组件,不同的视觉表现。这在 CSS Modules 中需要额外的状态类名管理,而 styled-components 将其内建为语言特性。

2.4 运行时机制

styled-components 在运行时执行以下步骤:

  1. 解析模板字符串:提取 CSS 规则和动态插值函数
  2. 生成唯一类名:类似 CSS Modules,但发生在运行时
  3. 注入 style 标签:动态创建 <style> 标签注入页面
  4. 响应式更新:当 props 变化时,重新计算样式

查看 package.json,可以看到依赖:

"styled-components": "^6.3.12"

这是整个方案的核心库。

2.5 技术特点

优势:

  • 极致动态性:样式完全由 JavaScript 控制,可实现复杂逻辑
  • 组件封装完整:样式与组件逻辑在同一文件,便于维护
  • 主题支持:内置 Theme Provider,轻松实现主题切换
  • 自动 vendor prefix:自动添加浏览器前缀

局限:

  • 运行时开销:需要在浏览器中解析和注入样式
  • SSR 复杂度:服务端渲染需要额外配置提取样式
  • 学习曲线:需要掌握模板字符串和 styled API
  • 调试难度:生成的类名难以直接对应源码

第三章:Vue Scoped CSS —— 框架集成的优雅

2.6 Vue 的单文件组件哲学

vue-css-demo 项目展示了 Vue 框架的 CSS 模块化方案。看 App.vue

<template>
  <div>
    <h1 class="txt">Hello world in app</h1>
    <HelloWorld />
    <h2 class="txt2">222</h2>
  </div>
</template>

<style scoped>
.txt {
  color: red;
}
.txt2 {
  color: green;
}
</style>

关键在于 <style scoped> 属性。这是 Vue 单文件组件(SFC)的内置特性。

2.7 编译时作用域隔离

Vue 的 scoped 样式机制与 CSS Modules 类似,但更简洁:

  1. 自动添加属性选择器:编译时为每个元素添加 data-v-hash 属性
  2. 样式规则重写:将 .txt { } 转换为 .txt[data-v-a7b3c] { }
  3. 作用域限制:样式只影响当前组件的元素

例如,编译后的 HTML 可能变成:

<h1 class="txt" data-v-a7b3c>Hello world in app</h1>

样式规则变为:

.txt[data-v-a7b3c] {
  color: red;
}

2.8 组件间隔离

HelloWorld.vue 展示了子组件的独立作用域:

<template>
  <div>
    <h1 class="txt">你好,世界!</h1>
    <h2 class="txt2">222</h2>
  </div>
</template>

<style scoped>
.txt {
  color: blue;
}
.txt2 {
  color: pink;
}
</style>

尽管父子组件都使用了 .txt 类名,但由于 scoped 机制,它们互不影响:

  • 父组件的 .txt 只影响父组件模板
  • 子组件的 .txt 只影响子组件模板

2.9 框架级集成

Vue 方案的核心优势是框架原生支持。查看 vite.config.js

import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
})

@vitejs/plugin-vue 插件自动处理 .vue 文件的 scoped 样式,无需额外配置。

2.10 技术特点

优势:

  • 零配置:框架内置支持,开箱即用
  • 语法简洁:只需添加 scoped 属性
  • 性能优秀:编译时处理,无运行时开销
  • 局部覆盖:可通过 :deep() 选择器穿透作用域

局限:

  • 框架绑定:仅适用于 Vue 生态
  • 动态性有限:不如 styled-components 灵活
  • 穿透复杂度:深度选择器需要特殊语法

第四章:技术选型与最佳实践

4.1 三种方案的对比

维度 CSS Modules Styled Components Vue Scoped
作用域 编译时哈希 运行时生成 编译时属性
动态性 ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐
性能 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
学习曲线
框架依赖 React Vue
文件大小 中(需 runtime)

4.2 选型建议

选择 CSS Modules 当:

  • 项目基于 React 且需要轻量级方案
  • 样式相对静态,不需要复杂动态逻辑
  • 团队熟悉传统 CSS,希望平滑过渡
  • 对性能有极致要求

选择 Styled Components 当:

  • 需要高度动态的样式(如主题、状态驱动)
  • 追求样式与逻辑的完全统一
  • 团队 JavaScript 能力强于 CSS
  • 接受一定的运行时开销换取开发体验

选择 Vue Scoped 当:

  • 项目使用 Vue 框架
  • 希望零配置解决样式隔离
  • 需要平衡简洁性和功能性

4.3 混合策略

在实际大型项目中,常常采用混合策略:

// 基础样式用 CSS Modules
import styles from './Button.module.css';

// 动态变体用 styled-components
const VariantButton = styled(Button)`
  background: ${props => props.variant};
`;

或者在 Vue 项目中:

<style scoped>
/* 组件私有样式 */
</style>

<style>
/* 全局共享样式 */
</style>

第五章:CSS 模块化的未来趋势

5.1 CSS 原生作用域

CSS 规范正在引入原生的作用域机制:

@scope (.component) {
  .button { /* 只影响 .component 内的 .button */ }
}

这将使浏览器原生支持样式隔离,减少对构建工具的依赖。

5.2 CSS Houdini

CSS Houdini 允许开发者通过 JavaScript 扩展 CSS 引擎,为样式系统带来编程能力,可能模糊 CSS Modules 和 CSS-in-JS 的边界。

5.3 零运行时 CSS-in-JS

新一代库如 Vanilla Extract、Linaria 尝试结合两者优势:

  • 开发体验:CSS-in-JS 语法
  • 运行时性能:编译为纯 CSS 文件
// Vanilla Extract 示例
import { style } from '@vanilla-extract/css';

export const button = style({
  background: 'blue',
  color: 'white',
});

结语:没有银弹,只有权衡

回顾三个项目,我们看到 CSS 模块化没有唯一正确答案:

技术选型的本质是权衡。理解每种方案的设计哲学、技术实现和适用场景,比盲目追随潮流更重要。

正如组件化思想的核心——关注点分离,CSS 模块化的终极目标不是技术本身,而是让开发者能够专注于创造优秀的用户体验,而不必担心样式冲突的困扰。

当你在下一个项目中面对 CSS 架构选择时,希望这篇文章能为你提供清晰的思考框架。毕竟,最好的技术方案,永远是那个最适合你团队和项目的方案。


搞懂 BFC:一篇文章彻底拿下两列与三列自适应布局

前言

在前端开发中,我们经常会遇到这样的场景:左侧固定宽度做导航,右侧自适应填满剩余空间做内容区;或者左右两侧固定,中间自适应的经典三栏布局。

很多人会直接使用 Flexbox 或 Grid 来解决,这当然没问题。但如果面试官追问:“如果不使用 Flex 和 Grid,仅用传统 CSS,你如何实现?背后的原理是什么?

这时候,就需要请出 CSS 布局中的“定海神针”—— BFC(Block Formatting Context,块级格式化上下文)  了。

一、 到底什么是 BFC?

image.png 官方定义很绕,用人话来说:BFC 就像是一个封闭的、独立的“结界”。
在这个结界里面的元素怎么折腾(比如浮动、塌陷),都不会影响到外面的元素;同理,外面的元素也进不来干扰里面。

它有三个维度的核心规则:怎么创建它、它内部怎么排版、它跟外部怎么相处。

二、 怎么召唤出 BFC?(创建规则)

记住一个前提:普通的块级元素(比如最常见的 div)默认不是 BFC,必须通过特定条件触发。

以下是触发 BFC 的常见方式:

  1. 根元素<html> 标签本身就是最大的 BFC。
  2. 浮动元素:给元素加上 float: left / right(只要不是 none)。
  3. 绝对/固定定位position: absolute / fixed
  4. 行内块元素display: inline-block
  5. 表格相关display: table-cell 等(现在极少用来做布局)。
  6. 🔥 溢出非可见(最常用)overflow: hidden / auto / scroll。(注意:默认的 overflow: visible 不会触发)。
  7. 现代布局display: flex / grid / inline-flex / inline-grid(这会使其成为 Flex/Grid 容器,内部自然形成独立的 BFC 机制)。

三、 BFC 内部布局规则

当一个元素变成了 BFC,它内部就会遵守以下规矩:

  • 垂直排列:BFC 内的块级盒子只能从上到下垂直排布,绝对不可能横向并排。

  • Margin 折叠现象:在同一个 BFC 中,相邻的两个块级元素的垂直外边距会发生“折叠”。比如上面的 margin-bottom: 10px,下面的 margin-top: 20px,两者之间的真实距离是取最大值 20px,而不是相加的 30px

    • 破解之法:把这两个元素放在两个不同的 BFC 结界里,就不会折叠了。
  • 触边对齐(⚠️易错点) :BFC 内部的每个盒子的左外边缘,都会死死贴住这个 BFC 容器的左边缘。

    • 注意:这里说的是“盒子的边缘”贴住容器,但盒子里的“文字内容”是活体,看到浮动元素会主动避让,形成“文字环绕浮动元素”的效果。这也是为什么普通 div 会被浮动覆盖,而文字不会。

四、 BFC 的“对外防御机制”(外部交互规则)

这是 BFC 最核心的价值所在,它拥有两大“超能力”:

  1. 包含内部浮动(解决高度塌陷) :如果 BFC 里面有子元素浮动了,按理说父元素会失去高度(高度塌陷)。但只要父元素触发了 BFC,它就会强行计算并把所有浮动子元素包裹在内
  2. 排除外部浮动(防重叠) :如果一个 BFC 旁边有一个浮动的元素,这个 BFC 绝对不会去跟那个浮动元素重叠,它会自动收缩自己的宽度,给浮动元素让出位置。

正是这第二个超能力,造就了经典的浮动布局!

五、 实战演练:用 BFC 搞定两列与三列布局

1. 经典两列式布局(左固定,右自适应)

需求:左侧导航栏宽 200px,右侧内容区撑满剩余宽度。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>BFC实现两列布局</title>
</head>
<style>
    * {
        margin: 0;
        padding: 0;
    }
    .left {
        width: 200px;
        height: 500px;
        float: left; /* 1. 左侧脱标浮动 */
        background-color: #ccc;
    }
    .right {
        height: 500px;
        overflow: hidden; /* 2. 右侧触发 BFC */
        background-color: #333;
        color: #fff;
        /* 加点内边距让文字不贴边,更好看 */
        padding: 20px; 
        /* 因为加了padding,需要用box-sizing保证不撑大盒子 */
        box-sizing: border-box; 
    }
</style>
<body>
    <!-- 注意:HTML的class属性里不需要加 . 号 -->
    <div class="left">左侧固定区域 (200px)</div>
    
    <!-- 这里使用上面定义好的 .right 类名 -->
    <div class="right">右侧自适应区域 (无论浏览器多宽,我都会填满剩下的空间,且不会覆盖左侧)</div>
</body>
</html>

image.png

原理解析:
左侧 float 起来了,右侧本是一个普通的块级 div,按理说它会无视浮动,占满整行跑到左侧下面去。但我们给右侧加了 overflow: hidden 触发了 BFC。根据 BFC  【排除外部浮动】  的规则,右侧这个“结界”拒绝与左侧的浮动区域重叠,于是它老老实实退缩到了浮动元素的右边,剩余多少宽度它就占多少,完美实现自适应!

2. 经典三列式布局(左右固定,中间自适应)

需求:左侧 200px,右侧 200px,中间自适应。

HTML 结构:

代码生成完成

HTML代码

CSS 代码:

.left {
  width: 200px; height: 500px;
  float: left;
  background: #ccc;
}
.right {
  width: 200px; height: 500px;
  float: right;
  background: #aaa;
}
.center {
  height: 500px;
  overflow: hidden; /* 触发 BFC */
  background: #333;
  color: #fff;
}

image.png原理解析:
左右两侧分别向左向右浮动,脱离了文档流。中间的 div 加上 overflow: hidden 触发 BFC 后,它既要躲避左边的浮动,又要躲避右边的浮动。两头都被挤压,最终只能乖乖夹在中间,剩下的空间全归它所有。

总结

在 Flexbox 和 Grid 横行的今天,我们写业务可能很少再手写 float + BFC 布局了。但是,理解 BFC 绝不是为了写古董代码

当你在页面中遇到莫名的“高度塌陷”、遇到“Margin 合并不生效”、遇到“浮动元素覆盖了后续内容”时,如果你脑子里能瞬间闪过 BFC 的那几条规则,你能在一分钟内定位并解决问题。

BFC 不是过时的技术,而是透视 CSS 底层渲染逻辑的一把钥匙。  掌握了它,你的 CSS 功力才算真正跨入了进阶的大门。

泛型:像“填空”一样写类型,让你的代码从“复制粘贴”中解放

你是不是遇到过这种场景:写了一个函数,处理数字的版本写一遍,处理字符串的版本再写一遍,处理数组的又写一遍……最后代码里全是长得差不多的“双胞胎”。今天我们来学TypeScript的泛型——一个能让你写一次、处处用的“类型模板”。从此告别复制粘贴,做个体面的程序员。

前言

想象一下,你开了一家“万能快递公司”。客户要寄书,你准备书盒;要寄衣服,你准备衣服盒;要寄手机,你准备手机盒……每种物品都要单独设计盒子,累不累?

更好的做法:设计一种可调节大小的盒子,客户说寄什么,你就把盒子调成对应大小。这个“可调节的盒子”,就是泛型

TypeScript的泛型让你在定义函数、类、接口时,先“留个空”,等用的时候再往里填具体类型。这样既保证了类型安全,又避免了重复代码。

一、泛型长啥样?一个简单的例子

先看一个没有泛型的“悲惨世界”:

// 只能处理数字
function identityNumber(arg: number): number {
  return arg;
}
// 只能处理字符串
function identityString(arg: string): string {
  return arg;
}
// 要处理布尔值?再写一个……

用泛型,只需要一个:

function identity<T>(arg: T): T {
  return arg;
}

这里的<T>就像个“占位符”,你调用时可以指定具体类型:

let output1 = identity<string>('hello'); // 类型是 string
let output2 = identity<number>(123);     // 类型是 number

但TS很聪明,能自动推断,所以通常可以省略:

let output = identity('hello'); // TS推断出T为string

二、泛型不只是“传进去又返回来”

你可以约束参数的类型关系。比如,你想让函数接收一个数组,并返回数组的第一个元素:

function getFirst<T>(arr: T[]): T {
  return arr[0];
}

const firstNumber = getFirst([1, 2, 3]);   // 类型 number
const firstString = getFirst(['a', 'b']); // 类型 string

T帮我们保持了“数组元素类型”和“返回值类型”的一致性。

三、泛型约束:给“占位符”画个圈

有时候,你不能让T为所欲为。比如你想写一个函数,打印参数的length属性:

function logLength<T>(arg: T): T {
  console.log(arg.length); // ❌ 报错:T可能没有length
  return arg;
}

因为T可能是numberboolean,它们没有.length。这时候需要约束——告诉TS:T必须是有length属性的类型。

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(arg: T): T {
  console.log(arg.length); // ✅ 安全
  return arg;
}

logLength('hello');    // 字符串有length
logLength([1, 2, 3]);  // 数组有length
logLength(123);        // ❌ 数字没有length,报错

extends关键字在这里不是继承,而是“约束为某个类型的子集”。

四、泛型接口:把接口变成“模具”

接口也可以泛型化,比如定义一个通用的响应结构:

interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

// 使用
type User = { name: string; age: number };
const response: ApiResponse<User> = {
  code: 200,
  message: 'success',
  data: { name: '张三', age: 18 }
};

这样,你就能用一个接口描述所有API返回格式,只需替换T

五、泛型类:像造模具一样造类

类同样可以泛型:

class Queue<T> {
  private data: T[] = [];
  push(item: T) {
    this.data.push(item);
  }
  pop(): T | undefined {
    return this.data.shift();
  }
}

const numberQueue = new Queue<number>();
numberQueue.push(123);
numberQueue.push('456'); // ❌ 报错,只能放数字

六、泛型工具类型:TS内置的“变形金刚”

TS提供了一些内置的泛型工具,能帮你快速转换类型。

1. Partial<T>:把属性都变成可选

interface User {
  name: string;
  age: number;
}
type PartialUser = Partial<User>; // { name?: string; age?: number; }

2. Readonly<T>:把所有属性变成只读

type ReadonlyUser = Readonly<User>; // { readonly name: string; readonly age: number; }

3. Pick<T, K>:从T中挑选部分属性

type UserName = Pick<User, 'name'>; // { name: string; }

4. Omit<T, K>:从T中排除部分属性

type UserWithoutAge = Omit<User, 'age'>; // { name: string; }

还有Record<K, T>ExcludeExtract等,遇到具体场景时再查文档即可。

七、联合类型与交叉类型:不是泛型,但常一起用

联合类型(|):这个或那个

let value: string | number;
value = 'hello'; // OK
value = 123;     // OK
value = true;    // ❌

联合类型适合“不确定具体是哪个,但知道是有限的几种”。

交叉类型(&):既要又要

interface Name { name: string; }
interface Age { age: number; }
type Person = Name & Age; // 同时有name和age属性

const p: Person = { name: '张三', age: 18 };

交叉类型常用来合并多个类型。

八、类型保护:让TS相信你

当你使用联合类型时,TS会限制你只能调用所有类型共有的方法。要调用特定类型的方法,需要类型保护

function printLength(value: string | number) {
  // console.log(value.length); // ❌ 报错,number没有length
  if (typeof value === 'string') {
    console.log(value.length); // ✅ 这里TS知道value是string
  } else {
    console.log(value.toFixed(2));
  }
}

除了typeof,还有instanceofin关键字、自定义类型守卫。

九、实战:用泛型写一个“万能”的缓存函数

interface Cache<T> {
  get(key: string): T | undefined;
  set(key: string, value: T): void;
}

function createCache<T>(): Cache<T> {
  const store: Record<string, T> = {};
  return {
    get(key) { return store[key]; },
    set(key, value) { store[key] = value; }
  };
}

const stringCache = createCache<string>();
stringCache.set('name', '张三');
const name = stringCache.get('name'); // 类型是 string | undefined

const numberCache = createCache<number>();
numberCache.set('age', 18);

看,一套代码同时服务了字符串缓存和数字缓存,类型还完全安全。

十、总结:泛型是“类型编程”的起点

  • 泛型就是“类型的参数”,让组件(函数、类、接口)能适应多种类型,同时保留类型关系。
  • 约束extends限定泛型的范围。
  • 泛型接口/类让数据结构通用。
  • 内置工具类型(Partial、Pick等)简化常见类型转换。
  • 联合类型表示“或”,交叉类型表示“且”,类型保护用来区分联合中的具体类型。

掌握泛型,你就能写出更抽象、更复用、更安全的代码。明天我们将继续TypeScript的高级主题——装饰器,看看这个类似Java注解的特性,如何在TS里玩出花样。

如果你觉得今天的“万能模具”讲得通透,点个赞让更多人看到。明天我们聊聊装饰器——那个在Angular和NestJS里无处不在的黑魔法。我们明天见!

前端开发效率翻倍:15个超级实用的工具函数,直接复制进项目(建议收藏)

大家好,今天给大家整理了一套前端日常开发高频用到的工具函数

没有复杂算法,也没有花里胡哨的封装,全是业务里真正常用的:时间格式化、手机号脱敏、防抖节流、深拷贝、URL参数解析……全部即插即用,复制到项目里就能跑,非常适合放进自己的 utils 工具库。


1. 时间格式化(最常用)

把时间戳 / Date 对象转成 YYYY-MM-DD HH:mm:ss

function formatDate(date, fmt = 'YYYY-MM-DD HH:mm:ss') {
  if (!date) return ''
  date = date instanceof Date ? date : new Date(date)

  const o = {
    'M+': date.getMonth() + 1,
    'D+': date.getDate(),
    'H+': date.getHours(),
    'm+': date.getMinutes(),
    's+': date.getSeconds()
  }

  if (/(Y+)/.test(fmt)) {
    fmt = fmt.replace(RegExp.$1, date.getFullYear().toString().slice(4 - RegExp.$1.length))
  }

  for (const k in o) {
    if (new RegExp(`(${k})`).test(fmt)) {
      fmt = fmt.replace(
        RegExp.$1,
        RegExp.$1.length === 1 ? o[k] : o[k].toString().padStart(2, '0')
      )
    }
  }
  return fmt
}

// 使用
formatDate(new Date()) // 2026-04-09 15:30:20

2. 防抖(输入搜索专用)

function debounce(fn, delay = 300) {
  let timer = null
  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), delay)
  }
}

3. 节流(滚动/点击防重复)

function throttle(fn, interval = 500) {
  let last = 0
  return function (...args) {
    const now = Date.now()
    if (now - last >= interval) {
      last = now
      fn.apply(this, args)
    }
  }
}

4. 深拷贝(处理对象/数组)

function deepClone(obj, hash = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') return obj
  if (hash.has(obj)) return hash.get(obj)

  const clone = Array.isArray(obj) ? [] : {}
  hash.set(obj, clone)

  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      clone[key] = deepClone(obj[key], hash)
    }
  }
  return clone
}

5. 获取 URL 参数

function getQueryParams(url = location.href) {
  const params = {}
  new URL(url).searchParams.forEach((v, k) => (params[k] = v))
  return params
}

// 使用
getQueryParams('https://xxx.com?id=1&name=test') // { id: '1', name: 'test' }

6. 手机号脱敏

function maskPhone(phone) {
  if (!phone || phone.length !== 11) return phone
  return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}

// 13812345678 → 138****5678

7. 姓名脱敏

function maskName(name) {
  if (!name) return ''
  if (name.length === 1) return name
  return name[0] + '*'.repeat(name.length - 1)
}

// 张三 → 张*
// 张三丰 → 张**

8. 数字千分位格式化

function formatMoney(num) {
  if (isNaN(num)) return '0'
  return Number(num).toLocaleString()
}

// 1234567 → 1,234,567

9. 存储操作(localStorage 封装)

const storage = {
  set(key, val) {
    localStorage.setItem(key, JSON.stringify(val))
  },
  get(key) {
    const val = localStorage.getItem(key)
    if (!val) return null
    try {
      return JSON.parse(val)
    } catch {
      return val
    }
  },
  remove(key) {
    localStorage.removeItem(key)
  },
  clear() {
    localStorage.clear()
  }
}

10. 判断数据类型

function getType(val) {
  return Object.prototype.toString.call(val).slice(8, -1).toLowerCase()
}

// getType([]) → 'array'
// getType({}) → 'object'
// getType(null) → 'null'

11. 数组去重

function uniqueArr(arr) {
  return [...new Set(arr)]
}

12. 数组扁平化

function flatten(arr) {
  return arr.flat(Infinity)
}

13. 生成随机字符串(ID)

function randomStr(len = 8) {
  const str = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678'
  let res = ''
  for (let i = 0; i < len; i++) {
    res += str[Math.floor(Math.random() * str.length)]
  }
  return res
}

14. 防抖立即执行版(提交按钮专用)

function debounceImmediate(fn, delay = 500) {
  let timer = null
  return function (...args) {
    const first = !timer
    clearTimeout(timer)
    timer = setTimeout(() => (timer = null), delay)
    if (first) fn.apply(this, args)
  }
}

15. 滚动到顶部(平滑)

function scrollToTop() {
  window.scrollTo({ top: 0, behavior: 'smooth' })
}

最后

这 15 个工具函数基本覆盖了80% 前端业务场景,建议直接新建一个 utils.js 全部存起来,以后开发至少快一倍。


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

前端面试通关指南:30个高频手写JS算法,吃透就能拿高薪(附完整代码)

聊前端面试,算法永远是绕不开的坎。很多小伙伴项目经验很丰富,框架用得溜,但一上面试场就被手写算法题卡住,直接导致面试失利。

我整理了一份30个高频手写JS算法清单,覆盖ES6语法、数组操作、链表、二叉树、动态规划等核心考点,从简单到进阶,每道题都附完整可复制代码+考点解析,跟着敲一遍,面试时直接手到擒来!

一、基础语法与数组变换(必拿分,入门级)

这类题考察基础语法功底,难度低、频率高,面试时先搞定这类题,稳拿基础分,给面试官留好第一印象。

1. 深度克隆(Deep Clone)

考察点:引用类型传址、循环引用、基本类型与引用类型区别

function deepClone(obj, map = new WeakMap()) {
  // 基本类型直接返回(null也是基本类型范畴)
  if (obj === null || typeof obj !== 'object') return obj;
  // 处理循环引用(避免无限递归)
  if (map.has(obj)) return map.get(obj);
  
  // 区分数组和对象,创建对应克隆容器
  const clone = Array.isArray(obj) ? [] : {};
  map.set(obj, clone); // 存入map,标记已克隆
  
  // 遍历对象/数组,递归克隆每一项
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) { // 只克隆自身属性,不克隆原型链属性
      clone[key] = deepClone(obj[key], map);
    }
  }
  return clone;
}

// 测试
const obj = { a: 1, b: { c: 2 }, d: [3, 4] };
const cloneObj = deepClone(obj);
obj.b.c = 100;
console.log(cloneObj.b.c); // 2(克隆后互不影响)

2. 数组扁平化(Flatten)

考察点:递归、数组方法(forEach、concat)、ES6新特性

// 解法1:递归(兼容性好,易理解)
function flatten(arr) {
  let result = [];
  arr.forEach(item => {
    // 若当前项是数组,递归扁平化,否则直接加入结果
    result = result.concat(Array.isArray(item) ? flatten(item) : item);
  });
  return result;
}

// 解法2:ES6 flat方法(简洁,实际开发常用)
const flatten = arr => arr.flat(Infinity); // Infinity表示无限层级扁平化

// 解法3:reduce实现(更简洁,面试加分)
const flatten = arr => arr.reduce((prev, curr) => {
  return prev.concat(Array.isArray(curr) ? flatten(curr) : curr);
}, []);

// 测试
console.log(flatten([1, [2, [3, 4], 5]])); // [1,2,3,4,5]

3. 防抖(Debounce)

考察点:高频事件控制、定时器、this指向

// 核心:频繁触发时,只在最后一次触发后延迟执行
function debounce(fn, delay = 500) {
  let timer = null; // 定时器标识,闭包保存
  return function(...args) {
    clearTimeout(timer); // 每次触发,清除上一次定时器
    // 重新设置定时器,延迟执行目标函数
    timer = setTimeout(() => {
      fn.apply(this, args); // 绑定this和参数,适配实际场景
    }, delay);
  };
}

// 用法(搜索框输入示例)
const handleSearch = debounce((val) => {
  console.log('请求搜索接口:', val);
}, 500);

4. 节流(Throttle)

考察点:高频事件控制、时间戳/定时器、性能优化

// 解法1:时间戳版(触发时立即执行,之后固定时间内不执行)
function throttle(fn, interval = 1000) {
  let lastTime = 0; // 上一次执行时间
  return function(...args) {
    const nowTime = Date.now(); // 当前时间
    // 若当前时间 - 上一次执行时间 > 间隔,执行函数
    if (nowTime - lastTime >= interval) {
      fn.apply(this, args);
      lastTime = nowTime; // 更新上一次执行时间
    }
  };
}

// 解法2:定时器版(触发后延迟执行,固定时间内只执行一次)
function throttle2(fn, interval = 1000) {
  let timer = null;
  return function(...args) {
    if (!timer) { // 若定时器不存在,执行函数
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null; // 执行后清空定时器,允许下次执行
      }, interval);
    }
  };
}

// 用法(滚动加载示例)
window.addEventListener('scroll', throttle(() => {
  console.log('滚动加载更多');
}, 1000));

5. 数组去重(Unique)

考察点:数组方法、Set数据结构、兼容性

// 解法1:Set实现(最简洁,ES6+常用)
const unique = arr => [...new Set(arr)];

// 解法2:indexOf实现(兼容性好,适合旧项目)
function unique(arr) {
  const result = [];
  arr.forEach(item => {
    // 若结果数组中没有当前项,加入结果
    if (result.indexOf(item) === -1) {
      result.push(item);
    }
  });
  return result;
}

// 解法3:filter+indexOf(简洁,面试常用)
const unique = arr => arr.filter((item, index) => {
  // 只保留第一次出现的元素(indexOf返回第一个匹配的索引)
  return arr.indexOf(item) === index;
});

// 测试
console.log(unique([1, 2, 2, 3, 3, 3])); // [1,2,3]

6. 数组排序(冒泡排序)

考察点:排序原理、循环逻辑、基础算法思维

// 冒泡排序:相邻元素对比,大的往后移,每次循环确定一个最大值
function bubbleSort(arr) {
  const len = arr.length;
  // 外层循环:控制排序轮次(共len-1轮)
  for (let i = 0; i < len - 1; i++) {
    let flag = false; // 优化:标记是否发生交换,若无则排序完成
    // 内层循环:对比相邻元素,交换位置
    for (let j = 0; j < len - 1 - i; j++) {
      if (arr[j] > arr[j + 1]) {
        // 交换两个元素
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
        flag = true;
      }
    }
    if (!flag) break; // 无交换,直接退出循环
  }
  return arr;
}

// 测试
console.log(bubbleSort([3, 1, 4, 1, 5, 9])); // [1,1,3,4,5,9]

7. 数组排序(快速排序)

考察点:分治思想、递归、时间复杂度优化(面试高频)

// 快速排序:分治思想,选一个基准值,将数组分成两部分,递归排序
function quickSort(arr) {
  // 终止条件:数组长度<=1,直接返回
  if (arr.length <= 1) return arr;
  // 选基准值(中间项,避免极端情况)
  const pivot = arr[Math.floor(arr.length / 2)];
  // 分治:小于基准值的放左边,等于的放中间,大于的放右边
  const left = arr.filter(x => x < pivot);
  const middle = arr.filter(x => x === pivot);
  const right = arr.filter(x => x > pivot);
  // 递归排序左右两部分,拼接结果
  return [...quickSort(left), ...middle, ...quickSort(right)];
}

// 测试
console.log(quickSort([3, 1, 4, 1, 5, 9])); // [1,1,3,4,5,9]

8. 实现数组forEach方法

考察点:数组方法原理、this绑定、回调函数

// 模拟数组forEach,接收回调函数和this指向
Array.prototype.myForEach = function(callback, thisArg) {
  // 边界判断:回调必须是函数
  if (typeof callback !== 'function') {
    throw new TypeError('callback must be a function');
  }
  // 遍历当前数组(this指向调用myForEach的数组)
  for (let i = 0; i < this.length; i++) {
    // 执行回调,传入三个参数:当前项、索引、原数组,绑定thisArg
    callback.call(thisArg, this[i], i, this);
  }
};

// 用法
[1, 2, 3].myForEach((item, index) => {
  console.log(item, index); // 1 0 | 2 1 | 3 2
});

9. 实现数组map方法

考察点:数组方法原理、返回值处理、回调函数

// 模拟数组map,返回新数组,新数组元素是回调函数的返回值
Array.prototype.myMap = function(callback, thisArg) {
  if (typeof callback !== 'function') {
    throw new TypeError('callback must be a function');
  }
  const result = []; // 存储结果的新数组
  for (let i = 0; i < this.length; i++) {
    // 执行回调,将返回值加入结果数组
    result.push(callback.call(thisArg, this[i], i, this));
  }
  return result;
};

// 用法
const newArr = [1, 2, 3].myMap(item => item * 2);
console.log(newArr); // [2,4,6]

10. 实现数组filter方法

考察点:数组方法原理、条件判断、返回值处理

// 模拟数组filter,返回满足条件的元素组成的新数组
Array.prototype.myFilter = function(callback, thisArg) {
  if (typeof callback !== 'function') {
    throw new TypeError('callback must be a function');
  }
  const result = [];
  for (let i = 0; i < this.length; i++) {
    // 回调返回true,将当前项加入结果数组
    if (callback.call(thisArg, this[i], i, this)) {
      result.push(this[i]);
    }
  }
  return result;
};

// 用法
const evenArr = [1, 2, 3, 4].myFilter(item => item % 2 === 0);
console.log(evenArr); // [2,4]

二、原型与作用域(进阶必问,中层前端考点)

这类题考察对JS底层原理的理解,是区分初级和中级前端的关键,面试时高频出现,必须吃透。

11. 实现new关键字

考察点:原型链、构造函数、this绑定、返回值判断

// myNew:模拟new关键字的作用
function myNew(fn, ...args) {
  // 1. 创建一个空对象,让其原型指向构造函数的prototype
  const obj = Object.create(fn.prototype);
  // 2. 执行构造函数,将this绑定到新创建的对象上
  const result = fn.apply(obj, args);
  // 3. 判断构造函数的返回值:若为对象(非null),则返回该对象;否则返回新创建的obj
  return result instanceof Object ? result : obj;
}

// 测试
function Person(name, age) {
  this.name = name;
  this.age = age;
}
const person = myNew(Person, '张三', 25);
console.log(person.name); // 张三
console.log(person instanceof Person); // true

12. 手写Promise(简易版,支持then链式调用)

考察点:异步编程、状态机、回调队列、链式调用

class MyPromise {
  constructor(exector) {
    // 初始化状态:pending(等待)、fulfilled(成功)、rejected(失败)
    this.status = 'pending';
    this.value = null; // 成功时的返回值
    this.reason = null; // 失败时的原因
    // 存储成功/失败的回调队列(支持多个then绑定)
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    // 成功回调:改变状态,保存值,执行所有成功回调
    const resolve = (value) => {
      if (this.status === 'pending') {
        this.status = 'fulfilled';
        this.value = value;
        // 执行所有缓存的成功回调
        this.onFulfilledCallbacks.forEach(fn => fn());
      }
    };

    // 失败回调:改变状态,保存原因,执行所有失败回调
    const reject = (reason) => {
      if (this.status === 'pending') {
        this.status = 'rejected';
        this.reason = reason;
        // 执行所有缓存的失败回调
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };

    // 执行 executor,捕获异常,异常时调用reject
    try {
      exector(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  // then方法:支持链式调用,返回新的Promise
  then(onFulfilled, onRejected) {
    // 兼容:若then未传回调,默认透传值/原因
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason; };

    // 返回新Promise,实现链式调用
    return new MyPromise((resolve, reject) => {
      // 状态为成功时,执行成功回调
      if (this.status === 'fulfilled') {
        try {
          const result = onFulfilled(this.value);
          // 回调返回值,传递给下一个then的成功回调
          resolve(result);
        } catch (err) {
          // 回调执行失败,传递给下一个then的失败回调
          reject(err);
        }
      }

      // 状态为失败时,执行失败回调
      if (this.status === 'rejected') {
        try {
          const result = onRejected(this.reason);
          resolve(result);
        } catch (err) {
          reject(err);
        }
      }

      // 状态为等待时,缓存回调
      if (this.status === 'pending') {
        this.onFulfilledCallbacks.push(() => {
          try {
            const result = onFulfilled(this.value);
            resolve(result);
          } catch (err) {
            reject(err);
          }
        });
        this.onRejectedCallbacks.push(() => {
          try {
            const result = onRejected(this.reason);
            resolve(result);
          } catch (err) {
            reject(err);
          }
        });
      }
    });
  }
}

// 测试
new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve('成功');
  }, 1000);
}).then(res => {
  console.log(res); // 成功
  return '下一个then';
}).then(res => {
  console.log(res); // 下一个then
});

13. 实现Promise.all方法

考察点:Promise并发控制、数组遍历、状态判断

// Promise.all:接收一个Promise数组,所有Promise成功才成功,有一个失败则失败
Promise.myAll = function(promises) {
  return new Promise((resolve, reject) => {
    // 边界判断:若传入的不是数组,直接reject
    if (!Array.isArray(promises)) {
      return reject(new TypeError('arguments must be an array'));
    }

    const result = []; // 存储所有Promise的成功结果
    let count = 0; // 记录已完成的Promise数量

    // 若数组为空,直接resolve空数组
    if (promises.length === 0) return resolve(result);

    // 遍历每个Promise
    promises.forEach((promise, index) => {
      // 兼容非Promise值(直接视为成功)
      Promise.resolve(promise).then(res => {
        result[index] = res; // 按原顺序存储结果
        count++;
        // 所有Promise都完成,resolve结果数组
        if (count === promises.length) {
          resolve(result);
        }
      }).catch(err => {
        // 有一个失败,直接reject该错误
        reject(err);
      });
    });
  });
};

// 测试
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);
Promise.myAll([p1, p2, p3]).then(res => {
  console.log(res); // [1,2,3]
});

14. 实现Promise.race方法

考察点:Promise并发控制、第一个完成的状态优先

// Promise.race:接收一个Promise数组,第一个完成(成功/失败)的结果作为最终结果
Promise.myRace = function(promises) {
  return new Promise((resolve, reject) => {
    if (!Array.isArray(promises)) {
      return reject(new TypeError('arguments must be an array'));
    }

    // 遍历每个Promise,第一个完成的直接改变状态
    promises.forEach(promise => {
      Promise.resolve(promise).then(res => {
        resolve(res); // 第一个成功,直接resolve
      }).catch(err => {
        reject(err); // 第一个失败,直接reject
      });
    });
  });
};

// 测试
const p1 = new Promise((resolve) => setTimeout(() => resolve(1), 1000));
const p2 = new Promise((resolve, reject) => setTimeout(() => reject('失败'), 500));
Promise.myRace([p1, p2]).catch(err => {
  console.log(err); // 失败(p2先完成,且失败)
});

15. 函数柯里化(Currying)

考察点:闭包、参数复用、函数式编程

// 柯里化:将多参数函数,转化为单参数函数的链式调用
function curry(fn) {
  // 闭包保存已传入的参数
  return function curried(...args) {
    // 若传入的参数数量 >= 原函数的参数数量,执行原函数
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      // 否则,返回一个新函数,接收剩余参数,递归调用curried
      return function(...args2) {
        return curried.apply(this, [...args, ...args2]);
      };
    }
  };
}

// 用法
const add = (a, b, c) => a + b + c; // 原函数,3个参数
const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)(3)); // 6(链式调用)
console.log(curriedAdd(1, 2)(3)); // 6(支持部分参数)
console.log(curriedAdd(1, 2, 3)); // 6(支持完整参数)

16. 实现call方法

考察点:this绑定、函数执行、参数传递

// 模拟Function.prototype.call:改变函数this指向,立即执行函数
Function.prototype.myCall = function(context, ...args) {
  // 边界判断:若context为null/undefined,指向window(浏览器环境)
  context = context || window;
  // 给context添加一个临时属性,指向当前函数(this就是调用myCall的函数)
  const fnKey = Symbol('tempFn'); // 用Symbol避免属性冲突
  context[fnKey] = this;
  // 执行函数,传入参数,获取返回值
  const result = context[fnKey](...args);
  // 删除临时属性,避免污染context
  delete context[fnKey];
  // 返回函数执行结果
  return result;
};

// 测试
const obj = { name: '张三' };
function sayHello(age) {
  console.log(`我是${this.name},年龄${age}`);
}
sayHello.myCall(obj, 25); // 我是张三,年龄25

17. 实现apply方法

考察点:this绑定、函数执行、参数传递(与call的区别:参数是数组)

// 模拟Function.prototype.apply:改变this指向,参数以数组形式传递,立即执行
Function.prototype.myApply = function(context, args = []) {
  context = context || window;
  const fnKey = Symbol('tempFn');
  context[fnKey] = this;
  // 执行函数,args是数组,用扩展运算符展开
  const result = context[fnKey](...args);
  delete context[fnKey];
  return result;
};

// 测试
const obj = { name: '李四' };
function sayHello(age, gender) {
  console.log(`我是${this.name},年龄${age},性别${gender}`);
}
sayHello.myApply(obj, [28, '男']); // 我是李四,年龄28,性别男

18. 实现bind方法

考察点:this绑定、闭包、函数柯里化、构造函数兼容

// 模拟Function.prototype.bind:改变this指向,返回一个新函数,不立即执行
Function.prototype.myBind = function(context, ...args1) {
  const fn = this; // 保存当前函数(this就是调用myBind的函数)
  // 返回新函数
  const boundFn = function(...args2) {
    // 兼容构造函数:若新函数被new调用,this指向实例,否则指向context
    const isNew = this instanceof boundFn;
    const targetContext = isNew ? this : context;
    // 合并参数,执行原函数
    return fn.apply(targetContext, [...args1, ...args2]);
  };
  // 继承原函数的原型,确保new调用时,实例能访问原函数原型上的属性
  boundFn.prototype = Object.create(fn.prototype);
  return boundFn;
};

// 测试1:普通调用
const obj = { name: '王五' };
function sayHello(age) {
  console.log(`我是${this.name},年龄${age}`);
}
const boundSay = sayHello.myBind(obj, 30);
boundSay(); // 我是王五,年龄30

// 测试2:new调用(构造函数兼容)
function Person(name, age) {
  this.name = name;
  this.age = age;
}
const BoundPerson = Person.myBind(null, '赵六');
const person = new BoundPerson(35);
console.log(person.name); // 赵六
console.log(person.age); // 35

19. 实现防抖+立即执行版

考察点:防抖原理、立即执行逻辑、定时器控制

// 立即执行版防抖:第一次触发立即执行,之后频繁触发不执行,延迟后可再次触发
function debounceImmediate(fn, delay = 500) {
  let timer = null;
  return function(...args) {
    // 若定时器存在,清除定时器(取消延迟执行)
    if (timer) clearTimeout(timer);
    // 判断是否是第一次触发(timer为null)
    const isImmediate = !timer;
    if (isImmediate) {
      fn.apply(this, args); // 立即执行
    }
    // 重置定时器,延迟后清空timer,允许下次立即执行
    timer = setTimeout(() => {
      timer = null;
    }, delay);
  };
}

// 用法(按钮提交示例,避免重复提交,第一次点击立即执行)
const handleSubmit = debounceImmediate(() => {
  console.log('提交表单');
}, 1000);

20. 实现节流+立即执行/延迟执行可选版

考察点:节流原理、参数配置、灵活性优化

// 可选版节流:可配置立即执行(leading)和延迟执行(trailing)
function throttleOpt(fn, interval = 1000, options = { leading: true, trailing: false }) {
  const { leading, trailing } = options;
  let lastTime = 0;
  let timer = null;

  return function(...args) {
    const nowTime = Date.now();
    // 若不允许立即执行,且是第一次触发,重置lastTime
    if (!leading && !lastTime) {
      lastTime = nowTime;
    }

    // 计算剩余时间
    const remainingTime = interval - (nowTime - lastTime);
    // 剩余时间<=0,执行函数
    if (remainingTime <= 0) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      fn.apply(this, args);
      lastTime = nowTime;
    } else if (trailing && !timer) {
      // 允许延迟执行,且无定时器,设置延迟执行
      timer = setTimeout(() => {
        timer = null;
        lastTime = Date.now();
        fn.apply(this, args);
      }, remainingTime);
    }
  };
}

// 用法
// 立即执行,不延迟执行(默认)
const throttle1 = throttleOpt(() => console.log('立即执行'), 1000);
// 不立即执行,延迟执行
const throttle2 = throttleOpt(() => console.log('延迟执行'), 1000, { leading: false, trailing: true });

三、数据结构与算法(大厂高频,高级前端考点)

这类题考察数据结构基础和算法思维,是大厂面试的重点,也是拉开薪资差距的关键,建议重点练习。

21. 两数之和(Two Sum)

考察点:哈希表、时间复杂度优化(从O(n²)优化到O(n))

// 题目:给定一个整数数组和一个目标值,找出数组中和为目标值的两个整数的索引
function twoSum(nums, target) {
  const map = new Map(); // 用Map存储{数值: 索引},快速查找
  for (let i = 0; i < nums.length; i++) {
    const complement = target - nums[i]; // 互补值
    // 若互补值存在于Map中,返回两个索引
    if (map.has(complement)) {
      return [map.get(complement), i];
    }
    // 否则,将当前数值和索引存入Map
    map.set(nums[i], i);
  }
  return []; // 无匹配项,返回空数组
}

// 测试
console.log(twoSum([2, 7, 11, 15], 9)); // [0,1]

22. LRU缓存机制

考察点:哈希表+双向链表、缓存淘汰策略(Vue3响应式缓存底层类似)

// LRU:最近最少使用,超出容量时,删除最久未使用的元素
class LRUCache {
  constructor(capacity) {
    this.capacity = capacity; // 缓存容量
    this.cache = new Map(); // Map特性:插入顺序就是访问顺序,可快速获取最久未使用的元素
  }

  // 获取元素:访问后,将元素移到最近使用的位置
  get(key) {
    if (!this.cache.has(key)) return -1; // 无该元素,返回-1
    const value = this.cache.get(key);
    this.cache.delete(key); // 删除旧位置
    this.cache.set(key, value); // 重新插入,移到末尾(最近使用)
    return value;
  }

  // 存入元素:超出容量时,删除最久未使用的元素(Map的第一个键)
  put(key, value) {
    // 若元素已存在,先删除(避免覆盖后,位置不变)
    if (this.cache.has(key)) {
      this.cache.delete(key);
    }
    // 超出容量,删除最久未使用的元素
    if (this.cache.size >= this.capacity) {
      const oldestKey = this.cache.keys().next().value; // Map的第一个键是最久未使用的
      this.cache.delete(oldestKey);
    }
    // 存入新元素,移到最近使用的位置
    this.cache.set(key, value);
  }
}

// 测试
const lru = new LRUCache(2);
lru.put(1, 1);
lru.put(2, 2);
console.log(lru.get(1)); // 1(访问后,1变为最近使用)
lru.put(3, 3); // 超出容量,删除最久未使用的2
console.log(lru.get(2)); // -1(已被删除)

23. 发布-订阅模式(EventBus)

考察点:设计模式、组件通信、回调函数管理(Vue EventBus底层原理)

// 发布-订阅模式:实现组件间通信,解耦
class EventEmitter {
  constructor() {
    this.events = new Map(); // 存储事件:{事件名: [回调函数数组]}
  }

  // 订阅事件:绑定回调函数
  on(event, callback) {
    if (!this.events.has(event)) {
      this.events.set(event, []); // 若事件不存在,初始化回调数组
    }
    this.events.get(event).push(callback); // 加入回调数组
  }

  // 发布事件:触发该事件的所有回调函数
  emit(event, ...args) {
    const callbacks = this.events.get(event);
    if (callbacks) {
      // 执行所有回调,传入参数
      callbacks.forEach(cb => cb(...args));
    }
  }

  // 取消订阅:移除指定事件的指定回调
  off(event, callback) {
    const callbacks = this.events.get(event);
    if (callbacks) {
      // 过滤掉要取消的回调,保留其他回调
      this.events.set(event, callbacks.filter(cb => cb !== callback));
      // 若回调数组为空,删除该事件
      if (this.events.get(event).length === 0) {
        this.events.delete(event);
      }
    }
  }

  // 一次性订阅:触发一次后,自动取消订阅
  once(event, callback) {
    // 包装回调,执行后取消订阅
    const wrapCallback = (...args) => {
      callback(...args);
      this.off(event, wrapCallback);
    };
    this.on(event, wrapCallback);
  }
}

// 测试
const bus = new EventEmitter();
const callback = (msg) => console.log('收到消息:', msg);

bus.on('message', callback);
bus.emit('message', 'Hello World'); // 收到消息:Hello World

bus.off('message', callback);
bus.emit('message', 'Hello Again'); // 无输出(已取消订阅)

bus.once('onceEvent', () => console.log('一次性事件'));
bus.emit('onceEvent'); // 一次性事件
bus.emit('onceEvent'); // 无输出(已自动取消)

24. 实现链表反转(单链表)

考察点:链表数据结构、指针操作、递归/迭代思维

// 1. 定义单链表节点
class ListNode {
  constructor(val = 0, next = null) {
    this.val = val;
    this.next = next;
  }
}

// 解法1:迭代法(推荐,空间复杂度O(1))
function reverseList(head) {
  let prev = null; // 前驱节点
  let curr = head; // 当前节点
  while (curr !== null) {
    const next = curr.next; // 保存下一个节点
    curr.next = prev; // 反转当前节点的指针
    prev = curr; // 前驱节点后移
    curr = next; // 当前节点后移
  }
  return prev; // 反转后,prev是新的头节点
}

// 解法2:递归法(易理解,空间复杂度O(n))
function reverseListRecursive(head) {
  // 终止条件:空节点或只有一个节点,直接返回
  if (head === null || head.next === null) return head;
  // 递归反转后续节点
  const newHead = reverseListRecursive(head.next);
  // 反转当前节点和下一个节点的指针
  head.next.next = head;
  head.next = null; // 避免循环
  return newHead;
}

// 测试
const head = new ListNode(1, new ListNode(2, new ListNode(3)));
const reversedHead = reverseList(head);
// 遍历反转后的链表:3 -> 2 -> 1
let curr = reversedHead;
while (curr) {
  console.log(curr.val); // 3 2 1
  curr = curr.next;
}

25. 判断回文链表

考察点:链表操作、双指针、回文判断

// 题目:判断一个单链表是否是回文(正读和反读一样)
// 步骤:1. 找到链表中点;2. 反转后半部分;3. 对比前半部分和反转后的后半部分
function isPalindrome(head) {
  if (head === null || head.next === null) return true; // 空链表或单个节点,是回文

  // 1. 找到链表中点(慢指针走1步,快指针走2步,快指针到末尾时,慢指针到中点)
  let slow = head;
  let fast = head;
  while (fast.next !== null && fast.next.next !== null) {
    slow = slow.next;
    fast = fast.next.next;
  }

  // 2. 反转后半部分链表(从slow.next开始)
  let prev = null;
  let curr = slow.next;
  while (curr !== null) {
    const next = curr.next;
    curr.next = prev;
    prev = curr;
    curr = next;
  }
  slow.next = prev; // 反转后的后半部分链表,头节点是prev

  // 3. 对比前半部分和反转后的后半部分
  let left = head;
  let right = prev;
  while (right !== null) {
    if (left.val !== right.val) return false; // 不相等,不是回文
    left = left.next;
    right = right.next;
  }
  return true; // 全部相等,是回文
}

// 测试
const head1 = new ListNode(1, new ListNode(2, new ListNode(1)));
console.log(isPalindrome(head1)); // true

const head2 = new ListNode(1, new ListNode(2, new ListNode(3)));
console.log(isPalindrome(head2)); // false

26. 二叉树的前序遍历(递归+迭代)

考察点:二叉树数据结构、遍历算法、递归/迭代思维

// 1. 定义二叉树节点
class TreeNode {
  constructor(val = 0, left = null, right = null) {
    this.val = val;
    this.left = left;
    this.right = right;
  }
}

// 解法1:递归法(简洁,易理解)
function preorderTraversalRecursive(root, result = []) {
  if (root === null) return result;
  result.push(root.val); // 根节点
  preorderTraversalRecursive(root.left, result); // 左子树
  preorderTraversalRecursive(root.right, result); // 右子树
  return result;
}

// 解法2:迭代法(面试常考,避免递归栈溢出)
function preorderTraversalIterative(root) {
  const result = [];
  if (root === null) return result;
  const stack = [root]; // 用栈存储节点
  while (stack.length > 0) {
    const node = stack.pop(); // 弹出栈顶节点(根节点)
    result.push(node.val);
    // 注意:栈是先进后出,所以先压右子树,再压左子树
    if (node.right !== null) stack.push(node.right);
    if (node.left !== null) stack.push(node.left);
  }
  return result;
}

// 测试
const root = new TreeNode(1, null, new TreeNode(2, new TreeNode(3)));
console.log(preorderTraversalRecursive(root)); // [1,2,3]
console.log(preorderTraversalIterative(root)); // [1,2,3]

27. 二叉树的中序遍历(递归+迭代)

考察点:二叉树遍历、栈的应用

// 解法1:递归法
function inorderTraversalRecursive(root, result = []) {
  if (root === null) return result;
  inorderTraversalRecursive(root.left, result); // 左子树
  result.push(root.val); // 根节点
  inorderTraversalRecursive(root.right, result); // 右子树
  return result;
}

// 解法2:迭代法
function inorderTraversalIterative(root) {
  const result = [];
  const stack = [];
  let curr = root;
  while (curr !== null || stack.length > 0) {
    // 先遍历左子树,所有左节点入栈
    while (curr !== null) {
      stack.push(curr);
      curr = curr.left;
    }
    // 弹出栈顶节点(左子树最底层节点),加入结果
    curr = stack.pop();
    result.push(curr.val);
    // 遍历右子树
    curr = curr.right;
  }
  return result;
}

// 测试
const root = new TreeNode(1, null, new TreeNode(2, new TreeNode(3)));
console.log(inorderTraversalRecursive(root)); // [1,3,2]
console.log(inorderTraversalIterative(root)); // [1,3,2]

28. 斐波那契数列(递归+迭代+优化)

考察点:递归、动态规划、时间/空间复杂度优化

// 题目:求第n个斐波那契数(F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2))

// 解法1:递归法(简单但效率低,时间复杂度O(2ⁿ),有重复计算)
function fibRecursive(n) {
  if (n <= 1) return n;
  return fibRecursive(n - 1) + fibRecursive(n - 2);
}

// 解法2:迭代法(推荐,时间复杂度O(n),空间复杂度O(1))
function fibIterative(n) {
  if (n <= 1) return n;
  let prevPrev = 0; // F(n-2)
  let prev = 1; // F(n-1)
  let curr = 0;
  for (let i = 2; i <= n; i++) {
    curr = prevPrev + prev; // F(n) = F(n-2) + F(n-1)
    prevPrev = prev;
    prev = curr;
  }
  return curr;
}

// 解法3:动态规划(空间复杂度O(n),适合需要保存所有斐波那契数的场景)
function fibDP(n) {
  if (n <= 1) return n;
  const dp = new Array(n + 1);
  dp[0] = 0;
  dp[1] = 1;
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
}

// 测试
console.log(fibIterative(10)); // 55

29. 最长公共前缀

考察点:字符串操作、遍历对比、边界处理

// 题目:编写一个函数,找出字符串数组中的最长公共前缀
function longestCommonPrefix(strs) {
  // 边界判断:数组为空,返回空字符串
  if (strs.length === 0) return '';
  // 以第一个字符串为基准,逐个字符对比
  let prefix = strs[0];
  for (let i = 1; i < strs.length; i++) {
    // 循环对比当前字符串和基准字符串,直到找到公共前缀
    while (strs[i].indexOf(prefix) !== 0) {
      // 若不匹配,缩短基准字符串(去掉最后一个字符)
      prefix = prefix.slice(0, prefix.length - 1);
      // 若基准字符串为空,说明没有公共前缀,直接返回
      if (prefix === '') return '';
    }
  }
  return prefix;
}

// 测试
console.log(longestCommonPrefix(["flower","flow","flight"])); // "fl"
console.log(longestCommonPrefix(["dog","racecar","car"])); // ""

30. 验证回文串

考察点:字符串处理、正则表达式、双指针

// 题目:验证一个字符串是否是回文串(只考虑字母和数字,忽略大小写)
function isPalindromeStr(s) {
  // 1. 过滤无效字符(只保留字母和数字),并转为小写
  const validStr = s.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
  // 2. 双指针:左指针从开头,右指针从末尾,逐步对比
  let left = 0;
  let right = validStr.length - 1;
  while (left < right) {
    if (validStr[left] !== validStr[right]) {
      return false; // 不相等,不是回文串
    }
    left++;
    right--;
  }
  return true; // 全部相等,是回文串
}

// 测试
console.log(isPalindromeStr("A man, a plan, a canal: Panama")); // true
console.log(isPalindromeStr("race a car")); // false

写在最后

这30个手写JS算法,覆盖了前端面试从基础到进阶的所有高频考点——基础语法、数组操作、原型作用域、数据结构、算法思维,每道题都能直接复制到编辑器调试,吃透这30道题,面试时再遇到手写算法题,就能从容应对。

很多前端同学觉得算法难,其实是没找对方法:不用刷上千道题,重点吃透这些高频题,搞懂每道题的原理和考点,比盲目刷题更有效。


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

前端性能优化实战:从3秒到1秒,我只做了这5件事(全网通用)

做前端的都知道,页面加载速度就是生命线

一个项目做完,代码写得再漂亮、框架选得再先进,只要打开慢个1-2秒,用户流失率直接翻倍。最近我接手了一个老项目,首屏加载要 3.5秒,通过一系列针对性优化,最终压到了 0.8秒

今天把这套通用型性能优化方案分享给大家,不限Vue、React、小程序,只要是前端项目,复制粘贴就能提升加载速度,收藏这一篇,面试、工作都能用!


一、现状复盘:为什么你的页面那么卡?

先别急着写代码,先搞清楚瓶颈在哪里。建议在开发环境打开 Chrome 开发者工具 -> Lighthouse 跑一次测评。

通常前端加载慢,无非逃不过这3点:

  1. 体积过大:打包后的JS/CSS体积太大,网络传输慢;
  2. 请求过多:首屏加载了无关的接口、图片,造成网络拥堵;
  3. 渲染阻塞:JS没加载完,页面就是一片空白,用户体验极差。

下面的5步优化,就是针对这三大痛点,由浅入深,解决80%的性能问题。

二、核心干货:5个必做优化步骤(直接复制)

1. 路由懒加载:只加载当前需要的代码

这是性价比最高的优化!默认情况下,Webpack会把所有路由打包成一个巨大的 app.js。首屏不管去哪个页面,都要把所有代码下载下来。

解决思路: 按路由拆分,只加载当前页面的代码。

Vue3 / Vite 写法

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'Home',
    // 直接引入,首屏会加载
    component: () => import('@/views/Home.vue') 
  },
  {
    path: '/about',
    name: 'About',
    // 关键:使用箭头函数+import,实现路由懒加载
    // 访问该路由时才会加载对应的Chunk文件
    component: () => import('@/views/About.vue') 
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

React / React Router 写法

// 同样适用React,只需在路由配置处改一下
const About = React.lazy(() => import('@/views/About.vue')); 
// 配合Suspense显示加载中
import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  );
}

2. 图片优化:去重+压缩,体积减半

图片通常是项目体积最大的资源。不要直接把UI给的原图丢上去。

3招搞定图片优化:

  1. 使用现代格式:把PNG/JPG转为 WebPAVIF,体积可缩小50%以上,兼容性极好。
  2. 压缩图片:使用 TinyPNG 或 Webpack 插件(如image-webpack-loader)自动压缩。
  3. 懒加载(Lazy Loading):给图片加上 loading="lazy" 属性,滚动到可视区域再加载,首屏请求瞬间减少。

原生写法(所有框架通用)

<!-- 只需添加这个属性,自动实现懒加载 -->
<img src="image.webp" loading="lazy" alt="优化后" />

Vue/React 中使用

在你的UI库(Element/Ant Design)中使用图片组件时,直接添加属性即可:

<el-image 
  src="image.webp" 
  loading="lazy" 
  fallback="fallback.png" <!-- 降级处理 -->
/>

3. 移除console和注释:清掉“垃圾代码”

打包发布时,千万别把 console.logdebugger 和大量注释打包进去。这些不仅增加体积,还会暴露前端代码,存在安全风险。

解决方案: 在Vite/Webpack配置中一键清除。

Vite 配置(vue.config.js 或 vite.config.js)

// vite.config.js
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    // 生产环境清除console
    minify: 'ter', // 使用terser进行压缩
    terserOptions: {
      compress: {
        drop_console: true, // 移除所有console
        drop_debugger: true // 移除debugger
      }
    }
  }
})

4. 资源压缩:Gzip / Brotli 终极武器

这一步是服务器端的,但只要是前端开发,必须跟后端同学沟通开启。

开启Gzip或Brotli压缩后,服务器会对传输的JS、CSS、HTML文件进行压缩,传输体积能减少60%-80%

如何配置:

  • Nginx:在配置文件中开启 gzip on;
  • Node.js (Express):使用 compression 中间件
  • 云服务:在阿里云/腾讯云CDN控制台直接开启Brotli压缩

效果对比: 原来100KB的JS文件,压缩后只剩20-30KB,加载速度快得惊人!

5. 核心JS降级与polyfill:告别老旧浏览器拖累

现在的ES6+语法很强大,但如果不转译,老旧浏览器(如IE11,甚至旧版Chrome)无法识别,会被迫重新加载大量polyfill补丁。

解决方案: 使用Babel或ESBuild进行转译。

Babel 配置(.babelrc)

{
  "presets": [
    ["@babel/preset-env", {
      "useBuiltIns": "usage", // 按需引入polyfill
      "corejs": 3 // 指定core-js版本
    }]
  ]
}

作用:自动识别你代码中用到的ES6+语法,只引入必要的补丁,大幅减少打包体积。


三、避坑指南:这2件事千万别做

  1. 不要过度优化:比如把一个小工具库手动从项目中移除,得不偿失。优先优化首屏体积和网络请求,这才是最直观的体验提升。
  2. 不要忽略首屏空白:即使加载快了,如果页面是白屏直到JS加载完才显示,用户体验依然不好。可以在 index.html 中添加简单的骨架屏(Skeleton Screen)。

四、写在最后

性能优化不是一蹴而就的,它是一个持续迭代的过程。今天分享的这5个方法,是前端项目上线前的必做项

你会发现,很多时候不需要引入复杂的库,也不需要重写整个项目,只需要对现有配置做几处微调,性能就能有质的飞跃。


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

JS炼化:手写一下promise——用一份外卖,看懂状态机+两个回调篓子

用一份外卖,看懂状态机+两个回调篓子

不少初学者看到完整版Promise手写源码就犯难,繁杂的边界处理和进阶优化让人望而生畏。其实抛开这些锦上添花的拓展逻辑,Promise的核心本质特别简单:一个状态机 + 两个回调篓子,所有功能都由此衍生。手写Promise从来不是盲目造轮子,核心目的就是拆开原生API的黑盒,告别只会调用不懂原理的陌生感,彻底吃透异步运行的底层逻辑。

大家或许对源码逻辑感到晦涩,但一定熟悉点外卖的日常。接下来我就用点外卖的生活化比喻,带你轻松弄懂状态机和回调篓子的核心运作逻辑。

先看逻辑思路

你点了一份外卖 = 发起一个异步任务

1. 状态机 = 外卖订单状态
  •  pending :商家正在做饭(任务进行中)
  •  fulfilled :外卖送到你手上(成功)
  •  rejected :商家没货/取消订单(失败)

状态机干了啥?

  • 告诉你现在能不能吃
  • 保证只会送一次,不会反复送
  • 饭做好了就一直是做好的状态,不会变回“正在做”
  • 结果会永久保存,你什么时候拿都有

(状态机 = 给异步任务定规矩: 只能走一次,走到哪就是哪,结果永久留着。)

2.回调篓子 = 你给外卖员留的“送达通知方式”

你还没拿到外卖时( pending ),你跟外卖员说:

  • 送到了给我打电话
  • 送不成给我发短信

这些“打电话、发短信”,就是你存在  then  里的回调。

回调篓子干了啥?

  • 饭还没好,先把你的要求存起来
  • 不催、不闹、不嵌套
  • 等饭一好,一次性按顺序执行

(回调篓子 = 暂存你的“后续操作”, 异步没跑完,先排队等通知。)

3. 两者合在一起,才是 Promise

流程是这样的:

1. 你下单 → Promise 新建

2. 状态立刻变成  pending → 商家正在做饭

3. 你调用  .then()  留下回调 → 把“打电话/发短信”放进篓子存好

4.饭做好了 →  resolve()

  • 状态变成  fulfilled 
  • 拿出成功篓子里的所有回调,挨个执行 → 挨个打电话通知你

5.饭做不成 →  reject()

  • 状态变成  rejected 
  • 拿出失败篓子里的回调,挨个执行 → 发短信告诉你取消了

再到后面链式调用“骑手”干了什么,“平台”有哪些补救措施

(今天我们就以点外卖的方式: 从0 开始,一小块一小块叠代码,先懂原理再落地实现,彻底撕开 Promise 的黑盒!)

 

第一阶段:逐块拆解手写「纯状态机基础版Promise」

比喻以注释的形式写进代码里方便理解

下单必有初始状态,Promise 创建瞬间也必须固定 pending 态,状态只能单向流转,这是一切的根基。我们从零一块块码

第1块:定义三大核心状态常量(杜绝硬编码写错)

// 对标外卖三种固定状态:做饭中 / 已送达 / 已取消
const STATUS_PENDING = "pending";    // 等待中-正在做饭
const STATUS_FULFILLED = "fulfilled";// 成功-外卖送到
const STATUS_REJECTED = "rejected";  // 失败-订单取消

为什么单独定义常量? 统一管理状态名,全程复用,避免手写字符串拼写错误。(可以理解为有报错提示,同时也能让大家看代码更清晰的行业规范)

第2块:搭建Promise空类骨架(相当于开通下单通道)

// 创建自定义Promise类,开始搭建订单系统外壳
class _Promise {
    // 构造函数:实例化瞬间触发 = 用户点击下单
    constructor(executor = () => {}) {

    }
}

executor 就是商家做饭的核心流程,默认给空函数防止报错。

第3块:构造器初始化核心属性(订单基础信息登记)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        // 刚下单默认状态:商家正在做饭 pending
        this.status = STATUS_PENDING;
        // 预留位置:存放送到手的外卖成果(成功结果)
        this.value = undefined;
        // 预留位置:存放订单失败的原因(商家没货/超时)
        this.reason = undefined;
    }
}

核心规则:只要Promise一创建,天生固定 pending,绝不允许开局直接成功/失败。

第4块:内部定义resolve函数(外卖送达开关)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        // 专属开关:调用就代表外卖顺利送达
        const resolve = (value) => {
            // 铁律校验:只有还在做饭中,才能改成已送达
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                // 把到手的外卖存起来
                this.value = value;
            }
        };
    }
}

状态不可逆核心体现:已经送达/取消的订单,再也不能二次修改状态。

第5块:内部追加reject函数(订单取消开关)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
            }
        };

        // 专属开关:调用就代表订单作废取消
        const reject = (reason) => {
            // 同样铁律:只有做饭中才能取消订单
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                // 把取消原因记录下来
                this.reason = reason;
            }
        };
    }
}

第6块:立即执行executor做饭流程(下单立刻开工)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
            }
        };

        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
            }
        };

        // 下单瞬间直接开火做饭!把两个开关交给外部掌控
        executor(resolve, reject);
    }
}

关键特性:Promise构造器里的执行器是同步立即执行,不会等待延迟。

第7块:追加基础then方法(外卖到了要干啥)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
            }
        };

        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
            }
        };

        executor(resolve, reject);
    }

    // 定制收货操作:成功干啥、失败干啥
    then(onFulfilled, onRejected) {
        // 外卖已经送到,有成功回调就直接执行
        if (this.status === STATUS_FULFILLED && typeof onFulfilled === 'function') {
            onFulfilled(this.value);//这个onFulfilled传进来必须是函数,加判断为了防止报错崩程序
        }
        // 订单已经取消,有失败回调就直接执行
        if (this.status === STATUS_REJECTED && typeof onRejected === 'function') {
            onRejected(this.reason);//这里同理
        }
        // 注意:当前版本暂时处理不了还在做饭中的异步等待场景
    }
}

第一阶段整合完整版(逐块拼装最终成品)

// 1. 定义外卖三大状态常量
const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

// 2. 自定义基础Promise类
class _Promise {
    constructor(executor = () => {}) {
        // 3. 初始化订单默认状态和存储容器
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        // 4. 送达开关逻辑
        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
            }
        };

        // 5. 取消开关逻辑
        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
            }
        };

        // 6. 立刻启动做饭流程
        executor(resolve, reject);
    }

    // 7. 收货处理then方法
    then(onFulfilled, onRejected) {
        if (this.status === STATUS_FULFILLED && typeof onFulfilled === 'function') {
            onFulfilled(this.value);
        }
        if (this.status === STATUS_REJECTED && typeof onRejected === 'function') {
            onRejected(this.reason);
        }
    }
}

下一阶段我们就给这套基础状态机装上「回调篓子队列」,解决异步等待存任务的问题,完美适配真实延时外卖场景~

第二阶段:加装「回调篓子」完整版(衔接基础状态机,递进改造)

上一版只有状态机,处理不了异步延时——就像外卖要等一会才送到,你提前说了收货要做的事,总得先记下来,这两个数组  resolveQueue/rejectQueue  就是专门存事的「回调篓子」。

第2块:类骨架不变,构造器里新增两个回调篓子属性

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        // 原有基础状态不变
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        // ========== 全新加装:两个回调篓子 ==========
        // 成功篓子:存外卖没送到时,所有收货要做的事
        this.resolveQueue = [];
        // 失败篓子:存订单没取消前,所有失败兜底要做的事
        this.rejectQueue = [];
    }
}

改动说明:凭空新增两个数组,专门排队存待执行的回调函数,对应「先记下来,等送达再办」。

第3块:改造 resolve 函数——送达后自动清空执行成功篓子

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.resolveQueue = [];
        this.rejectQueue = [];

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                // ========== 新增逻辑:外卖送到,挨个执行篓子里所有寄存的事 ==========
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };
    }
}

第4块:同步改造 reject 函数——订单取消清空执行失败篓子

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.resolveQueue = [];
        this.rejectQueue = [];

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                // ========== 新增逻辑:订单取消,挨个执行失败篓子里寄存的事 ==========
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };
    }
}

第6块:核心改造 then 方法——判断 pending,把回调塞进篓子

这是最关键改动:外卖还在做(pending),不执行,直接把事装篓子里排队

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.resolveQueue = [];
        this.rejectQueue = [];

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        const reject = reason => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };

        executor(resolve, reject);
    }

    then(onFulfilled, onRejected) {
        // 情况1:已经送到了,直接办收货的事
        if (this.status === STATUS_FULFILLED ) {
            onFulfilled(this.value);
        }
        // 情况2:已经取消了,直接办兜底的事
        else if (this.status === STATUS_REJECTED ) {
            onRejected(this.reason);
        }
        // ========== 全新核心逻辑:还在做饭 pending → 塞进对应篓子存起来 ==========
        else if (this.status === STATUS_PENDING) {
           this.resolveQueue.push(onFulfilled);
           this.rejectQueue.push(onRejected);
        }
    }
}

加装回调篓子·阶段完整汇总代码

// 外卖三态常量
const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        // 基础状态机属性
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        // 两个回调篓子队列
        this.resolveQueue = [];
        this.rejectQueue = [];

        // 送达开关 + 执行成功队列
        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        // 取消开关 + 执行失败队列
        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };

        // 立刻执行做饭流程
        executor(resolve, reject);
    }

    // 智能分发:已完成直接执行,pending就装篓子
    then(onFulfilled, onRejected) {
        if (this.status === STATUS_FULFILLED ) {
            onFulfilled(this.value);
        } else if (this.status === STATUS_REJECTED ) {
            onRejected(this.reason);
        } else if (this.status === STATUS_PENDING) {
             this.resolveQueue.push(onFulfilled);
             this.rejectQueue.push(onRejected);
        }
    }
}

第三阶段:刚需进阶版 基础链式调用(无边界裸奔版,核心骨架)

加装核心链式调用真正解决回调函数嵌套地狱

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.rejectReason = undefined;
        this.resolveQueue = [];
        this.rejectQueue = [];

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };

        executor(resolve, reject);
    }

    then(onFulfilled, onRejected) {
        // 核心刚需:返回新实例,实现链式接龙
        return new _Promise((nextResolve, nextReject) => {
            // 封装统一骑手任务:复用逻辑、可立即执行可入篓寄存、中转链式值
             // 封装统一处理函数handleSuccess原因:
            // 1.逻辑抽离复用,不用多处重复写判断/执行逻辑
            // 2.包装成独立函数,既能立即执行,也可直接塞进队列寄存
            // 3.中转结果交给下一个Promise,支撑链式流转
            const handleSuccess = () => {
                const res = onFulfilled(this.value);
                nextResolve(res);
            };
               // 同成功处理逻辑:统一封装复用、可入队列、中转链式结果
            const handleFail = () => {
                const err = onRejected(this.reason);
                nextResolve(err);
            };

            if (this.status === STATUS_FULFILLED) {
                handleSuccess();
            } else if (this.status === STATUS_REJECTED) {
                handleFail();
            } else if (this.status === STATUS_PENDING) {
                this.resolveQueue.push(handleSuccess);
                this.rejectQueue.push(handleFail);
            }
        });
    }
}

刚需裸奔版存在核心问题(对应骑手配送漏洞)

1. 执行器executor报错直接崩程序(后厨做饭出事直接瘫痪,无应急)

2. then乱传非函数、空传省略回调直接报错(招了不会干活的假骑手,配送直接翻车)

3. 无值透传,中间空then直接断链式(中途骑手离岗,外卖没人接力送,链路废掉)   4. then内部回调自己报错无捕获(骑手送餐中途出事,全程没人兜底救援)

分步针对性边界优化(只改对应位置)

优化1:构造器加try/catch 兜底executor全局报错

只改constructor最后一行执行代码,其余全不变:

// 原有执行代码删掉,替换成下面
try {
  executor(resolve, reject); // 正常执行商家出餐
} catch (err) {
  reject(err); // 改动注释:后厨做饭报错直接转订单失败兜底,不崩系统
}

  优化2:加函数校验+值透传 解决乱传/空传骑手断链问题

只改then里handle核心逻辑+队列存入判断:

const handleSuccess = () => {
  // 改动注释:判断是不是正经骑手函数,不是就原值透传接力,不废单
  const res = typeof onFulfilled === 'function' ? onFulfilled(this.value) : this.value;
  nextResolve(res);
};
const handleFail = () => {
  // 改动注释:失败回调同样校验+原因透传,保证坏单也能顺畅接力
  const err = typeof onRejected === 'function' ? onRejected(this.reason) : this.reason;
  nextResolve(err);
};

// 底部pending入队也改一行
if (this.status === STATUS_PENDING) {
  // 改动注释:只把正经骑手存进任务篓,假骑手直接拒收不占用队列
  typeof onFulfilled === 'function' && this.resolveQueue.push(handleSuccess);
  typeof onRejected === 'function' && this.rejectQueue.push(handleFail);
}
 

优化3:内层try/catch 兜底骑手送餐中途自身报错(最终闭环)

只再包一层内部捕获:

const handleSuccess = () => {
  // 改动注释:骑手干活中途出错即时救援,报错直接切失败单流转
  try {
    const res = typeof onFulfilled === 'function' ? onFulfilled(this.value) : this.value;
    nextResolve(res);
  } catch (err) {
    nextReject(err);
  }
};
const handleFail = () => {
  try {
    const err = typeof onRejected === 'function' ? onRejected(this.reason) : this.reason;
    nextResolve(err);
  } catch (err) { // 改动注释:失败处理出错同样兜底捕获,全链路无死角
    nextReject(err);
  }
};

 

最终完整完善版(直接阅读注释说明看到整个流程)

// 外卖订单三大固定状态:待接单/配送完成/订单拒收取消
const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        // 初始化订单默认状态:全部处于待接单
        this.status = STATUS_PENDING;
        // 存放配送完成的餐品结果
        this.value = undefined;
        // 存放订单拒收的原因备注
        this.reason = undefined;

        // 骑手任务收纳篓:排队等候的配送任务/拒收善后任务
        this.resolveQueue = [];
        this.rejectQueue = [];

        // 配送放行开关:只有待接单能改成完成,批量执行所有等候配送骑手
        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        // 订单拒收开关:只有待接单能改成取消,批量执行善后骑手任务
        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };

        // 后厨出餐容错:做饭翻车直接判订单失败,不瘫痪整个店铺
        try {
            executor(resolve, reject);
        } catch (err) {
            reject(err);
        }
    }

    then(onFulfilled, onRejected) {
        // 链式核心:每接一单就生成全新订单,实现骑手接力直送,完成异步时间扁平化
        return new _Promise((nextResolve, nextReject) => {
            // 统一封装正规配送骑手任务
            const handleSuccess = () => {
                // 骑手上岗核验+原值代送透传+送餐中途意外兜底
                try {
                    const res = typeof onFulfilled === 'function' ? onFulfilled(this.value) : this.value;
                    nextResolve(res);
                } catch (err) {
                    nextReject(err);
                }
            };
            // 统一封装订单善后退款骑手任务
            const handleFail = () => {
                // 坏单兜底核验+原因透传+善后出错应急保护
                try {
                    const err = typeof onRejected === 'function' ? onRejected(this.reason) : this.reason;
                    nextResolve(err);
                } catch (err) {
                    nextReject(err);
                }
            };

            // 根据订单当前状态调度骑手
            if (this.status === STATUS_FULFILLED) {
                handleSuccess(); // 已出餐直接派送
            } else if (this.status === STATUS_REJECTED) {
                handleFail(); // 已拒收直接善后
            } else if (this.status === STATUS_PENDING) {
                // 只收正经上岗骑手进任务篓,杂牌无效骑手直接拒收
                typeof onFulfilled === 'function' && this.resolveQueue.push(handleSuccess);
                typeof onRejected === 'function' && this.rejectQueue.push(handleFail);
            }
        });
    }
}

整个流程结尾

我们全程用外卖订单+骑手配送的思路走完了手写Promise的主要核心流程:

最初先搭建核心骨架,定好订单三态状态机,搭配存放任务的回调篓,实现了最基础的异步任务寄存能力;

接着核心打通链式调用逻辑,每调用一次then就生成一张全新外卖订单,让骑手接力顺路配送,把嵌套混乱的回调地狱改成线性直行的流程,完成了Promise最关键的异步扁平化;

最后一步步叠加全套边界优化,用try/catch兜底后厨出餐报错、骑手送餐中途异常,用函数筛查过滤不会干活的假骑手,搭配值透传规则让空岗骑手原样代送不丢单,保障整条配送链路永远顺畅不崩。

至此我们就从最简裸奔版,迭代打磨出了逻辑完整、健壮可用的完整版基础Promise。至于 Promise.all 、 Promise.race 这类静态工具方法,只是在这套核心完备的订单系统之上,额外封装的组合派单简易逻辑,属于锦上添花的拓展用法,不改动我们底层状态机、队列调度、链式流转的核心架构,这里就不再额外展开赘述了。

如有理解不当,欢迎大家指正,一起学习进步

iOS Runtime 深度解析

iOS Runtime 深度解析:原理、实战与前沿趋势

在 iOS 开发中,Runtime(运行时)是 Objective-C(以下简称 OC)语言的灵魂,也是区分 iOS 初级开发者与中高级开发者的核心门槛。它赋予 OC 动态特性,让代码在编译期无法确定的逻辑,能在运行时灵活调整、动态扩展。随着 Swift 生态的完善和 Apple 技术的迭代,Runtime 并未过时,反而在组件化、性能优化、逆向开发等场景中发挥着不可替代的作用。本文将从原理、实战、前沿三个维度,带你全面吃透 iOS Runtime,结合代码示例拆解核心用法,助力你在实际开发中灵活运用这门“黑魔法”。

一、Runtime 核心基础:是什么与为什么

1.1 什么是 Runtime

Runtime 本质上是一套用 C 和汇编语言编写的 API 集合,是 OC 语言与底层系统之间的桥梁,负责将 OC 代码转换为底层可执行的机器指令,实现动态类型、动态绑定、动态加载等核心特性。简单来说,OC 是“动态语言”,核心就在于 Runtime——编译期我们写的 OC 方法调用、属性访问,最终都会被转换为 Runtime 的 C 函数调用,直到运行时才真正确定具体执行逻辑。

举个直观的例子:我们调用 [object method] 时,编译器并不会直接确定 method 方法的具体实现,而是在运行时通过 Runtime 查找该方法的实现并执行,这也是 Runtime 与静态语言(如 C++)的核心区别。

1.2 Runtime 的核心价值

  • 动态扩展:无需修改类的源码,即可为类添加方法、属性,突破 OC 语法限制;
  • 解耦优化:在组件化、插件化开发中,通过 Runtime 实现组件间通信,降低耦合度;
  • 底层适配:解决系统 API 兼容、私有方法调用、逆向开发等场景的核心问题;
  • 性能优化:通过方法缓存、动态解析等机制,提升 App 运行效率。

1.3 核心数据结构

Runtime 的所有功能,都围绕以下几个核心结构体展开,理解它们是掌握 Runtime 的基础:

(1)objc_object:对象的本质

OC 中所有对象的底层都是 objc_object 结构体,核心字段是 isa 指针,用于指向对象所属的类。

// objc 对象的底层结构体
struct objc_object {
    Class isa; // 指向类对象的指针,核心字段
};

// OC 对象的本质就是 objc_object 的指针
typedef struct objc_object *id;

(2)objc_class:类的本质

类对象(Class)的底层是 objc_class 结构体,存储着类的元信息(方法列表、属性列表、协议列表等)。

struct objc_class {
    Class isa; // 指向元类(Meta Class),用于存储类方法
    Class super_class; // 指向父类
    const char *name; // 类名
    long instance_size; // 实例对象的内存大小
    struct objc_ivar_list *ivars; // 实例变量列表
    struct objc_method_list **methodLists; // 方法列表(可动态修改)
    struct objc_cache *cache; // 方法缓存(提升查找效率)
    struct objc_protocol_list *protocols; // 协议列表
};

(3)Method、SEL、IMP:方法的三要素

  • SEL:方法选择器,本质是字符串,用于唯一标识一个方法(如 @selector(method:));
  • IMP:函数指针,指向方法的具体实现,是方法执行的核心;
  • Method:方法结构体,封装了 SELIMP 的对应关系。
// 方法结构体
struct objc_method {
    SEL method_name; // 方法选择器
    char *method_types; // 方法类型编码(返回值、参数类型)
    IMP method_imp; // 方法实现的函数指针
};

二、Runtime 核心机制:从原理到实战

Runtime 的核心机制包括消息传递、方法缓存、动态解析、消息转发、方法交换等,其中消息传递是基础,其他机制都是基于消息传递的扩展。以下结合实战代码,拆解每个机制的原理与用法。

2.1 消息传递:OC 方法调用的本质

OC 中所有方法调用,本质上都是 Runtime 的 objc_msgSend 函数调用。当我们写下 [object method:arg] 时,编译器会自动转换为:

objc_msgSend(object, @selector(method:), arg);

消息传递的完整流程

  1. 通过对象的 isa 指针,找到对象所属的类;
  2. 优先在类的 cache(方法缓存)中查找对应 SELIMP
  3. 若缓存未命中,遍历类的 methodLists 查找方法;
  4. 若当前类未找到,沿着 super_class 父类链向上查找,直到找到 NSObject;
  5. 若找到方法,执行 IMP 并将方法加入缓存(提升下次查找效率);
  6. 若未找到方法,进入消息转发流程(后续详解)。

实战:手动调用 objc_msgSend

需导入 Runtime 头文件 #import <objc/runtime.h>,手动调用消息传递函数:

#import <objc/runtime.h>

@interface Person : NSObject
- (void)sayHello:(NSString *)name;
@end

@implementation Person
- (void)sayHello:(NSString *)name {
    NSLog(@"Hello, %@", name);
}
@end

// 调用方式
Person *person = [[Person alloc] init];
// 1. 常规调用
[person sayHello:@"Runtime"];
// 2. 手动调用 objc_msgSend
SEL sel = @selector(sayHello:);
objc_msgSend(person, sel, @"Runtime"); // 输出:Hello, Runtime

2.2 方法缓存:提升消息传递效率

Runtime 为每个类维护了一个 objc_cache(方法缓存),用于存储最近调用过的方法(SEL + IMP)。缓存采用哈希表实现,查找速度远快于遍历方法列表,这是 Runtime 优化性能的核心手段之一。

核心特点:

  • 缓存只存储“最近调用”的方法,避免缓存过大;
  • 每次调用方法后,若缓存未命中,找到 IMP 后会自动加入缓存;
  • 类的缓存会随着方法调用动态更新,优先保留高频调用的方法。

2.3 动态解析与消息转发:方法未找到的“补救机制”

当消息传递流程中未找到方法时,Runtime 不会直接崩溃,而是提供了三层“补救机制”,让我们有机会动态补充方法实现,避免 App 闪退。

(1)动态方法解析(第一层补救)

通过重写 +resolveInstanceMethod:(实例方法)或 +resolveClassMethod:(类方法),动态为未实现的方法添加实现。

@implementation Person
// 动态解析实例方法
+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(sayHello:)) {
        // 为 sel 动态添加实现:参数1=类,参数2=SEL,参数3=IMP,参数4=方法类型编码
        class_addMethod(self, sel, (IMP)dynamicSayHello, "v@:@");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

// 动态添加的方法实现(C语言函数)
void dynamicSayHello(id self, SEL _cmd, NSString *name) {
    NSLog(@"动态解析:Hello, %@", name);
}
@end

// 调用未声明的方法(不会崩溃)
Person *person = [[Person alloc] init];
[person sayHello:@"Dynamic Resolve"]; // 输出:动态解析:Hello, Dynamic Resolve

(2)消息转发(第二层+第三层补救)

若动态解析未处理(返回 NO),则进入消息转发流程,分为两步:

  1. 快速转发:通过 -forwardingTargetForSelector:,将消息转发给另一个对象处理;
  2. 完整转发:若快速转发未处理,通过 -methodSignatureForSelector: 获取方法签名,再通过 -forwardInvocation: 手动处理消息。
实战:快速转发
@interface Student : NSObject
- (void)sayHello:(NSString *)name;
@end

@implementation Student
- (void)sayHello:(NSString *)name {
    NSLog(@"Student 打招呼:Hello, %@", name);
}
@end

@implementation Person
// 快速转发:将消息转发给 Student 对象
- (id)forwardingTargetForSelector:(SEL)aSelector {
    if (aSelector == @selector(sayHello:)) {
        return [[Student alloc] init];
    }
    return [super forwardingTargetForSelector:aSelector];
}
@end

// 调用方法,消息会转发给 Student
Person *person = [[Person alloc] init];
[person sayHello:@"Forward"]; // 输出:Student 打招呼:Hello, Forward
实战:完整转发
@implementation Person
// 1. 获取方法签名(必须实现,否则崩溃)
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    if (aSelector == @selector(sayHello:)) {
        // 方法签名:返回值void(v),参数id(@)、SEL(:)、NSString(@)
        return [NSMethodSignature signatureWithObjCTypes:"v@:@"];
    }
    return [super methodSignatureForSelector:aSelector];
}

// 2. 手动处理消息
- (void)forwardInvocation:(NSInvocation *)anInvocation {
    SEL sel = anInvocation.selector;
    Student *student = [[Student alloc] init];
    if ([student respondsToSelector:sel]) {
        [anInvocation invokeWithTarget:student]; // 转发给 Student
    } else {
        [super forwardInvocation:anInvocation];
    }
}
@end

2.4 方法交换(Method Swizzling):Runtime 黑魔法

Method Swizzling(方法交换)是 Runtime 最常用的实战技巧,通过交换两个方法的 IMP,实现“hook”效果,无需修改原方法源码,即可拦截、扩展原方法的功能(如埋点、日志、性能监控)。

核心原理:交换两个 Method 结构体中的 IMP 指针,让原 SEL 指向新的实现,新 SEL 指向原实现。

实战:拦截 UIViewController 的 viewDidLoad 方法

#import <objc/runtime.h>
#import <UIKit/UIKit.h>

@implementation UIViewController (Swizzling)
// 在 +load 方法中执行方法交换(+load 方法会在类加载时自动调用,且只调用一次)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 1. 获取两个方法
        Class cls = [self class];
        SEL originalSel = @selector(viewDidLoad);
        SEL swizzledSel = @selector(swizzled_viewDidLoad);
        
        Method originalMethod = class_getInstanceMethod(cls, originalSel);
        Method swizzledMethod = class_getInstanceMethod(cls, swizzledSel);
        
        // 2. 交换方法实现
        method_exchangeImplementations(originalMethod, swizzledMethod);
    });
}

// 新的方法实现(拦截 viewDidLoad)
- (void)swizzled_viewDidLoad {
    // 1. 执行原 viewDidLoad 方法(此时 swizzled_viewDidLoad 指向原实现)
    [self swizzled_viewDidLoad];
    
    // 2. 扩展功能(如埋点、日志)
    NSLog(@"拦截到 %@ 的 viewDidLoad", self.class);
}
@end

方法交换的注意事项

  • dispatch_once_t 保证方法交换只执行一次,避免多次交换导致逻辑错乱;
  • 优先在 +load 方法中执行交换(类加载时执行,时机最早),避免在 +initialize 中执行(可能被多次调用);
  • 交换类方法时,需使用 class_getClassMethod 获取方法,而非 class_getInstanceMethod
  • 避免交换系统私有方法,可能导致 App 审核失败或系统崩溃。

2.5 动态添加属性与关联对象

OC 中,分类(Category)默认不能添加实例变量(ivar),但通过 Runtime 的关联对象(Associated Object),可以间接为分类添加“属性”,本质是将属性值存储在外部哈希表中,与对象关联起来。

实战:为 UIButton 分类添加属性

#import <objc/runtime.h>
#import <UIKit/UIKit.h>

@interface UIButton (Extension)
// 声明属性
@property (nonatomic, copy) NSString *customName;
@end

@implementation UIButton (Extension)
// 定义关联对象的 key(唯一标识)
static const void *CustomNameKey = &CustomNameKey;

// 重写 setter 方法
- (void)setCustomName:(NSString *)customName {
    // 关联对象:参数1=对象,参数2=key,参数3=值,参数4=内存管理策略
    objc_setAssociatedObject(self, CustomNameKey, customName, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

// 重写 getter 方法
- (NSString *)customName {
    // 获取关联对象
    return objc_getAssociatedObject(self, CustomNameKey);
}
@end

// 使用
UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
button.customName = @"我的按钮";
NSLog(@"按钮名称:%@", button.customName); // 输出:按钮名称:我的按钮

关联对象的内存管理策略

// 对应 OC 属性的内存修饰符
OBJC_ASSOCIATION_ASSIGN; // assign
OBJC_ASSOCIATION_RETAIN_NONATOMIC; // strong, nonatomic
OBJC_ASSOCIATION_COPY_NONATOMIC; // copy, nonatomic
OBJC_ASSOCIATION_RETAIN; // strong, atomic
OBJC_ASSOCIATION_COPY; // copy, atomic

三、Runtime 前沿趋势:适配 Swift 与 Apple 新生态

随着 Swift 成为 iOS 开发的主流语言,以及 Apple 推出的新工具、新框架(如 Xcode 26、基础模型框架),Runtime 的应用场景也在不断扩展,不再局限于 OC 开发,而是与 Swift 生态深度融合,呈现出全新的发展趋势。

3.1 Runtime 与 Swift 的协同发展

Swift 是静态语言,编译期会进行类型检查,但其底层仍然依赖 Runtime(尤其是与 OC 交互时),同时 Swift 也提供了自己的动态特性(如 @dynamicMemberLookup@objc 关键字),与 OC Runtime 形成互补。

  • Swift 中使用 @objc 修饰的方法、属性,会被暴露给 Runtime,可通过 OC Runtime API 调用;
  • Swift 5.0+ 引入的 @dynamicMemberLookup,允许动态访问属性,本质是 Runtime 动态特性的 Swift 封装;
  • 在 Swift 组件化开发中,通过 Runtime 实现跨模块调用(如通过类名字符串创建对象),解决 Swift 静态编译的限制。

实战:Swift 中调用 Runtime API

import ObjectiveC

class Person: NSObject {
    @objc func sayHello(_ name: String) {
        print("Hello, (name)")
    }
}

// 1. 动态创建对象
let className = "RuntimeDemo.Person"
guard let cls = NSClassFromString(className) as? Person.Type else { return }
let person = cls.init()

// 2. 动态调用方法
let sel = NSSelectorFromString("sayHello:")
person.perform(sel, with: "Swift Runtime") // 输出:Hello, Swift Runtime

// 3. 动态添加关联对象
extension UIButton {
    private static let customKey = UnsafeRawPointer(bitPattern: 0x123456)!
    var customName: String? {
        get {
            objc_getAssociatedObject(self, UIButton.customKey) as? String
        }
        set {
            objc_setAssociatedObject(self, UIButton.customKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }
    }
}

3.2 Runtime 在 Apple 新生态中的应用

随着 Apple 发布 Xcode 26、基础模型框架等新工具,Runtime 的应用场景进一步扩展,尤其在智能开发、性能优化、跨平台适配等方面发挥着重要作用:

  1. 智能开发辅助:Xcode 26 集成了大语言模型,可通过 Runtime 分析类的结构、方法列表,自动生成代码、修复错误,提升开发效率;
  2. 隐私保护与性能优化:基础模型框架支持设备端 AI 推理,Runtime 可动态管理模型调用的生命周期,避免敏感数据泄露,同时通过方法缓存优化 AI 推理的响应速度;
  3. 跨平台适配:Swift 6.2 支持 WebAssembly,Runtime 可帮助开发者实现 OC/Swift 代码与 Web 端的交互,动态适配不同平台的 API 差异;
  4. 逆向开发与安全防护:在 App 安全领域,通过 Runtime Hook 系统方法,可拦截敏感操作(如密码输入、网络请求),防止数据泄露;同时,也可通过 Runtime 混淆方法名、类名,提升 App 反逆向能力。

3.3 Runtime 的未来展望

尽管 Swift 生态日益完善,但 Runtime 作为 iOS 底层核心技术,短期内不会被替代,反而会随着 Apple 技术的迭代不断升级:

  • 更高效的方法缓存机制:Apple 可能进一步优化 objc_cache 的哈希算法,提升消息传递效率;
  • 更安全的动态扩展:加强 Runtime API 的权限管理,避免恶意代码通过 Runtime 篡改 App 逻辑;
  • 与 AI 深度融合:通过 Runtime 动态适配 AI 模型的调用,实现更智能的代码生成、性能优化。

四、结语

iOS Runtime 是 OC 语言的灵魂,也是 iOS 开发的“内功”。它不仅能帮助我们理解 iOS 底层原理,更能在实际开发中解决很多常规语法无法解决的问题——从组件化解耦、性能优化,到逆向开发、安全防护,Runtime 都发挥着不可替代的作用。

随着 Swift 与 Apple 新生态的发展,Runtime 的应用场景不断扩展,它不再是“小众黑魔法”,而是中高级 iOS 开发者必须掌握的核心技能。学习 Runtime,不仅是学习一套 API,更是培养一种“底层思维”——跳出上层语法的限制,从底层理解代码的执行逻辑,才能写出更高效、更健壮、更具扩展性的 iOS 应用。

最后,希望本文能帮助你快速吃透 Runtime 的核心原理与实战用法,在实际开发中灵活运用这门技术,突破自身开发瓶颈,成为更优秀的 iOS 开发者。未来,Runtime 还会不断进化,期待我们一起探索它的更多可能性。

前端必看!前端路由守卫这么写,再也不担心权限混乱(Vue/React通用)

所有前端必看!路由守卫看似简单,却藏着很多坑——未登录能直接访问个人中心、管理员页面普通人能进、跳转时数据未加载就渲染。全程实操干货+完整封装,Vue2/Vue3、React 都能用,复制就能实现权限管控

先搞懂:路由守卫到底用来做什么?

不管是 Vue Router 还是 React Router,路由守卫的核心作用只有一个:控制路由的访问权限和跳转逻辑,解决以下高频问题:

  • 未登录用户,禁止访问个人中心、订单页等需要权限的页面;
  • 不同角色(普通用户/管理员),展示不同的路由页面;
  • 页面跳转前,校验数据、确认操作(比如未保存的表单,提示用户);
  • 页面加载前,获取必要数据(比如用户信息),避免页面空白。

重点:路由守卫是前端权限管控的核心。Vue 和 React 用法略有差异,但逻辑一致。本文分别给出完整示例,复制就能适配自己的项目,不用再从零编写。


核心干货:Vue2/Vue3 路由守卫完整封装(直接复制)

Vue 项目用 Vue Router,路由守卫分为 3 类:全局守卫、路由独享守卫、组件内守卫。重点掌握全局守卫,就能解决 80% 的权限问题。

1. Vue3 + Vue Router 4(最常用,推荐)

新建 router/index.js,全局守卫 + 路由配置,一步到位:

// router/index.js(Vue3)
import { createRouter, createWebHistory } from 'vue-router';
import { getStorage } from '@/utils/storage'; // 复用之前封装的 LocalStorage 工具

// 1. 定义路由(区分公开路由和需要权限的路由)
const routes = [
  // 公开路由(无需登录就能访问)
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { requiresAuth: false } // 标记:无需权限
  },
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue'),
    meta: { requiresAuth: false }
  },
  // 需要权限的路由(必须登录才能访问)
  {
    path: '/user',
    name: 'UserCenter',
    component: () => import('@/views/UserCenter.vue'),
    meta: {
      requiresAuth: true, // 标记:需要权限
      role: 'user' // 角色限制:普通用户即可访问
    }
  },
  // 管理员路由(只有管理员能访问)
  {
    path: '/admin',
    name: 'Admin',
    component: () => import('@/views/Admin.vue'),
    meta: {
      requiresAuth: true,
      role: 'admin' // 角色限制:仅管理员
    }
  },
  // 404页面
  {
    path: '/:pathMatch(.*)*',
    name: '404',
    component: () => import('@/views/404.vue')
  }
];

// 2. 创建路由实例
const router = createRouter({
  history: createWebHistory(import.meta.env.VITE_BASE_URL),
  routes
});

// 3. 全局前置守卫(跳转前校验,核心)
router.beforeEach((to, from, next) => {
  // 1. 获取 token(从 LocalStorage 中取)
  const token = getStorage('token');
  // 2. 获取当前用户角色(登录后存储的用户信息)
  const userRole = getStorage('userInfo')?.role || '';

  // 3. 校验逻辑
  if (to.meta.requiresAuth) {
    // 3.1 需要权限的路由:判断是否登录
    if (!token) {
      // 未登录,跳转到登录页,登录后返回当前页面
      return next({ name: 'Login', query: { redirect: to.fullPath } });
    } else {
      // 已登录,判断角色是否匹配
      if (to.meta.role && to.meta.role !== userRole) {
        // 角色不匹配,跳转到首页(或 403 页面)
        return next({ name: 'Home' });
      }
      // 登录且角色匹配,允许跳转
      next();
    }
  } else {
    // 3.2 公开路由:直接跳转
    next();
  }
});

// 4. 全局后置守卫(跳转后执行,比如修改页面标题)
router.afterEach((to) => {
  // 设置页面标题
  document.title = to.meta.title || '前端路由守卫示例';
});

export default router;

页面中使用(Vue3):

<!-- 登录页面,登录成功后跳转回之前的页面 -->
<script setup>
import { useRouter, useRoute } from 'vue-router';
import { setStorage } from '@/utils/storage';

const router = useRouter();
const route = useRoute();

const login = async () => {
  const res = await loginApi(); // 登录接口
  // 存储 token 和用户信息
  setStorage('token', res.data.token, 86400);
  setStorage('userInfo', res.data.user, 86400);

  // 跳转回之前的页面(如果有),否则跳首页
  const redirect = route.query.redirect || '/';
  router.push(redirect);
};
</script>

2. Vue2 + Vue Router 3(兼容旧项目)

逻辑和 Vue3 一致,仅语法略有差异,直接复制:

// router/index.js(Vue2)
import Vue from 'vue';
import Router from 'vue-router';
import { getStorage } from '@/utils/storage';

Vue.use(Router);

const routes = [
  // 路由配置和 Vue3 一致
  { path: '/login', name: 'Login', component: () => import('@/views/Login'), meta: { requiresAuth: false } },
  { path: '/user', name: 'UserCenter', component: () => import('@/views/UserCenter'), meta: { requiresAuth: true, role: 'user' } },
  { path: '/admin', name: 'Admin', component: () => import('@/views/Admin'), meta: { requiresAuth: true, role: 'admin' } },
];

const router = new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
});

// 全局前置守卫
router.beforeEach((to, from, next) => {
  const token = getStorage('token');
  const userRole = getStorage('userInfo')?.role || '';

  if (to.meta.requiresAuth) {
    if (!token) {
      next({ name: 'Login', query: { redirect: to.fullPath } });
    } else {
      if (to.meta.role && to.meta.role !== userRole) {
        next({ name: 'Home' });
      } else {
        next();
      }
    }
  } else {
    next();
  }
});

export default router;

核心干货:React + React Router 6 路由守卫封装(直接复制)

React Router 6 取消了传统的路由守卫 API,改用「组件封装」的方式实现权限控制,更灵活,适配 React 函数式组件,直接复制就能用。

1. 封装权限守卫组件(utils/PrivateRoute.js

import { Navigate, Outlet } from 'react-router-dom';
import { getStorage } from '@/utils/storage';

/**
 * 权限守卫组件
 * @param {Object} props - 传入的角色限制
 * @param {string} props.role - 允许访问的角色(可选)
 */
export const PrivateRoute = ({ role }) => {
  // 获取 token 和用户角色
  const token = getStorage('token');
  const userRole = getStorage('userInfo')?.role || '';

  // 未登录,跳转到登录页
  if (!token) {
    return <Navigate to="/login" replace />;
  }

  // 有角色限制,且当前角色不匹配,跳转到首页
  if (role && role !== userRole) {
    return <Navigate to="/" replace />;
  }

  // 权限通过,渲染子路由(Outlet 对应 Vue 的 router-view)
  return <Outlet />;
};

2. 路由配置(router/index.jsx

import { createBrowserRouter } from 'react-router-dom';
import PrivateRoute from '@/utils/PrivateRoute';
// 引入页面组件
import Login from '@/views/Login';
import Home from '@/views/Home';
import UserCenter from '@/views/UserCenter';
import Admin from '@/views/Admin';
import NotFound from '@/views/404';

// 创建路由
const router = createBrowserRouter([
  {
    path: '/',
    element: <Home />
  },
  {
    path: '/login',
    element: <Login />
  },
  // 需要权限的路由:用 PrivateRoute 包裹
  {
    path: '/user',
    element: <PrivateRoute role="user" />, // 普通用户可访问
    children: [
      { path: '', element: <UserCenter /> } // 子路由,对应 Outlet
    ]
  },
  // 管理员路由:限制角色为 admin
  {
    path: '/admin',
    element: <PrivateRoute role="admin" />,
    children: [
      { path: '', element: <Admin /> }
    ]
  },
  // 404 页面
  {
    path: '*',
    element: <NotFound />
  }
]);

export default router;

3. 入口文件中使用(main.jsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import router from './router';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

4.React 登录页面使用:

import { useNavigate, useLocation } from 'react-router-dom';
import { setStorage } from '@/utils/storage';

function Login() {
  const navigate = useNavigate();
  const location = useLocation();
  // 获取跳转前的页面地址
  const redirect = new URLSearchParams(location.search).get('redirect') || '/';

  const login = async () => {
    const res = await loginApi();
    setStorage('token', res.data.token, 86400);
    setStorage('userInfo', res.data.user, 86400);
    // 跳转回之前的页面
    navigate(redirect, { replace: true });
  };

  return (
    <button onClick={login}>登录</button>
  );
}

export default Login;

实战避坑:4 个高频坑,新手必避

坑 1:Vue 路由守卫中,忘记调用 next(),导致页面卡死

错误示例:在 beforeEach 中只做了判断,没调用 next(),路由无法跳转,页面卡死。
正确做法:所有分支都必须调用 next(),允许跳转用 next(),重定向用 next({ name: 'Login' })

坑 2:React Router 6 中,用旧版本语法写守卫,导致失效

React Router 6 取消了 beforeEachafterEach 等 API,不要再用旧版本的写法。
正确做法:用「PrivateRoute 组件 + Outlet」的方式实现权限控制,本文示例直接可用。

坑 3:未处理“登录后跳转回原页面”,体验变差
用户未登录访问需要权限的页面,登录后应该跳转回之前的页面,而不是默认首页。
正确做法:跳转登录页时,携带当前页面地址(query 参数),登录成功后跳转回去。

坑 4:角色权限判断不严谨,导致越权访问

只判断是否登录,不判断角色,导致普通用户能访问管理员页面。
正确做法:在路由 meta(Vue)或 PrivateRoute 组件(React)中添加角色限制,登录后校验角色。


进阶技巧:路由守卫高级用法

1. 表单未保存,禁止跳转(组件内守卫 / Vue 专属)

<script setup>
import { onBeforeRouteLeave } from 'vue-router';

// 组件内守卫:离开当前页面时触发
onBeforeRouteLeave((to, from, next) => {
  // 判断表单是否未保存
  if (formIsDirty.value) {
    if (confirm('表单未保存,确定要离开吗?')) {
      next(); // 确认离开
    } else {
      next(false); // 取消离开
    }
  } else {
    next(); // 表单已保存,允许离开
  }
});
</script>

2. 路由跳转时,加载 loading 状态(全局守卫)

// Vue3 全局守卫中添加 loading
import { ref } from 'vue';
export const isLoading = ref(false);

router.beforeEach((to, from, next) => {
  isLoading.value = true; // 跳转前显示 loading
  // 原有校验逻辑...
  next();
});

router.afterEach(() => {
  setTimeout(() => {
    isLoading.value = false; // 跳转后隐藏 loading
  }, 300);
});

结尾:干货总结

路由守卫是前端权限管控的核心。Vue 和 React 用法虽有差异,但逻辑一致——判断登录状态、校验角色、控制跳转。一套封装就能覆盖所有场景,避开 4 个高频坑,复制就能实现权限管控。


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

前端必看!LocalStorage这么用,再也不踩坑(多框架通用,直接复制)

所有前端必看!LocalStorage看似简单,却有90%的人用错——存对象报错、存数组失效、数据污染、内存溢出,甚至导致页面卡顿。全程实操干货+通用封装,Vue/React/Uniapp/小程序/Node都能用,复制就能避免所有坑

先搞懂:LocalStorage核心痛点,你一定踩过

做前端开发,谁没用 LocalStorage 存过 token、用户信息?但大多数人都是裸写 localStorage.setItemlocalStorage.getItem,看似简单,实则全是坑:

  • 只能存字符串:存对象/数组直接报错,或取出来变成 [object Object]
  • 没有过期时间:存的 token、临时数据一直占用内存,导致数据污染;
  • 没有容错处理:取不到数据直接报错,影响页面渲染;
  • 键名混乱:多个页面/组件存数据,容易覆盖、冲突。

重点:LocalStorage 是前端本地存储的基础,Vue、React、Uniapp、小程序、Node(前端渲染)都能用。一套通用封装,彻底解决所有痛点,不用重复写冗余代码。

核心干货:LocalStorage通用封装(直接复制,多框架通用)

新建 utils/storage.js,一次封装,全局使用。支持存字符串、对象、数组,带过期时间、容错处理、键名统一,复制到任何前端项目都能直接用!

/**
 * LocalStorage通用封装(Vue/React/Uniapp/小程序通用)
 * 支持:存字符串、对象、数组 + 过期时间 + 容错处理 + 键名统一
 */
const STORAGE_KEY_PREFIX = 'frontend_'; // 键名前缀,避免冲突

// 1. 存数据(支持过期时间,单位:秒)
export const setStorage = (key, value, expire = 0) => {
  try {
    // 处理对象/数组,转为JSON字符串(LocalStorage只能存字符串)
    const data = {
      value: typeof value === 'object' ? JSON.stringify(value) : value,
      expire: expire > 0 ? Date.now() + expire * 1000 : 0 // 0表示永久有效
    };
    // 键名加前缀,避免和其他项目/插件冲突
    localStorage.setItem(`${STORAGE_KEY_PREFIX}${key}`, JSON.stringify(data));
  } catch (error) {
    console.error('LocalStorage存储失败:', error);
    // 兼容低版本浏览器/隐私模式(LocalStorage不可用)
    alert('浏览器存储不可用,请开启正常模式后重试');
  }
};

// 2. 取数据(自动处理JSON解析,判断过期)
export const getStorage = (key) => {
  try {
    const storageKey = `${STORAGE_KEY_PREFIX}${key}`;
    const dataStr = localStorage.getItem(storageKey);
    if (!dataStr) return null;

    const data = JSON.parse(dataStr);
    // 判断是否过期(expire=0表示永久有效)
    if (data.expire > 0 && Date.now() > data.expire) {
      // 过期后自动删除,避免无效数据占用内存
      localStorage.removeItem(storageKey);
      return null;
    }

    // 自动解析JSON(如果存的是对象/数组)
    try {
      return JSON.parse(data.value);
    } catch (e) {
      // 不是JSON格式,直接返回原始值(字符串)
      return data.value;
    }
  } catch (error) {
    console.error('LocalStorage获取失败:', error);
    return null;
  }
};

// 3. 删除单个数据
export const removeStorage = (key) => {
  try {
    localStorage.removeItem(`${STORAGE_KEY_PREFIX}${key}`);
  } catch (error) {
    console.error('LocalStorage删除失败:', error);
  }
};

// 4. 清空所有数据(只清空当前项目的,不影响其他项目)
export const clearStorage = () => {
  try {
    // 只删除带前缀的键,避免清空其他项目的存储
    Object.keys(localStorage).forEach(key => {
      if (key.startsWith(STORAGE_KEY_PREFIX)) {
        localStorage.removeItem(key);
      }
    });
  } catch (error) {
    console.error('LocalStorage清空失败:', error);
  }
};

// 5. 批量存数据
export const setStorageBatch = (obj, expire = 0) => {
  try {
    Object.entries(obj).forEach(([key, value]) => {
      setStorage(key, value, expire);
    });
  } catch (error) {
    console.error('LocalStorage批量存储失败:', error);
  }
};

实战用法:多框架示例,直接复制

不管是 Vue、React、Uniapp,用法完全一致,只需引入封装好的方法,无需额外适配。以下示例覆盖 80% 的使用场景。

1. 基础用法:存/取字符串、对象、数组

// 引入封装的方法(所有框架通用)
import { setStorage, getStorage, removeStorage } from '@/utils/storage';

// 1. 存字符串(比如token)
setStorage('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', 86400); // 过期时间1天(86400秒)

// 2. 存对象(比如用户信息)
const userInfo = { id: 1, name: '张三', age: 25 };
setStorage('userInfo', userInfo, 86400);

// 3. 存数组(比如历史记录)
const historyList = ['Vue', 'React', 'JS'];
setStorage('historyList', historyList); // 不设过期时间,永久有效

// 4. 取数据(自动解析对象/数组,无需手动JSON.parse)
const token = getStorage('token');
const user = getStorage('userInfo'); // 直接拿到对象,无需解析
const history = getStorage('historyList'); // 直接拿到数组

// 5. 删除数据
removeStorage('token'); // 删除单个
// clearStorage(); // 清空当前项目所有存储

2. Vue3/Uniapp页面中使用

<script setup>
import { ref, onMounted } from 'vue';
import { setStorage, getStorage } from '@/utils/storage';

const userInfo = ref({});

// 页面加载时,从LocalStorage取用户信息
onMounted(() => {
  const user = getStorage('userInfo');
  if (user) {
    userInfo.value = user;
  }
});

// 登录成功后,存用户信息和token
const login = async () => {
  const res = await loginApi(); // 登录接口
  setStorage('token', res.data.token, 86400);
  setStorage('userInfo', res.data.user, 86400);
  userInfo.value = res.data.user;
};
</script>

3. React页面中使用

import { useState, useEffect } from 'react';
import { setStorage, getStorage, removeStorage } from '@/utils/storage';

function UserPage() {
  const [user, setUser] = useState({});

  useEffect(() => {
    // 组件挂载时取数据
    const userInfo = getStorage('userInfo');
    if (userInfo) {
      setUser(userInfo);
    }
  }, []);

  // 退出登录,删除存储
  const logout = () => {
    removeStorage('token');
    removeStorage('userInfo');
    setUser({});
  };

  return (
    <div>
      {user.name}
      <button onClick={logout}>退出登录</button>
    </div>
  );
}

4. 小程序/Uniapp适配(特殊处理)

小程序不支持 window.localStorage,需替换为 wx.setStorageSync 等原生 API,修改封装方法即可,核心逻辑不变:

// 小程序版本封装(utils/storage.js)
const STORAGE_KEY_PREFIX = 'frontend_';

// 存数据
export const setStorage = (key, value, expire = 0) => {
  try {
    const data = {
      value: typeof value === 'object' ? JSON.stringify(value) : value,
      expire: expire > 0 ? Date.now() + expire * 1000 : 0
    };
    wx.setStorageSync(`${STORAGE_KEY_PREFIX}${key}`, data);
  } catch (error) {
    console.error('存储失败:', error);
    wx.showToast({ title: '存储不可用', icon: 'none' });
  }
};

// 取数据(其他方法同理,替换为wx.getStorageSync、wx.removeStorageSync)
export const getStorage = (key) => {
  try {
    const storageKey = `${STORAGE_KEY_PREFIX}${key}`;
    const data = wx.getStorageSync(storageKey);
    if (!data) return null;
    if (data.expire > 0 && Date.now() > data.expire) {
      wx.removeStorageSync(storageKey);
      return null;
    }
    try {
      return JSON.parse(data.value);
    } catch (e) {
      return data.value;
    }
  } catch (error) {
    console.error('获取失败:', error);
    return null;
  }
};

实战避坑:5个高频坑,新手必避

坑1:直接存对象/数组,导致报错或解析失败

错误示例localStorage.setItem('user', {name: '张三'}),直接存对象会报错。
正确做法:用封装的 setStorage,自动将对象/数组转为 JSON 字符串,取的时候自动解析。

坑2:不设过期时间,导致数据污染

存 token、临时数据时,不设过期时间,用户退出后数据依然存在,再次登录会出现异常。
正确做法:给敏感数据、临时数据设置过期时间(比如 token 设 1 天)。

坑3:键名不统一,导致覆盖冲突

多个组件/页面存数据,键名都是 “user”“data”,容易互相覆盖。
正确做法:用前缀统一键名(封装中已自带 frontend_ 前缀),避免冲突。

坑4:忽略浏览器兼容性,导致报错

部分低版本浏览器、隐私模式下,LocalStorage 不可用,裸写会报错。
正确做法:封装中添加容错处理,捕获异常并提示用户。

坑5:清空所有存储,影响其他项目

错误示例:直接用 localStorage.clear(),会清空浏览器中所有项目的 LocalStorage。
正确做法:用封装的 clearStorage,只清空当前项目带前缀的存储。

进阶技巧:LocalStorage进阶用法

1. 监听 LocalStorage 变化(跨页面通信)

// 页面A监听存储变化
window.addEventListener('storage', (e) => {
  // 只监听当前项目的存储变化(带前缀)
  if (e.key?.startsWith(STORAGE_KEY_PREFIX)) {
    console.log('存储变化:', e.key, e.newValue);
    // 比如监听token变化,实现跨页面登录状态同步
    if (e.key === `${STORAGE_KEY_PREFIX}token`) {
      // 处理登录状态更新
    }
  }
});

// 页面B修改存储,页面A会触发监听
setStorage('token', 'newToken');

2. 限制存储大小,避免内存溢出

LocalStorage 默认存储上限约 5MB,存大量数据会导致内存溢出。可在封装中添加存储大小校验:

// 新增:校验存储大小
const checkStorageSize = (value) => {
  const valueStr = typeof value === 'object' ? JSON.stringify(value) : value;
  const size = new Blob([valueStr]).size;
  // 限制单条数据不超过1MB
  if (size > 1 * 1024 * 1024) {
    alert('存储数据过大,建议拆分存储');
    return false;
  }
  return true;
};

// 在setStorage中添加校验
export const setStorage = (key, value, expire = 0) => {
  if (!checkStorageSize(value)) return;
  // 原有逻辑...
};

结尾:干货总结

LocalStorage 是前端必备的本地存储工具。一套通用封装,解决存对象、过期时间、冲突、容错等所有痛点,适配所有前端框架,复制就能用,避开 5 个高频坑,再也不用为存储问题头疼。


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

面试题里的 Custom Hook 思维:从三道题总结「异步状态管理」通用模式

最近在准备面试,翻到几道关于 Custom Hook 的模拟题。表面上看各不相同——轮询、筛选、防抖搜索——但仔细分析之后,发现它们背后有一套共同的思维框架。这篇文章是我整理这套框架的笔记,希望对同样在备战面试的你有参考价值。


三道题,三个场景

先简单描述一下这三道题在考什么:

  • useRideTracking:行程进行中轮询状态,每 5s 请求一次,页面隐藏时暂停,连续失败 3 次停止
  • useExpenseFilter:报表筛选 Hook,多维联动筛选,需要 useMemo 优化
  • useEmployeeSearch:员工搜索,防抖 500ms + AbortController 取消请求

三个场景,但核心都指向同一个问题:如何在 Hook 里正确管理「副作用」和「派生状态」?


归纳出的通用思维框架

在我看来,一个合格的 Custom Hook 需要从四个维度去思考:

1. 状态层(State)     ── 管什么数据?
2. 副作用层(Effect)  ── 什么时候做什么?
3. 清理层(Cleanup)   ── 离开时怎么收尾?
4. 优化层(Optimization) ── 怎么不做多余的工作?

下面逐层展开,结合题目来理解。


第一层:状态层 — 先想清楚「管什么」

拿到题目,第一步应该问自己:这个 Hook 需要对外暴露哪些状态?

这三道题都有一个共同的「三元组」:

// 几乎所有「异步请求型」Hook 的状态骨架
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

除了这个骨架,每道题还有「额外状态」:

  • useRideTracking:需要 failCount(连续失败次数),但这是内部状态,不对外暴露
  • useExpenseFilter:需要 filters 对象,并对外暴露 setFilter / resetFilters
  • useEmployeeSearch:需要 keyword,并对外暴露 setKeyword

一个实用技巧:区分「对外暴露」和「内部管理」的状态。对外的是接口契约,对内的是实现细节。面试中如果能主动说出这种区分,往往加分。

// useRideTracking 的状态设计示意
// 对外:{ status, loading, error }
// 对内:failCountRef(用 ref 而非 state,因为改变它不需要触发重渲染)
const failCountRef = useRef(0);

第二层:副作用层 — 明确「触发时机」

useEffect 的依赖数组,本质上是在描述「什么变化了我才需要重新执行」。

模式 A:挂载即执行 + 定时触发(useRideTracking)

// 环境:React 18+
// 场景:行程状态轮询

function useRideTracking(rideId) {
  const [status, setStatus] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const failCountRef = useRef(0);
  const timerRef = useRef(null);
  const stoppedRef = useRef(false);

  const fetchStatus = async () => {
    if (stoppedRef.current) return;

    setLoading(true);
    try {
      const res = await fetch(`/api/rides/${rideId}`);
      if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
      const data = await res.json();
      setStatus(data.status);
      setError(null);
      // success: reset fail counter
      failCountRef.current = 0;
    } catch (err) {
      failCountRef.current += 1;
      if (failCountRef.current >= 3) {
        stoppedRef.current = true;
        setError(err);
        setLoading(false);
        clearInterval(timerRef.current);
        return;
      }
    } finally {
      if (!stoppedRef.current) setLoading(false);
    }
  };

  useEffect(() => {
    // fetch immediately on mount
    fetchStatus();
    timerRef.current = setInterval(fetchStatus, 5000);

    const handleVisibility = () => {
      if (document.visibilityState === 'hidden') {
        clearInterval(timerRef.current);
      } else {
        fetchStatus(); // refetch immediately on visible
        timerRef.current = setInterval(fetchStatus, 5000);
      }
    };

    document.addEventListener('visibilitychange', handleVisibility);

    return () => {
      clearInterval(timerRef.current);
      document.removeEventListener('visibilitychange', handleVisibility);
      stoppedRef.current = true;
    };
  }, [rideId]);

  return { status, loading, error };
}

这道题的难点有两个:

  1. visibilitychange 事件——很多人第一反应想不到,但这是真实产品里节省资源的常见做法
  2. 连续失败计数用 ref 还是 state——改变它不需要重渲染,用 ref 更合适

模式 B:受控输入 + 派生计算(useExpenseFilter)

// 环境:React
// 场景:多维度联动筛选

const emptyFilter = {
  departments: [],
  dateRange: null,
  statuses: [],
  amountRange: null,
};

function useExpenseFilter(data) {
  const [filters, setFilters] = useState({ ...emptyFilter });

  const setFilter = useCallback((key, value) => {
    setFilters((prev) => ({ ...prev, [key]: value }));
  }, []);

  const resetFilters = useCallback(() => {
    setFilters({ ...emptyFilter });
  }, []);

  const filteredData = useMemo(() => {
    return data.filter((trip) => {
      if (filters.departments.length && !filters.departments.includes(trip.department)) return false;
      if (filters.statuses.length && !filters.statuses.includes(trip.status)) return false;
      if (filters.amountRange) {
        const [min, max] = filters.amountRange;
        if (trip.amount < min || trip.amount > max) return false;
      }
      if (filters.dateRange) {
        const [start, end] = filters.dateRange;
        if (trip.date < start || trip.date > end) return false;
      }
      return true;
    });
  }, [data, filters]);

  return { filters, setFilter, filteredData, resetFilters };
}

这道题相对直接,但有两个容易踩的坑:

  1. resetFilters 里要用 { ...emptyFilter } 而非直接传引用——否则 emptyFilter 对象可能被意外修改
  2. setFilter 要用 useCallback 包裹——否则每次渲染都会生成新函数,可能导致消费方的 memo 失效

模式 C:防抖 + 请求竞态处理(useEmployeeSearch)

// 环境:React
// 场景:带防抖的搜索请求,需要处理竞态

function useEmployeeSearch() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const abortControllerRef = useRef(null);

  useEffect(() => {
    const trimmed = keyword.trim();

    // empty keyword: reset state immediately
    if (!trimmed) {
      setResults([]);
      setError(null);
      setLoading(false);
      return;
    }

    const timer = setTimeout(async () => {
      // abort previous pending request
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      const controller = new AbortController();
      abortControllerRef.current = controller;

      setLoading(true);
      setError(null);

      try {
        const res = await fetch(
          `/api/employees/search?q=${encodeURIComponent(trimmed)}`,
          { signal: controller.signal }
        );
        if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
        const data = await res.json();
        setResults(data);
      } catch (err) {
        if (err.name === 'AbortError') return; // ignore abort errors
        setError(err);
      } finally {
        setLoading(false);
      }
    }, 500);

    return () => {
      clearTimeout(timer);
      // abort on cleanup (keyword changed or unmount)
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [keyword]);

  return { keyword, setKeyword, results, loading, error };
}

这道题的核心考点是「竞态条件」(race condition):用户快速输入时,后发出的请求可能比先发出的先返回,导致界面显示旧数据。AbortController 是解决这个问题的标准方案。


第三层:清理层 — 「离开时」的责任感

这是很多初学者写 Hook 时最容易忽略的部分,但在面试中往往是区分「会用」和「理解」的分水岭。

一个简单的清理检查清单:

□ 定时器(setInterval / setTimeout)需要 clearInterval / clearTimeout
□ 事件监听器需要 removeEventListener
□ 进行中的网络请求需要 AbortController.abort()
□ 组件卸载后不应再 setState(会产生 warning)

上面三道题都涉及清理,总结一下各自的清理策略:

Hook 需要清理的东西
useRideTracking clearInterval + removeEventListener + 标记 stoppedRef 防止 setState
useExpenseFilter 无(纯状态计算,无副作用)
useEmployeeSearch clearTimeout + AbortController.abort()

第四层:优化层 — 「不做多余的工作」

优化不是一开始就要做的事,但 Hook 里有几个固定场景需要考虑:

场景一:派生状态用 useMemo

useExpenseFilter 里的 filteredData 是典型案例。如果直接在函数体里 data.filter(...),每次任何状态变化都会重新过滤,即使 datafilters 没有变化。

// 不好:每次渲染都重新计算
const filteredData = getFilterData(data);

// 好:只在 data 或 filters 变化时重新计算
const filteredData = useMemo(() => getFilterData(data), [data, filters]);

场景二:回调函数用 useCallback

暴露给外部的函数,如果作为 props 传递给子组件,或者出现在其他 Hook 的依赖数组里,应该用 useCallback 包裹。

场景三:不需要触发重渲染的值用 useRef

failCountReftimerRefabortControllerRef 都属于这类。它们是「进行中的工作凭证」,改变它们不需要更新 UI。


一个「答题」的思维顺序

整理完这三道题,我发现面试时可以按这个顺序思考:

1. 明确返回值契约
   └── 对外暴露哪些状态和方法?

2. 识别副作用触发时机
   └── 依赖什么变化?立即执行还是延迟?

3. 规划清理策略
   └── 定时器 / 事件 / 请求,哪些要清理?

4. 考虑优化点
   └── 有无派生状态?回调需不需要 useCallback?

这个顺序不是铁律,但至少能保证不遗漏关键点。


延伸思考

整理这几道题时,有几个问题让我觉得值得继续探索:

  • useReducer vs 多个 useStateuseExpenseFilter 里的多个筛选条件,用 useReducer 管理会更清晰吗?什么情况下应该做这个选择?
  • 请求库的抽象层:SWR / React Query 的 revalidateOnFocus 本质上就是 useRideTracking 里的 visibilitychange 逻辑,只是封装层次不同。面试中能提到这层联系,可能会有加分
  • TypeScript 的泛型设计:这几个 Hook 如果要做成通用的,类型怎么设计?这可能是下一篇笔记的方向

小结

Custom Hook 的核心,我理解是「把复杂的副作用逻辑封装成可复用的、有明确接口的黑盒」。面试考这类题,考的其实不只是「能不能写出来」,更是「能不能清晰地描述你在解决什么问题」。

这篇文章是我自己的思考整理,不一定全对,如果你有不同的看法或者更好的方案,欢迎交流讨论。


参考资料

Linux-从0开始-20260408

Linux

一、基础命令

1. Linux 基础命令

问题:Linux 基础命令

答案核心回答:ls、cd、pwd、mkdir、rm、cp、mv 是基础命令。

代码示例

# 目录操作
ls -la                       # 详细列表
ls -lh                       # 人类可读大小
cd /path/to/dir              # 切换目录
pwd                          # 显示当前目录
mkdir -p /path/to/dir        # 创建目录
mkdir -m 755 dir             # 指定权限

# 文件操作
rm -rf dir                   # 删除目录
rm file.txt                   # 删除文件
cp -r source dest            # 复制目录
cp file.txt dest/             # 复制文件
mv oldname newname           # 重命名/移动

# 查看文件
cat file.txt                 # 全文
head -n 20 file.txt          # 前20行
tail -n 20 file.txt          # 后20行
tail -f /var/log/syslog      # 实时查看日志

# 搜索
grep "pattern" file.txt      # 搜索文本
find / -name "filename"     # 查找文件

2. 文件权限

问题:文件权限

答案核心回答:Linux 文件权限分为 owner、group、others 三类,各有 rwx 权限。

代码示例

# 查看权限
ls -l file.txt
# -rw-r--r-- 1 user group 4096 Jan 15 10:00 file.txt
# 权限位: -rw-r--r--
# 类型  owner  group  others
# r=4 w=2 x=1

# 修改权限
chmod 755 file               # 数字形式
chmod u+x file               # 所有者添加执行
chmod g-w file               # 组移除写
chmod o+r file               # 其他添加读
chmod a+x file               # 所有添加执行

# 修改所有者
chown user:group file        # 修改所有者和组
chown user file              # 只修改所有者
chgrp group file             # 只修改组

# 特殊权限
chmod +s file               # SUID/SGID
chmod +t file               # Sticky Bit

二、文本处理

3. 文本处理命令

问题:文本处理命令

答案核心回答:awk、sed、grep 是强大的文本处理工具。

代码示例

# awk - 文本分析
awk '{print $1, $3}' file.txt          # 打印第1、3列
awk -F',' '{print $2}' file.csv       # 指定分隔符
awk '/pattern/ {print $0}' file.txt   # 模式匹配
awk 'NR==5' file.txt                  # 第5行

# sed - 文本替换
sed 's/old/new/g' file.txt           # 全局替换
sed -i 's/old/new/g' file.txt        # 直接修改
sed '1,5d' file.txt                  # 删除1-5行
sed -n '2p' file.txt                 # 打印第2行

# grep - 搜索
grep "pattern" file.txt
grep -r "pattern" dir/               # 递归搜索
grep -i "pattern" file.txt           # 忽略大小写
grep -v "pattern" file.txt           # 反向匹配
grep -E "regex" file.txt             # 扩展正则

# 管道组合
cat file.txt | grep "pattern" | awk '{print $2}' | sort

三、进程管理

4. 进程管理

问题:进程管理命令

答案核心回答:ps、top、kill 用于进程管理。

代码示例

# 查看进程
ps aux                          # 所有进程
ps -ef                          # 详细格式
top                              # 实时监控
htop                             # 增强版 top

# 查找进程
ps aux | grep nginx
pgrep -f "nginx"

# 信号与 kill
kill -l                          # 列出信号
kill -9 pid                      # 强制终止
kill -15 pid                     # 优雅终止(SIGTERM)
killall nginx                     # 按名称终止
pkill -f "nginx"                 # 按模式终止

# 后台进程
nohup command &                  # 后台运行
bg                               # 后台任务
fg                               # 前台任务
jobs                             # 任务列表

四、网络管理

5. 网络命令

问题:网络命令

答案核心回答:ping、curl、wget、netstat、ss 是常用网络命令。

代码示例

# 测试连通性
ping -c 4 example.com           # 发4个包
ping -i 0.5 example.com         # 0.5秒间隔

# 下载
curl https://example.com       # 下载页面
curl -O file.txt               # 下载文件
curl -L url                    #跟随重定向
wget url                        # 下载工具
wget -c url                    # 断点续传

# 网络诊断
netstat -tuln                  # 监听端口
netstat -anp | grep 80         # 查找端口占用
ss -tuln                       # 替代 netstat
netstat -i                      # 网卡信息

# curl 高级用法
curl -X POST url -d "data"    # POST 请求
curl -H "Header: value" url   # 自定义头
curl -v url                    # 详细输出

五、磁盘与内存

6. 磁盘与内存

问题:磁盘与内存命令

答案核心回答:df、du、free 是查看磁盘和内存的常用命令。

代码示例

# 磁盘使用
df -h                          # 人类可读格式
df -i                          # 查看 inode
du -sh *                       # 目录大小
du -sh /path/to/dir            # 指定目录

# 内存使用
free -h                        # 人类可读格式
free -m                        # MB 为单位
cat /proc/meminfo              # 详细内存信息

# 磁盘挂载
mount /dev/sdb1 /mnt/usb      # 挂载
umount /mnt/usb                # 卸载
df -T                           # 显示文件系统类型

大厂都在偷偷用的 Cursor Rules 封装!告别重复 Prompt,AI 编程效率翻倍

还在每天教 AI “失忆实习生”?
每次开新对话都要重讲项目架构、代码规范、命名风格……
而用这套 Rules 配置,Cursor 直接变成“懂你心思的老搭档”——跨会话记忆、自动守规范、团队统一标准,字节/腾讯内部已全面落地

如果你受够了:

  • 写 200 行 Prompt 只为让 AI 记住项目
  • 团队成员各自为战,AI 输出风格五花八门
  • 代码不规范、漏 error、命名混乱,返工到崩溃

那么,这篇经过 GitHub 5w+ 星验证的 Rules 完全指南,就是为你写的——
不用背命令,装完就忘,自然语言写需求,AI 自动按规矩干活


一、先看效果:以前累死,现在躺赢

场景 旧方式 Rules 新方式
开新对话 粘贴 300 行项目背景 自动加载上下文
写 Go 代码 手动提醒“加注释、小驼峰” 自动遵守官方规范
团队协作 每人一套 Prompt,风格乱飞 共享规则包,输出统一
代码审查 人工查漏 自动 lint + 安全扫描

真实收益

  • 每天节省 1~2 小时重复解释
  • 代码返工率下降 70%
  • 新人上手 AI 编程速度提升 3 倍

二、Rules 是什么?

传统 Prompt = 便利贴
贴一次用一次,新开对话就丢。

Rules = 永久工作手册
写一次,全局生效,团队共享,Git 可管。

核心价值
让 Cursor 从“聪明但没规矩” → “专业且守纪律”


三、必装三大神级 Rules(附一键安装)

1. everything-cursor(闭眼装)

GitHub ⭐ 52k+,黑客松冠军,60+ 规则覆盖全开发流程

支持:Go / Java / Python / React / Rust / Next.js
能力:自动格式化、TDD、部署提示、安全检查
命令:/fmt /check /tdd /mem

/rule market add https://github.com/affaan-m/everything-cursor
/rule install everything-cursor@everything-cursor

2. cursor-mem(解决金鱼记忆)

GitHub ⭐ 23k+,跨会话永久记住你的项目

再也不用重复解释:

  • 业务逻辑
  • 技术架构
  • 特殊约束
/rule market add https://github.com/thedotmack/cursor-mem
/rule install cursor-mem@cursor-mem

3. super-rules(工程化最强)

GitHub ⭐ 28k+,让 AI 像资深工程师一样思考

TDD 测试驱动
结构化调试
代码审查分级(Minor/Normal/Critical)

/rule market add https://github.com/obra/cursor-super-rules.git
/rule install super-rules@cursor-super-rules

避坑:装了 everything-cursor 就别装 plan-with-code,指令冲突!


四、Rules 怎么工作?完全无感!

99% 的规则安装后自动生效,无需手动触发

Cursor 会智能判断:

  • 你打开的是 Go 文件 → 自动激活 go-standard
  • 你在写前端 → 自动加载 react-rules
  • 你问架构问题 → 启用 arch-rules

你只需要用自然语言描述需求,剩下的交给 Rules!


五、两种安装方式(推荐第一种)

方式一:市场导入(推荐)

# 添加市场(只需一次)
/rule market add https://github.com/getcursor/cursor-rules-official

# 安装规则包
/rule install go-pack@cursor-rules-official

方式二:本地手动(私有/离线场景)

# macOS/Linux
cp -r my-rule ~/.cursor/rules/

六、新手安装优先级(直接抄作业)

优先级 规则包 理由
第一 everything-cursor 全能王,闭眼装
第二 cursor-mem 解决记忆痛点
第三 super-rules 提升工程质量
4 code-fmt(官方) 自动格式化
5 rule-maker(官方) 自定义规则

七、大厂为什么都在用?

  • 字节:用 cursor-mem 统一 200+ 微服务上下文
  • 腾讯super-rules 接入 CI,代码审查自动化
  • 阿里云:基于 rule-maker 生成内部规范包

Rules 不是玩具,而是 AI 编程的“基础设施”


结语:AI 编程,进入“有纪律”时代

当你不再为重复解释焦头烂额,
当你团队的 AI 输出风格高度统一,
你就知道——Rules,是每个专业开发者必须掌握的生产力核弹

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

JS炼化 :(this+new+构造函数)面向对象秘法包——从抽象到落地“焚诀”来了

读完秘法包不会用?焚诀来了

上一篇我们用**剑(this)、剑鞘(new)、剑法(构造函数)**的比喻,理解了三者的本质关系。 本篇目标:把零散的“剑意”合成完整的“焚诀”,从概念落地到运行逻辑,吃透面向对象底层根基

先简单回顾一下秘法包

  • this 是剑:操作对象、访问实例属性的核心工具
  • new 是剑鞘:保证 this 正确指向实例,启动构造函数
  • 构造函数是剑法:定义对象模板,批量创建同类对象

详情看上一篇秘法包基础解析,下面开始修炼焚诀

焚诀导读(焚诀修炼结构)

1. 铸剑基础 • this 常见场景(先认识)

2. 剑之本意• this 面向对象核心(重点练)

3. 剑鞘认主 • new 到底做了什么(核心)

4. 剑法定式 • 构造函数完整用法(核心)

5. 焚诀合一 • 剑·鞘·法三者联动(本篇灵魂)

焚诀修炼开始

第一重 · 铸剑基础(this基础场景)

定位:了解即可 / 说明:this的使用场景较多,先梳理非面向对象的基础场景,建立基础认知即可,无需死抠细节,后续核心内容才是修炼重点。

1. 全局作用域中的this
  • 规则:浏览器环境指向 window ,Node.js环境指向 global ,严格模式下为 undefined 
console.log(this); // 浏览器:window / Node:global
'use strict';
console.log(this); // 严格模式:undefined
2. 普通函数调用中的this
  • 规则:非严格模式默认指向全局对象,严格模式下为 undefined 
function fn() {
  console.log(this);
}
fn(); // 非严格模式:window / 严格模式:undefined
3. 箭头函数中的this(箭头函数很神,但是面向对象不合适)
  • 规则:不绑定自身this,继承外层作用域的this指向,这是与普通函数的核心区别
const obj = {
  name: '张三',
  sayName: () => {
    console.log(this.name); // 继承外层全局this,浏览器下指向window
  }// obj 的 {} 是对象字面量,不产生作用域
};
obj.sayName(); // 输出:undefined
4. call / apply / bind显式绑定this
  • 规则:可手动强制修改this指向,优先级高于默认绑定,提升代码灵活性
function fn() {
  console.log(this.name);
}
const obj = { name: '李四' };
fn.call(obj);   // 手动绑定,输出:李四
fn.apply(obj);  // 效果同call,输出:李四
const boundFn = fn.bind(obj);
boundFn();      // 永久绑定,输出:李四

 

第二重 · 剑之本意(this面向对象核心)

定位:重点掌握 说明:抛开基础场景,面向对象中this的指向逻辑极其清晰,只有两个核心场景,也是后续写代码最常用的部分,必须吃透。

1. 对象方法调用中的this
  • 规则:this指向调用该方法的所属对象,谁调用方法,this就归属谁
const obj = {
  name: '前端学习者',
  sayName() {
    console.log(this.name); // this指向调用方法的obj对象
  }
};
obj.sayName(); // 输出:前端学习者
2. 构造函数 / new调用中的this
  • 规则:this指向new创建的新实例对象,也就是剑认主的核心逻辑,this通过new找到自己的归属
function Person(name) {
  this.name = name; // this指向new创建的新实例,剑找到新主人
}
const p = new Person("张三");
console.log(p.name); // 输出:张三

 

this使用注意事项

  • 对象方法作为回调函数时,this指向易改变→用箭头函数、bind绑定或缓存 const self = this 修正指向。(这个可能有些绕,但是还是this的核心机制:this  的指向 不是由「函数定义的位置」决定的,而是由「函数被调用的方式」决定的,也就是谁调用了这个函数, this  就指向谁(箭头函数除外,它是继承外层作用域的 this) 一句话就是:谁拿起this这把剑来用,剑就听谁的指挥,就为谁服务。你也可以用 call / apply / bind 提前给剑认主,不管谁拿,剑只听你指定的那个人的话。
  • 嵌套函数中this易混乱→借助箭头函数继承外层this,或显式绑定明确指向
  • 严格模式下this默认指向undefined→规范绑定对象,避免无绑定调用

 

第三重 · 剑鞘认主(new完整知识点)

定位:核心重点 说明:new是连接构造函数与this的关键,更是剑认主的核心仪式,没有new,剑法无法施展,剑也没有归属,彻底吃透它的执行逻辑,才算掌握面向对象的核心钥匙。

1. new操作符的核心作用

核心:new就是一场认主仪式,先造主人,再让剑(this)认主,最后按剑法武装出一个完整实例。

本质:是创建构造函数的实例对象,稳定绑定this,执行构造函数逻辑,最终返回可使用的完整实例。

2. new执行的4件事(比喻+实际作用一一对应)
2.1. 造一个新主人(创建空对象)

比喻:打造一个全新的、无属性的主人肉身

实际:创建一个空的JavaScript对象 {} ,这个空对象就是未来的实例载体

2.2. 给主人归入门派(关联构造函数原型)

比喻:给新主人打上剑法的门派标记,继承门派里的公共功法

实际:将空对象的  proto 指向构造函数的 prototype 属性,为后续继承公共方法打下基础,无需重复创建方法

(早期只用  this  和  new  创建对象时,每个实例都会重复携带方法,内存占用大、不好管理。 于是就有了原型(prototype),相当于一个门派,把共用方法统一存放。 实例归入门派,就能共享所有公共方法,节省内存、方便维护。 这一步就是把空对象关联到构造函数原型,更详细的原型机制我们后面再学。)

2.3. 剑认主:把this绑定给新主人(最核心一步)

比喻:剑鞘扣紧,将无主的剑(this)正式交给新主人,完成认主仪式

实际:构造函数里的this原本没有固定归属,new在这一步强制将this绑定给刚创建的空对象,从此this只归属这个实例,完美对应“this通过new认主”

2.4. 按剑法武装主人,再把主人交出来(执行构造、返回实例)

比喻:按照剑法招式,给主人装备专属属性、武器,打造完整的修炼者

实际:运行构造函数内的代码,通过this给实例添加名字、年龄等属性和方法,若构造函数无返回对象,就将这个武装完成的实例返回出去

3. new代码示例+手动模拟实现
// 构造函数(剑法)
function Person(name, age) {
  this.name = name;
  this.age = age;
}
// 门派公共功法(原型方法)
Person.prototype.sayHi = function() {
  console.log(`Hi, I'm ${this.name}`);
};

// 执行认主仪式(new调用)
const p = new Person('张三', 18);
p.sayHi(); // 输出:Hi, I'm 张三

// 手动模拟认主仪式(模拟new)
function myNew(constructor, ...args) {
  // 1. 造新主人肉身
  const obj = {};
  // 2. 归入门派
  obj.__proto__ = constructor.prototype;
  // 3. 剑认主,绑定this并执行剑法
  const result = constructor.call(obj, ...args);
  // 4. 交出完整主人
  return result instanceof Object ? result : obj;
}

// 测试模拟new
const p2 = myNew(Person, '李四', 20);
p2.sayHi(); // 输出:Hi, I'm 李四
 

new使用注意事项

  • 构造函数必须配合new使用,省略new会让构造函数变成普通函数,this指向全局,无法完成认主→构造函数首字母大写,养成配合new调用的习惯
  • 构造函数中手动返回对象,会覆盖new创建的默认实例,认主失效→无特殊需求,不手动返回数据,依靠new自动返回实例

 

第四重 · 剑法定式(构造函数完整知识点)

定位:核心重点 说明:构造函数是剑法的定式,是批量打造主人的模板,掌握规范写法,才能高效创建同类型对象。

1. 构造函数的本质与作用

本质:ES5中JavaScript实现面向对象编程的基础形式,专门用于创建对象

作用:定义对象的通用属性和方法,作为固定剑法模板,通过new调用批量创建实例

2. 构造函数的编写规范
  • 命名规范:首字母大写,与普通函数区分,行业通用约定
  • 赋值方式:通过this为实例添加属性和方法,借助剑认主的逻辑完成属性绑定
  • 返回值:无需手动return,new会自动返回完成认主的完整实例
3. 构造函数基础代码示例
// 基础剑法(构造函数)
function Person(name, age) {
  this.name = name;
  this.age = age;
  this.sayHi = function() {
    console.log(`Hi, ${this.name}`);
  };
}

// 执行认主仪式,创建两个主人
const p1 = new Person('张三', 18);
const p2 = new Person('李四', 20);

// 主人使用自身剑法
p1.sayHi(); // 输出:Hi, 张三
p2.sayHi(); // 输出:Hi, 李四
4. 构造函数与普通函数核心区别
  • 调用方式:构造函数必须用new调用,普通函数直接调用即可
  • this指向:构造函数中this指向new创建的实例,普通函数中this指向全局或undefined
  • 命名规范:构造函数首字母大写,普通函数小写开头
  • 主要作用:构造函数批量创建对象,普通函数执行单一逻辑、返回运算结果
  • 返回值:构造函数自动返回实例,普通函数需手动return结果
5. 抛出思考(引向下一篇)

当前基础写法能实现功能,但存在一个问题:每new一个实例,构造函数内的方法就会重新创建一次,p1和p2的sayHi功能完全一致,却占用多份内存,实例越多浪费越严重?

有没有办法让所有实例共用一套公共方法,不重复创建?这就是下一篇要探究的原型&原型链,以及ES6 class语法糖的优化逻辑。

 

第五重 · 焚诀合一(剑·鞘·法三者联动)

定位:本篇灵魂 说明:三者绝非独立存在,而是一套完整的焚诀功法,协同工作才能发挥最大作用,这是JS面向对象的核心逻辑。

1. 三者协同执行流程

一句话总结执剑鞘(new),运剑法(构造函数),剑(this)认主归位,最终炼成完整主人(实例)。 用new启动构造函数,先创建空实例,再将this绑定到该实例,执行构造函数为实例赋值,最终返回完整可用的实例。

2. 完整联动核心代码
// 剑法(构造函数):定好修炼定式
function Person(name) {
  // 剑(this):等待认主,为主人赋值
  this.name = name;
  this.sayName = function() {
    console.log(this.name);
  };
}

// 剑鞘(new):执行认主仪式,启动剑法
const p = new Person('张三');

// 主人(实例):使用自身的剑施展功法
p.sayName(); // 输出:张三

3. 联动核心总结

  • 构造函数(剑法):定下对象的基础规则,是功法核心
  • this(剑):负责实例数据传递,是操作对象的工具
  • new(剑鞘):完成认主仪式,保障this指向稳定,是功法启动器 三者缺一不可,配合使用才构成JS面向对象的基础,后续所有进阶知识,都围绕这一核心逻辑展开。

到这一步焚诀就大成了,只差修炼和优化了

学习复盘心得小小分享

其实不管学什么,刚开始的路都是又乱又零散。 回想刚学 JS 的那段时间,基础知识点,作用域、函数、闭包、this 一个个学过来, 知识点全都拼不起来,代码也看不懂,感觉知识学历和没有学一样,越学越迷茫,越学越煎熬。

直到今天回头复盘、把整条线梳理清楚才明白: 那些曾经看似无关又折磨的内容,并不是孤立的知识点, 最后拼装成了 JavaScript 最底层、最核心的运行框架。

虽然现在依然离精通很远,未来的学习也不会轻松, 但至少方向彻底清晰了,心里也有底了。 地基打牢,后面的路再难,也知道该怎么走,该往哪儿走。 踏踏实实继续往前走,就很好。

学习分享,如果有理解不对的地方,欢迎大家指正,一起学习进步

告别 `any`:TypeScript 中 `try...catch` 的最佳实践

在 TypeScript 项目中,你是否经常为了通过编译而写出这种代码?

try {
  // 某些逻辑
} catch (err: any) { // ❌ 违背了 TS 类型安全的初衷
  console.log(err.message); 
}

随着 TS 配置趋于严格,catch(err: any) 往往会触发 ESLint 警告或编译错误。本文将介绍处理 catch 块中错误对象的几种最佳实践

1. 理解 unknown 的必然性

在现代 TypeScript(4.0+)中,推荐将捕获到的错误声明为 unknown。这是因为在运行时刻,你无法保证捕获到的一定是 Error 实例。

try {
  throw "意外的错误字符串"; // 这里的错误甚至不是一个对象
} catch (err: unknown) {
  // ❌ 报错:'err' is of type 'unknown'
  // console.log(err.message); 
}

2. 方案一:类型守卫(Type Guards)—— 最稳健的方法

这是官方推荐的做法。通过显式的 instanceof 检查,TS 会在代码块内自动收窄(Narrowing)类型。

try {
  await fetchData();
} catch (err: unknown) {
  if (err instanceof Error) {
    // ✅ TS 现在知道 err 是 Error 类型
    console.error(err.message);
    console.error(err.stack);
  } else {
    // 处理非标准错误(如 throw "string")
    console.error("发生了未知类型的错误", err);
  }
}

3. 方案二:自定义工具函数(封装大法)

如果你觉得到处写 if (err instanceof Error) 太麻烦,可以封装一个工具函数。这是目前大型项目中最流行的做法。

编写工具函数

function toError(err: unknown): Error {
  if (err instanceof Error) return err;
  return new Error(String(err));
}

业务中使用

try {
  doSomething();
} catch (err: unknown) {
  const error = toError(err);
  console.log(error.message); // ✅ 永远安全
}

4. 方案三:函数式处理(类似 Rust/Go)

如果你讨厌深层嵌套的 try...catch,可以使用封装好的包装器,将错误作为返回值返回。

async function safeRun<T>(promise: Promise<T>): Promise<[Error | null, T | null]> {
  try {
    const data = await promise;
    return [null, data];
  } catch (err: unknown) {
    return [toError(err), null];
  }
}

// 使用:
const [err, data] = await safeRun(fetchUser(id));
if (err) {
  handle(err);
} else {
  render(data);
}

5. 进阶:处理 Axios 等库的特定错误

如果你在使用 Axios,可以使用它内置的类型守卫:

import axios from 'axios';

try {
  await axios.get('/api/user');
} catch (err: unknown) {
  if (axios.isAxiosError(err)) {
    // 这里可以访问 err.response, err.status 等特有属性
    console.log(err.response?.data);
  }
}

总结:该选哪一个?

场景 推荐做法
临时处理/小型脚本 if (err instanceof Error)
标准大型项目 封装 toError() 工具函数,确保类型安全
追求代码扁平化 采用 safeRun 包装器返回 [err, data]
第三方库请求 优先使用库提供的 isError 判断函数

核心原则: 永远不要相信 catch 捕获到的内容,永远在访问属性前进行类型检查。这不仅是过编译的要求,更是写出健壮代码的基石。

JS手撕:性能优化、渲染技巧与定时器实现

在前端开发中,我们经常会遇到「大量数据渲染卡顿」「频繁事件触发导致性能损耗」「自定义定时逻辑」等问题,今天就来拆解7个高频实用的JS代码片段,用「通俗话术+专业解析」的方式,讲懂每一行代码的作用、核心原理和实际应用场景,帮你吃透这些前端必备技能。

一、万条数据渲染优化:避免卡顿的核心技巧

前端渲染大量数据(比如10万条)时,直接一次性插入DOM会导致主线程阻塞,页面出现卡顿、掉帧,甚至浏览器崩溃。这段代码的核心思路是「分批渲染+文档片段+requestAnimationFrame」,从根源上减少DOM操作带来的性能损耗。

核心代码(带详细注释)

// 延迟0ms执行,让DOM先渲染完毕,避免阻塞主线程
// 这里的setTimeout(fn, 0)不是真的延迟,而是把回调放到宏任务队列,等待当前同步代码和DOM渲染完成后执行
setTimeout(() => {
  // 总共需要渲染10万条数据(实际开发中可根据需求调整)
  const total = 100000;
  // 每一批次渲染20条,防止一次性渲染过多导致卡顿
  // 批次大小可优化:一般建议20-50条,过多仍会卡顿,过少则渲染次数过多
  const once = 20;
  // 计算总共需要分多少批次(向上取整,避免最后一批数据不足20条被遗漏)
  const loopCount = Math.ceil(total / once);
  // 记录当前已经渲染了多少批次,用于判断是否渲染完成
  let countOfRender = 0;
  // 获取页面中的ul容器,所有li都会插入到这个容器中
  const ul = document.querySelector('ul');

  // 每一批次添加DOM的函数:核心是「减少DOM操作次数」
  function add() {
    // 创建文档片段(DocumentFragment),临时存放当前批次的li
    // 重点:fragment不属于页面DOM树,向它添加子元素不会触发页面重排重绘,相当于“临时仓库”
    const fragment = document.createDocumentFragment();

    // 循环生成当前批次的20个li
    for (let i = 0; i < once; i++) {
      const li = document.createElement('li');
      // 给li填充随机数字(实际开发中可替换为真实业务数据,比如列表项内容)
      li.innerText = Math.floor(Math.random() * total);
      // 先放入片段中,此时不触发任何页面渲染
      fragment.appendChild(li);
    }

    // 一次性将20条li插入ul,只触发一次DOM重排(关键优化点)
    // 对比:如果每次循环都appendChild到ul,会触发20次重排,性能极差
    ul.appendChild(fragment);
    // 已渲染批次+1,更新渲染进度
    countOfRender += 1;

    // 继续执行下一批渲染
    loop();
  }

  // 控制渲染节奏:使用requestAnimationFrame,跟随浏览器刷新频率执行
  // 浏览器每秒刷新约60次(16.67ms/帧),requestAnimationFrame会在每帧开始时执行回调
  // 好处:避免渲染操作与浏览器刷新冲突,保证页面流畅不卡顿
  function loop() {
    // 如果还没渲染完所有批次,就继续下一批
    if (countOfRender < loopCount) {
      window.requestAnimationFrame(add);
    }
  }

  // 启动第一轮渲染
  loop();
}, 0);

关键知识点解析

  • setTimeout(fn, 0):不是延迟执行,而是将回调推入宏任务队列,确保当前同步代码和DOM初始化完成后再执行,避免DOM未挂载时查询不到ul容器。

  • 分批渲染:将10万条数据拆分为5000批次(100000/20),每次只渲染20条,降低单次DOM操作的压力。

  • DocumentFragment:前端性能优化神器,作为临时DOM容器,所有操作都在内存中完成,最后一次性插入页面,只触发1次重排重绘,比直接操作真实DOM性能提升10倍以上。

  • requestAnimationFrame:与setTimeout相比,它能跟随浏览器刷新频率执行,避免“掉帧”,尤其适合大量DOM渲染、动画等场景,保证页面流畅度。

实际应用场景

大数据列表渲染(如后台管理系统的订单列表、日志列表)、长列表滚动加载(结合滚动事件,滚动到底部时加载下一批),避免一次性渲染大量数据导致页面卡死。

二、手撕防抖:解决频繁触发的“性能杀手”

防抖(Debounce)的核心逻辑:频繁触发同一事件时,只在最后一次触发后,延迟指定时间执行回调函数。比如搜索框输入、窗口resize、滚动事件,频繁触发会导致性能损耗,防抖能有效“合并”触发次数。

1. 基础版防抖(延迟执行)

// 防抖:频繁触发时,只在**最后一次触发后延迟执行**
function debounce(callback, wait) {
  // 定时器标识:用闭包保存,避免污染全局变量,且能在多次调用中共享状态
  let timer = null;

  // 返回一个可调用的包装函数,接收原函数的参数
  return function (...args) {
    // 保存原this(解决事件回调中this指向window的问题,比如btn点击事件中this应指向btn)
    const context = this;

    // 再次触发时,清除之前的定时器 → 重新计时(核心:取消上一次的延迟执行)
    if (timer) clearTimeout(timer);

    // 新建定时器:延迟 wait 毫秒后执行回调
    timer = setTimeout(() => {
      // 恢复原函数的this指向和参数,保证回调函数执行时上下文正确
      callback.apply(context, args);
      // 执行完清空timer,方便垃圾回收,避免内存泄漏
      timer = null;
    }, wait);
  };
}

2. 完整版防抖(支持立即执行/延迟执行)

基础版防抖是“延迟执行”(触发后等待wait时间再执行),但实际开发中有时需要“立即执行”(第一次触发就执行,之后频繁触发不执行,直到wait时间后才可再次执行),比如按钮点击防重复提交。

// 完整版防抖:支持 立即执行(immediate=true) / 延迟执行(immediate=false)
function debounce(callback, wait, immediate) {
  let timer = null; // 闭包缓存定时器,共享状态

  return function () {
    // 每次进入先清除上一次定时器 → 重新计时(无论立即还是延迟,都要取消上一次)
    if (timer) clearTimeout(timer);

    // ========== 立即执行模式 ==========
    if (immediate) {
      // timer为null时表示可以立即执行(首次触发或wait时间已过)
      const callNow = !timer;

      // 设置定时器:wait时间后把timer置空,解锁“立即执行”权限
      // 作用:这段时间内再次触发,callNow会为false,不会执行回调
      timer = setTimeout(() => {
        timer = null;
      }, wait);

      // 满足立即执行条件时,调用原函数,恢复this和参数
      if (callNow) {
        callback.apply(this, arguments);
      }

    // ========== 常规延迟执行模式 ==========
    } else {
      // 延迟wait时间执行,每次触发都重置定时器
      timer = setTimeout(() => {
        callback.apply(this, arguments);
        timer = null; // 执行后清空,垃圾回收
      }, wait);
    }
  };
}

关键知识点解析

  • 闭包的作用:保存timer变量,让多次触发的事件能共享同一个定时器标识,实现“清除上一次定时器”的逻辑,这是防抖的核心。

  • this指向修复:事件回调中this默认指向window(如addEventListener中的回调),通过context = this + apply(context, args),让原函数this指向正确的元素(如按钮、输入框)。

  • 立即执行vs延迟执行

    • 立即执行(immediate=true):适合防重复提交(按钮点击后立即执行,wait时间内不可再次点击);

    • 延迟执行(immediate=false):适合搜索框联想(输入停止后wait时间,再发送请求,避免频繁请求接口)。

实际应用场景

搜索框输入联想、窗口resize事件(调整窗口大小时,避免频繁计算布局)、滚动事件(滚动到底部加载更多,避免频繁触发)、按钮防重复提交。

三、手撕节流:固定频率执行,避免过度触发

节流(Throttle)的核心逻辑:频繁触发同一事件时,按照固定的时间间隔执行回调函数,无论触发多少次,都不会超过这个频率。和防抖的区别:防抖是“最后一次触发后执行”,节流是“固定频率持续执行”。

1. 立即触发版节流(停止触发后不执行最后一次)

// 节流:固定频率执行,立即触发,停止触发后不执行最后一次
function throttle(callback, wait) {
  // 上一次执行回调的时间戳(初始为0,确保第一次触发能立即执行)
  let previous = 0;

  return function(...args) {
    // 获取当前时间戳
    const now = Date.now();
    // 核心逻辑:当前时间 - 上一次执行时间 >= 等待时间,才执行回调
    if (now - previous >= wait) {
      // 执行回调,恢复this和参数
      callback.apply(this, args);
      // 更新上一次执行时间戳为当前时间,开始下一个周期
      previous = now;
    }
  };
}

2. 延迟触发版节流(停止触发后仍执行最后一次)

立即触发版节流的问题:如果停止触发时,距离上一次执行已经超过wait时间,不会执行最后一次触发的回调。延迟触发版可以解决这个问题,适合需要“收尾”的场景(如滚动加载,即使停止滚动,也要执行最后一次加载逻辑)。

// 节流:固定频率执行,延迟触发,停止触发后仍执行最后一次
function throttle(callback, wait) {
  let timer = null; // 用定时器控制延迟执行

  return function(...args) {
    const context = this;
    // 核心逻辑:没有定时器才创建,不重置计时(保证固定频率)
    if (!timer) {
      timer = setTimeout(() => {
        // 延迟wait时间执行回调
        callback.apply(context, args);
        // 执行后清空定时器,允许下一次创建
        timer = null;
      }, wait);
    }
  };
}

关键知识点解析

  • 时间戳版(立即触发):通过对比当前时间和上一次执行时间,控制执行频率,优点是简单高效,缺点是停止触发后不会执行最后一次。

  • 定时器版(延迟触发):通过定时器控制执行时机,优点是停止触发后仍会执行最后一次,缺点是首次触发会延迟wait时间才执行。

  • 防抖vs节流

    • 防抖:合并多次触发,只执行最后一次(比如搜索输入);

    • 节流:控制触发频率,固定间隔执行(比如滚动加载、鼠标移动绘制)。

实际应用场景

滚动事件(监听滚动位置,固定频率更新导航栏状态)、鼠标移动事件(绘制canvas,避免频繁重绘)、resize事件(固定频率调整页面布局)、高频点击事件(如游戏中的攻击按钮,控制点击频率)。

四、自定义定时器:可递增延迟的MySetInterval

原生setInterval的缺点:间隔时间固定,无法实现“每次执行延迟递增”的需求(比如第一次延迟100ms,第二次延迟200ms,第三次延迟300ms...)。这段代码通过类封装,实现了“基础延迟+递增步长”的自定义定时器,灵活满足复杂定时需求。

核心代码(带详细注释)

class MySetInterval {
  /**
   * @param {Function} fn 要执行的函数(回调函数)
   * @param {number} base 基础延迟 a(第一次执行的延迟时间)
   * @param {number} step 每次递增 b(每次执行的延迟比上一次多b ms)
   * @param  {...any} args 传递给 fn 的参数(可选)
   */
  constructor(fn, base, step, ...args) {
    this.fn = fn;         // 要执行的回调函数
    this.base = base;     // 基础延迟时间(ms)
    this.step = step;     // 延迟递增步长(ms)
    this.args = args;     // 传递给回调函数的参数
    this.count = 0;       // 记录执行次数(用于计算当前延迟)
    this.timer = null;    // 定时器ID,用于停止定时器
  }

  // 启动定时器
  start() {
    // 计算当前批次的延迟:a + count * b(第一次count=0,延迟a;第二次count=1,延迟a+b,以此类推)
    const delay = this.base + this.count * this.step;

    // 用setTimeout模拟递归执行,实现“递增延迟”
    this.timer = setTimeout(() => {
      // 执行用户传入的回调函数,并传递参数
      this.fn(...this.args);
      // 执行次数+1,为下一次延迟计算做准备
      this.count++;
      // 递归调用start,启动下一次定时执行
      this.start();
    }, delay);
  }

  // 停止定时器(必须有,避免内存泄漏)
  stop() {
    // 清除当前定时器
    clearTimeout(this.timer);
    // 清空timer,方便垃圾回收,也避免重复停止
    this.timer = null;
  }
}

// 使用示例
const timer = new MySetInterval(() => {
  console.log('自定义定时器执行');
}, 100, 50); // 第一次延迟100ms,第二次150ms,第三次200ms...
timer.start(); // 启动
// timer.stop(); // 停止(需要时调用)

关键知识点解析

  • 类封装优势:通过class封装,将定时器的状态(count、timer、base等)挂载到实例上,避免全局变量污染,同时方便调用start和stop方法,逻辑更清晰。

  • 递增延迟实现:通过count记录执行次数,每次执行后count+1,下一次延迟 = 基础延迟 + count * 递增步长,实现“每次延迟递增”的效果。

  • 递归setTimeout:没有使用原生setInterval,而是用setTimeout递归调用start方法,避免setInterval可能出现的“时间漂移”(比如回调执行时间过长,导致下一次执行延迟偏差)。

实际应用场景

倒计时递增(比如活动倒计时,后期每秒增加延迟,营造紧迫感)、轮播图渐变(每次切换的延迟递增,实现慢放效果)、接口重试(失败后重试,每次重试延迟递增,避免频繁请求接口)。

五、重写setTimeout:用requestAnimationFrame模拟

原生setTimeout的缺点:执行时间不精确,受主线程阻塞影响(比如主线程有耗时操作,setTimeout的回调会延迟执行)。而requestAnimationFrame(rAF)会跟随浏览器刷新频率执行(16.67ms/帧),用它模拟setTimeout,能让延迟执行更精确,同时避免主线程阻塞导致的偏差。

核心代码(带详细注释)

// 用 requestAnimationFrame 模拟 setTimeout,提升执行精度
let setTimeout = (fn, timeout, ...args) => {
  const start = Date.now(); // 记录定时器启动的时间戳
  let timer;               // 保存rAF的标识,用于取消定时器

  // 循环执行函数,每帧检查是否达到设定的延迟时间
  const loop = () => {
    // 注册下一次rAF回调,保证循环执行,跟随浏览器刷新频率
    timer = window.requestAnimationFrame(loop);
    // 获取当前时间戳
    const now = Date.now();

    // 核心逻辑:当前时间 - 启动时间 >= 设定的延迟时间,执行回调
    if (now - start >= timeout) {
      // 执行用户传入的回调函数,恢复this和参数
      fn.apply(this, args);
      // 执行完成后,取消rAF循环,避免无限执行
      window.cancelAnimationFrame(timer);
    }
  };

  // 启动rAF循环,开始计时
  window.requestAnimationFrame(loop);
};

// 使用示例(和原生setTimeout用法一致)
setTimeout(() => {
  console.log('用rAF模拟的setTimeout执行');
}, 1000);

关键知识点解析

  • 精度提升原理:原生setTimeout的延迟是“最小延迟”,如果主线程忙碌,回调会被推迟;而rAF每帧(16.67ms)执行一次loop,每次都检查时间差,一旦达到设定延迟就执行回调,精度更高。

  • 循环终止:通过cancelAnimationFrame(timer)取消rAF循环,避免回调执行后仍继续循环,造成性能损耗。

  • 用法兼容:模拟后的setTimeout用法和原生一致,无需修改现有代码,直接替换即可提升执行精度。

实际应用场景

需要精确延迟执行的场景(如动画同步、定时更新DOM)、避免主线程阻塞导致延迟偏差的场景(如复杂页面中的定时任务)。

六、模拟sleep函数:让代码“暂停”指定时间

JS中没有原生的sleep函数(即“暂停代码执行指定时间,再继续执行后面的代码”),但可以通过Promise+setTimeout模拟。核心思路:返回一个Promise,在setTimeout延迟后resolve,通过await等待Promise完成,实现代码“暂停”效果。

核心代码(带详细注释)

// 休眠函数:等待 time 毫秒后,再继续执行后面的代码
// 核心:通过Promise包裹setTimeout,用resolve触发后续代码执行
function sleep(time) {
  // 返回一个Promise对象,pending状态表示“正在休眠”
  return new Promise(function (resolve) {
    // 定时 time 毫秒后,执行resolve(),让Promise变为fulfilled状态
    // resolve()无参数,仅用于通知“休眠结束”
    setTimeout(resolve, time);
  });
}

// 使用示例(必须配合async/await,因为await只能在async函数中使用)
async function test() {
  console.log('开始执行');
  await sleep(2000); // 暂停2000ms(2秒)
  console.log('2秒后执行'); // 2秒后才会打印这句话
  await sleep(1000); // 再暂停1秒
  console.log('再1秒后执行');
}

关键知识点解析

  • Promise的作用:用Promise包裹setTimeout,将“延迟执行”转化为“异步等待”,配合await使用,实现代码的“线性暂停”,避免回调地狱。

  • async/await依赖:sleep函数返回Promise,必须在async函数中用await调用,才能实现“暂停”效果;如果不用await,代码会继续执行,不会暂停。

  • 非阻塞特性:sleep是异步暂停,不会阻塞主线程,其他异步任务(如接口请求、DOM渲染)可以在sleep期间正常执行,避免页面卡顿。

实际应用场景

代码分步执行(如引导页步骤切换,每步间隔1秒)、接口请求重试(失败后sleep1秒再重试)、模拟加载动画(sleep指定时间后隐藏加载框)。

七、版本号对比:实现语义化版本排序

在前端开发中,经常需要对版本号进行排序(如npm包版本、项目版本),版本号格式通常为“x.y.z”(如1.0.0、2.3.4、1.10.2),直接字符串排序会出现错误(如1.10.2会排在1.2.0前面),这段代码能正确对比版本号大小并排序。

核心代码(带详细注释)

// 版本号对比排序:接收版本号数组,返回按升序排列的数组
var compareVersion = function (versions) {
  // 用数组的sort方法排序,核心是自定义排序规则
  return versions.sort((version1, version2) => {
    // 1. 将版本号按“.”分割,转为数字数组(如"1.10.2" → [1,10,2])
    // map(Number):将分割后的字符串转为数字,避免字符串比较的误差
    let s1 = version1.split('.').map(Number);
    let s2 = version2.split('.').map(Number);

    // 2. 逐位对比版本号(从左到右,依次对比主版本、次版本、修订版本)
    // 循环次数取两个版本号数组的最大长度,不足的位补0(如1.0 → [1,0,0])
    for (let i = 0; i < s1.length || i < s2.length; i++) {
      const v1 = s1[i] || 0; // 版本1当前位的值,无则补0
      const v2 = s2[i] || 0; // 版本2当前位的值,无则补0
      // 若当前位不相等,直接返回差值(正数表示v1>v2,负数表示v1<v2)
      if (v1 !== v2) {
        return v1 - v2;
      }
    }

    // 3. 若所有位都相等,按原字符串排序(处理版本号格式完全一致的情况)
    return version1.localeCompare(version2);
  })
};

// 使用示例
const versions = ['1.10.2', '1.2.0', '2.3.4', '1.0.0', '1.5'];
console.log(compareVersion(versions)); 
// 输出:["1.0.0", "1.2.0", "1.5", "1.10.2", "2.3.4"]

关键知识点解析

  • 版本号分割与转数字:split('.')将版本号分割为数组,map(Number)转为数字数组,避免“10”作为字符串比“2”小的问题(字符串比较是按字符编码,'10' < '2')。

  • 逐位对比逻辑:从左到右对比每一位版本号,先对比主版本(第一位),主版本大的版本号更大;主版本相等则对比次版本(第二位),以此类推;不足的位补0(如1.5 → 1.5.0)。

  • 兜底逻辑:若所有位都相等(如1.0.0和1.0.0),用localeCompare按字符串排序,保证排序的稳定性。

实际应用场景

npm包版本排序、后台管理系统的版本日志排序、APP版本更新提示(对比当前版本和最新版本,判断是否需要更新)。

总结

以上7个代码片段,覆盖了前端开发中「性能优化」「事件处理」「定时任务」「版本对比」四大核心场景,每段代码都包含“核心逻辑+详细注释+知识点解析+实际应用”,既能直接复制使用,也能帮你理解背后的原理。

重点记住:前端性能优化的核心是「减少DOM操作」「避免频繁触发事件」「合理利用异步」,而防抖、节流、分批渲染、rAF都是实现这些目标的关键手段;自定义定时器、sleep、版本对比则是解决实际业务场景的实用工具,掌握这些,能让你的代码更高效、更健壮。

JS手撕:DOM操作 & 浏览器API高频场景详解

在前端开发中,我们经常会遇到一些重复且基础的需求——比如解析URL参数、给大量元素绑定点击事件、实现图片懒加载等。这些功能看似简单,但写得不够严谨就容易出现bug(比如中文参数乱码、事件绑定冗余、滚动加载卡顿)。

今天就整理了7个前端高频实用JS功能,用“通俗话+专业解析”的方式,把每个功能的原理、代码细节和使用场景讲透,还附上了可直接复制使用的优化版代码,新手也能快速套用。

一、URL参数解析:把URL里的参数“拆”成可直接用的对象

通俗解读

我们经常会看到这样的URL:https://xxx.com/list?page=1&size=10&keyword=前端,里面的page、size、keyword就是参数。这个功能就是把这些参数“拆出来”,变成一个对象(比如{page:1, size:10, keyword:"前端"}),不用我们手动去切割字符串,省心又不易错。

专业解析

核心利用浏览器原生的URL对象,它能自动解析URL的协议(http/https)、主机(xxx.com)、路径(/list)和查询参数(?后面的内容);再配合URLSearchParams处理查询参数,同时解决中文乱码(用decodeURIComponent解码)、空值、重复key、数字类型转换等常见问题。

完整代码(可直接复制)

function parseParam(url) {
  // 创建URL对象,自动解析协议、域名、路径、参数(原生API,无需手动切割)
  const urlObj = new URL(url);
  // 获取查询参数部分(即?后面的内容,不含?)
  const queryParams = new URLSearchParams(urlObj.search);
  const paramsObj = {};

  // 遍历所有查询参数,处理各种边界情况
  for (let [key, value] of queryParams.entries()) {
    // 空值处理:如果参数值是空字符串或null,统一赋值为true(比如?flag&name=xxx,flag对应true)
    if (value === '' || value == null) {
      paramsObj[key] = true;
    } else {
      // 解码参数:处理中文、特殊字符(比如%E5%89%8D%E7%AB%AF解码为“前端”)
      let val = decodeURIComponent(value);
      // 纯数字字符串转为数字类型(比如"123"转为123,避免后续使用时还要手动转换)
      val = /^\d+$/.test(val) ? parseFloat(val) : val;

      // 重复key处理:如果参数有多个相同key(比如?tag=js&tag=html),转为数组形式
      if (paramsObj.hasOwnProperty(key)) {
        paramsObj[key] = [].concat(paramsObj[key], val);
      } else {
        paramsObj[key] = val;
      }
    }
  }

  return paramsObj;
}

// 示例使用
const url = "https://api.example.com/data?id=123&name=张三&page=2&tag=js&tag=html&flag";
const params = parseParam(url);
console.log(params);
// 输出:{id: 123, name: "张三", page: 2, tag: ["js", "html"], flag: true}

关键注意点

  • URL对象是浏览器原生API,无需引入第三方库,兼容现代浏览器(IE不支持,需兼容IE可额外处理);

  • 中文参数必须用decodeURIComponent解码,因为URL中中文会被自动编码为%开头的字符;

  • 重复key(如?tag=js&tag=html)处理为数组,避免后面的参数覆盖前面的。

二、事件委托:一次绑定,搞定所有子元素的事件

通俗解读

如果页面有100个按钮,给每个按钮都绑定点击事件,会占用很多内存,而且新增按钮还得重新绑定。事件委托就是“找一个父容器”,只给父容器绑定一次事件,不管里面有多少个子元素(哪怕是后来新增的),点击子元素时都会触发父容器的事件,再通过判断点击的是哪个子元素,执行对应逻辑。

专业解析

利用DOM事件的“冒泡机制”(子元素的事件会向上传递给父元素),将事件绑定在父容器上,通过event.target获取真正被点击的子元素,再通过matches()方法匹配目标元素选择器,实现“一次绑定,多元素复用”,优化性能并简化代码。

完整代码(可直接复制)

/**
 * 事件委托(代理)
 * @param {string} eventType 事件类型 click/input 等(比如"click"、"input")
 * @param {string|Element} elDelegate 委托父元素(选择器字符串或DOM对象)
 * @param {string} selector 真正要触发的目标元素选择器(比如"#btn"、".item")
 * @param {Function} fn 触发的回调函数(this指向目标元素)
 */
function on(eventType, elDelegate, selector, fn) {
  // 1. 处理委托父元素:如果传入的是选择器字符串,自动转为DOM对象
  if (!(elDelegate instanceof Element) && typeof elDelegate === 'string') {
    elDelegate = document.querySelector(elDelegate);
  }

  // 安全判断:如果没找到父元素,直接退出,避免报错
  if (!elDelegate) return null;

  // 2. 给父元素绑定事件,利用事件冒泡机制
  elDelegate.addEventListener(eventType, (e) => {
    let el = e.target; // 真正被点击/触发的元素(子元素)

    // 3. 向上查找匹配selector的元素(防止点击的是子元素的子节点)
    while (el && !el.matches(selector)) {
      if (el === elDelegate) { // 查到委托父元素还没匹配到,说明不是目标元素,停止查找
        el = null;
        break;
      }
      el = el.parentNode; // 向上查找父级节点
    }

    // 4. 如果找到目标元素,执行回调,this指向目标元素
    el && fn.call(el, e, el);
  });

  return elDelegate;
}

// HTML示例
/*

*/

// 示例使用1:单个目标元素
on('click', '#box', '#btn', function(e, el){
  console.log('点击成功!');
  console.log(this); // this 指向 #btn(目标元素)
  console.log(el); // el 也是目标元素,和this一致
});

// 示例使用2:多个目标元素(.item)
on('click', '#box', '.item', function(e, el){
  console.log('点击了item按钮:', el.innerText);
});

关键注意点

  • 委托父元素必须是目标元素的祖先节点(比如按钮的父div、body、document);

  • 不要阻止事件冒泡(e.stopPropagation()),否则事件无法传递到父元素,委托失效;

  • 新增的子元素(比如通过JS动态添加的按钮),无需重新绑定事件,会自动触发委托的事件。

三、滚动加载:滚动到底部自动加载更多内容

通俗解读

我们刷朋友圈、逛电商列表时,往下滚动页面,到底部后会自动加载更多内容,这就是滚动加载。核心就是“判断页面是否滚动到底部”,如果到了,就执行加载数据的逻辑。

专业解析

通过监听window的scroll事件,获取三个关键高度:可视区域高度(屏幕能看到的页面高度)、滚动条已滚动距离、页面总高度(包括看不见的部分)。核心判断公式:可视区域高度 + 已滚动距离 ≥ 页面总高度,满足该条件即表示滚动到底部。

完整代码(可直接复制)

// 监听滚动事件
window.addEventListener('scroll', function() {
    // 1. 可视区域高度(屏幕能看到的高度,不同设备可能不同)
    const clientHeight = document.documentElement.clientHeight;
    // 2. 滚动条卷上去的高度(已滚动的距离,兼容不同浏览器)
    const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
    // 3. 整个页面总高度(包括看不见的部分,即页面完整高度)
    const scrollHeight = document.documentElement.scrollHeight;

    // 核心判断:可视高度 + 已滚动距离 ≥ 总高度 → 滚动到底部(加10是为了提前加载,优化体验)
    if (clientHeight + scrollTop >= scrollHeight - 10) {
        console.log("滚动到底部啦!");
        // 这里写加载更多逻辑(比如请求接口、渲染列表)
        // loadMoreData(); // 假设这是加载更多数据的函数
    }
}, false);

// 优化建议:滚动事件会频繁触发,可结合节流函数(参考后面的图片懒加载),避免性能消耗
// 比如:window.addEventListener('scroll', throttle(handleScroll, 300), false);

关键注意点

  • scroll事件会频繁触发(滚动过程中每秒触发几十次),建议结合节流函数(后面会讲),减少函数执行次数,优化性能;

  • 滚动到底部的判断可加一个小偏移量(比如-10),让加载提前触发,避免用户看到底部空白再加载;

  • 加载数据时,建议添加“加载中”状态,防止用户多次触发加载。

四、图片懒加载:减少页面加载时间,提升体验

通俗解读

页面有很多图片时,如果一打开就加载所有图片,会导致页面加载变慢、卡顿。图片懒加载就是“只加载屏幕能看到的图片”,用户往下滚动页面,图片进入视野后再加载,既节省带宽,又提升页面加载速度。

专业解析

核心思路:先给图片设置自定义属性(比如data-src)存储真实图片地址,src属性设为占位图(或空);监听scroll事件,判断图片是否进入可视区域,若进入,则将data-src的值赋给src,实现图片加载。同时用节流函数限制scroll事件触发频率,避免性能消耗。

完整代码(可直接复制)

// 节流函数:限制函数在指定时间内只能执行一次(优化scroll事件频繁触发)
function throttle(fn, delay) {
  let timer = null;
  return function (...args) {
    if (!timer) {
      fn.apply(this, args); // 执行函数
      timer = setTimeout(() => {
        timer = null; // 延迟后重置timer,允许下次执行
      }, delay);
    }
  };
}

// 图片懒加载核心函数
function lazyload() {
  const imgs = document.getElementsByTagName('img'); // 获取所有图片元素(注意加s,避免报错)
  const viewHeight = document.documentElement.clientHeight; // 可视区域高度
  // 滚动距离(兼容不同浏览器)
  const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;

  // 遍历所有图片,判断是否进入可视区域
  for (let i = 0; i < imgs.length; i++) {
    const img = imgs[i];
    const offsetTop = img.offsetTop; // 图片到页面顶部的距离

    // 判断:图片顶部距离 ≤ 可视区域高度 + 滚动距离 → 图片进入可视区域
    if (offsetTop < viewHeight + scrollTop) {
      // 优化:已经加载过的图片跳过,避免重复赋值(防止scroll事件重复触发导致的冗余操作)
      if (img.src === img.dataset.src) continue;

      // 开始加载图片:将data-src(真实地址)赋给src
      img.src = img.dataset.src;
    }
  }
}

// 监听scroll事件,用节流函数限制触发频率(300ms执行一次)
window.addEventListener('scroll', throttle(lazyload, 300));

// 页面刚打开时,执行一次懒加载(加载可视区域内的图片)
window.addEventListener('load', lazyload);

// HTML示例
/*
<!-- 占位图用1x1透明图,或loading图片,data-src存储真实图片地址 -->

*

关键注意点

  • 图片必须设置src属性(可设为占位图),否则会出现图片占位空白;

  • 节流函数的延迟时间(300ms)可根据需求调整,延迟太短达不到优化效果,太长会影响体验;

  • 页面加载完成后(window.load)必须执行一次lazyload,避免可视区域内的图片无法加载。

五、统计HTML页面标签:快速了解页面结构

通俗解读

有时候我们需要知道一个页面用了多少种标签、每种标签用了多少次(比如做页面优化、排查冗余标签),这个功能就能自动统计,不用手动一个个数,还能按使用次数排序。

专业解析

利用document.getElementsByTagName('*')获取页面所有元素,转为数组后提取每个元素的标签名;用Set统计标签种类(Set自动去重),用reduce统计每种标签的数量,最后用sort排序,返回标签种类数和各标签数量(从多到少)。

完整代码(可直接复制)

function countTagsOnPage() {
  // 1. 获取页面所有元素(*表示匹配所有标签)
  const allTags = document.getElementsByTagName('*');
  // 2. 转为数组,提取每个元素的标签名,并转为大写(统一格式,避免div和DIV重复统计)
  const tagNames = [...allTags].map(el => el.tagName.toUpperCase());
  
  // 3. 统计标签种类(Set自动去重,size就是种类数)
  const totalTagTypes = new Set(tagNames).size;

  // 4. 统计每种标签的数量(用reduce累加)
  const tagCount = tagNames.reduce((acc, tag) => {
    acc[tag] = (acc[tag] || 0) + 1; // 有则加1,无则初始化为1
    return acc;
  }, {});

  // 5. 按标签数量从多到少排序(将对象转为数组,再排序)
  const sorted = Object.entries(tagCount).sort((a, b) => b[1] - a[1]);

  // 返回统计结果:标签种类数、排序后的标签数量
  return {
    totalTagTypes,
    tagCounts: sorted
  };
}

// 使用并打印结果
const res = countTagsOnPage();
console.log('页面标签种类:', res.totalTagTypes);
console.log('各标签数量(从多到少):', res.tagCounts);
// 示例输出:
// 页面标签种类:8
// 各标签数量(从多到少):[["DIV", 12], ["SPAN", 8], ["IMG", 5], ["BUTTON", 3], ...]

关键注意点

  • tagName返回的是大写标签名(比如div返回DIV),统一转为大写可避免大小写重复统计;

  • document.getElementsByTagName('*')会获取所有元素,包括head、body、script等隐藏元素,若需统计可见元素,可添加筛选条件;

  • 排序后的结果是二维数组,每个元素的第一个值是标签名,第二个值是数量。

六、点击打印HTML标签名:快速定位元素标签

通俗解读

开发时,我们经常需要知道点击的元素是什么标签(比如排查样式问题、调试事件绑定),这个功能就是“点击页面任意元素,自动打印该元素的标签名”,不用手动去开发者工具里查看。

专业解析

利用事件委托(前面讲过的知识点),在document上绑定一次click事件,通过event.target获取被点击的具体元素,再用tagName获取该元素的标签名,最后用console.log打印(也可改为弹窗显示)。

完整代码(可直接复制)

// 利用事件委托,在document上绑定一次click事件,处理所有元素的点击
document.addEventListener('click', function(event) {
    // event.target 指向被点击的具体元素(最底层子元素)
    const clickedElement = event.target;
    
    // 获取标签名(tagName返回大写形式,如'DIV'、'SPAN'、'IMG')
    const tagName = clickedElement.tagName;
    
    // 打印标签名(默认控制台打印,可改为弹窗)
    console.log(`点击的元素标签名:${tagName}`);
    // 如需弹窗显示,可取消下面这行的注释
    // alert(`点击的元素标签名:${tagName}`);
});

// 示例:点击页面上的div、span、按钮,控制台会分别打印 DIV、SPAN、BUTTON

关键注意点

  • 点击的是元素的子节点(比如span里的文本),event.target会指向文本节点的父元素(span),不影响标签名获取;

  • 可根据需求修改打印方式(控制台打印/弹窗),弹窗适合非开发环境快速查看;

  • 若只想打印特定元素的标签名,可添加筛选条件(比如只打印按钮标签:if(tagName === 'BUTTON') { ... })。

七、模拟JSONP:解决跨域请求问题

通俗解读

前端请求接口时,经常会遇到“跨域”报错(比如前端域名是a.com,接口域名是b.com),JSONP是一种简单的跨域解决方案。核心就是“通过创建script标签,加载接口地址,利用script标签不受跨域限制的特性,获取后端返回的数据”。

注意:结合你提供的报错信息 link hit security strategy(链接触发安全策略),若使用JSONP时出现该报错,大概率是后端接口的安全策略限制了该请求(比如不允许JSONP请求、域名白名单限制),需联系后端调整安全策略。

专业解析

JSONP的核心原理:script标签的src属性不受同源策略限制,可加载任意域名的资源。前端生成唯一回调函数名,拼接在接口URL中;后端接收请求后,返回“回调函数名(数据)”的格式;前端通过全局回调函数,接收并处理后端返回的数据,最后删除临时创建的script标签和全局函数,避免冗余。

完整代码(可直接复制)

function JSONP(url, _params = {}) {
  // 1. 生成唯一回调函数名(防止多个JSONP请求冲突,默认用jsonp_+时间戳)
  const callbackName = _params.callback || "jsonp_" + Date.now();
  
  // 2. 处理请求参数(排除callback,因为要单独拼接)
  const params = [];
  for (let key in _params) {
    if (key !== "callback") {
      // 编码参数值,处理中文/特殊字符
      params.push(`${key}=${encodeURIComponent(_params[key])}`);
    }
  }
  // 3. 拼接callback参数(JSONP核心:后端会根据该参数返回对应的回调函数调用)
  params.push(`callback=${callbackName}`);

  // 4. 创建script标签,用于加载接口(script不受跨域限制)
  const script = document.createElement("script");
  // 拼接接口URL和参数(url?key1=value1&key2=value2&callback=xxx)
  script.src = `${url}?${params.join("&")}`;

  // 5. 返回Promise,方便用then/catch处理结果
  return new Promise((resolve, reject) => {
    
    // 6. 挂载全局回调函数(必须在script加载前定义,否则后端返回时函数还不存在)
    window[callbackName] = (result) => {
      try {
        resolve(result); // 成功:将后端返回的数据传入resolve
      } catch (err) {
        reject(err); // 失败:捕获异常并传入reject
      } finally {
        // 7. 清理工作:删除script标签和全局回调函数,避免内存泄漏
        document.body.removeChild(script);
        delete window[callbackName];
      }
    };

    // 8. 处理脚本加载失败(比如网络错误、接口不存在)
    script.onerror = () => {
      reject(new Error("JSONP 请求失败"));
      // 失败也需要清理
      document.body.removeChild(script);
      delete window[callbackName];
    };

    // 9. 把script插入页面(最后执行,确保回调函数已定义)
    document.body.appendChild(script);
  });
}

// 示例使用(结合你提供的URL)
JSONP("https://api.example.com/data", {
  id: 123,
  callback: "getData" // 可选:指定后端约定的回调名,不指定则自动生成
}).then(res => {
  console.log("拿到数据:", res);
}).catch(err => {
  console.log("出错:", err);
  // 若出现 "link hit security strategy" 报错,需检查后端安全策略
});

// 后端返回格式(必须是回调函数调用的形式)
// getData({ "name": "张三", "id": 123 })
// 前端会通过window.getData接收该数据,并传入then的回调函数

关键注意点

  • JSONP只支持GET请求,不支持POST请求(因为script标签的src只能发起GET请求);

  • 回调函数名必须唯一,避免多个JSONP请求冲突(代码中用时间戳保证唯一性);

  • 若出现 link hit security strategy 报错,不是前端代码问题,而是后端接口的安全策略限制了该JSONP请求,需联系后端调整(比如添加前端域名到白名单、允许JSONP请求);

  • 请求完成后必须清理script标签和全局函数,避免内存泄漏。

总结

以上7个JS功能,覆盖了前端开发中URL处理、事件绑定、性能优化、跨域请求等高频场景,代码均经过优化,可直接复制到项目中使用。

重点提醒:使用JSONP时若遇到 link hit security strategy 报错,需排查后端安全策略,而非前端代码;另外,事件绑定和滚动相关功能,建议结合节流函数优化性能,避免频繁触发函数导致页面卡顿。

跨越边界的艺术:现代 Web 开发跨域解决方案终极指南

一、跨域的本质:同源策略是什么?

想要解决跨域问题,首先要明白“跨域”从何而来。

1. 同源策略的定义

浏览器的同源策略(Same-Origin Policy) 是跨域的核心根源,它是浏览器最核心也最基本的安全功能。
所谓“同源”,要求两个页面的以下三点必须完全相同:

  • 协议(http/https)
  • 域名(包括主域名、子域名)
  • 端口号(80/443/3000等)

image.png 只要三者有其一不同,就会被判定为“跨域”。此时,浏览器会限制非同源页面的以下行为:

  • 读取非同源网页的 Cookie、LocalStorage、IndexedDB 等存储数据。
  • 获取非同源网页的 DOM 元素。
  • 向非同源地址发送 AJAX 请求(XMLHttpRequest/fetch)。

2. 为什么需要同源策略?

同源策略就像一道“防火墙”,从根本上限制了恶意网站的非法操作,保障了用户的信息安全。试想一下,如果没有同源策略:

  • 恶意网站可以轻易读取你网银页面的 Cookie,盗取账户信息。
  • 钓鱼网站可以嵌入真实的电商页面,篡改支付金额。
  • 任意网站都能向你的服务器发送伪造请求,发起 CSRF 攻击。

3. 跨域的常见场景

日常开发中,跨域几乎无处不在:

  • 前后端分离项目:前端运行在 localhost:5173,后端接口在 localhost:3000(端口不同)。
  • 调用第三方接口:如支付、地图、天气等第三方服务(域名不同)。
  • 多端协作:公司内部不同部门的系统对接(子域名不同)。

二、方案 1:JSONP——兼容性拉满的“老古董”

JSONP(JSON with Padding)是跨域方案中的“老前辈”,也是早期前端解决跨域最常用的方式,最大的优势是浏览器兼容性极好(甚至能兼容 IE6/7)。

1. JSONP 的核心原理

浏览器的同源策略限制了 AJAX 请求,但并没有限制 <script> 标签的 src 属性。<script> 可以加载任意域名的资源(比如 CDN 上的 jQuery)。
JSONP 正是利用这一“漏洞”实现跨域:

  • 前端动态创建 <script> 标签,通过 src 向跨域接口发送请求,同时传递一个回调函数名。
  • 后端接收到请求后,将数据包裹在回调函数中返回(即“JSON with Padding”)。
  • 前端的回调函数被执行,从而拿到跨域数据。

2. JSONP 实战实现

前端代码(封装 JSONP 函数)
这段代码封装了一个返回 Promise 的 JSONP 函数,便于处理异步逻辑:

// 封装JSONP请求函数,返回Promise方便异步处理
function jsonp({ url, params, callback }) {
  return new Promise((resolve, reject) => {
    // 1. 创建script标签
    let script = document.createElement('script')
    // 2. 定义全局回调函数,接收后端返回的数据
    window[callback] = function(data) {
      resolve(data) // 成功拿到数据,resolve Promise
      document.body.removeChild(script) // 移除script标签,避免污染
    }
    // 3. 拼接请求参数(包含回调函数名)
    params = { ...params, callback } // 比如:{wd: 'test', callback: 'show'}
    let arrs = []
    for (let key in params) {
      arrs.push(`${key}=${params[key]}`)
    }
    // 4. 设置script的src属性,发送请求
    script.src = `${url}?${arrs.join('&')}`
    document.body.appendChild(script)
    // 5. 处理请求失败场景
    script.onerror = function() {
      reject(new Error('JSONP请求失败'))
      document.body.removeChild(script)
    }
  })
}

// 调用JSONP请求
jsonp({
  url: 'http://localhost:3000/say',
  params: { wd: 'Iloveyou' },
  callback: 'show'
}).then(data => {
  console.log('JSONP请求结果:', data)
}).catch(err => {
  console.error(err)
})

后端代码(Node.js 原生实现)
后端需要接收回调函数名,并将数据包裹在函数调用中返回:

const http = require('http');
const server = http.createServer((req, res) => {
  // 匹配/say接口
  if (req.url.startsWith('/say')) {
    // 解析URL参数
    const url = new URL(req.url, `http://${req.headers.host}`);
    const callback = url.searchParams.get('callback'); // 获取回调函数名
    
    // 设置响应头:返回JS脚本
    res.writeHead(200, { 'Content-type': 'text/javascript' });
    // 构造返回数据,包裹在回调函数中
    const data = {
      id: 1,
      username: 'admin',
      msg: 'JSONP请求成功'
    }
    // 核心:返回 "回调函数(数据)" 格式的JS代码
    res.end(`${callback}(${JSON.stringify(data)})`);
  } else {
    res.writeHead(404);
    res.end('Not Found')
  }
})

server.listen(3000, () => {
  console.log('JSONP服务器运行在 http://localhost:3000');
})

3. JSONP 的优缺点

表格

维度 详细说明
✅ 优点 兼容性极强:支持所有主流浏览器,包括低版本 IE。 实现简单:无需复杂的配置,前端后端少量代码即可完成。
❌ 缺点 仅支持 GET 请求:因为 <script> 标签的 src 只能发起 GET 请求。 安全风险:容易遭受 XSS 攻击(加载的脚本可能包含恶意代码),需确保请求的服务器是可信的。 性能问题:额外加载的 <script> 标签会阻塞页面渲染,影响首屏加载速度。

4. 适用场景

仅推荐在兼容老旧浏览器(如需要支持 IE6/7)的场景下使用。在现代项目中,优先选择其他方案。

三、方案 2:CORS——现代跨域的主流之选

CORS(跨域资源共享,Cross-Origin Resource Sharing)是W3C制定的标准,也是目前解决跨域问题最主流、最推荐的方式。它本质上是在HTTP协议之上,通过增加特定的请求头和响应头,让浏览器和服务器协同工作,来判断一个跨域请求是否被允许。

1. CORS 的核心原理

CORS 的核心思想是:将跨域的控制权从浏览器完全转移到服务器。

1. 浏览器发起请求:当浏览器发起一个跨域请求时,它会自动在请求头中添加 Origin 字段,标明请求的来源(协议、域名、端口)。

2. 服务器决策:服务器接收到请求后,根据 Origin 字段的值来判断是否允许这个来源的请求。

3. 服务器响应:如果服务器允许该请求,它会在响应头中添加 Access-Control-Allow-Origin 字段,其值就是被允许的源。

4. 浏览器检查:浏览器收到响应后,会检查响应头中的 Access-Control-Allow-Origin 是否与请求的 Origin 匹配。如果匹配,则将响应数据返回给前端JS代码;如果不匹配,则浏览器会拦截响应,并在控制台抛出跨域错误。

2. 简单请求 vs 预检请求

CORS 将跨域请求分为两类:简单请求预检请求

简单请求 一个请求要成为简单请求,必须同时满足以下条件:

请求方法是以下之一:GET, HEAD, POST

自定义请求头:除了浏览器自动设置的 Accept, Accept-Language, Content-Language, Content-Type 等,没有添加其他自定义请求头。

Content-Type 的值仅限于:application/x-www-form-urlencoded, multipart/form-data, text/plain

对于简单请求,浏览器会直接发送,并在请求头中带上 Origin

预检请求 不满足简单请求条件的,就是预检请求。例如,使用 PUTDELETE 方法,或者 Content-Typeapplication/json,又或者添加了自定义请求头(如 token)。

对于预检请求,浏览器会先自动发起一个 OPTIONS 方法的请求(这就是“预检”),询问服务器是否允许当前的跨域请求。只有在服务器明确回复“允许”后,浏览器才会真正发起后续的请求。

3. CORS 实战实现

后端代码(Node.js + Express) 使用 Express 框架时,可以借助 cors 中间件轻松实现 CORS。

const express = require('express');
const cors = require('cors'); // 引入cors中间件
const app = express();

// 1. 允许所有来源的跨域请求 (最宽松的配置)
app.use(cors());

// 2. 或者,进行更精细的配置
const corsOptions = {
  origin: 'http://localhost:5173', // 只允许这个源访问
  methods: ['GET', 'POST', 'PUT'], // 允许的请求方法
  allowedHeaders: ['Content-Type', 'Authorization'], // 允许的自定义请求头
  credentials: true // 允许携带Cookie等凭证
};
app.use(cors(corsOptions));

// 定义一个需要跨域访问的接口
app.get('/api/data', (req, res) => {
  res.json({ message: 'CORS请求成功!', data: [1, 2, 3] });
});

app.listen(3000, () => {
  console.log('CORS服务器运行在 http://localhost:3000');
});

后端代码(Node.js 原生实现) 如果不使用框架,也可以手动设置响应头。

const http = require('http');
const server = http.createServer((req, res) => {
  // 设置允许跨域的源,* 表示允许所有源
  res.setHeader('Access-Control-Allow-Origin', '*');
  // 允许的请求方法
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  // 允许的自定义请求头
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  // 允许携带凭证(如Cookie)
  res.setHeader('Access-Control-Allow-Credentials', 'true');

  // 处理预检请求
  if (req.method === 'OPTIONS') {
    res.writeHead(204); // 204 No Content
    res.end();
    return;
  }

  // 处理其他业务请求
  if (req.url === '/api/data') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ message: 'CORS请求成功!', data: [1, 2, 3] }));
  }
});

server.listen(3000, () => {
  console.log('CORS服务器运行在 http://localhost:3000');
});

4. 关键的 CORS 响应头

响应头 说明
Access-Control-Allow-Origin 必需。指定允许访问的源,可以是 *(通配符)或具体的源(如 http://example.com)。
Access-Control-Allow-Methods 预检请求必需。指定允许的请求方法,如 GET, POST, PUT
Access-Control-Allow-Headers 预检请求必需。指定允许的自定义请求头,如 Content-Type, Authorization
Access-Control-Allow-Credentials 可选。一个布尔值,表示是否允许浏览器发送 Cookie。如果为 true,则 Access-Control-Allow-Origin 不能为 *,必须是具体的源。
Access-Control-Max-Age 可选。指定预检请求的缓存时间(秒),避免频繁发送 OPTIONS 请求。

5. CORS 的优缺点

维度 详细说明
优点 功能强大:支持所有类型的 HTTP 请求(GET, POST, PUT, DELETE 等)。安全性高:通过服务器精确控制允许的源、方法和请求头。现代标准:被所有现代浏览器支持,是前后端分离项目的最佳实践。
缺点 需要后端配合:必须在服务器端进行配置,前端无法单方面解决。配置复杂:对于需要携带凭证或复杂请求头的场景,配置相对繁琐。兼容性问题:不支持 IE9 及以下版本。

6. 适用场景

CORS 是现代 Web 开发中解决跨域问题的首选方案,尤其适用于前后端分离的架构。只要后端能够配合修改响应头,就应该优先使用 CORS。

四、方案 3:反向代理——“曲线救国”的万能钥匙

反向代理是开发环境中解决跨域问题最常用、也最省心的方法之一。它的核心思想是 “曲线救国” :既然浏览器禁止前端直接访问后端接口,那我们就让前端请求一个和自己“同源”的代理服务器,再由这个代理服务器去请求真正的后端接口。

1. 反向代理的核心原理

1.  前端请求代理:前端应用(如运行在 localhost:5173)不再直接请求后端接口(如 localhost:3000/api),而是请求一个与自己同源的代理地址(如 localhost:5173/api)。因为源相同,所以不会触发浏览器的跨域限制。

2.  代理服务器转发:这个代理服务器(通常是开发服务器或 Nginx)收到请求后,会以自己的身份向真正的后端接口(localhost:3000/api)发起请求。这个请求是服务器与服务器之间的通信,不受浏览器同源策略的限制。

3. 代理服务器返回:代理服务器拿到后端接口的响应数据后,再原封不动地返回给前端。 通过这种方式,前端巧妙地绕过了浏览器的跨域限制,实现了数据的获取。

2. 反向代理实战实现

反向代理的实现方式多种多样,从开发环境的配置到生产环境的部署,都有其身影。

开发环境:Vite/Webpack 配置 在现代前端框架(如 Vue, React)的开发环境中,我们通常使用 Vite 或 Webpack 作为开发服务器。它们都内置了强大的代理功能,只需几行配置即可解决跨域问题。

Vite 配置示例 ( vite.config.js )

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    // 配置代理规则
    proxy: {
      // 当前端请求 /api 路径时,触发代理
      '/api': {
        target: 'http://localhost:3000', // 代理的目标服务器地址
        changeOrigin: true, // 修改请求头中的 Origin 为目标服务器的 Origin
        // rewrite: (path) => path.replace(/^/api/, '') // 可选:重写路径,去掉 /api 前缀
      }
    }
  }
})

配置完成后,前端请求 http://localhost:5173/api/data,开发服务器会自动将其转发到 http://localhost:3000/api/data

生产环境:Nginx 配置 在项目部署到生产环境时,Nginx 是最常用的反向代理服务器。它不仅能处理跨域,还能提供负载均衡、静态资源服务、缓存等多种功能。

Nginx 配置示例 ( nginx.conf )

server {
    listen       80;
    server_name  localhost; # 或者你的域名

    # 1. 配置前端静态文件
    location / {
        root   /usr/share/nginx/html; # 前端打包文件的路径
        index  index.html index.htm;
        try_files $uri $uri/ /index.html; # 解决前端路由 history 模式刷新404的问题
    }

    # 2. 配置后端接口代理
    location /api/ {
        proxy_pass http://backend_server:3000/; # 后端服务器的地址
        proxy_set_header Host $host;             # 传递原始主机头
        proxy_set_header X-Real-IP $remote_addr; # 传递用户真实IP
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

这样配置后,无论是前端页面还是 /api/ 开头的接口请求,都由同一个 Nginx 服务器处理,完美规避了跨域问题。

3. 反向代理的优缺点

维度 详细说明
优点 前端无感:前端代码无需任何修改,完全不用关心跨域问题。功能强大:除了跨域,还能实现负载均衡、请求/响应拦截、日志记录等。通用性强:适用于任何类型的请求,不受请求方法和请求头的限制。
缺点 增加服务器成本:需要额外部署和维护一台代理服务器(如 Nginx)。配置相对复杂:相比 CORS,反向代理的配置(尤其是 Nginx)需要一定的运维知识。可能增加延迟:请求多了一次转发,理论上会增加一点点网络延迟。

4. 适用场景

● 开发环境:强烈推荐使用 Vite/Webpack 的代理功能,是开发阶段解决跨域问题的首选。

● 生产环境:当你无法控制后端服务器(例如调用第三方API),或者后端团队不方便配合配置 CORS 时,使用 Nginx 反向代理是最佳选择。它也是微服务架构中 API 网关的雏形。

五、方案 4:WebSocket——实时通信的“特权通道”

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。它最大的特点是不受同源策略的限制,这使得它在需要实时双向通信的场景下,成为了一个天然的跨域解决方案。

1. WebSocket 的核心原理

WebSocket 的工作流程可以分为三个阶段:

1. 握手阶段:前端通过 JavaScript 创建一个 WebSocket 对象,浏览器会向服务器发起一个特殊的 HTTP 请求。这个请求头中包含 Upgrade: websocket 字段,表示希望将协议从 HTTP 升级到 WebSocket。

2. 协议升级:服务器收到请求后,如果支持 WebSocket,会返回一个状态码为 101 (Switching Protocols) 的响应,同意协议升级。

3. 数据传输:一旦握手成功,客户端和服务器之间就建立了一条持久的 TCP 连接。此后,双方可以随时主动向对方推送数据,而无需像 HTTP 那样由客户端反复发起请求。

正是因为 WebSocket 在握手成功后就脱离了 HTTP 协议的范畴,建立了一条独立的“管道”,所以浏览器不会对其应用同源策略的限制。

2. WebSocket 实战实现

前端代码 前端使用非常简单,只需几行代码即可建立连接并监听事件。

// 1. 创建 WebSocket 连接,传入服务器地址
// 注意:协议是 ws:// (或 wss:// 用于加密连接)
const socket = new WebSocket('ws://localhost:3000');

// 2. 监听连接成功事件
socket.onopen = function(event) {
  console.log('WebSocket 连接已建立');
  // 连接成功后,可以立即向服务器发送数据
  socket.send(JSON.stringify({ type: 'init', data: 'Hello Server!' }));
};

// 3. 监听服务器发来的消息
socket.onmessage = function(event) {
  console.log('收到服务器消息:', event.data);
  const data = JSON.parse(event.data);
  // 根据消息类型处理数据
  if (data.type === 'notification') {
    alert(data.message);
  }
};

// 4. 监听连接关闭事件
socket.onclose = function(event) {
  console.log('WebSocket 连接已关闭');
};

// 5. 监听连接错误事件
socket.onerror = function(event) {
  console.error('WebSocket 发生错误:', event);
};

// 随时可以向服务器发送数据
function sendMsg() {
  socket.send('这是一条新消息');
}

后端代码(Node.js + ws 库) 后端可以使用 ws 这个轻量级的 WebSocket 库来快速搭建服务器。

const WebSocket = require('ws');

// 创建一个 WebSocket 服务器,监听 3000 端口
const wss = new WebSocket.Server({ port: 3000 });

// 监听客户端连接事件
wss.on('connection', (ws) => {
  console.log('有客户端连接进来了');

  // 向当前连接的客户端发送欢迎消息
  ws.send(JSON.stringify({ type: 'welcome', message: '欢迎连接到 WebSocket 服务器' }));

  // 监听当前客户端发来的消息
  ws.on('message', (data) => {
    console.log('收到客户端消息:', data.toString());
    // 可以将消息广播给所有连接的客户端
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(`广播: ${data}`);
      }
    });
  });

  // 监听客户端断开连接
  ws.on('close', () => {
    console.log('客户端连接已关闭');
  });
});

console.log('WebSocket 服务器运行在 ws://localhost:3000');

3. WebSocket 的优缺点

维度 详细说明
优点 天然跨域:不受同源策略限制,无需额外配置。实时双向通信:服务器可以主动向客户端推送数据,延迟极低。持久连接:只需一次握手,即可保持长时间通信,减少了 HTTP 反复建立连接的开销。
缺点 协议不同:需要服务器和客户端都支持 WebSocket 协议,不适用于传统的 HTTP 请求场景。兼容性问题:不支持 IE9 及以下版本。连接管理复杂:需要处理连接的建立、维持、断开和重连,比简单的 HTTP 请求更复杂。

4. 适用场景

WebSocket 专为实时性要求高的场景而生,例如:

● 在线聊天/即时通讯:如微信网页版、在线客服。

● 实时数据推送:如股票行情、体育比赛比分、新闻快讯。

● 协同编辑:如在线文档、代码编辑器。

● 多人在线游戏:需要实时同步玩家状态。


六、方案 5:Vite 反向代理 —— 本地开发的 “最优解”

在前端本地开发阶段,我们经常会遇到这样的场景:前端项目运行在 http://localhost:5173,而后端接口运行在 http://localhost:3000。虽然这只是开发环境下的端口不同,但在浏览器看来这就是“跨域”。

虽然可以通过后端配置 CORS 来解决,但在开发阶段,更优雅、更安全的方式是利用开发服务器(如 Vite、Webpack)进行反向代理。这种方式不需要后端做任何改动,完全由前端工具链来处理跨域问题。

1. Vite 代理的核心原理

Vite 代理的本质是利用了开发服务器(Dev Server)作为“中间人”。

  • 前端视角:前端代码发起请求时,目标地址是 Vite 服务器(例如 /api/user)。
  • 同源策略豁免:因为 Vite 服务器就是提供前端页面的服务端,所以前端请求 /api/user 属于“同源请求”,浏览器不会拦截。
  • 服务器转发:Vite 服务器接收到请求后,发现这是一个代理请求,于是它会以“服务器身份”向真正的后端接口(例如 http://localhost:3000/api/user)发起请求。
  • 响应返回:后端接口将数据返回给 Vite 服务器,Vite 再将数据返回给前端浏览器。

关键点:浏览器与 Vite 服务器之间是同源的(无跨域);Vite 服务器与后端服务器之间是服务器间的通信(不受浏览器同源策略限制)。通过这种“曲线救国”的方式,完美绕过了浏览器的跨域限制。

2. Vite 代理实战配置

Vite 内置了强大的代理功能,基于 http-proxy 中间件实现。我们只需要在 vite.config.js 中进行简单的配置即可。

配置步骤:

  1. 打开项目根目录下的 vite.config.js 文件。
  2. 在 server 选项中添加 proxy 配置。
  3. 定义需要代理的路径前缀(如 /api)。
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    // 配置代理规则
    proxy: {
      // 1. 定义代理前缀
      // 当请求路径以 '/api' 开头时,触发代理
      '/api': {
        // 2. 目标服务器地址
        // 这里填写后端接口的真实地址
        target: 'http://localhost:3000',
        
        // 3. 是否改变请求头中的 Origin
        // 设置为 true 时,Vite 会将请求头的 Host 改为目标服务器的 Host
        // 避免后端因为 Origin 校验不通过而拒绝请求
        changeOrigin: true,
        
        // 4. 路径重写 (可选)
        // 如果后端不需要 '/api' 这个前缀,可以将其重写为空
        // 例如:前端请求 '/api/user' -> 后端接收 '/user'
        rewrite: (path) => path.replace(/^/api/, '')
      },
      
      // 5. 多个代理配置 (可选)
      // 如果有多个不同的后端服务,可以继续添加
      '/upload': {
        target: 'http://upload-server.com',
        changeOrigin: true
      }
    }
  }
})

3. 配置项详解

表格

配置项 类型 说明
target String 必需。你要代理到的目标地址,即后端接口的真实域名或 IP。
changeOrigin Boolean 推荐开启。设为 true 时,会自动修改请求头中的 host 为 target 的值。很多后端框架(如 Nginx、Java Spring)会校验 host,不开启可能导致 403/404 错误。
rewrite Function 可选。用于重写请求路径。例如,如果后端接口不需要前端定义的前缀(如 /api),可以用此函数将其替换或删除。
secure Boolean 如果目标是 https 接口,设为 false 可以忽略 HTTPS 证书校验(开发环境常用)。

4. 优缺点分析

优点:

  • 开发环境专用神器:无需后端配合,前端开发者自己就能搞定,不影响生产环境配置。
  • 无跨域风险:完全在开发服务器层面处理,浏览器根本感知不到跨域的存在。
  • 配置极其简单:Vite 内置功能,几行代码即可完成,且支持 TypeScript 配置。
  • 支持 WebSocket:Vite 代理也支持代理 WebSocket 连接(配置 ws: true)。

缺点:

  • 仅限开发环境:Vite 代理只在 vite dev 启动的开发服务器中生效。项目打包上线后,Vite 服务器不再运行,代理配置也随之失效。
  • 无法解决生产环境问题:它只是一个开发时的“模拟器”,不能用于解决线上环境的跨域问题。

5. 适用场景

  • 前端本地开发:这是该方案的唯一且最佳场景。
  • 接口联调阶段:在后端尚未部署或无法修改响应头时,前端通过代理快速进行联调。
  • Mock 数据切换:配合环境变量,可以在代理真实接口和本地 Mock 服务之间灵活切换。

八、方案 7:postMessage —— 跨域通信的“万能信使”

前文提到的 JSONP、CORS、反向代理等方案,主要解决的是“浏览器向服务器请求数据”的跨域问题。但在现代 Web 开发中,我们经常会遇到“页面与页面”、“窗口与窗口”之间需要通信的场景,例如:

  • 父页面与嵌入的跨域 iframe 进行数据交互。
  • 主窗口与通过 window.open() 打开的跨域子窗口同步状态。
  • 主线程与 Web Worker 之间传递消息。

对于这些场景,postMessage 是 HTML5 提供的标准解决方案,它就像一个“万能信使”,允许不同源的窗口之间安全地进行双向通信。

1. postMessage 的核心原理

postMessage 的核心思想是 “消息传递” 而非“直接访问”。它打破了浏览器的同源策略,但并没有完全移除安全限制。

  • 发送方:通过 targetWindow.postMessage(message, targetOrigin) 方法,向目标窗口发送一条结构化的数据消息。
  • 接收方:通过监听 window 对象的 message 事件,来捕获并处理来自其他窗口的消息。
  • 安全校验:整个过程通过 targetOrigin(发送时指定目标源)和 event.origin(接收时检查消息来源)进行双重安全校验,确保消息只在你信任的窗口之间传递。

2. postMessage 实战实现

我们以最常见的 父页面与跨域 iframe 通信 为例,展示如何实现双向通信。

父页面 (parent.html)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>父页面</title>
</head>
<body>
    <h1>我是父页面</h1>
    <!-- 嵌入一个跨域的 iframe -->
    <iframe id="childFrame" src="https://iframe-example.com/child.html"></iframe>

    <script>
        const iframe = document.getElementById('childFrame');

        // 1. 向 iframe 发送消息
        // 注意:必须等待 iframe 加载完成后再发送
        iframe.onload = () => {
            const data = { type: 'GREETING', text: 'Hello from parent!' };
            // 精确指定目标源,这是安全的关键!
            iframe.contentWindow.postMessage(data, 'https://iframe-example.com');
        };

        // 2. 监听来自 iframe 的消息
        window.addEventListener('message', (event) => {
            // 【安全红线】必须校验消息来源!
            if (event.origin !== 'https://iframe-example.com') {
                console.warn('收到来自非法源的消息,已忽略');
                return;
            }
            console.log('父页面收到消息:', event.data);
        });
    </script>
</body>
</html>

子页面 (child.html)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>子页面 (iframe)</title>
</head>
<body>
    <h1>我是子页面 (iframe)</h1>
    <script>
        // 1. 监听来自父页面的消息
        window.addEventListener('message', (event) => {
            // 【安全红线】同样必须校验消息来源!
            if (event.origin !== 'https://parent-example.com') {
                return;
            }
            console.log('子页面收到消息:', event.data);

            // 2. 向父页面回复消息
            const replyData = { type: 'REPLY', text: 'Hello back!' };
            // event.source 是发送消息的窗口对象的引用
            event.source.postMessage(replyData, event.origin);
        });
    </script>
</body>
</html>

3. API 详解

发送消息:targetWindow.postMessage(message, targetOrigin)

表格

参数 类型 说明
message 任意类型 要发送的数据。可以是字符串、对象、数组等,数据会被浏览器使用“结构化克隆算法”进行序列化。
targetOrigin String 安全关键!  指定接收消息的窗口的源(协议+域名+端口)。必须精确指定,严禁在生产环境使用通配符 '*' ,否则可能导致敏感数据泄露给恶意网站。

接收消息:window.addEventListener('message', callback)

回调函数接收一个 MessageEvent 对象,其中包含三个关键属性:

表格

属性 类型 说明
event.data 任意类型 发送方传递的实际消息数据。
event.origin String 安全关键!  发送消息的窗口的源。接收方必须校验此属性,确保消息来自可信源。
event.source Window 对象 发送消息的窗口对象的引用。可用于向发送方回传消息,实现双向通信。

4. 优缺点分析

优点:

  • 功能强大:解决了页面间通信的跨域问题,这是 CORS 和代理无法做到的。
  • 双向通信:支持父子窗口、主副窗口之间的双向消息传递。
  • 安全性高:通过 targetOrigin 和 event.origin 的双重校验,可以有效防止恶意攻击。
  • 数据灵活:支持传递复杂的结构化数据。

缺点:

  • 使用场景特定:仅适用于窗口间通信,不适用于常规的 AJAX 请求。
  • 安全要求高:开发者必须手动进行源校验,任何疏忽都可能导致严重的安全漏洞(如 XSS)。
  • 异步通信:基于事件模型,处理复杂交互时逻辑可能变得分散。

5. 适用场景

  • 第三方组件集成:如嵌入支付宝/微信支付的 iframe,支付完成后通知父页面。
  • 跨域单点登录(SSO) :通过一个中央登录页,使用 postMessage 将登录令牌传递给其他域名的应用。
  • 微前端架构:主应用与子应用之间进行状态同步和事件通知。
  • 多窗口协作:如在线协作文档,主窗口打开多个编辑窗口,并同步光标位置和编辑内容。
  • Web Worker:主线程与后台线程进行数据交换。
❌