阅读视图

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

「九九八十一难」组合式函数到底有什么用?

引言

最近接手了一个 Vue 2 的老项目,翻开代码的那一刻,我陷入了沉思。

一个 .vue 文件足足 5000 行代码,data 里定义了 200 多个变量,methods 里塞了 100 多个方法。

相关逻辑散落在 datamethodscomputedwatch 各个角落,方法套方法,变量牵变量。

剪不断、理还乱。

终于明白了 Vue 3 为什么要引入组合式函数(Composables)

Q:有同学就要问了,为什么不用 mixin 实现?

A:在实际工程中使用 mixin ,还不一定比放在同一个组件里面维护起来方便。


组合式函数(Composables)定义

在 Vue 应用的概念中,"组合式函数"(Composables) 是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑函数

这个定义中有两个关键点需要理解:有状态逻辑函数形式

什么是有状态逻辑?

在程序设计中,"状态"指的是在程序运行过程中会发生变化的数据。有状态逻辑就是指那些管理着会变化的数据,并且需要对这些数据的变化做出响应的代码逻辑。

阅读下面文章之前,先理解下这两句话:

组合式函数内部可以使用 ref 或 reactive 创建响应式数据,并且这些数据在返回给组件后依然保持响应性

组合式函数可以接收任意参数,可以是普通值或响应式引用(ref)。

举个例子:

  • 无状态逻辑:一个纯函数 add(a, b) => a + b,给定相同的输入,永远返回相同的输出,不依赖任何外部状态。
  • 有状态逻辑:一个计数器,它维护一个当前计数值,可以增加、减少、重置,并且当计数值变化时,使用这个计数值的地方需要自动更新。

在 Vue 中,有状态逻辑通常包含:

  • 响应式数据(ref、reactive)
  • 计算属性(computed)
  • 侦听器(watch)
  • 生命周期钩子(onMounted、onUnmounted 等)

为什么是函数?

组合式函数选择以函数的形式存在,而不是类、对象或其他形式,这是经过深思熟虑的设计:

  1. 组合性:函数可以轻松地相互调用、嵌套、组合。你可以在一个组合式函数中调用另一个组合式函数,形成逻辑的层层封装。

  2. 作用域隔离:每次调用函数都会创建一个新的作用域,这意味着你可以在多个组件中多次调用同一个组合式函数,每次调用都是独立的实例,互不干扰。

  3. 参数传递灵活:函数可以接收参数,返回值,这使得逻辑的输入输出非常清晰。

  4. 符合 JavaScript 惯例:JavaScript 本身就是函数式编程友好的语言,使用函数封装逻辑符合开发者的直觉。

为什么要引入组合式函数(Composables)?

Vue 2 选项式 API 的困境

在 Vue 2 中,我们使用选项式 API(Options API)来组织代码。

这种方式在组件简单时非常直观,但当组件变得复杂时,问题就暴露出来了。

问题一:逻辑碎片化

假设我们要实现一个"鼠标追踪"功能,需要追踪鼠标在页面上的位置。在 Vue 2 中,代码会散落在多个选项中:

<script>
export default {
  data() {
    return {
      x: 0,
      y: 0
    }
  },
  mounted() {
    window.addEventListener('mousemove', this.handleMouseMove)
  },
  beforeUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove)
  },
  methods: {
    handleMouseMove(event) {
      this.x = event.pageX
      this.y = event.pageY
    }
  }
}
</script>

可以看到,一个完整的功能被拆分到了 datamountedbeforeUnmountmethods 四个不同的地方。当组件功能越来越多时,阅读代码就需要在不同选项之间来回跳转,理解成本极高。

其实这种编程习惯至今我仍有部分困惑,在书写 vue3 组合式写法时,部分同事还是喜欢将变量、方法、计算属性分类书写,方法放在一起、变量放在一堆,导致维护代码时候仍然会在多个代码块中进行跳转。

问题二:复用困难

Vue 2 提供了 Mixins 来复用逻辑,但它存在严重的问题:

<script>
const mouseTrackingMixin = {
  data() {
    return {
      x: 0,
      y: 0
    }
  },
  mounted() {
    window.addEventListener('mousemove', this.handleMouseMove)
  },
  beforeUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove)
  },
  methods: {
    handleMouseMove(event) {
      this.x = event.pageX
      this.y = event.pageY
    }
  }
}

export default {
  mixins: [mouseTrackingMixin],
  data() {
    return {
      x: 'I will be overwritten!'  // 命名冲突!
    }
  }
}
</script>

Mixins 的问题包括:

  • 命名冲突:多个 mixin 或组件与 mixin 之间可能有同名属性/方法,导致覆盖
  • 依赖隐式:mixin 内部可能使用了组件的某些属性,但这种依赖关系不明显
  • 数据来源不清晰:当使用了多个 mixin 时,很难分辨某个属性来自哪个 mixin

问题三:TypeScript 支持不友好

选项式 API 的类型推导相对复杂,IDE 的智能提示也不够完善,这在大型项目中是一个明显的短板。

组合式函数的解决方案

组合式函数完美解决了上述问题:

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function handleMouseMove(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => {
    window.addEventListener('mousemove', handleMouseMove)
  })

  onUnmounted(() => {
    window.removeEventListener('mousemove', handleMouseMove)
  })

  return { x, y }
}

const { x, y } = useMouse()
</script>

可以看到:

  • 逻辑聚合:所有与鼠标追踪相关的代码都集中在 useMouse 函数中
  • 命名清晰:通过解构赋值,可以清楚地看到 xy 来自 useMouse
  • 无命名冲突:即使有多个组合式函数返回同名属性,也可以通过重命名解决

组合式函数的优势

1. 逻辑组织更清晰

组合式函数允许我们按照功能而不是按照选项来组织代码。相关联的状态和方法可以放在一起,形成内聚的逻辑单元。

<script setup>
import { useMouse } from './composables/useMouse'
import { useFetch } from './composables/useFetch'
import { useTheme } from './composables/useTheme'

const { x, y } = useMouse()
const { data, error, loading } = useFetch('/api/users')
const { theme, toggleTheme } = useTheme()
</script>

每个组合式函数负责一个独立的功能,代码结构一目了然。

2. 逻辑复用更简单

组合式函数本质上是普通 JavaScript 函数,可以在任何地方调用:

import { useMouse } from './composables/useMouse'

export function useMouseWithDelay(delay = 100) {
  const { x: rawX, y: rawY } = useMouse()
  const x = ref(0)
  const y = ref(0)

  watch([rawX, rawY], debounce(([newX, newY]) => {
    x.value = newX
    y.value = newY
  }, delay))

  return { x, y }
}

你甚至可以在一个组合式函数中调用另一个组合式函数,实现逻辑的组合与扩展。

3. 类型推导更完善

组合式函数天然支持 TypeScript,类型推导非常准确:

import { ref, computed, type Ref, type ComputedRef } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

function useUser(id: Ref<number>) {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  const fullName = computed(() => {
    if (!user.value) return ''
    return `${user.value.name} <${user.value.email}>`
  })

  async function fetchUser() {
    loading.value = true
    error.value = null
    try {
      const response = await fetch(`/api/users/${id.value}`)
      user.value = await response.json()
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  return {
    user,
    loading,
    error,
    fullName,
    fetchUser
  }
}

IDE 可以准确推断出 user 的类型是 Ref<User | null>fullName 的类型是 ComputedRef<string>

4. 测试更方便

组合式函数是纯 JavaScript/TypeScript 函数,可以脱离 Vue 组件独立测试:

import { useCounter } from './composables/useCounter'
import { ref } from 'vue'

describe('useCounter', () => {
  it('should increment count', () => {
    const { count, increment } = useCounter()
    expect(count.value).toBe(0)
    increment()
    expect(count.value).toBe(1)
  })

  it('should accept initial value', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })
})

组合式函数的使用场景

1. 封装通用状态逻辑

当你发现多个组件中存在相同或相似的状态逻辑时,就应该考虑提取为组合式函数。

典型场景

  • 表单验证逻辑
  • 分页逻辑
  • 加载状态管理
  • 主题切换
  • 国际化

2. 组织复杂组件逻辑

当单个组件变得庞大时,可以使用组合式函数将不同功能的代码分离:

<script setup>
import { useUserAuth } from './composables/useUserAuth'
import { useUserProfile } from './composables/useUserProfile'
import { useUserPosts } from './composables/useUserPosts'

const { user, login, logout } = useUserAuth()
const { profile, updateProfile } = useUserProfile(user)
const { posts, fetchPosts, createPost } = useUserPosts(user)
</script>

3. 集成第三方库

将第三方库的集成逻辑封装为组合式函数,可以简化使用并提供 Vue 友好的 API:

import { ref, onMounted, onUnmounted } from 'vue'
import { debounce } from 'lodash-es'

export function useDebouncedRef(value, delay = 300) {
  const debouncedValue = ref(value)
  const updater = debounce((newValue) => {
    debouncedValue.value = newValue
  }, delay)

  watch(() => value, (newValue) => {
    updater(newValue)
  })

  onUnmounted(() => {
    updater.cancel()
  })

  return debouncedValue
}

4. 抽象浏览器 API

将浏览器原生 API 封装为响应式的组合式函数:

import { ref, onMounted, onUnmounted } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const value = ref(defaultValue)

  function read() {
    const stored = localStorage.getItem(key)
    if (stored !== null) {
      value.value = JSON.parse(stored)
    }
  }

  function write() {
    localStorage.setItem(key, JSON.stringify(value.value))
  }

  onMounted(() => {
    read()
    window.addEventListener('storage', read)
  })

  onUnmounted(() => {
    window.removeEventListener('storage', read)
  })

  watch(value, write, { deep: true })

  return value
}

组合式函数的实现规范

基本结构

一个标准的组合式函数通常包含以下部分:

import { ref, computed, watch, onMounted, onUnmounted } from 'vue'

export function useFeatureName(parameter) {
  const state = ref(initialValue)
  const computedValue = computed(() => {
    return state.value * 2
  })

  function doSomething() {
    state.value++
  }

  watch(state, (newValue, oldValue) => {
    console.log(`state changed from ${oldValue} to ${newValue}`)
  })

  onMounted(() => {
    console.log('component mounted')
  })

  onUnmounted(() => {
    console.log('component unmounted')
  })

  return {
    state,
    computedValue,
    doSomething
  }
}

命名约定

  • 函数命名:以 use 开头,采用驼峰命名法,如 useMouseuseFetchuseLocalStorage
  • 文件命名:与函数名一致,如 useMouse.jsuseMouse.ts
  • 目录结构:通常放在 composables/hooks/ 目录下

返回值约定

  • 返回一个对象,包含需要暴露给外部使用的响应式状态和方法
  • 返回的对象通常使用解构赋值接收
  • 如果需要返回响应式引用,不要在返回时解包,保持 ref 形式

参数约定

  • 可以接收普通值、响应式引用(ref)、响应式对象(reactive)作为参数
  • 如果参数可能是响应式的,使用 toValue() 工具函数进行解包:
import { toValue } from 'vue'

export function useFetch(url) {
  const urlValue = toValue(url)
}

组合式函数的实现示例

示例一:鼠标追踪器

这是一个经典的组合式函数示例,封装了鼠标位置追踪逻辑:

import { ref, onMounted, onUnmounted } from 'vue'

/**
 * 追踪鼠标在页面上的位置
 * @returns {Object} 包含鼠标 x、y 坐标的响应式引用
 */
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

在组件中使用:

<template>
  <div>鼠标位置:{{ x }}, {{ y }}</div>
</template>

<script setup>
import { useMouse } from './composables/useMouse'

const { x, y } = useMouse()
</script>

示例二:数据请求

封装通用的数据获取逻辑,包含加载状态和错误处理:

import { ref, watchEffect, toValue } from 'vue'

/**
 * 封装数据获取逻辑
 * @param {string|Ref<string>|() => string} url - 请求地址,可以是响应式引用或 getter 函数
 * @returns {Object} 包含 data、error、loading 状态的对象
 */
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  async function fetchData() {
    loading.value = true
    error.value = null

    try {
      const response = await fetch(toValue(url))
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, error, loading, refetch: fetchData }
}

在组件中使用:

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">加载失败:{{ error.message }}</div>
  <div v-else>
    <pre>{{ data }}</pre>
    <button @click="refetch">重新加载</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useFetch } from './composables/useFetch'

const userId = ref(1)
const { data, error, loading, refetch } = useFetch(
  () => `/api/users/${userId.value}`
)
</script>

示例三:计数器

一个简单但完整的计数器示例,展示参数接收和返回值:

import { ref, computed } from 'vue'

/**
 * 创建一个计数器
 * @param {number} initialValue - 初始值,默认为 0
 * @param {number} step - 步长,默认为 1
 * @returns {Object} 计数器状态和方法
 */
export function useCounter(initialValue = 0, step = 1) {
  const count = ref(initialValue)

  const isPositive = computed(() => count.value > 0)
  const isNegative = computed(() => count.value < 0)
  const isZero = computed(() => count.value === 0)

  function increment() {
    count.value += step
  }

  function decrement() {
    count.value -= step
  }

  function reset() {
    count.value = initialValue
  }

  function set(value) {
    count.value = value
  }

  return {
    count,
    isPositive,
    isNegative,
    isZero,
    increment,
    decrement,
    reset,
    set
  }
}

示例四:表单验证

封装表单验证逻辑,支持自定义验证规则:

import { ref, computed, reactive } from 'vue'

/**
 * 表单验证组合式函数
 * @param {Object} initialValues - 表单初始值
 * @param {Object} rules - 验证规则
 * @returns {Object} 表单状态和验证方法
 */
export function useForm(initialValues, rules) {
  const values = reactive({ ...initialValues })
  const errors = reactive({})
  const touched = reactive({})
  const isSubmitting = ref(false)

  const isValid = computed(() => {
    return Object.keys(errors).every(key => !errors[key])
  })

  function validateField(field) {
    const rule = rules[field]
    if (!rule) return true

    const value = values[field]
    const result = rule(value)

    if (typeof result === 'string') {
      errors[field] = result
      return false
    } else {
      errors[field] = ''
      return true
    }
  }

  function validateAll() {
    let allValid = true
    for (const field in rules) {
      if (!validateField(field)) {
        allValid = false
      }
    }
    return allValid
  }

  function setFieldTouched(field) {
    touched[field] = true
    validateField(field)
  }

  function resetForm() {
    Object.assign(values, initialValues)
    Object.keys(errors).forEach(key => {
      errors[key] = ''
    })
    Object.keys(touched).forEach(key => {
      touched[key] = false
    })
  }

  async function handleSubmit(callback) {
    isSubmitting.value = true

    Object.keys(values).forEach(key => {
      touched[key] = true
    })

    if (validateAll()) {
      await callback(values)
    }

    isSubmitting.value = false
  }

  return {
    values,
    errors,
    touched,
    isSubmitting,
    isValid,
    validateField,
    validateAll,
    setFieldTouched,
    resetForm,
    handleSubmit
  }
}

在组件中使用:

<template>
  <form @submit.prevent="handleSubmit(onSubmit)">
    <div>
      <label>用户名:</label>
      <input
        v-model="values.username"
        @blur="setFieldTouched('username')"
      />
      <span v-if="touched.username && errors.username" class="error">
        {{ errors.username }}
      </span>
    </div>

    <div>
      <label>邮箱:</label>
      <input
        v-model="values.email"
        @blur="setFieldTouched('email')"
      />
      <span v-if="touched.email && errors.email" class="error">
        {{ errors.email }}
      </span>
    </div>

    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? '提交中...' : '提交' }}
    </button>
  </form>
</template>

<script setup>
import { useForm } from './composables/useForm'

const initialValues = {
  username: '',
  email: ''
}

const rules = {
  username: (value) => {
    if (!value) return '用户名不能为空'
    if (value.length < 3) return '用户名至少 3 个字符'
    return true
  },
  email: (value) => {
    if (!value) return '邮箱不能为空'
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '邮箱格式不正确'
    return true
  }
}

const {
  values,
  errors,
  touched,
  isSubmitting,
  setFieldTouched,
  handleSubmit
} = useForm(initialValues, rules)

async function onSubmit(formValues) {
  console.log('表单提交:', formValues)
}
</script>

注意点与最佳实践

1. 始终在 setup 函数或 script setup 中调用

组合式函数依赖于 Vue 的组合式 API,必须在组件的 setup() 函数或 <script setup> 中同步调用:

export default {
  setup() {
    const { x, y } = useMouse()
    return { x, y }
  }
}
<script setup>
const { x, y } = useMouse()
</script>

错误示例

export default {
  setup() {
    setTimeout(() => {
      const { x, y } = useMouse()
    }, 1000)
  }
}

2. 返回响应式引用时保持 ref 形式

组合式函数返回的响应式数据应该保持 refreactive 形式,不要在返回时解包:

export function useCounter() {
  const count = ref(0)
  return { count }
}

这样可以让调用者明确知道这是一个响应式引用,并且可以灵活地传递给其他组合式函数。

3. 使用 toValue 处理可能是响应式的参数

当组合式函数接收的参数可能是普通值、ref 或 getter 函数时,使用 toValue 统一处理:

import { toValue } from 'vue'

export function useFetch(url) {
  const urlValue = toValue(url)
}

4. 合理使用 shallowRef 和 shallowReactive

对于大型对象或数组,如果只需要监听整体变化而不需要深度响应,使用 shallowRefshallowReactive 可以提升性能:

import { shallowRef } from 'vue'

export function useLargeData() {
  const data = shallowRef([])

  async function fetchData() {
    const response = await fetch('/api/large-data')
    data.value = await response.json()
  }

  return { data, fetchData }
}

5. 清理副作用

在组合式函数中创建的副作用(事件监听、定时器等)必须在组件卸载时清理:

import { onUnmounted } from 'vue'

export function useInterval(callback, delay) {
  let timer = null

  timer = setInterval(callback, delay)

  onUnmounted(() => {
    if (timer) {
      clearInterval(timer)
    }
  })
}

或者使用 Vue 提供的 watchEffectonCleanup

import { watchEffect } from 'vue'

export function useEventListener(target, event, callback) {
  watchEffect((onCleanup) => {
    target.addEventListener(event, callback)

    onCleanup(() => {
      target.removeEventListener(event, callback)
    })
  })
}

6. 避免在组合式函数中直接修改 props

组合式函数不应该直接修改接收到的 props,而应该通过 emit 或其他方式通知父组件:

export function useModelValue(props, emit) {
  const localValue = computed({
    get: () => props.modelValue,
    set: (value) => emit('update:modelValue', value)
  })

  return { localValue }
}

7. 提供合理的默认值

组合式函数的参数应该提供合理的默认值,提高易用性:

export function useDebounce(fn, delay = 300) {
}

8. 文档化你的组合式函数

使用 JSDoc 为组合式函数添加文档,说明参数、返回值和使用示例:

/**
 * 创建一个防抖的响应式引用
 * @template T
 * @param {T} initialValue - 初始值
 * @param {number} delay - 防抖延迟时间(毫秒)
 * @returns {import('vue').Ref<T>} 防抖后的响应式引用
 * @example
 * const searchTerm = useDebouncedRef('', 300)
 * watch(searchTerm, (value) => {
 *   console.log('搜索:', value)
 * })
 */
export function useDebouncedRef(initialValue, delay = 300) {
}

组合式函数 vs 其他方案对比

组合式函数 vs Mixins

特性 组合式函数 Mixins
数据来源 清晰(解构赋值) 不清晰
命名冲突 可重命名解决 静默覆盖
参数传递 支持参数 不支持
逻辑组合 可嵌套调用 困难
TypeScript 支持 完善 较差

组合式函数 vs Renderless Components

Renderless Components(无渲染组件)是 Vue 2 中另一种复用逻辑的方式:

<template>
  <slot :x="x" :y="y" />
</template>

<script>
export default {
  data() {
    return { x: 0, y: 0 }
  },
  mounted() {
    window.addEventListener('mousemove', this.handleMouseMove)
  },
  beforeUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove)
  },
  methods: {
    handleMouseMove(event) {
      this.x = event.pageX
      this.y = event.pageY
    }
  }
}
</script>

对比:

特性 组合式函数 Renderless Components
性能 更好(无组件开销) 有组件实例开销
使用方式 函数调用 组件嵌套
灵活性 更高 受限于组件树
TypeScript 支持 完善 一般

总结

组合式函数是 Vue 3 最具革命性的特性之一,它从根本上改变了我们组织和复用代码的方式。

核心价值

  • 解决逻辑碎片化:将相关联的状态和方法聚合在一起,代码更易读、易维护
  • 简化逻辑复用:以函数形式封装,可在任意组件中复用,无命名冲突之忧
  • 提升开发体验:完善的 TypeScript 支持和 IDE 智能提示
  • 便于测试:纯函数形式,可脱离组件独立测试

使用建议

  • 当发现多个组件存在相同逻辑时,提取为组合式函数
  • 当单个组件变得庞大时,使用组合式函数拆分功能模块
  • 遵循命名约定(use 前缀)和返回值约定
  • 注意清理副作用,避免内存泄漏

从 Vue 2 迁移

  • 不需要一次性重写所有代码,组合式函数可以与选项式 API 共存
  • 可以逐步将 Mixins 重构为组合式函数
  • 利用组合式函数简化新功能的开发

组合式函数不仅是一种技术方案,更是一种关注点分离组合优于继承的设计思想。掌握它,将让你的 Vue 开发体验提升一个台阶。

回到开头那个 5000 行的 Vue 2 组件,如果用组合式函数重构,或许可以变成这样:

<script setup>
import { useUserAuth } from './composables/useUserAuth'
import { useUserList } from './composables/useUserList'
import { useUserForm } from './composables/useUserForm'
import { usePagination } from './composables/usePagination'
import { useSearch } from './composables/useSearch'
import { useNotification } from './composables/useNotification'

const { user, login, logout } = useUserAuth()
const { users, fetchUsers, deleteUser } = useUserList()
const { form, submitForm, resetForm } = useUserForm()
const { page, pageSize, total, setPage } = usePagination()
const { keyword, filteredUsers } = useSearch(users)
const { showSuccess, showError } = useNotification()
</script>

清晰、简洁、优雅。这就是组合式函数的魅力。

回到标题,相同的业务实现,我不使用组合式函数也能实现。

读完这篇文章,是否可以尝试使用组合式函数,全凭各位看官决定。

写法只是手段,业务实现才是重点。

VUE3响应式原理——从零解析

基本概念

在开始讲解响应式原理之前,我们需要知道两个基本概念:

什么是副作用函数?

即该函数的执行影响到其他函数的执行结果,则称该函数为副作用函数。例如:

const obj = { text: 'test' };

function effect() {
    obj.text = ‘hello’;
}

effect()执行后,其他使用到obj.text的函数中,读取到的值将是hello,而不是text,产生了副作用,故称effect()为副作用函数。

什么是响应式数据?

即当某个数据发生变化时,所有使用该数据的地方都发生了变化,则称该数据为响应式数据。例如:

const obj = { text: 'test' };

function effect() {
    ducoment.body.innerText = obj.text;
}

effect();
obj.text = 'hello';

obj.text的值设置为hello后,若body显示的内容由test变为hello,则称obj是一个响应式数据。


如何实现响应式?

通过上述基本概念的举例说明可以看出,响应式数据涉及到了数据的读取(get)和设置(set)操作——副作用函数执行时,进行了读取操作;数据值改变时,进行了设置操作,同时副作用函数被执行。

那怎么样才能保证对数据进行设置操作时,副作用函数被执行呢?可以在读取操作时使用一个容器将副作用函数保存起来,在设置操作时取出副作用函数执行,就实现了最简单的响应式。

Snipaste_2026-02-27_14-43-27.png

Snipaste_2026-02-27_14-43-34.png 在ES2015+以后,Proxy可以实现拦截数据的getset操作,并进行一些特殊处理。

// 副作用函数
function effect() {
    document.getElementById("result").innerHTML = obj.text;
}

const data = { text: "test" };

// 收集副作用函数的容器
const bucket = new Set();

// 响应式数据
const obj = new Proxy(data, {
    get(target, key) {
        // 读取时将副作用函数存入容器
        bucket.add(effect);
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        // 设置后将容器中的副作用函数取出逐一执行
        bucket.forEach((fn) => fn());
        return true;
    },
});

然而,在实际应用过程中,副作用函数名称并不都是effect,可能是其他名称,也可能是一个匿名函数。因此,需要改造一下原有的effect函数,允许其接收一个真正的副作用函数,并存到一个变量中,解决副作用函数名称被硬编码的问题。

// 当前激活的副作用函数
let activeEffect;

// 改造原有的effect函数
function effect(fn){
    activeEffect = fn;
    fn();
}

const data = { text: "test" };

// 收集副作用函数的容器
const bucket = new Set();

// 响应式数据
const obj = new Proxy(data, {
    get(target, key) {
        if (activeEffect) {
            bucket.add(activeEffect);
        }

        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        bucket.forEach((fn) => fn());
        return true;
    },
});


如何仅触发特定的副作用函数?

上一节中,已经实现了基本的响应式数据。但如果给obj中原本不存在的属性设置数据后,会发现副作用函数被执行了两次,例如下面这段代码:

effect(() => {
    console.log('执行了副作用函数');
})

function exec() {
    obj.text = 'hello';
    obj.name = '张三';
}

exec();

这和预期不一致——原始数据没有name属性,且副作用函数中未读取该属性,exec()执行到最后一行时,不应触发副作用函数的执行。

通过观察可以发现,objtexteffect呈现一种树状结构: Snipaste_2026-02-26_17-28-35.png

拓展可以得到以下情况: Snipaste_2026-02-27_14-27-09.png

targetkeyeffect是一对多的关系,因此单单使用Set是不满足的,需要调整收集副作用函数的容器的数据结构。

// 当前激活的副作用函数
let activeEffect;

// 改造原有的effect函数
export function effect(fn) {
    activeEffect = fn;
    fn();
}

const data = { text: "test" };

// 收集副作用函数的容器
const bucket = new WeakMap();

// 响应式数据
export const obj = new Proxy(data, {

    get(target, key) {
        if (!activeEffect) {
            return target\[key];
        }

        let depsMap = bucket.get(target);
        if (!depsMap) {
            // 如果不存在,则创建一个新的Map
            bucket.set(target, (depsMap = new Map()));
        }

        let effectsSet = depsMap.get(key);
        if (!effectsSet) {
            // 如果不存在,则创建一个新的Set
            depsMap.set(key, (effectsSet = new Set()));
        }

        effectsSet.add(activeEffect);
        return target[key];
    },

    set(target, key, newVal) {
        target[key] = newVal;

        const depsMap = bucket.get(target);
        // 没有收集到有副作用函数的属性,直接返回
        if (!depsMap) {
            return;
        }

        // 取出与属性绑定的所有副作用函数逐一执行
        const effectsSet = depsMap.get(key);
        effectsSet && effectsSet.forEach((fn) => fn());
        return true;
    },
});

低代码平台表单设计系统技术分析(实战三)

第三篇:拖拽功能与布局系统

前两篇我们分析了低代码平台表单设计系统的整体架构和组件体系,这一篇将深入探讨拖拽功能与布局系统的实现

1. 拖拽功能实现

