普通视图

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

前端性能优化解决方案

作者 凉城a
2026年3月27日 12:18

前言

任何一个项目,随着开发迭代的增加,都离不开前端性能优化这一块,

宇宙第一定律:“熵增定律”。任何东西都会从有序走向无序,我们能做的只是减缓这个无序化过程。

性能优化恰恰就是减缓利器。

什么是前端性能优化?

前端性能优化‌是指通过一系列技术手段和策略,提升网页或 Web 应用的‌加载速度‌、‌渲染效率‌和‌交互响应能力‌,从而改善用户体验、降低跳出率、提升转化率与 SEO 表现的过程。

为什么要做前端性能优化?

  • 用户体验直接影响业务指标‌:

    • BBC 发现:页面加载时间每增加 1 秒,用户流失率上升 10%。
    • Pinterest:加载时间减少 40%,搜索和注册数提升 15%。
    • Google 数据:移动网页加载超过 3 秒,53% 的用户会离开 ‌48。
  • 搜索引擎排名因素‌:核心 Web 指标(如 LCP、CLS、FID)已成为 Google 排名依据之一 ‌4。

  • 成本节约‌:减少资源传输量可降低带宽与服务器压力 ‌2。

思考分析

知道了前端优化是什么?以及为什么要做前端优化?就可以思考下应该怎么做?

先看看整个工作过程

  • 首先我们项目打包完成后是放在服务器
  • 然后客户端发送请求,服务器把项目通过网络传输给浏览器
  • 最后浏览器加载项目

这个过程是不是和网购很像,仓库囤货,用户下单,仓库发货,快递运输,到达用户手里。

这里可以类比思想来分析,殊途同归。

  • 仓库对应服务器
  • 快递对应网络传输
  • 浏览器对应用户
  • 项目对应货物

仓库都做了些啥?

  1. 让仓库离用户更近,就近发货
  2. 仓库更大,类目更齐全,吞吐量更大

快递做了啥?

  1. 飞机送快递
  2. 扩大快递员规模

用户能做啥?

  1. 地址更具体清晰
  2. 提前选择合适的收件方案,收件时间

货物能做啥?

  1. 分类处理
  2. 压缩处理
  3. 预处理,提前加工

解决方案

通过上面思考分析,可以把前端性能优化分为3部分:

第一部分,服务器端优化

第二部分,传输优化

第三部分,浏览器优化

第四部分,项目优化

服务器端优化

通过仓库离用户更近,可以联想到分布式网络架构,也就是CDN

1. 部署CDN(内容分发网络)

CDN是服务器端性能优化的首选方案‌。它通过将静态资源(如JS、CSS、图片)缓存到全球分布的边缘节点,使用户能从离自己最近的节点获取资源,显著缩短加载时间。

  • 效果‌:可使首屏渲染时间(FCP)缩短40%-60%,页面加载延迟降低50%以上。

  • 实践建议‌:

    • 将静态资源托管至CDN,配置合理的缓存策略(如Cache-Control: max-age=31536000)。
    • 使用独立域名(如static.example.com)存放静态资源,避免携带主域Cookie,减少请求体积。

2. 启用服务器压缩(Gzip/Brotli)

在服务器端对传输资源进行压缩,能大幅减小文件体积,提升传输效率。

  • Gzip‌:广泛支持,可压缩HTML、CSS、JS等文本资源,压缩率约70%。
  • Brotli‌(.br):压缩率比Gzip高15%-25%,尤其适合文本资源,推荐在支持的服务器上启用。

建议操作:在Nginx或Apache中配置压缩模块,并结合构建工具预压缩资源以降低CPU开销。

3. 服务端渲染(SSR)

SSR能显著提升首屏加载速度和SEO表现‌。服务器提前将页面渲染为HTML字符串返回,用户无需等待JavaScript下载和执行即可看到内容。

  • 适用场景‌:内容型网站(如新闻、电商)、SEO敏感页面。
  • 注意事项‌:SSR会增加服务器计算负载,需合理设计缓存策略(如页面级缓存)以平衡性能。

4. 优化服务器配置与协议

  • 启用HTTP/2或HTTP/3‌:支持多路复用、头部压缩,减少连接开销,提升资源并行加载效率。
  • 使用负载均衡‌:在高并发场景下,通过负载均衡分散请求压力,提升系统可用性。
  • 配置长连接(Keep-Alive) ‌:减少TCP握手次数,提升连续资源请求的效率。

5. 动态资源加速与边缘计算

现代CDN支持在边缘节点处理动态内容,如:

  • 对API请求进行缓存或限流。
  • 在边缘节点完成用户鉴权等轻量逻辑,减少回源次数,降低后端负载。

传输优化

传输其实就是http请求,从传输层面优化前端性能,核心是‌减少数据传输耗时与网络延迟‌,让资源更快抵达用户设备。

先来分析下http的不足,然后思考如何改善优化?给出解决方案,最后总结

HTTP/1.1 作为曾经广泛使用的 Web 协议版本,虽然在当时显著提升了 Web 通信效率,但其设计也带来了一些性能瓶颈和限制,尤其是在高并发、高延迟或资源密集型的现代 Web 应用场景中。

1. 队头阻塞(Head-of-Line Blocking)

这是 HTTP/1.1 最核心的性能问题之一。

  • 成因‌:HTTP/1.1 使用持久连接(Keep-Alive),允许在一个 TCP 连接上发送多个请求,但‌响应必须按照请求的顺序返回‌。如果第一个请求处理缓慢,后续所有请求的响应都会被阻塞。
  • 影响‌:在高延迟网络中,一个慢响应会显著拖慢整个页面的加载速度,导致用户体验下降。例如,当浏览器请求一个包含多张图片的页面时,如果第一张图片加载缓慢,其他图片的加载也会被延迟。
  • 解决方案‌:HTTP/2 引入了‌多路复用‌(Multiplexing),允许在单个连接上并行发送多个请求和响应,从而消除了队头阻塞问题。

2. 头部冗余与未压缩

HTTP/1.1 每个请求和响应都携带完整的头部信息,且未进行压缩,导致不必要的带宽消耗。

  • 问题‌:例如,每次请求都必须重复发送 HostUser-AgentAccept 等字段,即使这些信息在多个请求中是相同的。
  • 影响‌:在频繁请求的场景下,这些冗余头部会显著增加传输数据量,尤其在移动端或低带宽环境下影响明显。
  • 解决方案‌:HTTP/2 引入了‌头部压缩机制‌(HPACK 算法),大幅减少了头部传输的体积。

3. 单连接限制

尽管 HTTP/1.1 支持持久连接,但‌每个连接只能处理一个请求‌,在高并发场景下仍需建立多个连接,增加了资源开销。

  • 问题‌:浏览器通常会为单个域名开启多个 TCP 连接(如 6 个),但这仍会导致:

    • 增加 TCP 连接建立和关闭的开销(三次握手、四次挥手)。
    • 服务器需要维护多个连接状态,增加内存和 CPU 负载。
    • TLS 握手带来的额外延迟(HTTPS 场景下)。
  • 解决方案‌:HTTP/2 通过‌多路复用‌,在一个连接上并发处理多个请求,避免了多个连接的开销。

4. 缺乏服务器推送

HTTP/1.1 不支持服务器主动推送资源,客户端必须先请求资源,服务器才能响应。

  • 问题‌:这导致了不必要的往返延迟。例如,服务器知道页面需要 CSS 和 JS 文件,但必须等待客户端请求后才发送,浪费了时间。
  • 解决方案‌:HTTP/2 引入了‌服务器推送‌(Server Push),允许服务器在客户端请求之前主动推送资源,提升加载速度。

5. 文本协议格式

HTTP/1.1 使用纯文本格式传输数据,解析效率低,且容易受到安全攻击。

  • 问题‌:文本格式需要解析器进行逐字符处理,效率不如二进制格式。
  • 解决方案‌:HTTP/2 采用‌二进制分帧‌(Binary Framing),将数据拆分为更小的帧,提高解析效率和安全性。

总结

升级传输协议(HTTP/2 或 HTTP/3)

先进协议能显著提升并发传输效率,减少连接开销‌。

  • HTTP/2‌:

    • 支持‌多路复用‌,多个请求可在同一连接并行传输,解决HTTP/1.1的“队头阻塞”。
    • 支持‌头部压缩‌(HPACK算法),减少请求头体积。
    • 可启用‌服务器推送‌(Server Push),提前推送关键资源(如CSS、JS)。
  • HTTP/3‌(基于QUIC):

    • 基于UDP协议,实现‌真正的多路复用‌,丢包不影响其他流。
    • 连接建立更快,支持‌0-RTT快速重连‌,特别适合移动端和弱网环境。

建议:优先启用HTTP/2,条件允许时逐步迁移至HTTP/3,尤其适用于高交互、多资源加载的Web应用。

浏览器优化

从浏览器出发优化前端性能,核心是‌利用浏览器自身机制提升资源加载效率与渲染流畅度‌,减少用户可见的等待时间与交互卡顿。

1. 合理利用浏览器缓存

缓存是减少重复请求、加速二次访问的核心手段‌。通过设置合适的HTTP缓存头,让静态资源直接从本地读取。

  • 强缓存‌:设置 Cache-Control: max-age=31536000,配合文件哈希(如app.a1b2c3.js)实现长期缓存。
  • 协商缓存‌:对HTML等动态内容使用 ETag 或 Last-Modified,由服务器判断是否更新。
  • Service Worker 缓存‌:可实现离线访问和精细缓存控制,适用于PWA应用。

实践建议:将JS、CSS、图片等静态资源纳入缓存策略,避免每次访问都回源请求。

2. 利用现代API提升效率

Vue、React其实就用到了这些新的API,React的Fiber架构其实就是用到requestIdleCallback,将渲染任务拆分成一个个小的时间切片,在浏览器空闲时,在加载。

使用高性能浏览器API替代传统实现方式‌。

  • ‌**IntersectionObserver**‌:替代 scroll 事件监听,实现高效的懒加载与可视区域检测,滚动性能提升70%以上。
  • ‌**ResizeObserver**‌:异步监听元素尺寸变化,避免手动计算带来的性能损耗。
  • ‌**requestIdleCallback**‌:在浏览器空闲时执行非关键任务,避免影响关键渲染。

项目优化

从项目出发优化前端性能,核心是‌将性能优化融入开发流程与工程体系‌,实现从编码、构建到部署的全链路提效。这不仅是技术手段的组合,更是开发规范与协作模式的升级。

1. 代码层面:提升可维护性与执行效率

高质量代码是性能优化的基石‌,从源头减少性能隐患。

  • 组件级优化(React/Vue) ‌:

    • 使用 React.memouseMemouseCallback 避免不必要渲染。
    • Vue 中利用 v-memo 和响应式优化减少依赖追踪开销。
  • 避免重排与重绘‌:

    • 动画优先使用 transform 和 opacity,触发GPU合成。
    • 批量修改样式,避免“读-写-读”布局抖动。
  • 事件优化‌:

    • 对 scrollresize 等高频事件使用防抖(debounce)或节流(throttle)。
    • 使用事件委托减少监听器数量,提升内存效率。

2. 构建优化:减小体积、加速打包

构建阶段是性能增益的关键环节‌,直接影响资源加载速度。

  • 代码分割(Code Splitting) ‌:

    • 路由懒加载:React.lazy + Suspense 实现按需加载,首屏JS体积减少50%以上。
    • 组件懒加载:非首屏复杂组件(如图表、编辑器)动态引入。
  • Tree Shaking‌:

    • 确保使用 ES Module 语法,配置 sideEffects: false 剔除未使用代码。
  • 依赖分包与共享‌:

    • 使用 SplitChunksPlugin 将 React、Vue、Lodash 等公共依赖提取为 vendor 包,利用浏览器缓存复用。
    • 微前端场景下,通过 Module Federation 实现跨应用依赖共享。
  • 构建工具调优‌:

    • 升级至 Vite,利用 ESBuild 加速编译,热更新进入毫秒级。
    • Webpack 启用持久化缓存,提升重复构建速度。

3. 资源与加载优化:提升首屏体验

控制资源加载节奏,优先保障核心内容展示‌。

  • 图片与静态资源优化‌:

    • 使用 WebP/AVIF 格式,体积比 JPG/PNG 减少30%-50%。
    • 图标优先使用 SVG 或字体图标,避免小图HTTP请求。
  • 预加载与预读取‌:

    • <link rel="preload"> 提前加载首屏关键字体、CSS、JS。
    • <link rel="prefetch"> 空闲时预读下一页资源,提升跳转速度。
  • 懒加载(Lazy Loading) ‌:

    • 图片/视频使用 loading="lazy" 原生支持。
    • 长列表采用虚拟滚动(如 react-window),内存占用减少90%以上。

4. 工程化与持续优化机制

性能优化不是一次性任务,而是持续的工程实践‌。

  • 性能预算(Performance Budget) ‌:

    • 在 CI/CD 中设定 LCP ≤ 1.2s、首屏JS ≤ 200KB 等硬性指标,超标则阻断发布。
  • 自动化监控与分析‌:

    • 集成 Lighthouse 审计,定期生成性能报告。
    • 使用 Web Vitals 监控真实用户性能数据(如 FCP、LCP、CLS)。
  • 骨架屏与加载反馈‌:

    • 首屏复杂页面使用骨架屏,降低用户感知延迟,提升体验流畅度。

写在最后

我是凉城a,一个前端,热爱技术也热爱生活。

与你相逢,我很开心。

如果你想了解更多,请点这里,期待你的小⭐⭐

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于掘金,未经许可禁止转载💌

语法全景对照

2026年3月27日 10:54

PHP、Go、JavaScript (ES6+) 核心语法全景对照指南

第一部分:变量声明与基础数据类型

三种语言在变量作用域、类型检查机制上有着本质区别:

  • PHP:弱类型(但 PHP 7/8 引入了强类型声明),变量以 $ 开头,作用域通常在函数内。
  • Go:静态强类型,编译型语言,严格区分类型,支持类型推导 :=,包级作用域与块级作用域。
  • JavaScript:弱类型,动态脚本语言,推荐使用 letconst 实现块级作用域。

1. PHP 实现

<?php
declare(strict_types=1); // 开启严格类型模式

// 1. 变量与常量声明
$username = "Alice"; // 字符串
$age = 25;           // 整型
$balance = 100.50;   // 浮点型
$isActive = true;    // 布尔型
$data = null;        // 空值

// 常量
define('MAX_LOGIN_ATTEMPTS', 5);
const APP_VERSION = "1.0.0";

// 2. 字符串操作
$greeting = "Hello, $username!"; // 双引号支持变量解析
$concat = 'Age: ' . $age;        // 单引号不支持解析,使用 . 拼接

// 3. 类型声明 (PHP 7.4+)
function printUserInfo(string $name, int $age, ?float $bal): void {
    echo "User: {$name}, Age: {$age}, Balance: " . ($bal ?? 0.0) . "\n";
}

printUserInfo($username, $age, $balance);

2. Go 实现

package main

import "fmt"

// 1. 包级变量与常量声明
const MaxLoginAttempts int = 5
const AppVersion = "1.0.0" // 无类型常量,根据上下文推导

var globalConfig string = "default"

func variablesDemo() {
    // 2. 局部变量声明
    var username string = "Alice"
    var age int = 25
    var balance float64 = 100.50
    var isActive bool = true
    
    // 短变量声明 (仅限函数内部)
    data := "Some Data" // 自动推导为 string
    
    // Go 中没有 null,只有各类型的零值 (nil 适用于指针、切片、映射、接口等)
    var ptr *int = nil 

    // 3. 字符串操作
    // Go 字符串不可变,支持双引号和反引号(多行)
    greeting := fmt.Sprintf("Hello, %s!", username)
    concat := "Age: " + fmt.Sprint(age)

    printUserInfo(username, age, balance)
    fmt.Println(greeting, concat, isActive, data, ptr)
}

// 4. 函数参数严格定型
func printUserInfo(name string, age int, bal float64) {
    fmt.Printf("User: %s, Age: %d, Balance: %.2f\n", name, age, bal)
}

3. JavaScript 实现

// 1. 变量与常量声明 (ES6+)
const MAX_LOGIN_ATTEMPTS = 5; // 常量,不可重新赋值
const APP_VERSION = "1.0.0";

let username = "Alice"; // 块级作用域变量
let age = 25;           // Number 类型 (JS 中不区分整型和浮点型)
let balance = 100.50;   // Number 类型
let isActive = true;    // Boolean
let data = null;        // Null 类型
let notDefined;         // Undefined 类型

// 2. 字符串操作
// 模板字符串 (Template Literals),支持多行和表达式插值
let greeting = `Hello, ${username}!`; 
let concat = 'Age: ' + age;

// 3. 弱类型函数 (可以使用 TypeScript 增加强类型)
function printUserInfo(name, age, bal) {
    // 使用 nullish coalescing operator (??) 赋默认值
    console.log(`User: ${name}, Age: ${age}, Balance: ${bal ?? 0.0}`);
}

printUserInfo(username, age, balance);

第二部分:复合数据结构(数组、字典、切片)

  • PHP:天下无敌的 Array(实际上是有序哈希表),既当列表又当字典。
  • Go:严格区分 Array(定长)、Slice(动态切片)和 Map(哈希表)。
  • JavaScript:区分 Array(动态列表)和 Object/Map(键值对)。

1. PHP 的万能数组

<?php
// 1. 索引数组 (List)
$fruits = ["Apple", "Banana", "Orange"];
$fruits[] = "Mango"; // 追加元素
array_push($fruits, "Grape");

// 2. 关联数组 (Map/Dictionary)
$user = [
    "id" => 101,
    "username" => "bob_smith",
    "email" => "bob@example.com",
    "roles" => ["admin", "editor"] // 嵌套数组
];

// 添加/修改键值对
$user["status"] = "active";

// 3. 数组遍历
foreach ($user as $key => $value) {
    if (is_array($value)) {
        echo "$key: " . implode(", ", $value) . "\n";
    } else {
        echo "$key: $value\n";
    }
}

// 4. 常用数组操作
$keys = array_keys($user);
$hasEmail = array_key_exists("email", $user);
$filtered = array_filter($fruits, fn($f) => strlen($f) > 5); // 闭包过滤

2. Go 的 Slice 与 Map

package main

import (
    "fmt"
    "strings"
)

func dataStructuresDemo() {
    // 1. 数组 (Array) - 长度固定,较少直接使用
    var arr [3]string = [3]string{"Apple", "Banana", "Orange"}

    // 2. 切片 (Slice) - 动态数组,最常用
    fruits := []string{"Apple", "Banana", "Orange"}
    fruits = append(fruits, "Mango", "Grape") // 追加元素

    // 3. 映射 (Map) - 键值对,必须使用 make 初始化或字面量初始化
    user := map[string]interface{}{ // 使用 interface{} 支持不同类型的值
        "id":       101,
        "username": "bob_smith",
        "email":    "bob@example.com",
        "roles":    []string{"admin", "editor"},
    }

    // 添加/修改键值对
    user["status"] = "active"

    // 4. 遍历
    for key, value := range user {
        // 类型断言 (Type Assertion)
        if roles, ok := value.([]string); ok {
            fmt.Printf("%s: %s\n", key, strings.Join(roles, ", "))
        } else {
            fmt.Printf("%s: %v\n", key, value)
        }
    }

    // 5. 检查键是否存在
    email, exists := user["email"]
    if exists {
        fmt.Println("Email found:", email)
    }
}

3. JavaScript 的 Array 与 Object

// 1. 数组 (Array)
const fruits = ["Apple", "Banana", "Orange"];
fruits.push("Mango", "Grape"); // 追加元素

// 2. 对象 (Object) - 作为字典使用
const user = {
    id: 101,
    username: "bob_smith",
    email: "bob@example.com",
    roles: ["admin", "editor"]
};

// 添加/修改键值对
user.status = "active";
user["last_login"] = "2023-10-01";

// 3. 遍历对象
for (const [key, value] of Object.entries(user)) {
    if (Array.isArray(value)) {
        console.log(`${key}: ${value.join(", ")}`);
    } else {
        console.log(`${key}: ${value}`);
    }
}

// 4. 常用高级操作 (ES6 数组方法)
const keys = Object.keys(user);
const hasEmail = "email" in user;
// 链式调用
const longFruits = fruits
    .filter(f => f.length > 5)
    .map(f => f.toUpperCase());

第三部分:函数、闭包与高阶特性

1. PHP 函数特性

<?php
// 1. 默认参数与可变参数
function buildQuery(string $table, array $conditions = [], string ...$fields): string {
    $select = empty($fields) ? "*" : implode(", ", $fields);
    $sql = "SELECT {$select} FROM {$table}";
    if (!empty($conditions)) {
        $sql .= " WHERE " . http_build_query($conditions, '', ' AND ');
    }
    return $sql;
}

// 2. 匿名函数与闭包 (使用 use 关键字引入外部变量)
$multiplier = 3;
$calculate = function (int $number) use ($multiplier): int {
    return $number * $multiplier;
};

// 3. 箭头函数 (PHP 7.4+,单行,自动捕获外部变量)
$calculateArrow = fn(int $number) => $number * $multiplier;

// 4. 命名参数 (PHP 8.0+)
$query = buildQuery(
    fields: "id", "name",
    table: "users",
    conditions: ["status" => 1]
);

2. Go 函数特性

package main

import (
    "fmt"
    "strings"
)

// 1. 多返回值与可变参数
func buildQuery(table string, conditions map[string]interface{}, fields ...string) (string, error) {
    if table == "" {
        return "", fmt.Errorf("table name cannot be empty")
    }
    
    selectFields := "*"
    if len(fields) > 0 {
        selectFields = strings.Join(fields, ", ")
    }
    
    sql := fmt.Sprintf("SELECT %s FROM %s", selectFields, table)
    // 省略复杂的 conditions 拼接逻辑...
    
    return sql, nil
}

func functionDemo() {
    // 2. 匿名函数与闭包 (自动捕获外部变量,无需类似 PHP 的 use)
    multiplier := 3
    calculate := func(number int) int {
        return number * multiplier
    }
    
    fmt.Println(calculate(10)) // 30

    // 3. 延迟执行 defer (Go 独有,常用于资源清理)
    defer fmt.Println("This runs at the end of functionDemo")
    
    // 4. 处理多返回值
    query, err := buildQuery("users", nil, "id", "name")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println(query)
}

3. JavaScript 函数特性

// 1. 默认参数与剩余参数 (Rest parameters)
function buildQuery(table, conditions = {}, ...fields) {
    const selectFields = fields.length === 0 ? "*" : fields.join(", ");
    let sql = `SELECT ${selectFields} FROM ${table}`;
    
    const condKeys = Object.keys(conditions);
    if (condKeys.length > 0) {
        const where = condKeys.map(k => `${k}=${conditions[k]}`).join(" AND ");
        sql += ` WHERE ${where}`;
    }
    return sql;
}

// 2. 匿名函数赋值
const calculate = function(number) {
    return number * multiplier; // 依赖外部作用域变量
};

// 3. 箭头函数 (Arrow Functions, 不绑定自己的 this)
let multiplier = 3;
const calculateArrow = (number) => number * multiplier;

// 4. 解构赋值传参
function processUser({ id, username, roles = [] }) {
    console.log(`Processing ${username} (ID: ${id}) with roles: ${roles}`);
}

const userObj = { id: 1, username: "admin", email: "a@a.com" };
processUser(userObj); // 只提取需要的字段

第四部分:面向对象 (OOP) 与结构体

  • PHP:经典的基于类的单继承 OOP(Class, Interface, Abstract, Trait)。
  • Go:没有 Class 和继承。通过 Struct(结构体)封装数据,通过给结构体绑定方法实现行为,通过 Interface(鸭子类型)实现多态,通过结构体嵌套实现组合。
  • JavaScript:基于原型链(Prototype)。ES6 引入了 class 语法糖。

1. PHP 的经典 OOP

<?php

// 接口定义契约
interface LoggerInterface {
    public function log(string $message): void;
}

// Trait 代码复用机制
trait TimestampTrait {
    protected function getTimestamp(): string {
        return date('Y-m-d H:i:s');
    }
}

// 抽象类
abstract class BaseService {
    protected LoggerInterface $logger;
    
    public function __construct(LoggerInterface $logger) {
        $this->logger = $logger;
    }
    
    abstract public function execute(): bool;
}

// 具体实现类
class PaymentService extends BaseService {
    use TimestampTrait;

    // PHP 8.0 构造器属性提升
    public function __construct(
        LoggerInterface $logger,
        private float $amount
    ) {
        parent::__construct($logger);
    }

    public function execute(): bool {
        $time = $this->getTimestamp();
        $this->logger->log("[{$time}] Processing payment of {$this->amount}");
        return true;
    }
}

// 匿名类实现接口
$consoleLogger = new class implements LoggerInterface {
    public function log(string $message): void {
        echo "CONSOLE: $message\n";
    }
};

$service = new PaymentService($consoleLogger, 99.99);
$service->execute();

2. Go 的结构体与接口 (组合与鸭子类型)

package main

import (
    "fmt"
    "time"
)

// 1. 接口定义 (Go 的接口是隐式实现的)
type Logger interface {
    Log(message string)
}

// 2. 结构体 (代替类)
type ConsoleLogger struct {
    Prefix string
}

// 3. 为结构体绑定方法 (实现 Logger 接口)
// 只要实现了 Log 方法,它就是 Logger
func (c *ConsoleLogger) Log(message string) {
    fmt.Printf("%s: %s\n", c.Prefix, message)
}

// 4. 基础服务结构体
type BaseService struct {
    logger Logger // 依赖注入
}

// 5. 具体服务结构体 (通过嵌套实现类似继承的"组合")
type PaymentService struct {
    BaseService // 匿名嵌套,继承了 BaseService 的字段
    Amount      float64
}

// 为 PaymentService 定义方法
func (p *PaymentService) Execute() bool {
    timestamp := time.Now().Format("2006-01-02 15:04:05")
    msg := fmt.Sprintf("[%s] Processing payment of %.2f", timestamp, p.Amount)
    // 调用嵌套结构体中的 logger
    p.logger.Log(msg)
    return true
}

func oopDemo() {
    logger := &ConsoleLogger{Prefix: "SYS_LOG"}
    
    service := &PaymentService{
        BaseService: BaseService{logger: logger},
        Amount:      99.99,
    }
    
    service.Execute()
}

3. JavaScript 的 Class 语法糖

// JS 没有内置的 Interface,通常靠文档或 TypeScript 约束

// 1. 定义类
class BaseService {
    // 私有字段 (ES2022+)
    #logger;

    constructor(logger) {
        this.#logger = logger;
    }

    // Getter
    get logger() {
        return this.#logger;
    }

    // 抛出错误模拟抽象方法
    execute() {
        throw new Error("Method 'execute()' must be implemented.");
    }
}

// 2. 继承
class PaymentService extends BaseService {
    #amount;

    constructor(logger, amount) {
        super(logger); // 必须调用 super
        this.#amount = amount;
    }

    // 私有方法
    #getTimestamp() {
        return new Date().toISOString();
    }

    // 方法重写
    execute() {
        const time = this.#getTimestamp();
        this.logger.log(`[${time}] Processing payment of ${this.#amount}`);
        return true;
    }
}

