普通视图

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

Vue3 接入 Google 登录:极简教程

作者 wing98
2026年3月14日 15:10

公司目前做的是一款激光雕刻产品,主要用于出口海外,需要开发一个web社区网站用于桌面端模型生成后发布到社区进行分享和交流。说到出海产品,google第三方登录是必须接入的,这两天和后端一起开发完成了此功能,现将流程大概梳理如下。

首先当然是看Google的OAuth 2.0文档,了解其流程和参数。

文档地址:developers.google.com/identity/pr…

第一步:在Google API Console创建 OAuth 2.0 凭据

第二步:接入方式和流程

我们可以看到文档提供了多种应用类型的接入方式,对于web来说主要是红框里的两种。

1、适用于服务器端 Web 应用

我们目前采用的方式,需要后端存储google用户信息。

接入流程:前端唤起 Google 授权 → 前端获取授权码 → 后端用授权码换 token → 验证用户信息并返回自有 token

2、适用于 JavaScript Web 应用

主要前端完成google登录,后端只需要接收前端返回的token进行验证即可。

接入流程:用户点击登录 → 授权 -> 前端直接获得id_token + 用户信息 -> id_token 发给后端验证 → 完成登录。

另外对于前端交互来说均有两种可供选择:

1、popup模式:在当前页面弹窗授权,体验更友好。

2、redirect模式:跳转新页面授权后重定向回来。

popup模式,采用vue3-google-login第三方依赖。需要注意的点:

1、前端测试通过code换取access_token时,postman需要设置请求头“Content-Type: application/x-www-form-urlencoded”,否则会报错“invalid_grant”。

2、Google凭据那里不需要配置重定向URI,且后端用code换取access_token所传的参数redirect_uri应该为“postmessage”,否则会报错“redirect_uri_mismatch”。

3、code不能重复使用。

以下是直接可用的前端代码(popup模式):

GoogleLoginBtn.vue

<template>  <GoogleLogin    :client-id="googleClientId"    popup-type="CODE"    :callback="handleGoogleSuccess"    :error="handleGoogleError"  >    <img class="google-login-icon" src="@/assets/icons/google.png" alt="google-login">    <button v-if="false" class="google-login-button" type="button">      <span class="google-mark">G</span>      <span class="google-label">{{ buttonLabel }}</span>    </button>  </GoogleLogin></template><script setup lang="ts">import { ElMessage } from 'element-plus'import { GoogleLogin, type CallbackTypes } from 'vue3-google-login'import { useUserStore } from '@/stores/user'interface Props {  buttonLabel?: string}interface Emits {  (e: 'success'): void  (e: 'error'): void}withDefaults(defineProps<Props>(), {  buttonLabel: 'Google 账号快捷登录',})const emit = defineEmits<Emits>()const userStore = useUserStore()const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefinedconst handleGoogleSuccess = async (response: CallbackTypes.CodePopupResponse) => {  if (!response?.code) {    ElMessage.error('未获取到 Google 授权码')    emit('error')    return  }  const success = await userStore.userLoginByGoogleCode(response.code)  if (success) {    emit('success')    return  }  emit('error')}const handleGoogleError = (_error: unknown) => {  ElMessage.error('Google 授权失败,请重试')  emit('error')}</script><style scoped lang="scss">.google-login-button {  width: 176px;  height: 44px;  border: 1px solid #d9d9d9;  border-radius: 10px;  background: #fff;  display: flex;  align-items: center;  justify-content: center;  gap: 8px;  cursor: pointer;  transition: all 0.2s ease;}.google-login-button:hover {  border-color: #c7c7c7;  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);  transform: translateY(-1px);}.google-mark {  font-size: 18px;  font-weight: 700;  color: #ea4335;}.google-label {  font-size: 13px;  color: #333;  white-space: nowrap;}.google-login-icon {  width: 48px;  height: 48px;  cursor: pointer;}</style>

redirect模式,期间由于踩了上面提的popup模式的坑,也改过一版redirect模式,没有采用第三方依赖。

以下是直接可用的前端代码(redirect模式):

GoogleLoginBtn.vue

<template>  <button class="google-login-trigger" type="button" @click="handleGoogleLogin">    <img class="google-login-icon" src="@/assets/icons/google.png" alt="google-login" />    <span class="sr-only">{{ buttonLabel }}</span>  </button></template><script setup lang="ts">import { ElMessage } from 'element-plus'interface Props {  buttonLabel?: string}interface Emits {  (e: 'success'): void  (e: 'error'): void}withDefaults(defineProps<Props>(), {  buttonLabel: 'Google 账号快捷登录',})const emit = defineEmits<Emits>()const GOOGLE_STATE_KEY = 'google_oauth_state'const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefinedconst googleRedirectUri = (import.meta.env.VITE_GOOGLE_REDIRECT_URI as string | undefined)  || `${window.location.origin}/front/community/home`const createOAuthState = () => {  if (window.crypto?.randomUUID) {    return window.crypto.randomUUID()  }  return `${Date.now()}_${Math.random().toString(36).slice(2)}`}const handleGoogleLogin = () => {  if (!googleClientId) {    ElMessage.error('未配置 Google Client ID,无法登录')    emit('error')    return  }  const state = createOAuthState()  sessionStorage.setItem(GOOGLE_STATE_KEY, state)  const query = new URLSearchParams({    client_id: googleClientId,    redirect_uri: googleRedirectUri,    response_type: 'code',    scope: 'openid profile email',    state,  })  window.location.assign(`https://accounts.google.com/o/oauth2/v2/auth?${query.toString()}`)}</script><style scoped lang="scss">.google-login-trigger {  display: inline-flex;  align-items: center;  justify-content: center;  border: none;  background: transparent;  padding: 0;  cursor: pointer;}.google-login-icon {  width: 48px;  height: 48px;  cursor: pointer;}.sr-only {  position: absolute;  width: 1px;  height: 1px;  padding: 0;  margin: -1px;  overflow: hidden;  clip: rect(0, 0, 0, 0);  white-space: nowrap;  border: 0;}</style>

App.vue

<template>  <Layout :show-top-bar="showTopBar">    <router-view />  </Layout></template><script setup lang="ts">import Layout from './components/Layout.vue'import { useRoute, useRouter } from 'vue-router'import { computed, onMounted, nextTick, ref, watch } from 'vue'import { useI18n } from 'vue-i18n'import { ElMessage } from 'element-plus'import { useUserStore } from '@/stores/user'const route = useRoute()const router = useRouter()const userStore = useUserStore()const { t } = useI18n()const isProcessingGoogleOAuth = ref(false)const GOOGLE_STATE_KEY = 'google_oauth_state'const showTopBar = computed(() => {  const from = route.query.from as string  localStorage.setItem('from', from || 'community')  return from !== 'pc-home'})const getSingleQueryValue = (value: unknown) => {  if (Array.isArray(value)) {    return value[0] || ''  }  return typeof value === 'string' ? value : ''}const clearGoogleOAuthQuery = async () => {  const nextQuery = { ...route.query }  delete nextQuery.code  delete nextQuery.scope  delete nextQuery.authuser  delete nextQuery.prompt  delete nextQuery.state  delete nextQuery.error  delete nextQuery.error_description  await router.replace({    path: route.path,    query: nextQuery,  })}const processGoogleOAuthCallback = async () => {  const code = getSingleQueryValue(route.query.code)  const oauthError = getSingleQueryValue(route.query.error)  const incomingState = getSingleQueryValue(route.query.state)  if ((!code && !oauthError) || isProcessingGoogleOAuth.value) {    return  }  isProcessingGoogleOAuth.value = true  try {    if (oauthError) {      ElMessage.error(`Google OAuth failed: ${oauthError}`)      return    }    const expectedState = sessionStorage.getItem(GOOGLE_STATE_KEY)    sessionStorage.removeItem(GOOGLE_STATE_KEY)    if (expectedState && expectedState !== incomingState) {      ElMessage.error('Google OAuth state validation failed')      return    }    const success = await userStore.userLoginByGoogleCode(code)    if (success) {      ElMessage.success(t('auth.loginSuccess'))    }  } finally {    await clearGoogleOAuthQuery()    isProcessingGoogleOAuth.value = false  }}watch(  () => route.fullPath,  () => {    void processGoogleOAuthCallback()  },  { immediate: true })onMounted(async () => {  await nextTick()  const from = route.query.from as string  console.log('route.query.from:', from)})</script><style scoped>* {  margin: 0;  padding: 0;  box-sizing: border-box;}body {  font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;  line-height: 1.5;  color: #333;  background-color: #f5f5f5;}.content-placeholder {  text-align: center;  padding: 60px 20px;  color: #666;}.content-placeholder h1 {  font-size: 36px;  margin-bottom: 20px;  color: #333;}</style>

深拷贝:JavaScript 引用类型的完全复制之道

作者 yuki_uix
2026年3月14日 13:21

"能手写一个深拷贝吗?"

我立马想到的是 JSON.parse(JSON.stringify(obj))

但如果进一步追问:"如果对象有循环引用呢?如果有 Date、RegExp 呢?" 我才意识到,深拷贝远比我想象的复杂。

这篇文章是我重新梳理深拷贝的学习笔记,从最简单的递归开始,一步步处理各种边界情况。

引子:浅拷贝带来的 Bug

先来看一个真实场景下容易踩的坑:

// 环境:浏览器 / Node.js
// 场景:用户信息编辑

const originalUser = {
  name: 'Alice',
  profile: {
    age: 25,
    city: 'Shanghai'
  }
};

// 使用展开运算符"复制"对象
const editedUser = { ...originalUser };

// 修改嵌套属性
editedUser.profile.city = 'Beijing';

console.log(originalUser.profile.city); // "Beijing" - 糟糕,原对象也被修改了!
console.log(editedUser.profile.city);   // "Beijing"

这个 Bug 的根源在于:展开运算符和 Object.assign 都只做浅拷贝

它们只拷贝对象的第一层属性。

对于嵌套的对象,拷贝的只是 引用

引用类型 vs 基本类型的内存模型

要理解这个问题,需要先理解 JavaScript 的内存模型:

// 基本类型:直接存储值
let a = 10;
let b = a;  // 复制值
b = 20;
console.log(a); // 10 - a 不受影响

// 引用类型:存储的是内存地址
let obj1 = { count: 10 };
let obj2 = obj1; // 复制的是地址
obj2.count = 20;
console.log(obj1.count); // 20 - obj1 也被修改了,因为它们指向 **同一块内存**

浅拷贝只拷贝了第一层的引用,深拷贝则需要递归地复制所有嵌套层级,创建 全新的对象

深拷贝的核心挑战

实现深拷贝主要面临这几个挑战:

1. 递归思维:处理嵌套结构

深拷贝的核心是递归

  • 如果遇到对象,就递归地拷贝它的每个属性;
  • 如果遇到基本类型,直接复制。
graph TD
    A[开始拷贝对象] --> B{属性是基本类型?}
    B -->|是| C[直接复制值]
    B -->|否| D{属性是对象/数组?}
    D -->|是| E[递归拷贝该属性]
    D -->|否| F[处理特殊类型]
    E --> A
    C --> G[继续下一个属性]
    F --> G
    G --> H{还有属性?}
    H -->|是| B
    H -->|否| I[返回新对象]

2. 循环引用检测:避免爆栈

如果对象存在循环引用,朴素的递归会导致无限循环:

// 环境:浏览器 / Node.js
// 场景:循环引用的对象

const obj = { name: 'Alice' };
obj.self = obj; // 循环引用自己

// 如果直接递归拷贝,会发生什么?
// deepClone(obj) -> deepClone(obj.self) -> deepClone(obj.self.self) -> ...
// 无限递归,最终栈溢出!

3. 类型判断:不同类型需要不同处理

JavaScript 的引用类型五花八门:Object、Array、Date、RegExp、Map、Set、Function... 每种类型的拷贝方式都不同。

4. 特殊值处理:null、undefined、Symbol

这些特殊值需要特别小心处理,容易成为 Bug 的源头。

从简单到完整的实现路径

让我们从最简单的版本开始,逐步完善。

版本 1:JSON 方法的局限性

最快速的深拷贝方式是使用 JSON:

// 环境:浏览器 / Node.js
// 场景:JSON 深拷贝

const obj = {
  name: 'Alice',
  profile: {
    age: 25,
    hobbies: ['reading', 'coding']
  }
};

const cloned = JSON.parse(JSON.stringify(obj));

cloned.profile.age = 30;
console.log(obj.profile.age);    // 25 - 原对象未改变 ✅
console.log(cloned.profile.age); // 30

但这个方法有严重的局限性:

// 环境:浏览器 / Node.js
// 场景:JSON 方法的各种问题

const obj = {
  date: new Date(),
  regex: /test/g,
  func: () => console.log('hello'),
  undef: undefined,
  symbol: Symbol('key'),
  nan: NaN,
  infinity: Infinity
};

obj.self = obj; // 循环引用

// ❌ 会抛出错误:TypeError: Converting circular structure to JSON
// const cloned = JSON.parse(JSON.stringify(obj));

// 即使没有循环引用,也会丢失很多信息:
const safeObj = {
  date: new Date(),
  regex: /test/g,
  func: () => console.log('hello'),
  undef: undefined,
  nan: NaN
};

const cloned = JSON.parse(JSON.stringify(safeObj));

console.log(cloned);
// {
//   date: "2024-03-14T10:30:00.000Z", // 变成了字符串!
//   regex: {},                        // 变成了空对象!
//   nan: null                         // NaN 变成了 null!
//   // func 和 undef 直接消失了!
// }

JSON 方法的问题总结

  • ❌ 无法处理循环引用(会报错)
  • ❌ 函数会丢失
  • ❌ undefined 会丢失
  • ❌ Symbol 会丢失
  • ❌ Date 变成字符串
  • ❌ RegExp 变成空对象
  • ❌ NaN/Infinity 变成 null

所以,JSON 方法只适用于简单的、纯数据的对象。

版本 2:基础递归实现

// 环境:浏览器 / Node.js
// 场景:基础深拷贝,处理 object 和 array

function deepClone(target) {
  // 基本类型直接返回
  if (typeof target !== 'object' || target === null) {
    return target;
  }
  
  // 区分数组和对象
  const cloneTarget = Array.isArray(target) ? [] : {};
  
  // 递归拷贝每个属性
  for (let key in target) {
    if (target.hasOwnProperty(key)) { // 只拷贝自身属性,不拷贝原型链
      cloneTarget[key] = deepClone(target[key]);
    }
  }
  
  return cloneTarget;
}

// 测试
const obj = {
  name: 'Alice',
  age: 25,
  hobbies: ['reading', 'coding'],
  profile: {
    city: 'Shanghai',
    education: {
      degree: 'Bachelor',
      school: 'MIT'
    }
  }
};

const cloned = deepClone(obj);
cloned.profile.city = 'Beijing';

console.log(obj.profile.city);    // "Shanghai" - 原对象未改变 ✅
console.log(cloned.profile.city); // "Beijing"

这个版本已经能处理基本的嵌套对象和数组了,但还有两个致命问题:

  1. 无法处理循环引用
  2. 无法处理特殊类型(Date、RegExp 等)

版本 3:使用 WeakMap 解决循环引用

循环引用的核心思路是:记录已经拷贝过的对象,如果再次遇到,直接返回之前的拷贝结果。

// 环境:浏览器 / Node.js
// 场景:处理循环引用

function deepClone(target, map = new WeakMap()) {
  // 基本类型直接返回
  if (typeof target !== 'object' || target === null) {
    return target;
  }
  
  // 检查是否已经拷贝过
  if (map.has(target)) {
    return map.get(target); // 返回之前的拷贝结果,避免无限递归
  }
  
  // 区分数组和对象
  const cloneTarget = Array.isArray(target) ? [] : {};
  
  // 记录当前对象的拷贝结果
  map.set(target, cloneTarget);
  
  // 递归拷贝每个属性
  for (let key in target) {
    if (target.hasOwnProperty(key)) {
      cloneTarget[key] = deepClone(target[key], map);
    }
  }
  
  return cloneTarget;
}

// 测试循环引用
const obj = { name: 'Alice' };
obj.self = obj;            // 指向自己
obj.nested = { parent: obj }; // 嵌套的循环引用

const cloned = deepClone(obj);

console.log(cloned.self === cloned);            // true ✅
console.log(cloned.nested.parent === cloned);   // true ✅
console.log(cloned !== obj);                    // true - 是新对象

为什么用 WeakMap 而不是 Map?

// WeakMap vs Map 的区别

// Map:强引用,即使原对象被销毁,Map 中的引用仍然存在
const map = new Map();
let obj = { data: 'large object' };
map.set(obj, 'value');
obj = null; // obj 被销毁,但 map 中的引用仍然存在,造成内存泄漏

// WeakMap:弱引用,原对象销毁后,WeakMap 中的引用会自动清除
const weakMap = new WeakMap();
let obj2 = { data: 'large object' };
weakMap.set(obj2, 'value');
obj2 = null; // obj2 被销毁,weakMap 中的引用也会被垃圾回收

在深拷贝的场景中,map 只是临时用来检测循环引用的,拷贝完成后就不需要了。使用 WeakMap 可以让垃圾回收器自动清理,避免内存泄漏。

版本 4:处理特殊类型

现在我们来处理 Date、RegExp、Map、Set 等特殊类型:

// 环境:浏览器 / Node.js
// 场景:处理各种特殊类型

function deepClone(target, map = new WeakMap()) {
  // 基本类型直接返回
  if (typeof target !== 'object' || target === null) {
    return target;
  }
  
  // 检查循环引用
  if (map.has(target)) {
    return map.get(target);
  }
  
  // 获取对象的具体类型
  const type = Object.prototype.toString.call(target);
  let cloneTarget;
  
  // 根据类型选择拷贝策略
  switch (type) {
    case '[object Array]':
      cloneTarget = [];
      break;
    case '[object Object]':
      cloneTarget = {};
      break;
    case '[object Date]':
      return new Date(target); // Date 直接返回,不需要递归
    case '[object RegExp]':
      // 拷贝 RegExp 需要保留 flags
      return new RegExp(target.source, target.flags);
    case '[object Map]':
      cloneTarget = new Map();
      map.set(target, cloneTarget);
      target.forEach((value, key) => {
        cloneTarget.set(key, deepClone(value, map));
      });
      return cloneTarget;
    case '[object Set]':
      cloneTarget = new Set();
      map.set(target, cloneTarget);
      target.forEach(value => {
        cloneTarget.add(deepClone(value, map));
      });
      return cloneTarget;
    default:
      // 其他类型(Function, Symbol 等)直接返回
      return target;
  }
  
  // 记录当前对象
  map.set(target, cloneTarget);
  
  // 递归拷贝属性
  for (let key in target) {
    if (target.hasOwnProperty(key)) {
      cloneTarget[key] = deepClone(target[key], map);
    }
  }
  
  return cloneTarget;
}

// 测试特殊类型
const obj = {
  date: new Date('2024-03-14'),
  regex: /test/gi,
  map: new Map([['key1', 'value1'], ['key2', { nested: true }]]),
  set: new Set([1, 2, { value: 3 }]),
  arr: [1, 2, 3]
};

const cloned = deepClone(obj);

console.log(cloned.date instanceof Date);           // true ✅
console.log(cloned.date.getTime() === obj.date.getTime()); // true ✅
console.log(cloned.regex.source === obj.regex.source);     // true ✅
console.log(cloned.regex.flags === obj.regex.flags);       // true ✅
console.log(cloned.map.get('key2') !== obj.map.get('key2')); // true - 深拷贝 ✅
console.log(cloned !== obj);                        // true - 新对象 ✅

类型检测的关键:Object.prototype.toString

为什么要用 Object.prototype.toString.call(target) 而不是 typeof

// 环境:浏览器 / Node.js
// 场景:类型检测对比

const arr = [1, 2, 3];
const date = new Date();
const regex = /test/;

// typeof 无法区分对象类型
console.log(typeof arr);   // "object"
console.log(typeof date);  // "object"
console.log(typeof regex); // "object"

// Object.prototype.toString 可以精确区分
console.log(Object.prototype.toString.call(arr));   // "[object Array]"
console.log(Object.prototype.toString.call(date));  // "[object Date]"
console.log(Object.prototype.toString.call(regex)); // "[object RegExp]"
console.log(Object.prototype.toString.call(null));  // "[object Null]"

这是判断 JavaScript 类型最可靠的方式。

边界情况与陷阱

在实际使用中,还有一些容易忽略的边界情况。

1. Function 能深拷贝吗?

函数的拷贝比较特殊。一种观点是:函数不应该被拷贝,因为函数通常依赖外部作用域,拷贝后可能失去原有的上下文。

// 环境:浏览器 / Node.js
// 场景:函数的拷贝问题

const obj = {
  count: 0,
  increment: function() {
    this.count++; // 依赖 this 上下文
  }
};

// 如果拷贝函数,this 指向会改变吗?
const cloned = deepClone(obj);
cloned.increment();
console.log(cloned.count); // 1 - this 指向 cloned,正常工作 ✅

在我们的实现中,函数直接返回原引用,这是一种常见的做法。如果真的需要拷贝函数,可以用 new Functioneval,但通常不推荐。

2. Symbol 作为 key 的处理

对象的 key 可以是 Symbol,而 for...inObject.keys 都无法遍历 Symbol 属性:

// 环境:浏览器 / Node.js
// 场景:Symbol 属性的拷贝

function deepCloneWithSymbol(target, map = new WeakMap()) {
  if (typeof target !== 'object' || target === null) {
    return target;
  }
  
  if (map.has(target)) {
    return map.get(target);
  }
  
  const cloneTarget = Array.isArray(target) ? [] : {};
  map.set(target, cloneTarget);
  
  // 拷贝普通属性
  for (let key in target) {
    if (target.hasOwnProperty(key)) {
      cloneTarget[key] = deepCloneWithSymbol(target[key], map);
    }
  }
  
  // 拷贝 Symbol 属性
  const symbolKeys = Object.getOwnPropertySymbols(target);
  for (let key of symbolKeys) {
    cloneTarget[key] = deepCloneWithSymbol(target[key], map);
  }
  
  return cloneTarget;
}

// 测试
const sym = Symbol('key');
const obj = {
  normal: 'value',
  [sym]: 'symbol value'
};

const cloned = deepCloneWithSymbol(obj);
console.log(cloned[sym]); // "symbol value" ✅

3. 不可枚举属性的处理

默认情况下,for...in 只遍历可枚举属性。如果需要拷贝不可枚举属性,要用 Object.getOwnPropertyNames:

// 环境:浏览器 / Node.js
// 场景:不可枚举属性

const obj = {};
Object.defineProperty(obj, 'hidden', {
  value: 'secret',
  enumerable: false // 不可枚举
});

console.log(obj.hidden); // "secret"

// for...in 无法遍历
for (let key in obj) {
  console.log(key); // 不会打印 "hidden"
}

// 使用 Object.getOwnPropertyNames
const allKeys = Object.getOwnPropertyNames(obj);
console.log(allKeys); // ["hidden"]

不过在大多数场景下,不可枚举属性通常是内部属性,不需要拷贝。

4. getter/setter 的处理

如果对象的属性定义了 getter/setter,直接拷贝会丢失这些访问器:

// 环境:浏览器 / Node.js
// 场景:getter/setter 的拷贝

const obj = {
  _age: 25,
  get age() {
    console.log('Getting age');
    return this._age;
  },
  set age(value) {
    console.log('Setting age');
    this._age = value;
  }
};

// 普通拷贝会丢失 getter/setter
const cloned = deepClone(obj);
cloned.age = 30; // 不会触发 setter
console.log(cloned.age); // 不会触发 getter

// 正确的做法:使用 Object.getOwnPropertyDescriptors
const correctCloned = Object.create(
  Object.getPrototypeOf(obj),
  Object.getOwnPropertyDescriptors(obj)
);

这涉及到属性描述符(Property Descriptor)的概念,在深拷贝的复杂场景中需要考虑。

手写实现的关键点

面试时手写深拷贝,这些点是考察重点:

1. 递归终止条件

// ✅ 正确:基本类型和 null 都要终止递归
if (typeof target !== 'object' || target === null) {
  return target;
}

// ❌ 错误:忘记检查 null
if (typeof target !== 'object') {
  return target;
}
// typeof null === 'object',会继续递归,导致报错!

这是很容易忽略的细节,因为 typeof null === 'object' 是 JavaScript 的一个历史遗留问题。

2. 循环引用的检测时机

// ✅ 正确:在创建新对象前检查
if (map.has(target)) {
  return map.get(target);
}
const cloneTarget = Array.isArray(target) ? [] : {};
map.set(target, cloneTarget);

// ❌ 错误:在递归拷贝后才记录
const cloneTarget = Array.isArray(target) ? [] : {};
for (let key in target) {
  cloneTarget[key] = deepClone(target[key], map); // 如果这里遇到循环引用,已经晚了
}
map.set(target, cloneTarget);

必须在递归前就记录,否则遇到循环引用时,还是会无限递归。

3. WeakMap 的作用

  • WeakMap 的 key 必须是对象(符合我们的需求)
  • WeakMap 是弱引用,不会阻止垃圾回收
  • 拷贝完成后,map 会被自动清理,避免内存泄漏

4. 数组和对象的区分

// ✅ 推荐:使用 Array.isArray
const cloneTarget = Array.isArray(target) ? [] : {};

// ⚠️ 可用但不够优雅:使用 instanceof
const cloneTarget = target instanceof Array ? [] : {};

// ❌ 不推荐:使用 constructor
const cloneTarget = target.constructor === Array ? [] : {};

Array.isArray 是最可靠的方式,即使在不同 iframe 环境下也能正常工作。

性能与工程实践

在实际项目中,深拷贝的性能也是需要考虑的。

lodash 的 cloneDeep 实现思路

lodash 的 cloneDeep 是工业级实现的参考,它的核心思路:

  1. 使用栈(stack)代替递归,避免爆栈
  2. 缓存已拷贝对象(类似我们的 WeakMap)
  3. 针对不同类型使用优化的拷贝策略
  4. 处理大量边界情况(原型链、属性描述符等)
// 环境:Node.js / 浏览器(需要引入 lodash)
// 场景:使用 lodash 的 cloneDeep

import _ from 'lodash';

const obj = {
  date: new Date(),
  func: () => console.log('hello'),
  symbol: Symbol('key')
};

obj.self = obj;

const cloned = _.cloneDeep(obj);
console.log(cloned.self === cloned); // true ✅

什么时候不需要深拷贝(Immutable 思想)

在 React 等现代框架中,推荐使用不可变数据(Immutable Data):

// 环境:React
// 场景:不可变更新,替代深拷贝

// ❌ 不推荐:深拷贝整个对象
const newState = deepClone(state);
newState.user.name = 'Bob';

// ✅ 推荐:只拷贝需要修改的部分
const newState = {
  ...state,
  user: {
    ...state.user,
    name: 'Bob'
  }
};

这种方式更高效,也更符合函数式编程的思想。深拷贝应该只在确实需要"完全独立的副本"时使用。

一些有关 deepClone 的追问

Q1: 深拷贝和浅拷贝的区别?

我的理解:

  • 浅拷贝只复制第一层属性,嵌套对象复制的是引用
  • 深拷贝递归复制所有层级,创建完全独立的副本
  • 浅拷贝: 展开运算符、Object.assign
  • 深拷贝: 递归实现、JSON 方法(有限制)、structuredClone

Q2: 如何检测循环引用?

使用 Map 或 WeakMap 记录已经拷贝过的对象。如果再次遇到,直接返回之前的拷贝结果,而不是继续递归。

Q3: 为什么不推荐用 JSON 方法做深拷贝?

JSON 方法的限制太多:

  • 无法处理循环引用(报错)
  • 函数、undefined、Symbol 会丢失
  • Date 变成字符串
  • RegExp 变成空对象
  • NaN/Infinity 变成 null

只适用于纯数据对象。

Q4: 在 React 状态管理中的最佳实践?

不推荐深拷贝整个 state,而是:

  • 使用展开运算符做浅拷贝
  • 只拷贝需要修改的部分
  • 保持数据不可变(Immutable)
  • 考虑使用 Immer 等库简化不可变更新

小结

深拷贝看似简单,实际上涉及递归、类型判断、循环引用检测、内存管理等多个知识点。手写深拷贝的核心是:

  1. 递归思维: 基本类型直接返回,对象类型递归拷贝
  2. 循环引用检测: 使用 WeakMap 记录已拷贝对象
  3. 类型判断: 用 Object.prototype.toString 精确区分类型
  4. 特殊类型处理: Date、RegExp、Map、Set 需要特殊构造

面试时,除了能写出代码,更重要的是能解释清楚:

  • 为什么要用 WeakMap?(弱引用,避免内存泄漏)
  • 为什么要先记录再递归?(避免循环引用)
  • 如何区分数组和对象?(Array.isArray)
  • 什么情况下不需要深拷贝?(不可变数据更新)

这篇文章是我准备面试时的思考过程,可能有理解不到位的地方。实际项目中,我会使用 lodash 的 cloneDeep,而不是手写实现。但理解原理对于调试问题和优化性能仍然很重要。

参考资料

Service Worker + stale-while-revalidate:让页面"假装"秒开的正经方案

2026年3月14日 13:04

Service Worker + stale-while-revalidate:让页面"假装"秒开的正经方案

你肯定遇到过这种场景:用户打开一个列表页,接口响应要 800ms,白屏晃一下,数据才出来。产品跑过来说"能不能秒开"。

秒开?服务端又不是你家的,CDN 也不归你管。但有一件事你能控制——上次请求的数据还躺在缓存里,为什么不先拿出来顶上?

这就是 stale-while-revalidate 的大致思路。先给用户看"旧的",后台悄悄拿"新的",拿到了再换上去。HTTP 协议本身支持这个策略,但浏览器实现得比较保守,真正好用的版本得靠 Service Worker 自己搞。


先搞清楚 HTTP 层的 stale-while-revalidate

Cache-Control 有个不太常用的指令:

Cache-Control: max-age=60, stale-while-revalidate=300

意思是:60 秒内直接用缓存,过期后的 300 秒内,先返回旧缓存,同时后台去 revalidate。超过 360 秒才真正过期。

听着挺完美,但实际用起来有几个问题:

  • 浏览器支持参差不齐,Safari 到 2024 年才补上
  • 只对 GET 请求生效,POST 的接口没戏
  • 你控制不了"拿到新数据后做什么"——浏览器默默更新缓存,但当前页面的 UI 不会变
  • 服务端不一定愿意配这个 header,后端同事可能觉得你在搞事

所以 HTTP 层的 SWR 更像个"被动优化"。你想精确控制缓存策略、想在新数据到了之后更新页面、想针对特定接口做差异化处理——得上 Service Worker。

Service Worker 里手搓 stale-while-revalidate

核心逻辑其实不复杂,伪代码就这么几行:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      // 不管有没有缓存,都发一次真实请求
      const fetching = fetch(event.request).then((response) => {
        // 拿到新响应,更新缓存
        const clone = response.clone()
        caches.open('api-cache').then((cache) => {
          cache.put(event.request, clone)
        })
        return response
      })

      // 有缓存?先返回缓存。没有?等网络请求
      return cached || fetching
    })
  )
})

看着简单,但这段代码有个很大的问题:网络请求的结果拿到了,怎么通知页面?

缓存返回 + 增量通知:真正能用的版本

解决办法是把"通知页面更新"这件事,从 response 层面提到消息通信层面。

// sw.js
const SWR_URLS = ['/api/feed', '/api/user/profile', '/api/config']

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)

  // 只对特定接口做 SWR,别全局开,会出事
  if (!SWR_URLS.some((p) => url.pathname.startsWith(p))) return

  event.respondWith(handleSWR(event.request))
})

async function handleSWR(request) {
  const cache = await caches.open('api-swr')
  const cached = await cache.match(request)

  // 后台发请求,不阻塞返回
  const networkPromise = fetch(request)
    .then(async (response) => {
      if (response.ok) {
        await cache.put(request, response.clone())

        // 拿到新数据了,通知所有页面
        const data = await response.clone().json()
        const clients = await self.clients.matchAll()
        clients.forEach((client) => {
          client.postMessage({
            type: 'SWR_UPDATE',
            url: request.url,
            data,
          })
        })
      }
      return response
    })
    .catch(() => cached) // 网络挂了,还是用缓存兜底

  // 有缓存就先返回,没有就等网络
  return cached || networkPromise
}

页面那边监听消息:

// main.js
navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data.type === 'SWR_UPDATE') {
    const { url, data } = event.data

    // 根据 url 判断要更新哪块 UI
    if (url.includes('/api/feed')) {
      store.commit('updateFeed', data) // Vue 的写法
      // 或者 dispatch 一个 action,看你项目怎么组织
    }
  }
})

哪些接口适合做 SWR,哪些别碰

不是所有接口都该走这套逻辑。分两类:

适合的:

  • 列表型数据(文章列表、商品列表)——旧数据和新数据差异通常不大
  • 配置型接口(用户设置、功能开关)——变更频率低
  • 个人信息(头像、昵称)——就算展示了旧的,几百毫秒后更新也没人在意

说到别碰的:

  • 余额、库存、价格,展示旧数据可能导致用户决策错误
  • 验证码、token 相关——用缓存数据直接出问题
  • 实时性要求高的(聊天消息、通知数)——stale 数据体验更差

之前在项目里犯过一次错,把订单状态接口也加了 SWR,用户付完款回来,看到的还是"待支付",慌了,又点了一次支付。虽然后端做了幂等,但客诉还是来了,后来老老实实把这类接口从 SWR 列表里摘出去了。

缓存版本控制:不处理迟早翻车

纯粹的 cache.put 有个隐患:接口返回的数据结构变了怎么办?

比如 v1 返回 { list: [...] },v2 改成了 { items: [...], total: 100 }。用户本地缓存的还是 v1 的结构,页面代码已经按 v2 写了,直接报错。

解决思路是给缓存加版本:

const CACHE_VERSION = 'api-swr-v3'

// 激活时清理旧版本缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => key.startsWith('api-swr-') && key !== CACHE_VERSION)
          .map((key) => caches.delete(key))
      )
    )
  )
})

每次前端发版,如果接口结构有 breaking change,把 CACHE_VERSION 改一下就行。SW 更新后会触发 activate,旧缓存自动清掉。

不过这里有个时间差的问题——SW 更新不是即时的。用户可能在旧 SW 还在跑的时候就加载了新页面代码。这种情况下要么在页面侧做防御性判断,要么用 skipWaiting() 强制接管(但 skipWaiting 也有自己的坑,后面说)。

skipWaiting 的取舍

self.addEventListener('install', () => {
  self.skipWaiting() // 装完直接激活,不等旧 SW 退出
})

好处很明显:新 SW 立刻生效,缓存版本立刻切换。

坏处:如果用户当前页面正在用旧 SW 处理请求,突然 SW 换了,可能出现一半请求走旧逻辑、一半走新逻辑的情况。

我的做法是:SWR 缓存场景下用 skipWaiting,但在页面侧监听 controllerchange 事件,检测到 SW 切换后自动刷新一次

