普通视图

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

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

2026年1月21日 19:23

在 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表格合并

2026年1月21日 18:47

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

vue2 表格合并

<el-table
      :data="tableData"
     :span-method="(param)=>objectSpanMethod(param,tableData)"
      border
      style="width: 100%">
      <el-table-column
        prop="id"
        label="ID"
        width="180">
      </el-table-column>
      <el-table-column
        prop="name"
        label="姓名">
      </el-table-column>
      <el-table-column
        prop="amount1"
        sortable
        label="数值 1">
      </el-table-column>
      <el-table-column
        prop="amount2"
        sortable
        label="数值 2">
      </el-table-column>
      <el-table-column
        prop="amount3"
        sortable
        label="数值 3">
      </el-table-column>
    </el-table>
<script>
function filterArray(item) {
  const valueArray = this.rule.filter(prop => {
    return item[prop] === this.data[prop]
  })
  if (valueArray.length === this.rule.length) {
    return true
  } else {
    return false
  }
}
  export default {
    data() {
      return {
        tableData: [{
          id: '12987122',
          name: '王小虎',
          amount1: '234',
          amount2: '3.2',
          amount3: 10
        }, {
          id: '12987123',
          name: '王小虎',
          amount1: '165',
          amount2: '4.43',
          amount3: 12
        }, {
          id: '12987124',
          name: '王小虎',
          amount1: '324',
          amount2: '1.9',
          amount3: 9
        }, {
          id: '12987125',
          name: '王小虎',
          amount1: '621',
          amount2: '2.2',
          amount3: 17
        }, {
          id: '12987126',
          name: '王小虎',
          amount1: '539',
          amount2: '4.1',
          amount3: 15
        }],
        spanRule: {
            rule: {
              0: ['department_name']   //表示第一列的合并规则
            }
      }
      };
    },
    methods: {
      // 表格合并
          objectSpanMethod({ row, column, rowIndex, columnIndex }, item) {
            if (Object.keys(this.spanRule.rule).includes(columnIndex.toString())) {
              // filter验证数组
              const currentTable = {
                rule: this.spanRule.rule[columnIndex],
                data: item[rowIndex]
              }
              // 该单元格是否被合并 true 合并, false : 不合并
              let chooseSpan = false
              if (rowIndex !== 0) {
                chooseSpan = filterArray.call(currentTable, item[rowIndex - 1])
              }
              if (chooseSpan) {
                return {
                  rowspan: 0,
                  colspan: 0
                }
              } else {
                return {
                  rowspan: item.filter(filterArray, currentTable).length,
                  colspan: 1
                }
              }
            }
          },
    }
  };
</script>


vue3 表格合并

vue3 hooks文件内容


// 定义通用类型(支持任意表格数据类型)
export interface TableSpanRule {
    rule: Record<string, string[]>; // 列索引 → 合并字段列表
}

// 表格合并Hook
export function useTableSpan<T = Record<string, any>>(spanRule: TableSpanRule) {

    const filterArray = (
        currentTable: { rule: string[]; data: T },
        item: T
    ): boolean => {
        const valueArray = currentTable.rule.filter((prop) => {
            return item[prop] === currentTable.data[prop];
        });
        return valueArray.length === currentTable.rule.length;
    };

    const objectSpanMethod = (
        param: {
            row: T;
            column: T;
            rowIndex: number;
            columnIndex: number;
        },
        tableData: T[]
    ) => {
        const { columnIndex, rowIndex } = param;
        // 判断当前列是否在合并规则中
        if (Object.keys(spanRule.rule).includes(columnIndex.toString())) {
            const currentTable = {
                rule: spanRule.rule[columnIndex],
                data: tableData[rowIndex]
            };
            let chooseSpan = false;
            // 非第一行时验证是否需要合并
            if (rowIndex !== 0) {
                chooseSpan = filterArray(currentTable, tableData[rowIndex - 1]);
            }
            // 需要合并则隐藏当前单元格,否则设置合并行数
            if (chooseSpan) {
                return {
                    rowspan: 0,
                    colspan: 0
                };
            } else {
                return {
                    rowspan: tableData.filter((i) => filterArray(currentTable, i)).length,
                    colspan: 1
                };
            }
        }
        // 非合并列返回默认值
        return {
            rowspan: 1,
            colspan: 1
        };
    };

    return {
        objectSpanMethod
    };
}

vue3 表格合并

<el-table
      :data="tableData"
     :span-method="(param)=>objectSpanMethod(param,tableData)" //这里非常重要,tableData字段是表格的数据
      border
      style="width: 100%">
      <el-table-column 
        prop="day"
        label="day"
        width="180">
      </el-table-column>
      <el-table-column
        prop="domainName"
        label="domainName">
      </el-table-column>
      <el-table-column
        prop="allPurchaseCount"
        sortable
        label="allPurchaseCount">
      </el-table-column>
      <el-table-column
        prop="allPurchaseValue"
        sortable
        label="allPurchaseValue">
      </el-table-column>
      <el-table-column
        prop="gaAmountUsd"
        sortable
        label="交易额">
      </el-table-column>
    </el-table>
const tableCol = [  //表格列
  {
    label: t('localeAudience.datetime'),
    prop: 'day',
    width: 120,
    sortable: "custom",
    formatter: (row: any, column: any, text: any) => {
      return text || "-";
    },
  },
  {
    label: t('localeAudience.domain'),
    width: 120,
    prop: 'domainName',
    'show-overflow-tooltip': true,
  },
  {
    label: t('localeAudience.allorders'),
    sortable: "custom",
    width: 120,
    prop: 'allPurchaseCount',
  },
  {
    label: t('localeAudience.allamount'),
    sortable: "custom",
    width: 140,
    prop: 'allPurchaseValue',
  },
  {
    label: '交易额',
    sortable: "custom",
    width: 180,
    prop: 'gaAmountUsd',
  },
];
const tableData = [ // 1.表格数据
  {
    day: '2023-08-01',
    domainName: 'example.com',
    allPurchaseCount: 10,
    allPurchaseValue: 1000,
    gaAmountUsd: 500,
  },
  {
    day: '2023-08-01',
    domainName: 'example.com',
    allPurchaseCount: 5,
    allPurchaseValue: 500,
    gaAmountUsd: 250,
  },
  {
    day: '2023-08-02',
    domainName: 'example.com',
    allPurchaseCount: 8,
    allPurchaseValue: 800,
    gaAmountUsd: 400,
  },
];
// 2. 定义列合并规则
const spanRule = {
  rule: {
    0: ['day','domainName','allPurchaseCount','allPurchaseValue'], //表示第1列的合并规则
    1: ['day','domainName','allPurchaseCount','allPurchaseValue'], //表示第2列的合并规则
    2: ['day','domainName','allPurchaseCount','allPurchaseValue'], //表示第3列的合并规则
    3: ['day','domainName','allPurchaseCount','allPurchaseValue'], //表示第4列的合并规则
  }
};

// 3. 使用表格合并Hook
const { objectSpanMethod } = useTableSpan(spanRule);

手把手实现链表:单链表与双链表的完整实现

作者 颜酱
2026年1月21日 18:26

手把手实现链表:单链表与双链表的完整实现

链表是数据结构的基础,也是面试高频考点。很多初学者会卡在“指针操作混乱”“边界条件处理不当”等问题上。本文将从设计思路出发,拆解单链表实现的核心逻辑,同时补充双向链表(双链表)的实现方法,帮你彻底掌握链表的实现技巧。

一、为什么需要手动实现链表?

编程语言(如JavaScript)没有内置链表结构,但链表的“动态扩容”“非连续存储”特性使其在插入/删除操作中比数组更高效(尤其是头部/中部操作)。手动实现链表的核心目标是:

  • 掌握指针(引用)操作的核心逻辑;

  • 理解虚拟头/尾节点等技巧解决边界问题;

  • 规避“空指针操作”“状态不同步”等高频报错;

  • 区分单链表与双链表的设计差异,适配不同场景需求。

二、单链表实现

1. 单链表核心设计思路

链表的最小单元是“节点(Node)”,每个节点包含两部分:

  • val:节点存储的值;

  • next:指向下一个节点的指针(引用),默认null

链表类(MyLinkedList)需维护核心属性,且遵守状态同步约束

属性名 作用 核心约束
dummyHead(虚拟头节点) 统一头节点操作逻辑,避免单独处理头节点 始终存在,next指向真实头节点
tail(尾节点) 优化尾插效率(从O(n)→O(1)) size=0tail=nullsize>0tail指向最后一个节点
size(链表长度) 简化边界判断,避免冗余遍历 增/删操作必须同步更新,与tail状态严格一致

实现步骤(从0开始设计)

第一步:定义节点类

class LinkedNode {
  constructor(val) {
    this.val = val;   // 节点值
    this.next = null; // 指向下一个节点的指针
  }
}

第二步:设计链表类结构

  1. 初始化虚拟头节点(dummyHead):统一头节点操作,避免边界判断
  2. 初始化尾节点(tail):初始为null,空链表时无尾节点
  3. 初始化长度(size):初始为0,记录链表节点数量

