普通视图

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

Flutist - Flutter 模块化架构管理框架

作者 JarvanMo
2026年4月20日 09:16

简单而系统地管理你的 Flutter 模块化结构


简介

随着我们的项目增长,模块化似乎势在必行,而我最近发现了一个比较新的Flutter模块化管理框架——Flutist。它是一个专为 Flutter 应用设计的强大项目管理框架,灵感来源于 iOS 开发生态中的 Tuist[1]。它为管理大型 Flutter 项目提供了一套结构化的方法,具备模块化架构、集中式依赖管理和代码生成能力。

为什么选择 Flutist?

模块化是大型 Flutter 项目的标准方案——独立构建、并行开发、测试隔离,优势显而易见。但随着模块数量增长,管理开销也随之攀升。Flutist 通过自动化消除了这些开销。

类型安全的依赖管理

当模块超过 10 个时,包版本不一致的问题极易出现。在 package.dart 中声明一次版本,flutist generate 会自动生成 flutist_gen.dart,所有模块都能通过 IDE 自动补全和类型检查安全地引用依赖。


    
    
    
  // 1. package.dart — 只声明一次版本
Dependency(name: 'dio', version: '^5.3.0'),
Dependency(name: 'flutter_bloc', version: '^8.1.6'),

// 2. flutist_gen.dart — 由 flutist generate 自动生成
Dependency get dio         => dependencies.firstWhere((d) => d.name == 'dio');
Dependency get flutterBloc => dependencies.firstWhere((d) => d.name == 'flutter_bloc');
Module     get authDomain   => modules.firstWhere((m) => m.name == 'auth_domain');

// 3. project.dart — 类型安全引用(IDE 自动补全 ✅)
Module(
  name: 'auth_data',
  dependencies: [package.dependencies.dio, package.dependencies.flutterBloc],
  modules: [package.modules.authDomain],
),

集中式 pubspec.yaml 管理

每增加一个模块就多一个 pubspec.yaml。升级一个包的版本意味着要手动编辑每个引用它的文件。

Flutist 根据 project.dart 的声明自动同步所有 pubspec.yaml 文件。开发者只需编辑一个文件——project.dart


    
    
    
  $ flutist generate
✓ pubspec.yaml synced: app, auth_domain, auth_data, auth_presentation,
                      product_interface, product_implementation ... (24 total)
✓ all architecture rules passed
✓ done (0.8s)

架构规则自动化

仅靠文档和代码审查很难持续保持架构规则的一致性。在开发压力下,domain 层最终会导入 http,或者一个功能模块直接引用了另一个功能模块的实现。这些违规很难被发现,等到发现时往往已经扩散开来。

Flutist 将架构规则转化为可执行代码。在 strictMode: true(默认值)下,任何违规都会立即终止 generate。曾经只存在于文档中的原则,现在变成了构建关卡。


    
    
    
  $ flutist generate
✗ [B4] auth_domain → auth_data: 检测到反向依赖 — domain 不应依赖 data
   → 请从 auth_domain 中移除 Dio 导入,仅声明 Repository 接口