let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (refreshing) return
  refreshing = true
  window.location.reload()
})

和 HTTP 缓存的关系:别打架

这块容易搞混。Service Worker 里的 fetch() 也是会走浏览器 HTTP 缓存的。如果服务端给接口加了 Cache-Control: max-age=300,那 SW 里 fetch(request) 拿到的可能也是 HTTP 缓存里的旧响应,根本不是最新的。

两层缓存叠在一起,你以为在 revalidate,其实在自己跟自己玩。

解法:SW 里发请求时强制跳过 HTTP 缓存。

const networkPromise = fetch(request, {
  cache: 'no-cache', // 跳过 HTTP 缓存,但仍然会写入缓存
  // 或者用 'reload',完全不读也不写 HTTP 缓存
})

no-cacheno-store 的区别:

// no-cache:发请求时不用 HTTP 缓存,但响应可以被缓存
// → 适合 SWR 场景,你想让 SW 层管缓存,HTTP 层别插手

// no-store:完全不缓存
// → 太激进了,连浏览器的 back/forward cache 都会受影响

监控:怎么知道 SWR 在正常工作

上线之后你怎么知道 SWR 真的在生效?不能全凭体感。

加几个埋点:

async function handleSWR(request) {
  const cache = await caches.open(CACHE_VERSION)
  const cached = await cache.match(request)

  const startTime = performance.now()

  const networkPromise = fetch(request, { cache: 'no-cache' })
    .then(async (response) => {
      const networkTime = performance.now() - startTime

      // 上报:网络请求耗时 + 是否命中了缓存
      reportMetrics({
        url: request.url,
        cacheHit: !!cached,
        networkTime,
        // 如果命中缓存,用户实际看到的耗时约等于 0
        perceivedTime: cached ? 0 : networkTime,
      })

      if (response.ok) {
        await cache.put(request, response.clone())
        await notifyClients(request.url, response.clone())
      }
      return response
    })

  return cached || networkPromise
}

重点看两个指标:

  • 缓存命中率:低于 60% 说明你的缓存策略有问题,可能是缓存被清得太频繁
  • perceived time(感知耗时):命中缓存时应该趋近于 0,这个才是用户体感

之前在一个项目里上了 SWR,缓存命中率能到 85% 左右。首屏的接口数据展示时间从平均 600ms 降到了接近 0(缓存命中的情况下),整体 P90 也从 1.2s 降到了 400ms。效果还是挺明显的。

容易忽略的边界情况

几个上线后才会遇到的问题:

1. Cache Storage 空间有限

浏览器对 Cache Storage 有配额限制(通常是可用磁盘的一定比例)。如果你缓存的接口太多,旧的缓存可能被浏览器自动清理。可以主动做 LRU:

async function trimCache(cacheName, maxEntries) {
  const cache = await caches.open(cacheName)
  const keys = await cache.keys()
  if (keys.length > maxEntries) {
    // 删掉最早的几条
    await Promise.all(
      keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key))
    )
  }
}

2. 用户长时间不打开页面

缓存的数据可能已经非常旧了。可以在缓存时写入时间戳,读取时判断是否超过了一个"最大容忍过期时间"。

async function putWithTimestamp(cache, request, response) {
  const headers = new Headers(response.headers)
  headers.set('X-SW-Cached-At', Date.now().toString())
  const timestamped = new Response(response.body, {
    status: response.status,
    headers,
  })
  await cache.put(request, timestamped)
}

async function getCachedIfFresh(cache, request, maxAge = 86400000) {
  const cached = await cache.match(request)
  if (!cached) return null

  const cachedAt = Number(cached.headers.get('X-SW-Cached-At') || 0)
  if (Date.now() - cachedAt > maxAge) {
    await cache.delete(request) // 过期太久,直接丢掉
    return null
  }
  return cached
}

24 小时没打开过的页面,就别拿旧缓存糊弄用户了,老老实实等网络请求。

3. 多 tab 场景

self.clients.matchAll() 会拿到所有 tab。如果用户开了同一个页面的多个 tab,每个 tab 都会收到 SWR_UPDATE 消息。这其实是个好事——所有 tab 数据保持同步。但要注意消息处理的幂等性,别重复触发副作用。


聊到这

stale-while-revalidate 不是什么新概念,HTTP 规范里早就有了。

这套方案的本质是一种乐观 UI 策略:先假设数据没怎么变,给用户看旧的,后台验证。跟 React 的 useOptimistic 和 SWR 库(对,swr 这个 npm 包名就是从这来的)的思路一脉相承。

但也别滥用。不是每个接口都值得缓存,不是每个场景都能容忍 stale data。用户感知不到延迟的地方,别加这套复杂度。 Service Worker 本身就是个不太好调试的东西,再叠一层缓存策略,出了问题排查起来会比较痛苦。

值不值得上,看你的场景。首屏有 3 个以上慢接口、用户会频繁重复访问同一个页面、数据时效性要求不是特别高——满足这三条,可以考虑。

给 Claude Code 造个趁手的 MCP Tool Server,聊聊我踩的那些坑

2026年3月14日 13:04

给 Claude Code 造个趁手的 MCP Tool Server,聊聊我踩的那些坑

搞前端工具链搞了好几年,组件库、设计稿、代码模板这些东西散落在各个系统里。每次新需求来了,得先翻组件库找有没有现成的,再去 Figma 看设计稿,最后手动糊代码。这套流程重复了几百次之后,终于忍不了了——能不能让 AI 帮我把这几步串起来?

一、Tool Schema 定义:看着简单,但你大概率会在这翻车

这块我花的时间最多,也是最想吐槽的部分。

MCP 的 Tool 定义看起来跟写个 JSON Schema 一样——给工具起个名字,声明入参类型,完事。但实际跑起来你就会发现,Schema 写得好不好,直接决定了 LLM 能不能正确调用你的工具。这不是"能用就行"的问题,是"差一点就完全不可用"的问题。

参数命名比你想的重要十倍

先说个真实场景。我做了个组件检索工具,第一版 Schema 长这样:

// 组件检索工具 —— 第一版,能跑但 LLM 经常调错
const tool = {
  name: "search_components",
  description: "搜索组件库中的组件",
  inputSchema: {
    type: "object",
    properties: {
      q: { type: "string", description: "搜索关键词" },
      t: { type: "string", enum: ["ui", "biz", "chart"] },
      limit: { type: "number" }
    },
    required: ["q"]
  }
}

跑了几次发现 Claude 经常不传 t 参数,或者把 q 理解错。改成下面这样之后命中率直接从六成拉到九成以上:

const tool = {
  name: "search_ui_components",
  description: "在团队组件库中按名称或用途搜索可复用的 UI/业务组件,返回组件名、Props 定义和使用示例",
  inputSchema: {
    type: "object",
    properties: {
      keyword: {
        type: "string",
        description: "组件名称或使用场景,比如 'Table'、'用户选择器'、'数据筛选'"
      },
      category: {
        type: "string",
        enum: ["ui-base", "business", "chart"],
        description: "组件分类:ui-base=基础UI组件, business=业务组件, chart=图表组件"
      },
      max_results: {
        type: "number",
        description: "最多返回几个结果,默认5"
      }
    },
    required: ["keyword"]
  }
}

区别在哪?三个地方:工具名自带语义search_componentssearch_ui_components),参数名是人话qkeyword),description 里给了具体例子

这不是什么高深的道理,但你不踩一遍坑真的意识不到——LLM 理解你工具的唯一信息源就是 Schema 里的文本。你偷懒少写一个 description,它就得靠猜,猜错的概率远比你想的高。

嵌套参数的深度控制

还有个坑是参数结构太深。一开始我想着把过滤条件做得灵活一点:

// 伪代码,别照抄
inputSchema: {
  filter: {
    platform: { os: string, version: string },
    style: { theme: string, size: enum },
    compatibility: { frameworks: string[], browsers: string[] }
  }
}

三层嵌套,参数十几个。结果 LLM 基本上构造不出正确的调用——它倒是能理解每个字段的意思,但组装成完整的嵌套 JSON 时总会漏字段或者层级搞错。

后来拍扁成一层:

// 全部拍平,宁可参数多一点也不要嵌套
inputSchema: {
  type: "object",
  properties: {
    keyword: { type: "string" },
    platform: { type: "string", enum: ["web", "mobile", "desktop"] },
    theme: { type: "string", enum: ["light", "dark"] },
    framework: { type: "string", enum: ["react", "vue", "angular"] },
    max_results: { type: "number" }
  },
  required: ["keyword"]
}

经验就一句话:Schema 嵌套不超过一层,参数不超过 6-7 个。超了就拆成多个工具。你可能觉得"一个工具能干的事为什么要拆成三个",但对 LLM 来说,三个简单工具比一个复杂工具好使得多。

多工具协作时的命名空间问题

项目里最终搞了七八个工具:搜组件、查设计稿、生成代码、查 API 文档……工具一多,命名冲突和语义模糊的问题就来了。

比如 search_componentssearch_docs 都有个 keyword 参数,但前者期望的是组件名,后者期望的是 API 名。LLM 有时候会搞混到底该调哪个。

招很粗暴——给工具名加前缀:comp_searchdoc_searchfigma_parsecode_gen

这块我还没想透的是,当工具数量超过 15 个以后,LLM 的工具选择准确率是不是会断崖式下降。目前我控制在 10 个以内没出过问题,但如果以后要接更多系统进来,可能得做工具的动态加载——根据当前对话上下文只暴露相关的工具子集。先放着,等真到那一步再说。

二、Claude Code 集成:没你想的那么复杂

配置 MCP Server 接入 Claude Code 这步反而没什么好说的,比想象中顺滑。

在项目根目录的 .mcp.json 里声明一下就行:

{
  "mcpServers": {
    "frontend-toolkit": {
      "command": "node",
      "args": ["./mcp-server/index.mjs"],
      "env": {
        "COMPONENT_LIB_PATH": "./src/components",
        "FIGMA_TOKEN": "${FIGMA_TOKEN}"
      }
    }
  }
}

Server 端用 @modelcontextprotocol/sdk 起一个 stdio 类型的服务就行。真正要注意的只有一件事:错误处理必须返回结构化信息,不能直接 throw

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  try {
    const result = await handleTool(request.params)
    return { content: [{ type: "text", text: JSON.stringify(result) }] }
  } catch (e) {
    // 不要 throw,返回 isError
    // 不然 Claude Code 会直接断开连接,你连错误信息都看不到
    return {
      content: [{ type: "text", text: `工具执行失败: ${e.message}` }],
      isError: true
    }
  }
})

三、组件检索这个场景值得单独说说

组件检索看着是最简单的功能——不就是个搜索嘛。但要做到 LLM 能真正用起来,返回的数据格式得反复调。

关键发现:返回给 LLM 的组件信息,多了不行少了也不行

一开始我把组件的完整源码都返回了,结果 context 直接爆掉。后来只返回组件名和一句话描述,又太少了,LLM 没法判断这个组件到底能不能用。

最后稳定下来的格式大概是这样——组件名、Props 类型定义(只保留 public 的)、一个最简使用示例、适用场景的一句话说明。差不多 30-50 行的信息量,刚好够 LLM 判断要不要用、怎么用。

四、工作流编排:别一上来就想做大做全

最后说说怎么把组件检索、设计稿解析、代码生成串成一条工作流。

一个典型场景:产品丢过来一个 Figma 链接,说"照这个做"。理想的流程是——解析设计稿拿到结构 → 匹配已有组件 → 生成代码。听着挺丝滑,但实际编排的时候有个根本性的取舍要做。

自动编排 vs 人工确认

第一种思路是全自动:在 MCP Server 内部把三步串起来,对外只暴露一个 figma_to_code 工具,一步到位。

第二种思路是拆开:暴露 figma_parsecomp_searchcode_gen 三个独立工具,让 Claude 自己决定调用顺序,每一步人都能看到中间结果。

我最开始选了第一种,因为显然更"优雅"。跑了一周之后切回了第二种。

原因很现实——全自动流程一旦中间某步出错(比如设计稿里有个自定义图标组件库里没有),整条链路就废了,返回一个笼统的错误信息,你还得去翻日志看是哪步挂的。拆开之后,Claude 调完 figma_parse 会先把结构展示出来,你扫一眼说"这几个组件用现有的,那个图标先跳过",它再去调 comp_search,灵活得多。

这个取舍背后的道理其实很通用:

工具编排的粒度选择:

粗粒度(一个工具做完所有事)
  优点:调用简单,LLM 决策少
  缺点:中间过程不可见,出错难排查,灵活性差

细粒度(每步一个工具)
  优点:中间结果可检查,人能介入,组合灵活
  缺点:LLM 需要自己编排调用顺序,偶尔会走弯路

实际选择:先细后粗
  先用细粒度工具跑通流程
  等流程稳定了,再把高频组合包装成粗粒度工具
  两套并存,简单场景用粗的,复杂场景用细的

返回值设计影响下一步决策

还有个容易忽略的细节——上一个工具的返回值格式,直接影响 LLM 下一步能不能做出正确决策。

figma_parse 返回的设计稿结构里,我专门加了一个 suggestedComponent 字段:

// figma_parse 返回的结构(简化版)
{
  layers: [
    {
      name: "顶部导航",
      type: "frame",
      suggestedComponent: "NavBar",  // 这个字段是给 LLM 看的提示
      children: [...]
    },
    {
      name: "数据表格",
      type: "frame",
      suggestedComponent: "DataTable",
      props: { columns: 5, hasFilter: true }
    }
  ]
}

这个 suggestedComponent 不是 Figma 原生的,是我在解析层做的一层映射——根据图层命名和结构特征猜一个可能的组件名。猜对了 LLM 直接拿去搜,猜错了也没关系,LLM 会根据搜索结果自行调整。

但如果不加这个字段,LLM 就得自己从图层名"顶部导航"推断出应该搜"NavBar",这步推断的准确率大概七成,加了字段之后变成九成。一个小字段,效果差很多。

这让我意识到一件事:设计 MCP 工具的时候,不能只想"这个工具给人用该返回什么",得想"给 LLM 用该返回什么"。有时候需要多返回一些冗余信息,专门用来降低 LLM 的推理难度。这跟传统 API 设计的"返回最少够用的信息"正好相反。

说到底,MCP Tool Server 就是给 AI 用的 API。但"给 AI 用"和"给人用"的设计直觉差异比想象中大——Schema 要更语义化,参数要更扁平,返回值要更冗余,错误信息要更具体。把这几条刻进脑子里,剩下的都是体力活。

防抖(Debounce):从用户体验到手写实现

作者 yuki_uix
2026年3月14日 13:02

准备面试的时候,我发现自己虽然用过 lodash 的 debounce,但如果要手写实现,脑子里却一片空白。

为什么需要延迟?为什么要用闭包?立即执行模式是怎么回事?带着这些疑问,我决定从头梳理一遍防抖的原理。这篇文章是我的学习笔记,希望能帮对 debounce 好奇的你建立清晰的思路。

问题的起源:一次性能优化的思考

防抖(Debounce)这个概念最初来自硬件领域,用来解决按钮的机械抖动问题。在前端开发中,我们遇到的其实是类似的场景:用户的连续操作可能触发大量不必要的事件处理。

假设你在做一个搜索框的实时联想功能。用户输入 "react hooks" 这 11 个字符,如果每次按键都发送一次请求,那就是 11 次网络调用。但实际上,用户真正想搜索的是完整的 "react hooks",前面 10 次请求都是无意义的。

这就是防抖要解决的核心问题:在用户频繁操作时,只响应最后一次有效操作

// 环境:浏览器
// 场景:搜索框输入,没有防抖的情况

const searchInput = document.querySelector('#search');

searchInput.addEventListener('input', (e) => {
  // 假设这是一个网络请求
  console.log('发送请求:', e.target.value);
  // fetch(`/api/search?q=${e.target.value}`)
});

// 用户输入 "react" 时,会触发 5 次 console.log
// "r" -> "re" -> "rea" -> "reac" -> "react"

核心原理:时间轴上的延迟与取消

防抖的实现本质上就两个关键动作:

  1. 延迟执行:不立即执行函数,而是等待一段时间
  2. 取消重复:如果在等待期间又触发了事件,取消之前的等待,重新开始计时

我们可以用一个时间轴来理解这个过程:

graph LR
    A[第1次输入] -->|开始等待300ms| B[等待中]
    C[第2次输入<br/>100ms后] -->|取消前一次<br/>重新等待300ms| D[等待中]
    E[第3次输入<br/>150ms后] -->|取消前一次<br/>重新等待300ms| F[等待中]
    F -->|300ms后<br/>无新输入| G[执行函数]

用人话说就是:

给我 300 毫秒的缓冲时间,如果这段时间内你又触发了事件,我就重置计时器;

如果 300 毫秒内你没再触发,我就执行函数。

定时器的作用:clearTimeout 实现"取消"

JavaScript 的 setTimeout 返回一个定时器 ID,我们可以用 clearTimeout 来取消这个定时器。

这就是防抖"取消重复"的核心机制。

// 环境:浏览器 / Node.js
// 场景:理解 clearTimeout 的作用

let timer = setTimeout(() => {
  console.log('这句话不会被打印');
}, 1000);

// 在定时器触发前取消它
clearTimeout(timer);

// 重新设置一个新的定时器
timer = setTimeout(() => {
  console.log('这句话会在 1 秒后打印');
}, 1000);

闭包的必要性:保存 timer 状态

为了在多次调用之间保持 timer 的引用,我们需要用闭包。这是很多人理解防抖时的第一个难点。

// 环境:浏览器
// 场景:为什么需要闭包

// ❌ 错误的实现:每次调用都是新的 timer
function wrongDebounce(func, delay) {
  return (..arg) => {
      let timer; // 这个 timer 在每次调用 wrongDebounce 时都会重新创建
      if(timer) clearTimeout(timer);
      timer = setTimeout(() => {
          func.apply(this,args);
      }, delay);
  };
}

// ✅ 正确的实现:返回一个函数,形成闭包
function correctDebounce(func, delay) {
  let timer; // 这个 timer 被闭包捕获,在多次调用间保持引用
  
  return (...args) => {
    // 取消上一个定时器
    clearTimeout(timer);
    // 重新设置定时器
    timer = setTimeout(() => {
      func(...args);
    }, delay);
  };
}

// 使用示例
const debouncedSearch = correctDebounce(() => {
  console.log('执行搜索');
}, 300);

debouncedSearch(); // 第 1 次调用,设置定时器
debouncedSearch(); // 第 2 次调用,取消上一个定时器,重新设置
debouncedSearch(); // 第 3 次调用,取消上一个定时器,重新设置
// 只有最后一次会在 300ms 后执行

闭包让 timer 变量成为了一个"私有状态",所有通过 correctDebounce 返回的函数都共享这个状态。

闭包让 debounce 能够"记住"之前设置的定时器,从而在新的调用时取消它,实现"延迟执行,只执行最后一次"的效果。

从最简实现到完整版本

让我们一步步构建防抖函数,每个版本解决一个具体的问题。

版本 1:最基础的防抖(核心逻辑)

// 环境:浏览器 / Node.js
// 场景:最简单的防抖实现
// 功能:延迟执行,取消重复

function debounce(func, delay) {
  let timer = null;
  
  return function() {
    // 如果已经有定时器在等待,取消它
    clearTimeout(timer);
    
    // 设置新的定时器
    timer = setTimeout(() => {
      func();
    }, delay);
  };
}

// 测试
const log = debounce(() => console.log('executed'), 300);

log(); // 不会执行
log(); // 不会执行
log(); // 300ms 后执行

这个版本虽然简单,但已经实现了防抖的核心思想。不过它有两个明显的问题:

  1. 没有处理函数的参数
  2. 没有处理 this 绑定

版本 2:处理参数和 this 绑定

// 环境:浏览器 / Node.js
// 场景:处理参数传递和 this 上下文

function debounce(func, delay) {
  let timer = null;
  
  return function(...args) { // 使用剩余参数收集所有参数
    const context = this; // 保存调用时的 this
    
    clearTimeout(timer);
    
    timer = setTimeout(() => {
      // 使用 apply 调用,传入正确的 this 和参数
      func.apply(context, args);
    }, delay);
  };
}

// 测试:在对象方法中使用
const searchBox = {
  keyword: '',
  
  search: debounce(function(value) {
    this.keyword = value; // this 应该指向 searchBox
    console.log('搜索:', this.keyword);
  }, 300)
};

searchBox.search('react'); // 300ms 后打印 "搜索: react"

这里有两个容易混淆的点:

  • 为什么要保存 this?因为 setTimeout 内部的箭头函数会捕获外层的 this,如果不保存,当 setTimeout 执行时,this 可能已经指向别的对象了。
  • 为什么用 apply 而不是直接调用?因为我们需要同时传入 this 上下文和参数数组。

版本 3:支持立即执行(leading edge)

有些场景下,我们希望第一次触发时立即执行,而不是延迟。比如提交表单按钮,用户第一次点击应该立即响应,后续的连续点击才需要防抖。

// 环境:浏览器 / Node.js
// 场景:支持立即执行模式
// 参数:immediate 为 true 时,第一次触发立即执行

function debounce(func, delay, immediate = false) {
  let timer = null;
  
  return function(...args) {
    const context = this;
    const callNow = immediate && !timer; // 立即执行的条件
    
    clearTimeout(timer);
    
    timer = setTimeout(() => {
      timer = null; // 重要:重置 timer,为下次立即执行做准备
      if (!immediate) {
        func.apply(context, args);
      }
    }, delay);
    
    // 如果是立即执行模式,且当前没有等待中的定时器,立即调用
    if (callNow) {
      func.apply(context, args);
    }
  };
}

// 测试:提交按钮防抖
const submitButton = document.querySelector('#submit');
const handleSubmit = debounce(function() {
  console.log('提交表单');
}, 1000, true); // immediate = true

submitButton.addEventListener('click', handleSubmit);

// 用户连续点击 3 次:
// 第 1 次:立即执行 console.log
// 第 2 次:不执行(在 1 秒内)
// 第 3 次:不执行(在 1 秒内)
// 1 秒后:timer 被重置,下次点击又会立即执行

立即执行模式的关键在于:

  • callNow 的判断:immediate && !timer 表示"开启了立即执行 且 当前没有等待中的定时器"
  • timer = null 的重置:延迟结束后,把 timer 重置为 null,这样下次触发时 !timer 才为 true,才能再次立即执行

版本 4: 添加取消功能

有时候我们需要手动取消防抖,比如用户切换了页面,之前设置的防抖操作应该被取消。

// 环境:浏览器 / Node.js
// 场景:可手动取消的防抖
// 功能:返回的函数附带 cancel 方法

function debounce(func, delay, immediate = false) {
  let timer = null;
  
  const debounced = function(...args) {
    const context = this;
    const callNow = immediate && !timer;
    
    clearTimeout(timer);
    
    timer = setTimeout(() => {
      timer = null;
      if (!immediate) {
        func.apply(context, args);
      }
    }, delay);
    
    if (callNow) {
      func.apply(context, args);
    }
  };
  
  // 添加 cancel 方法
  debounced.cancel = function() {
    clearTimeout(timer);
    timer = null;
  };
  
  return debounced;
}

// 测试:页面跳转时取消防抖
const autoSave = debounce(() => {
  console.log('自动保存草稿');
}, 2000);

// 用户编辑内容
document.addEventListener('input', autoSave);

// 用户点击"退出"按钮时,取消自动保存
document.querySelector('#exit').addEventListener('click', () => {
  autoSave.cancel();
  console.log('已取消自动保存');
});

手写实现的关键点

在面试中手写防抖,有几个容易踩的坑:

1. 闭包陷阱:为什么要用 apply

// ❌ 错误:直接调用会丢失 this 和参数
timer = setTimeout(() => {
  func(); // this 是 undefined,参数也丢失了
}, delay);

// ✅ 正确:使用 apply 传入 context 和 args
timer = setTimeout(() => {
  func.apply(context, args);
}, delay);

有人会问:为什么不用 func.call(context, ...args) 或者 func.bind(context)(...args)

  • call 也可以,但 apply 接受数组参数,写起来更简洁
  • bind 会创建新函数,性能略差,虽然在这个场景下影响不大

2. this 指向问题的常见错误

// ❌ 错误:使用箭头函数定义返回的函数
function debounce(func, delay) {
  let timer = null;
  
  // 箭头函数的 this 是词法作用域,不能被 apply/call 改变
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(this, args); // 这里的 this 不是调用时的 this!
    }, delay);
  };
}

// ✅ 正确:使用普通函数
return function(...args) {
  const context = this; // 这里的 this 才是调用时的 this
  // ...
};

3. 立即执行的边界条件

立即执行模式最容易写错的是 callNow 的判断逻辑:

// ❌ 错误:只判断 immediate
const callNow = immediate; // 这样每次都会立即执行!

// ✅ 正确:immediate && !timer
const callNow = immediate && !timer; // 只在第一次或延迟结束后立即执行

而且别忘了在 setTimeout 回调中重置 timer = null,否则第二轮的立即执行永远不会触发。

实战应用场景

理解了原理后,我们来看看防抖在实际开发中的几个典型场景。

场景 1:搜索框实时联想

// 环境:React 组件
// 场景:搜索框输入防抖
// 依赖:React, fetch API

import { useState, useCallback } from 'react';

function SearchBox() {
  const [suggestions, setSuggestions] = useState([]);
  
  // 使用 useCallback 配合防抖
  const fetchSuggestions = useCallback(
    debounce(async (keyword) => {
      if (!keyword) {
        setSuggestions([]);
        return;
      }
      
      const response = await fetch(`/api/search?q=${keyword}`);
      const data = await response.json();
      setSuggestions(data.suggestions);
    }, 300),
    [] // 空依赖,确保 debounce 只创建一次
  );
  
  const handleInput = (e) => {
    fetchSuggestions(e.target.value);
  };
  
  return (
    <div>
      <input type="text" onChange={handleInput} />
      <ul>
        {suggestions.map(item => (
          <li key={item.id}>{item.text}</li>
        ))}
      </ul>
    </div>
  );
}

这个例子中有个细节:为什么要用 useCallback 包裹?因为每次组件重新渲染,debounce 都会创建一个新的防抖函数,导致之前的 timer 丢失。用 useCallback 可以确保防抖函数在组件生命周期内只创建一次。

场景 2:窗口 resize 事件优化

// 环境:浏览器
// 场景:响应式布局调整
// 功能:窗口大小改变时重新计算布局

function initResponsiveLayout() {
  const updateLayout = () => {
    const width = window.innerWidth;
    
    // 假设这里有复杂的 DOM 操作
    if (width < 768) {
      document.body.classList.add('mobile');
    } else {
      document.body.classList.remove('mobile');
    }
  };
  
  // 初始化时执行一次
  updateLayout();
  
  // resize 时防抖执行
  const debouncedUpdate = debounce(updateLayout, 200);
  window.addEventListener('resize', debouncedUpdate);
  
  // 清理函数(如果在框架中使用)
  return () => {
    debouncedUpdate.cancel();
    window.removeEventListener('resize', debouncedUpdate);
  };
}

场景 3:表单自动保存

// 环境:Vue 组件
// 场景:富文本编辑器自动保存草稿
// 依赖:Vue 3, axios

import { ref, watch } from 'vue';
import axios from 'axios';

export default {
  setup() {
    const content = ref('');
    const lastSaved = ref(null);
    
    const saveDraft = debounce(async (value) => {
      try {
        await axios.post('/api/draft', { content: value });
        lastSaved.value = new Date();
        console.log('草稿已保存');
      } catch (error) {
        console.error('保存失败:', error);
      }
    }, 2000);
    
    // 监听内容变化,触发自动保存
    watch(content, (newValue) => {
      saveDraft(newValue);
    });
    
    return { content, lastSaved };
  }
};

防抖与节流的对比

"防抖和节流有什么区别?" 我的理解是:

防抖(Debounce) :等你冷静下来再说话

  • 触发机制: 连续触发只执行最后一次
  • 时间特征: 从最后一次触发开始计时
  • 典型场景: 搜索框输入、窗口 resize、表单验证

节流(Throttle) : 限制你说话的频率

  • 触发机制: 固定时间间隔内只执行一次
  • 时间特征: 从第一次触发开始计时
  • 典型场景: 滚动加载、鼠标移动、游戏射击

用一个例子类比:

// 防抖:电梯等人
// 有人进来就重新等 10 秒,10 秒内没人进来才关门
debounce(closeDoor, 10000);

// 节流:定时发车
// 不管来多少人,每 10 分钟发一班车
throttle(startBus, 600000);

在 React Hooks 中的正确使用

在函数组件中使用防抖,有一些特殊的注意点。

// 环境:React 18+
// 场景:在函数组件中正确使用防抖

import { useState, useMemo, useRef, useEffect } from 'react';

function SearchComponent() {
  const [keyword, setKeyword] = useState('');
  
  // ✅ 方案 1:使用 useMemo 创建防抖函数
  const debouncedSearch = useMemo(
    () => debounce((value) => {
      console.log('搜索:', value);
      // 实际的搜索逻辑
    }, 300),
    [] // 空依赖,确保只创建一次
  );
  
  // 组件卸载时清理
  useEffect(() => {
    return () => {
      debouncedSearch.cancel();
    };
  }, [debouncedSearch]);
  
  const handleChange = (e) => {
    const value = e.target.value;
    setKeyword(value);
    debouncedSearch(value);
  };
  
  return <input value={keyword} onChange={handleChange} />;
}

// ✅ 方案 2:使用自定义 Hook
function useDebounce(callback, delay) {
  const callbackRef = useRef(callback);
  
  // 保持 callback 引用最新
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
  
  const debouncedCallback = useMemo(() => {
    return debounce((...args) => {
      callbackRef.current(...args);
    }, delay);
  }, [delay]);
  
  // 清理
  useEffect(() => {
    return () => debouncedCallback.cancel();
  }, [debouncedCallback]);
  
  return debouncedCallback;
}

延伸思考

在研究防抖的过程中,我产生了一些新的疑问:

  1. 返回值怎么处理?如果被防抖的函数有返回值,我们能拿到吗?
    • 答案是不能直接拿到,因为 setTimeout 是异步的。一种可能的方案是返回 Promise,但这又引入了新的复杂度。
  2. 防抖函数能取消吗?我们在版本 4 中实现了 cancel 方法,但如果需要获取"是否有待执行的函数"这个状态呢?
    • 可以考虑添加 isPending 方法。
  3. 最大等待时间?有些场景下,我们希望防抖有一个上限,比如不管用户输入多快,至少每 5 秒要执行一次。
    • 这就需要结合节流的思想,实现一个"带最大等待时间的防抖"。
  4. 在微前端架构中,如果子应用被卸载,防抖函数还在等待中怎么办?
    • 这涉及到生命周期管理和内存泄漏的问题。

小结

防抖看似简单,实际上涉及闭包、this 绑定、定时器管理等多个知识点。手写防抖的关键是理解"延迟与取消"的核心思想,然后一步步处理参数、this、立即执行等细节。

这篇文章是我准备面试时的思考过程,可能有理解不到位的地方。如果你有更好的实现方案或者发现了文中的问题,欢迎交流讨论。

面试时,除了能写出代码,更重要的是能解释清楚"为什么这样写"。比如:

  • 为什么要用闭包?(保存 timer 状态)
  • 为什么要用 apply?(传递 this 和参数)
  • 立即执行模式的边界条件是什么?(immediate && !timer)

下一步我想探索的是:如何实现一个带最大等待时间的防抖?这在实际项目中可能更实用。

参考资料

在 Vue 2.6 微前端架构中,我们为什么放弃了 Vuex 管理页面状态?

2026年3月14日 13:01

在 Vue 2.6 微前端架构中,我们为什么放弃了 Vuex 管理页面状态

背景:一个越来越"重"的页面

我们团队用 single-spa 搭了一套微前端架构,主技术栈是 Vue 2.6 + Element UI。系统里有不少复杂页面——转化漏斗分析详情、行为数据分析仪表盘、事件流程分析……这类页面的共同特点是:

  • 组件层级深,一个页面拆成 10+ 子组件很常见
  • 组件间通信频繁,筛选条件变了、Tab 切了、日期选了,好几个组件要同步响应
  • 状态生命周期跟页面走,进来要初始化,离开要清干净

一开始我们用 Vuex 管这些状态,很快就发现不对劲。

Vuex 管页面状态,哪里不对?

第一个问题:状态残留。 用户从漏斗详情页跳到事件分析页,再跳回来,Vuex 里上一次的筛选条件还在。你说用 beforeDestroy 里手动 reset?可以,但每个页面都要写一遍,写漏了就是 bug。

第二个问题:命名空间膨胀。 每个复杂页面一个 Vuex module,funnelDetail/setFilterseventAnalysis/setFiltersbehaviorDashboard/setFilters……全局 store 越来越臃肿,而这些 module 99% 的时间都不需要存在。

第三个问题:Vuex 的仪式感太重。 改一个状态要经过 commit → mutation → state,对于页面内部的交互状态来说,这个链路完全多余。筛选条件变了就该直接改,不需要走 mutation 审计。

我们试过的其他方案

provide / inject——只能传数据,不能传事件。组件 A 想通知组件 B "筛选变了,你该刷新了",provide/inject 做不到。

