普通视图

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

你不知道的 TypeScript:模板字符串类型

2026年1月21日 22:49

大部分前端应该都或多或少地使用过 TypeScript 开发,但是此文将带你探索少有人了解的 TS 领域:模板字符串类型。

这样的类型操作你见过吗?

type World = 'World'
type Greeting = `Hello ${World}` // "Hello World"

type UserName = 'cookie'
type UserNameCapitalize = Capitalize<UserName> // "Cookie"

type ButtonVariant = `btn-${'primary' | 'secondary'}-${'sm' | 'lg'}`
// "btn-primary-sm" | "btn-primary-lg" | "btn-secondary-sm" | "btn-secondary-lg"

看起来不可思议,但是这些都是 TypeScript 模板字符串类型的能力。

模板字符串类型(Template Literal Types) 是 TypeScript 4.1 引入的高级特性。它建立在字符串字面量类型的基础上,允许你通过类似 JavaScript 模板字符串的语法,动态地组合、操作和生成新的字符串类型。

接下来,我将从字符串字面量类型开始,逐步讲解到模板字符串类型的初级到高级的用法。

一、基础:什么是字符串字面量类型?

1. 定义

字符串字面量类型是指将一个具体的字符串值作为一种类型

// 普通的 string 类型
let s1: string = 'hello'
s1 = 'world' // 正确

// 字符串字面量类型
let s2: 'hello' = 'hello'
s2 = 'world' // 报错:不能将类型"world"分配给类型"hello"

2. 联合类型 (Union Types)

字符串字面量类型最常见的用法是配合联合类型使用,限制变量只能是某几个特定字符串之一。

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
function request(method: HttpMethod, url: string) {
  // ...
}
request('GET', url) // 正确
request('LET', url) // 报错:类型"LET"的参数不能赋给类型"HttpMethod"的参数。

二、进阶:模板字符串类型

1. 基础拼接

就像 JavaScript 中的模板字符串 ${var} 一样,TypeScript 类型也可以通过反引号 ``${} 配合实现字符串类型的插值。

// 字符串拼接
type World = 'World'
type Greeting = `Hello ${World}` // 类型 "Hello World"

// 多个插值
type Protocol = 'https'
type Domain = 'example.com'
type URL = `${Protocol}://${Domain}` // 类型 "https://example.com"

// 与其他类型结合
type Version = 1 | 2 | 3
type APIVersion = `v${Version}` // "v1" | "v2" | "v3"

在这里提醒一下大家,一定不要把类型和值搞混,这里操作的是类型,不要把它当做值去操作。一些错误示例:

console.log(World) // 报错:"World"仅表示类型,但在此处却作为值使用。
type Greeting = "Hello" + World // 报错:"World"仅表示类型,但在此处却作为值使用。

2. 联合类型的自动分发(笛卡尔积)

在模板字符串中,把多个联合类型组合在一起时,TypeScript 会自动生成所有可能的组合,也就是按笛卡尔积(Cartesian Product)组合。

type Space = 'sm' | 'md' | 'lg'
type Side = 't' | 'b' | 'l' | 'r'
type PaddingClass = `p-${Side}-${Space}`
// "p-t-sm" | "p-t-md" | "p-t-lg" | "p-b-sm" | "p-b-md" | "p-b-lg" | "p-l-sm" | "p-l-md" | "p-l-lg" | "p-r-sm" | "p-r-md" | "p-r-lg"

// 实际使用
function addPadding(el: HTMLElement, className: PaddingClass) {
  el.classList.add(className)
}
const div = document.createElement('div')
addPadding(div, 'p-t-sm') // 正确
addPadding(div, 'p-x-xx') // 类型错误

性能提示:当联合类型的成员数量较多时,组合后的类型数量会呈指数级增长(例如 3 个联合类型各有 10 个成员,组合后会有 1000 种可能),这可能会导致 TypeScript 编译器性能下降或类型检查变慢。

3. 内置工具类型

为了性能和方便,TypeScript 编译器内置了四个特殊的工具类型。它们不是通过 TS 代码实现的,而是直接由编译器处理。

  • Uppercase<S> 将字符串中的每个字符转换为大写
  • Lowercase<S> 将字符串中的每个字符转换为小写
  • Capitalize<S> 将字符串的第一个字符转换为大写
  • Uncapitalize<S> 将字符串的第一个字符转换为小写

下面是使用示例:

// Uppercase:全部转大写
type Color = 'red'
type UpperColor = Uppercase<Color> // "RED"

// Lowercase:全部转小写
type MixedCase = 'TypeScript'
type LowerCase = Lowercase<MixedCase> // "typescript"

// Capitalize:首字母大写
type Name = 'cookie'
type CapName = Capitalize<Name> // "Cookie"

// Uncapitalize:首字母小写
type Components = 'Button' | 'Input' | 'Modal'
type UncapComponents = Uncapitalize<Components> // "button" | "input" | "modal"

结合模板字符串使用:

// 生成事件处理器名称
type Events = 'click' | 'change' | 'input'
type EventHandlers = `on${Capitalize<Events>}`
// "onClick" | "onChange" | "onInput"

// 生成 CSS 类名
type Size = 'sm' | 'MD' | 'Lg'
type SizeClass = `size-${Lowercase<Size>}`
// "size-sm" | "size-md" | "size-lg"

三、 高阶:模式匹配与 infer

掌握了基础的模板字符串拼接后,接下来我们进入更强大的领域——模式匹配。要在类型中解析字符串(例如提取参数、去掉空格),我们需要结合 extendsinfer

1. 什么是 infer

infer 属于 TS 的高阶用法,它需要配合条件语句一起使用:

A extends B ? C : D

含义是:如果类型 A 可以赋值给类型 B,则结果为 C,否则为 D。

infer 的作用是在条件类型的 extends 子句(也就是 B 语句)中 声明一个待推断的类型变量。可以把它理解为"占位符",让 TypeScript 帮你从某个复杂类型中"提取"出一部分。当类型匹配成功时,infer 声明的类型变量会被推断为匹配到的具体类型。

举一些实用的例子:

// 获取 Promise 返回值的类型
type UnpackPromise<T> = T extends Promise<infer R> ? R : T
type P1 = UnpackPromise<Promise<string>> // string

// 获取数组中元素类型
type GetArrayType<T> = T extends (infer U)[] ? U : never
type A1 = GetArrayType<number[]> // number

// 获取函数的返回值类型
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never
type R1 = GetReturnType<() => boolean> // boolean

2. 在模板字符串类型中使用 infer

在模板字符串中 infer 的使用方法同上,也需要配合条件语句,区别是我们需要通过 ${infer T} 把它插入到模板字符串类型中,通过模式匹配来提取字符串的特定部分。

比如提取出字符串类型空格后面的部分。

type GetSurname<S> = S extends `${infer First} ${infer Last}` ? Last : never

type T1 = GetSurname<'Hello Cookie'> // "Cookie"
type T2 = GetSurname<'Cookie'> // never (因为没有空格,匹配失败)

3. 贪婪与非贪婪匹配

多个 infer 连用,TypeScript 使用 “非贪婪” 匹配策略,也就是会遵循 “从左到右,尽可能匹配最小单位” 的原则。

比如,当使用 ${infer A}${infer B} 时:

  • A(第一个 infer):匹配第一个字符(最短匹配)
  • B(第二个 infer):匹配剩余所有字符(由于 A 已经匹配了第一个字符,B 必须匹配剩余的全部内容)

举例说明:

// 三个连续的 infer 会依次匹配:A 匹配第一个字符,B 匹配第二个字符,C 匹配剩余所有字符
type Split<S> = S extends `${infer A}${infer B}${infer C}` ? [A, B, C] : []
type Res = Split<'Hello'> // ["H", "e", "llo"]

// 有占位符的情况,也只会匹配到第一个
type GetExt<S> = S extends `${infer Name}.${infer Ext}` ? Ext : never
type E2 = GetExt<'ts.config.json'> 
// "config.json"(Name 匹配到第一个点之前,Ext 获取之后的所有内容)