该低代码平台使用 vuedraggable 库实现组件的拖拽功能,主要包括两个场景:

1.1 从左侧组件库拖拽到画布

<draggable
  :list="formDefine"
  :group="{ name: 'widget', pull: pullFuction, put: false }"
  item-key="id"
  :sort="false"
  @start="dragStart"
>
  <template #item="{ element }">
    <div class="item" @click="addComponent(element)" fill="currentColor">
      <span v-html="icons[element.type]" class="item-icon"></span>
      <span>{{ element.title }}</span>
    </div>
  </template>
</draggable>

核心实现:

  • 使用 vuedraggable 组件包装左侧组件列表
  • 配置 group 属性,设置拖拽组名为 "widget"
  • pull 函数控制拖拽行为,支持克隆模式 
  • dragStart 事件处理拖拽开始时的逻辑 
  • addComponent 方法处理点击添加组件的逻辑

1.2 画布内组件的拖拽排序

<draggable
  :list="props.formData.list"
  group="widget"
  item-key="id"
  @add="handleAdd"
>
  <template #item="{ element }">
    <FormDesignView
      @on-widget-select="widgetSelect(element)"
      :item="element"
      :chosenItem="currentItem"
      :formData="formData"
    ></FormDesignView>
  </template>
</draggable>

核心实现:

  • 使用 vuedraggable 包装画布内的组件列表 
  • 配置 group 属性为 "widget",与左侧组件库保持一致
  • @add 事件处理组件添加到画布的逻辑 
  • item-key 使用组件的 id 确保正确的DOM更新

1.3 拖拽逻辑处理

// 拖拽开始处理
const dragStart = (e) => {
  currentDragItem.value = formDefine[e.oldDraggableIndex]
  const currentType = currentDragItem.value.type
  currentTemplate(currentType)
  triggerScroll()
}

// 处理添加组件
const handleAdd = ({ newIndex }) => {
  const itemId = props.formData.list[newIndex].type + '_' + new Date().getTime()
  props.formData.list[newIndex] = {
    ...JSON.parse(JSON.stringify(props.formData.list[newIndex])),
    itemId,
    grid: props.formData.config?.colSpan || 24
  }
  currentItem.value = props.formData.list[newIndex]
}

拖拽处理特点:

  • 拖拽时克隆组件,而非移动原始组件 
  • 为新添加的组件生成唯一的 id 
  • 应用当前表单的布局配置
  • 自动选中新添加的组件
  • 触发右侧配置面板的更新

2. 布局系统设计

2.1 布局配置选项

表单布局通过 FormConfig 组件进行配置,支持多种布局方式:

<el-form-item label="表单布局" :label-position="itemLabelPosition">
  <el-select v-model="config.colSpan">
    <el-option
      v-for="item in layOutOptions"
      :key="item.key"
      :label="item.name"
      :value="item.colSpan"
    />
  </el-select>
</el-form-item>

布局选项:

const layOutOptions = [
  {
    key: 'single',
    name: '单列',
    colSpan: 24
  },
  {
    key: 'double',
    name: '双列',
    colSpan: 12
  },
  {
   ....省略
  }
]

2.2 标签对齐方式

支持三种标签对齐方式:

<el-form-item label="标签对齐方式" :label-position="itemLabelPosition">
  <el-radio-group
    v-model="config.labelPosition"
    aria-label="label position"
    @change="handleLabelPositionChange"
  >
    <el-radio-button value="left">左侧</el-radio-button>
    <el-radio-button value="right">右侧</el-radio-button>
    <el-radio-button value="top">顶部</el-radio-button>
  </el-radio-group>
</el-form-item>

2.3 标签宽度配置

<el-form-item label="标签宽度" :label-position="itemLabelPosition">
  <el-input-number
    v-model="config.labelWidth"
    :min="60"
    :max="500"
  >
    <template #suffix>
      <span>px</span>
    </template>
  </el-input-number>
</el-form-item>

2.4 组件级布局控制

每个组件可以单独设置宽度:

<el-form-item label="字段宽度" v-if="specialShow">
  <el-radio-group v-model="item.grid" class="field-width-wrapper">
    <el-radio-button :value="6" label="1/4" />
 
    <-- 省略-->

    <el-radio-button :value="24" label="整行" />
  </el-radio-group>
</el-form-item>

3. 布局渲染实现

3.1 响应式布局

使用 Element Plus 的栅格系统实现响应式布局:

<el-col :span="fixGridOptions.includes(item.type) ? 24 : finalGrid">
  <!-- 组件内容 -->
</el-col>

布局计算逻辑:

const { finalGrid, fixGridOptions } = useFormData()

// 监听组件属性和表单属性的布局变化
watch(
  () => props.formData.config?.colSpan,
  (newVal) => {
    finalGrid.value = newVal
    props.item.grid = newVal
  }
)

3.2 固定宽度组件

某些组件(如多标签页、分割线等)需要固定宽度:

const fixGridOptions = [
  FORM_TYPE.MULTI_TAB,
  FORM_TYPE.SEPARATOR,
  // 其他需要固定宽度的组件
]

4. 拖拽与布局的交互

4.1 拖拽时的布局应用

当组件被拖拽到画布时,会自动应用当前表单的布局设置:

const handleAdd = ({ newIndex }) => {
  // ...
  props.formData.list[newIndex] = {
    ...JSON.parse(JSON.stringify(props.formData.list[newIndex])),
    itemId,
    // 应用当前表单配置的布局
    grid: props.formData.config?.colSpan || 24
  }
  // ...
}

4.2 布局变更的实时响应

当表单布局发生变化时,所有组件会自动更新:

watch(
  () => props.formData.config?.colSpan,
  (newVal) => {
    finalGrid.value = newVal
    props.item.grid = newVal
  }
)

4.3 组件宽度的独立控制

组件可以覆盖表单的默认布局,设置自己的宽度:

watch(
  () => props.item.grid,
  (newVal) => {
    finalGrid.value = newVal
  }
)

5. 技术亮点 

  • 流畅的拖拽体验 :使用 vuedraggable 实现平滑的拖拽效果 
  • 智能的布局应用 :拖拽时自动应用表单布局设置
  • 灵活的布局选项 :支持多种布局方式和标签对齐方式
  • 组件级布局控制 :每个组件可以单独设置宽度 
  • 响应式设计 :基于 Element Plus 的栅格系统
  • 实时布局更新 :布局变更实时反映到所有组件
  • 固定宽度组件 :某些组件自动使用固定宽度

这种拖拽与布局系统的设计,大大简化了表单设计过程。用户可以通过直观的拖拽操作和灵活的布局配置,快速创建出表单。并且还有预览功能,直接在预览界面就可实时看到表单布局和试用数据填报。

下一篇预告 :《组件属性配置系统》,将详细分析组件属性配置的实现机制和设计思路。

SPA 首屏加载速度慢怎么解决?

一、问题根源拆解:为什么 SPA 首屏会这么慢?

在动手优化前,先明确核心瓶颈,确保优化方向精准:

  1. 资源体积过大:打包后 app.js 体积臃肿,包含大量未使用的代码(冗余代码);
  2. 脚本阻塞渲染:SPA 需加载完完整 JS 才能渲染首屏,JS 解析 / 执行时间过长导致白屏;
  3. 网络传输低效:未启用 CDN、未开启 Gzip,资源传输耗时久;
  4. 缓存未命中:静态资源未设置合理缓存策略,每次请求都重新下载;
  5. 重复请求 / 资源:多入口重复加载相同 JS/CSS/ 图片资源。

二、核心解决方案:分 5 大维度落地优化

维度 1:减小入口文件体积(最立竿见影)

入口文件(如 app.js)是首屏加载的核心瓶颈,需通过「代码分割、剔除冗余、按需加载」大幅减小体积。

1.1 路由懒加载(必做)

将路由按模块分割,实现「首屏只加载当前页面需要的代码」,Vue2/Vue3 通用配置:

Vue3 配置(src/router/index.js
import { createRouter, createWebHashHistory } from 'vue-router'

// 路由懒加载:每个路由对应一个独立 chunk
const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue')
  },
  {
    path: '/about',
    name: 'About',
    component: () => import(/* webpackChunkName: "about" */ '@/views/About.vue')
  }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

export default router
Vue2 配置(src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Home',
      component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue')
    },
    {
      path: '/about',
      name: 'About',
      component: () => import(/* webpackChunkName: "about" */ '@/views/About.vue')
    }
  ]
})

1.2 UI 框架按需加载(必做)

避免一次性引入完整 UI 框架(如 ElementUI、Ant Design Vue),仅引入使用的组件:

Vue3 (Element Plus 按需加载)
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
// 按需引入 Element Plus 组件
import { ElButton, ElInput } from 'element-plus'
import 'element-plus/dist/index.css'

const app = createApp(App)
// 注册需要的组件
app.use(ElButton)
app.use(ElInput)
app.mount('#app')
Vue2 (Element UI 按需加载)
// src/main.js
import Vue from 'vue'
import App from './App.vue'
// 按需引入 Element UI 组件
import { Button, Input } from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'

Vue.use(Button)
Vue.use(Input)
Vue.config.productionTip = false
new Vue({ render: h => h(App) }).$mount('#app')

1.3 移除冗余代码(Webpack 配置)

vue.config.js 中添加配置,自动剔除未使用的代码(Tree Shaking):

// vue.config.js
module.exports = {
  configureWebpack: {
    optimization: {
      usedExports: true, // 开启 Tree Shaking
      splitChunks: { // 代码分割:提取公共代码
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'chunk-vendors',
            priority: -10
          },
          common: {
            name: 'chunk-common',
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true
          }
        }
      }
    }
  }
}

维度 2:静态资源本地缓存(提升二次加载速度)

通过设置浏览器缓存,让用户二次访问时直接读取本地资源,大幅提升加载速度。

2.1 配置 Webpack 输出哈希(必做)

打包时为静态文件添加内容哈希,确保文件更新后浏览器能识别新文件:

// vue.config.js
module.exports = {
  filenameHashing: true, // 开启文件哈希
  outputDir: 'dist',
  assetsDir: 'static'
}

2.2 Nginx 缓存配置(服务端落地)

若使用 Nginx 部署,添加以下配置,设置缓存过期时间:

# 配置静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
    expires 30d; # 缓存 30 天
    add_header Cache-Control "public, max-age=2592000";
    add_header ETag ""; # 禁用 ETag(可选)
}

维度 3:图片资源压缩与优化(减少网络请求耗时)

图片是首屏资源体积的主要贡献者,需通过压缩、懒加载、CDN 优化。

3.1 图片压缩(构建时自动压缩)

使用 image-webpack-loader 压缩图片:

npm install image-webpack-loader --save-dev

配置 vue.config.js

module.exports = {
  chainWebpack: config => {
    config.module
      .rule('images')
      .use('image-webpack-loader')
      .loader('image-webpack-loader')
      .options({
        mozjpeg: { progressive: true, quality: 65 }, // 压缩 JPG
        optipng: { enabled: false }, // 压缩 PNG
        pngquant: { quality: [0.6, 0.8] } // 压缩 PNG
      })
  }
}

3.2 图片懒加载(仅加载可视区域图片)

使用 Vue 官方插件 vue-lazyload

npm install vue-lazyload --save
// src/main.js
import Vue from 'vue'
import VueLazyload from 'vue-lazyload'

Vue.use(VueLazyload, {
  loading: require('@/assets/images/loading.png'), // 加载中占位图
  error: require('@/assets/images/error.png') // 加载失败占位图
})

组件中使用

<template>
  <img v-lazy="imageUrl" alt="懒加载图片" />
</template>

维度 4:开启 Gzip 压缩(大幅减小传输体积)

Gzip 压缩可将 JS/CSS 体积压缩 60%-80%,是提升首屏速度的关键服务端配置。

4.1 Nginx 开启 Gzip(必做)

# 开启 Gzip
gzip on;
# 压缩文件类型
gzip_types text/plain text/css application/javascript application/json application/xml application/rss+xml text/xml text/javascript image/svg+xml;
# 压缩级别(1-9,数值越高压缩率越高,消耗 CPU 越多)
gzip_comp_level 6;
# 仅压缩大于 1k 的文件
gzip_min_length 1024;
# 压缩响应头
gzip_vary on;

4.2 前端构建时生成 Gzip 文件

// vue.config.js
const CompressionWebpackPlugin = require('compression-webpack-plugin')

module.exports = {
  configureWebpack: {
    plugins: [
      new CompressionWebpackPlugin({
        algorithm: 'gzip', // 压缩算法
        test: /\.(js|css|json|svg)$/, // 压缩哪些文件
        threshold: 10240, // 仅压缩大于 10k 的文件
        minRatio: 0.8 // 压缩率小于 0.8 才压缩
      })
    ]
  }
}

维度 5:解决脚本阻塞渲染(让首屏快速显示)

PA 中 JS 加载 / 解析 / 执行会阻塞 DOM 渲染,需通过「预加载、 defer、异步加载」解决。

5.1 关键 CSS 内联(首屏样式直接写入 HTML)

将首屏核心 CSS 内联到 index.html,避免外部 CSS 阻塞渲染:

<!-- public/index.html -->
<head>
  <!-- 内联首屏核心样式 -->
  <style>
    #app { height: 100%; }
    .loading { display: flex; justify-content: center; align-items: center; height: 100%; }
  </style>
</head>

5.2 非关键脚本异步加载

main.js 中,将非核心初始化逻辑(如埋点、第三方统计)异步加载:

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

// 核心初始化逻辑
const app = createApp(App)
app.use(router)
app.mount('#app')

// 非核心逻辑:异步加载(使用 setTimeout 或 import())
setTimeout(() => {
  import('./utils/analytics') // 埋点统计
  import('./utils/third-party') // 第三方 SDK
}, 1000)

总结

SPA 首屏加载优化是工程化 + 服务端 + 前端的协同工作,核心落地步骤如下:

  1. 体积优化:路由懒加载 + UI 按需加载 + 代码分割;
  2. 网络优化:Gzip 压缩 + CDN 加速 + 图片压缩;
  3. 渲染优化:关键 CSS 内联 + 非核心脚本异步;
  4. 缓存优化:文件哈希 + 浏览器缓存;

本文提供的方案完全可落地,从 Webpack 配置到 Nginx 再到前端代码,每一步都有具体可复制的代码,适配 Vue2/Vue3 生态,已在多个生产项目中验证,能彻底解决 SPA 首屏加载慢的问题。

从 Vue2 到 Vue3:语法差异与迁移时最容易懵的点

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、为什么要写这篇文章?

Vue3 已经是官方默认推荐版本,但很多团队的存量项目仍然在 Vue2 上跑。即便你已经开始用 Vue3 了,也很可能是"Options API 的写法 + <script setup> 的壳"——形式换了,思维没换。

这篇文章不讲玄学的底层原理,只讲一个核心问题

日常写代码到底该怎么选、为什么这么选、踩坑会踩在哪?

我们会把 Vue2 的 data / props / computed / methods / watch / 生命周期 和 Vue3 的 Composition API 做一次逐项对照,每一项都给出完整的代码示例和踩坑说明。

二、先建立一个全局视角:Options API vs Composition API

在动手对比之前,先花 30 秒看一张对照表,心里有个全貌:

关注点 Vue2(Options API) Vue3(Composition API / <script setup>
响应式数据 data() ref() / reactive()
接收外部参数 props 选项 defineProps()
计算属性 computed 选项 computed() 函数
方法 methods 选项 普通函数声明
侦听器 watch 选项 watch() / watchEffect()
生命周期 created / mounted … onMounted / onUnmounted …
模板访问 this.xxx 直接用变量名(<script setup> 自动暴露)

一句话总结:Vue2 按"选项类型"组织代码(数据放一块、方法放一块);Vue3 按"逻辑关注点"组织代码(一个功能的数据+方法+侦听可以放在一起)。

三、逐项对比 + 完整示例 + 踩坑点

3.1 响应式数据:data()ref() / reactive()

Vue2 写法

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ user.name }} - {{ user.age }}</p>
    <button @click="add">+1</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      count: 0,
      user: {
        name: '张三',
        age: 25
      }
    }
  },
  methods: {
    add() {
      this.count++
      this.user.age++
    }
  }
}
</script>

Vue2 里一切都挂在 this 上,data() 返回的对象会被 Vue 内部用 Object.defineProperty 做递归劫持,所以你只要 this.count++,视图就会更新。简单粗暴,上手友好。

Vue3 写法(<script setup>

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ user.name }} - {{ user.age }}</p>
    <button @click="add">+1</button>
  </div>
</template>

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

// 基本类型 → 用 ref
const count = ref(0)

// 对象类型 → 用 reactive
const user = reactive({
  name: '张三',
  age: 25
})

function add() {
  count.value++   // ← 注意:ref 在 JS 里要 .value
  user.age++      // ← reactive 对象不需要 .value
}
</script>

踩坑重灾区

坑 1:ref.value 到底什么时候要加?

这是从 Vue2 转过来最高频的困惑,记住一个口诀:

模板里不加,JS 里要加。

<template>
  <!-- 模板中直接用,Vue 会自动解包 -->
  <p>{{ count }}</p>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)

// JS 中必须 .value
console.log(count.value) // 0
count.value++
</script>

为什么模板里不用加?因为 Vue 的模板编译器遇到 ref 时会自动帮你插入 .value,这是编译期的语法糖。但在 <script> 里你是在写原生 JS,Vue 管不到,所以必须手动 .value

坑 2:refreactive 到底选哪个?

这是社区吵了很久的问题。我的实战建议(也是 Vue 官方文档推荐的倾向):

场景 推荐 原因
基本类型(number / string / boolean) ref() reactive() 不支持基本类型
对象/数组,且不会被整体替换 reactive() 不用到处写 .value,更清爽
对象/数组,但可能被整体替换 ref() reactive() 整体替换会丢失响应性
拿不准的时候 ref() 全部用 ref 不会出错,reactive 有限制

坑 3:reactive 的解构陷阱 —— 这个真的会坑到你

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

const user = reactive({ name: '张三', age: 25 })

// ❌ 错误:解构后变量失去响应性!
let { name, age } = user
age++  // 视图不会更新,因为 age 现在只是一个普通的数字 25

// ✅ 正确做法1:不解构,直接用
user.age++

// ✅ 正确做法2:用 toRefs 解构
import { toRefs } from 'vue'
const { name: nameRef, age: ageRef } = toRefs(user)
ageRef.value++  // 视图会更新(注意变成了 ref,需要 .value)
</script>

为什么会这样?因为 reactive 的响应性是挂在对象的属性访问上的(基于 Proxy),一旦你把属性值解构出来赋给一个新变量,那个新变量只是一个普通的 JS 值,和原来的 Proxy 对象已经没有关系了。

坑 4:reactive 整体替换会丢失响应性

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

let state = reactive({ list: [1, 2, 3] })

// ❌ 错误:整体替换,模板拿到的还是旧的那个对象
state = reactive({ list: [4, 5, 6] })  
// 此时模板绑定的引用还指向旧对象,视图不会更新

// ✅ 正确做法1:修改属性而不是替换对象
state.list = [4, 5, 6]  // 这样是OK的

// ✅ 正确做法2:需要整体替换的场景,改用 ref
const state2 = ref({ list: [1, 2, 3] })
state2.value = { list: [4, 5, 6] }  // 没问题,视图正常更新
</script>

这也是我建议"拿不准就用 ref"的原因——ref 不存在这个问题,因为你永远是通过 .value 赋值,Vue 能追踪到。


3.2 Props:props 选项defineProps()

Vue2 写法

<!-- 子组件 UserCard.vue -->
<template>
  <div class="card">
    <h3>{{ name }}</h3>
    <p>年龄:{{ age }}</p>
    <p>是否VIP:{{ isVip ? '是' : '否' }}</p>
  </div>
</template>

<script>
export default {
  props: {
    name: {
      type: String,
      required: true
    },
    age: {
      type: Number,
      default: 18
    },
    isVip: {
      type: Boolean,
      default: false
    }
  },
  mounted() {
    // 通过 this 访问
    console.log(this.name, this.age)
  }
}
</script>
<!-- 父组件中使用 -->
<UserCard name="李四" :age="30" is-vip />

Vue3 写法(<script setup>

<!-- 子组件 UserCard.vue -->
<template>
  <div class="card">
    <h3>{{ name }}</h3>
    <p>年龄:{{ age }}</p>
    <p>是否VIP:{{ isVip ? '是' : '否' }}</p>
  </div>
</template>

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

// defineProps 是编译器宏,不需要 import
const props = defineProps({
  name: {
    type: String,
    required: true
  },
  age: {
    type: Number,
    default: 18
  },
  isVip: {
    type: Boolean,
    default: false
  }
})

onMounted(() => {
  // 不再有 this,直接用 props 对象
  console.log(props.name, props.age)
})
</script>

如果你用 TypeScript,还可以用纯类型声明的写法,更加简洁:

<script setup lang="ts">
interface Props {
  name: string
  age?: number
  isVip?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  age: 18,
  isVip: false
})
</script>

踩坑重灾区

坑 1:defineProps 不需要 import,但 IDE 可能会报红

definePropsdefineEmitsdefineExpose 这些都是编译器宏(compiler macro),在编译阶段就被处理掉了,运行时并不存在。所以不需要 import

如果你的 ESLint 报 'defineProps' is not defined,那是 ESLint 配置问题,需要在 .eslintrc 里配置:

// .eslintrc.js
module.exports = {
  env: {
    'vue/setup-compiler-macros': true
  }
}

或者升级到较新版本的 eslint-plugin-vue(v9+),它默认已经支持了。

坑 2:Props 解构也会丢失响应性(Vue 3.2 及以前)

<script setup>
const props = defineProps({ count: Number })

// ❌ Vue 3.2及以前:解构会丢失响应性
const { count } = props  // count 变成普通值,父组件更新后这里不会变

// ✅ 保持响应性的做法
import { toRefs } from 'vue'
const { count: countRef } = toRefs(props)
// 或者直接用 props.count
</script>

好消息:Vue 3.5+ 引入了响应式 Props 解构(Reactive Props Destructure),如果你的项目版本够新,可以直接解构:

<script setup>
// Vue 3.5+ 可以直接解构,自动保持响应性
const { count = 0 } = defineProps({ count: Number })
// count 是响应式的,可以直接在模板中用
</script>

但如果你的项目还在 3.4 或更早版本上,老老实实用 props.counttoRefs 是最稳的。


3.3 Computed:computed 选项computed() 函数

Vue2 写法

<template>
  <div>
    <p>原价:{{ price }} 元</p>
    <p>折后价:{{ discountedPrice }} 元</p>
    <input v-model="fullName" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      price: 100,
      discount: 0.8,
      firstName: '张',
      lastName: '三'
    }
  },
  computed: {
    // 只读计算属性
    discountedPrice() {
      return (this.price * this.discount).toFixed(2)
    },
    // 可读可写计算属性
    fullName: {
      get() {
        return this.firstName + this.lastName
      },
      set(val) {
        // 假设第一个字是姓,后面是名
        this.firstName = val.charAt(0)
        this.lastName = val.slice(1)
      }
    }
  }
}
</script>

Vue3 写法

<template>
  <div>
    <p>原价:{{ price }} 元</p>
    <p>折后价:{{ discountedPrice }} 元</p>
    <input v-model="fullName" />
  </div>
</template>

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

const price = ref(100)
const discount = ref(0.8)
const firstName = ref('张')
const lastName = ref('三')

// 只读计算属性 —— 传一个 getter 函数
const discountedPrice = computed(() => {
  return (price.value * discount.value).toFixed(2)
})

// 可读可写计算属性 —— 传一个对象
const fullName = computed({
  get: () => firstName.value + lastName.value,
  set: (val) => {
    firstName.value = val.charAt(0)
    lastName.value = val.slice(1)
  }
})
</script>

踩坑重灾区

坑 1:computed 里千万别做"副作用"操作

这条 Vue2 和 Vue3 都一样,但很多人还是会犯:

// ❌ 错误示范:在 computed 里修改别的状态、发请求、操作 DOM
const total = computed(() => {
  otherState.value = 'changed'  // 副作用!
  fetch('/api/log')             // 副作用!
  return items.value.reduce((sum, item) => sum + item.price, 0)
})

// ✅ computed 应该是纯函数,只根据依赖算出一个值
const total = computed(() => {
  return items.value.reduce((sum, item) => sum + item.price, 0)
})

computed 的设计初衷就是"根据已有状态派生出新状态",它有缓存机制——只有依赖变了才重新计算。如果你往里面塞副作用,会导致不可预测的执行时机和执行次数。

坑 2:别把 computed 和 methods 搞混了

Vue2 老手可能觉得"computed 和 method 返回的值不是一样吗",但核心区别是缓存

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

const list = ref([1, 2, 3, 4, 5])

// computed:有缓存,list 不变就不会重新执行
const total = computed(() => {
  console.log('computed 执行了')
  return list.value.reduce((a, b) => a + b, 0)
})

// 普通函数:每次模板渲染都会重新执行
function getTotal() {
  console.log('function 执行了')
  return list.value.reduce((a, b) => a + b, 0)
}
</script>

<template>
  <!-- 假设模板里用了3次 -->
  <p>{{ total }} {{ total }} {{ total }}</p>
  <!-- computed 只会打印1次 log,函数会打印3次 -->
  <p>{{ getTotal() }} {{ getTotal() }} {{ getTotal() }}</p>
</template>

结论:需要缓存、依赖响应式数据派生值的用 computed;需要执行某个动作(点击事件等)的用普通函数。


3.4 Methods:methods 选项 → 普通函数

Vue2 写法

<template>
  <div>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="incrementBy(5)">+5</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script>
export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() {
      this.count++
    },
    incrementBy(n) {
      this.count += n
    },
    reset() {
      this.count = 0
      this.logAction('reset')  // 方法之间互相调用
    },
    logAction(action) {
      console.log(`[${new Date().toLocaleTimeString()}] 执行了: ${action}`)
    }
  }
}
</script>

Vue2 的 methods 是一个选项对象,所有方法平铺在里面,互相调用要通过 this

Vue3 写法

<template>
  <div>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="incrementBy(5)">+5</button>
    <button @click="reset">重置</button>
  </div>
</template>

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

const count = ref(0)

function increment() {
  count.value++
}

function incrementBy(n) {
  count.value += n
}

function logAction(action) {
  console.log(`[${new Date().toLocaleTimeString()}] 执行了: ${action}`)
}

function reset() {
  count.value = 0
  logAction('reset')  // 直接调用,不需要 this
}
</script>

关键差异说明

Vue3 里没有 methods 这个概念了——就是普通的 JavaScript 函数。在 <script setup> 中声明的函数会自动暴露给模板,不需要额外 return。

这带来几个实质性的好处:

  1. 不再需要 this:函数直接闭包引用变量,没有 this 指向问题
  2. 可以用箭头函数:Vue2 的 methods 里不建议用箭头函数(会导致 this 指向错误),Vue3 随意用
  3. 方法可以和相关数据放在一起:不用再在 datamethods 之间跳来跳去
<script setup>
import { ref } from 'vue'

// ———— 计数器相关逻辑 ————
const count = ref(0)
const increment = () => count.value++  // 箭头函数完全OK
const reset = () => (count.value = 0)

