普通视图

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

每日一题-三段式数组 I🟢

2026年2月3日 00:00

给你一个长度为 n 的整数数组 nums

如果存在索引 0 < p < q < n − 1,使得数组满足以下条件,则称其为 三段式数组(trionic)

  • nums[0...p] 严格 递增,
  • nums[p...q] 严格 递减,
  • nums[q...n − 1] 严格 递增。

如果 nums 是三段式数组,返回 true;否则,返回 false

 

示例 1:

输入: nums = [1,3,5,4,2,6]

输出: true

解释:

选择 p = 2, q = 4

  • nums[0...2] = [1, 3, 5] 严格递增 (1 < 3 < 5)。
  • nums[2...4] = [5, 4, 2] 严格递减 (5 > 4 > 2)。
  • nums[4...5] = [2, 6] 严格递增 (2 < 6)。

示例 2:

输入: nums = [2,1,3]

输出: false

解释:

无法选出能使数组满足三段式要求的 pq

 

提示:

  • 3 <= n <= 100
  • -1000 <= nums[i] <= 1000

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

相关资源

3637. 三段式数组 I

作者 stormsunshine
2025年8月4日 06:34

解法一

思路和算法

数组 $\textit{nums}$ 的长度是 $n$。最直观的思路是遍历 $0 < p < q < n - 1$ 的所有下标对 $(p, q)$,判断是否同时满足三段式数组的全部条件。

  • 下标范围 $[0, p]$ 严格递增。

  • 下标范围 $[p, q]$ 严格递减。

  • 下标范围 $[q, n - 1]$ 严格递增。

当遇到满足全部条件的下标对 $(p, q)$ 时,返回 $\text{true}$。如果不存在满足全部条件的下标对 $(p, q)$,返回 $\text{false}$。

代码

###Java

class Solution {
    public boolean isTrionic(int[] nums) {
        int n = nums.length;
        for (int p = 1; p < n - 2; p++) {
            if (!isMonotonicInRange(nums, 0, p, 1)) {
                continue;
            }
            for (int q = p + 1; q < n - 1; q++) {
                if (isMonotonicInRange(nums, p, q, -1) && isMonotonicInRange(nums, q, n - 1, 1)) {
                    return true;
                }
            }
        }
        return false;
    }

    public boolean isMonotonicInRange(int[] nums, int start, int end, int direction) {
        for (int i = start; i < end; i++) {
            if ((nums[i + 1] - nums[i]) * direction <= 0) {
                return false;
            }
        }
        return true;
    }
}

###C#

public class Solution {
    public bool IsTrionic(int[] nums) {
        int n = nums.Length;
        for (int p = 1; p < n - 2; p++) {
            if (!IsMonotonicInRange(nums, 0, p, 1)) {
                continue;
            }
            for (int q = p + 1; q < n - 1; q++) {
                if (IsMonotonicInRange(nums, p, q, -1) && IsMonotonicInRange(nums, q, n - 1, 1)) {
                    return true;
                }
            }
        }
        return false;
    }

    public bool IsMonotonicInRange(int[] nums, int start, int end, int direction) {
        for (int i = start; i < end; i++) {
            if ((nums[i + 1] - nums[i]) * direction <= 0) {
                return false;
            }
        }
        return true;
    }
}

复杂度分析

  • 时间复杂度:$O(n^3)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。需要遍历的下标对数量是 $O(n^2)$,每个下标对判断是否符合三段式数组的全部条件的时间是 $O(n)$,因此时间复杂度是 $O(n^3)$。

  • 空间复杂度:$O(1)$。

解法二

思路和算法

可以遍历数组 $\textit{nums}$ 一次,判断是否符合三段式的条件。

三个子数组的单调性必须依次满足严格单调递增、严格单调递减、严格单调递增。首个子数组的起始下标是 $0$,从起始下标向右遍历,遍历过程中执行如下操作。

  • 由于三个子数组都满足严格单调递增或严格单调递减,因此不允许出现相邻元素相等的情况。如果遇到相邻元素相等的情况,则一定不符合三段式的条件。

  • 如果遍历到数组 $\textit{nums}$ 的末尾或者遇到相邻元素的单调性与当前子数组的单调性条件相反的情况(已经排除相邻元素相等的情况),则可以确定当前子数组的结束下标。将当前子数组的结束下标作为下一个子数组的起始下标,继续遍历下一个子数组。

当数组 $\textit{nums}$ 可以恰好分成三个子数组且依次满足严格单调递增、严格单调递减、严格单调递增时,返回 $\text{true}$,否则返回 $\text{false}$。

代码

###Java

class Solution {
    public boolean isTrionic(int[] nums) {
        int n = nums.length;
        int index = 0;
        for (int i = 0, direction = 1; i < 3; i++, direction *= -1) {
            index = findMonotonicRangeEnd(nums, index, direction);
            if (index < 0) {
                return false;
            }
        }
        return index == n - 1;
    }

    public int findMonotonicRangeEnd(int[] nums, int start, int direction) {
        int n = nums.length;
        int end = -1;
        for (int i = start; i < n && end < 0; i++) {
            if (i < n - 1 && nums[i + 1] == nums[i]) {
                return -1;
            }
            if (i == n - 1 || (nums[i + 1] - nums[i]) * direction < 0) {
                end = i;
            }
        }
        return end > start ? end : -1;
    }
}

###C#

public class Solution {
    public bool IsTrionic(int[] nums) {
        int n = nums.Length;
        int index = 0;
        for (int i = 0, direction = 1; i < 3; i++, direction *= -1) {
            index = FindMonotonicRangeEnd(nums, index, direction);
            if (index < 0) {
                return false;
            }
        }
        return index == n - 1;
    }

