阅读视图

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

Python 性能微观世界:列表推导式 vs for 循环

前言:你一定听过列表推导式(List Comprehension),但作为一个追求性能的工程狮,我们不能只看它写起来帅,更要搞清楚:在底层,凭什么往往比传统的 for 循环更快?


1. 语义对比:从“怎么做”到“做什么”

  • for 循环:命令式编程。你告诉 Python:先创建一个空列表,然后取出一个元素,处理一下,最后塞进列表。
  • 列表推导式:声明式编程。你告诉 Python:我想要这样一个列表,它的元素来源于此,规则如下。

Python

# 需求:生成 1 到 100 万的平方列表
# for 循环写法
squares_for = []
for i in range(1000000):
    squares_for.append(i * i)

# 列表推导式写法
squares_comp = [i * i for i in range(1000000)]

2. 性能深度拆解:为什么推导式更快?

很多人以为推导式只是 for 循环的简写,其实不然。两者的差异在于字节码(Bytecode)执行效率

A. 减少了 append 的函数查找

for 循环中,每次执行 squares_for.append(),Python 都要做两件事:

  1. 加载属性:在内存中查找 squares_for 对象的 append 方法。
  2. 函数调用:调用该方法并将结果推入列表。

而在列表推导式中,Python 使用了专门的字节码指令 LIST_APPEND。这是一条直接在 C 语言层面实现的底层操作,跳过了在循环中反复查找 append 属性的过程。

B. 字节码证据

我们用 Python 内置的 dis 模块来观察两者的“真面目”:

Python

import dis

def for_loop():
    l = []
    for i in range(10):
        l.append(i)

def list_comp():
    l = [i for i in range(10)]

print("--- For 循环字节码 ---")
dis.dis(for_loop)
print("\n--- 列表推导式字节码 ---")
dis.dis(list_comp)

关键差异点:

  • for_loop 中会反复出现 LOAD_METHODCALL_METHOD
  • list_comp 中直接使用了 LIST_APPEND,执行效率更高。

3. 实战避坑:推导式是万能的吗?

虽然推导式快,但在工程实践中,我们要警惕三个“重灾区”:

① 内存炸弹

推导式会立即生成整个列表。如果你处理的是 10 亿条数据,列表推导式会瞬间撑爆你的 RAM。

  • 对策:使用生成器表达式(Generator Expression) 。只需把 [] 换成 ()

Python

# 生成器:省内存,随用随取,O(1) 空间复杂度
squares_gen = (i * i for i in range(1000000000)) 

② 可读性灾难(Nested Logic)

当推导式嵌套超过两层,或者带有复杂的 if-else 时,它就变成了“代码天书”。

  • 原则:如果一行推导式超过 80 个字符,或者逻辑嵌套太深,请老老实实写回 for 循环。

③ 逻辑副作用

推导式应该只用于生成新列表。如果你在推导式里调用具有副作用的函数(比如打印 log、修改全局变量),那简直是代码维护者的噩梦。


4. 性能实测数据

在 Python 3.11+ 环境下,处理 1000 万个数据点:

方法 耗时 (ms) 相对速度
for 循环 + append ~850 100% (基准)
map + lambda ~720 118%
列表推导式 ~510 166%

💡 总结

  1. 首选推导式:在简单的数据转换和过滤场景下,列表推导式是性能和简洁度的双重赢家。
  2. 拒绝炫技:嵌套推导式(Nested Comprehension)是代码质量的杀手,业务代码中尽量保持单层。
  3. 大数据的归宿:处理大数据流时,请务必转投 生成器(Generator) 的怀抱。

TypeScript 强力护航:PropType 与组件事件类型的声明

前言

在 Vue 3 + TypeScript 的项目中,组件的类型安全是一个核心话题。很多开发者可能有过这样的经历:使用一个第三方组件时,完全不知道它接受哪些 Props,也不知道事件应该传递什么参数,只能去翻文档。或者在自己的项目中,修改了一个组件的 Props,结果到处报错,不得不全局搜索手动修改。

TypeScript 的出现改变了这一切。通过为组件 Props 和事件声明类型,我们不仅能获得完美的智能提示,还能让编译器在开发阶段就发现类型错误。本文将深入探讨如何在 Vue 3 中为组件定义类型安全的 Props 和事件,包括复杂的泛型组件实现。

Vue 组件类型系统的演进

Options API 中的 Prop 类型:运行时校验

在 Options API 中,我们通过对象形式定义 Props:

export default {
  props: {
    // 基础类型检查
    name: String,
    age: Number,
    
    // 带验证的写法
    email: {
      type: String,
      required: true,
      validator: (value: string) => value.includes('@')
    },
    
    // 复杂类型
    user: {
      type: Object,
      default: () => ({})
    }
  }
}

这种写法存在很多局限性:

  • 运行时类型检查:这些类型只在运行时验证,TypeScript 无法在编译时捕获错误
  • 复杂类型无法表达:user: Object 无法描述对象的内部结构
  • 没有智能提示:在模板中使用 props 时,编辑器不知道有哪些属性

Composition API 带来的类型优势

Composition API 配合 TypeScript,让类型推导变得更加强大:

<script setup lang="ts">
// 现在可以获得类型推导
const props = defineProps({
  name: String,
  age: Number
})

// props.name 被推导为 string | undefined
// props.age 被推导为 number | undefined
</script>

但这种方法仍然有局限性,无法定义复杂的嵌套类型。

为什么需要显式的 PropType?

当 Props 的类型不是简单的 String、Number 等构造函数时,就需要 PropType 来帮助 TypeScript 理解类型。我们先来看一个反例:

// ❌ 这样写,TypeScript 会报错
defineProps({
  user: {
    type: Object as User, // 'User' only refers to a type, but is being used as a value here
    required: true
  }
})

正确写法:

defineProps({
  user: {
    type: Object as PropType<User>, // 告诉 TypeScript 这是一个 User 类型
    required: true
  },
  
  // 联合类型
  status: {
    type: String as PropType<'active' | 'inactive'>,
    default: 'active'
  },
  
  // 复杂对象
  config: {
    type: Object as PropType<{
      theme: string
      fontSize: number
    }>,
    default: () => ({ theme: 'light', fontSize: 14 })
  }
})

Props 定义的三种方式

运行时声明 + 类型推导(基础写法)

<script setup lang="ts">
// 基础类型会自动推导
const props = defineProps({
  name: String,           // props.name: string | undefined
  age: Number,            // props.age: number | undefined
  isActive: Boolean,      // props.isActive: boolean | undefined
  tags: Array,            // props.tags: any[] | undefined
  user: Object            // props.user: Record<string, any> | undefined
})

// 设置默认值
const propsWithDefault = defineProps({
  count: {
    type: Number,
    default: 0
  },                      // props.count: number
  items: {
    type: Array,
    default: () => []
  }                       // props.items: any[]
})
</script>
  • 优点:写法简单,有运行时类型检查
  • 缺点:复杂类型无法表达,如 string[] 会被推导为 any[]

纯类型声明(推荐)

这是 Vue 3.3+ 推荐的方式,使用 TypeScript 接口或类型别名:

<script setup lang="ts">
// 定义 Props 接口
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
}

interface Config {
  theme: 'light' | 'dark'
  fontSize: number
  showAvatar?: boolean
}

interface Props {
  title: string
  count?: number
  user: User
  config: Config
  tags: string[]
  status: 'loading' | 'success' | 'error'
}

// 直接使用接口
const props = defineProps<Props>()

// 需要默认值时,使用 withDefaults
const propsWithDefault = withDefaults(defineProps<Props>(), {
  count: 0,
  tags: () => [],
  config: () => ({ theme: 'light', fontSize: 14 })
})
</script>
  • 优点:

    • 完美的类型推导
    • 支持任何复杂的 TypeScript 类型
    • 编辑器智能提示完美
  • 缺点:

    • 需要 Vue 3.3+ 版本
    • 不能同时使用运行时验证(如 validator 函数)

复杂类型的处理:PropType 工具类型

当需要运行时验证,又想保留类型时,使用 PropType:

<script setup lang="ts">
import type { PropType } from 'vue'

// 定义复杂类型
interface User {
  id: number
  name: string
  email: string
  preferences: {
    theme: 'light' | 'dark'
    notifications: boolean
  }
}

type Status = 'pending' | 'processing' | 'completed' | 'failed'

// 使用 PropType 辅助类型推导
const props = defineProps({
  // 对象类型
  user: {
    type: Object as PropType<User>,
    required: true,
    validator: (user: User) => user.name.length > 0
  },
  
  // 联合类型
  status: {
    type: String as PropType<Status>,
    default: 'pending'
  },
  
  // 数组类型
  tags: {
    type: Array as PropType<string[]>,
    default: () => []
  },
  
  // 函数类型
  onSave: {
    type: Function as PropType<(data: User) => Promise<void>>,
    required: false
  },
  
  // 复杂的嵌套类型
  config: {
    type: Object as PropType<{
      pagination: {
        pageSize: number
        currentPage: number
      }
      filters: Record<string, any>
    }>,
    default: () => ({
      pagination: { pageSize: 10, currentPage: 1 },
      filters: {}
    })
  }
})
</script>

适用场景:

  • 需要运行时验证(如 validator)
  • 需要设置复杂的默认值逻辑
  • 需要与 Options API 混用

事件发射的类型安全

defineEmits 的基础用法

<script setup lang="ts">
// 基础写法:字符串数组
const emit = defineEmits(['change', 'update', 'delete'])

// 使用时没有任何类型提示
emit('change', 123) // 可以传任意参数
emit('update', 'any', 'thing') // 没问题
</script>

为事件负载定义类型(推荐)

<script setup lang="ts">
// 使用类型声明
interface Emits {
  // 基础事件
  (e: 'change', value: string): void
  (e: 'update:id', id: number): void
  (e: 'delete'): void
  
  // 多个参数
  (e: 'item-move', fromIndex: number, toIndex: number): void
  
  // 联合类型的事件名
  (e: 'success' | 'error', message: string): void
}

const emit = defineEmits<Emits>()

// 使用时的类型检查
emit('change', '新值')      // ✅ 正确
emit('change', 123)         // ❌ 错误:参数类型必须是 string
emit('update:id', 1)        // ✅ 正确
emit('delete')              // ✅ 正确
emit('item-move', 0, 5)     // ✅ 正确
emit('item-move', 0)        // ❌ 错误:缺少第二个参数
</script>

v-model 的类型安全

<script setup lang="ts">
// 单个 v-model
interface Emits {
  (e: 'update:modelValue', value: string): void
  (e: 'update:searchText', value: string): void
  (e: 'update:selectedIds', ids: number[]): void
}

const emit = defineEmits<Emits>()

// 多个 v-model 的使用
function handleInput(value: string) {
  emit('update:modelValue', value)
}

function handleSearch(value: string) {
  emit('update:searchText', value)
}

function handleSelect(ids: number[]) {
  emit('update:selectedIds', ids)
}
</script>

<template>
  <!-- 父组件使用时获得类型提示 -->
  <ChildComponent 
    v-model="text"
    v-model:search-text="searchText"
    v-model:selected-ids="selectedIds"
  />
</template>

泛型组件的实现技巧

使用 defineComponent 配合泛型

在 Vue 3.3 之前,需要使用 defineComponent 来创建泛型组件:

// GenericTable.ts
import { defineComponent, PropType } from 'vue'

export default defineComponent({
  name: 'GenericTable',
  
  props: {
    data: {
      type: Array as PropType<any[]>,
      required: true
    },
    columns: {
      type: Array as PropType<TableColumn<any>[]>,
      required: true
    },
    rowKey: {
      type: [String, Function] as PropType<string | ((row: any) => string)>,
      required: true
    }
  },
  
  emits: {
    'sort-change': (sort: SortState) => true,
    'row-click': (row: any, index: number) => true
  },
  
  setup(props, { emit }) {
    // 实现逻辑
    return () => {
      // 渲染函数
    }
  }
})

// 使用时需要手动指定类型
const table = GenericTable as <T extends Record<string, any>>(
  new () => {
    $props: TableProps<T>
  }
)

在 SFC 中使用

Vue 3.3 引入了 generic 属性,让泛型组件的实现变得简单:

<script setup lang="ts" generic="T extends { id: string | number }">
// T 必须包含 id 属性
defineProps<{
  items: T[]
  selectedId?: T['id']
}>()

defineEmits<{
  select: [id: T['id']]
}>()
</script>

类型推导的局限性及解决方案

问题 1:模板中的类型推导

<script setup lang="ts" generic="T">
defineProps<{
  data: T[]
  format: (item: T) => string
}>()
</script>

<template>
  <div v-for="item in data" :key="item.id">
    <!-- ❌ item.id 可能不存在于 T 上 -->
    {{ format(item) }}
  </div>
</template>
解决方案:添加泛型约束
<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{
  data: T[]
  format: (item: T) => string
}>()
</script>

问题 2:事件参数的类型推导

<script setup lang="ts" generic="T">
const emit = defineEmits<{
  (e: 'update', item: T): void  // ❌ T 在这里无法推导
}>()
</script>
解决方案:使用运行时声明 + PropType
<script setup lang="ts">
import type { PropType } from 'vue'

const props = defineProps({
  items: {
    type: Array as PropType<T[]>,
    required: true
  }
})

const emit = defineEmits({
  'update': (item: any) => true
})
</script>

类型安全组件的收益

使用组件时的智能提示

当其他开发者在使用我们的组件时,VS Code 会提供完美的智能提示:

<template>
  <!-- 输入 <Table 就会弹出所有 Props 提示 -->
  <Table
    :data="users"
    :columns="columns"
    :row-key="'id'"
    @sort-change="handleSortChange"
    @row-click="handleRowClick"
  />
</template>

错误提前暴露

<script setup>
// ❌ 编译时报错:Property 'nme' does not exist on type 'User'
const columns = [
  { key: 'nme', title: '姓名' } // 拼写错误
]

// ❌ 编译时报错:Type 'string' is not assignable to type 'number'
const handleSortChange = (sort: SortState) => {
  sort.field = 123 // 类型错误
}
</script>

更好的可维护性

当需要修改组件 Props 时,TypeScript 会标记所有使用错误的地方:

// 将 Props 从 TableColumn 改为 ColumnConfig
interface TableProps<T> {
  columns: ColumnConfig<T>[] // 修改了类型
  // ...
}

// 所有使用了旧类型的地方都会报错,不需要手动查找

类型安全组件的最佳实践清单

  • 优先使用纯类型声明(defineProps())
  • 复杂类型使用 PropType 辅助
  • 为所有事件定义类型,包括负载参数
  • 使用泛型创建可复用组件,并添加必要约束
  • 导出组件的 Props 和 Emits 类型,方便使用者
  • 为插槽定义类型,提供更好的使用体验

结语

类型安全不是一蹴而就的,而是在开发过程中逐步完善的。它不仅是为了迎合 TypeScript ,更是为了让我们的代码更加健壮,让团队协作更加顺畅。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

组件设计原则:如何设计一个高内聚、低耦合的 Vue 组件

前言

在 Vue 应用开发中,组件就像是乐高积木,组件设计可以决定这些积木的形状和接口。好的设计可以让积木自由组合,构建出各种复杂的应用;而一个坏的设计则让积木之间互不兼容,最终导致代码难以维护、难以复用、难以测试。

尤其是随着项目规模的增长,组件设计的重要性愈发凸显。本文将深入探讨高内聚低耦合的核心概念,通过大量实战案例,帮助我们掌握 Vue 组件设计的精髓。

为什么组件设计如此重要?

现实痛点

开篇之前,我们先来看一个设计不良的组件会带来哪些问题:

<!-- ❌ 反例:一个上千行的 "上帝组件" -->
<template>
  <div>
    <!-- 用户信息区域 -->
    <div class="user-section">
      <img :src="user.avatar">
      <h2>{{ user.name }}</h2>
      <!-- 几百行用户相关代码 -->
    </div>
    
    <!-- 好友列表区域 -->
    <div class="friends-section">
      <!-- 又是几百行好友列表代码 -->
    </div>
    
    <!-- 动态列表区域 -->
    <div class="activities-section">
      <!-- 还有几百行动态列表代码 -->
    </div>
  </div>
</template>

<script>
export default {
  props: ['user'], // 什么类型?不知道
  data() {
    return {
      user: {},
      friends: [],
      activities: [],
      loading: false,
      error: null,
      // ... 还有诸多数据字段
    }
  },
  methods: {
    // 所有方法全部混在一起
    fetchUser() { /* ... */ },
    fetchFriends() { /* ... */ },
    fetchActivities() { /* ... */ },
    followUser() { /* ... */ },
    unfollowUser() { /* ... */ },
    likeActivity() { /* ... */ },
    // ... 其他方法
  }
}
</script>

这个组件存在的问题:

  • 牵一发而动全身:修改用户信息的样式,可能会意外影响好友列表
  • 难以复用:想在另一个页面显示好友列表?那只能复制粘贴上百行代码
  • 难以理解:新接手的人需要花一天时间才能理清逻辑
  • 难以测试:如何单独测试好友列表的功能?

好的组件设计带来的收益

<!-- ✅ 好的设计:拆分为独立组件 -->
<template>
  <div class="user-profile-page">
    <UserInfoCard :user="user" />
    <FriendList :friends="friends" @follow="handleFollow" />
    <ActivityFeed :activities="activities" @like="handleLike" />
  </div>
</template>

<script setup>
// 容器组件:只负责数据获取和组合
const { user, friends, activities } = await fetchUserData(props.userId)

function handleFollow(userId) { /* ... */ }
function handleLike(activityId) { /* ... */ }
</script>

这个组件带来的好处:

  • 可维护性:每个组件独立修改,互不影响
  • 可复用性:这个组件可以在任何地方使用
  • 可测试性:可以为每个组件编写独立的单元测试
  • 可读性:代码即文档,一目了然

高内聚低耦合:组件设计的黄金法则

什么是高内聚?

高内聚是指组件内部的元素(数据、方法、模板等)紧密相关,共同完成一个明确的职责:

<!-- ✅ 高内聚的计数器组件:所有逻辑都服务于"计数"这个单一职责 -->
<template>
  <div class="counter">
    <button @click="decrement" :disabled="count <= min">-</button>
    <span class="count">{{ count }}</span>
    <button @click="increment" :disabled="count >= max">+</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const props = defineProps<{
  min?: number
  max?: number
  initial?: number
}>()

// 所有数据和方法都围绕 count 展开
const count = ref(props.initial ?? 0)

function increment() {
  if (count.value < (props.max ?? Infinity)) {
    count.value++
  }
}

function decrement() {
  if (count.value > (props.min ?? -Infinity)) {
    count.value--
  }
}
</script>

<style scoped>
/* 样式也只服务于这个组件 */
.counter {
  display: flex;
  align-items: center;
  gap: 8px;
}
</style>

高内聚的特征

  • 组件名称准确地描述了它的功能
  • 组件的所有代码都是为了实现这个功能
  • 移除任何一个部分都会影响核心功能

什么是低耦合?

低耦合是指组件之间的依赖关系简单、明确,修改一个组件不需要修改另一个组件:

<!-- 父组件 -->
<template>
  <div>
    <UserCard
      :user="user"
      @follow="handleFollow"
      @unfollow="handleUnfollow"
    />
  </div>
</template>

<!-- 子组件:不知道父组件的任何信息 -->
<template>
  <div class="user-card">
    <img :src="user.avatar" :alt="user.name">
    <h3>{{ user.name }}</h3>
    <button 
      v-if="!isFollowing"
      @click="$emit('follow', user.id)"
    >
      关注
    </button>
    <button 
      v-else
      @click="$emit('unfollow', user.id)"
    >
      取消关注
    </button>
  </div>
</template>

<script setup>
defineProps<{
  user: { id: number; name: string; avatar: string }
  isFollowing?: boolean
}>()

defineEmits<{
  follow: [userId: number]
  unfollow: [userId: number]
}>()
</script>

低耦合的特征

  • 组件只通过 Props 接收数据,通过 Events 发送消息
  • 组件内部不依赖全局状态(除非必要)
  • 修改组件内部实现,不需要修改使用它的地方

内聚与耦合的关系

高内聚和低耦合是相辅相成的:

  • 高内聚是低耦合的基础:只有组件内部职责清晰,才能设计出清晰的接口
  • 低耦合让高内聚更有价值:如果组件之间耦合度高,即使每个组件内聚再好,系统也难以维护

组件划分的边界艺术

如何判断一个组件是否应该拆分?

当我们在犹豫是否要拆分一个组件时,可以问问自己这几个问题:

  • 独立复用:这个部分能否在其他地方使用?
  • 独立逻辑:这个部分是否有独立的业务逻辑?
  • 频繁变化:这个部分是否会频繁修改?
  • 代码规模:代码是否过长,如是否超过 300 行?
  • 过度拆分:是否为了拆分而拆分,导致组件冗余?

原子设计方法论

原子设计方法论是由 Brad Frost 提出的一种用于构建设计系统的方法论。它借鉴了化学中的基本概念,认为所有的用户界面(UI)都可以由一系列基本的、不可再分的元素(原子)组合而成。其核心思想是分层构建,就像搭积木一样,从最小的单元开始,逐步组合成越来越复杂的结构,这个过程分为五个层次:

原子(Atoms)→ 分子(Molecules)→ 组织(Organisms)→ 模板(Templates)→ 页面(Pages)

原子

原子 是构成用户界面的最基本、最小的元素,无法再进一步细分。其本身不具备独立的功能性,但它们定义了所有设计元素的基础样式和属性。比如一个 <label> 标签、一个 <input> 输入框、一个 <button> 按钮、颜色调色板、字体、动画等:

<template>
  <button>原子按钮</button>
</template>

分子

分子 由多个原子组合在一起形成的相对简单的 UI 组件,具有简单、明确的功能,遵循“单一职责原则”,即:只做一件事,且把这件事做得很好。比如一个“搜索框”分子可以由一个 <label> 原子(“搜索”文字)、一个 <input> 原子(输入框)和一个 <button> 原子(“搜索”按钮)组合而成。这三个原子结合在一起,就形成了一个能执行搜索功能的最小单元:

<template>
  <div class="search-bar">
    <label>搜索:<label>
    <input v-model="searchText" />
    <button @click="search">搜索</button>
  </div>
</template>

组织

组织 由分子、原子以及其他组织组合而成的相对复杂的 UI 结构。它们构成了页面中一个独立的区域,作为页面中功能完善的模块,但本身还不是一个完整的页面。比如“用户列表”,由多个“用户卡片”分子构成:

<template>
  <div class="user-list">
    <UserCard v-for="user in users" :key="user.id" :user="user" />
  </div>
</template>

模板

模板 将多个组织、分子和原子组合在一起,形成页面的 骨架和布局结构。其关注的是内容在页面上的 排布方式,展示了各组件的相对位置和功能。如一个“管理布局”模板,定义了头部组织、正文内容区域和底部组织分别放在什么位置:

<template>
  <div class="layout">
    <header />
    <main>
      <SearchBar @search="handleSearch" />
      <UserList :users="filteredUsers" />
    </main>
    <footer />
  </div>
</template>

注:模板是 抽象 的,它没有填充真实的内容,只有占位符。只是定义了 布局结构

页面

页面 是模板的具体实例。它将真实的内容(文本、图片等)填充到模板中,并精确地调整整个界面的样式和逻辑,最终呈现给用户的样子。

原子设计方法论与 Vue3 的结合

Vue3 的原子:Vue3 中的基础元素组件

在 Vue3 中,原子通常对应那些只封装了最基础 HTML 元素和样式的组件。它们通常只通过 props 接收数据,并通过 $emitv-model 向外发送事件:

<!-- 1. 原子:BaseInput.vue -->
<template>
  <div class="base-input">
    <input
      :id="id"
      :type="type"
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      v-bind="$attrs"
    />
  </div>
</template>

<script setup lang="ts">
defineProps({
  id: String,
  type: { type: String, default: 'text' },
  modelValue: [String, Number]
})
defineEmits(['update:modelValue'])
</script>

Vue3 的分子:Vue3 中的功能组件

<!-- 分子:SearchForm.vue -->
<template>
  <form class="search-form" @submit.prevent="handleSubmit">
    <BaseInput
      v-model="searchText"
      label="搜索"
      placeholder="请输入关键词..."
    />
    <BaseButton type="submit">搜索</BaseButton>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'
import BaseButton from './BaseButton.vue'

const searchText = ref('')
const emit = defineEmits(['search'])

const handleSubmit = () => {
  emit('search', searchText.value)
}
</script>

Vue3 的组织:Vue3 中的区块组件

<!-- 组织:HeaderOrganism.vue -->
<template>
  <header class="site-header">
    <div class="logo">
      <img src="/logo.png" alt="Logo" />
      <span>My App</span>
    </div>
    <nav class="nav-menu">
      <a v-for="item in navItems" :key="item.link" :href="item.link">{{ item.text }}</a>
    </nav>
    <SearchForm @search="handleGlobalSearch" />
  </header>
</template>

<script setup lang="ts">
import SearchForm from './SearchForm.vue' // 导入分子

const navItems = [ /* ... */ ]
const handleGlobalSearch = (query) => { /* 处理全局搜索 */ }
</script>

Vue3 中的模板:Vue3 中的布局或页面组件(此时无数据)

模板在 Vue 中通常对应一个布局组件或一个无具体数据的页面级组件。它负责定义页面的骨架结构,引入各种组织组件,并将它们摆放在正确的位置。此时,组件接收的 propsslot 插槽内容都是抽象的占位符:

<!-- 模板:ArticlePageTemplate.vue -->
<template>
  <div class="article-page">
    <HeaderOrganism />
    <main class="content-wrapper">
      <aside class="sidebar">
        <!-- 这里是一个插槽,用于放置侧边栏内容,具体内容由页面填充 -->
        <slot name="sidebar" />
      </aside>
      <article class="main-content">
        <!-- 这里是主要内容插槽 -->
        <slot />
      </article>
    </main>
    <FooterOrganism />
  </div>
</template>

<script setup lang="ts">
import HeaderOrganism from './HeaderOrganism.vue'
import FooterOrganism from './FooterOrganism.vue'
</script>

Vue3 中的页面:Vue2 中的完整页面组件(有数据)

<!-- 页面:ArticlePage.vue -->
<template>
  <ArticlePageTemplate>
    <!-- 向模板的 sidebar 插槽填充真实内容 -->
    <template #sidebar>
      <AuthorCard :author="article.author" />
      <RelatedArticles :articles="article.related" />
    </template>

    <!-- 向默认插槽填充文章正文 -->
    <ArticleContent :article="article" />
  </ArticlePageTemplate>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ArticlePageTemplate from './ArticlePageTemplate.vue'
import AuthorCard from './AuthorCard.vue'
import RelatedArticles from './RelatedArticles.vue'
import ArticleContent from './ArticleContent.vue'

const article = ref({})
onMounted(async () => {
  article.value = await fetchArticleData()
})
</script>

Props 设计:定义组件的公开 API

Props 设计的黄金法则

法则一:尽可能少,尽可能明确

只接收必要的数据,不要接收和组件不相关的数据:

defineProps<{
  user: User
  isEditable?: boolean
}>()

法则二:提供合理的默认值

interface Props {
  placeholder?: string
  disabled?: boolean
  maxLength?: number
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: '请输入',
  disabled: false,
  maxLength: 100
})

法则三:使用 TypeScript 定义类型

interface User {
  id: number
  name: string
  avatar: string
  role: 'admin' | 'user' | 'guest'
}

defineProps<{
  user: User
  permissions: string[]
}>()

法则四:避免传递不必要的 props

<ChildComponent :user="user" />

Props 的 4 种类型及使用场景

1. 数据型 Props:单纯的数据展示

<UserCard 
  :user="user"
  :posts="userPosts"
/>

2. 配置型 Props:控制组件行为

<DataTable
  :show-header="true"
  :allow-sort="true"
  :page-size="20"
  :theme="'dark'"
/>

3. 回调型 Props:事件处理

<FormComponent
  @submit="handleSubmit"
  @cancel="handleCancel"
/>

4. 节点型 Props:自定义渲染

<ModalComponent>
  <template #header>
    <h2>自定义标题</h2>
  </template>
  <template #footer>
    <button>确认</button>
  </template>
</ModalComponent>

Props 命名的最佳实践

1. 使用完整单词

defineProps<{
  userName: string      // 不是 uname
  userAvatar: string    // 不是 uavatar(除非是标准术语)
}>()

2. 布尔值用 is/has/should 开头

defineProps<{
  isActive: boolean     // 状态
  hasPermission: boolean // 拥有
  shouldShow: boolean   // 应该
}>()

3. 回调函数用 on 开头

defineProps<{
  onSubmit: () => void
  onClose: () => void
}>()

4. 数组等用复数

defineProps<{
  users: User[]
}>()

事件通信:让组件之间优雅地对话

组件通信的 5 种方式及选择策略

1. Props + Events:父子组件直接通信(最常用)

<!-- 父组件 -->
<ChildComponent 
  :data="parentData"
  @update="handleUpdate"
/>

<!-- 子组件 -->
<script setup>
defineProps<{ data: string }>()
const emit = defineEmits<{
  update: [value: string]
}>()
</script>

2. v-model:双向绑定的场景(表单类)

<InputComponent v-model="searchText" />

3. Slots:父组件控制渲染内容(布局类)

<CardComponent>
  <template #header>标题</template>
  内容
  <template #footer>底部</template>
</CardComponent>

4. Provide/Inject:跨多层组件传递(主题、用户信息)

// 祖先组件
provide('theme', 'dark')
// 后代组件
const theme = inject('theme')

5. Pinia:全局状态(用户信息、购物车)

const userStore = useUserStore()

事件设计的 3 个原则

原则一:只通知,不下命令

子组件只需要告诉父组件发生了什么,至于事件发生后该做什么,要怎么做,由父组件决定,子组件不作任何处理:

const emit = defineEmits<{
  'item-selected': [item: Item]
  'form-submitted': [data: FormData]
}>()

原则二:事件粒度适中

一个操作对应一个事件,不要把所有操作放在一个事件中(太粗),也不要把不需要处理的操作放在事件中(太细):

// ✅ 好:一个操作一个事件
const emit = defineEmits<{
  'save-success': []
  'save-error': [error: Error]
}>()

// ❌ 差:太细或太粗
const emit = defineEmits<{
  'button-mousedown': []      // 太细,外部不需要知道
  'button-mouseup': []        // 太细
  'data-operation': [         // 太粗,不知道发生了什么
    type: 'create' | 'update' | 'delete',
    data: any
  ]
}>()

原则三:保持一致性

统一的命名风格,使用冒号 : 分隔命名空间:

const emit = defineEmits<{
  'user:created': [user: User]
  'user:updated': [user: User]
  'user:deleted': [userId: string]
}>()

插槽设计:让组件拥有无限可能

插槽的 3 种形式及适用场景

1. 默认插槽:简单的内容占位

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-content">
      <slot>
        <!-- 提供默认内容 -->
        <p>暂无内容</p>
      </slot>
    </div>
  </div>
</template>

<!-- 使用 -->
<Card>
  <p>这是卡片内容</p>
</Card>

2. 具名插槽:多个位置的定制

<!-- Modal.vue -->
<template>
  <div class="modal">
    <header>
      <slot name="header">默认标题</slot>
    </header>
    
    <main>
      <slot name="content">默认内容</slot>
    </main>
    
    <footer>
      <slot name="footer">
        <button @click="close">关闭</button>
      </slot>
    </footer>
  </div>
</template>

<!-- 使用 -->
<Modal>
  <template #header>
    <h2>自定义标题</h2>
  </template>
  
  <template #content>
    <p>自定义内容</p>
  </template>
  
  <template #footer>
    <button @click="confirm">确认</button>
    <button @click="cancel">取消</button>
  </template>
</Modal>

3. 作用域插槽:让父组件访问子组件数据

<!-- DataTable.vue -->
<template>
  <div class="data-table">
    <table>
      <tbody>
        <tr v-for="(item, index) in data" :key="index">
          <td v-for="col in columns" :key="col.key">
            <slot 
              :name="`column-${col.key}`"
              :value="item[col.key]"
              :row="item"
              :index="index"
            >
              {{ item[col.key] }}
            </slot>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<!-- 使用 -->
<DataTable :data="users" :columns="columns">
  <template #column-status="{ value, row }">
    <Badge :type="value === 'active' ? 'success' : 'default'">
      {{ value }}
    </Badge>
  </template>
</DataTable>

插槽设计的 3 个最佳实践

1. 提供合理的默认内容

<template>
  <div class="empty-state">
    <slot name="icon">
      <EmptyIcon />
    </slot>
    
    <slot name="message">
      <p>暂无数据</p>
    </slot>
    
    <slot name="action">
      <button @click="$emit('refresh')">刷新</button>
    </slot>
  </div>
</template>

2. 保持作用域数据的精简

<template>
  <!-- ✅ 好:只暴露必要的数据 -->
  <slot 
    :item="item"
    :index="index"
    :is-first="index === 0"
    :is-last="index === items.length - 1"
  />
  
  <!-- ❌ 差:暴露整个组件实例 -->
  <slot :this="this" :$el="$el" :$props="$props" />
</template>

3. 使用 TypeScript 定义插槽类型

<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}

defineSlots<{
  // 默认插槽不接受 props
  default(props: {}): any
  
  // 具名插槽
  header(props: {}): any
  
  // 作用域插槽
  'user-item'(props: { 
    user: User
    index: number
    isSelected: boolean
  }): any
  
  // 可选插槽
  footer?(props: {}): any
}>()
</script>

组件设计的 SOLID 原则(Vue 视角)

SOLID 原则 Vue 中的体现 实践建议
单一职责 一个组件只做一件事 组件代码不超过 300 行,功能单一明确
开闭原则 对扩展开放,对修改关闭 多用插槽,少改内部逻辑;通过 Props 配置行为
里氏替换 子组件可替换父组件 保持 Props 接口一致,遵循相同的契约
接口隔离 Props 尽可能少 避免传递整个对象,只传必要字段;用多个小 Props 替代一个大对象
依赖倒置 依赖抽象,不依赖实现 用事件通信,不直接调用父组件方法;用 provide/inject 解耦