// 3. 对象字面量实现依赖 (鸭子类型)
const consoleLogger = {
    log: function(message) {
        console.log(`CONSOLE: ${message}`);
    }
};

const service = new PaymentService(consoleLogger, 99.99);
service.execute();

第五部分:错误与异常处理

1. PHP (Try-Catch)

<?php
class CustomDatabaseException extends Exception {}

function connectDB(string $host) {
    if (empty($host)) {
        // 抛出异常
        throw new CustomDatabaseException("Host cannot be empty");
    }
    // 模拟连接成功
    return true;
}

try {
    connectDB("");
} catch (CustomDatabaseException $e) {
    error_log("DB Error: " . $e->getMessage());
} catch (Exception $e) {
    // 捕获其他所有异常
    error_log("General Error: " . $e->getMessage());
} finally {
    // 无论是否报错都会执行,常用于释放资源
    echo "Cleanup resources.\n";
}

2. Go (Error 值返回与 Panic)

Go 不推荐使用类似 try-catch 的控制流,而是将错误作为普通的返回值处理。

package main

import (
    "errors"
    "fmt"
)

// 定义自定义错误变量
var ErrEmptyHost = errors.New("host cannot be empty")

func connectDB(host string) (bool, error) {
    if host == "" {
        // 返回错误值
        return false, ErrEmptyHost
    }
    return true, nil
}

func errorDemo() {
    success, err := connectDB("")
    
    // 显式检查错误 (Go 的标志性写法)
    if err != nil {
        // 错误判定 (Go 1.13+)
        if errors.Is(err, ErrEmptyHost) {
            fmt.Println("DB Error: Provided host is empty.")
        } else {
            fmt.Println("Unknown Error:", err)
        }
        return
    }
    
    fmt.Println("Connected:", success)
    
    // Go 中的 Panic/Recover 仅用于极其严重的不可恢复错误
    // 类似于 try-catch,但不应作为常规业务逻辑
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from panic:", r)
        }
    }()
    // panic("Critical failure!") 
}

3. JavaScript (Try-Catch)

class CustomDatabaseError extends Error {
    constructor(message) {
        super(message);
        this.name = "CustomDatabaseError";
    }
}

function connectDB(host) {
    if (!host) {
        throw new CustomDatabaseError("Host cannot be empty");
    }
    return true;
}

try {
    connectDB("");
} catch (error) {
    if (error instanceof CustomDatabaseError) {
        console.error("DB Error:", error.message);
    } else {
        console.error("General Error:", error);
    }
} finally {
    console.log("Cleanup resources.");
}

第六部分:并发与异步处理 (核心差异)

这是三种语言差异最大的地方:

  • PHP:传统模型是同步阻塞的(多进程模型,如 PHP-FPM),每次请求一个进程。
  • Go:天生为并发设计。使用轻量级的 goroutinechannel 进行通信。
  • JavaScript:单线程事件循环。使用回调、Promiseasync/await 处理非阻塞 I/O。

1. PHP (同步阻塞)

<?php
// PHP 原生核心不支持非阻塞异步(不借助 Swoole/ReactPHP 等扩展)
function fetchData(string $url): string {
    sleep(2); // 模拟耗时网络请求,这里会阻塞整个进程
    return "Data from $url";
}

echo "Start\n";
$data1 = fetchData("API_1"); // 阻塞 2 秒
$data2 = fetchData("API_2"); // 阻塞 2 秒
echo "End: $data1, $data2\n"; // 总耗时 4 秒

2. Go (Goroutines 与 Channels)

Go 语言可以通过 go 关键字瞬间启动成千上万个并发任务。

package main

import (
    "fmt"
    "sync"
    "time"
)

// 模拟耗时请求
func fetchData(url string, ch chan<- string, wg *sync.WaitGroup) {
    defer wg.Done() // 函数结束时通知 WaitGroup 完成
    
    time.Sleep(2 * time.Second) // 模拟耗时
    // 将结果发送到通道 Channel
    ch <- fmt.Sprintf("Data from %s", url) 
}

func concurrencyDemo() {
    fmt.Println("Start")
    
    // 创建一个通道用于接收结果
    results := make(chan string, 2)
    // WaitGroup 用于等待所有 goroutine 完成
    var wg sync.WaitGroup
    
    urls := []string{"API_1", "API_2"}
    
    for _, url := range urls {
        wg.Add(1)
        // 开启 Goroutine 并发执行
        go fetchData(url, results, &wg) 
    }
    
    // 开启一个后台 Goroutine 等待所有任务完成并关闭通道
    go func() {
        wg.Wait()
        close(results)
    }()
    
    // 从通道中读取数据(阻塞直到有数据或通道关闭)
    for data := range results {
        fmt.Println("Received:", data)
    }
    
    fmt.Println("End") // 总耗时约 2 秒
}

3. JavaScript (Async / Await 与 Promise)

JS 使用异步非阻塞 I/O,主线程不等待,而是把回调挂起。

// 模拟返回 Promise 的耗时请求
function fetchData(url) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(`Data from ${url}`);
        }, 2000);
    });
}

// 使用 async/await 以同步的代码风格写异步逻辑
async function main() {
    console.log("Start");
    
    try {
        // 并发执行多个 Promise
        const [data1, data2] = await Promise.all([
            fetchData("API_1"),
            fetchData("API_2")
        ]);
        
        console.log(`Received: ${data1}`);
        console.log(`Received: ${data2}`);
    } catch (error) {
        console.error("Async Error:", error);
    }
    
    console.log("End"); // 总耗时约 2 秒
}

main();

总结

  • PHP:围绕 Web 请求生命周期构建,数组操作极其灵活,OOP 体系严谨成熟,适合快速开发传统 Web 后端。
  • Go:静态编译,极简语法,舍弃了传统类继承,以强悍的并发能力(Goroutine/Channel)和极高的运行效率见长,适合微服务和云原生架构。
  • JavaScript:一处编写到处运行,对象和函数高度灵活,以事件循环和异步非阻塞为核心,全栈(Node.js + 前端)开发的霸主。

Node.js/Express 实现 AI 流式输出 (SSE) 踩的坑:为什么客户端会“瞬间断开连接”?

作者 兔司基
2026年3月27日 10:52

Node.js/Express 实现 AI 流式输出 (SSE) 的深坑:为什么客户端会“瞬间断开连接”?

1. 背景与现象

最近在做一个基于 Node.js 和 Express 的 AI 爆款文案生成器(接入了类似 OpenAI/硅基流动的 API)。为了实现打字机效果,我使用了 Server-Sent Events (SSE) 技术将 AI 的返回结果流式推送到前端。

然而,在测试时遇到了一个极其诡异的 Bug:

  • 后端日志 :每次收到请求后,刚准备推送数据,瞬间就打印出 客户端提前断开连接,终止 AI 请求 。
  • 前端现象 :浏览器 Network 里的请求瞬间变成 Canceled (取消),或者终端用 curl 测试时直接卡死(Hanging),收不到任何数据。
  • 迷惑性 :一开始以为是前端组件刷新导致请求中断,或者网络代理拦截,排查了一圈发现都不是。

2. 罪魁祸首排查过程

问题出在后端代码里判断“客户端是否断开”的逻辑上。在处理长连接时,我们通常需要在客户端断开时停止向外推流,以节省服务器资源。最初的代码是这样的:

// 错误写法 1:依赖 Express 的 req.
closed 属性
for await (const chunk of stream) {
  if (req.closed) { 
    console.log('客户端提前断开');
    break; 
  }
  res.write(`data: ${content}\n\n`);
  res.flush?.(); // 强制刷新
}

或者这样的:

// 错误写法 2:依赖 HTTP 请求的 close 事件
req.on('close', () => {
  console.log('客户端断开');
  isDisconnected = true;
});

为什么会翻车?

  1. req.closed 的假阳性 :在 Express 5 及某些 Node.js 版本中, req.closed 属性在长连接流式响应下非常不可靠。它有时会在请求体(Body)被解析完成或者发送了第一批响应头后, 错误地将状态标记为 true ,导致后端误杀正常的请求。
  2. req.on('close') 提前触发 :同理,HTTP 层的 close 事件有时代表的是“请求接收完毕”,而不是“连接彻底断开”。
  3. res.flush() 杀手 :在没有正确引入压缩中间件的情况下,盲目调用 res.flush() 会破坏底层的 chunked 数据流状态,甚至直接导致底层 Socket 异常关闭。

3. 终极解决指南(正确姿势)

为了完美实现 SSE 并准确监听客户端断开,需要做以下三个关键的调整:

关键点一:放弃 req.closed,监听底层 TCP Socket

不要监听 HTTP 请求层的 close ,而是直接监听最底层的网络 Socket。只有 Socket 关了,才是真的断开了。

let isClientDisconnected = false;

// 正确姿势:监听底层的 socket 断开
req.socket.on('close', () => {
  isClientDisconnected = true;
  console.log('底层 socket 真实断开');
});

关键点二:一次性规范地设置 SSE Header

使用 res.writeHead 一次性下发所有头部,并务必加上 X-Accel-Buffering: no ,这能防止 Nginx 等反向代理层因为缓冲而导致数据卡顿。

// 正确姿势:使用 writeHead 并禁用代理缓冲
res.writeHead(200, {
  'Content-Type': 'text/event-stream; charset=utf-8',
  'Cache-Control': 'no-cache, no-transform',
  'Connection': 'keep-alive',
  'X-Accel-Buffering': 'no' 
});

关键点三:移除所有手动的 res.flush()

Node.js 只要设置了正确的流式头部,在调用 res.write() 时底层会自动处理数据分块传输(Chunked Encoding),不需要、也不应该再手动调用 res.flush() 。

for await (const chunk of stream) {
  if (isClientDisconnected) break;
  
  const content = chunk.choices[0]?.delta?.content || '';
  if (content) {
    res.write(`data: ${JSON.stringify({ text: content })}\n\n`);
    // 删掉这行:(res as any).flush();
  }
}

4. 总结

在 Node.js 中做大模型流式输出时:

  1. 监听断开请认准 req.socket.on('close') 。
  2. Header 里带上 X-Accel-Buffering: no 。
  3. 相信 Node.js 的流管理,不要乱用 flush() 。

Qt Quick Controls 控件库、样式与布局(八)

作者 HelloReader
2026年3月27日 10:49

适合人群: 已掌握 Qt Quick 基础视觉元素,想使用完整 UI 控件库的开发者

前言

上一篇我们用 RectangleTextMouseArea 手工搭建了 UI 组件。但在实际开发中,按钮、输入框、复选框、滑块这些常见控件不需要从零造——Qt Quick Controls 模块提供了一套完整的、开箱即用的 UI 控件库。

本文系统介绍 Qt Quick Controls 的核心控件、控件解剖结构、内置样式,以及如何用 Layouts 模块管理控件的排列与尺寸。


一、什么是 Qt Quick Controls

QtQuick.Controls 是建立在 Qt Quick 之上的控件模块,提供了:

  • 按钮类:ButtonCheckBoxRadioButtonSwitch
  • 输入类:TextFieldTextAreaSliderSpinBoxComboBox
  • 容器类:GroupBoxFrameScrollViewTabBar
  • 弹窗类:DialogPopupMenuDrawer
  • 导航类:StackViewSwipeViewPageIndicator
  • 显示类:LabelProgressBarBusyIndicator

导入方式:

import QtQuick.Controls

二、控件的解剖结构

理解 Qt Quick Controls 的关键是理解每个控件由哪些部分组成。以 Button 为例:

Button
├── background   ← 背景(Rectangle、图片等)
├── contentItem  ← 内容区域(通常是 Text 或 Icon)
├── indicator    ← 指示器(CheckBox 的勾选框等)
└── overlay      ← 覆盖层(按下时的涟漪效果等)

这个结构意味着你可以单独替换任意部分来自定义外观,而不需要重写整个控件:

Button {
    text: "自定义按钮"

    // 只替换背景,保留其他默认行为
    background: Rectangle {
        radius: 8
        color: parent.pressed ? "#2C72C7" : "#4A90E2"
        border.width: 0
    }

    // 只替换文字样式
    contentItem: Text {
        text: parent.text
        font.pixelSize: 15
        font.bold: true
        color: "white"
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment: Text.AlignVCenter
    }
}

三、内置样式

Qt Quick Controls 提供了几套内置样式,无需任何代码即可改变所有控件的外观。

可用样式

样式名 特点
Basic 极简风格,白色背景,适合自定义的起点
Fusion 跨平台桌面风格,类 Qt Widgets 外观
Material Google Material Design 风格
Universal Windows Universal 风格
iOS Apple iOS 风格(需在 iOS 平台)
macOS macOS 原生风格
Windows Windows 原生风格

设置全局样式

方式一:在 main.cpp 中设置(推荐)

#include <QQuickStyle>

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);
    QQuickStyle::setStyle("Material");    // 设置为 Material 风格

    QQmlApplicationEngine engine;
    // ...
}

方式二:通过环境变量

QT_QUICK_CONTROLS_STYLE=Material ./MyApp

方式三:在 qtquickcontrols2.conf 配置文件中设置

在项目根目录创建 qtquickcontrols2.conf,并在 CMakeLists.txt 中注册为资源:

[Controls]
Style=Material

[Material]
Theme=Light
Accent=Blue
Primary=BlueGrey

Material 样式示例

import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material

ApplicationWindow {
    width: 360; height: 500
    visible: true

    // Material 样式全局配置
    Material.theme: Material.Light
    Material.accent: Material.Blue
    Material.primary: Material.BlueGrey

    Column {
        anchors.centerIn: parent
        spacing: 16
        width: 280

        Button {
            width: parent.width
            text: "普通按钮"
        }

        Button {
            width: parent.width
            text: "高亮按钮"
            highlighted: true    // Material 风格下显示强调色
        }

        Button {
            width: parent.width
            text: "扁平按钮"
            flat: true
        }
    }
}

四、按钮类控件

4.1 Button

Button {
    text: "提交"
    enabled: true          // 是否可点击
    highlighted: false     // 强调样式(Material 风格有明显效果)
    flat: false            // 扁平样式(无边框背景)
    checkable: false       // 是否可切换选中状态
    icon.source: "images/send.png"    // 图标

    onClicked: console.log("提交")
    onPressAndHold: console.log("长按")
}

4.2 CheckBox — 复选框

Column {
    spacing: 8

    CheckBox {
        id: agreeCheck
        text: "我已阅读并同意用户协议"
        checked: false
        onCheckedChanged: console.log("同意状态:" + checked)
    }

    CheckBox {
        text: "订阅新闻邮件"
        checked: true
    }

    Button {
        text: "提交"
        enabled: agreeCheck.checked    // 绑定:勾选协议后才可提交
    }
}

4.3 RadioButton — 单选框

同一 ButtonGroup 中的单选框互斥:

import QtQuick
import QtQuick.Controls

Column {
    spacing: 8

    Label {
        text: "选择性别:"
        font.bold: true
    }

    ButtonGroup {
        id: genderGroup
    }

    RadioButton {
        text: "男"
        ButtonGroup.group: genderGroup
        checked: true
    }

    RadioButton {
        text: "女"
        ButtonGroup.group: genderGroup
    }

    RadioButton {
        text: "不愿透露"
        ButtonGroup.group: genderGroup
    }

    Label {
        text: "已选:" + genderGroup.checkedButton?.text
        color: "#888"
        font.pixelSize: 13
    }
}

4.4 Switch — 开关

Column {
    spacing: 12

    Switch {
        id: wifiSwitch
        text: "Wi-Fi"
        checked: true
        onCheckedChanged: console.log("Wi-Fi:" + (checked ? "开" : "关"))
    }

    Switch {
        text: "蓝牙"
        checked: false
    }

    Switch {
        text: "深色模式"
        checked: false
    }
}

五、输入类控件

5.1 TextField — 单行输入

Column {
    spacing: 12
    width: 280

    TextField {
        id: emailField
        width: parent.width
        placeholderText: "邮箱地址"
        inputMethodHints: Qt.ImhEmailCharactersOnly    // 键盘类型提示
        validator: RegularExpressionValidator {
            regularExpression: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/
        }
    }

    TextField {
        width: parent.width
        placeholderText: "密码"
        echoMode: TextInput.Password    // 密码掩码
    }

    TextField {
        width: parent.width
        placeholderText: "手机号"
        inputMethodHints: Qt.ImhDigitsOnly    // 只允许数字键盘
        maximumLength: 11
    }
}

5.2 TextArea — 多行输入

ScrollView {
    width: 300
    height: 150

    TextArea {
        placeholderText: "请输入详细描述..."
        wrapMode: TextArea.Wrap
        font.pixelSize: 14
    }
}

5.3 Slider — 滑块

Column {
    spacing: 16
    width: 300

    // 水平滑块
    Row {
        spacing: 12
        Label {
            anchors.verticalCenter: parent.verticalCenter
            text: "音量"
            width: 40
        }
        Slider {
            id: volumeSlider
            width: 200
            from: 0; to: 100; value: 70
            stepSize: 1
        }
        Label {
            anchors.verticalCenter: parent.verticalCenter
            text: Math.round(volumeSlider.value)
            width: 30
        }
    }

    // 垂直滑块
    Slider {
        orientation: Qt.Vertical
        height: 120
        from: 0; to: 100; value: 50
    }
}

5.4 SpinBox — 数字输入框

Row {
    spacing: 12
    Label {
        anchors.verticalCenter: parent.verticalCenter
        text: "数量:"
    }
    SpinBox {
        from: 1
        to: 99
        value: 1
        stepSize: 1
        editable: true    // 允许直接键盘输入
    }
}

5.5 ComboBox — 下拉选择框

Column {
    spacing: 12
    width: 240

    ComboBox {
        width: parent.width
        model: ["北京", "上海", "广州", "深圳", "杭州"]
        onCurrentIndexChanged: console.log("选中:" + currentText)
    }

    // 可编辑的 ComboBox
    ComboBox {
        width: parent.width
        editable: true
        model: ListModel {
            ListElement { text: "苹果" }
            ListElement { text: "香蕉" }
            ListElement { text: "橙子" }
        }
        onAccepted: {
            if (find(editText) === -1)
                model.append({ text: editText })    // 添加新选项
        }
    }
}

六、Layouts 布局模块

QtQuick.Layouts 提供了比 anchors 更强大的布局管理,特别适合需要自适应尺寸的 UI。

导入:

import QtQuick.Layouts

6.1 RowLayout — 水平布局

RowLayout {
    width: 400
    spacing: 8

    Button { text: "取消" }

    Item { Layout.fillWidth: true }    // 弹性空间,把后面的按钮推到右边

    Button { text: "确认"; highlighted: true }
}

6.2 ColumnLayout — 垂直布局

ColumnLayout {
    width: 300
    spacing: 12

    Label { text: "用户名" }

    TextField {
        Layout.fillWidth: true    // 填满父布局宽度
        placeholderText: "请输入用户名"
    }

    Label { text: "密码" }

    TextField {
        Layout.fillWidth: true
        echoMode: TextInput.Password
        placeholderText: "请输入密码"
    }

    Button {
        Layout.fillWidth: true
        Layout.topMargin: 8
        text: "登录"
        highlighted: true
    }
}

6.3 GridLayout — 网格布局

GridLayout {
    columns: 2
    columnSpacing: 12
    rowSpacing: 12
    width: 320

    Label { text: "姓名:" }
    TextField { Layout.fillWidth: true; placeholderText: "请输入姓名" }

    Label { text: "手机:" }
    TextField { Layout.fillWidth: true; placeholderText: "请输入手机号" }

    Label { text: "城市:" }
    ComboBox {
        Layout.fillWidth: true
        model: ["北京", "上海", "广州"]
    }

    // 跨列的按钮
    Button {
        Layout.columnSpan: 2
        Layout.fillWidth: true
        text: "提交"
        highlighted: true
    }
}

6.4 Layout 附加属性

在子元素上使用 Layout.* 属性控制其在布局中的行为:

RowLayout {
    width: 400

    Button {
        text: "固定宽度"
        Layout.preferredWidth: 100    // 期望宽度
        Layout.minimumWidth: 80       // 最小宽度
        Layout.maximumWidth: 120      // 最大宽度
    }

    TextField {
        Layout.fillWidth: true        // 填满剩余空间
        Layout.preferredHeight: 40
    }

    Button {
        text: "搜索"
        Layout.alignment: Qt.AlignVCenter    // 垂直居中对齐
    }
}

七、综合示例:用户注册表单

整合本文所有知识点,构建一个完整的注册表单:

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

ApplicationWindow {
    width: 400
    height: 580
    visible: true
    title: "用户注册"

    // 整体滚动支持
    ScrollView {
        anchors.fill: parent
        contentWidth: availableWidth

        ColumnLayout {
            width: parent.width
            spacing: 0

            // 顶部标题区
            Rectangle {
                Layout.fillWidth: true
                height: 100
                color: "#4A90E2"

                Column {
                    anchors.centerIn: parent
                    spacing: 4

                    Text {
                        anchors.horizontalCenter: parent.horizontalCenter
                        text: "创建账号"
                        font.pixelSize: 22
                        font.bold: true
                        color: "white"
                    }

                    Text {
                        anchors.horizontalCenter: parent.horizontalCenter
                        text: "加入我们,开始你的旅程"
                        font.pixelSize: 13
                        color: "#d0e8ff"
                    }
                }
            }

            // 表单区域
            ColumnLayout {
                Layout.fillWidth: true
                Layout.margins: 24
                spacing: 16

                // 姓名行
                RowLayout {
                    Layout.fillWidth: true
                    spacing: 12

                    ColumnLayout {
                        Layout.fillWidth: true
                        spacing: 4
                        Label { text: "姓"; font.pixelSize: 13; color: "#555" }
                        TextField {
                            Layout.fillWidth: true
                            placeholderText: "姓氏"
                        }
                    }

                    ColumnLayout {
                        Layout.fillWidth: true
                        spacing: 4
                        Label { text: "名"; font.pixelSize: 13; color: "#555" }
                        TextField {
                            Layout.fillWidth: true
                            placeholderText: "名字"
                        }
                    }
                }

                // 邮箱
                ColumnLayout {
                    Layout.fillWidth: true
                    spacing: 4
                    Label { text: "邮箱地址"; font.pixelSize: 13; color: "#555" }
                    TextField {
                        id: emailField
                        Layout.fillWidth: true
                        placeholderText: "example@email.com"
                        inputMethodHints: Qt.ImhEmailCharactersOnly
                    }
                }

                // 密码
                ColumnLayout {
                    Layout.fillWidth: true
                    spacing: 4
                    Label { text: "密码"; font.pixelSize: 13; color: "#555" }
                    TextField {
                        id: passwordField
                        Layout.fillWidth: true
                        placeholderText: "至少 8 位字符"
                        echoMode: TextInput.Password
                    }
                    Label {
                        visible: passwordField.text.length > 0 && passwordField.text.length < 8
                        text: "密码长度不足 8 位"
                        font.pixelSize: 12
                        color: "#E24A4A"
                    }
                }

                // 城市选择
                ColumnLayout {
                    Layout.fillWidth: true
                    spacing: 4
                    Label { text: "所在城市"; font.pixelSize: 13; color: "#555" }
                    ComboBox {
                        Layout.fillWidth: true
                        model: ["请选择城市", "北京", "上海", "广州", "深圳", "杭州", "成都"]
                    }
                }

                // 性别选择
                ColumnLayout {
                    Layout.fillWidth: true
                    spacing: 4
                    Label { text: "性别"; font.pixelSize: 13; color: "#555" }
                    RowLayout {
                        ButtonGroup { id: genderGroup }
                        RadioButton {
                            text: "男"
                            ButtonGroup.group: genderGroup
                            checked: true
                        }
                        RadioButton {
                            text: "女"
                            ButtonGroup.group: genderGroup
                        }
                    }
                }

                // 接收通知
                CheckBox {
                    id: notifyCheck
                    text: "接收产品更新通知"
                    checked: true
                }

                // 同意协议
                CheckBox {
                    id: agreeCheck
                    text: "我已阅读并同意《用户协议》和《隐私政策》"
                    font.pixelSize: 13
                }

                // 注册按钮
                Button {
                    Layout.fillWidth: true
                    Layout.topMargin: 8
                    text: "立即注册"
                    highlighted: true
                    enabled: agreeCheck.checked &&
                             emailField.text.length > 0 &&
                             passwordField.text.length >= 8
                    onClicked: console.log("注册成功!邮箱:" + emailField.text)
                }

                // 登录跳转
                RowLayout {
                    Layout.alignment: Qt.AlignHCenter
                    Label {
                        text: "已有账号?"
                        font.pixelSize: 13
                        color: "#888"
                    }
                    Button {
                        text: "立即登录"
                        flat: true
                        font.pixelSize: 13
                        onClicked: console.log("跳转到登录页")
                    }
                }
            }
        }
    }
}

八、常见问题

Q:Layout.fillWidthanchors.fill 有什么区别?

  • anchors.fill:用于 anchors 定位系统,将元素尺寸绑定到父元素
  • Layout.fillWidth:用于 Layouts 布局系统,让元素占满布局中的剩余宽度

两套系统不能混用——在 RowLayout / ColumnLayout 的直接子元素上,用 Layout.* 属性,不要用 anchors(会产生冲突警告)。

Q:控件的默认尺寸从哪里来?

Qt Quick Controls 的每个控件都有 implicitWidthimplicitHeight,由控件内容自动计算。不设置 width/height 时控件使用 implicit 尺寸;放入 Layout 后可以用 Layout.fillWidth 覆盖。

Q:如何统一修改应用内所有按钮的字体大小?

使用 ApplicationWindow 上的 font 属性设置全局字体,所有控件会继承:

ApplicationWindow {
    font.pixelSize: 15
    font.family: "PingFang SC"
    // ...
}

总结

控件 / 概念 用途
Button 可点击按钮,支持图标、高亮、扁平样式
CheckBox 复选框,独立勾选状态
RadioButton 单选框,配合 ButtonGroup 实现互斥
Switch 开关控件,适合设置页面
TextField 单行输入,支持验证器和键盘类型提示
TextArea 多行输入,配合 ScrollView 使用
Slider 滑块,水平或垂直方向
SpinBox 数字步进框
ComboBox 下拉选择,支持可编辑模式
RowLayout 水平自适应布局
ColumnLayout 垂直自适应布局
GridLayout 网格布局,支持跨行跨列
Layout.* 附加属性 控制子元素在布局中的尺寸和对齐
内置样式 BasicMaterialFusion 等,全局切换控件外观

一次 CR 引发的思考:我的 rules.ts 构想,究竟属于哪种开发哲学?

作者 yuki_uix
2026年3月27日 10:45

在一次普通的 Code Review 里,我提出我从一篇讲解 Spec-Driven Development 的文章中受到启发,想把组件里复杂的业务逻辑抽离成一个 rules.ts 文件作为核心维护管理的源头文件。

没想到这个想法引来了团队里两位同学的不同回应——后端同学说"这更像是 Business-Driven Development",另一位前端同学说"其实这是面向 AI 编程的重构"。

我当时有点懵:我只是想让代码好维护一点,怎么突然冒出来三个不同的概念?它们说的是同一件事吗?还是各说各的?

这篇文章是我事后整理的一些思考,不是标准答案,更多是一次概念厘清的过程。


起因:一段让人"胃疼"的组件逻辑