// ———— 用户信息相关逻辑 ————
const username = ref('')
const updateUsername = (name) => (username.value = name)
</script>

看到没?数据和操作数据的方法紧挨在一起,按"功能"而不是按"类型"组织。这就是 Composition API 的核心思想——当组件逻辑复杂的时候,不用在 datacomputedmethodswatch 之间反复横跳。


3.5 Watch:watch 选项watch() / watchEffect()

Vue2 写法

<script>
export default {
  data() {
    return {
      keyword: '',
      user: { name: '张三', age: 25 }
    }
  },
  watch: {
    // 基础用法
    keyword(newVal, oldVal) {
      console.log(`搜索词变了:${oldVal} → ${newVal}`)
      this.doSearch(newVal)
    },
    // 深度侦听
    user: {
      handler(newVal) {
        console.log('user 变了', newVal)
      },
      deep: true,
      immediate: true  // 创建时立即执行一次
    }
  },
  methods: {
    doSearch(kw) { /* ... */ }
  }
}
</script>

Vue3 写法

<script setup>
import { ref, reactive, watch, watchEffect } from 'vue'

const keyword = ref('')
const user = reactive({ name: '张三', age: 25 })

// ——— watch:和 Vue2 类似,显式指定侦听源 ———

// 侦听 ref
watch(keyword, (newVal, oldVal) => {
  console.log(`搜索词变了:${oldVal} → ${newVal}`)
  doSearch(newVal)
})

// 侦听 reactive 对象的某个属性(注意:要用 getter 函数)
watch(
  () => user.age,
  (newAge, oldAge) => {
    console.log(`年龄变了:${oldAge} → ${newAge}`)
  }
)

// 侦听整个 reactive 对象(自动深度侦听)
watch(user, (newVal) => {
  console.log('user 变了', newVal)
})

// 加选项:立即执行
watch(keyword, (newVal) => {
  doSearch(newVal)
}, { immediate: true })

// ——— watchEffect:自动收集依赖,不用指定侦听源 ———
watchEffect(() => {
  // 回调里用到了哪些响应式数据,就自动侦听哪些
  console.log(`当前搜索词:${keyword.value},用户:${user.name}`)
})

function doSearch(kw) { /* ... */ }
</script>

watch vs watchEffect 怎么选?

特性 watch watchEffect
需要指定侦听源 否(自动收集依赖)
能拿到 oldValue 不能
默认是否立即执行 否(可设 immediate: true 是(创建时立即执行一次)
适合场景 需要精确控制"侦听谁"、需要新旧值对比 "用到啥就侦听啥",简化写法

我的实战建议:大多数场景用 watch,因为它意图更明确——看代码就知道你在侦听什么。watchEffect 适合那种"把几个数据凑一起做点事、不关心谁变了"的简单场景。

踩坑重灾区

坑 1:侦听 reactive 对象的属性,必须用 getter 函数

const user = reactive({ name: '张三', age: 25 })

// ❌ 错误:直接写 user.age,这只是传了个数字 25 进去
watch(user.age, (val) => { /* 永远不会触发 */ })

// ✅ 正确:传一个 getter 函数
watch(() => user.age, (val) => { console.log(val) })

原因很简单:user.age 在传参时就已经求值了,得到数字 25——一个普通的数字不是响应式的,Vue 没法侦听它。用 () => user.age 则是传了一个函数,Vue 每次执行这个函数时都会触发 Proxy 的 get 拦截,从而建立依赖追踪。

坑 2:watch 的清理——组件卸载后还在跑?

// 在 <script setup> 顶层调用的 watch 会自动与组件绑定
// 组件卸载时自动停止,不用手动处理
watch(keyword, (val) => { /* ... */ })

// 但如果你在异步回调或条件语句里创建 watch,就需要手动停止
let stop
setTimeout(() => {
  stop = watch(keyword, (val) => { /* ... */ })
}, 1000)

// 需要停止时调用
// stop()
</script>

3.6 生命周期:选项式 → 组合式

对照表

Vue2(Options API) Vue3(Composition API) 说明
beforeCreate 不需要(setup 本身就是) <script setup> 的代码就运行在这个时机
created 不需要(setup 本身就是) 同上
beforeMount onBeforeMount() DOM 挂载前
mounted onMounted() DOM 挂载后
beforeUpdate onBeforeUpdate() 数据变了、DOM 更新前
updated onUpdated() DOM 更新后
beforeDestroy onBeforeUnmount() 卸载前(注意改名了!)
destroyed onUnmounted() 卸载后(注意改名了!)

完整示例

<!-- Vue2 -->
<script>
export default {
  data() {
    return { timer: null }
  },
  created() {
    console.log('created: 可以访问数据了')
    this.fetchData()
  },
  mounted() {
    console.log('mounted: DOM 准备好了')
    this.timer = setInterval(() => {
      console.log('tick')
    }, 1000)
  },
  beforeDestroy() {
    clearInterval(this.timer)
    console.log('beforeDestroy: 清理定时器')
  }
}
</script>
<!-- Vue3 -->
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const timer = ref(null)

// <script setup> 中的顶层代码 ≈ created
console.log('setup: 可以访问数据了')
fetchData()

onMounted(() => {
  console.log('onMounted: DOM 准备好了')
  timer.value = setInterval(() => {
    console.log('tick')
  }, 1000)
})

onBeforeUnmount(() => {
  clearInterval(timer.value)
  console.log('onBeforeUnmount: 清理定时器')
})

async function fetchData() { /* ... */ }
</script>

踩坑重灾区

坑 1:beforeDestroyonBeforeUnmount,名字改了!

Vue3 把 destroy 相关的钩子全部改名为 unmount

  • beforeDestroyonBeforeUnmount
  • destroyedonUnmounted

如果你用 Options API 写 Vue3 组件(是的,Vue3 也支持 Options API),那对应的选项名也变了:beforeUnmountunmounted

坑 2:不要在 setup 顶层做 DOM 操作

<script setup>
// ❌ 这里 DOM 还没挂载!
document.querySelector('.my-el')  // null

// ✅ DOM 操作要放到 onMounted 里
import { onMounted } from 'vue'
onMounted(() => {
  document.querySelector('.my-el')  // OK
})
</script>

<script setup> 的顶层代码执行时机等同于 beforeCreate + created,这时候 DOM 还不存在。


3.7 Emits:this.$emit()defineEmits()

Vue2 写法

<!-- 子组件 -->
<script>
export default {
  methods: {
    handleClick() {
      this.$emit('update', { id: 1, name: '新名称' })
      this.$emit('close')
    }
  }
}
</script>

<!-- 父组件 -->
<ChildComponent @update="onUpdate" @close="onClose" />

Vue3 写法

<!-- 子组件 -->
<script setup>
const emit = defineEmits(['update', 'close'])

// 或者带类型校验(TypeScript)
// const emit = defineEmits<{
//   (e: 'update', payload: { id: number; name: string }): void
//   (e: 'close'): void
// }>()

function handleClick() {
  emit('update', { id: 1, name: '新名称' })
  emit('close')
}
</script>

<!-- 父组件(用法不变) -->
<ChildComponent @update="onUpdate" @close="onClose" />

Vue3 要求显式声明组件会触发哪些事件。这不仅仅是规范,还有一个实际好处:Vue3 会把未声明的事件名当作原生 DOM 事件处理。如果你不声明 emits,给组件绑定 @click,这个 click 会直接穿透到子组件的根元素上。

四、一个完整的实战对比:Todo List

最后,用一个麻雀虽小五脏俱全的 Todo List,把上面所有知识点串起来。

Vue2 版本

<template>
  <div class="todo-app">
    <h2>待办清单(共 {{ activeCount }} 项未完成)</h2>
    <div class="input-bar">
      <input
        v-model="newTodo"
        @keyup.enter="addTodo"
        placeholder="输入待办事项..."
      />
      <button @click="addTodo" :disabled="!canAdd">添加</button>
    </div>
    <ul>
      <li v-for="todo in filteredTodos" :key="todo.id">
        <input type="checkbox" v-model="todo.done" />
        <span :class="{ done: todo.done }">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">删除</button>
      </li>
    </ul>
    <div class="filters">
      <button @click="filter = 'all'">全部</button>
      <button @click="filter = 'active'">未完成</button>
      <button @click="filter = 'completed'">已完成</button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      newTodo: '',
      nextId: 1,
      filter: 'all',
      todos: []
    }
  },
  computed: {
    canAdd() {
      return this.newTodo.trim().length > 0
    },
    activeCount() {
      return this.todos.filter(t => !t.done).length
    },
    filteredTodos() {
      if (this.filter === 'active') return this.todos.filter(t => !t.done)
      if (this.filter === 'completed') return this.todos.filter(t => t.done)
      return this.todos
    }
  },
  watch: {
    todos: {
      handler(newTodos) {
        localStorage.setItem('todos', JSON.stringify(newTodos))
      },
      deep: true
    }
  },
  created() {
    const saved = localStorage.getItem('todos')
    if (saved) {
      this.todos = JSON.parse(saved)
      this.nextId = this.todos.length
        ? Math.max(...this.todos.map(t => t.id)) + 1
        : 1
    }
  },
  methods: {
    addTodo() {
      if (!this.canAdd) return
      this.todos.push({
        id: this.nextId++,
        text: this.newTodo.trim(),
        done: false
      })
      this.newTodo = ''
    },
    removeTodo(id) {
      this.todos = this.todos.filter(t => t.id !== id)
    }
  }
}
</script>

Vue3 版本

<template>
  <div class="todo-app">
    <h2>待办清单(共 {{ activeCount }} 项未完成)</h2>
    <div class="input-bar">
      <input
        v-model="newTodo"
        @keyup.enter="addTodo"
        placeholder="输入待办事项..."
      />
      <button @click="addTodo" :disabled="!canAdd">添加</button>
    </div>
    <ul>
      <li v-for="todo in filteredTodos" :key="todo.id">
        <input type="checkbox" v-model="todo.done" />
        <span :class="{ done: todo.done }">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">删除</button>
      </li>
    </ul>
    <div class="filters">
      <button @click="filter = 'all'">全部</button>
      <button @click="filter = 'active'">未完成</button>
      <button @click="filter = 'completed'">已完成</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'

// ———— 状态 ————
const newTodo = ref('')
const filter = ref('all')
const todos = ref([])
let nextId = 1

// ———— 初始化(等同于 created) ————
const saved = localStorage.getItem('todos')
if (saved) {
  todos.value = JSON.parse(saved)
  nextId = todos.value.length
    ? Math.max(...todos.value.map(t => t.id)) + 1
    : 1
}

// ———— 计算属性 ————
const canAdd = computed(() => newTodo.value.trim().length > 0)

const activeCount = computed(() => {
  return todos.value.filter(t => !t.done).length
})

const filteredTodos = computed(() => {
  if (filter.value === 'active') return todos.value.filter(t => !t.done)
  if (filter.value === 'completed') return todos.value.filter(t => t.done)
  return todos.value
})

// ———— 侦听器 ————
watch(todos, (newTodos) => {
  localStorage.setItem('todos', JSON.stringify(newTodos))
}, { deep: true })

// ———— 方法 ————
function addTodo() {
  if (!canAdd.value) return
  todos.value.push({
    id: nextId++,
    text: newTodo.value.trim(),
    done: false
  })
  newTodo.value = ''
}

function removeTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}
</script>

对比两个版本你会发现:模板部分完全一样,变化全在 <script> 里。这也是 Vue3 设计的一个巧妙之处——模板语法几乎没有 breaking change,迁移成本主要在 JS 逻辑层。

五、迁移时的高频"懵圈"清单

最后汇总一下,从 Vue2 迁到 Vue3,最容易懵的点:

序号 懵圈点 一句话解惑
1 ref.value 什么时候加? 模板里不加,JS 里加
2 ref 还是 reactive 拿不准就全用 ref,不会出错
3 reactive 解构丢失响应性 toRefs() 解构,或者不解构
4 this 去哪了? 没有了,<script setup> 里直接用变量和函数
5 defineProps / defineEmits 要 import 吗? 不用,它们是编译器宏
6 beforeDestroy 不生效了? 改名了,叫 onBeforeUnmount
7 created 里的逻辑放哪? 直接写在 <script setup> 顶层
8 watch 侦听 reactive 属性无效? 要用 getter 函数 () => obj.prop
9 watchwatchEffect 选哪个? 大多数场景用 watch,意图更清晰
10 组件暴露方法给父组件怎么办? defineExpose({ methodName })

六、结语

Vue3 的 Composition API 不是为了"炫技"而存在的,它解决的是一个非常现实的问题:当组件逻辑变复杂后,Options API 的代码会像面条一样——数据在上面,方法在下面,watch 在中间,改一个功能要上下反复跳。

Composition API 让你可以按逻辑关注点把代码组织在一起,甚至抽成可复用的 composables(组合式函数),这才是它真正的威力所在。

但说实话,不需要一步到位。Vue3 完全兼容 Options API,你可以:

  1. 新组件用 <script setup> + Composition API
  2. 老组件维护时逐步迁移
  3. 复杂逻辑才抽 composables,简单组件怎么顺手怎么来

技术服务于业务,够用、好维护,就是最好的选择。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

新手引导 intro.js 的使用

1 依赖引入

npm install --save intro.js

2 intro.js的使用

vue3 为例

<template>
  <div id="step1">...</div>
  <div id="step2">...</div>
  <div id="step3">...</div>
</template>

<script setup>
import { onBeforeUnmount, onMounted } from 'vue'
// 引入intro.js相关依赖
import introJs from 'intro.js'
import 'intro.js/introjs.css'

const intro = introJs() // 申明引导

onMounted(() => {
  // 注册引导
  intro.setOptions({
    nextLabel: '下一步',
    prevLabel: '上一步',
    doneLabel: '完成',
    steps: [
      {
        element: document.querySelector('#step1'),
        intro: "这是第一步的描述",
        position: 'bottom'
      },
      {
        element: document.querySelector('#step2'),
        intro: "这是第二步的描述",
        position: 'bottom'
      },
      {
        element: document.querySelector('#step3'),
        intro: "这是第二步的描述",
        position: 'left'
      }
    ]
  })
  intro.start() // 开启引导
})

onBeforeUnmount(() => {
  intro?.exit() // 销毁监听
})
</script>

3 再次唤起引导

引导关闭后发现无法通过 intro.start() 方法再次唤起,因此需要销毁重建。

function openIntro() { // 打开引导方法,可绑定在 “新手引导” 按钮上重复触发
  intro.onExit(() => { // 引导关闭钩子,每次关闭都重新创建引导
    setTimeout(() => { // 手动异步
      intro?.exit() // 销毁
      intro = introJs() // 重构
    }, 10)
  })
  // 注册引导
  intro.setOptions({
    nextLabel: '下一步',
    prevLabel: '上一步',
    doneLabel: '完成',
    steps: [
      {
        element: document.querySelector('#step1'),
        intro: "这是第一步的描述",
        position: 'bottom'
      },
      {
        element: document.querySelector('#step2'),
        intro: "这是第二步的描述",
        position: 'bottom'
      },
      {
        element: document.querySelector('#step3'),
        intro: "这是第二步的描述",
        position: 'left'
      }
    ]
  })
  intro.start() // 开启引导
}

4 集成为公共组件使用

4.1 在 Vue3 作为 hook 使用

import { ref, onBeforeUnmount } from 'vue'
import introJs from 'intro.js'
import 'intro.js/introjs.css'

export function useIntro() {
  const intro = ref(introJs())

  function openIntroWithOptions(options = { steps: [] }) {
      intro.value.onExit(() => { // 每次关闭都重新创建引导器
        setTimeout(() => {
          intro.value?.exit() // 销毁
          intro.value = introJs() // 重构
        }, 10)
      })
      // 注册引导器
      intro.value.setOptions({
        nextLabel: '下一步',
        prevLabel: '上一步',
        doneLabel: '完成',
        ...options
      })
      intro.value.start()
  }

  onBeforeUnmount(() => {
    intro.value?.exit()
  })

  return {
    intro,
    openIntroWithOptions
  }
}

/** 
 * 在页面中使用示例:
 * 1. 引入 useIntro
 * 2. 声明方法
 * 3. 编写引导打开方法
 * 3.1 其中至少配置 steps,由于element要实时获取,所以必须在页面中的方法里实时配置
 * 3.2 如果是页面加载完立即启动引导,可直接在 onMounted 中执行 openIntro 方法内容 */
// import { useIntro } from '@/hooks/intro' // 1.引入
// const { openIntroWithOptions } = useIntro() // 2.声明 
// function openIntro() { // 3.引导打开方法
//   openIntroWithOptions({ // 配置引导options
//     steps: [
//       {
//         element: document.querySelector('#step1'),
//         intro: "这里是待办事项总览",
//         position: 'bottom'
//       },
//       {
//         element: document.querySelector('#step2'),
//         intro: "点击可查看此类目待办事项",
//         position: 'bottom'
//       },
//       {
//         element: document.querySelector('#step3'),
//         intro: "这是待办事项列表",
//         position: 'top'
//       },
//       {
//         element: document.querySelector('#step4'),
//         intro: "点击可前往处理",
//         position: 'left'
//       }
//     ]
//   })
// }

4.2 在 Vue2 作为 mixin 使用

// 引导器mixins
import introJs from 'intro.js'
import 'intro.js/introjs.css'

let intro = introJs()

export default {
  beforeDestroy() {
    intro?.exit() // 销毁监听
  },
  methods: {
    openIntroWithOptions(options = { steps: [] }) { // 打开引导
      intro.onExit(() => { // 每次关闭都重新创建引导器
        setTimeout(() => {
          intro?.exit() // 销毁
          intro = introJs() // 重构
        }, 10)
      })
      // 注册引导器
      intro.setOptions({
        nextLabel: '下一步',
        prevLabel: '上一步',
        doneLabel: '完成',
        ...options
      })
      intro.start()
    }
  }
}

/** 
 * 在页面中使用示例:
 * 1. 引入
 * 2. 申明mixins
 * 3. 在 methods 中写入以下方法
 * 3.1 其中至少配置 steps,由于element要实时获取,所以必须在页面中的方法里实时配置
 * 3.2 如果是页面加载完立即启动引导,可直接在 mounted 中执行 openIntro 方法内容 */
// import intro from '@/mixins/intro' // 1. 引入
// mixins: [intro] // 2. 申明
// openIntro() { // 3. 调用方法
//   this.openIntroWithOptions({ // 配置引导options
//     steps: [
//       {
//         element: document.querySelector('#step1'),
//         intro: "这里是待办事项总览",
//         position: 'bottom'
//       },
//       {
//         element: document.querySelector('#step2'),
//         intro: "点击可查看此类目待办事项",
//         position: 'bottom'
//       },
//       {
//         element: document.querySelector('#step3'),
//         intro: "这是待办事项列表",
//         position: 'top'
//       },
//       {
//         element: document.querySelector('#step4'),
//         intro: "点击可前往处理",
//         position: 'left'
//       }
//     ]
//   })
// }

笔记主要为自用,欢迎友好交流!

深入解析Vue的mixins与hooks:复用逻辑的两种核心方式

在Vue开发中,代码复用是提升开发效率、保证代码一致性的关键。无论是Vue 2时代的mixins,还是Vue 3 Composition API推出后的hooks,都是实现逻辑复用的核心方案,但二者在设计理念、使用方式和适用场景上存在显著差异。本文将从概念、用法、优缺点、区别对比等方面,全面解析mixins与hooks,帮助开发者在实际项目中做出更合适的选择。

一、Vue mixins:Vue 2时代的逻辑复用方案

1.1 什么是mixins?

mixins(混入)是Vue 2中最常用的逻辑复用方式,本质是一个包含组件选项(data、methods、created、computed等)的对象。当一个组件引入mixins后,mixins中的所有选项会被“合并”到该组件自身的选项中,实现逻辑的复用。

简单来说,mixins就像是一个“公共逻辑模板”,可以将多个组件共用的data、方法、生命周期钩子等提取出来,然后在需要的组件中引入,避免重复编码。

1.2 mixins的基本使用

mixins的使用分为两步:定义mixins、在组件中引入mixins。

第一步:定义mixins

创建一个mixins文件(如commonMixins.js),导出一个包含组件选项的对象:

// commonMixins.js
export default {
  data() {
    return {
      count: 0, // 共用的状态
      isLoading: false // 共用的加载状态
    };
  },
  methods: {
    increment() { // 共用的方法
      this.count++;
    },
    showLoading() { // 共用的加载方法
      this.isLoading = true;
    },
    hideLoading() {
      this.isLoading = false;
    }
  },
  created() { // 共用的生命周期钩子
    console.log("mixins created钩子执行");
  }
};

第二步:在组件中引入mixins

在需要复用逻辑的组件中,通过mixins选项引入定义好的mixins:

<template>
  <div>
    <p>计数:{{ count }}</p>
    <button @click="increment">增加</button>
  </div>
</template>

<script>
import commonMixins from './commonMixins.js';

export default {
  mixins: [commonMixins], // 引入mixins,可引入多个(数组形式)
  created() {
    console.log("组件自身created钩子执行");
  }
};
</script>

1.3 mixins的合并规则

当组件自身的选项与mixins中的选项重复时,Vue会按照特定规则进行合并,避免冲突:

  • data选项:组件自身的data会覆盖mixins中的data(如果键名重复),非重复键名会合并。
  • methods、computed、watch选项:组件自身的方法/计算属性/监听器会覆盖mixins中同名的内容,非同名会合并。
  • 生命周期钩子:mixins中的生命周期钩子会先执行,组件自身的钩子后执行(例如mixins的created先执行,组件的created后执行),多个mixins的钩子按引入顺序执行。

1.4 mixins的优缺点

优点

  • 用法简单,无需复杂语法,Vue 2原生支持,学习成本低。
  • 能快速实现多个组件的逻辑复用,减少重复代码,提升开发效率。

缺点

  • 命名冲突:mixins与组件、多个mixins之间容易出现命名冲突,且冲突后排查困难(无法直观看到属性/方法的来源)。
  • 逻辑隐晦:组件引入mixins后,mixins中的逻辑与组件自身逻辑耦合度高,难以追踪逻辑流向,维护成本高(尤其是大型项目,多个mixins嵌套时)。
  • 灵活性差:mixins是“全量合并”,无法按需引入部分逻辑,即使组件只需要mixins中的一个方法,也必须引入整个mixins。
  • 不支持传参:mixins无法接收组件传递的参数,无法根据组件需求动态调整逻辑。

二、Vue hooks:Vue 3 Composition API的逻辑复用方案

2.1 什么是hooks?

hooks(钩子函数)是Vue 3 Composition API推出的全新逻辑复用方案,本质是基于Composition API编写的可复用函数。与mixins的“选项合并”不同,hooks通过“函数调用”的方式,将复用逻辑封装成独立的函数,组件可以按需调用,实现逻辑的“按需复用”。

Vue 3的hooks命名通常以“use”开头(如useCount、useLoading),符合约定俗成的规范,便于识别和维护。hooks的核心思想是“组合式逻辑”,将组件逻辑拆分成多个独立的、可组合的函数,解决了mixins的耦合问题。

2.2 hooks的基本使用

hooks的使用同样分为两步:定义hooks函数、在组件中调用hooks。

第一步:定义hooks函数

创建一个hooks文件(如useCount.js),导出一个函数,函数内部使用Composition API(ref、reactive、onMounted等)封装复用逻辑,并返回需要暴露给组件的状态和方法:

// useCount.js
import { ref } from 'vue';

// 定义hooks函数,可接收参数(实现动态逻辑)
export default function useCount(initialValue = 0) {
  // 封装复用的状态
  const count = ref(initialValue);
  
  // 封装复用的方法
  const increment = () => {
    count.value++;
  };
  
  const decrement = () => {
    count.value--;
  };
  
  // 返回需要暴露给组件的状态和方法
  return {
    count,
    increment,
    decrement
  };
}

第二步:在组件中调用hooks

在组件中导入hooks函数,通过调用函数获取需要的状态和方法,按需使用,无需全量引入:

<template>
  <div>
    <p>计数:{{ count }}</p>
    <button @click="increment">增加</button>
    <button @click="decrement">减少</button>
  </div>
</template>

<script setup>
// 导入hooks函数
import useCount from './useCount.js';

// 调用hooks,可传递参数(初始值为10)
const { count, increment, decrement } = useCount(10);
</script>

2.3 hooks的核心特性

  • 按需复用:组件可以根据需求,调用多个hooks,且每个hooks的逻辑独立,无需引入无关逻辑。
  • 支持传参:hooks函数可以接收组件传递的参数,根据参数动态调整逻辑,灵活性更高。
  • 逻辑清晰:hooks的调用的位置明确,组件中的状态和方法来源可追溯(通过函数调用),避免命名冲突,维护成本低。
  • 组合灵活:多个hooks可以自由组合,一个hooks也可以调用其他hooks,实现复杂逻辑的拆分与复用。
  • 与Composition API无缝衔接:hooks基于ref、reactive、生命周期钩子(onMounted等)编写,完美适配Vue 3的Composition API,符合现代Vue开发理念。

2.4 hooks的优缺点

优点

  • 逻辑独立,耦合度低,可追溯性强,便于维护和调试。
  • 支持按需复用和传参,灵活性远高于mixins。
  • 可自由组合,能轻松实现复杂逻辑的拆分与复用,适合大型项目。
  • 符合Vue 3 Composition API的设计理念,是Vue 3推荐的逻辑复用方案。

缺点

  • 学习成本稍高,需要熟悉Vue 3 Composition API的语法(如ref、reactive、生命周期钩子的使用)。
  • Vue 2中无法直接使用(需配合Composition API插件,但体验不如Vue 3原生支持)。
  • 若hooks设计不合理,可能出现“过度拆分”的问题,导致组件中需要调用多个hooks,增加代码复杂度。

三、mixins与hooks的核心区别对比

对比维度 mixins hooks
本质 包含组件选项的对象 基于Composition API的可复用函数
复用方式 选项合并,全量引入 函数调用,按需引入
命名冲突 易出现冲突,排查困难 无冲突(变量/方法由组件自行接收命名)
灵活性 低,无法传参,不能按需复用 高,支持传参,可按需复用、自由组合
逻辑追溯 差,逻辑隐晦,来源不明确 好,调用位置明确,来源可追溯
Vue版本支持 Vue 2原生支持,Vue 3兼容 Vue 3原生支持,Vue 2需配合插件
适用场景 Vue 2项目、简单逻辑复用、小型项目 Vue 3项目、复杂逻辑复用、大型项目、需动态调整逻辑的场景

四、实际项目中的选择建议

1. 优先使用hooks的场景

  • 使用Vue 3开发的项目(hooks是Vue 3推荐方案,契合Composition API的设计思想)。
  • 逻辑复杂、需要拆分复用的场景(如表单验证、数据请求、状态管理等)。
  • 需要动态调整逻辑(通过传参)、按需复用的场景。
  • 大型项目(hooks的低耦合、可追溯性,能降低维护成本)。

2. 可使用mixins的场景

  • Vue 2项目(无Composition API支持,mixins是最便捷的复用方案)。
  • 简单逻辑的复用(如全局加载状态、简单的计数逻辑),且无需传参。
  • 小型项目(逻辑简单,无需复杂的组合,mixins的简单性更具优势)。

