普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月8日首页

深入理解 Vue.js 渲染机制:从声明式到虚拟 DOM 的完整实现

作者 EchoEcho
2026年2月8日 12:36

相关概念:

命令式 VS 声明式

从范式上来看,视图层框架通常分为:

  • 命令式框架
    • 更加关注过程,代码本身描述的是“做事的过程”,符合逻辑直觉
    •   // 自然语言描述能够与代码产生一一对应的关系
        // 示例:
        const div = document.querySelector('#app'// 获取div
        div.innerText = 'hello world'// 设置文本内容
        div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件
      
  • 声明式框架
    • 更加关注结果,主要是提升代码的可维护性
    •   // 用户提供一个“预期的结果”,中间的过程由vue.js实现
        // 示例
        <div @click="()  => alert('ok')">hello world</div>
      
    • 更新时性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗

因为声明式框架在更新时比命令式框架多了“找出差异”的过程,所以声明式代码的性能不会优于命令式代码的性能。而对比命令式代码,声明式代码又具有更强的可维护性,更加的直观。所以框架要做的就是:在保持可维护性的同时让性能损失最小化

在开发过程中,原生JS操作DOM,虚拟DOM和innerHTML三者操作页面的性能都与创建页面、更新页面,页面大小、变更部分的大小有关系,选择哪种更新策略,需要结合心智负担、可维护性等因素综合考虑。

性能对比

更新策略 心智负担 可维护性 性能 适用场景
原生JS 最高 简单页面
虚拟DOM 复杂应用
innerHTML 静态内容

运行时 VS 编译时

以上文中声明式框架示例代码为例,简单描述vue.js的渲染过程:

1、通过编译器【compile】 解析模版字符串识别到需要创建一个DOM元素,设置内容为hello world,并为其绑定一个点击事件,完成后输出一个虚拟DOM【即一个描述真实DOM的js对象】

2、通过渲染函数【render】 将虚拟DOM渲染成真实的DOM树挂载到指定元素上,完成渲染

当设计一个框架的时候,有三种选择

  • 纯运行时
    • 上面提到的如果只用渲染函数,由用户直接提供虚拟DOM作为入参,就是所谓的纯运行时框架
    • 没有编译过程,也就无法添加相关的优化手段,比如tree-shaking
  • 运行时 + 编译时
    • 代码运行时由编译器将语义化代码编译成目标数据并作为渲染函数的入参,这种操作就是 运行时编译框架。它既支持运行时【即用户直接提供数据对象】,又支持编译时【即将用户语义化代码编译为目标数据】
    • 由于代码运行时才开始编译会产生一定的性能开销,因此可以在构建时就执行编译操作,以提升性能。【在 Vue 3.5.22 中,运行时编译通过 @vue/compiler-dom 实现,构建时编译通过 @vitejs/plugin-vue 实现】
  • 纯编译时
    • 如果省略上面的渲染函数,直接将用户代码通过编译器完成真实DOM的渲染,就是一个纯编译时框架。即不支持任何运行时内容。
    • 由于不需要任何运行时,而是直接将代码编译成可执行的js代码,因为性能可能会更好,但是有损灵活性。

Vue.js就是内部封装了命令式代码从而实现的面向用户的声明式框架;是运行时+编译时架构,目的在于保持灵活性的基础上尽可能的优化性能

其中组件的实现依赖于渲染器,组件中模板的编译依赖于编译器虚拟DOM作为媒介在整个渲染过程中作为组件真实DOM的载体协助实现内容渲染和更新。

虚拟DOM【vnode

虚拟DOM 是一个用来描述真实DOM的js对象。

使用虚拟DOM的好处是可以将不同类型的标签、属性及子节点抽象成一个对象,这样描述UI可以更加灵活。

// 上文中的代码可以用以下形式表示
const vnode= {
    // 标签名称
    tag'div',
    // 标签属性
    props: {
        onClick: () =>alert('ok')
    },
    // 子节点
    children'hello world'
}

vue中的h函数就是一个辅助创建虚拟DOM的工具函数

import { h } from 'vue'

export default {
    render() {
        return h('div', { onClick: () => alert('ok') }, 'hello world')
    }
}

// 等价于
export default {
    render() {
        return {
            tag: 'div',
            props: {
                onClick: () => alert('ok')
            },
            children: 'hello world'
        }
    }
}

// 等价于
<div @click="() => alert('ok')">hello world</div>

虚拟DOM的性能优势:

  • 批量更新:可以将多次DOM操作合并为一次
  • 跨平台:同一套代码可以渲染到不同平台
  • 优化策略:通过diff算法最小化DOM操作

组件

组件就是一组DOM元素的封装,它可以是一个返回虚拟DOM的函数,也可以是一个对象。组件的返回值也是虚拟DOM,它代表组件要渲染的内容。

编译器【compile】

编译器的作用是将组件模板【<template>】编译为渲染函数并添加到<script>标签块的组件对象上

// demo.vue
<template>
<div@click="handler">
        hello world
    </div>
</template>

<script>
exportdefault {
        data() { }
        methods: {
            handler: () =>alert('ok')
        }
    }
</script>

组件编译后结果:

exportdefault {
    data() {},
    methods: {
        handler: () =>alert('ok')
    },
    render() {
        return _createElementVNode('div', { onClick: handler }, 'hello world', -1/* HOISTED */)
    }
}

无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的。然后再将渲染函数返回的虚拟DOM作为渲染器的入参,进行真实DOM的渲染

Vue3的编译优化:

  • 静态提升:将静态内容提升到渲染函数外部
  • 补丁标记:为动态内容添加标记,优化diff过程【通过在虚拟DOM中添加标记实现】
  • tree-shaking:移除未使用代码

渲染器【renderer】

渲染器的作用就是递归遍历虚拟DOM对象,并调用原生DOM API来完成真实DOM的创建。

渲染器的精髓在于后续的更新,它会通过Diff算法寻找并且只更新变化内容。

大致实现思路如下:

  • 如果不是内容变更:
    • 根据vnode.tag创建对应DOM元素
    • 遍历vnode.props对象,如果keyon字符开头,说明它是一个事件,调用addEventListener绑定事件处理函数;否则作为属性添加到DOM元素上
    • 处理children,如果是字符串,就创建文本节点;如果是数组就递归调用render继续渲染,最后把创建的元素挂载到新创建的元素内
  • 否则先找出vnode对象的变更点,并且只更新变更的内容

组件渲染过程详解:

vite@vitejs/plugin-vuevue-core的关系
  • vite中使用了@vitejs/plugin-vue来处理vue组件

  • @vitejs/plugin-vue中集成了vue-core中的compiler-sfc用于解析编译Vue组件

  • compiler-sfc中调用了compiler-core中的基础逻辑进行组件的编译和渲染

当我们新建并启动vue项目后,内容是如何渲染的,又是如何实时更新的?

创建并启动一个Vue应用 

// 创建新项目
npm create vue@latest
// 进入项目后安装依赖
npm install
// 启动,实际执行的是vite命令
npm run dev

当项目运行npm run dev命令时执行内容如下:

编译阶段:

启动一个vite开发服务器,浏览器会通过这个服务器来访问此项目的网页和代码

vite是一个通用的构建工具,vite本身并不直接处理.vue文件,而是通过插件系统来处理各种类型文件,其中@vitejs/plugin-vue就是用来处理vue单文件组件的

图片

构建时阶段

Vite接收到组件请求,会执行插件【@vitejs/plugin-vue】的load钩子函数,再执行Transform钩子函数

图片

在上图钩子函数执行过程中触发了compiler-sfc相关方法的执行

图片图片

监听组件变化

@Vitejs/plugin-vue插件的核心入口文件【packages/plugin-vue/src/index.ts】中定义了Vite插件的所有钩子函数,其中handleHotUpdate钩子是Vite提供的热更新处理函数,当Vue文件发生变化时,Vite会自动调用这个钩子,此时插件会检查变化的文件是否为Vue组件,如果是则调用专门的handleHotUpdate函数packages/plugin-vue/src/handleHotUpdate.ts

图片

最终将返回

SFCTemplateCompileResults : {
    code: string, // 渲染函数代码
    ast?: RootNode, // 抽象语法树
    preamble?: string// 预处理代码
    source: string// 输入源
    tips: string[], // 提示
    errors: (string | CompilerError)[], // 错误
    map?: RawSourceMap, // 源映射
}

这个阶段会将.vue文件转换为js代码,生成的是渲染函数的字符串

运行时阶段

当浏览器加载并执行这些js代码时,就会发生真正的渲染过程

应用启动 -> createApp() -> app.mount() -> render() -> patch() -> mountElement() -> 真实DOM

图片

到此就完成了vue中基本的渲染过程。

Vue项目BMI计算器技术实现

作者 滕青山
2026年2月8日 00:05

BMI计算器工具开发技术实现

本文主要分享一下我最近开发的 BMI 计算器工具的技术实现细节。这个工具基于 Vue 3 和 Nuxt.js 构建,包含核心计算逻辑和交互式的用户界面。我们将重点关注其功能实现部分。

在线工具网址:see-tool.com/bmi-calcula…

工具截图: 在这里插入图片描述

项目结构

这个工具的实现主要分为两个部分:

  1. 逻辑层utils/bmi-calculator.js —— 负责核心的 BMI 数值计算和状态判定。
  2. 视图层pages/bmi-calculator.vue —— 负责用户交互、输入验证和结果展示。

1. 核心计算逻辑

计算逻辑封装在 calculateBmi 函数中。它接收用户的身高(cm)和体重(kg)作为输入,返回计算后的 BMI 值以及对应的身体状态类别和健康风险等级。

1.1 输入验证

在进行计算之前,我们需要确保输入的数据是有效的数值且大于 0。如果输入无效,函数会抛出一个错误,以便前端捕获处理。

  const height = Number(heightCm)
  const weight = Number(weightKg)

  if (!Number.isFinite(height) || !Number.isFinite(weight) || height <= 0 || weight <= 0) {
    throw new Error('INVALID_INPUT')
  }

1.2 BMI 计算公式

BMI 的计算公式是:体重(公斤)除以身高(米)的平方。

  const heightInMeters = height / 100
  // 体重 / (身高^2)
  const bmiRaw = weight / (heightInMeters * heightInMeters)
  // 保留一位小数
  const bmi = Number(bmiRaw.toFixed(1))

1.3 状态判定

根据计算出的 BMI 值,我们可以判定用户的身体状态。这里我们参照了常见的 BMI 标准进行分类:

  • BMI < 18.5: 偏瘦(Underweight),存在营养不良风险。
  • 18.5 ≤ BMI < 24: 正常(Normal),健康风险低。
  • 24 ≤ BMI < 28: 超重(Overweight),通过轻度风险。
  • BMI ≥ 28: 肥胖(Obese),存在较高健康风险。
  if (bmi < 18.5) {
    return { bmi, categoryKey: 'underweight', riskKey: 'malnutrition' }
  }
  if (bmi < 24) {
    return { bmi, categoryKey: 'normal', riskKey: 'low' }
  }
  if (bmi < 28) {
    return { bmi, categoryKey: 'overweight', riskKey: 'mild' }
  }
  return { bmi, categoryKey: 'obese', riskKey: 'high' }

2. Vue 页面实现

页面组件主要由输入表单和结果展示两大部分组成。使用 Vue 3 的 Composition API (<script setup>) 来管理状态和逻辑。

2.1 状态管理

我们使用 ref 来定义响应式变量,用于存储用户的输入和计算结果。

const heightCm = ref('')  // 用户输入的身高
const weightKg = ref('')  // 用户输入的体重
const result = ref(null)  // 用于存储计算结果对象,初始为 null

2.2 用户交互处理

计算操作

当用户点击“计算”按钮或在体重输入框按下回车时,会触发 handleCalculate 方法。

该方法首先调用核心计算函数 calculateBmi。如果计算成功,将结果赋值给 result,页面会自动渲染结果区域;如果捕获到错误(如输入无效),则会提示用户。

const handleCalculate = () => {
  try {
    // 调用工具函数进行计算
    const r = calculateBmi(Number(heightCm.value), Number(weightKg.value))
    result.value = r
  } catch (e) {
    // 计算失败,清空结果并提示错误
    result.value = null
    safeMessage('error', '请输入有效的身高和体重')
  }
}
加载示例

为了方便用户快速体验,我们提供了一个 loadExample 方法,一键填入预设的示例数据并触发计算。

const loadExample = () => {
  heightCm.value = '170'
  weightKg.value = '65'
  handleCalculate()
}
清空重置

clearForm 方法用于重置所有输入和结果,让用户可以重新开始。

const clearForm = () => {
  heightCm.value = ''
  weightKg.value = ''
  result.value = null
}

2.3 结果动态展示

在模板中,我们使用 v-if="result" 来控制结果卡片的显示。只有当 result 有值时,结果区域才会渲染。这种设计保证了页面初始状态的整洁。

结果卡片通过 grid 布局展示了三个关键信息:BMI 数值、身体状态和健康风险。这些信息都直接来自于 result 对象。

<div v-if="result" class="...">
  <!-- BMI 数值 -->
  <p>{{ result.bmi }}</p>
  
  <!-- 身体状态分类 -->
  <p>{{ t(`bmiCalculator.result.categoryMap.${result.categoryKey}`) }}</p>
  
  <!-- 健康风险评估 -->
  <p>{{ t(`bmiCalculator.result.riskMap.${result.riskKey}`) }}</p>
</div>

通过将计算逻辑与界面展示分离,我们保持了代码的清晰和可维护性。Vue 强大的响应式系统让我们能够轻松地通过改变数据状态来驱动界面的更新。

深入Vue 3响应式系统:为什么嵌套对象修改后界面不更新?

2026年2月8日 01:20

一句话简介:Vue 3用Proxy重构了响应式系统,但嵌套对象的"深层响应"背后藏着5个致命陷阱。本文从源码级剖析响应性丢失的根本原因,并提供5种实战解决方案。


📋 目录


1. 背景:一个让人崩溃的Bug

1.1 现场重现

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

const state = reactive({
  user: {
    name: '张三',
    address: {
      city: '北京',
      district: '朝阳区'
    }
  }
})

// ❌ 这个操作不会触发界面更新!
const updateDistrict = () => {
  state.user.address.district = '海淀区'
  console.log('已修改为:', state.user.address.district) // 显示"海淀区"
  // 但界面上还是显示"朝阳区"!
}
</script>

<template>
  <div>
    <p>当前区域: {{ state.user.address.district }}</p>
    <button @click="updateDistrict">修改区域</button>
  </div>
</template>

是不是很像你昨天遇到的Bug?

控制台显示数据已经变了,但界面纹丝不动。你开始怀疑人生:

  • "我明明用了reactive,它不是深层的吗?"
  • "难道Vue 3的响应式坏了?"
  • "是不是需要手动调用什么方法?"

1.2 为什么会这样?

Vue 3的响应式系统基于ES6的Proxy,它确实提供了"深层响应"的能力。但问题出在JavaScript的对象引用机制Vue的依赖收集时机上。

让我们从源码层面一探究竟。


2. 核心原理:Proxy的"代理陷阱"

2.1 Vue 3响应式系统架构

┌─────────────────────────────────────────────────────────┐
│                    Vue 3 响应式系统                      │
├─────────────────────────────────────────────────────────┤
│  原始对象 ──► Proxy代理 ──► 依赖收集(track) ──► 触发更新(trigger)  │
│     │           │              │               │        │
│     │           │              ▼               ▼        │
│     │           │         WeakMap存储      执行effect    │
│     │           │     {target: {key: Set<effect>}}      │
│     ▼           ▼                                       │
│  {a: 1}    Proxy{a: 1}                                  │
│              get() ──track──┐                           │
│              set() ──trigger┘                           │
└─────────────────────────────────────────────────────────┘

2.2 核心源码解析

Vue 3的reactive函数简化实现:

// 简化版源码(基于vuejs/core)
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      // 1. 收集依赖:谁在用这个属性
      track(target, key)
      const result = target[key]
      // 2. 递归代理:让嵌套对象也变成响应式
      if (isObject(result)) {
        return reactive(result)
      }
      return result
    },
    set(target, key, value) {
      const oldValue = target[key]
      target[key] = value
      // 3. 触发更新:通知所有依赖这个属性的effect
      if (hasChanged(value, oldValue)) {
        trigger(target, key)
      }
      return true
    }
  })
}

2.3 依赖收集的"懒惰性"

关键问题:Vue的依赖收集是"按需"的。

const state = reactive({
  user: {
    address: {
      district: '朝阳区'
    }
  }
})

// 场景1:模板中只访问了 state.user
// 收集的依赖:state ──► user
// 当修改 state.user.address.district 时:
// - 修改的是 address 对象,不是 user 对象
// - 没有触发 user 的 setter
// - 界面不更新!

// 场景2:模板中访问了 state.user.address.district
// 收集的依赖:state ──► user ──► address ──► district
// 这时修改 district 才会触发更新

2.4 内存结构图解

初始状态(未访问深层属性):
┌─────────────────────────────────────┐
│  targetMap (WeakMap)                │
│  ├─ state: depsMap                  │
│  │   └─ "user": Set[ComponentEffect]│
│  │   // 注意:没有"address"和"district"的依赖!  │
└─────────────────────────────────────┘

访问深层属性后:
┌─────────────────────────────────────────────────┐
│  targetMap (WeakMap)                            │
│  ├─ state: depsMap                              │
│  │   ├─ "user": Set[ComponentEffect]            │
│  ├─ state.user: depsMap (Proxy)                 │
│  │   ├─ "address": Set[ComponentEffect]         │
│  ├─ state.user.address: depsMap (Proxy)         │
│  │   ├─ "district": Set[ComponentEffect]        │
│  │   // 现在修改 district 会触发更新了!        │
└─────────────────────────────────────────────────┘

3. 5种常见陷阱与解决方案

陷阱1:直接替换嵌套对象属性

❌ 错误示例:

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

const state = reactive({
  form: {
    name: '',
    items: [
      { id: 1, value: 'A' },
      { id: 2, value: 'B' }
    ]
  }
})

// 直接修改数组中的对象属性 - 不触发更新!
const updateItem = () => {
  state.form.items[0].value = 'C'  // ❌ 界面可能不更新
}
</script>

✅ 解决方案1:使用Vue.set风格的赋值

// 方法A:使用 splice 触发数组更新
const updateItem = () => {
  const newItems = [...state.form.items]
  newItems[0] = { ...newItems[0], value: 'C' }
  state.form.items = newItems  // ✅ 触发更新
}

// 方法B:使用 Vue 提供的工具函数
import { set } from 'vue'

const updateItem = () => {
  state.form.items[0].value = 'C'
  // 强制触发更新
  state.form.items = [...state.form.items]
}

✅ 解决方案2:使用ref而非reactive

import { ref } from 'vue'

const form = ref({
  name: '',
  items: [{ id: 1, value: 'A' }]
})

const updateItem = () => {
  // 通过 .value 访问,确保触发响应
  form.value.items[0].value = 'C'
  // 需要整体赋值才会触发
  form.value.items = [...form.value.items]
}

陷阱2:解构赋值丢失响应性

❌ 错误示例:

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

// 解构会失去响应性!
const { user } = state
// user 只是一个普通对象引用,不再是 Proxy

// 修改 user 不会触发界面更新
user.name = '李四'  // ❌ 界面不更新

✅ 解决方案:

// 方法1:始终通过原始对象访问
const updateName = () => {
  state.user.name = '李四'  // ✅ 会触发更新
}

// 方法2:使用 toRefs 保持响应性
import { reactive, toRefs } from 'vue'

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

// toRefs 会将对象的每个属性转换为 ref
const { user } = toRefs(state)
// 现在 user.value 是响应式的

const updateName = () => {
  user.value.name = '李四'  // ✅ 会触发更新
}

// 方法3:在 setup 中直接使用解构(仅限<script setup>)
<script setup>
const state = reactive({ user: { name: '张三' } })
// 直接使用,不要解构
</script>

陷阱3:数组索引修改不触发更新

❌ 错误示例:

const list = reactive([1, 2, 3])

// 直接通过索引修改
list[0] = 100  // ❌ 可能不会触发更新(在某些边界情况下)

✅ 解决方案:

// 方法1:使用 splice
list.splice(0, 1, 100)  // ✅ 触发更新

// 方法2:重新赋值整个数组
list[0] = 100
list.length = list.length  // 强制触发(hack方式,不推荐)

// 方法3:使用 ref 替代
const list = ref([1, 2, 3])
list.value[0] = 100  // ✅ 总是触发更新

陷阱4:Object新增属性不响应

❌ 错误示例:

const state = reactive({
  user: { name: '张三' }
})

// 添加新属性
state.user.age = 25  // ❌ 不会触发更新(即使访问过user)

✅ 解决方案:

// 方法1:使用 Object.assign
Object.assign(state.user, { age: 25 })  // ✅ 触发更新

// 方法2:预先声明所有可能用到的属性
const state = reactive({
  user: { 
    name: '张三',
    age: undefined  // 预先声明
  }
})
state.user.age = 25  // ✅ 现在会触发更新

// 方法3:使用 ref
const user = ref({ name: '张三' })
user.value = { ...user.value, age: 25 }  // ✅ 触发更新

陷阱5:深层嵌套对象的性能陷阱

❌ 问题场景:

const bigData = reactive({
  // 1000条数据,每条都有深层嵌套
  list: Array(1000).fill(null).map((_, i) => ({
    id: i,
    info: {
      detail: {
        deep: { value: i }
      }
    }
  }))
})
// 每次访问都会递归创建 Proxy,性能爆炸!

✅ 解决方案:

import { shallowRef, triggerRef } from 'vue'

// 使用 shallowRef,只有 .value 是响应式的,内部不做深代理
const bigData = shallowRef({
  list: Array(1000).fill(null).map((_, i) => ({
    id: i,
    info: { detail: { deep: { value: i } } }
  }))
})

// 修改深层数据
const updateDeep = () => {
  bigData.value.list[0].info.detail.deep.value = 999
  // 手动触发更新
  triggerRef(bigData)  // ✅ 强制刷新界面
}

4. 深拷贝的坑:你以为的安全其实是噩梦

4.1 深拷贝为什么会破坏响应性?

import { reactive } from 'vue'
import cloneDeep from 'lodash/cloneDeep'

const state = reactive({
  user: { name: '张三', items: [{ id: 1 }] }
})

// ❌ 致命错误:深拷贝后丢失了所有响应性!
const saveData = () => {
  const dataToSave = cloneDeep(state.user)
  // dataToSave 是一个纯对象,没有任何 Proxy 包装
  // 如果你把它赋回 state,响应性就彻底断了
  state.user = dataToSave  // ❌ 现在 state.user 不再是响应式代理!
}

4.2 正确的深拷贝姿势

场景1:需要提交到后端的数据

import { toRaw } from 'vue'

const saveData = () => {
  // 使用 toRaw 获取原始对象(不会递归解包,性能更好)
  const rawData = toRaw(state.user)
  // 发送给后端
  await api.saveUser(rawData)
}

场景2:需要复制数据同时保持响应性

import { reactive } from 'vue'

const duplicateUser = () => {
  // 方法1:逐个属性复制,保持响应性
  const newUser = reactive({
    name: state.user.name,
    items: state.user.items.map(item => ({ ...item }))
  })
  
  // 方法2:使用 JSON 解析(注意:会丢失函数、Date等特殊类型)
  const newUser2 = reactive(JSON.parse(JSON.stringify(state.user)))
}

场景3:使用 Immer 进行不可变更新

import { produce } from 'immer'
import { shallowRef } from 'vue'

const state = shallowRef({
  user: { name: '张三', items: [{ id: 1, value: 'A' }] }
})

const updateItem = () => {
  // Immer 会创建新的不可变对象
  state.value = produce(state.value, draft => {
    draft.user.items[0].value = 'B'
  })
  // shallowRef 检测到 .value 变化,触发更新 ✅
}

4.3 深拷贝 vs 浅拷贝速查表

方法 是否破坏响应性 性能 适用场景
JSON.parse(JSON.stringify()) ✅ 是 简单对象,无循环引用
lodash.cloneDeep ✅ 是 复杂对象,需要完整复制
toRaw() ❌ 否(只读) 提交数据到后端
{...obj} ❌ 否(浅拷贝) 只需复制一层
structuredClone() ✅ 是 现代浏览器,支持更多类型