4. infer 与联合类型联动

当模式中包含联合类型时,TypeScript 会尝试匹配联合类型中的任意一个成员:

// 提取指定几种扩展类型的文件名
type ImageExt = 'jpg' | 'png' | 'gif' | 'webp'
type ExtractRawName<FileName> = FileName extends `${infer Name}.${ImageExt}`
  ? Name
  : never

type E1 = ExtractRawName<'photo.jpg'> // "photo"
type E2 = ExtractRawName<'logo.png'> // "logo"
type E3 = ExtractRawName<'document.pdf'> // never (pdf 不在 ImageExt 联合类型中)

TypeScript 会对联合类型中的每一个成员分别执行 infer 逻辑,最后再把结果重新组合成一个新的联合类型,在模板字符串中也一样。

type IconNames = 'icon-home' | 'icon-user' | 'icon-settings' | 'logo-main'

// 提取所有以 "icon-" 开头的图标名称
type ExtractIcon<T> = T extends `icon-${infer Name}` ? Name : never

type PureNames = ExtractIcon<IconNames>
// 结果: "home" | "user" | "settings"
// 注意: "logo-main" 匹配失败,返回 never,在联合类型中 never 会被自动过滤掉

5. 递归类型 (Recursive Types)

在上一节的基础上,我们再结合递归,可以更加灵活的处理字符串,接下来以 TrimLeft 举例:

目标:去除字符串左边的空格。

type Space = ' ' | '\n' | '\t' // 联合类型 包含三种空白字符

// 如果第一个字符是空白字符,就取除了第一个空白字符的剩余字符串,然后递归处理
// 否则直接取整个字符
type TrimLeft<S extends string> = S extends `${Space}${infer R}`
  ? TrimLeft<R> // 递归调用
  : S // 终止

type T = TrimLeft<'  cookie'> // "cookie"

如果你在纠结为什么不是 \s 而是 ' '?这是因为 TypeScript 的模板字符串类型不支持正则表达式语法。这里的 ' ''\n''\t' 都是具体的字符串字面量类型,而 \s 是正则表达式的特殊语法,在类型系统中没有意义。

四、 映射类型与模板字符串的结合

TypeScript 4.1 不仅引入了模板字符串类型,还支持在映射类型中使用 as 重命名键名。

1. as 语法

type MappedType<T> = {
  [K in keyof T as NewKeyType<K>]: T[K]
}

在之前《面试官:请实现 TS 中的 Pick 和 Omit》一文中,在 Omit 的实现中就用到了 as 来剔除一些类型:

type MyOmit<T, K> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};

type Todo = {
  title: string
  description: string
  completed: boolean
}
type TodoWithoutDescription = MyOmit<Todo, 'description'>
/*
type TodoWithoutDescription = {
  title: string
  completed: boolean
}
*/

2. 在模板字符串中使用

示例 1:添加前缀/后缀

type AddPrefix<T, Prefix extends string> = {
  [K in keyof T as `${Prefix}${string & K}`]: T[K]
}

interface User {
  name: string
  age: number
}

type PrefixedUser = AddPrefix<User, 'user_'>
// { user_name: string; user_age: number }

为什么要 string & K

因为 K 的类型是 keyof T,可能是 string | number | symbol。用交叉类型 & 将其约束为 string

示例 2:生成 Getter/Setter

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (val: T[K]) => void
}

interface State {
  count: number
  name: string
}

type StateGetters = Getters<State>
// { getCount: () => number; getName: () => string }

type StateSetters = Setters<State>
// { setCount: (val: number) => void; setName: (val: string) => void }

示例 3:移除特定前缀

type RemovePrefix<T, Prefix extends string> = {
  [K in keyof T as K extends `${Prefix}${infer Rest}` ? Rest : K]: T[K]
}

interface ApiResponse {
  api_name: string
  api_token: string
  userId: number
}

type CleanResponse = RemovePrefix<ApiResponse, 'api_'>
// { name: string; token: string; userId: number }

// 注意:传入空字符串作为前缀时,由于空字符串会匹配所有键名,但实际上不会移除任何内容
type CleanResponse1 = RemovePrefix<ApiResponse, ''>
// { api_name: string; api_token: string; userId: number }

五、 总结

写这篇文章因为我在刷 TypeScript 类型体操 时,遇到了第一个模板字符串类型的题目 TrimLeft 搜了半天没有发现现成的文章,干脆自己写一个。

如果你也对 TypeScript 类型体操感兴趣,欢迎一起来刷!💪🏻💪🏻💪🏻

昨天 — 2026年1月21日技术

GDAL 实现影像裁剪

作者 GIS之路
2026年1月21日 21:08

^ 关注我,带你一起学GIS ^

前言

GDAL作为地理空间数据处理的核心工具,提供了多种影像裁剪方式,可以方便的提取目标区域遥感影像数据,为数据处理和分析提供高效服务。

在地理空间数据处理中,影像裁剪是基础且高频的操作,其核心目标是从整幅遥感影像或栅格数据中提取指定地理范围的子集,以降低数据体量、聚焦研究区域,满足专题分析、地图制作、数据共享等多样化业务需求。

由于本文由一些前置知识,在正式开始之前,需要你掌握一定的Python开发基础和GDAL的基本概念。在之前的文章中讲解了如何使用GDAL或者ogr2ogr工具将txt以及csv文本数据转换为Shp格式,可以作为基础入门学习。

本篇教程在之前一系列文章的基础上讲解如何使用GDAL 实现影像裁剪

如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2026年

系统:Windows 11

Python:3.11.11

GDAL:3.11.1

2. 数据准备

俗话说巧妇难为无米之炊,数据就是软件的基石,没有数据,再美好的设想都是空中楼阁。因此,第一步需要下载遥感影像数据。

但是,影像数据在哪里下载呢?别着急,本文都给你整理好了。

数据下载可参考文章:GIS 影像数据源介绍

如下,这是我在【地理空间数据云】平台下载的landsat8遥感影像。

3. 导入依赖

GeoTIFF作为一种栅格数据格式,可以使用GDAL直接进行处理,以实现影像数据的裁剪操作。

在影像裁剪开始之前,需要检查数据路径是否正确,所以导入os模块。

from osgeo import gdal
import os

4. 影像裁剪

定义一个方法image_clip(output_file,input_file,clip_file)用于实现影像数据裁剪。

本研究采用矢量范围提取影像区域,实现栅格数据按掩膜裁剪功能。

"""
说明:GDAL 影像裁剪
参数:
    -output_file:输出裁剪后的影像
    -input_files:输入需要裁剪的的影像
    -clip_file:用于影像裁剪的矢量文件
"""
def image_clip(output_file,input_file,clip_file):

在数据裁剪之前,使用方法checkFilePath检查数据路径。

# 检查文件是否存在
checkFilePath(input_file)
"""
说明:检查文件路径是否正常
参数:
-filePath:文件数据路径
"""
def checkFilePath(filePath):
    if os.path.exists(filePath):
        print(f"{filePath} 文件数据路径存在")
    else:
        print(f"{filePath} 文件数据路径不存在,请检查!")

使用gdal.WarpOptions选项定义裁剪参数,其中参数cutlineDSName为用于提取范围的矢量数据源,cropToCutline为布尔类型,表示是否使用裁剪线作为输出边界,dstNodata为设置NoData值。

# 定义裁剪参数
options = gdal.WarpOptions(
    cutlineDSName=clip_file,
    cropToCutline=True,
    dstNodata=0
)

这个方法参数非常多,感兴趣的同学可以到官网查看。

https://gdal.org/en/stable/api/python/utilities.html#osgeo.gdal.WarpOptions

调用gdal对象方法Warp进行影像裁剪,该函数第一个参数destNameOrDestDS 为输出数据集名称或者数据源,第二个参数srcDSOrSrcDSTab为源数据,第三个参数options为可选项描述,用于定义影像裁剪信息。