3. 注意事项

  • Vue 3项目中,尽量避免使用mixins,优先使用hooks,避免出现命名冲突和逻辑耦合问题。
  • 如果使用mixins,尽量减少mixins的数量,避免多个mixins嵌套,且给mixins中的属性/方法加上统一前缀(如mixinsCount、mixinsShowLoading),避免命名冲突。
  • 设计hooks时,遵循“单一职责”原则,一个hooks只封装一个核心逻辑,便于复用和维护;同时命名规范(以use开头),提高代码可读性。

五、总结

mixins和hooks都是Vue中实现逻辑复用的重要方案,二者各有优劣,适配不同的开发场景。mixins作为Vue 2时代的主流方案,胜在简单易用,但存在耦合度高、命名冲突等问题;hooks作为Vue 3 Composition API的核心特性,以低耦合、高灵活、可追溯的优势,成为Vue 3项目的首选。

在实际开发中,应根据项目的Vue版本、规模和逻辑复杂度,选择合适的复用方案:Vue 3项目优先使用hooks,Vue 2项目可使用mixins,同时注重代码的规范性和可维护性,让逻辑复用真正提升开发效率,而非增加维护成本。随着Vue生态的发展,hooks已成为现代Vue开发的主流趋势,掌握hooks的使用,能更好地应对复杂项目的开发需求。

Vue3+Element Plus 通用表格组件封装与使用实践

在中后台项目开发中,表格是高频使用的核心组件,基于 Element Plus 的el-table封装通用表格组件,能够统一表格样式、简化重复代码、提升开发效率。本文将详细讲解一款通用表格组件的封装思路、完整实现及使用方式,该组件兼顾了通用性与灵活性,适配日常开发中的各类表格场景。

一、封装思路

本次封装的核心目标是打造一款「基础能力通用化、个性化配置灵活化」的表格组件:

  1. 抽离表格通用配置(如高度、高亮行、合并单元格方法)作为基础 Props;
  2. el-tableel-pagination的原生属性 / 事件通过透传方式交给父组件控制,保留原生组件的灵活性;
  3. 统一列渲染逻辑,支持自定义render函数实现复杂单元格内容展示;
  4. 整合表格标题、分页等常用元素,形成完整的表格模块。

二、通用表格组件完整实现(MineTable.vue)

<template>
    <el-card class="mine-table">
        <!-- 表格标题 -->
        <el-text class="table-name">{{ tableName }}</el-text>
        <!-- 核心表格容器 -->
        <el-table 
            ref="elTable" 
            class="base-table" 
            :highlight-current-row="currentRow" 
            :preserve-expanded-content="true" 
            :span-method="spanMethod"
            :data="data" 
            :height="height"
            v-bind="tableProps"   <!-- 透传el-table原生属性 -->
            v-on="tableEvents"    <!-- 透传el-table原生事件 -->
        >
            <el-table-column 
                v-for="(item, index) in columnsData" 
                :key="index" 
                v-bind="item"      <!-- 透传列配置属性 -->
            >
                <!-- 展开列自定义渲染 -->
                <template v-if="item.type === 'expand'" #default="scope">
                    <component :is="item.render" v-bind="scope"></component>
                </template>
            </el-table-column>
        </el-table>

        <!-- 分页组件 -->
        <el-pagination 
            class="base-pagination" 
            layout="total, sizes, prev, pager, next, jumper"
            :page-sizes="[5, 10, 20, 30, 40, 50]" 
            background
            v-bind="paginationProps"  <!-- 透传el-pagination原生属性 -->
            v-on="paginationEvents"   <!-- 透传el-pagination原生事件 -->
        />
    </el-card>
</template>

<script setup>
import { computed, ref } from "vue"

// 关闭默认属性透传,避免属性泄露到外层DOM节点
defineOptions({
    inheritAttrs: false
})

// 定义组件Props
const props = defineProps({
    // 表格基础配置
    tableName: { type: String, default: "", description: "表格标题" },
    currentRow: { type: Boolean, default: false, description: "是否高亮当前行" },
    height: { type: String, default: "60vh", description: "表格高度" },
    data: { type: Array, default: () => [], description: "表格数据源" },
    columns: { type: Array, default: () => [], description: "列配置项" },
    spanMethod: { type: Function, default: () => {}, description: "单元格合并方法" },
    
    // el-table原生属性透传(支持所有el-table属性)
    tableProps: { type: Object, default: () => ({}) },
    // el-table原生事件透传(支持所有el-table事件)
    tableEvents: { type: Object, default: () => ({}) },
    
    // el-pagination原生属性透传(支持所有el-pagination属性)
    paginationProps: { type: Object, default: () => ({}) },
    // el-pagination原生事件透传(支持所有el-pagination事件)
    paginationEvents: { type: Object, default: () => ({}) },
})

// 暴露表格Ref,方便父组件调用el-table的原生方法
const elTable = ref(null)
defineExpose({ elTable })

// 列数据格式化处理,统一支持render函数渲染
const columnsData = computed(() => {
    return props.columns.map(item => ({
        formatter: (row, column, cellValue, index) => formatter(item, row, column, cellValue, index),
        ...item
    }))
})

// 单元格内容格式化逻辑
const formatter = (item, row, column, cellValue, index) => {
    // 优先级:行数据中的render函数 > 列配置中的render函数 > 默认值
    if (row?.[column.property]?.render) {
        return row[column.property].render(row, column, cellValue, index)
    } else if (item?.render) {
        return item.render(row, column, cellValue, index)
    }
    return row[column.property]
}
</script>

<style lang="scss" scoped>
.mine-table {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    
    .table-name {
        font-size: 18px;
        font-weight: bold;
        margin-bottom: 12px;
        display: flex;
        align-items: center;
        &::after {
            content: "";
            width: 5px;
            height: 100%;
            background-color: var(--el-color-primary);
            margin-right: 12px;
        }
    }

    .base-table {
        width: 100%;
        margin: 0 auto;
        min-width: 0;
        border: var(--el-table-border);
        border-radius: 4px;
    }

    .base-pagination {
        margin-top: 12px;
    }
}
</style>

核心封装点说明

  1. 属性 / 事件透传:通过tableProps/tableEventspaginationProps/paginationEvents分别透传el-tableel-pagination的原生属性与事件,既保留了原生组件的全部能力,又无需在组件内重复定义中转逻辑。
  2. 统一列渲染:封装了formatter函数,支持两种自定义渲染方式 —— 列配置中的render函数、行数据中的render函数,满足复杂单元格的展示需求。
  3. 基础样式整合:内置了表格标题、表格容器、分页的统一样式,无需在业务页面重复编写样式代码。
  4. Ref 暴露:将el-table的 Ref 暴露给父组件,方便调用clearSelectiontoggleRowSelection等原生方法。

三、组件使用示例

1. 基础使用(仅核心配置)

这是最常用的场景,只需配置表格数据、列配置、基础样式即可:

<template>
  <div class="demo-container">
    <!-- 通用表格组件使用 -->
    <MineTable
      height="200px"
      tableName="用户列表"
      :data="tableData"
      :columns="tableColumns"
    />
  </div>
</template>

<script setup>
import { ref } from "vue"
import MineTable from "@/components/MineTable.vue"
import { ElMessage, ElPopconfirm, ElButton, ElText } from "element-plus"

// 表格数据源
const tableData = ref([
  { id: 1, name: "张三", email: "zhangsan@example.com" },
  { id: 2, name: "李四", email: "lisi@example.com" },
  { id: 3, name: "王五", email: "wangwu@example.com" }
])

// 列配置项
const tableColumns = ref([
  { type: "index", label: "序号", width: 80 }, // 序号列
  {
    label: "用户名称",
    prop: "name",
    // 自定义单元格渲染
    render: (row) => <ElText type="primary">{row.name}</ElText>
  },
  {
    label: "操作",
    width: 100,
    // 操作列:带确认弹窗的删除按钮
    render: (row) => {
      const deleteUser = () => {
        // 模拟删除逻辑
        tableData.value = tableData.value.filter(item => item.id !== row.id)
        ElMessage.success(`已删除用户:${row.name}`)
      }

      return (
        <ElPopconfirm 
          title="确定删除吗?" 
          onConfirm={deleteUser}
          confirmButtonText="确定" 
          cancelButtonText="取消"
          v-slots={{
            reference: () => <ElButton type="danger" size="small" link>删除</ElButton>
          }}
        />
      )
    }
  }
])
</script>

<style scoped>
.demo-container {
  width: 800px;
  margin: 20px auto;
}
</style>

2. 进阶使用(透传原生属性 / 事件)

如果需要使用el-tableel-pagination的原生能力(如斑马纹、行点击事件、分页回调等),可通过透传 Props 实现:

<template>
  <div class="demo-container">
    <MineTable
      height="300px"
      tableName="用户列表"
      :data="tableData"
      :columns="tableColumns"
      <!-- 透传el-table原生属性 -->
      :table-props="{
        border: true,        // 显示表格边框
        stripe: true,        // 斑马纹效果
        showHeader: true     // 显示表头
      }"
      <!-- 透传el-table原生事件 -->
      :table-events="{
        'row-click': (row) => ElMessage.info(`点击了${row.name}的行`), // 行点击事件
        'sort-change': (val) => console.log('排序变更:', val)       // 排序变更事件
      }"
      <!-- 透传el-pagination原生属性 -->
      :pagination-props="{
        currentPage: 1,      // 当前页码
        pageSize: 10,        // 每页条数
        total: 100           // 总条数
      }"
      <!-- 透传el-pagination原生事件 -->
      :pagination-events="{
        'size-change': (size) => console.log('每页条数变更:', size), // 页大小变更
        'current-change': (page) => console.log('页码变更:', page)   // 页码变更
      }"
    />
  </div>
</template>

四、总结

本次封装的通用表格组件具备以下特点:

  1. 通用性强:整合了表格标题、分页等常用元素,统一了基础样式和渲染逻辑;
  2. 灵活性高:通过属性 / 事件透传,保留了 Element Plus 原生组件的全部能力,适配各类个性化需求;
  3. 易用性好:使用方式简洁,基础场景只需配置数据和列,进阶场景可透传原生属性 / 事件;
  4. 可扩展:在此基础上可进一步扩展空状态、加载状态、列宽自适应等通用能力,适配更多业务场景。

该组件能够有效减少中后台项目中表格相关的重复代码,提升开发效率,同时保持了足够的灵活性,满足不同业务场景的个性化需求。

Diff算法基础:同层比较与key的作用

在上一篇文章中,我们深入探讨了 patch 算法的完整实现。今天,我们将聚焦于 Diff 算法的核心思想——为什么需要它?它如何工作?key 又为什么如此重要?通过这篇文章,我们将彻底理解 Diff 算法的基础原理。

前言:从生活中的例子理解Diff

想象一下,假如我们有一排积木:

A B C D

然后我们想把它变成这样:

A C D B

这时,我们应该怎么做呢?

  • 方式一:全部推倒重来:移除所有,按照我们想要的顺序重新摆放

  • 方式二:只调整变化的部分:移动位置,替换积木,即:我们只需要调整 B C D 三块积木的位置即可。

很显然,方式二的做法更高效。这就是 Diff 算法的本质——找出最小化的更新方案。

为什么需要 Diff 算法?

没有 Diff 算法会怎样?

假设我们有一个简单的列表:

<!-- 旧列表 -->
<ul>
  <li>苹果</li>
  <li>香蕉</li>
  <li>橙子</li>
</ul>

<!-- 新列表(只改了最后一个) -->
<ul>
  <li>苹果</li>
  <li>香蕉</li>
  <li>橘子</li>
</ul>

上述两个列表中,新列表只改了最后一项数据,如果没有 Diff 算法,我们只能按照 前言 中的方式一处理:删除整个 ul,重新创建:

const oldUl = document.querySelector('ul');
oldUl.remove();

const newUl = document.createElement('ul');
newUl.innerHTML = `
  <li>苹果</li>
  <li>香蕉</li>
  <li>橘子</li>
`;
container.appendChild(newUl);

这种方式虽然可以解决问题,但存在很大的风险:

  1. 性能极差:即使只改一个字,也要重建整个 DOM 树
  2. 状态丢失:输入框内容、滚动位置都会丢失
  3. 浪费资源:创建了大量不必要的 DOM 节点

此时 Diff 算法的重要性就凸显出来了!

Diff 算法的目标

Diff 算法的核心目标可以概括为三点:

  1. 尽可能复用已有节点
  2. 只更新变化的部分
  3. 最小化 DOM 操作

还是以上述 ul 结构为例,理想中的 Diff 操作应该是:

  1. 更新第三个 li 的文本内容:将 <li>橙子</li> 替换成 <li>橘子</li>
  2. 其他节点完全复用,不作任何更改

传统 Diff 算法

function diff(oldList, newList){
  for(let i = 0; i < oldList.length; i++){
    for(let j = 0; j < newList.length; j++){
      if(oldList[i] === newList[j]){
        // 找到相同的节点,进行复用
        console.log('找到了相同的节点', oldList[i]);
        break;
      } else {
        // 没找到相同的节点,进行新增
        console.log('需要新增节点', newList[j]);
      }
    }
  }
}

上述代码的时间复杂度为:O(n²);如果再考虑到移动、删除、新增等操作,其时间复杂度可以达到:O(n³)。这显然是不合理的。

同层比较的核心思想

为了解决传统 Diff 算法的时间复杂度问题,Vue 团队通过两个关键思想,将 Diff 算法的时间复杂降低到了:O(n):

  1. 同层比较,即只比较同一层级的节点
  2. 类型相同,即不同类型节点直接替换

什么是同层比较?

同层比较的意思是:只比较同一层级的节点,不跨层级移动。 我们来看一个简单的例子: 同层比较 上图两个新旧 VNode 树中,对比过程是这样的: 同层比较示例图

为什么不跨层级比较?

我们可以再来一个更复杂的示例:

<!-- 旧列表 -->
<ul>
  <li>li-1</li>
  <li>li-2</li>
  <li>
    <span>
      <a>
        li-3
      </a>
    </span>
  </li>
</ul>

<!-- 新列表 -->
<ul>
  <li>li-1</li>
  <li>li-2</li>
  <li>
    <a>
      li-3
    </a>
  </li>
</ul>

假设新旧两个列表是这样的,如果支持跨层级比较和移动,那么上述列表应该进行如下操作:

  1. 发现旧列表中 a 标签位于 span 标签下,新列表中直接位于 li 标签下;
  2. 记录这个操作差异,保存 a 标签,删除 span 标签,再把 a 标签挂载到 li 标签下;
  3. 更新父子节点关系。

这种操作会让算法变得极其复杂,而且实际开发中,跨层级移动节点的情况非常罕见。所以 Vue 选择简化问题:如果节点跨层级了,就视为不同类型,直接替换。

function patch(oldVNode, newVNode) {
  // 如果节点类型不同,直接替换
  if (oldVNode.type !== newVNode.type) {
    unmount(oldVNode);
    mount(newVNode);
    return;
  }
  
  // 同类型节点,进行深度比较
  patchChildren(oldVNode, newVNode);
}

同层比较的优势

优势 说明 示例
算法简单 只需要比较同一层 树形结构简化为线性比较
性能可控 复杂度O(n) 1000个节点只需比较1000次
实现可靠 边界情况少 不需要处理复杂移动

key在节点复用中的作用

为什么需要key?

我们来看一个简单的代办列表:

<!-- 旧列表 -->
<li>学习Vue</li>
<li>写文章</li>
<li>休息一下</li>

<!-- 新列表(删除了中间项 写文章) -->
<li>学习Vue</li>
<li>休息一下</li>

如果没有 key,Vue 会如何进行 diff 比较呢:

  1. 比较位置0:都是"学习Vue",直接复用;
  2. 比较位置1:旧的是"写文章",新的是"休息一下" ,更新文本进行替换
  3. 比较位置2:旧的有"休息一下",新的没有,则删除

这样操作过程中,更新了一个 li 的文本,删除了一个 li 。 这个过程看起来是没有问题的,但是如果上述列表有状态呢?

<!-- 带输入框的列表 -->
<li>
  <input value="学习Vue" />
  学习Vue
</li>
<li>
  <input value="写文章" />
  写文章
</li>
<li>
  <input value="休息一下" />
  休息一下
</li>

<!-- 删除中间项后 -->
<li>
  <input value="学习Vue" />  <!-- 输入框内容被保留了 -->
  学习Vue
</li>
<li>
  <input value="休息一下" />  <!-- 这里会是"休息一下"吗? -->
  休息一下
</li>

这时候问题就出现了:输入框的内容被错误地复用了!由于没有 key 的情况下,Vue 只按位置比较,最后的实际结果是:

<li>
  <input value="学习Vue" />  <!-- 输入框内容被保留了 -->
  学习Vue
</li>
<li>
  <input value="写文章" />  <!-- label变成了"写文章" -->
  休息一下
</li>

这个例子也同样解释了为什么不推荐,或者说不能用 index 作为 key 的原因。正确的做法是使用唯一的、稳定的标识作为 key。

key的作用图解

key的作用可以这样理解: key的作用图解

手写实现:简单Diff算法

class SimpleDiff {
  constructor(options) {
    this.options = options;
  }
  
  /**
   * 执行diff更新
   * @param {Array} oldChildren 旧子节点数组
   * @param {Array} newChildren 新子节点数组
   * @param {HTMLElement} container 父容器
   */
  diff(oldChildren, newChildren, container) {
    // 1. 创建key到索引的映射(如果有key)
    const oldKeyMap = this.createKeyMap(oldChildren);
    const newKeyMap = this.createKeyMap(newChildren);
    
    // 2. 记录已处理的节点
    const processed = new Set();
    
    // 3. 第一轮:尝试复用有key的节点
    this.patchKeyedNodes(oldChildren, newChildren, oldKeyMap, newKeyMap, processed, container);
    
    // 4. 第二轮:处理剩余节点
    this.processRemainingNodes(oldChildren, newChildren, processed, container);
  }
  
  /**
   * 创建key到索引的映射
   */
  createKeyMap(children) {
    const map = new Map();
    for (let i = 0; i < children.length; i++) {
      const child = children[i];
      if (child.key != null) {
        map.set(child.key, i);
      }
    }
    return map;
  }
  
  /**
   * 处理有key的节点
   */
  patchKeyedNodes(oldChildren, newChildren, oldKeyMap, newKeyMap, processed, container) {
    // 遍历新节点
    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i];
      
      // 如果新节点没有key,跳过第一轮处理
      if (newVNode.key == null) continue;
      
      // 尝试在旧节点中找相同key的节点
      const oldIndex = oldKeyMap.get(newVNode.key);
      
      if (oldIndex !== undefined) {
        const oldVNode = oldChildren[oldIndex];
        
        // 标记为已处理
        processed.add(oldIndex);
        
        // 执行patch更新
        this.patchVNode(oldVNode, newVNode, container);
      } else {
        // 没有找到对应key,说明是新增节点
        this.mountVNode(newVNode, container);
      }
    }
  }
  
  /**
   * 处理剩余节点
   */
  processRemainingNodes(oldChildren, newChildren, processed, container) {
    // 1. 卸载未处理的旧节点
    for (let i = 0; i < oldChildren.length; i++) {
      if (!processed.has(i)) {
        this.unmountVNode(oldChildren[i]);
      }
    }
    
    // 2. 挂载新节点中未处理的节点
    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i];
      
      // 如果没有key或者key不在旧节点中,需要挂载
      if (newVNode.key == null) {
        this.mountVNode(newVNode, container);
      } else {
        const oldIndex = oldChildren.findIndex(old => old.key === newVNode.key);
        if (oldIndex === -1) {
          this.mountVNode(newVNode, container);
        }
      }
    }
  }
  
  /**
   * 更新节点
   */
  patchVNode(oldVNode, newVNode, container) {
    console.log(`更新节点: ${oldVNode.key || '无key'}`);
    
    // 复用DOM元素
    newVNode.el = oldVNode.el;
    
    // 更新属性
    this.updateProps(newVNode.el, oldVNode.props, newVNode.props);
    
    // 更新子节点
    if (newVNode.children !== oldVNode.children) {
      newVNode.el.textContent = newVNode.children;
    }
  }
  
  /**
   * 挂载新节点
   */
  mountVNode(vnode, container) {
    console.log(`挂载新节点: ${vnode.key || '无key'}`);
    
    // 创建DOM元素
    const el = document.createElement(vnode.type);
    vnode.el = el;
    
    // 设置属性
    this.updateProps(el, {}, vnode.props);
    
    // 设置内容
    if (vnode.children) {
      el.textContent = vnode.children;
    }
    
    // 插入到容器
    container.appendChild(el);
  }
  
  /**
   * 卸载节点
   */
  unmountVNode(vnode) {
    console.log(`卸载节点: ${vnode.key || '无key'}`);
    if (vnode.el && vnode.el.parentNode) {
      vnode.el.parentNode.removeChild(vnode.el);
    }
  }
  
  /**
   * 更新属性
   */
  updateProps(el, oldProps = {}, newProps = {}) {
    // 移除不存在的属性
    for (const key in oldProps) {
      if (!(key in newProps)) {
        el.removeAttribute(key);
      }
    }
    
    // 设置新属性
    for (const key in newProps) {
      if (oldProps[key] !== newProps[key]) {
        el.setAttribute(key, newProps[key]);
      }
    }
  }
}

// 创建VNode的辅助函数
function h(type, props = {}, children = '') {
  return {
    type,
    props,
    key: props.key,
    children,
    el: null
  };
}

结语

理解 Diff 算法的基础原理,就像掌握了Vue 更新 DOM 的"思维模式"。知道它如何思考、如何决策,才能写出与框架配合最好的代码。

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

patch算法:新旧节点的比对与更新

在前面的文章中,我们深入探讨了虚拟 DOM 的创建和组件的挂载过程。当数据变化时,Vue 需要高效地更新 DOM。这个过程的核心就是 patch 算法——新旧虚拟 DOM 的比对与更新策略。本文将带你深入理解 Vue3 的 patch 算法,看看它如何以最小的代价完成 DOM 更新。

前言:为什么需要patch?

想象一下,你有一个展示用户列表的页面。当某个用户的名字改变时,我们会怎么做?

  • 粗暴方式:重新渲染整个列表(性能差)
  • 聪明方式:只更新那个改变的用户名(性能好)

patch 算法就是 Vue 采用的"聪明方式"。它的核心思想是:找出新旧 VNode 的差异,只更新变化的部分,而不是重新渲染整个 DOM 树:

patch 过程图

patch函数的核心逻辑

patch的整体架构

patch 函数是整个更新过程的总调度器,它根据节点类型分发到不同的处理函数:

function patch(oldVNode, newVNode, container, anchor = null) {
  // 如果是同一个引用,无需更新
  if (oldVNode === newVNode) return;
  
  // 如果类型不同,直接替换
  if (oldVNode && !isSameVNodeType(oldVNode, newVNode)) {
    unmount(oldVNode);
    oldVNode = null;
  }
  
  const { type, shapeFlag } = newVNode;
  
  // 根据类型分发处理
  switch (type) {
    case Text:
      processText(oldVNode, newVNode, container, anchor);
      break;
    case Comment:
      processComment(oldVNode, newVNode, container, anchor);
      break;
    case Fragment:
      processFragment(oldVNode, newVNode, container, anchor);
      break;
    case Static:
      processStatic(oldVNode, newVNode, container, anchor);
      break;
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(oldVNode, newVNode, container, anchor);
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(oldVNode, newVNode, container, anchor);
      } else if (shapeFlag & ShapeFlags.TELEPORT) {
        processTeleport(oldVNode, newVNode, container, anchor);
      } else if (shapeFlag & ShapeFlags.SUSPENSE) {
        processSuspense(oldVNode, newVNode, container, anchor);
      }
  }
}

patch 的分发流程图

patch的分发流程图

判断节点类型的关键:isSameVNodeType

function isSameVNodeType(n1, n2) {
  // 比较类型和key
  return n1.type === n2.type && n1.key === n2.key;
}

为什么需要key?

我们看看下面的例子:

<!-- 旧列表 -->
<li key="a">A</li>
<li key="b">B</li>
<li key="c">C</li>

<!-- 新列表 -->
<li key="a">A</li>
<li key="c">C</li>
<li key="b">B</li>

<!-- 有key: 只移动节点,不重新创建 -->
<!-- 无key: 全部重新创建,性能差 -->

不同类型节点的处理策略

文本节点的处理

文本节点是最简单的节点类型,处理逻辑也最直接:

function processText(oldVNode, newVNode, container, anchor) {
  if (oldVNode == null) {
    // 首次挂载
    const textNode = document.createTextNode(newVNode.children);
    newVNode.el = textNode;
    container.insertBefore(textNode, anchor);
  } else {
    // 更新
    const el = (newVNode.el = oldVNode.el);
    if (newVNode.children !== oldVNode.children) {
      // 只有文本变化时才更新
      el.nodeValue = newVNode.children;
    }
  }
}

文本节点更新过程

文本节点更新过程

注释节点的处理

注释节点基本不需要更新,因为用户通常不关心注释的变化:

function processComment(oldVNode, newVNode, container, anchor) {
  if (oldVNode == null) {
    const commentNode = document.createComment(newVNode.children);
    newVNode.el = commentNode;
    container.insertBefore(commentNode, anchor);
  } else {
    // 注释节点很少变化,直接复用
    newVNode.el = oldVNode.el;
  }
}

元素节点的处理

元素节点的更新是最复杂的,需要处理属性和子节点:

function processElement(oldVNode, newVNode, container, anchor) {
  if (oldVNode == null) {
    // 首次挂载
    mountElement(newVNode, container, anchor);
  } else {
    // 更新
    patchElement(oldVNode, newVNode);
  }
}

function patchElement(oldVNode, newVNode) {
  const el = (newVNode.el = oldVNode.el);
  
  // 1. 更新props
  patchProps(el, oldVNode.props, newVNode.props);
  
  // 2. 更新children
  patchChildren(oldVNode, newVNode, el);
}

function patchProps(el, oldProps, newProps) {
  oldProps = oldProps || {};
  newProps = newProps || {};
  
  // 移除旧props中不存在于新props的属性
  for (const key in oldProps) {
    if (!(key in newProps)) {
      patchProp(el, key, oldProps[key], null);
    }
  }
  
  // 添加或更新新props
  for (const key in newProps) {
    const old = oldProps[key];
    const next = newProps[key];
    if (old !== next) {
      patchProp(el, key, old, next);
    }
  }
}

子节点的比对策略

子节点的比对是 patch 算法中最复杂、也最关键的部分。Vue3 根据子节点的类型,采用不同的策略。

子节点类型组合的处理策略

下表总结了所有可能的子节点类型组合及对应的处理方式:

旧子节点 新子节点 处理策略 示例
文本 文本 直接替换文本内容 "old" → "new"
文本 数组 清空文本,挂载数组 "text" → [vnode1, vnode2]
文本 清空文本 "text" → null
数组 文本 卸载数组,设置文本 [vnode1, vnode2] → "text"
数组 数组 执行核心diff [a,b,c] → [a,d,e]
数组 卸载所有子节点 [a,b,c] → null
文本 设置文本 null → "text"
数组 挂载数组 null → [a,b,c]