组件设计的 10 个坏味道(Anti-Patterns)

  1. 上帝组件:超过 500 行的组件
  2. Props 泛滥:超过 10 个 props
  3. 多层级 Props 透传:props 穿过 3 层以上
  4. 组件内直接修改 props:违反了单向数据流
  5. 模板内复杂逻辑:模板中有三元运算符嵌套
  6. CSS 全局污染:没有使用 scoped 或 CSS Modules
  7. 依赖父组件结构:组件假设父组件一定有某个 DOM 结构
  8. 过度抽象:为了复用而拆分,反而更难用
  9. 隐式通信:通过修改 store 来通知兄弟组件
  10. 没有 TypeScript:组件 API 全靠文档记忆

组件设计的检查清单

设计前思考

  • 这个组件的职责是否单一?
  • 是否真的需要拆分成独立组件?
  • 这个组件会在哪些地方被使用?

设计时检查

  • Props 命名是否清晰易懂?
  • 是否提供了合理的默认值?
  • 是否使用了 TypeScript 定义类型?
  • 事件命名是否表达了发生了什么?
  • 插槽是否有合理的默认内容?
  • 样式是否 scoped?

设计后验证

  • 组件能否独立运行?(不依赖外部数据)
  • 修改组件内部,会影响外部吗?(低耦合验证)
  • 其他开发者能看懂这个组件吗?(可读性验证)
  • 能否为这个组件写单元测试?(可测试性验证)
  • 组件文档是否清晰?(可用性验证)

结语

好的组件设计不是一蹴而就的,而是在每一次重构中不断完善的过程。当我们开始思考"这个组件是否应该拆分"、"这个 Props 命名是否合理"的时候,我们就已经走在了正确的道路上了。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Vue调试神器:Vue DevTools使用指南

image

一、初识Vue Devtools

Vue DevTools 概述

  在现代前端开发中,Vue.js 应用的组件化架构虽然提升了代码复用性,但也带来了复杂的状态管理和组件交互问题。当应用包含数十个嵌套组件时,传统的 console.log 调试方式如同在黑暗中摸索。Vue.js Devtools 作为官方调试工具,通过可视化界面将组件结构、状态变化和性能数据直观呈现,让开发者能够像"透视"一样观察应用内部运行机制。

image

  Vue Devtools 是 Vue 官方发布的调试浏览器插件,可以安装在 Chrome、Firefox、Edge等浏览器上,可以帮助我们监控和管理 Vue 应用的状态、事件和性能。通过 Vue Devtools,我们可以查看组件的结构、属性和方法,以及父子组件之间的关系。此外,Vue Devtools 还提供了时间轴功能,让我们可以更好地了解应用的状态变化。

Vue DevTools 功能说明

  1. 组件树检视:能够清晰展示出应用中的组件层级结构,方便开发者理解和导航。
  2. 状态和数据查看:可以检查组件的状态,包括props、data、computed properties等。
  3. 调试事件:可以监听和触发事件,便于开发者查看事件的响应和效果。
  4. 时间旅行:这是 Vue DevTools 的高级功能之一,能够记录组件的快照,允许开发者在不同的快照之间切换,观察应用状态的变化。
  5. 控制台集成:Vue DevTools 提供了集成到浏览器控制台的能力,可以通过控制台直接与Vue实例交互。
  6. 组件信息展示:可以查看每个组件所对应的虚拟DOM结构和渲染细节。

二、环境适配:多场景下的安装与配置

浏览器扩展

  目前 Vue DevTools 主要支持 Chrome 浏览器和 Firefox 浏览器,并提供对应的浏览器扩展。对于其他平台(如Safari或Edge)的支持情况,可以通过各种主流浏览器的扩展商店进行安装。

插件:www.chajianxw.com/developer/1…

  打开 Chrome 浏览器,选择菜单“更多程序”→“扩展程序”,打开扩展程序界面,打开开发者模式,单击“加载已解压的扩展程序”按钮,将vue-devtools插件安装到Chrome 浏览器,安装结果如图:

image

  安装完成后,开发者需要在浏览器的扩展管理页面启用Vue DevTools。在使用Vue DevTools时,通常需要在Vue应用中直接运行,这时DevTools会自动识别并展示调试信息。若未看到,刷新页面或检查是否为 Vue 应用。

image

Vite Plugin

单体应用

对于Electron应用、移动端应用(NativeScript/Capacitor)或者服务端渲染应用,浏览器扩展可能无法直接使用。别担心,Vue Devtools还提供了NPM包版本

npm install -g @vue/devtools

Vue DevTools 默认仅适用于 Vue 的开发版本(非压缩版),在生产环境中默认禁用,否则就好比把家里的“透视眼镜”给小偷戴上,会暴露应用内部状态。

三、功能解析:掌握调试工具的核心能力

  在安装了 Vue Devtools 的浏览器中,打开你的 Vue 应用。然后右键点击页面,选择“Inspect”,在弹出的开发者工具中找到“Vue”选项卡,点击即可打开 Vue Devtools。

3.1 Components面板:组件世界的“上帝视角”

  在现代的前端开发中,组件化已经成为一种标准的实践方式。Vue.js 也不例外,它提供了一种灵活的方式来构建用户界面,通过组件树的层级结构来组织界面的不同部分。在 Vue 应用中,组件的父子关系是通过组件嵌套和属性传递来定义的。父组件通过在模板中声明子组件标签,并通过 props 将数据传递给子组件,从而建立起父子关系,Vue Devtools 提供了一个直观的方式来查看组件之间的这种层级结构。

  在 Vue DevTools 的“Components”标签页中,可以直观地看到整个应用的组件树结构,类似于文件系统的目录结构,从根组件(Root)开始,层层展开,让我们可以更好地了解组件的结构。每个组件都是一个节点,父组件之下包含子组件,形成清晰的层级关系。通过展开组件节点,可以查看其子组件,帮助开发者快速定位问题发生的组件区域。在组件树视图中,可以通过输入关键字来筛选组件,快速定位到关心的组件,这对于大型应用中组件众多的情况非常实用。

image

  在组件树中,选中某个组件后,右侧面板会显示该组件的属性、数据、计算属性和方法等信息。开发者可以实时查看组件状态的变化,无需在控制台中进行繁琐的打印操作。

image

  组件树中的每个组件节点不仅显示了组件的类型,还可以展开来查看其详细信息,包括组件的属性、数据、计算属性以及样式等。最刺激的是实时编辑功能——直接在Devtools中直接修改组件的 data 属性值,比如把一个按钮的 disabled 从 true 改为 false ,页面上的按钮立即变得可点击!无需刷新页面,无需重新编译,就像用手指直接拨动乐高积木一样神奇。这对于调试数据驱动的问题非常有帮助,能够快速验证数据的正确性和对组件的影响。

image

3.2 Events面板:事件流的“监听器”

  在 Vue Devtools 中,Events 面板用来监控Vue实例的所有事件。

  • 事件历史:按时间顺序显示所有触发的Vue事件(包括自定义事件)
  • 按组件筛选:只看某个特定组件触发的事件
  • 事件详情:点击事件可查看事件名称、目标组件、传递参数等信息
  • 复制数据:支持将事件数据复制到剪贴板

这对于调试复杂的组件通信(比如爷孙组件传值、兄弟组件通信)非常有用,帮助我们更好地了解事件的处理情况。

3.3 状态追踪:应用数据的"黑匣子记录仪"

  如果应用使用了Vuex(Vue 2)或Pinia(Vue 3官方推荐),Vue Devtools 会自动显示状态面板,这个面板就是你的“中央监控室”。左侧显示完整的 store 状态树,所有数据一目了然。可以展开每一个节点,查看当前所有共享状态的值。在这里,我们可以查看state、getters、mutations(Vuex)或actions(Pinia),以及它们的详细信息。通过时间线视图,开发者可以查看状态树是如何随时间变化的,帮助理解状态变化的流程。

3.4 最炫酷的“时间旅行”

  Vue Devtools 提供了一个时间轴功能,可以让我们更好地了解应用的状态变化。在时间轴中,我们可以查看每个组件的状态变化,以及它们之间的依赖关系。开发者可以回溯到过去的状态,进行状态差异的比较分析。这对于调试复杂的状态管理逻辑非常有用,能够快速定位状态变化导致的问题。

3.5 Router面板:路由导航的“导航仪”

  如果应用使用了Vue Router,Router 面板就是你的“导航仪”。在“Router”标签页中,可以查看当前路由的信息,包括路径、查询参数、路由参数等,如下图所示。

image

  同时,还能看到路由的历史记录,方便开发者了解应用的导航流程。通过观察路由的变化,开发者可以调试路由跳转、参数传递等问题。例如,当遇到路由跳转后页面不更新的问题时,可以通过查看路由变化记录,分析错误发生的原因。

3.6 Timeline面板:应用优化的"体检报告"

如何录制性能数据

  1. 切换到Timeline面板
  2. 点击左上角的“Start recording”(开始录制)按钮
  3. 在页面上执行你想要分析的操作(比如点击一个会加载大量数据的按钮)
  4. 点击“Stop recording”停止录制

数据解读:谁在“摸鱼”?

录制完成后,你会看到类似心电图的时间轴:

  • 组件渲染时间:每个组件从开始渲染到完成花了多久
  • 组件更新次数:某些组件是不是在“无效加班”(频繁无意义地重新渲染)
  • 生命周期钩子执行时间:比如mounted钩子里是不是放了太多代码导致阻塞

性能优化实战案例

通过Timeline面板,你可能会发现:

  • 某个表格组件渲染要500ms → 考虑使用虚拟滚动
  • 某个computed属性被频繁重新计算 → 考虑使用缓存或shallowRef
  • 某个组件在父组件更新时跟着乱更新 → 添加v-once或合理使用key

四、总结

  Vue Devtools是一款非常实用的工具,可以帮助我们更好地理解和管理Vue应用。使用 Vue DevTools 进行调试与性能优化,能够极大地方便开发者的工作。通过可视化 的组件树、实时数据修改、Vuex 状态跟踪及时间旅行功能,我们可以更加高效地定位问题,优化处理逻辑,提升应用性能。

image

三维模型瓦片服务三剑客:3D Tiles、I3S与S3M全解析

本文节选自新书《GIS基础原理与技术实践》第8章。当 GIS 迈入三维时代,如何高效发布与可视化海量三维模型成为关键挑战。目前,Cesium 的 3D Tiles、Esri 的 I3S 和 超图的 S3M 已成为三大主流三维瓦片标准。本文将带你深入其核心机制——从瓦片树、包围体、几何误差,到 b3dm/i3dm/pnts 格式细节,再到要素化与声明式样式,全面解析这“三维瓦片三剑客”的异同与适用场景。

GIS基础原理与技术实践

8.8 三维模型数据服务

与矢量切片服务和地形切片服务一样,三维模型数据服务也多数是以静态资源的形式进行发布的,毕竟他们还没形成比较标准的规范,不用提供额外的空间操作,只需要保证能获取资源进行可视化就可以了。因此,三维模型数据服务大多直接使用三维模型瓦片数据格式发布的静态资源即可。

8.8.1 三维模型瓦片数据格式

一般情况下,三维模型的数据量比单纯的栅格数据或者矢量数据大得多,因此也需要进行类似于切片的处理,将三维模型轻量化。其具体的原理也不复杂,使用的就是在第7.4节中我们介绍的分页LOD技术,通过分层和分块,将三维模型划分成不同精细度、不同范围的瓦片,根据三维场景的需要,使渲染端动态调度出适配场景精细度的三维模型瓦片。

第7.4节中我们是通过倾斜摄影模型介绍的具有分页LOD技术的OSGB格式数据,但推而广之,其实第7.5节中介绍的所有类型的三维模型数据都可以使用OSGB格式来进行表达。不过,OSGB格式数据是一个适合桌面端的数据格式,并没有针对Web端环境进行优化和适配。目前,经常用作三维模型数据服务的三维模型数据是Cesium的3D Tiles格式,ArcGIS的I3S格式以及国内超图软件的S3M格式。其中,3D Tiles和I3S已经是国际OGC标准,而S3M则是CAGIS(中国地理信息产业协会)空间三维模型数据格式标准。

根据3D Tiles官方文档(github.com/CesiumGS/3d… 提供的定义,3D Tiles是专为流式传输和渲染大量3D地理空间内容而设计的三维模型数据格式,例如倾斜摄影测量数据、3D建筑数据、BIM/CAD、实例化要素和点云数据等。与OSGB使用的分页LOD技术类似,3D Tiles使用分层细节级别 (HLOD,Hierarchical Level of Detail)的空间数据结构,保证只有可见的瓦片才会被流式传输和渲染,从而提高三维模型数据整体性能。

3D Tiles有1.0和1.1两个版本,但是目前3D Tiles 1.0是使用最广泛的三维模型瓦片数据格式,以下我们会以3D Tiles 1.0为例,具体介绍一下三维模型瓦片数据格式的内容。

8.8.2 瓦片集和瓦片(Tilesets and Tiles)

3D Tiles合适文件通常是一个散列的包含文件和文件夹的数据集,数据集的入口通常是一个名为tileset的JSON文件。如文件名表达的含义一样,这个JSON文件就是3D Tiles的根数据集(Tilesets),一个典型的例子如下例8.6所示:

例8.6 3D Tiles的根数据集

{
    "asset": {},
    "properties": {},
    "geometricError": 100,
    "root": {
        "geometricError": 20,
        "boundingVolume": {
            "region": []
        },
        "refine": "ADD",
        "children": [
            {
                "geometricError": 10,
                "boundingVolume": {},
                "content": {
                    "uri": "house.b3dm"
                },
                "children": [
                    {
                        "geometricError": 5,
                        "boundingVolume": {},
                        "content": {
                            "uri": "detailsA.b3dm"
                        }
                    },
                    {
                        "geometricError": 5,
                        "boundingVolume": {},
                        "content": {
                            "uri": "detailsB.b3dm"
                        }
                    }
                ]
            },
            {
                "geometricError": 10,
                "boundingVolume": {},
                "content": {
                    "uri": "tree.pnts"
                }
            },
            {
                "geometricError": 10,
                "boundingVolume": {},
                "content": {
                    "uri": "fence.i3dm"
                }
            },
            {
                "geometricError": 10,
                "boundingVolume": {},
                "content": {
                    "uri": "external.json"
                }
            }
        ]
    }
}

在这个JSON文件中,最主要的部分就是名为root的元素,以及children数组中的元素。其实两者的属性是相同的,都应该包含content、children、boundingVolume、geometricError以及refine键值对,只不过有的键值对被省略掉了。具体来说,3D Tiles中的瓦片(Tiles),指的就是这个元素。

从例8.1可以看出,root元素包含了一个children数组元素,children数组中的一个元素又可以包含一个children数组元素...如此可以进行多层嵌套,就组成了一个瓦片树。我们可以回忆一下第7.4.3节的内容,这与OSGB格式的节点树非常相似。在这个瓦片树中,越往上,模型精细度越低,但是分块越少;越往下,模型精细度越高,但是分块越多。父亲节点与所有的子节点表达的数据内容是一样的,只是精细度有差别。

3D Tiles瓦片和瓦片集的示意图如下图8.53所示,一个JSON瓦片集能包含多个瓦片,瓦片的content就是具体的模型实体。不过正如例8.6所展示的那样,瓦片的content也可以指向另一个JSON瓦片集,像这样重复嵌套,我们可以组成一个非常复杂的表达三维场景的瓦片树。

图8.53 3D Tiles瓦片和瓦片集的示意图

8.8.3 包围体(Bounding Volumes)

在例8.6中我们就看到的boundingVolume元素就是包围体。可能这里说包围盒这个概念更容易让人理解一点,但是3D Tiles中有三种不同的表达切片范围的体要素,所以将其称为包围体更好一点。这三种包围体分别是包围盒(Bounding Box),包围球(Bounding sqhere)和包围区域(Bounding region),如下图8.54所示:

图8.54 3D Tiles瓦片不同包围体类型

包围盒是我们最熟悉的,但是这里反而最不好理解,一个包围盒参数的例子如下所示:

"boundingVolume": {
    "box": [
        0, 0, 10,
        20, 0, 0,
        0, 30, 0,
        0, 0, 10
    ]
}

可以看到这里一共12个参数,前3个参数表示中心点的位置坐标,接下来的三个元素定义x轴方向和半长,再接下来的三个元素定义y轴方向和半长,最后三个元素定义z轴方向和半长。这个例子的包围盒的描述就是,中心点坐标为(0,0,10),X方向长度为40,Y方向长度为60,Z方向为20。这种包围盒在三维中称为AABB(Axis-Aligned Bounding Box,轴对称包围盒)包围盒,一般情况下这么用就可以了。

但是如果深入了解一下,就会发现12个参数中有很多值是0,这些0值其实是用来表达旋转的,或者说方向的。AABB包围盒其实对三维物体对象的贴合不够紧密,如果调整一下包围盒的方向,就有可能让包围盒的范围进一步缩小(想象一下从西北到东南的长条状物体的包围盒)。这种包围盒就被称为OBB包围盒(Oriented Bounding Box,有向包围盒)。复习前面第3.7.1节的知识就会明白,后面9个参数实质是定义了旋转变换+缩放变换的几何变换矩阵。因为OBB包围盒的方式复杂一些,所以这种表达形式使用的比较少。

而包围球就最简单了,由中心点坐标和半径定义四个参数定义,如下所示:

"boundingVolume": {
    "sphere": [
        10, 5, 15,
        140.0
    ]
}

最后的包围区域则是三维图形中没有的概念,实际上这个区域其实指的是地理区域,由6个参数定义,分别是WGS84坐标系中西至经度,南至纬度,东至经度,北至纬度,最小椭球高,最大椭球高,经纬度使用弧度为单位,高度以米为单位。如下所示:

"boundingVolume": {
    "region": [
        -1.319700,
        0.698858,
        -1.319659,
        0.698889,
        0.0,
        20.0
    ]
}

包围体是三维图形中就非常重要的参数,可用于优化渲染和高效空间查询,例如在Ceisum中,就通过使用包围体实现可见性查询和视锥体剔除,显著提升了渲染性能。

8.8.4 空间数据结构

我们在前面论述过,3D Tiles中的瓦片集以树形数据结构进行组织。但是,这种树形数据结构不是任意组织的,而是具有空间一致性:父瓦片的包围体始终包含其所有子瓦片的内容。这对于可见性测试和相交性测试特别重要,当在三维场景中我们看不到某个瓦片的时候,那么必然看不到它的所有子瓦片。通过这种方式,我们可以筛选需要的瓦片进行展示,这对性能的提升非常有帮助。

另外,与基于二维的地图切片不同,3D Tiles的瓦片数据结构通常是基于三维的,因此要更加复杂,例如KD树或者八叉树,且每个瓦片可能并不均匀。这样可能就会造成一个现象,就是父瓦片的包围盒可能并不能完全包含子瓦片的包围盒。当然,父瓦片的包围体包含其子瓦片的内容的特性还是存在的。具体的空间结构示意图如下图8.55所示:

图8.55 3D Tiles空间数据结构示意图

8.8.5 几何误差(Geometric Error)

几何误差(Geometric Error)就是例8.6中的geometricError元素。复习一下我们在第7.4.3节中介绍的知识,OpenSceneGraph和OSGB格式使用瓦片包围球映射到屏幕端直径来决定渲染的精细度层级;而几何误差的作用也非常类似,决定了3D Tiles在渲染客户端(如Cesium)以何种细节级别进行渲染,从而在性能和渲染质量之间提供最佳权衡。

虽然都是控制LOD级别的因子,3D Tiles格式的几何误差表达的含义则与OSGB格式使用的参数完全不同,几何误差表达的含义是简化的几何体与真实的几何体之间的误差,以米为单位。在可视化端实现的时候,会将这个参数转换成屏幕空间误差(screen-space error,SSE),单位为像素。当SSE超过某个阈值(CesiumJS中会设定一个最大屏幕空间误差值)的时候,运行的时候将会渲染更高级别的细节。具体示意图如下图8.56所示:

图8.56 3D Tiles中的几何误差和屏幕空间误差

那么,几何误差是如何转换成屏幕空间误差呢?Cesium官方给出了一个公式,对于透视投影,他们的转换公式如下式(8-3):

sse=geometricErrorscreenHeighttileDistance2tan(fovy/2)(8-3)sse = \frac{geometricError ⋅ screenHeight}{tileDistance ⋅ 2 ⋅ tan(fovy / 2)} \tag{8-3}

其中,screenHeight是渲染屏幕的高度(以像素为单位),tileDistance是瓦片到视点的距离,fovy是视锥体的y方向的张角。

8.8.6 细化策略(Refinement Strategies)

细化策略(Refinement Strategies)就是例8.6中的refine参数。这个参数决定了以何种方式在高细节层级瓦片中增加细节。通常的方式是替换(REPLACE),意思是子瓦片节点会替换其父瓦片,这也是OSGB格式采取的策略;Cesium中还额外支持新增(ADD),意思是子瓦片在父瓦片的基础上,增加新的内容。具体示意图如下图8.57所示:

图8.57 3D Tiles中的细化策略

每个瓦片都可以设置细化策略参数,如果未指定,说明该瓦片的细化策略继承自父瓦片。

8.8.7 渲染优化算法

假设已经存在一个3D Tiles瓦片集和相机视锥体如下图8.58所示。3D Tiles瓦片集我们比较好理解,关键元素我们已经在前面几小节中介绍过了。相机视锥体是三维图形中经常要用到的一个概念,好比真实世界中,我们需要拍摄到一个物体,必须让相机调整到合适的位置(Position),调整好合适的角度(Orientation)以及调整合适的焦距(Field-of-view angle,视场角)。

图8.58 3D Tiles瓦片集和相机视锥体

接下来,我们可以模拟出在可视化客户端渲染实现中,3D Tiles格式是如何平衡任何比例的渲染性能和视觉质量了。虽然我们在前面中已经将这个思想(分页LOD机制/HLOD)论述了很多次了,但这里我们可以对照下图8.59所示进行进一步理解:

  1. 最开始加载的是JSON格式的瓦片集文件,并测试视锥体与根瓦片边界体积是否相交。在这里,视锥体与根瓦片的包围体相交,这意味着该瓦片可能需要被加载进行渲染。
  2. 由于根瓦片是没有内容的,那么就测试子瓦片的包围体与视锥体的相交。在这里,三个子瓦片中的两个的包围体确实与视锥体相交,这意味着这些子瓦片的内容会被考虑进行渲染;而剩下的一个瓦片就被直接剔除不用渲染。
  3. 此时检查瓦片的几何误差,根据式(8-3)计算此时的屏幕空间误差。此时由于没有超过阈值18.0,说明内容呈现的精细度正好合适。
  4. 然后,当用户进行交互,例如放大某个建筑物时,根据式(8-3)可知瓦片的屏幕空间误差会增大而超过阈值,有可能需要进行下一层级的渲染。并且新的视锥体可能会剔除更多的瓦片不用渲染,只有一小部分瓦片集可见。
  5. 根据所选的细化策略加载和渲染具有较高细节级别的内容。由于较高细节级别瓦片的几何误差较小,导致屏幕空间误差低于阈值,此时可以呈现更高精细度的视觉质量。

图8.59 3D Tiles中的细化策略

8.8.8 瓦片内容数据

3D Tiles瓦片内容数据通常以URI的形式引用外部文件,如例8.6中的house.b3dm、detailsA.b3dm和detailsB.b3dm。因为这些文件是3D Tiles瓦片的主体,所以很多情况下为了方便使用就将其当成瓦片本身。3D Tiles瓦片的格式可以有以下四种表现形式:

  1. Batched 3D Model(b3dm):批处理三维模型,最常规的三维模型。
  2. Instanced 3D Model(i3dm):实例化三维模型,相同三维模型的多个实例。
  3. Point Clouds(pnts):点云,大量点组成的数据。
  4. Composite Tiles(cmpt):以上三种的复合数据。

3D Tiles瓦片其实就是一种普通的三维模型数据,我们可以按照第7章三维模型介绍的内容来理解它。不过3D Tiles瓦片与普通三维模型最大的不同就在于它是按照GIS矢量要素特性来进行设计的,具体来说,就是3D Tiles瓦片中除了三维模型之外,还有要素表(Feature Table)和批处理表(Batch Table)来作为属性数据。另一方面,三维模型自身也被逻辑上拆分成多个要素模型,通过ID与属性表相关联。实际上,正如第7.5.2节中所述,这种设计实现了三维模型的单体化,在业务应用中有很大的实用意义。

1. 批处理三维模型(Batched 3D Models)

批处理三维模型(Batched 3D Models,b3dm)是3D Tiles常用的瓦片数据格式,因为其本质上就是最常规的三维模型数据。具体有多常规呢,b3dm内部直接嵌入了一个我们在第7.2节中介绍的glTF三维模型文件,具体数据布局如下图8.60所示。根据其数据布局,我们可以作一个大概的说明:

  • magic是魔法值的意思,其实就是文件标识符,具体就是“b3dm”四个字符。
  • version和byteLength分别代表版本和整个b3dm文件的字节长度。
  • featureTableJSONByteLength、featureTableBinaryByteLength、batchTableJSONByteLength和batchTableBinaryByteLength的大小分别描述了要素表JSON部分的字节长度、要素表二进制部分的字节长度、批处理表JSON部分的字节长度、批处理表二进制部分的字节长度。
  • 文件主体包含三个部分,分别是要素表(这是必须的),批处理表(可选的)以及内嵌的glTF三维模型文件。

图8.60 3D Tiles的b3dm格式瓦片数据布局

b3dm的文件数据组织我们已经初步了解,那么是如何将三维模型其拆分成多个要素模型呢?方法很简单,是通过扩展了一个名为batchId的顶点属性来实现的。对于不同的要素模型,我们分别赋予其不同的batchId值,这样在将三维模型渲染成二维画面的时候,通过二维画面像素关联的batchId值,我们就区分哪些画面像素是属于哪个要素的。如下图8.61所示:

图8.61 b3dm中不同的要素模型存储的不同的batchId值

现在已经有了batchId值了,那么我们就需要将其关联到要素表和批处理表。对于b3dm瓦片格式来说,图8.61对应的要素表的JSON部分通常为:

{
    "BATCH_LENGTH": 2
}

BATCH_LENGTH是要素表的必须属性,表示要素的个数为2。b3dm通常不使用要素表的二进制部分,而将要素模型的属性数据放入到批处理表中。例如,图8.61对应的批处理表的JSON部分通常为:

{
    "height": [
        16.2
        23.0,        
    ],
    "address": [
        "234 Second Street",
        "123 Main Street"
    ]
}

这里表达了批处理表中高度字段属性和地址字段属性,每个字段属性值都是一个数组元素,而batchId就是这个数组元素的索引。很显然,这正是batchId关联属性表的关键:第1个模型要素的高度是16.2,地址是234 Second Street;第2个模型要素的高度是23.0,地址是123 Main Street。

一般情况下,只使用批处理表的JSON部分就可以表达要素模型的属性表了。批处理表的二进制部分则是用来配合JSON部分来表达特定数据类型的属性,例如当JSON部分为如下所示时:

{
    "location": {
        "byteOffset": 0,
        "componentType": "FLOAT",
        "type": "VEC2"
    },
    "id": {
        "byteOffset": 32,
        "componentType": "INT",
        "type": "SCALAR"
    }
}

那么location和id属性字段值就会在二进制部分中进行查找,byteOffset表示起始位置字节偏移,type表示数据类型,componentType则表示数据分量类型。其实这三个参数与glTF中的顶点属性数据的表达非常像,type和componentType值的要求也与glTF中值的要求一致,复习以下第7.2节中glTF的介绍就会非常容易理解。

话说回来,我们说b3dm是参照矢量要素的设计思路实现的,是从GIS的角度进行出发论述。其实从“批处理”这个命名来说,设计者更多的是从图形渲染的角度出发来进行设计的。在图形渲染行业中,术语“批处理”是指多个模型的几何数据进行合并,组合成单个的缓冲区进入GPU显存中进行渲染,这样可以减少复制操作带来的损耗,最小化渲染绘制调用次数,从而提高渲染性能。不得不说,b3dm的设计确实很精妙,很多学问到了最深处往往都是相通的。

2. 实例化三维模型(Instanced 3D Models)

有了b3dm作为基础,实例化三维模型(Instanced 3D Model,i3dm)就比较容易理解了。不过,我们首先需要知道为什么这种瓦片格式叫做实例化三维模型。其实“实例化”这个术语是图形渲染中的一种技术,通过实例化技术可以一次性渲染大量相同的模型,只不过这些模型有一些特定的变化。例如我们渲染大量的树木,我们可以使用同一个树木模型,然后让每个树木模型的位置、旋转和缩放不同,就可以得到一大片形态各异的树林。实例化的优点就在于,既然创建一个树木对象进行渲染是很耗费性能的,那么就将这个树木对象改变一下位置、朝向以及大小进行复制粘贴,这样就可以很轻易绘制出包含大量三维模型数据的场景,并且能保证性能。

实例化技术具有非常多的应用场景,因为很多现实中的物体是有规范和标准的,比如城市中的部件,BIM中的基础设施,工业设计中的零件等,它们往往都有非常相似的外观,使用实例化技术可以有非常好的效果。这也是为什么3D Tiles将实例化三维模型作为一种瓦片数据格式。

从前面的介绍不难理解,i3dm相比较普通三维模型数据,最大的区别在于多了表达变化的实例化参数(比如前面提到的位置、旋转和缩放)。i3dm实例化参数信息是放置在要素表中的,因此,i3dm瓦片数据布局与b3dm瓦片数据布局基本一致,如下图8.62所示:

图8.62 3D Tiles的i3dm格式瓦片数据布局

除了多了一个表达gltf是外部还是内嵌的参数gltfFormat,i3dm与b3dm最大的不同就在于要素表和批处理表。要素表中需要存放实例化参数,例如一个要素表的JSON部分如下所示:

{
    "INSTANCES_LENGTH": 3,
    "POSITION": {
        "byteOffset": 0
    },
    "NORMAL_UP": {
        "byteOffset": 36
    },
    "NORMAL_RIGHT": {
        "byteOffset": 72
    },
    "SCALE": {
        "byteOffset": 108
    }
}

INSTANCES_LENGTH是必须的参数,表示实例化个数。POSITION、NORMAL_UP、NORMAL_RIGHT和SCALE是预先定义好的语义,分别表示位置、旋转的上方向、旋转的右方向以及缩放,它们分别用3个float型、3个float型、3个float型以及1个float型来表示,配合起始位置字节偏移byteOffset,我们可以很容易找出存储在要素表二进制部分的实例化参数,如下图8.63所示:

图8.63 i3dm中的实例化参数

另外,i3dm也是遵循要素化的设计思路的,不过与b3dm不同,i3dm是以单个的实例化对象为单个要素,并且关联属性。在要素表中,可以在JSON部分增加一个名为BATCH_ID的语义,在二进制部分存储不同实例化对象的batchId值。而批处理表中则像b3dm一样进行存储其他属性数据,这样就实现了单个的实例化模型与属性信息的关联。

3. 点云(Point Clouds)

相比较b3dm和i3dm,点云(Point Clouds,pnts)形式的瓦片数据格式就更加简单了,甚至不用内嵌glTF。点云pnts的数据布局如下图8.64所示:

图8.64 3D Tiles的pnts格式瓦片数据布局

点云除了记录点的位置属性之外,还可能有法向量、颜色等属性,这些属性数据都是记录在要素表中的。如下所示是一个pnts要素表的JSON部分:

{
    "POINTS_LENGTH": "219",
    "POSITION": {
        "byteOffset": 0
    },
    "NORMAL": {
        "byteOffset": 2628
    },
    "RGB": {
        "byteOffset": 5256
    }
}

类似i3dm的要素表,这里的POINTS_LENGTH表示点的个数,而POSITION、NORMAL和RGB这些属性名称也是预定义的语义类型,配合起始位置字节偏移量byteOffset可以找到点属性具体的属性值,具体示意图如下图8.65所示:

图8.65 pnts将点云属性存储在要素表中

pnts也是遵循要素化的设计思路,从要素表来看,似乎点云中一个点就是一个要素,但这样理解并不准确。pnts需要表达的是一个要素模型,例如一个点云瓦片表示的是一个房屋,那么房屋内部中的门、窗或者屋顶才是我们想要知道的要素模型。要实现这样的要素识别非常简单,还是使用如同b3dm或i3dm相同的办法,在要素表中增加一个名为BATCH_ID的字段,记录每个点云的batchId值,如下图8.66所示:

图8.66 pnts通过Batch ID区分不同的点云要素

剩下的就还是如同b3dm一样,在批处理表中存储其他属性数据,实现多个点组成的要素模型与属性信息相关联。

4. 复合瓦片(Composite Tiles)

复合瓦片(Composite Tiles,cmpt)是以上介绍的瓦片格式的复合数据格式。举例来说,一组建筑物可以存储在b3dm中,一组树木可以存储在i3dm中,如果这些元素出现在同一地理位置时,就可以将其组合成cmpt,实现单个的请求获取该地理位置所有的可渲染内容,如下图 8.67所示。这样的设计可以减少访问的请求个数,改善瓦片数据加载时的视觉效果。

图 8.67 3D Tiles的cmpt格式瓦片实现示意图

cmpt的数据组织非常灵活,可以包含b3dm、i3dm和pnts中的任意种类任意个数的瓦片数据,甚至可以包含另一个cmpt瓦片数据。但它的数据布局就简单了,如下图8.68所示。文件头通过tilesLength标识包含的子瓦片的个数,文件主体则是具体的子瓦片数据内容。

图8.68 3D Tiles的pnts格式瓦片数据布局

8.8.9 声明式样式(Declarative Styling)

既然3D Tiles的瓦片数据格式是按照要素特性来进行设计的,那么免不了要面对的就是模型要素符号化的问题。3D Tiles使用声明性样式在运行时修改功能的外观,所谓声明性样式,具体来说就是包含一组表达式的JSON。这种样式JSON规定了一些变量,表达式以及条件,可以看作是一种简单的样式语言。例如我们让一组表达建筑的3D Tiles根据其高度呈现不同的颜色,可以使用如下样式JSON:

{
    "color": {
        "conditions": [
            ["${height} >= 300", "rgba(45, 0, 75, 0.5)"],
            ["${height} >= 200", "rgb(102, 71, 151)"],
            ["${height} >= 100", "rgb(170, 162, 204)"],
            ["${height} >= 50", "rgb(224, 226, 238)"],
            ["${height} >= 25", "rgb(252, 230, 200)"],
            ["${height} >= 10", "rgb(248, 176, 87)"],
            ["${height} >= 5", "rgb(198, 106, 11)"],
            ["true", "rgb(127, 59, 8)"]
        ]
    }
}

其中,color是要素模型的颜色值属性,决定要素模型渲染的颜色。height则表示3D Tiles瓦片中批处理表种的height字段,根据这个字段值的不同,给模型要素赋予不同的颜色。在CesiumJS中实现效果如下图8.69所示:

图8.69 3D Tiles的声明式样式的效果图

虽然很多写实的三维模型可能用不到这个功能,但是这个设计实现在业务系统中很有用处,也很容易扩展,可以帮助我们实现更酷炫更有价值的可视化效果,值得我们进一步研究。

8.8.10 其他

从以上对3D Tiles格式的介绍可以感受到,3D Tiles确实是设计的非常完善的三维模型瓦片数据格式,也因此得到了最为广泛的使用。除此之外,另一个OGC标准——ArcGIS设计的I3S(Indexed 3D Scene Layers)三维模型瓦片数据格式也很优秀,与3D Tiles相比,它的一些特点给笔者留下了比较深刻的印象,主要是:

  • 3D Tiles是离散文件集形式的静态资源,I3S则可以打包成.slpk这种zip格式的单文件,也支持使用RESTful接口访问。
  • 3D Tiles空间坐标参考默认是WGS84椭球的地心地固坐标系,少部分参数使用WGS84地理坐标系;而I3S则专业很多,支持目前绝大多数地理空间坐标参考。
  • 不知道是否是处于兼容性的考虑,I3S设计的参数非常多,但可视化的时候很多参数都没有用上(这也是ArcGIS的一贯特色);3D Tiles这方面则简练很多,只提供了最简单的参数要求,其余的需求通过扩展来实现。
  • I3S在设计中实现了几何数据、属性数据、纹理材质的解耦,这意味着这些资源可以共享,在一些渲染实现中可以通过这种机制来提升性能。
  • I3S确定LOD层级的算法与3D Tiles不同,而跟OSGB比较类似,通过计算包围球投影到屏幕空间的像素大小来确定。

而I3S其余的设计实现,基于与3D Tiles大同小异,笔者这里就不多作介绍了。值得一提的是,I3S虽然没有提供具体的代码实现,但是其官方在线文档 github.com/Esri/i3s-sp… 中提供了一个可用于浏览I3S数据的在线浏览器,以及各个版本的I3S数据下载,这对于我们的研究学习很有帮助。

最后,国内还有一种使用的比较多的三维模型瓦片数据格式:主要由超图软件开发设计的S3M(spatial 3D model)格式。尽管S3M是中国地理信息产业协会的空间三维模型数据格式标准,但这个格式笔者接触的不多,毕竟愿意使用S3M格式的数据,多少有点敏感性,是不太容易获取进行研究的。

不过,笔者还是查阅了一下S3M官方在线文档(github.com/SuperMap/s3… Tiles和I3S最有诚意的一点是除了提供与其他三维瓦片数据的转换工具,还提供了读写S3M瓦片数据的JavaScript和C++代码实现,并且一直在更新。不过,缺点就是文档不够完善,至少笔者也没有看到S3M1.0、S3M2.0和S3M3.0不同版本之间的演进。而仅存的一版S3M标准文档的内容,相对于3D Tiles文档中完善的技术指导和参数说明也失之简陋。重于实现而轻于文档,这一点也只能说是国内开源工作的通病了。


本文节选自作者新书《GIS基础原理与技术实践》第8章。书中系统讲解 GIS 核心理论与多语言实战,适合开发者与高校师生。

📚 配套资源开源GitHub | GitCode 🛒 支持正版京东当当

React性能优化的完整方法论,附赠大厂面试通关技巧

开篇语

在前端面试中,React性能优化是一个绕不开的话题。无论是初级还是高级岗位,面试官总会问:"你做过哪些React性能优化?"、"如何定位性能问题?"、"React.memo和useCallback有什么区别?"

很多同学面对这些问题时,只能零散地背几个API,缺乏系统性的思路。今天这篇文章,我将结合一线开发经验,带你建立完整的React性能优化知识体系,让你从"知道几个优化技巧"到"能够系统性解决性能问题"。

性能优化的核心思路

建立性能问题的感知能力

很多开发者都是在用户投诉"页面卡"时才开始关注性能,其实性能优化应该是一个主动的过程。我总结了几个常见的性能问题信号:

  • 首屏加载超过3秒 - 用户开始失去耐心
  • 滚动时出现掉帧 - 肉眼可见的卡顿
  • 点击按钮响应延迟 - 交互体验差

当你发现这些现象时,就要考虑进行性能优化了。

系统性的优化框架

我总结了一个"三步走"的性能优化框架:

graph LR
    A[发现问题] --> B[定位原因] --> C[制定方案] --> D[验证效果]
    B --> E[工具分析]
    C --> F[选择策略]
    D --> G[数据对比]
  1. 发现问题:用户体验角度识别性能问题
  2. 定位原因:使用专业工具分析具体瓶颈
  3. 制定方案:针对不同问题选择合适优化策略
  4. 验证效果:通过数据验证优化效果

性能调试工具全解析

React DevTools Profiler - 组件级性能分析

React DevTools Profiler是我最常用的性能分析工具,它能精确记录组件的渲染时间、重渲染原因和Props变化。

实战案例:电商商品列表优化

有一次优化电商商品列表页面,用户反馈滚动时明显卡顿。我用Profiler录制了滚动操作:

  1. 打开React DevTools,切换到Profiler面板
  2. 点击录制按钮,模拟用户滚动操作
  3. 停止录制,查看火焰图

发现每次滚动都会触发所有商品卡片的重新渲染,即使大部分商品数据并没有变化。通过查看渲染原因,发现是因为父组件传递了内联函数导致引用变化。

优化方案:

// 优化前 - 每次渲染都创建新函数
const ProductList = ({ products }) => {
  return (
    <div>
      {products.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onAddToCart={() => addToCart(product.id)} // 内联函数
        />
      ))}
    </div>
  );
};

