阅读视图

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

GDAL 实现影像裁剪

^ 关注我,带你一起学GIS ^ 前言 由于本文由一些前置知识,在正式开始之前,需要你掌握一定的Python开发基础和GDAL的基本概念。在之前的文章中讲解了如何使用GDAL或者ogr2ogr工具将t

vite.config.js 8 大核心模块,一文吃透

一、Vite 是什么?—— 面向未来的前端构建工具 Vite(法语意为“快”)是由 Vue 作者尤雨溪创建的新型前端构建工具。它利用浏览器原生支持 ES 模块(ESM)的能力,在开发环境下实现了极快的

前端ESLint 和 Babel对比

ESLint 和 Babel 虽然都基于 AST(抽象语法树)工作,但它们的设计目的、工作流和 API 设计有着本质的区别。

  1. ESLint 插件实战:开发一个 eslint-plugin-clean-arch,用于强制执行“整洁架构”的依赖原则(例如:禁止 UI 层直接导入 DAO 层,必须经过 Service 层)。
  2. Babel 插件实战:开发一个 babel-plugin-auto-try-catch,用于在编译时自动给 async/await 函数包裹 try-catch 块,并上报错误信息,避免手动写大量重复代码。

第一部分:核心差异概览

在进入代码之前,先通过表格建立核心认知:

特性 ESLint 插件 Babel 插件
核心目标 代码质量检查与风格统一(Linting) 代码转换与编译(Transpiling)
输出结果 报告错误/警告,或进行源码级的字符串替换(Fix) 生成全新的、兼容性更好的 JavaScript 代码
AST 标准 ESTree (使用 espree 解析) Babel AST (基于 ESTree 但有细微差异,如 Literal 分类)
遍历方式 扁平化的选择器遍历 (Selectors) 访问者模式 (Visitor Pattern)
修改能力 弱。主要通过 fixer 提供文本范围替换,必须保持 AST 有效性比较难 强。可以随意增删改查节点,生成完全不同的代码结构
运行时机 开发时(IDE提示)、提交时(Husky)、CI/CD 阶段 构建打包阶段(Webpack/Vite/Rollup 加载器中)

第二部分:ESLint 自定义插件实战 (深度代码)

场景描述

在大型项目中,我们需要控制模块间的依赖关系。假设项目结构如下:

  • src/views (UI层)
  • src/services (业务逻辑层)
  • src/api (数据访问层)

规则src/views 下的文件,禁止直接 import 来自 src/api 的文件,必须通过 src/services 调用。

1. 插件入口结构

通常定义在 index.js 中。

/**
 * @fileoverview eslint-plugin-clean-arch
 * 强制执行项目架构分层依赖规则的 ESLint 插件
 */
'use strict';

// 导入我们即将编写的规则定义
const restrictLayerImports = require('./rules/restrict-layer-imports');

// 插件主入口
module.exports = {
  // 插件元数据
  meta: {
    name: 'eslint-plugin-clean-arch',
    version: '1.0.0'
  },
  // 暴露配置预设(用户可以直接 extends: ['plugin:clean-arch/recommended'])
  configs: {
    recommended: {
      plugins: ['clean-arch'],
      rules: {
        'clean-arch/restrict-layer-imports': 'error'
      }
    }
  },
  // 规则定义集合
  rules: {
    'restrict-layer-imports': restrictLayerImports
  },
  // 处理器(可选,用于处理非 JS 文件,如 .vue 中的 script)
  processors: {
    // 这里简单示意,通常 vue-eslint-parser 已经处理了
  }
};

2. 规则实现核心 (rules/restrict-layer-imports.js)

这是最核心的部分,包含了 AST 分析逻辑。

/**
 * @fileoverview 禁止跨层级直接调用
 */
'use strict';

const path = require('path');

// 辅助函数:标准化路径分隔符,兼容 Windows
function normalizePath(filePath) {
  return filePath.split(path.sep).join('/');
}

// 辅助函数:判断文件属于哪个层级
function getLayer(filePath) {
  const normalized = normalizePath(filePath);
  if (normalized.includes('/src/views/')) return 'views';
  if (normalized.includes('/src/services/')) return 'services';
  if (normalized.includes('/src/api/')) return 'api';
  return 'other';
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
  meta: {
    type: 'problem', // problem | suggestion | layout
    docs: {
      description: 'Enforce strict layer dependency rules: Views -> Services -> API',
      category: 'Architecture',
      recommended: true,
      url: 'https://my-company.wiki/arch-rules'
    },
    fixable: null, // 本规则不提供自动修复,因为架构调整需要人工介入
    // 定义错误消息模板
    messages: {
      restrictedImport: '架构违规: "{{currentLayer}}" 层禁止直接引入 "{{targetLayer}}" 层模块。请通过 Service 层中转。',
      invalidPath: '无法解析的导入路径: {{importPath}}'
    },
    // 规则配置 Schema
    schema: [
      {
        type: 'object',
        properties: {
          // 允许用户自定义层级映射
          layers: {
            type: 'object'
          }
        },
        additionalProperties: false
      }
    ]
  },

  /**
   * create 方法返回一个对象,该对象的方法名为 AST 选择器
   * ESLint 遍历 AST 时会回调这些方法
   * @param {import('eslint').Rule.RuleContext} context
   */
  create(context) {
    // 获取当前正在被 Lint 的文件名
    const currentFilename = context.getFilename();
    const currentLayer = getLayer(currentFilename);

    // 如果当前文件不在受控层级中,直接忽略
    if (currentLayer === 'other') {
      return {};
    }

    // 定义层级依赖约束表
    // Key: 当前层级, Value: 禁止引入的层级集合
    const RESTRICTED_MAP = {
      'views': ['api'], // View 层禁止引入 API 层
      'services': [],   // Service 层可以引入 API
      'api': ['views', 'services'] // API 层通常是底层的,不应反向依赖
    };

    /**
     * 核心校验逻辑
     * @param {ASTNode} node - ImportDeclaration 节点
     */
    function verifyImport(node) {
      // 获取 import 的路径值,例如: import x from '@/api/user' 中的 '@/api/user'
      const importPath = node.source.value;

      // 忽略第三方库 (通常不以 . / @ 开头,或者是 node_modules)
      // 这里简单判断:如果不是相对路径也不是别名路径,认为是 npm 包
      if (!importPath.startsWith('.') && !importPath.startsWith('/') && !importPath.startsWith('@')) {
        return;
      }

      // 尝试解析导入路径对应的实际层级
      // 注意:在 ESLint 规则中做完整的文件系统解析比较重,
      // 通常我们会根据字符串特征判断,或者依赖 resolver
      let targetLayer = 'other';
      
      if (importPath.includes('/api/') || importPath.includes('@/api/')) {
        targetLayer = 'api';
      } else if (importPath.includes('/services/') || importPath.includes('@/services/')) {
        targetLayer = 'services';
      } else if (importPath.includes('/views/') || importPath.includes('@/views/')) {
        targetLayer = 'views';
      }

      // 检查是否违规
      const forbiddenLayers = RESTRICTED_MAP[currentLayer] || [];
      
      if (forbiddenLayers.includes(targetLayer)) {
        context.report({
          node: node.source, // 错误红线标在路径字符串上
          messageId: 'restrictedImport', // 使用 meta.messages 中定义的 ID
          data: {
            currentLayer: currentLayer,
            targetLayer: targetLayer
          }
        });
      }
    }

    return {
      // 监听 ES6 Import 语句
      // 例如: import { getUser } from '@/api/user';
      ImportDeclaration(node) {
        verifyImport(node);
      },

      // 监听动态 Import
      // 例如: const user = await import('@/api/user');
      ImportExpression(node) {
        // 动态 import 的 source 就是调用的参数
        verifyImport(node);
      },

      // 监听 CommonJS require (如果项目混用)
      // 例如: const api = require('@/api/user');
      CallExpression(node) {
        if (
          node.callee.name === 'require' &&
          node.arguments.length > 0 &&
          node.arguments[0].type === 'Literal'
        ) {
          // 构造成类似的结构以便复用 verifyImport
          const mockNode = {
            source: node.arguments[0]
          };
          verifyImport(mockNode);
        }
      }
    };
  }
};

3. 单元测试 (tests/rules/restrict-layer-imports.test.js)

ESLint 提供了 RuleTester 工具,非常方便进行 TDD 开发。

'use strict';

const rule = require('../../rules/restrict-layer-imports');
const { RuleTester } = require('eslint');

const ruleTester = new RuleTester({
  parserOptions: {
    ecmaVersion: 2020,
    sourceType: 'module'
  }
});

// 定义测试用例
ruleTester.run('restrict-layer-imports', rule, {
  // 1. 合法代码测试 (Valid)
  valid: [
    {
      // Service 层调用 API 层 -> 合法
      code: "import { getUser } from '@/api/user';",
      filename: '/Users/project/src/services/userService.js'
    },
    {
      // View 层调用 Service 层 -> 合法
      code: "import { getUserService } from '@/services/userService';",
      filename: '/Users/project/src/views/UserDetail.vue'
    },
    {
      // 引入第三方库 -> 合法
      code: "import axios from 'axios';",
      filename: '/Users/project/src/views/UserDetail.vue'
    },
    {
      // 相对路径引用同层级文件 -> 合法
      code: "import Header from './Header';",
      filename: '/Users/project/src/views/Footer.vue'
    }
  ],

  // 2. 违规代码测试 (Invalid)
  invalid: [
    {
      // View 层直接调用 API 层 -> 报错
      code: "import { getUser } from '@/api/user';",
      filename: '/Users/project/src/views/UserDetail.vue',
      errors: [
        {
          message: '架构违规: "views" 层禁止直接引入 "api" 层模块。请通过 Service 层中转。',
          type: 'Literal' // 报错节点类型
        }
      ]
    },
    {
      // API 层反向依赖 Views 层 -> 报错
      code: "import router from '@/views/router';",
      filename: '/Users/project/src/api/http.js',
      errors: [
        {
          message: '架构违规: "api" 层禁止直接引入 "views" 层模块。请通过 Service 层中转。'
        }
      ]
    },
    {
      // 动态 Import 也要拦截
      code: "const api = import('@/api/user');",
      filename: '/Users/project/src/views/Home.vue',
      parserOptions: { ecmaVersion: 2020 },
      errors: [{ messageId: 'restrictedImport' }]
    }
  ]
});

第三部分:Babel 自定义插件实战 (深度代码)

场景描述

前端开发中,异步操作如果不加 try-catch,一旦报错可能导致页面白屏。手动给每个 awaittry-catch 很繁琐且代码臃肿。 目标:编写一个 Babel 插件,自动识别 async 函数中的 await 语句,如果它没有被 try-catch 包裹,则自动包裹,并注入错误上报逻辑。

转换前

async function fetchData() {
  const res = await api.getData();
  console.log(res);
}

转换后

async function fetchData() {
  try {
    const res = await api.getData();
    console.log(res);
  } catch (e) {
    console.error('Auto Captured Error:', e);
    // window.reportError(e); // 可以在插件配置中传入上报函数名
  }
}

1. Babel 插件基础结构

Babel 插件导出一个函数,返回一个包含 visitor 属性的对象。

// babel-plugin-auto-try-catch.js

/**
 * Babel Types 库提供了用于构建、验证和转换 AST 节点的工具方法
 * @param {import('@babel/core')} babel
 */
