普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月6日技术

Tauri 的 Capabilities 权限管理系统

作者 HelloReader
2026年3月6日 13:49

一、为什么需要 Capabilities?

Tauri 应用的前端运行在系统 WebView 中,而后端则是 Rust 编写的原生代码。前端通过 Tauri 提供的 API 与后端通信,从而访问文件系统、窗口管理、系统托盘等原生能力。

问题在于:如果前端代码被攻破(比如 XSS 攻击),攻击者就可能利用这些 API 对用户系统造成危害。Capabilities 系统正是为了应对这类场景而设计的——它让开发者可以精确控制每个窗口或 WebView 能使用哪些权限,将"最小权限原则"落到实处。

二、核心概念

Capabilities 本质上是一组声明式的权限配置,用来定义哪些窗口(window)或 WebView 被授予或拒绝了哪些权限。几个关键特性值得注意:

  • 一个 Capability 可以同时作用于多个窗口或 WebView。
  • 一个窗口也可以被多个 Capability 引用。当窗口属于多个 Capability 时,所有相关 Capability 的权限会合并生效——这意味着安全边界会扩大,配置时需要格外小心。

三、配置方式

Capability 文件以 JSON 或 TOML 格式存放在 src-tauri/capabilities 目录下。Tauri 提供了两种主要的配置方式。

方式一:独立文件 + 引用标识符

这是推荐的做法。在 capabilities 目录下定义独立的 Capability 文件,然后在 tauri.conf.json 中通过标识符引用它们。

首先,定义一个 Capability 文件:

// src-tauri/capabilities/default.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-capability",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:path:default",
    "core:event:default",
    "core:window:default",
    "core:app:default",
    "core:resources:default",
    "core:menu:default",
    "core:tray:default",
    "core:window:allow-set-title"
  ]
}

然后在配置文件中引用:

// src-tauri/tauri.conf.json
{
  "app": {
    "security": {
      "capabilities": ["my-capability", "main-capability"]
    }
  }
}

这种方式的好处是保持 tauri.conf.json 的简洁,同时让权限配置模块化、易于维护。

方式二:内联定义

对于简单场景,也可以直接在 tauri.conf.json 中内联定义 Capability,甚至将内联定义和引用混合使用:

{
  "app": {
    "security": {
      "capabilities": [
        {
          "identifier": "my-capability",
          "description": "My application capability used for all windows",
          "windows": ["*"],
          "permissions": ["fs:default", "allow-home-read-extended"]
        },
        "my-second-capability"
      ]
    }
  }
}

需要注意的是,capabilities 目录下的所有 Capability 文件默认自动启用。但一旦在 tauri.conf.json 中显式指定了 Capability,就只有被指定的那些会生效。

四、自定义命令的权限控制

默认情况下,通过 tauri::Builder::invoke_handler 注册的所有命令对所有窗口开放。如果你希望更精细地控制,可以在 build.rs 中使用 AppManifest::commands 来声明:

// src-tauri/build.rs
fn main() {
    tauri_build::try_build(
        tauri_build::Attributes::new()
            .app_manifest(
                tauri_build::AppManifest::new()
                    .commands(&["your_command"])
            ),
    )
    .unwrap();
}

五、平台特定配置

Capabilities 支持通过 platforms 字段限定作用的目标平台。可选值包括 linuxmacOSwindowsiOSandroid

一个面向桌面端的配置示例:

// src-tauri/capabilities/desktop.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "desktop-capability",
  "windows": ["main"],
  "platforms": ["linux", "macOS", "windows"],
  "permissions": ["global-shortcut:allow-register"]
}

以及面向移动端的配置:

// src-tauri/capabilities/mobile.json
{
  "$schema": "../gen/schemas/mobile-schema.json",
  "identifier": "mobile-capability",
  "windows": ["main"],
  "platforms": ["iOS", "android"],
  "permissions": [
    "nfc:allow-scan",
    "biometric:allow-authenticate",
    "barcode-scanner:allow-scan"
  ]
}

这种设计让你可以为不同平台启用不同的插件能力,同时避免在不支持某些硬件的平台上引入无意义的权限。

六、远程 API 访问

默认情况下,Tauri API 只对随应用打包的本地代码开放。但在某些场景下,你可能需要让远程加载的页面也能调用部分 Tauri 命令。这可以通过 remote 配置实现:

// src-tauri/capabilities/remote-tags.json
{
  "$schema": "../gen/schemas/remote-schema.json",
  "identifier": "remote-tag-capability",
  "windows": ["main"],
  "remote": {
    "urls": ["https://*.tauri.app"]
  },
  "platforms": ["iOS", "android"],
  "permissions": ["nfc:allow-scan", "barcode-scanner:allow-scan"]
}

这里有一个重要的安全提示:在 Linux 和 Android 上,Tauri 无法区分来自嵌入式 <iframe> 的请求和窗口本身的请求。因此在使用远程 API 访问功能时,务必仔细评估安全影响。

七、安全边界:能做什么,不能做什么

理解 Capabilities 系统的安全边界至关重要。

它能防护的场景包括:最小化前端被攻破后的影响、防止或减少本地系统接口和数据的意外暴露、防止从前端到后端/系统的权限提升。

它无法防护的场景包括:恶意或不安全的 Rust 后端代码、过于宽松的 scope 配置、命令实现中未正确检查 scope、来自 Rust 代码的故意绕过、系统 WebView 的零日漏洞、供应链攻击或开发者环境被入侵。

另外,安全边界依赖于窗口的 label(标签),而非 title(标题)。建议只对高权限窗口开放窗口创建功能。

八、Schema 文件与 IDE 支持

Tauri 通过 tauri-build 自动生成 JSON Schema 文件,其中包含了应用可用的所有权限定义。在 Capability 配置文件中设置 $schema 属性后,你的 IDE 就能提供自动补全,大幅提升开发体验:

{
  "$schema": "../gen/schemas/desktop-schema.json"
}

Schema 文件位于 gen/schemas 目录下,通常使用 desktop-schema.jsonmobile-schema.json,也可以为特定平台定义专属的 Schema。

九、项目结构概览

一个典型的 Tauri 应用目录结构如下:

tauri-app
├── index.html
├── package.json
├── src/
├── src-tauri/
│   ├── Cargo.toml
│   ├── capabilities/
│   │   └── <identifier>.json/toml
│   ├── src/
│   └── tauri.conf.json

capabilities 目录存放所有的权限配置文件,每个文件以其 identifier 命名,职责清晰,便于团队协作和代码审查。

十、最佳实践总结

在实际项目中使用 Capabilities 系统时,有几条经验值得参考。首先,遵循最小权限原则,只为每个窗口授予它实际需要的权限。其次,善用独立文件管理——将 Capability 定义为独立文件,通过标识符引用,保持配置清晰。第三,谨慎处理多 Capability 窗口,因为权限会合并,可能意外扩大安全边界。第四,利用平台特定配置,避免在不适用的平台上暴露无意义的权限。最后,对远程 API 访问保持警惕,仔细评估安全影响,尤其是在 Linux 和 Android 上。

Tauri 的 Capabilities 系统体现了"安全默认"的设计哲学——默认情况下,前端的能力是受限的,开发者需要显式地授予权限。这种设计虽然增加了一些配置工作,但换来的是更可控、更安全的应用架构。对于任何关注用户安全的桌面/移动应用项目来说,花时间理解和正确配置这套系统,都是值得的。

关于我明明用了ref还是陷入React闭包陷阱

作者 喵爱吃鱼
2026年3月6日 12:48
const appendChildren = (
  nodes: DataNode[],
  targetKey: React.Key,
  children: DataNode[],
): DataNode[] =>
  nodes.map((node) => {
    if (node.key === targetKey) {
      return {
        ...node,
        children,
      };
    }
    if (node.children && node.children.length) {
      return {
        ...node,
        children: appendChildren(node.children, targetKey, children),
      };
    }
    return node;
  });


export const DepartmentTree = forwardRef<
  DepartmentTreeRef,
  DepartmentTreeProps
>(function DepartmentTree(props, ref) {
  const treeDataRef = useRef<DataNode[]>([]);
  treeDataRef.current = treeData;

  // useEffect(() => {
  //   treeDataRef.current = treeData;
  // }, [treeData]);

  useEffect(() => {
    let shouldIgnore = false;

    const loadRootData = async () => {
      try {
        const rootRequest = newRequest({
          cache: true,
          ...(searchParams ?? {}),
        });
        const rootRes: any = await rootRequest;
        if (shouldIgnore) return;
        const finalTreeData = normalizeNodes(rootRes?.data?.data || []);
        setTreeData(finalTreeData);
      } catch (error) {
        console.warn( error);
      }
    };

    loadRootData();

    return () => {
      shouldIgnore = true;
    };
  }, [searchParams, getAllRootDeptFullPaths]);

  // 异步加载子节点
  const onLoadData: TreeProps['loadData'] = ({ key, children }) =>
    new Promise<void>((resolve) => {
      if (!key || (children && children.length > 0)) {
        resolve();
        return;
      }
      const newSearchParams = _cloneDeep(searchParams ?? {});
      _set(newSearchParams, 'data.code', key);
      newRequest({ cache: true, ...newSearchParams })
        .then((res: any) => {
          const childrenNodes = normalizeNodes(res?.data?.data || []);

          setTreeData((prev) => {
            const newData = appendChildren(prev, key, childrenNodes);
            // 关键
            treeDataRef.current = newData;
            return newData;
          });
        })
        .catch((error) => {
          console.warn( error);
        })
        .finally(() => resolve());
    });
    
  const onExpand = (nextExpandedKeys: React.Key[]) => {
    setExpandedKeys(nextExpandedKeys);
    setAutoExpandParent(false);
  };

  const findNodeByKey = (
    nodes: DataNode[],
    targetKey: React.Key,
  ): DataNode | null => {
    for (const node of nodes) {
      // 如果当前节点的ID就是我们要找的,直接返回这个节点
      if (node.key === targetKey) return node;
      // 如果当前节点有子节点,就递归查找子节点
      if (node.children && node.children.length) {
        const child = findNodeByKey(node.children as DataNode[], targetKey);
        if (child) return child;
      }
    }
    return null;
  };

  const expandPath = useCallback(
    async (pathKeys: string[]) => {
      // 遍历路径中的所有节点ID(除了最后一个,因为最后一个不需要展开)
      for (const key of pathKeys.slice(0, -1)) {
        const current = findNodeByKey(treeDataRef.current, key);
        if (!current) continue;
        // 根据pathKeys顺序,一层层加载子节点数据
        if (!current.children || current.children.length === 0) {
          await onLoadData({ key, children: current.children } as any);
        }
      }
      setExpandedKeys(pathKeys.slice(0, -1));
      setAutoExpandParent(true);
    },
    [onLoadData],
  );

  const handleSearchSelect = useCallback(
    async (value: string, option: any) => {
      const targetKey = option?.deptId ?? option?.value ?? value;
      const path = String(option?.deptFullPath ?? '')
        .split('/')
        .filter(Boolean);

      if (path.length > 0) {
        await expandPath(path);
      }

      setSelectedKeys([targetKey]);
      setSearchValue(value);
    },
    [onSelect, expandPath],
  );

  return (
    <Tree
        loadData={onLoadData}
        treeData={displayTreeData}
        blockNode
        onSelect={handleTreeSelect}
        onExpand={onExpand}
        expandedKeys={expandedKeys}
        autoExpandParent={autoExpandParent}
        selectedKeys={selectedKeys}
     />
  );
});

这是一个非常经典且高阶的 “React 渲染周期 vs JavaScript 执行顺序” 的问题。

简单来说:JavaScript 的代码执行速度(微任务队列)快于 React 的重新渲染速度。

仅仅在组件主体(或 useEffect)中写 treeDataRef.current = treeData 是不够的,因为这两者之间存在一个致命的时间差

下面拆解这个“时间差”到底发生在哪里:

1. 场景重现:如果没有手动赋值

假设 expandPath 正在循环处理 ['A', 'B'] 两个节点(先加载 A,再加载 B)。

第一步:加载节点 A

  1. expandPath 循环开始,找到节点 A。
  2. 调用 await onLoadData(A)
  3. 请求回来,数据有了。
  4. 执行 setTreeData(newData)
    • 注意: 此时 React 仅仅是**“收到了更新通知”**,它把这次更新放入了队列,准备稍后重新渲染组件。
    • 关键点: 组件还没有重新渲染!所以组件最上面的 treeDataRef.current = treeData 这行代码还没有运行!Ref 里存的还是树。
  5. onLoadData 的 Promise 结束(resolve)。

第二步:灾难发生(加载节点 B)

  1. await 结束,JS 引擎立刻继续执行下一行代码(for 循环进入下一次迭代)。
  2. 循环尝试处理节点 B。
  3. 执行 const current = findNodeByKey(treeDataRef.current, 'B')
  4. 问题来了: 此时 React 还没有来得及执行下一次渲染。treeDataRef.current 依然是的(里面还没有 A 的子节点)。
  5. 结果:找不到节点 B,逻辑中断,展开失败。

第三步:马后炮(React 终于渲染了)

  1. JS 的同步代码和微任务跑完后,React 终于开始 Re-render。
  2. 组件函数重新运行。
  3. 执行 treeDataRef.current = treeData(这时候 Ref 才变成新的)。
  4. 但此时 expandPath 的循环早就跑完了,黄花菜都凉了。

2. 为什么手动赋值能解决?

当写成这样时:

setTreeData((prev) => {
  const newData = appendChildren(prev, key, childrenNodes);
  
  // 【关键操作】强行插队
  // 不等 React 渲染,直接在 JS 层面立刻更新 Ref
  treeDataRef.current = newData; 
  
  return newData;
});

流程变成了:

  1. expandPath 调用 onLoadData
  2. 请求回来,执行 setTreeData 的回调。
  3. 立刻更新 treeDataRef.current = newData(这是同步的 JS 操作,不需要等 React 渲染)。
  4. onLoadData Promise 结束。
  5. expandPath 继续循环。
  6. 循环取 treeDataRef.current,此时它已经是最新的了(因为我们在第3步强行更新了它)。
  7. 成功找到下一个节点。

总结

  • treeDataRef.current = treeData (写在组件体内):依赖于 React 的渲染周期。只有等 React 画完下一帧,Ref 才会变。
  • expandPath 中的 await 循环:依赖于 JS 的 Promise 队列。它的速度极快,不会等待 React 的渲染。

结论: 因为 for 循环跑得太快,不等 React 渲染就要用新数据,所以必须在数据产生的源头(setTreeData 回调里)手动、同步地把最新数据塞进 Ref 里,才能追上循环的脚步。

解决 VSCode 中 ESLint 格式化不生效问题:新手也能看懂的配置指南

作者 an31742
2026年3月6日 11:49

解决 VSCode 中 ESLint 格式化不生效问题:新手也能看懂的配置指南

入职新公司接手前端项目,相信很多同学都遇到过这样的糟心事:明明用了同事给的setting.json配置,代码格式化却依然不遵循项目的 ESLint 规则,手动改格式又费时间又容易出错。

我最近就踩了这个坑,折腾了一番终于搞定了,今天把完整的解决方案整理出来,帮大家少走弯路。

一、先确认项目基础配置

在配置 VSCode 之前,首先要确保项目本身的 ESLint 配置是完整的,这是格式化生效的前提。

1. 检查项目根目录的 ESLint 配置文件

首先查看项目根目录下是否存在 ESLint 的核心配置文件,常见的有:

  • .eslintrc.js(最常用,推荐)
  • .eslintrc.json
  • .eslintrc
  • package.json中配置的eslintConfig字段

如果没有这些文件,说明项目本身未配置 ESLint 规则,后续 VSCode 配置再全也没用。可以找同事要一份项目对应的 ESLint 配置,或根据项目技术栈(Vue/React/TS)初始化一份。

2. 确认项目依赖已安装

确保项目node_modules中包含 ESLint 核心依赖及对应插件,比如:

# 安装核心ESLint(如果项目未安装)
npm install eslint --save-dev

# 针对Vue项目补充依赖(示例)
npm install eslint-plugin-vue @vue/eslint-config-standard --save-dev

# 针对React项目补充依赖(示例)
npm install eslint-plugin-react eslint-plugin-react-hooks --save-dev

二、VSCode 端配置:让格式化走 ESLint 规则

1. 安装并启用 ESLint 扩展

打开 VSCode 扩展市场(快捷键Ctrl+Shift+X),搜索ESLint(作者是 dbaeumer),安装后确保启用(扩展卡片显示"已启用")。

2. 配置 settings.json:核心步骤

打开 VSCode 的设置文件(快捷键Ctrl+,,然后点击右上角"打开设置(JSON)"图标),添加以下配置:

{
  // 启用ESLint作为格式化工具
  "eslint.format.enable": true,
  // 指定ESLint需要校验的文件类型
  "eslint.validate": [
    "javascript",
    "javascriptreact",
    "vue",
    "typescript",
    "typescriptreact" // 如有TS/TSX需求可添加
  ],
  // 为不同文件类型指定默认格式化器为ESLint
  "[javascript]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "[javascriptreact]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "[vue]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "[typescript]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "[typescriptreact]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  // 可选:自动保存(避免忘记保存导致格式化不生效)
  "files.autoSave": "afterDelay",
  // 可选:保存时自动格式化(核心!让保存即符合ESLint规则)
  "editor.formatOnSave": true,
  // 可选:保存时自动修复ESLint错误(比单纯格式化更强大)
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  // 可选:关闭其他可能冲突的格式化工具(如Prettier,避免规则冲突)
  "prettier.enable": false
}

关键配置说明

  • eslint.format.enable: 核心开关,允许 ESLint 作为格式化工具
  • eslint.validate: 告诉 ESLint 要处理哪些类型的文件,根据项目技术栈调整
  • editor.defaultFormatter: 为指定文件类型绑定 ESLint 作为默认格式化器,这是解决"格式化不走 ESLint"的核心
  • editor.codeActionsOnSave: 保存时自动修复 ESLint 错误(比如自动补分号、修正缩进),比单纯格式化更实用

三、常见问题排查

如果配置后仍不生效,按以下步骤排查:

  1. 重启 VSCode:修改settings.json后,重启编辑器让配置生效;
  2. 检查 ESLint 扩展状态:打开 VSCode 的"输出"面板(Ctrl+Shift+U),选择"ESLint",查看是否有报错(比如依赖缺失、配置文件语法错误);
  3. 确认文件类型:比如 Vue 文件是否被 VSCode 识别为"vue"类型(右下角可查看/修改);
  4. 排除规则冲突:如果项目同时配置了 Prettier,建议使用eslint-config-prettiereslint-plugin-prettier整合规则,避免冲突。

四、验证配置是否生效

  1. 打开项目中的一个 JS/Vue 文件,故意写一段不符合 ESLint 规则的代码(比如少分号、缩进错误);
  2. 按下Ctrl+S保存文件;
  3. 如果代码自动修正为符合 ESLint 规则的格式,说明配置成功。

总结

  1. 格式化生效的前提是项目有完整的 ESLint 配置文件和依赖,否则 VSCode 端配置无意义;
  2. VSCode 核心配置是绑定对应文件类型的默认格式化器为 ESLint,并启用保存自动修复;
  3. 配置后若不生效,优先检查 ESLint 扩展状态和配置文件语法,重启 VSCode 是简单有效的排查手段。

希望这篇指南能帮到刚入职新项目、被 ESLint 格式化困扰的同学,少踩坑,多写优雅的代码~

🚀《JavaScript 灵魂深处:从 V8 引擎的“双轨并行”看执行上下文的演进之路》

作者 Lee川
2026年3月6日 11:30

引言

“如果你只懂 varlet 的语法区别,那你只看到了冰山一角。真正的魔法,藏在 V8 引擎执行上下文的双轨存储架构里。”

在 JavaScript 的发展历程中,有一个著名的“历史遗留问题”——变量提升(Hoisting)。它曾让无数开发者抓狂,也让 JS 背上了“设计缺陷”的骂名。然而,随着 ES6 的诞生,JavaScript 通过一种巧妙的**“双轨并行”策略**,不仅完美兼容了旧代码,还引入了现代化的块级作用域。

今天,我们将结合您提供的完整文档(readme.md8.js),深入 V8 引擎的底层机制,剖析执行上下文、作用域链、变量环境 vs 词法环境的奥秘。特别是针对 7.js 中的经典案例,我们将借助两张精美的示意图,为您揭开 JavaScript 变量管理的终极真相。


📜 第一章:历史的回响——为什么 JavaScript 会有“变量提升”?

1.1 一个“KPI 项目”的意外走红

正如 readme.md 中所言,JavaScript 最初只是 Netscape 为了浏览器竞争而快速推出的“KPI 项目”。设计周期极短,目标简单:给静态页面加点动态效果

在那个年代,复杂的面向对象特性(如 class, constructor, private 等)并不是首要任务。为了追求最快、最简单的实现方案,设计师做出了两个关键决定:

  1. 不支持块级作用域if, for, while 等代码块 {} 内部声明的变量,直接暴露在外层。
  2. 引入变量提升:将所有变量声明统一“抬升”到函数顶部,简化编译器的实现逻辑。

1.2 变量提升的“双刃剑”

让我们看看 4.js 中的经典案例:

showName();
console.log(myname);
var myname = "张三";
function showName() {
    console.log("函数 showName 执行了");
}

这段代码之所以能运行(不报错),是因为在编译阶段,JS 引擎做了如下处理:

// 编译后的伪代码
function showName() { ... } // 函数声明提升
var myname;                 // 变量声明提升,初始化为 undefined

showName();                 // 输出:函数 showName 执行了
console.log(myname);        // 输出:undefined (因为赋值语句还没执行)
myname = "张三";            // 执行赋值

⚠️ 缺陷暴露

  • 变量容易被意外覆盖(见 2.js 中的 var name 遮蔽全局变量)。
  • 本应销毁的变量因提升而长期驻留内存。
  • 代码行为与直觉不符,增加调试难度。

🌍 第二章:ES6 的救赎——“双轨并行”的巧妙设计

面对历史包袱,ES6 没有选择“推倒重来”(那样会破坏海量旧代码),而是采取了一种兼容性极强的解决方案:在执行上下文中实行“双轨并行”存储机制

2.1 执行上下文的双核架构

当 JavaScript 引擎执行一个函数时,会创建一个执行上下文(Execution Context)。在 ES6 及以后,这个上下文被划分为两个独立但协同工作的区域:

轨道 名称 管理对象 特性 对应关键字
轨道一:变量环境 (Variable Environment) 传统轨道 var 声明的变量 函数作用域、变量提升、可重复声明 var
轨道二:词法环境 (Lexical Environment) 现代轨道 let, const 声明的变量 块级作用域、暂时性死区 (TDZ)、不可重复声明 let, const

💡 核心思想

  • var 继续留在变量环境轨道,享受“提升特权”,保证旧代码正常运行。
  • let/const 进入全新的词法环境轨道,支持块级作用域,杜绝提升带来的隐患。
  • 两条轨道在同一个执行上下文中并行存在,互不干扰却又协同工作。

2.2 词法环境的“栈结构”秘密

readme.md 中提到:“块级作用域中通过 let/const 声明的变量,会被放在词法环境的一个单独的区域中,维护了一个小型栈结构。

这意味着:

  • 每进入一个块级作用域 {},引擎就在词法环境中压入一个新的“帧”(Frame)。
  • 变量查找时,优先从栈顶(当前块)开始。
  • 块执行完毕,该帧弹出,内部变量立即销毁,外界无法访问。

这正是 6.jsfor(let i=0;...) 循环后 i 未定义的原因,也是 8.js 中“暂时性死区”产生的根源。


🔍 第三章:实战演练——从 1.js8.js 的全景解析

现在,让我们遍历所有文件,逐一验证上述理论。