场景大概是这样的:一个表单组件,里面有十几个字段,每个字段的显示、禁用、必填状态都依赖彼此的值,还受到用户角色、当前流程状态、后端返回的配置项等多个因素影响。

随着需求迭代,这些判断逻辑开始散落在 JSX 的各个 disabledhiddenrequired 属性里,或者藏在 useEffect 的某个角落。改一个需求要跳好几个地方,测试也很难覆盖。

我的想法是:把这些判断统一收进一个文件。

// env: React + TypeScript
// scene: extract form business rules into a single file

// OrderForm.rules.ts

export type OrderState = {
  items: CartItem[];
  address: Address | null;
  userRole: 'guest' | 'member' | 'vip';
  flowStatus: 'draft' | 'pending' | 'submitted';
  isPending: boolean;
};

// Can the order be submitted?
export const canSubmitOrder = (state: OrderState): boolean =>
  state.items.length > 0 &&
  state.address !== null &&
  !state.isPending &&
  state.flowStatus === 'draft';

// Why can't it be submitted? (for tooltip / disabled hint)
export const getSubmitDisabledReason = (state: OrderState): string | null => {
  if (state.items.length === 0) return 'Cart is empty';
  if (!state.address) return 'Please fill in the delivery address';
  if (state.flowStatus !== 'draft') return 'Order has been submitted';
  return null;
};

// Should the VIP discount field be shown?
export const shouldShowVipDiscount = (state: OrderState): boolean =>
  state.userRole === 'vip' && state.items.length > 0;

这些函数有几个共同点:纯函数、无副作用、不依赖任何 UI 层的东西。它们可以被独立测试,也可以被 AI copilot 单独修改,不会误伤组件渲染逻辑。

然后 CR 的时候,争论来了。


三个概念,说的是同一件事吗?

Spec-Driven Development:先定规范,再写实现

后端同学最初提的是 Spec-Driven Development(规范驱动开发) ,但随即他自己也觉得这个词不太准。

SDD 的核心是:有一份"单一真相来源"(Single Source of Truth),开发围绕它展开。最典型的例子是 API 开发里先写 OpenAPI YAML,再生成 server stub 和 client SDK;或者先定 TypeScript 类型,再写实现。

我的 rules.ts 某种程度上也在做这件事——先定义"业务规则是什么",再让组件去引用它。这个文件就是那份 source of truth。

但 SDD 更多强调的是流程,而不是文件结构本身。它关心的是"你是不是先写了规范才动手写实现",而不是"你把规范放在哪里"。

Business-Driven Development:让代码说人话

后端同学后来改口说"其实更像是 BDD",这里他说的不是测试领域里的 Behavior-Driven Development(那个 BDD 特指用 Gherkin 语法写测试用例的实践),而是一种更广义的业务驱动思想:代码要贴近业务语言,要让不写代码的人也能读懂意图

这背后其实是 DDD(领域驱动设计)里"通用语言(Ubiquitous Language)"的落地——领域专家、产品、开发用同一套词汇描述同一件事。

放在我的场景里,这层意思是:rules.ts 里的函数名要用业务词汇。

// Business-oriented naming (recommended)
export const canSubmitOrder = ...
export const shouldShowVipDiscount = ...
export const isAddressRequired = ...

// Technical-oriented naming (harder to maintain)
export const checkFlag = ...
export const validateConditionA = ...
export const controlVisibility = ...

前者用业务动词命名,看名字就能理解意图;后者命名模糊,要深入读代码才能知道它在判断什么业务规则。

这个维度说的是语义,和放不放在一个文件里其实是两回事,但两者结合起来会更有价值。

AI-First Refactoring:为工具协作重新组织代码

另一位前端同学说的"面向 AI 编程的重构",是最近才开始被广泛讨论的工程实践,目前还没有一个统一的正式名字。

它的核心假设是:AI copilot 在处理小的、单一职责的、自描述的文件时效果最好。一个 500 行的组件文件里混杂着 UI 结构、样式逻辑、副作用和业务判断,AI 改起来容易"误伤";而一个 60 行的 rules.ts,里面只有纯函数和类型定义,AI 一眼就能理解意图,改起来精准且可预测。

从这个角度看,rules.ts 是在为 AI 协作创造一个"安全操作区":

OrderForm/
  index.tsx           ← UI structure, calls rules, doesn't contain logic
  OrderForm.rules.ts  ← pure business rules, safe area for AI to operate
  OrderForm.test.ts   ← only tests rules, no need to mount the component
  OrderForm.types.ts  ← shared type definitions

这个维度说的是工具链适配,和前两个是完全不同的维度。


那我的构想到底叫什么?

把三位同学的视角放在一起,我意识到他们说的其实是同一件事的三个面:

维度 概念 对 rules.ts 的意义
流程 Spec-Driven rules.ts 是规范,先写它再写组件
语义 Business-Driven rules.ts 里要用业务词汇命名
工具 AI-First 小文件单职责,让 AI 每次只改一处

但如果要给这个模式找一个最贴切的名字,我查阅资料后觉得它最接近的是业务规则外置(Business Rules Externalization) ,这是企业架构领域的成熟实践;在面向对象设计里,有时也叫 Policy Object 模式

思路很简单:把"判断逻辑"从"执行逻辑"里分离出来,单独成文件,单独测试,单独演化。

这种模式以前在前端的存在感不强,因为 Redux、MobX 这类状态管理库的 action/reducer 结构在一定程度上替代了它。但在 AI copilot 普及之后,它的价值被重新放大了——不只是为了人类维护,也是为了让 AI 能精准地修改业务规则,而不是在一个巨型组件文件里大海捞针。


落地时的几个小取舍

这个模式并不是银弹,在考虑引入的时候有几个地方值得权衡,我目前的理解是这样的(不一定对):

适合放进 rules.ts 的:

  • 返回 boolean 的状态判断(canXxxshouldXxxisXxx
  • 返回提示文案的逻辑(getXxxMessagegetXxxReason
  • 基于当前状态的派生值计算(getXxxConfig

不太适合放进去的:

  • 需要调用 API 的异步逻辑(那更适合放在 service 或 hook 里)
  • 直接操作 DOM 或依赖 React context 的逻辑(破坏了纯函数的特性)
  • 过于简单的单行判断(items.length > 0,直接内联更清晰)

还有一个细节:当 rules.ts 里的函数数量增多,可以考虑按业务子域继续拆分,比如 OrderForm.submit.rules.tsOrderForm.display.rules.ts,而不是一个文件越堆越大。


延伸想了一些问题

在整理这些思路的过程中,我产生了几个新的疑问,暂时还没有答案:

  1. 规则文件里的测试应该怎么组织? 纯函数很好测,但当 OrderState 的字段越来越多,构造 mock 数据会变得繁琐,有没有更好的测试策略?
  2. 如果规则本身来自后端配置(比如 feature flag 或动态表单配置),这个模式还成立吗? 这时候"规则"本身是运行时数据,而不是编译时代码,边界就模糊了。
  3. 这和 Zod 之类的 schema 验证库是什么关系? 两者都在做"约束表达",但侧重点不同——Zod 偏数据合法性校验,rules.ts 偏业务状态判断。能不能配合使用?
  4. AI 工具真的会更倾向于修改小文件吗? 这个假设我还没有系统性地验证过,只是直觉上觉得合理。

小结

回头看这次 CR 的对话,三位同学说的都有道理,只是各自在不同的维度切入。Spec-Driven、Business-Driven、AI-First,这三个标签并不是互斥的选择,它们描述的是同一个设计决策在不同语境下的意义。

把业务规则从组件里抽出来,这件事本身并不新鲜;但在 AI 工具成为日常开发协作者的今天,这种结构的价值被重新放大了。它既是给人类读者的清晰表达,也是给 AI 工具的精准操作界面。

这让我开始思考:我们在做代码组织决策的时候,"对 AI 是否友好",会不会慢慢变成和"可读性"、"可测试性"同等重要的考量维度?


参考资料

Qt Quick 视觉元素、交互与自定义组件(七)

作者 HelloReader
2026年3月27日 10:44

适合人群: 已理解 QML 基础语法,想掌握 Qt Quick 核心视觉元素的开发者

前言

上一篇我们系统学习了 QML 的语法机制——对象、属性、绑定、信号。本篇进入 Qt Quick 模块本身:它提供了哪些视觉元素,如何导入外部资源,如何处理用户输入,以及如何用 JavaScript 扩展交互逻辑。

Qt Quick 是建立在 QML 语言之上的标准组件库,是你构建实际界面的直接工具。

一、搭建项目基础

本文所有示例基于以下 CMakeLists.txt 配置:

cmake_minimum_required(VERSION 3.16)
project(QtQuickDemo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(Qt6 REQUIRED COMPONENTS Quick)
qt_standard_project_setup(REQUIRES 6.5)

qt_add_executable(QtQuickDemo main.cpp)

qt_add_qml_module(QtQuickDemo
    URI QtQuickDemo
    VERSION 1.0
    QML_FILES Main.qml
    RESOURCES
        images/background.jpg
        fonts/MyFont.ttf
)

target_link_libraries(QtQuickDemo PRIVATE Qt6::Quick)

main.cpp 使用标准模板不作修改,所有开发工作集中在 QML 文件中。


二、核心视觉元素

2.1 Item — 所有视觉元素的基类

Item 是 Qt Quick 中所有可视元素的基类,它本身不可见,但提供了所有视觉元素共有的属性:

import QtQuick

Item {
    width: 400
    height: 300

    // Item 的核心属性
    x: 0; y: 0            // 位置
    z: 0                  // 层叠顺序,数值大的在上层
    opacity: 1.0          // 透明度 0.0 ~ 1.0
    visible: true         // 是否显示
    clip: false           // 是否裁剪超出边界的子元素
    rotation: 0           // 旋转角度(度)
    scale: 1.0            // 缩放比例
}

2.2 Rectangle — 矩形

最常用的容器元素:

import QtQuick

Rectangle {
    width: 200
    height: 120
    color: "#4A90E2"
    radius: 12              // 圆角

    // 边框
    border.width: 2
    border.color: "#2C5F9E"

    // 渐变色
    gradient: Gradient {
        GradientStop { position: 0.0; color: "#6AB0F5" }
        GradientStop { position: 1.0; color: "#2C72C7" }
    }
}

2.3 Text — 文本

import QtQuick

Text {
    text: "Qt Quick 文本示例"

    // 字体设置
    font.family: "Arial"
    font.pixelSize: 18
    font.bold: true
    font.italic: false
    font.letterSpacing: 1.5    // 字间距

    // 颜色与对齐
    color: "#333333"
    horizontalAlignment: Text.AlignHCenter
    verticalAlignment: Text.AlignVCenter

    // 多行处理
    width: 300
    wrapMode: Text.WordWrap        // 自动换行
    elide: Text.ElideRight         // 超出显示省略号

    // 富文本
    textFormat: Text.RichText
    text: "普通文字 <b>加粗</b> <i>斜体</i> <font color='red'>红色</font>"
}

2.4 Image — 图片

import QtQuick

Image {
    width: 200
    height: 200
    source: "images/photo.jpg"     // 相对路径

    // 缩放模式
    fillMode: Image.PreserveAspectFit    // 保持比例,完整显示
    // fillMode: Image.PreserveAspectCrop  // 保持比例,裁剪填满
    // fillMode: Image.Stretch             // 拉伸填满(可能变形)

    // 加载状态
    onStatusChanged: {
        if (status === Image.Ready)
            console.log("图片加载完成")
        else if (status === Image.Error)
            console.log("图片加载失败")
    }
}

加载网络图片:

Image {
    source: "https://example.com/image.jpg"
    // 网络图片加载是异步的,status 会经历 Loading → Ready
}

三、导入外部资源

3.1 使用自定义字体

第一步:CMakeLists.txtRESOURCES 中注册字体文件。

第二步: 在 QML 中加载字体:

import QtQuick

Item {
    // 加载自定义字体
    FontLoader {
        id: customFont
        source: "fonts/MyFont.ttf"
    }

    Text {
        text: "自定义字体效果"
        font.family: customFont.name    // 使用加载的字体
        font.pixelSize: 24
    }
}

3.2 使用图片资源

注册到 CMakeLists.txt 后,在 QML 中直接用相对路径引用:

// 项目内资源(推荐)
Image { source: "images/logo.png" }

// 也可以用 qrc:/ 前缀显式引用
Image { source: "qrc:/QtQuickDemo/images/logo.png" }

四、定位:anchors 锚点系统

anchors 是 Qt Quick 中最灵活的定位机制,通过将一个元素的边与另一个元素的边对齐来定位。

4.1 基本锚点

Rectangle {
    id: parent_rect
    width: 400; height: 300

    // 贴左边
    Rectangle {
        width: 100; height: 100
        color: "red"
        anchors.left: parent.left
        anchors.top: parent.top
        anchors.margins: 10         // 所有方向留 10px 间距
    }

    // 贴右下角
    Rectangle {
        width: 100; height: 100
        color: "blue"
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        anchors.rightMargin: 10
        anchors.bottomMargin: 10
    }

    // 水平居中,垂直方向在顶部
    Rectangle {
        width: 100; height: 40
        color: "green"
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.top: parent.top
        anchors.topMargin: 10
    }
}

4.2 填满父容器

Rectangle {
    anchors.fill: parent               // 完全填满父容器
    anchors.margins: 16                // 四周留 16px 边距
}

4.3 相对于兄弟元素定位

Rectangle {
    id: firstBox
    width: 100; height: 100
    color: "orange"
    anchors.left: parent.left
    anchors.top: parent.top
    anchors.margins: 20
}

Rectangle {
    width: 100; height: 100
    color: "purple"
    anchors.left: firstBox.right      // 紧跟在 firstBox 右边
    anchors.leftMargin: 10
    anchors.top: firstBox.top         // 与 firstBox 顶部对齐
}

五、处理用户输入

5.1 MouseArea — 鼠标与触控

import QtQuick

Rectangle {
    width: 200
    height: 100
    color: clickArea.pressed ? "#2C72C7" : "#4A90E2"    // 按下时变深色
    radius: 8

    MouseArea {
        id: clickArea
        anchors.fill: parent

        // 常用信号
        onClicked: console.log("点击,位置:" + mouse.x + "," + mouse.y)
        onDoubleClicked: console.log("双击")
        onPressAndHold: console.log("长按")
        onEntered: console.log("鼠标进入")
        onExited: console.log("鼠标离开")

        // 接受右键
        acceptedButtons: Qt.LeftButton | Qt.RightButton
        onClicked: {
            if (mouse.button === Qt.RightButton)
                console.log("右键点击")
        }
    }

    Text {
        anchors.centerIn: parent
        text: clickArea.pressed ? "按住中..." : "点击我"
        color: "white"
        font.pixelSize: 16
    }
}

5.2 鼠标悬停效果

Rectangle {
    id: card
    width: 180; height: 100
    radius: 10
    color: "#f5f5f5"
    border.width: 1
    border.color: hoverArea.containsMouse ? "#4A90E2" : "#e0e0e0"
    scale: hoverArea.containsMouse ? 1.03 : 1.0     // 悬停时轻微放大

    // scale 属性变化自动有过渡效果(需配合 Behavior,见后续课程)

    MouseArea {
        id: hoverArea
        anchors.fill: parent
        hoverEnabled: true    // 必须启用才能检测 containsMouse
    }

    Text {
        anchors.centerIn: parent
        text: "悬停查看效果"
        color: hoverArea.containsMouse ? "#4A90E2" : "#666"
        font.pixelSize: 14
    }
}

六、使用 JavaScript 扩展逻辑

QML 原生支持 JavaScript,可以直接在属性绑定和信号处理器中写逻辑,也可以定义函数。

6.1 内联 JavaScript

import QtQuick

Rectangle {
    width: 300; height: 200

    property int score: 75

    Text {
        anchors.centerIn: parent
        font.pixelSize: 18

        // 三元表达式
        text: score >= 90 ? "优秀"
            : score >= 75 ? "良好"
            : score >= 60 ? "及格"
            :                "不及格"

        color: score >= 75 ? "#1D9E75" : "#E24A4A"
    }
}

6.2 定义函数

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 360; height: 300
    visible: true

    property real celsius: 0

    // 在 Item 内定义函数
    function celsiusToFahrenheit(c) {
        return (c * 9 / 5 + 32).toFixed(1)
    }

    function getTemperatureLabel(c) {
        if (c < 0)   return "冰点以下"
        if (c < 15)  return "寒冷"
        if (c < 25)  return "舒适"
        if (c < 35)  return "温热"
        return "炎热"
    }

    Column {
        anchors.centerIn: parent
        spacing: 16
        width: 280

        Slider {
            width: parent.width
            from: -20; to: 50; value: 0
            onValueChanged: celsius = value
        }

        Text {
            anchors.horizontalCenter: parent.horizontalCenter
            text: celsius.toFixed(1) + "°C  =  " + celsiusToFahrenheit(celsius) + "°F"
            font.pixelSize: 22
            font.bold: true
        }

        Text {
            anchors.horizontalCenter: parent.horizontalCenter
            text: getTemperatureLabel(celsius)
            font.pixelSize: 16
            color: "#888"
        }
    }
}

6.3 将 JS 逻辑抽离到独立文件

当 JavaScript 逻辑较复杂时,可以放入独立的 .js 文件:

新建 utils.js

// utils.js
.pragma library    // 声明为共享库,多个 QML 文件导入时只加载一次

function formatNumber(num) {
    return num.toLocaleString()
}

function clamp(value, min, max) {
    return Math.max(min, Math.min(max, value))
}

在 QML 中导入使用:

import "utils.js" as Utils

Text {
    text: Utils.formatNumber(1234567)    // 输出:1,234,567
}

七、创建自定义组件

7.1 提取独立组件文件

把一个"卡片"封装成可复用的组件 InfoCard.qml

// InfoCard.qml
import QtQuick

Rectangle {
    id: root

    // 对外暴露的属性接口
    property string title: "标题"
    property string subtitle: "副标题"
    property color accentColor: "#4A90E2"

    // 对外暴露的信号
    signal tapped()

    width: 240
    height: 90
    radius: 12
    color: "#ffffff"
    border.width: 1
    border.color: "#e8e8e8"

    // 左侧色条
    Rectangle {
        width: 4
        height: parent.height
        radius: 2
        color: root.accentColor
    }

    Column {
        anchors {
            left: parent.left
            leftMargin: 20
            verticalCenter: parent.verticalCenter
        }
        spacing: 4

        Text {
            text: root.title
            font.pixelSize: 16
            font.bold: true
            color: "#222"
        }

        Text {
            text: root.subtitle
            font.pixelSize: 13
            color: "#888"
        }
    }

    MouseArea {
        anchors.fill: parent
        onClicked: root.tapped()
    }
}

在主文件中使用:

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 360; height: 400
    visible: true

    Column {
        anchors.centerIn: parent
        spacing: 12

        InfoCard {
            title: "今日步数"
            subtitle: "8,432 步"
            accentColor: "#1D9E75"
            onTapped: console.log("点击了步数卡片")
        }

        InfoCard {
            title: "活跃时间"
            subtitle: "47 分钟"
            accentColor: "#4A90E2"
            onTapped: console.log("点击了时间卡片")
        }

        InfoCard {
            title: "消耗热量"
            subtitle: "312 千卡"
            accentColor: "#E2934A"
            onTapped: console.log("点击了热量卡片")
        }
    }
}

7.2 组件的属性别名(alias)

alias 让外部可以直接访问组件内部某个子元素的属性:

// SearchBar.qml
import QtQuick
import QtQuick.Controls

Rectangle {
    id: root
    height: 44
    radius: 22
    color: "#f5f5f5"
    border.width: 1
    border.color: "#e0e0e0"

    // alias 将内部 textField  text 属性暴露出去
    property alias searchText: textField.text
    property alias placeholderText: textField.placeholderText

    signal searchSubmitted(string query)

    TextField {
        id: textField
        anchors {
            left: parent.left
            right: submitBtn.left
            verticalCenter: parent.verticalCenter
            leftMargin: 16; rightMargin: 8
        }
        background: Item {}     // 去掉默认背景
        placeholderText: "搜索..."
        onAccepted: root.searchSubmitted(text)
    }

    Button {
        id: submitBtn
        anchors {
            right: parent.right
            verticalCenter: parent.verticalCenter
            rightMargin: 8
        }
        text: "搜索"
        flat: true
        onClicked: root.searchSubmitted(textField.text)
    }
}

使用时直接访问 searchText

SearchBar {
    id: bar
    width: 300
    onSearchSubmitted: function(query) {
        console.log("搜索:" + query)
    }
}

Text {
    text: "当前输入:" + bar.searchText    // 通过 alias 访问内部属性
}

八、综合示例:个人资料卡片

整合本文所有知识点,构建一个完整的个人资料展示组件:

// ProfileCard.qml
import QtQuick
import QtQuick.Controls

Rectangle {
    id: root

    property string avatarSource: ""
    property string name: "用户名"
    property string bio: "个人简介"
    property int followerCount: 0
    property int followingCount: 0

    signal followClicked()

    width: 320
    height: 200
    radius: 16
    color: "#ffffff"
    border.width: 0.5
    border.color: "#e8e8e8"

    // 顶部背景条
    Rectangle {
        width: parent.width
        height: 70
        color: "#4A90E2"
        radius: 16

        // 修复底部圆角
        Rectangle {
            width: parent.width
            height: 16
            anchors.bottom: parent.bottom
            color: parent.color
        }
    }

    // 头像
    Rectangle {
        id: avatarFrame
        width: 64; height: 64
        radius: 32
        color: "#e0e0e0"
        border.width: 3
        border.color: "white"
        anchors {
            left: parent.left
            leftMargin: 20
            top: parent.top
            topMargin: 38
        }

        Image {
            anchors.fill: parent
            anchors.margins: 2
            source: root.avatarSource
            fillMode: Image.PreserveAspectCrop
            layer.enabled: true
            layer.effect: null
        }

        // 无头像时显示首字母
        Text {
            anchors.centerIn: parent
            text: root.name.length > 0 ? root.name[0].toUpperCase() : "?"
            font.pixelSize: 24
            font.bold: true
            color: "#888"
            visible: root.avatarSource === ""
        }
    }

    // 关注按钮
    Button {
        text: "关注"
        anchors {
            right: parent.right
            rightMargin: 16
            top: parent.top
            topMargin: 80
        }
        onClicked: root.followClicked()
    }

    // 名字和简介
    Column {
        anchors {
            left: parent.left
            leftMargin: 20
            top: avatarFrame.bottom
            topMargin: 8
        }
        spacing: 2

        Text {
            text: root.name
            font.pixelSize: 16
            font.bold: true
            color: "#222"
        }

        Text {
            text: root.bio
            font.pixelSize: 13
            color: "#888"
            width: 200
            elide: Text.ElideRight
        }
    }

    // 粉丝数据
    Row {
        anchors {
            right: parent.right
            rightMargin: 20
            bottom: parent.bottom
            bottomMargin: 14
        }
        spacing: 16

        Column {
            horizontalItemAlignment: Qt.AlignHCenter
            Text {
                anchors.horizontalCenter: parent.horizontalCenter
                text: root.followerCount
                font.pixelSize: 15
                font.bold: true
                color: "#222"
            }
            Text {
                text: "粉丝"
                font.pixelSize: 11
                color: "#aaa"
            }
        }

        Column {
            horizontalItemAlignment: Qt.AlignHCenter
            Text {
                anchors.horizontalCenter: parent.horizontalCenter
                text: root.followingCount
                font.pixelSize: 15
                font.bold: true
                color: "#222"
            }
            Text {
                text: "关注"
                font.pixelSize: 11
                color: "#aaa"
            }
        }
    }
}

在主文件中使用:

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 400; height: 300
    visible: true

    ProfileCard {
        anchors.centerIn: parent
        name: "林小明"
        bio: "Qt 开发爱好者 · 嵌入式工程师"
        followerCount: 1248
        followingCount: 362
        onFollowClicked: console.log("点击关注")
    }
}

九、下一步

掌握了 Qt Quick 的核心视觉元素之后,建议继续:

  1. Introduction to Qt Quick Controls — 学习更完整的 UI 控件库(按钮、输入框、菜单、对话框等)
  2. Positioners and Layouts — 深入布局系统,让 UI 自适应不同屏幕尺寸
  3. QML Fluid Elements and Animation — 为界面加入流畅动画

总结

元素 / 概念 用途
Item 所有视觉元素的基类,提供位置、透明度、旋转等基础属性
Rectangle 矩形容器,支持圆角、边框、渐变
Text 文本显示,支持富文本、换行、省略
Image 图片显示,支持多种缩放模式和异步加载
FontLoader 加载自定义字体文件
anchors 锚点定位系统,通过边对齐实现灵活布局
MouseArea 处理鼠标和触控事件
JavaScript 函数 在 QML 中定义逻辑函数,复杂逻辑可抽离到 .js 文件
自定义组件 独立 .qml 文件封装,property 暴露接口,alias 透传内部属性

前端3D·Three.js一学就会系列:第二 画线

2026年3月27日 10:43

各位前端伙伴们,大家好,我是阿峰。最近开始入坑前端3D建站,跟大家一起慢慢深入three.js做网站3D。

今天给大家讲下three.js 画线


一、省略部分

官网,介绍,以及引入库,参看文章片头系列文章:01 第一个3D网站

二、使用方法

创建一个场景

const scene = new THREE.Scene();

创建一个透视摄像机

const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
camera.position.set( 0, 0, 100 );
camera.lookAt( 0, 0, 0 );

知识点: camera.position.set():三个参数固定透视摄像机的位置 camera.lookAt():三个参数固定透视摄像机的拍摄方向

将渲染器添加到页面上

const renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );

创建一个线条

const points = [];
points.push(new THREE.Vector3( - 10, 0, 0 ) );
points.push( new THREE.Vector3( 0, 10, 0 ) );
points.push( new THREE.Vector3( 10, 0, 0 ) );
const geometry = new THREE.BufferGeometry().setFromPoints( points );
const material = new THREE.LineBasicMaterial( { color: 0x0000ff } );

const line = new THREE.Line( geometry, material );
scene.add( line );

知识点: Vector3:三维向量x、y和z 代表位置 BufferGeometry: 是面片、线或点几何体的有效表述 setFromPoints:设置数据来源 LineBasicMaterial:线条材质:可定义属性 color颜色,linewidth线宽等参考LineBasicMaterial 【扩展】 LineDashedMaterial:与LineBasicMaterial同样是线条材质:可定义属性 color颜色,linewidth线宽等参考LineDashedMaterial

渲染场景

function animate() {
requestAnimationFrame( animate );
renderer.render( scene, camera );
}
animate();

requestAnimationFrame有很多的优点。最重要的一点或许就是当用户切换到其它的标签页时,它会暂停,因此不会浪费用户宝贵的处理器资源,也不会损耗电池的使用寿命。

线条动起来

function animate() {
requestAnimationFrame( animate );
// 旋转方向,及大小
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;

renderer.render( scene, camera );
};

animate();

完整代码(实例)

<html>
<head>
<meta charset="utf-8">
<title>My first three.js app</title>
<style>
body { margin: 0; }
</style>
</head>
<body>
<script src="./three.js"></script>
<!-- <script src="https://threejs.org/build/three.js"></script> -->
<script>
// 创建一个场景
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
camera.position.set( 0, 0, 100 );
camera.lookAt( 0, 0, 0 );