gdal.Warp(output_file,input_file,options=options)

main函数中调用合并方法。

if __name__ == "__main__":

    input_file"E:\ArcGIS\band_432.tif"

    output_file"E:\ArcGIS\clip_result.tif"

    clip_file"E:\data\target.shp"

    image_clip(output_file,input_file,clip_file)

注:GDAL某些参数是真难记,难写啊,比如WarpOptions对象。

GIS之路

图片效果

OpenLayers示例数据下载,请在公众号后台回复:vector

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

GDAL 实现影像合并

小小声说一下GDAL的官方API接口

《云南省加快构建现代化产业体系推进产业强省建设行动计划》发布

ArcGIS Pro 添加底图的方式

为什么每次打开 ArcGIS Pro 页面加载都如此缓慢?

ArcGIS 波段合成操作

自然资源部党组关于苗泽等4名同志职务任免的通知

GDAL 创建矢量图层的两种方式

GDAL 实现矢量数据转换处理(全)

GDAL 实现投影转换

GDAL 实现矢量合并

国产版的Google Earth,吉林一号卫星App“共生地球”来了

2026年全国自然资源工作会议召开

GDAL 实现矢量裁剪

GDAL 实现空间分析

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

作者 yyt_
2026年1月21日 20:46

一、Vite 是什么?—— 面向未来的前端构建工具

Vite(法语意为“快”)是由 Vue 作者尤雨溪创建的新型前端构建工具。它利用浏览器原生支持 ES 模块(ESM)的能力,在开发环境下实现了极快的冷启动和热更新;而在生产环境中,则通过预构建依赖和 Rollup 打包输出高性能代码。

vite.config.js 作为 Vite 工程的核心配置文件,定义了整个项目的运行规则、编译逻辑和部署方案,是连接 Vite 核心能力与项目实际需求的桥梁,一份完善的 vite.config.js 能够让前端工程化流程更高效、更规范。

二、核心概念对比:Vite vs Webpack

虽然 Vite 和 Webpack 都用于构建前端应用,但它们的设计哲学完全不同,核心概念的差异直接决定了两者的使用体验和性能表现:

概念 Webpack Vite
Entry(入口) 显式配置 entry 字段,从 JS 入口开始递归解析依赖 默认以 index.html 为入口,开发时按需加载 ESM 模块,生产环境可显式配置 HTML 入口
Chunk(代码块) 构建阶段静态分析生成 chunks 开发时无需生成 chunk,生产构建依托 Rollup 实现动态代码拆分
Loader(转换器) 使用 loader 处理非 JS 资源(如 babel-loader, sass-loader) 无明确 Loader 概念,通过插件机制 + 内置转换器处理特殊资源,更灵活高效
Plugin(插件) 插件监听生命周期钩子扩展功能 插件系统强大,支持开发、构建双模式介入,兼容部分 Rollup 插件
Output(输出) 输出 bundle 到指定目录,需额外配置优化 生产环境输出优化后的静态资源,内置多种打包优化策略,配置更简洁

关键区别:开发与生产环境的差异化处理

Webpack:开发和生产环境均走完整的打包流程,所有模块需提前编译合并为 bundle 文件,项目体积越大,启动和更新速度越慢。

Vite:

  1. 开发环境:基于浏览器 ESM 直接运行,不进行全量打包,仅对浏览器请求的模块进行即时编译,响应速度极快。
  2. 生产环境:使用 Rollup 进行完整打包,产出经过代码压缩、树摇优化、资源分类的静态资源,兼顾性能与兼容性。

这正是 Vite 能够实现“秒级启动”的根本原因,既保证了开发体验,又满足了生产环境的部署要求。

三、vite.config.js 核心模块配置实战

vite.config.js 采用模块化导出方式,支持根据环境动态返回配置,下面将按照功能模块拆解配置逻辑,详细说明各部分的配置目的和实现方式。

模块一:环境初始化与多环境配置

这是配置文件的前置步骤,核心是获取当前环境变量,实现不同环境下的差异化配置,依赖 Vite 内置的 loadEnv 方法。

配置逻辑

  1. 接收 Vite 传入的 mode 参数,该参数对应启动/构建命令中的环境(如 development、production)。
  2. 通过 loadEnv 加载对应环境的配置文件(如 .env.development、.env.production)。
  3. 定义不同环境下的公共路径、输出目录等核心配置,实现环境隔离。

代码实现

import { defineConfig, loadEnv } from 'vite';
import path from 'path';
import rimraf from 'rimraf';

// 生成时间戳,用于生产环境版本隔离
function createFileDate () {
  const today = new Date();
  const y = today.getFullYear();
  const m = today.getMonth() + 1 > 9 ? today.getMonth() + 1 : '0' + (today.getMonth() + 1);
  const d = today.getDate() > 9 ? today.getDate() : '0' + today.getDate();
  const h = today.getHours() > 9 ? today.getHours() : '0' + today.getHours();
  const M = today.getMinutes() > 9 ? today.getMinutes() : '0' + today.getMinutes();
  return y + '' + m + '' + d + '' + h + '' + M;
}

export default ({ mode }) => {
  // 第一步:加载环境变量,指定环境配置文件所在目录
  const env = loadEnv(mode, path.join(process.cwd(), './env'));
  const fileDateDir = createFileDate();
  
  // 第二步:定义多环境核心配置项
  // 生产环境 CDN 公共路径
  const prodPublicPath = `https://yyt.com/resources/ph7/${fileDateDir}/`;
  // 测试环境本地公共路径
  const testPublicPath = '/ph7/';
  
  // 第三步:生产环境前置清理旧构建产物
  if (mode === 'production') {
    rimraf(path.join(process.cwd(), './dist'), (err) => {
      if (err) console.error('清理 dist 目录失败:', err);
    });
  }

  // 返回最终配置
  return defineConfig({
    // 配置公共基础路径,根据环境切换
    base: mode === 'production' ? prodPublicPath : testPublicPath,
    // 其他核心配置...
  });
};

配置说明

  1. loadEnv 第一个参数为环境模式,第二个参数为环境配置文件目录,会自动加载该目录下 .env.${mode} 格式的文件。
  2. 生产环境构建前通过 rimraf 清理旧的 dist 目录,避免旧资源残留导致部署问题。
  3. 公共路径 base 用于配置打包后资源的根路径,生产环境配置 CDN 地址,测试环境配置本地子路径,解决资源 404 问题。

模块二:生产构建配置(build)

该模块是 vite.config.js 的核心之一,用于定义生产环境打包的输出规则、优化策略,所有配置均放在 build 字段下,依托 Rollup 实现打包能力。

配置逻辑

  1. 配置差异化输出目录,实现生产环境版本隔离。
  2. 开启输出目录自动清空,避免手动清理遗漏。
  3. 配置 Rollup 打包参数,包括入口、代码块输出规则、静态资源分类输出规则。
  4. 配置插件实现打包后资源自动拷贝,满足本地部署需求。
  5. 配置 SourceMap 生成规则,兼顾调试与安全。

代码实现