全局 EventBus($micRootBus ——我们微前端里有一个全局事件总线。但拿它做页面内通信,三个致命问题:

// 1. 命名冲突:漏斗详情和事件分析都有 filter:change
this.$micRootBus.$emit('filter:change', filters) // 谁的 filter?

// 2. 内存泄漏:每个 $on 都要手动 $off,页面销毁时漏一个就泄漏
beforeDestroy() {
  this.$micRootBus.$off('funnelDetail:filter:change', this.handler1)
  this.$micRootBus.$off('funnelDetail:tab:change', this.handler2)
  this.$micRootBus.$off('funnelDetail:date:change', this.handler3)
  // ... 8 个地方全要清,漏一个就寄
}

// 3. 边界模糊:事件扩散到全局,debug 时不知道谁在监听

组件 data + props 层层传递——5 层组件传一个筛选条件,中间 3 层只是当传话筒。经典的 props drilling 地狱。

每个方案都差点意思。我们需要的是一个页面级别的运行时上下文——状态、通信、副作用,全部限定在当前页面的作用域里,页面销毁时一键回收。

于是我们造了 vue-page-store

核心思路很简单:用一个隐藏的 Vue 实例承载响应式 state + computed getters,再加一个闭包隔离的事件总线,生命周期绑定在一起。

npm install vue-page-store

定义一个页面级 Store

import { definePageStore } from 'vue-page-store'

export const useFunnelStore = definePageStore('funnelDetail', {
  state: () => ({
    filters: { dateRange: [], platform: '' },
    loading: false,
    funnelSteps: [],
  }),

  getters: {
    isReady() {
      return !this.loading && this.funnelSteps.length > 0
    },
  },

  actions: {
    async fetchData() {
      this.loading = true
      try {
        this.funnelSteps = await api.getFunnelSteps(this.filters)
      } finally {
        this.loading = false
      }
    },
  },
})

API 风格完全对齐 Pinia:state / getters / actions,用过 Pinia 的人零学习成本。

组件中使用

const store = useFunnelStore()

// 直接读
store.filters
store.isReady

// 直接改
store.filters = newFilters

// 调 action
store.fetchData()

// 批量更新
store.$patch({ loading: true, filters: newFilters })

没有 commit,没有 mutation,没有 mapState。直接属性访问,直接赋值。

页面内通信:作用域隔离的事件

这是 vue-page-store 和 Pinia 最大的区别。我们内置了一个页面作用域级的事件总线

// 组件 A —— 发射事件
store.$emit('filter:change', newFilters)

// 组件 B —— 监听事件
const off = store.$on('filter:change', (filters) => {
  this.applyFilters(filters)
})

重点来了: _listeners 是闭包内的私有变量,每个 store 实例独立一份。 漏斗详情的 filter:change 和事件分析的 filter:change 完全隔离,互不干扰。

为什么不拆成独立的 EventBus?因为生命周期要跟 store 绑定。$destroy 的时候自动清空所有 listeners:

store.$destroy = () => {
  // 清空事件 —— 不会泄漏
  Object.keys(_listeners).forEach(key => delete _listeners[key])
  // 销毁 Vue 实例 —— 回收 watchers
  vm.$destroy()
  // 移除注册 —— 下次进来是全新的
  storeRegistry.delete(id)
}

调用方(子组件)只需要注入 store 就能通信,不需要感知全局 Bus,不需要手动 $off,不需要加命名前缀。

页面销毁:一行代码全部回收

// 页面根组件
beforeDestroy() {
  useFunnelStore().$destroy()
}

state、getters、watchers、事件监听——全部清干净。下次进这个页面,又是一个全新的 store。

它不是 Pinia 的替代品

这一点必须说清楚。vue-page-store 解决的是 Vuex / Pinia 覆盖不到的那个中间地带:

Vuex Pinia vue-page-store
作用域 全局 全局 页面级
生命周期 应用级 应用级 页面级($destroy 回收)
事件通信 内置 emit/emit/on(作用域隔离)
Vue 2.6 支持 ⚠️ 需 @vue/composition-api ✅ 原生支持
适合管什么 用户信息、权限、全局配置 同左 复杂页面内部状态

推荐组合:Vuex 管全局,vue-page-store 管页面。 各管各的,互不干扰。

声明式 watch:页面级副作用的自动管理

除了状态和事件,页面里还有一类东西需要管理——副作用。比如"查询时间范围变了,自动判断是否按小时查询":

export const useFunnelStore = definePageStore('funnelDetail', {
  state: () => ({ /* ... */ }),

  getters: {
    isQueryByHour() {
      const range = this.filters?.dateRange
      return (new Date(range[1]) - new Date(range[0])) / 3600000 <= 24
    },
  },

  watch: {
    'isQueryByHour'(val) {
      if (!val) this.tabTime = 'hour'
    },
  },
})

声明式写法,定义的时候绑上去,$destroy 的时候跟着 Vue 实例一起销毁。不需要手动 $watch 再手动 unwatch

实现原理:100 行代码

核心实现非常简单,整个库不到 200 行,核心逻辑 100 行出头:

  1. new Vue({ data: { $$state }, computed }) —— 一个隐藏的 Vue 实例,承载响应式和 computed
  2. Object.defineProperty 代理 —— 把 state 和 getters 暴露到 store 对象上
  3. 闭包内的 _listeners 对象 —— 作用域隔离的事件总线
  4. storeRegistry Map —— 保证同一个 id 只有一个实例

没有黑魔法,没有额外依赖,gzip 后不到 3KB。

最后

如果你也在用 Vue 2.6 + 微前端架构,遇到了页面级状态管理的痛点,可以试试:

npm install vue-page-store

Vue 3 项目推荐用 Pinia,这个库专为 Vue 2.6 场景设计。

如果对你有帮助,欢迎 star ⭐️,有问题直接提 issue。

Flutter 进阶 UI搭建 iOS 风格通讯录应用(十一)

作者 HelloReader
2026年3月14日 12:55

前言

恭喜你完成了前两个项目:Birdle 猜词游戏和维基百科阅读器!你已经掌握了 Widget 基础、布局、状态管理和 MVVM 架构。

从这一篇开始,我们进入全新的章节——Flutter UI 102。我们将构建第三个应用:一个 iOS 风格的通讯录应用 Rolodex。在这个过程中,你会学到自适应布局、高级滚动、导航模式和 iOS 风格主题等进阶 UI 技巧。

本文基于官方教程的「Advanced UI Features」章节,今天的任务是搭建 Rolodex 项目、认识 Cupertino 组件库,并创建数据模型。


一、新应用要做什么?

Rolodex 是一个仿 iOS 通讯录的应用,最终效果包括:

  • 自适应布局:大屏幕显示侧边栏 + 详情面板,小屏幕用导航跳转
  • 高级滚动:使用 Sliver 实现可折叠的搜索栏和字母索引
  • 导航模式:基于栈的页面跳转(push/pop)
  • iOS 风格主题:使用 Cupertino 组件,支持亮色/暗色模式

1.1 Material vs Cupertino

前两个项目用的都是 MaterialApp(Material Design 风格),这是 Google 的设计语言。而这次我们用 CupertinoApp(Cupertino 风格),这是 Apple 的 iOS 设计语言。

两者的核心区别:

// Material 风格(Google 设计语言)
// 前两个项目一直在用
MaterialApp(
  home: Scaffold(
    appBar: AppBar(title: Text('标题')),
    body: ...,
  ),
)

// Cupertino 风格(Apple iOS 设计语言)
// 本项目使用
CupertinoApp(
  home: CupertinoPageScaffold(
    navigationBar: CupertinoNavigationBar(middle: Text('标题')),
    child: ...,
  ),
)

虽然组件名字不同,但编程方式完全一样——都是 Widget 嵌套。Cupertino 组件在所有平台上都能运行(不只是 iOS),只是视觉风格模仿了 iOS。


二、创建 Rolodex 项目

2.1 创建项目并安装依赖

# 创建新项目
flutter create rolodex --empty

# 进入项目目录
cd rolodex

# 安装 Cupertino 图标包
# 提供了 iOS 风格的图标(如返回箭头、搜索图标等)
flutter pub add cupertino_icons

# 创建代码目录结构
# data/    → 数据模型
# screens/ → 页面组件
# theme/   → 主题配置
mkdir lib/data lib/screens lib/theme

2.2 替换 main.dart

// 导入 Cupertino 组件库(替代 Material)
// Cupertino 提供了 iOS 风格的按钮、导航栏、列表等组件
import 'package:flutter/cupertino.dart';

// 导入数据模型(下一步创建)
import 'package:rolodex/data/contact_group.dart';

// 全局状态:联系人分组的数据模型
// 使用 ValueNotifier 管理状态变化
final contactGroupsModel = ContactGroupsModel();

void main() {
  runApp(const RolodexApp());
}

// RolodexApp:应用根组件
// 使用 CupertinoApp 替代 MaterialApp
class RolodexApp extends StatelessWidget {
  const RolodexApp({super.key});

  @override
  Widget build(BuildContext context) {
    // CupertinoApp 提供 iOS 风格的应用框架
    // 包括 iOS 风格的路由动画、字体、颜色等
    return CupertinoApp(
      title: 'Rolodex',
      // CupertinoThemeData 配置 iOS 风格主题
      theme: const CupertinoThemeData(
        // barBackgroundColor 设置导航栏的背景色
        // CupertinoDynamicColor 自动适配亮色/暗色模式
        barBackgroundColor: CupertinoDynamicColor.withBrightness(
          color: Color(0xFFF9F9F9),      // 亮色模式:浅灰白
          darkColor: Color(0xFF1D1D1D),  // 暗色模式:深灰黑
        ),
      ),
      // CupertinoPageScaffold 是 iOS 风格的页面脚手架
      // 相当于 Material 的 Scaffold
      home: CupertinoPageScaffold(
        child: Center(child: Text('Hello Rolodex!')),
      ),
    );
  }
}

三、创建数据模型

3.1 Contact 类

lib/data/contact.dart 中创建联系人数据模型:

// Contact:单个联系人
// 包含 ID、姓、名、中间名(可选)、后缀(可选,如 Jr.、Sr.)
class Contact {
  Contact({
    required this.id,           // 唯一标识符
    required this.firstName,    // 名
    this.middleName,            // 中间名(可选)
    required this.lastName,     // 姓
    this.suffix,                // 后缀(可选,如 Jr.、III)
  });

  final int id;
  final String firstName;
  final String lastName;
  final String? middleName;  // ? 表示可空
  final String? suffix;
}

// 示例联系人数据
final johnAppleseed = Contact(id: 0, firstName: 'John', lastName: 'Appleseed');
final kateBell = Contact(id: 1, firstName: 'Kate', lastName: 'Bell');
final annaHaro = Contact(id: 2, firstName: 'Anna', lastName: 'Haro');
final danielHiggins = Contact(
  id: 3, firstName: 'Daniel', lastName: 'Higgins', suffix: 'Jr.',
);
// ... 更多联系人(完整列表见代码库文件)

// 所有联系人的集合
final Set<Contact> allContacts = <Contact>{
  johnAppleseed, kateBell, annaHaro, danielHiggins,
  // ... 完整列表
};

3.2 ContactGroup 类

lib/data/contact_group.dart 中创建联系人分组:

import 'dart:collection';
import 'package:flutter/cupertino.dart';
import 'contact.dart';

// ContactGroup:联系人分组
// 如"所有联系人"、"收藏"、"工作"等
class ContactGroup {
  factory ContactGroup({
    required int id,
    required String label,
    bool permanent = false,   // 是否为固定分组(不可删除)
    String? title,
    List<Contact>? contacts,
  }) {
    // 创建时自动按姓名排序
    final contactsCopy = contacts ?? <Contact>[];
    _sortContacts(contactsCopy);
    return ContactGroup._internal(
      id: id, label: label, permanent: permanent,
      title: title, contacts: contactsCopy,
    );
  }

  // 私有构造函数
  ContactGroup._internal({
    required this.id,
    required this.label,
    this.permanent = false,
    String? title,
    List<Contact>? contacts,
  }) : title = title ?? label,
       _contacts = contacts ?? const <Contact>[];

  final int id;
  final bool permanent;
  final String label;
  final String title;
  final List<Contact> _contacts;

  List<Contact> get contacts => _contacts;

  // 按首字母分组(用于字母索引滚动列表)
  // SplayTreeMap 自动按 key 排序(A、B、C...)
  AlphabetizedContactMap get alphabetizedContacts {
    final AlphabetizedContactMap contactsMap = AlphabetizedContactMap();
    for (final Contact contact in _contacts) {
      final String lastInitial = contact.lastName[0].toUpperCase();
      if (contactsMap.containsKey(lastInitial)) {
        contactsMap[lastInitial]!.add(contact);
      } else {
        contactsMap[lastInitial] = [contact];
      }
    }
    return contactsMap;
  }
}

// 按字母排序的联系人 Map 类型别名
typedef AlphabetizedContactMap = SplayTreeMap<String, List<Contact>>;

// 联系人排序函数:先按姓排序,再按名,再按中间名
void _sortContacts(List<Contact> contacts) {
  contacts.sort((Contact a, Contact b) {
    final int checkLastName = a.lastName.compareTo(b.lastName);
    if (checkLastName != 0) return checkLastName;
    final int checkFirstName = a.firstName.compareTo(b.firstName);
    if (checkFirstName != 0) return checkFirstName;
    if (a.middleName != null && b.middleName != null) {
      final int checkMiddleName = a.middleName!.compareTo(b.middleName!);
      if (checkMiddleName != 0) return checkMiddleName;
    } else if (a.middleName != null || b.middleName != null) {
      return a.middleName != null ? 1 : -1;
    }
    return a.id.compareTo(b.id);
  });
}

// 示例分组数据
final allPhone = ContactGroup(
  id: 0, permanent: true,
  label: 'All iPhone', title: 'iPhone',
  contacts: allContacts.toList(),
);
final friends = ContactGroup(
  id: 1, label: 'Friends',
  contacts: [allContacts.elementAt(3)],
);
final work = ContactGroup(id: 2, label: 'Work');

// 生成初始数据
List<ContactGroup> generateSeedData() {
  return [allPhone, friends, work];
}

// ContactGroupsModel:状态管理
// 使用 ValueNotifier 管理分组列表的变化
class ContactGroupsModel {
  ContactGroupsModel()
    : _listsNotifier = ValueNotifier(generateSeedData());

  // ValueNotifier:一种简化版的 ChangeNotifier
  // 它包裹一个值,当值改变时自动通知监听者
  final ValueNotifier<List<ContactGroup>> _listsNotifier;

  ValueNotifier<List<ContactGroup>> get listsNotifier => _listsNotifier;
  List<ContactGroup> get lists => _listsNotifier.value;

  // 根据 ID 查找分组
  ContactGroup findContactList(int id) {
    return lists[id];
  }

  // 释放资源
  void dispose() {
    _listsNotifier.dispose();
  }
}

四、ValueNotifier 简介

在维基百科阅读器中我们用了 ChangeNotifier。这次用的 ValueNotifier 是它的简化版:

// ChangeNotifier:通用版,可以有多个属性
// 需要手动调用 notifyListeners()
class ArticleViewModel extends ChangeNotifier {
  Summary? summary;
  bool loading = false;
  // 修改后需要手动调用 notifyListeners()
}

// ValueNotifier:简化版,只包裹一个值
// 修改 .value 时自动通知监听者,不需要手动调用
final counter = ValueNotifier<int>(0);
counter.value = 1; // 自动通知!无需调用 notifyListeners()

当你的状态只有一个值时,ValueNotifierChangeNotifier 更简洁。


五、本节知识点小结

CupertinoApp: Apple iOS 风格的应用框架,替代 MaterialApp。提供 iOS 风格的导航栏、按钮、列表等组件。可在所有平台运行,不只是 iOS。

CupertinoDynamicColor: 能自动适配亮色和暗色模式的颜色类。传入两个颜色值,系统会根据当前模式自动选择。

ValueNotifier: ChangeNotifier 的简化版,包裹一个值。当 .value 被修改时自动通知监听者,不需要手动调用 notifyListeners()

项目结构: 按职责划分目录——data/ 放数据模型、screens/ 放页面组件、theme/ 放主题配置,让代码组织更清晰。


六、下一步学习

项目骨架和数据模型已就绪。下一课我们将学习 自适应布局(Adaptive Layouts),使用 LayoutBuilder 让应用在不同屏幕尺寸上自动切换布局方式。

我们下篇文章见!

参考资料:Flutter 官方教程 - Advanced UI Features

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

作者 SmalBox
2026年3月14日 12:49

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

Cubemap Asset 节点是 Unity URP Shader Graph 中用于定义和引用立方体贴图资源的核心节点。立方体贴图是一种特殊类型的纹理,由六个二维纹理面组成,形成一个完整的立方体环境映射。这种纹理格式在实时渲染中广泛应用于天空盒、环境反射、折射效果以及基于图像的照明等场景。

在 Shader Graph 中使用 Cubemap Asset 节点时,它本身并不直接执行采样操作,而是作为一个资源引用点。这意味着该节点只是定义了要在着色器中使用的立方体贴图资源,而实际的纹理采样需要通过连接 Sample Cubemap 节点来完成。这种设计使得资源定义与采样操作分离,提高了节点的复用性和灵活性。

Cubemap Asset 节点的一个重要特性是资源复用能力。在着色器图中,单个 Cubemap Asset 节点可以被多次使用,连接到不同的 Sample Cubemap 节点,每个采样节点可以使用不同的采样参数。这意味着无需在图中重复定义相同的立方体贴图资源,即可实现多种不同的采样效果,从而优化着色器性能和资源管理。

立方体贴图在三维图形学中具有独特的坐标系统。与传统的二维纹理使用 UV 坐标不同,立方体贴图使用三维方向向量进行采样。这个方向向量从立方体的中心指向外部,与立方体的六个面相交,确定要采样的具体纹理位置。这种采样方式使得立方体贴图特别适合表示全方位的环境信息。

在 Unity 的 URP 渲染管线中,Cubemap Asset 节点支持各种立方体贴图格式,包括 HDR(高动态范围)和 LDR(低动态范围)贴图。HDR 立方体贴图能够存储超出标准范围的颜色值,这对于实现真实的环境反射和全局照明效果至关重要。此外,节点还支持不同的压缩格式和 Mipmap 级别,以满足各种性能和质量需求。

端口

输出端口

Cubemap Asset 节点仅包含一个输出端口,设计简洁而功能明确:

  • 名称:Out
  • 方向:输出
  • 类型:立方体贴图(Cubemap)
  • 绑定:无
  • 描述:输出所引用的立方体贴图资源

输出端口是 Cubemap Asset 节点与着色器图中其他节点交互的唯一接口。这个端口不包含任何纹理数据的具体信息,而是作为一个资源引用,指向项目中实际的立方体贴图资源文件。当将此端口连接到 Sample Cubemap 节点的相应输入端口时,就建立了一个完整的纹理采样链路。

在实际使用中,输出端口可以连接到多个不同的 Sample Cubemap 节点,实现同一立方体贴图资源的多重采样。这种连接方式特别有用当需要在同一着色器中实现不同精度或不同功能的立方体贴图采样时,比如同时实现主要反射和辅助反射效果。

输出端口的数据流在 Shader Graph 的编译过程中会被转换为适当的 HLSL 代码,其中包括对立方体贴图资源的声明和引用。在生成的着色器代码中,这个端口对应的通常是一个 TextureCube 类型的变量,并在必要时包含相关的采样器状态。

控件

Cubemap Asset 节点的界面控件设计直观且功能集中,主要围绕立方体贴图资源的选择和管理:

  • 名称:无特定名称(在节点标题栏显示)
  • 类型:对象字段(立方体贴图)
  • 选项:无额外选项
  • 描述:定义项目中的立方体贴图资源

对象字段详解

对象字段是 Cubemap Asset 节点的核心控件,表现为一个资源选择槽。用户可以通过多种方式指定立方体贴图资源:

  • 拖拽分配:直接从 Project 窗口拖拽立方体贴图资源到节点的对象字段上
  • 对象选择器:点击对象字段右侧的圆形选择按钮,从弹出的资源选择窗口中选择合适的立方体贴图
  • 资源创建:如果尚未创建合适的立方体贴图,可以通过右键菜单创建新的立方体贴图资源

支持的立方体贴图类型

Cubemap Asset 节点支持 Unity 中所有类型的立方体贴图资源:

  • 常规立方体贴图:标准的六个面的立方体贴图
  • HDR 立方体贴图:高动态范围立方体贴图,适用于逼真的环境照明
  • 渲染纹理立方体贴图:运行时通过相机渲染生成的动态立方体贴图
  • 程序生成立方体贴图:通过代码生成的立方体贴图资源

资源属性继承

当立方体贴图资源被分配给 Cubemap Asset 节点后,节点会自动继承该资源的所有属性设置,包括:

  • 纹理导入设置:如 Wrap Mode、Filter Mode 和 Anisotropic Level
  • Mipmap 设置:包括 Mipmap 的生成和使用状态
  • 压缩格式:纹理的压缩格式和质量设置
  • 颜色空间:线性空间或伽马空间的颜色数据处理

这些继承的属性会在着色器执行时影响立方体贴图的采样行为和质量表现。例如,Filter Mode 设置会影响纹理采样的平滑程度,Wrap Mode 会决定在采样方向超出立方体范围时的行为。

资源验证和错误处理

Cubemap Asset 节点包含完善的资源验证机制:

  • 资源类型检查:确保分配的资源确实是立方体贴图类型
  • 资源存在性验证:检查引用的资源是否存在于项目中
  • 平台兼容性检查:验证立方体贴图设置是否与目标平台兼容
  • 错误提示系统:当资源配置有问题时,显示明确的错误信息和解决建议

当资源分配出现问题时,节点会在图中以明显的视觉提示(如红色高亮或警告图标)标识问题状态,帮助用户快速识别和解决资源引用问题。

应用实例

基础环境映射

创建一个简单的环境反射效果是 Cubemap Asset 节点的典型应用场景:

  • 在 Shader Graph 中创建 Cubemap Asset 节点
  • 从项目资源中分配一个环境立方体贴图
  • 添加 Sample Cubemap 节点并连接 Cubemap Asset 节点的输出
  • 使用 Reflection Probe 或摄像机方向向量作为采样坐标
  • 将采样结果与表面颜色混合,实现基础反射效果

这种设置可以用于金属表面、水面或其他反射性材质的模拟,通过调整采样参数和混合系数,可以控制反射的强度和清晰度。

高级反射效果

利用单个 Cubemap Asset 节点实现多重反射采样:

  • 将同一 Cubemap Asset 节点连接到两个不同的 Sample Cubemap 节点
  • 第一个采样节点使用主反射向量,设置较高的 LOD 偏置以获得清晰反射
  • 第二个采样节点使用模糊化的反射向量,设置较低的 LOD 偏置获得模糊反射
  • 将两个采样结果按一定权重混合,创建具有深度感的反射效果

这种技术特别适合实现粗糙表面的反射,其中同时包含清晰的镜面反射和柔和的漫反射成分。

动态环境交互

结合脚本控制实现动态环境效果:

  • 在运行时通过脚本替换 Cubemap Asset 节点引用的立方体贴图资源
  • 根据游戏时间、天气条件或场景变化切换不同的环境贴图
  • 实现昼夜循环的环境反射变化
  • 创建基于玩家位置的动态环境映射更新

这种动态引用机制使得着色器能够响应游戏状态的变化,创建更加生动和沉浸式的视觉体验。

性能优化建议

合理使用 Cubemap Asset 节点对于保持良好渲染性能至关重要:

  • 资源复用:尽可能通过单个 Cubemap Asset 节点支持多个采样操作,减少纹理绑定次数
  • Mipmap 利用:根据视觉效果需求选择合适的 Mipmap 级别,平衡质量与性能
  • 压缩格式选择:针对不同使用场景选择合适的纹理压缩格式,减少内存占用和带宽需求
  • 采样优化:在不需要高质量采样的场合,使用更简单的滤波模式和较低的各向异性设置
  • 流式加载管理:对于大型立方体贴图,合理配置流式加载设置,避免运行时内存峰值

常见问题与解决方案

资源引用丢失

当立方体贴图资源被移动或删除时,Cubemap Asset 节点会出现引用丢失问题:

  • 症状:节点显示红色错误状态,着色器效果失效
  • 解决方案:通过对象字段重新分配正确的立方体贴图资源,或恢复被移动的资源文件

平台兼容性问题

不同平台对立方体贴图格式的支持可能存在差异:

  • 症状:在特定平台构建后立方体贴图显示异常或缺失
  • 解决方案:检查立方体贴图的导入设置,确保为每个目标平台配置了合适的纹理格式和压缩设置

性能问题

不合理的立方体贴图使用可能导致性能下降:

  • 症状:渲染帧率下降,GPU 负载过高
  • 解决方案:优化立方体贴图分辨率,使用合适的 Mipmap 和压缩设置,避免不必要的实时立方体贴图更新

视觉瑕疵

立方体贴图采样可能产生各种视觉问题:

  • 接缝可见:确保立方体贴图的六个面在边缘处完美衔接
  • 颜色偏差:检查颜色空间设置和 HDR 数据处理是否正确
  • 反射失真:验证采样向量的计算和坐标空间的正确性

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

每个 React 开发者都需要的 10 个浏览器 API Hooks

2026年3月14日 12:39

学习如何在 React 中使用 Geolocation、Clipboard、Fullscreen、Media Queries 等浏览器 API,借助 ReactUse 提供的简洁、可复用的 Hooks。

原文发布于 reactuse.com

现代浏览器提供了强大的 API,包括地理定位、剪贴板访问、全屏模式、网络状态等等。在 React 中直接使用它们比应有的难度更大。你需要防范服务端渲染、添加和移除事件监听器、处理权限,以及在卸载时清理。将这些工作乘以你的应用涉及的每个浏览器 API,你就有了大量重复且容易出错的代码。

ReactUse 通过一个包含 100 多个 Hooks 的库来解决这个问题,将浏览器 API 封装为简洁的、SSR 安全的、TypeScript 友好的接口。只需安装一次,按需导入:

npm i @reactuses/core

1. useMediaQuery -- 响应式设计

在 JavaScript 中响应 CSS 媒体查询。该 Hook 返回一个布尔值,在视口变化时实时更新。

import { useMediaQuery } from "@reactuses/core";

function App() {
  const isMobile = useMediaQuery("(max-width: 768px)");
  return <div>{isMobile ? <MobileNav /> : <DesktopNav />}</div>;
}

2. useClipboard -- 复制到剪贴板

使用现代 Clipboard API 读写系统剪贴板。该 Hook 处理权限、HTTPS 要求和焦点状态边界情况。

import { useClipboard } from "@reactuses/core";

function CopyButton({ text }: { text: string }) {
  const [clipboardText, copy] = useClipboard();
  return (
    <button onClick={() => copy(text)}>
      {clipboardText === text ? "Copied!" : "Copy"}
    </button>
  );
}

3. useGeolocation -- 用户位置

追踪用户的地理坐标,在卸载时自动清理 watchPosition 监听器。

import { useGeolocation } from "@reactuses/core";

function LocationDisplay() {
  const { coordinates, error, isSupported } = useGeolocation();
  if (!isSupported) return <p>不支持地理定位。</p>;
  if (error) return <p>错误: {error.message}</p>;
  return <p>纬度: {coordinates.latitude}, 经度: {coordinates.longitude}</p>;
}

4. useFullscreen -- 全屏模式

对任意元素切换全屏。该 Hook 封装了 Fullscreen API,返回当前状态和控制函数。

import { useRef } from "react";
import { useFullscreen } from "@reactuses/core";

function VideoPlayer() {
  const ref = useRef<HTMLDivElement>(null);
  const [isFullscreen, { toggleFullscreen }] = useFullscreen(ref);
  return (
    <div ref={ref}>
      <video src="/demo.mp4" />
      <button onClick={toggleFullscreen}>
        {isFullscreen ? "退出" : "全屏"}
      </button>
    </div>
  );
}

5. useNetwork -- 在线/离线状态

监控用户的网络连接。该 Hook 追踪在线/离线状态,在可用时还提供连接详情。

import { useNetwork } from "@reactuses/core";

function NetworkBanner() {
  const { online, effectiveType } = useNetwork();
  if (!online) return <div className="banner">您已离线</div>;
  return <div>连接类型: {effectiveType}</div>;
}

6. useIdle -- 空闲检测

检测用户何时停止与页面交互。

import { useIdle } from "@reactuses/core";

function IdleWarning() {
  const isIdle = useIdle(300_000); // 5 分钟
  return isIdle ? <div>你还在吗?</div> : null;
}

7. useDarkMode -- 深色模式切换

管理深色模式,包含系统偏好检测、localStorage 持久化和根元素自动类名切换。

import { useDarkMode } from "@reactuses/core";

function ThemeToggle() {
  const [isDark, toggle] = useDarkMode({
    classNameDark: "dark",
    classNameLight: "light",
  });
  return (
    <button onClick={toggle}>
      {isDark ? "切换到浅色" : "切换到深色"}
    </button>
  );
}

8. usePermission -- 权限状态

查询浏览器权限的状态并实时响应变化。

import { usePermission } from "@reactuses/core";

function CameraAccess() {
  const status = usePermission("camera");
  if (status === "denied") return <p>摄像头访问被拒绝。</p>;
  if (status === "prompt") return <p>我们需要摄像头权限。</p>;
  return <p>摄像头访问已授权。</p>;
}

9. useLocalStorage -- 持久化状态

useState 的替代方案,持久化到 localStorage。处理序列化、SSR 安全性、跨标签页同步和错误恢复。

import { useLocalStorage } from "@reactuses/core";

function Settings() {
  const [lang, setLang] = useLocalStorage("language", "en");
  return (
    <select value={lang ?? "en"} onChange={(e) => setLang(e.target.value)}>
      <option value="en">English</option>
      <option value="es">Spanish</option>
      <option value="fr">French</option>
    </select>
  );
}

10. useEventListener -- 事件处理

将事件监听器附加到任何目标,自动清理,并提供 TypeScript 安全的事件类型。

import { useEventListener } from "@reactuses/core";

function KeyLogger() {
  useEventListener("keydown", (event) => {
    console.log("按键:", event.key);
  });
  return <p>按任意键...</p>;
}

手动实现 vs. ReactUse

关注点 手动实现 ReactUse Hook
SSR 安全检查 到处添加 typeof window !== "undefined" 内置
事件监听器清理 useEffect + removeEventListener 自动
TypeScript 事件类型 手动泛型约束 完全类型化
localStorage 序列化 JSON.parse/stringify + 错误处理 自动
跨标签页同步 手动 storage 事件监听 内置

对于单个 Hook 来说节省量不大。但在整个应用中使用五个或更多浏览器 API 时,ReactUse 消除了数百行防御性代码。


ReactUse 提供了 100 多个 React Hooks。查看全部 →

Flutter ListenableBuilder让界面自动响应数据变化(十)

作者 HelloReader
2026年3月14日 12:35

前言

在前两篇文章中,我们完成了 MVVM 架构的 Model 层(HTTP 请求)和 ViewModel 层(ChangeNotifier 状态管理)。但界面仍然是一个"Loading..."的占位页面——数据虽然拿到了,却没有展示出来。

今天这篇文章基于官方教程的「Use ListenableBuilder to update app UI」章节,我们将实现 MVVM 的最后一层——View 层。通过 ListenableBuilder,UI 会自动监听 ViewModel 的变化,在数据更新时自动重绘。

完成这一课后,维基百科阅读器就能完整运行了!


一、ListenableBuilder 是什么?

还记得上一课的 ChangeNotifier 吗?ViewModel 调用 notifyListeners() 时会广播"数据变了"。但谁在"收听"这个广播呢?答案就是 ListenableBuilder

ListenableBuilder 是一个 Widget,它能自动监听一个 ChangeNotifier(或任何 Listenable)。当被监听的对象调用 notifyListeners() 时,ListenableBuilder 会自动重新执行它的 builder 函数,重绘 UI。

整个链条串起来就是:

用户点击"下一篇" 
    → ViewModel 调用 model.getRandomArticleSummary()
    → 数据返回,ViewModel 调用 notifyListeners()
    → ListenableBuilder 收到通知
    → 自动重新执行 builder 函数
    → UI 展示新文章

二、创建 ArticleView

2.1 基本结构

ArticleView 是整个页面的容器,它持有 ViewModel 并用 ListenableBuilder 监听变化:

// ArticleView:页面级组件(MVVM 的 View 层入口)
// 职责:创建 ViewModel,用 ListenableBuilder 监听状态变化
class ArticleView extends StatelessWidget {
  ArticleView({super.key});

  // 创建 ViewModel,传入 Model
  // ViewModel 在构造时会自动发起第一次数据请求
  final ArticleViewModel viewModel = ArticleViewModel(ArticleModel());

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Wikipedia Flutter'),
      ),
      // ListenableBuilder 监听 viewModel
      // 当 viewModel 调用 notifyListeners() 时,builder 自动重新执行
      body: ListenableBuilder(
        // listenable:要监听的对象(必须是 ChangeNotifier 或 Listenable)
        listenable: viewModel,
        // builder:每次 notifyListeners() 被调用时,这个函数会重新执行
        // 它根据 viewModel 的当前状态返回对应的 Widget
        builder: (context, child) {
          // 下一步:根据状态返回不同的界面
          return const Center(child: Text('UI will update here'));
        },
      ),
    );
  }
}

2.2 用 switch 表达式处理所有状态

ViewModel 有三个状态属性:loadingsummaryerrorMessage。我们用 Dart 的 switch 表达式根据它们的组合决定显示什么:

builder: (context, child) {
  // 将三个状态属性组合成一个元组(tuple),用 switch 匹配
  // 这样可以覆盖所有可能的状态组合,不会遗漏
  return switch ((
    viewModel.loading,      // bool
    viewModel.summary,      // Summary?
    viewModel.errorMessage, // String?
  )) {
    // 模式 1:正在加载 → 显示转圈圈
    // loading=true 时,忽略其他两个属性(用 _ 通配符)
    (true, _, _) => const Center(
      child: CircularProgressIndicator(),
    ),

    // 模式 2:加载完成,有错误信息 → 显示错误提示
    // loading=false,errorMessage 是非空 String
    (false, _, String message) => Center(
      child: Text(message),
    ),

    // 模式 3:加载完成,无数据无错误 → 未知错误
    // 理论上不应该出现,但作为兜底处理
    (false, null, null) => const Center(
      child: Text('An unknown error has occurred'),
    ),

    // 模式 4:加载完成,有文章数据 → 显示文章内容
    // summary 是非空的 Summary 对象
    (false, Summary summary, null) => ArticlePage(
      summary: summary,
      onPressed: viewModel.getRandomArticleSummary,
    ),
  };
},

这就是声明式 UI 的精髓——你不需要手动判断"什么时候该隐藏加载圈、什么时候该显示内容"。你只需要描述"在每种状态下界面长什么样",Flutter 会自动处理切换。


三、创建 ArticlePage

ArticlePage 包含文章内容和一个"加载下一篇"的按钮:

// ArticlePage:文章页面
// 接收 Summary 数据和一个回调函数
// 职责:展示文章内容 + 提供"下一篇"按钮
class ArticlePage extends StatelessWidget {
  const ArticlePage({
    super.key,
    required this.summary,
    required this.onPressed,
  });

  final Summary summary;          // 文章摘要数据
  final VoidCallback onPressed;   // 点击"下一篇"时的回调

  @override
  Widget build(BuildContext context) {
    // SingleChildScrollView 让内容可以滚动
    // 当文章较长时不会溢出屏幕
    return SingleChildScrollView(
      child: Column(
        children: [
          // 文章内容组件
          ArticleWidget(summary: summary),
          // "下一篇"按钮
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: ElevatedButton(
              // 点击时调用 viewModel.getRandomArticleSummary
              // 触发新一轮:请求数据 → 更新状态 → 通知 UI → 重绘
              onPressed: onPressed,
              child: const Text('Next random article'),
            ),
          ),
        ],
      ),
    );
  }
}

四、创建 ArticleWidget

ArticleWidget 负责展示文章的具体内容:图片、标题、描述、正文。

// ArticleWidget:文章内容展示组件
// 职责:将 Summary 数据渲染为图片 + 标题 + 描述 + 正文
class ArticleWidget extends StatelessWidget {
  const ArticleWidget({super.key, required this.summary});