// 展示
const renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
// 创建一条线
const points = [];
points.push( new THREE.Vector3( - 10, 0, 0 ) );
points.push( new THREE.Vector3( 0, 10, 0 ) );
points.push( new THREE.Vector3( 10, 0, 0 ) );
const geometry = new THREE.BufferGeometry().setFromPoints( points );
const material = new THREE.LineBasicMaterial( { color: 0x0000ff } );

const line = new THREE.Line( geometry, material );
scene.add( line );

function animate() {
requestAnimationFrame( animate );

line.rotation.x += 0.01;
line.rotation.y += 0.01;

renderer.render( scene, camera );
};

animate();
</script>
</body>
</html>

效果

在这里插入图片描述

总结

以上就是今天要讲的内容,本文仅仅简单介绍了three.js的使用,而three.js提供了非常多的3D显示功能,后续文章,我将带大家慢慢深入了解。


如果这篇这篇文章对您有帮助?关注、点赞、收藏,三连支持一下。 有疑问或想法?评论区见。 我们下期再见。

React 性能优化(上):memo 与 useMemo 的正确使用

作者 Csvn
2026年3月27日 10:30

引言

在 React 应用开发中,性能优化是每个开发者都必须面对的课题。很多团队在遇到渲染性能问题时,第一反应就是给组件加上 React.memo,给函数加上 useMemo。然而,滥用这些优化手段反而可能导致性能下降

今天我们来深入探讨 memouseMemo 的工作原理,以及如何在实际项目中正确使用它们。

React 渲染机制回顾

在深入优化之前,我们需要理解 React 的渲染机制:

  1. 状态变化触发渲染:当组件的 state 或 props 发生变化时,React 会重新渲染该组件及其子组件
  2. 虚拟 DOM 比对:React 会生成新的虚拟 DOM 树,并与旧树进行比对(diff)
  3. 最小化真实 DOM 操作:只有实际变化的部分才会更新到真实 DOM

关键点:即使父组件重新渲染,子组件的 props 如果没有实际变化,理论上子组件不需要重新渲染。但默认情况下,React 会重新渲染所有子组件。

React.memo:组件级别的记忆化

React.memo 是一个高阶组件,用于记忆化函数组件的渲染结果。

基本用法

import React from 'react';

// 未经优化的组件 - 每次父组件渲染都会重新渲染
const ChildComponent = ({ name, count }) => {
  console.log('ChildComponent rendered');
  return <div>{name}: {count}</div>;
};

// 使用 React.memo 优化
const MemoizedChild = React.memo(({ name, count }) => {
  console.log('MemoizedChild rendered');
  return <div>{name}: {count}</div>;
});

// 自定义比较函数
const CustomMemoChild = React.memo(
  ({ name, count, data }) => {
    console.log('CustomMemoChild rendered');
    return <div>{name}: {count}</div>;
  },
  (prevProps, nextProps) => {
    // 只比较 name 和 count,忽略 data
    return prevProps.name === nextProps.name && 
           prevProps.count === nextProps.count;
  }
);

常见陷阱

陷阱 1:对象/数组/函数作为 props

// ❌ 错误示范 - 每次渲染都会创建新对象,memo 失效
function Parent() {
  const [count, setCount] = useState(0);
  
  return (
    <MemoizedChild 
      config={{ theme: 'dark' }}  // 每次都是新对象引用
      onClick={() => {}}          // 每次都是新函数引用
    />
  );
}

// ✅ 正确示范 - 使用 useMemo/useCallback 稳定引用
function Parent() {
  const [count, setCount] = useState(0);
  const config = useMemo(() => ({ theme: 'dark' }), []);
  const handleClick = useCallback(() => {}, []);
  
  return (
    <MemoizedChild 
      config={config}
      onClick={handleClick}
    />
  );
}

陷阱 2:过度使用 memo

// ❌ 不推荐 - 简单组件不需要 memo
const SimpleLabel = React.memo(({ text }) => <span>{text}</span>);

// ✅ 推荐 - 仅在以下情况使用 memo:
// 1. 组件渲染开销大
// 2. 组件接收复杂对象/数组 props
// 3. 父组件频繁渲染但子组件 props 稳定

useMemo:值级别的记忆化

useMemo 用于记忆化计算结果,避免每次渲染都重新计算。

基本用法

import React, { useMemo, useState } from 'react';

function ExpensiveComponent({ items, filter }) {
  // 昂贵的计算操作
  const filteredItems = useMemo(() => {
    console.log('Expensive filtering...');
    return items
      .filter(item => item.category === filter)
      .sort((a, b) => a.priority - b.priority)
      .map(item => ({
        ...item,
        computed: heavyComputation(item)
      }));
  }, [items, filter]); // 仅当 items 或 filter 变化时重新计算
  
  return (
    <ul>
      {filteredItems.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

实际应用场景

场景 1:复杂计算

function Dashboard({ data }) {
  // 数据统计计算
  const statistics = useMemo(() => {
    return {
      total: data.reduce((sum, item) => sum + item.value, 0),
      average: data.reduce((sum, item) => sum + item.value, 0) / data.length,
      max: Math.max(...data.map(item => item.value)),
      min: Math.min(...data.map(item => item.value)),
    };
  }, [data]);
  
  return <StatsPanel stats={statistics} />;
}

场景 2:依赖稳定的对象引用

function Form({ onSubmit }) {
  // 表单配置对象 - 需要稳定引用
  const formConfig = useMemo(() => ({
    validateOnBlur: true,
    validateOnChange: false,
    shouldUnregister: false,
  }), []);
  
  return <FormRenderer config={formConfig} />;
}

常见误区

误区 1:useMemo 总是能提升性能

// ❌ 错误 - 简单计算不需要 useMemo
const doubled = useMemo(() => count * 2, [count]);

// ✅ 正确 - 仅在计算开销大于记忆化开销时使用
// useMemo 本身也有成本:依赖比较 + 缓存管理

误区 2:依赖数组不完整

// ❌ 危险 - 可能使用过时的值
function Component({ userId }) {
  const user = useMemo(() => {
    return fetchUser(userId); // 如果 userId 变化,这里不会重新执行
  }, []); // 缺少 userId 依赖
  
  // ✅ 正确
  const user = useMemo(() => {
    return fetchUser(userId);
  }, [userId]);
}

性能优化检查清单

在使用 memouseMemo 之前,请确认:

  • 组件渲染确实存在性能问题(使用 React DevTools Profiler 验证)
  • 组件接收的 props 包含对象/数组/函数
  • 父组件渲染频率高于子组件需要的渲染频率
  • 计算操作确实是"昂贵"的(遍历大数组、复杂数学运算等)
  • 依赖数组完整且正确

总结

React.memouseMemo 是强大的性能优化工具,但它们不是银弹:

  1. 先测量,再优化:使用 React DevTools Profiler 找到真正的性能瓶颈
  2. 理解原理:明白它们何时生效、何时失效
  3. 避免滥用:简单的组件和计算不需要优化
  4. 注意依赖:确保依赖数组完整,避免闭包陷阱

下一篇我们将继续探讨 useCallbackuseTransition 的使用技巧,进一步完善 React 性能优化知识体系。


📚 延伸阅读

前端大屏适配方案:rem、vw/vh、scale 到底选哪个?

作者 可视之道
2026年3月27日 10:18

上周帮朋友救火一个数据大屏项目,甲方临时说要从 1920×1080 的投影换成 3840×1080 的超宽拼接屏。朋友用的是 transform: scale 方案,结果两边各留了一大片黑边,甲方当场黑脸。

这事儿让我决定把大屏适配这个"老生常谈但总有人踩坑"的话题彻底讲清楚。

先说结论

方案 一句话总结 适合场景 不适合场景
scale 整体等比缩放,简单粗暴 比例固定的展示型大屏 超宽屏/非标比例/有交互
vw/vh 视口单位,真正的流式适配 需要铺满全屏的响应式大屏 ECharts 字体适配麻烦
rem 根字体驱动,移动端经典方案 内容丰富、组件化开发 配置繁琐,效果接近 scale
混合方案 rem 管布局 + vw 管字体 + JS 管图表 生产级项目 小 demo 用不着

我的推荐: 如果是快速交付、比例固定,用 scale;如果是正经项目,用混合方案。别用纯 rem,性价比太低。

方案一:scale(缩放大法)

最简单的方案,核心思路是把整个页面当图片一样等比缩放。

核心代码

function setScale() {
  const designWidth = 1920
  const designHeight = 1080
  const wRatio = window.innerWidth / designWidth
  const hRatio = window.innerHeight / designHeight
  
  // 取较小值,保证内容完整显示
  const ratio = Math.min(wRatio, hRatio)
  
  const container = document.getElementById('app')
  container.style.width = designWidth + 'px'
  container.style.height = designHeight + 'px'
  container.style.transform = `scale(${ratio})`
  container.style.transformOrigin = 'left top'
  
  // 居中处理
  const marginLeft = (window.innerWidth - designWidth * ratio) / 2
  const marginTop = (window.innerHeight - designHeight * ratio) / 2
  container.style.marginLeft = marginLeft + 'px'
  container.style.marginTop = marginTop + 'px'
}

window.addEventListener('resize', setScale)
setScale()

优点

  • 开发成本极低:所有尺寸按设计稿 1:1 写 px,不用任何换算
  • 还原度高:等比缩放,设计稿怎么画就怎么写
  • 兼容性好transform 兼容性没问题

踩坑记录

坑 1:字体模糊。 缩放比例不是整数时(比如 0.833),浏览器在亚像素渲染时会导致文字发虚。解决办法是给文字容器单独设置 will-change: transform 或者用 -webkit-font-smoothing: antialiased,但只能缓解,不能根治。

坑 2:鼠标坐标偏移。 scale 缩放后,DOM 元素的实际位置和视觉位置不一致。如果大屏上有 tooltip、弹窗、拖拽等交互,鼠标位置会对不上。这个问题在 ECharts 的 tooltip 上尤为明显。

坑 3:超宽屏留白。 就像我朋友遇到的情况,16:9 的设计稿放到 32:9 的拼接屏上,两边各空一大块。你可以选择拉伸(Math.max),但内容会变形。

适用场景

固定比例的纯展示大屏,没有复杂交互,交付时间紧。注意:这是个快餐方案,别当正餐吃。

方案二:vw/vh(视口单位)

vw/vh 是 CSS3 的视口单位,1vw = 视口宽度的 1%,1vh = 视口高度的 1%。

核心实现

用 SCSS 封装转换函数:

@use "sass:math";

$designWidth: 1920;
$designHeight: 1080;

@function vw($px) {
  @return math.div($px, $designWidth) * 100vw;
}

@function vh($px) {
  @return math.div($px, $designHeight) * 100vh;
}

使用:

.dashboard-card {
  width: vw(460);      // 460 / 1920 * 100vw
  height: vh(320);     // 320 / 1080 * 100vh
  padding: vh(20) vw(24);
  font-size: vw(14);   // 字体也用 vw
  border-radius: vw(8);
}

优点

  • 真正的流式适配:内容会铺满整个屏幕,不会留白
  • 无缩放副作用:没有 scale 带来的模糊和坐标偏移问题
  • 响应式:宽高独立计算,不同比例的屏幕都能适配

踩坑记录

坑 1:ECharts 不认 vw。 ECharts 的 fontSize、padding 等配置只接受 px 数值。你需要一个 JS 转换函数:

export function fitChartSize(px, base = 1920) {
  const clientWidth = document.documentElement.clientWidth
  return Number((px * clientWidth / base).toFixed(3))
}

// 使用
option = {
  title: {
    textStyle: {
      fontSize: fitChartSize(18)
    }
  },
  grid: {
    left: fitChartSize(60),
    right: fitChartSize(20)
  }
}

而且窗口 resize 后,ECharts 需要重新 setOption 才能更新字体大小,光调 chart.resize() 不够。

坑 2:极端比例下内容挤压。 如果屏幕是 1080×1920(竖屏),用 vw 计算出的宽度值会变得很小,内容会严重挤压。需要加最小宽度兜底。

坑 3:开发体验一般。 所有数值都得过一遍转换函数,写起来不如直接写 px 顺手。可以用 PostCSS 插件(如 postcss-px-to-viewport)自动转换来缓解。

适用场景

需要适配多种比例的全屏大屏,希望内容始终铺满,没有留白。

方案三:rem(根字体缩放)

rem 的原理是通过动态修改 htmlfont-size 来实现全局缩放。

核心实现

// flexible.js
const BASE_WIDTH = 1920
const BASE_HEIGHT = 1080
const BASE_FONT_SIZE = 16

function updateRootFontSize() {
  const { clientWidth, clientHeight } = document.documentElement
  // 宽高比判断,取较小缩放比
  const ratio = clientWidth / clientHeight > BASE_WIDTH / BASE_HEIGHT
    ? clientHeight / BASE_HEIGHT
    : clientWidth / BASE_WIDTH
  
  document.documentElement.style.fontSize = `${ratio * BASE_FONT_SIZE}px`
}

updateRootFontSize()
window.addEventListener('resize', updateRootFontSize)

配合 postcss-pxtorem 自动将 px 转为 rem:

// postcss.config.js
module.exports = {
  plugins: {
    'postcss-pxtorem': {
      rootValue: 16,
      propList: ['*'],
      minPixelValue: 2
    }
  }
}

我的看法

说实话,rem 方案在大屏场景下有点过度设计。它的本质是:动态 font-size + rem 单位 → 等比缩放。最终效果跟 scale 差不多——都是等比缩放,不同比例的屏幕依然会留白。

但它比 scale 多了一堆配置(PostCSS 插件、flexible 脚本、rootValue 计算),开发体验并没有提升。rem 在移动端是经典方案,但在大屏场景,我觉得不如 scale 简单或 vw/vh 灵活。

方案四:混合方案(我的推荐)

实际项目中,我一般用混合方案:

布局容器 → vw/vh(铺满屏幕)
组件内部 → rem 或 px(保持组件独立性)  
ECharts 等第三方库 → JS 动态计算 px
极端比例兜底 → CSS clamp() + 最小宽度

架构设计

┌─────────────────────────────────────────┐
           浏览器视口 (100vw × 100vh)      
                                         
  ┌──────────┐  ┌──────────────────────┐ 
    左侧栏           主内容区          
   w: 20vw        w: 80vw            
   h: 100vh       h: 100vh           
                                     
   内部组件      ┌────────────────┐   
    rem         ECharts 图表      
                  JS 计算 px        
                └────────────────┘   
  └──────────┘  └──────────────────────┘ 
└─────────────────────────────────────────┘

关键代码

1. 布局层用 vw/vh:

.layout-left {
  width: 20vw;
  height: 100vh;
}

.layout-main {
  width: 80vw;
  height: 100vh;
}

2. 组件内用 CSS clamp() 做弹性字体:

.card-title {
  // 最小 12px,理想 1vw,最大 24px
  font-size: clamp(12px, 1vw, 24px);
}

.card-value {
  font-size: clamp(24px, 2.5vw, 56px);
  font-weight: bold;
}

clamp() 是个被低估的 CSS 函数,它让字体在合理范围内自适应,不会在超大屏上变成巨型字、也不会在小屏上小到看不清。

3. ECharts 封装自适应 hook(Vue 3):

// useChartResize.ts
import { onMounted, onUnmounted, ref } from 'vue'
import * as echarts from 'echarts'

export function useChartResize(chartRef: Ref<HTMLElement | null>) {
  let chart: echarts.ECharts | null = null
  
  const fitSize = (px: number, base = 1920) => {
    const width = document.documentElement.clientWidth
    return Math.round(px * width / base)
  }
  
  const handleResize = () => {
    if (chart) {
      chart.resize()
      // 重要:resize 后要重新设置包含字体大小的 option
    }
  }
  
  onMounted(() => {
    if (chartRef.value) {
      chart = echarts.init(chartRef.value)
      window.addEventListener('resize', handleResize)
    }
  })
  
  onUnmounted(() => {
    window.removeEventListener('resize', handleResize)
    chart?.dispose()
  })
  
  return { chart, fitSize }
}

4. 极端比例兜底:

#app {
  min-width: 1024px;
  min-height: 600px;
  overflow: auto;  /* 实在太小就出滚动条 */
}

2026 年的新选择:CSS Container Queries

这里补充一个很多大屏适配文章没提到的新玩意儿——容器查询(Container Queries)

传统的媒体查询(Media Queries)基于视口尺寸,而容器查询基于父容器尺寸。这意味着组件可以根据自己所在区域的大小来调整样式,而不是根据整个屏幕。

.chart-wrapper {
  container-type: inline-size;
  container-name: chart;
}

@container chart (min-width: 600px) {
  .chart-title { font-size: 18px; }
  .chart-legend { display: flex; }
}

@container chart (max-width: 599px) {
  .chart-title { font-size: 14px; }
  .chart-legend { display: none; }
}

截至 2026 年初,主流浏览器(Chrome 105+、Firefox 110+、Safari 16+)都已支持容器查询。在大屏项目中,特别是一个组件可能出现在不同大小区域的场景下,容器查询比媒体查询好用得多。

不过要注意,容器查询解决的是组件级响应式,不能替代全局的适配方案。它更适合作为混合方案中的一环。

实战选型决策树

你的大屏需要适配多种比例吗?
├── 不需要(固定 16:9)
│   └── 有复杂交互吗?
│       ├── 没有 → scale ✅ 快速搞定
│       └── 有 → vw/vh + JS 图表适配
└── 需要(多种屏幕)
    └── 混合方案 ✅
        ├── 布局:vw/vh
        ├── 字体:clamp()
        ├── 图表:JS 动态计算
        └── 组件:Container Queries

常见 FAQ

Q:大屏一般用什么设计稿尺寸? A:1920×1080 最常见。如果是 4K 屏,设计稿按 3840×2160 出,但开发时可以按 1920×1080 写,浏览器会自动处理设备像素比。

Q:scale 方案字体模糊怎么办? A:没有完美解决方案。可以尝试 will-change: transform-webkit-font-smoothing: antialiased、设置较大基础字号然后缩小(而不是小字号放大)。实在不行就换 vw/vh 方案。

Q:ECharts 图表在 resize 后字体没变怎么办? A:chart.resize() 只更新画布尺寸,不会重新计算 option 中的固定 px 值。你需要在 resize 时重新调用 setOption,将 fontSize 等值用 JS 函数动态计算。

Q:大屏需要适配移动端吗? A:一般不需要。大屏就是大屏,手机打开看的场景极少。如果甲方非要,建议做两套页面,用媒体查询切换,而不是一套代码适配所有。

总结

大屏适配没有银弹。scale 最简单但最受限,vw/vh 最灵活但开发成本高,rem 两头不靠。生产项目推荐混合方案,把每种技术用在它最擅长的地方。

最重要的是:开工前跟甲方确认好所有要投放的屏幕尺寸和比例。 很多适配问题不是技术问题,是需求沟通问题。

electron forge 初始化 vite ts vue3 项目模版

作者 blanks2020
2026年3月27日 10:17

直接使用官方文档,肯定会掉进坑里,浪费不少时间。

切勿 升级 package.json 里面的依赖,否则可能会掉进 vitevue.runtime 的坑里,除非你下定决心彻底解决所有问题。

默认使用的 npm 装的依赖。

1

# https://www.electronforge.io/templates/vite-+-typescript
npx create-electron-app@latest my-new-app --template=vite-typescript

# https://www.electronforge.io/guides/framework-integration/vue-3
npm install vue
npm install --save-dev @vitejs/plugin-vue

2 重命名 vite.renderer.config.tsvite.renderer.config.mts

修改 forge.config.ts 中的 renderer 配置: vite.renderer.config.tsvite.renderer.config.mts

// vite.renderer.config.mts

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

// https://vitejs.dev/config
export default defineConfig({
  plugins: [vue()]
});


3 改动一下文件

// index.html

<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Hello World!</title>
</head>
<body>
<div id="app"></div>
<script
type="module"
src="/src/renderer.ts"
></script>
</body>
</html>

创建 src/vue/App.vue

<template>
<h1>💖 Hello World!</h1>
<p>Welcome to your Electron application.</p>
</template>

<script setup>
console.log('👋 This message is being logged by "App.vue", included via Vite')
</script>

修改 src/renderer.ts

import { createApp } from 'vue';
import './index.css';
import App from './vue/App.vue';

console.log(
  '👋 This message is being logged by "renderer.ts", included via Vite',
);

createApp(App).mount('#app');

启动项目和打包应该都是能正常运行的

npm run start
npm run package

【节点】[Texture2DAsset节点]原理解析与实际应用

作者 SmalBox
2026年3月27日 10:14

【Unity Shader Graph 使用与特效实现】专栏-直达

Texture 2D Asset 节点是 Unity URP Shader Graph 中用于定义和引用 2D 纹理资源的基础节点。在实时渲染和着色器开发中,纹理是构建视觉丰富效果的核心元素,而 Texture 2D Asset 节点正是连接美术资源与着色器逻辑的桥梁。该节点本身并不直接对纹理进行采样操作,而是作为纹理资源的声明和引用点,为后续的采样节点提供数据源。

在 Shader Graph 的工作流程中,Texture 2D Asset 节点代表了项目中实际的纹理文件,如 PNG、JPG 或 TGA 格式的图像文件。通过该节点,开发者可以将外部纹理资源引入着色器图,并在不修改原始纹理资源的情况下,通过不同的采样参数实现多样化的纹理应用效果。

理解 Texture 2D Asset 节点的正确使用方法对于创建高效、可维护的着色器至关重要。它不仅简化了纹理资源的管理,还提供了在单一着色器中复用同一纹理资源的机制,从而优化着色器性能和内存使用。

描述

核心功能与定位

Texture 2D Asset 节点的主要功能是在着色器图中定义和引用一个 2D 纹理资源。在 Unity 的渲染管线中,纹理资源是着色器计算中的重要输入数据,用于表现物体表面的颜色、法线、粗糙度等各种表面特性。

该节点在 Shader Graph 中的定位是资源声明节点,类似于编程中的变量声明。它告诉着色器:"这里有一个纹理资源可以使用",但实际如何使用这个纹理资源(如采样、变换、混合等)则由其他专门的节点(如 Sample Texture 2D 节点)来完成。

与采样节点的关系

Texture 2D Asset 节点必须与 Sample Texture 2D 节点结合使用才能发挥实际作用。这种设计遵循了关注点分离的原则:Texture 2D Asset 节点负责"这是什么纹理",而 Sample Texture 2D 节点负责"如何从这个纹理获取数据"。

这种分离带来的优势包括:

  • 资源复用:单个 Texture 2D Asset 节点可以连接到多个 Sample Texture 2D 节点,每个采样节点可以使用不同的采样参数
  • 代码优化:在生成的着色器代码中,同一纹理只需要声明一次,即使被多次采样使用
  • 工作流清晰:美术师和开发者可以更清晰地理解资源引用和采样操作之间的区别

使用场景与重要性

Texture 2D Asset 节点在几乎所有的 Shader Graph 应用场景中都是基础且必需的组件:

  • 基础颜色纹理:定义物体表面的基础颜色和图案
  • 法线贴图:为低多边形模型添加表面细节
  • 遮罩纹理:控制不同效果的强度分布
  • 光照贴图:预计算光照信息的应用
  • 程序化纹理生成:与生成的纹理数据结合使用

在 URP(Universal Render Pipeline)环境中,Texture 2D Asset 节点的正确使用对于实现跨平台兼容性和性能优化尤为重要。URP 针对移动平台和高端设备提供了自动的纹理压缩和 mipmap 处理,而 Texture 2D Asset 节点正是这一优化流程的入口点。

端口

输出端口详解

Texture 2D Asset 节点只有一个输出端口,标记为"Out",其数据类型为"2D 纹理"。这个输出端口代表了节点所引用的纹理资源,可以连接到其他节点的输入端口,特别是 Sample Texture 2D 节点的"Texture"输入端口。

输出端口的特性

输出端口具有以下几个重要特性:

  • 数据类型严格性:输出端口严格定义为 2D 纹理类型,这意味着它只能连接到接受 2D 纹理输入的端口
  • 资源引用语义:输出端口传递的是对纹理资源的引用,而不是纹理数据本身
  • 连接兼容性:可以连接到任意数量下游节点的输入端口,实现资源复用

实际应用中的端口行为

在实际的 Shader Graph 构建过程中,Texture 2D Asset 节点的输出端口行为表现为:

  • 拖拽连接:可以将输出端口拖拽到 Sample Texture 2D 节点的纹理输入端口
  • 自动类型匹配:当靠近兼容的输入端口时,连接线会自动吸附
  • 可视化反馈:连接建立后,在 Shader Graph 中会有清晰的连线显示资源流向

端口使用最佳实践

正确使用 Texture 2D Asset 节点的输出端口需要遵循一些最佳实践:

  • 命名规范:为重要的纹理连接添加有意义的注释,说明纹理的用途
  • 组织管理:在复杂的着色器图中,合理布局 Texture 2D Asset 节点,使其易于查找和管理
  • 连接验证:定期检查纹理连接是否正确,特别是当纹理资源在项目中移动或重命名时

控件

对象字段控件

Texture 2D Asset 节点的核心控件是一个对象字段,用于选择和定义项目中具体的 2D 纹理资源。这个控件表现为一个可以接受拖拽的对象槽,或者可以通过点击对象选择按钮打开资源选择器。

控件交互方式

对象字段控件支持多种交互方式:

  • 拖拽赋值:从 Project 窗口直接拖拽纹理资源到控件区域
  • 选择器赋值:点击控件右侧的选择按钮,从弹出的资源选择窗口中选取纹理
  • 直接引用:通过脚本或材质属性在运行时动态指定纹理资源

控件状态反馈

对象字段控件提供多种视觉状态反馈:

  • 空状态:当没有指定纹理时,显示"None (Texture 2D)"提示
  • 有效状态:当指定了有效纹理时,显示纹理的缩略图和名称
  • 错误状态:当纹理资源丢失或类型不匹配时,显示错误指示

纹理资源选择标准

在选择纹理资源时,需要考虑多个因素:

  • 纹理尺寸:符合性能要求的适当尺寸,通常是 2 的幂次方
  • 纹理格式:根据用途选择合适的格式(RGB、RGBA、压缩格式等)
  • 导入设置:确保纹理的导入设置(如 sRGB、Wrap Mode、Filter Mode)符合预期用途
  • 内存占用:权衡纹理质量与内存消耗,特别是在移动平台上

高级控件特性

对于高级用户,Texture 2D Asset 节点的控件还支持一些扩展功能:

  • 属性绑定:可以将纹理控件暴露为材质属性,允许在材质实例中修改纹理
  • 条件显示:基于其他节点参数或图形设置动态显示或隐藏纹理控件
  • 预设支持:保存和加载纹理配置预设,便于在不同项目间共享配置

生成的代码示例

代码生成机制

当 Shader Graph 编译时,Texture 2D Asset 节点会生成对应的 HLSL 代码。理解生成的代码结构对于调试和优化着色器性能非常重要。

基础生成的代码结构如下:

HLSL

TEXTURE2D(_Texture2DAsset);
SAMPLER(sampler_Texture2DAsset);

这段代码实际上完成了两个关键任务:

  • 纹理声明:使用 TEXTURE2D 宏声明一个纹理资源
  • 采样器声明:使用 SAMPLER 宏声明对应的采样器状态

代码生成详解

TEXTURE2D 宏

TEXTURE2D 是 Unity 提供的一个宏,它在不同平台和渲染管线下会展开为适当的纹理声明语句。在大多数情况下,它等价于:

HLSL

Texture2D _Texture2DAsset;

但使用宏的好处是保证了跨平台的兼容性,特别是在处理如 Vulkan、Metal 或 Console 平台时的特殊要求。

SAMPLER 宏

SAMPLER 宏同样是一个跨平台的抽象,它声明了纹理的采样器状态。采样器状态控制了纹理采样时的行为,包括:

  • 过滤模式:点过滤、双线性过滤、三线性过滤
  • 环绕模式:重复、钳制、镜像等
  • 各向异性设置:各向异性过滤的级别

在 URP 中,采样器通常与纹理分开声明,这允许不同的纹理共享相同的采样器状态,优化采样器使用数量。

实际应用中的代码变体

根据 Texture 2D Asset 节点的具体配置,生成的代码可能会有一些变体:

当纹理设置为可编程时

HLSL

TEXTURE2D(_Texture2DAsset);
SAMPLER(sampler_Texture2DAsset);
float4 _Texture2DAsset_TexelSize;

TexelSize 变量提供了纹理的像素大小信息,常用于需要了解纹理精确尺寸的算法,如边缘检测或精确的 UV 计算。

当纹理作为材质属性暴露时

如果 Texture 2D Asset 节点被设置为材质属性,生成的代码会包含相应的属性声明:

HLSL

TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);

同时在 Properties 块中会有:

HLSL

_MainTex("Main Texture", 2D) = "white" {}

代码优化考虑

理解生成的代码有助于进行着色器优化:

  • 纹理重复使用:确保同一纹理在着色器中只声明一次,即使被多次采样
  • 采样器共享:合理安排采样操作,尽可能共享采样器状态
  • 平台特定优化:了解不同平台上纹理声明的差异,进行针对性的优化

实际应用示例

基础颜色纹理应用

最基本的 Texture 2D Asset 节点应用是为材质提供基础颜色纹理:

  1. 创建 Texture 2D Asset 节点并指定漫反射纹理
  2. 连接至 Sample Texture 2D 节点
  3. 将采样结果连接到主节点的 Base Color 输入

这种配置生成的代码清晰地反映了资源声明与采样操作的分离:

HLSL

// 纹理声明(来自Texture 2D Asset节点)
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);