return defineConfig({
  // 其他配置...
  build: {
    // 1. 差异化输出目录:生产环境带时间戳,测试环境简易目录
    outDir: mode === 'production' ? `dist/cdn/${fileDateDir}` : 'dist',
    // 2. 打包前自动清空 outDir 对应的目录
    emptyOutDir: true,
    // 3. 是否生成 SourceMap:开发环境生成,生产环境关闭(安全+减小体积)
    sourcemap: mode === 'development',
    // 4. Rollup 打包详细配置
    rollupOptions: {
      // 配置打包入口:指定 index.html 作为入口文件
      input: {
        main: path.resolve(__dirname, 'index.html')
      },
      // 配置输出规则
      output: {
        // 入口代码块输出规则:输出到 assets/js 目录,添加 hash 后缀
        entryFileNames: 'assets/js/[name]-[hash].js',
        // 公共/异步代码块输出规则:与入口代码块统一目录
        chunkFileNames: 'assets/js/[name]-[hash].js',
        // 静态资源分类输出规则:按文件类型拆分目录
        assetFileNames: ({ name }) => {
          if (name.endsWith('.css')) {
            return 'assets/css/[name]-[hash][extname]';
          }
          if (name.endsWith('.html')) {
            return 'assets/html/[name]-[hash][extname]';
          }
          if (
            name.endsWith('.png') ||
            name.endsWith('.jpg') ||
            name.endsWith('.jpeg') ||
            name.endsWith('.svg')
          ) {
            return 'assets/img/[name]-[hash][extname]';
          }
          if (
            name.endsWith('.xls') ||
            name.endsWith('.xlsx') ||
            name.endsWith('.csv') ||
            name.endsWith('.pdf')
          ) {
            return 'assets/files/[name]-[hash][extname]';
          }
          if (
            name.endsWith('.ttf') ||
            name.endsWith('.eot') ||
            name.endsWith('.woff') ||
            name.endsWith('.otf')
          ) {
            return 'assets/fonts/[name]-[hash][extname]';
          }
          // 默认输出目录
          return 'assets/[name]-[hash][extname]';
        }
      },
      // 5. Rollup 插件配置:打包后资源拷贝
      plugins: [
        copy({
          targets: [
            {
              src: [
                `dist/cdn/${fileDateDir}/json`,
                `dist/cdn/${fileDateDir}/locales`,
                `dist/cdn/${fileDateDir}/index.html`
              ],
              dest: 'dist/local'
            }
          ],
          // 打包完成后执行拷贝
          hook: 'writeBundle',
          // 扁平化目录结构,避免多级嵌套
          flatten: true
        })
      ]
    }
  },
});

配置说明

  1. outDir 定义打包输出目录,生产环境使用带时间戳的目录名,实现版本隔离,防止旧资源缓存导致线上问题。
  2. Vite 生产打包(vite build)时,默认会给静态资源文件(CSS/图片/字体等)添加内容哈希,规则是:文件名格式:[name].[hash].[ext](比如 app.8a3b2.js)。对于普通 JS 文件,需通过配置 entryFileNames/chunkFileNames 手动添加 hash。
  3. assetFileNames 实现静态资源分类,将 CSS、图片、办公文件、字体分别放入对应目录,便于部署和运维排查。
  4. rollupOptions.plugins 中配置 rollup-plugin-copy,在打包完成后将核心资源拷贝到 dist/local,满足本地测试部署需求。

模块三:静态资源扩展配置(assetsInclude)

Vite 有默认支持的静态资源类型,对于一些特殊格式的文件(如 xlsx、pdf),需要通过 assetsInclude 扩展识别,确保打包时能正确处理这些资源。

配置逻辑

  1. 以数组形式列出需要扩展的静态资源后缀。
  2. 配置在顶层字段中,全局生效。

代码实现

return defineConfig({
  // 其他配置...
  // 扩展静态资源类型识别
  assetsInclude: [
    '**/*.xlsx',
    '**/*.xls',
    '**/*.csv',
    '**/*.pdf',
    '**/*.png',
    '**/*.jpg',
    '**/*.svg'
  ],
});

配置说明

  1. 通配符 **/ 表示匹配所有目录下的对应文件。
  2. 配置后,这些特殊格式文件可以通过 import 引入,打包时会按照 build.rollupOptions.output 中的规则输出到对应目录。

模块四:插件配置(plugins)

插件是 Vite 扩展功能的核心载体,通过配置不同插件,可以实现 Vue 解析、JSX 支持、HTML 优化等功能,所有插件配置在 plugins 数组中,按需求引入并初始化。

配置逻辑

  1. 安装所需插件(如 @vitejs/plugin-vue)。
  2. 在配置文件中导入插件。
  3. plugins 数组中初始化插件,传入必要的配置参数。

代码实现

import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import { createHtmlPlugin } from 'vite-plugin-html';
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';

return defineConfig({
  // 其他配置...
  plugins: [
    // 1. 解析 Vue 单文件组件(.vue),Vue 项目必备
    vue({
      template: {
        transformAssetUrls: {
          video: ['src', 'poster'],
          source: ['src'],
          img: ['src'],
          image: ['xlink:href', 'href'],
          use: ['xlink:href', 'href'],
          a: ['downloadHref']
        }
      }
    }),
    // 2. 支持 Vue JSX/TSX 语法解析
    vueJsx({}),
    // 3. HTML 优化插件:压缩 HTML、动态注入数据
    createHtmlPlugin({
      minify: true,
      entry: 'src/main.js',
      inject: {
        data: {}
      }
    }),
    // 4. SVG 图标管理插件:生成 SVG Sprite,实现图标复用
    createSvgIconsPlugin({
      iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
      symbolId: 'g-icon-[name]'
    })
  ],
});

配置说明

  1. @vitejs/plugin-vue:Vue 项目核心插件,用于解析 .vue 单文件组件,transformAssetUrls 配置用于修正模板中资源的路径解析。
  2. @vitejs/plugin-vue-jsx:支持 JSX/TSX 语法,满足个性化编码需求,无需额外配置即可使用。
  3. vite-plugin-html:生产环境自动压缩 HTML,支持动态注入数据到 HTML 中,提升页面加载性能。
  4. vite-plugin-svg-icons:将指定目录下的 SVG 图标生成 Sprite,在项目中可通过 <svg><use xlink:href="#g-icon-xxx"></use></svg> 复用,避免图标重复引入。

模块五:路径别名配置(resolve.alias)

在大型项目中,相对路径(如 ../../../src/components/Modal)会降低开发效率和代码可维护性,通过 resolve.alias 配置路径别名,可简化模块引入路径。

配置逻辑

  1. 借助 path.resolve 解析绝对路径。
  2. resolve.alias 中定义别名与对应目录的映射关系。

代码实现

return defineConfig({
  // 其他配置...
  resolve: {
    // 优先解析 browser 字段和 module 字段
    mainFields: ['browser', 'module'],
    // 路径别名配置
    alias: {
      '@': path.resolve('./src'), // 映射 src 目录
      '@LC': path.resolve('../lib-components/src'), // 映射外部组件库目录
      '#': path.resolve('./types'), // 映射类型定义目录
      'td-print': path.resolve('./node_modules/td-print/index.js') // 映射特定模块
    }
  },
});

配置说明

  1. 配置后,可使用 @/components/Modal 替代 ../../../src/components/Modal,简化路径书写。
  2. 别名不仅支持目录映射,还支持单个文件映射(如 td-print):
    • td-print 是自定义模块别名,对应项目中 ./node_modules/td-print/index.js(前端打印相关第三方库);
    • 不配置别名时需写完整路径 import print from './node_modules/td-print/index.js',配置后可直接 import print from 'td-print',简化导入、提升可读性。
  3. path.resolve 用于生成绝对路径,避免不同操作系统下的路径分隔符问题。
  4. 补充:路径别名需配合编辑器配置(如 tsconfig.json/jsconfig.jsoncompilerOptions.paths),实现代码提示和跳转。

模块六:CSS 预处理器配置(css)

Vite 内置支持 SCSS、Less 等 CSS 预处理器,只需安装对应的依赖,再通过 css.preprocessorOptions 配置预处理器参数,即可正常使用。

配置逻辑

  1. 安装 SCSS 依赖(sass,注意不是 node-sass)。
  2. css.preprocessorOptions.scss 中配置编译参数、抑制弃用警告等。

代码实现

return defineConfig({
  // 其他配置...
  css: {
    preprocessorOptions: {
      scss: {
        // 使用现代编译器 API,提升兼容性和编译性能
        api: 'modern-compiler',
        // 抑制 import 相关的弃用警告,保持构建日志整洁
        silenceDeprecations: ['import']
      }
    }
  },
});

配置说明

  1. 使用 SCSS 前需安装依赖:npm install sass --save-dev
  2. api: 'modern-compiler' 指定使用现代编译器 API,替代旧的 node-sass 编译器,提升编译速度和兼容性。
  3. silenceDeprecations 用于抑制不必要的弃用警告,避免构建日志被冗余信息覆盖。