🧪 案例 1:作用域链的基础(1.js & 5.js

// 1.js
let name = "流萤";
function showName(){
    console.log(name); // 流萤
    if(true){
        let name = "大厂的苗子" // 块级变量,不影响外层
    }
}
showName();

// 5.js
var globalVar='我是全局变量';
function myFunction() {
    var localVar = '我是局部变量';
    console.log(globalVar); // 可访问
    console.log(localVar);  // 可访问
}
myFunction();
console.log(localVar); // ❌ ReferenceError: localVar is not defined

解析

  • 1.js 展示了 let 的块级隔离性:块内 name 不影响块外。
  • 5.js 展示了函数作用域的边界:localVar 仅在函数内有效。

🧪 案例 2:变量提升的陷阱(2.js & 4.js

// 2.js
var name = '张三';
function showName() {
    console.log(name); // undefined (局部变量遮蔽全局)
    if(false) {
        var name = '李四'; // 声明提升,赋值不执行
    }
    console.log(name); // undefined
}
showName();

解析

  • var name 在函数内被提升,导致全局 name 被遮蔽。
  • 即使 if(false) 不执行,name 仍存在于局部作用域,值为 undefined

🧪 案例 3:块级作用域的胜利(6.js & 8.js

// 6.js
function foo() {
    for(let i=0;i<7;i++) { }
    console.log(i); // ❌ ReferenceError: i is not defined
}
foo();

// 8.js
let name = '流萤';
{
    console.log(name); // ✅ 输出 "流萤" (访问外层)
    let othername = '大厂的苗子';
}
// 若取消注释下方代码,将触发 TDZ
// {
//     console.log(name); // ❌ ReferenceError
//     let name = '大厂的苗子';
// }

解析

  • 6.js 证明 let 循环变量仅限块内。
  • 8.js 展示两种情况:
    • 块内无同名 let → 访问外层变量。
    • 块内有同名 let → 触发暂时性死区 (TDZ),禁止在声明前访问。

🖼️ 第四章:深度图解——7.js 与执行上下文的视觉化

现在,我们来到本文的高潮部分:7.js 的代码与您提供的两张示意图。这两张图完美诠释了“双轨并行”机制在实际运行中的状态变化。

📄 代码回顾

function foo() {
    var a = 1;
    let b = 2;
    {
        let b = 3;
        var c = 4;
        let d = 5;
        console.log(a); // 1
        console.log(b); // 3
    }
    console.log(b); // 2
    console.log(c); // 4
    console.log(d); // ❌ ReferenceError
}
foo();

🖼️ 图一:函数初始化状态(预编译阶段)

image.png

此时,函数刚被调用,引擎完成“预编译”,双轨开始运作:

  • 左轨:变量环境

    • a = 1var a 已声明并赋值。
    • c = undefinedvar c 被提升到函数顶(变量环境顶层),但尚未赋值。
  • 右轨:词法环境

    • 外层帧:b = 2let b 已初始化。
    • 内层帧(块级):b = undefined, d = undefined ← 已绑定但未初始化(处于 TDZ)。

📌 关键点var c 虽在块内代码中书写,却出现在变量环境的顶层;而 let b/d 则严格限制在词法环境的块级帧中。这就是双轨并行的直观体现。

🖼️ 图二:执行到块内 console.log 时的状态

image.png

程序执行流进入块内,并完成赋值操作,双轨状态发生动态变化:

  • 左轨:变量环境

    • a = 1 ← 保持不变。
    • c = 4var c = 4 已执行,赋值成功!注意它依然位于函数级的变量环境中。
  • 右轨:词法环境

    • 外层帧:b = 2 ← 保留,暂时被遮蔽。
    • 内层帧(当前激活):
      • b = 3 ← 块内 let b = 3 已赋值,遮蔽了外层帧的 b
      • d = 5 ← 已赋值。

🔄 查找规则(双轨协同)

  • console.log(a) → 引擎查询变量环境 → 找到 1
  • console.log(b) → 引擎查询词法环境,从栈顶(内层帧)开始 → 找到 3(忽略外层 b=2)。

🎬 完整执行流程表

步骤 代码 输出/结果 原因分析
1 console.log(a) 1 访问变量环境中的 a
2 console.log(b) 3 访问词法环境栈顶的 b(块内遮蔽外层)
3 块结束 块级词法环境帧弹出,b=3, d=5 销毁
4 console.log(b) 2 恢复访问词法环境外层的 b
5 console.log(c) 4 访问变量环境中的 c(函数级有效)
6 console.log(d) ❌ Error d 位于已销毁的块级词法环境帧中,外界不可见

🛠️ 第五章:开发者指南——如何驾驭这套机制?

✅ 最佳实践

  1. 优先使用 letconst:利用词法环境轨道的块级特性,避免 var 的提升和函数作用域陷阱。
  2. 明确作用域边界:用 {} 包裹逻辑块,防止变量泄露到不必要的范围。
  3. 警惕 TDZ:不要在 let/const 声明前访问变量,理解这是词法环境的保护机制。
  4. 利用 DevTools 调试:观察 Scope 面板,你会清晰地看到“Variable”和“Local/Lexical”两个不同的区域。

常见误区

  • ❌ “let 也会提升” → 错!let 有“绑定提升”,但存在 TDZ,在声明前不可访问。
  • ❌ “块级作用域是新的作用域类型” → 不准确!它是词法环境中的“栈帧”,而非独立的作用域类型。
  • ❌ “var 在块内无效” → 错!var 无视块级,始终提升至变量环境的函数顶层。

🌟 结语:理解执行上下文,就是理解 JavaScript 的灵魂

readme.md 的历史回顾,到 7.js 的深度图解,我们走完了一段从“设计缺陷”到“优雅兼容”的旅程。JavaScript 通过变量环境与词法环境的“双轨并行”架构,成功实现了新旧语法的完美融合:既尊重了历史,又拥抱了未来。

下次当你写下 letvar 时,请记住:

你不仅仅是在声明一个变量,你是在指挥 V8 引擎在两条不同的轨道上存储数据。

掌握这套机制,你将不再畏惧任何作用域谜题,写出更健壮、更高效的代码。


📚 附录:核心概念速查表

概念 描述 示例
变量提升 var 声明移至函数顶 var x; x=1;
暂时性死区 (TDZ) let/const 声明前不可访问 console.log(y); let y=1; → Error
作用域链 变量查找路径:当前 → 外层 → 全局 内层 b 遮蔽外层 b
词法环境 存储 let/const,支持块级栈结构 { let a=1; }
变量环境 存储 var,函数级作用域 function(){ var b; }
双轨并行 执行上下文中同时存在变量环境和词法环境 var 走左轨,let 走右轨

🎉 恭喜! 你现在已掌握 JavaScript 执行上下文的核心精髓。无论是面试、工作还是开源贡献,这套知识都将是你最强大的武器。

前端JS: 数组扁平化

2026年3月6日 11:14

JavaScript 数组扁平化实现详解

一、扁平化概念

数组扁平化是指将一个多维数组转换为一维数组的过程:

// 多维数组
const arr = [1, [2, [3, [4, 5]], 6], 7];

// 扁平化后
// [1, 2, 3, 4, 5, 6, 7]

二、原生方法(ES2019+)

1. Array.prototype.flat()

const arr = [1, [2, [3, [4, 5]], 6], 7];

// 默认只展开一层
console.log(arr.flat()); // [1, 2, [3, [4, 5]], 6, 7]

// 指定展开深度
console.log(arr.flat(2)); // [1, 2, 3, [4, 5], 6, 7]

// 完全展开(Infinity表示无限深度)
console.log(arr.flat(Infinity)); // [1, 2, 3, 4, 5, 6, 7]

// 移除空位
console.log([1, 2, , 3, 4].flat()); // [1, 2, 3, 4]

三、手动实现方法

1. 递归实现(基础版)

function flatten(arr) {
  let result = [];
  
  for (let i = 0; i < arr.length; i++) {
    if (Array.isArray(arr[i])) {
      result = result.concat(flatten(arr[i]));
    } else {
      result.push(arr[i]);
    }
  }
  
  return result;
}

// 使用示例
const arr = [1, [2, [3, [4, 5]], 6], 7];
console.log(flatten(arr)); // [1, 2, 3, 4, 5, 6, 7]

2. 递归实现(可指定深度)

function flattenDepth(arr, depth = 1) {
  if (depth === 0) return arr.slice(); // 深度为0,直接返回副本
  
  let result = [];
  
  for (let i = 0; i < arr.length; i++) {
    if (Array.isArray(arr[i]) && depth > 0) {
      result = result.concat(flattenDepth(arr[i], depth - 1));
    } else {
      result.push(arr[i]);
    }
  }
  
  return result;
}

// 使用示例
const arr = [1, [2, [3, [4, 5]], 6], 7];
console.log(flattenDepth(arr, 1)); // [1, 2, [3, [4, 5]], 6, 7]
console.log(flattenDepth(arr, 2)); // [1, 2, 3, [4, 5], 6, 7]
console.log(flattenDepth(arr, Infinity)); // [1, 2, 3, 4, 5, 6, 7]

3. 使用reduce实现

function flattenReduce(arr) {
  return arr.reduce((result, current) => {
    return result.concat(
      Array.isArray(current) ? flattenReduce(current) : current
    );
  }, []);
}

// 带深度的reduce版本
function flattenReduceDepth(arr, depth = 1) {
  return depth > 0
    ? arr.reduce((acc, val) => 
        acc.concat(Array.isArray(val) 
          ? flattenReduceDepth(val, depth - 1) 
          : val
        ), [])
    : arr.slice();
}

4. 使用栈实现(非递归)

function flattenStack(arr) {
  const stack = [...arr];
  const result = [];
  
  while (stack.length) {
    const next = stack.pop();
    
    if (Array.isArray(next)) {
      // 将数组元素推入栈中(注意保持顺序)
      stack.push(...next.slice().reverse());
    } else {
      result.push(next);
    }
  }
  
  return result.reverse();
}

// 优化版本(保持顺序)
function flattenStackOrdered(arr) {
  const stack = [];
  const result = [];
  let current = arr;
  let i = 0;
  
  while (current !== undefined) {
    if (i < current.length) {
      const item = current[i];
      i++;
      
      if (Array.isArray(item)) {
        // 保存当前状态
        stack.push({ current, i });
        // 切换到子数组
        current = item;
        i = 0;
      } else {
        result.push(item);
      }
    } else if (stack.length > 0) {
      // 恢复上一个状态
      const saved = stack.pop();
      current = saved.current;
      i = saved.i;
    } else {
      current = undefined;
    }
  }
  
  return result;
}

5. 使用toString()方法

function flattenToString(arr) {
  return arr.toString()
    .split(',')
    .map(item => {
      // 转换回适当的数据类型
      const num = Number(item);
      return isNaN(num) ? item : num;
    });
}

// 注意:这种方法会将所有元素转为字符串再解析
// 只适用于纯数字数组或可转换为字符串的元素
const arr = [1, [2, [3, [4, 5]], 6], 7];
console.log(flattenToString(arr)); // [1, 2, 3, 4, 5, 6, 7]

// 局限性示例
const mixedArr = [1, [2, ['a', ['b', 'c']]], 3];
console.log(flattenToString(mixedArr)); // [1, 2, 'a', 'b', 'c', 3]

总结

推荐方法选择

  1. 现代项目(支持ES2019+) :直接使用arr.flat(Infinity)
  2. 需要深度控制:使用递归版本flattenDepth
  3. 大数组或性能敏感:使用栈实现的非递归版本
  4. 需要处理循环引用:使用flattenSafe或完整版
  5. 简单场景:使用reduce或递归基础版

注意事项

  • 方法选择要考虑浏览器兼容性
  • 递归方法可能导致栈溢出(深度过大)
  • 字符串转换方法有类型丢失问题
  • 注意处理稀疏数组和循环引用
  • 性能测试显示原生flat通常最快,栈实现次之

Nest 项目小实践之图书增删改查

作者 前端付豪
2026年3月6日 11:05

写图书新增、修改、删除、详情功能

新建 BookManage/CreateBookModal.tsx

import { Button, Form, Input, Modal, message } from "antd";
import { useForm } from "antd/es/form/Form";
import TextArea from "antd/es/input/TextArea";

interface CreateBookModalProps {
    isOpen: boolean;
    handleClose: Function
}
const layout = {
    labelCol: { span: 6 },
    wrapperCol: { span: 18 }
}

export interface CreateBook {
    name: string;
    author: string;
    description: string;
    cover: string;
}

export function CreateBookModal(props: CreateBookModalProps) {

    const [form] = useForm<CreateBook>();

    const handleOk = async function() {

    }

    return <Modal title="新增图书" open={props.isOpen} onOk={handleOk} onCancel={() => props.handleClose()} okText={'创建'}>
        <Form
            form={form}
            colon={false}
            {...layout}
        >
            <Form.Item
                label="图书名称"
                name="name"
                rules={[
                    { required: true, message: '请输入图书名称!' },
                ]}
            >
                <Input />
            </Form.Item>
            <Form.Item
                label="作者"
                name="author"
                rules={[
                    { required: true, message: '请输入图书作者!' },
                ]}
            >
                <Input />
            </Form.Item>
            <Form.Item
                label="描述"
                name="description"
                rules={[
                    { required: true, message: '请输入图书描述!' },
                ]}
            >
                <TextArea/>
            </Form.Item>
            <Form.Item
                label="封面"
                name="cover"
                rules={[
                    { required: true, message: '请上传图书封面!' },
                ]}
            >
                <Input/>
            </Form.Item>
        </Form>
    </Modal>
}

在 BookManage/index.tsx 调用

const [isCreateBookModalOpen, setCreateBookModalOpen] = useState(false);
    
 <Button type="primary" htmlType="submit" style={{background: 'green'}} onClick={ () =>setCreateBookModalOpen(true)}>
  添加图书
   </Button>
   
 <CreateBookModal isOpen={isCreateBookModalOpen} handleClose={() => {
  setCreateBookModalOpen(false);
  }}></CreateBookModal>

点击添加图书就会展示

image.png

在 interfaces/index.ts 里添加 /book/create 接口

export async function create(book: CreateBook) {
  return await axiosInstance.post("/book/create", {
    name: book.name,
    author: book.author,
    description: book.description,
    cover: book.cover,
  });
}

在 CreateBookModal 组件调用

const handleOk = async function() {
    await form.validateFields();

    const values = form.getFieldsValue();

    try {
        const res = await create(values);

        if(res.status === 201 || res.status === 200) {
            message.success('创建成功');
            form.resetFields();
            props.handleClose();
        }
    } catch(e: any) {
        message.error(e.response.data.message);
    }
}

可以新增图书

image.png

只是没有封面图

添加 BookManage/Coverupload.tsx 组件

import { InboxOutlined } from "@ant-design/icons";
import { message } from "antd";
import Dragger, { type DraggerProps } from "antd/es/upload/Dragger";

interface CoverUploadProps {
    value?: string;
    onChange?: Function
}

let onChange: Function;

const props: DraggerProps = {
    name: 'file',
    action: 'http://localhost:3000/book/upload',
    method: 'post',
    onChange(info) {
        const { status } = info.file;
        if (status === 'done') {
            onChange(info.file.response);
            message.success(`${info.file.name} 文件上传成功`);
        } else if (status === 'error') {
            message.error(`${info.file.name} 文件上传失败`);
        }
    }
};

const dragger = <Dragger {...props}>
    <p className="ant-upload-drag-icon">
        <InboxOutlined />
    </p>
    <p className="ant-upload-text">点击或拖拽文件到区域上传</p>
</Dragger>

export function CoverUpload(props: CoverUploadProps) {

    onChange = props.onChange!

    return props?.value ? <div>
        <img src={'http://localhost:3000/' + props.value} alt="封面" width="100" height="100"/>
        {dragger}
    </div>: <div>
        {dragger}
    </div>
}

image.png

添加成功

image.png

修改如何做 ? 新建 BookManage/UpdateBookModal.tsx

需要带上 id

import { Button, Form, Input, Modal, message } from "antd";
import { useForm } from "antd/es/form/Form";
import TextArea from "antd/es/input/TextArea";
import { CoverUpload } from "./CoverUpload";

interface UpdateBookModalProps {
    id: number;
    isOpen: boolean;
    handleClose: Function
}
const layout = {
    labelCol: { span: 6 },
    wrapperCol: { span: 18 }
}

export interface UpdateBook {
    id: number;
    name: string;
    author: string;
    description: string;
    cover: string;
}

export function UpdateBookModal(props: UpdateBookModalProps) {

    const [form] = useForm<UpdateBook>();

    const handleOk = async function() {

    }

    return <Modal title="更新图书" open={props.isOpen} onOk={handleOk} onCancel={() => props.handleClose()} okText={'更新'}>
        <Form
            form={form}
            colon={false}
            {...layout}
        >
            <Form.Item
                label="图书名称"
                name="name"
                rules={[
                    { required: true, message: '请输入图书名称!' },
                ]}
            >
                <Input />
            </Form.Item>
            <Form.Item
                label="作者"
                name="author"
                rules={[
                    { required: true, message: '请输入图书作者!' },
                ]}
            >
                <Input />
            </Form.Item>
            <Form.Item
                label="描述"
                name="description"
                rules={[
                    { required: true, message: '请输入图书描述!' },
                ]}
            >
                <TextArea/>
            </Form.Item>
            <Form.Item
                label="封面"
                name="cover"
                rules={[
                    { required: true, message: '请上传图书封面!' },
                ]}
            >
                <CoverUpload></CoverUpload>
            </Form.Item>
        </Form>
    </Modal>
}

在 BookManage index.tsx 引入使用

const [isUpdateBookModalOpen, setUpdateBookModalOpen] = useState(false); const [updateId, setUpdateId] = useState(0);

<UpdateBookModal id={updateId} isOpen={isUpdateBookModalOpen} handleClose={() => { setUpdateBookModalOpen(false); setName(''); }}></UpdateBookModal>

<a href="#" onClick={() => { setUpdateId(book.id); setUpdateBookModalOpen(true); }}>编辑</a>

点击编辑

image.png

interfaces/index.ts 里加一下接口

export async function detail(id: number) {
  return await axiosInstance.get(`/book/${id}`);
}

UpdateBookModal.tsx 添加

async function query() {
    if(!props.id) {
        return;
    }
    try{
        const res = await detail(props.id);
        const { data } = res;
        debugger;
        if(res.status === 200 || res.status === 201) {
            form.setFieldValue('id', data.id);
            form.setFieldValue('name', data.name);
            form.setFieldValue('author', data.author);
            form.setFieldValue('description', data.description);
            form.setFieldValue('cover', data.cover);
        } 
    } catch(e: any){
        message.error(e.response.data.message);
    }
}

useEffect(() => {
    query();
}, [props.id]);

点击编辑 就会带出已有的信息

image.png

更新 interfaces/index.ts

export async function update(book: UpdateBook) {
    return await axiosInstance.put('/book/update', {
        id: book.id,
        name: book.name,
        author: book.author,
        description: book.description,
        cover: book.cover
    });
}

UpdateBookModal.tsx 调用

    const handleOk = async function() {
    await form.validateFields();

    const values = form.getFieldsValue();

    try {
        const res = await update({...values, id: props.id});

        if(res.status === 201 || res.status === 200) {
            message.success('更新成功');
            props.handleClose();
        }
    } catch(e: any) {
        message.error(e.response.data.message);
    }
}

可以成功更新

image.png

BookManage index.tsx

<Popconfirm
    title="图书删除"
    description="确认删除吗?"
    onConfirm={() => handleDelete(book.id)}
    okText="Yes"
    cancelText="No"
>  
    <a href="#">删除</a>
</Popconfirm>

async function handleDelete(id: number) {
    try {
        await deleteBook(id);        
        message.success('删除成功');
        setNum(Math.random())
    } catch(e: any) {
        message.error(e.response.data.message);
    }
}

interfaces/index.ts

export async function deleteBook(id: number) { return await axiosInstance.delete(`/book/delete/${id}`); }

成功删除

image.png

后续会新增一些优化部分

  • 登录之后怎么保存登录状态?比如有的接口需要登录才能访问,怎么控制?

这需要用 session + cookie 或 jwt 的方式来实现登录状态的保存。

  • 数据保存在文件里并不方便,还有啥更好的方式?

保存在 mysql 数据库,用 TypeORM 作为 ORM 框架。

  • 后端接口怎么提供 api 文档?

这需要用 swagger

  • 文件保存在文件目录下,如果磁盘空间满了怎么办?

可以换用 minio 或者阿里 OSS 等对象存储服务。

  • 怎么部署?

前端用 nginx,后端代码用 docker 和 docker compose

  • 如何实现验证码?

可以用 nodemailer 发送邮件,然后用 redis 保存验证码数据。

前端导出 Word/Excel/PDF 文件

2026年3月6日 11:00

核心思路是:后端返回文件二进制流 → 前端接收并转换为 Blob → 创建下载链接触发保存

一、核心实现步骤(通用逻辑)

  1. 后端接口需返回 文件二进制流(Content-Type 对应文件类型),而非 JSON。
  2. 前端请求时设置 responseType: 'blob',确保接收二进制数据。
  3. 将 Blob 转换为临时下载链接,模拟点击实现文件保存。
  4. 清理临时链接,避免内存泄漏。

二、完整代码实现(Axios 版本,最常用)

1. 导出 Excel 文件(.xlsx)

// 安装依赖:npm install axios
import axios from 'axios';

/**
 * 导出Excel文件
 * @param {string} apiUrl - 接口地址
 * @param {object} params - 接口参数(如筛选条件)
 * @param {string} fileName - 自定义文件名(无需后缀)
 */
export const exportExcel = async (apiUrl, params, fileName = '导出数据') => {
  try {
    // 1. 发送请求,指定接收二进制流
    const response = await axios({
      url: apiUrl,
      method: 'GET', // 也可POST,根据后端接口调整
      params: params, // POST请用data: params
      responseType: 'blob', // 关键:指定返回Blob类型
      headers: {
        'Content-Type': 'application/json', // 根据后端要求调整
        // 如有token,添加认证:
        // 'Authorization': `Bearer ${localStorage.getItem('token')}`
      }
    });

    // 2. 处理返回的Blob数据
    const blob = new Blob([response.data], {
      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' // Excel MIME类型
    });

    // 3. 创建临时下载链接
    const downloadUrl = window.URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = downloadUrl;
    link.download = `${fileName}.xlsx`; // 文件名+后缀
    document.body.appendChild(link);

    // 4. 触发下载
    link.click();

    // 5. 清理资源
    document.body.removeChild(link);
    window.URL.revokeObjectURL(downloadUrl);

    console.log('Excel文件导出成功');
  } catch (error) {
    console.error('Excel导出失败:', error);
    alert('文件导出失败,请重试');
  }
};

// 调用示例
// exportExcel('/api/export/user', { department: '技术部' }, '技术部用户列表');

2. Blob的type值

  • 如果你的接口返回的是 .xls 格式(旧版二进制),就用:

    const type = 'application/vnd.ms-excel';
    const blob = new Blob([response.data], { type: type });
    

    下载时文件名后缀用 .xls

  • 如果接口返回的是 .xlsx 格式(新版 XML),就用:

    const type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
    const blob = new Blob([response.data], { type: type });
    

    下载时文件名后缀用 .xlsx

⚠️ 注意:MIME 类型必须和实际文件格式一致,否则可能导致下载的文件无法打开。

3. 导出 Word 文件(.docx)

仅需修改 Blob 的 type 和文件名后缀,核心逻辑完全一致:

// 新版 文件格式.docx
    const blob = new Blob([response.data], {
      type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' // Word MIME类型
    });
    
// 旧版 文件格式.doc
const blob = new Blob([response.data], {
      type: 'application/msword' 
    });

  

4. 导出 PDF 文件(.pdf)

    // 创建 Blob,指定 PDF 类型
    const blob = new Blob([response.data], {
      type: 'application/pdf'
    });

@logicflow/vue-node-registry 在 Vite 中无法解析的踩坑记录与解决方案

作者 codeniu
2026年3月6日 10:59

@logicflow/vue-node-registry 在 Vite 中无法解析的踩坑记录与解决方案

前言

在使用 LogicFlow 的 Vue 节点注册库 @logicflow/vue-node-registry 时,特别是在 Vite 构建工具环境下,开发者经常会遇到模块解析失败的问题。本文记录了一次完整的踩坑过程,从 Failed to resolve entry 到类型声明缺失,最终到内部 API 访问的各种问题,并提供系统性的解决方案。


问题一:Pre-transform Error - 无法解析包入口

错误现象

Pre-transform error: Failed to resolve entry for package "@logicflow/vue-node-registry". 
The package may have incorrect main/module/exports specified in its package.json.

根本原因

Vite 无法正确识别 @logicflow/vue-node-registry 的入口文件。这通常是因为:

  1. 包的 package.jsonexports 字段配置不规范
  2. Vite 的依赖预构建(optimizeDeps)未能正确处理该包
  3. 版本兼容性问题(如使用 2.0.x 版本的 LogicFlow 却搭配了不兼容的 registry 版本)

解决方案

1. 配置 Vite Alias(必须使用绝对路径)
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      // ❌ 错误:相对路径会导致 Vite 在项目根目录查找
      // '@logicflow/vue-node-registry': '@logicflow/vue-node-registry/dist/index.js'
      
      // ✅ 正确:使用 path.resolve 转换为绝对路径
      '@logicflow/vue-node-registry': path.resolve(
        __dirname, 
        'node_modules/@logicflow/vue-node-registry/dist/index.esm.js'
      )
    }
  },
  optimizeDeps: {
    include: ['@logicflow/vue-node-registry', '@logicflow/core']
  }
})

关键要点:Vite 的 alias 必须使用绝对路径,否则会报 was not an absolute path 警告,并导致模块重复或找不到文件。

2. 对于 ES Module 项目(package.json 中 "type": "module")
import { fileURLToPath } from 'url'
import { dirname } from 'path'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

export default defineConfig({
  resolve: {
    alias: {
      '@logicflow/vue-node-registry': path.resolve(
        __dirname,
        'node_modules/@logicflow/vue-node-registry/dist/index.esm.js'
      )
    }
  }
})

问题二:TypeScript 类型声明缺失

错误现象

找不到模块“@logicflow/vue-node-registry”或其相应的类型声明。

根本原因

虽然 Vite 能解析模块,但 TypeScript 编译器无法找到对应的 .d.ts 类型声明文件。

解决方案

方案 A:创建类型声明文件(推荐)

在项目 src 目录下创建 types/logicflow.d.ts

declare module '@logicflow/vue-node-registry' {
  import { DefineComponent } from 'vue'
  import LogicFlow, { NodeModel } from '@logicflow/core'

  export interface VueNodeConfig {
    type: string
    view: DefineComponent<{}, {}, any>
    model?: typeof NodeModel
  }

  export function register(options: VueNodeConfig, lf: LogicFlow): void
  export function getTeleport(): DefineComponent<{}, {}, any>
  export class VueNodeModel extends NodeModel {
    static extendKey: string
  }
}
方案 B:配置 tsconfig.json 路径映射
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@logicflow/vue-node-registry": [
        "node_modules/@logicflow/vue-node-registry/dist/index.d.ts"
      ]
    }
  }
}

问题三:内部 API 访问限制 - vueNodesMap

错误现象

模块“"@logicflow/vue-node-registry"”没有导出的成员“vueNodesMap”。

根本原因

vueNodesMap@logicflow/vue-node-registry内部实现细节,主要用于在 Vue 节点组件内部监听属性变化。它不是官方公开的 API,因此在类型声明中默认不存在。

正确使用方式

vueNodesMap 应该只在自定义节点组件内部使用,配合 inject 获取节点实例:

<script lang="ts">
import { defineComponent, inject } from 'vue'
import { EventType } from '@logicflow/core'
// @ts-ignore 或扩展类型声明
import { vueNodesMap } from '@logicflow/vue-node-registry'

export default defineComponent({
  name: 'CustomVueNode',
  
  setup() {
    const getNode = inject('getNode') as () => any
    const getGraph = inject('getGraph') as () => any
    
    const node = getNode()
    const graph = getGraph()
    
    // 监听属性变化
    graph.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, (eventData) => {
      const content = vueNodesMap[node.type]
      if (content && eventData.id === node.id) {
        const { effect } = content
        if (!effect || eventData.keys.some((key) => effect.includes(key))) {
          // 处理属性变化逻辑
        }
      }
    })
    
    return {}
  }
})
</script>

如需在业务代码中使用

扩展类型声明文件:

// src/types/logicflow.d.ts
declare module '@logicflow/vue-node-registry' {
  import { Component } from 'vue'
  import LogicFlow, { NodeModel } from '@logicflow/core'

  export interface VueNodeConfig {
    type: string
    view: Component
    model?: typeof NodeModel
    effect?: string[] // 需要监听的属性 key
  }

  // 内部映射表,谨慎使用
  export const vueNodesMap: Record<string, VueNodeConfig>
  
  export function register(options: VueNodeConfig, lf: LogicFlow): void
  export function getTeleport(): Component
}

⚠️ 警告vueNodesMap 作为内部 API,可能在版本更新中变更。建议仅在必要时使用,并关注官方更新日志。


完整配置参考

项目结构

project/
├── src/
│   ├── types/
│   │   └── logicflow.d.ts      # 类型声明文件
│   ├── nodes/                  # 自定义节点目录
│   │   ├── CustomNode.vue
│   │   └── config.ts
│   └── main.ts
├── vite.config.ts
└── tsconfig.json

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@logicflow/vue-node-registry': path.resolve(
        __dirname,
        'node_modules/@logicflow/vue-node-registry/dist/index.esm.js'
      )
    }
  },
  optimizeDeps: {
    include: ['@logicflow/vue-node-registry', '@logicflow/core']
  },
  build: {
    commonjsOptions: {
      transformMixedEsModules: true
    }
  }
})

使用示例

// src/main.ts
import { createApp } from 'vue'
import LogicFlow from '@logicflow/core'
import { register, getTeleport } from '@logicflow/vue-node-registry'
import CustomNode from './nodes/CustomNode.vue'

const app = createApp(App)
const lf = new LogicFlow({ container: document.getElementById('app')! })

// 注册 Vue 节点
register({
  type: 'custom-vue-node',
  view: CustomNode,
  model: LogicFlow.NodeModel
}, lf)

// 使用 Teleport 容器
const TeleportContainer = getTeleport()
app.component('TeleportContainer', TeleportContainer)

版本兼容性建议

根据社区实践,推荐以下版本组合:

{
  "dependencies": {
    "@logicflow/core": "^2.0.12",
    "@logicflow/extension": "^2.0.14",
    "@logicflow/vue-node-registry": "^1.0.12"
  }
}

注意vue-node-registry 1.2.x 版本目前为 alpha 状态,生产环境建议使用 1.0.x 或 1.1.x 稳定版。


总结

问题 核心原因 解决方案
Failed to resolve entry package.json exports 配置问题 / Vite 解析失败 配置绝对路径 alias + optimizeDeps.include
找不到类型声明 TypeScript 无法识别模块类型 创建 .d.ts 声明文件或配置 tsconfig paths
vueNodesMap 未导出 内部 API 未公开 扩展类型声明 + @ts-ignore,或在组件内部使用

通过以上配置,可以彻底解决 @logicflow/vue-node-registry 在 Vite 环境下的各种解析问题,实现 Vue 组件作为 LogicFlow 节点的无缝集成。

JavaScript异步编程深度解析:从回调到Async Await的演进之路

作者 bluceli
2026年3月6日 10:57

引言

JavaScript作为单线程语言,异步编程是其核心特性之一。从最初的回调函数,到Promise,再到Async/Await语法糖,JavaScript的异步编程方式经历了多次演进。本文将深入探讨JavaScript异步编程的8大核心概念,帮助你全面掌握异步编程的艺术。