// 采样操作(来自Sample Texture 2D节点)
float4 baseColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);

多用途纹理复用

展示同一纹理资源在不同上下文中的复用:

  1. 单一 Texture 2D Asset 节点提供法线贴图资源
  2. 连接到第一个 Sample Texture 2D 节点,用于常规法线计算
  3. 同时连接到第二个 Sample Texture 2D 节点,用于细节法线计算
  4. 两个采样节点使用不同的 UV 变换和采样参数

这种配置体现了 Texture 2D Asset 节点的核心价值——资源复用:

HLSL

// 单一纹理声明
TEXTURE2D(_NormalMap);
SAMPLER(sampler_NormalMap);

// 多个采样操作
float3 baseNormal = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, baseUV));
float3 detailNormal = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, detailUV));
float3 finalNormal = BlendNormals(baseNormal, detailNormal);

性能优化配置

通过合理配置 Texture 2D Asset 节点实现性能优化:

  • 选择合适的纹理压缩格式减少内存占用
  • 使用 mipmap 确保在远距离渲染时的性能
  • 根据目标平台调整纹理的最大尺寸
  • 利用纹理数组或图集减少纹理采样次数

高级技巧与最佳实践

纹理流送优化

在大型场景或开放世界游戏中,纹理流送是重要的优化技术:

  • 合理设置纹理的流送 mipmap 偏移
  • 根据视觉重要性分配纹理流送预算
  • 使用 Texture 2D Asset 节点配合 Mipmap Bias 控制流送细节

跨平台兼容性

确保 Texture 2D Asset 节点在不同平台上的兼容性:

  • 了解不同平台的纹理格式支持差异
  • 使用适当的后备纹理应对格式不支持的情况
  • 测试在不同设备上的纹理内存占用和加载性能

动态纹理管理

在运行时动态管理纹理资源:

  • 通过脚本动态替换 Texture 2D Asset 节点引用的纹理
  • 实现纹理的异步加载和卸载
  • 使用纹理压缩技术在运行时平衡质量和性能

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

RSA攻略

2026年3月27日 10:02

成为 推荐、搜索、广告 (RSA) 领域的算法工程师,是目前工业界算法需求最旺盛、变现最直接的方向。这三个领域虽然业务场景不同,但底层技术架构高度一致,统称为 “信息流与检索” 技术。

以下是进阶之路,分为四个阶段:


第一阶段:夯实基础 (地基期)

这是进入算法领域的敲门砖,无论哪个方向都绕不开。

  • 数学功底
    • 统计学:理解概率分布、假设检验、极大似然估计(推荐系统本质是在预测概率)。
    • 最优化:梯度下降 (SGD)、正则化 (L1/L2)、损失函数(Loss Function)。
  • 编程能力
    • Python:精通 NumPy, Pandas, Scikit-learn。
    • SQL极其重要。RSA 工程师 50% 的时间在处理数据,必须能熟练编写复杂的 Hive/Spark SQL。
    • 数据结构与算法:LeetCode 必刷,重点关注:哈希表、二分查找、动态规划、图算法。
  • 机器学习基础
    • 掌握逻辑回归 (LR)、支持向量机 (SVM)、决策树。
    • 重点掌握集成学习:XGBoost, LightGBM, CatBoost(工业界处理表格类数据的利器)。

第二阶段:掌握 RSA 核心架构 (入门期)

工业界的推荐/搜索/广告系统通常是一个**“漏斗形”**架构。你需要学习每一层的核心逻辑。

  • 核心流程:召回 (Recall) → 粗排 → 精排 (Ranking) → 重排 (Re-ranking)
  • 召回层 (Retrieval)
    • 协同过滤 (CF):User-based, Item-based。
    • 向量化召回 (Embedding):Word2Vec, Item2Vec, DeepWalk。
    • 多路召回策略:热度、标签、兴趣、地理位置等多维度聚合。
  • 排序层 (Ranking)
    • 特征工程:这是 RSA 的灵魂。学习如何处理类别特征(One-hot)、数值特征(归一化)、交叉特征。
    • 经典模型:FM (Factorization Machines), DeepFM, Wide & Deep。
  • 评估指标
    • 理解 CTR (点击率)、CVR (转化率)。
    • 学习离线评估指标:AUC, GAUC, NDCG, MAP

第三阶段:工业级工程与大数据 (实战期)

算法工程师不只是写模型,RSA 领域对“大数据”和“在线工程”的要求极高。

  • 大数据处理
    • 学习 SparkFlink:掌握分布式处理千万级、亿级数据的能力。
  • 特征工程进阶
    • 学习如何处理大规模稀疏特征
    • 掌握特征存取工具:Redis, Cassandra 或特征平台。
  • A/B Testing
    • 学习如何科学地设计实验,理解显著性检验。这是评价算法上线效果的唯一标准。
  • 深度学习模型进阶
    • 序列建模:DIN, DIEN (捕捉用户兴趣随时间的变化)。
    • 多任务学习 (MTL):MMoE, ESSM (同时预测点击和转化)。

第四阶段:前沿探索与进阶 (专家期)

当你在工业界有了 2-3 年经验后,需要关注更深层次的问题。

  • 图神经网络 (GNN):利用用户-商品的二分图进行建模(如 GraphSage, PinSage)。
  • 强化学习 (RL):解决推荐系统的长期收益问题(如用户留存)。
  • LLM + RSA:研究如何利用大模型(如 GPT、Llama)生成的语义 Embedding 来增强推荐效果,或利用大模型进行冷启动推荐。
  • 系统架构设计:考虑如何支撑每秒万级 (QPS) 的高并发请求,如何平衡模型的复杂性推理延迟

如何高效进阶?

  1. 打好 SQL 基础:很多新人死在不会写复杂的 SQL 取特征上。
  2. 阅读经典论文
    • 必读 Google 的 Wide & Deep
    • 必读阿里、美团、字节跳动发布的技术博客(它们代表了国内 RSA 的最高水平)。
  3. 参加竞赛 (Kaggle/天池)
    • 找一个“点击率预测 (CTR Prediction)”或“个性化推荐”的比赛,走完从数据清洗到特征工程再到模型融合的全流程。
  4. 关注业务逻辑
    • 算法是为业务服务的。理解为什么搜索需要相关性,为什么广告需要考虑出价 (eCPM),为什么推荐需要考虑多样性和惊喜感。

一句话总结: RSA 工程师是 “数据科学家 + 软件工程师 + 业务专家” 的结合体。先从写好 SQL 和掌握逻辑回归开始,再逐步攻克深度学习和分布式架构。

从零集成RainbowKit:我如何解决多链钱包连接中的“幽灵网络”问题

作者 竹林818
2026年3月27日 10:02

背景

上个月,我接手了一个多链DeFi聚合器前端项目的重构工作。这个项目需要支持 Ethereum、Arbitrum、Polygon 和 Base 四条链,用户可以在不同链之间无缝切换来查看和管理资产。之前的代码用的是 ethers.js + 自己封装的钱包连接按钮,维护起来特别头疼——每个新链上线都要手动加配置,钱包切换的逻辑散落在各个组件里,测试一次要连接断开钱包几十次。

团队决定用 RainbowKit 来统一钱包连接体验,毕竟它封装了连接按钮、网络切换模态框这些通用UI。我心想:“这还不简单?照着文档装个包,几行代码不就搞定了?” 结果,我低估了多链配置的复杂性,特别是当用户的钱包(比如 MetaMask)里预置了自定义网络时,问题就来了。

问题分析

我按照 RainbowKit 官方文档的“快速开始”,十分钟就搭出了一个漂亮的连接按钮。点击后能弹出钱包列表,连接 MetaMask 也很顺利。但当我尝试从 Ethereum 切换到 Arbitrum 时,奇怪的事情发生了:前端页面显示“已连接至 Arbitrum”,但 MetaMask 扩展却还停留在 Ethereum 主网,而且发交易时会失败。

我打开浏览器控制台,发现 wagmiuseAccount 钩子返回的 chainId 和我通过 window.ethereum.chainId 拿到的值不一致。前端状态是 Arbitrum (42161),但钱包实际还在 Ethereum (1)。我管这叫“幽灵网络”问题——前端以为自己在一个链上,但钱包却在另一个链上,用户操作必然失败。

最初的排查思路是:是不是 RainbowKit 的 chain 配置没传对?我反复检查了传给 getDefaultConfig 的链对象。后来发现,问题出在 wagmi 的配置模式和与钱包的同步机制上。RainbowKit 底层依赖 wagmi 进行状态管理,而 wagmi 默认的 config 如果不明确指定连接器(connector)的行为模式,它可能不会主动要求钱包切换网络。

核心实现

1. 正确的多链配置初始化

首先,我放弃了文档里那个最简单的 getDefaultConfig 调用。它虽然方便,但对多链场景的控制力不够。我决定手动构建 wagmi 的 config,并显式地配置连接器。

// src/config/wagmi.ts
import { http, createConfig } from 'wagmi';
import { mainnet, arbitrum, polygon, base } from 'wagmi/chains';
import { injected, walletConnect } from 'wagmi/connectors';
import { getDefaultConfig } from '@rainbow-me/rainbowkit';

// 注意:这里不要用 getDefaultConfig,它隐藏了太多细节
// 我们手动创建 config 以便精细控制
export const config = createConfig({
  chains: [mainnet, arbitrum, polygon, base], // 明确支持哪些链
  transports: {
    // 为每条链指定 RPC 端点
    [mainnet.id]: http('https://eth.llamarpc.com'), // 建议用公共节点或自己的节点
    [arbitrum.id]: http('https://arb1.arbitrum.io/rpc'),
    [polygon.id]: http('https://polygon-rpc.com'),
    [base.id]: http('https://mainnet.base.org'),
  },
  connectors: [
    // 注入式连接器(如 MetaMask)
    injected({
      // 关键配置:让连接器去同步钱包的网络
      target: 'metaMask',
    }),
    // 钱包连接连接器(WalletConnect)
    walletConnect({
      projectId: '你的 WalletConnect Cloud Project ID', // 必须去 walletconnect.com 申请
      showQrModal: false, // RainbowKit 会自己处理二维码弹窗
    }),
  ],
  // 这个配置很重要,确保状态同步
  ssr: false, // 我们做的是前端应用
});

这里有个坑:injected 连接器的 target 配置。如果不指定,某些钱包可能不会正确触发网络切换事件。我一开始漏了这行,导致 MetaMask 的网络变更事件没有被 wagmi 捕获。

2. 封装自定义的连接上下文组件

接下来,我创建了一个独立的 Provider 组件,用来包裹整个应用。这样可以把所有 Web3 相关的配置隔离在一个地方。

// src/providers/Web3Provider.tsx
import { ReactNode } from 'react';
import { WagmiProvider } from 'wagmi';
import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from '@/config/wagmi';

// 创建 React Query 客户端,wagmi 用它来缓存数据
const queryClient = new QueryClient();

interface Web3ProviderProps {
  children: ReactNode;
}

export function Web3Provider({ children }: Web3ProviderProps) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider
          theme={darkTheme({
            accentColor: '#3B82F6', // 自定义主题色
            borderRadius: 'medium',
          })}
          // 关键设置初始链避免未定义状态
          initialChain={config.chains[0]}
          // 这个模式决定了用户切换网络时的行为
          modalSize="compact"
        >
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

注意 initialChain 这个配置。我一开始没设,结果应用刚加载时,useChainId() 返回 undefined,导致一些组件渲染报错。把它设为配置中的第一条链(这里是 Ethereum),确保了初始状态的稳定性。

3. 实现安全的链切换逻辑

在需要切换链的组件(比如一个网络选择下拉菜单)里,我不能再简单调用 switchChain 就完事了。必须处理用户拒绝切换、钱包不支持目标链等各种情况。

// src/components/NetworkSwitcher.tsx
import { useState } from 'react';
import { useSwitchChain, useAccount } from 'wagmi';
import { config } from '@/config/wagmi';

export function NetworkSwitcher() {
  const { chainId } = useAccount();
  const { chains, switchChain, isPending } = useSwitchChain();
  const [error, setError] = useState<string | null>(null);

  const handleSwitch = async (targetChainId: number) => {
    setError(null); // 清空旧错误
    try {
      // 这里有个重要细节:switchChain 返回 Promise,必须 await
      await switchChain({ chainId: targetChainId });
      // 切换成功后,错误状态会被 wagmi 自动更新
    } catch (err: any) {
      // 错误处理是必须的!
      console.error('切换链失败:', err);
      
      // 用户拒绝了切换请求
      if (err?.code === 4001) {
        setError('用户拒绝了网络切换');
        return;
      }
      
      // 钱包里没有添加这个网络
      if (err?.code === 4902) {
        // 这里可以触发添加网络的逻辑
        setError('请先在钱包中添加该网络');
        // 在实际项目中,这里可以调用 wallet_addEthereumChain RPC
        return;
      }
      
      setError(`切换失败: ${err?.message || '未知错误'}`);
    }
  };

  return (
    <div>
      <select 
        value={chainId || ''} 
        onChange={(e) => handleSwitch(Number(e.target.value))}
        disabled={isPending}
      >
        <option value="" disabled>选择网络</option>
        {chains.map((chain) => (
          <option key={chain.id} value={chain.id}>
            {chain.name} {isPending && chain.id === chainId ? '(切换中...)' : ''}
          </option>
        ))}
      </select>
      {error && <div style={{ color: 'red' }}>{error}</div>}
    </div>
  );
}

最大的教训在这里:一定要处理 switchChain 的 Promise 拒绝。我一开始只用 switchChain({ chainId }) 而不 await,也没加 try-catch。结果用户拒绝切换时,前端状态已经更新了,但钱包没变,又回到了“幽灵网络”状态。

4. 关键:监听钱包网络变化并同步

为了解决“幽灵网络”问题,我添加了一个监听器组件,专门负责同步钱包和前端的网络状态。

// src/components/NetworkSync.tsx
import { useEffect } from 'react';
import { useAccount, useChainId } from 'wagmi';

// 这个组件不渲染任何UI,只负责副作用
export function NetworkSync() {
  const { connector } = useAccount();
  const chainId = useChainId();

  useEffect(() => {
    if (!connector) return;

    // 监听钱包的网络变化事件
    const handleChange = ({ chainId: newChainId }: { chainId?: number }) => {
      if (newChainId && newChainId !== chainId) {
        console.log(`钱包网络已切换至: ${newChainId}`);
        // 这里不需要手动更新状态,wagmi 会处理
        // 但可以在这里触发一些副作用,比如重新查询余额
      }
    };

    // 注意:不同连接器的事件名可能不同
    connector.on('change', handleChange);

    return () => {
      connector.off('change', handleChange);
    };
  }, [connector, chainId]);

  return null; // 不渲染任何东西
}

这个组件放在 App 的根组件里。它确保当用户在 MetaMask 里手动切换网络时,前端状态能及时更新。我一开始以为 wagmi 会自动处理所有事件,后来发现某些边缘情况下(比如用户直接操作钱包扩展),事件传递会丢失。

5. 完整的应用集成

最后,我把所有部分组装起来:

// src/App.tsx
import { Web3Provider } from './providers/Web3Provider';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { NetworkSwitcher } from './components/NetworkSwitcher';
import { NetworkSync } from './components/NetworkSync';
import { useAccount } from 'wagmi';

function AppContent() {
  const { isConnected } = useAccount();
  
  return (
    <div style={{ padding: '20px' }}>
      <h1>多链 DeFi 聚合器</h1>
      <div style={{ marginBottom: '20px' }}>
        <ConnectButton />
      </div>
      
      <NetworkSync /> {/* 关键:同步网络状态 */}
      
      {isConnected && (
        <div style={{ marginTop: '20px' }}>
          <h3>切换网络</h3>
          <NetworkSwitcher />
        </div>
      )}
      
      {/* 其他应用内容... */}
    </div>
  );
}

export default function App() {
  return (
    <Web3Provider>
      <AppContent />
    </Web3Provider>
  );
}

完整代码

以下是完整的、可运行的示例,需要安装依赖:wagmi v2@rainbow-me/rainbowkitviem@tanstack/react-query

// 文件结构:
// src/
//   ├── App.tsx
//   ├── main.tsx (或 index.tsx)
//   ├── providers/
//   │   └── Web3Provider.tsx
//   ├── config/
//   │   └── wagmi.ts
//   └── components/
//       ├── NetworkSwitcher.tsx
//       └── NetworkSync.tsx

// 1. 首先安装依赖:
// npm install wagmi viem @rainbow-me/rainbowkit @tanstack/react-query

// 2. src/config/wagmi.ts
import { http, createConfig } from 'wagmi';
import { mainnet, arbitrum, polygon, base } from 'wagmi/chains';
import { injected, walletConnect } from 'wagmi/connectors';

export const config = createConfig({
  chains: [mainnet, arbitrum, polygon, base],
  transports: {
    [mainnet.id]: http(),
    [arbitrum.id]: http(),
    [polygon.id]: http(),
    [base.id]: http(),
  },
  connectors: [
    injected({ target: 'metaMask' }),
    walletConnect({ 
      projectId: 'YOUR_PROJECT_ID', // 替换为实际ID
      showQrModal: false 
    }),
  ],
  ssr: false,
});

// 3. src/providers/Web3Provider.tsx
import { ReactNode } from 'react';
import { WagmiProvider } from 'wagmi';
import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from '@/config/wagmi';

const queryClient = new QueryClient();

interface Web3ProviderProps {
  children: ReactNode;
}

export function Web3Provider({ children }: Web3ProviderProps) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider
          theme={darkTheme({ accentColor: '#3B82F6' })}
          initialChain={config.chains[0]}
          modalSize="compact"
        >
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

// 4. src/components/NetworkSwitcher.tsx
import { useState } from 'react';
import { useSwitchChain, useAccount } from 'wagmi';

export function NetworkSwitcher() {
  const { chainId } = useAccount();
  const { chains, switchChain, isPending } = useSwitchChain();
  const [error, setError] = useState<string | null>(null);

  const handleSwitch = async (targetChainId: number) => {
    setError(null);
    try {
      await switchChain({ chainId: targetChainId });
    } catch (err: any) {
      console.error('切换链失败:', err);
      
      if (err?.code === 4001) {
        setError('用户拒绝了网络切换');
        return;
      }
      
      if (err?.code === 4902) {
        setError('请先在钱包中添加该网络');
        return;
      }
      
      setError(`切换失败: ${err?.message || '未知错误'}`);
    }
  };

  return (
    <div>
      <select 
        value={chainId || ''} 
        onChange={(e) => handleSwitch(Number(e.target.value))}
        disabled={isPending}
      >
        <option value="" disabled>选择网络</option>
        {chains.map((chain) => (
          <option key={chain.id} value={chain.id}>
            {chain.name} {isPending && chain.id === chainId ? '(切换中...)' : ''}
          </option>
        ))}
      </select>
      {error && <div style={{ color: 'red' }}>{error}</div>}
    </div>
  );
}

// 5. src/components/NetworkSync.tsx
import { useEffect } from 'react';
import { useAccount, useChainId } from 'wagmi';

export function NetworkSync() {
  const { connector } = useAccount();
  const chainId = useChainId();

  useEffect(() => {
    if (!connector) return;

    const handleChange = ({ chainId: newChainId }: { chainId?: number }) => {
      if (newChainId && newChainId !== chainId) {
        console.log(`钱包网络已切换至: ${newChainId}`);
        // 可以在这里触发数据重新获取
      }
    };

    connector.on('change', handleChange);

    return () => {
      connector.off('change', handleChange);
    };
  }, [connector, chainId]);

  return null;
}

// 6. src/App.tsx
import { Web3Provider } from './providers/Web3Provider';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { NetworkSwitcher } from './components/NetworkSwitcher';
import { NetworkSync } from './components/NetworkSync';
import { useAccount } from 'wagmi';

function AppContent() {
  const { isConnected } = useAccount();
  
  return (
    <div style={{ padding: '20px' }}>
      <h1>多链 DeFi 聚合器</h1>
      <div style={{ marginBottom: '20px' }}>
        <ConnectButton />
      </div>
      
      <NetworkSync />
      
      {isConnected && (
        <div style={{ marginTop: '20px' }}>
          <h3>切换网络</h3>
          <NetworkSwitcher />
        </div>
      )}
    </div>
  );
}

export default function App() {
  return (
    <Web3Provider>
      <AppContent />
    </Web3Provider>
  );
}

// 7. 入口文件 (如 src/main.tsx)
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import '@rainbow-me/rainbowkit/styles.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

踩坑记录

  1. “幽灵网络”问题:现象是前端显示一个链,钱包实际在另一个链。解决方法:添加 NetworkSync 组件监听钱包事件,并在 injected 连接器中配置 target: 'metaMask' 确保事件正确传递。

  2. WalletConnect 项目 ID 报错:控制台提示“Project ID required”。解决方法:必须去 walletconnect.com 注册并创建一个项目,获取真实的 Project ID,不能用示例中的占位符。

  3. 切换链时未处理用户拒绝:用户点击“拒绝”后,前端状态已更新但钱包未切换。解决方法:用 try-catch 包裹 switchChain 调用,特别处理错误码 4001(用户拒绝)。

  4. 初始加载时 chainId 为 undefined:应用刚加载时,useChainId() 返回 undefined 导致组件报错。解决方法:在 RainbowKitProvider 中设置 initialChain={config.chains[0]} 提供默认值。

  5. TypeScript 类型错误connector.on('change', handler) 提示类型不存在。解决方法:检查连接器类型,有些连接器的事件名可能是 'chainChanged',需要查看具体连接器的文档或类型定义。

小结

这次集成让我明白,RainbowKit 虽然简化了UI,但多链状态同步的责任还在开发者肩上。核心收获是:必须显式处理网络切换的拒绝情况,并建立可靠的钱包事件监听机制。下一步可以继续优化用户体验,比如在钱包未添加网络时自动调用 wallet_addEthereumChain 来添加网络。

算法工程师分类

2026年3月27日 10:01

算法工程师是一个非常广阔的领域,随着人工智能和大数据的发展,岗位划分也越来越精细。一般来说,算法工程师主要分为以下几个核心方向:

1. 计算机视觉 (CV - Computer Vision)

这是目前应用最广泛的方向之一,主要让计算机“看懂”图像和视频。

  • 常见应用:人脸识别、自动驾驶(目标检测)、医疗影像分析、OCR(文字识别)、视频监控安防。
  • 核心技能
    • 基础理论:图像处理基础、矩阵运算、卷积神经网络 (CNN)。
    • 经典模型:ResNet, YOLO, Faster R-CNN, Vision Transformer (ViT)。
    • 工具框架:OpenCV, PyTorch, TensorFlow。

2. 自然语言处理 (NLP - Natural Language Processing)

专注于让计算机“听懂”或“读懂”人类语言,当前最火的大模型 (LLM) 就属于这个范畴。

  • 常见应用:机器翻译、情感分析、智能客服、文本摘要、ChatGPT 等大语言模型。
  • 核心技能
    • 基础理论:词向量 (Word2Vec)、循环神经网络 (RNN/LSTM)、Attention 机制。
    • 前沿模型:Transformer, BERT, GPT 系列、LLaMA 等。
    • 领域知识:文本清洗、分词、语义表示、提示工程 (Prompt Engineering)。

3. 推荐 / 搜索 / 广告 (RSA - Recommendation/Search/Advertising)

这通常被称为“工业界最赚钱”的算法方向,主要解决信息过载问题。

  • 常见应用:抖音/小红书的个性化推荐、淘宝的商品搜索、百度/腾讯的广告精准投放。
  • 核心技能
    • 经典算法:协同过滤、FM (Factorization Machines)、DeepFM、GBDT+LR。
    • 工程能力特征工程(非常重要)、召回与排序架构、冷启动策略。
    • 大数据工具:Spark, Flink, Hive (SQL 是基本功)。

4. 语音处理 (Speech/Audio)

处理语音信号,实现人机交互。

  • 常见应用:语音识别 (ASR)、语音合成 (TTS)、声纹识别、降噪处理。
  • 核心技能
    • 基础理论:信号处理(傅里叶变换)、声学模型、语言模型。
    • 模型:WaveNet, Conformer, Whisper 等。

5. 机器学习 / 数据挖掘 (General ML/DM)

更偏向于通用数据分析和预测。

  • 常见应用:金融风控(欺诈检测)、销量预测、用户画像建模、工业异常检测。
  • 核心技能
    • 统计学:概率分布、假设检验、回归分析。
    • 传统算法:逻辑回归、支持向量机 (SVM)、随机森林、XGBoost/LightGBM。

通用必备技能 (所有算法岗都需要)

无论你选择哪个方向,以下技能是算法工程师的“护城河”:

1. 数学基础

  • 线性代数(矩阵运算是深度学习的基石)。
  • 概率论与数理统计(模型评估、优化算法的基础)。
  • 最优化方法(梯度下降、正则化等)。