✗ generate 已终止(strictMode: true

即使是刚加入团队、对架构理解不深的新成员,在违反规则的那一刻也能获得清晰的反馈。架构违规不再依赖人工审查,工具会自动检查。

样板代码自动生成

模块化架构最大的痛点之一就是样板代码。每个新功能都需要创建 interfaceimplementationtestingtestsexample 包,每个包都有自己的 pubspec.yamllib/ 结构和 barrel 文件。

像 BLoC 这样的状态管理模式,每个功能都需要 event、state、BLoC、page 和 widget 文件。使用 flutist createflutist scaffold,一条命令就能生成所有这些内容。


    
    
    
  # 以 micro 类型创建 todos 功能 — 5 个包 + 完整结构自动化
$ flutist create --name todos --path features --options micro
✓ features/todos/todos_interface      已创建
✓ features/todos/todos_implementation 已创建
✓ features/todos/todos_testing        已创建
✓ features/todos/todos_tests          已创建
✓ features/todos/todos_example        已创建

# 使用 BLoC 脚手架生成所有文件
$ flutist scaffold --template bloc --name todos_overview --path features/todos/todos_implementation
✓ todos_overview_bloc.dart  已创建
✓ todos_overview_event.dart 已创建
✓ todos_overview_state.dart 已创建

核心特性

特性 说明
声明式 通过单一的 project.dart 文件声明整个项目结构
单一来源 所有依赖版本通过 package.dart 集中管理
规则即代码 架构违规会立即终止生成过程

安装


    
    
    
  dart pub global activate flutist

前置条件:Flutter SDK,并确保 ~/.pub-cache/bin 已添加到 PATH

快速开始

1. 初始化项目


    
    
    
  cd my_flutter_project
flutist init

Flutist 会根据上下文自动适配:

  • pubspec.yaml:询问是否创建新的 Flutter 项目
  • 存在 pubspec.yaml:询问是新建项目还是迁移现有项目
    • • 新项目:创建 app 模块,添加到工作区,生成 lib/main.dart
    • • 现有项目:仅创建配置文件,保留现有代码结构

2. 创建模块


    
    
    
  # 创建 Clean 架构模块
flutist create --name login --path features --options clean

# 创建 Microfeature 架构模块
flutist create --name network --path packages --options micro

# 创建 Lite 模块
flutist create --name auth --path packages --options lite

# 创建单一包
flutist create --name utils --path core

3. 管理依赖


    
    
    
  # 添加包(自动解析版本)
flutist pub add http bloc flutter_bloc

# 同步依赖到所有模块
flutist generate

4. 从自定义模板生成代码


    
    
    
  # 列出可用模板
flutist scaffold list

# 从模板生成
flutist scaffold feature --name login
flutist scaffold feature --name login --path lib/features

命令一览

命令 描述 用法
init 初始化新项目或现有项目 flutist init
create 创建新模块 flutist create --name <name> --path <path> [--options <type>]
generate 同步依赖并重新生成文件 flutist generate
check 检查架构规则(CI 友好,不修改文件) flutist check
test 并行运行所有模块的测试 flutist test [-m <module>]
scaffold 从模板生成代码 flutist scaffold <template> --name <name>
pub 管理依赖 flutist pub add <package>
graph 可视化模块依赖关系 flutist graph [--format <format>]
help 显示帮助信息 flutist help [command]

核心文件

文件 说明
package.dart 外部包版本和模块名称的单一真实来源,多行格式为解析必需
project.dart 声明模块依赖和模块间关系,由 flutist generate 读取
flutist_gen.dart 自动生成的类型安全访问器,提供 IDE 自动补全支持

项目结构

典型的 Flutist 项目结构:


    
    
    
  my_project/
├── project.dart              # 项目配置
├── package.dart              # 集中式依赖管理
├── pubspec.yaml              # 工作区配置
├── lib/                      # 根应用代码
│   └── main.dart
├── app/                      # 主应用模块
│   ├── lib/
│   │   └── app.dart
│   └── pubspec.yaml
├── features/                 # 功能模块
│   └── auth/
│       ├── auth_domain/
│       ├── auth_data/
│       └── auth_presentation/
├── packages/                 # 库模块
│   └── network/
│       ├── network_interface/
│       ├── network_implementation/
│       ├── network_testing/
│       ├── network_tests/
│       └── network_example/
└── flutist/
    ├── templates/            # 脚手架模板
    └── flutist_gen.dart      # 生成的代码

模块类型

flutist create 会生成层级包并自动在 project.dart 中配置依赖关系

Clean 架构 (--options clean)

3 层 Clean Architecture,最适合需要清晰关注点分离的功能模块。


    
    
    
  features/login/
├── login_domain/          # 业务规则、实体、用例(无外部依赖)
├── login_data/            # 仓库、数据源、DTO
└── login_presentation/    # UI 和状态管理

自动配置依赖presentation → domaindata → domain

规则:所有依赖箭头指向 domain,domain 不依赖任何东西。

Microfeature 架构 (--options micro)

5 层 Microfeature Architecture,最适合跨功能共享的可复用库。


    
    
    
  packages/network/
├── network_interface/         # 公共 API(抽象类、模型)
├── network_implementation/    # 具体实现
├── network_testing/           # 测试辅助、模拟对象
├── network_tests/             # 单元测试和集成测试
└── network_example/           # 模块演示应用

自动配置依赖implementation/testing → interfacetests/example → implementation + testing

规则:消费者只依赖 interface,组合根注入实现。

Lite 架构 (--options lite)

4 层 Microfeature lite(无 example),最适合内部 API。


    
    
    
  packages/auth/
├── auth_interface/
├── auth_implementation/
├── auth_testing/
└── auth_tests/

单一包(省略 --options

无层级,最适合工具类、共享模型或应用外壳。


    
    
    
  core/utils/
├── lib/
│   └── utils.dart
└── pubspec.yaml

架构验证

flutist generateflutist check 自动执行以下规则:

规则 说明
实现引用 只有组合根(默认:app)和同功能测试/example 可以引用 _implementation
测试层隔离 _testing 包被排除在生产依赖之外
Example 独立性 _example 模块不能被任何生产代码引用
方向强制 同功能层级遵循声明的依赖方向
循环依赖 通过 DFS 遍历检测,绝不允许

配置选项


    
    
    
  // project.dart
ProjectOptions(
  strictModetrue,              // true(默认):违规时终止 / false:仅警告
  compositionRoots: ['app'],     // 允许引用 _implementation 的模块
)

Scaffold 脚手架模板

将重复性工作保存为模板,通过 flutist scaffold 自动化生成代码。

模板变量

.template 文件和 path 值中使用 {{变量}} 进行替换:

变量 输入 login_feature 输出
`{{name snake_case}}` login_feature login_feature
`{{name pascal_case}}` login_feature LoginFeature
`{{name camel_case}}` login_feature loginFeature
`{{name upper_case}}` login_feature LOGIN_FEATURE

template.yaml 结构


    
    
    
  description: "BLoC Feature Template"

attributes:
  - name: name
    required: true
  - name: path
    required: false
    default: "lib/features"

items:
  - type: file
    path"{{path}}/{{name | snake_case}}/{{name | snake_case}}_bloc.dart"
    templatePath: "bloc.dart.template"

  - typestring
    path"{{path}}/{{name | snake_case}}/README.md"
    contents: |
      # {{name | pascal_case}}

Item 类型

类型 说明
file 读取 .template 文件,替换变量后生成
string 使用内联内容直接生成文件
directory 复制整个模板目录

实战示例:BLoC Feature 模板

bloc.dart.template


    
    
    
  import 'package:bloc/bloc.dart';

part '{{name | snake_case}}_event.dart';
part '{{name | snake_case}}_state.dart';

class {{name | pascal_case}}Bloc
    extends Bloc<{{name | pascal_case}}Event, {{name | pascal_case}}State> {
  {{name | pascal_case}}Bloc() : super(const {{name | pascal_case}}Initial()) {
    on<{{name | pascal_case}}Started>(_onStarted);
  }

  Future<void_onStarted(
    {{name | pascal_case}}Started event,
    Emitter<{{name | pascal_case}}State> emit,
  ) async {}
}

event.dart.template


    
    
    
  part of '{{name | snake_case}}_bloc.dart';

sealed class {{name | pascal_case}}Event {
  const {{name | pascal_case}}Event();
}

final class {{name | pascal_case}}Started extends {{name | pascal_case}}Event {
  const {{name | pascal_case}}Started();
}

state.dart.template


    
    
    
  part of '{{name | snake_case}}_bloc.dart';

sealed class {{name | pascal_case}}State {
  const {{name | pascal_case}}State();
}

final class {{name | pascal_case}}Initial extends {{name | pascal_case}}State {
  const {{name | pascal_case}}Initial();
}

final class {{name | pascal_case}}Loading extends {{name | pascal_case}}State {
  const {{name | pascal_case}}Loading();
}

final class {{name | pascal_case}}Loaded<T> extends {{name | pascal_case}}State {
  final T data;
  const {{name | pascal_case}}Loaded(this.data);
}

final class {{name | pascal_case}}Error extends {{name | pascal_case}}State {
  final String message;
  const {{name | pascal_case}}Error(this.message);
}

运行生成


    
    
    
  $ flutist scaffold bloc_feature --name login --path lib/features

✓ lib/features/login/login_bloc.dart
✓ lib/features/login/login_event.dart
✓ lib/features/login/login_state.dart

实战示例:Riverpod Notifier 模板

notifier.dart.template


    
    
    
  import 'package:riverpod_annotation/riverpod_annotation.dart';
import '{{name | snake_case}}_state.dart';

part '{{name | snake_case}}_notifier.g.dart';

@riverpod
class {{name | pascal_case}}Notifier extends _${{name | pascal_case}}Notifier {
  @override
  {{name | pascal_case}}State build() => const {{name | pascal_case}}State.initial();

  Future<void> load() async {
    state = const {{name | pascal_case}}State.loading();
    try {
      state = const {{name | pascal_case}}State.loaded(null);
    } catch (e) {
      state = {{name | pascal_case}}State.error(e.toString());
    }
  }
}

state.dart.template


    
    
    
  import 'package:freezed_annotation/freezed_annotation.dart';

part '{{name | snake_case}}_state.freezed.dart';

@freezed
class {{name | pascal_case}}State with _${{name | pascal_case}}State {
  const factory {{name | pascal_case}}State.initial()            = _Initial;
  const factory {{name | pascal_case}}State.loading()            = _Loading;
  const factory {{name | pascal_case}}State.loaded(dynamic data) = _Loaded;
  const factory {{name | pascal_case}}State.error(String msg)    = _Error;
}

示例项目

Clean Architecture 示例

flutist_clean_architecture[2]

  • • Domain、Data、Presentation 三层 Clean Architecture
  • • 集中式依赖管理
  • • 大型 Flutter 应用最佳实践

Microfeature Architecture 示例

flutist_microfeature_architecture[3]

  • • Interface、Implementation、Tests、Testing 四层 Microfeature 架构
  • • 完全隔离的可复用库模块
  • • 集中式依赖管理

相关链接

资源 链接
📦 pub.dev pub.dev/packages/fl…
📖 文档网站 deepwiki.com/seonwooke/f…
💻 GitHub github.com/seonwooke/f…

引用链接

[1] Tuist: tuist.io/
[2] flutist_clean_architecture: github.com/seonwooke/f…
[3] flutist_microfeature_architecture: github.com/seonwooke/f…

6.响应式系统比对:通过 Vue3 响应式库写 React 应用

作者 Cobyte
2026年4月20日 09:04

前言

鉴于 Vue3 已经把响应式库进行了独立,也就是 @vue/reactivity,既然 Mobx 也是一个响应式库都可以应用在 React 上,那么 @vue/reactivity 可不可以也应用在 React 上呢?很显然是可以的,社区里也有很多关于这么方面的实践。那么我们这里也提供一个参考 Mobx 实现的版本。

跟 Mobx 对比的话,@vue/reactivity 就相当于 mobx 库,所以我们只需要参考 mobx-react-lite 实现一个 vue-react-lite 即可。

实现 vue-react-lite

我们通过上一篇文章可以知道 Mobx 是通过 mobx-react-lite 实现与 React 进行链接的,其中最重要的函数就是 observer,那么我们也在 vue-react-lite 中实现一个 observer 函数。根据我们前篇所学的知识知道 observer 是一个高阶函数,所以我们初步把 observer 的基础架构搭建出来。

function observer(baseComponent) {
    return (props) => {
        return baseComponent(props)
    }
}

接下来我们知道 Mobx 中是通过 Reaction 这个订阅者中介来实现不同组件函数的代理的,而在 @vue/reactivity 中的跟 Reaction 相同角色的的则是 ReactiveEffect,那么我们就可以通过它来实现我们想要的功能。

代码实现如下:

import { useState, useRef } from "react"
import { ReactiveEffect  } from "@vue/reactivity"
function observer(baseComponent) {
    return (props) => {
        const [, setState] = useState()
        const admRef = useRef(null)
        if (!admRef.current) {
            admRef.current = new ReactiveEffect(() => {
                return baseComponent(props)
            }, () => {
                setState(Symbol())
            })
        }
        const effect = admRef.current
        return effect.run()
    }
}

那么我们就通过 ReactiveEffect 实现了一个跟 mobx-react-lite 中的 observer 一样的功能的函数。

如果大家对 Vue3 的 effect 函数熟悉的话,我们上述 observer 的实现过程跟 Vue3 的 effect 实现很类似的。我们可以回顾一下 Vue3 的 ReactiveEffect 类的功能,它本质是一个订阅者中介,跟 Vue2 的 Watcher 类是一样的角色。ReactiveEffect 的第一个参数就是具体的订阅者函数,而第二个参数则是一个叫 scheduler 的回调函数,在更新的时候如果存在 scheduler 回调函数则执行 scheduler 回调函数,否则执行第一个参数的函数。基于这个原理,我们就在 ReactiveEffect 的第二个参数中设置执行 React 的更新 setState(Symbol()),同时 ReactiveEffect 上存在一个 run 方法,需要通过手动执行进行初始化。

应用 vue-react-lite

那么我们上面通过 ReactiveEffect 实现了 observer 函数,这样我们就可以在 React 中应用 Vue3 的数据响应式库了。下面我们来测试一下:

import { reactive } from "@vue/reactivity";
import { observer } from "./vue-react-lite"

const proxy = reactive({ name: 'Cobyte', secondsPassed: 0 })

const TimerView = observer(({ proxy }) => <span>the content run in `@vue/reactivity` is "Seconds passed: {proxy.secondsPassed}"</span>)

function App() {
  return (
    <TimerView proxy={proxy}></TimerView>
  );
}

setInterval(() => {
  proxy.secondsPassed +=1
}, 1000)

export default App;

打印结果如下:

tutieshi_640x195_5s.gif

我们发现已经成功把 @vue/reactivity 库应用到 React 中了。

根据 Mobx 的启发实现 Vue 数据响应式的 OOP

我们知道 Mobx 的写法是更倾向 OOP 的,同时是严格遵守单向数据流,所以我们也可以在通过 Vue 响应式库提供的 shallowRef API 实现 OOP。

import { reactive, shallowRef } from "@vue/reactivity"
import { observer } from "./vue-react-lite"

class DataService {
  constructor(val) {
    this.r = shallowRef(val)
  }
  get count() {
    return this.r.value
  }
  setCount(val) {
    this.r.value = val
  }
}
const dataService = new DataService(0)
const TimerView = observer(({ proxy }) => <span>the content run in @vue/reactivity is "Seconds passed: {proxy.count}"</span>)

function App() {
  return (
    <TimerView proxy={dataService}></TimerView>
  );
}

setInterval(() => {
  dataService.setCount(Date.now())
}, 1000)

export default App;

但上述方式还是不能堵住别人可以通过直接修改对象的方式更改响应式的值,从而打破单向数据流的规则。

例如下面的例子:

setInterval(() => {
    dataService.r.value = Date.now()
}, 1000)

那么为了堵住这个漏洞,我们可以通过私有变量来解决:

class DataService {
  #r
  constructor(val) {
    this.#r = shallowRef(val)
  }
  get count() {
    return this.#r.value
  }
  setCount(val) {
    this.#r.value = val
  }
}
const dataService = new DataService(0)

这个时候我们就不能通过直接修改对象的方式更改响应式的值了。

setInterval(() => {
    dataService.#r.value = Date.now()
}, 1000)

我们上述这种方式比较适合基本数据类型的情况,如果是引用类型的话,就不太适用了。如果是引用类型我们不可能在上面写那么多属性访问器,我们可以像 Vue2 那样把所有的响应式数据代理到 Vue 的实例对象上,然后可以通过 this 进行访问。

修改如下:

import { shallowRef } from "@vue/reactivity";
class DataService {
  #r
  constructor(val) {
    this.#r = shallowRef(val)
    // 像 Vue2 一样把响应式数据代理到实例对象上
    return new Proxy(this, {
      get(target, key) {
        // 如果是响应式数据就返回响应式数据
        if (target.#r.value[key]) {
          return target.#r.value[key]
        } else {
          // 如果是自身的属性就返回自身属性,例如 setState
          return target[key]
        }
      },
      set(target, key, val) {
        throw new Error('请通过 setState 方法进行更新')
      }
    })
  }
  setState(val) {
    this.#r.value = val
  }
}

const dataService = new DataService({ name: 'Cobyte', date: '2024-03-22', now: { time: 123 } })
const TimerView = observer(({ proxy, now }) => <span>the content run in @vue/reactivity "author: {proxy.name}, the date is: {proxy.date} now is {proxy.now.time}"</span>)

function App() {
  return (
    <TimerView proxy={dataService} now={dataService.now}></TimerView>
  );
}

setInterval(() => {
  dataService.setState({ name: '掘金签约作者', date: '2024年3月22日', now: { time: Date.now() }})
}, 1000)

export default App;

我们通过把响应式数据代理到实例对象上,优化了引用类型的使用方式。

tutieshi_640x284_4s.gif

至此,我们受 Mobx 的启发实现了在 React 中使用 Vue3 的响应式数据库,同时跟 Mobx、Flux、Redux 一样实现单向数据流。不过我们目前采用的是最新的技术私有变量,这个方案目前兼容性并不好,但作为技术交流也可以给大家一个启发。

为什么 Vue 可以通过重新运行组件 render 函数进行更新?

我们在前篇文章通过相对比较简洁的代码实现了 Mobx 的核心原理,同时对比了同时响应式的 Vue 和 Mobx 的最大设计区别,在 Vue 中创建的响应式数据,是可以随意在任何地方通过普通属性访问器进行修改的,但 Mobx 中则不提倡这种可以随意修改 state 的方式,在 Mobx 中希望开发者通过 actions 来改变 state,本质是像 React 那样通过一个函数来修改 state,或者说是遵循 Flux 和 Redux 的单向数据流思想。同时 Mobx 中的订阅者中介 Reaction 和 Vue 中的订阅者中介实现则有比较大的区别,主要是因为 Mobx 主要的设计受 React 的影响,在更新的时候需要特别的设置,而不像 Vue 那样直接重新运行副作用函数就可以了,这个说到底也是因为 React 不是靠依赖追踪来实现响应式的缘故。

那么问题就来了,为什么 Vue 可以通过重新运行组件 render 函数进行更新,而 React 则不行?当然 React 在普通情况下,你在更新的时候是不知道哪个组件函数需要更新,但我们通过 Mobx 就可以实现了依赖收集,就可以知道更新的时候那些组件函数需要重新执行,但即便这样 React 也不能通过重新执行组件函数来实现更新,这是为什么呢?

一个组件要渲染到页面上需要哪些必备条件呢?我们先看看下面的一个 React 应用的渲染例子:

ReactDOM.render(App, document.getElementById("root")

那么从上述的 React 应用渲染的例子我们可以知道,一个组件渲染到页面上是一定要知道渲染到哪个元素容器中的,这一点无论是 React 还是 Vue 都是一样的。如果仅仅只是执行一个组件函数是不能实现渲染的,所以在实现 Mobx 的 Reaction 的时候,不能像 Vue 的订阅者中介那样实现。那么为什么在 Vue 中可以通过重新运行组件 render 函数进行更新呢,或者是直接重新运行组件函数进行更新呢?

这是因为在 Vue 中被收集到订阅者记录变量中的函数,并不是组件的 render 函数,而是一个高阶函数,在高阶函数内部才最后执行组件的 render 函数。我们这里以 Vue3 中的情况进分析,在 Vue3 中最后处理组件 render 函数的地方是在 setupRenderEffect 函数中,下面是 setupRenderEffect 的简洁实现代码结构。

function setupRenderEffect(instance, initialVNode, container, anchor, parentSusp) {
    const componentUpdateFn = () => {
        if (!instance.isMounted) {
            // 初始化走这里
            const subTree = (instance.subTree = renderComponentRoot(instance))
            // 通过 patch 函数进行挂载,第三个参数就要挂载的HTML容器
            patch(
                null,
                subTree,
                container, // 目标挂载点
                anchor,
                instance,
                parentSuspense,
                isSVG
            )
            instance.isMounted = true
        } else {
            // 更新走这里
            // 重新执行组件 render 函数
            const nextTree = renderComponentRoot(instance)
            // 上一次的生成的虚拟DOM为旧的虚拟DOM
            const prevTree = instance.subTree
            instance.subTree = nextTree
            // 更新也是通过 patch 函数进行挂载,也同样需要提供挂载的HTML容器,也就是第三个参数
            patch(
                prevTree,
                nextTree,
                // parent may have changed if it's in a teleport
                hostParentNode(prevTree.el!)!, // 更新的时候也需要提供渲染的目标挂载HTML元素
                // anchor may have changed if it's in a fragment
                getNextHostNode(prevTree),
                instance,
                parentSuspense,
                isSVG
            )
        }
    }
    // 从这我们可以看到被收集的依赖并不是组件的 render 函数,而是一个包装函数 componentUpdateFn
    const effect = (instance.effect = new ReactiveEffect(
      componentUpdateFn,
      () => queueJob(update), // 调度函数 scheduler,最后还是执行 update 方法
      instance.scope // track it in component's effect scope
    ))
    // 初始化的时候需要执行 run 方法
    const update = (instance.update = () => effect.run())
    // 执行
    update()
}

我们从上面的 Vue3 的 setupRenderEffect 的简洁实现代码中可以看到在 Vue 中所谓收集依赖的依赖并不是组件的渲染函数,而是一个包装函数,在包装函数中在初始化和更新阶段都是通过执行组件的 render 函数获得组件的虚拟DOM,然后再通过 patch 函数进行渲染挂载到具体的元素节点下。而在 Vue 的内部中是可以获取到具体需要渲染挂载的元素节点的,而我们在 React 的应用层首先是无法通过组件函数获得需要挂载的元素节点的,其次 React 的更新流程本质上就跟 Vue 这类型通过依赖收集的数据响应式框架不一样。

总结

本文受 Mobx 启发,利用 @vue/reactivity 的 ReactiveEffect 实现了类似 mobx-react-lite 的 observer 高阶函数,成功将 Vue 响应式库集成到 React 中,实现了单向数据流和依赖追踪。同时,通过私有变量和 Proxy 代理优化了 OOP 风格下的响应式数据访问,避免了直接修改状态。最后,从底层机制解释了 Vue 能够直接重新运行组件 render 函数更新,而 React 不能的根本原因:Vue 的依赖收集针对的是包含 patch 挂载逻辑的包装函数,可获取具体渲染容器;React 的更新流程不依赖此类追踪,且组件函数层面无法获取挂载节点。这揭示了两种框架在设计哲学与实现机制上的本质差异。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

THREE.JS实现一个魔法镜子!

作者 苏武难飞
2026年4月20日 08:44

分享一个THREE.JS实现的魔法镜子效果!

最近依然在学习THREE.JS发现了一个比较有意思的方法renderTarget,借助这个方法我们能实现很多有意思的效果

13

初始renderTarget

简单来说,RenderTarget(渲染目标) 就像是给相机准备的一张“离屏画布”或“隐藏显示器”。

以下是它的核心概念:

  1. 它是干什么的?

平时渲染时,相机拍到的画面直接显示在你的显示器屏幕上。 而使用 RenderTarget 时,相机拍到的画面被画在了一张内存中的纹理(Texture) 上。这被称为“离屏渲染”。

  1. 为什么要用它? 当你需要“在 3D 场景里显示 3D 场景”时,它是必不可少的。
  • 后处理效果:先把场景拍下来,加上模糊或调色滤镜,再贴到屏幕上。

  • 镜面与传送门:用一个虚拟相机拍下镜子背后的场景存入 RenderTarget,然后把这张图贴在镜子的平面上。

  • 动态贴图:比如做一个监控显示屏,画面是另一个房间的实时录像。

React Three Fiber中使用的方法如下

const mainRenderTarget = useFBO();

useFrame((state) => {
  const { gl, scene, camera } = state;

  gl.setRenderTarget(mainRenderTarget);
  gl.render(scene, camera);

  mesh.current.material.map = mainRenderTarget.texture;

  gl.setRenderTarget(null);
});

为什么要放到useFrame中呢,是因为我们需要绘制每一帧的状态并把当前的状态当作纹理传递给几何体

先从一个简单的例子开始🌰



const InfinityMirror = () => {
    const mesh = useRef<THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>>(null);

    const renderTarget = useFBO();

    useFrame((state) => {
        const {gl, scene, camera} = state;

        if (mesh.current) {
            mesh.current.material.map = null;
        }

        gl.setRenderTarget(renderTarget);
        gl.render(scene, camera);

        if (mesh.current) {
            mesh.current.material.map = renderTarget.texture;
        }

        gl.setRenderTarget(null);
    });


    return (
        <>
            <Sky sunPosition={[10, 10, 0]}/>
            <directionalLight position={[10, 10, 0]} intensity={1}/>
            <ambientLight intensity={0.5}/>
            <Environment preset="sunset"/>
            <mesh position={[-2, 0, 0]}>
                <dodecahedronGeometry args={[1]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh position={[0, 2, 0]}>
                <dodecahedronGeometry args={[1]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh position={[2, 0, 0]}>
                <dodecahedronGeometry args={[1]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh position={[0, -2, 0]}>
                <dodecahedronGeometry args={[1]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh ref={mesh} scale={1}>
                <planeGeometry args={[2, 2]}/>
                <meshBasicMaterial/>
            </mesh>
        </>
    );
};



function App() {

    return <Canvas camera={{position: [0, 0, 9]}} dpr={[1, 2]}>
        <InfinityMirror/>
        <OrbitControls autoRotate={false}/>
        <GizmoHelper alignment="bottom-right" margin={[80, 80]}>
            <GizmoViewport axisColors={['red', 'green', 'blue']} labelColor="white" />
        </GizmoHelper>
    </Canvas>
}

20260415143110

核心是利用renderTarget把当前的屏幕内容当作纹理传递给了我们的planeGeometry,逻辑图如下

01

画外渲染

目前我们已经知道renderTarget是使用渲染目标来拍摄当前场景的快照,并将结果用作纹理,那么我们是不是可以考虑用到createPortal来渲染一个不在当前屏幕中的场景呢!使用createPortal的基本语法如下

import { Canvas, createPortal } from '@react-three/fiber';
import * as THREE from 'three';

const Scene = () => {
  const otherScene = new THREE.Scene();

  return (
    <>
      <mesh>
        <planeGeometry args={[2, 2]} />
        <meshBasicMaterial />
      </mesh>
      {createPortal(
        <mesh>
          <sphereGeometry args={[1, 64]} />
          <meshBasicMaterial />
        </mesh>,
        otherScene
      )}
    </>
  );
};

还是通过一个例子来学习

const Portal = () => {

    const mesh = useRef<THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>>(null);
    const otherMesh = useRef<THREE.Mesh<THREE.DodecahedronGeometry, THREE.MeshPhysicalMaterial>>(null);
    const otherCamera = useRef<THREE.PerspectiveCamera>(null);
    const otherScene = new THREE.Scene();

    const renderTarget = useFBO();


    useFrame((state) => {
        const {gl, clock, camera} = state;
        if (otherCamera.current) {
            otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);
        }

        gl.setRenderTarget(renderTarget);

        if (otherCamera.current) {
            gl.render(otherScene, otherCamera.current);
        }

        if (mesh.current) {
            mesh.current.material.map = renderTarget.texture;
        }

        if (otherMesh.current) {
            otherMesh.current.rotation.x = Math.cos(clock.elapsedTime / 2);
            otherMesh.current.rotation.y = Math.sin(clock.elapsedTime / 2);
            otherMesh.current.rotation.z = Math.sin(clock.elapsedTime / 2);
        }

        gl.setRenderTarget(null);
    });


    return (
        <>
            <PerspectiveCamera
                manual
                ref={otherCamera}
                aspect={1.5 / 1}
            />
            {createPortal(
                <>
                    <Sky sunPosition={[10, 10, 0]}/>
                    <Environment preset="sunset"/>
                    <directionalLight args={[10, 10, 0]} intensity={1}/>
                    <ambientLight intensity={0.5}/>
                    <ContactShadows
                        frames={1}
                        scale={10}
                        position={[0, -2, 0]}
                        blur={8}
                        opacity={0.75}
                    />
                    <group>
                        <mesh ref={otherMesh}>
                            <dodecahedronGeometry args={[1]}/>
                            <meshPhysicalMaterial
                                roughness={0}
                                clearcoat={1}
                                clearcoatRoughness={0}
                                color="#73B9ED"
                            />
                        </mesh>
                        <mesh position={[-3, 1, -2]}>
                            <dodecahedronGeometry args={[1]}/>
                            <meshPhysicalMaterial
                                roughness={0}
                                clearcoat={1}
                                clearcoatRoughness={0}
                                color="#73B9ED"
                            />
                        </mesh>
                        <mesh position={[3, -1, -2]}>
                            <dodecahedronGeometry args={[1]}/>
                            <meshPhysicalMaterial
                                roughness={0}
                                clearcoat={1}
                                clearcoatRoughness={0}
                                color="#73B9ED"
                            />
                        </mesh>
                    </group>
                </>,
                otherScene
            )}
            <mesh ref={mesh}>
                <planeGeometry args={[3, 2]}/>
                <meshBasicMaterial color="white"/>
            </mesh>
        </>
    );

}

02

这里有几个重点

  1. PerspectiveCamera因为是画外渲染所以我们必须再建立一个摄影机
  2. otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);新建立的摄影机视角和外部主视角保持一致
  3. <PerspectiveCamera aspect={1.5 / 1}/> 1.5 / 1 是为了和 <planeGeometry args={[3, 2]}/> 保持一致

我们这里讨论一下重点3,如果我们把画外场景的摄影机比例改变了呢!

// <PerspectiveCamera aspect={1.5 / 1}/>
<PerspectiveCamera aspect={1 / 1}/>

03

可以很明显的看到我们的纹理比例被压缩了,这也引申出了另一个问题

uv坐标 or 屏幕坐标

首先我们还是先看一张示意图

04

我们现在使用的就是默认的UV坐标,我们生成的纹理图会自动的根据几何体的宽高来压缩以适配我们的UV坐标

如果我们要实现一个屏幕坐标需要几个步骤

  1. 实现自定义着色器
  2. uniform来传递纹理和屏幕坐标

1. 实现自定义着色器


<mesh ref={mesh}>
                <planeGeometry args={[3, 2]}/>
                <meshBasicMaterial color="white"/>
</mesh>

// 👆之前我们一直用的是meshBasicMaterial现在需要改成 

<mesh ref={mesh}>
    <planeGeometry args={[3, 2]}/>
    {/*<meshBasicMaterial color="white"/>*/}

    <shaderMaterial
        fragmentShader={fragmentShader}
        vertexShader={vertexShader}
        uniforms={uniforms}/>
</mesh>


// 顶点着色器vertexShader
void main() {
    vec4 worldPos = modelMatrix * vec4(position, 1.0);
    vec4 mvPosition = viewMatrix * worldPos;
    gl_Position = projectionMatrix * mvPosition;
}

// fragmentShader
uniform vec2 winResolution;
uniform sampler2D uTexture;

void main() {
    vec2 uv = gl_FragCoord.xy / winResolution.xy;
    vec4 color = texture2D(uTexture, uv);
    gl_FragColor = color;
    #include <tonemapping_fragment>
    #include <colorspace_fragment>
}

这里我们的顶点着色器vertexShader就是默认的几何体空间定位设置,我们重点看一下fragmentShader

  • gl_FragCoord.xy 几何体在屏幕空间的位置
  • winResolution 通过uniform传递进来的屏幕大小
  • uTexture 通过uniform传递进来的纹理

所以我们现在uv计算逻辑就变成了几何体在屏幕空间中的坐标位置百分比!

2. 用uniform来传递纹理和屏幕坐标


    const uniforms = useMemo(() => ({
            uTexture: {value: null,},
            winResolution: {
                value: new THREE.Vector2(window.innerWidth, window.innerHeight).multiplyScalar(Math.min(window.devicePixelRatio, 2)),
            },
        }),
        []
    );


    useFrame((state) => {
        const {gl, clock, camera} = state;
        if (otherCamera.current) {
            otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);
        }

        gl.setRenderTarget(renderTarget);

        if (otherCamera.current) {
            gl.render(otherScene, otherCamera.current);
        }

        if (mesh.current) {
            mesh.current.material.uniforms.uTexture.value = renderTarget.texture;
            mesh.current.material.uniforms.winResolution.value = new THREE.Vector2(
                window.innerWidth,
                window.innerHeight
            ).multiplyScalar(Math.min(window.devicePixelRatio, 2));
        }

        if (otherMesh.current) {
            otherMesh.current.rotation.x = Math.cos(clock.elapsedTime / 2);
            otherMesh.current.rotation.y = Math.sin(clock.elapsedTime / 2);
            otherMesh.current.rotation.z = Math.sin(clock.elapsedTime / 2);
        }

        gl.setRenderTarget(null);
    });


屏幕坐标

调整一下几何体和摄影机看的更明显

...
...
...

 <PerspectiveCamera
                        makeDefault
                        manual
                        ref={otherCamera}
                        position={[0, 0, 8]}
                    />

   <mesh ref={mesh}>
                <boxGeometry args={[3, 3,3]}/>
                {/*<meshBasicMaterial color="white"/>*/}

                <shaderMaterial
                    fragmentShader={fragmentShader}
                    vertexShader={vertexShader}
                    uniforms={uniforms}/>

   </mesh>

05

一起来使用魔法吧!

我们的基础已经打完了,接下来我们就正式开始我们的魔法教程!

06

之前我们都是把整个的纹理绘制到几何体中,在这一章节我们开始使用动态的纹理图并把其传递给几何体

const Lens2: React.FC = () => {

    const mesh1 = useRef<THREE.Mesh<THREE.DodecahedronGeometry, THREE.MeshPhysicalMaterial>>(null);
    const lens = useRef<THREE.Mesh<THREE.SphereGeometry, THREE.ShaderMaterial>>(null);
    const renderTarget = useFBO();

    const uniforms = useMemo(
        () => ({
            uTexture: {value: null,},
            winResolution: {
                value: new THREE.Vector2(window.innerWidth, window.innerHeight).multiplyScalar(Math.min(window.devicePixelRatio, 2)),
            },
        }),
        []
    );

    useFrame((state) => {
        const {gl, clock, scene, camera, pointer} = state;

    });

    return (
        <>
            <Sky sunPosition={[10, 10, 0]}/>
            <Environment preset="sunset"/>
            <directionalLight position={[10, 10, 0]} intensity={1}/>
            <ambientLight intensity={0.5}/>
            <ContactShadows
                frames={1}
                scale={10}
                position={[0, -2, 0]}
                blur={4}
                opacity={0.2}
            />
            <mesh ref={lens} scale={0.5} position={[0, 0, 2.5]}>
                <sphereGeometry args={[1, 128]}/>
                <shaderMaterial
                    fragmentShader={fragmentShader}
                    vertexShader={vertexShader}
                    uniforms={uniforms}
                    wireframe={false}
                />
            </mesh>
            <group>
                <mesh ref={mesh1}>
                    <dodecahedronGeometry args={[1]}/>
                    <meshPhysicalMaterial
                        roughness={0}
                        clearcoat={1}
                        clearcoatRoughness={0}
                        color="#73B9ED"
                    />
                </mesh>
            </group>
        </>
    );
}

这是一个基础模板代码,此时的效果是

07

1. 使用 MeshTransmissionMaterial

MeshTransmissionMaterialThree.js 生态中(特别是在 react-three-drei 库中)非常受欢迎的一种高级材质,它主要用于模拟毛玻璃、塑料、水、或变色玻璃等具有物理感的高级透明效果。

相比于原生的 MeshPhysicalMaterial,它的性能更优化,且效果更具“数字美感”。


...
...

    useFrame((state) => {
        const {gl, clock, scene, camera, pointer} = state;

        const viewport = state.viewport.getCurrentViewport(state.camera, [0, 0, 2.5]);

        if (!lens.current) return;

        lens.current.position.x = THREE.MathUtils.lerp(
            lens.current.position.x,
            (pointer.x * viewport.width) / 2,
            0.1
        );
        lens.current.position.y = THREE.MathUtils.lerp(
            lens.current.position.y,
            (pointer.y * viewport.height) / 2,
            0.1
        );

        gl.setRenderTarget(renderTarget);
        gl.render(scene, camera);

        gl.setRenderTarget(null);

    });

...
...
...
 <mesh ref={lens} scale={0.5} position={[0, 0, 2.5]}>
                <sphereGeometry args={[1, 128]}/>
                <MeshTransmissionMaterial
                    buffer={renderTarget.texture}
                    ior={1.025}
                    thickness={0.5}
                    chromaticAberration={0.05}
                    backside/>
            </mesh>

08

2. 保存瞬时状态!

useFrame中我们是根据用户设备刷新率进行刷新的如1秒60次调用,而我们的

 gl.setRenderTarget(renderTarget);
        gl.render(scene, camera);

是保留当前帧的状态,所以我们可以利用这个特性做很多事情!

 mesh1.current.material.wireframe = true;

 // 👇保留线框状态
        gl.setRenderTarget(renderTarget);
        gl.render(scene, camera);

// 👇取消线框状态保证用户不能直接看到线框
        mesh1.current.material.wireframe = false;

        gl.setRenderTarget(null);

09

可以看到此时我们的效果看起来就像是鼠标滑过的地方直接展示了几何体的内部!!

3. 魔法筒

有了上面的基础我们再来画一个示意图看我们的魔法筒效果应该如何实现

图片来自Beautiful and mind-bending effects with WebGL Render Targets

图片来自Beautiful and mind-bending effects with WebGL Render Targets

也就是说我们利用两个圆柱体并且两个圆柱体的纹理分别使用小球和圆锥

  • 圆柱A-纹理使用小球,所以视角在圆柱A时看不到圆锥只能看到小球
  • 圆柱B-整体和圆柱A相反
 return <>
        <Sky sunPosition={[10, 10, 0]}/>
        <Environment preset="sunset"/>
        <directionalLight position={[10, 10, 0]} intensity={1}/>
        <ambientLight intensity={0.5}/>
        <group ref={groupRef}>
            <mesh
                ref={cylinder1}
                position={[0, 0, -4]}>
                <cylinderGeometry args={[3, 3, 8, 32]}/>
                <meshStandardMaterial
                    color="green"
                    transparent
                />
            </mesh>
            <mesh
                ref={cylinder2}
                position={[0, 0, 4]}
            >
                <cylinderGeometry args={[3, 3, 8, 32]}/>

                <meshStandardMaterial
                    color="red"
                    transparent
                />
            </mesh>
            <mesh>
                <torusGeometry args={[3, 0.2, 16, 100]}/>
                <meshStandardMaterial color="#F9F9F9"/>
            </mesh>
        </group>
    </>

20260416173256

圆柱体本身是由以下形状组成的

  • 顶部
  • 柱体
  • 底部

所以我们可以分别设置纹理!

<mesh
                ref={cylinder2}
                position={[0, 0, 4]}
            >
                <cylinderGeometry args={[3, 3, 8, 32]}/>

                <meshStandardMaterial
                    attach="material-0"
                    color="red"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-1"
                    color="green"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                />
            </mesh>

10

接下来旋转一下!

 <mesh
                ref={cylinder1}
                position={[0, 0, -4]}
                rotation={[-Math.PI / 2, 0, 0]}>
                <cylinderGeometry args={[3, 3, 8, 32]}/>
                <meshStandardMaterial
                    attach="material-0"
                    color="red"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-1"
                    color="green"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                />
            </mesh>
            <mesh
                ref={cylinder2}
                position={[0, 0, 4]}
                rotation={[Math.PI / 2, 0, 0]}
            >
                <cylinderGeometry args={[3, 3, 8, 32]}/>

                <meshStandardMaterial
                    attach="material-0"
                    color="red"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-1"
                    color="green"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                />
            </mesh>

11

接下来我们暂时先把这两个圆柱体隐藏,来做一个几何体的移动效果



    useFrame((state, delta) => {

        const {gl, scene, camera, clock} = state;

        const newPosZ = Math.sin(clock.elapsedTime) * 3.5;
        boxRef.current!.position.z = newPosZ;
        torusRef.current!.position.z = newPosZ;

        boxRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
        boxRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
        boxRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);

        torusRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
        torusRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
        torusRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);

    });



<mesh ref={torusRef} position={[0, 0, 0]}>
                <torusKnotGeometry args={[0.75, 0.3, 100, 16]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh ref={boxRef} position={[0, 0, 0]}>
                <boxGeometry args={[2, 2, 2]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"/>
            </mesh>

12

接下来就是见证奇迹的时候了!我们要运用我们之前保留关键帧的模式来动态的visible模型

const TransformPortal2: React.FC = () => {

    const groupRef = React.useRef<THREE.Group>(null)
    const boxRef = React.useRef<THREE.Mesh<THREE.BoxGeometry, THREE.MeshPhysicalMaterial>>(null)
    const torusRef = React.useRef<THREE.Mesh<THREE.TorusKnotGeometry, THREE.MeshPhysicalMaterial>>(null)
    const cylinder1 = React.useRef<THREE.Mesh<THREE.CylinderGeometry, THREE.ShaderMaterial[]>>(null)
    const cylinder2 = React.useRef<THREE.Mesh<THREE.TorusKnotGeometry, THREE.ShaderMaterial[]>>(null)


    const renderTarget1 = useFBO();
    const renderTarget2 = useFBO();

    const uniforms = useMemo(() => ({
        uTexture: {
            value: null,
        },
        winResolution: {
            value: new THREE.Vector2(window.innerWidth, window.innerHeight).multiplyScalar(Math.min(window.devicePixelRatio, 2)),
        },
    }), []);


    useFrame((state, delta) => {

        const {gl, scene, camera, clock} = state;

        if (cylinder1.current) {
            cylinder1.current.material.forEach((material) => {
                if (material.type === "ShaderMaterial") {
                    material.uniforms.winResolution.value = new THREE.Vector2(
                        window.innerWidth,
                        window.innerHeight
                    ).multiplyScalar(Math.min(window.devicePixelRatio, 2));
                }
            });
        }


        cylinder2.current!.material.forEach((material) => {
            if (material.type === "ShaderMaterial") {
                material.uniforms.winResolution.value = new THREE.Vector2(
                    window.innerWidth,
                    window.innerHeight
                ).multiplyScalar(Math.min(window.devicePixelRatio, 2));
            }
        });

        if (torusRef.current) {
            torusRef.current.visible = false;
        }
        if (boxRef.current) {
            boxRef.current.visible = true;
        }
        gl.setRenderTarget(renderTarget1);
        gl.render(scene, camera);


        if (torusRef.current) {
            torusRef.current.visible = true;
        }
        if (boxRef.current) {
            boxRef.current.visible = false;
        }

        gl.setRenderTarget(renderTarget2);
        gl.render(scene, camera);

        gl.setRenderTarget(null);

        const newPosZ = Math.sin(clock.elapsedTime) * 3.5;
        boxRef.current!.position.z = newPosZ;
        torusRef.current!.position.z = newPosZ;

        boxRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
        boxRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
        boxRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);

        torusRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
        torusRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
        torusRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);

    });


    return <>
        <Sky sunPosition={[10, 10, 0]}/>
        <Environment preset="sunset"/>
        <directionalLight position={[10, 10, 0]} intensity={1}/>
        <ambientLight intensity={0.5}/>
        <group ref={groupRef}>
            <mesh
                ref={cylinder1}
                position={[0, 0, -4]}
                rotation={[-Math.PI / 2, 0, 0]}>
                <cylinderGeometry args={[3, 3, 8, 32]}/>
                <shaderMaterial
                    vertexShader={vertexShader}
                    fragmentShader={fragmentShader}
                    uniforms={{
                        ...uniforms,
                        uTexture: {
                            value: renderTarget1.texture,
                        },
                    }}
                    attach="material-0"
                />
                <shaderMaterial
                    vertexShader={vertexShader}
                    fragmentShader={fragmentShader}
                    uniforms={{
                        ...uniforms,
                        uTexture: {
                            value: renderTarget1.texture,
                        },
                    }}
                    attach="material-1"
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                    opacity={0}
                />
            </mesh>
            <mesh
                ref={cylinder2}
                position={[0, 0, 4]}
                rotation={[Math.PI / 2, 0, 0]}
            >
                <cylinderGeometry args={[3, 3, 8, 32]}/>

                <shaderMaterial
                    vertexShader={vertexShader}
                    fragmentShader={fragmentShader}
                    uniforms={{
                        ...uniforms,
                        uTexture: {
                            value: renderTarget2.texture,
                        },
                    }}
                    attach="material-0"
                />
                <shaderMaterial
                    vertexShader={vertexShader}
                    fragmentShader={fragmentShader}
                    uniforms={{
                        ...uniforms,
                        uTexture: {
                            value: renderTarget2.texture,
                        },
                    }}
                    attach="material-1"
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                    opacity={0}
                />
            </mesh>
            <mesh>
                <torusGeometry args={[3, 0.2, 16, 100]}/>
                <meshStandardMaterial color="#F9F9F9"/>
            </mesh>
            <mesh ref={torusRef} position={[0, 0, 0]}>
                <torusKnotGeometry args={[0.75, 0.3, 100, 16]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh ref={boxRef} position={[0, 0, 0]}>
                <boxGeometry args={[2, 2, 2]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"/>
            </mesh>
        </group>
    </>


}

13

参考资料

Beautiful and mind-bending effects with WebGL Render Targets

昨天 — 2026年4月19日掘金 前端

2026 年前端工程师面试:一份来自面试官视角的真实复盘

作者 怕浪猫
2026年4月19日 23:09

前言:为什么我要写这篇文章

前两天和一个在高校和企业都面试过不少候选人的"面试官老炮"聊天,他听过太多候选人抱怨面试内容脱离实际、工作用不到。也听过面试官抱怨候选人只会背题、动手能力差。有意思的是,这两拨人的抱怨,往往都对。

今天我想换个视角——不站在候选人角度刷题,也不站在理论派角度讲八股文,而是站在有实际招聘需求、真正要带团队干活的面试官视角,聊聊 2026 年的前端工程师面试,到底在考什么、为什么这么考。

核心结论先行

2026 年的前端面试,考察维度已经发生了结构性变化:

维度 占比 变化趋势
AI 工程能力 20% 大幅上升,2024 年几乎不考
项目深度与结果 30% 持续核心,但问法变了
Coding 基本功 20% 稳定,需要证明你能写
框架原理(React 为主) 20% 稳定,但要理解本质
系统设计 10% 稳定,外企中大厂标配

一个重要变化:纯背题的通过率断崖式下跌。面试官开始问"这个方案你实际落地过吗""遇到什么问题""怎么取舍"。


一、AI 工程能力:这是 2026 年的新标配

为什么 AI 能力突然重要了?

原因很简单:团队里用 AI 的工程师,和不用的工程师,生产效率差 2-3 倍。不是 10-20%,是 2-3 倍。

任何一个正常的技术团队 Leader,只要用过了,都会想把 AI 用到团队里。所以面试 AI 能力,本质上是在判断:你能不能快速融入一个 AI-Augmented 的团队

面试怎么考?

通常分三个层次:

层次一:工具使用(基础分)

  • 你用哪些 AI 编程工具?
  • 你的日常 AI 工作流是什么?
  • 你如何保证 AI 生成代码的质量?

这三个问题几乎每场面试都会问到。如果你还在说"我就用 ChatGPT 写代码",那只能得基础分。

层次二:工程化落地(核心竞争力)

  • 你在公司里推动过 AI 工作流落地吗?
  • 团队如何统一 AI 工具配置?(Rule 文件、MCP 服务等)
  • 怎么量化 AI 提效的价值?
  • AI 生成的代码谁来 Review?流程是什么?

这部分是拉开差距的关键。很多候选人用 AI 用得很溜,但从来没想过如何让团队也用好

层次三:边界认知(加分项)

  • AI 能帮你做什么?不能帮你做什么?
  • 什么时候你选择不用 AI?
  • AI 生成的代码可能有哪些隐蔽的坑?

这部分考察的是你的判断力和工程素养。AI 不是万能的,知道它的边界在哪里,才是成熟工程师的标志。

我的 AI 工作流(可直接写在简历里的框架)

需求 → Context 构建 → AI 生成 → 质量保障 → 持续优化

1. Context 构建阶段

  • 维护项目 Rule 文件(代码规范、架构约束)
  • 配置 MCP 服务(提供项目特定上下文)
  • 沉淀 Skills(常见任务的最佳实践)
  • 持续更新 README 和 Onboarding 文档

2. AI 生成阶段

  • UI to Code:设计稿直接生成组件代码
  • 组件生成:可复用组件批量生产
  • 逻辑实现:业务逻辑 + 状态管理
  • 测试生成:单元测试 + 集成测试

3. 质量保障阶段

  • 静态检查:ESLint + TypeScript + Prettier
  • 自动测试:Jest + React Testing Library
  • CI Pipeline:自动化流水线
  • Code Review:AI 辅助 + 人工 Review 结合

4. 持续优化阶段

  • 收集 AI 生成代码的运行时反馈
  • 优化 Prompt 和上下文配置
  • 沉淀最佳实践到知识库

高质量 Prompt 的五要素

面试时经常会被问到"怎么写 Prompt",可以参考这个框架:

要素 说明 示例
目标 要实现什么功能 "帮我实现一个可复用的分页组件"
约束 技术栈、代码规范 "使用 React + TypeScript,遵循项目 ESLint 规则"
上下文 相关代码、接口定义 "已有基础的 Table 组件,路径在 src/components/Table"
输出 期望的交付物 "完整的 .tsx 组件 + 对应的单元测试"
质量要求 类型安全、错误处理、可访问性 "必须标注完整的 TypeScript 类型,处理 loading/error 状态"

二、项目深度:这是永远的压轴戏

变了什么?问法升级了

以前的项目问题:

"请介绍一下你做过的最有挑战性的项目。"

现在的问题:

"你在那个项目里遇到的最大技术挑战是什么?你尝试了几种方案?为什么最终选择了这一种?"

以前是描述题,现在是决策题

面试官不关心你做过什么,关心的是你怎么做决定

推荐的回答框架:决策链法则

每个项目准备一套"决策链":

背景 → 问题 → 约束 → 方案对比 → 最终选择 → 结果 → 复盘

背景:项目的业务背景是什么?你负责什么? 问题:核心挑战是什么?量化指标是什么? 约束:时间、技术栈、团队能力等限制条件 方案对比:你考虑了哪几种方案?各自的优劣? 最终选择:为什么选了这个?trade-off 是什么? 结果:最终的成果,用数据说话 复盘:如果重来一次,你会怎么做?

三个必杀项目类型

无论你有多少项目,建议准备这三类:

1. 性能优化项目(技术深度证明)

这是面试官的最爱,因为数据清晰、过程明确。

参考回答模板:

"我们有一个管理后台,包含 10 万条数据的表格,用户反馈滚动卡顿。

我先用 React DevTools Profiler 定位到问题是每帧渲染的行数太多,不是分页能解决的。

调研了三个方案:虚拟滚动(react-window)、分片渲染、骨架屏。最终选了虚拟滚动,因为这是唯一能满足无限滚动 + 搜索 + 排序三个需求的方案。

实现的时候遇到两个坑:动态行高滚动位置保持。行高问题通过预先测量+缓存解决,滚动位置用 key + scrollTop 记录。

结果:首屏从 3s 降到 0.3s,滚动帧率从 20fps 到 60fps,内存从 500MB 降到 50MB。"

2. 架构设计项目(系统思维证明)

可以是一次重构,也可以是新项目的架构选型。

关键不是选了什么框架,而是为什么这么选

"我们系统从 jQuery 迁移到 React,我主导了技术方案选型。

调研了三个方案:渐进式迁移(逐页替换)、微前端隔离、独立重写。

最终选了渐进式迁移,原因:

  • 独立重写风险太高,涉及 200+ 页面
  • 微前端适合多团队独立部署,我们团队就 3 个人
  • 渐进式迁移风险可控,同时能积累经验

迁移过程中设计了沙箱隔离层,让 React 和 jQuery 组件可以互相通信。

2 年时间完成了 100% 迁移,线上零事故。"

3. 失败/踩坑项目(工程成熟度证明)

面试官特别喜欢问:"你做过的项目里,有没有什么失败的经历?"

这不是送命题,这是送分题。关键是展示你如何从失败中学习。

"我曾经在一个项目里过度设计了状态管理。明明是一个简单的表单页面,我上了 Redux Toolkit。

结果:引入复杂度远超收益,团队其他成员维护成本很高。

我的复盘:状态管理方案应该由业务复杂度决定,不是技术炫技。

后来我总结了选型原则:能用 Context 就不用 Zustand,能用 Zustand 就不用 Redux。只有当团队超过 5 人、业务复杂度超过一定阈值时,才考虑引入全局状态管理库。


三、Coding 基本功:证明你能写代码

考什么?

2026 年的 Coding 环节大致分两类:

LeetCode 算法题(约 10%)

  • 高频题型:数组/字符串操作、DFS/BFS、基础动态规划
  • 难度:Medium 为主,偶尔 Easy 或 Hard
  • 时间控制:15 分钟以内,超时基本挂

手写代码(约 10%)

  • Promise 系列:Promise.all、Promise.race、Promise 并发控制
  • 数组操作:拍平、深拷贝、防抖/节流
  • 框架相关:简易版 useState、简易版 useEffect

为什么还要考算法?

这是面试官被"简历包装"坑怕了之后的保底手段。

你说你熟练使用 React,代码怎么写的?Promise 用得溜,实际能写一个 Promise.all 吗?

算法题的目的不是考你数据结构知识,而是看你在压力下思考和表达的能力。写不出来没关系,能讲清楚思路也算半个通过。

我的准备建议

  1. 刷题策略:刷 LeetCode Top 100 高频题足矣,不需要刷 500 道
  2. 手写题:一定要能讲清楚原理,不是背答案
  3. 善用 AI:用 AI 帮你理解算法思路,但一定要自己手写实现
  4. 时间管理:20 分钟内没思路,主动问面试官要提示,不丢人

四、框架原理:理解本质,而非背诵

React Fiber:必考,没有之一

关于 Fiber,我见过最离谱的候选人回答是:

"Fiber 是一个新的……React 版本。"

这是送命题。

Fiber 到底考什么?

问题层级 预期回答深度
Fiber 是什么? 一种链表结构的虚拟 DOM 描述对象
为什么要 Fiber? 解决大型应用更新时的卡顿问题,让渲染可中断
Fiber 的两个阶段? render 阶段(可中断) + commit 阶段(不可中断)
render 阶段做了什么? diff + 标记副作用(placement/update/deletion)
时间切片怎么实现? requestIdleCallback(现已被 MessageChannel 替代)
优先级调度? lanes 模型,不同更新有不同的优先级

最低要求:能说清楚 Fiber 解决了什么问题、两个阶段的区别。 加分项:能讲清楚 lanes/优先级调度的实现细节。

Hooks 原理:理解原理,而非 API

Hooks 的问题正在从"怎么用"升级到"为什么这么设计"。

问题 考察点
useState 的实现原理? 链表结构、current 指针、dispatch 闭包
为什么不能在条件语句中调用 Hook? 链表顺序对应,每次调用对应链表的一个节点
useEffect 的清理机制? 函数返回值作为清理函数,下次执行前调用
useMemo vs useCallback? 前者缓存值,后者缓存函数引用
什么时候不用 useMemo? 计算不耗时时、简单值,memo 本身的开销可能更大

性能优化:基于数据的优化

面试官最烦的答案是:

"用 React.memo 优化性能。"

面试官最喜欢的问题是:

"你在哪个场景下遇到了性能问题?怎么定位的?用的什么优化手段?效果如何?"

性能优化的正确打开方式:

Profile 定位瓶颈 → 假设原因 → 实施优化 → 数据验证效果

光优化没用,要能复现问题、定位原因、验证效果


五、系统设计:考的是权衡能力

常见题目类型

  • 设计一个支付页面
  • 设计一个实时协作编辑器
  • 设计一个图片上传和裁剪系统
  • 设计一个新闻推荐系统

答题框架:先问后画

第一步:需求澄清(必做)

"我想确认几个问题:

  1. 预期的用户规模是多少?(1 万 vs 1000 万,方案差异很大)
  2. 重点关注哪个方面?(性能、安全、可扩展性)
  3. 是纯前端系统设计还是包含后端?"

这一步做不做,差距非常大。不问就开始画的,往往答不到点子上。

第二步:从高层到细节

整体架构
    ↓
数据模型
    ↓
核心模块
    ↓
关键决策点(trade-off 讨论)

第三步:讲清楚权衡

"方案 A 的优势是 XXX,劣势是 YYY。 方案 B 的优势是 XXX,劣势是 YYY。 考虑到我们的场景是……,所以最终选了方案 B。"

面试官想听的不是"最佳方案",而是你如何权衡取舍


总结:面试的核心逻辑

2026 年的前端面试,核心考察的是四个能力层次:

层次 能力 对应的面试内容
能干活 Coding 基本功 LeetCode、手写代码
懂原理 框架深度理解 React Fiber、Hooks、性能优化
能扛事 项目落地能力 决策链、问题解决、技术选型
会协作 AI 工程能力 工作流、工具链、团队提效

这四个层次,层层递进。前两个是基础,后两个是拉开差距的关键。

一个忠告:不要把面试当成一场演技表演。真正的高手,面试时说的每一句话,都是自己真实做过的事情。与其花时间背题,不如花时间真正把项目做深、把问题想透。

面试只是开始,入职后的每一天才是真正的考验。

祝各位都能找到伯乐,也祝各位伯乐都能找到千里马。

构建无障碍组件之Window Splitter Pattern

作者 anOnion
2026年4月19日 22:02

Window Splitter Pattern 详解:构建可拖拽面板分割器

Window Splitter(窗口分割器,也称为 Resizable SplitterPane ResizerSplit PanelDivider)是一种可移动的分隔组件,用于调整两个相邻面板(pane)的相对大小。本文基于 W3C WAI-ARIA Window Splitter Pattern 规范,详解如何构建无障碍的窗口分割器组件。

一、Window Splitter 的定义与核心概念

1.1 什么是 Window Splitter

Window Splitter 是一种可移动的分隔条,位于两个面板之间,允许用户调整面板的相对大小。它具有以下特征:

  • 位于两个面板之间,作为可交互的分隔线
  • 支持拖拽调整面板大小
  • 可以是**可变(variable)固定(fixed)**类型
    • 可变分割器:可以在允许范围内调整到任意位置
    • 固定分割器:在两个固定位置之间切换
  • 具有表示**主面板(primary pane)**大小的数值

1.2 核心术语

术语 说明
Primary Pane 主面板,分割器的值表示该面板的大小
Secondary Pane 次面板,大小随主面板变化而调整
Variable Splitter 可变分割器,可在范围内任意调整
Fixed Splitter 固定分割器,只能在两个位置间切换
Value 分割器当前值,表示主面板的大小(通常为 0-100)
┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│  ┌──────────────────┬──────────────────────────────────────┐    │
│  │                  │                                      │    │
│  │   Primary Pane   │          Secondary Pane              │    │
│  │                  │                                      │    │
│  │  ┌────────────┐  │  ┌────────────────────────────────┐  │    │
│  │  │            │  │  │                                │  │    │
│  │  │  Content   │  │  │         Content                │  │    │
│  │  │            │  │  │                                │  │    │
│  │  └────────────┘  │  └────────────────────────────────┘  │    │
│  │                  │                                      │    │
│  └──────────────────┼──────────────────────────────────────┘    │
│                     │                                           │
│              ┌──────┴──────┐                                    │
│              │  Splitter   │  <-- draggable separator           │
│              │  (separator)│      role="separator"              │
│              └─────────────┘      aria-valuenow                 │
│                                                                 │
│  Value = 30 (Primary: 30%, Secondary: 70%)                      │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

注意:"主面板"仅表示该面板的大小由分割器控制,不表示其内容更重要。

1.3 典型应用场景

  • 代码编辑器:左侧文件树,右侧代码编辑区
  • 阅读应用:左侧目录,右侧正文内容
  • 邮件客户端:左侧邮件列表,右侧邮件详情
  • 设计工具:左侧工具栏,右侧画布

二、WAI-ARIA 角色与属性

2.1 基本角色

Window Splitter 使用 role="separator" 标记。从 ARIA 1.1 开始,当 separator 元素可聚焦时,它被视为一个控件(widget)

<div
  role="separator"
  aria-label="目录"
  aria-valuenow="30"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-controls="primary-pane"
  tabindex="0">
</div>

2.2 必需属性

属性 说明 示例值
role="separator" 标记为分隔符角色 -
aria-valuenow 当前值,表示主面板大小 "30"
aria-valuemin 最小值,主面板最小时的位置 "0"
aria-valuemax 最大值,主面板最大时的位置 "100"
aria-controls 指向主面板元素 "primary-pane"
aria-labelaria-labelledby 可访问标签,应与主面板名称匹配 "目录"

2.3 属性详解

aria-valuenow

表示分割器的当前位置,通常映射为主面板的百分比大小:

  • 0:主面板完全折叠(最小)
  • 100:主面板完全展开(最大)
  • 30:主面板占 30%,次面板占 70%
aria-controls

指向主面板元素,让辅助技术知道分割器控制哪个面板:

<div id="primary-pane" role="region" aria-label="目录">
  <!-- 主面板内容 -->
</div>

<div
  role="separator"
  aria-controls="primary-pane"
  ...>
</div>
aria-label

标签应与主面板名称匹配,帮助用户理解分割器的作用:

<!-- 好的示例 -->
<div role="region" aria-label="目录" id="toc-pane">...</div>
<div role="separator" aria-label="目录" aria-controls="toc-pane">...</div>

<!-- 不好的示例 -->
<div role="separator" aria-label="分割器">...</div>

三、键盘交互规范

3.1 基本键盘交互

按键 功能
← Left Arrow 垂直分割器向左移动
→ Right Arrow 垂直分割器向右移动
↑ Up Arrow 水平分割器向上移动
↓ Down Arrow 水平分割器向下移动
Enter 切换主面板的展开/折叠状态
Home(可选) 将分割器移到最小位置(可能完全折叠主面板)
End(可选) 将分割器移到最大位置(可能完全展开主面板)
F6(可选) 在窗口面板之间循环切换焦点

3.2 Enter 键行为详解

Enter 键用于切换主面板的折叠状态

  • 如果主面板未折叠:折叠主面板(分割器移到最小值)
  • 如果主面板已折叠:恢复分割器到之前的位置
function handleEnter(splitter) {
  const currentValue = parseInt(splitter.getAttribute('aria-valuenow'));
  const minValue = parseInt(splitter.getAttribute('aria-valuemin'));
  
  if (currentValue > minValue) {
    // 主面板未折叠,保存当前位置并折叠
    splitter.dataset.previousValue = currentValue;
    setSplitterValue(splitter, minValue);
  } else {
    // 主面板已折叠,恢复到之前的位置
    const previousValue = parseInt(splitter.dataset.previousValue || '50');
    setSplitterValue(splitter, previousValue);
  }
}

3.3 固定分割器的键盘交互

固定分割器只支持 Enter 键,不支持方向键:

  • 在两个固定位置之间切换
  • 例如:折叠/展开侧边栏

四、鼠标交互规范

4.1 拖拽行为

  • 鼠标按下:开始拖拽,记录起始位置
  • 鼠标移动:实时更新分割器位置和面板大小
  • 鼠标释放:结束拖拽,保存最终位置

4.2 视觉反馈

  • 悬停状态:鼠标悬停时显示可拖拽的视觉提示(如改变光标为 col-resizerow-resize
  • 拖拽状态:拖拽过程中显示视觉反馈(如半透明遮罩)
  • 焦点状态:键盘聚焦时显示清晰的焦点指示器
[role="separator"] {
  cursor: col-resize; /* 垂直分割器 */
}

[role="separator"][aria-orientation="horizontal"] {
  cursor: row-resize; /* 水平分割器 */
}

[role="separator"]:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

五、实现方式

5.1 基础 Window Splitter 结构

<!-- 窗口容器 -->
<div class="window-container">
  <!-- 主面板 -->
  <div
    id="primary-pane"
    class="primary-pane"
    role="region"
    aria-label="目录">
    <!-- 主面板内容 -->
    <nav>
      <h2>目录</h2>
      <ul>
        <li><a href="#ch1">第一章</a></li>
        <li><a href="#ch2">第二章</a></li>
      </ul>
    </nav>
  </div>

  <!-- 分割器 -->
  <div
    role="separator"
    class="splitter"
    aria-label="目录"
    aria-valuenow="30"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-controls="primary-pane"
    tabindex="0">
  </div>

  <!-- 次面板 -->
  <div
    class="secondary-pane"
    role="region"
    aria-label="内容">
    <!-- 次面板内容 -->
    <article>
      <h1>文章标题</h1>
      <p>文章内容...</p>
    </article>
  </div>
</div>

5.2 CSS 样式

.window-container {
  display: flex;
  height: 100vh;
}

.primary-pane {
  width: 30%; /* 初始宽度对应 aria-valuenow="30" */
  min-width: 0;
  overflow: auto;
}

.splitter {
  width: 4px;
  background-color: #e5e7eb;
  cursor: col-resize;
  transition: background-color 0.2s;
}

.splitter:hover,
.splitter:focus {
  background-color: #3b82f6;
}

.splitter:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

.secondary-pane {
  flex: 1;
  overflow: auto;
}

5.3 JavaScript 实现

class WindowSplitter {
  constructor(splitterElement) {
    this.splitter = splitterElement;
    this.primaryPane = document.getElementById(
      splitterElement.getAttribute('aria-controls')
    );
    this.container = this.splitter.parentElement;
    
    this.isDragging = false;
    this.startX = 0;
    this.startWidth = 0;
    
    this.init();
  }

  init() {
    // 鼠标事件
    this.splitter.addEventListener('mousedown', this.handleMouseDown.bind(this));
    document.addEventListener('mousemove', this.handleMouseMove.bind(this));
    document.addEventListener('mouseup', this.handleMouseUp.bind(this));
    
    // 键盘事件
    this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleMouseDown(e) {
    this.isDragging = true;
    this.startX = e.clientX;
    this.startWidth = this.primaryPane.offsetWidth;
    this.container.style.userSelect = 'none';
  }

  handleMouseMove(e) {
    if (!this.isDragging) return;
    
    const delta = e.clientX - this.startX;
    const newWidth = this.startWidth + delta;
    const containerWidth = this.container.offsetWidth;
    const percentage = Math.round((newWidth / containerWidth) * 100);
    
    this.setValue(percentage);
  }

  handleMouseUp() {
    this.isDragging = false;
    this.container.style.userSelect = '';
  }

  handleKeyDown(e) {
    const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
    const step = 5; // 每次移动 5%

    switch (e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        this.setValue(Math.max(minValue, currentValue - step));
        break;
      case 'ArrowRight':
        e.preventDefault();
        this.setValue(Math.min(maxValue, currentValue + step));
        break;
      case 'Home':
        e.preventDefault();
        this.setValue(minValue);
        break;
      case 'End':
        e.preventDefault();
        this.setValue(maxValue);
        break;
      case 'Enter':
        e.preventDefault();
        this.toggleCollapse();
        break;
    }
  }

  setValue(value) {
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
    
    // 限制在范围内
    value = Math.max(minValue, Math.min(maxValue, value));
    
    // 更新 ARIA 属性
    this.splitter.setAttribute('aria-valuenow', value);
    
    // 更新视觉
    this.primaryPane.style.width = value + '%';
  }

  toggleCollapse() {
    const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    
    if (currentValue > minValue) {
      // 保存当前值并折叠
      this.splitter.dataset.previousValue = currentValue;
      this.setValue(minValue);
    } else {
      // 恢复之前的位置
      const previousValue = parseInt(this.splitter.dataset.previousValue || '30');
      this.setValue(previousValue);
    }
  }
}

// 初始化
const splitter = document.querySelector('[role="separator"]');
new WindowSplitter(splitter);

5.4 固定分割器实现

固定分割器只支持 Enter 键切换:

class FixedWindowSplitter {
  constructor(splitterElement) {
    this.splitter = splitterElement;
    this.primaryPane = document.getElementById(
      splitterElement.getAttribute('aria-controls')
    );
    
    this.positions = [0, 30]; // 两个固定位置:折叠、展开
    this.currentIndex = 1; // 默认展开
    
    this.init();
  }

  init() {
    this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleKeyDown(e) {
    if (e.key === 'Enter') {
      e.preventDefault();
      this.togglePosition();
    }
  }

  togglePosition() {
    this.currentIndex = (this.currentIndex + 1) % this.positions.length;
    const value = this.positions[this.currentIndex];
    
    this.splitter.setAttribute('aria-valuenow', value);
    this.primaryPane.style.width = value + '%';
  }
}

六、最佳实践

6.1 提供清晰的标签

分割器的标签应与主面板名称匹配:

<!-- 好的示例 -->
<div role="region" aria-label="文件树" id="file-tree">...</div>
<div role="separator" aria-label="文件树" aria-controls="file-tree">...</div>

<!-- 不好的示例 -->
<div role="separator" aria-label="拖拽调整">...</div>

6.2 确保键盘可访问

  • 分割器必须可聚焦(tabindex="0"
  • 支持方向键调整位置
  • 支持 Enter 键折叠/展开

6.3 提供视觉反馈

  • 悬停时改变光标样式
  • 焦点状态清晰可见
  • 拖拽过程中实时更新面板大小

6.4 限制调整范围

设置合理的 aria-valueminaria-valuemax,防止面板过小或过大:

<!-- 主面板最小 15%,最大 50% -->
<div
  role="separator"
  aria-valuemin="15"
  aria-valuemax="50"
  ...>
</div>

6.5 保存用户偏好

记住用户调整后的面板大小,下次访问时恢复:

// 保存
localStorage.setItem('splitter-value', splitter.getAttribute('aria-valuenow'));

// 恢复
const savedValue = localStorage.getItem('splitter-value');
if (savedValue) {
  splitter.setAttribute('aria-valuenow', savedValue);
  primaryPane.style.width = savedValue + '%';
}

6.6 响应式设计考虑

在小屏幕上,考虑禁用分割器或提供替代方案:

@media (max-width: 768px) {
  [role="separator"] {
    display: none; /* 小屏幕隐藏分割器 */
  }
  
  .primary-pane {
    width: 100% !important; /* 全宽显示 */
  }
}

七、常见错误

7.1 忘记设置 aria-controls

<!-- 错误 -->
<div role="separator" aria-label="目录"></div>

<!-- 正确 -->
<div role="separator" aria-label="目录" aria-controls="primary-pane"></div>

7.2 标签与主面板不匹配

<!-- 错误 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="调整大小">...</div>

<!-- 正确 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="目录">...</div>

7.3 忽略键盘交互

只实现鼠标拖拽,不实现键盘支持,导致键盘用户无法调整面板大小。

八、总结

构建无障碍的 Window Splitter 组件需要关注:

  1. 正确的角色:使用 role="separator"
  2. 必需的属性aria-valuenow aria-valuemin aria-valuemax aria-controls aria-label
  3. 完整的键盘支持:方向键调整、Enter 键折叠、Home/End 快捷键
  4. 鼠标拖拽支持:mousedown/mousemove/mouseup 事件
  5. 清晰的标签:标签与主面板名称匹配
  6. 视觉反馈:悬停、焦点、拖拽状态的视觉提示

遵循 W3C Window Splitter Pattern 规范,我们能够创建既实用又无障碍的面板分割器,提升所有用户的操作体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

PDF无限制预览!Jit-Viewer V1.5.0开源文档预览神器正式发布

作者 徐小夕
2026年4月19日 21:30

下面和大家分享一下最近我们开源的文档预览SDK——Jit-Viewer,昨天刚发布 1.5.0 版本,和大家分享一下最新的功能更新。

图片

如果你是开发文档预览功能的开发者,一定经历过这种崩溃:txt文档预览乱码、PDF只能看前5页、大文件加载卡顿,代码文件预览毫无章法。

为了帮大家解决这些真实的使用痛点,提升开发体验,我们这段时间优化了 Jit-Viewer 开源文档预览SDK。上周刚帮不少开发者解决了PDF预览受限的问题——终于能完整查看所有PDF文档了。

今天,Jit-Viewer V1.5.0 正式发布,4大核心更新,让文档预览开发更高效、更省心。

文档地址:jitword.com/jit-viewer.…

开源地址:github.com/jitOffice/j…

这次更新,我们重点带来了以下功能:

1. 支持txt多编码格式预览兼容  

图片

之前很多开发者反馈,txt文档预览经常出现乱码,尤其是非UTF-8编码的文件,调试起来特别麻烦,浪费大量时间。

这次更新,我们优化了txt文档解析逻辑,全面兼容ANSI、UTF-8、GBK等多种常见编码格式,不管你导入的txt文件是什么编码,都能正常显示,再也不用手动转换编码、反复调试,帮大家节省更多开发时间。

2. 支持PDF文件完整预览,告别5页限制  

图片

这是本次更新最受期待的功能!之前版本的Jit-Viewer,PDF文件只能预览前5页,对于需要完整预览长文档的开发者来说,实用性大打折扣,很多场景下根本无法满足需求。

图片

这次我们彻底突破了这个限制,底层重构了PDF渲染能力,支持PDF文件全页完整预览,不管是几页的PDF,都能一次性加载完成,搭配原有缩放、翻页功能,完美适配各类PDF预览场景,再也不用为了查看完整PDF额外集成其他工具。

3. 优化SDK预览性能,搭载高性能文件预览引擎  

我们知道,开发者在集成文档预览SDK时,最在意的就是性能——大文件加载慢、切换页面卡顿,都会影响产品体验。这次更新,我们重新设计了文件预览引擎,优化了文件加载、渲染的全流程,大幅提升了预览速度和稳定性,即使是大文档,也能快速加载、流畅切换,不会出现卡顿、崩溃的情况,同时降低了资源占用,让你的应用运行更流畅。

4. 支持代码文件高亮预览  

针对开发类场景,我们新增了代码文件高亮预览功能。不管是Java、Python、JavaScript,还是HTML、CSS等常见编程语言,导入后都能自动识别语言类型,实现语法高亮,代码结构清晰可见,再也不用看着杂乱无章的纯文本代码发愁,尤其适合需要在应用中集成代码预览功能的开发者,大幅提升使用体验。

市面上很多商业文档预览SDK,只解决“能预览”的问题,而 Jit-Viewer 想解决的是“好用、省心、适配多场景”。

这次V1.5.0的更新,本质上是在“轻量高效”的核心定位上,进一步突破场景限制、优化使用体验——让复杂的文档预览开发,变得更简单,让不同需求的开发者,都能快速集成、高效使用,不用再为各类预览问题额外消耗精力。

简单来说,Jit-Viewer 是一个纯前端的文件预览引擎。不需要后端转换服务,不需要安装任何插件,几行代码就能让浏览器具备"专业软件"的预览能力。图片目前 jit-viewer 已经支持了:

  • docx / ppt / pdf / excel
  • csv
  • html
  • markdown
  • txt
  • 代码文件(如js,css, java, go, c#, php, ts等)
  • 音频 / 视频
  • CAD
  • 3D模型
  • OFD(国产格式)

同时我们还在持续迭代优化,帮助大家仅通过几行代码,就能让自己的web系统轻松拥有多种文档预览的能力。

github:github.com/jitOffice/j…

从零实现一个前端监控系统:性能、错误与用户行为全方位监控

2026年4月19日 17:03

从零实现一个前端监控系统:性能、错误与用户行为全方位监控

深入探索前端监控 SDK 的实现原理,从性能指标采集、错误捕获到用户行为追踪,手把手教你打造一个企业级的前端监控方案。

前言

在现代 Web 应用中,前端监控是保障产品质量和用户体验的重要基石。一个完善的前端监控系统应该具备以下能力:

  • 性能监控:采集页面加载性能、接口请求耗时等关键指标
  • 错误监控:捕获 JS 错误、资源加载失败、Promise 异常等问题
  • 行为监控:追踪用户点击、页面跳转、PV/UV 等行为数据
  • 数据上报:高效、可靠地将数据发送到服务端

本文将基于 webEyeSDK 项目,详细讲解如何从零实现一个前端监控 SDK。

架构设计

整体架构

webEyeSDK
├── src/
│   ├── webEyeSDK.js        # SDK 入口文件
│   ├── config.js            # 配置管理
│   ├── report.js            # 数据上报
│   ├── cache.js             # 数据缓存
│   ├── utils.js             # 工具函数
│   ├── performance/         # 性能监控
│   │   ├── index.js
│   │   ├── observeLCP.js
│   │   ├── observerFCP.js
│   │   ├── observerLoad.js
│   │   ├── observerPaint.js
│   │   ├── observerEntries.js
│   │   ├── fetch.js
│   │   └── xhr.js
│   ├── error/               # 错误监控
│   │   └── index.js
│   └── behavior/            # 行为监控
│       ├── index.js
│       ├── pv.js
│       ├── onClick.js
│       └── pageChange.js

模块职责

模块 职责
Performance 采集页面性能指标(FCP、LCP、Load 等)
Error 捕获 JS 错误、资源错误、Promise 错误
Behavior 追踪用户行为(点击、页面跳转、PV)
Report 数据上报(支持 sendBeacon、XHR、Image)
Cache 数据缓存与批量上报

核心功能实现

一、性能监控

性能监控是前端监控的核心模块,主要通过 Performance APIPerformanceObserver 来采集关键指标。

1. FCP(首次内容绘制)

FCP(First Contentful Paint)测量页面首次渲染任何文本、图像等内容的时间。

import { lazyReportBatch } from '../report';

export default function observerFCP() {
    const entryHandler = (list) => {
        for (const entry of list.getEntries()) {
            if (entry.name === 'first-contentful-paint') {
                observer.disconnect();
                const json = entry.toJSON();
                const reportData = {
                    ...json,
                    type: 'performance',
                    subType: entry.name,
                    pageUrl: window.location.href,
                };
                lazyReportBatch(reportData);
            }
        }
    };

    // 统计和计算 FCP 的时间
    const observer = new PerformanceObserver(entryHandler);
    // buffered: true 确保观察到所有 paint 事件
    observer.observe({ type: 'paint', buffered: true });
}

核心要点

  • 使用 PerformanceObserver 监听 paint 类型的性能条目
  • buffered: true 确保能观察到页面加载过程中已发生的性能事件
  • 找到 first-contentful-paint 后立即断开监听,避免重复上报
2. LCP(最大内容绘制)

LCP(Largest Contentful Paint)测量视口内最大内容元素渲染的时间,是 Core Web Vitals 的重要指标。

import { lazyReportBatch } from '../report';

export default function observerLCP() {
    const entryHandler = (list) => {
        if (observer) {
            observer.disconnect();
        }
        for (const entry of list.getEntries()) {
            const json = entry.toJSON();
            const reportData = {
                ...json,
                type: 'performance',
                subType: entry.name,
                pageUrl: window.location.href,
            };
            lazyReportBatch(reportData);
        }
    };

    // 统计和计算 LCP 的时间
    const observer = new PerformanceObserver(entryHandler);
    observer.observe({ type: 'largest-contentful-paint', buffered: true });
}

LCP 特点

  • LCP 可能会多次触发(如最大内容元素改变),需要持续监听
  • 通常在用户交互或页面加载完成后才上报最终值
  • Google 建议 LCP 应在 2.5 秒以内
3. XHR/Fetch 请求监控

通过重写 XMLHttpRequest 原型方法,实现接口请求的性能监控。

import { lazyReportBatch } from '../report';

export const originalProto = XMLHttpRequest.prototype;
export const originalSend = originalProto.send;
export const originalOpen = originalProto.open;

function overwriteOpenAndSend() {
    originalProto.open = function newOpen(...args) {
        this.url = args[1];
        this.method = args[0];
        originalOpen.apply(this, args);
    };

    originalProto.send = function newSend(...args) {
        this.startTime = Date.now();
        const onLoaded = () => {
            this.endTime = Date.now();
            this.duration = this.endTime - this.startTime;

            const { url, method, startTime, endTime, duration, status } = this;
            const reportData = {
                status,
                duration,
                startTime,
                endTime,
                url,
                method: method.toUpperCase(),
                type: 'performance',
                success: status >= 200 && status < 300,
                subType: 'xhr'
            };

            lazyReportBatch(reportData);
            this.removeEventListener('loadend', onLoaded, true);
        };

        this.addEventListener('loadend', onLoaded, true);
        originalSend.apply(this, args);
    };
}

export default function xhr() {
    overwriteOpenAndSend();
}

实现原理

  • 重写 XMLHttpRequest.prototype.open 方法,记录请求 URL 和方法
  • 重写 XMLHttpRequest.prototype.send 方法,记录请求开始时间
  • 监听 loadend 事件,计算请求耗时并上报
4. 性能监控入口
import fetch from "./fetch";
import observerEntries from "./observerEntries";
import observerLCP from "./observeLCP";
import observerFCP from "./observerFCP";
import observerLoad from "./observerLoad";
import observerPaint from "./observerPaint";
import xhr from "./xhr";

export default function performance() {
    fetch();
    observerEntries();
    observerLCP();
    observerFCP();
    observerLoad();
    observerPaint();
    xhr();
}

二、错误监控

错误监控帮助开发者及时发现和定位线上问题,是保障应用稳定性的关键。

1. JS 运行时错误

通过 window.onerror 捕获 JavaScript 运行时错误。

window.onerror = function (msg, url, lineNo, columnNo, error) {
    const reportData = {
        type: 'error',
        subType: 'js',
        msg,
        url,
        lineNo,
        columnNo,
        stack: error.stack,
        pageUrl: window.location.href,
        startTime: performance.now(),
    };
    lazyReportBatch(reportData);
};

参数说明

  • msg:错误消息
  • url:发生错误的脚本 URL
  • lineNo:错误行号
  • columnNo:错误列号
  • error:Error 对象,包含堆栈信息
2. 资源加载错误

资源加载错误(如图片、CSS、JS 文件加载失败)需要通过事件捕获来监听。

window.addEventListener(
    'error',
    function (e) {
        const target = e.target;
        if (target.src || target.href) {
            const url = target.src || target.href;
            const reportData = {
                type: 'error',
                subType: 'resource',
                url,
                html: target.outerHTML,
                pageUrl: window.location.href,
                paths: e.path,
            };
            lazyReportBatch(reportData);
        }
    },
    true  // 使用捕获阶段
);

关键点

  • 必须在捕获阶段监听(第三个参数为 true),因为资源加载错误不会冒泡
  • 通过 e.target.srce.target.href 判断是否为资源错误
3. Promise 错误

Promise 中未被捕获的错误需要监听 unhandledrejection 事件。

window.addEventListener(
    'unhandledrejection',
    function (e) {
        const reportData = {
            type: 'error',
            subType: 'promise',
            reason: e.reason?.stack,
            pageUrl: window.location.href,
            startTime: e.timeStamp,
        };
        lazyReportBatch(reportData);
    },
    true
);
4. 框架错误捕获

Vue 错误捕获

export function install(Vue, options) {
    if (__webEyeSDK__.vue) return;
    __webEyeSDK__.vue = true;
    setConfig(options);

    const handler = Vue.config.errorHandler;
    Vue.config.errorHandler = function (err, vm, info) {
        const reportData = {
            info,
            error: err.stack,
            subType: 'vue',
            type: 'error',
            startTime: window.performance.now(),
            pageURL: window.location.href,
        };
        lazyReportBatch(reportData);

        if (handler) {
            handler.call(this, err, vm, info);
        }
    };
}

React 错误捕获

export function errorBoundary(err, info) {
    if (__webEyeSDK__.react) return;
    __webEyeSDK__.react = true;

    const reportData = {
        error: err?.stack,
        info,
        subType: 'react',
        type: 'error',
        startTime: window.performance.now(),
        pageURL: window.location.href,
    };
    lazyReportBatch(reportData);
}

使用方式

// Vue 项目
import webEyeSDK from './webEyeSDK';
Vue.use(webEyeSDK, { appId: 'xxx' });

// React 项目
import webEyeSDK from './webEyeSDK';
class ErrorBoundary extends React.Component {
    componentDidCatch(error, info) {
        webEyeSDK.errorBoundary(error, info);
    }
    render() {
        return this.props.children;
    }
}
5. 错误监控入口
import { lazyReportBatch } from '../report';

export default function error() {
    // 捕获资源加载失败的错误
    window.addEventListener('error', function (e) {
        const target = e.target;
        if (target.src || target.href) {
            const url = target.src || target.href;
            const reportData = {
                type: 'error',
                subType: 'resource',
                url,
                html: target.outerHTML,
                pageUrl: window.location.href,
                paths: e.path,
            };
            lazyReportBatch(reportData);
        }
    }, true);

    // 捕获 JS 错误
    window.onerror = function (msg, url, lineNo, columnNo, error) {
        const reportData = {
            type: 'error',
            subType: 'js',
            msg,
            url,
            lineNo,
            columnNo,
            stack: error.stack,
            pageUrl: window.location.href,
            startTime: performance.now(),
        };
        lazyReportBatch(reportData);
    };

    // 捕获 Promise 错误
    window.addEventListener('unhandledrejection', function (e) {
        const reportData = {
            type: 'error',
            subType: 'promise',
            reason: e.reason?.stack,
            pageUrl: window.location.href,
            startTime: e.timeStamp,
        };
        lazyReportBatch(reportData);
    }, true);
}

三、行为监控

用户行为监控帮助我们理解用户如何使用应用,为产品优化提供数据支持。

1. PV(页面浏览量)
import { lazyReportBatch } from '../report';
import { generateUniqueId } from '../utils';

export default function pv() {
    const reportData = {
        type: 'behavior',
        subType: 'pv',
        startTime: performance.now(),
        pageUrl: window.location.href,
        referrer: document.referrer,
        uuid: generateUniqueId(),
    };
    lazyReportBatch(reportData);
}

PV 数据包含

  • 当前页面 URL
  • 来源页面(document.referrer
  • 访问时间
  • 唯一标识(用于关联用户行为链路)
2. 点击行为
import { lazyReportBatch } from '../report';

export default function onClick() {
    ['mousedown', 'touchstart'].forEach((eventType) => {
        window.addEventListener(eventType, (e) => {
            const target = e.target;
            if (target.tagName) {
                const reportData = {
                    type: 'behavior',
                    subType: 'click',
                    target: target.tagName,
                    startTime: e.timeStamp,
                    innerHtml: target.innerHTML,
                    outerHtml: target.outerHTML,
                    width: target.offsetWidth,
                    height: target.offsetHeight,
                    eventType,
                    path: e.path,
                };
                lazyReportBatch(reportData);
            }
        });
    });
}

点击数据用途

  • 分析用户交互热点
  • 绘制热力图
  • 检测异常点击行为
3. 页面跳转
import { lazyReportBatch } from '../report';
import { generateUniqueId } from '../utils';

export default function pageChange() {
    let oldUrl = '';

    // Hash 路由
    window.addEventListener('hashchange', function (event) {
        const newUrl = event.newURL;
        const reportData = {
            from: oldUrl,
            to: newUrl,
            type: 'behavior',
            subType: 'hashchange',
            startTime: performance.now(),
            uuid: generateUniqueId(),
        };
        lazyReportBatch(reportData);
        oldUrl = newUrl;
    }, true);

    let from = '';
    // History 路由
    window.addEventListener('popstate', function (event) {
        const to = window.location.href;
        const reportData = {
            from: from,
            to: to,
            type: 'behavior',
            subType: 'popstate',
            startTime: performance.now(),
            uuid: generateUniqueId(),
        };
        lazyReportBatch(reportData);
        from = to;
    }, true);
}

路由监听

  • 支持 Hash 路由(hashchange 事件)
  • 支持 History 路由(popstate 事件)
  • 记录跳转前后 URL,用于分析用户路径
4. 行为监控入口
import onClick from './onClick';
import pageChange from './pageChange';
import pv from './pv';

export default function behavior() {
    onClick();
    pageChange();
    pv();
}

四、数据上报

数据上报是前端监控的最后一步,需要保证数据可靠、高效地发送到服务端。

1. 上报策略
const config = {
    url: '',
    projectName: 'eyesdk',
    appId: '123456',
    userId: '123456',
    isImageUpload: false,
    batchSize: 5,
};

配置项说明

  • url:上报接口地址
  • appId:应用唯一标识
  • userId:用户标识
  • isImageUpload:是否使用图片方式上报
  • batchSize:批量上报阈值
2. 批量上报
import { addCache, getCache, clearCache } from './cache';

export function lazyReportBatch(data) {
    addCache(data);
    const dataCache = getCache();

    if (dataCache.length && dataCache.length > config.batchSize) {
        report(dataCache);
        clearCache();
    }
}

批量上报优势

  • 减少网络请求次数
  • 降低服务端压力
  • 提升性能
3. 多种上报方式
export function report(data) {
    if (!config.url) {
        console.error('请设置上传 url 地址');
    }

    const reportData = JSON.stringify({
        id: generateUniqueId(),
        data,
    });

    // 使用图片方式上报
    if (config.isImageUpload) {
        imgRequest(reportData);
    } else {
        // 优先使用 sendBeacon
        if (window.navigator.sendBeacon) {
            return beaconRequest(reportData);
        } else {
            xhrRequest(reportData);
        }
    }
}

上报方式对比

方式 优点 缺点 适用场景
sendBeacon 异步、不阻塞页面卸载、可靠 浏览器兼容性 页面关闭时上报
Image 简单、跨域友好 数据大小限制 简单数据上报
XHR 兼容性好、支持 POST 可能被阻塞 常规上报
4. sendBeacon 上报
export function beaconRequest(data) {
    if (window.requestIdleCallback) {
        window.requestIdleCallback(
            () => {
                window.navigator.sendBeacon(config.url, data);
            },
            { timeout: 3000 }
        );
    } else {
        setTimeout(() => {
            window.navigator.sendBeacon(config.url, data);
        });
    }
}

sendBeacon 特点

  • 浏览器在页面卸载时也能可靠发送
  • 异步执行,不阻塞页面关闭
  • 适合用于页面关闭前的数据上报
5. XHR 上报
export function xhrRequest(data) {
    if (window.requestIdleCallback) {
        window.requestIdleCallback(
            () => {
                const xhr = new XMLHttpRequest();
                originalOpen.call(xhr, 'post', config.url);
                originalSend.call(xhr, JSON.stringify(data));
            },
            { timeout: 3000 }
        );
    } else {
        setTimeout(() => {
            const xhr = new XMLHttpRequest();
            originalOpen.call(xhr, 'post', url);
            originalSend.call(xhr, JSON.stringify(data));
        });
    }
}

requestIdleCallback

  • 在浏览器空闲时执行上报
  • 避免阻塞关键渲染任务
  • 设置 timeout: 3000 确保最迟 3 秒后执行
6. 图片上报
export function imgRequest(data) {
    const img = new Image();
    img.src = `${config.url}?data=${encodeURIComponent(JSON.stringify(data))}`;
}

Image 上报优势

  • 实现简单
  • 天然支持跨域
  • 无需担心阻塞

五、数据缓存

import { deepCopy } from './utils.js';

const cache = [];

export function getCache() {
    return deepCopy(cache);
}

export function addCache(data) {
    cache.push(data);
}

export function clearCache() {
    cache.length = 0;
}

缓存机制

  • 使用数组缓存待上报数据
  • 达到阈值后批量上报
  • 上报后清空缓存

六、工具函数

// 深拷贝
export function deepCopy(target) {
    if (typeof target === 'object') {
        const result = Array.isArray(target) ? [] : {};
        for (const key in target) {
            if (typeof target[key] == 'object') {
                result[key] = deepCopy(target[key]);
            } else {
                result[key] = target[key];
            }
        }
        return result;
    }
    return target;
}

// 生成唯一 ID
export function generateUniqueId() {
    return 'id-' + Date.now() + '-' + Math.random().toString(36).substring(2, 9);
}

七、SDK 入口

import performance from './performance/index';
import error from './error/index';
import behavior from './behavior/index';
import { setConfig } from './config';
import { lazyReportBatch } from './report';

window.__webEyeSDK__ = {
    version: '0.0.1',
};

// 针对 Vue 项目的错误捕获
export function install(Vue, options) {
    if (__webEyeSDK__.vue) return;
    __webEyeSDK__.vue = true;
    setConfig(options);
    const handler = Vue.config.errorHandler;
    Vue.config.errorHandler = function (err, vm, info) {
        const reportData = {
            info,
            error: err.stack,
            subType: 'vue',
            type: 'error',
            startTime: window.performance.now(),
            pageURL: window.location.href,
        };
        lazyReportBatch(reportData);
        if (handler) {
            handler.call(this, err, vm, info);
        }
    };
}

// 针对 React 项目的错误捕获
export function errorBoundary(err, info) {
    if (__webEyeSDK__.react) return;
    __webEyeSDK__.react = true;
    const reportData = {
        error: err?.stack,
        info,
        subType: 'react',
        type: 'error',
        startTime: window.performance.now(),
        pageURL: window.location.href,
    };
    lazyReportBatch(reportData);
}

export function init(options) {
    setConfig(options);
    performance();
    error();
    behavior();
}

export default {
    install,
    errorBoundary,
    performance,
    error,
    behavior,
    init,
}

使用指南

安装

import webEyeSDK from './webEyeSDK';

// 初始化
webEyeSDK.init({
    url: 'http://your-server.com/report',
    appId: 'your-app-id',
    userId: 'user-123',
    batchSize: 10,
});

Vue 项目集成

import Vue from 'vue';
import webEyeSDK from './webEyeSDK';

Vue.use(webEyeSDK, {
    url: 'http://your-server.com/report',
    appId: 'your-app-id',
});

React 项目集成

import React from 'react';
import webEyeSDK from './webEyeSDK';

class ErrorBoundary extends React.Component {
    componentDidCatch(error, info) {
        webEyeSDK.errorBoundary(error, info);
    }

    render() {
        return this.props.children;
    }
}

// 初始化
webEyeSDK.init({
    url: 'http://your-server.com/report',
    appId: 'your-app-id',
});

核心特性总结

功能模块 监控内容 实现方式
性能监控 FCP、LCP、Load、XHR/Fetch PerformanceObserver、重写原型
错误监控 JS 错误、资源错误、Promise 错误 window.onerror、事件监听
行为监控 PV、点击、页面跳转 事件监听
数据上报 批量上报、多种方式 sendBeacon、XHR、Image

性能优化建议

  1. 使用 requestIdleCallback:在浏览器空闲时执行数据上报,避免阻塞关键渲染
  2. 批量上报:减少网络请求次数,降低服务端压力
  3. sendBeacon:页面关闭时使用 sendBeacon 保证数据可靠性
  4. 数据压缩:上报前压缩数据,减少传输体积
  5. 采样上报:对高频事件(如点击)进行采样,减少数据量

扩展方向

  1. SourceMap 解析:还原压缩后的错误堆栈
  2. 录屏回放:使用 rrweb 记录用户操作
  3. 白屏检测:检测页面白屏问题
  4. 性能评分:基于 Core Web Vitals 计算性能评分
  5. 告警系统:实时告警通知

参考资料

总结

本文从零实现了一个前端监控 SDK,涵盖了性能监控、错误监控、行为监控三大核心模块。通过 Performance API、事件监听、原型重写等技术,实现了全方位的前端监控能力。

掌握前端监控的实现原理,不仅能帮助你在工作中构建完善的监控系统,还能加深对浏览器性能、错误处理等底层机制的理解。


如果觉得本文有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!

为了在 Vue 项目里用上想要的 React 组件,我写了这个 skill

作者 Hooray
2026年4月19日 16:49

背景

逛 X 和 Github 的时候,经常会刷到一些很有意思的前端插件或组件,但它们大部分都是用 React 写的,毕竟 React 在全球的市占率还是远远高于 Vue 的。

但看到了就会一直心痒痒,总会想着如果能在我的 Vue 项目里也用上就好了。

换在以前,简单的我还能照着 React 源码实现手撸一份 Vue 的,但越来越多小而美的组件,背后的代码量不一定就很少。而且它也有可能是某个组件合集内的其中一个,并不是一个独立组件,这就会牵扯到 shared utils、样式入口、导出结构等等情况。大大增加了我这个只会一点 React 三脚猫功夫的 Vue 开发者。

于是,我决定写一个 skill ,让它能帮我将这些 React 组件,直接 1:1 复刻成 Vue 的。

为什么要用它?

直接让 AI 开干不行么?一定要用这个 skill 么?当然可以,如果是一个代码量很少的 React 组件,你完全可以直接将整个代码丢给 AI ,让 AI 直接写。

但下面这些场景,我更建议你使用这个 skill :

1. 已经有明确的“参考实现”

这是最典型的情况,比如我在 GitHub 上看到了一个不错的组件,或者已经锁定了某个包、某个子目录,甚至只是某个 demo 页面。则可以直接将链接地址发给这个 skill ,接下来就是等待并验收。

我并不是从零想交互,而是已经有一个清晰的参考物,希望 Vue 版本尽量接近它的行为和体验。

2. 有些组件简约但不简单

很多组件表面上看起来不复杂,但想要复刻,背后的逻辑却是一层套一层:焦点管理、键盘交互、受控与非受控状态、浮层定位、portal、隐藏输入、事件抛出方式、组合式 API 的组织方式。这些东西很多只靠鼠标点点前端的UI界面是不一定能看出来的。

但如果使用这个 skill,它会把 README、示例、测试、源码、包导出这些材料一起当作“行为说明书”来看,完整复刻每一处逻辑,确保高还原度。

3. 迁移的是一个“小包”

还有一个场景就是把某个 React 小型前端库迁到 Vue,这时候就不只是重新实现几个 .tsx 文件了。比如一个带 RootTriggerContentItem 这种结构的 headless 组件族,或者一个带若干 helper、样式入口和包级导出的轻量插件。

这种场景下,真正难的不是把组件渲染出来,而是保留它原来的使用方式和工程组织。

4. 不确定能不能实现一份 Vue 的版本

有些 React 组件它本身也就是个包装层,核心实现可能是引入了某个三方依赖,这时候我不确定三方依赖是否支持 Vue ,如果不支持,Vue 生态又是否有同类型的平替。再或者有些依赖本身就没必要保留,局部重写反而能让代码更轻量。

这时候这个 skill 就不是简单进行复刻了,它会先收集完整的信息,分析复刻难度,最后采用最合适的方案进行。

如何使用它?

安装:

npx skills add hooray/skills --skill='replica-to-vue' -g

然后在 Agent 里把链接丢给它就行,就像这样:

/replica-to-vue https://github.com/owner/repo 将这个仓库,实现一份适用于当前项目的 Vue 组件

实战案例

案例一:sileo

这是一个前阵子在 X 上算是比较火的 Toast 通知组件,这是它原本的样子:

而这是通过 skill 复刻后的样子:

案例二:Dice UI 里的 Mention 组件

这是一个在大组件库中的一个"提及"功能小组件,功能看起来不复杂,但因为并不是独立组件,涉及到一些组件库内部共享的包依赖,所以要单独复刻其中这一个组件,场景会更复杂。

这是它原本的样子:

而这是通过 skill 复刻后的样子:

甚至通过几轮简单沟通,还实现了自定义插槽,比原本组件功能更强大。

最后

写这个 skill 不仅仅是为了追求语法层面的转换效率,而是考虑在面对一个已经存在的优秀参考实现时,能更稳地把它带进 Vue 3 的世界里。

什么该保留,什么该替换,什么时候该承认边界,这些判断本身,才是这个 skill 真正重要的部分。

🔥IntersectionObserver:前端性能优化的“隐形监工”

作者 风花雪月_
2026年4月19日 16:11

前言 🚀

作为前端新手,你是否也遇到过这些困惑:想实现图片懒加载却怕写滚动监听卡顿,想优化页面性能却无从下手?

在过去,实现“元素是否进入视口”的判断,往往需要写一堆 scroll 监听、计算偏移量,还要手动加防抖节流优化,代码繁琐又容易踩坑。

而今天要讲的 IntersectionObserver API,就是浏览器为我们准备的“性能神器”——它能自动监听元素与视口的交叉状态,无需手动计算,性能拉满,用法还简单容易上手!

一、IntersectionObserver 的概念

一句话总结: IntersectionObserver 是浏览器原生提供的API,用于异步监听目标元素与视口(或指定祖先元素)的交叉状态。当元素进入/离开视口、交叉比例变化时,会自动触发我们定义的回调函数。

用一张图看懂它的工作逻辑

  • 根元素(root):默认是视口,也可以指定某个祖先元素作为“观察范围”;
  • 目标元素(target):我们需要监听的元素(可以同时监听多个);
  • 交叉区域(intersection area):目标元素与根元素重叠的部分;
  • 回调触发:当交叉区域的比例达到我们设定的“阈值”时,就会执行回调函数。

二、API语法详解

IntersectionObserver 的用法非常简洁,核心就3步:创建观察器 → 监听目标元素 → 处理回调逻辑。

核心语法

// 1. 创建 IntersectionObserver 实例
const observer = new IntersectionObserver(callback, options);

// 2. 监听目标元素
observer.observe(targetElement1);
observer.observe(targetElement2);

// 3. 停止监听(可选,避免内存泄漏)
observer.unobserve(targetElement); // 停止监听单个元素
observer.disconnect(); // 停止所有监听

参数详解

参数1:callback

交叉状态变化时的回调函数,当目标元素的交叉状态发生变化时(进入/离开视口、交叉比例达标),会自动执行这个函数,它接收两个参数:

  • entries:IntersectionObserverEntry 数组,每个成员对应一个被监听元素的交叉信息(最常用 isIntersecting 和 intersectionRatio);

  • observer:当前的 IntersectionObserver 实例,可用于停止监听等操作。

回调函数示例:

const callback = (entries, observer) => {
  // 遍历所有被监听的元素
  entries.forEach(entry => {
    // 核心判断:元素是否进入视口
    if (entry.isIntersecting) {
      console.log("元素进入视口!", entry.target);
      // 执行操作:加载图片、触发动画等
      // 操作完成后,可停止监听该元素,避免重复触发
      observer.unobserve(entry.target);
    } else {
      console.log("元素离开视口!", entry.target);
    }
  });
};

参数2:options(可选)

配置对象,用于自定义观察规则,常见3个属性:

属性 说明 默认值 示例
root 观察的根元素(祖先元素) null(即视口) root: document.querySelector('.container')
rootMargin 根元素的边距,用于扩大/缩小观察范围 "0px 0px 0px 0px" rootMargin: "100px 0px"(提前100px开始监听)
threshold 触发回调的交叉比例阈值(0~1,可传数组),0表示元素刚进入视口就触发,1表示完全进入才触发 [0] threshold: [0, 0.5, 1](元素进入0%、50%、100%时都触发)

实例方法

observe(target):开始监听目标元素

unobserve(target):停止监听指定目标元素,避免重复触发,优化性能

disconnect():停止所有监听,页面销毁时用,避免内存泄漏

takeRecords():返回所有被监听元素的交叉信息(了解)

三、高频应用场景

场景一:图片懒加载

核心逻辑: 页面初始化时,图片不加载真实地址,只存储在 data-src 中;当图片进入视口时,再将 data-src 赋值给 src,实现延迟加载。

示例代码:

<img class="lazy" data-src="image1.jpg" alt="懒加载图片">
<img class="lazy" data-src="image2.jpg" alt="懒加载图片">
<img class="lazy" data-src="image3.jpg" alt="懒加载图片">

<script>
  // 创建观察器
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src; // 加载真实图片
        observer.unobserve(img);   // 停止监听
      }
    });
  });

  // 监听所有图片
  document.querySelectorAll('.lazy').forEach(img => {
    observer.observe(img);
  });
</script>

效果: 减少初始页面的网络请求,提升页面加载速度,尤其适合图片较多的页面(如商品列表、博客页面)。

场景二:滚动动画

核心逻辑: 元素进入视口时,触发动画(如渐显、平移);离开视口时可重置动画,让页面滚动更有层次感。

示例代码:

<div class="animate-box">我会滚动渐入</div>
<div class="animate-box">我会滚动渐入</div>
<div class="animate-box">我会滚动渐入</div>

<style>
  .animate-box {
    height: 100vh;
    opacity: 0;
    transform: translateY(100px);
    transition: 0.6s ease;
  }
  .animate-box.show {
    opacity: 1;
    transform: translateY(0);
  }
</style>

<script>
  const observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.classList.add('show');
      } else {
        entry.target.classList.remove('show');
      }
    });
  });

  document.querySelectorAll('.animate-box').forEach(el => {
    observer.observe(el);
  });