回调函数时代

1. 基础回调模式

回调函数是JavaScript最早的异步处理方式,虽然简单但容易产生"回调地狱"。

// 基础回调示例
function fetchData(callback) {
  setTimeout(() => {
    const data = { id: 1, name: 'JavaScript' };
    callback(data);
  }, 1000);
}

// 使用回调
fetchData(function(data) {
  console.log('获取到数据:', data);
});

2. 回调地狱问题

当多个异步操作需要按顺序执行时,回调函数会嵌套过深,代码难以维护。

// 回调地狱示例
fetchData(function(data1) {
  processData(data1, function(data2) {
    saveData(data2, function(result) {
      notifyUser(result, function() {
        console.log('所有操作完成');
      });
    });
  });
});

3. 错误处理困境

回调函数的错误处理比较复杂,需要通过额外的参数传递错误信息。

// Node.js风格的错误优先回调
function readFile(filename, callback) {
  fs.readFile(filename, (err, data) => {
    if (err) {
      callback(err, null);
    } else {
      callback(null, data);
    }
  });
}

// 使用示例
readFile('config.json', (err, data) => {
  if (err) {
    console.error('读取文件失败:', err);
    return;
  }
  console.log('文件内容:', data);
});

Promise革命

4. Promise基础用法

Promise的出现解决了回调地狱的问题,提供了更优雅的异步处理方式。

// 创建Promise
function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (userId > 0) {
        resolve({ id: userId, name: '用户' + userId });
      } else {
        reject(new Error('无效的用户ID'));
      }
    }, 1000);
  });
}

// 使用Promise
fetchUserData(1)
  .then(user => {
    console.log('用户信息:', user);
    return user;
  })
  .catch(error => {
    console.error('获取用户失败:', error);
  });

5. Promise链式调用

Promise支持链式调用,可以优雅地处理多个异步操作。

// Promise链式调用
fetchUserData(1)
  .then(user => {
    console.log('步骤1: 获取用户', user);
    return fetchUserPosts(user.id);
  })
  .then(posts => {
    console.log('步骤2: 获取文章', posts);
    return fetchPostComments(posts[0].id);
  })
  .then(comments => {
    console.log('步骤3: 获取评论', comments);
  })
  .catch(error => {
    console.error('发生错误:', error);
  });

6. Promise静态方法

Promise提供了多个静态方法,用于处理多个Promise实例。

// Promise.all - 所有Promise都成功才成功
Promise.all([
  fetchUserData(1),
  fetchUserData(2),
  fetchUserData(3)
])
  .then(users => {
    console.log('所有用户:', users);
  })
  .catch(error => {
    console.error('至少有一个请求失败:', error);
  });

// Promise.race - 返回最先完成的Promise
Promise.race([
  fetchFromServer1(),
  fetchFromServer2()
])
  .then(result => {
    console.log('最快的结果:', result);
  });

// Promise.allSettled - 等待所有Promise完成
Promise.allSettled([
  fetchUserData(1),
  fetchUserData(-1) // 这个会失败
])
  .then(results => {
    results.forEach((result, index) => {
      if (result.status === 'fulfilled') {
        console.log(`请求${index}成功:`, result.value);
      } else {
        console.log(`请求${index}失败:`, result.reason);
      }
    });
  });

// Promise.any - 返回第一个成功的Promise
Promise.any([
  fetchFromBackup1(),
  fetchFromBackup2(),
  fetchFromBackup3()
])
  .then(result => {
    console.log('第一个成功的备份:', result);
  })
  .catch(error => {
    console.error('所有备份都失败:', error);
  });

Async/Await语法糖

7. Async/Await基础

Async/Await是Promise的语法糖,让异步代码看起来像同步代码。

// Async函数定义
async function getUserData() {
  try {
    const user = await fetchUserData(1);
    console.log('用户信息:', user);
    
    const posts = await fetchUserPosts(user.id);
    console.log('用户文章:', posts);
    
    return posts;
  } catch (error) {
    console.error('获取数据失败:', error);
    throw error;
  }
}

// 调用Async函数
getUserData()
  .then(posts => console.log('最终结果:', posts))
  .catch(error => console.error('错误:', error));

8. 并行异步操作

使用Promise.all配合Async/Await可以实现并行异步操作。

// 串行执行(慢)
async function sequentialExecution() {
  const user1 = await fetchUserData(1);
  const user2 = await fetchUserData(2);
  const user3 = await fetchUserData(3);
  return [user1, user2, user3];
}

// 并行执行(快)
async function parallelExecution() {
  const [user1, user2, user3] = await Promise.all([
    fetchUserData(1),
    fetchUserData(2),
    fetchUserData(3)
  ]);
  return [user1, user2, user3];
}

// 批量处理
async function batchProcessing(userIds) {
  const batchSize = 5;
  const results = [];
  
  for (let i = 0; i < userIds.length; i += batchSize) {
    const batch = userIds.slice(i, i + batchSize);
    const batchResults = await Promise.all(
      batch.map(id => fetchUserData(id))
    );
    results.push(...batchResults);
  }
  
  return results;
}

高级异步模式

9. 异步迭代器

异步迭代器可以处理异步的数据流。

// 异步生成器函数
async function* asyncGenerator() {
  let i = 0;
  while (i < 5) {
    await new Promise(resolve => setTimeout(resolve, 1000));
    yield i++;
  }
}

// 使用异步迭代器
async function consumeAsyncGenerator() {
  for await (const value of theAsyncGenerator()) {
    console.log('接收到值:', value);
  }
}

// 实际应用:分页获取数据
async function* fetchPaginatedData(url) {
  let page = 1;
  let hasMore = true;
  
  while (hasMore) {
    const response = await fetch(`${url}?page=${page}`);
    const data = await response.json();
    
    yield data.items;
    
    hasMore = data.hasMore;
    page++;
  }
}

// 使用分页数据
async function processAllData() {
  for await (const items of fetchPaginatedData('/api/data')) {
    items.forEach(item => processItem(item));
  }
}

10. 异步队列

实现一个异步队列,控制并发请求数量。

class AsyncQueue {
  constructor(concurrency = 5) {
    this.concurrency = concurrency;
    this.running = 0;
    this.queue = [];
  }
  
  async add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject });
      this.runNext();
    });
  }
  
  async runNext() {
    if (this.running >= this.concurrency || this.queue.length === 0) {
      return;
    }
    
    this.running++;
    const { task, resolve, reject } = this.queue.shift();
    
    try {
      const result = await task();
      resolve(result);
    } catch (error) {
      reject(error);
    } finally {
      this.running--;
      this.runNext();
    }
  }
}

// 使用异步队列
const queue = new AsyncQueue(3); // 最多3个并发

const urls = [
  'https://api.example.com/data1',
  'https://api.example.com/data2',
  'https://api.example.com/data3',
  'https://api.example.com/data4',
  'https://api.example.com/data5'
];

const promises = urls.map(url => 
  queue.add(() => fetch(url).then(res => res.json()))
);

const results = await Promise.all(promises);

11. 异步重试机制

实现自动重试的异步操作。

async function retryAsyncOperation(
  operation,
  maxRetries = 3,
  delay = 1000
) {
  let lastError;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error) {
      lastError = error;
      
      if (attempt < maxRetries) {
        console.log(`第${attempt}次尝试失败,${delay}ms后重试...`);
        await new Promise(resolve => setTimeout(resolve, delay));
        delay *= 2; // 指数退避
      }
    }
  }
  
  throw new Error(`操作失败,已重试${maxRetries}次: ${lastError.message}`);
}

// 使用示例
async function fetchWithRetry(url) {
  return retryAsyncOperation(
    () => fetch(url).then(res => {
      if (!res.ok) throw new Error('请求失败');
      return res.json();
    }),
    3,
    1000
  );
}

const data = await fetchWithRetry('https://api.example.com/data');

12. 异步超时控制

为异步操作设置超时时间。

function withTimeout(promise, timeoutMs) {
  return Promise.race([
    promise,
    new Promise((_, reject) => 
      setTimeout(() => reject(new Error('操作超时')), timeoutMs)
    )
  ]);
}

// 使用示例
async function fetchWithTimeout(url, timeout = 5000) {
  try {
    const response = await withTimeout(
      fetch(url),
      timeout
    );
    return response.json();
  } catch (error) {
    if (error.message === '操作超时') {
      console.error('请求超时,请检查网络连接');
    }
    throw error;
  }
}

// AbortController实现可取消的请求
async function fetchWithAbort(url, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, {
      signal: controller.signal
    });
    clearTimeout(timeoutId);
    return response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      throw new Error('请求超时');
    }
    throw error;
  }
}

错误处理最佳实践

13. 全局错误处理

设置全局的异步错误处理器。

// 未捕获的Promise错误
window.addEventListener('unhandledrejection', event => {
  console.error('未处理的Promise拒绝:', event.reason);
  
  // 发送到错误监控服务
  sendToErrorTracking({
    type: 'unhandledRejection',
    error: event.reason,
    stack: event.reason?.stack
  });
});

// 全局错误
window.addEventListener('error', event => {
  console.error('全局错误:', event.error);
  
  sendToErrorTracking({
    type: 'globalError',
    error: event.error,
    message: event.message
  });
});

14. 错误边界模式

在React等框架中实现错误边界。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }
  
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
  
  componentDidCatch(error, errorInfo) {
    console.error('组件错误:', error, errorInfo);
    
    // 记录错误
    logErrorToService(error, errorInfo);
  }
  
  render() {
    if (this.state.hasError) {
      return <ErrorFallback error={this.state.error} />;
    }
    
    return this.props.children;
  }
}

// 使用ErrorBoundary
<ErrorBoundary>
  <MyComponent />
</ErrorBoundary>

性能优化技巧

15. 防抖与节流

在异步操作中使用防抖和节流优化性能。

// 异步防抖
function asyncDebounce(func, delay) {
  let timer = null;
  let pendingPromise = null;
  
  return function(...args) {
    return new Promise((resolve, reject) => {
      if (timer) {
        clearTimeout(timer);
        if (pendingPromise) {
          pendingPromise.reject(new Error('取消'));
        }
      }
      
      pendingPromise = { resolve, reject };
      
      timer = setTimeout(async () => {
        try {
          const result = await func.apply(this, args);
          pendingPromise.resolve(result);
        } catch (error) {
          pendingPromise.reject(error);
        }
        timer = null;
        pendingPromise = null;
      }, delay);
    });
  };
}

// 使用示例
const debouncedSearch = asyncDebounce(
  async (query) => {
    const response = await fetch(`/api/search?q=${query}`);
    return response.json();
  },
  300
);

// 搜索输入框
searchInput.addEventListener('input', async (e) => {
  try {
    const results = await debouncedSearch(e.target.value);
    displayResults(results);
  } catch (error) {
    if (error.message !== '取消') {
      console.error('搜索失败:', error);
    }
  }
});

16. 请求缓存

实现异步请求的缓存机制。

class AsyncCache {
  constructor(ttl = 60000) {
    this.cache = new Map();
    this.ttl = ttl;
  }
  
  async get(key, fetcher) {
    const cached = this.cache.get(key);
    
    if (cached && Date.now() - cached.timestamp < this.ttl) {
      return cached.data;
    }
    
    const data = await fetcher();
    this.cache.set(key, {
      data,
      timestamp: Date.now()
    });
    
    return data;
  }
  
  clear() {
    this.cache.clear();
  }
  
  clearExpired() {
    const now = Date.now();
    for (const [key, value] of this.cache.entries()) {
      if (now - value.timestamp >= this.ttl) {
        this.cache.delete(key);
      }
    }
  }
}

// 使用示例
const cache = new AsyncCache(300000); // 5分钟缓存

async function getUserInfo(userId) {
  return cache.get(`user:${userId}`, async () => {
    const response = await fetch(`/api/users/${userId}`);
    return response.json();
  });
}

总结

JavaScript异步编程经历了从回调函数到Promise,再到Async/Await的演进,每种方式都有其适用场景:

1. 选择合适的异步方式

  • 简单场景:回调函数仍然适用
  • 链式操作:Promise提供更好的可读性
  • 复杂逻辑:Async/Await让代码更清晰

2. 最佳实践

  • 错误处理:始终使用try/catch或.catch()
  • 并发控制:合理使用Promise.all和队列
  • 性能优化:使用缓存、防抖、节流等技术
  • 可读性:保持异步代码的清晰和简洁

3. 进阶技巧

  • 异步迭代器:处理异步数据流
  • 重试机制:提高操作可靠性
  • 超时控制:防止操作卡死
  • 错误边界:优雅处理错误

掌握JavaScript异步编程,将帮助你构建出更高效、更可靠的前端应用。记住,异步编程的核心是理解事件循环和Promise的工作原理,这样才能写出真正优秀的异步代码。


本文首发于掘金,欢迎关注我的专栏获取更多前端技术干货!

前端网络请求实战 | Axios 从入门到封装(拦截器 / 错误处理 / 重试)

作者 代码煮茶
2026年3月6日 10:49

一、为什么选择 Axios?

在项目开发中,网络请求是必不可少的一环。虽然浏览器提供了 Fetch API 和 XMLHttpRequest,但 Axios 凭借其强大的功能和友好的 API,成为最受欢迎的请求库。

1.1 Axios 核心优势

// 1. 支持浏览器和 Node.js 环境
// 浏览器:XMLHttpRequest
// Node.js:http 模块

// 2. 自动转换 JSON 数据
axios.get('/api/user').then(res => {
  console.log(res.data) // 自动解析为 JavaScript 对象
})

// 3. 请求拦截和响应拦截
// 4. 取消请求
// 5. 超时处理
// 6. 并发请求
// 7. CSRF 防护
// 8. 上传/下载进度监控

二、Axios 基础入门

2.1 安装与引入

# 使用 npm
npm install axios

# 使用 yarn
yarn add axios

# 使用 CDN
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>

2.2 基本请求方法

import axios from 'axios'

// GET 请求
axios.get('/api/users', {
  params: {
    page: 1,
    limit: 10
  }
})
.then(response => {
  console.log('用户列表:', response.data)
})
.catch(error => {
  console.error('请求失败:', error)
})

// POST 请求
axios.post('/api/users', {
  name: '张三',
  email: 'zhangsan@example.com',
  age: 25
})
.then(response => {
  console.log('创建成功:', response.data)
})

// PUT 请求(更新)
axios.put('/api/users/1', {
  name: '张三丰',
  age: 26
})

// DELETE 请求
axios.delete('/api/users/1')

// PATCH 请求(部分更新)
axios.patch('/api/users/1', {
  age: 27
})

2.3 请求配置详解

axios({
  method: 'post',                    // 请求方法
  url: '/api/users',                  // 请求地址
  baseURL: 'https://api.example.com', // 基础URL
  headers: {                           // 请求头
    'Content-Type': 'application/json',
    'Authorization': 'Bearer token123'
  },
  params: {                            // URL参数
    page: 1,
    limit: 10
  },
  data: {                              // 请求体
    name: '张三'
  },
  timeout: 5000,                       // 超时时间(ms)
  withCredentials: true,                // 跨域请求时携带cookie
  responseType: 'json',                 // 响应数据类型
  maxContentLength: 2000,               // 最大响应长度
  validateStatus: function (status) {   // 定义哪些状态码是成功的
    return status >= 200 && status < 300
  },
  proxy: {                              // 代理配置
    host: '127.0.0.1',
    port: 9000
  }
})

2.4 响应数据结构

axios.get('/api/user').then(response => {
  // response 对象包含:
  console.log(response.data)       // 服务器返回的数据
  console.log(response.status)     // HTTP 状态码
  console.log(response.statusText) // 状态消息
  console.log(response.headers)    // 响应头
  console.log(response.config)     // 请求配置
  console.log(response.request)    // 原生XMLHttpRequest对象
})

三、项目中的 Axios 封装

在实际项目中,我们通常会对 Axios 进行二次封装,统一处理请求配置、拦截器、错误处理等。

3.1 基础封装结构

// service/index.js
import axios from 'axios'

class RequestService {
  constructor() {
    // 创建 axios 实例
    this.service = axios.create({
      baseURL: process.env.VUE_APP_BASE_API || '/api',
      timeout: 10000,
      headers: {
        'Content-Type': 'application/json;charset=UTF-8'
      }
    })
    
    // 初始化拦截器
    this.setupInterceptors()
  }
  
  setupInterceptors() {
    // 请求拦截器
    this.service.interceptors.request.use(
      this.handleRequestSuccess,
      this.handleRequestError
    )
    
    // 响应拦截器
    this.service.interceptors.response.use(
      this.handleResponseSuccess,
      this.handleResponseError
    )
  }
  
  handleRequestSuccess(config) {
    console.log('请求配置:', config)
    return config
  }
  
  handleRequestError(error) {
    console.error('请求错误:', error)
    return Promise.reject(error)
  }
  
  handleResponseSuccess(response) {
    console.log('响应数据:', response)
    return response
  }
  
  handleResponseError(error) {
    console.error('响应错误:', error)
    return Promise.reject(error)
  }
  
  // 请求方法封装
  get(url, params = {}, config = {}) {
    return this.service.get(url, { params, ...config })
  }
  
  post(url, data = {}, config = {}) {
    return this.service.post(url, data, config)
  }
  
  put(url, data = {}, config = {}) {
    return this.service.put(url, data, config)
  }
  
  delete(url, params = {}, config = {}) {
    return this.service.delete(url, { params, ...config })
  }
  
  patch(url, data = {}, config = {}) {
    return this.service.patch(url, data, config)
  }
}

export default new RequestService()

3.2 完整的拦截器实现

// service/interceptors.js
import { message, Modal } from 'ant-design-vue'
import router from '@/router'
import store from '@/store'