5. 实战案例:表格嵌套数据更新

5.1 需求描述

实现一个可编辑表格,支持:

  1. 多行数据展示
  2. 每行可以展开显示子表格
  3. 子表格数据可编辑
  4. 编辑后实时更新

5.2 完整代码实现

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

// 表格数据结构
const tableData = reactive({
  rows: [
    {
      id: 1,
      name: '产品A',
      expanded: false,
      children: [
        { id: '1-1', sku: 'SKU001', stock: 100 },
        { id: '1-2', sku: 'SKU002', stock: 200 }
      ]
    },
    {
      id: 2,
      name: '产品B',
      expanded: false,
      children: [
        { id: '2-1', sku: 'SKU003', stock: 150 }
      ]
    }
  ]
})

// ✅ 正确的更新方法:展开/收起
const toggleExpand = (row) => {
  // 直接修改会触发更新
  row.expanded = !row.expanded
}

// ✅ 正确的更新方法:修改库存
const updateStock = (row, childIndex, newStock) => {
  // 方法1:直接修改嵌套属性(如果模板中访问过这个路径)
  row.children[childIndex].stock = newStock
  
  // 方法2:如果不确定是否访问过,强制刷新
  // row.children = [...row.children]
}

// ✅ 正确的更新方法:添加子项
const addChild = (row) => {
  const newChild = {
    id: `${row.id}-${row.children.length + 1}`,
    sku: `SKU00${Date.now()}`,
    stock: 0
  }
  // 使用 push 会触发更新
  row.children.push(newChild)
  
  // 确保展开以显示新添加的行
  row.expanded = true
}

// ❌ 错误示例:直接替换整个 children 数组可能丢失响应性
const wrongUpdate = (row) => {
  // 如果 row.children 是从外部传入的非响应式数据
  row.children = row.children.map(child => ({ ...child }))  // ⚠️ 危险!
}

// ✅ 安全示例:批量更新
const batchUpdate = async (row) => {
  // 批量修改前先冻结更新
  const originalChildren = JSON.parse(JSON.stringify(row.children))
  
  // 修改数据
  originalChildren.forEach(child => {
    child.stock += 10
  })
  
  // 一次性赋值,触发单次更新
  row.children = originalChildren
  
  // 等待 DOM 更新
  await nextTick()
  console.log('批量更新完成')
}
</script>

<template>
  <div class="table-container">
    <table>
      <thead>
        <tr>
          <th>展开</th>
          <th>ID</th>
          <th>名称</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody>
        <template v-for="row in tableData.rows" :key="row.id">
          <!-- 主行 -->
          <tr class="main-row">
            <td>
              <button @click="toggleExpand(row)">
                {{ row.expanded ? '▼' : '▶' }}
              </button>
            </td>
            <td>{{ row.id }}</td>
            <td>{{ row.name }}</td>
            <td>
              <button @click="addChild(row)">添加子项</button>
              <button @click="batchUpdate(row)">批量+10</button>
            </td>
          </tr>
          
          <!-- 子表格 -->
          <tr v-if="row.expanded" class="child-row">
            <td colspan="4">
              <table class="child-table">
                <thead>
                  <tr>
                    <th>SKU</th>
                    <th>库存</th>
                  </tr>
                </thead>
                <tbody>
                  <tr v-for="(child, index) in row.children" :key="child.id">
                    <td>{{ child.sku }}</td>
                    <td>
                      <input 
                        type="number" 
                        v-model="child.stock"
                        @change="updateStock(row, index, child.stock)"
                      />
                    </td>
                  </tr>
                </tbody>
              </table>
            </td>
          </tr>
        </template>
      </tbody>
    </table>
  </div>
</template>

<style scoped>
.table-container {
  padding: 20px;
}
table {
  width: 100%;
  border-collapse: collapse;
}
th, td {
  border: 1px solid #ddd;
  padding: 8px;
  text-align: left;
}
.main-row {
  background: #f5f5f5;
}
.child-row {
  background: #fff;
}
.child-table {
  margin: 10px;
  width: calc(100% - 20px);
}
input {
  width: 80px;
  padding: 4px;
}
</style>

5.3 关键点总结

  1. 模板访问路径很重要:确保模板中访问了你要修改的完整路径
  2. 数组方法优先使用pushsplice 等方法会触发更新
  3. 批量更新优化:多次修改后一次性赋值,减少重渲染次数
  4. nextTick 的时机:需要在 DOM 更新后执行操作时记得使用

6. 性能优化:大规模数据下的最佳实践

6.1 虚拟滚动 + shallowRef

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

// 超大数据列表(10万条)
const hugeList = shallowRef([
  // 假设这里有10万条嵌套数据
])

// 只显示可视区域的数据
const visibleData = computed(() => {
  const start = scrollTop.value // 当前滚动位置
  const end = start + visibleCount.value // 可视数量
  return hugeList.value.slice(start, end)
})

// 修改数据时手动触发
const updateItem = (index, newData) => {
  hugeList.value[index] = newData
  triggerRef(hugeList) // 手动触发更新
}

6.2 分页加载与局部响应

import { reactive, ref } from 'vue'

const state = reactive({
  // 只有当前页的数据是响应式的
  currentPageData: [],
  // 总数据只存原始数据,不做响应式处理
  allData: []
})

// 切换页面时更新响应式数据
const changePage = (page) => {
  const start = (page - 1) * pageSize
  const end = start + pageSize
  // 只让当前页数据成为响应式
  state.currentPageData = state.allData.slice(start, end)
}

6.3 使用 Map/Set 替代对象数组

import { reactive } from 'vue'

// ❌ 低效:大数组查找
const list = reactive([
  { id: 1, data: {} },
  { id: 2, data: {} },
  // ... 10000条
])
// 查找需要 O(n)
const item = list.find(i => i.id === targetId)

// ✅ 高效:使用 Map
const dataMap = reactive(new Map())
dataMap.set(1, { data: {} })
dataMap.set(2, { data: {} })
// 查找只需 O(1)
const item = dataMap.get(targetId)

7. 总结与避坑清单

7.1 核心要点回顾

┌─────────────────────────────────────────────────────────────┐
│                   Vue 3 嵌套数据更新避坑指南                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 访问路径原则                                            │
│     └── 模板中必须访问到你要修改的最深层属性                   │
│                                                             │
│  2. 赋值触发原则                                            │
│     └── 直接修改对象属性可能不触发,考虑整体替换              │
│                                                             │
│  3. 解构危险                                                │
│     └── 解构 reactive 对象会失去响应性,使用 toRefs          │
│                                                             │
│  4. 深拷贝陷阱                                              │
│     └── cloneDeep 会破坏响应性,使用 toRaw 或浅拷贝          │
│                                                             │
│  5. 性能优化                                                │
│     └── 大数据用 shallowRef + triggerRef 手动控制            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

7.2 快速决策流程图

遇到嵌套数据不更新?
    │
    ├─ 是否在模板中访问了完整路径?
    │   ├─ 否 → 补充访问路径:{{ obj.level1.level2 }}
    │   └─ 是 → 继续
    │
    ├─ 是否使用了深拷贝(cloneDeep)?
    │   ├─ 是 → 换成 toRaw() 或浅拷贝
    │   └─ 否 → 继续
    │
    ├─ 是否解构了 reactive 对象?
    │   ├─ 是 → 使用 toRefs() 或避免解构
    │   └─ 否 → 继续
    │
    ├─ 数据量是否很大(>1000条)?
    │   ├─ 是 → 使用 shallowRef + triggerRef
    │   └─ 否 → 继续
    │
    └─ 尝试强制刷新:
        ├─ 数组:arr = [...arr]
        ├─ 对象:obj = { ...obj }
        └─ 或使用 nextTick() 延迟更新

7.3 推荐工具函数

// utils/reactiveHelper.js

import { reactive, toRaw, isProxy } from 'vue'

/**
 * 安全地更新嵌套对象属性
 */
export function safeUpdate(obj, path, value) {
  const keys = path.split('.')
  let current = obj
  
  for (let i = 0; i < keys.length - 1; i++) {
    current = current[keys[i]]
  }
  
  current[keys[keys.length - 1]] = value
  
  // 如果是 reactive 对象,触发更新
  if (isProxy(obj)) {
    // 强制刷新(hack 方式,慎用)
    Object.assign(obj, obj)
  }
}

/**
 * 深度克隆但保持响应性(适用于简单对象)
 */
export function cloneReactive(obj) {
  const raw = toRaw(obj)
  return reactive(JSON.parse(JSON.stringify(raw)))
}

/**
 * 批量更新数组(减少重渲染)
 */
export function batchUpdateArray(arr, updates) {
  // updates: [{ index: 0, value: newValue }, ...]
  const newArr = [...arr]
  updates.forEach(({ index, value }) => {
    newArr[index] = value
  })
  return newArr
}

7.4 最后的话

Vue 3的响应式系统基于Proxy确实是巨大的进步,但它不是银弹。理解依赖收集的惰性Proxy的代理边界,是避免嵌套数据更新问题的关键。

记住:响应式不是魔法,是精确追踪。当你明白Vue在什么时机、追踪哪些依赖,你就能游刃有余地处理任何复杂的数据结构。


参考链接

  1. Vue 3 响应式原理官方文档 - 验证状态: ✅
  2. Vue 3 Reactivity API 高级用法 - 验证状态: ✅
  3. GitHub Issue #1387 - 嵌套属性更新问题 - 验证状态: ✅
  4. Proxy MDN 文档 - 验证状态: ✅
  5. Immer 不可变数据更新库 - 验证状态: ✅

如果本文对你有帮助,欢迎点赞收藏!你在使用 Vue 3 响应式时还遇到过哪些坑?欢迎在评论区分享。

昨天以前首页

高德地图「点标记+点聚合」加载慢、卡顿问题 解决方案

作者 Joie
2026年2月7日 14:04

coffee.joiepink.space/

Coffee网页的设计灵感来自于一个普通的在星巴克喝咖啡的下午,突发奇想能不能把全国的星巴克门店都整合到一起,用地图可视化的形式展示门店分布密度,为咖啡爱好者提供便捷的门店查询和导航服务。

于是便诞生了。

技术栈:Vue Amap UnoCSS Icônes Vant ESLint Vite

coffee-performance-01.png

痛点:本项目有8000+的数据量,需要在高德地图上点标记每家星巴克门店且支持交互,由于数据量过大,首次进入页面加载速度很慢,且把8000+点标记显示在地图上,点标记的交互动作会崩盘,地图操作响应速度也会变慢、卡顿,非常影响用户的使用体验

基于此,我从以下几个方面对项目进行了性能优化

  1. Amap SDK按需、动态加载
  2. 用 shallowRef 存地图相关实例
  3. 点聚合 + 只渲染视野内点位
  4. 视口变化防抖 + 只绑一次
  5. 首屏后再拉数据
  6. 主题切换与地图样式

1. Amap SDK按需、动态加载

在地图页面的js中,我并不在js顶部写import AMapLoader from '@amap/amap-jsapi-loader'

这种使用方法有两个弊端:

第一方面,在{Vite}打包的时候,这个依赖会被打包进入首屏就要加载的bundle(主chunk或和主入口一起被加载的chunk),用户第一次打开页面的时候,浏览器就会一起下载这份包含高德的JS,导致首屏体积变大,加载速度变慢。

第二方面,这种方式在模块被Node执行的时候们就会运行,于是会加载@amap/amap-jsapi-loader及其内部依赖, 而{Amap}内部SDK/loader会用到window,但是Node里面是没有window的,所以会导致报错(例如 Reference Error: window is not defined)。

为了避免以上两种问题,我在初始化{Amap}的函数里写const {default: AMapLoader} = await import('@amp/amp-jsapi-loader'),这个函数只在onMounted生命周期中调用,也就是说只在浏览器里、页面挂载之后才会执行

在{Vite}打包的时候,@amap/amap-jsapi-loader会被打包成单独的chunk,只有执行到const {default: AMapLoader} = await import('@amp/amp-jsapi-loader')的时候才会加载这段JS,首屏主bundle里并没有{Amap}相关代码,所以首包更小、首屏更快。

SSG时Node不会执行onMounred钩子,所以不会执行这段import,自然也就不会在Node里加载高德,不会碰到window,避免了报错。

async function initMap() {
  const { default: AMapLoader } = await import('@amap/amap-jsapi-loader') // [!code hl]
  const amapKey = import.meta.env.VITE_AMAP_KEY
  if (!amapKey)
    return Promise.reject(new Error('Missing VITE_AMAP_KEY'))
  window._AMapSecurityConfig = { securityJsCode: amapKey }
  return AMapLoader.load({
    key: amapKey,
    version: '2.0',
    plugins: ['AMap.Scale', 'AMap.MarkerCluster', 'AMap.Geolocation'], // [!code hl]
  })
}

initMap函数中,我使用{Amap}2.0的按插件加载特性,通过AMapLoader.load({plugins: [...]})按需加载需要的插件,这种方式在项目中精准引入需要使用的插件,使得项目请求更少、解析更少、地图初始化更轻,从而加快了加载速度、减小了打包的包体积。

2. 用 shallowRef 存地图相关实例

const map = shallowRef(null)
const currentLocationMarker = shallowRef(null)
const geolocation = shallowRef(null)
const Amap = shallowRef(null)

/** 根据 isDark 设置地图底图样式;返回 Promise,地图样式切换完成后再 resolve */
function applyMapStyle() {
    if (!map.value) return Promise.resolve()
  const style = i
   sDark.value ? 'amap://styles/dark' : 'amap://styles/normal'
  return new Promise((resolve) => {
      nextTick(() => {
        map.value?.setMapStyle(style)
      setTimeout(resolve, 1800)
    })
})
}

/** 点击切换主题 */
function onThemeToggle() {
    themeChanging.value = true
  nextTick(() => {
      toggleDark()

/** 监听 isDark,切完样式再关 loading */
watch(isDark, () => {
    const p = applyMapStyle()
  if (p) {
      p.finally(() => {
        themeChanging.value = false

    se {
  }
    themeChanging.value = false
}

先来说一下{Vue}中refshallowRef的区别

ref: {Vue}会对你塞进去的整个对象做深度响应式代理——递归的把对象里每一层属性都报称getter/setter(Proxy), 这样任意一层属性变了都会触发更新

shallowRef:只对[.value这一层]做响应式。当把一个对象赋值给shallowRef.value的时候,{Vue}不会去递归代理这个对象内部,内部属性变了{Vue}并不知道,但只有把整个对象换掉(重新赋值.value)时候才会触发更新

如果使用ref来存储{Amap}实例,会出现因深度代理造成的postMessage克隆报错,例如:DOMException: Failed to execute 'postMessage' on 'Worker': AMap.Map#xxx could not be cloned.

{Amap}的实例如Map Marker Geolocation MarkerCluster等内部会用到postMessage例如和地图iframe/worker通信

浏览器在发postMessage的时候,会对要传的数据做结构化克隆 structured clone,把对象序列号之后再在另一边反序列化,而Proxy对象不支持被克隆,所以会报错

能被结构化克隆的:普通对象、数组、部分简单类型

不能被结构化克隆的:函数、DOM节点、Proxy对象(Vue的响应式对象就是Proxy)

如果使用shallowRef存储的话,赋值给shallowRef.value的是{Amap}原始的实例对象,{Vue}不会去递归代理它里面的属性,也就不会报错。而ref存储会递归遍历、创建大量的Proxy,但其实并不需要在地图内部某个坐标变了就触发Vue更新,我们只需要在换地图、换marker、换聚合的这种整实例替换的时候更新即可。所以shallowRef的时候内存和CPU开销都更小,从而最性能有利。

3. 点聚合 + 只渲染视野内点位

为了解决数据量过大导致DOM数量巨大(每个门店创建一个marker标记)导致卡顿崩盘的问题,我使用{Amap}的点聚合 MarkerCluster将距离近的一批点在逻辑上归为一组,地图上只画一个聚合点,用户放大地图时,聚合会拆开变成更小的簇或者单点,缩小地图的时候,会合并成更大的簇。

即使使用点聚合 MarkerCluster,如果把全国所有门店(8000+)的数据量一次性都塞给点聚合 MarkerCluster,聚合算法要对这所有点都做距离计算、分簇、计算量十分之庞大,而绝大部分点并不在当前用户所见的屏幕内,用户根本看不到,却还是在耗费后台进行参与计算和内部管理,所以,更合理的做法是只把当前视野 current viewport内的点标记交给点聚合 MarkerCluster进行计算,而视野外的点不参与计算和渲染,当用户拖动地图画布或者放大缩小当前视野的时候,再进行计算参与。

具体做法如下:

function updateClusterByViewport(AmapInstance) {
    if (!map.value || !pointList.value?.length) return
  // 返回当前地图可视区域的地理范围(一个矩形,有西南角、东北角经纬度)。

  const b = map.value.getBounds()
  if (!b) return
  // 过滤当前
   视野内的点数据
  const inBounds = pointList.value.filter((point) => {
      const ll = point.lnglat
    const lng = Array.isArray(ll) ? ll[0] : (ll?.lng ?? ll?.longitude)
    const lat = Array.isArray(ll) ? ll[1] : (ll?.lat ?? ll?.latitude)
    // 判断点是否在当前视野矩形内
    return lng != null && lat != null && b.contains(new AmapInstance.LngLat(lng, lat))
   }
  // 把「视野内点数」存起来给界面用
  pointsInBounds.value = inBounds

    // 销毁旧聚合并只拿视野内的点重建聚合
  if (clusterRef.value) {
      clusterRef.value.setMap(null)
    clusterRef.value = null
   }
  if (!inBounds.length) return
  const myList = inBoun
   ds.map((point) => ({
      lnglat: Array.isArray(pont.lnlat)
        ? point.lnglat
      : [point.lnglat?.lng ?? point.lnglat?.longitude, point.lnglat?.lat ?? point.lnglat?.latitude],
   id:point.id,
  })
  const gridSize = 60
  const cluster = new AmapInstance.MarkerCluster(map.value, myList, {
      gridSize,
    renderClusterMarker: createRenderClusterMarker(AmapInstance, myList.length),
    renderMarker: createRenderStoreMarker(AmapInstance),
  })
  setupClusterClickZoom(cluster)
  clusterRef.value = cluster
}

4. 视口变化防抖 + 只绑一次

当用户拖拽、缩放地图的时候,地图会连续触发很多次moveend/zoomend的事件,如果每次触发都执行上文的updateClusterByViewport方法,计算执行过于频繁,容易造成页面卡顿、浪费CPU,因此为这些操作都加上防抖

const onViewportChange = useDebounceFn(() => updateClusterByViewport(Amap.value), 150)
map.value.on('moveend', onViewportChange)
map.value.on('zoomend', onViewportChange)

5. 首屏后再拉数据

首屏加载的时候,应该把注意力放在地图容器快速渲染上面,从而给用户一个比较好的使用体验。而加载数据(loadAndRenderStores)会执行请求数据、处理数据、渲染视野内点聚合这一系列操作,逻辑较重,因此如果在地图还没准备好、或者首屏还在渲染的时候同步做这些事情,就会占用主线程,从而拖慢首屏DOM的渲染、拖慢地图SDK的首次绘制,所以把目标变成:先让首屏和地图第一次渲染完成,再在浏览器空闲的时候去拉取数据、计算聚合。

map.value.on('complete', () => {
  const run = () => loadAndRenderStores(AmapInstance)
  if (typeof requestIdleCallback !== 'undefined') {
    requestIdleCallback(run, { timeout: 500 })
  }
  else {
    setTimeout(run, 0)
  }
  mapReady.value = true

  tryEndFirstScreenLoading()
})

6. 主题切换与地图样式

项目中 设置了DarkLight两种主题模式,而在切换的时候,地图样式切换是异步的,比导航条样式切换慢,这就会导致出现导航条已经变化主题,但地图主题还没更新,中间有一小段时间两者颜色不一致,甚至会闪动一下,带给用户不好的体验效果

为了解决这个问题,我在切换主题的[中间态]中,将页面用全屏loading遮罩层罩住,等地图样式基本切换完毕再隐藏,避免了中间态的闪烁问题

小结

至此,{Amap}相关的性能优化结束,首屏加载从原先的8,304ms优化到了4,181ms加载时间减少了4,123ms,性能提升了约49.65%,加载速度快了一倍

优化前:

coffee-performance-02.webp

优化后:

coffee-performance-03.png

虚拟列表:从定高到动态高度的 Vue 3 & React 满分实现

2026年2月7日 11:49

前言

在处理海量数据渲染(如万级甚至十万级列表)时,直接操作 DOM 会导致严重的页面卡顿甚至崩溃。虚拟列表(Virtual List) 作为前端性能优化的“核武器”,通过“只渲染可视区”的策略,能将渲染性能提升数个量级。本文将带你从零实现一个支持动态高度的通用虚拟列表。

定高虚拟列表滚动.gif

一、 核心原理解析

虚拟列表本质上是一个“障眼法”,其结构通常分为三层:

  1. 外层容器(Container) :固定高度,设置 overflow: auto,负责监听滚动事件。
  2. 占位背景(Placeholder) :高度等于“总数据量 × 列表项高度”,用于撑开滚动条,模拟真实滚动的视觉效果。
  3. 渲染内容区(Content Area) :绝对定位,根据滚动距离动态计算起始索引,并通过 translateY 偏移到当前可视区域。

image.png


二、 定高虚拟列表

1. 设计思路

  • 可视项数计算Math.ceil(容器高度 / 固定高度) ± 缓冲区 (BUFFER)
  • 起始索引Math.floor(滚动距离 / 固定高度)
  • 偏移量起始索引 * 固定高度

2. Vue 3 + TailwindCSS实现

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:用于撑开滚动条,高度 = 总数据量 * 每项高度 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表:通过 transform 定位到滚动位置 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <div
            v-for="item in visibleList"
            :key="item.id"
            class="py-2 px-4 border-b border-gray-200"
            :class="{
              'bg-pink-200 h-[100px]': item.id % 2 !== 0,
              'bg-green-200 h-[100px]': item.id % 2 === 0,
            }"
          >
            {{ item.name }}
          </div>
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';

const router = useRouter();

const ITEM_HEIGHT = 100; // 列表项固定高度(与样式中的 h-[100px] 一致)
const BUFFER = 5; // 缓冲区数量,避免滚动时出现空白

const virtualListRef = ref<HTMLDivElement | null>(null);

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动容器的滚动距离

// 总列表高度(撑开滚动条用)
const totalHeight = computed(() => ListData.value.length * ITEM_HEIGHT);

// 可视区域高度(滚动容器的高度)
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || 0;
});

// 可视区域可显示的列表项数量(向上取整 + 缓冲区)
const visibleCount = computed(() => {
  return Math.ceil(viewportHeight.value / ITEM_HEIGHT) + BUFFER;
});

// 当前显示的起始索引
const startIndex = computed(() => {
  // 滚动距离 / 每项高度 = 跳过的项数(向下取整)
  const index = Math.floor(scrollTop.value / ITEM_HEIGHT);
  // 防止索引为负数
  return Math.max(0, index);
});

// 当前显示的结束索引
const endIndex = computed(() => {
  const end = startIndex.value + visibleCount.value;
  // 防止超出总数据长度
  return Math.min(end, ListData.value.length);
});

// 可视区域需要渲染的列表数据
const visibleList = computed(() => {
  return ListData.value.slice(startIndex.value, endIndex.value);
});

// 可视区域的偏移量(让列表项定位到正确位置)
const offsetY = computed(() => {
  return startIndex.value * ITEM_HEIGHT;
});

// 处理滚动事件
const handleScroll = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

// 返回首页
const goBack = () => {
  router.push('/home');
};

// 初始化
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
});
</script>

3. 实现效果图

定高虚拟列表滚动.gif


三、 进阶:不定高(动态高度)虚拟列表

在实际业务(如社交动态、聊天记录)中,每个 Item 的高度往往是不固定的。

1. 核心改进思路

  • 高度映射表(Map) :记录每一个 Item 渲染后的真实高度。
  • 累计高度数组(Cumulative Heights) :存储每一项相对于顶部的偏移位置。
  • ResizeObserver:利用该 API 监听子组件高度变化,实时更新映射表,解决图片加载或文本折行导致的位移。

2. Vue 3 + tailwindCSS 实现(子组件抽离)

子组件: 负责上报真实高度:

<template>
  <div
    ref="itemRef"
    class="py-2 px-4 border-b border-gray-200"
    :class="{
      'bg-pink-200': item.id % 2 !== 0,
      'bg-green-200': item.id % 2 === 0,
    }"
    :style="{ height: item.id % 2 === 0 ? '150px' : '100px' }"
  >
    {{ item.name }}
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUpdated, onUnmounted, watch, nextTick } from 'vue';

// 定义props:接收父组件传递的item数据
const props = defineProps<{
  item: {
    id: number;
    name: string;
  };
}>();

// 定义emit:向父组件传递高度更新事件
const emit = defineEmits<{
  (e: 'update-height', id: number, height: number): void;
}>();

const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

// 计算并发送当前组件的高度
const sendItemHeight = () => {
  if (!itemRef.value) return;
  const realHeight = itemRef.value.offsetHeight;
  emit('update-height', props.item.id, realHeight);
};

// 监听组件挂载:首次发送高度 + 监听高度变化
onMounted(() => {
  // 首次渲染完成后发送高度
  nextTick(() => {
    sendItemHeight();
  });

  // 监听元素高度变化(适配动态内容导致的高度变化)
  if (window.ResizeObserver) {
    resizeObserver = new ResizeObserver(() => {
      sendItemHeight();
    });
    if (itemRef.value) {
      resizeObserver.observe(itemRef.value);
    }
  }
});

// 组件更新后重新发送高度(比如内容变化)
onUpdated(() => {
  nextTick(() => {
    sendItemHeight();
  });
});

// 组件卸载:清理监听
onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});