</script>

效果: 替代传统的 scroll 监听动画,性能更优,动画触发更精准。

场景三:无限滚动

核心逻辑: 在列表底部添加一个“加载占位符”,监听该占位符;当占位符进入视口时,触发数据加载,加载完成后更新列表,实现“无限滚动”。

示例代码:

<div id="list"></div>
<div id="load-more">加载中...</div>

<script>
  const list = document.getElementById('list');
  const loadMore = document.getElementById('load-more');
  let page = 1;

  // 模拟加载数据
  function loadData() {
    for (let i = 0; i < 10; i++) {
      const item = document.createElement('div');
      item.textContent = `列表项 ${page * 10 + i}`;
      list.appendChild(item);
    }
    page++;
  }

  // 监听底部占位符
  const observer = new IntersectionObserver(entries => {
    if (entries[0].isIntersecting) {
      loadData();
    }
  });

  observer.observe(loadMore);
  loadData(); // 首次加载
</script>

效果: 替代分页按钮,提升用户体验,用于加载更多数据,常见于社交媒体、新闻APP的前端页面。

场景四:有效曝光埋点

核心逻辑: 监听页面中的关键元素(如广告、卡片),当元素完全进入视口(交叉比例≥1)时,记录曝光数据(如上报接口),用于数据分析、广告计费等。