当新旧节点都为数组时,需要执行 diff 算法,diff 算法的内容在后面的文章中会专门介绍。

Fragment和Text节点的特殊处理

Fragment的处理

Fragment 是 Vue3 新增的节点类型,用于支持多根节点:

function processFragment(oldVNode, newVNode, container, anchor) {
  if (oldVNode == null) {
    // 首次挂载
    mountFragment(newVNode, container, anchor);
  } else {
    // 更新
    patchFragment(oldVNode, newVNode, container, anchor);
  }
}

function mountFragment(vnode, container, anchor) {
  const { children, shapeFlag } = vnode;
  
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 文本子节点:挂载为文本节点
    const textNode = document.createTextNode(children);
    vnode.el = textNode;
    container.insertBefore(textNode, anchor);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 数组子节点:挂载所有子节点
    mountChildren(children, container, anchor);
    
    // 设置el和anchor
    vnode.el = children[0]?.el;
    vnode.anchor = children[children.length - 1]?.el;
  }
}

function patchFragment(oldVNode, newVNode, container, anchor) {
  const oldChildren = oldVNode.children;
  const newChildren = newVNode.children;
  
  // Fragment本身没有DOM,直接patch子节点
  patchChildren(oldVNode, newVNode, container);
  
  // 更新el和anchor
  if (Array.isArray(newChildren)) {
    newVNode.el = newChildren[0]?.el || oldVNode.el;
    newVNode.anchor = newChildren[newChildren.length - 1]?.el || oldVNode.anchor;
  }
}

文本节点的优化

Vue3 对纯文本节点做了特殊优化,避免不必要的 VNode 创建:

// 模板:<div>{{ message }}</div>
// 编译后:
function render(ctx) {
  return h('div', null, ctx.message, PatchFlags.TEXT);
}

// 在patch过程中:
if (newVNode.patchFlag & PatchFlags.TEXT) {
  // 只需要更新文本内容,不需要比较其他属性
  const el = oldVNode.el;
  if (newVNode.children !== oldVNode.children) {
    el.textContent = newVNode.children;
  }
  newVNode.el = el;
  return;
}

手写实现:完整的patch函数基础版本

基础工具函数

// 类型标志
const ShapeFlags = {
  ELEMENT: 1,
  FUNCTIONAL_COMPONENT: 1 << 1,
  STATEFUL_COMPONENT: 1 << 2,
  TEXT_CHILDREN: 1 << 3,
  ARRAY_CHILDREN: 1 << 4,
  SLOTS_CHILDREN: 1 << 5,
  TELEPORT: 1 << 6,
  SUSPENSE: 1 << 7,
  COMPONENT_SHOULD_KEEP_ALIVE: 1 << 8,
  COMPONENT_KEPT_ALIVE: 1 << 9
};

// 特殊节点类型
const Text = Symbol('Text');
const Comment = Symbol('Comment');
const Fragment = Symbol('Fragment');

// 判断是否同类型节点
function isSameVNodeType(n1, n2) {
  return n1.type === n2.type && n1.key === n2.key;
}

完整的patch函数

class Renderer {
  constructor(options) {
    this.options = options;
  }
  
  patch(oldVNode, newVNode, container, anchor = null) {
    if (oldVNode === newVNode) return;
    
    // 处理不同类型的节点
    if (oldVNode && !isSameVNodeType(oldVNode, newVNode)) {
      this.unmount(oldVNode);
      oldVNode = null;
    }
    
    const { type, shapeFlag } = newVNode;
    
    // 根据类型分发
    switch (type) {
      case Text:
        this.processText(oldVNode, newVNode, container, anchor);
        break;
      case Comment:
        this.processComment(oldVNode, newVNode, container, anchor);
        break;
      case Fragment:
        this.processFragment(oldVNode, newVNode, container, anchor);
        break;
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          this.processElement(oldVNode, newVNode, container, anchor);
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          this.processComponent(oldVNode, newVNode, container, anchor);
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
          this.processTeleport(oldVNode, newVNode, container, anchor);
        }
    }
  }
  
  processElement(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      // 挂载
      this.mountElement(newVNode, container, anchor);
    } else {
      // 更新
      this.patchElement(oldVNode, newVNode);
    }
  }
  
  mountElement(vnode, container, anchor) {
    const { type, props, children, shapeFlag } = vnode;
    
    // 创建元素
    const el = this.options.createElement(type);
    vnode.el = el;
    
    // 设置属性
    if (props) {
      for (const key in props) {
        this.options.patchProp(el, key, null, props[key]);
      }
    }
    
    // 处理子节点
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      this.options.setElementText(el, children);
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      this.mountChildren(children, el);
    }
    
    // 插入
    this.options.insert(el, container, anchor);
  }
  
  patchElement(oldVNode, newVNode) {
    const el = (newVNode.el = oldVNode.el);
    const oldProps = oldVNode.props || {};
    const newProps = newVNode.props || {};
    
    // 更新属性
    this.patchProps(el, oldProps, newProps);
    
    // 更新子节点
    this.patchChildren(oldVNode, newVNode, el);
  }
  
  patchChildren(oldVNode, newVNode, container) {
    const oldChildren = oldVNode.children;
    const newChildren = newVNode.children;
    
    const oldShapeFlag = oldVNode.shapeFlag;
    const newShapeFlag = newVNode.shapeFlag;
    
    // 新子节点是文本
    if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
      if (oldShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.unmountChildren(oldChildren);
      }
      if (oldChildren !== newChildren) {
        this.options.setElementText(container, newChildren);
      }
    }
    // 新子节点是数组
    else if (newShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      if (oldShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        this.options.setElementText(container, '');
        this.mountChildren(newChildren, container);
      } else if (oldShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.patchKeyedChildren(oldChildren, newChildren, container);
      }
    }
    // 新子节点为空
    else {
      if (oldShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.unmountChildren(oldChildren);
      } else if (oldShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        this.options.setElementText(container, '');
      }
    }
  }
  
  processText(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const textNode = this.options.createText(newVNode.children);
      newVNode.el = textNode;
      this.options.insert(textNode, container, anchor);
    } else {
      const el = (newVNode.el = oldVNode.el);
      if (newVNode.children !== oldVNode.children) {
        this.options.setText(el, newVNode.children);
      }
    }
  }
  
  processFragment(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      this.mountFragment(newVNode, container, anchor);
    } else {
      this.patchFragment(oldVNode, newVNode, container, anchor);
    }
  }
  
  mountFragment(vnode, container, anchor) {
    const { children, shapeFlag } = vnode;
    
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      const textNode = this.options.createText(children);
      vnode.el = textNode;
      this.options.insert(textNode, container, anchor);
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      this.mountChildren(children, container, anchor);
      vnode.el = children[0]?.el;
      vnode.anchor = children[children.length - 1]?.el;
    }
  }
  
  mountChildren(children, container, anchor) {
    for (let i = 0; i < children.length; i++) {
      this.patch(null, children[i], container, anchor);
    }
  }
  
  unmount(vnode) {
    const { shapeFlag, el } = vnode;
    
    if (shapeFlag & ShapeFlags.COMPONENT) {
      this.unmountComponent(vnode);
    } else if (shapeFlag & ShapeFlags.FRAGMENT) {
      this.unmountFragment(vnode);
    } else if (el) {
      this.options.remove(el);
    }
  }
}

Vue2 与 Vue3 的 patch 差异

核心差异对比表

特性 Vue2 Vue3 优势
数据劫持 Object.defineProperty Proxy Vue3可以监听新增/删除属性
编译优化 全量比较 静态提升 + PatchFlags Vue3跳过静态节点比较
diff算法 双端比较 最长递增子序列 Vue3移动操作更少
Fragment 不支持 支持 多根节点组件
Teleport 不支持 支持 灵活的DOM位置控制
Suspense 不支持 支持 异步依赖管理
性能 中等 优秀 Vue3更新速度提升1.3-2倍

PatchFlags 带来的优化

Vue3 通过 PatchFlags 标记动态内容,减少比较范围:

const PatchFlags = {
  TEXT: 1,           // 动态文本
  CLASS: 2,          // 动态class
  STYLE: 4,          // 动态style
  PROPS: 8,          // 动态属性
  FULL_PROPS: 16,    // 全量props
  HYDRATE_EVENTS: 32, // 事件
  STABLE_FRAGMENT: 64, // 稳定Fragment
  KEYED_FRAGMENT: 128, // 带key的Fragment
  UNKEYED_FRAGMENT: 256, // 无key的Fragment
  NEED_PATCH: 512,   // 需要非props比较
  DYNAMIC_SLOTS: 1024, // 动态插槽
  
  HOISTED: -1,       // 静态节点
  BAIL: -2           // 退出优化
};

结语

理解 patch 算法,就像是掌握了 Vue 更新 DOM 的"手术刀"。知道它如何精准地找到需要更新的部分,以最小的代价完成更新,这不仅能帮助我们写出更高效的代码,还能在遇到性能问题时快速定位和优化。

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

“啪啪啪”三下键盘,极速拉起你的 uni-app 项目!

说实话,我也不想造轮子。但试了一圈之后,我发现了一个让我忍不了的问题:选了不要某个功能,生成的代码里居然还有它的 import 和空壳文件。 与其花半小时手动删代码,不如用 hy-uni —— 三下键盘,1 秒钟搞定!


🚫 那些年,我们新建项目后手动删过的代码

如果你经常用社区的高分脚手架创建项目,一定会遇到这个进退两难的死胡同:

  • 官方模板太"毛坯":API 拦截器、状态管理全要自己从 0 开始配。新手直接劝退。

  • 社区模板太"精装":不仅送你一堆组件,还送你几个业务全景页。新建项目第一件事,就是花半小时去删那些不需要的页面和 npm 包。最痛苦的是,删的时候还得提心吊胆,生怕漏删了某个 import 导致整个项目一跑就白屏报错。

第 21 次从头搭项目时,我终于受不了了。于是,我过年时花了点时间写了 hy-uni


🎯 先说结论:三下键盘,极速拉起项目

一条命令,三下键盘,1 秒钟,带给你一个干干净净的、随时可进入业务开发的工业级 uni-app 项目:

# ⚡ 极速拉起纯净骨架(1 秒钟)
npx hy-uni my-app --pure
# 或者 📋 交互式精装配置(30 秒内完成)
npx hy-uni my-app

核心理念:你不要的功能,连一行代码、一段注释、一个 npm 依赖,都不该出现在最终的产物中。


⚡ 速度对比(为什么说"极速"?)

方案 时间 特点
hy-uni --pure ⚡ 1 秒 三下键盘极速拉起纯净骨架
hy-uni (交互) 📋 30 秒 选择功能后自动生成完整项目
官方脚手架 5 分钟+ 毛坯房,需要自己配置工程化
社区全量模板 10 分钟+ 功能全但冗余,需要手动删代码

关键对比:hy-uni 不仅快,而且不用删代码 —— 你不选的功能从代码到依赖全部消失。


💻 极客最爱的"双轨"构建体验

很多老手开发者拥有"代码洁癖",喜欢毫无业务代码的"极净空壳";也有很多开发者希望项目能"满级出生",自带网络请求和主题切换方案。

在这款 CLI 中,我们将选择权完全交还给你。

路线 A:极速构建"极致纯净"空壳(老手狂喜)

对于只想要**"帮我把工程化基建搭好,其他的我自己来"**的极客,你只需在命令后敲入一个 --pure 参数:


npx hy-uni my-app --pure

啪啪啪三下键盘,敲下回车,1秒钟静默生成。 没有任何繁琐的交互问答选项,你将直接获得一个强迫症狂喜的极净项目:

  • 只有基础工程化体系:Vue 3 + TypeScript + Vite + UnoCSS + Pinia 开箱即用。

  • 没有任何网络请求、主题切换、业务示例等多余代码。

  • 目录结构极其纯粹,没有多余的文件夹。

路线 B:交互式精装配置(开箱即用)

如果不加 --pure,CLI 则会提供完全可定制的丝滑交互面板:


┌ 🚀 火叶 - 快速创建高性能 uni-app 项目
│
● 模板来源: 缓存 (~/.huoye/templates/) [2天前更新]
│
◇ 请输入项目名称:
│ my-app
│
◇ 请选择创建路径:
│ ./demo
│
◇ 是否需要网络请求层?
│ ○ Yes / ● No
│
◆ 是否需要业务示例页面?
│ ○ Yes / ● No
│
◆ 是否需要主题管理?
│ ○ Yes / ● No
│
◆ 确认创建项目?
│ ● Yes / ○ No
│
◇ 🎉 恭喜!您的项目已准备就绪。
│
◇ Getting Started  ─────────╮
│                           │
│ $ cd demo/my-app          │
│ $ pnpm install            │
│ $ pnpm dev:h5             │
│                           │
├───────────────────────────╯

此时,选择全选 Yes 的你,将获得一个"满级配置"项目:

  • 封装极佳的 Http 客户端、请求拦截器体系及全局错误分类处理机制。

  • 完善的亮暗色主题无缝切换落地方案及 CSS 变量体系。

最硬核的是:无论你是走纯净路线还是全选路线,生成的项目 App.vuemain.ts 以及 package.json 中的所有代码,都会像你自己手写的一般融洽,没有任何一点"被暴力注销掉"的痕迹。

💡 温馨提示:三个功能之间有依赖关系。"业务示例页面"依赖"网络请求层"——因为示例必须有 API 封装才能跑起来。所以如果你不选"网络请求层",CLI 就不会问你要不要"业务示例"。这样设计是为了保证生成的项目永远可以直接运行,没有任何破碎的依赖关系。


💡 三种使用场景速查

我想要 命令 适合谁
极速纯净空壳 npx hy-uni my-app --pure 有代码洁癖的老手,想自己搭业务
交互式精装配置 npx hy-uni my-app 想要完整方案,但不想要冗余代码
本地开发版本 npx hy-uni my-app --local 项目贡献者,想用最新开发模板

📂 看看生成出来的项目差异

路线 A 生成结果(--pure)

my-app/
├── src/
│ ├── pages/
│ │ ├── index/index.vue
│ │ └── about/about.vue
│ ├── layouts/default.vue
│ ├── store/index.ts
│ ├── utils/
│ │ ├── platform.ts
│ │ ├── system.ts
│ │ ├── data.ts
│ │ └── time.ts
│ ├── style/
│ └── static/
├── vite.config.ts
├── tsconfig.json
└── package.json ← 只有基础依赖

路线 B 生成结果(全选)


my-app/
├── src/
│ ├── pages/
│ │ ├── index/index.vue
│ │ ├── about/about.vue
│ │ ├── theme/ ← 新增
│ │ └── examples/ ← 新增
│ │ ├── api-demo.vue
│ │ ├── form-demo.vue
│ │ └── list-demo.vue
│ ├── api/ ← 新增
│ │ ├── client.ts
│ │ ├── interceptors.ts
│ │ ├── errors.ts
│ │ └── modules/
│ ├── composables/
│ │ └── useTheme.ts ← 新增
│ ├── config/
│ │ └── theme.ts ← 新增
│ ├── components/
│ │ └── ThemeToggle.vue ← 新增
│ ├── store/
│ │ ├── theme.ts ← 新增
│ │ ├── index.ts
│ │ └── modules/
│ │ ├── app.ts
│ │ └── counter.ts ← 新增
│ ├── layouts/default.vue
│ ├── utils/
│ ├── style/
│ └── static/
├── vite.config.ts
├── tsconfig.json
└── package.json ← 完整的依赖列表

对比一目了然 —— 不选就是真的没有,不是"注释掉"。


🛠️ 不只是干净:开箱即用的重型工程底座

不管你怎么选裁剪,hy-uni 都为你提供了工业级的开发体验,包含了 7 个 Vite 核心插件的自动装配:

插件 作用
vite-plugin-uni-pages 页面自动路由生成
vite-plugin-uni-layouts 布局系统搭建
vite-plugin-uni-manifest manifest 编程化配置
vite-plugin-uni-components 组件按需自动导入
unplugin-auto-import Vue / uni-app API 自动导入
UnoCSS 原子化极速 CSS 构建
mp-selector-transform 小程序选择器兼容隔离转换

这意味着,创建完项目后:

  • 你不需要手动导入 refonMounted

  • 你不需要手动去繁琐的 pages.json 注册页面和组件。

  • 路径别名 @/src/ 已全部打通。

  • 开发体验直接拉满。


✨ 你到底能得到什么?

基础工程化(所有项目都有)

  • Vue 3 + TypeScript —— 类型安全,开发爽

  • Vite 5 —— 毫秒级热更新,极速开发

  • 7 个 Vite 插件 —— 页面自动路由、组件自动导入、manifest 编程化配置等,全配好

  • UnoCSS —— 按需生成原子化 CSS,再也不用手写 class

  • Pinia 状态管理 —— 开箱即用的持久化存储(适配小程序)

  • ESLint + TypeScript 类型检查 —— 代码规范自动化

可选功能 1:网络请求层

选了它,项目会多出完整的 src/api/ 目录:

import { get, post } from "@/api"
// GET 请求,自动拼接 params
const users = await get("/users", { page: 1, limit: 10 })
// POST 请求
const result = await post("/users", { name: "张三", age: 25 })

你获得了什么:

  • HTTP 客户端(基于 uni.request,支持 GET/POST/PUT/DELETE/PATCH)

  • 请求/响应/错误拦截器(自动注入 Token、处理超时等)

  • 7 种自定义错误分类(网络、超时、鉴权、权限等)

  • 跨平台兼容(H5 / 小程序 / App 无缝切换)

  • 完整的 API 模块化示例

不选它? src/api/ 目录根本不存在,package.json 里也没任何相关依赖。干干净净。

可选功能 2:主题管理

选了它,你就能这样用:

<script setup>
import { useTheme } from "@/composables/useTheme"
const { isDark, themeStore } = useTheme()
</script>
<template>
<button @click="themeStore.toggleTheme()">
{{ isDark ? "切换到亮色" : "切换到暗色" }}
</button>
</template>

你获得了什么:

  • 亮色/暗色/跟随系统 三种主题模式

  • 8 种预设主色调,可自定义

  • 20+ CSS 变量自动注入

  • 多端适配(H5 用 CSS 变量、小程序用全局事件、App 用状态栏同步)

  • 主题切换组件 + 完整的设置页面

不选它? 上面所有文件全部消失。布局组件里的主题代码也会被移除,替换成一个固定的 background-color: #f8f8f8 —— 不是留空,而是提供正确的 fallback。

可选功能 3:业务示例页面

选了它(需要先选网络请求层),你会得到 3 个完整的业务演示:

  • API 调用演示 —— 列表获取、详情查看、数据创建的完整流程

  • 表单演示 —— 输入、选择、复选、日期选择器,带表单验证

  • 列表演示 —— 上拉加载、下拉刷新、搜索过滤的完整实现

这不是 "Hello World",每个页面都是可以直接拿来改改就用的业务代码

不选它? 这些示例页面全部消失,首页上的导航入口也会一起消失(不会留下死链接)。


⚙️ 底层揭秘:如何做到代码级无痕裁剪?

一般的脚手架提供的是"多套模板分支组合"。而 hy-uni 创新性地引入了 "特征标记系统 (Feature Markers)",实现了一份源码,2^N 种自由组合引擎

我们在架构底层源码中,巧妙地隐藏了特定的注释标记:

1. 单行精确抹除

如果在 CLI 里没选 examples 示例功能,下面带有 // 【examples】 标记的代码行,会从物理层面直接消失:

export * from "./modules/app"
export { useCounterStore } from "./modules/counter" // 【examples】

2. 块级区域剥离(支持多语言环境)

如果没选 theme 主题功能,被包裹的代码块整块剥离(支持 TS、SCSS、Vue 甚至 HTML 注释):

<!-- 【theme:start】 -->
<view class="nav-link" @click="goToPage('/pages/theme')">
    <text>主题设置</text>
</view>
<!-- 【theme:end】 -->

3. 独门绝技:反向兜底(Fallback)裁剪

这是市面上其他脚手架极难做到的技术细节。针对"如果不选某个高阶模块,我仍然需要保留一套写死的基础兜底代码"的场景,我们设计了 ! 反向保留标记:


.layout {
    // 【!theme:start】 (如果没选动态主题,就保留这段写死的极简灰色背景)
    background-color: #f8f8f8;
    // 【!theme:end】

    // 【theme:start】 (如果选了主题,才保留动态的 CSS 变量注入机制)
    background-color: var(--bg-color-primary);
    transition: background-color 0.3s;
    // 【theme:end】
}

正是这套底层切割引擎,加上我们对 npm 依赖 dependencies 的按树剥离,以及支持功能间的链式感知(不支持底层功能时不展示进阶询问逻辑),才铸就了极致纯净的代码产物质量。


🔧 进阶:把它变成你们团队的专属黑科技

"这套裁剪逻辑不错,但我司有祖传架构,我单纯想白嫖这套神级裁剪引擎怎么办?"

完全没问题。整个脚手架能力是靠底层模板根目录的 .templaterc.json 驱动的:

{
"features": {
    "auth": {
           "name": "权限管理",
           "files": ["src/store/user.ts"],
           "dependencies": ["jwt-decode"]
        }
    }
}

结合在你的祖传代码里打上好 // 【auth】 标记,你就可以把 hy-uni 当作你们内部团队私有化的高阶脚手架来直接复用!

(剧透:在这个大版本之后,我们将正式支持 hy-uni template add 命令,允许你直接接管并挂载任意外部 Git 仓库,搭建你的私有定制生态!)


🚀 立即体验(极速拉起只需 3 个命令)

别再对着一堆乱糟糟的精装房一筹莫展了:

# 极速纯净版
npx hy-uni my-app --pure

创建后的常用命令

cd my-app
pnpm install

# 开发命令
pnpm dev:h5 # H5 本地开发(localhost:3000)
pnpm dev:mp # 微信小程序开发
pnpm dev:app # App 开发

# 构建命令
pnpm build:h5 # H5 生产构建
pnpm build:mp # 小程序构建

# 检查命令
pnpm lint # ESLint 检查 + 自动修复
pnpm type-check # TypeScript 类型检查


📊 跟现有方案对比

官方模板 社区全量模板 hy-uni
创建后能直接开发 ❌ 需要自己搭 ✅ 能,但要先删一堆 ✅ 开箱即用
功能选择 ❌ 无 ❌ 无 / 模板分支 ✅ 交互式按需选择
不要的功能 N/A ⚠️ 自己删(怕误删) ✅ 从代码到依赖全清理
生成代码质量 空壳 ⚠️ 可能有残留 ✅ 零残留,像手写的
模板维护成本 ⚠️ 高(N 个分支) ✅ 低(1 份模板)
极速纯净模式 --pure 1秒钟

🔗 获取地址(直达阵地)

核心源码不到 500 行,没有任何冗余包装。如果你也是代码洁癖患者,恰好懂我对极致整洁的坚持,欢迎来给我点一个宝贵的 Star!使用中发现任何 Bug,随时 Issue 见!


📌 总结

hy-uni

  • 我只想要骨架--pure 1秒钟搞定,零冗余

  • 我想要完整方案 → 交互式选择,按需组合

  • 我想要纯净但有示例 → 选 API + 示例,不选主题

  • 我想用自己的模板 → 即将支持,用我们的引擎

核心理念:你不要的功能,连一行代码都不该出现。


🚀 现在就试试


npx hy-uni my-app

让我们一起告别"删文件夹"的时代。

Electron 无边框窗口拖拽实现

Electron 无边框窗口拖拽实现详解:从问题到完美解决方案

技术栈: Electron 40+, Vue 3.5+, TypeScript 5.9+

🎯 问题背景

在开发 Electron 无边框应用时,遇到一个经典问题:如何让用户能够拖拽移动整个窗口?

传统的 Web 应用有浏览器标题栏可以拖拽,但 Electron 的无边框窗口 (frame: false) 完全去除了系统原生的标题栏,这就需要我们自己实现拖拽功能。

挑战

  1. 右键误触发: 用户右键点击时窗口也会跟着移动
  2. 定时器泄漏: 鼠标抬起后窗口仍在跟随鼠标
  3. 事件覆盖不全: 忘记处理鼠标离开窗口等边界情况
  4. 性能问题: 频繁的位置计算导致卡顿
  5. 安全考虑: 如何在保持功能的同时确保 IPC 通信安全

本文将从零开始,实现一个 Electron 窗口拖拽解决方案。

🛠️ 技术方案概览

我们采用 渲染进程 + IPC + 主进程 的三层架构:

[Vue 组件][IPC 安全通道][Electron 主进程][窗口控制]

核心优势:

  • ✅ 精确区分鼠标左/右键
  • ✅ 完整的事件生命周期管理
  • ✅ 内存安全(无定时器泄漏)
  • ✅ 安全的 IPC 通信
  • ✅ 流畅的用户体验(60fps)

🔧 详细实现步骤

第一步:主进程窗口配置

首先确保你的 Electron 窗口正确配置为无边框模式:

// electron/main/index.ts
const win = new BrowserWindow({
  title: 'Main window',
  frame: false,           // 关键:禁用系统标题栏
  transparent: true,      // 透明窗口(可选)
  backgroundColor: '#00000000', // 完全透明
  width: 288,
  height: 364,
  webPreferences: {
    preload: path.join(__dirname, '../preload/index.mjs'),
  }
})

第二步:预加载脚本 - 安全的 IPC 桥梁

使用 contextBridge 安全地暴露 API 给渲染进程:

// electron/preload/index.ts
import { ipcRenderer, contextBridge } from 'electron'

contextBridge.exposeInMainWorld('ipcRenderer', {
  // ... 其他 IPC 方法
  
  // 暴露窗口拖拽控制方法
  windowMove(canMoving: boolean) {
    ipcRenderer.invoke('windowMove', canMoving)
  }
})

为什么这样做?

  • 避免直接暴露完整的 ipcRenderer
  • 限制可调用的方法范围
  • 符合 Electron 安全最佳实践

第三步:主进程拖拽逻辑

创建专门的拖拽工具函数:

// electron/main/utils/windowMove.ts
import { screen } from "electron";

// 全局定时器引用 - 关键!
let movingInterval: NodeJS.Timeout | null = null;

export default function windowMove(
  win: Electron.BrowserWindow | null, 
  canMoving: boolean
) {
  let winStartPosition = { x: 0, y: 0 };
  let mouseStartPosition = { x: 0, y: 0 };

  if (canMoving && win) {
    // === 启动拖拽 ===
    console.log("main start moving");
    
    // 记录起始位置
    const winPosition = win.getPosition();
    winStartPosition = { x: winPosition[0], y: winPosition[1] };
    mouseStartPosition = screen.getCursorScreenPoint();
    
    // 清理已存在的定时器 - 防止重复
    if (movingInterval) {
      clearInterval(movingInterval);
      movingInterval = null;
    }
    
    // 启动位置更新定时器 (20ms ≈ 50fps)
    movingInterval = setInterval(() => {
      const cursorPosition = screen.getCursorScreenPoint();
      
      // 相对位移算法
      const x = winStartPosition.x + cursorPosition.x - mouseStartPosition.x;
      const y = winStartPosition.y + cursorPosition.y - mouseStartPosition.y;
      
      // 更新窗口位置
      win.setResizable(false); // 拖拽时禁止调整大小
      win.setBounds({ x, y, width: 288, height: 364 }); // 使用setBounds 同时设置位置和宽高防止拖动过程窗口变大,宽高可动态获取
    }, 20);
    
  } else {
    // === 停止拖拽 ===
    console.log("main stop moving");
    
    // 清理定时器
    if (movingInterval) {
      clearInterval(movingInterval);
      movingInterval = null;
    }
    
    // 恢复窗口状态
    if (win) {
      win.setResizable(true);
    }
  }
}