module.exports = function(babel) {
  const { types: t, template } = babel;

  return {
    name: 'babel-plugin-auto-try-catch',
    // visitor 是访问者模式的核心
    visitor: {
      // 我们关注 FunctionDeclaration, FunctionExpression, ArrowFunctionExpression
      // 可以合并为一个选择器 'Function'
      Function(path, state) {
        // 1. 如果函数不是 async 的,跳过
        if (!path.node.async) {
          return;
        }

        // 2. 如果函数体已经是空的,跳过
        if (path.node.body.body.length === 0) {
          return;
        }

        // 3. 检查函数体是否已经被 try-catch 包裹
        // 获取函数体的第一条语句
        const firstStatement = path.node.body.body[0];
        // 如果只有一条语句且是 TryStatement,说明已经处理过或用户手动写了,跳过
        if (path.node.body.body.length === 1 && t.isTryStatement(firstStatement)) {
          return;
        }

        // 4. 获取用户配置的排除项 (例如排除某些文件或函数名)
        const exclude = state.opts.exclude || [];
        // 获取当前处理的文件路径
        const filename = state.file.opts.filename || 'unknown';
        // 简单的排除逻辑示例
        if (exclude.some(pattern => filename.includes(pattern))) {
          return;
        }
        
        // 5. 开始执行转换
        // 核心逻辑:将函数体原来的内容,塞入 try 块中
        
        // 步骤 A: 生成 catch 子句的 error 参数节点 (identifier)
        // 使用 path.scope.generateUidIdentifier 防止变量名冲突 (例如防止用户原代码里已经有个变量叫 err)
        const errorParam = path.scope.generateUidIdentifier('err');

        // 步骤 B: 构建 catch 块的内容
        // 这里我们可以根据配置,生成 console.error 或 reportError 调用
        const reporterName = state.opts.reporter || 'console.error';
        
        // 使用 babel template 快速构建 AST 节点,比手动 t.callExpression 更直观
        // %%err%% 是占位符,会被替换为上面生成的 errorParam
        const catchBodyTemplate = template.statement(`
          ${reporterName}('Async Error:', %%err%%);
        `);
        
        const catchBlockStatement = t.blockStatement([
          catchBodyTemplate({ err: errorParam })
        ]);

        // 步骤 C: 构建 catch 子句节点
        const catchClause = t.catchClause(
          errorParam,
          catchBlockStatement
        );

        // 步骤 D: 构建 try 语句节点
        // path.node.body 是 BlockStatement,包含 body 属性(语句数组)
        const originalBodyStatements = path.node.body.body;
        
        const tryStatement = t.tryStatement(
          t.blockStatement(originalBodyStatements), // try 块内容
          catchClause, // catch 块
          null // finally 块 (可选)
        );

        // 步骤 E: 替换原函数体
        // 注意:直接替换 body 可能会导致死循环(因为新生成的节点也包含函数体),
        // 但这里我们要替换的是 Function 的 body (BlockStatement) 的内容,
        // 或者直接替换 body 为包含 tryStatement 的新 BlockStatement。
        
        path.get('body').replaceWith(
          t.blockStatement([tryStatement])
        );

        // 标记该节点已被访问,避免递归处理死循环 (Babel 默认会重新访问新插入的节点)
        path.skip(); 
      }
    }
  };
};

2. 增强版 Babel 插件逻辑 (处理细节)

上面的版本比较粗暴(把整个函数体包起来)。但在实际中,我们可能只想包裹包含 await 的代码段,或者如果用户已经写了部分 try-catch 该怎么办?

下面是更精细的 AST 操作逻辑:

// 进阶工具函数:检查 BlockStatement 中是否包含 await 表达式
function hasAwaitExpression(path) {
  let hasAwait = false;
  // 使用 path.traverse 可以在当前路径下进行子遍历
  path.traverse({
    AwaitExpression(childPath) {
      // 必须确保 await 是属于当前函数的,而不是嵌套在内部其他 async 函数里的
      const parentFunction = childPath.getFunctionParent();
      if (parentFunction === path) {
        hasAwait = true;
        childPath.stop(); // 找到一个就停止
      }
    },
    // 防止遍历进入内部函数的陷阱
    Function(childPath) {
      childPath.skip();
    }
  });
  return hasAwait;
}

// 修改 visitor 部分
visitor: {
  Function(path, state) {
    if (!path.node.async) return;
    
    // 进阶优化:如果没有 await,其实不需要包裹 try-catch (虽然 async 函数报错会返回 reject promise,但这里假设只捕获 await 异常)
    if (!hasAwaitExpression(path)) {
      return;
    }

    // 处理 React/Vue 组件方法名排除
    const functionName = path.node.id ? path.node.id.name : '';
    if (['render', 'setup', 'componentDidCatch'].includes(functionName)) {
      return;
    }
    
    // ... 后续转换逻辑同上 ...
  }
}

3. Babel 插件单元测试

Babel 插件测试通常使用 babel-plugin-tester 或直接调用 @babel/coretransformSync

const babel = require('@babel/core');
const autoTryCatchPlugin = require('./babel-plugin-auto-try-catch');
const assert = require('assert');

// 辅助测试函数
function transform(code, options = {}) {
  const result = babel.transformSync(code, {
    plugins: [
      [autoTryCatchPlugin, options] // 加载插件并传入配置
    ],
    // 禁用 Babel 默认生成严格模式,减少干扰
    sourceType: 'script', 
    compact: false // 格式化输出代码
  });
  return result.code;
}

console.log('--- 开始测试 Babel 插件 ---');

