阅读视图

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

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

第一步: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 - 排除最后一项

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-常用修饰符

前言

在 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:为静态网站打造的极速搜索方案

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. 多语言分词:支持中英文等智能分词

相关资源

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

写在开头

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 的原理,我直接甩出三个版本》

今天我们来聊聊 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

如何实现丝滑流畅的 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 的基本用法和对比,对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

Vue3 props穿透(attrs)重大变化:$attrs 居然包含class/style了!

一、前言:为什么要关注 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 组件~

使用 Python 实现 Flutter 项目的自动化构建与发布

前言

作为公司唯一的移动端开发,我需要同时负责 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 次后,我的首款开源鸿蒙应用终上架,真的坎坷~

经过近几个月的开发与打磨,我的首款鸿蒙应用👉 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,这只龙虾又双叒改名了

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。


要说最近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了。

❌