示例代码:

// 曝光去重 (IntersectionObserver)
const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const target = entry.target as HTMLElement
          const workId = target.dataset.workId
          if (workId) {
            tracker.track('work_show', {
                page_name: document.title,
                work_id: workId
            })
            observer.unobserve(target) // 曝光后取消观察,实现去重
          }
        }
      })
    },
    { threshold: 0.5 }
)

效果: 替代分页按钮,提升用户体验,用于加载更多数据,常见于社交媒体、新闻APP的前端页面。

四、优势、局限性与兼容性

优势:

性能优异:异步执行,不阻塞主线程,避免传统 scroll 监听的频繁计算导致的卡顿;

用法简洁:无需手动计算元素位置、偏移量,浏览器自动处理交叉状态,代码量大幅减少;

多场景适配:懒加载、滚动动画、无限滚动、埋点等场景都能覆盖,实用性强;

原生支持:浏览器原生API,无需引入第三方库,轻量化。

局限性:
  1. 无法监听元素内部的滚动,只能监听元素与根元素的交叉状态;

  2. 回调函数是异步的,无法在回调中同步获取元素的最新位置;

  3. 不支持IE浏览器

兼容性:

五、总结

看完本文,你已经掌握了 IntersectionObserver 的核心用法,总结3个要点,帮你快速巩固:

1. 核心作用:监听元素与视口(或祖先元素)的交叉状态,异步触发回调;

2. 用法步骤:创建观察器 → 监听目标元素 → 处理回调(核心判断 isIntersecting);

3. 实战价值:4个高频场景 懒加载、滚动动画、无限滚动、埋点。

Web 性能的架构边界:跨线程信令通道的确定性分析

作者 DiffServ
2026年4月19日 15:55

Web 性能的架构边界:跨线程信令通道的确定性分析

当主线程被长任务阻塞时,常规跨线程通信通道也随之失效。

🎬 核心现象演示:KILL_500MS

▶ 点击观看实录:KILL_500MS 物理降维打击实录

点击按钮触发500ms主线程阻塞:

  • UI完全冻结:按钮、动画、滚动全部停止响应
  • postMessage通道中断:红线代表的延迟数据断崖式上升,通信完全阻塞
  • 物理硬同步通道持续运行:绿线代表的心跳数据保持60fps稳定更新

这不是特效,这是浏览器底层架构决定的系统行为。以下是对该现象的精确技术分析。


⚖️ 第〇章:架构代价——COOP/COEP与跨域隔离

在深入技术细节之前,必须明确此项技术的适用边界与前置代价

必要条件:跨域隔离

SharedArrayBuffer 在现代浏览器中默认禁用(Spectre漏洞的缓解措施)。要启用它,服务器必须下发以下HTTP响应头:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless

代价清单

配置这两个响应头后,你的页面将进入跨域隔离状态。浏览器会强制执行以下限制:

能力限制 具体影响
跨域资源加载 除非资源响应包含 Cross-Origin-Resource-Policy: cross-origin,否则全部被阻断
跨域窗口交互 window.opener 被置为 nullpostMessage 跨窗口通信受限
第三方脚本/字体/CDN 依赖方必须配置 CORP 头,否则加载失败
广告埋点、外链懒加载 大量旧Web生态组件直接失效

"性能提升"的精确含义

上表中的延迟和抖动压缩率,不是应用执行速度的提升——它们是跨线程信令通道的通信精度指标。

这个区分至关重要:对于普通C端页面,18ms的消息延迟完全可以接受,这套方案毫无价值且成本极高。但对于以下场景,极低的Jitter是系统不发生Buffer Underrun或状态撕裂的物理前提:

  • AudioWorklet DSP处理:128 sample buffer @ 48kHz = 2.67ms周期,任何 >2.67ms 的调度抖动都会导致音频爆音
  • 高并发SharedArrayBuffer状态机:多线程对共享内存的无锁读写要求纳秒级同步精度
  • 实时性能监控探针:探针本身的延迟抖动不能超过被测量的事件

这不是"让你的页面更快"的方案。这是"在特定场景下,让跨线程信令通道具备物理级确定性"的方案。

适用边界声明

如果你的业务场景不属于以下范围,启用 COOP/COEP 带来的兼容性代价远超其收益:

  • 实时音视频处理(AudioWorklet DSP流水线,对抖动敏感)
  • 高并发SharedArrayBuffer状态机(游戏引擎、WebAssembly运行时)
  • 性能探针系统(需要免疫主线程阻塞的监测工具)

对于普通C端页面,这套方案毫无价值且部署成本极高。

理解了这个前提,以下的技术分析才具有工程参考意义。


🏰 第一章:postMessage的调度依赖

1.1 事件循环中的序列化与排队

postMessage 是跨线程通信的标准API,但其传输路径依赖主线程的事件循环:

// 发送端
worker.postMessage({ type: 'heartbeat', timestamp: performance.now() });

// 接收端的物理路径:
// 1. 结构化克隆算法序列化(耗时与对象大小正相关)
// 2. 消息被推入目标线程的宏任务队列
// 3. 等待目标线程的事件循环轮询到该任务
// 4. 反序列化还原对象
// 总延迟:受目标线程当前任务队列长度、GC状态、渲染管线阻塞程度共同决定

这套机制在"发消息、收消息"的常规场景下没有问题。但当通信链路本身需要作为度量基准时,依赖被测对象的调度器来传递测量结果,就构成了循环依赖:你无法用一个受主线程影响的方法来测量主线程的状态

1.2 抖动的来源

KILL_500MS 测试中(主线程被500ms同步循环阻塞),postMessage 通道表现出以下行为:

  • 调度抖动(Jitter):延迟标准差 ±8ms,因为消息执行时机受宏任务队列长度影响
  • GC干扰:V8的Major GC会暂停主线程,消息处理随之冻结
  • 长任务阻塞:任何同步计算超过一帧(16.67ms),当前帧的消息全部延迟到下一帧

