普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月3日掘金 前端

Three.js 适配的“保姆级”教程,拒绝拉伸和模糊!

作者 烛阴
2026年2月2日 23:44

第一步:CSS 铺满全屏

首先,我们要让 Canvas 元素本身在网页里占满位置。这步最简单,但是很关键,很多人容易漏掉 display: block

默认情况下,Canvas 是 inline 元素,这会导致它底部莫名其妙多出几像素的“留白”(就像文字的行高一样)。

✅ 正确的 CSS 写法:

html, body {
  margin: 0;
  height: 100%;
}

#c {
  width: 100%;  /* 宽度占满父容器 */
  height: 100%; /* 高度占满父容器 */
  display: block; /* 关键!去除底部留白 */
}

第二步:拒绝“暴力拉伸”

CSS 把 Canvas 拉大了,但 Canvas 内部的“绘图缓冲区”(Drawing Buffer)可能还停留在原来的大小。

这就好比你把一张 100x100 的低清图片,强行用 CSS 放大到 1920x1080 显示,结果必然是模糊马赛克

在渲染循环中检查 Three.js 官方推荐写一个通用函数,在每一帧渲染前检查 Canvas 的显示大小和实际大小是否一致。

function resizeRendererToDisplaySize(renderer) {
  const canvas = renderer.domElement;
  // 获取 Canvas 在屏幕上显示的像素宽
  const width = canvas.clientWidth;
  // 获取 Canvas 在屏幕上显示的像素高
  const height = canvas.clientHeight;
  
  // 检查渲染器的内部尺寸是否和显示尺寸一样
  const needResize = canvas.width !== width || canvas.height !== height;
  
  if (needResize) {
    // 如果不一样,就调整渲染器大小
    // 第三个参数 false 很重要!表示不要让渲染器去修改 Canvas 的 CSS 样式
    renderer.setSize(width, height, false);
  }
  
  return needResize;
}

第三步:告诉摄像机“别把人拍扁了”(校准)

解决了模糊问题,还有一个变形问题。

想象一下,你的显示器本来是宽屏(16:9),摄像机也是按 16:9 拍摄的。当你把窗口缩窄变成竖屏(9:16)时,如果摄像机不调整,它还是把 16:9 的画面强行塞进 9:16 的格子里,物体就会被横向挤压

所以,每当分辨率改变时,我们必须更新摄像机的 长宽比 (Aspect Ratio)

✅ 最终的渲染循环代码:

function render(time) {
  time *= 0.001;

  // 1. 检查并调整分辨率
  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    
    // 2. 修正摄像机长宽比
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    
    // 3. 必须调用这个方法,摄像机参数才会生效!
    camera.updateProjectionMatrix();
  }

  // 正常渲染
  renderer.render(scene, camera);
  
  requestAnimationFrame(render);
}

requestAnimationFrame(render);

📂 核心代码与完整示例:   my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

type-challenges(ts类型体操): 16 - 排除最后一项

作者 fxss
2026年2月2日 22:26

16 - 排除最后一项

by Anthony Fu (@antfu) #中等 #array

题目

在此挑战中建议使用TypeScript 4.0

实现一个泛型Pop<T>,它接受一个数组T,并返回一个由数组T的前 N-1 项(N 为数组T的长度)以相同的顺序组成的数组。

例如

type arr1 = ['a', 'b', 'c', 'd']
type arr2 = [3, 2, 1]

type re1 = Pop<arr1> // expected to be ['a', 'b', 'c']
type re2 = Pop<arr2> // expected to be [3, 2]

额外:同样,您也可以实现ShiftPushUnshift吗?

在 Github 上查看:tsch.js.org/16/zh-CN

代码

/* _____________ 你的代码 _____________ */

type Pop<T extends any[]> = T extends [] ? [] : T extends [...infer A, infer _] ? A : never

关键解释:

  • T extends any[]:约束 T 必须是数组类型。
  • T extends [...infer A, infer _]:模式匹配,捕获数组最后一个元素之外的所有前置元素。
  • A:前置元素数组。
  • _:数组最后一个元素。

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

infer

infer 是 TypeScript 在条件类型中提供的关键字,用于声明一个 待推导的类型变量(类似给类型起一个临时名字),只能在 extends 子句中使用。它的核心作用是:从已有类型中提取 / 推导我们需要的部分,而无需手动硬编码类型。

infer 必须配合条件类型使用,语法结构如下:

// 基础结构:推导 T 的类型为 U,若能推导则返回 U,否则返回 never
type InferType<T> = T extends infer U ? U : never;

type Example = InferType<string>; // Example 类型为 string
type Example2 = InferType<number[]>; // Example2 类型为 number[]

高频使用场景:

1. 提取函数的返回值类型
// 定义类型工具:提取函数的返回值类型
type GetReturnType<Fn> = Fn extends (...args: any[]) => infer R ? R : never;

// 测试用函数
const add = (a: number, b: number): number => a + b;
const getUser = () => ({ name: "张三", age: 20 });

// 使用类型工具
type AddReturn = GetReturnType<typeof add>; // AddReturn 类型为 number
type UserReturn = GetReturnType<typeof getUser>; // UserReturn 类型为 { name: string; age: number }
2. 提取数组的元素类型
// 定义类型工具:提取数组元素类型
type GetArrayItem<T> = T extends (infer Item)[] ? Item : never;

// 测试
type NumberArray = GetArrayItem<number[]>; // NumberArray 类型为 number
type StringArray = GetArrayItem<string[]>; // StringArray 类型为 string
type MixedArray = GetArrayItem<[string, number]>; // MixedArray 类型为 string | number
3. 提取 Promise 的泛型参数类型
// 定义类型工具:提取 Promise 的泛型类型
type GetPromiseValue<T> = T extends Promise<infer Value> ? Value : never;

// 测试
type PromiseString = GetPromiseValue<Promise<string>>; // PromiseString 类型为 string
type PromiseUser = GetPromiseValue<Promise<{ id: number }>>; // PromiseUser 类型为 { id: number }
4. 提取函数的参数类型
// 定义类型工具:提取函数参数类型
type GetFunctionParams<Fn> = Fn extends (...args: infer Params) => any ? Params : never;

// 测试
const fn = (name: string, age: number): void => {};
type FnParams = GetFunctionParams<typeof fn>; // FnParams 类型为 [string, number]

// 进一步:提取第一个参数的类型
type FirstParam = GetFunctionParams<typeof fn>[0]; // FirstParam 类型为 string

...infer

type Pop<T extends any[]> = T extends [] ? [] : T extends [...infer A, infer _] ? A : never

type Res1 = Pop<[1,2,3]> // 结果:[1,2]
type Res2 = Pop<[string]> // 推导:A = [],_ = string → 返回 []
type Res3 = Pop<[]> // 结果:[]

这里的 ...infer A 不是值层面的剩余操作符,而是 TS 数组 / 元组类型的模式匹配语法,它放在第一个位置是为了匹配并捕获数组最后一个元素之外的所有前置元素

// Shift 类型:移除第一个元素,返回剩余部分
type Shift<T extends any[]> = T extends [] ? [] : T extends [infer _, ...infer A] ? A : never

type Res4 = Shift<[1,2,3]> // 结果:[2,3]
type Res5 = Shift<[string]> // 结果:[]

...infer A 放到最后,infer _ 放到第一个,模式就变成了 [infer _, ...infer A],此时匹配的是 固定第一个元素 + 任意后置剩余元素

// 移除数组最后两个元素,返回前置部分
type Pop2<T extends any[]> = T extends [...infer A, infer _, infer __] ? A : T

type Res6 = Pop2<[1,2,3,4]> // 结果:[1,2]
type Res7 = Pop2<[1,2]> // 结果:[]
type Res8 = Pop2<[1]> // 不匹配模式,返回原类型 [1]

测试用例

/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<Pop<[3, 2, 1]>, [3, 2]>>,
  Expect<Equal<Pop<['a', 'b', 'c', 'd']>, ['a', 'b', 'c']>>,
  Expect<Equal<Pop<[]>, []>>,
]

相关链接

分享你的解答:tsch.js.org/16/answer/z… 查看解答:tsch.js.org/16/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

前端功能点:实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

Vue-常用修饰符

2026年2月2日 21:49

前言

在 Vue 开发中,修饰符(Modifiers)是指令后的一个特殊后缀(以 . 开头),它能以极简的方式帮我们处理事件冒泡、键盘监听以及复杂的双向绑定逻辑。掌握它们,能让你的模板代码既优雅又高效。

一、 事件修饰符:精准控制交互行为

事件修饰符主要用于处理 DOM 事件的细节。

  • .stop:阻止事件冒泡(调用 event.stopPropagation())。
  • .prevent:阻止事件的默认行为(调用 event.preventDefault())。
  • .capture:在捕获模式下触发事件监听器。
  • .self:只有当事件是从触发元素本身触发时才触发回调。
  • .once:事件只触发一次,之后自动移除监听器。
  • .passive:滚动事件的性能优化,告诉浏览器不需要等待 preventDefault

二、 键盘与鼠标修饰符:语义化监听

1. 按键修饰符

在监听键盘事件时,我们经常需要检查特定的按键,例如:<input @keyup.enter="submitForm" type="text" placeholder="按回车提交">

  • .enter:回车键
  • .tab:Tab 键
  • .space:空格键
  • .delete:删除或退格键
  • .up / .down / .left / .right:方向键

2. 鼠标修饰符

用于限制处理程序仅响应特定的鼠标按键。

  • .left:点击鼠标左键触发。
  • .right:点击鼠标右键触发。
  • .middle:点击鼠标中键(滚轮点击)触发。

三、 v-model 修饰符:数据预处理

这些修饰符可以自动处理表单输入的数据格式。

  • .lazy: 将v-model的同步时机设置在change事件之后,一般为在输入框失去焦点时。
  • .number:自动将用户的输入值转为数值类型(内部使用 parseFloat)。
  • .trim:自动过滤用户输入内容的首尾空白字符。

四、 双向绑定修饰符

这是 Vue 2 到 Vue 3 变化最大的部分。

1. Vue 2 时代的 .sync

在 Vue 2 中,.sync 是实现父子组件属性双向绑定的语法糖。

// 使用 .sync 的语法糖
<ChildComponent :title.sync="pageTitle" />
// 在子组件的方法中
this.$emit('update:title', newTitleValue);

2. Vue 3 的统一:v-model:prop

Vue 3 废弃了 .sync,将其功能合并到了 v-model 中。支持在同一个组件上绑定多个 v-model

 // 在父组件中
<ChildComponent v-model:title="pageTitle" />

// 子组件
<script setup>
defineProps(['title']);
const emit = defineEmits(['update:title']);

const updateTitle = (newVal) => {
  emit('update:title', newVal);
};
</script>

3. Vue 3.4+ 的黑科技:defineModel

这是目前 Vue 3 最推荐的写法,极大简化了双向绑定的逻辑代码。

// 父组件
<ChildComponent v-model="inputValue" />

// 子组件
const inputValue = defineModel({
 // inputValue为双向绑定输入框的值
  type: [String],
  // 默认值
  default: ''
})

五、 总结

  1. 交互逻辑优先使用事件修饰符,减少组件内的非业务代码。
  2. 表单处理善用 .trim.number,降低后端校验压力。
  3. 父子通信在 Vue 3 项目中全面拥抱 v-model:prop,如果是新项目(Vue 3.4+),请直接使用 defineModel,它能让你的代码量减少 50% 以上。

Pagefind:为静态网站打造的极速搜索方案

作者 jump_jump
2026年2月2日 21:04

Pagefind 是一个专为静态网站设计的开源搜索引擎,它能够自动索引你的网站并提供完全离线的搜索体验。

核心特性

  • 按需加载:只下载搜索相关的内容片段,而不是整个索引
  • 轻量级:核心 JS 仅约 20KB,索引文件高度压缩(相比 Lunr.js 减少 85%)
  • 零配置:自动识别内容,开箱即用
  • 多语言支持:内置中文、日文等多语言分词器
  • 完全静态:无需服务器端支持,支持完全离线

快速上手

三步启用搜索

# 1. 构建你的静态网站
npm run build

# 2. 生成搜索索引
npx pagefind --source "dist"

# 3. 在 HTML 中添加搜索界面
<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
<div id="search"></div>
<script src="/pagefind/pagefind-ui.js"></script>
<script>
    new PagefindUI({ element: "#search" });
</script>

Pagefind 会自动在 dist/pagefind/ 目录下生成索引文件。

核心用法

控制索引范围

使用 data-pagefind-body 标记要索引的内容:

<main data-pagefind-body>
    <h1>文章标题</h1>
    <p>这部分内容会被索引</p>
</main>

<!-- 使用 data-pagefind-ignore 排除特定内容 -->
<div data-pagefind-ignore>
    <h2>评论</h2>
    <div class="comments">...</div>
</div>

添加元数据和权重

<!-- 自定义元数据 -->
<article data-pagefind-body
         data-pagefind-meta="author:张三,date:2024-01-01">
    <h1 data-pagefind-weight="10">文章标题</h1>
    <p data-pagefind-weight="5">摘要内容...</p>
    <div>正文内容...</div>
</article>

配置文件

# pagefind.yml
source: "dist"
exclude_selectors:
  - "nav"
  - ".sidebar"
force_language: "zh-cn"

自定义搜索 UI

import * as pagefind from '/pagefind/pagefind.js';

const search = await pagefind.search("React");
const results = await Promise.all(
    search.results.map(r => r.data())
);

实战指南

集成到构建流程

{
  "scripts": {
    "build": "vite build",
    "postbuild": "pagefind --source dist"
  }
}

React 自定义搜索组件

import { useState } from 'react';

function Search() {
    const [results, setResults] = useState([]);

    const handleSearch = async (e) => {
        const { default: pagefind } = await import('/pagefind/pagefind.js');
        const search = await pagefind.search(e.target.value);
        const data = await Promise.all(
            search.results.slice(0, 5).map(r => r.data())
        );
        setResults(data);
    };

    return (
        <>
            <input type="search" onChange={handleSearch} />
            {results.map((r, i) => (
                <a key={i} href={r.url}>
                    <h3>{r.meta.title}</h3>
                    <p dangerouslySetInnerHTML={{ __html: r.excerpt }} />
                </a>
            ))}
        </>
    );
}

最佳实践

1. 只索引主要内容

<!-- ✅ 推荐 -->
<main data-pagefind-body>
    <article>...</article>
</main>

2. 使用权重优化结果

<h1 data-pagefind-weight="10">标题</h1>
<p data-pagefind-weight="5">摘要</p>

3. CLI 参数配置

# 排除选择器
pagefind --source "dist" --exclude-selectors "nav" --exclude-selectors "footer"

# 强制语言
pagefind --source "dist" --force-language "zh-cn"

配置参考

HTML 属性

属性 说明
data-pagefind-body 标记要索引的主要内容区域
data-pagefind-ignore 排除该元素及其子元素
data-pagefind-meta 添加自定义元数据
data-pagefind-filter 定义可过滤的字段
data-pagefind-sort 定义可排序的字段
data-pagefind-weight 设置内容权重(1-10)

JavaScript API

// 高级搜索
const search = await pagefind.search("React", {
  filters: { category: "tutorial" },
  sort: { date: "desc" },
  limit: 10
});

// 获取结果
const results = await Promise.all(
  search.results.map(r => r.data())
);

原理深度解析

整体架构

首先通过架构图了解 Pagefind 的整体设计:

graph TB
    subgraph "构建阶段 Build Time"
        A[HTML 文件] --> B[内容扫描器]
        B --> C[内容提取器]
        C --> D[多语言分词器]
        D --> E[倒排索引构建器]
        E --> F[索引分片器]
        F --> G[压缩引擎]
        G --> H[索引文件]
    end

    subgraph "运行阶段 Runtime"
        I[用户查询] --> J[查询分词]
        J --> K[哈希计算]
        K --> L[按需加载器]
        H --> L
        L --> M[索引查询]
        M --> N[TF-IDF 评分]
        N --> O[结果排序]
        O --> P[内容片段加载]
        P --> Q[摘要生成]
        Q --> R[搜索结果]
    end

    subgraph "缓存层 Cache Layer"
        S[浏览器缓存]
        T[内存缓存]
        L -.-> S
        L -.-> T
    end

    style A fill:#e1f5ff
    style H fill:#e1f5ff
    style I fill:#fff3e0
    style R fill:#fff3e0

索引构建过程

Pagefind 的工作流程可以分为两个阶段:构建时索引运行时搜索

1. 构建时索引(Build Time)

当你运行 pagefind --source "dist" 时,Pagefind 会执行以下步骤:

flowchart TD
    Start([开始构建]) --> Scan[扫描 HTML 文件]
    Scan --> Parse[解析 HTML DOM]
    Parse --> Extract[提取内容]

    Extract --> CheckBody{检查 data-pagefind-body}
    CheckBody -->|找到| UseBody[使用标记的内容]
    CheckBody -->|未找到| UseDefault[使用 body 全部内容]

    UseBody --> Filter[应用排除规则]
    UseDefault --> Filter

    Filter --> Meta[提取元数据]
    Meta --> Tokenize[文本分词]

    Tokenize --> CheckLang{检测语言}
    CheckLang -->|英文| EnTokenizer[英文分词器]
    CheckLang -->|中文| ZhTokenizer[中文分词器 n-gram]
    CheckLang -->|其他| OtherTokenizer[对应语言分词器]

    EnTokenizer --> BuildIndex[构建倒排索引]
    ZhTokenizer --> BuildIndex
    OtherTokenizer --> BuildIndex

    BuildIndex --> CalcWeight[计算词条权重]
    CalcWeight --> Shard[索引分片 256个桶]

    Shard --> Compress[压缩处理]
    Compress --> GenFragment[生成内容片段]
    GenFragment --> WriteFiles[写入文件]

    WriteFiles --> Output[输出到 pagefind/]
    Output --> End([构建完成])

    style Start fill:#90EE90
    style End fill:#FFB6C1
    style BuildIndex fill:#FFE4B5
    style Compress fill:#E0FFFF

关键技术点:

  • 倒排索引:对于每个词条,记录它出现在哪些文档的哪些位置
  • 分片存储:将索引拆分成小块,按需加载(使用一致性哈希算法分配到 256 个桶)
  • 压缩算法:使用高效的压缩减少文件大小

索引结构详解:

pagefind/
├── pagefind.js           # 核心搜索引擎(~20KB)# - 包含哈希函数# - 索引加载器# - 搜索算法
│
├── pagefind-ui.js        # UI 组件(~15KB)
├── pagefind-ui.css       # 样式文件(~3KB)
│
├── index/                # 索引分片(256 个)
│   ├── index_00.pf       # 哈希值 0x00-0x00
│   ├── index_01.pf       # 哈希值 0x01-0x01
│   ├── ...
│   └── index_ff.pf       # 哈希值 0xFF-0xFF
│
├── fragment/             # 内容片段
│   ├── en_<hash>.pf      # 英文页面片段
│   ├── zh_<hash>.pf      # 中文页面片段
│   └── ...
│
└── filter/               # 过滤器数据(如果使用)
    ├── category.pf
    └── tags.pf

2. 运行时搜索(Runtime)

当用户输入搜索查询时的完整时序:

sequenceDiagram
    actor User as 用户
    participant UI as 搜索界面
    participant Core as Pagefind 核心
    participant Cache as 浏览器缓存
    participant Server as 静态服务器

    User->>UI: 输入 "React 教程"
    UI->>UI: 防抖延迟 (300ms)

    UI->>Core: search("React 教程")
    Core->>Core: 分词 ["React", "教程"]

    par 并行计算哈希
        Core->>Core: hash("React") = 0x42
        Core->>Core: hash("教程") = 0xA7
    end

    par 并行加载索引分片
        Core->>Cache: 检查 index_42.pf
        Cache-->>Core: 缓存未命中
        Core->>Server: GET /pagefind/index/index_42.pf
        Server-->>Core: 返回索引数据 (5KB)

        Core->>Cache: 检查 index_a7.pf
        Cache-->>Core: 缓存命中
        Cache-->>Core: 返回缓存数据
    end

    Core->>Core: 解析索引分片
    Core->>Core: 查找匹配文档<br/>"React": [1,5,23]<br/>"教程": [1,8,15]<br/>交集: [1]

    Core->>Core: 计算 TF-IDF 得分
    Core->>Core: 排序结果

    Core->>Cache: 检查 fragment_1.pf
    Cache-->>Core: 缓存未命中
    Core->>Server: GET /pagefind/fragment/zh_1.pf
    Server-->>Core: 返回内容片段 (12KB)

    Core->>Core: 提取摘要<br/>高亮关键词
    Core->>Core: 生成结果对象

    Core-->>UI: 返回搜索结果
    UI->>UI: 渲染结果列表
    UI-->>User: 显示搜索结果

    Note over Core,Server: 总耗时: ~80ms<br/>网络请求: 2 个 (17KB)<br/>缓存命中: 1 个

性能分析:

阶段 耗时 说明
用户输入 + 防抖 300ms 等待用户完成输入
分词 + 哈希计算 <5ms 纯计算,无 I/O
加载索引分片 20-50ms 取决于网络和缓存
索引查询 + 评分 5-10ms 纯内存操作
加载内容片段 15-30ms 取决于网络和缓存
摘要生成 + 渲染 5-10ms DOM 操作
总计(首次) ~80ms 不含防抖延迟
总计(缓存) ~25ms 索引和片段均已缓存

核心技术解析

1. 按需加载机制

Pagefind 最大的创新是渐进式加载。传统的客户端搜索(如 Lunr.js)需要加载完整索引:

// 传统方案:需要加载整个索引
// 假设网站有 1000 个页面,索引文件可能有 5MB
await loadFullIndex(); // 加载 5MB
search("React");

Pagefind 的方案:

// Pagefind:按需加载
search("React");
// 1. 根据 "React" 计算哈希 -> 只加载包含 "React" 的索引分片(可能只有 10KB)
// 2. 找到匹配的文档 ID
// 3. 只加载这些文档的内容片段(可能 20KB)
// 总共只需要下载 30KB,而不是 5MB

实现原理:

查询词 "React"
    ↓
计算哈希:hash("React") = 0x3A7F
    ↓
确定分片:0x3A7F % 256 = 127
    ↓
加载:GET /pagefind/index/index_127.pf
    ↓
解析分片,找到文档 ID: [5, 23, 87]
    ↓
加载内容:GET /pagefind/fragment/en_005.pf

2. 倒排索引结构

倒排索引是搜索引擎的核心数据结构:

正向索引(文档 → 词条):
文档1: ["React", "教程", "入门"]
文档2: ["Vue", "教程", "进阶"]
文档3: ["React", "进阶", "Hooks"]

倒排索引(词条 → 文档):
"React"  → [文档1, 文档3]
"Vue"    → [文档2]
"教程"   → [文档1, 文档2]
"入门"   → [文档1]
"进阶"   → [文档2, 文档3]
"Hooks"  → [文档3]

当搜索 "React 教程" 时:

  1. 查找 "React" → [文档1, 文档3]
  2. 查找 "教程" → [文档1, 文档2]
  3. 取交集 → [文档1]

3. TF-IDF 相关性评分

Pagefind 使用 TF-IDF 算法计算搜索结果的相关性:

TF(词频):词条在文档中出现的频率

TF(t, d) = 词条 t 在文档 d 中出现的次数 / 文档 d 的总词数

IDF(逆文档频率):词条的稀有程度

IDF(t) = log(总文档数 / 包含词条 t 的文档数)

TF-IDF 得分

TF-IDF(t, d) = TF(t, d) × IDF(t)

示例计算:

假设我们有 100 个文档,搜索 "React Hooks":

文档A:
- "React" 出现 10 次,文档总词数 100
  TF("React", A) = 10/100 = 0.1
  包含 "React" 的文档有 30IDF("React") = log(100/30) = 0.52
  TF-IDF("React", A) = 0.1 × 0.52 = 0.052

- "Hooks" 出现 5TF("Hooks", A) = 5/100 = 0.05
  包含 "Hooks" 的文档有 5IDF("Hooks") = log(100/5) = 1.30
  TF-IDF("Hooks", A) = 0.05 × 1.30 = 0.065

文档A 总分 = 0.052 + 0.065 = 0.117

"Hooks" 更稀有,所以权重更高。

4. 多语言分词

Pagefind 内置了多种语言的分词器:

英文分词(基于空格和标点):

"Hello, world!" → ["hello", "world"]

中文分词(基于字典和统计):

"自然语言处理" → ["自然", "语言", "处理"]
或 → ["自然语言", "处理"]
或 → ["自然语言处理"]

Pagefind 使用 n-gram 技术处理 CJK 文本:

"搜索引擎" → ["搜索", "搜索引", "搜索引擎", "索引", "索引擎", "引擎"]

这样即使查询 "搜索" 或 "引擎",也能匹配到 "搜索引擎"。

性能优化技术

Pagefind 通过多种技术实现高性能:

索引压缩(原始 10MB → 500KB,压缩率 95%):

  • 去除 HTML 标签和属性
  • 词干提取(stemming):"running" → "run"
  • 停用词过滤(去除 "the", "a", "is" 等常见词)
  • 增量编码 + Gzip 压缩

并行加载: 支持 HTTP/2 多路复用,多个词条的索引分片并行加载,总耗时 = max(单个加载时间)。

技术内幕深度剖析

1. 核心算法实现

Pagefind 是用 Rust 编写并编译为 WASM,核心逻辑包括:

哈希计算(FNV-1a 算法):

// 词条归一化(转小写、去除特殊字符)→ FNV-1a 哈希 → 映射到 0-255
hash("React") = 0x42 (66)
hash("react") = 0x42 (66)  // 大小写不敏感

索引加载器

  1. 计算词条哈希 → 确定分片编号
  2. 检查内存缓存 → 未命中则加载对应的 .pf 文件
  3. 解析二进制格式 → 存入缓存
  4. 返回词条对应的文档 ID 列表

TF-IDF 评分器

// 计算每个文档的相关性得分
score = Σ(TF × IDF × weight) × lengthNorm
// - TF: 词频
// - IDF: 逆文档频率(缓存优化)
// - weight: 自定义权重
// - lengthNorm: 长度归一化(防止长文档占优)

2. .pf 文件格式

Pagefind 使用自定义的 .pf(Pagefind Format)二进制格式:

索引文件(index_XX.pf)

  • Header:Magic Number (0x5046 'PF') + 版本 + 标志 + 条目数
  • Entries:每个词条 → 文档 ID 列表(增量编码)

示例:"React" → [1, 5, 23] 存储为 [1, +4, +18]

内容片段(fragment_XX.pf)

  • Header:Magic Number + 压缩类型 + 文档 ID + 长度
  • Metadata:JSON 格式(title, url, excerpt 等)
  • Content:原始文本 + 词条位置映射

3. 四层压缩策略

graph LR
    A[原始数据<br/>100KB] --> B[增量编码<br/>50KB]
    B --> C[VarInt 编码<br/>40KB]
    C --> D[词干提取<br/>30KB]
    D --> E[Gzip 压缩<br/>25KB]

    style E fill:#90EE90

Level 1: 增量编码(Delta Encoding)

  • 文档 ID [1, 5, 23, 45][1, +4, +18, +22]
  • 节省 50% 存储空间

Level 2: 变长整数编码(VarInt)

  • 小数字用 1 字节,大数字自动扩展
  • 1 → [0x01]128 → [0x80, 0x01]

Level 3: 词干提取(Stemming)

  • "running", "runs", "runner" → "run"
  • 减少唯一词条数量 30-40%

Level 4: Gzip 压缩

  • 文本压缩率 60-80%
  • 最终实现 95% 总压缩率

4. 三层缓存架构

graph TD
    A[搜索请求] --> B{L1 内存缓存}
    B -->|命中| C[返回结果]
    B -->|未命中| D{L2 HTTP 缓存}
    D -->|命中| C
    D -->|未命中| E{L3 Service Worker}
    E -->|命中| C
    E -->|未命中| F[网络请求]
    F --> G[更新所有缓存]
    G --> C

    style B fill:#FFE4B5
    style D fill:#E0FFFF
    style E fill:#F0E68C
缓存层级 命中延迟 容量 适用场景
L1 内存缓存 <1ms ~10MB 频繁访问的索引(LRU 淘汰)
L2 HTTP 缓存 ~5ms ~100MB 已访问的所有索引(Cache-Control)
L3 Service Worker ~10ms ~50MB 离线访问(可选)
网络请求 50-200ms - 首次访问

性能提升

  • 首次搜索:~80ms
  • 后续搜索(缓存命中):~25ms
  • 离线模式:~25ms

服务器配置(Nginx):

location /pagefind/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    gzip on;
}

性能对比

方案 初次加载 索引大小 (1000页) 搜索速度 离线支持
Pagefind ~20KB ~500KB <50ms
Algolia 0 (CDN) N/A <10ms
Lunr.js ~30KB ~3MB ~100ms

实际数据(500 页文档网站):

  • 首次搜索:下载 45KB,耗时 ~80ms
  • 后续搜索:下载 10KB,耗时 ~25ms
  • 对比 Lunr.js:减少 97% 的下载量

常见问题

Q: Pagefind 与 Algolia 如何选择?

  • Pagefind:中小型网站(< 10,000 页)、免费、离线支持、重视隐私
  • Algolia:大型网站、高级功能、极致速度、付费

Q: 支持哪些框架? 框架无关,支持 VitePress、Docusaurus、Hugo、Jekyll、Astro、Next.js(SSG)等任何生成 HTML 的工具。

Q: 是否影响 SEO? 不影响。Pagefind 的搜索 UI 是客户端渲染的,原始 HTML 内容完全不受影响。

Q: 如何更新索引? 每次构建时重新生成索引。在 CI/CD 中使用 postbuild 脚本自动化。

总结

Pagefind 为静态网站提供了轻量、高性能的搜索方案:

  • 轻量级:核心 20KB,按需加载
  • 高性能:搜索响应 < 50ms
  • 零配置:开箱即用
  • 完全静态:无需服务器,支持离线
  • 多语言:内置 CJK 分词

核心原理

  1. 倒排索引 + 分片:将索引拆分成 256 个小块
  2. 按需加载:根据查询词哈希值只加载相关分片
  3. TF-IDF 评分:计算相关性智能排序
  4. 多语言分词:支持中英文等智能分词

相关资源

昨天 — 2026年2月2日掘金 前端

跨平台框架怎么选:16 个框架全景对比(2026 版)

作者 jump_jump
2026年2月2日 20:29

跨平台不是"能不能跑",而是"用哪条技术路线换哪种确定性"。

选错框架的代价:某团队用 Electron 做笔记应用,上线后用户反馈"启动 5 秒,内存 500MB"。重构用了 3 个月。如果一开始选 Tauri 或 Wails,这个坑完全可以避免。

本文目标:帮你在动手前想清楚。