// 测试用例 1: 普通 Async 函数转换
const code1 = `
async function getData() {
  const res = await api.get('/user');
  return res;
}
`;
const output1 = transform(code1);
console.log('[Case 1 Output]:\n', output1);
/*
预期输出:
async function getData() {
  try {
    const res = await api.get('/user');
    return res;
  } catch (_err) {
    console.error('Async Error:', _err);
  }
}
*/
assert.match(output1, /try \{/, 'Case 1 Failed: try block missing');
assert.match(output1, /catch \(_err\)/, 'Case 1 Failed: catch block missing');


// 测试用例 2: 箭头函数转换
const code2 = `
const doWork = async () => {
  await sleep(1000);
  console.log('done');
};
`;
const output2 = transform(code2);
console.log('[Case 2 Output]:\n', output2);
assert.match(output2, /try \{/, 'Case 2 Failed');


// 测试用例 3: 已经有 Try-Catch 的函数 (应跳过)
const code3 = `
async function safe() {
  try {
    await risky();
  } catch (e) {
    handle(e);
  }
}
`;
const output3 = transform(code3);
// 输出应该和输入几乎一样(除了格式化差异)
// 我们通过判断 catch 块的数量来验证没有重复插入
const catchCount = (output3.match(/catch/g) || []).length;
assert.strictEqual(catchCount, 1, 'Case 3 Failed: Should not add extra try-catch');


// 测试用例 4: 自定义 Reporter 配置
const code4 = `async function test() { await fn(); }`;
const output4 = transform(code4, { reporter: 'window.reportToSentry' });
console.log('[Case 4 Output]:\n', output4);
assert.match(output4, /window\.reportToSentry/, 'Case 4 Failed: Custom reporter not working');

console.log('--- 所有测试通过 ---');

第四部分:底层机制深度对比

这部分解释为什么代码要这么写,这对于理解 1000 行级别的复杂插件开发至关重要。

1. 遍历机制:Scope (作用域) 管理

这是 Babel 和 ESLint 插件开发中最难的部分。

  • ESLint:

    • context.getScope() 获取当前节点的作用域。
    • 主要用于查找变量定义(References)。例如:no-undef 规则就是通过遍历 Scope 中的 references 列表,看是否有未定义的变量。
    • ESLint 的 Scope 分析是静态只读的。你不能在 lint 过程中修改 Scope。
  • Babel:

    • path.scope 对象非常强大。
    • path.scope.generateUidIdentifier('name'):自动生成唯一变量名(如 _name, _name2),这在转换代码注入变量时必不可少(如上面 try-catch 中的 err)。
    • path.scope.push({ id: ... }):可以将变量声明提升到作用域顶部。
    • Binding:Babel 维护了极其详细的变量绑定信息。你可以通过 path.scope.bindings['x'] 找到变量 x 的所有引用位置(referencePaths)和赋值位置(constantViolations)。这使得做“死代码消除”或“常量折叠”成为可能。

2. 状态管理 (State)

  • ESLint:

    • 状态通常保存在闭包变量中,或者 create 函数的局部变量中。
    • 因为 ESLint 是按文件处理的,create 每次处理新文件都会重新执行,所以闭包变量是文件隔离的。
    • 如果需要跨文件信息(极其少见且不推荐,因为破坏缓存),需要用到全局单例。
  • Babel:

    • 状态通过 state 参数在 Visitor 方法间传递。
    • state.file 包含当前文件的元数据。
    • state.opts 包含用户在 .babelrc 传入的插件配置。
    • 可以在 pre()post() 钩子中初始化和清理状态。
// Babel 状态管理示例
module.exports = {
  pre(state) {
    this.cache = new Map(); // 初始化文件级缓存
  },
  visitor: {
    Identifier(path, state) {
      // this.cache 在这里可用
    }
  },
  post(state) {
    // 清理
  }
};

3. 节点构造与替换

  • ESLint Fixer:

    • 基于文本索引(Index)。
    • API: replaceText(node, 'newText'), insertTextAfter(node, ';').
    • 非常脆弱。如果你删除了一个逗号,可能导致后续的代码语法错误。ESLint 会尝试多次运行 fix 直到不再有变动,但它不保证生成的代码 AST 结构正确,只保证文本替换。
  • Babel Types:

    • 基于对象构建
    • API: t.binaryExpression('+', t.numericLiteral(1), t.numericLiteral(2))
    • 非常健壮。只要符合 AST 规范,Babel Generator 就能生成合法的 JavaScript 代码(自动处理括号优先级、分号等)。

总结

  • 如果你的需求是 “阻止开发者提交烂代码” 或者 “统一团队的代码风格”,请选择 ESLint 插件。它的核心是 Context 和 Report。
  • 如果你的需求是 “减少重复的样板代码”“兼容低版本浏览器” 或者 “实现一种新的语法糖”,请选择 Babel 插件。它的核心是 Path、Visitor 和 Types Builder。

以上代码展示了从零构建一个架构级 ESLint 规则和一个编译级 Babel 转换插件的完整过程,涵盖了 AST 分析、上下文判断、节点构建和单元测试等核心环节。在实际工程中,这两者往往结合使用:Babel 负责把代码变样,ESLint 负责保证变样前的源码符合规范。

前端向架构突围系列模块化 [4 - 1]:思想-超越文件拆分的边界思维

写在前面

很多前端开发者对“模块化”的理解,长期停留在“文件拆分”的物理层面。

比如:一个 Vue/React 组件写了 1000 行,觉得太乱了,于是把里面的三个函数提取出来,扔到 utils.js 里;把 HTML 里的弹窗拆出来,扔到 components/Modal.vue 里。做完这些,看着只有 200 行的主文件,心里一阵舒爽:“啊,我做好了模块化。”

这是错觉。

如果你只是把一团乱麻的代码切成了五段乱麻,那这不叫模块化,这叫 “分布式屎山”

在架构师的眼里,模块化不是为了把文件变小,而是为了治理复杂性。它是关于边界(Boundaries)内聚(Cohesion)耦合(Coupling) 的艺术。本篇我们将抛开具体的语法,探讨如何建立架构级别的模块化思维。

image.png


一、 什么是模块?从“物理文件”到“逻辑单元”

初级工程师看模块,看到的是文件后缀(.js, .vue, .tsx);架构师看模块,看到的是职责的边界

1.1 模块化的三个层级

我们的认知通常经历了三个阶段的进化:

  1. 语法级模块化 (Syntax Level): 这是最基础的。AMD、CommonJS、ES Modules。解决的是命名空间污染脚本加载顺序的问题。这是 2015 年前我们要解决的主要矛盾,现在已经变成了像空气一样的基础设施。
  2. 文件级模块化 (File Level): 为了代码复用,我们将通用逻辑提取为 hooks,将 UI 提取为 components。这是目前绝大多数中级工程师所处的阶段。但如果不小心,很容易陷入 “为了拆分而拆分” 的陷阱。
  3. 领域级模块化 (Domain Level): 这是架构师关注的层面。一个模块不再是一个文件,而是一个业务能力的集合。 比如“用户模块”,它可能包含 UserCard.tsx(UI)、useUser.ts(逻辑)、user-service.ts(API)、UserType.ts(类型)。它们在物理上可能分散,但在逻辑上是一个整体。只有当这个整体对外暴露极其有限的接口,而隐藏内部所有复杂度时,它才是一个真正的模块。

1.2 架构师的视角:隐藏而非暴露

软件工程大师 David Parnas 早在 1972 年就提出了一个振聋发聩的观点:

“模块化的核心在于你隐藏了什么,而不是你暴露了什么。”

在前端开发中,我们经常犯的错误是暴露过多

  • 错误示范: 一个 DatePicker 组件,通过 props 把内部的 calendarInstance 暴露给父组件,允许父组件直接操作日历内部状态。
  • 架构灾难: 这意味着父组件和子组件形成了隐性耦合。一旦哪天你要把底层的日历库从 Moment.js 换成 Day.js,整个应用可能都会崩溃。

真正的模块化思维是“黑盒思维”: 外部只管输入(Props/Params)和输出(Events/Return Values),绝不关心内部是如何实现的。


二、 核心心法:高内聚与低耦合的辩证关系

这八个字被说烂了,但真正能做到的寥寥无几。在前端语境下,它们有具体的落地含义。

2.1 什么是“真内聚”?(True Cohesion)

很多项目习惯按“技术类型”分目录:

src/
  ├── components/  (放所有组件)
  ├── hooks/       (放所有钩子)
  ├── utils/       (放所有工具)
  ├── types/       (放所有类型)

这看起来很整洁,其实是 “假内聚” (或者叫偶然内聚)。 当你需要修改“登录”功能时,你需要去 components 改表单,去 hooks 改逻辑,去 types 改接口定义。你的修改行为是跨越空间的。

现代前端架构推崇的“真内聚”是按“功能特性(Feature)”组织:

src/
  ├── features/
  │   ├── auth/          (登录模块:包含自己的 components, hooks, types)
  │   ├── dashboard/     (大盘模块)

判定标准: 那些只有在一起工作才有意义的代码,必须物理上就在一起。共同封闭原则(CCP) 告诉我们:将那些会因为相同理由而修改的类/文件,聚合在一起。

2.2 什么是“低耦合”?(Loose Coupling)

耦合不可避免,没有耦合的代码就是一堆死代码。架构师要做的是治理耦合的类型

  • 内容耦合(最差): 直接修改另一个模块的内部数据。比如组件 A 通过 ref 强行修改组件 B 的 state。
  • 控制耦合(较差): 传递 flag 告诉另一个模块该怎么做。比如 Button 组件接收一个 isLoginButton 的 prop,导致 Button 内部包含了业务逻辑。
  • 数据耦合(推荐): 仅仅传递数据。组件只接收它需要渲染的数据,不关心数据来源。
  • 事件/消息耦合(最优): 通过发布订阅或回调函数通信。我不直接调用你,我只广播“我做完了”,谁关心谁就来处理。

架构师的刀法: 当你发现两个模块必须同时修改才能跑通时,它们就是强耦合的。要么把它们合并成一个模块,要么引入一个中间层(适配器)来解耦。


三、 边界思维:如何切分模块?

在拿到一个复杂的业务需求(比如一个在线协作文档编辑器)时,普通开发者的第一反应是画页面,而架构师的第一反应是划边界

3.1 不稳定的依赖要隔离

稳定依赖原则(SDP): 依赖关系应该指向更稳定的方向。

  • UI 是不稳定的:产品经理今天要把按钮放左边,明天要放右边,后天要换个颜色。
  • 业务逻辑是相对稳定的:文档的保存、协同算法、权限校验,这些核心逻辑不会轻易变。
  • 基础库是最稳定的:React 框架、Lodash 工具函数。

模块化切分策略: 绝不要把核心业务逻辑写在 UI 组件里(Vue 的 script 或 React 的 useEffect)。 Headless(无头化) 是前端架构的必然趋势。你应该把逻辑抽离成纯 JS/TS 模块(Hook 或 Class),UI 只是一个只有 render 函数的笨蛋壳子。这样,当 UI 翻天覆地变化时,你的核心逻辑模块可以纹丝不动。

3.2 循环依赖是架构的癌细胞

如果 A 模块引用了 B,B 又引用了 A,这在文件层面可能通过 Webpack 解决了,但在逻辑层面,这意味着 A 和 B 锁死在了一起,无法单独测试,无法单独复用。

如何打破循环?

  1. 下沉法: 找到 A 和 B 共同依赖的部分,抽取成 C 模块,A 和 B 都依赖 C。
  2. 反转法(依赖倒置 DIP): A 不直接依赖 B,A 定义一个接口(Interface),B 去实现这个接口。A 只依赖接口。

四、 模块化的代价:过度设计的陷阱

最后,必须给架构师们泼一盆冷水。模块化是有成本的。

模块化 = 增加间接层。

如果你把一个简单的“Hello World”拆成了 Provider、Service、Component、Type 四个文件,那你不是在做架构,你是在制造噪音

架构师的判断力体现在:

  • 识别变化点: 只有那些未来极有可能发生变化,或者复杂度极高的地方,才值得被封装成独立模块。
  • 适度冗余(DRY vs WET): 有时候,复制粘贴代码比错误的抽象更好。如果你强行把两个看似相似但业务背景完全不同的逻辑合并成一个模块,未来当它们向不同方向演进时,你将陷入无尽的 if (isModeA) else (isModeB) 的地狱。

结语:从“写代码”到“设计系统”

模块化不是一种技术,而是一种世界观

当你开始思考**“如果我删掉这行代码,影响的范围是多大” ,或者“如果我把这个文件夹移走,其他部分还能不能跑”**的时候,你就已经跨越了“文件拆分”的边界,开始用架构师的眼光审视你的系统了。

这只是思想的开篇。有了这个思维基石,接下来我们将深入骨架,探讨如何在具体的 UI 层面实现极致的逻辑与视图分离

Next Step: 思想已经建立,下一节我们将进入实战深水区。如何设计一个既能复用,又能灵活定制 UI 的组件? 请看**《第二篇:骨架(上)——组件化深度设计:逻辑与视图的极致分离(Headless UI)》**。

数据语义层 vs 宽表模式:哪种架构更适合 AI 时代的数据分析?

在 AI 驱动的数据分析时代,传统宽表模式因敏捷性不足、数据冗余和难以支持即席查询而力不从心。相比之下,NoETL 数据语义层(Semantic Layer)作为位于数据存储与应用间的抽象层,通过将物理数据映射为统一业务语义,实现了逻辑与物理解耦。对于需要快速响应变化、支持 AI 交互的场景,语义层架构是更具适应性的选择,能提供零等待的指标交付和 100% 一致的业务口径。

AI 时代下,传统宽表模式为何力不从心?

数据分析正从“预制品加工”转向“自助式厨房”。过去支撑报表的宽表模式,在 AI 驱动、即席查询的需求下暴露三大瓶颈:

  1. 敏捷性坍塌:业务变更需回溯修改 ETL、重跑宽表,响应周期长达数周。
  2. 数据一致性失控:多张口径各异的宽表导致“指标打架”,AI 模型基于此将产生不可靠洞察。
  3. 无法支持即席查询:宽表只能回答预设问题,无法响应跨域、临时的分析需求。

例如,周五下午,市场部需要新指标评估促销活动。数据团队告知需新建宽表,排期至下周三。决策时机已然错过。这种“响应迟滞”在 AI 时代是致命的。

什么是 NoETL 数据语义层(Semantic Layer)?

NoETL 数据语义层(Semantic Layer)是数据存储与数据应用间的关键抽象层,其核心功能是将复杂的技术数据结构映射为统一的业务术语和指标,充当数据的“业务翻译官”。其颠覆性源于三大技术理念:

  1. 解耦逻辑与物理:业务逻辑(如“销售额=价格×数量-折扣”)不再硬编码于 ETL,而是作为可复用定义存储于语义层。
  2. 统一业务语义:动态编织明细数据为统一的业务语义,确保全公司对“销售额”只有一个定义,实现“单一事实来源”。
  3. 实时查询下推:将“查看华东区销售额”的查询实时翻译、优化并下推至数据源执行,无需移动和预计算数据。

为什么它是 AI 时代的关键?

AI Agent 需要无歧义的上下文来准确生成 SQL。语义层提供了这份“业务词典”,为 AI 提供了稳定、可靠的数据接口,从根本上避免了因口径混乱导致的“AI 幻觉”。

Aloudata 如何基于语义层赋能 AI 驱动的分析?

作为国内数据语义编织(Semantic Fabric)领导者,Aloudata 方案的核心是:用 Aloudata CAN 自动化指标平台构建语义层,用 Aloudata Agent 分析决策智能体作为交互入口。

企业可以通过 Aloudata CAN 中连接数仓明细层,在可视化界面通过配置化的方式定义业务实体、维度和指标,构建语义模型,形成 NoETL 数据语义层,实现业务语义的标准化开发和管理,保障 100% 指标口径的一致性,避免 AI 问数的“幻觉”出现。

以 NoETL 数据语义层为底座,用户可以部署 Aloudata Agent,通过自然语言交互的方式直接提问:“上周新用户首单平均客单价?”Agent 基于语义层理解意图,通过 NL2MQL2SQL 的技术路径,先输出 MQL,再通过指标语义引擎生成 100% 准确的 SQL 语句并返回结果。

在这个过程中,用户零等待指标交付,逻辑变更分钟级生效,无需 ETL;100%一致口径,所有人与 AI 通过同一语义层访问数据;无缝对接 AI,语义层为 AI 提供标准化查询 API。

常见疑问回答(FAQ)

Q: 语义层架构的性能是否比宽表差?

不会。语义层采用智能查询下推与缓存,其优势在于在保证核心性能的同时,极大扩展了可即时响应的问题范围。

Q: 已建的宽表和数据仓库,是否要推倒重来?

不需要。语义层是增强层。Aloudata CAN 可直接连接现有数据资产,在其之上构建统一语义,保护投资的同时解锁新能力。

Q: 语义层如何保证数据安全与权限控制?

企业级产品(如 Aloudata CAN)提供行列级权限管控,并将规则与语义模型绑定。任何查询都会自动注入权限过滤,确保安全合规。

vue2+vue3 Table表格合并

之前在写表格合并的时候非常痛苦,弄不明白合并的具体逻辑,我这里直接贴上通用方法,只需要配置合并规则就可以了,在这里不扯那么多过程,你完全可以拷贝回去立马能用。

写给前端同学的 21 条职场教训

很多人以为在大厂工作,就是不停地写代码、解决技术难题。 但事实是:真正成功的工程师并不是那些代码写得最好的人,而是那些解决了代码以外事情的人。 本篇和你分享 21 条职场教训。 这些教训,有的能让你少

大文档也能丝滑流式渲染?试试Markstream-Vue,体验前所未有的Markdown流畅感!

为什么选择Markstream-Vue?只因它“流”得够快!

  • 🚀 真·流式渲染,支持超大文档、实时预览,边写边看不卡顿
  • 🏆 彻底解决传统Markdown组件渲染慢、卡顿、内存暴涨等痛点
  • 🧩 组件化设计,Vue 3项目即插即用,API极简
  • 🎨 支持代码高亮、公式、流程图等丰富内容,体验无缝流畅
  • 🔥 SSR/静态站点/移动端全兼容,性能拉满

真实场景,极致体验

  • 技术文档、知识库、长篇小说、实时协作编辑器……再大的内容都能流畅渲染
  • 支持内容“边加载边渲染”,让用户体验“所见即所得”的极致流畅

3步上手,流式体验立享

  1. 安装依赖
pnpm add markstream-vue
  1. 引入组件
<Markstream :source="longMarkdown" stream />
  1. 享受流式渲染的丝滑体验!

你的Star,是我持续优化的最大动力!

👉 GitHub地址


一杯茶时间,带你用 RWKV 并发模型做 VS Code 多候选代码补全插件 🤔🤔🤔

在写这份实现教程之前,我已经把该插件的一个版本发布到了 VS Code 扩展市场,在市场中搜索 rwkv 即可找到,你可以先安装试用,再决定是否跟着下文从零实现一版。

本文以这款基于 RWKV 模型的智能代码补全插件为例,讲解从零实现 VS Code 扩展的思路与步骤,并说明如何接入 rwkv_lightning 后端。

该插件通过并发推理一次返回多个不同的补全答案供选择,在侧边栏展示,方便在多种写法之间对比、挑选后再插入,适合写一半、让模型多想几种实现的编码方式;光标后有代码时自动走 FIM(Fill-in-the-Middle)接口做中间填充,否则走普通续写。全文按功能目标、代码实现(项目结构、补全触发、API 调用、Webview 展示与插入)、后端接入组织,后端部分包含硬件要求、模型准备、与 Albatross 的关系、启动服务、模型加载机制、HTTP API、快速测试以及插件配置与验证,文末附常见问题。

下图为在编辑器中触发补全后,并发推理得到的多个不同答案在侧边栏展示、点击即可插入到光标位置的情形。

rwkv-code-completion 效果

前端项目地址:rwkv-code-completion

后端项目地址:rwkv_lightning

一、我们要做怎样的功能

动手写代码之前,首先要考虑我们要实现一个什么样的 VS Code 插件,这决定了后续的架构与实现方式。

在本例中,我们想做一款智能代码补全插件,并事先想清楚四件事。补全结果通过并发推理一次返回多个不同的答案,在侧边栏展示供用户选择,点选后插入。根据光标后是否已有代码,在 FIM(Fill-in-the-Middle)与普通续写之间自动切换接口。在空格、换行、删除等操作时自动触发,并做好防抖与取消,避免频繁请求。服务地址、密码、生成长度、采样参数(temperaturetop_p)、候选数量、防抖延迟等通过 VS Code 设置暴露。把这四件事的对应关系梳理出来,大致如下:

20260121175047

把这些想清楚之后,再按代码实现过程和如何接入后端两部分往下做。

二、代码实现过程

2.1 项目结构

yo code 或手工 scaffold 一个扩展后,核心只需两个源码文件,职责分开,与 VS Code 打交道的放一边,与后端 HTTP 打交道的放另一边,方便维护和单测。

  • src/extension.ts 作为插件入口,在 activate 里实现 CompletionItemProvider、注册补全、用 onDidChangeTextDocument 监听编辑并按条件触发补全;拿到候选列表后,不再往原生 suggest 里塞,而是创建 Webview、渲染多条结果,并处理用户点击插入与插完再补全。
  • src/completionService.ts 负责补全服务,根据有无 suffix 选择调用普通续写接口或 FIM 接口,组装请求体、发 fetch、解析 data.choicesstring[],并透传 AbortSignal 以支持取消。

两者与后端的关系可以概括为:

20260121175344

package.json 里,main 指向打包后的入口(如 ./dist/extension.js),VS Code 按它加载扩展;activationEvents 可设为 onStartupFinished,这样只在 IDE 就绪后才激活,避免启动时卡顿;contributes.configuration 声明 enabledbaseUrlpasswordmaxTokenstemperaturetopPnumChoicesdebounceDelay 等,用户改设置后可通过 vscode.workspace.getConfiguration("rwkv-code-completion") 读到。

构建可用 esbuild 或 tsc,把 extension.ts 等打出到 dist,调试和发布都从 dist 走。

2.2 激活与补全触发

激活时在 activate(context) 里完成两件事,一是向 VS Code 注册谁在什么情况下提供补全,二是监听文档变更,在特定编辑动作后自动调出补全,用户不必每次手动按 Ctrl+Space。

实现 vscode.CompletionItemProviderprovideCompletionItems(document, position, token, context),再用 vscode.languages.registerCompletionItemProvider 挂上去。selector{ pattern: "**" } 表示对所有语言生效;第三参数 triggerChars 是一串字符,当用户输入或删除其中某一个时,VS Code 会来调 provideCompletionItems。这里把空格、换行以及 ASCII 33–126(常见可打印字符)都放进去了,这样在写代码、加空格、换行时都有机会触发,例如:

const selector = { pattern: "**" };
const triggerChars = [
  " ",
  "\n",
  ...Array.from({ length: 94 }, (_, i) => String.fromCharCode(i + 33)),
];
vscode.languages.registerCompletionItemProvider(
  selector,
  provider,
  ...triggerChars,
);

光有 triggerChars 还不够,例如用户输入 abc 时也会触发,容易导致敲一个字母就发一次请求。因此再加一层文档变更的过滤,用 vscode.workspace.onDidChangeTextDocument 监听,只有在本次编辑是删除、换行或输入一个空格时,才在防抖后执行 editor.action.triggerSuggest,从而间接调用 provideCompletionItems。这样可以把触发收敛到更自然的断句、换行场景,例如:

const shouldTrigger = event.contentChanges.some((change) => {
  const isDelete = change.rangeLength > 0 && change.text === "";
  const isNewline = change.text === "\n" || change.text === "\r\n";
  const isSpace = change.text === " ";
  return isDelete || isNewline || isSpace;
});
if (shouldTrigger) {
  debounceTimer = setTimeout(() => {
    vscode.commands.executeCommand("editor.action.triggerSuggest");
  }, config.debounceDelay);
}

防抖时间用 config.debounceDelay(如 150–300ms),用户停一会儿才发请求,减少连打时的无效调用。还可以加两条限制,一是只处理当前活动编辑器的文档,避免在切文件、分屏时误触发,二是与上一次触发至少间隔几百毫秒,进一步避免短时间内重复弹补全。整体触发链路如下:

20260121175403

2.3 补全逻辑与 API 调用

provideCompletionItems 被调用后,先做一轮要不要真的发请求的过滤和节流,再取上下文、调后端、拿 string[]

流程可以拆成五步。一,读配置,若 enabled 为 false 直接 return null。二,防抖,用 setTimeout(..., debounceDelay) 把实际请求放到回调里;若在等待期间又有新的触发,则 clearTimeout 掉上一次,只保留最后一次,这样连续输入时只会发一次请求。三,若此前已有进行中的 fetch,用 AbortController.abort() 取消,再 new AbortController() 给本次请求用。四,取上下文,前缀 prefix 为从文档开头到光标前的文本,document.getText(new vscode.Range(0, 0, position)),过长时截断到约 2000 字符,避免超过后端限制;后缀 suffix 为从光标到往后若干行(如 10 行),主要用来判断光标后是否还有代码,从而决定走 FIM 还是普通续写。五,调用 CompletionService.getCompletion(prefix, suffix, languageId, config, abortController.signal),在 withProgress 里展示正在生成 N 个补全并可取消。五步关系如下:

20260121175421

CompletionService.getCompletion 内部按 suffix 是否非空分支,有后缀则认为用户在中间写代码,走 FIM,否则走普通续写。接口选择如下:

20260121175704

例如下面这样。

async getCompletion(prefix, suffix, languageId, config, signal): Promise<string[]> {
  const hasSuffix = suffix && suffix.trim().length > 0;
  return hasSuffix
    ? this.callFIMAPI(prefix, suffix, config, signal)
    : this.callCompletionAPI(prefix, config, signal);
}

普通补全走 callCompletionAPI,请求 POST {baseUrl}/v2/chat/completions。body 里 contentsArray(numChoices).fill(prefix),即同一段 prefix 复制多份,利用后端批量接口一次推理出多条不同采样结果;再配上 stream: falsepasswordmax_tokenstemperaturetop_pstop_tokens 等。返回的 data.choices 里,每条取 choice.message?.content || choice.text,trim 掉首尾空白并滤掉空串,得到 string[]

FIM 补全走 callFIMAPI,请求 POST {baseUrl}/FIM/v1/batch-FIMprefixsuffix 各为长度为 4 的数组(同一 prefix、同一 suffix 各复制 4 份),对应 4 条并发中间填充;其它参数与普通补全类似,解析方式相同。两处都把 signal 传给 fetch,这样在用户点击取消、或防抖导致下一次触发而 abort() 时,正在进行的请求会被中断,不把过时结果再展示出来。

2.4 Webview 展示与插入

拿到 string[] 之后,不转成 CompletionItem[] 通过 resolve(items) 塞给原生 suggest,因为原生列表单条、偏短,且没法做多列、点击选一等自定义交互。这里改为 resolve(null) 表示不往建议列表里填,同时在 withProgress 里调 showCompletionWebview(document, position, completions, languageId),用 Webview 在侧边栏展示多条候选,支持多选一、点即插、插完再补。

vscode.window.createWebviewPanel 创建 Webview,指定 id、标题、ViewColumn.Two 在侧边打开,以及 enableScripts: trueretainContextWhenHidden: true 以便跑脚本和在切走时保留状态。panel.webview.htmlgetWebviewContent(completions, languageId) 生成。在打开面板之前,必须把当时的 documentposition 存到闭包或变量里,因为 Webview 是异步的,用户可能切文件、移光标,等到点击插入时要以当初触发补全的那次位置为准,否则会插错地方。

const panel = vscode.window.createWebviewPanel(
  "rwkvCompletion",
  "RWKV 代码补全 (N 个选项)",
  vscode.ViewColumn.Two,
  { enableScripts: true, retainContextWhenHidden: true },
);
panel.webview.html = getWebviewContent(completions, languageId);

HTML 里顶部放标题与简短说明,下面一个 div 容器,用 grid-template-columns: 1fr 1fr 做多列布局,每个格子一个 div.code-block,含小标题(序号、字符数、行数)和 <pre><code> 放补全内容。补全文本要先做 HTML 转义再插入,避免 XSS;颜色、背景用 var(--vscode-editor-background) 等,跟主题一致;:hover.selected 给一点高亮,点的时候有反馈。

前端通过 acquireVsCodeApi() 拿到和扩展通信的 API,completionsgetWebviewContent 里用 JSON 注入到页面。每个 code-block 点击时执行 vscode.postMessage({ command: 'insert', code: completions[index] })。扩展侧在 panel.webview.onDidReceiveMessage 里监听,若 message.command === 'insert',先 vscode.window.showTextDocument(targetDocument, ViewColumn.One) 把原文档激活到主编辑区,再用 editor.edit(eb => eb.insert(targetPosition, message.code)) 在事先存好的 targetPosition 插入;插入成功后 panel.dispose() 关掉 Webview,并 setTimeout(..., 300) 后执行 editor.action.triggerSuggest,让光标后的新内容再触发一轮补全,形成补全、选一、再补全的连贯体验。从拿到结果到插入再触发的流程如下:

20260121175751

原生 suggest 只能一条条、样式固定,没法同时展示多条并发结果和自定义交互;用 Webview 可以自己布局、自己处理点击和插入,更适合并发推理、多答案选一的用法。

三、如何接入后端

插件通过 HTTP 调用 rwkv_lightning,需要先部署后端,再在 VS Code 里填好配置。扩展详情页会标注后端部署与配置说明,便于快速上手,下图为扩展市场中的页面示意。

RWKV 代码补全 - 扩展市场页面

接入后端的整体步骤如下。

20260121175818

3.1 硬件要求

重要提示:本后端必须使用 GPU 加速,不支持纯 CPU 运行。

rwkv_lightning 依赖自定义的 CUDA 或 HIP 内核进行高性能推理,因此需要以下硬件之一:

  • NVIDIA GPU:需要支持 CUDA 的 NVIDIA 显卡,并安装 CUDA 工具包
  • AMD GPU:需要支持 ROCm 的 AMD 显卡,并安装 ROCm 运行时

如果您只有 CPU 环境,请使用 llama.cpp 进行 RWKV 模型的 CPU 推理,该项目针对 CPU 进行了专门优化。

3.2 模型文件准备

rwkv_lightning 当前不提供自动下载功能,需要您自行准备模型权重文件。

下载模型权重

RWKV-7 模型的官方权重托管在 Hugging Face 上,推荐从 BlinkDL/rwkv7-g1 仓库下载。模型文件格式为 .pth,例如 rwkv7-g1b-1.5b-20251202-ctx8192.pth

您可以通过以下方式下载:

方式一:使用 huggingface-cli(推荐)

# 首先登录 Hugging Face(如未登录)
huggingface-cli login

# 下载模型文件
huggingface-cli download BlinkDL/rwkv7-g1 \
  rwkv7-g1b-1.5b-20251202-ctx8192.pth \
  --local-dir /path/to/models \
  --local-dir-use-symlinks False

方式二:使用 Python 脚本

from huggingface_hub import hf_hub_download

model_path = hf_hub_download(
    repo_id="BlinkDL/rwkv7-g1",
    filename="rwkv7-g1b-1.5b-20251202-ctx8192.pth",
    local_dir="/path/to/models"
)
print(f"模型已下载到: {model_path}")

路径命名规则

启动服务时,--model-path 支持两种写法。写法一:不带后缀,程序会自动补上 .pth,例如:

--model-path /path/to/rwkv7-g1b-1.5b-20251202-ctx8192
# 实际加载: /path/to/rwkv7-g1b-1.5b-20251202-ctx8192.pth

3.3 与 Albatross 的关系

rwkv_lightning 是基于 Albatross 高效推理引擎开发的 HTTP 服务后端。Albatross 是 BlinkDL 开发的高性能 RWKV 推理引擎,专注于底层计算优化和性能基准测试。

Albatross 项目简介

Albatross 是一个独立的开源项目,GitHub 地址:github.com/BlinkDL/Alb… RWKV-7 模型的高效推理实现,包括:

  • 批量推理支持:支持大规模批量处理,在 RTX 5090 上可实现 7B 模型 fp16 bsz960 超过 10000 token/s 的解码速度
  • 性能优化:集成了 CUDA Graph、稀疏 FFN、自定义 CUDA 内核等优化技术
  • 基准测试工具:提供详细的性能基准测试脚本,用于评估不同配置下的推理性能
  • 参考实现:包含完整的模型实现和工具类,可作为开发参考

性能参考数据

根据 Albatross 官方测试结果(RTX 5090,RWKV-7 7.2B fp16):

  • 单样本解码(bsz=1):145+ token/s,使用 CUDA Graph 优化后可达 123+ token/s
  • 批量解码(bsz=960):10250+ token/s
  • Prefill 阶段(bsz=1):11289 token/s
  • 批量解码(bsz=320):5848 token/s,速度恒定且显存占用稳定(RNN 特性)

rwkv_lightning 的定位

rwkv_lightning 在 Albatross 的基础上,专注于提供生产级的 HTTP 推理服务:

  • HTTP API 接口:提供完整的 RESTful API,支持流式和非流式推理
  • 状态管理:实现三级缓存系统(VRAM、RAM、Disk),支持会话状态持久化
  • 连续批处理:动态管理批次,提高 GPU 利用率
  • 多接口支持:提供聊天、翻译、代码补全等多种应用场景的专用接口

如果您需要深入了解底层实现细节、进行性能调优或对比不同优化方案,建议参考 Albatross 项目的源代码和基准测试脚本。Albatross 提供了更底层的实现细节,而 rwkv_lightning 则专注于提供易用的服务化接口。

3.4 启动推理服务

rwkv_lightning 以 Robyn 版本为主,提供密码认证、多接口、状态管理等特性,适合生产环境使用。Robyn 版本功能更全面,支持密码认证、多接口、状态管理等高级特性,适合生产环境使用。

python main_robyn.py --model-path /path/to/model --port 8000 --password rwkv7_7.2b

如果不需要密码保护,可以省略 --password 参数:

python main_robyn.py --model-path /path/to/model --port 8000

3.5 模型加载机制

了解模型加载机制有助于排查问题和优化性能。

权重加载流程

模型类 RWKV_x070 在初始化时会执行以下步骤:

  1. 读取权重文件:使用 torch.load(args.MODEL_NAME + '.pth', map_location='cpu') 将权重加载到 CPU 内存
  2. 数据类型转换:将权重转换为半精度(dtype=torch.half)以节省显存
  3. 设备迁移:根据硬件平台将权重移动到 GPU
    • NVIDIA GPU:使用 device="cuda"
    • AMD GPU:使用 ROCm 的 HIP 运行时

词表加载

词表文件 rwkv_batch/rwkv_vocab_v20230424.txt 通过 TRIE_TOKENIZER 类自动加载。TRIE 数据结构提供了高效的 token 查找和编码、解码功能。

CUDA、HIP 内核编译

项目包含自定义的 CUDA(NVIDIA)和 HIP(AMD)内核,用于加速 RWKV 的核心计算。这些内核在首次导入 rwkv_batch.rwkv7 模块时通过 torch.utils.cpp_extension.load 自动编译和加载:

  • CUDA 内核:rwkv_batch/cuda/rwkv7_state_fwd_fp16.cu
  • HIP 内核:rwkv_batch/hip/rwkv7_state_fwd_fp16.hip

首次运行时会进行编译,可能需要几分钟时间。编译后的内核会被缓存,后续启动会更快。

3.6 HTTP API 接口

rwkv_lightning 提供了丰富的 HTTP API 接口,支持多种推理场景。

聊天完成接口

  • v1/chat/completions:基础批量同步处理接口,支持流式和非流式输出。
  • v2/chat/completions:连续批处理接口,动态管理批次以提高 GPU 利用率,适合高并发场景。
  • v3/chat/completions:异步批处理接口,使用 CUDA Graph 优化(batch_size=1 时),提供最低延迟。

Fill-in-the-Middle 接口

FIM/v1/batch-FIM:支持代码和文本的中间填充补全,适用于代码补全、文本编辑等场景。

批量翻译接口

translate/v1/batch-translate:批量翻译接口,兼容沉浸式翻译插件的 API 格式,支持多语言互译。

会话状态管理接口

state/chat/completions:支持会话状态缓存的对话接口,实现多轮对话的上下文保持。状态采用三级缓存设计:

  • L1 缓存:VRAM(显存),最快访问
  • L2 缓存:RAM(内存),中等速度
  • L3 缓存:SQLite 数据库(磁盘),持久化存储

流式推理示例

以下示例展示如何使用 v2 接口进行批量流式推理:

curl -N -X POST http://localhost:8000/v2/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "contents": [
      "English: After a blissful two weeks, Jane encounters Rochester in the gardens.\n\nChinese:",
      "English: That night, a bolt of lightning splits the same chestnut tree.\n\nChinese:"
    ],
    "max_tokens": 1024,
    "stop_tokens": [0, 261, 24281],
    "temperature": 0.8,
    "top_k": 50,
    "top_p": 0.6,
    "alpha_presence": 1.0,
    "alpha_frequency": 0.1,
    "alpha_decay": 0.99,
    "stream": true,
    "chunk_size": 128,
    "password": "rwkv7_7.2b"
  }'