模块七:本地开发服务器配置(server)

该模块用于配置本地开发服务器的相关参数,包括端口、跨域代理、主机访问权限等,核心是通过 proxy 配置解决本地开发的接口跨域问题。

配置逻辑

  1. 配置服务器端口和主机访问权限。
  2. 通过 proxy 配置接口转发规则,将前端请求转发到后端服务。
  3. 配置 changeOrigin 实现跨域模拟,配置 rewrite 修正请求路径。

代码实现

return defineConfig({
  // 其他配置...
  server: {
    // 配置本地开发服务器端口
    port: 8387,
    // 允许外部设备访问(如手机、同一局域网的其他电脑)
    host: true,
    // 跨域代理配置
    proxy: {
      // 匹配以 /charm 开头的请求
      '/charm': {
        // 后端服务目标地址
        target: 'http://10.1.11.11:58***/',
        // 开启跨域模拟:修改请求头中的 Origin 为目标地址
        changeOrigin: true,
        // 路径重写:此处保持原路径不变,可根据需求修改
        rewrite: (path) => path.replace(/^\/charm/, '/charm')
      },
      // 可配置多个代理规则
      '/g-filestore': {
        target: 'http://10.5.11.11:8***/',
        changeOrigin: true
      }
    }
  },
});

配置说明

  1. port 配置本地开发端口,避免与其他服务端口冲突。
  2. host: true 允许外部设备访问,方便在手机上调试移动端页面。
  3. proxy 中的 target 为后端服务地址,changeOrigin: true 是解决跨域的核心,通过修改请求头的 Origin 模拟同源请求。
  4. rewrite 用于修正请求路径,若前端请求路径与后端接口路径不一致,可通过该配置进行调整。

模块八:全局常量注入配置(define)

通过 define 可以在项目中注入全局常量,这些常量会在打包时被静态替换,无需手动引入即可在代码中直接使用,适用于埋点、版本号、CDN 路径拼接等场景。

配置逻辑

  1. define 中定义全局常量,注意字符串类型需要使用 JSON.stringify 包裹。
  2. 在项目代码中直接访问该常量。

代码实现

return defineConfig({
  // 其他配置...
  define: {
    // 注入时间戳全局常量,用于版本标识
    'import.meta.env.VITE_APP_LOCAL_HASH': JSON.stringify(fileDateDir)
  },
});

配置说明

  1. define 中的键名建议遵循 import.meta.env.XXX 格式,与 Vite 内置环境变量格式保持一致。
  2. 字符串类型必须使用 JSON.stringify 包裹,否则打包时会被当作变量解析,导致报错。
  3. 在项目代码中可直接使用:console.log(import.meta.env.VITE_APP_LOCAL_HASH),打包后会被静态替换为对应的时间戳字符串。

四、Vite 的构建流程

尽管 Vite 采用了与 Webpack 不同的底层机制,但它依然遵循清晰的构建流程,分为开发环境和生产环境两个阶段:

1. 开发环境构建流程

  1. 初始化参数:解析 vite.config.js,合并命令行参数,加载环境变量和插件。
  2. 启动开发服务器:创建 HTTP 服务,监听指定端口,开启 WebSocket 通信(用于热更新)。
  3. 确定入口:加载根目录下的 index.html,自动修正其中的资源路径和公共基础路径。
  4. 按需编译模块:浏览器请求某个模块时,Vite 实时对该模块进行编译(如 Vue SFC 解析、TS 转 JS),修正依赖路径后返回给浏览器。
  5. 热更新(HMR):监听项目文件变化,仅重新编译修改的单个模块,通过 WebSocket 向浏览器推送更新通知,浏览器直接替换对应模块,无需全页刷新。
  6. 接口代理:根据 server.proxy 配置,将前端请求转发到后端服务,解决跨域问题。

2. 生产环境构建流程

  1. 环境准备:解析 mode 参数,加载对应环境变量,清理旧的打包产物。
  2. 初始化编译器:合并 vite.config.js 中的 build 配置,初始化 Rollup 编译器,注册所有插件。
  3. 解析入口与依赖:以 index.html 为入口,递归分析所有模块的依赖关系,构建完整的依赖图谱。
  4. 模块编译与优化:对所有模块进行编译转换,执行 Tree Shaking 剔除死代码,进行代码压缩和混淆。
  5. 组装与输出资源:将模块组装为入口代码块、公共代码块,按照配置的输出规则将静态资源写入指定目录。
  6. 后续自动化操作:执行插件的 writeBundle 钩子(如资源拷贝),生成最终的打包产物,完成构建。

五、Vite 的核心优势与适用场景

核心优势

  1. 极速启动:利用浏览器原生 ESM,无需全量打包,开发环境启动速度远超传统打包工具。
  2. 快速热更新:仅更新修改的单个模块,热更新响应无延迟,大幅提升开发效率。
  3. 丰富的插件生态:支持 Vue、React、TypeScript 等主流技术栈,兼容部分 Rollup 插件,扩展能力强。
  4. 开箱即用:内置 TypeScript、JSX、CSS 预处理器等支持,无需额外复杂配置。
  5. 高度可配置:vite.config.js 提供完善的配置项,可满足各类项目的工程化需求。
  6. 优化的生产打包:基于 Rollup 实现,产出的静态资源体积小、性能优,满足生产环境部署要求。

适用场景

  1. 新一代 SPA/MPA 项目开发。
  2. 前端组件库开发。
  3. 内部中后台系统、管理平台开发。
  4. 需要快速迭代的原型项目。
  5. 注重开发者体验的团队和项目。

六、总结

vite.config.js 作为 Vite 项目的核心配置文件,涵盖了环境配置、打包输出、插件扩展、本地开发等多个模块,一份完善的配置能够让前端工程化流程更规范、更高效。

Vite 凭借“开发环境按需编译、生产环境 Rollup 打包”的差异化策略,既解决了传统打包工具的性能瓶颈,又满足了生产环境的部署要求,是现代前端开发的优质选择。掌握 vite.config.js 的配置逻辑,能够充分发挥 Vite 的核心优势,助力项目高效开发与部署。

《实时渲染》第2章-图形渲染管线-2.3几何处理

作者 charlee44
2026年1月21日 20:42

实时渲染

2. 图形渲染管线

2.3 几何处理

GPU上的几何处理阶段负责大多数每个三角形和每个顶点的操作。该阶段进一步分为以下功能阶段:顶点着色、投影、裁剪和屏幕映射(如图2.3)。

图2.3. 几何处理阶段分为一系列功能阶段

2.3.1 顶点着色

顶点着色有两个主要任务,即计算顶点的位置和评估程序员可能喜欢的顶点输出数据,例如法线坐标和纹理坐标。传统上,对象的大部分阴影是通过将灯光应用到每个顶点的位置和法线并仅将结果颜色存储在顶点来计算的。然后将这些颜色插入整个三角形。出于这个原因,这个可编程的顶点处理单元被命名为顶点着色器[1049]。随着现代GPU的出现,以及每个像素发生的部分或全部着色,这个顶点着色阶段更加通用,并且可能根本不会求取任何着色方程的值,其工作主要取决于程序员的意图。顶点着色器现在是一个更通用的单元,专门用于设置与每个顶点关联的数据。例如,顶点着色器可以使用第4.4节第4.5节中的方法为对象设置动画。

我们首先描述如何计算顶点位置,一组始终需要的坐标。在被屏幕显示的过程中,模型被转换成几个不同的空间或坐标系。最初,模型驻留在自己的模型空间中,这仅意味着它根本没有被转换。每个模型都可以与模型变换相关联,以便可以对其进行定位和定向。可以将多个模型转换与单个模型相关联。这允许同一模型的多个副本(称为实例)在同一场景中具有不同的位置、方向和大小,而无需复制基本几何体。