// 监听item变化:如果item替换,重新计算高度
watch(
  () => props.item.id,
  () => {
    nextTick(() => {
      sendItemHeight();
    });
  }
);
</script>

父组件:核心逻辑

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:撑开滚动条 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <!-- 渲染子组件,监听高度更新事件 -->
          <VirtualListItem
            v-for="item in visibleList"
            :key="item.id"
            :item="item"
            @update-height="handleItemHeightUpdate"
          />
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import VirtualListItem from './listItem.vue'; // 引入子组件

const router = useRouter();

const MIN_ITEM_HEIGHT = 100; // 子项预设的最小高度
const BUFFER = 5; //上下缓冲区数目
const virtualListRef = ref<HTMLDivElement | null>(null); // 滚动容器引用

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动距离
const itemHeights = ref<Map<number, number>>(new Map()); // 子组件高度映射表
const cumulativeHeights = ref<number[]>([0]); // 累计高度数组
const scrollTimer = ref<number | null>(null); // 滚动节流定时器
const isUpdatingCumulative = ref(false); // 累计高度更新防抖

// 初始化位置数据
const initPositionData = () => {
  // 初始化高度映射表(默认最小高度)
  const heightMap = new Map<number, number>();
  ListData.value.forEach((item) => {
    heightMap.set(item.id, MIN_ITEM_HEIGHT);
  });
  // 初始化累计高度
  updateCumulativeHeights();
};

// 更新累计高度(核心)
const updateCumulativeHeights = () => {
  if (isUpdatingCumulative.value) return;
  isUpdatingCumulative.value = true;

  const itemCount = ListData.value.length;
  const cumulative = [0];
  let sum = 0;

  for (let i = 0; i < itemCount; i++) {
    const itemId = ListData.value[i].id;
    sum += itemHeights.value.get(itemId) || MIN_ITEM_HEIGHT;
    cumulative.push(sum);
  }

  cumulativeHeights.value = cumulative;
  isUpdatingCumulative.value = false;
};

// 处理子组件的高度更新事件
const handleItemHeightUpdate = (id: number, height: number) => {
  // 高度未变化则跳过
  if (itemHeights.value.get(id) === height) return;

  // 更新高度映射表
  itemHeights.value.set(id, height);

  // 异步更新累计高度(避免同步更新导致的性能问题)
  nextTick(() => {
    updateCumulativeHeights();
  });
};

// 总高度,根据统计高度数组最后一个值计算得出
const totalHeight = computed(() => {
  return cumulativeHeights.value[cumulativeHeights.value.length - 1] || 0;
});

// 列表可视区域高度
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || MIN_ITEM_HEIGHT * 5;
});

// 计算起始索引
const startIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  if (totalItemCount === 0) return 0;
  if (scrollTop.value <= 0) return 0;

  let baseStartIndex = 0;
  // 反向遍历找起始索引
  for (let i = cumulativeHeights.value.length - 1; i >= 0; i--) {
    if (cumulativeHeights.value[i] <= scrollTop.value) {
      baseStartIndex = i;
      break;
    }
  }
  const finalIndex = Math.max(0, baseStartIndex - BUFFER); // 确保不小于0
  return Math.min(finalIndex, totalItemCount - 1);
});

// 计算结束索引
const endIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  const viewportHeightVal = viewportHeight.value;
  if (totalItemCount === 0) return 0;

  const targetScrollBottom = scrollTop.value + viewportHeightVal; // 目标滚动到底部位置
  let baseEndIndex = totalItemCount - 1;
  for (let i = 0; i < cumulativeHeights.value.length; i++) {
    if (cumulativeHeights.value[i] > targetScrollBottom) {
      baseEndIndex = i - 1;
      break;
    }
  }
  const finalEndIndex = Math.min(baseEndIndex + BUFFER, totalItemCount - 1); // 确保不大于总项数-1
  return finalEndIndex;
});

// 可见列表
const visibleList = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return start <= end ? ListData.value.slice(start, end + 1) : [];
});

const offsetY = computed(() => {
  return cumulativeHeights.value[startIndex.value] || 0;
});

// 滚动节流处理
const handleScroll = () => {
  if (!virtualListRef.value) return;

  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  scrollTimer.value = window.setTimeout(() => {
    scrollTop.value = virtualListRef.value!.scrollTop;
  }, 20);
};

const handleResize = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

const goBack = () => {
  router.push('/home');
};

// 生命周期
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
  initPositionData();
  window.addEventListener('resize', handleResize); // 监听窗口大小变化
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize);
  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  isUpdatingCumulative.value = false;
  itemHeights.value.clear();
});
</script>

3. React + tailwindCSS 实现(子组件抽离)

子组件:

import React, { useEffect, useRef, useState, useCallback } from 'react';

interface VirtualListItemProps {
  item: {
    id: number;
    name: string;
  };
  onUpdateHeight: (id: number, height: number) => void; // 替代 Vue 的 emit
}

const VirtualListItem: React.FC<VirtualListItemProps> = ({
  item,
  onUpdateHeight,
}) => {
  const itemRef = useRef<HTMLDivElement>(null);
  // 存储 ResizeObserver 实例(避免重复创建)
  const resizeObserverRef = useRef<ResizeObserver | null>(null);

  // 计算并上报高度
  const sendItemHeight = useCallback(() => {
    if (!itemRef.current) return;
    const realHeight = itemRef.current.offsetHeight;
    onUpdateHeight(item.id, realHeight);
  }, [item.id, onUpdateHeight]);

  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);

    // 初始化 ResizeObserver 监听高度变化
    if (window.ResizeObserver) {
      resizeObserverRef.current = new ResizeObserver(() => {
        sendItemHeight();
      });
      if (itemRef.current) {
        resizeObserverRef.current.observe(itemRef.current);
      }
    }

    // 清理定时器(对应 Vue 的 onUnmounted 部分)
    return () => {
      clearTimeout(timer);
      if (resizeObserverRef.current) {
        resizeObserverRef.current.disconnect();
        resizeObserverRef.current = null;
      }
    };
  }, [sendItemHeight]); // 仅首次挂载执行

  //监听 item 变化重新计算高度
  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);
    return () => clearTimeout(timer);
  }, [item.id, sendItemHeight]); // item.id 变化时执行

  const itemClass = `py-2 px-4 border-b border-gray-200 ${
    item.id % 2 !== 0 ? 'bg-pink-200' : 'bg-green-200'
  }`;

  const itemStyle: React.CSSProperties = {
    height: item.id % 2 === 0 ? '150px' : '100px',
  };

  return (
    <div ref={itemRef} className={itemClass} style={itemStyle}>
      {item.name}
    </div>
  );
};

export default VirtualListItem;


父组件:

import React, {
  useEffect,
  useRef,
  useState,
  useCallback,
  useMemo,
} from 'react';
import VirtualListItem from './listItem';