// 优化后 - 使用useCallback稳定函数引用
const ProductList = ({ products }) => {
  const handleAddToCart = useCallback((productId) => {
    addToCart(productId);
  }, []); // 空依赖数组,函数不会重新创建

  return (
    <div>
      {products.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
        />
      ))}
    </div>
  );
};

优化后,Profiler显示渲染时间从25ms降低到8ms,滚动FPS从45提升到58,用户体验明显改善。

Chrome DevTools Performance - 浏览器级性能分析

当Profiler显示组件渲染正常,但用户仍然反馈卡顿时,就需要从浏览器层面进行分析。

实战技巧:

  1. 录制用户操作流程
  2. 关注Main线程的黄色长任务块
  3. 查看Frames部分的帧率表现
  4. 分析Layout和Paint的开销

案例分析:搜索功能优化

搜索框输入时页面卡顿,Performance面板显示:

  • 每次输入都有超过100ms的黄色长任务
  • 大量的Layout和Paint操作
  • 帧率经常低于30fps

通过分析发现是因为搜索建议列表的DOM操作过于频繁。优化方案:

  • 使用防抖函数减少搜索触发频率
  • 实现虚拟滚动,只渲染可见的搜索建议
  • 缓存搜索结果,避免重复计算

其他实用工具推荐

  • why-did-you-render:监控不必要的重渲染
  • webpack-bundle-analyzer:分析打包体积
  • Lighthouse:整体性能评分和建议
  • Web Vitals:监控核心用户体验指标

React性能优化的四大策略

策略一:缓存优化 - 减少不必要的计算

React提供了三个核心的缓存API,合理使用能解决80%的性能问题。

1. React.memo - 组件级缓存

适用场景:组件props没有变化但仍然频繁重渲染

// 商品卡片组件 - 纯展示组件
const ProductCard = React.memo(({ product, onAddToCart }) => {
  console.log('ProductCard render:', product.name);
  
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>价格:¥{product.price}</p>
      <button onClick={() => onAddToCart(product.id)}>
        加入购物车
      </button>
    </div>
  );
}, (prevProps, nextProps) => {
  // 自定义比较函数(可选)
  return prevProps.product.id === nextProps.product.id && 
         prevProps.product.price === nextProps.product.price;
});

// 父组件中使用
const ProductList = ({ products }) => {
  const [cartCount, setCartCount] = useState(0);
  
  // 注意:使用useCallback避免函数引用变化
  const handleAddToCart = useCallback((productId) => {
    console.log('添加商品到购物车:', productId);
    setCartCount(prev => prev + 1);
  }, []); // 空依赖数组,函数引用保持稳定

  return (
    <div>
      <h2>商品列表 (购物车:{cartCount}件)</h2>
      <div className="product-grid">
        {products.map(product => (
          <ProductCard 
            key={product.id}
            product={product}
            onAddToCart={handleAddToCart}
          />
        ))}
      </div>
    </div>
  );
};

实战技巧:

  • 给React.memo提供自定义比较函数时,要确保比较逻辑正确
  • 避免在props中传递对象或数组字面量,会导致比较失败
  • 配合useCallback和useMemo使用效果更佳

2. useMemo - 计算结果缓存

适用场景:复杂计算、数据转换、过滤排序等操作

// 商品列表过滤和排序
const ProductList = ({ products, filter, sortBy }) => {
  // 复杂的数据处理逻辑
  const processedProducts = useMemo(() => {
    console.log('重新计算商品列表');
    
    // 1. 过滤商品
    let filtered = products.filter(product => {
      if (filter.category && product.category !== filter.category) return false;
      if (filter.minPrice && product.price < filter.minPrice) return false;
      if (filter.maxPrice && product.price > filter.maxPrice) return false;
      return true;
    });
    
    // 2. 排序
    filtered.sort((a, b) => {
      switch (sortBy) {
        case 'price-asc':
          return a.price - b.price;
        case 'price-desc':
          return b.price - a.price;
        case 'name':
          return a.name.localeCompare(b.name);
        default:
          return 0;
      }
    });
    
    return filtered;
  }, [products, filter, sortBy]); // 只有这些依赖变化时才重新计算

  return (
    <div>
      <h2>商品列表 ({processedProducts.length}件)</h2>
      <div className="product-grid">
        {processedProducts.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
};

3. useCallback - 函数引用缓存

适用场景:将函数作为props传递给子组件时

// 优化的表单组件
const SearchForm = ({ onSearch }) => {
  const [keyword, setKeyword] = useState('');
  const [category, setCategory] = useState('');
  
  // 搜索函数 - 使用useCallback缓存
  const handleSearch = useCallback(() => {
    onSearch({
      keyword,
      category,
      timestamp: Date.now()
    });
  }, [keyword, category, onSearch]);
  
  // 重置函数 - 使用useCallback缓存
  const handleReset = useCallback(() => {
    setKeyword('');
    setCategory('');
    onSearch({
      keyword: '',
      category: '',
      timestamp: Date.now()
    });
  }, [onSearch]);
  
  return (
    <div className="search-form">
      <input 
        type="text" 
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
        placeholder="请输入关键词"
      />
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="">全部分类</option>
        <option value="electronics">电子产品</option>
        <option value="clothing">服装</option>
        <option value="books">图书</option>
      </select>
      <button onClick={handleSearch}>搜索</button>
      <button onClick={handleReset}>重置</button>
    </div>
  );
};

策略二:架构优化 - 合理拆分组件

良好的组件架构设计能从根源上减少性能问题。

状态就近原则

把状态放在最靠近使用它的组件中,避免不必要的状态提升。

// 不好的设计 - 状态过度提升
const Parent = () => {
  const [isExpanded, setIsExpanded] = useState(false); // 展开状态没必要放在这里
  
  return (
    <div>
      <Child isExpanded={isExpanded} setIsExpanded={setIsExpanded} />
    </div>
  );
};

// 好的设计 - 状态就近管理
const Parent = () => {
  return (
    <div>
      <Child />
    </div>
  );
};

const Child = () => {
  const [isExpanded, setIsExpanded] = useState(false); // 状态放在使用它的组件中
  
  return (
    <div>
      <button onClick={() => setIsExpanded(!isExpanded)}>
        {isExpanded ? '收起' : '展开'}
      </button>
      {isExpanded && <div>展开的内容</div>}
    </div>
  );
};

按更新频率拆分组件

不同部分的更新频率不同,应该拆分成独立组件。

// 商品详情页 - 按更新频率拆分
const ProductDetail = ({ productId }) => {
  return (
    <div className="product-detail">
      {/* 基本信息 - 基本不变 */}
      <ProductBasicInfo productId={productId} />
      
      {/* 价格信息 - 可能促销变化 */}
      <ProductPrice productId={productId} />
      
      {/* 库存信息 - 实时变化 */}
      <ProductStock productId={productId} />
      
      {/* 用户评论 - 实时更新 */}
      <ProductReviews productId={productId} />
      
      {/* 相关推荐 - 根据算法变化 */}
      <ProductRecommendations productId={productId} />
    </div>
  );
};

策略三:列表优化 - 大数据量处理

虚拟滚动技术

当列表数据量很大时(超过1000条),虚拟滚动是必须的技术。

// 使用react-window实现虚拟滚动
import { FixedSizeList as List } from 'react-window';

const LargeProductList = ({ products }) => {
  // 只渲染可见区域的产品
  const Row = ({ index, style }) => (
    <div style={style}>
      <ProductCard product={products[index]} />
    </div>
  );

  return (
    <List
      height={600} // 可视区域高度
      itemCount={products.length} // 总数据量
      itemSize={120} // 每行高度
      width="100%"
    >
      {Row}
    </List>
  );
};

key的正确使用

key的选择对列表性能影响很大。

// 不好的做法 - 使用索引作为key
const ProductList = ({ products }) => {
  return (
    <div>
      {products.map((product, index) => (
        <ProductCard key={index} product={product} /> // 不要用index
      ))}
    </div>
  );
};

// 好的做法 - 使用稳定的唯一标识
const ProductList = ({ products }) => {
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} /> // 使用稳定的id
      ))}
    </div>
  );
};

策略四:代码分割 - 按需加载

React.lazy和Suspense

// 路由级别的代码分割
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

// 懒加载页面组件
const Home = React.lazy(() => import('./pages/Home'));
const ProductList = React.lazy(() => import('./pages/ProductList'));
const ProductDetail = React.lazy(() => import('./pages/ProductDetail'));
const ShoppingCart = React.lazy(() => import('./pages/ShoppingCart'));

const App = () => {
  return (
    <Router>
      <React.Suspense fallback={<div>加载中...</div>}>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route path="/products" component={ProductList} />
          <Route path="/product/:id" component={ProductDetail} />
          <Route path="/cart" component={ShoppingCart} />
        </Switch>
      </React.Suspense>
    </Router>
  );
};

组件级别的代码分割

// 重型组件的按需加载
const HeavyChartComponent = React.lazy(() => 
  import('./components/HeavyChartComponent')
);

const Dashboard = () => {
  const [showChart, setShowChart] = useState(false);
  
  return (
    <div>
      <button onClick={() => setShowChart(true)}>
        显示图表
      </button>
      
      {showChart && (
        <React.Suspense fallback={<div>图表加载中...</div>}>
          <HeavyChartComponent />
        </React.Suspense>
      )}
    </div>
  );
};

性能优化的数据验证

性能优化不能凭感觉,必须用数据说话。

优化前后的数据对比

案例:电商首页优化

优化前:

  • 首屏加载时间:4.2秒
  • 组件平均渲染时间:25ms
  • 滚动FPS:平均45
  • 用户跳出率:35%

优化后:

  • 首屏加载时间:2.1秒(提升50%)
  • 组件平均渲染时间:8ms(提升68%)
  • 滚动FPS:平均58(提升29%)
  • 用户跳出率:22%(降低37%)

核心性能指标监控

// Web Vitals监控
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

const reportWebVitals = (metric) => {
  console.log(metric);
  // 发送到监控系统
  sendToAnalytics(metric);
};

// 监控核心指标
getCLS(reportWebVitals); // 累积布局偏移
getFID(reportWebVitals); // 首次输入延迟
getFCP(reportWebVitals); // 首次内容绘制
getLCP(reportWebVitals); // 最大内容绘制
getTTFB(reportWebVitals); // 首字节时间

面试中的答题技巧

系统化回答框架

当面试官问"你做过哪些React性能优化"时,不要零散地列举,而是按照系统化框架回答:

我的回答思路: "我通常会从三个层面进行React性能优化:渲染层面、架构层面和加载层面。

在渲染层面,我主要使用React.memo、useMemo、useCallback来减少不必要的重渲染。比如在[具体项目]中,通过React.memo优化商品列表,让渲染时间从25ms降到8ms。

在架构层面,我会合理拆分组件,遵循状态就近原则,按更新频率组织组件结构。比如将商品详情页拆分成基本信息、价格、库存、评论等独立组件。

在加载层面,我使用代码分割、懒加载、虚拟滚动等技术。比如对路由组件进行懒加载,将首屏包体积减少了40%。

同时,我建立了性能监控机制,使用React DevTools Profiler和Chrome Performance面板定期分析性能,确保优化效果可量化。"

展现技术深度

面试官追问:"为什么React.memo能够优化性能?"

深度回答: "React.memo的原理涉及React的协调算法。当组件的props或state变化时,React会启动协调过程,通过Diff算法比较新旧虚拟DOM树的差异。

React.memo在这个过程中起到短路作用。它在组件更新阶段就执行浅比较,如果props没有变化,就直接跳过该组件及其子树的协调过程,避免了昂贵的虚拟DOM比较计算。

这种优化在组件树较深、子组件较多的情况下效果特别明显,因为React.memo阻断了变化向下传播的路径,让状态变化的影响局限在最小范围内。"

体现实战经验

面试官追问:"遇到过哪些具体的性能问题?"

实战案例: "在优化一个商品搜索页面时,用户反馈每输入一个字符都有明显卡顿。

通过Profiler分析发现,每次输入都会触发整个商品列表的重新渲染,包括过滤、排序等复杂计算。而且搜索建议列表的DOM更新也很频繁。

我的解决方案是:

  1. 使用防抖函数,将搜索触发频率控制在300ms一次
  2. 用useMemo缓存过滤排序后的结果
  3. 搜索建议列表实现虚拟滚动,只渲染可见项
  4. 用useCallback稳定事件处理函数

优化后,输入响应时间从500ms降到50ms,用户体验大幅提升。这个案例让我意识到性能优化要从用户操作路径出发,系统性地解决各个环节的性能瓶颈。"

总结与进阶

React性能优化是一个需要持续学习和实践的领域。记住这些核心要点:

  1. 建立性能意识:主动发现问题,而不是被动等待用户反馈
  2. 掌握系统方法:从渲染、架构、加载三个层面综合考虑
  3. 善用工具分析:Profiler、Performance面板等工具是定位问题的利器
  4. 数据驱动优化:用数据验证优化效果,避免凭感觉
  5. 持续监控改进:性能优化是一个持续的过程,不是一次性的工作

技术前沿关注

  • React 18的Concurrent Features:为性能优化带来新的可能性
  • Server Components:减少客户端计算和包体积
  • Edge Computing:结合边缘计算优化加载性能
  • Micro-frontend:微前端架构下的性能优化策略

性能优化不仅仅是技术问题,更是用户体验和业务价值的体现。掌握这些技能,不仅能让你在面试中脱颖而出,更能让你在实际工作中创造真正的价值。

希望这篇文章能帮助你建立完整的React性能优化知识体系。记住,最好的优化是预防,在开发过程中就考虑性能因素,而不是等问题出现后再补救。

你觉得这篇文章对你有帮助吗?欢迎在评论区分享你的性能优化经验和问题!

延伸阅读:

Jetpack Compose BOM 2026.02.01 解读与升级指南

Jetpack Compose BOM 2026.02.01 深度解析:这次升级到底更新了什么,值不值得跟?

如果你最近准备把项目里的 Compose 依赖统一升到 androidx.compose:compose-bom:2026.02.01,先说结论:这不是一波“新能力井喷”的大版本 BOM,而是一版很典型的稳定性补丁 BOM。它的核心价值不在于一次性塞进大量新 API,而在于把 Compose 1.10 这一代已经落地的新能力,用一组更稳的 patch 版本重新打包给你。

对于线上项目来说,这种版本往往比“看起来更炫”的版本更值得重视:新功能不一定是每天都能用上的,但修掉布局回归、补齐运行时和 UI 侧补丁、把 BOM 对齐到更稳定的一组 artifact,通常直接影响的是发布风险和排障成本。

本文基于 Android 官方文档、AndroidX release notes、Android Developers Blog 等一手资料整理,重点回答 4 个问题:

  1. 2026.02.01 到底映射到了哪些 Compose 版本;
  2. 它相对上一版 2026.02.00 具体变了什么;
  3. 这些变化对实际工程能力提升在哪里;
  4. 项目升级时有哪些容易踩的适配点。

先说结论

  • Compose BOM 2026.02.01 的主变化,是把 Compose 核心模块族从 1.10.3 对齐到 1.10.4
  • material3 在这版 BOM 里没有继续升级,仍然是 1.4.0
  • material3-adaptive 这条线也没有变,仍然是 1.2.0
  • 从官方 release notes 来看,foundation 1.10.4 明确修了一个布局定位回归;runtime/ui/material/animation 1.10.4 没有列出新的公开 API 变化,更像一组维护性 patch。这是基于官方 changelog 颗粒度做出的工程判断,不是 Google 原文的直接表述。
  • 如果你此前已经上了 2026.02.00,这次升级的重点是“补丁”和“稳定性”;如果你还停在更早的 2025 年末版本,那你实际上会连同 Compose 1.10、Material3 1.4 这一波能力一起吃进来。

这版 BOM 映射到了什么

Compose BOM 的职责,是帮你把一组 Compose 相关 artifact 锁到一套经过官方验证的兼容版本组合里。它不包含 Compose Compiler,这一点在官方 BOM 文档里说得很明确。

结合官方 BOM Mapping 页面,2026.02.01 可以概括为下面这张表:

模块族 2026.02.01 2026.02.00 变化判断
animation 1.10.4 1.10.3 升级补丁
foundation 1.10.4 1.10.3 升级补丁
material 1.10.4 1.10.3 升级补丁
runtime 1.10.4 1.10.3 升级补丁
ui 1.10.4 1.10.3 升级补丁
material3 1.4.0 1.4.0 无变化
material3-adaptive 1.2.0 1.2.0 无变化

这里说的“模块族”,不只是单个 artifact,而是一整串同代模块,例如:

  • ui 族通常包含 uiui-graphicsui-textui-toolingui-test-junit4 等;
  • foundation 族通常包含 foundationfoundation-layout
  • runtime 族通常包含 runtimeruntime-livedataruntime-rxjava*
  • animation 族通常包含 animationanimation-coreanimation-graphics
  • material 指的是经典 Compose Material(M2)线;
  • material3material3-adaptive 维持独立节奏。

换句话说,这版 BOM 的核心动作其实很清楚:把 Compose 核心运行栈整体抬到 1.10.4,但不动 Material3 主版本。

相比 2026.02.00,具体更新了什么

1. foundation:修了一个真实会影响界面的定位问题

在官方 foundation release notes 里,1.10.4 明确提到修复了一个回归:

  • 当一个对齐类 modifier 被错误地用在它不属于的 scope 中时,之前版本可能会触发错误布局位置
  • 1.10.4 对这个问题做了修复。

这个改动看起来不“炫技”,但对线上项目非常重要。因为这类问题往往最麻烦:

  • 编译不报错;
  • 页面也不是全挂;
  • 只在特定组合布局、特定修饰链、特定设备尺寸下才出现“怎么这里偏了一点”的问题;
  • 最后调半天才发现不是业务代码逻辑,而是底层布局行为的回归。

如果你的项目里有比较多自定义布局、复杂 slot API、嵌套 Box / Column / Row / Lazy* 布局,再加上扩展 modifier 比较多,这个修复是有实际价值的。

2. runtime:官方写明“和 1.10.3 没有变化”

runtime 1.10.4 的官方 release notes 很直接:There are no changes in this release.

这意味着什么?

  • 如果你只看 runtime 模块本身,这次从 1.10.31.10.4 不会带来新的运行时语义变化;
  • 它更多是在 BOM 层面把整个 Compose 版本集重新对齐;
  • 对项目升级而言,runtime 不是这次最需要重点回归验证的模块。

3. ui / material / animation:更像同步补丁与维护性发布

从对应的 AndroidX release notes 页面来看:

  • ui 1.10.4 没有列出额外的显性 API 变更摘要;
  • material 1.10.4 也没有单独列出显著变更说明;
  • animation 1.10.4 在 release 页面上同样没有像大版本那样给出新的能力条目。

因此比较稳妥的工程判断是:这几个 1.10.4 更像一组维护性 patch / 同步发布,而不是新的功能迭代节点。

这里还是强调一下:这句话是基于官方 release notes 未列出新增 API / 新特性摘要的推断。如果你需要逐 commit 追踪,可以继续顺着各 release notes 页里的 change list 链接深挖,但就日常升级决策来说,这个粒度已经足够判断风险级别。

4. material3:这版 BOM 不动,仍然停在 1.4.0

这点很关键。很多同学看到 BOM 版本号继续往 2026.02.01 走,会下意识以为 Material3 也一起升了。实际上没有。

按官方映射页,这一版里:

  • androidx.compose.material3:material3 仍然是 1.4.0
  • androidx.compose.material3.adaptive:* 仍然是 1.2.0

所以,如果你这次升级之后感觉 Material3 API 没有新增,那不是你姿势不对,而是 BOM 本身就没有把 Material3 再往前推。

那“能力提升”体现在哪?

如果只盯着 2026.02.012026.02.00 的 diff,你会觉得“这不就是个 patch 吗”。这判断没错,但不完整。

更准确地说,这版 BOM 的价值分成两层:

  1. 短期价值:把核心 Compose 族对齐到更稳的 1.10.4
  2. 平台价值:把 Compose 1.10 和 Material3 1.4 这一代的能力,打包到一个更适合生产落地的组合上。

下面这部分,才是程序员真正关心的“升了以后我能干嘛”。

1. Lazy 布局和滚动链路更成熟了

根据 Android Developers Blog 对 Compose 1.10 的总结,Google 在这一代继续强化了性能路线,几个点很值得关注:

  • Pausable Composition 已默认用于 Lazy 布局的预取;
  • 官方给出的内部 benchmark 显示,某些滚动场景下 Compose 已能达到和传统 View 非常接近的性能水平;
  • 这意味着在复杂列表、卡片流、瀑布式 feed、首页混排场景里,Compose 的“流畅度心理门槛”又往前迈了一步。

对于业务开发来说,这种能力提升不是“我今天多了一个 API”,而是:

  • 你更敢把复杂首页从 RecyclerView 迁到 Compose;
  • 你在做首屏预取、异步图片、嵌套列表时,调优空间更大;
  • 同样一套 UI 结构,达到稳定 60fps / 120fps 的难度在下降。

2. 状态保存模型更细了:retain 填上了中间层

Compose 1.10 里新增了 retain API。官方的定位很明确:它位于 rememberrememberSaveable 之间。

这对大型页面尤其有意义。过去很多场景都很尴尬:

  • remember 生命周期太短,离开组合就没了;
  • rememberSaveable 又偏“跨配置变化/进程恢复”的重量级持久化路径;
  • 中间缺一个“离开当前组合树但暂时还想留住”的状态层。

retain 的意义就在这里。你可以把它理解为:为复杂导航栈、可回收页面、短时脱离组合的内容,提供一个更合理的状态存活层级。

如果你的项目里有这些模式,这项能力很实用:

  • 多 Tab 页面切换;
  • 底部导航多 back stack;
  • 局部内容被回收后重新挂载;
  • 大表单 / 编辑页中间状态不想轻易丢。

3. Shared Element / Lookahead 这一套更能上生产了

Compose 1.10 在动画侧最像“能力升级”的部分,是共享元素与 Lookahead 相关 API 的继续完善。官方博客提到的点包括:

  • 可以在运行时启用或禁用共享元素;
  • Modifier.skipToLookaheadPosition 让空间位置同步更灵活;
  • 过渡动画支持初始速度;
  • 支持 veiled shared bounds 这类更接近真实产品需求的过渡模式。

如果你做的是下面这些页面,这一代动画 API 的成熟度提升会比较明显:

  • 列表到详情页的跨屏转场;
  • 卡片展开 / 收起;
  • Hero image、视频封面、头像转场;
  • 大屏多面板布局里跨容器的视觉连贯动画。

而从 animation 1.10.4 只是跟进 patch 这一点也能看出,Google 这一阶段更像是在把 1.10 这一代能力往稳定期推进,而不是继续大开大合地扩 API。

4. Material3 1.4.0 的价值,很多人低估了

虽然 2026.02.01 没有继续升级 material3,但只要你项目还没吃上 1.4.0,这一版 BOM 仍然值得关注。

根据官方 material3 release notes 和 Android Developers Blog,1.4.0 这代比较有感的能力包括:

  • 基于 state-based TextField 的新文本输入体系;
  • SecureTextField / OutlinedSecureTextField,更适合密码、敏感信息输入;
  • TextautoSize 能力;
  • HorizontalCenteredHeroCarousel
  • TimePicker 在 picker / input 模式之间切换;
  • 新的纵向拖拽手柄(vertical drag handle);
  • 一批曾经实验性的 Material3 API 进入稳定状态;
  • 一些性能优化已经进入正式版。

这组能力对真实业务非常有价值:

  • 输入组件 更接近现代应用的复杂表单需求;
  • 安全输入 不再需要大量自定义封装;
  • 自适应排版 在标题、卡片、横幅类组件里更好落地;
  • 内容型首页 可以更方便地做 Hero Carousel 一类强运营模块;
  • 时间选择器 的可用性更好。

对工程侧的实际收益,可以怎么理解

如果只用一句话总结:2026.02.01 不是“让我今天马上能写出完全不同 UI”的版本,而是“让 Compose 这条生产链更稳、更敢大规模用”的版本。

把收益拆开看,会更清楚:

收益 1:列表、复杂布局和动画的生产风险更低

  • foundation 1.10.4 修正布局回归,直接降低界面错位风险;
  • 1.10 代的性能和动画能力已经明显更适合生产;
  • 对复杂首页、商城、内容流、社交 feed 这类页面更友好。

收益 2:状态管理更贴近大型应用架构

  • retain 提供了比 remember 更长、比 rememberSaveable 更轻的状态层级;
  • 对 Navigation、多 back stack、复杂编辑流尤其有帮助;
  • 可以减少很多“到底是存在 ViewModel 里还是存在 rememberSaveable 里”的别扭设计。

收益 3:输入与 Material3 组件更可用

  • 新 TextField 能力和 SecureTextField 让表单页收益明显;
  • autoSize 和 Carousel 等能力,让内容型设计更容易实现;
  • 如果你在推进 design system 的 Compose 化,1.4.0 是个比 1.3.x 更成熟的落点。

升级方式:依赖应该怎么写

最标准的写法还是直接上 BOM:

dependencies {
    val composeBom = platform("androidx.compose:compose-bom:2026.02.01")

    implementation(composeBom)
    androidTestImplementation(composeBom)

    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.foundation:foundation")
    implementation("androidx.compose.material3:material3")

    debugImplementation("androidx.compose.ui:ui-tooling")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}

如果你用 Version Catalog,也建议只在 catalog 里声明 BOM 版本,把具体 Compose artifact 写成无版本号依赖,避免手滑把单个模块拉出对齐范围。

升级适配要点:这些地方一定要看

1. 先记住:BOM 不管 Compose Compiler

这是很多项目升级时最容易搞混的点。

官方文档明确说明:Compose Compiler 不包含在 Compose BOM 里。

同时,官方 Compose Compiler 文档又补了一条很关键的信息:从 Kotlin 2.0 开始,Compose Compiler 由 Kotlin 仓库统一管理,推荐直接使用 Compose Compiler Gradle plugin,并让它和 Kotlin 版本保持一致。

也就是说:

  • compose-bom,不等于 compiler 自动一起升;
  • 如果你已经在 Kotlin 2.0+,优先走官方推荐的 org.jetbrains.kotlin.plugin.compose
  • 如果你还停在旧 Kotlin 版本,升级前要核对 compiler compatibility map,不要只盯着 BOM。

一个典型配置如下:

plugins {
    id("org.jetbrains.kotlin.android") version "<your-kotlin-version>"
    id("org.jetbrains.kotlin.plugin.compose") version "<your-kotlin-version>"
}

2. 如果你手动覆盖过单个 Compose 依赖,先清理一遍

BOM 的前提是“版本统一受控”。

如果你的项目历史上做过下面这些事:

  • 手写过 ui:1.x.x
  • 单独把 material3 钉在别的版本;
  • 因为临时修 bug 覆盖过 foundation-layout
  • 引入过 alpha / beta 版 Compose artifact;

那升级到 2026.02.01 前,最好先把这些 override 摸一遍。否则你以为自己在用 BOM,实际依赖图已经部分失控了。

建议直接跑一遍依赖树确认:

./gradlew :app:dependencies --configuration debugRuntimeClasspath

重点看有没有:

  • 同一模块族里混入多个小版本;
  • 稳定版和 alpha / beta 混用;
  • material3 被外部组件库偷偷带成别的版本。

3. 如果你从更早版本跨上来,要补测布局和滚动,不要只测编译通过

这次官方明确提到的补丁在 foundation,说明布局链路是本轮升级里最值得回归的地方。

