阅读视图

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

基于 Lexical 实现变量输入编辑器

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:霁明

1. 引言

1.1 背景与动机

在 AIWorks 的工作流和 Agent 编排系统中,有一个核心需求:支持在节点配置面板的配置项中引用上游节点的输出变量。例如,一个 LLM 节点需要引用“开始节点”的用户输入或自定义变量,或者引用上一个“HTTP 请求节点”的返回结果。

最直接的方案是使用传统的 Input 或 Textarea 组件,配合变量占位符语法如 {{nodeId.variableName}}。但这种方案存在明显的用户体验问题:

  • 可读性差:原始的变量语法对用户不友好,难以快速识别变量来源
  • 输入效率低:用户需要记忆变量名称和语法格式
  • 缺乏上下文:无法直观展示变量所属节点和类型
  • 易出错:手动输入变量语法容易出现拼写错误

我们期望的用户体验是:

  1. 用户输入 / 字符时,自动弹出变量选择菜单
  2. 菜单按节点分组展示所有可用变量,支持搜索过滤
  3. 选择变量后,以可视化标签的形式展示(显示节点图标、节点名称、变量名)
  4. 底层数据仍保持 {{#nodeId.variableName#}} 格式,便于后端解析

1.2 最终效果

实现后的效果如下:

Lexical 变量输入编辑器 Jan 6 2026.gif

  • 触发菜单:在编辑器中任意位置输入 /,即刻弹出变量选择悬浮菜单
  • 变量搜索:支持按变量名进行搜索
  • 可视化标签:选中的变量渲染为带有节点图标和样式的标签
  • 无缝编辑:标签与普通文本混排,支持 Input 组件中的常规操作,例如复制、删除、撤销等

2. 技术选型:为什么选择 Lexical?

2.1 Lexical 简介

Lexical 是 Meta(Facebook)于 2022 年开源的一个可扩展的可扩展富文本编辑器框架,它专注于提供高可靠性、出色的可访问性和高性能,让开发者能构建出从简单文本到复杂富文本协作编辑器的应用。它核心是一个轻量、无依赖的编辑器,通过模块化的插件机制支持自定义功能,支持与 React 等前端框架进行绑定,旨在简化富文本编辑器的开发和维护。

2.2 主流富文本框架对比

维度 Lexical Slate Tiptap ProseMirror Editor.js Quill
维护方 Meta 社区 Tiptap 团队 社区 CodeX 团队 社区
是否开源 是 (MIT) 是 (MIT) 是 (MIT) 是 (MIT) 是 (Apache 2.0) 是 (BSD)
React 支持 原生 原生 支持 需适配层 支持 支持
学习曲线 中等 中等偏高 中等偏低 陡峭
社区生态 增长迅速 稳定 繁荣 稳定 稳定 稳定
TS 支持 完善 完善 完善 支持 支持 支持
核心优势 高可靠性、高性能、Meta 背书,适合现代 web 应用 灵活性极高、符合 React 直觉 兼顾易用与强大、UI 无头 协同编辑天花板、极其严谨 块级结构、天然适合 CMS 简单易用、稳定
主要劣势 文档仍可优化 升级可能断层 协作/高级功能需付费订阅 开发门槛极高 跨行选择等体验有限 定制复杂功能较难
适用场景 现代高性能 React 应用 需要极度定制 UI 的 React 项目 快速交付的产品 复杂协同办公 (Google Docs 类) 新闻发布、类 Notion 编辑器 评论区、简单博客、CMS

2.3 选择 Lexical 的理由

  1. 轻量级:核心库约 42KB(gzip 后),对 bundle size 友好
  2. 现代架构:基于不可变状态,与 React 理念一致
  3. 高性能:优化的内部机制使得能够处理大规模的文本编辑任务而不牺牲响应速度
  4. 强扩展性:插件化设计,自定义节点类型简单直观
  5. React 深度集成:虽然并不仅限于 React,但它提供了与 React 深度集成的能力
  6. 官方维护:Meta 活跃维护,稳定可靠
  7. TypeScript 原生:完整的类型支持,开发体验好
  8. 同类主流产品验证:Dify、FastGPT 等都采用 Lexical 实现变量输入功能

2.4 AIWorks 使用的依赖

{
  "lexical": "^0.35.0",
  "@lexical/react": "^0.35.0",
  "@lexical/text": "^0.35.0",
  "@lexical/utils": "^0.35.0"
}
  • lexical:核心库,提供编辑器状态管理、节点系统、命令系统
  • @lexical/react:React 绑定,提供 Composer、插件等组件
  • @lexical/text:文本处理工具,包含文本实体(Text Entity)相关功能
  • @lexical/utils:工具函数,如 mergeRegister 用于批量注册/注销

3. Lexical 核心概念速览

在深入实现之前,我们需要理解 Lexical 的几个核心概念。

3.1 编辑器状态

Lexical 采用不可变状态设计。编辑器的所有内容都存储在 EditorState 中,任何修改都会产生新的状态对象。

// 读取状态(只读操作)
editor.getEditorState().read(() => {
  const root = $getRoot();
  const text = root.getTextContent();
});

// 更新状态(写操作)
editor.update(() => {
  const selection = $getSelection();
  if ($isRangeSelection(selection)) {
    selection.insertText('Hello');
  }
});

关键点

  • read() 内只能读取,不能修改
  • update() 内可以读取和修改
  • 所有 $ 开头的函数(如 $getRoot$getSelection)只能在这两个回调中调用

3.2 节点体系

Lexical 的内容由树状节点结构组成:

RootNode
  └── ParagraphNode (ElementNode)
        ├── TextNode ("普通文本")
        ├── VariableLabelNode (DecoratorNode) 
        └── TextNode ("更多文本")

核心节点类型:

类型 说明 示例
RootNode 根节点,每个编辑器有且仅有一个 -
ElementNode 容器节点,可包含子节点 ParagraphNode, ListNode
TextNode 文本叶子节点 普通文本内容
DecoratorNode 装饰器节点,可渲染自定义 React 组件 变量标签、提及、表情

DecoratorNode 是实现自定义可视化元素的关键,后文会详细讲解。

3.3 命令系统

Lexical 使用命令模式处理用户输入和操作:

// 创建自定义命令
const HELLO_WORLD_COMMAND: LexicalCommand<string> = createCommand();

// 注册自定义命令行为
editor.registerCommand(
  HELLO_WORLD_COMMAND,
  (payload: string) => {
    console.log(payload);
    return false;
  },
  COMMAND_PRIORITY_LOW,
);

// 触发对应命令
editor.dispatchCommand(HELLO_WORLD_COMMAND, 'Hello World!');

Lexical 内置了许多命令,例如:KEY_DOWN_COMMAND、UNDO_COMMAND、INSERT_TAB_COMMAND 等,具体可查看LexicalCommands.ts

命令优先级从高到低:

  • COMMAND_PRIORITY_CRITICAL (4)
  • COMMAND_PRIORITY_HIGH (3)
  • COMMAND_PRIORITY_NORMAL (2)
  • COMMAND_PRIORITY_LOW (1)
  • COMMAND_PRIORITY_EDITOR (0)

3.4 节点转换

节点转换是 Lexical 的强大特性,允许监听特定类型节点的变化并自动处理:

editor.registerNodeTransform(TextNode, (textNode) => {
  // 每当 TextNode 发生变化时触发
  const text = textNode.getTextContent();
  
  // 检测特定模式并转换
  if (isVariablePattern(text)) {
    const variableNode = $createVariableLabelNode(...);
    textNode.replace(variableNode);
  }
});

这是实现“输入特定文本自动转换为自定义节点”的核心机制。

3.5 插件架构

Lexical 采用组合式插件设计:

<LexicalComposer initialConfig={config}>
  {/* 核心编辑插件 */}
  <RichTextPlugin contentEditable={...} placeholder={...} />
  
  {/* 功能插件 */}
  <HistoryPlugin />           {/* 撤销/重做 */}
  <OnChangePlugin />          {/* 内容变化监听 */}
  <VariableLabelPlugin />     {/* 自定义:变量渲染 */}
  <VariableLabelPickerPlugin />{/* 自定义:变量选择 */}
</LexicalComposer>

插件通过 useLexicalComposerContext() 获取编辑器实例:

const MyPlugin = () => {
  const [editor] = useLexicalComposerContext();
  
  useEffect(() => {
    // 使用 editor 注册命令、转换等
  }, [editor]);
  
  return null; // 无 UI 的纯逻辑插件
};

4. 整体架构设计

4.1 架构图

Lexical 变量输入编辑器.png

4.2 组件职责划分

组件/模块 职责
PromptEditor 业务组件,连接 workflow store,处理多行提示词场景
VariableEditor 业务组件,处理单行变量输入场景
Editor 核心组件,封装 Lexical 编辑器和所有插件
VariableLabelNode 自定义节点,渲染为 React 组件,用于反显变量标签
VariableLabelPlugin 自定义插件,监听文本变化,将变量语法转换为变量标签
VariableLabelPickerPlugin 自定义插件,处理 / 触发和变量选择
SingleLinePlugin 自定义插件,限制单行输入

4.3 数据流及渲染过程

flowchart TD
Start([开始]) --> Input["用户输入 '/'"]
Input --> Detect["VariableLabelPickerPlugin 检测到 '/'"]
Detect --> Menu["弹出 VariableMenu 菜单"]
Menu --> Select["用户选择变量"]
Select --> Insert["插入文本 '{{#nodeId.varName#}}'"]
Insert --> Transform["VariableLabelPlugin 的 TextNode Transform 检测到变量语法"]
Transform --> CreateNode["创建 VariableLabelNode 替换文本"]
CreateNode --> Render["VariableLabelNode 渲染 VariableLabel 组件"]
Render --> Sync["OnChangePlugin 的 onChange 方法触发,同步文本内容到外部状态"]
Sync --> End([结束])

5. 核心实现详解

5.1 自定义 VariableLabelNode

这是整个方案的核心。我们通过继承 DecoratorNode 来创建一个可以渲染 React 组件的自定义节点:

export class VariableLabelNode extends DecoratorNode<JSX.Element> {
  __variableKey: string;      // 变量的完整标识,如 {{#nodeId.name#}}
  __variableLabel: string;    // 显示用的标签
  __isSystemVariable: boolean; // 是否为系统变量

  static getType(): string {
    return "variableLabel";
  }

  // 返回 React 组件作为节点的渲染内容
  decorate(): JSX.Element {
    return (
      <VariableLabel
        variableLabel={this.__variableLabel}
        isSystemVariable={this.__isSystemVariable}
      />
    );
  }
  // ... 其他方法
}

关键设计点:

  1. 继承 DecoratorNode:这使得节点可以渲染任意 React 组件
  2. **getTextContent()**:返回变量的原始格式文本,确保序列化时能正确还原
  3. **decorate()**:返回 VariableLabel 组件,实现可视化展示

5.2 触发器:/ 唤起变量选择菜单

当用户输入 / 时,我们需要弹出一个变量选择菜单。这里使用 Lexical 官方提供的 LexicalTypeaheadMenuPlugin

const VariableLabelPickerPlugin = ({ variableGroups }) => {
  const [editor] = useLexicalComposerContext();

  // 自定义触发匹配:检测用户输入 /
  const checkForTriggerMatch = useBasicTypeaheadTriggerMatch("/", {
    minLength: 0,
  });

  // 用户选择变量后的处理逻辑
  const onSelectOption = useCallback((selectedOption, nodeToRemove, closeMenu) => {
    editor.update(() => {
      // 删除触发字符 /
      if (nodeToRemove) nodeToRemove.remove();

      // 插入变量文本,格式为 {{#nodeId.variableName#}}
      selection.insertNodes([
        $createTextNode(`{{#${selectedOption.nodeId}.${selectedOption.name}#}}`),
      ]);
      closeMenu();
    });
  }, [editor]);
  // ...
};

工作流程:

  1. 用户输入 /checkForTriggerMatch 返回匹配结果
  2. 弹出 VariableMenu 组件,显示可用变量列表
  3. 用户点击选择 → onSelectOption 插入格式化的变量文本
  4. VariableLabelPlugin 监测到文本变化,自动转换为节点

注意这里我们并不直接插入 VariableLabelNode,而是插入格式化的文本字符串。这是为了解耦选择逻辑和渲染逻辑——文本到节点的转换由下一个插件统一处理。

5.3 文本实体识别与自动转换

VariableLabelPlugin 负责监听文本变化,当发现符合变量格式的文本时,自动将其转换为 VariableLabelNode

const VariableLabelPlugin = () => {
  const [editor] = useLexicalComposerContext();

  // 创建变量节点的工厂函数
  const createVariableLabelPlugin = useCallback((textNode: TextNode) => {
    const text = textNode.getTextContent();
    const info = parseVariableTokenInfo(text);
    return $createVariableLabelNode(
      text,
      info?.variableName ?? "",
      info?.isSystemVariable ?? false,
    );
  }, []);

  useEffect(() => {
    // 注册文本实体转换器
    registerLexicalTextEntity(
      editor,
      getVariableMatchInText,  // 正则匹配函数
      VariableLabelNode,
      createVariableLabelPlugin,
    );
  }, [editor]);
  // ...
};

变量格式通过正则表达式定义:

// 用户变量格式:{{#uuid.variableName#}}
export const USER_VARIABLE_REGEX = new RegExp(
  "(\\{\\{)(#)([a-fA-F0-9-]{36}\\.[a-zA-Z0-9_]+)(#)(\\}\\})",
);

// 系统变量格式:{{#system.xxx#}}
export const SYSTEM_VARIABLE_REGEX = new RegExp(
  "(\\{\\{)(#)(system\\.[a-zA-Z0-9_]+)(#)(\\}\\})",
);

registerLexicalTextEntity 是核心的转换逻辑,它注册了两个 Transform:

export function registerLexicalTextEntity(editor, getMatch, targetNode, createNode) {
  // 1. TextNode → VariableLabelNode 的转换
  const textNodeTransform = (node: TextNode) => {
    const text = node.getTextContent();
    const match = getMatch(text);
    if (match === null) return;

    // 分割文本节点,将匹配部分替换为目标节点
    const [nodeToReplace, remainingNode] = node.splitText(match.start, match.end);
    const replacementNode = createNode(nodeToReplace);
    nodeToReplace.replace(replacementNode);

    // 递归处理剩余文本(可能包含多个变量)
    if (remainingNode) textNodeTransform(remainingNode);
  };

  // 2. 反向转换:当节点内容不再匹配时还原为文本
  const reverseNodeTransform = (node) => {
    const match = getMatch(node.getTextContent());
    if (match === null) {
      replaceWithSimpleText(node);  // 还原为普通文本
    }
  };

  return [
    editor.registerNodeTransform(TextNode, textNodeTransform),
    editor.registerNodeTransform(targetNode, reverseNodeTransform),
  ];
}

5.4 变量标签的可视化渲染

VariableLabel 组件负责将变量以友好的方式呈现给用户:

const VariableLabel = ({ variableLabel, isSystemVariable }) => {
  const { Icon, nodeLabel, displayLabel } = useVariableLabelInfo(
    variableLabel,
    isSystemVariable,
  );

  return (
    <div className="inline-flex items-center rounded-sm bg-bg-primary-4 px-[2px]">
      <Icon className="flex-shrink-0" />
      <span className="text-text-2-icon">{nodeLabel}</span>
      <span className="text-text-4-description">/</span>
      <span className="text-primary-default">{displayLabel}</span>
    </div>
  );
};

会渲染一个可视化变量标签,包含节点图标、节点名称和变量名,效果如下:

5.5 编辑器单行模式

在某些场景(如 HTTP 节点的 URL 输入、条件节点的表达式输入),我们需要限制编辑器为单行模式:

const SingleLinePlugin = ({ onEnter }) => {
  const [editor] = useLexicalComposerContext();

  useEffect(() => {
    mergeRegister(
      // 1. 限制 RootNode 只保留一个段落
      editor.registerNodeTransform(RootNode, (rootNode) => {
        if (rootNode.getChildrenSize() <= 1) return;

        const children = rootNode.getChildren();
        const firstChild = children[0];
        // 将后续段落的内容合并到第一个段落
        for (let i = 1; i < children.length; i++) {
          const paragraph = children[i];
          paragraph.getChildren().forEach(child => firstChild.append(child));
          paragraph.remove();
        }
      }),

      // 2. 拦截 Enter 键
      editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
        event?.preventDefault();
        onEnter?.();  // 可以触发外部回调,如提交表单
        return true;
      }, COMMAND_PRIORITY_HIGH),
    );
  }, [editor, onEnter]);

  return null;
};

这个插件通过两种机制实现单行限制:

  1. RootNode Transform:当检测到多个段落时,自动合并为一个
  2. Command 拦截:阻止 Enter 键创建新段落

5.6 编辑器状态初始化与同步

编辑器内容需要与后端数据同步,我们采用纯文本格式存储。

编辑器状态初始化:

export const textToEditorState = (text = "") => {
  const lines = text.split("\n");
  const paragraph = lines.map((p) => ({
    children: [{ text: p, type: "text", ... }],
    type: "paragraph",
    //...
  }));

  return JSON.stringify({
    root: { children: paragraph, type: "root", ... },
  });
};

编辑器状态同步:

const handleEditorChange = (editorState: EditorState) => {
  const text = editorState.read(() => {
    return $getRoot()
      .getChildren()
      .map((p) => p.getTextContent())
      .join("\n");
  });
  onChange(text);
};