2. 编程能力

  • Python:算法开发的主力语言,需精通 NumPy, Pandas, Scikit-learn。
  • C++:在高性能计算、底层优化、自动驾驶等对延迟敏感的场景下必不可少。
  • 数据结构与算法:这是面试必考项,也是写出高效代码的前提。

3. 深度学习框架

  • 至少精通 PyTorchTensorFlow 其中之一。

4. 工程与落地能力

  • 算法不只是在实验室跑代码,还需要考虑模型部署(TensorRT, ONNX)、线上性能数据清洗等实际问题。

总结建议: 如果你是初学者,建议先打好 数学Python/数据结构 的基础,然后根据个人兴趣选择一个垂直领域(如 NLP 或 CV)深挖。如果你想追求更高的商业价值和就业机会,推荐算法大模型 (LLM) 是目前市场需求最旺盛的方向。

230行代码,零依赖,我用一个文件造了一个AI Agent

作者 EnoYao
2026年3月27日 10:01

230行代码,零依赖,我用一个文件造了一个AI Agent

一个文件,230行代码,零npm依赖——这是我造一个AI Agent的全部成本。

它能自主思考、调用工具、读写文件、执行命令,完成你交给它的任务。它叫 Mini OpenClaw


为什么要造这个轮子

市面上的Agent框架,LangChain、AutoGPT、CrewAI……动辄几十个依赖,上千个文件,光node_modules就能吃掉半个硬盘。

我只想搞清楚一件事:Agent的本质到底是什么?

翻遍所有框架的源码,剥掉封装、抽象、设计模式,剩下的核心逻辑只有一个循环:

用户提问 → LLM思考 → 需要工具吗?
                       ├── 是 → 执行工具 → 结果喂回LLM → 继续思考…
                       └── 否 → 输出最终回答 ✅

这就是 ReAct(Reasoning + Acting)

Agent不是魔法,是一个while循环。

既然核心这么简单,为什么不能用一个文件实现它?

于是我动手了。


架构:极简到不能再简

整个项目只有一个文件 mini-openclaw.mjs,分成4个部分:

 mini-openclaw.mjs(单文件,4个部分)
┌──────────────────────────────────────┐
│  第一部分:工具定义                    │  ← 4个内置工具
│  第二部分:LLM调用                    │  ← CodeBuddy CLI驱动
│  第三部分:ReAct循环                  │  ← 思考→行动→观察
│  第四部分:交互式REPL                 │  ← 命令行界面
└──────────────────────────────────────┘
模块 职责 代码量
工具定义 read_file · write_file · list_dir · run_command ~40行
LLM调用 通过CodeBuddy CLI非交互式调用大模型 ~50行
ReAct循环 检测<tool_call>标签 → 执行工具 → 结果追加 → 继续 ~80行
REPL readline交互界面,支持多轮对话 ~60行

没有package.json。没有node_modules。没有构建步骤。

node mini-openclaw.mjs,一行命令,直接跑。

最好的架构,是你能一眼看完的架构。


核心实现:4个关键设计决策

决策一:文本标签式工具调用

主流方案用OpenAI的function calling格式,需要特定的API结构。

Mini OpenClaw用了一种更简单的方式——文本标签

<tool_call>{"name":"read_file","args":{"path":"test.txt"}}</tool_call>

一个正则就能解析:

const m = text.match(/<tool_call>([\s\S]*?)<\/tool_call>/);

Before:依赖特定API格式,换模型就得改代码 After:纯文本协议,任何模型都能用

决策二:CLI而非HTTP

调用LLM不走HTTP API,直接spawn CodeBuddy CLI进程:

const proc = spawn("codebuddy", [
  "-p",                        // 非交互式
  "--output-format", "json",   // JSON输出
  "--max-turns", "1",          // 单轮
  "--tools", "",               // 禁用内置工具
  "--model", model,
  prompt,
]);

Before:需要HTTP客户端、处理鉴权、管理连接 After:一个spawn搞定,零网络代码

决策三:纯文本对话历史

不用复杂的消息对象数组,直接用字符串拼接:

User: 读取test.txt
Assistant: [调用了 read_file]
[工具结果]: hello world
Assistant: 文件内容是 hello world

简单、直观、debug友好。

决策四:重复调用检测

模型有时会陷入死循环,反复调用同一个工具。Mini OpenClaw用一行代码解决:

const callKey = JSON.stringify(call);
if (callKey === lastCallKey) {
  // 强制终止,让模型总结已有结果
}

Before:Agent卡死,token烧光 After:自动刹车,强制输出

好的防御机制,是用户永远感知不到的那种。


实战效果:3个场景

场景一:普通对话(0次工具调用)

You> 你好,介绍一下你自己

  ⚙ 迭代 1/15

🤖 Assistant:
我是一个智能助手,拥有读写文件、列出目录、执行命令等工具,
可以帮助你完成编程、调试、项目维护等开发相关工作。

LLM判断不需要工具,1次迭代,直接回答。

场景二:读取文件(1次工具调用)

You> 读取 .gitattributes 文件内容并解释它的作用

  ⚙ 迭代 1/15
  🔧 调用工具: read_file({"path":".gitattributes"})
  📋 结果: # Auto detect text files and perform LF normalization
            * text=auto

  ⚙ 迭代 2/15

🤖 Assistant:
.gitattributes 文件用于配置 Git 对文件的处理方式:
- `* text=auto`:对所有文件启用文本自动检测和行尾LF标准化

LLM思考 → 调用工具 → 拿到结果 → 总结回答。2次迭代。

场景三:执行命令(1次工具调用)

You> 用命令查看当前的 node 版本和操作系统信息

  ⚙ 迭代 1/15
  🔧 调用工具: run_command({"command":"node --version && uname -a"})
  📋 结果: v22.22.0
            Darwin Enos-MacBook-Pro.local 24.6.0 ... arm64

  ⚙ 迭代 2/15

🤖 Assistant:
当前环境信息:
- Node.js 版本:v22.22.0
- 操作系统:macOS Darwin (Apple Silicon arm64)

Before:手动敲命令,自己看输出 After:Agent自主决策执行什么命令,自动总结结果

Agent的价值不在于它能做什么,在于你不用告诉它怎么做。


ReAct循环:一张图看懂全部

┌─────────┐    prompt     ┌──────────────┐    spawn     ┌───────────────┐
│  用户    │ ────────────→ │  ReAct循环    │ ──────────→ │ CodeBuddy CLI │
│  输入    │              │  (react函数)   │ ←────────── │  (LLM回复)    │
└─────────┘              └──────┬───────┘    JSON       └───────────────┘
                                │
                    检测 <tool_call> 标签
                                │
                    ┌───────────┴───────────┐
                    │ 有                     │ 无
                    ▼                       ▼
              ┌──────────┐           ┌──────────┐
              │ 执行工具  │           │ 输出回答  │
              │ 追加结果  │           │ 循环结束  │
              │ 继续循环  │           └──────────┘
              └──────────┘

整个流程的代码实现,核心就是react函数里的一个for循环:

for (let i = 1; i <= maxIter; i++) {
  const prompt = buildSystemPrompt() + "\n\n" + history.join("\n\n");
  const reply = await callLLM(prompt, model, apiKey);
  const call = extractToolCall(reply);
  
  if (!call) {
    // 没有工具调用 → 最终回答
    return reply;
  }
  
  // 有工具调用 → 执行 → 结果追加到历史 → 继续循环
  const result = executeTool(call);
  history.push(`[工具结果]: ${result}`);
}

这就是一个AI Agent的全部核心逻辑。

没有中间件。没有插件系统。没有抽象层。

一个循环,一个正则,一个spawn。


快速上手:3步跑起来

# 1. 克隆
git clone https://github.com/wscats/enoclaw.git
cd enoclaw

# 2. 设置Key
export CODEBUDDY_API_KEY=ck_你的key

# 3. 运行
node mini-openclaw.mjs

想换模型?一个环境变量:

MODEL=hunyuan-2.0-thinking node mini-openclaw.mjs

支持的模型:deepseek-v3-2-volc · hunyuan-2.0-thinking · glm-5.0 · glm-4.7 · minimax-m2.5 · kimi-k2.5

前置条件只有两个:Node.js ≥ 18,CodeBuddy CLI(npm i -g @tencent-ai/codebuddy-code)。

好工具的标准:README都不用看完就能跑起来。


写在最后

230行代码能造一个Agent,这件事本身说明了什么?

AI Agent的门槛,从来不在代码量。

LangChain有10万行代码,Mini OpenClaw有230行。它们的核心循环,一模一样。

区别在于:一个让你用框架,一个让你理解框架

所有Agent框架,都是这230行的变体。


📎 GitHub:github.com/Wscats/mini…

图文教学,服务端如何发送(钉钉 +飞书 )机器人通知

作者 工边页字
2026年3月27日 10:00

一共就两步,创建自定义机器人,然后拿到请求接口,最后把消息发出去。完事~

飞书和钉钉基本上都是一个套路,很简单的~

我们开始

创建一个钉钉机器人

首先你得有个群聊

image.png

image.png

image.png

image.png

image.png

如果没有发送的文字里没有我们刚刚设定关键字,钉钉接口会返回如下内容

image.png

image.png

钉钉服务端发送代码

接下来只需要对这个接口进行http请求就完事了

我先来演示下效果,然后,会给出node,php,java,go,python五个语言的演示case

image.png

🟩 1. Node.js(ESM版)

import express from "express";
import axios from "axios";

const app = express();
const PORT = 3000;

const DINGTALK_WEBHOOK =
  "https://oapi.dingtalk.com/robot/send?access_token=YOUR_DINGTALK_TOKEN";

app.get("/send-dingtalk", async (req, res) => {
  try {
    const payload = {
      msgtype: "text",
      text: {
        content: "Node.js 发送测试",
      },
    };

    const response = await axios.post(DINGTALK_WEBHOOK, payload);

    res.json(response.data);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.listen(PORT, () => {
  console.log(`http://localhost:${PORT}`);
});

🟨 2. Java(Spring Boot)

import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.util.*;

@RestController
public class DingController {

    private static final String WEBHOOK =
        "https://oapi.dingtalk.com/robot/send?access_token=YOUR_DINGTALK_TOKEN";

    @GetMapping("/send-dingtalk")
    public Object send() {
        RestTemplate restTemplate = new RestTemplate();

        Map<String, Object> payload = new HashMap<>();
        payload.put("msgtype", "text");

        Map<String, String> text = new HashMap<>();
        text.put("content", "Java 发送测试");

        payload.put("text", text);

        return restTemplate.postForObject(WEBHOOK, payload, String.class);
    }
}

🟪 3. PHP

<?php

$url = "https://oapi.dingtalk.com/robot/send?access_token=YOUR_DINGTALK_TOKEN";

$data = [
    "msgtype" => "text",
    "text" => [
        "content" => "PHP 发送测试"
    ]
];

$options = [
    "http" => [
        "header"  => "Content-Type: application/json",
        "method"  => "POST",
        "content" => json_encode($data),
    ]
];

$context = stream_context_create($options);
$result = file_get_contents($url, false, $context);

echo $result;

🟦 4. Python

import requests

url = "https://oapi.dingtalk.com/robot/send?access_token=YOUR_DINGTALK_TOKEN"

payload = {
    "msgtype": "text",
    "text": {
        "content": "Python 发送测试"
    }
}

response = requests.post(url, json=payload)

print(response.json())

🟫 5. Go

package main

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)

func main() {
url := "https://oapi.dingtalk.com/robot/send?access_token=YOUR_DINGTALK_TOKEN"

payload := map[string]interface{}{
"msgtype": "text",
"text": map[string]string{
"content": "Go 发送测试",
},
}

jsonData, _ := json.Marshal(payload)

resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
panic(err)
}
defer resp.Body.Close()

fmt.Println("Status:", resp.Status)
}

创建一个飞书机器人

其实飞书和钉钉的流程大差不差

image.png

image.png

image.png

image.png

image.png

image.png

飞书发送代码

其实设钉钉的流程是一样的,就是吧url换一下,入参结构换一下。为了大家方便,我还是五个语言的case都来一份,要case的可以直接cv过去试试

image.png

1️⃣ Node.js

import axios from "axios";

const FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN_HERE";

const payload = {
  msg_type: "text",
  content: {
    text: "这是给伙计们的测试数据,带了‘测试’两个字"
  }
};

axios.post(FEISHU_WEBHOOK, payload)
  .then(res => console.log(res.data))
  .catch(err => console.error(err));

2️⃣ Python

import requests

FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN_HERE"

payload = {
    "msg_type": "text",
    "content": {
        "text": "这是给伙计们的测试数据,带了‘测试’两个字"
    }
}

response = requests.post(FEISHU_WEBHOOK, json=payload)
print(response.json())

3️⃣ Java (使用 HttpClient, Java 11+)

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class FeishuBot {
    public static void main(String[] args) throws Exception {
        String webhook = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN_HERE";

        String json = "{"
                + ""msg_type":"text","
                + ""content":{"text":"这是给伙计们的测试数据,带了‘测试’两个字"}"
                + "}";

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(webhook))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json))
                .build();

        HttpClient client = HttpClient.newHttpClient();
        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

        System.out.println(response.body());
    }
}

4️⃣ PHP

<?php
$webhook = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN_HERE";

$data = [
    "msg_type" => "text",
    "content" => [
        "text" => "这是给伙计们的测试数据,带了‘测试’两个字"
    ]
];

$options = [
    'http' => [
        'header'  => "Content-Type: application/json\r\n",
        'method'  => 'POST',
        'content' => json_encode($data),
    ],
];

$context  = stream_context_create($options);
$result = file_get_contents($webhook, false, $context);
if ($result === FALSE) { /* 错误处理 */ }

echo $result;

5️⃣ Go

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
)

func main() {
    webhook := "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN_HERE"

    payload := map[string]interface{}{
        "msg_type": "text",
        "content": map[string]string{
            "text": "这是给伙计们的测试数据,带了‘测试’两个字",
        },
    }

    b, _ := json.Marshal(payload)
    resp, err := http.Post(webhook, "application/json", bytes.NewBuffer(b))
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    var result map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&result)
    fmt.Println(result)
}

最后

如果对你有用的话

点赞收藏吃灰去呀~

Pareto 3.0 发布:基于 Vite 7 的轻量级 React SSR 框架

作者 AI划重点
2026年3月26日 23:28

Pareto 3.0 发布:基于 Vite 7 的轻量级 React SSR 框架

TL;DR:Pareto 3.0 彻底重写 — Rspack 换成 Vite 7,要求 React 19,精简路由约定,Immer 驱动的状态管理,新的 ParetoErrorBoundary 组件。立即体验:npx create-pareto@latest my-app


如果你用过 Next.js 或 Remix,你已经熟悉 React SSR 的模式:基于文件的路由、布局、loader、流式渲染。Pareto 给你相同的模式,但去掉了复杂性。没有 Server Components,没有框架锁定,没有配置迷宫。

Pareto 3.0 是框架追上愿景的版本:构建快速 React 应用所需的一切,没有多余的东西。

3.0 变了什么

Vite 7 取代 Rspack

Pareto 2.x 使用 Rspack,需要分别配置客户端/服务端、Babel 和懒编译器。全部移除了。

Pareto 3.0 使用 Vite 7

  • 开发服务器瞬间启动 — 毫秒级就绪
  • 原生 ESM — 开发时不打包
  • React Fast Refresh — 保留组件状态的 HMR
  • 你的 Vite 插件直接可用 — PostCSS、Tailwind、MDX 等,无需框架封装
  • 单一配置pareto.config.ts 中的 configureVite()
// pareto.config.ts
import type { ParetoConfig } from '@paretojs/core'

const config: ParetoConfig = {
  configureVite(config) {
    config.plugins.push(myVitePlugin())
    return config
  },
}

export default config

React 19

Pareto 3.0 要求 React 19。你可以用 use()、Actions、useOptimistic() 和改进的 Suspense — 但没有 Server Components。Pareto 使用 loader 模式:你的组件是标准的 React,同时在服务端和客户端工作。

精简的路由约定

3.0 的约定文件:

文件 用途
page.tsx 路由组件
layout.tsx 包裹布局
loader.ts 服务端数据获取(新增!)
head.tsx 路由级 title 和 meta 标签
not-found.tsx 404 页面
route.ts JSON API 端点

新增:loader.ts — 在独立文件中定义 loader,将数据获取逻辑与组件分离:

// app/dashboard/loader.ts
import type { LoaderContext } from '@paretojs/core'

export function loader(ctx: LoaderContext) {
  return { stats: getDashboardStats() }
}

变更:错误处理error.tsx 约定被移除。使用 ParetoErrorBoundary 组件 — 可以放在组件树的任意位置:

import { ParetoErrorBoundary } from '@paretojs/core'

<ParetoErrorBoundary fallback={({ error }) => <p>{error.message}</p>}>
  <RiskyComponent />
</ParetoErrorBoundary>

Immer 驱动的状态管理

defineStore() 现在使用 Immer。直接修改,得到不可变结果:

import { defineStore } from '@paretojs/core/store'

const { useStore, getState, setState } = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment(state) {
      state.count += 1  // Immer 使这变成不可变更新
    },
  },
})

支持直接解构:const { count, increment } = counterStore.useStore()。SSR 序列化是自动的 — 无需手动写水合代码。

安全头

OWASP 推荐的安全头,开箱即用:

import { securityHeaders } from '@paretojs/core/node'

const config: ParetoConfig = {
  configureServer(app) {
    app.use(securityHeaders())
  },
}

流式 SSR — 核心特性

Pareto 存在的理由。立即发送页面骨架,慢数据解析后流式传输:

import { defer, useLoaderData, Await } from '@paretojs/core'
import { Suspense } from 'react'

export function loader() {
  return defer({
    quickData: { total: 42 },           // 立即发送
    slowData: fetchFromDatabase(),       // 稍后流式传输
  })
}

export default function Page() {
  const { quickData, slowData } = useLoaderData()

  return (
    <div>
      <h1>{quickData.total} items</h1>
      <Suspense fallback={<Skeleton />}>
        <Await resolve={slowData}>
          {(data) => <DataTable rows={data} />}
        </Await>
      </Suspense>
    </div>
  )
}

用户快速看到内容。慢数据渐进加载。没有全页面的 loading。

从 2.x 迁移

  1. 安装 @paretojs/core@3,更新到 React 19
  2. 移除 Rspack 配置,改用 configureVite()
  3. ParetoErrorBoundary 替换 error.tsx 文件
  4. 用 Vite 开发服务器测试 loader

立即体验

npx create-pareto@latest my-app
cd my-app
npm install
npm run dev

打开 http://localhost:3000,编辑 app/page.tsx,完成。


链接:


Pareto 是 MIT 协议的开源项目。如果觉得有用,请在 GitHub 上给个 Star。

LangChain 教程 03|快速开始:10 分钟创建第一个 Agent

作者 二十一_
2026年3月26日 23:12

LangChain 教程 03|快速开始:10 分钟创建第一个 Agent

📖 本篇导读:这是 LangChain 系列教程的第 3 篇。本篇将带你用 10 行代码创建第一个智能 Agent,体验 LangChain 的核心魅力。读完预计需要 10 分钟。


简单来说

快速开始只需 5 步:定义工具 → 创建 Agent → 配置参数 → 运行测试 → 扩展功能。

就像做一道菜:准备食材(工具)→ 点火(创建 Agent)→ 调味(配置)→ 翻炒(运行)→ 摆盘(扩展)。


🎯 本节目标

读完本节,你将能够回答这些问题:

  • ❓ 如何用 10 行代码创建一个会查天气的 Agent?
  • ❓ 系统提示(System Prompt)有什么用?如何写一个好的系统提示?
  • ❓ 什么是结构化输出?为什么要用它?
  • ❓ 如何让 Agent 记住之前的对话?
  • ❓ 真实世界的 Agent 需要哪些组件?

核心痛点与解决方案

痛点:AI 开发的"起步困难症"

痛点 传统做法 有多痛苦
不知从何开始 面对一堆文档,无从下手 看了一天文档,一行代码没写
功能太简单 只能调用模型,不会用工具 说是 AI 助手,其实就是个聊天机器人
难以扩展 想加个功能,要重写一半代码 越写越复杂,最后成了"代码屎山"
没有记忆 聊完就忘,无法持续对话 用户:"我刚才问什么来着?"

传统做法 vs LangChain 效率对比

举个例子: 你想做一个能查天气的 AI 助手。

传统做法:

1. 注册天气 API 账号
2. 写天气 API 调用代码
3. 写 OpenAI 调用代码
4. 写逻辑:用户问天气就调用天气 API
5. 测试、调试、修复 bug
6. 想加记忆功能?重写一半代码

解决:LangChain 一键生成

import { createAgent, tool } from "langchain";
import * as z from "zod";

// 1. 定义天气工具
const getWeather = tool(
  (input) => `It's always sunny in ${input.city}!`,
  {
    name: "get_weather",
    description: "Get the weather for a given city",
    schema: z.object({ city: z.string() }),
  }
);

// 2. 创建 Agent
const agent = createAgent({
  model: "claude-sonnet-4-5-20250929",
  tools: [getWeather],
});

// 3. 运行测试
const result = await agent.invoke({
  messages: [{ role: "user", content: "东京天气怎么样?" }],
});

console.log(result.messages.at(-1)?.content);
// Output: It's always sunny in Tokyo!

效果对比:

指标 传统做法 LangChain
代码量 50+ 行 10+ 行
开发时间 半天 10 分钟
功能完整度 基础 完整(工具 + 推理 + 记忆)
可扩展性 好(加工具就行)

生活化类比:创建 Agent 就像开咖啡店

创建 Agent 就像开咖啡店

步骤 类比 LangChain 对应
准备工具 咖啡机、磨豆机、冰箱 tool() 定义工具
设定规则 咖啡店规则("微笑服务") systemPrompt 设定行为
配置原料 咖啡豆、牛奶、糖 model 配置模型
记住常客 会员卡、偏好记录 checkpointer 添加记忆
规范输出 统一杯型、标签 responseFormat 结构化输出
开始营业 迎接客人 invoke() 运行 Agent

步骤一:创建基础 Agent(10 行代码)

创建基础 Agent 流程

完整代码

import { createAgent, tool } from "langchain";
import * as z from "zod";

// 1. 定义天气工具
const getWeather = tool(
  (input) => `It's always sunny in ${input.city}!`,
  {
    name: "get_weather",
    description: "Get the weather for a given city",
    schema: z.object({ city: z.string() }),
  }
);

// 2. 创建 Agent
const agent = createAgent({
  model: "claude-sonnet-4-5-20250929",
  tools: [getWeather],
});

// 3. 运行测试
const result = await agent.invoke({
  messages: [{ role: "user", content: "东京天气怎么样?" }],
});

// 4. 查看结果
console.log(result.messages.at(-1)?.content);
// Output: It's always sunny in Tokyo!

代码解析

行号 代码 人话解读
5-14 tool() 定义 "我创建了一个叫 get_weather 的工具,能查指定城市的天气"
6 工具逻辑 "工具被调用时,返回一个固定的天气信息"
8-12 工具配置 "告诉 Agent:这个工具叫什么、能做什么、需要什么参数"
17-20 createAgent() "创建一个 AI 助手,用 Claude 模型,会使用天气工具"
23-26 invoke() "启动任务:用户问东京天气,Agent 会自己决定调用什么工具"
29 查看结果 "从返回的消息中找到最后一条,那是 Agent 的回答"

💡 人话解读

  • tool() 函数就像"注册一个技能",告诉 Agent 它会什么
  • createAgent() 就像"雇佣一个员工",给他技能和大脑
  • invoke() 就像"给员工派任务",他会自己想办法完成

步骤二:创建真实世界的 Agent

真实世界 Agent 架构

真实世界的 Agent 需要什么?

组件 作用 为什么需要
系统提示 设定角色和行为 让 Agent 知道自己是谁,该怎么说话
多个工具 扩展能力 一个工具不够用,需要多个工具配合
模型配置 控制输出 调整温度、超时等参数,让输出更稳定
结构化输出 格式统一 让 Agent 返回固定格式的数据,方便后续处理
记忆 持续对话 记住之前的对话,像人类一样聊天

完整示例:天气预报助手(会说双关语)

import { createAgent, tool } from "langchain";
import { MemorySaver } from "@langchain/langgraph";
import * as z from "zod";

// 1. 定义系统提示
const systemPrompt = `You are an expert weather forecaster, who speaks in puns.

You have access to two tools:

- get_weather_for_location: use this to get the weather for a specific location
- get_user_location: use this to get the user's location

If a user asks you for the weather, make sure you know the location. 
If you can tell from the question that they mean wherever they are, 
use the get_user_location tool to find their location.`;

// 2. 定义工具
const getWeather = tool(
  ({ city }) => `It's always sunny in ${city}!`,
  {
    name: "get_weather_for_location",
    description: "Get the weather for a specific location",
    schema: z.object({ city: z.string() }),
  }
);

const getUserLocation = tool(
  (_, config) => {
    const { user_id } = config.context;
    return user_id === "1" ? "Florida" : "SF";
  },
  {
    name: "get_user_location",
    description: "Get the user's current location",
    schema: z.object({}),
  }
);

// 3. 定义结构化输出格式
const responseFormat = z.object({
  punny_response: z.string(),
  weather_conditions: z.string().optional(),
});

// 4. 设置记忆
const checkpointer = new MemorySaver();

// 5. 创建 Agent
const agent = createAgent({
  model: "claude-sonnet-4-5-20250929",
  systemPrompt,
  tools: [getUserLocation, getWeather],
  responseFormat,
  checkpointer,
});

// 6. 运行 Agent
const config = {
  configurable: { thread_id: "1" },
  context: { user_id: "1" },
};

// 第一次提问:问外面的天气
const response1 = await agent.invoke(
  { messages: [{ role: "user", content: "外面天气怎么样?" }] },
  config
);
console.log("First response:", response1.structuredResponse);

// 第二次提问:继续对话
const response2 = await agent.invoke(
  { messages: [{ role: "user", content: "谢谢!" }] },
  config
);
console.log("Second response:", response2.structuredResponse);

预期输出

// 第一次回答
First response: {
  punny_response: "Florida is still having a 'sun-derful' day! The sunshine is playing 'ray-dio' hits all day long!",
  weather_conditions: "It's always sunny in Florida!"
}

// 第二次回答
Second response: {
  punny_response: "You're 'thund-erfully' welcome! It's always a 'breeze' to help you stay 'current' with the weather.",
  weather_conditions: undefined
}

💡 人话解读

  • 系统提示让 Agent 成为"会说双关语的天气预报员"
  • get_user_location 工具让 Agent 知道用户在哪里
  • 结构化输出让 Agent 返回固定格式的数据
  • checkpointer 让 Agent 记住之前的对话

核心组件详解

1. 系统提示(System Prompt)

什么是系统提示? 系统提示是给 Agent 的"身份说明书",告诉它:

  • 你是谁(角色)
  • 你该怎么说话(风格)
  • 你有什么工具(能力)
  • 你该怎么使用工具(规则)

好的系统提示的特点:

特点 示例 为什么重要
具体 "你是会说双关语的天气预报员" 让 Agent 知道自己的定位
可操作 "如果不知道位置,使用 get_user_location 工具" 给 Agent 明确的行动指南
简洁 控制在 100-200 字 避免占用太多上下文空间
个性化 "说话要幽默,多用天气相关的双关语" 让 Agent 有独特的人格