3.7 快速测试与性能评估

快速测试

项目提供了测试脚本,可以快速验证服务是否正常运行:

bash ./test_curl.sh

该脚本会发送示例请求到本地服务,检查各个接口的基本功能。

性能基准测试

使用 benchmark.py 脚本可以评估模型的推理性能,包括吞吐量、延迟等指标:

# 需要先修改 benchmark.py 中的模型路径
python benchmark.py

基准测试会输出详细的性能报告,帮助您了解模型在不同配置下的表现。

3.8 插件配置

在 VS Code 中打开设置(可搜索 rwkv-code-completion 或执行命令 RWKV: 打开设置),重点配置:

配置项 说明 示例
rwkv-code-completion.enabled 是否启用补全 true
rwkv-code-completion.baseUrl 后端基础地址,不含路径 http://192.168.0.157:8000http://localhost:8000
rwkv-code-completion.password --password 一致 rwkv7_7.2b
rwkv-code-completion.maxTokens 单次生成最大 token 数 200
rwkv-code-completion.numChoices 普通补全的候选数量(1–50) 24
rwkv-code-completion.debounceDelay 防抖延迟(毫秒) 150300

baseUrl 只需填 http(s)://host:port,插件内部会拼上 /v2/chat/completions/FIM/v1/batch-FIM。若设置界面中仅有 endpoint 等项,可在 settings.json 中手动添加 "rwkv-code-completion.baseUrl": "http://<主机>:<端口>"