由于 VariableLabelNode.getTextContent() 返回原始变量格式({{#nodeId.name#}}),导出的文本可以直接存储,再次加载时会自动转换回节点形式。

6. 总结

本文介绍了基于 Lexical 实现工作流变量输入编辑器的完整方案:

  1. VariableLabelNode:继承 DecoratorNode 实现渲染自定义变量标签节点
  2. VariableLabelPickerPlugin:使用 LexicalTypeaheadMenuPlugin 实现 / 触发展示变量选择菜单
  3. VariableLabelPlugin:通过 Transform 自动识别和转换变量文本
  4. SingleLinePlugin:可选的单行模式支持
  5. 插件化架构:功能解耦,各插件职责单一,方便维护和扩展

这套方案适用于:

  • 工作流中的变量引用
  • 类似评论区的 Mention 功能
  • 模板引擎的可视化编辑
  • 任何需要“触发字符 + 选择菜单 + 自定义渲染”的场景

最后

欢迎关注【袋鼠云数栈 UED 团队】~ 袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

从零构建一个现代登录页:深入解析 Tailwind CSS + Vite + Lucide React 的完整技术栈

引言

在当今前端开发的快节奏世界中,开发者们不再满足于“能用”的界面,而是追求高效、美观、可维护且体验流畅的 UI。而要实现这一目标,一套现代化的技术组合至关重要。

本文将带你从零开始,使用 ViteTailwind CSSLucide React 构建一个专业级的登录页面,并对每一行代码、每一个 Tailwind 工具类进行逐层拆解与深度解析。我们将不仅告诉你“怎么写”,更要解释“为什么这样写”、“背后原理是什么”、“如何举一反三”。

📌 核心目标:让你彻底掌握 Tailwind CSS 的思维方式,理解现代 React 应用的工程结构,并能独立构建高保真、响应式、交互丰富的用户界面。


第一部分:技术选型 —— 为什么是 Vite + Tailwind + Lucide?

Vite:下一代前端构建工具

Vite 由 Vue.js 作者尤雨溪打造,利用原生 ES 模块(ESM)和浏览器对 import 的原生支持,实现了闪电般的冷启动速度毫秒级热更新。它摒弃了传统打包器(如 Webpack)在开发时“先打包再运行”的模式,转而采用“按需编译”,极大提升了开发体验。

对于新项目,官方推荐使用:

npm create vite@latest my-project -- --template react

Tailwind CSS:原子化 CSS 的革命者

Tailwind CSS 不是一个组件库,而是一个 Utility-First(实用优先) 的 CSS 框架。它提供数千个低层级的 CSS 类(如 p-4text-centerbg-blue-500),让你直接在 HTML/JSX 中组合出任意设计。

💡 关键理念“你不需要写一行自定义 CSS,就能构建完全定制化的 UI。”

优势包括:

  • 开发速度极快:所见即所得,无需切换文件。
  • 天然响应式md:p-10 这样的前缀让适配屏幕轻而易举。
  • 自动 Purge(Tree-shaking) :只打包你实际使用的类,生产包体积极小。
  • 主题一致性:所有颜色、间距、圆角都来自同一套设计系统(Design Token)。

Lucide React:轻量、类型安全的 SVG 图标库

LucideFeather Icons 的社区驱动继任者,提供超过 1000 个精心设计的开源图标。其 React 版本 lucide-react 具备以下优点:

  • 每个图标都是独立的 React 组件,支持 TypeScript。
  • 完全 tree-shakable:只打包你导入的图标。
  • 高度可定制:通过 sizecolorstrokeWidth 等 props 控制外观。
  • 渲染为内联 SVG:无额外 HTTP 请求,性能优异。

安装命令:

pnpm add lucide-react

第二部分:工程搭建 —— 零配置集成 Tailwind 到 Vite

根据 Tailwind 官方 Vite 安装指南,我们只需四步:

步骤 1:创建 Vite 项目(如果尚未创建)

npm create vite@latest tailwindcss-login -- --template react
cd tailwindcss-login

步骤 2:安装依赖

npm install tailwindcss @tailwindcss/vite

⚠️ 注意:这里使用的是 @tailwindcss/vite 插件,这是 Tailwind v4 推出的新方式,无需 PostCSS 配置,简化了集成流程。

步骤 3:配置 Vite

编辑 vite.config.ts(或 .js):

import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

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

步骤 4:引入 Tailwind CSS

在你的主样式文件(如 src/index.css)中添加:

@import "tailwindcss";

然后在 main.jsxApp.jsx 中确保该 CSS 被引入。

步骤 5:启动开发服务器

npm run dev

✅ 恭喜!你现在可以在任何组件中自由使用 Tailwind 的所有工具类了。


第三部分:业务逻辑 —— React 状态与受控组件

在 React 中,表单的最佳实践是使用 受控组件(Controlled Components) —— 即表单元素的值由 React 的 state 驱动,而非 DOM 自己管理。这确保了 UI 与数据状态始终保持同步。

核心状态定义

const [formData, setFormData] = useState({
  email: '',
  password: '',
  rememberMe: false
})
  • emailpassword 是字符串,用于文本输入框。
  • rememberMe 是布尔值,用于复选框。

通用事件处理器

const handleChange = (e) => {
  const { name, value, type, checked } = e.target;
  setFormData((prev) => ({
    ...prev,
    [name]: type === "checkbox" ? checked : value
  }));
}
  • 使用 计算属性名 [name] 动态更新对应字段。
  • 区分 input(取 value)和 checkbox(取 checked)。

密码可见性切换

const [showPassword, setShowPassword] = useState(false);
// 在 input 的 type 中动态切换
type={showPassword ? "text" : "password"}

加载状态(预留)

const [isLoading, setIsLoading] = useState(false);

虽然当前 handleSubmit 是空的,但未来可在此处调用 API,并设置 setIsLoading(true) 来禁用按钮、显示 loading 动画等。


第四部分:深度解析 —— Tailwind 工具类全解

项目源码链接:react/tailwindcss-login/src/App.jsx · Zou/lesson_zp - 码云 - 开源中国

接下来,我们将逐层、逐类、逐像素地解析这个 UI 的构建逻辑。

1. 页面容器:撑满屏幕并居中

<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
类名 含义 技术细节
min-h-screen 最小高度 = 100vh 确保即使内容很少,页面也占满整个视口,避免“短页面”出现空白。
bg-slate-50 背景为浅灰蓝 slate-50 是 Tailwind 默认调色板中最浅的中性色,柔和不刺眼。
flex 启用 Flexbox 布局 现代布局的基石。
items-center 交叉轴(垂直)居中 子元素在垂直方向上居中。
justify-center 主轴(水平)居中 子元素在水平方向上居中。
p-4 内边距 1rem (16px) 为移动端提供安全边距,防止内容贴边。

📏 单位说明:Tailwind 的默认间距单位基于 0.25rem(4px)。所以 p-4 = 4 * 4px = 16px


2. 登录卡片:视觉焦点与层次感

<div className="relative z-10 w-full max-w-md bg-white rounded-3xl shadow-xl shadow-slate-200/60 border border-slate-100 p-8 md:p-10">
类名 含义 技术细节
relative z-10 相对定位 + 层级提升 为内部绝对定位元素建立上下文;z-10 确保卡片在背景之上(虽非必需,但良好习惯)。
w-full 宽度 100% 占满父容器(即 p-4 后的可用宽度)。
max-w-md 最大宽度 28rem (448px) 在大屏设备上限制宽度,避免文字行长过长影响阅读。
bg-white 纯白背景 bg-slate-50 形成对比,突出内容区域。
rounded-3xl 圆角 1.5rem (24px) 超大圆角,营造现代、友好的感觉。
shadow-xl 大阴影 对应 CSS: box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);
shadow-slate-200/60 阴影颜色 + 透明度 将默认黑色阴影替换为 slate-200 并设 60% 透明度,更柔和自然。
border border-slate-100 1px 边框 slate-100 几乎是白色,在浅背景下提供微妙分隔线。
p-8 md:p-10 内边距响应式 手机: 2rem (32px);中屏及以上: 2.5rem (40px),提升桌面体验。

🌐 响应式前缀md: 表示“中等屏幕及以上”(默认断点 ≥768px)。Tailwind 采用 Mobile First 策略,所有类默认作用于最小屏幕,更大屏幕通过前缀覆盖。


3. 顶部图标与标题

<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-600 text-white mb-4 shadow-lg shadow-indigo-200">
  <Lock size={24}/>
</div>
类名 含义
inline-flex 行内 Flex 容器
w-12 h-12 3rem × 3rem (48px × 48px)
rounded-xl 圆角 0.75rem (12px)
bg-indigo-600 品牌主色背景
text-white 白色文字/图标
shadow-lg shadow-indigo-200 发光效果

标题文字使用 text-slate-900(接近黑)和 text-slate-500(中灰),形成清晰的视觉层次。


4. 表单结构:间距与分组

<form className='space-y-6'>
  <div className="space-y-2">...</div>
</form>
  • space-y-6子元素之间垂直间距 1.5rem (24px)。这是 Tailwind 的 “间距组” 功能,避免手动写 margin-top
  • space-y-2:label 与 input 之间间距 0.5rem (8px)。

💡 原理space-y-N 会为除第一个子元素外的所有子元素添加 margin-top: N * 0.25rem


5. 输入框布局:绝对定位与交互反馈

每个输入框都被包裹在 relative group 中:

<div className="relative group">
  <!-- 左侧图标 -->
  <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-indigo-600 transition-colors">
    <Mail size={18} />
  </div>
  <!-- 输入框 -->
  <input className="block w-full pl-11 pr-4 py-3 ..." />
</div>

定位系统

  • relative:为内部 absolute 元素建立定位上下文。
  • absolute inset-y-0 left-0:图标容器垂直拉满(top: 0; bottom: 0),贴左对齐。
  • pl-4:图标容器内部左填充 1rem (16px),控制图标与边界的距离。
  • pl-11(输入框):左填充 2.75rem (44px),为图标预留空间(图标约 18px + pl-4 ≈ 34px,留有余量)。

交互状态

  • pointer-events-none:禁止图标接收鼠标事件,避免点击图标时无法聚焦 input。
  • group-focus-within:text-indigo-600:当 .group 内任意子元素(如 input)获得焦点时,图标颜色变为品牌色。这是实现“聚焦高亮”的关键。
  • transition-colors:颜色变化时添加平滑过渡(默认 150ms ease)。

输入框自身样式

className="block w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600 transition-all"
类名 作用
block 块级元素,独占一行
w-full 宽度 100%
py-3 上下内边距 0.75rem (12px),增大点击区域
pr-4 右内边距,为密码切换按钮留空间
bg-slate-50 浅灰背景,区别于白色卡片
border border-slate-200 极浅灰色边框
rounded-xl 12px 圆角
text-slate-900 深色文字,保证可读性
placeholder:text-slate-400 placeholder 文字为浅灰色(注意:这不是伪类,而是对 ::placeholder 的封装)
focus:outline-none 移除浏览器默认蓝色轮廓
focus:ring-2 添加 2px 宽的“环形阴影”(位于边框外)
focus:ring-indigo-600/20 ring 颜色为品牌色 + 20% 透明度,柔和高亮
focus:border-indigo-600 边框变品牌色,明确指示当前字段
transition-all 所有可变属性(颜色、边框、阴影)都启用过渡动画

🎯 伪类前缀:Tailwind 使用 hover:focus:group-focus-within: 等前缀来模拟 CSS 伪类。例如 focus:border-indigo-600 编译为:

.focus:border-indigo-600:focus {
  border-color: #4f46e5;
}

👁️ 6. 密码可见性切换

<button
  type="button"
  onClick={() => setShowPassword(!showPassword)}
  className="absolute inset-y-0 right-0 pr-4 flex items-center text-slate-400 hover:text-slate-600 transition-colors"
>
  {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
  • absolute inset-y-0 right-0:按钮垂直拉满,贴右对齐。
  • pr-4:内部右填充,控制图标与右边界的距离。
  • hover:text-slate-600:悬停时颜色变深,提示可点击。
  • 使用 EyeEyeOff 图标动态切换,直观表达状态。

7. “忘记密码?”链接

<a href="#" className="text-sm font-medium text-indigo-600 hover:text-indigo-500 transition-colors">
  忘记密码?
</a>
  • 使用品牌色 text-indigo-600 引导用户操作。
  • hover:text-indigo-500 提供悬停反馈。
  • ml-1(在父容器)微调左外边距,使对齐更精确。

第六部分:特别说明 —— 关于 placeholder 和伪类

虽然代码中使用了:

placeholder:text-slate-400

但这不是伪类,而是 Tailwind 对 ::placeholder 伪元素的直接封装。

真正的伪类组合示例(虽未使用):

focus:placeholder:text-indigo-500

表示“当 input 聚焦时,placeholder 文字变为 indigo-500”。

📚 伪类 vs 伪元素

  • 伪类:hover, :focus):描述元素的状态。
  • 伪元素::before, ::placeholder):创建不在文档中的虚拟元素。

Tailwind 对两者都提供了前缀支持,但语法略有不同。


第七部分:总结与展望

通过这个登录页,我们不仅实现了一个美观、响应式的 UI,更重要的是掌握了:

  1. 现代前端工程化流程:Vite + Tailwind 的零配置集成。
  2. 原子化 CSS 思维方式:用组合代替继承,用工具类代替手写 CSS。
  3. React 状态管理最佳实践:受控组件、通用事件处理。
  4. 高级布局技巧:Flexbox 居中、绝对定位嵌套、间距组。
  5. 交互细节打磨:聚焦高亮、悬停反馈、过渡动画、品牌色贯穿。
  6. 第三方库集成:Lucide React 的按需引入与定制。

下一步你可以做什么?

  • 添加表单验证:使用 react-hook-form + zod
  • 实现加载状态:在 handleSubmit 中设置 isLoading,并禁用按钮。
  • 抽象 Input 组件:将带图标的 input 封装为可复用组件。
  • 主题切换:利用 Tailwind 的 dark: 前缀实现暗色模式。
  • 国际化:使用 react-i18next 支持多语言。

结语

前端开发不再是“切图 + 写 CSS”的体力活,而是一门融合工程、设计与用户体验的艺术。Tailwind CSS 让你从繁琐的样式命名和调试中解放出来,专注于构建真正有价值的用户界面

正如 Tailwind 官方所说:

“You aren’t limited to the design you started with — you can customize everything.”

而今天,你已经迈出了第一步。

Happy coding! 🚀

JS执行机制、作用域及作用域链

要了解js的执行机制,那么首先需要明白执行上下文、执行栈以及作用域和作用域链的概念。

执行上下文

执行上下文(Execution Context),缩写为EC。js代码在执行之前需要做一些准备,类比我们在上课之前需要在教室中准备好粉笔,黑板课桌等等。js代码在执行之前也需要做一些准备,给代码的执行创建一个环境 —— 执行上下文。

执行上下文的类型

  • 全局执行上下文:js代码在在执行前首先会创建一个全局执行上下文,并且一个程序只有一个。
  • 函数执行上下文:当一个函数被调用时,就会为该函数创建一个新的执行上下文,函数执行上下文可以有多个。
  • eval函数执行上下文

现在了解了执行上下文,但是js代码在执行过程中怎么去找到不同的执行上下文呢?那么就需要一个存放这些不同执行上下文的地方 —— 执行栈。执行栈,顾名思义是一个栈的数据结构,有先入后出的特点。执行栈栈顶的执行上下文就是当前的执行上下文

执行栈.png

现在两个重要的概念都了解之后,我们学习一下执行上下文到底是什么,它运作的过程是什么样的?

执行上下文的结构

执行上下文结构.png

可以看到执行上下文中主要包含四个部分:

  1. 变量环境用来存储所有的var声明的变量以及函数声明;
  2. 词法环境用来存储let/const/class声明的变量和全局对象;
  3. this绑定对于全局执行上下文,如果是浏览器绑定的是window,如果是node.js指向的就是global,函数执行上下文中的this指向取决于函数的调用方法;
  4. 外部引用其实就是作用域链,全局执行上下文引用为null,函数执行上下文指向函数定义时的词法环境

了解了执行上下文的结构,我们来用一个小的代码片段说明一下执行上下文的创建和执行。

创建和执行步骤

  1. 创建全局执行上下文,并加入执行栈顶
  2. 分析:
    • 找到所有的非函数中的var声明,在变量环境中创建绑定
    • 找到所有的顶级函数声明,在变量环境中创建绑定
    • 找到顶级let、const、class声明,在词法环境中创建绑定
    • 块级作用域中的变量声明(let/const)创建新的词法环境放入其中,函数声明特殊,类似于let
  3. 变量名重复处理:let const class声明的变量名不能重复,他们与var function的名字也不能重复;若varfunction 名字重复,function声明的函数名优先
  4. 创建绑定:
    • 变量环境绑定:var初始化为undefined,函数初始化为函数对象,并且会把函数定义时的词法环境保存到函数对象中。
    • 词法环境绑定:let/const/class 创建但未初始化 —— 暂时性死区
  5. 执行语句
var a = 10
function foo(){
  console.log(a)
  let a
}
foo()

以上面的代码块为例

  • 创建阶段,创建全局的执行上下文,在变量环境中存放a变量,并初始化为undefined,存放函数名为foo,初始化为函数对象,并且保存函数定义时的词法环境(全局执行上下文的词法环境)。
  • 执行阶段,变量a赋值为10,foo函数被调用,会创建一个foo函数的执行上下文压入栈顶,这个执行上下文的词法环境的outer会指向foo函数初始化时保存在体内的那个词法环境,也就是指向函数定义时的执行上下文的词法环境(全局执行上下文的词法环境)。
    • 函数foo中又继续上述的创建步骤,在词法环境中存放变量a,未初始化。
    • 继续执行,console.log(a),当前的执行上下文就是foo函数的执行上下文,所以会在foo函数的执行上下文中去查找变量a,找到为未初始化的状态,所以最终会报错。

执行过程.png

到这里就说完了这个简单代码块的执行上下文的创建和执行,那么会不会有一个疑问,我在上面的描述中着重强调函数执行上下文的词法环境的一个指向,但是似乎也没有起到什么作用。

那么如果把函数foo中的let声明去掉我们就可以看出这个指向的用处了!如果去掉的话,函数执行上下文中就找不到这个变量a,那么就会沿着outer指向找到父级的执行上下文查看其中是否有变量a,最后会输出10。

这也就引出了作用域链

作用域

那么在介绍作用域链之前,我们先了解什么是作用域

作用域是解析(查找)变量名的一个集合,规定了变量和函数的可访问范围,也就是它定义了在哪里可以访问什么变量。作用域就类比规则,当前执行上下文的词法环境就类比实现规则的数据结构。

作用域的类型

  • 全局作用域:在代码任何地方都可访问的作用域,对应全局执行上下文
  • 函数作用域:函数内部创建的作用域,只在函数内部可访问。
  • 块级作用域:ES6 引入 letconst后新增的作用域,由 {}代码块创建。

我们再举一个例子来说明

function foo(){
    console.log(a)
}

funtion bar(){
    var a = 3
    foo()
}
var a = 2
bar()

以上的代码最终会输出2。

  • 创建阶段,将函数foo、函数bar以及变量a存放在全局执行上下文的变量环境中,并且初始化。
  • 执行阶段,首先给全局执行上下文中的a赋值2,遇到bar(),创建一个新的bar函数执行上下文,将该执行上下文的outer指向全局执行上下文的词法环境,然后运行函数内部的代码。
    • 创建阶段,函数内部的变量a存放在bar函数执行上下文的词法环境中。
    • 执行阶段,变量a赋值为3,调用foo函数。这时候会继续创建一个foo函数的执行上下文,并且它的outer指向也是全局执行上下文的词法环境,然后foo函数中就一句代码,输出a,就会先去当前执行上下文中查找有没有变量a,发现没有;那么!就会沿着outer指向继续往父级查找到全局执行上下文,发现有变量a,值为2。

作用域链体现.png

这就是作用域链!经过以上的分析,我们也发现函数的作用域是函数定义时的作用域决定的,和函数调用时的作用域没有关系,否则输出就应该是3。

现在对于函数作用域有了一定的了解后,我们继续看块级作用域。

遇到块级作用域,也有以下几个步骤:

  1. 创建新的记录环境(词法环境),连接在原来记录之前
  2. 分析:
    • 所有的顶级函数声明
    • let/const声明
  3. 名字重复处理
  4. 创建绑定:
    • 登记function,初始化为函数对象
    • 登记let const,未初始化
  5. 执行语句

细心的同学肯定已经发现,遇到块级作用域{},我们的处理方式其实之前类似,但是不会创建新的执行上下文了,而是改成了创建新的一个记录指向原来的记录。

用以下代码块来说明:

let inIf = 'out of statement'

if(true){
    let inIf = 'in of statement'
    console.log(inIf)
}

console.log(inIf)

最后会先输出in of statement,再输出 out of statement

块级作用域.png 如上图,执行完块级作用域后,这个记录就会销毁,然后把原先的记录重新接回。

那么我们再看下面这个例子,体会闭包的作用

var liList = []

for(var i = 0; i < 5; i++){
    liList[i] = function(){
        console.log(i)
     }
}

liList[0]()
liList[1]()
liList[2]()
liList[3]()
liList[4]()

以上代码我们执行会发现,五个函数调用后输出的结果都是5。这是因为var没有块级作用域,会直接把变量i存放在全局执行上下文中,后面块级作用域中定义的所有函数的environment属性都指向全局执行上下文的词法环境,所以循环结束 i为5,每个函数调用创建的新的函数执行上下文中的outer都指向全局执行上下文,也会去全局中找i输出都为5。

将上述代码改为:

var liList = []

for(let i = 0; i < 5; i++){
    liList[i] = function(){
        console.log(i)
     }
}

liList[0]()
liList[1]()
liList[2]()
liList[3]()
liList[4]()

就可以利用闭包,让调用输出的i不再是共享的值。因为let声明创建了块级作用域,每次循环都会创建一个新的词法环境存储变量i,定义的五个函数的enviroment属性也会指向不同的块级作用域。最后调用沿着作用域链找到的也是不同的i值。

闭包

这个例子清晰地展示了闭包的核心机制:函数能够记住并访问其定义时所处的词法作用域,即使该函数在其词法作用域之外被调用。在上面的例子中,每个函数都通过闭包"记住"了定义时所在的块级作用域(及其中的 i值)。

闭包的主要用途

  • 数据封装与私有变量
  • 函数工厂与柯里化
  • 事件处理与异步编程

闭包的注意事项

  • 内存泄漏风险
  • 性能考虑

为什么现在不推荐使用 React.FC 了?

在 React + TypeScript 项目中,React.FC(即 React.FunctionComponent 的别名)曾经是定义函数组件的常见方式,但如今社区普遍推荐避免使用它,转而直接注解组件的 props 类型。下面我一步步解释原因和演变过程。

历史背景和早期问题

早期(React 17 及更早版本结合旧 TypeScript 时),React.FC 被广泛使用,因为它提供了一种简便的类型注解方式,能自动推断一些静态属性如 displayNamepropTypesdefaultProps。然而,它存在几个显著缺点:

  • 隐式 children propReact.FC 会自动将 children 添加到 props 类型中,即使组件不需要它。这会导致类型不准确,例如如果你定义一个不接受 children 的组件,用户仍能传入 children 而不会报错。
  • 泛型支持不佳:使用 React.FC 时,泛型组件的类型定义变得复杂且不易读。
  • defaultProps 问题:与 defaultProps 结合时,可能出现类型不兼容或推断错误。
  • 返回类型限制React.FC 强制返回 React.ReactElement,不允许直接返回 nullstringnumber 或其他类型,这在某些场景下不灵活。
  • 函数类型应用困难React.FC 本质上是函数类型,难以直接应用于命名函数,导致代码更冗长。

这些问题导致社区(如 Create React App 在 2020 年移除对 React.FC 的默认使用)开始转向其他方式。 许多知名开发者、TypeScript 贡献者和框架(如 Backstage)也公开不推荐它。

最近变化(React 18 和 TypeScript 5.1 后)

从 React 18(2022 年发布)开始,React.FC 的许多问题被修复:

  • 移除了隐式 children prop,现在只有显式定义时才会存在。
  • 支持更多返回类型,不再严格限制为 React.ReactElement

TypeScript 5.1(2023 年)进一步优化了这些,使得 React.FC 在技术上“可用”且不再“危险”。如果你已经在代码中使用它,也没有必要急于移除。

为什么现在还是不推荐使用?

尽管修复了早期缺陷,社区共识(包括 2025 年的最新讨论)仍建议避免 React.FC,因为它不再是最佳实践。理由包括:

  • 更简单的语法和更少 boilerplate:直接注解 props 更简洁、直观,符合纯 TypeScript 风格,而 React.FC 引入了不必要的框架特定类型。
  • 更好的灵活性:支持泛型、复合组件和“组件作为命名空间”的模式,而不需额外处理。
  • 学习曲线和可读性:新手可能觉得 React.FC 容易上手,但长期看,直接类型定义更易维护和阅读。
  • 现代工具支持:像 ESLint 规则和 codemod 可以自动禁止或替换 React.FC
  • 无额外价值React.FC 提供的自动推断(如静态属性)在现代 TypeScript 中已无需它就能实现。

总体上,使用 React.FC 不会出错,但它被视为过时,类似于一些旧的 React 模式。 如果你的项目是新启动的,或在重构时,建议切换到更现代的写法。

推荐的替代方式

直接定义函数组件,并注解 props 类型。示例:

// 旧方式(不推荐)
const MyComponent: React.FC<{ name: string }> = ({ name }) => <div>Hello, {name}</div>;

// 新方式(推荐)
function MyComponent({ name }: { name: string }) {
  return <div>Hello, {name}</div>;
}

// 或箭头函数
const MyComponent = ({ name }: { name: string }) => <div>Hello, {name}</div>;

// 如果需要 children 或其他复杂类型
type Props = {
  name: string;
  children?: React.ReactNode; // 显式添加,如果需要
};
function MyComponent({ name, children }: Props) {
  return <div>{name}{children}</div>;
}

这种方式更贴近 JavaScript 原生语法,同时充分利用 TypeScript 的类型推断。

深入理解 Vue 依赖收集:从源码角度拆解响应式的核心

在 Vue 的响应式系统中,“依赖收集” 是贯穿整个数据驱动视图的核心环节。很多开发者日常使用datacomputedwatch时只知其然,却不知其所以然 —— 为什么修改数据视图会自动更新?为什么computed能精准缓存?这一切的背后,都是依赖收集机制在起作用。

本文将从 Vue 2 的源码出发,层层拆解依赖收集的完整流程,带你从 “使用层” 走向 “原理层”,真正理解 Vue 响应式的底层逻辑。

一、先搞懂:什么是 “依赖”?

在开始源码分析前,我们先明确核心概念:

  • 依赖:本质上是 “使用了某个响应式数据的执行函数”,比如渲染组件的render函数、computed的计算函数、watch的回调函数。
  • 依赖收集:在响应式数据被读取时,记录下 “哪些函数依赖了这个数据”;当数据被修改时,找到这些记录的函数并执行,最终实现 “数据变 → 视图更”。

简单来说,依赖收集的核心目标是:建立 “响应式数据” 与 “使用数据的函数” 之间的映射关系

二、核心角色:依赖收集的 3 个关键模块

Vue 2 的依赖收集主要依赖三个核心模块,我们先认识它们:

表格

模块 作用 核心源码位置
Observer 将普通对象 / 数组转为响应式(给属性添加 get/set) src/core/observer/index.js
Dep 依赖管理器:存储某个响应式数据的所有依赖 src/core/observer/dep.js
Watcher 依赖的载体:封装需要执行的函数(如 render、computed) src/core/observer/watcher.js

三者的关系可以总结为:

Observer 给数据加 get/set 钩子 → 读取数据时触发 get,通过Dep收集Watcher → 修改数据时触发 set,通过Dep通知所有Watcher执行。

三、源码拆解:依赖收集的完整流程

3.1 第一步:响应式数据的初始化(Observer)

首先,Vue 会通过Observer类将data中的数据转为响应式,核心是给每个属性定义getter/setter

核心源码(简化版):

javascript

运行

// src/core/observer/index.js
class Observer {
  constructor(value) {
    this.value = value
    this.dep = new Dep() // 给对象/数组本身创建Dep
    def(value, '__ob__', this) // 给数据添加__ob__属性,标记为响应式
    
    if (Array.isArray(value)) {
      // 处理数组的响应式(重写push/pop等方法)
      this.observeArray(value)
    } else {
      // 处理对象的响应式:遍历属性并定义get/set
      this.walk(value)
    }
  }

  // 遍历对象属性,定义响应式
  walk(obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  // 遍历数组,给每个元素做响应式处理
  observeArray(items) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

// 核心:给单个属性定义get/set
export function defineReactive(
  obj,
  key,
  val,
  customSetter,
  shallow
) {
  // 每个响应式属性都有一个专属的Dep实例
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // 保留原有的get/set
  const getter = property && property.get
  const setter = property && property.set

  // 递归处理子属性,保证深层数据也是响应式
  let childOb = !shallow && observe(val)

  // 定义新的getter/setter
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // 读取属性时触发:依赖收集的入口
    get: function reactiveGetter() {
      const value = getter ? getter.call(obj) : val
      
      // 关键:如果当前有活跃的Watcher,就收集依赖
      if (Dep.target) {
        dep.depend() // 1. 让Dep记录当前Watcher
        if (childOb) {
          // 2. 给对象/数组本身也收集依赖(处理数组/对象整体变更)
          childOb.dep.depend()
          if (Array.isArray(value)) {
            // 3. 数组特殊处理:遍历子元素收集依赖
            dependArray(value)
          }
        }
      }
      return value
    },
    // 修改属性时触发:通知依赖更新
    set: function reactiveSetter(newVal) {
      const value = getter ? getter.call(obj) : val
      // 新旧值相同则不处理
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 新值也要做响应式处理
      childOb = !shallow && observe(newVal)
      // 关键:通知所有依赖更新
      dep.notify()
    }
  })
}

核心要点

  • 每个响应式属性都会创建一个Dep实例,专属管理该属性的依赖;
  • getter中触发依赖收集,setter中触发依赖更新;
  • 不仅处理单个属性,还会递归处理子对象 / 数组,保证深层响应式。

3.2 第二步:依赖管理器(Dep)

Dep是依赖的 “容器”,核心作用是存储和管理某个数据的所有Watcher,提供depend(收集)和notify(通知)两个核心方法。

核心源码(简化版):

javascript

运行

// src/core/observer/dep.js
export default class Dep {
  static target: ?Watcher; // 静态属性,存储当前活跃的Watcher
  id: number; // 唯一标识
  subs: Array<Watcher>; // 存储依赖的Watcher数组

  constructor() {
    this.id = uid++
    this.subs = []
  }

  // 添加一个Watcher到依赖列表
  addSub(sub: Watcher) {
    this.subs.push(sub)
  }

  // 移除一个Watcher
  removeSub(sub: Watcher) {
    remove(this.subs, sub)
  }

  // 核心:收集依赖(让Dep和Watcher互相记录)
  depend() {
    if (Dep.target) {
      // 调用当前Watcher的addDep方法,双向绑定
      Dep.target.addDep(this)
    }
  }

  // 核心:通知所有Watcher更新
  notify() {
    // 复制一份依赖列表,避免更新过程中列表变化
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      // 调用Watcher的update方法
      subs[i].update()
    }
  }
}

// 全局唯一的Dep.target栈(处理嵌套Watcher,比如computed嵌套)
Dep.target = null
const targetStack = []

// 入栈:设置当前活跃的Watcher
export function pushTarget(target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

// 出栈:恢复上一个Watcher
export function popTarget() {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

核心要点

  • Dep.target是全局唯一的,始终指向 “当前正在执行的 Watcher”;
  • depend()方法不是直接添加 Watcher,而是调用Watcher.addDep(),实现 Dep 和 Watcher 的双向记录(避免重复收集);
  • 用栈结构targetStack处理嵌套场景(比如组件嵌套、computed 嵌套)。

3.3 第三步:依赖载体(Watcher)

Watcher是 “依赖” 的具体载体,每个 Watcher 对应一个需要执行的函数(比如组件的render函数、computed的计算函数)。

核心源码(简化版):

javascript

运行

// src/core/observer/watcher.js
export default class Watcher {
  constructor(
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)
    
    // 处理配置项(比如lazy、deep、sync)
    if (options) {
      this.lazy = !!options.lazy // computed用
      this.deep = !!options.deep // 深度监听用
      this.sync = !!options.sync // 同步更新用
    } else {
      this.lazy = this.deep = this.sync = false
    }
    
    this.cb = cb // 更新回调
    this.id = uid++ // 唯一标识
    this.deps = [] // 存储当前Watcher依赖的Dep
    this.newDeps = [] // 临时存储新依赖(用于依赖清理)
    this.depIds = new Set() // 去重
    this.newDepIds = new Set()
    
    // 解析表达式/函数,得到最终要执行的函数
    this.getter = typeof expOrFn === 'function' ? expOrFn : parsePath(expOrFn)
    
    // 非lazy模式(比如render、watch)立即执行get,触发依赖收集
    this.value = this.lazy ? undefined : this.get()
  }

  // 核心:执行getter并收集依赖
  get() {
    // 1. 将当前Watcher入栈,设置为Dep.target
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      // 2. 执行getter(比如render函数),触发数据的getter
      // 此时数据的getter会检测到Dep.target,从而收集当前Watcher
      value = this.getter.call(vm, vm)
    } catch (e) {
      // 错误处理
    } finally {
      // 3. 深度监听处理
      if (this.deep) {
        traverse(value)
      }
      // 4. 出栈,恢复Dep.target
      popTarget()
      // 5. 清理无用的依赖
      this.cleanupDeps()
    }
    return value
  }

  // 核心:添加Dep到Watcher(与Dep.depend()配合)
  addDep(dep: Dep) {
    const id = dep.id
    // 避免重复收集
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        // 让Dep也记录当前Watcher
        dep.addSub(this)
      }
    }
  }

  // 清理无用依赖(比如数据从视图中移除后,不再监听)
  cleanupDeps() {
    // 省略清理逻辑...
  }

  // 核心:响应式数据更新时,触发Watcher更新
  update() {
    if (this.lazy) {
      // computed:标记为脏值,下次访问时重新计算
      this.dirty = true
    } else if (this.sync) {
      // 同步更新:立即执行run
      this.run()
    } else {
      // 异步更新(Vue默认):加入队列,批量更新
      queueWatcher(this)
    }
  }

  // 执行getter并触发回调
  run() {
    const value = this.get()
    if (value !== this.value || this.deep) {
      const oldValue = this.value
      this.value = value
      // 执行回调(比如watch的回调函数)
      this.cb.call(this.vm, value, oldValue)
    }
  }

  // computed专用:计算并返回最新值
  evaluate() {
    this.value = this.get()
    this.dirty = false
  }

  // 重新收集依赖
  depend() {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
}

核心要点

  • Watcher.get()是触发依赖收集的关键:先将自身设为Dep.target,再执行getter(比如render函数),此时render中用到的所有响应式数据都会触发getter,从而收集当前 Watcher;

  • 不同类型的 Watcher 有不同的更新策略:

    • 渲染 Watcher(render):异步更新,加入队列批量执行;
    • 计算 Watcher(computed):懒更新(lazy: true),只有访问时才重新计算;
    • 侦听 Watcher(watch):可配置同步 / 异步,支持深度监听。

3.4 第四步:完整流程梳理(以组件渲染为例)

结合上面的源码,我们用流程图梳理组件渲染时的依赖收集完整流程:

预览

查看代码

组件初始化

创建渲染Watcher

生成失败,请重试

graph TD
    A[组件初始化] --> B[创建渲染Watcher]
    B --> C[执行Watcher.get()]
    C --> D[pushTarget:设置Dep.target为当前Watcher]
    D --> E[执行render函数]
    E --> F[读取响应式数据,触发getter]
    F --> G[Dep.depend():收集依赖]
    G --> H[Watcher.addDep():双向绑定Dep和Watcher]
    H --> I[render执行完成]
    I --> J[popTarget:恢复Dep.target]
    J --> K[依赖收集完成:数据→Dep→Watcher映射建立]
    L[修改响应式数据] --> M[触发setter]
    M --> N[Dep.notify():通知所有Watcher]
    N --> O[Watcher.update():执行更新]
    O --> P[重新执行render,更新视图]

组件初始化

创建渲染Watcher

生成失败,请重试

豆包

你的 AI 助手,助力每日工作学习

四、特殊场景的依赖处理

4.1 数组的依赖收集

数组的响应式处理和对象不同(因为数组的索引无法被Object.defineProperty拦截),Vue 重写了pushpopsplice等 7 个数组方法,核心逻辑:

javascript

运行

// src/core/observer/array.js
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

// 重写的7个方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

methodsToPatch.forEach(function (method) {
  // 保留原方法
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator(...args) {
    // 执行原方法
    const result = original.apply(this, args)
    // 获取数组的__ob__(Observer实例)
    const ob = this.__ob__
    // 处理新增元素(push/unshift/splice),转为响应式
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // 关键:通知依赖更新
    ob.dep.notify()
    return result
  })
})

核心:数组的依赖收集在数组本身的__ob__.dep中,修改数组时调用ob.dep.notify()触发更新。

4.2 computed 的依赖收集