这三个问题不是实现缺陷,是事件循环调度模型的固有属性——postMessage 的执行权由主线程"施舍",而不是由发送方掌控。


🔬 第二章:SharedArrayBuffer + 原子操作的通信模型

2.1 绕过事件循环的内存共享

SharedArrayBuffer 提供了一块可在多个线程(主线程、Worker、AudioWorklet)间直接映射的内存区域。结合 Atomics API,可以实现不依赖事件循环的数据交换:

// 共享内存定义
const sab = new SharedArrayBuffer(1024);
const clockView = new BigInt64Array(sab);

// 写入端(AudioWorklet 线程):原子写入
const preciseTime = BigInt(Math.round(performance.now() * 1000));
Atomics.store(clockView, 0, preciseTime);

// 读取端(主线程):原子读取,零排队延迟
const truthTime = Number(Atomics.load(clockView, 0)) / 1000;

关键区别:读取方的 Atomics.load 是一条CPU指令(x86的 LOCK CMPXCHG 系列或ARM的 LDAXR),它不进入任何队列,不需要调度器分配执行权。

2.2 为什么使用BigInt64

  • 原子性Atomics API要求操作的内存地址必须自然对齐。BigInt64Array 保证8字节对齐,Atomics.store/load 在CPU指令层面是单指令操作。
  • 精度保持performance.now() 返回亚毫秒精度的浮点数。乘以1000后转为 BigInt,微秒级时间戳可无损存储于64位整数中。避免了 Int32 的溢出问题(Int32最大值 ≈ 2.1×10⁹ μs ≈ 35分钟后溢出)。
  • 跨平台一致性:x86-64、ARMv8-A 等现代架构均在硬件层面支持64位整数的原子加载/存储。

这不是"更好的选择",是Atomics API和CPU内存模型约束下的唯一可行路径。

2.3 缓存一致性:为什么读取端能看到最新值

Atomics.store 操作隐含 seq_cst 内存顺序,它触发以下硬件行为:

  • 写端:CPU核心执行 store 时,将缓存行标记为 Modified 状态,并通过总线嗅探机制通知其他核心该缓存行已失效。
  • 读端Atomics.load 在执行前会等待所有 pending 的写操作完成,确保读取到的是最新写入的值。

这是MESI/MOESI缓存一致性协议在Web平台上的投影,也是主线程阻塞时绿线仍能更新数据的物理原因。

2.4 两条验证路径

我们在 /lab/lab/experimental 分别用不同的驱动源验证了同一结论:

实验路径 驱动源 心跳周期 验证目标
/lab AudioWorklet(OS实时音频线程) 2.67ms 音频子系统的线程隔离
/lab/experimental OffscreenCanvas + rAF(Worker线程) 16.67ms 渲染子系统的线程隔离

两条路径的心跳周期不同,因为驱动源不同——AudioWorklet以 128 sample / 48kHz 的固定频率驱动,OffscreenCanvas以rAF的 ~60fps 驱动。但两者的Jitter测量结论一致:线程隔离后,心跳抖动趋近于零,不受主线程状态影响

2.5 Sanctuary Protocol:工程纪律约束

核心代码(HeartbeatMonitor.tsxsab.tsprocessor.js)已通过多次压力测试验证,被标记为"物理度量衡基准"。我们用三层机制防止AI辅助开发时意外破坏:

  1. 结构化阻尼:文件顶部要求修改前输出 PROTOCOL_UNLOCK: <原因> | <预期物理影响>,强制修改者在推理链中调取相关知识进行自检
  2. 沙盒验证:所有改动先在 /lab/experimental 隔离沙盒复刻并压测通过,再同步回主文件
  3. 跨会话钢印:核心规则写入项目根 CLAUDE.md,所有AI工具会话启动时默认加载

这不是代码层面的防御,是工程纪律层面的防御——让任何修改者(包括AI)在动核心度量衡时明确知道自己"在按核按钮"。


📊 第三章:信令通道性能对比

3.1 对比指标定义

以下对比严格限定于跨线程信令通道的延迟和抖动,不涉及应用层业务逻辑的性能。

指标 postMessage通道 物理硬同步(SharedArrayBuffer)
平均信令延迟 ~18ms ~0.01ms
抖动(Jitter,标准差) ±8ms ±0.001ms
GC干扰敏感度 高(GC暂停期间消息排队) 免疫(内存直接读写,无GC触发点)
目标线程长任务期间 通信完全阻塞 无影响

3.2 "性能提升"的精确含义

表格中的延迟和抖动压缩率,其工程价值体现在以下场景:

  1. AudioWorklet DSP流水线:2.67ms的音频渲染量子(quantum)内必须完成处理。±8ms的调度抖动意味着Buffer Underrun必然发生;±0.001ms的抖动是音频无毛刺的物理前提。
  2. 高并发SharedArrayBuffer状态机:多个Worker通过原子操作协同更新状态,信令延迟决定系统响应速度的上限。
  3. 性能探针系统:探针自身的存活不依赖被监测对象,是监测数据可信度的底线要求。

这不是通用计算性能的1800倍提升,而是特定场景下信令通道确定性量级的差异。


🛡️ 第四章:降级策略——两套系统的不同取舍

基于实际代码实现,stw-sentinel@diffserv/heartbeat 在面对 SharedArrayBuffer 不可用时采取了不同的降级策略。

4.1 AudioWorklet 路径:Fail-fast

stw-sentinel 在检测到 SharedArrayBuffer 不可用时,直接抛出错误,拒绝初始化

// stw-sentinel/src/core/STWSentinel.ts 的实际行为
if (typeof SharedArrayBuffer === 'undefined') {
    throw new Error('SharedArrayBuffer is not available.');
}

设计取舍

  • 前提:探针系统的核心价值是提供免疫主线程阻塞的精确监测数据。
  • 逻辑:若降级到 postMessage,探针自身在STW期间也会失效,数据完全不可信,失去了存在的意义。
  • 结论:宁可拒绝服务,也不提供有误导性的"脏数据"。

4.2 OffscreenCanvas 路径:静默降级

@diffserv/heartbeatSharedArrayBuffer 不可用时,自动回退到 postMessage 通道,并在控制台输出警告。

设计取舍

  • 前提:OffscreenCanvas rAF 心跳的主要用途是渲染主权演示和可视化。
  • 逻辑:降级后绿线仍可更新,视觉演示可继续,但主线程阻塞时红线精度会显著下降。
  • 结论:优先保证演示的可用性,同时通过警告告知用户当前精度受限。

4.3 设计取舍对比

维度 stw-sentinel (AudioWorklet) @diffserv/heartbeat (OffscreenCanvas)
SAB不可用时的行为 throw new Error 降级至postMessage,console.warn
数据可信度优先 ✅ 最高(宁缺毋滥) 可用性优先
适用场景 性能审计、上线前STW检测 演示、教学、兼容性场景
兼容性要求 严格(必须COOP/COEP) 宽松(降级后仍可运行)

两种策略没有对错,取决于系统的核心约束。对于探针,数据不可信比没有数据更危险;对于可视化,能展示比精确展示更重要。


🌌 第五章:工程实践与代码仓库

5.1 stw-sentinel

核心特性:

  • 基于 AudioWorklet + SharedArrayBuffer 的无锁环形缓冲区
  • 亚毫秒级STW尖峰检测
  • 单行命令试运行:npx stw-sentinel

5.2 @diffserv/heartbeat

  • 基于 OffscreenCanvas + rAF 的渲染主权验证
  • 支持降级运行,用于演示物理隔离的视觉效果

🥂 结语:架构边界的清醒认知

SharedArrayBuffer + Atomics 提供的跨线程信令通道,其延迟和抖动指标确实远优于依赖事件循环的 postMessage。这是浏览器多线程架构和CPU缓存一致性协议共同决定的系统特性。

但这套方案的前置代价——COOP/COEP跨域隔离——对大多数Web应用而言是不可接受的兼容性负担

选择权在工程决策者手中

  • 如果你的场景对跨线程通信的确定性有极端要求,且能承受跨域隔离的代价,这套方案提供了目前Web平台上最精确的信令通道。
  • 如果你的场景是常规的业务应用,postMessage 依然是正确的、兼容性良好的选择。

物理学没有免费的参数。Atomics 提供的确定性,是用HTTP响应头筑起的进程隔离围墙换来的。


在线实验diffserv.xyz/lab

开源仓库github.com/hlng2002/st…

模仿ai数据流 开箱即用

2026年4月19日 15:47
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>AI 流式输出 + Markdown渲染</title>
  <style>
    body { max-width: 800px; margin: 20px auto; padding: 0 20px; }
    #result {
      white-space: pre-wrap;
      border: 1px solid #eee;
      padding: 16px;
      min-height: 200px;
      margin-top: 20px;
      line-height: 1.6;
    }
    #result h1, #result h2, #result h3 { margin: 10px 0; }
    #result strong { color: #007bff; }
    #result code { background: #f4f4f4; padding: 2px 4px; border-radius: 4px; }
    #result pre { background: #f4f4f4; padding: 10px; overflow-x: auto; }
    #btn { padding: 10px 20px; font-size: 16px; cursor: pointer; }
  </style>
</head>
<body>
  <h3>AI 流式输出演示(渲染Markdown)</h3>
  <button id="btn">开始提问:介绍一下JavaScript</button>
  <div id="result"></div>

  <!-- 👇 加这一行:引入 Markdown 渲染库(和豆包用的一样) -->
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>

  <script>
    const btn = document.getElementById('btn');
    const result = document.getElementById('result');

    // 👇 用来存完整的回答文本
    let fullText = '';

    btn.onclick = async () => {
      btn.disabled = true;
      btn.innerText = 'AI 正在流式输出...';
      result.innerText = '';
      fullText = '';

      try {
        const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "Authorization": "96f0813aca214bb486892a55f7148622.oQFhjTVnwDHvmnEC",
          },
          body: JSON.stringify({
            model: "glm-4-flash",
            messages: [{ role: "user", content: "介绍一下JavaScript" }],
            stream: true
          })
        });

        const reader = response.body.getReader();
        const decoder = new TextDecoder();

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          const chunk = decoder.decode(value, { stream: true });
          const lines = chunk.split("\n").filter(i => i);

          for (let line of lines) {
            if (line.startsWith("data: ")) {
              const jsonStr = line.replace("data: ", "");
              if (jsonStr === "[DONE]") continue;

              try {
                const data = JSON.parse(jsonStr);
                const text = data.choices[0]?.delta?.content || "";

                // 👇 拼接完整文本
                fullText += text;

                // 👇 关键:把 Markdown 渲染成 HTML(豆包就是这么做的!)
                result.innerHTML = marked.parse(fullText);

              } catch (e) {}
            }
          }
        }
      } catch (err) {
        result.innerText = "错误:" + err.message;
      } finally {
        btn.innerText = "重新提问";
        btn.disabled = false;
      }
    };
  </script>
</body>
</html>


总结:

  • fetch 发请求 → stream: true

  • reader.read() 接收二进制流

  • 转字符串 → 按行拆分

  • data: 后面的 JSON

  • choices[0].delta.content 拼文字 → 渲染页面

备注:

Markdown:带「排版标记」的纯文本字符串

直接复制到.html,然后到open.bigmodel.cn/apikey/plat… 平台拿取一个api的key值

这段代码,就是豆包 /chat/completion 接口的工作方式 + 渲染方式

Vue v-bind 转 React:VuReact 怎么处理?

作者 Ruihong
2026年4月19日 15:30

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-bind/: 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-bind 指令用法。

编译对照

v-bind / ::基础属性绑定

v-bind(简写为 :)是 Vue 中用于动态绑定 HTML 属性、组件 propsclassstyle 的指令。

  • Vue 代码:
<img :src="imageUrl" :class="imageCls" />
  • VuReact 编译后 React 代码:
<img src={imageUrl} className={imageCls} />

从示例可以看到:Vue 的 :src:class 指令被编译为 React 的标准属性语法。VuReact 采用 属性直接编译策略,将模板指令转换为 React 的 JSX 属性,完全保持 Vue 的属性绑定语义——动态地将变量值绑定到元素属性。


class 和 style 的动态绑定

Vue 支持复杂的 classstyle 绑定表达式,VuReact 通过运行时辅助函数处理这些复杂场景。

动态 class 绑定

  • Vue 代码:
<div :class="['card', active && 'is-active', error ? 'has-error' : '']" />
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<div className={dir.cls(['card', active && 'is-active', error ? 'has-error' : ''])} />

动态 style 绑定

  • Vue 代码:
<div :style="{ color: textColor, fontSize: size + 'px', 'background-color': bgColor }" />
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<div style={dir.style({ color: textColor, fontSize: size + 'px', backgroundColor: bgColor })} />

从示例可以看到:复杂的 class 和 style 绑定被编译为使用 dir.cls()dir.style() 辅助函数。VuReact 采用 复杂绑定运行时处理策略,将 Vue 的复杂表达式转换为运行时函数调用,完全保持 Vue 的动态样式语义