模型变换所变换的是模型的顶点和法线。对象的坐标称为模型坐标,在对这些坐标应用模型变换后,模型被称为位于世界坐标或世界空间中。世界空间是唯一的,模型经过各自的模型变换后,所有的模型都存在于同一个空间中。

如前所述,模型只有被相机(或观察者)看到才能渲染。相机在世界空间中有一个位置和一个方向,用于放置和瞄准相机。为了便于投影和剪辑,相机和所有模型都使用视图变换进行了变换。视图变换的目的是将相机放置在原点并瞄准它,使其看向负z轴的方向,y轴指向上方,x轴指向右侧。我们使用-z轴约定;一些文章也会使用向下看+z轴的约定。区别主要是语义上的,因为一个和另一个之间的转换很简单。应用视图变换后的实际位置和方向取决于底层应用程序编程接口 (API)。如此划定的空间称为相机空间,或更常见的是,视图空间或眼睛空间。视图变换影响相机和模型的方式示例如图2.4所示。模型变换和视图变换都可以用4×4矩阵来实现,这是第4章的主题。但是,重要的是要意识到可以以程序员喜欢的任何方式计算顶点的位置和法线。

图2.4 在左图中,自上而下的视图显示了在+z轴向上的坐标系中,按照用户希望的方式定位和定向的相机。视图变换重新定向了坐标系,使相机位于原点,沿其负z轴看,相机的+y轴向上,如右图所示。这样做是为了使裁剪和投影操作更简单、更快捷。浅蓝色区域是视锥体。在这里,假设透视图,因为视锥体是一个平截头体。类似的技术适用于任何类型的投影。

接下来,我们将描述顶点着色的第二种类型的输出。要生成逼真的场景,仅渲染对象的形状和位置是不够的,还必须对它们的外观进行建模。该描述包括每个物体的材质,以及任何光源照射在物体上的效果。材料和灯光可以通过多种方式建模,从简单的颜色到物理描述的精细表示。

这种确定光对材料效果的操作称为着色。它涉及计算对象上不同点的着色方程。通常,其中一些计算在模型顶点的几何处理期间执行,而其他计算可能在逐像素处理期间执行。各种材质数据可以存储在每个顶点,例如点的位置、法线、颜色或求取着色方程值所需的任何其他数字信息。然后,顶点着色结果(可以是颜色、矢量、纹理坐标以及任何其他类型的着色数据)被发送到光栅化和像素处理阶段进行插值并用于计算表面的着色。

GPU顶点着色器形式的顶点着色在本书中进行了更深入的讨论,尤其是在第3章第5章中。

作为顶点着色的一部分,渲染系统先进行投影,然后进行裁剪,将视图体换为单位立方体,其极值点位于(1,1,1)(-1,-1,-1)(1,1,1)(1,1,1)之间。可以使用不同的范围来定义相同的体积,例如,(0z1)(0 ≤ z ≤ 1)。单位立方体称为正规化视图体。投影是在GPU上由顶点着色器首先完成的。常用的投影方法有两种,即正射投影(也称平行投影)和透视投影,如图2.5。事实上,正射投影只是一种平行投影。也可以使用其他几种投影方式(特别是在建筑领域),例如斜投影和轴测投影。老式街机游戏Zaxxon就是以后者命名的。

图2.5. 左侧是正射投影或平行投影;右边是透视投影。

请注意,投影表示为矩阵(第4.7节),因此它有时可能与几何变换的其余部分连接。

正交观察的视图体通常是一个矩形框,正交投影将这个视图体变换为单位立方体。正交投影的主要特点是平行线在变换后保持平行。这种转换是平移和缩放的组合。

透视投影有点复杂。在这种类型的投影中,物体离相机越远,投影后看起来越小。另外,平行线可能会聚在地平线上。因此,透视变换模仿了我们感知物体大小的方式。在几何上,称为截头锥体的视图体是一个具有矩形底面的截棱锥。截头锥体也转化为单位立方体。正交变换和透视变换都可以用4×4矩阵构造(第4章),并且在任一变换之后,模型都被称为在裁剪坐标中。这些实际上是齐次坐标,在第4章中讨论过,因此这发生在除以w之前。GPU的顶点着色器必须始终输出这种类型的坐标,以便下一个功能阶段(裁剪)正常工作。

尽管这些矩阵将一个几何体转换为另一个几何体,但它们被称为投影,因为在显示之后,z坐标不存储在生成的图像中,而是存储在z缓冲区中,如第2.5节所述。通过这种方式,模型从三维投影到两维。

2.3.2 可选的顶点处理

每个管线都有刚刚描述的顶点处理。完成此处理后,可以在GPU上进行几个可选阶段,按顺序是:曲面细分、几何着色和流输出。它们的使用取决于硬件的能力——并非所有 GPU 都有它们——以及程序员的愿望。它们相互独立,一般不常用。 将在第3章中详细介绍每一个。

第一个可选阶段是曲面细分。假设你有一个弹跳球对象。如果用一组三角形表示它,则可能会遇到质量或性能问题。您的球在5米外可能看起来不错,但近距离观察单个三角形,三角形的轮廓,就会变得清晰可见。如果你用更多的三角形制作球来提高质量,当球很远并且只覆盖屏幕上的几个像素时,你可能会浪费大量的处理时间和内存。通过曲面细分,可以使用适当数量的三角形生成曲面。

我们已经讨论了一些三角形,但在管线中的这一点上,我们只处理了顶点。这些可用于表示点、线、三角形或其他对象。顶点可用于描述曲面,例如球。这样的表面可以由一组面片指定,每个面片由一组顶点组成。曲面细分阶段由一系列阶段本身组成——外包着色器(hull shader)、曲面细分器(tessellator)和域着色器(domain shader)——将这些面片顶点集转换为(通常)更大的顶点集,然后用于制作新的三角形集。场景的相机可用于确定生成了多少个三角形:面片很近时很多,远处时很少。

下一个可选阶段是几何着色器。该着色器早于曲面细分着色器,因此在GPU上更常见。它类似于曲面细分着色器,因为它接受各种类型的图元并可以生成新的顶点。这是一个更简单的阶段,因为此创建的范围有限,输出图元的类型也更有限。几何着色器有多种用途,其中最流行的一种是粒子生成。想象一下模拟烟花爆炸。每个火球都可以用一个点来表示,一个顶点。几何着色器可以将每个点变成面向观察者并覆盖多个像素的正方形(由两个三角形组成),从而为我们提供更令人信服的图元进行着色。

最后一个可选阶段称为流输出。这个阶段让我们使用GPU作为几何引擎。与将我们处理过的顶点沿着管道的其余部分发送到屏幕上不同,此时我们可以选择将这些顶点输出到数组中以供进一步处理。这些数据可以由CPU或GPU本身在以后的过程中使用。此阶段通常用于粒子模拟,例如我们的烟花示例。

这三个阶段按此顺序执行——曲面细分、几何着色和流输出——每个阶段都是可选的。无论使用哪个(如果有)选项,如果我们继续沿着管道向下走,我们就会得到一组具有齐次坐标的顶点,这些顶点将被检查相机是否能看到它们。

2.3.3 裁剪

只有全部或部分在视图体内部的图元需要传递到光栅化阶段(以及随后的像素处理阶段),然后在屏幕上绘制它们。完全位于视图体内部的图元将按原样传递到下一个阶段。完全在视图体积之外的基元不会被进一步传递,因为它们没有被渲染。部分位于视图体内部的图元需要裁剪。例如,一条直线,在视图体外部有一个顶点,在视图体积内部有一个顶点,此时应该根据视图体对其进行裁剪;以便外部的顶点被位于该线和视图体之间的交点处的新顶点替换。投影矩阵的使用意味着变换后的图元被裁剪到单位立方体上。在裁剪之前进行视图变换和投影的好处是可以使裁剪问题保持一致;图元总是针对单位立方体进行裁剪。

裁剪过程如图2.6所示。除了视图体积的六个剪裁平面之外,用户还可以定义额外的剪裁平面来明显地剪裁对象。第818页的图19.1中显示了显示这种可视化类型的图像,称为剖视(sectioning)。