覆盖范围:16 个框架,4 大技术路线

  • 主流稳定:Flutter、React Native、Electron、Qt(生产环境)
  • 新兴可靠:Wails(Go)、Dioxus(Rust)、Tauri(已值得试水)
  • 垂直场景:Slint(嵌入式)、Uno(C# WASM)、NativeScript(Vue/Angular)
  • 探索阶段:Lynx、Valdi、Electrobun、GPUI

阅读建议

  • 想快速决策?→ 直接看"快速决策表"
  • 想深度了解?→ 按章节完整阅读
  • 想对比细节?→ 查看"指标矩阵"

第一章:先搞懂底层逻辑

在看具体框架前,你需要理解一个核心问题:UI 是怎么画到屏幕上的?

不同的"画法"决定了框架的基因,也决定了它擅长什么、不擅长什么。

1.1 四种技术路线

路线一:自绘渲染(Self-rendering)

原理:框架自己实现一套渲染引擎,拿到系统给的"画布"(Canvas/Surface),一笔一画把 UI 画出来。

类比:你买了一块空白画布,用自己的颜料和画笔画画。画出来的风格完全由你决定,跟画布是什么牌子的没关系。

代表框架:Flutter、Lynx、Qt Quick、GPUI、Dioxus、Slint

优势

  • 跨端一致性极强——因为渲染逻辑是自己写的,不依赖系统控件
  • 动效表现好——可以做到 60fps 甚至 120fps 的流畅动画
  • 可控性高——想改渲染管线?自己动手就行

劣势

  • 包体更大——要打包渲染引擎
  • 与系统"格格不入"——比如 iOS 的橡皮筋效果、Android 的 Material You 动态取色,需要额外适配
  • 无障碍支持需要额外工作
┌─────────────────────────────────────┐
│           你的应用代码               │
├─────────────────────────────────────┤
│         框架的渲染引擎               │  ← 这一层是框架自己实现的
├─────────────────────────────────────┤
│    系统图形 API (Metal/Vulkan/GL)   │
├─────────────────────────────────────┤
│              GPU                     │
└─────────────────────────────────────┘

路线二:原生控件映射(Native Bridging)

原理:框架把你写的代码"翻译"成原生控件调用。你写 <Button>,框架帮你调用 iOS 的 UIButton 或 Android 的 MaterialButton

类比:你是导演,给演员(原生控件)下指令。演员按照各自平台的"表演风格"来演,iOS 演员演得像 iOS,Android 演员演得像 Android。

代表框架:React Native、.NET MAUI、Uno Platform、NativeScript、Valdi

优势

  • 原生体验——因为用的就是原生控件
  • 系统功能集成方便——推送、权限、传感器等直接调用
  • 无障碍支持天然继承

劣势

  • 跨端一致性差——同一份代码在不同平台上长得不一样
  • 有"桥接"成本——JS 和原生通信需要序列化/反序列化
  • 复杂动效难做——要协调多个原生控件
┌─────────────────────────────────────┐
│           你的应用代码               │
├─────────────────────────────────────┤
│       框架的桥接层 (Bridge)          │  ← 翻译 + 通信
├─────────────────────────────────────┤
│   原生控件 (UIKit / Android Views)  │
├─────────────────────────────────────┤
│              系统                    │
└─────────────────────────────────────┘

路线三:WebView/Chromium 方案

原理:用 Web 技术栈(HTML/CSS/JS)写 UI,通过 WebView 或内嵌 Chromium 来渲染。

类比:在应用里开了一个"浏览器窗口",你的 UI 实际上是一个网页。

代表框架:Electron、Tauri、Wails、Electrobun

优势

  • 前端团队无缝上手——就是写网页
  • 生态巨大——npm 上百万个包随便用
  • 开发效率高——热更新、DevTools 一应俱全

劣势

  • 资源占用——Chromium 本身就吃内存
  • 启动慢——要初始化整个浏览器引擎
  • "不够原生"——滚动、右键菜单等细节需要额外打磨
┌─────────────────────────────────────┐
│      你的 Web 应用 (HTML/CSS/JS)    │
├─────────────────────────────────────┤
│   WebView / Chromium / 系统浏览器    │
├────────────────┬────────────────────┤
│  后端进程      │   系统 API 调用    │
│  (Node/Rust)   │                    │
└────────────────┴────────────────────┘

Electron vs Tauri vs Electrobun 核心对比

维度 Electron Tauri Electrobun
渲染引擎 内嵌 Chromium 系统 WebView 系统 WebView/CEF
后端语言 Node.js Rust Bun (TypeScript)
包体大小 150MB+ 3-10MB 10-30MB
启动速度 慢(初始化大) 中等
适合团队 前端团队 愿意学 Rust 前端团队

路线四:逻辑共享优先(Shared Logic First)

原理:只共享业务逻辑和数据层,UI 各平台自己写(或用 Compose Multiplatform 部分共享)。

类比:后厨(业务逻辑)是统一的,但前台装修(UI)各店不同。

代表框架:Kotlin Multiplatform (KMP)

优势

  • 原生体验最佳——UI 就是原生写的
  • 渐进式迁移——可以一点点把逻辑抽到共享层
  • 风险可控——UI 出问题不影响共享逻辑

劣势

  • UI 要写多份(除非用 Compose Multiplatform)
  • 团队需要掌握多平台 UI 开发
  • 共享层的边界需要仔细设计
┌──────────────────────────────────────────────────┐
│                  共享层 (Kotlin)                  │
│         网络、数据库、业务逻辑、状态管理           │
├─────────────────┬────────────────┬───────────────┤
│   Android UI    │    iOS UI      │   Desktop UI  │
│   (Compose)     │   (SwiftUI)    │  (Compose)    │
└─────────────────┴────────────────┴───────────────┘

1.2 一张图看懂路线选择

                        你的核心诉求是什么?
                              │
            ┌─────────────────┼─────────────────┐
            ▼                 ▼                 ▼
       跨端一致性          原生体验           开发效率
       视觉完全统一        系统深度集成        快速上线
            │                 │                 │
            ▼                 ▼                 ▼
       自绘渲染           原生映射          WebView 方案
    Flutter/Lynx/      RN/MAUI/Uno/      Electron/Tauri/
    Dioxus/Slint/Qt    NativeScript/KMP  Wails/Electrobun

技术栈快速匹配

你的团队主要用什么语言?
│
├─ JavaScript/TypeScript
│  ├─ React → React Native / Lynx
│  ├─ Vue/Angular → NativeScript
│  └─ 任意框架 → Electron / Tauri / Wails / Electrobun
│
├─ Dart → Flutter
│
├─ C# → .NET MAUI / Uno Platform(需要 WASM)
│
├─ C++ → Qt / Slint(嵌入式)
│
├─ Go → Wails(桌面)
│
├─ Kotlin → KMP
│
└─ Rust
   ├─ Web 前端 → Tauri
   ├─ 全栈(含 UI)→ Dioxus
   ├─ 嵌入式 → Slint
   └─ 极致性能 → GPUI

第二章:16 个框架逐一拆解

下面我们按"成熟度从高到低"的顺序介绍每个框架。为了便于理解,我们将框架按技术路线分组呈现。

2.1 Flutter(Google,2018 稳定版)

一句话定位:自绘渲染的"全能选手",跨端一致性最强的主流方案。

技术栈

  • 语言:Dart(Google 自研,语法类似 Java/JS 混合体)
  • 渲染:Skia 引擎 → 正在迁移到 Impeller(iOS 已默认启用)
  • 架构:Widget 树 + 声明式 UI

适合场景

  • 品牌型应用,强调视觉一致性(如 Google Pay、阿里闲鱼)
  • 重动效、重交互的应用(如游戏化电商、社交)
  • 需要同时覆盖移动 + Web + 桌面

不太适合

  • 需要深度系统集成的工具类应用(如文件管理器)
  • 团队对 Dart 抵触强烈
  • 包体大小极度敏感(Flutter 最小包体约 4-5MB)

真实案例

  • Google Pay:全球支付应用,Flutter 重写后开发效率提升 70%
  • 闲鱼:阿里的二手交易平台,首页用 Flutter 实现
  • BMW:车载信息娱乐系统

代码示例(感受一下 Dart 风格):

// 一个简单的计数器页面
class CounterPage extends StatefulWidget {
  @override
  _CounterPageState createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('计数器')),
      body: Center(
        child: Text('点击了 $_count 次', style: TextStyle(fontSize: 24)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => setState(() => _count++),
        child: Icon(Icons.add),
      ),
    );
  }
}

入门步骤

  1. 安装 Flutter SDK:docs.flutter.dev/get-started…
  2. 配置平台工具链(Android Studio + SDK;macOS 需 Xcode)
  3. 运行环境检查:flutter doctor
  4. 创建项目:flutter create my_app
  5. 运行:cd my_app && flutter run

常见坑

  • 热重载失效:有时需要热重启(Shift+R)或完全重启
  • 包体优化:使用 --split-debug-info--obfuscate 可减小约 30%
  • iOS 审核:确保 Info.plist 里的权限说明清晰

2.2 React Native(Meta,2015)

一句话定位:用 React 写原生应用,前端团队的"舒适区扩展"。

技术栈

  • 语言:JavaScript/TypeScript + React
  • 渲染:映射到原生控件
  • 架构:新架构(Fabric + TurboModules)正在推进

适合场景

  • 团队是 React 技术栈,想复用前端能力
  • 需要原生体验,但开发效率也很重要
  • 应用以内容展示为主(如新闻、电商列表页)

不太适合

  • 复杂动效(如游戏、3D 展示)
  • 需要跨端 UI 完全一致
  • 对启动速度要求极高(RN 的 JS 引擎初始化需要时间)

真实案例

  • Facebook/Instagram:部分页面使用 RN
  • Shopify:商家管理应用
  • Discord:移动端部分功能

代码示例

// React Native 的代码对 React 开发者很熟悉
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <View style={styles.container}>
      <Text style={styles.text}>点击了 {count} 次</Text>
      <TouchableOpacity style={styles.button} onPress={() => setCount(c => c + 1)}>
        <Text style={styles.buttonText}>+1</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  text: { fontSize: 24, marginBottom: 20 },
  button: { backgroundColor: '#007AFF', padding: 15, borderRadius: 8 },
  buttonText: { color: 'white', fontSize: 18 },
});

入门步骤

  1. 安装 Node.js(推荐 18+)
  2. 选择初始化方式:
    • 快速上手:npx create-expo-app my-app(Expo 托管方案)
    • 完全控制:npx react-native init MyApp(裸 RN)
  3. 配置原生工具链(Android Studio + Xcode)
  4. 运行:npx expo startnpx react-native run-ios

常见坑

  • 桥接性能:大量数据传递时考虑用新架构的 JSI
  • 第三方库兼容性:检查是否支持新架构
  • 启动时间:用 Hermes 引擎替代 JSC 可提升 30-50%

2.3 NativeScript(Progress Software,2014)

一句话定位:用 Vue/Angular/Vanilla JS 写原生应用,填补非 React 前端技术栈的空白。

技术栈

  • 语言:JavaScript/TypeScript + Vue/Angular/Vanilla JS
  • 渲染:映射到原生控件(与 RN 类似)
  • 架构:直接访问原生 API(无桥接层)

适合场景

  • Vue 或 Angular 技术栈的团队
  • 需要直接访问原生 API
  • 想要原生体验的移动应用

不太适合

  • React 技术栈(直接用 React Native)
  • 需要复杂动效
  • 桌面端需求(主要支持移动端)

真实案例

  • SAP:企业应用
  • Strudel:音乐流媒体应用

代码示例(Vue 风格):

<template>
  <Page>
    <ActionBar title="计数器"/>
    <StackLayout class="p-20">
      <Label :text="`点击了 ${count} 次`" class="text-center text-2xl mb-4"/>
      <Button text="+1" @tap="count++" class="btn btn-primary"/>
    </StackLayout>
  </Page>
</template>

<script>
export default {
  data() {
    return {
      count: 0
    }
  }
}
</script>

入门步骤

  1. 安装 Node.js 和 NativeScript CLI:npm install -g @nativescript/cli
  2. 创建项目:ns create my-app --vue--angular
  3. 配置原生工具链(Android Studio + Xcode)
  4. 运行:ns run iosns run android

与 React Native 的对比

维度 React Native NativeScript
框架支持 React Vue/Angular/Vanilla
原生访问 通过桥接 直接访问
性能 有桥接开销 理论上更快
生态 更大 较小

2.4 Electron(GitHub/OpenJS Foundation,2013)

一句话定位:Web 技术栈做桌面应用的"事实标准",简单粗暴但有效。

技术栈

  • 前端:HTML/CSS/JS(任意前端框架)
  • 后端:Node.js(完整的 Node API)
  • 渲染:Chromium

适合场景

  • 快速把 Web 应用搬到桌面
  • 团队只有前端能力
  • 对包体大小和内存占用不敏感

不太适合

  • 资源敏感型应用(如系统工具)
  • 需要极致启动速度
  • 用户设备配置较低

真实案例

  • VS Code:微软的代码编辑器(证明 Electron 可以做出高性能应用)
  • Slack:团队协作工具
  • Discord:桌面端
  • Figma:桌面端

资源占用参考

  • 空项目包体:~150MB(压缩后)
  • 空项目内存:~80-150MB
  • VS Code 内存:~300-800MB(取决于打开的文件和扩展)

入门步骤

  1. 初始化项目:npm init -y
  2. 安装 Electron:npm install -D electron
  3. 创建 main.js
const { app, BrowserWindow } = require('electron');

app.whenReady().then(() => {
  const win = new BrowserWindow({ width: 800, height: 600 });
  win.loadFile('index.html');
});
  1. 添加启动脚本到 package.json"start": "electron ."
  2. 运行:npm start

性能优化技巧

  • 使用 BrowserWindowshow: false + ready-to-show 事件避免白屏
  • 延迟加载非必要模块
  • 考虑使用 contextIsolation 提升安全性

2.5 Qt / Qt Quick(The Qt Company,1995/2010)

一句话定位:工业级跨平台方案,嵌入式和桌面的"老大哥"。

技术栈

  • 语言:C++(核心)+ QML(声明式 UI)
  • 渲染:RHI(Rendering Hardware Interface),支持 Vulkan/Metal/D3D/OpenGL
  • 架构:信号槽机制 + 属性绑定

适合场景

  • 工业软件、医疗设备、汽车 HMI
  • 嵌入式系统(Linux 嵌入式、MCU)
  • 对性能和稳定性要求极高

不太适合

  • 快速原型验证(学习曲线陡)
  • 小团队短周期项目
  • 纯移动端应用(移动端生态弱于 Flutter/RN)

真实案例

  • 特斯拉 Model S/X:早期车载系统
  • 达芬奇手术机器人:控制界面
  • Autodesk Maya:部分 UI
  • VirtualBox:虚拟机管理界面

代码示例(QML):

// QML 声明式 UI,类似 JSON 但带逻辑
import QtQuick 2.15
import QtQuick.Controls 2.15

ApplicationWindow {
    width: 400
    height: 300
    visible: true
    title: "计数器"

    Column {
        anchors.centerIn: parent
        spacing: 20

        Text {
            text: "点击了 " + counter + " 次"
            font.pixelSize: 24
        }

        Button {
            text: "+1"
            onClicked: counter++
        }
    }

    property int counter: 0
}

许可证说明

  • 开源版(LGPL/GPL):可免费商用,但有一些限制(如动态链接、开源要求)
  • 商业版:按开发者人数收费,约 $300-500/月/人

入门步骤

  1. 下载 Qt Online Installer:www.qt.io/download
  2. 安装 Qt 6.x + Qt Creator
  3. 创建新项目 → Qt Quick Application
  4. 选择目标 Kit(Desktop/Android/iOS)
  5. 运行(Qt Creator 一键构建)

2.6 .NET MAUI(Microsoft,2022)

一句话定位:C# 团队的跨平台方案,微软生态的"官方答案"。

技术栈

  • 语言:C#
  • UI:XAML 或 C# Markup
  • 渲染:原生控件映射(类似 RN)

适合场景

  • 企业内部应用(与 Azure、Office 365 集成好)
  • 已有 C#/.NET 技术栈的团队
  • Windows 优先,兼顾其他平台

不太适合

  • 需要极致跨端一致性
  • 非 .NET 团队(学习成本高)
  • iOS/Android 优先的消费级应用

代码示例

// .NET MAUI 的 XAML + C# 模式
// MainPage.xaml
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui">
    <VerticalStackLayout Spacing="20" VerticalOptions="Center">
        <Label x:Name="CounterLabel" Text="点击了 0 次" FontSize="24" HorizontalOptions="Center"/>
        <Button Text="+1" Clicked="OnCounterClicked" HorizontalOptions="Center"/>
    </VerticalStackLayout>
</ContentPage>

// MainPage.xaml.cs
public partial class MainPage : ContentPage
{
    int count = 0;

    public MainPage() => InitializeComponent();

    void OnCounterClicked(object sender, EventArgs e)
    {
        count++;
        CounterLabel.Text = $"点击了 {count} 次";
    }
}

入门步骤

  1. 安装 .NET 8 SDK:dotnet.microsoft.com/download
  2. 安装 MAUI 工作负载:dotnet workload install maui
  3. 创建项目:dotnet new maui -n MyApp
  4. 用 Visual Studio 或 VS Code 打开
  5. 选择目标平台运行

2.7 Uno Platform(Uno Platform,2018)

一句话定位:C# 生态的"全平台方案",比 .NET MAUI 更早、支持 WebAssembly。

技术栈

  • 语言:C#
  • UI:XAML(与 UWP/WinUI 兼容)
  • 渲染:各平台原生控件 + WebAssembly 支持
  • 架构:基于 WinUI API surface

与 .NET MAUI 的关键区别

维度 .NET MAUI Uno Platform
发布时间 2022 2018
WebAssembly 不支持 支持(核心优势)
API 来源 Xamarin.Forms 演进 WinUI/UWP
Windows 优先度 中等 高(WinUI 语法)
Linux 支持 有限 通过 Skia 支持

适合场景

  • 需要 WebAssembly 支持(在浏览器中运行)
  • 熟悉 WinUI/UWP 的团队
  • 需要更广泛的平台支持(包括 Linux、Tizen)
  • Windows 应用需要迁移到其他平台

不太适合

  • 新项目且对 WebAssembly 无需求(考虑 MAUI)
  • 不熟悉 XAML 的团队
  • 需要最轻量级的移动应用

真实案例

  • HSBC:银行应用的部分功能
  • Bluebeam:建筑协作软件

代码示例

<!-- MainPage.xaml - 与 WinUI 语法兼容 -->
<Page x:Class="MyApp.MainPage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
    <StackPanel Spacing="20" HorizontalAlignment="Center" VerticalAlignment="Center">
        <TextBlock x:Name="CounterText" Text="点击了 0 次" FontSize="24"/>
        <Button Content="+1" Click="OnCounterClicked"/>
    </StackPanel>
</Page>
// MainPage.xaml.cs
public sealed partial class MainPage : Page
{
    private int _count = 0;

    public MainPage()
    {
        this.InitializeComponent();
    }

    private void OnCounterClicked(object sender, RoutedEventArgs e)
    {
        _count++;
        CounterText.Text = $"点击了 {_count} 次";
    }
}

入门步骤

  1. 安装 .NET SDK 和 Uno Platform 模板:
    dotnet new install Uno.Templates
    
  2. 创建项目:
    dotnet new unoapp -o MyApp
    
  3. 选择目标平台(iOS/Android/WebAssembly/Windows/macOS/Linux)
  4. 运行:
    • WebAssembly: dotnet run --project MyApp.Wasm
    • 移动端:用 Visual Studio 或 Rider

WebAssembly 优势示例

# 构建 WebAssembly 版本
dotnet publish MyApp.Wasm -c Release

# 直接部署到 Web 服务器,无需应用商店审核
# 用户通过浏览器访问即可使用

2.8 Tauri(Tauri Programme,2022 v1.0)

一句话定位:Electron 的"轻量替代品",用系统 WebView + Rust 后端。

技术栈

  • 前端:任意 Web 框架(React/Vue/Svelte/原生)
  • 后端:Rust
  • 渲染:系统 WebView(macOS: WKWebView, Windows: WebView2, Linux: WebKitGTK)

与 Electron 的关键区别

维度 Electron Tauri
包体(空项目) ~150MB ~3MB
内存(空项目) ~100MB ~30MB
后端语言 Node.js Rust
WebView 内嵌 Chromium 系统自带
跨端一致性 高(同一个 Chromium) 中(系统 WebView 版本不同)

适合场景

  • 在意包体大小和资源占用
  • 团队愿意学 Rust(或只做简单后端逻辑)
  • 不需要复杂的 Node.js 生态

不太适合

  • 需要保证不同系统上渲染完全一致
  • 后端逻辑复杂且团队不熟悉 Rust
  • 需要使用大量 Node.js 包

入门步骤

  1. 安装 Rust:rustup.rs/
  2. 安装系统依赖(Linux 需要 WebKitGTK)
  3. 创建项目:npm create tauri-app@latest
  4. 选择前端模板(React/Vue/Svelte/Vanilla)
  5. 开发:npm run tauri dev
  6. 构建:npm run tauri build

Rust 后端示例

// src-tauri/src/main.rs
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
// 前端调用
import { invoke } from '@tauri-apps/api/tauri';
const greeting = await invoke('greet', { name: 'World' });

2.9 Wails(Wails Project,2019 / v2 2022)【重点推荐】

一句话定位:Go + WebView 的桌面应用方案,填补 Go 技术栈空白,比 Tauri 学习曲线更低。

技术栈

  • 前端:任意 Web 框架(React/Vue/Svelte/原生)
  • 后端:Go
  • 渲染:系统 WebView(与 Tauri 相同)
  • 绑定:Go 方法直接暴露给前端

核心优势

优势 说明 对比
学习曲线低 Go 比 Rust 容易学 比 Tauri 门槛低 50%
类型安全 自动生成 TS 类型 编译时发现错误
并发能力强 goroutine 原生支持 适合高并发场景
包体适中 10-15MB 比 Electron 小 90%
编译快 Go 编译速度快 比 Rust 快 5-10 倍

桌面 WebView 方案全面对比

维度 Electron Tauri Wails Electrobun
后端语言 Node.js Rust Go Bun (TS)
学习曲线 低(JS/TS) 高(Rust陡) 低(Go易学) 低(TS)
包体大小 150MB 3MB 10MB 15MB
内存占用 100MB 30MB 45MB 50MB
编译速度 无需编译 慢(Rust) 快(Go)
并发模型 事件循环 异步+线程 goroutine 异步
类型安全 JS→TS 手动定义 自动生成TS TS 原生
生态成熟度 5/5 4/5 3/5 2/5

适合场景

  • Go 技术栈团队做桌面应用(这是最主要的使用场景)
  • 后端逻辑复杂,需要高并发处理(如数据同步、文件处理)
  • 需要调用 Go 生态的库(如 gRPC、各种数据库驱动)
  • 在意包体大小,但不想学 Rust
  • 系统工具类应用(文件管理、网络工具、开发工具)

不太适合

  • 需要跨平台 UI 完全一致(WebView 版本不同)
  • 需要移动端支持(Wails 主要是桌面)
  • 复杂的前端逻辑但后端很简单(考虑 Electron)
  • 团队完全是前端,没人会 Go

真实案例

  • LocalSend:跨平台文件传输工具(开源,6k+ stars)
  • Clash Verge:代理工具的 GUI 版本
  • 多个企业内部工具(数据分析、运维面板)

代码示例(完整的类型安全流程):

步骤 1:后端 Go 方法

// app.go - 定义后端方法
type App struct {
    ctx context.Context
}

func (a *App) Greet(name string) string {
    return fmt.Sprintf("Hello %s!", name)
}

func (a *App) ProcessFile(path string) error {
    // 利用 Go 的 goroutine 并发处理
    go func() {
        // 后台处理文件
    }()
    return nil
}

步骤 2:Wails 自动生成 TypeScript 类型

// wailsjs/go/models.ts - 自动生成,无需手写
export namespace main {
    export class App {
        static Greet(name: string): Promise<string>;
        static ProcessFile(path: string): Promise<void>;
    }
}

步骤 3:前端调用(完全类型安全)

import { Greet } from '../wailsjs/go/main/App';

const result = await Greet("World");  // ✅ 类型正确
// await Greet(123);  // ❌ TypeScript 编译错误

核心优势:前后端接口不匹配在编译时就能发现,而不是运行时报错。

快速开始(5 分钟)

# 1. 安装 CLI
go install github.com/wailsapp/wails/v2/cmd/wails@latest

# 2. 检查环境
wails doctor

# 3. 创建项目(选择模板:react/vue/svelte)
wails init -n myapp -t react

# 4. 开发(热重载)
cd myapp && wails dev

# 5. 构建
wails build  # 输出: myapp.app / myapp.exe / myapp

Wails v2 vs v3(2025 重大更新)

Wails v3 正在开发中,主要改进:

  • 原生移动端支持(iOS/Android)
  • 插件系统(类似 Tauri 的插件)
  • 更好的 TypeScript 集成
  • 自动更新支持

性能优化技巧

  1. 使用 Go 的并发优势
// 并行处理多个任务
func (a *App) ProcessMultipleFiles(files []string) {
    var wg sync.WaitGroup
    for _, file := range files {
        wg.Add(1)
        go func(f string) {
            defer wg.Done()
            // 处理文件
        }(file)
    }
    wg.Wait()
}
  1. 使用事件系统(前后端通信):
// 后端发送事件
runtime.EventsEmit(a.ctx, "progress", Progress{
    Current: 50,
    Total: 100,
})
// 前端监听事件
import { EventsOn } from '../wailsjs/runtime';

EventsOn('progress', (data) => {
    console.log(`Progress: ${data.current}/${data.total}`);
});
  1. 按需构建(减小包体):
# 只构建当前平台
wails build

# 跨平台构建
wails build -platform darwin/amd64,darwin/arm64,windows/amd64

常见问题

问题 解决方案
Windows 缺少 WebView2 引导用户安装 WebView2 Runtime
跨平台 WebView 差异 测试各平台,使用 polyfill
Go 依赖管理 运行 go mod tidy
前端资源路径错误 检查 wails.json 配置

Wails vs Tauri 选择指南

维度 选 Wails 选 Tauri
团队技能 熟悉 Go / 不想学 Rust 愿意学 Rust
后端需求 高并发(goroutine) 一般
包体要求 10MB 可接受 要求最小(3MB)
编译速度 要求快 可接受慢
类型安全 要自动生成 手动定义可接受
生态成熟度 可接受成长期 要求更成熟

2.10 Kotlin Multiplatform / KMP(JetBrains,2023 稳定版)

一句话定位:Android 团队扩展 iOS 的"最小阻力路径",逻辑共享优先。

技术栈

  • 语言:Kotlin
  • 共享层:commonMain(纯 Kotlin,编译到各平台)
  • UI 方案:
    • 原生 UI:Android 用 Jetpack Compose,iOS 用 SwiftUI
    • 共享 UI:Compose Multiplatform(跨平台 Compose)

核心概念

┌────────────────────────────────────────────────┐
│                  commonMain                     │
│   expect fun getPlatformName(): String          │  ← 声明接口
├──────────────────────┬─────────────────────────┤
│      androidMain     │        iosMain          │
│   actual fun get..()actual fun get..()   │  ← 各平台实现
│   = "Android"        │    = "iOS"              │
└──────────────────────┴─────────────────────────┘

适合场景

  • 已有 Android 应用,想扩展到 iOS
  • 想保持各平台的原生体验
  • 团队熟悉 Kotlin

不太适合

  • 想一套代码搞定所有 UI
  • 团队对 Kotlin 不熟悉
  • iOS 是主要平台(用 SwiftUI 原生可能更顺)

代码示例

// commonMain - 共享的网络请求逻辑
class UserRepository(private val api: UserApi) {
    suspend fun getUser(id: String): User {
        return api.fetchUser(id)
    }
}

// 在 Android 和 iOS 中都可以直接使用
val repo = UserRepository(api)
val user = repo.getUser("123")

入门步骤

  1. 安装 Android Studio + Kotlin Multiplatform Mobile 插件
  2. 创建 KMP 项目(选择模板)
  3. shared/src/commonMain 中编写共享逻辑
  4. Android 端:直接依赖 shared 模块
  5. iOS 端:通过 CocoaPods 或 Swift Package Manager 集成

2.11 Lynx(ByteDance,2024 开源)

一句话定位:字节跳动的跨端方案,用 Web 语法写原生渲染的 UI。

技术栈

  • 语言:JavaScript/TypeScript
  • UI 语法:类 React/CSS(支持 Flexbox)
  • 渲染:自研原生渲染引擎(非 WebView)

核心特点

  • 双线程架构:UI 线程和 JS 线程分离,避免 JS 阻塞渲染
  • CSS 子集:支持 Flexbox、常用属性,但不是完整 CSS
  • PlatformView:可嵌入原生控件(如地图、视频播放器)

适合场景

  • 前端团队想做高性能移动应用
  • 需要比 RN 更好的动效性能
  • 字节系应用的技术选型

不太适合

  • 追求稳定、成熟的生态
  • 需要社区大量第三方库支持
  • 桌面端需求(目前主要支持移动端 + Web)

代码示例

// Lynx 的语法对 React 开发者很熟悉
import { Component, View, Text, Image } from '@anthropic/lynx';

export default class App extends Component {
  state = { count: 0 };

  render() {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <Text style={{ fontSize: 24 }}>点击了 {this.state.count} 次</Text>
        <View
          style={{ padding: 15, backgroundColor: '#007AFF', borderRadius: 8 }}
          onClick={() => this.setState({ count: this.state.count + 1 })}
        >
          <Text style={{ color: 'white' }}>+1</Text>
        </View>
      </View>
    );
  }
}

入门步骤

  1. 参考官方文档:lynxjs.org/
  2. 安装 Lynx CLI
  3. 创建项目并配置模拟器环境
  4. 运行调试

2.12 Valdi(Snapchat,2024 Beta)

一句话定位:TypeScript 编译成原生视图,追求 TS 开发体验 + 原生性能。

技术栈

  • 语言:TypeScript
  • 编译:TS → 原生视图代码(不是解释执行)
  • 渲染:原生控件

核心理念

  • 不走 WebView,也不走 JS 运行时
  • 把 TS 代码编译成原生代码
  • 类型安全 + 原生性能

适合场景

  • 喜欢 TypeScript 但不想用 WebView
  • 追求原生性能
  • 愿意尝试新技术

不太适合

  • 需要稳定、成熟的生态
  • 大型团队生产环境使用(目前是 Beta)

入门步骤

  1. 访问:github.com/Snapchat/Va…
  2. 按 README 安装工具链
  3. 创建项目并配置目标平台
  4. 开发调试

2.13 Electrobun(2024 早期)

一句话定位:比 Electron 更轻量的桌面方案,用 Bun + 系统 WebView/CEF。

技术栈

  • 语言:TypeScript
  • 运行时:Bun(替代 Node.js)
  • 渲染:系统 WebView 或 CEF(可选)
  • 底层:Zig

与 Electron/Tauri 对比

维度 Electron Tauri Electrobun
后端 Node.js Rust Bun (TS)
学习成本 中(要学 Rust)
包体
成熟度 早期

适合场景

  • 想要比 Electron 轻量,但不想学 Rust
  • 喜欢 Bun 的开发体验
  • 愿意接受早期阶段的风险

入门步骤

  1. 安装 Bun:bun.sh/
  2. 访问:electrobun.dev/
  3. 按文档初始化项目
  4. 开发调试

2.14 Dioxus(Dioxus Labs,2021 / v0.5 2024)【重点推荐】

一句话定位:Rust 版的 React,用 React-like 语法写全平台 UI,Rust 生态的"全能选手"。

技术栈

  • 语言:Rust
  • 语法:类 React Hooks(但是 Rust 宏实现)
  • 渲染:多后端(Web/Desktop/Mobile/TUI)
  • 架构:虚拟 DOM + 响应式

核心优势

优势 说明 独特性
React-like 语法 前端开发者易上手 Rust GUI 中最像 React
多渲染后端 Web/Desktop/Mobile/TUI 一套代码多平台
WASM 性能 接近原生的 Web 性能 比 JS 快 2-10 倍
类型安全 Rust 编译时检查 内存安全 + 线程安全
TUI 支持 终端 UI 独特优势 其他框架都不支持

与其他 Rust GUI 框架的对比

维度 Dioxus GPUI Tauri egui
语法风格 React-like Rust 原生 Web 前端 即时模式
学习曲线 低(前端易上手) 高(需熟练 Rust) 低(会 Web 即可) 中等
移动端支持 开发中 v2 支持 有限
Web 支持 5/5 完整 WASM 5/5 完整 3/5 有限
TUI 支持 5/5 独特优势
组件生态 3/5 成长中 2/5 早期 5/5(npm生态) 3/5
渲染性能 4/5 强 5/5 极强 3/5 中等 4/5 强
成熟度 3/5 成长中 3/5 成长中 4/5 稳定 4/5 稳定