const VirtualList: React.FC = () => {
  const MIN_ITEM_HEIGHT = 100; // 最小项高度
  const BUFFER = 5; // 缓冲区项数

  const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用

  const [listData, setListData] = useState<Array<{ id: number; name: string }>>(
    []
  ); // 列表数据
  const [scrollTop, setScrollTop] = useState(0); // 滚动位置
  const [itemHeights, setItemHeights] = useState<Map<number, number>>(
    new Map()
  ); // 高度映射表(Map 结构)
  const [cumulativeHeights, setCumulativeHeights] = useState<number[]>([0]); // 累计高度数组
  const scrollTimerRef = useRef<number | null>(null); // 滚动节流定时器

  // 初始化模拟数据
  const initData = () => {
    const mockData = Array.from({ length: 1000 }, (_, index) => ({
      id: index,
      name: `Item ${index}`,
    }));
    setListData(mockData);
    // 初始化高度映射表(默认最小高度)
    const initHeightMap = new Map<number, number>();
    mockData.forEach((item) => {
      initHeightMap.set(item.id, MIN_ITEM_HEIGHT);
    });
    setItemHeights(initHeightMap);
    // 初始化累计高度
    updateCumulativeHeights(initHeightMap, mockData);
  };

  useEffect(() => {
    initData();
    // 监听窗口大小变化
    const handleResize = () => {
      if (virtualListRef.current) {
        setScrollTop(virtualListRef.current.scrollTop);
      }
    };
    window.addEventListener('resize', handleResize);

    // 清理监听
    return () => {
      window.removeEventListener('resize', handleResize);
      if (scrollTimerRef.current) {
        clearTimeout(scrollTimerRef.current);
      }
      itemHeights.clear(); // 清空 Map 释放内存
    };
  }, []);

  // 更新累计高度(核心函数)
  const updateCumulativeHeights = useCallback(
    (heightMap: Map<number, number>, data: typeof listData) => {
      const cumulative = [0];
      let sum = 0;
      for (let i = 0; i < data.length; i++) {
        const itemId = data[i].id;
        sum += heightMap.get(itemId) || MIN_ITEM_HEIGHT;
        cumulative.push(sum);
      }
      setCumulativeHeights(cumulative);
    },
    [MIN_ITEM_HEIGHT]
  );

  // 处理子组件的高度更新事件(对应 Vue 的 handleItemHeightUpdate)
  const handleItemHeightUpdate = useCallback(
    (id: number, height: number) => {
      // 高度未变化则跳过
      if (itemHeights.get(id) === height) return;

      // 更新高度映射表
      const newHeightMap = new Map(itemHeights);
      newHeightMap.set(id, height);
      setItemHeights(newHeightMap);

      // 异步更新累计高度
      setTimeout(() => {
        updateCumulativeHeights(newHeightMap, listData);
      }, 0);
    },
    [itemHeights, listData, updateCumulativeHeights]
  );

  // 滚动节流处理
  const handleScroll = useCallback(() => {
    if (!virtualListRef.current) return;

    // 节流:20ms 内只更新一次 scrollTop
    if (scrollTimerRef.current) {
      clearTimeout(scrollTimerRef.current);
    }
    scrollTimerRef.current = setTimeout(() => {
      setScrollTop(virtualListRef.current!.scrollTop);
    }, 20);
  }, []);

  // 可视区域高度
  const viewportHeight = useMemo(() => {
    return virtualListRef.current?.clientHeight || MIN_ITEM_HEIGHT * 5;
  }, []);

  //  总列表高度
  const totalHeight = useMemo(() => {
    return cumulativeHeights[cumulativeHeights.length - 1] || 0;
  }, [cumulativeHeights]);

  // 起始索引
  const startIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;
    if (scrollTop <= 0) return 0;

    // 反向遍历找起始索引
    let baseStartIndex = 0;
    for (let i = cumulativeHeights.length - 1; i >= 0; i--) {
      if (cumulativeHeights[i] <= scrollTop) {
        baseStartIndex = i;
        break;
      }
    }

    const finalIndex = Math.max(0, baseStartIndex - BUFFER);
    return Math.min(finalIndex, totalItemCount - 1);
  }, [
    scrollTop,
    viewportHeight,
    totalHeight,
    cumulativeHeights,
    listData.length,
  ]);

  // 结束索引
  const endIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;

    const targetScrollBottom = scrollTop + viewportHeight;
    let baseEndIndex = totalItemCount - 1;

    for (let i = 0; i < cumulativeHeights.length; i++) {
      if (cumulativeHeights[i] > targetScrollBottom) {
        baseEndIndex = i - 1;
        break;
      }
    }

    let finalEndIndex = baseEndIndex + BUFFER;
    finalEndIndex = Math.min(finalEndIndex, totalItemCount - 1);
    return finalEndIndex;
  }, [scrollTop, viewportHeight, cumulativeHeights, listData.length]);

  // 可视区列表
  const visibleList = useMemo(() => {
    return startIndex <= endIndex
      ? listData.slice(startIndex, endIndex + 1)
      : [];
  }, [startIndex, endIndex, listData]);

  // 偏移量
  const offsetY = useMemo(() => {
    return cumulativeHeights[startIndex] || 0;
  }, [startIndex, cumulativeHeights]);

  return (
    <div className="h-full bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5">
      <div className="bg-white mt-10 h-[calc(100vh-200px)] rounded-xl">
        {/* 滚动容器 */}
        <div
          ref={virtualListRef}
          className="h-full overflow-auto relative"
          onScroll={handleScroll}
        >
          {/* 占位容器:撑开滚动条 */}
          <div style={{ height: `${totalHeight}px` }}></div>

          {/* 可视区域列表:transform 偏移 */}
          <div
            className="absolute top-0 left-0 right-0"
            style={{ transform: `translateY(${offsetY}px)` }}
          >
            {visibleList.map((item) => (
              <VirtualListItem
                key={item.id}
                item={item}
                onUpdateHeight={handleItemHeightUpdate}
              />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

4. 实现效果图

动高虚拟列表滚动.gif


四、 总结与避坑指南

1. 为什么需要缓冲区(BUFFER)?

如果只渲染可见部分,用户快速滚动时,异步渲染可能会导致瞬间的“白屏”。设置上下缓冲区可以预加载部分 DOM,让滑动更顺滑。

2. 性能进一步优化

  • 滚动节流(Throttle) :虽然滚动监听很快,但在 handleScroll 中加入 requestAnimationFrame 或 20ms 的节流,能有效减轻主线程压力。
  • Key 的选择:在虚拟列表中,key 必须是唯一的 id,绝对不能使用 index,否则在滚动重用 DOM 时会出现状态错乱。

3. 注意事项

  • 定高:逻辑简单,性能极高。
  • 不定高:依赖 ResizeObserver,需注意频繁重排对性能的影响,建议对 updateCumulativeHeights 做异步批处理。

拒绝重复造轮子?我们偏偏花365天,用Vue3写了款AI协同的Word编辑器

作者 徐小夕
2026年2月6日 18:07

ps:开源版SDK已发布,github地址:github.com/MrXujiang/j…

又到了我们定期AI创业复盘的时间了。今天和大家分享一下,我们决定花1年时间打造AI Word编辑器的理由,以及做AI文档创业过程中踩过的坑。图片

为什么"偏偏"要造个Word轮子

我们之所以下定决心做这件事,主要是因为前两年我们的一个产品,需要集成word能力对外服务,但是我们调研了一圈,得到的结论是:1. 大厂的文档产品集成成本过高,对外商业化受限头部大厂做的文档产品,要么是按次付费(并发次数):图片比如上面这张,如果高频使用,我们团队算了一笔账,每年api的调用基本都要花10-20w左右,更别说对外给客户服务了,那成本只有大公司能玩了。另一方面,国内B端客户大部分的Saas场景都需要私有化部署,国企事业金融企业更要求内网部署,所以基本上不可能集成大厂的SDK,这条商业模式已经被堵死了。2. 大厂的文档产品技术债过重,扩展度和开放程度受限这基本上是行业的共识了。普通企业只能用大厂的系统,如果要开发,要么动辄百万,要么就等“等更新”。但是我们的AI文档场景,并不需要很多冗余的功能,而是保留最核心的能力,其他的我们希望开发给企业自定义。同时,现有文档编辑器都是"互联网时代的产物",为"人写给人看"设计。AI无法真正理解文档结构,只能当"高级搜索框"。我们结合这两年AI的发展,洞察的结果是:内容创作正在从"人驱动"转向"人机协作",但工具没有跟上。所以综合上面分析,再结合我们团队大厂架构和文档产品的研发经验,我们毅然决定自研。

365天我们做了什么

图片

说实话,规划了1年,其实并不是单纯的时间维度的概念,我们打算将 JitWord 打造成一个未来我们 all in 的一个产品长链路的方向:基于JitWord的文档引擎,扩展出企业共建版,JitOffice办公软件,JitCloud AI云服务生态。所以可能未来1-2年,我们还是会持续深耕在“知识内容传承”这个赛道。

第一步,架构重构:从"富文本"到"结构化数据"

在做 JitWord 之前,我们对 Office 文档做了大量的调研,从docx格式到文档的复杂操作,都意味着传统的富文本格式(html)难以驾驭复杂的文档场景。

我们也调研了很多开源方案,比如 tiptap,quill,editorjs等。最终我们选择了tiptap的一个早期稳定的版本,作为我们的底层文档组件,并对 tiptap 的架构做了上层的优化,已支持复杂的文档操作。

下面是我们第一版的文档设计架构:

图片

其实单纯实现类似 Office 的UI界面,难度不是很大,只需要花时间来开发,我相信每一个前端工程师都能实现,其难点在于:

  • 如何高效的做文档解析(需要对docx进行高精度格式还原)
  • 如何基于文档做高性能协同(支持多人协同编辑)
  • 如何在web文档处理复杂公式编辑和渲染
  • 实现文档的复杂操作(划词评论,版本对比,高级排版,分页视图等)
  • 实现文档的权限控制和高性能渲染(100w+字秒级渲染)

等等,每一个骨头都比较硬,基本上都是我们花大量实现自研,比如:

基于文档做高性能协同(支持多人协同编辑)

目前市面上其实有些协同方案,比如CRDT算法驱动的YJS,当然我们也是基于YJS实现的文档协同算法。但是单纯使用Yjs,只能实现基础协同,在协同过程中我们需要考虑很多复杂场景:

  1. 操作可交换性不同用户的操作可以以任意顺序执行,最终结果一致
  2. 操作可合并性多个操作可以智能合并,减少网络传输
  3. 最终一致性所有客户端最终会收敛到相同的文档状态
  4. 无需中央协调不依赖中央服务器进行冲突解决

下面是我们设计的协同操作流程:

图片

在协同编辑的性能上,我们也做了进一步算法优化,保证我们在普通服务器上(2核4G)也能支持10-50人的高效协同编辑,如果扩大服务器性能和集群,我们将有可能支持数千上万人的协同编辑。

下面是我们的文档协同和存储架构数据流:

图片

实现在web端处理复杂公式编辑和渲染

基本上市面上的开源方案都达不到我们对复杂公式的极致追求,大部分是让用户直接编辑latex,但是到了导出docx后,公式要么无法导出,要么导出后无法二次编辑。

基于上面的痛点,我们对docx文件做了数据结构的分析和算法层的兼容,同时对用户编辑公式的体验也做了进一步升级:

图片

我们提供了数学公式的编辑面板,我们可以实时编辑和预览公式,同时我们内置了100+高频的数学和科研公式,方便大家更快速的编写复杂公式。

下面是渲染到 JitWord 中的公式效果:

图片

同时,我们也能直接导出带复杂公式的文档,并在 word 中二次编辑。

文档的高效渲染(100w字秒级渲染)

图片

上面是我们文档中渲染了100w字的效果,目前测试下来还是可以编辑文档,只有略微的延迟。

这一结果归功于我们对文档性能的极致追求,在文档渲染层我们做了极致的优化,并全方面测试了渲染协同稳定性能:

图片

实现文档的复杂操作(划词评论,版本对比,高级排版,分页视图等)

任何一个富文本编辑器,都很难自带划词评论,版本对比,高级排版,分页视图这些高级操作功能。

我们研究这些功能花费了很多时间,也在每个月以2-3个版本的迭代速度更新着JitWord。

终于在2025年底实现了上面提到的这几个功能,下面我也给大家一一介绍一下。

  1. 划词评论

图片

当然大家可不要以为我们只是做了划词评论功能。我们在划词评论之后,还做了通信机制,在多人协同过程中可以实时通知给其他人,让协作者可以第一时间看到划词的内容,这个流程我们完全打通了。

  1. 版本管理功能

图片

我们的操作会定期存储,可以一键恢复,并支持版本的差异对比。这个功能基本上也是市面上web端文档独一份的,当然我们还在持续优化。

  1. 分页视图功能

图片

这个功能是最难坑的,不容置疑。但是我们花了2个月 all in 这个功能,终于实现了类似 word 的分页功能。它的难点在于分页之后内容的排版和位置自动计算机制,需要消耗大量的 js 性能,所以我们各种性能优化的方式都用上了,结合我们设计的独有的dom组织模式,终于实现了分页能力。

大家可以在 JitWord 共建版体验到分页的功能。

当然,我们还会支持页眉页脚功能,全面对标 Office。

第二步,AI协作引擎:让文档"活"起来

图片

上面是我们设计的 AI Native 驱动的模式架构,保证我们在文档编辑的全生命周期里,都能体验AI创作的乐趣。

下面是演示效果:

图片

我们除了直接让AI创作内容,还可以基于文本和段落,进行二次AI优化,比如文本润色,纠错,续写等:

图片

最近我们还迭代上线了AI公文助手,可以通过AI一键生成合同和公文:

图片图片

当然后续会实现更多和AI结合的场景,提高用户办公的效率。

第三步,Vue3的执念:为什么死磕Vue?

很多人问:文档编辑器为什么要强调Vue3?用React不是生态更好?

我们的回答是:响应式性能决定了AI协作的流畅度

技术细节:

  • Proxy-based响应式:10万字符文档的实时协作,传统Object.defineProperty会卡成PPT,Vue3的Proxy实现了毫秒级更新
  • Composition API:AI建议卡片、协作光标、公式渲染器……这些复杂组件的组合逻辑,用Composables管理比HOC清晰10倍
  • Tree-shaking友好:最终打包体积比同类React方案小40%,SaaS嵌入时客户感知明显

一个真实案例:

一个客户把我们的产品嵌入他们的CRM系统,原先用的React方案首屏加载3.2s,替换为 JitWord 后降到1.1s。客户CTO的原话:"你们这个Vue3版本,让我们的SaaS看起来像原生应用。 "

这就是"造轮子"的意义:不是为造而造,是为跑得更快。

并且国产化环境,很多客户都是更倾向用 Vue 技术栈,所以站在客户和用户体验的角度,我们Vue的策略是完全经得起考验的。

拒绝自嗨,持续打磨应用场景

技术人最容易犯的错:拿着锤子找钉子。

我们花了两个月时间,把产品扔到真实场景里验证,我们上线了我们开源SDK,让大家可以集成到自己的真实项目中来体验:

图片

目前有各行各业的客户给我们反馈了大量的建议,我们也在持续排期优化,下面分享一下我们内部的需求列表:

图片

目前已经有100多个我们评估后觉得非常有价值的功能点,在今年的一年里,我们会陆续上线。

当然大家有好的建议,也欢迎随时交流反馈~

github地址:github.com/MrXujiang/j…


开源与商业化思考

写到这里,必须回答那个最尖锐的问题:

你们最后要卖钱吗?还是说只是技术情怀?

我的答案是:部分开源,商业闭环。


为什么开源?

开源了基础的文档引擎SDK,包括:

  • 结构化文档模型
  • Vue3组件基础
  • 公式渲染模块
  • docx导入导出功能

目的不是做慈善,是建立标准。

如果 JitWord 的文档模型成为行业事实标准,第三方开发者自然会基于我们的格式开发插件。这等于用社区力量帮我们扩展生态——比招100个产品经理都有效


为什么保留商业版?

商业版包含:

  • AI协作引擎(调用大模型API,成本敏感)
  • 企业级协作功能(权限管理、审计日志)
  • SaaS嵌入解决方案

这不是"开源版阉割功能",是"开源版定义基础,商业版解决复杂问题"。

类比:Android开源,但GMS(Google服务)收费;MySQL开源,但企业版有高级监控。这是经过验证的商业模式。

一个创业者的坦白:

我们考虑过完全开源、靠服务变现。但过去一年和几十家企业聊过后,我发现B端客户真正付费的不是"软件",是"确定性" ——出了问题能找到人、能签SLA、能定制开发。这些只能靠商业团队支撑。

所以,造轮子不是目的。

让轮子跑得更快,让更多人用上更快的轮子,同时让造轮子的人能持续造下去——这才是目的。


关于作者:前大厂技术专家,现 JitWord 联合创始人。相信AI时代的生产力工具,应该由懂产品和技术的人重新做一遍。

我们团队均来自一线中大厂,资深工程师和技术专家,配合3个Agent,开启6人协作的创业之旅~

如果大家有好的建议,想法,欢迎留言反馈~

vue3 页面缓存KeepAlive示例

2026年2月6日 15:25

KeepAlive 示例

1.全部缓存

//APP.vue
<router-view v-slot="{ Component }">
    <KeepAlive>
      <component :is="Component"/>
    </KeepAlive>
</router-view>

2.根据路由配置缓存

  • 通过v-if来实现、配置在路由即可
//router/index.js
const routes = [
  {
    path: '/',
    component: () => import('@/views/home/index.vue'),
    name: 'home',
    meta: {
      title: '首页',
      keepAlive: true,//是否缓存
    },
  },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
//APP.vue
<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive>
      <component :is="Component" :key="$route.fullPath" v-if="route.meta.keepAlive" />
    </keep-alive>
    <component :is="Component" :key="$route.fullPath" v-if="!route.meta.keepAlive" />
  </router-view>
</template>

3.动态控制页面缓存:include

  • 通过include匹配实现;
  • 要求:值要和页面组件的name一致;
  • 方便:为方便取值,页面组件的name要和对应路由name保持一致,直接获取路由的name即可
  • 管理:借助pinia进行管理
  • 缺点:组件需要手动设置name且和路由一致,麻烦点
  • 1、路由:/router/index.js
const routes = [
  {
    path: '/',
    component: () => import('@/views/home/index.vue'),
    name: 'home',//这里和component组件文件的defineOptions({ name: 'home'})保持一致
    meta: {
      title: '首页',
      keepAlive: true,//是否缓存
    },
  },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
  • 2、App.vue
<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive :include="appStore.cacheList">
      <component :is="Component" :key="route.fullPath" />
    </keep-alive>
  </router-view>
</template>
<script setup>
import { useAppStore } from '@/store/app'

const router = useRouter()
const appStore = useAppStore()

// 初始化缓存列表:根据路由配置添加需要缓存的组件
onMounted(() => {
  appStore.initCacheList(router)
})
</script>
<style scoped></style>
  • 3、pinia实现控制逻辑:/store/app.js
import { defineStore } from 'pinia'

/**
 * 应用状态管理 Store
 * 主要用于管理 keep-alive 缓存列表
 */
export const useAppStore = defineStore('app', {
    state: () => ({
        // 缓存列表,存储需要缓存的组件名称
        // keep-alive 的 include 需要匹配组件的 name
        cacheList: []
    }),

    getters: {
        /**
         * 获取缓存列表(只读)
         */
        getCacheList: (state) => {
            return [...state.cacheList]
        },

        /**
         * 检查某个组件是否在缓存列表中
         */
        isCached: (state) => {
            return (componentName) => {
                return state.cacheList.includes(componentName)
            }
        }
    },

    actions: {
        /**
         * 初始化缓存列表:根据路由配置添加需要缓存的组件
         * @param {Object} router - Vue Router 实例
         */
        initCacheList(router) {
            if (!router) {
                console.warn('initCacheList: router 参数不能为空')
                return
            }

            router.getRoutes().forEach(route => {
                if (route.meta?.keepAlive && route.name) {
                    // keep-alive 的 include 需要匹配组件的 name
                    // 这里使用路由 name,需要确保路由 name 和组件 defineOptions 中的 name 一致
                    const componentName = route.name
                    if (!this.cacheList.includes(componentName)) {
                        this.cacheList.push(componentName)
                    }
                }
            })
        },

        /**
         * 添加组件到缓存列表
         * @param {string} componentName - 组件名称
         */
        addCache(componentName) {
            if (!componentName) {
                console.warn('addCache: componentName 参数不能为空')
                return
            }

            if (!this.cacheList.includes(componentName)) {
                this.cacheList.push(componentName)
                console.log(`已添加 ${componentName} 到缓存列表`)
            }
        },

        /**
         * 从缓存列表中移除组件(临时取消缓存)
         * @param {string} componentName - 组件名称
         */
        removeCache(componentName) {
            if (!componentName) {
                console.warn('removeCache: componentName 参数不能为空')
                return
            }

            const index = this.cacheList.indexOf(componentName)
            if (index > -1) {
                this.cacheList.splice(index, 1)
                console.log(`已移除 ${componentName} 的缓存`)
            } else {
                console.warn(`未找到 ${componentName} 的缓存`)
            }
        },

        /**
         * 清空所有缓存
         */
        clearAllCache() {
            this.cacheList = []
        },

        /**
         * 根据路由信息自动管理缓存
         * 如果路由配置了 keepAlive 为 true,则自动添加到缓存列表
         * @param {Object} route - 路由对象
         */
        autoManageCache(route) {
            if (!route) return

            const componentName = route.name
            if (route.meta?.keepAlive && componentName) {
                if (!this.cacheList.includes(componentName)) {
                    this.addCache(componentName)
                }
            }
        }
    }
})

4.解决上面麻烦点

  • 动态赋值组件name为路由的name值
  • 1.在路由页面/router/index.js 使用工具处理
import { processRoutes } from '@/utils/route-helper';//自动为组件设置路由 name的辅助工具
const routes = [....];//如上
// 自动为所有路由组件设置 name(如果组件没有设置 name,则使用路由 name)
const processedRoutes = processRoutes(routes);

const router = createRouter({
  history: createWebHashHistory(),
  routes: processedRoutes,
})
export default router;
  • 2.@/utils/route-helper
/**
 * 路由辅助工具
 * 自动为组件设置路由 name,避免每个页面都手动设置 defineOptions
 */

import { defineComponent, h, markRaw } from 'vue'

/**
 * 包装路由组件,自动设置组件 name 为路由 name
 * @param {Function|Object} component - 组件导入函数或组件对象
 * @param {string} routeName - 路由名称
 * @returns {Function|Object} 包装后的组件
 */
export function withRouteName(component, routeName) {
    if (!routeName) {
        return component
    }

    // 如果是异步组件(函数)
    if (typeof component === 'function') {
        return () => {
            return component().then((module) => {
                const comp = module.default || module

                // 如果组件已经有 name,直接返回
                if (comp.name) {
                    return module
                }

                // 使用 defineComponent 包装组件,设置 name
                const wrappedComponent = defineComponent({
                    name: routeName,
                    setup(props, { slots, attrs }) {
                        // 渲染原组件
                        return () => h(comp, { ...props, ...attrs }, slots)
                    }
                })

                // 标记为原始对象,避免响应式
                markRaw(wrappedComponent)

                // 返回包装后的组件
                // 注意:需要先展开 module 再覆盖 default,否则 module.default 会把 wrappedComponent 覆盖掉
                return {
                    ...module,
                    default: wrappedComponent,
                }
            })
        }
    }

    // 如果是同步组件(对象)
    if (typeof component === 'object' && component !== null) {
        // 如果组件已经有 name,直接返回
        if (component.name) {
            return component
        }

        // 使用 defineComponent 包装组件,设置 name
        const wrappedComponent = defineComponent({
            name: routeName,
            ...component
        })

        markRaw(wrappedComponent)
        return wrappedComponent
    }

    return component
}

/**
 * 批量处理路由配置,自动为组件设置 name
 * @param {Array} routes - 路由配置数组
 * @returns {Array} 处理后的路由配置数组
 */
export function processRoutes(routes) {
    return routes.map(route => {
        // 如果有 name 和 component,则自动设置组件 name
        if (route.name && route.component) {
            route.component = withRouteName(route.component, route.name)
        }

        // 递归处理子路由
        if (route.children && Array.isArray(route.children)) {
            route.children = processRoutes(route.children)
        }

        return route
    })
}

以上已验证

  • 登录后 根据接口返回用户可访问的菜单信息 动态添加的路由 缓存功能同样适用

Vue 2.7 封装全屏弹窗组件:基于命名空间的样式定制

2026年2月6日 15:14

在 Vue 2.7 + Element UI 项目中,封装全屏 Iframe 弹窗常遇到样式覆盖无效的问题。特别是开启 append-to-body 后,弹窗 DOM 位于根节点,常规的 scoped 样式难以生效。

本文介绍一种不依赖 scoped,通过CSS 命名空间来实现安全样式隔离的方案。

1. 核心需求

  • 全屏沉浸:弹窗无边距、无默认内边距。
  • DOM 结构安全:必须使用 append-to-body,防止被父级容器截断。
  • 样式无污染:在全局样式模式下,确保只影响当前组件。

2. 组件实现 (SurveyPortal.vue)

Template 结构

关键在于设置 custom-class。这个唯一的类名将作为我们的“样式防火墙”。

<template>
  <el-dialog
    :visible.sync="dialogVisible"
    :title="title"
    fullscreen
    :append-to-body="true"
    :destroy-on-close="true"
    custom-class="survey-portal-dialog" 
    @close="handleClose"
  >
    <div class="iframe-wrapper" v-loading="loading">
      <iframe
        :src="surveyUrl"
        frameborder="0"
        width="100%"
        height="100%"
        @load="onIframeLoad"
      ></iframe>
    </div>
  </el-dialog>
</template>

Script 逻辑

保持标准的 Vue 2.7 写法,计算属性处理 URL,Watch 处理双向绑定。

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

const props = defineProps({
  visible: Boolean,
  surveyId: { type: [String, Number], required: true },
  title: { type: String, default: '外部页面' }
});

const emit = defineEmits(['update:visible', 'close']);

const dialogVisible = ref(false);
const loading = ref(true);

const surveyUrl = computed(() => `/wj/${props.surveyId}`);

watch(() => props.visible, (val) => {
  dialogVisible.value = val;
  if (val) loading.value = true;
});

watch(dialogVisible, (val) => {
  emit('update:visible', val);
});

const onIframeLoad = () => {
  loading.value = false;
};

const handleClose = () => emit('close');
</script>

3. 样式处理(命名空间隔离法)

由于 append-to-body 将 DOM 移出了组件作用域,我们放弃 scoped,转而使用全局样式。为了防止污染全局,我们将所有样式严格包裹在 custom-class 定义的唯一类名中。

CSS 实现原理

  1. 去掉 scoped:让样式变为全局可见。
  2. 顶层包裹:所有规则必须写在 .survey-portal-dialog 内部。
  3. 覆盖 Element UI:直接选中 .el-dialog__body 进行重置。
<style lang="scss">
/* * 注意:不加 scoped 
 * 通过 ".survey-portal-dialog" 这个唯一类名实现逻辑隔离
 */
.survey-portal-dialog {
  display: flex;
  flex-direction: column;

  /* 1. 修正头部样式 */
  .el-dialog__header {
    padding: 15px 20px;
    border-bottom: 1px solid #ebeef5;
  }

  /* 2. 暴力清除 Body 内边距,实现全屏无缝 */
  .el-dialog__body {
    padding: 0 !important;
    margin: 0 !important;
    flex: 1;
    overflow: hidden;
    height: 100%;
  }

  /* 3. 内部 Iframe 容器高度计算 */
  .iframe-wrapper {
    /* 减去 Header 高度(约54px),避免出现双重滚动条 */
    height: calc(100vh - 54px);
    width: 100%;
    overflow: hidden;
  }
}
</style>

4. 方案优劣分析

  • 优点

    • 极度稳定:不受 Vue Loader 版本或 scoped 穿透语法(/deep/ vs ::v-deep)变更的影响。
    • 符合直觉:完美兼容 append-to-body 的 DOM 移动行为。
  • 注意点

    • 命名唯一性:必须保证 survey-portal-dialog 这个类名在项目中是唯一的,避免与其他弹窗冲突。

5. 总结

在处理 Element UI 的 append-to-body 弹窗时,“全局样式 + 唯一类名包裹”是最简单且副作用最小的方案。它通过 CSS 选择器的嵌套规则,手动建立了一个“样式沙箱”,既解决了全屏覆盖问题,又规避了全局污染风险。

你不知道的 v-on

作者 SuperEugene
2026年2月6日 14:18

v-onVue事件绑定指令,近期在使用Vxe Table组件库的时候看见了一个就职公司项目场景不常用的写法,在此分享给同样不常用或不知道的同学们。

//此处复制的是vxetbale组件库的示例代码
<vxe-grid v-bind="gridOptions" v-on="gridEvents"></vxe-grid>

const gridEvents: VxeGridListeners = { 
    pageChange ({ pageSize, currentPage }) { 
        pagerVO.currentPage = currentPage
        pagerVO.pageSize = pageSize
        loadList()
    } 
}

v-on绝大部分人只知道是vue提供的事件绑定api,通常用法:v-on:click="getInfo" 或者简写 @click="handleClick"。在上述案例代码中v-on后面直接就是="gridEvents"这并不是错误写法, 而是v-on对象式事件绑定写法。和常用的 @click="handleClick" 属于同一套事件绑定机制,仅写法形式不同。

两种写法对比

1. 单个事件(常规熟悉写法)

@v-on: 的语法糖,两种写法完全等价:

<button @click="handleClick">点击</button>
<!-- 等价于 -->
<button v-on:click="handleClick">点击</button>

2. 对象式绑定(v-on="对象" 用法)

直接通过 v-on 绑定一个事件对象,适用于多个事件绑定的场景:

<vxe-grid v-on="gridEvents"></vxe-grid>

其中 gridEvents 是一个键值对对象

  • 键:事件名(如 pageChangecellClick
  • 值:该事件对应的处理函数Vue 会自动遍历这个对象,将每个键值对解析为「v-on:事件名=处理函数」的形式完成绑定。

在代码中的实际含义

vxe-table的分页事件为例,实际定义的事件对象如下(包含TypeScript类型约束):

const gridEvents: VxeGridListeners = {
  pageChange({ pageSize, currentPage }) {
    pagerVO.currentPage = currentPage
    pagerVO.pageSize = pageSize
    loadList()
  },
}

此时 v-on="gridEvents" 完全等价于单个事件绑定的写法

<vxe-grid v-on:pageChange="gridEvents.pageChange"></vxe-grid>

如果 gridEvents 中包含多个事件Vue会自动完成所有事件的批量绑定,例如:

// 包含多个事件的处理对象
const gridEvents = {
  pageChange: (e) => { ... },
  editClosed: (e) => { ... },
  cellClick: (e) => { ... },
}

等价于手动为每个事件单独绑定:

<vxe-grid
  v-on:pageChange="gridEvents.pageChange"
  v-on:editClosed="gridEvents.editClosed"
  v-on:cellClick="gridEvents.cellClick"
></vxe-grid>

为什么使用对象式事件绑定写法?

  1. 事件多时更简洁:无需在模板中重复书写大量 v-on:xxx="xxx",仅需一个 v-on="对象" 即可完成批量绑定,简化模板代码;
  2. 便于维护:所有事件的处理函数都集中在一个对象中,事件名和对应逻辑一一对应,后续新增 / 修改 / 删除事件时,只需操作该对象,无需改动模板;
  3. 适配组件库场景vxe-tableElement Plus这类 UI 组件库的复杂组件(如表格、树形控件)通常提供大量事件,使用对象统一配置事件,代码结构会更清晰。

以上便是对v-on的分享,欢迎大家指正讨论,与大家共勉。

Vue 3 + Vite 集成 Monaco Editor 开发笔记

作者 昭阳
2026年2月5日 15:25

背景:在 Vue 3 (JS/TS) + Vite 项目中集成代码编辑器 Monaco Editor。

目标:实现代码高亮、自定义主题、中文汉化、语言切换、双向绑定等功能。

1. 方案选择与安装

在 Vite 中集成 Monaco Editor 主要有两种方式:

  1. 原生 Worker 方式:最稳定,利用 Vite 的 ?worker 特性,但汉化极其困难。
  2. 插件方式 (vite-plugin-monaco-editor)推荐。配置简单,自带汉化支持,但需要处理导入兼容性问题。

安装依赖

Bash

# 核心库
npm install monaco-editor

# Vite 插件 (用于处理 Worker 和汉化)
npm install -D vite-plugin-monaco-editor

2. 核心配置 (Vite)

🔴 常见报错与修复

在使用插件时,可能会遇到以下报错:

  1. TypeError: monacoEditorPlugin is not a function
  2. TypeError: Cannot read properties of undefined (reading 'entry')

这是因为 ESM/CommonJS 模块导入兼容性问题。

✅ 最佳配置 (vite.config.js)

JavaScript

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import monacoEditorPlugin from 'vite-plugin-monaco-editor'

export default defineConfig({
  plugins: [
    vue(),
    // 🟢 核心修复:兼容写法,防止报错
    (monacoEditorPlugin.default || monacoEditorPlugin)({
        // 需要加载 Worker 的语言 (JSON, TS/JS, HTML, CSS 有独立 Worker)
        languageWorkers: ['json', 'editorWorkerService'],
        // 🟢 开启中文汉化
        locale: 'zh-cn', 
    })
  ],
})

注意SQL 属于 Basic Language(基础语言),没有独立的 Worker,不需要加到 languageWorkers 列表中。


3. 组件封装 (MonacoEditor.vue)

封装一个支持 双向绑定 (v-model)语言切换自定义主题 的通用组件。

核心逻辑点:

  1. 主题生效顺序:必须先 defineTheme,再 create 实例,并在配置中显式指定 theme
  2. 语言切换:使用 monaco.editor.setModelLanguage 动态切换。
  3. 双向绑定:同时支持内容 (v-model) 和 语言 (v-model:language)。

完整代码

代码段

<template>
  <div class="monaco-wrapper">
    <select :value="language" class="lang-select" @change="handleLanguageChange">
      <option value="json">JSON</option>
      <option value="sql">SQL</option>
      <option value="javascript">JS</option>
      <option value="css">CSS</option>
    </select>

    <div ref="editorContainer" class="editor-container"></div>
  </div>
</template>

<script setup>
import { onMounted, onBeforeUnmount, ref, watch, toRaw } from 'vue'
import * as monaco from 'monaco-editor'

// 定义 Props
const props = defineProps({
  modelValue: { type: String, default: '' },
  language: { type: String, default: 'json' },
  readOnly: { type: Boolean, default: false }
})

// 定义 Emits (支持双 v-model)
const emit = defineEmits(['update:modelValue', 'update:language', 'change'])

const editorContainer = ref(null)
let editorInstance = null

// 1. 切换语言逻辑
const handleLanguageChange = (e) => {
  const newLang = e.target.value
  emit('update:language', newLang) // 通知父组件
  if (editorInstance) {
    monaco.editor.setModelLanguage(editorInstance.getModel(), newLang)
  }
}

onMounted(() => {
  if (!editorContainer.value) return

  // 2. 定义自定义主题 (必须在 create 之前)
  monaco.editor.defineTheme('my-dark-theme', {
    base: 'vs-dark',
    inherit: true,
    rules: [
      { token: 'key', foreground: 'dddddd' },
      { token: 'string.key.json', foreground: 'dddddd' },
      { token: 'string.value.json', foreground: 'b4e98c' },
    ],
    colors: {
      'editor.background': '#0e1013', // 背景色
      'editor.lineHighlightBackground': '#1f2329',
    },
  })

  // 3. 创建编辑器实例
  editorInstance = monaco.editor.create(editorContainer.value, {
    value: props.modelValue,
    language: props.language,
    theme: 'my-dark-theme', // 🟢 显式引用主题
    readOnly: props.readOnly,
    automaticLayout: true, // 自动适应宽高
    minimap: { enabled: false }, // 关闭小地图
    scrollBeyondLastLine: false,
  })

  // 4. 监听内容变化 -> 通知父组件
  editorInstance.onDidChangeModelContent(() => {
    const value = editorInstance.getValue()
    emit('update:modelValue', value)
    emit('change', value)
  })
})

// 5. 监听 Props 变化 (外部修改 -> 同步到编辑器)
watch(() => props.modelValue, (newValue) => {
  if (editorInstance && newValue !== editorInstance.getValue()) {
    // toRaw 避免 Vue 代理对象干扰 Monaco 内部逻辑
    toRaw(editorInstance).setValue(newValue)
  }
})

watch(() => props.language, (newLang) => {
  if (editorInstance) {
    monaco.editor.setModelLanguage(editorInstance.getModel(), newLang)
  }
})

// 销毁
onBeforeUnmount(() => {
  editorInstance?.dispose()
})
</script>

<style scoped>
.monaco-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
  min-height: 300px;
}
.editor-container {
  width: 100%;
  height: 100%;
}
.lang-select {
  position: absolute;
  right: 15px;
  top: 10px;
  z-index: 20;
  background: #1f2329;
  color: #ddd;
  border: 1px solid #555;
  border-radius: 4px;
}
</style>

4. 疑难杂症 (Q&A)

Q1: 为什么在 node_modules 里找不到 SQL 的 Worker 文件?

  • 原因:Monaco 将语言分为两类。

    • Rich Languages (JSON, TS, CSS, HTML):有独立 Worker,支持高级语法检查。路径在 esm/vs/language
    • Basic Languages (SQL, Python, Java 等):没有独立 Worker,只依靠主线程进行简单高亮。路径在 esm/vs/basic-languages
  • 结论:配置插件时,languageWorkers 不需要加 SQL。

Q2: 为什么 import 'monaco-editor/esm/nls.messages.zh-cn.js' 汉化不生效?

  • 原因:在 ESM 模式下,编辑器核心初始化往往早于语言包加载,或者直接被 Tree-shaking 忽略。
  • 解决:使用 vite-plugin-monaco-editor 并配置 locale: 'zh-cn',插件会在编译构建阶段自动注入语言包。

Q3: 为什么 Ctrl+点击 @/... 路径无法跳转?

  • 原因:VS Code 需要配置文件来理解别名。对于 Vue+JS 项目,根目录缺少 jsconfig.json

  • 解决:在根目录创建 jsconfig.json

    JSON

    {
      "compilerOptions": {
        "baseUrl": ".",
        "paths": { "@/*": ["src/*"] }
      },
      "include": ["src/**/*"]
    }
    

    设置完后记得重启 VS Code。


5. 最佳实践:父组件调用

使用 Vue 3 的多 v-model 特性,代码语义最清晰:

HTML

<template>
  <div class="page">
    <MonacoEditor 
      v-model="codeContent" 
      v-model:language="currentLang" 
    />
    
    <button @click="runCode">运行</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import MonacoEditor from '@/components/MonacoEditor/index.vue'

const codeContent = ref('SELECT * FROM users;')
const currentLang = ref('sql') // 切换下拉框会自动更新此变量

const runCode = () => {
  console.log(`正在运行 ${currentLang.value} 代码:`, codeContent.value)
}
</script>

uni-app使用非uni_modules的ucharts组件,本地运行没问题,部署到线上出问题?

2026年2月5日 14:47

问题背景:使用非uni_modules的ucharts组件,本地运行没用问题,发布为h5后未见报错,但ucharts却始终出不来。

步骤复现

  1. 手上有个需求,需要使用uni-app开发微信小程序,初始时使用h5作为演示系统,需求里面存在图表展示功能。这时,网上去找对应适合的charts组件,发现ucharts可以用于移动端图表展示。

  2. 引入官方非uni_modules组件(官方有说明文档),进行开发。 image.png

  3. 按照步骤引入qiun-data-charts.vue组件后,本地运行可以正常出来,也没见报错,这时build为h5后发布到服务器,神奇的一幕出现了,charts竟然出不来!!!赶紧某度,甚至上了AI,但都没解决问题,后面我就琢磨是否配置有问题,比如opts或者eopts出问题了,但对比了一下官方api均未发现问题,只能去翻ucharts源码。

  4. 看了一下源码后发现有一段代码用到了路径,就怀疑是不是路径解析出了问题,加了个打印再次部署查看,发现果真多了个./ 然后去找了一下为什么会多出这个东西,原因是打包的时候配置了指定资源打包路径,重新修改ucharts资源路径之后就可以正常出来了。 image.pngimage.pngimage.png

以上内容仅供参考

前端工程化 - Vite初始化Vue项目及代码规范配置

作者 EchoEcho
2026年2月5日 08:40

前端工程化是通过工具和规范,提升开发效率、代码质量和团队协作的系统化方案。大致包含以下内容:

  • 代码规范
  • Git Hooks
  • 环境变量
  • 构建优化

本文内容包含

  1. 使用 vite 创建 vue 项目
  2. 配置代码规范及相关格式化

一、使用vite创建vue项目

初始化项目

pnpm create vue

图片

按需完善项目结构

图片

设置别名

修改vite.config.ts

import path from 'path'

...
resolve: {
    alias: {
        '@': path.resolve(__dirname, 'src'),
    }
}
...

修改tsconfig.app.json

{
    "compilerOptions": {
        "baseUrl": ".",
        "paths": {
            "@/*": ["src/*"]
        },
    }
}

为项目添加自动导入

pnpm add -D unplugin-auto-import unplugin-vue-components

修改vite.config.ts

import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'

export default defineConfig({
    plugins: [
        vue(),
        // 新增
        AutoImport({
            imports: ['vue'],
            dts: './src/auto-imports.d.ts',
            eslintrc: {
                enabled: true,
                filepath: './src/.eslintrc-auto-import.json',
            }
        }),
        // 新增
        Components({
            dirs: ['src/components'],
            extensions: ['vue'],
            deep: true,
            dts: './src/components.d.ts',
            resolvers: []
        })
    ]
})

修改tsconfig.app.json

{
  ...
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "src/auto-imports.d.ts",   // 新增
    "src/components.d.ts"     // 新增
  ]
}

二、配置代码规范及相关格式化

配置格式化校验

统一代码风格,自动检查常见错误和潜在问题

  • ESLint: 代码质量检查(语法、最佳实践)

    ESLint 9.x 不再支持 .eslintrc.*,需要使用新的扁平配置格式 eslint.config.js

  • Prettier: 代码格式化(缩紧、引号、分号等)

  • 依赖:

pnpm add -D \
eslint \
@typescript-eslint/parser \
@typescript-eslint/eslint-plugin \
eslint-plugin-vue \
@eslint/js \
vue-eslint-parser \
prettier \
eslint-config-prettier \
eslint-plugin-prettier
  • 配置文件: eslint.config.js.prettierrc.cjs.prettierignore
  • 脚本:在package.json中添加检验和格式化命令

添加eslint.config.js

import js from '@eslint/js'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import vueParser from 'vue-eslint-parser'
import vuePlugin from 'eslint-plugin-vue'
import prettierConfig from 'eslint-config-prettier'
import prettierPlugin from 'eslint-plugin-prettier'

exportdefault [
    // 基础配置
    js.configs.recommended,

    // 全局忽略
    {
        ignores: ['node_modules/**', 'dist/**', '*.config.*', 'pnpm-lock.yaml'],
    },

    // vue文件配置
    {
        files: ['**/*.vue'],
        languageOptions: {
            parser: vueParser,
            parserOptions: {
                parser: tsParser,
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                console: 'readonly',
                process: 'readonly',
            },
        },
        plugins: {
            vue: vuePlugin,
            '@typescript-eslint': tsPlugin,
            prettier: prettierPlugin,
        },
        /**
         * "off" 或 0    ==>  关闭规则
         * "warn" 或 1   ==>  打开的规则作为警告(不影响代码执行)
         * "error" 或 2  ==>  规则作为一个错误(代码不能执行,界面报错)
         */
        rules: {
            ...prettierConfig.rules,

            // eslint 规则
            'no-var': 'error', // 要求使用 let 或 const 而不是 var
            'no-multiple-empty-lines': ['error', { max: 1 }], // 不允许多个空行
            'prefer-const': 'off', // 使用 let 关键字声明但在初始分配后从未重新分配的变量,要求使用 const
            'no-use-before-define': 'off', // 禁止在 函数/类/变量 定义之前使用它们
            'no-param-reassign': ['error', { props: false }], // 禁止修改函数参数
            'max-classes-per-file': 'off', // 禁止类超过一个文件

            // typescript 规则
            '@typescript-eslint/no-unused-vars': 'error', // 禁止定义未使用的变量
            '@typescript-eslint/no-empty-function': 'error', // 禁止空函数
            '@typescript-eslint/prefer-ts-expect-error': 'error', // 禁止使用 @ts-ignore
            '@typescript-eslint/ban-ts-comment': 'error', // 禁止 @ts-<directive> 使用注释或要求在指令后进行描述
            '@typescript-eslint/no-inferrable-types': 'off', // 禁止对初始化为数字、字符串或布尔值的变量或参数进行显式类型声明
            '@typescript-eslint/no-namespace': 'off', // 禁止使用 namespace 声明
            '@typescript-eslint/no-explicit-any': 'off', // 禁止使用 any 类型
            '@typescript-eslint/ban-types': 'off', // 禁止使用 any 类型
            '@typescript-eslint/no-var-requires': 'off', // 禁止使用 require 语句
            '@typescript-eslint/no-non-null-assertion': 'off', // 禁止使用 ! 断言
            '@typescript-eslint/no-use-before-define': [
              'error',
              {
                functions: false,
              },
            ],

            // vue 规则
            // 'vue/script-setup-uses-vars': 'error', // 要求在 script setup 中使用已定义的变量
            'vue/v-slot-style': 'error', // 要求 v-slot 指令的写法正确
            'vue/no-mutating-props': 'error', // 禁止修改组件的 props
            'vue/custom-event-name-casing': 'error', // 要求自定义事件名称符合 kebab-case 规范
            'vue/html-closing-bracket-newline': 'off', // 要求 HTML 闭合标签换行
            'vue/attribute-hyphenation': 'error', // 对模板中的自定义组件强制执行属性命名样式:my-prop="prop"
            'vue/attributes-order': 'off', // vue api使用顺序,强制执行属性顺序
            'vue/no-v-html': 'off', // 禁止使用 v-html
            'vue/require-default-prop': 'off', // 此规则要求为每个 prop 为必填时,必须提供默认值
            'vue/multi-word-component-names': 'off', // 要求组件名称始终为 “-” 链接的单词
            'vue/no-setup-props-destructure': 'off', // 禁止解构 props 传递给 setup
            'vue/max-len': 0, // 强制所有行都小于 80 个字符
            'vue/singleline-html-element-content-newline': 0, // 强制单行元素的内容折行
            
            // Prettier 规则
            'prettier/prettier': 'error', // 强制使用 prettier 格式化代码
        }
    },
    // js文件配置
    {
        files: ['**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts}'],
        languageOptions: {
            parser: tsParser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                console: 'readonly',
                process: 'readonly',
            }
        },
        plugins: {
            '@typescript-eslint': tsPlugin,
            prettier: prettierPlugin,
        },
        rules: {
            ...prettierConfig.rules,

            // eslint 规则
            'no-var': 'error', // 要求使用 let 或 const 而不是 var
            'no-multiple-empty-lines': ['error', { max: 1 }], // 不允许多个空行
            'prefer-const': 'off', // 使用 let 关键字声明但在初始分配后从未重新分配的变量,要求使用 const
            'no-use-before-define': 'off', // 禁止在 函数/类/变量 定义之前使用它们
            'prettier/prettier': 'error', // 强制使用 prettier 格式化代码

            // TypeScript 规则
            '@typescript-eslint/no-unused-vars': 'error',
            '@typescript-eslint/no-empty-function': 'error',
            '@typescript-eslint/prefer-ts-expect-error': 'error',
            '@typescript-eslint/ban-ts-comment': 'error',
            '@typescript-eslint/no-inferrable-types': 'off',
            '@typescript-eslint/no-namespace': 'off',
            '@typescript-eslint/no-explicit-any': 'off',
            '@typescript-eslint/ban-types': 'off',
            '@typescript-eslint/no-var-requires': 'off',
            '@typescript-eslint/no-non-null-assertion': 'off',
            '@typescript-eslint/no-use-before-define': [
                'error',
                {
                    functions: false,
                },
            ],
            
            'prettier/prettier': 'error',
        }
    }
]

添加.prettierrc.cjs

/**
 * Prettier 代码格式化配置
 * 文档:https://prettier.io/docs/en/configuration.html
 */
module.exports= {
  // 是否在语句末尾添加分号
  semi: false,
  // 是否使用单引号
  singleQuote: true,
  // 设置缩进
  tabWidth: 2,
  // 尾随逗号
  trailingComma: 'es5',
  // 每行最大字符数
  printWidth: 120,
  // 箭头函数参数括号: avoid( 避免 ) | always( 总是 )
  arrowParens: 'avoid',
  // 文件行尾: lf( 换行 ) | crlf( 回车换行 ) | auto( 自动 )
  endOfLine: 'lf',
}

添加.prettierignore

node_modules
dist
*.specstory
*.local
pnpm-lock.yaml
package-lock.json
.DS_Store
coverage
.vscode
.idea
public

package.json中添加相关scripts

...
"scripts": {
  ...
    "lint": "eslint . --fix",
    "format": "prettier --write "src/**/*.{js,ts,vue,json,css,scss,md}"",
    "lint:check": "eslint .",
    "format:check": "prettier --check "src/**/*.{js,ts,vue,json,css,scss,md}""
},
...

配置css格式校验及其他

  • Stylelint: css/scss样式校验和格式化,统一样式代码风格,发现样式错误
  • EditorConfig: 统一编辑器配置,保证跨编辑器保持一致的编码风格
  • Commitlint: Git 提交信息格式校验,规范提交信息,便于追踪和生成changeling
  • Husky + lint-staged: Git hooks 自动化校验,代码提交前自动检查,避免提交不符合规范的代码
  1. 安装相关依赖

    # 基础依赖(必需)
    # stylelint-config-html: HTML/Vue模板样式格式化
    # stylelint-config-recess-order: css属性书写顺序
    # stylelint-config-recommended-vue: Vue推荐配置
    pnpm add -D \
      stylelint \
      stylelint-config-standard \
      stylelint-config-standard-vue \
      stylelint-config-prettier \
      stylelint-config-html \ 
      stylelint-config-recess-order \  
      stylelint-config-recommended-vue \ 
      @commitlint/cli \
      @commitlint/config-conventional \
      husky \
      lint-staged \
      postcss-html 
    
    
    # 可选依赖(根据项目需要)
    # 如果使用 Tailwind CSS
    pnpm add -D stylelint-config-tailwindcss
    
    # 如果使用SCSS
    pnpm add -D stylelint-config-standard-scss stylelint-scss
    
  1. 创建.stylelintrc.cjs

    module.exports= {
      // 继承规则
      extends: [
        'stylelint-config-standard', // 配置 stylelint 拓展插件
        'stylelint-config-html/vue', // 配置 vue 中 template 样式格式化
        'stylelint-config-recess-order', // 配置 stylelint css 属性书写顺序插件,
        'stylelint-config-standard-scss', // 配置 stylelint scss 插件
        'stylelint-config-recommended-vue/scss', // 配置 vue 中 scss 样式格式化
        'stylelint-config-tailwindcss',
      ],
      overrides: [
        // 扫描 .vue/html 文件中的 <style> 标签内的样式
        {
          files: ['**/*.{vue,html}'],
          // 使用 postcss-html 解析器
          customSyntax: 'postcss-html',
        },
      ],
      rules: {
        'keyframes-name-pattern': null, // 强制关键帧名称的格式
        'custom-property-pattern': null, // 强制自定义属性的格式
        'selector-id-pattern': null, // 强制选择器 ID 的格式
        'declaration-block-no-redundant-longhand-properties': null, // 禁止冗余的长属性
        'function-url-quotes': 'always', // URL 的引号 "always(必须加上引号)"|"never(没有引号)"
        'color-hex-length': 'long', // 指定 16 进制颜色的简写或扩写 "short(16进制简写)"|"long(16进制扩写)"
        'rule-empty-line-before': 'never', // 要求或禁止在规则之前的空行 "always(规则之前必须始终有一个空行)"|"never(规则前绝不能有空行)"|"always-multi-line(多行规则之前必须始终有一个空行)"|"never-multi-line(多行规则之前绝不能有空行)"
        'font-family-no-missing-generic-family-keyword': null, // 禁止在字体族名称列表中缺少通用字体族关键字
        'property-no-unknown': null, // 禁止未知的属性
        'no-empty-source': null, // 禁止空源码
        'selector-class-pattern': null, // 强制选择器类名的格式
        'value-no-vendor-prefix': null, // 关闭 vendor-prefix (为了解决多行省略 -webkit-box)
        'no-descending-specificity': null, // 不允许较低特异性的选择器出现在覆盖较高特异性的选择器
        // 禁止未知的伪类
        'selector-pseudo-class-no-unknown': [
          true,
          {
            ignorePseudoClasses: ['global', 'v-deep', 'deep'],
          },
        ],
        // 禁止未知的 at-rule
        'scss/at-rule-no-unknown': [
          true,
          {
            ignoreAtRules: ['tailwind', 'apply'],
          },
        ],
        // 禁止未知的函数
        'function-no-unknown': [
          true,
          {
            ignoreFunctions: ['constant'],
          },
        ],
      },
      ignoreFiles: ['**/*.js', '**/*.ts', '**/*.jsx', '**/*.tsx', 'node_modules/**', 'dist/**'],
    }
    
  1. 创建.editorconfig

    # EditorConfig 是帮助多个编辑器和 IDE 维护一致的编码样式的配置文件
    # https://editorconfig.org
    
    root = true
    
    [*] # 表示所有文件适用
    charset = utf-8 # 设置文件字符集为 utf-8
    end_of_line = lf # 设置文件行尾为 LF
    indent_style = space # 缩进风格(tab | space)
    indent_size = 2 # 缩进大小
    insert_final_newline = true # 在文件末尾插入一个新行
    trim_trailing_whitespace = true # 删除行尾的空格
    max_line_length = 130 # 最大行长度
    
    [*.md] # 表示仅对 md 文件适用以下规则
    max_line_length = off # 关闭最大行长度限制
    trim_trailing_whitespace = false # 关闭末尾空格修剪
    
    [*.{yml,yaml}]
    indent_size = 2 # 设置 yaml 文件的缩进大小为 2
    
    [Makefile]
    indent_style = tab # 设置 Makefile 文件的缩进风格为 tab
    
  1. 创建commitlint.config.js文件

    exportdefault {
        extends: ['@commitlint/config-conventional'],
        rules: {
            'type-enum': [
                2,
                'always',
                [
                    'feat', // 新功能
                    'fix', // 修复问题
                    'docs', // 文档更新
                    'style', // 代码格式(不影响代码运行的变动)
                    'refactor', // 重构代码(既不是新增功能,也不是修复问题的代码变动)
                    'perf', // 性能优化
                    'test', // 添加测试
                    'chore', // 构建过程或辅助工具的变动
                    'build', // 打包
                    'ci', // CI配置
                    'revert', // 回退
                    'release', // 发布
                    'wip', // 开发中
                ]
            ],
            // 类型必须小写
            'type-case': [
                2,
                'always',
                'lower-case'
            ],
            // 类型不能为空
            'type-empty': [2, 'never'],
            // 作用域必须小写
            'scope-case': [
                2,
                'always',
                'lower-case'
            ],
            // 主题必须小写
            'subject-case': [
                2,
                'always',
                'lower-case'
            ],
            // 头部最大长度为 100 个字符
            'header-max-length': [
                2,
                'always',
                100
            ],
            // 主体前面必须有一个空行
            'body-leading-blank': [
                2,
                'always'
            ],
        }
    }
    
  1. 创建.lintstagedrc.js

    exportdefault {
      '*.{js,jsx,ts,tsx,vue}': ['eslint --fix', 'prettier --write'],
      '*.{css,scss,less,styl}': ['stylelint --fix', 'prettier --write'],
      '*.{json,md,yml,yaml}': ['prettier --write'],
    }
    
  1. 更新package.json

    {
    ...
    "scripts": {
        "lint": "eslint . --fix",
        "format": "prettier --write "src/**/*.{js,ts,vue,json,css,scss,md}"",
        "lint:check": "eslint .",
        "format:check": "prettier --check "src/**/*.{js,ts,vue,json,css,scss,md}"",
    
        "lint:style": "stylelint "**/*.{css,scss,vue}" --fix",
        "lint:style:check": "stylelint "**/*.{css,scss,vue}"",
    
        "type-check": "vue-tsc --noEmit",
    
        "check": "pnpm lint:check && pnpm format:check && pnpm lint:style:check && pnpm type-check",
        "fix": "pnpm lint && pnpm format && pnpm lint:style",
    
        "prepare": "husky install"
    },
    ...
    }
    
  2. 初始化Husky(Git Hooks)

    pnpm prepare
    

    这会在根目录下生成.husky目录,其中包含了_子目录,将子目录下的commit-msgpre-commit文件拷贝到.husky目录下,并修改文件内容如下:

    .husky/commit-msg文件内容

    #!/usr/bin/env sh
    . "$(dirname -- "$0") /_/husky.sh"
    
    npx --no -- commitlint --edit $1
    

    .husky/pre-commit文件内容

    #!/usr/bin/env sh
    . "$(dirname -- "$0")/_/husky.sh"
    
    pnpm lint-staged
    
  3. 验证配置文件语法

    如果某些验证失败,请检查:

    • 依赖是否已正确安装

    • 配置文件语法是否正确

    • 文件路径是否正确

    # 1. 验证 ESLint 配置
    pnpm exec eslint --print-config src/App.vue > /dev/null && echo "✅ ESLint 配置正确" || echo "❌ ESLint 配置有误"
    
    # 2. 验证 Prettier 配置
    pnpm exec prettier --check . > /dev/null 2>&1 && echo "✅ Prettier 配置正确" || echo "⚠️  Prettier 发现格式问题(这是正常的)"
    
    # 3. 验证 Stylelint 配置
    pnpm exec stylelint --print-config src/style.css > /dev/null && echo "✅ Stylelint 配置正确" || echo "❌ Stylelint 配置有误"
    
    # 4. 验证 Commitlint 配置
    pnpm exec commitlint --help > /dev/null && echo "✅ Commitlint 已安装" || echo "❌ Commitlint 未安装"
    
    # 5. 验证 TypeScript 配置
    pnpm exec vue-tsc --version && echo "✅ vue-tsc 已安装" || echo "❌ vue-tsc 未安装"
    

    图片

  1. 运行检查命令

    # 1. 检查代码格式(ESLint)
    pnpm lint:check
    
    # 2. 检查代码格式(Prettier)
    pnpm format:check
    
    # 3. 检查样式格式(Stylelint)
    pnpm lint:style:check
    
    # 4. 检查 TypeScript 类型
    pnpm type-check
    
    # 5. 综合检查(运行所有检查)
    pnpm check
    
    # 6. 自动修复
    pnpm fix
    

配置文件保存时自动格式化

  1. 安装相关插件
    • Prettier - Code formatter
    • ESLint
    • Stylelint
    • Volar
    • TypeScript Vue Plugin

图片

  1. 创建.vscode/setting.json

    {
      // 编辑器基础配置
      "editor.formatOnSave": true,
      "editor.defaultFormatter": "esbenp.prettier-vscode",
      "editor.codeActionsOnSave": {
        "source.fixAll.eslint": "explicit",
        "source.fixAll.stylelint": "explicit"
      },
    
      // Vue 文件特殊配置 - 使用 Volar 格式化
      "[vue]": {
        "editor.defaultFormatter": "Vue.volar",
        "editor.formatOnSave": true,
        "editor.codeActionsOnSave": {
          "source.fixAll.eslint": "explicit",
          "source.fixAll.stylelint": "explicit"
        }
      },
    
      // Volar 配置
      "volar.formatting.printWidth": 120,
      "volar.formatting.singleQuote": true,
      "volar.formatting.semi": false,
      "volar.formatting.tabSize": 2,
      "volar.formatting.trailingComma": "es5",
      "volar.formatting.arrowParens": "avoid",
      "volar.formatting.endOfLine": "lf",
    
      // 或者使用 Prettier 格式化 Vue(需要配置)
      // "[vue]": {
      //   "editor.defaultFormatter": "esbenp.prettier-vscode",
      //   "editor.formatOnSave": true
      // },
    
      // 文件类型特定配置
      "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[javascriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[typescript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[typescriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[json]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[jsonc]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[css]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[scss]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[less]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[html]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[markdown]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
    
      // ESLint 配置
      "eslint.enable": true,
      "eslint.validate": [
        "javascript",
        "javascriptreact",
        "typescript",
        "typescriptreact",
        "vue"
      ],
      "eslint.format.enable": true,
      "eslint.codeAction.showDocumentation": {
        "enable": true
      },
    
      // Stylelint 配置
      "stylelint.enable": true,
      "stylelint.validate": [
        "css",
        "scss",
        "less",
        "vue"
      ],
    
      // Prettier 配置
      "prettier.enable": true,
      "prettier.requireConfig": true,
      "prettier.configPath": ".prettierrc.cjs",
    
      // 使用 Prettier 格式化 Vue(如果使用 Prettier 而不是 Volar)
      "prettier.documentSelectors": ["**/*.vue"],
    
      // 其他编辑器配置
      "files.eol": "\n",
      "files.insertFinalNewline": true,
      "files.trimTrailingWhitespace": true,
      "files.encoding": "utf8",
    
      // Vue 相关配置 - 禁用 Vetur(如果安装了)
      "vetur.format.enable": false,
      "vetur.validation.template": false,
      "vetur.validation.script": false,
      "vetur.validation.style": false,
    
      // TypeScript 配置
      "typescript.tsdk": "node_modules/typescript/lib",
      "typescript.enablePromptUseWorkspaceTsdk": true
    }
    
  1. 验证配置

    打开任意.vuets.js文件,故意写一些格式不规范的代码(例如:多余空格,缺少分号等),保存文件,检查代码是否自动格式化

常见问题:

1. 保存时格式化不生效

  • 检查 VSCode 扩展是否已安装
  • 检查 .vscode/settings.json 是否正确配置
  • 重启 VSCode 或重新加载窗口

2. ESLint 报错找不到模块

  • 运行 pnpm install 重新安装依赖
  • 检查 eslint.config.js 中的导入路径

3. Git Hooks 不生效

  • 检查 .husky/pre-commit.husky/commit-msg 文件是否存在且可执行
  • 运行 chmod +x .husky/pre-commit .husky/commit-msg 添加执行权限

总结

通过以上配置,我们已经为 Vue 3 + TypeScript + Vite 项目搭建了完整的代码规范体系:

代码质量检查:ESLint + TypeScript 类型检查

代码格式化:Prettier

样式规范:Stylelint + EditorConfig

提交规范:Commitlint + Husky + lint-staged

开发体验:VSCode 保存自动格式化

配置清单

项目根目录下应包含以下配置文件:

  • eslint.config.js - ESLint 配置
  • .prettierrc.cjs - Prettier 配置
  • .prettierignore - Prettier 忽略文件
  • .stylelintrc.cjs - Stylelint 配置
  • .editorconfig - 编辑器配置
  • commitlint.config.js - Commitlint 配置
  • .lintstagedrc.js - lint-staged 配置
  • .husky/pre-commit - Git pre-commit hook
  • .husky/commit-msg - Git commit-msg hook
  • .vscode/settings.json - VSCode 工作区配置

相关资源

📦 完整示例: GitHub 仓库地址

【性能优化】响应式图片

作者 曾富贵
2026年2月4日 21:23

引言

在现代 Web 开发中,图片往往占据了页面总资源的 50% 以上。在移动设备和高分辨率屏幕普及的今天,如何让用户以最小的带宽成本获得最优的视觉体验,是性能优化中的关键课题。响应式图片正是解决这一问题的核心技术方案。

手段(从手段上通过格式优化与按需加载选图)

  1. 格式优化:优先使用现代高效格式(AVIF、WebP),不支持则降级到传统格式(JPG、PNG)
  2. 按需加载:根据图片显示尺寸(槽位分段适配)/视口宽度(视口分段适配)、设备像素比(DPR)选择最合适的图片资源

作用(从作用上减少带宽浪费、提升加载速度)

  1. 减少带宽浪费:避免在小屏设备上加载过大的图片,避免在低 DPR 设备上加载高清图片
  2. 提升加载速度:更小的图片体积意味着更快的首屏渲染和更流畅的用户体验

一、槽位适配(Slot-based Adaptation)

定义:以分段适配的方式,预先提供多档尺寸、多档 DPR 的候选图片,根据图片在页面中的实际显示宽度、**设备像素比(DPR)**选择最合适的一档资源。

技术实现:通过 HTML 的 <picture> + srcset + sizes 属性实现。

核心特性

  • srcset 定义了多个候选图片地址及宽度描述符(如 image-400w.jpg 400w
  • sizes 由多组「媒体条件 + 源尺寸值」组成,描述在不同视口下的槽位宽度(如 (max-width: 600px) 100vw, 50vw
  • 浏览器根据 媒体条件源尺寸值 得出槽位宽度,再结合 设备像素比(DPR)宽度描述符 选择最合适的图片

示例代码

① 槽位分段 + 多格式(完整用法):

<picture>
  <source 
    type="image/avif" 
    srcset="/images/hero-400w.avif 400w, /images/hero-800w.avif 800w, /images/hero-1200w.avif 1200w"
    sizes="(max-width: 600px) 100vw, 50vw"
  />
  <source 
    type="image/webp" 
    srcset="/images/hero-400w.webp 400w, /images/hero-800w.webp 800w, /images/hero-1200w.webp 1200w"
    sizes="(max-width: 600px) 100vw, 50vw"
  />
  <img 
    src="/images/hero-400w.jpg" 
    srcset="/images/hero-400w.jpg 400w, /images/hero-800w.jpg 800w, /images/hero-1200w.jpg 1200w"
    sizes="(max-width: 600px) 100vw, 50vw"
    alt="Hero Image"
  />
</picture>

② DPR + 格式适配(srcsetx 描述符,picture 做格式切换):

<picture>
  <source type="image/avif" srcset="/images/hero.avif 1x, /images/hero@2x.avif 2x" />
  <source type="image/webp" srcset="/images/hero.webp 1x, /images/hero@2x.webp 2x" />
  <img 
    src="/images/hero.jpg" 
    srcset="/images/hero.jpg 1x, /images/hero@2x.jpg 2x" 
    alt="Hero Image"
  />
</picture>

二、视口适配(Viewport-based Adaptation)

定义:以分段适配的方式,结合格式选择(AVIF/WebP)、媒体查询(视口宽度、设备像素比 DPR)为每一档指定图片,浏览器根据当前视口、DPR 及格式支持情况匹配到对应的一档并加载该资源。

技术实现

  • 视口 × DPR 分段:通过 CSS 媒体查询 @media 实现
  • 格式选择(AVIF/WebP 等):依赖 JS 检测——在应用启动时探测浏览器支持情况,给 <html> 添加 .avif.webp 等 class,再由 CSS 选择器覆盖对应格式的背景图。CSS 的 @supports 对图片格式不可靠,故采用 JS + class 方案

示例代码(视口 × DPR 分段 + 格式覆盖):

① 格式检测并往 document 加 class(仅示例 AVIF):

// 用 1x1 AVIF data URI 探测,支持则在根节点加 .avif
const avifDataUri = 'data:image/avif;base64,AAAAIGZ0eXBhdmlm...'
const img = new Image()
img.onload = () => { if (img.width > 0) document.documentElement.classList.add('avif') }
img.onerror = () => {}
img.src = avifDataUri

② 视口 × DPR 分段 + 用 .avif 覆盖格式:

.hero {
  // 降级格式(JPG):视口 × DPR
  @media (width >= 0) {
    @media (resolution >= 1dppx) {
      background-image: url('/images/hero-400w.jpg');
    }
  }
  @media (width >= 768px) {
    @media (resolution >= 2dppx) {
      background-image: url('/images/hero-800w@2x.jpg');
    }
  }

  .avif & {
    @media (width >= 0) {
      @media (resolution >= 1dppx) {
        background-image: url('/images/hero-400w.avif');
      }
    }
    @media (width >= 768px) {
      @media (resolution >= 2dppx) {
        background-image: url('/images/hero-800w@2x.avif');
      }
    }
  }
}

三、槽位适配与视口适配的对比

3.1 槽位适配更细腻精确

槽位适配在媒体查询的基础上,还依据图片在页面中的实际显示宽度选图,形成“二维精准匹配”。例如:视口 1920px 时,若图片只占 50vw(960px),通过 sizes="50vw" 浏览器会选约 1000w 的图,而视口适配只能按 1920px 选图,容易造成浪费。在复杂布局(多栏、网格等)中,这种差异更明显。

3.2 适用场景不同

槽位适配适用于页面中某些槽位的显示尺寸会随视口变化而变化的场景(如多栏、网格、响应式布局中宽度不固定的图片区域)。通过 sizes 声明各视口下的槽位宽度,浏览器按实际显示宽度选图,避免大视口下小槽位仍加载大图。

视口适配则适合整体随视口缩放的场景(如全屏头图、整页背景),只按视口宽度与 DPR 分段选图,不关心图片在页面中的实际占位大小。该方案在 H5 / 小程序 等移动端页面中应用广泛。

3.3 背景图

槽位适配依赖 HTML 的 sizes 来声明“图片在不同视口下的实际宽度”。CSS 的 background-image 没有等价语法,无法描述“背景在容器中的显示宽度”,只能通过媒体查询获知视口宽度与 DPR。因此背景图无法做槽位式选图,只能采用视口分段适配。

Vue 组件 API 覆盖率工具

作者 yddddddddddur
2026年2月4日 20:19

前言

在组件开发中,我们经常面临一个问题:组件测试是否真正覆盖了组件的所有 API,传统的代码覆盖率工具只能告诉我们代码行数的覆盖情况,却无法准确反映组件的 Props、Events、Slots 和 Exposed Methods 是否被充分测试。

传统代码覆盖率的局限性

传统的代码覆盖率工具(如 Istanbul、nyc 等)虽然能够统计代码行的执行情况,但在组件测试场景下存在明显的不足。它们无法检查出以下这些问题:

  • 无法追踪对象的某个 key 是否被使用:这是根本性的限制。传统工具只能知道某行代码被执行了,但无法精确追踪对象的哪些属性被访问。例如,组件的 props 对象被传递了,但不知道具体哪些 prop 键被使用
  • 无法 测试 是否遗漏了 Slots 的 TS 类型定义:组件有测试 slots 的功能,但没有声明 slots 的类型
  • 无法找出是否存在冗余的 Props:定义了某些 props,但未被实际使用
  • 无法检查 Props 的所有枚举值是否都 测试 :例如 type: 'primary' | 'ghost' | 'dashed' 这种联合类型,可能只测试了 'primary',而遗漏了其他变体
  • 无法检查 Props 的所有类型是否都 测试 :例如 value 可能接受 Boolean | String | Number | Array,但测试中只传了字符串

这些问题在组件库开发中尤为突出。一个看似 90% 代码覆盖率的组件,实际上可能有大量未经测试的 API 边界情况。传统覆盖率工具基于代码执行行数统计,而组件 API 测试需要的是基于类型系统和对象属性的精确追踪

实践中的困境

在早期做公司内部组件库的时候,我们也开启过一轮对组件 API 覆盖率的人工检查。然而,由于组件 API 过多,检查过程极其困难,最终总会有许多漏写的单元测试。人工核对的方式不仅效率低下,而且容易遗漏,标准也难以统一。

为了解决这个问题,我在半年前用 AI 开发了 vc-api-coverage,一个专门为 Vue 3 TSX 组件设计的 API 覆盖率分析工具。本文将深入剖析这个工具的技术实现原理,分享如何利用 TypeScript 类型系统和 AST 分析来实现精准的 API 覆盖率检测。

核心设计思路

这个工具的核心理念是:通过静态分析组件定义和 测试 代码,建立组件 API 与测试用例之间的映射关系

设计理念

在大学学过的一门项目管理课程中,讲到了"设备点检",这是一种预防性设备维护管理制度。通过定期、定点、定标、定人、定法的方式对设备进行检查,以确保设备正常运行。

这个覆盖率工具的设计思路与"设备点检"有异曲同工之妙,主要对定标、定法、定期这3个环节进行了强化:

  • 定标:将原本模糊、可完成可不完成的测试标准,变成一个明确、量化、强制的标准(如:100% API 覆盖率)。
  • 定法:将对api覆盖率的手动检查,变成程序自动化的检查。
  • 定期:将原本一次性的检查,变成CI流水线的周期检查。

通过工具化的方式,我们把主观的人工检查转变为客观的自动化检测,把模糊的质量要求转变为精确的量化指标。

整体架构

整体架构分为三个核心模块:

  1. ComponentAnalyzer:分析组件定义,提取所有可用的 API
  2. UnitTestAnalyzer:分析测试代码,识别哪些 API 被测试覆盖
  3. Reporter:生成可视化的覆盖率报告(CLI、HTML、JSON)

image.png

技术选型

在开始介绍具体实现之前,先分享一下技术选型过程中的弯路和思考。

早期方案

最初设计这个覆盖率工具时,我的想法是通过 AST(抽象语法树)去分析组件代码,直接提取出 Props、Slots 和 Exposed Methods。这个方案看起来很直接,但在实践中遇到了巨大的挑战:

Vue 组件的写法复杂多变,静态分析难以覆盖所有场景:

  1. 多种 API 风格:组件既可以用 Composition API 的 setup 写法,也可以用 Options API 写法
  2. 运行时配置:组件可能配置了 mixinsextends 等,这些内容需要递归分析多个文件
  3. 动态计算的 Props:有些组件的 props 需要运行时才能确定,例如使用 lodash.pick 从另一个对象选取部分 props:
import { pick } from 'lodash';
const baseProps = { a: String, b: Number, c: Boolean };
const componentProps = pick(baseProps, ['a', 'b']); // 静态分析无法得知结果

4. 类型信息丢失:纯 AST 分析只能看到代码结构,很难准确推断出 union 类型、可选属性等类型信息

经过几次尝试,发现要覆盖所有 Vue 组件的写法,需要实现一个接近完整的 Vue 编译器,这显然不现实。

最终方案

后来换了一个思路:既然 Vue 3 组件本身就有完整的类型定义,为什么不直接利用 TypeScript 的类型系统呢?

这个方案的优势非常明显:

  • 统一的接口:无论组件怎么写(setup、options、mixins),最终都会生成统一的组件类型,TypeScript 编译器已经帮我们处理好了所有复杂情况
  • 完整的类型信息:可以直接获取 union 类型、可选属性、泛型参数等完整的类型信息
  • 简单快捷:通过 InstanceType<typeof Component>['$props'] 就能获取所有 props,无需关心组件内部实现
  • 零维护成本:随着 Vue 版本升级,只要类型定义更新了,工具就能自动适配

这就是为什么最终选择了"类型系统 + AST"的混合方案:

  • 类型系统提取 Props、Events、Slots(简单可靠)
  • AST 提取单元测试代码(类型系统无法覆盖的场景)

技术选型的启示:不要试图重新实现已有的轮子。TypeScript 编译器已经解决了类型推断的复杂问题,我们应该站在巨人的肩膀上。

技术实现详解

组件 API 提取

组件的 Props、Events 和 Slots 信息隐藏在 Vue 组件的类型定义中,见TS Playground示例。我们利用 ts-morph 库来访问 TypeScript 的类型系统:

1. Props/Events 提取

 // src/analyzer/ComponentAnalyzer.ts:30
analyzePropsAndEmits(instanceType: Type, exportedExpression: Expression) {
    // 通过 $props 属性获取组件的所有 props
    const dollarPropsSymbol = instanceType.getProperty('$props');
    if (!dollarPropsSymbol) returnconst dollarPropsType = dollarPropsSymbol.getTypeAtLocation(exportedExpression);
    dollarPropsType.getProperties().forEach(propSymbol => {
        const propName = propSymbol.getName();
        // 过滤内部属性
        if (!internalProps.includes(propName)) {
            this.props.add(propName);
        }
    });
}

核心原理:Vue 3 组件通过 InstanceType<typeof Component>['$props'] 暴露了所有 props 的类型信息。我们直接访问这个类型,遍历其所有属性,就能获得完整的 props 列表。

2. Slots 提取

 // src/analyzer/ComponentAnalyzer.ts:157
analyzeSlots(instanceType: Type, exportedExpression: Expression) {
    const dollarPropsSymbol = instanceType.getProperty('$slots');
    if (!dollarPropsSymbol) returnconst dollarPropsType = dollarPropsSymbol.getTypeAtLocation(exportedExpression);
    dollarPropsType.getProperties().forEach(propSymbol => {
        const propName = propSymbol.getName();
        this.slots.add(propName);
    });
}

核心原理:与 props 类似,通过 $slots 属性获取所有插槽的类型定义。

3. Exposed Methods 提取

Exposed methods无法从 TypeScript 类型系统中获取,我们采用了 AST 代码分析的方法:

 // src/analyzer/ComponentAnalyzer.ts:176
analyzeExposeContextCalls() {
    // 方法1: 检测 expose({ ... }) 调用
    const matches = this.code.match(/expose(\s*{([^}]+)}\s*)/g);

    if (matches && matches.length > 0) {
        for (const match of matches) {
            const propsStr = match.replace(/expose(\s*{/, '').replace(/}\s*)/, '');
            const propMatches = propsStr.match(/(\w+),?/g);

            if (propMatches) {
                for (const prop of propMatches) {
                    const cleanProp = prop.replace(/,/g, '').trim();
                    if (cleanProp) {
                        this.exposes.add(cleanProp);
                    }
                }
            }
        }
    }
}
 // src/analyzer/ComponentAnalyzer.ts:202
analyzeExposeArrayOption(exportedExpression: Expression) {
    // 方法2: 检测 defineComponent({ expose: ['method1', 'method2'] })
    const componentOptions = this.getComponentOptions(exportedExpression);
    if (!componentOptions) return;

    const exposeArray = this.getExposeArrayFromOptions(componentOptions);
    if (!exposeArray) return;

    const exposeItems = exposeArray.getElements();
    for (const item of exposeItems) {
        const itemName = this.getItemName(item);
        if (itemName) {
            this.exposes.add(itemName);
        }
    }
}

核心原理

  1. 通过正则表达式匹配 expose({ ... }) 调用
  2. 通过 AST 分析 defineComponentexpose 选项
  3. 支持多种写法:字符串字面量、标识符、枚举值等

测试覆盖分析

测试代码有多种写法,我们需要支持各种常见的测试模式。

模式 1:传统 mount 方法

 // 测试代码
mount(Button, {
  props: { variant: 'primary', disabled: true },
  slots: { default: 'Click me' }
});
 // src/analyzer/UnitTestAnalyzer.ts:186
processMountComponent(componentArgNode: Node, optionsNode?: ObjectLiteralExpression) {
    if (!optionsNode) returnconst componentName = componentArgNode.getText();
    const componentFile = this.resolveComponentPath(componentArgNode as Identifier);

    if (!this.result[componentFile]) {
        this.result[componentFile] = {};
    }

    // 提取 props、emits、slots
    this.extractProps(optionsNode, this.result[componentFile]);
    this.extractEmits(optionsNode, this.result[componentFile]);
    this.extractSlots(optionsNode, this.result[componentFile]);
}

模式 2:JSX 写法

 // 测试代码
render(<Button variant="primary" disabled onClick={handler}>
  Click me
</Button>);
 // src/analyzer/UnitTestAnalyzer.ts:678
private analyzeJSXElements(callExpression: CallExpression) {
    const jsxElements = this.findJsxInCallExpression(callExpression);

    for (const jsxElement of jsxElements) {
        const openingElement = Node.isJsxElement(jsxElement)
            ? jsxElement.getOpeningElement()
            : jsxElement;

        const tagName = openingElement.getTagNameNode().getText();
        const filePath = this.resolveComponentPath(openingElement.getTagNameNode());

        // 提取 JSX 属性作为 props
        this.extractJSXAttrs(openingElement, this.result[filePath]);

        // 提取 JSX 子元素作为 slots
        if (Node.isJsxElement(jsxElement)) {
            this.extractJSXSlots(jsxElement, this.result[filePath]);
        }
    }
}

模式 3:Template 字符串

 // 测试代码
mount({
  template: '<Button variant="primary" @click="handler">Click me</Button>',
  components: { Button }
});
 // src/analyzer/UnitTestAnalyzer.ts:269
private extractPropsFromTemplate(template: string, componentTagName: string, componentTestUnit: TestUnit) {
    // 使用正则表达式解析模板中的属性
    const tagRegex = new RegExp(`<${componentTagName}(\s+[^>]*?)?>`, 'ig');
    let match;
    const propsFound: string[] = [];

    while ((match = tagRegex.exec(template)) !== null) {
        const attrsString = match[1];
        if (!attrsString) continue;

        // 解析属性名
        const attrRegex = /([@:a-zA-Z0-9_-]+)(?:=(?:"[^"]*"|'[^']*'|[^\s>]*))?/g;
        let attrMatch;
        while ((attrMatch = attrRegex.exec(attrsString)) !== null) {
            let propName = attrMatch[1];

            // 处理 v-bind:, :, v-model: 等前缀
            if (propName.startsWith(':')) {
                propName = propName.substring(1);
            } else if (propName.startsWith('v-bind:')) {
                propName = propName.substring(7);
            }

            propsFound.push(propName);
        }
    }

    componentTestUnit.props = [...new Set([...(componentTestUnit.props || []), ...propsFound])];
}

Exposed Methods 检测

对于暴露的方法,我们采用了一个简单但有效的策略:方法名匹配

 // src/analyzer/UnitTestAnalyzer.ts:1381
private analyzeExposedMethods(testCall: CallExpression) {
    const calledMethods = new Set<string>();

    // 查找所有属性访问表达式 (xxx.methodName)
    const propertyAccesses = testCall.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression);

    for (const access of propertyAccesses) {
        const methodName = access.getName();

        // 检查是否为暴露的方法
        if (this.isExposedMethod(methodName)) {
            calledMethods.add(methodName);
        }
    }

    // 将这些方法添加到组件的覆盖记录中
    for (const componentFile in this.result) {
        if (!this.result[componentFile].exposes) {
            this.result[componentFile].exposes = [];
        }
        for (const method of calledMethods) {
            if (!this.result[componentFile].exposes.includes(method)) {
                this.result[componentFile].exposes.push(method);
            }
        }
    }
}

核心原理:扫描测试代码中的所有属性访问表达式(如 wrapper.vm.focus()),提取方法名,然后过滤掉 Vue 内置方法和测试工具方法。

Strict Mode

在严格模式下,我们不仅检测 prop 是否被测试,还会检测每个 union 类型的变体是否都被测试。

 // src/analyzer/ComponentAnalyzer.ts:42
if (this.strictMode) {
    const propType = propSymbol.getTypeAtLocation(exportedExpression);
    const nonNullableType = propType.getNonNullableType();
    const variants = this.extractVariantsFromType(nonNullableType);

    if (variants.length > 0) {
        this.propsWithVariants.push({
            name: propName,
            variants
        });
    }
}

Union 类型展开

 // src/analyzer/ComponentAnalyzer.ts:62
private extractVariantsFromType(type: Type): PropVariant[] {
    const variants: PropVariant[] = [];

    if (type.isUnion()) {
        const unionTypes = type.getUnionTypes();

        for (const unionType of unionTypes) {
            // 跳过 undefined 和 null
            if (unionType.isUndefined() || unionType.isNull()) {
                continue;
            }

            const variant = this.getVariantFromType(unionType);
            if (variant) {
                // 跳过 false(boolean 类型只展开 true)
                if (!(variant.type === 'literal' && variant.value === false)) {
                    variants.push(variant);
                }
            }
        }
    }

    return variants;
}

核心原理

  1. 检测 prop 类型是否为 union 类型
  2. 遍历所有 union 成员,提取字面量值
  3. Boolean 类型只展开 true(因为 false 通常是默认值)
  4. 过滤掉 undefinednull

测试值提取

在测试代码中,我们需要提取实际传递的值:

 // src/analyzer/UnitTestAnalyzer.ts:942
private extractPropValue(attr: Node): PropValue | null {
    if (!Node.isJsxAttribute(attr)) return null;

    const propName = attr.getNameNode().getText();
    const initializer = attr.getInitializer();

    if (!initializer) {
        // 布尔属性 <Button disabled />
        return { propName, value: true, type: 'literal' };
    }

    // 字符串字面量
    if (Node.isStringLiteral(initializer)) {
        return { propName, value: initializer.getLiteralValue(), type: 'literal' };
    }

    // JSX 表达式
    if (Node.isJsxExpression(initializer)) {
        const expression = initializer.getExpression();
        if (!expression) return null;

        // 数字、布尔等字面量
        if (Node.isNumericLiteral(expression)) {
            return { propName, value: Number(expression.getLiteralValue()), type: 'literal' };
        }

        // 处理变量:通过类型推断获取实际值
        const exprType = expression.getType();
        if (exprType.isLiteral()) {
            const literalValue = exprType.getLiteralValue();
            if (literalValue !== undefined) {
                return { propName, value: literalValue, type: 'literal' };
            }
        }
    }

    return null;
}

核心原理

  1. 直接提取字面量值
  2. 对于变量和表达式,利用 TypeScript 的类型推断获取值
  3. 支持 ref 值追踪、循环变量展开等复杂场景

组件路径解析

为了准确关联测试代码和组件定义,我们需要解析 import 语句,找到组件的真实路径:

 // src/analyzer/UnitTestAnalyzer.ts:89
private resolveComponentPath(identifier: Identifier, importSymbol?: Symbol) {
    try {
        let originalSymbol: Symbol | undefined = importSymbol;
        if (identifier) {
            const typeChecker = this.project.getTypeChecker();
            originalSymbol = typeChecker.getSymbolAtLocation(identifier);
        }
        if (!originalSymbol) return null;

        // 解析别名
        while (originalSymbol?.getAliasedSymbol()) {
            originalSymbol = originalSymbol.getAliasedSymbol();
        }

        if (!originalSymbol) return null;
        const declarations = originalSymbol.getDeclarations();
        const declarationNode = declarations[0];
        if (!declarationNode) return null;

        const declarationSourceFile = declarationNode.getSourceFile();
        const originalPath = declarationSourceFile.getFilePath();

        if (!isComponentFile(originalPath)) {
            // 继续解析转发导出
            return this.resolveTsPath(declarationNode);
        }

        return originalPath;
    } catch (error) {
        return null;
    }
}

核心原理

  1. 从 identifier 获取 symbol

  2. 递归解析 alias symbol(处理 export { Button as Btn } 等情况)

  3. 获取原始声明文件路径

  4. 处理中间层的转发导出

实际应用场景

1. CI/CD 集成

通过 onFinished 回调强制 100% 覆盖:

export default defineConfig({
  test: {
    reporters: [['vc-api-coverage', {
      onFinished: (data) => {
        for (const item of data) {
          if (item.total > item.covered) {
            throw new Error(`${item.name} API Coverage is not 100%`)
          }
        }
      }
    }]]
  }
})

2. 组件库开发

对于组件库,确保每个组件的所有 API 都有测试覆盖:

reporters: [['vc-api-coverage', {
  include: ['**/src/components/**/*.{tsx,vue}'],
  format: ['cli', 'html'],
  openBrowser: true
}]]

3. 严格模式下的全面测试

对关键组件使用严格模式,确保每个 prop 变体都被测试:

reporters: [['vc-api-coverage', {
  include: ['**/src/components/Button/**/*.tsx'],
  strict: true  // 开启严格模式
}]]

总结

vc-api-coverage 通过巧妙地结合 TypeScript 类型系统和 AST 分析,实现了对 Vue 组件 API 覆盖率的精准检测。核心技术点包括:

  1. 类型系统利用:通过 $props$slots 等类型属性提取组件 API
  2. 多模式识别:支持 JSX、模板字符串、mount 对象等多种测试写法
  3. 严格模式:细粒度追踪 union 类型的每个变体
  4. 路径解析:递归追踪 import/export,准确关联测试和组件

这个工具不仅提升了组件测试的质量,还为团队提供了可量化的测试指标,让"测试覆盖率"这个概念更加贴近前端组件开发的实际需求。

后记

在目前 AI 辅助开发、Markdown 文档泛滥的场景下,其实开发一个强约束的工具也是一个不错的方向。相比于只能提供建议的文档和规范,带有强制检查能力的工具能够真正保证代码质量的底线。就像这个 API 覆盖率工具,它不是告诉你“应该写测试”,而是确保“必须写哪些测试”。

最后,感谢 Cursor 和 Claude Code 帮我完成了这个覆盖率工具和这篇分享文档。在 AI 辅助开发的时代,借助这些强大的工具,我们能够快速将想法转化为可用的产品。当然 AI 也不是万能,在某些场景下 AI i写的单测并没有实际测试到组件的功能,所以 AI 写的单测还是要让 AI 去review的。

参考资源

v-bind 你用对了吗?

作者 SuperEugene
2026年2月4日 19:18

先极简总结(2 句话记死,终身受用)

  1. 绑 1 个属性:用缩写 :属性名="值"(原生标签 / Vue 组件通用,日常 90% 用这个);
  2. 绑 N 个属性:用 v-bind="属性对象"(无冒号、无属性名,属性越多越简洁,封装组件 / 配置驱动场景必用)。

核心前提先讲透(一句话干货)

  • v-bind是 Vue 用来给HTML 标签 / Vue 组件绑定动态属性值的指令,只有 2 种核心用法,本质都是「动态传值」,区别仅在于绑 1 个属性还是绑多个属性
  1. 单个属性绑定v-bind:属性名="值" → 缩写 :属性名="值"(日常 90% 高频用);

  2. 批量属性绑定:直接v-bind="属性对象"(无属性名、无冒号,把多个属性打包成对象一次性绑定);

关键:批量绑定的属性对象键 = HTML 标签 / Vue 组件的属性名值 = 要绑定的动态数据,Vue 会自动解析成「键 = 值」的单个属性,一一绑定到标签上。

例子 :原生img图片标签(最易理解的通用场景)

img标签的src(图片地址)、alt(占位文字)、width(宽度)是所有人都认识的原生属性,用它举例最直观,重点看单个绑定批量绑定的等价关系。

步骤 :先定义 Vue 里的动态数据(脚本部分,通用)

<script setup> 
    // Vue3基础响应式数据,不用纠结ref,知道是动态值就行 
    import { ref } from 'vue' 
    // 图片地址 
    const imgUrl = ref('https://picsum.photos/200/200')
    // 图片占位文字 
    const imgText = ref('风景图') 
    // 图片宽度 
    const imgW = ref(200)
</script>

用法 1:单个属性绑定(缩写:,逐个绑)

最常用的写法,给img逐个绑定动态属性,每个属性都用:缩写,模板清晰:

<template> 
    <!-- 核心:每个原生属性前加:,绑定对应的动态值 --> 
    <img :src="imgUrl" :alt="imgText" :width="imgW" /> 
</template>

等价于完整v-bind写法(繁琐,几乎没人用):

<script setup> 
    import { ref, reactive } from 'vue' 
    // 1. 先定义零散动态值 
    const imgUrl = ref('https://picsum.photos/200/200') 
    const imgText = ref('风景图') 
    const imgW = ref(200) 
    // 2. 打包成「属性对象」:键=img原生属性名,值=动态值 
    const imgProps = reactive({ src: imgUrl, alt: imgText, width: imgW }) 
</script> 
<template> 
    <!-- 核心:无属性名、无冒号,v-bind直接绑属性对象 --> 
    <img v-bind="imgProps" /> 
</template>

用法 2:批量属性绑定(v-bind="对象",打包绑)

img需要的所有动态属性打包成一个对象,用v-bind直接绑定这个对象,Vue 会自动解析:

<script setup> 
    import { ref, reactive } from 'vue' 
    // 1. 先定义零散动态值 
    const imgUrl = ref('https://picsum.photos/200/200') 
    const imgText = ref('风景图') 
    const imgW = ref(200) 
    // 2. 打包成「属性对象」:键=img原生属性名,值=动态值 
    const imgProps = reactive({ src: imgUrl, alt: imgText, width: imgW }) 
</script> 
<template> 
    <!-- 核心:无属性名、无冒号,v-bind直接绑属性对象 --> 
    <img v-bind="imgProps" /> 
</template>

完全等价于单个绑定的写法,Vue 会自动把imgProps里的src/alt/width逐个绑定到img标签上,效果一模一样。

单个和批量这两种用法是 Vue 最基础、最高频的语法,不用记复杂概念,只要知道「单个用:,多个用 v-bind = 对象」,就能搞定所有动态属性绑定场景。

Guigu 甑选平台第一篇:项目初始化与配置

2026年2月4日 18:55

第一章:项目创建 - 使用Create Vue的理由和步骤

步骤1:使用官方脚手架创建项目

使用npm create vue@latest是因为这是Vue团队官方维护的脚手架工具,能够确保项目结构与最新Vue特性完全兼容。它集成了Vue社区的最佳实践和推荐配置,减少了手动配置可能出现的错误。交互式命令行让开发者能够按需选择功能模块。

bash

复制下载

# 执行创建命令
npm create vue@latest

# 交互式配置
 Project name: ... guigu-zhenxuan-platform
 Add TypeScript? ... Yes  # 选择TypeScript是为了提供类型安全,减少运行时错误
 Add JSX Support? ... No  # 不使用JSX是因为Vue推荐使用模板语法,保持项目语法一致性
 Add Vue Router for Single Page Application development? ... Yes  # 添加Vue Router是因为SPA应用必须的路由管理
 Add Pinia for state management? ... Yes  # 选择Pinia是因为它是Vue官方推荐的状态管理库
 Add Vitest for Unit Testing? ... No  # 不先添加单元测试是为了先搭建项目基础架构,测试可以后期添加
 Add an End-to-End Testing Solution? ... No  # 不添加E2E测试是因为初期项目重点在功能开发
 Add ESLint for code quality? ... Yes  # 添加ESLint是为了统一代码风格,提高代码质量
 Add Prettier for code formatting? ... Yes  # 添加Prettier是为了自动格式化代码,避免团队成员间的格式争议

# 进入项目并安装基础依赖
cd guigu-zhenxuan-platform
npm install

初始化后的项目结构说明

text

复制下载

guigu-zhenxuan-platform/
├── src/
│   ├── components/    # components目录用于存放可复用的UI组件
│   ├── views/         # views目录用于存放页面级组件,这是Vue Router的惯例命名
│   ├── router/        # router目录用于集中管理路由配置
│   ├── stores/        # stores目录用于存放Pinia状态管理文件
│   └── main.ts        # main.ts是Vue应用的入口文件
├── public/            # public目录用于存放不需要构建处理的静态资源
├── .eslintrc.cjs      # ESLint配置文件,使用.cjs扩展名是因为需要CommonJS格式
├── .prettierrc        # Prettier代码格式化配置文件
├── index.html         # HTML入口文件,浏览器通过这个文件加载应用
├── package.json       # 项目配置文件,管理依赖和脚本
├── tsconfig.json      # TypeScript编译配置文件
└── vite.config.ts     # Vite构建工具配置文件

第二章:修改Package.json - 详细配置解析

步骤1:更新scripts配置

scripts配置决定了项目的开发工作流,合理的配置能提高开发效率。

打开package.json,修改scripts部分:

json

复制下载

"scripts": {
  "dev": "vite --open",
  // 配置vite --open是为了启动开发服务器后自动打开浏览器,提升开发体验
  
  "build": "run-p type-check "build-only {@}" --",
  // 这样配置build命令是为了并行执行类型检查和构建过程,提高构建速度
  
  "preview": "vite preview",
  // preview命令用于预览生产环境构建结果,验证构建效果是否符合预期
  
  "build-only": "vite build",
  // 单独的build-only命令用于纯构建操作,方便在组合命令中调用
  
  "type-check": "vue-tsc --build",
  // 使用vue-tsc是因为它专门针对Vue单文件组件进行TypeScript类型检查
  
  "lint": "run-s lint:*",
  // 使用run-s是为了顺序执行所有lint相关任务,确保代码检查的完整性
  
  "lint:oxlint": "oxlint . --fix",
  // 配置oxlint是因为它相比ESLint有更好的性能表现,检查速度更快
  
  "lint:eslint": "eslint . --fix --cache",
  // 保留ESLint是因为它有成熟的生态系统和丰富的插件支持
  
  "format": "prettier --write --experimental-cli src/",
  // 使用--experimental-cli参数是为了启用Prettier新版本的命令行特性
  
  "preinstall": "node ./scripts/preinstall.js"
  // preinstall脚本用于在安装依赖前检查开发环境是否符合要求
}

步骤2:添加生产依赖

生产依赖是项目运行时必须的包,每个依赖都有特定的业务用途。

执行以下安装命令:

bash

复制下载

# 安装Element Plus UI组件库
npm install element-plus
# 安装Element Plus是因为它提供了丰富的企业级UI组件,能显著加快开发速度

# 安装Element Plus图标库
npm install @element-plus/icons-vue
# 安装图标库是为了提供丰富的图标资源,提升用户界面视觉效果

# 安装Axios HTTP客户端
npm install axios
# 安装Axios是因为它是一个功能强大的HTTP客户端,支持请求拦截、响应拦截等高级特性

# 安装Mock.js数据模拟库
npm install mockjs
# 安装Mock.js是为了在开发阶段模拟后端API数据,实现前后端并行开发

package.json中的dependencies部分配置如下:

json

复制下载

"dependencies": {
  "@element-plus/icons-vue": "^2.3.2",  // Element Plus图标组件
  "axios": "^1.13.4",                    // HTTP请求库,用于API调用
  "element-plus": "^2.13.1",             // UI组件库,提供基础界面组件
  "mockjs": "^1.1.0",                    // 模拟数据生成器
  "pinia": "^2.1.7",                     // 状态管理库,已由create-vue安装
  "vue": "^3.4.21",                      // Vue核心框架,已由create-vue安装
  "vue-router": "^4.3.0"                 // 路由管理库,已由create-vue安装
}

步骤3:添加开发依赖

开发依赖只在开发阶段使用,用于提升开发体验和保证代码质量。

执行以下安装命令:

bash

复制下载

# 安装TypeScript相关配置
npm install --save-dev @tsconfig/node24
# 安装@tsconfig/node24是为了使用Node.js 24的TypeScript配置预设

npm install --save-dev @vue/tsconfig
# 安装@vue/tsconfig是为了使用Vue官方推荐的TypeScript配置预设

npm install --save-dev @types/node
# 安装@types/node是为了获取Node.js API的类型定义

# 安装Vite插件
npm install --save-dev vite-plugin-mock
# 安装vite-plugin-mock是为了将Mock数据集成到Vite开发服务器中

npm install --save-dev vite-plugin-svg-icons
# 安装vite-plugin-svg-icons是为了优化SVG图标的使用体验

npm install --save-dev vite-plugin-vue-devtools
# 安装vite-plugin-vue-devtools是为了增强Vue开发工具的功能

# 安装代码质量工具
npm install --save-dev eslint-config-prettier
# 安装eslint-config-prettier是为了集成Prettier和ESLint,避免规则冲突

npm install --save-dev eslint-plugin-oxlint
# 安装eslint-plugin-oxlint是为了在ESLint中使用oxlint规则

npm install --save-dev oxlint
# 安装oxlint是因为它提供了比ESLint更快的JavaScript代码检查

# 安装工具库
npm install --save-dev npm-run-all2
# 安装npm-run-all2是为了并行或顺序运行多个npm脚本

npm install --save-dev jiti
# 安装jiti是为了提供TypeScript文件的即时编译能力

完整的devDependencies配置如下:

json

复制下载

"devDependencies": {
  "@tsconfig/node24": "^24.0.4",           // Node.js 24的TypeScript配置预设
  "@types/node": "^20.12.7",              // Node.js API类型定义
  "@vitejs/plugin-vue": "^5.0.4",         // Vite的Vue单文件组件插件
  "@vue/eslint-config-typescript": "^13.0.0", // Vue项目的TypeScript ESLint配置
  "@vue/tsconfig": "^0.5.0",              // Vue项目的TypeScript配置
  "eslint": "^9.0.0",                     // JavaScript代码检查工具
  "eslint-config-prettier": "^9.1.0",     // 关闭与Prettier冲突的ESLint规则
  "eslint-plugin-oxlint": "~1.42.0",      // oxlint的ESLint插件
  "eslint-plugin-vue": "^9.23.0",         // Vue.js的ESLint插件
  "jiti": "^1.21.0",                      // TypeScript即时编译工具
  "npm-run-all2": "^8.0.4",               // 并行运行npm脚本的工具
  "oxlint": "~1.42.0",                    // 高性能JavaScript linter
  "prettier": "3.2.5",                    // 代码格式化工具
  "typescript": "~5.3.3",                 // TypeScript编译器
  "vite": "^5.2.0",                       // 前端构建工具
  "vite-plugin-mock": "^3.0.2",           // Vite的Mock数据插件
  "vite-plugin-svg-icons": "^2.0.1",      // Vite的SVG图标插件
  "vite-plugin-vue-devtools": "^7.3.0",   // Vite的Vue开发工具插件
  "vue-tsc": "^1.8.27"                    // Vue单文件组件的TypeScript检查器
}

步骤4:配置引擎要求和Prettier

在package.json末尾添加以下配置:

json

复制下载

"engines": {
  "node": "^20.19.0 || >=22.12.0"
},
// 配置engines是为了明确项目所需的Node.js版本范围,确保开发环境一致性

"prettier": {
  "ignorePath": ".prettierignore"
}
// 配置prettier是为了指定忽略文件配置,避免对特定文件进行格式化

第三章:创建环境检查脚本

步骤1:创建预安装脚本

预安装脚本在npm install之前执行,用于检查开发环境是否符合要求。

bash

复制下载

# 创建scripts目录
mkdir scripts

# 创建preinstall.js文件
touch scripts/preinstall.js

编辑scripts/preinstall.js文件:

javascript

复制下载

// 检查Node.js版本是否符合项目要求
const currentNodeVersion = process.versions.node;
const semver = currentNodeVersion.split('.');
const major = parseInt(semver[0], 10);

// 项目要求Node.js 20.19.0或更高版本
if (major < 20) {
  console.error(
    '你正在使用 Node.js ' +
      currentNodeVersion +
      '。\n' +
      '本项目需要 Node.js 20.19.0 或更高版本。\n' +
      '请升级你的 Node.js 版本。'
  );
  process.exit(1);  // 退出进程,阻止继续安装
}

console.log('✅ Node.js 版本检查通过');
// 版本检查通过后,npm install会继续执行

这个脚本的作用:确保所有开发者在一致的Node.js环境下工作,避免因版本差异导致的兼容性问题。

第四章:配置HTML入口文件

步骤1:修改index.html

index.html是Web应用的入口文件,浏览器通过加载这个文件启动整个应用。

编辑index.html文件:

html

复制下载运行

<!DOCTYPE html>
<html lang="zh-CN">
  <!-- 指定中文语言是为了更好的无障碍支持和SEO优化 -->
  
  <head>
    <meta charset="UTF-8">
    <!-- 设置UTF-8编码是为了支持中文等多语言字符 -->
    
    <link rel="icon" href="/favicon.ico">
    <!-- 设置网站图标,提升品牌识别度 -->
    
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <!-- 配置viewport是为了实现响应式设计,适配移动设备 -->
    
    <title>硅谷甑选平台</title>
    <!-- 设置页面标题,显示在浏览器标签页上 -->
  </head>
  
  <body>
    <div id="app"></div>
    <!-- Vue应用挂载点,所有Vue组件将在这个div内渲染 -->
    
    <script type="module" src="/src/main.ts"></script>
    <!-- 使用type="module"启用ES模块支持,加载应用入口文件 -->
  </body>
</html>

第五章:配置TypeScript和Vite

步骤1:修改tsconfig.json

TypeScript配置文件决定了TypeScript编译器如何工作。

打开tsconfig.json,确保配置正确:

json

复制下载

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  // 继承Vue官方的TypeScript配置,减少手动配置工作量
  
  "compilerOptions": {
    "target": "ES2020",
    // 设置编译目标为ES2020,使用较新的JavaScript特性
    
    "useDefineForClassFields": true,
    // 使用ES2022的类字段定义方式
    
    "module": "ESNext",
    // 使用ES模块系统,支持tree-shaking
    
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    // 包含的库文件,提供类型提示
    
    "skipLibCheck": true,
    // 跳过库文件的类型检查,加快编译速度
    
    "moduleResolution": "bundler",
    // 使用bundler的模块解析策略,与Vite保持一致
    
    "allowImportingTsExtensions": true,
    // 允许导入TypeScript扩展名的文件
    
    "resolveJsonModule": true,
    // 允许导入JSON文件作为模块
    
    "isolatedModules": true,
    // 确保每个文件都能单独编译
    
    "noEmit": true,
    // 不输出编译文件,由Vite处理构建
    
    "jsx": "preserve",
    // 保留JSX语法,由其他工具处理
    
    "strict": true,
    // 启用所有严格类型检查
    
    "noUnusedLocals": true,
    // 检查未使用的局部变量
    
    "noUnusedParameters": true,
    // 检查未使用的函数参数
    
    "noFallthroughCasesInSwitch": true,
    // 检查switch语句的fallthrough情况
    
    "baseUrl": ".",
    // 设置基础路径为当前目录
    
    "paths": {
      "@/*": ["./src/*"]
    }
    // 配置路径别名,@表示src目录,简化导入路径
  },
  
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  // 包含需要编译的文件类型
  
  "references": [
    {
      "path": "./tsconfig.node.json"
    }
    // 引用Node环境的TypeScript配置
  ]
}

步骤2:修改tsconfig.node.json

这个文件用于配置Node.js环境的TypeScript编译。

json

复制下载

{
  "extends": "@tsconfig/node24/tsconfig.json",
  // 继承Node.js 24的TypeScript配置预设
  
  "include": [
    "vite.config.ts",
    "scripts/**/*",
    "mock/**/*"
  ],
  // 包含Node环境下的TypeScript文件
  
  "compilerOptions": {
    "composite": true,
    // 启用复合编译,支持项目引用
    
    "noEmit": true,
    // 不输出编译文件
    
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
    // TypeScript构建信息文件位置
  }
}

步骤3:配置Vite构建工具

Vite配置文件决定了项目的构建行为和开发服务器配置。

打开vite.config.ts,修改为:

typescript

复制下载

import { fileURLToPath, URL } from 'node:url'
// 导入URL处理工具,用于处理文件路径
import { defineConfig } from 'vite'
// 导入Vite配置函数
import vue from '@vitejs/plugin-vue'
// 导入Vite的Vue插件,用于处理.vue文件
import { viteMockServe } from 'vite-plugin-mock'
// 导入Mock插件,用于开发阶段的数据模拟
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
// 导入SVG图标插件,优化图标使用
import VueDevTools from 'vite-plugin-vue-devtools'
// 导入Vue开发工具插件,增强调试能力
import path from 'path'
// 导入路径处理工具

export default defineConfig(({ command }) => ({
  // 根据命令模式(serve/build)返回不同配置
  
  plugins: [
    vue(),
    // Vue单文件组件插件,必须放在第一个
    
    viteMockServe({
      mockPath: 'mock',
      // Mock数据文件存放目录
      enable: command === 'serve',
      // 只在开发服务器启用Mock
    }),
    
    createSvgIconsPlugin({
      iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
      // SVG图标文件目录
      symbolId: 'icon-[dir]-[name]',
      // 图标ID生成规则
    }),
    
    VueDevTools(),
    // Vue开发工具插件
  ],
  
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
      // 配置路径别名,@指向src目录
    }
  },
  
  server: {
    port: 3000,
    // 开发服务器端口号
    open: true,
    // 启动后自动打开浏览器
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        // 代理目标地址
        changeOrigin: true,
        // 修改请求头中的Origin字段
        rewrite: (path) => path.replace(/^/api/, '')
        // 重写请求路径,移除/api前缀
      }
    }
  }
}))