  final Summary summary;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      // Column + spacing 让子组件之间有统一的 10 像素间距
      child: Column(
        spacing: 10.0,
        children: [
          // ===== 条件渲染:只在有图片时才显示 =====
          // summary.hasImage 是 Summary 类中的 getter
          // 检查 originalImage 或 thumbnail 是否可用
          if (summary.hasImage)
            Image.network(
              // 从网络加载图片
              // ! 是空断言操作符,因为 hasImage 已确认非空
              summary.originalImage!.source,
            ),

          // ===== 标题 =====
          Text(
            summary.titles.normalized,
            // TextOverflow.ellipsis:文字超长时显示省略号 "..."
            overflow: TextOverflow.ellipsis,
            // 使用主题中的 displaySmall 样式(大号标题)
            style: TextTheme.of(context).displaySmall,
          ),

          // ===== 描述(可选)=====
          // 并非所有文章都有描述,所以用 if 条件渲染
          if (summary.description != null)
            Text(
              summary.description!,
              overflow: TextOverflow.ellipsis,
              // bodySmall 样式(小号正文,适合副标题/描述)
              style: TextTheme.of(context).bodySmall,
            ),

          // ===== 正文摘要 =====
          Text(
            summary.extract, // 文章的前几句话(纯文本)
          ),
        ],
      ),
    );
  }
}

几个值得关注的 UI 技巧:

  • 条件渲染if (condition) Widget() 在 Flutter 的 children 列表中直接使用,只在条件为真时添加该组件
  • 文字溢出处理TextOverflow.ellipsis 防止超长标题撑破布局
  • 主题字体:用 TextTheme.of(context) 获取统一的字体样式,保持视觉层次

五、更新 MainApp

最后,把 MainApp 中的占位页面替换为 ArticleView

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // 将占位的 Scaffold 替换为完整的 ArticleView
      // ArticleView 内部已经包含了 Scaffold、AppBar 等
      home: ArticleView(),
    );
  }
}

六、运行效果

热重载后,你会看到完整的交互流程:

  1. 应用启动 → 显示加载转圈圈(loading = true)
  2. 数据返回 → 显示文章标题、描述、图片和正文(summary 有值)
  3. 点击"Next random article" → 转圈圈 → 新文章出现
  4. 如果网络出错 → 显示错误信息(errorMessage 有值)

整个过程你不需要写任何"切换界面"的命令式代码——ListenableBuilder 自动根据 ViewModel 的状态决定显示什么。


七、MVVM 完整回顾

至此,三层架构全部完成:

层级 类名 职责 课程
Model ArticleModel 发起 HTTP 请求,解析 JSON 第 10 课
ViewModel ArticleViewModel 管理状态(loading/summary/error),调用 notifyListeners 第 11 课
View ArticleView + ArticlePage + ArticleWidget 用 ListenableBuilder 监听变化,展示 UI 第 12 课

数据流动的完整链路:

API → ArticleModel.getRandomArticleSummary()
        → JSON → Summary 对象
            → ArticleViewModel.summary = ...
                → notifyListeners()
                    → ListenableBuilder 重新执行 builder
                        → switch 匹配状态
                            → 显示 ArticlePage + ArticleWidget

八、本节知识点小结

ListenableBuilder: 监听 ChangeNotifier 的 Widget。当被监听对象调用 notifyListeners() 时,自动重新执行 builder 函数重绘 UI。是连接 ViewModel 和 View 的关键组件。

switch 表达式处理状态: 将多个状态属性组合为元组,用模式匹配覆盖所有可能的组合。确保每种状态都有对应的 UI,不会遗漏。

条件渲染: 在 Column 的 children 列表中直接使用 if (condition) Widget(),只在条件为真时添加组件。适合处理可选数据(如文章图片、描述)。

声明式 UI: 你只需描述"每种状态下界面长什么样",Flutter 和 ListenableBuilder 自动处理状态切换和界面更新。不需要手动控制组件的显示/隐藏。


九、下一步学习

恭喜你完成了维基百科阅读器的全部功能!你已经掌握了 MVVM 架构、HTTP 请求、状态管理和响应式 UI 的核心知识。接下来的官方教程会进入 Flutter UI 102 进阶章节,学习自适应布局、高级滚动、导航等更深入的主题。

我们下篇文章见!

参考资料:Flutter 官方教程 - Use ListenableBuilder to update app UI

Vite 工程化实战 | 从 0 配置一个企业级前端项目(按需引入 / 环境变量 / 打包优化)

作者 代码煮茶
2026年3月14日 11:38

零、为什么我们要“折腾”环境?

还记得你第一次用 create-vue 脚手架时的感受吗?一行命令,项目就跑起来了,那叫一个爽!

但是!当你真正进入公司项目,你会发现:

// 理想中的项目
npm run dev // 启动,完事!

// 现实中的项目
npm run dev // 报错!Node版本不对
npm run build // 报错!内存溢出
npm run lint // 报错!代码格式不对
npm run test // 报错!环境变量没配

这时候你才明白:脚手架给你的是“毛坯房”,企业级项目需要的是“精装修”

今天,我们就从一个空文件夹开始,一步步搭建一个企业级 Vue3 + Vite 项目。这不是简单的“搭环境”,而是“搭项目”!

一、项目初始化:从零开始的艺术

1.1 创建项目(这次不用脚手架)

# 创建项目目录
mkdir vite-enterprise-demo
cd vite-enterprise-demo

# 初始化 package.json
npm init -y

# 安装核心依赖
npm install vue@latest
npm install -D vite @vitejs/plugin-vue typescript vue-tsc

# 创建项目结构
mkdir -p src/{assets,components,views,router,store,utils,styles,types}
touch index.html vite.config.ts tsconfig.json src/main.ts src/App.vue

现在的项目结构应该是这样:

vite-enterprise-demo/
├── src/
│   ├── assets/        # 静态资源
│   ├── components/     # 组件
│   ├── views/         # 页面
│   ├── router/        # 路由
│   ├── store/         # 状态管理
│   ├── utils/         # 工具函数
│   ├── styles/        # 全局样式
│   ├── types/         # TypeScript类型
│   ├── main.ts        # 入口文件
│   └── App.vue        # 根组件
├── index.html         # 入口HTML
├── vite.config.ts     # Vite配置
├── tsconfig.json      # TypeScript配置
└── package.json       # 项目配置

1.2 配置入口文件

<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <link rel="icon" href="/favicon.ico">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vite企业级项目实战</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'

// 创建应用实例
const app = createApp(App)

// 挂载应用
app.mount('#app')
<!-- src/App.vue -->
<template>
  <div class="app">
    <h1>🚀 Vite企业级项目实战</h1>
    <p>从0开始,搭建一个生产可用的项目</p>
  </div>
</template>

<script setup lang="ts">
// 这里写逻辑
</script>

<style scoped>
.app {
  text-align: center;
  padding: 2rem;
  color: #2c3e50;
}
</style>

1.3 配置 Vite

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  
  // 路径别名
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '@components': resolve(__dirname, 'src/components'),
      '@views': resolve(__dirname, 'src/views'),
      '@utils': resolve(__dirname, 'src/utils'),
      '@styles': resolve(__dirname, 'src/styles')
    }
  },
  
  // 开发服务器配置
  server: {
    port: 3000,
    open: true, // 自动打开浏览器
    cors: true, // 允许跨域
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/api/, '')
      }
    }
  },
  
  // 构建配置
  build: {
    target: 'es2015',
    outDir: 'dist',
    assetsDir: 'assets',
    assetsInlineLimit: 4096, // 小于4kb的图片转base64
    sourcemap: false, // 不生成sourcemap
    reportCompressedSize: false, // 关闭压缩大小报告
    chunkSizeWarningLimit: 500 // 块大小警告限制
  }
})
// package.json 添加脚本
{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "type-check": "vue-tsc --noEmit"
  }
}

试试运行:

npm run dev

看到页面了吗?恭喜!你已经从0开始搭建了一个Vite项目!

二、TypeScript 配置:告别 any 恐惧症

2.1 配置 tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"],
    "types": ["vite/client"],
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@views/*": ["src/views/*"],
      "@utils/*": ["src/utils/*"],
      "@styles/*": ["src/styles/*"]
    },
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
  "exclude": ["node_modules", "dist"]
}

2.2 添加类型声明

// src/types/shims-vue.d.ts
declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

// src/types/env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_APP_TITLE: string
  readonly VITE_API_BASE_URL: string
  readonly VITE_ENABLE_MOCK: string
  // 更多环境变量...
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

三、环境变量配置:一套代码,多套环境

3.1 环境变量文件

# .env                # 所有环境共用
# .env.local          # 本地覆盖(不提交)
# .env.development    # 开发环境
# .env.production     # 生产环境
# .env.test           # 测试环境
# .env.development
VITE_APP_TITLE=开发环境
VITE_API_BASE_URL=http://localhost:8080/api
VITE_ENABLE_MOCK=true
VITE_LOG_LEVEL=debug

# .env.production
VITE_APP_TITLE=生产环境
VITE_API_BASE_URL=https://api.example.com
VITE_ENABLE_MOCK=false
VITE_LOG_LEVEL=error

3.2 使用环境变量

// src/utils/config.ts
export const config = {
  appTitle: import.meta.env.VITE_APP_TITLE,
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
  enableMock: import.meta.env.VITE_ENABLE_MOCK === 'true',
  logLevel: import.meta.env.VITE_LOG_LEVEL,
  
  // 判断环境
  isDev: import.meta.env.DEV,
  isProd: import.meta.env.PROD,
  mode: import.meta.env.MODE
}

console.log('当前环境:', config.mode)
console.log('API地址:', config.apiBaseUrl)

四、路由配置:让页面"动"起来

4.1 安装路由

npm install vue-router@4

4.2 配置路由

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

// 路由配置
const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@views/Home.vue'),
    meta: {
      title: '首页',
      requiresAuth: false
    }
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('@views/About.vue'),
    meta: {
      title: '关于',
      requiresAuth: false
    }
  },
  {
    path: '/user',
    name: 'User',
    component: () => import('@views/User.vue'),
    meta: {
      title: '个人中心',
      requiresAuth: true
    }
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@views/404.vue')
  }
]

// 创建路由实例
const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

// 全局前置守卫
router.beforeEach((to, from, next) => {
  // 设置页面标题
  document.title = to.meta.title ? `${to.meta.title} - Vite企业级项目` : 'Vite企业级项目'
  
  // 检查是否需要登录
  if (to.meta.requiresAuth) {
    const token = localStorage.getItem('token')
    if (token) {
      next()
    } else {
      next({ path: '/login', query: { redirect: to.fullPath } })
    }
  } else {
    next()
  }
})

export default router

4.3 创建页面组件

<!-- src/views/Home.vue -->
<template>
  <div class="home">
    <h2>🏠 首页</h2>
    <p>欢迎来到首页!</p>
    <button @click="goToAbout">去关于页面</button>
  </div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'

const router = useRouter()
const goToAbout = () => {
  router.push('/about')
}
</script>

4.4 在 main.ts 中注册路由

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(router) // 注册路由

app.mount('#app')

五、状态管理:Pinia 来了

5.1 为什么不用 Vuex?

// Vuex 的写法
mutations: {
  SET_USER(state, user) {
    state.user = user
  }
},
actions: {
  async fetchUser({ commit }) {
    const user = await api.getUser()
    commit('SET_USER', user)
  }
}

// Pinia 的写法
export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async fetchUser() {
      this.user = await api.getUser()
    }
  }
})

Pinia 更简洁、更 TypeScript 友好、更模块化!

5.2 安装 Pinia

npm install pinia
npm install pinia-plugin-persistedstate # 持久化插件

5.3 创建 store

// src/store/index.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

export default pinia
// src/store/modules/user.ts
import { defineStore } from 'pinia'

interface UserState {
  token: string | null
  userInfo: {
    id?: number
    name?: string
    avatar?: string
    role?: string
  } | null
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    token: localStorage.getItem('token'),
    userInfo: null
  }),
  
  getters: {
    isLoggedIn: (state) => !!state.token,
    userName: (state) => state.userInfo?.name || '游客',
    userRole: (state) => state.userInfo?.role || 'guest'
  },
  
  actions: {
    // 登录
    async login(credentials: { username: string; password: string }) {
      try {
        // 模拟登录请求
        const response = await fetch('/api/login', {
          method: 'POST',
          body: JSON.stringify(credentials)
        })
        const data = await response.json()
        
        this.token = data.token
        this.userInfo = data.userInfo
        
        localStorage.setItem('token', data.token)
        
        return true
      } catch (error) {
        console.error('登录失败:', error)
        return false
      }
    },
    
    // 登出
    logout() {
      this.token = null
      this.userInfo = null
      localStorage.removeItem('token')
    },
    
    // 获取用户信息
    async fetchUserInfo() {
      if (!this.token) return
        
      try {
        const response = await fetch('/api/user/info', {
          headers: {
            Authorization: `Bearer ${this.token}`
          }
        })
        this.userInfo = await response.json()
      } catch (error) {
        console.error('获取用户信息失败:', error)
      }
    }
  },
  
  persist: {
    key: 'user-store',
    storage: localStorage,
    paths: ['token'] // 只持久化 token
  }
})
// src/store/modules/app.ts
import { defineStore } from 'pinia'

interface AppState {
  sidebarCollapsed: boolean
  theme: 'light' | 'dark'
  language: 'zh' | 'en'
}

export const useAppStore = defineStore('app', {
  state: (): AppState => ({
    sidebarCollapsed: false,
    theme: 'light',
    language: 'zh'
  }),
  
  getters: {
    isSidebarCollapsed: (state) => state.sidebarCollapsed,
    currentTheme: (state) => state.theme,
    currentLanguage: (state) => state.language
  },
  
  actions: {
    toggleSidebar() {
      this.sidebarCollapsed = !this.sidebarCollapsed
    },
    
    setTheme(theme: 'light' | 'dark') {
      this.theme = theme
      document.documentElement.setAttribute('data-theme', theme)
    },
    
    setLanguage(language: 'zh' | 'en') {
      this.language = language
    }
  },
  
  persist: true // 持久化整个 store
})

5.4 在组件中使用

<!-- src/views/User.vue -->
<template>
  <div class="user">
    <h2>👤 个人中心</h2>
    
    <div v-if="userStore.isLoggedIn">
      <p>欢迎回来,{{ userStore.userName }}!</p>
      <p>角色:{{ userStore.userRole }}</p>
      <button @click="handleLogout">退出登录</button>
    </div>
    
    <div v-else>
      <p>请先登录</p>
      <button @click="handleLogin">模拟登录</button>
    </div>
    
    <hr>
    
    <h3>应用设置</h3>
    <p>侧边栏状态: {{ appStore.isSidebarCollapsed ? '折叠' : '展开' }}</p>
    <button @click="appStore.toggleSidebar">切换侧边栏</button>
    
    <p>当前主题: {{ appStore.currentTheme }}</p>
    <button @click="appStore.setTheme('dark')">深色模式</button>
    <button @click="appStore.setTheme('light')">浅色模式</button>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import { useAppStore } from '@/store/modules/app'

const userStore = useUserStore()
const appStore = useAppStore()

const handleLogin = async () => {
  const success = await userStore.login({
    username: 'admin',
    password: '123456'
  })
  
  if (success) {
    alert('登录成功!')
  }
}

const handleLogout = () => {
  userStore.logout()
  alert('已退出登录')
}
</script>

六、UI 组件库集成:按需引入的艺术

6.1 安装 Element Plus

npm install element-plus
npm install -D unplugin-vue-components unplugin-auto-import

6.2 配置自动按需引入

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/types/auto-imports.d.ts',
      eslintrc: {
        enabled: true, // 生成 .eslintrc-auto-import.json
        filepath: './.eslintrc-auto-import.json'
      }
    }),
    Components({
      resolvers: [ElementPlusResolver()],
      dts: 'src/types/components.d.ts',
      dirs: ['src/components'] // 自动注册自己的组件
    })
  ]
})

6.3 自定义主题

// src/styles/element.scss
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary': (
      'base': #409eff,
    ),
    'success': (
      'base': #67c23a,
    ),
    'warning': (
      'base': #e6a23c,
    ),
    'danger': (
      'base': #f56c6c,
    ),
    'info': (
      'base': #909399,
    ),
  )
);

// 如果需要,可以导入所有样式
// @use "element-plus/theme-chalk/src/index.scss" as *;
// vite.config.ts 添加 CSS 配置
export default defineConfig({
  // ... 其他配置
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "@/styles/element.scss" as *;`
      }
    }
  }
})

6.4 在组件中使用

<template>
  <div>
    <el-button type="primary" @click="handleClick">
      主要按钮
    </el-button>
    
    <el-table :data="tableData" style="width: 100%">
      <el-table-column prop="date" label="日期" width="180" />
      <el-table-column prop="name" label="姓名" width="180" />
      <el-table-column prop="address" label="地址" />
    </el-table>
    
    <el-pagination
      v-model:current-page="currentPage"
      :page-size="pageSize"
      :total="total"
      layout="prev, pager, next"
    />
  </div>
</template>

<script setup lang="ts">
// 不用手动导入,自动按需引入!
const handleClick = () => {
  ElMessage.success('点击成功!')
}

const tableData = [
  { date: '2024-01-01', name: '张三', address: '北京市' }
]

const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(100)
</script>

七、HTTP 请求封装:让 API 调用更优雅

7.1 封装 axios

npm install axios
// src/utils/request.ts
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { useUserStore } from '@/store/modules/user'
import { ElMessage, ElMessageBox } from 'element-plus'

// 定义响应数据类型
export interface ApiResponse<T = any> {
  code: number
  data: T
  message: string
}

class Request {
  private instance: AxiosInstance
  
  constructor(config: AxiosRequestConfig) {
    this.instance = axios.create(config)
    
    // 请求拦截器
    this.instance.interceptors.request.use(
      (config) => {
        // 添加 token
        const userStore = useUserStore()
        if (userStore.token) {
          config.headers.Authorization = `Bearer ${userStore.token}`
        }
        
        // 开发环境打印请求信息
        if (import.meta.env.DEV) {
          console.log('🚀 请求:', config.method?.toUpperCase(), config.url)
          console.log('参数:', config.params || config.data)
        }
        
        return config
      },
      (error) => {
        return Promise.reject(error)
      }
    )
    
    // 响应拦截器
    this.instance.interceptors.response.use(
      (response: AxiosResponse<ApiResponse>) => {
        const { code, data, message } = response.data
        
        // 根据后端约定的 code 处理业务错误
        if (code !== 200) {
          ElMessage.error(message || '请求失败')
          return Promise.reject(new Error(message))
        }
        
        return data as any
      },
      (error) => {
        // 处理 HTTP 错误
        if (error.response) {
          switch (error.response.status) {
            case 401:
              handleUnauthorized()
              break
            case 403:
              ElMessage.error('没有权限访问')
              break
            case 404:
              ElMessage.error('请求的资源不存在')
              break
            case 500:
              ElMessage.error('服务器错误')
              break
            default:
              ElMessage.error(`请求失败: ${error.response.status}`)
          }
        } else if (error.request) {
          ElMessage.error('网络连接失败,请检查网络')
        } else {
          ElMessage.error('请求配置错误')
        }
        
        return Promise.reject(error)
      }
    )
  }
  
  // 统一请求方法
  public request<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.instance.request(config)
  }
  
  // GET 请求
  public get<T = any>(url: string, params?: any): Promise<T> {
    return this.instance.get(url, { params })
  }
  
  // POST 请求
  public post<T = any>(url: string, data?: any): Promise<T> {
    return this.instance.post(url, data)
  }
  
  // PUT 请求
  public put<T = any>(url: string, data?: any): Promise<T> {
    return this.instance.put(url, data)
  }
  
  // DELETE 请求
  public delete<T = any>(url: string, params?: any): Promise<T> {
    return this.instance.delete(url, { params })
  }
  
  // 上传文件
  public upload<T = any>(url: string, file: File, onProgress?: (progress: number) => void): Promise<T> {
    const formData = new FormData()
    formData.append('file', file)
    
    return this.instance.post(url, formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      onUploadProgress: (progressEvent) => {
        if (onProgress && progressEvent.total) {
          const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
          onProgress(percentCompleted)
        }
      }
    })
  }
  
  // 下载文件
  public download(url: string, filename?: string): Promise<void> {
    return this.instance.get(url, {
      responseType: 'blob'
    }).then(response => {
      const blob = new Blob([response as any])
      const downloadUrl = window.URL.createObjectURL(blob)
      const link = document.createElement('a')
      link.href = downloadUrl
      link.download = filename || 'download'
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)
      window.URL.revokeObjectURL(downloadUrl)
    })
  }
}

// 处理未授权
function handleUnauthorized() {
  ElMessageBox.confirm('登录已过期,请重新登录', '提示', {
    confirmButtonText: '去登录',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    const userStore = useUserStore()
    userStore.logout()
    window.location.href = '/login'
  })
}

// 创建实例
const request = new Request({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json;charset=UTF-8'
  }
})

export default request

7.2 封装 API 模块

// src/api/user.ts
import request from '@/utils/request'

export interface LoginParams {
  username: string
  password: string
}

export interface UserInfo {
  id: number
  name: string
  avatar: string
  role: string
  permissions: string[]
}

export const userApi = {
  // 登录
  login(data: LoginParams) {
    return request.post<{ token: string; userInfo: UserInfo }>('/user/login', data)
  },
  
  // 获取用户信息
  getUserInfo() {
    return request.get<UserInfo>('/user/info')
  },
  
  // 获取用户列表
  getUserList(params: { page: number; limit: number }) {
    return request.get<{ list: UserInfo[]; total: number }>('/user/list', params)
  },
  
  // 更新用户信息
  updateUserInfo(id: number, data: Partial<UserInfo>) {
    return request.put(`/user/${id}`, data)
  },
  
  // 删除用户
  deleteUser(id: number) {
    return request.delete(`/user/${id}`)
  }
}

八、代码规范:让团队代码像一个人写的

8.1 安装 ESLint + Prettier

npm install -D eslint prettier eslint-plugin-vue @vue/eslint-config-typescript
npm install -D @typescript-eslint/eslint-plugin @typescript-eslint/parser
npm install -D eslint-config-prettier eslint-plugin-prettier

8.2 配置 ESLint

// .eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    es2021: true,
    node: true
  },
  extends: [
    'plugin:vue/vue3-recommended',
    '@vue/typescript/recommended',
    'plugin:prettier/recommended'
  ],
  parserOptions: {
    ecmaVersion: 2021,
    parser: '@typescript-eslint/parser',
    sourceType: 'module'
  },
  plugins: ['@typescript-eslint'],
  rules: {
    // 自定义规则
    'vue/multi-word-component-names': 'off',
    '@typescript-eslint/no-explicit-any': 'warn',
    '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
  },
  globals: {
    defineProps: 'readonly',
    defineEmits: 'readonly',
    defineExpose: 'readonly',
    withDefaults: 'readonly'
  }
}

8.3 配置 Prettier

// .prettierrc
{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "none",
  "printWidth": 100,
  "vueIndentScriptAndStyle": true,
  "endOfLine": "auto"
}

8.4 配置 Husky + lint-staged

npm install -D husky lint-staged

# 初始化 husky
npx husky install

# 添加 pre-commit 钩子
npx husky add .husky/pre-commit "npx lint-staged"
// package.json 添加 lint-staged 配置
{
  "lint-staged": {
    "*.{js,ts,vue}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{css,scss,html,json,md}": [
      "prettier --write"
    ]
  }
}

九、打包优化:让项目飞起来

9.1 配置打包分析

npm install -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    // ... 其他插件
    visualizer({
      filename: 'dist/stats.html',
      open: true,
      gzipSize: true,
      brotliSize: true
    })
  ]
})

9.2 代码分割优化

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 手动分包
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 将 vue、vue-router、pinia 打包在一起
            if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
              return 'vendor-vue'
            }
            
            // 将 element-plus 单独打包
            if (id.includes('element-plus')) {
              return 'vendor-element'
            }
            
            // 其他依赖打包在一起
            return 'vendor-other'
          }
        },
        
        // 自定义 chunk 文件名
        chunkFileNames: 'assets/js/[name]-[hash].js',
        entryFileNames: 'assets/js/[name]-[hash].js',
        assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
      }
    },
    
    // 启用/禁用 CSS 代码分割
    cssCodeSplit: true,
    
    // 设置资源大小限制
    assetsInlineLimit: 4096
  }
})

9.3 图片压缩

npm install -D vite-plugin-imagemin
// vite.config.ts
import viteImagemin from 'vite-plugin-imagemin'

export default defineConfig({
  plugins: [
    // ... 其他插件
    viteImagemin({
      gifsicle: {
        optimizationLevel: 7,
        interlaced: false
      },
      optipng: {
        optimizationLevel: 7
      },
      mozjpeg: {
        quality: 80
      },
      pngquant: {
        quality: [0.8, 0.9],
        speed: 4
      },
      svgo: {
        plugins: [
          {
            name: 'removeViewBox'
          },
          {
            name: 'removeEmptyAttrs',
            active: false
          }
        ]
      }
    })
  ]
})

9.4 打包进度条

npm install -D vite-plugin-progress
// vite.config.ts
import progress from 'vite-plugin-progress'

export default defineConfig({
  plugins: [
    // ... 其他插件
    progress()
  ]
})

9.5 压缩打包结果

npm install -D vite-plugin-compression
// vite.config.ts
import viteCompression from 'vite-plugin-compression'

export default defineConfig({
  plugins: [
    // ... 其他插件
    viteCompression({
      verbose: true,
      disable: false,
      threshold: 10240, // 大于10kb才压缩
      algorithm: 'gzip',
      ext: '.gz'
    })
  ]
})

十、完整的 vite.config.ts

// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { visualizer } from 'rollup-plugin-visualizer'
import viteImagemin from 'vite-plugin-imagemin'
import progress from 'vite-plugin-progress'
import viteCompression from 'vite-plugin-compression'

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
  // 加载环境变量
  const env = loadEnv(mode, process.cwd())
  
  return {
    plugins: [
      vue(),
      
      // 自动导入
      AutoImport({
        resolvers: [ElementPlusResolver()],
        imports: ['vue', 'vue-router', 'pinia'],
        dts: 'src/types/auto-imports.d.ts',
        eslintrc: {
          enabled: true,
          filepath: './.eslintrc-auto-import.json'
        }
      }),
      
      // 自动注册组件
      Components({
        resolvers: [ElementPlusResolver()],
        dts: 'src/types/components.d.ts',
        dirs: ['src/components']
      }),
      
      // 图片压缩
      viteImagemin({
        optipng: { optimizationLevel: 7 },
        mozjpeg: { quality: 80 },
        pngquant: { quality: [0.8, 0.9], speed: 4 },
        svgo: { plugins: [{ name: 'removeViewBox' }] }
      }),
      
      // 打包进度条
      progress(),
      
      // gzip压缩
      viteCompression({
        threshold: 10240,
        algorithm: 'gzip',
        ext: '.gz'
      }),
      
      // 打包分析(只在分析时开启)
      ...(mode === 'analyze' ? [visualizer({
        filename: 'dist/stats.html',
        open: true,
        gzipSize: true,
        brotliSize: true
      })] : [])
    ],
    
    // 路径别名
    resolve: {
      alias: {
        '@': resolve(__dirname, 'src'),
        '@components': resolve(__dirname, 'src/components'),
        '@views': resolve(__dirname, 'src/views'),
        '@utils': resolve(__dirname, 'src/utils'),
        '@styles': resolve(__dirname, 'src/styles')
      }
    },
    
    // CSS 配置
    css: {
      preprocessorOptions: {
        scss: {
          additionalData: `@use "@/styles/element.scss" as *;`
        }
      }
    },
    
    // 开发服务器配置
    server: {
      port: 3000,
      open: true,
      cors: true,
      proxy: {
        '/api': {
          target: env.VITE_API_BASE_URL,
          changeOrigin: true,
          rewrite: (path) => path.replace(/^/api/, '')
        }
      }
    },
    
    // 构建配置
    build: {
      target: 'es2015',
      outDir: 'dist',
      assetsDir: 'assets',
      assetsInlineLimit: 4096,
      sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
      reportCompressedSize: false,
      chunkSizeWarningLimit: 500,
      
      rollupOptions: {
        output: {
          manualChunks(id) {
            if (id.includes('node_modules')) {
              if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
                return 'vendor-vue'
              }
              if (id.includes('element-plus')) {
                return 'vendor-element'
              }
              return 'vendor-other'
            }
          },
          chunkFileNames: 'assets/js/[name]-[hash].js',
          entryFileNames: 'assets/js/[name]-[hash].js',
          assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
        }
      }
    }
  }
})

十一、package.json 完整配置

{
  "name": "vite-enterprise-demo",
  "version": "1.0.0",
  "description": "Vite企业级项目实战",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "build:analyze": "vue-tsc && vite build --mode analyze",
    "preview": "vite preview",
    "type-check": "vue-tsc --noEmit",
    "lint": "eslint . --ext .vue,.js,.ts --fix",
    "format": "prettier --write 'src/**/*.{vue,js,ts,css,scss}'",
    "prepare": "husky install"
  },
  "dependencies": {
    "axios": "^1.6.0",
    "element-plus": "^2.4.0",
    "pinia": "^2.1.0",
    "pinia-plugin-persistedstate": "^3.2.0",
    "vue": "^3.3.0",
    "vue-router": "^4.2.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "@typescript-eslint/eslint-plugin": "^6.0.0",
    "@typescript-eslint/parser": "^6.0.0",
    "@vitejs/plugin-vue": "^4.0.0",
    "@vue/eslint-config-typescript": "^12.0.0",
    "eslint": "^8.0.0",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-prettier": "^5.0.0",
    "eslint-plugin-vue": "^9.0.0",
    "husky": "^8.0.0",
    "lint-staged": "^15.0.0",
    "prettier": "^3.0.0",
    "rollup-plugin-visualizer": "^5.9.0",
    "sass": "^1.69.0",
    "typescript": "^5.0.0",
    "unplugin-auto-import": "^0.16.0",
    "unplugin-vue-components": "^0.25.0",
    "vite": "^4.0.0",
    "vite-plugin-compression": "^0.5.0",
    "vite-plugin-imagemin": "^0.6.0",
    "vite-plugin-progress": "^0.0.7",
    "vue-tsc": "^1.8.0"
  }
}

十二、总结:从“搭环境”到“搭项目”

回顾一下,我们做了什么:

  1. 项目初始化:从空文件夹开始,手动搭建了项目结构
  2. TypeScript配置:告别 any,拥抱类型安全
  3. 环境变量:一套代码,多套环境
  4. 路由配置:让页面动起来
  5. Pinia状态管理:比 Vuex 更香的选择
  6. Element Plus 按需引入:告别全量引入的臃肿
  7. Axios封装:统一的请求处理
  8. 代码规范:ESLint + Prettier + Husky
  9. 打包优化:代码分割、图片压缩、gzip

现在,你拥有的是一个真正的企业级项目模板,而不仅仅是一个“能跑的项目”。

为什么这很重要?

// 面试官问:你们的项目是怎么配置的?
// 初级回答:用的 create-vue 脚手架
// 中级回答:配置了路由、状态管理、按需引入
// 高级回答:从零搭建了完整的工程化体系,包括代码规范、打包优化、环境管理等

当你能够从零搭建一个企业级项目,你就掌握了前端工程化的核心能力。这不仅是一份工作,更是一种将想法转化为产品的能力。

下一步做什么?

  1. 添加单元测试:Vitest + Vue Test Utils
  2. 配置 CI/CD:GitHub Actions 自动部署
  3. 添加 Mock 服务:开发环境模拟数据
  4. 性能监控:集成 Sentry 等错误监控
  5. 文档生成:使用 VitePress 生成组件文档

记住:好的工程化不是一蹴而就的,而是在实践中不断完善的。现在,带着这个模板去创建你的下一个项目吧!🚀

Flexbox 完全指南:从此告别浮动,拥抱一维战神

作者 kyriewen
2026年3月14日 11:35

还在用float做导航?还在为垂直居中写positiontransform?今天我们来认识一位布局界的“一维战神”——Flexbox。它专治各种居中、等分、排列难题,让你写布局像搭积木一样简单。

前言

回想那些年被float支配的日子:清浮动要写clearfix,垂直居中要算半天,几个元素等宽还得用百分比小心翼翼……直到Flexbox的出现,前端布局才真正迎来了春天。

Flexbox的全称是Flexible Box Layout Module,翻译过来就是“弹性盒子”。它的核心思想是:让容器有能力改变子项的宽度、高度、顺序,以最好地填充可用空间。尤其擅长处理一维布局(也就是一行或一列)。今天我们就来彻底掌握这个“一维战神”。

一、Flexbox的两大核心:容器与项目

要使用Flexbox,你只需要在父元素上设置display: flexdisplay: inline-flex。这时,父元素成为flex容器,它的直接子元素自动成为flex项目

.container {
  display: flex;  /* 容器开启flex模式 */
}

就像一支军队有了指挥官,所有士兵(项目)都听从容器(指挥官)的调遣。

二、轴:Flexbox的方向感

Flexbox里有两条轴:主轴交叉轴,所有排列都围绕这两条轴进行。

  • 主轴:默认水平方向,从左到右。你可以通过flex-direction改变它的方向。
  • 交叉轴:始终垂直于主轴。

想象你手里拿着一排士兵,你可以命令他们横着站(主轴水平),也可以竖着站(主轴垂直),甚至可以倒着站。这就是flex-direction的作用。

.container {
  flex-direction: row;            /* 默认值,主轴水平,从左到右 */
  flex-direction: row-reverse;    /* 主轴水平,从右到左 */
  flex-direction: column;         /* 主轴垂直,从上到下 */
  flex-direction: column-reverse; /* 主轴垂直,从下到上 */
}

三、主轴上的排列:justify-content

justify-content控制项目在主轴上的对齐方式。这是最常用的属性之一。

.container {
  justify-content: flex-start;    /* 默认,左对齐/上对齐 */
  justify-content: flex-end;      /* 右对齐/下对齐 */
  justify-content: center;        /* 居中 */
  justify-content: space-between; /* 两端对齐,项目之间间距相等 */
  justify-content: space-around;  /* 每个项目两侧间距相等 */
  justify-content: space-evenly;  /* 项目之间间距相等,边缘间距也是项目间距的一半?不,是均匀分布,包括两端 */
}

其中space-betweenspace-evenly尤其好用:一个让首尾贴边,中间均分;一个让所有间隙相等,包括两端。

四、交叉轴上的对齐:align-items 与 align-content

1. align-items:单行项目的交叉轴对齐

当所有项目在一行(或一列)时,用align-items控制它们在交叉轴上的对齐方式。

.container {
  align-items: stretch;   /* 默认,如果项目未设置高度,则拉伸填满容器 */
  align-items: flex-start; /* 交叉轴起点对齐 */
  align-items: flex-end;   /* 交叉轴终点对齐 */
  align-items: center;     /* 交叉轴居中 */
  align-items: baseline;   /* 按第一行文字基线对齐 */
}

这个属性就是垂直居中的神器:只要容器有高度,设置align-items: center,项目就能垂直居中(当然主轴方向得是row)。

2. align-content:多行项目的整体对齐

当容器在交叉轴方向有多余空间,且项目有多行时,用align-content控制多行整体的对齐方式。它和justify-content类似,只不过作用于交叉轴。

.container {
  flex-wrap: wrap;        /* 先允许换行 */
  align-content: stretch;   /* 默认,拉伸占满 */
  align-content: flex-start;
  align-content: flex-end;
  align-content: center;
  align-content: space-between;
  align-content: space-around;
  align-content: space-evenly;
}