关键设计点:

  1. 全局定时器: movingInterval 声明在模块级别,确保能被正确清理
  2. 相对位移算法: 基于起始位置的相对移动,避免累积误差
  3. 防重复机制: 每次启动前清理已有定时器
  4. 窗口状态管理: 拖拽时禁用调整大小,结束后恢复

第四步:渲染进程事件处理

在 Vue 组件中精确处理鼠标事件:

<!-- src/App.vue -->
<script setup lang="ts">
import Camera from './components/Camera.vue'

// 调用主进程拖拽方法
const windowMove = (canMoving: boolean): void => {
  window?.ipcRenderer?.windowMove(canMoving);
}

// 只有左键按下时才开始移动
const handleMouseDown = (e: MouseEvent) => {
  if (e.button === 0) { // 0 = 左键, 1 = 中键, 2 = 右键
    windowMove(true);
  }
}

// 鼠标抬起时停止移动(任何按键)
const handleMouseUp = () => {
  windowMove(false);
}

// 鼠标离开容器时停止移动
const handleMouseLeave = () => {
  windowMove(false);
}

// 右键菜单处理 - 关键!
const handleContextMenu = (e: MouseEvent) => {
  e.preventDefault(); // 阻止默认右键菜单
  windowMove(false);  // 确保停止拖拽
}
</script>

<template>
  <div class="app-container" 
       @mousedown="handleMouseDown" 
       @mouseleave="handleMouseLeave"
       @mouseup="handleMouseUp" 
       @contextmenu="handleContextMenu">
    <Camera />
  </div>
</template>

鼠标按键值参考:

  • e.button === 0: 左键 (Left click)
  • e.button === 1: 中键 (Middle click)
  • e.button === 2: 右键 (Right click)

第五步:主进程 IPC 处理器

注册 IPC 处理器并集成拖拽逻辑:

// electron/main/index.ts
import windowMove from './utils/windowMove'

// ... 其他代码 ...

// 注册 IPC 处理器
ipcMain.handle("windowMove", (_, canMoving) => {
  console.log('ipcMain.handle windowMove', canMoving)
  windowMove(win, canMoving)
})

🔒 安全最佳实践

1. IPC 方法限制

// 好的做法:只暴露必要方法
contextBridge.exposeInMainWorld('ipcRenderer', {
  windowMove: (canMoving) => ipcRenderer.invoke('windowMove', canMoving)
})

// 避免:暴露完整 ipcRenderer
// contextBridge.exposeInMainWorld('ipcRenderer', ipcRenderer)

2. 输入验证

// 在主进程中验证输入
if (typeof canMoving !== 'boolean') {
  throw new Error('Invalid parameter');
}

3. 窗口引用安全

// 始终检查窗口是否存在
if (!win || win.isDestroyed()) {
  return;
}

🔄 替代方案对比

方案 A: CSS -webkit-app-region: drag (推荐用于简单场景)

.drag-area {
  -webkit-app-region: drag;
}

优点: 零 JavaScript,硬件加速,无 IPC 开销
缺点: 无法区分鼠标按键,会阻止所有鼠标事件

方案 B: 完整的自定义拖拽 (本文方案)

优点: 完全可控,支持复杂交互,可区分按键
缺点: 需要 IPC 通信,代码量较大

选择建议

  • 简单应用: 使用 CSS 方案
  • 复杂交互: 使用本文的自定义方案
  • 混合方案: 在非交互区域使用 CSS,在需要精确控制的区域使用自定义方案

💡 扩展功能思路

1. 拖拽区域限制

// 限制窗口不能拖出屏幕
const bounds = screen.getDisplayNearestPoint(cursorPosition).bounds;
const newX = Math.max(bounds.x, Math.min(x, bounds.x + bounds.width - windowWidth));
const newY = Math.max(bounds.y, Math.min(y, bounds.y + bounds.height - windowHeight));

2. 拖拽动画效果

// 拖拽开始时添加阴影
win.webContents.executeJavaScript(`
  document.body.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)';
`);

// 拖拽结束时移除
win.webContents.executeJavaScript(`
  document.body.style.boxShadow = 'none';
`);

3. 多显示器支持

// 获取所有显示器信息
const displays = screen.getAllDisplays();
// 根据当前显示器调整拖拽行为

📚 完整项目结构

electron-camera/
├── electron/
│   ├── main/
│   │   ├── utils/windowMove.ts    # 拖拽核心逻辑
│   │   └── index.ts               # 主进程入口
│   └── preload/index.ts           # IPC 安全桥梁
└── src/
    └── App.vue                    # 渲染进程事件处理

🤝 总结

通过本文的完整实现,你将获得一个:

  • 功能完整 的窗口拖拽解决方案
  • 安全可靠 的 IPC 通信架构
  • 性能优秀 的用户体验
  • 易于维护 的代码结构

这个方案已经在实际项目中经过充分测试,可以直接用于你的 Electron 应用开发。

在实现过程中发现还有一个好的库,github.com/Wargraphs/e… 有空可以试试。

如果你觉得这篇文章对你有帮助,请点赞、收藏或分享给其他开发者!

有任何问题或改进建议,欢迎在评论区讨论! 🚀

仅仅一行 CSS,竟让 2000 个节点的页面在弹框时卡成 PPT?

【哲风壁纸】可爱玩偶-地面落叶.png

前言

在最近的一个会议室排期系统(类似甘特图)的性能优化中,我遇到了一个诡异的现象:页面初始化非常流畅,但在点击“详情”打开 el-dialog 弹框时,遮罩层的渐入动画极其卡顿,掉帧感严重。

我原本以为是 DOM 节点过多(约 2000 个)导致 Vue 响应式数据更新太慢。但在排查过程中,我发现罪魁祸首竟然是一行看似为了“设计感”而存在的 CSS 属性:mix-blend-mode: multiply;

1. 现象描述:消失的帧率

我们的系统在横轴(时间)和纵轴(会议室)的交叉网格中渲染了大量的“档期卡片”。为了让卡片的背景色能和底部的网格线、文字有更好的融合感,代码中使用了 CSS 混合模式:

.card-bg {
  position: absolute;
  /* 混合模式:正片叠底 */
  mix-blend-mode: multiply; 
  background-color: #e6f7ff;
}

当页面只有几十个节点时,一切正常。但当展示 6 周数据,节点数达到 2000+  时,每当点击打开 el-dialog,浏览器就像陷入了泥潭。


2. 核心原因:混合模式背后的渲染逻辑

为什么 mix-blend-mode 会成为性能杀手?这要从浏览器的渲染机制说起。

A. 像素级重算(Pixel-by-Pixel Calculation)

常规的 background-color 渲染非常简单:浏览器只需要知道这个像素点的 RGB 值,直接涂色即可。

但 mix-blend-mode 不同。它要求浏览器执行 CSS Compositing(层叠组合)  规范。以 multiply(正片叠底)为例,浏览器渲染每一个像素点时,必须执行以下公式:

C=Cs×Cb255C = \frac{C_s \times C_b}{255}
  • CsC_s:当前图层颜色值(source)
  • CbC_b:底层背景颜色值(background)
  • CC:混合后的颜色值

这意味着,浏览器在绘制这 2000 个节点时,不能简单地“涂色”,而是必须先读取底层网格、文字、背景的颜色,再进行数学计算,最后输出结果。

B. 强制创建堆叠上下文(Stacking Context)

一旦元素应用了 mix-blend-mode(且值不为 normal),浏览器会强制该元素及其子元素创建一个新的堆叠上下文

在 2000 个节点上同时开启混合模式,会创建 2000 个 stacking context,并可能触发额外的合成层管理和 GPU 参与。这极大地消耗了显存和合成器的性能。

C. “弹框卡顿”的终极诱因:图层合成爆炸

这是最关键的一点。当你打开 el-dialog 时:

  1. el-dialog 会带有一个全屏的半透明遮罩层(Overlay)。
  2. 遮罩层在做淡入淡出动画(Opacity Animation)。
  3. 连锁反应:因为下方 2000 个节点都具有混合属性,它们对“背景”及其敏感。当上方的遮罩层颜色或透明度发生变化时,浏览器认为下方所有节点的“最终成色”都可能受到影响,从而被迫在动画的每一帧中,对这 2000 个节点进行全量的混合重计算和重绘。

渲染引擎在每一秒内要进行几十万次的像素乘法运算,GPU 瞬间满载,动画自然就变成了 PPT。


3. 解决方案:返璞归真

解决办法出奇地简单:移除混合模式,改用传统的透明色。

/* 优化前 */
.card-bg {
  mix-blend-mode: multiply;
  background-color: #e6f7ff;
}

/* 优化后 */
.card-bg {
  /* 移除混合模式 */
  /* 使用带透明度的 rgba 或者直接指定固定色值 */
  background-color: rgba(230, 247, 255, 0.8);
}

通过这一行代码的改动,浏览器不再需要读取背景像素进行乘法运算,节点被归类为普通渲染任务。再次打开 el-dialog,遮罩层的动画恢复到了丝滑的 60 FPS。


4. 经验总结:避开 CSS 的渲染陷阱

在构建高密度数据看板或复杂网格系统时,我们需要警惕以下这些“昂贵”的 CSS 属性:

  1. mix-blend-mode:在大量节点上使用是性能灾难。
  2. filter (如 blur(), drop-shadow()) :同样涉及复杂的卷积运算和像素偏移计算。
  3. box-shadow:特别是带有扩散半径的大面积阴影,会显著增加重绘成本。

视觉设计固然重要,但在节点过千的 B 端系统中,性能优先。  很多时候,通过预先计算好颜色值(如将混合后的颜色直接写死为 HEX),不仅能达到 90% 的视觉相似度,更能换来 100% 的交互流畅度。


如何在Vue3中优化生命周期钩子性能并规避常见陷阱?

一、Vue3 生命周期钩子基础回顾

1.1 生命周期钩子的核心作用

Vue3 组件从创建到销毁会经历一系列标准化阶段,生命周期钩子就是在这些阶段触发的回调函数,让开发者能在特定时机注入自定义逻辑。比如:

  • onMounted:组件首次渲染完成、DOM 节点创建后执行,适合初始化第三方库、获取DOM元素或发起初始数据请求。
  • onUpdated:组件响应式数据更新导致DOM重新渲染后执行,可用于处理更新后的DOM操作。
  • onUnmounted:组件从DOM中卸载前执行,用于清理资源(如定时器、事件监听)防止内存泄漏。

所有钩子的this上下文默认指向当前组件实例,但需注意不能使用箭头函数声明钩子,否则会丢失this指向。

1.2 正确的钩子注册方式

<script setup>中注册钩子的标准写法:

<script setup>
import { onMounted, onUnmounted } from 'vue'

// 同步注册钩子(必须在setup执行栈内同步调用)
onMounted(() => {
  console.log('组件已挂载,可操作DOM')
})

onUnmounted(() => {
  console.log('组件即将卸载,清理资源')
})
</script>

⚠️ 错误示例:异步注册钩子会失效

// 错误:setTimeout异步调用导致钩子无法关联当前组件实例
setTimeout(() => {
  onMounted(() => { /* 此回调不会执行 */ })
}, 100)

二、性能优化策略:让生命周期钩子更高效

2.1 onMounted:聚焦初始化必要操作

onMounted是组件初始化的关键节点,但需避免在此执行冗余逻辑:

  • 优化点1:合并重复DOM操作,避免频繁重排重绘
  • 优化点2:延迟非关键初始化(如非首屏必需的第三方库)到用户交互后
  • 优化点3:批量发起数据请求,减少网络开销

示例:按需加载第三方图表库

<script setup>
import { onMounted, ref } from 'vue'
const chartRef = ref(null)

onMounted(async () => {
  // 首屏优先渲染,延迟加载非关键库
  const { Chart } = await import('chart.js')
  new Chart(chartRef.value, { /* 配置项 */ })
})
</script>
<template>
  <canvas ref="chartRef"></canvas>
</template>

2.2 onUpdated:避免不必要的重复执行

onUpdated会在每次数据更新后触发,若处理不当极易引发性能问题:

  • 优化点1:用watch替代onUpdated监听特定数据变化,避免全局更新触发冗余逻辑
  • 优化点2:添加条件判断,仅在目标数据变化时执行操作
  • 优化点3:避免在onUpdated中修改响应式数据(会触发无限循环更新)
往期文章归档
免费好用的热门在线工具

示例:用watch替代onUpdated实现精准监听

<script setup>
import { ref, watch } from 'vue'
const tableData = ref([])

// 仅在tableData变化时执行表格重绘,而非每次组件更新都执行
watch(tableData, (newData) => {
  console.log('表格数据更新,执行重绘逻辑')
  // 调用表格重绘方法
}, { deep: true })
</script>

2.3 onUnmounted:及时清理资源防止泄漏

组件卸载时必须清理所有外部资源,否则会导致内存泄漏:

  • 清理定时器/间隔器
  • 移除DOM事件监听
  • 取消数据订阅(如WebSocket、RxJS流)
  • 销毁第三方库实例

示例:完整的资源清理流程

<script setup>
import { onMounted, onUnmounted } from 'vue'
let timer = null
let resizeHandler = null

onMounted(() => {
  timer = setInterval(() => {
    console.log('定时任务执行中...')
  }, 1000)

  resizeHandler = () => {
    console.log('窗口大小变化')
  }
  window.addEventListener('resize', resizeHandler)
})

onUnmounted(() => {
  // 清理定时器
  clearInterval(timer)
  // 移除事件监听
  window.removeEventListener('resize', resizeHandler)
})
</script>

2.4 合理选择钩子:用组合式API替代传统钩子

Vue3的组合式API允许将相关逻辑聚合,减少钩子中的碎片化代码。比如:

  • watchEffect替代onMounted + onUnmounted组合,自动处理依赖清理
  • computed替代onUpdated中的重复计算

示例:watchEffect自动清理资源

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

watchEffect((onInvalidate) => {
  const timer = setInterval(() => {
    console.log('定时任务')
  }, 1000)

  // 组件卸载或依赖变化时自动执行清理
  onInvalidate(() => {
    clearInterval(timer)
  })
})
</script>

三、常见陷阱与规避方案

3.1 箭头函数导致的this指向错误

陷阱:用箭头函数声明钩子,导致this无法指向组件实例

// 错误示例
onMounted(() => {
  console.log(this) // undefined,箭头函数继承外部this
})

规避方案:始终使用普通函数声明钩子,或在<script setup>中直接使用组合式API(无需依赖this

3.2 onUpdated中的无限循环陷阱

陷阱:在onUpdated中修改响应式数据,触发新一轮更新导致无限循环

// 错误示例:会导致无限循环
onUpdated(() => {
  this.count++ // 修改响应式数据,再次触发onUpdated
})

规避方案

  1. watch监听特定数据变化,仅在目标数据更新时执行逻辑
  2. 添加条件判断,确保数据修改仅在必要时执行

3.3 未清理的全局事件监听

陷阱:在组件中添加全局事件监听(如window.resize),但未在onUnmounted中移除,导致组件卸载后监听仍存在 规避方案:在onUnmounted中严格匹配移除事件,或使用watchEffectonInvalidate自动清理

3.4 依赖第三方库的资源泄漏

陷阱:在onMounted中初始化第三方库实例(如地图、图表),但未在onUnmounted中销毁,导致DOM节点已卸载但实例仍占用内存 规避方案:查阅第三方库文档,调用实例的销毁方法(如map.destroy()

四、课后Quiz:巩固你的理解

问题1:如何避免在onUpdated中触发无限循环?

答案解析

  • 方案1:使用watch替代onUpdated,仅监听特定响应式数据变化,而非全局更新
  • 方案2:在onUpdated中添加条件判断,仅当目标数据发生预期变化时才执行逻辑
  • 方案3:避免在onUpdated中直接修改响应式数据,若必须修改需添加防抖/节流控制

问题2:组件卸载时必须清理哪些类型的资源?

答案解析

  1. 定时器/间隔器(setTimeout/setInterval
  2. 全局事件监听(window.addEventListener绑定的事件)
  3. 第三方库实例(如地图、图表、WebSocket连接)
  4. 自定义的订阅/发布事件(如Vuex的subscribe、EventBus)

问题3:为什么不能用箭头函数声明生命周期钩子?

答案解析: 箭头函数没有自己的this上下文,会继承外层作用域的this。在Vue钩子中,默认this指向组件实例,使用箭头函数会导致this丢失,无法访问组件的响应式数据和方法。

五、常见报错与解决方案

5.1 报错:Cannot read property 'xxx' of undefined

场景:在钩子中使用this.xxx时出现 原因:使用箭头函数声明钩子导致this指向错误 解决办法:将箭头函数改为普通函数,或在<script setup>中直接使用组合式API(无需this

5.2 报错:onMounted中获取DOM元素为null

场景:在onMounted中通过document.querySelector获取组件内DOM元素返回null 原因:组件的DOM结构可能使用了v-if条件渲染,导致元素在onMounted时未被创建 解决办法

  1. 使用Vue的模板引用(ref)替代原生DOM查询
  2. 若必须使用原生查询,可包裹在nextTick中确保DOM更新完成
<script setup>
import { onMounted, nextTick } from 'vue'

onMounted(async () => {
  await nextTick()
  const element = document.querySelector('.target') // 此时DOM已完全渲染
})
</script>

5.3 内存泄漏:组件卸载后定时器仍在运行

场景:组件卸载后控制台仍打印定时任务日志 原因:未在onUnmounted中清理定时器 解决办法:在onUnmounted中调用clearInterval/clearTimeout清理定时器,或使用watchEffect自动清理

参考链接

vuejs.org/guide/essen…

高性能直播弹幕系统实现:从 Canvas 2D 到 WebGPU

高性能直播弹幕系统实现:从 Canvas 2D 到 WebGPU

前言

在现代直播应用中,弹幕是提升用户互动体验的重要功能。本文将深入介绍如何实现一个支持大规模并发、高性能渲染的弹幕系统,该系统支持 Canvas 2DWebGPU 两种渲染方式,能够在不同设备环境下自适应选择最佳渲染方案。

技术选型与架构设计

整体架构

我们的弹幕系统采用了以下架构设计:

┌─────────────────────┐
│  DanmakuCanvas.vue  │  ← Vue组件层(UI交互)
└──────────┬──────────┘
           │
┌──────────▼──────────┐
│  DanmakuManager.ts  │  ← 管理层(协调通信)
└──────────┬──────────┘
           │
┌──────────▼──────────┐
│     worker.js       │  ← Worker层(核心渲染逻辑)
└─────────────────────┘

核心特性:

  • 🚀 使用 Web Worker 实现离屏渲染,避免阻塞主线程
  • 🎨 支持 Canvas 2D 和 WebGPU 双渲染引擎
  • 📊 智能轨道分配算法,防止弹幕碰撞
  • 🎯 支持富文本渲染(文字 + 表情)
  • 📈 性能监控与数据上报
  • 🔄 响应式画布尺寸适配

技术栈

  • Vue 3: 组件层框架
  • TypeScript: 类型安全
  • OffscreenCanvas: 离屏渲染
  • Web Worker: 多线程
  • WebGPU: GPU加速渲染(可选)

核心实现详解

一、Vue 组件层实现

DanmakuCanvas.vue 作为用户界面层,主要负责:

<template>
  <div class="xhs-danmaku-container">
    <canvas ref="canvasRef" class="xhs-danmaku-container-canvas" />
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import DanmakuManager from './danmakuManager'

const props = defineProps({
  position: { type: String, default: 'top' },
  emojis: null,
  showDanmaku: { type: Boolean, default: true },
  config: { type: Object, default: null },
})

const canvasRef = ref<HTMLCanvasElement>()
const danmakuManager = ref<DanmakuManager>()

// 初始化弹幕管理器
function init() {
  if (!canvasRef.value) return
  
  danmakuManager.value = new DanmakuManager(
    handleError, 
    handleErrorReport, 
    updateHeartDim, 
    logger
  )
  danmakuManager.value.init(canvasRef.value, props.emojis, props.config)
}

// 添加弹幕的公共方法
function addDanmaku(message: string, options: any = { type: 'scroll' }) {
  if (!danmakuManager.value || !message.trim()) return
  danmakuManager.value?.addDanmaku(message, options)
}

// 响应式尺寸适配
function updateCanvasSize() {
  if (!danmakuManager.value || !canvasRef.value) return
  
  const rect = canvasRef.value.getBoundingClientRect()
  const newConfig = { 
    canvasWidth: rect.width, 
    canvasHeight: rect.height 
  }
  danmakuManager.value.updateConfig(newConfig)
}

onMounted(() => {
  init()
  if (props.showDanmaku) {
    danmakuManager.value?.start()
  }
  
  // 监听窗口变化
  window.addEventListener('resize', handleResize)
  window.addEventListener('fullscreenchange', handleResize)
})

onUnmounted(() => {
  danmakuManager.value?.destroy()
  window.removeEventListener('resize', handleResize)
  window.removeEventListener('fullscreenchange', handleResize)
})

// 暴露方法给父组件
defineExpose({
  addDanmaku,
  openDanmaku,
  closeDanmaku,
  playDanmaku,
  pauseDanmaku,
})
</script>

关键点:

  1. 使用 ref 获取 canvas DOM 元素
  2. 生命周期管理:初始化 → 运行 → 销毁
  3. 监听窗口 resize 和全屏事件,实时调整画布尺寸
  4. 通过 defineExpose 暴露控制接口

二、管理层实现

danmakuManager.ts 负责主线程与 Worker 线程的通信:

export default class DanmakuManager {
  private worker: Worker | null = null
  private onError: (err: any) => void
  private onErrorReport: (data: any) => void
  private updateHeartDim: (key: string, value: any) => void

  constructor(
    onError: (err: any) => void,
    onErrorReport: (data: any) => void,
    updateHeartDim: (key: string, value: any) => void,
    logger?: any,
  ) {
    this.onError = onError
    this.onErrorReport = onErrorReport
    this.updateHeartDim = updateHeartDim
    
    try {
      // 创建 Web Worker
      this.worker = work(require.resolve('./worker.js'))
      this.worker.onerror = this.handleError.bind(this)
      this.worker.onmessage = this.handleMessage
    } catch (error) {
      this.logger.warn('创建弹幕 Worker 失败:', error)
      this.onError(error)
    }
  }

  // 初始化离屏Canvas
  init = (canvas: HTMLCanvasElement, mojiData: any, config: any) => {
    try {
      // 转移 Canvas 控制权到 Worker
      const offScreenCanvas = canvas.transferControlToOffscreen()
      
      const emojis = this.serializeMojiData(mojiData)
      const rect = canvas.getBoundingClientRect()
      
      // 向 Worker 发送初始化消息
      this.worker?.postMessage({
        type: 'INIT',
        data: {
          config: {
            canvasWidth: rect.width,
            canvasHeight: rect.height,
            pixelRatio: window.devicePixelRatio || 1,
            emojis,
            ...config,
          },
          danmuRenderType: localStorage.getItem('danmuRenderType'),
          offScreenCanvas,
        },
      }, [offScreenCanvas]) // 转移对象所有权
    } catch (error) {
      this.onError(error)
    }
  }

  // 添加弹幕
  addDanmaku(message: string, options: any) {
    this.worker?.postMessage({ 
      type: 'ADD_DANMAKU', 
      data: { message, options } 
    })
  }

  // 更新弹幕配置(用于响应式调整)
  updateConfig(newConfig: any) {
    this.worker?.postMessage({ 
      type: 'UPDATE_CONFIG', 
      data: { newConfig } 
    })
  }

  // 销毁 Worker
  destroy() {
    this.worker?.terminate()
  }
}

核心技术点:

  1. OffscreenCanvas 转移:通过 transferControlToOffscreen() 将 Canvas 控制权转移到 Worker 线程
  2. 结构化克隆:使用 postMessage 的第二个参数传递可转移对象
  3. ImageBitmap 序列化:将表情图片转换为可传输的 ImageBitmap 对象

三、Worker 核心渲染逻辑

worker.js 是整个系统的核心,包含以下关键模块:

3.1 弹幕数据结构
class Danmaku {
  constructor(message, options, config, ctx) {
    const type = options?.type || 'scroll'
    const parts = this.parseRichText(message)
    const width = this.computeDanmakuWidth(parts, options, config, ctx)
    const boxWidth = options.showBorder ? width + PADDING_LEFT * 2 : width
    const speed = options.speed || config.speed

    this.id = this.getDanmakuId()
    this.text = message
    this.type = type
    this.speed = type === 'scroll' ? speed : 0
    this.parts = parts           // 富文本片段
    this.width = width
    this.boxWidth = boxWidth
    this.x = this.getDanmakuX(boxWidth, type, config)
    this.timestamp = Date.now()
    this.color = options.color || config.color
    this.fontSize = options.fontSize || config.fontSize
    this.priority = options.priority || 0
    this.showBorder = options.showBorder || false
  }

  // 解析富文本(文字+表情)
  parseRichText(message) {
    const parts = []
    let lastIndex = 0
    const matches = [...message.matchAll(/\[([^\]]+)\]/g)]
    
    if (matches.length === 0) return []
    
    for (const match of matches) {
      // 添加普通文本
      if (match.index > lastIndex) {
        parts.push({
          type: 'text',
          content: message.slice(lastIndex, match.index),
        })
      }
      // 添加表情
      parts.push({
        type: 'emoji',
        content: match[0],
      })
      lastIndex = match.index + match[0].length
    }
    
    // 添加剩余文本
    if (lastIndex < message.length) {
      parts.push({
        type: 'text',
        content: message.slice(lastIndex),
      })
    }
    return parts
  }
}

设计亮点:

  • 富文本解析:支持 [表情名] 格式的表情符号
  • 动态宽度计算:精确计算文字+表情的混合宽度
  • 优先级系统:支持 VIP 弹幕等优先展示场景
3.2 渲染器实现
class DanmakuRenderer {
  constructor(config, ctx) {
    this.config = config
    this.ctx = ctx
  }

  render(danmakuList) {
    danmakuList.forEach((danmaku) => {
      if (danmaku.showBorder) {
        this.drawDanmakuWithBorder(danmaku)
      } else {
        this.renderRichDanmaku(danmaku)
      }
    })
  }

  // 富文本弹幕渲染
  renderRichDanmaku(danmaku) {
    this.setupCanvasContext(danmaku)
    
    const startX = danmaku.x
    const yPosition = danmaku.y
    
    if (!danmaku.parts || danmaku.parts.length === 0) {
      this.renderSimpleDanmaku(danmaku.text, startX, yPosition)
      return
    }
    
    this.renderParts(danmaku, startX, yPosition)
  }

  // 渲染富文本各部分
  renderParts(danmaku, startX, yPosition) {
    let currentX = startX
    
    for (const part of danmaku.parts) {
      const { content, type } = part || {}
      if (!content) continue

      if (type === 'emoji') {
        currentX = this.renderEmoji(content, danmaku, currentX, yPosition)
      } else {
        currentX = this.renderText(content, danmaku, currentX, yPosition)
      }
    }
  }

  // 渲染文本
  renderText(content, danmaku, x, y) {
    this.ctx.strokeText(content, x, y)
    this.ctx.fillText(content, x, y)
    return x + this.measureTextWidth(content, danmaku).width
  }

  // 渲染表情
  renderEmoji(content, danmaku, x, y) {
    try {
      const emojiBitmap = this.config.emojis[content]?.bitmap
      
      if (!emojiBitmap) {
        // 回退到文本渲染
        return this.renderText(content, danmaku, x, y)
      }

      const emojiActualSize = danmaku.fontSize
      const emojiY = y - emojiActualSize / 2

      this.ctx.drawImage(
        emojiBitmap,
        x,
        emojiY,
        emojiActualSize,
        emojiActualSize,
      )

      return x + danmaku.fontSize
    } catch (error) {
      return this.renderText(content, danmaku, x, y)
    }
  }
}

渲染优化:

  1. 文字描边:使用 strokeText + fillText 提升可读性
  2. 混排处理:文字和表情按顺序依次渲染
  3. 容错机制:表情加载失败时回退到文本显示
3.3 智能轨道分配算法
class DanmakuWorker {
  constructor() {
    this.danmakuList = []      // 屏幕上的弹幕
    this.penddingList = []     // 等待队列
    this.usedTrackIds = new Set()  // 已占用轨道
    this.config = defaultConfig
  }

  // 创建轨道列表
  createTrackList() {
    const { trackCount, trackHeight, trackGap } = this.config
    return Array.from({ length: trackCount }, (_, i) => ({
      id: `${i}-track`,
      height: trackHeight * (i + 1) + trackGap / 2,
    }))
  }

  // 为新弹幕分配轨道
  assignTrack(newDanmaku) {
    const trackList = this.config.trackList
    
    // 优先分配未使用的轨道
    if (this.usedTrackIds.size < trackList.length) {
      return trackList.find(track => !this.usedTrackIds.has(track.id))
    }

    // 检查每个轨道是否有足够空间
    for (const track of trackList) {
      if (this.isTrackAvailable(track, newDanmaku)) {
        return track
      }
    }
    
    return null  // 无可用轨道
  }

  // 检查轨道是否可用
  isTrackAvailable(track, newDanmaku) {
    if (newDanmaku.type !== 'scroll') {
      // 固定弹幕:确保轨道上没有其他固定弹幕
      const sameTrackDanmakus = this.danmakuList.filter(
        d => d.type !== 'scroll' && d.trackId === track.id,
      )
      return sameTrackDanmakus.length === 0
    }

    // 滚动弹幕:检查是否有足够空间
    const sameTrackDanmakus = this.danmakuList.filter(
      d => d.type === 'scroll' && d.trackId === track.id,
    )
    
    if (sameTrackDanmakus.length === 0) return true

    // 检查最后一个弹幕是否已留出足够空间
    const lastDanmaku = sameTrackDanmakus[sameTrackDanmakus.length - 1]
    const lastDanmakuPosition = lastDanmaku.x + lastDanmaku.boxWidth
    const availableSpace = this.config.canvasWidth - lastDanmakuPosition
    
    return availableSpace >= SAFE_AREA  // 36px 安全距离
  }
}

算法特点:

  • 空间优先:优先使用完全空闲的轨道
  • 碰撞检测:计算前一条弹幕是否留出足够安全距离
  • 队列机制:无可用轨道时加入等待队列
3.4 WebGPU 渲染实现
async initWebGpu() {
  if (!navigator.gpu) {
    return false
  }

  // 获取 GPU 适配器和设备
  const adapter = await navigator.gpu.requestAdapter()
  const device = await adapter.requestDevice()
  const context = this.offScreenCanvas.getContext('webgpu')

  // 创建辅助 Canvas 用于 2D 绘制
  const webgpuCanvas = new OffscreenCanvas(
    this.offScreenCanvas.width, 
    this.offScreenCanvas.height
  )
  const webgpuCtx = webgpuCanvas.getContext('2d')
  
  this.webgpuCanvas = webgpuCanvas
  this.ctx = webgpuCtx  // 使用 2D 上下文绘制,再由 GPU 渲染

  // 配置 Canvas 格式
  const canvasFormat = navigator.gpu.getPreferredCanvasFormat()
  context.configure({
    device,
    format: canvasFormat,
    alphaMode: 'premultiplied',
  })

  // 创建着色器
  const vertexShaderCode = `
    struct VertexOutput {
      @builtin(position) position: vec4f,
      @location(0) uv: vec2f,
    };

    @vertex
    fn main(@location(0) position: vec2f, @location(1) uv: vec2f) -> VertexOutput {
      var output: VertexOutput;
      output.position = vec4f(position, 0.0, 1.0);
      output.uv = uv;
      return output;
    }
  `

  const fragShaderCode = `
    @group(0) @binding(0) var textureSampler: sampler;
    @group(0) @binding(1) var texture: texture_2d<f32>;

    @fragment
    fn main(@location(0) uv: vec2f) -> @location(0) vec4f {
      let flippedUV = vec2<f32>(uv.x, 1.0 - uv.y);
      return textureSample(texture, textureSampler, flippedUV);
    }
  `

  // 创建渲染管线
  const pipeline = device.createRenderPipeline({
    layout: 'auto',
    vertex: {
      module: device.createShaderModule({ code: vertexShaderCode }),
      entryPoint: 'main',
      buffers: [/* ... */],
    },
    fragment: {
      module: device.createShaderModule({ code: fragShaderCode }),
      entryPoint: 'main',
      targets: [{ format: canvasFormat }],
    },
    primitive: { topology: 'triangle-strip' },
  })

  this.pipeline = pipeline
  this.renderType = 'WEBGPU'
  return true
}

async renderWebgpu() {
  // 1. 在 2D Canvas 上绘制弹幕
  this.renderer.render(this.danmakuList)

  // 2. 将 2D Canvas 内容复制到 GPU 纹理
  this.device.queue.copyExternalImageToTexture(
    { source: this.webgpuCanvas },
    { texture: this.texture },
    { width: this.webgpuCanvas.width, height: this.webgpuCanvas.height },
  )

  // 3. 使用 GPU 渲染到屏幕
  const encoder = this.device.createCommandEncoder()
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: this.context.getCurrentTexture().createView(),
      loadOp: 'clear',
      clearValue: [0, 0, 0, 0],
      storeOp: 'store',
    }],
  })

  pass.setPipeline(this.pipeline)
  pass.setVertexBuffer(0, this.vertexBuffer)
  pass.setBindGroup(0, this.bindGroup)
  pass.draw(4)
  pass.end()

  this.device.queue.submit([encoder.finish()])
}