适合场景

  • Rust 技术栈,想做全平台应用
  • 需要 Web(WASM)和桌面共享代码
  • 前端转 Rust 的开发者(熟悉 React)
  • 命令行工具需要 TUI 界面
  • 性能敏感的应用(利用 Rust + WASM)
  • 开源项目(生态正在快速成长)

不太适合

  • 不熟悉 Rust 的团队(学习曲线陡)
  • 需要大量现成组件(生态还在建设中)
  • 生产环境要求极高稳定性(v1.0 还未发布)
  • 移动端是主要平台(移动端支持还在完善)

真实案例

  • Blitz(开源):游戏辅助工具
  • FutureSDR:软件定义无线电框架的 UI
  • 多个开源开发工具和 TUI 应用

代码示例(感受 Rust + React 的组合):

基础计数器

use dioxus::prelude::*;

fn main() {
    dioxus_desktop::launch(App);
}

fn App(cx: Scope) -> Element {
    let mut count = use_state(cx, || 0);

    cx.render(rsx! {
        div {
            style: "display: flex; flex-direction: column; align-items: center; gap: 20px;",
            h1 { "计数器" }
            p {
                style: "font-size: 24px;",
                "点击了 {count} 次"
            }
            button {
                onclick: move |_| count += 1,
                style: "padding: 10px 20px; font-size: 18px;",
                "+1"
            }
        }
    })
}

组件复用(像 React 一样):

// 可复用的 Button 组件
#[component]
fn MyButton<'a>(
    cx: Scope<'a>,
    onclick: EventHandler<'a, MouseEvent>,
    children: Element<'a>,
) -> Element<'a> {
    cx.render(rsx! {
        button {
            class: "custom-button",
            onclick: move |evt| onclick.call(evt),
            children
        }
    })
}

// 使用组件
fn App(cx: Scope) -> Element {
    cx.render(rsx! {
        MyButton {
            onclick: |_| println!("Clicked!"),
            "点击我"
        }
    })
}

异步数据获取(类似 React Query):

use dioxus::prelude::*;

fn App(cx: Scope) -> Element {
    let user_data = use_future(cx, (), |_| async move {
        // 异步请求数据
        reqwest::get("https://api.example.com/user")
            .await?
            .json::<User>()
            .await
    });

    cx.render(match user_data.value() {
        None => rsx! { p { "加载中..." } },
        Some(Ok(user)) => rsx! {
            div {
                h1 { "欢迎, {user.name}" }
                p { "邮箱: {user.email}" }
            }
        },
        Some(Err(e)) => rsx! { p { "错误: {e}" } },
    })
}

多渲染后端示例

// 同一套代码,不同渲染后端

// 1. 桌面应用(WebView)
fn main() {
    dioxus_desktop::launch(App);
}

// 2. Web 应用(WASM)
fn main() {
    dioxus_web::launch(App);
}

// 3. 终端 UI(TUI)
fn main() {
    dioxus_tui::launch(App);
}

// 4. 服务端渲染(SSR)
fn main() {
    let html = dioxus_ssr::render(&App(cx));
    // 返回 HTML 字符串
}

入门步骤

  1. 安装 Rust 和 Dioxus CLI

    # 安装 Rust
    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    
    # 安装 Dioxus CLI
    cargo install dioxus-cli
    
  2. 创建项目(自动配置):

    dx new my-app
    # 选择模板:web, desktop, mobile, TUI
    
  3. 开发模式(带热重载):

    cd my-app
    dx serve  # Web
    # 或
    dx serve --platform desktop  # 桌面
    
  4. 构建生产版本

    dx build --release
    

Dioxus 0.5 的重大改进(2024):

  • 信号系统:更简单的状态管理
  • 资源系统:内置异步数据获取
  • 路由系统:完整的客户端路由
  • 服务端组件:支持 SSR 和流式渲染
  • 热重载:开发体验接近 Vite

性能优化技巧

  1. 利用 Rust 的零成本抽象
// 组件会在编译时优化
#[inline(always)]
#[component]
fn FastComponent(cx: Scope) -> Element {
    // 编译器会内联这个组件
}
  1. 使用 memo 避免重渲染
let expensive = use_memo(cx, (dep1, dep2), |(d1, d2)| {
    // 只在 dep1 或 dep2 变化时重新计算
    heavy_computation(d1, d2)
});
  1. WASM 优化
# 构建优化的 WASM
dx build --release --platform web
# 生成的 WASM 包通常只有几百 KB

TUI 应用示例(独特优势):

// 用同样的代码创建漂亮的终端 UI
use dioxus::prelude::*;
use dioxus_tui::Config;

fn main() {
    dioxus_tui::launch_cfg(
        App,
        Config::new().with_rendering_mode(RenderingMode::Ansi),
    );
}

fn App(cx: Scope) -> Element {
    let mut count = use_state(cx, || 0);

    cx.render(rsx! {
        div {
            border_width: "1px",
            padding: "2",
            h1 { "Terminal Counter" }
            p { "Count: {count}" }
            button {
                onclick: move |_| count += 1,
                "Increment"
            }
        }
    })
}

常见坑

  • 生命周期标注

    • Rust 的生命周期可能让新手困惑
    • 使用 Dioxus CLI 生成的模板可以避免大部分问题
  • 异步运行时

    • 需要理解 Rust 的 async/await
    • 建议使用 use_future 而不是手动管理
  • 跨平台样式

    • 不同渲染后端的样式支持不同
    • Web 支持完整 CSS,桌面支持子集

Rust GUI 框架选择指南

需求 推荐框架 理由
Web + 桌面共享代码 Dioxus WASM + 多后端
前端团队用 Rust 后端 Tauri 前后端分离
React 开发者转 Rust Dioxus 语法相似
需要 TUI(终端界面) Dioxus 独特支持
追求极致性能(编辑器) GPUI 为 Zed 设计
嵌入式设备 Slint 轻量级
要 npm 生态 Tauri Web 前端

未来展望

  • 📱 移动端支持:Dioxus Mobile 正在开发,预计 2026 稳定
  • 🎨 组件库:社区正在建设类似 shadcn/ui 的组件库
  • 🔧 开发者工具:DevTools 正在完善,类似 React DevTools

2.15 Slint(SixtyFPS GmbH,2020 / v1.0 2023)

一句话定位:嵌入式和桌面 GUI 框架,填补"Qt 太重,Flutter 太大"的空白。

技术栈

  • 语言:Rust/C++/JavaScript(多语言绑定)
  • UI 语法:自研 DSL(.slint 文件)
  • 渲染:多后端(软件渲染/OpenGL/Skia/Femtovg)
  • 架构:声明式 UI + 响应式属性

核心特点

  1. 极致轻量

    • 适合低端嵌入式设备(MCU、ARM Cortex-M)
    • 包体可以做到 < 300KB(不含资源)
    • 内存占用可控(几 MB 级别)
  2. 多语言支持

    • Rust(一等公民)
    • C++(适合嵌入式团队)
    • JavaScript/Node.js(快速原型)
    • Python(正在开发)
  3. 设计师友好

    • 提供可视化设计工具(Slint UI Designer)
    • 支持热重载
    • 类似 QML 的声明式语法

与 Qt 的对比(嵌入式场景):

维度 Qt (Qt Quick) Slint
最小包体 ~10-20MB ~300KB
内存占用 ~20-50MB ~2-10MB
启动速度
MCU 支持 需要 Qt for MCUs(商业版) 开源版支持
许可证 LGPL/GPL 或商业 GPL/商业(企业版)
学习曲线
工业案例 5/5 极多 3/5 成长中

适合场景

  • 嵌入式设备:智能家居、工业控制面板、车载 HMI(低端)
  • 资源受限环境:老旧设备、单板计算机(树莓派)
  • 快速启动应用:系统工具、启动界面
  • 多语言团队:可以用 Rust/C++/JS 中的任意一种

不太适合

  • 需要复杂动效(Qt/Flutter 更强)
  • 需要大量现成组件(生态还在建设)
  • Web 应用(虽然有 WASM,但不如 Dioxus)
  • 移动端应用(主要是桌面+嵌入式)

真实案例

  • 工业控制面板
  • 智能家居设备 UI
  • 医疗设备界面

代码示例(感受 Slint 的 DSL):

UI 文件.slint 声明式语法):

// counter.slint
import { Button, VerticalBox } from "std-widgets.slint";

export component Counter {
    in-out property <int> counter: 0;

    VerticalBox {
        Text {
            text: "点击了 \{counter} 次";
            font-size: 24px;
        }

        Button {
            text: "+1";
            clicked => {
                counter += 1;
            }
        }
    }
}

Rust 调用

// main.rs
slint::slint! {
    import { Counter } from "counter.slint";
}

fn main() {
    let ui = Counter::new().unwrap();

    // 可以从 Rust 代码访问和修改属性
    ui.set_counter(0);

    // 监听属性变化
    ui.on_counter_changed(|value| {
        println!("Counter changed to: {}", value);
    });

    ui.run().unwrap();
}

C++ 调用(嵌入式团队友好):

// main.cpp
#include "counter.h"

int main() {
    auto ui = Counter::create();

    // C++ API 类型安全
    ui->set_counter(0);

    // 回调
    ui->on_counter_changed([](int value) {
        std::cout << "Counter: " << value << std::endl;
    });

    ui->run();
}

入门步骤

  1. 安装 Slint(Rust 项目):

    cargo new my-app
    cd my-app
    cargo add slint
    
  2. 创建 UI 文件

    # 创建 ui/counter.slint
    mkdir ui
    
  3. 配置 build.rs(自动编译 .slint 文件):

    // build.rs
    fn main() {
        slint_build::compile("ui/counter.slint").unwrap();
    }
    
  4. 运行

    cargo run
    

可视化设计工具

# 安装 Slint UI Designer
cargo install slint-viewer

# 实时预览 .slint 文件
slint-viewer ui/counter.slint

嵌入式示例(软件渲染,适合无 GPU 设备):

use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType};

fn main() {
    slint::platform::set_platform(Box::new(MyPlatform::new())).unwrap();

    let ui = Counter::new().unwrap();

    // 渲染到帧缓冲区
    let window = ui.window();
    window.set_size(slint::PhysicalSize::new(800, 480));

    // 自定义事件循环(适合 bare-metal 环境)
    loop {
        slint::platform::update_timers_and_animations();
        window.draw_if_needed(|renderer| {
            // 渲染到你的帧缓冲区
        });
    }
}

常见坑

  • DSL 学习.slint 语法需要学习,但比 QML 简单
  • 组件库有限:标准组件够用,但不如 Qt 丰富
  • 文档:相比 Qt 文档较少,但正在改善

什么时候选 Slint 而不是 Qt?

Slint 如果:

  • ✅ 嵌入式设备资源受限(RAM < 50MB)
  • ✅ 需要快速启动(< 100ms)
  • ✅ 想用 Rust 开发嵌入式 GUI
  • ✅ 对许可证敏感(Qt 商业版很贵)

Qt 如果:

  • ✅ 需要丰富的组件库
  • ✅ 工业级项目,稳定性第一
  • ✅ 团队已经熟悉 Qt
  • ✅ 需要跨平台(包括移动端)

2.16 GPUI(Zed Industries,2024)

一句话定位:Zed 编辑器的 UI 框架,Rust 生态的高性能 GUI 方案。

技术栈

  • 语言:Rust
  • 渲染:GPU 加速,自绘渲染
  • 架构:ECS(Entity-Component-System)风格

核心特点

  • 性能极致——为 Zed 编辑器设计,追求每一帧的流畅
  • Rust 原生——类型安全,内存安全
  • 现代 API——异步优先,响应式

适合场景

  • Rust 团队做桌面应用
  • 对性能有极致追求
  • 愿意投入时间学习

不太适合

  • 不熟悉 Rust 的团队
  • 需要快速出成果
  • 需要成熟的组件库

代码示例

// GPUI 的 Rust 风格 UI
use gpui::*;

struct Counter {
    count: i32,
}

impl Render for Counter {
    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
        div()
            .flex()
            .flex_col()
            .items_center()
            .child(format!("Count: {}", self.count))
            .child(
                button("Increment")
                    .on_click(cx.listener(|this, _, _| this.count += 1))
            )
    }
}

入门步骤

  1. 安装 Rust:curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  2. 创建项目:cargo new my_app
  3. 添加 GPUI 依赖
  4. 编写 UI 代码
  5. 运行:cargo run

第三章:横向对比

3.1 核心信息对照表

框架 渲染方式 语言 平台覆盖 生态成熟度 一句话定位
Flutter 自绘 Dart 移动+桌面+Web 5/5 成熟 全能选手,跨端一致性最强
React Native 原生映射 JS/TS 移动为主 5/5 成熟 前端团队的原生应用方案
NativeScript 原生映射 JS/TS+Vue/Angular 移动 3/5 成长中 Vue/Angular 写原生应用
Electron WebView JS/TS 桌面 5/5 成熟 Web 做桌面的事实标准
Qt Quick 自绘 C++/QML 全平台+嵌入式 5/5 成熟 工业级、嵌入式首选
.NET MAUI 原生映射 C# 全平台 4/5 稳定 C# 团队的官方方案
Uno Platform 原生/WASM C# 全平台+Web 4/5 稳定 C# + WebAssembly
Tauri 系统WebView+Rust Rust+Web 桌面+移动 4/5 稳定 轻量级 Electron 替代
Wails 系统WebView+Go Go+Web 桌面 3/5 成长中 Go 技术栈做桌面
KMP 原生/Compose Kotlin 移动+桌面 4/5 稳定 Android 团队扩 iOS
Lynx 自绘 JS/TS 移动+Web 3/5 成长中 高性能+Web语法
Valdi 编译到原生 TypeScript 移动 2/5 早期 TS 编译到原生
Electrobun 系统WebView/CEF TypeScript 桌面 2/5 早期 轻量桌面方案
Dioxus 自绘/多后端 Rust 全平台+TUI 3/5 成长中 Rust 版 React
Slint 自绘 Rust/C++/JS 桌面+嵌入式 3/5 成长中 轻量嵌入式 GUI
GPUI 自绘 Rust 桌面 2/5 早期 Rust 高性能 GUI

3.2 指标矩阵

说明:以下评价基于渲染原理和生态现状的一般判断,实际表现取决于具体实现。评分采用 1-5 分制,5 分最高。

框架 包体/启动 性能上限 原生体验 跨端一致 开发效率 生产风险
Flutter 3 中等 5 极强 3 一般 5 极强 5 极高
React Native 3 中等 4 强 5 极强 3 一般 5 极高
NativeScript 3 中等 4 强 5 极强 3 一般 4 高
Electron 2 大/慢 3 中等 3 一般 5 极强 5 极高
Qt Quick 3 中等 5 极强 3 一般 5 极强 3 中等
.NET MAUI 3 中等 4 强 5 极强 3 一般 4 高
Uno Platform 3 中等 4 强 4 强 4 强 4 高
Tauri 5 小/快 4 强 3 一般 4 强 4 高
Wails 4 小/快 4 强 3 一般 4 强 5 极高
KMP 视UI方案 视UI方案 5 极强 3 一般 4 高
Lynx 4 小/快 5 极强 3 一般 5 极强 4 高
Valdi 4 小/快 5 极强 5 极强 3 一般 3 中等
Electrobun 4 小/快 3 中等 3 一般 4 强 4 高
Dioxus 4 小/快 5 极强 3 一般 4 强 4 高
Slint 5 小/快 4 强 3 一般 5 极强 3 中等
GPUI 4 小/快 5 极强 3 一般 3 一般 3 中等

指标说明

  • 包体/启动:应用包大小和启动速度(5=最小最快,1=最大最慢)
  • 性能上限:复杂动效、大数据量场景的表现潜力(5=极限性能,1=性能受限)
  • 原生体验:与系统控件的融合程度(5=完全原生,1=明显非原生)
  • 跨端一致:不同平台上 UI 的统一程度(5=完全一致,1=差异大)
  • 开发效率:上手速度、调试体验、工具链成熟度(5=极高,1=很低)
  • 生产风险:生态稳定性、长期维护的不确定性(低/中/高)

第四章:场景化选型指南

快速决策表

不想看详细分析?根据你的情况直接查表:

按技术栈选择

你的技术栈 首选 备选 理由
React React Native Lynx 复用 React 技能
Vue/Angular NativeScript Flutter 直接用 Vue/Angular
Go Wails - 唯一的 Go 桌面方案
Rust(有前端) Tauri Dioxus 前后端分离
Rust(纯 Rust) Dioxus GPUI React-like 语法
C# .NET MAUI Uno Platform 微软生态
C++ Qt Slint 工业级/嵌入式
Kotlin KMP Flutter Android 团队扩展

按需求选择

你的需求 推荐框架 原因
极致跨端一致性 Flutter, Qt 自绘渲染
极小包体(< 5MB) Tauri, Slint 系统 WebView/轻量
原生体验优先 React Native, .NET MAUI 原生控件
嵌入式设备 Slint, Qt 资源占用低
需要 WebAssembly Uno Platform, Dioxus 浏览器运行
需要终端 UI(TUI) Dioxus 独特优势
快速原型 Electron, Flutter 工具链成熟

场景 A:移动端为主,重动效、品牌视觉统一

典型产品:电商首页、社交 feed、游戏化应用

推荐:Flutter / Lynx

理由

  • 自绘渲染保证跨端一致性
  • 动效性能有保障
  • Flutter 生态成熟,Lynx 性能更极致(但风险更高)

备选:Qt Quick(如果团队熟悉 C++)


场景 B:桌面应用(按技术栈)

团队技术栈 首选方案 优势 典型产品
纯前端团队 Electron 生态最成熟,工具链完善 VS Code, Slack
前端 + 在意包体 Tauri 包体小(3MB),启动快 系统工具
Go 后端团队 Wails 无需学 Rust,类型安全 运维面板,数据处理
Rust 团队 Tauri 安全性高,插件生态好 开发工具
想尝新 Electrobun Bun 运行时,TS 全栈 原型项目

快速决策

  • 求稳定 → Electron
  • 要轻量 → Tauri
  • 用 Go → Wails
  • 学 Rust → Tauri

场景 C:企业内部应用,C# 团队,长期维护

典型产品:ERP、CRM、内部审批系统

推荐:.NET MAUI

理由

  • 与微软生态(Azure、Office 365)集成好
  • C# 企业级开发经验可复用
  • 长期维护有保障(微软背书)

备选:Qt(如果需要嵌入式支持)/ Electron(如果有 Web 版需求)


场景 D:Android 团队扩展 iOS

典型产品:已有 Android 应用,想扩展到 iOS

推荐:KMP(逻辑共享 + 原生 UI)

理由

  • Kotlin 语言统一,学习成本低
  • 可以渐进式迁移,风险可控
  • 各平台 UI 保持原生体验

进阶:如果想共享部分 UI → KMP + Compose Multiplatform


场景 E:需要深度系统集成

典型产品:文件管理器、系统工具、相机应用

推荐:React Native / KMP(原生 UI)

理由

  • 原生控件映射,系统 API 调用方便
  • 无障碍支持天然继承
  • 可以针对各平台做深度优化

备选:.NET MAUI、纯原生


场景 F:极度关注包体大小

典型产品:Lite 版应用、下沉市场、低端设备

推荐:Tauri(桌面)/ Valdi(移动)

理由

  • Tauri 空项目约 3MB
  • Valdi 编译到原生,无运行时开销

备选:KMP + 原生 UI


场景 G:全平台覆盖(移动 + 桌面 + Web)

典型产品:跨平台协作工具、内容消费应用

推荐:Flutter

理由

  • 唯一真正"一套代码,全平台运行"的成熟方案
  • 移动、桌面、Web 体验一致

备选:Qt(工业场景)、各平台分别开发


场景 H:Rust 技术栈做桌面应用

典型产品:开发工具、性能敏感型应用

推荐

  • 需要 Web 前端:Tauri
  • 需要 Web + 桌面共享代码:Dioxus
  • 纯 Rust,极致性能:GPUI

理由

  • Tauri:前后端分离,前端用熟悉的 Web 技术栈
  • Dioxus:React-like 语法,前端转 Rust 易上手,支持 WASM
  • GPUI:为代码编辑器设计,性能极致但学习曲线陡

选择建议

  • 团队有前端,后端用 Rust → Tauri
  • 想要纯 Rust 技术栈,喜欢 React → Dioxus
  • 追求极致性能(如编辑器)→ GPUI

场景 I:嵌入式设备 GUI

典型产品:智能家居面板、车载 HMI、工业控制、医疗设备

推荐:Slint(首选)/ Qt(工业级)

理由

  • Slint:轻量(< 300KB),支持软件渲染,适合低端 MCU
  • Qt:功能强大,工业案例丰富,但包体大、需商业授权

选择建议

  • 资源极度受限(RAM < 50MB)→ Slint
  • 需要丰富组件库,工业级项目 → Qt
  • 原型验证、Rust 技术栈 → Slint

场景 J:Vue/Angular 团队做移动应用

典型产品:企业内部应用、内容展示应用

推荐:NativeScript

理由

  • 直接用 Vue 或 Angular 写原生应用
  • 无需学 React(如果用 React Native 需要学 React)
  • 直接访问原生 API,无桥接层

备选:Flutter(如果愿意学 Dart)


场景 K:C# 团队,需要 WebAssembly

典型产品:需要 Web 版的企业应用、渐进式 Web 应用

推荐:Uno Platform

理由

  • 同时支持原生平台和 WebAssembly
  • 一套代码可以跑在浏览器里
  • WinUI 语法,Windows 应用迁移方便

备选:Blazor WebAssembly(纯 Web)+ .NET MAUI(原生)


第五章:选型方法论

5.1 三步选型法

Step 1: 确定渲染路线
    │
    ├── 需要跨端视觉完全一致 → 自绘渲染(Flutter/Lynx/Qt)
    ├── 需要原生体验优先 → 原生映射(RN/MAUI/KMP)
    └── 需要快速上线、前端技术栈 → WebView(Electron/Tauri)

Step 2: 确定平台覆盖
    │
    ├── 移动端为主 → Flutter/RN/Lynx/KMP
    ├── 桌面端为主 → Electron/Tauri/Qt/GPUI
    └── 全平台 → Flutter/Qt

Step 3: 匹配团队技能
    │
    ├── Dart → Flutter
    ├── JS/TS + React → React Native / Lynx / Dioxus(想学 Rust)
    ├── JS/TS + Vue/Angular → NativeScript
    ├── JS/TS + 任意框架 → Electron / Tauri / Wails / Electrobun
    ├── C# → .NET MAUI / Uno Platform(需要 WASM)
    ├── C++ → Qt / Slint(嵌入式)
    ├── Go → Wails
    ├── Kotlin → KMP
    └── Rust → Tauri(Web前端) / Dioxus(全栈) / GPUI(纯Rust) / Slint(嵌入式)

5.2 决策检查清单

在最终决定前,问自己这些问题:

基础问题

  • 团队对目标语言的熟悉程度如何?(Dart/JS/TS/C#/C++/Go/Kotlin/Rust)
  • 是否有时间预算来学习新技术?
  • 对包体大小和启动速度的要求有多高?
  • 是否需要与系统功能深度集成?
  • 是否需要跨端 UI 完全一致?
  • 项目周期是多长?是否允许使用新兴框架?
  • 团队规模如何?是否需要大量第三方库支持?
  • 未来是否需要扩展到更多平台?

新增考虑点(针对新框架):

  • 是否是 Go 技术栈?考虑 Wails
  • 是否需要 WebAssembly 支持?考虑 Uno Platform / Dioxus
  • 是否是 Vue/Angular 技术栈?考虑 NativeScript
  • 是否是嵌入式设备(RAM < 50MB)?考虑 Slint
  • 是否想用 Rust 写全栈(包括 UI)?考虑 Dioxus
  • 是否需要终端 UI(TUI)?考虑 Dioxus
  • 是否追求极致性能(如代码编辑器)?考虑 GPUI

第六章:趋势观察

6.1 当前格局(2026 更新)

成熟稳定层(生产环境可放心使用):

  • 移动端:Flutter、React Native
  • 桌面端:Electron、Qt
  • C# 生态:.NET MAUI、Uno Platform
  • 逻辑共享:KMP

快速上升层(已有成功案例,值得认真考虑):

  • 轻量桌面:Tauri、Wails
  • 新兴移动:Lynx(字节跳动背书)
  • Rust 全栈:Dioxus(社区活跃)

新锐探索层(有潜力,需承担早期风险):

  • 桌面端:Electrobun、GPUI
  • 移动端:Valdi
  • 嵌入式:Slint

6.2 趋势预判

  1. 自绘渲染持续演进

    • Flutter 的 Impeller 引擎带来更好的 iOS 性能
    • Dioxus、Slint 等新框架证明自绘渲染仍有创新空间
    • GPU 加速成为标配
  2. Rust 生态全面爆发(重要趋势)

    • 桌面端:Tauri(轻量)、Dioxus(全栈)、GPUI(性能)、Slint(嵌入式)
    • Rust 已经形成完整的 GUI 生态矩阵
    • WebAssembly + Rust 成为 Web 高性能方案
    • 预测:2026-2027 会有更多 Rust GUI 框架成熟
  3. WebView 方案的"语言多样化"

    • 传统:Electron(Node.js)
    • 新势力:Tauri(Rust)、Wails(Go)、Electrobun(Bun)
    • 趋势:每个后端语言都会有自己的 WebView 方案
    • Go、Rust、Bun 的学习曲线比 Node.js 低(或类型更安全)
  4. 逻辑共享成为共识

    • 即使 UI 不共享,业务逻辑共享也成为趋势
    • KMP 模式证明了渐进式迁移的可行性
    • Dioxus 的多渲染后端也是类似思路
  5. WebAssembly 的崛起

    • Uno Platform 证明了 C# + WASM 的可行性
    • Dioxus 的 WASM 性能接近原生
    • 预测:更多框架会支持 WASM 作为部署目标
  6. 类型安全成为标配

    • Wails 的自动生成 TypeScript 类型
    • Dioxus 的 Rust 类型安全
    • Slint 的多语言类型绑定
    • 趋势:前后端通信的类型不匹配会成为历史
  7. 前端框架语法的多样化

    • 不再是"React 一家独大"
    • NativeScript 支持 Vue/Angular
    • Dioxus 带来 Rust + React-like 语法
    • 趋势:每个前端生态都能找到对应的跨平台方案
  8. 嵌入式 GUI 的轻量化

    • Slint 证明了 Qt 不是嵌入式唯一选择
    • 软件渲染 + 极致优化可以跑在 MCU 上
    • 趋势:智能家居、车载等场景会有更多轻量方案

总结

选框架不是选"最好的",而是选"最适合的"。

如果你只记住一件事,那就是:

先想清楚你的核心诉求是什么——跨端一致性、原生体验、还是开发效率?然后在对应的技术路线里,选一个匹配团队技能的框架。

2026 关键变化总结

变化 具体表现 影响
1. 桌面方案多元化 Electron/Tauri/Wails/Electrobun 每个后端语言都有选择
2. Rust GUI 成熟 Tauri/Dioxus/Slint/GPUI 覆盖全场景
3. 前端多样化 React/Vue/Angular 都有方案 不再是 React 独大
4. WASM 普及 Uno/Dioxus 支持 浏览器运行原生性能
5. 嵌入式轻量化 Slint 挑战 Qt 低端设备新选择

选型建议(按风险偏好)

稳妥派(生产环境)
├─ 移动端:Flutter, React Native
├─ 桌面端:Electron, Qt
└─ C# 生态:.NET MAUI, Uno Platform

平衡派(值得尝试)
├─ Go 桌面:Wails
├─ Rust 全栈:Dioxus
└─ 逻辑共享:KMP

激进派(原型/小项目)
├─ Lynx, Valdi(移动端新思路)
├─ Electrobun(桌面 Bun 方案)
└─ Slint(嵌入式轻量)

祝选型顺利!


参考资源

官方文档

成熟框架

新兴框架

延伸阅读

对比文章

生产实践案例

技术深度解析

字符串拼接?使用 knitwork-x,更轻量的 TS 代码生成方案

作者 Mr_Mao
2026年2月2日 20:10

在做 genapi(OpenAPI 转 TS 请求代码工具)时,我就一直有一个很纠结的问题。

传统方案要么太重,要么太乱:

  • TS 官方 AST / ts-morph:功能天花板,但体积巨大、构建慢,在轻量工具里引入它像大炮打蚊子。
  • 手拼字符串code += 'export class ' + name + ' {'…… 这种代码写多了,引号和换行能让人怀疑人生。

后来发现 UnJS 的 knitwork设计思路挺不错的,用一组轻量 API 把「结构」转成代码字符串,不碰 AST,也不管类型检查。但原库偏简陋,类、接口、类型、循环、try-catch 等都不够用,装饰器、复杂泛型也基本没覆盖,也没法直接拿来生成 genapi 要的那类 TS。
所以就 fork 了一版 knitwork-xGitHub),在保持「轻量、只拼字符串」的前提下,补了一整套 TS 代码生成能力:import/export、类、接口、函数、类型、循环条件 try-catch 等,用 API 拼代码,不用自己操心引号和换行。

简单来说,knitwork-x 就是为了填补「手拼」与「AST」之间的空白。

懒得往下看的小伙伴可以到文档里查看 APIs 的用例,我做了方法比对的案例,每个都很直观。


knitwork-x 是啥?

一句话:按数据结构调用 API,得到合法的 JS/TS 代码字符串。 你要的是一段「可被解析的代码文本」,而不是执行它。典型场景:根据配置生成 import/export、根据 schema 生成 interface/type/class、动态生成 try/catch、for、switch 等。

三种方案对比如下:

方案 易用性 体积/性能 复杂度控制
手拼字符串 极简 极快 极差(容易写错)
knitwork-x 简单 快/轻量 良好(结构化)
ts-morph 复杂 笨重 极强(支持校验)

knitwork-x 在延续 knitwork 思路的前提下,把「能拼什么」补全了:一组小而专的 genXxx,只负责把「结构」转成「代码字符串」。

先看一个最简单的例子:生成一句 ESM 的 default export。

import { genDefaultExport } from 'knitwork-x'

genDefaultExport('foo')
// => `export default foo;`

genDefaultExport('42', { singleQuotes: true })
// => `export default 42;`

你不用管分号、引号,API 给你的就是可直接拼进文件里的字符串。


能生成哪些东西?

按能力分几块,每块看一个小例子。

ESM:import / export

import/export 这种高频需求,用 API 一拼就有:

import { genImport, genExport, genDynamicImport } from 'knitwork-x'

// 默认导入、具名导入、重命名
genImport('pkg', 'foo')
// => `import foo from "pkg";`
genImport('pkg', ['a', 'b'])
// => `import { a, b } from "pkg";`
genImport('pkg', [{ name: 'foo', as: 'bar' }])
// => `import { foo as bar } from "pkg";`

// type-only
genImport('@nuxt/utils', ['test'], { type: true })
// => `import type { test } from "@nuxt/utils";`

// 动态 import,可包一层箭头函数、interopDefault
genDynamicImport('pkg', { wrapper: true })
// => `() => import("pkg")`
genDynamicImport('pkg', { type: true, name: 'foo' })
// => `typeof import("pkg").foo`

genExport('pkg', ['a', 'b'])
// => `export { a, b } from "pkg";`

插件里根据用户配置拼「要 import 的模块列表」时,用这些就能少写很多字符串拼接。

字符串与字面量

最烦人的转义逻辑,现在交给 API 就行:

import { genString, escapeString, genTemplateLiteral, genKey } from 'knitwork-x'

genString('foo\nbar')
// => `"foo\nbar"`
genString('foo', { singleQuotes: true })
// => `'foo'`

escapeString("foo'bar")
// => `foo\'bar`

genTemplateLiteral(['hello ', 'x'])
// => `\`hello ${x}\``

genKey('foo-bar')
// => `"foo-bar"`
genKey('with space')
// => `"with space"`

这样生成出来的代码里,引号和换行不会被搞乱。

类、接口、函数

类、接口、函数声明,不用再自己拼 class + name:

import {
  genClass,
  genConstructor,
  genInterface,
  genFunction,
  genArrowFunction,
} from 'knitwork-x'

genClass('Foo')
// => `class Foo {}`

genClass('Bar', [genConstructor([], ['super();'])])
// => `class Bar { constructor() { super(); } }`

genClass('Baz', [], { extends: 'Base', implements: ['I1', 'I2'] })
// => `class Baz extends Base implements I1, I2 {}`

genInterface('User', { name: 'string', id: 'number' })
// => `interface User { name: string, id: number }`

genFunction({
  name: 'id',
  generics: [{ name: 'T' }],
  parameters: [{ name: 'x', type: 'T' }],
  returnType: 'T',
  body: ['return x;'],
})
// => `function id<T>(x: T): T { return x; }`

genArrowFunction({ parameters: [{ name: 'x', type: 'number' }], body: 'x * 2' })
// => `(x: number) => x * 2`

适合根据配置或 schema 生成「一段 TS 声明文件」或「运行时用的类/函数代码」。

类型:type、union、conditional、mapped

类型别名、联合、条件类型、mapped type,同样不用手拼:

import {
  genTypeAlias,
  genUnion,
  genConditionalType,
  genMappedType,
  genKeyOf,
} from 'knitwork-x'

genTypeAlias('Id', 'T', { generics: [{ name: 'T' }] })
// => `type Id<T> = T`

genUnion(['string', 'number'])
// => `string | number`

genConditionalType('T', 'null', 'never', 'T')
// => `T extends null ? never : T`

genMappedType('K', 'keyof T', 'U')
// => `{ [K in keyof T]: U }`

做代码生成时,类型部分不用再自己拼字符串。

控制流:if / for / switch / try-catch

if/else、for、switch、try-catch 等语句也有对应 genXxx,传条件、循环变量、body 即可:

import { genIf, genForOf, genSwitch, genCase, genDefault, genTry, genCatch } from 'knitwork-x'

genIf('x > 0', 'return x;')
// => `if (x > 0) { return x; }`

genForOf('const item', 'list', 'yield item;', { bracket: false })
// => `for (const item of list) yield item;`

genSwitch('x', [genCase('1', 'break;'), genDefault('return 0;')])
// => switch(x) { case 1: break; default: return 0; } 的格式化字符串

genTry(['const x = await f();', 'return x;'])
// => `try { const x = await f(); return x; }`
genCatch(['throw e;'], { binding: 'e' })
// => `catch (e) { throw e; }`

需要根据配置生成「一坨语句」时,用这些可以保持缩进和括号一致。


拼一整段代码:小例子

把上面几块串起来,输入模块名和类名,直接得到可写入文件的 TS:

import {
  genImport,
  genClass,
  genConstructor,
  genMethod,
} from 'knitwork-x'

function generateModule(modulePath: string, className: string) {
  const lines = [
    genImport(modulePath, ['Base']),
    '',
    genClass(
      className,
      [
        genConstructor([], ['super();']),
        genMethod({
          name: 'run',
          parameters: [],
          returnType: 'void',
          body: ['console.log("ok");'],
        }),
      ],
      { extends: 'Base' }
    ),
  ]
  return lines.join('\n')
}

console.log(generateModule('./base', 'MyService'))

输出就是一段完整的 TS 文本,可以直接写进 .ts 文件或再交给别的工具处理。
这里没有手写任何 "class " + className" extends " + base,可读性和可维护性都会好很多。


总结一下

  • knitwork-x 提供一整套 genXxx,把「想生成的代码结构」用 API 描述出来,得到合法的 JS/TS 代码字符串。
  • 覆盖 ESM、字符串、类/接口/函数、类型、控制流等,适合脚手架、插件、构建时代码生成;genapi 里把 OpenAPI 转成 TS/JS 请求代码的流水线,底层就是用它拼出来的。
  • 用 API 拼代码,比手拼字符串安全,比上 AST 轻量。

如果你需要做生成 TS 代码这类事,可以到 文档GitHub 看完整 API 和更多示例。有更好的方法,也欢迎在评论区分享一下 🐛🐛🐛。

拒绝造轮子!Quill 实现自定义标签功能的踩坑实录 🛠️

作者 橙某人
2026年2月2日 19:01

写在开头

Hello,各位UU们好呀!😋

今是 2026年2月1日 下午,距离上一次写文已经过去一个月了,时间飞快。

昨天,公司开了年会,可惜没有中大奖,还好有阳光普照奖,也不差哈。😃

然后,上上几个周末,小编全去爬山了,广州的"火凤线"、"白江湖"与"龙凤线"全去了一遍,主打一个运动🏃健康,其中,"龙凤线" 稍微费劲一些,不过区区二十公里,随便"拿下",就是腿略抖,但问题不大。😋

10223a002b83fcbc6e226b5162802650.jpg

回到正题,本次要分享的是如何在 Quill 富文本编辑器中实现一个 "自定义标签" 功能,支持插入不可编辑的标签块,并能以特定格式输出数据,效果如下,请诸君按需食用哈。

26020201.gif

需求背景 📝

最近,做业务中遇到个需要在输入框中插入一些动态变量(比如"用户姓名"、"订单号"等)的需求。这些变量在输入框里需要长得像一个"标签"一样,作为一个整体存在,不能被用户修改里面的文字,只能整体插入与删除。

该需求属于典型富文本场景,原生 input/textarea 基本无法实现。

小编最初尝试基于 contentEditable 手写组件,不料却深陷光标位置、删除逻辑、异常边界等细节问题折磨,后续测试同学又反馈大量交互 bug,最终果断弃用手写方案,改用 Quill 重构。

其实,如果再给一些时间,小编能写好的哇😭,特别是在AI的协助下,可惜时间紧,任务重,这个完成了90%以上的组件至今小编还没舍得删掉,虽然不用了。

不过,对于同类富文本定制需求,小编还是不建议从零手写。直接使用 Quill、CodeMirror 等成熟开源库,底层细节与性能均经过充分验证,省心高效,能大幅减少无效调试与 bug 修复成本。

本次开发涉及 Quill 自定义 Blot、数据格式双向转换等核心逻辑,结合踩坑经验做一次简单分享。🏃

实现过程 🛠️

第1️⃣步:自定义 Blot (mark.js)

小编认为 Quill 的强大之处就在于它的扩展性。要实现一个 "标签",咱们可以继承一下 blots/embed Blot。这个 Blot 负责定义标签长什么样,以及它的一些行为。

对于 Blot 的概念:传送门

我们创建一个 mark.js

import Quill from "quill";
const BlockEmbed = Quill.import("blots/embed");

export default class MarkBlot extends BlockEmbed {
  static blotName = "mark-blot"; // Blot 的名称
  static tagName = "span";       // 对应的 DOM 标签
  static className = "mark-blot-class"; // 对应的类名

  static create(value) {
    const node = super.create();
    const id = typeof value === "object" ? value.id : value;
    const label = typeof value === "object" ? value.label : value;

    // 存储 ID,用于后续获取数据
    node.setAttribute("data-value", id);
    // 关键!设置为不可编辑
    node.contentEditable = "false";

    // 1. 标签文本
    const textSpan = document.createElement("span");
    textSpan.className = "mark-blot-text";
    textSpan.innerText = label;
    node.appendChild(textSpan);

    // 2. 删除按钮 (小叉叉)
    const closeBtn = document.createElement("span");
    closeBtn.className = "close-btn";
    closeBtn.innerHTML = `...SVG代码...`; // 这里省略 SVG 代码哈,小编的图标是使用svg形式,你可以根据你自己的需要调整

    // 点击删除按钮时,通知外部删除
    closeBtn.addEventListener("click", (e) => {
      e.stopPropagation();
      // 派发自定义事件,方便 Vue 组件监听
      const event = new CustomEvent("mark-remove", {
        detail: { markId: id },
        bubbles: true,
        composed: true,
      });
      node.dispatchEvent(event);
      
      // 直接在 DOM 上移除自己
      const blot = Quill.find(node);
      if (blot) blot.remove();
    });

    node.appendChild(closeBtn);
    return node;
  }

  static value(node) {
    // 获取 Blot 的值,用于 getContents()
    const id = node.getAttribute("data-value");
    const textSpan = node.querySelector("span");
    const label = textSpan ? textSpan.innerText : id;
    return { id, label };
  }
}

这里有个小细节,我们在 create 方法里给节点加了 contentEditable="false",这样用户就没法光标点进去修改文字,只能把它当做一个整体来处理。

第2️⃣步:Vue 组件封装 (index.vue)

接下来就是重头戏了,咱们需要在 Vue 组件里初始化 Quill,并处理数据转换。

初始化与注册

import MarkBlot from "./customBlot/mark.js";

// 注册自定义 Blot
if (!Quill.imports["formats/mark-blot"]) {
  Quill.register(MarkBlot);
}

// 初始化 Quill
quillInstance = new Quill(editorRef.value, {
  modules: { toolbar: false }, // 咱们这里不需要默认的工具栏
  placeholder: props.placeholder,
});

数据转换