第六章:配置代码质量和样式

步骤1:创建样式重置文件

样式重置文件用于统一不同浏览器的默认样式,提供一致的基准样式。

bash

复制下载

# 创建styles目录
mkdir src/styles

# 创建reset.css文件
touch src/styles/reset.css

编辑src/styles/reset.css文件:

css

复制下载

/* 重置所有元素的默认边距和内边距 */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  /* 使用border-box盒模型,更符合开发直觉 */
}

/* 设置根元素和body的高度 */
html, body {
  height: 100%;
  /* 确保页面能占满整个视口高度 */
  
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  /* 设置字体栈,优先使用系统字体 */
}

/* 设置Vue应用容器的样式 */
#app {
  height: 100%;
  /* 应用容器占满整个父元素高度 */
}

步骤2:修改ESLint配置

ESLint配置文件定义了代码检查规则,确保代码质量一致性。

打开.eslintrc.cjs,修改为:

javascript

复制下载

/* eslint-env node */
// 声明当前文件运行在Node.js环境中

require('@rushstack/eslint-patch/modern-module-resolution')
// 使用ESLint补丁,解决模块解析问题

module.exports = {
  root: true,
  // 指定为根配置文件,ESLint不会向上查找其他配置
  
  extends: [
    'plugin:vue/vue3-essential',
    // Vue 3基础规则集
    'eslint:recommended',
    // ESLint推荐规则
    '@vue/eslint-config-typescript',
    // Vue的TypeScript配置
    '@vue/eslint-config-prettier/skip-formatting'
    // 跳过Prettier的格式化规则
  ],
  
  parserOptions: {
    ecmaVersion: 'latest'
    // 使用最新的ECMAScript版本
  },
  
  rules: {
    'vue/multi-word-component-names': 'off'
    // 关闭Vue组件必须多单词命名的规则
    // 因为有些基础组件如Login、Home使用单单词更合适
  }
}