computed的 Watcher 是 “懒 Watcher”(lazy: true),特点:

  1. 初始化时不立即执行get,只有首次访问时才触发;
  2. 依赖的数据更新时,只标记dirty: true,不立即重新计算;
  3. 下次访问computed属性时,才重新计算并缓存结果。

核心逻辑:

javascript

运行

// src/core/instance/state.js
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        // 只有脏值时才重新计算
        watcher.evaluate()
      }
      // 收集渲染Watcher到computed的依赖中
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

五、常见问题与面试考点

5.1 为什么 Vue 不能检测数组索引和长度的变化?

  • 数组索引的get/set虽然能被Object.defineProperty拦截,但考虑到性能成本(数组元素可能很多),Vue 放弃了这种方式;
  • 数组长度的set也无法被有效拦截,且修改长度的场景较少;
  • 解决方案:使用 Vue 提供的变异方法(push/splice 等)或Vue.set

5.2 为什么修改对象的新属性视图不更新?

  • 因为对象初始化时,只有已定义的属性被添加了get/set,新属性没有;
  • 解决方案:使用Vue.set(obj, key, value)this.$set,本质是给新属性添加get/set并触发依赖更新。

5.3 依赖收集为什么要双向记录(Dep→Watcher 和 Watcher→Dep)?

  • 避免重复收集:通过depIdsnewDepIds去重;
  • 方便依赖清理:组件销毁时,Watcher 可以遍历自己的deps,从 Dep 中移除自身;
  • 支持依赖更新:Watcher 可以通过deps重新收集依赖(比如computeddepend方法)。

六、总结

Vue 的依赖收集机制是响应式系统的灵魂,核心可以总结为 3 点:

  1. 核心链路Observer给数据加get/set → 读取数据时Dep收集Watcher → 修改数据时Dep通知Watcher执行 → 视图更新;
  2. 核心角色Observer(响应式标记)、Dep(依赖容器)、Watcher(依赖载体)三者协同工作;
  3. 性能优化:通过懒更新(computed)、异步队列(渲染 Watcher)、依赖清理等方式,保证响应式的高效性。

理解依赖收集,不仅能帮你解决日常开发中的响应式问题,更能让你从底层理解 Vue 的设计思想。希望本文能让你对 Vue 的响应式系统有更深入的认识~

深入浅出 React 闭包陷阱:从现象到原理

深入浅出 React 闭包陷阱:从现象到原理

前言

React Hooks 的推出让函数组件焕发新生,我们可以用更简洁的代码实现状态和副作用。然而,Hooks 也带来了一些“坑”,其中 闭包陷阱 是初学者乃至有经验的开发者都容易遇到的问题。本文将从 JavaScript 闭包的基础出发,结合实际的 React 代码,一步步剖析闭包陷阱的成因、表现以及多种解决方案,帮助你彻底理解并避免它。

1. 什么是闭包?

在讨论 React 之前,我们必须先理解 JavaScript 中的闭包。闭包是指一个函数能够记住并访问它的词法作用域,即使该函数在其词法作用域之外执行。简单来说,闭包让你可以在一个内层函数中访问到外层函数的变量。

function outer() {
  let message = "Hello";
  function inner() {
    console.log(message); // inner 可以访问 outer 的变量
  }
  return inner;
}

const fn = outer();
fn(); // 输出 "Hello" —— 闭包使得 message 仍然可访问

闭包的形成需要两个条件:函数嵌套,且内部函数引用了外部函数的变量。当内部函数被返回或在其他地方被调用时,它依然持有对外部作用域的引用,这就是闭包。

2. React 函数组件中的闭包

React 函数组件每次渲染都会执行整个函数体,每次执行都会创建全新的局部变量和嵌套函数(如事件处理、useEffect 回调等)。这些嵌套函数会捕获当前渲染中的 props 和 state,形成闭包。

考虑一个简单的计数器组件:

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  return <button onClick={handleClick}>{count}</button>;
}

每次渲染,handleClick 函数都是新创建的,它捕获的是本次渲染的 count 值。当用户点击按钮时,handleClick 中使用的 count点击发生时所处渲染的那一时刻的值,而不是最新的 state。这正是 React 正常工作的方式,也是每次渲染拥有独立 props 和 state 的体现。

3. 什么是闭包陷阱?

闭包陷阱(Closure Trap)通常指:在 useEffect、useCallback 等 Hook 中,由于依赖数组写得不正确,导致回调函数中捕获的是旧渲染中的状态值,从而引发 Bug

最常见的情景是在 useEffect 中启动一个定时器,并且依赖数组为空 [],期望定时器只运行一次,但定时器回调内部使用了外部的 state 或 props。由于空依赖的 Effect 只执行一次,回调函数捕获的是首次渲染时的值,后续更新后定时器依然使用旧值,导致“过期闭包”。

4. 代码演示:一个典型的闭包陷阱

来看一段示例代码:

import { useState, useEffect } from 'react';

export default function App() {
  const [count, setCount] = useState(0);
  // 打印此时的count值
  console.log("----------count:",count)

  // ❌ 闭包陷阱版本
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Current count:', count);
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 空依赖数组

  return (
    <>
      <p>count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        count + 1
      </button>
    </>
  );
}

现象:点击按钮增加 count,页面显示的数字会更新,但控制台每隔一秒打印的 count 始终是 0,永远不会变化。

为什么?

  • 首次渲染时,count = 0useEffect 运行,创建定时器,定时器回调通过闭包捕获了本次渲染的 count 值(0)。
  • 点击按钮,setCount 触发重新渲染,count 变为 1。但由于依赖数组为空,useEffect 不会重新执行,定时器依然是旧的,其回调仍然持有旧的 count = 0
  • 于是每次定时器执行,都打印 0。

这就是典型的闭包陷阱:异步操作(定时器)引用了过时的状态。

效果图,可以看到尽管count已经加一,当时此时定时器打印的值仍为0

屏幕录制 2026-03-01 160044.gif

5. 原因深度剖析

要彻底理解这个问题,需要明白两件事:

5.1 每次渲染都有独立的“快照”

React 函数组件每次渲染就像是一次函数调用,参数是当前的 props 和 state。在某个特定渲染中,所有的变量(count、setCount 等)都是该渲染的常量。定时器回调是在未来某个时刻执行的,但它定义时的作用域是本次渲染,所以它捕获的是本次渲染的值。

5.2 Effect 的清理机制

useEffect 的返回函数(清理函数)会在组件卸载前执行,也会在每次 Effect 重新执行前执行(清理上一次的 Effect)。当依赖数组变化时,React 会先运行上一次的清理函数,再运行新的 Effect。

如果我们在依赖数组中包含 count

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current count:', count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // ✅ 依赖 count

那么每次 count 变化时,都会:

  1. 清理上一次的定时器。
  2. 重新创建新的定时器,新回调捕获最新的 count
  3. 控制台每次打印的值都是最新的。

这解决了闭包陷阱,但也意味着定时器会被频繁重置,可能不是我们想要的效果(比如我们想要一个持续运行的定时器,但能读取最新值)。

效果图,可以看到此时的count加一,定时器打印出的count也随之增加

屏幕录制 2026-03-01 160448.gif

6. 解决方案

6.1 在依赖数组中包含所有外部依赖

最简单直接的方法:将 Effect 中用到的所有响应式值(state、props)都放入依赖数组。这样每次值变化,Effect 都会重新执行,确保闭包总是新鲜的。

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current count:', count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]);

优点:简单、符合直觉。
缺点:如果依赖变化频繁,可能导致 Effect 频繁创建销毁,影响性能;某些场景(如定时器)可能并不希望被频繁重置。

6.2 使用 ref 保存最新值

useRef 返回一个可变对象,它的 current 属性在组件整个生命周期内保持不变,修改它不会触发重新渲染。我们可以利用 ref 来保存最新的状态,在异步回调中读取 ref.current。

const [count, setCount] = useState(0);
const countRef = useRef(count);

// 每次渲染后更新 ref 的值
useEffect(() => {
  countRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current count:', countRef.current); // ✅ 总是最新值
  }, 1000);
  return () => clearInterval(timer);
}, []); // 依赖数组为空,定时器不会重置

原理:ref 是一个容器,我们可以手动保持它与 state 同步。由于定时器回调通过闭包捕获的是 countRef 这个对象(而不是它的值),而对象引用不变,但 current 属性可以随时更新,因此总能访问到最新的 count。

优点:定时器只创建一次,不会因 count 变化而重启。
缺点:需要手动同步 ref 与 state(可以用一个 useEffect 来做),代码稍显啰嗦。

6.3 使用 useReducer 或函数式更新

如果定时器逻辑只需要基于当前 state 计算新值(而不需要直接读取 state 用于其他目的),可以使用 setState 的函数式更新形式,但这通常适用于更新 state 的场景,而不是读取。

例如:

useEffect(() => {
  const timer = setInterval(() => {
    setCount(prevCount => prevCount + 1); // 基于前一个值更新
  }, 1000);
  return () => clearInterval(timer);
}, []);

这里我们不需要读取 count 的值,而是用函数式更新,因此没有闭包陷阱。但如果我们确实需要读取 count 做其他操作(比如打印),这种方法就不适用。

6.4 自定义 Hook 封装

对于常见场景,可以封装一个自定义 Hook 来简化 ref 方案。比如 useInterval

function useInterval(callback, delay) {
  const savedCallback = useRef();

  useEffect(() => {
    savedCallback.current = callback;
  });

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }
    if (delay !== null) {
      const id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}

使用时:

useInterval(() => {
  console.log('Current count:', count);
}, 1000);

这个自定义 Hook 内部使用 ref 保存最新的回调,从而避免了闭包陷阱,且定时器不会因为依赖变化而重启(除非 delay 变化)。

7. useCallback 中的闭包陷阱

类似的问题也会出现在 useCallback 中。例如:

const handleClick = useCallback(() => {
  console.log(count); // 依赖 count
}, []); // 空依赖

handleClick 捕获了首次渲染的 count,后续无论 count 如何变化,handleClick 都不会更新,导致调用时总是旧值。

解决:在依赖数组中正确填写所有依赖。

const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

或者使用 ref 方案:

const countRef = useRef(count);
useEffect(() => {
  countRef.current = count;
}, [count]);

const handleClick = useCallback(() => {
  console.log(countRef.current);
}, []); // 依赖为空,但 ref 总是最新

8. 总结

React 闭包陷阱本质是函数式组件每次渲染的独立性异步操作持久化引用旧渲染环境之间的矛盾。理解闭包和 React 渲染机制是避免陷阱的关键。

最佳实践建议

  1. 遵守 Hooks 规则:useEffect、useCallback 等 Hook 的依赖数组必须包含所有外部依赖(即该 Effect 或回调中使用的所有 props、state 以及由它们衍生而来的值)。ESLint 插件 eslint-plugin-react-hooks 会帮助你自动检查依赖,建议开启。

  2. 合理选择解决方案

    • 如果 Effect 需要响应变化且重置成本低,直接添加依赖即可。
    • 如果 Effect 需要持久运行且必须读取最新值,考虑 ref 方案或封装自定义 Hook。
    • 对于定时器、事件监听等场景,优先考虑自定义 Hook(如 useIntervaluseEventListener)来统一处理。
  3. 理解闭包:编写 React 代码时,时刻提醒自己:函数组件每次渲染都是一次独立的“快照”,异步回调捕获的是定义时的快照。

最后,强烈推荐使用 React 官方提供的 ESLint 规则,它可以捕获绝大多数遗漏依赖的情况,是避免闭包陷阱的第一道防线。

希望本文能帮助你彻底掌握 React 闭包陷阱,从此写出更健壮的代码!

表单最佳实践:从 v-model 到自定义表单组件(含校验)

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、v-model 到底是什么?——先把"语法糖"这三个字吃透

很多人写了好几年 Vue,天天用 v-model,但如果问一句:"v-model 的本质是什么?",不少人只能说出"双向绑定"四个字就卡住了。

一句话结论:v-model 是一颗语法糖,它帮你把"传值进去 + 监听变化抛出来"这两步合成了一步。

1.1 在原生元素上:v-model = :value + @input

先看最基础的用法:

<template>
  <input v-model="username" />
</template>

<script setup>
import { ref } from 'vue'
const username = ref('')
</script>

这段代码等价于:

<template>
  <input :value="username" @input="username = $event.target.value" />
</template>

<script setup>
import { ref } from 'vue'
const username = ref('')
</script>

看到了吗?Vue 帮你做了两件事:

  1. username 的值通过 :value 绑定到 input 上(数据 → 视图)
  2. 监听 input 的 input 事件,拿到用户输入的新值,赋回给 username(视图 → 数据)

这就是所谓的"双向绑定"——但本质上并不神秘,就是一个 prop 传入 + 一个事件抛出的简写。

踩坑提醒: 不同的原生表单元素,v-model 背后绑定的属性和事件是不一样的:

元素 绑定的属性 监听的事件
<input type="text"> value input
<input type="checkbox"> checked change
<select> value change
<textarea> value input

所以当你用原生 checkbox 配合 v-model 时,它走的是 checked + change,别和 text input 搞混。

1.2 一个常见的新手困惑:v-model 和 :value 能一起写吗?

不能。 写了 v-model 就不要再手动写 :value,因为 v-model 已经包含了 :value 的行为。如果你两个都写,Vue 会在控制台警告你冲突。

<!-- ❌ 错误写法 -->
<input v-model="username" :value="username" />

<!-- ✅ 二选一 -->
<input v-model="username" />
<!-- 或者 -->
<input :value="username" @input="username = $event.target.value" />

二、Vue 3 自定义组件的 v-model——规则变了,别用 Vue 2 的老习惯

如果说原生元素上的 v-model 是"开胃菜",那自定义组件上的 v-model 才是日常业务中真正高频使用的。而且 Vue 3 对 v-model 的机制做了重大改动,这里是很多从 Vue 2 迁移过来的同学最容易踩坑的地方。

2.1 Vue 2 vs Vue 3 的对比

特性 Vue 2 Vue 3
默认 prop 名 value modelValue
默认事件名 input update:modelValue
多个 v-model ❌ 不支持(要用 .sync ✅ 原生支持
.sync 修饰符 ❌ 移除了,用 v-model:xxx 替代

2.2 最基础的自定义组件 v-model

场景: 封装一个自定义输入框组件 MyInput

子组件 MyInput.vue

<template>
  <div class="my-input-wrapper">
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      class="my-input"
    />
  </div>
</template>

<script setup>
defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

defineEmits(['update:modelValue'])
</script>

父组件使用:

<template>
  <MyInput v-model="username" />
  <p>你输入的是:{{ username }}</p>
</template>

<script setup>
import { ref } from 'vue'
import MyInput from './MyInput.vue'

const username = ref('')
</script>

拆解: 父组件写 v-model="username",Vue 3 会自动展开为:

<MyInput :modelValue="username" @update:modelValue="username = $event" />

所以子组件需要做两件事:

  1. modelValue 这个 prop 接收值
  2. 变化时通过 $emit('update:modelValue', newValue) 把新值抛出去

踩坑提醒: 事件名必须是 update:modelValue,中间是冒号,不是横杠、不是驼峰拼接。很多人写成 updateModelValue 或者 update-model-value,都是错的。

2.3 多个 v-model——Vue 3 的杀手级特性

Vue 2 时代,一个组件只能有一个 v-model,如果要双向绑定多个值,得用 .sync 修饰符,写起来很割裂。Vue 3 直接支持多个 v-model,优雅多了。

场景: 一个用户信息组件,同时需要双向绑定姓名和年龄。

子组件 UserFields.vue

<template>
  <div class="user-fields">
    <label>
      姓名:
      <input
        :value="name"
        @input="$emit('update:name', $event.target.value)"
      />
    </label>
    <label>
      年龄:
      <input
        type="number"
        :value="age"
        @input="$emit('update:age', Number($event.target.value))"
      />
    </label>
  </div>
</template>

<script setup>
defineProps({
  name: { type: String, default: '' },
  age: { type: Number, default: 0 }
})

defineEmits(['update:name', 'update:age'])
</script>

父组件使用:

<template>
  <UserFields v-model:name="form.name" v-model:age="form.age" />
  <p>姓名:{{ form.name }},年龄:{{ form.age }}</p>
</template>

<script setup>
import { reactive } from 'vue'
import UserFields from './UserFields.vue'

const form = reactive({
  name: '',
  age: 0
})
</script>

v-model:name="form.name" 展开后就是 :name="form.name" @update:name="form.name = $event"。规则和默认的 v-model 一样,只是把 modelValue 换成了你自己指定的 prop 名。

2.4 defineModel()——Vue 3.4+ 的终极简化

从 Vue 3.4 开始,defineModel() 正式转正(之前是实验性 API)。它让自定义组件的 v-model 写法大幅简化。

改造上面的 MyInput.vue

<template>
  <div class="my-input-wrapper">
    <input v-model="model" class="my-input" />
  </div>
</template>

<script setup>
const model = defineModel({ type: String, default: '' })
</script>

没有了手动 defineProps + defineEmits,也不用自己写 $emitdefineModel() 返回的是一个 ref,你直接用 v-model 绑定到原生 input 上就行,它会自动帮你处理和父组件之间的双向通信。

多个 v-model 也支持:

<script setup>
const name = defineModel('name', { type: String, default: '' })
const age = defineModel('age', { type: Number, default: 0 })
</script>

选型建议:

  • 如果你的项目已经是 Vue 3.4+,强烈推荐用 defineModel(),代码量少、可读性好、不容易出错。
  • 如果项目还在 Vue 3.3 及以下,老老实实用 defineProps + defineEmits 的经典写法。
  • 别在生产项目里用实验性 API,等转正了再上。

三、表单组件拆分的实战思路——什么时候该拆?怎么拆?

知道了 v-model 的原理,接下来聊聊实战中最常遇到的问题:表单越写越长,该怎么拆?

3.1 不拆的代价

先看一个典型的"不拆"写法——一个订单表单,所有字段怼在一个组件里:

<template>
  <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
    <!-- 基本信息 -->
    <el-form-item label="订单名称" prop="name">
      <el-input v-model="form.name" />
    </el-form-item>
    <el-form-item label="订单类型" prop="type">
      <el-select v-model="form.type">
        <el-option label="普通" value="normal" />
        <el-option label="加急" value="urgent" />
      </el-select>
    </el-form-item>
    <!-- 收货信息 -->
    <el-form-item label="收货人" prop="receiver">
      <el-input v-model="form.receiver" />
    </el-form-item>
    <el-form-item label="手机号" prop="phone">
      <el-input v-model="form.phone" />
    </el-form-item>
    <el-form-item label="地址" prop="address">
      <el-input v-model="form.address" />
    </el-form-item>
    <!-- 商品信息 -->
    <el-form-item label="商品名称" prop="product">
      <el-input v-model="form.product" />
    </el-form-item>
    <el-form-item label="数量" prop="quantity">
      <el-input-number v-model="form.quantity" :min="1" />
    </el-form-item>
    <el-form-item label="备注" prop="remark">
      <el-input v-model="form.remark" type="textarea" />
    </el-form-item>
    <el-form-item>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive } from 'vue'

const formRef = ref()
const form = reactive({
  name: '', type: '', receiver: '', phone: '',
  address: '', product: '', quantity: 1, remark: ''
})
const rules = {
  name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }],
  type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
  receiver: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
  phone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
  ],
  address: [{ required: true, message: '请输入地址', trigger: 'blur' }],
  product: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
  quantity: [{ required: true, message: '请输入数量', trigger: 'change' }]
}

const handleSubmit = async () => {
  await formRef.value.validate()
  console.log('提交数据:', form)
}
</script>

这还只是 8 个字段。现实业务中,一个表单二三十个字段是常态,加上联动逻辑、动态显隐、异步校验……一个文件轻松上千行。

问题在哪?

  • 改一个区块的字段,要在一大坨模板里翻半天
  • 校验规则和字段分离,对应关系全靠 prop 字符串"人肉匹配"
  • 无法复用——收货信息这一块在别的页面也要用,你只能复制粘贴

3.2 拆分原则:按业务区块拆,不是按字段拆

核心原则:一个子表单组件 = 一个业务含义的区块。

以上面的订单表单为例,天然可以拆成三块:

  1. 基本信息 —— OrderBasicInfo.vue
  2. 收货信息 —— ReceiverInfo.vue
  3. 商品信息 —— ProductInfo.vue

3.3 拆分后的代码实现

子组件 ReceiverInfo.vue(收货信息区块):

<template>
  <el-form-item label="收货人" prop="receiver">
    <el-input :model-value="modelValue.receiver" @update:model-value="updateField('receiver', $event)" />
  </el-form-item>
  <el-form-item label="手机号" prop="phone">
    <el-input :model-value="modelValue.phone" @update:model-value="updateField('phone', $event)" />
  </el-form-item>
  <el-form-item label="地址" prop="address">
    <el-input :model-value="modelValue.address" @update:model-value="updateField('address', $event)" />
  </el-form-item>
</template>

<script setup>
const props = defineProps({
  modelValue: {
    type: Object,
    required: true
  }
})

const emit = defineEmits(['update:modelValue'])

const updateField = (field, value) => {
  emit('update:modelValue', { ...props.modelValue, [field]: value })
}
</script>

关键点解读:

  1. 子组件通过 modelValue 接收整个区块的数据对象(不是单个字段)
  2. 更新时,用展开运算符创建一个新对象{ ...props.modelValue, [field]: value }
  3. 整体抛出给父组件,让父组件拿到新值去更新

⚠️ 这里有一个非常重要的踩坑点:为什么不能直接修改 props?

你可能想:props.modelValue 是个对象,我直接 props.modelValue.receiver = '张三' 不行吗?

技术上可以,Vue 不会报错(对象是引用传递)。但这是一个非常坏的习惯。 原因:

  • 违反了 Vue 的"单向数据流"原则——数据应该从父组件流向子组件,子组件想改数据,应该通过事件通知父组件去改
  • 当组件层级变深、多个子组件共享同一份数据时,直接修改 props 会导致"数据在哪被改的"完全无法追踪
  • 在使用 Vue DevTools 调试时,直接改 props 不会触发事件记录,等于"偷偷改了但没人知道"

结论:永远通过 emit 通知父组件修改,哪怕多写几行代码。

父组件 OrderForm.vue(组装):

<template>
  <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
    <h3>基本信息</h3>
    <OrderBasicInfo v-model="basicInfo" />

    <h3>收货信息</h3>
    <ReceiverInfo v-model="receiverInfo" />

    <h3>商品信息</h3>
    <ProductInfo v-model="productInfo" />

    <el-form-item>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive, computed } from 'vue'
import OrderBasicInfo from './OrderBasicInfo.vue'
import ReceiverInfo from './ReceiverInfo.vue'
import ProductInfo from './ProductInfo.vue'

const formRef = ref()

const basicInfo = ref({ name: '', type: '' })
const receiverInfo = ref({ receiver: '', phone: '', address: '' })
const productInfo = ref({ product: '', quantity: 1, remark: '' })

// 组装完整表单数据(用于提交和校验)
const form = computed(() => ({
  ...basicInfo.value,
  ...receiverInfo.value,
  ...productInfo.value
}))

const rules = {
  name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }],
  receiver: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
  phone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
  ],
  // ...其他规则
}

const handleSubmit = async () => {
  await formRef.value.validate()
  console.log('提交数据:', form.value)
}
</script>

3.4 用 defineModel() 简化子组件(Vue 3.4+)

如果项目版本允许,子组件可以进一步简化:

<!-- ReceiverInfo.vue(Vue 3.4+ 简化版)-->
<template>
  <el-form-item label="收货人" prop="receiver">
    <el-input v-model="model.receiver" />
  </el-form-item>
  <el-form-item label="手机号" prop="phone">
    <el-input v-model="model.phone" />
  </el-form-item>
  <el-form-item label="地址" prop="address">
    <el-input v-model="model.address" />
  </el-form-item>
</template>

<script setup>
const model = defineModel({ type: Object, required: true })
</script>

注意: defineModel() 返回的 ref 本质上会对对象的属性修改进行追踪并自动触发 update:modelValue。但如果你需要更细粒度的控制(比如只在某些条件下才允许更新),还是用 defineProps + defineEmits 的显式写法更合适。

四、Element Plus 表单封装实战——来点真正的项目级经验

上面讲了拆分的基本思路,下面来看在 Element Plus 体系下,实际项目中常用的几个封装模式。

4.1 踩坑重灾区:el-form 的 prop 路径与嵌套对象

当表单数据是嵌套结构时,el-form-itemprop 需要写成路径形式,否则校验不生效。

<template>
  <el-form :model="form" :rules="rules" ref="formRef">
    <!-- ❌ 错误:prop 写成 "name",但数据在 form.basic.name -->
    <el-form-item label="姓名" prop="name">
      <el-input v-model="form.basic.name" />
    </el-form-item>

    <!-- ✅ 正确:prop 要写完整路径 -->
    <el-form-item label="姓名" prop="basic.name">
      <el-input v-model="form.basic.name" />
    </el-form-item>
  </el-form>
</template>

<script setup>
import { reactive } from 'vue'

const form = reactive({
  basic: { name: '', age: 0 },
  contact: { phone: '', email: '' }
})

const rules = {
  // 规则的 key 也要用完整路径
  'basic.name': [{ required: true, message: '请输入姓名', trigger: 'blur' }],
  'basic.age': [{ required: true, message: '请输入年龄', trigger: 'blur' }]
}
</script>

踩坑总结:

  • prop 的值必须和 form 对象中的路径一一对应
  • rules 对象的 key 也必须用相同的路径
  • 如果 prop 和实际数据路径对不上,校验静默失败——不报错、不提示、就是不校验,非常难排查

4.2 封装一个通用的表单弹窗组件

这是项目中使用频率最高的模式之一——点击按钮弹出表单弹窗,填写后提交。

<!-- FormDialog.vue -->
<template>
  <el-dialog
    :model-value="visible"
    @update:model-value="$emit('update:visible', $event)"
    :title="title"
    width="600px"
    :close-on-click-modal="false"
    @closed="handleClosed"
  >
    <el-form
      ref="formRef"
      :model="formData"
      :rules="rules"
      label-width="100px"
      @submit.prevent
    >
      <slot :form="formData" />
    </el-form>
    <template #footer>
      <el-button @click="$emit('update:visible', false)">取消</el-button>
      <el-button type="primary" :loading="loading" @click="handleConfirm">
        确定
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, watch } from 'vue'

const props = defineProps({
  visible: { type: Boolean, default: false },
  title: { type: String, default: '表单' },
  rules: { type: Object, default: () => ({}) },
  initialData: { type: Object, default: () => ({}) }
})

const emit = defineEmits(['update:visible', 'confirm'])

const formRef = ref()
const formData = ref({})
const loading = ref(false)

// 每次打开弹窗时,用 initialData 初始化表单
watch(() => props.visible, (val) => {
  if (val) {
    formData.value = JSON.parse(JSON.stringify(props.initialData))
  }
})

const handleConfirm = async () => {
  try {
    await formRef.value.validate()
    loading.value = true
    emit('confirm', { ...formData.value })
  } catch {
    // 校验未通过,不做处理
  } finally {
    loading.value = false
  }
}

const handleClosed = () => {
  formRef.value?.resetFields()
}
</script>

父组件使用:

<template>
  <el-button @click="dialogVisible = true">新增用户</el-button>

  <FormDialog
    v-model:visible="dialogVisible"
    title="新增用户"
    :rules="rules"
    :initial-data="defaultForm"
    @confirm="handleConfirm"
  >
    <template #default="{ form }">
      <el-form-item label="用户名" prop="username">
        <el-input v-model="form.username" />
      </el-form-item>
      <el-form-item label="邮箱" prop="email">
        <el-input v-model="form.email" />
      </el-form-item>
    </template>
  </FormDialog>
</template>

<script setup>
import { ref } from 'vue'
import FormDialog from './FormDialog.vue'

const dialogVisible = ref(false)
const defaultForm = { username: '', email: '' }

const rules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ]
}

const handleConfirm = async (formData) => {
  // 调用接口提交
  console.log('提交的数据:', formData)
  dialogVisible.value = false
}
</script>

这个封装的核心设计:

设计点 为什么这么做
initialData 深拷贝 避免编辑时直接修改原始数据,取消后数据被"污染"
@closedresetFields 关闭动画结束后再清理,避免用户看到表单闪烁
:close-on-click-modal="false" 防止用户填了一半误触遮罩关闭
@submit.prevent 防止表单内按回车触发页面刷新
通过 slot 传入表单项 表单内容由调用方决定,弹窗组件只管"壳"和"行为"

踩坑提醒: resetFields() 只会重置到 el-form 初始挂载时的值,不是清空为空字符串。所以如果你在弹窗打开后才给 formData 赋值,resetFields 可能重置到的是空对象而不是你期望的初始值。这就是为什么我们用 watchvisible 变为 true 时就立即赋值——确保表单挂载时就有正确的初始数据。

4.3 编辑与新增共用同一个弹窗

实际业务中,新增和编辑往往共用一个表单弹窗,只是初始数据不同。

<template>
  <el-button @click="handleAdd">新增</el-button>
  <el-button @click="handleEdit(mockData)">编辑</el-button>

  <FormDialog
    v-model:visible="dialogVisible"
    :title="isEdit ? '编辑用户' : '新增用户'"
    :rules="rules"
    :initial-data="currentForm"
    @confirm="handleConfirm"
  >
    <template #default="{ form }">
      <el-form-item label="用户名" prop="username">
        <el-input v-model="form.username" :disabled="isEdit" />
      </el-form-item>
      <el-form-item label="邮箱" prop="email">
        <el-input v-model="form.email" />
      </el-form-item>
    </template>
  </FormDialog>
</template>

<script setup>
import { ref, computed } from 'vue'
import FormDialog from './FormDialog.vue'

const dialogVisible = ref(false)
const editingItem = ref(null)

const isEdit = computed(() => !!editingItem.value)

const defaultForm = { username: '', email: '' }
const currentForm = computed(() =>
  isEdit.value ? { ...editingItem.value } : { ...defaultForm }
)

const mockData = { id: 1, username: '张三', email: 'zhangsan@example.com' }

const handleAdd = () => {
  editingItem.value = null
  dialogVisible.value = true
}

const handleEdit = (item) => {
  editingItem.value = item
  dialogVisible.value = true
}

const rules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ]
}

const handleConfirm = async (formData) => {
  if (isEdit.value) {
    console.log('更新数据:', { id: editingItem.value.id, ...formData })
  } else {
    console.log('新增数据:', formData)
  }
  dialogVisible.value = false
}
</script>

五、表单校验的那些事——从基础到自定义

5.1 Element Plus 校验的基本运作方式

Element Plus 的表单校验基于 async-validator 这个库。理解它的工作流程:

用户输入 → 触发 trigger 事件(blur/change)→ 根据 prop 查找对应的 rules → 执行校验 → 显示/隐藏错误提示

5.2 自定义校验器(validator)

内置规则(required、min、max、pattern 等)能覆盖大多数场景,但碰到复杂逻辑就得用自定义 validator。

const rules = {
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    {
      validator: (rule, value, callback) => {
        if (value.length < 8) {
          callback(new Error('密码至少8位'))
        } else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
          callback(new Error('密码需包含大小写字母和数字'))
        } else {
          callback()
        }
      },
      trigger: 'blur'
    }
  ],
  confirmPassword: [
    { required: true, message: '请确认密码', trigger: 'blur' },
    {
      validator: (rule, value, callback) => {
        if (value !== form.password) {
          callback(new Error('两次密码不一致'))
        } else {
          callback()
        }
      },
      trigger: 'blur'
    }
  ]
}

注意事项:

  • callback 必须调用,不管校验通过还是失败。忘记调用会导致校验"卡死"——按钮一直 loading,表单提交不了也不报错
  • 校验通过时调用 callback()(不传参数)
  • 校验失败时调用 callback(new Error('错误信息'))

5.3 异步校验(如:检查用户名是否已存在)

const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    {
      validator: async (rule, value, callback) => {
        try {
          const { data } = await checkUsernameApi(value)
          if (data.exists) {
            callback(new Error('用户名已被占用'))
          } else {
            callback()
          }
        } catch {
          callback(new Error('校验失败,请稍后再试'))
        }
      },
      trigger: 'blur'
    }
  ]
}

踩坑提醒: 异步校验如果不做防抖,用户每输入一个字符都会发请求。建议给异步校验加一个 debounce

import { debounce } from 'lodash-es'

const checkUsername = debounce(async (value, callback) => {
  try {
    const { data } = await checkUsernameApi(value)
    data.exists ? callback(new Error('用户名已被占用')) : callback()
  } catch {
    callback(new Error('校验失败,请稍后再试'))
  }
}, 500)

const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { validator: (rule, value, callback) => checkUsername(value, callback), trigger: 'blur' }
  ]
}

5.4 动态表单项的校验

动态增删的表单项(比如"添加更多联系人"),校验规则需要跟着走。

<template>
  <el-form :model="form" ref="formRef" label-width="100px">
    <div v-for="(contact, index) in form.contacts" :key="index" class="contact-row">
      <el-form-item
        :label="'联系人' + (index + 1)"
        :prop="'contacts.' + index + '.name'"
        :rules="[{ required: true, message: '请输入姓名', trigger: 'blur' }]"
      >
        <el-input v-model="contact.name" />
      </el-form-item>
      <el-form-item
        label="电话"
        :prop="'contacts.' + index + '.phone'"
        :rules="[
          { required: true, message: '请输入电话', trigger: 'blur' },
          { pattern: /^1[3-9]\d{9}$/, message: '格式不正确', trigger: 'blur' }
        ]"
      >
        <el-input v-model="contact.phone" />
      </el-form-item>
      <el-button @click="removeContact(index)" type="danger" text>删除</el-button>
    </div>
    <el-button @click="addContact" type="primary" plain>添加联系人</el-button>
    <el-form-item>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive } from 'vue'

const formRef = ref()
const form = reactive({
  contacts: [{ name: '', phone: '' }]
})

const addContact = () => {
  form.contacts.push({ name: '', phone: '' })
}

const removeContact = (index) => {
  form.contacts.splice(index, 1)
}

const handleSubmit = async () => {
  try {
    await formRef.value.validate()
    console.log('提交数据:', form.contacts)
  } catch {
    console.log('校验未通过')
  }
}
</script>

踩坑要点:

  • :prop 必须是动态的,格式为 '数组名.' + index + '.字段名'
  • 校验规则可以直接写在 el-form-item:rules 上,不用全放在 el-form:rules
  • v-for 一定要绑定 :key,且最好不要用 index 作为 key(删除中间项时会导致校验状态错乱)。推荐给每个 contact 加一个唯一 id

改进后的做法:

import { nanoid } from 'nanoid'

const addContact = () => {
  form.contacts.push({ id: nanoid(), name: '', phone: '' })
}
<div v-for="(contact, index) in form.contacts" :key="contact.id">

六、常见踩坑汇总

写了这么多,最后把文中提到的和额外的高频坑点汇总一下,方便速查:

# 踩坑点 现象 正确做法
1 v-model 和 :value 同时写 控制台警告,行为异常 二选一,不要混用
2 Vue 3 事件名写错 子组件修改不生效 必须是 update:modelValue
3 子组件直接修改 props 对象 短期没问题,长期数据流混乱 通过 emit 通知父组件修改
4 prop 路径和数据路径不匹配 校验静默失败 prop 值必须对应 form 对象的完整路径
5 validator 忘记调用 callback 表单提交"卡死" 无论通过还是失败都要调用 callback
6 resetFields 重置到空而非初始值 编辑弹窗关闭后数据异常 确保表单挂载时就有正确初始数据
7 动态表单 v-for 用 index 做 key 删除项后校验状态错乱 用唯一 id 作为 key
8 异步校验没做防抖 疯狂发请求 用 debounce 包装异步校验
9 弹窗表单 close-on-click-modal 填了一半误触关闭 设为 false
10 表单内按回车刷新页面 原生 form 默认提交行为 @submit.prevent

七、总结

回顾整篇文章的知识脉络:

v-model 本质(语法糖)
    ├─ 原生元素::value + @input
    └─ 自定义组件
         ├─ Vue 3 经典写法:defineProps + defineEmits
         ├─ Vue 3.4+ 简化:defineModel()
         └─ 多个 v-model:v-model:xxx
              │
              ▼
表单组件拆分
    ├─ 按业务区块拆分
    ├─ 子组件通过 v-model 和父组件通信
    └─ 永远不要直接修改 props
              │
              ▼
Element Plus 表单封装
    ├─ 嵌套对象的 prop 路径
    ├─ 通用表单弹窗组件
    └─ 新增/编辑共用弹窗
              │
              ▼
表单校验
    ├─ 自定义 validator
    ├─ 异步校验 + 防抖
    └─ 动态表单项校验

表单是前端日常工作中占比最大的 UI 模式之一。把 v-model 的本质搞清楚,把组件拆分的边界想明白,把 Element Plus 的校验机制摸透——这三件事做到了,你在日常表单开发中就能做到写得快、改得动、不踩坑


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

React性能优化:深入理解useMemo和useCallback

React性能优化:深入理解useMemo和useCallback

前言

在React函数组件中,每次状态更新都会导致整个组件函数重新执行。如果组件内部有复杂的计算或者传递了回调函数给子组件,可能会引发不必要的性能开销。React为我们提供了两个重要的Hook:useMemouseCallback,用于缓存计算结果和函数引用,从而优化组件性能。本文将从浅入深,结合实际代码,带你彻底理解这两个Hook的使用场景和原理。


1. 性能优化的必要性

我们先看一个简单的例子:

function App() {
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState('');

  const list = ['apple', 'banana', 'orange', 'pear'];

  // 每次渲染都会重新执行filter
  const filterList = list.filter(item => {
  // 测试fliter是否执行
  console.log("filter执行了")
  return item.includes(keyword) 
  });

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={() => setCount(count + 1)}>count: {count}</button>
      <ul>
        {filterList.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

这里 filterList 依赖于 keyword,但每次 count 改变导致组件重新渲染时,filterList 也会重新计算。如果 list 很大或者过滤逻辑复杂,这种不必要的计算就会影响性能。同样,如果我们将一个函数作为 prop 传递给子组件,每次父组件渲染都会生成一个新函数,导致子组件(即使使用 React.memo)也无法避免重新渲染。这正是 useMemouseCallback 要解决的问题。

效果图

屏幕录制 2026-03-01 154604.gif

2. useMemo:缓存计算结果

useMemo 用于缓存一个计算后的值,只有当依赖项发生变化时,才会重新计算。

2.1 基本用法

const cachedValue = useMemo(computeFn, dependencies);
  • computeFn:纯函数,返回需要缓存的值。
  • dependencies:依赖项数组,当任意依赖项变化时,重新执行 computeFn

2.2 优化列表过滤

改进上面的例子:

import { useState, useMemo } from 'react';

export default function App() {
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState('');
  const list = ['apple', 'banana', 'orange', 'pear'];

  // 只有 keyword 变化时才重新过滤
  const filterList = useMemo(() => {
    console.log('filter执行了'); // 依赖变化时打印
    return list.filter(item => item.includes(keyword));
  }, [keyword]);

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={() => setCount(count + 1)}>count: {count}</button>
      <ul>
        {filterList.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

现在,点击 count 按钮不会触发 filter 重新执行,只有输入框改变时才会。

效果图

屏幕录制 2026-03-01 154745.gif

2.3 缓存昂贵计算

假设有一个非常耗时的函数 slowSum

function slowSum(n) {
  console.log('计算中...');
  let sum = 0;
  for (let i = 0; i < n * 1000000; i++) {
    sum += i;
  }
  return sum;
}

我们可以用 useMemo 将其结果缓存起来:

const [num, setNum] = useState(0);
const result = useMemo(() => slowSum(num), [num]);

这样只有当 num 变化时才会重新计算,其他状态变化不会触发 slowSum,这样做我们就可以节约成本。

2.4 小结

  • useMemo 适用于需要缓存计算结果的场景,比如过滤、排序、复杂数学运算等。
  • 依赖数组要正确填写,避免遗漏或多余依赖。
  • 不要滥用,如果计算本身不昂贵,就没有必要缓存。

3. useCallback:缓存函数引用

在React中,函数组件每次渲染都会重新创建内部定义的函数。如果这个函数作为 prop 传递给子组件,即使子组件使用了 React.memo,也会因为每次父组件传递的函数引用不同而导致子组件重新渲染。useCallback 就是用来缓存函数引用的。

3.1 基本用法

const cachedFn = useCallback(fn, dependencies);
  • fn:需要缓存的函数。
  • dependencies:依赖项数组,当依赖变化时,重新创建函数。

3.2 配合 React.memo 优化子组件

先看一个没有优化的例子:

import { useState, memo } from 'react';

const Child = memo(({ count, handleClick }) => {
  console.log('子组件渲染了');
  return <div onClick={handleClick}>子组件 count: {count}</div>;
});

export default function App() {
  const [count, setCount] = useState(0);
  const [num, setNum] = useState(0);

  // 每次 App 渲染,这里都会生成一个新函数
  const handleClick = () => {
    console.log('点击了');
  };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>count+1</button>
      <button onClick={() => setNum(num + 1)}>num+1</button>
      <Child count={count} handleClick={handleClick} />
    </div>
  );
}

虽然 Childmemo 包裹,理论上只有 counthandleClick 变化时才会重新渲染。但由于 handleClick 每次都是新函数,memo 浅比较发现 handleClick 引用变了,导致子组件也会重新渲染。即使点击的是 num 按钮,子组件依然会渲染。

效果图,可以看到当我们点击nums组件时,我们的子组件依然会执行

屏幕录制 2026-03-01 155203.gif

使用 useCallback 缓存 handleClick

const handleClick = useCallback(() => {
  console.log('点击了');
}, []); // 空依赖表示函数永远不会重新创建

现在点击 num 按钮,handleClick 引用不变,子组件就不会重新渲染。

效果图,可以看到这个适合当我们点击nums组件,子组件就不会执行了

屏幕录制 2026-03-01 155340.gif

3.3 依赖项的作用

如果回调函数内部使用了某些状态,需要将这些状态添加到依赖数组中,否则函数内部会一直使用旧的闭包值。

const handleClick = useCallback(() => {
  console.log('当前count:', count);
}, [count]); // 当 count 变化时,重新生成函数

这样既能保证函数引用在 count 不变时稳定,又能在 count 变化时获取最新值。

3.4 小结

  • useCallback 主要用于将回调函数传递给经过 memo 优化的子组件,避免不必要的重绘。
  • 如果回调函数不依赖任何组件状态,依赖数组可以为空。
  • 注意不要过度优化,如果子组件很轻量或者渲染成本很低,不一定需要使用 useCallback

4. useMemo 与 useCallback 的关系

  • useMemo 缓存的是useCallback 缓存的是函数
  • 实际上,useCallback(fn, deps) 等价于 useMemo(() => fn, deps)
  • 两者都是通过闭包和依赖追踪来实现缓存,目的都是减少不必要的计算或渲染。

5. 常见误区与注意事项

5.1 依赖数组要完整

无论是 useMemo 还是 useCallback,都要确保依赖数组中包含了所有在回调/计算中使用的响应式值(props、state、context等)。否则会因闭包捕获旧值而产生 bug。可以使用 eslint-plugin-react-hooks 自动检查依赖。

5.2 不要过早优化

只有在确实存在性能瓶颈时才使用这两个 Hook。滥用会增加代码复杂度,且缓存本身也有开销。可以通过 React DevTools 的 Profiler 来识别需要优化的组件。

5.3 缓存稳定但非“纯”的函数

如果 useCallback 的回调函数依赖于外部状态,但依赖数组为空,则函数内部的变量会一直保持初始值,可能导致 bug。一定要根据实际依赖填写数组。


6. 总结

  • useMemo 用于缓存计算值,避免每次渲染都重新执行昂贵计算。
  • useCallback 用于缓存函数引用,配合 React.memo 避免子组件不必要的重新渲染。
  • 两者都需要指定依赖项,确保缓存内容在依赖变化时更新。
  • 性能优化应当有的放矢,先测量后优化,不要盲目使用。

通过合理使用这两个 Hook,我们可以让 React 应用在复杂场景下依然保持流畅。希望本文能帮助你深入理解 useMemouseCallback,并在实际项目中正确应用它们。


本文代码示例基于 React 18,你可以在自己的项目中尝试并观察效果。如果有任何疑问或见解,欢迎在评论区讨论!

流程设计器为啥选择diagram-js

工作流应用的行业背景

前端工作流可视化的开源js库有很多选择,在具体介绍之前,有必要让大家先了解下目前行业对这块的需求以及薪资情况。因为很多行业的企业针对工作流和规则引擎有定制化需求,能做到对业务人员友好、配置灵活、自动化智能化程度高,这些无疑会给公司的业务扩展、人力成本控制、业绩提升等带来巨大的收益。

这样的背景下,自研工作流平台相关产品,并衍生出一些技术变现的渠道,像自研产品的试用、推销、行业培训、整合应用的在线课程等等,只要令用户满意,都会是一笔客观的收益。为此,身处AI大行其道、技术不再成为产品研发瓶颈的当下,自研工作流产品以及技术整合、应用的视频教程等方面的工作也排入了我今年业余副业规划的日程。

从我15年前开始接触工作流引擎jbpm3到如今,工作流相关的工作岗位可以说一直是某些行业领域的香饽饽,只是定制程度要求更高了,这是我在boss直聘上找到几个典型的招聘要求:

20260301133040.png

流程设计器开源选择

现在工作流的研发向着企业建模定制化和低代码的方向不断迭代和产品升级,这里就不得不提流程可视化的UI建模方面了,恰恰是工作流UI的展示和交互,直接决定了产品的用户选择程度,当然像泛微这样有着强大研发团队的IT企业,他们的流程建模的底层支持可以基于产品的需求做到从零研发定制,市面上研发该类产品更多的选择是,采用开源js库,甚至是现成的UI组件。下面咱们就针对这类选择来聊一聊市面上常用的几款流程可视化前端开源库。

LogicFlow

LogicFlow这是由滴滴出品的用于流程绘制的一套底层UI库,它为企业流程建模和个性化主题扩展方面提供了出色的行业解决方案。从它网站的展示风格就寓示着万物皆可拖拽、万物皆可连结(纯属个人娱乐,非官方解释),绷不住笑~

20260301142424.png

从它官方的图标示例来看,它提供了用于各种用途的场景下绘制需求的demo,尤其是在主题风格的定制化方面。示例的应用场景还在不断完善,从流程图界面的操作体验来感受下,基本的连线操作和布局过于呆板,连接点采用锚点的设计个人觉得甚至有点鸡肋,而且连线布局的路径规划没有做优化,本该一条直连线,在调整图形位置后,会出现许多拐弯,且背景的点阵网格对连接线路径不会限制(不要求路径落在网格点上),基础使用体验上还可以进一步优化下底层功能:

20260301144627.png

关于它的API使用以及功能的扩展,有感兴趣的小伙伴可以自行操作官方指南。因为不作为我的个人选择,不做过多的研究和赘述。本人的流程设计器底层js库的选型要求是,操作便捷流畅,展示简洁大气,对UI包装和扩展上没有过多要求,但要提供优雅的方式进行功能扩展。对比这些要求,我坚定的选择了diagram-js

Workflow

Workflow这是一款个人开发的起名比较大众化的前端流程设计器UI组件,UI仿照钉钉的风格,采用Vue + Element UI打造的直接可用的页面UI组件,Github上提供了相关用法介绍,源码中也有页面使用示例,方便与项目集成,它还提供了页面演示地址。从视觉和操作体验上来看,布局过于呆板,垂直的布局让屏幕空间利用率很低,节点和连线都是固定的,页面显示要素过多,不够简洁:

20260301150308.png

抽屉式的属性编辑弹层,操作像卡壳了(绷不住笑)。 当然该项目很久就不再维护了,看到某站有up用它来研发规则决策可视化平台,才留意到这个UI库。因为布局过于固执己见,这是硬伤。

diagram-js

diagram-js这个js库很多人都没听过,如果提起基于它的产品bpmn-js,似乎是无人不知无人不晓(绷不住笑)。
bpmn-js这么成功,就是因为它的底层图形库:diagram-js,它可是国外众多前端大佬贡献的结晶。个人觉得越是好的东西,宣传越是不当回事(绷不住笑)。
这里重点说下咱用这个库做出来的一点小小的成果。而关于这块的学习教程,看各位读者的反馈吧。我先整理了第一部分的笔记:

20260301152207.png

有一定的学习曲线,因为没有系统的文档和使用场景,如果有耐心可以像我一样读diagram-js和bpmn-js的源码,如果觉得麻烦,后续我会安排上《diagram-js打造企业产品级流程设计器》的视频教程(绷不住笑出声)。

基于diagramjs打造产品级流程设计器

目前个人觉得做的最成功的流程设计器,还属bpmn.io出品的工具集三大件(bpmn-js、dmn-js和form-js)之一的bpmn-js。只是该产品是完全遵循bpmn规范的,对于本文一开始提到的国内使用场景,需要做到高度的模型定制化,因此本人才业余潜心研究这个底层库,向着产品化的目标不断的学习和实践。从初步实践中,发现这个库的几个显著的特色:

  • 库很轻量级,不依赖外部任何库,写原生js就可以轻易扩展
  • 内置模块功能足够的强大
  • 模块即插即用,且模块功能叠加使用下,可相互作用得到更好的结果
  • 模块扩展非常方便、定制和覆盖功能有着非常优雅的解决方案

第一部分实现的成果,发一个粗糙的截图:

20260301154555.png 但话说美的东西不都是从最简单的东西一点点布局修饰出来的吗(绷不住笑),后续会以录屏的方式给大家分享在某站,以链接的形式发出来。

Vue3 项目如何迁移到 uni-app x:从纯 Web 到多端应用的系统指南

一、引言:为什么要从「纯 Vue3 Web」迁移到 uni-app x?

许多团队已经有了一套成熟的 Vue3 Web 项目(基于 Vite、Vue Router、Pinia 等),跑在浏览器里一切正常。但随着业务发展,往往会遇到这些新需求:

  • 需要上线微信/支付宝/抖音等小程序入口;
  • 需要有一套「原生 App」承载更重的功能(推送、离线、深度系统能力);
  • 维护多套代码(Web、一堆小程序、原生 App)成本太高。

uni-app x 的目标就是:让你继续写 Vue3 + TS 风格的代码,但可以一套工程覆盖 App + 各类小程序 + H5。
因此,对已有 Vue3 项目来说,一个自然的问题就是:

如何在不推倒重来的前提下,尽量平滑地迁移到 uni-app x?

本文将从整体策略、目录结构改造、路由/状态管理适配、组件与 API 替换等方面,给出一套可操作的迁移思路和步骤,并分析过程中可能的坑与注意点。


二、迁移前评估:先搞清楚自己是什么项目

迁移前不要急着动手,先回答几个关键问题:

  1. 当前项目的技术栈

    • 是否使用:Vite、Vue Router、Pinia 或 Vuex、Axios、Element Plus/Ant Design Vue 等?
    • 是否大量使用 DOM 直接操作、windowdocument 等 Web 专属 API?
  2. 业务复杂度与依赖

    • 是否大量依赖第三方 UI 库、图表库(ECharts、AntV)、富文本编辑器、复杂表格等?
    • 是否有强 Web 特性(如 iframe、浏览器插件接口、localStorage 逻辑等)?
  3. 迁移目标平台

    • 必须支持哪些:App(iOS/Android)/ 微信小程序 / 其他小程序 / H5?
    • 是否对某些平台有特别强的能力诉求(如推送、蓝牙、相机、文件系统)?
  4. 时间和人力约束

    • 能不能接受一段时间的「双线维护(旧 Web + 新 uni-app x)」?
    • 是否有安卓/iOS 原生同事能协助插件层能力?

根据评估结果,可以大致判断:

  • 适合重用大量业务逻辑,只做外壳改造
  • 还是必须进行较重的架构重构(比如完全脱离 DOM 思维)。

三、迁移总体策略:不要一下子「全搬」,而是分层解耦

从纯 Web(Vue3 SPA)到 uni-app x,本质上是从:

Vue3 + Router + Web DOM + 浏览器特性
          ↓
Vue3 + uni-app x 组件体系 + 多端(App/小程序/H5

迁移的关键策略是:

先把“与平台强绑定”的部分(路由、UI、API)剥离出来,把“与业务有关”的逻辑、数据、服务层抽出来复用。

可以按「三层架构」来思考:

  1. 业务逻辑层(可高度复用)

    • 接口请求封装(API Service)
    • 业务状态管理(Pinia Store)
    • 领域模型与工具函数(utils, hooks)
  2. 页面 & 组件层(部分复用,需要适配)

    • 原有的 .vue 页面可以搬过去,但需要调整:

      • DOM 标签 -> uni-app 组件(div -> viewspan -> text 等)
      • UI 库替换或重构(Element Plus -> 移动端自定义 UI / uni UI 等)
  3. 基础设施层(需重构)

    • 路由:Vue Router -> uni-app 页面路由机制
    • 运行环境:浏览器 -> 多端运行(小程序/App/H5)
    • 全局入口:main.ts -> App.vue + pages.json

四、实际迁移步骤:从创建 uni-app x 项目开始

4.1 步骤 1:新建一个 uni-app x 项目骨架

使用 HBuilderX 或 CLI 创建一个 uni-app x 项目(以 CLI 为例,命令以官方最新文档为准,下面用伪示例):

# 假设已有相关 CLI 工具
npx degit dcloudio/uni-app-x-starter my-uniappx-app
cd my-uniappx-app
pnpm install # 或 npm/yarn

项目结构通常类似:

my-uniappx-app
├─ src
│  ├─ pages
│  │  └─ index
│  │     └─ index.vue
│  ├─ App.vue
│  ├─ main.ts
│  └─ ...
├─ pages.json
├─ manifest.json
└─ ...

先跑通基础项目(例如 H5 或 App 模拟器),确保环境与编译没问题。

4.2 步骤 2:抽取原项目的「可复用业务层」

在原 Vue3 项目中,重点抽离这些:

  1. services/api/:接口封装
  2. stores/:Pinia 或 Vuex
  3. utils/:通用工具函数
  4. 纯 TS/JS 模块:与平台无关的业务逻辑

将它们复制到新项目的 src/shared/(或任意你喜欢的目录名),例如:

src
├─ shared
│  ├─ api
│  │  └─ user.ts
│  ├─ stores
│  │  └─ user.ts
│  ├─ utils
│  │  └─ date.ts
│  └─ types
│     └─ user.ts
├─ pages
│  └─ index
│     └─ index.vue
└─ ...

4.2.1 网络请求封装适配

如果原来使用 axios,有两种做法:

  • 做一个轻薄的适配层:内部根据运行环境调用 uni.requestfetch
  • 或者直接改用 uni.request + 自己封装

示例(简化版):

// src/shared/api/request.ts
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'

interface RequestOptions<T = any> {
  url: string
  method?: HttpMethod
  data?: Record<string, any>
  headers?: Record<string, string>
}

export function request<T = any>(options: RequestOptions): Promise<T> {
  const { url, method = 'GET', data, headers } = options

  return new Promise((resolve, reject) => {
    uni.request({
      url,
      method,
      data,
      header: headers,
      success: (res) => {
        // 根据你后端返回格式处理
        const data = res.data as any
        if (data.code === 0) {
          resolve(data.data as T)
        } else {
          reject(new Error(data.message || 'Request error'))
        }
      },
      fail: (err) => {
        reject(err)
      }
    })
  })
}

原来用 axios.get('/user') 的地方,就改成使用这个 request 封装。

4.2.2 状态管理:Pinia 基本可以直接复用

uni-app x 基于 Vue3,使用 Pinia 通常是可行的。只需在 main.ts 中按 Vue3 方式挂载:

// main.ts(uni-app x 项目)
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'

export function createApp() {
  const app = createSSRApp(App)
  const pinia = createPinia()
  app.use(pinia)
  return { app }
}

原项目的 Pinia store 代码几乎可以原样搬过来,如:

// src/shared/stores/user.ts
import { defineStore } from 'pinia'
import type { UserInfo } from '../types/user'
import { fetchUserInfo } from '../api/user'

export const useUserStore = defineStore('user', {
  state: (): { info: UserInfo | null } => ({
    info: null
  }),
  actions: {
    async loadUser() {
      this.info = await fetchUserInfo()
    }
  }
})

在 uni-app x 的页面里正常使用即可:

import { useUserStore } from '@/shared/stores/user'

const userStore = useUserStore()
userStore.loadUser()

4.3 步骤 3:重构路由结构:Vue Router -> pages.json

原 Vue3 SPA 中典型的路由配置大致是:

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import User from '@/views/User.vue'

const routes = [
  { path: '/', name: 'Home', component: Home },
  { path: '/user', name: 'User', component: User }
]

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

export default router

在 uni-app x 中,不使用 Vue Router 管理页面路由,而是:

  • pages.json 声明页面;
  • 使用 uni.navigateTo / uni.redirectTo / uni.switchTab 等 API 进行跳转。

例如:

// pages.json
{
  "pages": [
    {
      "path": "pages/home/index",
      "style": {
        "navigationBarTitleText": "首页"
      }
    },
    {
      "path": "pages/user/index",
      "style": {
        "navigationBarTitleText": "我的"
      }
    }
  ],
  "tabBar": {
    "color": "#666666",
    "selectedColor": "#007aff",
    "borderStyle": "black",
    "backgroundColor": "#ffffff",
    "list": [
      {
        "pagePath": "pages/home/index",
        "text": "首页",
        "iconPath": "static/tab-home.png",
        "selectedIconPath": "static/tab-home-active.png"
      },
      {
        "pagePath": "pages/user/index",
        "text": "我的",
        "iconPath": "static/tab-user.png",
        "selectedIconPath": "static/tab-user-active.png"
      }
    ]
  }
}

跳转示例:

// 在页面脚本中
const goUser = () => {
  uni.navigateTo({ url: '/pages/user/index' })
}

如果你原来大量依赖「编程式路由 + 命名路由 + 路由守卫」,需要:

  • 全局守卫逻辑(如登录校验)转移到:

    • 页面生命周期(onLoadonShow)里做校验;
    • 或封装为导航函数:goUserPage() 里统一判断登录态。
  • 路由参数route.params / route.query 改到:

    • onLoad((options) => {...}) 中的参数;
    • 或通过 uni.navigateTo({ url: '/pages/detail/index?id=123' }) 传 query。

4.4 步骤 4:页面 & 组件改造:从 DOM -> uni-app 组件体系

这是最费时的部分,但也是「迁移成败的关键」。

4.4.1 基础标签替换

常见的替换规则(示意):

Web (Vue3) 标签 uni-app 推荐标签 说明
div view 通用容器
span text 行内文本
img image 图片,支持多端能力
a navigator / view+跳转 页面跳转,用 uni.navigateTo
button button(uni 组件) 支持表单、权限等能力
input input(uni 组件) 不同平台封装
textarea textarea 多行输入

示例:原 Web 代码(简化):

<template>
  <div class="card" @click="goDetail(item.id)">
    <img :src="item.cover" class="cover" />
    <div class="info">
      <span class="title">{{ item.title }}</span>
      <span class="desc">{{ item.desc }}</span>
    </div>
  </div>
</template>

迁移到 uni-app x:

<template>
  <view class="card" @click="goDetail(item.id)">
    <image :src="item.cover" class="cover" mode="aspectFill" />
    <view class="info">
      <text class="title">{{ item.title }}</text>
      <text class="desc">{{ item.desc }}</text>
    </view>
  </view>
</template>

提示:

  • 避免使用原生 DOM 相关 API(document.querySelector 等),改为 Vue 的响应式 + uni 组件能力。
  • 样式方面继续使用 rpx、flex 等,但要注意小程序与 H5 对部分 CSS 特性的支持差异。

4.4.2 UI 组件库的处理

如果原项目使用了 Element Plus / Ant Design Vue / View UI 等「PC Web UI 库」,一般:

  • 不建议直接迁移:这些 UI 库大多为 PC/H5 设计,不适合 App/小程序体验和尺寸;

  • 建议:

    • 为移动端重新选择 uni-app/uni-app x 生态内的 UI 库(如 uView、uni-ui 等,看后续对 x 的适配);
    • 或自行封装一套轻量 UI 组件库(Button、Cell、List、Dialog、Toast 等)。

迁移策略:

  1. 先识别项目中常用 UI 组件类型:表单、列表、弹窗、Tabs、Drawer 等;

  2. 在 uni-app x 项目中统一封装一层「业务 UI 组件库」,即便内部暂时用原生 view + text 拼:

    • 例如 src/components/base/Button.vueDialog.vue 等;
  3. 业务页面只依赖这套「业务 UI 组件库」,未来要换实现也方便。


4.5 步骤 5:平台相关 API 替换:window/document -> uni.*

原来的 Vue3 Web 项目,常见这些写法:

  • window.localStoragesessionStorage
  • window.location
  • document.title = 'xxx'
  • 监听 window.addEventListener('resize', ...)

在 uni-app x 里,要换成跨端封装的方式,例如:

  1. 本地存储

    • 使用 uni.setStorageSync / uni.getStorageSync
    • 封装一个 storage 工具:
    // src/shared/utils/storage.ts
    const TOKEN_KEY = 'TOKEN'
    
    export function setToken(token: string) {
      uni.setStorageSync(TOKEN_KEY, token)
    }
    
    export function getToken(): string | null {
      const t = uni.getStorageSync(TOKEN_KEY)
      return t || null
    }
    
    export function clearToken() {
      uni.removeStorageSync(TOKEN_KEY)
    }
    
  2. 页面标题

    • pages.json 通过 navigationBarTitleText 设置;
    • 或调用:uni.setNavigationBarTitle({ title: 'xxx' })
  3. 窗口尺寸与滚动监听

    • 使用 uni.getSystemInfo / uni.onWindowResize(不同端支持情况略有差异,要查文档);
    • 滚动监听通过 scroll-view / 页面滚动事件实现,而非直接 DOM 监听。

4.6 步骤 6:分阶段验证与发布策略

不要等「全项目迁移完」才开始验证,多阶段、小步快跑更靠谱:

  1. 阶段 1:最小可运行版本

    • 至少有 1–2 个核心页面在 uni-app x 中可运行(H5 & 小程序/App 模拟器都跑通);
    • 关键业务流程走通(登录 -> 主页 -> 某个主要业务)。
  2. 阶段 2:模块化迁移

    • 按业务模块迁移,例如「用户中心模块」「订单模块」;
    • 每迁移完成一个模块,就在测试环境整体验证。
  3. 阶段 3:灰度发布与 AB 测试(如果条件允许)

    • 对移动端入口,逐渐导入一部分用户到新 uni-app x 客户端或小程序;
    • 收集性能表现、崩溃率、用户反馈。
  4. 阶段 4:旧 Web 项目收缩职责

    • 慢慢把纯 Web SPA 项目的核心功能剥离,只留下必要的 PC Web 功能;
    • 移动端流量逐步切到 uni-app x 提供的多端入口。

五、迁移过程中的常见坑与应对

5.1 拼命想要「完全复用」原模板代码

很多同学迁移之初,会希望 .vue 页面一个字都不要改地搬过来,这通常是做不到的,主要原因:

  • 标签体系不同:div/spanview/text 的语义和能力不同;
  • CSS 差异:小程序端对部分 CSS 支持不全;
  • DOM API 不存在:uni-app 环境下没有真实 DOM。

建议
接受「逻辑可以高复用,UI 层需要适配」这个事实,提前预估这部分工作量。

5.2 忽略小程序/App 端的权限与能力差异

  • 比如:文件下载、打开外链、支付、登录态管理,在小程序/App/H5 上都有差异;
  • 不要把它们揉在一起写,建议封装为:
// src/shared/utils/platform.ts
export function isWeixinMiniProgram(): boolean {
  // 参考 uni-app 平台判断写法
  // #ifdef MP-WEIXIN
  return true
  // #endif
  return false
}

再在业务逻辑里按平台区分处理。
有条件可以统一封装 service:例如 pay(order) 内部再根据平台调用不同实现。

5.3 图表、富文本等第三方库的适配

  • ECharts/AntV 等在小程序 & App 端需要专门的 Canvas/组件适配;
  • 富文本编辑器在移动端、小程序生态差异很大。

建议

  • 优先搜寻「uni-app/uni-app x 生态中已有的适配方案或组件库」;
  • 实在没有,考虑为 App 和小程序端写专门版本,或者功能做轻量降级。

六、总结:从 Vue3 到 uni-app x 的核心经验

整体回顾:

  1. 不要从“Vue3 -> uni-app x”直接想,而是从「Web-only -> 多端」的角度思考

  2. 成功迁移的关键在于:

    • 抽离业务逻辑层(API、Store、Utils),尽可能与平台解耦;
    • 重构 UI 与路由层,接受一定程度的「模版和样式重写」;
    • 使用 uni-app/uni-app x 提供的跨端 API 替代浏览器专属能力。

对大多数中小团队来说,迁移的回报是:

  • 从一个只能跑在浏览器里的 Vue3 SPA,升级成一套可覆盖 App + 小程序 + H5 的多端应用;
  • 在后续需求演进中,「新平台支持」会变成「配置和适配问题」,而不是「新项目问题」。

uni-app x 发展前景技术分析:跨端统一的新阶段?

一、引言:从「一套代码多端运行」到「真正的跨端统一」

过去几年,前端与移动开发领域围绕「跨端」已经卷了很多轮:

  • 从传统 H5 + WebView 的 Hybrid 方案
  • 到 React Native、Weex 这类 JS + 原生渲染
  • 再到 Flutter、Hippy 等自绘 UI 引擎
  • 以及 uni-app、Taro、mpx 等小程序多端框架

uni-app x 是 DCloud 在 uni-app 基础上的一次「重构级」升级,目标并不是简单地再做一个多端框架,而是通过统一渲染引擎、统一语法能力、强化原生性能,在 Web、小程序、App 原生等多端之间真正实现「同一套代码,体验接近原生」。

它试图解决的核心问题有三个:

  1. 性能瓶颈:传统 uni-app(基于 WebView 渲染)在复杂交互、动画、长列表场景下性能受限。
  2. 多端差异:各家小程序、App 端能力差异大,开发者频繁写条件分支、做兼容。
  3. 技术演进:在 Vue3、Vite、TypeScript、原生生态更新的背景下,原有体系的扩展性不足。

本文将围绕 uni-app x 的技术特点、实现思路和实际使用体验,分析它的技术路线、优缺点以及未来发展前景,并给出一些落地建议。


二、背景与问题:传统 uni-app 与跨端方案的痛点

2.1 传统 uni-app 的架构与局限

传统 uni-app(下文称 uni-app classic)构建在以下基础之上:

  • 开发语言:Vue2(支持 Vue3 但生态偏 Vue2)

  • 运行环境:

    • H5 端:标准 Web 环境
    • 小程序端:编译为各家的小程序语法(微信/支付宝/抖音等)
    • App 端:基于 WebView(plus/webview)+ 原生能力(plus.* API)
  • 渲染方式:以 WebView 为主,JS 代码跑在 JS 引擎中,UI 通过 DOM/CSS 渲染

这套方案的优点是:

  • 成本低:基于成熟 HTML/CSS/JS 能力
  • 适配广:可以覆盖各种小程序、H5 和 App
  • 生态丰富:大量 uni-app 组件、插件、uView、uCharts 等库可用

但也不可避免地存在这些痛点:

  1. App 端性能不足

    • 复杂动画掉帧明显
    • 列表滚动、滚动吸顶、骨架屏等体验不够丝滑
    • 与 Flutter、原生 App 相比差距明显
  2. 多端差异仍然明显

    • 不同小程序的组件和 API 差异大,框架做了统一封装,但边缘场景仍要写 #ifdef
    • 部分平台独占能力无法平滑兼容(如某些支付、推送、系统级能力)
  3. 架构年代感

    • 早期设计受 Vue2 + WebView 限制,面对 Vue3、Composition API、TypeScript 深度整合时显得笨重
    • App 端想要引入更接近 Flutter、RN 的渲染机制时阻力较大

在这样的背景下,仅对 uni-app 做增量优化,很难带来质的提升。于是有了uni-app x


三、uni-app x 的核心技术思路与实现

注意:官方在迭代中可能持续更新名字与特性,以下基于公开资料与通用跨端技术趋势做结构化解读,重点在技术路径而非具体版本号。

3.1 uni-app x 的总体目标

可以概括为:

在保持 uni-app「一套代码、多端覆盖」优势的前提下,引入更接近原生和 Flutter 的渲染性能与开发体验。

具体体现为:

  • 渲染层:引入统一的跨端渲染引擎(非简单 WebView DOM)
  • 语法层:向 Vue3、TS 友好,优化开发体验
  • 能力层:加深与原生能力的集成,降低“JS 调原生”的心智和性能成本

3.2 架构:从 WebView 到「跨端渲染引擎」

传统 uni-app App 端架构简化可以写成:

Vue (JS) --> WebView (HTML/CSS) --> plus.* 原生能力

而 uni-app x 的典型跨端架构更接近:

Vue/TS (JS) --> 虚拟 DOM / Fiber 层 --> 跨端渲染引擎 --> 原生控件 / 自绘渲染

其中关键点在于:

  1. 虚拟 DOM 与 UI 描述从 DOM 解耦

    • 你的 template 不再仅仅是 HTML 的映射,而是抽象 UI 树
    • 渲染引擎可以根据平台把这棵 UI 树映射为原生控件树或自绘视图树
  2. 渲染引擎负责平台差异

    • 在 Android 上可以使用 RecyclerViewViewGroup 等组合
    • 在 iOS 上使用 UIViewUICollectionView 等组合
    • 在 Web/H5 上降级为 DOM 渲染
    • 在小程序中映射为其自有组件系统
  3. JSBridge 优化

    • 通过批量 diff 更新、异步队列减少「JS <-> Native」的频繁通信
    • 典型方案类似 RN、Flutter 的 batch update / message queue 模型

一个抽象的 UI 渲染过程示意

flowchart LR
  A[Vue 组件] --> B[模板编译 & 响应式系统]
  B --> C[虚拟 UI 树 (VNode)]
  C --> D[Diff & Patch 层]
  D --> E[跨端渲染引擎]
  E --> F1[Android 原生控件]
  E --> F2[iOS 原生控件]
  E --> F3[Web DOM / Canvas]
  E --> F4[小程序组件]

3.3 基于 Vue3 + Composition API + TypeScript

为了更好地支持下一代前端生态,uni-app x 通常会以Vue3 生态为优先,例如:

  • script setup 语法
  • Composition API(refreactivecomputed 等)
  • TypeScript 类型推导与 IDE 支持
  • Vite / Rollup 构建体系

示例(伪代码,结构风格接近实际 uni-app x):

<template>
  <view class="page">
    <view class="header">
      <text class="title">uni-app x Demo</text>
    </view>
    <scroll-view class="list" scroll-y>
      <view v-for="item in list" :key="item.id" class="list-item">
        <image :src="item.cover" mode="aspectFill" class="cover" />
        <view class="content">
          <text class="name">{{ item.name }}</text>
          <text class="desc">{{ item.desc }}</text>
        </view>
      </view>
    </scroll-view>
    <button class="fab" @click="addItem">新增一条</button>
  </view>
</template>

<script setup lang="ts">
import { ref } from 'vue'

interface Item {
  id: number
  cover: string
  name: string
  desc: string
}

const list = ref<Item[]>([
  { id: 1, cover: '/static/cover1.png', name: '示例 1', desc: '说明文字 1' },
  { id: 2, cover: '/static/cover2.png', name: '示例 2', desc: '说明文字 2' }
])

let id = 3

const addItem = () => {
  list.value.push({
    id: id++,
    cover: '/static/cover-new.png',
    name: `示例 ${id}`,
    desc: `新增的说明文字 ${id}`
  })
}
</script>

<style scoped>
.page {
  flex: 1;
  background-color: #f5f5f5;
}
.header {
  padding: 20rpx 30rpx;
  background-color: #007aff;
}
.title {
  color: #fff;
  font-size: 34rpx;
  font-weight: 600;
}
.list {
  height: calc(100vh - 100rpx);
}
.list-item {
  display: flex;
  padding: 20rpx;
  margin: 20rpx;
  border-radius: 16rpx;
  background-color: #fff;
}
.cover {
  width: 160rpx;
  height: 160rpx;
  border-radius: 12rpx;
}
.content {
  flex: 1;
  margin-left: 20rpx;
}
.name {
  font-size: 32rpx;
  font-weight: 500;
}
.desc {
  margin-top: 10rpx;
  font-size: 26rpx;
  color: #999;
}
.fab {
  position: fixed;
  right: 40rpx;
  bottom: 80rpx;
  width: 120rpx;
  height: 120rpx;
  border-radius: 60rpx;
  background-color: #ff9500;
  color: #fff;
}
</style>

上述代码在 uni-app x 下,可以被编译到多端,底层由新的渲染管线完成实际 UI 构建,性能会优于传统 WebView DOM 渲染。

3.4 原生能力与插件生态的强化

uni-app 一大优势在于 App 端可通过 native.js、原生插件、plus.* API 接入本地能力。uni-app x 的发展方向主要包括:

  1. 统一原生插件模型

    • 提供一套更现代、更 TS 友好的插件接口定义
    • 把原生模块能力映射为 JS/TS 中的模块(类似 RN 的 Native Module)
  2. 降低 JS/Native 交互成本

    • 通过序列化策略、批量调用机制减少开销
    • 部分高频能力(如滚动监听、手势)下沉到引擎内部执行,JS 只关心结果事件
  3. 与操作系统特性同步

    • 支持最新的 Android/iOS SDK 能力:权限、拍照相册、多媒体、蓝牙、NFC 等
    • 深度集成推送、剪贴板、文件系统、通知等系统级功能

一个典型的调用示例(伪代码):

import { useCamera } from '@dcloudio/uni-camera-x'

const camera = useCamera()

const takePhoto = async () => {
  try {
    const res = await camera.capture()
    console.log('photo path: ', res.path)
  } catch (e) {
    console.error('capture error: ', e)
  }
}

底层由 uni-app x 的 Native 模块完成设备调度、权限检查等逻辑,开发者保持较为统一的业务代码。


四、技术优缺点分析与实践建议

4.1 优点分析

4.1.1 性能和体验更接近原生

  • 列表滚动、复杂动画更流畅:

    • 非 DOM 渲染,更多使用平台原生控件或自绘,减少中间层
    • 布局计算、绘制更贴合平台原生 pipeline
  • 减少 WebView 局限

    • 无需处理部分 WebView bug(如某些机型滚动抖动、输入法遮挡)
    • 更容易做像素级布局控制

在对性能和体验敏感的场景(如内容信息流、IM、互动页面)中,是明显优势。

4.1.2 统一多端语法和能力,提高复用度

  • 更统一的组件体系:viewtextimage 等基础组件在 App、小程序、H5 端表现更一致。
  • 统一的 API 接口:如网络、存储、路由、系统能力等,对多端做了封装与降级处理。
  • 通过工程化与插件系统扩展能力,用「一套工程」覆盖更多平台。

对于中大型团队而言,这可以显著降低「端上差异」导致的维护成本。

4.1.3 紧跟 Vue3/TS 等前端主流技术

  • script setup 结构更简洁
  • TS 类型与 IDE 支持让业务代码更可靠
  • 可组合 API 便于抽象可复用逻辑(如 useAuthuseRequestuseStore 等)

这对前端工程师极为友好——学习成本低,转化效率高

4.2 缺点与挑战

4.2.1 生态迁移成本与兼容问题

  • 旧的 uni-app 插件与组件库,可能需要适配或升级才可在 uni-app x 上使用。

  • 现有项目如要「无痛迁移」,往往做不到,需有一段双线维护期:

    • 一条线继续用 uni-app classic 维护现网
    • 一条线尝试 uni-app x 做新需求或新版本

建议

  • 新项目可直接评估是否上 uni-app x。
  • 旧项目要做详细成本评估再决定是否迁移,特别是插件依赖多的项目。

4.2.2 学习与调试心智负担

  • 虽然对开发者暴露的是 Vue 语法,但底层已经不是 DOM,部分 Web 习惯(如某些 CSS 特殊写法、DOM API)可能不再适用,需要重新理解:

    • 例如:不能直接使用 document.getElementById 之类 Web API
    • 某些 CSS 特性支持情况与 Web 有差异
  • 调试工具链、性能分析工具需要适应新的渲染架构,前期资料和社区经验积累可能不足。

4.2.3 与 Flutter/React Native 的竞争与对比

从技术路线看,uni-app x 与 Flutter/RN 在 App 端开发上有一定「竞合」关系:

  • Flutter 优点:性能极佳、自绘 UI、一套 Dart 代码多端

  • RN 优点:React/JS 生态强大,Facebook 维护

  • uni-app x 优点

    • 对中国小程序生态有较好支持
    • Web 和各端小程序的多端覆盖能力比 Flutter/RN 更强
    • 使用 Vue/TS,对已有 uni-app/Web 开发者更友好

但在极致性能场景(例如 3D 游戏、复杂图形渲染)上,Flutter/RN 仍有明显优势。uni-app x 的定位更像是:

以业务应用、内容应用、企业级应用为主,追求「足够好」的原生体验 + 极高的多端复用效率。

4.3 实际应用场景与选型建议

4.3.1 推荐使用 uni-app x 的场景

  • 新立项的中大型业务 App,需要同时支持:

    • 至少一个主流 App 应用(Android/iOS)
    • 1–2 个小程序(例如微信 + 支付宝)
    • H5 备用入口
  • 需要较好性能但不追求极致游戏级体验的场景:

    • 内容资讯流、社交/社区 App
    • 电商、教育类应用
    • 企业内部管理、SaaS 移动端
  • 团队已有 uni-app/Vue 前端基础,希望升级到更现代技术栈,同时长期维护一个统一代码仓库。

4.3.2 谨慎或暂缓采用的场景

  • 已有一个成熟、复杂度极高且紧耦合原生的 uni-app classic 项目:

    • 如有大量原生插件,且当前方案已相对稳定
    • 短期主要目标是维护与小改,非重构
  • 极致性能/图形场景:

    • 复杂 3D 场景、游戏、AR/VR
    • 这类场景更建议选用 Flutter + 原生、Unity 等方案
  • 团队整体原生技术占比高,对 Vue/JS 并不熟悉,且未来以原生项目为主。

4.3.3 落地实践步骤建议

  1. 从新功能或新模块试水

    • 不要一上来就全项目迁移
    • 可以选择一个新模块(如新活动、独立子应用)用 uni-app x 开发,验证性能与工程体验
  2. 抽象跨项目可复用的基础层

    • 例如:

      • UI 规范封装成组件库:按钮、导航栏、卡片、弹窗
      • 通用 hooks:useRequestuseUseruseEnv
      • 网络、日志、埋点等基础设施
  3. 原生插件策略

    • 待观察 uni-app x 原生插件市场是否成熟

    • 自研的核心原生插件,设计为「跨框架可复用」:

      • 将底层能力以标准原生 SDK 的形式封装
      • 再针对 uni-app x/Flutter/RN 等封一层适配

五、未来发展前景分析

5.1 技术趋势维度

  1. 跨端框架仍将是刚需

    • 企业不可能在所有平台都养独立团队做纯原生
    • 跨端框架的存在是「成本与效率」的必然平衡
  2. 「新一代渲染架构」会成为主流

    • Flutter 已证明自绘引擎路线可行
    • RN 新架构(Fabric)也在优化 JS-Native 通信
    • uni-app x 引入新渲染引擎顺应这一趋势
  3. Vue3/React + TS 成为前端事实标准

    • uni-app x 若能持续对齐 Vue3 生态,天然享受前端技术红利

5.2 市场与生态维度

uni-app 在国内小程序/H5/App 多端框架中占有量较高,具备以下基础:

  • 丰富的插件市场与生态
  • 大量存量 uni-app 项目与开发者
  • 深度本地化(中文文档、社区、运维支持)

如果 uni-app x 能做到:

  • 提供平滑的迁移路径
  • 持续提升稳定性与性能
  • 保持与各大小程序平台、Android/iOS 新版本的同步升级

那么在未来 3–5 年,在国内多端低门槛应用开发市场中持续占据重要位置是可预期的。

5.3 风险与变数

  • 与 Flutter、RN、Taro 等竞品的技术竞合,会影响其在部分新项目中的选型份额。

  • 需要时间沉淀生态:

    • 第三方组件库、UI 库、数据可视化库对 uni-app x 的适配情况
    • 开发者踩坑经验与文档完善程度

但从目前的发展轨迹和需求结构看,只要官方持续投入,uni-app x 作为 uni-app 体系的「下一代主力」的可能性相当大。


六、结论:uni-app x 的实际价值与未来定位

综合来看,uni-app x 是 DCloud 在 uni-app 基础上的一次架构升级和技术迭代,其核心价值可以概括为:

  1. 性能更好:通过新的跨端渲染架构,更接近原生体验,弥补了传统 uni-app 在 App 端的主要短板。
  2. 技术栈现代化:与 Vue3、TypeScript、Vite 等前端主流技术生态对齐,提升开发效率与可维护性。
  3. 多端统一能力强化:保持了 uni-app 在小程序 + H5 + App 多端统一方面的传统优势,并进一步降低多端差异成本。
  4. 适合中长期项目投资:对于规划 3 年以上生命周期的应用,使用 uni-app x 能在未来兼顾性能、维护成本和团队人才结构。

未来几年,如果你所在的团队:

  • 需要做「一套代码、多端上线」的业务
  • 对体验有一定要求但不追求极致游戏级性能
  • 团队以 Web/Vue 技术栈为主

那么把 uni-app x 纳入你的主流技术选型列表,是一个非常值得考虑的决策。


七、参考资料与延伸阅读(可选)

注:具体链接可能随时间调整,可通过关键词在官网或 GitHub 搜索最新版本。

  1. 官方文档与指南

    • DCloud uni-app 官网:

      • 关键字:「uni-app 官网」「uni-app x 文档」
    • 官方 GitHub 仓库(包含示例与 issue 讨论)

  2. 跨端技术原理与比较

    • 关键词建议:

      • 「跨端框架技术对比:uni-app vs Taro vs Flutter vs React Native」
      • 「JSBridge 通信机制原理」
      • 「跨平台渲染引擎架构(Flutter/RN/Weex)」
  3. Vue3 与 TypeScript 学习资料

    • Vue3 官方文档(中文):关键词「Vue3 文档」
    • TypeScript 官方文档与中文教程:关键词「TypeScript 中文网」
  4. 性能与调试实践

    • 搜索关键字:

      • 「uni-app 性能优化实践」
      • 「Vue3 性能调优」
      • 「移动端长列表优化技巧」

我的 Monorepo 实践经验:从基础概念到最佳实践

本文将系统地整理我在 Monorepo 和前端工程化方面的一些实践经验,先简单介绍几个概念。

Monorepo(单一代码仓库):Monorepo 是将多个应用和共享库放在同一个仓库中进行管理。与多仓库模式相比,Monorepo 更加高效,尤其在代码共享、依赖升级和跨项目协作方面。

工程化:通过规范、工具链和流程,把“能跑”提升到“可维护、可协作、可持续交付”。它覆盖的不只是开发,还包括构建、测试、发布、质量保障等完整生命周期。

Monorepo 里最关键的抽象:应用包与库包

应用包(App)

应用包是最终会被部署的项目,通常放在 apps/*,例如 Web、Admin、Docs、BFF 等。

库包(Package)

库包用于复用能力,通常放在 packages/*,本身一般不直接部署,而是被应用包消费。

在 Monorepo 里,库包常见有三种策略:

  1. 可发布包(Publishable Package)
  2. 预构建包(Compiled Package)
  3. 源码引用包(Source Package)

选择哪种策略取决于具体场景,没有绝对的优劣之分。

三种库包策略介绍

1) 可发布包:面向仓库外复用

如果这个包是要提供给外部团队或开源用户使用,那么应该采用可发布包的方式。

优点:

  • 对外分发标准清晰,边界明确
  • 可独立版本化,兼容性管理更规范

代价:

  • package.json 字段配置更复杂(nameexportstypesfilespublishConfig 等)
  • 可能需要考虑 CJS/ESM 的导出兼容与消费方式(如 import/require、不同 bundler 解析差异)
  • 需要维护发版流程、版本语义和变更记录
  • 在仓库内频繁迭代时,版本与锁文件更新会增加心智负担

2) 预构建包:面向仓库内复用、兼顾稳定与性能

预构建包首先会构建出 dist 文件,应用包再消费这些构建结果。

优点:

  • 应用包构建更加稳定,模块之间的边界更加清晰
  • 减少重复转译,尤其在大仓库中

代价:

  • 需要维护 build 步骤与产物一致性
  • 调试链路比直接使用源码更长

一个典型配置:

{
  "name": "@workspace/utils",
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./dist/index.js"
    }
  },
  "scripts": {
    "build": "tsc"
  }
}

在工作区里引用通常用:

{
  "dependencies": {
    "@workspace/utils": "workspace:*"
  }
}

3) 源码引用包:开发体验优先

源码引用包直接导出 src/*.ts(x),由应用包的构建工具(如 Vite、Webpack等)完成转译。

优点:

  • 配置简单,改完即生效,开发体验好
  • 少一层“先打包再消费”的步骤

代价:

  • 应用包需要承担类型检查和转译的成本
  • 对 TypeScript 配置一致性要求更高

示例:

{
  "name": "@workspace/utils",
  "exports": {
    "./tool": "./src/tool.ts"
  }
}

这种模式通常不需要 build 脚本,但建议保留独立的类型检查脚本(例如 typecheck)。

很多团队最终会混用这三种策略:

  • UI 组件用预构建或源码引用
  • 配置类包(eslint、tsconfig)走源码引用
  • SDK 或公共能力包走可发布流程

如何初始化一个靠谱的 Monorepo

最好的学习方法就是模仿。可以参考 Turborepo Getting Started 页面。提供的示例

例如,Kitchen Sink中的 @repo/ui 采用了预构建包的方式。如果你想使用预构建包,可以重点查看 apps/admin 是如何消费它的。示例中还展示了 eslint-config 和 tsconfig 这类公共配置包的使用。不同包通过继承 base 配置,既能保证代码风格一致,也能针对不同项目做细化适配(比如规则微调或插件加载)。

如果你更倾向于使用源码引用包,可以参考Vite + React 示例。这个例子里的 @repo/ui 采用的就是源码引用包。

另外,Turborepo 还有一个面向 AI 的 best-practices/RULE.md,也可以读一读:链接

因为 Turborepo 本身就是 Monorepo 的任务编排工具,所以它的文档和示例质量都很高,值得反复参考。

共享 tsconfig.json:把配置做成一个包

在 monorepo 中创建一个共享的 tsconfig 配置包(比如放在 packages/typescript-config 里)是一个常见的做法:把一些通用的 TypeScript 配置写在基础配置(base.json)里,然后让各个项目(比如 Next.js 应用、库项目等)通过 extends 引用这个基础配置。这样整个仓库能有一致的 TS 设置。

packages/typescript-config 可以声明一个 package.json

{
  "name": "@repo/typescript-config"
}

关于 tsconfig references

在 Monorepo 中,很多人都会接触到 TypeScript 的 references 配置。Turborepo 官方建议,大多数情况下不需要使用 TypeScript 项目引用。

参考: You likely don’t need TypeScript Project References

它会引入额外的配置和缓存层,这可能会在使用 Turborepo 时带来问题,且很少能带来实际的好处。

具体来说:

  • 额外的配置:使用 TypeScript 项目引用时,你需要在不同的项目之间配置相应的 tsconfig.json 文件,这增加了配置的复杂度。

  • 额外的缓存层:TypeScript 项目引用为每个项目生成独立的构建输出,需要将缓存目录配置到.gitignore中,turbo.json中。

但在某些特定场景下,如 Hono RPC 的前后端类型联动时,项目引用非常必要。 参考:

如果你使用了源码引用包,建议统一关键编译选项(如 modulemoduleResolution),避免跨包解析不一致。

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler"
  }
}

references 示例:

{
  "references": [{ "path": "../packages/utils/tsconfig.json" }]
}

关于 package imports(子路径导入)

在编写子包代码时,可以在 tsconfig 中使用 compilerOptions.paths 来创建别名。然而,这个别名只在当前 TypeScript 配置上下文中有效,不会被其他包自动读取。

如果你使用 TypeScript 5.4+,推荐使用 Node.js 的子路径导入(imports)来替代 TypeScript 的路径别名,在 package.json 内编写:

{
  "imports": {
    "#*": "./src/*"
  }
}

那源码中可以这样引用自己的文件:

import { MY_STRING } from "#utils.ts"; // Uses .ts extension
export const Button = () => {
  return <button>{MY_STRING}</button>;
};

通过这种方式,可以在模块内部使用子路径导入,不会受 TypeScript 配置的限制。这里的imports 主要解决包内部如何引用,exports 主要解决包对外暴露什么。也就是说,跨包消费还是走包名和 exports,而不是把 imports 当成跨包 alias。

这种模式下要注意导入路径和产物格式保持一致(例如编译包需要使用 .js 后缀)。

关于跨包“跳转到定义”的实现

在 Monorepo 项目中,多个包通常是彼此依赖的。如果你希望在 IDE(如 VSCode)中通过“跳转到定义”功能,在不同包之间轻松导航(例如,从 ui 包跳转到 utils 包中的代码),需要进行一些配置,以确保不同包之间的 TypeScript 类型信息能够正确链接和识别。

对于预构建包,当包已经编译后,跳转到定义的功能通常不会直接跳转到源代码。例如,点击一个 A.js 的导出,编辑器将跳转到 dist 文件夹中的生成代码,而不是源代码。为了确保跳转功能正常工作,需要在 TypeScript 配置文件中启用 declarationdeclarationMap 选项。这样生成的 .d.ts(类型声明文件)和 .d.ts.map(源映射文件)就能帮助编辑器找到原始的 TypeScript 源代码。

配置示例:

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true
  }
}

我个人对这个问题还有另一种替代方法,可以通过配置 allowImportingTsExtensionsrewriteRelativeImportExtensions 来解决,同时改成源码引用包。启用这两个选项后,编辑器会识别并允许在代码中显式地使用 .ts 扩展名进行模块导入,这样跳转功能会直接指向原始的 TypeScript 源代码。代码里原有的import ’./A.js' 也可以改成import ’./A.ts'了。

相关配置如下:

{
  "compilerOptions": {
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true
  }
}

配置项解释:

  • allowImportingTsExtensions:此配置项允许你在导入模块时显式使用 .ts 扩展名。默认情况下,TypeScript 会自动忽略文件扩展名,但启用此选项后,你可以在 import 语句中明确指定 .ts 扩展名。

  • rewriteRelativeImportExtensions:此配置项使得 TypeScript 在生成 JavaScript 代码时,会自动将相对路径导入的 .ts.tsx 扩展名重写为 .js 扩展名。这样,在 TypeScript 代码中使用 .ts 扩展名导入文件时,最终生成的 JavaScript 代码会使用 .js 扩展名,从而确保路径的兼容性。

通过这些配置,开发者可以更方便地在不同包之间进行跳转,提升开发效率。

用Turborepo 管理Package Graph 和 Task Graph

在一个大型项目中,或者跨语言项目中,可能会有很多命令。不同的包可能有自己的build命令,dev命令,lint命令,test命令,类型检查命令等等。这些包之间,可能还有依赖关系。一个命令以另一个命令完成为前提,而Turborepo可以很好的完成,这一节我们展示如何用 Turborepo 进行管理。

Package Graph(包图)

Turborepo 自动从你的 Monorepo 结构和各个子包的 package.json 里找出来的依赖关系图。比如你有一个 apps/web 应用,它依赖两个库 packages/uipackages/utils,Turborepo 就会把这些关联“连成线”,构成一个图,形成所有包之间的依赖网络。这个图是 Task Graph 的基础。

Task Graph(任务图)

任务图是 Turborepo 通过你的 turbo.json 配置和上面那个 Package Graph 从你的任务定义里构建出来的一个 有向无环图。节点(node)是任务(比如 build、lint、test),边(edge)表示任务之间的依赖关系——也就是 “这个任务要等另一个任务跑完才能运行”。

如果一个任务(比如 build)在 turbo.json 里写了 dependsOn: ["^build"],这就表示“在当前包的 build 任务之前,先跑掉所有它依赖的包的 build 任务”。这种依赖关系会被表示成一条从依赖任务指向当前任务的边。

例如,执行apps/web 应用的build命令之前,会先运行 packages/uipackages/utilsbuild命令。Turborepo 还有自己的缓存策略,通过指定任务的inputsoutputs,它可以观察文件是否改动,如果没有改动,就可以直接跳过这个步骤。同时,这两个任务还可以最大程度地并行化执行,提升构建效率。

此外,任务还有分类,如持续任务,可以将任务声明为"persistent": true。一些持续任务可能还需要另一个任务始终同时运行,如后端服务器,亦或者是路由库的router-cli,通过with字段可以设置自动启动。一些任务即使非TS语言仓库,也支持加入到Turborepo的任务流中。

Turborepo还支持使用tui在一个终端内同时查看所有日志并与任务进行交互。

Turborepo 能够提高构建与任务运行的效率,通过并行执行、缓存命中等优化手段加速你的 Monorepo 工作流。更多相关内容,请参考Crafting your repository - Configuring tasks

Catalog 目录协议

在 Monorepo 中,使用相同的依赖项版本非常常见。通过 pnpm-workspace.yaml 中的 Catalog 协议,我们可以减少依赖的重复并保持一致性:

  • 维护唯一版本 - 我们通常希望在工作空间中共同的依赖项版本一致。 Catalog 让工作区内共同依赖项的版本更容易维护。 重复的依赖关系可能会在运行时冲突并导致错误。 当使用打包器时,不同版本的重复依赖项也会增大项目体积。
  • 易于更新 — 升级或者更新依赖项版本时,只需编辑 pnpm-workspace.yaml 中的目录,而不需要更改所有用到该依赖项的 package.json 文件。
  • 减少合并冲突 — 由于在升级依赖项时不需要编辑 package.json 文件,所以这些依赖项版本更新时就不会发生 git 冲突。

如果你使用的pnpm,可以参考这个文档,如果是bun,则可以参考这个文档

以 pnpm 管理的 workspace 为例,它是这么使用的:

pnpm-workspace.yaml 中定义:

packages:
  - packages/*

# 定义目录和依赖版本号
catalog:
  react: ^18.3.1
  redux: ^5.0.1
{
  "name": "@example/app",
  "dependencies": {
    "react": "catalog:",
    "redux": "catalog:"
  }
}

Enginue 和包管理器配置

在 Monorepo 中使用合适的包管理器配置是至关重要的。尤其是在使用 pnpm 时,可以指定运行的 Node 版本以及 pnpm 的版本,确保包管理器和 Node.js 的版本一致,避免版本不兼容的问题。

{
  "engines": {
    "node": ">=10",
    "pnpm": ">=9"
  },
  "packageManager": "pnpm@9.3.0"
}

在本地开发时, 如果其版本与 engines 字段中指定的版本不匹配,pnpm 将始终失败并报错。

与之相对应的,还有一个pnpm-workspace.yaml的配置字段nodeVersion,当同时设置了 engine-strict=true 时,npm 会在安装包时检查你的 Node.js 版本是否大于或等于设置的版本范围(应当填精确的语义化版本号),如果不符合,安装会被拒绝。例如,当开发公共包时,设置这个选项可以保证不安装不支持特定node版本的依赖。参见链接

nodeVersion: 22.22.0
engineStrict: true

还有一个就是如果你使用了nvm配置,有时候项目根部会有一个.nvmrc文件来指定版本,这样在该目录下唤起Node时,会自动启动相应版本的Node。 例如可以设置useNodeVersion: 16.16.0。pnpm 将自动安装指定的 Node.js 版本,并使用它来运行pnpm run命令pnpm node。参见链接

Monorepo 的 hoist

Hoisting(提升)是指在安装依赖时,某些依赖会被提升到 node_modules 的顶层(根目录)。这种行为确保了在整个项目中可以共享某些常用的依赖包,而不是每个子包都单独安装一份。这有助于避免重复安装相同版本的依赖,减少磁盘空间的占用。

在 npm 和 yarn 中,依赖项的 hoisting 行为通常是自动的。当你安装依赖时,它们会根据包的依赖关系被扁平化,并被提升到 node_modules 根目录中。在 pnpm 中,依赖不会像在 npm 或 yarn 中那样自动扁平化,而是根据每个包的依赖结构创建嵌套的 node_modules 目录。

转存失败,建议直接上传图片文件

默认情况下,pnpm 创建一个半严格的 node_modules,所有依赖项都会被提升到 node_modules/.pnpm/node_modules。这使得 node_modules 中的所有包都可以访问未列出的依赖项,而 node_modules 之外的模块不行。通过这种布局,大多数的包都可以正常工作。

但是也会有一些不能正常工作例外,但你可以通过配置来控制。这种情况下,可能需要设置publicHoistPattern属性。命中的模块,会安装到根模块目录node_modules中。例如,之前版本的pnpm的默认配置['types', 'eslint', '@prettier/plugin-*', 'prettier-plugin-'],项目如果依赖了 eslintbabel,可以看到根模块目录中如下所示。一般来说,我们不需要关心这个,如果需要配置,依赖的文档会讲这些。

> tree node_modules -L 1
node_modules
  ├── @babel
  ├── @eslint
  ├── @types
  ├── @typescript-eslint
  ├── eslint
  ├── eslint-config-ali
  ├── eslint-import-resolver-node
  ├── eslint-module-utils
  ├── eslint-plugin-import
  ├── eslint-plugin-jsx-plus

关于 hoist 的更多知识,可以参考这个文章:A diagram to show how pnpm works

一些值得设置的 npmrc 配置或者 pnpm-workspace 设置

npmrcpnpm-workspace 中设置适当的配置项,能有效提高项目管理效率。

npmrc

  • registry:指定 npm 使用的默认注册表 URL。

  • save-exact:确保依赖项以精确版本安装,而不是使用版本范围,例如^1.2.3。

pnpm

  • prefer-frozen-lockfile:强制使用锁定文件中的依赖版本。如果设置为 true,即使 package.json 中的依赖有更新,也会优先使用锁定文件(pnpm-lock.yaml)中的版本,避免自动升级。

  • overrides:强制指定某些依赖包的版本,无论这些包在其他依赖包中是否有版本冲突。例如,假设你有两个依赖包 AB,它们依赖于同一个包 C,但它们的版本不同。通过 overrides,你可以强制这两个包都使用 C 的同一版本。


参考资料

  1. TypeScript 5.4: Auto-import support for subpath imports
  2. Turborepo TypeScript 指南
  3. 设置(pnpm-workspace.yaml)
  4. A diagram to show how pnpm works 5.Crafting your repository - Configuring tasks

Isolation Pattern(隔离模式)在前端与 Core 之间加一道“加密网关”,拦截与校验所有 IPC

一、Isolation Pattern 是什么

Isolation Pattern 的核心目标是:用 JavaScript 在 WebView 内部实现一层安全拦截器,把前端到 Core 的 IPC 通道变成“必须先过隔离层”的受控通道。

在这个模式下,前端应用并不是直接把消息交给 Core,而是:

  1. 先交给隔离应用(Isolation application)
  2. 隔离应用运行 hook,对消息做检查/改写
  3. 隔离应用将消息用 AES-GCM 加密(密钥每次启动动态生成)
  4. 加密后的消息再被转交给 Core 解密并处理

隔离应用本质上就是一段 被注入的安全 JS,它是 Isolation Pattern 的“执行载体”。

二、为什么要 Isolation:解决“前端不可信内容”的威胁模型

Isolation Pattern 诞生的动机非常现实:现代前端项目往往依赖庞大且深层嵌套的依赖树,风险来源包括但不限于:

  1. 构建期工具链(几十到上百个依赖)
  2. 运行时代码(打包进产物的依赖同样可能很多)
  3. 外部内容或第三方脚本(如果你加载了外部资源,风险更高)

这些都可能导致“前端执行环境不完全可信”。Isolation 的价值就在于:即使前端被污染,仍然能在进入 Core(拥有完整系统权限)之前做集中拦截与约束

三、什么时候用:官方强烈建议“能用就用”

Isolation Pattern 的一个特点是:它拦截的是“所有 IPC 消息”,因此几乎在任何应用里都能启用。

更进一步,官方建议当你使用外部 Tauri API(比如文件系统、HTTP、shell、窗口管理等)时,最好同时做更严格的“锁定(lockdown)”策略,并利用隔离应用进行输入验证,例如:

  1. 文件读写:限制路径必须落在应用允许的目录内,禁止越权访问
  2. HTTP 请求:限制 Origin、URL 白名单、Header 约束等
  3. 事件(Events):即使是“常开 API”,也能在隔离层拦截与验证,因为事件触发可能导致 Rust 执行敏感动作

四、怎么工作:sandbox iframe + AES-GCM 加密通道

Isolation Pattern 的机制可以概括为“三件事”:

  1. 隔离运行环境
    利用 <iframe sandbox> 的沙箱能力,让隔离应用在更受限、更可控的环境中运行,减少被主前端影响的概率。
  2. 强制消息经由隔离层
    Tauri 在页面加载时强制所有 IPC 调用先路由到隔离应用。
  3. 加密与动态密钥
    隔离应用使用浏览器 SubtleCrypto 对消息进行 AES-GCM 加密,再传回主前端并转交给 Core;Core 解密后像正常 IPC 一样处理。密钥每次应用启动都会重新生成,避免攻击者复用旧密钥对消息做离线篡改。

IPC 消息的大致流程(按步骤理解)

  1. IPC handler 收到前端消息
  2. IPC handler → 隔离应用
  3. 隔离应用 hook 执行(可修改/校验 payload)
  4. 隔离应用使用运行时生成密钥 AES-GCM 加密
  5. 隔离应用 → IPC handler(传回加密消息)
  6. IPC handler → Tauri Core(Core 解密并处理)

五、性能影响:有开销,但通常可忽略

Isolation 相比默认模式会增加:

  1. 每条 IPC 的加解密开销(AES-GCM)
  2. 启动时生成一次安全随机密钥(依赖系统熵池)

大多数应用不会明显感知这点开销,因为 AES-GCM 很快;真正需要关注的是“极致性能敏感应用”,它们往往依赖更少、攻击面更小,也更容易接受其它模式。

如果你在无头环境做 WebDriver 集成测试,可能会遇到熵不足导致启动慢的问题;这时候需要在系统层补足熵源(例如 Linux 环境的相关服务/机制)。

六、限制与坑点:Windows 的 sandbox iframe 外部文件加载限制

一个最重要的限制来自平台不一致:Windows 上 sandbox iframe 对外部文件加载存在问题。因此 Tauri 在构建时做了“脚本内联(script inlining)”步骤:

  • 传统 <script src="index.js"></script> 这种相对路径脚本会被内联处理,从而正常工作
  • 但较新的机制例如 ES Modulestype="module" / import ...)在隔离应用里可能无法正常加载

结论是:隔离应用尽量用“老实的、最简单的脚本加载方式”,别追求花哨打包玩法。

七、官方建议:隔离应用要极简、低依赖

Isolation 的目标是抵御“开发期/供应链威胁”,所以隔离应用本身也要避免成为新的供应链入口。

推荐做法:

  1. 逻辑尽量少:只做校验、白名单、简单转换
  2. 依赖尽量少:最好零依赖或极少依赖
  3. 构建步骤尽量少:减少额外工具链带来的风险

八、手把手示例:创建最小 Isolation Application

假设你主前端的 frontendDist../dist,隔离应用输出到 ../dist-isolation

1)隔离应用:../dist-isolation/index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Isolation Secure Script</title>
  </head>
  <body>
    <script src="index.js"></script>
  </body>
</html>

2)隔离应用:../dist-isolation/index.js

window.__TAURI_ISOLATION_HOOK__ = (payload) => {
  // 不做校验,仅打印并原样返回
  console.log('hook', payload);
  return payload;
};

这个 hook 是你真正写安全逻辑的地方:你可以在这里做参数校验、路径约束、Origin 校验、命令白名单等。

九、启用 Isolation Pattern:tauri.conf.json 配置

{
  "build": {
    "frontendDist": "../dist"
  },
  "app": {
    "security": {
      "pattern": {
        "use": "isolation",
        "options": {
          "dir": "../dist-isolation"
        }
      }
    }
  }
}

含义非常直观:

  1. 主前端资源在 ../dist
  2. security pattern 使用 isolation
  3. 隔离应用目录在 ../dist-isolation

Vibe Coding 全栈专业名词清单|设计模式·基础篇(创建型+结构型核心名词)

image.png

家人们!谁懂啊😭 做Vibe Coding全栈开发,一听到“设计模式”就头大,一堆专业名词像天书,刚记完单例,转头就忘了工厂方法是啥。这篇基础篇,就是来拯救小白的——不搞虚的,不堆专业废话,用打工人能听懂的大白话+写实场景,把“创建型+结构型”设计模式的核心名词,掰碎了喂给你,单篇读完也就15分钟,轻松拿捏入门知识点,再也不用被产品经理问“你这组件怎么创建的”时支支吾吾!

先唠句大实话:设计模式到底是啥?

image.png

说白了,设计模式就是“全栈打工人的摸鱼高效模板”——就像你点外卖,不用每次都自己买菜、做饭、洗碗(重复造轮子),直接点现成的(用设计模式),又快又不出错。而创建型、结构型,就是最基础的两类“模板”,一个管“怎么造对象”,一个管“怎么把对象拼起来用”,学会这俩,日常80%的简单开发需求,都能轻松hold住。

一、创建型设计模式(管“造对象”的模板)

image.png

核心逻辑:不用你每次都new对象,搞一堆重复代码,像工厂流水线一样,按需造对象,省时间、少出错,主打一个“懒出效率”。

1. 单例模式(全栈er最常用,没有之一)

image.png

专业名词解读:整个程序里,某个对象只造一次,不管你调用多少次,都是同一个“打工人”,不重复招人(new对象),避免浪费资源。

写实场景:就像公司的打印机📠 ——全公司几十号人,不用每个人都配一台打印机(重复new),就一台公用的,谁用都是这台,省成本、不混乱。在Vibe Coding里,全局配置、全局弹窗、请求拦截器,基本都用单例模式,比如你写Vue的全局组件,总不能每次调用都new一次吧?那页面直接崩给你看!

避坑小吐槽:别瞎用单例!比如你写表单组件,要是用单例,多个表单共用一个对象,填完一个表单,另一个表单的数据也跟着变,到时候测试小姐姐追着你改bug,你就知道错了😂

2. 工厂模式(简单工厂+工厂方法,懒人福音)

image.png

专业名词解读:专门搞“对象生产线”,你告诉它要啥类型的对象,它就给你造啥,不用你自己写一堆if-else判断,代码看起来更干净。简单工厂是“小作坊”,能造多种对象但不好扩展;工厂方法是“连锁店”,每种对象一个工厂,扩展起来更方便。

写实场景:就像你去奶茶店点单🥤 ——你不用自己动手做(new对象),告诉店员要“珍珠奶茶”“果茶”(指定对象类型),店员(工厂)就给你做出来。在Vibe Coding里,写组件渲染、接口请求适配不同环境(开发/测试/生产),用工厂模式最合适,比如根据环境不同,工厂自动返回对应的请求地址,不用你写一堆判断,后期改起来也方便。

3. 建造者模式(复杂对象的“定制师”)

image.png

专业名词解读:造那种“零件多、结构复杂”的对象,比如一个完整的用户信息对象,有姓名、年龄、地址、权限等多个属性,不用一次性把所有属性都传进去,分步建造,灵活又清晰。

写实场景:就像你去定制蛋糕🎂 ——不用一次性告诉老板“我要一个草莓味、6寸、奶油裱花、带插件的蛋糕”,可以分步来:先选口味,再选尺寸,再选装饰,最后确认,老板一步步给你做好。在Vibe Coding里,写复杂的表单提交、页面配置组件,用建造者模式,能避免传参太多导致的混乱,后期维护也能快速找到对应的“零件”。

4. 原型模式(对象的“复制粘贴”)

image.png

专业名词解读:不用重新new一个对象,而是复制一个已有的对象(原型),再稍微修改一下属性,就能得到一个新对象,适合需要大量相似对象的场景,省时间。

写实场景:就像你写周报📝 ——不用每次都从零开始写,复制上一周的周报(原型),修改一下工作内容、进度,就能快速生成新的周报。在Vibe Coding里,写列表渲染、批量创建相似组件(比如多个卡片组件,只有内容不同),用原型模式,能减少重复代码,提升渲染效率。

二、结构型设计模式(管“拼对象”的模板)

核心逻辑:对象造好了,怎么把它们组织起来,让结构更清晰、更灵活,方便后期扩展,就像搭积木一样,把零散的积木(对象)拼成好看又结实的造型(程序结构)。

1. 适配器模式(“翻译官”一样的存在)

image.png

专业名词解读:解决“接口不兼容”的问题,就像翻译官,把一个接口的格式,转换成另一个接口能看懂的格式,不用修改原有代码,就能让两个不兼容的对象一起工作。

写实场景:就像你去国外旅游🌍 ——你说中文,外国人说英文,没法直接沟通,找个翻译官(适配器),把中文翻译成英文,就能正常交流。在Vibe Coding里,最常见的就是接口适配,比如后端返回的字段是“userName”,而你前端用的是“name”,不用改后端接口,也不用改前端大量代码,写一个适配器,把“userName”转换成“name”,完美解决问题。

2. 装饰器模式(给对象“加buff”)

image.png

专业名词解读:不改变原有对象的核心功能,给它加一些额外的功能,就像给手机戴手机壳、贴钢化膜,手机本身的功能没变,但多了保护、装饰的作用,灵活又不破坏原有结构。

写实场景:就像你上班摸鱼💼 ——你本身的工作是写代码(核心功能),但你可以一边写代码,一边听音乐(额外功能),不影响写代码,还能提升摸鱼幸福感。在Vibe Coding里,写日志打印、权限校验、函数防抖节流,都能用装饰器模式,比如给一个接口请求函数加个装饰器,自动打印请求参数和返回结果,不用在每个函数里都写日志代码。

3. 代理模式(对象的“代言人”)

image.png

专业名词解读:不让你直接访问某个对象,而是通过一个“代言人”(代理对象)去访问,代理对象可以帮你做一些额外的操作,比如权限校验、缓存、日志,保护原对象的安全。

写实场景:就像你找房子🏠 ——你不用直接找房东,而是找中介(代理),中介帮你筛选房源、带看、谈价格,你只需要最终确认,中介还能帮你规避一些风险(比如房东不靠谱)。在Vibe Coding里,写缓存、权限控制、懒加载,都能用代理模式,比如图片懒加载,不用直接加载图片对象,而是通过代理对象,判断图片是否进入可视区域,再加载,提升页面性能。

4. 其他常用结构型模式(简单唠唠,不啰嗦)

组合模式:像文件夹一样,文件夹里可以放文件,也可以放子文件夹,用来处理树形结构,比如前端的菜单组件、树形控件,就是典型的组合模式。

外观模式:给复杂的系统套一个“简化接口”,就像手机的快捷指令,不用你一步步操作,点一下快捷指令,就能完成一系列复杂操作,比如前端的封装请求工具,把复杂的请求逻辑封装起来,只暴露一个简单的调用方法。

桥接模式、享元模式:日常用得相对少一点,不用死记,知道它们是“解决结构冗余、提升复用性”的就行,等用到的时候再回头查,主打一个“不浪费脑细胞”😎

基础篇小结(划重点,别偷懒)

创建型:管“造对象”,记住单例、工厂、建造者、原型,日常开发够用;结构型:管“拼对象”,重点掌握适配器、装饰器、代理,解决接口、功能、访问的问题。这篇基础篇,单独看也能轻松入门,不用依赖后面的内容,下次再被设计模式名词难住,就回头翻这篇,包你一看就懂!

揭秘 Web3 隐私社交标杆:CocoCat 的核心架构与智能合约实现

前端

特此声明:本文仅做项目技术拆解,不做项目投资推荐,投资有风险,入市需谨慎。在 Web3.0 浪潮中,社交赛道正经历从“数据垄断”到“主权回归”的范式转移。作为去中心化隐私社交应用的代表,CocoCat 通过独特的影子身份协议(Shadow Identity)与 RelayX 分布式中继网络,成功构建了一个既保护隐私又具备商业可持续性的社交生态。

本文将深入探讨 CocoCat 的核心技术点,并展示如何基于 Solidity 0.8.24OpenZeppelin V5 实现其核心业务流。

CocoCat项目相关梳理

概述:CocoCat 是一个基于区块链的隐私社交平台,类“去中心化版的通讯软件”。

1. 它能做什么?

  • 私密聊天:聊天内容端到端加密,没有中央服务器,没人能监控。
  • 影子身份:用匿名哈希(Shadow ID)社交,不暴露真实钱包地址。
  • 赚取收益:用户贡献带宽作为“中继节点”转发消息,赚取 CAT 代币
  • Web3 支付:内置钱包,聊天时直接转账、发红包。

2. 核心特点(黑科技)

  • 零服务器 (RelayX) :消息通过全球用户节点点对点传输,永不宕机,成本极低。
  • 三重隐私:身份、聊天数据、社交关系全部加密且去中心化存储。
  • 离线签名 (EIP-712) :聊天支付不走即时链上交易,省 Gas 费且速度快。

3. 项目风险

  • 用户量不足:P2P 网络依赖节点数量,人少会导致消息延迟或收不到。
  • 监管压力:极致隐私可能被用于非法用途,面临各国政策审查。
  • 代币风险:CAT 代币价值完全取决于生态热度,波动巨大。
  • 门槛高:对普通用户来说,理解和使用 Web3 社交软件有学习成本。

一、 CocoCat 的三大技术支柱

1. 影子身份协议 (Shadow Identity)

传统的社交软件通过手机号或中心化 ID 锚定用户,数据极易被追踪。CocoCat 引入了 “影子身份”

  • 链上锚定:用户的钱包地址仅作为身份的“担保人”,真实社交活动使用隐匿的“影子公钥哈希”。
  • 隐私隔离:即使外部观察者追踪到钱包地址,也无法将其与 P2P 网络中的具体聊天行为关联。

2. RelayX 中继协议

为了解决去中心化网络中的通信效率与成本问题,CocoCat 采用了 RelayX 协议

  • 去服务器化:消息不经过中心化服务器,而是通过分布式的 P2P 节点进行中继。
  • 低成本运行:通过将通讯逻辑下放到中继节点,开发者可节省超过 90% 的传统运营成本。

3. 基于 EIP-712 的支付闭环

社交中继并非免费。CocoCat 巧妙地利用 EIP-712 类型化签名,实现了“链下授权、链上结算”的经济闭环。用户在发送消息时签署小额支付凭证,节点定期上链兑付,极大优化了 Gas 效率。


二、 核心合约实现:从锚定到结算

代币合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

contract CocoCatToken is ERC20, ERC20Burnable, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    constructor() ERC20("CocoCat Token", "CAT") {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(MINTER_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }
}

CocoCatCore合约:它集成了身份注册、预存话费与 EIP-712 签名结算功能。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract CocoCatCore is EIP712, ReentrancyGuard {
    using ECDSA for bytes32;

    IERC20 public immutable catToken;
    bytes32 private constant SETTLEMENT_TYPEHASH = 
        keccak256("Settlement(address node,uint256 amount,uint256 nonce)");

    // 1. 锚定逻辑:钱包地址 => 影子公钥哈希 (ShadowHash)
    mapping(address => bytes32) public shadowIdentities;
    
    // 2. 结算逻辑数据
    mapping(address => uint256) public userDeposits;
    mapping(address => uint256) public nonces;
    mapping(address => uint256) public nodeBalances;

    event IdentityAnchored(address indexed wallet, bytes32 shadowHash);
    event Settled(address indexed user, address indexed node, uint256 amount);

    constructor(address _catToken) EIP712("CocoCat_RelayX", "1") {
        catToken = IERC20(_catToken);
    }

    // --- 锚定阶段 (Anchoring) ---
    function registerShadowIdentity(bytes32 _shadowHash) external {
        require(shadowIdentities[msg.sender] == bytes32(0), "Already anchored");
        shadowIdentities[msg.sender] = _shadowHash;
        emit IdentityAnchored(msg.sender, _shadowHash);
    }

    // --- 发现阶段 (Discovery - View Only) ---
    function getShadowHash(address _wallet) external view returns (bytes32) {
        return shadowIdentities[_wallet];
    }

    // --- 结算阶段 (Settlement) ---
    function deposit(uint256 _amount) external nonReentrant {
        require(catToken.transferFrom(msg.sender, address(this), _amount), "Transfer failed");
        userDeposits[msg.sender] += _amount;
    }

    function settleWithSignature(
        address _user,
        uint256 _amount,
        uint256 _nonce,
        bytes calldata _signature
    ) external nonReentrant {
        require(_nonce == nonces[_user], "Invalid nonce");
        require(userDeposits[_user] >= _amount, "Insufficient user funds");

        // EIP-712 验证
        bytes32 structHash = keccak256(abi.encode(SETTLEMENT_TYPEHASH, msg.sender, _amount, _nonce));
        bytes32 hash = _hashTypedDataV4(structHash);
        address signer = hash.recover(_signature);
        
        require(signer == _user, "Invalid service signature");

        // 状态更新
        nonces[_user]++;
        userDeposits[_user] -= _amount;
        nodeBalances[msg.sender] += _amount;

        emit Settled(_user, msg.sender, _amount);
    }

    function withdrawEarnings() external nonReentrant {
        uint256 amount = nodeBalances[msg.sender];
        require(amount > 0, "No earnings");
        nodeBalances[msg.sender] = 0;
        require(catToken.transfer(msg.sender, amount), "Withdrawal failed");
    }
}

三、 自动化测试与安全性验证

测试用例:

1. 业务流程全路径验证

通过模拟  “生成 -> 锚定 -> 发现 -> 签名 -> 结算” ,确保整个逻辑闭环。测试结果显示,用户可以在不频繁操作区块链的情况下,通过离线签名完成对中继服务的支付。

2. 安全边界测试

针对“影子身份被篡改”和“签名重放”进行了压力测试。例如,通过 assert.rejects 和 simulateContract 捕获合约回滚,确保一个地址无法绑定多个影子身份,保护了系统身份的一致性。

import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { network } from "hardhat"; 
import { parseEther, type Address, keccak256, toHex ,BaseError, ContractFunctionRevertedError} from "viem";

describe("CocoCat 业务全流程深度测试", function () {
    let catToken: any, cocoCore: any;
    let admin: any, userA: any, userB: any, relayNode: any;
    let vClient: any, pClient: any;

    beforeEach(async function () {
        const { viem } = await (network as any).connect();
        vClient = viem;
        [admin, userA, userB, relayNode] = await vClient.getWalletClients();
        pClient = await vClient.getPublicClient();

        // 1. 部署环境
        catToken = await vClient.deployContract("CocoCatToken"); // 假设已有 ERC20
        cocoCore = await vClient.deployContract("CocoCatCore", [catToken.address as Address]);

        // 2. 初始资金注入
        await catToken.write.mint([userA.account.address, parseEther("100")], { account: admin.account });
        await catToken.write.approve([cocoCore.address, parseEther("100")], { account: userA.account });
    });

    it("完整业务流验证:生成 -> 锚定 -> 发现 -> 通讯签名 -> 结算", async function () {
        // --- 1. 生成 (Generation) ---
        // 模拟客户端生成影子密钥对的哈希
        const shadowPubKeyHash = keccak256(toHex("Shadow_Key_For_UserA"));

        // --- 2. 锚定 (Anchoring) ---
        await cocoCore.write.registerShadowIdentity([shadowPubKeyHash], { account: userA.account });
        console.log("✅ UserA 已完成影子身份锚定");

        // --- 3. 发现 (Discovery) ---
        // UserB 通过 UserA 的钱包地址找到其影子哈希,用于路由通讯
        const foundHash = await cocoCore.read.getShadowHash([userA.account.address]);
        assert.strictEqual(foundHash, shadowPubKeyHash, "UserB 未能发现正确的影子身份");
        console.log("✅ UserB 成功发现 UserA 的影子哈希:", foundHash);

        // --- 4. 通讯与签名 (Communication & Signing) ---
        // 用户 A 预存话费
        const depositAmt = parseEther("10");
        await cocoCore.write.deposit([depositAmt], { account: userA.account });

        // 用户 A 在发送消息给 B 时,签署给中继节点的小额支付凭证
        const settleAmt = parseEther("0.1"); // 每条/批消息费用
        const nonce = 0n;
        const chainId = await pClient.getChainId();

        const signature = await userA.signTypedData({
            domain: { name: "CocoCat_RelayX", version: "1", chainId, verifyingContract: cocoCore.address },
            types: { Settlement: [{ name: 'node', type: 'address' }, { name: 'amount', type: 'uint256' }, { name: 'nonce', type: 'uint256' }] },
            primaryType: 'Settlement',
            message: { node: relayNode.account.address, amount: settleAmt, nonce: nonce }
        });
        console.log("✅ UserA 已签署通讯服务凭证");

        // --- 5. 结算 (Settlement) ---
        // 中继节点提交凭证换钱
        const txHash = await cocoCore.write.settleWithSignature(
            [userA.account.address, settleAmt, nonce, signature],
            { account: relayNode.account }
        );
        await pClient.waitForTransactionReceipt({ hash: txHash });

        // 最终检查
        const nodeEarnings = await cocoCore.read.nodeBalances([relayNode.account.address]);
        assert.strictEqual(nodeEarnings, settleAmt, "节点未收到结算 CAT");
        
        const userRemaining = await cocoCore.read.userDeposits([userA.account.address]);
        assert.strictEqual(userRemaining, depositAmt - settleAmt, "用户余额扣减错误");
        console.log("✅ 全业务流程结算成功!");
    });

it("安全性测试:篡改影子身份锚定应失败", async function () {
    const realHash = keccak256(toHex("Real"));
    const fakeHash = keccak256(toHex("Fake_Hash"));

    // 1. 第一次锚定
    await cocoCore.write.registerShadowIdentity([realHash], { account: userA.account });
    
    // 2. 验证重复锚定会触发 revert
    try {
        // 直接尝试写入交易,而不是模拟
        await cocoCore.write.registerShadowIdentity([fakeHash], { 
            account: userA.account 
        });
        
        assert.fail("应当触发 Already anchored 错误但交易成功了");
    } catch (err: any) {
        // 在 Hardhat 中,如果 write 失败,错误信息通常直接存在 err.message 或 err.details 中
        const fullMessage = (err.details || err.shortMessage || err.message || "").toLowerCase();
        
        // 打印一下,方便调试时观察实际捕获到了什么
        // console.log("Captured full message:", fullMessage);

        // 使用不区分大小写的匹配,增加鲁棒性
        const isMatched = /already anchored/i.test(fullMessage) || /reverted/i.test(fullMessage);
        
        assert.ok(
            isMatched, 
            `预期的错误信息应包含 'Already anchored',实际捕获内容为: ${fullMessage}`
        );
    }
    console.log("✅ 安全性测试:拦截重复锚定成功");
});

});

四、部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  // 加载合约
  const CocoCatTokenArtifact = await artifacts.readArtifact("CocoCatToken");
  const CocoCatCoreArtifact = await artifacts.readArtifact("CocoCatCore");
    const CocoCatTokenHash = await deployer.deployContract({
      abi: CocoCatTokenArtifact.abi,//获取abi
      bytecode: CocoCatTokenArtifact.bytecode,//硬编码
      args: [],//process.env.RECIPIENT, process.env.OWNER
    });
    const CocoCatTokenReceipt = await publicClient.waitForTransactionReceipt({ hash: CocoCatTokenHash });
    console.log("CocoCatToken合约地址:", CocoCatTokenReceipt.contractAddress);
  // 部署(构造函数参数:recipient, initialOwner)
  const CocoCatCoreHash = await deployer.deployContract({
    abi: CocoCatCoreArtifact.abi,//获取abi
    bytecode: CocoCatCoreArtifact.bytecode,//硬编码
    args: [CocoCatTokenReceipt.contractAddress],
  });

  // 等待确认并打印地址
  const CocoCatCoreReceipt = await publicClient.waitForTransactionReceipt({ hash: CocoCatCoreHash });
  console.log("CocoCatCore合约地址:", CocoCatCoreReceipt.contractAddress);
}

main().catch(console.error);

五、 结语

CocoCat 的核心竞争力在于它不仅仅是一个聊天工具,而是一套去中心化的社交基础设施。通过将隐私身份锚定在链上,而将高频交互放在链下的中继网络,它在隐私、效率与成本之间找到了完美的平衡点。

对于开发者而言,理解其“链上存证、链下通讯、签名结算”的模式,是构建下一代大规模 Web3 应用的关键参考。

一个 Hook 拦截所有 AJAX 请求:ajax-hooker 使用指南与原理

一个 Hook 拦截所有 AJAX 请求:ajax-hooker 使用指南与原理

开发调试时想改接口返回值?油猴脚本里想劫持网络请求?Chrome 扩展里想做请求监控?ajax-hooker 用一套 API 搞定 XMLHttpRequest 和 Fetch 的拦截,还支持流式响应。

痛点:为什么需要 AJAX 拦截?

作为前端开发者,你一定遇到过这些场景:

  • 接口联调:后端接口没开发完,想 mock 返回数据继续写页面
  • 线上调试:生产环境接口有 bug,想临时修改返回值定位问题
  • 请求监控:给所有请求统一加上 Token、追踪日志
  • 油猴脚本:修改第三方网站的接口行为,比如去广告、改数据展示
  • Chrome 扩展:开发网络调试工具

现有方案的问题是:XHR 和 Fetch 是两套完全不同的 API。你要么写两套拦截逻辑,要么找一个库帮你抹平差异。

ajax-hooker 就是做这件事的 —— 一个钩子函数同时拦截 XHR 和 Fetch,统一的请求/响应数据结构,还支持流式响应拦截

快速上手

安装

# npm
npm install ajax-hooker

# pnpm
pnpm add ajax-hooker

# CDN(IIFE 全局变量 AjaxHooker)
# https://unpkg.com/ajax-hooker
# https://cdn.jsdelivr.net/npm/ajax-hooker

三步完成拦截

import AjaxInterceptor from 'ajax-hooker';

// 1. 获取单例
const interceptor = AjaxInterceptor.getInstance();

// 2. 注入拦截(替换原生 XMLHttpRequest 和 fetch)
interceptor.inject();

// 3. 注册钩子
interceptor.hook((request) => {
  console.log(`[${request.type}] ${request.method} ${request.url}`);

  // 修改请求:统一添加 Token
  request.headers.set('Authorization', 'Bearer my-token');

  // 修改响应
  request.response = (resp) => {
    if (request.url.includes('/api/user')) {
      resp.json = { name: '测试用户', id: 1 };
    }
  };
});

这段代码会拦截页面上所有的 XHR 和 Fetch 请求。不管第三方库用的是 axios(基于 XHR)还是原生 fetch,都会被捕获。

实战场景

场景 1:接口版本迁移

后端正在将 /api/v1/ 迁移到 /api/v2/,你可以在前端无感切换:

interceptor.hook((request) => {
  if (request.url.includes('/api/v1/')) {
    request.url = request.url.replace('/api/v1/', '/api/v2/');
  }
});

甚至可以切换域名:

interceptor.hook((request) => {
  if (request.url.includes('old-api.example.com')) {
    request.url = request.url.replace(
      'old-api.example.com',
      'new-api.example.com'
    );
  }
});

场景 2:Mock 接口返回

后端接口还没好?直接拦截返回 mock 数据:

interceptor.hook((request) => {
  request.response = (resp) => {
    if (request.url.includes('/api/products')) {
      // 对 XHR 修改 response/responseText
      resp.response = JSON.stringify([
        { id: 1, name: '商品A', price: 99 },
        { id: 2, name: '商品B', price: 199 },
      ]);
      resp.responseText = resp.response;
      // 对 Fetch 修改 json
      resp.json = [
        { id: 1, name: '商品A', price: 99 },
        { id: 2, name: '商品B', price: 199 },
      ];
      resp.status = 200;
      resp.statusText = 'OK';
    }
  };
});

场景 3:请求日志 & 性能监控

interceptor.hook((request) => {
  const startTime = Date.now();

  request.response = (resp) => {
    const duration = Date.now() - startTime;
    console.log(
      `[${request.type.toUpperCase()}] ${request.method} ${request.url}`,
      `| ${resp.status} | ${duration}ms`
    );

    // 慢接口报警
    if (duration > 3000) {
      console.warn(`慢接口: ${request.url} 耗时 ${duration}ms`);
    }
  };
});

场景 4:统一添加公共参数

interceptor.hook((request) => {
  const url = new URL(request.url);
  url.searchParams.set('app_version', '2.0.0');
  url.searchParams.set('platform', 'web');
  request.url = url.toString();
});

场景 5:拦截流式响应(SSE / NDJSON)

现在 AI 场景越来越多,接口经常返回流式数据。ajax-hooker 可以逐块拦截:

interceptor.hook((request) => {
  if (request.url.includes('/api/chat/stream')) {
    // onStreamChunk 会在每个数据块到达时被调用
    request.onStreamChunk = (chunk) => {
      console.log(`chunk #${chunk.index}:`, chunk.text);

      // 返回修改后的文本(会替换原始数据)
      return chunk.text.replace('敏感词', '***');

      // 返回 void/undefined 则保持原数据不变
    };

    request.response = (resp) => {
      console.log('流开始,status:', resp.status);
    };
  }
});

自动检测的流式 Content-Type 包括:

  • text/event-stream (SSE)
  • application/x-ndjson
  • application/stream+json
  • application/jsonl
  • application/json-seq

场景 6:多个钩子协作

钩子按注册顺序链式执行,可以做职责分离:

// 钩子 1:认证
interceptor.hook((request) => {
  request.headers.set('Authorization', 'Bearer token-xxx');
});

// 钩子 2:日志
interceptor.hook((request) => {
  console.log('请求已携带 Auth:', request.headers.get('Authorization'));
});

// 钩子 3:只拦截 Fetch 的响应
interceptor.hook((request) => {
  if (request.type === 'fetch') {
    request.response = (resp) => {
      // 只处理 fetch 响应
    };
  }
});

场景 7:在 Chrome 扩展中使用

Chrome 扩展的 Content Script 运行在隔离环境中,无法直接修改页面的 XMLHttpRequest。需要将代码注入到页面的 main world:

// content.js
const script = document.createElement('script');
script.src = chrome.runtime.getURL('vendor/ajax-hooker.iife.js');
script.onload = () => {
  const init = document.createElement('script');
  init.textContent = `
    const interceptor = AjaxHooker.getInstance();
    interceptor.inject();
    interceptor.hook((request) => {
      // 你的拦截逻辑
    });
  `;
  document.documentElement.appendChild(init);
  init.remove();
};
document.documentElement.appendChild(script);
script.remove();

manifest.json 中需要声明:

{
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content.js"],
    "run_at": "document_start"
  }],
  "web_accessible_resources": [{
    "resources": ["vendor/ajax-hooker.iife.js"],
    "matches": ["<all_urls>"]
  }]
}

API 速查

方法 说明
AjaxInterceptor.getInstance() 获取单例实例
interceptor.inject(type?) 注入拦截。type 可选 'xhr' / 'fetch',不传则两者都注入
interceptor.uninject(type?) 移除拦截,恢复原生对象
interceptor.hook(fn, type?) 注册钩子函数。type 可选,用于只拦截特定类型
interceptor.unhook(fn?, type?) 移除钩子。不传 fn 则清空所有钩子

Request 对象属性

属性 类型 可写 说明
type 'xhr' | 'fetch' 请求类型
method string HTTP 方法
url string 请求 URL
headers Headers 请求头(标准 Headers 对象)
data any 请求体
response (resp) => void 响应回调,在响应返回时触发
onStreamChunk (chunk) => string | void 流式响应逐块回调(仅 Fetch)
responseType string XHR 专属
withCredentials boolean XHR 专属
timeout number XHR 专属

Response 对象属性

属性 适用于 可写 说明
status 两者 HTTP 状态码
statusText 两者 状态文本
headers 两者 响应头
finalUrl 两者 最终 URL(重定向后)
response XHR 原始响应
responseText XHR 文本响应
responseXML XHR XML 响应
json Fetch JSON 数据
text Fetch 文本数据
arrayBuffer Fetch ArrayBuffer 数据
blob Fetch Blob 数据
formData Fetch FormData 数据
ok Fetch 是否成功 (2xx)
redirected Fetch 是否重定向

实现原理简述

如果你对底层实现感兴趣,这里简单介绍核心机制:

XHR 拦截

使用 ES6 Proxy 包裹原生 XMLHttpRequest 实例:

new XMLHttpRequest()
  → proxyXhr() 构造函数
    → 创建真实 xhr 实例
    → 返回 Proxy(xhr, handler)

Proxy 的 get trap 拦截了属性读取:

  • 方法调用open/send/setRequestHeader):在调用原生方法前后执行钩子逻辑
  • 响应属性response/responseText/status):在响应处理完成后返回可能被修改的值
  • 事件监听addEventListener/removeEventListener):包装响应事件的 listener,确保在触发前执行响应处理

关键设计点:

  • 使用 Symbol 在实例上附加状态,避免属性名冲突
  • 如果钩子修改了 urlmethod,会自动 reopen(重新调用原生 open
  • responseProcessor 有幂等守卫,即使 onloadonreadystatechange 同时触发也只执行一次

Fetch 拦截

替换全局 window.fetch 为代理函数:

fetch(url, options)
  → proxyFetch()
    → 规范化请求参数
    → 执行钩子链
    → 调用原生 fetch
    → Proxy 包裹 Response

Fetch 拦截更复杂的地方在于:

  • fetch() 支持三种调用形式(string / URL / Request 对象),需要统一规范化
  • 使用 sourceMap 追踪每个属性的来源(Request 对象 / options / 默认值),确保还原请求时的精确性
  • 响应处理分两条路径:普通响应(并行解析 5 种格式)和流式响应(TransformStream 管道)

为什么用 Proxy 而不是直接覆写原型?

常见的 XHR 拦截方案是修改 XMLHttpRequest.prototype 上的方法。ajax-hooker 选择 Proxy 的原因:

  1. 更细粒度的控制:Proxy 可以拦截属性读取(get)和写入(set),不仅仅是方法调用
  2. 不污染原型链:每个实例独立代理,互不干扰
  3. 响应属性拦截response/responseText 是只读属性,覆写原型无法拦截它们的 getter,而 Proxy 的 get trap 可以
  4. instanceof 兼容:通过 copyNativePropsAndPrototype 确保 xhr instanceof XMLHttpRequest 仍然返回 true

与其他方案的对比

特性 ajax-hooker Mock Service Worker (MSW) axios interceptors
拦截 XHR 是 (Service Worker) 仅 axios
拦截 Fetch 是 (Service Worker)
修改响应 是(直接修改) 是(需要定义 handler) 是(仅 axios)
流式响应
运行时依赖 0 msw + worker 文件 内置于 axios
使用场景 运行时拦截 测试/开发 mock axios 项目内部
油猴/扩展 适合 不适合 不适合
拦截第三方代码

项目信息


写在最后

ajax-hooker 目前还在持续迭代中,后续计划支持 EventSource (SSE) 拦截等更多能力。

如果你在使用过程中遇到问题,欢迎到 GitHub Issues 提反馈;如果这个库对你有帮助,希望你能去 GitHub 仓库 点个 Star,这对开源作者来说是最大的鼓励,也能让更多有需要的人看到这个项目。感谢!

一个有趣的原型继承实验:为什么“男人也会生孩子”?从对象赋值到构造函数继承的完整推演

在学习 JavaScript 面向对象时,很多人都会经历一个非常“离谱”的瞬间:

你给女人对象加了一个“生孩子”的方法
结果男人对象也能生孩子了

这不是业务 bug,这是原型和对象引用机制在给你上课。

这篇文章就带你完整复盘一个真实学习过程:

从最直觉的对象赋值继承
到出现诡异共享问题
再到构造函数 + 原型继承的正确解法

我们不仅看结果,更要看底层机制。


一、问题的起点:我只是想让两个对象拥有相同属性

假设我们有一个“人类基础属性”:

  • 两只眼睛
  • 一个脑袋

最直觉的写法:

const Person = {
  eyes: 2,
  head: 1
}

然后想让:

  • Woman 拥有这些属性
  • Man 也拥有这些属性

很多人第一反应是:

const Woman = Person
const Man = Person

或者:

Woman.__proto__ = Person
Man.__proto__ = Person

看起来很合理。

毕竟目标只是“复用”。

但问题马上出现。


二、诡异现象:给女人加方法,男人也会了

现在给 Woman 添加一个方法:

Woman.baby = function () {
  console.log('宝贝')
}

然后测试:

Man.baby()

居然也能调用。

为什么?

男人也会生孩子了。

这明显不合理。

但从 JavaScript 的角度,这是完全合理的。


三、本质原因:你根本没有创建多个对象

关键理解一句话:

对象赋值不是复制,而是引用传递

当你写:

const Woman = Person

本质是:

Woman 和 Person 指向同一个内存对象

内存结构:

Person  ─┐
         ├── 同一个对象
Woman  ──┘
Man    ───┘

你不是创建三个对象。

你只是创建三个“变量名”。

它们共同指向一个对象。

所以:

给 Woman 添加属性
就是给 Person 添加属性
自然 Man 也能访问

问题不是继承错了。

是你压根没继承。

你只是多起了几个别名。


四、真正的目标:结构相同,但对象独立

我们希望:

  • Woman 有 eyes / head
  • Man 有 eyes / head
  • 但互不影响
  • 方法也可独立扩展

这意味着:

必须创建多个独立对象

这就是构造函数存在的意义。


五、第一层解决方案:使用构造函数创建实例

构造函数的本质:

批量生产结构相同的对象

实现:

function Person() {
  this.eyes = 2
  this.head = 1
}

每次执行:

new Person()

都会发生:

1 创建新对象
2 绑定 this
3 执行函数
4 返回对象

关键点:

每次 new 都创建全新对象

互不影响。


六、现在我们来做真正的“继承”

目标:

Woman 继承 Person
Man 继承 Person

传统原型继承写法:

function Woman() {}
Woman.prototype = new Person()
Woman.prototype.constructor = Woman

同理:

function Man() {}
Man.prototype = new Person()
Man.prototype.constructor = Man

这行代码非常关键:

Woman.prototype = new Person()

它做了什么?

不是复制代码。

是创建一个 Person 实例,然后把它作为 Woman 的原型。

结构变成:

Woman实例
   ↓
Woman.prototypePerson实例)
   ↓
Object.prototype

Woman 的实例可以访问:

Person 实例里的属性。

这就是原型继承。


七、验证:现在给女人加能力,男人不会受影响

Woman.prototype.baby = function () {
  console.log('宝贝')
}

测试:

new Woman().baby()   ✔
new Man().baby()     ✘

终于正常了。

原因:

Woman.prototype 和 Man.prototype 是两个不同对象。

虽然都来自 new Person()

但:

是不同实例

互不干扰。


八、这就是关键转折:对象复用 vs 构造函数实例化

错误方案:

共享同一个对象

正确方案:

基于同一构造规则创建多个对象

这就是:

面向对象思想中的“实例化”。


九、构造函数为什么体现封装?

来看一个典型封装例子:

function Person() {
  this.name = '佚名'

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

  this.getName = function () {
    console.log(this.name)
  }
}

创建两个实例:

let p1 = new Person()
let p2 = new Person()

修改:

p1.setName('小明')

结果:

p1.name = 小明
p2.name = 佚名

互不影响。

这就是封装:

数据 + 操作数据的方法
打包在对象内部

并且实例独立。


十、但构造函数也有性能问题

如果方法写在构造函数里:

每个实例都会创建一份函数。

这很浪费内存。

解决方案:

使用原型。


十一、原型的真正作用:共享方法

function Person() {}

Person.prototype.sayHi = function () {
  console.log('Hi')
}

所有实例共享:

一份函数

而不是每人一份。

这就是原型的核心价值。


十二、实例是如何找到原型方法的?

访问对象属性时:

查找顺序:

1 先找实例自身
2 再找 prototype
3 再找上层原型
4 直到 null

这叫:

原型链查找

UI UX Pro Max:AI 驱动的专业级 UI/UX 设计智能体实战教程

一句话总结ui-ux-pro-max-skill 不是一个普通的设计插件,而是一个嵌入式 AI 设计智能体AI Skill,它将专业 UI/UX 知识库直接注入你日常使用的 AI 编程助手(如 Cursor、Claude、Copilot 等),让你在写代码的同时,自动生成符合行业规范、美学原则与平台最佳实践的高质量界面。

一、为什么我们需要 UI UX Pro Max?

在当前 AI 编程工具爆发式增长的背景下,开发者已经可以借助 Copilot、Cursor、Windsurf 等工具快速生成基础代码。然而,当我们尝试让这些通用 AI 帮助我们“设计一个好看的页面”时,往往会遇到以下问题:

  • 生成的 UI 风格陈旧(比如还在用 Bootstrap 3 的卡片样式)
  • 配色混乱,缺乏品牌一致性
  • 字体搭配随意,影响可读性
  • 表格、图表等复杂组件交互逻辑缺失
  • 移动端适配差,无障碍支持为零

这些问题的本质在于:通用大模型没有经过结构化的 UI/UX 专业知识训练,它们知道“HTML 怎么写”,但不知道“SaaS 后台仪表盘应该怎么设计”。

ui-ux-pro-max-skill 正是为解决这一痛点而生。它不是一个独立应用,而是一个可嵌入的 AI 技能包(AI Skill),通过将数千个优秀设计案例、行业规范、色彩系统、字体组合等知识结构化后注入到你的 AI 助手中,使其在生成 UI 代码时具备“专业设计师”的判断力。


二、传统 AI 工具 vs UI UX Pro Max:本质差异在哪?

❌ 传统 AI(如 Copilot 原生、通义灵码等)的局限:

  1. 通用性强,专业性弱 能生成基础 HTML/CSS,但缺乏对设计趋势(如 Glassmorphism)、行业配色(如 Fintech 蓝金系)、无障碍规范的理解。
  2. 无上下文感知 你写“做一个 SaaS 登录页”,它可能返回一个过时的 Bootstrap 风格,而非现代 Tailwind + 深色模式 + 微交互动效。
  3. 无法联动设计系统 字体搭配混乱、间距不一致、色彩对比度不达标,需人工反复调整。
  4. 输出不可控 每次生成结果差异大,难以形成统一的产品语言。

✅ UI UX Pro Max 的突破:

维度 传统 AI UI UX Pro Max
知识源 通用训练数据 结构化专业数据库(57 种 UI 风格 + 95 套行业配色 + 56 组字体组合)
响应方式 盲目生成 先匹配场景 → 再推荐设计系统 → 最后生成代码
平台适配 无差别输出 支持 React/Next.js/Vue/SwiftUI/Flutter 等 8 大技术栈专属规范
可集成性 独立工具 作为“技能”无缝嵌入主流 AI 编程助手
输出一致性 随机波动 基于同一套设计语言,保证跨页面一致性
可维护性 代码杂乱 自动清理冗余样式,遵循原子化 CSS 原则

核心理念:不是取代设计师,而是让开发者在编码阶段就具备“专业 UI 设计师”的决策能力。


三、真实使用体验:一次完整的重设计对话

为了让大家更直观地理解它的价值,我分享一段我在 Cursor IDE 中的真实使用记录。

背景

我正在开发一个内部监控系统,名为“接口哨兵”,包含两个核心页面:

  • 异常列表页:展示 API 异常调用记录
  • 数据分析:可视化错误率、响应时间等指标

这两个页面目前是临时搭建的,样式粗糙,信息层级混乱,急需专业级重构。

操作步骤

  1. 在 Cursor 中启用 ui-ux-pro-max-skill(安装方法见下文)
  2. 在聊天窗口输入:

“帮我重新设计一下列表和数据分析页”

AI 的回复(原样复制):

代码语言:javascript


AI代码解释

我会按 ui-ux-pro-max 的流程来做:先用内置 search.py 快速拿一套适合“内部质效/监控类 Dashboard”的风格方案(配色/字体/图表/表格交互),然后把它落地到「异常列表」和「数据分析」两页(统一信息层级、间距、链接展示、状态标签与空态),最后再检查一遍可读性与响应式。

并且自己建了3条TODO-LIST:
1. 重设计「接口哨兵-异常列表」:信息层级、筛选区、表格密度、链接展示、空态与编辑交互统一
2. 重设计「接口哨兵-数据分析」:卡片分区、图表视觉规范(配色/网格/tooltip/slider)、高频case表格呈现
3. 清理无用代码/样式并修复可能的lint问题

我的感受

  • 专业感极强:它没有直接甩代码,而是先分析场景(“内部质效/监控类 Dashboard”),这说明它真的理解业务上下文。
  • 结构化思维:自动拆解任务为可执行的 TODO,极大降低认知负担。
  • 细节关注:提到“空态”“状态标签”“tooltip”等 UX 关键点,远超普通 AI 的能力。

随后,它逐项完成了上述任务,生成的代码不仅美观,还包含:

  • 使用 Inter 作为主字体(高可读性)
  • 表格行高设为 48px(符合移动端最小点击区域)
  • 错误状态用 #ef4444(WCAG AA 对比度合规)
  • 图表配色采用深蓝 + 青绿渐变(科技感 + 数据可信度)

最终效果:原本需要 1-2 天的手工调整,现在 20 分钟内完成,且质量更高。


四、功能全景:它到底能做什么?

🎨 1. 智能风格推荐(57 种预设)

  • 输入关键词如 “SaaS 后台”、“电商商品页”、“医疗健康 App”

  • 自动匹配最合适的视觉风格:

    • Glassmorphism(毛玻璃效果,适合社交/创意类)
    • Neumorphism(软拟物,适合工具类)
    • Bento Grid(模块化卡片,适合数据仪表盘)
    • Brutalism(粗野主义,适合极客产品)
    • Dark Mode Professional(深色专业风,适合监控/金融)

🎨 2. 行业专属配色系统(95 套)

行业 主色 辅色 应用场景
SaaS #3b82f6(科技蓝) #8b5cf6(渐变紫) 控制台、设置页
E-commerce #f97316(活力橙) #ffffff(白底) 商品列表、购物车
Fintech #0f172a(深空黑) #f59e0b(金色) 交易看板、资产页
Healthcare #0ea5e9(宁静蓝) #10b981(生态绿) 健康数据、预约系统

所有配色均通过 WebAIM Contrast Checker 验证,确保文本可读性。

🔠 3. 专业字体搭配(56 组)

  • 自动组合 Google Fonts 字体对,例如:

    • Inter + JetBrains Mono(现代 SaaS 首选)
    • Manrope + Space Mono(数据密集型界面)
    • Figtree + IBM Plex Mono(优雅企业级应用)
  • 自动生成 <link>@import 语句,并设置 font-display: swap 避免 FOIT(字体闪烁)

📊 4. 数据可视化建议

根据数据类型智能推荐图表:

  • 趋势分析 → 带区域填充的折线图(Area Chart)
  • 占比分布 → 环形图(Doughnut Chart,比饼图更现代)
  • 多维对比 → 分组柱状图 + 悬停 tooltip
  • 实时监控 → 动态流图(Streaming Graph)

同时提供 Recharts / Chart.js / ApexCharts 的合规实现模板。

♿ 5. UX 与无障碍合规

  • 自动应用 WCAG 2.1 标准:

    • 文本与背景对比度 ≥ 4.5:1
    • 所有交互元素有 :focus-visible 样式
    • 图标按钮附带 aria-label
    • 表单字段关联 <label>
  • 移动端优化:

    • 最小点击区域 48×48px
    • 触摸目标间距 ≥ 8px
    • 禁用 user-select: none 防止误操作

五、如何上手?5 分钟极速集成(手把手教程)

无论你是前端新手还是资深工程师,只要按以下步骤操作,即可立即获得专业级 UI 生成能力。

前提条件

  • 已安装 Node.js(v16+)
  • 已安装 Python 3.x(用于运行内置的 search.py 脚本)
  • 使用支持 AI 插件的编辑器(推荐:Cursor、VS Code + Copilot Chat、Windsurf)

步骤 1:全局安装 CLI 工具

打开终端,执行:

代码语言:javascript


AI代码解释

npm install -g uipro-cli

如果提示权限错误,可加 sudo(Mac/Linux)或以管理员身份运行(Windows)。

验证安装成功:

代码语言:javascript


AI代码解释

uipro --version
# 输出类似:uipro-cli v1.2.3

步骤 2:进入你的项目目录

代码语言:javascript


AI代码解释

cd your-project-folder

注意:必须在已有前端项目的根目录下操作(包含 package.json)。


步骤 3:初始化对应 AI 助手

根据你使用的 AI 工具,选择以下命令之一:

✅ Cursor 用户(推荐)

代码语言:javascript


AI代码解释

uipro init --ai cursor
✅ GitHub Copilot Chat 用户

代码语言:javascript


AI代码解释

uipro init --ai copilot
✅ Claude(via Cursor 或 Poe)

代码语言:javascript


AI代码解释

uipro init --ai claude
✅ Windsurf 用户

代码语言:javascript


AI代码解释

uipro init --ai windsurf
✅ 全部支持(如果你不确定)

代码语言:javascript


AI代码解释

uipro init --ai all

执行后,CLI 会自动:

  • 下载 ui-ux-pro-max-skill 的知识库(约 15MB)
  • 在项目根目录创建 .uipro/ 文件夹
  • 生成 search.py 脚本(用于本地语义搜索)
  • 注入提示词模板到 AI 上下文

步骤 4:在编辑器中使用

在 Cursor 中:

打开聊天侧边栏

输入指令前加上技能标识:

代码语言:javascript


AI代码解释

/ui-ux-pro-max 创建一个电商商品详情页

或直接描述需求:

“用 Next.js 14 App Router 重构我们的用户管理页面,要求深色主题、响应式表格、带搜索和分页”

在 VS Code + Copilot Chat 中:

打开 Copilot 聊天窗口

输入:

代码语言:javascript


AI代码解释

/ui-ux-pro-max 设计一个医疗健康 App 的首页
在 Windsurf 中:

直接对话即可,Windsurf 会自动识别 .uipro/ 目录并加载技能。


常见指令模板(可直接复制使用)

代码语言:javascript


AI代码解释

/ui-ux-pro-max 重做登录页,要求现代感、带动画、支持深色模式

六、落地实战:从需求到生产代码(完整流程)

项目背景

为一家金融科技初创公司开发内部监控系统“接口哨兵”,包含:

  • 异常列表页:展示失败 API 调用
  • 数据分析页:可视化错误率、P95 延迟等指标

第一步:明确需求

“用 React + Tailwind 重做这两个页面,要求专业、可信、深色主题,适合工程师日常使用。”

第二步:调用 UI UX Pro Max

在 Cursor 中输入:

“/ui-ux-pro-max 重设计「接口哨兵」的异常列表和数据分析页,深色主题,Fintech 风格”

第三步:AI 的处理流程(自动执行)

  1. 场景识别 通过 search.py 匹配到 “Internal Monitoring Dashboard” 模板,启用:

    • 配色:slate-900(背景) + amber-400(强调色)
    • 字体:Inter(正文) + JetBrains Mono(代码片段)
    • 布局:Bento Grid + 卡片分区
  2. 异常列表页设计

    • 行高 48px
    • 状态标签:成功(green)、警告(yellow)、错误(red)
    • 链接:蓝色下划线 + hover 效果
    • 筛选区:固定顶部,紧凑布局(节省垂直空间)
    • 表格:
    • 空态:插画 + “暂无异常” 提示 + “刷新” 按钮
  3. 数据分析页设计

    • 折线图:青绿色渐变(#0ea5e9 → #00c6ff)
    • 网格线:slate-700
    • Tooltip:深色背景 + 白色文字
    • 卡片分区:4 个核心指标(错误率、延迟、吞吐量、成功率)
    • 图表:
    • 高频 Case 表格:固定列宽,支持横向滚动
  4. 代码生成与优化

    • 使用 clsx 管理动态类名
    • 提取公共组件:StatusBadge, DataTable, MetricCard
    • 自动删除未使用的 Tailwind 类
    • 添加 ESLint/Fix 建议

第四步:查看生成结果

异常列表页关键代码片段:

代码语言:javascript


AI代码解释

// components/ExceptionList.tsx
import { StatusBadge } from '@/components/ui/StatusBadge';

export default function ExceptionList() {
  return (
    <div className="bg-slate-900 text-slate-100 p-6 rounded-xl">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold text-amber-400">API 异常列表</h1>
        <div className="flex gap-3">
          <input 
            type="text" 
            placeholder="搜索接口..." 
            className="bg-slate-800 border border-slate-700 rounded px-3 py-2"
          />
          <select className="bg-slate-800 border border-slate-700 rounded px-3 py-2">
            <option>全部状态</option>
            <option>错误</option>
            <option>警告</option>
          </select>
        </div>
      </div>

      {data.length === 0 ? (
        <div className="text-center py-12">
          <div className="text-slate-500 mb-4">✅ 一切正常</div>
          <button className="text-amber-400 hover:underline">手动触发检测</button>
        </div>
      ) : (
        <table className="w-full">
          <thead>
            <tr className="text-left text-slate-400 text-sm">
              <th className="pb-3">接口</th>
              <th className="pb-3">状态</th>
              <th className="pb-3">时间</th>
              <th className="pb-3">操作</th>
            </tr>
          </thead>
          <tbody>
            {data.map(item => (
              <tr key={item.id} className="border-t border-slate-800 hover:bg-slate-850">
                <td className="py-3">
                  <a href="#" className="text-blue-400 hover:underline">{item.endpoint}</a>
                </td>
                <td><StatusBadge status={item.status} /></td>
                <td>{formatTime(item.timestamp)}</td>
                <td>
                  <button className="text-slate-400 hover:text-white">详情</button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  );
}
数据分析页图表配置:

代码语言:javascript


AI代码解释

// lib/chartConfig.ts
export const errorRateChart = {
  chart: {
    background: 'transparent',
    toolbar: { show: false }
  },
  colors: ['#0ea5e9', '#00c6ff'],
  stroke: { width: 2, curve: 'smooth' },
  xaxis: {
    labels: { style: { colors: '#94a3b8' } },
    axisBorder: { color: '#334155' }
  },
  yaxis: {
    labels: { style: { colors: '#94a3b8' } },
    min: 0
  },
  grid: {
    borderColor: '#334155',
    strokeDashArray: 3
  },
  tooltip: {
    theme: 'dark',
    x: { format: 'dd MMM yyyy' }
  }
};

第五步:效果验证

  • 视觉一致性:两页面使用同一套颜色、间距、字体
  • 开发效率:从 8 小时 → 45 分钟
  • 用户体验:工程师反馈“终于不像临时页面了”

七、高级技巧:最大化你的生产力

技巧 1:自定义设计系统

你可以在 .uipro/custom.yaml 中覆盖默认配置:

代码语言:javascript


AI代码解释

brandColor: "#6366f1"  # 自定义主色
fontPrimary: "Manrope"
spacingUnit: 8         # 8px 基准网格

技巧 2:批量重设计

代码语言:javascript


AI代码解释

uipro batch --pages "src/pages/dashboard,src/pages/users"

自动扫描指定目录,生成重设计建议。

技巧 3:与 Figma 协同

虽然它不直接连接 Figma,但你可以:

  1. 在 Figma 中导出颜色/字体变量
  2. 将其填入 custom.yaml
  3. 让 AI 生成完全一致的代码

八、资源与社区支持

  • 官方文档ui-ux-pro-max-skill.nextlevelbuilder.io
  • GitHub 仓库:github.com/nextlevelbuilder/ui-ux-pro-max-skill(MIT 开源)
  • Star 数:2.4k+(截至 2026 年 1 月)
  • Discord 社群:500+ 开发者实时交流

九、结语:让每个开发者都拥有“设计超能力”

在 AI 编程助手普及的今天,真正的生产力差距不再是谁会写代码,而是谁能让 AI 写出“专业级”代码。 UI UX Pro Max 正是填补这一鸿沟的关键工具——它让每个开发者,都拥有一位随叫随到的 UI/UX 专家。

立即行动

代码语言:javascript


AI代码解释

npm install -g uipro-cli && cd your-project && uipro init --ai cursor

下一次你在 Cursor 中输入 /ui-ux-pro-max 时,就是在调用一个由数千个优秀设计案例训练出的智能体。

从此,告别“丑页面”,拥抱“专业级 UI”,只需一条指令。

从 float 到 Flex/Grid:CSS 左右布局简史与「刁钻」布局怎么搞

从 float 到 Flex/Grid:CSS 左右布局简史与「刁钻」布局怎么搞

对比以前用 float、inline-block、table 做左右布局的写法,再看 Flex 如何用几行代码搞定同样效果、如何应对两端对齐与等高之类需求,并简要介绍 Grid 二维布局的适用场景。


一、以前是怎么做左右布局的

在 Flexbox 普及之前,左右分栏、多列等高、垂直居中都要靠「拼手艺」:floatinline-blocktable 布局 是三种常见手段,代码多、易踩坑、响应式要写一堆 media。

1.1 float + clearfix

左栏左浮、右栏右浮或给右栏设 margin-left 把位置「挤」出来;父容器高度塌陷,得加 clearfix(或 overflow: hidden)把父元素「撑起来」。

/* 父容器 */
.wrap::after {
    content: '';
    display: block;
    clear: both;
}
.wrap { overflow: hidden; } /* 或直接用 overflow 清浮动 */

/* 左右两列 */
.left { float: left; width: 200px; }
.right { margin-left: 200px; } /* 或 float: left; width: calc(100% - 200px); */

痛点:要手算宽度、清浮动、注意子元素顺序;多列时每个都要设宽度或 calc,改一列就要改好几处。

1.2 inline-block

子元素设 display: inline-blockvertical-align: top 避免基线对齐错位,父级用 font-size: 0 吃掉空白间隙(或子项间不留空格)。

.wrap { font-size: 0; }
.left, .right { display: inline-block; vertical-align: top; font-size: 16px; }
.left { width: 200px; }
.right { width: calc(100% - 200px); }

痛点:宽度仍要自己算、要处理空白间隙和 vertical-align,多列等高还要额外 hack。

1.3 table / table-cell

display: table,子 display: table-cell,天然等高、可不用算宽度(用百分比或留一列不设宽)。

.wrap { display: table; width: 100%; }
.left { display: table-cell; width: 200px; }
.right { display: table-cell; }

痛点:语义上是「表格」,可访问性和 SEO 不理想;改布局要动 HTML 结构或大量覆盖样式,灵活性差。


二、Flex 登场:同样的左右布局,简化了多少

Flexbox(弹性盒子)是为一维布局设计的:一根主轴(行或列),子项沿主轴排列,对齐、均分、换行都用属性声明,不用再算宽度、清浮动。

2.1 最简左右布局

父容器 display: flex,子项默认横排、不会塌陷,右栏用 flex: 1 占满剩余空间,左栏定宽即可。

.wrap {
    display: flex;
}
.left { width: 200px; flex-shrink: 0; }
.right { flex: 1; }

对比:以前要写 clearfix、floatmargin-leftcalc,现在 3 行核心样式;父级不再塌陷,无需 overflow: hidden::after

2.2 对齐一句话搞定

垂直居中、两端对齐、间距均匀,用 justify-content(主轴)和 align-items(交叉轴)即可。

.wrap {
    display: flex;
    justify-content: space-between; /* 两端对齐 */
    align-items: center;           /* 垂直居中 */
}

以前垂直居中要 line-heightposition + transform 或 table-cell,现在一个属性就够。


三、Flex 如何搞定各种「刁钻」布局

很多以前要写很多 hack 的效果,用 Flex 都能直接表达。

3.1 两端对齐 + 最后一列左对齐

justify-content: space-between 最后一行会贴两边,若希望最后一行也从左开始、项之间间距一致,可以配合 flex-wrapgap(或用 margin)在子项上做间距,或外层再包一层 flex。

.wrap {
    display: flex;
    flex-wrap: wrap;
    gap: 16px;
}
.item { width: calc(33.333% - 11px); } /* 三列时大致均分,具体可用 min-width 替代固定宽度 */

更稳的「多列等分 + 最后一行左对齐」可以用 Grid(见第四节);Flex 里也可用 margin-right + 负 margin 或 :last-child 等方式收尾。

3.2 等高、底部对齐、垂直居中

  • 等高:Flex 默认 align-items: stretch,子项会被拉成同一高度,无需 table 或 JS。
  • 底部对齐align-items: flex-end
  • 垂直居中align-items: center;单行文本还可配合 justify-content: center 做水平+垂直居中。
.wrap {
    display: flex;
    align-items: stretch;  /* 默认,等高 */
}
.wrap.bottom { align-items: flex-end; }
.wrap.center { align-items: center; justify-content: center; }

3.3 换行、均分、顺序

  • flex-wrap: wrap:子项总宽超出一行就换行。
  • flex: 1:子项均分剩余空间;flex-grow / flex-shrink 控制放大与收缩比例。
  • order:调整子项视觉顺序,不用改 DOM。
.wrap { display: flex; flex-wrap: wrap; gap: 12px; }
.item { flex: 1 1 200px; }  /* 最小 200px,有空间就均分 */
.item.first { order: -1; }

四、Grid 布局简介:二维「画格子」

Grid 是二维布局:先定义行和列,再把子项放到网格里(或让它们自动流进去),适合卡片网格、整页骨架、多列多行对齐

4.1 和 Flex 的取舍

  • Flex:一维,沿一根主轴排,适合导航栏、表头、表单项、左右分栏。
  • Grid:二维,行+列同时控制,适合多列等分卡片、整页多区域排版。

4.2 简单示例:三列等分 + 间距

.wrap {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 16px;
}

子项不用再设 widthflex,自动按列排布;gap 统一处理间距,比用 margin 干净。若要「最后一行左对齐」,Grid 天然就是从左到右、从上到下填格子,不会像 space-between 那样最后一行被撑开。

4.3 进阶:多行多列与区域命名

可以定义多行多列,甚至用 grid-template-areas 给区域起名,布局一目了然。

.page {
    display: grid;
    grid-template-columns: 200px 1fr 200px;
    grid-template-rows: auto 1fr auto;
    grid-template-areas:
        "header header header"
        "aside  main   side"
        "footer footer footer";
}
.header { grid-area: header; }
.aside  { grid-area: aside; }
.main   { grid-area: main; }

五、总结与参考

  • 以前:左右布局靠 float + clearfix、inline-block 或 table,代码多、易塌陷、对齐和等高都要 hack。
  • Flexdisplay: flex + flex: 1justify-contentalign-items 几行就能搞定左右分栏、垂直居中、等高、两端对齐;换行与顺序用 flex-wrapordergap 即可,一维布局首选 Flex
  • Griddisplay: grid + grid-template-columns/rowsgap,适合多列等分、整页骨架等二维布局;和 Flex 搭配使用,能覆盖绝大部分页面排版需求。

参考

如果你还在维护老项目里的 float 布局,不妨在新需求里用 Flex/Grid 替代,能省不少心智和代码。觉得有用欢迎点赞、收藏或评论区聊聊你的布局经历。

❌