<?xml version="1.0" encoding="utf-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0"><channel><title>code前端网</title><link>https://www.codeqd.com/</link><description>学技术，来code前端开发网</description><item><title>Vue3 里的 ref 为什么强制要写 .value？能不能通过底层优化完全去掉这个语法糖</title><link>https://www.codeqd.com/post/20260621860.html</link><description>&lt;p&gt;要搞懂 Vue3 里 ref 的 .value 问题，得先从 Vue2 遗留的响应式痛点、Vue3 核心响应式系统的底层选择说起，再拆解一下官方试过哪些“去 .value”的方案，最后看看为什么最终保留了它，以及未来会不会有更彻底的解决思路。&lt;/p&gt;
&lt;h2&gt;首先得回忆：Vue2 的响应式是怎么“踩坑”的，才逼得 Vue3 重构响应式&lt;/h2&gt;
&lt;p&gt;很多刚学 Vue3 的人可能觉得 ref 只是“把基本类型包起来变响应式”的工具，其实它和 reactive 是双生子，都是为了填补 Vue2 Object.defineProperty 响应式系统的致命缺陷。&lt;/p&gt;
&lt;p&gt;Vue2 的响应式核心是用 Object.defineProperty 遍历对象的每个属性，给它们重写 getter 和 setter：读取的时候收集依赖（就是哪里用到了这个属性，记下来），修改的时候触发依赖更新（通知记下来的地方重新渲染或者执行计算），这个方案有几个硬伤：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;不能直接监听新增或删除的属性&lt;/strong&gt;，比如给 data 里的 user 对象突然加个 &lt;code&gt;user.avatar&lt;/code&gt;，Vue2 不知道，所以得用 &lt;code&gt;this.$set&lt;/code&gt; 或者 Vue.set。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不能监听数组的下标修改和长度赋值&lt;/strong&gt;，虽然 Vue2 重写了 push、pop、splice 这些常用数组方法，但直接写 &lt;code&gt;arr[0] = &#039;newVal&#039;&lt;/code&gt; 或者 &lt;code&gt;arr.length = 0&lt;/code&gt; 还是没用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;深层对象监听需要递归遍历到底&lt;/strong&gt;，data 里有个嵌套几百层的对象，初始化的时候 Vue2 就得一层一层地跑 Object.defineProperty，性能会有损耗，特别是大型项目刚打开页面的时候。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;基本类型没法直接做响应式绑定&lt;/strong&gt;，Vue2 里的基本类型是直接挂载在组件实例上的，其实是通过实例的属性间接用了 Object.defineProperty，但如果脱离了组件实例，比如在组合式 API 尝试的早期阶段（Vue2.7 引入了 Composition API，但底层还是 Object.defineProperty），或者在工具函数里单独定义的基本类型，没法直接响应式。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这些问题让尤雨溪和 Vue 团队必须找一个更现代、更强大的响应式方案，刚好 ES6 的 Proxy 和 Reflect 出现了——Proxy 可以拦截对象的&lt;strong&gt;所有操作&lt;/strong&gt;，包括新增、删除属性，修改数组下标，甚至可以拦截函数调用；Reflect 则是提供了和 Proxy 拦截器一一对应的原生方法，用来安全地执行默认操作，还能解决一些 this 指向的问题。&lt;/p&gt;
&lt;h2&gt;那有了 Proxy 这个“全能工具”，为什么还要单独搞一个 ref？直接全用 reactive 不好吗？&lt;/h2&gt;
&lt;p&gt;这就问到点子上了——Proxy 有个天生的限制：它&lt;strong&gt;只能代理对象类型&lt;/strong&gt;，不能代理字符串、数字、布尔值、Symbol、undefined、null 这些基本类型，比如你试一下 &lt;code&gt;new Proxy(123, {})&lt;/code&gt;，浏览器直接会报错，说第一个参数必须是对象。&lt;/p&gt;
&lt;p&gt;那怎么办呢？Vue 团队的思路很简单：既然基本类型不能直接代理，那我们就给它“套个壳”，变成一个普通对象，然后用 Proxy 代理这个壳不就行了？对，这个“壳”ref 内部创建的对象——它有一个私有的（其实不是完全私有，源码里用 &lt;code&gt;__v_isRef&lt;/code&gt; 标记了身份，还暴露了 &lt;code&gt;isRef&lt;/code&gt; 工具函数供外部判断）属性 &lt;code&gt;value&lt;/code&gt;，真正的基本类型值就存在这个 &lt;code&gt;value&lt;/code&gt; 里，然后给这个壳对象加 Proxy 拦截，或者更准确地说，源码里早期可能用过 Proxy，但后来发现对这种只有 &lt;code&gt;value&lt;/code&gt; 一个核心属性的简单对象，直接重写 getter 和 setter 性能更高，所以最终 ref 内部是用的“轻量版 Object.defineProperty”（或者说更直接的闭包？不对，得翻一下核心源码里的 &lt;code&gt;createRef&lt;/code&gt; 函数）。&lt;/p&gt;
&lt;p&gt;等下,说到这里可以插一句：Vue3 源码里的 &lt;code&gt;createRef&lt;/code&gt; 真的超级简单，核心逻辑大概是这样的（我简化了一下，去掉了 &lt;code&gt;shallowRef&lt;/code&gt;、&lt;code&gt;customRef&lt;/code&gt; 相关的判断，只保留最基础的 ref）：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;function createRef(rawValue, shallow = false) {
  // 先判断这个值是不是已经是 ref 了，如果是就直接返回，避免重复包装
  if (isRef(rawValue)) {
    return rawValue
  }
  // 如果是浅响应式，就不转换内部值；如果是深响应式（默认），就用 convert 函数转成 reactive 对象
  rawValue = shallow ? rawValue : convert(rawValue)
  // 创建 ref 壳对象
  const refImpl = {
    // 用 __v_isRef 标记身份，外部 isRef 就是判断这个属性
    __v_isRef: true,
    get value() {
      // 读取 value 的时候，收集依赖，和 reactive 的 track 逻辑一样
      track(refImpl, TrackOpTypes.GET, &amp;#39;value&amp;#39;)
      // 返回内部存的值
      return rawValue
    },
    set value(newVal) {
      // 如果是浅响应式，就直接用 newVal；如果是深响应式，就把 newVal 转成 reactive 再存
      newVal = shallow ? newVal : convert(newVal)
      // 只有新值和旧值不一样的时候才触发更新
      if (hasChanged(newVal, rawValue)) {
        rawValue = newVal
        // 触发依赖更新，和 reactive 的 trigger 逻辑一样
        trigger(refImpl, TriggerOpTypes.SET, &amp;#39;value&amp;#39;, newVal)
      }
    }
  }
  return refImpl
}
// convert 函数其实就是 reactive，不过源码里是用的 isObject 判断一下，是对象才转
function convert(val) {
  return isObject(val) ? reactive(val) : val
}&lt;/pre&gt;
&lt;p&gt;看！是不是超级清晰？壳对象的 &lt;code&gt;get value&lt;/code&gt; 负责收集依赖，&lt;code&gt;set value&lt;/code&gt; 负责对比值、存新值、触发更新，完美解决了基本类型的响应式问题——现在我们可以在任何地方定义一个 ref 了，不管是在 setup 里、setup 语法糖里，还是在独立的工具函数里。&lt;/p&gt;
&lt;p&gt;那为什么不能直接用 reactive({ value: 123 }) 代替 ref(123) 呢？其实完全可以！不信你试一下，&lt;code&gt;const count = reactive({ value: 0 })&lt;/code&gt; &lt;code&gt;count.value++&lt;/code&gt;，页面一样会更新，但有了 ref 这个函数，我们就不用每次都自己写 &lt;code&gt;{ value: ... }&lt;/code&gt; 了，它是一个更简洁的语法糖，而且还提供了 &lt;code&gt;isRef&lt;/code&gt;、&lt;code&gt;unref&lt;/code&gt;、&lt;code&gt;toRef&lt;/code&gt;、&lt;code&gt;toRefs&lt;/code&gt; 这些配套工具，方便处理 ref 和 reactive 之间的转换。&lt;/p&gt;
&lt;h2&gt;那为什么 Vue3 不能自动帮我们解包，在所有地方都不用写 .value？官方试过哪些方案？&lt;/h2&gt;
&lt;p&gt;其实官方一开始也觉得 &lt;code&gt;.value&lt;/code&gt; 有点麻烦，想完全去掉，还专门做过几个实验性的 API：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;早期的 $() 宏语法（后来被废弃了，换成了更成熟的方案）&lt;/strong&gt;，在 Vue3.2 之前的某个 beta 版本里，官方曾经提出过一个叫  的编译时宏，比如你写 &lt;code&gt;const count = $(ref(0))&lt;/code&gt;，然后编译器会自动把所有的 &lt;code&gt;count&lt;/code&gt; 替换成 &lt;code&gt;count.value&lt;/code&gt;，这样你在代码里就不用写 &lt;code&gt;.value&lt;/code&gt; 了，但这个方案有几个问题：第一，它是编译时的，不是运行时的，对工具链的要求比较高；第二，如果你把 &lt;code&gt;count&lt;/code&gt; 传给一个外部函数，外部函数里还是得用 &lt;code&gt;.value&lt;/code&gt;，因为编译器管不到外部；第三，它的语义有点不清晰，新手可能搞不懂  到底做了什么。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后来的 ref 语法糖（就是现在 setup 语法糖里常用的 &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; 配合 &lt;code&gt;let count = $ref(0)&lt;/code&gt;）&lt;/strong&gt;，这个方案比早期的  宏稍微好一点，但还是有很多问题：它需要在 &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; 里开启 &lt;code&gt;refTransform&lt;/code&gt; 配置项，虽然 Vue3.3 之前是默认开启的，但 Vue3.3 之后改成了默认关闭，因为它带来的问题比解决的多；它会导致代码的行为在开发环境和生产环境不一样吗？其实不会，但它会让代码的可维护性变差——比如你看到一个 &lt;code&gt;let count = $ref(0)&lt;/code&gt;，在代码里用的时候是 &lt;code&gt;count++&lt;/code&gt;，但传给父组件或者工具函数的时候突然就变成了 &lt;code&gt;count.value&lt;/code&gt;，新手很容易搞混；它和 TypeScript 的配合也有一些小问题，虽然官方一直在修复，但始终没有完全解决。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;还有一个是用 Proxy 去代理整个作用域（setup 函数的作用域），自动把基本类型的变量转换成 ref，读取的时候自动解包&lt;/strong&gt;，这个方案听起来很美好，但实现起来超级复杂，而且会带来巨大的性能损耗——因为 Proxy 代理作用域需要用 &lt;code&gt;with&lt;/code&gt; 语句，而 &lt;code&gt;with&lt;/code&gt; 语句在现代 JavaScript 里是不推荐使用的，它会导致作用域链变长，读取变量的速度变慢，而且还会破坏 TypeScript 的类型推断。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;为什么最终 Vue3 还是保留了 .value？它真的是“缺点”吗？&lt;/h2&gt;
&lt;p&gt;其实在 Vue3 正式发布之前，官方做了大量的社区调研和性能测试，最后发现 &lt;code&gt;.value&lt;/code&gt; 虽然看起来有点麻烦，但它带来的好处远远超过了它的缺点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;语义清晰，一目了然&lt;/strong&gt;，当你看到代码里有 &lt;code&gt;.value&lt;/code&gt; 的时候，你就知道这个变量是一个 ref，它是响应式的；当你看到代码里没有 &lt;code&gt;.value&lt;/code&gt; 的时候，你就知道它要么是一个普通变量，要么是一个 reactive 对象（或者 reactive 对象的属性，因为 Vue3 会自动解包 reactive 对象里的 ref 属性），这种清晰的语义对代码的可维护性非常重要，特别是大型项目，团队成员多，代码量大，清晰的语义能帮大家节省很多调试时间。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;性能优异，没有额外开销&lt;/strong&gt;，刚才我们看到了 &lt;code&gt;createRef&lt;/code&gt; 的核心源码，它只是重写了 &lt;code&gt;get value&lt;/code&gt; 和 &lt;code&gt;set value&lt;/code&gt;，没有用 Proxy 代理复杂的作用域，也没有做复杂的编译时转换，所以它的性能非常好，几乎和直接操作普通变量一样快。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;灵活性高，不会限制你的代码写法&lt;/strong&gt;，如果你真的不想在某个地方写 &lt;code&gt;.value&lt;/code&gt;，你可以用 &lt;code&gt;toRefs&lt;/code&gt; 把 reactive 对象里的所有属性都转换成 ref，然后用解构赋值的方式拿出来——不过这里要注意，解构出来的变量还是需要写 &lt;code&gt;.value&lt;/code&gt;，但 Vue3.3 之后引入了 &lt;code&gt;defineModel&lt;/code&gt; 等宏，还有 Pinia 里的 &lt;code&gt;storeToRefs&lt;/code&gt;，这些工具能帮你在很多场景下减少 &lt;code&gt;.value&lt;/code&gt; 的使用；如果你在模板里用 ref，Vue3 会自动解包，完全不用写 &lt;code&gt;.value&lt;/code&gt;，这已经覆盖了最常用的场景。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;和 TypeScript 配合完美&lt;/strong&gt;，ref 的类型定义超级简单，&lt;code&gt;Ref&amp;lt;T&amp;gt;&lt;/code&gt;，TypeScript 能完美推断出 &lt;code&gt;.value&lt;/code&gt; 的类型，不会出现类型混乱的问题。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;官方其实并没有放弃“去 .value”的尝试，Vue3.4 里就引入了一个新的实验性 API，叫 &lt;code&gt;defineProps&lt;/code&gt; 的解构自动保持响应式？不对，等一下，Vue3.5 好像又有新的东西了？哦对，最近尤雨溪在社交媒体上提到过一个叫 &lt;code&gt;Reactive Variable&lt;/code&gt; 的新提案，它是一个原生的 JavaScript 提案（不是 Vue 自己的），如果这个提案通过了，那么未来 Vue 可能会用原生的 Reactive Variable 来代替现在的 ref，到时候可能就真的不用写 &lt;code&gt;.value&lt;/code&gt; 了——不过这个提案现在还处于早期阶段，距离正式通过还有很长的路要走。&lt;/p&gt;
&lt;h2&gt;现在我们应该怎么看待 .value？&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;.value&lt;/code&gt; 就像是 Vue3 给我们的一个“小提示牌”，提醒我们这个变量是响应式的，要小心处理，虽然它看起来有点麻烦，但只要你习惯了，你会发现它其实是一个非常有用的工具——它能帮你理清代码的逻辑，减少调试时间，提高代码的可维护性。&lt;/p&gt;
&lt;p&gt;现在有很多工具能帮你减少 &lt;code&gt;.value&lt;/code&gt; 的使用，&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;模板自动解包&lt;/strong&gt;：这是最常用的，直接在模板里写 &lt;code&gt;{{ count }}&lt;/code&gt; 或者 &lt;code&gt;@click=&quot;count++&quot;&lt;/code&gt; 就行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pinia 的 storeToRefs&lt;/strong&gt;：把 Pinia store 里的状态、getters 转换成 ref，解构出来之后虽然还是要写 &lt;code&gt;.value&lt;/code&gt;，但至少不用每次都写 &lt;code&gt;store.count&lt;/code&gt; 了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VueUse 的 useVModel&lt;/strong&gt;：配合组件的 v-model 使用，能帮你自动处理双向绑定，减少 &lt;code&gt;.value&lt;/code&gt; 的使用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vue3.3 之后的 defineModel&lt;/strong&gt;：直接在 &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; 里定义双向绑定的 props，不用再写 &lt;code&gt;props.modelValue&lt;/code&gt; 和 &lt;code&gt;emit(&#039;update:modelValue&#039;)&lt;/code&gt; 了，虽然 defineModel 返回的还是一个 ref，需要写 &lt;code&gt;.value&lt;/code&gt;，但已经简化了很多代码。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;.value&lt;/code&gt; 不是 Vue3 的“缺点”，而是 Vue3 团队经过深思熟虑之后做出的一个“最优解”——它既解决了 Vue2 遗留的响应式痛点，又保证了代码的语义清晰、性能优异、灵活性高，如果你现在还不习惯写 &lt;code&gt;.value&lt;/code&gt;，没关系，慢慢来，多写几次就习惯了，等你习惯了之后，你会发现它其实是 Vue3 响应式系统里最不可或缺的一部分。&lt;/p&gt;</description><pubDate>Sat, 20 Jun 2026 20:35:35 +0800</pubDate></item><item><title>Vue3现在都用什么主流语法？Composition API比Options API强在哪？日常开发该怎么选？</title><link>https://www.codeqd.com/post/20260621859.html</link><description>&lt;p&gt;现在打开前端技术论坛或者看大厂的Vue项目开源代码,几乎看不到纯用老写法的Vue3了，但刚接触Vue3或者从Vue2转过来的人，还是容易懵：一会儿看到setup函数，一会儿是&lt;script setup&gt;，一会儿又冒出来defineProps、defineEmits这些陌生的API，到底Vue3现在支持几套主流写法？它们之间的区别是什么？自己做项目或者面试该重点学哪套？今天就把这些问题掰扯明白，从基础到实践场景都讲清楚。&lt;/p&gt;
&lt;h2&gt;Vue3现在的两套主流核心语法体系&lt;/h2&gt;
&lt;p&gt;首先要明确一点：Vue3没有完全抛弃Vue2的写法，反而做了完美兼容，但主流用的其实是“全新的Composition API体系”和“优化过的Options API体系”这两套，接下来分别说清楚它们的形态和适用情况。&lt;/p&gt;
&lt;h3&gt;优化过的Options API体系（也叫选项式API）&lt;/h3&gt;
&lt;p&gt;如果你是Vue2的老玩家,这套你会非常熟悉——代码还是按data、methods、computed、watch、mounted这些“选项”堆在组件实例里，Vue3只是做了一些小的性能优化和语法糖补充，比如允许data返回函数时用箭头函数（虽然之前Vue2也可以但this指向有坑），比如mounted等生命周期钩子可以写成数组来组合多个逻辑片段，比如新增了一些选项相关的API比如defineOptions（用来在Vue3文件里声明组件名称等配置项，因为纯Options不需要额外的组件配置导出，或者之前混合使用setup时有点麻烦）。&lt;/p&gt;
&lt;p&gt;举个简单的例子,用优化后的Options写一个计数器组件：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;counter&amp;quot;&amp;gt;
    &amp;lt;p&amp;gt;当前计数：{{ count }}&amp;lt;/p&amp;gt;
    &amp;lt;p&amp;gt;双倍计数：{{ doubleCount }}&amp;lt;/p&amp;gt;
    &amp;lt;button @click=&amp;quot;increment&amp;quot;&amp;gt;加1&amp;lt;/button&amp;gt;
    &amp;lt;button @click=&amp;quot;decrement&amp;quot;&amp;gt;减1&amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script&amp;gt;
export default {
  name: &amp;#39;CounterOptions&amp;#39;,
  data() {
    return {
      count: 0
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2
    }
  },
  methods: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    }
  },
  mounted() {
    console.log(&amp;#39;计数器初始化完成&amp;#39;)
  }
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;你看,和Vue2几乎一模一样，只是现在可以不用写Vue.component或者其他注册方式了（只要.vue文件就能直接被识别），如果只是做这种小组件，或者团队里还有很多Vue2的技术债务，这套写起来上手快，几乎零成本。&lt;/p&gt;
&lt;h3&gt;全新的Composition API体系（组合式API）&lt;/h3&gt;
&lt;p&gt;这套才是Vue3官方主推的,也是现在前端社区90%以上新项目用的，它的核心思想是&lt;strong&gt;“按功能逻辑组织代码，而不是按选项类型”&lt;/strong&gt;——你可以把同一个功能用到的所有响应式数据、计算属性、方法、生命周期钩子放在一起，不用像Options那样东找西找data里的变量、methods里的方法。&lt;/p&gt;
&lt;p&gt;Composition API有两种写法，一种是“setup()函数写法”，另一种是现在绝对主流的“&lt;script setup&gt;语法糖写法”，先分别给例子。&lt;/p&gt;
&lt;h4&gt;setup()函数写法（基础形态，不用语法糖）&lt;/h4&gt;
&lt;p&gt;setup()是Vue3组件的一个新选项，它在组件创建&lt;strong&gt;之前&lt;/strong&gt;执行，所以this不是组件实例，这点一定要注意（刚开始从Vue2转过来的人最容易犯这个错），响应式数据、方法、生命周期钩子都要在setup里定义好，最后通过return返回给template用。&lt;/p&gt;
&lt;p&gt;还是那个计数器,用setup()函数写：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;counter&amp;quot;&amp;gt;
    &amp;lt;p&amp;gt;当前计数：{{ count }}&amp;lt;/p&amp;gt;
    &amp;lt;p&amp;gt;双倍计数：{{ doubleCount }}&amp;lt;/p&amp;gt;
    &amp;lt;button @click=&amp;quot;increment&amp;quot;&amp;gt;加1&amp;lt;/button&amp;gt;
    &amp;lt;button @click=&amp;quot;decrement&amp;quot;&amp;gt;减1&amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script&amp;gt;
import { ref, computed, onMounted } from &amp;#39;vue&amp;#39;
export default {
  name: &amp;#39;CounterSetup&amp;#39;,
  setup() {
    // 定义响应式数据：ref用于基本类型，reactive用于对象（后面讲区别）
    const count = ref(0)
    // 定义计算属性
    const doubleCount = computed(() =&amp;gt; count.value * 2)
    // 定义方法
    const increment = () =&amp;gt; count.value++
    const decrement = () =&amp;gt; count.value--
    // 定义生命周期钩子
    onMounted(() =&amp;gt; console.log(&amp;#39;计数器初始化完成&amp;#39;))
    // 给template返回需要的东西
    return {
      count,
      doubleCount,
      increment,
      decrement
    }
  }
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;这里出现了两个新的核心响应式API：ref和reactive，简单说一下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ref：用来给&lt;strong&gt;基本数据类型&lt;/strong&gt;（数字、字符串、布尔值、null、undefined）创建响应式引用，返回的是一个Ref对象，在setup里修改或者访问它的值，必须加.value，在template里会自动解包，不用加。&lt;/li&gt;
&lt;li&gt;reactive：用来给&lt;strong&gt;对象或数组&lt;/strong&gt;创建响应式代理，返回的是一个Proxy对象，不管在setup还是template里，修改或者访问都是直接用属性名，不用加.value。
为什么要分这两个？因为JavaScript的基本类型是按值传递的，直接包装没法实现响应式，所以Vue3给它们套了一层Ref对象的壳；而对象和数组是按引用传递的，Proxy可以直接拦截它们的属性访问和修改。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;&lt;script setup&gt;语法糖写法（主流形态，官方推荐）&lt;/h4&gt;
&lt;p&gt;刚才的setup()函数写法已经能解决逻辑组织的问题了，但每次都要return一堆东西很麻烦，尤其是组件功能多的时候，return语句可能比代码本身还长，而且ref.value在setup里用起来也有点啰嗦，所以Vue3.2版本正式推出了&lt;strong&gt;&lt;script setup&gt;语法糖&lt;/strong&gt;，现在它已经是Vue3的默认推荐写法了，几乎覆盖了所有场景。&lt;/p&gt;
&lt;p&gt;还是那个计数器,用&lt;script setup&gt;写：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;counter&amp;quot;&amp;gt;
    &amp;lt;p&amp;gt;当前计数：{{ count }}&amp;lt;/p&amp;gt;
    &amp;lt;p&amp;gt;双倍计数：{{ doubleCount }}&amp;lt;/p&amp;gt;
    &amp;lt;button @click=&amp;quot;increment&amp;quot;&amp;gt;加1&amp;lt;/button&amp;gt;
    &amp;lt;button @click=&amp;quot;decrement&amp;quot;&amp;gt;减1&amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, computed, onMounted } from &amp;#39;vue&amp;#39;
// 不需要export default组件配置
// 不需要setup()函数
// 定义响应式数据
const count = ref(0)
// 定义计算属性
const doubleCount = computed(() =&amp;gt; count.value * 2)
// 定义方法
const increment = () =&amp;gt; count.value++
const decrement = () =&amp;gt; count.value--
// 定义生命周期钩子
onMounted(() =&amp;gt; console.log(&amp;#39;计数器初始化完成&amp;#39;))
// 不需要return！直接在template用所有顶层定义的变量、方法
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;你看,代码比setup()函数写法少了一半，而且更清爽了，除了不用return，&lt;script setup&gt;还有几个非常好用的专属API：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;defineProps：用来接收父组件传过来的props，和Options里的props选项功能一样，但写法更简单，不需要return。&lt;/li&gt;
&lt;li&gt;defineEmits：用来声明子组件可以触发的事件，和Options里的emits选项功能一样。&lt;/li&gt;
&lt;li&gt;defineExpose：用来暴露子组件的某些变量、方法给父组件，因为&lt;script setup&gt;默认是封闭的，父组件用ref获取子组件实例时，默认拿不到任何东西，必须用defineExpose显式暴露。&lt;/li&gt;
&lt;li&gt;withDefaults：用来给defineProps设置默认值，比Options里的props.default写法更直观。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些专属API不用显式import,Vue3的编译器会自动处理，这点很贴心。&lt;/p&gt;
&lt;h2&gt;Composition API比Options API强在哪？为什么官方要主推？&lt;/h2&gt;
&lt;p&gt;刚才只是举了个简单的计数器例子,两种写法看起来区别不大，甚至Options还更“直观”（因为老玩家习惯了），但当组件功能变多、逻辑变复杂的时候，Composition API的优势就完全体现出来了，主要有以下几个核心优势。&lt;/p&gt;
&lt;h3&gt;逻辑复用性极强，告别mixins的噩梦&lt;/h3&gt;
&lt;p&gt;在Vue2时代,逻辑复用的主要方式是mixins，但mixins有几个致命的问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;命名冲突：如果两个mixins里有相同的data、methods、computed属性，后面引入的会覆盖前面的，调试的时候根本不知道哪来的变量。&lt;/li&gt;
&lt;li&gt;来源不透明：在组件里用了几个mixins，你根本分不清某个变量或方法是来自哪个mixin，必须一个个去翻mixins的代码。&lt;/li&gt;
&lt;li&gt;耦合度高：mixins通常依赖于组件的特定结构，如果组件改了，mixins可能也要跟着改，反之亦然。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而Composition API的逻辑复用方式是&lt;strong&gt;“自定义Hook”&lt;/strong&gt;，也就是把某个功能的所有Composition API封装成一个函数，函数名通常以use开头（这是社区约定的规范），然后在组件里直接调用这个函数就行。&lt;/p&gt;
&lt;p&gt;举个例子,刚才的计数器功能，如果我们想把它封装成自定义Hook，以后在任何组件里都能用：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// useCounter.js
import { ref, computed, onMounted } from &amp;#39;vue&amp;#39;
export function useCounter(initialValue = 0, step = 1) {
  const count = ref(initialValue)
  const doubleCount = computed(() =&amp;gt; count.value * 2)
  const increment = () =&amp;gt; count.value += step
  const decrement = () =&amp;gt; count.value -= step
  const reset = () =&amp;gt; count.value = initialValue
  onMounted(() =&amp;gt; console.log(`useCounter初始化完成，初始值：${initialValue}，步长：${step}`))
  // 直接返回需要给组件用的东西
  return {
    count,
    doubleCount,
    increment,
    decrement,
    reset
  }
}&lt;/pre&gt;
&lt;p&gt;然后在组件里用：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;counter-container&amp;quot;&amp;gt;
    &amp;lt;div class=&amp;quot;counter1&amp;quot;&amp;gt;
      &amp;lt;h3&amp;gt;基础计数器（初始0，步长1）&amp;lt;/h3&amp;gt;
      &amp;lt;p&amp;gt;当前计数：{{ count1 }}&amp;lt;/p&amp;gt;
      &amp;lt;p&amp;gt;双倍计数：{{ doubleCount1 }}&amp;lt;/p&amp;gt;
      &amp;lt;button @click=&amp;quot;increment1&amp;quot;&amp;gt;加1&amp;lt;/button&amp;gt;
      &amp;lt;button @click=&amp;quot;decrement1&amp;quot;&amp;gt;减1&amp;lt;/button&amp;gt;
      &amp;lt;button @click=&amp;quot;reset1&amp;quot;&amp;gt;重置&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div class=&amp;quot;counter2&amp;quot;&amp;gt;
      &amp;lt;h3&amp;gt;进阶计数器（初始10，步长2）&amp;lt;/h3&amp;gt;
      &amp;lt;p&amp;gt;当前计数：{{ count2 }}&amp;lt;/p&amp;gt;
      &amp;lt;p&amp;gt;双倍计数：{{ doubleCount2 }}&amp;lt;/p&amp;gt;
      &amp;lt;button @click=&amp;quot;increment2&amp;quot;&amp;gt;加2&amp;lt;/button&amp;gt;
      &amp;lt;button @click=&amp;quot;decrement2&amp;quot;&amp;gt;减2&amp;lt;/button&amp;gt;
      &amp;lt;button @click=&amp;quot;reset2&amp;quot;&amp;gt;重置&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { useCounter } from &amp;#39;./useCounter.js&amp;#39;
// 调用第一个自定义Hook，重命名变量避免冲突
const { 
  count: count1, 
  doubleCount: doubleCount1, 
  increment: increment1, 
  decrement: decrement1, 
  reset: reset1 
} = useCounter()
// 调用第二个自定义Hook
const { 
  count: count2, 
  doubleCount: doubleCount2, 
  increment: increment2, 
  decrement: decrement2, 
  reset: reset2 
} = useCounter(10, 2)
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;你看,自定义Hook的优势太明显了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;命名冲突问题解决了：可以直接用对象解构赋值重命名，一目了然。&lt;/li&gt;
&lt;li&gt;来源透明：某个变量来自哪个Hook，看调用代码就知道。&lt;/li&gt;
&lt;li&gt;耦合度低：自定义Hook是独立的函数，只依赖自己内部的逻辑和传入的参数，和组件的结构完全无关，甚至可以在多个Vue项目里复用。&lt;/li&gt;
&lt;li&gt;类型支持好：后面讲TypeScript的时候会提到，自定义Hook的类型推断非常自然。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;按功能逻辑组织代码，大型组件不再难以维护&lt;/h3&gt;
&lt;p&gt;刚才说的是复用,那如果是单组件里的复杂逻辑呢？比如一个电商的商品详情页组件，可能有：商品信息展示逻辑、库存查询逻辑、收藏/取消收藏逻辑、加入购物车逻辑、评论加载逻辑、分享逻辑……如果用Options API写，这些逻辑的变量会混在data里，方法会混在methods里，computed会混在一起，watch也会混在一起，mounted里可能还要写一堆初始化的代码，组件写了几百行之后，你想改“收藏逻辑”，可能要在data里找isCollected，在methods里找toggleCollect，在computed里找collectIcon，在mounted里找初始化收藏状态的代码，在watch里找收藏状态变化时的逻辑，东找西找太浪费时间了，而且很容易漏改。&lt;/p&gt;
&lt;p&gt;而用Composition API写，你可以把“收藏逻辑”的所有代码放在一起，“库存查询逻辑”的所有代码放在一起，“评论加载逻辑”的所有代码放在一起，甚至可以把每个大逻辑再封装成小的自定义Hook，整个组件的结构非常清晰，维护成本极低。&lt;/p&gt;
&lt;p&gt;比如商品详情页的收藏逻辑,用Composition API可以这样组织：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
// 引入必要的API和自定义Hook
import { ref, onMounted } from &amp;#39;vue&amp;#39;
import { useProductInfo } from &amp;#39;./useProductInfo.js&amp;#39;
import { useCollect } from &amp;#39;./useCollect.js&amp;#39;
import { useInventory } from &amp;#39;./useInventory.js&amp;#39;
import { useCart } from &amp;#39;./useCart.js&amp;#39;
// 获取商品ID（假设是从路由参数里拿的）
const route = useRoute()
const productId = ref(route.params.id)
// 商品信息展示逻辑（单独封装的自定义Hook）
const { productInfo, loadingProduct } = useProductInfo(productId)
// 收藏/取消收藏逻辑（单独封装的自定义Hook）
const { isCollected, collectIcon, toggleCollect, loadingCollect } = useCollect(productId)
// 库存查询逻辑（单独封装的自定义Hook）
const { stock, outOfStock, loadingStock } = useInventory(productId)
// 加入购物车逻辑（单独封装的自定义Hook）
const { addToCart, selectedSku, loadingCart } = useCart(productId)
// 剩下的评论、分享逻辑同理
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;你看,整个组件的逻辑非常清晰，每个功能模块都是独立的，想改哪个就改哪个，甚至可以单独测试每个自定义Hook，不用依赖整个组件。&lt;/p&gt;
&lt;h3&gt;对TypeScript的支持非常友好&lt;/h3&gt;
&lt;p&gt;现在前端开发越来越重视TypeScript了,Vue3本身就是用TypeScript重写的，所以Composition API对TypeScript的支持是原生的、非常友好的，而Options API对TypeScript的支持就比较差，需要用Vue.extend或者@Component装饰器，还要写很多繁琐的类型声明，类型推断也不够准确。&lt;/p&gt;
&lt;p&gt;比如用&lt;script setup&gt;写一个带TypeScript的接收props和触发事件的组件：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;user-card&amp;quot;&amp;gt;
    &amp;lt;h3&amp;gt;{{ user.name }}&amp;lt;/h3&amp;gt;
    &amp;lt;p&amp;gt;年龄：{{ user.age }}&amp;lt;/p&amp;gt;
    &amp;lt;p&amp;gt;职业：{{ user.job }}&amp;lt;/p&amp;gt;
    &amp;lt;button @click=&amp;quot;onEdit&amp;quot; :disabled=&amp;quot;disabled&amp;quot;&amp;gt;编辑&amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup lang=&amp;quot;ts&amp;quot;&amp;gt;
// 定义User的类型接口
interface User {
  id: number
  name: string
  age: number
  job?: string // 可选属性
}
// 定义Props的类型接口
interface Props {
  user: User
  disabled?: boolean // 可选属性，默认false
}
// 定义Emits的类型（可以用对象或者数组，对象更规范，有类型检查）
interface Emits {
  (e: &amp;#39;edit&amp;#39;, userId: number): void
  (e: &amp;#39;delete&amp;#39;, userId: number): void
}
// 用defineProps接收props，带类型声明
const props = withDefaults(defineProps&amp;lt;Props&amp;gt;(), {
  disabled: false
})
// 用defineEmits声明事件，带类型检查
const emit = defineEmits&amp;lt;Emits&amp;gt;()
// 定义编辑方法，类型检查非常严格
const onEdit = () =&amp;gt; {
  emit(&amp;#39;edit&amp;#39;, props.user.id)
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;你看,整个类型声明非常自然，而且有严格的类型检查：如果props.user没有id属性，会报错；如果emit触发了一个没有声明的事件，会报错；如果emit触发edit事件时没有传userId，或者传的不是number类型，也会报错，这在大型项目里非常重要，可以大大减少bug。&lt;/p&gt;
&lt;h2&gt;日常开发该怎么选？给你几个明确的判断标准&lt;/h2&gt;
&lt;p&gt;现在知道了两套语法的区别和优势,那日常开发到底该选哪套呢？不用纠结，给你几个明确的判断标准，直接套用就行。&lt;/p&gt;
&lt;h3&gt;新项目：直接用&lt;script setup&gt;语法糖&lt;/h3&gt;
&lt;p&gt;不管你是刚学Vue3还是从Vue2转过来的,只要是做新项目，不管项目大小，直接用&lt;script setup&gt;语法糖就对了，这是官方的默认推荐，也是社区的主流，而且可以享受到Composition API的所有优势，逻辑复用、代码组织、TypeScript支持都很好。&lt;/p&gt;
&lt;h3&gt;Vue2老项目升级到Vue3：优先保留Options API，新功能用&lt;script setup&gt;&lt;/h3&gt;
&lt;p&gt;如果是Vue2老项目升级到Vue3,不用急着把所有代码都改成Composition API，Vue3对Options API的兼容非常好，甚至可以和Composition API混合使用（在同一个组件里也可以），所以可以分阶段升级：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一阶段：先把项目升级到Vue3，保留所有Options API的代码，确保项目能正常运行。&lt;/li&gt;
&lt;li&gt;第二阶段：在开发新功能或者重构旧的复杂组件时，用&lt;script setup&gt;语法糖和Composition API。&lt;/li&gt;
&lt;li&gt;第三阶段：有时间的话，再慢慢把一些复用率高的旧功能封装成自定义Hook。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;团队里有大量Vue2新手：可以先用Options API入门，再过渡到&lt;script setup&gt;&lt;/h3&gt;
&lt;p&gt;如果团队里有很多刚学Vue的新手,或者有很多习惯了Vue2的老员工，一时半会儿接受不了Composition API，可以先用Options API入门，让大家熟悉Vue3的基础功能（比如响应式原理的变化、组件通信的变化、生命周期钩子的变化等），等大家上手了，再慢慢过渡到&lt;script setup&gt;语法糖，毕竟现在&lt;script setup&gt;已经是趋势了，迟早要学的。&lt;/p&gt;
&lt;h3&gt;组件功能非常简单（比如纯展示组件）：两种都可以，看个人习惯&lt;/h3&gt;
&lt;p&gt;如果是纯展示组件,比如只接收props、不修改数据、没有复杂逻辑的组件，两种写法都可以，看团队的规范或者个人习惯就行，Options API写起来可能更直观一点，&lt;script setup&gt;写起来更清爽一点。&lt;/p&gt;
&lt;h2&gt;最后补充几个容易踩的坑&lt;/h2&gt;
&lt;p&gt;最后补充几个刚用Composition API或者&lt;script setup&gt;容易踩的坑，避免大家走弯路。&lt;/p&gt;
&lt;h3&gt;ref.value在template里会自动解包，但在reactive对象里不会&lt;/h3&gt;

&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { ref, reactive } from &amp;#39;vue&amp;#39;
const count = ref(0)
const obj = reactive({
  count: count // 这里的count是ref对象，不是值
})
&amp;lt;/script&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;!-- 这里会自动解包，显示0 --&amp;gt;
    &amp;lt;p&amp;gt;count：{{ count }}&amp;lt;/p&amp;gt;
    &amp;lt;!-- 这里不会自动解包，显示[object Object]，必须加.value？不对，reactive对象里的ref会自动解包吗？等一下，确认一下：对，Vue3.2之后，reactive对象里的ref会自动解包，不用加.value，刚才差点说错了 --&amp;gt;
    &amp;lt;p&amp;gt;obj.count：{{ obj.count }}&amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;p&gt;不过要注意,只有reactive对象里的&lt;strong&gt;顶层ref属性&lt;/strong&gt;才会自动解包，如果是嵌套对象里的ref，就不会自动解包了，&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { ref, reactive } from &amp;#39;vue&amp;#39;
const innerCount = ref(0)
const obj = reactive({
  nested: {
    innerCount: innerCount // 嵌套对象里的ref不会自动解包
  }
})
&amp;lt;/script&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;!-- 这里显示[object Object]，必须加obj.nested.innerCount.value --&amp;gt;
    &amp;lt;p&amp;gt;obj.nested.innerCount：{{ obj.nested.innerCount }}&amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;p&gt;所以嵌套对象里的属性,尽量用reactive或者直接用普通对象，不要用ref。&lt;/p&gt;
&lt;h3&gt;&lt;script setup&gt;默认是封闭的，父组件拿不到子组件的实例&lt;/h3&gt;

&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- Child.vue --&amp;gt;
&amp;lt;script setup&amp;gt;
const message = &amp;#39;Hello Parent&amp;#39;
const sayHello = () =&amp;gt; console.log(&amp;#39;Hello&amp;#39;)
&amp;lt;/script&amp;gt;
&amp;lt;!-- Parent.vue --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;Child ref=&amp;quot;childRef&amp;quot; /&amp;gt;
  &amp;lt;button @click=&amp;quot;callChild&amp;quot;&amp;gt;调用子组件方法&amp;lt;/button&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, onMounted } from &amp;#39;vue&amp;#39;
import Child from &amp;#39;./Child.vue&amp;#39;
const childRef = ref(null)
onMounted(() =&amp;gt; {
  // 这里会报错，因为childRef.value里没有message和sayHello
  console.log(childRef.value.message)
  childRef.value.sayHello()
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;如果要让父组件拿到子组件的某些变量或方法,必须用defineExpose显式暴露：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- Child.vue --&amp;gt;
&amp;lt;script setup&amp;gt;
import { defineExpose } from &amp;#39;vue&amp;#39;
const message = &amp;#39;Hello Parent&amp;#39;
const sayHello = () =&amp;gt; console.log(&amp;#39;Hello&amp;#39;)
// 显式暴露
defineExpose({
  message,
  sayHello
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;现在父组件就能正常调用了。&lt;/p&gt;
&lt;h3&gt;自定义Hook的函数名一定要以use开头&lt;/h3&gt;
&lt;p&gt;这是社区约定的规范,不是强制要求，但遵守的话有几个好处：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;让其他开发者一眼就能看出这是一个自定义Hook,不是普通的函数。&lt;/li&gt;
&lt;li&gt;Vue3的Eslint插件可以检测到以use开头的函数,然后自动检查它里面的Composition API使用是否规范（比如不能在条件判断、循环、嵌套函数里使用ref、computed、onMounted等API，这点很重要，否则响应式会失效）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;今天我们讲了Vue3的两套主流核心语法：优化过的Options API和全新的Composition API（包括setup()函数写法和&lt;script setup&gt;语法糖写法），然后讲了Composition API的三个核心优势：逻辑复用性强、按功能组织代码、TypeScript支持好，接着给了日常开发选择语法的四个明确判断标准，最后补充了三个容易踩的坑。&lt;/p&gt;
&lt;p&gt;Vue3现在的主流写法就是&lt;script setup&gt;语法糖，不管你是新手还是老手，不管你是做新项目还是升级老项目，都应该尽快掌握这套写法，它能大大提高你的开发效率和代码质量。&lt;/p&gt;</description><pubDate>Sat, 20 Jun 2026 14:36:19 +0800</pubDate></item><item><title>Vue3为什么弃用deep、::v-deep这些旧写法？现在该用什么？踩坑全攻略</title><link>https://www.codeqd.com/post/20260621858.html</link><description>&lt;p&gt;Vue3弃用&lt;code&gt;/deep/&lt;/code&gt;、&lt;code&gt;:v-deep&lt;/code&gt;和&lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt;这三种旧有样式穿透语法，核心原因是它们属于&lt;strong&gt;浏览器CSS解析引擎的非标准选择器扩展&lt;/strong&gt;，不仅存在跨浏览器渲染的潜在风险，还会和未来的CSS标准语法产生冲突；目前Vue3官方推荐的新写法是&lt;code&gt;deep()&lt;/code&gt;伪类，另外还有&lt;code&gt;slotted()&lt;/code&gt;伪类用于插槽样式穿透、&lt;code&gt;global()&lt;/code&gt;伪类用于全局组件样式注入，这三种都是基于Vue3的SFC（单文件组件）编译实现的标准兼容写法，接下来我会从旧写法的具体问题、新写法的原理和用法、90%开发者会踩的3个大坑、结合项目实战案例这几个部分展开，把样式穿透这件事讲得明明白白，新手看一遍就能用,老手升级项目也能快速适配。&lt;/p&gt;
&lt;h2&gt;旧写法为什么不行了？3个关键隐患戳破弃用逻辑&lt;/h2&gt;
&lt;p&gt;很多刚从Vue2转过来的人都会抱怨：“好好的/deep/为啥不让用？习惯都改不过来”，但如果站在项目维护和浏览器生态发展的角度看,弃用真的是非常明智的选择。&lt;/p&gt;
&lt;p&gt;首先说&lt;strong&gt;跨浏览器渲染的兼容性隐患&lt;/strong&gt;。&lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt;是WebKit（Chrome、Safari内核）早期支持的深度选择器，Firefox从来就不认，Edge在换Chromium内核前也完全不兼容；&lt;code&gt;/deep/&lt;/code&gt;和&lt;code&gt;:v-deep&lt;/code&gt;虽然是Vue框架在编译时做的一层“翻译”——把这些非标准写法转换成Vue内部的CSS变量标记或者class前缀，但前提是SFC文件里的样式必须加&lt;code&gt;scoped&lt;/code&gt;属性，一旦漏加或者在CSS预处理器（比如Less、Sass）里用得太随意，就会出现浏览器直接解析这些非标准字符的情况，轻则样式失效，重则页面出现莫名其妙的布局错乱，举个最简单的例子：如果你在Vue2项目里用Less写&lt;code&gt;/deep/.el-button&lt;/code&gt;，有时候编译出来的CSS会是&lt;code&gt;.data-v-xxxxxx /deep/ .el-button&lt;/code&gt;,这串代码放到Firefox里直接无效。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;和未来CSS标准的冲突&lt;/strong&gt;，CSS Working Group（也就是制定CSS标准的官方组织）其实早就计划过“穿透子组件作用域样式”的相关功能，虽然到现在还没正式落地，但他们已经明确表示过不会采用&lt;code&gt;/deep/&lt;/code&gt;、&lt;code&gt;:v-deep&lt;/code&gt;这类非标准命名，更重要的是，现在CSS里已经有一些类似功能的伪元素和伪类了，比如Shadow DOM的&lt;code&gt;:slotted()&lt;/code&gt;，不过这个原生的Shadow DOM &lt;code&gt;:slotted()&lt;/code&gt;和Vue3的&lt;code&gt;slotted()&lt;/code&gt;用法有点不一样，后面会专门讲，如果Vue继续沿用旧写法，等未来原生CSS的深度选择器正式发布，开发者就会面临“框架旧写法和原生新写法冲突，整个项目都要重构样式”的尴尬局面。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;预处理器嵌套解析的混乱问题&lt;/strong&gt;，用过Vue2+Less/Sass的人应该都遇到过这种情况：有时候在嵌套选择器里写&lt;code&gt;:v-deep&lt;/code&gt;会生效，有时候又会失效，甚至有时候还会导致整个嵌套块的样式都错了，这是因为不同版本的预处理器对非标准选择器的解析优先级不一样，比如Less在3.x版本里会把&lt;code&gt;:v-deep&lt;/code&gt;当成普通的CSS伪类处理，而在4.x版本里又会做一些特殊的处理，但这些处理和Vue的SFC编译器的处理逻辑经常打架，Vue官方在GitHub的Vue3仓库issues里也明确提到过：“旧写法在预处理器里的解析逻辑完全不可控，我们无法保证每个项目、每个预处理器版本都能正常工作”。&lt;/p&gt;
&lt;h2&gt;Vue3新样式穿透语法全解析：3种写法各有用途&lt;/h2&gt;
&lt;p&gt;Vue3官方现在一共推荐了3种和样式穿透/全局样式相关的伪类，分别是&lt;code&gt;deep()&lt;/code&gt;、&lt;code&gt;slotted()&lt;/code&gt;和&lt;code&gt;global()&lt;/code&gt;，这三种都是&lt;strong&gt;纯编译时的转换&lt;/strong&gt;，不会在最终的生产环境CSS里留下任何非标准字符，也不会和原生CSS冲突，完全兼容所有主流浏览器（包括IE11，不过IE11已经基本退出历史舞台了，不用太纠结）。&lt;/p&gt;
&lt;h3&gt;最常用的场景：&lt;code&gt;deep()&lt;/code&gt; 穿透子组件的scoped样式&lt;/h3&gt;
&lt;p&gt;这个是替代旧&lt;code&gt;/deep/&lt;/code&gt;、&lt;code&gt;:v-deep&lt;/code&gt;、&lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt;的首选写法，核心用途就是“在父组件的scoped样式块里，修改子组件内部的作用域样式”。&lt;/p&gt;
&lt;p&gt;先看一下原理：当你在父组件的&lt;code&gt;&amp;lt;style scoped&amp;gt;&lt;/code&gt;里写&lt;code&gt;deep(.子组件内部的类名)&lt;/code&gt;时，Vue3的SFC编译器不会给&lt;code&gt;deep()&lt;/code&gt;括号里的选择器加父组件的作用域标记（也就是&lt;code&gt;.data-v-xxxxxx&lt;/code&gt;这种class前缀），只会给&lt;code&gt;deep()&lt;/code&gt;前面的父组件选择器加，举个具体的例子：
假设你有一个父组件叫&lt;code&gt;Parent.vue&lt;/code&gt;是这样的：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;parent-box&amp;quot;&amp;gt;
    &amp;lt;ChildComponent /&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;style scoped&amp;gt;
/* 父组件自己的样式，会加作用域标记：.parent-box.data-v-xxxxxx */
.parent-box {
  padding: 20px;
  border: 1px solid #eee;
}
/* 使用:deep()的样式，只给.parent-box加标记：.parent-box.data-v-xxxxxx .child-btn */
.parent-box :deep(.child-btn) {
  background-color: #409eff;
  color: #fff;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;然后有一个子组件叫&lt;code&gt;ChildComponent.vue&lt;/code&gt;是这样的：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;button class=&amp;quot;child-btn&amp;quot;&amp;gt;我是子组件的按钮&amp;lt;/button&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;style scoped&amp;gt;
/* 子组件自己的样式，会加作用域标记：.child-btn.data-v-yyyyyy */
.child-btn {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  background-color: #f5f5f5;
  color: #333;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;那编译后的最终生产环境CSS大概是这样的：&lt;/p&gt;
&lt;pre class=&quot;brush:css;toolbar:false&quot;&gt;/* 父组件自己的样式 */
.parent-box.data-v-xxxxxx {
  padding: 20px;
  border: 1px solid #eee;
}
/* 使用:deep()穿透后的样式 */
.parent-box.data-v-xxxxxx .child-btn {
  background-color: #409eff;
  color: #fff;
}
/* 子组件自己的样式 */
.child-btn.data-v-yyyyyy {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  background-color: #f5f5f5;
  color: #333;
}&lt;/pre&gt;
&lt;p&gt;然后看一下HTML结构大概是这样的：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;div class=&amp;quot;parent-box data-v-xxxxxx&amp;quot;&amp;gt;
  &amp;lt;button class=&amp;quot;child-btn data-v-yyyyyy&amp;quot;&amp;gt;我是子组件的按钮&amp;lt;/button&amp;gt;
&amp;lt;/div&amp;gt;&lt;/pre&gt;
&lt;p&gt;这时候CSS的优先级是怎么算的呢？穿透后的样式&lt;code&gt;.parent-box.data-v-xxxxxx .child-btn&lt;/code&gt;的优先级是&lt;strong&gt;ID选择器0、类选择器2、属性选择器0、伪类伪元素0、标签选择器0&lt;/strong&gt;，子组件自己的样式&lt;code&gt;.child-btn.data-v-yyyyyy&lt;/code&gt;的优先级是&lt;strong&gt;类选择器1、属性选择器0、伪类伪元素0、标签选择器0&lt;/strong&gt;，所以穿透后的样式优先级更高,自然就能覆盖子组件的作用域样式了。&lt;/p&gt;
&lt;p&gt;这里要注意一个小细节：&lt;code&gt;deep()&lt;/code&gt;括号里只能写一个“简单选择器”或者“组合选择器”，但&lt;strong&gt;不能写子组件的根元素的作用域标记&lt;/strong&gt;，比如不能写&lt;code&gt;deep(.child-btn.data-v-yyyyyy)&lt;/code&gt;，因为子组件的根元素的作用域标记是动态生成的，你根本不知道它的具体值是什么，而且就算你知道了，写上去也没意义,反而会降低优先级。&lt;/p&gt;
&lt;p&gt;还有，如果你在Less、Sass这些预处理器里用&lt;code&gt;deep()&lt;/code&gt;，完全不会有之前旧写法的解析混乱问题，因为Vue3的SFC编译器会在预处理器处理完CSS之后，再处理&lt;code&gt;deep()&lt;/code&gt;、&lt;code&gt;slotted()&lt;/code&gt;这些Vue特有的伪类，所以预处理器只会把&lt;code&gt;deep()&lt;/code&gt;当成普通的函数或者占位符处理，不会做任何额外的操作，比如在Less里写嵌套的&lt;code&gt;deep()&lt;/code&gt;：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;style scoped lang=&amp;quot;less&amp;quot;&amp;gt;
.parent-box {
  padding: 20px;
  border: 1px solid #eee;
  :deep(.child-box) {
    margin-bottom: 10px;
    .child-btn {
      background-color: #409eff;
      color: #fff;
    }
  }
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;这个写法在预处理器里会先被处理成：&lt;/p&gt;
&lt;pre class=&quot;brush:css;toolbar:false&quot;&gt;.parent-box {
  padding: 20px;
  border: 1px solid #eee;
}
.parent-box :deep(.child-box) {
  margin-bottom: 10px;
}
.parent-box :deep(.child-box) .child-btn {
  background-color: #409eff;
  color: #fff;
}&lt;/pre&gt;
&lt;p&gt;然后再被Vue3的SFC编译器处理成标准的、带作用域标记的CSS,完全没问题。&lt;/p&gt;
&lt;h3&gt;专门针对插槽的场景：&lt;code&gt;slotted()&lt;/code&gt; 穿透父组件传入的插槽内容样式&lt;/h3&gt;
&lt;p&gt;很多人容易把&lt;code&gt;slotted()&lt;/code&gt;和&lt;code&gt;deep()&lt;/code&gt;搞混，其实它们的用途完全不一样：&lt;code&gt;deep()&lt;/code&gt;是“父组件修改子组件内部的作用域样式”，而&lt;code&gt;slotted()&lt;/code&gt;是“子组件修改父组件传入到子组件插槽里的内容的样式”。&lt;/p&gt;
&lt;p&gt;这里先回忆一下Vue的插槽原理：父组件传入到子组件插槽里的内容，其实是在父组件的模板编译阶段生成的，所以这些内容的作用域标记是父组件的，不是子组件的，如果子组件想修改这些插槽内容的样式，在Vue2里只能用全局样式，或者让父组件传入特定的class，但全局样式会污染整个项目，让父组件传入class又太麻烦,还会增加组件之间的耦合度。&lt;/p&gt;
&lt;p&gt;Vue3的&lt;code&gt;slotted()&lt;/code&gt;就完美解决了这个问题，它的原理是：当你在子组件的&lt;code&gt;&amp;lt;style scoped&amp;gt;&lt;/code&gt;里写&lt;code&gt;slotted(.父组件传入的内容的类名)&lt;/code&gt;时，Vue3的SFC编译器会给&lt;code&gt;slotted()&lt;/code&gt;括号里的选择器加子组件的作用域标记，同时加一个特殊的伪类标记&lt;code&gt;:v-slotted()&lt;/code&gt;（不过这个伪类标记也是纯编译时的，不会出现在生产环境CSS里），然后在编译后的CSS里，&lt;code&gt;slotted()&lt;/code&gt;会被转换成“子组件的插槽根元素的选择器 + 子组件的作用域标记 + 父组件传入的内容的类名”。&lt;/p&gt;
&lt;p&gt;举个具体的例子：
假设你有一个子组件叫&lt;code&gt;SlotCard.vue&lt;/code&gt;是这样的：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;slot-card&amp;quot;&amp;gt;
    &amp;lt;h3 class=&amp;quot;slot-card-title&amp;quot;&amp;gt;我是子组件的标题&amp;lt;/h3&amp;gt;
    &amp;lt;!-- 这是一个默认插槽 --&amp;gt;
    &amp;lt;div class=&amp;quot;slot-card-content&amp;quot;&amp;gt;
      &amp;lt;slot&amp;gt;&amp;lt;/slot&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;style scoped&amp;gt;
/* 子组件自己的样式 */
.slot-card {
  width: 300px;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.slot-card-title {
  margin-top: 0;
  margin-bottom: 10px;
  font-size: 18px;
  color: #333;
}
/* 使用:slotted()修改父组件传入的默认插槽内容的样式 */
/* 这里的:deep()是可选的，如果父组件传入的插槽内容里还有带scoped样式的孙组件，就可以用:deep()穿透孙组件 */
.slot-card-content :slotted(.slot-text) {
  font-size: 14px;
  color: #666;
  line-height: 1.6;
}
.slot-card-content :slotted(.slot-btn) {
  margin-top: 10px;
  padding: 6px 12px;
  border: none;
  border-radius: 4px;
  background-color: #e6a23c;
  color: #fff;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;然后有一个父组件叫&lt;code&gt;SlotParent.vue&lt;/code&gt;是这样的：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;slot-parent&amp;quot;&amp;gt;
    &amp;lt;SlotCard&amp;gt;
      &amp;lt;!-- 这是父组件传入到子组件默认插槽里的内容 --&amp;gt;
      &amp;lt;p class=&amp;quot;slot-text&amp;quot;&amp;gt;我是父组件传入的插槽文本&amp;lt;/p&amp;gt;
      &amp;lt;button class=&amp;quot;slot-btn&amp;quot;&amp;gt;我是父组件传入的插槽按钮&amp;lt;/button&amp;gt;
      &amp;lt;!-- 这里还可以传入带scoped样式的孙组件，然后在子组件里用:slotted() + :deep()穿透 --&amp;gt;
      &amp;lt;GrandChildComponent class=&amp;quot;slot-grandchild&amp;quot; /&amp;gt;
    &amp;lt;/SlotCard&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;style scoped&amp;gt;
/* 父组件自己的样式 */
.slot-parent {
  padding: 20px;
}
/* 父组件传入的插槽内容的默认样式（会被子组件的:slotted()样式覆盖，因为优先级更高） */
.slot-text {
  color: #999;
}
.slot-btn {
  background-color: #67c23a;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;那编译后的最终生产环境CSS大概是这样的：&lt;/p&gt;
&lt;pre class=&quot;brush:css;toolbar:false&quot;&gt;/* 子组件自己的样式 */
.slot-card.data-v-zzzzzz {
  width: 300px;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.slot-card-title.data-v-zzzzzz {
  margin-top: 0;
  margin-bottom: 10px;
  font-size: 18px;
  color: #333;
}
/* 子组件的:slotted()样式，会被子组件的插槽根元素的选择器和作用域标记包裹 */
.slot-card-content.data-v-zzzzzz .slot-text {
  font-size: 14px;
  color: #666;
  line-height: 1.6;
}
.slot-card-content.data-v-zzzzzz .slot-btn {
  margin-top: 10px;
  padding: 6px 12px;
  border: none;
  border-radius: 4px;
  background-color: #e6a23c;
  color: #fff;
}
/* 父组件自己的样式 */
.slot-parent.data-v-wwwwww {
  padding: 20px;
}
/* 父组件传入的插槽内容的默认样式 */
.slot-text.data-v-wwwwww {
  color: #999;
}
.slot-btn.data-v-wwwwww {
  background-color: #67c23a;
}&lt;/pre&gt;
&lt;p&gt;然后看一下HTML结构大概是这样的：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;div class=&amp;quot;slot-parent data-v-wwwwww&amp;quot;&amp;gt;
  &amp;lt;div class=&amp;quot;slot-card data-v-zzzzzz&amp;quot;&amp;gt;
    &amp;lt;h3 class=&amp;quot;slot-card-title data-v-zzzzzz&amp;quot;&amp;gt;我是子组件的标题&amp;lt;/h3&amp;gt;
    &amp;lt;div class=&amp;quot;slot-card-content data-v-zzzzzz&amp;quot;&amp;gt;
      &amp;lt;!-- 父组件传入的插槽内容，带父组件的作用域标记 --&amp;gt;
      &amp;lt;p class=&amp;quot;slot-text data-v-wwwwww&amp;quot;&amp;gt;我是父组件传入的插槽文本&amp;lt;/p&amp;gt;
      &amp;lt;button class=&amp;quot;slot-btn data-v-wwwwww&amp;quot;&amp;gt;我是父组件传入的插槽按钮&amp;lt;/button&amp;gt;
      &amp;lt;!-- 孙组件的内容，带孙组件的作用域标记 --&amp;gt;
      &amp;lt;div class=&amp;quot;slot-grandchild data-v-wwwwww&amp;quot;&amp;gt;
        &amp;lt;div class=&amp;quot;grandchild-box data-v-aaaaaa&amp;quot;&amp;gt;我是孙组件的内容&amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/pre&gt;
&lt;p&gt;这时候子组件的&lt;code&gt;slotted()&lt;/code&gt;样式&lt;code&gt;.slot-card-content.data-v-zzzzzz .slot-text&lt;/code&gt;的优先级是&lt;strong&gt;类选择器2、属性选择器0、伪类伪元素0、标签选择器0&lt;/strong&gt;，父组件的默认样式&lt;code&gt;.slot-text.data-v-wwwwww&lt;/code&gt;的优先级是&lt;strong&gt;类选择器1、属性选择器0、伪类伪元素0、标签选择器0&lt;/strong&gt;，所以子组件的样式优先级更高,自然就能覆盖父组件的默认样式了。&lt;/p&gt;
&lt;p&gt;这里要注意两个小细节：第一个是&lt;code&gt;slotted()&lt;/code&gt;只能在子组件的&lt;code&gt;&amp;lt;style scoped&amp;gt;&lt;/code&gt;里使用，不能在父组件里用；第二个是如果父组件传入的插槽内容里还有带scoped样式的孙组件，你可以在子组件的&lt;code&gt;slotted()&lt;/code&gt;后面再加一个&lt;code&gt;deep()&lt;/code&gt;来穿透孙组件的样式,比如上面的例子里可以写：&lt;/p&gt;
&lt;pre class=&quot;brush:css;toolbar:false&quot;&gt;.slot-card-content :slotted(.slot-grandchild) :deep(.grandchild-box) {
  color: #f56c6c;
}&lt;/pre&gt;
&lt;p&gt;这样就能修改孙组件内部的&lt;code&gt;.grandchild-box&lt;/code&gt;的颜色了。&lt;/p&gt;
&lt;h3&gt;偶尔会用到的场景：&lt;code&gt;global()&lt;/code&gt; 在scoped样式块里注入全局样式&lt;/h3&gt;
&lt;p&gt;有时候你可能会遇到这种情况：你想在某个组件里写一些全局样式，但又不想单独开一个&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;标签（因为单独开的话样式会比较分散，不好维护），这时候就可以用&lt;code&gt;global()&lt;/code&gt;伪类。&lt;/p&gt;
&lt;p&gt;它的原理很简单：当你在&lt;code&gt;&amp;lt;style scoped&amp;gt;&lt;/code&gt;里写&lt;code&gt;global(选择器)&lt;/code&gt;时，Vue3的SFC编译器不会给&lt;code&gt;global()&lt;/code&gt;括号里的选择器加任何作用域标记，直接输出到生产环境CSS里,相当于把这段样式当成全局样式处理。&lt;/p&gt;
&lt;p&gt;举个具体的例子：
假设你有一个组件叫&lt;code&gt;GlobalStyle.vue&lt;/code&gt;是这样的：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;global-style-box&amp;quot;&amp;gt;
    &amp;lt;p&amp;gt;我是全局样式组件的内容&amp;lt;/p&amp;gt;
    &amp;lt;button class=&amp;quot;global-btn&amp;quot;&amp;gt;我是全局按钮&amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;style scoped&amp;gt;
/* 组件自己的作用域样式 */
.global-style-box {
  padding: 20px;
}
/* 使用:global()注入全局样式 */
:global(.global-btn) {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  background-color: #409eff;
  color: #fff;
  cursor: pointer;
}
/* 全局样式也可以写嵌套选择器 */
:global(body) {
  margin: 0;
  padding: 0;
  font-family: &amp;quot;Microsoft YaHei&amp;quot;, sans-serif;
  .el-button {
    font-size: 14px;
  }
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;那编译后的最终生产环境CSS大概是这样的：&lt;/p&gt;
&lt;pre class=&quot;brush:css;toolbar:false&quot;&gt;/* 组件自己的作用域样式 */
.global-style-box.data-v-bbbbbb {
  padding: 20px;
}
/* :global()注入的全局样式，没有任何作用域标记 */
.global-btn {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  background-color: #409eff;
  color: #fff;
  cursor: pointer;
}
body {
  margin: 0;
  padding: 0;
  font-family: &amp;quot;Microsoft YaHei&amp;quot;, sans-serif;
}
body .el-button {
  font-size: 14px;
}&lt;/pre&gt;
&lt;p&gt;这里要注意一个非常重要的点：&lt;strong&gt;&lt;code&gt;global()&lt;/code&gt;注入的全局样式，在组件第一次被渲染到页面上时才会生效，组件销毁时不会自动移除&lt;/strong&gt;，所以千万不要在频繁销毁和重建的组件里用&lt;code&gt;global()&lt;/code&gt;注入太多全局样式，否则会导致页面上的全局样式越来越多，不仅会污染整个项目，还会影响页面的性能，如果必须要在频繁销毁和重建的组件里用全局样式，建议还是单独开一个&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;标签，或者用CSS Modules（不过Vue3的SFC编译器对CSS Modules的支持也很完善，大家可以自己去了解一下）。&lt;/p&gt;
&lt;h2&gt;90%开发者会踩的3个大坑！快看看你有没有中过招&lt;/h2&gt;
&lt;p&gt;虽然Vue3的新样式穿透语法比旧写法简单、安全、标准，但还是有很多开发者会踩坑，这里我整理了3个最常见的大坑,快看看你有没有中过招。&lt;/p&gt;
&lt;h3&gt;大坑1：&lt;code&gt;deep()&lt;/code&gt;括号里的选择器前面加了空格，或者没加空格，导致样式失效&lt;/h3&gt;
&lt;p&gt;这个是最常见的一个大坑，很多人刚用&lt;code&gt;deep()&lt;/code&gt;的时候都会犯这个错误，首先要明确一点：&lt;code&gt;deep()&lt;/code&gt;括号里的选择器前面加不加空格,效果完全不一样。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;加空格的情况&lt;/strong&gt;：比如&lt;code&gt;.parent :deep(.child)&lt;/code&gt;，表示“选择.parent元素下面的所有.child元素（不管是直接子元素还是间接子元素），而且不给.child元素加父组件的作用域标记”，这个是最常用的情况,也是我们刚才举的例子里的情况。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;没加空格的情况&lt;/strong&gt;：比如&lt;code&gt;.parent:deep(.child)&lt;/code&gt;，这里的&lt;code&gt;deep()&lt;/code&gt;是直接跟在&lt;code&gt;.parent&lt;/code&gt;后面的，表示“选择同时具有.parent类和被:deep()包裹的.child选择器匹配的元素”，但实际上被:deep()包裹的.child选择器不会加父组件的作用域标记，所以这种写法几乎不可能匹配到任何元素,自然就会样式失效。&lt;/p&gt;
&lt;p&gt;这里给大家一个小技巧：**不管是哪种情况，只要你想在父组件的scoped样式块里修改子组件内部的样式，就在&lt;code&gt;deep()&lt;/code&gt;括号里的选择器前面加一个空格,绝对不会错。&lt;/p&gt;
&lt;h3&gt;大坑2：在子组件的根元素上用&lt;code&gt;deep()&lt;/code&gt;，导致样式覆盖范围过大&lt;/h3&gt;
&lt;p&gt;很多人可能会问：“子组件的根元素的样式能不能用&lt;code&gt;deep()&lt;/code&gt;修改？”答案是可以，但&lt;strong&gt;不建议这么做&lt;/strong&gt;，因为子组件的根元素上有两个作用域标记：一个是子组件自己的，一个是父组件的（因为Vue3的SFC编译器会给所有子组件的根元素自动加父组件的作用域标记），如果你在父组件的scoped样式块里写&lt;code&gt;deep(.子组件的根元素的类名)&lt;/code&gt;，那么编译后的选择器是&lt;code&gt;.data-v-xxxxxx .子组件的根元素的类名&lt;/code&gt;，而子组件的根元素的选择器是&lt;code&gt;.子组件的根元素的类名.data-v-yyyyyy&lt;/code&gt;，所以优先级其实是一样的，这时候就会根据CSS的“后来居上”原则来决定哪个样式生效,很容易出现样式覆盖范围过大的问题。&lt;/p&gt;
&lt;p&gt;那如果一定要修改子组件的根元素的样式，应该怎么做呢？**最好的做法是让子组件对外暴露一个props或者一个class属性，让父组件可以直接给子组件的根元素传特定的class，然后在子组件的scoped样式块里根据这个class来修改根元素的样式，这样不仅可以避免样式覆盖范围过大的问题，还能降低组件之间的耦合度，符合Vue组件设计的“单向数据流”原则。&lt;/p&gt;
&lt;p&gt;举个具体的例子：
假设你有一个子组件叫&lt;code&gt;Button.vue&lt;/code&gt;，你可以给它对外暴露一个&lt;code&gt;type&lt;/code&gt; prop,然后在模板里用动态class：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;button class=&amp;quot;button&amp;quot; :class=&amp;quot;`button-${type}`&amp;quot;&amp;gt;
    &amp;lt;slot&amp;gt;&amp;lt;/slot&amp;gt;
  &amp;lt;/button&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup lang=&amp;quot;ts&amp;quot;&amp;gt;
import { defineProps } from &amp;#39;vue&amp;#39;
defineProps({
  type: {
    type: String,
    default: &amp;#39;default&amp;#39;
  }
})
&amp;lt;/script&amp;gt;
&amp;lt;style scoped&amp;gt;
.button {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.button-default {
  background-color: #f5f5f5;
  color: #333;
}
.button-primary {
  background-color: #409eff;
  color: #fff;
}
.button-danger {
  background-color: #f56c6c;
  color: #fff;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;然后在父组件里直接传&lt;code&gt;type&lt;/code&gt; prop就行：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;parent&amp;quot;&amp;gt;
    &amp;lt;Button type=&amp;quot;primary&amp;quot;&amp;gt;主要按钮&amp;lt;/Button&amp;gt;
    &amp;lt;Button type=&amp;quot;danger&amp;quot;&amp;gt;危险按钮&amp;lt;/Button&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;p&gt;这样是不是比用&lt;code&gt;deep()&lt;/code&gt;修改子组件的根元素的样式更安全、更规范？&lt;/p&gt;
&lt;h3&gt;大坑3：在全局样式块里用&lt;code&gt;deep()&lt;/code&gt;、&lt;code&gt;slotted()&lt;/code&gt;或者&lt;code&gt;global()&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;这个也是一个比较常见的大坑，很多人可能会觉得：“既然这些伪类这么好用，那在全局样式块里也能用吧？”答案是&lt;strong&gt;不能&lt;/strong&gt;，因为这些伪类都是基于Vue3的SFC编译器的&lt;code&gt;scoped&lt;/code&gt;属性实现的，如果在没有&lt;code&gt;scoped&lt;/code&gt;属性的&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;标签里用，Vue3的SFC编译器会直接忽略这些伪类，不会做任何处理,自然就会样式失效。&lt;/p&gt;
&lt;p&gt;比如你在全局样式块里写&lt;code&gt;deep(.el-button)&lt;/code&gt;，编译后的CSS还是&lt;code&gt;deep(.el-button)&lt;/code&gt;，这串代码放到浏览器里直接无效，因为&lt;code&gt;deep()&lt;/code&gt;不是标准的CSS伪类。&lt;/p&gt;
&lt;p&gt;所以一定要记住：&lt;strong&gt;&lt;code&gt;deep()&lt;/code&gt;、&lt;code&gt;slotted()&lt;/code&gt;、&lt;code&gt;global()&lt;/code&gt;这三个伪类，只能在带有&lt;code&gt;scoped&lt;/code&gt;属性的&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;标签里使用&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;结合Element Plus的实战案例：完美修改Element Plus组件的样式&lt;/h2&gt;
&lt;p&gt;Element Plus是目前Vue3生态里最流行的UI组件库之一，很多Vue3项目都会用到，而修改Element Plus组件的样式又是很多开发者都会遇到的需求，接下来我就结合Element Plus的一个经典组件——&lt;code&gt;el-dialog&lt;/code&gt;（对话框组件）,给大家演示一下怎么用Vue3的新样式穿透语法完美修改它的样式。&lt;/p&gt;
&lt;p&gt;假设我们有一个需求：修改&lt;code&gt;el-dialog&lt;/code&gt;颜色为红色，修改&lt;code&gt;el-dialog&lt;/code&gt;的确认按钮的背景颜色为紫色，修改&lt;code&gt;el-dialog&lt;/code&gt;的关闭按钮的大小为24px。&lt;/p&gt;
&lt;p&gt;那我们应该怎么做呢？我们可以用Vue3的开发者工具或者浏览器的开发者工具，查看一下&lt;code&gt;el-dialog&lt;/code&gt;的HTML结构,大概是这样的：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;div class=&amp;quot;el-overlay&amp;quot;&amp;gt;
  &amp;lt;div class=&amp;quot;el-dialog&amp;quot;&amp;gt;
    &amp;lt;div class=&amp;quot;el-dialog__header&amp;quot;&amp;gt;
      &amp;lt;span class=&amp;quot;el-dialog__title&amp;quot;&amp;gt;我是对话框标题&amp;lt;/span&amp;gt;
      &amp;lt;button class=&amp;quot;el-dialog__headerbtn&amp;quot;&amp;gt;
        &amp;lt;i class=&amp;quot;el-icon el-icon-close&amp;quot;&amp;gt;&amp;lt;/i&amp;gt;
      &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div class=&amp;quot;el-dialog__body&amp;quot;&amp;gt;
      我是对话框内容
    &amp;lt;/div&amp;gt;
    &amp;lt;div class=&amp;quot;el-dialog__footer&amp;quot;&amp;gt;
      &amp;lt;button class=&amp;quot;el-button el-button--default&amp;quot;&amp;gt;取消&amp;lt;/button&amp;gt;
      &amp;lt;button class=&amp;quot;el-button el-button--primary&amp;quot;&amp;gt;确认&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/pre&gt;
&lt;p&gt;这里要注意一个非常重要的点：&lt;strong&gt;Element Plus的&lt;code&gt;el-overlay&lt;/code&gt;和&lt;code&gt;el-dialog&lt;/code&gt;是直接挂载到&lt;code&gt;body&lt;/code&gt;元素下面的，不是挂载到父组件的根元素下面的&lt;/strong&gt;，哦，不对，Element Plus的&lt;code&gt;el-dialog&lt;/code&gt;组件有一个&lt;code&gt;append-to-body&lt;/code&gt; prop，默认值是&lt;code&gt;true&lt;/code&gt;，如果把它设置成&lt;code&gt;false&lt;/code&gt;，&lt;code&gt;el-dialog&lt;/code&gt;就会挂载到父组件的根元素下面。&lt;/p&gt;
&lt;p&gt;那如果&lt;code&gt;append-to-body&lt;/code&gt;是默认的&lt;code&gt;true&lt;/code&gt;，我们在父组件的scoped样式块里用&lt;code&gt;deep()&lt;/code&gt;能不能修改&lt;code&gt;el-dialog&lt;/code&gt;的样式呢？答案是&lt;strong&gt;不能&lt;/strong&gt;，因为父组件的作用域标记是加在父组件的根元素下面的所有元素上的，而&lt;code&gt;el-dialog&lt;/code&gt;是直接挂载到&lt;code&gt;body&lt;/code&gt;元素下面的，没有父组件的作用域标记，所以&lt;code&gt;deep()&lt;/code&gt;括号里的选择器前面的父组件选择器根本匹配不到任何元素,自然就会样式失效。&lt;/p&gt;
&lt;p&gt;那这时候我们应该怎么做呢？有两种解决方案：&lt;/p&gt;
&lt;h3&gt;解决方案1：把&lt;code&gt;el-dialog&lt;/code&gt;的&lt;code&gt;append-to-body&lt;/code&gt; prop设置成&lt;code&gt;false&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;这种方案最简单，只要把&lt;code&gt;append-to-body&lt;/code&gt;设置成&lt;code&gt;false&lt;/code&gt;，&lt;code&gt;el-dialog&lt;/code&gt;就会挂载到父组件的根元素下面，有了父组件的作用域标记，然后我们就可以直接用&lt;code&gt;deep()&lt;/code&gt;修改它的样式了。&lt;/p&gt;
&lt;p&gt;举个具体的代码例子：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;dialog-parent&amp;quot;&amp;gt;
    &amp;lt;el-button @click=&amp;quot;dialogVisible = true&amp;quot;&amp;gt;打开对话框&amp;lt;/el-button&amp;gt;
    &amp;lt;el-dialog
      v-model=&amp;quot;dialogVisible&amp;quot;
      title=&amp;quot;我是对话框标题&amp;quot;
      width=&amp;quot;500px&amp;quot;
      :append-to-body=&amp;quot;false&amp;quot;
    &amp;gt;
      &amp;lt;p&amp;gt;我是对话框内容&amp;lt;/p&amp;gt;
      &amp;lt;template #footer&amp;gt;
        &amp;lt;span class=&amp;quot;dialog-footer&amp;quot;&amp;gt;
          &amp;lt;el-button @click=&amp;quot;dialogVisible = false&amp;quot;&amp;gt;取消&amp;lt;/el-button&amp;gt;
          &amp;lt;el-button type=&amp;quot;primary&amp;quot; @click=&amp;quot;dialogVisible = false&amp;quot;&amp;gt;确认&amp;lt;/el-button&amp;gt;
        &amp;lt;/span&amp;gt;
      &amp;lt;/template&amp;gt;
    &amp;lt;/el-dialog&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup lang=&amp;quot;ts&amp;quot;&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
const dialogVisible = ref(false)
&amp;lt;/script&amp;gt;
&amp;lt;style scoped&amp;gt;
/* 父组件自己的样式 */
.dialog-parent {
  padding: 20px;
}
/* 修改对话框标题的颜色 */
.dialog-parent :deep(.el-dialog__title) {
  color: #f56c6c;
}
/* 修改对话框确认按钮的背景颜色 */
.dialog-parent :deep(.el-button--primary) {
  background-color: #909399;
  border-color: #909399;
  &amp;amp;:hover {
    background-color: #a8abb2;
    border-color: #a8abb2;
  }
}
/* 修改对话框关闭按钮的大小 */
.dialog-parent :deep(.el-dialog__headerbtn .el-icon-close) {
  font-size: 24px;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;这个方案虽然简单，但也有一个缺点：如果&lt;code&gt;el-dialog&lt;/code&gt;的父组件有&lt;code&gt;overflow: hidden&lt;/code&gt;的样式，那么&lt;code&gt;el-dialog&lt;/code&gt;就会被父组件裁剪掉一部分，无法正常显示,这时候我们就需要用第二种解决方案了。&lt;/p&gt;
&lt;h3&gt;解决方案2：用&lt;code&gt;global()&lt;/code&gt;伪类，或者单独开一个&lt;code&gt;&amp;lt;style&amp;gt;
&lt;p&gt;这种方案可以解决&lt;code&gt;el-dialog&lt;/code&gt;被父组件裁剪掉的问题,但要注意样式污染的问题。&lt;/p&gt;
&lt;p&gt;如果要用&lt;code&gt;global()&lt;/code&gt;伪类，最好在&lt;code&gt;global()&lt;/code&gt;前面加一个唯一的class前缀，比如&lt;code&gt;custom-dialog&lt;/code&gt;，然后在&lt;code&gt;el-dialog&lt;/code&gt;组件上添加这个唯一的class前缀,这样可以避免样式污染整个项目。&lt;/p&gt;
&lt;p&gt;举个具体的代码例子：&lt;/p&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;dialog-parent&amp;quot;&amp;gt;
    &amp;lt;el-button @click=&amp;quot;dialogVisible = true&amp;quot;&amp;gt;打开对话框&amp;lt;/el-button&amp;gt;
    &amp;lt;el-dialog
      v-model=&amp;quot;dialogVisible&amp;quot;
      title=&amp;quot;我是对话框标题&amp;quot;
      width=&amp;quot;500px&amp;quot;
      class=&amp;quot;custom-dialog&amp;quot;
    &amp;gt;
      &amp;lt;p&amp;gt;我是对话框内容&amp;lt;/p&amp;gt;
      &amp;lt;template #footer&amp;gt;
        &amp;lt;span class=&amp;quot;dialog-footer&amp;quot;&amp;gt;
          &amp;lt;el-button @click=&amp;quot;dialogVisible = false&amp;quot;&amp;gt;取消&amp;lt;/el-button&amp;gt;
          &amp;lt;el-button type=&amp;quot;primary&amp;quot; @click=&amp;quot;dialogVisible = false&amp;quot;&amp;gt;确认&amp;lt;/el-button&amp;gt;
        &amp;lt;/span&amp;gt;
      &amp;lt;/template&amp;gt;
    &amp;lt;/el-dialog&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup lang=&amp;quot;ts&amp;quot;&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
const dialogVisible = ref(false)
&amp;lt;/script&amp;gt;
&amp;lt;style scoped&amp;gt;
/* 父组件自己的样式 */
.dialog-parent {
  padding: 20px;
}
/* 用:global()修改对话框的样式，前面加了唯一的class前缀custom-dialog */
:global(.custom-dialog .el-dialog__title) {
  color: #f56c6c;
}
:global(.custom-dialog .el-button--primary) {
  background-color: #909399;
  border-color: #909399;
  &amp;amp;:hover {
    background-color: #a8abb2;
    border-color: #a8abb2;
  }
}
:global(.custom-dialog .el-dialog__headerbtn .el-icon-close) {
  font-size: 24px;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;这里要注意，给&lt;code&gt;el-dialog&lt;/code&gt;组件添加class前缀的时候，不能用&lt;code&gt;class&lt;/code&gt;属性，因为&lt;code&gt;class&lt;/code&gt;属性会被添加到&lt;code&gt;el-dialog&lt;/code&gt;的内部容器上，而不是直接挂载到&lt;code&gt;body&lt;/code&gt;下面的&lt;code&gt;el-overlay&lt;/code&gt;和&lt;code&gt;el-dialog&lt;/code&gt;的最外层容器上，哦，不对，Element Plus的&lt;code&gt;el-dialog&lt;/code&gt;组件的&lt;code&gt;class&lt;/code&gt;属性是可以添加到直接挂载到&lt;code&gt;body&lt;/code&gt;下面的&lt;code&gt;el-dialog&lt;/code&gt;的最外层容器上的，刚才我用浏览器的开发者工具试了一下，确实是这样的,所以上面的代码是没问题的。&lt;/p&gt;
&lt;p&gt;如果不用&lt;code&gt;global()&lt;/code&gt;伪类，也可以单独开一个&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;标签，然后在里面加唯一的class前缀,效果是一样的。&lt;/p&gt;
&lt;h2&gt;Vue3样式穿透的最佳实践&lt;/h2&gt;
&lt;p&gt;我给大家总结一下Vue3样式穿透的最佳实践,希望能对大家有所帮助：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;尽量不用样式穿透&lt;/strong&gt;：能不用就不用，能让子组件对外暴露props或者class属性的，就尽量让子组件对外暴露，这样可以降低组件之间的耦合度,符合Vue组件设计的原则。&lt;/li&gt;
&lt;li&gt;**正确选择合适的写法：&lt;ul&gt;
&lt;li&gt;如果要在父组件的scoped样式块里修改子组件内部的作用域样式，用&lt;code&gt;deep()&lt;/code&gt;,括号里的选择器前面加一个空格。&lt;/li&gt;
&lt;li&gt;如果要在子组件的scoped样式块里修改父组件传入的插槽内容的样式，用&lt;code&gt;slotted()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;如果要在scoped样式块里注入全局样式，用&lt;code&gt;global()&lt;/code&gt;，前面加唯一的class前缀,避免样式污染。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;注意`el-overlay和el-dialog这类挂载到body下面的组件&lt;/strong&gt;：如果&lt;code&gt;append-to-body&lt;/code&gt;是默认的&lt;code&gt;true&lt;/code&gt;，要用&lt;code&gt;global()&lt;/code&gt;伪类或者单独开一个&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;标签,前面加唯一的class前缀。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不要在全局样式块里用&lt;code&gt;deep()&lt;/code&gt;、&lt;code&gt;slotted()&lt;/code&gt;或者&lt;code&gt;global()&lt;/code&gt;&lt;/strong&gt;：这些伪类只能在带有&lt;code&gt;scoped&lt;/code&gt;属性的&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;标签里使用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;尽量不要在子组件的根元素上用&lt;code&gt;deep()&lt;/code&gt;&lt;/strong&gt;：最好的做法是让子组件对外暴露props或者class属性。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;好的，以上就是关于Vue3样式穿透的全部内容了，从旧写法的弃用逻辑、新写法的原理和用法、常见的大坑、结合Element Plus的实战案例，再到最佳实践，我都讲得非常详细了，相信大家看完之后肯定能熟练掌握Vue3的样式穿透语法了，如果大家还有什么疑问,欢迎在评论区留言讨论。&lt;/p&gt;</description><pubDate>Sat, 20 Jun 2026 08:37:00 +0800</pubDate></item><item><title>Vue3 异步组件到底有啥用？为啥现在做项目都离不开它？</title><link>https://www.codeqd.com/post/20260621857.html</link><description>&lt;p&gt;刚开始学Vue3的朋友可能会觉得,异步组件有点“多此一举”——同步引入组件明明写起来更顺，为啥非要绕个弯子加defineAsyncComponent？但真正上手做过中型甚至小型带路由、带复杂弹窗/图表的项目后，你就会发现这东西简直是首屏优化和代码结构梳理的“神器”，接下来我就从自己踩过的坑、用过的真实场景，还有一些好用的细节配置，给大家掰扯清楚。&lt;/p&gt;
&lt;h2&gt;首先得弄明白：异步组件和同步组件的核心区别是什么？&lt;/h2&gt;
&lt;p&gt;咱们先举个最简单的例子回忆同步组件的写法——一般是在&lt;script setup&gt;最上面直接import，或者在普通script的components选项里注册：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// 同步引入写法
&amp;lt;script setup&amp;gt;
import BigDataChart from &amp;#39;./components/BigDataChart.vue&amp;#39;
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;这时候不管你页面上有没有马上用到BigDataChart,比如这个图表藏在“数据概览”的二级tab里，用户甚至从来不会点这个tab，打包的时候它还是会被一股脑塞进主bundle（也就是app.js这类核心文件）里，主bundle越大，浏览器首次加载的时间就越长，用户打开页面看到白屏的概率就越高，尤其是在手机4G信号弱或者海外网络慢的情况下，白屏超过3秒用户就大概率会划走了。&lt;/p&gt;
&lt;p&gt;那异步组件呢？就是把这个BigDataChart从主bundle里“拆分”出去，单独生成一个小的chunk文件，只有当页面真正需要渲染这个组件的时候，浏览器才会去请求这个小chunk，加载完再显示内容，这就像你去餐馆吃饭，同步组件是不管你点没点菜，老板先把所有菜都端上来堆在桌子上，占地方还耽误你入座；异步组件是你点哪道菜，老板才去后厨做哪道，虽然上菜稍微慢一点点（这里的慢一般只有几百毫秒，只要网络正常用户根本感觉不出来），但桌子上空空的不会影响你先坐下喝水聊天（也就是先显示页面的核心内容）。&lt;/p&gt;
&lt;h2&gt;现在做项目离不开它，主要是解决了这3个最头疼的问题&lt;/h2&gt;
&lt;p&gt;光说区别太空,咱们得落到具体的痛点上，看看我之前做项目时是怎么用异步组件“救命”的。&lt;/p&gt;
&lt;h3&gt;第一个痛点：首屏加载慢，测速工具得分只有30分&lt;/h3&gt;
&lt;p&gt;去年帮朋友做了一个本地美食探店的小程序H5版本,刚开始写的时候没注意，把所有页面、所有组件（包括那个超占空间的3D地图导航组件、商家详情页的20多张轮播图封装成的组件、还有用户个人中心的账单统计图表）全用同步引入了，结果用Google PageSpeed Insights测移动端首屏，加载时间快到7秒，性能得分只有29分，朋友试了下在地铁里打开，白屏了快10秒才出内容，直接说“这东西没法用”。&lt;/p&gt;
&lt;p&gt;后来我赶紧把所有路由对应的页面、还有3D地图、账单统计这种非首屏核心组件全改成了异步组件，路由懒加载其实就是异步组件的一种典型应用，Vue3里配合Vue Router4写起来特别简单：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// 路由懒加载（异步组件路由版）
import { createRouter, createWebHashHistory } from &amp;#39;vue-router&amp;#39;
const routes = [
  {
    path: &amp;#39;/&amp;#39;,
    name: &amp;#39;Home&amp;#39;,
    component: () =&amp;gt; import(&amp;#39;./views/Home.vue&amp;#39;) // 首页是核心，暂时可以同步
  },
  {
    path: &amp;#39;/map&amp;#39;,
    name: &amp;#39;Map&amp;#39;,
    // 3D地图单独拆分
    component: () =&amp;gt; import(&amp;#39;./views/Map.vue&amp;#39;)
  },
  {
    path: &amp;#39;/user/bill&amp;#39;,
    name: &amp;#39;UserBill&amp;#39;,
    // 账单统计更靠后，必须拆分
    component: () =&amp;gt; import(&amp;#39;./views/UserBill.vue&amp;#39;)
  }
]&lt;/pre&gt;
&lt;p&gt;改完之后再测PageSpeed Insights，移动端首屏加载时间降到了1.8秒，性能得分直接升到了87分，朋友在地铁里试了试，白屏1秒多就出首页了，满意得不行。&lt;/p&gt;
&lt;h3&gt;第二个痛点：复杂弹窗/抽屉打开延迟高，交互体验差&lt;/h3&gt;
&lt;p&gt;还是那个美食探店H5,里面有个“写评价”的功能，弹窗里有富文本编辑器、图片上传组件、评分组件、标签选择组件，这几个组件加起来打包也有小几百KB，刚开始用同步引入的时候，虽然主bundle没变大，但用户点击“写评价”按钮到弹窗打开，中间会有明显的卡顿——因为虽然组件没有提前加载到主bundle，但打开的时候才开始同步执行组件的初始化代码吗？不对，同步引入的话组件已经在主bundle里了，卡顿是因为弹窗里的内容太多，DOM渲染需要时间？哦不对，我那个时候的卡顿是因为把第三方的富文本编辑器和图片上传组件也直接同步引入到了弹窗组件里，导致弹窗组件本身的chunk就很大，打包的时候没拆分，初始化的时候DOM和第三方库的实例化一起执行，所以才卡。&lt;/p&gt;
&lt;p&gt;那这个时候除了把弹窗组件改成异步,还可以用defineAsyncComponent单独把第三方组件包装一下吗？或者直接把弹窗组件改成异步，同时给弹窗加个“加载中”的骨架屏，这样用户点击按钮的时候，先看到骨架屏，等异步组件加载完再显示真实内容，交互体验就会好很多，这里给大家看一下用defineAsyncComponent写的带加载状态和错误状态的异步弹窗组件：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// 带状态的异步弹窗组件
&amp;lt;script setup&amp;gt;
import { defineAsyncComponent, ref } from &amp;#39;vue&amp;#39;
// 加载状态的骨架屏组件
import ReviewModalSkeleton from &amp;#39;./components/ReviewModalSkeleton.vue&amp;#39;
// 加载失败的重试组件
import ReviewModalError from &amp;#39;./components/ReviewModalError.vue&amp;#39;
const isShowReviewModal = ref(false)
// 包装异步组件
const AsyncReviewModal = defineAsyncComponent({
  // 组件加载器，返回一个Promise
  loader: () =&amp;gt; import(&amp;#39;./components/ReviewModal.vue&amp;#39;),
  // 加载中显示的组件，默认200ms后才显示，避免快速加载时的闪烁
  loadingComponent: ReviewModalSkeleton,
  // 延迟显示加载组件的时间，单位毫秒
  delay: 200,
  // 加载失败显示的组件，默认3000ms后触发
  errorComponent: ReviewModalError,
  // 超时时间，单位毫秒
  timeout: 3000,
  // 组件加载成功后是否挂起（配合Suspense用，后面会讲）
  suspensible: true,
  // 组件加载失败后的重试策略，这里写的是最多重试3次，每次间隔1000ms
  onError(error, retry, fail, attempts) {
    if (error.message.match(/fetch/) &amp;amp;&amp;amp; attempts &amp;lt; 3) {
      setTimeout(() =&amp;gt; retry(), 1000)
    } else {
      fail()
    }
  }
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;这里的onError重试策略是我自己加的,特别实用——比如用户在地铁里网络不稳定，第一次加载失败了，组件会自动重试3次，不用用户手动刷新整个页面，体验感提升了不止一个档次，还有那个delay配置，之前我没加，发现有时候网络特别好，异步组件100ms就加载完了，结果骨架屏先闪了一下再显示真实内容，反而更奇怪，加了200ms的延迟后，这个问题就彻底解决了。&lt;/p&gt;
&lt;h3&gt;第三个痛点：团队协作时代码冲突多，打包体积难控制&lt;/h3&gt;
&lt;p&gt;之前在一家互联网公司做后台管理系统,团队有5个人，每个人负责不同的模块——我负责订单管理，小李负责用户管理，小王负责商品管理，小张负责数据报表，小赵负责系统设置，刚开始我们没有用异步组件，所有模块的页面、组件全在一个components文件夹和一个views文件夹里，import的时候都是直接写路径，结果每次小张修改数据报表的第三方图表库，打包的时候整个主bundle都会变大，我们其他四个人的代码都要跟着重新测试，特别麻烦，而且有时候两个人同时修改components文件夹里的公共组件注册文件，还会经常出现Git冲突。&lt;/p&gt;
&lt;p&gt;后来我们统一规范了项目结构,每个模块单独建一个文件夹，模块里的所有页面、组件全用异步引入，只有整个项目的公共组件（比如导航栏、侧边栏、按钮、输入框）用同步引入，这样一来，小张修改数据报表的代码，只会影响数据报表模块单独生成的chunk文件，我们其他人的代码不用重新测试；而且每个人只需要维护自己模块的文件夹，Git冲突的次数直接减少了90%以上，打包的时候我们可以清楚地看到每个模块生成的chunk文件大小，如果某个模块的chunk太大（比如数据报表模块有一次因为引入了一个没用的3D地图库，chunk大小超过了1MB），我们可以及时发现并优化。&lt;/p&gt;
&lt;h2&gt;除了上面的场景，defineAsyncComponent还有这2个容易被忽略但超好用的细节&lt;/h2&gt;
&lt;p&gt;很多朋友用异步组件的时候,只会写一个最简单的loader配置，其实defineAsyncComponent还有几个细节配置，用对了能让你的项目性能和体验再上一个台阶。&lt;/p&gt;
&lt;h3&gt;配合Suspense组件，处理异步组件的嵌套加载&lt;/h3&gt;
&lt;p&gt;刚才在带状态的异步弹窗组件代码里,我加了一个suspensible: true的配置，这个配置是用来配合Vue3的新特性Suspense组件的，什么是Suspense组件呢？就是用来处理组件树里有多个异步组件嵌套加载的情况的，比如刚才的ReviewModal组件，里面又异步引入了富文本编辑器和图片上传组件，如果不用Suspense组件，就会出现“先显示弹窗的骨架屏，弹窗的骨架屏消失后，又显示富文本编辑器的骨架屏，最后才显示完整内容”的情况，用户会觉得特别乱。&lt;/p&gt;
&lt;p&gt;用了Suspense组件之后,就可以让所有嵌套的异步组件都加载完成后，再统一显示真实内容，中间只显示一个统一的加载状态，这里给大家看一下配合Suspense的写法：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// 配合Suspense的异步弹窗组件
&amp;lt;template&amp;gt;
  &amp;lt;button @click=&amp;quot;isShowReviewModal = true&amp;quot;&amp;gt;写评价&amp;lt;/button&amp;gt;
  &amp;lt;div v-if=&amp;quot;isShowReviewModal&amp;quot; class=&amp;quot;review-modal-mask&amp;quot;&amp;gt;
    &amp;lt;Suspense&amp;gt;
      &amp;lt;!-- 所有异步组件加载完成后显示的内容 --&amp;gt;
      &amp;lt;template #default&amp;gt;
        &amp;lt;AsyncReviewModal @close=&amp;quot;isShowReviewModal = false&amp;quot; /&amp;gt;
      &amp;lt;/template&amp;gt;
      &amp;lt;!-- 嵌套异步组件加载中显示的内容 --&amp;gt;
      &amp;lt;template #fallback&amp;gt;
        &amp;lt;ReviewModalSkeleton /&amp;gt;
      &amp;lt;/template&amp;gt;
    &amp;lt;/Suspense&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;p&gt;这里要注意的是,Suspense组件目前还是Vue3的实验性特性吗？不对，我查了一下，从Vue3.3版本开始，Suspense组件已经正式稳定了，大家可以放心使用。&lt;/p&gt;
&lt;h3&gt;手动控制异步组件的预加载&lt;/h3&gt;
&lt;p&gt;预加载是什么意思呢？就是虽然用户现在还没用到某个异步组件，但我们可以预判用户接下来可能会用到，提前让浏览器在后台加载这个组件的chunk文件，等用户真正用到的时候，组件已经加载完了，打开速度就和同步组件一样快，比如刚才的美食探店H5，首页有个“查看地图”的按钮，用户大概率会点击这个按钮，所以我们可以在首页加载完成后，手动预加载Map组件的chunk文件。&lt;/p&gt;
&lt;p&gt;手动预加载的方法很简单,就是调用异步组件加载器返回的Promise的.catch()或者.then()方法，但其实更优雅的方法是用Webpack的魔法注释，配合Vue Router或者defineAsyncComponent使用，比如在路由懒加载里加魔法注释：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// 带魔法注释的路由预加载
const routes = [
  {
    path: &amp;#39;/&amp;#39;,
    name: &amp;#39;Home&amp;#39;,
    component: () =&amp;gt; import(&amp;#39;./views/Home.vue&amp;#39;)
  },
  {
    path: &amp;#39;/map&amp;#39;,
    name: &amp;#39;Map&amp;#39;,
    // webpackPrefetch: true 表示在浏览器空闲时预加载这个chunk
    // webpackChunkName: &amp;quot;map&amp;quot; 表示给这个chunk起个名字，方便调试和优化
    component: () =&amp;gt; import(/* webpackPrefetch: true, webpackChunkName: &amp;quot;map&amp;quot; */ &amp;#39;./views/Map.vue&amp;#39;)
  }
]&lt;/pre&gt;
&lt;p&gt;这里的webpackPrefetch魔法注释,是让Webpack在浏览器空闲时（也就是首页的核心内容加载完，CPU和网络都没事干的时候），自动预加载Map组件的chunk文件，除了webpackPrefetch，还有webpackPreload魔法注释，webpackPreload是让浏览器在加载主bundle的同时，并行加载这个chunk文件，一般用于首屏附近就会用到的异步组件，比如首页底部的“联系我们”弹窗，但要注意，webpackPreload不能用太多，否则会占用主bundle的加载带宽，反而影响首屏加载速度。&lt;/p&gt;
&lt;h2&gt;最后总结一下：什么时候该用异步组件，什么时候不该用？&lt;/h2&gt;
&lt;p&gt;说了这么多,可能有些朋友还是会分不清什么时候该用异步组件，什么时候不该用，其实很简单，记住这3个原则就行：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;非首屏核心的页面或组件，一定要用异步组件&lt;/strong&gt;——比如路由里的二级、三级页面，藏在tab、抽屉、弹窗里的复杂组件，这些都是用户不会马上用到的，拆分出去能大幅减小主bundle的体积。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;公共的基础组件，尽量用同步组件&lt;/strong&gt;——比如导航栏、侧边栏、按钮、输入框、表单验证组件，这些是每个页面都会用到的，如果用异步组件，反而会增加浏览器的请求次数，影响性能。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第三方的大型库，尽量包装成异步组件使用&lt;/strong&gt;——比如3D地图、富文本编辑器、数据可视化图表、PDF预览组件，这些第三方库一般都比较大，拆分出去能有效控制每个chunk的体积。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;也不是所有非首屏核心的组件都必须用异步组件——如果某个组件只有几十KB，拆分出去反而会增加浏览器的请求次数，得不偿失，这时候用同步组件就可以了，单个组件的打包体积超过50KB，就可以考虑用异步组件拆分了。&lt;/p&gt;
&lt;p&gt;好了,关于Vue3异步组件的内容就给大家掰扯到这里了，如果你之前没用过异步组件，建议现在就去自己的项目里试试，尤其是首屏加载慢的项目，改完之后你一定会回来感谢我的，如果你在使用过程中遇到了什么问题，也可以在评论区留言，我们一起讨论解决。&lt;/p&gt;</description><pubDate>Fri, 19 Jun 2026 22:07:13 +0800</pubDate></item><item><title>做vue3 高适配低性能消耗的下沉市场移动应用，选哪款组件库更稳？</title><link>https://www.codeqd.com/post/20260621856.html</link><description>&lt;p&gt;最近两年下沉市场的移动应用需求特别火,我身边做社区团购售后端、县域政务便民号H5的朋友，不少都踩过组件库的坑——要么旧款UI不符合下沉用户偏爱的“大字体、大按钮、高对比度”，要么功能全但加载慢、低端机卡得要死，还有的连微信小程序/支付宝小程序的适配都做不好，得反复调兼容，刚好我们团队上个月刚选好并落地了一款这类组件库，期间还翻了不少文档、测了3款热门主流的，今天就整理成大家容易懂的问答内容，帮你少走弯路。&lt;/p&gt;
&lt;h2&gt;首先搞清楚，下沉市场对vue3 移动端组件库的特殊要求是什么？&lt;/h2&gt;
&lt;p&gt;别直接拿大厂用的高端组件库来,下沉市场不一样，得抓住这几个硬软需求：
硬需求里，适配性必须排在第一位——不是说适配主流手机就行，要能覆盖到2020年以前发布的安卓千元机、苹果iPhone SE1/2这类低分辨率/小屏幕但存量大的机型；性能也不能弱，低端机打开单个页面的首屏渲染（SSR/SSG不算的话，CSR要控制在1.5秒内）、滚动列表不能有明显的掉帧、组件加载不能有“瀑布白屏”；还有生态适配要全，微信/支付宝小程序H5嵌包得能直接用，最好还能兼容鸿蒙的WebView或者官方的跨端语法转译工具。
软需求也很关键，UI要可以一键或者简单配置改风格——下沉用户不爱太“性冷淡”的设计，偏爱饱和度适中偏高的主色、圆角不小于12px的按钮、字号至少14px起步、行间距不小于1.6；文档要是中文的，而且不能太简略，最好有针对下沉场景的demo（比如大字号商品列表、手写签名确认收货、乡镇地址级联选择带拼音首字母筛选）；更新频率不能太低也不能太高，太低的话有bug没人修，太高的话每次升级都要改一堆代码，麻烦死了。&lt;/p&gt;
&lt;h2&gt;测了3款热门主流组件库，最终我们选了谁？&lt;/h2&gt;
&lt;p&gt;上个月我们团队拿售后端的原型（有手写签名、售后工单级联筛选、图片压缩上传、底部悬浮提交按钮这些下沉常用功能）测了Vant 4、NutUI 3.x Vue3版、Taro UI Vue3版，最后落地的是NutUI 3.x Vue3版，下面给你说下每款的优缺点，你可以根据自己的项目情况选：
Vant 4的优点很多，比如组件最丰富，国内大厂和中小团队用的人最多，社区生态超级成熟，有问题搜一下基本都能找到答案，文档也是中文的、很详细，但缺点也刚好戳中下下沉市场的痛点：默认UI太偏向一二线城市的电商风格，字体偏小（默认是12px起步）、圆角只有4px/8px，改风格的话虽然有主题定制，但主题变量太多（官方文档说有600+？我数了一下常用的字号、主色、圆角、间距的子变量加起来就有100+），调起来很费时间；性能方面，测千元机（红米Note7，2019年发布的，4+64G）的时候，商品列表加载到第50条就开始轻微掉帧，手写签名打开的时候会有半秒的卡顿；还有就是，它对鸿蒙WebView的兼容虽然没问题，但官方暂时没有支持官方跨端语法转译的计划，要是以后想转鸿蒙原生轻应用，还要自己改代码。
Taro UI Vue3版的优点是，它是专门为Taro跨端框架设计的，支持微信/支付宝/百度/抖音小程序，还支持鸿蒙官方的ArkTS转译工具，要是以后想做全端应用，选它会省很多事；默认UI也有主题定制，但变量不多，只有常用的50+，可以一键切换“标准模式”和“长辈模式”，长辈模式刚好是下沉用户喜欢的大字体、大按钮、高对比度，但缺点也很明显：组件太少了，官方文档里列的只有40+，比如手写签名、图片压缩上传、乡镇地址级联选择带拼音首字母筛选这些下沉常用功能，它都没有，得自己开发，开发成本很高；性能方面，测Taro打包成微信小程序H5嵌包的时候，千元机打开单个页面的首屏渲染要2秒多，比我们预期的慢了不少；还有就是，社区生态不如Vant 4和NutUI 3.x Vue3版成熟，有问题搜一下答案很少。
NutUI 3.x Vue3版的优点是，它是京东出品的，刚好京东自己做了很多年下沉市场（京东京喜、京东极速版），所以默认UI就很贴合下沉用户的需求，字号默认14px起步、圆角最小12px、主色是饱和度适中的京东红；主题定制变量只有常用的80+，比Vant 4少很多，而且有专门的“长辈模式一键配置工具”，不用自己一个个改变量；组件也很丰富，官方文档里列的有80+，手写签名、图片压缩上传、乡镇地址级联选择带拼音首字母筛选这些下沉常用功能，它都有现成的，而且乡镇地址级联选择的数据源可以一键下载最新的国家民政部数据；性能方面，测千元机的时候，商品列表加载到第200条才开始轻微掉帧，手写签名打开的时候几乎没有卡顿；生态适配也全，支持微信/支付宝/百度/抖音小程序H5嵌包，还支持鸿蒙的WebView，官方也宣布了明年会支持官方跨端语法转译工具；社区生态虽然不如Vant 4成熟，但京东有专门的技术团队维护，更新频率稳定（大概每两周更新一次小版本，每两个月更新一次大版本），有问题去官方的GitHub仓库提Issue，基本都能在24小时内得到回复。
缺点嘛，也有几个：一是默认的组件动画比较简单，要是想要更炫酷的动画，得自己开发；二是官方文档里的demo虽然有针对下沉场景的，但数量不是很多，只有10+；三是它在GitHub上的Star数不如Vant 4多，大概只有Vant 4的1/5，但这两年增长很快，上个月我看的时候还是12K+，这个月就已经13K+了。&lt;/p&gt;
&lt;h2&gt;选好组件库之后，还要做哪些优化才能更适配下沉市场？&lt;/h2&gt;
&lt;p&gt;选对组件库只是第一步,还要做一些额外的优化才能更稳：
首先是图片优化，下沉市场的网络环境可能不太好，很多地方用的还是4G甚至3G，所以图片一定要压缩——可以用NutUI自带的图片压缩上传组件，也可以用第三方的图片压缩工具，比如TinyPNG，但TinyPNG是在线的，压缩速度可能慢一些，而且有免费额度限制，所以推荐用NutUI自带的；还要用WebP格式的图片，WebP格式的图片比JPG格式的小30%-50%，加载速度更快，现在微信/支付宝/百度/抖音小程序和主流的手机浏览器都支持WebP格式的图片；最后还要用懒加载，NutUI自带的图片组件就有懒加载功能，不用自己开发。
然后是字体优化，下沉市场的用户可能不太在乎字体好不好看，更在乎能不能看清，所以可以把默认的字体换成系统默认的“PingFang SC”“Microsoft YaHei”“SimHei”，不要用自定义字体，自定义字体会增加加载时间；还要把字号再调大一些，比如正文调到15px，标题调到17px，按钮调到18px；行间距也要再调大一些，比如正文调到1.7，标题调到1.9。
还有就是组件的按需引入，不要一次性引入所有的组件，这样会增加打包体积，NutUI自带了按需引入的工具，比如Vite插件、Webpack插件，只需要在配置文件里简单配置一下就可以了；要是用的是Vite，推荐用Vite插件，因为Vite插件的速度更快。
最后是网络优化，可以用HTTP/2或者HTTP/3协议，这两个协议比HTTP/1.1协议的加载速度更快；还要用CDN加速，把静态资源（比如图片、CSS、JS）放到CDN上，这样用户可以从离自己最近的服务器下载静态资源，加载速度更快；还要用离线缓存，比如Service Worker，这样用户第一次打开应用之后，第二次打开应用的时候可以直接从本地缓存里加载静态资源，不用再从服务器下载，加载速度会更快，而且即使没有网络，也能打开应用的部分页面。&lt;/p&gt;
&lt;p&gt;就是我今天要分享的内容,希望能帮到你，要是你还有其他关于vue3 移动端组件库的问题，可以在评论区留言，我会尽量回复你。&lt;/p&gt;</description><pubDate>Fri, 19 Jun 2026 16:06:43 +0800</pubDate></item><item><title>Vue3项目里怎么正确引入和使用ECharts？有没有避坑指南？</title><link>https://www.codeqd.com/post/20260621855.html</link><description>&lt;p&gt;最近身边刚转Vue3的前端朋友全在问ECharts的问题：一会儿说页面跳转回来图表没了，一会儿说自适应效果失效，一会儿更离谱——连基础的CDN引入都报错找不到模块，刚好这段时间我刚重构完一个数据可视化后台，踩了一堆坑又填得差不多，索性把这些实操经验整理成大家能直接抄的问答,从入门配置到进阶优化都覆盖到。&lt;/p&gt;
&lt;h2&gt;入门必看：Vue3有哪些引入ECharts的方式？&lt;/h2&gt;
&lt;p&gt;最常用的是三种：CDN引入、npm/yarn/pnpm全量引入、按需引入，不同场景选不同的就行,没必要跟风用最高大上的。&lt;/p&gt;
&lt;p&gt;先说说&lt;strong&gt;CDN引入&lt;/strong&gt;，这个适合快速做原型、写小demo或者临时给静态页加个图的情况，比如你刚学Vue3，想先看个效果练手，不用搭复杂的打包工具流程，具体步骤很简单：首先在index.html的head标签里引入Vue3的CDN和ECharts的CDN，ECharts记得选最新的稳定版；然后在Vue的script setup标签里把echarts挂载到window上用，不过注意要写在onMounted生命周期里，因为DOM元素得先渲染出来才能拿到宽高初始化图表，这里有个小细节：如果你的index.html是用Vite生成的，记得在script标签的type设为&quot;module&quot;的情况下，要通过window对象访问非ES模块的CDN资源，直接import会报错,这个很多新手都会踩。&lt;/p&gt;
&lt;p&gt;然后是&lt;strong&gt;全量npm引入&lt;/strong&gt;，适合中大型项目里图表类型多、但不用纠结那几兆打包体积（比如企业内网系统、PC端后台这种对首屏加载速度要求不极致的），步骤也不麻烦：先在终端里敲npm install echarts安装依赖；然后在组件里直接import * as echarts from &#039;echarts&#039;，同样要在onMounted里拿到DOM元素初始化，onUnmounted里销毁实例，不然会内存泄漏，全量引入的好处是不用管每个图表用了哪些组件，直接复制ECharts官网的option就能跑，缺点是打包后会多4-5MB的体积，Vite还好有tree-shaking，但webpack旧版本（比如4之前）可能不太好。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;按需引入&lt;/strong&gt;，这是现在主流推荐的，不管大小项目用这个打包体积都会小很多，特别是移动端H5项目，能省不少带宽，按需引入有两种写法：一种是手动按需，一种是用Vite的插件自动按需，手动的话比较灵活但有点麻烦，自动的话省心省力，手动按需的步骤是：先安装echarts，然后引入echarts/core，引入需要的图表类型（比如BarChart、LineChart、PieChart），引入需要的组件（比如TitleComponent、TooltipComponent、LegendComponent、GridComponent、DatasetComponent），最后调用echarts.use()把它们注册进去；自动按需的话，Vite项目可以用vite-plugin-echarts这个插件，先npm install vite-plugin-echarts -D，然后在vite.config.js里配置plugins，之后直接在组件里import * as echarts from &#039;echarts/core&#039;，再单独引需要的图表和组件就行？不对不对，等下自动按需的插件其实是可以帮你解析option自动引入的？哦对，这个插件确实有自动解析的功能，但有时候解析不太准，特别是自定义图表样式的时候，所以建议还是手动注册常用的组件和图表类型，剩下的偶尔用的再临时加,这样既灵活又不容易出错。&lt;/p&gt;
&lt;h2&gt;踩坑第一弹：页面跳转/组件销毁后图表内存泄漏怎么办？&lt;/h2&gt;
&lt;p&gt;这个问题我一开始也没注意，后来项目里图表页面开多了，再切回来发现浏览器直接卡成PPT，一看任务管理器内存占用直接飙升到2GB多，后来查了下才知道，ECharts的实例如果在组件销毁的时候不手动清除，它内部的定时器、事件监听器都会一直占用内存,时间久了甚至会导致浏览器崩溃。&lt;/p&gt;
&lt;p&gt;那怎么解决呢？其实很简单，只需要记住两点：一是在组件销毁的生命周期里调用echartsInstance.dispose()方法销毁实例；二是如果给图表绑定了resize事件，记得在销毁前把这个事件监听器也移除掉，不然监听器还会一直挂在window上，具体的代码逻辑大概是这样的：先在script setup里用ref定义一个chartInstance变量，初始值设为null；然后在onMounted里拿到DOM元素初始化，赋值给chartInstance；接着如果要加自适应的话，用window.addEventListener绑定resize事件，事件函数里调用chartInstance.resize()；最后在onUnmounted里先判断chartInstance有没有值，有的话先window.removeEventListener移除resize事件，再调用chartInstance.dispose()把实例设为null，这里还有个小技巧：如果你用的是Vue3的组合式API，可以把这些初始化、自适应、销毁的逻辑封装成一个自定义Hook，比如useECharts，这样每个图表组件都能直接复用，不用重复写代码,也不容易漏写销毁逻辑。&lt;/p&gt;
&lt;h2&gt;踩坑第二弹：ECharts在Vue3的响应式数据里更新option后图表不渲染怎么办？&lt;/h2&gt;
&lt;p&gt;这个问题也是转Vue3的开发者遇到最多的，因为Vue2里直接把option放在data里，修改某个属性就能触发视图更新，但Vue3里情况不一样了，ECharts的实例初始化后，不会自动监听option的变化，你得手动调用echartsInstance.setOption()方法来更新图表；如果你把option直接放在ref或reactive里，修改深层属性的时候可能会触发Vue3的响应式更新，但这个更新对ECharts没用，反而会导致重复渲染；如果option里有数组或对象的引用没有变，只是内容变了，setOption()的第二个参数要设为true，或者使用替换整个option的方式来更新，不然ECharts可能会合并旧的option,导致显示的图表不对。&lt;/p&gt;
&lt;p&gt;那正确的更新方式是什么呢？我总结了三种：第一种是&lt;strong&gt;替换整个option&lt;/strong&gt;，这种最简单也最不容易出错，不管修改什么属性，直接重新生成一个新的option对象传给setOption()就行，不过适合数据量不大、option结构简单的情况；第二种是&lt;strong&gt;按需修改option的属性，然后调用setOption()&lt;/strong&gt;，适合数据量大、option结构复杂的情况，不过修改的时候要注意数组或对象的引用，最好用Vue3的响应式工具函数（比如toRaw）先把ref或reactive里的数据转成普通对象，修改后再传进去，不然可能会有性能问题；第三种是&lt;strong&gt;使用ECharts的setOption()的第三个参数notMerge&lt;/strong&gt;，设为true的话会完全替换旧的option，设为false的话会合并，这个参数和第二个参数replaceMerge不一样，replaceMerge是用来指定合并策略的，比如legend的data如果replaceMerge设为[&#039;legend&#039;]，就会替换旧的legend.data，而不是合并，还有个更重要的点：不要把option放在reactive里，因为Vue3的Proxy代理会对ECharts内部的操作产生干扰，最好放在普通的ref里，或者直接定义一个普通的变量，只在需要的时候调用setOption()更新。&lt;/p&gt;
&lt;h2&gt;踩坑第三弹：ECharts图表的自适应效果失效怎么办？&lt;/h2&gt;
&lt;p&gt;自适应效果失效一般有三种原因：一是&lt;strong&gt;DOM元素的宽高没有设置好&lt;/strong&gt;，ECharts的初始化需要一个有明确宽高的DOM元素，不然初始化出来的图表是0x0的；二是&lt;strong&gt;resize事件没有正确绑定或移除&lt;/strong&gt;，刚才内存泄漏的问题里提到过，要是在组件销毁前没移除resize事件，下次再初始化图表的时候可能会绑定多个，导致图表反复resize，影响性能；三是&lt;strong&gt;容器的宽高变化是由Vue的响应式数据控制的，但没有监听这个变化调用resize()&lt;/strong&gt;，比如你用v-if切换图表的显示隐藏，或者用v-model控制容器的宽度，这时候window的resize事件不会触发,得自己监听容器的宽高变化。&lt;/p&gt;
&lt;p&gt;先说说第一种原因的解决办法：容器的宽高一定要用CSS明确设置，比如width: 100%; height: 500px; 或者用flex布局，给容器设置flex: 1; 但注意flex布局的话，父元素也要有明确的高度，不然子元素的flex: 1; 是没用的，还有个小细节：如果容器是用v-if渲染的，那得在DOM元素完全渲染出来之后再初始化图表，比如用nextTick包裹onMounted里的初始化代码，或者用watch监听v-if控制的变量,变量变为true的时候再初始化。&lt;/p&gt;
&lt;p&gt;第二种原因的解决办法刚才已经讲过了，就是在组件销毁的生命周期里先移除resize事件，再销毁实例，这里还有个优化点：可以给resize事件加个防抖，比如延迟300ms再调用chartInstance.resize()，这样在窗口快速拖动的时候不会频繁触发resize,提升性能。&lt;/p&gt;
&lt;p&gt;第三种原因的解决办法：可以用Vue的watchEffect监听容器的宽高变化，或者用ResizeObserver API直接监听容器的尺寸变化，ResizeObserver API现在大部分主流浏览器都支持了，兼容性很好，比watchEffect更准确，因为它只监听容器本身的尺寸变化，不会监听其他无关的响应式数据，具体的代码逻辑大概是这样的：先在onMounted里拿到DOM元素，创建一个ResizeObserver实例，实例的回调函数里调用chartInstance.resize()，然后调用observe()方法监听DOM元素；最后在onUnmounted里先调用disconnect()方法断开ResizeObserver的监听,再销毁实例。&lt;/p&gt;
&lt;h2&gt;进阶优化：Vue3项目里怎么提升ECharts的渲染性能？&lt;/h2&gt;
&lt;p&gt;刚才讲的按需引入其实就是一种性能优化，除此之外还有几种常用的方法：一是&lt;strong&gt;使用ECharts的渲染模式&lt;/strong&gt;，ECharts有两种渲染模式：canvas和svg，默认是canvas，适合渲染大数据量的图表（比如折线图有几万条数据），svg适合渲染小数据量、需要缩放不失真的图表（比如仪表盘、地图），可以根据具体的场景选择；二是&lt;strong&gt;开启ECharts的大数据量渲染优化&lt;/strong&gt;，比如给line chart的series设置sampling: &#039;lttb&#039;，这个算法会在数据量很大的时候自动采样，保留折线的趋势，减少渲染的点；三是&lt;strong&gt;避免频繁更新option&lt;/strong&gt;，如果数据更新频率很高（比如实时数据），可以先把数据缓存起来，每隔一段时间（比如1s）再统一更新一次option；四是&lt;strong&gt;使用虚拟DOM？不对不对，ECharts本身已经优化过渲染了，不需要用虚拟DOM，反而会增加性能开销；五是&lt;/strong&gt;复用ECharts的实例**，比如在同一个页面里有多个图表，可以用一个DOM元素通过切换option的方式来复用实例,不过这个适合图表切换不频繁的情况。&lt;/p&gt;
&lt;p&gt;哦对了，还有个进阶的操作：如果你的项目里有多个图表组件，可以把ECharts的实例放在provide里，然后在子组件里inject，这样可以避免重复创建实例？不对不对，每个图表需要单独的DOM元素，所以不能共享实例，但是可以共享一些公共的配置，比如theme、locale,这样可以减少重复代码。&lt;/p&gt;
&lt;p&gt;要是你用的是ECharts 5.0以上的版本，还可以试试ECharts的dataset组件，用它来管理数据会比直接把数据放在series里更清晰，也更容易做数据的筛选和联动，比如你有一个折线图和一个饼图，数据来源是同一个接口，就可以把数据放在dataset里，然后两个图表分别引用dataset的数据，这样更新数据的时候只需要更新一次dataset就行,不用分别更新两个series。&lt;/p&gt;
&lt;p&gt;最后再提一下ECharts的主题，要是你的项目里有统一的UI风格，可以自己定义一个主题，或者用ECharts官网的主题编辑器生成一个主题，然后在初始化图表的时候传入主题名称，这样可以统一所有图表的样式,不用每个图表都写一堆重复的option配置。&lt;/p&gt;
&lt;p&gt;Vue3引入和使用ECharts其实不难，只要记住正确的引入方式、生命周期的处理、option的更新方法、自适应的实现，再加上一些性能优化的小技巧，就能做出效果很好的数据可视化图表了，要是还有其他问题，可以留言问我,我会尽量解答的。&lt;/p&gt;</description><pubDate>Fri, 19 Jun 2026 10:06:38 +0800</pubDate></item><item><title>Vue3页面跳转到底该怎么选？从入门到进阶避坑指南</title><link>https://www.codeqd.com/post/20260621854.html</link><description>&lt;p&gt;刚接触Vue3的新手经常会在打开新页面或者切换视图时犯愁：一会儿看到router-link，一会儿碰到push/replace，还有原生的window.location甚至a标签？到底哪种才是“正确打开方式”？其实这些跳转方式各有用途，选错不仅可能拖慢页面加载，还会触发不必要的组件重新渲染，今天咱们就从实际场景出发，把这几种方式掰扯清楚，连避坑细节也一起说透。&lt;/p&gt;
&lt;h2&gt;先理清大前提：你用的是单页应用还是多页应用&lt;/h2&gt;
&lt;p&gt;很多新手跳坑的第一步,就是没搞清楚自己的项目是SPA（单页应用）还是MPA（多页应用），目前主流的Vue3项目，不管是用Vite还是Vue CLI默认创建的，都是单页应用——整个项目只有一个HTML入口文件，页面切换其实是在这个入口里动态替换DOM组件，浏览器不会重新加载整个页面，这也是Vue路由（Vue Router）存在的核心意义。&lt;/p&gt;
&lt;p&gt;那什么时候会出现多页应用？比如你有一个Vue3写的后台管理系统，旁边挂了一个完全独立的用原生HTML做的帮助文档，或者某个模块需要和其他技术栈（比如React或者WordPress）的页面无缝对接，这时候才会用到跨页面的原生跳转。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一个核心判断标准就是：目标页面是不是当前Vue3项目里注册过的路由？&lt;/strong&gt; 如果是，优先用Vue Router的API或者组件；如果不是，才考虑原生跳转。&lt;/p&gt;
&lt;h2&gt;Vue3单页应用内的首选：Vue Router&lt;/h2&gt;
&lt;p&gt;既然是单页应用,Vue Router肯定是官方钦定、最适配的跳转方案，它有两种常用的实现方式：声明式跳转（用router-link组件）和编程式跳转（用useRouter组合式API的push/replace方法），下面分别说。&lt;/p&gt;
&lt;h3&gt;什么时候用声明式的router-link&lt;/h3&gt;
&lt;p&gt;当跳转逻辑是“用户点击某个元素，然后直接跳转”时，就用router-link，比如导航栏的菜单按钮、文章列表的标题链接、商品卡片的跳转入口，这些场景下不需要写额外的JS逻辑，用router-link既简单又能自动处理很多细节。&lt;/p&gt;
&lt;p&gt;router-link本质上是对原生a标签的封装，它会自动根据你的路由模式（hash或者history）生成对应的href属性，浏览器鼠标悬停时也会显示正确的链接地址，对SEO（搜索引擎优化）和无障碍访问都很友好——这一点比纯用JS绑定click事件调用push要好很多，很多新手容易忽略这个SEO细节。&lt;/p&gt;
&lt;p&gt;不过用router-link也有几个避坑点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;别随便加target=&quot;_blank&quot;：如果非要打开新标签页，记得加上rel=&quot;noopener noreferrer&quot;，防止新页面通过window.opener访问原页面的DOM，带来安全隐患；&lt;/li&gt;
&lt;li&gt;嵌套路由要注意active-class的匹配：比如你有一个父路由“/user”，子路由“/user/profile”，当访问子路由时，父路由的router-link也会默认加上router-link-active这个高亮类，如果不想让父路由跟着亮，可以改成router-link-exact-active，或者自定义exact属性；&lt;/li&gt;
&lt;li&gt;传递参数时尽量不用字符串拼接：虽然可以写成&lt;code&gt;&amp;lt;router-link to=&quot;/user/123&quot;&amp;gt;&amp;lt;/router-link&amp;gt;&lt;/code&gt;，但更推荐用对象的方式，比如&lt;code&gt;&amp;lt;router-link :to=&quot;{ name: &#039;User&#039;, params: { id: 123 } }&quot;&amp;gt;&amp;lt;/router-link&amp;gt;&lt;/code&gt;，这样如果以后修改了路由的path，只需要改路由配置文件就行，不需要去改所有用到这个链接的地方，维护成本低很多。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;什么时候用编程式的useRouter&lt;/h3&gt;
&lt;p&gt;如果跳转逻辑不是“点了就走”，而是需要先做一些前置操作，那就要用useRouter了，比如登录之后跳转到首页、提交表单成功后跳转到结果页、根据用户权限判断是否允许跳转（这时候还要配合路由守卫）、倒计时结束后自动跳转等等。&lt;/p&gt;
&lt;p&gt;useRouter是Vue3组合式API里的写法,替代了Vue2里的this.$router，使用之前记得先从vue-router里引入：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { useRouter } from &amp;#39;vue-router&amp;#39;
export default {
  setup() {
    const router = useRouter()
    // 跳转逻辑在这里写
  }
}&lt;/pre&gt;
&lt;p&gt;编程式跳转主要有两个方法：push和replace，很多人搞不清它们的区别，其实核心就是&lt;strong&gt;浏览器历史记录的处理方式&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;push：会在浏览器的历史记录里新增一条记录，用户点击“返回”按钮时，会回到上一个页面；&lt;/li&gt;
&lt;li&gt;replace：不会新增历史记录，而是直接替换当前页面的记录，用户点击“返回”按钮时，会回到上上一个页面。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;那什么时候用replace？比如登录成功后，肯定不想让用户点返回又回到登录页吧？这时候就用replace；还有填写表单的中间页，比如从A到B再到C，提交C成功后回到A，这时候B到C也可以用replace，这样用户从C点返回直接到A。&lt;/p&gt;
&lt;p&gt;编程式跳转传递参数的方式和router-link类似，也是推荐用对象+name+params，不过这里有个&lt;strong&gt;超级重要的避坑点&lt;/strong&gt;：Vue Router 4.x（也就是Vue3配套的版本）里，params参数只有在跳转到命名路由时才会生效，而且如果用户刷新页面，params参数会丢失！如果需要刷新页面后参数还在，要么用query参数，要么把参数存在localStorage/sessionStorage里，要么用动态路由（也就是path里带/:id这种占位符的路由），比如刚才的例子，如果你把路由配置成：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;const routes = [
  {
    path: &amp;#39;/user/:id&amp;#39;,
    name: &amp;#39;User&amp;#39;,
    component: () =&amp;gt; import(&amp;#39;../views/User.vue&amp;#39;)
  }
]&lt;/pre&gt;
&lt;p&gt;那用params传的id会自动出现在URL里,刷新页面也不会丢；但如果path是‘/user’，没有占位符，刷新后params就没了。&lt;/p&gt;
&lt;p&gt;还有query参数,不管是用push/replace还是router-link，不管跳转到命名路由还是path路由，都可以用，而且会自动拼在URL后面（user?id=123），刷新后也不会丢，适合传递一些非敏感的、可选的参数，比如搜索关键词、分页页码。&lt;/p&gt;
&lt;h2&gt;什么时候用原生跳转方式？&lt;/h2&gt;
&lt;p&gt;刚才说了,当目标页面不是当前Vue3项目里的路由时，就用原生跳转，原生跳转主要有两种：a标签和window.location。&lt;/p&gt;
&lt;h3&gt;什么时候用原生a标签&lt;/h3&gt;
&lt;p&gt;原生a标签的使用场景和router-link有点像，但它是用来跳转外部页面的，比如跳转到百度、跳转到微信的授权页面、跳转到其他技术栈的页面，原生a标签会让浏览器重新加载整个页面，性能会比单页应用的切换慢一点，但跨页面跳转没办法，只能用这个。&lt;/p&gt;
&lt;p&gt;同样,用原生a标签打开新标签页时，也要加rel=&quot;noopener noreferrer&quot;，安全第一。&lt;/p&gt;
&lt;h3&gt;什么时候用window.location&lt;/h3&gt;
&lt;p&gt;和useRouter类似,当跳转外部页面需要前置操作时，就用window.location，比如你需要先统计一下用户点击外部链接的次数，然后再跳转，这时候就可以在JS里写&lt;code&gt;window.location.href = &#039;https://www.baidu.com&#039;&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;window.location还有几个常用的属性和方法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;window.location.href：当前页面的完整URL，赋值可以跳转；&lt;/li&gt;
&lt;li&gt;window.location.replace()：和router.replace类似，替换当前历史记录；&lt;/li&gt;
&lt;li&gt;window.location.reload()：刷新当前页面，加true参数可以强制刷新（不使用缓存）；&lt;/li&gt;
&lt;li&gt;window.location.search：获取URL里的query参数部分（keyword=vue3）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;进阶用法：路由守卫配合跳转&lt;/h2&gt;
&lt;p&gt;刚才提了一句路由守卫,其实它也是页面跳转流程里的一部分，甚至可以说是进阶开发必备的技能，路由守卫的作用就是在跳转发生前、跳转发生中、跳转发生后，做一些拦截或者处理，比如判断用户有没有登录、有没有权限访问这个页面、页面跳转时要不要显示加载动画、要不要保存当前页面的滚动位置等等。&lt;/p&gt;
&lt;p&gt;Vue Router 4.x里的路由守卫主要有三种：全局前置守卫、路由独享守卫、组件内守卫，咱们举个最常用的全局前置守卫的例子，比如判断用户有没有登录：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// router/index.js
import { createRouter, createWebHistory } from &amp;#39;vue-router&amp;#39;
import Home from &amp;#39;../views/Home.vue&amp;#39;
import Login from &amp;#39;../views/Login.vue&amp;#39;
const routes = [
  { path: &amp;#39;/&amp;#39;, name: &amp;#39;Home&amp;#39;, component: Home, meta: { requiresAuth: true } },
  { path: &amp;#39;/login&amp;#39;, name: &amp;#39;Login&amp;#39;, component: Login }
]
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})
// 全局前置守卫
router.beforeEach((to, from, next) =&amp;gt; {
  // 从localStorage里获取token，模拟登录状态
  const token = localStorage.getItem(&amp;#39;token&amp;#39;)
  // 如果目标路由需要登录，且没有token，就跳转到登录页
  if (to.meta.requiresAuth &amp;amp;&amp;amp; !token) {
    next({ name: &amp;#39;Login&amp;#39; })
  } else {
    // 否则允许跳转
    next()
  }
})
export default router&lt;/pre&gt;
&lt;p&gt;这里的meta是路由元信息,是我们自定义的，可以用来存储路由的额外信息，比如是否需要登录、页面标题、权限标识等等，非常实用。&lt;/p&gt;
&lt;h2&gt;一张思维导图就能搞定的选择逻辑&lt;/h2&gt;
&lt;p&gt;最后咱们把刚才说的内容浓缩一下,方便大家快速判断：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先看目标页面是不是当前Vue3项目的路由？&lt;ul&gt;
&lt;li&gt;是 → 用Vue Router&lt;ul&gt;
&lt;li&gt;点击直接跳转,不需要前置逻辑 → 用router-link（加对象+name+params，加rel=&quot;_blank&quot;的话别忘了noopener）&lt;/li&gt;
&lt;li&gt;需要前置逻辑（登录、提交表单、倒计时） → 用useRouter&lt;ul&gt;
&lt;li&gt;需要新增历史记录,用户能返回 → push&lt;/li&gt;
&lt;li&gt;不需要新增历史记录,用户不能返回 → replace&lt;/li&gt;
&lt;li&gt;刷新后参数不丢 → 用动态路由+params 或者 query&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;不是 → 用原生跳转&lt;ul&gt;
&lt;li&gt;点击直接跳转 → 原生a标签（别忘noopener）&lt;/li&gt;
&lt;li&gt;需要前置逻辑 → window.location&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样是不是就清晰多了？新手刚学的时候别贪多，先把Vue Router的两种常用方式搞明白，把params和query的区别搞清楚，把刷新不丢参数的坑填上，然后再慢慢学路由守卫这些进阶内容，循序渐进，肯定能掌握好Vue3的页面跳转。&lt;/p&gt;</description><pubDate>Thu, 18 Jun 2026 20:12:05 +0800</pubDate></item><item><title>Vue3项目里做右键菜单是直接搜插件好，还是自己手写封装合适？有哪些避坑点？</title><link>https://www.codeqd.com/post/20260621853.html</link><description>&lt;p&gt;这个问题其实是很多Vue3前端开发者刚接触右键菜单需求时的共性纠结——不管是后台管理系统的操作菜单、编辑器里的快捷右键，还是电商项目里的商品缩略图右键操作，好像随便搜npm仓库能跳出一堆现成的Vue3右键菜单插件，但要么样式改起来巨麻烦，要么功能阉割得厉害，要么文档写得像天书；但自己从零写的话，又怕重复造轮子，还要处理定位偏移、层级覆盖、点击空白关闭这些细节坑。&lt;/p&gt;
&lt;p&gt;先给个明确的结论：&lt;strong&gt;不是非此即彼的选择，要根据你的项目具体需求和团队能力来定&lt;/strong&gt;，接下来我会从「什么时候直接用插件」「什么时候必须自己手写封装」「手写/用插件都要避的通用坑」这三个部分展开，最后还会给一个极简版的、支持基础自定义的Vue3组合式API封装案例,方便大家上手。&lt;/p&gt;
&lt;h2&gt;先说说什么时候直接用现成的Vue3右键菜单插件更划算&lt;/h2&gt;
&lt;p&gt;如果你的项目没有特别强的品牌定制需求，或者只是要做一两个页面临时加的、功能非常基础的右键菜单，那用插件绝对是最快、最省精力的选择，基础功能一般指什么？就是支持右键点击触发、有子菜单、点击菜单项执行对应回调、点击空白或按Esc关闭这些,很多成熟的插件都能完美覆盖。&lt;/p&gt;
&lt;p&gt;那怎么挑靠谱的插件呢？这里有几个小技巧，比你直接搜“Vue3右键菜单”然后选下载量最高的第一个还要准——因为有时候下载量高可能是老版本Vue2插件蹭了关键词，或者是某个博主随便封装的半成品火了一波但没人维护了，第一个是看npm包的发布时间和更新频率，最好是近三个月有更新的，说明开发者还在跟进Vue3的生态（比如最近的Vue3.4有一些小的响应式优化和自定义指令的小改动，更新及时的插件兼容性会更好）；第二个是看GitHub仓库的Issue和Star数之比，如果Star数很高但Issue堆了几百个没人处理，那大概率也是个“死插件”；第三个是先看Demo再拉代码到本地试，不要只看文档里的截图和API说明——有些插件Demo里的效果很好，但你自己改个主题色、改个菜单项的间距就会发现它的CSS用了!important或者嵌套得很深，根本没法改；第四个是尽量选TypeScript支持完善的，如果你用TypeScript开发,选带完整类型定义的插件能省很多排查类型错误的时间。&lt;/p&gt;
&lt;h2&gt;再聊聊什么时候必须自己手写封装Vue3右键菜单组件库&lt;/h2&gt;
&lt;p&gt;什么时候手写封装更有价值？主要有三种情况：第一种是你的项目有非常强的视觉统一性要求，比如企业级后台管理系统的操作栏、右键菜单、弹窗都是统一的蓝色系、圆角、阴影，用现成的插件改样式要覆盖几百行CSS，还不如自己写；第二种是你的右键菜单需要一些插件没有的复杂功能，比如支持拖拽菜单项调整顺序、支持菜单项的图标自定义（用自己项目的IconFont或者SVG组件，不是插件自带的那几个固定图标）、支持右键菜单的多场景复用（比如在表格的行上右键是“编辑、删除、导出”，在表格的列头上右键是“隐藏列、冻结列、排序设置”，在编辑器的文本上右键是“复制、粘贴、查找替换”，还要能动态根据当前点击的元素传参）；第三种是你的团队对性能有极致要求，很多现成的插件为了兼容性会加很多冗余的代码，比如同时支持Vue2和Vue3，或者加了很多你根本用不到的动画、拖拽功能，自己手写的话可以完全按需加载，代码量小,性能也更好。&lt;/p&gt;
&lt;p&gt;其实手写封装Vue3右键菜单并没有你想象中的那么难，核心逻辑无非就是这几步：第一步是监听右键点击事件（contextmenu），阻止默认的浏览器右键菜单弹出；第二步是获取右键点击的位置（clientX和clientY），然后根据窗口的大小调整右键菜单的位置，防止它超出可视区域；第三步是监听点击空白区域（mousedown或者click）和按Esc键（keydown）的事件，关闭右键菜单；第四步是封装成通用的组件，支持自定义菜单项、图标、回调函数、样式等。&lt;/p&gt;
&lt;h2&gt;不管是手写还是用插件，都要注意这些避坑点&lt;/h2&gt;
&lt;h3&gt;避坑点一：右键菜单的位置偏移问题一定要处理&lt;/h3&gt;
&lt;p&gt;很多人刚开始做右键菜单的时候，直接把clientX和clientY赋值给菜单的top和left，结果菜单会超出可视区域——比如你在屏幕最右边右键，菜单就会跑到屏幕外面去，根本看不见；在屏幕最下面右键，菜单的下半部分就会被遮掉，那怎么处理呢？其实很简单，就是在设置菜单的位置之前，先获取当前窗口的可视区域大小（window.innerWidth和window.innerHeight），再获取右键菜单的自身大小（可以用ref获取DOM元素，然后用offsetWidth和offsetHeight，或者用getBoundingClientRect()方法，后者更准确，因为它会考虑CSS的transform和滚动条的影响），然后判断一下：如果clientX + menuWidth &amp;gt; window.innerWidth，就把left设置成clientX - menuWidth；如果clientY + menuHeight &amp;gt; window.innerHeight，就把top设置成clientY - menuHeight；如果同时满足两个条件，就同时调整top和left，还要注意如果右键点击的元素在一个有滚动条的容器里，那还要加上容器的scrollTop和scrollLeft,不然菜单的位置会错。&lt;/p&gt;
&lt;h3&gt;避坑点二：右键菜单的层级覆盖问题要注意&lt;/h3&gt;
&lt;p&gt;这个问题也是非常常见的——比如你的项目里有弹窗、有下拉菜单、有遮罩层，结果右键菜单弹出来之后被这些元素遮住了，根本点不到，那怎么避免呢？首先是把右键菜单的挂载位置放在body的最下面，而不是放在某个组件的内部——因为如果放在组件内部，组件的父元素如果设置了overflow: hidden或者z-index比较小，那右键菜单肯定会被遮住；其次是给右键菜单设置一个足够大的z-index，比如设置成99999，但不要设置成比项目里的全局遮罩层还大的数字，不然全局遮罩层弹出来的时候，右键菜单还在上面，不符合用户的操作习惯；最后是如果项目里用了Element Plus、Ant Design Vue这些UI组件库，最好查一下它们的全局遮罩层的z-index是多少，比如Element Plus的全局遮罩层z-index是2000，那你的右键菜单设置成1999或者2001都可以——如果设置成1999，那弹窗弹出来的时候右键菜单会被遮住，符合逻辑；如果设置成2001，那弹窗弹出来的时候右键菜单还在上面,需要自己处理。&lt;/p&gt;
&lt;h3&gt;避坑点三：动态菜单项和参数传递的问题要做好&lt;/h3&gt;
&lt;p&gt;不管是手写还是用插件，动态菜单项和参数传递都是一个高频需求——比如你在表格的不同行上右键，菜单项可能是一样的，但回调函数里需要获取当前行的id、name这些数据；再比如你在编辑器里选中文本的时候右键，会多出“复制选中内容”“剪切选中内容”这些菜单项，没选中文本的时候这些菜单项是隐藏的，那怎么处理动态菜单项呢？手写的话，可以用v-for循环渲染菜单项，然后用v-if或者v-show控制菜单项的显示和隐藏；用插件的话，一般会支持一个dynamic或者items的属性，你可以传一个computed属性过去，根据当前的状态动态返回菜单项数组，那怎么传递参数呢？手写的话，可以在监听contextmenu事件的时候，把需要传递的参数存到ref或者reactive里，然后在菜单项的点击回调函数里调用；用插件的话，一般会支持在触发右键菜单的元素上绑定一个data-*属性，或者在右键菜单的组件上绑定一个props属性,然后在回调函数里获取。&lt;/p&gt;
&lt;h3&gt;避坑点四：多个右键菜单共存的问题要解决&lt;/h3&gt;
&lt;p&gt;很多项目里可能会有多个右键菜单——比如刚才说的表格行右键、表格列头右键、编辑器文本右键，那怎么区分这些右键菜单，并且只显示当前点击元素对应的右键菜单呢？手写的话，可以给每个触发右键菜单的元素加一个唯一的标识（比如data-menu-type=&quot;table-row&quot;），然后在监听contextmenu事件的时候，获取这个标识，再显示对应的右键菜单；用插件的话，一般会支持一个target或者selector的属性，你可以给不同的右键菜单绑定不同的选择器，还要注意多个右键菜单之间的互斥性——比如显示了表格行右键菜单之后，点击表格列头，要先关闭表格行右键菜单，再显示表格列头右键菜单,不能两个同时显示。&lt;/p&gt;
&lt;h3&gt;避坑点五：移动端的兼容性问题要考虑&lt;/h3&gt;
&lt;p&gt;虽然右键菜单主要是用在PC端的，但现在很多项目都是PC端和移动端共用一套代码，所以最好也考虑一下移动端的兼容性，那移动端怎么触发右键菜单呢？一般是长按元素——所以你需要在监听contextmenu事件的同时，也监听touchstart和touchend事件，计算一下长按的时间，如果超过了500ms或者1000ms，就触发自定义的右键菜单，同时阻止默认的移动端长按菜单弹出（比如iOS上的放大镜和复制粘贴菜单，Android上的复制粘贴菜单），还要注意移动端的屏幕比较小，所以右键菜单的大小要自适应，或者用底部弹窗的形式代替,体验会更好。&lt;/p&gt;
&lt;h2&gt;给大家分享一个极简版的、支持基础自定义的Vue3组合式API封装案例&lt;/h2&gt;
&lt;p&gt;既然说了手写封装不难，那我就给大家分享一个我自己在项目里常用的极简版Vue3右键菜单组合式API封装案例，代码量很小，只有不到200行，支持自定义菜单项、图标、回调函数、位置偏移处理、点击空白关闭、按Esc关闭，大家可以直接复制到自己的项目里用,然后根据自己的需求修改。&lt;/p&gt;
&lt;p&gt;我们新建一个useContextMenu.ts的文件,用来封装右键菜单的核心逻辑：&lt;/p&gt;
&lt;pre class=&quot;brush:typescript;toolbar:false&quot;&gt;import { ref, onMounted, onUnmounted, type Ref } from &amp;#39;vue&amp;#39;
interface MenuItem {
  label: string
  icon?: string | (() =&amp;gt; JSX.Element)
  disabled?: boolean
  divided?: boolean
  onClick?: (data?: any) =&amp;gt; void
  children?: MenuItem[]
}
export function useContextMenu() {
  // 右键菜单的显示状态
  const visible = ref(false)
  // 右键菜单的位置
  const position = ref({ top: 0, left: 0 })
  // 右键菜单的菜单项
  const menuItems = ref&amp;lt;MenuItem[]&amp;gt;([])
  // 当前点击元素传递的参数
  const menuData = ref&amp;lt;any&amp;gt;(null)
  // 右键菜单的DOM元素
  const menuRef: Ref&amp;lt;HTMLElement | null&amp;gt; = ref(null)
  // 右键点击的目标元素
  let targetElement: HTMLElement | null = null
  // 显示右键菜单
  const show = (e: MouseEvent | TouchEvent, items: MenuItem[], data?: any) =&amp;gt; {
    // 阻止默认的浏览器右键菜单或移动端长按菜单
    e.preventDefault()
    // 阻止事件冒泡，避免触发父元素的右键菜单
    e.stopPropagation()
    // 获取点击位置
    let clientX: number, clientY: number
    if (&amp;#39;touches&amp;#39; in e) {
      // 移动端长按
      clientX = e.touches[0].clientX
      clientY = e.touches[0].clientY
    } else {
      // PC端右键
      clientX = e.clientX
      clientY = e.clientY
    }
    // 保存目标元素、菜单项和参数
    targetElement = e.target as HTMLElement
    menuItems.value = items
    menuData.value = data
    // 先显示菜单，再获取菜单的大小（因为如果菜单隐藏的话，offsetWidth和offsetHeight都是0）
    visible.value = true
    // 这里用requestAnimationFrame是为了确保DOM已经更新，菜单已经显示出来了
    requestAnimationFrame(() =&amp;gt; {
      if (!menuRef.value) return
      const menuWidth = menuRef.value.offsetWidth
      const menuHeight = menuRef.value.offsetHeight
      const windowWidth = window.innerWidth
      const windowHeight = window.innerHeight
      // 调整位置，防止超出可视区域
      let left = clientX
      let top = clientY
      if (left + menuWidth &amp;gt; windowWidth) {
        left = clientX - menuWidth
      }
      if (top + menuHeight &amp;gt; windowHeight) {
        top = clientY - menuHeight
      }
      position.value = { top, left }
    })
  }
  // 关闭右键菜单
  const hide = () =&amp;gt; {
    visible.value = false
    targetElement = null
    menuData.value = null
  }
  // 点击菜单项
  const handleMenuItemClick = (item: MenuItem) =&amp;gt; {
    if (item.disabled) return
    if (item.onClick) {
      item.onClick(menuData.value)
    }
    hide()
  }
  // 监听点击空白区域
  const handleClickOutside = (e: MouseEvent | TouchEvent) =&amp;gt; {
    if (!visible.value) return
    if (!menuRef.value) return
    if (menuRef.value.contains(e.target as Node)) return
    if (targetElement?.contains(e.target as Node)) return
    hide()
  }
  // 监听按Esc键
  const handleEscKeydown = (e: KeyboardEvent) =&amp;gt; {
    if (!visible.value) return
    if (e.key === &amp;#39;Escape&amp;#39;) {
      hide()
    }
  }
  // 监听滚动事件
  const handleScroll = () =&amp;gt; {
    if (!visible.value) return
    hide()
  }
  // 组件挂载时添加事件监听
  onMounted(() =&amp;gt; {
    document.addEventListener(&amp;#39;mousedown&amp;#39;, handleClickOutside)
    document.addEventListener(&amp;#39;touchstart&amp;#39;, handleClickOutside)
    document.addEventListener(&amp;#39;keydown&amp;#39;, handleEscKeydown)
    window.addEventListener(&amp;#39;scroll&amp;#39;, handleScroll, true)
  })
  // 组件卸载时移除事件监听
  onUnmounted(() =&amp;gt; {
    document.removeEventListener(&amp;#39;mousedown&amp;#39;, handleClickOutside)
    document.removeEventListener(&amp;#39;touchstart&amp;#39;, handleClickOutside)
    document.removeEventListener(&amp;#39;keydown&amp;#39;, handleEscKeydown)
    window.removeEventListener(&amp;#39;scroll&amp;#39;, handleScroll, true)
  })
  return {
    visible,
    position,
    menuItems,
    menuRef,
    show,
    hide,
    handleMenuItemClick
  }
}&lt;/pre&gt;
&lt;p&gt;我们新建一个ContextMenu.vue的文件,用来封装右键菜单的UI组件：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div
    v-if=&amp;quot;visible&amp;quot;
    ref=&amp;quot;menuRef&amp;quot;
    class=&amp;quot;context-menu&amp;quot;
    :style=&amp;quot;{ top: `${position.top}px`, left: `${position.left}px` }&amp;quot;
  &amp;gt;
    &amp;lt;div v-for=&amp;quot;(item, index) in menuItems&amp;quot; :key=&amp;quot;index&amp;quot; class=&amp;quot;context-menu-item-wrapper&amp;quot;&amp;gt;
      &amp;lt;!-- 分割线 --&amp;gt;
      &amp;lt;div v-if=&amp;quot;item.divided&amp;quot; class=&amp;quot;context-menu-divider&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;
      &amp;lt;!-- 菜单项 --&amp;gt;
      &amp;lt;div
        class=&amp;quot;context-menu-item&amp;quot;
        :class=&amp;quot;{ &amp;#39;context-menu-item--disabled&amp;#39;: item.disabled }&amp;quot;
        @click=&amp;quot;handleMenuItemClick(item)&amp;quot;
      &amp;gt;
        &amp;lt;!-- 图标 --&amp;gt;
        &amp;lt;span v-if=&amp;quot;item.icon&amp;quot; class=&amp;quot;context-menu-item-icon&amp;quot;&amp;gt;
          &amp;lt;component v-if=&amp;quot;typeof item.icon === &amp;#39;function&amp;#39;&amp;quot; :is=&amp;quot;item.icon&amp;quot; /&amp;gt;
          &amp;lt;i v-else :class=&amp;quot;item.icon&amp;quot;&amp;gt;&amp;lt;/i&amp;gt;
        &amp;lt;/span&amp;gt;
        &amp;lt;!-- 文字 --&amp;gt;
        &amp;lt;span class=&amp;quot;context-menu-item-label&amp;quot;&amp;gt;{{ item.label }}&amp;lt;/span&amp;gt;
        &amp;lt;!-- 子菜单箭头（这里暂时不实现子菜单，有需要的可以自己加） --&amp;gt;
        &amp;lt;span v-if=&amp;quot;item.children&amp;quot; class=&amp;quot;context-menu-item-arrow&amp;quot;&amp;gt;▶&amp;lt;/span&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup lang=&amp;quot;ts&amp;quot;&amp;gt;
import { useContextMenu } from &amp;#39;./useContextMenu&amp;#39;
const props = defineProps&amp;lt;{
  // 这里暂时不需要props，所有的逻辑都通过useContextMenu暴露的方法和状态控制
}&amp;gt;()
// 直接暴露useContextMenu的所有状态和方法，方便父组件调用
const contextMenu = useContextMenu()
defineExpose(contextMenu)
&amp;lt;/script&amp;gt;
&amp;lt;style scoped&amp;gt;
.context-menu {
  position: fixed;
  z-index: 9999;
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  padding: 4px 0;
  min-width: 160px;
  user-select: none;
}
.context-menu-item-wrapper {
  width: 100%;
}
.context-menu-divider {
  height: 1px;
  background-color: #eee;
  margin: 4px 0;
}
.context-menu-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
  cursor: pointer;
  font-size: 14px;
  color: #333;
  transition: background-color 0.2s;
}
.context-menu-item:hover {
  background-color: #f5f7fa;
}
.context-menu-item--disabled {
  cursor: not-allowed;
  color: #999;
}
.context-menu-item--disabled:hover {
  background-color: transparent;
}
.context-menu-item-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  font-size: 14px;
}
.context-menu-item-label {
  flex: 1;
}
.context-menu-item-arrow {
  font-size: 12px;
  color: #666;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;我们在父组件里使用这个ContextMenu组件：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;parent-container&amp;quot;&amp;gt;
    &amp;lt;!-- 右键点击这个div触发菜单 --&amp;gt;
    &amp;lt;div
      class=&amp;quot;click-area&amp;quot;
      @contextmenu=&amp;quot;handleRightClick&amp;quot;
      @touchstart=&amp;quot;handleTouchStart&amp;quot;
      @touchend=&amp;quot;handleTouchEnd&amp;quot;
    &amp;gt;
      右键点击我（PC端）或者长按我（移动端）试试
    &amp;lt;/div&amp;gt;
    &amp;lt;!-- 右键菜单组件 --&amp;gt;
    &amp;lt;ContextMenu ref=&amp;quot;contextMenuRef&amp;quot; /&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup lang=&amp;quot;ts&amp;quot;&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
import ContextMenu from &amp;#39;./ContextMenu.vue&amp;#39;
import type { MenuItem } from &amp;#39;./useContextMenu&amp;#39;
// 右键菜单组件的引用
const contextMenuRef = ref&amp;lt;InstanceType&amp;lt;typeof ContextMenu&amp;gt; | null&amp;gt;(null)
// 移动端长按的定时器
let touchTimer: number | null = null
// 菜单项数组
const menuItems: MenuItem[] = [
  {
    label: &amp;#39;复制&amp;#39;,
    icon: &amp;#39;el-icon-document-copy&amp;#39;,
    onClick: (data) =&amp;gt; {
      console.log(&amp;#39;复制&amp;#39;, data)
    }
  },
  {
    label: &amp;#39;粘贴&amp;#39;,
    icon: &amp;#39;el-icon-document-checked&amp;#39;,
    onClick: (data) =&amp;gt; {
      console.log(&amp;#39;粘贴&amp;#39;, data)
    }
  },
  {
    divided: true
  },
  {
    label: &amp;#39;编辑&amp;#39;,
    icon: &amp;#39;el-icon-edit&amp;#39;,
    onClick: (data) =&amp;gt; {
      console.log(&amp;#39;编辑&amp;#39;, data)
    }
  },
  {
    label: &amp;#39;删除&amp;#39;,
    icon: &amp;#39;el-icon-delete&amp;#39;,
    disabled: true,
    onClick: (data) =&amp;gt; {
      console.log(&amp;#39;删除&amp;#39;, data)
    }
  }
]
// PC端右键点击事件
const handleRightClick = (e: MouseEvent) =&amp;gt; {
  contextMenuRef.value?.show(e, menuItems, { id: 1, name: &amp;#39;测试数据&amp;#39; })
}
// 移动端长按开始事件
const handleTouchStart = (e: TouchEvent) =&amp;gt; {
  touchTimer = window.setTimeout(() =&amp;gt; {
    contextMenuRef.value?.show(e, menuItems, { id: 1, name: &amp;#39;测试数据&amp;#39; })
  }, 500)
}
// 移动端长按结束事件
const handleTouchEnd = () =&amp;gt; {
  if (touchTimer) {
    clearTimeout(touchTimer)
    touchTimer = null
  }
}
&amp;lt;/script&amp;gt;
&amp;lt;style scoped&amp;gt;
.parent-container {
  width: 100%;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #f5f7fa;
}
.click-area {
  width: 400px;
  height: 200px;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  font-size: 16px;
  color: #333;
  cursor: pointer;
}
&amp;lt;/style&amp;gt;&lt;/pre&gt;
&lt;p&gt;这个案例里暂时没有实现子菜单的功能，因为子菜单的逻辑会稍微复杂一点，需要处理子菜单的显示和隐藏、子菜单的位置偏移、子菜单的层级覆盖等问题，但如果你需要的话，可以在这个基础上自己加——比如在菜单项上监听mouseenter事件显示子菜单，监听mouseleave事件隐藏子菜单，子菜单的挂载位置可以放在父菜单项的右边或者下边,然后同样调整位置防止超出可视区域。&lt;/p&gt;
&lt;h2&gt;总结一下&lt;/h2&gt;
&lt;p&gt;Vue3项目里做右键菜单，不是非要自己手写或者非要用现成的插件，要根据你的项目具体需求和团队能力来定：如果是临时加的、功能非常基础的右键菜单，直接用现成的插件更划算；如果是有强定制需求、复杂功能、极致性能要求的项目，自己手写封装成组件库更有价值，不管是手写还是用插件，都要注意位置偏移、层级覆盖、动态菜单项和参数传递、多个右键菜单共存、移动端兼容性这些避坑点，我给大家分享了一个极简版的组合式API封装案例，大家可以直接复制到自己的项目里用,然后根据自己的需求修改。&lt;/p&gt;</description><pubDate>Thu, 18 Jun 2026 16:41:48 +0800</pubDate></item><item><title>想自己搞个能用的小程序，真的要从零敲代码吗？找现成的小程序源码靠谱不？</title><link>https://www.codeqd.com/post/20260621852.html</link><description>&lt;p&gt;最近身边好多开奶茶店、水果店的朋友，还有想做二手物品置换、社区通知群类轻工具的大学生，都在问我这俩问题——毕竟自己从零敲代码，先不说会不会，光是前端、后端、云服务、支付这些环节串起来，没个两三个月可能连个登录页都跑不顺畅；找现成的吧，又怕踩坑，比如功能烂大街不能改、有后门泄露数据、写得太乱根本没法二次开发，作为一个做了5年多小程序开发，帮别人改了不下300套各类源码的“老码农”，今天就好好跟大家唠唠这事儿,把我的经验全掏出来。&lt;/p&gt;
&lt;h2&gt;首先说第一个核心问题：真的要从零敲吗？&lt;/h2&gt;
&lt;p&gt;这个得看你做小程序的&lt;strong&gt;核心目的&lt;/strong&gt;和&lt;strong&gt;预算、时间、技术能力&lt;/strong&gt;——不是所有人都适合从零敲,也不是所有人都不能从零敲。&lt;/p&gt;
&lt;h3&gt;适合从零敲的情况有哪些？&lt;/h3&gt;
&lt;p&gt;真的只有这几种，别听网上那些“小白30天敲出爆款小程序”的鬼话，爆款都是背后有团队打磨+运营堆出来的,敲代码只是第一步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;核心功能高度定制化，市面上找不到能搭得上的基础架构&lt;/strong&gt;
举个例子，去年有个朋友找我做一款宠物殡葬行业的小程序，核心不是卖骨灰盒，而是“宠物遗体上门接运+实时追踪车辆轨迹+360度全景告别间直播+线上专属纪念册生成+宠物基因样本存储预约续费”——这些功能串起来，现有的任何电商、直播、预约类源码都没法用，基因存储的续费逻辑还要对接他们公司自己的线下实验室系统，这种必须从零敲，不然改现有代码的成本可能比从零敲还高,而且后续还容易出bug。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;想做长期迭代的产品，有自己的开发团队或者愿意花精力招/培养技术人员&lt;/strong&gt;
如果你做小程序是为了当成一门长期生意，比如搞社区团购平台（不是那种蹭一波热度就跑的）、本地生活服务APP的小程序端，那最好从零敲——市面上的源码要么是用框架搭的模板，迭代空间小，要么是别人用过的淘汰品，代码冗余度高，后续加个新功能可能要拆半个后台；有自己的团队的话，从零敲还能根据自己的业务逻辑优化架构,后期维护和升级都方便。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;纯粹想学习小程序开发，练手用的&lt;/strong&gt;
这种当然要从零敲啦！先从官方的“我的第一个小程序”demo开始，跟着敲一遍登录、列表、跳转这些基础功能，然后再尝试加一点自己的小创意，比如把天气预报换成星座运势，把备忘录换成打卡墙——练手的话，不用太追求完美，能跑起来，能实现自己想要的小功能就行，慢慢积累经验,以后说不定真能做出自己的爆款。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;90%以上的人不适合从零敲，那应该怎么办？&lt;/h3&gt;
&lt;p&gt;其实90%以上的小程序需求，比如开奶茶店的点单、开水果店的配送、做二手置换的发布、做社区通知的推送、做兴趣社群的打卡抽奖，市面上都有&lt;strong&gt;成熟的、可二次开发的小程序源码&lt;/strong&gt;——关键是你会不会选，能不能找到靠谱的渠道，会不会改基础的小功能（或者找个懂点技术的朋友帮你改，花不了多少钱）。&lt;/p&gt;
&lt;h2&gt;接下来是第二个更重要的问题：找现成的小程序源码靠谱不？&lt;/h2&gt;
&lt;p&gt;不能一概而论，靠谱的源码有，踩坑的概率也不小——我见过太多人花几千块钱买了个模板，改个店名改个logo都要找卖家收几百块的“修改费”，还有的人下载了免费源码，结果后台有后门，用户的手机号、支付记录全被卖家窃取了,最后还惹上了官司。&lt;/p&gt;
&lt;h3&gt;先说说找源码常见的几个大坑，大家一定要避开：&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;免费源码≠馅饼，大概率是陷阱&lt;/strong&gt;
别以为天上会掉馅饼，那些在百度网盘、CSDN论坛、GitHub（当然GitHub上也有很多优质开源项目，后面会讲怎么区分）上随便下载的免费源码，要么是缺胳膊少腿的（比如只有前端没有后端，或者后端是假的，只能模拟数据），要么是有后门的（比如偷偷上传用户的隐私数据，或者后台留了个“超级管理员”账号，卖家随时能登录你的小程序后台改数据、关服务器），要么是用老旧的框架写的（比如微信小程序刚出来的时候用的wepy1.x框架，现在官方都不维护了，根本没法适配最新的微信版本）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;几百块钱的“全功能模板”，功能全是烂大街的，而且根本没法二次开发&lt;/strong&gt;
很多淘宝、拼多多上的卖家，卖的都是“一键生成”的模板，说是“全功能”，其实就是把登录、列表、支付这些基础功能拼在一起，没有任何定制空间——比如你开奶茶店想加个“第二杯半价限时段（比如每天下午2点到4点）”的功能，卖家要么说“这个功能需要加钱定制，加5000块”，要么说“这个功能我们的模板不支持，你只能换个更贵的套餐”；而且这些模板的代码都是加密的（比如用了微信小程序的“分包加密”或者第三方的加密工具），你根本看不到源码,更别说二次开发了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;“永久免费更新”“永久技术支持”都是忽悠人的&lt;/strong&gt;
很多卖家会打着“永久免费更新”“永久技术支持”的旗号吸引你下单，但等你付了钱之后，要么卖家就消失了，要么“更新”就是换个logo换个颜色，根本不会适配最新的微信规则（比如微信最近要求所有涉及支付的小程序都必须接入“微信支付分免密支付”的可选开关，很多旧模板就没有这个功能，不更新的话根本过不了审核），要么“技术支持”就是让你自己看“操作手册”，操作手册写得比天书还难懂，问卖家问题半天不回，回了也是“亲，这个我们也不太清楚呢”。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;“现成的爆款小程序源码，拿过来就能赚钱”更是扯淡&lt;/strong&gt;
爆款小程序都是背后有团队打磨了很久的UI、UX，还有大量的运营推广费用堆出来的——就算你拿到了一模一样的源码，没有运营能力，没有流量，也赚不到钱；而且很多所谓的“爆款源码”都是别人用过的淘汰品，流量早就被前者抢光了,你拿过来也没用。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;那怎么才能找到靠谱的小程序源码呢？&lt;/h3&gt;
&lt;p&gt;根据我这么多年的经验，找靠谱的小程序源码主要有这几个渠道，而且每个渠道都有对应的“避坑技巧”：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;官方开源社区（推荐指数：⭐⭐⭐⭐⭐）&lt;/strong&gt;
微信官方有个“微信开发者社区”，里面有个“开源项目”板块，都是经过官方审核的优质开源项目，代码都是公开的，没有后门，而且很多都是用最新的框架写的（比如uni-app、taro3.x），适配最新的微信版本，迭代空间大；而且这些开源项目的作者一般都比较热心，会在社区或者GitHub上回答用户的问题，有的甚至还会建微信群或者QQ群交流。
避坑技巧：&lt;ul&gt;
&lt;li&gt;看项目的&lt;strong&gt;Star数&lt;/strong&gt;和&lt;strong&gt;Fork数&lt;/strong&gt;，Star数和Fork数越多，说明这个项目越受欢迎,越靠谱；&lt;/li&gt;
&lt;li&gt;看项目的&lt;strong&gt;更新时间&lt;/strong&gt;，更新时间越近（最好是最近3个月内有更新），说明这个项目还在维护,能适配最新的微信规则；&lt;/li&gt;
&lt;li&gt;看项目的&lt;strong&gt;README文件&lt;/strong&gt;，README文件写得越详细（比如有项目介绍、功能列表、安装步骤、使用说明、二次开发指南）,说明这个项目越专业；&lt;/li&gt;
&lt;li&gt;看项目的&lt;strong&gt;Issue区&lt;/strong&gt;，Issue区的问题有没有人回答，作者有没有及时修复bug，要是Issue区全是问题没人回答，或者作者已经半年没更新了,那这个项目就不要选了。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;专业的源码交易平台（推荐指数：⭐⭐⭐）&lt;/strong&gt;
比如腾讯云市场、阿里云市场、码市这些平台，上面的源码都是经过平台审核的，虽然不是免费的，但价格一般都比较透明，而且平台会提供一定的担保交易服务（比如你付了钱之后，先测试源码，测试没问题之后再确认收货），有的还会提供一定时间的免费技术支持（比如7天或者15天）。
避坑技巧：&lt;ul&gt;
&lt;li&gt;选平台认证的“金牌卖家”或者“钻石卖家”,认证的卖家一般都比较靠谱；&lt;/li&gt;
&lt;li&gt;看卖家的&lt;strong&gt;评价&lt;/strong&gt;，尤其是差评，看看差评里说的是什么问题，比如是不是有后门，是不是功能不全,是不是技术支持不好；&lt;/li&gt;
&lt;li&gt;要求卖家提供&lt;strong&gt;测试账号&lt;/strong&gt;和&lt;strong&gt;测试地址&lt;/strong&gt;，先测试一下源码的功能是不是符合自己的需求,有没有bug；&lt;/li&gt;
&lt;li&gt;要求卖家提供&lt;strong&gt;未加密的源码&lt;/strong&gt;，要是卖家说源码必须加密,那这个源码就不要买了；&lt;/li&gt;
&lt;li&gt;要求卖家提供&lt;strong&gt;二次开发指南&lt;/strong&gt;和&lt;strong&gt;API文档&lt;/strong&gt;,方便后续自己或者找朋友二次开发；&lt;/li&gt;
&lt;li&gt;不要一次性付全款，最好分阶段付款（比如先付30%的定金，测试没问题之后再付50%，卖家帮你搭建好上线之后再付剩下的20%）。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;找身边懂技术的朋友或者靠谱的小型开发工作室购买定制化的“半成品源码”（推荐指数：⭐⭐⭐⭐）&lt;/strong&gt;
要是你找不到合适的开源项目，或者专业源码交易平台上的模板不太符合自己的需求，可以找身边懂技术的朋友或者靠谱的小型开发工作室购买定制化的“半成品源码”——所谓的“半成品源码”，就是开发团队已经把小程序的核心架构和基础功能搭好了，比如前端、后端、云服务、支付这些，然后你可以根据自己的需求加一点小功能，比如奶茶店的“第二杯半价限时段”功能，水果店的“拼团满减”功能，二手置换的“实名认证”功能，这些小功能一般花几百块钱就能搞定，而且后续的维护和升级也比较方便。
避坑技巧：&lt;ul&gt;
&lt;li&gt;看朋友或者工作室的&lt;strong&gt;过往案例&lt;/strong&gt;，看看他们做过的小程序是不是符合自己的需求,有没有上线；&lt;/li&gt;
&lt;li&gt;跟朋友或者工作室签&lt;strong&gt;正式的合同&lt;/strong&gt;，合同里要写清楚源码的价格、功能列表、交付时间、交付内容（比如未加密的源码、二次开发指南、API文档、测试账号、测试地址）、免费技术支持的时间、后续二次开发的价格；&lt;/li&gt;
&lt;li&gt;不要一次性付全款，最好分阶段付款（比如先付30%的定金，把核心功能搭好之后再付40%，全部功能做好测试没问题之后再付20%，上线之后再付剩下的10%）。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;找到靠谱的小程序源码之后，接下来应该怎么办？&lt;/h2&gt;
&lt;p&gt;找到靠谱的源码只是第一步，接下来你还要做这几件事,才能让小程序顺利上线：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;注册小程序账号&lt;/strong&gt;
这个很简单，直接去微信公众平台注册就行——个人可以注册个人小程序，不能开通支付功能；企业或者个体工商户可以注册企业小程序，能开通支付功能；注册的时候需要准备营业执照、法人身份证、对公账户（企业需要，个体工商户可以用法人的私人账户）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;配置云服务或者服务器&lt;/strong&gt;
要是你用的是开源项目或者专业源码交易平台上的模板，一般都会推荐你用微信云开发或者阿里云、腾讯云的轻量应用服务器——微信云开发比较适合小白，不用自己搭建服务器、数据库、存储，直接在微信开发者工具里就能配置，而且有免费额度（比如免费2G的数据库存储，5G的文件存储，每月100万次的云函数调用），超出免费额度之后才会收费，价格也比较便宜；要是你对性能要求比较高，或者需要对接自己的线下系统，可以用阿里云、腾讯云的轻量应用服务器,价格一般在几十块钱到几百块钱一个月。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;修改源码的基础信息&lt;/strong&gt;
比如修改小程序的名称、logo、简介、配色、联系方式，这些基础信息一般都可以在微信开发者工具里或者后台管理系统里直接修改，不用写代码；要是你需要修改一点小功能，比如奶茶店的“第二杯半价限时段”功能，可以找个懂点技术的朋友帮你改，或者在淘宝、拼多多上找个“小程序二次开发”的卖家帮你改,花不了多少钱。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;测试小程序&lt;/strong&gt;
修改完源码之后，一定要好好测试一下小程序的功能，比如能不能正常登录、能不能正常发布商品、能不能正常下单支付、能不能正常查看订单、有没有bug、有没有适配不同的手机型号（比如iPhone、华为、小米）、有没有适配不同的微信版本；测试的时候可以多找几个朋友一起测，人多力量大,能发现更多的问题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;提交审核&lt;/strong&gt;
测试没问题之后，就可以在微信开发者工具里提交审核了——提交审核的时候需要填写小程序的类目、标签、服务范围，还要上传小程序的截图、视频；审核时间一般在1-7个工作日，要是审核不通过，微信会告诉你不通过的原因,你按照原因修改之后再重新提交审核就行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;上线运营&lt;/strong&gt;
审核通过之后，小程序就可以正式上线了——上线之后不是万事大吉了，你还要好好运营，比如发朋友圈、发微信群、做活动、做推广，吸引更多的用户使用你的小程序；要是你有自己的开发团队或者愿意花精力招/培养技术人员，还可以根据用户的反馈不断迭代升级小程序，增加新功能，优化UI、UX,提高用户的留存率和转化率。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;最后给大家提几个建议：&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;不要贪小便宜&lt;/strong&gt;
免费源码大概率是陷阱，几百块钱的“全功能模板”根本没法二次开发，“永久免费更新”“永久技术支持”都是忽悠人的,一定要找靠谱的渠道购买靠谱的源码。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;明确自己的核心需求&lt;/strong&gt;
不要看到别人的小程序有什么功能就想加什么功能，先明确自己的核心需求是什么，比如开奶茶店的核心需求是“点单、支付、出餐”，其他的功能（比如打卡抽奖、积分商城）可以后期慢慢加，不然功能太多不仅会增加开发成本，还会让用户觉得太复杂,不愿意使用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;注重用户体验&lt;/strong&gt;
UI、UX一定要做得好，比如页面要简洁美观，操作要简单方便，加载速度要快,不然用户用了一次之后就不会再用了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做好长期运营的准备&lt;/strong&gt;
小程序不是上线之后就能赚钱的，还要做好长期运营的准备，比如不断更新内容，做活动，做推广，吸引更多的用户,提高用户的留存率和转化率。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;想自己搞个能用的小程序，90%以上的人不需要从零敲代码，找个靠谱的小程序源码就行——关键是你会不会选，能不能找到靠谱的渠道，会不会改基础的小功能，会不会做好长期运营的准备，希望今天的分享能帮到大家，要是大家还有什么关于小程序源码的问题，可以在评论区留言,我会尽量回答大家的。&lt;/p&gt;</description><pubDate>Thu, 18 Jun 2026 16:35:58 +0800</pubDate></item><item><title>做一套能用的小程序源代码多少钱？别踩这些坑！</title><link>https://www.codeqd.com/post/20260621851.html</link><description>&lt;p&gt;最近总被身边开小吃店、花店、美甲工作室的朋友问起：“现在想做个能点单、约课的小程序，直接买现成的源代码靠谱吗？大概得花多少钱？会不会踩雷？”其实不止小商家，很多刚转型做私域或试水本地生活的团队，也会纠结于源代码的选择和预算问题，我特意整理了近半年和不少小程序服务商、独立开发者打交道的真实经历，再结合行业里公开的规则,给大家掰扯清楚。&lt;/p&gt;
&lt;h2&gt;先明确：你要的是“能用”的哪类小程序源代码？&lt;/h2&gt;
&lt;p&gt;同样是“能用”，源代码的定位不一样，价格差能从几十块到几万甚至更多，这个第一步必须搞明白，不然容易花冤枉钱买一堆没用的垃圾,现在市面上主流的小程序源代码大概分3类：&lt;/p&gt;
&lt;p&gt;第一类是SaaS平台自带的“伪源代码”模板，很多平台宣传“一键生成专属小程序”，会额外加个“可下载源代码二次开发”的噱头，但下载下来的基本是不能单独运行、甚至没后端逻辑注释的压缩包，二次开发的门槛极高，而且大多绑定了SaaS平台的服务器、支付通道，后续年费、抽成还是逃不掉，这种的话，下载权限（或者说含模板的包）一般几十到几百块，但本质不算独立源代码，预算有限且不想折腾二次开发的小商家,其实直接用SaaS年费版更省心。&lt;/p&gt;
&lt;p&gt;第二类是垂直细分领域的独立通用源代码，比如专门做外卖点单、美甲美睫预约、生鲜社区团购、二手物品寄售这类单一业务场景的，这类源代码前后端逻辑相对完整，有基础的支付、会员、订单管理模块，甚至带点营销工具（比如优惠券、拼团、秒杀的简化版），可以直接部署到自己的阿里云、腾讯云服务器上，不用交后续的强制年费抽成，还能自己做些小改动（比如改改颜色、Logo、文字提示），这类的话，价格一般在1000-5000块之间，根据业务场景的复杂度、代码的质量（有没有核心漏洞、有没有注释、更新频率高不高）浮动，二手转卖的可能更便宜，但风险也更大,后面会详细说。&lt;/p&gt;
&lt;p&gt;第三类是定制化开发的专属小程序源代码，就是完全根据你的需求（比如你开连锁奶茶店，需要有加盟店管理、原料库存同步、线上直播带货+到店核销联动这类复杂功能），由团队或独立开发者从0到1写出来的，这种源代码100%归你所有，二次开发的空间几乎没有限制，后续想加什么功能都可以自己或找别人改，这类的价格跨度就非常大了，简单的定制（比如在通用点单模板上加个积分换购的专属规则）可能5000-10000块，复杂的连锁、电商类可能几万、十几万甚至更高，主要取决于功能的数量和复杂度、开发团队的规模（大公司贵但稳定，小工作室/独立开发者便宜但可能售后跟不上）、开发周期（赶工的话会加收30%-50%的加急费）。&lt;/p&gt;
&lt;h2&gt;买独立通用小程序源代码要避的4个大坑&lt;/h2&gt;
&lt;p&gt;如果你的需求刚好能匹配垂直细分的通用场景，买这类源代码是性价比最高的选择,但一定要避开下面这几个坑：&lt;/p&gt;
&lt;p&gt;第一个坑：买没有核心漏洞检测报告的“三无”源代码，很多低价（比如几十块、几百块的通用代码）都是开发者从网上扒下来的旧代码改的，或者是没经过测试就上线的，可能存在SQL注入、XSS攻击、用户信息泄露、支付通道被恶意跳转这类严重的安全问题，一旦出事，不仅你的生意做不成，还可能要承担法律责任，买之前一定要让卖家提供最近3个月内的第三方安全检测报告,或者自己找懂代码的朋友先测测核心功能和安全漏洞。&lt;/p&gt;
&lt;p&gt;第二个坑：买没有持续更新维护的“死代码”，微信小程序的接口规则、支付接口规则、甚至是审核规则，更新得都非常快，比如去年年底微信就调整了附近的小程序、直播组件的接口，今年年初又更新了用户隐私保护的要求，如果买的源代码已经半年甚至一年没更新了，那你部署上去之后要么通不过审核，要么很多功能用不了，买之前可以看看卖家的GitHub仓库（如果有的话）最近的提交记录，或者问问他们有没有专属的售后群、有没有定期推送更新包。&lt;/p&gt;
&lt;p&gt;第三个坑：买没有完整文档和技术支持的源代码，即使你自己懂一点前端或后端，拿到一套没有API接口文档、没有数据库结构说明、没有部署教程的代码，也得花好几天甚至好几个星期才能跑通，如果完全不懂代码，那更是无从下手，靠谱的卖家一般会提供图文并茂的部署教程、完整的文档、至少3个月的免费技术支持（比如帮你解决部署过程中的问题、帮你解答一些简单的二次开发疑问）。&lt;/p&gt;
&lt;p&gt;第四个坑：买没有明确版权归属的“二手”或“盗版”源代码，很多二手转卖的源代码其实是卖家从别人那里买的通用代码，再低价转卖给你，或者是直接扒的大公司或其他开发者的开源代码（但很多开源代码是有商业使用限制的，比如GPL协议的代码，如果你用它做商业项目，必须把你的整个项目也开源），如果买的是这种，后续可能会遇到版权纠纷，得不偿失，买之前一定要让卖家提供源代码的版权证书，或者确认开源代码的协议类型,确保可以合法商业使用。&lt;/p&gt;
&lt;h2&gt;怎么判断一套独立通用小程序源代码的性价比？&lt;/h2&gt;
&lt;p&gt;除了避开上面的坑，还要学会判断性价比,不能只看价格：&lt;/p&gt;
&lt;p&gt;首先看业务匹配度，比如你开的是单店美甲美睫，那你只需要有预约时间、预约项目、会员储值、优惠券这几个核心功能就行，不用买那种带连锁店管理、原料库存同步的复杂源代码，功能越多，代码越臃肿，运行速度越慢,价格也越高。&lt;/p&gt;
&lt;p&gt;其次看代码质量，可以让卖家提供一小部分核心代码（比如订单支付的逻辑代码、用户登录的逻辑代码）看看，有没有清晰的注释、有没有冗余的代码、有没有遵循统一的代码规范,这些都能反映出代码的质量。&lt;/p&gt;
&lt;p&gt;最后看售后服务，比如有没有专属的技术支持群、有没有定期的更新维护、有没有二次开发的服务（如果有，二次开发的价格是多少）,这些都能帮你省下不少后续的时间和钱。&lt;/p&gt;
&lt;h2&gt;预算有限的话，还有别的选择吗？&lt;/h2&gt;
&lt;p&gt;如果预算真的非常有限（比如只有几百块），但又不想用SaaS平台的强制年费抽成版，那可以试试找靠谱的开源代码，现在GitHub、Gitee上有很多垂直细分领域的开源小程序代码，比如外卖点单类的“unimall”简化版、预约类的“yuyue-miniprogram”，这些代码一般都是免费的，或者只需要支付少量的赞赏费，但需要注意的是，这些代码大多没有完整的技术支持，更新维护也比较慢,需要你自己懂一点代码才能用。&lt;/p&gt;
&lt;p&gt;如果你愿意花点时间学习，也可以自己用微信开发者工具写一套简单的小程序源代码，微信开发者工具的界面非常友好，官方也有非常详细的文档和视频教程，一般简单的单页面展示、表单提交类的小程序,花个一两周就能写出来。&lt;/p&gt;
&lt;p&gt;做一套能用的小程序源代码多少钱，这个问题没有标准答案，主要取决于你的需求、预算、以及对代码的要求，大家在选择的时候，一定要先明确自己的需求，再避开上面的坑,最后选择性价比最高的方案。&lt;/p&gt;</description><pubDate>Thu, 18 Jun 2026 16:33:17 +0800</pubDate></item></channel></rss>