步骤3:创建.prettierignore文件

Prettier忽略文件指定了哪些文件不需要进行代码格式化。

bash

复制下载

# 创建Prettier忽略文件
touch .prettierignore

编辑.prettierignore文件:

plaintext

复制下载

node_modules
# 忽略node_modules目录,因为这是第三方依赖

dist
# 忽略构建输出目录

*.min.js
# 忽略压缩的JavaScript文件

*.min.css
# 忽略压缩的CSS文件

第七章:配置项目核心文件

步骤1:修改main.ts文件

main.ts是Vue应用的入口文件,负责初始化Vue应用并注册各种插件。

打开src/main.ts,修改为:

typescript

复制下载

import { createApp } from 'vue'
// 导入Vue的createApp函数,用于创建Vue应用实例

import './styles/reset.css'
// 导入重置样式,确保样式一致性

import App from './App.vue'
// 导入根组件

import router from './router'
// 导入路由配置

import { createPinia } from 'pinia'
// 导入Pinia的createPinia函数,用于创建状态存储

// 导入Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// 导入Element Plus及其样式

import * as ElementPlusIconsVue from '@element-plus/icons-vue'
// 导入Element Plus的所有图标组件

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

// 创建Pinia状态存储实例
const pinia = createPinia()

// 注册Element Plus插件
app.use(ElementPlus)
// 注册路由
app.use(router)
// 注册Pinia状态管理
app.use(pinia)