注意:如果项目只有一行,align-content不起作用。

五、项目的灵活性:flex 相关属性

项目自己也可以设置属性,控制自己的尺寸、排列顺序等。

1. flex-grow:如何分剩余空间

当容器还有剩余空间时,flex-grow决定项目是否放大、放大多少。默认值为0,即不放大。如果所有项目都设为1,则它们等分剩余空间;如果一个为2,其他为1,则2的那个多占一倍。

.item {
  flex-grow: 1;   /* 所有项目等分剩余空间 */
}

2. flex-shrink:空间不够时如何缩小

当容器空间不足时,flex-shrink决定项目是否缩小、缩小多少。默认值为1,即所有项目等比例缩小。设为0的项目不会缩小。

.item {
  flex-shrink: 0;   /* 打死我也不缩小 */
}

3. flex-basis:项目的基础尺寸

flex-basis定义项目在分配空间前的默认尺寸,可以理解为在主轴上的“初始宽度”(主轴水平时)。优先级高于width(如果同时设置)。默认值为auto,即参考项目本身的尺寸。

.item {
  flex-basis: 200px;   /* 我希望基础宽度是200px */
}

4. flex 简写

通常我们会用flex属性将上面三个合起来写:flex: grow shrink basis。常见值:

  • flex: 1 = flex: 1 1 0%(等分剩余空间)
  • flex: auto = flex: 1 1 auto(根据内容分配空间)
  • flex: none = flex: 0 0 auto(固定尺寸,不弹性)

六、项目的排序与对齐覆盖

1. order:改变项目顺序

默认所有项目的order为0,按源码顺序排列。你可以给某个项目设置更大的order让它往后排,或更小的order让它往前排。支持负数。

.item:last-child {
  order: -1;   /* 最后一个变成第一个 */
}

2. align-self:覆盖容器的 align-items

如果你想单独改变某个项目在交叉轴上的对齐方式,可以用align-self,它的取值和align-items一样。

.item.special {
  align-self: flex-end;   /* 单独沉底 */
}

七、实战:常见的Flexbox布局套路

1. 水平垂直居中

最简单的居中方案:

.parent {
  display: flex;
  justify-content: center;
  align-items: center;
}

无论子元素是一个还是多个,都能完美居中。

2. 导航栏:Logo左,菜单中,登录右

<nav class="nav">
  <div class="logo">Logo</div>
  <ul class="menu">
    <li>首页</li>
    <li>产品</li>
    <li>关于</li>
  </ul>
  <div class="login">登录</div>
</nav>
.nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.menu {
  display: flex;
  gap: 20px;
  list-style: none;
}

如果想菜单绝对居中(不受左右宽度影响),可以给.menumargin: 0 auto

3. 等分布局

比如三个卡片等宽,间距固定:

.container {
  display: flex;
  gap: 20px;
}
.item {
  flex: 1;   /* 三个项目等分剩余空间,宽度相等 */
}

4. 圣杯布局(经典三栏)

左右固定宽度,中间自适应:

.container {
  display: flex;
}
.left {
  width: 200px;
}
.right {
  width: 200px;
}
.main {
  flex: 1;   /* 中间占满剩余空间 */
}

5. 底栏自动贴底

页面内容不足时,footer贴在底部;内容多时,footer被推下:

<body style="display: flex; flex-direction: column; min-height: 100vh;">
  <header>...</header>
  <main style="flex: 1;">...</main>
  <footer>...</footer>
</body>

八、常见坑点与避坑指南

1. 浮动失效

一旦元素成为flex项目,它的floatclearvertical-align都会失效。所以放心用flex,不用再担心浮动了。

2. margin: auto 的妙用

在flex容器中,设置某个项目的margin: auto,它会自动吸收剩余空间,实现“推挤”效果。例如让一个项目单独靠右:

.container {
  display: flex;
}
.item.move-right {
  margin-left: auto;   /* 把自己挤到右边 */
}

3. 文本溢出省略号

在flex项目中设置文本省略号时,可能需要给项目设置min-width: 0overflow: hidden,因为flex项目默认不会缩小到内容最小宽度以下。

.item {
  min-width: 0;        /* 允许项目缩小到比内容宽度小 */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

4. gap 属性

gap是较新的属性,可以方便地设置项目之间的间距,不用再为margin头疼。支持row-gapcolumn-gap,也可简写gap: 10px 20px

.container {
  display: flex;
  gap: 20px;   /* 项目之间左右、上下间距都是20px(如果换行) */
}

九、总结

Flexbox是现代布局的基石,掌握它,你就能轻松应对绝大多数一维布局场景。再回顾一下核心:

  • 容器设置display: flex,开启弹性世界。
  • flex-direction定主轴,用justify-content定主轴排列,用align-items定单行交叉轴对齐。
  • 项目用flex控制弹性,用order改变顺序,用align-self独立对齐。
  • 记住几个常用套路:居中、等分、导航、贴底。
  • 避坑:浮动失效、margin auto推挤、最小宽度限制。

Flexbox并不难,关键是理解“主轴”和“剩余空间分配”这两个概念。多动手写几个例子,你就能成为布局大师。

如果你喜欢这篇文章,欢迎点赞、收藏、分享。明天我们接着讲Grid布局——二维世界的终极武器,敬请期待!


明日预告:Grid网格布局从入门到精通——用网格思想重构网页,让二维布局不再头疼。

vue-router 5.x RouterView 组件是如何实现?

作者 米丘
2026年3月14日 11:32

RouterView 是 Vue Router 提供的「路由视图占位符组件」,本质是渲染函数组件。 支持嵌套路由、命名视图、插槽自定义渲染、路由 props 传递等。

RouterView

const RouterView = RouterViewImpl as unknown as {
  new (): {
    // AllowedComponentPropsVue 允许的基础组件属性(如 id/style)
    $props: AllowedComponentProps &
     // 全局自定义 Props(如 app.config.globalProperties 扩展的属性)
      ComponentCustomProps &
      VNodeProps & // Vue 内置 VNode 属性(如 key/ref/class)
      RouterViewProps // <RouterView> 专属 Props

    $slots: {
      default?: ({
        Component, // 当前路由匹配的组件 VNode(
        route, // 当前激活的标准化路由信息
      }: {
        Component: VNode
        route: RouteLocationNormalizedLoaded // 手动指定渲染的路由(默认用当前路由)
      }) => VNode[]
    }
  }
}

RouterViewImpl

做了什么?

  1. 注入路由上下文:通过 inject 获取 routerViewLocationKey(当前激活路由)和 viewDepthKey(嵌套深度)。
  2. 计算匹配深度:根据 depth 从路由 matched 数组中筛选有效路由记录(跳过无 components 的空路由)。
  3. 获取组件:根据命名视图(props.name)从匹配的路由记录 components 中获取组件。
  4. 渲染组件:通过 h 函数创建组件 VNode,支持插槽自定义渲染,最终返回渲染结果。
export const RouterViewImpl = /*#__PURE__*/ defineComponent({
  name: 'RouterView',
  // #674 we manually inherit them
  inheritAttrs: false, // 禁用属性继承,避免 attrs 透传到子组件(手动控制)
  props: {
    // 命名视图名称
    name: {
      type: String as PropType<string>,  
      default: 'default',
    },
    // 手动指定渲染的路由
    route: Object as PropType<RouteLocationNormalizedLoaded>,
  },

  // Better compat for @vue/compat users
  // https://github.com/vuejs/router/issues/1315
  // 兼容 @vue/compat 模式
  compatConfig: { MODE: 3 },

  // Setup 阶段:核心依赖注入 + 响应式计算
  setup(props, { attrs, slots }) {
    __DEV__ && warnDeprecatedUsage()

    // 获取路由信息
    // Vue Router 插件在应用初始化时通过 app.provide(routerViewLocationKey, 路由信息) 注册
    const injectedRoute = inject(routerViewLocationKey)!
    // 获取最后要渲染的路由
    const routeToDisplay = computed<RouteLocationNormalizedLoaded>(
      // 若父组件给 <RouterView> 传入了 route props(如 <RouterView :route="customRoute" />),则使用该自定义路由
      () => props.route || injectedRoute.value
    )

    // 父级传递的深度(默认 0)
    const injectedDepth = inject(viewDepthKey, 0)

    // The depth changes based on empty components option, which allows passthrough routes e.g. routes with children
    // that are used to reuse the `path` property
    // 响应式计算:确定渲染的路由与深度
    const depth = computed<number>(() => {
      let initialDepth = unref(injectedDepth) // 解包初始深度(处理响应式值)

      // 获取当前要渲染的路由的 matched 数组(嵌套路由记录)
      const { matched } = routeToDisplay.value
      let matchedRoute: RouteLocationMatched | undefined
      while (
        (matchedRoute = matched[initialDepth]) &&
        !matchedRoute.components
      ) {
        initialDepth++ // 跳过空路由,深度 +1
      }
      return initialDepth // 返回最终实际渲染深度
    })

    // 根据深度获取要渲染的路由记录
    const matchedRouteRef = computed<RouteLocationMatched | undefined>(
      () => routeToDisplay.value.matched[depth.value]
    )

    // 注入当前深度 + 1 作为子视图的深度
    provide(
      viewDepthKey,
      computed(() => depth.value + 1)
    )
    provide(matchedRouteKey, matchedRouteRef) // 注入当前匹配的路由记录
    provide(routerViewLocationKey, routeToDisplay)  // 注入当前路由信息

    const viewRef = ref<ComponentPublicInstance>()

    // watch at the same time the component instance, the route record we are
    // rendering, and the name
    // 实例管理:监听组件实例 & 路由守卫回调
    watch(
      // 监听<RouterView> 渲染的组件实例
      // 监听当前匹配的路由记录
      // 监听命名视图名称
      () => [viewRef.value, matchedRouteRef.value, props.name] as const,
      // 新值:[instance, to, name](新组件实例、新路由记录、当前视图名称)
      // 旧值:[oldInstance, from, oldName](旧组件实例、旧路由记录、旧视图名称)
      ([instance, to, name], [oldInstance, from, oldName]) => {
        // copy reused instances
        if (to) {
          // this will update the instance for new instances as well as reused
          // instances when navigating to a new route
          to.instances[name] = instance
          // the component instance is reused for a different route or name, so
          // we copy any saved update or leave guards. With async setup, the
          // mounting component will mount before the matchedRoute changes,
          // making instance === oldInstance, so we check if guards have been
          // added before. This works because we remove guards when
          // unmounting/deactivating components
          // 组件实例被复用于不同路由/名称的场景:拷贝守卫
          if (from && from !== to && instance && instance === oldInstance) {
             // 拷贝离开守卫(leaveGuards)
            if (!to.leaveGuards.size) {
              to.leaveGuards = from.leaveGuards
            }
            // 拷贝更新守卫(updateGuards)
            if (!to.updateGuards.size) {
              to.updateGuards = from.updateGuards
            }
          }
        }

        // trigger beforeRouteEnter next callbacks
        if (
          instance &&
          to &&
          // if there is no instance but to and from are the same this might be
          // the first visit
          // 无旧实例/新旧路由记录不同/无旧路由记录 → 首次访问/路由切换
          (!from || !isSameRouteRecord(to, from) || !oldInstance)
        ) {
          // 触发 beforeRouteEnter 回调
          ;(to.enterCallbacks[name] || []).forEach(callback =>
            callback(instance)
          )
        }
      },
      { flush: 'post' } // 后置刷新:DOM 更新后执行
    )

    // 渲染阶段:生成组件 VNode & 处理插槽
    return () => {
      const route = routeToDisplay.value // 获取当前要渲染的路
      // we need the value at the time we render because when we unmount, we
      // navigated to a different location so the value is different
      const currentName = props.name // 保存渲染时的命名视图名称(
      const matchedRoute = matchedRouteRef.value // 获取匹配的路由记录

      // 按命名视图获取要渲染的组件
      const ViewComponent =
        matchedRoute && matchedRoute.components![currentName]

      // 无匹配组件:渲染默认插槽(传递空 Component 和路由)
      if (!ViewComponent) {
        return normalizeSlot(slots.default, { Component: ViewComponent, route })
      }

      // props from route configuration
      // 从路由记录中获取命名视图对应的 props 配置
      const routePropsOption = matchedRoute.props[currentName]

      // 解析路由 props:支持 4 种配置方式
      const routeProps = routePropsOption
      // 方式1:true → 传递 route.params 作为 props
        ? routePropsOption === true
          ? route.params
          : typeof routePropsOption === 'function'
          // 方式2:函数 → 执行函数返回 props
            ? routePropsOption(route)
            // 方式3:对象 → 直接传递该对象
            : routePropsOption
          // 方式4:无配置 → 不传 props
        : null

      const onVnodeUnmounted: VNodeProps['onVnodeUnmounted'] = vnode => {
        // remove the instance reference to prevent leak
         // 组件已卸载时,清空路由记录中的实例引用
        if (vnode.component!.isUnmounted) {
          matchedRoute.instances[currentName] = null
        }
      }

      // 创建组件 VNode
      const component = h(
        ViewComponent,  // 要渲染的组件
        // 合并 props:路由 props → 组件 attrs → 自定义属性
        assign({}, routeProps, attrs, {
          onVnodeUnmounted,
          ref: viewRef,
        })
      )

      if (
        (__DEV__ || __FEATURE_PROD_DEVTOOLS__) &&
        isBrowser &&
        component.ref
      ) {
        // TODO: can display if it's an alias, its props
        const info: RouterViewDevtoolsContext = {
          depth: depth.value,
          name: matchedRoute.name,
          path: matchedRoute.path,
          meta: matchedRoute.meta,
        }

        const internalInstances = isArray(component.ref)
          ? component.ref.map(r => r.i)
          : [component.ref.i]

        internalInstances.forEach(instance => {
          // @ts-expect-error
          instance.__vrv_devtools = info
        })
      }

      // 优先插槽(传递 Component VNode 和 route),否则直接渲染组件
      return (
        // pass the vnode to the slot as a prop.
        // h and <component :is="..."> both accept vnodes
        normalizeSlot(slots.default, { Component: component, route }) ||
        component
      )
    }
  },
})
/**
 * 标准化 Vue 作用域插槽(Scoped Slot)输出
 * @param slot Vue 中的作用域插槽(Scoped Slot) 本质是一个函数
 * @param data 作用域数据,包含 Component VNode 和 route
 * @returns 标准化后的插槽内容(VNode/VNode 数组)
 */
function normalizeSlot(slot: Slot | undefined, data: any) {
  if (!slot) return null

  // 执行插槽函数,传入作用域数据,获取插槽内容(VNode/VNode 数组)
  const slotContent = slot(data)

  // 标准化返回值:单元素数组 → 单个 VNode,否则返回原数组
  return slotContent.length === 1 ? slotContent[0] : slotContent
}

image.png

应用场景有哪些?

1、默认路由渲染。

最基础的用法,无任何自定义配置,自动渲染当前激活路由对应的组件。

2、命名视图:多组件同时渲染。

一个路由匹配多个组件,通过 name 区分不同 RouterView,实现「布局拆分」。

  routes: [
    {
      path: '/',
      name: 'index',
      alias: ['/home'],
      // HomeView 组件内包含 <router-view> 和 <router-view name="dashboard">
      component: () => import('@/views/home/HomeView.vue'),
      children:[{
        path: '',
        components: {
          default: () => import('@/views/home/MainCard.vue'),
          dashboard: () => import('@/views/home/DashBoard.vue'),
       }
      }]
    }

src/views/home/HomeView.vue

<template>
  <div>
    <router-view></router-view>
    <router-view name="dashboard"></router-view>
  </div>
</template>

3、嵌套路由:多层级路由渲染。

路由嵌套(如「用户列表 → 用户详情」),父组件中嵌套 RouterView 渲染子路由组件,需配合路由配置的 children 字段。

    {
      path: '/user',
      name: 'user',
      component: () => import('@/views/user/UserView.vue'),
      children: [
        {
          path: 'lists',
          name: 'user-list',
          component: () => import('@/views/user/UserList.vue')
        },
        {
          path: ':id',
          // name: 'user-detail',
          component: () => import('@/views/user/UserDetail.vue')
        }
      ]
    }
  • /user/lists
  • list/1list/2
<template>
  <div>
    <p>User View</p>
    <RouterView></RouterView>
  </div>
</template>

4、插槽自定义:包装路由组件。

通过 RouterView 的默认插槽,自定义路由组件的渲染逻辑(如添加加载动画、过渡效果、错误边界)。

  <div class="manage-page">
    <RouterView v-slot="{ Component, route }">
      <component :is="Component" :key="route.path" />
    </RouterView>
  </div>

最后

  1. github github.com/hannah-lin-…
  2. vue-router router.vuejs.org/zh/guide/ad…

easy-model 实战:跨组件通信、监听与异步加载,一库搞定 React 状态难题

作者 张一凡93
2026年3月14日 10:59

在 React 开发中,状态管理往往是痛点:Redux 太重,Zustand 太轻,MobX 学习成本高。今天分享一个平衡的选择:easy-model,一个基于类模型的工具集。它不仅简化了状态管理,还内置 IoC 和监听能力。让我通过几个实战场景带大家看看它的威力。

场景1:跨组件通信(实例共享)

import { useModel } from "easy-model";

class CommunicateModel {
  constructor(public name: string) {}
  value = 0;
  random() {
    this.value = Math.random();
  }
}

function CommunicateA() {
  const { value, random } = useModel(CommunicateModel, ["channel"]);
  return (
    <div>
      <span>组件 A:{value}</span>
      <button onClick={random}>改变数值</button>
    </div>
  );
}

function CommunicateB() {
  const { value } = useModel(CommunicateProvider, ["channel"]);
  return <div>组件 B:{value}</div>;
}

组件 A 改变数值,组件 B 立刻更新。天然支持「按业务 key 分区」状态。

场景2:精细化监听(watch 与 offWatch)

监听模型变化,跳过某些字段以优化性能:

import { watch, offWatch } from "easy-model";

class WatchModel {
  constructor(public name: string) {}
  value = 0;
  @offWatch
  internalCounter = 0; // 跳过监听,提高性能

  increment() {
    this.value += 1;
    this.internalCounter += 1;
  }
}

const inst = provide(WatchModel)("demo");
const stop = watch(inst, (keys, prev, next) => {
  console.log(`${keys.join(".")}: ${prev} -> ${next}`);
});

inst.increment(); // 只输出 value 的变更
stop();

适合日志记录、外部同步等场景。

场景3:异步加载管理(loader 与 useLoader)

统一处理 loading 状态:

import { loader, useLoader, useModel } from "easy-model";

class LoaderModel {
  constructor(public name: string) {}

  @loader.load(true)
  async fetch() {
    return new Promise<number>(resolve =>
      setTimeout(() => resolve(42), 1000)
    );
  }
}

function LoaderDemo() {
  const { isGlobalLoading, isLoading } = useLoader();
  const inst = useModel(LoaderModel, ["demo"]);

  return (
    <div>
      <div>全局加载:{String(isGlobalLoading)}</div>
      <div>当前方法加载:{String(isLoading(inst.fetch))}</div>
      <button onClick={() => inst.fetch()} disabled={isGlobalLoading}>
        加载数据
      </button>
    </div>
  );
}

适用场景

  • 领域模型清晰的项目(用类承载业务)。
  • 需要依赖注入的中大型应用(内置 IoC)。
  • 对性能敏感的批量更新场景(benchmark 显示个位数毫秒)。

easy-model 让我在项目中少写了很多模板代码,心智负担低。感兴趣的同学可以去 GitHub 看看示例:github.com/ZYF93/easy-…

npm 安装:npm install @e7w/easy-model

你用过哪些状态管理方案?觉得 easy-model 怎么样?评论区聊聊!

《前端细节控:如何完美实现聊天窗口的“智能自动滚动”?》

2026年3月14日 10:53

一、问题背景:流式输出的滚动痛点

刚实现流式输出,产品经理就来找麻烦了:用户正在复制 AI 回复的历史消息,新内容突然涌出,视口被强行踢到底部——操作被打断,体验极差。能不能实现一个「智能自动滚动」,别打扰用户?

二、需求分析:三种行为场景

分析下需求,分为三个行为:

  1. 默认行为:用户在底部时,新消息自动跟进。
  2. 干预行为:用户一旦上滑,查看历史,立即停止自动滚动,把控制权交给用户:
  3. 恢复行为:用户回到底部,恢复自动跟进。

三、核心原理

  • scrollTop:用户滚走了多少。
  • scrollHeight:内容总共有多长。
  • clientHeight:窗口能看见多长。

利用这三个值,就能精准判断用户是“在看最新消息”(在底部),还是“在翻看历史消息”(不在底部),从而决定是否要自动滚动。

判定公式

//浏览器里元素的总高度=滚动高度+可视区域高度+距离底部高度。
// 距离底部的剩余像素 
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;

// 是否在底部(留 5px 容错,防止亚像素渲染导致的抖动)
const isAtBottom = distanceFromBottom <= 5;

四、状态机设计:userScrolled 的妙用

底部高度既然判断出了,就需要一个标记,来判断“用户是否主动干涉过”。

逻辑:

  1. 监听元素的scroll事件。
  2. 计算isAtBottom(是否在底部):
    • 若 !isAtBottom无条件锁定 (userScrolled = true)。 (解释:包括用户滚到中间或顶部的所有情况)
    • 如果 isAtBottom:说明用户回去了 →→ 设置 userScrolled = false(解锁自动滚动)。

核心代码实现:状态机与滚动监听

import { useState, useCallback, useEffect, useRef } from 'react';

export default function ChatContainer({ messages, isStreaming }) {
  const scrollContainerRef = useRef(null);
  
  // 1. 状态定义
  // userScrolled: true = 用户已干预(锁定自动滚动), false = 允许自动跟随
  const [userScrolled, setUserScrolled] = useState(false);
  const [isAtBottom, setIsAtBottom] = useState(true);

  // 2. 滚动监听器 (状态机的核心)
  const handleScroll = useCallback(() => {
    const container = scrollContainerRef.current;
    if (!container) return;

    const { scrollTop, scrollHeight, clientHeight } = container;
    
    // 计算距离底部的像素 (兼容不同浏览器的亚像素渲染差异,留 5px 容错)
    const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
    const atBottom = distanceFromBottom <= 5;

    // 更新底部状态标记
    setIsAtBottom(atBottom);

    // --- 关键逻辑修正点 ---
    // 只要不在底部 (无论 scrollTop 是 100 还是 0),都视为用户正在阅读历史,必须锁定!
    if (!atBottom) {
      setUserScrolled(true); 
    } else {
      // 只有当用户主动滚回底部时,才解锁自动跟随
      setUserScrolled(false);
    }
  }, []);

  // 3. 自动滚动执行器 (响应新消息)
  useEffect(() => {
    // 【守卫条件】只有同时满足:正在流式输出 AND 用户未锁定 AND 容器存在
    if (!isStreaming || userScrolled || !scrollContainerRef.current) {
      return;
    }

    const container = scrollContainerRef.current;

    // 【性能优化】使用 requestAnimationFrame 确保 DOM 已更新
    requestAnimationFrame(() => {
      // 【策略选择】流式高频更新用 'auto' 防卡顿,非流式用 'smooth' 做过渡
      const behavior = isStreaming ? 'auto' : 'smooth';
      
      container.scrollTo({
        top: container.scrollHeight,
        behavior: behavior
      });
    });

  }, [messages, isStreaming, userScrolled]); // 依赖 messages 以触发更新

  return (
    <div 
      ref={scrollContainerRef}
      onScroll={handleScroll}
      style={{ height: '500px', overflowY: 'auto', border: '1px solid #ccc' }}
    >
      {messages.map((msg) => (
        <div key={msg.id}>{msg.content}</div>
      ))}
      {isStreaming && <div style={{ color: '#999', fontStyle: 'italic' }}>AI 正在思考...</div>}

    </div>
  );
}

五、总结

实现“智能自动滚动”其实就抓住了两个核心:

  1. 精准判断:用 scrollHeight - scrollTop - clientHeight 计算距底距离,5px 容错防抖动。
  2. 状态仲裁:用 userScrolled 做开关,用户在底部时自动跟随,用户上滑时立即锁定。

练习单导出

作者 前端付豪
2026年3月14日 09:10

新增:

  • 导出当前解析结果
  • 导出当前学生错题本
  • 导出学习建议
  • 新开页面生成练习单
  • 浏览器里直接 打印 / 存成 PDF

后端新增导出接口

1)修改 backend/app/main.py

先补充 import:

from fastapi.responses import HTMLResponse

2)新增一个导出页面接口

把下面接口加到 main.py 里:

@app.get("/api/export/report", response_class=HTMLResponse)
def export_report_html(
    student_id: int = Query(1),
    db: Session = Depends(get_db)
):
    student = db.query(Student).filter(Student.id == student_id).first()
    student_name = student.name if student else f"学生{student_id}"

    rows = (
        db.query(QuestionHistory)
        .filter(QuestionHistory.student_id == student_id)
        .order_by(QuestionHistory.id.desc())
        .all()
    )

    report = build_learning_report(db, student_id)
    suggestion = build_study_suggestion(db, student_id)

    wrong_rows = [row for row in rows if row.is_wrong][:10]

    wrong_html = ""
    for row in wrong_rows:
        try:
            knowledge_points = json.loads(row.knowledge_points or "[]")
        except Exception:
            knowledge_points = []

        try:
            steps = json.loads(row.steps or "[]")
        except Exception:
            steps = []

        wrong_html += f"""
        <div class="card">
          <h3>错题 #{row.id}</h3>
          <p><strong>题目:</strong>{row.question}</p>
          <p><strong>答案:</strong>{row.answer}</p>
          <p><strong>知识点:</strong>{'、'.join(knowledge_points) if knowledge_points else '无'}</p>
          <div>
            <strong>步骤解析:</strong>
            <ol>
              {''.join([f'<li>{step}</li>' for step in steps])}
            </ol>
          </div>
        </div>
        """

    top_kp_html = "".join([
        f"<li>{item['name']}({item['count']}次)</li>"
        for item in report["top_knowledge_points"]
    ])

    weak_html = "".join([
        f"""
        <div class="card">
          <h3>{item['name']}</h3>
          <p>错误 {item['wrong_count']} 次 / 共出现 {item['total_count']} 次 / 错误率 {item['wrong_rate']}%</p>
          <p>{item['suggestion']}</p>
        </div>
        """
        for item in suggestion["weak_knowledge_points"]
    ])

    html = f"""
    <!DOCTYPE html>
    <html lang="zh-CN">
    <head>
      <meta charset="UTF-8" />
      <title>{student_name} - 学习练习单</title>
      <style>
        body {{
          font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
          margin: 0;
          padding: 24px;
          color: #222;
          background: #f7f8fa;
        }}
        .container {{
          max-width: 960px;
          margin: 0 auto;
          background: #fff;
          padding: 32px;
          border-radius: 16px;
        }}
        h1, h2, h3 {{
          margin-top: 0;
        }}
        .summary {{
          display: grid;
          grid-template-columns: repeat(4, 1fr);
          gap: 12px;
          margin-bottom: 24px;
        }}
        .summary-item {{
          background: #f5f7fa;
          border-radius: 12px;
          padding: 16px;
          text-align: center;
        }}
        .summary-label {{
          color: #666;
          font-size: 14px;
          margin-bottom: 8px;
        }}
        .summary-value {{
          font-size: 28px;
          font-weight: bold;
          color: #18a058;
        }}
        .card {{
          background: #fafafa;
          border-radius: 12px;
          padding: 16px;
          margin-bottom: 16px;
        }}
        .section {{
          margin-top: 32px;
        }}
        .print-bar {{
          margin-bottom: 24px;
        }}
        .print-btn {{
          padding: 10px 16px;
          border: none;
          background: #18a058;
          color: #fff;
          border-radius: 8px;
          cursor: pointer;
        }}
        @media print {{
          body {{
            background: #fff;
            padding: 0;
          }}
          .container {{
            max-width: none;
            border-radius: 0;
            padding: 0;
          }}
          .print-bar {{
            display: none;
          }}
        }}
      </style>
    </head>
    <body>
      <div class="container">
        <div class="print-bar">
          <button class="print-btn" onclick="window.print()">打印 / 保存为 PDF</button>
        </div>

        <h1>{student_name} - 学习练习单</h1>

        <div class="summary">
          <div class="summary-item">
            <div class="summary-label">总题数</div>
            <div class="summary-value">{report['total_count']}</div>
          </div>
          <div class="summary-item">
            <div class="summary-label">错题数</div>
            <div class="summary-value">{report['wrong_count']}</div>
          </div>
          <div class="summary-item">
            <div class="summary-label">正确数</div>
            <div class="summary-value">{report['correct_count']}</div>
          </div>
          <div class="summary-item">
            <div class="summary-label">错题率</div>
            <div class="summary-value">{report['wrong_rate']}%</div>
          </div>
        </div>

        <div class="section">
          <h2>整体学习建议</h2>
          <div class="card">{suggestion['overall_suggestion']}</div>
        </div>

        <div class="section">
          <h2>高频知识点</h2>
          <div class="card">
            <ul>{top_kp_html or '<li>暂无</li>'}</ul>
          </div>
        </div>

        <div class="section">
          <h2>薄弱知识点分析</h2>
          {weak_html or '<div class="card">暂无薄弱知识点</div>'}
        </div>

        <div class="section">
          <h2>错题本(最近10题)</h2>
          {wrong_html or '<div class="card">暂无错题</div>'}
        </div>
      </div>
    </body>
    </html>
    """
    return html

前端新增导出按钮

修改 frontend/src/api/math.ts

新增一个方法:

export function getExportReportUrl(student_id: number) {
  return `http://127.0.0.1:8000/api/export/report?student_id=${student_id}`
}

修改 frontend/src/App.vue

1)补充 import

getExportReportUrl 加进去:

import {
  solveMathQuestion,
  solveMathImage,
  getHistoryList,
  getWrongQuestionList,
  markWrongQuestion,
  generatePracticeByKnowledge,
  regenerateQuestion,
  getLearningReport,
  getStudySuggestion,
  getStudentList,
  createStudent,
  getExportReportUrl,
  type SolveResponse,
  type HistoryItem,
  type PracticeQuestionItem,
  type LearningReportResponse,
  type StudySuggestionResponse,
  type StudentItem,
} from './api/math'

2)新增导出方法

script setup 里新增:

const handleExportReport = () => {
  const url = getExportReportUrl(currentStudentId.value)
  window.open(url, '_blank')
}

页面里加导出入口

修改 frontend/src/App.vue

在学生切换区域 student-bar 里,最后加一个按钮:

<button class="wrong-btn" @click="handleExportReport">
  导出练习单
</button>

重启看效果

重启后端

uvicorn app.main:app --reload --port 8000

重启前端

npm run dev

image.png

image.png

image.png

不同学生 也没问题

image.png

image.png

万字解析 OpenClaw 源码架构-跨平台应用之Android 应用

作者 毛骗导演
2026年3月14日 08:46

Android 应用层的关键文件组织如下:

  • 应用入口与运行时:NodeApp、NodeRuntime
  • 前台服务:NodeForegroundService
  • 网关会话:GatewaySession(WebSocket 客户端)
  • 平台服务:DeviceNotificationListenerService(通知监听)
  • 设备信息与健康:DeviceHandler(电池、内存等)
  • 安全与偏好:SecurePrefs
  • UI 绑定:MainViewModel(对 NodeRuntime 的调用封装)
graph TB
subgraph "应用层"
A["NodeApp<br/>应用入口"]
B["NodeRuntime<br/>节点运行时"]
C["NodeForegroundService<br/>前台服务"]
D["DeviceNotificationListenerService<br/>通知监听服务"]
end
subgraph "网关层"
E["GatewaySession<br/>WebSocket 会话"]
end
subgraph "设备与系统"
F["DeviceHandler<br/>设备信息/健康"]
G["SecurePrefs<br/>加密偏好"]
end
A --> B
B --> C
B --> E
B --> F
B --> G
D --> B

核心组件

  • NodeApp:应用生命周期持有者,延迟初始化 NodeRuntime
  • NodeRuntime:核心运行时,负责:
    • 网关连接(操作员会话与节点会话)
    • 自动重连与断线恢复
    • 状态流(连接状态、服务器名、麦克风状态等)
    • 事件分发与命令派发(InvokeDispatcher)
    • 通知监听事件转发到节点会话
  • NodeForegroundService:前台服务,负责:
    • 创建并更新通知
    • 订阅 NodeRuntime 状态流,动态刷新通知内容
    • 提供“断开”动作以触发 NodeRuntime 断开连接
  • GatewaySession:WebSocket 客户端,封装连接、请求、事件与自动重连循环
  • DeviceNotificationListenerService:系统通知监听服务,将通知事件转发给 NodeRuntime
  • DeviceHandler:设备健康信息采集(电池、内存、温度等)
  • SecurePrefs:加密存储(SharedPreferences 加密包装),用于实例 ID、显示名、网关凭据等

架构总览

下图展示从前台服务到运行时、再到网关会话的整体交互流程。

sequenceDiagram
participant Sys as "系统"
participant Svc as "NodeForegroundService"
participant RT as "NodeRuntime"
participant GS as "GatewaySession"
participant GW as "网关"
Sys->>Svc : "启动前台服务"
Svc->>RT : "订阅状态流状态/服务器/连接/Mic"
RT-->>Svc : "状态变化连接/断开/麦克风状态"
Svc->>Svc : "构建/更新通知"
Note over Svc : "点击“断开”触发停止动作"
Svc->>RT : "disconnect()"
RT->>GS : "关闭会话/取消重连"
GS-->>RT : "onDisconnected 回调"
RT-->>Svc : "状态流更新"
Svc->>Svc : "更新通知为断开状态"

详细组件分析

NodeForegroundService 前台服务

  • 职责
    • 在应用启动后立即以前台服务方式运行,避免被系统回收
    • 订阅 NodeRuntime 的多源状态流,动态更新通知标题与文本
    • 提供“断开”动作,通过广播触发 NodeRuntime 断开连接并停止自身
  • 关键点
    • 使用通知通道(IMPORTANCE_LOW)与 FOREGROUND_SERVICE_TYPE_DATA_SYNC
    • 通知设置为 ongoing 且仅提示一次,确保用户可随时查看
    • 首次启动时调用 startForegroundWithTypes,后续使用 notify 更新
  • 生命周期
    • onCreate:创建通知通道、初始通知、订阅状态流
    • onStartCommand:处理 ACTION_STOP 动作,触发断开并 stopSelf
    • onDestroy:取消协程作业,释放资源
flowchart TD
Start(["服务启动"]) --> Ensure["创建通知通道"]
Ensure --> InitNotify["构建初始通知并启动前台"]
InitNotify --> Subscribe["订阅 NodeRuntime 状态流"]
Subscribe --> OnChange{"状态变化?"}
OnChange --> |是| Build["构建新通知"]
Build --> Update["notify 更新"]
OnChange --> |否| Wait["等待下次变化"]
Update --> Wait
Wait --> OnChange
StopAction["收到 ACTION_STOP"] --> Disconnect["调用 NodeRuntime.disconnect()"]
Disconnect --> StopSelf["stopSelf()"]