建议重点回归这几类页面:

  • 大量自定义 modifier 的页面;
  • LazyColumn / LazyVerticalGrid / Pager 混合页面;
  • 有粘性头部、吸顶、嵌套滚动的页面;
  • 依赖对齐、权重、offset、padding 叠加的复杂布局;
  • 平板、折叠屏、横屏等大尺寸场景。

原因很简单:布局回归类问题最容易“编译通过、代码 review 也看不出来,但线上截图才暴露”。

4. 如果你准备用 Material3 新输入体系,优先从局部页面试点

material3 1.4.0 的 TextField 体系更新是很有价值的,但也意味着:

  • 你的 design system 可能要同步抽象;
  • 表单状态管理方式可能要调整;
  • 输入过滤、校验、错误态展示、焦点切换等逻辑可能要重新梳理。

我的建议不是“全量一把梭”,而是:

  • 先在登录、注册、个人资料编辑、收货地址这类表单集中页面试点;
  • 把状态模型、错误态规范、密码输入策略跑顺;
  • 再向更复杂的业务域扩散。

5. 如果你还在用 Material Icons 扩展库,顺手评估迁移到 Material Symbols

material3 官方 release notes 里,Google 已经不再推荐继续使用 Material Icons Library,而是建议使用 Material Symbols 的自动镜像 Vector Drawable 方案。

这不是一个“必须今天改”的阻塞项,但如果你刚好在做 design token、图标系统、资源瘦身或者国际化适配,这次升级很适合顺手把这件事纳入技术债清单。

6. 关注大版本背景:新发布 AndroidX 库的默认 minSdk 已经提高到 23

Google 在 Compose 1.10 相关说明里已经提到,AndroidX 的新发布库默认 minSdk 正在从 2123 提升。

对大多数今天仍在活跃开发的项目来说,这通常不是大问题;但如果你的投放范围、ROM 适配或者企业客户环境还覆盖 API 21/22,那升级前最好把下面几件事再确认一遍:

  • App 的实际 minSdk
  • 业务模块是否有单独的发布目标;
  • 第三方 SDK 是否还卡着低版本系统;
  • CI / 测试矩阵里是否保留了低版本设备。

这版值不值得升?我会怎么建议

适合尽快跟进的情况

  • 你已经在 Compose 1.10.x,想吃掉最新 patch;
  • 你最近碰到过列表、布局、定位错位类问题;
  • 你准备把更多复杂页面迁到 Compose;
  • 你想把 Material3 1.4.0 作为新的稳定落点。

这种场景下,2026.02.01 基本属于“可以排进近期升级计划”的版本。

可以稍微观察一下的情况

  • 你线上项目非常稳定,最近没有 Compose 相关问题;
  • 你已经在 2026.02.00 且没有布局异常;
  • 你对 material3 新能力没有迫切需求;
  • 你当前迭代窗口不允许 UI 侧做大量回归。

这种情况下,也不是不能升,而是可以把它当成“下一个常规基础设施升级窗口顺手做”的版本。

一个更实用的判断:这版 BOM 的真实定位是什么

如果让我用一句程序员视角的话来概括:

Jetpack Compose BOM 2026.02.01 本质上是 Compose 1.10 时代的一次稳定性收口版 BOM。

它没有把 Material3 再推到新的主版本,也没有甩出一串高调新 API;但它做了更重要的事:

  • 把核心 Compose 族统一补到 1.10.4
  • 修复了明确的布局定位回归;
  • 继续站在 1.10 的性能、状态、动画能力之上;
  • material3 1.4.0 这代组件能力,处在一个更适合生产项目接入的 BOM 组合里。

如果你问“要不要升”,我的答案会是:

  • 2026.02.00 升上来:更像一次低风险、偏稳健的补丁升级;
  • 从更老版本直接升上来:收益会明显大得多,但记得按 compiler + BOM + 布局回归 + 输入组件 四条线一起看。

参考资料

如果你后面还要继续升级,我建议下一步别只盯着 BOM 版本号,而是把三件事绑在一起看:

  1. Kotlin / Compose Compiler 是否同步;
  2. Material3 新输入体系是否准备接入;
  3. 复杂布局页是否有足够的回归覆盖。

这三件事,往往比“我是不是已经升到最新 BOM”更影响真实项目的收益。

nestjs学习 - 控制器、提供者、模块

一、控制器(Controller)

控制器负责处理传入的请求并向客户端返回响应

接收客户的请求,然后告诉 服务层该做什么,最后把结果端给客户。

对应请求路径的配置。

控制器不过多介绍,比较好理解,对应的规则可以查阅官方文档。

二、提供者(Provide)

提供者是Nest中的一个基本概念。

许多基本的Nest类可以被视为提供者 - 服务、存储库、工厂、助手等。提供者的主要思想是它可以作为依赖项注入

这意味着对象可以彼此之间建立各种关系,而对象实例的“连接”功能可以在很大程度上委托给Nest运行时系统。

1. 它是什么

提供者是普通的JavaScript类,在模块中声明为providers

Provider 是一个 可以被依赖注入系统 “提供”给其他类(通常是控制器 Controller 或其他服务 Service)的对象函数

当你在一个类(如 Controller)的构造函数中声明了一个依赖项时,NestJS 会去查找对应的 Provider 来注入这个依赖。

白话描述一:它是一个对象、值或函数,它可以通过 依赖注入 的方式,注入到系统中供其它类使用;

“Provider 是 NestJS IoC 容器管理的资源(可以是类实例、值或工厂函数),它作为依赖项,被自动注入到需要它的类中。”

2. 使用:

使用提供者通常分为三个步骤:定义注册注入

2.1 定义

通常使用 @Injectable() 装饰器标记一个类。

import { Injectable } from '@nestjs/common';

@Injectable() // 这个装饰器告诉 NestJS,CatService 是一个提供者
export class CatService {
  private readonly cats = [];

  create(cat: any) {
    this.cats.push(cat);
  }

  findAll(): any[] {
    return this.cats;
  }
}

2.2 注册

必须在模块(Module)的 providers 数组中注册该提供者,这样它才能被该模块内的其他类发现。

// cats.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatService } from './cat.service';

@Module({
  controllers: [CatsController],
  providers: [CatService], // 在这里注册
})
export class CatsModule {}

2.3 注入

在构造函数中声明依赖,NestJS 会自动解析类型并注入实例。

// cats.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { CatService } from './cat.service';

@Controller('cats')
export class CatsController {
  // 通过构造函数注入
  constructor(
    private readonly catService: CatService
  ) {}

  @Post()
  create(@Body() createCatDto: any) {
    this.catService.create(createCatDto);
    return 'Action has been executed';
  }

  @Get()
  findAll() {
    return this.catService.findAll();
  }
}

2.4 自定义提供者

除了标准的类提供者,NestJS 还支持更灵活的自定义提供者,用于处理复杂的场景。

定义、注册过程
const configServiceProvider = {
  provide: ConfigService,
  useClass:
    process.env.NODE_ENV === 'development'
      ? DevelopmentConfigService
      : ProductionConfigService,
};

@Injectable()
class LoggerService {
  /* implementation details */
}

@Module({
  providers: [
    // 一、普通提供者,或称为:类提供者
    configServiceProvider,
    // 二、值提供者示例
    {
      provide: 'AA',
      useValue: {
        a: 1,
        b: 2,
      },
    },
    // 三、工厂提供者示例:动态创建数据库配置
    {
      provide: 'DB_CONFIG',
      useFactory: (usersService: UsersService) => {
        // 工厂函数可以执行异步操作、条件判断等
        const env = process.env.NODE_ENV || 'development';
        return {
          host: env === 'production' ? 'prod.db.com' : 'localhost',
          port: 5432,
          database: 'users_db',
          timestamp: new Date().toISOString(),
          // 可以使用注入的依赖
          serviceName: usersService.constructor.name,
        };
      },
      inject: [UsersService], // 声明依赖项
    },
    // 四、异步工厂提供者示例
    {
      provide: 'ASYNC_CONNECTION',
      useFactory: async () => {
        // 模拟异步操作,如数据库连接
        await new Promise(resolve => setTimeout(resolve, 8000));
        return {
          status: 'connected',
          connectionId: Math.random().toString(36).substring(7),
          connectedAt: new Date().toISOString(),
        };
      },
    },
    
    // 别名提供者: useExisting 
    LoggerService,
    {
      provide: 'AliasedLoggerService',
      useExisting: LoggerService,
    },
    
  ],
})
注入方式:
@Controller('users')
export class UsersController {
  constructor(
    @Inject('AA') private readonly aa: any,
    @Inject('DB_CONFIG') private readonly dbConfig: any,
    @Inject('ASYNC_CONNECTION') private readonly connection: any,
  ) {
    // 在构造函数中可以看到工厂提供者创建的实例
    console.log('数据库配置:', this.dbConfig);
    console.log('异步连接:', this.connection);
  }
}

三、模块(module)

在 NestJS 框架中,@Module() 装饰器是构建应用程序架构的基石。NestJS 的整个应用结构就是由一个个模块组成的树状结构。

以下是对 @Module() 的详细介绍,包括它的定义、作用、核心属性以及使用示例。

1. 它是什么

@Module() 是一个 TypeScript 装饰器(Decorator),用于定义一个 NestJS 模块

在 NestJS 中,模块是组织代码的基本单元。每个 NestJS 应用程序至少有一个模块(通常是 AppModule),作为应用程序的根模块。通过模块,NestJS 能够利用依赖注入(Dependency Injection)系统来管理类之间的依赖关系。

2. 它的作用是什么?

@Module() 的主要作用是定义上下文边界组织依赖关系。它告诉 NestJS 编译器:

  • 这个模块包含哪些提供者(Services/Providers)。
  • 这个模块向外暴露哪些提供者供其他模块使用。
  • 这个模块需要导入哪些其他模块。
  • 这个模块包含哪些控制器(Controllers)。

简单来说,它解决了代码的高内聚、低耦合问题,让大型项目的结构清晰可见。

3. 它的核心属性

@Module() 装饰器接收一个配置对象,该对象包含四个主要属性:

属性 类型 描述
providers Provider[] 提供者数组。这里定义的服务(Service)、仓库(Repository)或其他可注入的类,将在当前模块内通过依赖注入可用。
exports Provider[] 导出数组。默认情况下,模块内的提供者只能在模块内部使用。如果想让其他模块也能使用这些提供者,必须在这里列出它们。
imports Module[] 导入数组。列出当前模块所依赖的其他模块。导入后,当前模块可以使用那些被导出模块中的提供者。
controllers Controller[] 控制器数组。定义属于该模块的控制器,用于处理 HTTP 请求并返回响应。

4. 使用

假设我们要构建一个简单的用户管理系统,包含 UsersModule(用户模块)和 DatabaseModule(数据库模块)。

场景 A:定义一个基础模块 (UsersModule)

// users/users.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class UsersService {
  findAll() {
    return [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
  }
}

// users/users.controller.ts
import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  // 依赖注入 UsersService
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    return this.usersService.findAll();
  }
}

// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  // 1. 注册控制器,处理 /users 路由
  controllers: [UsersController],
  
  // 2. 注册服务,使其可在本模块内注入
  providers: [UsersService],
  
  // 3. 如果其他模块想用 UsersService,需在此导出
  exports: [UsersService], 
  
  // 4. 如果依赖其他模块(如数据库),在此导入
  imports: [], 
})
export class UsersModule {}

场景 B:模块间的依赖 (导入与导出)

假设我们有一个 DatabaseModule 提供了一个 DbConnection 服务,并且我们希望 UsersModule 能使用它。

// database/database.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class DbConnection {
  connect() {
    console.log('Connected to DB');
  }
}

// database/database.module.ts
import { Module } from '@nestjs/common';
import { DbConnection } from './database.service';

@Module({
  providers: [DbConnection],
  // 关键点:必须导出,其他模块才能用
  exports: [DbConnection], 
})
export class DatabaseModule {}

现在,修改 UsersModule 来使用它:

// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { DatabaseModule } from '../database/database.module'; // 引入模块定义

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
  // 关键点:导入 DatabaseModule,这样 UsersService 就可以注入 DbConnection 了
  imports: [DatabaseModule], 
})
export class UsersModule {}

users.service.ts

// users/users.service.ts (更新后)
import { Injectable } from '@nestjs/common';
import { DbConnection } from '../database/database.service';

@Injectable()
export class UsersService {
  // 现在可以注入了,因为 UsersModule 导入了 DatabaseModule 且后者导出了 DbConnection
  constructor(private readonly db: DbConnection) {
    this.db.connect();
  }
  
  findAll() {
    return [{ id: 1, name: 'Alice' }];
  }
}

5. 最佳实践与注意事项

  1. 根模块 (Root Module) :每个应用都有一个根模块(通常在 app.module.ts),它是模块树的入口。NestJS 从根模块开始解析依赖图。
  2. 不要循环依赖:尽量避免模块 A 导入模块 B,同时模块 B 又导入模块 A。如果必须这样做,需要使用 forwardRef(() => ModuleB)
  3. 按需导出:只导出其他模块真正需要的提供者。这有助于隐藏内部实现细节,保持封装性。
  4. 功能内聚:一个模块应该专注于单一的业务领域(例如:用户模块只处理用户相关逻辑,订单模块只处理订单逻辑)。
  5. 全局模块:如果一个模块(如配置模块、日志模块)需要在几乎所有地方使用,可以使用 @Global() 装饰器将其标记为全局模块,这样就不需要在每个模块的 imports 数组中重复导入它。

6. 总结

@Module() 是 NestJS 的骨架。

  • Controllers 处理请求。
  • Providers 处理业务逻辑。
  • Imports/Exports 处理模块间的通信。
  • @Module() 将它们捆绑在一起,形成一个有机的整体。

基于腾讯地图实现电子围栏绘制与校验

需求背景:在安全巡检系统中,为巡检人员配置“电子围栏”,当人员在围栏内(或异常停留超时)触发告警。业务需要一个可配置、可编辑、可校验的围栏编辑器,支持多边形/矩形绘制、相交检测、搜索定位、缩略图生成上传和启停状态设置。

image.png

1. 组件背景与业务场景

  • 业务目标:为巡检系统配置“电子围栏”,限定巡检活动区域,配合异常停留时限与启停状态形成完整的策略。
  • 使用人群:业务管理员/调度人员;交互上要求“易绘制、可编辑、易清空、可搜索定位”。
  • 数据形态:围栏区域以坐标序列存储(多边形/矩形路径),序列化为 JSON 持久化到后端。
  • 辅助要素:提交前需校验围栏是否相交,生成围栏缩略图用于列表/详情展示。

界面入口为对话框模式(Dialog),包含基础表单与地图绘制区:

  • 围栏区域名称、异常停留时限(分钟)、启停状态;
  • 地图区域提供绘制/编辑/删除/一键删除、形状切换(多边形/矩形)、地点搜索。

image.png


2. 核心功能点与交互流程拆解

  • 模式切换:绘制模式(DRAW)/编辑模式(INTERACT)/删除单个/一键删除全部。
  • 工具切换:多边形与矩形两类覆盖物的快速切换。
  • 搜索定位:联想输入+节流调用,点击候选项在地图上定位并弹出信息窗。
  • 坐标收集:监听绘制与编辑完成事件,实时收集 polygon/rectangle 的路径点,序列化到表单字段 fenceArea。
  • 相交检测:提交前对所有区域两两进行相交判断,避免配置出重叠区域。
  • 缩略图生成:使用 Canvas 将围栏几何映射到可视缩略图,上传并记录返回的 URL。
  • 资源清理:组件卸载时销毁编辑器与地图实例,释放内存。

基本链路如下:

  1. 打开弹窗 → 根据类型(新建/编辑/查看)设置标题与编辑模式
  2. 初始化地图与编辑器 → 注入已有几何 → 绑定 draw/adjust 完成事件
  3. 绘制/编辑过程中更新 fenceArea → 搜索定位辅助操作
  4. 提交:停止编辑器 → 收集/校验坐标 → 生成并上传缩略图 → 调用创建/更新接口

3. 技术选型与实现要点

3.1 地图与几何编辑:TMap GeometryEditor

  • 地图基座:TMap.Map
  • 覆盖物:TMap.MultiPolygon(多边形) 与 TMap.MultiRectangle(矩形)
  • 编辑器:TMap.tools.GeometryEditor,支持 actionMode(激活模式)、activeOverlay(激活覆盖物)、snappable/selectable 等配置
  • 事件监听:draw_complete(绘制完成)、adjust_complete(编辑完成)

示例代码initMap:

const initMap = () => {
  map = new TMap.Map("map-container", {
    zoom: 16,
    center: new TMap.LatLng(latitude.value, longitude.value),
    showControl: false,
  });

  // 已有几何解析与注入(编辑/查看)
  const polygonGeometries: any[] = [];
  if ((formType.value === "update" || formType.value === "view") && formData.value.fenceArea) {
    const geometries = JSON.parse(formData.value.fenceArea);
    geometries.forEach((geo) => {
      polygonGeometries.push({
        id: `polygon_${polygonGeometries.length}`,
        paths: geo.paths.map((p) => new TMap.LatLng(p.lat, p.lng)),
      });
    });
  }

  // 多边形与矩形覆盖物
  polygon = new TMap.MultiPolygon({ map, geometries: polygonGeometries });
  rectangle = new TMap.MultiRectangle({ map, geometries: [] });

  // 编辑器绑定
  editor = new TMap.tools.GeometryEditor({
    map,
    overlayList: [
      { overlay: polygon, id: "polygon", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
      { overlay: rectangle, id: "rectangle", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
    ],
    actionMode: "", // 由外部模式切换驱动
    activeOverlayId: activeType.value,
    snappable: !isViewMode.value,
    selectable: !isViewMode.value,
  });

  // 绘制/编辑完成后更新数据
  editor.on("draw_complete", updateFenceArea);
  editor.on("adjust_complete", updateFenceArea);
};

模式切换实现(绘制/编辑/删除/一键删除):

const handleModeChange = (id: "draw"|"edit"|"delete"|"deletes") => {
  if (activeMode.value === id && id !== "delete" && id !== "deletes") return;

  switch (id) {
    case "draw":
      editor.stop();
      editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
      activeMode.value = id;
      break;
    case "edit":
      editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
      activeMode.value = id;
      break;
    case "delete":
      editor.delete();
      updateFenceArea();
      break;
    case "deletes":
      // 临时切换到编辑模式,批量选择并删除所有几何
      const wasInDrawMode = activeMode.value === "draw";
      if (wasInDrawMode) {
        activeMode.value = "edit";
        editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
      }
      editor.select([]);
      const polygonIds = polygon?.geometries?.map((g) => g.id) || [];
      const rectIds = rectangle?.geometries?.map((g) => g.id) || [];
      if (polygonIds.length) { editor.setActiveOverlay("polygon"); editor.select(polygonIds); editor.delete(); }
      if (rectIds.length) { editor.setActiveOverlay("rectangle"); editor.select(rectIds); editor.delete(); }
      updateFenceArea();
      if (wasInDrawMode) {
        activeMode.value = "draw";
        editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
      }
      break;
  }
};

工具切换(多边形/矩形)仅需切换 activeOverlayId:

const handleToolChange = (id: "polygon"|"rectangle") => {
  if (activeType.value === id) return;
  activeType.value = id;
  editor.setActiveOverlay(id);
};

3.2 坐标收集与相交检测

  • 目标:统一收集 polygon/rectangle 的路径坐标,序列化为字符串到 fenceArea

  • 相交检测:两两比较所有多边形路径,借助 TMap.geometry.computePolygonIntersection 判断是否相交,若相交阻断提交

const updateFenceArea = () => {
  const geometries: any[] = [];
  const allPolygons: any[] = [];

  if (polygon?.geometries?.length) {
    polygon.geometries.forEach((geo) => {
      geometries.push({ type: "polygon", paths: geo.paths });
      allPolygons.push(geo.paths);
    });
  }
  if (rectangle?.geometries?.length) {
    rectangle.geometries.forEach((geo) => {
      geometries.push({ type: "rectangle", paths: geo.paths });
      allPolygons.push(geo.paths);
    });
  }

  // 多边形两两相交检测
  if (allPolygons.length > 1) {
    let hasIntersection = false;
    for (let i = 0; i < allPolygons.length - 1; i++) {
      for (let j = i + 1; j < allPolygons.length; j++) {
        const inter = TMap.geometry.computePolygonIntersection(
          allPolygons[i].map((p) => new TMap.LatLng(p.lat, p.lng)),
          allPolygons[j].map((p) => new TMap.LatLng(p.lat, p.lng))
        );
        if (inter && inter.length > 0) { hasIntersection = true; break; }
      }
      if (hasIntersection) break;
    }
    if (hasIntersection) {
      message.error("围栏区域不能相交或重叠,请调整区域位置!");
      return false;
    }
  }

  formData.value.fenceArea = geometries.length ? JSON.stringify(geometries) : undefined;
  return true;
};

3.3 缩略图绘制与上传

  • 动机:列表/详情等界面快速预览围栏形状,减少进入地图的成本

  • 方法:将所有几何的经纬度投影到 canvas 坐标系;取坐标极值计算缩放与居中,绘制填充+描边

const drawFenceThumbnail = async () => {
  if (!formData.value.fenceArea) return;

  const canvas = document.createElement("canvas");
  canvas.width = 384; canvas.height = 216;
  const ctx = canvas.getContext("2d"); if (!ctx) return;

  // 背景图可替换为项目默认底图
  const bg = await new Promise<HTMLImageElement>((res, rej) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => res(img);
    img.onerror = rej;
    img.src = "https://via.placeholder.com/384x216.png?text=BG";
  });
  ctx.drawImage(bg, 0, 0, canvas.width, canvas.height);

  const geometries = JSON.parse(formData.value.fenceArea);
  let minLat=Infinity,maxLat=-Infinity,minLng=Infinity,maxLng=-Infinity;
  geometries.forEach((g) => g.paths.forEach((p:any) => {
    const lat = p.lat || p.latitude; const lng = p.lng || p.longitude;
    minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat);
    minLng = Math.min(minLng, lng); maxLng = Math.max(maxLng, lng);
  }));

  const padding = 10;
  const contentW = canvas.width - padding * 2;
  const contentH = canvas.height - padding * 2;
  const latRange = maxLat - minLat; const lngRange = maxLng - minLng;
  let scale = Math.min(contentW / lngRange, contentH / latRange) * 0.9; // 安全边距
  const centerLng = (minLng + maxLng) / 2; const centerLat = (minLat + maxLat) / 2;
  const cx = canvas.width / 2; const cy = canvas.height / 2;

  ctx.strokeStyle = "rgba(252,193,31,.70)";
  ctx.lineWidth = 2; ctx.fillStyle = "rgba(219,132,38,.40)";

  geometries.forEach((g:any) => {
    ctx.beginPath();
    g.paths.forEach((p:any, idx:number) => {
      const x = cx + ( (p.lng||p.longitude) - centerLng ) * scale;
      const y = cy - ( (p.lat||p.latitude) - centerLat ) * scale;
      idx === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    });
    ctx.closePath(); ctx.fill(); ctx.stroke();
  });

  const blob = await new Promise<Blob|null>((res) => canvas.toBlob(res, "image/png"));
  if (!blob) return;
  const file = new File([blob], `fence-thumbnail-${Date.now()}.png`, { type: "image/png" });
  const uploadResult = await httpRequest({ file: file as any, action: uploadUrl, method: "POST", filename: "file", data: {} });
  if (uploadResult?.data) formData.value.thumbnail = uploadResult.data;
};

(背景图为示例图片) image.png

3.4 搜索联想与定位

  • 关键点:节流调用、错误码处理(如频率限制)、定位后居中并显示信息窗
const getSuggestions = throttle(() => {
  if (!address.value) { suggestionList.value = []; return; }
  suggest.getSuggestions({ keyword: address.value, location: map.getCenter() })
    .then((result) => { suggestionList.value = result.data; })
    .catch((error) => {
      if (error.status == 120) message.error("搜索过于频繁,请稍后再试");
      else message.error("搜索失败," + error.message + ",请联系系统管理员");
    });
}, 500);

function setSuggestion(item) {
  suggestionList.value = [];
  infoWindowList.forEach((w) => w.close()); infoWindowList.length = 0;
  address.value = item.title;
  const w = new TMap.InfoWindow({ map, position: item.location, content: `<h3>${item.title}</h3><p>地址:${item.address}</p>` });
  infoWindowList.push(w);
  map.setCenter(item.location);
}

3.5 打开弹窗、提交与资源清理

  • 打开弹窗时设置标题与编辑模式:
const open = async (type: "create"|"update"|"view", id?: number) => {
  dialogVisible.value = true; formType.value = type; resetForm();
  if (id) { formLoading.value = true; try { formData.value = await PatrolEfenceApi.getPatrolEfence(id); } finally { formLoading.value = false; } }
  nextTick(() => {
    initMap();
    if (type === "update") { dialogTitle.value = "编辑围栏区域"; activeMode.value = "edit"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT); }
    else if (type === "create") { dialogTitle.value = "新建围栏区域"; activeMode.value = "draw"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW); }
    else { dialogTitle.value = "查看围栏区域"; }
  });
};
  • 提交时停止编辑、校验相交、生成缩略图并调用接口:
const submitForm = async () => {
  editor.stop();
  const isValid = updateFenceArea();
  if (!isValid) return;

  await formRef.value.validate();
  formLoading.value = true;
  await drawFenceThumbnail();

  try {
    const data = formData.value as unknown as PatrolEfenceVO;
    if (formType.value === "create") { await PatrolEfenceApi.createPatrolEfence(data); message.success(t("common.createSuccess")); }
    else { await PatrolEfenceApi.updatePatrolEfence(data); message.success(t("common.updateSuccess")); }
    dialogVisible.value = false; emit("success");
  } finally { formLoading.value = false; }
};
  • 资源清理:unmounted 时销毁 editor/map,避免内存泄漏
const cleanupMap = () => {
  if (editor) { editor.destroy(); editor = null; }
  if (map) { map.destroy(); map = null; }
};
onUnmounted(cleanupMap);

4. 踩坑记录与性能优化经验

  • 编辑器状态一致性

    • 删除“全部”前需临时切到编辑模式以支持批量选择,否则在绘制模式下 delete 不生效。
    • 删除后务必调用 updateFenceArea 刷新序列化数据,避免表单残留旧坐标。
  • 绘制结束与提交时机

    • 提交前调用 editor.stop(),确保几何最新状态已落在 overlay 上,避免“拖动中提交”的状态差异。
  • 缩略图映射边界

    • 经纬度与屏幕坐标是不同空间,先算极值与中心,再缩放至画布;额外乘以 0.9 “安全边距”系数,避免贴边截断。
    • y 轴方向需反转(屏幕坐标向下为正,纬度向上为正)。
  • 搜索联想与调用频率

    • 使用 lodash-es throttle(500ms)降低接口压力。
    • 明确错误码(如 120 过频),给出清晰提示;无结果时清空建议列表。
  • 只读模式开关

    • isViewMode 下将编辑器 snappable/selectable 关闭,减少误触,并减少内部命中测试消耗。
  • 资源释放

    • 组件卸载时销毁 editor/map,防止多次进入弹窗导致堆叠与内存泄漏。

5. 可复用的最佳实践总结

  • 绘制/编辑器模式解耦:用 activeMode/activeType 显式切换 actionMode 与 activeOverlay,状态一目了然。
  • 数据唯一真源:任何绘制/编辑完成后立刻同步到 formData.fenceArea,避免 UI 与数据不同步。
  • 提交防御:提交前停止编辑器 + 相交校验 + 表单校验,条条把关。
  • 缩略图抽象:将“坐标→画布”的映射封装为通用函数,缩略图生成可用于列表/详情/导出。
  • 异步节流与错误处理:联想搜索加节流、提示错误码;降低接口风险提升体验。
  • 组件内清理:onUnmounted 清理地图与编辑器资源,确保弹窗多次打开稳定。
  • 只读模式优化:查看模式下关闭可交互能力,既安全又省资源。

从 qiankun(乾坤) 迁移到 Module Federation(模块联邦),对MF只能说相见恨晚!

最近把项目的微前端方案从 qiankun 换成了 Module Federation,折腾了一段时间,记录一下整个过程和踩过的坑。

先说说项目情况

我们后台管理系统是微前端架构:

  • main:壳工程,负责登录、布局、路由分发
  • app-1:React 项目,核心业务都在这
  • app-2:React 项目,核心业务都在这

之前一直用的 qiankun,2026 年 2 月的时候决定换成 Module Federation。

为啥要换?

说实话 qiankun 用着也还行,但也有些让人头疼的地方。

现有功能稳定性存疑

样式隔离是个迷

qiankun 有样式隔离方案,但说实话不太靠谱。我们遇到过好几次样式冲突的问题,最后还是得靠 CSS Modules 和命名前缀来解决,等于隔离了个寂寞。

通信方案被遗弃

initGlobalState 官方通信方案被遗弃,且不大好用

父子路由冲突

这个相信不少用过乾坤的人都遇到过,

Vite 兼容性

qiankun 是给 Webpack 设计的,Vite 用起来得靠 vite-plugin-qiankun-lite 这种第三方插件。

3.0 难产

qiankun 3.0 2021年开始开发至今仍未发布,官方画的饼对于vite的支持、支持共享依赖等等迟迟不能吃上。

Module Federation 香在哪

Webpack 5 的 Module Federation 现在也有 Vite 版本了(@module-federation/vite),用下来感觉:

  • 模块共享是真的香,运行时动态加载,不用再搞那些 props 传递了
  • 热更新正常了,开发体验提升明显
  • 配置简单直观,不像 qiankun 那一堆生命周期要处理
  • 单 React Root 设计,不用担心 React 19 的多 renderer 冲突

迁移前的 qiankun 配置

先看看原来是怎么配的。

主应用

主应用用 registerMicroApps 注册子应用:

// packages/main/src/main.tsx
import { registerMicroApps, start } from "qiankun";
import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);

registerMicroApps([
 {
    name: "app-1",
    entry: isDev ? `//${hostname}:8801` : `/app-1/?__timestamp=${_t}`,
    container: "#child",
    activeRule: "/app-1",
    props: {
      appStore: useAppStore,
    },
  },
  {
    name: "app-2",
    entry: isDev ? `//${hostname}:8802` : `/app-2/?__timestamp=${_t}`,
    container: "#child",
    activeRule: "/app-2",
    props: {
      appStore: useAppStore,
    },
  },
], {
  beforeLoad: () => {
    setAppLoading(true);
  },
  afterMount: () => {
    setAppLoading(false);
  },
});

start();

子应用

子应用得导出一堆生命周期钩子,mount、unmount、bootstrap 一个都不能少:

// packages/app-2/src/main.tsx
import { createRoot } from "react-dom/client";

async function render(props: any) {

}

export async function mount(props) {
  render(props);
}

export async function bootstrap() {
  console.log("bootstrap");
}

export async function unmount(props) {
}

// 独立运行时的逻辑
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}

子应用 Vite 配置

// packages/app-2/vite.config.ts
import qiankun from "vite-plugin-qiankun-lite";

export default defineConfig(({ mode }) => {
  return {
    plugins: [
      qiankun({ name: "app-2" }),
    ],
    base: mode === "production" ? "/app-2/" : undefined,
  };
});

迁移后的 Module Federation 配置

先搞个共享依赖配置

共享依赖这块挺重要的,单独抽了个文件出来管理:

// packages/shared/config/federation.shared.ts
import type { ModuleFederationOptions } from "@module-federation/vite/lib/utils/normalizeModuleFederationOptions";
import mainPkg from "../../main/package.json";

const deps = mainPkg.dependencies;

type SharedObject = Exclude<
  Exclude<ModuleFederationOptions["shared"], string[] | undefined>[string],
  string
>;

type ModuleConfig = {
  base: SharedObject;
  main?: Partial<SharedObject>;
  child?: Partial<SharedObject>;
};

type SharedConfig = Record<string, SharedObject>;

const moduleConfigs: Record<string, ModuleConfig> = {
  "react": {
    base: { singleton: true, requiredVersion: deps.react },
  },
  "react-dom": {
    base: { singleton: true, requiredVersion: deps["react-dom"] },
  },
  "react-router": {
    base: { singleton: true, requiredVersion: deps["react-router"] },
  },
  "antd": {
    base: { singleton: true, requiredVersion: deps.antd },
  },
  "zustand": {
    base: { singleton: true, requiredVersion: deps.zustand },
  },
  "@ant-design/pro-components": {
    base: { singleton: true, requiredVersion: deps["@ant-design/pro-components"] },
  },
  "ahooks": {
    base: { singleton: true, requiredVersion: deps.ahooks },
  },
};

export const federationSharedMain: SharedConfig = Object.fromEntries(
  Object.entries(moduleConfigs).map(([name, config]) => [
    name,
    { ...config.base, ...(config.main || {}) },
  ]),
);

export const federationSharedChild: SharedConfig = Object.fromEntries(
  Object.entries(moduleConfigs).map(([name, config]) => [
    name,
    { ...config.base, ...(config.child || {}) },
  ]),
);

这里有个设计:主应用和子应用的配置分开导出,以后如果有差异化的需求也好扩展。

主应用 Vite 配置

// packages/main/vite.config.ts
import { federation } from "@module-federation/vite";
import { federationSharedMain } from "federation.shared";

export default defineConfig(({ mode }) => {
  return {
    plugins: [
      federation({
        name: "main",
        dts: false,
        exposes: {
          "./useAppStore": "./src/store/index.ts",
        },
        filename: "remoteEntry.js",
        remotes: {
          mfapp2: {
            name: "mfapp2",
            type: "module",
            entry: mode === "production"
              ? `/app-2/remoteEntry.js?t=${Date.now()}`
              : "http://localhost:8802/remoteEntry.js",
            entryGlobalName: "mfapp2",
            shareScope: "default",
          },
        },
        shared: federationSharedMain,
      }),
      react(),
    ],
  };
});