运行时辅助函数的工作原理

  1. dir.cls()

    • 处理数组、对象、字符串等多种 class 格式
    • 自动过滤 falsy 值(false、null、undefined、'')
    • 合并重复的 class 名称
    • 生成最终的 className 字符串
  2. dir.style()

    • 处理对象格式的样式
    • 自动转换 kebab-case 为 camelCase(background-colorbackgroundColor
    • 处理带单位的数值(自动添加 px 等)
    • 生成 React 兼容的 style 对象

编译策略详解

// Vue: :class="{ active: isActive, 'text-danger': hasError }"
// React: className={dir.cls({ active: isActive, 'text-danger': hasError })}

// Vue: :class="[isActive ? 'active' : '', errorClass]"
// React: className={dir.cls([isActive ? 'active' : '', errorClass])}

// Vue: :style="style"
// React: style={dir.style(style)}

无参数 v-bind:对象展开

Vue 支持无参数的 v-bind,用于将整个对象展开为元素的属性。

  • Vue 代码:
<Comp v-bind="props">点击</Comp>
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<Comp {...dir.keyless(props)}>点击</Comp>

从示例可以看到:无参数的 v-bind 被编译为使用 dir.keyless() 辅助函数和对象展开语法。VuReact 采用 对象展开编译策略,将 Vue 的对象绑定转换为 React 的对象展开,完全保持 Vue 的对象属性绑定语义

dir.keyless() 辅助函数的作用

  1. 属性冲突处理:处理对象属性与已有属性的冲突
  2. 特殊属性转换:自动转换 classclassNameforhtmlFor
  3. 样式对象处理:识别并正确处理 style 对象
  4. 事件处理:识别并转换事件属性(@clickonClick

布尔属性绑定

Vue 对布尔属性有特殊处理,VuReact 也保持了这种语义。

  • Vue 代码:
<button :disabled="isLoading">提交</button>
<input :checked="isChecked" />
<option :selected="isSelected">选项</option>
  • VuReact 编译后 React 代码:
<button disabled={isLoading}>提交</button>
<input checked={isChecked} />
<option selected={isSelected}>选项</option>

动态属性名绑定

Vue 支持使用动态表达式作为属性名,但不建议这么做,不过 VuReact 也能正确处理。

  • Vue 代码:
<div :[dynamicAttr]="value">内容</div>
  • VuReact 编译后 React 代码:
<div {...{ [dynamicAttr]: value }}>内容</div>

编译策略

  1. 计算属性名:使用对象计算属性语法 { [key]: value }
  2. 对象展开:通过对象展开语法应用到元素上

编译策略总结

VuReact 的 v-bind 编译策略展示了完整的属性绑定转换能力

  1. 基础属性映射:将 Vue 属性绑定精确映射到 React JSX 属性
  2. 复杂样式处理:通过运行时辅助函数支持复杂的 class 和 style 绑定
  3. 对象展开支持:完整支持无参数 v-bind 的对象展开语义
  4. 布尔属性处理:正确处理布尔属性的特殊行为
  5. 动态属性名:支持动态表达式作为属性名
  6. 组件 props 转换:正确处理组件间的 props 传递

性能优化策略

  1. 按需导入:只有使用复杂绑定时才导入 dir 辅助函数
  2. 缓存优化:智能缓存相同表达式的处理结果
  3. 编译期优化:对于简单表达式,直接生成内联逻辑

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写属性绑定逻辑。编译后的代码既保持了 Vue 的语义和功能,又符合 React 的属性处理最佳实践,让迁移后的应用保持完整的 UI 表现能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

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

作者 SmalBox
2026年4月19日 15:00

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

Inverse Lerp 节点是 Unity URP Shader Graph 中一个功能强大且用途广泛的数学工具节点。该节点的主要功能是返回在输入 A 到输入 B 范围内生成由输入 T 指定的插值的线性参数。从本质上讲,Inverse Lerp 节点执行的是 Lerp 节点的逆运算,能够帮助开发者确定在已知插值结果的情况下,原始的时间参数或混合权重是多少。

在图形着色器编程中,插值操作是极其常见的需求。我们经常需要在两个值、两个颜色或两个纹理之间进行平滑过渡。Lerp 节点能够根据一个权重参数(通常称为 T)在两个输入值之间进行线性插值。而 Inverse Lerp 节点则解决了相反的问题:当我们知道插值的结果,想要找出产生这个结果的权重参数时,就需要使用 Inverse Lerp。

理解 Inverse Lerp 节点的最佳方式是通过一个简单的数值示例。假设我们有两个边界值 A = 0 和 B = 2,当我们使用 T = 0.5 作为权重参数进行 Lerp 操作时,得到的结果是 1。那么 Inverse Lerp 节点解决的问题就是:已知 A = 0,B = 2,插值结果 T = 1,求原始权重是多少?通过计算 (1-0)/(2-0) = 0.5,我们得到了答案 0.5。

Inverse Lerp 节点在着色器开发中有着广泛的应用场景:

  • 数值范围的重映射和标准化
  • 基于物理属性的材质混合
  • 动态效果的参数控制
  • 复杂动画和过渡效果的时间管理
  • 数据可视化和分析着色器

该节点支持动态矢量类型,这意味着它可以处理浮点数、二维向量、三维向量和四维向量等各种数据类型,为复杂的着色器效果提供了极大的灵活性。

数学原理

基本计算公式

Inverse Lerp 节点的核心数学公式相对简单但功能强大。对于标量(单浮点数)情况,计算公式为:

Out = (T - A) / (B - A)

这个公式表达了几个重要的数学概念:

  • 分子 (T - A) 表示目标值 T 相对于起点 A 的偏移量
  • 分母 (B - A) 表示整个插值区间的长度
  • 结果 Out 表示 T 在 A 到 B 区间内的相对位置,标准化到 [0, 1] 范围内

当处理矢量类型时,Inverse Lerp 节点会对每个分量独立执行相同的计算。例如,对于 float4 类型:

Out.x = (T.x - A.x) / (B.x - A.x)
Out.y = (T.y - A.y) / (B.y - A.y)
Out.z = (T.z - A.z) / (B.z - A.z)
Out.w = (T.w - A.w) / (B.w - A.w)

边界情况处理

在实际应用中,理解 Inverse Lerp 节点在边界条件下的行为至关重要:

  • 当 T 等于 A 时,结果为 0
  • 当 T 等于 B 时,结果为 1
  • 当 T 在 A 和 B 之间时,结果在 0 到 1 之间
  • 当 T 超出 [A, B] 范围时,结果可能小于 0 或大于 1
  • 当 A 等于 B 时,由于除零问题,结果未定义(在实际应用中通常返回 0 或特殊值)

与 Lerp 节点的关系

Inverse Lerp 与 Lerp 节点构成了一对互补的操作:

Lerp(A, B, t) = A + (B - A) * t
InverseLerp(A, B, T) = (T - A) / (B - A)

这两个节点的关系可以表示为:对于任何有效的 A、B 和 t,都有:

InverseLerp(A, B, Lerp(A, B, t)) = t

同样地,对于任何在 A 和 B 之间的 T,都有:

Lerp(A, B, InverseLerp(A, B, T)) = T

这种数学关系使得这两个节点在着色器设计中可以配合使用,实现复杂的动画和过渡效果。

端口详解

输入端口 A

输入端口 A 代表插值范围的起始点或下限值。这个端口接受动态矢量类型,意味着它可以连接各种数据类型的节点输出,包括但不限于:

  • 常量值节点
  • 属性节点(如浮点、向量或颜色属性)
  • 其他数学节点的输出
  • 纹理采样节点的特定通道
  • 时间节点的输出

在实际应用中,端口 A 的设置取决于具体的使用场景。例如,在创建基于高度的材质混合效果时,A 可能代表最低高度值;在颜色过渡效果中,A 可能代表起始颜色。

输入端口 B

输入端口 B 代表插值范围的结束点或上限值。与端口 A 一样,B 也接受动态矢量类型,并且通常与 A 保持相同的数据类型以确保计算的一致性。

端口 B 的典型应用包括:

  • 定义数值范围的上限
  • 指定目标颜色或数值
  • 设置效果参数的极限值
  • 与其他节点配合创建动态范围

输入端口 T

输入端口 T 代表需要计算其相对位置的目标值。这个值应该位于 A 和 B 定义的范围内,但也可以超出这个范围,此时 Inverse Lerp 的结果会小于 0 或大于 1。

端口 T 的数据来源多种多样,常见的有:

  • 顶点位置或 UV 坐标
  • 时间或正弦函数输出
  • 纹理采样值
  • 物理属性如法线方向或深度值
  • 自定义计算的结果

输出端口 Out

输出端口 Out 提供 Inverse Lerp 计算的结果,其数据类型与输入端口保持一致。输出值表示 T 在 A 到 B 范围内的相对位置,通常(但不总是)在 0 到 1 之间。

输出值的解读:

  • 当 Out = 0 时,表示 T 等于 A
  • 当 Out = 1 时,表示 T 等于 B
  • 当 0 < Out < 1 时,表示 T 在 A 和 B 之间
  • 当 Out < 0 时,表示 T 小于 A
  • 当 Out > 1 时,表示 T 大于 B

使用方法和示例

基础数值重映射

最基本的 Inverse Lerp 应用是将一个数值从一个范围映射到标准化范围 [0, 1]。假设我们有一个表示物体高度的值,范围在 10 到 50 单位之间,我们想将其标准化:

  • 设置 A = 10
  • 设置 B = 50
  • 连接高度值到 T
  • 输出结果即为标准化后的高度值

这种标准化操作在着色器中非常有用,因为它允许我们使用一致的范围来处理各种不同的输入值。

颜色过渡和混合

Inverse Lerp 节点在颜色处理方面表现出色,特别是在创建平滑的颜色过渡效果时:

// 创建从红色到蓝色的过渡
A = float3(1, 0, 0)  // 红色
B = float3(0, 0, 1)  // 蓝色
T = 当前混合参数

通过将 Inverse Lerp 的输出连接到 Lerp 节点的 T 输入,可以实现基于各种条件(如高度、角度、距离等)的颜色混合效果。

基于高度的雪线效果

一个经典的应用是创建基于高度的雪线效果,其中雪材质在特定高度以上逐渐出现:

  • 使用物体世界坐标的 Y 分量作为 T 输入
  • 设置 A 为雪开始出现的高度
  • 设置 B 为完全被雪覆盖的高度
  • 将 Inverse Lerp 的输出用作雪材质的混合权重

这种方法可以创建出非常自然的 altitude-based 材质过渡效果。

动态效果控制

Inverse Lerp 节点可以用于控制各种动态效果的强度或进度:

  • 将时间值映射到标准化范围以控制动画进度
  • 基于玩家距离控制特效强度
  • 根据光照条件调整材质参数

这些应用展示了 Inverse Lerp 节点在创建响应式、动态着色器效果方面的强大能力。

实际应用案例

案例一:地形高度混合

在地形着色器中,我们经常需要根据高度混合不同的材质,比如草地、岩石和雪。使用 Inverse Lerp 节点可以精确控制这些材质之间的过渡:

// 高度范围定义
float grassEndHeight = 10.0;
float rockStartHeight = 8.0;
float rockEndHeight = 25.0;
float snowStartHeight = 22.0;

// 计算各材质的权重
float grassWeight = 1 - InverseLerp(grassEndHeight, rockStartHeight, worldPos.y);
float rockWeight = InverseLerp(rockStartHeight, rockEndHeight, worldPos.y);
float snowWeight = InverseLerp(snowStartHeight, rockEndHeight, worldPos.y);

// 确保权重总和为1
float totalWeight = grassWeight + rockWeight + snowWeight;
grassWeight /= totalWeight;
rockWeight /= totalWeight;
snowWeight /= totalWeight;

这种方法创建了平滑的材质过渡,避免了生硬的边界。

案例二:菲涅耳效果增强

在创建水面或其他反射材质时,Inverse Lerp 可以用于增强菲涅耳效果:

// 计算视角与表面法线的点积
float fresnel = dot(viewDir, normal);

// 使用Inverse Lerp控制菲涅耳效果的强度
float fresnelStrength = InverseLerp(0.1, 0.5, fresnel);

// 应用菲涅耳效果
float3 reflection = texCUBE(_ReflectionCubemap, reflectDir);
float3 color = lerp(baseColor, reflection, fresnelStrength);

这种方法可以创建出更加自然和可控制的反射效果。

案例三:动画曲线控制

Inverse Lerp 节点可以模拟动画曲线的行为,为着色器效果添加更加自然的运动:

// 基于时间的脉冲效果
float pulse = abs(sin(_Time.y * 2.0));

// 使用Inverse Lerp创建缓动效果
float easedPulse = InverseLerp(0.3, 0.7, pulse);

// 应用缓动后的脉冲值
float glowIntensity = easedPulse * _MaxGlow;

这种方法比简单的线性动画更加生动和有趣。

高级技巧和优化

多节点组合使用

Inverse Lerp 节点与其他数学节点组合可以创建更复杂的效果:

  • 与 Clamp 节点结合,限制输出范围
  • 与 Power 节点结合,创建非线性响应
  • 与 Sine 或 Cosine 节点结合,创建周期性效果
  • 与 Condition 节点结合,实现条件逻辑

这些组合扩展了 Inverse Lerp 节点的应用范围,使其能够处理更加复杂的着色器需求。

性能优化建议

虽然 Inverse Lerp 节点本身计算开销不大,但在性能敏感的场景中仍需注意:

  • 避免在片段着色器中过度使用复杂计算
  • 尽可能在顶点着色器中预计算不变的值
  • 使用适当的精度(float/half/fixed)
  • 考虑使用查找纹理替代实时计算

常见问题解决

在使用 Inverse Lerp 节点时可能会遇到的一些常见问题及解决方案:

  • 除零错误:确保 A 和 B 不相等,或添加微小偏移
  • 范围溢出:使用 Clamp 节点限制输出范围
  • 性能问题:简化计算或使用近似方法
  • 视觉瑕疵:调整边界值或使用平滑函数

与其他节点的配合

与 Lerp 节点的配合

Inverse Lerp 与 Lerp 节点的配合使用可以创建复杂的插值系统:

// 创建基于物理属性的材质混合
float blendFactor = InverseLerp(minValue, maxValue, physicalProperty);
float3 finalColor = Lerp(colorA, colorB, blendFactor);

这种模式在需要基于某种度量(如高度、角度、距离)进行混合的场景中非常有用。

与 Remap 节点的关系

虽然 Shader Graph 没有专门的 Remap 节点,但可以使用 Inverse Lerp 和 Lerp 组合实现相同的功能:

// 将值从 [oldMin, oldMax] 重映射到 [newMin, newMax]
float normalized = InverseLerp(oldMin, oldMax, inputValue);
float remapped = Lerp(newMin, newMax, normalized);

这种方法提供了极大的灵活性,可以处理各种范围重映射需求。

在子图中的应用

将 Inverse Lerp 节点封装到自定义子图中可以提高工作流效率:

  • 创建专门的范围标准化子图
  • 开发特定用途的材质混合子图
  • 构建可重用的动画控制子图

这种方法不仅提高了工作效率,还确保了项目中的一致性。


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

Svelte/SvelteKit 多语言配置指南

2026年4月19日 14:36

方案对比

方案 适用场景 复杂度 依赖大小
自定义 Store SvelteKit 全栈 0
svelte-i18n 纯 Svelte 应用 ~3KB
typesafe-i18n 类型安全优先 ~5KB
paraglide-js 编译时优化 ~2KB

方案一:自定义 Store(推荐 SvelteKit)

最轻量的方案,无需额外依赖,代码完全可控。

GitHub: 无(纯手写)

1. 目录结构

src/lib/i18n/
├── translations.ts      # 翻译数据聚合
├── index.ts             # 导出接口
└── locales/
    ├── zh.ts            # 中文
    └── en.ts            # 英文

2. 翻译文件

// src/lib/i18n/locales/zh.ts
export const zh = {
    nav: {
        home: '首页',
        about: '关于'
    },
    welcome: '欢迎'
};

// src/lib/i18n/locales/en.ts
export const en = {
    nav: {
        home: 'Home',
        about: 'About'
    },
    welcome: 'Welcome'
};

3. 核心实现

// src/lib/i18n/translations.ts
import { zh } from './locales/zh';
import { en } from './locales/en';

export const translations = { zh, en };
export type Language = keyof typeof translations;
export type TranslationType = typeof zh;

// 检测浏览器语言
export function detectLang(): Language {
    if (typeof navigator === 'undefined') return 'zh';
    const lang = navigator.language.toLowerCase();
    return lang.startsWith('zh') ? 'zh' : 'en';
}

// 从 localStorage 读取
export function getStoredLang(): Language | null {
    if (typeof localStorage === 'undefined') return null;
    const stored = localStorage.getItem('lang');
    return stored === 'zh' || stored === 'en' ? stored : null;
}
// src/lib/i18n/index.ts
import { writable, derived, get } from 'svelte/store';
import { translations, detectLang, getStoredLang, type Language } from './translations';

// 优先从 localStorage 读取,否则检测浏览器语言
const initialLang = getStoredLang() || detectLang();

// 当前语言 Store
export const currentLang = writable<Language>(initialLang);

// 翻译函数 Store
export const t = derived(currentLang, ($lang) => {
    return (key: string): string => {
        const keys = key.split('.');
        let value: any = translations[$lang];
        for (const k of keys) {
            value = value?.[k];
        }
        // 回退到 key 本身
        return typeof value === 'string' ? value : key;
    };
});

// 切换语言
export function setLang(lang: Language) {
    currentLang.set(lang);
    if (typeof localStorage !== 'undefined') {
        localStorage.setItem('lang', lang);
    }
}

// 获取当前语言(非响应式,用于脚本)
export function getLang(): Language {
    return get(currentLang);
}

4. 组件中使用

$ 前缀的作用:Svelte 中 $storeNamestoreName.subscribe() 的语法糖,表示自动订阅该 Store,值变化时组件自动更新。

<!-- +layout.svelte -->
<script lang="ts">
    import { currentLang, t, setLang } from '$lib/i18n';
    
    // 不带 $:获取 Store 对象本身
    console.log(currentLang);  // Store 对象 { subscribe, set, update }
    
    // 带 $:获取 Store 的当前值(自动订阅)
    console.log($currentLang); // 'zh' 或 'en'
</script>

<nav>
    <!-- 使用 $t() 获取翻译,$currentLang 获取当前语言 -->
    <a href="/">{$t('nav.home')}</a>
    <a href="/about">{$t('nav.about')}</a>
    
    <button on:click={() => setLang($currentLang === 'zh' ? 'en' : 'zh')}>
        {$currentLang === 'zh' ? 'EN' : '中文'}
    </button>
</nav>

对比

写法 含义 使用场景
currentLang Store 对象 传递给函数、调用方法
$currentLang Store 的值 模板中显示、读取当前值

5. SSR 服务端渲染支持

SvelteKit 原生支持 SSR,语言从 URL/Cookie 检测,服务端预加载翻译。

服务端与客户端的差异

环境 可用 不可用
服务端 URL、Cookie、Header localStorage、navigator
客户端 全部

Cookie 工具函数

// src/lib/i18n/cookies.ts
import type { Cookies } from '@sveltejs/kit';

export function getLangFromCookies(cookies: Cookies): 'zh' | 'en' {
    const stored = cookies.get('lang');
    return stored === 'zh' || stored === 'en' ? stored : 'zh';
}

export function setLangCookie(cookies: Cookies, lang: 'zh' | 'en') {
    cookies.set('lang', lang, {
        path: '/',
        maxAge: 60 * 60 * 24 * 365  // 1年
    });
}

Layout Load(服务端预加载):

// src/routes/+layout.ts
import type { LayoutLoad } from './$types';
import { translations } from '$lib/i18n/translations';
import { getLangFromCookies, setLangCookie } from '$lib/i18n/cookies';

export const load: LayoutLoad = ({ cookies, url }) => {
    // 服务端:从 Cookie 或 URL 参数获取语言
    const langParam = url.searchParams.get('lang');
    const lang = (langParam === 'en' ? 'en' : 'zh');

    // 同步 Cookie
    setLangCookie(cookies, lang);

    // 预加载翻译数据
    const t = translations[lang];

    return { lang, t };
};

Layout(接管切换):

<!-- src/routes/+layout.svelte -->
<script lang="ts">
    import { onMount } from 'svelte';
    import { setLang } from '$lib/i18n';

    let { data, children } = $props();
    
    // 初始化语言
    setLang(data.lang);
    
    // 语言切换
    function switchLang() {
        const newLang = $currentLang === 'zh' ? 'en' : 'zh';
        setLang(newLang);
        // 跳转刷新
        window.location.href = `/?lang=${newLang}`;
    }
</script>

<nav>
    <a href="/?lang=zh">中文</a>
    <a href="/?lang=en">EN</a>
    <button on:click={switchLang}>
        当前: {$currentLang}
    </button>
</nav>

{@render children()}

工作原理

  1. 用户访问 /?lang=en
  2. 服务端从 URL 读取参数,同步到 Cookie,返回预加载了英文的 HTML
  3. 客户端 setLang(data.lang) 同步 Store,页面已有翻译
  4. 用户切换语言 → 跳转 /?lang=zh → 服务端返回中文 HTML

SEO 友好

  • 搜索引擎爬虫访问 /?lang=zh 抓中文内容
  • 访问 /?lang=en 抓英文内容
  • 每个语言都有独立 URL

5. SSR 注意事项

<!-- 安全访问 localStorage -->
<script lang="ts">
    import { onMount } from 'svelte';
    import { setLang } from '$lib/i18n';
    
    onMount(() => {
        // 客户端才执行
        const saved = localStorage.getItem('lang');
        if (saved) setLang(saved as 'zh' | 'en');
    });
</script>

方案二:svelte-i18n (纯svelte推荐)

社区最流行的方案,API 设计简洁。

npm install svelte-i18n

初始化文件

// src/lib/i18n.ts
import { register, init, getLocaleFromNavigator, locale } from 'svelte-i18n';

// 注册语言文件(懒加载)
register('zh', () => import('./locales/zh.json'));
register('en', () => import('./locales/en.json'));

// 初始化配置
init({
    fallbackLocale: 'zh',
    initialLocale: getLocaleFromNavigator()
});

// 导出切换函数
export { locale };
export const setLocale = (lang: string) => locale.set(lang);

应用入口引入

// src/main.ts (纯 Svelte)
import './lib/i18n';  // ← 必须先导入初始化
import App from './App.svelte';

const app = new App({ target: document.body });
export default app;
// src/routes/+layout.ts (SvelteKit)
import { browser } from '$app/environment';
import { locale, waitLocale } from 'svelte-i18n';
import '$lib/i18n';  // 导入执行初始化
import type { LayoutLoad } from './$types';

export const load: LayoutLoad = async () => {
    if (browser) {
        const saved = localStorage.getItem('lang');
        if (saved) locale.set(saved);
    }
    await waitLocale();  // 等待翻译加载完成
    return {};
};

组件使用

<script>
    import { _, locale } from 'svelte-i18n';
    import { setLocale } from '$lib/i18n';
</script>

<h1>{$_('welcome')}</h1>
<p>{$_('footer.copyright')}</p>

<button on:click={() => setLocale($locale === 'zh' ? 'en' : 'zh')}>
    切换
</button>

带参数的翻译

{
    "hello": "Hello {name}!",
    "items": "You have {count} item | You have {count} items"
}
<p>{$_('hello', { values: { name: 'World' } })}</p>
<p>{$_('items', { values: { count: 5 } })}</p>

方案三:typesafe-i18n

类型安全的国际化方案,IDE 自动补全翻译 key。

npm install typesafe-i18n
npx typesafe-i18n --setup  # 生成配置文件

自动生成类型

// src/i18n/i18n-types.ts(自动生成)
export type Translation = {
    nav: {
        home: string;
        about: string;
    };
    welcome: string;
};

使用

<script lang="ts">
    import { LL } from '$lib/i18n/i18n-svelte';
    import { setLocale } from '$lib/i18n/i18n-util';
</script>

<h1>{$LL.welcome()}</h1>
<a href="/about">{$LL.nav.about()}</a>

方案四:paraglide-js

编译时优化的国际化方案,零运行时开销。

npm install @inlang/paraglide-js

特点

  • 编译时将翻译内联到代码中
  • 只打包用到的翻译
  • 支持 Tree Shaking
// 编译后直接使用
import * as m from '$lib/paraglide/messages.js';

console.log(m.hello_world()); // "Hello World!"

方案五:URL 路由级多语言(SvelteKit)

SEO 友好的方案,语言体现在 URL 中。

/zh/about    → 中文关于页
/en/about    → 英文关于页
/about       → 默认语言

路由配置

// src/params/lang.ts
import type { ParamMatcher } from '@sveltejs/kit';

export const match: ParamMatcher = (param) => {
    return ['zh', 'en'].includes(param);
};
src/routes/
├── [[lang=lang]]/         # 可选语言前缀
│   ├── +page.svelte
│   └── about/
│       └── +page.svelte
└── +layout.ts

加载翻译

// src/routes/[[lang=lang]]/+layout.ts
import type { LayoutLoad } from './$types';
import { translations } from '$lib/i18n/translations';

export const load: LayoutLoad = ({ params }) => {
    const lang = (params.lang as 'zh' | 'en') || 'zh';
    return {
        lang,
        t: translations[lang]
    };
};

关键决策点

场景 推荐方案 理由
快速上线 svelte-i18n 生态成熟,文档丰富
类型安全 typesafe-i18n 编译时检查,IDE 提示
SEO 优先 URL 路由级 语言在 URL,搜索引擎友好
极简依赖 自定义 Store 零依赖,完全可控
大型应用 paraglide-js 编译优化,性能最好
SSR + SEO 自定义 Store + Cookie 服务端预加载,客户端接管切换

最佳实践

1. 延迟加载翻译

// 不要:import zh from './locales/zh';  // 打包进主 bundle
// 要:
register('zh', () => import('./locales/zh.json'));  // 按需加载

2. SSR 安全访问浏览器 API

<script>
    import { browser } from '$app/environment';
    import { onMount } from 'svelte';
    
    // 方式一:onMount
    onMount(() => {
        localStorage.getItem('lang');  // 安全
    });
    
    // 方式二:browser 判断
    if (browser) {
        localStorage.getItem('lang');  // 安全
    }
</script>

3. 回退机制

// 找不到翻译时回退到 key
export function t(key: string): string {
    const value = getNestedValue(translations[lang], key);
    return value || key;  // 回退到 key
}

4. 类型安全(自定义 Store 版)

// 生成翻译 key 的类型
type DotPrefix<T extends string> = T extends '' ? '' : `.${T}`;

type DotPath<T> = (
    T extends object ?
        { [K in keyof T]:
            `${Exclude<K, symbol>}${DotPrefix<DotPath<T[K]>>}`
        }[keyof T] :
        ''
) extends infer D ? Extract<D, string> : never;

export type TranslationKey = DotPath<typeof zh>;

// 使用
export const t = (key: TranslationKey) => ...
// IDE 提示: 'nav.home' | 'nav.about' | 'welcome' ...

5. 语言切换动画

{#key $currentLang}
    <div in:fade={{ duration: 150 }}>
        <h1>{$t('welcome')}</h1>
    </div>
{/key}

参考资源

微服务-乾坤

2026年4月19日 14:01

乾坤:

目标是将庞大的单体前端应用,拆解成多个可独立开发、部署、运行的小型应用(微应用),并最终无缝集成在一起

  • 主应用(基座)

    • 负责注册、加载、卸载子应用。
    • 提供公共布局、登录、全局样式、全局状态。
  • 子应用(微应用)

    • 一个完整的业务模块(如:商品、订单、用户中心)。
    • 暴露固定生命周期钩子,供主应用调用

实操:

一、主应用

1、main.js 注册子应用

import { registerMicroApps, start } from 'qiankun';
const props = {
  getMainData: () => store.state.globalState.mainData,
  updateMainData:(child)=>{
    store.commit('SET_GLOBAL_STATE',child)
  }
}
registerMicroApps([
{
  name: 'vue app',
  entry: '//localhost:8000',
  container: '#childContainer',
  activeRule: '/vue',
  props
},
{
  name: 'react app', // app name registered
  entry: '//localhost:9000',
  container: '#childContainer',
  activeRule: '/react',
  props
}
]);
start({
sandbox: {
  strictStyleIsolation: true, // 严格样式隔离(推荐),主子应用样式隔离
  // experimentalStyleIsolation: true // 可选:追求兼容(弹窗、UI 库正常)
}
})

2、App.vue (任意组件,存放子应用容器)

<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/vue">跳转子应用vue</router-link> |
      <router-link to="/react">跳转子应用react</router-link> |
    </nav>
    <div>
      <h1>主应用data</h1>
      <h2 style="color:red">name:{{ name}}</h2>
    </div>
    <router-view/>
    <hr>
    <div id="childContainer"></div> // 存放子应用容器
  </div>
</template>

<script>
import { mapState } from 'vuex'
export default {
  computed: {
    ...mapState(['globalState']),
    name(){
      return this.globalState?.mainData?.userInfo?.name||''
    }
  }
}
</script>
<style lang="scss">
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;
}
.childContainer{
  display: flex;
  justify-content: center;
}
</style>

二、子应用-vue

1、main.js

// 定义变量存储 Vue 实例
let instance = null

// 渲染函数
function render(props = {}) {
  const { container } = props

  instance = new Vue({
    router,
    store,
    render: h => h(App)
  // 乾坤会把容器传给你,避免挂载到主应用根节点(不污染主应用节点)
  }).$mount(container ? container.querySelector('#app') : '#app')
}

// 独立运行时(非微应用环境)直接渲染
if (!window.__POWERED_BY_QIANKUN__) {
  render()
}

export async function bootstrap() {
  // console.log('[vue] 微应用初始化')
}
export async function mount(props) { // props主应用传递的公共数据
  store.commit('SET_GLOBAL_STATE',{
    ...props,
    mainData:props?.getMainData()||{}
  })
  render(props)
}

export async function unmount() {
  console.log('[vue2] 微应用卸载')
  instance.$destroy() // 销毁实例
  instance.$el.innerHTML = '' // 清空 DOM
  instance = null
}

说明:为什么主应用传递给子应用时,子应用能拿到 container.querySelector('#app') ,主应用时如何能识别到的?

当子应用被主应用加载时,qiankun 会自动做这一步

  1. 去请求子应用的 index.html
  2. 解析子应用 HTML,隔离后挂载到主应用容器(包括里面的 <div id="app"></div>
  3. 子应用的根节点 #app 渲染到主应用的 #childContainer 容器中

2、vue.config.js(子应用能被主应用识别加载)

const { defineConfig } = require('@vue/cli-service')
const { name } = require('./package.json')

module.exports = defineConfig({
  // 微应用唯一名称(主应用注册时要一致)
  configureWebpack: {
    output: {
      library: `${name}-[name]`, // 主应用上name呼应
      libraryTarget: 'umd', // 把微应用打包成 umd 格式,让子应用变成“可被主应用加载的格式”
      chunkLoadingGlobal: `webpackJsonp_${name}`,
    },
  },
  transpileDependencies: true,
  devServer: {
    port: 8000, // 自己定义微应用端口
    headers: {
      'Access-Control-Allow-Origin': '*', // 允许跨域(乾坤必须)
    },
  },
})

3、router/index.js

const router = new VueRouter({
  mode: 'history',
  base: window.__POWERED_BY_QIANKUN__ ? '/vue' : process.env.BASE_URL, // vue 是主应用配置的 activeRule
  routes
})

说明:为什么vue不需要改publicPath?

  • Vue CLI 项目默认 publicPath: '/'

  • 被 qiankun 加载时,会自动修正子应用静态资源路径

  • 子应用部署到非根目录时必须改,不是永远不用改

三、子应用-react

1、index.js

let instance = null

function render(props = {}) {
  const { container } = props
  const domContainer = container
    ? container.querySelector('#root')
    : document.getElementById('root')

  instance = ReactDOM.createRoot(domContainer)

  instance.render(
    <React.StrictMode>
      {/* 必须包 Provider */}
      <Provider store={store}>
        <RouterProvider router={router} />
      </Provider>
    </React.StrictMode>
  )
}
// 独立运行
if (!window.__POWERED_BY_QIANKUN__) {
  render()
}

export async function bootstrap() {
  console.log('[react] 微应用初始化')
}

export async function mount(props) {
  store.dispatch({
    type: "SET_GLOBAL_STATE",
    payload: {
      ...props,
      mainData: props?.getMainData?.() || {}
    }
  })
  render(props)
}

export async function unmount() {
  if (instance) {
    instance.unmount()
    instance = null
  }
}
reportWebVitals();

2、craco.config.js

react脚手架默认是这样的:

  • 所有 webpack、babel、eslint 配置全部藏在 node_modules 里
  • 你看不到、改不了
  • 你的项目很干净,只有 src、public

官方eject方法:

  • 不可逆:一旦执行,再也回不去

    • 把所有隐藏的配置文件,一次性全部复制到你的项目里
    • 这个命令会被删掉,再也不能执行第二次,也不能撤销!
  • 暴露几百个配置文件,你必须自己维护所有依赖和更新

    • 暴露几百个配置文件,必须自己维护依赖
  • 失去 CRA 后续升级能力

    • CRA 官方会不断更新,但eject就没有了(比如:优化打包速度、修复安全漏洞、升级 webpack、升级 babel、升级 eslint、加新特性等)

craco不用 eject,也能改 webpack 配置:

const { name } = require('./package.json')

module.exports = {
  webpack: {
    configure: (config) => {
      config.output.library = `${name}-[name]`
      config.output.libraryTarget = 'umd'
      config.output.chunkLoadingGlobal = `webpackJsonp_${name}`
      config.output.publicPath = process.env.NODE_ENV === 'development'
    ? 'http://localhost:9000/' 
    : '/'; // 方便引入静态资源不会404
    return config
    }
  },
  devServer: (config) => {
    config.headers = {
      'Access-Control-Allow-Origin': '*'
    }
    return config
  }
}

3、router/index

import { createBrowserRouter } from 'react-router-dom'
import App from "../App.js"

// 👇 核心:微应用必须加这个 base
const base = window.__POWERED_BY_QIANKUN__ ? '/react' : '/'

const router = createBrowserRouter([
  {
    path: '/',
    element:<App />
  }
], {
  basename: base  // 👈 这里注入 base
})

export default router

四、主、子通信

1、vuex+props

  • 将公共数据、更新公共数据方法存储到vuex
  • 通过注册应用registerMicroApps中props传递给子数据
    const props = {
      getMainData: () => store.state.globalState.mainData,
      updateMainData:(child)=>{
        store.commit('SET_GLOBAL_STATE',child)
      }
    }
     registerMicroApps([
        {
          name: 'vue app',
          entry: '//localhost:8000',
          container: '#childContainer',
          activeRule: '/vue',
          props
        }
      ]);
    
  • 子应用通过周期函数mount获取props再另行存储

2、initGlobalState、setGlobalState、onGlobalStateChange

  • initGlobalState(数据初始化)
  • setGlobalState(更新数据)
  • onGlobalStateChange(监听数据变化)
// qiankun/index.js
import { initGlobalState } from 'qiankun';

const initialState = {
  userInfo: {},
  token:''
}

// 生成 actions
const actions = initGlobalState(initialState)

// 监听全局变化(可选)
actions.onGlobalStateChange((state) => {
  console.log('主应用全局状态变化:', state)
})
export { actions }    
// main.js
import "./qiankun"
// 组件内使用
import { actions } from '@/qiankun/index.js'

onChangeGlobal(){
  actions.setGlobalState({token:`token_update_----`})
}
// 子应用中使用
// 子应用通过props接收,方法都在props上可以直接调用
props.setGlobalState({token:'00000000000000000000'})

五、子、子通信

需要主应用做中转

  • initGlobalState主应用
  • 子应用A:setGlobalState
  • 子应用B:onGlobalStateChange监听获取

总结:

主应用、子应用相连:

1、主应用做什么

  • 注册子应用(registerMicroApps

  • 启动 qiankun(start

  • 提供子应用挂载容器(<div id="container"></div>

  • 通过 activeRule 路由规则匹配子应用

2、子应用做什么

  • 子应用在主应用提供的容器内进行渲染

  • 导出生命周期函数bootstrap/mount/unmount

  • 配置 webpack 打包为 umd 格式(让主应用能识别)

    • library
    • libraryTarget: 'umd'
    • chunkLoadingGlobal
  • 配置跨域devServer.headers

  • 配置路由 base(与主应用 activeRule 对应)

  • 配置 publicPath(防止静态资源 404)

    • React 必须配
    • Vue 可配可不配(建议配)

深度解析浏览器本地存储:原理、方案与实战指南

作者 Wect
2026年4月19日 13:01

在前端开发中,“浏览器本地存储”是一个高频出现但容易被浅尝辄止的知识点——我们常用它保存用户偏好、缓存接口数据、实现离线访问,却很少深入探究其底层原理、不同存储方案的差异的适用场景。本文将从“为什么需要本地存储”出发,逐层拆解Cookie、localStorage、sessionStorage、IndexedDB、Cache API这五大核心存储方案,结合通俗类比与专业解析,搭配原理流程图和实战示例,帮你彻底吃透浏览器本地存储,同时规避使用中的“坑点”,适合作为学习笔记或团队技术分享。

阅读提示:本文面向前端开发工程师、前端学习者,假设你具备基础的HTML、JavaScript知识,无需后端或底层浏览器内核经验,全程用“通俗类比+专业拆解”的方式讲解,兼顾深度与易懂性。

一、前置认知:为什么需要浏览器本地存储?

在没有本地存储的时代,浏览器与服务器的交互遵循“HTTP无状态协议”——简单说,服务器记不住你是谁,每次请求都是“陌生人见面”。比如你登录网站后,刷新页面就需要重新登录;浏览商品时,切换页面购物车就会清空。这不仅体验极差,还会增加服务器的请求压力(每次都要重新传输用户状态数据)。

浏览器本地存储的核心作用,就是在客户端(用户浏览器)保存少量或大量数据,实现“状态持久化”,解决HTTP无状态的痛点。类比来说,浏览器本地存储就像你电脑上的“文件夹”,网站可以把需要频繁使用的数据存进去,下次访问时直接读取,不用再麻烦服务器“重复发送”。

其核心价值主要有3点:

  • 提升用户体验:保存用户偏好(如主题、语言)、会话状态(如登录状态、购物车),避免重复操作;

  • 降低服务器压力:缓存非敏感接口数据、静态资源(如图片、CSS),减少重复请求;

  • 支持离线访问:结合PWA技术,缓存核心资源和数据,让用户在无网络环境下也能访问部分功能。

这里需要明确一个关键概念:浏览器本地存储≠内存存储。内存存储(如JavaScript中的变量、数组)是“临时存储”,页面刷新、浏览器关闭后数据就会丢失;而本地存储是“持久化存储”(部分方案除外),数据会保存在用户设备的硬盘中,即使关闭浏览器,再次打开仍能读取。

补充:浏览器本地存储受“同源策略”限制——即只有同一协议(http/https)、同一域名、同一端口的网页,才能共享本地存储数据。这是浏览器的安全机制,防止不同网站之间窃取数据。

二、五大核心存储方案:原理、特性与对比

浏览器提供了五种常用的本地存储方案,各自有不同的设计初衷、容量限制、生命周期和适用场景。我们先通过一张表格快速梳理核心差异,再逐一深入解析每种方案的底层原理和实战用法。

存储方案 容量限制 生命周期 核心特性 适用场景
Cookie 约4KB/域名 可设置过期时间(会话级/持久级) 自动随HTTP请求发送到服务器,支持跨域配置 会话管理、身份验证、用户追踪
localStorage 约5-10MB/源 持久化,除非手动删除或清除浏览器数据 客户端独有,不自动发送到服务器,同步操作 用户偏好设置、非敏感数据缓存
sessionStorage 约5-10MB/源 会话级,关闭标签页/浏览器后失效 客户端独有,不自动发送,标签页隔离,同步操作 临时表单数据、页面会话状态
IndexedDB 无固定上限(受设备磁盘空间限制) 持久化,除非手动删除 客户端NoSQL数据库,异步操作,支持复杂查询和二进制存储 大量结构化数据、离线应用、文件缓存
Cache API 无固定上限(受浏览器配额管理) 持久化,可被浏览器主动清理 专为资源缓存设计,配合Service Worker,支持离线访问 静态资源(HTML/CSS/JS/图片)缓存、PWA离线支撑

2.1 Cookie:历史最久的“数据信使”

Cookie是浏览器本地存储中历史最悠久的方案,诞生于1994年,最初是为了解决“HTTP无状态”的问题——让服务器能够识别用户的连续请求。通俗来说,Cookie就像服务器给用户发的“身份证”,用户第一次访问服务器时,服务器会生成一个唯一标识,放在响应头中发给浏览器,浏览器保存这个“身份证”,之后每次访问该服务器,都会自动把“身份证”带上,服务器就能通过它识别出用户。

2.1.1 底层原理与工作流程

Cookie的工作流程可分为4步,用文字流程图表示如下:

1. 客户端(浏览器)发送HTTP请求到服务器(如访问www.example.com);

2. 服务器处理请求后,在响应头中添加Set\-Cookie字段,携带Cookie数据(如会话ID、用户偏好);

3. 浏览器接收响应后,解析Set\-Cookie字段,将Cookie数据保存到本地(按域名分类存储);

4. 客户端后续访问该服务器时,浏览器会自动在请求头中添加Cookie字段,携带之前保存的Cookie数据,服务器通过该数据识别用户状态。

关键细节:Cookie是“按域名隔离”的,不同域名的Cookie互不干扰;同一域名下的Cookie,会根据DomainPath属性进一步限制作用范围。

2.1.2 核心属性详解(必掌握)

Cookie的行为由多个属性控制,理解这些属性是正确使用Cookie的关键,也是面试高频考点:

  • Name=Value:Cookie的核心,键值对形式,存储具体数据(如sessionId=abc123),值只能是字符串。

  • Expires:过期时间(GMT格式),如Expires=Wed, 21 Oct 2026 07:28:00 GMT,指定Cookie的绝对过期时间;若不设置,默认为“会话级Cookie”,关闭浏览器后失效。

  • Max-Age:过期时间(相对秒数),如Max-Age=3600(表示1小时后过期),优先级高于Expires;从Chrome M104版本开始,Max-Age不能超过400天,防止永久性跟踪。

  • Domain:指定Cookie所属域名,默认是设置Cookie的页面主机名(不含子域);若设置为.Domain=example.com(前面带点),则该Cookie可在example.com及其所有子域(如www.example.com、api.example.com)下访问,常用于跨子域共享会话信息。

  • Path:指定Cookie生效的URL路径,默认是设置Cookie的页面路径;如Path=/admin,则只有访问/admin、/admin/users等路径时,浏览器才会发送该Cookie,用于限制作用范围。

  • Secure:标记为Secure的Cookie,只能通过HTTPS协议发送到服务器,防止Cookie在HTTP连接中被窃取;设置SameSite=None时,必须同时设置Secure,否则Cookie设置失败。

  • HttpOnly:禁止JavaScript通过document.cookie访问Cookie,只能由服务器通过HTTP头读写,有效防止XSS攻击窃取敏感Cookie(如会话ID),敏感数据建议必设。

  • SameSite:控制Cookie在跨站请求中的发送行为,用于防范CSRF攻击,有三个值:

    • Strict(严格模式):仅在同站请求中发送,完全禁止第三方Cookie,安全性最高,但可能影响用户体验(如从外部链接点击进入网站需重新登录);

    • Lax(宽松模式):现代浏览器默认值,允许顶级导航(如点击链接)的GET请求发送Cookie,禁止POST、iframe、AJAX等场景发送,平衡安全性和可用性;

    • None(无限制):允许跨站请求发送Cookie,必须同时设置Secure,适用于第三方登录、嵌入式内容等场景。

一个完整的Cookie设置示例(服务器响应头):

Set-Cookie: sessionId=abc123; Domain=.example.com; Path=/; Max-Age=3600; Secure; HttpOnly; SameSite=Lax

2.1.3 实战用法与注意事项

客户端(JavaScript)操作Cookie:

// 1. 设置Cookie(简单写法,可添加属性)
document.cookie = "username=zhangsan; Max-Age=3600; Path=/; Secure; SameSite=Lax";

// 2. 读取Cookie(需手动解析,因为document.cookie返回所有Cookie的字符串拼接)
function getCookie(name) {
  const cookies = document.cookie.split("; ");
  for (let cookie of cookies) {
    const [key, value] = cookie.split("=");
    if (key === name) return decodeURIComponent(value);
  }
  return null;
}

// 3. 删除Cookie(设置Max-Age=0或Expires为过去时间)
document.cookie = "username=; Max-Age=0; Path=/";

注意事项:

  • 容量限制极严(4KB),只能存储少量数据,不能存复杂对象;

  • 每次HTTP请求都会自动携带Cookie,过多或过大的Cookie会增加请求体积,影响加载速度;

  • 敏感数据(如密码、令牌)需设置HttpOnly和Secure属性,防止泄露;

  • 避免滥用Cookie进行数据存储,优先用其他方案存储非会话相关数据。

2.2 localStorage:最常用的“持久化存储”

localStorage是HTML5新增的本地存储方案,设计初衷是“在客户端持久化存储少量非敏感数据”,弥补Cookie容量小、自动发送的缺点。通俗来说,localStorage就像一个“本地记事本”,你可以把需要长期保存的小数据(如用户主题、语言设置)写进去,即使关闭浏览器,下次打开仍能看到,且不会主动发送给服务器。

2.2.1 底层原理与核心特性

localStorage基于“同源策略”,每个源(协议+域名+端口)拥有独立的localStorage空间,不同源之间无法访问对方的localStorage数据。其底层是将数据以键值对的形式存储在浏览器的本地文件中(不同浏览器存储位置不同,如Chrome存储在SQLite数据库中),属于“持久化存储”——除非用户手动清除(如清除浏览器缓存、通过代码删除),否则数据会一直存在。

核心特性:

  • 容量:约5-10MB/源(不同浏览器略有差异,Chrome为5MB);

  • 数据类型:仅支持字符串,存储对象、数组等复杂数据时,需用JSON.stringify()序列化,读取时用JSON.parse()反序列化;

  • 操作方式:同步操作(阻塞主线程),适合少量数据操作,大量数据操作会导致页面卡顿;

  • 跨标签共享:同源的不同标签页,可共享localStorage数据,一个标签页修改后,其他标签页可通过storage事件监听变化。

2.2.2 实战用法与常见坑点

localStorage的API非常简洁,只有4个核心方法:

// 1. 存储数据(键值对,值必须是字符串)
localStorage.setItem("theme", "dark"); // 简单字符串
localStorage.setItem("userInfo", JSON.stringify({ name: "zhangsan", age: 20 })); // 复杂对象

// 2. 读取数据
const theme = localStorage.getItem("theme");
const userInfo = JSON.parse(localStorage.getItem("userInfo")); // 反序列化

// 3. 删除指定数据
localStorage.removeItem("theme");

// 4. 清空所有数据(慎用,会删除当前源下所有localStorage数据)
localStorage.clear();

常见坑点(必避):

  • 坑点1:忘记序列化/反序列化——存储对象时未用JSON.stringify(),会自动转为“[object Object]”,读取后无法使用;

  • 坑点2:同步操作阻塞主线程——频繁读写大量数据(如循环存储1000条数据),会导致页面卡顿,建议合并操作或改用IndexedDB;

  • 坑点3:存储敏感数据——localStorage可被JavaScript访问,易受XSS攻击窃取数据,严禁存储密码、令牌等敏感信息;

  • 坑点4:多环境key冲突——开发、测试、生产环境共用同一域名时,不同环境的key可能冲突,建议添加环境前缀(如dev_theme、prod_theme);

  • 坑点5:隐私模式限制——部分浏览器(如Safari)的隐私模式下,localStorage会被临时存储,关闭隐私窗口后数据丢失。

2.3 sessionStorage:“一次性”的会话存储

sessionStorage与localStorage API完全一致,核心区别在于生命周期——sessionStorage是“会话级存储”,数据仅在当前标签页/窗口的生命周期内有效,关闭标签页、刷新页面(F5)不会清空,但新开标签页(即使是同源)会创建新的sessionStorage空间,关闭浏览器后数据彻底丢失。

通俗来说,sessionStorage就像“临时便签纸”,你可以把当前页面的临时数据(如表单草稿、临时筛选条件)写进去,切换标签页或关闭浏览器后,便签纸就会自动销毁,不会占用长期存储空间。

2.3.1 核心特性与适用场景

核心特性(与localStorage对比):

  • 生命周期:会话级,关闭标签页/窗口失效,刷新页面保留;

  • 作用域:标签页隔离,同一源的不同标签页,sessionStorage互不共享;

  • 其他特性:容量、数据类型、API与localStorage完全一致,同步操作。

适用场景:

  • 多步表单草稿(如注册表单,分步骤填写,防止刷新页面丢失数据);

  • 单页应用(SPA)的路由临时状态(如当前选中的菜单、分页页码);

  • 临时缓存数据(如接口请求的临时结果,无需长期保存);

  • OAuth回跳防止重复提交(存储临时授权码,使用后立即删除)。

2.3.2 实战示例与注意事项

实战示例(与localStorage用法一致,仅替换对象名):

// 存储多步表单草稿
sessionStorage.setItem("formStep1", JSON.stringify({ username: "zhangsan", phone: "13800138000" }));

// 读取表单草稿
const formStep1 = JSON.parse(sessionStorage.getItem("formStep1"));

// 页面跳转后,清除临时数据
sessionStorage.removeItem("formStep1");

注意事项:

  • sessionStorage不能跨标签共享,若需要跨标签传递临时数据,可改用localStorage+storage事件,或postMessage;

  • 虽然数据会自动销毁,但敏感临时数据(如临时令牌)仍需在使用后手动删除,防止意外泄露;

  • 避免用sessionStorage存储需要长期保留的数据,否则会导致用户体验下降(如刷新页面后数据丢失)。

2.4 IndexedDB:客户端的“NoSQL数据库”

当需要存储大量结构化数据(如用户笔记、离线商品列表)、二进制数据(如图片、文件)时,Cookie、localStorage、sessionStorage的容量和功能就无法满足需求——此时,IndexedDB应运而生。IndexedDB是HTML5新增的客户端内置NoSQL数据库,具备大容量、异步操作、复杂查询、事务支持等特性,通俗来说,它就像“浏览器里的小数据库”,可以存储大量数据,且不会阻塞页面渲染。

2.4.1 底层原理与核心概念

IndexedDB的底层基于B树索引,数据以“键值对”形式存储,支持多种数据类型(字符串、数字、对象、数组、Blob、File等),无需序列化即可存储复杂对象。其核心概念如下(类比关系型数据库,便于理解):

  • 数据库(Database):IndexedDB的顶层容器,每个源可创建多个数据库,数据库名唯一,需通过版本号管理(版本号递增,不可递减);

  • 对象仓库(Object Store):类似关系型数据库的“表”,用于存储同一类型的结构化数据,每个数据库可包含多个对象仓库;

  • 索引(Index):类似数据库索引,用于加速数据查询,可基于对象仓库的某个字段创建索引,支持单字段索引、复合索引;

  • 事务(Transaction):保证数据操作的原子性(要么全部成功,要么全部失败),IndexedDB的所有数据操作都必须在事务中进行,支持读写事务、只读事务;

  • 游标(Cursor):用于遍历对象仓库中的数据,支持按条件筛选、排序,适合大量数据的分页查询。

核心特性:

  • 容量:无固定上限,受设备磁盘空间限制,浏览器会进行配额管理(通常为磁盘空间的50%),超出配额时会提示用户;

  • 操作方式:异步操作(基于事件或Promise),不会阻塞主线程,适合大量数据操作;

  • 数据类型:支持复杂对象、二进制数据,无需序列化;

  • 查询能力:支持基于键、索引的范围查询、模糊查询,功能远超Web Storage;

  • 生命周期:持久化,除非用户手动删除或浏览器清理,否则数据一直存在。

2.4.2 实战用法(原生API+封装简化)

IndexedDB原生API基于事件,写法繁琐,容易陷入“回调地狱”,实际开发中通常会使用封装库(如Dexie.js、idb)简化操作。以下先展示原生API的核心流程,再给出Dexie.js的简化示例。

原生API核心流程(创建数据库、操作数据):

// 1. 打开数据库(不存在则创建,版本号1)
const request = indexedDB.open("MyDatabase", 1);

// 2. 数据库首次创建或版本更新时,创建对象仓库和索引
request.onupgradeneeded = function(e) {
  const db = e.target.result;
  // 创建对象仓库(主键为id,自增)
  const userStore = db.createObjectStore("users", { keyPath: "id", autoIncrement: true });
  // 创建索引(基于name字段,不允许重复)
  userStore.createIndex("nameIndex", "name", { unique: false });
};

// 3. 打开成功,获取数据库实例
request.onsuccess = function(e) {
  const db = e.target.result;
  // 执行数据操作(增删改查)
  addUser(db, { name: "zhangsan", age: 20, gender: "male" });
  getUserById(db, 1);
};

// 4. 打开失败(如版本号错误)
request.onerror = function(e) {
  console.error("打开数据库失败:", e.target.error);
};

// 新增数据(需在读写事务中进行)
function addUser(db, user) {
  const transaction = db.transaction("users", "readwrite");
  const store = transaction.objectStore("users");
  const addRequest = store.add(user);
  addRequest.onsuccess = function() {
    console.log("新增用户成功");
  };
  addRequest.onerror = function(e) {
    console.error("新增用户失败:", e.target.error);
  };
}

// 根据id查询数据
function getUserById(db, id) {
  const transaction = db.transaction("users", "readonly");
  const store = transaction.objectStore("users");
  const getRequest = store.get(id);
  getRequest.onsuccess = function(e) {
    console.log("查询到的用户:", e.target.result);
  };
}

Dexie.js简化示例(推荐实际开发使用):

// 1. 安装Dexie.js:npm install dexie
import Dexie from "dexie";

// 2. 创建数据库实例
const db = new Dexie("MyDatabase");

// 3. 定义对象仓库和索引(版本号1)
db.version(1).stores({
  users: "++id, name, age", // ++id表示自增主键,name、age为索引字段
  notes: "++id, title, updatedAt" // 新增notes对象仓库
});

// 4. 数据操作(Promise语法,简洁易懂)
// 新增用户
db.users.add({ name: "zhangsan", age: 20 }).then(() => {
  console.log("新增用户成功");
}).catch(err => {
  console.error("新增失败:", err);
});

// 查询所有用户
db.users.toArray().then(users => {
  console.log("所有用户:", users);
});

// 根据name查询用户
db.users.where("name").equals("zhangsan").first().then(user => {
  console.log("查询到的用户:", user);
});

// 修改用户
db.users.update(1, { age: 21 }).then(updatedCount => {
  console.log("修改成功,影响条数:", updatedCount);
});

// 删除用户
db.users.delete(1).then(() => {
  console.log("删除用户成功");
});

2.4.3 适用场景与注意事项

适用场景:

  • 离线Web应用:存储核心业务数据(如用户笔记、离线订单),实现无网络环境下的访问;

  • 大量结构化数据:如电商网站的商品缓存、新闻网站的文章缓存,减少接口请求;

  • 二进制数据存储:如图片、音频、PDF文件的本地缓存,提升加载速度;

  • 复杂查询场景:需要根据多个条件筛选、排序数据,Web Storage无法满足需求时。

注意事项:

  • 原生API繁琐,建议使用封装库(Dexie.js、idb),提升开发效率;

  • 异步操作需注意回调/Promise的执行顺序,避免数据操作混乱;

  • 事务的原子性:若事务中的某一步操作失败,整个事务会回滚,需做好错误处理;

  • 敏感数据需加密存储:IndexedDB可被JavaScript访问,易受XSS攻击,敏感数据(如用户隐私)需通过Web Crypto API加密后再存储。

2.5 Cache API:专为资源缓存设计的“利器”

Cache API是HTML5新增的、专为“静态资源缓存”设计的本地存储方案,常与Service Worker配合使用,是PWA(渐进式Web应用)实现离线访问的核心技术。通俗来说,Cache API就像“浏览器的资源缓存文件夹”,专门用于存储HTTP请求和响应(如HTML、CSS、JS、图片等静态资源),下次访问时,可直接从缓存中读取资源,无需再次请求服务器,大幅提升页面加载速度。

2.5.1 底层原理与核心特性

Cache API的核心是“缓存键值对”,键是Request对象,值是Response对象,即缓存的是“完整的HTTP请求-响应对”。其底层存储与IndexedDB类似,受浏览器配额管理,容量无固定上限,但浏览器会在磁盘空间不足时,主动清理长期未使用的缓存。

核心特性:

  • 用途专一:仅用于缓存HTTP请求和响应,不适合存储业务数据;

  • 操作方式:异步操作(基于Promise),不阻塞主线程;

  • 缓存策略:支持自定义缓存策略(如缓存优先、网络优先、 stale-while-revalidate);

  • 生命周期:持久化,可被浏览器主动清理,也可通过代码手动删除;

  • 依赖环境:需在HTTPS协议(或localhost)下使用,依赖Service Worker实现请求拦截。

2.5.2 实战用法(配合Service Worker)

Cache API通常与Service Worker配合使用,实现“资源缓存+离线访问”,核心流程分为3步:注册Service Worker、缓存核心资源、拦截请求并从缓存读取。

// 1. 主页面(index.html)注册Service Worker
if ("serviceWorker" in navigator && "Cache" in window) {
  window.addEventListener("load", async () => {
    try {
      // 注册Service Worker
      const registration = await navigator.serviceWorker.register("/sw.js");
      console.log("Service Worker注册成功:", registration);
    } catch (err) {
      console.error("Service Worker注册失败:", err);
    }
  });
}

// 2. Service Worker文件(sw.js):缓存核心资源+拦截请求
const CACHE_NAME = "my-cache-v1"; // 缓存版本号,用于更新缓存
const CACHE_ASSETS = [
  "/",
  "/index.html",
  "/css/style.css",
  "/js/main.js",
  "/images/logo.png" // 需要缓存的静态资源
];

// 安装阶段:缓存核心资源
self.addEventListener("install", (e) => {
  // 等待缓存完成后,再完成安装
  e.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(CACHE_ASSETS))
      .then(() => self.skipWaiting()) // 强制激活新的Service Worker
  );
});

// 激活阶段:删除旧版本缓存
self.addEventListener("activate", (e) => {
  e.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.filter(name => name !== CACHE_NAME)
          .map(name => caches.delete(name)) // 删除旧缓存
      );
    }).then(() => self.clients.claim()) // 控制所有打开的客户端
  );
});