// 请求拦截器
export function requestSuccess(config) {
  // 1. 添加 token
  const token = store.state.user.token
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`
  }
  
  // 2. 添加时间戳防止缓存(GET请求)
  if (config.method === 'get') {
    config.params = {
      ...config.params,
      _t: Date.now()
    }
  }
  
  // 3. 请求日志(开发环境)
  if (process.env.NODE_ENV === 'development') {
    console.log('🚀 请求信息:', {
      url: config.url,
      method: config.method,
      params: config.params,
      data: config.data,
      headers: config.headers
    })
  }
  
  return config
}

export function requestError(error) {
  console.error('❌ 请求发送失败:', error)
  message.error('网络请求失败,请检查网络连接')
  return Promise.reject(error)
}

// 响应拦截器
export function responseSuccess(response) {
  // 可以统一处理业务状态码
  const { code, data, message: msg } = response.data
  
  // 根据后端约定的状态码处理
  switch (code) {
    case 200: // 成功
      return data
    case 401: // 未授权
      handleUnauthorized()
      return Promise.reject(new Error('未授权,请重新登录'))
    case 403: // 禁止访问
      message.error('没有权限访问')
      return Promise.reject(new Error('禁止访问'))
    case 500: // 服务器错误
      message.error('服务器错误,请稍后重试')
      return Promise.reject(new Error('服务器错误'))
    default:
      // 其他错误
      message.error(msg || '请求失败')
      return Promise.reject(new Error(msg || '请求失败'))
  }
}

export function responseError(error) {
  // 处理 HTTP 状态码错误
  if (error.response) {
    // 服务器返回了错误状态码
    const { status, data } = error.response
    
    switch (status) {
      case 400:
        message.error(data?.message || '请求参数错误')
        break
      case 401:
        handleUnauthorized()
        break
      case 403:
        message.error('没有权限访问')
        break
      case 404:
        message.error('请求的资源不存在')
        break
      case 500:
        message.error('服务器内部错误')
        break
      case 502:
        message.error('网关错误')
        break
      case 503:
        message.error('服务不可用')
        break
      case 504:
        message.error('网关超时')
        break
      default:
        message.error(`网络错误: ${status}`)
    }
  } else if (error.request) {
    // 请求已发送但没有收到响应
    message.error('服务器无响应,请检查网络')
  } else {
    // 请求配置出错
    message.error('请求配置错误')
  }
  
  return Promise.reject(error)
}

// 处理未授权
function handleUnauthorized() {
  Modal.confirm({
    title: '登录已过期',
    content: '您的登录信息已过期,请重新登录',
    okText: '去登录',
    cancelText: '取消',
    onOk: () => {
      store.dispatch('user/logout')
      router.push('/login')
    }
  })
}

3.3 增强版封装(支持取消请求、重试)

// service/advanced.js
import axios from 'axios'
import qs from 'qs'

class AdvancedRequest {
  constructor() {
    this.service = axios.create({
      baseURL: process.env.VUE_APP_API_URL,
      timeout: 30000,
      paramsSerializer: params => {
        // 处理复杂参数序列化
        return qs.stringify(params, { indices: false })
      }
    })
    
    // 存储取消请求的控制器
    this.pendingRequests = new Map()
    
    this.setupInterceptors()
  }
  
  setupInterceptors() {
    // 请求拦截器
    this.service.interceptors.request.use(
      config => {
        // 添加取消请求功能
        this.addCancelToken(config)
        
        // 请求签名
        if (config.needSign) {
          config.data = this.signRequest(config)
        }
        
        // 加密敏感数据
        if (config.encrypt) {
          config.data = this.encryptData(config.data)
        }
        
        return config
      },
      error => Promise.reject(error)
    )
    
    // 响应拦截器
    this.service.interceptors.response.use(
      response => {
        // 请求完成后移除 pending 记录
        this.removePendingRequest(response.config)
        
        // 处理文件下载
        if (response.config.responseType === 'blob') {
          return this.handleFileResponse(response)
        }
        
        return response.data
      },
      error => {
        // 如果是取消请求,不处理错误
        if (axios.isCancel(error)) {
          console.log('请求已取消:', error.message)
          return Promise.reject(error)
        }
        
        // 移除 pending 记录
        if (error.config) {
          this.removePendingRequest(error.config)
        }
        
        return Promise.reject(error)
      }
    )
  }
  
  // 取消请求管理
  addCancelToken(config) {
    // 避免重复请求
    const requestKey = `${config.method}-${config.url}-${JSON.stringify(config.params)}-${JSON.stringify(config.data)}`
    
    // 如果已有相同请求,取消之前的请求
    if (this.pendingRequests.has(requestKey)) {
      const cancel = this.pendingRequests.get(requestKey)
      cancel('重复请求已取消')
      this.pendingRequests.delete(requestKey)
    }
    
    // 创建新的取消令牌
    config.cancelToken = new axios.CancelToken(cancel => {
      this.pendingRequests.set(requestKey, cancel)
    })
  }
  
  removePendingRequest(config) {
    const requestKey = `${config.method}-${config.url}-${JSON.stringify(config.params)}-${JSON.stringify(config.data)}`
    if (this.pendingRequests.has(requestKey)) {
      this.pendingRequests.delete(requestKey)
    }
  }
  
  // 取消所有请求
  cancelAllRequests() {
    this.pendingRequests.forEach(cancel => cancel('主动取消所有请求'))
    this.pendingRequests.clear()
  }
  
  // 带重试机制的请求
  async requestWithRetry(config, retries = 3) {
    let lastError
    
    for (let i = 0; i < retries; i++) {
      try {
        const response = await this.service(config)
        return response
      } catch (error) {
        lastError = error
        
        // 是否应该重试
        if (this.shouldRetry(error, i, retries)) {
          // 指数退避延迟
          const delay = Math.pow(2, i) * 1000
          console.log(`第${i + 1}次请求失败,${delay}ms后重试...`)
          await this.sleep(delay)
          continue
        }
        break
      }
    }
    
    throw lastError
  }
  
  shouldRetry(error, currentRetry, maxRetries) {
    // 只有特定错误才重试
    if (axios.isCancel(error)) return false
    
    // 网络错误或超时重试
    const retryableErrors = [
      'ECONNABORTED',  // 超时
      'ETIMEDOUT',      // 连接超时
      'ECONNREFUSED',   // 连接被拒绝
      'ECONNRESET',     // 连接重置
      'ENOTFOUND'       // DNS解析失败
    ]
    
    const shouldRetry = (
      currentRetry < maxRetries - 1 &&
      (error.code && retryableErrors.includes(error.code)) ||
      (error.response && error.response.status >= 500)
    )
    
    return shouldRetry
  }
  
  // 文件上传(支持进度)
  uploadFile(url, file, onProgress) {
    const formData = new FormData()
    formData.append('file', file)
    
    return this.service.post(url, formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      onUploadProgress: progressEvent => {
        if (onProgress) {
          const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
          onProgress(percentCompleted)
        }
      }
    })
  }
  
  // 文件下载
  async downloadFile(url, filename) {
    const response = await this.service.get(url, {
      responseType: 'blob'
    })
    
    // 创建下载链接
    const blob = new Blob([response.data])
    const downloadUrl = window.URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = downloadUrl
    link.download = filename || 'download'
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
    window.URL.revokeObjectURL(downloadUrl)
  }
  
  handleFileResponse(response) {
    const contentDisposition = response.headers['content-disposition']
    let filename = 'download'
    
    if (contentDisposition) {
      const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
      if (match && match[1]) {
        filename = match[1].replace(/['"]/g, '')
        // 处理中文文件名
        try {
          filename = decodeURIComponent(escape(filename))
        } catch (e) {
          console.error('文件名解码失败', e)
        }
      }
    }
    
    return {
      data: response.data,
      filename,
      type: response.headers['content-type']
    }
  }
  
  sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms))
  }
  
  signRequest(config) {
    // 实现请求签名逻辑
    const timestamp = Date.now()
    const nonce = Math.random().toString(36).substring(7)
    const data = { ...config.data, timestamp, nonce }
    
    // 签名计算...
    // data.sign = generateSignature(data)
    
    return data
  }
  
  encryptData(data) {
    // 实现数据加密逻辑
    return data
  }
}

export default new AdvancedRequest()

四、业务层封装

4.1 API 模块化管理

// api/modules/user.js
import request from '@/service'

// 用户相关接口
export const userApi = {
  // 登录
  login(data) {
    return request.post('/auth/login', data, {
      needSign: true  // 需要签名
    })
  },
  
  // 登出
  logout() {
    return request.post('/auth/logout')
  },
  
  // 获取用户信息
  getUserInfo() {
    return request.get('/user/info', {}, {
      retry: 3  // 失败重试3次
    })
  },
  
  // 更新用户信息
  updateUserInfo(data) {
    return request.put('/user/info', data)
  },
  
  // 上传头像
  uploadAvatar(file, onProgress) {
    return request.uploadFile('/user/avatar', file, onProgress)
  },
  
  // 获取用户列表
  getUserList(params) {
    return request.get('/user/list', params, {
      cache: true  // 启用缓存
    })
  },
  
  // 导出用户数据
  exportUsers(params) {
    return request.get('/user/export', params, {
      responseType: 'blob'
    })
  }
}

// api/modules/product.js
export const productApi = {
  getProductList(params) {
    return request.get('/product/list', params)
  },
  
  getProductDetail(id) {
    return request.get(`/product/detail/${id}`)
  },
  
  createProduct(data) {
    return request.post('/product', data)
  },
  
  updateProduct(id, data) {
    return request.put(`/product/${id}`, data)
  },
  
  deleteProduct(id) {
    return request.delete(`/product/${id}`)
  }
}

// api/index.js
export { userApi } from './modules/user'
export { productApi } from './modules/product'

4.2 请求缓存管理

// service/cache.js
class RequestCache {
  constructor() {
    this.cache = new Map()
    this.maxAge = 5 * 60 * 1000 // 默认5分钟
  }
  
  // 生成缓存key
  generateKey(config) {
    return `${config.method}-${config.url}-${JSON.stringify(config.params)}-${JSON.stringify(config.data)}`
  }
  
  // 设置缓存
  set(key, data, maxAge = this.maxAge) {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      maxAge
    })
  }
  
  // 获取缓存
  get(key) {
    const cached = this.cache.get(key)
    if (!cached) return null
    
    // 检查是否过期
    if (Date.now() - cached.timestamp > cached.maxAge) {
      this.cache.delete(key)
      return null
    }
    
    return cached.data
  }
  
  // 清除缓存
  clear() {
    this.cache.clear()
  }
  
  // 清除指定缓存
  delete(key) {
    this.cache.delete(key)
  }
  
  // 清除匹配模式的缓存
  clearPattern(pattern) {
    const regex = new RegExp(pattern)
    for (const key of this.cache.keys()) {
      if (regex.test(key)) {
        this.cache.delete(key)
      }
    }
  }
}

export default new RequestCache()

4.3 在 Vue/React 中使用

// Vue 3 中使用
import { userApi } from '@/api'
import { ref, onMounted } from 'vue'

export default {
  setup() {
    const userList = ref([])
    const loading = ref(false)
    
    const fetchUserList = async () => {
      loading.value = true
      try {
        const res = await userApi.getUserList({
          page: 1,
          limit: 10
        })
        userList.value = res
      } catch (error) {
        console.error('获取用户列表失败:', error)
      } finally {
        loading.value = false
      }
    }
    
    onMounted(() => {
      fetchUserList()
    })
    
    return {
      userList,
      loading,
      fetchUserList
    }
  }
}

// React 中使用
import { useState, useEffect } from 'react'
import { userApi } from '@/api'

function UserList() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(false)
  
  useEffect(() => {
    const fetchUsers = async () => {
      setLoading(true)
      try {
        const data = await userApi.getUserList()
        setUsers(data)
      } catch (error) {
        console.error('Failed to fetch users:', error)
      } finally {
        setLoading(false)
      }
    }
    
    fetchUsers()
  }, [])
  
  return (
    <div>
      {loading ? <Spin /> : (
        <Table dataSource={users} />
      )}
    </div>
  )
}

五、高级功能实现

5.1 请求队列管理

// service/queue.js
class RequestQueue {
  constructor(concurrency = 5) {
    this.concurrency = concurrency
    this.queue = []
    this.running = 0
  }
  
  // 添加请求到队列
  add(request) {
    return new Promise((resolve, reject) => {
      this.queue.push({
        request,
        resolve,
        reject
      })
      this.next()
    })
  }
  
  // 执行下一个请求
  next() {
    while (this.running < this.concurrency && this.queue.length) {
      const { request, resolve, reject } = this.queue.shift()
      this.running++
      
      request()
        .then(resolve)
        .catch(reject)
        .finally(() => {
          this.running--
          this.next()
        })
    }
  }
  
  // 清空队列
  clear() {
    this.queue = []
    this.running = 0
  }
  
  // 获取队列状态
  getStatus() {
    return {
      queueLength: this.queue.length,
      running: this.running,
      concurrency: this.concurrency
    }
  }
}

export default RequestQueue

5.2 请求节流防抖

// service/throttle.js
class RequestThrottle {
  constructor() {
    this.pendingRequests = new Map()
  }
  
  // 防抖:最后一次请求有效
  debounce(key, fn, delay = 300) {
    if (this.pendingRequests.has(key)) {
      clearTimeout(this.pendingRequests.get(key))
    }
    
    const timeout = setTimeout(() => {
      fn()
      this.pendingRequests.delete(key)
    }, delay)
    
    this.pendingRequests.set(key, timeout)
  }
  
  // 节流:限制请求频率
  throttle(key, fn, limit = 1000) {
    const now = Date.now()
    const lastCall = this.pendingRequests.get(key)
    
    if (!lastCall || now - lastCall > limit) {
      fn()
      this.pendingRequests.set(key, now)
    }
  }
  
  // 取消所有待执行的请求
  cancelAll() {
    this.pendingRequests.forEach(timeout => {
      clearTimeout(timeout)
    })
    this.pendingRequests.clear()
  }
}

export default new RequestThrottle()

5.3 断网重连机制

// service/reconnect.js
class ReconnectManager {
  constructor(requestService) {
    this.requestService = requestService
    this.isOnline = navigator.onLine
    this.reconnectAttempts = 0
    this.maxReconnectAttempts = 5
    this.pendingRequests = []
    
    this.initEventListeners()
  }
  
  initEventListeners() {
    window.addEventListener('online', () => {
      this.handleOnline()
    })
    
    window.addEventListener('offline', () => {
      this.handleOffline()
    })
  }
  
  handleOnline() {
    console.log('网络已恢复,开始重连...')
    this.isOnline = true
    this.reconnectAttempts = 0
    
    // 重试所有待处理的请求
    this.processPendingRequests()
  }
  
  handleOffline() {
    console.log('网络已断开')
    this.isOnline = false
  }
  
  async processPendingRequests() {
    while (this.pendingRequests.length > 0) {
      const request = this.pendingRequests.shift()
      try {
        const result = await this.requestService(request.config)
        request.resolve(result)
      } catch (error) {
        request.reject(error)
      }
    }
  }
  
  // 添加请求到待处理队列
  addPendingRequest(config) {
    return new Promise((resolve, reject) => {
      this.pendingRequests.push({
        config,
        resolve,
        reject
      })
      
      // 尝试重新连接
      this.attemptReconnect()
    })
  }
  
  attemptReconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.log('重连失败次数过多,请手动刷新')
      return
    }
    
    this.reconnectAttempts++
    setTimeout(() => {
      if (navigator.onLine) {
        this.handleOnline()
      }
    }, Math.pow(2, this.reconnectAttempts) * 1000)
  }
}

六、错误处理与日志

6.1 统一错误处理

// service/errorHandler.js
class ErrorHandler {
  constructor() {
    this.errorListeners = []
  }
  
  // 处理错误
  handle(error, context = {}) {
    // 格式化错误信息
    const errorInfo = this.formatError(error, context)
    
    // 记录错误日志
    this.logError(errorInfo)
    
    // 触发错误监听器
    this.notifyListeners(errorInfo)
    
    // 根据错误类型进行处理
    this.processByType(errorInfo)
    
    return errorInfo
  }
  
  formatError(error, context) {
    return {
      timestamp: new Date().toISOString(),
      type: this.getErrorType(error),
      message: error.message,
      code: error.code,
      status: error.response?.status,
      url: error.config?.url,
      method: error.config?.method,
      params: error.config?.params,
      data: error.config?.data,
      stack: error.stack,
      context
    }
  }
  
  getErrorType(error) {
    if (error.response) {
      // 服务器返回错误状态码
      const status = error.response.status
      if (status >= 500) return 'SERVER_ERROR'
      if (status === 401) return 'UNAUTHORIZED'
      if (status === 403) return 'FORBIDDEN'
      if (status === 404) return 'NOT_FOUND'
      if (status >= 400) return 'CLIENT_ERROR'
    } else if (error.request) {
      // 请求已发送但没有响应
      return 'NETWORK_ERROR'
    } else {
      // 请求配置错误
      return 'CONFIG_ERROR'
    }
    return 'UNKNOWN_ERROR'
  }
  
  logError(errorInfo) {
    // 开发环境打印到控制台
    if (process.env.NODE_ENV === 'development') {
      console.group('❌ 请求错误')
      console.log('时间:', errorInfo.timestamp)
      console.log('类型:', errorInfo.type)
      console.log('信息:', errorInfo.message)
      console.log('状态码:', errorInfo.status)
      console.log('URL:', errorInfo.url)
      console.log('方法:', errorInfo.method)
      console.log('参数:', errorInfo.params)
      console.log('数据:', errorInfo.data)
      console.trace('堆栈:', errorInfo.stack)
      console.groupEnd()
    }
    
    // 生产环境发送到日志服务
    if (process.env.NODE_ENV === 'production') {
      this.sendToLogService(errorInfo)
    }
  }
  
  sendToLogService(errorInfo) {
    // 发送错误日志到服务器
    fetch('/api/log/error', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(errorInfo),
      keepalive: true // 即使页面卸载也发送
    }).catch(() => {
      // 静默失败
    })
  }
  
  processByType(errorInfo) {
    switch (errorInfo.type) {
      case 'UNAUTHORIZED':
        // 跳转到登录页
        this.redirectToLogin()
        break
      case 'SERVER_ERROR':
        // 显示服务器错误提示
        this.showErrorMessage('服务器开小差了,请稍后重试')
        break
      case 'NETWORK_ERROR':
        // 显示网络错误提示
        this.showErrorMessage('网络连接失败,请检查网络设置')
        break
      default:
        // 显示通用错误提示
        this.showErrorMessage('操作失败,请重试')
    }
  }
  
  addListener(listener) {
    this.errorListeners.push(listener)
  }
  
  notifyListeners(errorInfo) {
    this.errorListeners.forEach(listener => {
      try {
        listener(errorInfo)
      } catch (e) {
        console.error('Error listener failed:', e)
      }
    })
  }
  
  redirectToLogin() {
    // 跳转到登录页
    if (window.location.pathname !== '/login') {
      window.location.href = '/login'
    }
  }
  
  showErrorMessage(message) {
    // 使用UI库的提示组件
    if (window.$message) {
      window.$message.error(message)
    } else {
      alert(message)
    }
  }
}

export default new ErrorHandler()

6.2 请求监控

// service/monitor.js
class RequestMonitor {
  constructor() {
    this.metrics = {
      totalRequests: 0,
      successRequests: 0,
      failedRequests: 0,
      totalTime: 0,
      slowRequests: [],
      errorStats: {}
    }
    
    this.slowThreshold = 3000 // 慢请求阈值(ms)
  }
  
  // 记录请求开始
  startRequest(config) {
    const requestId = this.generateRequestId()
    const startTime = Date.now()
    
    config.metadata = {
      requestId,
      startTime
    }
    
    return config
  }
  
  // 记录请求结束
  endRequest(config, response, error) {
    const endTime = Date.now()
    const startTime = config.metadata?.startTime || endTime
    const duration = endTime - startTime
    
    // 更新总请求数
    this.metrics.totalRequests++
    
    if (error) {
      // 记录失败请求
      this.metrics.failedRequests++
      this.recordError(config, error)
    } else {
      // 记录成功请求
      this.metrics.successRequests++
      this.metrics.totalTime += duration
    }
    
    // 检查慢请求
    if (duration > this.slowThreshold) {
      this.recordSlowRequest(config, duration, error)
    }
    
    // 打印性能日志
    this.logPerformance(config, duration, error)
    
    // 清理metadata
    delete config.metadata
  }
  
  recordError(config, error) {
    const errorType = error.response?.status || 'NETWORK_ERROR'
    this.metrics.errorStats[errorType] = (this.metrics.errorStats[errorType] || 0) + 1
  }
  
  recordSlowRequest(config, duration, error) {
    this.metrics.slowRequests.push({
      url: config.url,
      method: config.method,
      duration,
      timestamp: new Date().toISOString(),
      success: !error,
      error: error?.message
    })
    
    // 保留最近100条慢请求记录
    if (this.metrics.slowRequests.length > 100) {
      this.metrics.slowRequests.shift()
    }
  }
  
  logPerformance(config, duration, error) {
    const status = error ? '❌' : '✅'
    const slow = duration > this.slowThreshold ? '🐢' : ''
    
    console.log(
      `${status} ${slow} [${config.method.toUpperCase()}] ${config.url} - ${duration}ms`
    )
  }
  
  generateRequestId() {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
  }
  
  // 获取监控报告
  getReport() {
    const avgTime = this.metrics.successRequests > 0
      ? Math.round(this.metrics.totalTime / this.metrics.successRequests)
      : 0
    
    return {
      ...this.metrics,
      avgTime,
      successRate: this.metrics.totalRequests > 0
        ? `${Math.round((this.metrics.successRequests / this.metrics.totalRequests) * 100)}%`
        : '0%'
    }
  }
  
  // 重置监控数据
  reset() {
    this.metrics = {
      totalRequests: 0,
      successRequests: 0,
      failedRequests: 0,
      totalTime: 0,
      slowRequests: [],
      errorStats: {}
    }
  }
}

export default new RequestMonitor()

七、测试与调试

7.1 单元测试

// service/__tests__/request.test.js
import MockAdapter from 'axios-mock-adapter'
import request from '../index'
import axios from 'axios'

describe('Request Service', () => {
  let mock
  
  beforeEach(() => {
    mock = new MockAdapter(axios)
  })
  
  afterEach(() => {
    mock.reset()
  })
  
  test('should handle GET request successfully', async () => {
    const mockData = { id: 1, name: '张三' }
    mock.onGet('/api/user/1').reply(200, mockData)
    
    const result = await request.get('/api/user/1')
    expect(result).toEqual(mockData)
  })
  
  test('should handle request error', async () => {
    mock.onGet('/api/user/1').reply(500)
    
    await expect(request.get('/api/user/1')).rejects.toThrow()
  })
  
  test('should add token to headers', async () => {
    const token = 'test-token'
    localStorage.setItem('token', token)
    
    mock.onGet('/api/user').reply(config => {
      expect(config.headers.Authorization).toBe(`Bearer ${token}`)
      return [200, {}]
    })
    
    await request.get('/api/user')
  })
  
  test('should handle timeout', async () => {
    mock.onGet('/api/user').timeout()
    
    await expect(request.get('/api/user')).rejects.toThrow('timeout')
  }, 10000)
})

7.2 调试技巧

// 调试配置
if (process.env.NODE_ENV === 'development') {
  // 开启调试模式
  axios.defaults.debug = true
  
  // 拦截所有请求并打印详细信息
  axios.interceptors.request.use(config => {
    console.group(`🌐 请求 [${config.method}] ${config.url}`)
    console.log('参数:', config.params)
    console.log('数据:', config.data)
    console.log('头信息:', config.headers)
    console.groupEnd()
    return config
  })
  
  // 模拟慢网络
  if (process.env.VUE_APP_SLOW_NETWORK) {
    axios.interceptors.request.use(async config => {
      await new Promise(resolve => setTimeout(resolve, 2000))
      return config
    })
  }
  
  // 模拟随机失败
  if (process.env.VUE_APP_RANDOM_FAIL) {
    axios.interceptors.response.use(
      response => response,
      error => {
        if (Math.random() < 0.1) { // 10% 概率失败
          return Promise.reject(new Error('模拟网络错误'))
        }
        return Promise.reject(error)
      }
    )
  }
}

八、最佳实践总结

8.1 项目结构推荐

src/
├── api/
│   ├── modules/
│   │   ├── user.js
│   │   ├── product.js
│   │   └── order.js
│   ├── index.js
│   └── config.js
├── service/
│   ├── index.js           # 请求服务主入口
│   ├── interceptors.js    # 拦截器
│   ├── errorHandler.js    # 错误处理
│   ├── cache.js          # 缓存管理
│   ├── monitor.js        # 监控
│   └── utils.js          # 工具函数
└── utils/
    └── request.js        # 导出封装的请求方法

8.2 配置管理

// service/config.js
const config = {
  development: {
    baseURL: 'http://localhost:3000/api',
    timeout: 10000,
    withCredentials: true
  },
  test: {
    baseURL: 'https://test-api.example.com/api',
    timeout: 15000
  },
  production: {
    baseURL: 'https://api.example.com/api',
    timeout: 30000,
    withCredentials: true
  }
}

export default config[process.env.NODE_ENV || 'development']

8.3 安全建议

// 1. 防止 CSRF 攻击
axios.defaults.xsrfCookieName = 'csrf-token'
axios.defaults.xsrfHeaderName = 'X-CSRF-Token'

// 2. 敏感信息加密
import CryptoJS from 'crypto-js'

function encryptRequest(data) {
  const key = CryptoJS.enc.Utf8.parse(process.env.VUE_APP_SECRET_KEY)
  const encrypted = CryptoJS.AES.encrypt(JSON.stringify(data), key, {
    mode: CryptoJS.mode.ECB,
    padding: CryptoJS.pad.Pkcs7
  })
  return encrypted.toString()
}

// 3. HTTPS 强制
if (window.location.protocol !== 'https:' && process.env.NODE_ENV === 'production') {
  window.location.href = 'https://' + window.location.host + window.location.pathname
}

九、常见面试题

Q1: 如何取消重复请求?

// 使用 CancelToken
const cancelTokenSource = axios.CancelToken.source()

axios.get('/api/user', {
  cancelToken: cancelTokenSource.token
})

// 取消请求
cancelTokenSource.cancel('操作取消')

Q2: 如何实现请求缓存?

// 使用 Map 存储响应结果
const cache = new Map()

async function requestWithCache(url) {
  if (cache.has(url)) {
    return cache.get(url)
  }
  
  const response = await axios.get(url)
  cache.set(url, response.data)
  return response.data
}

Q3: 如何统一处理错误?

// 响应拦截器中统一处理
axios.interceptors.response.use(
  response => response,
  error => {
    if (error.response) {
      switch (error.response.status) {
        case 401:
          // 处理未授权
          break
        case 404:
          // 处理未找到
          break
        case 500:
          // 处理服务器错误
          break
      }
    }
    return Promise.reject(error)
  }
)

Q4: 如何监控请求性能?

// 使用 Performance API
const start = performance.now()

axios.get('/api/data').then(() => {
  const end = performance.now()
  console.log(`请求耗时: ${end - start}ms`)
})

十、总结

Axios 封装的核心要点:

  1. 统一配置:baseURL、超时、请求头等
  2. 拦截器:请求/响应拦截,统一处理 token、日志、错误
  3. 错误处理:分类处理、友好提示、日志记录
  4. 取消请求:防止重复提交、组件卸载时取消
  5. 重试机制:网络错误时自动重试
  6. 缓存管理:减少重复请求
  7. 监控告警:性能监控、错误上报
  8. 测试覆盖:单元测试保证稳定性

通过合理的封装,可以让项目中的网络请求更加健壮、可维护,提升开发效率和用户体验。

为什么每个程序员都应该试试 cmux:AI 加持的终端效率革命

作者 jerrywus
2026年3月6日 10:49

一个来自 lazygit 作者的终端管理神器,让你的终端效率直接起飞


前言

作为一名程序员,你是否经历过这样的场景:

  • 同时开着多个项目,每个项目都要开一个终端窗口
  • 切换分支的时候手忙脚乱,鼠标在终端和 Git GUI 之间来回穿梭
  • 想看日志又不想关掉当前的开发环境,只能硬着头皮开新标签页
  • 文件管理靠 cd + ls,每次都要输入一长串路径

以上这些破事我全遇到过。传统终端的操作方式真的该淘汰了。今天要介绍的工具叫 cmux,来自 lazygit 的作者,用完之后你会觉得以前的日子简直是原始人。

cmux 是什么?

cmux(Composite Mux)是一个终端复用器,类似于 tmux 但更加现代化。它的目标是:让你在一个终端窗口里完成所有操作,再也不用来回切换窗口。

直接看效果:

image.png

左边是项目导航,右边是终端面板,底部还集成了浏览器。一个窗口就是一个完整的工作空间,不用再满屏幕找窗口了。

核心功能一览

1. 项目分类导航

左侧边栏可以按项目维度组织终端会话。比如你有三个项目,每个项目开 2-3 个终端,切换项目就像在文件管理器里切换目录一样。项目再多也不乱。

2. 终端灵活拆分

这是 cmux 最实用的功能:

操作 快捷键
纵向拆分(上下排列) Ctrl + Shift + D
水平拆分(左右排列) Ctrl + D
新建标签页 Ctrl + T
跨终端切换 Ctrl + Option + 方向键

上面跑着开发服务器,中间写着代码,底部开着数据库终端。一个窗口搞定全部,不用满世界找窗口。

3. oh-my-zsh 无缝集成

cmux 能自动识别你的 oh-my-zsh 配置,包括主题和插件。之前配置的个性化设置直接迁移,不用重新折腾。

4. 内置浏览器

没错,cmux 还带了个小型浏览器。查文档的时候不用再切换到 Chrome,看完直接切回来继续干活。

image.png


插件生态

cmux 支持自动识别终端里的各种效率工具。装上这几个插件,你的终端会变得特别好用。

lazygit - 终端里的 Git 客户端

官方网站: github.com/jesseduffie…

image.png

用命令行操作 Git 的痛点:

  • 想看某个文件的修改历史,要敲 git log --oneline --graph --all -- filename
  • 想看某次提交的具体内容,grep 半天找不到重点
  • 合并分支的时候提心吊胆,生怕冲突没处理好
  • 只想看哪些文件改了,也要输入 git status

lazygit 就是为了解决这些问题而生的。

安装和使用:

# 安装
brew install lazygit

# 运行
lazygit

lazygit 能做什么?

  • 单个文件内部分行暂存:按空格键暂存选中行,v 键选择多行,a 键暂存整个 hunk
  • 交互式变基:按 i 键进入变基模式,可以 squash、fixup、drop、edit 提交,还能调整提交顺序
  • Cherry-pick:shift+c 复制提交,shift+v 粘贴
  • 二分查找:按 b 键开始 git bisect,精确定位问题提交
  • 强删工作区:shift+d 可以一键清空所有未提交的修改
  • 修正旧提交:shift+a 可以用当前暂存的修改去修正历史提交
  • 筛选视图:按 / 键筛选分支、提交等列表
  • Worktree 管理:按 w 键创建 worktree,多分支并行开发
  • 撤销/重做:z 撤销,ctrl+z 重做
  • 提交图:窗口够大时会显示彩色提交图
  • 比较提交:shift+w 比较两个提交之间的差异

我用 lazygit 之后,再也没打开过 SourceTree。键盘操作确实比鼠标快太多了。

在 cmux 中装上 lazygit,你可以一边写着代码,一边用 lazygit 管理版本。遇到需要提交的时候,Ctrl + Option + 方向键 切换到底部终端,输入提交信息,一气呵成。


fresh - 现代终端文本编辑器

image.png

官方网站: github.com/sinelaw/fre…

很多开发者习惯用 VS Code 或 Sublime Text 这种图形化编辑器,但有时候在终端里工作更高效。fresh 就是为了解决这个问题——把 VS Code 的体验带到终端里。

安装和使用:

# 快速安装
curl https://raw.githubusercontent.com/sinelaw/fresh/refs/heads/master/scripts/install.sh | sh

# 或者用 homebrew
brew tap sinelaw/fresh
brew install fresh-editor

# 运行
fresh

fresh 能做什么?

  • 零配置:安装后直接能用,不需要任何配置
  • 熟悉的热键:Ctrl+S、Ctrl+Z、Ctrl+F 这些标准快捷键都能用
  • 完整鼠标支持:像图形编辑器一样用鼠标操作
  • 命令面板:一个快捷键就能搜索文件、运行命令、切换标签页、跳转到任意行
  • 多光标编辑:同时选中多处进行批量编辑,和图形编辑器一样的体验
  • 文件管理:内置文件浏览器,支持标签页、git 状态显示、模糊搜索
  • LSP 支持:跳转到定义、引用、悬停显示文档、代码诊断、自动补全
  • 内置终端:集成终端模拟器,支持键盘捕获模式和会话持久化
  • Vim 模式:也支持 Vim 风格的 Normal/Insert/Visual 模式
  • 主题系统:内置多套主题,支持可视化主题编辑器
  • 插件系统:用 TypeScript 编写插件,Sandboxed QuickJS 环境运行
  • 多语言:支持 11 种以上语言,包括中文

这是一个终端文本编辑器,和 VS Code/Sublime Text 体验类似,但运行在终端里。特别适合那种想在终端里完成所有工作的人。


yazi - 快到飞起的终端文件管理器

官方网站: github.com/sxyazi/yazi

image.png

传统的终端文件管理:

cd /long/path/to/some/directory
ls -la
cd subfolder
ls
cd ..

每次都要 cd,路径一长简直崩溃。

yazi 带来了全新的体验,基于 Rust + 异步 I/O,速度极快。

安装和使用:

# 安装
brew install yazi

# 运行
yazi

或者直接作为命令行工具用:

# 用 yazi 打开当前目录
yazi .

# 选择文件后自动 cd 进入
yazi

yazi 的核心特性:

  • 全异步支持:所有 I/O 操作都是异步的,CPU 任务分布在多个线程
  • 内置图片预览:支持多种终端协议(kitty、iTerm2、WeTerm 等)
  • 内置代码高亮:结合预加载机制,文件加载速度极快
  • 插件系统:用 Lua 编写插件,支持 UI 重写、功能扩展
  • 虚拟文件系统:支持远程文件管理、自定义搜索引擎
  • 数据分发服务:基于客户端-服务器架构,实现跨实例通信和状态持久化
  • 包管理器:一条命令安装插件和主题
  • 集成 ripgrep、fd、fzf、zoxide
  • Vim 风格交互:输入、选择、确认、通知组件,cd 路径自动补全
  • 多标签页支持:跨目录选择、可滚动预览(视频、PDF、压缩包、代码、目录等)
  • 批量重命名:批量修改文件名
  • 归档解压:直接在终端内解压文件
  • Git 集成:显示文件修改状态
  • 主题系统:支持鼠标、回收站、自定义布局

yazi 最惊艳的功能是预览,支持图片、代码高亮、Markdown 渲染、PDF、压缩包等内容直接预览。

在 cmux 中,按 Ctrl + Option + 方向键 切换到文件管理面板,用 yazi 快速定位文件,确认后自动在当前目录执行操作。整个过程行云流水,完全不需要鼠标。


为什么选择 cmux?

特性 tmux iTerm2 cmux
学习曲线
项目导航 需要配置 一般 原生支持
终端拆分 需要配置 原生支持 原生支持
插件集成 需要配置 需要配置 自动识别
内置浏览器

说白了:cmux 就像给 iTerm2 装上了项目管理器和插件市场,让终端真正变成了 IDE

安装与配置

# macOS
brew install cmux

# Linux
# 参考官方文档

首次启动后,按照提示配置 oh-my-zsh 路径,cmux 会自动识别你的主题和插件。


写在最后

很多人觉得终端就是「古老的命令行」,用起来糙。但 cmux 证明了:终端也可以很现代,也可以很高效。

如果你也受够了窗口开太多、切换太麻烦的问题,试试 cmux + 这三个插件的组合。工具选对了,效率才能翻倍。


参考链接

用canvans画一个流程图

作者 大金乄
2026年3月6日 10:41

效果图

screenshot-1772763984082-0.006.png

组件代码如下:

<template>
  <div class="g6_main">
    <canvas
      @click="clickCanvas"
      :width="canvasWidth"
      :height="canvasHeight"
      class="g6_main_content"
    ></canvas>
    <el-dialog
      title="审批流程节点信息"
      :visible.sync="dialogData.show"
      width="40%"
      top="20vh"
      custom-class="g6_dia"
      :append-to-body="true"
    >
      <div class="c333 fs16" style="padding: 15px 0 30px 0">
        <div class="t-c">
          <span>{{
            `${dialogData.actionName}&nbsp;&nbsp;&nbsp;${dialogData.approveDesc}`
          }}</span>
        </div>
      </div>
    </el-dialog>
  </div>
</template>

<script>
  import cloneDepp from "lodash/cloneDeep";

  /**
   * 每一项数据带有nodeConfig节点对象,记录数据节点配置
   *
   * this.nodesData 数据结构:
   * [
   * [[--这里面是真正的数据也是个数组--], [], [], [], []], // 第一行 每行数组长度为 this.lineNum
   * [[], [], [], [], []], // 第二行
   * ]
   */
  const lineWidth = 4; // 线条宽度
  const lineColor = "#d3e3f4"; //  线条颜色
  export default {
    name: "Approve-G6",
    data() {
      return {
        nodesData: [],
        originData: [], // 原始数据
        canvasWidth: 0,
        canvasHeight: 0,
        lineNum: 5, // 每一行可以放多少个节点
        lineHGap: 140, // 节点之间的水平间隔 为了好看建议在 120 ~ 200 之间
        lineVGap: 60, // 节点之间的垂直间隔
        nodeConfig: {
          nodeVGap: 15, // 单个节点内部之间上下间隔
          w: 190, // 节点宽度
          h: 90, // 节点高度
        },
        lineCenterY: [], // 每一行的中心Y轴坐标
        dialogData: {
          show: false,
          actionName: "",
          approveDesc: "",
        },
        scale: 1, // 缩放比例
        canvasTranlate: {
          x: 0,
          y: 0,
        },
        resizeObserver: null,
      };
    },
    mounted() {
      this.observeParentDom();
    },
    beforeUnmount() {
      this.resizeObserver.disconnect();
    },
    methods: {
      destoryCanvas() {
        const instance = this.getCanvasInstance();
        if (instance) {
          instance.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
          instance.resetTransform();
        }
      },
      // 点击画布
      clickCanvas(evt) {
        let { offsetX, offsetY } = evt;
        for (let i = 0; i < this.nodesData.length; i++) {
          for (let k = 0; k < this.nodesData[i].length; k++) {
            if (!this.nodesData[i][k]?.length) continue;
            for (let j = 0; j < this.nodesData[i][k].length; j++) {
              let { x, y, w, h } = this.nodesData[i][k][j]["nodeConfig"];
              let inRect = this.isPointInRect(
                x,
                y,
                w,
                h,
                offsetX - this.canvasTranlate.x,
                offsetY - this.canvasTranlate.y
              );
              if (inRect) {
                let { actionName, approveDesc } = this.nodesData[i][k][j];
                this.showApproveInfo(actionName, approveDesc);
                return void 0;
              }
            }
          }
        }
      },
      // 判断点是否在矩形内
      isPointInRect(x, y, w, h, x1, y1) {
        return x <= x1 && x1 <= x + w && y <= y1 && y1 <= y + h;
      },
      // 显示审批信息
      showApproveInfo(actionName, approveDesc) {
        this.dialogData.actionName = actionName;
        this.dialogData.approveDesc = approveDesc;
        this.dialogData.show = true;
      },
      // 获取组件父级宽度大小
      getFatherSize() {
        if (!this.$el.parentElement) return;

        const { offsetWidth } = this.$el.parentElement;
        document.querySelector(".g6_main_content").style.width =
          offsetWidth + "px";
        this.canvasWidth = offsetWidth * this.scale;
      },
      /**
       * 将数组分组,每组包含lineNum个元素,不足的补冲默认数组,并将分组后的奇数行倒序
       * @param arr
       * @param chunkSize
       */
      chunkArray(arr, chunkSize) {
        const result = [];
        let isOdd = 0;
        let placeholder = [];
        for (let i = 0; i < arr.length; i += chunkSize) {
          let chunk = arr.slice(i, i + chunkSize);
          if (chunk.length < chunkSize) {
            chunk = chunk.concat(
              new Array(chunkSize - chunk.length)
                .fill(0)
                .map((v) => JSON.parse(JSON.stringify(placeholder)))
            );
          }
          if (isOdd % 2 === 1) {
            chunk = chunk.reverse();
          }
          isOdd++;
          result.push([...chunk]);
        }
        return result;
      },
      // 根据实际数据计算canvas高度
      getCanvasHeight(d) {
        let h = 0;
        let preLineH = 0; // 每一行的高度
        for (let i = 0; i < d.length; i++) {
          preLineH = 0;
          for (let j = 0; j < d[i].length; j++) {
            if (!d[i][j]?.length) continue;
            let len = d[i][j].length;
            let VGap = (len - 1) * this.nodeConfig.nodeVGap;
            let nodeH = len * this.nodeConfig.h;
            preLineH = Math.max(preLineH, nodeH + VGap);
          }
          h += preLineH + this.lineVGap;
        }
        h -= this.lineVGap;
        document.querySelector(".g6_main_content").style.height = h + "px";
        this.canvasHeight = Math.ceil(h * this.scale);
      },
      // 获取画布实例
      getCanvasInstance() {
        const canvas = document.querySelector(".g6_main_content");
        const ctx = canvas?.getContext("2d");
        return canvas ? ctx : null;
      },
      /**
       * 扩展:支持不同圆角半径
       * @param {Object} radii - 圆角半径配置
       * @param {number} radii.topLeft - 左上角半径
       * @param {number} radii.topRight - 右上角半径
       * @param {number} radii.bottomRight - 右下角半径
       * @param {number} radii.bottomLeft - 左下角半径
       */
      drawRoundRectAdvanced(ctx, x, y, width, height, radii) {
        ctx.beginPath();
        ctx.globalCompositeOperation = "source-over"; // 修改混合模式
        // 左上角
        ctx.moveTo(x + radii.topLeft, y);
        ctx.arcTo(x + width, y, x + width, y + radii.topRight, radii.topRight);
        // 右下角
        ctx.arcTo(
          x + width,
          y + height,
          x + width - radii.bottomRight,
          y + height,
          radii.bottomRight
        );
        // 左下角
        ctx.arcTo(
          x,
          y + height,
          x,
          y + height - radii.bottomLeft,
          radii.bottomLeft
        );
        // 左上角
        ctx.arcTo(x, y, x + radii.topLeft, y, radii.topLeft);
        ctx.closePath();
      },
      // 绘制节点
      drawNode(ctx, cfg) {
        let fillColor = ["#d4eaff", "#FFFFFF"];
        let { x, y, w, h } = cfg;
        let o = {
          topLeft: 8,
          topRight: 8,
          bottomRight: 8,
          bottomLeft: 8,
        };
        for (let i = 0; i < 2; i++) {
          if (i === 0) {
            o.bottomRight = 0;
            o.bottomLeft = 0;
            o.topLeft = 8;
            o.topRight = 8;
          } else {
            o.bottomRight = 8;
            o.bottomLeft = 8;
            o.topLeft = 0;
            o.topRight = 0;
          }
          this.drawRoundRectAdvanced(ctx, x, !i ? y : y + h / 2, w, h / 2, o);
          ctx.fillStyle = fillColor[i];
          ctx.fill();
        }
        // 绘制边框
        ctx.strokeStyle = "#7EA7CE";
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.roundRect(x, y, w, h, 8);
        ctx.stroke();
      },
      // 处理字符串超出长度
      fittingString(s, len = 10) {
        if (s.length <= len) {
          return s;
        }
        return s.substring(0, len) + "...";
      },
      // 绘制居中文字
      drawCenteredText(ctx, node) {
        let { x, y, w, h } = node.nodeConfig;
        y = y - h / 4;
        const paddingUp = 8;
        const paddingleft = 8;
        const cpaddingUp = 30;
        const cpaddingLeft = -5;
        const textX = x + paddingleft + (w - 2 * paddingleft) / 2;
        const textY = y + paddingUp + (h - 2 * paddingUp) / 2;
        const circleX = x + cpaddingLeft + (w - 20 * paddingleft) / 4;
        const circleY = y + cpaddingUp + (h - 2 * paddingUp) / 2;
        ctx.beginPath();
        // 设置文字对齐方式
        ctx.textAlign = "center";
        ctx.font = "15px Arial";
        ctx.textBaseline = "middle";
        ctx.fillStyle = "#0062A9";

        // 绘制文字
        let { approveSort, actionName } = node;
        ctx.fillText(this.fittingString(actionName, 10), textX, textY);
        this.drawNumberCircle(ctx, circleX, circleY, 12, approveSort);
        ctx.shadowBlur = 0; // 阴影模糊程度,数值越大越模糊
        ctx.closePath();
      },
      // 绘制带序号的圆形节点
      drawNumberCircle(ctx, x, y, radius, number) {
        ctx.beginPath();
        ctx.shadowColor = "rgba(0, 0, 0, 0.3)"; // 半透明黑色阴影,更自然
        ctx.shadowBlur = 10; // 阴影模糊程度,数值越大越模糊
        ctx.shadowOffsetX = 0; // 水平偏移量为0
        ctx.shadowOffsetY = 0; // 垂直偏移量为0(两者均为0,实现四周均匀阴影)
        ctx.arc(x - 2, y, radius, 0, Math.PI * 2);
        ctx.fillStyle = "#fff";
        ctx.fill();
        // ctx.strokeStyle = "red";// 设置边框颜色
        // ctx.stroke();// 绘制边框
        ctx.fillStyle = "#1A4F9B"; // 设置字体颜色
        ctx.font = "bold 14px Arial";
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText(number, x - 2, y + 1);
      },
      drawCenteredDescText(ctx, node) {
        let { x, y, w, h } = node.nodeConfig;
        y = y + h / 4;
        const paddingUp = 8;
        const paddingleft = 12;
        let textX = x + paddingleft + (w - 2 * paddingleft) / 2;
        let textY = y + paddingUp + (h - 2 * paddingUp) / 2;

        // 设置文字对齐方式
        ctx.textAlign = "center";
        ctx.font = "bold 12px Arial";
        ctx.textBaseline = "middle";
        ctx.fillStyle = "#333333";

        // 绘制文字
        let { approveDesc } = node;
        const lineWordNum = 14;
        approveDesc = this.fittingString(approveDesc, lineWordNum * 2 - 1);
        if (approveDesc.length > 12) {
          ctx.textAlign = "start";
          textX = textX - (w - 2 * paddingleft) / 2;
          textY = textY - paddingUp;
          ctx.fillText(approveDesc.substring(0, lineWordNum), textX, textY);
          ctx.fillText(
            approveDesc.substring(lineWordNum, approveDesc.length),
            textX,
            textY + 20
          );
        } else {
          ctx.fillText(approveDesc, textX, textY);
        }
        // ctx.fillText(, textX, textY, w, h);
      },
      // 根据数据绘制节点
      drawNodes(ctx, nodesData) {
        for (let i = 0; i < nodesData.length; i++) {
          for (let k = 0; k < nodesData[i].length; k++) {
            if (!nodesData[i][k]?.length) continue;
            for (let j = 0; j < nodesData[i][k].length; j++) {
              let { nodeConfig } = nodesData[i][k][j];
              this.drawNode(ctx, nodeConfig);
              this.drawCenteredText(ctx, nodesData[i][k][j]);
              this.drawCenteredDescText(ctx, nodesData[i][k][j]);
            }
          }
        }
      },
      // 计算画布每一行绘制的统一Y中心轴坐标,后续绘制实际节点时,根据该Y轴坐标进行响应计算并绘制
      getLineCenterY(d) {
        let saveCenterY = [];
        let lastLineY = 0; // 上一行的计算到的y轴底部坐标
        for (let i = 0; i < d.length; i++) {
          let curLineH = 0; // 当前行节点中最大节点高度
          for (let k = 0; k < d[i].length; k++) {
            if (!d[i][k]?.length) continue;
            for (let j = 0; j < d[i][k].length; j++) {
              let len = d[i][k].length;
              let nodeRealHeight =
                len * this.nodeConfig.h + (len - 1) * this.nodeConfig.nodeVGap;
              curLineH = Math.max(curLineH, nodeRealHeight);
            }
          }
          saveCenterY.push(lastLineY + curLineH / 2);
          lastLineY += curLineH + this.lineVGap;
        }
        return saveCenterY;
      },
      // 根据行中心坐标以及节点中矩形元素个数获取第一个矩形的Y坐标
      getFirstRectY(centerY, len) {
        let realHeightHaft =
          (len * this.nodeConfig.h + (len - 1) * this.nodeConfig.nodeVGap) / 2;
        return centerY - realHeightHaft;
      },
      // 获取实际元素四边的中点坐标
      getCenterPointerEgde(cfg) {
        let { x, y, w, h } = cfg;
        return {
          topCenter: [x + w / 2, y], // 上中
          rightCenter: [x + w, y + h / 2], // 右中
          bottomCenter: [x + w / 2, y + h], // 下中
          leftCenter: [x, y + h / 2], // 左中
        };
      },
      // 判断path上箭头方向
      getArrowDirection(isOdd) {
        if (!Number.isInteger(isOdd)) return "v";
        if (isOdd % 2 === 0) return ">"; // ^表示上,v表示下,<和>
        if (isOdd % 2 === 1) return "<";
      },
      // 绘制边上的圆形
      drawEgdeCircle(ctx, x, y, r) {
        r = r || 10;
        // 开始路径
        ctx.beginPath();
        // 设置填充色
        ctx.fillStyle = "#6d758c";
        // 圆心位置 (x, y), 半径, 起始角度, 结束角度, 方向
        ctx.arc(x, y, r, 0, Math.PI * 2, false);
        // 填充
        ctx.fill();
        ctx.closePath();
      },
      // 绘制边上的圆形箭头
      drawEdgeArrow(ctx, textX, textY, isOdd) {
        ctx.beginPath();
        ctx.globalCompositeOperation = "source-over"; // 修改混合模式
        const arrowTxt = this.getArrowDirection(isOdd);
        ctx.textAlign = "center";
        ctx.font = "20px Arial";
        ctx.textBaseline = "middle";
        ctx.fillStyle = "#FFFFFF";
        ctx.fillText(arrowTxt, textX, textY + 1);
        ctx.closePath();
      },
      // 绘制水平边
      drawHorizontalLineEdge(ctx, start, end, centerY, isOdd) {
        // 左边节点起点
        let sKey = "rightCenter";
        let circleX = -1;
        for (let i = 0; i < start.length; i++) {
          ctx.beginPath();
          ctx.globalCompositeOperation = "destination-over"; // 修改混合模式
          let { anchor } = start[i].edgeConfig;
          let posi = anchor[sKey];
          if (circleX === -1) {
            circleX = posi[0] + this.lineHGap / 2;
          }
          ctx.moveTo(posi[0], posi[1]);
          ctx.lineTo(posi[0] + this.lineHGap / 2, centerY);
          ctx.lineWidth = lineWidth; // 设置线的宽度
          ctx.strokeStyle = lineColor; // 设置线的颜色
          ctx.lineCap = "round";
          ctx.stroke();
          ctx.closePath();
        }
        let eKey = "leftCenter";
        for (let i = 0; i < end.length; i++) {
          this.drawEgdeCircle(ctx, circleX, centerY, 10);
          this.drawEdgeArrow(ctx, circleX, centerY, isOdd);
        }
        for (let i = 0; i < end.length; i++) {
          this.drawEgdeCircle(ctx, circleX, centerY, 10);
          this.drawEdgeArrow(ctx, circleX, centerY, isOdd);
          ctx.beginPath();
          ctx.globalCompositeOperation = "destination-over"; // 修改混合模式
          let { anchor } = end[i].edgeConfig;
          let posi = anchor[eKey];
          ctx.moveTo(posi[0] - this.lineHGap / 2, centerY);
          ctx.lineTo(posi[0], posi[1]);
          ctx.lineWidth = lineWidth; // 设置线的宽度
          ctx.strokeStyle = lineColor; // 设置线的颜色
          ctx.lineCap = "round";
          ctx.stroke();
          ctx.closePath();
        }
      },
      // 绘制垂直边
      drawVerticalLineEdge(ctx, start, end) {
        let { bottomCenter } = start["edgeConfig"]["anchor"];
        let { topCenter } = end["edgeConfig"]["anchor"];
        this.drawEgdeCircle(
          ctx,
          bottomCenter[0],
          (bottomCenter[1] + topCenter[1]) / 2,
          10
        );
        this.drawEdgeArrow(
          ctx,
          bottomCenter[0],
          (bottomCenter[1] + topCenter[1]) / 2
        );
        ctx.beginPath();
        ctx.globalCompositeOperation = "destination-over"; // 修改混合模式
        ctx.moveTo(bottomCenter[0], bottomCenter[1]);
        ctx.lineTo(topCenter[0], topCenter[1]);
        ctx.lineWidth = lineWidth; // 设置线的宽度
        ctx.strokeStyle = lineColor; // 设置线的颜色
        ctx.lineCap = "round";
        ctx.stroke();
        ctx.closePath();
      },
      // 集中执行 drawEdge
      drawEdges(ctx, nodes) {
        let isOdd = 0;

        for (let i = 0; i < nodes.length; i++) {
          for (let k = 0; k < nodes[i].length; k++) {
            if (!nodes[i][k + 1]?.length) continue;
            if (!nodes[i][k]?.length || k === nodes[i].length - 1) continue;
            this.drawHorizontalLineEdge(
              ctx,
              nodes[i][k],
              nodes[i][k + 1],
              this.lineCenterY[i],
              isOdd
            );
          }
          let hasNextLine =
            nodes[i]?.length &&
            nodes[i].length === this.lineNum &&
            nodes[i + 1]?.length; // 是否有下一行数据
          if (hasNextLine) {
            let startNodes = nodes[i][isOdd % 2 === 0 ? this.lineNum - 1 : 0];
            let endNodes = nodes[i + 1][isOdd % 2 === 0 ? this.lineNum - 1 : 0];
            this.drawVerticalLineEdge(
              ctx,
              startNodes[startNodes.length - 1],
              endNodes[0]
            );
          }
          isOdd++;
        }
      },
      /**
       * 如果数据只有一行,且一行元素个数小于this.lineNum时,图形平移至画布中心
       * 否则 根据画布余量 图形平移至画布中心
       * @param ctx 画布上下文
       */
      translateCenterCanvas(ctx) {
        let lineOneRealNum = this.nodesData[0].filter(
          (item) => item?.length
        ).length;
        let contentWidth = 0;

        if (this.nodesData.length === 1 && lineOneRealNum < this.lineNum) {
          contentWidth =
            lineOneRealNum * this.nodeConfig.w +
            (lineOneRealNum - 1) * this.lineHGap;
        } else {
          contentWidth =
            this.lineNum * this.nodeConfig.w +
            (this.lineNum - 1) * this.lineHGap;
        }

        const canvasWidth = this.canvasWidth / this.scale;
        const restLen = Math.max(0, canvasWidth - contentWidth);
        this.canvasTranlate.x = restLen / 2;
        ctx.translate(restLen / 2, 0);
      },
      // 计算一行放多少个节点
      getLineNum(w) {
        let lineNum = Math.floor(w / this.nodeConfig.w);
        let accumulatedW = 0;
        for (let k = 1; k <= lineNum; k++) {
          accumulatedW += this.nodeConfig.w + this.lineHGap;
          if (w < accumulatedW) {
            if (w < accumulatedW - this.lineHGap) {
              lineNum = k - 1;
            } else {
              lineNum = k;
            }
            break;
          }
        }
        this.lineNum = lineNum;
      },
      initCanvas(nodes) {
        this.originData = cloneDepp(nodes);
        this.scale = window.devicePixelRatio || 1;
        this.getFatherSize();
        let realCanvasWidth = this.canvasWidth / this.scale; // 真实画布作画宽度 在缩放屏幕时,画布大小会发生变化,所以需要将画布内容限制在css尺寸内
        this.getLineNum(realCanvasWidth);
        if (nodes?.length) {
          this.nodesData = this.chunkArray(nodes, this.lineNum);
          this.getCanvasHeight(this.nodesData);
          this.lineCenterY = this.getLineCenterY(this.nodesData);

          this.$nextTick(() => {
            let ctx = this.getCanvasInstance();
            ctx.resetTransform();
            // 缩放上下文
            ctx.scale(this.scale, this.scale);
            ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
            if (this.scale > 1) {
              ctx.imageSmoothingEnabled = false;
            }

            this.translateCenterCanvas(ctx);
            for (let i = 0; i < this.nodesData.length; i++) {
              let lineItem = this.nodesData[i];
              for (let k = 0; k < lineItem.length; k++) {
                if (!lineItem[k]?.length) continue;
                let lineItemtarget = lineItem[k];
                let lineItemtargetLen = lineItemtarget.length;
                let startY = this.getFirstRectY(
                  this.lineCenterY[i],
                  lineItemtargetLen
                ); // 第一个矩形的起始y坐标
                for (let j = 0; j < lineItemtarget.length; j++) {
                  let cfg = {
                    x: 0 + k * this.lineHGap + this.nodeConfig.w * k + 4, // 4是整体向右偏移4
                    y:
                      startY +
                      j * this.nodeConfig.h +
                      j * this.nodeConfig.nodeVGap,
                    w: this.nodeConfig.w,
                    h: this.nodeConfig.h,
                  };
                  let edgeCfg = {
                    anchor: this.getCenterPointerEgde(cfg),
                  };
                  lineItemtarget[j].nodeConfig = cfg;
                  lineItemtarget[j].edgeConfig = edgeCfg;
                }
              }
            }
            this.drawNodes(ctx, this.nodesData);
            this.drawEdges(ctx, this.nodesData);
          });
        }
      },
      resizeDom() {
        this.destoryCanvas();
        this.initCanvas(this.originData);
      },
      observeParentDom() {
        if (!this.$el.parentElement) return;
        if (this.resizeObserver) {
          this.resizeObserver.disconnect();
          this.resizeObserver = null;
          return;
        }
        const resizeObserver = new ResizeObserver((entries) => {
          for (const entry of entries) {
            this.resizeDom();
          }
        });

        resizeObserver.observe(this.$el.parentElement);
        this.resizeObserver = resizeObserver;
      },
    },
  };
</script>

<style lang="less">
  .g6_dia {
    .el-dialog__header .el-dialog__title {
      font-size: 18px;
    }
  }
</style>

页面使用组件:

<template>
    <div>
        <div><ApproveG6 ref="canvasApprove"></ApproveG6></div>
    </div>
</template>
<script>
methods: {
    // 接口返回的数据用这个方法来处理结构
    handlerData(dataList){
        let list = [];
        if (
          Array.isArray(dataList) && dataList.length) {
          dataList.forEach((o, ind) => {
            if (
              ind == 0 ||
              (ind > 0 &&
                o.approveSort !=
                  dataList[ind - 1].approveSort)
            ) {
              list[o.approveSort - 1] = [];
            }
            list[o.approveSort - 1].push(o);
          });

          list = list.filter((o) => {
            return o.length;
          });

          this.endStep = list.length || 0;

          if (list.length % 4 != 0) {
            const num = 4 - (list.length % 4);
            for (let i = 0; i < num; i++) {
              list.push([]);
            }
          }
        }
        list = list.filter((v) => !!v?.length);
        this.$nextTick(() => {
            this.$refs.canvasApprove?.initCanvas(list);
          });
    }
}
</script>

AI 术语满天飞?90% 的人只懂名词,不懂为什么!

作者 孟祥_成都
2026年3月6日 10:37

现在的 AI 术语满天飞:一会儿是 Model、Agent,一会儿又是 RAG、MCP、Vibe Coding 还有最近大火的 Skills。

但看了这么多文章和视频,你有没有发现一个问题:绝大多数内容都在死磕 “名词解释”,却没告诉你 “为什么会有它”。概念之间逻辑性很弱。

所以,这篇文章的目标很简单:不堆砌晦涩的术语,只讲前因后果和保证逻辑清晰。

同时,我们公众号还特别服务于面试前端开发或者前端ai全栈岗位的的同学,在我开发的前端面试真题网站上,发现近期已经出现了相关考点,例如:

  1. 美团(2026.02.28): “聊聊 MCP 和 Skills 的底层原理?”
  2. 字节跳动(2026.01.30): “MCP 的概念是什么?它和传统的 Function Call 有什么区别?”

然后,上一篇 《ai 全栈开发》系列文章 - AI 是如何进化到大模型的,从宏观层面,讲述了 AI 的发展史。为了让本篇文章有一个宏观的背景铺垫(所有微观的概念,都是宏观思想的延伸与落地。)

因此,在进入新的术语之前,我先带你回顾一下之前文章介绍的 ai 宏观的历史。

第一章:缘起--从“想要智能”开始

人工智能的本质:当“推理”变成“计算”

计算机诞生之初,其实是个极度“偏科”的天才。它天生擅长算数,无论多复杂的数字,它都能在极短的时间算出结果。

既然机器可以精准地模拟人类的“计算”能力,那么它是否也能模拟人类最引以为傲的“推理”能力?

所以人工智能的目标就是:智能如何被形式化,也就是智能如何表达为逻辑公式或者数学模型,如果可以的话,那么计算机就能实现智能!

初代尝试——被规则锁死的“教条”

既然想让计算机像人一样思考,最直观的办法就是:把人类的经验,翻译成机器能听懂的逻辑。

逻辑的起点:If(如果)...Then(那么)...

当时的科学家觉得,人类的智慧不就是一套复杂的规则吗?

  • If(如果) 今天乌云密布 + 湿度大于百分之 70
  • Then(那么) 结论:可能会下雨。

而这就是最早的 “规则引擎” 。只要规则给得够多,机器看起来就像有了 “脑子”

但人类世界的复杂是“无限”的,程序员写的代码永远是“有限”的。既然“穷举不了”,那我们就得换个思路,可不可以让机器自己去“学”?这就是 机器学习 的概念。

机器学习——从“死记硬背”到“自找公式”

在计算机眼里,万物皆可“公式化”。拿“分辨这只动物是不是猫”为例,我们先给机器一个基础公式:f(x) = w * x + b

  • x:一张图片(输入数据)
  • w、b:可以反复微调的参数。w(权重)表示这个特征有多重要,b(偏置)是基础修正。
  • f(x): 机器算出的“是猫程度有多少”

机器学习的过程,本质如下图

image.png 机器学习的进化过程:

  1. 初始状态:机器是个小白,公式里的参数都是随机生成的。它画出的那条“分界线”乱七八糟,把很多狗看成了猫(出现了错误标记)。
  2. 数据“毒打”:每当预测错误,机器就会通过反向传播算法,计算出 wwbb 偏离了多少,然后像拧收音机旋钮一样精确微调。
  3. 最终形态:经过海量迭代,机器找到了最完美的参数,那条线精准地划开了“猫”与“非猫”的界限。这就是机器学习最朴素的含义——让机器从数据中总结规律,而不是靠人手写规则

进化 —— 机器开始“模仿大脑”

虽然公式能画分界线,但现实很残酷:猫长得太复杂了! 只看“耳朵尖不尖”显然不够。猫有花纹、有尾巴、有瞳孔变化……如果把所有特征都写进一个公式,这个公式会变得冗长且低效。

于是,科学家模仿人类大脑,玩了一场“乐高积木”的游戏:他们发现,上面那个 f(x) = w * x + b 的公式,其实非常像大脑里的一个神经元。

我们不妨让一个神经元不要做判断“一张图是否是猫”这样复杂的逻辑。尝试让一个神经元负责一些小事,例如判断像素是什么,或者形状是什么。把他们联合起来后:

  1. 基层员工(第一层神经元): 只看局部,认出线条和色块。
  2. 中层干部(第二层神经元): 汇总线条,认出耳朵、眼睛的形状。
  3. 高层老板(输出层神经元): 结合所有特征,拍板判定:“这就是一只猫!”

而千上万个神经元排列组合,就构成了 神经网络,从而实现更复杂的功能。

既然堆叠能产生智慧,那如果把这个工厂盖得更高、层数更多呢?

这就是 深度学习(Deep Learning) 。“深”字代表的就是神经网络的层数。

阵痛——当“工厂”太深带来的难题

然而,要把这栋“摩天大楼”盖起来并运转顺畅,科学家们碰到了一个巨大的技术天花板。

我们知道,深度学习的过程就是“预测—纠偏—迭代”。当最终结果发现预测错了,它需要通过一套叫 “反向传播” 的算法,把错误信息往回传,告诉前面每一层的神经元:“嘿,你的参数 w 和 b 调得不对,得改改!”

但在早期的尝试中(比如传统的 RNN 循环神经网络),出现了一个致命的问题:消失的信号(梯度消失)。

想象你在玩一个 100 人的 “传声筒游戏”

队尾的教官喊了一句:“反馈错误:参数调大 0.5!”

信息往回传时,每经过一个人的耳朵,声音都会损耗一点。

传到第 50 个人时,声音变成了蚊子叫;传到第 10 个人时,由于损耗累积(数学上的连乘效应),信号彻底归零。

这种“前路漫漫,后路无声”的尴尬,让深度神经网络在很长一段时间里无法处理超长的数据(比如超长的句子)。

直到一个被称为 “Transformer” 的天才架构出现,彻底解决了这个问题……

从“接力赛”到“上帝视角”——Transformer 与大模型的诞生

它不仅解决了信号传不远的“心碎”问题,还顺手解决了一个让所有程序员头疼的效率难题:“死板的顺序”。

在 Transformer 之前,AI 读文章像小学生,必须从左往右一个字一个字读(这叫串行);而 Transformer 像是一眼十行的天才,它能同时处理整篇文章(这叫并行),并且通过大名鼎鼎的 “注意力机制(Attention)” ,一眼就能抓出词与词之间跨越千山万水的联系。

正是因为 Transformer 的出现,人类发现“摩天大楼”不仅可以盖得很高,而且盖得飞快。

于是,科学家们做了一个疯狂的决定:

不再只喂它几万张图片,而是把整个人类互联网的文本、代码、书籍全塞进去。

不再只让它判断“一张图片是否世茂”,而是让它在这片星辰大海般的数据里,寻找人类语言的终极公式。

“大力出奇迹” - 大模型诞生了

由于这栋“楼”层数极深(训练数据大)、规模极大(参数量动辄千亿级)、并且计算量大,它有了一个震慑人心的新名字:大语言模型(Large Language Model,简称 LLM)。

这下面试官再问你什么是大语言模型的大到底是什么,咋们就能跟他好好聊聊了。好的,废了这么大力气终于把背景说清楚了,如果觉得不错,感谢你的关注转发和点赞哦!

第二章:内功——大模型如何从“数字”练就“思维”?

image.png

我们上面仍然没有解释一个问题,LLM 学习后的回答的结果,为什么看起来就像它会思考呢?其实,要解开这个谜题,我们得先剥开它“文科生”的外表,看看它“理科生”的内核。

背景问题:计算机怎么可能懂“诗词歌赋”?

计算机本质上只是一个超级计算器,它只认识数字。如果你直接给电脑发一个词“苹果”,它并不理解这是水果还是手机,它只会看到一串毫无意义的二进制代码。

本质: 计算机无法理解文本,它只能处理数字。所以,第一步就是把人类语言“翻译”成机器能算的数字。

解决方案:给万物画“多维画像”(嵌入 Embedding) 要把文字变成数字,最关键的技术叫 嵌入(Embedding)。

你可以把“嵌入”理解为:给每一个词画一张极其精细的“多维画像”。

此时向量这个出现在很多科普文章中关键概念出现了,我会用更通俗易懂的方式来解释。举例:描述一个人,可以用四个维度:[性别(0是女生,1是男生), 身高, 体重, 年龄]

  • “男人” → [1, 175, 70, 30]

  • “女人” → [0, 165, 50, 25]

这里的 [1, 175, 70, 30] 在数学上就叫 向量(Vector)。而把“男人”这个词映射到这串数字的过程,就叫 嵌入(Embedding)

核心思想:在数学空间里产生“思维”

如果 4 个维度能描述一个人,那么大模型会用成千上万个维度(比如:褒贬、生命体、具体/抽象、科技术语等)来给每一个词画画像。

当所有的词都被打分并转化成“嵌入向量”后,神奇的事情发生了:这些词不再是孤立的符号,而是变成了多维空间里的一个点。

在这个数学空间里:

  • 距离代表关系: 意思相近的词(如“开心”和“快乐”),它们的画像数字非常接近,在空间里的距离也极短。
  • 逻辑可以计算: 因为每个词都是一组数字,它们竟然可以像加减法一样运算。

科学家们发现了一个震惊世界的现象:

“国王”的向量 - “男人”的向量 + “女人”的向量 ≈ “女王”的向量

换句话说: “嵌入”技术不仅把文字变成了数字,还把文字背后的逻辑关系也一并平移到了数学世界里。这是大模型能够“思考”的物理基础。

既然我们已经通过 Embedding(嵌入) 把文字变成了原材料,那么 AI 是如何把这些原材料组合起来,玩起“完形填空”并最终觉醒思维的呢?

我们接下来聊聊:无限练习——预测下一个词(预训练)

无限练习:预测下一个字(预训练)

有了画像(嵌入)还不够,因为画像是死的。真正让词与词之间产生复杂逻辑关系的(虽然坐标近的词可能意思相近,但它无法理解语境。比如“苹果”的坐标是固定的,它没法区分是“好吃的苹果”还是“好用的苹果手机”),所以还需要 “预训练”

训练方法:把全人类的知识,变成一场无限的“完形填空”。

想象一下大模型的特训过程:

  • 输入: “今天天气真__” → 目标输出: “好”
  • 输入: “人工智能是__” → 目标输出: “未” → 接着猜: “来”
  • 输入: “我喜欢吃苹果和__” → 目标输出: “香蕉”

本质是: 大模型并不真的“理解”苹果是什么味道,它在预训练中只做一件事:计算概率。它在数万亿次练习中,记住了词与词之间跨越时空的权重关系(即 注意力机制)。

换句话说: 它通过“猜词”掌握了人类语言的底层密码。当它猜得足够多、模型足够大,它表现出来的逻辑能力就越像是在“思考”。

第三章:沟通的协议 —— Prompt 与 Token

既然这个“概率天才”已经练成了,我们该如何指挥它干活呢?这就涉及到了我们每天都在用、面试也必考的两个词:Prompt 与 Token。

Promp —— 下达“任务说明书”

当你打开 AI 聊天框,输入:

“帮我规划一下去东莞的旅游攻略”

这句话就是 提示词(Prompt)。

本质是: Prompt 是你给这个概率机器提供的 “初始线索”。既然大模型是靠“预测下一个字”工作的,你的 Prompt 就是在帮它限定范围,引导它往正确的概率轨道上走。

Token —— AI 世界的“最小货币单位”

有趣的是,AI 收到你的 Prompt 后,并不是直接阅读整句话。在进入大脑处理之前,它会先通过 分词器(Tokenizer) 把你的话切成碎片。

这些碎片就叫 Token

这里就涉及到一个重要的问题为什么不直接按“字数”算?

因为在 AI 眼里,没有“字”这种概念。

它不能直接读:

帮我规划一下去东莞的旅游攻略

它真正看到的流程是:

人类语言 → Tokenizer 切分 → Token → 转成数字编号 → 模型计算

比如这句话,可能会被拆成:

[帮] [我] [规划] [一下] [去] [东莞] [的] [旅游] [攻略]

每一个 Token 都会变成一个数字编号,比如:

[1023] [8742] [5561] ...

模型真正计算的,其实是这些数字。例如上面的 [1023] 就是这个 token 编号,对应的就是我们之前说的向量,这才是用 token 的核心,因为最终大模型搜索的基础资料是向量,token 只是指向向量的一个标识符。

第四章:从“概率机器”到“智能体” —— AI Agent 的觉醒

到这里,大模型已经能很好地回答一个提问了。但请注意,仅仅是 “一个” 提问。

从模型的视角来看,每一次调用都是完全独立的。大模型的本质,就是一个训练完毕、参数确定的静态数据集。你可以把它想象成一张存储了全人类知识的、巨大的、确定的 Excel 表——你只能在已有的单元格里搜索,它无法产生新的记忆,更不会自动更新。

这里跟专业人士补充一下,大模型本质是擅长处理非结构化数据,就是自然语言,而平时我们开发的程序,往往数据库存储和程序处理的都是结构化的数据,例如 JSON 格式,并且定义了每个字段的含义

那为什么我们平时使用 ChatGPT、Claude 或豆包时,它们好像能记住之前的对话,甚至能帮你查最新的新闻呢?

核心认知: 大模型(LLM)本身没有记忆,也没有手脚。我们平时使用的产品,本质上是 AI Agent(智能体应用)。

AI Agent = 大模型(大脑) + 各种外挂功能(记忆、手脚、感官)

为了让这个“静态数据表”变得像一个有生命、有能力的助手,开发者必须解决单纯大模型存在的几个致命问题:

  1. 记忆的缺失(No Memory)

正如前面所说,大模型是“秒忘”的天才。如果不依靠 Agent 系统在后台不断地把你之前的对话历史(Context)重新打包发给模型,它根本无法维持长期的逻辑连贯性。

  1. 信息的时效性(Knowledge Cutoff)

大模型的知识停留在它训练结束的那一天。它就像一个被关在图书馆里的天才,如果不给它“外接”互联网,它永远不知道昨天的天气或者今天最新的面试真题。

  1. 逻辑的“幻觉” (Hallucination)

由于大模型本质上是根据概率预测下一个字,当它面对超出自己知识范围的问题时,为了完成“补全”任务,它会一本正经地胡说八道。在数据表里找不到答案时,它会自己“编”一个单元格。

  1. 无法与现实世界交互 (No Action)

大模型只能处理非结构化的文本。它能写出一份精美的订餐代码,但它自己没法按下那个“下单”按钮,因为它无法直接驱动那些只认结构化指令(API)的计算机系统。

所以你会发现,后面我们要讲的那些概念——Context Window、RAG、Function Calling、MCP 其实并不神秘。它们不是某种“新魔法”,而是用来解决我们前面一步步拆解出来的问题。

  • 模型没有记忆能力 - Context Window 解决
  • 模型不知道最新知识或者特定你想给他了解的知识 → RAG 解决
  • 模型只能说不会做 → Function Calling 解决
  • 工具越来越多需要统一协议 → MCP 解决

(Skill 的差异我们会在后面单独展开。)

当你带着“问题意识”去看这些名词时,

你会发现 —— 它们不再是术语,

而是答案。 如果这一套拆解思路,让你对大模型的理解更清晰了一点,也欢迎给个点赞、转发或者收藏。

第五章:记忆的错觉 —— Context(上下文)

很多人在和 AI 聊天时会觉得:“哇,它记得我上一句话说了什么,它是有记忆的!”

但真相可能会让你大吃一惊:大模型(LLM)本身其实是个“秒忘”的生物,它并没有人类那种持久的、存储式的记忆。

真相: 每次你发新消息,程序都会把你之前的对话记录全抓出来,拼成一串超长的文本,重新喂给模型。这就是 Memory(记忆)。

所以面试官问你:“什么是 Agent 的 Memory 机制?” 你心里想的是:“就是把对话记录塞进 Prompt 里。” 但你嘴上要说:“Memory 是指通过维持 Context 的连贯性,赋予 LLM 跨轮次的上下文感知能力。”(你看,身价瞬间涨了。)

但实际上大模型并不能无限制塞文字给他,是有限度的。我们之前说的 Transformer 有个很厉害的功能,就是自注意力机制,虽然厉害,但也有瓶颈:

因为 Transformer 的计算复杂度是 O(N²)。简单来说,就是每一个 Token 都要和剩下的所有 Token 计算相关性,总计算量就是 N × N。而 N,就是 Context 里的 Token 数量。

Context 就是一个模型能一次性容纳的 token 数量,我们称之为 Context Window(上下文窗口)。 所以当我们看到很多模型注明 8k,32k,128k 的时候,其实表示的就是上下文窗口,这也是为什么Context 越大越贵,因为训练量会暴涨。

但随着聊得越来越深,Context Window 很快就满了。Token 越来越贵,反应也越来越慢。这时,一个想法出现在脑海:

操作: 把那堆又臭又长的对话历史,先丢给大模型说:“嘿,帮我把这段话总结成 50 个字!”

结果: 这 50 个字的“摘要”替换掉了原来的 500 个字。

核心思想: 这就是 Memory Summarization(记忆总结)。用大模型来“精简”大模型的记忆,从而节省昂贵的 Context 空间。这也是很多 AI Agent 拥有“记忆能力”的秘密。

当你发现“伪造记忆”能让 AI 聊天更顺畅时,你开始产生一个大胆的想法:既然我能把对话历史拼进去,那我能不能把外部的资料也拼进去?

第五章:给模型“外接大脑” —— RAG

大模型虽然博学,但它本质上是一个 “闭卷考试” 的选手。所有的知识都压缩在它的参数里。如果知识更新了,或者涉及你公司的内部文档,它就抓瞎了。

一旦问题超出“记忆范围”,它就只能猜。

所以本质问题不是模型不够聪明,而是它没有最新的资料,那么如果我们提前把这些最新资料存储到外部数据库呢?这样每次大模型处理问题时,先去这个数据库查询是否有相关资料,然后在拼接到用户的 prompt 后,不就解决了吗?

RAG 的逻辑其实极其简单:把“闭卷考试”变成“开卷考试”。

第一步:整理资料(索引) 你把成千上万的文档切成小块,用我们前面讲过的 Embedding 技术,把这些文字块变成一组组数字坐标(向量),存进一个专门的仓库——向量数据库。

第二步:找参考书(检索) 当你问:“美团 2026 年的面试题有哪些?” 系统先不去问大模型,而是把你的问题也变成数字坐标,去仓库里比对。由于意思相近的词距离近,系统能瞬间抓取到那几篇相关的面试文章。

第三步:打小抄(增强) 系统把抓取到的文章内容,和你的问题拼在一起,变成一个新的 Prompt:

“已知参考资料:[美团 2026.02.28 题目...]。请根据这些资料回答:美团面试题是什么?”

第四步:念答案(生成) 大模型看着手里的“小抄”,底气十足地回答出了准确答案。

AI Agent 本身就是一个运行在物理设备上的程序(你可以假设是 Python语言编写的)。既然 AI Agent 是用 Python 等语言写的,它就拥有了编程语言的上下文。这些编程语言天生就擅长:连接网络、读写数据库、执行计算。所以 RAG 简单说来就是 Python 查了一个向量数据库的内容,然后返回相关信息给大模型而已

一句话总结 RAG: 不是让模型去“背”所有知识,而是在它回答之前,先帮它去图书馆翻翻书。

第六章:从“只会说”到“开始做” —— Function Calling

如果说 RAG 让 AI 成了“百晓生”,那么 Function Calling(函数调用) 则让 AI 拿起了“工具箱”。

痛点:AI 是个“只会动嘴”的面试官

你问 AI:“今天北京天气怎么样?” 如果没有 RAG,它会说:“对不起,我不知道实时天气。” 即便有了 RAG,如果你没给它准备天气的文档,它还是不知道。 更重要的是,如果你想让 AI “帮我把这段代码部署到服务器”或者“给老板发封邮件”,RAG 也无能为力,因为 RAG 只能读取,不能执行。

逻辑递进:给 AI 一个“操作手册”

为了让 AI 能干活,我们给它设计了一套协议:Function Calling。

  1. 定义工具: 程序员给 AI 一份清单,告诉它:“我这里有个工具叫 get_weather,你需要传入 city 参数,它就能返回天气。”
  2. 识别意图: 当用户说“帮我查查北京的天气”时,大模型发现自己回答不了,但它对比了工具清单,发现 get_weather 很合适。
  3. 输出指令: 注意!AI 此时并不亲自运行代码。它只是吐出一串结构化的指令(通常是 JSON):{ "tool": "get_weather", "args": { "city": "Beijing" } }
  4. 程序执行: 你的前端或后端代码接收到这个 JSON,替 AI 去调用真正的天气 API,拿到结果后再喂回给 AI。
  5. 总结呈现: AI 拿到结果,转换成自然语言告诉你:“北京今天晴,25°C。” 所以,Function Calling 的本质是: 大模型不再直接输出“答案”,而是输出 “调用工具的结果”

Function Calling 的本质

再次强调:AI Agent 本身就是一个运行在物理设备上的程序(你可以假设是 Python语言编写的)。既然 AI Agent 是用 Python 等语言写的,它就拥有了编程语言的上下文。这些编程语言天生就擅长:连接网络、读写数据库、执行计算。

所以编程语言执行一个函数调用是再简单不过的事情了,Function Calling(函数调用)就是这么简单一个东西而已。白话的流程上面已经介绍了,如下属于更专业一点的 Function Calling 调用流程。

image.png

  1. 第一阶段:能力构建(前提条件)
  • 大模型的微调:这是流程启动前必须完成的工作。通过微调,大模型获得了以下核心能力:

  • 意图理解能力:能准确判断用户的问题是否需要调用外部工具。

  • 参数提取能力:能从非结构化的对话中提取出函数执行所需的特定参数。

  • 格式化输出能力:能生成符合规范的 Function 请求,从而实现从文本到系统指令的转换。

  1. 工具注册与下放 (Tool Registration):
  • 开发者在 智能体中枢 (AI Agent) 中定义工具的 JSON Schema(包含函数名、功能描述、入参类型)。

  • 在发起请求时,这些工具定义会随用户问题一起 下放(注入) 给大模型(作为 System Prompt 或专用 Tools 字段)。

  • 没有这一步,大模型即使有识别能力,也不知道当前环境下有哪些具体工具可用。

  1. 第二阶段:实际运行调用流程 在具备了上述微调后的能力后,系统才能理解用户请求中对对应 Function Calling 的请求。
  • 用户提问 (Query):用户发起初始请求。

  • 注入与识别: Agent 将用户问题 + 注册的工具定义一并传给 LLM。

  • 意图识别:LLM 发现当前问题在已注册的工具范围内,输出 call: { function_name: "...", arguments: "..." }。

  • 路由解析:Agent 中枢解析 LLM 的输出,在本地映射表中找到对应的真实函数。

  • 实际调用执行:嵌入大模型的 AI Agent 根据指令,在自己的编程上下文,例如 Python 环境中调用函数,无论该函数是在本地内存中运行、执行复杂的数学计算,还是通过 HTTP 请求访问远程微服务,对模型而言都是透明且无感知的。

  • 注意,虽然执行过程对模型透明,但 输入(请求)与输出(响应) 必须严格符合预定义的协议格式。例如必须有唯一的函数名,清晰的描述(用来给大模型识别),参数等等。

结果整合与输出:将获取的数据再次提交给 LLM,由其整合生成最终答案并返回给用户。

本质就是大语言模型擅长处理自然语言,而我们人类传统编程应用程序擅长处理结构化的数据(例如 JSON 格式),所以我们就需要一个桥梁或者机制打通自然语言和传统应用,这就是 Function Calling 的最大作用

第七章:工具世界的“USB接口” —— MCP

在传统计算机世界里,我们已经见过类似的问题。比如早期的电脑:

  • 打印机一个接口
  • 鼠标一个接口
  • 键盘一个接口

后来人类发明了一个伟大的东西:USB。只要是 USB 设备,电脑都能识别。AI 世界也遇到了同样的问题。

于是 Model Context Protocol(MCP) 诞生了。MCP 的核心思想其实非常简单:

让所有工具都用统一的协议,暴露给 AI。

这样一来:

  • 数据库可以变成 MCP 工具
  • GitHub 可以变成 MCP 工具
  • 浏览器可以变成 MCP 工具
  • 本地文件系统也可以变成 MCP 工具

对 AI 来说,它面对的就不再是各种乱七八糟的 API。

所以本质上 MCP 就是借助 Function Calling 的机制,对外提供一个服务,让既有的系统快速集成到 LLM 中,通常一个 MCP Server 有很多工具,而不是一种。

那 MCP 跟 Function Calling 是什么关系呢?这是网上大多数文章都有问题的地方,它们是协作关系!我们简单叙述一下流程:

image.png

  1. 第一阶段:能力构建与协议初始化(前提条件)
  • 大模型的微调 (Foundation):通过微调使 LLM 获得核心能力,也就是能理解 MCP 标准的格式化请求。这个本质上跟训练如何识别 Function Calling 是一样的,所以有人说 MCP 就是基于 Function Calling 的。

  • MCP 动态注册 (MCP Registration):AI Agent 启动的时候,同时会启动在 Agent 里的 MCP client 客户端,MCP Client 客户端回拿到的数据返回给 AI Agent,然后 AI Agent 根据之前大模型训练好的如何接收 MCP 标准的格式化请求的要求,将这些动态获取的工具定义会随用户问题一起注入大模型

  1. 第二阶段:实际运行与协议化调用
  • 用户提问 (Query):后续用户发起请求给 AI Agent
  • 注入与识别:Agent 将“用户问题”与“从 MCP Server 拿到的工具定义”一并下发给 LLM
  • 意图识别与决策:LLM 匹配工具集,识别出调用需求,输出符合 MCP 协议的指令,例如:call: { function_name: "...", arguments: "..." }
  • 路由解析与分发:Agent 中枢解析指令,通过 MCP 客户端 将执行请求精确路由至对应的 MCP 服务器。
  • 协议化执行:MCP Server 在其编程上下文(如本地数据库、Python 环境或 HTTP 远程服务)中执行函数。

MCP Client 将返回的结果再次返回给 AI Agent, Agent 将获取的数据再次请求大模型, 最终返回给用户结果。

第八章:任务变复杂 —— Workflow 的出现

当工具越来越多时,你会发现:很多任务 不是调用一个工具就能完成的。

比如一个真实任务:帮我分析今天 GitHub 仓库的 issue,并生成日报发到 Slack。”这个任务其实包含多个步骤:

  • 查询 GitHub issue
  • 汇总数据
  • 生成总结
  • 发送 Slack

也就是说:AI 需要执行一整条流程。这就产生了一个新的概念:Workflow(工作流),而 Workflow 落地的可视化项目你必定听过 coze,没错就是扣子,当然 n8n,dify等等加入了大模型节点的 Workflow 也是一样的。

可以看下图,可视化的 Coze Workflow 是什么样的。

image.png

第九章:能力沉淀 —— Skills 的诞生

什么是 Skills?

当 Workflow(工作流程)被频繁使用后,你会发现一个规律:很多流程会被反复用到。 比如:

  1. 写周报
  2. 分析问题反馈
  3. 整理会议记录
  4. 总结文档内容
  5. 自动生成演示文稿

如果每次都从头写一遍完整流程,效率会特别低。 于是工程师做了一件很自然的事:把常用的 Workflow 打包、封装起来。 这就诞生了:Skills(技能) Skill 的本质非常简单: Skill = 可复用的 Workflow

Skills 的核心:基于 Function Calling 的巧妙封装

Skills 并不是凭空创造的新能力,它实际上是 Function Calling(函数调用) 机制的高级应用。其运行逻辑如下:

  1. 初始化/发现: 模型启动时加载所有 Skill 的元数据(名称和描述)。
  2. 触发决策: 模型根据用户请求,判断是否需要调用某个 Skill。
  3. 动态加载: 通过 Function Calling 调用 load_skill(skill_name),将详细的 SKILL.md 指令注入当前上下文。
  4. 按需执行: 依照指令执行任务,过程中可继续通过 Function Calling 调用脚本或读取资源。。。

所以 Function Calling 可以算是 AI Agent 的基础能力了,任何跟外界返回结构化数据的程序打交道,都要需要这个能力。所以 Skill 跟 Function Calling 属于协作关系。

那么 Sklls 和 MCP 属于什么关系呢?我认为既有竞争关系也有合作关系,你想想看,当市面上能把一些 MCP 功能封装为 Skills 的时候,是不是 Skills 加载要简单的多,但是问题也来了,当某个 Skill 特别复杂,我们计算机本地很难提供这个 Skill 所依赖的环境和数据的时候,这时候 MCP 仍然是有价值的。

最后我们简单创建的一个 Skill 帮助我们彻底知道 Skill 到底是什么。 感谢你的耐心解读,也感谢你的点赞收藏和转发,我们下期不见不散!

帮助理解:简单创建一个 Skill

一个标准的 Skill 目录结构如下:

commit-message-helper/
├── SKILL.md      # 核心指令(必需)
├── scripts/      # 可执行脚本 (Optional)
└── references/   # 参考资料 (Optional)

编写 SKILL.md 这是 Skill 的灵魂,必须包含 YAML frontmatter:

---
name: commit-message-helper
description: 当用户要求提交代码或编写 Git Commit 时使用。遵循 Conventional Commits 规范。
---

# Commit Message Helper
编写 Commit 时请遵循以下格式:
<type>(<scope>): <subject>

## 类型规范
- feat: 新功能
- fix: 修复 Bug
- docs: 文档变更
...

关键点: description 描述得越具体(包含触发场景),模型调用的准确率就越高。

如果你使用的是 curosr 可以将上面的内容放在 .cursorrules 文件(trae 的话,放在到 .traerules 文件),然后在跟大模型 chat 的时候,就会触发对应的 skill

Vue 3 + Vite 自动引入插件完整指南(unplugin-vue-components,unplugin-auto-import)

2026年3月6日 10:33

Vue 3 + Vite 自动引入插件完整指南

介绍如何在 Vue 3 + Vite 项目中配置 unplugin-vue-components(自动引入组件)和 unplugin-auto-import(自动引入 API),实现零 import 开发体验


一、两个插件的区别

unplugin-vue-components unplugin-auto-import
作用 自动导入组件 自动导入 API / 函数
省去什么 import DictTag from '@/components/DictTag/index.vue' import { ref, computed } from 'vue'
生成的类型文件 components.d.ts auto-imports.d.ts

效果对比

使用前(手动导入):

<template>
  <DictTag :value="count" />
</template>

<script setup>
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import DictTag from "@/components/DictTag/index.vue";

const count = ref(0);
const doubled = computed(() => count.value * 2);
</script>

使用后(自动导入):

<template>
  <DictTag :value="count" />
  <!-- 自动导入组件 -->
</template>

<script setup>
const count = ref(0); // 自动导入 ref
const doubled = computed(() => count.value * 2); // 自动导入 computed
const router = useRouter(); // 自动导入 useRouter
</script>

二、从零搭建步骤

2.1 安装依赖

npm install -D unplugin-vue-components unplugin-auto-import

如果需要自动导入 Element Plus 等 UI 框架的组件和样式,不需要额外安装 resolver,它们已内置在 unplugin-vue-components 中。

2.2 配置 vite.config.ts

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import Components from "unplugin-vue-components/vite";
import AutoImport from "unplugin-auto-import/vite";
// 如需 Element Plus 按需导入,取消下面注释
// import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

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

    // ==========================================
    // 插件一:自动导入 API(ref、computed 等)
    // ==========================================
    AutoImport({
      // 需要自动导入的库
      imports: [
        "vue", // ref, computed, watch, onMounted 等
        "vue-router", // useRouter, useRoute 等
        "pinia", // defineStore, storeToRefs 等
        // '@vueuse/core', // 按需添加
      ],

      // 生成类型声明文件(让编辑器识别自动导入的 API)
      dts: "src/types/auto-imports.d.ts",

      // 是否在 Vue 模板中自动导入
      vueTemplate: true,

      // 如需自动导入 Element Plus 的 API(ElMessage 等),取消注释:
      // resolvers: [ElementPlusResolver()],

      // 生成 ESLint 配置(避免 eslint 报未定义错误)
      eslintrc: {
        enabled: true, // 首次生成后可改为 false
        filepath: "./.eslintrc-auto-import.json",
      },
    }),

    // ==========================================
    // 插件二:自动导入组件
    // ==========================================
    Components({
      // 指定组件扫描目录
      dirs: ["src/components"],

      // 递归扫描子目录
      deep: true,

      // 组件文件扩展名
      extensions: ["vue"],

      // 生成类型声明文件
      dts: "src/types/components.d.ts",

      // 如需自动导入 Element Plus 组件,取消注释:
      // resolvers: [ElementPlusResolver()],
    }),
  ],
});

2.3 配置 tsconfig.json

确保 TypeScript 能识别自动生成的类型文件:

{
  "compilerOptions": {
    // ... 其他配置
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.vue",
    "src/types/auto-imports.d.ts",
    "src/types/components.d.ts"
  ]
}

2.4 配置 ESLint(可选)

.eslintrc.cjs 中引入自动生成的全局变量声明:

module.exports = {
  extends: [
    // ... 其他配置
    "./.eslintrc-auto-import.json", // 自动导入的全局变量
  ],
};

三、组件目录结构

unplugin-vue-components 支持以下两种组件结构,组件名自动推导:

src/components/
│
├── MyButton.vue              → 组件名:<MyButton />
│
├── DictTag/
│   └── index.vue             → 组件名:<DictTag />
│
├── UserCard/
│   └── index.vue             → 组件名:<UserCard />
│
└── FileUpload/
    └── index.vue             → 组件名:<FileUpload />

四、自动生成的文件说明

启动项目后,插件会自动生成以下文件(不要手动修改,也建议加入 .gitignore):

src/types/components.d.ts(组件类型声明)

// 由 unplugin-vue-components 自动生成
declare module "vue" {
  export interface GlobalComponents {
    DictTag: (typeof import("../components/DictTag/index.vue"))["default"];
    FileUpload: (typeof import("../components/FileUpload/index.vue"))["default"];
    // ... 其他组件
  }
}

src/types/auto-imports.d.ts(API 类型声明)

// 由 unplugin-auto-import 自动生成
declare global {
  const ref: (typeof import("vue"))["ref"];
  const computed: (typeof import("vue"))["computed"];
  const watch: (typeof import("vue"))["watch"];
  const onMounted: (typeof import("vue"))["onMounted"];
  const useRouter: (typeof import("vue-router"))["useRouter"];
  // ... 其他 API
}

五、常用进阶配置

5.1 搭配 Element Plus 按需导入

npm install element-plus
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";

AutoImport({
  imports: ["vue", "vue-router"],
  resolvers: [ElementPlusResolver()], // 自动导入 ElMessage, ElNotification 等
});

Components({
  dirs: ["src/components"],
  resolvers: [ElementPlusResolver()], // 自动导入 <el-button>, <el-input> 等
});

5.2 自定义导入规则

AutoImport({
  imports: [
    "vue",
    "vue-router",
    {
      // 自定义导入:从 '@/utils/request' 自动导入 request 函数
      "@/utils/request": ["request", "download"],
      // 从 axios 自动导入
      axios: [["default", "axios"]],
    },
  ],
});

5.3 排除不需要自动注册的组件

Components({
  dirs: ["src/components"],
  // 排除特定目录
  exclude: [/\.test\./, /node_modules/],
});

六、常见问题排查

Q1:组件自动导入不生效?

检查项 解决方案
components.d.ts 为空 删除后重启 npm run dev,确保有页面访问触发编译
项目路径含特殊字符 ()[]{} 重命名路径,去掉括号等 glob 特殊字符
组件结构不对 确保是 ComponentName/index.vueComponentName.vue
dirs 路径错误 用绝对路径验证:dirs: [path.resolve(__dirname, 'src/components')]

Q2:ESLint 报 ref is not defined

确保:

  1. AutoImporteslintrc.enabled 设为 true 生成配置文件
  2. .eslintrc.cjs 中 extends 了 .eslintrc-auto-import.json
  3. 生成后可将 enabled 改回 false(避免每次启动都重写)

Q3:编辑器没有智能提示?

确保 tsconfig.jsoninclude 中包含了两个 .d.ts 文件路径。


七、工作原理简述

┌──────────────────────────────────────────────────┐
│                   Vite 编译流程                    │
├──────────────────────────────────────────────────┤
│                                                  │
│  .vue 文件 → Vite 编译                            │
│     │                                            │
│     ├── <template> 中发现 <DictTag />             │
│     │   └── unplugin-vue-components 介入          │
│     │       └── 自动注入:                         │
│     │           import DictTag from               │
│     │           '@/components/DictTag/index.vue'  │
│     │                                            │
│     ├── <script> 中发现 ref()                     │
│     │   └── unplugin-auto-import 介入             │
│     │       └── 自动注入:                         │
│     │           import { ref } from 'vue'         │
│     │                                            │
│     └── 编译产物(已包含所有 import)                │
│                                                  │
└──────────────────────────────────────────────────┘

核心点:两个插件都是在 Vite 编译阶段 介入的,它们不改变你的源码,而是在编译产物中自动注入需要的 import 语句。写代码时完全不需要手动 import。

TreeSelect 是一个基于 Element UI 的树形选择器组件,结合了 el-select 和 el-tree 的功能,支持单选和多选模式,支持树形

作者 大金乄
2026年3月6日 10:14

看效果图: 多选模式:

screenshot-1772762188061-0.003.png 单选模式:

screenshot-1772762441231-0.004.png 直接上复制代码就能用,在代码中找到这个方法getCompanyList;然后替换成自己的树形结构的接口,返回的数据结构需要根据自身的结构来处理

使用例子

<template>
  <TreeSelect 
    v-model="selectedValue"
    :tree-data="treeData"
    :multiple="true"
    placeholder="请选择部门"
  />
</template>

<script>
export default {
  data() {
    return {
      selectedValue: [],
      treeData: [
        {
          deptName: '总公司',
          rcmOrgId: '1',
          children: [
            { deptName: '技术部', rcmOrgId: '2' },
            { deptName: '市场部', rcmOrgId: '3' }
          ]
        }
      ]
    }
  }
}
</script>

动态加载数据

<template>
  <TreeSelect 
    v-model="selectedDept"
    resource-code="cmrmp:dept"
    :is-dept="true"
    placeholder="选择部门"
    @tree-done="handleTreeDone"
  />
</template>

<script>
export default {
  data() {
    return {
      selectedDept: ''
    }
  },
  methods: {
    handleTreeDone(treeData) {
      console.log('树数据加载完成:', treeData)
    }
  }
}
</script>

自定义配置

<template>
  <TreeSelect 
    v-model="selectedValues"
    :tree-data="orgData"
    :multiple="true"
    :check-strictly="true"
    :default-props="{ 
      children: 'children', 
      label: 'name', 
      value: 'id' 
    }"
    :clearable="false"
    size="medium"
    @change="handleSelectionChange"
  />
</template>

<script>
export default {
  data() {
    return {
      selectedValues: [],
      orgData: [
        {
          name: '组织架构',
          id: 'org-1',
          children: [
            { name: '研发中心', id: 'dept-1' },
            { name: '产品部', id: 'dept-2' }
          ]
        }
      ]
    }
  },
  methods: {
    handleSelectionChange(values, nodes) {
      console.log('选中值:', values)
      console.log('节点数据:', nodes)
    }
  }
}
</script>

获取选中数据

<template>
  <div>
    <TreeSelect 
      ref="treeSelect"
      v-model="selectedIds"
      :tree-data="companyData"
      :multiple="true"
    />
    <button @click="getSelectedData">获取选中数据</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      selectedIds: [],
      companyData: [] // 树数据
    }
  },
  methods: {
    getSelectedData() {
      const nodes = this.$refs.treeSelect.getCheckedNodes()
      const keys = this.$refs.treeSelect.getCheckedKeys()
      console.log('选中节点:', nodes)
      console.log('选中键值:', keys)
    }
  }
}
</script>

组件代码如下: 在components文件夹下建立treeSelect文件夹,然后创建index.vue文件;把下面代码复制进去

<template>
  <div
    class="treeSelectWrap"
    @mouseleave="onmouseleave"
    @mouseenter="onmouseenter"
  >
    <!-- 禁用状态,单选 -->
    <el-input
      v-if="disabled && !multiple"
      disabled
      style="width: 100%"
      placeholder="请输入"
      :size="size"
      :value="selectedDisabledValue"
    ></el-input>
    <!-- 禁用状态,多选 -->
    <el-select
      v-if="disabled && multiple"
      :value="selectedDisabledValue"
      :size="size"
      style="width: 100%"
      disabled
      multiple
      collapse-tags
    >
      <el-option
        v-for="item in options"
        :key="item.value"
        :label="item.label"
        :value="item.value"
      ></el-option>
    </el-select>
    <template v-if="!disabled">
      <!-- 下拉树形选择器 -->
      <el-popover
        :trigger="trigger"
        ref="popoverRef"
        popper-class="treeSelectPopover"
        :width="popoverSlotWidth"
        :visible-arrow="false"
        :placement="placement"
        :disabled="disabled"
        :append-to-body="appendToBody"
      >
        <el-select
          v-model="selectedValue"
          ref="treeSelect"
          slot="reference"
          filterable
          popper-class="displayNone"
          placeholder="请选择(可输入名称搜索)"
          style="width: 100%"
          :multiple="multiple"
          :clearable="clearable"
          :collapse-tags="multiple"
          :filter-method="filterMethod"
          :size="size"
          :disabled="disabled"
          @clear="clearSelected"
          @remove-tag="removeTag"
          @visible-change="visbleChange"
        >
          <el-option
            v-for="item in options"
            :key="item.value"
            :label="item.label"
            :value="item.value"
          ></el-option>
        </el-select>
        <el-tree
          v-if="!disabled"
          ref="tree"
          :data="treeDataList"
          :props="defaultProps"
          :node-key="treeNodeKey"
          :show-checkbox="multiple"
          :filter-node-method="filterNode"
          :check-strictly="!checkStrictly"
          :default-expanded-keys="defaultExpandedKeys"
          :highlight-current="!multiple"
          :expand-on-click-node="false"
          @check="handleTreeCheck"
          @node-click="handleTreeNodeClick"
          @current-change="handleCurrentChange"
          @node-expand="nodeExpand"
          @node-collapse="nodeCollapse"
        >
          <!-- 多种勾选选项 -->
          <div
            class="span-ellipsis"
            slot-scope="{ node, data }"
            v-if="multiple"
          >
            <TreeSeletLabelPopover
              v-if="node.data.children && node.data.children.length"
              v-bind="refs"
              :node="node"
              :node-key="treeNodeKey"
              :selectedValue="selectedValue"
              @popoverChangeValue="popoverChangeValue"
            >
              <div
                class="text-ellipsis"
                :style="{
                  backgroundColor: data.orgStatus === '0' ? '#DDD' : '',
                  textDecoration: data.orgStatus === '0' ? 'line-through' : '',
                }"
              >
                <span>{{ node.label }}</span>
              </div>
            </TreeSeletLabelPopover>
            <div
              v-else
              class="text-ellipsis"
              :style="{
                backgroundColor: data.orgStatus === '0' ? '#DDD' : '',
                textDecoration: data.orgStatus === '0' ? 'line-through' : '',
              }"
              :title="node.label"
            >
              <span>{{ node.label }}</span>
            </div>
          </div>
          <div
            v-else
            slot-scope="{ node, data }"
            :class="{ 'text-ellipsis': true, 'node-disabled': !data.open }"
            :style="{
              backgroundColor: data.orgStatus === '0' ? '#DDD' : '',
              textDecoration: data.orgStatus === '0' ? 'line-through' : '',
            }"
            :title="node.label"
          >
            <span>{{ node.label }}</span>
          </div>
        </el-tree>
      </el-popover>
    </template>
  </div>
</template>

<script>
  import { getOpenNode, getTreeAllLength } from "@/utils";
  import TreeSeletLabelPopover from "./TreeSeletLabelPopover";
  import axios from "axios";

  export default {
    name: "CMR_TreeSelect",
    components: {
      TreeSeletLabelPopover,
    },
    props: {
      // select的size
      size: {
        type: String,
        default: "small",
      },
      // 树形数据
      treeData: {
        type: Array,
        default: () => [],
      },
      // 是否多选
      multiple: {
        type: Boolean,
        default: false,
      },
      // 是否可清空
      clearable: {
        type: Boolean,
        default: true,
      },
      // 父子节点关联(多选时有效)
      checkStrictly: {
        type: Boolean,
        default: false,
      },
      // 节点配置选项
      defaultProps: {
        type: Object,
        default: () => ({
          children: "children",
          label: "deptName",
          value: "rcmOrgId",
        }),
      },
      // 默认选中的值
      value: {
        type: [String, Number, Array],
        default: () => [],
      },
      // 是否默认选中第一个
      defaultOne: {
        type: Boolean,
        default: false,
      },
      resourceCode: {
        type: String,
        default: "",
      },
      isDept: {
        type: Boolean,
        default: false,
      },
      requestUrl: {
        type: String,
        default: "",
      },
      // 是否挂在body上
      appendToBody: {
        type: Boolean,
        default: false,
      },
      // popover的位置
      placement: {
        type: String,
        default: "bottom-start",
      },
      disabled: {
        type: Boolean,
        default: false,
      },
      // 禁用状态下,以文本的方式展示
      /**
       * 为何需要单独设置禁用状态的值?
       * 1.禁用下不走树结构,减少数的接口请求
       * 2.回显值不一定在树结构中
       */
      disabledValue: {
        type: [String, Array],
        default: "",
      },
      trigger: {
        type: String,
        default: "click",
      },
    },
    data() {
      return {
        selectedValue: this.multiple ? [] : "", // 选中的值
        selectedDisabledValue: this.multiple ? [] : "", // 禁用状态的值
        treeDataList: [], // 数结构数据
        treeNodeKey: "", // 节点唯一标识字段
        options: [], // 下拉选择的option
        popoverSlotWidth: 350, // popover的宽度
        isInit: true,
        refs: {
          companyTreeRef: {},
          companySelectRef: {},
        },
      };
    },
    watch: {
      value: {
        handler(newVal) {
          if (!this.disabled) {
            this.selectedValue = newVal;
            this.initSelectedNodes();
          }
        },
        deep: true,
        immediate: true,
      },
      disabled: {
        handler(newVal) {
          if (newVal) {
            this.initDisabledText();
          }
        },
        deep: true,
        immediate: true,
      },
      disabledValue: {
        handler() {
          this.initDisabledText();
        },
        immediate: true,
        deep: true,
      },
      treeData: {
        handler(val) {
          if (val.length && !this.disabled) {
            this.treeDataList = val;
          }
        },
        deep: true,
        immediate: true,
      },
      treeDataList: {
        handler(val) {
          if (!this.disabled) {
            this.addAttr(val);
            if (this.defaultOne) {
              this.setDefaultOne();
            }
            this.initOptions();
            this.initSelectedNodes();
          }
        },
        deep: true,
        immediate: true,
      },
      resourceCode: {
        handler(val) {
          if (val) {
            this.getCompanyList(val);
          }
        },
        immediate: true,
      },
      defaultProps: {
        handler(val) {
          if (val && typeof val === "object") {
            this.treeNodeKey = val.value;
          }
        },
        deep: true,
        immediate: true,
      },
      // 动态改变展开节点
      defaultExpandedKeys: {
        handler(keys) {
          if (this.$refs.tree && this.$refs.tree.store) {
            const nodes = this.$refs.tree.store._getAllNodes();
            nodes.forEach((item) => {
              item.expanded = keys.includes(item.data[this.defaultProps.value]);
            });
          }
        },
        deep: true,
      },
    },
    computed: {
      // 默认展开的节点
      defaultExpandedKeys() {
        if (this.disabled) {
          return [];
        }
        const findOpen = getOpenNode(this.treeDataList || []);
        let findNode = [];
        if (findOpen.length) {
          findNode = this.getExpandChild(this.treeDataList, findOpen[0].level);
        }

        const data = Array.isArray(this.value) ? this.value : [this.value];
        const valueLength = getTreeAllLength(this.treeDataList);
        // 当全选时,只展开根节点
        if (valueLength === data.length) {
          return findNode;
        }
        // 初始化时, 不为第一级时,展开第一个open的项
        if (this.isInit && findOpen[0]?.level > 1) {
          // eslint-disable-next-line vue/no-side-effects-in-computed-properties
          this.isInit = false;
          return [findOpen[0][this.defaultProps.value]];
        }
        const checkedParentKeys = data
          .filter(Boolean)
          .map((item) => {
            return this.findParentOrSelf(
              this.treeDataList || [],
              (node) => node[this.defaultProps.value] === item
            );
          })
          .filter(Boolean)
          .map((i) => i[this.defaultProps.value]);

        const res = [...findNode, ...checkedParentKeys].filter(Boolean);
        return this.$lodash.uniq(res);
      },
    },
    mounted() {
      if (!this.disabled) {
        this.refs.companySelectRef = this.$refs.treeSelect;
        this.refs.companyTreeRef = this.$refs.tree;
        const treeSelectPopoverEl =
          this.$refs.popoverRef.$el.querySelector(".treeSelectPopover");
        if (treeSelectPopoverEl) {
          treeSelectPopoverEl.addEventListener("mouseenter", this.onmouseenter);
          treeSelectPopoverEl.addEventListener("mouseleave", this.onmouseleave);
        }
        window.addEventListener("resize", this.setPopoverSlotWidth);
        this.setPopoverSlotWidth();
      }
    },
    beforeDestroy() {
      if (!this.disabled) {
        window.removeEventListener("resize", this.setPopoverSlotWidth);
        const treeSelectPopoverEl =
          this.$refs.popoverRef.$el.querySelector(".treeSelectPopover");
        if (treeSelectPopoverEl) {
          treeSelectPopoverEl.removeEventListener(
            "mouseenter",
            this.onmouseenter
          );
          treeSelectPopoverEl.removeEventListener(
            "mouseleave",
            this.onmouseleave
          );
        }
      }
    },
    methods: {
      // 处理hover模式下的交互样式问题
      onmouseleave() {
        if (!this.disabled && this.trigger === "hover") {
          this.$refs.treeSelect.visible = false;
        }
      },
      onmouseenter() {
        if (!this.disabled && this.trigger === "hover") {
          this.$refs.treeSelect.visible = true;
        }
      },
      // 设置popover的宽度
      setPopoverSlotWidth() {
        setTimeout(() => {
          this.popoverSlotWidth =
            this.$refs.treeSelect?.$el?.offsetWidth || 350;
        });
      },
      // 添加权限禁用节点
      addAttr(tree) {
        tree.map((item) => {
          if (!item.open) {
            item.disabled = true;
          } else {
            item.disabled = false;
          }
          if (item.children) {
            this.addAttr(item.children);
          }
        });
      },
      // 重置过滤显示
      visbleChange(visible) {
        if (!visible) {
          this.$refs.tree.filter();
        }
      },
      // 输入过滤寻找
      filterMethod(val) {
        if (val) {
          this.$refs.tree.filter(val);
        } else {
          this.$refs.tree.filter();
        }
      },
      filterNode(value, data) {
        if (!value) return true;
        return data.deptName.indexOf(value) !== -1;
      },
      // 节点悬浮选择
      popoverChangeValue(ids) {
        this.$emit("change", ids);
        this.$emit("input", ids);
      },
      // 获取数结构数据
      async getCompanyList(val) {
        if (this.disabled || (this.treeDataList && this.treeDataList.length)) {
          return;
        }
        let url = "/cmrmp/api/commonComponent/queryCompanyTree";
        if (this.isDept) url = "/cmrmp/api/commonComponent/queryOrgTree";
        if (this.requestUrl) url = this.requestUrl;

        const res = await axios.get(url, {
          params: {
            resourceCode: val,
          },
        });

        if (res.data.code === "1") {
          const data = res.data.result || [];
          this.addAttr(data);
          this.treeDataList = data;
          if (this.defaultOne) {
            this.setDefaultOne();
          }
          this.$emit("treeDone", data);
        }
      },
      // 找寻父节点或自己
      findParentOrSelf(tree, condition, parent = null) {
        // 如果 tree 是数组,遍历每个子节点
        if (Array.isArray(tree)) {
          for (let node of tree) {
            const result = this.findParentOrSelf(node, condition, parent);
            if (result) return result;
          }
          return null;
        }

        // 如果当前节点满足条件
        if (condition(tree)) {
          // 如果有父节点,返回父节点;否则返回自己
          return parent || tree;
        }

        // 递归查找子节点
        if (tree.children && Array.isArray(tree.children)) {
          for (let child of tree.children) {
            const result = this.findParentOrSelf(child, condition, tree);
            if (result) return result;
          }
        }

        return null;
      },
      // 找到需要展开的节点层级
      getExpandChild(node, level, result = []) {
        if (Array.isArray(node)) {
          node.forEach((o, ind) => {
            if (ind === 0 && level === 1 && o.level === 1)
              result.push(o[this.defaultProps.value]);
            else if (o.level < level) result.push(o[this.defaultProps.value]);
            if (o.children) this.getExpandChild(o.children, level, result);
          });
        } else {
          if (node.children) this.getExpandChild(node.children, level, result);
        }
        return result;
      },
      // 递归找到第一个有权限(open)的节点
      findFirstOpenNode(list) {
        if (!list) return;
        let arr = JSON.parse(JSON.stringify(list));
        while (arr.length) {
          let cur = arr[0];
          if (cur.open) return cur;
          if (cur.children?.length) {
            arr = arr.concat(cur.children);
          }
          arr.shift();
        }
      },
      // 默认选中第一个
      setDefaultOne() {
        if (!this.treeDataList.length) return;
        const curNode = this.findFirstOpenNode(this.treeDataList);
        if (curNode) {
          const data = this.multiple
            ? [curNode[this.defaultProps.value]]
            : curNode[this.defaultProps.value];
          this.$emit("change", data);
          this.$emit("input", data);
        }
      },
      // 初始化已选中的节点
      initSelectedNodes() {
        if (!this.treeDataList.length) return;

        this.$nextTick(() => {
          if (this.multiple) {
            // 多选模式
            this.$refs.tree?.setCheckedKeys(this.selectedValue || []);
          } else {
            // 单选模式
            this.$refs.tree?.setCurrentKey(this.selectedValue || "");
          }
        });
      },
      // 初始化禁用状态下的文本
      initDisabledText() {
        if (!this.disabled) {
          return;
        }
        const val = this.disabledValue;
        if (this.multiple) {
          // 多选模式
          if (typeof val == "string" && val.includes(",")) {
            this.selectedDisabledValue = val.split(",");
          } else if (Array.isArray(val)) {
            this.selectedDisabledValue = val;
          } else {
            this.selectedDisabledValue = [val];
          }
          this.options = this.selectedDisabledValue.map((d) => {
            return { value: d, label: d };
          });
        } else {
          // 单选模式
          this.selectedDisabledValue = val;
        }
      },
      // 树结构平铺成el-select的一维option
      flattenTree(
        treeData,
        idKey = this.defaultProps.value,
        nameKey = this.defaultProps.label,
        childrenKey = this.defaultProps.children
      ) {
        const result = [];

        // 确保输入是数组,如果不是则包装成数组
        const treeArray = Array.isArray(treeData) ? treeData : [treeData];

        function traverse(node) {
          if (!node) return;

          // 将当前节点转换为目标格式并添加到结果中
          result.push({
            label: node[nameKey],
            value: node[idKey],
          });

          // 如果存在子节点,则递归遍历
          if (node[childrenKey] && Array.isArray(node[childrenKey])) {
            node[childrenKey].forEach(traverse);
          }
        }

        // 遍历树的每个根节点
        treeArray.forEach(traverse);

        return result;
      },
      // 初始化options
      initOptions() {
        if (!this.treeDataList.length) return;
        const list = this.flattenTree(this.treeDataList);
        this.options = list;
      },

      // 节点展开
      nodeExpand() {
        this.$refs.treeSelect.visible = true;
      },
      // 节点收起
      nodeCollapse() {
        this.$refs.treeSelect.visible = true;
      },
      // 处理树节点点击(单选)
      handleTreeNodeClick(data, node) {
        if (this.multiple) {
          this.$refs.treeSelect.visible = true;
          return;
        }
        if (!this.multiple && data.open) {
          this.selectedValue =
            data[this.defaultProps.value] || data[this.treeNodeKey];
          this.$emit("input", this.selectedValue);
          this.$emit("change", this.selectedValue, data, node);
          this.$refs.popoverRef.doClose();
        }
      },

      // 处理树节点选择变化(多选)
      handleTreeCheck(data, checkedInfo) {
        if (this.multiple) {
          this.$refs.treeSelect.visible = true;
          const checkedKeys = checkedInfo.checkedKeys;
          this.selectedValue = checkedKeys;
          this.$emit("input", this.selectedValue);
          this.$emit("change", this.selectedValue, checkedInfo.checkedNodes);
        }
      },

      // 处理当前节点变化(单选)
      handleCurrentChange(data, node) {
        if (this.multiple) {
          this.$refs.treeSelect.visible = true;
          return;
        }
        if (!this.multiple && data.open) {
          this.selectedValue =
            data[this.defaultProps.value] || data[this.treeNodeKey];
        }
      },

      // 清空选择
      clearSelected() {
        // if (this.defaultOne) {
        //   this.setDefaultOne();
        //   return;
        // }
        if (this.multiple) {
          this.selectedValue = [];
          this.$refs.tree.setCheckedKeys([]);
        } else {
          this.selectedValue = "";
          this.$refs.tree.setCurrentKey(null);
        }
        this.$emit("input", this.selectedValue);
        this.$emit("change", this.selectedValue);
      },

      // 移除标签(多选时)
      removeTag(tag) {
        // 查找要移除的节点
        const nodes = this.$refs.tree.getCheckedNodes();
        const nodeToRemove = nodes.find(
          (node) => node[this.defaultProps.value] === tag
        );

        if (nodeToRemove) {
          const key =
            nodeToRemove[this.defaultProps.value] ||
            nodeToRemove[this.treeNodeKey];
          this.$refs.tree.setChecked(key, false);

          // 更新选中值
          const index = this.selectedValue.indexOf(key);
          if (index > -1) {
            this.selectedValue.splice(index, 1);
          }

          // if (this.selectedValue.length === 0) {
          //   if (this.defaultOne) {
          //     this.setDefaultOne();
          //     return;
          //   }
          // }
          this.$emit("input", this.selectedValue);
          this.$emit("change", this.selectedValue);
        }
      },

      // 获取选中的节点数据
      getCheckedNodes() {
        return this.multiple
          ? this.$refs.tree.getCheckedNodes()
          : [this.$refs.tree.getCurrentNode()].filter(Boolean);
      },

      // 获取选中的键值
      getCheckedKeys() {
        return this.multiple
          ? this.$refs.tree.getCheckedKeys()
          : this.selectedValue
          ? [this.selectedValue]
          : [];
      },
    },
  };
</script>

<style lang="less">
  .treeSelectPopover {
    max-height: 350px;
    overflow-y: auto;
    padding: 0;
    .el-tree {
      padding: 5px;
    }
  }
  .treeSelectPopover[x-placement] {
    margin: 0;
  }
</style>
<style lang="less" scoped>
  /deep/ .el-select-dropdown__item {
    padding: 0;
  }

  /deep/ .el-tree {
    // max-height: 350px;
    // padding: 5px;
    /* max-height: 300px; */
    /* overflow-y: auto; */
    // overflow: unset;
  }
  /deep/ .el-popover {
    // overflow-y: auto;
    // padding: 0;
    // width: 100%;
  }
  .treeSelectWrap {
    width: 100%;
  }
  .el-select-dropdown__item.selected {
    font-weight: normal;
  }
  .span-ellipsis {
    color: #333;
  }
  .text-ellipsis {
    max-width: 280px;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
  }
  .node-disabled {
    color: #999;
    cursor: not-allowed;
  }
</style>

在treeSelect文件夹下再建立TreeSeletLabelPopover.vue文件,代码如下:

<template>
  <el-popover placement="right" :open-delay="600" trigger="hover">
    <div class="optional-list-wrapper">
      <div :title="node.label" class="popover-title">
        {{ node.label || node.data.deptName }}
      </div>
      <el-link
        v-for="item in optionalList"
        :key="item"
        type="primary"
        :underline="false"
        style="display: block; margin: 1px 0"
        @click="handlePopoverClick(node.data, item)"
      >
        {{ item }}
      </el-link>
    </div>

    <template #reference>
      <slot></slot>
    </template>
  </el-popover>
</template>
<script>
  export default {
    props: {
      node: {
        // 树节点
        type: Object,
        default: () => ({}),
      },
      selectedValue: {
        type: Array,
        default: () => [],
      },
      companyTreeRef: {
        // 树组件的ref,必填
        type: Object,
        default: () => ({}),
      },
      companySelectRef: {
        type: Object,
        default: () => ({}),
      },
      nodeKey: {
        type: String,
        default: "rcmOrgId",
      },
    },
    data() {
      return {
        optionalList: [
          "勾选本级和直属下级",
          "勾选本级和全部下级",
          "取消勾选全部下级",
        ],
      };
    },
    methods: {
      getAllChildrenIds(node) {
        // 存储结果的数组
        const ids = [];

        // 如果当前节点有children且是一个数组
        if (node.children && Array.isArray(node.children)) {
          for (const child of node.children) {
            // 将当前子节点的id加入结果
            if (child[this.nodeKey] && child.open) {
              ids.push(child[this.nodeKey]);
            }
            // 递归处理该子节点的后代
            ids.push(...this.getAllChildrenIds(child));
          }
        }

        return ids;
      },
      handlePopoverClick(companyNode, type) {
        this.companySelectRef.visible = true;
        const allIds = this.getAllChildrenIds(companyNode);
        if (type === this.optionalList[0]) {
          const data = [...this.selectedValue];
          if (companyNode.open) {
            data.push(companyNode[this.nodeKey]);
          }
          const ids = data.filter((item) => !allIds.includes(item));
          if (companyNode.children?.length) {
            companyNode.children.forEach((element) => {
              if (element.open) {
                ids.push(element[this.nodeKey]);
              }
            });
          }
          this.$emit("popoverChangeValue", this.$lodash.uniq(ids));
          return;
        }
        if (type === this.optionalList[1]) {
          const ids = [...this.selectedValue];
          if (companyNode.open) {
            ids.push(companyNode[this.nodeKey]);
          }
          ids.push(...allIds);
          this.$emit("popoverChangeValue", this.$lodash.uniq(ids));
          return;
        }
        if (type === this.optionalList[2]) {
          const ids = this.selectedValue.filter(
            (item) => !allIds.includes(item)
          );
          this.$emit("popoverChangeValue", this.$lodash.uniq(ids));
          return;
        }
      },
    },
  };
</script>
<style lang="less" scoped>
  .optional-list-wrapper {
    margin: -10px;
    padding: 10px;
    position: relative;
    z-index: 1000000;
  }
  .popover-title {
    color: rgba(0, 0, 0, 0.4);
    font-size: 14px;
    padding: 0 4px 4px 0;
    margin-bottom: 3px;
    max-width: 150px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
</style>

它的效果如图:

screenshot-1772763172631-0.005.png

前端手写: Promise封装Ajax

2026年3月6日 10:11

精简版(仅核心功能):

function simpleAjax(url, method = 'GET', data = null) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open(method, url, true);
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(xhr.responseText);
      } else {
        reject(new Error(xhr.statusText));
      }
    };
    xhr.onerror = () => reject(new Error('网络错误'));
    xhr.send(data);
  });
}

完整版

封装说明

  1. 基本功能:支持GET/POST等方法,自动处理JSON数据
  2. 错误处理:包含HTTP状态码、网络错误、超时等错误处理
  3. 响应解析:自动识别JSON响应并解析
  4. 头部支持:可自定义请求头
  5. 扩展性:可根据需要添加超时设置、进度监听等功能
function ajaxPromise(options) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open(options.method || 'GET', options.url, true);
    
    // 设置请求头(可自定义)
    if (options.headers) {
      for (let key in options.headers) {
        xhr.setRequestHeader(key, options.headers[key]);
      }
    }
    
    xhr.onreadystatechange = function() {
      if (xhr.readyState === 4) {
        if (xhr.status >= 200 && xhr.status < 300) {
          try {
            const response = xhr.responseText;
            const contentType = xhr.getResponseHeader('Content-Type');
            
            // 自动解析JSON响应
            let data = response;
            if (contentType && contentType.includes('application/json')) {
              data = JSON.parse(response);
            }
            
            resolve({
              data: data,
              status: xhr.status,
              xhr: xhr
            });
          } catch (error) {
            reject(new Error(`解析响应失败: ${error.message}`));
          }
        } else {
          reject(new Error(`请求失败: ${xhr.status} ${xhr.statusText}`));
        }
      }
    };
    
    xhr.onerror = function() {
      reject(new Error('网络错误'));
    };
    
    xhr.ontimeout = function() {
      reject(new Error('请求超时'));
    };
    
    // 发送请求
    if (options.data) {
      let body = options.data;
      if (typeof body === 'object') {
        body = JSON.stringify(body);
        xhr.setRequestHeader('Content-Type', 'application/json');
      }
      xhr.send(body);
    } else {
      xhr.send();
    }
  });
}

// 使用示例
ajaxPromise({
  url: 'https://api.example.com/data',
  method: 'GET'
})
  .then(response => {
    console.log('请求成功:', response.data);
  })
  .catch(error => {
    console.error('请求失败:', error.message);
  });

// POST请求示例
ajaxPromise({
  url: 'https://api.example.com/submit',
  method: 'POST',
  data: { name: '张三', age: 25 },
  headers: {
    'Authorization': 'Bearer token123'
  }
})
  .then(response => console.log('提交成功:', response.data))
  .catch(error => console.error('提交失败:', error));

自动构建打包脚本(开发环境)

作者 大金乄
2026年3月6日 09:51

1.确保你的分支提交完毕; 2.在开发分支运行此脚本; 3.打包完成,部署平台进行部署;

一.vue2项目为例,在根目录下建立一个script文件夹,里面建一个di-build.js文件,文件的内容代码如下:

const { spawn } = require("child_process");
const { promisify } = require("util");
const exec = promisify(require("child_process").exec);

class BranchDeployer {
  constructor() {
    this.targetBranch = 'develop';
    this.currentBranch = "";
    this.npmStdout = 'di'
  }

  async run() {
    try {
      console.log("🚀 开始di构建打包流程...");

      // 获取当前分支
      await this.getCurrentBranch();
      console.log(`📋 检测到当前分支: ${this.currentBranch}`);

      // 如果当前分支已经是目标分支,则退出
      if (this.currentBranch === this.targetBranch) {
        console.log(`⚠️  当前分支已经是 ${this.targetBranch},跳过部署`);
        return;
      }

      // 检查工作区是否干净
      await this.checkWorkingDirectoryClean();

      // 拉取最新代码
      await this.pullCurrentBranch();

      // 更新所有分支并切换到目标分支
      await this.switchToTargetBranch();

      // 合并分支
      await this.mergeBranches();

      // 构建 - 添加更详细的错误处理
      await this.buildWithFallback();

      // 提交构建产物
      await this.commitBuildArtifacts();

      // 推送
      await this.pushToRemote();

      // 切换回原分支
      await this.switchBackToOriginalBranch();

      console.log(
        `✅ 打包完成!已将 ${this.currentBranch} 成功合并到 ${this.targetBranch}`
      );
    } catch (error) {
      console.error("❌ 打包过程中发生错误:", error.message);

      process.exit(1);
    }
  }

  async getCurrentBranch() {
    try {
      const { stdout } = await exec("git branch --show-current");
      this.currentBranch = stdout.trim();
    } catch (error) {
      throw new Error(`获取当前分支失败: ${error.message}`);
    }
  }

  async checkWorkingDirectoryClean() {
    try {
      const { stdout } = await exec("git status --porcelain");
      if (stdout.trim() !== "") {
        throw new Error("工作区有未提交的更改,请先提交或暂存更改");
      }
    } catch (error) {
      throw new Error(`检查工作区状态失败: ${error.message}`);
    }
  }

  async pullCurrentBranch() {
    console.log("⬇️ 开始拉取最新的远端代码...", this.currentBranch);
    try {
      await exec(`git pull origin ${this.currentBranch}`);
    } catch (error) {
      throw new Error(`拉取当前分支代码失败: ${error.message}`);
    }
  }

  async switchToTargetBranch() {
    try {
      // 更新所有分支
      await exec("git fetch origin");

      // 检查目标分支是否存在
      try {
        await exec(
          `git show-ref --verify --quiet refs/heads/${this.targetBranch}`
        );
      } catch (error) {
        throw new Error(`目标分支 ${this.targetBranch} 不存在,请检查分支名称`);
      }

      // 切换到目标分支并更新
      await exec(`git checkout ${this.targetBranch}`);
      await exec(`git pull origin ${this.targetBranch}`);
    } catch (error) {
      throw new Error(`切换到目标分支失败: ${error.message}`);
    }
  }

  async mergeBranches() {
    console.log(
      `🔄 开始将 ${this.currentBranch} 合并到 ${this.targetBranch}...`
    );

    return new Promise((resolve, reject) => {
      const mergeProcess = spawn(
        "git",
        ["merge", "--no-ff", this.currentBranch],
        {
          stdio: "inherit",
          shell: true
        }
      );

      mergeProcess.on("close", async (code) => {
        if (code === 0) {
          resolve();
        } else {
          // 合并冲突,回滚并切换回原分支
          try {
            await exec("git merge --abort");
            await exec(`git checkout ${this.currentBranch}`);
            reject(new Error("合并冲突!请手动解决冲突后重新运行脚本"));
          } catch (error) {
            reject(new Error(`合并失败且回滚也失败: ${error.message}`));
          }
        }
      });

      mergeProcess.on("error", (error) => {
        reject(new Error(`合并过程出错: ${error.message}`));
      });
    });
  }

  async buildWithFallback() {
    console.log("📦 开始构建过程...");

    try {
      // 首先检查 package.json 是否存在
      const fs = require("fs");
      if (!fs.existsSync("package.json")) {
        throw new Error("package.json 文件不存在,请在项目根目录运行此脚本");
      }

      // 检查 npm 命令是否可用
      try {
        await exec("npm --version");
      } catch (error) {
        throw new Error("npm 命令不可用,请确保已安装 Node.js 和 npm");
      }

      try {
        console.log(`🔄 尝试运行: npm run ${this.npmStdout}`);
        const success = await this.runNpmCommand(this.npmStdout);
        if (success) {
          console.log(`✅ 使用 npm run ${this.npmStdout} 构建成功`);
          return;
        }
      } catch (error) {
        console.log(`❌ npm run ${this.npmStdout} 失败: ${error.message}`);
        throw new Error(`npm run ${this.npmStdout} 失败: ${error.message}`);
      }
    } catch (error) {
      throw new Error(`构建过程失败: ${error.message}`);
    }
  }

  async runNpmCommand(command) {
    return new Promise((resolve, reject) => {
      console.log(`▶️  执行: npm run ${command}`);

      const npmProcess = spawn("npm", ['run', command], {
        stdio: "inherit",
        shell: true
      });

      npmProcess.on("close", (code) => {
        if (code === 0) {
          resolve(true);
        } else {
          reject(new Error(`npm ${command} 退出码: ${code}`));
        }
      });

      npmProcess.on("error", (error) => {
        reject(new Error(`npm ${command} 执行错误: ${error.message}`));
      });
    });
  }

  async commitBuildArtifacts() {
    console.log("💾 检查构建产物...");
    try {
      // 添加所有文件
      await exec("git add .");

      // 检查是否有文件需要提交
      const { stdout } = await exec("git status --porcelain");
      if (stdout.trim() !== "") {
        const timestamp = new Date().toLocaleString("zh-CN");
        await exec(
          `git commit -m "dist: 自动构建提交 from ${this.currentBranch} (${timestamp})"`
        );
        console.log("✅ 构建产物已提交");
      } else {
        console.log("📝 没有检测到构建产物的变化");
      }
    } catch (error) {
      throw new Error(`提交构建产物失败: ${error.message}`);
    }
  }

  async pushToRemote() {
    console.log(`🚀 推送到远程 ${this.targetBranch} 分支...`);
    try {
      await exec(`git push origin ${this.targetBranch}`);
    } catch (error) {
      throw new Error(`推送到远程失败: ${error.message}`);
    }
  }

  async switchBackToOriginalBranch() {
    console.log(`🔙 切换回原分支 ${this.currentBranch}...`);
    try {
      await exec(`git checkout ${this.currentBranch}`);
    } catch (error) {
      throw new Error(`切换回原分支失败: ${error.message}`);
    }
  }
}


// 使用示例
async function main() {
  const deployer = new BranchDeployer();
  await deployer.run();
}

// 如果直接运行此文件
if (require.main === module) {
  main().catch((error) => {
    console.error("❌ 打包失败:", error.message);
    process.exit(1);
  });
}

module.exports = BranchDeployer;

第二步.在package.json文件中加入进行如图:

screenshot-1772761542772-0.001.png

第三步:运行该命令 npm run di-build 如图:

screenshot-1772761724354-0.002.png

注意点:各自的开发环境打包命令根据自身的命令来配置

路由与布局骨架篇:布局系统 | 头部、侧边栏、内容区、面包屑的拆分与复用

作者 SuperEugene
2026年3月6日 09:19

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

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

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

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

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

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

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

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

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

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

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

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

一、先搞清楚一件事:什么是布局?

布局(Layout)就是页面里不随路由变的那一部分:头部、侧边栏、面包屑、底部等。
真正随路由变化的是「内容区」。布局负责把这些固定区域包起来,内容区填进其中。

  • 布局:结构固定、多页面共用
  • 内容区:随路由切换、每页不同

理解了这一点,再去看 Vue Router 的嵌套路由,就很好理解。

二、为什么要拆分布局组件?

不拆的话,每个页面都要写一遍头部、侧边栏,会有这些问题:

  1. 重复代码多
  2. 改头部要改 N 个页面
  3. 页面结构和布局混在一起,难维护

拆分后:

  • 布局组件:只负责头部、侧边栏等固定结构
  • 内容区:只负责当前页面的业务
  • 路由:负责决定「用哪个布局」「在哪个槽位渲染内容」

三、整体结构预览

Layout(布局容器)
├── AppHeader(头部)
├── AppSidebar(侧边栏)
├── Breadcrumb(面包屑,可选)
└── 内容区(由 <router-view> 渲染)

接下来按「路由配置 → 布局组件 → 各子组件」的顺序说明。

四、路由配置:布局与路由如何配合?

核心思路:用嵌套路由,父路由用 Layout,子路由占内容区。

4.1 基础路由结构

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/layouts/BasicLayout.vue'

const routes = [
  {
    path: '/',
    component: Layout,  // 父路由使用布局组件
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/Dashboard.vue'),
        meta: { title: '仪表盘', icon: 'dashboard' }
      },
      {
        path: 'user',
        name: 'User',
        component: () => import('@/views/User.vue'),
        meta: { title: '用户管理', icon: 'user' }
      }
    ]
  }
]

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

export default router

要点:

  • 父路由 path: '/'Layout
  • children 里的每个路由才是具体页面
  • meta 用来存标题、图标等,后面给面包屑和菜单用

4.2 多个布局怎么办?

例如:后台用带侧边栏的布局,登录页用简单布局。

const routes = [
  // 后台布局(带侧边栏)
  {
    path: '/',
    component: () => import('@/layouts/BasicLayout.vue'),
    children: [
      { path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '仪表盘' } },
      { path: 'user', component: () => import('@/views/User.vue'), meta: { title: '用户管理' } }
    ]
  },
  // 登录页布局(无侧边栏)
  {
    path: '/login',
    component: () => import('@/layouts/BlankLayout.vue'),
    children: [
      { path: '', component: () => import('@/views/Login.vue') }
    ]
  }
]

每个布局对应一个父路由,它的 children 共用同一个布局。

五、布局组件 BasicLayout.vue

5.1 完整示例

<!-- layouts/BasicLayout.vue -->
<template>
  <el-container class="basic-layout">
    <!-- 头部 -->
    <AppHeader />
    
    <el-container>
      <!-- 侧边栏 -->
      <AppSidebar />
      
      <!-- 主内容区 -->
      <el-main class="main-content">
        <!-- 面包屑 -->
        <Breadcrumb />
        <!-- 内容区:由路由渲染 -->
        <div class="content-wrapper">
          <router-view v-slot="{ Component }">
            <transition name="fade" mode="out-in">
              <component :is="Component" />
            </transition>
          </router-view>
        </div>
      </el-main>
    </el-container>
  </el-container>
</template>

<script setup>
import AppHeader from './components/AppHeader.vue'
import AppSidebar from './components/AppSidebar.vue'
import Breadcrumb from './components/Breadcrumb.vue'
</script>

<style scoped>
.basic-layout {
  min-height: 100vh;
  flex-direction: column;
}
.main-content {
  padding: 20px;
  background: #f5f7fa;
}
.content-wrapper {
  margin-top: 16px;
  padding: 20px;
  background: #fff;
  border-radius: 4px;
  min-height: calc(100vh - 180px);
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

要点:

  • <router-view> 就是子路由渲染的地方
  • v-slot="{ Component }" + <component :is="Component"> 可以配合过渡动画
  • 没有用 Element Plus 的话,把 el-container 换成普通 div 即可

六、各子组件实现

6.1 头部 AppHeader.vue

<!-- layouts/components/AppHeader.vue -->
<template>
  <header class="app-header">
    <div class="header-left">
      <span class="logo">后台管理系统</span>
    </div>
    <div class="header-right">
      <span class="user-name">管理员</span>
      <button @click="handleLogout">退出</button>
    </div>
  </header>
</template>

<script setup>
const handleLogout = () => {
  // 登出逻辑
  console.log('退出登录')
}
</script>

<style scoped>
.app-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 60px;
  padding: 0 24px;
  background: #001529;
  color: #fff;
}
.header-right {
  display: flex;
  align-items: center;
  gap: 16px;
}
</style>

6.2 侧边栏 AppSidebar.vue

侧边栏菜单需要和路由保持一致,用 routerroutes 或自己维护菜单配置都可以。

<!-- layouts/components/AppSidebar.vue -->
<template>
  <aside class="app-sidebar">
    <el-menu
      :default-active="activeMenu"
      router
      background-color="#001529"
      text-color="#fff"
    >
      <el-menu-item index="/dashboard">
        <span>仪表盘</span>
      </el-menu-item>
      <el-menu-item index="/user">
        <span>用户管理</span>
      </el-menu-item>
    </el-menu>
  </aside>
</template>

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

// 高亮当前路由对应的菜单项
const activeMenu = computed(() => route.path)
</script>

<style scoped>
.app-sidebar {
  width: 200px;
  background: #001529;
}
</style>

要点:

  • router 属性:点击菜单项会直接 router.push(index),无需手动处理
  • default-active 绑定当前路径,实现高亮

6.3 面包屑 Breadcrumb.vue

面包屑需要从当前路由推导出层级,用 route.matched 即可。

<!-- layouts/components/Breadcrumb.vue -->
<template>
  <el-breadcrumb separator="/" class="breadcrumb">
    <el-breadcrumb-item
      v-for="(item, index) in breadcrumbList"
      :key="item.path"
    >
      <!-- 最后一项不跳转 -->
      <router-link v-if="index < breadcrumbList.length - 1" :to="item.path">
        {{ item.meta?.title || item.name || '未命名' }}
      </router-link>
      <span v-else>{{ item.meta?.title || item.name || '未命名' }}</span>
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

// 从路由的 matched 自动生成面包屑
const breadcrumbList = computed(() => {
  return route.matched.filter(item => item.meta?.title || item.name)
})
</script>

<style scoped>
.breadcrumb {
  margin-bottom: 16px;
}
</style>

要点:

  • route.matched 是当前路由及其所有父路由的数组,正好对应面包屑层级
  • 最后一项用 <span>,前面的用 <router-link> 方便点击返回

七、常见坑点

坑 1:侧边栏和路由不同步

  • 原因:菜单写死在模板里,路由改了菜单没改
  • 做法:用 router.options.routes 或单独维护菜单配置,和路由保持一致,用 route.path 作为菜单的 index

坑 2:面包屑不显示或显示不对

  • 原因:route.matched 里的路由没有 meta.title
  • 做法:给每个需要出现在面包屑中的路由加上 meta: { title: 'xxx' },根路由如果是 redirect 可以不加或设为 hidden: true

坑 3:刷新后侧边栏高亮错误

  • 原因:default-active 没正确绑定到当前路径
  • 做法:用 computed(() => route.path) 绑定,并且菜单项的 index 和路由的 path 一致

坑 4:布局组件被重复创建

  • 原因:同一个父路由下的子路由切换时,Vue Router 默认会复用父级 Layout
  • 做法:这是正常行为。若需要在切换子路由时强制重挂载 Layout,可以给 router-view:key="route.fullPath",但一般不需要

八、菜单与路由统一:进阶写法

为了不重复维护「路由」和「菜单」,可以统一用路由生成菜单:

// 在 router 里定义好 meta
// 在 AppSidebar 里动态读取
import { useRouter } from 'vue-router'

const router = useRouter()
const menuRoutes = computed(() => {
  const parent = router.options.routes.find(r => r.path === '/')
  return (parent?.children || []).filter(r => !r.meta?.hidden)
})
<el-menu-item
  v-for="item in menuRoutes"
  :key="item.path"
  :index="'/' + item.path"
>
  {{ item.meta?.title }}
</el-menu-item>

这样菜单和路由只维护一份。

九、总结

模块 职责 与路由的关系
Layout 包裹头部、侧边栏、内容区 作为父路由的 component
Header 顶部固定区域 一般与路由无关
Sidebar 菜单导航 使用 routerroute.path 高亮
Breadcrumb 当前路径层级展示 依赖 route.matchedmeta
内容区 子页面内容 <router-view> 渲染

记住三步:

  1. 用嵌套路由,父用 Layout,子用具体页面
  2. 布局拆成 Header、Sidebar、Breadcrumb、router-view 四个区域
  3. 菜单、面包屑都从 routemeta 推导,避免重复配置

如果你希望我把某个小节展开(例如只用原生 div + CSS,或用 Vue 2 + Vue Router 3 版本),可以说一下具体需求,我可以再补一版对应示例。

🔍 本系列专栏导航

一、《路由与布局扫盲篇:Vue Router 实战 | 动态路由、嵌套路由与多级菜单》

二、《路由与布局扫盲篇:登录态与路由守卫 | token 校验、白名单、重定向》

三、《路由与布局扫盲篇:多标签页(Tab)与缓存 | keep-alive、includeexclude、路由 meta》

四、《路由与布局扫盲篇:布局系统 | 头部、侧边栏、内容区、面包屑的拆分与复用》

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~


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

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

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

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

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

Vue 3 新标准:<script setup> 核心特性、宏命令与避坑指南

作者 QLuckyStar
2026年3月6日 08:58

<script setup> 是 Vue 3.2 引入的一种编译时语法糖,旨在简化 Composition API 的使用。它并不是一个新的功能,而是对原有 <script> 中使用 Composition API 写法的一种语法优化

简单来说,它让你用更少的代码更直观的写法来实现同样的功能,同时在性能上也有显著提升。


1. 核心对比:传统写法 vs <script setup>

❌ 传统写法 (Vue 3.2 之前)

你需要手动导入 API,定义数据/方法,并显式 return 给模板使用。

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

export default {
  components: { MyComponent }, // 需手动注册组件
  props: ['title'],           // 需手动定义 props
  
  setup(props, { emit }) {
    const count = ref(0)
    const user = reactive({ name: 'Alice' })
    
    function increment() {
      count.value++
    }

    // ⚠️ 必须手动 return,模板才能访问
    return {
      count,
      user,
      increment,
      title // props 也要 return
    }
  }
}
</script>

✅ <script setup> 写法

无需 export default,无需 return,顶层变量自动暴露。

<script setup>
import { ref, reactive } from 'vue'
import MyComponent from './MyComponent.vue' // ✅ 自动注册组件

// ✅ 直接定义 props (编译后自动生成)
defineProps(['title'])

// ✅ 直接定义 emits
const emit = defineEmits(['change'])

// 顶层变量自动暴露给模板,无需 return
const count = ref(0)
const user = reactive({ name: 'Alice' })

function increment() {
  count.value++
  emit('change', count.value)
}
</script>

2. <script setup> 的五大核心好处

1. 代码更简洁(少写样板代码)

  • 无需 export default:组件选项直接在标签内定义。
  • 无需 return:在 <script setup> 中声明的所有顶层变量(reffunctionimport 的组件等)自动暴露给模板使用。这减少了大量的重复代码和出错可能。
  • 组件自动注册:导入的组件(如 import MyComp from ...)可以直接在模板中使用 <MyComp />,无需在 components 选项中注册。

2. 更好的 TypeScript 支持

  • 类型推导更精准:由于不需要通过 return 对象来暴露变量,TS 可以直接推断顶层变量的类型,无需复杂的泛型声明。
  • Props/Emits 类型化:配合 defineProps<Type>() 和 defineEmits<Type>(),可以获得完美的类型提示和校验,而传统写法需要繁琐的 withDefaults 或接口定义。

3. 更高的运行时性能

  • 编译优化<script setup> 的组件会被编译为一个匿名函数,作为 setup() 钩子的实现。
  • 避免代理开销:传统写法中,setup 返回的对象会被 Vue 包装成代理(Proxy)以便模板访问。而 <script setup> 中的绑定是通过闭包直接访问的,省去了创建代理对象的开销,访问速度更快。
  • Tree-shaking:未使用的代码更容易被打包工具剔除。

4. 逻辑更清晰

  • 消除“割裂感” :在传统写法中,定义的变量和模板中使用的变量之间隔着一个 return 块,阅读时需要上下跳转。<script setup> 让代码从上到下线性执行,定义即使用。
  • 专注于逻辑:开发者可以更专注于业务逻辑本身,而不是 Vue 的样板结构。

5. 原生支持宏(Macros)

提供了一些编译时宏,无需导入即可直接使用:

  • defineProps: 声明 props。
  • defineEmits: 声明 emits。
  • defineExpose: 显式暴露属性给父组件(默认情况下 <script setup> 组件实例是关闭的,即父组件无法通过 ref 访问其内部属性,除非使用此宏)。
  • defineOptions: (Vue 3.3+) 声明组件选项(如 nameinheritAttrs)。
  • withDefaults: 为 defineProps 设置默认值。

3. 特殊用法详解

A. 定义 Props 和 Emits

<script setup>
// 接收 props,具有类型推导
const props = defineProps({
  msg: String,
  count: { type: Number, required: true }
})

// 定义 emits
const emit = defineEmits(['update:count', 'submit'])

function update() {
  emit('update:count', props.count + 1)
}
</script>

B. 暴露给父组件 (defineExpose)

默认情况下,父组件通过 ref 获取子组件实例时,无法访问 <script setup> 内部的变量。如果需要暴露,必须显式声明:

<!-- Child.vue -->
<script setup>
import { ref } from 'vue'

const secret = 'hidden'
const publicData = ref(100)

function publicMethod() {
  console.log('called')
}

// 只暴露 publicData 和 publicMethod
defineExpose({
  publicData,
  publicMethod
})
</script>

C. 配合 TypeScript

<script setup lang="ts">
interface User {
  id: number
  name: string
}

// 泛型支持
const props = defineProps<{
  userId: number
  list: User[]
}>()

// 默认值
withDefaults(defineProps<{
  msg?: string
  labels?: string[]
}>(), {
  msg: 'Hello',
  labels: () => ['new'] // 对象/数组默认值需用工厂函数
})
</script>

4. 总结:为什么它是“最佳实践”?

特性 传统<script>+setup() <script setup>
代码量 多 (需 export, return, register) 极少 (声明即用)
性能 正常 (有代理开销) 更高 (闭包访问,无代理)
TS 支持 良好 (但需额外类型声明) 完美 (原生推导)
组件注册 手动 自动
推荐度 ⭐⭐ (兼容旧项目) ⭐⭐⭐⭐⭐ (新项目首选)

结论
除非你需要维护非常古老的 Vue 3 早期代码,否则在所有新的 Vue 3 项目中,都应该无条件使用 <script setup> 。它是 Vue 团队官方推荐的默认写法,代表了 Vue 未来的发展方向。

❌
❌