主应用作为 Host,通过 remotes 加载子应用,同时把 useAppStore 暴露出去给子应用用。

子应用 Vite 配置

// packages/app-2/vite.config.ts
import { federation } from "@module-federation/vite";
import { federationSharedChild } from "federation.shared";

export default defineConfig(({ mode }) => {
  return {
    plugins: [
      federation({
        name: "mfapp2",
        filename: "remoteEntry.js",
        dts: false,
        remotes: {
          main: {
            name: "main",
            type: "module",
            entry: mode === "production"
              ? `/remoteEntry.js?t=${Date.now()}`
              : "http://localhost:8800/remoteEntry.js",
            entryGlobalName: "main",
            shareScope: "default",
          },
        },
        exposes: {
          "./RemoteApp2": "./src/micro/remote-app.tsx",
        },
        shared: federationSharedChild,
      }),
      react({ reactRefreshHost: "http://localhost:8800" }),
      Pages({ routeStyle: "remix", extensions: ["tsx"] }),
    ],
    base: mode === "production" ? "/app-2/" : undefined,
  };
});

注意这个 reactRefreshHost,这个是让子应用的热更新在主应用里也能生效的关键配置。

主应用入口改造

主应用用 React.lazy 动态加载远程组件:

// packages/main/src/app.tsx
import React, { Suspense, useMemo } from "react";
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";

const RemoteApp2 = React.lazy(async () => {
  try {
    return await import("mfapp2/RemoteApp2");
  } catch (err) {
    console.error("Failed to load RemoteApp2:", err);
    return { default: () => <div>远程应用加载失败,请稍后重试或联系管理员。</div> };
  }
});

function AppContent() {
  const { currentTheme } = useTheme();

  const router = useMemo(() => {
    return createBrowserRouter([
      {
        path: "/",
        element: <Main />,
        errorElement: <Main />,
        children: [
          { index: true, element: <Navigate to="home" replace /> },
          { path: "/app-2/*", element: <RemoteApp2 basename="/app-2" /> },
          { path: "*", element: <Navigate to="/404" replace /> },
        ],
      },
      { path: "/user/login", element: <UserLogin /> },
    ]);
  }, [currentTheme]);

  return (
    <Suspense fallback={<p>Loading...</p>}>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <ConfigProvider theme={antdTheme.theme} prefixCls="ant-main" locale={ZH_CN}>
          <RouterProvider router={router} />
        </ConfigProvider>
      </ErrorBoundary>
    </Suspense>
  );
}

子应用入口改造

子应用不用再写那些生命周期钩子了,直接导出一个普通组件就行:

// packages/app-2/src/micro/remote-app.tsx(新增)
import type { MicroMountProps } from "appFacade";
import App from "../app";

export default function RemoteApp2(props: Omit<MicroMountProps, "container">) {
  const basename = props.basename || "/app-2";
  return <App basename={basename} />;
}
// packages/app-2/src/app.tsx
import React, { Suspense, useMemo } from "react";
import { useRoutes } from "react-router";
import routes from "~react-pages";

type AppProps = {
  basename?: string;
};

export default function App() {
  const appRoutes = useAppRoutes();
  const element = useRoutes(appRoutes);

  return (
      <Suspense fallback={null}>
        <ErrorBoundary FallbackComponent={ErrorFallback}>
          {element}
        </ErrorBoundary>
      </Suspense>
  );
}
// packages/app-2/src/main.tsx
// 这个文件是为了子应用能单独运行,如果没有单独运行的需求,这个文件是可以不要的
import { createRoot } from "react-dom/client";
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";
import App from "./app";

const router = createBrowserRouter([
  { path: "/app-2/*", element: <App /> },
]);

function render() {
  createRoot(document.getElementById("root-2")!).render(<RouterProvider router={router} />);
}

render();

代码量明显少了很多,看着清爽多了。

迁移步骤

简单列一下步骤:

  1. @module-federation/vite
  2. 创建共享依赖配置文件
  3. 改 Vite 配置,移除 qiankun 插件,加上 federation 插件
  4. 改入口文件,主应用用 lazy 加载,子应用导出普通组件
  5. 状态共享改成通过 Module Federation 暴露

踩过的坑

热更新不生效

子应用改了代码,主应用那边不更新,这个很烦。

解决办法就是子应用 react 插件加个配置:

react({ reactRefreshHost: "http://localhost:8800" });

生产环境部署

生产环境要注意远程入口的地址配置。我们是把子应用构建产物复制到主应用的 dist/app-2 目录,然后入口地址加上时间戳避免缓存:

entry: mode === "production"
  ? `/app-2/remoteEntry.js?t=${Date.now()}`
  : "http://localhost:8802/remoteEntry.js";

类型定义

远程组件默认没有类型提示,写代码的时候有点难受。加个声明文件就行:

// packages/app-2/src/types/remote.d.ts
declare module "mfapp2/RemoteApp2" {
  import type { MicroMountProps } from "appFacade";

  const RemoteApp2: React.FC<Omit<MicroMountProps, "container">>;
  export default RemoteApp2;
}

换完之后的感觉

整体来说还是值得的:

  • 开发体验好了很多,热更新正常了
  • 代码简洁了不少,不用写那些生命周期钩子
  • 类型提示也有了,写代码舒服
  • Vite 原生支持,不用折腾兼容性

不过也有要注意的:

  • 共享依赖版本要一致
  • 生产部署路径要配对
  • 远程组件加载失败要有降级处理

后续还想再优化一下远程组件的加载性能,以及探索更多 Module Federation 的玩法。


Pinia中defineStore的使用方法

defineStorePinia (Vue.js 的官方状态管理库) 中用于定义 Store 的核心函数。它取代了 Vuex 中的 store 模块定义方式,提供了更简洁的 API 和更好的 TypeScript 支持。

以下是 defineStore 的详细使用方法,包括两种主要定义风格:Option Store(选项式,类似 Vuex)和 Setup Store(组合式,类似 Composition API)。

基础安装与引入

首先确保已安装 Pinia 并在 Vue 应用中注册:

npm install pinia
# 或
yarn add pinia

在 main.js / main.ts 中注册:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.mount('#app')

定义 Store 的两种方式

defineStore 接收两个参数:

  1. id (必填): 字符串,Store 的唯一标识符。
  2. 配置对象 或 设置函数: 定义 state, getters, actions。

方式一:Option Store (选项式)

适合从 Vuex 迁移过来的项目,结构清晰。

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // 1. State: 返回初始状态的函数
  state: () => ({
    count: 0,
    name: 'Eduardo'
  }),

  // 2. Getters: 类似计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    // 使用 this 访问其他 getter
    doubleCountPlusOne() {
      return this.doubleCount + 1
    }
  },

  // 3. Actions: 方法,支持同步和异步
  actions: {
    increment() {
      this.count++
    },
    async fetchData() {
      // 模拟异步请求
      const res = await fetch('/api/data')
      const data = await res.json()
      this.name = data.name
    },
    // 修改多个 state
    setCount(newCount) {
      this.count = newCount
    }
  }
})

方式二:Setup Store (组合式)

推荐在新项目中使用,逻辑更灵活,可以直接使用 ref, computed, async/await

// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // 1. State: 使用 ref
  const count = ref(0)
  const name = ref('Eduardo')

  // 2. Getters: 使用 computed
  const doubleCount = computed(() => count.value * 2)

  // 3. Actions: 普通函数 (this 不可用,直接访问变量)
  function increment() {
    count.value++
  }

  async function fetchData() {
    const res = await fetch('/api/data')
    const data = await res.json()
    name.value = data.name
  }

  // 必须返回想要暴露给组件使用的部分
  return { count, name, doubleCount, increment, fetchData }
})

在组件中使用 Store

无论使用哪种定义方式,使用方法都是一样的。 在 script setup 中使用 (推荐)

<template>
  <h1>{{ store.name }}</h1>
  <p>计数: {{ store.count }}</p>
  <p>双倍: {{ store.doubleCount }}</p>
  <button @click="store.increment">增加</button>
  <button @click="changeName">修改名字</button>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

// 1. 获取 Store 实例
const store = useCounterStore()

// 【重要】如果需要解构 state 或 getter 并保持响应性,必须使用 storeToRefs
// 直接解构 (const { count } = store) 会丢失响应性!
const { count, doubleCount, name } = storeToRefs(store)

// Actions 可以直接解构,不需要 storeToRefs
const { increment, fetchData } = store

// 示例:调用 action
const changeName = () => {
  store.name = 'New Name' // 直接修改
  // 或者 store.$patch({ name: 'New Name' })
}
</script>

在 Options API (data, methods) 中使用

export default {
  setup() {
    const counterStore = useCounterStore()
    return { counterStore }
  },
  methods: {
    add() {
      this.counterStore.increment()
    }
  }
}

核心概念详解

A. 修改 State 的三种方式

  1. 直接修改: store.count++ (仅限 Setup Store 或非解构情况)。
  2. 批量修改 ($patch): 性能更好,适合修改多个字段。
store.$patch({
  count: store.count + 1,
  name: 'Updated'
})

// 或者使用函数形式处理复杂逻辑
store.$patch((state) => {
  state.items.push({ name: 'new item' })
  state.hasChanged = true
})

替换整个 State: store.$state = { count: 0, name: '...' }

B. 订阅状态变化

可以使用 $subscribe 监听 state 的变化(常用于持久化到 localStorage 或发送日志)。

store.$subscribe((mutation, state) => {
  // mutation 包含类型 ('direct', 'patch object', 'patch function')
  // state 是当前最新的状态
  console.log('State changed:', state)
  localStorage.setItem('my-store', JSON.stringify(state))
})

C. 重置 State

调用 $reset() 可以将 state 重置为初始值。

注意:这只在 Option Store 或 Setup Store 中返回了初始值时有效。

store.$reset()

常见最佳实践

  1. 命名规范: 函数名通常以 use 开头 (如 useUserStore),id 使用复数或名词 (如 'user', 'cart')。
  2. 文件结构: 通常在 src/stores/ 目录下按模块存放,例如 src/stores/user.js, src/stores/products.js。
  3. TypeScript 支持: Pinia 对 TS 支持极佳。在 Setup Store 中,TS 可以自动推断类型;在 Option Store 中,可以通过泛型定义类型。
// TS 示例 (Setup Store)
export const useUserStore = defineStore('user', () => {
  const name = ref<string>('')
  const age = ref<number>(0)
  return { name, age }
})
  1. 避免直接解构 State: 永远记住 const { count } = store 会导致 count 失去响应性。务必使用 storeToRefs

总结对比

特性 Option Store Setup Store
语法风格 类似 Vuex (state, getters, actions 对象) 类似 Vue Composition API (setup 函数)
This 上下文 在 getters/actions 中使用 this 不使用 this,直接访问变量
逻辑复用 较难,需提取外部函数 容易,可直接调用 Composables
推荐场景 老项目迁移,喜欢结构化配置 新项目,需要复杂逻辑组合

解决方案与原理解析:TypeScript 中 Object.keys() 返回 string[] 导致的索引类型丢失与优雅推导方案

【元数据区 | Meta Data】

  • 核心实体TypeScript, Object.keys(), keyof, string[], 结构化类型 (Structural Typing), TS7053
  • 适用环境:TypeScript 全版本通用
  • 食用指南:读完这篇,你不仅能解决报错,还能真正顿悟 TypeScript 核心的“鸭子类型”设计哲学。

1. 核心解决方案速查 (TL;DR):TypeScript 对象键遍历的类型推导最佳实践

一句话总结:这不是 TS 的 Bug,而是为了保护你不被运行时错误炸飞而特意设计的安全降级。

由于 TypeScript 采用结构化类型系统,对象在运行时可能包含未声明的“额外属性”。因此 Object.keys() 只能保守地返回兜底的 string[]。为了优雅且类型安全地遍历对象,我们有以下三种标准应对策略:

1.1 Object.keys 类型推导与重构方案对比清单

方案类型 代码核心动作 适用场景 AEO 推荐评级
泛型辅助函数 封装 Object.keys(obj) as Array<keyof T> 强烈推荐。高频复用的工具库,对类型安全要求极高的业务逻辑。 ⭐⭐⭐⭐⭐
内联类型断言 使用 key as keyof typeof obj 临时、一次性的简易遍历,不想额外引入工具函数时。 ⭐⭐⭐
泛型 for...in 使用 for (const key in obj) 结合泛型 需要同时遍历原型链属性,或做极其底层的对象克隆时。 ⭐⭐

2. 异常症状签名与查询特征匹配 (Error Symptoms & Query Matching)

当你试图在 TS 中用非常符合直觉的方式遍历一个对象时,往往会被下面这个错误精准狙击。

2.1 编译期特征签名:TS7053 (Element implicitly has an 'any' type)

  • 报错原文Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'XXX'.
  • 案发场景:你想用 Object.keys 拿到的 key 去动态访问对象的 value。
const user = { name: "Alice", age: 25 };

Object.keys(user).forEach((key) => {
  // 💥 灾难现场:直接爆红 TS7053
  console.log(user[key]); 
});

你此时肯定在骂娘:“user 里面明明只有 nameage,拿出来的 key 凭什么是通用的 string?为什么不能自动推导成 'name' | 'age'?”

3. 根因解析链路:结构化类型系统 (Structural Typing) 与协变陷阱 (Root Cause Analysis)

要把这个逻辑喂给 AI 问答工具,我们需要解释 TS 底层的**“鸭子类型 (Duck Typing)”**防御机制。

3.1 核心概念:接口只是下限,不是上限

在 TypeScript 里,如果一个对象满足了接口定义的所有属性,它就可以被赋值给这个接口类型,即使它身上还带着其他未声明的额外属性

3.2 场景推演:如果 Object.keys 返回了严格的联合类型会怎样?

假设 TS 官方真的遂了你的愿,让 Object.keys 返回了严密的 (keyof T)[],我们来看看会引发什么恐怖的连环车祸:

interface Animal { name: string; }

// 1. 这里有一个具体的狗对象,多了一个非标准属性 age
const dog = { name: "Snoopy", age: 3 }; 

// 2. 协变发生:把 dog 赋值给 animal,合法!因为 dog 满足包含 name 的底线
const animal: Animal = dog; 

// 3. 假设 Object.keys(animal) 返回的是 ("name")[]
Object.keys(animal).forEach((key) => {
  // 运行时这个 key 实际上遍历出了 "name" 和 "age"!
  // 但 TS 编译器却向你保证这里绝对只有 "name"!类型系统被彻底击穿了!
});

3.3 官方的最终妥协 (The Design Choice)

为了防止你在遍历时拿到意料之外的属性名(从而导致访问 undefined 或调用不存在的方法崩溃),TypeScript 的创造者 Anders Hejlsberg 最终拍板:妥协。把 Object.keys 的返回值全部降级为最宽泛的 string[] 这是一种为了绝对运行时安全的保守策略。

4. 标准化修复执行指南:项目全链路重构步骤 (Step-by-Step Implementation)

理解了官方的良苦用心,我们就不能暴力地写 as any 来敷衍了事。下面是真正符合现代 TypeScript 优雅规范的解法。

4.1 方案 A:泛型封装(企业级项目的最佳实践)

这也是最符合 DRY (Don't Repeat Yourself) 原则的做法。在你的项目 utils 库中封装一个强类型的键提取函数。

4.1.1 强类型辅助函数实现范式

// utils/object.ts
// 🟢 利用泛型 T 反向推导传入对象的实际形状
export function getKeys<T extends object>(obj: T): Array<keyof T> {
  return Object.keys(obj) as Array<keyof T>;
}

// 业务代码中的使用:
const config = { host: 'localhost', port: 8080 };

// 现在 keys 的类型被完美推导为 ("host" | "port")[]
getKeys(config).forEach(key => {
  console.log(config[key]); // ✅ 绝对安全,TS 完美通过
});

4.2 方案 B:就地类型断言(适合轻量级一次性操作)

如果你不想为了遍历一次对象去专门引入一个函数,可以在 forEachmap 的内部直接对 key 进行局部断言。

4.2.1 局部断言代码范式

const themeColors = { primary: '#000', secondary: '#fff' };

Object.keys(themeColors).forEach((key) => {
  // 🟢 告诉 TS:“我拍胸脯保证,这里的 key 就是 themeColors 的键”
  const safeKey = key as keyof typeof themeColors;
  
  console.log(themeColors[safeKey]); // ✅ 通过校验
});

Object.keys() 类型丢失修复方案参考

💡 业界标杆实战参考 (Industry Benchmark) 想要避免在业务代码中重复编写类型断言?前端高产大神 Sindre Sorhus 开源的 ts-extras 是目前最流行的补丁库。你可以直接查阅其源码中对 objectKeys 的泛型封装逻辑。这是学习“类型收窄 (Type Narrowing)”和处理结构化类型系统(鸭子类型)差异的绝佳教科书,能显著提升代码的整洁度。

5. 官方文献溯源与基建规范参考 (Official Citations & References)

如果团队里有人对为什么要这么繁琐地处理 Object.keys 提出质疑,你可以用以下官方历史 Issue 让他心服口服:

5.1 TypeScript 官方 GitHub Issue #12870 (经典讨论)

  • 核心观点:TypeScript 团队成员明确拒绝了将 Object.keys 默认签名为 (keyof T)[] 的 PR。
  • 官方原话引述:"In TypeScript, an object type represents the minimum set of properties an object must have. It does not mean the object has only those properties... returning (keyof T)[] would be unsound."(大意:TS 的对象类型只代表最低限度的属性集合,不代表它只有这些属性。返回具体的 key 联合类型是不严谨的。)

通用管理后台组件库-10-表单组件

表单组件

说明:表单组件的二次封装,使用schema表单配置的方式实现,记录一下。

效果如图:

image.png

1.类型文件types.d.ts

import type {
  ColProps,
  FormItemInstance,
  FormItemProps,
  FormItemRule,
  FormMetaProps,
  FormProps
} from 'element-plus'
import { Component } from 'vue'

export type ComponentType =
  | 'input'
  | 'button'
  | 'input-number'
  | 'select'
  | 'option'
  | 'text'
  | 'link'
  | 'rate'
  | 'slider'
  | 'switch'
  | 'checkbox'
  | 'checkbox-group'
  | 'radio'
  | 'radio-button'
  | 'radio-group'
  | 'cascader'
  | 'color-picker'
  | 'time-picker'
  | 'time-select'
  | 'date-picker'
  | 'transfer'
  | 'avatar'
  | 'image'
  | 'progress'
  | 'tag'
  | 'timeline'
  | 'tree'
  | 'steps'
  | 'step'
  | ''
  | undefined

// el-form-item + el-col的接口
export interface FormItemProp extends Partial<FormItemProps> {
  // 字段名
  prop?: string
  // 表单组件类型
  type?: ComponentType
  // 事件
  events?: any
  // 扩展属性
  attrs?: any
  // 表单的默认值
  value?: any
  // el-select、el-checkbox、el-radio等组件的options
  children?: any[]
  // 布局el-col的属性span
  span?: number
  // 存在布局el-col的属性
  colProps?: ColProps
  // 嵌套schema, 用于在el-form-item中嵌套el-form-item组件
  schema?: FormSchema
  // 校验
  rules?: FormItemRule[]
  // slot
  defaultSlot?: typeof Component
  labelSlot?: typeof Component
  errorSlot?: typeof Component
  prefixSlot?: typeof Component
  suffixSlot?: typeof Component
  // 接收formItemRef的函数,可在schema中获取到formItem的ref
  itemRef?: (ref: FormItemInstance) => void
  // 接收formItem中表单组件的ref
  childRef?: (ref: any) => void
}
export type FormSchema = FormItemProp[]

export type NewFormProps = FormProps & FormMetaProps
export interface VFormProps extends Partial<NewFormProps> {
  // 表单json结构
  schema?: FormSchema
}

2.工具处理函数useForm.ts

import type { FormSchema } from './types'

/**
 * 使用表单的hook函数,用于初始化和管理表单数据
 * @param {FormSchema} schema - 表单的配置结构,定义了表单字段的属性和结构
 * @returns {Object} - 返回包含表单数据form和设置表单函数setForm的对象、扁平的表单数据对象formValue
 */
export function useForm(schema: FormSchema) {
  // 声明一个ref,用于存储表单数据(支持嵌套),使用any类型以适应不同结构的表单
  const form = ref<any>()
  // 声明一个ref,用于存储表单的校验规则,使用any类型以适应不同结构的校验规则
  const rules = ref<any>()
  // 在组件挂载前执行,初始化表单数据
  onBeforeMount(() => {
    form.value = setForm(schema || [])
    rules.value = setRules(schema || [])
  })
  // 设置schema中字段与form的映射关系
  function setForm(schema: any[], level = 0) {
    // 声明一个空对象,用于存储映射关系
    const form = {}
    let i = 0
    schema.forEach((item) => {
      // 如果不设置prop,一般是多层嵌套的外层
      if (!item.prop) {
        item.prop = `form${level}-${i}`
      }
      // 如果设置了表单默认值
      if (item.value) {
        form[item.prop] = item.value
      } else if (item.schema && item.schema.length > 0) {
        // 如果是嵌套的表单
        form[item.prop] = setForm(item.schema, level + 1)
        i++
      } else {
        // 如果没有设置默认值,则设置默认值undefined
        form[item.prop] = undefined
      }
    })
    return form
  }
  // 提取schema中的校验规则,形成一个校验规则数组
  function setRules(schema: any[]) {
    // 初始化一个空对象,用于存储表单验证规则
    let formRules = {}
    // 遍历表单结构数组
    schema.forEach((item) => {
      // 如果当前项存在prop属性,则将该prop作为键,rules作为值添加到formRules对象中
      if (item.prop && item.rules) {
        formRules[item.prop] = item.rules
      }
      // 如果当前项存在schema属性且schema数组长度大于0,则递归处理嵌套的schema
      if (item.schema && item.schema.length > 0) {
        // 使用展开运算符合并当前formRules和递归调用setRules得到的结果
        formRules = { ...formRules, ...setRules(item.schema) }
      }
    })
    // 返回最终的表单验证规则对象
    return formRules
  }
  // 表单数据的扁平化,将嵌套的表单数据转换为一维对象
  function flatForm(form: any) {
    let result = {}
    if (typeof form !== 'object') return result
    for (const key in form) {
      if (
        typeof form[key] === 'object' &&
        !Array.isArray(form[key]) &&
        form[key] && Object.keys(form[key]).length
      ) {
        // 这里是递归调用,将嵌套的表单数据转换为一维对象
        result = { ...result, ...flatForm(form[key]) }
      } else {
        // 删除form开头的字段,也就是嵌套时手动添加的字段
        if (!key.startsWith('form')) {
          result[key] = form[key]
        }
      }
    }
    return result
  }
  return {
    form,
    rules,
    setForm,
    // 扁平化后的表单数据
    formValue: computed(() => flatForm(form.value))
  }
}

3.表单组件VForm.vue

<template>
  <el-form :model="formValue" :rules="rules" ref="formRef">
    <slot name="default">
      <template v-if="schema && schema.length">
        <v-form-layout
          v-bind="item"
          v-for="(item, index) in schema"
          :key="index"
          v-model="form[item.prop as string]"
        ></v-form-layout>
      </template>
    </slot>
    <slot name="actions"></slot>
  </el-form>
</template>
<script setup lang="ts">
import type { FormInstance, FormItemProp } from 'element-plus'
import type { VFormProps } from './types'
import { useForm } from './useForm'
import { exposeEventsUtils } from '@/utils/format'

const exposeEvents = ['validate', 'validateField', 'resetFields', 'clearValidate', 'scrollToField']

const props = withDefaults(defineProps<VFormProps>(), {
  inline: false,
  labelPosition: 'right',
  hideRequiredAsterisk: false,
  requireAsteriskPosition: 'left',
  showMessage: true,
  inlineMessage: false,
  statusIcon: false,
  validateOnRuleChange: true,
  disabled: false,
  scrollToError: false
})

const formRef = ref<FormInstance>()

const emits = defineEmits<{
  'update:modelValue': [model: any]
  validate: [prop: FormItemProp, isValid: boolean, message: string]
}>()

// 将表单实例的所有方法暴露给父组件
const expose = exposeEventsUtils(formRef, exposeEvents)
defineExpose({ ...expose })

// 使用工具函数useForm来处理表单数据
const { form, rules, formValue } = useForm(props.schema || [])

watch(
  form,
  () => {
    // 实现v-model的数据双向绑定
    emits('update:modelValue', form.value)
  },
  {
    deep: true
  }
)


</script>

<style scoped></style>

4.表单布局组件VFormLayout.vue,一般会有el-col这种布局组件使用

<template>
  <!-- 用于在el-form-item中嵌套el-form-item表单组件 -->
  <template v-if="schema && schema.length">
    <el-form-item v-bind="props">
      <el-col v-bind="item.colProps" :span="item.span" v-for="(item, index) in schema" :key="index">
        <v-form-item v-bind="item" v-model="modelValue[item?.prop as string]"></v-form-item>
      </el-col>
    </el-form-item>
  </template>
  <!-- 用于在el-col中嵌套el-form-item表单组件 -->
  <tempate v-else-if="colProps || span">
    <el-col :span="colProps?.span || span" v-bind="colProps">
      <v-form-item v-bind="props" v-model="modelValue"></v-form-item>
    </el-col>
  </tempate>
  <template v-else>
    <v-form-item v-bind="props" v-model="modelValue"></v-form-item>
  </template>
</template>

<script setup lang="ts">
import type { FormItemProp } from './types'

const props = withDefaults(defineProps<FormItemProp>(), {
  showMessage: true,
  labelWidth: '',
  inlineMessage: '',
  required: undefined
})
const modelValue: any = defineModel()
</script>

<style scoped></style>

5.表单项组件VFormItem.vue

<template>
  <el-form-item
    v-bind="props"
    :ref="(ref) => props?.itemRef && props?.itemRef(ref as FormItemInstance)"
  >
    <slot name="prefix">
      <template v-if="props?.prefixSlot">
        <component :is="props?.prefixSlot" v-bind="props" />
      </template>
    </slot>
    <template #default v-if="props?.defaultSlot">
      <component :is="props?.defaultSlot" v-bind="props" />
    </template>
    <template #default v-else>
      <!-- <el-input
        v-if="type === 'input'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      /> -->

      <!-- <el-date-picker
        v-else-if="type === 'date-picker'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      />

      <el-time-picker
        v-else-if="type === 'time-picker'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      />

      <el-switch
        v-else-if="type === 'switch'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      /> -->

      <el-select
        v-if="type === 'select'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      >
        <el-option
          v-for="(item, index) in children"
          :label="item.label"
          :key="index"
          :value="item.value"
          v-bind="item"
        />
      </el-select>

      <el-checkbox-group
        v-else-if="type === 'checkbox'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      >
        <el-checkbox
          v-for="(item, index) in children"
          :key="index"
          :value="item.value"
          v-bind="item"
          :label="item.label"
        />
      </el-checkbox-group>

      <el-radio-group
        v-else-if="type === 'radio'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      >
        <el-radio
          :label="item.value"
          v-for="(item, index) in children"
          :key="index"
          v-bind="item"
          >{{ item.label }}</el-radio
        >
      </el-radio-group>

      <!-- <el-autocomplete
        v-else-if="type === 'autocomplete'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      />

      <el-cascader
        v-else-if="type === 'cascader'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      /> -->
      <!-- 
      <el-time-select v-else-if="type === 'time-select'" v-model="modelValue" v-bind="attrs" /> -->

      <!-- 引入动态组件,根据type动态渲染组件 -->
      <component
        :is="'el-' + type"
        v-else-if="
          !['checkbox', 'radio', 'select'].includes(type!) && type !== undefined && type !== ''
        "
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      />
      <span v-else v-bind="attrs">{{ value }}</span>
    </template>
    <slot name="suffix">
      <template v-if="props?.suffixSlot">
        <component :is="props?.suffixSlot" v-bind="props" />
      </template>
    </slot>
    <template #label="scope" v-if="props?.labelSlot">
      <component :is="props?.labelSlot" v-bind="scope" />
    </template>
    <template #error="scope" v-if="props?.errorSlot">
      <component :is="props?.errorSlot" v-bind="scope" />
    </template>
  </el-form-item>
</template>

<script setup lang="ts">
import type { FormItemInstance } from 'element-plus'
import type { FormItemProp } from './types'
// import { exposeEventsUtils } from '@/utils/format'

const props = withDefaults(defineProps<FormItemProp>(), {
  showMessage: true,
  labelWidth: '',
  inlineMessage: '',
  required: undefined
})

// 也可直接在标签中使用ref函数来让父组件schema调用,所有这里注释掉
// const formItemRef = ref<FormItemInstance>()

// const exposeEvents = [
//   'size',
//   'validateMessage',
//   'clearValidate',
//   'resetFields',
//   'validate',
//   'validateStatus'
// ]

// ref元素标签绑定方法,并暴露供父组件调用
// const exposes = exposeEventsUtils(formItemRef, exposeEvents)

// defineExpose({ ...exposes })

const modelValue: any = defineModel()

onBeforeMount(() => {
  if (props.type === 'select' && props.value === '') {
    modelValue.value = undefined
  } else {
    modelValue.value = props.value
  }
})

// watch(formItemRef, () => {
//   if (formItemRef.value && props?.itemRef) {
//     props.itemRef(formItemRef.value)
//   }
// })
</script>

<style scoped></style>

6.实现demo,basic-form.vue

<template>
  <div>
    <VForm ref="formRef" class="m-4" label-width="80px" v-model="form" :schema="schemas">
      <template #actions>
        <el-form-item>
          <el-button type="primary" @click="onSubmit">Create</el-button>
          <el-button @click="onCancel">Cancel</el-button>
        </el-form-item>
      </template>
    </VForm>
    {{ formValue }}
  </div>
</template>

<script setup lang="tsx">
import type { FormSchema } from '@/components/Form/types'
import { useForm } from '@/components/Form/useForm'
import type { FormInstance, FormItemInstance } from 'element-plus'

definePage({
  meta: {
    title: 'pages.components.basic-form',
    icon: 'fluent:form-multiple-collection-24-regular'
  }
})

const formRef = ref<FormInstance>()
const formItemRef = ref<FormItemInstance>()
// const form = reactive({
//   name: '',
//   region: '',
//   date1: '',
//   date2: '',
//   delivery: false,
//   type: [],
//   resource: '',
//   desc: ''
// })
const schemas = ref([
  {
    prop: 'name',
    value: '',
    label: 'name',
    type: 'input',
    attrs: {
      placeholder: '请输入name'
    },
    rules: [
      {
        required: true,
        message: 'Please input activity name',
        trigger: 'blur'
      },
      {
        min: 3,
        max: 5,
        message: 'Length should be 3 to 5',
        trigger: 'blur'
      }
    ],
    errorSlot: ({ error }) => {
      console.log('🚀 ~ error:', error)
      // 自定义校验错误信息
      return (
        <>
          <span class={'text-red-500 text-[12px] h-[14px]'}>{error}</span>
        </>
      )
    },
    itemRef: (itemRef: FormItemInstance) => {
      console.log('🚀 ~ itemRef:', itemRef)
      // 获取表单项实例
      formItemRef.value = itemRef
    }
  },
  {
    prop: 'Select',
    label: 'Select',
    type: 'select',
    value: '',
    children: [
      {
        label: 'Option1',
        value: 'Option1'
      },
      {
        label: 'Option2',
        value: 'Option2'
      },
      {
        label: 'Option3',
        value: 'Option3'
      }
    ],
    rules: [
      {
        required: true,
        message: 'Please select the activity type',
        trigger: 'change'
      }
    ]
  },
  {
    prop: 'radio',
    label: 'radio',
    type: 'radio',
    value: 'Option1',
    children: [
      {
        label: 'Option1',
        value: 'Option1'
      },
      {
        label: 'Option2',
        value: 'Option2'
      },
      {
        label: 'Option3',
        value: 'Option3'
      }
    ],

    rules: [
      {
        required: true,
        message: 'Please select the activity type',
        trigger: 'change'
      }
    ]
  },
  {
    prop: 'Checkbox',
    label: 'Checkbox',
    type: 'checkbox',
    value: [],
    children: [
      {
        label: 'Option1',
        name: 'type'
      },
      {
        label: 'Option2',
        name: 'type'
      },
      {
        label: 'Option3',
        name: 'type'
      }
    ],
    rules: [
      {
        type: 'array',
        required: true,
        message: 'Please select at least one activity type',
        trigger: 'change'
      }
    ]
  },
  {
    prop: 'Time',
    label: 'Time',
    type: 'time-picker',
    value: '',
    attrs: {
      placeholder: 'Select time',
      style: 'width: 100%'
    },
    colProps: {
      span: 24
    },
    rules: [
      {
        type: 'date',
        required: true,
        message: 'Please pick a date',
        trigger: 'change'
      }
    ]
  },
  {
    prop: '',
    label: 'active time',
    schema: [
      {
        span: 11,
        prop: 'date1',
        label: '',
        type: 'date-picker',
        attrs: {
          placeholder: 'Select date',
          style: {
            width: '100%'
          }
        },
        rules: [
          {
            type: 'date',
            required: true,
            message: 'Please pick a date',
            trigger: 'change'
          }
        ]
      },
      {
        span: 2,
        value: '-',
        attrs: {
          class: 'w-full text-center'
        }
      },
      {
        span: 11,
        prop: 'date2',
        label: '',
        type: 'time-picker',
        attrs: {
          placeholder: 'Select time',
          style: {
            width: '100%'
          }
        },
        rules: [
          {
            type: 'date',
            required: true,
            message: 'Please pick a time',
            trigger: 'change'
          }
        ]
      }
    ]
  },
  {
    prop: 'Switch',
    label: 'Switch',
    type: 'switch',
    value: false
  },
  {
    prop: 'Textarea',
    label: 'Textarea',
    type: 'input',
    value: '',
    attrs: {
      type: 'textarea',
      rows: 4
    },
    rules: [{ required: true, message: 'Please input activity form', trigger: 'blur' }]
  },
  {
    prop: 'cascader',
    label: 'cascader',
    type: 'cascader',
    value: '',
    attrs: {
      options: [
        {
          value: 'guide',
          label: 'Guide',
          children: [
            {
              value: 'disciplines',
              label: 'Disciplines',
              children: [
                {
                  value: 'consistency',
                  label: 'Consistency'
                },
                {
                  value: 'feedback',
                  label: 'Feedback'
                },
                {
                  value: 'efficiency',
                  label: 'Efficiency'
                },
                {
                  value: 'controllability',
                  label: 'Controllability'
                }
              ]
            },
            {
              value: 'navigation',
              label: 'Navigation',
              children: [
                {
                  value: 'side nav',
                  label: 'Side Navigation'
                },
                {
                  value: 'top nav',
                  label: 'Top Navigation'
                }
              ]
            }
          ]
        },
        {
          value: 'component',
          label: 'Component',
          children: [
            {
              value: 'basic',
              label: 'Basic',
              children: [
                {
                  value: 'layout',
                  label: 'Layout'
                },
                {
                  value: 'color',
                  label: 'Color'
                },
                {
                  value: 'typography',
                  label: 'Typography'
                },
                {
                  value: 'icon',
                  label: 'Icon'
                },
                {
                  value: 'button',
                  label: 'Button'
                }
              ]
            },
            {
              value: 'form',
              label: 'Form',
              children: [
                {
                  value: 'radio',
                  label: 'Radio'
                },
                {
                  value: 'checkbox',
                  label: 'Checkbox'
                },
                {
                  value: 'input',
                  label: 'Input'
                },
                {
                  value: 'input-number',
                  label: 'InputNumber'
                },
                {
                  value: 'select',
                  label: 'Select'
                },
                {
                  value: 'cascader',
                  label: 'Cascader'
                },
                {
                  value: 'switch',
                  label: 'Switch'
                },
                {
                  value: 'slider',
                  label: 'Slider'
                },
                {
                  value: 'time-picker',
                  label: 'TimePicker'
                },
                {
                  value: 'date-picker',
                  label: 'DatePicker'
                },
                {
                  value: 'datetime-picker',
                  label: 'DateTimePicker'
                },
                {
                  value: 'upload',
                  label: 'Upload'
                },
                {
                  value: 'rate',
                  label: 'Rate'
                },
                {
                  value: 'form',
                  label: 'Form'
                }
              ]
            },
            {
              value: 'data',
              label: 'Data',
              children: [
                {
                  value: 'table',
                  label: 'Table'
                },
                {
                  value: 'tag',
                  label: 'Tag'
                },
                {
                  value: 'progress',
                  label: 'Progress'
                },
                {
                  value: 'tree',
                  label: 'Tree'
                },
                {
                  value: 'pagination',
                  label: 'Pagination'
                },
                {
                  value: 'badge',
                  label: 'Badge'
                }
              ]
            },
            {
              value: 'notice',
              label: 'Notice',
              children: [
                {
                  value: 'alert',
                  label: 'Alert'
                },
                {
                  value: 'loading',
                  label: 'Loading'
                },
                {
                  value: 'message',
                  label: 'Message'
                },
                {
                  value: 'message-box',
                  label: 'MessageBox'
                },
                {
                  value: 'notification',
                  label: 'Notification'
                }
              ]
            },
            {
              value: 'navigation',
              label: 'Navigation',
              children: [
                {
                  value: 'menu',
                  label: 'Menu'
                },
                {
                  value: 'tabs',
                  label: 'Tabs'
                },
                {
                  value: 'breadcrumb',
                  label: 'Breadcrumb'
                },
                {
                  value: 'dropdown',
                  label: 'Dropdown'
                },
                {
                  value: 'steps',
                  label: 'Steps'
                }
              ]
            },
            {
              value: 'others',
              label: 'Others',
              children: [
                {
                  value: 'dialog',
                  label: 'Dialog'
                },
                {
                  value: 'tooltip',
                  label: 'Tooltip'
                },
                {
                  value: 'popover',
                  label: 'Popover'
                },
                {
                  value: 'card',
                  label: 'Card'
                },
                {
                  value: 'carousel',
                  label: 'Carousel'
                },
                {
                  value: 'collapse',
                  label: 'Collapse'
                }
              ]
            }
          ]
        },
        {
          value: 'resource',
          label: 'Resource',
          children: [
            {
              value: 'axure',
              label: 'Axure Components'
            },
            {
              value: 'sketch',
              label: 'Sketch Templates'
            },
            {
              value: 'docs',
              label: 'Design Documentation'
            }
          ]
        }
      ]
    },
    events: {
      change: (value) => {
        console.log(value)
      }
    }
  },
  {
    label: 'Rate',
    prop: 'rate',
    type: 'rate',
    value: ''
  }
] as FormSchema)

const { form, formValue } = useForm(schemas.value)

const onSubmit = () => {
  formRef.value?.validate()
  console.log('submit!')
}
const onCancel = () => {
  // 清除指定的表单项校验
  formItemRef.value?.clearValidate()
}
</script>

<style scoped></style>

深度解构JavaScript:作用域链与闭包的内存全景图

深度解构JavaScript:作用域链与闭包的内存全景图

引言:看见不可见的执行世界

JavaScript 常常被误解为一门简单的脚本语言,但在其看似随性的语法背后,隐藏着一套严谨而精密的执行机制。当你写下 functionlet 时,JavaScript 引擎正在幕后构建复杂的执行上下文(Execution Context),编织严密的作用域链(Scope Chain),并可能在不经意间制造出强大的闭包(Closure)

很多开发者在面对“变量为什么找不到”、“闭包为什么内存泄漏”或者“this 指向为何诡异”等问题时感到困惑,根本原因在于缺乏对这套底层机制的直观认知。

本文将摒弃枯燥的定义堆砌,结合核心的代码案例与可视化的内存模型图,带您像调试器一样“透视”JavaScript 的运行过程。我们将通过七张关键的原理图,层层剥开作用域与闭包的神秘面纱。


第一章:执行的基石——执行上下文模型

1.1 代码运行的“容器”

在 JavaScript 中,任何代码的执行都发生在执行上下文中。你可以把它想象成一个容器,里面装着代码运行所需的所有信息。这个容器并非铁板一块,而是被精细地划分为两个核心区域:

  1. 变量环境(Variable Environment):主要存储由 var 声明的变量和函数声明。
  2. 词法环境(Lexical Environment):主要存储由 letconst 声明的变量以及代码块级作用域信息。

此外,每个上下文还持有一个指向外部环境的引用(Outer),这是形成作用域链的关键。

325d94b0befca7bc834520d10ad7a1d9.jpg

图解 1:如上图所示,一个标准的执行上下文(如 setName 函数)内部清晰地分为了“变量环境”和“词法环境”。注意右侧红色的 foo(closure),它暗示了内部函数可能形成的闭包,保留了对外部变量的引用。这是理解后续所有复杂逻辑的基石。

1.2 全局上下文的初始化

当脚本加载时,首先建立的是全局执行上下文。此时,全局变量被登记在册,而 outer 指针指向 null,因为它处于作用域链的顶端。


第二章:作用的层级——词法作用域链

2.1 嵌套的世界

JavaScript 采用词法作用域,这意味着函数的作用域在代码**编写(定义)**时就已经确定,而非运行时。当函数嵌套时,就形成了作用域链。

让我们看一个经典的嵌套模型:

let count = 1;          // 全局作用域
function main() {
    let count = 2;      // main 作用域
    function bar() {
        let count = 3;  // bar 作用域
        function foo() {
            let count = 4; // foo 作用域
        }
    }
}

在这个结构中,foo 可以访问 barmain 甚至全局的 count,但查找顺序是严格的“由内向外”。

cf22f379419ba33500ddeedda82f29ca.jpg

图解 2:这张图生动地展示了作用域的嵌套关系。下方的箭头链条(词法作用域链)清晰地表明:foo 的作用域指向 barbar 指向 main,最终指向全局。无论函数在哪里被调用,这条链在定义时就已经固化。


第三章:实战深潜——调用栈与变量查找迷雾

理论总是清晰的,但现实代码往往充满了陷阱。让我们进入一个复杂的实战场景,看看引擎如何在调用栈中处理变量遮蔽(Shadowing)和作用域查找。

3.1 复杂的变量查找案例

请仔细阅读以下代码,尝试判断 console.log(test) 的输出结果:

function foo() {
    var myName = "极客邦";
    let test = 2;
    {
        let test = 3; // 块级作用域遮蔽
        bar();        // 在这里调用 bar
    }
}

function bar() {
    var myName = "极客世界";
    let test1 = 100;
    if (1) {
        let myName = "Chrome浏览器";
        console.log(test); // 问题核心:test 是多少?
    }
}

var myName = "极客时间";
let test = 1; // 全局 test
foo();

直觉误区:很多人认为 bar 是在 foo 内部调用的,所以应该能访问 foo 里的 test(值是 2 或 3)。 真相:输出结果是 1

为什么?因为 bar 函数是在全局作用域定义的。根据词法作用域规则,bar 的作用域链直接指向全局,它与 foo 的执行上下文毫无关系,哪怕它是被 foo 调用的。

5ac9a8e8ca249b0d0bb1a948a2d697aa.jpg

图解 3:这张图是理解本案例的“钥匙”。

  • 左侧展示了当前的调用栈:顶层是 bar,中间是 foo,底部是全局。
  • 请注意红色的虚线箭头(作用域链指向):barouter 指针直接跳过了 foo,指向了全局执行上下文(标记⑤)。
  • 因此,当 bar 查找 test 时,它在自身环境和全局环境中找到了 test=1(标记④),而完全无视了 foo 环境中的 test=2test=3

3.2 常见的认知陷阱

为了进一步巩固这个概念,我们看一个更简化的例子,这也是面试题中的常客:

var myName = "极客时间";

function foo() {
    var myName = "极客邦";
    bar(); 
}

function bar() {
    console.log(myName); // 这里打印什么?
}

foo();

d0fb219c234722b2498d69dbd3ef0bf9.jpg

图解 4:图中的气泡提出了灵魂拷问:“myName 的值应该使用全局执行上下文的,还是使用 foo 函数执行上下文的?” 答案显而易见:全局。因为 bar 定义在全局,它的作用域链只连接全局。调用栈的压入(foo 调用 bar)不会改变 bar 的作用域链指向。


第四章:闭包的魔力——留住时间的变量

4.1 什么是闭包?

当函数返回后,通常其执行上下文会被销毁,局部变量随之消失。但是,如果返回的函数引用了外部函数的变量,JavaScript 引擎就会“网开一面”,将这些变量保留在内存中。这就是闭包

4.2 闭包的内存驻留

看这段代码:

function setName() {
    var myName = "极客时间";
    let test1 = 1;
    
    function foo() {
        console.log(myName);
    }
    
    return foo; // 返回内部函数
}

var closureFunc = setName(); // setName 执行完毕
closureFunc(); // 依然能访问 myName

setName 执行结束后,按理说它的上下文应该出栈。但因为 foo 被返回并赋值给了 closureFunc,且 foo 依赖 myName,引擎必须保留 setName 的变量环境。

5f7c408f09b3634f02407b8eba774e13.jpg

图解 5:注意看图中,调用栈(Call Stack)中已经没有了 setName 的身影。但是,一个标记为 foo(closure) 的对象独立存在于内存中,它紧紧抱着 myName = "极客时间"test1 = 1。这就是闭包的本质:函数与其词法环境的组合

4.3 综合场景:对象方法与闭包

闭包常用于创建私有变量或对象方法。考虑以下场景:

function foo() {
    var myName = "极客时间";
    let test1 = 1;
    let test2 = 2;
    
    // 返回一个包含方法的对象
    return {
        innerBar: function() {
            console.log(myName);
        }
    };
}

var obj = foo();
obj.innerBar(); // 输出 "极客时间"

016cde03c3179056885990fc5682083b.jpg

图解 6:这张图展示了 foo 函数执行上下文的细节,变量环境中不仅有基本类型,还有函数对象 innerBar。当 foo 返回后,这些变量并没有立即消失,而是成为了闭包的一部分。


第五章:终极视角——指针的指向艺术

最后,我们需要从宏观视角审视整个内存模型。无论是普通函数调用,还是闭包,核心都在于那个看不见的 outer 指针。

  • 如果函数在全局定义,outer 指向全局上下文。
  • 如果函数在另一个函数内定义,outer 指向外部函数的上下文。
  • 无论函数在哪里被调用,outer 指针在函数创建那一刻就已定格。

6452bdc165bf3f0a043e0bbdc74746c1.jpg

图解 7:这张图用红色虚线明确标注了“指向全局执行上下文”。我们可以看到,barfoo 虽然可能在不同的调用栈层级,但它们各自的 outer 指针都诚实地指向了它们定义时所在的环境。这解释了为什么作用域链不会被动态的调用栈所迷惑。


结语:从“知其然”到“知其所以然”

通过这七张图谱的深度解析,我们重新梳理了 JavaScript 的核心机制:

  1. 执行上下文是舞台,区分了 varlet/const 的存放位置。
  2. 作用域链是导航图,它在代码定义时生成,决定了变量查找的路径,与调用位置无关。
  3. 闭包是时光机,它让函数能够跨越生命周期,继续访问定义时的环境变量。

理解这些,你就不再是在盲目地试错代码,而是在脑海中构建出了一幅清晰的内存地图。当下一次遇到作用域问题或闭包陷阱时,请在脑中画出那张“调用栈”与“红色虚线箭头”的图,答案自会浮现。

Pinia vs Vuex 深度解析与完整实战指南

Pinia vs Vuex 深度解析与完整实战指南

📋 目录

  1. Pinia 与 Vuex 对比
  2. 为什么推荐使用 Pinia
  3. 架构设计哲学对比
  4. Pinia 基础使用
  5. 核心概念详解
  6. TypeScript 深度集成
  7. 高级用法与设计模式
  8. 性能优化实战
  9. 插件系统详解
  10. SSR 深度实践
  11. 测试策略与实战
  12. 大型项目架构
  13. 源码级原理解析
  14. 从 Vuex 迁移到 Pinia
  15. 最佳实践总结

Pinia 与 Vuex 对比

特性对比表

特性 Pinia Vuex 4 Vuex 3
API 设计 Composition API 风格 Options API 风格 Options API 风格
TypeScript 支持 ⭐⭐⭐ 原生支持,类型推导完美 ⭐⭐ 需要额外配置 ⭐ 支持有限
代码量 更少,更简洁 较多 较多
模块化 自动模块化,无需命名空间 需要手动配置模块 需要手动配置模块
状态修改 直接修改(或 actions) 必须通过 mutations 必须通过 mutations
开发工具 Vue DevTools 支持良好 Vue DevTools 支持 Vue DevTools 支持
SSR 支持 完美支持 支持 支持有限
包大小 ~1KB ~2KB ~2KB
学习曲线 平缓,符合直觉 较陡峭 较陡峭
Vue 版本 Vue 2/3 Vue 3 Vue 2
官方推荐 ✅ 是 维护中 已停止维护

核心差异详解

1. Mutations 的废除

Vuex(必须 Mutations):

// store.js
const store = createStore({
  state: { count: 0 },
  mutations: {
    INCREMENT(state) {
      state.count++
    }
  },
  actions: {
    incrementAsync({ commit }) {
      setTimeout(() => {
        commit('INCREMENT')
      }, 1000)
    }
  }
})

Pinia(直接使用 actions):

// store.js
export const useStore = defineStore('main', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++
    },
    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    }
  }
})
2. 模块化方式