2. 工具(Tools)

工具的结构:

const myTool = tool(
  (input, config) => {
    // 工具逻辑:接收输入,返回结果
    return "工具执行结果";
  },
  {
    name: "tool_name",          // 工具名字
    description: "工具描述",     // Agent 靠这个决定何时使用
    schema: z.object({          // 参数验证
      param1: z.string(),
      param2: z.number(),
    }),
  }
);

工具的参数:

参数 类型 说明 例子
input object 工具的输入参数 { city: "Tokyo" }
config object 上下文信息 { context: { user_id: "1" } }

3. 结构化输出(Response Format)

什么是结构化输出? 让 Agent 返回固定格式的数据,而不是自由文本。

为什么要用?

  • ✅ 格式统一,方便后续处理
  • ✅ 类型安全,减少错误
  • ✅ 前端展示更方便

使用方法:

const responseFormat = z.object({
  name: z.string(),         // 必需字段
  age: z.number().optional(), // 可选字段
  tags: z.array(z.string()), // 数组
});

const agent = createAgent({
  // ...
  responseFormat, // 告诉 Agent 返回这个格式
});

// 使用时
const result = await agent.invoke({/* ... */});
console.log(result.structuredResponse); // 直接得到结构化对象

4. 记忆(Memory)

什么是记忆? 让 Agent 记住之前的对话,保持上下文连续性。

如何使用?

import { MemorySaver } from "@langchain/langgraph";

// 创建记忆存储
const checkpointer = new MemorySaver();

const agent = createAgent({
  // ...
  checkpointer, // 添加记忆
});

// 运行时需要 thread_id
const config = {
  configurable: { thread_id: "conversation_1" }, // 每个对话一个 ID
};

// 第一次对话
await agent.invoke({/* ... */}, config);

// 第二次对话(用同一个 thread_id)
await agent.invoke({/* ... */}, config);

⚠️ 注意MemorySaver 是内存存储,重启后会丢失。生产环境要用持久化存储,比如数据库。


业务场景:不同类型的快速应用

Agent 业务场景应用

场景 工具需求 系统提示 特色功能
客服助手 查询订单、查物流、处理退款 "你是专业客服,语气友好,解决问题"
结构化输出:统一回复格式
个人助手 查天气、定闹钟、发邮件 "你是贴心助手,记住用户偏好" 记忆功能:记住用户习惯
学习助手 搜索资料、解答问题、生成练习 "你是耐心老师,讲解详细,鼓励学生" 多工具协作:搜索 + 总结
营销助手 生成文案、分析数据、找客户 "你是创意营销专家,善于抓痛点" 结构化输出:营销文案模板
代码助手 搜索文档、生成代码、调试错误 "你是资深程序员,代码简洁,注释清晰" 工具集成:查 API 文档

示例:客服助手

工具:

  • query_order:查询订单状态
  • track_shipment:查询物流信息
  • process_refund:处理退款

系统提示:

You are a helpful customer service agent. 
Be friendly and patient. 
Always try to solve the customer's problem. 
If you need order information, use the query_order tool. 
If you need shipping information, use the track_shipment tool. 
If the customer wants a refund, use the process_refund tool.

使用:

const result = await agent.invoke({
  messages: [{ role: "user", content: "我的订单 #12345 发货了吗?" }]
});

常见问题与解决方案

问题 原因 解决方案
Agent 不知道用工具 工具描述不够清晰 写更详细的 description,说明什么时候用
Agent 回答格式不对 没有使用结构化输出 添加 responseFormat
Agent 记不住对话 没有添加记忆 使用 checkpointerthread_id
Agent 说话风格不对 系统提示不够具体 写更详细的系统提示,指定风格
运行速度慢 模型参数设置不当 调整 temperaturetimeout 等参数
API Key 错误 环境变量没配置 检查环境变量是否正确设置

💡 调试技巧

  • 先从简单的工具开始
  • 逐步添加功能
  • console.log 打印中间结果
  • 检查 Agent 的思考过程

总结对比表

功能 基础 Agent 真实世界 Agent 区别
工具数量 1 个 多个 能力更全面
系统提示 详细 行为更规范
模型配置 默认 自定义 输出更稳定
结构化输出 格式更统一
记忆 能持续对话
代码量 10 行 50 行 功能更完整
适用场景 快速测试 生产环境 更专业可靠

核心要点回顾

  1. 快速开始 5 步:定义工具 → 创建 Agent → 配置参数 → 运行测试 → 扩展功能

  2. 10 行代码tool() 定义技能,createAgent() 创建助手,invoke() 启动任务

  3. 系统提示:给 Agent 设定角色、风格和规则,越具体越好

  4. 结构化输出:用 Zod 定义格式,让 Agent 返回固定结构的数据

  5. 记忆功能:用 MemorySaverthread_id 让 Agent 记住对话

  6. 真实世界:多个工具、详细系统提示、自定义模型配置、结构化输出、记忆,这些是生产级 Agent 的标配


记住:快速开始的目的不是写完美的代码,而是快速体验 LangChain 的魅力。

先跑起来,再慢慢优化。你已经迈出了 AI 应用开发的第一步,接下来的路会越来越精彩!🚀

关注「WEB大前端」,每周分享技术实践和行业洞察。

构建工具 - Webpack 的工程实现分析

作者 二十_M
2026年3月26日 22:58

这里更倾向于着重分析设计与实现,不会聚焦于具体的配置使用细节。

Webpack 是现代前端开发的基石,以使用的复杂度为代价,实现了 1 个几乎没有边界场景的构建工具。

作为 1 个静态模块打包工具:

  • 「静态」体现在「开发阶段」项目在编译时就确定了所有模块之间的依赖关系并依此跑完了全量的构建,HMR 时虽说是局部更新但也需要经历打包和替换;
  • 「模块」是指将项目内所有类型资源都转为统 1 的单位进行处理,webpack 默认只支持 JSON 和 JS 但通过 Loader 可以实现无限扩展支持;
  • 「事件驱动」则是指 webpack 通过 Tapable 实现了 1 个生命周期链路来涵盖所有步骤,工作时通过推进这个链路的向前最终完成任务;

Webpack 这个过时的「构建工具」虽然在性能方面已经开始显得乏善可陈,但设计思想堪称工程艺术的典范。

  • Tapable 实现了完善的发布订阅能力;
  • Webpack 提供了完整的 配置 -> 编译 JS&JSON -> 产出 的能力并支持在所有打包节点进行 IOC ;
  • DevServer 则是对 Webpack 进行二次封装来实现本地服务和 HMR ;

Tapable 实现了完善的发布订阅能力

它是插件系统的底层基础设施,在此之上 Compiler 和 Compilation 构建了自己的生命周期。

// 核心类:所有钩子的基类
class Hook {
  constructor(args = []) {
    this._args = args; // 参数名列表,如 ['compilation', 'callback']
    this.taps = []; // 注册的回调
    this._call = null; // 编译后的执行函数缓存
    this.call = this._call; // 对外方法
  }

  // 注册
  tap(name, fn) {
    // 注册同步回调
    this.taps.push({ name, fn, type: "sync" });
    this._call = null; // 清除缓存,下次 call 会重新编译
  }

  // 调用
  call() {
    // 懒编译
    if (!this._call) {
      this._call = this._compile();
    }
    return this._call(...args);
  }

  // 编译
  _compile() {
    // 抽象方法,由子类实现具体的编译逻辑
    throw new Error("必须由子类实现");
  }
}

// 同步串行:不关心返回值
class SyncHook extends Hook {
  constructor(args) {
    super(args); // 调用父类构造函数
  }
  // 重写 _compile 方法,实现同步逻辑
  _compile() {
    // 获取所有注册的回调
    const taps = this.taps;
    const args = this._args;

    // 动态生成执行函数
    // 注意:这里返回的是一个函数,会被缓存到 this._call
    return function syncCall() {
      return `use strict ...`;
    };
  }
}

class SyncBailHook extends Hook {} // 同步串行:返回非undefined即停止
class SyncWaterfallHook extends Hook {} // 同步瀑布:返回值传给下一个
class SyncLoopHook extends Hook {} // 同步循环:返回true则重复执行
class AsyncParallelHook extends Hook {} // 异步并行:不关心返回值
class AsyncParallelBailHook extends Hook {} // 异步并行:第一个出错即停止
class AsyncSeriesHook extends Hook {} // 异步串行:不关心返回值
class AsyncSeriesBailHook extends Hook {} // 异步串行:出错或返回值即停止
class AsyncSeriesWaterfallHook extends Hook {} // 异步串行瀑布:返回值传给下一个

// 容器类(几乎是个空壳,新版本 webpack 已经删掉)
class Tapable {
  constructor() {
    this.hooks = {};
  }
}

Hook 作为基类提供了标准的发布订阅能力

Hook 作为基类实现了标准的发布订阅能力,只负责注册和缓存逻辑; _compile 作为抽象方法,由子类单独实现,通过懒编译 + 缓存机制,避免重复编译:

  • 由于编译依赖 tapintercept 内容,所以每次新增订阅后,缓存 _call 都要删掉;

intercept 是基类提供的拦截器能力,任意 Plugin 都可以对订阅的 Hook 实例添加拦截逻辑,用以在不改变原有订阅基础上,新增 1 些如 日志、调试 等横切面能力; 拦截器可以监听所有订阅的 register call tap 这 3 个阶段,插入 1 些自定义逻辑;

  • register 在每个 tap 挂载时执行 1 次;
  • callhook.call 触发时执行 1 次;
  • tap 则在每个 tap 回调执行前都执行 1 次;

多个相同阶段的拦截器会按照 Plugin 的引用顺序执行,不支持自定义权重;

tap 与 intercept 的定位与区别:

  • tap 用于注册业务逻辑回调,执行确定且可编译展开,是插件系统的核心承载点;
  • intercept 用于注入横切关注点,方法可选,所以需要运行时判断,适合日志、监控、参数注入等辅助逻辑;
  • 两者在 _compile 中处理方式不同:
    • tap 回调被平铺展开以消除循环开销,
    • intercept 逻辑保留循环因其数量少且方法存在性不确定。

不同的子类通过重写 _compile 实现不同的执行逻辑

class SyncHook {
  constructor(args = []) {
    // 存储所有注册的 tap 回调
    this._x = undefined; // 实际存储函数的数组
    this.taps = []; // 存储 tap 信息(name, type, fn)
    this.interceptors = []; // 存储拦截器
    this._args = args; // 参数名称列表,如 ['name', 'age']
  }

  // 注册 tap 回调
  tap(options, fn) {
    this._tap("sync", options, fn);
  }

  _tap(type, options, fn) {
    // 标准化 options
    if (typeof options === "string") {
      options = { name: options };
    }

    const tapInfo = {
      type,
      fn,
      name: options.name,
      ...options,
    };

    // 调用 intercept 的 register 钩子
    for (const interceptor of this.interceptors) {
      if (interceptor.register) {
        const newTapInfo = interceptor.register(tapInfo);
        if (newTapInfo) {
          tapInfo.fn = newTapInfo.fn;
          tapInfo.name = newTapInfo.name;
        }
      }
    }

    // 添加到 taps 数组
    this.taps.push(tapInfo);

    // 重新编译 hook
    this._compile();
  }

  // 添加拦截器
  intercept(interceptor) {
    this.interceptors.push(interceptor);

    // 如果已有 taps,对新添加的 taps 调用 register
    if (interceptor.register) {
      for (let i = 0; i < this.taps.length; i++) {
        const newTapInfo = interceptor.register(this.taps[i]);
        if (newTapInfo) {
          this.taps[i] = newTapInfo;
        }
      }
    }

    // 重新编译
    this._compile();
  }

  // 调用 hook
  call(...args) {
    // 执行编译后的函数
    return this._call(args);
  }

  // 编译方法:生成调用函数
  _compile() {
    // 提取所有 tap 函数到 _x 数组
    this._x = this.taps.map((tap) => tap.fn);

    // 生成函数代码
    const code = this._createCallCode();

    // 使用 new Function 创建函数
    // 参数: this, _x, 实际的调用参数
    this._call = new Function(
      "var _context;\n" +
        "var _x = this._x;\n" +
        "var _taps = this.taps;\n" +
        "var _interceptors = this.interceptors;\n" +
        code,
    ).bind(this);
  }

  // 核心:生成调用代码(这是关键)
  _createCallCode() {
    const taps = this.taps;
    const interceptors = this.interceptors;
    const args = this._args;

    // 构建参数列表
    const argsStr = args.length ? args.join(", ") : "";

    let code = "";

    // 1. 生成 intercept 的 call 钩子(循环,因为方法可选)
    if (interceptors.length > 0) {
      code += `
        var _interceptors = this.interceptors;
        if (_interceptors.length > 0) {
          for (var i = 0; i < _interceptors.length; i++) {
            var interceptor = _interceptors[i];
            if (interceptor.call) {
              interceptor.call(${argsStr});
            }
          }
        }
      `;
    }

    // 2. 生成 tap 回调的执行代码(平铺展开)
    for (let i = 0; i < taps.length; i++) {
      const tap = taps[i];

      // 每个 tap 执行前,调用 intercept 的 tap 钩子(循环)
      if (interceptors.length > 0) {
        code += `
          {
            var _tap = this.taps[${i}];
            for (var j = 0; j < _interceptors.length; j++) {
              var interceptor = _interceptors[j];
              if (interceptor.tap) {
                interceptor.tap(_tap);
              }
            }
          }
        `;
      }

      // 执行 tap 回调(直接调用,没有循环)
      code += `
        var _fn${i} = _x[${i}];
        var _result${i} = _fn${i}(${argsStr});
      `;

      // SyncBailHook 会有返回判断,但 SyncHook 不需要
      // if (_result${i} !== undefined) return _result${i};
    }

    // 3. 返回结果(SyncHook 返回最后一个结果)
    if (taps.length > 0) {
      code += `return _result${taps.length - 1};`;
    } else {
      code += `return undefined;`;
    }

    return code;
  }
}

不同的子类通过重写 _compile 实现不同的执行逻辑。例如:

  • SyncHook 会将回调依次排列按顺序执行;
  • BailHook 会判断前 1 个回调的返回值是否为 undefined 来决定是否继续向下执行;
  • AsyncServiceHook 中后 1 个 Promise 回调的执行时机依赖于前 1 个 Promise 回调的完整状态;
  • 等等;

_compile 内部逻辑可以简单理解为是拼接,会把所有订阅回调都「编织」进了最终的执行函数中, 也包括拦截器等 tap 以外的内容, 来确保在真正执行回调时不再包含「遍历数组」等运行时开销, 在 Node.js 环境中,这种「以空间换时间」思路的开销远小于循环控制的开销;

Compiler 和 Compilation 实例在 hooks 字段中声明若干个具备语义的特定类型的钩子实例,实现它的生命周期

Compiler 和 Compilation 实例在 hooks 字段中声明若干个具备语义的特定类型的钩子实例,实现它的生命周期。

Compiler 中:

  • environment 相关代表环境准备阶段;
  • run 相关代表运行阶段;
  • compile compilation make 相关代表编译阶段;
  • emit 相关代表产出阶段;

Compilation 中:

  • addEntry 开始识别入口文件;
  • buildModule succeedModule 相关代表模块构建阶段;
  • finishModules 表示所有模块构建完成;
  • seal 表述开始进行封装;
  • optimize 相关表示进行优化;
  • processAssets 相关表示资源生成;

同时外部插件也可以新增生命周期节点提供给其它插件挂载回调; 例如 html-webpack-plugin ,本身也是对其他插件的"服务提供者" , 它需要在HTML生成的不同阶段暴露控制点,让其他插件可以修改标签、内容、属性等,例如:

  • csp-html-webpack-plugin 添加 CSP 相关 meta 标签;
  • html-webpack-inject-preload 添加 preload 链接;

所以它提供了若干 AsyncSeriesWaterfallHook 类型的 hook ;

Webpack 提供了完整的 配置 -> 编译 JS&JSON -> 产出 的能力并支持在所有打包节点进行 IOC

import merge form 'webpack-merge'

class Compilation extends Tapable {
  constructor(compiler) {
    super();

    this.compiler = compiler; // 所属的 Compiler
    this.options = compiler.options;
    this.inputFileSystem = compiler.inputFileSystem;
    this.outputFileSystem = compiler.outputFileSystem;

    // ===== 核心数据结构 =====
    this.modules = new Set(); // 所有模块
    this.chunks = new Set(); // 所有代码块
    this.assets = {}; // 待输出的资源
    this.errors = []; // 错误收集
    this.warnings = []; // 警告收集

    // ===== 依赖关系图 =====
    this.moduleGraph = new ModuleGraph(); // 模块依赖图
    this.chunkGraph = new ChunkGraph(); // Chunk 关系图

    // ===== 内置钩子 =====
    this.hooks = {
      buildModule: new SyncHook(["module"]), // 开始构建一个模块前
      succeedModule: new SyncHook(["module"]), // 模块构建成功后
      finishModules: new AsyncSeriesHook(["modules"]), // 所有模块构建完成
      seal: new SyncHook(), // 开始封装
      optimizeChunks: new SyncHook(["chunks"]), // 优化 Chunk
      processAssets // 12. 
      // ... 更多
    };
  }

  addEntry(){}

  // 构建模块
  buildModule(module, callback) {
    this.hooks.buildModule.call(module);

    // 执行 Loader → 解析 AST → 收集依赖
    module.build(this.options, this, (err) => {
      if (err) return callback(err);

      this.hooks.succeedModule.call(module);
      callback(null, module);
    });
  }

  // 处理依赖,构建依赖图
  processModuleDependencies() {
    const dependencies = module.dependencies;

    // 调用 addModuleDependencies 处理所有依赖
    this.addModuleDependencies(module, dependencies, callback);
  }

  // 递归处理每个依赖重走 1 次 buildModule -> processModuleDependencies
  addModuleDependencies(module, dependencies, callback) {}

  // 完成构建
  finish(callback) {
    this.hooks.finishModules.callAsync(this.modules, callback);
  }

  // 11. 封装(模块 → Chunk)
  seal(callback) {
    this.hooks.seal.call(); // 开始封装

    // 创建 Chunk(根据入口和动态导入)
    this.createChunks();

    // 构建 ChunkGraph(Chunk 之间的关系)
    this.buildChunkGraph();

    // 优化 Chunk(合并、分割等)
    this.hooks.optimizeChunks.callAsync(this.chunks, () => {
      // 生成资源(将 Chunk 转为 Asset)
      this.createAssets();

      this.hooks.afterSeal.call();
      callback();
    });
  }

  // 将 ModuleGraph 转换为 ChunkGraph 。负责决定哪些模块应该打包在同 1 个 chunk 中。
  buildChunkGraph() {}

  // 添加资源
  emitAsset(filename, source) {
    this.assets[filename] = source;
  }
}

class Compiler extends Tapable {
  hooks;
  options;
  constructor(context) {
    super();
    // 5.1 生命 Compiler 相关 hooks
    this.hooks = {
      // -------------------- 环境准备 --------------------
      environment: new SyncHook(), // 环境正在准备
      afterEnvironment: new SyncHook(), // 环境已就绪

      // -------------------- 运行期 --------------------
      run: new AsyncSeriesHook(["compiler"]), // 运行开始
      watchRun: new AsyncSeriesHook(["compiler"]), // 监听模式运行

      // -------------------- 编译期 --------------------
      compile: new SyncHook(["params"]), // 编译开始

      compilation: new SyncHook(["compilation", "params"]), // Compilation准备就绪

      make: new AsyncParallelHook(["compilation"]), // 开始构建模块(核心!)

      // -------------------- 产出期 --------------------
      emit: new AsyncSeriesHook(["compilation"]), // 输出文件

      // -------------------- 完成期 --------------------
      done: new AsyncSeriesHook(["stats"]), // 构建成功完成
      failed: new SyncHook(["error"]), // 构建失败

      // -------------------- 监听模式 --------------------
      watchClose: new SyncHook(), // 监听停止
    };
  }

  newCompilation(params) {
    // 创建 Compilation 实例
    const compilation = new Compilation(this, params);

    // 触发 thisCompilation 钩子(刚创建)
    this.hooks.thisCompilation.call(compilation, params);

    // 触发 compilation 钩子(准备就绪)
    this.hooks.compilation.call(compilation, params);

    return compilation;
  }

  // 10. 编译
  compile(callback) {
    // 创建编译参数
    const params = this.newCompilationParams();

    // 触发compile钩子
    this.hooks.compile.call(params);

    // 创建compilation(核心产出)
    const compilation = this.newCompilation(params);

    // 触发make钩子 - 推进 Compilation 开始编译构建
    this.hooks.make.callAsync(compilation, (err) => {
      if (err) return callback(err);

      // 完成编译
      compilation.finish((err) => {
        if (err) return callback(err);

        // 11. 封装结果
        compilation.seal((err) => {
          if (err) return callback(err);

          // 返回编译结果
          callback(null, compilation);
        });
      });
    });
  }

  // 9. 运行
  run(callback) {
    // 处理最终回调
    const onCompiled = (err, compilation) => {
      if (err) return finalCallback(err);

      // 触发emit钩子
      this.hooks.emit.callAsync(compilation, (err) => {
        if (err) return finalCallback(err);

        // 13. 写入文件系统
        this.emitAssets(compilation, (err) => {
          // 触发done钩子
          this.hooks.done.callAsync(stats, finalCallback);
        });
      });
    };

    // 触发beforeRun钩子
    this.hooks.beforeRun.callAsync(this, (err) => {
      if (err) return finalCallback(err);

      // 触发run钩子
      this.hooks.run.callAsync(this, (err) => {
        if (err) return finalCallback(err);

        // 10. 调用 compile 开始真正的编译
        this.compile(onCompiled);
      });
    });
  }

  // 输出结果到文件系统中
  emitAssets() {}

  close(callback) {
    // 触发close钩子,让插件清理资源
    this.hooks.close.callAsync((err) => {
      // 清理文件监听
      if (this.watching) {
        this.watching.close();
      }

      // 释放内存
      this.cache = null;
      this.compilations = [];

      // 完成清理
      callback(err);
    });
  }

  watch(watchOptions, handler) {
    // 创建 Watching 实例
    const watching = new Watching(this, watchOptions, handler);

    // 启动文件监听
    this.watchFileSystem.watch(watchOptions, (err, changes) => {
      // 文件变化时调用 watching 的 _go 方法
      watching._go(changes); 
    });

    // 返回 Watching 对象
    return watching;
  }
}

const createCompiler = (rawOptions) => {
  // 4. 标准化配置,处理预设
  const options = getNormalizedWebpackOptions(rawOptions);
  applyWebpackOptionsBaseDefaults(options);

  // 5. 实例化Compiler
  const compiler = new Compiler(options.context);
  compiler.options = options;

  // 6. 注入核心内置插件(如EntryPlugin、NodeEnvironmentPlugin等)
  new NodeEnvironmentPlugin().apply(compiler);

  // 7. 现在 Compiler 有了钩子,开始挂载配置中的插件
  if (Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
      if (typeof plugin === "function") {
        plugin.call(compiler, compiler); // 这里才真正调用插件的apply方法!
      } else {
        plugin.apply(compiler);
      }
    }
  }

  // 8. 应用所有内置插件(基于配置)
  new WebpackOptionsApply().process(options, compiler);

  // 8.9 Compiler 环境准备相关 hooks 执行
  compiler.hooks.environment.call();
  compiler.hooks.afterEnvironment.call();

  return compiler;
};

const webpack = (options, callback) => {
  // 1. 合并配置
  options = merge(cliOptions, options);

  // 2. 验证配置
  validateSchema(schema, options);

  // 3. 创建Compiler
  const compiler = createCompiler(options);

  // 9. 立即执行模式:如果传了callback,直接执行
  if (callback) {
    compiler.run((err, stats) => {
      compiler.close((closeErr) => {
        callback(err || closeErr, stats);
      });
    });
    // 构建已经异步开始了,但这里返回的compiler仍然可用
    return compiler;
  }

  // 999. 惰性模式:如果没有callback,只返回compiler(由调用者自己控制)
  return compiler;
};

// 基础设施
class NodeEnvironmentPlugin {
  apply(compiler) {
    // 输入文件系统(读取文件的基础)
    compiler.inputFileSystem = new CachedInputFileSystem(new NodeJsInputFileSystem(), 60000);

    // 输出文件系统(写入文件的基础)
    compiler.outputFileSystem = new NodeOutputFileSystem();

    // 监听文件系统(watch 模式的基础)
    compiler.watchFileSystem = new NodeWatchFileSystem(compiler.inputFileSystem);

    // 缓存系统
    compiler.cache = new MemoryCachePlugin();
  }
}

// 所有内置插件的注册中心
class WebpackOptionsApply {
  process(options, compiler) {
    // 输入:标准化后的配置对象 + Compiler 实例
    // 输出:根据配置,注册所有需要的内置插件

    // 基础插件(几乎总是启用)
    new JavascriptModulesPlugin().apply(compiler);
    new JsonModulesPlugin().apply(compiler);

    // 14. 根据入口配置
    if (options.entry) {
      new EntryOptionPlugin().apply(compiler);
    }

    // 根据输出配置
    if (options.output.path) {
      // 输出相关插件
    }

    // 根据目标环境
    switch (options.target) {
      case "web":
        new WebTargetPlugin().apply(compiler);
        break;
      case "node":
        new NodeTargetPlugin().apply(compiler);
        break;
      case "electron-main":
        new ElectronTargetPlugin().apply(compiler);
        break;
    }

    // 根据优化配置
    if (options.optimization) {
      if (options.optimization.splitChunks) {
        new SplitChunksPlugin().apply(compiler);
      }
      if (options.optimization.minimize) {
        new TerserPlugin().apply(compiler);
      }
    }

    // 根据模块规则
    if (options.module) {
      if (options.module.rules) {
        new RuleSetPlugin().apply(compiler);
      }
    }

    // ... 几百行这样的条件判断
  }
}

// 文件监听中间件:收集涉及文件改动相关的回调,挂载到 Compiler.compile 中
class Watching {
  constructor(compiler, watchOptions, handler) {
    this.compiler = compiler;
    this.handler = handler; // 编译完成的回调
    this.running = false;
  }

  _go(changes) {
    this.running = true;

    // 触发 watchRun 钩子
    this.compiler.hooks.watchRun.callAsync(this.compiler, () => {
      // 执行编译(复用之前的 compile 逻辑!)
      this.compiler.compile((err, compilation) => {
        // 编译完成,调用 handler
        this.handler(err, stats);
        this.running = false;
      });
    });
  }

  close(callback) {
    // 停止监听
    this.compiler.watchFileSystem.close(callback);
  }
}

export { webpack };

0. webpack 函数本质上是 1 个工厂函数,负责实例化 Compiler,是整个 Webpack 构建流程的启动入口。

webpack 函数本质上是 1 个工厂函数,负责实例化 Compiler,是整个 Webpack 构建流程的启动入口。

这里说它是工厂函数,是因为它不直接作为类被实例化(不通过 new webpack() 调用), 而是通过普通函数调用 webpack(config) 来创建并返回 1 个全新的实例。