NodeRuntime 节点运行时与生命周期控制

  • 职责
    • 管理两个 GatewaySession:操作员会话与节点会话
    • 维护连接状态、服务器名、远端地址、主会话键等状态流
    • 自动发现与自动连接(受信任网关 TLS 指纹约束)
    • 将通知监听事件转发至节点会话
    • 管理麦克风、TTS、Canvas 等子系统
  • 连接与重连
    • runLoop 循环尝试连接,失败指数退避,保持持续重连
    • onConnected/onDisconnected 回调驱动状态流更新
  • 状态流
    • isConnected、statusText、serverName、remoteAddress、micEnabled、micIsListening 等
    • 通过 combine 组合多个流,驱动前台通知实时更新
  • 生命周期控制
    • setForeground:切换前后台,必要时停止语音会话
    • disconnect:清空目标端点并断开会话
classDiagram
class NodeRuntime {
+connect(endpoint)
+disconnect()
+refreshGatewayConnection()
+setForeground(Boolean)
+statusText : StateFlow
+isConnected : StateFlow
+serverName : StateFlow
+remoteAddress : StateFlow
+micEnabled : StateFlow
+micIsListening : StateFlow
}
class GatewaySession {
+connect(endpoint, token, password, options, tls)
+disconnect()
+reconnect()
+request(method, paramsJson)
+sendNodeEvent(event, payloadJson)
}
NodeRuntime --> GatewaySession : "管理两个会话"

网关会话(GatewaySession)与自动重连

  • 运行循环
    • runLoop:根据 desired 目标连接;失败则延迟重试,指数退避上限 8 秒
    • connectOnce:建立单次连接,等待关闭或异常
  • 请求与事件
    • request:发送 RPC 请求并等待响应,超时抛出异常
    • handleEvent:处理事件帧,识别 node.invoke.request 并派发到 onInvoke
  • TLS 与指纹
    • 支持 TLS 参数与指纹回调,首次连接时探测并保存指纹,后续自动信任
flowchart TD
Loop["runLoop 开始"] --> Target{"存在目标连接?"}
Target --> |否| Close["关闭当前连接/等待"] --> Delay["delay(250ms)"] --> Loop
Target --> |是| Connect["connectOnce 连接"]
Connect --> Ok{"连接成功?"}
Ok --> |是| Run["保持连接/等待关闭"]
Ok --> |否| Retry["增加尝试次数"] --> Backoff["指数退避(<=8s)"] --> Loop

通知监听服务与状态同步

  • DeviceNotificationListenerService
    • 实现 onNotificationPosted,解析通知为内部条目并写入存储
    • 过滤自身包名的通知,向 NodeEventSink 发送变更事件
  • 事件同步
    • NodeRuntime 初始化时设置事件 Sink,将事件转发到节点会话(node.event)
    • 前台服务订阅 NodeRuntime 状态流,实现 UI/通知与运行时状态的双向同步
sequenceDiagram
participant OS as "系统通知栏"
participant NLS as "DeviceNotificationListenerService"
participant Sink as "NodeEventSink"
participant NR as "NodeRuntime"
participant NS as "Node Session"
OS->>NLS : "通知发布"
NLS->>NLS : "解析为条目/写入存储"
NLS->>Sink : "emitNotificationsChanged(...)"
Sink->>NR : "转发事件"
NR->>NS : "sendNodeEvent(event, payload)"

设备健康与内存管理

  • DeviceHandler
    • 读取电池快照(状态、充电、电量分数、温度)
    • 读取内存快照(总内存、可用内存、是否低内存、压力等级)
    • 输出 JSON 负载供上层使用
  • 内存与电池指标可用于:
    • UI 健康指示
    • 自适应降级(如降低麦克风采样率、暂停非关键任务)

安全与配置

  • 权限与前台服务类型
    • AndroidManifest 声明 FOREGROUND_SERVICE、FOREGROUND_SERVICE_DATA_SYNC、INTERNET 等
    • NodeForegroundService 使用 FOREGROUND_SERVICE_TYPE_DATA_SYNC
  • 加密存储
    • SecurePrefs 使用 EncryptedSharedPreferences 存储网关令牌、密码、TLS 指纹等
    • 实例 ID 与显示名自动生成/迁移,保证唯一性与隐私
  • TLS 指纹校验
    • 首次连接探测指纹,用户确认后持久化,后续自动信任

依赖关系分析

  • 组件耦合
    • NodeForegroundService 强依赖 NodeRuntime 的状态流,弱依赖 NodeApp(获取 runtime)
    • NodeRuntime 依赖 GatewaySession(两个会话)、DeviceNotificationListenerService(事件源)、DeviceHandler(健康数据)、SecurePrefs(配置与凭据)
    • GatewaySession 依赖 OkHttp WebSocket、DeviceIdentityStore、DeviceAuthStore
  • 外部依赖
    • Android 系统服务:通知、前台服务、通知监听服务
    • OkHttp:WebSocket 客户端
  • 可能的循环依赖
    • 当前结构无直接循环依赖;事件通过回调与状态流解耦
graph LR
Svc["NodeForegroundService"] --> RT["NodeRuntime"]
RT --> GS["GatewaySession"]
RT --> DH["DeviceHandler"]
RT --> NLS["DeviceNotificationListenerService"]
RT --> SP["SecurePrefs"]
GS --> OkHttp["OkHttp WebSocket"]

性能与电池优化

  • 通知即时行为
    • 使用 FOREGROUND_SERVICE_IMMEDIATE,确保通知立即可见,减少用户感知延迟
  • 协程与背压
    • 使用 SupervisorJob + IO 主线程,避免主线程阻塞
    • 状态流合并使用 distinctUntilChanged,减少无效 UI 刷新
  • 自动重连与退避
    • 指数退避上限 8 秒,降低网络波动对系统的影响
  • 电池与内存
    • DeviceHandler 提供电池/内存快照,便于在低电量/低内存时降级处理
  • 建议
    • 在低电量/低内存场景下暂停非关键任务(如 Canvas 重载)
    • 控制通知频率,避免频繁更新导致 CPU/电量消耗
    • 对长耗时任务拆分为小步,配合 WorkManager 或前台服务

界面组件

Android UI 采用 Jetpack Compose 架构,以“主题层 → 布局层 → 屏幕层 → 功能组件层”的分层组织方式:

  • 主题层:统一颜色、字体与动态色方案
  • 布局层:顶部状态栏、底部导航、Scaffold 容器
  • 屏幕层:引导流程、标签页容器、各功能页(聊天、语音、屏幕、设置)
  • 功能组件层:聊天输入区、消息气泡、工具调用提示、错误提示等
graph TB
A["MainActivity<br/>设置主题与根内容"] --> B["OpenClawTheme<br/>Material3 动态色"]
B --> C["RootScreen<br/>引导/标签页入口"]
C --> D["PostOnboardingTabs<br/>Scaffold + TabBar"]
D --> E["ConnectTabScreen"]
D --> F["ChatSheetContent<br/>消息列表 + 输入区"]
D --> G["VoiceTabScreen"]
D --> H["ScreenTabScreen"]
D --> I["SettingsSheet<br/>权限与设备配置"]

核心组件

  • 主题系统:基于 Material3 动态色,提供覆盖容器色与图标色的辅助函数,确保深浅模式一致体验
  • 移动 UI 令牌:集中定义颜色、字体族与排版样式,统一视觉语言
  • 根屏幕:根据引导完成状态切换到标签页容器
  • 标签页容器:顶部状态栏 + 底部导航 + 内容区域,支持 IME 折叠与 Tab 切换
  • 设置页面:集中管理权限、位置模式、睡眠策略、通知与数据访问等
  • 聊天界面:会话选择、消息列表、输入区(文本+图片附件)、工具调用提示、实时流式助手

架构总览

Compose 驱动的单 Activity 多屏幕架构,通过 ViewModel 暴露状态流,屏幕层订阅并渲染 UI;功能组件通过参数回调与 ViewModel 交互,形成清晰的数据流向。

sequenceDiagram
participant A as "MainActivity"
participant B as "OpenClawTheme"
participant C as "RootScreen"
participant D as "PostOnboardingTabs"
participant E as "MainViewModel"
participant F as "ChatSheetContent"
participant G as "ChatComposer"
A->>B : "设置主题"
B->>C : "包裹根内容"
C->>D : "根据引导状态切换"
D->>E : "订阅状态流"
D->>F : "加载聊天/语音/屏幕/设置"
F->>E : "读取消息/会话/健康状态"
F->>G : "传递发送/刷新/中止回调"
G->>E : "触发发送/中止/切换思考级别"

详细组件分析

主题系统与颜色/字体令牌

  • 动态色方案:根据系统深浅模式选择动态亮/暗配色,作为 MaterialTheme 的 colorScheme
  • 覆盖容器色与图标色:提供 overlayContainerColor 与 overlayIconColor,用于浮层与覆盖 UI 的一致性
  • 颜色令牌:集中定义背景、表面、边框、文本、强调色、成功/警告/危险等语义色
  • 字体令牌:定义字体族与多级排版样式(标题、正文、说明、脚注等),统一在组件中引用
classDiagram
class OpenClawTheme {
+OpenClawTheme(content)
+overlayContainerColor()
+overlayIconColor()
}
class MobileUiTokens {
+mobileBackgroundGradient
+mobileSurface
+mobileText*
+mobileAccent*
+mobileCode*
+mobileFontFamily
+mobileTitle1/mobileTitle2
+mobileHeadline/mobileBody
+mobileCallout/mobileCaption1/mobileCaption2
}
OpenClawTheme --> MobileUiTokens : "使用颜色/字体令牌"

标签页容器与状态指示器

  • 顶部状态栏:根据连接状态映射为不同视觉状态(已连接、连接中、警告、错误、离线),并以色块与点标示
  • 底部导航:Tab 切换时高亮当前项,结合 IME 显示隐藏规则优化输入体验
  • 内容区域:按需渲染各功能页,如屏幕页支持“恢复仪表盘”提示
flowchart TD
A["状态文本"] --> B{"解析状态"}
B --> |已连接| C["Connected<br/>绿色系"]
B --> |连接中/重连| D["Connecting<br/>蓝色系"]
B --> |配对/授权| E["Warning<br/>橙色系"]
B --> |错误/失败| F["Error<br/>红色系"]
B --> |默认| G["Offline<br/>灰阶"]

聊天界面

  • 会话选择:横向滚动展示历史会话,当前会话高亮
  • 错误提示:错误文本出现时以警示色块显示
  • 消息列表:支持文本、Markdown、图片、工具调用、流式助手等多类型渲染
  • 输入区:支持文本输入、图片附件、思考级别选择、刷新/中止、发送按钮与禁用态
sequenceDiagram
participant U as "用户"
participant CS as "ChatSheetContent"
participant VM as "MainViewModel"
participant CC as "ChatComposer"
participant MV as "ChatMessageViews"
U->>CS : "打开聊天页"
CS->>VM : "订阅消息/会话/健康状态"
CS->>CC : "传入发送/刷新/中止回调"
U->>CC : "输入文本/选择图片/选择思考级别"
CC->>VM : "sendChat(message, thinking, attachments)"
VM-->>CS : "更新消息/健康状态"
CS->>MV : "渲染消息列表"

设置页面

  • 设备信息:实例 ID、设备型号、版本号
  • 权限管理:麦克风、相机、短信、通知、照片、联系人、日历、运动、位置(含精确位置)
  • 位置模式:Off/WhileUsing/Precise Location 三态控制
  • 屏幕常亮:防止休眠开关
  • 交互流程:权限请求通过 ActivityResultLauncher 触发,状态变更通过 ViewModel 同步
flowchart TD
A["进入设置页"] --> B["读取权限状态"]
B --> C{"是否已授予?"}
C --> |是| D["显示“管理”按钮"]
C --> |否| E["显示“授权”按钮"]
D --> F["打开系统设置或执行操作"]
E --> G["启动权限请求"]
G --> H["回调后更新状态"]

数据流与状态管理

  • ViewModel 暴露 StateFlow,屏幕与组件通过 collectAsState 订阅
  • 聊天:消息列表、错误、健康状态、思考级别、流式助手文本、待执行工具调用、会话列表
  • 连接:网关发现状态、连接状态、远端地址、服务器名
  • 语音/屏幕/节点:相机、Canvas 控制、前台服务等
classDiagram
class MainViewModel {
+canvasCurrentUrl/statusText/isConnected
+chatMessages/chatError/chatHealthOk
+chatThinkingLevel/chatStreamingAssistantText
+chatSessions/pendingRunCount
+locationMode/locationPreciseEnabled
+preventSleep/cameraEnabled
+displayName/instanceId
+set*()/connect()/disconnect()
}
class ChatSheetContent
class ChatComposer
class SettingsSheet
ChatSheetContent --> MainViewModel : "收集状态/触发操作"
ChatComposer --> MainViewModel : "发送/中止/切换思考级别"
SettingsSheet --> MainViewModel : "权限/位置/睡眠策略"

依赖关系分析

  • 组件耦合:屏幕层仅依赖 ViewModel 的 StateFlow,功能组件通过回调与 ViewModel 解耦
  • 主题与样式:所有 UI 组件统一引用 MobileUiTokens 中的颜色与排版,避免硬编码
  • 权限与系统服务:设置页通过 ActivityResultLauncher 与系统权限对话交互,避免直接持有上下文引用
graph LR
MV["MainViewModel"] --> RC["RootScreen"]
RC --> POT["PostOnboardingTabs"]
POT --> CNT["ChatSheetContent"]
POT --> SET["SettingsSheet"]
CNT --> CMP["ChatComposer"]
CNT --> MSG["ChatMessageViews"]
CMP --> THEME["OpenClawTheme"]
MSG --> THEME
SET --> THEME

性能考量

  • 布局折叠:底部导航在 IME 弹出时自动隐藏,减少重绘范围
  • 滚动优化:消息列表与会话选择使用水平滚动与懒加载容器,限制一次性渲染量
  • 图片处理:图片附件在 IO 线程解码与 Base64 编码,完成后在主线程更新 UI
  • 状态订阅:仅在必要作用域内订阅 StateFlow,避免过度重组
  • 主题与样式:集中定义样式令牌,减少重复计算与资源分配

权限与安全

Android 应用位于 apps/android/app,核心安全与权限相关文件分布如下:

  • 权限请求:PermissionRequester.kt
  • 安全偏好:SecurePrefs.kt
  • 清单与服务:AndroidManifest.xml
  • 构建与依赖:build.gradle.kts
  • 网络与备份配置:network_security_config.xml、backup_rules.xml
  • TLS 固定:GatewayTls.kt
  • 设备权限状态上报:DeviceHandler.kt
  • 设置界面权限状态展示:SettingsSheet.kt
  • 安全偏好测试:SecurePrefsTest.kt
graph TB
subgraph "Android 应用"
A["PermissionRequester<br/>权限请求器"]
B["SecurePrefs<br/>安全偏好设置"]
C["AndroidManifest<br/>权限与服务声明"]
D["build.gradle.kts<br/>构建与依赖"]
E["network_security_config.xml<br/>网络安全策略"]
F["backup_rules.xml<br/>备份规则"]
G["GatewayTls<br/>TLS 固定"]
H["DeviceHandler<br/>设备权限状态上报"]
I["SettingsSheet<br/>设置界面权限状态"]
J["SecurePrefsTest<br/>安全偏好测试"]
end
A --> C
B --> C
G --> C
H --> C
I --> C
D --> C
E --> C
F --> C
J --> B

核心组件

  • 权限请求器(PermissionRequester):封装多权限一次性请求、理由说明对话框、超时控制与设置引导。
  • 安全偏好(SecurePrefs):基于 EncryptedSharedPreferences 的敏感数据加密存储,同时维护明文偏好用于非敏感配置;支持实例 ID、网关凭据、唤醒词等。
  • 清单与服务(AndroidManifest):声明网络、定位、相机、麦克风、通知、短信、媒体访问、日历联系人等权限;注册前台服务、通知监听服务、FileProvider。
  • 网络安全策略(network_security_config.xml):允许本地与特定域名的清晰文本流量,适配受信尾随网络场景。
  • 备份规则(backup_rules.xml):启用全量文件备份。
  • TLS 固定(GatewayTls):自定义 TrustManager 实现证书指纹校验,支持首次信任(TOFU)与持久化存储。
  • 设备权限状态上报(DeviceHandler):汇总设备权限状态并以 JSON 形式上报。
  • 设置界面权限状态展示(SettingsSheet):在设置页动态检查并显示各类权限状态。

架构总览

下图展示了从“权限请求”到“安全存储”再到“网络通信”的整体链路,以及与清单和服务的关系。

sequenceDiagram
participant UI as "设置界面/调用方"
participant PR as "PermissionRequester"
participant AM as "Android 系统权限框架"
participant SP as "SecurePrefs"
participant TLS as "GatewayTls"
participant NET as "远端网关"
UI->>PR : 请求若干运行时权限
PR->>AM : 发起多权限请求
AM-->>PR : 返回授权结果
PR-->>UI : 合并当前状态并提示设置入口
UI->>SP : 写入/读取敏感配置如网关令牌
SP-->>UI : 返回解密后的值
UI->>TLS : 基于参数构建 TLS 配置
TLS-->>UI : 返回校验证书的 SSL Socket 工厂
UI->>NET : 使用已校验的 TLS 连接进行通信

详细组件分析

权限请求器(PermissionRequester)

  • 功能要点
    • 批量检测缺失权限,必要时弹出理由对话框,尊重用户选择。
    • 使用 ActivityResultLauncher 触发系统授权对话框,支持超时控制。
    • 合并当前授权状态与回调结果,对被拒绝且无理由的权限引导至系统设置。
  • 关键行为
    • 检测逻辑:仅对未授予的权限发起请求。
    • 超时与并发:通过互斥锁与超时机制避免阻塞与竞态。
    • 结果合并:若某权限在回调前已被授予,视为已授权。
  • 用户体验
    • 对相机、麦克风、短信等权限提供明确标签提示。
    • 对不可恢复权限(无理由)引导至应用详情页设置。
flowchart TD
Start(["开始"]) --> Detect["检测缺失权限"]
Detect --> AnyMissing{"存在缺失权限?"}
AnyMissing --> |否| ReturnAllTrue["返回全部已授权"]
AnyMissing --> |是| NeedRationale{"是否需要理由说明?"}
NeedRationale --> |是| ShowRationale["显示理由对话框"]
ShowRationale --> UserChoice{"用户同意?"}
UserChoice --> |否| ReturnCurrent["返回当前授权状态"]
UserChoice --> |是| Launch["启动系统权限请求"]
NeedRationale --> |否| Launch
Launch --> Await["等待授权结果或超时"]
Await --> Merge["合并当前状态与回调结果"]
Merge --> Denied{"是否存在被拒绝且无理由的权限?"}
Denied --> |是| OpenSettings["引导打开应用设置"]
Denied --> |否| Done["完成"]
OpenSettings --> Done
ReturnAllTrue --> End(["结束"])
ReturnCurrent --> End
Done --> End

安全偏好设置(SecurePrefs)

  • 数据隔离
    • 明文偏好:存放非敏感配置(如实例 ID、显示名、位置模式、唤醒词等)。
    • 加密偏好:存放敏感信息(如网关令牌、密码),使用 EncryptedSharedPreferences 与 AES256-GCM。
  • 主密钥与加密方案
    • 使用 MasterKey.AES256_GCM 作为主密钥,Key/Value 分别采用 AES256_SIV 与 AES256_GCM。
  • 生命周期与状态流
    • 大部分字段以 StateFlow 暴露,便于 UI 订阅。
  • 迁移与兼容
    • 对历史键值进行迁移(例如位置模式的“always”迁移到新枚举值)。
  • 测试覆盖
    • 单元测试验证迁移逻辑正确性。
classDiagram
class SecurePrefs {
-appContext : Context
-json : Json
-plainPrefs : SharedPreferences
-masterKey : MasterKey
-securePrefs : SharedPreferences
-_instanceId : StateFlow~String~
-_displayName : StateFlow~String~
-_locationMode : StateFlow~LocationMode~
-_gatewayToken : StateFlow~String~
+setDisplayName(value)
+setLocationMode(mode)
+setGatewayToken(token)
+saveGatewayToken(token)
+loadGatewayToken() String?
+saveGatewayPassword(password)
+loadGatewayPassword() String?
+getString(key) String?
+putString(key,value)
+remove(key)
}

Android 清单与权限配置(AndroidManifest)

  • 权限声明
    • 网络与前台服务:INTERNET、ACCESS_NETWORK_STATE、FOREGROUND_SERVICE、FOREGROUND_SERVICE_DATA_SYNC。
    • 通知与 Wi‑Fi:POST_NOTIFICATIONS、NEARBLY_WIFI_DEVICES(含 flags)、ACCESS_FINE_LOCATION、ACCESS_COARSE_LOCATION。
    • 媒体与存储:CAMERA、RECORD_AUDIO、SEND_SMS、READ_MEDIA_IMAGES、READ_MEDIA_VISUAL_USER_SELECTED、READ_EXTERNAL_STORAGE(maxSdk=32)。
    • 联系人与日历:READ_CONTACTS、WRITE_CONTACTS、READ_CALENDAR、WRITE_CALENDAR。
    • 行为识别:ACTIVITY_RECOGNITION。
  • 特性声明
    • 摄像头与电话硬件为可选(required=false)。
  • 服务与 Provider
    • 前台服务、通知监听服务、FileProvider(用于分享文件)。
graph LR
M["AndroidManifest"] --> P1["网络/服务权限"]
M --> P2["媒体/存储权限"]
M --> P3["位置/通知权限"]
M --> P4["联系人/日历权限"]
M --> S1["前台服务"]
M --> S2["通知监听服务"]
M --> PDR["FileProvider"]

网络安全策略与备份规则

  • 网络安全配置
    • 允许基础清晰文本流量,针对 openclaw.local 与 ts.net 子域开放 HTTP。
    • 适用于受信尾随网络环境,降低本地开发与内网场景的 TLS 成本。
  • 备份规则
    • 启用全量文件备份,注意敏感数据应仅存于加密偏好中。

TLS 固定与安全通信

  • 目标
    • 在客户端侧对远端网关证书进行指纹校验,防止中间人攻击。
  • 实现
    • 自定义 TrustManager:当提供期望指纹时严格比对;否则回退到默认信任策略。
    • 支持首次信任(TOFU):若允许且首次成功握手,则将指纹持久化。
    • 提供 HostnameVerifier 与 SSLSocketFactory,便于 OkHttp 或原生 HTTPS 使用。
  • 使用建议
    • 对外网与非受信网络强制启用 TLS 与指纹校验;对本地环回地址可放宽策略。
sequenceDiagram
participant APP as "应用"
participant TLS as "GatewayTls"
participant CHAIN as "证书链"
participant STORE as "指纹存储"
APP->>TLS : 传入 GatewayTlsParams
TLS->>CHAIN : 获取首张证书并计算 SHA-256
alt 提供期望指纹
TLS->>TLS : 比对指纹一致?
TLS-->>APP : 一致则允许,否则取消
else 允许 TOFU
TLS->>STORE : 持久化指纹
TLS-->>APP : 允许连接
else 默认策略
TLS->>TLS : 使用系统信任策略校验
TLS-->>APP : 结果由系统决定
end

设备权限状态上报与设置页展示

  • 设备权限状态上报
    • DeviceHandler 汇总相机、麦克风、位置、照片、联系人、日历、运动、通知等权限状态,生成 JSON。
  • 设置页展示
    • SettingsSheet 在 onResume 时检查各权限状态,更新 UI 展示。

依赖关系分析

  • 构建与依赖
    • Compose、OkHttp、安全加密库(androidx.security:security-crypto)、相机库等。
    • 测试框架(Robolectric、Kotest、MockWebServer)。
  • 运行时依赖
    • 权限请求依赖 ActivityResultContracts 与 ActivityCompat。
    • 安全存储依赖 EncryptedSharedPreferences 与 MasterKey。
    • TLS 固定依赖 javax.net.ssl 与系统信任管理器。
graph TB
BR["build.gradle.kts"] --> ACT["AndroidX Activity"]
BR --> SEC["AndroidX Security Crypto"]
BR --> OK["OkHttp"]
BR --> CAM["CameraX"]
PR["PermissionRequester"] --> ACT
SP["SecurePrefs"] --> SEC
TLS["GatewayTls"] --> OK

性能考量

  • 权限请求
    • 使用互斥锁避免并发重复请求;超时控制防止 UI 卡死。
    • 合并当前状态与回调结果,减少后续判断成本。
  • 安全存储
    • EncryptedSharedPreferences 会带来一定开销,建议批量写入与合理缓存。
    • 对频繁读取的键值可引入内存缓存(当前实现以 StateFlow 为主)。
  • TLS 固定
    • 证书指纹计算与持久化需在后台线程执行,避免阻塞主线程。
    • 首次握手失败重试与超时控制,提升稳定性。

设备控制

Android 节点应用位于 apps/android/app,核心控制逻辑集中在 node 包中;网关侧策略与测试位于 src/gateway。

graph TB
subgraph "Android 节点"
ID["InvokeDispatcher<br/>命令分发器"]
DH["DeviceHandler<br/>设备信息/权限/健康"]
CH["CameraHandler<br/>相机列表/拍照/录像"]
CNH["ContactsHandler<br/>联系人搜索/新增"]
NH["NotificationsHandler<br/>通知快照/动作"]
SH["SmsHandler<br/>短信发送"]
LH["LocationHandler<br/>位置获取"]
AH["A2UIHandler<br/>A2UI 就绪/消息应用"]
end
subgraph "网关"
POL["node-command-policy.ts<br/>命令白名单/平台默认值"]
TEST["android-node.capabilities.live.test.ts<br/>能力验证测试"]
DISC["GatewayDiscovery.kt<br/>mDNS/NSD 发现"]
PAIR["message-handler.ts<br/>配对握手/拒绝"]
PNOTI["device-pair/notify.ts<br/>配对提醒轮询"]
end
ID --> DH
ID --> CH
ID --> CNH
ID --> NH
ID --> SH
ID --> LH
ID --> AH
POL --> ID
TEST --> ID
DISC --> ID
PAIR --> ID
PNOTI --> ID

核心组件

  • 命令分发器(InvokeDispatcher)
    • 统一入口,根据命令名路由到对应处理器,并进行前台限制、能力可用性检查与错误包装
    • 支持 Canvas/A2UI、相机、位置、设备、通知、系统、相册、联系人、日历、运动、短信等命令族
  • 各子处理器
    • DeviceHandler:设备状态、信息、权限、健康度
    • CameraHandler:相机设备枚举、拍照、录视频(含大小限制与错误处理)
    • ContactsHandler:联系人搜索、新增(含权限校验与批量插入)
    • NotificationsHandler:通知快照、动作执行(打开/忽略/回复)
    • SmsHandler:短信发送(错误码映射)
    • LocationHandler:位置获取(权限、精度、超时)
    • A2UIHandler:A2UI 主机解析、就绪检测、消息应用
  • 网关侧策略
    • node-command-policy.ts:按平台定义默认命令集、危险命令白名单、节点声明校验
  • 文档与测试
    • 平台文档:Android 节点连接、命令面与注意事项
    • 能力测试:覆盖 Canvas、相机、位置、设备、通知、调试等命令

架构总览

Android 节点通过 mDNS/NSD 发现网关,建立 WebSocket 连接并完成配对。命令由网关侧策略决定是否允许,节点侧通过 InvokeDispatcher 分发至各 Handler 执行,结果回传给网关。

sequenceDiagram
participant Node as "Android 节点"
participant Disc as "GatewayDiscovery<br/>mDNS/NSD"
participant GW as "Gateway"
participant Policy as "node-command-policy.ts"
participant Disp as "InvokeDispatcher"
participant H as "各 Handler"
Node->>Disc : 发现 _openclaw-gw._tcp
Disc-->>Node : 返回网关地址/端口
Node->>GW : 建立 WebSocket 连接
GW-->>Node : 握手/配对请求
alt 未配对且非静默
GW-->>Node : 拒绝连接并提示配对
else 已配对或静默
GW-->>Node : 允许连接
end
Node->>GW : node.invoke(command, params)
GW->>Policy : 校验命令是否允许
Policy-->>GW : 允许/拒绝
GW->>Disp : 调用分发器
Disp->>H : 路由到具体处理器
H-->>Disp : 执行结果/错误
Disp-->>GW : 包装响应
GW-->>Node : 返回 payload 或错误

详细组件分析

命令分发与注册机制(NodeHandler 系统)

  • 命令注册
    • 通过 InvokeCommandRegistry 查找命令规格,包含命令名、是否要求前台、可用性条件
    • 可用性条件支持:Always、CameraEnabled、LocationEnabled、SmsAvailable、MotionActivityAvailable、MotionPedometerAvailable、DebugBuild
  • 分发流程
    • 若命令未知,返回 INVALID_REQUEST
    • 若命令要求前台但节点不在前台,返回 NODE_BACKGROUND_UNAVAILABLE
    • 根据可用性条件检查(如相机/位置/短信/运动),不满足则返回相应错误码
    • 路由到具体处理器执行,A2UI 需要先确保 Canvas 可用与 A2UI 主机就绪
  • 错误处理
    • 统一包装为 GatewaySession.InvokeResult,包含 ok、payload 或 error(code/message)
flowchart TD
Start(["收到 node.invoke"]) --> Lookup["查找命令规格"]
Lookup --> Unknown{"未知命令?"}
Unknown --> |是| ErrUnknown["返回 INVALID_REQUEST"]
Unknown --> |否| Foreground{"需要前台?"}
Foreground --> |是且不在前台| ErrBg["返回 NODE_BACKGROUND_UNAVAILABLE"]
Foreground --> |否| Avail["检查可用性条件"]
Avail --> Denied{"条件不满足?"}
Denied --> |是| ErrCond["返回对应错误码"]
Denied --> |否| Route["路由到具体处理器"]
Route --> Exec["执行并返回结果"]
Exec --> End(["结束"])
ErrUnknown --> End
ErrBg --> End
ErrCond --> End

设备权限与状态(DeviceHandler)

  • 设备状态:电池、热状态、存储、网络连通性、耗电模式、uptime
  • 设备信息:设备名、型号标识、系统版本、应用版本/构建号、语言区域
  • 权限状态:相机、麦克风、位置、短信、通知监听、通知、相册、通讯录、日历、运动
  • 健康度:内存压力、电池状态/充电类型、温度、电流、Doze/LowPower 模式、安全补丁级别
classDiagram
class DeviceHandler {
+handleDeviceStatus(params) InvokeResult
+handleDeviceInfo(params) InvokeResult
+handleDevicePermissions(params) InvokeResult
+handleDeviceHealth(params) InvokeResult
-statusPayloadJson() String
-infoPayloadJson() String
-permissionsPayloadJson() String
-healthPayloadJson() String
}

相机控制(CameraHandler)

  • 列表:列举可用相机设备
  • 拍照:触发闪光、HUD 提示、捕获并返回 base64 图像
  • 录像:可选包含外部音频,限制最大负载,过大则删除临时文件并返回 PAYLOAD_TOO_LARGE
  • 错误处理:将异常映射为错误码与用户可读消息
sequenceDiagram
participant GW as "网关"
participant Disp as "InvokeDispatcher"
participant Cam as "CameraHandler"
GW->>Disp : node.invoke(camera.snap/clip)
Disp->>Cam : handleSnap/handleClip
Cam->>Cam : 触发 HUD/闪光/开始录制
Cam-->>Disp : 成功返回 base64/元数据
Disp-->>GW : InvokeResult.ok

联系人管理(ContactsHandler)

  • 搜索:支持查询关键字与数量上限,返回联系人列表
  • 新增:支持姓名、组织、电话、邮箱,通过批量插入写入系统通讯录
  • 权限:读取需 READ_CONTACTS,写入需 WRITE_CONTACTS;无权限直接返回 CONTACTS_PERMISSION_REQUIRED
flowchart TD
S(["contacts.search"]) --> CheckPerm["检查 READ_CONTACTS 权限"]
CheckPerm --> |无| ErrPerm["返回 CONTACTS_PERMISSION_REQUIRED"]
CheckPerm --> |有| Query["查询联系人"]
Query --> Ok["返回 contacts[]"]
A(["contacts.add"]) --> CheckWrite["检查 WRITE_CONTACTS 权限"]
CheckWrite --> |无| ErrPermAdd["返回 CONTACTS_PERMISSION_REQUIRED"]
CheckWrite --> |有| Validate["校验参数姓名/组织/电话/邮箱至少一项"]
Validate --> |无效| ErrInvalid["返回 CONTACTS_INVALID"]
Validate --> |有效| Insert["批量插入系统通讯录"]
Insert --> OkAdd["返回新增联系人"]

通知处理(NotificationsHandler)

  • 快照:读取通知快照(若启用监听但未连接,尝试重新绑定)
  • 动作:支持 open、dismiss、reply,reply 需要 replyText
  • 失败:返回 UNAVAILABLE 或具体错误码
sequenceDiagram
participant GW as "网关"
participant Disp as "InvokeDispatcher"
participant Noti as "NotificationsHandler"
GW->>Disp : node.invoke(notifications.list/actions)
Disp->>Noti : handleNotificationsList/handleNotificationsActions
Noti->>Noti : 读取快照/必要时重绑
Noti-->>Disp : 返回快照或执行结果
Disp-->>GW : InvokeResult

短信发送(SmsHandler)

  • 参数解析后委托底层 SmsManager 执行
  • 错误映射:将内部错误字符串按冒号前缀提取为错误码,返回 SMS_SEND_FAILED 默认码

位置服务(LocationHandler)

  • 权限:需要粗/精定位之一;前台运行时才允许
  • 精度:precise/coarse/balanced,受精确权限与系统设置影响
  • 超时:默认 10 秒,范围 1–60 秒
  • 异常:超时返回 LOCATION_TIMEOUT,其他异常返回 LOCATION_UNAVAILABLE

A2UI 交互(A2UIHandler)

  • 主机解析:优先节点 Canvas 主机,否则回退到运营者主机
  • 就绪检测:导航到 A2UI 页面并轮询检查 ready 标志
  • 消息应用:支持 messages 数组或 jsonl 字段,严格校验 v0.8 消息格式

网关命令策略与平台默认

  • 平台默认命令集:Android 默认开放 Canvas、Camera、Location、通知、系统通知、设备信息/状态/权限/健康、联系人、日历、提醒、相册、运动等命令
  • 危险命令:相机拍照/录像、屏幕录制、联系人新增、日历新增、提醒新增、短信发送等需显式允许
  • 声明校验:命令必须同时在节点声明的 commands 列表中

设备发现、蓝牙配对与网络通信

  • 设备发现:Android 使用 mDNS/NSD 发现 _openclaw-gw._tcp,支持本地与单播 DNS-SD(跨网络场景)
  • 蓝牙配对:通过网关握手阶段触发配对请求,未配对且非静默时拒绝连接并提示
  • 网络通信:WebSocket 连接,支持 Token/密码认证与 TLS