3.9 验证接入

可先用 curl -X POST http://<host>:<port>/v2/chat/completions -H "Content-Type: application/json" -d '{"contents":["你好"],"max_tokens":10,"password":"<你的password>"}' 或运行 ./test_curl.sh 确认 v2 与 FIM 接口正常。在任意代码文件中输入、换行或删除,防抖后应出现「🤖 RWKV 正在生成 N 个代码补全...」并弹出侧边栏展示多个候选;若失败,可查看「输出」中该扩展的 channel 或弹窗报错,检查 baseUrlpassword、端口与防火墙。


四、常见问题

为何不能在 CPU 上运行?

rwkv_lightning 的核心计算依赖自定义的 CUDA、HIP 内核,这些内核专门为 GPU 并行计算设计。CPU 无法执行这些内核代码,因此必须使用 GPU。如果您需要在 CPU 上运行 RWKV 模型,请使用 llama.cpp,它提供了针对 CPU 优化的实现。

模型权重应该放在哪里?

模型权重可以放在任何可访问的路径。启动服务时通过 --model-path 参数指定路径即可。路径可以是绝对路径或相对路径,程序会自动处理 .pth 后缀的添加。

首次启动为什么很慢?

首次启动时会编译 CUDA、HIP 内核,这个过程可能需要几分钟。编译后的内核会被缓存,后续启动会快很多。如果希望进一步优化性能,可以考虑使用 torch.compile 模式(详见 README.md 中的 Tips 部分)。

如何选择合适的接口?

  • v1:适合简单的批量推理需求
  • v2:适合高并发场景,需要动态批处理
  • v3:适合单请求低延迟场景(batch_size=1)
  • FIM:适合代码补全和文本编辑
  • state:适合需要保持上下文的对话场景

本插件已按「无 suffix 用 v2、有 suffix 用 FIM」自动选择。

如何实现自动下载模型?

当前版本不提供内置的自动下载功能。您可以在启动脚本中添加下载逻辑,使用 huggingface_hub 库在启动前检查并下载模型文件。

主Agent与多个协同子Agent的方案设计

前言

如今的大模型应用架构设计基本都是一个主Agent携带多个子Agent。

主Agent负责调度其他垂类Agent,子Agent负责单一领域的角色,属于垂直域专家。

架构上比较类似这样:

┌─────────────────────────────────────────────────────────┐
│                    主 Agent(Orchestrator)              │
│  职责:理解用户意图、分解任务、协调子 Agent、聚合结果   │
└──────────────────────┬──────────────────────────────────┘
                       │
        ┌──────────────┼──────────────┬──────────────┐
        │              │              │              │
        ▼              ▼              ▼              ▼
   ┌────────┐    ┌────────┐    ┌────────┐    ┌────────┐
   │差旅Agent│   │日程Agent│   │支付Agent│   │通知Agent│
   │(Travel)│   │(Calendar)│  │(Payment)│  │(Alert) │
   └────────┘    └────────┘    └────────┘    └────────┘
        │              │              │              │
        └──────────────┴──────────────┴──────────────┘
                       │
        ┌──────────────┼──────────────┐
        │              │              │
        ▼              ▼              ▼
   数据库           API 服务        外部服务
   (DB)          (Flights,        (Payment,
                  Hotels,          Email,
                 Trains)          SMS)

那一个基本的LLM应用框架一般怎么设计?本文基于Midwayjs来解读分析。

Agent&提示词设计

基类Agent

所有Agent都集成于该类,核心触发如下能力。

  1. 上下文管理;
  2. 大模型调用;
  3. 提示词注入;
// src/agent/base-agent.ts
import { Logger } from "@midwayjs/core"
import { LLMService } from "@/service/llm.service"

interface Message {
  role: "system" | "user" | "assistant"
  content: string
}

interface ToolCall {
  name: string
  arguments: Record<string, any>
  id?: string
}

/**
 * Agent 基类
 * 所有 Agent 都继承这个基类
 */
export abstract class BaseAgent {
  @Logger()
  logger: any

  protected llmService: LLMService
  protected conversationHistory: Message[] = []

  constructor(llmService: LLMService) {
    this.llmService = llmService
  }

  /**
   * 初始化 Agent
   * 1. 设置系统提示词
   * 2. 注入工具定义
   * 3. 初始化对话历史
   */
  protected initializeAgent(
    systemPrompt: string,
    tools: any[]
  ): void {
    this.logger.info(`[${this.getAgentName()}] 初始化 Agent`)

    // Step 1: 清空历史对话
    this.conversationHistory = []

    // Step 2: 添加系统提示词
    const enrichedSystemPrompt = this.enrichSystemPrompt(
      systemPrompt,
      tools
    )

    this.conversationHistory.push({
      role: "system",
      content: enrichedSystemPrompt,
    })

    this.logger.info(
      `[${this.getAgentName()}] Agent 初始化完成,已注入 ${tools.length} 个工具`
    )
  }

  /**
   * 增强系统提示词(注入工具定义)
   */
  private enrichSystemPrompt(systemPrompt: string, tools: any[]): string {
    const toolDescriptions = tools
      .map(
        (tool) => `
### 工具:${tool.name}
描述:${tool.description}
参数:${JSON.stringify(tool.parameters, null, 2)}
`
      )
      .join("\n")

    return `
${systemPrompt}

## 可用的工具

${toolDescriptions}

## 工具调用格式

当你需要使用工具时,请返回以下 JSON 格式:
\`\`\`json
{
  "type": "tool_call",
  "tool_name": "工具名称",
  "arguments": {
    "参数1": "值1",
    "参数2": "值2"
  }
}
\`\`\`

重要:
1. 每次只调用一个工具
2. 工具会返回结果,你会收到 "tool_result" 角色的消息
3. 根据工具结果继续推理和决策
4. 最终向用户返回友好的文字回复
`
  }