// 拦截请求:优先从缓存读取,无缓存则请求网络
self.addEventListener("fetch", (e) => {
  // 只缓存GET请求(POST请求不适合缓存)
  if (e.request.method !== "GET") return;

  e.respondWith(
    caches.match(e.request)
      .then(cachedResponse => {
        // 缓存存在则返回缓存,否则请求网络
        return cachedResponse || fetch(e.request)
          .then(networkResponse => {
            // 将网络响应存入缓存(更新缓存)
            caches.open(CACHE_NAME).then(cache => {
              cache.put(e.request, networkResponse.clone());
            });
            return networkResponse;
          })
          .catch(() => {
            // 网络失败时,返回备用页面(如离线提示页)
            return caches.match("/offline.html");
          });
      })
  );
});

Cache API核心方法(手动操作缓存):

// 1. 打开缓存(不存在则创建)
const cache = await caches.open("my-cache-v1");

// 2. 缓存资源(添加请求-响应对)
await cache.add("/css/style.css"); // 自动发送请求并缓存响应
await cache.put(new Request("/js/main.js"), new Response("Hello World")); // 手动添加缓存

// 3. 读取缓存
const response = await cache.match("/css/style.css");

// 4. 删除缓存条目
await cache.delete("/images/old-logo.png");

// 5. 清空缓存
await cache.clear();

// 6. 获取所有缓存条目
const cacheEntries = await cache.keys();

2.5.3 适用场景与注意事项

适用场景:

  • PWA应用:缓存核心静态资源,实现离线访问、秒开页面;

  • 静态资源缓存:如网站的CSS、JS、图片、字体等,减少重复请求,提升加载速度;

  • 图片懒加载备用:缓存已加载的图片,下次访问时直接从缓存读取;

  • 接口数据缓存:缓存GET请求的接口数据(如商品列表、新闻内容),减少接口请求压力。

注意事项:

  • 不适合缓存动态数据(如实时排行榜、用户个人信息),避免数据过期;

  • POST、PUT、DELETE等非GET请求不适合缓存,因为这类请求会修改服务器数据;

  • 需做好缓存更新策略:通过版本号管理缓存,避免缓存过期导致页面显示异常;

  • 依赖Service Worker,需兼容低版本浏览器(如IE不支持),可做降级处理。

三、存储方案选型指南:按需选择,避免踩坑

实际开发中,选择哪种本地存储方案,核心取决于“数据量、生命周期、是否需要发送到服务器、是否需要复杂查询”这四个维度。以下是具体的选型建议,结合场景帮你快速决策:

3.1 按场景选型

  • 场景1:会话管理、身份验证(如登录状态) 选型:Cookie(必设HttpOnly、Secure、SameSite属性)

理由:自动随HTTP请求发送到服务器,适合服务器识别用户状态,4KB容量足够存储会话ID。

  • 场景2:用户偏好设置(如主题、语言、布局) 选型:localStorage

理由:持久化存储,容量足够(5-10MB),API简洁,无需自动发送到服务器。

  • 场景3:临时表单、页面会话数据(如多步表单、临时筛选条件) 选型:sessionStorage

理由:会话级生命周期,自动销毁,避免污染长期存储,标签页隔离更安全。

  • 场景4:大量结构化数据、离线应用、复杂查询(如用户笔记、商品缓存) 选型:IndexedDB(推荐用Dexie.js封装)

理由:大容量、支持复杂查询和二进制存储,异步操作不阻塞主线程,适合离线场景。

  • 场景5:静态资源缓存、PWA离线访问(如CSS、JS、图片) 选型:Cache API + Service Worker 理由:专为资源缓存设计,支持自定义缓存策略,是PWA离线访问的核心。

3.2 常见选型误区

  • 误区1:用localStorage存储敏感数据(如密码、令牌)——易受XSS攻击,应改用HttpOnly Cookie或加密后的IndexedDB;

  • 误区2:用Cookie存储大量数据——容量仅4KB,会增加请求体积,应改用localStorage或IndexedDB;

  • 误区3:用sessionStorage跨标签共享数据——sessionStorage标签页隔离,无法跨标签共享,应改用localStorage;

  • 误区4:用IndexedDB存储静态资源——不如Cache API高效,Cache API专为资源缓存设计,配合Service Worker更便捷;

  • 误区5:忽略缓存更新——如localStorage、Cache API的缓存未及时更新,会导致页面显示旧数据,需做好版本管理或过期清理。

四、安全防护:规避本地存储的风险

浏览器本地存储虽然便捷,但也存在安全风险——数据存储在客户端,可被用户手动修改或通过恶意脚本窃取。以下是核心安全防护措施,必看!

4.1 核心安全风险

  • XSS攻击(跨站脚本攻击):恶意脚本通过用户输入、第三方库、浏览器扩展等方式注入页面,读取localStorage、IndexedDB、Cookie(无HttpOnly属性)中的数据,窃取用户信息;

  • CSRF攻击(跨站请求伪造):恶意网站利用用户的登录状态(Cookie自动发送),伪造用户请求,执行恶意操作(如转账、修改密码);

  • 本地篡改:用户可通过浏览器开发者工具,手动修改localStorage、sessionStorage、Cookie(无HttpOnly属性)的数据,绕过前端校验;

  • 第三方脚本泄露:引入的第三方脚本(如统计脚本、UI库)被攻破后,可访问本地存储数据,导致信息泄露。

4.2 安全防护措施

  • 针对XSS攻击

    • 敏感Cookie设置HttpOnly属性,禁止JavaScript访问;

    • 对用户输入进行过滤、转义(如防止HTML、JavaScript代码注入);

    • 使用CSP(内容安全策略),限制脚本加载来源,禁止inline-script;

    • localStorage、IndexedDB存储敏感数据时,先通过Web Crypto API加密;

    • 谨慎引入第三方脚本,优先选择官方渠道,定期检查脚本安全性。

  • 针对CSRF攻击

    • Cookie设置SameSite属性(推荐Lax或Strict),限制跨站请求发送;

    • 服务器端添加CSRF令牌,前端请求时携带令牌,验证请求合法性;

    • 敏感操作(如转账、修改密码)添加二次验证(如短信验证码、密码确认)。

  • 针对本地篡改

    • 前端校验仅作为辅助,核心校验逻辑必须在服务器端实现;

    • 对本地存储的数据添加校验码(如MD5),读取时验证数据完整性,防止篡改;

    • 敏感数据不存储在客户端,仅存储非敏感的临时数据或标识(如会话ID)。

  • 其他防护

    • 使用HTTPS协议,防止数据在传输过程中被窃取、篡改;

    • 定期清理过期缓存和无用数据,减少安全风险;

    • 隐私模式下,避免存储敏感数据,部分浏览器隐私模式会临时存储数据,关闭后丢失。

五、总结与扩展

本文详细讲解了浏览器本地存储的五大核心方案——Cookie、localStorage、sessionStorage、IndexedDB、Cache API,从底层原理、核心特性、实战用法、选型指南到安全防护,覆盖了前端开发中本地存储的所有核心知识点。

核心总结:

  • Cookie:小容量、自动发送,适合会话管理;

  • localStorage:中容量、持久化,适合用户偏好;

  • sessionStorage:中容量、会话级,适合临时数据;

  • IndexedDB:大容量、结构化,适合离线应用和复杂查询;

  • Cache API:资源专用,适合静态资源缓存和PWA。

扩展知识点(进阶学习):

  • Web Crypto API:用于本地存储数据加密,提升数据安全性;

  • PWA离线缓存策略:结合Cache API和Service Worker,实现更完善的离线访问;

  • IndexedDB性能优化:如索引设计、事务管理、批量操作优化;

  • 浏览器存储配额管理:了解不同浏览器的存储限制,处理配额不足的场景;

  • 跨域存储方案:如postMessage、iframe结合localStorage,实现跨域数据传递。

浏览器本地存储是前端开发的基础知识点,也是提升用户体验、优化性能的关键手段。掌握每种存储方案的适用场景和安全隐患,才能在实际开发中按需选择、合理使用,既保证功能实现,又兼顾安全性和性能。

瑞幸 UI 上 pub.dev 了 —— 22 个 Flutter 组件,与微信小程序版双端对齐

作者 qwfy
2026年4月19日 11:13

瑞幸 UI 上 pub.dev 了 —— 22 个 Flutter 组件,与微信小程序版双端对齐

把 DESIGN.md 当作跨端的"单一真相",一套设计语言同时喂给 WeChat 小程序和 Flutter。

效果截图

瑞幸-fl-首页.png

瑞幸-fl-方案选择.png

瑞幸-fl-等级卡.png

瑞幸-fl-产品.png

瑞幸-fl-左侧导航.png

瑞幸-fl-通知.png

瑞幸-fl-网格.png

瑞幸-fl-头像.png

瑞幸-fl-按钮.png

背景

之前我写了《我从瑞幸咖啡小程序里,拆出了一套 22 个组件的开源 UI 库》,发布了 npm 包 lkcn-ui

验证 DESIGN.md 真的是"可复用的设计规范"吗,那它至少应该能驱动两个不同的运行时。于是有了这一版:

  • GitHubhttps://github.com/qwfy5287/lkcn-ui-flutter
  • pub.devhttps://pub.dev/packages/lkcn_ui
  • 姊妹项目https://github.com/qwfy5287/lkcn-ui(小程序版)

双端对照

两个仓库,一份 DESIGN.md,相同的 22 个组件:

平台 包名 分发 仓库
微信小程序 lkcn-ui npm qwfy5287/lkcn-ui
Flutter lkcn_ui pub.dev qwfy5287/lkcn-ui-flutter

命名这里踩了个小坑:pub.dev 要求 snake_case,不能用连字符,所以 npm 的 lkcn-ui 到 pub.dev 就成了 lkcn_ui。这是 Dart/Flutter 生态的惯例,不算破坏品牌一致性。

版本号策略是 MAJOR.MINOR 对齐 + PATCH 独立——看到 npm 1.2.3 + pub 1.2.1 就知道 API 对齐、只是 Flutter 单独修了两个 bug。

设计语言的「跨端翻译」

如果说小程序版是把 DESIGN.md 翻译成 WXSS + WXML,那 Flutter 版就是翻译成 Dart Widget。这过程有 5 件事需要做决定:

1. Design Token:CSS 变量 → Dart const class

小程序版把 token 写成 CSS 变量,注入到 page {}

page {
  --lkcn-blue: #1A6EFF;
  --lkcn-radius-md: 24rpx;
}

Flutter 没有 CSS 变量这种运行时机制,但它的类型系统更强。我用 const class 做等价物:

class LkcnColors {
  static const Color primary = Color(0xFF002FA7);      // 克莱因蓝
  static const Color accentOrange = Color(0xFFFF6A3D);
  static const Color accentGold = Color(0xFFC9A66B);
}

class LkcnRadius {
  static const double md = 12;
  static const double pill = 999;
}

使用:

Container(
  decoration: BoxDecoration(
    color: LkcnColors.primary,
    borderRadius: BorderRadius.circular(LkcnRadius.md),
  ),
)

好处是编译期常量、IDE 自动补全、类型安全;坏处是换肤没办法像 CSS 变量那样"覆盖即生效"——要彻底换肤得上 ThemeExtension。首版先不折腾这个。

2. 单位:rpx → logical pixels

小程序的 rpx 基于 750 设计稿,Flutter 的 logical pixel 是独立密度单位。换算规则就一条:rpx = lpt × 2

字号 28rpx 对应 14 lpt,间距 24rpx 对应 12 lpt,圆角 16rpx 对应 8 lpt。习惯了之后是肌肉记忆,但第一次做映射表时你会翻 variables.wxss 翻到吐。

3. 组件 API:kebab-case → PascalCase / enum

  • 组件类:lkcn-buttonLkcnButton
  • 枚举属性:type="primary"LkcnButtonType.primary
  • 事件回调:bind:tap="onClick"onTap: () {}

Flutter 的 enum 比字符串属性严格得多——如果你传了个不存在的 type 字符串,小程序只会默默 fallback,Flutter 直接编译不过。对库作者是好事。

4. 插槽:<slot> → Widget 参数

小程序靠 <slot> 传子内容,支持具名插槽。Flutter 对应的是具名参数:

LkcnCard(
  title: '我的资产',
  child: Column(children: [...]),   // 主内容
  footer: Row(...),                  // footer 槽
)

一个命名参数 = 一个插槽,清晰、类型安全、IDE 能提示。