依赖关系分析

  • 节点侧
    • InvokeDispatcher 依赖各 Handler 与 A2UIHandler,受 InvokeCommandRegistry 的规格约束
    • 各 Handler 依赖系统服务(相机、联系人、通知、位置、短信等)
  • 网关侧
    • node-command-policy.ts 决定命令允许与否,结合节点声明与配置
    • 测试用例覆盖 Android 节点能力矩阵,验证命令返回结构与错误码
graph LR
Reg["InvokeCommandRegistry"] --> Disp["InvokeDispatcher"]
Disp --> DH["DeviceHandler"]
Disp --> CH["CameraHandler"]
Disp --> CNH["ContactsHandler"]
Disp --> NH["NotificationsHandler"]
Disp --> SH["SmsHandler"]
Disp --> LH["LocationHandler"]
Disp --> AH["A2UIHandler"]
Policy["node-command-policy.ts"] --> Disp
Test["android-node.capabilities.live.test.ts"] --> Disp

性能考量

  • 相机录视频负载限制:超过阈值会删除临时文件并返回错误,避免大包导致传输失败
  • 前台限制:Canvas/A2UI/相机/录屏等命令仅在前台可用,减少后台资源占用
  • 通知监听:若监听未连接,自动尝试重绑,降低用户干预成本
  • 网络发现:本地与单播 DNS-SD 双通道,提升跨网络发现成功率

网关通信

Android 网关通信相关代码主要位于 Android 应用模块中,核心类为 GatewaySession 与 DeviceAuthPayload;同时在后端/通用层提供协议定义与校验工具。

graph TB
subgraph "Android 应用"
GS["GatewaySession.kt<br/>会话管理/连接/消息处理"]
DAP["DeviceAuthPayload.kt<br/>认证载荷构建/归一化"]
end
subgraph "通用协议"
PI["protocol/index.ts<br/>帧校验/常量导出"]
SCHEMA["protocol/schema.ts<br/>协议模式入口"]
end
GS --> DAP
GS --> PI
PI --> SCHEMA

核心组件

  • GatewaySession(Android)
    • 负责 WebSocket 连接、消息收发、RPC 请求/响应、事件分发、节点调用请求处理、TLS 配置与指纹回调、Canvas URL 规范化等
    • 内部 Connection 类封装单次连接细节,包括握手、认证、心跳与断线处理
  • DeviceAuthPayload(Android)
    • 构建 v3 设备认证载荷字符串,统一元数据字段大小写规则,便于跨运行时一致性校验
  • 协议与校验(通用)
    • 提供帧类型、参数 Schema、AJV 校验器、协议版本常量与错误码导出

架构总览

Android 端通过 OkHttp WebSocket 客户端发起连接,按“握手挑战—连接参数—认证—会话建立”的顺序完成握手;随后进入消息循环,区分“响应帧”和“事件帧”,并支持节点侧向客户端发起的“invoke 请求”。

sequenceDiagram
participant App as "Android 应用"
participant WS as "OkHttp WebSocket"
participant Srv as "网关服务器"
App->>WS : "建立 WebSocket 连接"
WS-->>App : "onOpen()"
App->>Srv : "等待 connect.challenge 事件"
Srv-->>App : "事件 : connect.challenge { nonce }"
App->>App : "构造 connect 参数 + 设备签名"
App->>Srv : "RPC : connect"
Srv-->>App : "响应 : connect 成功 + 会话信息"
App->>App : "保存设备令牌/Canvas URL/会话键"
App->>Srv : "心跳/事件/请求/响应 循环"

组件详解

GatewaySession 会话管理

  • 连接与生命周期
    • 支持 connect/disconnect/reconnect,内部使用协程与互斥锁保证并发安全
    • 运行循环按指数回退策略重连,最大延迟上限控制
  • 消息处理
    • onMessage 解析 JSON 帧,区分 "res"(响应)与 "event"(事件)
    • pending 映射用于匹配请求 ID 与响应
  • 认证与握手
    • 等待 connect.challenge 事件提取 nonce,随后发送 connect RPC
    • 可选携带 token/password 与设备签名(公钥、签名、时间戳、nonce)
  • 节点调用
    • 接收 "node.invoke.request" 事件,调用应用提供的处理器,再以 "node.invoke.result" 回_ack_
  • Canvas URL 规范化
    • 根据连接是否 TLS 以及返回的 canvasHostUrl,修正 scheme/port/path/query/fragment
classDiagram
class GatewaySession {
+connect(endpoint, token, password, options, tls)
+disconnect()
+reconnect()
+request(method, paramsJson, timeoutMs)
+sendNodeEvent(event, payloadJson)
+refreshNodeCanvasCapability(timeoutMs)
}
class Connection {
+connect()
+request(method, params, timeoutMs)
+awaitClose()
-sendConnect(nonce)
-handleMessage(text)
-handleEvent(frame)
-handleResponse(frame)
-handleInvokeEvent(payloadJson)
-sendInvokeResult(id, nodeId, result, timeoutMs)
}
GatewaySession --> Connection : "持有并管理"

DeviceAuthPayload 认证机制

  • 载荷格式
    • v3 版本字符串由固定字段拼接,字段包括版本、设备 ID、客户端 ID、模式、角色、作用域列表、签名时间、令牌、nonce、平台、设备系列
    • 平台与设备系列字段进行大小写归一化(仅 ASCII A-Z 转小写),确保跨运行时一致性
  • 使用场景
    • 在 connect 参数中生成设备签名,随同公钥一起提交给网关,由网关验证签名与时间戳
flowchart TD
Start(["开始"]) --> BuildFields["拼接字段: v3|deviceId|clientId|mode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily"]
BuildFields --> Normalize["平台/设备系列字段大小写归一化"]
Normalize --> Join["以'|'连接为最终载荷字符串"]
Join --> Sign["使用设备私钥对载荷签名"]
Sign --> Done(["结束"])

协议常量与校验(通用)

  • 协议版本
    • 通过 schema 导入统一导出协议版本常量,客户端在 connect 参数中声明 min/maxProtocol
  • 帧与参数校验
    • 使用 AJV 编译各 Schema,提供 validateXxx 函数用于请求/响应/事件/参数的运行时校验
    • formatValidationErrors 将校验错误格式化为可读字符串
  • 错误码与帧类型
    • 导出 ErrorCodes、GatewayFrame、RequestFrame、ResponseFrame、EventFrame 等类型与校验器
graph LR
IDX["protocol/index.ts"] --> SCH["protocol/schema.ts"]
IDX --> VALID["AJV 校验器"]
IDX --> CONST["协议常量/版本"]

心跳检测与保活

  • 服务端心跳
    • 网关定期下发 "tick" 事件,客户端收到后更新最近心跳时间
  • 客户端保活
    • OkHttp 客户端设置 pingInterval,默认 30 秒;若长时间无消息,可结合业务层 watchdog 强制重连(参考其他平台实现)

消息序列化与反序列化

  • 发送
    • request 方法构造 "req" 帧,序列化为 JSON 后通过 WebSocket 发送
  • 接收
    • onMessage 解析文本为 JSON 对象,根据 "type" 分派到 handleResponse 或 handleEvent
    • 响应帧通过 pending 映射匹配请求 ID,超时抛出异常
  • 节点调用结果
    • invoke 结果以 "node.invoke.result" RPC 返回,支持 payload 与 error 字段

连接配置与 TLS

  • TLS 配置
    • 可选 SSL Socket Factory 与 Hostname Verifier,支持自定义证书指纹校验回调
  • Ping 与超时
    • 写超时 60 秒,读超时无限制,ping 间隔 30 秒
  • Canvas URL 规范化
    • 根据连接是否 TLS 修正 https/scheme 与端口,保留路径/查询/片段

节点调用(GatewaySessionInvoke)

  • 事件到 RPC 的桥接
    • 当收到 "node.invoke.request" 事件时,解析参数(id/nodeId/command/paramsJSON/timeoutMs),调用应用提供的处理器
    • 处理完成后以 "node.invoke.result" 发送结果,超时范围在 15–120 秒之间
  • 测试场景
    • 单测覆盖握手、事件分发、结果回传与关闭流程
sequenceDiagram
participant GS as "GatewaySession.Connection"
participant App as "应用处理器"
GS->>GS : "接收事件 : node.invoke.request"
GS->>App : "调用处理器(InvokeRequest)"
App-->>GS : "返回 InvokeResult"
GS->>GS : "构造 node.invoke.result 参数"
GS-->>GS : "发送 RPC : node.invoke.result"

依赖关系分析

  • Android 端
    • GatewaySession 依赖 OkHttp WebSocket、Kotlinx Serialization、协程与互斥锁
    • DeviceAuthPayload 作为纯函数对象,被 Connection 构造 connect 参数时调用
  • 通用层
    • protocol/index.ts 导出 AJV 校验器与协议常量,供服务端/客户端共享
    • schema.ts 汇总各类 Schema,形成强类型协议模型
graph TB
A["GatewaySession.kt"] --> B["DeviceAuthPayload.kt"]
A --> C["protocol/index.ts"]
C --> D["protocol/schema.ts"]

性能与可靠性

  • 指数回退重连
    • 连接失败时按 1.7 指数增长延迟,上限 8 秒,避免风暴式重试
  • 超时与确认
    • connect RPC 超时 12 秒,invoke 结果确认超时在 15–120 秒区间
  • 心跳与保活
    • OkHttp ping 间隔 30 秒;业务层可结合 watchdog 在长时间无消息时触发重连
  • TLS 与指纹
    • 可配置自定义证书链与主机名校验,并在连接建立时回调指纹,便于运维审计

相机与屏幕录制

Android 相关实现集中在 apps/android/app 模块,核心文件包括:

  • 节点命令处理器:CameraHandler、PhotosHandler
  • 相机采集与录制:CameraCaptureManager
  • 状态与权限:CameraHudState、PermissionRequester
  • 辅助工具:JpegSizeLimiter
  • 文档参考:docs/platforms/android.md
graph TB
subgraph "Android 应用"
CH["CameraHandler<br/>节点命令入口"]
CCM["CameraCaptureManager<br/>相机采集/录制"]
PH["PhotosHandler<br/>相册读取"]
PR["PermissionRequester<br/>权限申请"]
HLS["CameraHudState<br/>HUD 状态模型"]
JSL["JpegSizeLimiter<br/>JPEG 尺寸限制器"]
end
CH --> CCM
CH --> HLS
CH --> PR
PH --> PR
CCM --> PR
CCM --> JSL

核心组件

  • CameraHudState:定义相机 HUD 的状态类型(拍照、录制、成功、错误),用于 UI 提示
  • CameraHandler:节点命令入口,负责 camera.list、camera.snap、camera.clip 的调用与结果封装
  • CameraCaptureManager:实际执行相机操作,含权限检查、设备选择、拍照与录制、文件输出与事件监听
  • PhotosHandler:读取系统相册最新图片,按预算压缩为 JPEG 并返回 base64
  • PermissionRequester:统一的权限申请与引导,支持理由说明与设置页跳转
  • JpegSizeLimiter:在尺寸与质量之间迭代压缩,确保输出不超过上限

架构总览

下图展示了从节点命令到相机采集与录制的整体流程,以及与权限系统的交互。

sequenceDiagram
participant Node as "节点命令"
participant Handler as "CameraHandler"
participant Manager as "CameraCaptureManager"
participant Perm as "PermissionRequester"
participant Cam as "相机系统(CamerX)"
participant UI as "HUD 状态"
Node->>Handler : 调用 camera.snap 或 camera.clip
Handler->>UI : 显示提示(拍照/录制)
alt 需要相机权限
Handler->>Perm : 请求相机权限
Perm-->>Handler : 返回授权结果
end
Handler->>Manager : 执行 snap/clip
Manager->>Cam : 绑定生命周期/选择相机/开始录制
Cam-->>Manager : 回调事件(Finalize/状态)
Manager-->>Handler : 返回结果(照片/文件)
Handler->>UI : 显示成功/错误提示
Handler-->>Node : 返回 JSON 结果

组件详解

相机状态管理:CameraHudState

  • 定义状态类型:Photo(拍照)、Recording(录制中)、Success(成功)、Error(错误)
  • 数据结构包含 token、kind、message,用于 UI 层渲染与自动隐藏

相机处理器:CameraHandler

职责与流程:

  • 设备列表:调用 CameraCaptureManager.listDevices,返回设备数组
  • 拍照:显示 HUD、触发闪光、调用 snap,返回 base64 JPEG;失败时显示错误 HUD
  • 录制:显示 HUD、可选启用外部音频、调用 clip,将 mp4 文件读入内存并 base64 编码返回;超过阈值则删除临时文件并报错

关键行为:

  • 参数解析:includeAudio、durationMs、deviceId、facing、quality、maxWidth
  • 负载限制:对 clip 的二进制大小进行上限检查,避免 WebSocket 超限
  • HUD 生命周期:成功/错误提示与自动隐藏时间

相机采集与录制:CameraCaptureManager

职责与流程:

  • 设备枚举:通过 ProcessCameraProvider 获取可用摄像头并映射为设备信息
  • 权限保障:ensureCameraPermission / ensureMicPermission
  • 拍照:
    • 解析参数:facing、quality、maxWidth、deviceId
    • 绑定生命周期并拍摄 JPEG,读取 EXIF 方向并旋转,按 maxWidth 缩放
    • 使用 JpegSizeLimiter 控制 JPEG 大小,返回 JSON 字符串
  • 录制:
    • 解析参数:facing、durationMs、includeAudio、deviceId
    • 设置最低质量以减小文件体积
    • 绑定 Preview 与 VideoCapture,预热后开始录制,延时后停止
    • 监听 Finalize 事件,超时或失败时清理临时文件并抛出异常
    • 返回 File 与元数据(时长、是否含音频)

辅助工具:

  • takeJpegWithExif:异步拍摄 JPEG 并返回字节与 EXIF 方向
  • cameraDeviceInfoOrNull / cameraIdOrNull:从 CameraInfo 解析设备信息
  • JpegSizeLimiter:在尺寸与质量间迭代压缩,确保不超过上限

相册处理:PhotosHandler

职责与流程:

  • 权限检查:根据系统版本选择 READ_MEDIA_IMAGES 或 READ_EXTERNAL_STORAGE
  • 查询策略:按拍摄时间与添加时间降序查询最近图片
  • 解码与缩放:按最大宽度计算 inSampleSize 并解码,必要时缩放
  • 压缩与预算:使用预算约束编码 JPEG,逐张评估 base64 长度,累计不超过总预算
  • 返回结构:每张图片包含 format、base64、width、height、createdAt

权限管理:PermissionRequester

  • 支持多权限一次性申请
  • 对需要理由的权限弹窗说明用途
  • 对被拒绝且不再提示的权限,引导用户前往应用设置开启
  • 内部互斥与超时控制,保证并发安全

JPEG 尺寸限制器:JpegSizeLimiter

  • 输入:初始宽高、起始质量、最大字节数
  • 策略:优先降低质量,再逐步缩小尺寸,直到满足上限
  • 输出:最终字节、宽高、质量

屏幕录制(跨平台对比)

  • iOS 实现要点:
    • 计算配置:时长、帧率、音频开关、输出路径
    • 启动/停止/写入:分阶段回调,准备写入器、处理视频样本、完成写入
    • FPS 采样间隔:按目标帧率去抖,避免过量写入
  • macOS 实现要点:
    • 可选音频输入配置(AAC)
    • 流停止错误记录与日志

依赖关系分析

  • CameraHandler 依赖 CameraCaptureManager、PermissionRequester、CameraHudState
  • CameraCaptureManager 依赖 CameraX(ProcessCameraProvider、ImageCapture、VideoCapture)、ExifInterface、JpegSizeLimiter
  • PhotosHandler 依赖系统媒体存储 ContentResolver、Bitmap 解码与压缩
  • PermissionRequester 依赖 Android ActivityResultLauncher 与系统设置页面
classDiagram
class CameraHandler {
+handleList(params)
+handleSnap(params)
+handleClip(params)
}
class CameraCaptureManager {
+listDevices()
+snap(params)
+clip(params)
}
class PermissionRequester {
+requestIfMissing(perms)
}
class PhotosHandler {
+handlePhotosLatest(params)
}
class JpegSizeLimiter {
+compressToLimit(...)
}
class CameraHudState {
+token
+kind
+message
}
CameraHandler --> CameraCaptureManager : "调用"
CameraHandler --> PermissionRequester : "请求权限"
CameraHandler --> CameraHudState : "更新HUD"
CameraCaptureManager --> JpegSizeLimiter : "压缩JPEG"
PhotosHandler --> PermissionRequester : "读取相册需权限"

性能考量

  • 拍照路径
    • 预热与方向:先旋转再缩放,减少重复变换开销
    • 压缩预算:JPEG 压缩与尺寸缩放双管齐下,确保 payload 不超限
  • 录制路径
    • 最低质量:优先降低质量而非分辨率,兼顾体积与清晰度
    • 预览绑定:强制绑定 Preview 以激活编码管线,避免无有效数据
    • 超时与清理:录制完成后等待 Finalize,超时或失败及时删除临时文件
  • 相册读取
    • inSampleSize 估算与按预算编码,避免一次性加载过大位图
    • 累计预算控制,保证多图返回不越界

应用架构

Android 应用位于 apps/android/app 模块,采用 Kotlin + Jetpack Compose UI + OkHttp WebSocket 通信的现代 Android 技术栈。核心目录与职责概览:

  • app/src/main/java/ai/openclaw/app
    • 入口与生命周期:NodeApp、MainActivity、NodeForegroundService
    • 状态与视图模型:MainViewModel
    • 核心运行时:NodeRuntime 及其子系统(网关会话、Canvas、相机、语音等)
    • UI 层:RootScreen 与各功能页(Compose)
    • 配置与清单:AndroidManifest.xml、build.gradle.kts
  • app/src/main/res:资源与主题
  • 测试:app/src/test/java 下按功能域分层测试
graph TB
subgraph "应用进程"
NA["NodeApp<br/>Application"]
MA["MainActivity<br/>ComponentActivity"]
VM["MainViewModel<br/>AndroidViewModel"]
NS["NodeForegroundService<br/>Service"]
end
subgraph "运行时核心"
NR["NodeRuntime<br/>核心编排"]
GS["GatewaySession<br/>操作端/节点端"]
CC["CanvasController<br/>WebView 承载"]
CM["CameraCaptureManager<br/>CameraX 封装"]
end
NA --> NR
MA --> VM
VM --> NR
MA --> NS
NR --> GS
NR --> CC
NR --> CM

核心组件

  • NodeApp:应用入口,负责严格模式调试与延迟初始化 NodeRuntime 单例
  • MainActivity:承载 Compose UI,绑定 MainViewModel,处理权限与系统窗口标志,延后启动前台服务
  • MainViewModel:将 NodeRuntime 的大量 StateFlow 暴露给 UI,并转发用户设置与连接控制命令
  • NodeRuntime:应用核心运行时,聚合网关会话、Canvas、相机、位置、短信、语音等子系统,统一状态与事件分发
  • NodeForegroundService:前台服务,根据 NodeRuntime 状态流动态更新通知
  • GatewaySession:封装 WebSocket 连接、鉴权、RPC 请求/响应、事件分发与自动重连
  • CanvasController:WebView 容器与调试状态注入,提供快照与脚本执行能力
  • CameraCaptureManager:基于 CameraX 的拍照/视频录制封装,含权限与 EXIF 方向处理

架构总览

应用采用“单例运行时 + 响应式状态流”的架构模式:

  • NodeApp.lazy 持有 NodeRuntime,避免冷启动路径冗余
  • NodeRuntime 使用协程与 StateFlow 组织多源状态(连接、Canvas、相机、语音、聊天等),并通过 GatewaySession 与远端网关保持双向通信
  • MainActivity 仅承担 UI 与生命周期职责,通过 MainViewModel 访问运行时能力
  • NodeForegroundService 以前台服务形式常驻,实时反映连接状态与麦克风监听状态
sequenceDiagram
participant App as "NodeApp"
participant Runtime as "NodeRuntime"
participant Operator as "GatewaySession(操作端)"
participant Node as "GatewaySession(节点端)"
participant Service as "NodeForegroundService"
App->>Runtime : lazy 初始化
Note over Runtime : 启动协程作用域并注册各子系统
Runtime->>Operator : 构建连接参数并发起连接
Runtime->>Node : 构建连接参数并发起连接
Operator-->>Runtime : 连接成功/失败回调
Node-->>Runtime : 连接成功/失败回调
Runtime-->>Service : 状态流变化连接/服务器名/麦克风
Service-->>Service : 更新通知

详细组件分析

启动流程与生命周期

  • 应用启动
    • AndroidManifest 指定 Application 为 NodeApp,Activity 为 MainActivity
    • NodeApp 在 onCreate 中启用严格模式(调试构建)并延迟初始化 NodeRuntime
  • MainActivity 生命周期
    • onCreate:禁用系统窗口装饰适配、初始化 PermissionRequester、绑定相机/短信权限请求器、设置 UI 主题与根屏幕、在首帧后延时启动 NodeForegroundService
    • onStart/onStop:切换前台状态,影响 NodeRuntime 内部的语音会话与外部音频捕获标记
  • 前台服务
    • NodeForegroundService 在 onCreate 中创建通知通道并首次显示“正在启动”通知
    • 通过组合 NodeRuntime 的多个状态流,动态更新标题与内容;支持从通知触发断开连接
flowchart TD
Start(["应用启动"]) --> AppInit["NodeApp.onCreate()<br/>启用严格模式"]
AppInit --> RuntimeInit["NodeApp.runtime.lazy 初始化"]
RuntimeInit --> ActivityInit["MainActivity.onCreate()"]
ActivityInit --> BindPerms["绑定权限请求器"]
ActivityInit --> SetUI["设置主题与根屏幕"]
ActivityInit --> DelayStart["首帧后延时启动前台服务"]
ActivityInit --> Foreground["NodeForegroundService.start()"]
Foreground --> CombineState["合并状态流<br/>连接/服务器/麦克风"]
CombineState --> UpdateNotify["更新通知"]
ActivityInit --> OnStart["onStart() 设置前台=true"]
ActivityInit --> OnStop["onStop() 设置前台=false"]

状态管理与 MainViewModel

  • MainViewModel 作为 AndroidViewModel,持有 NodeApp.runtime 并直接暴露 NodeRuntime 的大量 StateFlow(连接状态、Canvas 状态、相机/位置/麦克风/扬声器、聊天状态等)
  • 提供 setter 方法将用户设置与连接控制命令转发至 NodeRuntime,实现 UI 对运行时的可控访问
  • 通过 viewModels() 在 MainActivity 中获取实例,确保进程内共享与生命周期感知
classDiagram
class MainViewModel {
+canvas : CanvasController
+camera : CameraCaptureManager
+sms : SmsManager
+isConnected : StateFlow<Boolean>
+statusText : StateFlow<String>
+chat* 系列状态
+set*() 用户设置方法
+connect()/disconnect()
+sendChat()/abortChat()
}
class NodeRuntime {
+canvas : CanvasController
+camera : CameraCaptureManager
+sms : SmsManager
+gateways/statusText/pendingGatewayTrust
+chat* 系列状态
+set*() 用户设置
+connect()/disconnect()
+sendChat()/abortChat()
}
MainViewModel --> NodeRuntime : "委托调用"

NodeRuntime:运行时核心

  • 协程与作用域:使用 SupervisorJob + Dispatchers.IO 管理子系统任务,保证异常不扩散
  • 子系统聚合:Canvas、相机、位置、短信、通知、系统、照片、联系人、日历、运动、A2UI、Invoke 分发器等
  • 网关会话:维护两个 GatewaySession(操作端与节点端),分别处理连接、断开、事件与 RPC 请求
  • 状态流:对外暴露大量只读 StateFlow,内部通过 MutableStateFlow 维护可变状态并进行去抖与合并
  • 自动连接:依据偏好设置与发现列表,自动连接可信网关(基于存储的 TLS 指纹)
  • Canvas A2UI:支持从 WebView 触发 agent.request 并回传状态反馈
classDiagram
class NodeRuntime {
-scope : CoroutineScope
-canvas : CanvasController
-camera : CameraCaptureManager
-location : LocationCaptureManager
-sms : SmsManager
-discovery : GatewayDiscovery
-operatorSession : GatewaySession
-nodeSession : GatewaySession
-invokeDispatcher : InvokeDispatcher
+connect()/disconnect()
+requestCanvasRehydrate()
+handleCanvasA2UIActionFromWebView()
}
class GatewaySession {
+connect()
+disconnect()
+reconnect()
+request()
+sendNodeEvent()
}
class CanvasController {
+attach()/detach()
+navigate()
+eval()/snapshot*
}
NodeRuntime --> CanvasController : "持有"
NodeRuntime --> GatewaySession : "两个会话"
NodeRuntime --> CanvasController : "A2UI 交互"

UI 与导航

  • RootScreen:根据 onboardingCompleted 决定展示引导流程或主标签页
  • MainActivity:设置系统窗口装饰、权限请求器、绑定相机/短信权限、根据生命周期控制“防休眠”标志位、渲染主题与根屏幕
  • NodeForegroundService:根据运行时状态流动态更新通知,支持从通知断开连接
sequenceDiagram
participant UI as "RootScreen"
participant VM as "MainViewModel"
participant NR as "NodeRuntime"
UI->>VM : collect onboardingCompleted
alt 未完成
UI->>UI : 显示引导流程
else 已完成
UI->>UI : 显示主标签页
end
VM->>NR : set*()/connect()/sendChat()

网络与安全

  • GatewaySession 使用 OkHttp WebSocket 实现连接、鉴权、RPC 请求与事件分发
  • 支持 TLS 参数解析与指纹校验,首次连接时捕获指纹并提示用户验证,随后持久化到偏好中
  • 自动重连策略随失败次数指数退避,上限保护
flowchart TD
Connect["发起连接"] --> Challenge["接收挑战 nonce"]
Challenge --> Auth["构造 connect 参数<br/>签名/公钥/角色/权限"]
Auth --> Send["发送 connect 请求"]
Send --> Resp{"响应 ok?"}
Resp -- 是 --> Ready["建立会话<br/>保存 canvasHostUrl/mainSessionKey"]
Resp -- 否 --> Fail["抛出错误并断开"]
Ready --> Event["事件/请求分发"]
Event --> Retry["异常/断开后按指数退避重连"]

设备能力与媒体

  • CanvasController:WebView 容器,支持导航、调试状态注入、JS 评估与图片快照(PNG/JPEG)
  • CameraCaptureManager:基于 CameraX 的拍照与视频录制,含 EXIF 方向旋转、质量压缩、最大尺寸限制与权限检查
classDiagram
class CanvasController {
+attach()/detach()
+navigate()
+eval()
+snapshotPngBase64()
+snapshotBase64()
}
class CameraCaptureManager {
+snap()
+clip()
+listDevices()
-ensureCameraPermission()
-ensureMicPermission()
}
NodeRuntime --> CanvasController : "持有"
NodeRuntime --> CameraCaptureManager : "持有"

依赖关系分析

  • 模块耦合
    • NodeApp 与 NodeRuntime:单例持有,低耦合高内聚
    • MainActivity 与 MainViewModel:通过 ViewModelProvider 解耦,UI 不直接依赖运行时
    • NodeRuntime 与子系统:通过组合模式聚合,职责清晰
  • 外部依赖
    • Jetpack Compose、Material3、Navigation
    • OkHttp WebSocket、CameraX、Kotlinx Serialization、Kotlinx Coroutines
    • BouncyCastle、CommonMark 等
graph LR
NA["NodeApp"] --> NR["NodeRuntime"]
MA["MainActivity"] --> VM["MainViewModel"]
VM --> NR
NR --> GS["GatewaySession"]
NR --> CC["CanvasController"]
NR --> CM["CameraCaptureManager"]
MA --> NS["NodeForegroundService"]

性能考量

  • 启动路径优化:MainActivity 在首帧后才启动前台服务,减少冷启动阻塞
  • 状态流去抖与合并:NodeRuntime 使用 combine + distinctUntilChanged 控制 UI 更新频率
  • 图片快照与压缩:CanvasController 与 CameraCaptureManager 对图片进行缩放与质量压缩,避免超大负载
  • 协程调度:IO 调度器用于网络与磁盘 IO,Main 调度器用于 UI 相关操作
  • 通知更新:NodeForegroundService 仅在状态变化时更新通知,降低系统开销

语音功能

Android 语音相关代码主要位于应用模块的 voice 包与根级配置类中,配合 UI 层的引导与权限请求,形成完整的语音工作流。

graph TB
subgraph "Android 应用"
A["VoiceWakeMode<br/>唤醒模式枚举"]
B["VoiceWakeManager<br/>唤醒监听器"]
C["WakeWords<br/>唤醒词工具"]
D["SecurePrefs<br/>安全偏好存储"]
E["VoiceWakeCommandExtractor<br/>命令提取器"]
F["TalkDirectiveParser<br/>指令解析器"]
G["TalkModeManager<br/>Talk 模式管理器"]
H["ElevenLabsStreamingTts<br/>流式 TTS"]
I["OnboardingFlow<br/>引导与权限"]
J["VoiceTabScreen<br/>语音标签页 UI"]
end
A --> D
C --> D
D --> B
D --> G
B --> E
G --> F
G --> H
I --> J
J --> G

核心组件

  • 语音唤醒模式:VoiceWakeMode 定义 off/foreground/always 三种模式,并提供从原始字符串解析的方法。
  • 唤醒词管理:WakeWords 提供解析、变更检测与清洗逻辑;SecurePrefs 负责持久化存储与默认值。
  • 唤醒监听:VoiceWakeManager 使用 Android SpeechRecognizer 进行持续监听,结合 VoiceWakeCommandExtractor 提取触发后的命令。
  • Talk 指令解析:TalkDirectiveParser 解析首行 JSON 指令,支持多键名别名映射,剥离后返回纯文本与未知键列表。
  • Talk 模式:TalkModeManager 实现录音、转写、对话、TTS 播放与中断控制,支持 ElevenLabs 流式 TTS 与系统 TTS 双通道。
  • 流式 TTS:ElevenLabsStreamingTts 通过 WebSocket 接收实时音频,AudioTrack/PCM 或 MediaPlayer 播放。

架构总览

Android 语音功能采用“手动触发 + 云端转写 + 本地/云端 TTS”的混合架构。用户在语音标签页点击开始录音,TalkModeManager 启动 SpeechRecognizer,转写为文本后发送到网关,等待最终回复并进行 TTS 播放。若具备 ElevenLabs 凭证则优先使用其流式 TTS,否则回退系统 TTS。

sequenceDiagram
participant UI as "语音标签页 UI"
participant TM as "TalkModeManager"
participant SR as "SpeechRecognizer"
participant GW as "网关会话"
participant EL as "ElevenLabs"
participant SYS as "系统TTS"
UI->>TM : 开始录音
TM->>SR : 启动识别(云转写)
SR-->>TM : 部分/最终结果
TM->>GW : chat.send(带会话键)
GW-->>TM : agent 流事件/最终事件
alt 有 ElevenLabs 凭证
TM->>EL : 流式合成(WebSocket)
EL-->>TM : 音频流
TM-->>UI : 播放音频
else 回退
TM->>SYS : 文本转语音
SYS-->>TM : 语音输出
TM-->>UI : 播放语音
end

详细组件分析

语音唤醒模式与唤醒词管理

  • VoiceWakeMode:定义三种模式,提供字符串到枚举的解析,默认值为前台模式。
  • WakeWords:限制最大数量与长度,支持逗号分隔解析、变更检测与清洗。
  • SecurePrefs:持久化存储唤醒词列表与模式,提供默认唤醒词集合;加载时进行 JSON 解码与清洗。
classDiagram
class VoiceWakeMode {
+Off
+Foreground
+Always
+fromRawValue(raw)
}
class WakeWords {
+maxWords : Int
+maxWordLength : Int
+parseCommaSeparated(input)
+parseIfChanged(input, current)
+sanitize(words, defaults)
}
class SecurePrefs {
+setWakeWords(words)
+setVoiceWakeMode(mode)
+loadWakeWords()
+loadVoiceWakeMode()
}
VoiceWakeMode <.. SecurePrefs : "使用"
WakeWords <.. SecurePrefs : "清洗/校验"

语音唤醒监听与命令提取

  • VoiceWakeManager:封装 SpeechRecognizer 生命周期,处理错误与重启;监听部分/最终结果,调用 VoiceWakeCommandExtractor 提取命令。
  • VoiceWakeCommandExtractor:基于触发词正则匹配,提取触发词之后的自然语言命令,过滤空值与标点。
flowchart TD
Start(["开始监听"]) --> Listen["SpeechRecognizer 启动"]
Listen --> OnResult{"收到结果?"}
OnResult --> |否| Restart["延迟重启"]
OnResult --> |是| Extract["提取命令"]
Extract --> Valid{"命令有效?"}
Valid --> |否| Restart
Valid --> |是| Dispatch["派发命令回调"]
Dispatch --> Restart

Talk 指令解析机制

  • TalkDirectiveParser:解析首行 JSON 指令,支持多键名别名(如 voice_id、speakerBoost 等),记录未知键;剥离指令后返回纯文本与未知键列表。
flowchart TD
In(["输入文本"]) --> Split["按行分割"]
Split --> FindHead["定位首个非空行"]
FindHead --> IsObj{"是否为 JSON 对象?"}
IsObj --> |否| ReturnPlain["返回纯文本与空未知键"]
IsObj --> |是| Parse["解析对象字段"]
Parse --> MapKeys["键名归一化映射"]
MapKeys --> BuildDirective["构建指令对象"]
BuildDirective --> HasDirective{"存在有效字段?"}
HasDirective --> |否| ReturnPlain
HasDirective --> |是| Strip["移除首行与空行"]
Strip --> CollectUnknown["收集未知键"]
CollectUnknown --> Out(["返回指令+文本+未知键"])

Talk 模式:录音、转写、TTS 播放

  • 录音与转写:TalkModeManager 使用 SpeechRecognizer,启用云转写与静默窗口策略,避免过早结束。
  • 对话与订阅:支持 chat.subscribe 订阅事件流,缓存最终文本,减少轮询。
  • TTS 播放:优先 ElevenLabs 流式 TTS(WebSocket),失败或不支持时回退到文件下载播放或系统 TTS;支持音频焦点与中断控制。
sequenceDiagram
participant TM as "TalkModeManager"
participant SR as "SpeechRecognizer"
participant GW as "网关"
participant ST as "ElevenLabsStreamingTts"
participant AT as "AudioTrack/MediaPlayer"
participant SYS as "系统TTS"
TM->>SR : startListening(云转写)
SR-->>TM : 部分/最终结果
TM->>GW : chat.send + subscribe
GW-->>TM : 流式/最终事件
alt ElevenLabs 可用
TM->>ST : start + sendText
ST-->>TM : 音频流
TM->>AT : 播放
else 回退
TM->>SYS : speak
SYS-->>TM : 语音
TM->>AT : 播放
end