  /**
   * 与大模型交互(核心方法)
   */
  async callLLM(userMessage: string): Promise<string> {
    this.logger.info(
      `[${this.getAgentName()}] 用户消息: ${userMessage}`
    )

    // 1. 添加用户消息到历史
    this.conversationHistory.push({
      role: "user",
      content: userMessage,
    })

    // 2. 调用大模型
    let response = await this.llmService.call({
      model: "gpt-4",
      messages: this.conversationHistory,
      temperature: 0.7,
      maxTokens: 2000,
    })

    this.logger.info(
      `[${this.getAgentName()}] 模型响应: ${response.content.substring(0, 100)}...`
    )

    // 3. 检查是否是工具调用
    let finalResponse = response.content
    let toolCalls = this.extractToolCalls(response.content)

    // 4. 如果有工具调用,递归执行直到没有工具调用
    while (toolCalls.length > 0) {
      this.logger.info(
        `[${this.getAgentName()}] 检测到工具调用: ${toolCalls.map((t) => t.name).join(", ")}`
      )

      // 添加助手的响应到历史
      this.conversationHistory.push({
        role: "assistant",
        content: response.content,
      })

      // 执行所有工具调用
      const toolResults = await Promise.all(
        toolCalls.map((call) =>
          this.executeTool(call.name, call.arguments)
        )
      )

      // 5. 将工具结果添加到历史
      const toolResultMessage = toolResults
        .map(
          (result, index) => `
[工具结果 ${index + 1}]
工具:${toolCalls[index].name}
参数:${JSON.stringify(toolCalls[index].arguments)}
结果:${JSON.stringify(result, null, 2)}
`
        )
        .join("\n")

      this.conversationHistory.push({
        role: "user",
        content: `工具执行结果:\n${toolResultMessage}`,
      })

      this.logger.info(
        `[${this.getAgentName()}] 工具执行完成,继续推理...`
      )

      // 6. 再次调用大模型,让它基于工具结果继续推理
      response = await this.llmService.call({
        model: "gpt-4",
        messages: this.conversationHistory,
        temperature: 0.7,
        maxTokens: 2000,
      })

      this.logger.info(
        `[${this.getAgentName()}] 后续模型响应: ${response.content.substring(0, 100)}...`
      )

      // 7. 再次检查是否有工具调用
      toolCalls = this.extractToolCalls(response.content)
      finalResponse = response.content
    }

    // 8. 添加最终回复到历史
    this.conversationHistory.push({
      role: "assistant",
      content: finalResponse,
    })

    return finalResponse
  }

  /**
   * 提取工具调用(从模型响应中)
   */
  private extractToolCalls(content: string): ToolCall[] {
    const toolCalls: ToolCall[] = []

    // 匹配 JSON 格式的工具调用
    const jsonMatches = content.match(/```json\n([\s\S]*?)\n```/g)

    if (jsonMatches) {
      jsonMatches.forEach((match) => {
        try {
          const json = match.replace(/```json\n/g, "").replace(/\n```/g, "")
          const parsed = JSON.parse(json)

          if (parsed.type === "tool_call") {
            toolCalls.push({
              name: parsed.tool_name,
              arguments: parsed.arguments,
            })
          }
        } catch (error) {
          this.logger.warn(`[${this.getAgentName()}] 无法解析 JSON: ${match}`)
        }
      })
    }

    return toolCalls
  }

  /**
   * 执行工具(由子类实现)
   */
  protected abstract executeTool(
    toolName: string,
    arguments: Record<string, any>
  ): Promise<any>

  /**
   * 获取 Agent 名称
   */
  protected abstract getAgentName(): string
}

工具定义&设计

工具定义核心是基于约定式的配置体,来提供给大模型。

这些工具可以是mcp,可以是function call,在工具中增加type即可扩展。

// src/tools/travel-tools.ts

/**
 * 差旅工具定义
 * 这些工具会被注入到 Agent 的提示词中
 */
export const TRAVEL_TOOLS = [
  {
    name: "search_flights",
    description: "搜索机票,返回可用的航班列表",
    parameters: {
      type: "object",
      properties: {
        from: {
          type: "string",
          description: "出发城市(如:北京、上海)",
        },
        to: {
          type: "string",
          description: "目的城市",
        },
        date: {
          type: "string",
          description: "出发日期(格式:YYYY-MM-DD)",
        },
        return_date: {
          type: "string",
          description: "返回日期(可选,格式:YYYY-MM-DD)",
        },
      },
      required: ["from", "to", "date"],
    },
  },
  {
    name: "search_hotels",
    description: "搜索酒店,返回可用的酒店列表",
    parameters: {
      type: "object",
      properties: {
        city: {
          type: "string",
          description: "目的城市",
        },
        check_in: {
          type: "string",
          description: "入住日期(格式:YYYY-MM-DD)",
        },
        check_out: {
          type: "string",
          description: "退房日期(格式:YYYY-MM-DD)",
        },
        max_price: {
          type: "number",
          description: "最高价格(可选,单位:元)",
        },
      },
      required: ["city", "check_in", "check_out"],
    },
  },
  {
    name: "book_trip",
    description: "预订机票和酒店,返回订单号",
    parameters: {
      type: "object",
      properties: {
        flight_id: {
          type: "string",
          description: "航班 ID",
        },
        hotel_id: {
          type: "string",
          description: "酒店 ID",
        },
        passengers: {
          type: "number",
          description: "乘客人数",
        },
      },
      required: ["flight_id", "hotel_id"],
    },
  },
  {
    name: "get_trip_details",
    description: "获取已预订差旅的详细信息",
    parameters: {
      type: "object",
      properties: {
        trip_id: {
          type: "string",
          description: "订单号",
        },
      },
      required: ["trip_id"],
    },
  },
  {
    name: "cancel_trip",
    description: "取消已预订的差旅",
    parameters: {
      type: "object",
      properties: {
        trip_id: {
          type: "string",
          description: "订单号",
        },
        reason: {
          type: "string",
          description: "取消原因(可选)",
        },
      },
      required: ["trip_id"],
    },
  },
]

export const CALENDAR_TOOLS = [
  {
    name: "add_calendar_event",
    description: "添加日历事件",
    parameters: {
      type: "object",
      properties: {
        title: {
          type: "string",
          description: "事件标题",
        },
        start_date: {
          type: "string",
          description: "开始时间(格式:YYYY-MM-DD HH:mm)",
        },
        end_date: {
          type: "string",
          description: "结束时间(格式:YYYY-MM-DD HH:mm)",
        },
        description: {
          type: "string",
          description: "事件描述",
        },
      },
      required: ["title", "start_date", "end_date"],
    },
  },
  {
    name: "get_calendar_events",
    description: "查询特定日期的日历事件",
    parameters: {
      type: "object",
      properties: {
        date: {
          type: "string",
          description: "查询日期(格式:YYYY-MM-DD)",
        },
      },
      required: ["date"],
    },
  },
]

export const PAYMENT_TOOLS = [
  {
    name: "process_payment",
    description: "处理支付请求",
    parameters: {
      type: "object",
      properties: {
        order_id: {
          type: "string",
          description: "订单号",
        },
        amount: {
          type: "number",
          description: "金额(单位:元)",
        },
        payment_method: {
          type: "string",
          enum: ["credit_card", "debit_card", "wechat", "alipay"],
          description: "支付方式",
        },
      },
      required: ["order_id", "amount", "payment_method"],
    },
  },
]

export const ALERT_TOOLS = [
  {
    name: "send_notification",
    description: "发送通知给用户",
    parameters: {
      type: "object",
      properties: {
        title: {
          type: "string",
          description: "通知标题",
        },
        content: {
          type: "string",
          description: "通知内容",
        },
        channels: {
          type: "array",
          items: { type: "string", enum: ["email", "sms", "app"] },
          description: "通知渠道",
        },
      },
      required: ["title", "content", "channels"],
    },
  },
]

MCP设计

Agent基于多个Mcp能力的提供从而实现更垂直的领域能力。

因此Mcp也可以单独设计出来。

// src/mcp/types.ts

/**
 * MCP 工具定义
 */
export interface MCPTool {
  name: string
  description: string
  inputSchema: {
    type: "object"
    properties: Record<string, any>
    required: string[]
  }
}

/**
 * MCP 资源定义
 */
export interface MCPResource {
  uri: string
  name: string
  description: string
  mimeType: string
  contents: string
}

/**
 * MCP 提示词定义
 */
export interface MCPPrompt {
  name: string
  description: string
  arguments?: Array<{
    name: string
    description: string
    required?: boolean
  }>
}

/**
 * MCP 工具调用请求
 */
export interface MCPToolCallRequest {
  toolName: string
  arguments: Record<string, any>
}

/**
 * MCP 工具执行结果
 */
export interface MCPToolResult {
  success: boolean
  data?: any
  error?: string
}

/**
 * MCP 服务器接口
 */
export interface IMCPServer {
  // 获取服务器信息
  getServerInfo(): Promise<{
    name: string
    version: string
    capabilities: string[]
  }>

  // 列出所有可用工具
  listTools(): Promise<MCPTool[]>

  // 执行工具
  callTool(request: MCPToolCallRequest): Promise<MCPToolResult>

  // 列出所有可用资源
  listResources(): Promise<MCPResource[]>

  // 获取资源内容
  getResource(uri: string): Promise<MCPResource>

  // 列出所有可用提示词
  listPrompts(): Promise<MCPPrompt[]>

  // 获取提示词内容
  getPrompt(name: string, arguments?: Record<string, string>): Promise<string>
}

有了AgentMcp,本质上完整的一次自然语言对话 -> 反馈的系统流转图就很清晰了。

基于这套框架来扩展即可。

一次完整对话到反馈的时序图大概是这样:

用户                主Agent              子Agent           MCP服务器         LLM模型          数据库
 │                   │                   │                 │                │                │
 │ 用户请求:         │                   │                 │                │                │
 │ "帮我订一张      │                   │                 │                │                │
 │  明天北京到      │                   │                 │                │                │
 │  上海的机票      │                   │                 │                │                │
 │  和酒店"        │                   │                 │                │                │
 │──────────────────>│                   │                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 1. 初始化对话      │                 │                │                │
 │                   │    构建系统提示词  │                 │                │                │
 │                   │────────────────────────────────────>│                │                │
 │                   │                   │                 │                │                │
 │                   │ 2. 请求可用工具列表│                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │                │
 │                   │ 3. 返回工具列表    │                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    (search_flights, search_hotels,   │                │                │
 │                   │     book_trip, etc.)                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 4. 获取提示词模板  │                 │                │                │
 │                   │──────────────────────────────────────>│                │                │
 │                   │                   │                 │                │                │
 │                   │ 5. 返回提示词      │                 │                │                │
 │                   │<──────────────────────────────────────│                │                │
 │                   │   (booking_recommendation等)         │                │                │
 │                   │                   │                 │                │                │
 │                   │ 6. 构建系统消息    │                 │                │                │
 │                   │    (系统提示词+工具定义+提示词)      │                │                │
 │                   │    users消息="用户请求内容"         │                │                │
 │                   │                   │                 │                │                │
 │                   │ 7. 调用 LLM        │                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │  分析意图       │
 │                   │                   │                 │                │  (BOOK_TRIP)    │
 │                   │                   │                 │                │  提取参数       │
 │                   │                   │                 │                │  (from, to,date)│
 │                   │                   │                 │                │  生成工具调用   │
 │                   │                   │                 │                │                │
 │                   │ 8. LLM 响应        │                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    {                                 │                │                │
 │                   │      "type": "tool_call",            │                │                │
 │                   │      "tool_name": "search_flights",  │                │                │
 │                   │      "arguments": {                  │                │                │
 │                   │        "from": "北京",                │                │                │
 │                   │        "to": "上海",                  │                │                │
 │                   │        "date": "明天"                 │                │                │
 │                   │      }                               │                │                │
 │                   │    }                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 9. 检测到工具调用, │                 │                │                │
 │                   │    路由到子Agent   │                 │                │                │
 │                   │────────────────────>│                │                │                │
 │                   │                   │                 │                │                │
 │                   │                   │ 10. 子Agent     │                │                │
 │                   │                   │     处理工具调用 │                │                │
 │                   │                   │──────────────────>│                │                │
 │                   │                   │                 │                │                │
 │                   │                   │ 11. Travel MCP  │                │                │
 │                   │                   │     执行         │                │                │
 │                   │                   │     search_flights│               │                │
 │                   │                   │                 │ 查询数据库     │                │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回机票列表   │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 12. 返回工具结果   │                 │                │                │
 │                   │<────────────────────                 │                │                │
 │                   │    [                                │                │                │ │                   │      {                               │                │                │ │                   │        "id": "CA123",                │                │                │ │                   │        "airline": "国航",             │                │                │ │                   │        "departure": "10:00",         │                │                │ │                   │        "price": 1200                 │                │                │ │                   │      },                              │                │                │ │                   │      ...                             │                │                │ │                   │    ]                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 13. 添加工具结果   │                 │                │                │
 │                   │     到对话历史     │                 │                │                │
 │                   │     再次调用 LLM   │                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │  分析机票      │
 │                   │                   │                 │                │  生成下一个工具│
 │                   │                   │                 │                │  调用:         │
 │                   │                   │                 │                │  search_hotels │
 │                   │                   │                 │                │                │
 │                   │ 14. LLM 响应(第2次)│                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    {                                 │                │                │
 │                   │      "type": "tool_call",            │                │                │
 │                   │      "tool_name": "search_hotels",   │                │                │
 │                   │      "arguments": {                  │                │                │
 │                   │        "city": "上海",                │                │                │
 │                   │        "check_in": "明天",            │                │                │
 │                   │        "check_out": "后天"            │                │                │
 │                   │      }                               │                │                │
 │                   │    }                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 15. 再次路由到子Agent│                │                │                │
 │                   │────────────────────>│                │                │                │
 │                   │                   │                 │                │                │
 │                   │                   │ 16. 执行        │                │                │
 │                   │                   │     search_hotels│               │                │
 │                   │                   │──────────────────>│                │                │
 │                   │                   │                 │                │ 查询酒店       │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回酒店列表   │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 17. 返回酒店结果   │                 │                │                │
 │                   │<────────────────────                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 18. 再次调用 LLM   │                 │                │                │
 │                   │     (决定下一步)   │                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │  分析酒店      │
 │                   │                   │                 │                │  推荐最佳套餐  │
 │                   │                   │                 │                │  生成工具调用: │
 │                   │                   │                 │                │  book_trip     │
 │                   │                   │                 │                │                │
 │                   │ 19. LLM 响应(第3次)│                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    {                                 │                │                │
 │                   │      "type": "tool_call",            │                │                │
 │                   │      "tool_name": "book_trip",       │                │                │
 │                   │      "arguments": {                  │                │                │
 │                   │        "flight_id": "CA123",         │                │                │
 │                   │        "hotel_id": "SH001"           │                │                │
 │                   │      }                               │                │                │
 │                   │    }                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 20. 路由到子Agent  │                 │                │                │
 │                   │ (预订差旅)         │                 │                │                │
 │                   │────────────────────>│                │                │                │
 │                   │                   │                 │                │                │
 │                   │                   │ 21. 执行book_trip│               │                │
 │                   │                   │──────────────────>│                │                │
 │                   │                   │                 │                │ 创建订单       │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回订单号     │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 22. 返回预订结果   │                 │                │                │
 │                   │<────────────────────                 │                │                │
 │                   │    {                                 │                │                │
 │                   │      "trip_id": "TRIP_001",          │                │                │
 │                   │      "status": "confirmed",          │                │                │
 │                   │      "total_cost": 3000              │                │                │
 │                   │    }                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 23. 调用Calendar MCP│                │                │                │
 │                   │     添加日程        │                │                │                │
 │                   │────────────────────────────────────────────────────────>│               │
 │                   │                   │                 │                │ 添加日历事件   │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回事件ID     │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 24. 调用Payment MCP│                 │                │                │
 │                   │     处理支付        │                 │                │                │
 │                   │────────────────────────────────────────────────────────>│               │
 │                   │                   │                 │                │ 创建支付单     │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回交易ID     │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 25. 调用Alert MCP  │                 │                │                │
 │                   │     发送通知        │                 │                │                │
 │                   │────────────────────────────────────────────────────────>│               │
 │                   │                   │                 │                │ 记录通知        │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │ 26. 最后调用 LLM   │                 │                │                │
 │                   │     生成友好回复   │                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │ 总结整个过程    │
 │                   │                   │                 │                │ 生成用户友好    │
 │                   │                   │                 │                │ 的文字回复      │
 │                   │                   │                 │                │                │
 │                   │ 27. LLM 最终响应   │                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    "好的,已为您预订了从北京       │                │                │
 │                   │     到上海的差旅。您的订单号是    │                │                │
 │                   │     TRIP_001,总费用3000元。     │                │                │
 │                   │     已添加到您的日程,并发送

本质上一句话总结:对话发起后,主Agent构建基础提示词进行首轮行为分析后,然后按需注入子Agent来递归/循环完成一轮对话。

结尾

如上就非常简单直观的结合代码,讲解了现在LLM大模型应用的核心架构和角色拆解。

希望对大家有所帮助。

使用uniapp vue2开发微信小程序时,图片处理插件

vue3处理插件

参考juejin.cn/post/738574…

因为上面的文章中提出的例子在vue2中并不生效, 因此单独写了一个针对vue2使用的loader.

实现1: 通过字符串替换方式处理