// 注册所有Element Plus图标组件
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
  // 将每个图标注册为全局组件
}

// 将Vue应用挂载到HTML中的#app元素
app.mount('#app')

步骤2:修改App.vue文件

App.vue是应用的根组件,所有其他组件都在这个组件内渲染。

打开src/App.vue,修改为:

vue

复制下载

<script setup lang="ts">
// 使用<script setup>语法糖,简化组合式API的使用
// lang="ts"指定使用TypeScript

import { RouterView } from 'vue-router'
// 导入RouterView组件,用于渲染当前路由对应的组件
</script>

<template>
  <!-- 路由视图容器,根据当前路由显示不同的页面 -->
  <RouterView />
</template>

<style scoped>
/* scoped样式,只作用于当前组件 */
/* 可以在这里添加全局的样式规则 */
</style>

步骤3:配置路由

路由配置文件定义了应用的路由结构和页面导航逻辑。

打开src/router/index.ts,确保基本配置:

typescript

复制下载

import { createRouter, createWebHistory } from 'vue-router'
// 导入Vue Router的创建函数
// createWebHistory使用HTML5 History API,URL更美观

import type { RouteRecordRaw } from 'vue-router'
// 导入路由记录类型定义

// 定义路由数组,每个路由对应一个页面
const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    // 根路径
    redirect: '/login'
    // 重定向到登录页面,作为默认首页
  },
  {
    path: '/login',
    // 登录页面路径
    name: 'Login',
    // 路由名称,用于编程式导航
    component: () => import('@/views/LoginView.vue')
    // 使用动态导入实现路由懒加载,提高首屏加载速度
  }
  // 可以在这里添加更多路由配置
]