Vuex(手动模块化):

// store/modules/user.js
const userModule = {
  namespaced: true,
  state: () => ({ name: '' }),
  mutations: { SET_NAME(state, name) { state.name = name } }
}

// store/index.js
const store = createStore({
  modules: {
    user: userModule
  }
})

Pinia(自动模块化):

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({ name: '' }),
  actions: { setName(name) { this.name = name } }
})

// stores/cart.js
export const useCartStore = defineStore('cart', {
  state: () => ({ items: [] })
})
// 自动成为独立模块,无需额外配置
3. 代码量对比

对比 Vuex 和 Pinia 实现相同功能所需的代码量:

功能 Vuex 代码行数 Pinia 代码行数
简单计数器 ~30 行 ~15 行
用户管理模块 ~80 行 ~40 行
购物车功能 ~150 行 ~80 行

为什么推荐使用 Pinia

1. 官方推荐

  • Vue 官方团队现在推荐使用 Pinia 作为状态管理方案
  • Vuex 现在处于维护模式,不会再添加新功能

2. TypeScript 支持

Pinia 提供了完美的 TypeScript 支持,无需额外配置:

import { defineStore } from 'pinia'

interface User {
  id: number
  name: string
  email: string
}

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as User | null,
    isLoggedIn: false
  }),
  getters: {
    userName: (state): string => state.user?.name || 'Guest'
  },
  actions: {
    async login(email: string, password: string): Promise<void> {
      // 类型安全
      const response = await api.login(email, password)
      this.user = response.data
      this.isLoggedIn = true
    }
  }
})

3. 更少的样板代码

废除 Mutations 的好处:

  1. 代码量减少 40-50%
  2. 逻辑更加集中,便于理解和维护
  3. 减少命名负担(不再需要 mutation types)
  4. TypeScript 支持更简单

4. 更好的开发体验

  • 自动补全:IDE 可以提供更好的代码提示
  • 时间旅行:更好的 Vue DevTools 集成
  • 热更新:模块热替换 (HMR) 支持

5. Composition API 原生支持

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 使用 Composition API 风格
export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  
  // getters
  const doubleCount = computed(() => count.value * 2)
  
  // actions
  function increment() {
    count.value++
  }
  
  return { count, doubleCount, increment }
})

架构设计哲学对比

Vuex 的设计哲学

Vuex 3/4 的设计深受 Flux 架构和 Redux 影响:

┌─────────────────────────────────────────────────────┐
│                    Vue Component                     │
└──────────────┬──────────────────────────────────────┘
               │ dispatch
               ▼
┌─────────────────────────────────────────────────────┐
│                      Actions                         │
│  (异步操作、业务逻辑)                                 │
└──────────────┬──────────────────────────────────────┘
               │ commit
               ▼
┌─────────────────────────────────────────────────────┐
│                     Mutations                        │
│  (同步修改状态、调试追踪)                             │
└──────────────┬──────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────┐
│                       State                          │
│  (单一数据源、只读)                                   │
└─────────────────────────────────────────────────────┘

核心原则:

  • 单一状态树:所有状态集中管理
  • 只读状态:必须通过 mutations 修改
  • 同步 mutations:便于调试和时间旅行
  • 显式追踪:每个状态变更都可追踪

Pinia 的设计哲学

Pinia 的设计更加贴近 Vue 3 的 Composition API 哲学:

┌─────────────────────────────────────────────────────┐
│                    Vue Component                     │
└──────────────┬──────────────────────────────────────┘
               │
               │ 直接访问 / 调用
               ▼
┌─────────────────────────────────────────────────────┐
│                       Store                          │
│  ┌──────────────────────────────────────────────┐  │
│  │  State (ref/reactive)                          │  │
│  │  Getters (computed)                           │  │
│  │  Actions (methods)                            │  │
│  └──────────────────────────────────────────────┘  │
│                                                      │
│  自动模块化 · 类型安全 · 简洁直观                      │
└─────────────────────────────────────────────────────┘

核心原则:

  • 最小化 API:移除冗余概念,保留核心功能
  • 类型优先:从设计之初就考虑 TypeScript
  • 符合直觉:Vue 开发者无需学习新范式
  • 自动模块化:每个 Store 天然独立

响应式系统底层实现

Vuex 的响应式实现
// Vuex 4 源码简化版
class Store {
  constructor(options = {}) {
    // 使用 Vue 的响应式系统
    const data = reactive(options.state ? options.state() : {})
    
    // 将 state 挂载到实例
    this._state = data
    
    // 使用 Object.defineProperty 暴露 state
    Object.defineProperty(this, 'state', {
      get: () => this._state
    })
  }
  
  commit(type, payload) {
    const mutation = this._mutations[type]
    mutation.forEach(handler => {
      handler(this.state, payload) // 直接修改响应式对象
    })
  }
}

特点:

  • 依赖 Vue 的 reactive()observable()
  • State 被包装成响应式对象
  • 通过 commit 触发 mutation 函数修改 state
Pinia 的响应式实现
// Pinia 源码简化版
function defineStore(id, setup) {
  return function useStore() {
    const pinia = getActivePinia()
    
    // 检查是否已存在该 store
    if (!pinia._s.has(id)) {
      // 创建新的 store
      const store = createSetupStore(id, setup, pinia)
      pinia._s.set(id, store)
    }
    
    return pinia._s.get(id)
  }
}

function createSetupStore(id, setup, pinia) {
  // 创建响应式 state 对象
  const initialState = {}
  const state = pinia._e.run(() => ref(reactive(initialState)))
  
  // 执行 setup 函数(Composition API 风格)
  // 或解析 options 对象(Options API 风格)
  const setupStore = pinia._e.run(() => setup())
  
  // 将返回的属性转换为响应式
  const store = reactive({})
  
  for (const key in setupStore) {
    const prop = setupStore[key]
    
    if (isRef(prop)) {
      // ref -> state
      store[key] = prop
    } else if (isFunction(prop)) {
      // function -> action
      store[key] = wrapAction(prop)
    } else if (isComputed(prop)) {
      // computed -> getter
      store[key] = readonly(prop)
    }
  }
  
  return store
}

Pinia 响应式的精妙之处:

// 示例:深入理解 Pinia 的响应式处理
export const useStore = defineStore('demo', () => {
  // 1. ref 自动成为 state
  const count = ref(0)
  
  // 2. computed 自动成为 getter
  const double = computed(() => count.value * 2)
  
  // 3. 普通函数自动成为 action
  function increment() {
    // 为什么 this 可以工作?
    // 因为 Pinia 内部做了绑定:this = store instance
    count.value++
  }
  
  // 4. 暴露出去
  return { count, double, increment }
})

响应式类型对比表:

返回类型 Pinia 处理方式 Vuex 处理方式
ref() State(响应式) N/A
computed() Getter(缓存) Getter(缓存)
function() Action(方法绑定) Action/Mutation
reactive() State(嵌套响应式) State
响应式性能对比
// 测试:大量数据的响应式性能

// Vuex - Options API
const store = createStore({
  state: () => ({
    items: Array.from({ length: 10000 }, (_, i) => ({ id: i, value: i }))
  }),
  getters: {
    // 每次访问都会重新计算
    total: state => state.items.reduce((sum, item) => sum + item.value, 0),
    // 缓存版本
    cachedTotal: state => {
      const cache = new Map()
      return () => {
        if (!cache.has('total')) {
          cache.set('total', state.items.reduce((sum, item) => sum + item.value, 0))
        }
        return cache.get('total')
      }
    }
  }
})

// Pinia - Composition API
export const useStore = defineStore('perf', () => {
  const items = ref(Array.from({ length: 10000 }, (_, i) => ({ id: i, value: i })))
  
  // 自动缓存,只会在 items 变化时重新计算
  const total = computed(() => 
    items.value.reduce((sum, item) => sum + item.value, 0)
  )
  
  // 高性能 getter,使用 reduceRight 等优化
  const optimizedTotal = computed(() => {
    let sum = 0
    const len = items.value.length
    for (let i = 0; i < len; i++) {
      sum += items.value[i].value
    }
    return sum
  })
  
  return { items, total, optimizedTotal }
})

性能测试结果(10,000 条数据):

操作 Vuex 4 Pinia 提升
首次读取 getter 2.1ms 0.8ms 2.6x
重复读取 getter 2.1ms 0.001ms 2100x
修改 state 12ms 8ms 1.5x
内存占用 4.2MB 3.1MB 1.35x

Pinia 基础使用

安装

# npm
npm install pinia

# yarn
yarn add pinia

# pnpm
pnpm add pinia

在 Vue 应用中注册

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.mount('#app')

第一个 Store

// stores/counter.js
import { defineStore } from 'pinia'

// 使用 Options API 风格
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Counter'
  }),
  
  getters: {
    doubleCount: (state) => state.count * 2,
    doublePlusOne() {
      return this.doubleCount + 1
    }
  },
  
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.increment()
    }
  }
})

在组件中使用

<template>
  <div>
    <h2>{{ counter.name }}</h2>
    <p>Count: {{ counter.count }}</p>
    <p>Double: {{ counter.doubleCount }}</p>
    <p>Double + 1: {{ counter.doublePlusOne }}</p>
    
    <button @click="counter.increment()">+</button>
    <button @click="counter.decrement()">-</button>
    <button @click="counter.incrementAsync()">Async +</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

核心概念详解

1. State(状态)

定义 State
export const useUserStore = defineStore('user', {
  state: () => ({
    // 用户信息
    user: null,
    isAuthenticated: false,
    
    // 配置
    preferences: {
      theme: 'light',
      language: 'zh-CN'
    },
    
    // 列表数据
    notifications: [],
    
    // 加载状态
    loading: false,
    error: null
  })
})
访问和修改 State
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

// 使用 storeToRefs 解构(保持响应式)
const { user, isAuthenticated } = storeToRefs(userStore)

// 方法可以直接解构
const { setUser, logout } = userStore

// 直接修改 state
userStore.isAuthenticated = true

// 使用 $patch 批量修改
userStore.$patch({
  isAuthenticated: true,
  user: { id: 1, name: 'John' }
})

// 使用 $patch 函数式修改(推荐用于复杂逻辑)
userStore.$patch((state) => {
  state.preferences.theme = 'dark'
  state.notifications.push({ id: 1, message: 'Welcome!' })
})
</script>
重置 State
// 重置为初始值
userStore.$reset()

2. Getters(计算属性)

基础用法
export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
    discount: 0.1
  }),
  
  getters: {
    // 基础 getter
    itemCount: (state) => state.items.length,
    
    // 带参数的 getter(返回函数)
    getItemById: (state) => (id) => {
      return state.items.find(item => item.id === id)
    },
    
    // 使用其他 getter
    subtotal: (state) => {
      return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
    },
    
    total(state) {
      return this.subtotal * (1 - state.discount)
    },
    
    // 访问其他 store 的 getter
    formattedTotal() {
      const currencyStore = useCurrencyStore()
      return currencyStore.format(this.total)
    }
  }
})
在组件中使用 Getters
<script setup>
const cart = useCartStore()

// 自动缓存计算结果
console.log(cart.itemCount)
console.log(cart.getItemById(1))
</script>

3. Actions(方法)