这个方式的缺点是较为死板, 无法处理模板字符串和表达式相关, 但是对于src=xxx的类型有较好的匹配

 module.exports = function (source) {
  console.log("----customLoader original content----", source);
  function replaceImageSrcInVue(content) {
    
    content = content.replace(
      /(<template[\s\S]*?>)([\s\S]*?)(<\/template>)/,
      (match, start, middle, end) => {
        // 替换 <image ... src="..." ...>
        const replaced = middle.replace(
          /(<image\b[^>]*?\bsrc=)(['"])([^'"]+)\2/gi,
          (imgMatch, prefix, quote, src) => {
            // 只替换非 http/https 开头的 src
            if (/^https?:\/\//.test(src)) return imgMatch;
            console.log(
              "----customLoader src----",
              imgMatch,
              "  prefix:",
              prefix,
              "   src:",
              src,
            );
            return `${prefix}${quote}${"https://www.xxx.com/"}${src}${quote}`;
          },
        );
        return start + replaced + end;
      },
    );
    return content;
  }
  return replaceImageSrcInVue(source);
};

实现2: 基于ast

这个模式的优点是可以精确匹配到image对应的src属性, 还可以对于绑定src的属性中的模板字符串和字符串类型进行处理, 比如说以下代码, 同时也可以很方便扩展到其他类型的元素中, 比如video等.

:src="isActive ? `${activeHost}/logo.png` : '/staticHost/logo.png'"

依赖编译器版本为yarn add -D @@vue/compiler-sfc@3.5.26

详细实现方式如下:

const compiler = require("@vue/compiler-sfc");

module.exports = function (source) {
  const options = this.getOptions();
  let { publicPath: staticHost, sourceDir } = options || {};
  if (staticHost.endsWith("/")) {
    staticHost = staticHost.slice(0, -1);
  }
  try {
    const sfc = compiler.parse(source, {
      templateParseOptions: { parseMode: "sfc" },
    });
    if (!sfc.descriptor.template) {
      return source;
    }
    let content = sfc.descriptor.template.content;
    const ast = sfc.descriptor.template.ast;
    const tempLen = "<template>".length; // 10, loc是基于整个文件的偏移量,需要减去前面的长度
    const traverseAst = (node) => {
      if (!node) return;
      if (node.children && node.children.length) {
        for (let i = node.children.length - 1; i >= 0; i--) {
          traverseAst(node.children[i]);
        }
      }
      const doReplace = (loc, oldValue) => {
        if (oldValue.startsWith(sourceDir)) {
          const newValue =
            '"' + oldValue.replace(sourceDir, `${staticHost}/`) + '"';
          content =
            content.slice(0, loc.start.offset - tempLen) +
            newValue +
            content.slice(loc.end.offset - tempLen);
        }
      };
      if (node.type === 1 && node.tag === "image") {
        // console.log("Found <image> node:", node);
        const srcAttr = node.props.find(
          (prop) => prop.name === "src" && prop.type === 6,
        );
        if (srcAttr) {
          console.log("Original src value:", srcAttr);
          const srcValue = srcAttr.value.content;
          const loc = srcAttr.value.loc;
          doReplace(loc, srcValue);
        } else {
          const bindSrcAttr = node.props.find(
            (prop) =>
              prop.name === "bind" &&
              prop.type === 7 &&
              prop.rawName === ":src",
          );
          // console.log("Bind src attribute:", bindSrcAttr);
          if (!bindSrcAttr) return;

          const ast = bindSrcAttr.exp.ast;
          const loc = bindSrcAttr.exp.loc;
          // 处理简单的模板字符串情况, 只需要遍历处理template和字符串类型就可以
          // 这里可能包含的类型为三目预算符和逻辑运算符
          const traverseBindAst = (bindNode, loc) => {
            if (!bindNode) return;
            // 逻辑运算符|| 或者 &&
            if (bindNode.type === "LogicalExpression") {
              traverseBindAst(bindNode.right, loc);
              traverseBindAst(bindNode.left, loc);
            } else if (bindNode.type === "ConditionalExpression") {
              // 三目运算符
              traverseBindAst(bindNode.alternate, loc);
              traverseBindAst(bindNode.consequent, loc);
              traverseBindAst(bindNode.test, loc);
            } else if (bindNode.type === "TemplateLiteral") {
              // 模板字符串类型
              if (bindNode.quasis && bindNode.quasis.length > 0) {
                const indexLoc = bindNode.quasis[0].loc;
                const value = bindNode.quasis[0].value.cooked;
                if (value.startsWith(sourceDir)) {
                  const newValue = value.replace(sourceDir, `${staticHost}/`);
                  content =
                    content.slice(
                      0,
                      loc.start.offset - tempLen + indexLoc.start.index - 1,
                    ) + // -1 是因为模板字符串的 ` 符号占位
                    newValue +
                    content.slice(
                      loc.start.offset - tempLen + indexLoc.end.index - 1,
                    );
                }
              }
            } else if (bindNode.type === "StringLiteral") {
              // 字符串类型
              const indexLoc = bindNode.loc;
              const value = bindNode.value;
              if (value.startsWith(sourceDir)) {
                const newValue = value.replace(sourceDir, `${staticHost}/`);
                content =
                  content.slice(
                    0,
                    loc.start.offset - tempLen + indexLoc.start.index, // 这里不减是需要保留 "" 符号
                  ) +
                  newValue +
                  content.slice(
                    loc.start.offset - tempLen + indexLoc.end.index - 2,
                  ); // -2 是因为字符串的 "" 符号占位
              }
            }
          };
          traverseBindAst(ast, loc);
        }
      }
    };
    traverseAst(ast);
    // 替换 template 内容
    const loc = sfc.descriptor.template.loc;
    const newSource = source.slice(0, loc.start.offset) + content + source.slice(loc.end.offset);
    return newSource;
  } catch (err) {
    console.error("Error parsing SFC:", err);
    return source;
  }
}

在vue.config.js中的用法

chainWebpack: (config) => {
      config.module
        .rule("vue")
        .use("vue-loader")
        .end()
        .use("customLoader")
        .loader(path.resolve(__dirname, "./customLoader.js"))
        .options({
          publicPath: "https://xxx.com",
          sourceDir: '/staticHost/',
        })
        .end();
  }

ps

如果遇到报错this.getConfig不存在, 则可以把config配置项写到load.js里面中...

CSS 盒子又“炸”了?一文看懂标准盒模型 vs 怪异盒模型

大家好,我叫【小奇腾】,你们有没有遇到过这种情况?明明设置了两个 width: 50% 的盒子想让它们并排,结果右边那个死活都要掉到下一行去?

难道是 50% + 50% > 100%?数学老师骗了我们? 不,是 CSS 盒模型 在“欺骗”你的眼睛。

今天这节课,我们不背枯燥的概念

本期详细的视频教程bilibili:CSS 盒子又“炸”了?一文看懂标准盒模型 vs 怪异盒模型

一、盒子的“解剖学”:洋葱是怎么剥开的?

在开始区分”标准盒模型 vs 怪异盒模型“之前,我们先了解什么是盒子模型的基本组成,想象你现在手里拿着一个橘子🍊,准备送给朋友。CSS 的盒子模型(Box Model)和这个橘子🍊一模一样,由从内到外的四层组成:

  • Content(果肉) :最核心好吃的那个部分

  • Padding(果皮) :保护果肉的缓冲层。注意:果皮和果肉是一体的,果肉烂了(背景色),果皮通常也是那个颜色。

  • Border(包装盒) :最外层的硬壳。它是橘子和外界的分界线

  • Margin(社交距离) :这一箱橘子和那一箱苹果之间,必须留出的空气缝隙

划重点:Margin 是用来推开别人的,不属于盒子本身的大小;而 Content + Padding + Border 才是盒子自己的“肉身”。

盒子模型的示意图

在浏览器,自己写一个盒子,然后通过检查工具,就可以看到盒子模型的样子。

.box {
    width: 200px;
    height: 200px;
    border: 10px solid #ccc;
    padding: 30px;
    margin: 20px;
}

<div class="box"></div>
  • 盒子模型图

image.png

  • 盒子模型的每个部分(当我的鼠标放在盒子模型上)
    • content(内容区) 宽度200 x 高度200
    • padding(内边距) 4个内边距都是 30
    • border(边框) 4条边框都是 10
    • margin(外边距) 4个外边距都是 20

image.png

二、 直觉的陷阱:你要买多大的橘子?

在我们的直觉里,如果我们买一个宽 100px 的盒子,那这个盒子占的地方应该就是 100px,对吧?

但在 CSS 的标准盒模型(Standard Box Model) 里,逻辑是反直觉的。

🍊 橘子比喻

想象你去买橘子。

  • Content(内容区) :是橘子果肉。
  • Padding(内边距) :是橘子皮。
  • Border(边框) :是包装盒。

当你写下 width: 100px 时,你以为你控制了整个橘子的大小。 实际上,你只控制了“橘子果肉”的大小。

如果你给这个橘子穿上 20px 厚的皮(padding),再套上 5px 厚的壳(border)。 浏览器是这样算账的(做加法):

实际占地宽度 = 果肉(100) + 左皮(20) + 右皮(20) + 左壳(5) + 右壳(5) 结果 = 150px!

💥 案发现场

你有一个盒子里面装了两个子盒子,里面两个子盒子你设置了 width: 50%,但只要你加了一丁点 paddingborder,这个盒子的实际宽度就变成了 50% + 皮。 两个胖子挤在一起,总宽度超过了 100%,父容器装不下,右边的胖子就被挤下去了。这就是标准盒模型给新手挖的最大的坑。

代码示例

从代码中,可以看到给两个子元素都给的50%的宽度,按道理是应该平并排在.box这个父盒子里的,但是却掉下来了一个.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .box {
            width: 1000px;
            display: flex;
            flex-wrap: wrap;
            border: 4px solid purple;
        }

        .left {
            width: 50%;
            padding: 20px;
            border: 5px solid #ccc;
            background-color: red;
        }

        .right {
            width: 50%;
            padding: 20px;
            border: 5px solid blue;
            background-color: green;
        }
    </style>
</head>

<body>
    <div class="box">
        <div class="left"></div>
        <div class="right"></div>
    </div>
</body>

</html>

效果图:

二、 救星登场:怪异盒模型(Border-box)

为了解决这个问题,CSS 提供了一个属性,虽然它以前被称为“怪异盒模型”(Quirks Mode),但我觉得它应该叫**“省心盒模型”**。

即:box-sizing: border-box;

📦 快递箱比喻

在这个模式下,盒子就像一个快递箱。 当你写下 width: 100px 时,这个箱子死锁就是 100px 宽,雷打不动。

如果你非要往里面塞 20px 的泡沫(padding):

  • 泡沫可以被压缩,箱子外壳不会变大(不会撑破布局)。
  • 只能委屈里面的空间(Content)变小

计算这里发生了什么

还是刚才的数据,但这次我们加上了 box-sizing: border-box 给到两个子盒子;

  • CSS 设置width: 100px, padding: 20px, border: 5px

  • 浏览器实际渲染宽度100px(不用算了,就是它!)

  • 里面的内容还能剩多少空间?

    100px (总宽) - 40px (左右皮) - 10px (左右壳) = 50px

虽然内容区被挤小了,但你的页面布局稳如泰山,绝对不会乱跑!

三、 终极一招:一行代码走天下

在实际开发中,我们不想每次写个 div 都要掏出计算器算宽度。怪异盒模型”好用也更符合直觉, 比如淘宝、京东页面,前端工程师们都会在CSS的第一行加上box-sizing: border-box

这句话翻译过来就是:

“浏览器你给我听好了!从现在开始,我说这个盒子宽 100px,它就是 100px。不管我加多少内边距和边框,你都得自己在内部消化,绝对不准把盒子撑大!”

四、总结一下

  1. 盒子四要素:Content(橘子果肉)、Padding(橘子果皮)、Border(包装壳)、Margin(橘子和其他物品距离)。
  2. 标准盒模型width 只管肉,加皮会变胖(容易炸布局)。
  3. 怪异盒模型width 管整体,加皮肉变少(布局超稳定)。
  4. 建议:开局一条 box-sizing: border-box,写代码少掉很多头发。

前后端分离开发实战:从等后端到自己造数据

遇到的真实问题

刚入行那会儿,我经常遇到这种尴尬情况:写好了页面布局,准备连接后端接口,结果后端同事说:"接口还没写完,你先等等。"

等啊等,一等就是一周,有时候甚至两周。我只能在那干坐着,或者写一些无关紧要的代码,感觉特别被动。

后来老鸟告诉我:"兄弟,你不用等后端的,自己先造点假数据跑起来,等后端接口出来后再换掉就行了。"

我当时还不信,直到看到Mock.js这个工具,才发现原来前端开发可以这么爽!

什么是前后端分离?

简单说,就是前端只管页面和交互,后端只管数据和业务逻辑。就像两个人合作做菜,一个人负责摆盘(前端),一个人负责炒菜(后端),最后合成一道完整的菜。

但是,如果摆盘的师傅等炒菜的师傅先做好菜,那整个流程就很慢。所以聪明的做法是,摆盘师傅先拿一些假菜练习摆盘,等真菜来了再换上去。

Mock.js:前端的"造物主"

Mock.js就像是前端开发者的"造物主",可以凭空变出各种数据来。比如我要100篇文章,它就能瞬间给我100篇;我要用户信息,它也能马上生成。

安装和使用

bash

npm install mockjs

然后就可以开始"造数据"了:

javascript

import Mock from 'mockjs'

// 比如我要造10篇文章的数据
const articles = Mock.mock({
    'list|10': [{  // 生成10条数据
        'id|+1': 1,  // ID从1开始递增
        'title': '@ctitle(10, 30)',  // 随机中文标题,10-30个字符
        'content': '@cparagraph(3, 10)',  // 随机中文段落,3-10句话
        'author': '@cname',  // 随机中文姓名
        'date': '@date("yyyy-MM-dd")'  // 随机日期
    }]
})

console.log(articles.list)  // 就能看到10条随机文章数据

是不是很神奇?几行代码就能生成看起来很真实的测试数据。

实战:博客文章列表功能

我们来做一个具体的例子:博客文章列表页面。这个页面需要显示文章列表,还要有分页功能。

先看接口长什么样

一般后端会给我们这样的接口文档:

text

GET /api/posts?page=1&limit=10

返回数据格式:
{
  "code": 200,
  "msg": "success",
  "data": {
    "items": [...],  // 文章列表
    "pagination": {
      "current": 1,  // 当前页
      "limit": 10,   // 每页数量
      "total": 100,  // 总数
      "totalPage": 10  // 总页数
    }
  }
}

用Mock.js造数据

javascript

import Mock from 'mockjs'

// 定义文章标签
const tags = ["前端", "后端", "AI", "职场", "面试", "算法"]

// 造45篇文章数据
const posts = Mock.mock({
    'list|45': [{
        'id|+1': 1,  // ID自增
        'title': '@ctitle(8, 20)',  // 中文标题
        'brief': '@cparagraph(1, 3)',  // 文章简介
        'totalComments|0-50': 1,  // 评论数0-50
        'totalLikes|0-1000': 1,  // 点赞数0-1000
        'publishedAt': '@datetime("yyyy-MM-dd HH:mm")',  // 发布时间
        'user': {  // 用户信息
            'id|1-10': 1,  // 用户ID 1-10
            'name': '@cname',  // 用户姓名
            'avatar': '@image("100x100", "#ccc", "#fff", "avatar")'  // 头像
        },
        'tags': function() {  // 标签,随机选2个
            return Mock.Random.pick(tags, 2)
        },
        'thumbnail': '@image("300x200", "#eee", "#999", "thumb")'  // 缩略图
    }]
}).list

// 定义Mock接口
export default [
    {
        url: '/api/posts',
        method: 'get',
        response: ({ query }) => {
            // 获取分页参数
            const { page = '1', limit = '10' } = query
            const currentPage = parseInt(page)
            const size = parseInt(limit)

            // 参数校验
            if (isNaN(currentPage) || isNaN(size) || currentPage < 1 || size < 1) {
                return {
                    code: 400,
                    msg: '页码或每页数量参数错误',
                    data: null
                }
            }

            // 计算分页数据
            const total = posts.length
            const start = (currentPage - 1) * size
            const end = start + size
            const paginatedData = posts.slice(start, end)

            return {
                code: 200,
                msg: 'success',
                data: {
                    items: paginatedData,
                    pagination: {
                        current: currentPage,
                        limit: size,
                        total: total,
                        totalPage: Math.ceil(total / size)
                    }
                }
            }
        }
    }
]

代码解释

让我解释一下这段代码的关键部分:

  1. @ctitle(8, 20) :生成8-20个字符的中文标题
  2. @datetime("yyyy-MM-dd HH:mm") :生成格式化的日期时间
  3. Mock.Random.pick(tags, 2) :从tags数组中随机选择2个标签
  4. 'id|+1': 1:ID从1开始递增
  5. 分页逻辑(currentPage - 1) * size计算起始位置

如何在Vite项目中使用

在你的vite.config.js中加入:

javascript

import { defineConfig } from 'vite'
import { viteMockServe } from 'vite-plugin-mock'

export default defineConfig({
  plugins: [
    viteMockServe({
      mockPath: 'mock',  // mock文件夹位置
      enable: true,      // 开启mock
    })
  ]
})

这样启动项目后,访问/api/posts?page=1&limit=10就能得到Mock数据了。

为什么这样做很好?

1. 不用等后端了

以前:前端 → 等后端 → 开发
现在:前端 → Mock数据 → 开发 → 换真实接口

2. 可以测试边界情况

用Mock数据,我们可以轻松测试各种边界情况:

  • 空数据列表
  • 错误参数
  • 大量数据
  • 网络超时

3. 数据格式可控

Mock数据完全由前端控制,可以确保数据格式符合前端需求。

4. 提高开发效率

前端可以专注于页面交互和用户体验,不用被后端进度拖累。

实际开发中的注意事项

1. Mock数据要接近真实

Mock的数据格式要尽量和真实接口保持一致,否则后面对接口时会有麻烦。

2. 接口文档要明确

前后端最好先确定好接口文档,包括:

  • 请求路径
  • 请求方法
  • 参数格式
  • 返回数据结构

3. 错误处理也要Mock

不仅要Mock正常情况,还要Mock错误情况,比如网络错误、参数错误等。

真实接口来了怎么办?

当后端接口开发完成后,只需要修改请求的基础URL:

javascript

// 开发环境用Mock
const BASE_URL = import.meta.env.DEV ? '' : 'https://api.real.com'

// 发请求
fetch(`${BASE_URL}/api/posts?page=1&limit=10`)

或者在axios中配置:

javascript

// 开发环境
if (process.env.NODE_ENV === 'development') {
  axios.defaults.baseURL = ''  // Mock接口
} else {
  axios.defaults.baseURL = 'https://api.real.com'  // 真实接口
}

小结

通过Mock.js,前端开发者可以:

  • 摆脱对后端的依赖
  • 快速验证UI和交互
  • 提高开发效率
  • 更好地测试各种场景

这种开发模式已经成为现代前端开发的标准做法。下次再遇到后端没写完接口的情况,你就可以自信地说:"没关系,我自己造数据!"

2025 年 CSS 年度调查报告亮点速览

近日,「State of CSS 2025」年度调查报告公布。 这份报告收集了全球数万名开发者的真实使用经验和反馈,堪称是 Web 开发领域的“年度风向标”。 本篇我们盘点下这份报告的亮点部分。 1.
❌