图2.6. 只需要单位立方体内部的图元(对应视锥体内部的图元)继续处理。因此,单位立方体外面的图元被丢弃,而完全在里面的图元被保留。与单位立方体相交的图元被裁剪在单位立方体上,从而产生新的顶点并丢弃旧的顶点。

裁剪步骤使用投影产生的4值齐次坐标进行裁剪。值通常不会跨透视空间中的三角形进行线性插值。需要第四个坐标,以便在使用透视投影时正确插入和裁剪数据。最后,执行透视除法,将生成的三角形的位置放入三维标准化设备坐标中。如前所述,此视图体积范围从(1,1,1)(-1,-1,-1)(1,1,1)(1,1,1)。几何阶段的最后一步是从这个空间转换到窗口坐标。

2.3.4 屏幕映射

只有视图体内部的(裁剪的)图元被传递到屏幕映射阶段,进入这个阶段时坐标仍然是三维的。每个图元的x和y坐标被转换为屏幕坐标。屏幕坐标与z坐标一起也称为窗口坐标。假设场景应该被渲染到一个最小位置在(x1,y1)(x_1,y_1),最大位置在(x2,y2)(x_2 ,y_2)处的窗口(其中x1<x2x_1 < x_2y1<y2y_1 < y_2)。屏幕映射先是平移,然后是缩放操作。新的x和y坐标称为屏幕坐标。z坐标(OpenGL的[1,+1][−1,+1]和DirectX的[0,1][0,1])也被映射到[z1,z2][z_1,z_2],其中z1=0z_1=0z2=1z_2=1作为默认值。但是,这些可以通过API进行更改。窗口坐标连同这个重新映射的z值被传递到光栅化阶段。屏幕映射过程如图2.7所示。

图2.7. 投影变换后的图元位于单位立方体中,屏幕映射程序负责在屏幕上找到坐标。

接下来,我们描述整数和浮点值如何与像素(和纹理坐标)相关。给定像素的水平数组并使用笛卡尔坐标,最左边像素的左边缘在浮点坐标中为0.0。OpenGL一直使用这种方案,DirectX10及其后续版本也使用它。该像素的中心为0.5。因此,一系列像素 [0,9] 覆盖了 [0.0,10.0) 的跨度。转换很简单:

d=floor(c)(2.1)d = floor(c) \tag{2.1}
c=d+0.5(2.2)c = d + 0.5 \tag{2.2}

其中dd是像素的离散(整数)索引,cc是像素内的连续(浮点)值。

虽然所有API的像素位置值都从左到右增加,但在OpenGL和DirectX1之间的某些情况下,顶部和底部边缘的零位置不一致。OpenGL始终偏爱笛卡尔系统,将左下角视为最低值元素,而DirectX有时根据上下文将左上角定义为该元素。每个人都有一个逻辑,在他们不同的地方不存在正确的答案。例如,(0,0)(0,0)在 OpenGL中位于图像的左下角,而在DirectX中位于左上角。在从一个API迁移到另一个API时,必须考虑到这种差异。

Footnotes

  1. “Direct3D”是DirectX的三维图形API组件。DirectX包括其他API元素,例如输入和音频控件。我们不区分在指定特定版本时编写“DirectX”和在讨论此特定API时编写“Direct3D”,而是通过始终编写“DirectX”来遵循常见用法。

都2026年,React源码还值不值得读 ❓❓❓

作者 Moment
2026年1月21日 20:26

随着前端技术生态的不断演进,React 作为目前最流行的前端框架之一,已经走过了十多个年头。在 2026 年这个时间节点,很多开发者都在思考一个问题:React 源码还值不值得深入阅读?

这个问题的答案并不是简单的"是"或"否",而需要从多个维度进行分析。本文将从实际价值、学习成本、技术趋势等角度,为你提供一个全面的分析。

为什么曾经值得读 React 源码?

在讨论"现在是否值得"之前,我们先回顾一下为什么 React 源码曾经被认为是值得学习的经典。

React 引入了很多开创性的概念:虚拟 DOM(Virtual DOM)虽然现在已不是新概念,但在当时是一个突破;组件化思想确立了声明式 UI 开发范式;单向数据流成为状态管理的最佳实践;Fiber 架构实现了时间切片和可中断渲染的创新。

React 的代码库以其高质量著称:清晰的代码组织和架构设计、完善的注释和文档、严格的类型检查(使用 Flow,后来迁移到 TypeScript)、丰富的测试覆盖。

阅读 React 源码可以学到大型开源项目的组织方式、性能优化的思路和技巧、复杂状态管理的实现方式,以及设计模式和架构模式的实际应用。

2026 年的技术环境变化

到了 2026 年,React 已经非常成熟:API 已经相对稳定,重大变更减少;核心概念已经被广泛理解和应用;生态系统完善,最佳实践明确。

前端技术栈变得更加多样化:Vue 3、Svelte、Solid.js 等框架各有优势;服务端渲染框架(Next.js、Remix、Astro)的兴起;Web Components 的标准化;编译时优化成为趋势(如 React Compiler)。

学习资源也变得更加丰富:大量的教程、视频、文章;官方文档的完善;社区经验的沉淀;AI 辅助学习工具的普及。

2026 年读 React 源码的利弊分析

仍然值得读的理由

虽然你可以通过文档和教程学会如何使用 React,但阅读源码能让你真正理解 React 的工作原理,而不是表面的 API 使用;理解为什么某些 API 是这样设计的;理解性能优化的底层原理(如 Diff 算法、Fiber 调度)。

当你遇到框架层面的问题时,源码知识能帮助你快速定位问题根源、找到绕过框架限制的方法、为框架贡献代码或参与讨论。

从职业发展角度来看,深入理解 React 源码可以展现技术深度、学习大型项目的架构设计思路、培养阅读复杂代码的能力。

面试仍然是读源码的重要驱动力。打开牛客网等面试平台,你会发现 React 源码相关的面经依然大量存在。大厂面试中,React 源码问题几乎是必问项:Fiber 架构的实现原理、Hooks 的工作机制、Diff 算法的优化策略、事件系统的合成事件机制、调度器的优先级调度原理等等。如果你只能回答 API 的使用,而无法解释底层的实现原理,在面试中很难获得竞争优势。不仅仅是中高级前端开发者的面试,就连秋招和实习面试中也经常出现 React 源码相关的问题,深入理解 React 源码几乎成了标配要求。

在 AI 工具日益普及的 2026 年,一个现实是:即使你不懂 React 原理,也能通过 AI 辅助工具(如 GitHub Copilot、claude code、Cursor 等)完成日常开发工作。AI 可以帮助你生成代码、解决 bug、优化性能。但如果你想要找到一份好的工作,特别是如果你的技术栈是 React,那么最好还是深入理解 React 原理。原因很简单:AI 可以帮你写代码,但不能帮你通过技术面试;AI 可以解决具体问题,但不能替代你对框架的深度理解。在竞争激烈的就业市场中,能够解释 React 底层原理的开发者,明显比只会使用 API 的开发者更有优势。

以下是从牛客网面经中截取的实际面试题目,可以看到 React 原理类问题确实频繁出现:

牛客网 React 面经截图 1

牛客网 React 面经截图 2

React 19 引入了很多新特性,值得深入研究:并发渲染(Concurrent Rendering)、自动批处理(Automatic Batching)、Suspense 的完整实现、Transition API、React Compiler、Actions 和 Form 的改进等。

React 源码是学习设计模式的绝佳教材:观察者模式(事件系统)、策略模式(调度器)、适配器模式(各种 renderer)、工厂模式(组件创建)。

可能不值得读的理由

React 源码库庞大(超过 10 万行代码),需要大量的时间和精力投入,对于大多数应用开发场景,可能"用不到"这么深的知识。

如果你已经是经验丰富的 React 开发者,读源码的边际收益可能不大,很多概念已经通过其他方式学习到了,实际工作中很少需要深入到框架实现层面。