    public int FindMonotonicRangeEnd(int[] nums, int start, int direction) {
        int n = nums.Length;
        int end = -1;
        for (int i = start; i < n && end < 0; i++) {
            if (i < n - 1 && nums[i + 1] == nums[i]) {
                return -1;
            }
            if (i == n - 1 || (nums[i + 1] - nums[i]) * direction < 0) {
                end = i;
            }
        }
        return end > start ? end : -1;
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。最多需要遍历数组一次。

  • 空间复杂度:$O(1)$。

两种写法,推广到多个拐弯的一般情况(Python/Java/C++/Go)

作者 endlesscheng
2025年8月3日 12:22

写法一:三个循环

三段式子数组必须满足「严格递增 - 严格递减 - 严格递增」,一共三段,每一段至少要有两个数

每一段分别用一个循环寻找,具体请看 视频讲解 中的图,欢迎点赞关注~

###py

class Solution:
    def isTrionic(self, nums: List[int]) -> bool:
        n = len(nums)
        # 第一段
        i = 1
        while i < n and nums[i - 1] < nums[i]:
            i += 1
        if i == 1:  # 第一段至少要有两个数
            return False

        # 第二段
        i0 = i
        while i < n and nums[i - 1] > nums[i]:
            i += 1
        if i == i0 or i == n:  # 第二段至少要有两个数,第三段至少要有两个数
            return False

        # 第三段
        while i < n and nums[i - 1] < nums[i]:
            i += 1
        return i == n

###java

class Solution {
    public boolean isTrionic(int[] nums) {
        int n = nums.length;
        // 第一段
        int i = 1;
        while (i < n && nums[i - 1] < nums[i]) {
            i++;
        }
        if (i == 1) { // 第一段至少要有两个数
            return false;
        }

        // 第二段
        int i0 = i;
        while (i < n && nums[i - 1] > nums[i]) {
            i++;
        }
        if (i == i0 || i == n) { // 第二段至少要有两个数,第三段至少要有两个数
            return false;
        }

        // 第三段
        while (i < n && nums[i - 1] < nums[i]) {
            i++;
        }
        return i == n;
    }
}

###cpp

class Solution {
public:
    bool isTrionic(vector<int>& nums) {
        int n = nums.size();
        // 第一段
        int i = 1;
        while (i < n && nums[i - 1] < nums[i]) {
            i++;
        }
        if (i == 1) { // 第一段至少要有两个数
            return false;
        }

        // 第二段
        int i0 = i;
        while (i < n && nums[i - 1] > nums[i]) {
            i++;
        }
        if (i == i0 || i == n) { // 第二段至少要有两个数,第三段至少要有两个数
            return false;
        }

        // 第三段
        while (i < n && nums[i - 1] < nums[i]) {
            i++;
        }
        return i == n;
    }
};

###go

func isTrionic(nums []int) bool {
n := len(nums)
// 第一段
i := 1
for i < n && nums[i-1] < nums[i] {
i++
}
if i == 1 { // 第一段至少要有两个数
return false
}

// 第二段
i0 := i
for i < n && nums[i-1] > nums[i] {
i++
}
if i == i0 || i == n { // 第二段至少要有两个数,第三段至少要有两个数
return false
}

// 第三段
for i < n && nums[i-1] < nums[i] {
i++
}
return i == n
}

写法二:一个循环

如果题目改成「增减增减增」,难道要写五个循环吗?

改成统计拐弯的次数,可以推广到更一般的情况。

###py

class Solution:
    def isTrionic(self, nums: List[int]) -> bool:
        if nums[0] >= nums[1]:  # 一开始必须是递增的
            return False
        cnt = 1
        for i in range(2, len(nums)):
            if nums[i - 1] == nums[i]:
                return False
            if (nums[i - 2] < nums[i - 1]) != (nums[i - 1] < nums[i]):
                cnt += 1
        return cnt == 3  # 一定是增减增

###java

class Solution {
    public boolean isTrionic(int[] nums) {
        if (nums[0] >= nums[1]) { // 一开始必须是递增的
            return false;
        }
        int cnt = 1;
        for (int i = 2; i < nums.length; i++) {
            if (nums[i - 1] == nums[i]) {
                return false;
            }
            if ((nums[i - 2] < nums[i - 1]) != (nums[i - 1] < nums[i])) {
                cnt++;
            }
        }
        return cnt == 3; // 一定是增减增
    }
}

###cpp

class Solution {
public:
    bool isTrionic(vector<int>& nums) {
        if (nums[0] >= nums[1]) { // 一开始必须是递增的
            return false;
        }
        int cnt = 1;
        for (int i = 2; i < nums.size(); i++) {
            if (nums[i - 1] == nums[i]) {
                return false;
            }
            if ((nums[i - 2] < nums[i - 1]) != (nums[i - 1] < nums[i])) {
                cnt++;
            }
        }
        return cnt == 3; // 一定是增减增
    }
};

###go

func isTrionic(nums []int) bool {
if nums[0] >= nums[1] { // 一开始必须是递增的
return false
}
cnt := 1
for i := 2; i < len(nums); i++ {
if nums[i-1] == nums[i] {
return false
}
if (nums[i-2] < nums[i-1]) != (nums[i-1] < nums[i]) {
cnt++
}
}
return cnt == 3 // 一定是增减增
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

专题训练

见下面滑动窗口与双指针题单的「六、分组循环」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

昨天 — 2026年2月2日技术

拒绝造轮子!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 的基本用法和对比,对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

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 组件~

❌
❌