5. Demo 的组织:pages/demo-*example/lib/demos/*

小程序版每个 demo 是独立 page(wxml/wxss/js/json 四件套),通过 pages.json 注册。Flutter 版按 pub.dev 惯例,example/ 是个独立的可运行 app,每个组件对应一个 .dart 文件,用 MaterialPageRoute 跳转:

example/
├── lib/
│   ├── main.dart              # 按 原子/交互/容器/业务 分组的索引页
│   └── demos/
│       ├── button_demo.dart
│       ├── product_card_demo.dart
│       └── ... (21 个)
└── pubspec.yaml               # path: ../ 引用主包

cd example && flutter run 就能跑,iOS / Android / macOS / Web 四端都能看。这比小程序的"打开微信开发者工具"门槛低多了。

几个还原得比较得意的组件

LkcnStepper:加购从 + 展开到 [-] n [+]

瑞幸菜单页最有辨识度的微交互,Flutter 版用 setState 切两个形态:

LkcnStepper(
  value: _quantity,
  onChanged: (v) => setState(() => _quantity = v),
)

弹性动画走 LkcnMotion.bounce(即 Cubic(0.34, 1.56, 0.64, 1)),跟 WXSS cubic-bezier 常量完全一致。

LkcnPrice:三段式价格渲染

"符号小 + 整数大 + 小数小"的层次是瑞幸价格的灵魂:

LkcnPrice(value: 9.9, original: 32, prefix: '预估到手')

内部把 9.9 拆成 9.9 两段不同字号,¥ 给第三种字号,原价走 TextDecoration.lineThrough

LkcnCouponScroll:票据左侧半圆缺口

小程序版靠 CSS clip-path 裁出缺口,Flutter 没有这个 API。我用 CustomPainter 手画 path:

final path = Path()
  ..moveTo(r, 0)
  ..lineTo(size.width - r, 0)
  // ...
  ..lineTo(0, size.height * 0.5 + 6)
  ..arcToPoint(                          // ← 半圆缺口
    Offset(0, size.height * 0.5 - 6),
    radius: const Radius.circular(6),
    clockwise: false,
  )
  ..close();

最终效果和小程序版几乎一致。CustomPainter 写起来比 CSS clip-path 啰嗦,但控制粒度更细。

LkcnMembershipPlan:会员订阅全流程

方案选择器 + 订阅 CTA + 协议勾选,三件事一个 Widget 解决:

LkcnMembershipPlan(
  plans: const [
    LkcnPlan(name: '连续包月', price: 9.9, badge: '爆款天天 9.9 起'),
    LkcnPlan(name: '月卡', price: 19.9),
  ],
  agreement: '开通会员代表接受',
  agreementLinks: const [
    LkcnAgreementLink(text: '《会员服务协议》'),
    LkcnAgreementLink(text: '《自动续费协议》'),
  ],
  onSubscribe: (plan, agreed) {
    // agreed = false 时可以弹 toast 提示勾选
  },
)

快速上手

pubspec.yaml

dependencies:
  lkcn_ui: ^0.1.0

业务代码:

import 'package:flutter/material.dart';
import 'package:lkcn_ui/lkcn_ui.dart';

class MenuPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: LkcnColors.pageBg,
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          LkcnProductCard(
            image: 'https://.../coconut-latte.png',
            title: '生椰拿铁',
            tags: const ['全球销量第一', 'IIAC 金奖'],
            price: 9.9,
            originalPrice: 32,
            pricePrefix: '预估到手',
            onAdd: () {},
          ),
          const SizedBox(height: 16),
          LkcnButton.cta(
            text: '立即开通连续包月 ¥9.9',
            size: LkcnButtonSize.large,
            block: true,
            round: true,
            onTap: () {},
          ),
        ],
      ),
    );
  }
}

22 个组件速览

  • 原子:Button · Tag · Price · Badge · Avatar
  • 交互:SearchBar · Segment · Stepper · Tabs · Tabbar
  • 容器:Card · Grid · Swiper · NoticeBar · LocationBar · FloatingButton · CategorySidebar
  • 业务:ProductCard · CouponScroll · PromoCard · LevelCard · MembershipPlan

每个的 API 尽量跟 npm 版同名、同语义。小程序那边的 bind:add 事件在 Flutter 是 onAdd,小程序的 custom-class 在 Flutter 通过 child/padding 参数调——这些映射关系看完一遍 README 就能对上号。

一些数据

  • 22 个组件,零第三方依赖(只依赖 Flutter SDK)
  • 约 3000 行 Dart 代码(不含 example)
  • flutter analyze / example && flutter analyze0 警告 0 错误
  • lib/ 目录 25 个 .dart 文件
  • Dart SDK:^3.11.3,Flutter:>=3.22.0
  • MIT License

跨端维护的几条经验

做完这版 Flutter 之后,最深的感受是:跨端组件库的真正难点不在代码,在保持纪律。

  1. DESIGN.md 做单一真相:色值 / 间距 / 圆角这些决策写在文档里,而不是写在某一端代码的注释里。PR 有分歧时,以文档为准。
  2. MAJOR.MINOR 对齐 + PATCH 独立:两端版本号不强求完全一致,但 API 变更要同步发版。
  3. Issue 加端标签[wx] / [flutter] / [design] 三类,避免跨端 issue 混战。
  4. demo 先行:改组件前先改 demo,再改源码 —— 这样能强制你想清楚 API 长什么样。

后续计划

  • 每个组件写 widget test,提 pub.dev Like / Popularity 评分
  • ThemeExtension 版的 Design Token,支持运行时换肤
  • 深色模式
  • GitHub Actions CI:analyze + test + 自动 pub publish
  • VitePress 双端文档站(两端 API 并排展示)

觉得有用的话,欢迎 Star / 试用:

  • GitHub:https://github.com/qwfy5287/lkcn-ui-flutter
  • pub.dev:https://pub.dev/packages/lkcn_ui
  • 小程序版姊妹项目:https://github.com/qwfy5287/lkcn-ui

🧑‍💻 顺便求职

目前正在找工作,前端优先,全栈也可以胜任,坐标 厦门

案例集(前端 / 全栈):my.feishu.cn/wiki/XUmGw8…

有合适岗位欢迎评论或私信,感谢。

Vue自定义指令全解析(Vue2+Vue3适配)| 底层DOM操作必备

2026年4月19日 11:02

Vue 除了提供 v-modelv-showv-bind 等内置指令外,还允许开发者注册自定义指令(Custom Directives),用于封装涉及普通元素的底层 DOM 访问逻辑,弥补内置指令的灵活性不足。自定义指令的核心作用是复用 DOM 相关的重复操作,无需在组件的生命周期钩子中编写大量冗余代码,尤其适合焦点控制、权限控制、输入校验、动画效果等场景,是 Vue 开发中提升代码复用性和可维护性的重要手段。

本文将详细讲解 Vue 自定义指令的核心概念、注册方式、钩子函数、参数说明,结合 Vue2 与 Vue3 的语法差异,提供可直接复制的实战示例和进阶用法,兼顾新手入门与企业级实战需求。

一、自定义指令核心基础(必懂)

1. 核心定位

自定义指令主要用于处理底层 DOM 操作,与组件、组合式函数形成互补:组件是主要的构建模块,组合式函数侧重于有状态的逻辑,而自定义指令则专注于 DOM 元素的直接操作。需要注意的是,若功能可通过 v-bind 等内置指令或组件实现,优先选择内置指令,因其更高效、对服务端渲染更友好。

2. 命名规范

自定义指令的命名需遵循以下规范,确保兼容性和可读性:

  • 指令名不包含 v- 前缀(注册时无需写,使用时必须加 v-);
  • 命名采用“小写字母 + 连字符”形式(如 v-focusv-permission),避免驼峰式(Vue3 中虽支持驼峰命名,但模板中仍需转为连字符形式);
  • 避免与 Vue 内置指令重名(如不能命名为 v-modelv-show)。

3. 核心分类

根据作用域,自定义指令分为两类,适配不同使用场景:

  • 全局指令:在整个 Vue 应用中注册,所有组件均可直接使用,适合通用型场景(如 v-focusv-loading);
  • 局部指令:仅在单个组件内注册,仅当前组件可用,适合组件专属的 DOM 操作场景。

二、自定义指令的注册方式(Vue2+Vue3对比)

Vue2 与 Vue3 的注册方式核心差异在于“全局注册的调用对象”,局部注册逻辑基本一致,以下是完整实战示例。

1. 全局注册(推荐通用指令使用)

// 1. Vue2 全局注册(main.js)
import Vue from 'vue'
import App from './App.vue'

// 全局注册 v-focus 指令(实现输入框自动聚焦)
Vue.directive('focus', {
  // 钩子函数(后续详解)
  mounted(el) {
    el.focus() // 直接操作DOM元素
  }
})

new Vue({
  render: h => h(App)
}).$mount('#app')

// 2. Vue3 全局注册(main.js)
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// 全局注册 v-focus 指令,语法与Vue2一致,仅注册对象不同
app.directive('focus', {
  mounted(el) {
    el.focus()
  }
})

app.mount('#app')

2. 局部注册(推荐组件专属指令使用)

// 1. Vue2 局部注册(组件内)
<template>
  <input v-focus type="text" placeholder="自动聚焦输入框" />
</template>

<script>
export default {
  // 局部注册指令,仅当前组件可用
  directives: {
    focus: {
      mounted(el) {
        el.focus()
      }
    }
  }
}
</script>

// 2. Vue3 局部注册(选项式API,与Vue2一致)
<template>
  <input v-focus type="text" placeholder="自动聚焦输入框" />
</template>

<script>
export default {
  directives: {
    focus: {
      mounted(el) {
        el.focus()
      }
    }
  }
}
</script>

// 3. Vue3 局部注册(组合式API,<script setup><template>
  <input v-focus type="text" placeholder="自动聚焦输入框" />
</template>

<script setup>
// 组合式API中,直接定义以v开头的驼峰变量,即可完成局部注册
// 变量名vFocus,模板中使用时需转为v-focus
const vFocus = {
  mounted(el) {
    el.focus()
  }
}
</script>

说明:Vue3 组合式 API 中,无需在 directives 选项中注册,只要定义以 v 开头的驼峰式变量(如 vFocus),即可在模板中以 v-focus 形式使用,简化了局部注册流程。

三、自定义指令的钩子函数(核心)

自定义指令的本质是一组钩子函数的集合,用于在指令生命周期的不同阶段执行 DOM 操作。Vue2 与 Vue3 的钩子函数名称和执行时机有差异,核心逻辑一致,以下分版本详解。

1. Vue3 钩子函数(7个,推荐)

Vue3 提供 7 个钩子函数,覆盖指令从绑定到卸载的完整生命周期,按执行顺序排列如下:

app.directive('custom', {
  // 1. created:指令绑定到元素后立即调用(元素未插入DOM,无法操作DOM)
  created(el, binding, vnode) {},
  // 2. beforeMount:元素被插入DOM前调用
  beforeMount(el, binding, vnode) {},
  // 3. mounted:元素被插入DOM后调用(最常用,适合执行初始化DOM操作)
  mounted(el, binding, vnode) {},
  // 4. beforeUpdate:包含指令的组件更新前调用(子组件未更新)
  beforeUpdate(el, binding, vnode, prevVnode) {},
  // 5. updated:包含指令的组件更新后调用(子组件已更新,适合更新DOM状态)
  updated(el, binding, vnode, prevVnode) {},
  // 6. beforeUnmount:元素被卸载前调用(可做清理前准备)
  beforeUnmount(el, binding, vnode) {},
  // 7. unmounted:元素被卸载后调用(必须清理资源,避免内存泄漏)
  unmounted(el, binding, vnode) {}
})

2. Vue2 钩子函数(5个)

Vue2 的钩子函数与 Vue3 对应,名称和执行时机略有差异,核心功能一致,按执行顺序排列如下:

Vue.directive('custom', {
  // 1. bind:指令第一次绑定到元素时调用(仅一次,元素未插入DOM,可做初始化设置)
  bind(el, binding, vnode) {},
  // 2. inserted:元素被插入父节点时调用(仅保证父节点存在,不一定插入文档)
  inserted(el, binding, vnode) {},
  // 3. update:包含指令的组件VNode更新时调用(子组件可能未更新)
  update(el, binding, vnode, oldVnode) {},
  // 4. componentUpdated:组件VNode及其子VNode全部更新后调用
  componentUpdated(el, binding, vnode, oldVnode) {},
  // 5. unbind:指令与元素解绑时调用(仅一次,用于清理资源)
  unbind(el, binding, vnode) {}
})

3. 钩子函数参数(Vue2/Vue3通用)

所有钩子函数都会接收 4 个固定参数(顺序不可变),除 el 外,其他参数均为只读,不可修改,若需共享数据,可通过 el 的自定义属性实现:

  • el:指令绑定的真实 DOM 元素,可直接操作(如el.focus()el.style.color = 'red');

  • binding:指令绑定信息的对象,核心属性如下:

    • value:传递给指令的值(如 v-custom="100",value 为 100);
    • oldValue:指令的旧绑定值,仅在 update/componentUpdated(Vue2)、beforeUpdate/updated(Vue3)中可用;
    • arg:指令的参数(如 v-custom:click,arg 为 'click');
    • modifiers:指令的修饰符对象(如 v-custom.prevent,modifiers 为 { prevent: true });
    • name:指令名(不包含 v- 前缀)。
  • vnode:Vue 编译生成的虚拟节点,描述 DOM 元素的结构;

  • prevVnode(Vue3)/ oldVnode(Vue2):上一个虚拟节点,仅在更新相关钩子中可用。

4. 钩子函数简化写法

若仅需使用 mounted(Vue3)或 bind + inserted(Vue2)一个钩子函数,可简化为函数形式,无需定义完整的钩子对象:

// Vue3 简化写法(仅使用mounted钩子)
app.directive('focus', (el) => {
  el.focus() // 等同于 { mounted: (el) => el.focus() }
})

// Vue2 简化写法(等同于 bind + inserted 钩子执行相同逻辑)
Vue.directive('focus', (el) => {
  el.focus()
})

四、Vue2与Vue3自定义指令核心差异汇总

为方便快速区分和项目迁移,整理核心差异如下,重点关注钩子函数和注册方式的差异:

对比维度 Vue2 Vue3
全局注册方式 Vue.directive('指令名', 钩子对象/函数) app.directive('指令名', 钩子对象/函数)
局部注册方式 仅支持 directives 选项注册 支持 directives 选项 +
钩子函数 bind、inserted、update、componentUpdated、unbind(5个) created、beforeMount、mounted、beforeUpdate、updated、beforeUnmount、unmounted(7个)
核心差异点 无 created、beforeMount、beforeUnmount 钩子 新增3个钩子,完善生命周期覆盖;支持组合式API集成
虚拟节点参数 update/componentUpdated 接收 oldVnode beforeUpdate/updated 接收 prevVnode

五、实战示例(可直接复制使用)

以下示例覆盖企业级开发中高频场景,适配 Vue2 和 Vue3,标注清晰,复制后可直接集成到项目中。

示例1:v-focus(自动聚焦,基础示例)

实现输入框挂载后自动聚焦,比原生 autofocus 属性更实用,可在 Vue 动态插入元素时生效。

// Vue3 实现(全局注册,main.js)
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)
// 自动聚焦指令
app.directive('focus', {
  mounted(el) {
    el.focus() // 元素挂载后执行聚焦
  }
})
app.mount('#app')

// 模板中使用(所有组件均可使用)
<template>
  <input v-focus type="text" placeholder="自动聚焦输入框" />
</template>

// Vue2 实现(全局注册,main.js)
import Vue from 'vue'
import App from './App.vue'

Vue.directive('focus', {
  inserted(el) {
    el.focus() // Vue2 用inserted钩子,确保元素已插入DOM
  }
})

new Vue({
  render: h => h(App)
}).$mount('#app')

示例2:v-permission(权限控制,后台系统必用)

根据用户权限控制元素显隐,无对应权限则移除元素,适用于按钮、菜单等权限管控场景。

// Vue3 实现(全局注册,directives/permission.js)
import { useUserStore } from '@/stores/user' // 假设使用Pinia管理用户状态

export default {
  mounted(el, binding) {
    const userStore = useUserStore()
    const permission = binding.value // 接收权限码(如 'user:add')
    if (!permission) return
    // 无权限则移除元素
    if (!userStore.permissions.includes(permission)) {
      el.parentNode?.removeChild(el)
    }
  }
}

// main.js 引入注册
import permission from './directives/permission'
app.directive('permission', permission)

// 模板中使用
<button v-permission="'user:add'">添加用户</button>
<button v-permission="'user:delete'">删除用户</button>

// Vue2 实现(全局注册)
import Vue from 'vue'
import store from './store' // Vuex管理用户状态

Vue.directive('permission', {
  inserted(el, binding) {
    const permission = binding.value
    if (!permission) return
    if (!store.state.user.permissions.includes(permission)) {
      el.parentNode?.removeChild(el)
    }
  }
})

示例3:v-debounce(防抖点击,防重复提交)

实现按钮点击防抖,避免用户快速点击导致重复请求,适用于搜索、提交等场景。

// Vue3 实现(局部注册,组件内)
<script setup>
// 防抖指令
const vDebounce = {
  mounted(el, binding) {
    const { func, delay = 300 } = binding.value // 接收函数和延迟时间
    let timer = null
    // 绑定点击事件,实现防抖
    el.addEventListener('click', () => {
      clearTimeout(timer)
      timer = setTimeout(() => func(), delay)
    })
    // 卸载时清理定时器,避免内存泄漏
    el._timer = timer
  },
  unmounted(el) {
    clearTimeout(el._timer)
  }
}

// 点击事件
const handleSubmit = () => {
  console.log('提交表单')
}
</script>

<template>
  <button v-debounce="{ func: handleSubmit, delay: 500 }">提交</button>
</template>

示例4:v-lazy(图片懒加载,性能优化)

实现图片懒加载,当图片进入视口后再加载,减少首屏资源请求,提升加载速度。

// Vue3 实现(全局注册)
app.directive('lazy', {
  mounted(el, binding) {
    // 监听元素是否进入视口
    const observer = new IntersectionObserver(([{ isIntersecting }]) => {
      if (isIntersecting) {
        el.src = binding.value // 进入视口后加载图片
        observer.unobserve(el) // 加载完成后停止监听
      }
    })
    observer.observe(el) // 开始监听元素
  }
})

// 模板中使用(src绑定占位图,v-lazy绑定真实图片地址)
<template>
  <img v-lazy="realImgUrl" src="placeholder.png" alt="懒加载图片" />
</template>

六、自定义指令进阶用法

1. 指令传递动态参数和修饰符

通过 arg 传递动态参数,modifiers 传递修饰符,实现更灵活的指令逻辑:

<template>
  <!-- 动态参数:click(触发事件),修饰符:prevent(阻止默认行为) -->
  <button v-custom:click.prevent="handleClick">点击触发</button>
</template>

<script setup>
const vCustom = {
  mounted(el, binding) {
    const event = binding.arg // 接收动态参数:click
    const { prevent } = binding.modifiers // 接收修饰符:prevent
    // 绑定事件
    el.addEventListener(event, (e) => {
      // 若有prevent修饰符,阻止默认行为
      if (prevent) e.preventDefault()
      binding.value() // 执行传递的函数
    })
  }
}

const handleClick = () => {
  console.log('点击事件触发')
}
</script>

2. 指令与组件实例交互

Vue3 中,可通过 binding.instance 访问使用指令的组件实例,实现指令与组件的联动:

app.directive('custom', {
  mounted(el, binding) {
    // 访问组件实例的data、methods
    const componentInstance = binding.instance
    console.log(componentInstance.msg) // 访问组件的msg数据
    componentInstance.handleMethod() // 调用组件的方法
  }
})

3. 指令模块化封装

对于大型项目,可将通用指令封装为独立模块,统一管理,便于复用和维护:

// 1. 新建 directives/index.js(指令入口)
import focus from './focus'
import permission from './permission'
import debounce from './debounce'

// 批量注册全局指令
export default (app) => {
  app.directive('focus', focus)
  app.directive('permission', permission)
  app.directive('debounce', debounce)
}

// 2. main.js 引入
import installDirectives from './directives'
const app = createApp(App)
installDirectives(app) // 批量注册所有指令
app.mount('#app')

七、自定义指令使用注意事项

1. 避免过度使用

自定义指令仅用于底层 DOM 操作,若功能可通过组件、Props、组合式函数实现,优先选择其他方式,避免滥用指令导致代码逻辑混乱。

2. 必须清理资源

unbind(Vue2)或 unmounted(Vue3)钩子中,必须清理指令绑定的事件监听器、定时器、观察者等资源,避免内存泄漏(如示例中防抖指令清理定时器)。

3. 不依赖 DOM 结构

指令操作的 DOM 元素可能被动态渲染或删除,需做好容错处理(如使用 el.parentNode?.removeChild(el),避免父节点不存在导致报错)。

4. 区分 Vue2/Vue3 钩子差异

Vue2 中,若需操作已插入 DOM 的元素,需使用 inserted钩子;Vue3 中,对应使用 mounted 钩子,避免因钩子使用错误导致 DOM 操作失效。

5. 支持 TypeScript 类型定义

Vue3 中,可通过扩展 ComponentCustomProperties 接口,为自定义全局指令添加 TypeScript 类型,提升类型安全性和开发体验。

八、总结

Vue 自定义指令的核心是封装底层 DOM 操作逻辑,实现代码复用,其核心用法可总结为:

  • 注册方式:全局注册(通用指令)、局部注册(组件专属指令),Vue3 组合式 API 简化了局部注册流程;
  • 核心逻辑:通过钩子函数在指令生命周期的不同阶段执行 DOM 操作,钩子参数提供了指令绑定的关键信息;
  • 适用场景:焦点控制、权限控制、防抖节流、图片懒加载、输入校验等需直接操作 DOM 的场景;
  • 版本差异:重点区分 Vue2 与 Vue3 的钩子函数和注册方式,便于项目迁移和兼容。

本文所有示例均可直接复制到项目中使用,只需根据 Vue 版本调整钩子函数和注册方式,即可快速适配实战需求。合理使用自定义指令,能有效减少冗余代码,提升项目的可维护性和开发效率。

Vue插槽用法全解析(Vue2+Vue3适配)| 组件复用必备

2026年4月19日 10:49

Vue插槽(Slot)是组件间内容分发的核心机制,用于解决“父组件向子组件传递模板片段”的需求,实现组件的灵活复用与结构解耦。简单来说,插槽就是子组件中预留的“内容占位符”,占位符的具体内容由父组件决定,子组件仅负责固定布局和逻辑,让组件既能保持统一风格,又能灵活适配不同场景。

本文将详细讲解Vue插槽的核心概念、3种核心用法(默认插槽、具名插槽、作用域插槽),明确Vue2与Vue3的语法差异,提供可直接复制的实战示例,同时梳理常见问题,兼顾新手入门与实战开发需求。

一、插槽核心基础(必懂)

插槽的核心逻辑可类比为“函数传参”:父组件向子组件传递“模板内容”(相当于函数参数),子组件通过<slot>标签(相当于函数接收参数的位置)接收并渲染内容,最终实现“子组件定结构、父组件定内容”的复用效果。

核心要点:

  • 插槽内容可是任意合法模板(文本、标签、组件等),不局限于简单文本;
  • 插槽内容的作用域:插槽内容定义在父组件,因此只能访问父组件的数据,无法直接访问子组件的数据(需用作用域插槽解决);
  • Vue2与Vue3插槽核心功能一致,仅在具名插槽、作用域插槽的语法上有差异,下文将分别标注适配版本。

二、Vue插槽3种核心用法(实战重点)

按“基础到复杂”排序,默认插槽适用于简单内容分发,具名插槽适用于多区域内容分发,作用域插槽适用于子组件向父组件传递数据后,父组件自定义渲染内容。

1. 默认插槽(匿名插槽)—— 最简单的内容分发

默认插槽是最基础的插槽形式,子组件中仅定义一个无名称的<slot>标签,父组件传入的所有未命名内容,都会自动填充到这个插槽中。Vue2与Vue3用法基本一致。

实战示例(Vue2+Vue3通用)

// 1. 子组件(SlotDefault.vue)—— 定义默认插槽
<template>
  <div class="slot-container">
    <!-- 插槽出口:未命名,即为默认插槽 --&gt;
    &lt;slot&gt;
      <!-- 后备内容(默认内容):父组件未传入内容时显示 -->
      这是默认插槽的后备内容(父组件未传内容时显示)
    </slot>
  </div>
</template>

<style scoped>
.slot-container {
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}
</style>

// 2. 父组件 —— 使用默认插槽
<template>
  <div>
    <h3>默认插槽用法</h3>
    <!-- 方式1:传入简单文本 -->
    <SlotDefault>父组件传入的简单文本内容</SlotDefault>

    <!-- 方式2:传入复杂内容(标签+组件) -->
    <SlotDefault>
      <span style="color: #42b983;">父组件传入的带样式文本</span>
      <button>父组件传入的按钮</button>
      <!-- 传入其他组件 -->
      <OtherComponent />
    </SlotDefault>

    <!-- 方式3:不传入内容(显示子组件的后备内容) -->
    <SlotDefault />
  </div>
</template>

<script setup>
// Vue3 需引入子组件
import SlotDefault from './SlotDefault.vue'
import OtherComponent from './OtherComponent.vue'
</script>

// Vue2 脚本写法(父组件)
<script>
import SlotDefault from './SlotDefault.vue'
import OtherComponent from './OtherComponent.vue'
export default {
  components: { SlotDefault, OtherComponent }
}
</script>

说明:子组件<slot>标签内的内容为“后备内容”,仅当父组件未传入任何插槽内容时才会显示,传入内容后会自动替换后备内容。

2. 具名插槽 —— 多区域内容精准分发

当子组件需要多个不同的内容占位区域(如页面布局的头部、主体、底部)时,默认插槽无法满足需求,此时需使用具名插槽。通过给<slot>标签添加name属性命名,父组件可精准将内容分发到对应插槽,Vue2与Vue3语法差异较大。

实战示例(Vue2 vs Vue3)

// 1. 子组件(SlotNamed.vue)—— 定义具名插槽(Vue2+Vue3通用)
<template>
  <div class="layout">
    <!-- 头部插槽:name="header" -->
    <slot name="header">默认头部</slot>
    
    <!-- 主体插槽:name="main" -->
    <slot name="main">默认主体</slot>
    
    <!-- 底部插槽:name="footer" -->
    <slot name="footer">默认底部</slot>
  </div>
</template>

<style scoped>
.layout {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.layout > div { padding: 10px; border: 1px solid #eee; }
</style>

// 2. 父组件使用 —— Vue2 写法
<template>
  <SlotNamed>
    <!-- 用 slot 属性指定插槽名称,已废弃(Vue2.6+推荐用v-slot) -->
    <div slot="header">Vue2 头部内容(自定义)</div>
    <div slot="main">Vue2 主体内容(自定义)</div>
    <div slot="footer">Vue2 底部内容(自定义)</div>
    
    <!-- Vue2.6+ 推荐写法:template + v-slot -->
    <template v-slot:header>
      <div>Vue2.6+ 头部内容(自定义)</div>
    </template>
    <template v-slot:main>
      <div>Vue2.6+ 主体内容(自定义)</div>
    </template>
    <template v-slot:footer>
      <div>Vue2.6+ 底部内容(自定义)</div>
    </template>
  </SlotNamed>
</template>

// 3. 父组件使用 —— Vue3 写法(核心:废弃slot属性,统一用v-slot)
<template>
  <SlotNamed>
    <!-- 语法:template + v-slot:插槽名,可简写为 #插槽名 -->
    <template #header>
      <div>Vue3 头部内容(自定义)</div>
    </template>
    <template #main>
      <div>Vue3 主体内容(自定义)</div>
    </template>
    <template #footer>
      <div>Vue3 底部内容(自定义)</div>
    </template>
    
    <!-- 未命名内容,自动分发到默认插槽(若子组件有默认插槽) -->
    <div>默认插槽内容(未命名)</div>
  </SlotNamed>
</template>

<script setup>
import SlotNamed from './SlotNamed.vue'
</script>

关键差异:Vue2支持slot属性和v-slot两种写法,Vue3仅支持v-slot(简写为#),且必须配合<template>标签使用(默认插槽可省略<template>)。

3. 作用域插槽 —— 子传父数据+父自定义渲染

默认插槽和具名插槽,只能实现“父组件向子组件传递内容”,无法让插槽内容访问子组件的数据。作用域插槽解决了这一问题:子组件通过v-bind将自身数据绑定到<slot>标签上(称为“插槽属性”),父组件接收这些数据后,可根据子组件数据自定义插槽内容的渲染方式。

核心场景:子组件有数据(如列表数据),但渲染样式由父组件决定(如列表项可渲染为文字、按钮、卡片)。

实战示例(Vue2 vs Vue3)

// 1. 子组件(SlotScoped.vue)—— 绑定子组件数据(Vue2+Vue3通用)
<template>
  <div class="list">
    <!-- 子组件数据:列表数组 -->
    <div v-for="(item, index) in list" :key="index">
      <!-- 绑定子组件数据到插槽::item="item" :index="index" -->
      <slot :item="item" :index="index"&gt;
        <!-- 后备内容父组件未自定义渲染时显示 -->
        {{ item.name }}(默认渲染)
      </slot>
    </div>
  </div>
</template>

<script setup>
// Vue3 脚本
import { ref } from 'vue'
const list = ref([
  { id: 1, name: 'Vue基础', type: '前端' },
  { id: 2, name: '插槽用法', type: '前端' },
  { id: 3, name: '路由跳转', type: '前端' }
])
</script>

// Vue2 脚本(子组件)
<script>
export default {
  data() {
    return {
      list: [
        { id: 1, name: 'Vue基础', type: '前端' },
        { id: 2, name: '插槽用法', type: '前端' },
        { id: 3, name: '路由跳转', type: '前端' }
      ]
    }
  }
}
</script>

// 2. 父组件使用 —— Vue2 写法
<template>
  <SlotScoped>
    <!-- 方式1:slot-scope 接收插槽属性(Vue2.6-) -->
    <div slot-scope="slotProps">
      索引:{{ slotProps.index }} | 名称:{{ slotProps.item.name }}
    </div>
    
    <!-- 方式2:v-slot 接收(Vue2.6+ 推荐,可解构) -->
    <template v-slot:default="slotProps">
      <!-- 解构简化:直接提取item和index -->
      <template v-slot:default="{ item, index }">
        索引{{ index + 1 }}:{{ item.name }}({{ item.type }})
      </template>
    </template>
  </SlotScoped>
</template>

// 3. 父组件使用 —— Vue3 写法(核心:废弃slot-scope,统一用v-slot接收)
<template>
  <SlotScoped>
    <!-- 方式1:完整写法,接收所有插槽属性 -->
    <template #default="slotProps">
      索引:{{ slotProps.index }} | 名称:{{ slotProps.item.name }}
    </template>
    
    <!-- 方式2:解构简化(推荐),可设置默认值避免报错 -->
    <template #default="{ item = { name: '默认名称' }, index = 0 }">
      索引{{ index + 1 }}:{{ item.name }}({{ item.type }})
      <button @click="handleClick(item.id)">查看详情</button>
    </template>
  </SlotScoped>
</template>

<script setup>
import SlotScoped from './SlotScoped.vue'
const handleClick = (id) => {
  console.log('查看ID为', id, '的详情')
}
</script>

关键差异:Vue2用slot-scopev-slot接收插槽属性,Vue3仅用v-slot接收,且支持ES6解构赋值,可设置默认值提升组件健壮性。

三、Vue2与Vue3插槽语法差异汇总

为方便快速区分和迁移,整理核心差异如下,重点关注Vue3的语法规范:

插槽类型 Vue2 语法 Vue3 语法 核心差异
默认插槽 直接在子组件标签内写内容;支持
❌
❌