// 创建路由实例
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  // 使用history模式,需要服务器配置支持
  // import.meta.env.BASE_URL获取基础URL
  
  routes
  // 传入路由配置
})

// 导出路由实例,供main.ts使用
export default router

步骤4:配置Pinia Store

Pinia配置文件定义了应用的状态管理结构。

打开src/stores/index.ts,修改为:

typescript

复制下载

import { createPinia } from 'pinia'
// 导入createPinia函数,用于创建Pinia实例

// 创建Pinia实例
const pinia = createPinia()

// 导出Pinia实例,供main.ts使用
export default pinia

// 在这里可以导出具体的store模块
// 例如:export { useUserStore } from './user'
// 这样可以集中管理所有store的导出

第八章:创建Mock数据

步骤1:创建Mock目录和文件

Mock数据用于在开发阶段模拟后端API响应,实现前后端并行开发。

bash

复制下载

# 创建mock目录
mkdir mock

# 创建user mock文件
touch mock/user.ts

步骤2:配置Mock数据

编辑mock/user.ts文件:

typescript

复制下载

/*
 * @Description: Stay hungry,Stay foolish
 * @Author: Huccct
 * @Date: 2024-03-21
 */

// 模拟用户列表数据
const userList = [
  {
    id: 1,
    username: 'admin',
    password: '123456',
    name: '超级管理员',
    phone: '13800138000',
    roleName: '超级管理员',
    createTime: '2024-03-21',
    updateTime: '2024-03-21',
    status: 1,
  },
  {
    id: 2,
    username: 'test',
    password: '123456',
    name: '测试用户',
    phone: '13800138001',
    roleName: '普通管理员',
    createTime: '2024-03-21',
    updateTime: '2024-03-21',
    status: 1,
  },
]

export default [
  // 用户登录接口
  {
    url: '/api/user/login',
    method: 'post',
    response: ({ body }) => {
      const { username, password } = body
      const checkUser = userList.find(
        (item) => item.username === username && item.password === password,
      )
      if (!checkUser) {
        return { code: 201, data: { message: '账号或者密码不正确' } }
      }
      return { code: 200, data: {token:'Admin Token' }}
    },
  },
  // 获取用户信息
  {
    url: '/api/user/info',
    method: 'get',
    response: (request) => {
      const token = request.headers.token
      if (token === 'Admin Token') {
        return {
          code: 200,
          data: {
            name: 'admin',
            avatar:
              'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
            roles: ['admin'],
            buttons: ['cuser.detail'],
            routes: [
              'home',
              'Acl',
              'User',
              'Role',
              'Permission',
              'Product',
              'Trademark',
              'Attr',
              'Spu',
              'Sku',
            ],
          },
          message: '获取用户信息成功',
        }
      }
      return {
        code: 201,
        data: null,
        message: '获取用户信息失败',
      }
    },
  },
  // 获取用户列表
  {
    url: '/api/acl/user/:page/:limit',
    method: 'get',
    response: ({ query }) => {
      const { username } = query
      let filteredList = userList
      if (username) {
        filteredList = userList.filter((user) =>
          user.username.includes(username),
        )
      }
      return {
        code: 200,
        data: {
          records: filteredList,
          total: filteredList.length,
        },
      }
    },
  },
  // 添加/更新用户
  {
    url: '/api/acl/user/save',
    method: 'post',
    response: ({ body }) => {
      const newUser = {
        ...body,
        id: userList.length + 1,
        createTime: new Date().toISOString().split('T')[0],
        updateTime: new Date().toISOString().split('T')[0],
        status: 1,
      }
      userList.push(newUser)
      return { code: 200, data: null, message: '添加成功' }
    },
  },
  {
    url: '/api/acl/user/update',
    method: 'put',
    response: ({ body }) => {
      const index = userList.findIndex((item) => item.id === body.id)
      if (index !== -1) {
        userList[index] = {
          ...userList[index],
          ...body,
          updateTime: new Date().toISOString().split('T')[0],
        }
      }
      return { code: 200, data: null, message: '更新成功' }
    },
  },
  // 删除用户
  {
    url: '/api/acl/user/remove/:id',
    method: 'delete',
    response: (request) => {
      const id = request.query.id
      if (!id) {
        return { code: 201, data: null, message: '参数错误' }
      }
      const index = userList.findIndex((item) => item.id === Number(id))
      if (index !== -1) {
        userList.splice(index, 1)
        return { code: 200, data: null, message: '删除成功' }
      }
      return { code: 201, data: null, message: '用户不存在' }
    },
  },
  // 批量删除用户
  {
    url: '/api/acl/user/batchRemove',
    method: 'delete',
    response: ({ body }) => {
      const { idList } = body
      idList.forEach((id) => {
        const index = userList.findIndex((item) => item.id === id)
        if (index !== -1) {
          userList.splice(index, 1)
        }
      })
      return { code: 200, data: null, message: '批量删除成功' }
    },
  },
  // 获取用户角色
  {
    url: '/api/acl/user/toAssign/:userId',
    method: 'get',
    response: () => {
      return {
        code: 200,
        data: {
          assignRoles: [
            {
              id: 1,
              roleName: '超级管理员',
              createTime: '2024-03-21',
              updateTime: '2024-03-21',
            },
          ],
          allRolesList: [
            {
              id: 1,
              roleName: '超级管理员',
              createTime: '2024-03-21',
              updateTime: '2024-03-21',
            },
            {
              id: 2,
              roleName: '普通管理员',
              createTime: '2024-03-21',
              updateTime: '2024-03-21',
            },
          ],
        },
      }
    },
  },
  // 分配用户角色
  {
    url: '/api/acl/user/doAssignRole',
    method: 'post',
    response: () => {
      return { code: 200, data: null, message: '分配角色成功' }
    },
  },
  // 用户登出接口
  {
    url: '/api/user/logout',
    method: 'post',
    response: () => {
      return { code: 200, data: null, message: '退出成功' }
    },
  },
]

第九章:总结

至此,你已经完成了Guigu致选平台项目的初始化配置。通过这个一步一步的教程,你应该能够:

  1. ✅ 使用create-vue脚手架创建项目
  2. ✅ 按照项目文档配置所有依赖
  3. ✅ 设置TypeScript和Vite配置
  4. ✅ 配置Element Plus和图标
  5. ✅ 设置Mock数据服务
  6. ✅ 创建基础的项目结构
  7. ✅ 启动并验证项目运行

前端 er 速码!TinyVue 全局动效实践指南,干货拉满

2026年2月4日 17:34

本文由TinyVue贡献者程锴原创。

一、前言:为什么要统一管理动效

在前端开发中,动画不仅是锦上添花的“视觉糖”,更是交互体验的重要组成部分: 它能引导用户关注、反馈操作结果、缓解等待焦虑、提升产品质感。

但当项目变大、组件增多后,你可能遇到这些问题:

  • 同样的淡入淡出,在不同组件中表现不一致
  • 想调整动画速度,却要修改多个文件
  • 动画样式难以复用、维护困难

这些问题的根源在于:动画定义分散、缺乏统一管理。 为此,TinyVue 引入了一套全新的 全局动效体系,基于 LESS + CSS 变量 实现集中配置与动态控制。

二、为什么选择 LESS + CSS 变量

常见的动画实现方式有两种:

方式 优点 缺点
1️⃣ 直接在组件中定义@keyframes 简单直观,局部可定制 无法统一、修改麻烦
2️⃣ 全局管理动画 可复用、风格一致 静态,难以动态调整

TinyVue 采用 LESS + CSS 变量结合方案,兼顾两者优势:

变量化控制 所有动效的时长、透明度、位移量都由 CSS 变量控制

可局部覆盖 组件可根据需求覆盖变量,灵活调整动画参数

主题可切换 只需在不同主题文件中修改变量,即可快速切换全局动效风格

三、环境搭建与示例预览

1. 拉取 TinyVue 仓库:

git clone https://github.com/opentiny/tiny-vue.git
cd tiny-vue
pnpm i

1.PNG

2. 启动TinyVue项目

pnpm dev

浏览器访问:http://localhost:7130

2.png

3. 打开配置文件:

/packages/theme/src/base/vars.less

3.png

1). 修改变量即可实时生效:

--tv-motion-slide-speed: 1.2s;

刷新页面后,可在抽屉(Drawer)组件中观察滑动动效速度变化。

4.gif

同样地:

--tv-motion-fade-offset-y: 100px;

会影响对话框(DialogBox)的淡入位移动画。

5.gif

四、全局动效的设计思路

1. 统一变量管理

所有动画相关参数集中在 /packages/theme/src/base/vars.less

:root {
  /* 淡入淡出 */
  --tv-motion-fade-speed: 0.3s;

  /* 滑动类 */
  --tv-motion-slide-speed: 0.4s;
  --tv-motion-slide-offset-left: -30px;
  --tv-motion-slide-offset-left-mid: -10px;
  --tv-motion-slide-opacity-mid: 0.5;

  /* 蚂蚁线 */
  --tv-motion-ants-shift: 8px;
  --tv-motion-ants-speed: 0.8s;
}

修改任意变量即可影响全局动效表现。

2. 按类型分类管理

为方便维护和扩展,动效按类型拆分为多个 LESS 文件:

motion/
  fade.less       // 淡入淡出
  slide.less      // 滑动
  zoom.less       // 缩放
  rotate.less     // 旋转
  bounce.less     // 弹跳
  ants.less       // 蚂蚁线
  ...
  index.less      // 汇总引入

每个文件独立维护一类动效,结构清晰,修改成本低。

3. 动效命名规范

统一命名规则: {type}-{direction}-{state}

示例:

  • fade-in:淡入
  • slide-left-in:从左滑入
  • zoom-in:放大进入
  • ants-x-rev:蚂蚁线反向滚动

保证语义清晰、全局唯一,方便引用与调试。

五、动效实现示例

1️⃣ 淡入淡出动效

@keyframes fade-in {
  0% { opacity: 0; }
  100% { opacity: 1; }
}
@keyframes fade-out {
  0% { opacity: 1; }
  100% { opacity: 0; }
}

调用方式:

.fade-enter-active {
  animation: fade-in var(--tv-motion-fade-speed) ease-out both;
}
.fade-leave-active {
  animation: fade-out var(--tv-motion-fade-speed) ease-in both;
}

2️⃣ 滑动动效

@keyframes slide-left-in {
  0% {
    opacity: 0;
    transform: translateX(var(--tv-motion-slide-offset-left));
  }
  50% {
    opacity: var(--tv-motion-slide-opacity-mid);
    transform: translateX(var(--tv-motion-slide-offset-left-mid));
  }
  100% {
    opacity: 1;
    transform: translateX(0);
  }
}

通过变量可灵活调整动画节奏和距离。

3️⃣ 蚂蚁线动画(Ants)

@keyframes ants-x {
  0% { background-position: 0 0; }
  100% { background-position: var(--tv-motion-ants-shift, 8px) 0; }
}

在组件中调用:

.copyed-borders {
  --tv-motion-ants-shift: 13px;
  .border-top {
    animation: ants-x var(--tv-motion-ants-speed) linear infinite;
  }
}

六、组件集成方式

方式 描述
全局引入 motion/index.less 统一引入所有动效,确保全局可用
局部调用 组件通过类名或 animation 属性使用对应动效
变量覆盖 通过覆盖 CSS 变量实现不同组件动效差异化

七、实践经验与优化建议

保持命名规范:保证语义清晰、避免重复
文件分类明确:不同类型动效分文件管理
加注释和示例:便于团队协作与复用

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue源码:github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyVue、TinyEngine、TinyPro、TinyNG、TinyCLI、TinyEditor 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

Vue-深度解读代理技术:Object.defineProperty 与 Proxy

2026年2月3日 17:06

前言

在 Vue 的进化史中,从 Vue 2 到 Vue 3 的跨越,最核心的变革莫之过于响应式系统的重构。而这场重构的主角,正是 Object.definePropertyProxy。本文将带你从底层描述符到 Reflect 陷阱,深度拆解这两大对象代理技术。

一、 ES5 时代的功臣:Object.defineProperty

Object.defineProperty 用于在一个对象上定义或修改属性。Vue 2 的响应式基础正是建立在其“存取描述符”之上的。

1. 基础语法

Object.defineProperty(obj, prop, descriptor);

  • obj:目标对象
  • prop:要定义或修改的属性名(字符串或 Symbol)
  • descriptor:属性描述符,是一个配置对象(包含数据描述符与存取描述符)

2. descriptor描述符分类

它可分为两类,一类为数据描述符、一类为存取描述符

属性描述符不能同时包含 value/writable(数据描述符)和 get/set(存取描述符)。

  • 数据描述符

    字段 类型 默认值 说明
    value any undefined 属性的值
    writable boolean false 是否可写(能否被重新赋值)
    enumerable boolean false 是否可枚举(能否在 for...inObject.keys 中出现)
    configurable boolean false 是否可配置(能否被删除或修改描述符)
  • 存取描述符:

    字段 类型 说明
    get function 读取属性时调用的函数
    set function 设置属性时调用的函数

注意❗:一个描述符不能同时包含 value/writableget/set,否则会报错。

3. 局限性分析(Vue 2 的痛点)

  • 无法监听新增/删除:必须预先定义好属性,动态添加的属性(data.b = 2)无法响应。

  • 数组支持差:无法拦截索引修改(arr[0] = x)及 length 变更。

  • 性能开销:必须通过递归遍历对象的所有属性进行拦截。

4. 使用示例:

// 封装一个劫持对象所有属性的函数
function observe(obj) {
  // 遍历对象的自有属性
  Object.keys(obj).forEach((prop) => {
    let value = obj[prop]; // 存储原始值
    Object.defineProperty(obj, prop, {
      enumerable: true,
      configurable: true,
      get() {
        console.log(`读取 ${prop} 属性:${value}`);
        return value;
      },
      set(newValue) {
        console.log(`给 ${prop} 赋值:${newValue}`);
        value = newValue;
      },
    });
  });
}

// 测试
const person = { name: "张三", gender: "男" };
observe(person);

person.name = "李四"; // 输出:给 name 赋值:李四
console.log(person.gender); // 输出:读取 gender 属性:男 → 男

二、 ES6 时代的巅峰:Proxy

Proxy 是ES6引入的一个新对象,用于创建一个对象的代理,从而拦截并自定义这个对象的基本操作(比如属性读取、赋值、删除、遍历等)。它是 Vue 3 实现高效响应式的基石。

1. 基本语法

  • 语法:const proxy = new Proxy(target, handler);

    • target:要代理的目标对象(可以是普通对象、数组、函数,甚至是另一个 Proxy)。

    • handler:一个配置对象,包含多个陷阱函数(traps),每个陷阱函数对应一种对目标对象的操作(比如读取属性对应get陷阱,赋值对应set陷阱)

    • proxy:返回的代理对象,后续操作都通过这个代理对象进行,而非直接操作原对象。

1. 常见陷阱函数 (Traps)

Proxy 的强大在于它能拦截多种底层操作。

Trap 触发时机 示例
get(target, prop, receiver) 读取属性时 obj.foo
set(target, prop, value, receiver) 设置属性时 obj.foo = 'bar'
has(target, prop) 使用in 操作符时 'foo' in obj
deleteProperty(target, prop) 删除属性时 delete obj.foo
ownKeys(target) 获取自身属性名时 Object.keys(obj)
apply(target, thisArg, args) 调用函数时(仅当 target 是函数) fn()
construct(target, args) 使用new操作符时 new Obejct()

2. 使用示例

    // 1. 定义原始用户对象
    const user = {
      name: '张三',
      age: 20,
    };

    // 2. 创建 Proxy 代理对象
    const userProxy = new Proxy(user, {
      // 拦截属性读取操作(比如 userProxy.name)
      get(target, prop, receiver) {
        console.log(`读取属性${prop}`);
        // 核心逻辑:属性不存在时返回默认提示
        if (!Reflect.has(target, prop)) {
          return `属性${prop}不存在`;
        }
        return Reflect.get(target, prop, receiver); // 用 Reflect 保证 this 指向正确
      },

      // 拦截属性赋值操作(比如 userProxy.age = 25)
      set(target, prop, value, receiver) {
        console.log(`给属性${prop}赋值:${value}`);
        // 核心逻辑:属性合法性校验
        switch (prop) {
          case 'age':
            if (typeof value !== 'number' || value <= 0) {
              console.error(' 年龄必须是大于0的数字!');
              return false; // 返回 false 表示赋值失败
            }
            break;
          case 'name':
            if (typeof value !== 'string' || value.trim() === '') {
              console.error(' 姓名不能为空字符串!');
              return false;
            }
            break;
        }
        return Reflect.set(target, prop, value, receiver); // 合法则执行赋值,返回 true 表示成功
      },
    });

    // 3. 测试代理功能
    console.log('===== 测试属性读取 =====');
    console.log(userProxy.name); // 读取存在的属性
    console.log(userProxy.age); // 读取存在的属性
    console.log(userProxy.gender); // 读取不存在的属性

    console.log('\n===== 测试合法赋值 =====');
    userProxy.age = 25; // 合法的年龄赋值
    userProxy.name = '李四'; // 合法的姓名赋值
    console.log('赋值后 name:', userProxy.name);
    console.log('赋值后 age:', userProxy.age);

    console.log('\n===== 测试非法赋值 =====');
    userProxy.age = -5; // 非法的年龄(负数)
    userProxy.name = ''; // 非法的姓名(空字符串)
    console.log('非法赋值后 age:', userProxy.age); // 年龄仍为 25
   
// 打印结果:  
===== 测试属性读取 =====
 读取属性name
 张三
 读取属性age
 20
 读取属性gender
 属性gender不存在
===== 测试合法赋值 =====
 给属性age赋值:25
 给属性name赋值:李四
 读取属性name
 赋值后 name: 李四
 读取属性age
 赋值后 age: 25
 114 
===== 测试非法赋值 =====
 给属性age赋值:-5
 年龄必须是大于0的数字!
 给属性name赋值:
 姓名不能为空字符串!
 读取属性age
 非法赋值后 age: 25



三、 Reflect:Proxy 的最佳拍档

Reflect 是 ES6 引入的内置全局对象,不能通过 new 实例化(不是构造函数)。它的核心作用是把原本属于 Object 对象的底层操作(比如属性赋值、删除)提炼成独立的函数方法,同时能保证操作的 “正确性”—— 比如转发操作时保留正确的 this 指向。

1. 为什么一定要配合 Reflect?

核心原因:处理 this 指向问题。

当对象内部存在 getter 并依赖 this 时,如果直接使用 target[prop]this 将指向原始对象而非代理对象,导致后续的属性读取无法被 Proxy 拦截。

2. Reflect使用对比

const person = {
      _name: '张三',
      get name() {
        console.log('getter 被调用,this:', this === person ? 'person' : this);
        return this._name;
      },

      introduce() {
        console.log('this', this)
        return `我叫${this.name}`;
      },
    };

    // 错误代理
    const badProxy = new Proxy(person, {
      get(target, prop, receiver) {
        console.log(`拦截: ${prop}`);
        if (prop === 'introduce') {
          const original = target[prop]; // 错误:直接获取
          return function () {
            return original(); // this 指向 badProxy
          };
        }
        return target[prop];
      },
    });

    // 正确代理
    const goodProxy = new Proxy(person, {
      get(target, prop, receiver) {
        console.log(`拦截: ${prop}`);
        if (prop === 'introduce') {
          return function () {
            return Reflect.apply(target[prop], receiver, arguments); // 正确
          };
        }
        return Reflect.get(target, prop, receiver);
      },
    });

    console.log('=== 测试错误代理 ===');
    console.log(badProxy.introduce());

    console.log('\n=== 测试正确代理 ===');
    console.log(goodProxy.introduce()

3. 打印结果分析

  1. 首先执行console.log(badProxy.introduce())

    • 它会读取badProxy.introduce属性,触发badProxyget 陷阱,参数target = personprop = 'introduce'receiver = badProxy
  2. 接着进入badProxyget陷阱函数,此时返回的新函数被赋值给badProxy.introduce,然后执行这个新函数。

    console.log(`拦截: ${prop}`);  // 输出:拦截: introduce
    if (prop === 'introduce') {
      const original = target[prop]; // 拿到 person.introduce 函数
      return function () { // 返回一个新函数
        return original(); // 关键错误:裸调用 original
      };
    }
    
  3. 执行返回的新函数original()(即person.introduce()

    • original是裸调用(没有对象前缀),所以introduce方法里的this指向window(非严格模式);
    • 输出:this window
    • 执行this.namewindow.name,不会触发personnamegetter(因为this不是person/badProxy),所以window._name不存在,返回undefined
    • 最终返回我叫undefined,控制台输出:我叫

  4. 执行console.log(goodProxy.introduce())

    • 它会读取goodProxy.introduce属性,触发goodProxyget 陷阱,参数:
    • target = personprop = 'introduce'receiver = goodProxy
  5. 第一次触发get陷阱(拦截introduce),此时返回的新函数被赋值给goodProxy.introduce,然后执行这个新函数

    console.log(`拦截: ${prop}`); // 输出:拦截: introduce → 第一次拦截
    if (prop === 'introduce') {
      return function () { // 返回一个新函数
        return Reflect.apply(target[prop], receiver, arguments); // 正确绑定 this
      };
    }
    
  6. 执行返回的新函数,Reflect.apply(target[prop], receiver, arguments),其中

    • target[prop]=person.introduce 函数;
    • receiver=goodProxy(把introduce方法的this绑定到goodProxy);
    • 执行person.introduce方法,此时方法内的this = goodProxy
  7. 执行 introduce 方法内部代码

    console.log('this', this); // 输出:this Proxy(Object) { _name: '张三' }(即 goodProxy)
    return `我叫${this.name}`; // 关键:读取 this.name → goodProxy.name
    
  8. 第二次触发get陷阱(拦截name),因为this = goodProxy,所以this.name等价于goodProxy.name,需要读取goodProxy.name属性,再次触发goodProxyget 陷阱,参数:

    • target = personprop = 'name'receiver = goodProxy
    • 进入get陷进函数
console.log(`拦截: ${prop}`); // 输出:拦截: name → 第二次拦截
if (prop === 'introduce') { /* 不执行 */ }
return Reflect.get(target, prop, receiver); // 调用 Reflect.get 读取 person.name

9. 调用Reflect.get(target, prop, receiver),触发person.name的 getter,此时 getter 里的thisreceiver绑定为goodProxy

get name() {
  console.log('getter 被调用,this:', this === person ? 'person' : this); 
  // 输出:getter 被调用,this: Proxy(Object) { _name: '张三' }
  return this._name; // this = goodProxy → 读取 goodProxy._name
}

10. 返回this._name(不是name!),这时会第三次触发goodProxy的get陷阱(prop = '_name'

console.log(`拦截: ${prop}`); // 输出:拦截: _name
return Reflect.get(target, '_name', receiver); // 返回 person._name = '张三'

11. 最终返回结果 我叫张三

![](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/cb44628ee3904c759428efdadbba9e90~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Y-R546w5LiA5Y-q5aSn5ZGG55Oc:q75.awebp?rk3s=f64ab15b&x-expires=1770714393&x-signature=VN5mF0OKtlLwwfknHvfBPYIqpVE%3D)

四、 总结:Proxy 的降维打击

  1. 全方位拦截:不仅能拦截读写,还能拦截删除、函数调用、new 操作等。
  2. 性能优势:无需遍历属性,直接代理整个对象。
  3. 原生支持数组:完美解决 Vue 2 中数组监听的各种奇技淫巧(如重写数组原型方法)。
  4. 配合 Reflect:通过 receiver 参数完美转发 this 绑定,保证了响应式系统的严密性。
❌
❌