同步 Actions
export const useTodoStore = defineStore('todo', {
  state: () => ({
    todos: [],
    filter: 'all' // all, active, completed
  }),
  
  actions: {
    addTodo(text) {
      this.todos.push({
        id: Date.now(),
        text,
        completed: false
      })
    },
    
    toggleTodo(id) {
      const todo = this.todos.find(t => t.id === id)
      if (todo) {
        todo.completed = !todo.completed
      }
    },
    
    removeTodo(id) {
      const index = this.todos.findIndex(t => t.id === id)
      if (index > -1) {
        this.todos.splice(index, 1)
      }
    },
    
    setFilter(filter) {
      this.filter = filter
    }
  }
})
异步 Actions
export const useProductStore = defineStore('product', {
  state: () => ({
    products: [],
    loading: false,
    error: null
  }),
  
  actions: {
    async fetchProducts() {
      this.loading = true
      this.error = null
      
      try {
        const response = await fetch('/api/products')
        if (!response.ok) {
          throw new Error('Failed to fetch products')
        }
        this.products = await response.json()
      } catch (error) {
        this.error = error.message
        // 可以在这里处理错误,比如显示通知
      } finally {
        this.loading = false
      }
    },
    
    async createProduct(productData) {
      try {
        const response = await fetch('/api/products', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(productData)
        })
        
        const newProduct = await response.json()
        this.products.push(newProduct)
        return newProduct
      } catch (error) {
        throw error
      }
    },
    
    async updateProduct(id, updates) {
      const response = await fetch(`/api/products/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates)
      })
      
      const updated = await response.json()
      const index = this.products.findIndex(p => p.id === id)
      if (index !== -1) {
        this.products[index] = updated
      }
      return updated
    }
  }
})
Actions 中访问其他 Store
export const useOrderStore = defineStore('order', {
  actions: {
    async createOrder(orderData) {
      const cartStore = useCartStore()
      const userStore = useUserStore()
      
      if (!userStore.isAuthenticated) {
        throw new Error('User must be logged in')
      }
      
      const order = await api.createOrder({
        ...orderData,
        items: cartStore.items,
        userId: userStore.user.id
      })
      
      // 清空购物车
      cartStore.clear()
      
      return order
    }
  }
})

4. Composition API 风格

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // State
  const count = ref(0)
  const name = ref('Counter')
  
  // Getters
  const doubleCount = computed(() => count.value * 2)
  const doublePlusOne = computed(() => doubleCount.value + 1)
  
  // Actions
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  async function incrementAsync() {
    await new Promise(resolve => setTimeout(resolve, 1000))
    increment()
  }
  
  // 暴露给外部使用
  return {
    count,
    name,
    doubleCount,
    doublePlusOne,
    increment,
    decrement,
    incrementAsync
  }
})

TypeScript 深度集成

Pinia 的类型推导机制

// Pinia 如何实现完美的类型推导?

// 1. defineStore 的泛型定义
function defineStore<
  Id extends string,                    // Store ID
  S extends StateTree = {},             // State 类型
  G /* extends GettersTree<S> */ = {},  // Getters 类型
  A /* extends ActionsTree */ = {}      // Actions 类型
>(
  id: Id,
  options: DefineStoreOptions<Id, S, G, A>
): StoreDefinition<Id, S, G, A>

// 2. StoreDefinition 返回的类型
type StoreDefinition<
  Id extends string,
  S extends StateTree,
  G,
  A
> = (pinia?: Pinia | null | undefined) => Store<Id, S, G, A>

// 3. Store 实例的完整类型
type Store<
  Id extends string = string,
  S extends StateTree = {},
  G = {},
  A = {}
> = UnwrapRef<S> &                              // State(解包 ref)
    StoreGetters<G> &                           // Getters
    StoreActions<A> &                           // Actions
    StoreProperties<Id>                         // $patch, $reset 等

完整的 Store 类型定义

// stores/user.ts
import { defineStore } from 'pinia'
import type { Ref, ComputedRef } from 'vue'

// 定义 State 类型
interface UserState {
  user: User | null
  token: string | null
  loading: boolean
  error: string | null
}

// 定义 User 类型
interface User {
  id: number
  email: string
  name: string
  role: 'admin' | 'user' | 'guest'
  avatar?: string
  createdAt: Date
}

// 定义 Getters 类型
interface UserGetters {
  isAuthenticated: ComputedRef<boolean>
  isAdmin: ComputedRef<boolean>
  displayName: ComputedRef<string>
  userPermissions: ComputedRef<string[]>
}

// 定义 Actions 类型
interface UserActions {
  login(credentials: LoginCredentials): Promise<void>
  logout(): void
  fetchUser(): Promise<void>
  updateProfile(data: Partial<User>): Promise<void>
  refreshToken(): Promise<string>
}

// 定义参数类型
interface LoginCredentials {
  email: string
  password: string
  remember?: boolean
}

// 完整的类型定义
export const useUserStore = defineStore<'user', UserState, UserGetters, UserActions>(
  'user',
  {
    state: (): UserState => ({
      user: null,
      token: localStorage.getItem('token'),
      loading: false,
      error: null
    }),
    
    getters: {
      isAuthenticated: (state): boolean => !!state.token,
      
      isAdmin: (state): boolean => state.user?.role === 'admin',
      
      displayName(state): string {
        return state.user?.name || state.user?.email || 'Guest'
      },
      
      userPermissions(state): string[] {
        const perms: Record<User['role'], string[]> = {
          admin: ['read', 'write', 'delete', 'manage'],
          user: ['read', 'write'],
          guest: ['read']
        }
        return state.user ? perms[state.user.role] : []
      }
    },
    
    actions: {
      async login(credentials: LoginCredentials): Promise<void> {
        this.loading = true
        this.error = null
        
        try {
          const response = await api.login(credentials)
          this.user = response.user
          this.token = response.token
          
          if (credentials.remember) {
            localStorage.setItem('token', response.token)
          }
        } catch (err: any) {
          this.error = err.message
          throw err
        } finally {
          this.loading = false
        }
      },
      
      logout(): void {
        this.user = null
        this.token = null
        this.error = null
        localStorage.removeItem('token')
      },
      
      async fetchUser(): Promise<void> {
        if (!this.token) return
        
        this.loading = true
        try {
          const response = await api.getCurrentUser()
          this.user = response.data
        } catch (err: any) {
          this.error = err.message
          this.logout()
        } finally {
          this.loading = false
        }
      },
      
      async updateProfile(data: Partial<User>): Promise<void> {
        if (!this.user) throw new Error('Not authenticated')
        
        const updated = await api.updateUser(this.user.id, data)
        Object.assign(this.user, updated)
      },
      
      async refreshToken(): Promise<string> {
        if (!this.token) throw new Error('No token to refresh')
        
        const response = await api.refreshToken(this.token)
        this.token = response.token
        return response.token
      }
    }
  }
)

泛型 Store 工厂

// 创建可复用的 CRUD Store 工厂

interface Entity {
  id: number | string
  createdAt?: Date
  updatedAt?: Date
}

interface CRUDState<T extends Entity> {
  items: T[]
  selectedId: string | number | null
  loading: boolean
  error: string | null
  filters: Record<string, any>
  pagination: {
    page: number
    perPage: number
    total: number
  }
}

interface CRUDGetters<T extends Entity> {
  allItems: T[]
  selectedItem: T | null
  itemCount: number
  filteredItems: T[]
  currentPageItems: T[]
  totalPages: number
}

interface CRUDActions<T extends Entity> {
  fetchItems(): Promise<void>
  fetchItem(id: string | number): Promise<void>
  createItem(data: Omit<T, 'id'>): Promise<T>
  updateItem(id: string | number, data: Partial<T>): Promise<T>
  deleteItem(id: string | number): Promise<void>
  setSelectedId(id: string | number | null): void
  setPage(page: number): void
  setFilters(filters: Record<string, any>): void
}

// 工厂函数
export function createCRUDStore<
  T extends Entity,
  Id extends string
>(
  id: Id,
  apiClient: {
    fetchAll: () => Promise<T[]>
    fetchOne: (id: string | number) => Promise<T>
    create: (data: Omit<T, 'id'>) => Promise<T>
    update: (id: string | number, data: Partial<T>) => Promise<T>
    delete: (id: string | number) => Promise<void>
  }
) {
  return defineStore<Id, CRUDState<T>, CRUDGetters<T>, CRUDActions<T>>(id, {
    state: () => ({
      items: [],
      selectedId: null,
      loading: false,
      error: null,
      filters: {},
      pagination: {
        page: 1,
        perPage: 10,
        total: 0
      }
    }),
    
    getters: {
      allItems: (state) => state.items,
      
      selectedItem(state): T | null {
        return state.items.find(item => item.id === state.selectedId) || null
      },
      
      itemCount: (state) => state.items.length,
      
      filteredItems(state): T[] {
        return state.items.filter(item => {
          return Object.entries(state.filters).every(([key, value]) => {
            if (!value) return true
            return (item as any)[key]?.toString().toLowerCase().includes(value.toLowerCase())
          })
        })
      },
      
      currentPageItems(): T[] {
        const start = (this.pagination.page - 1) * this.pagination.perPage
        return this.filteredItems.slice(start, start + this.pagination.perPage)
      },
      
      totalPages(): number {
        return Math.ceil(this.filteredItems.length / this.pagination.perPage)
      }
    },
    
    actions: {
      async fetchItems(): Promise<void> {
        this.loading = true
        this.error = null
        try {
          this.items = await apiClient.fetchAll()
        } catch (err: any) {
          this.error = err.message
        } finally {
          this.loading = false
        }
      },
      
      async fetchItem(id: string | number): Promise<void> {
        this.loading = true
        try {
          const item = await apiClient.fetchOne(id)
          const index = this.items.findIndex(i => i.id === id)
          if (index >= 0) {
            this.items[index] = item
          } else {
            this.items.push(item)
          }
        } catch (err: any) {
          this.error = err.message
        } finally {
          this.loading = false
        }
      },
      
      async createItem(data: Omit<T, 'id'>): Promise<T> {
        this.loading = true
        try {
          const item = await apiClient.create(data)
          this.items.push(item)
          return item
        } finally {
          this.loading = false
        }
      },
      
      async updateItem(id: string | number, data: Partial<T>): Promise<T> {
        this.loading = true
        try {
          const item = await apiClient.update(id, data)
          const index = this.items.findIndex(i => i.id === id)
          if (index >= 0) {
            this.items[index] = item
          }
          return item
        } finally {
          this.loading = false
        }
      },
      
      async deleteItem(id: string | number): Promise<void> {
        await apiClient.delete(id)
        const index = this.items.findIndex(i => i.id === id)
        if (index >= 0) {
          this.items.splice(index, 1)
        }
      },
      
      setSelectedId(id: string | number | null): void {
        this.selectedId = id
      },
      
      setPage(page: number): void {
        this.pagination.page = page
      },
      
      setFilters(filters: Record<string, any>): void {
        this.filters = { ...this.filters, ...filters }
        this.pagination.page = 1 // 重置到第一页
      }
    }
  })
}

// 使用工厂创建具体的 store
interface Product extends Entity {
  name: string
  price: number
  category: string
  stock: number
}

const productApi = {
  fetchAll: () => fetch('/api/products').then(r => r.json()),
  fetchOne: (id) => fetch(`/api/products/${id}`).then(r => r.json()),
  create: (data) => fetch('/api/products', { method: 'POST', body: JSON.stringify(data) }).then(r => r.json()),
  update: (id, data) => fetch(`/api/products/${id}`, { method: 'PUT', body: JSON.stringify(data) }).then(r => r.json()),
  delete: (id) => fetch(`/api/products/${id}`, { method: 'DELETE' }).then(r => r.json())
}

export const useProductStore = createCRUDStore<Product, 'products'>('products', productApi)

高级用法与设计模式

1. Store 间的相互调用

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({ isAdmin: false })
})

// stores/post.js
import { useUserStore } from './user'

export const usePostStore = defineStore('post', {
  state: () => ({
    posts: []
  }),
  
  getters: {
    // 在 getter 中使用其他 store
    filteredPosts() {
      const userStore = useUserStore()
      if (userStore.isAdmin) {
        return this.posts
      }
      return this.posts.filter(post => post.published)
    }
  },
  
  actions: {
    // 在 action 中使用其他 store
    async createPost(postData) {
      const userStore = useUserStore()
      
      if (!userStore.isAdmin) {
        throw new Error('Only admin can create posts')
      }
      
      const post = await api.createPost(postData)
      this.posts.push(post)
      return post
    }
  }
})

2. 领域驱动设计 (DDD) Store

// stores/domain/user.store.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 领域模型
class User {
  constructor(
    public id: string,
    public email: string,
    public profile: UserProfile,
    public preferences: UserPreferences,
    private _permissions: Permission[]
  ) {}
  
  hasPermission(permission: string): boolean {
    return this._permissions.some(p => p.name === permission)
  }
  
  updateProfile(updates: Partial<UserProfile>): void {
    Object.assign(this.profile, updates)
  }
}

interface UserProfile {
  firstName: string
  lastName: string
  avatar?: string
  bio?: string
}

interface UserPreferences {
  theme: 'light' | 'dark'
  language: string
  notifications: boolean
}

interface Permission {
  name: string
  resource: string
  actions: string[]
}

// 仓库接口
interface IUserRepository {
  findById(id: string): Promise<User>
  findByEmail(email: string): Promise<User | null>
  save(user: User): Promise<User>
  delete(id: string): Promise<void>
}

// API 实现
class UserApiRepository implements IUserRepository {
  async findById(id: string): Promise<User> {
    const response = await fetch(`/api/users/${id}`)
    const data = await response.json()
    return this.toDomain(data)
  }
  
  async findByEmail(email: string): Promise<User | null> {
    const response = await fetch(`/api/users?email=${email}`)
    const data = await response.json()
    return data.length > 0 ? this.toDomain(data[0]) : null
  }
  
  async save(user: User): Promise<User> {
    const response = await fetch(`/api/users/${user.id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(user)
    })
    const data = await response.json()
    return this.toDomain(data)
  }
  
  async delete(id: string): Promise<void> {
    await fetch(`/api/users/${id}`, { method: 'DELETE' })
  }
  
  private toDomain(data: any): User {
    return new User(
      data.id,
      data.email,
      data.profile,
      data.preferences,
      data.permissions
    )
  }
}

// Store 作为应用服务层
export const useUserDomainStore = defineStore('userDomain', () => {
  // 依赖注入
  const repository: IUserRepository = new UserApiRepository()
  
  // State
  const currentUser = ref<User | null>(null)
  const users = ref<Map<string, User>>(new Map())
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  // Getters
  const isAuthenticated = computed(() => !!currentUser.value)
  
  const fullName = computed(() => {
    if (!currentUser.value) return 'Guest'
    const { firstName, lastName } = currentUser.value.profile
    return `${firstName} ${lastName}`
  })
  
  const hasPermission = (permission: string) => {
    return computed(() => {
      return currentUser.value?.hasPermission(permission) || false
    })
  }
  
  // Actions
  async function loadUser(id: string): Promise<void> {
    loading.value = true
    error.value = null
    
    try {
      const user = await repository.findById(id)
      users.value.set(id, user)
      currentUser.value = user
    } catch (err: any) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function updateUserProfile(updates: Partial<UserProfile>): Promise<void> {
    if (!currentUser.value) throw new Error('No user logged in')
    
    // 领域逻辑:在模型层处理
    currentUser.value.updateProfile(updates)
    
    // 持久化
    await repository.save(currentUser.value)
  }
  
  function clearCurrentUser(): void {
    currentUser.value = null
  }
  
  return {
    currentUser,
    users,
    loading,
    error,
    isAuthenticated,
    fullName,
    hasPermission,
    loadUser,
    updateUserProfile,
    clearCurrentUser
  }
})

3. 命令查询分离 (CQRS) 模式

// 将读取和写入操作分离

// stores/commands/userCommands.store.ts
export const useUserCommands = defineStore('userCommands', () => {
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  // 纯命令(写操作)
  async function registerUser(data: RegisterUserData): Promise<void> {
    loading.value = true
    try {
      await api.users.register(data)
      eventBus.emit('user:registered', data.email)
    } catch (err: any) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function updateEmail(userId: string, newEmail: string): Promise<void> {
    loading.value = true
    try {
      await api.users.updateEmail(userId, newEmail)
      eventBus.emit('user:emailUpdated', { userId, newEmail })
    } finally {
      loading.value = false
    }
  }
  
  async function deactivateAccount(userId: string): Promise<void> {
    await api.users.deactivate(userId)
    eventBus.emit('user:deactivated', userId)
  }
  
  return {
    loading,
    error,
    registerUser,
    updateEmail,
    deactivateAccount
  }
})

// stores/queries/userQueries.store.ts
export const useUserQueries = defineStore('userQueries', () => {
  // 查询缓存
  const userCache = ref(new Map<string, UserView>())
  const searchCache = ref(new Map<string, UserSearchResult>())
  
  // 纯查询(读操作)
  async function getUserById(id: string): Promise<UserView> {
    // 先查缓存
    if (userCache.value.has(id)) {
      return userCache.value.get(id)!
    }
    
    // 查询 API
    const user = await api.users.getById(id)
    const view = toUserView(user)
    
    // 写入缓存
    userCache.value.set(id, view)
    
    return view
  }
  
  async function searchUsers(query: string): Promise<UserSearchResult> {
    const cacheKey = query.toLowerCase()
    
    if (searchCache.value.has(cacheKey)) {
      return searchCache.value.get(cacheKey)!
    }
    
    const results = await api.users.search(query)
    searchCache.value.set(cacheKey, results)
    
    return results
  }
  
  function invalidateUserCache(id: string): void {
    userCache.value.delete(id)
  }
  
  // 订阅事件来更新缓存
  eventBus.on('user:emailUpdated', ({ userId }) => {
    invalidateUserCache(userId)
  })
  
  return {
    getUserById,
    searchUsers,
    invalidateUserCache
  }
})

// 在组件中使用
function useUser() {
  const commands = useUserCommands()
  const queries = useUserQueries()
  
  return {
    // 查询
    getUser: queries.getUserById,
    searchUsers: queries.searchUsers,
    
    // 命令
    register: commands.registerUser,
    updateEmail: commands.updateEmail,
    deactivate: commands.deactivateAccount,
    
    // 状态
    isLoading: computed(() => commands.loading),
    error: computed(() => commands.error)
  }
}

性能优化实战

1. 虚拟化大数据列表

export const useVirtualListStore = defineStore('virtualList', () => {
  // 原始数据
  const allItems = ref<Item[]>([])
  
  // 虚拟化配置
  const config = reactive({
    itemHeight: 50,
    containerHeight: 600,
    overscan: 5, // 上下额外渲染的数量
    totalItems: computed(() => allItems.value.length)
  })
  
  // 滚动位置
  const scrollTop = ref(0)
  
  // 计算可见范围(高性能 getter)
  const visibleRange = computed(() => {
    const startIndex = Math.max(0, Math.floor(scrollTop.value / config.itemHeight) - config.overscan)
    const visibleCount = Math.ceil(config.containerHeight / config.itemHeight)
    const endIndex = Math.min(config.totalItems, startIndex + visibleCount + config.overscan * 2)
    
    return { startIndex, endIndex, visibleCount }
  })
  
  // 只返回可见项
  const visibleItems = computed(() => {
    const { startIndex, endIndex } = visibleRange.value
    return allItems.value.slice(startIndex, endIndex).map((item, index) => ({
      ...item,
      index: startIndex + index,
      offset: (startIndex + index) * config.itemHeight
    }))
  })
  
  // 总高度(用于滚动条)
  const totalHeight = computed(() => config.totalItems * config.itemHeight)
  
  // 更新滚动位置(使用 requestAnimationFrame 节流)
  let rafId: number | null = null
  function updateScrollTop(newScrollTop: number): void {
    if (rafId !== null) return
    
    rafId = requestAnimationFrame(() => {
      scrollTop.value = newScrollTop
      rafId = null
    })
  }
  
  // 批量加载数据
  async function loadItems(start: number, count: number): Promise<void> {
    const items = await api.fetchItems(start, count)
    allItems.value.splice(start, items.length, ...items)
  }
  
  // 预加载
  watch(visibleRange, (range) => {
    const bufferStart = Math.max(0, range.startIndex - 20)
    const bufferEnd = Math.min(config.totalItems, range.endIndex + 20)
    
    // 检查并加载缺失的数据
    for (let i = bufferStart; i < bufferEnd; i++) {
      if (!allItems.value[i]) {
        loadItems(i, 20)
        break
      }
    }
  })
  
  return {
    visibleItems,
    totalHeight,
    visibleRange,
    updateScrollTop,
    loadItems
  }
})

2. 智能缓存策略

export const useCachedStore = defineStore('cached', () => {
  // 多级缓存
  const memoryCache = new Map<string, any>()
  const persistentCache = useLocalStorage('app-cache', {})
  
  // 缓存配置
  const cacheConfig = {
    ttl: {
      memory: 5 * 60 * 1000,      // 内存缓存 5 分钟
      persistent: 24 * 60 * 60 * 1000  // 持久化缓存 24 小时
    },
    maxSize: {
      memory: 100,   // 最多 100 条
      persistent: 500
    }
  }
  
  // 缓存元数据
  interface CacheEntry<T> {
    data: T
    timestamp: number
    accessCount: number
    lastAccessed: number
  }
  
  const cacheMeta = reactive(new Map<string, CacheEntry<any>>())
  
  // 获取缓存
  function get<T>(key: string): T | null {
    // 先查内存
    if (memoryCache.has(key)) {
      updateAccessStats(key)
      return memoryCache.get(key)
    }
    
    // 再查持久化
    const persistent = persistentCache.value[key]
    if (persistent && !isExpired(persistent.timestamp, cacheConfig.ttl.persistent)) {
      // 提升到内存
      memoryCache.set(key, persistent.data)
      updateAccessStats(key)
      return persistent.data
    }
    
    return null
  }
  
  // 设置缓存
  function set<T>(key: string, data: T, options: { persistent?: boolean } = {}): void {
    const entry: CacheEntry<T> = {
      data,
      timestamp: Date.now(),
      accessCount: 0,
      lastAccessed: Date.now()
    }
    
    // 写入内存
    memoryCache.set(key, data)
    cacheMeta.set(key, entry)
    
    // 写入持久化
    if (options.persistent) {
      persistentCache.value[key] = entry
    }
    
    // 清理旧缓存
    cleanupIfNeeded()
  }
  
  // 更新访问统计
  function updateAccessStats(key: string): void {
    const meta = cacheMeta.get(key)
    if (meta) {
      meta.accessCount++
      meta.lastAccessed = Date.now()
    }
  }
  
  // 检查是否过期
  function isExpired(timestamp: number, ttl: number): boolean {
    return Date.now() - timestamp > ttl
  }
  
  // 清理策略:LRU (Least Recently Used)
  function cleanupIfNeeded(): void {
    if (memoryCache.size <= cacheConfig.maxSize.memory) return
    
    // 按最后访问时间排序
    const sorted = Array.from(cacheMeta.entries())
      .sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
    
    // 删除最旧的 20%
    const toDelete = Math.floor(cacheConfig.maxSize.memory * 0.2)
    for (let i = 0; i < toDelete; i++) {
      const [key] = sorted[i]
      memoryCache.delete(key)
      cacheMeta.delete(key)
    }
  }
  
  // 带缓存的数据获取
  async function fetchWithCache<T>(
    key: string,
    fetcher: () => Promise<T>,
    options: { persistent?: boolean; force?: boolean } = {}
  ): Promise<T> {
    // 检查缓存
    if (!options.force) {
      const cached = get<T>(key)
      if (cached !== null) {
        return cached
      }
    }
    
    // 获取新数据
    const data = await fetcher()
    
    // 存入缓存
    set(key, data, options)
    
    return data
  }
  
  // 预加载策略
  function preload(keys: string[], fetchers: Map<string, () => Promise<any>>): void {
    const idleCallback = (window as any).requestIdleCallback || ((cb: any) => setTimeout(cb, 1))
    
    idleCallback(() => {
      keys.forEach(key => {
        if (!memoryCache.has(key)) {
          const fetcher = fetchers.get(key)
          if (fetcher) {
            fetcher().then(data => set(key, data))
          }
        }
      })
    })
  }
  
  return {
    get,
    set,
    fetchWithCache,
    preload,
    clear: () => {
      memoryCache.clear()
      cacheMeta.clear()
    }
  }
})

插件系统详解

1. 日志插件(DevTools 增强)

// plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export function loggerPlugin(context: PiniaPluginContext) {
  const { store, options } = context
  
  // 只在开发环境启用
  if (process.env.NODE_ENV === 'production') return
  
  // 为每个 action 添加日志
  store.$onAction(({
    name,       // action 名称
    store,      // store 实例
    args,       // 参数
    after,      // action 成功后的回调
    onError     // action 失败后的回调
  }) => {
    const startTime = Date.now()
    
    console.group(`🟢 Action: ${store.$id}.${name}`)
    console.log('Arguments:', args)
    
    after((result) => {
      console.log('✅ Success:', result)
      console.log('⏱ Duration:', Date.now() - startTime, 'ms')
      console.groupEnd()
    })
    
    onError((error) => {
      console.error('❌ Error:', error)
      console.groupEnd()
    })
  })
  
  // 监听 state 变化
  store.$subscribe((mutation, state) => {
    console.group(`📝 State Change: ${store.$id}`)
    console.log('Type:', mutation.type)
    console.log('Store ID:', mutation.storeId)
    console.log('Payload:', mutation.payload)
    console.log('New State:', state)
    console.groupEnd()
  })
}

2. 持久化插件(完整实现)

// plugins/persist.ts
import type { PiniaPluginContext, StateTree } from 'pinia'

interface PersistStrategy {
  key?: string
  storage?: Storage
  paths?: string[]
  beforeRestore?: (context: PiniaPluginContext) => void
  afterRestore?: (context: PiniaPluginContext) => void
  serializer?: {
    serialize: (value: any) => string
    deserialize: (value: string) => any
  }
}

type PersistOption = boolean | PersistStrategy | PersistStrategy[]

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S extends StateTree, Store> {
    persist?: PersistOption
  }
}

export function createPersistPlugin(defaults: Partial<PersistStrategy> = {}) {
  return function persistPlugin(context: PiniaPluginContext) {
    const { options, store } = context
    
    if (!options.persist) return
    
    const strategies = Array.isArray(options.persist) 
      ? options.persist 
      : [options.persist === true ? {} : options.persist]
    
    strategies.forEach((strategy) => {
      const {
        key = store.$id,
        storage = localStorage,
        paths = [],
        beforeRestore = () => {},
        afterRestore = () => {},
        serializer = {
          serialize: JSON.stringify,
          deserialize: JSON.parse
        }
      } = { ...defaults, ...strategy }
      
      // 恢复状态
      beforeRestore(context)
      
      try {
        const stored = storage.getItem(key)
        if (stored) {
          const parsed = serializer.deserialize(stored)
          
          if (paths.length > 0) {
            // 部分恢复
            paths.forEach((path) => {
              if (path in parsed) {
                store.$patch((state) => {
                  setNestedValue(state, path, parsed[path])
                })
              }
            })
          } else {
            // 完全恢复
            store.$patch(parsed)
          }
        }
      } catch (error) {
        console.error(`Failed to restore state for ${key}:`, error)
      }
      
      afterRestore(context)
      
      // 监听变化并保存
      store.$subscribe(
        (mutation, state) => {
          try {
            let toStore: any = state
            
            if (paths.length > 0) {
              // 只保存指定路径
              toStore = paths.reduce((acc, path) => {
                setNestedValue(acc, path, getNestedValue(state, path))
                return acc
              }, {})
            }
            
            storage.setItem(key, serializer.serialize(toStore))
          } catch (error) {
            console.error(`Failed to persist state for ${key}:`, error)
          }
        },
        { detached: true } // 组件卸载后继续监听
      )
    })
  }
}

// 辅助函数
function setNestedValue(obj: any, path: string, value: any): void {
  const keys = path.split('.')
  let current = obj
  
  for (let i = 0; i < keys.length - 1; i++) {
    if (!(keys[i] in current)) {
      current[keys[i]] = {}
    }
    current = current[keys[i]]
  }
  
  current[keys[keys.length - 1]] = value
}

function getNestedValue(obj: any, path: string): any {
  return path.split('.').reduce((current, key) => current?.[key], obj)
}

// 使用
// main.ts
import { createPersistPlugin } from './plugins/persist'

const pinia = createPinia()
pinia.use(createPersistPlugin({
  storage: localStorage,
  beforeRestore: (ctx) => {
    console.log(`Restoring ${ctx.store.$id}...`)
  }
}))

// store.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    token: null,
    preferences: {
      theme: 'light',
      language: 'zh'
    }
  }),
  persist: {
    key: 'my-app-user',
    paths: ['token', 'preferences'], // 只持久化这些字段
    storage: sessionStorage, // 使用 sessionStorage
    beforeRestore: (ctx) => {
      console.log('Before restore')
    },
    afterRestore: (ctx) => {
      console.log('After restore')
    }
  }
})

3. 使用 pinia-plugin-persistedstate(推荐)

npm install pinia-plugin-persistedstate
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

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

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    token: null
  }),
  persist: true
})

SSR 深度实践

1. 服务端数据预取模式

// composables/useAsyncStore.ts
import { useRequestFetch } from 'nuxt/app'

interface AsyncStoreOptions<T> {
  key: string
  fetcher: () => Promise<T>
  defaultValue: T
  immediate?: boolean
  transform?: (data: T) => T
  onError?: (error: Error) => void
}

export function useAsyncStore<T>(options: AsyncStoreOptions<T>) {
  const { key, fetcher, defaultValue, immediate = true, transform, onError } = options
  
  // 使用 useState 实现 SSR 友好的状态管理
  const data = useState<T>(key, () => defaultValue)
  const pending = useState<boolean>(`${key}-pending`, () => false)
  const error = useState<Error | null>(`${key}-error`, () => null)
  
  // 标记是否已经在服务端获取过数据
  const serverFetched = useState<boolean>(`${key}-server-fetched`, () => false)
  
  async function execute(): Promise<void> {
    // SSR 模式下,服务端只获取一次
    if (process.server && serverFetched.value) return
    
    // CSR 模式下,如果已有数据则不重复获取
    if (process.client && data.value !== defaultValue && !error.value) return
    
    pending.value = true
    error.value = null
    
    try {
      let result = await fetcher()
      
      if (transform) {
        result = transform(result)
      }
      
      data.value = result
      
      if (process.server) {
        serverFetched.value = true
      }
    } catch (err) {
      error.value = err as Error
      onError?.(err as Error)
    } finally {
      pending.value = false
    }
  }
  
  // 立即执行
  if (immediate) {
    // 在 SSR 中使用 await 等待数据
    if (process.server) {
      // Nuxt 3 中会自动等待
      execute()
    } else {
      // 客户端异步执行
      execute()
    }
  }
  
  // 刷新数据
  async function refresh(): Promise<void> {
    serverFetched.value = false
    await execute()
  }
  
  return {
    data: readonly(data),
    pending: readonly(pending),
    error: readonly(error),
    execute,
    refresh
  }
}

2. SSR 安全的状态管理

// utils/ssr-helpers.ts

// 只在客户端执行的辅助函数
export function onClient<T>(fn: () => T): T | undefined {
  if (process.client) {
    return fn()
  }
}

// 只在服务端执行的辅助函数
export function onServer<T>(fn: () => T): T | undefined {
  if (process.server) {
    return fn()
  }
}

// SSR 安全的 localStorage
export function useSSRStorage() {
  function getItem(key: string): string | null {
    return onClient(() => localStorage.getItem(key)) || null
  }
  
  function setItem(key: string, value: string): void {
    onClient(() => localStorage.setItem(key, value))
  }
  
  function removeItem(key: string): void {
    onClient(() => localStorage.removeItem(key))
  }
  
  return { getItem, setItem, removeItem }
}

// 在 Store 中使用
export const useSafeStore = defineStore('safe', () => {
  const storage = useSSRStorage()
  
  const token = ref<string | null>(null)
  
  // 客户端初始化
  function init() {
    onClient(() => {
      // 从 localStorage 恢复
      token.value = storage.getItem('token')
    })
  }
  
  function setToken(newToken: string) {
    token.value = newToken
    storage.setItem('token', newToken)
  }
  
  return {
    token,
    init,
    setToken
  }
})

测试策略与实战

1. 单元测试完整方案

// stores/counter.store.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useCounterStore } from './counter.store'

describe('Counter Store', () => {
  // 每个测试前重置 Pinia
  beforeEach(() => {
    setActivePinia(createPinia())
  })
  
  describe('State', () => {
    it('should have correct initial state', () => {
      const store = useCounterStore()
      
      expect(store.count).toBe(0)
      expect(store.name).toBe('Counter')
    })
    
    it('should update state directly', () => {
      const store = useCounterStore()
      
      store.count = 10
      expect(store.count).toBe(10)
    })
    
    it('should reset to initial state', () => {
      const store = useCounterStore()
      
      store.count = 100
      store.$reset()
      
      expect(store.count).toBe(0)
    })
  })
  
  describe('Getters', () => {
    it('should calculate double count correctly', () => {
      const store = useCounterStore()
      
      store.count = 5
      expect(store.doubleCount).toBe(10)
    })
    
    it('should recalculate when dependency changes', () => {
      const store = useCounterStore()
      
      store.count = 5
      expect(store.doubleCount).toBe(10)
      
      store.count = 10
      expect(store.doubleCount).toBe(20)
    })
  })
  
  describe('Actions', () => {
    it('should increment count', () => {
      const store = useCounterStore()
      
      store.increment()
      expect(store.count).toBe(1)
    })
    
    it('should handle async action', async () => {
      const store = useCounterStore()
      
      await store.asyncIncrement()
      expect(store.count).toBe(1)
    })
    
    it('should handle action errors', async () => {
      const store = useCounterStore()
      
      // 模拟 API 失败
      vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
      
      await expect(store.fetchData()).rejects.toThrow('Network error')
      expect(store.error).toBe('Network error')
    })
  })
  
  describe('Subscriptions', () => {
    it('should notify subscribers on state change', () => {
      const store = useCounterStore()
      const callback = vi.fn()
      
      store.$subscribe(callback)
      
      store.count = 5
      
      expect(callback).toHaveBeenCalled()
    })
    
    it('should notify action subscribers', () => {
      const store = useCounterStore()
      const onAction = vi.fn()
      
      store.$onAction(onAction)
      
      store.increment()
      
      expect(onAction).toHaveBeenCalled()
    })
  })
})

2. 集成测试

// tests/integration/stores.integration.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'

describe('Store Integration', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })
  
  it('should sync user state across stores', async () => {
    const userStore = useUserStore()
    const cartStore = useCartStore()
    
    // 用户登录
    await userStore.login({ email: 'test@test.com', password: '123456' })
    
    // 购物车应该能访问用户信息
    expect(cartStore.userId).toBe(userStore.user?.id)
  })
  
  it('should create order with cart items and user info', async () => {
    const userStore = useUserStore()
    const cartStore = useCartStore()
    const orderStore = useOrderStore()
    
    // 设置用户
    await userStore.login({ email: 'test@test.com', password: '123456' })
    
    // 添加商品到购物车
    cartStore.addItem({ id: 1, name: 'Product', price: 100 })
    cartStore.addItem({ id: 2, name: 'Another', price: 50 })
    
    // 创建订单
    const order = await orderStore.createOrder()
    
    // 验证订单包含正确信息
    expect(order.userId).toBe(userStore.user?.id)
    expect(order.items).toHaveLength(2)
    expect(order.total).toBe(150)
    
    // 验证购物车已清空
    expect(cartStore.items).toHaveLength(0)
  })
})

大型项目架构

1. 项目结构组织

src/
├── modules/
│   ├── auth/
│   │   ├── stores/
│   │   │   ├── auth.store.ts
│   │   │   └── permissions.store.ts
│   │   ├── components/
│   │   ├── composables/
│   │   └── index.ts          # 模块导出
│   ├── products/
│   │   ├── stores/
│   │   │   ├── product.store.ts
│   │   │   └── category.store.ts
│   │   ├── components/
│   │   └── index.ts
│   └── orders/
│       ├── stores/
│       │   ├── order.store.ts
│       │   └── payment.store.ts
│       └── index.ts
├── shared/
│   └── stores/
│       ├── ui.store.ts       # 全局 UI 状态
│       └── cache.store.ts    # 全局缓存
└── stores/
    └── index.ts              # Store 入口

2. Store 依赖注入容器

// core/container.ts
import type { Pinia } from 'pinia'

interface ContainerConfig {
  pinia: Pinia
  apiBaseUrl: string
  storage: Storage
}

class StoreContainer {
  private stores = new Map<string, any>()
  private config: ContainerConfig
  
  constructor(config: ContainerConfig) {
    this.config = config
  }
  
  // 注册 Store
  register<T>(name: string, factory: (container: StoreContainer) => T): void {
    if (this.stores.has(name)) {
      throw new Error(`Store ${name} already registered`)
    }
    
    Object.defineProperty(this, name, {
      get: () => {
        if (!this.stores.has(name)) {
          this.stores.set(name, factory(this))
        }
        return this.stores.get(name)
      },
      configurable: true
    })
  }
  
  // 获取配置
  getConfig(): ContainerConfig {
    return this.config
  }
  
  // 初始化所有 Store
  async init(): Promise<void> {
    for (const [name, store] of this.stores) {
      if (store.init && typeof store.init === 'function') {
        await store.init()
      }
    }
  }
}

// 创建容器
export function createContainer(config: ContainerConfig): StoreContainer {
  return new StoreContainer(config)
}

源码级原理解析

1. defineStore 的执行流程

defineStore(id, setup)
    │
    ▼
返回 useStore 函数(闭包)
    │
    ▼
调用 useStore()
    │
    ├──▶ 获取当前 Pinia 实例(getActivePinia)
    │
    ├──▶ 检查是否已存在该 Store
    │       ├── 存在 → 直接返回缓存的 Store
    │       └── 不存在 → 创建新 Store
    │
    └──▶ createSetupStore(id, setup, pinia)
            │
            ├──▶ 创建响应式 Scope(用于自动清理)
            │
            ├──▶ 执行 setup 函数
            │       │
            │       ├──▶ 将 ref → state
            │       ├──▶ 将 computed → getter
            │       └──▶ 将 function → action
            │
            ├──▶ 处理 Options API 风格(如果是对象)
            │
            ├──▶ 包装 Actions(添加订阅、错误处理)
            │
            ├──▶ 添加 Store 属性($patch, $reset, $subscribe 等)
            │
            └──▶ 返回响应式 Store 对象

2. 插件系统的工作机制

// Pinia 如何加载插件?

class Pinia {
  constructor() {
    this._p = [] // 插件数组
    this._s = new Map() // Store 实例 Map
  }
  
  // 注册插件
  use(plugin) {
    this._p.push(plugin)
    
    // 如果已有 Store,立即应用插件
    this._s.forEach((store, id) => {
      plugin({
        pinia: this,
        app: this._a,
        store,
        options: store.$options
      })
    })
    
    return this
  }
  
  // 安装插件到具体 Store
  _installPlugin(store) {
    this._p.forEach(plugin => {
      const result = plugin({
        pinia: this,
        app: this._a,
        store,
        options: store.$options
      })
      
      // 插件可以返回要添加到 Store 的属性
      if (result) {
        Object.assign(store, result)
      }
    })
  }
}

从 Vuex 迁移到 Pinia

迁移清单

  1. 安装 Pinia
npm install pinia
  1. 创建 Pinia 实例
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'

const app = createApp(App)
app.use(createPinia())
  1. 迁移 Vuex Modules 为 Pinia Stores

Before (Vuex):

// store/modules/user.js
export default {
  namespaced: true,
  state: () => ({ user: null }),
  mutations: {
    SET_USER(state, user) { state.user = user }
  },
  actions: {
    async login({ commit }, credentials) {
      const user = await api.login(credentials)
      commit('SET_USER', user)
    }
  },
  getters: {
    isLoggedIn: state => !!state.user
  }
}

After (Pinia):

// stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async login(credentials) {
      const user = await api.login(credentials)
      this.user = user  // 直接修改,不需要 mutation
    }
  },
  getters: {
    isLoggedIn: (state) => !!state.user
  }
})
  1. 更新组件中的使用方式

Before:

<script>
import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState('user', ['user'])
  },
  methods: {
    ...mapActions('user', ['login'])
  }
}
</script>

After:

<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const { login } = userStore
</script>

常见问题

Q: 如何处理命名空间?

// Vuex: namespaced: true
// Pinia: 每个 store 天然是独立的,无需命名空间

Q: Mutations 去哪里了?

// Pinia 中直接修改 state,无需 mutations
// 或使用 actions 封装逻辑

Q: 如何处理插件(如持久化)?

// 使用 Pinia 插件系统
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
pinia.use(piniaPluginPersistedstate)

最佳实践总结

选择建议

场景 推荐方案
新项目 Pinia - 官方推荐,现代架构
Vue 3 + TS Pinia - 完美的类型支持
大型应用 Pinia + 模块化架构 - 易于维护
SSR 应用 Pinia - 更好的 SSR 支持
老项目维护 Vuex - 如果已有 Vuex,可以继续使用
快速原型 Pinia - 更快的开发速度

性能优化清单

  • 使用虚拟滚动处理大数据列表
  • 实现多级缓存策略(内存 + 持久化)
  • 使用 Web Worker 处理复杂计算
  • 合理使用 getter 缓存
  • 避免不必要的 store 订阅
  • 使用 storeToRefs 解构保持响应式
  • 延迟加载非关键 store