在 Node.js 环境中调用它时:

  1. webpack 函数接收 1 个配置对象(或配置数组)作为参数,返回 1 个 Compiler 实例。这个实例代表了整个编译过程的完整生命周期,拥有 run、watch 等方法,以及贯穿整个构建过程的钩子系统。

  2. 它内部会解析用户传入的配置,合并默认配置、CLI 参数以及不同模式下的预设配置,最终生成标准化的配置对象传递给 Compiler。

  3. 当传入配置数组或 1 个返回包含多个配置的函数时,它会创建 MultiCompiler 实例,用于并行或串行管理多个独立的编译流程。

1. 合并配置

webpack 会根据「CLI 命令行参数 > 用户配置文件」的优先级通过 webpack-merge 来实现配置合并。

同时也可以手动使用 webpack-merge 来合并多个配置文件来实现多环境下相同配置的抽离;

  • webpack5 新增的 extends 字段内部实现也是 webpack-merge 能力;
import merge form 'webpack-merge'

const webpack = () => {
  options = merge( cliOptions, options );
};

2. 验证配置

import schema form './schema';

const webpack = (options) => {
  validateSchema(schema, options);
};

validateSchema 是 1 个内部由 JSON schema 验证库实现的配置校验函数; 所有版本的 webpack 都有维护 1 个 JSON Schema 定义文件放在全局,类似 1 种声明式的 DSL ,描述了所有合法配置项的类型、结构、枚举值、默认行为等。

webpack() 首次执行时,会将这份 JSON Schema 转为 AST 并根据这份 AST 动态生成 1 个校验逻辑集合,并缓存起来,后续直接复用;

  • 这里传入配置数组时会多次执行校验环境,但针对 JSON Schema 的编译动作只执行 1 次;

这里的 JSON Schema 作为配置规则的唯 1 源头,不止用于在 validateSchema 中;

  • 在 IDE 中编写 webpack.config.json 时可以通过 $schema 字段引用这份 JSON Schema 来生成配置编写提示;
    • 这里的 $schema@types/webpack 的类型提示本质是同 1 个能力,区别在于 $schema 针对 .json 文件,@types/webpack 则针对 .ts 文件;
  • webpack 官方配置文档也是根据这份 JSON Schema 映射生成而不是手动维护的;
  • 第三方库可以利用这份 Schema 在合并时提供类型安全的校验;

webpack 编译 1 次 Schema 通常会耗时 5-20ms ,相较后续若干密集I/O操作在时间消耗上几乎可以忽略不计, 所以 webpack 在发版前没有将这份编译结果保留在 webpack 源码中。

3. 创建Compiler

「配置信息的整理和校验」并不是 webpack 的责任,责任属于它的上层调用者,比如 webpack-cli 、webpack-dev-server , 若在第 3 方项目中直接引用了 webpack 能力,那配置相关的责任则需要第 3 方自行承担。

配置信息整理完成以后,开始进入 webpack 核心逻辑,执行 createCompiler 创建 1 个 Compiler 实例。 注意,这里如果涉及多构建任务时,会转而执行 createMultiCompiler

  • 物料库 和 公共工具包 通常会涉及同时提供多个 bundle 格式的情况:
    • CommonJS 供 Node 环境使用;
    • ES Module 供支持 tree shaking 的打包工具使用;
    • UMD 供浏览器直接引用;
  • 在 monorepo 根目录下调用 1 次 Webpack 同时构建多个子包是非常常见的需求;

MultiCompiler 的并行实现是依赖 NodeJS 的轮询机制,本质是并发, 由于编译任务多为 I/O 密集型任务,所以可以在宏观上表现出“同时进行”的效果。

const webpack = (options) => {
  const isMultiCompiler = Array.isArray(options);
  if (isMultiCompiler) {
    const compilers = createMultiCompiler(options);
  } else {
    const compiler = createCompiler(options);
  }
};

4. 标准化配置,处理预设

const createCompiler = () => {
  const options = getNormalizedWebpackOptions(rawOptions);
  applyWebpackOptionsBaseDefaults(options);
};

将用户传入的配置(可能含有简写形式、未规范的字段)转换为标准化的内部格式; 填充那些不依赖于模式(mode)的默认值,也就是无论开发模式还是生产模式都通用的默认配置。

这些默认值是在配置校验通过后、编译器实例化之前填充的,确保编译器运行时拥有完整的配置。

5. 实例化Compiler

const createCompiler = () => {
  const compiler = new Compiler(options.context);
  compiler.options = options;
};

首先会初始化 1 些基础属性:

  • 设置 this.context 为传入的上下文路径(通常是 process.cwd()),作为项目根目录;

    • 是 Compiler 实例的绝对路径根目录,它在整个构建过程中扮演“路径锚点”的角色,后续若干相对路径最终都会会和它对齐;
    • Monorepo 项目下的 MultiCompiler 实例因为需要对应多个不同路径的子应用,需要配置多个 this.context 值;
  • 设置 this.name 等标识属性;

    • 在 MultiCompiler 实例下才需要 this.name 字段来在「缓存、日志」等等位置区分应用实例;
  • 初始化 this.options 为传入的配置(此时配置已经过校验、标准化、默认值填充,是完整配置);

Compiler 继承自 Tapable,在构造函数中会初始化一系列贯穿构建生命周期的钩子;

初始化 this.cache 缓存、this.resolverFactory 解析器工厂等内部数据结构;

  • cache 是配置中用于控制构建缓存行为的字段。Webpack5 引入了持久化缓存,可以将构建结果缓存到硬盘,大幅提升 2 次构建速度。

6. 注入核心内置插件(如EntryPlugin、NodeEnvironmentPlugin等)

const createCompiler = () => {
  new NodeEnvironmentPlugin().apply(compiler);
};

NodeEnvironmentPlugin 是 Webpack 内置的基础环境插件,负责为 Compiler 注入 Node.js 环境下的文件系统和基础日志能力。

  • compiler.inputFileSystem 和 compiler.outputFileSystem 为 Compiler 提供文件读写能力,
    • 后续为了支持用户覆盖这里的逻辑实现自定义,这里没有将其直接塞入 Compiler 内部而是独立作为 plugin 维护;
    • 单一职责:Compiler 负责构建流程编排,文件系统注入作为独立插件,符合插件化架构设计;
  • compiler.infrastructureLogger 为 Webpack 内部提供日志输出能力;
  • compiler.watchFileSystem 依赖 compiler.inputFileSystem 实现对文件改动的监听;
    • 默认基于 fs.watch ,环境不支持的话会降级为轮询机制,性能会差很多;

compiler.infrastructureLogger 和 Stats 是 Webpack 中两个完全独立的模块。

  • compiler.infrastructureLogger:构建过程的实时日志;
  • Stats:构建结果的汇总报告;

7. 现在Compiler有了钩子,开始挂载配置中的插件

const createCompiler = () => {
  if (Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
      // 这里才真正调用插件的apply方法!
      if (typeof plugin === "function") {
        plugin.call(compiler, compiler);
      } else {
        plugin.apply(compiler);
      }
    }
  }
};

依次执行配置文件中的 plugin ,将相关订阅挂载到目标 hook 上; 这里 Plugin 同时支持 Function 和 Class 两种写法,所以在这里要做区别执行;

8. 应用所有内置插件(基于配置)

class WebpackOptionsApply {
  process(options, compiler) {
    // 1. 触发环境钩子(基础环境已就绪)
    compiler.hooks.environment.call();
    compiler.hooks.afterEnvironment.call();

    // 2. 根据 target 加载核心插件
    if (options.target === "web") {
      // 模拟:添加 web 环境下的 chunk 加载插件
      const JsonpTemplatePlugin = require("./JsonpTemplatePlugin");
      new JsonpTemplatePlugin().apply(compiler);
    } else if (options.target === "node") {
      const NodeTemplatePlugin = require("./NodeTemplatePlugin");
      new NodeTemplatePlugin().apply(compiler);
    }

    // 3. 处理 entry 配置
    const EntryOptionPlugin = require("./EntryOptionPlugin");
    new EntryOptionPlugin().apply(compiler);
    // 触发 entryOption 钩子,实际会创建 EntryPlugin
    compiler.hooks.entryOption.call(options.context, options.entry);

    // 4. 处理 resolve 配置(简化:直接赋值)
    compiler.resolverFactory.hooks.resolveOptions
      .for("normal")
      .tap("WebpackOptionsApply", (resolveOptions) => {
        return { ...resolveOptions, ...options.resolve };
      });

    // 处理 loader 解析器配置
    compiler.resolverFactory.hooks.resolveOptions
      .for("loader")
      .tap("WebpackOptionsApply", (resolveOptions) => {
        return { ...resolveOptions, ...options.resolveLoader };
      });

    // 5. 处理 module.rules(简化:模拟规则注册)
    if (options.module && options.module.rules) {
      const NormalModule = require("./NormalModule");
      for (const rule of options.module.rules) {
        // 实际会调用 NormalModule 的注册逻辑
        compiler.hooks.compilation.tap("WebpackOptionsApply", (compilation) => {
          compilation.hooks.buildModule.tap("RuleHandler", (module) => {
            // 判断是否命中规则
            if (matches(rule, module)) {
              module.addLoader(rule.use);
            }
          });
        });
      }
    }

    // 6. 根据 devtool 添加 source map 插件
    if (options.devtool) {
      const SourceMapDevToolPlugin = require("./SourceMapDevToolPlugin");
      new SourceMapDevToolPlugin(options.devtool).apply(compiler);
    }

    // 7. 根据 optimization 配置添加优化插件
    if (options.optimization && options.optimization.splitChunks) {
      const SplitChunksPlugin = require("./SplitChunksPlugin");
      new SplitChunksPlugin(options.optimization.splitChunks).apply(compiler);
    }

    if (options.optimization && options.optimization.minimize) {
      const TerserPlugin = require("./TerserPlugin");
      new TerserPlugin().apply(compiler);
    }

    // 8. 处理 externals
    if (options.externals) {
      const ExternalsPlugin = require("./ExternalsPlugin");
      new ExternalsPlugin(options.target, options.externals).apply(compiler);
    }

    // 9. 触发装配完成钩子
    compiler.hooks.afterPlugins.call(compiler);
    compiler.hooks.afterResolvers.call(compiler);
  }
}

const createCompiler = () => {
  new WebpackOptionsApply().process(options, compiler);
};

8.1 触发环境钩子(基础环境已就绪)

用户的自定义 plugins 挂载完成以后,开始执行 WebpackOptionsApply 来挂载系统内置的 plugins ,

  • 首先这里需要依赖系统的读写能力,所以要在 NodeEnvironmentPlugin 后面,
  • 同时这里也触发若干钩子,所以用户的自定义 plugins 要在此之前就挂载好,以免被漏掉;

此时所有 静态配置 都已经固定下来,首先会触发 environment 相关的钩子:

  • 这里分为 hooks.environmenthooks.afterEnvironment 2 个步骤,这种“分步钩子”的设计在 Webpack 中非常常见,是为插件提供可控的执行顺序边界。
  • hooks.environment 提供给 plugin 最后 1 次对 配置 进行增删改的机会;
  • hooks.afterEnvironment 在语义上认为环境已最终确定,可以安全地将配置翻译为插件了,只能进行查询;
    • webpack 中所有配置项都对应了相关的 class 来负责实现,
      • entry 对应 EntryPlugin,
      • output 对应 JsonpTemplatePlugin,NodeTemplatePlugin 等等;

8.2 根据 target 加载核心插件

接下来会根据 options.type 来确定如何来加载 chunk , 这里默认值是 web ,会选择使用 JsonpTemplatePlugin 来实现;

JsonpTemplatePlugin 会被注册进 Compiler ,待后续 seal 阶段发现有 import() 语法出现 LoadScriptRuntimeModule 会被实例化并插入到主 bundle 内,LoadScriptRuntimeModule 的能力是使用 JSONP 的方式动态加载模块;

  • 在生成的 HTML 中,通过 <script> 标签异步加载额外的 chunk 文件;
  • 支持跨域加载、按需加载;
  • 处理 chunk 的缓存、重试、并发加载等逻辑;

Webpack 在 Web 环境选择 JSONP 作为 chunk 加载方式,主要是基于浏览器环境的技术限制和性能考量的综合选择。

  • 浏览器中常规的 XMLHttpRequest 或 fetch 受同源策略限制。但 <script> 标签不受此限制;
    • 浏览器对 <script> 标签的加载有成熟的优化机制:
    • 多个 <script> 标签会并行下载(受浏览器同域名并发数限制,通常 6-8 个);
    • 加载后的脚本会被浏览器独立缓存,下次访问相同 chunk 时直接使用缓存;
    • 通过 async 或 defer 属性可以控制执行时机,避免阻塞页面渲染;
  • 与浏览器原生缓存机制深度集成,天然支持代码执行隔离与错误边界;

现代浏览器的 ESM 在技术特性上全面优于 JSONP 方案,Webpack 选择 JSONP 为默认加载方式,核心原因是历史包袱,而非技术优劣。

8.3 处理 entry 配置

接下来处理入口文件,需要把 EntryOptionPlugin 能力注册进 Compiler , 它是 1 个必选 plugin 不受条件影响,但这里因为「需要依赖文件读取能力 和 entry 配置的最终结果」所以才拖到 WebpackOptionsApply 阶段才进行引入和挂载,同时将这两个动作放在 1 起也增加了可读性。

8.4 处理 resolve 配置(简化:直接赋值)

接下来是将用户配置的 resolve 和 resolveLoader 选项注入到 ResolverFactory 中,从而影响模块解析的行为。

// 常见的 resolve 配置项示例
{
  "resolve": {
    "extensions": [".js", ".jsx", ".ts", ".tsx"],
    "alias": {
      "@components": "/absolute/path/to/src/components",
      "@utils": "/absolute/path/to/src/utils"
    },
    "modules": ["node_modules", "/absolute/path/to/src"],
    "mainFields": ["module", "main"],
    "mainFiles": ["index", "main"],
    "enforceExtension": false,
    "fullySpecified": false
  },
  "resolveLoader": {
    "modules": ["node_modules"],
    "extensions": [".js", ".json"],
    "mainFields": ["loader", "main"],
    "mainFiles": ["index"],
    "symlinks": true,
    "cache": true
  }
}

ResolverFactory 是用于创建模块解析器的工厂类。它统 1 管理不同类型解析器的创建逻辑。

Webpack 中需要 3 种解析器:

  • normal:解析普通模块(如 import './foo'、import 'lodash');
  • loader:解析 loader 模块(如 import 'babel-loader');
  • context:解析上下文模块;

ResolverFactory 通过 get(type, options) 方法返回对应类型的解析器实例。

ResolverFactory 的 normal 解析器会读取 options.resolve 来决定以何种规则与顺序来尝试匹配模块; ResolverFactory 的 loader 会读取 options.resolveLoader 构建 1 个寻找目标 loader 的工具函数; options.resolveLoader 在大部分场景下不需要手动配置,预设即可满足需求,除非需要引入项目内自定义 loader(不按照 npm 来引入);

8.5 处理 module.rules(简化:模拟规则注册)

接下来会遍历 options.module.rules 给 compilation.hooks.buildModule 添加包含匹配判断的订阅,

  • 注意这里插入的是匹配逻辑,不是匹配结果,因为在模块使用哪些 loader,不仅仅取决于静态配置,还取决于模块的具体内容或运行时信息。
    • 支持在 use 中标记静态的 loader 集合,
    • 支持给 use 传递 1 个函数条件,根据模块内容再做决定;
    • 也支持配置内联 loader ,跳过 module.rules 步骤;
    • 可以理解为这里每 1 匹配判断的订阅都是 1 个处理器;
  • 这里 compilation.hooks.buildModule 会跟随每个模块的解析执行 1 次,
    • 然后按照 module.rules 的配置顺序依次尝试匹配,直到首次匹配成功;

8.6 根据 devtool 添加 sourceMap 插件

根据用户配置的 devtool 选项,动态添加对应的 Source Map 生成插件。

  • 'source-map':生成独立的 .map 文件;
  • 'eval-source-map':将 Source Map 内联到 eval 执行的代码中,开发时重建速度快;
  • 'cheap-module-source-map':只保留行映射,不包含列信息,提升构建速度;

8.7 根据 optimization 配置添加优化插件

Webpack 的核心优化功能完全不需要外部插件,内置全部覆盖;

  • 代码分割 SplitChunksPlugin
  • 代码压缩 TerserPlugin
  • 作用域提升 ModuleConcatenationPlugin

这时会根据 options.optimization 配置来决定挂载哪些 plugin ;

1 些特殊格式模块的压缩还需要外部 plugin 来实现,还有 可视化分析 等等;

8.8 处理 externals

options.externals 有值时,会添加 ExternalsPlugin 用于在构建过程中排除某些依赖,将其指向外部变量或全局对象。

  • 减少 bundle 体积
  • 利用 CDN 加速

ExternalsPlugin 会根据 options.target(如 'web'、'node')和 options.externals 配置,注册相应的钩子来修改模块解析行为:

  • 在模块解析阶段,如果模块名匹配 externals 中的键,则返回一个外部变量引用,而非继续解析模块路径
  • 根据 target 不同,引用方式也不同:
  • web:生成 global.React 或 window.React ,同时需要手动引入相关 CDN ,例如通过 html-webpack-plugin 的 template 来动态添加 CDN 脚本,尤其是在区分开发和生产环境时。

8.9 触发装配完成钩子

触发 compiler.hooks.afterPlugins.callcompiler.hooks.afterResolvers.call

这里 Resolver 相关的行为早已经结束,afterResolvers 排在 afterPlugins 后执行是为了避免后面挂载的 Plugin 再次对 resolver 配置进行修改;

此时,Compiler 所有准备工作都已经完成;

9. Compiler.run 执行

createCompiler 执行完成以后,回到 webpack 函数中,判断当前是否传递了 callback 来决定是否立即执行构建;

webpack-dev-server 是不传 callback 模式最典型的案例。它获取 compiler 实例后,会替换文件系统为内存文件系统,监听到文件改变后要将心内容返回给浏览器。

// 在 webpack-dev-server 内部
const compiler = webpack(config);

// 关键:将 outputFileSystem 替换为内存文件系统
const MemoryFileSystem = require("memory-fs");
compiler.outputFileSystem = new MemoryFileSystem();

// 后续编译的输出不会写入硬盘,而是写入内存
compiler.watch(watchOptions, (err, stats) => {
  // 从内存中读取构建结果,快速响应 HTTP 请求
  const outputPath = compiler.options.output.path;
  const content = compiler.outputFileSystem.readFileSync(outputPath + "/bundle.js");
  // 将内容返回给浏览器
});

Compiler.run 并不包含构建逻辑,更多的是象征意义:表示「构建」开始了。

这里会定义最终的 onCompiled 回调, 会触发 Compiler.hook.beforeRunCompiler.hooks.run 钩子方法;

  • beforeRun 适合做破坏性操作(如删除文件),因为编译尚未开始,不会干扰后续流程。
  • run 适合做启动性记录,因为它标志着编译即将开始,但还未触及模块。

然后 Compiler.Compile 执行;

10. Compiler.Compile 执行

这里会创建 1 个 Compilation 实例,然后触发 Compiler.hooks.make 开始工作。

Compilation 是构建流程中的核心工作单元,它代表 1 次完整的模块构建和资源生成过程, 从它的生命周期中就可以看清它完整的工作步骤:

  • buildModule 递归触发,构建每个模块;
  • finishModules 所有模块构建完成;
  • optimize 相关,优化;
  • processAssets 生成最终资源,压缩、添加 source map、生成额外文件;

早先 EntryPlugin 相关的 Plugin 针对 Compiler.hooks.make 进行的了订阅,此时会执行相关回调,

  • 首先它执行了 compilation.addEntry 生成 入口依赖对象 ,Compilation 会记录这个入口,后续用来 chunk 分组;
  • 执行 _addModuleChain,调用 processModuleDependencies 启动递归:
    • buildModule 中识别当前模块所需的 loader 并执行,将模块构建结果与模块路径绑定缓存起来,解析依赖继续向下,
    • 这期间逐步构建 1 个 DAG 依赖图,确保无环;
  • 最终 queue 队列被清空掉,finishModules 执行;
class Compilation {
  constructor() {
    this.modules = new Map(); // 缓存已构建的模块
    this.queue = []; // 待构建的模块队列
    this.dependencies = new Map(); // 依赖关系记录
  }

  addEntry(entryPath, callback) {
    // 入口模块入队
    this.queue.push({ path: entryPath, parent: null });
    this._processQueue(callback);
  }

  _processQueue(callback) {
    const processNext = () => {
      if (this.queue.length === 0) {
        callback();
        return;
      }

      // 取出下一个待构建的模块
      const { path, parent } = this.queue.shift();

      // 检查缓存,避免重复构建
      if (this.modules.has(path)) {
        // 只记录依赖关系,不重复构建
        if (parent) {
          this._addDependency(parent, path);
        }
        processNext();
        return;
      }

      // 构建模块(模拟)
      this._buildModule(path, (err, dependencies) => {
        if (err) throw err;

        // 缓存模块
        this.modules.set(path, { code: `// content of ${path}` });

        // 记录父模块依赖
        if (parent) {
          this._addDependency(parent, path);
        }

        // 将依赖加入队列(深度优先:立即递归处理第一个依赖)
        // 注意:这里是"递归"处理,而不是一次性加入所有依赖
        this._addDependenciesToQueue(path, dependencies, processNext);
      });
    };

    processNext();
  }

  _buildModule(path, callback) {
    // 模拟读取文件、执行 loader、解析依赖
    console.log(`Building: ${path}`);

    // 模拟解析出的依赖
    const mockDependencies = this._parseDependencies(path);

    setTimeout(() => {
      callback(null, mockDependencies);
    }, 10);
  }

  _parseDependencies(path) {
    // 模拟依赖解析
    const depsMap = {
      "./src/index.js": ["./src/utils/math.js", "./src/components/Button.js"],
      "./src/utils/math.js": [],
      "./src/components/Button.js": ["./src/utils/math.js"],
    };
    return depsMap[path] || [];
  }

  _addDependenciesToQueue(modulePath, dependencies, callback) {
    if (dependencies.length === 0) {
      callback();
      return;
    }

    // 深度优先:取第一个依赖,立即处理(递归)
    const firstDep = dependencies[0];
    const remainingDeps = dependencies.slice(1);

    // 从后往前插入,保持原顺序
    for (let i = dependencies.length - 1; i >= 0; i--) {
      this.queue.unshift({ path: dependencies[i], parent: modulePath });
    }

    // 继续处理队列(会立即处理刚插入的第一个依赖)
    callback();
  }

  _addDependency(parent, child) {
    if (!this.dependencies.has(parent)) {
      this.dependencies.set(parent, []);
    }
    this.dependencies.get(parent).push(child);
  }
}

// 使用示例
const compilation = new Compilation();
compilation.addEntry("./src/index.js", () => {
  console.log("All modules built");
  console.log("Modules:", Array.from(compilation.modules.keys()));
  console.log("Dependencies:", compilation.dependencies);
});

11. Compilation.seal 执行,chunk 封装

Compilation.hooks.finishModules 的回调函数中,Compilation.seal 执行。

seal 阶段被定义为优化阶段,具体的行为有很多,但最主要的步骤按照顺序依次是:

  • optimizeDependencies 标记使用的导出(Tree Shaking 标记)
  • optimizeChunks 代码分割
  • optimizeModules 作用域提升

首先 FlagDependencyUsagePlugin 会遍历模块依赖图,标记哪些导出被实际使用,哪些未被使用,为后续的代码压缩和死代码移除提供依据。这是实现 Tree Shaking 的核心前置。

然后 SplitChunksPlugin 进行代码分割,根据 import 语句、模块的使用频率等等,将有效模块拆分为多个 chunk ;

最后 ModuleConcatenationPlugin 在 chunk 内进行作用域提升,减少运行时的访问开销,也能减少 chunk 的体积;

在 Compilation.seal 的最后,会执行 Compilation.hook.processAssets ;

12. Compilation.hook.processAssets ,优化

这是资源生成的核心钩子,所有对最终输出文件的处理都在这个阶段完成:

  • TerserPlugin JS 压缩
  • SourceMapDevToolPlugin 生成 sourceMap
  • HtmlWebpackPlugin 生成 HTML

WebpackOptionsApply 在最后阶段,会读取 options.optimization 配置,据此动态创建和挂载对应的 Plugin 。

12.1 TerserPlugin

用来对 JavaScript 文件进行压缩,支持并行处理以提升性能。 它会遍历 compilation.assets,筛选出 JavaScript 文件。 根据配置决定是否创建子进程池,对每个文件调用 terser.minify() 生成压缩后的内容,然后进行替换。

  • Compilation 内部维护了 1 个类 sourceMap 结构来记录每个模块的脚本细节,terser.minify() 更新文件结构后会更新这里;
  • 首先,将当前模块内容解析为 AST ,根据配置决定是否去除 空格、换行、注释、日志 等内容;
  • 然后针对局部变量替换变量名,注意避免全局冲突;
  • 进行 1 些逻辑优化;

12.2 SourceMapDevToolPlugin

12.3 HtmlWebpackPlugin

HtmlWebpackPlugin 是第三方 Webpack 插件,专门用来自动生成 HTML 文件,并将打包后的资源(JS、CSS)注入其中。

大致步骤包括:收集资源 > 读取模版 > 注入文件依赖 > 替换模版变量;

13. Compiler.emitAssets 执行,bundle 输出

此时所有资源(JS、CSS、HTML、图片等)已经过 processAssets 阶段的最终处理,内容完全定型。 Compiler.emitAssets 会将 compilation.assets 中的资源实际写入文件系统(或内存)的内部流程。

  • 遍历 compilation.assets 内容,创建资源文件并插入到输出地址。
class Compiler {
  emitAssets(compilation, callback) {
    const outputPath = this.options.output.path;
    const outputFileSystem = this.outputFileSystem;

    // 创建输出目录(如果不存在)
    outputFileSystem.mkdirp(outputPath, (err) => {
      if (err) return callback(err);

      // 遍历所有资源
      const assets = compilation.getAssets();
      let processed = 0;

      for (const { name, source } of assets) {
        const targetPath = path.join(outputPath, name);
        const content = source.source(); // 获取文件内容(Buffer 或 string)

        // 写入文件
        outputFileSystem.writeFile(targetPath, content, (err) => {
          if (err) return callback(err);

          // 触发 assetEmitted 钩子
          this.hooks.assetEmitted.call(name, content);

          processed++;
          if (processed === assets.length) {
            callback();
          }
        });
      }
    });
  }
}

DevServer 对 Webpack 进行二次封装来实现本地服务和 HMR(未完成)

class WebpackCLI {
  async serve() {
    // 创建 compiler
    const compiler = webpack(options);

    // 创建 Server
    const Server = require("webpack-dev-server");
    const server = new Server(compiler, serveOptions);

    // 999. 启动服务
    await server.start();
  }
}

// webpack-dev-server 内部实现
class Server {
  constructor(compiler, options = {}) {
    this.compiler = compiler;

    // 在这里挂载 middleware
    this.middleware = require("webpack-dev-middleware")(compiler, {
      publicPath: this.compiler.options.output.publicPath,
      // ...
    });

    // 设置 express 使用这个中间件
    this.app.use(this.middleware);
  }

  start() {
    // 这里开始监听
    this.watching = this.compiler.watch(this.watchOptions, (err, stats) => {
      this.sendStats(stats);
    });

    // 启动 express
    this.listen();
  }
}
❌
❌