WebGPU 优势:

  • GPU 加速合成,降低 CPU 负载
  • 更高的渲染性能,支持更大弹幕量
  • 适合高端设备,提供极致体验
3.5 动画循环与性能优化
update = (currentTime) => {
  if (this.state !== 'playing') return

  const elapsed = currentTime - this.lastUpdateTime
  this.lastUpdateTime = currentTime

  // 防止时间跳变(如标签页切换回来)
  if (elapsed <= 0) {
    this.animationId = requestAnimationFrame(this.update)
    return
  }

  // 更新弹幕位置
  this.updateDanmakuX(elapsed)

  // 渲染
  if (this.renderType === 'WEBGPU') {
    this.renderWebgpu()
  } else {
    this.render2D()
  }

  // 尝试从队列中添加弹幕
  this.tryAddPendingDanmaku()

  this.animationId = requestAnimationFrame(this.update)
}

// 更新弹幕位置
updateDanmakuX = (deltaTime) => {
  this.danmakuList = this.danmakuList.filter((danmaku) => {
    // 限制 deltaTime 防止时间跳变导致位置突变
    let _deltaTime = deltaTime
    if (_deltaTime >= 20) {
      _deltaTime = 20
    }
    if (deltaTime < 20 && deltaTime > 15) {
      _deltaTime = 16
    }

    // 滚动弹幕位置更新
    if (danmaku.type === 'scroll' && danmaku.trackId) {
      danmaku.x -= danmaku.speed * (_deltaTime / 1000)
    }

    const isVisible = this.isDanmakuVisible(danmaku)
    if (!isVisible) {
      this.clearCanvas()
    }
    return isVisible
  })
}

// 检查弹幕可见性
isDanmakuVisible(danmaku) {
  if (danmaku.type === 'scroll') {
    // 滚动弹幕:完全离开屏幕左侧才移除
    return danmaku.x + danmaku.boxWidth + SAFE_AREA > 0
  } else {
    // 固定弹幕:根据持续时间判断
    return Date.now() - danmaku.timestamp < danmaku.duration
  }
}

性能优化点:

  1. 时间平滑处理:限制 deltaTime 范围,避免标签页切换导致的位置跳变
  2. 自动清理:及时移除不可见弹幕,减少渲染负担
  3. 按需渲染:只在有弹幕时执行渲染逻辑

四、响应式尺寸适配

updateConfig = ({ newConfig }) => {
  const oldWidth = this.config?.canvasWidth
  const newWidth = newConfig.canvasWidth
  
  // 合并新配置
  this.config = { ...this.config, ...newConfig }

  const newWidthPx = newConfig.canvasWidth * this.config.pixelRatio
  const newHeightPx = newConfig.canvasHeight * this.config.pixelRatio
  
  // 更新画布尺寸
  if (this.offScreenCanvas) {
    this.offScreenCanvas.width = newWidthPx
    this.offScreenCanvas.height = newHeightPx
  }

  // 调整现有弹幕位置
  this.adjustDanmakuX(oldWidth, newWidth, this.danmakuList)
  this.adjustDanmakuX(oldWidth, newWidth, this.penddingList)
}

adjustDanmakuX = (oldWidth, newWidth, danmakuList) => {
  danmakuList.forEach((danmaku) => {
    if (danmaku.type === 'scroll') {
      // 滚动弹幕:保持相对位置
      danmaku.x += (newWidth - oldWidth)
    } else {
      // 固定弹幕:重新居中
      danmaku.x = this.config.canvasWidth / 2 - (danmaku.boxWidth / 2)
    }
  })
}

适配特点:

  • 无缝调整:窗口变化时保持弹幕连续性
  • 位置修正:滚动弹幕保持相对位置,固定弹幕重新居中
  • 双向同步:同时调整屏幕上的弹幕和等待队列

性能对比

指标 Canvas 2D WebGPU
CPU 占用 中等
GPU 占用 中等
最大弹幕量 ~300/s ~800/s
兼容性 99%+ ~70%
适用场景 通用 高端设备

使用示例

<template>
  <DanmakuCanvas
    ref="danmakuRef"
    :show-danmaku="true"
    :emojis="emojiData"
    :config="danmakuConfig"
    @on-error="handleError"
  />
</template>

<script setup>
import { ref } from 'vue'
import DanmakuCanvas from './components/CanvasBarrage/DanmakuCanvas.vue'

const danmakuRef = ref()

const danmakuConfig = {
  fontSize: 20,
  fontFamily: 'PingFang SC',
  color: '#fff',
  duration: 8000,
  trackHeight: 52,
  trackGap: 16,
  trackCount: 3,
  speed: 140,
}

// 发送弹幕
function sendDanmaku(message) {
  danmakuRef.value?.addDanmaku(message, {
    type: 'scroll',      // scroll | fixed
    priority: 0,         // 优先级
    showBorder: false,   // 是否显示边框
  })
}

// 发送 VIP 弹幕
function sendVipDanmaku(message) {
  danmakuRef.value?.addDanmaku(message, {
    type: 'scroll',
    priority: 10,        // 高优先级
    showBorder: true,    // 带边框
    color: '#FFD700',    // 金色
  })
}
</script>

最佳实践

1. 性能监控

// 在 Worker 中上报性能指标
globalThis.postMessage({ 
  type: 'updateHeartDim', 
  data: { 
    key: 'onScreenDanmuCount', 
    value: this.danmakuList.length 
  } 
})

2. 渲染模式选择

// 根据设备能力选择渲染方式
const danmuRenderType = localStorage.getItem('danmuRenderType') || 'webgpu'

// 浏览器支持检测
if (!navigator.gpu) {
  localStorage.setItem('danmuRenderType', 'canvas2d')
  window.location.reload()
}

3. 表情图片预处理

// 使用 ImageBitmap 提升渲染性能
async function loadEmojis(emojiUrls) {
  const emojis = {}
  for (const [key, url] of Object.entries(emojiUrls)) {
    const response = await fetch(url)
    const blob = await response.blob()
    emojis[key] = await createImageBitmap(blob)
  }
  return emojis
}

4. 内存管理

// 限制等待队列长度
const MAX_PENDDING_LIST_LEN = 100

if (this.penddingList.length >= MAX_PENDDING_LIST_LEN) {
  // 丢弃最早的弹幕
  this.penddingList.shift()
  // 上报丢弃数据
  globalThis.postMessage({ 
    type: 'updateHeartDim', 
    data: { key: 'discardDanmuCount', value: 1 } 
  })
}

总结

本文介绍的弹幕系统具备以下特点:

高性能:Web Worker + OffscreenCanvas,不阻塞主线程
可扩展:双渲染引擎,支持渐进增强
智能调度:轨道分配算法 + 优先级队列
功能丰富:富文本、边框、多种弹幕类型
响应式:自适应屏幕尺寸变化
可监控:完善的性能指标上报

这套方案已在生产环境稳定运行,能够支撑高并发直播场景下的大规模弹幕渲染需求。

参考资料

如果这篇文章对你有帮助,欢迎点赞收藏!有任何问题欢迎在评论区讨论~ 🎉

uniapp实现图片压缩并上传

最近在使用uniapp开发时,有个功能既要支持H5和小程序双平台,又要实现图片自动压缩,还要处理好接口响应的各种异常情况。最终封装了这个 useUploadMethod 自定义上传方法,今天分享给大家。

痛点分析

先看看我们平时会遇到哪些问题:

// 痛点1:图片太大,上传慢
uni.uploadFile({
  filePath: 'big-image.jpg'  // 5MB的图片直接上传
  // 用户等得花儿都谢了
})

// 痛点2:登录态过期
uni.uploadFile({
  success: (res) => {
    // {"code":405,"msg":"未登录"}
    // 啥也没发生,用户继续操作,然后报错
  }
})

// 痛点3:H5和小程序API不统一
// H5用 File/Blob
// 小程序用 tempFilePath
// 代码里到处都是 #ifdef
技术方案
1. 整体架构

整个上传方法分为三个核心层:

  • 预处理层:图片压缩、参数组装
  • 上传层:跨平台上传、进度监听
  • 响应层:状态码处理、登录态管理
2. 图片压缩模块

跨平台压缩策略

async function compressImage(file: UploadFileItem, options: any): Promise<File | string> {
  // 未启用压缩,直接返回
  if (!options?.enabled) return file.url

  // H5平台:使用 compressorjs
  // #ifdef H5
  return compressImageH5(file, options)
  // #endif

  // 小程序平台:使用 uni.compressImage
  // #ifndef H5
  return new Promise((resolve) => {
    uni.compressImage({
      src: file.url,
      quality: options.quality || 80,
      width: options.maxWidth,
      height: options.maxHeight,
      success: (res) => resolve(res.tempFilePath),
      fail: () => resolve(file.url) // 压缩失败回退原图
    })
  })
  // #endif
}

设计亮点

  • 条件编译处理平台差异
  • 压缩失败自动降级使用原图
  • 统一返回类型,上层无感知

H5平台深度优化(compressorjs)

async function compressImageH5(file: UploadFileItem, options?: CompressOptions): Promise<File | string> {
  let { name: fileName, url: filePath } = file
  
  return new Promise((resolve) => {
    // 从blob URL获取文件
    fetch(filePath)
      .then(res => res.blob())
      .then((blob) => {
        // compressorjs压缩配置
        new Compressor(blob, {
          quality: (options?.quality || 80) / 100, // 转换为0-1范围
          maxWidth: options?.maxWidth,
          maxHeight: options?.maxHeight,
          mimeType: blob.type,
          success: (compressedBlob) => {
            // 生成标准File对象
            const fileName = `file-${Date.now()}.${blob.type.split('/')[1]}`
            const file = new File([compressedBlob], fileName, { type: blob.type })
            resolve(file)
          },
          error: () => resolve(filePath) // 压缩失败回退
        })
      })
      .catch(() => resolve(filePath))
  })
}

关键点

  • fetch + blob() 获取原始文件数据
  • compressorjs 提供高质量的图片压缩
  • 返回 File 对象,H5上传更标准
3. 核心上传方法
export function useUploadMethod(httpOptions: HttpOptions) {
  const { url, name, formData: data, header, timeout, onStart, onFinish, onSuccess, compress } = httpOptions

  const uploadMethod: UploadMethod = async (file, formData, options) => {
    // 1. 上传开始钩子
    onStart?.()

    // 2. 图片压缩(如果启用)
    let filePath = file.url
    try {
      filePath = await compressImage(file, compress)
    } catch {
      filePath = file.url // 异常降级
    }

    // 3. 创建上传任务
    const uploadTask = uni.uploadFile({
      url: options.action || url,
      header: { ...header, ...options.header },
      name: options.name || name,
      formData: { ...data, ...formData },
      timeout: timeout || 60000,
      
      // 4. 跨平台文件参数处理
      ...(typeof File !== 'undefined' && filePath instanceof File 
          ? { file: filePath }   // H5: File对象
          : { filePath }),       // 小程序: 路径字符串

      // 5. 响应处理
      success: (res) => handleSuccess(res, file, options),
      fail: (err) => handleError(err, file, options)
    })

    // 6. 进度监听
    uploadTask.onProgressUpdate((res) => {
      options.onProgress(res, file)
    })
  }

  return { uploadMethod }
}
4. 智能响应处理器
// 上传成功处理
function handleSuccess(res: any, file: UploadFileItem, options: any) {
  try {
    // 解析响应数据
    const resData = JSON.parse(res.data) as ResData<any>
    
    // 状态码检查
    if (res.statusCode >= 200 && res.statusCode < 300) {
      const { code, msg: errMsg = '上传失败' } = resData
      
      if (+code === 200) {
        // 上传成功
        options.onSuccess(res, file, resData)
        onSuccess?.(res, file, resData)
        return
      }
      
      // 登录态过期处理
      if (+code === 405 || errMsg.includes('未登录')) {
        toast.show(errMsg || '登录态失效')
        logout()
        login() // 自动跳转登录页
        return
      }
      
      // 其他业务错误
      toast.show(errMsg)
      options.onError({ ...res, errMsg }, file, resData)
      return
    }
    
    // HTTP 401处理
    if (res.statusCode === 401) {
      toast.show('登录态失效')
      logout()
      login()
      return
    }
    
    // 其他HTTP错误
    toast.show(resData.msg || `服务出错:${res.statusCode}`)
    options.onError({ ...res, errMsg: '服务开小差了' }, file)
    
  } finally {
    onFinish?.() // 无论成功失败都调用
  }
}

// 上传失败处理
function handleError(err: any, file: UploadFileItem, options: any) {
  try {
    toast.show('网络错误,请稍后再试')
    // 设置上传失败
    options.onError(err, file, formData)
  } finally {
    // 文件上传完成时调用
    onFinish?.()
  }
} as any)
基础用法
<template>
  <wd-upload
    :upload-method="uploadMethod"
    v-model:file-list="fileList"
    @change="handleChange"
  />
</template>

<script setup>
import { useUploadMethod } from './upload-method'

// 配置上传方法
const { uploadMethod } = useUploadMethod({
  url: '/api/upload',
  name: 'file',
  header: {
    'Authorization': 'Bearer ' + getToken()
  },
  // 图片压缩配置
  compress: {
    enabled: true,
    quality: 80,
    maxWidth: 1920,
    maxHeight: 1080
  },
  // 钩子函数
  onStart: () => console.log('开始上传'),
  onSuccess: (res, file) => console.log('上传成功', file),
  onFinish: () => console.log('上传完成')
})
</script>

通往“全干”之路一:前端部署

年底入职了一家创业小公司,感觉还是很幸运的。由于前端就我1个人而且没有运维,很自然前端项目部署的工作就落在我的肩上。

第一周我搭建起了公司的后台管理系统框架,按需求开发了两个页面,主要是文件上传相关的。然后那周剩余的时间,我就想先部署上去。

一、常见的前端部署
部署环境:JumpServer开源堡垒机

部署所需配置文件就是nginx.conf

部署步骤:  

1、账号密码登录堡垒机 

2、安装nginx 

3、让豆包提供一份标准nginx.conf 

4、上传dist文件 

5、解压dist.zip到nginx目录/usr/share/nginx/html/ 

6、启动nginx

后续项目更新只需要上传,并解压文件到指定目录,前端页面刷新后即可看到更新。 这种部署方式比较常见,也比较简单,半天不到即可搞定。在这里不得不提一下AI编程工具对开发效率的提升,特别是新项目来说。

 
二、亚马逊容器云部署

然后是第二周在另一个前端项目里开发了用户侧的显示界面,也需要部署上去。听面试我的后端大佬说,后端服务是在亚马逊上,采用docker集群部署。还好之前的工作也接触的docker,所以也不是很慌。

部署环境:亚马逊堡垒机
部署所需配置文件:
1、nginx.conf:配置静态资源和前端api请求代理,此文件放前端项目里,然后打包进docker镜像。

2、front-model.yaml:此文件放服务器上,主要配置nginx服务的端口、内存占用,以及镜像地址等。可让AI生成一份,然后修改对应的名称即可。

3、xxx-ingress:服务器上路由文件,主要配置前端路由转到nginx服务。

 配置好以上文件后,即可按下面步骤完成部署:
1、打包构建

npm run build:test

2、打镜像

docker build -t front-model:v1.0.1 .

3、amazonaws镜像重命名

docker tag front-model:v1.0.1 628639829879.dkr.ecr.us-east-1.amazonaws.com/front-model:v1.0.1

4、amazonaws登录(先安装aws client)

aws ecr get-login-password --region us-east-1 | docker login --username xxx --password-stdin xxx.dkr.ecr.us-east-1.amazonaws.com

5、推送镜像到amazonaws仓库 

docker push 628639829879.dkr.ecr.us-east-1.amazonaws.com/front-model:v1.0.1

6、修改front-model.yaml镜像tag

sudo vim front-model.yaml

7、应用yaml 

kubectl apply -f front-model.yaml

8、重启pod服务

kubectl rollout restart deployment/front-model

9、查看指定pod状态

kubectl get pods | grep front-model

遇到的问题:
1、docker客户端提示缺少win包,然后下载进度卡住拉不下来,原因是docker的下载终端在鼠标点击后默认暂停了。
2、前端资源的mime类型不对,需修改nginx.conf。
3、api请求没有经过nginx,原因是ingress的path不支持正则表达式的写法,需要拆开单独写。

大家也发现了上面的部署方式都是纯手工,比较繁琐。后面会考虑做成脚本自动执行,或者接入CICD。

Vue3 组件生命周期详解

在上一篇文章中,我们深入探讨了组件从VNode到DOM的渲染过程。本篇文章将聚焦于组件的生命周期——这个贯穿组件从创建到销毁整个过程的时间轴。理解生命周期,不仅能帮助我们写出更可靠的代码,还能在合适的时机做合适的事情。

前言:为什么需要生命周期?

想象一下,我们正在搭建一座房子,一般需要经过以下几个阶段:

  1. 创建阶段:设计图纸、准备材料
  2. 挂载阶段:打地基、砌墙、安装门窗
  3. 更新阶段:翻新墙面、更换家具
  4. 卸载阶段:拆除房屋、清理场地

组件也是如此。在它的整个生命周期中,我们需要在不同的时间点执行不同的操作:

const Component = {
  // 创建时:初始化数据
  created() {
    this.fetchData();
  },
  
  // 挂载后:操作DOM
  mounted() {
    this.$el.focus();
  },

  // 更新时:页面刷新
  updated() {
    this.$el.scrollTop = this.$el.scrollHeight; 
  }
  
  // 卸载前:清理资源
  beforeUnmount() {
    clearInterval(this.timer);
  }
};

生命周期的完整图谱

生命周期全景图

下面这张图展示了 Vue3 组件的完整生命周期流程: 组件生命周期

各个阶段的核心任务

阶段 钩子 核心任务 注意事项
创建阶段 setup / beforeCreate / created 初始化数据、设置响应式 无法访问DOM
挂载阶段 beforeMount / mounted 渲染DOM、操作DOM 可以访问DOM
更新阶段 beforeUpdate / updated 响应数据变化 避免在更新钩子中修改数据
卸载阶段 beforeUnmount / unmounted 清理资源 清除定时器、取消订阅
缓存阶段 activated / deactivated 配合keep-alive 缓存组件的激活/失活

创建阶段:组件诞生

创建阶段的三个钩子

在 Vue3 中,创建阶段实际上由三个关键步骤组成:

  1. 最先执行:setup
  2. 然后执行:beforeCreate
  3. 最后执行:created
export default {
  // 1. 最先执行:setup
  setup() {
    console.log('1. setup 执行');
    
    const count = ref(0);
    
    // setup 中不能使用 this
    // console.log(this); // undefined
    
    return { count };
  },
  
  // 2. 然后执行:beforeCreate
  beforeCreate() {
    console.log('2. beforeCreate 执行');
    console.log('数据尚未初始化:', this.count); // undefined
    console.log('DOM 尚未创建:', this.$el);     // undefined
  },
  
  // 3. 最后执行:created
  created() {
    console.log('3. created 执行');
    console.log('数据已初始化:', this.count);    // 0
    console.log('DOM 尚未创建:', this.$el);      // undefined
  }
};

各钩子的数据访问能力

为了更好地理解每个阶段能做什么,我们用一个表格来展示:

钩子 访问 data 访问 props 访问 computed 访问 methods 访问 DOM 访问 $el
setup ❌ (尚未创建) ❌ (尚未创建)
beforeCreate
created

为什么需要三个创建钩子?

你可能有这样的疑问:为什么有了 setup 还要保留 beforeCreatecreated

这其实是为了兼容性和渐进迁移。在 Vue3 中,setup 实际上是 beforeCreatecreated 的替代品;但是 Vue3 为了向下兼容 Vue2 ,仍然保留了 beforeCreatecreated

Vue2 风格的创建钩子:

export default {
  beforeCreate() {
    // 初始化非响应式数据
    this.nonReactive = {};
  },
  created() {
    // 发起API请求
    this.fetchData();
  }
};

Vue3 组合式风格:

export default {
  setup() {
    // 初始化非响应式数据
    const nonReactive = {};
    
    // 发起API请求
    fetchData();
    
    return { nonReactive };
  }
};

挂载阶段:组件展现

挂载过程的内部机制

挂载阶段是组件第一次将虚拟 DOM 渲染为真实 DOM 的过程:

export default {
  beforeMount() {
    console.log('1. beforeMount 执行');
    console.log('此时已有编译好的模板,但尚未挂载到DOM');
    console.log('DOM 尚不存在:', this.$el);  // undefined
  },
  
  // render 函数在 beforeMount 之后、mounted 之前执行
  render() {
    console.log('2. render 执行');
    return h('div', 'Hello World');
  },
  
  mounted() {
    console.log('3. mounted 执行');
    console.log('DOM 已创建并挂载:', this.$el);     // <div>Hello World</div>
    console.log('可以安全地操作DOM了');
    
    // 可以访问DOM元素
    this.$el.querySelector('input')?.focus();
    
    // 可以集成第三方库
    new Chart(this.$el.querySelector('#chart'), {...});
  }
};

挂载阶段的时序图

挂载阶段的执行时序图

挂载阶段的典型应用场景

1. 操作DOM

this.$el.scrollTop = 100;

2. 获取元素尺寸

const width = this.$refs.box.offsetWidth;

3. 集成第三方库(需要DOM存在)

this.chart = new Chart(this.$refs.canvas, {
  type: 'line',
  data: this.chartData
});

4. 添加全局事件监听

window.addEventListener('resize', this.handleResize);

5. 启动定时器

this.timer = setInterval(this.refreshData, 5000);

6. 卸载前清理操作

window.removeEventListener('resize', this.handleResize);
clearInterval(this.timer);
this.chart?.destroy();

更新阶段:组件响应

更新阶段的时序图

更新阶段的时序图

更新阶段的注意事项

export default {
  beforeUpdate() {
    // ✅ 可以在DOM更新前访问旧状态
    const oldHeight = this.$refs.box.offsetHeight;
    console.log('旧高度:', oldHeight);
    
    // ❌ 不要在更新钩子中修改数据(可能造成死循环)
    // this.count++; // 会触发无限循环
    
    // ✅ 可以在这里手动操作DOM(不推荐)
    // 但要注意这些操作可能会被后续的更新覆盖
  },
  
  updated() {
    // ✅ 可以获取更新后的DOM信息
    const newHeight = this.$refs.box.offsetHeight;
    console.log('新高度:', newHeight);
    
    // ✅ 可以根据新状态调整其他非响应式内容
    if (newHeight > 500) {
      this.$refs.box.classList.add('overflow');
    }
    
    // ❌ 同样避免在这里修改数据
    // ❌ 避免直接操作DOM来"修复"样式,应该通过数据驱动
  }
};

卸载阶段:组件消亡

卸载的完整过程

export default {
  beforeUnmount() {
    console.log('1. beforeUnmount 执行');
    console.log('组件即将被卸载,但依然可以访问');
    console.log('DOM 仍然存在:', this.$el);
    
    // 清理工作
    this.cleanup();
  },
  
  unmounted() {
    console.log('2. unmounted 执行');
    console.log('组件已被卸载');
    console.log('DOM 已移除:', this.$el); // 被移除或置空
    
    // 最终清理
    this.finalCleanup();
  }
};

需要清理的典型资源

  1. 定时器:clearInterval(timer);
  2. 事件监听:window.removeEventListener('resize', handleResize);
  3. 观察者:observer.disconnect();
  4. 网络请求:controller.abort();
  5. 第三方库实例:chart.destroy();
  6. 手动订阅:subscription.unsubscribe();

缓存阶段:KeepAlive 的特殊生命周期

为什么需要缓存阶段?

当组件被 <KeepAlive> 包裹时,它的生命周期会发生变化: KeepAlive生命周期.png

activated 和 deactivated 的使用

const CacheComponent = {
  setup() {
    console.log('setup 执行'); // 只执行一次
    
    const count = ref(0);
    
    // 这些钩子会在组件被缓存时特殊处理
    onMounted(() => {
      console.log('mounted 执行'); // 只执行一次
    });
    
    onActivated(() => {
      console.log('activated 执行'); // 每次进入视图时执行
      
      // 恢复一些状态
      startAnimation();
      startPolling();
    });
    
    onDeactivated(() => {
      console.log('deactivated 执行'); // 每次离开视图时执行
      
      // 暂停一些操作,但不销毁
      stopAnimation();
      stopPolling();
    });
    
    onUnmounted(() => {
      console.log('unmounted 执行'); // 最终销毁时执行
    });
    
    return { count };
  }
};

父子组件的生命周期顺序

挂载阶段的执行顺序

当父子组件嵌套时,生命周期的执行顺序非常关键:

const Parent = {
  setup() { console.log('Parent setup'); },
  beforeCreate() { console.log('Parent beforeCreate'); },
  created() { console.log('Parent created'); },
  beforeMount() { console.log('Parent beforeMount'); },
  mounted() { console.log('Parent mounted'); },
  
  render() {
    return h('div', [
      h(Child)
    ]);
  }
};

const Child = {
  setup() { console.log('Child setup'); },
  beforeCreate() { console.log('Child beforeCreate'); },
  created() { console.log('Child created'); },
  beforeMount() { console.log('Child beforeMount'); },
  mounted() { console.log('Child mounted'); }
};

// 渲染输出顺序:
// 1. Parent setup
// 2. Parent beforeCreate
// 3. Parent created
// 4. Parent beforeMount
// 5. Child setup
// 6. Child beforeCreate
// 7. Child created
// 8. Child beforeMount
// 9. Child mounted
// 10. Parent mounted

更新阶段的执行顺序

当 Parent 的数据变化时:

  1. Parent beforeUpdate
  2. Child beforeUpdate
  3. Child updated
  4. Parent updated

卸载阶段的执行顺序

当父组件被移除时:

  1. Parent beforeUnmount
  2. Child beforeUnmount
  3. Child unmounted
  4. Parent unmounted

执行顺序的规律总结

阶段 执行顺序 原因
创建 父 → 子 父组件先创建,才能传递props给子组件
挂载 子 → 父 父组件需要等待所有子组件挂载完成
更新 父 → 子 父组件数据变化,传递给子组件
卸载 子 → 父 先拆除内部,再拆除外部

Vue3 中两种写法的生命周期对比

Vue3 同时支持两种写法:选项式 API组合式 API

选项式 API 生命周期

  • beforeCreate / created
  • beforeMount / mounted
  • beforeUpdate / updated
  • beforeUnmount / unmounted
  • activated / deactivated
  • errorCaptured
  • renderTracked / renderTriggered:新增的调试钩子

组合式 API 生命周期

  • setup
  • onBeforeMount / onMounted
  • onBeforeUpdate / onUpdated
  • onBeforeUnmount / onUnmounted
  • onActivated / onDeactivated
  • onErrorCaptured
  • onRenderTracked / onRenderTriggered

两种写法的对应关系表

选项式 API 组合式 API 执行时机
beforeCreate/created 直接在 setup 中编写代码 组件初始化前/组件初始化后
beforeMount/mounted onBeforeMount/onMounted DOM 挂载前/DOM 挂载后
beforeUpdate/updated onBeforeUpdate/onUpdated 数据更新、DOM 更新前/DOM 更新后
beforeUnmount/unmounted onBeforeUnmount/onUnmounted 组件卸载前/组件卸载后
activated/deactivated onActivated/onDeactivated keep-alive 组件激活/keep-alive 组件失活
errorCaptured onErrorCaptured 捕获后代组件错误

核心差异:setup 中的生命周期

setup 函数是最早的生命周期钩子,本身执行在 beforeCreatecreated 之前,属于 beforeCreatecreated 的替代品,因此在 setup 中编写的代码相当于在这两个钩子中执行:

export default {
  setup() {
    // 这些代码相当于在 beforeCreate 和 created 中执行
    
    console.log('相当于 beforeCreate/created');
    
    const count = ref(0);
    
    // 可以在这里执行初始化操作
    fetchData();
    
    // 注册生命周期钩子
    onMounted(() => {
      console.log('mounted');
    });
    
    return { count };
  }
};

<script setup> 的特殊性

<script setup> 的本质

<script setup> 是组合式 API 的语法糖,它在编译时会被转换为普通的 setup() 函数:

<!-- 源码写法 -->
<script setup>
import { ref, onMounted } from 'vue';

const count = ref(0);

function increment() {
  count.value++;
}

onMounted(() => {
  console.log('组件已挂载');
});
</script>
// 编译后
export default {
  setup() {
    const count = ref(0);
    
    function increment() {
      count.value++;
    }
    
    onMounted(() => {
      console.log('组件已挂载');
    });
    
    return { count, increment };
  }
};

<script setup> 中的生命周期变化

<script setup> 中,生命周期钩子的使用变得更加简洁:

  • onBeforeMount / onMounted
  • onBeforeUpdate / onUpdated
  • onBeforeUnmount / onUnmounted
  • onActivated / onDeactivated

<script setup> 的特殊特性

<script setup>
// 1. 自动返回顶层变量
const count = ref(0);           // 自动暴露给模板
function increment() {}          // 自动暴露给模板

// 2. 支持顶层 await
const data = await fetchData();  // 组件会等待异步操作完成

// 3. 使用 defineProps 和 defineEmits
const props = defineProps({
  title: String
});

const emit = defineEmits(['update']);

// 4. 使用 defineExpose 暴露方法
defineExpose({
  resetCount: () => count.value = 0
});

// 5. 生命周期钩子可以直接使用
onMounted(() => {
  console.log('mounted');
});
</script>

生命周期的最佳实践

各阶段适合做什么

阶段 适合的操作 不适合的操作
setup/created 初始化数据、设置响应式、发起API请求 操作DOM、访问$el
beforeMount 最后一次数据修改机会 操作DOM
mounted 操作DOM、集成第三方库、添加事件监听 同步修改数据(可能触发额外更新)
beforeUpdate 访问更新前的DOM 修改数据(可能死循环)
updated 执行依赖更新后DOM的操作 修改数据(可能死循环)
beforeUnmount 清理资源、移除事件监听 异步操作
unmounted 最终清理 访问已销毁的实例

生命周期调试技巧

使用生命周期追踪

<script setup>
import { onMounted, onUpdated, onRenderTracked, onRenderTriggered } from 'vue';

// 追踪渲染依赖
onRenderTracked((event) => {
  console.log('渲染依赖追踪:', event);
  // {
  //   key: 'count',      // 依赖的属性名
  //   target: {},        // 依赖的目标对象
  //   type: 'get',       // 操作类型
  // }
});

// 追踪渲染触发原因
onRenderTriggered((event) => {
  console.log('渲染触发原因:', event);
  // {
  //   key: 'count',
  //   target: {},
  //   type: 'set',
  //   oldValue: 0,
  //   newValue: 1
  // }
});

// 记录完整生命周期
onBeforeMount(() => console.log('🔄 beforeMount'));
onMounted(() => console.log('✅ mounted'));
onBeforeUpdate(() => console.log('🔄 beforeUpdate'));
onUpdated(() => console.log('✅ updated'));
onBeforeUnmount(() => console.log('🔄 beforeUnmount'));
onUnmounted(() => console.log('✅ unmounted'));
</script>

使用钩子组合

// 创建可复用的生命周期逻辑
function useLogger(componentName) {
  onBeforeMount(() => {
    console.log(`${componentName} 准备挂载`);
  });
  
  onMounted(() => {
    console.log(`${componentName} 已挂载`);
  });
  
  onBeforeUnmount(() => {
    console.log(`${componentName} 准备卸载`);
  });
  
  onUnmounted(() => {
    console.log(`${componentName} 已卸载`);
  });
}

// 在组件中使用
<script setup>
const props = defineProps({ name: String });
useLogger(props.name);
</script>

结语

理解生命周期,就像是掌握了组件从生到死的完整剧本。知道在每个阶段该做什么、不该做什么,才能写出既高效又可靠的Vue应用。

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

组件渲染:从组件到DOM

在前面的文章中,我们深入探讨了虚拟DOM的创建和原生元素的挂载过程。但 Vue 真正的威力在于组件系统——它让我们能够将界面拆分成独立的、可复用的模块。本文将揭示 Vue3 如何将我们编写的组件,一步步渲染成真实的 DOM 节点。

前言:组件的魔法

当我们编写这样的Vue组件时:

<template>
  <div class="user-card">
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <button @click="sayHello">打招呼</button>
  </div>
</template>

<script>
export default {
  props: ['user'],
  setup(props) {
    const sayHello = () => {
      alert(`你好,我是${props.user.name}`);
    };
    
    return { sayHello };
  }
}
</script>

Vue内部经历了一系列复杂而有序的过程: 组件渲染流程 本文将带你一步步拆解这个过程,理解组件从定义到 DOM 的完整旅程。

组件的VNode结构

组件VNode的特殊性

与原生元素不同,组件的 VNode 有其独特的结构:

const componentVNode = {
  type: UserCard,                  // 对象/函数:表示组件定义
  props: { user: { name: '张三' } }, // 传递给组件的props
  children: {                       // 插槽内容
    default: () => h('span', '默认插槽'),
    header: () => h('h1', '头部')
  },
  shapeFlag: ShapeFlags.STATEFUL_COMPONENT, // 标记为组件
  
  // 组件特有属性
  key: null,
  ref: null,
  component: null,                  // 组件实例(挂载后填充)
  suspense: null,
  scopeId: null,
  slotScopeIds: null
};

组件类型的多样性

Vue3中的组件类型更加丰富:

1. 有状态组件(最常用)

const StatefulComponent = {
  data() { return { count: 0 } },
  template: `<div>{{ count }}</div>`
};

2. 函数式组件(无状态)

const FunctionalComponent = (props) => {
  return h('div', props.message);
};

3. 异步组件

const AsyncComponent = defineAsyncComponent(() => 
  import('./MyComponent.vue')
);

4. 内置组件

const KeepAliveComponent = {
  type: KeepAlive,
  props: { include: 'a,b' }
};

shapeFlag 标志

const enum ShapeFlags {
  ELEMENT = 1,
  FUNCTIONAL_COMPONENT = 1 << 1,  // 2
  STATEFUL_COMPONENT = 1 << 2,     // 4
  COMPONENT = ShapeFlags.FUNCTIONAL_COMPONENT | ShapeFlags.STATEFUL_COMPONENT // 6
}

组件VNode的创建过程

import UserCard from './UserCard.vue';

// 这行代码背后
const vnode = h(UserCard, { user: userInfo }, {
  default: () => h('span', 'children')
});

// 实际执行的是
function createComponentVNode(component, props, children) {
  // 规范化props
  props = normalizeProps(props);
  
  // 提取key和ref
  const { key, ref } = props || {};
  
  // 处理插槽
  let slots = null;
  if (children) {
    slots = normalizeSlots(children);
  }
  
  // 创建VNode
  const vnode = {
    type: component,
    props: props || {},
    children: slots,
    key,
    ref,
    shapeFlag: isFunction(component) 
      ? ShapeFlags.FUNCTIONAL_COMPONENT 
      : ShapeFlags.STATEFUL_COMPONENT,
    
    // 组件实例(稍后填充)
    component: null,
    
    // 其他内部属性
    el: null,
    anchor: null,
    appContext: null
  };
  
  return vnode;
}

组件实例的设计

为什么需要组件实例?

组件实例是组件的"活"的体现,它包含了组件的所有状态和功能: 组件状态与功能

组件实例的结构

一个完整的组件实例包含以下核心部分:

class ComponentInstance {
  // 基础标识
  uid = ++uidCounter;           // 唯一ID
  type = null;                  // 组件定义对象
  parent = null;                 // 父组件实例
  appContext = null;             // 应用上下文
  
  // 状态相关
  props = null;                  // 解析后的props
  attrs = null;                  // 非prop属性
  slots = null;                  // 插槽
  emit = null;                   // 事件发射器
  
  // 响应式系统
  setupState = null;             // setup返回的状态
  data = null;                   // data选项
  computed = null;               // 计算属性
  refs = null;                   // 模板refs
  
  // 生命周期
  isMounted = false;              // 是否已挂载
  isUnmounted = false;            // 是否已卸载
  isDeactivated = false;          // 是否被keep-alive缓存
  
  // 渲染相关
  subTree = null;                // 渲染子树
  render = null;                  // 渲染函数
  proxy = null;                   // 渲染代理
  withProxy = null;               // 带with语句的代理
  
  // 依赖收集
  effects = null;                 // 组件级effects
  provides = null;                // 依赖注入
  components = null;              // 局部注册组件
  directives = null;              // 局部注册指令
  
  constructor(public vnode, parent) {
    this.type = vnode.type;
    this.parent = parent;
    this.appContext = parent ? parent.appContext : vnode.appContext;
    
    // 初始化空容器
    this.props = {};
    this.attrs = {};
    this.slots = {};
    this.setupState = {};
    
    // 创建代理
    this.proxy = new Proxy(this, PublicInstanceProxyHandlers);
  }
}

为什么需要代理?

组件实例的代理(proxy)是为了提供一个统一的访问接口:

// 实例代理处理函数
const PublicInstanceProxyHandlers = {
  get({ _: instance }, key) {
    const { setupState, props, data } = instance;
    
    // 优先从setupState获取
    if (key in setupState) {
      return setupState[key];
    }
    
    // 然后从props获取
    else if (key in props) {
      return props[key];
    }
    
    // 然后从data获取
    else if (data && key in data) {
      return data[key];
    }
    
    // 最后是内置属性
    else if (key === '$el') {
      return instance.subTree?.el;
    }
    // ... 其他内置属性
  },
  
  set({ _: instance }, key, value) {
    const { setupState, props, data } = instance;
    
    // 按照优先级设置
    if (key in setupState) {
      setupState[key] = value;
    } else if (key in props) {
      // props 是只读的
      console.warn(`Attempting to mutate prop "${key}"`);
      return false;
    } else if (data && key in data) {
      data[key] = value;
    }
    
    return true;
  }
};

这个代理让我们可以在模板中直接使用 count,而不需要写 $data.countsetupState.count

setup 函数的执行时机

setup 的执行时机图

setup的执行时机图

setup 参数解析

setup函数接收两个参数:

setup(props, context) {
  // props: 响应式的props对象
  console.log(props.title);  // 自动解包,无需.value
  
  // context: 一个对象,包含有用的方法
  const { 
    attrs,    // 非prop属性
    slots,    // 插槽
    emit,     // 事件发射
    expose    // 暴露公共方法
  } = context;
  
  // 返回对象,暴露给模板
  return {
    count: ref(0),
    increment() {
      this.count.value++;
    }
  };
}

setup 的内部实现

function setupComponent(instance) {
  const { type, props, children } = instance.vnode;
  const { setup } = type;
  
  if (setup) {
    // 创建setup上下文
    const setupContext = createSetupContext(instance);
    
    // 设置当前实例(用于getCurrentInstance)
    setCurrentInstance(instance);
    
    try {
      // 执行setup
      const setupResult = setup(
        props,           // 只读的props
        setupContext     // 上下文
      );
      
      // 处理返回值
      handleSetupResult(instance, setupResult);
    } finally {
      // 清理
      setCurrentInstance(null);
    }
  }
  
  // 完成组件初始化
  finishComponentSetup(instance);
}

function createSetupContext(instance) {
  return {
    // 非prop属性
    get attrs() {
      return instance.attrs;
    },
    
    // 插槽
    get slots() {
      return instance.slots;
    },
    
    // 事件发射
    emit: instance.emit,
    
    // 暴露公共方法
    expose: (exposed) => {
      instance.exposed = exposed;
    }
  };
}

function handleSetupResult(instance, setupResult) {
  if (setupResult && typeof setupResult === 'object') {
    // 返回对象:作为模板上下文
    instance.setupState = proxyRefs(setupResult);
  } else if (typeof setupResult === 'function') {
    // 返回函数:作为渲染函数
    instance.render = setupResult;
  }
}

render 函数的调用

从 setup 到 render

从setup到render到挂载流程图

render 函数的创建

Vue3 中,render 函数可以通过多种方式获得:

function finishComponentSetup(instance) {
  const Component = instance.type;
  
  // 1. 优先使用setup返回的render函数
  if (!instance.render) {
    if (Component.render) {
      // 2. 使用组件选项中的render
      instance.render = Component.render;
    } else if (Component.template) {
      // 3. 编译模板为render函数
      instance.render = compile(Component.template);
    }
  }
  
  // 对函数式组件的处理
  if (!Component.render && !Component.template) {
    // 如果组件本身是函数,当作render函数
    if (typeof Component === 'function') {
      instance.render = Component;
    }
  }
}

渲染代理的工作机制

渲染代理让模板可以轻松访问各种状态:

const PublicInstanceProxyHandlers = {
  get(target, key) {
    const instance = target._;
    const { setupState, props, data } = instance;
    
    // 1. 特殊处理以$开头的内置属性
    if (key[0] === '$') {
      switch (key) {
        case '$el': return instance.subTree?.el;
        case '$props': return props;
        case '$slots': return instance.slots;
        case '$parent': return instance.parent?.proxy;
        case '$root': return instance.root?.proxy;
        case '$emit': return instance.emit;
        case '$refs': return instance.refs;
      }
    }
    
    // 2. 普通状态查找
    if (setupState && key in setupState) {
      return setupState[key];
    }
    if (props && key in props) {
      return props[key];
    }
    if (data && key in data) {
      return data[key];
    }
    
    // 3. 没找到返回undefined
    return undefined;
  }
};

手写实现:mountComponent

mountComponent的整体流程

  1. 创建组件实例:const instance = createComponentInstance(vnode);
  2. 初始化并执行组件: setupComponent(instance);
  3. 设置渲染effect:setupRenderEffect(instance, container, anchor);
  4. 返回组件实例:return instance;

创建组件实例

let uidCounter = 0;

function createComponentInstance(vnode, parent = null) {
  const instance = {
    // 基础信息
    uid: ++uidCounter,
    vnode,
    type: vnode.type,
    parent,
    
    // 状态
    props: {},
    attrs: {},
    slots: {},
    setupState: {},
    
    // 渲染相关
    render: null,
    subTree: null,
    isMounted: false,
    
    // 生命周期
    isUnmounted: false,
    
    // 代理
    proxy: null,
    
    // emit函数
    emit: () => {},
    
    // 上下文
    appContext: parent ? parent.appContext : vnode.appContext,
    provides: parent ? Object.create(parent.provides) : {}
  };
  
  // 创建代理
  instance.proxy = new Proxy(instance, PublicInstanceProxyHandlers);
  
  // 绑定emit
  instance.emit = createEmit(instance);
  
  return instance;
}

设置渲染 effect

function setupRenderEffect(instance, container, anchor) {
  // 创建组件更新函数
  const componentUpdateFn = () => {
    if (!instance.isMounted) {
      // 首次挂载
      
      // 1. 执行render函数,生成子树VNode
      const subTree = instance.render.call(
        instance.proxy,    // this指向代理
        instance.proxy     // 第一个参数
      );
      
      // 2. 保存子树
      instance.subTree = subTree;
      
      // 3. 挂载子树
      patch(null, subTree, container, anchor);
      
      // 4. 保存根元素引用
      instance.vnode.el = subTree.el;
      
      // 5. 标记已挂载
      instance.isMounted = true;
      
      // 6. 触发mounted钩子
      invokeLifecycle(instance, 'mounted');
    } else {
      // 更新阶段
      
      // 1. 获取新子树
      const nextTree = instance.render.call(
        instance.proxy,
        instance.proxy
      );
      
      // 2. 保存旧子树
      const prevTree = instance.subTree;
      instance.subTree = nextTree;
      
      // 3. 执行更新
      patch(prevTree, nextTree, container, anchor);
      
      // 4. 更新元素引用
      instance.vnode.el = nextTree.el;
      
      // 5. 触发updated钩子
      invokeLifecycle(instance, 'updated');
    }
  };
  
  // 创建ReactiveEffect
  const effect = new ReactiveEffect(
    componentUpdateFn,
    // 调度器:异步更新
    () => queueJob(instance.update)
  );
  
  // 保存更新函数
  instance.update = effect.run.bind(effect);
  
  // 立即执行首次渲染
  instance.update();
}

完整的mountComponent实现

function mountComponent(vnode, container, anchor) {
  // 1. 创建组件实例
  const instance = createComponentInstance(vnode);
  
  // 2. 初始化 props 和 slots(如果有props 和 slots)
  initProps(instance, vnode.props);
  initSlots(instance, vnode.children);
  
  // 3. 初始化并执行组件
  setupComponent(instance);
  
  // 4. 创建渲染effect
  setupRenderEffect(instance, container, anchor);
  
  // 5. 返回组件实例
  return instance;
}

// 初始化props
function initProps(instance, rawProps) {
  const props = {};
  const attrs = {};
  
  const options = instance.type.props || {};
  
  // 根据组件定义的props进行过滤
  if (rawProps) {
    for (const key in rawProps) {
      if (options[key] !== undefined) {
        // 是定义的prop
        props[key] = rawProps[key];
      } else {
        // 是普通属性
        attrs[key] = rawProps[key];
      }
    }
  }
  
  instance.props = shallowReactive(props);
  instance.attrs = shallowReactive(attrs);
}

// 初始化slots
function initSlots(instance, children) {
  if (children) {
    instance.slots = normalizeSlots(children);
  }
}

// 规范化插槽
function normalizeSlots(children) {
  if (typeof children === 'function') {
    // 单个函数:默认插槽
    return { default: children };
  } else if (Array.isArray(children)) {
    // 数组:也是默认插槽
    return { default: () => children };
  } else if (typeof children === 'object') {
    // 对象:多个插槽
    const slots = {};
    for (const key in children) {
      const slot = children[key];
      slots[key] = (props) => normalizeSlot(slot, props);
    }
    return slots;
  }
  return {};
}

组件渲染的生命周期

完整的组件生命周期流程图

完整的组件生命周期流程图

生命周期钩子的触发时机

// 生命周期钩子的内部实现
const LifecycleHooks = {
  BEFORE_CREATE: 'bc',
  CREATED: 'c',
  BEFORE_MOUNT: 'bm',
  MOUNTED: 'm',
  BEFORE_UPDATE: 'bu',
  UPDATED: 'u',
  BEFORE_UNMOUNT: 'bum',
  UNMOUNTED: 'um'
};

function invokeLifecycle(instance, hook) {
  const handlers = instance.type[hook];
  if (handlers) {
    // 设置当前实例
    setCurrentInstance(instance);
    
    // 执行钩子函数
    if (Array.isArray(handlers)) {
      handlers.forEach(handler => handler.call(instance.proxy));
    } else {
      handlers.call(instance.proxy);
    }
    
    // 清理
    setCurrentInstance(null);
  }
}

一个完整示例的渲染过程

// 示例:父子组件
const Child = {
  props: ['message'],
  setup(props) {
    console.log('Child setup');
    return {};
  },
  render() {
    console.log('Child render');
    return h('div', '子组件: ' + this.message);
  }
};

const Parent = {
  setup() {
    console.log('Parent setup');
    const msg = ref('Hello');
    
    setTimeout(() => {
      msg.value = 'World';
    }, 1000);
    
    return { msg };
  },
  render() {
    console.log('Parent render');
    return h('div', [
      h('h1', '父组件'),
      h(Child, { message: this.msg })
    ]);
  }
};

// 挂载
const vnode = h(Parent);
render(vnode, document.getElementById('app'));

// 控制台输出顺序:
// Parent setup
// Child setup
// Parent render
// Child render
// (1秒后)
// Parent render
// Child render

结语

本文深入剖析了Vue3组件渲染的完整过程,对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

❌