数据转换是核心环节🎯,前端需要处理两种格式的互转:

  • Blot ↔ 字符串:编辑器内部使用 Blot 显示标签,但保存时需要转换为 {{#id#}} 格式的字符串
  • 字符串 → 实际值:提交给后端前,需要将 {{#id#}} 替换为实际内容

标签模板:{{#id#}} 这玩意你可以自己定义,适配好正则匹配规则就行。

1. 字符串转 Blot(回显逻辑)

当获取到包含 {{#id#}} 格式的数据时,需要将其转换为 Blot 显示在富文本中:

/**
 * @name 转换文本,将特定内容转为blot
 * @param {string} text 原始文本
 * @return {Object[]} delta 操作数组
 */
function textToDelta(text) {
  const delta = [];
  const { start, end } = getDelimiters(); // 解析标签模板,如 {{##}} -> {start: "{{#", end: "#}}"}
  const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const regex = new RegExp(`${escapeRegExp(start)}(.*?)${escapeRegExp(end)}`, "g");
  let lastIndex = 0;
  let match;

  while ((match = regex.exec(text)) !== null) {
    // 1. 添加匹配前的普通文本
    if (match.index > lastIndex) {
      delta.push({ insert: text.substring(lastIndex, match.index) });
    }
    // 2. 添加标签 Blot
    const id = match[1]; // 提取 id
    const label = props.getMarkLabel(id); // 通过 id 获取显示文本,就是进行id映射,如 {1: 小明, 2: 小红, 3: 小黑}
    delta.push({ insert: { "mark-blot": { id, label } } });
    lastIndex = regex.lastIndex;
  }
  // 3. 添加剩余文本
  if (lastIndex < text.length) {
    delta.push({ insert: text.substring(lastIndex) });
  }
  // 确保至少有一个 insert,否则 Quill 可能报错
  if (delta.length === 0) {
    delta.push({ insert: "" });
  }
  return delta;
}

/**
 * @name 解析标签模版
 * @return {{start: string, end: string}}
 */
function getDelimiters() {
  const start = props.specialBracket.slice(0, props.specialBracket.length / 2);
  const end = props.specialBracket.slice(props.specialBracket.length / 2);
  // props.specialBracket 等于 {{##}} ,单独定义,方便维护
  return { start, end };
}

2. Blot 转字符串(保存逻辑)

用户编辑完成后,需要将 Blot 转换回 {{#id#}} 格式的字符串:

/**
 * @name 统一获取富文本内容
 */
function getContent() {
  if (!quillInstance) return "";
  const delta = quillInstance.getContents();
  let text = "";
  const { start, end } = getDelimiters();
  
  // 遍历 delta 操作,构建文本内容
  if (delta && delta.ops) {
    delta.ops.forEach((op) => {
      if (typeof op.insert === "string") {
        text += op.insert;
      } else if (op.insert && op.insert["mark-blot"]) {
        // 遇到 mark-blot,拼接成 {{#id#}}
        const val = op.insert["mark-blot"];
        const id = typeof val === "object" ? val.id : val;
        text += `${start}${id}${end}`;
      }
    });
  }
  // Quill 总是会在末尾添加一个 \n,通常我们需要移除它以获取实际输入内容
  return text.replace(/\n$/, "");
}

3. 提交时的变量替换

{{#id#}} 是前端规定的特定格式,用于在编辑器中标识动态变量。但在提交给后端时,需要将这些占位符替换为实际值。

在小编的业务场景中,是单独写了一个 replaceVariableTags 函数来处理这个事情:

/**
 * 替换对象中字符串属性的 {{#xxx#}} 标签为指定值(包裹【】)
 * @param {Object} targetObj - 待处理的目标对象(支持多层嵌套)
 * @param {Array} replaceList - 替换映射数组(结构:[{id: 'xxx', ...}, ...])
 * @param {Object} [options] - 可选配置项
 * @param {string} [options.idKey='id'] - replaceList 中 id 对应的字段名(支持嵌套路径,如 'data.id')
 * @param {string} [options.valueKey='properties.taskResult'] - replaceList 中值对应的字段名(支持嵌套路径)
 * @returns {Object} 处理后的新对象(原对象不变)
 */
export function replaceVariableTags(targetObj, replaceList, options = {}) {
  // 默认配置:id字段为根层级id,值字段为properties.taskResult
  const { idKey = "id", valueKey = "content" } = options;

  // 工具函数:根据嵌套路径获取对象属性值
  const getNestedValue = (obj, path) => {
    if (typeof obj !== "object" || obj === null) return undefined;
    return path.split(".").reduce((current, key) => {
      return current?.[key]; // 路径不存在时返回undefined
    }, obj);
  };

  // 1. 构建 id -> 值 的映射表(支持嵌套字段解析)
  const replaceMap = new Map();
  replaceList.forEach((item) => {
    const id = getNestedValue(item, idKey);
    const value = getNestedValue(item, valueKey);
    if (id && value) {
      // 过滤id/值为空的无效项
      replaceMap.set(id, value);
    }
  });

  // 2. 正则:匹配 {{#xxx#}} 标签,捕获中间的 nodeId
  const tagRegex = /{{#([^#]+)#}}/g;

  // 3. 递归处理字符串/对象的核心函数
  const processValue = (value) => {
    // 字符串类型:替换标签
    if (typeof value === "string") {
      return value.replace(tagRegex, (match, nodeId) => {
        const replaceValue = replaceMap.get(nodeId);
        return replaceValue ? `${replaceValue}` : match; // 未匹配则保留原标签
      });
    }

    // 对象/数组类型:递归处理
    if (typeof value === "object" && value !== null) {
      return Array.isArray(value)
        ? value.map((item) => processValue(item)) // 数组遍历
        : Object.fromEntries(
            // 普通对象:保留原属性,递归处理值
            Object.entries(value).map(([key, val]) => [key, processValue(val)]),
          );
    }

    // 非字符串/对象类型(数字、布尔等):直接返回原值
    return value;
  };

  // 4. 返回新对象(避免修改原对象)
  return processValue(targetObj);
}

这个函数会递归遍历对象,将所有字符串属性中的 {{#id#}} 替换为对应的实际值,然后后端收到的是已经转换好的完整数据。

💡 关键理解

  • {{#id#}} 是前端内部的格式约定,用于在编辑器中标识和显示标签。
  • 前端提交给后端时,会先将 {{#id#}} 替换为实际内容,后端收到的是完整的数据。
  • 这样设计的好处是:后端无需关心前端的显示逻辑,只需要处理业务数据即可。

第3️⃣步:细节优化

由于,富文本核心就是输出一个值的内容,所以小编通过 v-model 形式来使用组件的数据绑定。

但是,在实现 v-model 双向绑定时,最头疼的就是光标跳动问题。

每次值变化时都会重新 setContents,光标就会跑回开头,为此咱们需要做一些判断:

// 监听 modelValue 变化
watch(() => props.modelValue, (newValue) => {
  // 如果新值等于上一次emit的值,说明是回显,无需更新
  if (newValue === lastEmittedValue) return;
  lastEmittedValue = newValue;
  
  if (quillInstance && newValue !== getContent()) {
    // 1. 记录光标位置
    const selection = quillInstance.getSelection();
    
    // 2. 更新内容
    setFormattedContent(newValue);
    
    // 3. 恢复光标位置
    if (selection && isInputFocused.value) {
      const currentLength = quillInstance.getLength();
      const index = Math.min(selection.index, currentLength - 1);
      quillInstance.setSelection(Math.max(0, index), 0);
    }
  }
});

// 监听 Quill 内容变化
quillInstance.on("text-change", (_delta, _oldDelta, source) => {
  if (source === "user") {
    const content = getContent();
    inputUpdate(content); // emit update:modelValue
  }
});

还有一个细节,当用户手动输入 {{#id#}} 格式的文本时,需要实时检测并转换为 Blot。这可以在 text-change 事件中处理:

quillInstance.on("text-change", (_delta, _oldDelta, source) => {
  if (source === "user") {
    // 检查并替换 {{#...#}} 模式
    const text = quillInstance.getText();
    const { start, end } = getDelimiters();
    const regex = new RegExp(`${escapeRegExp(start)}(.*?)${escapeRegExp(end)}`, "g");
    const matches = [...text.matchAll(regex)];
    
    // 如果有匹配项,从后往前处理,避免索引变化影响前面的匹配
    if (matches.length > 0) {
      for (let i = matches.length - 1; i >= 0; i--) {
        const match = matches[i];
        const index = match.index;
        const fullMatch = match[0];
        const content = match[1];
        const label = props.getMarkLabel(content);
        
        // 替换文本为 Blot
        quillInstance.deleteText(index, fullMatch.length);
        quillInstance.insertEmbed(index, "mark-blot", { id: content, label });
        quillInstance.insertText(index + 1, "\u00A0"); // 插入不换行空格
      }
    }
    
    const content = getContent();
    inputUpdate(content);
  }
});

完整源码

传送门

总结 💡

其实,富文本编辑器开发中,小编认为最难的往往不是 API 的使用,而是如何处理数据格式的转换以及各种边界情况,最后希望这篇文章能给正在折腾 Quill 的小伙伴们一些灵感吧!





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

1. 《手写系列:面试官问我 new 的原理,我直接甩出三个版本》

作者 NEXT06
2026年2月2日 18:16

今天我们来聊聊 JavaScript 中那个既熟悉又神秘的 new 操作符。相信很多小伙伴在面试时都经历过这样的“名场面”:面试官微微一笑,推过来那个熟悉的键盘:“来,能不能手写一个 new 的实现?”

这时候,如果你只是背诵了代码,稍微问深一点可能就露怯了。今天,我们就把这个“黑盒”拆开,从底层原理到完美实现,彻底搞懂它!

一、核心原理拆解:new 到底干了啥?

我们在日常开发中,const person = new Person('Fog', 18) 写得飞起。但 new 背后到底发生了什么?

简单来说,new 就是一个**“生产车间”**。它拿着你的图纸(构造函数),给你造出一个实实在在的产品(实例对象)。

这个过程,标准流程只有四步(核心四步法):

  1. 建空房:创建一个全新的空对象 {}。
  2. 挂牌子:将这个空对象的原型链(proto)链接到构造函数的原型对象(prototype)上。(这步最关键,决定了你能用这一类的公共方法)。
  3. 搞装修:将构造函数内部的 this 指向这个新对象,并执行构造函数。(给对象添加属性,如 name, age)。
  4. 交钥匙:判断构造函数的返回值。如果构造函数自己返回了一个对象(或函数),那就以它为准;否则,默认返回我们在第一步创建的那个新对象。

image.png

二、面试官到底在考什么?

面试官让你手写 new,绝对不是为了看你默写代码。通过这寥寥几行代码,他在考察你以下四大内功:

  1. 原型链的理解:你知不知道实例和类是怎么关联起来的?
  2. this 指向机制:你懂不懂怎么用 call 或 apply 改变函数执行上下文?
  3. 函数参数处理:面对不定参数,你会用 arguments 还是 ...args?
  4. 边界情况处理:**这是高分点!**如果构造函数里写了 return,你的代码还能正常工作吗?

三、手写进阶之路

接下来,我们由浅入深,演示三个版本的实现。

V1.0 青铜版:ES5 经典写法

这是最基础的写法,也是很多老教材里的标准答案。我们需要处理 arguments 这个“伪数组”。

JavaScript

function Person(name, age) {
    this.name = name;
    this.age = age;
}
Person.prototype.sayName = function () {
    console.log(this.name);
}

// 核心实现
function objectFactory() {
    // 1. 创建一个空对象
    var obj = new Object();
    
    // 2. 获取构造函数
    // arguments 是类数组,没有 shift 方法,我们借用数组原型的 shift
    // 这行代码有两个作用:取出第一个参数(Constructor),同时 arguments 里剩下的就是参数了
    var Constructor = [].shift.call(arguments);
    
    // 3. 链接原型:让 obj 能访问 Person.prototype 上的属性
    obj.__proto__ = Constructor.prototype;
    
    // 4. 绑定 this 并执行
    // 使用 apply 将 remaining arguments 传进去
    var result = Constructor.apply(obj, arguments);
    
    // 5. 返回值处理
    // 这是一个常见的简易判断,但其实有漏洞(稍后在王者版揭晓)
    return typeof result === 'object' && result !== null ? result : obj;
}

// 测试
var awei = objectFactory(Person, '阿伟', 20);
console.log(awei.name); // 阿伟
awei.sayName(); // 阿伟

重点解析:

  • 为什么用 [].shift.call(arguments)?
    arguments 是一个类数组对象(有 length,有索引,但没数组方法)。通过 call,我们强行让它借用了数组的 shift 方法,切掉并拿到了第一个参数(构造函数),剩下的正好传给 apply。

V2.0 黄金版:ES6 现代化写法

时代变了,我们有了更优雅的语法糖。proto 虽然好用,但在生产环境中被视为非标准(尽管浏览器支持),性能也不如 Object.create。

image.png JavaScript

// 使用 ...args 剩余参数,告别 arguments
function objectFactory(Constructor, ...args) {
    // 1. & 2. 创建对象并直接链接原型
    // Object.create(proto) 创建一个新对象,带着指定的原型,性能更好,更符合规范
    const obj = Object.create(Constructor.prototype);
    
    // 3. 执行构造函数
    const result = Constructor.apply(obj, args);
    
    // 4. 返回值处理 (依然沿用旧逻辑)
    return typeof result === 'object' && result !== null ? result : obj;
}

重点解析:

  • Object.create 的优势:它直接创建一个已经连接好原型的对象,避免了创建后再修改 proto 指针带来的性能损耗(修改原型链在 V8 引擎中是非常昂贵的操作)。

V3.0 王者版:无懈可击的最终版

注意了!如果你能写出这个版本,面试官绝对会对你刮目相看。

在 V1 和 V2 中,我们对返回值的判断是 typeof result === 'object'。这有一个巨大的隐形漏洞
如果构造函数返回的是一个 function 呢?

在 JS 原生 new 中,如果构造函数返回函数,new 表达式的结果就是那个函数。但 typeof function 是 'function' 而不是 'object',之前的代码会错误地返回 obj 实例。

JavaScript

function objectFactory(Constructor, ...args) {
    // 0. 参数校验 (严谨性加分项)
    if (typeof Constructor !== 'function') {
        throw new TypeError('Constructor must be a function');
    }

    // 1. 创建对象,链接原型
    const obj = Object.create(Constructor.prototype);
    
    // 2. 绑定 this 执行
    const result = Constructor.apply(obj, args);
    
    // 3. 完美的返回值处理(关键修正!)
    // 如果 result 是对象(非null) 或者 是函数,则返回 result
    // 否则返回新创建的 obj
    const isObject = typeof result === 'object' && result !== null;
    const isFunction = typeof result === 'function';
    
    return (isObject || isFunction) ? result : obj;
}

// 验证特殊情况
function Factory() {
    return function() { console.log('I am a function'); };
}
const test = objectFactory(Factory);
console.log(typeof test); // "function" —— 逻辑正确!

四、总结

你看,所谓的“手写源码”,其实就是对基础知识的排列组合。

  1. 创建:Object.create
  2. 执行:Function.prototype.apply
  3. 判断:类型检测与逻辑运算

掌握了这三点,new 操作符对你来说就不再是黑盒。下次面试遇到,直接展示“王者版”,告诉面试官:我不止会写,我还知道为什么要这么写。

JavaScript事件循环(下) - requestAnimationFrame与Web Workers

作者 wuhen_n
2026年2月2日 18:03

如何实现丝滑流畅的 60fps 动画?如何在单线程 JavaScript 中实现真正的并行计算?本篇文章将探索事件循环的高阶应用。

前言:从60fps的动画说起

在 JavaScript 中,常见的动画实现方式有以下三种:

使用setInterval(不推荐)

function animateWithSetInterval() {
    setInterval(() => {
        updateAnimation();
        renderFrame();
    }, 16.67); 
}

上述代码试图达到60fps(1000/60 ≈ 16.67ms),但定时器不精确,可能丢帧或过度绘制。

递归setTimeout

function animateWithSetTimeout() {
    function loop() {
        updateAnimation();
        renderFrame();
        setTimeout(loop, 16.67);
    }
    loop();
}

这种方式比 setInterval 稍好,但仍可能和屏幕刷新不同步。

使用requestAnimationFrame(推荐)

function animateWithRAF() {
    function loop(timestamp) {
        updateAnimation(timestamp);
        renderFrame();
        requestAnimationFrame(loop);
    }
    requestAnimationFrame(loop);
}

优势:自动匹配屏幕刷新率,节省资源。

requestAnimationFrame:动画的黄金标准

什么是requestAnimationFrame?

requestAnimationFrame(简称 rAF) 是浏览器专门为动画和连续视觉更新提供的 API。它的核心特点是:

  • 在浏览器下一次重绘之前调用指定的回调函数,确保动画与屏幕刷新同步。

rAF的基本用法

function animate() {
    // 更新动画状态
    updateAnimation();
    
    // 渲染当前帧
    renderFrame();
    
    // 请求下一帧
    requestAnimationFrame(animate);
}

// 启动动画循环
requestAnimationFrame(animate);

rAF的优势

  1. 自动匹配显示器刷新率(通常是60Hz)
  2. 页面不可见时自动暂停,节省资源
  3. 浏览器可以优化动画性能
  4. 提供精确的时间戳参数

rAF的工作原理

function experimentRAF() {
    console.log('实验开始');
    
    // 记录帧数
    let frameCount = 0;
    let lastTimestamp = 0;
    
    function frameCallback(timestamp) {
        frameCount++;
        
        // 计算帧间隔
        if (lastTimestamp > 0) {
            const interval = timestamp - lastTimestamp;
            console.log(`第${frameCount}帧,间隔: ${interval.toFixed(2)}ms`);
        }
        
        lastTimestamp = timestamp;
        
        if (frameCount < 10) {
            requestAnimationFrame(frameCallback);
        } else {
            console.log('实验结束,平均帧率:', (1000 / ((timestamp - startTime) / 10)).toFixed(1), 'fps');
        }
    }
    
    const startTime = performance.now();
    requestAnimationFrame(frameCallback);
}

rAf 的关键点在于:frameCallback() 回调中的 timestamp 参数,这个 timestampperformance.now() 返回的高精度时间,也表示回调开始执行的时间。

rAF在事件循环中的位置

setTimeout(() => {
    console.log('1. setTimeout - 宏任务');
    
    Promise.resolve().then(() => {
        console.log('2. setTimeout中的微任务');
    });
}, 0);

Promise.resolve().then(() => {
    console.log('3. Promise - 微任务');
    
    requestAnimationFrame(() => {
        console.log('4. Promise中注册的rAF');
    });
});

requestAnimationFrame(() => {
    console.log('5. 直接注册的rAF');
    
    setTimeout(() => {
        console.log('6. rAF中注册的setTimeout');
    }, 0);
});

queueMicrotask(() => {
    console.log('7. queueMicrotask - 微任务');
});

console.log('8. 同步代码');

上述代码的输出顺序如下:

  • 8.同步代码
  • 3.Promise - 微任务
  • 7.queueMicrotask - 微任务
  • 1.setTimeout - 宏任务
  • 2.setTimeout中的微任务
  • 5.直接注册的rAF
  • 4.Promise中注册的rAF
  • 6.rAF中注册的setTimeout

其执行过程如下:

  1. 执行宏任务 (setTimeout, 事件回调等)
  2. 执行微任务 (Promise, queueMicrotask等)
  3. 执行rAF回调 (动画更新)
  4. 样式计算和布局
  5. 绘制 (Paint)
  6. 合成 (Composite)
  7. 检查空闲,执行 requestIdleCallback 回调

Web Workers:真正的多线程编程

什么是Web Workers?

Web Workers 允许 JavaScript 在后台线程中运行脚本,而不会阻塞主线程。这意味着我们可以执行CPU密集型任务,而不会影响页面的响应性。

// 主线程代码
console.log('主线程: 开始');

// 创建一个Worker
const worker = new Worker('worker.js');

// 向Worker发送消息
worker.postMessage({
    type: 'CALCULATE',
    data: { numbers: [1, 2, 3, 4, 5] }
});

// 接收Worker的消息
worker.onmessage = (event) => {
    const result = event.data;
    console.log('主线程: 收到Worker结果', result);
    
    // 更新UI
    document.getElementById('result').textContent = `结果: ${result}`;
};

// 处理Worker错误
worker.onerror = (error) => {
    console.error('Worker错误:', error);
};

console.log('主线程: 继续执行其他任务...');

Worker的限制:

  1. 无法访问DOM
  2. 无法使用window、document等
  3. 不能执行同步的XHR(可以使用fetch)
  4. 有同源策略限制
  5. 不能加载本地文件(file://协议)

Web Workers的类型

1. 专用Worker (Dedicated Worker)

只能被创建它的脚本使用:

const dedicatedWorker = new Worker('dedicated-worker.js');

2. 共享Worker (Shared Worker)

可以被多个脚本共享(同源):

if (window.SharedWorker) {
    const sharedWorker = new SharedWorker('shared-worker.js');
    
    // 通过port通信
    sharedWorker.port.onmessage = (event) => {
        console.log('收到共享Worker消息:', event.data);
    };
    
    sharedWorker.port.postMessage('Hello Shared Worker');
} else {
    console.log('浏览器不支持Shared Worker');
}

3. Service Worker

用于离线缓存、推送通知等:

if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('service-worker.js')
        .then(registration => {
            console.log('Service Worker注册成功:', registration);
        })
        .catch(error => {
            console.error('Service Worker注册失败:', error);
        });
}

4. Audio Worklet (Chrome 66+)

用于高性能音频处理:

if (window.audioContext && window.audioContext.audioWorklet) {
    audioContext.audioWorklet.addModule('audio-processor.js')
        .then(() => {
            console.log('Audio Worklet加载成功');
        });
}

5. Paint Worklet (CSS Houdini)

用于自定义CSS绘制:

if (CSS.paintWorklet) {
    CSS.paintWorklet.addModule('paint-worklet.js')
        .then(() => {
            console.log('Paint Worklet加载成功');
        });
}

requestIdleCallback:空闲期任务调度

什么是requestIdleCallback?

requestIdleCallback (简称 rIC )允许开发者在浏览器空闲时期调度任务。这对于执行低优先级或非紧急的工作非常有用,避免影响关键的用户交互和动画。

const idleCallbackId = requestIdleCallback((deadline) => {
    console.log('空闲回调开始执行');
    
    // deadline对象包含重要信息:
    console.log('剩余时间:', deadline.timeRemaining(), 'ms');
    console.log('是否超时:', deadline.didTimeout);
    
    // 在空闲时间内执行任务
    while (deadline.timeRemaining() > 0 && hasMoreWork()) {
        doSomeLowPriorityWork();
    }
    
    // 如果还有工作未完成,再次安排
    if (hasMoreWork()) {
        requestIdleCallback(processLowPriorityWork);
    }
    
    console.log('空闲回调结束');
}, { timeout: 1000 }); // 设置超时,确保在1秒内执行

// 主线程继续执行其他任务
console.log('主线程继续执行...');

rIC的关键特点:

  1. 只在浏览器空闲时执行
  2. 提供deadline对象,包含剩余时间信息
  3. 可以设置timeout确保执行
  4. 适合低优先级、可中断的任务

rIC在事件循环中的位置

// 理解rIC的执行时机
console.log('=== 事件循环中各API的执行时机 ===');

setTimeout(() => {
    console.log('1. setTimeout - 宏任务');
}, 0);

Promise.resolve().then(() => {
    console.log('2. Promise - 微任务');
});

requestAnimationFrame(() => {
    console.log('3. requestAnimationFrame - 动画帧回调');
    
    // 在rAF中安排rIC
    requestIdleCallback(() => {
        console.log('5. rAF中安排的rIC - 空闲回调');
    }, { timeout: 100 });
});

requestIdleCallback(() => {
    console.log('4. 直接安排的rIC - 空闲回调');
    
    // 在rIC中安排微任务
    Promise.resolve().then(() => {
        console.log('6. rIC中的Promise - 微任务');
    });
}, { timeout: 100 });

queueMicrotask(() => {
    console.log('7. queueMicrotask - 微任务');
});

console.log('8. 同步代码');

上述代码的输出顺序如下:

  • 8.同步代码
  • 2.Promise - 微任务
  • 7.queueMicrotask - 微任务
  • 1.setTimeout - 宏任务
  • 3.requestAnimationFrame - 动画帧回调
  • 4.直接安排的rIC - 空闲回调
  • 6.rIC中的Promise - 微任务
  • 5.rAF中安排的rIC - 空闲回调

其执行过程如下:

  1. 执行宏任务 (setTimeout, 事件回调等)
  2. 执行微任务 (Promise, queueMicrotask等)
  3. 执行rAF回调 (动画更新)
  4. 样式计算和布局
  5. 绘制 (Paint)
  6. 合成 (Composite)
  7. 检查空闲时间,如果有空闲,则执行rIC回调;否则等待下一帧。

核心概念总结

requestAnimationFrame (rAF):

  • 是什么:浏览器提供的动画API,在每次重绘前执行回调
  • 为什么用:自动匹配显示器刷新率,页面不可见时暂停,节省资源
  • 最佳时机:视觉更新、动画、连续状态变化
  • 执行位置:在微任务之后,重绘之前

Web Workers:

  • 是什么:允许JavaScript在后台线程运行的技术
  • 为什么用:执行CPU密集型任务而不阻塞主线程
  • 限制:无法访问DOM,通过消息传递通信
  • 类型:专用Worker、共享Worker、Service Worker等

requestIdleCallback (rIC):

  • 是什么:在浏览器空闲时调度任务的API
  • 为什么用:执行低优先级、非紧急任务
  • 关键对象:deadline包含剩余时间和超时信息
  • 执行位置:在一帧的最后,如果有空闲时间

结语

本文简单介绍了requestAnimationFrameWeb WorkersrequestIdleCallback 的基本用法和对比,对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

别再用 Web 思路搞 Node 服务打包了!这可能是你“地狱级”痛苦的根源

作者 donecoding
2026年2月2日 18:00

🚀 省流助手(速通结论):

  1. 思维脱钩:Node 服务不是 Web 组件。放弃“单文件 Bundle”执念,改走  “业务代码打包 + 生产环境安装依赖”  的工业化路径。
  2. 物理隔离:将所有 Node 原生依赖标记为 external,利用 Node 原生模块查找机制对抗打包工具的“环境互操作性”误判。
  3. 路径安全:在 ESM 环境下彻底告别 __dirname,坚持使用 import.meta.url 动态寻址,确保物理路径的绝对确定性。
  4. 运维友好:依赖外置不仅是为了避坑,更是为了满足生产环境的 SCA 安全扫描 与 应急热修复 主权。

一、 观念降维:为什么 Web 必须 Bundle,而 Node 不需要?

在前端 Web 开发中,Bundle Everything 是绝对真理。因为浏览器没有文件系统,必须通过极致合并来减少 HTTP 请求并解决兼容性。

但在 Node.js 环境下,你的代码运行在 操作系统 之上。Node.js 有一套近乎完美的模块查找算法(node_modules 检索层级),这是它的根基。强行把所有依赖揉进一个单文件,本质上是在破坏 Node.js 的“物理寻址逻辑”。


二、 Web 思路打包 Node 服务的“三大深坑”

如果你坚持用 Web 的“单文件”思路去打包 Node 服务,你一定会遇到以下灾难:

  1. 原生二进制模块 (Native Addons)

像图像处理(sharpcanvas)、加密或高性能日志库(pino),内部包含 .node 后缀的 C++ 二进制代码。

  • Bundle 结局:打包工具无法将二进制代码塞进 JS 文本。单文件运行时,会因无法在虚拟路径中定位物理 .node 文件而直接崩溃。
  1. 动态路径陷阱:告别 __dirname

很多开发者在打包时纠结 __dirname 丢失。如果你还试图靠打包工具去模拟它,说明你的思维还没转过来。

  • Bundle 结局:一旦打包,原本深层嵌套的目录结构被拍平,模拟的 __dirname 往往指向错误的 dist 目录。
  • 工业级做法:在 ESM 环境下,直接使用原生 Node.js URL API 进行动态寻址:

javascript

import { fileURLToPath } from 'node:url';
import path from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); // 物理位置 100% 准确

请谨慎使用此类代码。

  1. Interop (互操作性) 灾难

Node 的 CommonJS 和 ESM 混用机制极其复杂。

  • Bundle 结局:打包工具在转换 import 和 require 时,极易弄坏 default 导出。你遇到的 TypeError: ct.destination is not a function 往往就是因为打包工具把一个 CJS 模块错误地包裹成了代理对象。

三、 维度升级:为什么运维(SRE)更喜欢“依赖外置”?

这是 Web 出身的开发者最容易忽略的视角:在线上生产环境,依赖外置是运维同学的“刚需”。

  • 安全审计的“黑盒” vs “透明件”

    • 外置模式:运维通过 SCA(软件成分分析)工具 扫描 lock 文件即可毫秒级识别 CVE 漏洞。
    • Bundle 模式:你交付的是几十万行压缩混淆后的代码。安全扫描器无法穿透混淆后的变量名,整个应用变成了不可控的安全死角。
  • 应急热修复 (Hotfix)
    如果凌晨 3 点发现某个深度依赖包有致命 Bug,外置模式允许运维直接进入容器修改 node_modules 里的某行代码救火。而 Bundle 模式 除了全量重新打包发布,没有任何自救手段。


四、 工业级标准方案:从“折腾打包”转向“环境交付”

既然单文件打包是自寻死路,那么“正规军”的标配流程是什么?

  1. 拦截模式:Vite/Rollup 仅作为“转译器”

在配置中将所有 dependencies 标记为 external。Vite 只负责把你的 TS 业务代码转换成轻量的 MJS,不介入任何依赖的处理。

  1. 交付模式:生产环境“现场”安装

利用 Docker 的分阶段构建(Multi-stage Builds),这才是真正的工业化部署:

  1. 准备环境:在 Docker 镜像中 COPY package.json
  2. 现场安装RUN pnpm install --prod。这一步在目标容器(通常是 Linux)中执行,确保原生模块针对该系统完成正确的编译,彻底规避跨平台兼容性问题。
  3. 放入业务COPY dist/  产物。
  4. 启动:让 Node.js 原生的模块加载器去处理最稳健的依赖加载。

五、 总结:造轮子是为了看清路

  • 前端思维:追求产物极小、高度混淆、单兵作战(Bundle)。
  • 后端思维:追求 环境一致性、运行时确定性、二进制兼容性(Environment)

放弃“单文件打包”是对开发者精神健康的极大保护。承认 Node 环境的复杂性,拥抱  “外置依赖 + 容器化安装” ,这才是从 Web 开发者向后端架构进化的必经之路。

总结一句话:Web 项目看体积,Node 项目看环境。

Vue3 props穿透(attrs)重大变化:$attrs 居然包含class/style了!

作者 boooooooom
2026年2月2日 18:00

一、前言:为什么要关注 Vue3 的 $attrs 变化?

在 Vue 组件开发中,props 穿透(借助 $attrs 传递未声明的属性)是高频场景——比如封装通用按钮、输入框组件时,需要将父组件传递的额外属性(如 placeholder、disabled)透传给子组件内部的原生元素。

而 Vue3 对 attrs的改动堪称“颠覆性”,最核心的一点就是:classstyle不再被attrs 的改动堪称“颠覆性”,最核心的一点就是:**class 和 style 不再被 attrs 过滤,会直接包含在 $attrs 中并透传**。这和 Vue2 的行为完全相反,也是很多开发者迁移项目、编写新组件时最容易踩坑的点,掌握这个变化能少走大量弯路。

二、Vue2 vs Vue3:$attrs 核心差异对比(重点!)

要理解这个变化,先明确 Vue2 中 $attrs 的行为,再对比 Vue3 的改动,差异一目了然,避免混淆。

1. Vue2 中的 $attrs 行为

在 Vue2 中,$attrs 有一个明确的“过滤规则”:

  • 仅包含父组件传递给子组件、但子组件未通过 props 声明的非 class、非 style 属性
  • class 和 style 会被单独提取,直接应用到子组件的根元素上,不会出现在 $attrs 中
  • 若想手动控制 class/style 透传,需借助 inheritAttrs: false 关闭默认透传,但即便关闭,$attrs 依然不包含 class/style。

2. Vue3 中的 $attrs 行为(核心变化)

Vue3 彻底简化了这一逻辑,同时改变了 $attrs 的包含范围:

  • 核心变化:$attrs 不再过滤 class 和 style,会将父组件传递的所有未被 props 声明的属性(包括 class、style)全部包含在内;
  • 默认透传行为不变:未声明的 props(含 class/style)依然会自动透传至子组件的根元素;
  • inheritAttrs: false 的作用升级:关闭默认透传后,class 和 style 也会跟随 attrs一起被“拦截”,不会再自动应用到根元素,需手动通过vbind="attrs 一起被“拦截”,不会再自动应用到根元素,需手动通过 v-bind="attrs" 控制透传位置。
  • Vue2 中:$attrs = { disabled: true }(class、style 被过滤,直接应用到子组件 button 上);
  • Vue3 中:attrs=class:"btnprimary",style:padding:"10px",disabled:trueclassstyle被包含在attrs = { class: "btn-primary", style: { padding: "10px" }, disabled: true }(class、style 被包含在 attrs 中,同时自动透传至 button 上)。

4. inheritAttrs: false 后的差异(关键避坑点)

当子组件设置 inheritAttrs: false 后,两者的差异更明显:

  • Vue2 中:class、style 依然会自动应用到子组件根元素(button),仅未声明的 props(disabled)被拦截在 $attrs 中;
  • Vue3 中:class、style 会和 disabled 一起被拦截在 attrs中,不再自动应用到根元素,需手动写<buttonvbind="attrs 中,**不再自动应用到根元素**,需手动写 <button v-bind="attrs"> 才能将所有属性(含 class/style)透传。

四、常见踩坑场景及解决方案

掌握变化后,重点解决实际开发中最易遇到的 2 个坑,新手直接套用即可。

坑点1:子组件根元素不需要 class/style 透传

场景:父组件传递的 class/style 是给子组件内部某个非根元素用的,根元素有自己的样式,不想继承父组件的 class/style。

解决方案:

  1. 子组件设置 inheritAttrs: false,关闭默认透传;
  2. 通过解构赋值,从 $attrs 中剔除 class 和 style,再将剩余属性透传给目标元素。
<!-- 子组件 Child.vueVue3) -->
<template>
  <div class="child-root">
    <button v-bind="restAttrs">测试按钮</button>
  </div>
</template>
<script setup>
import { useAttrs } from 'vue'
const props = defineProps(['title'])
const attrs = useAttrs()
// 解构剔除 class 和 style,剩余属性透传给 button
const { class: _, style: __, ...restAttrs } = attrs
</script>

坑点2:手动透传 $attrs 导致 class/style 重复

场景:未关闭 inheritAttrs,又手动写 v-bind="$attrs",导致 class/style 被重复应用(根元素会同时拥有默认透传和手动透传的样式)。

解决方案:

  • 要么不手动透传,依赖默认透传行为;
  • 要么设置 inheritAttrs: false,再手动透传 $attrs(按需剔除多余属性)。

五、总结:Vue3 $attrs 变化核心要点

无需死记硬背,记住 3 个核心要点,轻松应对所有场景:

  1. 包含范围变化:Vue3 $attrs 包含 class/style,Vue2 不包含;
  2. 默认透传:两者一致,未声明的 props(含 class/style)自动透传至根元素;
  3. inheritAttrs 作用:Vue3 中关闭后,class/style 会被一同拦截,需手动控制透传。

Vue3 对 $attrs 的改动,本质是简化了属性透传的逻辑,让开发者能更灵活地控制属性传递,但也带来了新的踩坑点。掌握本文的差异对比和实操方案,就能轻松规避风险,高效运用 props 穿透开发 Vue3 组件~

统一开发规范--Git hooks工具库——husky

2026年2月2日 17:52

🐾 Husky 是什么?

Husky 是一个用于 管理 Git 钩子(Git hooks) 的 JavaScript 工具。它允许你在执行 Git 操作(如 commit、push 等)时自动运行自定义脚本,比如: 提交前检查代码格式(lint) 运行单元测试 阻止不符合规范的提交

它的目标是 提升代码质量、统一团队开发规范、防止低级错误进入仓库。

✅ 为什么在 Vue 3 项目中使用 Husky?

在现代前端工程化项目(如 Vue 3 + Vite + TypeScript)中,通常会配合以下工具链: ESLint:代码规范检查 Prettier:代码格式化 lint-staged:只对暂存文件(staged files)运行 lint Husky:在 Git 提交阶段触发上述检查

通过 Husky,可以确保: “任何提交到 Git 的代码都必须通过 lint 和测试”,避免污染主分支或远程仓库。

🛠️ 在 Vue 3 项目中如何配置 Husky?

当前环境:node.js为16.20.2 pnpm为8.15.9 第一步:安装依赖

bash pnpm install husky@^8.0.3 --save-dev pnpm install lint-staged@^13.3.0 --save-dev

第二步:启用 Husky

bash npx husky install 这会在项目根目录生成 .husky/ 文件夹。 第三步:添加钩子(例如 pre-commit)

bash npx husky add .husky/pre-commit "npx lint-staged" 第四步:配置 lint-staged

在 package.json 中添加:

{
  "lint-staged": {
    "*.{js,ts,vue}": [
      "eslint --fix",
      "prettier --write"
    ]
  }
}

这样每次 git commit 时,只会格式化和检查你本次修改的文件。 第五步(可选):自动启用 Husky(推荐)

在 package.json 的 scripts 中加入:

{
  "scripts": {
    "prepare": "husky install"
  }
}

这样其他开发者执行 npm install 后会自动启用 Husky 钩子。

💡 小贴士

注意 Husky 和 lint-staged的版本 Husky v7+ 要求 Node.js ≥ 14。

husky常用的两个git钩子脚本

一、.husky/pre-commit

.husky/pre-commit 文件是一个 Git 钩子脚本,它的主要作用是在您执行 git commit 命令时自动运行。具体来说:

  1. 自动代码检查:在每次提交代码前,自动运行代码检查工具(如 ESLint、Prettier 等)来确保代码质量。

  2. 防止低质量问题提交:如果代码检查失败(例如有语法错误、代码风格问题等),提交会被中断,直到问题解决。

  3. 自动化格式化:可以自动格式化代码,确保团队成员提交的代码风格一致。

在您的项目中,.husky/pre-commit 文件包含以下内容:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged

这意味着在每次提交前,会运行 lint-staged 命令,它只会检查和修复那些被暂存(staged)的文件,而不是整个项目的所有文件。

这种机制有助于:

  • 确保只有符合代码规范的代码才能被提交
  • 保持代码库的一致性
  • 在早期发现问题,避免在后续流程中出现更多问题
  • 自动格式化代码,减少代码审查时的风格争议

二、.husky/commit-msg

.husky/commit-msg 是另一个 Git 钩子脚本,它的作用是在您提交代码时检查提交消息(commit message)的格式。

具体来说,commit-msg 钩子的功能包括:

  1. 提交信息格式验证:检查提交信息是否符合预设的格式规范,比如是否遵循 Conventional Commits 规范(如 feat: 新增功能fix: 修复bug 等)。

  2. 强制规范化提交:确保团队成员使用统一的提交信息格式,便于后续自动生成 CHANGELOG、计算版本号等。

  3. 阻止不合规提交:如果提交信息不符合规范,会中断提交过程,要求修改提交信息。

例如,一个典型的 commit-msg 钩子可能包含:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no-install commitlint --edit "$1"

这个脚本会使用 commitlint 工具来验证提交信息是否符合规范。常见的规范包括:

  • feat: 添加新功能
  • fix: 修复 bug
  • docs: 文档更新
  • style: 代码格式调整
  • refactor: 重构代码
  • test: 添加测试
  • chore: 构建工具或辅助工具变动

这样可以确保项目的历史提交记录清晰、有序,便于团队协作和自动化工具处理。

可以通过以下命令添加:

npx husky add .husky/commit-msg "npx --no-install commitlint --edit \$1"

学习Three.js--星环粒子(ShaderMaterial)

2026年2月2日 17:22

学习Three.js--星环粒子(ShaderMaterial)

前置核心说明

开发目标

基于Three.js的ShaderMaterial实现高性能10万粒子纯净白色星环效果,核心能力包括:

  1. 生成环形分布的大量粒子(10万级),形成内外半径固定的星环,粒子分布均匀协调;
  2. 借助GPU着色器实现粒子3D脉动动画,兼顾流畅效果与高性能,低配设备无明显卡顿;
  3. 实现纯净白色粒子,保证加法混合下明亮不返白、柔和有光晕,避免生硬刺眼;
  4. 实现圆形抗锯齿粒子,避免默认方形粒子的生硬边缘,提升视觉细腻度;
  5. 支持轨道交互(拖拽旋转、滚轮缩放),全方位查看3D星环的脉动与光晕效果。

dc73c358-2461-4480-bd95-eea934b70677.png

核心技术栈

技术点 作用
THREE.ShaderMaterial 自定义顶点/片元着色器,逻辑运行在GPU上并行处理,高效支撑10万级粒子(性能远超普通材质如PointsMaterial
自定义attributesizes/shift 向着色器传递每个粒子的独立数据(尺寸、脉动参数),实现粒子差异化效果(不同大小、不同脉动节奏)
自定义uniformuTime 向着色器传递全局统一数据(时间),驱动所有粒子的动画同步更新,保证脉动效果协调统一
圆柱坐标系(setFromCylindricalCoords 快速生成环形分布的粒子坐标,无需手动计算sin/cos三角函数,简洁高效且不易出错
模型视图/投影矩阵(modelViewMatrix/projectionMatrix 着色器中完成3D顶点的透视变换,将粒子的局部坐标转换为屏幕可显示的2D坐标,是3D渲染的必备步骤
透视缩放点大小 实现粒子大小随相机距离变化,模拟「近大远小」的真实透视效果,避免远处粒子过大/过小导致视觉失真
片元着色器圆形粒子+smoothstep 绘制圆形粒子,并用抗锯齿算法实现边缘渐隐,同时为白色粒子营造柔和光晕,提升视觉质感
THREE.AdditiveBlending 粒子白色亮度叠加发光,让星环呈现更明亮、更有层次感的朦胧光晕,同时通过亮度控制避免返白
白色亮度安全控制(基底<1.0+尺寸限制) 加法混合下的核心避坑点,保证白色粒子明亮通透且不出现过曝返白,是纯净白色星环的关键优化

分步开发详解

步骤1:基础环境搭建(场景/相机/渲染器/控制器)

1.1 核心代码
// 1. 场景初始化(纯黑背景,最大化衬托白色星环的光晕效果)
const scene = new THREE.Scene();

// 2. 透视相机(适配3D场景,兼顾星环整体查看与细节观察)
const camera = new THREE.PerspectiveCamera(
  60, // 视角(FOV):60°视野适中,无场景变形
  innerWidth / innerHeight, // 宽高比:适配浏览器窗口
  1, // 近裁切面:过滤过近无效对象,提升性能
  1000 // 远裁切面:保证星环完整处于可见范围
);
camera.position.set(0, 6, 100); // 高位侧视:既完整查看环形形态,又体现3D脉动层次感

// 3. 渲染器(抗锯齿,提升白色粒子边缘细腻度,避免光晕锯齿感)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio); // 高清适配:Retina屏幕无模糊
document.body.appendChild(renderer.domElement);

// 4. 轨道控制器(支持拖拽旋转/滚轮缩放,便捷查看3D白色星环)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 启用阻尼:拖拽旋转有惯性,交互更顺滑自然
controls.dampingFactor = 0.05; // 阻尼系数:惯性适中,兼顾精准度与流畅度
1.2 关键说明
  • 相机位置(0, 6, 100) 采用「高位+稍远」视角,既可以完整捕捉白色星环的环形形态,又能清晰观察粒子3D脉动的光晕变化,避免视角过近导致星环变形、光晕过曝。
  • 渲染器antialias: true:开启抗锯齿,配合片元着色器的smoothstep抗锯齿逻辑,让白色粒子的边缘和光晕更细腻,减少锯齿感,这对明亮的白色星环尤为重要。
  • 控制器阻尼:启用阻尼后,交互体验更贴近真实3D场景,适合长时间查看星环的脉动效果,避免拖拽后瞬间停止的生硬感。

步骤2:粒子数据生成(环形坐标+自定义属性)

这是星环的基础,需要生成10万粒子的环形坐标,以及每个粒子的独立尺寸、脉动参数,为后续着色器提供数据支撑,同时保证粒子分布均匀,为白色光晕叠加打下基础。

2.1 核心代码
// 粒子系统核心参数(集中管理,方便调整,适配白色星环效果)
const count = 100000; // 总粒子数(10万,ShaderMaterial可高效处理,无明显卡顿)
const innerRadius = 10, outerRadius = 40; // 星环内外半径:决定环形大小与宽度
const pointsArr = []; // 粒子顶点坐标数组
const sizes = []; // 粒子尺寸数组(每个粒子独立尺寸,避免白色星环单调)
const shift = []; // 粒子脉动参数数组(每个粒子4个独立参数,实现差异化脉动)
const radii = []; // 粒子环形半径数组(备用,方便后续扩展)

// 辅助函数:向shift数组添加单个粒子的4个脉动参数
const pushShift = () => {
  shift.push(
    Math.random() * Math.PI, // 脉动参数1:初始相位(控制脉动起始位置,避免所有粒子同步脉动)
    Math.random() * Math.PI * 2, // 脉动参数2:水平相位(控制水平方向脉动,增加3D层次感)
    (Math.random() * 0.9 + 0.1) * Math.PI * 0.1, // 脉动参数3:脉动频率(控制脉动快慢,0.1倍PI保证舒缓流畅)
    Math.random() * 0.7 + 0.05 // 脉动参数4:脉动幅度(控制脉动距离,0.05~0.75避免粒子跑出星环)
  );
};

// 循环生成10万粒子数据
for (let i = 0; i < count; i++) {
  // 幂次采样(Math.pow(Math.random(), 1.5)):让粒子更均匀分布在环形区域
  // 避免直接使用Math.random()导致内侧粒子密集、外侧稀疏的问题
  const rand = Math.pow(Math.random(), 1.5);
  const radius = Math.sqrt(outerRadius * outerRadius * rand + (1 - rand) * innerRadius * innerRadius);
  radii.push(radius); // 存储粒子环形半径(备用,方便后续扩展白色星环的密度变化)

  // 从圆柱坐标系转换为直角坐标系,快速生成环形粒子坐标
  pointsArr.push(
    new THREE.Vector3().setFromCylindricalCoords(
      radius, // 圆柱坐标系半径(对应星环环形半径,决定粒子在星环中的位置)
      Math.random() * 2 * Math.PI, // 圆柱坐标系角度(0~2π,实现环形均匀分布)
      (Math.random() - 0.5) * 2 // 圆柱坐标系高度(Z轴,-1~1):让星环有轻微厚度,提升3D立体感
    )
  );

  // 生成粒子独立尺寸(0.3~1.5之间随机):实现白色粒子大小差异化,光晕叠加更自然
  sizes.push(Math.random() * 1.2 + 0.3);

  // 生成粒子独立脉动参数:每个粒子脉动节奏不同,避免白色星环脉动过于规则
  pushShift();
}
2.2 关键技术点解析
  • 10万粒子的高性能支撑:普通材质(如PointsMaterial)处理10万粒子时,动画逻辑运行在CPU上,会出现明显卡顿;而ShaderMaterial的逻辑运行在GPU上,具备强大的并行处理能力,可轻松应对10万级甚至百万级粒子,这是实现高性能白色星环的核心基础。
  • 圆柱坐标系(setFromCylindricalCoords:Three.js内置的坐标转换方法,参数为「半径、角度、高度」,无需手动计算sin/cos来生成环形坐标,简洁高效且不易出错,是实现星环、圆柱等环形结构的最佳实践。
  • 幂次采样(Math.pow(Math.random(), 1.5):如果直接使用Math.random(),粒子会在「内半径到外半径」的区间内均匀分布,导致星环内侧粒子密集、外侧稀疏,白色光晕叠加后会出现内侧过亮返白的问题;幂次采样后,粒子会更均匀地分布在环形区域,白色光晕叠加更协调,不易出现局部过曝。
  • 自定义属性数组(sizes/shift
    • sizes:存储每个粒子的独立尺寸,实现白色粒子大小的差异化,避免星环过于单调,同时让光晕叠加呈现自然的明暗变化;
    • shift:每个粒子存储4个脉动参数,后续在着色器中用于计算3D脉动动画,让每个粒子的脉动效果不同,白色星环的脉动更贴近真实星尘的效果。

步骤3:构建BufferGeometry(绑定顶点+自定义attribute)

将步骤2生成的粒子数据绑定到BufferGeometry,并将自定义属性(sizes/shift/radii)添加到几何体中,让着色器能够访问这些数据,为实现差异化粒子效果和白色星环优化提供数据支撑。

3.1 核心代码
// 1. 构建BufferGeometry,绑定粒子顶点坐标(环形粒子的基础位置数据)
const pointsGeometry = new THREE.BufferGeometry().setFromPoints(pointsArr);

// 2. 添加自定义attribute:sizes(粒子尺寸,每个粒子1个值)
// 第二个参数「1」表示每个顶点的分量数为1,与sizes数组的每个元素一一对应
pointsGeometry.setAttribute(
  'sizes', 
  new THREE.Float32BufferAttribute(sizes, 1)
);

// 3. 添加自定义attribute:shift(粒子脉动参数,每个粒子4个值)
// 第二个参数「4」表示每个顶点的分量数为4,与shift数组的每4个元素对应一个粒子
pointsGeometry.setAttribute(
  'shift', 
  new THREE.Float32BufferAttribute(shift, 4)
);

// 4. 添加自定义attribute:radii(粒子环形半径,每个粒子1个值,备用扩展)
pointsGeometry.setAttribute(
  'radii', 
  new THREE.Float32BufferAttribute(radii, 1)
);
3.2 关键技术点解析
  • BufferGeometry:高效的几何体类型,直接操作二进制数组存储数据,渲染时减少CPU与GPU之间的数据传输开销,适合大量粒子场景,性能远优于已被废弃的普通Geometry,是Three.js推荐的几何体类型。
  • 自定义attribute:这是向着色器传递「每个顶点/粒子独立数据」的核心方式,语法为geometry.setAttribute(属性名, BufferAttribute实例)
    • 着色器中需要声明同名attribute变量(如attribute float sizes;),才能访问对应数据;
    • 对于白色星环而言,通过attribute传递的size参数,是实现粒子大小差异化、避免光晕均匀过曝的关键。
  • Float32BufferAttribute:最常用的BufferAttribute类型,存储32位浮点型数据,兼顾精度与性能,适合传递粒子尺寸、脉动参数等数据。

步骤4:创建ShaderMaterial(核心!纯净白色星环的灵魂)

ShaderMaterial是本次实战的核心,通过自定义顶点着色器片元着色器,实现粒子的纯净白色、3D脉动、圆形抗锯齿和柔和光晕,同时保证加法混合下不返白,所有逻辑在GPU上运行,保证高性能。

4.1 核心代码
// 构建ShaderMaterial,配置全局uniforms和着色器,实现纯净白色星环
const pointsMaterial = new THREE.ShaderMaterial({
  // 1. 全局uniforms(向着色器传递全局统一数据,此处为时间和星环半径)
  uniforms: {
    uTime: { value: 0 }, // 全局时间:驱动所有粒子的动画同步更新
    uInnerRadius: { value: innerRadius }, // 星环内半径(备用扩展)
    uOuterRadius: { value: outerRadius } // 星环外半径(备用扩展)
  },

  // 2. 顶点着色器(处理粒子位置、颜色、大小,运行在每个顶点/粒子上)
  vertexShader: `
    uniform float uTime;
    uniform float uInnerRadius;
    uniform float uOuterRadius;
    attribute float sizes; // 粒子尺寸(自定义attribute,每个粒子独立)
    attribute vec4 shift; // 粒子脉动参数(自定义attribute,每个粒子4个值)
    attribute float radii; // 粒子环形半径(备用扩展)
    varying vec3 vColor; // 传递给片元着色器的白色(varying变量,实现平滑插值)

    void main() {
      // 步骤1:获取粒子原始位置
      vec3 pos = position;

      // 步骤2:设置纯净白色(核心避坑:不返白,留光晕叠加空间)
      vColor = vec3(0.9, 0.9, 0.9); // 白色基底:0.9(<1.0),避免加法混合过曝返白
      vColor *= 0.99; // 全局亮度微调:0.99(安全最大值),明亮且不返白,保留光晕感

      // 步骤3:3D脉动动画(球面扰动,让粒子沿球面方向脉动,白色光晕更有层次)
      float t = uTime;
      // 计算粒子脉动相位(结合初始相位和时间,实现每个粒子不同的脉动节奏)
      // 6.28318530718 = 2π,取模保证相位始终在0~2π之间,实现循环脉动
      float moveT = mod(shift.x + shift.z * t, 6.28318530718);
      float moveS = mod(shift.y + shift.z * t, 6.28318530718);
      // 计算脉动偏移量(球面坐标转换,实现3D方向脉动,避免平面化)
      vec3 offset = vec3(
        cos(moveS) * sin(moveT),
        cos(moveT),
        sin(moveS) * sin(moveT)
      ) * shift.w; // 乘以脉动幅度,控制粒子脉动距离,避免跑出星环
      pos += offset; // 叠加偏移量,更新粒子位置,实现脉动效果

      // 步骤4:透视变换(将3D粒子坐标转换为2D屏幕坐标,3D渲染必备)
      vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); // 模型视图矩阵:局部坐标→相机视角坐标
      gl_Position = projectionMatrix * mvPosition; // 投影矩阵:相机视角坐标→屏幕裁剪坐标

      // 步骤5:粒子大小计算(透视缩放+最大尺寸限制,避免白色光晕过曝)
      gl_PointSize = 0.18 * sizes * (200.0 / -mvPosition.z); // 透视缩放:近大远小,视觉更真实
      gl_PointSize = min(gl_PointSize, 5.0); // 限制最大尺寸:5.0,避免粒子过大导致光晕叠加返白
    }
  `,

  // 3. 片元着色器(处理粒子像素颜色、形状,运行在每个像素上,实现圆形抗锯齿与柔和光晕)
  fragmentShader: `
    varying vec3 vColor; // 从顶点着色器传递过来的纯净白色

    void main() {
      // 步骤1:绘制圆形粒子(基于点坐标的UV计算,替代默认方形粒子)
      vec2 uv = gl_PointCoord - 0.5; // 将点坐标从(0,0)~(1,1)转换为(-0.5,-0.5)~(0.5,0.5)
      float d = length(uv); // 计算当前像素到粒子中心的距离

      // 步骤2:圆形裁剪(丢弃超出圆心0.5范围的像素,形成圆形粒子)
      if (d > 0.5) discard; // discard:丢弃当前像素,不渲染,实现圆形轮廓

      // 步骤3:抗锯齿+柔和光晕(smoothstep实现渐隐,避免圆形边缘锯齿,提升白色光晕质感)
      float alpha = smoothstep(0.5, 0.05, d); // 从0.5到0.05,alpha从0渐变到1,边缘渐隐

      // 步骤4:设置最终像素颜色(纯净白色+渐变Alpha,实现柔和光晕)
      gl_FragColor = vec4(vColor, alpha);
    }
  `,

  // 4. 材质附加配置(提升白色星环视觉效果,核心是加法混合与透明设置)
  transparent: true, // 启用透明:支持粒子边缘渐隐,实现柔和光晕效果
  depthTest: false, // 关闭深度测试:允许白色粒子叠加,营造光晕层次感,避免粒子互相遮挡
  blending: THREE.AdditiveBlending, // 加法混合:粒子白色亮度叠加,呈现朦胧光晕,提升星环质感
  premultipliedAlpha: false // 关闭预乘Alpha:避免白色发灰,保持纯净通透,适配加法混合
});

// 5. 创建Points粒子对象,添加到场景(将几何体与材质结合,形成最终的白色星环)
const points = new THREE.Points(pointsGeometry, pointsMaterial);
scene.add(points);
4.2 关键技术点解析
(1)全局uniforms与着色器变量声明
  • uniform float uTime:全局时间变量,从JS端每帧更新,驱动所有粒子的动画同步,所有粒子共享该值,保证白色星环的脉动效果协调统一;
  • attribute变量:声明自定义属性(sizes/shift/radii),每个粒子有独立的值,对应BufferGeometry中绑定的数据,是实现粒子差异化效果的核心;
  • varying vec3 vColor:插值变量,用于在顶点着色器和片元着色器之间传递白色数据,Three.js会自动在顶点之间进行平滑插值,保证白色光晕的过渡自然,无明显断层。
(2)顶点着色器核心逻辑(纯净白色+3D脉动+透视优化)
  1. 纯净白色设置(核心避坑:不返白)

    • 直接将vColor赋值为vec3(0.9, 0.9, 0.9),白色基底值设为0.9而非1.0,为加法混合预留光晕叠加空间,避免10万粒子密集叠加后亮度溢出(>1.0)导致返白;
    • 保留vColor *= 0.99的全局亮度微调,让白色更通透柔和,带有自然的光晕感,不会显得生硬刺眼,同时0.99是安全最大值,不会触发过曝返白;
    • 若觉得白色偏暗,可将基底值调整为0.95,全局增益调整为0.99切勿将任一值设为1.0及以上,否则会出现明显返白。
  2. 3D脉动动画(球面扰动,提升光晕层次感)

    • 利用mod函数计算脉动相位,保证相位始终在0~2π之间,实现循环流畅的脉动效果,避免粒子脉动出现断层;
    • 通过球面坐标转换(cos/sin)计算偏移量offset,让粒子沿球面方向脉动,而非平面方向,白色星环更具3D立体感,光晕叠加也更有层次;
    • 乘以shift.w(脉动幅度),控制每个粒子的脉动距离,实现差异化脉动效果,避免白色星环脉动过于规则,更贴近真实星尘的效果。
  3. 透视变换与点大小缩放(视觉真实+避坑优化)

    • modelViewMatrix + projectionMatrix:Three.js内置的矩阵,完成3D顶点坐标到2D屏幕坐标的转换,是3D渲染的必备步骤,保证白色星环能够正确显示在屏幕上;
    • gl_PointSize:设置粒子的屏幕尺寸,200.0 / -mvPosition.z实现「近大远小」的透视效果,让白色星环更具真实感,0.18为整体缩放系数,控制粒子的整体大小;
    • min(gl_PointSize, 5.0):限制粒子的最大尺寸,避免远处粒子因透视缩放过大,导致白色光晕叠加过曝返白,这是白色星环的关键避坑点之一。
(3)片元着色器核心逻辑(圆形抗锯齿+柔和光晕)
  1. 圆形粒子绘制(替代默认方形,提升视觉质感)

    • gl_PointCoord:Three.js内置变量,代表当前像素在粒子中的坐标,范围为(0,0)(1,1)
    • 转换为(-0.5,-0.5)(0.5,0.5)的坐标,计算到粒子中心的距离d,通过if (d > 0.5) discard丢弃超出圆心0.5范围的像素,形成圆形粒子,替代默认的方形粒子,让白色星环更细腻。
  2. 抗锯齿+柔和光晕(smoothstep核心优化)

    • smoothstep(a, b, x):GLSL内置平滑插值函数,当x <= a时返回0,x >= b时返回1,中间为平滑渐变;
    • 此处smoothstep(0.5, 0.05, d),当d=0.5时返回0(完全透明),d=0.05时返回1(完全不透明),中间为平滑渐隐过渡,既实现了圆形粒子的抗锯齿,又为白色粒子营造了柔和的光晕,避免边缘生硬刺眼;
    • 若想让光晕更强,可将第二个参数调整为0.0,若想让粒子边缘更锐利,可调整为0.1,根据需求灵活适配。
  3. 加法混合(AdditiveBlending

    • 粒子的白色亮度会与背景和其他粒子的亮度叠加,越密集的地方越亮,形成自然的朦胧光晕,提升白色星环的层次感和视觉冲击力;
    • 配合transparent: truedepthTest: false,保证白色粒子之间能够正常叠加,不会互相遮挡,光晕效果更连贯,同时避免白色发灰,保持纯净通透。

步骤5:动画循环(驱动动画+更新渲染)

每帧更新全局时间uTime,驱动粒子脉动动画,同时更新星环整体旋转和控制器阻尼,实现流畅的白色星环动画效果,保证视觉体验的连贯性。

5.1 核心代码
const clock = new THREE.Clock(); // 时钟:用于获取累计运行时间,不受帧率影响,避免动画累积误差

function animate() {
  requestAnimationFrame(animate); // 绑定浏览器刷新率(通常60帧/秒),实现流畅无卡顿的动画

  // 1. 获取累计运行时间,驱动着色器脉动动画(减慢速度,让白色星环脉动更舒缓)
  const t = clock.getElapsedTime() * 0.5; // 乘以0.5:减慢时间流速,提升观察体验
  pointsMaterial.uniforms.uTime.value = t * Math.PI; // 乘以PI:放大相位变化,让脉动更流畅

  // 2. 星环整体旋转,增加场景活力(白色星环缓慢旋转,光晕效果更丰富)
  points.rotation.y = t * 0.05;

  // 3. 更新轨道控制器阻尼(必须在动画循环中调用,保证阻尼效果生效)
  controls.update();

  // 4. 渲染场景(将场景和相机的3D信息渲染为2D画布,呈现最终的白色星环效果)
  renderer.render(scene, camera);
}

// 启动动画循环(开始运行白色星环的脉动与渲染)
animate();
5.2 关键说明
  • clock.getElapsedTime():获取从时钟启动到当前的累计运行时间(单位:秒),相比getDelta()(获取两帧之间的时间差)更适合驱动全局循环动画,避免动画因帧率波动出现累积误差,保证白色星环的脉动效果在不同设备上一致。
  • 动画速度调节:乘以0.5减慢时间流速,乘以Math.PI放大相位变化,让白色星环的脉动更舒缓、更易观察,可根据需求调整系数(如0.3更慢,1.0更快)。
  • 星环整体旋转points.rotation.y让白色星环绕Y轴缓慢旋转,配合粒子的3D脉动,白色光晕的变化更丰富,避免场景过于静态,提升视觉体验。

步骤6:窗口适配(响应式调整)

保证白色星环在不同屏幕尺寸下都能全屏显示,且不会出现拉伸变形,适配桌面端、移动端等不同设备。

6.1 核心代码
window.addEventListener('resize', () => {
  // 1. 更新相机宽高比(适配新的窗口尺寸,避免场景拉伸)
  camera.aspect = window.innerWidth / window.innerHeight;
  // 2. 更新相机投影矩阵(必须调用,否则宽高比修改不生效,场景会出现拉伸变形)
  camera.updateProjectionMatrix();
  // 3. 更新渲染器尺寸(适配新的窗口尺寸,保证白色星环全屏显示)
  renderer.setSize(window.innerWidth, window.innerHeight);
});
6.2 关键说明
  • 窗口大小变化时,同步更新相机宽高比和渲染器尺寸,保证白色星环在不同屏幕尺寸下都能全屏显示,且透视效果正常,不会出现拉伸变形。
  • camera.updateProjectionMatrix():相机参数(如宽高比)修改后,必须调用该方法更新投影矩阵,否则宽高比的修改不会生效,场景会出现明显的拉伸变形,影响白色星环的视觉效果。

核心参数速查表(快速调整白色星环效果)

参数名 当前取值 作用 修改建议
count 100000 总粒子数,决定白色星环的密集程度与光晕细腻度 低配设备改为50000~80000,减少卡顿;高配设备改为200000,提升光晕细腻度
innerRadius/outerRadius 10/40 星环内/外半径,决定白色星环的大小和环形宽度 改为5/30:星环更小更窄,光晕更集中;改为20/60:星环更大更宽,光晕更分散
shift.w(生成时) 0.05~0.75 粒子脉动幅度,决定白色粒子的移动距离与光晕变化 改为0.050.5:脉动更柔和,光晕变化更平缓;改为0.51.0:脉动更剧烈,光晕变化更明显(避免>1.0,粒子易跑出星环)
白色基底值 vec3(0.9, 0.9, 0.9) 0.9 白色基础亮度,决定星环的整体明亮度,核心避坑点 改为0.8~0.95:亮度适中,不易返白;切勿≥1.0,否则加法混合会过曝返白
全局亮度增益 * 0.99 0.99 白色整体增益,微调星环明亮度,保留光晕空间 改为0.9~0.99:安全范围,明亮且不返白;切勿>1.0,触发过曝返白
粒子整体缩放 0.18gl_PointSize 0.18 白色粒子的整体尺寸缩放系数,决定粒子基础大小 改为0.15:粒子更小,光晕更细腻;改为0.25:粒子更大,光晕更明显(避免>0.3,易返白)
粒子最大尺寸 5.0min限制) 5.0 白色粒子的最大尺寸限制,核心避坑点,防止光晕过曝 改为3.04.0:更不易返白,光晕更柔和;改为6.08.0:粒子更大,光晕更亮(需降低白色基底值,避免返白)
smoothstep(0.5, 0.05, d) 0.05 白色粒子边缘渐隐起始值,决定光晕强弱与边缘细腻度 改为0.0:光晕更强,边缘更柔和;改为0.1:光晕更弱,边缘更锐利
clock.getElapsedTime() * 0.5 0.5 动画时间流速,决定白色星环的脉动与旋转速度 改为0.3:动画更舒缓,便于观察光晕细节;改为1.0:动画更快速,光晕变化更活跃

完整优化代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>星环粒子 - ShaderMaterial 纯净白色版</title>
  <style>body { margin: 0; overflow: hidden; background: #000; }</style>
</head>
<body>
  <script type="module">
  // 导入Three.js核心库和轨道控制器
  import * as THREE from 'https://esm.sh/three@0.174.0';
  import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';

  // ========== 1. 基础环境初始化(场景/相机/渲染器/控制器) ==========
  const scene = new THREE.Scene();

  // 透视相机:高位侧视,清晰观察白色星环的环形形态与3D脉动
  const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 1, 1000);
  camera.position.set(0, 6, 100);

  // 渲染器:抗锯齿,提升白色粒子边缘与光晕的细腻度,适配高清屏幕
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  document.body.appendChild(renderer.domElement);

  // 轨道控制器:启用阻尼,实现顺滑的3D交互体验
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.dampingFactor = 0.05;

  // ========== 2. 粒子数据生成(环形坐标+自定义属性,适配白色星环) ==========
  const count = 100000; // 总粒子数:10万,ShaderMaterial高效支撑,无明显卡顿
  const innerRadius = 10, outerRadius = 40; // 星环内外半径:决定环形大小与宽度
  const pointsArr = []; // 粒子顶点坐标数组
  const sizes = []; // 粒子尺寸数组:每个粒子独立,实现差异化光晕
  const shift = []; // 粒子脉动参数数组:每个粒子4个值,实现差异化脉动
  const radii = []; // 粒子环形半径数组:备用,方便后续扩展

  // 辅助函数:生成单个粒子的4个脉动参数,控制脉动节奏与距离
  const pushShift = () => {
    shift.push(
      Math.random() * Math.PI, // 脉动初始相位1:控制起始位置
      Math.random() * Math.PI * 2, // 脉动初始相位2:控制水平方向脉动
      (Math.random() * 0.9 + 0.1) * Math.PI * 0.1, // 脉动频率:控制快慢,舒缓流畅
      Math.random() * 0.7 + 0.05 // 脉动幅度:控制距离,避免粒子跑出星环
    );
  };

  // 循环生成10万粒子数据,保证环形分布均匀,为白色光晕叠加打基础
  for (let i = 0; i < count; i++) {
    // 幂次采样:让粒子均匀分布在环形区域,避免内侧密集、外侧稀疏导致光晕过曝
    const rand = Math.pow(Math.random(), 1.5);
    const radius = Math.sqrt(outerRadius * outerRadius * rand + (1 - rand) * innerRadius * innerRadius);
    radii.push(radius);

    // 圆柱坐标系→直角坐标系:快速生成环形粒子坐标,提升开发效率
    pointsArr.push(
      new THREE.Vector3().setFromCylindricalCoords(
        radius,
        Math.random() * 2 * Math.PI,
        (Math.random() - 0.5) * 2 // 轻微高度:让星环有3D立体感,光晕更丰富
      )
    );

    // 生成粒子独立尺寸:0.3~1.5,实现差异化大小,光晕叠加更自然
    sizes.push(Math.random() * 1.2 + 0.3);

    // 生成粒子独立脉动参数:实现差异化脉动,避免星环脉动过于规则
    pushShift();
  }

  // ========== 3. 构建BufferGeometry(绑定顶点+自定义attribute,传递粒子数据) ==========
  const pointsGeometry = new THREE.BufferGeometry().setFromPoints(pointsArr);

  // 添加自定义attribute:sizes(粒子尺寸),着色器中实现差异化大小
  pointsGeometry.setAttribute('sizes', new THREE.Float32BufferAttribute(sizes, 1));

  // 添加自定义attribute:shift(粒子脉动参数),着色器中实现差异化脉动
  pointsGeometry.setAttribute('shift', new THREE.Float32BufferAttribute(shift, 4));

  // 添加自定义attribute:radii(粒子环形半径),备用扩展
  pointsGeometry.setAttribute('radii', new THREE.Float32BufferAttribute(radii, 1));

  // ========== 4. 创建ShaderMaterial(核心:实现纯净白色、3D脉动、柔和光晕) ==========
  const pointsMaterial = new THREE.ShaderMaterial({
    // 全局uniforms:传递时间与星环半径,驱动全局动画与扩展
    uniforms: {
      uTime: { value: 0 },
      uInnerRadius: { value: innerRadius },
      uOuterRadius: { value: outerRadius }
    },

    // 顶点着色器:处理粒子位置、白色、大小,实现3D脉动与透视优化
    vertexShader: `
      uniform float uTime;
      uniform float uInnerRadius;
      uniform float uOuterRadius;
      attribute float sizes;
      attribute vec4 shift;
      attribute float radii;
      varying vec3 vColor;

      void main() {
        vec3 pos = position;

        // 纯净白色设置:基底0.9+增益0.99,明亮不返白,保留光晕叠加空间
        vColor = vec3(0.9, 0.9, 0.9);
        vColor *= 0.99;

        // 3D脉动动画:球面扰动,实现流畅循环的差异化脉动,提升光晕层次感
        float t = uTime;
        float moveT = mod(shift.x + shift.z * t, 6.28318530718);
        float moveS = mod(shift.y + shift.z * t, 6.28318530718);
        vec3 offset = vec3(
          cos(moveS) * sin(moveT),
          cos(moveT),
          sin(moveS) * sin(moveT)
        ) * shift.w;
        pos += offset;

        // 透视变换:3D坐标→2D屏幕坐标,保证白色星环正确显示
        vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
        gl_Position = projectionMatrix * mvPosition;

        // 透视缩放+最大尺寸限制:近大远小更真实,避免粒子过大导致光晕过曝返白
        gl_PointSize = 0.18 * sizes * (200.0 / -mvPosition.z);
        gl_PointSize = min(gl_PointSize, 5.0);
      }
    `,

    // 片元着色器:处理粒子形状、抗锯齿、柔和光晕,实现纯净白色圆形粒子
    fragmentShader: `
      varying vec3 vColor;

      void main() {
        // 圆形粒子绘制:转换UV坐标,计算到粒子中心的距离
        vec2 uv = gl_PointCoord - 0.5;
        float d = length(uv);

        // 圆形裁剪:丢弃超出圆心的像素,形成圆形轮廓,替代默认方形
        if (d > 0.5) discard;

        // 抗锯齿+柔和光晕:smoothstep实现边缘渐隐,提升白色光晕质感
        float alpha = smoothstep(0.5, 0.05, d);

        // 最终像素颜色:纯净白色+渐变Alpha,实现明亮柔和的光晕效果
        gl_FragColor = vec4(vColor, alpha);
      }
    `,

    // 材质配置:提升白色星环视觉效果,核心是加法混合与透明设置
    transparent: true, // 启用透明,支持边缘渐隐与光晕叠加
    depthTest: false, // 关闭深度测试,允许粒子叠加,光晕更连贯
    blending: THREE.AdditiveBlending, // 加法混合,白色亮度叠加,呈现朦胧光晕
    premultipliedAlpha: false // 关闭预乘Alpha,避免白色发灰,保持纯净通透
  });

  // 创建Points粒子对象,添加到场景,形成最终的纯净白色星环
  const points = new THREE.Points(pointsGeometry, pointsMaterial);
  scene.add(points);

  // ========== 5. 动画循环(驱动脉动+更新渲染,实现流畅白色星环效果) ==========
  const clock = new THREE.Clock();

  function animate() {
    requestAnimationFrame(animate);

    // 更新全局时间,驱动着色器脉动动画,减慢速度提升观察体验
    const t = clock.getElapsedTime() * 0.5;
    pointsMaterial.uniforms.uTime.value = t * Math.PI;

    // 星环整体旋转,增加场景活力,白色光晕变化更丰富
    points.rotation.y = t * 0.05;

    // 更新轨道控制器阻尼,保证顺滑交互
    controls.update();

    // 渲染场景,呈现最终的纯净白色星环效果
    renderer.render(scene, camera);
  }

  // 启动动画循环,开始运行白色星环的脉动与渲染
  animate();

  // ========== 6. 窗口适配(响应式调整,适配不同屏幕尺寸) ==========
  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });
  </script>
</body>
</html>

总结与扩展建议

核心总结

  1. 高性能核心ShaderMaterial将动画与渲染逻辑移至GPU并行处理,可轻松应对10万级甚至百万级粒子,性能远超普通JS驱动的粒子系统,这是实现流畅白色星环的基础。
  2. 数据传递核心:自定义attribute传递粒子独立数据,uniform传递全局统一数据,varying实现着色器间数据平滑插值,这是Three.js着色器开发的核心范式,适用于所有复杂粒子场景。
  3. 白色星环核心避坑
    • 白色基底值设为<1.0,全局增益设为≤0.99,为加法混合预留光晕叠加空间,避免过曝返白;
    • 限制粒子最大尺寸,避免远处粒子过大导致光晕叠加过曝;
    • 幂次采样保证粒子均匀分布,避免局部密集导致光晕过亮。
  4. 视觉效果核心
    • 圆柱坐标系快速生成环形粒子,幂次采样保证分布均匀,为白色光晕叠加打下基础;
    • 顶点着色器实现3D脉动,片元着色器实现圆形抗锯齿与柔和光晕;
    • AdditiveBlending加法混合实现白色亮度叠加,营造朦胧光晕,提升星环层次感。
  5. 透视优化核心:粒子大小随相机距离缩放,实现「近大远小」的真实透视效果,避免场景失真,提升白色星环的视觉真实感。

扩展建议

  1. 白色星环效果扩展
    • 动态调整白色亮度:通过uniform传递白色增益值,让星环随时间实现「明暗呼吸」效果,提升场景张力;
    • 调整光晕强弱:通过修改smoothstep的渐隐参数,实现光晕的「浓淡变化」,适配不同视觉风格;
    • 多环叠加:创建多个不同半径、不同脉动速度的白色星环,形成星系效果,提升场景复杂度。
  2. 功能扩展
    • 交互增强:绑定鼠标位置,让白色星环跟随鼠标旋转、脉动,提升交互体验;
    • 参数控制面板:提供可视化面板,允许用户调整星环半径、粒子数、脉动速度、白色亮度等参数,实时预览效果;
    • 响应式优化:根据设备性能自动调整粒子数,低配设备减少粒子数,保证流畅性,高配设备增加粒子数,提升细腻度。
  3. 性能优化
    • 使用InstancedBufferGeometry替代BufferGeometry,进一步减少DrawCall,支持更多粒子(百万级);
    • 开启渲染器的powerPreference: "high-performance",优先使用高性能GPU,提升渲染效率;
    • 剔除不可见粒子:通过视锥体裁剪,剔除屏幕外的粒子,减少GPU渲染开销。
  4. 视觉风格扩展
    • 添加轻微蓝色/银色调:在白色基底中加入少量蓝色(如vec3(0.9, 0.9, 1.0)),营造冷色调科技感星环;
    • 添加辉光效果:结合THREE.UnrealBloomPass后期处理,增强白色星环的辉光感,提升视觉冲击力。

# Vue3 音频标注插件 wavesurfer

作者 叫我AddV
2026年2月2日 17:20

Vue3 音频标注插件 wavesurfer

最近前端在开发一个音频标注软件,需要加载一个mp3格式文件,然后展示出mp3音频文件的声波,然后通过鼠标在声波拖拽的方式,对某个时间段进行标注功能,记录出标注时间段的开始时间和结束时间,同时可以打标签,比如该区域是“发言人一”这类操作。

前期

这个功能看上去简单,但是实际开发起来还是有点难度的,首先是加载mp3音频数据,后端提供一个mp3音频文件的链接,比如:http://xxx/xxx/1.mp3,然后前端需要拿到对应的音频文件,将音频文件的声波显示出来。

起初没打算用插件,自己用canvas绘制了一下声波,通过鼠标事件,也成功的实现了需要的效果,还不错,除了有点Low,当然可以通过修改样式进行优化页面:

在这里插入图片描述

这样子之后,我发现加载几分钟的音频是没有什么问题的,但是加载像是长音频就会出现一些问题,最大的问题就是当前时间轴和音频播放位置对应不起来,会有几百毫秒的偏差,在一个就是加载音频声波时间太长了(当然使用插件绘制也有同样的问题),主要是获取到音频后,需要解码获取声波,所以时间越久解码时间越久。但是自己写的话,最大的好处就是你想怎么改就怎么改,想实现什么功能就实现什么功能,但是最后迫于某些原因,再加上时间不够,工期压的很紧,根本没时间去一点点维护和迭代,果断放弃了自己写,采用了插件 —— wavesurfer.js

wavesurfer.js 安装

vue3 安装 wavesurfer 很简单,和其他 vue 安装插件一样:

npm i wavesurfer

等待安装完成就可以了。

在这里插入图片描述

我项目安装的版本是 7.12.1,是开发时候的最新版本。

使用

安装完成之后,就可以使用了。

首先呢,这个插件怎么说呢,比我想象中的难用,但是他确实帮我完成了很多功能,API暴露出的参数和函数上来说,很多我觉得可以提供的API或者参数,他都没有,所以说很多逻辑需要自己需写,但是我不确定后期会不会加上。

使用的话提供的API倒是也很简单,比如加载一个音频,展示声波:

首先需要引用以下必要的插件:

import WaveSurfer from 'wavesurfer.js'
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.js'
import TimelinePlugin from 'wavesurfer.js/dist/plugins/timeline.esm.js'

然后编写一个函数用来加载:

// 加载音频声波,url为音频链接
const initAudio = (url = "") => {
  if (!waveformRef.value) return
  // 销毁现有的实例
  if (wavesurfer.value) { destroyAudio() }
  annoList.value = JSON.parse(JSON.stringify(list))
  regionsPlugin.value = RegionsPlugin.create()
  // 创建新的 Wavesurfer 实例
  wavesurfer.value = WaveSurfer.create({
    container: waveformRef.value,
    waveColor: '#4a90e2',
    progressColor: '#A0CFFF',
    cursorColor: '#000',
    cursorWidth: 1,
    barWidth: 2,
    barRadius: 3,
    barGap: 3,
    height: 150,
    responsive: true,
    normalize: true,
    backend: 'WebAudio',
    plugins: [regionsPlugin.value, TimelinePlugin.create()],
  })
  // 音频加载完成
  wavesurfer.value.on('ready', () => {
    const totalDuration = wavesurfer.value.getDuration(); // 单位:秒
    audioTotalTime.value = totalDuration
  });
  // 音频播放时,更新当前时间
  wavesurfer.value.on('audioprocess', (currentTime) => {
    audioCurrentTime.value = currentTime  // 更新当前时间
  });
  // 点击 waveform 时,更新当前时间
  wavesurfer.value.on('click', () => {
    audioCurrentTime.value = wavesurfer.value.getCurrentTime()  // 点击鼠标时候获取当前时间
  });
  // 监听是否正在播放
  wavesurfer.value.on('play', () => {
    isPlaying.value = true  // 正在播放
  })
  // 暂停播放
  wavesurfer.value.on('pause', () => {
    isPlaying.value = false  // 暂停播放
  })
  // 播放完成
  wavesurfer.value.on('finish', () => {
    isPlaying.value = false   // 暂停播放
  })
  // 加载音频
  wavesurfer.value.load(url)
}

看一下效果:

在这里插入图片描述

拖拽绘制区域

首先鼠标绘制标注区域的功能是怎么实现呢,鼠标移动到声波上面,按下鼠标左键,创建一个临时(temp-前缀)的矩形区域,当鼠标移动的时候,随时更新临时矩形宽度,跟随鼠标绘制。鼠标松开后,删除临时矩形,绘制正经的标注矩形。

注册鼠标事件

首先我们需要注册鼠标事件,包括鼠标按下、鼠标移动、鼠标抬起、鼠标离开;

鼠标按下: 开始准备数据,在鼠标按下后,若移动,说明正在绘制矩形;若点击后抬起,则不是绘制;

鼠标移动: 如果是绘制矩形标注,则需要创建一个临时的矩形标注,跟随鼠标位置动态设置矩形宽度,可视化绘制区域;

鼠标抬起: 如果是绘制矩形标注,则抬起的时候,删除临时标注,生成一个正式的标注区域;

鼠标离开: 如果正在绘制矩形,鼠标移动的过程中脱离了声波区域,则删除绘制,当没有绘制处理;

创建鼠标监听

可以创建个函数,在初始化的时候,调用这个方法开启鼠标监听:

  container.addEventListener('mousedown', handleMouseDown)
  container.addEventListener('mousemove', handleMouseMove)
  container.addEventListener('mouseup', handleMouseUp)
  container.addEventListener('mouseleave', handleMouseLeave)
鼠标按下事件

首先,鼠标按下不一定就是绘制,也可能就是单纯的点击,什么时候算绘制呢?第一是鼠标按下,第二是鼠标拖拽,只有鼠标按下后拖拽才算绘制。

在这里插入图片描述

其次,鼠标可能在已有的矩形标注上点击,这个就有歧义了,也不算歧义,比如我鼠标点在绿色的标注上,这个时候是想拖拽已有的绿色标注还是要绘制新的标注呢?这个需要通过业务来确定,这里就当拖拽已有的绿色标注,不再绘制新标注。

所以,在鼠标按下的时候,我们需要定义两个参数,一个参数用来表明是不是点击到已有的标注上了,如果点击到已有的标注上了的话,那么鼠标拖拽的时候什么也不处理,直接让wavesurfer插件自己处理就可。

所以在鼠标按下的时候,我们需要先判断是不是点击在了已有的标注上,如果点击在了已有的标注上则啥也不用干了。如果没有,则需要想办法根据点击的坐标,计算出标注的开始时间。

// 鼠标按下事件
const handleMouseDown = (e) => {
  if (e.button !== 0) return
  const container = waveformRef.value
  // 检查是否点击在已有区域上
  const clickedRegion = checkClickOnRegion(e)
  if (clickedRegion) {
    isDraggingRegion = true
    return // 如果是点击区域,让 WaveSurfer 自己处理拖拽
  }
  // 开始绘制新区域
  isDraggingRegion = false
  isDrawing.value = true
  const rect = container.getBoundingClientRect()  
  const x = e.clientX - rect.left
  // 计算开始时间
  const duration = wavesurfer.value.getDuration()
  drawStartX.value = x
  drawStartTime.value = (x / rect.width) * duration
  // 移除旧的临时区域(如果存在)
  if (tempRegion.value) {
    tempRegion.value.remove()
    tempRegion.value = null
  }
  container.style.cursor = 'col-resize'
}

检查是不是点击在已有标注上的函数:

// 检查是否点击在已有区域上
const checkClickOnRegion = (event) => {
  if (!regionsPlugin.value || !regions.value.length) return null
  // 获取点击位置
  const rect = waveformRef.value.getBoundingClientRect()
  const clickX = event.clientX - rect.left
  // 计算点击时间
  const duration = wavesurfer.value.getDuration()
  const clickTime = (clickX / rect.width) * duration
  // 检查是否点击在任何区域内
  for (const region of regions.value) {
    if (clickTime >= region.start && clickTime <= region.end) {
      return region
    }
  }
  return null
}
鼠标拖拽事件

鼠标拖拽监听,什么时候是绘制标注呢?是鼠标点下后的拖拽才说明是在拖拽。如果鼠标点击是在已有的标注上,则直接停止处理就可以了,全有wavesurfer插件来处理已有标注拖拽,不需要我们自己写代码实现。

如果不是点击在了然后我们判断isDrawing参数是不是true,如果是true说明鼠标已经按下了,然后我们就需要获取鼠标实时位置,从而计算出标注的结束时间,然后就可以操作临时区域,如果没有临时区域就创建,如果有临时区域了的话就修改临时区域的开始时间和结束时间就可以了。

// 鼠标移动事件
const handleMouseMove = (e) => {
  if (isDraggingRegion) return
  const container = waveformRef.value
  if (isDrawing.value) {
    const rect = container.getBoundingClientRect()
    const currentX = e.clientX - rect.left
    // 计算当前时间
    const duration = wavesurfer.value.getDuration()
    const currentTime = (currentX / rect.width) * duration
    // 确定开始和结束时间
    const startTime = Math.min(drawStartTime.value, currentTime)
    const endTime = Math.max(drawStartTime.value, currentTime)
    // 确保区域有最小长度
    if (endTime - startTime < 0.01) return
    if (!tempRegion.value) {
      // 创建临时区域
      tempRegion.value = regionsPlugin.value.addRegion({
        id: `temp-${Date.now()}`,
        start: startTime,
        end: endTime,
        color: '#90939933',
        drag: false,
        resize: false
      })
    } else {
      // 更新现有临时区域
      try {
        // 直接更新区域的 start 和 end 属性
        tempRegion.value.setOptions({
          start: startTime,
          end: endTime
        })
      } catch (error) {
        // 如果更新失败,重新创建
        tempRegion.value.remove()
        tempRegion.value = regionsPlugin.value.addRegion({
          id: `temp-${Date.now()}`,
          start: startTime,
          end: endTime,
          color: '#90939933',
          drag: false,
          resize: false
        })
      }
    }
  }
}
鼠标抬起事件

鼠标抬起处理的事情有点小多了,首先你在鼠标按下的时候判断了是不是点击在了已有标注上,如果是的话,鼠标抬起后需要把isDraggingRegion参数重置回false,然后return就可以了,不需要其他的处理。

如果鼠标点击了,现在抬起来之后,则需要重置一下isDrawing参数重置为false

然后还要判断一下,有没有临时标注,如果有临时标注的话,说明是绘制的,这个时候需要根据临时区域创建一个正经的标注区域。然后在把临时的标注区域删除掉。

// 鼠标释放事件
const handleMouseUp = (e) => {
  if (isDraggingRegion) {
    isDraggingRegion = false
    return // 让 WaveSurfer 处理区域拖拽的结束
  }
  const container = waveformRef.value
  if (!isDrawing.value) return
  isDrawing.value = false
  container.style.cursor = 'default'
  // 如果没有临时区域,直接返回
  if (!tempRegion.value) { return }
  const rect = container.getBoundingClientRect()
  const currentX = e.clientX - rect.left
  // 计算结束时间
  const duration = wavesurfer.value.getDuration()
  const currentTime = (currentX / rect.width) * duration
  const startTime = Math.min(drawStartTime.value, currentTime)
  const endTime = Math.max(drawStartTime.value, currentTime)
  // 如果区域太小,删除临时区域
  if (endTime - startTime < 0.1) { // 增加最小长度到0.1秒
    tempRegion.value.remove()
    tempRegion.value = null
    return
  }
  // 创建永久区域
  createPermanentRegion(startTime, endTime)
  // 清除临时区域
  tempRegion.value.remove()
  tempRegion.value = null
}

创建临时区域的话,是下面的函数:

// 创建永久区域
const createPermanentRegion = (startTime, endTime) => {
  if (!regionsPlugin.value) return nul
  const regionId = `${uuidv4()}`
  const tempRegions = regionsPlugin.value.getRegions().filter(r => r.id.startsWith('temp-'))
  tempRegions.forEach(region => region.remove())
  regionsPlugin.value.regions = regionsPlugin.value.regions.filter(r => !r.id.startsWith('temp-'))
  // 创建永久区域
  const region = regionsPlugin.value.addRegion({
    id: regionId,
    start: startTime,
    end: endTime,
    color: '#90939933',
    drag: true,
    resize: true,
    minLength: 0.1,
    content: "标注",
  })
  region.element.id = regionId;
}
鼠标移除事件

如果鼠标在移出声波这个区域的时候,说明不想绘制了,我们就直接取消绘制就可以了,把该重置的数据重置了就可以了。

// 鼠标离开事件
const handleMouseLeave = () => {
  if (isDrawing.value) {
    const container = waveformRef.value
    isDrawing.value = false
    container.style.cursor = 'default'
    // 删除临时区域
    if (tempRegion.value) {
      tempRegion.value.remove()
      tempRegion.value = null
    }
  }
  isDraggingRegion = false
}

在页面卸载的时候不要忘记销毁监听事件嗷

  container?.removeEventListener('mousedown', handleMouseDown)
  container?.removeEventListener('mousemove', handleMouseMove)
  container?.removeEventListener('mouseup', handleMouseUp)
  container?.removeEventListener('mouseleave', handleMouseLeave)

标注事件监听

我们可以监听一下标注事件。

  regionsPlugin.value.on('region-created', (region) => {
    // 创建完成回调
    
    // 监听区域更新完成
    region.on('update-end', () => {
    // 修改完成回调
    })
  })

  // 监听标注区域点击
  regionsPlugin.value.on('region-clicked', (region, e) => {
    e.stopPropagation()
   // 点击标注回调
  })

相关文档

wavesurfer.xyz/docs/types/…

wavesurfer.xyz/examples/?t…

数据工程指南:指标平台选型避坑与 NoETL 语义编织技术解析

2026年2月2日 16:49

本文首发于 Aloudata 官方技术博客:《指标平台选型避坑指南:数据负责人必看,如何根治口径乱、响应慢、成本贵》转载请注明出处。

摘要:本文面向数据架构师与数据负责人,深度剖析指标平台选型中“口径乱、响应慢、成本贵”三大核心短板的技术根因与隐性成本。重点解析 Aloudata CAN 如何通过 NoETL 语义编织技术构建统一语义层,实现“定义即开发、定义即治理、定义即服务”,从而根治传统顽疾,并提供一套结合量化成效的选型决策评估框架。

引言:指标平台选型,为何总在“不可能三角”中妥协?

“全球至少有 80% 的工业数据依然被锁在各自的孤岛,如果这些沉睡的数据被唤醒和打通,如果隐藏其中的规律被算法照亮,将会为产业升级释放出巨大价值。” —— 某家电制造业全球执行副总裁

这不仅是制造业的困境,更是所有数据驱动型企业的缩影。数据负责人在选型时,普遍面临一个残酷的“数据分析不可能三角”:口径统一、敏捷响应、成本可控,三者难以兼得。

其根源在于传统“数仓+BI”模式的架构瓶颈:

  • 口径统一:依赖人工在物理宽表(DWS/ADS)上定义指标,不同报表、不同 BI 工具间同名不同义,导致决策依据混乱。
  • 敏捷响应:一个分析需求需经历“需求沟通 → ETL 开发排期 → 测试上线”的漫长链路,动辄数周,无法满足业务快速决策。
  • 成本可控:为满足层出不穷的报表需求,数据团队重复建设大量宽表和汇总表,导致存储和计算资源(TCO)急剧膨胀。

当企业试图通过“上线报表平台”或部署“静态元数据目录”来解决问题时,往往发现投产比远低于预期,数据治理陷入“叫好不叫座”的尴尬境地。问题的本质在于,传统的“物理建模”范式,已无法应对业务灵活多变的分析需求。

决策评估第一步:识别三类核心短板及其隐性成本

选型失误的代价巨大。根据 IT之家对数据治理平台的测评,企业核心痛点聚焦于“数据割裂、数据不可信、数据难复用”。映射到指标平台领域,则具体表现为以下三类短板,其隐性成本远超软件采购费用本身。

核心短板 业务表现 技术根因 隐性成本
口径乱 业务与 IT、部门与部门间对同一指标(如“活跃用户”、“毛利率”)定义不一致,会议沦为“数据辩论会”。 指标定义与物理宽表强耦合,缺乏企业级唯一语义定义层。 决策失误风险、跨部门协作内耗、数据信任体系崩塌。
响应慢 业务一个简单的“按新维度看数”需求,需要排期 2-3 周等待 ETL 开发,错失市场时机。 分析路径被预建的物理宽表固化,任何变更都需要底层数据开发。 业务敏捷性丧失、分析师产能闲置、创新试错成本高昂。
成本贵 数据仓库中充斥着大量字段相似、逻辑雷同的宽表,存储和计算费用居高不下,且难以治理。 “烟囱式”开发模式,为每个报表需求单独建表,缺乏跨需求的智能复用机制。 基础设施 TCO 持续攀升,资源利用率低下,技术债日益沉重。

短板一:根治“口径乱”——从静态目录到动态语义引擎

传统指标平台或 BI 内置的指标模块,本质是静态的元数据目录(Catalog)。它们仅记录“指标 A 来自宽表 B 的字段 C”,但无法保证当业务逻辑变化时,所有引用该指标的地方能同步更新。指标口径依赖人工治理和沟通,极易出现偏差。

Aloudata CAN 的根治方案:构建统一语义层(虚拟业务事实网络)

其核心是引入一个与物理存储解耦的语义引擎。数据团队无需预先物理打宽,只需在 Aloudata CAN 中通过声明式策略,基于 DWD 明细数据定义业务实体(如表)之间的逻辑关联(Join)。系统据此在逻辑层面构建一个“虚拟明细大宽表”或“虚拟业务事实网络”。

  • 定义即治理:当业务人员需要定义新指标(如“近 30 天高净值客户交易金额”)时,直接在语义层配置“基础度量(交易金额)”、“业务限定(客户标签=高净值)”、“统计周期(近30天)”。系统在创建时会自动进行判重校验,确保全平台口径唯一。
  • 复杂指标表达能力:支持多层嵌套聚合、指标转标签(如“上月交易量>0的用户”)、自定义日历(如“近5个交易日”)等复杂业务逻辑,通过配置而非编码实现。

权威背书:某头部券商(平安证券)在落地 Aloudata CAN 后,实现了全公司 100% 的指标口径一致,彻底消除了因数据定义分歧导致的决策争议。

短板二:根治“响应慢”——从人工 ETL 到自动化指标生产

在传统模式下,响应慢的症结在于“物理实现”的强依赖。每一个新的分析维度组合,都可能意味着一次新的 ETL 任务开发、测试和上线,周期以“天”或“周”计。

Aloudata CAN 的根治方案:声明式指标定义 + 智能物化加速引擎

  1. 声明式定义,分钟级交付:业务分析师或数据产品经理在统一的语义层中,通过拖拽和配置即可完成新指标或新分析视角的定义。系统自动将其翻译为优化的 SQL 查询逻辑,实现“定义即开发”,将需求响应时间从数周缩短至分钟级。
  2. 智能物化,秒级响应:对于高频或重要的查询,管理员可以基于声明式策略配置物化加速任务(如“将‘销售额按省份和品类’的日汇总结果提前计算”)。系统自动编排和维护这些物化视图。
  3. 透明路由,性能保障:当用户发起查询时,语义引擎会自动进行 SQL 改写,并智能路由到最优的物化结果上,实现“空间换时间”。在百亿级数据规模下,可保障 P90 响应时间 <1 秒,P95 <3 秒。

权威背书:某汽车企业应用后,指标开发效率从原来的 1 天 3.1 个 提升至 1 天 40 个,效率提升约 13 倍,有力支撑了其多平台(BI、分析平台、AI)的指标服务需求。

短板三:根治“成本贵”——从重复建表到做轻数仓

成本高的本质是数据资产的“重复建设”和“低效复用”。大量计算和存储资源消耗在维护逻辑相似、生命周期短暂的中间表上。

Aloudata CAN 的根治方案:基于明细层定义,智能复用物化结果

  • 做轻数仓:Aloudata CAN 倡导直接基于 DWD 明细层定义指标,无需建设繁重的 DWS/ADS 物理宽表层。这从源头上遏制了宽表的无序膨胀。
  • 智能复用:其智能物化加速引擎具备自动判重能力。当多个指标或查询请求共享相同的计算逻辑和维度粒度时,系统只会生成和维护一份物化结果,并被所有相关查询智能复用。
  • 成本可视化:平台清晰展示语义资产和物化资产的使用频率与资源消耗,辅助管理员优化物化策略,实现精细化的成本治理。

实际客户数据显示,通过上述机制,可有效减少 70% 以上的指标开发维护成本,整体基础设施成本(TCO)节约可达 50%,并释放超过 1/3 的服务器资源。

选型决策矩阵:如何评估平台是否真正“根治”短板?

参考 IT之家提出的企业选型五步指南(明确需求、技术适配、协作效率、生态兼容),并结合指标平台特性,我们提炼出以下四个核心评估维度,帮助您穿透营销话术,直击本质。

评估维度 关键问题 传统方案 / 静态目录型平台 Aloudata CAN NoETL 指标平台
本质定位 平台是“记录者”还是“计算者”?指标定义是否与物理表强绑定? 静态元数据目录:仅记录指标出处,依赖底层已存在的物理宽表。 动态语义计算引擎:在逻辑语义层定义指标,直接基于 DWD 明细数据动态计算,无需预建宽表。
技术架构 如何平衡灵活性与性能?能否支持复杂业务逻辑(如留存率、指标转标签)? 灵活性差:分析路径受限于预建宽表。性能依赖人工优化:需 DBA 手动创建索引、汇总表。 声明式物化加速:基于策略自动生成和维护物化视图,查询时智能路由。原生复杂指标:支持多层聚合、自定义周期等。
开放生态 指标能否作为统一资产服务全企业?是否与现有技术栈解耦? 封闭或绑定:BI 内置指标锁定特定前端;部分平台与特定云或数仓深度绑定。 Headless 开放基座:通过标准 API、JDBC 向任何 BI、AI、业务系统提供统一指标服务。与底层数据湖仓解耦。
AI 适配 平台是否为 AI 和大模型提供了高质量、可理解、安全的数据接口? 难以适配:AI 需直接面对杂乱物理表,幻觉风险高,安全管控难。 AI-Ready 原生设计:NL2MQL2SQL架构根治幻觉;语义知识图谱赋能 RAG;标准化 Function Calling 提供指标归因等高级能力;内置 AI 访问控制层。

行动指南:从选型到落地的“三步走”资产演进策略

选择正确的平台后,平稳落地是关键。我们推荐采用渐进式的“三步走”技术策略,最小化迁移风险,最大化投资回报。

  1. 存量挂载:将逻辑成熟、质量稳定、查询性能尚可的现有宽表,直接挂载到 Aloudata CAN 的语义层。零开发成本,即可实现这些历史资产口径的统一管理和对外服务。
  2. 增量原生:所有新产生的分析需求,不再走传统 ETL 建宽表的老路。直接基于 DWD 明细数据,在 Aloudata CAN 的语义层中进行配置化定义和开发,敏捷响应业务。
  3. 存量替旧:随着新模式的稳定运行,逐步评估并下线那些维护成本高、逻辑变更频繁、资源消耗巨大的“包袱型”旧宽表,将其逻辑迁移至语义层,完成架构的彻底优化。

FAQ

Q1: 指标平台和 BI 工具自带的指标功能有什么区别?

BI 内置指标功能旨在增强特定 BI 工具的粘性,指标被锁定在该前端,且不同 BI 工具间的指标口径易不一致。Aloudata CAN 作为中立的 Headless 指标基座,通过标准 API/JDBC 提供全企业统一的指标服务,确保一处定义、处处一致,并支持向任意消费端(BI、AI、业务系统)开放。

Q2: 引入新的指标平台,如何与我们现有的数据仓库集成?

Aloudata CAN 设计为与现有数据湖仓解耦的语义层。它通过标准连接器对接底层 DWD 明细数据,无需改变原有存储和计算引擎。实际客户已验证其与主流数据湖仓的良好兼容性,实现快速落地。

Q3: 如何量化指标平台带来的 ROI(投资回报率)?

ROI 可从三个维度量化:技术降本(减少宽表开发、释放服务器资源)、效率提升(需求交付周期从周/天缩短至分钟级)、业务价值(因决策加速和口径统一带来的收入增长或风险降低)。参考案例显示,指标开发效率可提升 10 倍以上,基础设施成本节约可达 50%。

Q4: 指标平台如何支持未来的 AI 应用和大模型?

Aloudata CAN 原生具备 AI-Ready 能力。其语义知识图谱为 RAG 提供高质量业务语境;NL2MQL2SQL架构将自然语言问题转化为精准的指标查询,根治大模型幻觉;标准化 Function Calling让 AI 能像调用 API 一样使用指标归因等复杂能力。

核心要点

  1. 架构范式革新:根治指标顽疾的关键,是从“物理建模”转向“语义建模”。Aloudata CAN 的 NoETL 语义编织技术,通过构建与存储解耦的统一语义层,实现了指标的逻辑定义与物理执行的分离。
  2. 三位一体价值:通过“定义即开发、定义即治理、定义即服务”的核心理念,同步解决口径乱(100%一致)、响应慢(效率提升10倍)、成本贵(TCO降低50%)三大核心短板,打破“数据分析不可能三角”。
  3. 面向未来的底座:一个合格的指标平台不应仅是报表的支撑,更应是 AI-Ready 的数据底座。Aloudata CAN 原生的 NL2MQL2SQL 架构、语义知识图谱和标准化 API,为企业安全、高效地拥抱 AI 提供了必经之路。

本文首发于 Aloudata 官方技术博客,查看更多技术细节与客户案例,请访问原文链接:ai.noetl.cn/knowledge-b…

uni-app 小程序(兼容鸿蒙)多参数传递避坑:eventChannel 完胜 URL & 拼接

作者 渔_
2026年2月2日 16:45

微信图片_20260202164502_81_66.jpg

做 uni-app 小程序开发的同学,尤其是需要兼容鸿蒙环境的,大概率都踩过 URL 拼接 & 传递多参数 的坑 —— 要么参数丢失、要么特殊字符解析异常、要么鸿蒙环境直接不兼容。

今天给大家分享一种更优雅、更稳定的多参数传递方案:eventChannel 事件通道,亲测在微信小程序、支付宝小程序、鸿蒙环境下都能稳定运行,告别 URL 拼接的各种糟心事。

一、为什么不推荐 URL 拼接 & 传递多参数?

先说说我们以前常用的 URL 拼接方式,代码大概是这样的:

// 不推荐的 URL 拼接方式
uni.navigateTo({
  url: `/pages/work/partslist/expanded-list?id=${item.id}&fileSrc=${item.fileSrc}`
});

这种方式在简单场景下可行,但存在明显弊端:

  1. 兼容性差:鸿蒙环境对 uni-app 的 URL 拼接支持不友好,容易出现参数丢失、页面跳转失败的问题。
  2. 特殊字符问题:如果 fileSrc 是一个完整的图片 / 文件链接(包含 http:///? 等特殊字符),会被解析为 URL 的一部分,导致接收页获取的参数错乱。
  3. 可读性差:多参数拼接后,URL 冗长杂乱,后期维护困难。
  4. 数据类型限制:只能传递字符串类型,数字、对象等类型需要手动转换,容易出现类型错误。

eventChannel 作为 uni-app 提供的官方事件通道方案,完美解决了以上所有问题 —— 支持传递任意类型数据、无需处理特殊字符、兼容性更好、代码更优雅。

二、完整实现方案(发送页 + 接收页)

前置说明

  • 适用场景:uni.navigateTouni.redirectTo 页面跳转(不支持 uni.switchTab,因为 switchTab 会关闭其他页面)。
  • 核心逻辑:发送页跳转成功后,通过 eventChannel 发送数据;接收页在生命周期中获取 eventChannel,监听并接收数据。

第一步:发送页(跳转页)实现

这是触发页面跳转的代码,重点在 uni.navigateTosuccess 回调中发送数据:

// 列表点击或其他触发跳转的方法
handleJumpToExpandedList(item) {
  // 先做数据校验,避免空数据传递
  if (!item || (!item.id && !item.fileSrc)) {
    uni.showToast({
      title: '数据异常,无法跳转',
      icon: 'none'
    });
    return;
  }
  
  uni.navigateTo({
    // 注意:URL 无需拼接任何参数,保持纯净路径即可
    url: `/pages/work/partslist/expanded-list`,
    success: (res) => {
      // 跳转成功后,获取事件通道并发送数据
      res.eventChannel.emit('passData', {
        id: item.id, // 支持数字、字符串等类型
        fileSrc: item.fileSrc, // 支持完整链接、特殊字符,无需转义
        // 还可以传递对象、数组等复杂数据,示例:
        // info: { name: '测试', status: 1 },
        // list: [1, 2, 3]
      });
    },
    fail: (err) => {
      console.error('页面跳转失败:', err);
      uni.showToast({
        title: '页面跳转失败',
        icon: 'none'
      });
    }
  });
}

发送页关键要点

  1. URL 保持纯净,不拼接任何参数,避免解析冲突。
  2. res.eventChannel.emit('事件名', 传递的数据):第一个参数是自定义事件名(后续接收页需要对应),第二个参数是任意格式的数据源(对象、数组、基本类型均可)。
  3. 增加数据校验和 fail 回调,提升代码健壮性,避免用户看到空白页面或报错。

第二步:接收页(目标页)实现

目标页需要获取事件通道,并监听发送页定义的事件,从而接收数据,核心注意点:调用时机

<template>
  <!-- 你的页面结构 -->
  <view>接收的商品ID:{{ goodsId }}</view>
  <view>接收的文件链接:{{ fileSrc }}</view>
</template>

<script>
export default {
  data() {
    return {
      goodsId: '', // 接收的id
      fileSrc: '', // 接收的文件链接
      // info: {}, // 接收复杂对象(可选)
      // list: [] // 接收数组(可选)
    };
  },
  onLoad() {
    // 关键:必须在 onLoad / onShow 生命周期中获取事件通道
    // 鸿蒙环境下,created 生命周期中调用会返回 undefined,踩坑!
    const eventChannel = this.getOpenerEventChannel();
    
    if (eventChannel) {
      // 监听发送页定义的 'passData' 事件,与发送页的事件名保持一致
      eventChannel.on('passData', (data) => {
        console.log('接收成功的数据:', data);
        
        // 赋值给当前页面的变量,用于后续业务逻辑
        if (data.id) {
          this.goodsId = data.id;
        }
        if (data.fileSrc) {
          this.fileSrc = data.fileSrc;
        }
        // 复杂数据赋值(可选)
        // if (data.info) {
        //   this.info = data.info;
        // }
        
        // 调用依赖参数的初始化方法(如请求接口、渲染列表)
        this.initListData();
      });
    } else {
      console.error('获取事件通道失败,无法接收参数');
    }
  },
  methods: {
    // 你的业务初始化方法(依赖接收的参数)
    initListData() {
      if (!this.goodsId) {
        console.warn('商品ID为空,无法正常初始化列表');
        return;
      }
      
      // 后续业务逻辑:如根据 goodsId 和 fileSrc 请求接口
      console.log('初始化列表,参数:', this.goodsId, this.fileSrc);
      // ...你的接口请求、列表渲染等代码
    }
  }
};
</script>

接收页关键要点(避坑重点!)

  1. 调用时机this.getOpenerEventChannel() 必须在 onLoadonShow 生命周期中调用,不能在 created 或非生命周期中调用,尤其是鸿蒙环境下,否则会返回 undefined,无法获取事件通道。
  2. 事件名一致:eventChannel.on('passData', ...) 中的事件名,必须和发送页 emit 的事件名完全一致(大小写敏感)。
  3. 数据判空:接收数据后做判空处理,避免因数据异常导致后续业务逻辑报错。

三、鸿蒙环境额外注意事项

  1. 避免在接收页延迟调用 getOpenerEventChannel(),页面加载完成后再调用,大概率会获取失败。
  2. 传递复杂数据(如大对象、长数组)时,优先使用 eventChannel,比 storage 更高效,且不会造成缓存污染。
  3. 数据类型保持一致:发送页传递的数字类型,接收页无需手动转换,直接使用即可,避免类型不匹配导致的业务问题。

四、备选方案(若 eventChannel 偶发兼容问题)

如果在个别特殊环境下,eventChannel 出现偶发问题,可以使用 uni.setStorageSync 临时存储参数作为备选,步骤如下:

// 发送页:临时存储参数
handleJumpToExpandedList(item) {
  uni.setStorageSync('expandedListParams', {
    id: item.id,
    fileSrc: item.fileSrc
  });
  
  uni.navigateTo({
    url: `/pages/work/partslist/expanded-list`
  });
}

// 接收页 onLoad 中读取并删除临时存储
onLoad() {
  const params = uni.getStorageSync('expandedListParams');
  if (params) {
    this.goodsId = params.id || '';
    this.fileSrc = params.fileSrc || '';
    this.initListData();
    // 读取后立即删除,避免缓存污染和数据泄露
    uni.removeStorageSync('expandedListParams');
  }
}

注意:该方案适合简单场景,不推荐传递大体积数据,且需要手动删除临时存储,否则会占用小程序缓存空间。

五、总结

  1. uni-app 小程序(兼容鸿蒙)传递多参数,优先使用 eventChannel 事件通道,完胜 URL & 拼接。
  2. 核心避坑点:接收页在 onLoad 生命周期中调用 this.getOpenerEventChannel(),确保事件通道获取成功。
  3. eventChannel 支持任意数据类型、无需处理特殊字符、兼容性更好,是 uni-app 页面多参数传递的最优解。

使用 Python 实现 Flutter 项目的自动化构建与发布

作者 明君87997
2026年2月2日 16:36

前言

作为公司唯一的移动端开发,我需要同时负责 5 个 Flutter App 的开发和维护工作。每个应用都需要支持 iOS 和 Android 双平台,这意味着每次发版我可能要进行多达 10 次的打包操作。如果每次都手动执行构建、上传、通知这些重复性工作,不仅耗时巨大,还极易出错。

为了从繁琐的重复劳动中解放出来,把更多精力投入到真正有价值的开发工作中,我使用 Python 编写了一套自动化脚本,实现了一键完成构建、上传和通知的完整流程。

本文将分享我在这个过程中的实践经验和代码实现。

项目背景

公司目前有 5 个移动端应用在同时运营,而移动端开发只有我一个人。每个应用都是基于 Flutter 开发的跨平台应用,需要同时支持 iOS 和 Android 平台。在日常开发中,存在以下痛点:

  1. 项目多、人手少:5 个 App × 2 个平台 = 10 个构建任务,一个人根本忙不过来
  2. 构建流程繁琐:每次打包都需要手动执行多个命令,切换项目、切换环境配置
  3. 上传步骤重复:构建完成后需要手动上传到测试平台(蒲公英),操作机械且耗时
  4. 通知不及时:需要手动通知测试人员新版本已就绪,容易遗漏
  5. iOS 构建环境问题:CocoaPods 缓存问题经常导致构建失败,排查费时费力

面对这样的工作强度,自动化不再是"锦上添花",而是"刚需"。

技术方案

我设计了以下几个 Python 脚本来解决这些问题:

python/
├── build_app.py          # 主构建脚本(iOS + Android)
├── build_android_app.py  # Android 单独构建脚本
├── clean_ios_build.py    # iOS 构建环境清理
├── force_clean_ios.py    # 强制清理脚本
├── bulk_email.py         # 群发邮件工具类
├── send_email.py         # 单封邮件发送
└── test_email_auth.py    # 邮箱授权测试

核心实现

1. 自动构建脚本

构建脚本的核心功能是自动执行 Flutter 构建命令,并支持不同环境(开发/生产)的配置。

#!/usr/local/bin/python3

import os
import subprocess

# 获取当前脚本所在目录
script_dir = os.path.dirname(os.path.abspath(__file__))
# Flutter项目根目录(python文件夹在项目根目录下)
flutter_root = os.path.dirname(script_dir)

# 获取用户输入的环境
env = input("请输入环境(dev/prod): ")

# 检查环境配置文件是否存在
env_file = os.path.join(flutter_root, f"{env}.json")
if not os.path.exists(env_file):
    print(f"错误: 环境配置文件 {env}.json 不存在")
    exit(1)

# 切换到Flutter项目根目录
os.chdir(flutter_root)

# 构建Android应用
def build_android(env):
    print("正在构建Android应用...")
    env_text = '生产' if env == 'prod' else '开发'
    print(f"构建版本: {env_text}环境...")
    
    # 构建命令,支持代码混淆
    build_command = f'fvm flutter build apk --release --dart-define-from-file={env}.json --obfuscate --split-debug-info=./build/debug_info'
    
    try:
        process = subprocess.run(
            build_command.split(), 
            stdout=subprocess.PIPE, 
            stderr=subprocess.PIPE, 
            text=True
        )
        print("构建输出:")
        print(process.stdout)
        print("Android构建成功!")
        print("APK文件路径: build/app/outputs/flutter-apk/app-release.apk")
        return True
    except subprocess.CalledProcessError as e:
        print(f"Android构建失败: {e}")
        return False

# 构建iOS应用
def build_ios(env, upload_to_appstore=False):
    print("正在构建iOS应用...")
    env_text = '生产' if env == 'prod' else '开发'
    
    # 根据是否上传App Store选择导出方法
    export_method = "app-store" if upload_to_appstore else "development"
    build_command = f"fvm flutter build ipa --release --export-method {export_method} --dart-define-from-file={env}.json --obfuscate --split-debug-info=./build/debug_info"
    
    try:
        process = subprocess.run(
            build_command.split(), 
            stdout=subprocess.PIPE, 
            stderr=subprocess.PIPE, 
            text=True
        )
        print("构建输出:")
        print(process.stdout)
        print("iOS构建成功!")
        return True
    except subprocess.CalledProcessError as e:
        print(f"iOS构建失败: {e}")
        return False

2. 自动上传到蒲公英

构建完成后,自动将安装包上传到蒲公英测试平台:

import requests

def upload_to_pgyer(env, ipa_path, platform):
    """上传到蒲公英测试平台"""
    print(f"正在上传到蒲公英...")
    print(f"文件路径: {ipa_path}")
    
    # 从配置文件或环境变量读取 API Key
    api_key = os.environ.get('PGYER_API_KEY', 'your_api_key')
    user_key = os.environ.get('PGYER_USER_KEY', 'your_user_key')
    
    files = {"file": open(ipa_path, "rb")}
    headers = {"enctype": "multipart/form-data"}
    
    platform_text = "android" if platform == "android" else "ios"
    payload = {
        "uKey": user_key,
        "_api_key": api_key,
        "installType": 1,
        "updateDescription": f"{platform_text}自动化打包"
    }
    
    try:
        response = requests.post(
            "https://www.pgyer.com/apiv2/app/upload", 
            data=payload, 
            files=files, 
            headers=headers
        )
        result = response.json()
        
        # 获取构建信息
        qr_code_url = result["data"]["buildQRCodeURL"]
        version = result["data"]["buildVersion"]
        version_no = result["data"]["buildVersionNo"]
        build_name = result["data"]["buildName"]
        
        print(f"上传成功!")
        print(f"二维码地址: {qr_code_url}")
        print(f"版本: {version} ({version_no})")
        
        return {
            "qr_code_url": qr_code_url,
            "version": version,
            "version_no": version_no,
            "build_name": build_name
        }
    except Exception as e:
        print(f"上传失败: {e}")
        return None

3. 群发邮件通知

构建并上传成功后,自动发送邮件通知团队成员:

#!/usr/local/bin/python3

import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
import os
import time
from typing import List, Dict, Optional

class BulkEmailSender:
    """群发邮件发送器"""
    
    def __init__(self, smtp_server: str, smtp_port: int, 
                 sender_email: str, sender_password: str):
        """
        初始化群发邮件发送器
        
        Args:
            smtp_server: SMTP服务器地址
            smtp_port: SMTP端口
            sender_email: 发送者邮箱
            sender_password: 发送者邮箱密码或授权码
        """
        self.smtp_server = smtp_server
        self.smtp_port = smtp_port
        self.sender_email = sender_email
        self.sender_password = sender_password
    
    def _create_connection(self):
        """创建SMTP连接"""
        try:
            if self.smtp_port == 465:
                # 使用SSL连接(465端口)
                server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port)
            else:
                # 使用STARTTLS连接(587/25端口)
                server = smtplib.SMTP(self.smtp_server, self.smtp_port)
                server.starttls()
            
            server.login(self.sender_email, self.sender_password)
            return server
        except Exception as e:
            print(f"连接失败: {e}")
            return None
    
    def send_bulk_individual(self, recipients: List[str], subject: str, 
                            body: str, html_body: Optional[str] = None,
                            attachment_path: Optional[str] = None,
                            delay: float = 1.0) -> Dict[str, bool]:
        """
        逐个发送邮件(隐私保护最好,每个人只能看到自己的邮箱)
        
        Args:
            recipients: 收件人列表
            subject: 邮件主题
            body: 邮件内容(纯文本)
            html_body: HTML邮件内容(可选)
            attachment_path: 附件路径(可选)
            delay: 发送间隔(秒),避免被服务器限制
        """
        results = {}
        server = self._create_connection()
        
        if not server:
            return {email: False for email in recipients}
        
        try:
            for i, recipient in enumerate(recipients):
                try:
                    print(f"发送邮件 {i+1}/{len(recipients)} 到: {recipient}")
                    
                    # 创建邮件
                    if html_body:
                        msg = MIMEMultipart('alternative')
                        msg.attach(MIMEText(body, 'plain', 'utf-8'))
                        msg.attach(MIMEText(html_body, 'html', 'utf-8'))
                    else:
                        msg = MIMEMultipart()
                        msg.attach(MIMEText(body, 'plain', 'utf-8'))
                    
                    msg['From'] = self.sender_email
                    msg['To'] = recipient
                    msg['Subject'] = subject
                    
                    # 添加附件
                    if attachment_path and os.path.exists(attachment_path):
                        self._add_attachment(msg, attachment_path)
                    
                    # 发送邮件
                    server.sendmail(self.sender_email, [recipient], msg.as_string())
                    results[recipient] = True
                    print(f"✅ 发送成功: {recipient}")
                    
                    # 延迟避免被限制
                    if i < len(recipients) - 1:
                        time.sleep(delay)
                        
                except Exception as e:
                    results[recipient] = False
                    print(f"❌ 发送失败 {recipient}: {e}")
                    
        finally:
            server.quit()
            
        return results
    
    def send_bulk_bcc(self, recipients: List[str], subject: str, body: str,
                     html_body: Optional[str] = None, batch_size: int = 50) -> bool:
        """
        使用BCC批量发送(隐私保护,收件人看不到其他人)
        适合大批量发送通知邮件
        """
        server = self._create_connection()
        if not server:
            return False
        
        try:
            # 分批发送,避免单次发送太多
            for i in range(0, len(recipients), batch_size):
                batch = recipients[i:i + batch_size]
                print(f"发送批次 {i//batch_size + 1}: {len(batch)} 个收件人")
                
                if html_body:
                    msg = MIMEMultipart('alternative')
                    msg.attach(MIMEText(body, 'plain', 'utf-8'))
                    msg.attach(MIMEText(html_body, 'html', 'utf-8'))
                else:
                    msg = MIMEMultipart()
                    msg.attach(MIMEText(body, 'plain', 'utf-8'))
                
                msg['From'] = self.sender_email
                msg['To'] = self.sender_email  # 显示发送者自己
                msg['Bcc'] = ', '.join(batch)   # 密送给所有收件人
                msg['Subject'] = subject
                
                all_recipients = [self.sender_email] + batch
                server.sendmail(self.sender_email, all_recipients, msg.as_string())
                print(f"✅ 批次发送成功: {len(batch)} 个收件人")
                
                if i + batch_size < len(recipients):
                    time.sleep(2)
                    
            return True
            
        except Exception as e:
            print(f"❌ BCC群发失败: {e}")
            return False
        finally:
            server.quit()
    
    def _add_attachment(self, msg: MIMEMultipart, attachment_path: str):
        """添加附件到邮件"""
        with open(attachment_path, "rb") as attachment:
            part = MIMEBase('application', 'octet-stream')
            part.set_payload(attachment.read())
        
        encoders.encode_base64(part)
        part.add_header(
            'Content-Disposition',
            f'attachment; filename= {os.path.basename(attachment_path)}'
        )
        msg.attach(part)

4. 发送构建通知邮件

将构建信息通过 HTML 邮件发送给团队:

def send_build_notification(build_info, env, platform, test_content=""):
    """发送构建通知邮件"""
    
    env_text = '生产' if env == 'prod' else '开发'
    platform_text = "Android" if platform == "android" else "iOS"
    
    # 构建HTML邮件内容
    html_body = f"""
    <html>
        <body>
            <h2>项目构建通知</h2>
            <p>构建状态: <span style="color: green;"><b>成功</b></span></p>
            <ul>
                <li>构建名称: {build_info['build_name']}</li>
                <li>平台: {platform_text}</li>
                <li>环境: {env_text}</li>
                <li>版本: {build_info['version']}</li>
                <li>版本号: {build_info['version_no']}</li>
                <li>测试内容: {test_content}</li>
            </ul>
            <img src="{build_info['qr_code_url']}" alt="下载二维码">
            <p>请扫描二维码下载安装测试。</p>
        </body>
    </html>
    """
    
    # 从环境变量读取邮件配置
    smtp_server = os.environ.get('SMTP_SERVER', 'smtp.exmail.qq.com')
    smtp_port = int(os.environ.get('SMTP_PORT', '587'))
    sender_email = os.environ.get('SENDER_EMAIL')
    sender_password = os.environ.get('SENDER_PASSWORD')
    
    bulk_sender = BulkEmailSender(
        smtp_server=smtp_server,
        smtp_port=smtp_port,
        sender_email=sender_email,
        sender_password=sender_password
    )
    
    # 收件人列表(从配置文件读取)
    recipients = load_recipients_from_config()
    
    subject = f"构建通知: {build_info['build_name']} - {platform_text} - {env_text}环境"
    
    results = bulk_sender.send_bulk_individual(
        recipients=recipients,
        subject=subject,
        body=f'{platform_text}打包通知',
        html_body=html_body,
        delay=0.5
    )
    
    return results

5. iOS 构建环境清理脚本

在 iOS 开发中,经常会遇到 CocoaPods 缓存导致的构建问题。这个脚本可以彻底清理构建环境:

#!/usr/bin/env python3
"""
iOS 构建环境清理脚本
用于解决 Firebase Crashlytics 模块化头文件等常见问题
"""

import os
import subprocess
import shutil

def run_command(command, description):
    """执行命令并打印结果"""
    print(f"\n{description}...")
    print(f"执行命令: {command}")
    
    try:
        result = subprocess.run(command, shell=True, capture_output=True, text=True)
        if result.stdout:
            print("输出:", result.stdout)
        if result.returncode == 0:
            print(f"✅ {description} 成功")
        else:
            print(f"❌ {description} 失败")
        return result.returncode == 0
    except Exception as e:
        print(f"❌ {description} 异常: {e}")
        return False

def force_clean_ios():
    """强制清理 iOS 构建环境"""
    print("🧹 开始强制清理 iOS 构建环境...")
    
    # 获取项目根目录
    project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    ios_dir = os.path.join(project_root, "ios")
    
    if not os.path.exists(ios_dir):
        print(f"❌ iOS 目录不存在: {ios_dir}")
        return False
    
    os.chdir(project_root)
    print(f"📁 当前工作目录: {os.getcwd()}")
    
    # 1. 清理 Flutter 缓存
    run_command("fvm flutter clean", "清理 Flutter 构建缓存")
    
    # 2. 删除 pubspec.lock
    pubspec_lock = os.path.join(project_root, "pubspec.lock")
    if os.path.exists(pubspec_lock):
        print(f"🗑️ 删除 pubspec.lock")
        os.remove(pubspec_lock)
    
    # 3. 删除 .dart_tool 目录
    dart_tool_dir = os.path.join(project_root, ".dart_tool")
    if os.path.exists(dart_tool_dir):
        print(f"🗑️ 删除 .dart_tool 目录")
        shutil.rmtree(dart_tool_dir)
    
    # 4. 删除 iOS 构建目录
    for dir_name in ["build", "Pods", ".symlinks"]:
        dir_path = os.path.join(ios_dir, dir_name)
        if os.path.exists(dir_path):
            print(f"🗑️ 删除 {dir_name} 目录")
            shutil.rmtree(dir_path)
    
    # 5. 删除 Podfile.lock
    podfile_lock = os.path.join(ios_dir, "Podfile.lock")
    if os.path.exists(podfile_lock):
        print(f"🗑️ 删除 Podfile.lock")
        os.remove(podfile_lock)
    
    # 6. 清理 CocoaPods 缓存
    run_command("pod cache clean --all", "清理 CocoaPods 缓存")
    
    # 7. 重新获取 Flutter 依赖
    run_command("fvm flutter pub get", "重新获取 Flutter 依赖")
    
    # 8. 重新安装 Pods
    os.chdir(ios_dir)
    run_command("pod install --repo-update", "重新安装 Pods")
    
    print("\n🎉 强制清理完成!")
    print("💡 现在可以重新尝试构建 iOS 应用了")
    
    return True

if __name__ == "__main__":
    force_clean_ios()

6. 邮箱授权测试工具

在配置邮件服务前,可以使用这个工具测试授权码是否正确:

#!/usr/local/bin/python3

import smtplib

def test_email_auth(smtp_server, smtp_port, email, auth_code):
    """
    测试邮箱授权码是否正确
    """
    try:
        print(f"正在测试邮箱: {email}")
        print(f"SMTP服务器: {smtp_server}:{smtp_port}")
        
        # 连接SMTP服务器
        server = smtplib.SMTP(smtp_server, smtp_port)
        server.starttls()
        
        # 尝试登录
        server.login(email, auth_code)
        server.quit()
        
        print("✅ 授权码验证成功!")
        return True
        
    except smtplib.SMTPAuthenticationError:
        print("❌ 授权码验证失败!请检查:")
        print("   1. 授权码是否正确")
        print("   2. 是否已开启SMTP服务")
        print("   3. 是否使用了邮箱密码而非授权码")
        return False
        
    except Exception as e:
        print(f"❌ 连接失败: {e}")
        return False

if __name__ == "__main__":
    # 常用邮箱SMTP配置
    email_configs = {
        'qq': ('smtp.qq.com', 587),
        '163': ('smtp.163.com', 25),
        'gmail': ('smtp.gmail.com', 587),
        'outlook': ('smtp-mail.outlook.com', 587),
        'wechat': ('smtp.exmail.qq.com', 587)
    }
    
    print("=== 邮箱授权码测试工具 ===\n")
    
    email_type = input("请选择邮箱类型 (qq/163/gmail/outlook/wechat): ").lower()
    
    if email_type not in email_configs:
        print("不支持的邮箱类型")
        exit(1)
    
    email = input("请输入邮箱地址: ")
    auth_code = input("请输入授权码: ")
    
    smtp_server, smtp_port = email_configs[email_type]
    test_email_auth(smtp_server, smtp_port, email, auth_code)

使用方式

1. 环境准备

首先确保安装了必要的 Python 依赖:

pip install requests

2. 配置敏感信息

建议使用环境变量或配置文件管理敏感信息,不要硬编码在脚本中:

# 设置环境变量
export PGYER_API_KEY="your_api_key"
export PGYER_USER_KEY="your_user_key"
export SENDER_EMAIL="your_email@example.com"
export SENDER_PASSWORD="your_auth_code"
export SMTP_SERVER="smtp.exmail.qq.com"
export SMTP_PORT="587"

3. 执行构建

# 进入 python 脚本目录
cd python

# 执行构建脚本
python3 build_app.py

脚本会依次提示:

  1. 选择环境(dev/prod)
  2. 是否发送邮件通知
  3. 输入测试内容
  4. 是否上传到 App Store(仅生产环境)

4. 清理 iOS 构建环境

当遇到 iOS 构建问题时,执行:

python3 force_clean_ios.py

最佳实践

1. 敏感信息管理

  • 使用环境变量存储 API Key、密码等敏感信息
  • 不要将敏感信息提交到版本控制
  • 可以使用 .env 文件配合 python-dotenv

2. 错误处理

  • 每个关键步骤都添加 try-except 处理
  • 构建失败时输出详细错误信息
  • 记录日志便于问题排查

3. 邮件发送策略

  • 群发邮件时添加适当延迟,避免被服务器限制
  • 使用 BCC 方式保护收件人隐私
  • 分批发送大量邮件

4. 构建优化

  • 使用 --obfuscate 参数进行代码混淆
  • 使用 --split-debug-info 分离调试信息
  • 根据环境使用不同的配置文件

总结

通过 Python 脚本实现 Flutter 项目的自动化构建,可以显著提高开发效率:

  1. 一键完成:构建、上传、通知全流程自动化
  2. 减少出错:避免手动操作带来的失误
  3. 节省时间:构建期间可以专注于其他工作
  4. 规范流程:统一的构建和发布流程

这套脚本已经在我使用了一段时间,效果良好。希望这篇文章对有类似需求的开发者有所帮助。


相关技术栈:

  • Python 3.x
  • Flutter + FVM
  • 蒲公英测试平台
  • SMTP 邮件服务

被拒 10 次后,我的首款开源鸿蒙应用终上架,真的坎坷~

2026年2月2日 16:36

经过近几个月的开发与打磨,我的首款鸿蒙应用👉 uViewPro(跨平台UI组件库)👈 点击体验 正式上线华为鸿蒙应用市场!这是一款基于 uni-app + uView Pro 开发的应用,主要面向开发者实践的应用,它不仅展示了 uView Pro 开源组件库的强大能力,更是一次跨平台开发的完美落地实践。

预览图.png

但是上线的过程可谓是坎坷不断...

一. 说一下心酸苦楚

我感觉鸿蒙应用上架可比其他平台上线严格多了,我反复修改提交了近10次才最终上架成功。

第一次提交申请后被拒的原因是:

  1. 功能交互简单,影响用户的总体体验。
  2. 横竖屏布局未适配问题,不符合鸿蒙应用UX设计规范。
  3. 未正常适配设备深色模式,不符合鸿蒙应用UX设计规范。

11.png

第2,3个问题都好解决,因为问题明确,解决方案明了:

  • 第2个问题需要适配横竖屏切换的布局适配,布局不要错乱即可。
  • 第3个问题需要将应用的所有页面适配暗色模式。

但官方也明确说了,这两个问题不是重点,不解决也可以通过审核。

最重要的卡着不让上线的是第1个原因,由于应用交互简单所以不能上线。我真是....,这太主观了,审核人员说你交互简单,那你就过不了!

果真,经过优化后再次提交,后面所有被拒绝的原因都为第一个,其他的问题都已解决,不管应用内容如何丰富,都被拒绝!(这根本就是不想给过啊...)

12.png

image.png

提交多次被审核驳回后,快到了放弃的边缘,再次经群友提点,提交申诉和工单可能会通过,让我又看到了曙光。

image.png

很快,几天后一盆冷水又浇来了,唉,工单也给驳回了。

image.png

难道这一次真到了要放弃的时候了?那不可能,已经付出了那么多,不能轻易放弃。我甚至怀疑 UI 组件演示库就根本不让上,但是我通过在鸿蒙应用商店搜索,别人做的那么简单的都能上架,我的应用说太简单了?

1.0.01.0.9,我增加了下述重要的功能:

  • 加入引导系统:首次启动展示引导页,在复杂组件页面新增分步引导。
  • 提高用户参与度:任务与体验值体系,每个组件配套任务(学习/实现类任务),完成后奖励经验值与成就。
  • 互动反馈功能:加入点赞/收藏/评分功能,记录用户偏好并提供“常用组件”列表。
  • 增强交互动效:为演示页加入真实交互,“在线模拟” 操作,体验动效。
  • 支持API文档查询:各组件的演示+API手册,做一个APP全能大师

所以我不认可审核人员说的“交互简单”,我梳理一下思路,开始复盘,开始反思。最终,我整理提交了一大堆材料+万字说明文档,包括如下:

image.png

我把这一次当成绝地反击的最后一次,终于,之前的努力没有被化作泡影,通过了审核!

为什么会这么艰难?不止我感觉难,鸿蒙开发群里的小伙伴同样是这样,可能是与报名了鸿蒙应用激励计划有关!

image.png

下面来说说,我为什么一定要在鸿蒙系统上线这款应用?

二. 为什么要开发这款鸿蒙应用?

众所周知,我是👉 uView Pro 开源组件库 的作者,自从2025年8月份开源以来,目前已经有不少开发者使用,期间有许多小伙伴像我询问,支不支持鸿蒙?但由于我从未在鸿蒙系统上开发过应用,也没上架过鸿蒙应用,也不知道它的兼容性如何。

所以,我打算通过将这款应用真正上架到鸿蒙应用商店,来既验证uView Pro 在鸿蒙系统上的可行性,也为其他开发者提供了可参考的落地案例。

跨平台开发常见痛点:文档不直观、示例难落地、多端适配易踩坑。基于 uView Pro 组件库,因此我必须要做一个真实应用来解决这些问题,让开发者可以:

  • 直观体验 真实场景下的组件表现
  • 快速上手 通过交互 Demo 和任务系统掌握用法
  • 验证可行性 在鸿蒙平台验证跨平台方案
  • 提升效率 借助模板与工具加速开发

而我选择在鸿蒙上验证 uView Pro 组件库的可行性,主要是为了:

  • 补齐版图:其他主流平台已兼容,鸿蒙是必选的一环
  • 验证能力:确认 uni-app + Vue3 + uView Pro 在鸿蒙的兼容性、性能与体验
  • 市场红利:华为对在2025年要求时间段上架的应用,会给开发者发放激励金
  • 技术成长:学习鸿蒙特性、解决新问题、沉淀跨平台经验

最终不负众望,这次实践既验证了方案的可行性,也为其他开发者提供了可参考的落地案例。

三. 技术栈:为什么选择这些技术?

  • 开发框架:uni-app(多端开发,提高生产力)
  • 开发语言:Vue3 + TS(鸿蒙打包只支持Vue3)
  • UI组件库:uView Pro(我自己的开源组件库)

最主要的是我想验证 uView Pro 开发鸿蒙应用的可行性。

1. uni-app:一次开发,多端运行

uni-app 作为老牌的多端开发的跨平台开发框架,它的核心优势在于:真正的跨平台能力。

uni-app 开发的应用,支持编译到多个平台:

  • 移动端:Android、iOS、HarmonyOS
  • 小程序:微信、支付宝、百度、头条、QQ
  • Web:H5,PC
  • 快应用:华为、小米等

这意味着,使用 uni-app 可以同时覆盖多个平台,会大大降低开发和维护成本。使用它可以充分利用它的跨平台能力,确保应用在各个平台上都能正常运行,特别是在鸿蒙平台上的表现,也完全达到了预期。

2. Vue 3 + TS:现代化的开发体验

Vue 3 作为当前最流行的前端框架之一,带来了:

  • Composition API:更灵活的代码组织方式,逻辑复用更简单
  • 性能提升:相比 Vue 2,性能提升 2-3 倍
  • TypeScript 支持:完整的类型系统,只能提示校验,减少运行时错误
  • 生态丰富:庞大的社区和丰富的插件生态

使用它可以充分利用 Vue 3 的 Composition API,将复杂的组件逻辑拆分成可复用的组合函数,代码更加清晰和易维护。不过,鸿蒙开发也只能用 Vue3,因为 uni-app 并不支持将 Vue2 的代码打包成鸿蒙应用。

// 示例:使用 Composition API 管理主题状态
<script setup lang="ts">
import type { DarkMode } from 'uview-pro/types/global'
import { useTheme } from 'uview-pro'
import { ref } from 'vue'

const { darkMode, currentTheme, setDarkMode, setTheme, getAvailableThemes } = useTheme()

const darkModes = ref<{ value: DarkMode, label: string }[]>(
  [
    { value: 'auto', label: '自动' },
    { value: 'light', label: '亮色' },
    { value: 'dark', label: '深色' },
  ],
)
function handleThemeSelect(theme: string) {
  // 切换到选定的主题
  setTheme(theme)
}

function handleDarkModeSelect(mode: DarkMode) {
  setDarkMode(mode)
}
</script>

TypeScript 的加入,会让整个项目更加健壮:

  • 编译时类型检查,提前发现潜在问题
  • 更好的 IDE 智能提示,提升开发效率
  • 代码可读性更强,团队协作更顺畅
  • 重构更安全,减少引入 Bug 的风险

1.gif

3. uView Pro:强大的 UI 组件库

image.png

uView Pro 是我长期维护的开源 UI 组件库,它提供了:

(1). 丰富的组件生态

uView Pro 已包含 80+ 组件,覆盖了日常开发所需:

  • 基础组件:Button、Input、Icon、Image 等
  • 表单组件:Form、Checkbox、Radio、Picker 等
  • 布局组件:Layout、Grid、Flex、Card 等
  • 导航组件:Navbar、Tabbar、Tabs、Steps 等
  • 数据展示:Table、List、Swiper、Waterfall 等
  • 反馈组件:Toast、Modal、Loading、ActionSheet 等
  • 其他组件:MessageInput、LazyLoad、Loadmore、Link 等

uView Pro 基于官方 uView UI 1.8.8 版本,完全使用 Vue3 + TypeScript 源码级重写,每个组件都经过精心重构优化,既保证了功能的完整性,又兼顾了易用性。

(2). 完善的文档和示例

uView Pro 的文档非常详细,同样进行了重构级优化,免费无广告:

  • API 文档:每个组件的属性、事件、方法都有详细说明
  • 示例代码:提供多种使用场景的示例
  • 最佳实践:分享组件使用的最佳实践
  • 常见问题:整理常见问题和解决方案

(3). 主题定制能力

uView Pro 支持完整的主题定制:

  • 内置主题:提供多种预设主题
  • 自定义主题:支持自定义颜色、字体等
  • 暗黑模式:完整的暗黑模式支持
  • 动态切换:支持运行时切换主题

可以充分利用 uView Pro 的主题系统,实现多主题切换和暗黑模式,用户体验非常流畅。不仅如此,你可以3分钟智能生成多种主题,主要是靠:

智能推断主题色工具: 通过设置某个主题色,可以阶梯生成其他色值。

智能推断主题色.gif

随机生成主题色工具: 随机生成主题色阶梯色值。

随机主题色.gif

生成后可在 main.ts 这样使用:

import uViewPro from '@/uni_modules/uview-pro';

// 主题列表,仅作演示,应单独提取出来统一维护
const themes = [
    // 主题: 绿色
    {
        name: 'green',
        label: '清翠绿',
        color: {
            // 明亮模式下的主题色
            primary: '#059669',
            error: '#dc2626',
            warning: '#eab308',
            success: '#16a34a',
            info: '#78716c',
            primaryLight: '#ecfdf5',
            errorLight: '#fee2e2',
            warningLight: '#fefce8',
            successLight: '#dcfce7',
            infoLight: '#fafaf9',
            primaryDark: '#047857',
            errorDark: '#b91c1c',
            warningDark: '#ca8a04',
            successDark: '#15803d',
            infoDark: '#57534e',
            primaryDisabled: '#6ee7b7',
            errorDisabled: '#fca5a5',
            warningDisabled: '#facc15',
            successDisabled: '#86efac',
            infoDisabled: '#e7e5e4'
        },
        darkColor: {
            // 暗黑模式下的主题色
            // 如未配置,系统会自动根据亮色生成暗黑色值
        }
    }
];

export function createApp() {
    const app = createSSRApp(App);
    // 引入uView Pro 主库
    app.use(uViewPro, {
        theme: {
            themes: themes,
            defaultTheme: 'green',
            defaultDarkMode: 'light'
        },
    });
    return {
        app
    };
}

(4). 多语言能力

uView Pro 所有内置组件均支持多语言,支持全局与组件级配置、响应式切换与持久化语言偏好。

核心特性如下:

  • 内置语言: 默认包含 zh-CNen-US
  • 配置灵活: 支持在应用入口全局配置或组件内覆盖局部语言包。
  • 响应式切换: 切换语言时组件文案自动更新。
  • 持久化: 用户选择会被保存以便下次恢复。
  • 扩展友好: 可按需添加或覆盖语言包,支持按需加载。

在 main.ts 这样使用:

import uViewPro from 'uview-pro';

export function createApp() {
    const app = createSSRApp(App);
    // 引入uView Pro 主库,
    app.use(uViewPro, {
        locale: {
            // 部分覆盖内置语言包
            locales: [
                { name: 'zh-CN', uModal: { confirmText: '好的', cancelText: '算了' } },
                { name: 'en-US', uModal: { confirmText: 'OK', cancelText: 'Cancel' } }
            ],
            defaultLocale: 'zh-CN'
        }
    });
    return {
        app
    };
}

image.png

四. 核心优势:这款鸿蒙应用为什么值得体验?

1. 真正的跨平台体验

uView Pro 鸿蒙应用本身就是跨平台开发的最佳实践。通过这款应用,你可以:

  • 验证跨平台能力:在鸿蒙设备上体验,验证 uni-app + uView Pro 的跨平台能力
  • 学习最佳实践:了解如何在跨平台项目中组织代码、处理兼容性问题
  • 参考实现方案:参考应用的实现方式,应用到自己的项目中

2. 新增游戏化学习机制

传统的组件库文档往往比较枯燥,而 uView Pro 鸿蒙应用引入了游戏化学习机制:

(1). 任务系统

每个组件 Demo 都配套一个或多个任务,例如:

  • 表单验证任务:完成一个完整的表单验证流程
  • 数据展示任务:使用 Table 组件展示数据列表
  • 交互设计任务:实现特定的交互效果

完成任务后,会获得经验值奖励,让学习过程更有趣。

(2). 成就系统

达到一定经验值后,可以解锁成就和特权:

  • 主题解锁:解锁更多主题选项
  • 模板下载:增加模板下载次数
  • 特殊标识:获得特殊的用户标识

(3). 体验地图

可视化展示学习进度:

  • 已完成任务:清晰展示已掌握的内容
  • 推荐任务:根据当前进度推荐下一步学习内容
  • 成就展示:展示已解锁的成就

这种游戏化的学习方式,让学习组件库变得更加有趣和高效。

体验地图.png

3. 丰富的功能模块

uView Pro 不仅仅是一个组件展示应用,更是一个完整的开发工具集合:

(1). 80+ 组件演示

每个组件都包含:

  • 交互 Demo:可以直接操作,感受组件的实际效果
  • 参数说明:详细的 API 文档
  • 示例代码:多种使用场景的代码示例
  • 最佳实践:组件使用的最佳实践建议

(2). 20+ 工具库

提供实用的开发工具:

  • 颜色工具:颜色选择器、颜色转换、主题生成
  • HTTP 工具:请求测试、接口调试
  • 路由工具:路由跳转、参数解析
  • 规则校验:表单验证、数据校验
  • 其他工具:图标库、Mock 数据生成器等

这些工具都是日常开发中经常用到的,集成在应用中,方便随时使用。

(3). 10+ 业务模板

提供完整的业务页面模板,支持分享,一键下载业务模板源码:

  • 登录界面:多种登录方式的设计
  • 地址管理:地址列表、添加、编辑
  • 评论列表:评论展示、回复、点赞
  • 个人中心:用户信息、设置、订单等
  • 设置页:应用设置、账号设置等
  • ...

(4). 4 个实用场景实践

内置4个完整的业务场景,可以感受组件在实际应用中的使用:

  • 待办事项:TODO 应用,记录任务,完成它们。
  • 我的笔记:记录灵光乍现的想法,可随时查看。
  • 数据统计:统计你的使用情况,了解你的使用习惯。
  • 我的收藏:收藏喜欢的组件,快速查看。

image.png

4. 完善的用户体验

(1). 多主题系统

通过便捷的主题配置工具,3分钟即可生成多种主题,应用内置了5套主题,例如:

  • 默认蓝:经典的蓝色主题
  • 霞光紫:优雅的紫色主题
  • 清翠绿:清新的绿色主题
  • 暖阳橙:温暖的橙色主题
  • 午夜蓝:深沉的蓝色主题

工具支持自定义主题,选择主色后可以预览效果,并保存为本地配置。

多主题.png

(2). 暗黑模式

完整的暗黑模式支持:

  • 自动模式:跟随系统设置自动切换
  • 手动模式:手动切换亮色/暗色
  • 即时生效:切换后立即生效,无需重启

暗黑模式不仅覆盖了组件样式,还包括示例页、代码高亮、图表等,确保整个应用的视觉体验一致。

暗黑模式.png

(3). 引导系统

首次使用应用时,会展示引导页:

  • 应用定位:介绍应用的核心价值
  • 功能速览:快速了解主要功能
  • 使用指南:如何使用演示和任务系统

进入具体页面时,也会有分步引导,帮助用户快速上手复杂组件。

应用引导页.png

页面引导页.png

以上部分功能仅限在鸿蒙应用中体验!

五. 如何体验?

1. 通过华为应用市场

  1. 打开华为应用市场(AppGallery)
  2. 搜索 uViewPro跨平台UI组件库

👉 或直接访问应用页面

重要提示: 此应用仅在 HarmonyOS 5.0 及以上版本 设备的应用市场中提供,请确保您的设备系统版本满足要求后再进行下载。

2. 首次使用建议

  1. 完成引导:首次打开应用时,建议完成引导页,了解应用的核心功能
  2. 探索组件:从首页进入组件库,浏览感兴趣的组件
  3. 完成任务:尝试完成一些任务,体验游戏化学习机制
  4. 切换主题:尝试切换不同的主题,感受主题系统的强大
  5. 体验模板:查看业务模板,了解如何快速搭建页面

六. 总结

uView Pro 不仅仅是一款UI组件库,更是鸿蒙跨平台开发的一次实践。通过这款应用,我希望能够:

  • 展示跨平台开发的可行性:证明 uni-app + uView Pro 可以在鸿蒙平台上完美运行
  • 帮助开发者提升效率:通过丰富的组件和模板,帮助开发者快速开发应用
  • 推动技术生态发展:为跨平台开发技术生态贡献一份力量

如果你是一名开发者,如果你对跨平台开发感兴趣,如果你想要体验鸿蒙跨平台的能力,那么,uView Pro 应用绝对值得你下载体验!

uView Pro 应用的代码全部开源,你可随时体验和使用!👇

相关资料

从Clawdbot到Moltbot再到OpenClaw,这只龙虾又双叒改名了

2026年2月2日 16:19

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。


要说最近AI圈最折腾的项目,非这只"龙虾"莫属。 两个月前,它还叫Clawdbot,三天前改成了Moltbot,结果还没等大家念顺口,1月30日又宣布最终定名OpenClaw。

短短72小时内两度更名,GitHub上那个超过10万星标的开源项目,硬是把取名这件事演成了连续剧。

从一封律师函说起

事情从25年11月份说起,国外开发者Peter搞了个项目,最初叫"WhatsApp Relay"。

后来他觉得Claude Code那个龙虾形象挺酷,就给自己的项目起了个谐音梗名字——Clawdbot(龙虾叫Clawd),Logo也用了类似的红色龙虾形象。

image.png

项目意外爆火。一周200万访问量,GitHub星标蹭蹭往上涨,连Mac Mini都因为这玩意儿销量激增。

image 1.png

人红是非多,Anthropic的法务团队找上门了:Clawd跟Claude发音太像,涉嫌商标侵权。

"去掉d改成Clawbot也不行",面对AI巨头的压力,他最终还是妥协了。

第一次改名:Moltbot

1月27日,Clawdbot正式更名为Moltbot。新名字取自龙虾"蜕皮"(Molt)的生物学过程——龙虾必须蜕掉旧壳才能长大。Peter在公告里写:"同样的龙虾灵魂,换了一身新壳。"

image 2.png

吉祥物从Clawd改成了Molty,Logo也同步更新。社区对这个名字还算包容,毕竟寓意挺深刻。但麻烦接踵而至:GitHub在重命名时出了故障,Peter的个人账号一度报错;更离谱的是,X上的旧账号@clawdbot在改名后短短10秒内就被加密货币骗子抢注,随即开始炒作一款叫CLAWD的假代币,市值一度炒到1600万美元后崩盘。

Peter不得不连发数条推文澄清:这是个非营利项目,他永远不会发币,任何挂他名字的代币都是骗局。

image 3.png

第二次改名:OpenClaw

Moltbot这个名字还没捂热,三天后,Peter又宣布了最终名称:OpenClaw。

这次他学乖了。这个名字是凌晨5点Discord群里脑暴出来的,Peter提前做了功课——商标查询没问题,域名全部买断,迁移代码也写好了。

Open代表开源、开放、社区驱动;Claw代表龙虾 heritage,向起源致敬。Peter说,这精准概括了项目的精神内核。

改名背后的折腾

回头看这三次更名,简直像一场被迫的成长。

第一次是玩梗撞上了法律墙,第二次是应急方案不够完善,第三次才算真正站稳。这期间还夹杂着GitHub故障、账号被抢注、币圈骚扰、安全漏洞被研究人员点名——一个个人开发者的业余项目,在爆红后遭遇的连锁反应,比代码调试还让人头大。

现在它叫OpenClaw

不管名字怎么变,这个项目的核心没变:跑在你自己机器上的AI助手,支持WhatsApp、Telegram、飞书、钉钉等20多个平台,数据全本地,能操作文件、执行命令、调用API。你可以把它当成一个7×24小时待命的"数字员工",在聊天软件里@它一声,它就能帮你查数据库、整理会议纪要、甚至批量删除7.5万封邮件。

最新版本还增加了Twitch和Google Chat支持,集成了KIMI K2.5等模型,Web界面也能发图片了。

至于那只龙虾,还在。只是现在它叫OpenClaw,不叫Clawd,也不叫Molty了。

❌
❌