第三步:实现基础查询方法

  • isEmpty():判断链表是否为空(size === 0
  • get(index):获取指定索引的节点值
    • 边界校验:index < 0 || index >= size 返回 -1
    • dummyHead.next开始遍历到目标位置

第四步:实现插入方法(核心:先连后断)

  • addAtHead(val):头部插入

    1. 创建新节点
    2. 新节点next指向原头节点(dummyHead.next
    3. dummyHead.next指向新节点
    4. 更新size,若size === 1则更新tail
  • addAtTail(val):尾部插入

    1. 边界处理:空链表时调用addAtHead
    2. 创建新节点
    3. tail.next指向新节点
    4. tail更新为新节点
    5. 更新size
  • addAtIndex(index, val):指定位置插入

    1. 边界处理:index <= 0调用addAtHeadindex > size直接返回
    2. 遍历到插入位置的前驱节点
    3. 新节点next指向原节点,前驱节点next指向新节点
    4. 若插入到尾部,更新tail
    5. 更新size

第五步:实现删除方法(核心:先连后断)

  • deleteAtIndex(index):删除指定位置节点
    1. 边界校验:index < 0 || index >= size || isEmpty() 直接返回
    2. 遍历到删除位置的前驱节点
    3. 前驱节点next指向待删除节点的next(跳过待删除节点)
    4. 待删除节点next置为null(释放引用)
    5. 更新size,若删除的是尾节点,更新tail

关键设计要点:

  • ✅ 使用虚拟头节点统一边界处理
  • ✅ 维护tail指针优化尾插操作
  • sizetail状态必须严格同步
  • ✅ 所有指针操作前先校验边界条件
  • ✅ 遵循"先连后断"原则:先建立新连接,再断开旧连接
  • ✅ 使用虚拟头节点统一边界处理
  • ✅ 维护tail指针优化尾插操作
  • sizetail状态必须严格同步
  • ✅ 所有指针操作前先校验边界条件

2. 单链表完整实现


/**
 * 单链表节点类
 * @param {number} val - 节点存储的值
 */
class LinkedNode {
  constructor(val) {
    this.val = val;       // 节点值
    this.next = null;     // 指向下一个节点的指针
  }
}

/**
 * 单链表实现
 */
class MySinglyLinkedList {
  constructor() {
    this.dummyHead = new LinkedNode('_dummy'); // 虚拟头节点
    this.tail = null;  // 尾节点
    this.size = 0;     // 链表长度
    // 约束:size=0 时 tail=null;size>0 时 tail 指向最后一个节点
  }

  /**
   * 判断链表是否为空
   * @returns {boolean}
   */
  isEmpty() {
    return this.size === 0;
  }

  /**
   * 获取指定索引的节点值
   * @param {number} index - 目标索引(从0开始)
   * @returns {number} 节点值,索引无效返回-1
   */
  get(index) {
    if (index < 0 || index >= this.size) return -1;

    let pointer = this.dummyHead.next;
    for (let i = 0; i < index; i++) {
      pointer = pointer.next;
    }
    return pointer.val;
  }

  /**
   * 头部插入节点
   * @param {number} val - 要插入的值
   */
  addAtHead(val) {
    const newNode = new LinkedNode(val);
    newNode.next = this.dummyHead.next;
    this.dummyHead.next = newNode;

    this.size++;
    // 空链表插入,尾节点同步更新
    if (this.size === 1) {
      this.tail = newNode;
    }
  }

  /**
   * 尾部插入节点
   * @param {number} val - 要插入的值
   */
  addAtTail(val) {
    // 双重兜底校验:避免tail为null但size>0的异常
    if (this.isEmpty() || this.tail === null) {
      this.addAtHead(val);
      return;
    }

    const newNode = new LinkedNode(val);
    this.tail.next = newNode;
    this.tail = newNode;
    this.size++;
  }

  /**
   * 指定索引插入节点
   * @param {number} index - 插入位置
   * @param {number} val - 要插入的值
   */
  addAtIndex(index, val) {
    if (index <= 0) {
      this.addAtHead(val);
      return;
    }
    if (index > this.size) return;

    let pointer = this.dummyHead;
    for (let i = 0; i < index; i++) {
      pointer = pointer.next;
    }

    const newNode = new LinkedNode(val);
    newNode.next = pointer.next;
    pointer.next = newNode;

    // 插入到尾部时更新tail
    if (index === this.size) {
      this.tail = newNode;
    }
    this.size++;
  }

  /**
   * 删除头部节点
   */
  deleteAtHead() {
    if (this.isEmpty()) return;

    const oldHead = this.dummyHead.next;
    this.dummyHead.next = oldHead.next;
    oldHead.next = null;

    this.size--;
    // 同步更新tail
    if (this.size === 0) {
      this.tail = null;
    } else if (oldHead === this.tail) {
      this.tail = this.dummyHead.next;
    }
  }

  /**
   * 删除尾部节点
   */
  deleteAtTail() {
    if (this.isEmpty()) return;

    if (this.size === 1) {
      this.deleteAtHead();
      return;
    }

    let pointer = this.dummyHead;
    while (pointer.next.next) {
      pointer = pointer.next;
    }

    pointer.next.next = null;
    this.tail = pointer.next;
    this.size--;
  }

  /**
   * 删除指定索引节点
   * @param {number} index - 要删除的索引
   */
  deleteAtIndex(index) {
    if (index < 0 || index >= this.size || this.isEmpty()) {
      return;
    }

    let pointer = this.dummyHead;
    for (let i = 0; i < index; i++) {
      pointer = pointer.next;
    }

    const nodeToDel = pointer.next;
    pointer.next = nodeToDel.next;
    nodeToDel.next = null;

    this.size--;
    // 同步更新tail
    if (this.size === 0) {
      this.tail = null;
    } else if (nodeToDel === this.tail) {
      this.tail = pointer.next || pointer;
    }
  }
}

// 单链表测试用例
const singlyList = new MySinglyLinkedList();
singlyList.addAtHead(1);
singlyList.addAtTail(3);
singlyList.addAtIndex(1, 2);
console.log("单链表get(1):", singlyList.get(1)); // 输出2
singlyList.deleteAtIndex(1);
console.log("单链表get(1):", singlyList.get(1)); // 输出3

3. 单链表核心易错点

易错点 错误表现 修复方案
空指针操作 Cannot set properties of null (setting 'next') 所有指针操作前先校验null,使用isEmpty()size判断
tail状态不同步 删除节点后tail仍指向已删除节点 删除操作后同步更新tailsize=0tail=null
边界条件遗漏 index=0index=size时操作失败 使用虚拟头节点统一处理,特殊位置单独判断
指针操作顺序错误 先断开原链表导致节点丢失 遵循"先连后断"原则:先建立新连接,再断开旧连接
size未同步更新 size与实际节点数不一致 每次增/删操作必须同步更新size

调试技巧:

// 添加调试方法:打印链表结构
toString() {
  const values = [];
  let current = this.dummyHead.next;
  while (current) {
    values.push(current.val);
    current = current.next;
  }
  return `[${values.join(' -> ')}] (size: ${this.size}, tail: ${this.tail?.val ?? 'null'})`;
}

三、双向链表(双链表)实现

1. 双链表核心实现逻辑

(1)双链表与单链表的核心差异

单链表的节点只有next指针(指向后继节点),只能“单向遍历”;双链表的节点新增prev指针(指向前驱节点),支持“双向遍历”,核心优势:

  • 删除节点时,无需遍历找前驱节点(时间复杂度从O(n)→O(1));

  • 支持从尾部反向遍历,适配“逆序操作”场景;

  • 插入/删除操作更灵活,边界处理可通过“虚拟头+虚拟尾”进一步简化。

(2)双链表核心设计要点
  • 节点结构:每个节点包含val(值)、prev(前驱指针)、next(后继指针);

  • 虚拟节点:同时维护dummyHead(虚拟头)和dummyTail(虚拟尾),彻底统一头尾节点的操作逻辑;

  • 状态同步:维护size(长度),且每个节点的prev/next指针必须成对更新(避免指针悬空);

  • 操作原则:插入/删除时,先更新新节点的prev/next,再更新原链表的指针(先连后断)。

实现步骤(基于单链表扩展)

前提:已掌握单链表实现,在此基础上扩展为双链表。

第一步:扩展节点类(新增prev指针)

class DoublyLinkedNode {
  constructor(val) {
    this.val = val;   // 节点值
    this.prev = null; // 指向前驱节点的指针(新增)
    this.next = null; // 指向后继节点的指针
  }
}

第二步:扩展链表类结构(新增虚拟尾节点)

  1. 保留虚拟头节点(dummyHead):与单链表相同
  2. 新增虚拟尾节点(dummyTail:统一尾节点操作,避免边界判断
  3. 初始化连接:dummyHead.next = dummyTaildummyTail.prev = dummyHead
  4. 初始化长度(size):初始为0

第三步:实现辅助方法(优化查找)

  • getNode(index):根据索引获取节点(优化版)
    • 边界校验:index < 0 || index >= size 返回 null
    • 优化策略:索引在前半段从头遍历,在后半段从尾遍历(最坏O(n/2))

第四步:实现插入方法(核心:prevnext成对更新)

  • addAtHead(val):头部插入

    1. 创建新节点
    2. 获取原头节点(dummyHead.next
    3. 成对更新指针
      • 新节点:prev指向dummyHeadnext指向原头节点
      • 原头节点:prev指向新节点
      • dummyHeadnext指向新节点
    4. 更新size
  • addAtTail(val):尾部插入

    1. 创建新节点
    2. 获取原尾节点(dummyTail.prev
    3. 成对更新指针
      • 新节点:prev指向原尾节点,next指向dummyTail
      • 原尾节点:next指向新节点
      • dummyTailprev指向新节点
    4. 更新size
  • addAtIndex(index, val):指定位置插入

    1. 边界处理:index <= 0调用addAtHeadindex > size直接返回,index === size调用addAtTail
    2. 使用getNode(index)找到插入位置的后继节点(nextNode
    3. 获取前驱节点(nextNode.prev
    4. 成对更新指针
      • 新节点:prev指向prevNodenext指向nextNode
      • prevNodenext指向新节点
      • nextNodeprev指向新节点
    5. 更新size

第五步:实现删除方法(核心优势:O(1)删除)

  • deleteAtIndex(index):删除指定位置节点
    1. 边界校验:使用getNode(index)获取待删除节点,无效则返回
    2. 核心优势:直接获取前驱(nodeToDel.prev)和后继(nodeToDel.next),无需遍历
    3. 成对更新指针
      • 前驱节点:next指向后继节点
      • 后继节点:prev指向前驱节点
      • 待删除节点:prevnext置为null(释放引用)
    4. 更新size

第六步:实现扩展功能(双链表特有)

  • reverseTraverse():逆序遍历
    1. dummyTail.prev开始
    2. 通过prev指针向前遍历
    3. 直到dummyHead结束

关键设计要点(相比单链表的升级):

  • 双指针维护:每个节点的prevnext必须成对更新
  • 虚拟头+虚拟尾:彻底统一边界处理,无需维护tail指针
  • O(1)删除优势:删除任意节点无需遍历找前驱
  • 双向遍历优化:根据索引位置选择遍历方向(优化查找效率)
  • 指针释放:删除节点后必须将prevnext置为null

2. 双链表完整实现


/**
 * 双链表节点类
 * @param {number} val - 节点存储的值
 */
class DoublyLinkedNode {
  constructor(val) {
    this.val = val;       // 节点值
    this.prev = null;     // 指向前驱节点的指针
    this.next = null;     // 指向后继节点的指针
  }
}

/**
 * 双向链表实现(优化版:虚拟头+虚拟尾)
 */
class MyDoublyLinkedList {
  constructor() {
    this.dummyHead = new DoublyLinkedNode('_dummyHead'); // 虚拟头节点
    this.dummyTail = new DoublyLinkedNode('_dummyTail'); // 虚拟尾节点
    this.size = 0;                                       // 链表长度

    // 初始化:虚拟头的next指向虚拟尾,虚拟尾的prev指向虚拟头
    this.dummyHead.next = this.dummyTail;
    this.dummyTail.prev = this.dummyHead;
    // 约束:真实节点始终在dummyHead和dummyTail之间
  }

  /**
   * 判断链表是否为空
   * @returns {boolean}
   */
  isEmpty() {
    return this.size === 0;
  }

  /**
   * 辅助方法:根据索引找到对应节点(优化:判断索引位置,选择从头/尾遍历)
   * @param {number} index - 目标索引
   * @returns {DoublyLinkedNode|null} 找到的节点/索引无效返回null
   */
  getNode(index) {
    if (index < 0 || index >= this.size) return null;

    let current;
    // 优化:索引在前半段,从头遍历;索引在后半段,从尾遍历
    if (index < this.size / 2) {
      current = this.dummyHead.next;
      for (let i = 0; i < index; i++) {
        current = current.next;
      }
    } else {
      current = this.dummyTail.prev;
      for (let i = this.size - 1; i > index; i--) {
        current = current.prev;
      }
    }
    return current;
  }

  /**
   * 获取指定索引的节点值
   * @param {number} index - 目标索引
   * @returns {number} 节点值,索引无效返回-1
   */
  get(index) {
    const node = this.getNode(index);
    return node ? node.val : -1;
  }

  /**
   * 头部插入节点
   * @param {number} val - 要插入的值
   */
  addAtHead(val) {
    const newNode = new DoublyLinkedNode(val);
    const nextNode = this.dummyHead.next; // 虚拟头的后继节点(原真实头)

    // 步骤1:新节点的prev指向虚拟头,next指向原真实头
    newNode.prev = this.dummyHead;
    newNode.next = nextNode;

    // 步骤2:原真实头的prev指向新节点
    nextNode.prev = newNode;

    // 步骤3:虚拟头的next指向新节点
    this.dummyHead.next = newNode;

    this.size++; // 长度+1
  }

  /**
   * 尾部插入节点
   * @param {number} val - 要插入的值
   */
  addAtTail(val) {
    const newNode = new DoublyLinkedNode(val);
    const prevNode = this.dummyTail.prev; // 虚拟尾的前驱节点(原真实尾)

    // 步骤1:新节点的prev指向原真实尾,next指向虚拟尾
    newNode.prev = prevNode;
    newNode.next = this.dummyTail;

    // 步骤2:原真实尾的next指向新节点
    prevNode.next = newNode;

    // 步骤3:虚拟尾的prev指向新节点
    this.dummyTail.prev = newNode;

    this.size++; // 长度+1
  }

  /**
   * 指定索引插入节点
   * @param {number} index - 插入位置
   * @param {number} val - 要插入的值
   */
  addAtIndex(index, val) {
    // 边界处理:index<=0插头部,index>size不插入
    if (index <= 0) {
      this.addAtHead(val);
      return;
    }
    if (index > this.size) return;
    // index===size 插尾部
    if (index === this.size) {
      this.addAtTail(val);
      return;
    }

    // 找到插入位置的目标节点(新节点的后继节点)
    const nextNode = this.getNode(index);
    const prevNode = nextNode.prev; // 目标节点的前驱(新节点的前驱)
    const newNode = new DoublyLinkedNode(val);

    // 步骤1:新节点的prev指向prevNode,next指向nextNode
    newNode.prev = prevNode;
    newNode.next = nextNode;

    // 步骤2:prevNode的next指向新节点
    prevNode.next = newNode;

    // 步骤3:nextNode的prev指向新节点
    nextNode.prev = newNode;

    this.size++; // 长度+1
  }

  /**
   * 删除指定索引节点
   * @param {number} index - 要删除的索引
   */
  deleteAtIndex(index) {
    const nodeToDel = this.getNode(index);
    if (!nodeToDel) return; // 索引无效直接返回

    // 步骤1:获取待删除节点的前驱和后继
    const prevNode = nodeToDel.prev;
    const nextNode = nodeToDel.next;

    // 步骤2:跳过待删除节点,连接前驱和后继
    prevNode.next = nextNode;
    nextNode.prev = prevNode;

    // 步骤3:释放待删除节点的指针(避免内存泄漏)
    nodeToDel.prev = null;
    nodeToDel.next = null;

    this.size--; // 长度-1
  }

  /**
   * 扩展方法:逆序遍历链表(双链表核心优势)
   * @returns {number[]} 逆序的节点值数组
   */
  reverseTraverse() {
    const result = [];
    let current = this.dummyTail.prev; // 从虚拟尾的前驱开始遍历
    while (current !== this.dummyHead) {
      result.push(current.val);
      current = current.prev;
    }
    return result;
  }
}

// 双链表测试用例
const doublyList = new MyDoublyLinkedList();
doublyList.addAtHead(1);
doublyList.addAtTail(3);
doublyList.addAtIndex(1, 2);
console.log("双链表get(1):", doublyList.get(1)); // 输出2
console.log("双链表逆序遍历:", doublyList.reverseTraverse()); // 输出[3,2,1]
doublyList.deleteAtIndex(1);
console.log("双链表get(1):", doublyList.get(1)); // 输出3
console.log("双链表逆序遍历:", doublyList.reverseTraverse()); // 输出[3,1]

3. 双链表核心易错点

易错点 错误表现 修复方案
指针更新顺序错误 先修改原链表指针,导致新节点指针丢失 先更新新节点的prev/next,再修改原链表的指针(先连后断)
虚拟头尾未初始化 dummyHead.next/dummyTail.prev为null,操作时报错 初始化时必须让dummyHead.next = dummyTaildummyTail.prev = dummyHead
遍历方向选择不当 无论索引位置都从头遍历,效率低 判断索引是否小于size/2,选择从头/尾遍历(优化时间复杂度)
仅更新单向指针 只更新next不更新prev,导致链表断裂 插入/删除时,prevnext必须成对更新
未释放删除节点的指针 节点删除后仍有prev/next引用,导致内存泄漏(JS中影响小,但不规范) 删除后将节点的prev/next置为null

四、实战应用场景

1. LeetCode 经典题目

2. 实际应用场景

  • LRU缓存:使用双链表维护访问顺序,O(1)时间删除任意节点
  • 浏览器历史记录:双链表支持前进/后退操作
  • 撤销/重做功能:双链表维护操作历史
  • 音乐播放列表:单链表实现顺序播放
  • 任务队列:单链表实现FIFO队列

3. 面试高频考点

  1. 指针操作:如何正确更新next/prev指针
  2. 边界处理:空链表、单节点、头尾节点的特殊处理
  3. 状态同步sizetail等状态的维护
  4. 时间复杂度优化:双链表的删除优势、虚拟节点的作用
  5. 内存管理:指针释放、避免内存泄漏

五、总结

1. 单链表核心

  • 核心属性:dummyHead(虚拟头)+ tail(尾节点)+ size(长度);

  • 修复关键:sizetail同步更新,对null敏感操作增加兜底校验;

  • 避坑原则:先校验边界,再执行核心逻辑,指针操作“先连后断”。

2. 双链表核心

  • 核心升级:节点新增prev指针,新增dummyTail(虚拟尾);

  • 效率优势:删除节点无需找前驱,支持双向遍历;

  • 操作原则:prev/next成对更新,遍历方向按需选择。

掌握单链表和双链表的实现逻辑后,不仅能应对链表等基础题,还能扩展到环形链表、LRU缓存(双链表+哈希表)等进阶场景。建议结合测试用例反复调试,重点关注指针操作和状态同步,形成肌肉记忆。

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

作者 冴羽
2026年1月21日 18:20

很多人以为在大厂工作,就是不停地写代码、解决技术难题。

但事实是:真正成功的工程师并不是那些代码写得最好的人,而是那些解决了代码以外事情的人。

本篇和你分享 21 条职场教训。

这些教训,有的能让你少走几个月的弯路,有的则需要数年才能完全领悟。

它们都与具体的技术无关,因为技术变化太快,根本无关紧要。

但这些教训,项目换了一个又一个,团队换了一批又一批,始终在重复上演。

希望能帮助到你:

1. 最优秀的工程师都痴迷于解决用户问题

很多人容易爱上一项新技术,然后到处找地方用它。

我干过,你肯定也干过。

但真正创造最大价值的工程师是反过来的:

他们专注于深入理解用户问题,并让解决方案从这种理解中自然而然地涌现。

以用户为中心意味着花时间处理支持工单,与用户沟通,观察用户遇到的困难,不断追问“为什么”,直到找到问题的症结所在。

真正理解问题的工程师往往会发现,优雅的解决方案比任何人预想的都要简单。

工程师如果一开始就想着如何解决问题,往往会为了寻找理由而人为地增加复杂性。

2. 正确很容易,共同达成正确才是真正的挑战

即使你在技术上胜券在握,最终也可能输掉项目。

我曾亲眼目睹一些才华横溢的工程师,自诩为房间里最聪明的人,但总是默默地积攒怨气。最终表现为“莫名其妙的执行问题”和“莫名其妙的阻力”。

关键不在于证明自己正确,而在于参与讨论以达成对问题的共识。

为他人创造发言空间,并对自己确信的观点保持怀疑。

3. 行动优先,先做,再做对,再做好

追求完美会让人停滞不前。

我曾经见过工程师花几周讨论一个从没建过的东西的理想架构。

但完美的方案很少从思考中产生,它都是从与现实的碰撞中产生。

先做出来,再做对,再做得更好。

把丑陋的原型放到用户面前,写出乱糟糟的技术文档初稿,发布那个让你有点尴尬的 MVP。

从真实反馈中学到的内容,哪怕只有一周,也远比一个月的理论辩论多得多。

4. 代码清晰远比炫技重要

我知道你想要写出酷炫的代码,那可以证明自己很牛逼。

但项目往往不止你一个人,以后还有其他同事要维护。

优化时要考虑他们的理解能力,而不是你的代码是否优美。

5. 谨慎选择新技术

新技术就像贷款,你要用 bug、招聘困难和认知负担来还。

关键不在于“永远不要创新”,而在于“只在因创新可以带来独特报酬的领域进行创新”。其他的一切还是应该回归平庸。

6. 你的代码不会替你说话,但人会

刚开始工作时,我相信是金子总会发光。

但我错了。

代码静静地躺在仓库里。你的领导在会议上提到你,或者没提。同事推荐你参与项目,或者推荐了别人。

在大公司,决策是在你没被邀请的会议上做出的,用的是你没写的总结,由只有五分钟时间和十二件事要处理的人做出的。

如果你不在场时没人能清楚说出你的价值,那你的价值就等于可有可无。

这不是让你鼓吹自己,而是告诉你:你需要让你的价值被所有人看到。

7. 最好的代码是你根本不用写的代码

工程师文化崇拜创造。

没有人会因为删除代码而获得晋升,即使删除代码往往比添加代码更能改进系统。

因为你不写的每一行代码,都意味着你永远不必调试、维护或解释。

在动工之前,先仔细思考一下:“如果我们不做这件事会发生什么?” 有时答案是“没什么坏处”,那就是你的解决方案。

问题不是工程师不会写代码,而是我们太会写了,以至于忘了问:该不该写?

8. 大规模时,连你的 bug 都有用户

用户多的时候,连你的 bug 都会有用户,这产生了一个职业级洞察:

你不能把兼容性工作当“维护”,把新功能当“真正的工作”。兼容性就是产品。

所以把你的“废弃”做成“迁移”,带上时间、工具和同理心。

9. 慢实际上是因为不协调

项目进展缓慢时,人们的第一反应往往是责怪执行:员工不够努力、技术不成熟、工程师人手不足。

但通常来说,这些都不是真正的问题所在。

在大公司,团队是并发执行的基本单位,但随着团队数量的增加,协调成本呈几何级增长。

大多数效率低下实际上源于目标不一致——人们在做错误的事情,或者以不兼容的方式做正确的事情。

所以高级工程师花更多时间澄清方向、接口和优先级,而不是“写代码更快”,那些才是真正的瓶颈所在。

10. 专注你能控制的,忽略你无法控制的

在大公司,无数的变数都超出你的掌控——组织架构调整、管理决策、市场变化、产品转型等等。

过度关注这些因素只会让你焦虑不安,却又无能为力。

所以高效的工程师,会锁定自己的影响圈。你控制不了是否会重组,但你能控制工作质量、如何应对、学到什么。

这并非被动接受,而是策略性关注。

把精力浪费在无法改变的事情上,就等于浪费了原本花在可以改变的事情上的精力。

11. 抽象并不能消除复杂性

每一次抽象都是一种赌博,赌你不需要理解下面是什么。

有时候你会赢,但总会有漏洞,一旦出现漏洞,你就需要清晰地知道你站在什么上面。

所以高级工程师即使技术栈越来越高,也要持续学习“更底层”的东西。

12. 写作让表达更清晰,以教带学是最快的学习方式

写作能带来更清晰的表达。

当我向别人解释一个概念——在文档里、演讲中、代码评审评论里、甚至和 AI 聊天,我都会发现自己理解上的不足。

所以如果你觉得自己懂了什么,试着简单地解释它。卡住的地方,就是你理解肤浅的地方。

13. 注重粘合性工作

粘合性工作——例如写文档、帮新人上手、跨团队协调、流程优化——至关重要。

但如果你总是无意识地做这些,反而可能会拖慢技术成长,把自己累垮。

陷阱在于把它当“乐于助人”的活动,而不是当作有边界的、刻意的、可见的影响力。

尝试给它设时限,轮换做,把它变成产出物:文档、模板、自动化。

让它作为“影响力”被看见,而不是作为“性格特点”。

14. 如果你赢得每一场辩论,你很可能是在积累无声的阻力

当人们不再和你争,不是因为你说服了他们,而是因为他们放弃了。

但他们会在执行中表达分歧,而不是在会议上。

所以真正的共识需要更长时间。你得真正理解别人的观点,吸收反馈,有时候需要你当众改变主意。

短期“我是对的”的快感,远不如长期和心甘情愿的合作者一起建设的现实来得珍贵。

15. 当衡量标准变成目标时,它就停止了衡量

你暴露给管理层的每个指标,最终都会被博弈。

不是因为恶意,而是因为人会优化被度量的东西。

追如果你追踪代码行数,你会得到更多的代码行数。如果你追踪开发速度,你会得到过高的估算值。

高手的做法是:对每个指标请求都提供一对指标。一个用于衡量速度,一个用于衡量质量或风险。然后,坚持解读趋势,而不是盲目追求阈值。

目标是洞察,而非监控。

16. 承认自己不知道的事情比假装自己知道更能带来安全感

资深工程师说“我不知道”并不是示弱——他们是在鼓励大家坦诚面对。

当领导者承认自己的不确定性时,就等于在暗示其他人也可以这样做。如果不这样的话,就会形成一种人人假装理解、问题被掩盖直到爆发的文化。

我见过团队里最资深的人从不承认自己不明白,我也见过由此造成的后果。问题不被问出来,假设不被挑战,初级工程师保持沉默因为他们以为别人都懂。

17. 你的人脉关系比你拥有的任何一份工作都更长久

职业生涯早期,我专注于工作本身,忽视了人脉经营。回头看,这是个错误。

那些注重人脉关系的同事,在接下来的几十年里都受益匪浅。他们最先了解机会,更快地建立人脉,获得职位推荐,和多年来建立信任的人一起创业。

你的工作不会永远持续下去,但你的人脉网络却会一直存在。

以好奇心和慷慨的态度去拓展人脉,而不是抱着功利主义的心态。

当需要向前迈进的时候,往往是人际关系打开了这扇门。

18. 大多数绩效的提升来自于减少工作量

当系统变慢时,人们的第一反应往往是加东西:加缓存、并行处理、使用更智能的算法。

有时候这样做是对的。

但我发现,通过询问“我们计算了哪些不必要的东西?”往往能带来更多性能提升。

删除不必要的工作几乎总是比更快地完成必要的工作更有成效。最快的代码是永远不会运行的代码。

所以在进行优化之前,先问问自己这项工作是否真的应该存在。

19. 流程存在的目的是为了减少不确定性,而不是为了留下书面记录

最好的流程是让协调更容易、让失败成本更低。

最差的流程是官僚主义——它的存在不是为了帮忙,而是为了出事时推卸责任。

如果你无法解释一个个流程如何降低风险或提高清晰度,那么它很可能只是增加了额外开销。

如果人们花在记录工作上的时间比做工作的时间还多,那就说明出了大问题。

20. 最终,时间会比金钱更有价值

刚开始工作的时候,你用时间换钱——这没问题。

但到了某个阶段,情况就完全不同了。你会开始意识到,时间才是不可再生资源。

我见过一些高级工程师为了晋升而累垮自己,只为了多拿几个百分点的薪酬。有些人确实升职了,但事后大多数人都在反思,自己放弃的一切是否值得。

答案不是“别努力工作”,而是“知道你在交易什么,并深思熟虑地进行交易”。

21. 没有捷径,但有复利

专业技能源于刻意练习——略微超越现有水平,然后不断反思,不断重复。年复一年,没有捷径可走。

但令人欣慰的是:学习的进步在于创造新的选择,而不仅仅是积累新的知识。

写作——不是为了吸引眼球,而是为了清晰表达。构建可复用的基础模型。将过往的经验总结成行动指南。

所以如果工程师把职业生涯看作是复利投资,而不是彩票,那么他最终往往会取得更大的成就。

22. 最后

21 条听起来很多,但它们可以归结为几个核心点:保持好奇,保持谦逊,记住工作始终是关于人的——你的用户、你的队友。

工程师的职业生涯足够长,可以犯很多错误。我最钦佩的工程师,不是那些什么都做对的人——而是那些从错误中学习、分享发现、并坚持不懈的人。

本篇整理自《21 Lessons From 14 Years at Google》,希望能帮助到你。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

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

作者 Simon_He
2026年1月21日 18:13

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

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

真实场景,极致体验

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

3步上手,流式体验立享

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

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

👉 GitHub地址


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

作者 Moment
2026年1月21日 18:05

在写这份实现教程之前,我已经把该插件的一个版本发布到了 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的方案设计

作者 sorryhc
2026年1月21日 17:51

前言

如今的大模型应用架构设计基本都是一个主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开发微信小程序时,图片处理插件

2026年1月21日 17:47

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 怪异盒模型

作者 Flinton
2026年1月21日 17:40

大家好,我叫【小奇腾】,你们有没有遇到过这种情况?明明设置了两个 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,写代码少掉很多头发。

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

2026年1月21日 17:03

遇到的真实问题

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

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

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

我当时还不信,直到看到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和交互
  • 提高开发效率
  • 更好地测试各种场景

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

前端发送文件,后端的nest.js如何接收文件名?

作者 江启年
2026年1月20日 21:06

当我们在前端发送文件的时候,首先把文件的数据整合起来,在业务层汇聚到接口层,接口层使用参数接收一个或多个文件:

export function useUploadFile() {
  return useMutation({
    mutationFn: async ({
      bucket,
      files,
    }: {
      bucket: string;
      files: File[];
    }) => {
      const formData = new FormData();
      files.forEach((file) => {
        formData.append('files', file, encodeURIComponent(file.name));
      });

      const res = await http.post<UploadFileApiResponse>(
        `${uploadFilePath}/${bucket}`,
        formData,
        {
          headers: {
            'Accept-Charset': 'utf-8',
          },
        }
      );
      return res;
    },
  });
}

在上面的代码中,接收的是多个文件,使用File[]类型进行约束。

拿到文件后,先new一个FormData,用来发送文件,当文件的发送“容器”。那么多个文件就需要把它们都放入“容器”里才行。使用forEach进行放入。

使用formdata的append函数即可添加。注意append函数的第一个参数,那是文件的描述符号,可以随便写,当然,开发过程中需要遵守开发标准,后端接收的时候用什么描述符号,就填什么。

最关键的是第三个参数,第三个参数是调用了encodeURIComponent函数,这个函数会把字符转换为URL-encoded UTF-8 也就是URL编码的utf8格式。

那么就需要说一下nestjs的文件接收的编码转换的坑了。

使用nestjs接收文件时,使用:

@Post('upload/:bucket')
  @UseInterceptors(FilesInterceptor('files', 100))
  async uploadFile(
    @Param('bucket') bucket: string,
    @UploadedFiles() files: Express.Multer.File[],
  ) {}

这样的方式接收文件。

问题在于它接收的时候,会把文件名从utf8转为ISO-8859-1 (Latin-1 ) 编码,这导致文件的中文名会变成:“çµå¶é”这样的字符。

所以为了避免这样的问题,就需要

formData.append('files', file, encodeURIComponent(file.name));

在这里调用encodeURIComponent对原始的文件名进行编码,这样到了后端,文件的编码形式就不会改变。

后端使用文件名时,如下方法即可:

        files.map(async (f) => {
            // 解码前端发送的url编码形式,正确的将文件名传输过来
            const newfilename = decodeURIComponent(f.originalname);

            // 为minIO的每个文件添加一个单独的uuid,防止文件名重名,在获取文件信息以及下载文件时予以清除
            const UUID = randomUUID();
            const safeFilename = newfilename.replace(/[“”#]/g, '').trim();
            this.minioService.validateFile(f);

            // 保存文件信息到mysql数据库
            await this.fileService.saveFile(
              safeFilename,
              UUID,
              f.mimetype,
              f.size,
            );

            // 分片上传文件到 MinIO
            await this.minioService.uploadFile(UUID, f, bucket);

            // 更新任务的已上传文件数
            const task = uploadTasks.get(taskId);
            if (task) task.uploadedFiles++;
          }),
        );

可以看到在第三行,调用了decodeURIComponent对文件的原始名称进行解码,即可得到正确的文件名。

前端向架构突围系列 - 工程化(四):Vite 与基于 Rust/Go 的新一代构建浪潮

2026年1月20日 11:19

写在前面

"天下苦 Webpack 久矣。"

这句话虽然偏激,却道出了 2020 年前后前端开发者的心声。随着项目规模从几百个模块膨胀到几万个模块,Webpack 的启动时间从 30 秒延长到了 5 分钟。开发者每天花在 npm run dev 上的等待时间,足够喝完一整天的咖啡。

并不是 Webpack 不够优秀,而是基于 JavaScript 编写的构建工具,在处理海量 AST(抽象语法树)转换时,撞上了 Node.js 单线程的物理墙壁。

于是,一场关于“速度”的革命爆发了。这场革命分为两条战线:一条是以 Vite 为代表的 架构模式革新(Unbundled) ,另一条是以 Esbuild/Rspack 为代表的 底层语言大换血(Native)

unnamed.jpg


一、 架构的降维打击:Vite 的 O(1) 启动哲学

Vite(法语意为“快”)的横空出世,给了当时的构建圈一记响亮的耳光。它的核心武器不是算法优化,而是 “偷懒”

1.1 从 Eager 到 Lazy:开发环境的范式转移

Webpack 的 Dev Server 是 Eager(急切) 的。不管你当前访问哪个页面,它必须先把整个项目打包好,存到内存里,然后才能启动服务器。项目越大,启动越慢,时间复杂度是 O(n)

Vite 的 Dev Server 是 Lazy(懒惰) 的。

利用浏览器原生支持 ESM (

  1. 启动一个简单的 Node Server。
  2. 浏览器请求 main.js
  3. Vite 拦截请求,简单处理(比如把 JSX 编译成 JS),返回给浏览器。
  4. 浏览器解析 main.js,发现 import App from './App.vue',再次发送请求。
  5. Vite 再次拦截,编译 App.vue,返回...

架构视角:

Vite 将“构建”这个动作,从 服务器端 (Build Time) 转移到了 浏览器端 (Runtime),并将全量计算拆解成了按需计算。

无论你的项目有 100 个页面还是 1000 个页面,Vite 启动时只处理入口文件。启动速度与项目规模解耦,时间复杂度变成了 O(1)。

1.2 生产环境的妥协:Bundle 依然必要

很多新手误以为 Vite 在生产环境也是 Bundless(无打包)的。这是错的。

虽然 HTTP/2 支持多路复用,理论上可以并发请求几百个文件,但在物理现实中,网络瀑布流 (Network Waterfall) 依然是性能杀手。

如果 A.js 依赖 B.js,B.js 依赖 C.js,浏览器必须串行下载。在移动端弱网环境下,这种 RTT(往返时延)是不可接受的。

因此,Vite 在生产环境依然调用 Rollup 进行打包。

这就引入了一个架构上的 一致性风险:开发环境(Esbuild + 原生 ESM)与生产环境(Rollup Bundle)不仅运行机制不同,甚至部分 AST 解析逻辑也不一致。这是 Vite 架构中最大的 Trade-off。


二、 算力的暴力美学:Rust 与 Go 的底层重写

架构优化总有尽头,当 JS 引擎跑得冒烟也追不上项目膨胀的速度时,基础架构师们决定更换底层的“发动机”。

2.1 Esbuild:Go 语言的闪电战

Esbuild 是第一个打破僵局的英雄。它用 Go 语言编写,主打“快到离谱”。

它比 Webpack 快 10-100 倍。为什么?

  1. 编译型语言 vs 解释型语言: Go 编译成机器码,没有 JS 的 JIT 开销。
  2. 极致并行 (Parallelism): Webpack 运行在 Node.js 主线程(虽然有 worker-thread 但开销大),而 Esbuild 利用 Go 的 Goroutine 榨干了 CPU 的每一个核心。
  3. 零内存拷贝: 从解析到打印,尽量复用数据结构,减少内存占用。

Vite 的预构建(Pre-bundling)就是基于 Esbuild 实现的,把成吨的 node_modules 依赖瞬间转换成 ESM。

2.2 SWC 与 Turbopack:Vercel 的野望

SWC (Speedy Web Compiler) 是用 Rust 写的,它的目标是替代 Babel。

而 Turbopack(由 Webpack 之父 Tobias 创建,Vercel 赞助)则是基于 Rust 的“Webpack 接班人”。它引入了 增量计算 (Incremental Computation) 的概念——永远只计算变动的部分,且在函数级别进行缓存。

虽然 Turbopack 声称比 Vite 快 10 倍,但在生态兼容性上目前仍处于追赶状态。


三、 终极缝合怪:Rspack 的兼容之道

架构选型往往不是选“最先进的”,而是选“最合适的”。

Vite 虽然好,但对于那些拥有几百个自定义 Webpack Loader/Plugin 的巨型老项目来说,迁移成本等于重写。

这时候,字节跳动开源的 Rspack 走了一条极其务实(也极其困难)的路:

用 Rust 重写 Webpack。

3.1 "Webpack Interface, Rust Kernel"

Rspack 的架构策略非常狡猾:

  • 外壳: 几乎 100% 模拟 Webpack 的配置 API、Loader 机制和 Plugin 系统。
  • 内核: 底层全部用 Rust 实现并行编译、Tree Shaking 和压缩。

这意味着,你可能只需要把 webpack.config.js 改名为 rspack.config.js,安装几个包,就能获得 10 倍的构建性能提升。

架构视角:

Rspack 解决的是 “存量市场” 的痛点。它牺牲了部分架构的纯洁性(背负了 Webpack 的配置包袱),换取了巨大的生态兼容性。这在架构演进中被称为 Strangler Fig Pattern(绞杀榕模式) 的变体——在旧接口下替换新实现。


四、 架构师的决策矩阵:2026 年选什么?

站在今天,面对 Webpack、Vite、Rspack,架构师该如何抉择?

维度 Vite Rspack Webpack 5
核心场景 中小型项目、新项目、单页应用 (SPA) 巨型 Monorepo、存量项目迁移、对构建速度极度敏感 及其特殊的定制化需求、老旧生态捆绑
开发体验 (DX) 🌟🌟🌟🌟🌟 (秒开) 🌟🌟🌟🌟 (极快) 🌟🌟 (慢)
生产性能 (UX) 🌟🌟🌟 (依赖 Rollup) 🌟🌟🌟🌟🌟 (内置 Rust 优化) 🌟🌟🌟
生态兼容 Rollup 生态优先 Webpack 生态优先 原生 Webpack
底层语言 JS (部分 Esbuild-Go) Rust JS

最佳实践建议:

  1. Greenfield (新项目): 闭眼选 Vite。社区最活跃,插件生态最好,DX 最佳。
  2. Brownfield (老旧大仓): 尝试迁移到 Rspack。不要试图把一个配置了 5 年的 Webpack 项目硬迁到 Vite,你会死在 Loader 的兼容性上。Rspack 是这类项目的救星。
  3. Library (库开发): 依然推荐 Rollup 或基于 Rollup 的构建流。Vite 的库模式本质也是 Rollup。

结语:工具链的终局

从 Webpack 的大包大揽,到 Vite 的按需加载,再到 Rspack 的 Rust 重构。前端构建工具的发展史,其实就是一部 “对抗熵增与压榨硬件性能” 的历史。

未来的构建工具将不再是单纯的 JavaScript 脚本,而是一套 基于 Rust/Go 的高性能二进制工具链。前端工程师的门槛,正在从“配置工程师”转变为“理解编译原理与系统架构”的工程师。

Next Step:

引擎已经准备就绪,<模块化> 和 <包管理> 也已齐备。但如何让成百上千的开发人员在这条高速公路上不发生车祸?我们需要一套强有力的交通规则。

下一节,我们将进入**《第五篇:规范——把标准装进盒子里:企业级脚手架的设计与落地》**,聊聊 如何通过脚手架强制执行架构意志。

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

作者 冴羽
2026年1月20日 11:04

近日,「State of CSS 2025」年度调查报告公布。

这份报告收集了全球数万名开发者的真实使用经验和反馈,堪称是 Web 开发领域的“年度风向标”。

本篇我们盘点下这份报告的亮点部分。

1. 使用率最高的功能是 :has()

在调查的所有功能中,**:has()**是使用率最高也是最受欢迎的功能。

想必大家已经很熟悉了,它是一个功能非常强大的伪类,可以实现类似“父选择器”和“前面兄弟选择器”的功能。

举个简单的例子,下面的 CSS 代码表示如果 <a> 元素里面有 <img> 元素,则这个 <a> 元素就会匹配。

:has(img) {
  display: block;
}

我们可以使用这个选择器轻松区分是文字链接还是图像链接,并设置不同的 CSS 样式。

2. 使用率第二高的功能是 aspect-ratio

这个 CSS 属性允许你定义元素盒子的宽高比。

这意味着即使父容器或视口大小发生变化,浏览器也会调整元素的尺寸以保持指定的宽高比。

比如我们将一张图片设置为 3/2 宽高比:

img {
  aspect-ratio: 3/2;
}

3. 使用率最低的是 sibling-count 和 sibling-index

记得以前实现列表项交错动画时,要手动给每个元素设置不同的延迟吗?

现在,用 sibling-index() 一行代码就能搞定!

li {
  transition: opacity 0.3s ease;
  transition-delay: calc((sibling-index() - 1) * 100ms);
}

这个函数会自动获取元素在兄弟节点中的位置(从 1 开始计数),通过简单的计算就能实现流畅的交错动画效果

如果再搭配 @starting-style,连入场动画都能轻松搞定:

li {
  transition: opacity 0.3s ease;
  transition-delay: calc((sibling-index() - 1) * 100ms);

  @starting-style {
    opacity: 0;
  }
}

实现效果如下:

之所以使用率最低,可以理解,因为浏览器支持还比较新。

4. 受欢迎程度第二高的功能是 Subgrid

Subgrid 表示子网格,它并不是一个 CSS 属性,而是 grid-template-columns 和 grid-template-rows 属性支持的关键字,其使用的场景需要外面已经有个 Grid 布局。

什么时候会用到 Subgrid 呢?

举个例子,这是一个布局效果:

你会发现,标题字数不一样,内容字数不一样,导致底部很难对齐。

然而我们想要的效果是这样的:

此时就可以用到 Subgrid,使用示例如下:

.wrapper {
  display: grid;
  grid-template-columns: 1fr 1fr;
}

.item {
  grid-row: 1 / 4;
  display: grid;
  grid-template-rows: subgrid;
}

5. 认知度增长最高的是 light-dark()

不知道你是否实现过网站的浅色和深色主题:

:root {
  /* 默认浅色主题 */
  --text-heading: #000;
  --text-body: #212121;
  --surface: #efefef;

  @media (prefers-color-scheme: dark) {
    /* 暗色主题 - 第一遍 */
    --text-heading: #fff;
    --text-body: #efefef;
    --surface: #212121;
  }
}

.dark-theme {
  /* 暗色主题 - 又写一遍! */
  --text-heading: #fff;
  --text-body: #efefef;
  --surface: #212121;
}

同样的颜色写两遍,一个给媒体查询(自动切换),一个给切换按钮。

改一次要改两个地方,烦死了!

现在使用 light-dark() 轻松实现!

:root {
  /* 跟随系统偏好 */
  color-scheme: light dark;

  /* 一次定义,自动切换 */
  --text-heading: light-dark(#000, #fff);
  --text-body: light-dark(#212121, #efefef);
  --surface: light-dark(#efefef, #212121);
}

就这么简单!系统是浅色就用第一个,暗色就用第二个。

6. 评论最多的功能是 line-clamp,多是负面评价

CSS 属性 line-clamp 用于将容器的内容限制为指定的行数,也就是我们常实现的内容多时显示省略号的效果。

举个例子:

p {
  width: 300px;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
}

效果如下:

之所以被大家吐槽,多是因为技术局限性问题,比如:

  • 能限制行数,无法精确控制高度
  • 浏览器兼容性还不够好
  • 与动态内容配合困难:当文本内容长度不确定时,难以准确控制显示效果

当我们实际使用 line-clamp 的时候,还要配合一系列属性比如 display、-webkit-box-orient、overflow、text-overflow,这种组合方案既复杂又不够语义化。

7. 结论

CSS 这些年无疑在快速的发展中,而人们对 CSS 的满意度也在持续攀升。

引用报告中的一句话:

“如果说 2025 年的主题是稳定不可能之事,那么 2026 年或许是实现期待已久的梦想之年。”

对于热爱 CSS 的人来说,现在正是尝试、学习并参与塑造未来发展方向的最佳时机。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

新的一年,如果你想快速改变自己,欢迎加入我的知识星球:“冴羽·前端大佬成长之路”,10 年工作总结、100+ 篇精华主题、70W 字原创内容,带你升级认知、重构生活、建立知识管理系统、通关面试、引领职场。用一年时间,实现十倍成长,一鸣惊人。

前端指纹技术是如何实现的?(Canvas、Audio、硬件API 核心原理解密)

作者 ErpanOmer
2026年1月20日 11:02

Fingerprint-KYS-01-1-1200x596.png

什么是设备指纹?

在讲实现之前,先纠正一个误区:设备指纹(Device Fingerprint)不是为了知道你是张三,而是为了知道 这台设备是编号 9527

它的核心逻辑只有一条:利用浏览器暴露的硬件底层差异(显卡、声卡、电池、屏幕),组合出极高熵值的唯一 ID。

就算你清空了 Cookie,换了 IP,只要你的硬件没变,这套代码算出来的 Hash 值就永远不变。

下面我们直接上代码,拆解最核心的三种实现方式。

👉 在线体验 Device Fingerprint


Canvas 指纹

这是目前最成熟、识别率最高的技术。

实现原理

Canvas 绘图不仅仅依赖浏览器引擎,它极度依赖底层的 GPU 绘图指令操作系统图形驱动 以及 抗锯齿(Anti-aliasing)算法

当你命令浏览器画一个红色的矩形,里面写上 Hello, world! 时:

  • NVIDIA 的显卡和 AMD 的显卡,在处理边缘像素的混合(抗锯齿)时,算法有微小的数学差异。
  • Windows 和 Mac 在字体渲染(Sub-pixel rendering)上,对笔画粗细的处理不同。
  • 这就导致了:同一段 Canvas 代码,生成的图片像素数据(RGBA),在不同设备上是完全不同的。

核心代码实现方式

我们不需要画多复杂的图,关键是要触发差异。通常会用到:光影叠加、异形字体、emoji(检测字体库)。

function getCanvasFingerprint() {
    // 1. 创建一个不会挂载到 DOM 上的画布
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    canvas.width = 200;
    canvas.height = 50;

    // 2. 文字干扰:利用字体渲染差异
    // textBaseline 设为 top/bottom 会触发不同的垂直对齐算法
    ctx.textBaseline = "top";
    ctx.font = "14px 'Arial'";
    ctx.fillStyle = "#f60";
    ctx.fillRect(125, 1, 62, 20); // 背景块

    // 3. 叠加混合:触发 GPU 的颜色混合算法
    ctx.fillStyle = "#069";
    // 写入带特殊符号的文字,测试系统字体支持度
    ctx.fillText("Hello, world! \ud83d\ude03", 2, 15);
    
    // 再次叠加,利用 rgba 透明度触发抗锯齿差异
    ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
    ctx.fillText("Hello, world! \ud83d\ude03", 4, 17);

    // 4. 导出指纹
    // toDataURL 会返回 base64 字符串
    // 同样的图像,不同显卡生成的 base64 字符串的 CRC 校验码是不同的
    const b64 = canvas.toDataURL().replace("data:image/png;base64,", "");
    
    // 5. 将超长字符串 Hash 化 (这里用简单的 hash 示例,生产环境可用 MurmurHash3)
    let bin = atob(b64);
    let crc = bin2hex(bin.slice(-16, -12)); // 取部分校验位
    return crc;
}

image.png

差异在哪?

肉眼看这两张图是一模一样的。

但如果你把两台电脑生成的 Base64 字符串拿去对比,会发现可能在第 5000 个字符处,有一个字母不一样。那就是显卡留下的签名。


AudioContext 指纹

既然显卡有差异,声卡(Audio Stack)自然也有。

原理

Audio 指纹不是录音(不需要麦克风权限)。

它是利用 Web Audio API 生成一段数学上的声音信号(正弦波、三角波),然后经过一系列处理(压缩、滤波)。

由于计算机浮点数运算的精度差异,以及底层音频处理单元(DSP)的实现不同,最终生成的PCM 音频数据流会有极其微小的差别。

代码实现

通常使用 OfflineAudioContext(离线音频上下文),它可以在后台静默渲染音频,不需要用户听到声音,速度极快。

function getAudioFingerprint() {
    // 兼容性处理
    const AudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext;
    if (!AudioContext) return null;

    // 1. 创建离线上下文:1个声道,44100采样率,5000帧
    const context = new AudioContext(1, 5000, 44100);

    // 2. 创建振荡器 (Oscillator)
    // 三角波 (triangle) 比正弦波更容易暴露硬件处理的非线性差异
    const oscillator = context.createOscillator();
    oscillator.type = 'triangle';
    oscillator.frequency.value = 10000;

    // 3. 创建动态压缩器 (Compressor) - 核心步骤
    // 压缩器的算法在不同浏览器/硬件上实现差异很大
    const compressor = context.createDynamicsCompressor();
    compressor.threshold.value = -50;
    compressor.knee.value = 40;
    compressor.ratio.value = 12;
    compressor.reduction.value = -20;

    // 4. 连接节点:振荡器 -> 压缩器 -> 输出
    oscillator.connect(compressor);
    compressor.connect(context.destination);

    // 5. 开始渲染
    oscillator.start(0);
    context.startRendering().then(buffer => {
        // 6. 获取渲染后的 PCM 数据
        // 这是一个 Float32Array 数组
        const data = buffer.getChannelData(0);
        
        // 7. 计算 Hash
        let sum = 0;
        // 简单累加所有采样点的绝对值,作为指纹
        for (let i = 0; i < data.length; i++) {
            sum += Math.abs(data[i]);
        }
        console.log("Audio Fingerprint:", sum);
    });
}

image.png

不同设备跑出来的 sum 值,会精确到小数点后十几位,那个微小的尾数差异就是指纹。


电池与硬件并发

光有 Canvas 和 Audio 还不够(因为同一型号的 iPhone 可能会完全一样)。这时候需要引入动态硬件特征来增加熵值。

电池电量 API (Battery Status API)

注:由于隐私争议太大,Firefox 和 Safari 已禁用,但 Chrome (部分版本) 和 Android Webview 中依然可能获取。

这玩意的逻辑非常粗暴:

电量百分比, 充电/放电时间 这个组合在特定时间点是极具唯一性的。

// 核心代码
navigator.getBattery().then(battery => {
    const level = battery.level; // 例如 0.55
    const chargingTime = battery.chargingTime; // 例如 1200 (秒)
    const dischargingTime = battery.dischargingTime; // 例如 Infinity
    
    // 指纹因子:0.55_1200_Infinity
    // 结合 IP,能把用户锁定得死死的
    const batteryFingerprint = `${level}_${chargingTime}_${dischargingTime}`;
});

如果我在 1 分钟内连续请求两次,发现你的电量从 0.42 变成了 0.41,这个变化曲线也是一种强指纹。

硬件并发数与内存

这些是 Navigator 对象上赤裸裸的硬件参数:

const hardwareInfo = [
    navigator.hardwareConcurrency, // CPU 核心数,如 12
    navigator.deviceMemory,        // 内存大小 (GB),如 8
    screen.width + 'x' + screen.height, // 分辨率
    screen.colorDepth,             // 色彩深度,如 24
    window.devicePixelRatio        // 像素比,如 2
].join('_');

// 输出示例:12_8_2560x1440_24_2

image.png

虽然这些参数单看很普通,但如果你把 CPU + 内存 + 分辨率 + Canvas指纹 + Audio指纹 拼接在一起,全球几十亿设备中,能和你撞车的概率,几乎为零。


所谓的前端指纹技术,本质上就是找不同的一种方式。

开发者利用一切可以调用的 API(Canvas, Audio, WebGL, 硬件信息),强迫浏览器进行某种复杂的运算。由于硬件和驱动的细微差别,运算结果必然存在差异。

这些差异被收集起来,生成了一个字符串。

这就是浏览器在互联网上的唯一ID,希望对你们有帮助!

如果喜欢我的内容,希望给个Start 😁👉⭐Github

谢谢大家.gif

啊?我刚把 Electron 版本升级到39,现在40又来了❓❓❓

作者 Moment
2026年1月20日 09:39

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

就在 2025 年 10 月 28 日,Electron 团队刚刚发布了 Electron 39.0.0 版本,带来了 ASAR Integrity 功能的稳定化、Chromium 142 的升级以及一系列新特性。许多开发者还在熟悉 39 版本的功能和特性,特别是 ASAR Integrity 这个从实验性功能转为稳定版的重要安全特性。

然而,仅仅过了大约两个半月,Electron 团队就在 2026 年 1 月 13 日再次发布了 Electron 40.0.0 版本!这个发布节奏确实体现了 Electron 团队在技术迭代上的高效和积极。从 39 版本到 40 版本,不仅核心技术栈有了重大升级,还引入了一些新的功能和改进,同时也有一些需要开发者注意的破坏性变更。

核心技术栈升级

Electron 40.0.0 的核心技术栈进行了重大升级:

  • Chromium:从 142.0.7444.52 升级到 144.0.7559.60
  • Node.js:从 v22.20.0 升级到 v24.11.1
  • V8 引擎:从 14.2 升级到 14.4

这些升级意味着 Electron 应用将获得更好的性能、安全性和对新 Web 标准的支持。特别值得注意的是,Node.js 从 22 版本直接跳到了 24 版本,这是一个重大升级,带来了更多现代化的 JavaScript 特性和性能优化。Chromium 144 的升级也带来了最新的浏览器特性、安全补丁和性能改进。

对比 Electron 39 版本(Chromium 142、Node.js 22.20.0、V8 14.2),40 版本在技术栈上有了明显的进步,特别是在 Node.js 版本上的跨越式升级。

新功能和改进

硬件加速检测能力

新增了 app.isHardwareAccelerationEnabled() 方法,允许开发者检测硬件加速是否已启用。这个功能在 Electron 39 中就已经引入,在 40 版本中继续可用。这对于需要了解应用运行环境的场景非常有用,可以帮助开发者更好地优化应用性能,特别是在处理图形密集型任务时。

高动态范围(HDR)支持

在离屏渲染(Offscreen Rendering)中新增了 RGBAF16 输出格式,支持 scRGB HDR 色彩空间。这个功能同样在 Electron 39 中就已经引入,在 40 版本中继续完善。这对于需要处理高动态范围内容的媒体应用来说是一个重要的改进,能够提供更丰富的视觉体验,让应用能够更好地展示高质量的视频和图像内容。

Linux 系统主题色支持

在 Linux 平台上新增了通过 systemPreferences.getAccentColor 获取系统强调色的能力。这个功能在 Electron 39 中就已经引入,在 40 版本中继续可用。这使得 Electron 应用能够更好地与系统主题集成,提供更一致的用户体验。这个功能在 macOS 和 Windows 上已经存在,现在 Linux 用户也能享受到同样的体验。

文件系统 API 权限持久化

现在可以在会话中持久化 File System API 的授权状态。这个功能在 Electron 37、38、39 版本中就已经引入,在 40 版本中继续完善。这意味着用户授予的文件访问权限可以在应用重启后保持,减少了重复授权的麻烦,提升了用户体验。这对于需要频繁访问文件系统的应用来说是一个很实用的改进。

开发者工具自动聚焦

当检查元素或触发断点时,开发者工具会自动聚焦。这个功能在 Electron 37、38、39 版本中就已经引入,在 40 版本中继续可用。这个小改进可以显著提升开发体验,让调试过程更加流畅,特别是在使用多个窗口进行开发时。

动态 ESM 导入支持

在非上下文隔离的预加载脚本中支持动态 ESM 导入。这个功能在 Electron 37、38、39 版本中就已经引入,在 40 版本中继续可用。这为开发者提供了更灵活的模块加载方式,特别是在需要条件加载模块的场景中,可以让代码组织更加灵活。

SF Symbol 支持

更新了 nativeImage.createFromNamedImage 以支持 SF Symbol 名称。这个功能在 Electron 39 中就已经引入,在 40 版本中继续完善。SF Symbol 是 macOS 和 iOS 上的系统图标集,这个改进让 Electron 应用能够更好地使用系统原生图标,提供更原生的视觉体验。

视频帧导入支持

新增了将外部共享纹理导入为 VideoFrame 的支持。这是 Electron 40 版本的新功能。这对于需要处理视频内容的媒体应用来说是一个重要的功能增强,可以更好地处理视频流和图像数据。

内存回收机制改进

新增了 "memory-eviction" 作为子进程退出的可能原因。这是 Electron 40 版本的新功能。这有助于开发者更好地诊断和理解应用的内存管理问题,特别是在处理内存压力时能够更准确地定位问题。

网络请求协议处理增强

net.request 中新增了 bypassCustomProtocolHandlers 选项,提供了更细粒度的协议处理控制。这个功能在 Electron 38、39 版本中就已经引入,在 40 版本中继续可用。这让开发者可以更精确地控制网络请求的行为,特别是在处理自定义协议时。

无障碍功能增强

新增了更细粒度的无障碍支持管理方法,帮助开发者更好地实现应用的无障碍功能。这个功能在 Electron 37、38、39 版本中就已经引入,在 40 版本中继续完善。这对于需要满足无障碍标准的应用来说是一个重要的改进。

强调色重置功能

现在可以通过 window.setAccentColor(null) 将强调色重置为跟随系统设置,提供了更灵活的主题控制。这个功能在 Electron 38、39 版本中就已经引入,在 40 版本中继续可用。这让应用可以更好地响应用户的系统主题变化。

破坏性变更

剪贴板 API 访问变更(已弃用)

重要变更:在渲染进程中直接使用剪贴板 API 已被弃用。如果需要在渲染进程中调用剪贴板 API,应该将 API 调用放在预加载脚本中,并使用 contextBridge API 暴露给渲染进程。

这是一个安全相关的变更,建议开发者尽快迁移代码:

// ❌ 不推荐:在渲染进程中直接使用
navigator.clipboard.writeText('text');

// ✅ 推荐:在预加载脚本中使用 contextBridge
// preload.js
const { contextBridge, clipboard } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
  writeText: (text) => clipboard.writeText(text)
});

// renderer.js
window.electronAPI.writeText('text');

这个变更的目的是提高应用的安全性,通过 contextBridge 可以更好地控制渲染进程和主进程之间的通信,减少潜在的安全风险。这与 Electron 39 版本中 ASAR Integrity 稳定化的安全导向是一致的。

macOS dSYM 文件压缩格式变更

macOS 的调试符号(dSYM)文件现在使用 xz 压缩格式,以处理更大的文件大小。dsym.zip 文件现在变成了 dsym.tar.xz 文件。使用调试符号的开发者可能需要更新他们的解压工具以支持 xz 格式。

这个变更主要是为了处理越来越大的调试符号文件,xz 压缩格式提供了更好的压缩率,但需要开发者更新工具链。

与 Electron 39 版本的对比

Electron 40 版本相比 39 版本的主要变化:

  1. 技术栈升级:Chromium 从 142 升级到 144,Node.js 从 22.20.0 升级到 24.11.1,V8 从 14.2 升级到 14.4

  2. 新功能:新增了视频帧导入支持和内存回收机制改进等新功能

  3. 功能延续:许多在 39 版本中引入的功能在 40 版本中继续可用和完善,如硬件加速检测、HDR 支持、Linux 主题色支持等

  4. 破坏性变更:新增了剪贴板 API 访问的弃用警告,这是 40 版本特有的变更

  5. 安全改进:延续了 39 版本中 ASAR Integrity 稳定化的安全导向,进一步强化了应用安全性

版本支持情况

根据 Electron 的支持策略,Electron 37.x.y 已经到达支持结束(End-of-Support)。开发者和应用应该尽快升级到更新的版本。

当前支持的版本矩阵:

E40 (Jan'26) E41 (Mar'26) E42 (May'26)
40.x.y 41.x.y 42.x.y
39.x.y 40.x.y 41.x.y
38.x.y 39.x.y 40.x.y

对比 Electron 39 版本发布时的支持矩阵(E39、E40、E41),可以看到版本支持策略保持稳定,Electron 团队通常同时维护三个主要版本,每个版本大约支持 6 个月。这意味着开发者需要保持相对频繁的升级节奏,以确保应用的安全性和兼容性。

升级建议

对于正在使用 Electron 的开发者,建议:

  1. 及时升级:如果使用的是 Electron 37 或更早版本,应该尽快升级到 Electron 40。Electron 37 已经到达支持结束,不再接收安全更新。如果正在使用 Electron 39,也可以考虑升级到 40 版本以获得最新的技术栈升级。

  2. 检查破坏性变更:特别注意剪贴板 API 的使用,需要迁移到预加载脚本模式。这个变更虽然需要一些代码调整,但对于应用的安全性是有益的。

  3. 充分利用新功能:如果应用涉及视频处理,可以利用新的视频帧导入支持。如果需要更好的内存管理,可以利用新的内存回收机制改进。

  4. 充分测试应用:升级后充分测试应用功能,特别是涉及文件系统、网络请求和主题相关的功能。新版本可能带来一些行为变化,需要仔细验证。

  5. 关注版本发布节奏:从 39 版本到 40 版本的发布间隔来看,Electron 团队保持了稳定的发布节奏。开发者应该建立更灵活的升级和测试流程,以适应这种节奏。

未来展望

Electron 团队将继续专注于跟上主要组件(Chromium、Node.js 和 V8)的开发进度,确保 Electron 应用能够使用最新的 Web 技术和性能优化。

从 Electron 39 到 40 的版本迭代可以看出,Electron 团队在保持稳定发布节奏的同时,也在不断引入新技术栈升级和新功能。特别是 Node.js 24 的升级,显示了 Electron 团队对最新技术的积极采用。开发者需要适应这种节奏,建立更灵活的升级和测试流程。

更多关于未来变更的信息可以在 Planned Breaking Changes 页面找到。

安装和使用

可以通过以下方式安装 Electron 40.0.0:

npm install electron@latest

或者从 Electron 发布网站下载。

Electron 40.0.0 的发布标志着 Electron 生态系统的又一次重要进步。通过升级核心技术栈和引入新功能,Electron 继续为跨平台桌面应用开发提供强大的支持。虽然版本迭代速度较快可能会给开发者带来一些压力,但这也意味着能够更快地获得最新的技术和安全更新。开发者应该关注这些更新,特别是破坏性变更,以确保应用的稳定性和安全性。

对于刚刚熟悉 Electron 39 版本的开发者来说,40 版本的发布提供了一个很好的机会来评估是否需要升级,以及如何利用新版本的功能来改进应用。

破局 AI 幻觉:构建以 NoETL 语义编织为核心的 AI 就绪数据架构

2026年1月21日 17:07

企业部署大模型分析应用时,常遭遇“幻觉”困扰——AI 输出的数据结论看似合理,实则错误。根源在于传统数据架构无法为 AI 提供准确、一致、实时、可信的数据供给。破局之道在于构建以 NoETL 语义编织为核心的 AI 就绪数据架构。该架构通过创建“统一指标语义层”作为业务与数据间的“标准协议”,并采用 NL2MQL2SQL 技术路径,确保大模型生成 100% 准确的 SQL 查询,从根本上杜绝“数据幻觉”,赋能可信的智能决策。

传统数据架构为何成为 AI“幻觉”的温床?

当大模型(LLM)接入企业数据时,传统数据架构的固有缺陷被急剧放大,成为制造“数据幻觉”的系统性风险源。

  1. 数据孤岛与指标歧义:混乱的源头 企业内通常存在多套独立系统(CRM、ERP、财务软件等),导致同一业务指标(如“销售额”)在不同系统中的定义、计算口径和取数逻辑各不相同。当大模型从这些矛盾的数据源中检索信息时,必然输出逻辑混乱、结论错误的回答。指标口径不统一,是 AI 产生幻觉的首要原因。

  2. “黑盒”式数据访问:错误的催化剂 主流 NL2SQL 方案让大模型直接理解原始数据库的复杂 Schema(表结构、关联关系),并生成 SQL。这要求 AI 具备数据库专家的知识,无异于“盲人摸象”。结果常出现:错误的表连接、误解的业务逻辑、性能低下的查询。生成的错误数据难以追溯和调试,幻觉在查询阶段就已注定。

  3. 僵化的数据供给:失效的决策 基于 ETL 的批处理数据管道,开发周期长达数周甚至数月。当业务人员提出一个临时、跨域的分析需求时,数据无法及时就绪。AI 基于过时、片面的数据进行分析,必然滞后于市场变化,丧失决策时效性。

  4. 可信度与安全缺失:不可逾越的鸿沟 分析结果缺乏透明的数据血缘,管理者无法信任其来源。同时,直接向 AI 开放数据库查询权限,缺乏在查询生成过程中的动态权限校验,极易导致敏感数据泄露。

让大模型在“数据迷雾”中工作,幻觉是必然产出。 要获得可信 AI,必须先解决数据架构的“可信”问题。

NoETL 数据语义编织——AI 就绪的数据架构范式

NoETL 数据语义编织是一种创新的数据架构范式,其核心是构建一个介于原始数据与 AI 应用之间的“翻译层”与“契约层”。

  1. 核心组件:统一指标语义层 这是整个架构的基石与中枢。它使用业务语言(如“毛利率”、“月活跃用户”)明确定义每一个指标的计算公式、数据来源、关联维度及刷新周期。它成为企业唯一可信的“数据事实源”,确保在任何场景(AI 查询、BI 报表、API 服务)下,同一指标的计算逻辑绝对一致,从根本上消灭了指标歧义,为 AI 提供了清晰、无矛盾的指令集。

  2. 工作原理:从“搬运”到“编织”

  • 传统 ETL 模式:通过复杂的代码,将数据从源头“搬运”到数仓,过程僵化,变更成本高。

  • NoETL 语义编织:

    1. 虚拟接入:通过逻辑数据编织平台,以虚拟化方式连接全域数据源,无需物理搬迁。
    2. 自动转化:系统自动扫描数据源,将技术元数据(如sales_db.orders.amount)与语义层的业务术语(如“订单金额”)关联。
    3. 动态查询:形成一张全局可查询的“语义网络”。用户和 AI 只需与这张网络交互,完全屏蔽底层数百张表的复杂性。
  1. 架构优势:敏捷与无侵入 最大的优势在于以逻辑统一替代物理集中。数据准备时间从“数月”缩短至“数周”,并能随时根据业务变化调整语义逻辑,实现低成本、高敏捷的响应。

基于 NoETL 语义编织的可信 Data Agent

基于 NoETL 语义层,可构建可信的 Data Agent(数据智能体)。其核心技术路径为 NL2MQL2SQL ,这是区分“玩具”与“企业级”AI 分析的关键。

三步实现 100% 准确查询:

  1. NL2MQL(自然语言→指标查询语言):用户问:“上海地区 Q3 的销售毛利率如何?”大模型理解意图后,依据语义层,输出标准化的 MQL。例如:{“metric”: “gross_profit_margin”, “filters”: {“city”: “上海”, “quarter”: “Q3”}}。MQL 指向的是已定义的、无歧义的指标。
  2. MQL2SQL(指标查询语言→SQL):语义层引擎(规则驱动)接收 MQL,像编译器一样,根据预定义的指标逻辑(如gross_profit_margin = (revenue - cost) / revenue),确定性地生成优化后的 SQL。此步骤由规则保障,彻底杜绝大模型生成错误 SQL 的可能。
  3. 执行与返回:引擎通过智能路由与加速技术,高效执行 SQL,将结果返回给大模型进行解读与呈现。

构建分析决策闭环: 在此可信数据基础上,Data Agent 能实现更高级的能力:

  • 智能归因:面对“利润率为何下降?”的提问,能自动进行多维度(产品、渠道、地区)下钻,定位核心影响因子。
  • 智能报告:对“准备季度经营分析”等复杂指令,能自动规划分析框架,整合数据、洞察与建议,生成结构化报告。
  • 场景化助手:企业可为不同部门(财务、营销、供应链)配置专属助手,每个助手基于同一语义层,但拥有不同的数据权限和知识上下文,实现安全、合规的数据民主化。

NL2MQL2SQL 通过在 AI 与数据之间引入“语义层”这一关键中间件,在准确性与灵活性上取得了根本平衡,是企业构建可信数据智能的基石路径。

常见疑问(FAQ)

Q1: 与传统的数据仓库或数据湖相比,NoETL 数据语义编织架构最大的优势是什么?

传统数仓/湖依赖沉重的、周期长的 ETL 管道“搬运”和“固化”数据,变更成本高。NoETL 架构通过虚拟化和语义层,无需大规模物理搬迁数据,并能提供逻辑统一的实时数据视图,使数据准备时间从数月缩短至数周,并能灵活响应不断变化的业务分析需求。

Q2: 引入 NoETL 和 Data Agent,企业数据团队的角色会发生怎样的变化?

数据团队的工作重心将从繁琐的“需求响应”(写 SQL、做报表)向更高价值的“数据资产管理与赋能”转变。 团队将更专注于:1、设计和维护统一、标准的指标语义层;2、治理数据质量与安全;3、培训和配置业务部门的场景化分析助手。这释放了数据团队的生产力,聚焦于数据战略和创新。

Q3: 如何衡量一个数据架构是否真正达到了“AI-Ready”的标准?

可以参考“三真三好”的可信 AI 标准进行评估:三真即口径真(指标全局一致)、数据真(来源可靠、质量可控)、血缘真(计算逻辑全程可追溯);三好即听力好(准确理解自然语言意图)、眼力好(能进行多维度、深层次的洞察与归因)、脑力好(能整合信息,形成决策建议与报告)。满足这些标准的数据架构,才能支撑起可信、有用的企业级 AI 应用。

未来展望:

以 NoETL 语义编织为核心的 AI 就绪架构,不仅是解决当前 AI 幻觉问题的方案,更是面向未来“数据智能时代”的基础设施。它将使数据以一种更自然、更可靠的方式服务于每一位决策者,真正实现“数据驱动”从口号到现实的跃迁。企业越早构建这一架构,就越能在智能化竞争中占据先机。

TESOLLO小巧轻便灵巧手“DG-5F-S”发布

作者 爱迪斯通
2026年1月21日 17:05

机器人手爪专家Tesollo宣布,已经开发出“DG-5F-S”,这是一种新型人形机器人手,是其现有旗舰产品的紧凑和轻便版本。该产品计划于今年上半年正式推出,原型将在CES 2026上首次亮相。

DG-5F-S的特点是其紧凑和轻便的设计,通过推进Tesollo用于机器人手的内部致动器技术实现,同时保持现有模型的核心结构。

与现有的DG-5F一样,DG-5F-S保留了五个手指,20个自由度(DoF)的结构,应用了相同的类人配置,其中五个手指中的每个手指都由四个关节独立驱动。这增强了人形机器人所需的精确操纵性能和敏捷性。

图片1.png

1公斤以下的超轻设计和接近成年人手的紧凑尺寸,使得DG-5F-S可以自然集成到各种人形机器人平台中。此外,根据客户要求,其支持扩展选项,如触觉传感器集成、防水涂层和操作算法的定制,从而实现从研究使用到实际过程部署的广泛适用性。

通过采用直接驱动机制,DG-5F-S可最大限度地减少反冲,并提供高位置精度和直观的控制环境。此外DG-5F-S还可以通过算法稳定地抓取和操纵各种形状和材料的物体,并通过支持在工业现场的通信协议来提高可用性。

Tesollo预计价格将设定在低DG-5F更低的入门水平,以大大减轻初创公司、研究机构和中小型企业的采用负担。

此前,Tesollo于2024年推出了20自由度人形手DG-5F,该产品搭载其自主开发的致动器技术,并于同年在智能机器人与系统国际会议(IROS)上首次亮相。DG-5F目前已出口到全球16个国家,证明了其技术竞争力和广阔市场。

VSCode 如何断点调试 uv:`uv run langchain serve` - 前端学 FastAPI 系列

作者 Legend80s
2026年1月21日 16:50

How to debug `uv run ...` python program in VSCode

想要调试下 FastAPI 中 sqlmodel(底层是 sqlalchemy)是如何通过主键 id 获取一个数据库记录的:

@app.get("/heroes/{hero_id}")
def read_hero(hero_id: int, session: SessionDep) -> HeroPublic:
    hero = session.get(Hero, hero_id)

    if not hero:
        raise HTTPException(status_code=404, detail="Hero not found")

    return HeroPublic.model_validate(hero)

今天尝试了很久才成功在 uv run langchain serve 运行的 python 程序中打断点。当然 Trae 等 VSCode IDE 一律可用。

👩‍🏫 抄作业

.vscode/launch.json

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "✅ `uv run langchain serve` debugger",
      "type": "debugpy",
      "request": "launch",
      "program": "${workspaceFolder}/.venv/Scripts/langchain.exe",
      "args": ["serve"],
      "console": "integratedTerminal",
      "justMyCode": false,
      "cwd": "${workspaceFolder}",
      "env": {
        "PYTHONPATH": "${workspaceFolder}"
      }
    }
  ]
}

说明

  1. 因为我们想调试包源码,故 "justMyCode": false
  2. langchain 位置如何确定?首先进入项目根目录且确保虚拟环境已经启动:
which langchain 
/f/workspace/github/my-app/.venv/Scripts/langchain

or

❯ uv run which langchain
/f/workspace/github/my-app/.venv/Scripts/langchain

注意 Windows 需要加 .exe "program": "${workspaceFolder}/.venv/Scripts/langchain.exe", 否则报错:

FileNotFoundError: [Errno 2] No such file or directory: 'F:\workspace\github\my-app\.venv\Scripts\langchain'

🐞 开启调试

已 Trae 为例:打断点 → 然后点击左侧 Bug 小虫子 🐞 标志 → 下拉框选择 uv run langchain serve debugger → 点击右侧绿色小虫子(Start Debugging)或直接 F5F5 开启调试,日志如下:

❯  cd F:\\workspace\\github\\my-app ; /usr/bin/env f:\\workspace\\github\\my-app\\.venv\\Scripts\\python.exe c:\\Users\\liuchuanzong\\.trae-cn\\extensions\\ms-python.debugpy-2025.18.0-win32-x64\\bundled\\libs\\debugpy\\launcher 54274 -- F:\\workspace\\github\\my-app/.venv/Scripts/langchain.exe serve
INFO:     Will watch for changes in these directories: ['F:\\workspace\\github\\my-app']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [18892] using StatReload
INFO:     Started server process [16184]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

触发

❯ curl -s http://localhost:8000/heroes/1 | jq

{
  "name": "legend80s",
  "age": null,
  "id": 1
}

可以看到我们的程序断在了我们刚刚打的断点处。

💭 感想

还是 DeepSeek 帮我解决了问题,Kimi 2 胡说八道,社区方案并不可信,uv 官方这个 issue Running uv scripts in debug mode #8558 一直是 open,还在等着 VSCode 官方解决 🐒🐎 🐌。

更多有用文章请关注「JavaScript与编程艺术」。

🚀 告别繁琐配置!这款 Vue 云上传组件让文件上传变得如此简单

2026年1月21日 16:44

🚀 告别繁琐配置!这款 Vue 云上传组件让文件上传变得如此简单

前端开发中,文件上传功能几乎是每个项目都绕不开的需求。但你是否也曾为对接腾讯云COS、华为云OBS、阿里云OSS而头疼?是否也曾为分片上传、断点续传、进度显示等功能而熬夜加班?

今天,我要向大家推荐一款开箱即用、功能强大的 Vue 云上传组件 —— vue-cloud-upload,它将彻底改变你对文件上传的认知!

✨ 为什么选择 vue-cloud-upload?

🎯 痛点一:三大云平台 SDK 对接繁琐

传统做法:

  • 需要分别学习腾讯云、华为云、阿里云的 SDK 文档
  • 每个平台的 API 调用方式各不相同
  • 临时凭证获取逻辑需要自己实现
  • 代码冗余,维护成本高

vue-cloud-upload 的解决方案:

<template>
  <CloudUpload
    cloudType="tencent"
    :cloudConfig="cloudConfig"
    v-model="fileList"
    @success="handleSuccess"
  />
</template>

<script>
import COS from 'cos-js-sdk-v5';
import CloudUpload, { setExternalCOS } from 'vue-cloud-upload';

setExternalCOS(COS);

export default {
  data() {
    return {
      cloudConfig: {
        bucket: "your-bucket",
        region: "ap-guangzhou",
        path: "uploads/",
        getTempCredential: this.getTempCredential
      }
    };
  }
};
</script>

只需三步:

  1. 安装对应云平台的 SDK
  2. 配置云平台参数
  3. 引入组件即可使用!

🎯 痛点二:大文件上传体验差

传统做法:

  • 大文件上传容易失败
  • 网络波动需要重新上传
  • 用户无法看到上传进度
  • 用户体验极差

vue-cloud-upload 的解决方案:

  • 自动分片上传:大文件自动切分成小块上传
  • 断点续传:网络中断后可继续上传,无需重新开始
  • 实时进度显示:上传进度实时更新,用户一目了然
  • 分片大小可配置:根据网络环境灵活调整

🎯 痛点三:文件预览功能缺失

传统做法:

  • 上传后只能看到文件名
  • 无法预览图片、PDF、视频等内容
  • 需要额外开发预览功能
  • 增加开发成本

vue-cloud-upload 的解决方案:

  • 📸 图片预览:支持图片缩放、旋转、全屏查看
  • 📄 PDF 预览:直接在线查看 PDF 文档
  • 🎬 视频播放:内置视频播放器,支持在线播放
  • 🎵 音频播放:支持音频文件在线播放
  • 📝 TXT 预览:文本文件直接查看内容

🌟 核心特性一览

1️⃣ 三大云平台无缝对接

  • 🅰️ 腾讯云 COS
  • 🅱️ 华为云 OBS
  • 🅾️ 阿里云 OSS

2️⃣ 丰富的功能特性

功能 说明
多文件上传 支持同时上传多个文件
拖拽上传 支持拖拽文件到上传区域
文件类型限制 可限制上传文件类型
文件大小限制 可限制单个文件大小
上传进度显示 实时显示上传进度
文件列表管理 支持查看、删除已上传文件
附件回显 支持通过文件 key 回显附件
自定义样式 支持自定义上传组件样式
丰富的事件回调 支持上传成功、失败、进度等事件

3️⃣ 灵活的配置选项

cloudConfig: {
  bucket: "your-bucket",           // 桶名
  region: "ap-guangzhou",          // 地域
  path: "uploads/",                // 上传目录
  getTempCredential: async () => { // 获取临时凭证
    const response = await fetch('/api/sts');
    return await response.json();
  }
}

4️⃣ 多种文件 key 生成策略

  • uuid:使用 UUID 生成唯一文件名
  • name:使用原始文件名
  • uuid+name:使用 UUID + 原始文件名(默认)
  • customKey:自定义函数生成文件 key

📦 快速开始

安装组件

npm install vue-cloud-upload

安装对应云平台 SDK

# 腾讯云 COS
npm install cos-js-sdk-v5

# 华为云 OBS
npm install esdk-obs-browserjs

# 阿里云 OSS
npm install ali-oss

基础使用示例

<template>
  <div>
    <CloudUpload
      cloudType="tencent"
      :cloudConfig="cloudConfig"
      v-model="fileList"
      :multiple="true"
      :limit="5"
      :maxSize="100"
      @success="handleSuccess"
      @error="handleError"
      @progress="handleProgress"
    />
  </div>
</template>

<script>
import COS from 'cos-js-sdk-v5';
import "vue-cloud-upload/dist/vue-cloud-upload.css";
import CloudUpload, { setExternalCOS } from 'vue-cloud-upload';

setExternalCOS(COS);

export default {
  components: { CloudUpload },
  data() {
    return {
      fileList: [],
      cloudConfig: {
        bucket: "your-bucket",
        region: "ap-guangzhou",
        path: "uploads/",
        getTempCredential: this.getTempCredential
      }
    };
  },
  methods: {
    async getTempCredential() {
      const response = await fetch('/api/sts');
      return await response.json();
    },
    handleSuccess(result, file) {
      console.log('上传成功:', result.url);
    },
    handleError(error, file) {
      console.error('上传失败:', error);
    },
    handleProgress(percent, file) {
      console.log('上传进度:', percent);
    }
  }
};
</script>

🎨 功能演示

各类文件上传

各类型文件上传.png

上传进度展示

上传进度.png

丰富的参数配置

参数配置.png

视频预览

视频预览.png

图片预览

图片预览.png

PDF 预览

pdf预览.png

💡 实战场景

场景一:企业级文件管理系统

<CloudUpload
  cloudType="aliyun"
  :cloudConfig="cloudConfig"
  v-model="fileList"
  :multiple="true"
  :limit="10"
  :maxSize="500"
  listType="picture-card"
  :previewConfig="{
    image: true,
    pdf: true,
    video: true,
    audio: true
  }"
/>

场景二:图片上传组件

<CloudUpload
  cloudType="tencent"
  :cloudConfig="cloudConfig"
  v-model="imageList"
  accept=".jpg,.jpeg,.png,.gif"
  :maxSize="10"
  listType="picture-card"
  :keyType="'uuid'"
/>

场景三:文档上传组件

<CloudUpload
  cloudType="huawei"
  :cloudConfig="cloudConfig"
  v-model="docList"
  accept=".pdf,.doc,.docx,.xls,.xlsx"
  :maxSize="50"
  listType="text"
/>

🔮 未来规划

组件正在持续迭代中,以下功能正在开发中:

  • 🔄 图片添加水印
  • 🔄 图片无损压缩
  • 🔄 视频首帧截取
  • 🔄 Office 文档在线预览(Word, Excel, PowerPoint)
  • 🔄 更多云存储平台支持

📊 项目数据

  • ⭐ GitHub Stars:持续增长中
  • 📦 NPM 下载量:月下载量稳步上升
  • 🎯 支持平台:腾讯云、华为云、阿里云
  • 📝 文档完善度:详细的使用文档和示例
  • 🐛 问题响应:快速响应和修复

🤝 贡献与支持

如果你觉得这个组件对你有帮助,欢迎:

  • 给项目一个 ⭐️ Star
  • 提交 Issue 和 Pull Request
  • 分享给你的同事和朋友

📚 完整文档

更多详细的使用文档和 API 说明,请查看:

📧 联系方式

商务合作请通过邮箱联系:shazhoulen@outlook.com


vue-cloud-upload —— 让文件上传变得更简单!

如果你正在为文件上传功能而烦恼,不妨试试这个组件,相信它会给你带来惊喜!🎉


相关推荐:

❌
❌