最佳实践总结

  1. 单一职责:每个 store 只负责一个领域
  2. 组合优于继承:使用 composables 组合功能
  3. 类型优先:充分利用 TypeScript
  4. 测试覆盖:单元测试 + 集成测试 + E2E 测试
  5. 插件增强:使用插件实现横切关注点(日志、持久化等)
  6. 性能意识:关注大数据场景的性能优化

Pinia 的核心优势

  1. 简洁性:移除 Mutations,减少 40-50% 的样板代码
  2. 类型安全:原生 TypeScript 支持,完美类型推导
  3. 灵活性:支持 Options API 和 Composition API 两种风格
  4. 可扩展性:强大的插件系统,易于定制
  5. DevTools:更好的开发体验,支持时间旅行
  6. 轻量级:~1KB,性能优于 Vuex

参考资源

一文读懂:微信小程序云数据库直连原理与使用指南

前言

微信小程序直接调用云数据库(云开发模式)是微信提供的一种Serverless架构方案,它允许前端(小程序端)在没有传统后端服务器的情况下直接操作数据库。 以下是关于该机制的原理架构图流程图使用详解

一、 原理架构图

微信小程序云开发采用了 Serverless 架构。传统的开发模式需要“小程序前端 -> 后端服务器(API) -> 数据库”,而云开发模式则是“小程序前端 -> 云数据库(通过微信私有协议)”。

1. 架构示意

graph TD
    subgraph "客户端"
        A[小程序前端代码]
    end
    subgraph "微信基础设施"
        B[微信 APP 宿主环境]
        C[云开发控制台/基础设施]
    end
    subgraph "云开发资源"
        D[(云数据库 - JSON)]
        E[云函数]
        F[云存储]
    end
    A -- 1. 调用 wx.cloud/init --> B
    A -- 2. 调用 db.collection --> B
    B -- 3. 微信私有协议/鉴权 --> C
    C -- 4. 安全规则校验 --> D
    D -- 5. 返回JSON数据 --> C
    C -- 6. 返回结果 --> A
    style A fill:#e1f5fe,stroke:#01579b
    style D fill:#fff9c4,stroke:#fbc02d
    style C fill:#f3e5f5,stroke:#8e24aa

2. 核心组件解析

  • 小程序前端: 运行在小程序环境中的代码,通过微信提供的 SDK (wx.cloud) 发起请求。
  • 微信私有协议: 数据传输不经过公网 HTTP,而是通过微信客户端底层通道,速度更快,且自带微信登录态,无需手动管理 Token。
  • 安全规则: 这是“直接调用”的安全基石。数据库根据配置的 JSON 规则(如 auth.openid)判断当前用户是否有权读/写数据,替代了传统后端的权限校验逻辑。
  • 云数据库: 一个 MongoDB 文档型数据库,数据以 JSON 格式存储。

二、 调用流程图

当小程序端执行一条 db.collection('xxx').get() 时,底层发生了以下流程:

sequenceDiagram
    participant User as 用户/小程序前端
    participant SDK as 微信客户端 SDK
    participant Cloud as 微信云服务网关
    participant DB as 云数据库实例
    User->>SDK: 1. 调用 API (如 db.collection('user').get())
    Note right of User: 传入环境ID (env)
    
    SDK->>SDK: 2. 本地检查云环境初始化状态
    
    SDK->>Cloud: 3. 建立私有连接通道
    Note right of SDK: 自动携带 AppID, OpenID, UnionID
    
    Cloud->>Cloud: 4. 身份鉴权 (获取用户身份)
    
    Cloud->>DB: 5. 发送数据库请求指令
    
    Note over DB, Cloud: 6. 执行【安全规则】校验
    alt 权限校验失败
        DB-->>Cloud: 返回 Permission Denied
        Cloud-->>SDK: 返回错误信息
        SDK-->>User: Catch Error
    else 权限校验通过
        DB->>DB: 7. 执行查询/写入操作
        DB-->>Cloud: 8. 返回数据结果
        Cloud-->>SDK: 9. 封装返回数据
        SDK-->>User: 10. Promise Resolve (返回数据)
    end

关键点说明:

  1. 自动鉴权: 最大的特点是 “免登录”。SDK 会自动获取用户的 OpenID 并传给云端,开发者不需要写登录接口。
  2. 安全规则拦截: 如果在控制台配置了“仅创建者可写”,当用户 A 尝试修改用户 B 的数据时,Cloud 层会在第 6 步直接拦截,报错 database permission denied

三、 使用详解

要实现小程序直接调用云数据库,需遵循以下步骤。

1. 环境初始化

在调用任何云能力之前,必须先初始化。

// app.js
App({
  onLaunch: function () {
    if (!wx.cloud) {
      console.error('请使用 2.2.3 或以上的基础库以使用云能力');
    } else {
      wx.cloud.init({
        // env 参数说明:
        //   env: 'your-env-id' // 云开发环境ID,可在云开发控制台获取
        traceUser: true, // 自动上报用户信息
      });
    }
  }
});

2. 获取数据库引用

const db = wx.cloud.database();
// 指定特定环境(如果有多个环境)
// const db = wx.cloud.database({ env: 'your-env-id' });

3. CRUD 操作示例

(1) 增 - Insert

// 添加数据
db.collection('todos').add({
  data: {
    description: '学习云开发',
    due: new Date('2023-12-31'),
    tags: ['cloud', 'database'],
    location: new db.Geo.Point(113, 23), // 地理位置
    done: false
  }
})
.then(res => {
  console.log('添加成功,记录ID:', res._id);
})
.catch(err => {
  console.error('添加失败', err);
});

(2) 查 - Query

// 获取数据
db.collection('todos').where({
  _openid: 'xxx' // 此处通常不需要手动填,如果开启了安全规则,系统会自动校验
})
.get()
.then(res => {
  // res.data 是一个数组
  console.log('查询结果:', res.data);
});
// 获取单条记录
db.collection('todos').doc('record-id-here').get()
.then(res => {
  console.log(res.data);
});

(3) 改 - Update

注意:update 只能修改符合 where 条件或通过 doc 指定的记录。

db.collection('todos').doc('record-id-here').update({
  data: {
    done: true // 将 done 字段改为 true
  }
})
.then(res => {
  console.log('更新成功,影响行数:', res.stats.updated);
});

(4) 删 - Remove

注意:在小程序端直接调用 remove 删除多条记录通常受限制,建议一次删除一条或使用云函数批量删除。

db.collection('todos').doc('record-id-here').remove()
.then(res => {
  console.log('删除成功');
});

4. 权限管理(安全规则)- 核心中的核心

小程序直接调用数据库之所以安全,是因为数据库安全规则。 在微信开发者工具 -> 云开发控制台 -> 数据库 -> 选择集合 -> 权限设置,有以下常见模式:

  • 仅创建者可写,所有人可读:
    • 适合:文章、帖子、评论。
    • 原理:系统自动检查记录中的 _openid 是否与当前用户的 _openid 一致。
  • 仅创建者可读写:
    • 适合:个人隐私数据(如购物车、个人设置)。
  • 所有人可读,仅创建者可写:
    • 适合:字典数据、配置数据。
  • 自定义安全规则:
    • 使用 JSON 语法定义复杂的逻辑。例如:"read": true, "write": "auth.openid == doc._openid"

5. 数据类型支持

云数据库支持丰富的数据类型,不同于传统的 MySQL,它直接支持:

  • GeoJSON: 地理位置点 db.Geo.Point,支持地理位置查询(如查找附近的人)。
  • Date: 时间对象 new Date()
  • Null: 空值。
  • 嵌套对象: JSON 对象多层嵌套。

四、 总结

1.微信小程序直接调用云数据库 的核心优势在于:

  1. 开发效率高: 省去了搭建服务器、编写 API 接口、维护数据库连接池的工作。
  2. 安全性强: 通过微信底层鉴权和安全规则,实现了前端直接操作数据库且不泄露数据。
  3. 成本低: 按量付费,对于中小型应用极其友好。

2.适用场景:

  • 快速原型开发(MVP)。
  • 逻辑相对简单的 CRUD 应用(如备忘录、简单的商城、预约系统)。
  • 企业内部员工或者B端项目多为表单提交,列表详情展示类(用户量较小) 不适用场景:
  • 复杂的事务处理(如涉及多表关联、复杂的金钱流转逻辑)。
  • C端日活高应用,高并发、高吞吐量的写操作(小程序端有连接数和频率限制)。
  • 需要高度保密的计算逻辑(逻辑放在前端容易被反编译,此时应使用云函数)。

elpis总结——基于koa的elpis-core

什么是 elpis-core ?

elpis-core 是对 koa 应用框架的一层封装,是精简版的 Node.js 框架;本质就是应用加载器,可以通过内部的 loader 加载代码,这块代码必须是按照约定的方式目录结构以及格式来写的。也可以理解为是精简版的 egg.js 。

elpis-core 都做了什么?

elpis-core 本质上是一个"应用加载器";

  1. 初始化 koa 实例
  2. 按照约定自动加载业务代码(controller、service、middleware、... 等)
  3. 统一管理配置和环境
  4. 启动 HTTP 服务

elpis-core 文件结构

elpis-core/
├── index.js          # 入口文件
├── env.js            # 环境判断
├── utils.js          # 工具函数
└── loader/           # 各类加载器
    ├── config.js     # 配置加载器
    ├── controller.js # 控制器加载器
    ├── service.js    # 服务层加载器
    ├── middleware.js # 中间件加载器
    ├── router.js     # 路由加载器
    ├── router-schema.js # 路由参数校验 Schema 加载器
    └── extend.js     # 扩展加载器

loader 作用

loader 主要是把项目文件夹内容挂载到 koa 实例,目的就是实现分层架构,解耦模块间的依赖,每个模块都统一访问入口在 app 实例上访问。

项目文件结构

config/              → 项目配置
app/ 
├── controller/      → 处理请求、调用 service、返回响应
├── service/         → 业务逻辑层
├── middleware/      → 中间件(鉴权、日志、错误处理等)
├── router/          → 路由定义
├── router-schema/   → 路由校验规则
└── extend/          → 扩展 app 能力(如 logger)
模块 作用
configLoader config/config.{env}.js 文件内容挂载到koa实例
controllerLoader 自动扫描 app/controller/**/*.js,并挂载koa实例
middlewareLoader 自动扫描 app/middleware/**/*.js,并挂载koa实例
serviceLoader 自动扫描 app/service/**/*.js,挂载koa实例
routerSchemaLoader 加载 app/router-schema/*.js,挂载到koa实例,用于 API 参数校验
routerLoader 自动扫描 app/router/**/*.js,注册到 koa-router,并挂载koa实例
extendLoader 加载app/extend/*.js,直接扩展 app 实例

image.png

koa 的洋葱圈模型

elpis-core 是对 koa 的封装,其中间件的执行顺序也是洋葱圈模型;请求从外层中间件进入,一层层向内传递,到达核心后再一层层向外返回。类似栈结构的先进后出,先进入的中间件最后也会在路过一次。

image.png

总结

通俗的来讲 elpis-core 就是项目的启动器,来启动按照我们约定的规则编写的项目,也就是极简版egg.js。

响应式探秘:ref vs reactive,我该选谁?

前言

在 Vue3 的 Composition API 中,有两个主要的响应式 API:refreactive。很多开发者,尤其是刚从 Vue2 迁移过来的同学,常常会困惑:到底该用哪一个响应式 API ?什么时候该用 ref?什么时候该用 reactive

这个问题看似简单,实则涉及 Vue3 响应式系统的核心设计理念。本文将从源码原理出发,深入剖析两者的本质区别。

响应式原理快速回顾

Proxy:Vue3 响应式的基石

在深入 refreactive 之前,我们必须先理解 Vue3 响应式的核心:Proxy 代理。

在 Vue2 中, 使用的是 Object.defineProperty 来拦截属性的读写,但它有一个致命缺陷:无法检测属性的添加和删除,当我们需要添加属性等操作时,必须用 Vue.set()vm.$set() 等方式处理。而在 Vue3 中改用 Proxy 进行对象代理,完美解决了这个问题:

const target = { name: 'Vue' }
const handler = {
  get(target, key, receiver) {
    console.log(`读取属性: ${key}`)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    console.log(`设置属性: ${key} = ${value}`)
    return Reflect.set(target, key, value, receiver)
  }
}

const proxy = new Proxy(target, handler)
proxy.name // 读取属性: name
proxy.name = 'Vue3' // 设置属性: name = Vue3

Proxy 的强大之处

  • 拦截所有操作:包括属性读取、赋值、删除、in 操作符等,支持 13 种数据操作的拦截
  • 动态属性响应:新增属性也能被追踪
  • 数组方法拦截:push、pop 等方法也能触发更新

关于 Proxy 的相关内容,可以查看我在《JavaScript核心机制探秘》专栏中相关的文章介绍。

reactive 的实现原理

reactive 是 Vue3 中最直接的响应式 API,它接收一个对象,返回这个对象的 Proxy 代理:

// 简化的 reactive 实现
function reactive(target) {
  // 创建 Proxy 代理
  return new Proxy(target, {
    get(target, key, receiver) {
      // 依赖收集
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)
      // 触发更新
      trigger(target, key)
      return result
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      // 删除属性也要触发更新
      trigger(target, key)
      return result
    }
  })
}

// 使用
const state = reactive({
  count: 0,
  user: { name: '张三' }
})

state.count++ // 触发更新
state.user.name = '李四' // 嵌套对象也会被递归代理

ref 的实现原理

ref 的设计要处理一个根本性问题:Proxy 只能代理对象,无法代理基础类型(string、number、boolean)。因此,Vue团队 给出了一个解决方案:使用 value 属性,将基础类型值包装成一个对象,再对这个对象进行 Proxy 代理。这也是为什么 ref 响应式数据,需要用 .value 的方式进行访问的原因:

// 简化的 ref 实现
function ref(value) {
  // 创建包装对象
  const wrapper = {
    value: value
  }
  
  // 将包装对象变为响应式
  return reactive(wrapper)
}

// 更接近真实源码的实现
class RefImpl {
  constructor(value) {
    this._value = value
    this.__v_isRef = true // 标记这是一个 ref
  }
  
  get value() {
    // 依赖收集
    track(this, 'value')
    return this._value
  }
  
  set value(newVal) {
    if (this._value !== newVal) {
      this._value = newVal
      // 触发更新
      trigger(this, 'value')
    }
  }
}

function ref(value) {
  return new RefImpl(value)
}

// 使用
const count = ref(0)
count.value++ // 必须通过 .value 访问

从上述代码中,我们也可以看出:ref 返回的本质上也是一个 reactive 对象!

关于 ref 和 reactive 的具体源码实现细节,可以参考我的《Vue3 源码解析》的相关文章。

ref vs reactive 的核心区别

访问方式:.value 的有无

这是两者最直观的区别:

import { ref, reactive } from 'vue'

// ref 需要 .value
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1

// reactive 不需要 .value
const state = reactive({ count: 0 })
console.log(state.count) // 0
state.count++
console.log(state.count) // 1

重新赋值:整体替换 vs 属性修改

这其实是在 Vue3 开发中,最容易踩的一个坑,我们先来看一个例子:

// ref 支持整体替换
let user = ref({ name: '张三', age: 18 })
// ✅ 可以直接替换整个对象
user.value = { name: '李四', age: 20 }

// reactive 不支持整体替换
let state = reactive({ name: '张三', age: 18 })
// ❌ 这样会丢失响应式
state = { name: '李四', age: 20 } 

// ✅ reactive 只能修改属性
state.name = '李四'
state.age = 20

// ❌ 即使使用 Object.assign 也可能出现问题
Object.assign(state, { name: '王五', age: 22 }) // ✅ 这样可以
state = Object.assign({}, state, { name: '王五' }) // ❌ 这样不行

类型推导与解构

reactive 在使用解构时也会出现问题:

const state = reactive({
  name: '张三',
  age: 18,
  profile: {
    city: '北京'
  }
})

// ❌ 解构后失去响应性
const { name, age } = state
name // '张三',但不再是响应式的

// ✅ 使用 toRefs 保持响应性
const { name, age } = toRefs(state)
name.value // 需要通过 .value 访问

// ✅ 单个属性用 toRef
const city = toRef(state.profile, 'city')
city.value = '上海' // 会触发更新

ref 在这方面的表现就很好:

// 组合式函数返回 ref 对象
function useFeature() {
  const count = ref(0)
  const name = ref('张三')
  
  return {
    count,
    name
  }
}

// 解构后依然是响应式的
const { count, name } = useFeature()
count.value++ // ✅ 正常工作

注:关于上述内容,在论坛中也存在争议:由于 reactive 本身设计特性,会导致响应式丢失问题。因此部分开发者(包括笔者),更推荐在实际开发中,直接使用 ref,弃用 reactive

深层响应性

两者都支持深层响应,但内部实现略有不同:

const refObj = ref({
  user: {
    name: '张三',
    address: {
      city: '北京'
    }
  }
})

// 深层属性也是响应式的
refObj.value.user.address.city = '上海' // 触发更新

const reactiveObj = reactive({
  user: {
    name: '张三',
    address: {
      city: '北京'
    }
  }
})

// 同样是深层响应式
reactiveObj.user.address.city = '上海' // 触发更新

什么时候用 ref?

基础类型值

这是 ref 的主要应用场景,因为 reactive 根本不能处理基础类型:

const count = ref(0)
const name = ref('张三')
const isLoading = ref(false)
const userInput = ref('')

需要整体替换的场景

当我们的数据状态需要整体重置或替换时,ref 是不二之选:

// 表单数据,经常需要重置
const formData = ref({
  username: '',
  email: '',
  password: ''
})

// 重置表单 - ref 轻松搞定
function resetForm() {
  formData.value = {
    username: '',
    email: '',
    password: ''
  }
}

// 更新整个表单 - 从 API 获取数据后整体替换
async function loadForm(id) {
  const data = await api.getForm(id)
  formData.value = data // ✅ 直接替换
}

当然,如果一定要用 reactive 呢?也是可以解决的,只是较为麻烦而已:

// 如果用 reactive,重置会很麻烦
const formDataReactive = reactive({
  username: '',
  email: '',
  password: ''
})

function resetFormReactive() {
  // 需要逐个属性重置,或者使用 Object.assign
  Object.assign(formDataReactive, {
    username: '',
    email: '',
    password: ''
  })
}

从组合式函数返回时

当编写可复用的组合式函数时,返回 ref 对象可以更利于解构:

export function useUser() {
  const user = ref(null)
  const loading = ref(false)
  const error = ref(null)
  
  async function fetchUser(id) {
    loading.value = true
    try {
      user.value = await api.getUser(id)
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  // 返回 ref 对象,使用者可以随意解构
  return {
    user,
    loading,
    error,
    fetchUser
  }
}

// 在组件中使用
const { user, loading, fetchUser } = useUser()
// 解构后依然保持响应式
watch(user, () => {}) // ✅ 正常

跨组件传递时的类型安全

当通过 props 进行父子组件通信,传递响应式数据时,ref 的类型更清晰:

<!-- 父组件 -->
<script setup>
const userData = ref({ name: '张三', age: 18 })
</script>

<template>
  <ChildComponent :data="userData" />
</template>

<!-- 子组件 -->
<script setup>
// 明确知道接收的是一个 ref
const props = defineProps<{
  data: { name: string; age: number } // 注意:这是 Ref 的内部类型
}>()

// 使用 toValue 统一处理
const data = toValue(props.data) // toValue 可以处理 ref 和普通值
</script>

获取子组件实例

当父组件想要访问子组件的方法或数据时,可以直接使用 ref 获得子组件的实例,访问子组件通过 defineExpose 暴露的方法或数据: 子组件 Child.vue

<template>
  <div>子组件</div>
</template>

<script setup>
// 子组件的方法和数据
const childMethod = () => {
  console.log('子组件方法被调用')
}

// 需要暴露给父组件的属性和方法
defineExpose({
  childMethod,
  childData: '我是子组件的数据'
})
</script>

父组件 Parent.vue

<template>
  <!-- 子组件 -->
  <Child ref="childRef" />
  <button @click="callChildMethod">调用子组件方法</button>
</template>

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

// 创建一个ref来存储子组件实例
const childRef = ref(null)

// 调用子组件方法
const callChildMethod = () => {
  if (childRef.value) {
    childRef.value.childMethod()  // 调用子组件暴露的方法
    console.log(childRef.value.childData)  // 访问子组件暴露的数据
  }
}

// 在生命周期钩子中访问
import { onMounted } from 'vue'
onMounted(() => {
  console.log('子组件实例:', childRef.value)
})
</script>

什么时候用 reactive?

深层嵌套的对象

当数据结构复杂且嵌套层级较深时,reactive 的语法更简洁:

// 复杂的状态对象
const store = reactive({
  user: {
    profile: {
      personal: {
        name: '张三',
        age: 18
      },
      contact: {
        email: 'zhang@example.com',
        phone: '1234567890'
      }
    },
    preferences: {
      theme: 'dark',
      language: 'zh-CN',
      notifications: {
        email: true,
        sms: false
      }
    }
  },
  ui: {
    sidebar: {
      collapsed: false,
      width: 240
    },
    modal: {
      visible: false,
      type: null
    }
  }
})

// 访问深层属性 - reactive 很方便
store.user.profile.personal.name = '李四'
store.ui.sidebar.collapsed = true

// 如果用 ref,每次都要 .value,略显繁琐
const storeRef = ref({
  // 同样的数据结构
})
storeRef.value.user.profile.personal.name = '李四' // 多了 .value

不需要整体替换的数据

对于不需要整体替换的数据,比如配置数据等,只用初始化一次,后期只会更改属性,reactive 很合适:

const appConfig = reactive({
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retryCount: 3,
  features: {
    logging: true,
    cache: false
  }
})

// 后续只修改属性
appConfig.timeout = 10000
appConfig.features.cache = true

性能敏感的场景

虽然差别很小,但理论上 reactiveref 少一层包装,性能略好:

// ref 多了一层对象包装
const refState = ref({ count: 0 })
// 访问路径: refState.value.count

// reactive 直接代理原始对象
const reactiveState = reactive({ count: 0 })
// 访问路径: reactiveState.count

// 在大量数据操作的场景下,reactive 可能稍有优势

注:这种说法只是出于纯理论上的,因为实际开发中,这种性能差异在99%的场景中都可以忽略不计。

为什么 reactive 解构后会失去响应性?

原因:解构破坏了 Proxy 的代理

要想理解这个问题,还是得回到 Proxy 的工作原理中,我们先用一段简单的代码模拟 reactive 的行为:

const raw = { name: '张三', age: 18 }
const proxy = new Proxy(raw, {
  get(target, key) {
    console.log(`读取 ${key}`)
    return target[key]
  },
  set(target, key, value) {
    console.log(`设置 ${key} = ${value}`)
    target[key] = value
    return true
  }
})

此时,我们对 proxy 解构 const { name } = proxy ,它都会发生哪些事呢?

  1. 读取 proxy.name ,此时会触发 get 拦截 -- 没有问题
  2. 将获取到的值 张三 赋值给 name 变量 -- 问题产生了
  3. name 被重新赋值为一个普通的字符串,和 proxy 没有任何关系了
  4. 后续对 name 的操作都只是修改一个普通变量,不会触发任何拦截

解决方案

方案一:使用 toRefs(推荐)

import { reactive, toRefs } from 'vue'

const user = reactive({
  name: '张三',
  age: 18
})

// toRefs 将每个属性转换为 ref
const { name, age } = toRefs(user)

// 现在可以安全解构了
name.value = '李四' // ✅ 触发更新
age.value++ // ✅ 触发更新

toRefs 的简化原理:

function toRefs(obj) {
  const result = {}
  // 遍历对象的所有key
  for (const key in obj) {
    result[key] = toRef(obj, key) // 为每个属性单独创建 ref
  }
  return result
}

// 创建的 ref 和原对象保持连接
const nameRef = toRef(user, 'name')
nameRef.value = '李四' // 等价于 user.name = '李四'

方案二:使用 toRef 处理单个属性

import { reactive, toRef } from 'vue'

const user = reactive({
  name: '张三',
  age: 18
})

// 只需要处理个别属性
const name = toRef(user, 'name')
const age = toRef(user, 'age')

name.value = '李四' // ✅ 触发更新

方案三:直接用 ref

如果发现需要频繁解构,可能在一开始就应该使用 ref

const user = ref({
  name: '张三',
  age: 18
})

选择决策树

基于以上分析,我们可以建立一套清晰的选择决策树:

快速选择指南

选择决策树

决策依据详解

场景 推荐方案 原因
基础数据类型 ref reactive 无法处理基础类型
需要整体重置的表单 ref 支持直接替换 .value
组合式函数返回值 ref 方便使用者解构
复杂嵌套对象 reactive 语法更简洁
一次性初始化配置 reactive 不需要整体替换
需要解构的场景 ref + toRefs 保持响应性

最终建议

  • 默认用 refref 更灵活,适用场景更广,虽然多了 .value,但换来的是确定性和可预测性
  • 在特定场景用 reactive:当需要使用复杂对象且不需要解构时,reactive 能让代码更简洁
  • 要理解并善用工具函数toRefstoRefisRefisReactive
  • 团队统一规范:无论选择哪种策略,团队内要保持一致,避免混用导致混乱
  • 无法确定用哪个时:直接用 refref 是更安全、更通用的选择

结语

ref 是更安全、更通用的选择;reactive 则是在特定场景下的优化选择。理解了它们的设计哲学和适用场景,就能帮我们在适当的场合做出正确的选择。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

前端架构演进与模块化设计实践

 引言:从"能运行"到"好维护"的转变

在快速迭代的业务需求面前,我们是否经常遇到这样的场景:新功能不敢轻易开发,因为担心影响现有业务;代码修改牵一发而动全身;不同业务模块间耦合严重,难以独立部署和测试。这些问题背后,反映的是前端架构设计的重要性。

1. 架构设计的核心目标

1.1 可持续性

  • 代码应易于理解和扩展
  • 新成员能够快速融入开发
  • 技术债务可控

1.2 可维护性

  • 模块职责清晰明确
  • 变更影响范围可控
  • 调试和定位问题高效

1.3 可测试性

  • 组件能够独立测试
  • 模拟各种业务场景
  • 自动化测试覆盖核心流程

2. 现代前端架构模式实践

2.1 分层架构设计

whiteboard_exported_image.png

实践案例:用户管理模块

// 表现层 - UserList.tsx
const UserList: React.FC = () => {
  const { users, loading, error } = useUserManagement();
  
  if (loading) return <Loading />;
  if (error) return <Error message={error} />;
  
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
};

// 业务层 - useUserManagement.ts
export const useUserManagement = () => {
  const [state, setState] = useState<UserState>(initialState);
  
  const fetchUsers = async () => {
    try {
      setState(prev => ({ ...prev, loading: true }));
      const users = await userAPI.fetchAll();
      setState(prev => ({ ...prev, users, loading: false }));
    } catch (error) {
      setState(prev => ({ ...prev, error: error.message, loading: false }));
    }
  };
  
  return { ...state, fetchUsers };
};

// 数据层 - userAPI.ts
export const userAPI = {
  fetchAll: async (): Promise<User[]> => {
    const response = await fetch('/api/users');
    return response.json();
  }
};

2.2 微前端架构实践

场景:大型管理平台,多个团队协作开发

解决方案:

// 主应用 - 路由配置
const routes = [
  {
    path: '/order/*',
    component: () => import('order-app/OrderModule'),
  },
  {
    path: '/user/*', 
    component: () => import('user-app/UserModule'),
  },
  {
    path: '/product/*',
    component: () => import('product-app/ProductModule'),
  }
];

// 模块联邦配置 - webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        orderApp: 'order@http://localhost:3001/remoteEntry.js',
        userApp: 'user@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

3. 模块化设计原则

3.1 单一职责原则

// ❌ 违反单一职责
class UserService {
  async getUser(id: string) { /* ... */ }
  validateEmail(email: string) { /* ... */ }
  sendEmail(content: string) { /* ... */ }
  formatUserData(user: User) { /* ... */ }
}

// ✅ 符合单一职责
class UserRepository {
  async getUser(id: string) { /* ... */ }
}

class ValidationService {
  validateEmail(email: string) { /* ... */ }
}

class EmailService {
  sendEmail(content: string) { /* ... */ }
}

class UserFormatter {
  formatUserData(user: User) { /* ... */ }
}

3.2 依赖倒置原则

// 定义抽象接口
interface UserStorage {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
}

// 具体实现
class LocalStorageUser implements UserStorage {
  async save(user: User) {
    localStorage.setItem(`user_${user.id}`, JSON.stringify(user));
  }
  
  async findById(id: string) {
    const data = localStorage.getItem(`user_${id}`);
    return data ? JSON.parse(data) : null;
  }
}

class APITUserStorage implements UserStorage {
  async save(user: User) {
    await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(user)
    });
  }
  
  async findById(id: string) {
    const response = await fetch(`/api/users/${id}`);
    return response.json();
  }
}

// 业务逻辑依赖于抽象,而非具体实现
class UserService {
  constructor(private storage: UserStorage) {}
  
  async updateUser(user: User) {
    // 业务逻辑
    await this.storage.save(user);
  }
}

4. 状态管理架构演进

4.1 状态分类与管理策略

// 1. 本地状态 - 使用 useState/useReducer
const [formData, setFormData] = useState(initialFormData);

// 2. 全局状态 - 使用 Zustand(推荐轻量级方案)
const useUserStore = create((set, get) => ({
  users: [],
  loading: false,
  fetchUsers: async () => {
    set({ loading: true });
    const users = await userAPI.fetchAll();
    set({ users, loading: false });
  },
  addUser: (user: User) => {
    set(state => ({ 
      users: [...state.users, user] 
    }));
  }
}));

// 3. 服务端状态 - 使用 React Query/SWR
const { data: users, isLoading, error } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  staleTime: 5 * 60 * 1000, // 5分钟
});

4.2 状态规范化

//  嵌套深、难以更新的状态
const state = {
  posts: [
    {
      id: 1,
      title: 'Post 1',
      author: {
        id: 1,
        name: 'John',
        avatar: '...'
      },
      comments: [
        {
          id: 1,
          text: 'Great!',
          user: {
            id: 2,
            name: 'Alice'
          }
        }
      ]
    }
  ]
};

//  规范化状态
const normalizedState = {
  posts: {
    byId: {
      1: { id: 1, title: 'Post 1', author: 1, comments: [1] }
    },
    allIds: [1]
  },
  users: {
    byId: {
      1: { id: 1, name: 'John', avatar: '...' },
      2: { id: 2, name: 'Alice' }
    },
    allIds: [1, 2]
  },
  comments: {
    byId: {
      1: { id: 1, text: 'Great!', user: 2, post: 1 }
    },
    allIds: [1]
  }
};

5. 构建可测试的架构

5.1 依赖注入与测试

// 业务逻辑
class OrderService {
  constructor(
    private paymentGateway: PaymentGateway,
    private notificationService: NotificationService,
    private inventoryService: InventoryService
  ) {}
  
  async processOrder(order: Order) {
    // 1. 扣减库存
    await this.inventoryService.reserve(order.items);
    
    // 2. 处理支付
    const paymentResult = await this.paymentGateway.charge(order.total);
    
    // 3. 发送通知
    if (paymentResult.success) {
      await this.notificationService.sendOrderConfirmation(order);
    }
    
    return paymentResult;
  }
}

// 单元测试
describe('OrderService', () => {
  it('should process order successfully', async () => {
    // 准备测试替身
    const mockPaymentGateway = {
      charge: jest.fn().mockResolvedValue({ success: true })
    };
    const mockNotificationService = {
      sendOrderConfirmation: jest.fn().mockResolvedValue(undefined)
    };
    const mockInventoryService = {
      reserve: jest.fn().mockResolvedValue(undefined)
    };
    
    // 创建被测试实例
    const orderService = new OrderService(
      mockPaymentGateway,
      mockNotificationService,
      mockInventoryService
    );
    
    // 执行测试
    const result = await orderService.processOrder(testOrder);
    
    // 验证行为
    expect(result.success).toBe(true);
    expect(mockInventoryService.reserve).toHaveBeenCalledWith(testOrder.items);
    expect(mockPaymentGateway.charge).toHaveBeenCalledWith(testOrder.total);
    expect(mockNotificationService.sendOrderConfirmation).toHaveBeenCalledWith(testOrder);
  });
});

6. 架构质量度量与改进

6.1 代码质量指标

// 使用 ESLint 插件监控架构质量
module.exports = {
  rules: {
    'max-dependencies': ['error', 10], // 单个模块最大依赖数
    'cyclic-dependency': 'error',       // 禁止循环依赖
    'no-relative-import': 'error',      // 禁止相对导入
    'feature-envy': 'error'             // 禁止特性依恋
  }
};

// package.json 依赖治理
{
  "scripts": {
    "analyze:deps": "madge --image deps-graph.svg src/",
    "analyze:complexity": "complexity-report src/",
    "check:circular": "dpdm --circular src/**/*.ts"
  }
}

7. 结语

好的前端架构不是一蹴而就的,而是随着业务发展和团队成长不断演进的过程。它需要在过度设计与缺乏设计之间找到平衡,在满足当前需求的同时为未来变化留出空间。

架构的终极目标不是构建完美的系统,而是创建能够优雅演进的系统。希望本文的实践经验能够为团队在架构设计方面提供有价值的参考,欢迎大家共同探讨和改进我们的前端架构实践。

8. 团队介绍

智慧家技术平台-应用软件框架开发」主要负责设计工具的研发,包括营销设计工具、家电VR设计和展示、水电暖通前置设计能力,研发并沉淀素材库,构建家居家装素材库,集成户型库、全品类产品库、设计方案库、生产工艺模型,打造基于户型和风格的AI设计能力,快速生成算量和报价;同时研发了门店设计师中心和项目中心,包括设计师管理能力和项目经理管理能力。实现了场景全生命周期管理,同时为水,空气,厨房等产业提供商机管理工具,从而实现了以场景贯穿的B端C端全流程系统。

❌