如果项目使用了其他框架(Vue、Svelte 等),React 源码的学习价值相对降低;如果转向了服务端渲染或边缘计算,客户端框架源码的价值降低。

现在有更多高质量的学习资源(视频教程、互动式课程),可以通过构建简化版 React 来学习核心概念,通过 TypeScript 类型定义也能理解很多设计。

如何判断你是否应该读 React 源码?

适合读源码的情况包括:你已经熟练使用 React 进行开发(至少 1-2 年经验);你遇到了框架层面的问题,需要深入理解才能解决;你想提升技术深度,为职业发展做准备;你即将参加面试,无论是秋招、实习还是社招,特别是大厂面试,React 源码问题几乎是必考点;你对框架设计感兴趣,想要学习架构设计;你想要为 React 贡献代码,或参与相关技术讨论;你是前端技术专家或架构师,需要全面的技术理解。

不太适合读源码的情况包括:React 新手应该先掌握基础使用,再考虑读源码;时间有限的情况下,如果项目压力大,先保证业务能力;如果职业方向不在前端框架,如果转向全栈、后端或移动端,优先级应该调整;如果只做业务开发,工作中不需要深入到框架层面,可能收益不大。

如果决定读,应该如何读?

如果你决定要读 React 源码,不要试图一次性读完所有代码,应该按模块学习。

下面这张图是我整理的 React 源码整体阅读流程,用一条清晰的路径把从 JSX 到 Fiber、再到 Scheduler 和 commit 阶段串联在一起。建议你先整体看一遍这张图,对 React 内部的执行链路有个全局认知,再按图中的顺序去对应阅读源码里的关键部分。

20260121200944

结合这张流程图,可以按下面的顺序来读 React 源码:

  1. 从使用入口出发:先看 packages/react 中与 JSX 相关的实现(createElement、jsx 等),再看 packages/react-dom 中的 createRoot、render,弄清楚一次渲染是如何被发起的。

  2. 理解 Fiber 数据结构:在 packages/react-reconciler 中阅读 FiberNode 的定义、createFiber 等代码,搞清楚 Fiber 上都挂了哪些信息,以及它和更新队列的关系。

  3. 看渲染阶段的工作循环:继续在 react-reconciler 里看 workLoopConcurrent、performUnitOfWork、beginWork、completeWork,对应流程图中「渲染阶段」这部分。

  4. 单独啃 Scheduler:切到 packages/scheduler,理解任务队列、优先级和时间切片机制,这一块对应流程图中间的调度器节点。

  5. 回到 commit 阶段:再回到 react-reconciler,看 commitRoot、commitMutationEffects、commitLayoutEffects,弄清楚 DOM 实际在什么时候、以什么顺序被更新。

  6. 深入 Hooks 实现:重点阅读 useState、useReducer、useEffect 等 Hook 对应的实现文件,结合 Hooks 链表的那部分流程图,理解 hooks 链表如何在 current、workInProgress 之间切换。

  7. 最后再看 Context、Suspense、并发特性等模块,把前面打下的基础扩展成完整的 React 内部心智模型。

如果你时间不多,建议优先把 1-6 跑通:入口能串起来、Fiber 能看懂、workLoop 能跟住、Scheduler 有概念、commit 知道在干嘛、Hooks 能解释清楚,基本就已经具备“读懂 React 源码”的主干能力。

工具和方法上,建议先把 React 源码仓库拉到本地,在源码里定位关键函数和关键数据结构。

同时搭配 React DevTools 观察组件树和状态变化。真正调试运行时路径时,浏览器里执行的是构建后的 bundle,所以需要开启 Source Map,把断点、调用栈从 bundle 映射回你本地的源码文件,这样你在 DevTools 里跟代码时看到的就是 React 源码而不是编译产物。

遇到数据结构或边界条件不确定,再对照官方文档和 TypeScript 类型定义去校验。

最后,边读边做一个“最小可运行”的简化版 React,再用少量测试用例验证自己的推导,会比纯读更容易形成稳定的心智模型。

2026 年的新视角:React Compiler 与未来

到了 2026 年,React 可能已经集成了一些新特性,值得关注。

如果 React Compiler 已经集成到核心,这将是值得深入学习的新内容:编译时优化的思路、静态分析和代码转换、性能优化的新范式。

React 19 的并发特性已经稳定,这些实现值得深入研究:时间切片(Time Slicing)、优先级调度、可中断渲染。

了解 React 如何与新技术整合也很重要:React Server Components、与 WebAssembly 的交互、与 Web Workers 的集成。

替代学习路径

如果你觉得读完整源码成本太高,可以考虑这些替代方案。

通过实现一个简化版的 React(如 1000 行左右的代码),你可以学习到核心概念:Virtual DOM、组件系统、Diff 算法、Hooks 基础实现。

只读最核心的部分:Reconciler 的核心逻辑、Hooks 的实现、Scheduler 的调度算法。

React 的 TypeScript 类型定义本身就是很好的文档,可以帮你理解 API 设计思路、数据流、组件生命周期。

关注技术博客和视频:React 团队的官方博客、技术社区的文章、深度解析视频。

结论与建议

在 2026 年,React 源码仍然值得读,但不再是"必读项"。

对于大多数开发者,建议优先掌握 React 的使用和最佳实践,通过文档、教程和项目经验提升能力,只在遇到特定问题或想提升深度时,有针对性地阅读相关源码。

对于有追求的开发者,如果你有时间和兴趣,阅读源码绝对是有价值的投资。建议采用"选择性深度阅读"的方式,重点学习核心模块。结合实践项目,通过构建简化版来加深理解。

对于技术专家和架构师,深入理解 React 源码是必要的。这不仅能帮你做出更好的技术决策,还能提升架构设计能力。

技术学习是一个持续的过程,不应该有"一劳永逸"的想法。是否读 React 源码,应该基于你的当前水平、职业目标、项目需求、时间资源。

在 2026 年,我们有更多的学习选择。React 源码仍然是宝贵的学习资源,但它不再是唯一的选择。选择最适合你当前情况的路径,比盲目追求"读完源码"更重要。

无论你是否选择深入阅读 React 源码,保持学习的心态和对技术的热情,比掌握任何特定的技术栈都更重要。技术会变化,但学习能力是永恒的。

如果你读到这里,说明你对 React 源码已经有不小的兴趣,或者正在认真考虑要不要系统地学一遍。最近我刚刚完成了 React 源码的系统学习,并整理成了一个专栏,用大量示意图和流程图来讲清楚 Fiber 架构、Hooks 的内部实现、调度器以及事件系统等关键部分。

2148a5531fb6563f813d90e0dd467838

3c465d2e2e4b34ff7d851ecb64f30482

60956e3d526aca95969b8a72bad33364

如果你想用更直观的方式把这些知识真正吃透,欢迎加我微信 yunmz777 私聊,一起交流源码学习的思路和实践经验。

前端ESLint 和 Babel对比

2026年1月21日 20:05

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

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

第一部分:核心差异概览

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

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

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

场景描述

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

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

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

1. 插件入口结构

通常定义在 index.js 中。

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

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

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

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

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

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

const path = require('path');

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

'use strict';

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

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

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

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

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

场景描述

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

转换前

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

转换后

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

1. Babel 插件基础结构

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3. Babel 插件单元测试

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

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

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

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

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


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


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


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

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

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

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

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

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

  • ESLint:

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

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

2. 状态管理 (State)

  • ESLint:

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

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

3. 节点构造与替换

  • ESLint Fixer:

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

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

总结

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

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

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

2026年1月21日 19:50

写在前面

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

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

这是错觉。

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

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

image.png


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

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

1.1 模块化的三个层级

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

3.1 不稳定的依赖要隔离

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

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

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

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

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

如何打破循环?

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

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

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

模块化 = 增加间接层。

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

架构师的判断力体现在:

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

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

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

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

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

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

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

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和交互
  • 提高开发效率
  • 更好地测试各种场景

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

❌
❌