依赖关系分析

  • 组件耦合
    • VoiceWakeManager 依赖 WakeWords 与 VoiceWakeCommandExtractor,受 SecurePrefs 中的模式与触发词影响。
    • TalkModeManager 依赖 GatewaySession、ElevenLabsStreamingTts 与系统 TTS,内部维护状态流与播放令牌。
  • 外部依赖
    • Android SpeechRecognizer(云转写)
    • ElevenLabs API(流式 TTS)
    • Android AudioManager/AudioTrack/MediaPlayer(音频播放)
graph LR
SW["SecurePrefs"] --> VWM["VoiceWakeManager"]
WW["WakeWords"] --> VWM
VWE["VoiceWakeCommandExtractor"] --> VWM
VWM --> CMD["命令回调"]
TM["TalkModeManager"] --> SR["SpeechRecognizer"]
TM --> GW["GatewaySession"]
TM --> ELS["ElevenLabsStreamingTts"]
TM --> SYS["系统TTS"]

性能考虑

  • 转写与静默策略
    • 使用云转写模型,配合静默窗口参数,提升自然停顿后的识别稳定性。
    • 在播放期间可选择“说话时打断”以避免录音拾取播放音频导致的设备特定问题。
  • TTS 播放路径
    • 优先 WebSocket 流式 PCM,降低延迟;失败时降级为 MP3 文件下载播放,提高兼容性。
    • AudioTrack 缓冲区大小与最小缓冲区计算,避免 OEM 设备(如 OxygenOS/OnePlus)的 AudioTrack 写入问题。
  • 状态与资源管理
    • 使用播放令牌与生成器确保并发播放不会互相干扰;及时释放 MediaPlayer/AudioTrack 与 SpeechRecognizer。
    • 缓存最终聊天文本,减少历史查询开销。

Android 应用

Android 应用位于 apps/android 目录,采用 Gradle 多模块结构,核心模块为 app;测试与基准位于 benchmark、test 等目录。应用通过 Jetpack Compose 构建 UI,使用 OkHttp WebSocket 连接网关,结合 CameraX 实现相机与视频录制能力,并通过自研 NodeRuntime 统一调度各类节点命令与状态。

graph TB
subgraph "应用层"
MA["MainActivity<br/>生命周期与权限绑定"]
VM["MainViewModel<br/>状态与行为入口"]
RT["NodeRuntime<br/>运行时与会话调度"]
end
subgraph "节点处理层"
CAM["CameraCaptureManager<br/>拍照/录屏"]
DEVH["DeviceHandler<br/>设备信息/健康/权限"]
INV["InvokeDispatcher<br/>命令分发"]
A2UI["A2UIHandler<br/>Canvas/A2UI"]
end
subgraph "网关通信层"
GS["GatewaySession<br/>WebSocket/RPC"]
DISC["GatewayDiscovery<br/>发现/信任提示"]
end
subgraph "系统与权限"
PERM["PermissionRequester<br/>动态权限请求"]
SVC["NodeForegroundService<br/>前台服务"]
NLS["DeviceNotificationListenerService<br/>通知监听"]
end
MA --> VM
VM --> RT
RT --> CAM
RT --> DEVH
RT --> INV
RT --> A2UI
RT --> GS
RT --> DISC
MA --> PERM
MA --> SVC
MA --> NLS

核心组件

  • MainActivity:负责应用启动、权限请求器绑定、前台服务启动时机、保持屏幕常亮等。
  • MainViewModel:桥接 UI 与 NodeRuntime,暴露状态流与操作方法。
  • NodeRuntime:统一运行时,管理网关会话、节点命令分发、Canvas/A2UI、语音、聊天、设备能力等。
  • PermissionRequester:封装动态权限请求流程,支持理由对话与设置页引导。
  • CameraCaptureManager:基于 CameraX 的拍照与录屏能力,支持参数化控制与权限校验。
  • DeviceHandler:提供设备状态、信息、权限与健康度查询。
  • GatewaySession:基于 OkHttp 的 WebSocket 会话,负责连接、RPC 请求、事件分发与重连。

架构总览

应用采用“运行时统一调度 + 节点处理器 + 网关会话”的分层架构。NodeRuntime 将 UI 层与底层系统能力解耦,通过 InvokeDispatcher 将命令路由到具体处理器(如 CameraHandler、LocationHandler、DeviceHandler 等),并通过 GatewaySession 与网关建立长连接,实现命令调用、事件推送与 Canvas/A2UI 交互。

sequenceDiagram
participant UI as "UI/MainActivity"
participant VM as "MainViewModel"
participant RT as "NodeRuntime"
participant GS as "GatewaySession"
participant DISP as "InvokeDispatcher"
participant H as "各处理器(如Camera/Device)"
UI->>VM : 用户操作/状态订阅
VM->>RT : 调用连接/断开/命令
RT->>GS : 建立/维护会话
RT->>DISP : 分发命令(command,params)
DISP->>H : 路由到对应处理器
H-->>DISP : 返回结果或错误
DISP-->>RT : 汇总结果
RT-->>GS : 发送 node.invoke.result 或 node.event
GS-->>UI : 推送事件/状态更新

详细组件分析

设备控制与命令执行

  • 运行时调度:NodeRuntime 初始化多个处理器(相机、位置、设备、通知、系统、照片、联系人、日历、运动、短信、A2UI、调试等),并通过 InvokeDispatcher 将命令路由至对应处理器。
  • 命令分发:InvokeDispatcher 在处理前检查前置条件(如前台状态、相机启用、位置模式、短信可用性、录音权限等),并根据处理器返回结果构造响应。
  • 网关会话:GatewaySession 负责 WebSocket 连接、RPC 请求、事件分发与重连策略,支持 TLS 参数与指纹校验,确保首次连接的信任提示与后续自动连接的安全性。
classDiagram
class NodeRuntime {
+gateways
+discoveryStatusText
+isConnected
+nodeConnected
+statusText
+camera
+location
+sms
+canvas
+connect(endpoint)
+disconnect()
+refreshGatewayConnection()
}
class InvokeDispatcher {
+handleInvoke(command,params)
}
class GatewaySession {
+connect(endpoint,token,password,options,tls)
+request(method,params,timeout)
+sendNodeEvent(event,payload)
+reconnect()
+disconnect()
}
NodeRuntime --> InvokeDispatcher : "分发命令"
NodeRuntime --> GatewaySession : "会话管理"

相机与屏幕录制

  • 权限与参数:支持 CAMERA、RECORD_AUDIO(可选)权限;参数包括 facing、quality、maxWidth、durationMs、deviceId、includeAudio 等。
  • 拍照:使用 CameraX ImageCapture,读取 EXIF 方向并旋转/缩放,压缩至 5MB 以内,返回 base64 JPEG。
  • 录屏:使用 VideoCapture + Recorder,最低质量以减小文件体积;需预热摄像头并提供空预览以激活编码器;支持带/不带音频录制。
  • 错误处理:超时与失败场景删除临时文件、释放资源并抛出明确异常。
flowchart TD
Start(["开始 clip/snap"]) --> CheckPerm["检查相机/麦克风权限"]
CheckPerm --> |缺失| RequestPerm["弹窗请求权限"]
RequestPerm --> |拒绝| ThrowErr["抛出权限不足错误"]
RequestPerm --> |允许| BindCam["绑定生命周期与相机选择器"]
BindCam --> Mode{"操作类型"}
Mode --> |snap| TakePhoto["拍照并读取EXIF方向"]
TakePhoto --> Rotate["按EXIF旋转位图"]
Rotate --> Scale["按maxWidth缩放"]
Scale --> Limit["压缩至5MB内(base64上限)"]
Limit --> ReturnSnap["返回JPEG payload"]
Mode --> |clip| Record["准备录制(可含音频)"]
Record --> Warm["预热摄像头1.5秒"]
Warm --> StartRec["开始录制并等待定时结束"]
StartRec --> StopRec["停止录制并等待完成事件"]
StopRec --> Finalize{"是否成功完成"}
Finalize --> |否| Clean["删除临时文件并抛错"]
Finalize --> |是| ReturnClip["返回文件路径/时长/音频标记"]

设备命令与权限管理

  • 设备状态/信息/健康:DeviceHandler 提供电池、存储、网络、内存、温度、压力等级等信息;权限状态汇总(相机、麦克风、位置、短信、通知监听、通知、相册、联系人、日历、运动等)。
  • 权限请求:PermissionRequester 支持多权限批量请求、理由对话、被拒后引导至系统设置页。
  • 后台限制:应用通过前台服务维持关键能力(数据同步、通知等),并在生命周期变化时调整行为(如前台切换时停止语音会话)。
classDiagram
class DeviceHandler {
+handleDeviceStatus(params)
+handleDeviceInfo(params)
+handleDevicePermissions(params)
+handleDeviceHealth(params)
}
class PermissionRequester {
+requestIfMissing(permissions,timeout)
-showRationaleDialog(permissions)
-showSettingsDialog(permissions)
}
class NodeRuntime {
+setForeground(value)
+setMicEnabled(value)
+setSpeakerEnabled(value)
}
DeviceHandler ..> GatewaySession : "返回JSON负载"
PermissionRequester --> MainActivity : "触发系统权限对话"
NodeRuntime --> PermissionRequester : "绑定相机/短信等权限"

网络通信与设备发现

  • WebSocket 会话:GatewaySession 建立 wss/ws 连接,发送 connect 挑战、签名设备信息、接收 canvasHostUrl 与会话默认值;支持 TLS 参数与指纹校验。
  • 自动连接与信任:NodeRuntime 在发现可信网关后自动连接,首次连接要求用户确认 TLS 指纹并持久化;手动连接模式要求已保存指纹。
  • 事件与 RPC:支持 node.event 推送与 node.invoke.request 调用,InvokeDispatcher 将请求路由到对应处理器并回传结果。
sequenceDiagram
participant RT as "NodeRuntime"
participant GS as "GatewaySession"
participant GW as "网关"
RT->>GS : connect(endpoint, token/password, options, tls)
GS->>GW : 建立WebSocket
GW-->>GS : challenge(nonce)
GS->>GS : 构造connect参数(签名/权限/能力)
GS->>GW : 发送connect
GW-->>GS : 返回server/canvasHostUrl/sessionDefaults
GS-->>RT : onConnected回调
RT->>GS : sendNodeEvent / request
GS-->>RT : 事件/响应

Android 权限体系与后台服务限制

  • 权限清单:应用声明 INTERNET、NETWORK_STATE、FOREGROUND_SERVICE、POST_NOTIFICATIONS、NEARBY_WIFI_DEVICES、LOCATION、CAMERA、RECORD_AUDIO、SMS、READ_MEDIA_*、READ_CONTACTS、READ_CALENDAR、ACTIVITY_RECOGNITION 等。
  • 动态权限:相机、录音、短信、通知监听等在运行时请求;PermissionRequester 提供理由对话与设置页引导。
  • 后台限制:应用通过前台服务维持数据同步;前台切换时停止语音会话以节省资源;最小化对电池与性能的影响。

依赖关系分析

  • 构建与工具链:根级 build.gradle.kts 引入 Android 插件、ktlint、Compose、Serialization 插件;app/build.gradle.kts 配置编译目标、签名、依赖与打包规则。
  • 第三方库:OkHttp、Material3、CameraX、dnsjava、BCProv、Commonmark、Kotlinx Serialization、Kotlinx Coroutines、Kotest/Robolectric 测试框架等。
  • 资源与图标:mipmap、values、xml 等资源目录用于主题、备份/数据提取规则、网络配置与文件提供者。
graph LR
A["根构建脚本"] --> B["应用模块构建脚本"]
B --> C["OkHttp/网络"]
B --> D["CameraX/相机"]
B --> E["Material3/Compose"]
B --> F["Kotlinx/序列化"]
B --> G["测试框架"]

性能考虑

  • 启动路径精简:MainActivity 在首帧后才启动前台服务,降低冷启动时间。
  • 拍摄与录制优化:拍照前旋转/缩放在主线程完成但耗时短;录屏使用最低质量与空预览激活编码器,缩短初始化时间。
  • 资源压缩:JPEG 压缩限制在 5MB 内,避免传输与解析开销过大。
  • 任务调度:使用 SupervisorJob 与 IO 线程池隔离网络与 I/O;状态流组合与去重减少 UI 重组。
  • 打包与混淆:开启资源压缩与 ProGuard 规则,排除无关文件与元数据。

🚀 深入浅出 Event Loop:带你彻底搞懂 JS 执行机制

2026年3月14日 00:19

你好呀,掘金的各位小伙伴们!👋

今天我们要来聊一个老生常谈,但每次提起都能让人“掉几根头发”的话题 —— Event Loop(事件循环)

你是不是也曾在面试中被面试官那迷离的眼神注视着,问道:

“同学,你能告诉我 setTimeoutPromiseasync/await 到底谁先执行吗?为什么?” 🤯

或者在写代码时,发现打印出来的日志顺序和你想的完全不一样,怀疑人生?🤔

别担心!今天这篇文章,我们就把这些“玄学”问题一次性讲清楚!我们将不再只是死记硬背“宏任务”、“微任务”的定义,而是结合浏览器底层原理,用最轻松愉快的语气,带你啃下这块最硬的骨头!🍖

准备好了吗?我们要发车啦!🚗💨


🧐 第一部分:浏览器的“打工”日常 —— 进程与线程

在深入 Event Loop 之前,我们得先了解一下 JS 代码运行的“环境” —— 浏览器。

大家常说“JS 是单线程的”,但这其实是说 JS 的主线程 是单线程的。现代浏览器(比如 Chrome)其实是一个多进程的架构。你可以把浏览器想象成一个大型工厂 🏭。

1.1 浏览器的核心“部门”(进程)

这个工厂里有几个核心部门(进程),它们各司其职:

  • Browser 进程 🧠:工厂的“厂长”。负责界面显示、用户交互、子进程管理等。
  • GPU 进程 🎨:负责 3D 绘制等图形工作。
  • Network 进程 📡:负责网络资源加载。
  • Plugin 进程 🧩:负责插件运行。
  • Renderer 进程(渲染进程) 🏗️:重点来了! 这是我们要关注的主角。每个 Tab 页通常都有自己独立的渲染进程。

1.2 渲染进程的“繁忙”生活

渲染进程的主要职责是把 HTML、CSS、JS 变成用户看得到的网页。这个部门里有一个超级忙碌的员工,名叫 “渲染主线程” (Main Thread)

这个主线程有多忙呢?来看看它的 To-Do List:

  1. 解析 HTML 📄:生成 DOM 树。
  2. 计算样式 💅:构建 CSSOM 树。
  3. 布局 (Layout) 📐:计算元素的位置和大小,生成 Layout Tree。
  4. 分层 (Layer) 🍰:处理图层。
  5. 绘制 (Paint) 🖌️:生成绘制指令。
  6. 执行 JavaScript ⚡:处理业务逻辑、交互等。

划重点:⚠️ 渲染和 JS 执行是互斥的! 也就是说,主线程在执行 JS 的时候,就不能进行渲染;在渲染的时候,就不能执行 JS。它就像一个单核 CPU,同一时间只能做一件事。


🤔 第二部分:为什么 JS 必须是单线程?

你可能会问:“既然主线程这么忙,为什么不多招几个人(多线程)一起干呢?”

想象一下,如果 JS 是多线程的:

  • 线程 A 想把某个 DOM 节点删掉 ❌。
  • 线程 B 想给同一个 DOM 节点添加子元素 ➕。
  • 这时候浏览器该听谁的?🤷‍♂️

为了避免这种复杂的同步问题,JS 从诞生之初就设计为单线程。简单、高效,但副作用就是——容易堵车。🚗🚕🚙


🔄 第三部分:Event Loop —— 永不休止的循环

既然是单线程,如果遇到耗时的任务怎么办?比如:

  • 网络请求(要等几秒钟)⏳
  • 定时器(要等几秒钟)⏲️
  • 用户点击(不知道什么时候点)🖱️

如果主线程傻傻地等着这些任务完成,那页面早就卡死了!😱

为了解决这个问题,浏览器引入了 消息队列 (Message Queue)事件循环 (Event Loop) 机制。

3.1 浏览器的“排队”策略

我们可以把主线程看作是一个永不停歇的检票员 👮‍♂️。

  1. 同步任务:就像是买了 VIP 票的乘客,直接在主线程上执行,立即处理。
  2. 异步任务
    • 主线程发起异步任务(比如 setTimeoutfetch)。
    • 相应的其他线程(如定时器线程、网络线程)去处理这些耗时操作。
    • 一旦处理完成(比如时间到了、数据回来了),这些线程会把回调函数包装成一个任务,扔进消息队列里排队。

3.2 循环机制 (The Loop)

主线程(检票员)的工作逻辑是这样的:

// 伪代码模拟 Event Loop
for (;;) {
    // 1. 看看消息队列里有没有任务
    Task task = message_queue.take();
    
    if (task) {
        // 2. 有任务,拿出来执行
        Process(task);
    } else {
        // 3. 没任务,休息一下,等待新任务(休眠)
        Sleep();
    }
}

这就是 Event Loop!主线程不断地从消息队列中取出任务执行,执行完一个,再去取下一个。


⚖️ 第四部分:宏任务 vs 微任务 —— 优先级的博弈

但是!事情并没有那么简单。队列不只有一个! 为了更精细地控制任务的执行时机,浏览器把异步任务分成了两类:

4.1 🐢 宏任务 (Macro Task)

通常我们说的“任务”就是指宏任务。消息队列里的每一个任务本质上都是宏任务。

  • 来源script (整体代码)、setTimeoutsetInterval、I/O、UI 交互事件、postMessage 等。
  • 特点:每次 Event Loop 循环只执行一个宏任务。

4.2 🐇 微任务 (Micro Task)

微任务是 VIP 中的 VIP,它不需要去普通的消息队列排队,而是有一个专门的微任务队列

  • 来源Promise.then/catch/finallyasync/awaitMutationObserverqueueMicrotask
  • 特点在当前宏任务执行结束后,下一次渲染之前,会立即清空所有的微任务! 🧹

4.3 🔄 完整的 Event Loop 流程

  1. 执行一个宏任务(最开始是 script 整体代码)。
  2. 遇到同步代码:直接执行。
  3. 遇到微任务:放入微任务队列。
  4. 遇到宏任务:交给其他模块处理,处理完放入宏任务队列。
  5. 当前宏任务执行完毕
  6. 检查微任务队列
    • 如果有微任务,依次执行所有微任务,直到队列为空。(如果在执行微任务的过程中又产生了新的微任务,也会在这一轮里被执行掉!无限套娃警告 ⚠️)
  7. 尝试进行页面渲染 (UI Rendering) 🎨。(并不是每次循环都会渲染,通常 60Hz 频率下每 16.6ms 渲染一次)。
  8. 开始下一轮 Event Loop:从宏任务队列取下一个任务执行。

⚔️ 第五部分:硬核实战 —— 代码执行全解析

光说不练假把式。我们拿一段包含各种情况的复杂代码来“解剖”一下!🔪

这是我们的测试代码:

// 1.html 源码解析
<script>
    // ------------------- 代码开始 -------------------
    console.log('同步代码 1'); // line 10

    setTimeout(() => { // line 12
        console.log('setTimeout 1');
        Promise.resolve().then(() => {
            console.log('setTimeout 1 内部微任务');
        });
    }, 0);

    const promise1 = new Promise((resolve) => { // line 19
        console.log('Promise 构造函数');
        resolve();
        console.log('Promise 构造函数内 resolve 后');
    });

    promise1.then(() => { // line 25
        console.log('Promise.then 1');
        setTimeout(() => {
            console.log('Promise.then 1 内部 setTimeout');
        }, 0);
    });

    async function asyncFn() { // line 32
        console.log('async 函数同步部分');
        // await 后面的所有代码 作为 promise.then 的回调函数里面的代码
        await Promise.resolve(); // 异步变同步的语法糖,本质还是异步的
        console.log('await 后微任务');
    }

    asyncFn(); // line 39

    console.log('同步代码 2'); // line 41

    // html5 标准 微任务队列
    queueMicrotask(() => { // line 43
        console.log('queueMicrotask 微任务');
    });

    // 额外增加 DOM 监听类微任务(前端特有)
    const observer = new MutationObserver(() => { // line 48
        console.log('MutationObserver 微任务');
    });
    const div = document.createElement('div');
    observer.observe(div, { attributes: true });
    div.setAttribute('data-test', '1'); // 触发 MutationObserver
    // ------------------- 代码结束 -------------------
</script>

🕵️‍♂️ 详细执行步骤解析

我们将整个执行过程分为三个阶段:

  1. 第一轮宏任务(Script 整体代码)执行
  2. 清空微任务队列
  3. 第二轮宏任务(如果有)

🎬 第一阶段:执行主线程同步代码(Script 宏任务)

  1. Line 10: console.log('同步代码 1')
    • 👉 输出: '同步代码 1'
  2. Line 12: setTimeout(..., 0)
    • 这是一个宏任务。浏览器将回调函数交给定时器线程。因为是 0ms,它会尽快被放入宏任务队列
    • 🏗️ 宏任务队列: [setTimeout1_callback]
  3. Line 19: new Promise(...)
    • 注意Promise 的构造函数是同步执行的!
    • console.log('Promise 构造函数') 👉 输出: 'Promise 构造函数'
    • resolve():Promise 状态变为 Resolved。
    • console.log('Promise 构造函数内 resolve 后') 👉 输出: 'Promise 构造函数内 resolve 后'
  4. Line 25: promise1.then(...)
    • 这是一个微任务。因为 promise1 已经 resolve 了,回调函数被放入微任务队列
    • 🧬 微任务队列: [promise1_then_callback]
  5. Line 39: 执行 asyncFn()
    • 进入函数体。
    • Line 33: console.log('async 函数同步部分') 👉 输出: 'async 函数同步部分'
    • Line 35: await Promise.resolve()
      • await 这一行右边的代码是同步执行的(这里是 Promise.resolve())。
      • 关键点await 就像一个分界线。它下面的代码(console.log('await 后微任务'))会被相当于放入一个 Promise.then 中。
      • 所以,await 后面的逻辑进入微任务队列
    • 🧬 微任务队列: [promise1_then_callback, async_await_callback]
  6. Line 41: console.log('同步代码 2')
    • 👉 输出: '同步代码 2'
  7. Line 43: queueMicrotask(...)
    • 直接添加一个微任务。
    • 🧬 微任务队列: [promise1_then_callback, async_await_callback, queueMicrotask_callback]
  8. Line 48-53: MutationObserver
    • div.setAttribute 修改了 DOM,触发了观察者。这是一个微任务。
    • 🧬 微任务队列: [..., queueMicrotask_callback, mutation_observer_callback]

🏁 第一阶段小结控制台输出

同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2

当前队列状态

  • 微任务队列[promise1.then, async_await, queueMicrotask, MutationObserver]
  • 宏任务队列[setTimeout1]

🧹 第二阶段:清空微任务队列

Script 宏任务执行完了,现在主线程空了。Event Loop 检查微任务队列,发现一大堆任务,开始依次执行。

  1. 执行 promise1.then 回调
    • console.log('Promise.then 1') 👉 输出: 'Promise.then 1'
    • setTimeout(..., 0):产生一个新的宏任务!放入宏任务队列。
    • 🏗️ 宏任务队列: [setTimeout1, setTimeout_inside_then]
  2. 执行 asyncFnawait 后的代码
    • console.log('await 后微任务') 👉 输出: 'await 后微任务'
  3. 执行 queueMicrotask 回调
    • console.log('queueMicrotask 微任务') 👉 输出: 'queueMicrotask 微任务'
  4. 执行 MutationObserver 回调
    • console.log('MutationObserver 微任务') 👉 输出: 'MutationObserver 微任务'

🏁 第二阶段小结: 微任务队列清空了!🎉 控制台新增输出

Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务

🎬 第三阶段:执行下一个宏任务

微任务清空后,浏览器可能会进行渲染(Render)。然后 Event Loop 再次转动,去宏任务队列取任务。

  1. 取出 setTimeout1 的回调

    • console.log('setTimeout 1') 👉 输出: 'setTimeout 1'
    • Promise.resolve().then(...)注意! 这里又产生了一个微任务!
    • 这个微任务会立刻被加入微任务队列。
    • 🧬 微任务队列: [setTimeout1_microtask]
    • 当前宏任务执行完毕
  2. 再次检查微任务队列(你以为完了?并没有!):

    • 发现刚才新产生的微任务 [setTimeout1_microtask]
    • 立即执行!
    • console.log('setTimeout 1 内部微任务') 👉 输出: 'setTimeout 1 内部微任务'
  3. 取出 setTimeout_inside_then 的回调(来自 promise1.then 内部):

    • console.log('Promise.then 1 内部 setTimeout') 👉 输出: 'Promise.then 1 内部 setTimeout'

✅ 最终输出结果

同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务
setTimeout 1
setTimeout 1 内部微任务
Promise.then 1 内部 setTimeout

📝 第六部分:总结与避坑指南

通过上面的分析,我们可以总结出几条黄金法则:

  1. JS 主线程是单线程的,依靠 Event Loop 搞定异步。
  2. 同步代码优先:所有同步代码都在第一个宏任务(Script)中执行。
  3. 微任务插队:宏任务执行完,必须清空微任务队列,才能去执行下一个宏任务。
  4. Promise 构造函数是同步的then 才是微任务。
  5. await 是分水岭await 右边是同步,下面是微任务。

💡 为什么懂这个很重要?

  • 性能优化:如果你在微任务里写了死循环或者巨量计算,会导致页面卡死,因为宏任务(如渲染、点击响应)永远没机会执行!这叫“微任务阻塞”。
  • Bug 排查:理解执行顺序,才能知道为什么你的数据没更新,或者为什么 DOM 还没渲染出来代码就报错了。

希望这篇文章能帮你彻底打通 Event Loop 的任督二脉!下次面试,请自信地告诉面试官:“我不仅知道结果,我还知道浏览器底层是怎么跑的!” 😎


本文代码示例基于 Chrome 浏览器环境,不同浏览器或 Node.js 版本可能存在细微差异,但标准模型大同小异。

一文详解JS中的执行顺序——事件循环(宏任务、微任务)

作者 晴栀ay
2026年3月13日 23:47

一文详解JS中的执行顺序——事件循环(宏任务、微任务)

为什么 JavaScript 是单线程的?

JavaScript 诞生的初衷是为了处理网页上的简单交互,比如表单验证。试想一下,如果 JavaScript 是多线程的:

  • 线程 A 想修改某个 DOM 节点的内容
  • 线程 B 想删除同一个 DOM 节点

这就会导致复杂的同步问题(锁机制),对于轻量级的网页脚本来说太重了。因此,JavaScript 从诞生起就是单线程的,这意味着它在同一时间只能做一件事。

但“单线程”并不意味着它慢。JavaScript 巧妙地利用了异步非阻塞机制,配合 Event Loop (事件循环),让它能够高效地处理大量并发任务(如网络请求、定时器、DOM 事件)。

核心概念解析

为了理解 Event Loop,我们需要先搞清楚几个“角色”:

同步任务 (Synchronous)

那些立即执行不等待其他操作完成、并且按顺序在主线程上依次执行的任务就是同步任务。

你可以直接把这段代码复制到浏览器的控制台(F12 -> Console)运行:

console.log('1. 任务开始');

// 【同步任务】:一个极其耗时的循环
// 假设我们要计算 10 亿次加法
let sum = 0;
const limit = 1000000000; // 10 亿

console.log('2. 开始执行耗时同步任务 (请观察页面是否卡住)...');

for (let i = 0; i < limit; i++) {
  sum += i;
  // 注意:在这个循环结束前,JS 引擎绝对不会去处理任何其他事情
  // 你的鼠标点击、页面滚动、定时器回调、网络请求完成等,全部被阻塞!
}

console.log('3. 耗时任务结束,结果:', sum);

当今浏览器的性能虽说不至于卡死,但是在进行计算的这一秒内你尝试滚动页面,发现页面似乎无响应了,这就是JS的主进程被阻塞,无法执行其他任务(页面滚动)。

异步任务 (Asynchronous)

异步任务就是“现在不执行,将来某个时刻再执行”的任务。  它们不会阻塞主线程,而是将回调函数注册好,交给浏览器(或 Node.js)的 API 去处理,等处理完了,再把回调函数放入队列,等待 Event Loop 在合适的时机执行。

宏任务 (MacroTask)

  • 代表一个个离散的、独立的任务。
  • 浏览器为了能够使 JS 内部 task 与 DOM 任务能够有序的执行,会在一个 task 执行结束后,在下一个 task 执行开始前,对页面进行重新渲染。
  • 常见宏任务
    • 整体代码 script (可以理解为第一个宏任务)
    • setTimeout / setInterval
    • UI 渲染 / I/O

微任务 (MicroTask)

  • 优先级高于宏任务(除了当前的 script)。
  • 在当前宏任务执行结束后,下一次渲染之前,会立即清空所有的微任务。
  • 常见微任务
    • Promise.then / catch / finally
    • async/await (本质是 Promise)
    • MutationObserver (监听 DOM 变化)
    • queueMicrotask
image.png ---

Event Loop 执行流程

这就是 JavaScript 永不停歇的“心脏”跳动机制:

  1. 执行栈 (Call Stack) 选择最先进入队列的宏任务(通常是整体 script 代码),执行其同步代码。
  2. 执行过程中,遇到微任务,将其放入微任务队列
  3. 执行过程中,遇到宏任务(如 setTimeout),将其回调放入宏任务队列
  4. 当前宏任务执行完毕(Call Stack 清空)。
  5. 关键步骤:检查微任务队列。如果有微任务,依次执行所有微任务,直到队列清空。
    • 注意:如果在执行微任务的过程中又产生了新的微任务,会继续添加到队列末尾并在本次循环中一并执行!这可能导致“死循环”阻塞页面渲染。
  6. 渲染页面(如果有必要)。
  7. 检查宏任务队列,取出下一个宏任务,回到步骤 1。

口诀:

同步先行 -> 清空微任务 -> 渲染 -> 下一个宏任务

代码案例

让我们通过一段复杂的代码来彻底捋清楚执行顺序。

// 1. 同步代码
console.log('同步代码 1');

// 2. 宏任务 (setTimeout)
setTimeout(() => {
  console.log('setTimeout 1');
  Promise.resolve().then(() => {
    console.log('setTimeout 1 内部微任务');
  });
}, 0);

// 3. Promise 构造函数 (同步)
const promise1 = new Promise((resolve) => {
  console.log('Promise 构造函数');
  resolve();
  console.log('Promise 构造函数内 resolve 后');
});

// 4. 微任务 (Promise.then)
promise1.then(() => {
  console.log('Promise.then 1');
  setTimeout(() => {
    console.log('Promise.then 1 内部 setTimeout');
  }, 0);
});

// 5. Async/Await (同步+微任务)
async function asyncFn() {
  console.log('async 函数同步部分');
  // await 相当于 Promise.resolve().then(...)
  // await 这一行及之后的代码会被放入微任务队列
  await Promise.resolve(); 
  console.log('await 后微任务');
}

asyncFn();

// 6. 同步代码
console.log('同步代码 2');

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

// 8. 微任务 (MutationObserver)
const observer = new MutationObserver(() => {
  console.log('MutationObserver 微任务');
});
const div = document.createElement('div');
observer.observe(div, { attributes: true });
div.setAttribute('data-test', '1'); // 触发

执行步骤详解

第一轮:执行 Script 宏任务(同步代码)

  1. 执行 console.log('同步代码 1')

    • 控制台输出同步代码 1
  2. 执行 setTimeout(...)

    • 宏任务队列[SetTimeout1]
  3. 执行 new Promise(...)

    • 控制台输出Promise 构造函数
    • 控制台输出Promise 构造函数内 resolve 后
  4. 执行 promise1.then(...)

    • 微任务队列[Then1]
  5. 执行 asyncFn()

    • 控制台输出async 函数同步部分
    • 微任务队列[Then1, Await]
  6. 执行 console.log('同步代码 2')

    • 控制台输出同步代码 2
  7. 执行 queueMicrotask(...)

    • 微任务队列[Then1, Await, Queue]
  8. 执行 MutationObserver

    • 微任务队列[Then1, Await, Queue, Observer]

第二轮:清空微任务队列

  1. 取出 Then1 执行

    • 控制台输出Promise.then 1
    • 宏任务队列[SetTimeout1, SetTimeout2]
  2. 取出 Await 执行

    • 控制台输出await 后微任务
  3. 取出 Queue 执行

    • 控制台输出queueMicrotask 微任务
  4. 取出 Observer 执行

    • 控制台输出MutationObserver 微任务

第三轮:执行下一个宏任务

  1. 取出 SetTimeout1 执行

    • 控制台输出setTimeout 1
    • 微任务队列[InnerThen] (宏任务中产生的微任务)
  2. 清空微任务队列(执行 InnerThen

    • 控制台输出setTimeout 1 内部微任务

第四轮:执行再下一个宏任务

  1. 取出 SetTimeout2 执行
    • 控制台输出Promise.then 1 内部 setTimeout

最终输出结果

同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务
setTimeout 1
setTimeout 1 内部微任务
Promise.then 1 内部 setTimeout

(注:微任务之间的顺序主要取决于入队顺序,awaitPromise.then 的具体先后可能因浏览器版本/ECMAScript 规范版本略有差异,但在现代浏览器中通常如上所示。MutationObserver 和 queueMicrotask 通常也在微任务队尾)

易错点与避坑指南

Promise 构造函数是同步的

很多人误以为 new Promise 里的代码是异步的。错!只有 .then().catch() 里的回调才是异步微任务。

Await 的本质

await xxx 相当于 Promise.resolve(xxx).then(() => { ...后续代码... })。它把异步代码写得像同步一样,但本质上它是让出了线程,把后续代码扔进了微任务队列。

微任务死循环 (Microtask Loop)

这是一个非常危险的操作! 宏任务执行完一个,会给 UI 渲染的机会。 微任务则是“死磕到底”——只要队列不空,就不停地执行。

如果你在微任务里不断添加新的微任务:

function loop() {
  Promise.resolve().then(loop); // 无限递归微任务
}
loop();

结果:页面完全卡死。因为主线程一直忙着清空微任务,根本没机会去执行 UI 渲染,也没机会去执行下一个宏任务(如点击事件、定时器)。这比 while(true) 更隐蔽,但同样致命。

总结

JavaScript 的 Event Loop 就像一个不知疲倦的调度员:

  1. 先处理手里现有的急事(同步代码)。
  2. 处理完急事,马上看看有没有“小纸条”(微任务),有就一口气全处理完。
  3. 如果“小纸条”处理完了,喘口气,看看能不能画画(UI渲染)。
  4. 最后再去信箱里拿下一封信(宏任务),开始新的轮回。

理解了这个机制,你就能明白为什么 setTimeout(fn, 0) 不一定准时,为什么大量计算要放在 Web Worker 里,以及为什么你的页面有时候会莫名其妙地卡顿了。

❌
❌