普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月13日首页

Vue3 转 React:组件透传 Attributes 与 useAttrs 使用详解|VuReact 实战

作者 Ruihong
2026年4月13日 13:08

在 Vue3 迁移 React、跨框架组件封装的场景里,透传 Attributes 是几乎必用、但极易踩坑的能力。Vue 的 $attrs / useAttrs 和 React 的 props 体系设计差异很大,而 VuReact 作为稳定的 Vue3 → React 编译工具,已经把这套逻辑做了完整对齐。

本文带你一次性搞懂:透传属性是什么、为什么必须用 useAttrs、TS 怎么写、转换后长什么样,直接复制就能用。


一、先搞懂:透传 Attributes 到底是什么?

1. Vue 官方定义

透传 attribute:传给组件,但没有被声明为 props / emits 的属性或事件监听器。 最常见:classstyleid、自定义属性、v-on 监听等。

Vue 默认会把它们自动继承到组件根节点,也可以用 $attrsuseAttrs() 手动控制。

2. React 里的等价逻辑

React 没有“透传”这个名词,但行为一致: 所有没在 Props 里定义的属性,都属于“透传属性”,全部挂在 props 上。

区别是:

  • Vue:运行时自动处理
  • React + TS:必须显式写类型,否则报错

3. VuReact 的核心适配规则

VuReact 把透传属性统一理解为: 无类型约束的运行时对象 + 已声明 Props 合并 = 最终组件属性

  • 组件无 Props → 自动生成:props: Record<string, unknown>
  • 组件有 Props → 自动交叉类型:Props & Record<string, unknown>

二、关键:必须从 $attrs 转向 useAttrs()

Vue 里有两种写法:

  • $attrs:运行时隐式变量 → VuReact 无法静态分析
  • useAttrs():显式 API → VuReact 完美支持、推荐唯一写法

1. Vue 中标准 useAttrs 写法(必背)

<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>

好处:

  • 编译器可静态识别
  • 支持 TS 类型注解
  • 符合 React“显式优于隐式”的习惯

2. VuReact 转换规则(一张表看懂)

Vue useAttrs 写法 React 转换结果
无类型 const attrs = props as Record<string, unknown>
类型断言 as Attrs const attrs = props as Attrs
变量带类型 attrs: Attrs const attrs = props as Attrs
搭配 defineProps Props & Record<string, unknown>

三、实战示例:从 Vue 到 React 完整对照

示例 1:基础用法(无 TS)

Vue 输入

<template>
  <div :class="attrs.class" :style="attrs.style">
    {{ attrs.title }}
  </div>
</template>

<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>

React 输出

import { memo } from 'react'

const Comp = memo((props: Record<string, unknown>) => {
  const attrs = props as Record<string, unknown>

  return (
    <div className={attrs.class} style={attrs.style}>
      {attrs.title}
    </div>
  )
})

export default Comp

示例 2:TS 类型增强(企业级推荐)

Vue 输入

<template>
  <div :class="attrs.class" :style="attrs.style">
    {{ attrs.customTitle }}
  </div>
</template>

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

interface CustomAttrs {
  class?: string
  style?: React.CSSProperties
  customTitle?: string
  [key: string]: unknown
}

const props = defineProps<{
  id: string
}>()

const attrs = useAttrs() as CustomAttrs
</script>

React 输出

import { memo } from 'react'

interface CustomAttrs {
  class?: string
  style?: React.CSSProperties
  customTitle?: string
  [key: string]: unknown
}

type ICompProps = { id: string }

const Comp = memo((props: ICompProps & Record<string, unknown>) => {
  const attrs = props as CustomAttrs

  return (
    <div className={attrs.class} style={attrs.style}>
      {attrs.customTitle}
    </div>
  )
})

export default Comp

示例 3:动态属性 / 可选链(真实业务常用)

Vue 输入

<template>
  <div
    :class="[
      'base',
      attrs.class,
      attrs.xx?.class,
      attrs['custom-class']
    ]"
  >
    {{ attrs?.xxx?.content }}
  </div>
</template>

React 输出

import { memo } from 'react'
import { dir } from '@vureact/runtime-core'

const Comp = memo((props: Record<string, unknown>) => {
  const attrs = props

  return (
    <div
      className={dir.cls([
        'base',
        attrs.class,
        attrs.xx?.class,
        attrs['custom-class']
      ])}
    >
      {attrs?.xxx?.content}
    </div>
  )
})

四、避坑指南(VuReact 必看)

  1. 必须用 useAttrs(),禁止用 $attrs 编译器无法分析运行时变量,会丢属性。

  2. TS 尽量写接口 有利于提示、重构、避免空值报错。

  3. class/style 自动适配 classclassName style → 自动适配 React.CSSProperties

  4. defineProps + useAttrs 会自动合并类型 不用手动改。

  5. JS 项目直接用 会被编译成 const attrs = props,完全兼容。


五、总结

VuReact 处理透传 Attributes 的核心思想只有一句话: 把 Vue 隐式的 $attrs 变成显式的 useAttrs,再映射到 React 的 props 体系。

  • 你只管按 Vue 官方写法写
  • 编译器自动转成标准 React TSX
  • 类型安全、生产可用、迁移成本极低

正在做 Vue3 → React 迁移的同学,这套透传方案可以直接进团队规范。


🔗 相关资源


推荐阅读

#Vue3 #React #Vue转React #VuReact #前端迁移 #useAttrs #组件封装 #TypeScript

vue3中静态提升和patchflag实现

2026年4月13日 11:48

1. 更快的 Virtual DOM (VDOM) - 具体体现

Vue 3 在虚拟 DOM 方面的改进是多方面的,旨在提高渲染效率和减少不必要的计算。

A. 编译时优化 (Compile-time Optimizations)

这是 Vue 3 与 Vue 2 最大的区别之一。Vue 2 的 VDOM diff 过程是在运行时进行的,它需要逐个比较节点和属性。而 Vue 3 的编译器在构建阶段就能分析模板,生成包含“优化提示”的渲染函数。

  • 静态提升 (Hoisting/Diff Skipping): 编译器会识别出模板中的静态节点(即内容不会改变的节点),并将它们提取到渲染函数之外。在后续更新时,Vue 完全跳过对这些节点的比较,因为它们永远不会变。
<!-- 模板 -->
<div>
  <h1>This is static</h1> <!-- 静态节点 -->
  <p>{{ dynamicValue }}</p> <!-- 动态节点 -->
  <span>Another static content</span> <!-- 静态节点 -->
</div>

在 Vue 2 中,每次更新 dynamicValue 时,都会对整个 <div> 的所有子节点进行 diff。在 Vue 3 中,<h1><span> 会被提升,只对 <p> 进行比较,大大减少了工作量。

  • Block Tree (块树): Vue 3 会将动态节点组织成一棵“块树”。更新时,只需要遍历这棵更小的动态节点树,而不是整个 VDOM 树。
  • Patch Flags (补丁标志): 编译器会给动态节点打上标记(flag),标明该节点哪些部分可能会变化(如文本、class、props、事件监听器等)。在 diff 阶段,Vue 可以根据这些标志跳过不必要的比较,直接执行特定的更新操作。
<!-- 模板 -->
<p :class="className">{{ message }}</p>

编译器会知道这个 <p> 元素可能变化的部分是 class 和文本内容,并打上相应的 flag。更新时就不会去检查它的 id 或其他不变的属性。

B. 更高效的 Diff 算法

虽然核心思想仍是双端 Diff,但 Vue 3 的实现更加优化,尤其是在处理列表更新时。

  • 快速路径 (Fast Paths) for List Updates: 对于一些常见的列表更新模式(如在末尾添加元素、替换整个列表等),Vue 3 提供了专门的快速路径算法,避免了复杂的最长递增子序列计算。
  • 更精确的移动策略: 在处理列表项顺序改变时,Vue 3 的算法能更精确地判断哪些元素需要移动,哪些可以就地复用,从而减少 DOM 操作次数。

总结 VDOM 性能提升体现:

  • 更快的初始渲染: 静态节点提升和块树优化减少了首次渲染的计算量。
  • 更快的状态更新: Patch flags 和优化的 Diff 算法减少了状态变更时的比较和更新开销。
  • 更少的内存占用: Block tree 结构和静态提升减少了运行时需要跟踪的节点数量。

2.静态提升和pathflag例子

<template>
  <div id="app">
    <h1 class="title">Welcome to My App</h1>
    <p>{{ greeting }}</p>
    <ul>
      <li>Static Item 1</li>
      <li>Static Item 2</li>
      <li>{{ dynamicItem }}</li> <!-- 这一项是动态的 -->
    </ul>
    <button @click="changeGreeting">Change Greeting</button>
  </div>
</template>

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

const greeting = ref('Hello Vue 3!');
const dynamicItem = ref('Dynamic Item 3');

const changeGreeting = () => {
  greeting.value = 'Greetings from Vue 3!';
};
</script>

编译器分析和优化过程:

  1. 识别静态节点:
    • <h1>Welcome to My App</h1>:标签名、内容、class 属性都不变,是静态节点
    • <li>Static Item 1</li><li>Static Item 2</li>:标签名和内容都不变,是静态节点
    • <button>:标签名、内容和事件处理器(@click)都不变,是静态节点
  1. 识别动态节点:
    • <p>{{ greeting }}</p>:内容 {{ greeting }} 是动态的。
    • <li>{{ dynamicItem }}</li>:内容 {{ dynamicItem }} 是动态的。
    • <div id="app">:虽然 id 是静态的,但它包含了动态子节点,因此自身是动态的。
  1. 执行静态提升:
    • 编译器会将上面识别出的静态节点的 VNode 对象创建代码提取出来,放在渲染函数外面,通常赋值给一个变量(比如 _hoisted_1, _hoisted_2 等)。这样它们只会被创建一次。
  1. 添加 Patch Flags:
    • <p> 节点:它的内容是动态的,编译器会为其 VNode 添加 patchFlag: Text (数值通常是 1)。这告诉运行时,只需要比较和更新它的文本内容。
    • <li>{{ dynamicItem }}</li> 节点:它的内容是动态的,同样会添加 patchFlag: Text (数值通常是 1)。
    • <ul> 节点:它的子节点列表是动态的(因为包含动态的 <li>),编译器会为其添加 patchFlag: Children (数值通常是 8 或更复杂的组合)。这告诉运行时,需要对其子节点进行 diff。

编译后生成的渲染函数(简化示意):

import { createElementVNode as _createElementVNode, createTextVNode as _createTextVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock, toDisplayString as _toDisplayString } from 'vue'

// --- 静态提升的 VNodes ---
// 这些 VNodes 只在模块加载时创建一次,后续渲染直接复用
const _hoisted_1 = /*#__PURE__*/_createElementVNode("h1", { class: "title" }, "Welcome to My App", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createTextVNode("Static Item 1")
const _hoisted_3 = /*#__PURE__*/_createTextVNode("Static Item 2")
const _hoisted_4 = /*#__PURE__*/_createElementVNode("button", { onClick: "changeGreeting" }, "Change Greeting", -1 /* HOISTED */)
// -----------------------------------

function render(_ctx, _cache, $props, $setup, $data, $options) {
  // `_ctx` 通常包含 `greeting` 和 `dynamicItem` 等响应式数据
  return (_openBlock(), _createBlock("div", { id: "app" },
    [
      _hoisted_1, // 直接复用,无需 diff
      _createElementVNode("p", null, _toDisplayString($setup.greeting), 1 /* TEXT */), // patchFlag: 1
      _createElementVNode("ul", null, [
        _hoisted_2, // 直接复用,无需 diff
        _hoisted_3, // 直接复用,无需 diff
        _createElementVNode("li", null, _toDisplayString($setup.dynamicItem), 1 /* TEXT */) // patchFlag: 1
      ], 16 /* FULL_PROPS */), // patchFlag: 16 (这里可能表示子节点是动态的,需要 diff)
      _hoisted_4  // 直接复用,无需 diff
    ]
  ))
}

关键点解读:

  • _hoisted_1, _hoisted_2, _hoisted_3, _hoisted_4:这些都是在编译时创建好的静态 VNode 对象。/* HOISTED */ 注释表明它们被提升了。在运行时,渲染函数直接使用这些对象,而不必每次都重新创建。
  • _createElementVNode("p", ...)_createElementVNode("li", ...):这些是动态节点,每次渲染时都需要重新创建 VNode。
  • 1 /* TEXT */:这就是 patchFlag。它告诉运行时,这个 VNode 只需要关心文本内容的变化。当 $setup.greeting$setup.dynamicItem 改变时,运行时只需比较新旧文本字符串,然后更新真实 DOM 的 textContent,而不需要比较 classid 等其他属性。
  • 16 /* FULL_PROPS */ (或类似的数值):<ul>patchFlag 表明其子节点是动态的,需要进行子节点的 diff。

通过这种方式,Vue 3 在编译时就对模板进行了深度优化,使得运行时的渲染和更新过程更加高效

前端视频媒体带声音自动播放方案最佳实践和教程

作者 鹏多多
2026年4月13日 10:29

在前端开发过程中,经常会碰到这样的需求,自动播放视频,要求默认带声音。在浏览器环境下,视频媒体自动播放是可以的,默认静音的自动播放可以正常执行。浏览器不能自动播放的限制,仅针对带声音的自动播放。当网页无用户交互、媒体参与度不足时,带声音的自动播放会被浏览器拦截。

本文结合「用户交互触发」「媒体参与度优化」「跨域权限下放」三大核心场景,提供可落地的实现方案,附代码片段与关键细节说明,明确区分静音与带声音自动播放的实现差异。看完如有帮助,谢谢三连~

1. 核心前提:浏览器自动播放策略

浏览器对自动播放的限制核心的是「避免声音突然打扰用户」,具体规则如下:

  1. 「静音自动播放」:默认允许,无需用户交互、无需满足媒体参与度阈值,可直接实现;
  2. 「带声音自动播放」:受严格限制,需满足以下任一条件才可正常执行:
  3. 用户有强交互(如点击、Tab切换、滑动等),且交互触发在当前域名下;
  4. 媒体参与度(Media Engagement)达标(不同浏览器阈值不同,阈值达标后浏览器会放宽限制);
  5. 父元素(如顶级页面)已获得带声音播放权限,可下放给子iframe(同域名/配置跨域权限后)。

1.1. 什么是媒体参与度?

媒体参与度

媒体参与度(Media Engagement)是浏览器(以Chrome为首)内置的一种用户行为评分机制,核心是通过监测用户对当前域名的媒体交互行为,评估该网站的信任度,进而决定是否放宽带声音自动播放的限制,本质是浏览器给予域名的「信任积分」。

其判定依据主要包括:用户在当前域名播放过音频/视频、进行过点击/滑动/输入等交互操作、停留时间较长或浏览多个页面等;得分越高,浏览器对该域名的信任度越高,越容易允许带声音自动播放。

查看 Chrome 浏览器媒体参与度:直接访问 chrome://media-engagement,可查看当前域名的参与度得分及阈值。其中,Score 为当前得分(0-100分),Threshold 为准入阈值(通常20-30分,因浏览器版本和设备而异),Engaged 为 true 时,说明得分达标,浏览器授予带声音自动播放权限。

2. 基础实现:静音自动播放

该方式无需交互,直接可用。

2.2. 实现逻辑

无需用户前置交互,直接创建媒体元素并设置 muted="true",即可实现静音自动播放; 若需切换为带声音播放,需通过用户交互触发(如点击按钮取消静音)。

2.3. 案例

<!-- 页面结构:静音自动播放 + 手动取消静音按钮 -->
<div class="media-container">
  <video 
    id="videoPlayer" 
    muted="true"  <!-- 关键设置静音实现自动播放 -->
    autoplay       <!-- 自动播放属性 -->
    loop           <!-- 可选:循环播放 -->
    style="width: 100%;"
  >
    <source src="your-video-url.mp4" type="video/mp4">
  </video>
  <button id="unmuteBtn" style="margin-top: 10px; padding: 8px 16px;">
    点击开启声音
  </button>
</div>

<script>
  const video = document.getElementById('videoPlayer');
  const unmuteBtn = document.getElementById('unmuteBtn');

  // 静音自动播放无需额外触发,浏览器默认允许
  console.log('静音自动播放已执行');

  // 用户交互触发:取消静音(带声音播放)
  unmuteBtn.addEventListener('click', async () => {
    try {
      // 取消静音并尝试带声音播放(需用户交互触发,否则会报错)
      video.muted = false;
      await video.play();
      console.log('带声音播放成功');
    } catch (error) {
      console.log('带声音播放失败(未满足权限条件):', error);
      // 失败后恢复静音,避免影响自动播放
      video.muted = true;
    }
  });
</script>

2.4. 关键细节

  • 只要设置 muted="true"autoplay 属性可直接生效,无需用户交互;
  • 带声音播放必须通过用户交互触发(如点击按钮),否则即使取消静音,play() 也会被拦截;
  • 建议搭配 try/catch 包裹带声音播放逻辑,避免报错影响页面正常运行。

3. 带声音自动播放

若需实现「无需用户每次交互,即可带声音自动播放」,核心是提升当前域名的媒体参与度:

  1. 设计引导页,让用户完成高频交互(如点击、滑动、按键);
  2. 引导页中播放静音媒体,持续积累媒体参与度;
  3. 参与度达标后,跳转至目标页面,此时浏览器会默认允许带声音自动播放。

这里的引导页,实际上可以是任意页面,设计得让用户无感,只要发生交互即可。

示例代码:

<!-- 引导页:用于提升媒体参与度,为带声音自动播放铺路 -->
<div class="guide-page" style="text-align: center; padding: 50px 0;">
  <h3>点击任意区域进入播放页</h3>
  <p id="guideTip" style="margin: 20px 0; color: #666;">当前媒体参与度:<span id="engagementScore">0</span>(达标即可带声音自动播放)</p>
</div>

<script>
  // 创建静音音频(用于积累参与度,不干扰用户)
  const engagementAudio = new Audio('silent-audio.mp3');
  engagementAudio.loop = true;
  engagementAudio.muted = true; // 静音播放,避免打扰

  // 监听用户交互,触发参与度提升
  document.querySelector('.guide-page').addEventListener('click', async () => {
    // 首次点击触发静音播放,开始积累参与度
    await engagementAudio.play();
    // 模拟参与度更新(实际可通过 chrome://media-engagement 查看真实值)
    updateEngagementScore();
    // 假设参与度达标(模拟值≥80),跳转至目标页面
    const currentScore = Number(document.getElementById('engagementScore').textContent);
    if (currentScore >= 80) {
      setTimeout(() => {
        window.location.href = 'autoplay-page.html'; // 目标带声音自动播放页面
      }, 1000);
    }
  });

  // 模拟媒体参与度积累
  function updateEngagementScore() {
    const scoreSpan = document.getElementById('engagementScore');
    let currentScore = Number(scoreSpan.textContent) + 20;
    scoreSpan.textContent = Math.min(currentScore, 100); // 参与度上限100
  }
</script>

3.1. 媒体参与度提升流程图

插图1.png

4. 跨域 iframe 自动播放

  • 静音自动播放:iframe 可直接实现,无需父页面权限;
  • 带声音自动播放:需父页面已获得带声音播放权限(通过用户交互/参与度达标),并将权限下放给 iframe(同域名/配置跨域权限)。

4.1. 案例

示例代码:

  • 父页面(同域名,已获带声音播放权限)
<!-- 父页面:通过用户交互获得带声音播放权限 -->
<button id="parentPlayBtn" style="padding: 8px 16px; margin: 20px 0;">点击开启带声音播放(授权)</button>
<iframe id="mediaIframe" src="iframe-page.html" width="800" height="450"></iframe>

<script>
  const parentPlayBtn = document.getElementById('parentPlayBtn');
  const iframe = document.getElementById('mediaIframe');
  let hasAudioPermission = false;

  // 父页面用户交互,获得带声音播放权限
  parentPlayBtn.addEventListener('click', async () => {
    const testAudio = new Audio('test-audio.mp3');
    try {
      await testAudio.play();
      testAudio.pause();
      hasAudioPermission = true;
      console.log('父页面已获得带声音播放权限');
      // 向iframe发送权限下放通知
      iframe.contentWindow.postMessage('autoplay-allowed', '*');
    } catch (error) {
      console.log('父页面带声音播放授权失败:', error);
    }
  });
</script>
  • iframe 页面
<!-- 子iframe:根据父页面权限,实现对应自动播放 -->
<video id="iframeVideo" muted="true" autoplay loop style="width: 100%;">
  <source src="iframe-video-url.mp4" type="video/mp4">
</video>

<script>
  const video = document.getElementById('iframeVideo');

  // 监听父页面权限通知,切换为带声音播放
  window.addEventListener('message', async (e) => {
    if (e.data === 'autoplay-allowed') {
      try {
        // 父页面已授权,尝试带声音播放
        video.muted = false;
        await video.play();
        console.log('iframe 带声音自动播放成功');
      } catch (error) {
        console.log('iframe 带声音播放失败:', error);
        video.muted = true; // 失败后恢复静音自动播放
      }
    }
  });
</script>

4.2. 关键细节

  • 跨域场景下,需在父页面响应头配置 Permissions-Policy: autoplay=(self "https://子域名.example.com"),允许权限下放;
  • 即使父页面授权,iframe 带声音播放仍建议用 try/catch 处理异常,避免权限失效导致播放失败;
  • 若父页面未授权,iframe 仍可正常实现静音自动播放,不影响基础体验。

5. 进阶方案:智能检测播放能力

封装音频/视频播放类,自动检测浏览器是否允许带声音播放:能播放则自动开启声音,不能则默认静音播放,无需手动判断,适配所有场景。

5.1. 案例

// 封装智能媒体播放类(适配音频/视频,自动区分静音/带声音)
class SmartMediaPlayer {
  constructor(mediaUrl, isVideo = false) {
    // 创建媒体元素(音频/视频)
    this.media = isVideo ? document.createElement('video') : document.createElement('audio');
    this.media.src = mediaUrl;
    this.media.loop = true; // 可选:循环播放
    this.canPlayWithAudio = false; // 标记是否可带声音播放
  }

  // 初始化检测:自动判断播放能力
  async init() {
    try {
      // 尝试带声音播放(无用户交互时,此处会报错)
      await this.media.play();
      this.canPlayWithAudio = true;
      console.log('可带声音自动播放');
    } catch (error) {
      // 带声音播放失败,切换为静音自动播放(默认允许)
      this.media.muted = true;
      await this.media.play();
      this.canPlayWithAudio = false;
      console.log('带声音播放受限,已切换为静音自动播放');
    }
  }

  // 手动切换声音(需用户交互触发)
  toggleAudio() {
    if (!this.canPlayWithAudio) return; // 未获得带声音权限,不执行
    this.media.muted = !this.media.muted;
  }

  // 播放/暂停控制
  togglePlay() {
    this.media.paused ? this.media.play() : this.media.pause();
  }
}

// 调用示例(音频)
(async () => {
  const audioPlayer = new SmartMediaPlayer('background-music.mp3');
  await audioPlayer.init();
  // 页面加载完成后自动播放(静音/带声音自动适配)
  audioPlayer.togglePlay();
})();

// 调用示例(视频)
(async () => {
  const videoPlayer = new SmartMediaPlayer('demo-video.mp4', true);
  await videoPlayer.init();
  // 追加到页面
  document.body.appendChild(videoPlayer.media);
})();

5.2. 智能播放能力检测流程图

插图2.png

6. 参考资料与注意事项

6.1. 官方参考

6.2. 开发注意事项

  • 静音自动播放虽无需交互,但建议搭配加载状态提示,避免用户误以为媒体未加载;
  • 带声音自动播放的核心是「用户交互」或「媒体参与度」,二者缺一不可,不可强行绕过浏览器限制;
  • 移动端浏览器对带声音自动播放的限制更严格,即使参与度达标,部分机型仍需用户交互触发;
  • 媒体文件建议压缩优化,避免加载延迟导致自动播放触发时机滞后,影响用户体验;
  • 可通过 chrome://media-engagement 调试当前域名的参与度,适配不同浏览器的阈值差异。

以上,在实际开发中,可根据业务需求(是否需要声音),组合使用以上方案,既满足自动播放需求,又符合浏览器权限策略,兼顾用户体验与开发合规性。

本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

4.响应式系统基础:从发布订阅模式的角度理解 Vue3 的数据响应式原理

作者 Cobyte
2026年4月13日 09:00

前言

我们从前面的文章中知道的所谓发布订阅模式的本质是不管代码结构如何变化,它的核心都是管理对象间的依赖关系,或者说是事件间的依赖关系,一方变化了,所有跟其建立依赖关系的依赖都将得到通知。同时发布者对象既可以是发布者也可以是订阅者,所以我们不能只从代码组织结构去分辨模式,而是从意图去分辨。

Vue2 的数据响应式的实现,在代码结构层面多少是看得出有经典发布订阅模式的架构影子,所以社区里也有人从发布订阅模式角度去分析过,但 Vue3 的数据响应式的实现从代码结构上来看跟所谓标准的发布订阅模式的代码架构差别是很大的。一般社区作者也不从发布订阅模式的角度去分析它的实现原理,那么今天就让我们从发布订阅模式的角度去理解 Vue3 的数据响应式原理吧。

发布订阅模式原理回顾

我们经过前面的学习,我们很容易通过发布订阅模式初步实现 Vue3 的 reactive API,代码如下:

class Dep {
  constructor() {
    // 订阅者存储中心
    this.subs = []
  }
  // 添加订阅者
  addSub(sub) {
    this.subs.push(sub)
  }
  // 通知订阅者
  notify() {
    this.subs.forEach(sub => sub())
  }
}
const dep = new Dep()
let activeEffect
// reactive
function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
            activeEffect && dep.addSub(activeEffect)
            return Reflect.get(target, key) 
        },
        set(target, key, val) {
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
            dep.notify()
            return result
        }
    })
}

我们就可以进行以下测试了:

const proxy = reactive({ author: 'Cobyte' })

// 订阅者
const subscriber = () => {
    console.log(`我是:${proxy.author}`)
}

activeEffect = subscriber
subscriber()
activeEffect = null

// 修改
proxy.author = 'coboy'

根据上一篇 Vue2 的数据响应式原理的实践,我们可以做小小的优化:

class Dep {
  // 省略...
-  addSub(sub) {
+  addSub() {
    if (activeEffect) {
-        this.subs.push(sub)
+        this.subs.push(activeEffect)
    }
  }
  // 省略...
}
// 省略...
function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
-            activeEffect && dep.addSub(activeEffect)
+            dep.addSub()
            return Reflect.get(target, key) 
        },
        // 省略... 
    })
}

我们上面 reactive 的实现,每个订阅者还不能进行跟每个对象的属性进行隔离的。什么意思呢?看以下测试代码:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })

// 订阅者
const subscriber = () => {
    console.log(`我是:${proxy.author}`)
}
// 订阅者2
const subscriber2 = () => {
    console.log(`日期是:${proxy.date}`)
}
activeEffect = subscriber
subscriber()
activeEffect = subscriber2
subscriber2()
activeEffect = null

// 修改
proxy.author = 'coboy'

测试结果如下:

D01.png

我们可以看到最后修改 author 属性值的时候,两个订阅者函数都执行了。是因为我们在 getter 进行订阅的时候,把不同属性的订阅者都存储在同一个全局变量中了,而在 Vue2 中把每一个属性的消息代理都通过闭包进行了隔离,也就是每一个属性都拥有属于自己的消息代理,相当于每一个属性都是一个发布者。

而 Vue3 中的 Proxy API 很明显不能通过闭包来进行隔离每个属性的消息代理。那么我们根据前面的发布订阅模式的实践理解,还可以通过给消息代理对象通过添加 key 的方式来让订阅者只订阅自己感兴趣的内容。

那么相关代码修改如下:

class Dep {
  constructor() {
    // 订阅者存储中心
-    this.subs = []
+    this.subs = {}
  }
  // 添加订阅者
-  addSub() {
+  addSub(key) {
+    if (!this.subs[key]) {
+        this.subs[key] = []
+    }
    if (activeEffect) {
-    this.subs.push(sub)
+    this.subs[key].push(activeEffect)
    }
  }
  // 通知订阅者
-  notify() {
+  notify(key)
-    this.subs.forEach(sub => sub())
+    this.subs[key].forEach(sub => sub())
  }
}
const dep = new Dep()
let activeEffect
// reactive
function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
-            dep.addSub()
+            dep.addSub(key)
            return Reflect.get(target, key) 
        },
        set(target, key, val) { 
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
-            dep.notify()
+            dep.notify(key)
            return result
        }
    })
}

我们经过上面的修改再进行测试,我们发现已经可以正确打印我们期待的结果了。

D02.png

我们上面实现的 reactive 函数还存在一个问题,我们现在可以通过 key 来把不同订阅者进行分类,但不同的对象中可能会存在相同的 key,例子如下:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })

const proxy2 = reactive({ author: 'Cobyte2' })
// 订阅者
const subscriber = () => {
    console.log(`我是:${proxy.author}`)
}
// 订阅者2
const subscriber2 = () => {
    console.log(`我是:${proxy2.author}`)
}
activeEffect = subscriber
subscriber()
activeEffect = subscriber2
subscriber2()
activeEffect = null

// 修改
proxy.author = 'coboy'

测试结果如下:

D03.png

我们发现我们只修改了 proxy.author 的值,但订阅者2 subscriber2 也执行了,这不是我们期待的结果,所以我们还要迭代我们的功能。

我们既然可以添加 key 来让订阅者订阅自己喜欢的内容,那么是否还可以进行增加 key, 来区分不同的对象呢?我们把对象也当成一个 key,也就是在 getter 添加依赖的时候这样操作:dep.addSub(target, key, activeEffect),那么在 setter 的时候这样操作:dep.notify(target, key)。很明显我们可以通过 Map 来把一个对象作为一个 key。

所以我们对消息代理中心做以下修改:

class Dep {
  constructor() {
    // 订阅者存储中心
-    this.subs = {}
+    this.subs = new Map()
  }
  // 添加订阅者
-  addSub(key) {
+  addSub(target, key) {
+    let depsMap = this.subs.get(target)
+    if (!depsMap) {
+        depsMap = {}
+        this.subs.set(target, depsMap)
+    }
    
-    if (!this.subs[key]) {
-      this.subs[key] = []
-    }
+    if (!depsMap[key]) {
+        depsMap[key] = []
+    }
     if (activeEffect) {
-    this.subs[key].push(activeEffect)
+    depsMap[key].push(activeEffect)
     }
  }
  // 通知订阅者
-  notify(key) {
+  notify(target, key) {
-    this.subs[key].forEach(sub => sub())
+    const depsMap = this.subs.get(target)
+    if (!depsMap) return
+    const deps = depsMap[key] 
+    deps && deps.forEach(sub => sub())
  }
}

接着我们也去修改 reactive 中相关的地方:

function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
-            dep.addSub(key)
+            dep.addSub(target, key)
            return Reflect.get(target, key) 
        },
        set(target, key, val) {
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
-            dep.notify(key)
+            dep.notify(target, key)
            return result
        }
    })
}

我们重新测试,我们发现打印了如期的结果:

D04.png

桶的数据结构设计?

我们看到通过发布订阅模式去理解 Vue3 的数据响应式原理,理解起所谓依赖数据结构 ,是非常好理解的。我们通过由浅入深的地讲解所谓 数据结构的形成,它的形成是自然而然的形成的,而不是一开始就经过特别精心设计的,它没有那么的神秘,它是由最简单的功能一步步迭代形成的,是非常符合我们的日常开发规律的,因为我们日常的应用也是由最简单的功能开始慢慢迭代成非常复杂的功能。一开始所谓 ,只是一个 Array (可以简单理解为:[]) 的结构,后来我们通过增加 key 来区分不同的订阅者,这行为在发布订阅模式中就是通过 key 来让订阅者只订阅自己感兴趣的内容;增加 key 后, 的数据结构变为 Object -> Array (可以简单理解为:{ key: [] }),再后来我们继续增加响应式对象作为 key,来区分不同的属性,避免不同响应式数据中可能存在相同属性的情况。最后我们的 的数据结构变为 Map -> Object -> Array (可以简单理解为:{ target: { key: [] } })。

我们熟悉 Vue3 源码的同学会知道,所谓 的数据结构跟我们上面还是区别的,那其实都是性能优化迭代的结果,我们也可以继续迭代我们的功能。首先是我们的订阅者是通过 Array 的方式存储的,为了防止重复添加订阅者,我们需要在执行完订阅者函数之后把 activeEffect 变量设置为 null,同时也是为了确保只在副作用函数中读取响应式变量才进行依赖收集。我们可以把订阅者的存储方法改成 Set 的数据结构,因为 Set 具有自动去除重复的功能。

相关代码修改如下:

class Dep {
  // 省略...
  addSub(target, key, sub) {
    // 省略...
    if (!depsMap[key]) {
-        depsMap[key] = []
+        depsMap[key] = new Set()
    }
    if (activeEffect) {
-    depsMap[key].push(activeEffect)
+    depsMap[key].add(activeEffect)
    }
  }
  // 省略...
}

经过上面修改,我们的 结构变成了 Map -> Object -> Set。我们还可以继续优化,我们可以把中间的 Object 改成 Map,因为在频繁增删键值对和存储大量数据的场景下 Map 的性能要比 Ojbect 更好。

class Dep {
  // 省略...
  addSub(target, key, sub) {
    let depsMap = this.subs.get(target)
    if (!depsMap) {
-        depsMap = {}
        depsMap = new Map()
        this.subs.set(target, depsMap)
    }
+    let dep = depsMap.get(key)
-    if (!depsMap[key]) {
+    if (!dep) {
-        depsMap[key] = new Set()
+        dep = new Set()
+        depsMap.set(key, dep)
+    }
    if (activeEffect) {
-    depsMap[key].add(activeEffect)
+    dep.add(activeEffect)
    }
  }
  // 通知订阅者
  notify(target, key) {
    const depsMap = this.subs.get(target)
    if (!depsMap) return
-    const deps = depsMap[key] 
+    const deps = depsMap.get(key) 
    deps && deps.forEach(sub => sub())
  }
}

最后我们还可以继续优化的地方就是把存储订阅者的变量 this.subsMap 类型改成 WeakMap 类型。

class Dep {
  constructor() {
    // 订阅者存储中心
-    this.subs = new Map()
+    this.subs = new WeakMap()
  }
}

为什么不采用 WeakMap 而不采用 Map 呢?我们通过下面的一个例子来说明:

const map = new Map()
const weakMap = new WeakMap()

function test() {
    const mapObj = { test: 'mapObj' }
    const weakMapObj = { test: 'weakMapObj' }
    map.set(mapObj, true)
    weakMap.set(weakMapObj, true)
}

test()

console.log('map', map)
console.log('weakMap', weakMap)

我们从打印的结果中可以一目了然地看出两者的区别,WeakMap 对 key 是弱引用的,所谓弱引用就是一旦上下文执行完毕,WeakMap 中 key 对象没有被其他代码引用的时候,垃圾回收器就会把该对象从内存移除。Map 则不会把 key 对象进行移除,这样就会容易导致内存溢出,就算不内存溢出,当数据大的时候,操作性能也会下降,所以 Vue3 源码中就采用了 WeakMap。

最后小结 Vue3 底层源码是使用 WeakMap 和 Map 来构建依赖关系图,具体来说是:

  • targetMap 是一个WeakMap,键是响应式对象(target),值是一个Map(depsMap)。
  • depsMap 的键是对象的属性(key),值是一个Dep(即一个Set),存储了所有依赖该属性的副作用函数。

订阅者中介的实现

我们通过前面的文章对发布订阅模式的学习,可以知道发布者可以抽离一些公共功能统一放到一个中介类中,也就是所谓的事件总线或者消息代理,而订阅者同样也可以进行中介化,从而实现订阅者的多态化。所谓多态就是当不同的对象去执行同一个方法时会产生出不同的状态。我们通过上一篇文章可以知道 Vue2 中所谓的 Watcher 类其实就是订阅者中介,在项目中不同的组件其实底层都是通过 Watcher 类来执行的,而所谓依赖收集,其中收集的是 Watcher,那些响应式数据发生变化后去通知的也是 Watcher,然后再通过 Watcher 去执行具体的组件渲染。

那么 Vue3 的数据响应式也是通过发布订阅模式实现的,那么很自然的也存在订阅者中介。在 Vue3 源码中 ReactiveEffect 类从发布订阅模式的角度理解就是订阅者中介的角色,所以从发布订阅模式的角度理解 Vue3 的数据响应式原理,就非常容易理解为什么要有一个 ReactiveEffect 类了,甚至不用去看具体的实现细节,我们都可以知道 ReactiveEffect 所实现的功能是什么了。

我们知道 Vue2 中的 Watcher 有一个 update 方法,就是在发布者去通知所有订阅者的时候,订阅者统一执行的方法就是 update,那么很明显 ReactiveEffect 也同样需要这样的一个方法,在 Vue3 源码中这个方法叫 run,同样初始化的时候需要接收一个函数作为参数也就是具体订阅者需要做的事情。

ReactiveEffect 的初步实现:

class ReactiveEffect {
    constructor(fn) {
        this._fn = fn
    }
    run () {
        // 根据 Vue2 的数据响应式原理,我们知道在执行具体订阅者函数之前需要把当前订阅者赋值给一个中间变量。
        activeEffect = this
        this._fn()
        // 确保只在副作用函数中读取响应式变量才进行依赖收集
        activeEffect = null
    }
}

然后我们进行测试:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })
const _effect = new ReactiveEffect(() => {
    console.log(`我是:${proxy.author}`)
})
_effect.run()
proxy.author = 'coboy' 

我们可以看到正确打印了结果:

D06.png

同时我们发现 ReactiveEffect 的订阅者函数参数初始化在外部手动执行的,而 Vue2 的 Watcher 中的订阅者函数初始化在 Watcher 内部实例化的时候自动执行的,这个只是设计上区别。

我们把上述实现订阅的过程进行封装一下,那么就是 effect API 了,代码如下:

function effect(fn) {
    const _effect = new ReactiveEffect(fn)
    _effect.run()
}

从发布订阅模式的角度来看本质上 Vue3 的数据响应式实现原理跟 Vue2 的数据响应式原理的实现是一脉相承的。

互为订阅者

我们通过前面文章的学习,我们知道在 Vue2 中会存在发布者中介类 Dep 和订阅者类 Watcher 互为订阅者的情况,场景就是可能会取消某一个副作用函数的中的响应式数据的追踪,比如组件卸载了,那么我们就需要停止组件的依赖追踪。在 Vue3 中自然也存在这种场景,那么也就说在 Vue3 中也存在互为订阅者的情况。但在 Vue3 中的情况又会跟 Vue2 不一样,Vue2 是订阅者 Watcher 类直接订阅发布者中介类 Dep,因为在 Vue2 中每一个 Dep 实例都和一个发布者关联,也就是和每一个属性或者对象进行关联。而在 Vue3 中因为是通过 Proxy API 实现的数据响应式,每一个 Dep 的实例并不对应着具体的属性,所以我们要找到对应具体的属性的记录的变量,其实就是对应 key 的记录变量。

我们再看看 Dep 中关于对应 key 部分的订阅者记录变量部分代码:

class Dep {
  // 省略...
  addSub(target, key) {
    let depsMap = this.subs.get(target)
    if (!depsMap) {
        depsMap = new Map()
        this.subs.set(target, depsMap)
    } 
    let dep = depsMap.get(key)
    if (!dep) {
      dep = new Set()
      depsMap.set(key, deps)
    }
    if (activeEffect) {
      dep.add(activeEffect)
    }
  }
  // 省略...
}

我们可以看到对应每一个 key 的订阅者记录变量是 deps,所以我们只需要把对应的 deps 记录到 ReactiveEffect 中即可。

首先我们修改 ReactiveEffect 类,添加记录变量 deps

class ReactiveEffect {
+    // 记录哪些变量记录了该订阅者,在 Vue2 中则是记录哪些 Dep 记录了该 Watcher
+    deps = []
    // 省略...
}

接着我们在记录响应式数据对象的 key 的消息代理对象的地方把对应的 key 的消息代理对象添加到订阅者 ReactiveEffectdeps 变量中,代码如下:

class Dep {
  // 省略...
  addSub(target, key) {
    // 省略...
    if (activeEffect) {
      deps.add(activeEffect)
+      activeEffect.deps.push(deps)
    }
  }
  // 省略...
}

这样我们就完成了对应 key 的变量对 ReactiveEffect 的订阅,那么有订阅,也就有取消订阅。

取消订阅功能如下:

class ReactiveEffect {
    // 省略...
    // 取消订阅
+    stop () {
+      this.deps.forEach(dep => dep.delete(this))
+    }
}

接着我们再修改 effect API:

function effect(fn) {
    const _effect = new ReactiveEffect(fn)
    _effect.run()
+    return _effect
}

这样我们就可以进行以下测试了:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })

const _effect = effect(() => {
  console.log(`我是:${proxy.author}`)
})
proxy.author = 'Coboy'
// 取消订阅,也就是取消依赖追踪
_effect.stop()
proxy.author = '掘金签约作者'

打印结果如下:

D06.png

我们看到取消依赖追踪后,我们再去修改响应式数据,我们之前设置的订阅者函数就不再执行了,也就是得不到通知了。

那么停止依赖追踪之后,我又想它继续进行依赖追踪呢?这样我们就需要把 ReactiveEffect 中的 run 方法也返回出来。

我们继续进行 effect API 的功能迭代,新的修改如下:

function effect(fn) {
    const _effect = new ReactiveEffect(fn)
    _effect.run()
+    const runner = _effect.run.bind(_effect)
+    runner.effect = _effect
+    return runner 
}

这样我们就可以在取消依赖追踪后,还可以在某个时机中又恢复依赖追踪了,测试代码如下:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })

const runner = effect(() => {
  console.log(`我是:${proxy.author}`)
})
proxy.author = 'Coboy'
// 取消订阅,也就是取消依赖追踪
runner.effect.stop()
proxy.author = '掘金签约作者'
// 恢复依赖追踪
runner()
proxy.author = '恢复依赖追踪了'

我们可以看到如期打印了我们期待的结果:

D07.png

为什么 Vue3 的发布订阅模式不采用传统代码结构?

我们上面实现 Vue2 的数据响应式原理是很明显采用了发布订阅模式的,因为我们存在一个发布者中介类 Dep,这个代码结构跟传统教学中的发布订阅模式中的代码结构是很相似的。但实际上 Vue3 源码中是不存在发布者中介类的,也就是跟传统发布订阅模式的代码结构是不相同的,那么是否意味着 Vue3 并没有采用发布订阅模式呢?答案是否定的,正如我们前面文章中所说的那样,判断模式不能从代码结构上进行判断,而应该从代码意图。

class Dep {
  constructor() {
    // 订阅者存储中心
    this.subs = new WeakMap()
  }
  // 添加订阅者
-  addSub(target, key) {
+  track(target, key) {
    let depsMap = this.subs.get(target)
    if (!depsMap) {
        depsMap = new Map()
        this.subs.set(target, depsMap)
    }
    let dep = depsMap.get(key)
    if (!dep) {
      dep = new Set()
      depsMap.set(key, dep)
    }
    if (activeEffect) {
      dep.add(activeEffect)
      activeEffect.deps.push(dep)
    }
  }
  // 通知订阅者
-  notify(target, key) {
+  trigger(target, key) {
    const depsMap = this.subs.get(target)
    if (!depsMap) return
    const deps = depsMap.get(key)
    deps && deps.forEach(effect => effect.run())
  }
}

上面我们经过对方法名称的修改,我们的代码结构从命名上跟 Vue3 源码有些类似了,我们接着把 Dep 类也去掉:

  // 全局订阅者记录变量
  const targetMap = new WeakMap()
  // 添加订阅者
  function track(target, key) {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        depsMap = new Map()
        targetMap.set(target, depsMap)
    }
    let dep = depsMap.get(key)
    if (!dep) {
      dep = new Set()
      depsMap.set(key, dep)
    }
    if (activeEffect) {
      dep.add(activeEffect)
      activeEffect.deps.push(dep)
    }
  }
  // 通知订阅者
  function trigger(target, key){
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    const deps = depsMap.get(key)
    deps && deps.forEach(effect => effect.run())
  }

接着我们也要把 reactive 中的相关代码也进行修改:

function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
-            dep.addSub(target, key)
+            track(target, key)
            return Reflect.get(target, key) 
        },
        set(target, key, val) {
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
-            dep.notify(target, key)
+            trigger(target, key)
            return result
        }
    })
}

我们可以看到经过上述修改之后,我们的代码结构跟 Vue3 源码是一模一样的了,但并不是说代码结构变了,模式也变了,上述代码结构依然是发布订阅模式。那么 Vue3 为什么要把依赖收集和依赖触发的函数进行分开呢?主要是因为分开之后依赖收集和依赖触发的函数就可以分别独立导出了,给其他功能 API 比如 ref、computed 使用了,代码可以达到最极致的抽象及复用。

确保只在副作用函数中读取响应式变量才进行依赖收集

不采用 Proxy API 实现数据响应式

因为 Proxy 无法提供对原始值的代理,所以我们需要对原始值的响应式进行特别处理,我们可以使用一层对象作为包裹,间接实现原始值的响应式方案。

当我们不通过 Proxy 实现代理的时候,除了使用 Vue2 中使用的 Object.defineProperty以外,我们还可以根据前面总结的实践规律,我们只需要可以实现在数据读取的时候进行依赖收集,然后在数据更改的时候进行依赖触发就可以了。那么明显我们可以使用在发布订阅模式那篇中讲到的公众号的例子。

// 定义发布者公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '原始值内容',
    // 发布文章
    setArticle(value) {
        this.article = value
        // 更新文章的时候通知所有的订阅者
        this.notify()
    },
    // 添加订阅者
    addDep(fn) {
        // 把订阅者添加进记录列表
        this.subscribers.push(fn) 
    },
    // 广播信息
    notify(title) {
        // 发布信息时就是把记录列表中的订阅者全部通知一次
        this.subscribers.forEach(fn => fn(title));
    }
}

上述代码就是前面我们实现公众号讲解发布订阅模式的例子。在上述例子中,我们实现了在数据更新的时候触发依赖,也就是 setArticle 函数。那么我们再实现在数据读取的时候进行依赖收集即可,为了现在这个功能,我们把读取 article 属性值的行为也封装成一个函数。

代码如下:

// 定义发布者公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '',
+    getArticle() {
+        return this.article
+    },
    // 省略...
}

这样我们就可以通过以下的方式获取文章内容了:

effect(() => {
    console.log(`原始值内容:${weChatOfficialAccount.getArticle()}`)
})
// 更改内容
weChatOfficialAccount.setArticle(520)

同时我们的发布者的通知函数也需要进行修改:

// 定义发布者公众号
const weChatOfficialAccount = {
    // 省略...
    // 广播信息
-    notify(title) {
+    notify() {
        // 发布信息时就是把记录列表中的订阅者全部通知一次
-        this.subscribers.forEach(fn => fn(title))
+        this.subscribers.forEach(dep => dep.run())
    }
}

那么我们就可以在 getArticle 函数中进行依赖收集了:

// 定义发布者公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '',
    getArticle() {
+        // 进行依赖收集,也就是进行订阅
+        if (activeEffect) this.addDep(activeEffect)
        return this.article
    },
    // 省略...
}

这样我们的测试结果如下:

D08.png

我们上述方式是通过一个典型的发布订阅模式来实现对一个对象的观察,当这个对象发生改变之后,所有依赖该对象的订阅者都将得到通知。

我们通过一个工厂函数上面的公众号对象进行进行封装,代码如下:

// ref 工厂函数
function ref(value) {
    return {
        // 订阅公众号的人的记录列表
        subscribers: [],
        // 文章内容
        _value: value,
        getArticle() {
            if (activeEffect) this.addDep(activeEffect)
            return this._value
        },
        // 发布文章
        setArticle(value) {
            this._value = value
            // 更新文章的时候通知所有的订阅者
            this.notify()
        },
        // 添加订阅者
        addDep(fn) {
            // 把订阅者添加进记录列表
            this.subscribers.push(fn) 
        },
        // 广播信息
        notify() {
            // 发布信息时就是把记录列表中的订阅者全部通知一次
            this.subscribers.forEach(dep => dep.run());
        }
    }
}

我们可以看到经过上述的代码封装之后,我们实现了对原始值的响应式。那么接下来我们希望通过普通的方式获取和设置对象的值:

const weChatOfficialAccount = ref('初始值')
effect(() => {
    console.log(`原始值内容:${weChatOfficialAccount.article}`)
})
// 更改内容
weChatOfficialAccount.article = 520

通过前面的学习我们知道除了使用 Object.defineProperty 进行显式声明属性访问器之外,还可以通过字面量的方式,本质还是属性访问器

修改如下:

function ref(value) {
    return {
        // 订阅公众号的人的记录列表
        subscribers: [],
        // 文章内容
        _value: value,
-        getArticle() {
+        get article() {
            if (activeEffect) this.addDep(activeEffect)
            return this._value
        },
        // 发布文章
-        setArticle(value) {
+        set article(value) {
            this._value = value
            // 更新文章的时候通知所有的订阅者
            this.notify()
        },
        // 省略...
}

经过上述修改之后,我们就可以通过属性访问器像普通方式那样访问和设置对象的属性值了。

那么为了跟 Vue3 的 ref API 设计一致,我们把 article 属性改成 value

function ref(value) {
    return {
        // 订阅公众号的人的记录列表
        subscribers: [],
        // 文章内容
        _value: value,
-        get article() {
+        get value() {
            if (activeEffect) this.addDep(activeEffect)
            return this._value
        },
        // 发布文章
-        set article(value) {
+        set value(value) {
            this._value = value
            // 更新文章的时候通知所有的订阅者
            this.notify()
        },
        // 省略...
}

那么改了之后我们的 ref 就跟 Vue3 的一样用法了:

const weChatOfficialAccount = ref('初始值')
effect(() => {
    console.log(`原始值内容:${weChatOfficialAccount.value}`)
})
// 更改内容
weChatOfficialAccount.value = 520

接着我们对依赖收集函数 track 和依赖触发函数 trigger 进行修改让我们的代码尽可能地复用。修改如下:

// 添加订阅者
function track(target, key) {
    // 省略...
    if (activeEffect) {
-        dep.add(activeEffect)
-        activeEffect.deps.push(dep)
+        trackEffect(dep)
    }
}
+ function trackEffect(dep) {
+     dep.add(activeEffect)
+     activeEffect.deps.push(dep)
+ }
// 通知订阅者
function trigger(target, key) {
    // 省略...
-    deps && deps.forEach(effect => effect.run())
+    triggerEffect(deps)
}
+ function triggerEffect(deps) {
+     if(deps) {
+         deps.forEach(effect => effect.run());
+     }
}

接着我们进行重构 ref 函数:

function ref(value) {
    return {
        // 订阅公众号的人的记录列表
-        subscribers: [],
+        dep: new Set()
        // 文章内容
        _value: value,
        get value() {
-            if (activeEffect) this.addDep(activeEffect)
+            if (activeEffect) trackEffect(this.dep)
            return this._value
        },
        // 发布文章
        set value(value) {
            this._value = value
            // 更新文章的时候通知所有的订阅者
-            this.notify()
+            triggerEffect(this.dep)
        },
-        // 添加订阅者
-        addDep(fn) {
-            // 把订阅者添加进记录列表
-            this.subscribers.push(fn) 
-        },
-        // 广播信息
-        notify() {
-            // 发布信息时就是把记录列表中的订阅者全部通知一次
-            this.subscribers.forEach(dep => dep.run());
-        }
-    }
}

我们可以看到经过重构之后,我们的 ref 函数就变得比较整洁了,我们 ref 中的部分发布订阅的功能就和前面 reative 的发布订阅已经实现的功能代码进行了复用。

我们通过前面文章的学习,我们知道 Vue3 的 ref 底层是通过 OOP 的方式进行实现的,但本质还是跟我们上面一样的,那么我们也通过 OOP 的方式实现一遍吧。

实现代码如下:

class RefImpl {
    _value
    dep = new Set()
    constructor(value) {
        // 如果传进来的是对象那么最终还是通过 reactive API 实现数据响应式
        this._value = isObject(value) ? reactive(value) : value
    }
    get value() {
       // 存在依赖就把依赖收集到依赖存储中心
       if (activeEffect) trackEffect(this.dep)
       return this._value 
    }
    set value(val) {
        this._value = val
        // 更新文章的时候通知所有的订阅者
        triggerEffect(this.dep)
    }
}

function ref(value) {
    return new RefImpl(value)
}

最终我们的测试结果还是一样的,这里唯一值得注意的是,如果传进来的是对象那么最终还是通过 reactive API 实现数据响应式。

API 的设计技巧及知识的串联

我们上文中实现的数据响应式代码中,有一个函数的名称叫:observe,还有一个类叫:Observer,在 Vue2 源码中也是这么起名的。那么为什么要这么起名称呢?这么起名称有什么特殊的含义吗?

我们上面这个所谓数据响应式的原理,其实是在观察数据的变化,跟我们在 web 开发中观察 DOM 对象的变化的行为是很像的,甚至可以说本质是一样的。

MutationObserver 与 Vue2 数据响应式的联系

我们如果要观察一个 DOM 对象发生改变了就进行某些操作的话,可以通过 MutationObserver API来实现。例子如下:

// 获取 DOM 对象
const targetNode = document.querySelector('#some-id');

// 观察者回调函数
const subscriber = (mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList') {
      mutation.addedNodes.forEach((addedNode) => {
        console.log(`添加了子元素:${addedNode.nodeName}`);
        // 执行相应的处理逻辑
      });
      mutation.removedNodes.forEach((removedNode) => {
        console.log(`移除了子元素:${removedNode.nodeName}`);
        // 执行相应的处理逻辑
      });
    }
  });
} 

// 创建一个观察器实例并传入回调函数,当观察到变动时便执行回调函数
const observer = new MutationObserver(subscriber);
// 配置需要观察的选项
const config = {
  childList: true, // 观察子元素是否发生变化
};
// 观察 DOM 对象是否发生变化
observer.observe(targetNode, config);

我们从上面的代码可以看出 MutationObserver 所做的事情,跟我们 Vue2 中对响应式数据的监听是一样的。DOM 对象就是我们 Vue2 中的响应式数据,当它发生变化之后就会去触发回调函数执行,相当于 Vu2 中的响应式数据发生改变后会触发 Watcher 一样。所以 MutationObserver 本质也是一个发布订阅模式,但它使用方式跟我们所谓传统的发布订阅模式是不一样的,但正如我们前面说的理解一种模式不应该从代码组织结构去进行分辨,而是意图。

所以我们从 Vue2 的数据响应式实现原理,就可以联系到 MutationObserver,然后联系它们的相同点,从而加深我们对知识的理解。当然尤雨溪当初给 Vue2 对一个对象实现数据响应式的处理函数和类命名为 observeObserver,是否参考了 MutationObserver 的 API 命名规则我们无从考证,但它们的工作方式值得我们联系,从而加深我们的知识理解。

总结

本文从发布订阅模式的核心思想出发,深入剖析了 Vue3 响应式系统的设计本质。发布订阅模式的关键在于管理对象间的依赖关系——一方变化时,所有依赖方都能得到通知,而非拘泥于特定的代码结构。Vue3 虽然不再像 Vue2 那样拥有显式的 Dep 类,但其底层依然遵循这一模式。

通过逐步迭代,我们自然形成了 Vue3 中著名的“桶”数据结构:最初用一个数组存储订阅者,然后按属性 key 分类,再按响应式对象 target 隔离,最终演变为 WeakMap(target) → Map(key) → Set(effect) 的依赖图。这种结构并非凭空设计,而是功能迭代的自然产物,体现了发布订阅模式在 Proxy 场景下的灵活应用。

Vue3 中的 ReactiveEffect 类扮演了订阅者中介的角色,类似于 Vue2 的 Watcher,负责管理具体副作用函数的执行与依赖追踪。通过 effect 函数封装,我们可以轻松创建响应式副作用,并借助 stop 机制实现取消订阅,这体现了订阅者与发布者之间“互为订阅”的关系。

值得注意的是,Vue3 将依赖收集(track)和依赖触发(trigger)拆分为独立函数,而非保留传统的 Dep 类结构。这一设计变化并非模式的改变,而是为了提升代码复用性,让 refcomputed 等 API 也能共享同一套响应式核心。

此外,对原始值的响应式实现(ref)同样基于发布订阅模式——通过属性访问器(getter/setter)在读取时收集依赖,在修改时触发更新。当 ref 包裹对象时,内部会回退到 reactive 处理,保证了逻辑的一致性。

最后,从 API 命名(如 observe / Observer)到与浏览器原生 MutationObserver 的类比,都能看出响应式系统与观察者模式之间的深刻联系。理解这些设计背后的模式思想,远比记忆具体代码实现更有价值。

上述文章写于:2023 年,由于个人原因今年 2026 年发布。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

昨天 — 2026年4月12日首页

《前端周刊》尤大开源 Vite+ 全家桶,前端工业革命启动;尤大爆料 Void 云服务新产品,Vite 进军全栈开发;ECMA 源码映射规范......

作者 Web情报局
2026年4月12日 19:40

🌐 今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

欢迎阅读《Web 周刊》,上周全球 Web 开发圈的主要情报如下:

  • 🎉 尤大出席 Vue 大会,发表了关于 Vue & Vite 的重要讲话
  • ✨ Vue 生态“文艺复兴“,“蒸汽模式“公测,Pinia Colada 新品首发
  • ✅ TC39 工作组推进 ECMA 源码映射规范
  • 👍 Vite 生态“工业革命“,Vite+ 全家桶免费开源
  • 🙏 尤大爆料 Void 一键部署云平台,Vite 进军全栈开发

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

🎉 Vue 生态文艺复兴

近日,Vue 之父 & Vite 之父 & VoidZero CEO 尤雨溪出席了 Vue 阿姆斯特丹大会和 D2 技术大会,发表了重要讲话。

cover.png

本期我们就来回顾一下这位“前端之神“已公开的关于 Vite & Vue 生态的最新情报和未来规划。

Vue 3.6 Beta

Vue 是 GitHub 第二 UI 框架,也是唯一一个同时支持 SFC(单文件组件)/ JSX、集成 Signals(细粒度响应性)的渐进式框架。

Vue 目前发布了 v3.6-beta(公测版),主要包括:

  • 移植 alien-signals 重构 @vue/reactivity
  • 新增可选的 Vapor Mode 编译模式,这是一种专属于 <script setup> SFC 的无虚拟 DOM “蒸汽模式“
  • Vapor Mode 是 Vue 当前 API 的子集,所以部分功能受限,比如不支持 <Suspense /> 组件和 Options API

尤大最初在 2022 透露了 Vapor Mode(蒸汽模式),去年 Vue 从 Alpha 顺利晋升到 Beta,今年有望正式发布。但考虑到尤大还需要兼顾 Vite+ 等海量开源项目,暂不确定,敬请期待!

Vue Router 5.0

Vue Router 是 Vue 生态官方的客户端路由库,年初发布了 v5.0 主版本,主要包括:

  • 不再依赖 unplugin-vue-router,将其集成为 vue-router/unplugin,支持基于文件的路由
  • IIFE 构建不再包含 @vue/devtools-api,该模块升级到 v8.0 之后,不再提供 IIFE 格式
  • v5.0 是一个过渡版本,v6.0 将只支持纯 ESM 模块

Pinia Colada v1 首发

Pinia 近期没有主/次版本更新,但上线了新的产品 Pinia Colada。

pinia-colada.png

Pinia Colada 是基于 Pinia 的 Vue 专属异步状态管理库,第一个主版本正式发布,优点在于:

  • 支持缓存、去重、SWR(过期重验证)等高级功能,不用我们自己定义相关复杂逻辑
  • 无需手写 isLoading 等属性,内部封装后暴露这些接口
  • 消除数据请求的大量重复模板代码,更符合人体工学

colada-demo.png

Nuxt 4.4

Vue 生态的第一全栈元框架 Nuxt 发布了 v4.4 次版本,主要包括:

  • 新增 createUseFetch() 等工厂函数,支持组合拦截器定义高级实例,比如创建带有默认选项的 useApiFetch() 来替换 useFetch()
  • 新增 useAnnouncer() 组合函数和 <NuxtAnnouncer /> 组件,适用于页面内容动态变化、但焦点不变的场景,比如表单提交
  • 更棒的 import 保护,现在会显示建议和完整的追踪信息
  • 构建性能分析报告,显示构建阶段或打包插件的持续时间等数据,轻松诊断性能瓶颈

profiler.png

🎉 Vite 生态工业革命

而 Vite 生态,尤大所在的 VoidZero 团队掀起了一场前端“工业革命“。

上周我们提到了 Rust 驱动的第一个 Vite 稳定版 Vite 8 正式发布,替换 Rollup + esbuild,采用 Rolldown + Oxc,性能爆表。

此外,基于 Oxc 编译器的格式化神器 Oxfmt 发布了 beta(公测版),JavaScript 跟 TypeScript 的格式化功能 100% 兼容 Prettier,但性能比快了 30 倍。

本期补充更多 Vite 生态的进展,包括 Oxlint 和 Vitest。

Oxlint JS 插件 Alpha

ESLint 的 Rust 移植版 Oxlint 也有新进展,它的 JS 插件进入 Alpha 阶段,目前 100% 通过 ESLint 内置规则的官方测试套件。

oxc-test.png

具体而言,Oxlint 采用 Rust 重写了 650+ 多条代码质检规则,涵盖了 ESLint 的大部分规则。即使没有使用 Rust 重写的规则,Oxlint 也提供了 oxlint-plugin-eslint 插件来无缝迁移,使得 Oxlint 100% 兼容 ESLint 的所有内置规则。

性能方面,Oxlint 团队把 Node 源码库的 ESLint 替换掉,进行测评跑分,性能暴涨近 5 倍。

Oxlint JS 插件的成熟意味着目前 JS 生态现存的 ESLint 插件,比如非官方的社区插件,也能无缝迁移到 Oxlint 项目。这样用户无需重写插件,同时部分受益于 Rust 的原生性能。

Vitest 4.1

Vite 生态衍生的 Vitest 是 GitHub 前十的测试框架,近期也发布了 4.1 次版本,主要包括:

  • 采用新鲜出炉的 Vite 8
  • 测试标签分组,按标签设置或筛选测试,借鉴 pytest 筛选标签的自定义语法
  • 开发体验优化,比如自定义 UI 窗口配置,Playwright 追踪视图改进,自动生成 GitHub Actions Job 摘要
  • Vitest 的 VS Code 扩展现在支持 Deno,import 语句后会显示模块加载时间

vscode.png

🛜 官方情报

ECMA-426 源码映射格式规范

Source Map(源码映射)是一种特殊的 JSON 文件,用于在我们编写的源码和运行时代码之间进行映射。

举个栗子,我们在开发时可能编写一些强大的方言,比如 Sass 或 TypeScript,再把它们转换成 HTML、CSS 或 JavaScript,这样浏览器才能正常执行。

source-map.gif

问题在于,当我们使用 devtools(开发者工具) debug 时,我们希望直接定位到源码,而不是编译或压缩后人类不可读的代码。

这时就要用到 Source Map 了,这个 JSON 文件中保留了源码和运行时代码之间的映射关系,比如哪一行、哪一列等等。

过去,Source Map 并未被标准化,大家通过一份谷歌文档约定实现,但一些功能始终无法协调。

为此,彭博社成立了 TC39-TG4(源码映射工作组),制定了 ECMA-426(源码映射格式规范)。

ecma.png

近年来,它们标准化了更多功能,Scopes 和 Range Mappings 也即将上线 devtools!

React 文档更新

React 文档更新了 <ViewTransition /> 组件结合 <Activity /> 组件章节,如果你想让组件在保持状态的同时实现进场或出场动画,可以使用 <Activity /> 组件。

react-doc.png

Svelte 最佳实践

Svelte 文档更新了“最佳实践“章节,帮助大家编写快速健壮的 Svelte 应用,可以将 svelte-core-bestpractices 投喂给 AI 代理作为 Skills 使用。

svelte-doc.png

🛠️ 工具推荐

Vite+ 全家桶开源

随着 Vite 生态的各个工具逐渐成熟,尤大创立的 Void Zero 公司也官宣:Vite+ 进入 Alpha 阶段!

Vite+ 是将 Vite 生态所有开发工具叠加在一起的一体化工具链,由 Vite Task 任务运行器驱动,提供了 vp install / vp dev 等命令。

具体而言,Vite+ 把 Vite 生态的所有流行的开源软件 —— Vite、Vitest、Oxlint、Oxfmt、Rolldown 和 tsdown 都添加到一个全家桶,用于开发、测试、代码质检、格式化和构建生产环境项目。

此外,Vite+ 还支持管理 pnpm / Bun 等包管理器,甚至能管理 Node 的版本。同时,我们配置的 lint 或格式化规则,比如 eslint.config.js.prettierrc 等配置文件,可以整合到单一的 vite.config.js 中。

vite-plus.gif

之前,Vite+ 原本要求对企业用户付费授权,现在尤大直接开源,完全免费。不管是公司还是独立开发者,都能纵享丝滑了,感谢 Void Zero 的慷慨!

随着 Vite+ 官宣 Alpha 版本,很多项目开始试用,Vite+ 目前已经集成到 Vue 源码的相关分支,还有 Vue CLI create-vue,进一步投入到生产环境测评。

Void Cloud 全栈开发

尤大在 Vue 大会演讲的最后,致敬乔布斯经典的“One More Thing“环节放大招,透露了新产品 Void Cloud。

VOID 是 Vite+ / Optimized(优化)/ Isomorphic(同构)/ Deployment(部署)这几个设计理念的首字母缩写,这是一款 Vite 专属的云服务插件,也是一个也云服务平台,或者全栈元框架。

Void 内置了强大的后端 SDK(软件开发工具),包括数据库、键值存储、对象存储、AI 推理、身份认证等后端应用常见功能,可以按需采用。

void.gif

由于 Void 基于 Vite 生态,因此所有前端框架/元框架天然支持,比如 React、Vue、Nuxt 等,且支持静态站点生成或服务端渲染等不同渲染方式。

Void Cloud 旨在让 Vite 用户能够一键部署,直接上线全栈应用,标志着 Vite 生态将进军全栈开发领域。

Antdv Next 组件库

我一般不推荐组件库,因为 GitHub 有大量成熟的组件库供大家白嫖,容易选择困难。但最近新出了一个 Vue 3 的组件库,它就是 Antdv Next!

Antdv Next 是一套开箱即用的高质量 Vue3 企业级组件库,基于阿里系的蚂蚁设计系统构建。

阿里系之前 Ant Design Vue 是比较流行的组件库,虽然其源码仓库还有提交,但我发现 2024 之后就没有再发布新版本了,目测不会推出新功能了。Ant Design Vue 支持 Vue 2 和 Vue 3,而 Antdv Next 只服务于 Vue 3。

我粗略看了一下,Antdv Next 采用了现代化的技术栈,比如 Vite / Vitest / pnpm 等,可以集成 AI、UnoCSS、Tailwind CSS 等。

Vue 初学者最不习惯的应该是 Antd 系列的组件源码都是基于 TSX 来实现,而不是常见的 SFC,但这只影响开源贡献,不会影响我们以 SFC 的方式使用。

由于组件实现采用了 TSX,Antdv Next 的自定义主题相应地也采用了 CSS-in-JS,其主题是目前我个人比较喜欢的亮点之一。

antdv-next.gif

总之,Antdv Next v1.0 已经正式官宣,值得继续关注,欢迎大家去 GitHub star 支持一波~

🙏 特别鸣谢

以上就是本期《Web 周刊》的全部内容了,希望对你有所帮助。

👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

😘 已经关注我的粉丝们,我们下期再见啦,掰掰~~

cat-thank.gif

从输入 URL 到页面:一个 Vue 项目的“奇幻漂流”

作者 LanceJiang
2026年4月12日 16:00

🧭 从 URL 到页面:一个 Vue 项目的“奇幻漂流”

这是一段你每天都可能经历的旅程:在浏览器输入一个地址,按下回车,几毫秒后,一个 Vue 单页应用就活生生地出现在屏幕上。这背后发生了什么?

Vue 的响应式系统、虚拟 DOM、编译器和“发布‑订阅”主角们——Observer、Dep、Watcher、Patch——是如何协作的?

让我们像侦探一样,一步步追踪这段旅程,用有趣但不失严谨的方式,把整个技术链路掰开揉碎。

🚀 第一站:浏览器 —— 资源的“快递小哥”

输入 URL → DNS 解析 → TCP 连接 → 请求 HTML → 接收响应

当你在地址栏敲下 https://my-vue-app.com,浏览器立刻化身快递调度中心:

  1. DNS 查询: 把域名变成 IP 地址(比如 192.0.2.1)。
  2. TCP 握手: 与服务器建立可靠连接。
  3. 发送 HTTP 请求: 告诉服务器“我要你的首页”。
  4. 服务器返回 HTML: 通常一个极简的 index.html,里面只有一个 <div id="app"></div> 和一串 <script src="/js/chunk-vendor.js"> 之类的标签。

这时 Vue 还没现身,只是一个空壳 HTML 被浏览器解析。但关键的 JS 文件已经开始下载——它们才是 Vue 的“灵魂”。

📦 第二站:Vue 实例诞生 —— “造物主”的仪式

当浏览器加载并执行完打包后的 JS 文件(通常由 Webpack/Vite 生成),Vue 的舞台正式搭好。

// main.js —— 一切从这里开始
new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

这行代码背后,Vue 内部展开了一场精密的初始化交响乐:

🎼 乐章一:合并选项 & 生命周期初始化

  • 将传入的 routerstorerender 等与默认配置合并。
  • 设置内部标志(如 _isMounted),调用 beforeCreate 钩子。

🎼 乐章二:数据响应式 —— Observer 的“大改造”

beforeCreate钩子执行完,执行initState 接着初始化 injectinitState(propsdatacomputedwatch)provide

function initState(vm) {
    initProps(vm, opts.props);
    initMethods(vm, opts.methods); // 处理 methods
    initData(vm);       // 调用 observe() 将 data 转为响应式
    initComputed(vm, opts.computed);// 处理 computed
    initWatch(vm, opts.watch); // 处理 watch
}

响应式data这是最精彩的部分。Vue 会遍历 data() 返回的对象,递归地把每一个属性变成响应式:

  • Vue 2:用 Object.defineProperty 重写 getter/setter,每个属性配一个专属的 Dep(依赖管理器)。
  • Vue 3:用 Proxy 代理整个对象,更强大(能监听属性添加/删除)。
    // 简化的响应式模型
    data() {
      return { count: 0, user: { name: 'Alice' } }
    }
    
    // ↓ 响应式数据 内部主要实现

    // 1. Observer(观察者)- 数据劫持
    /**核心工作:
     *  - 为对象添加 __ob__ 属性,指向 Observer 实例
     *  - 对数组:重写 push/pop/shift/unshift/splice/sort/reverse 方法
     *  - 对对象:调用 defineReactive 将每个属性转换为 getter/setter
     */
    class Observer {
        constructor(value, shallow = false, mock = false) {
            this.value = value;
            this.shallow = shallow;
            this.dep = new Dep();        // 每个 Observer 持有一个 Dep
            this.vmCount = 0;
            def(value, '__ob__', this);  // 在对象上标记 __ob__
    
            if (isArray(value)) {
                // 数组:拦截变异方法
                this.observeArray(value);
            } else {
                // 对象:遍历每个属性,转换为 getter/setter
                const keys = Object.keys(value);
                for (let i = 0; i < keys.length; i++) {
                    const key = keys[i];
                    defineReactive(value, key, NO_INITIAL_VALUE, undefined, shallow, mock);
                }
            }
        }
    }
    function defineReactive(obj, key, val) {
        observe(val); // 递归处理嵌套对象
        const dep = new Dep(); // 每个属性有自己的依赖管理器
        Object.defineProperty(obj, key, {
            get() {
                if (Dep.target) { // 当前正在执行的 Watcher
                    dep.addSub(Dep.target); // 依赖收集
                }
                return val;
            },
            set(newVal) {
                if (newVal !== val) {
                    val = newVal;
                    observe(newVal); // 新值如果是对象,也需要转为响应式
                    dep.notify(); // 派发更新,通知所有 Watcher
                }
            }
        });
    }
    
    // Dep 类 是一个依赖收集器,充当发布-订阅模式的调度中心:
    class Dep {
        constructor() { this.subs = []; }
        addSub(watcher) { this.subs.push(watcher); }
        notify() { this.subs.forEach(w => w.update()); }
    }
    // ↓ 经过 Observer
    count 拥有了 getter/setter + 一个 Dep
    user 对象也被递归改造,name 同样拥有 getter/setter + Dep

同时,computedwatch 也会创建对应的 Watcher(观察者)。但此时它们都只是“预备役”,还没有真正去订阅数据。

🎼 乐章三:created 钩子触发

现在 datacomputedmethods 都已经可用,但 DOM 还不存在。你可以在 created 里发起异步请求、设置定时器,因为响应式数据已经 ready。

🛠️ 第三站:编译 —— 模板如何变成“渲染函数”?

Vue 有两种方式获得 render 函数:

  • 你直接提供了(比如单文件组件里的 <script> 导出 render)。
  • 或者 Vue 需要编译模板——这是最通用的方式。 假设我们有一个模板:
<div id="app">
  <p>{{ message }}</p>
  <button @click="count++">Click me</button>
</div>

Compiler 会做三件事:

  1. 解析(Parse): 把模板字符串转换成 AST(抽象语法树)。AST 就是一个 JS 对象,精准描述了 DOM 结构、指令、文本插值等。
  2. 优化(Optimize): 标记静态节点(比如没有绑定任何动态数据的纯文本)。这一步为后续虚拟 DOM 的 diff 减负。
  3. 代码生成(Codegen): 从 AST 生成一个可执行的 render 函数,类似:
function render() {
    with(this) {
        return _c('div', { attrs: { id: 'app' } }, [
            _c('p', [_v(_s(message))]),
            _c('button', { on: { click: () => count++ } }, [_v('Click me')])
        ])
    }
}

注意:编译阶段不会把 {{ message }} 替换成具体值,也不会为每个指令绑定更新函数。它只产出 render 函数,真正的数据替换要到运行时。

##🎬 第四站:首次渲染 —— 从数据到真实 DOM 的“首秀”

🎼 乐章四:mountComponent 组件挂载阶段

created执行结束,开始执行 $mount 进入组件挂载阶段。

$mount 现在 datacomputedmethods 都已经可用,但 DOM 还不存在。你可以在 created 里发起异步请求、设置定时器,因为响应式数据已经 ready。

$mount 函数被调用,Vue 创建了一个渲染 Watcher

Vue.prototype.$mount = function (el, hydrating) {
    // ...
    return mountComponent(this, el, hydrating)
}
function mountComponent(vm, el, hydrating) {
    vm.$el = el;

    callHook$1(vm, 'beforeMount');
    // 创建更新函数
    const updateComponent = () => {
        vm._update(vm._render(), hydrating);  // render 生成 vnode,update 更新 DOM
    };
    // 创建渲染 Watcher !!!!!!!!!!!!!在这呢~
    new Watcher(vm, updateComponent, noop, {
        before() {
            if (vm._isMounted && !vm._isDestroyed) {
                callHook$1(vm, 'beforeUpdate');
            }
        }
    }, true)
    if (vm.$vnode == null) {
        vm._isMounted = true;
        callHook$1(vm, 'mounted');
    }
    return vm;
}

class Watcher {
    constructor(vm, expOrFn, cb, options, isRenderWatcher) {
        this.vm = vm;
        this.deps = [];          // 当前依赖的 Dep 列表
        this.newDeps = [];       // 新一轮收集的 Dep 列表
        this.depIds = new Set(); // 避免重复添加
        this.getter = expOrFn;   // 获取值的函数(渲染函数或表达式)

        this.value = this.lazy ? undefined : this.get();
    }

    get() {
        pushTarget(this);  // 将自己设为 Dep.target
        let value;
        try {
            value = this.getter.call(this.vm, this.vm);  // 执行 getter,触发依赖收集
        } finally {
            popTarget();      // 恢复上一个 Dep.target
            this.cleanupDeps(); // 清理不再需要的依赖
        }
        return value;
    }

    addDep(dep) {
        const id = dep.id;
        if (!this.newDepIds.has(id)) {
            this.newDepIds.add(id);
            this.newDeps.push(dep);
            if (!this.depIds.has(id)) {
                dep.addSub(this);  // 双向绑定:Watcher 订阅 Dep
            }
        }
    }

    update() {
        if (this.lazy) {
            this.dirty = true;
        } else if (this.sync) {
            this.run();
        } else {
            queueWatcher(this);  // 异步队列更新
        }
    }
}

updateComponent 内部就是:vm._update(vm._render(), ...)。 渲染 Watcher 会立即执行一次,开启首次渲染之旅。

1️⃣ _render() —— 生成虚拟 DOM (VNode)

调用刚才生成的 render 函数。 在 render 执行过程中,this.messagethis.count 被读取 → 触发它们的 getter → 依赖收集开始!

  • 每个响应式属性的 Dep 会检查当前是否有活动的 Watcher(此时就是渲染 Watcher)。
  • 如果有,就把这个渲染 Watcher 添加到自己的订阅列表(subs)中。
// 伪代码:依赖收集
getter() {
  if (Dep.target) {
    dep.depend()  // 把 Dep.target(渲染 Watcher)加入 subs
  }
  return value
}

结果:messagecount 现在“认识”了渲染 Watcher。以后它们变了,就知道该通知谁。 render 最终返回一棵 VNode 树——一个轻量级的 JS 对象,描述了 DOM 结构。

2️⃣ _update() —— patch 挂载到真实 DOM

调用 __patch__ 函数,首次渲染时 oldVnode 是挂载点(真实 DOM 元素,比如 <div id="app">),vnode 是新 VNode。

patch 会递归地创建真实 DOM 元素,设置属性、事件监听(比如 @click 被绑定到真正的 click 事件),最后把生成的 DOM 插入到页面中。

页面终于显示了! 🎉 随后 mounted 钩子被调用,你可以在里面操作 DOM 了。

🔄 第五站:交互与响应式更新 —— “自动档”的魔法

用户点击了“Click me”按钮,count++ 被执行。

1️⃣ 数据变化

countsetter 被触发,内部调用 dep.notify()

2️⃣ 派发更新

dep.notify() 会遍历 subs 列表(里面目前有渲染 Watcher),调用每个 Watcher 的 update() 方法。

3️⃣ 异步调度

update() 不会立即重新渲染,而是调用 queueWatcher(this) 把渲染 Watcher 放入一个异步队列。 Vue 通过 nextTick(微任务或降级宏任务)来批量处理更新,避免同一个 Watcher 被重复添加(去重)。

4️⃣ 重新渲染与 Diff

在下一个 tick,队列被清空:

  • 渲染 Watcher 执行 run() → 再次调用 updateComponent。
  • 重新执行 render() 生成新 VNode(此时 count 已经变成新值,依赖收集会重新建立,旧依赖会被清理)。
  • 调用 _update() 执行 patch(oldVNode, newVNode)Diff 算法登场(Vue 2 双端比较 / Vue 3 快速 diff + 最长递增子序列):
  • 比较新旧 VNode 树,找出最小变化集。
  • 只更新变化的部分(比如按钮文本从 “Click me” 变成 “Click me (1)”),而不重新渲染整个列表。 最终真实 DOM 被高效更新,用户看到了新的数字。 随后 updated 钩子触发。

🗺️ 完整流程图

URL 输入
   ↓
DNS 解析 → TCP 连接
   ↓
HTML 加载 & 解析 JS
   ↓
new Vue() 
   ├─ 合并选项
   ├─ beforeCreate(inject → props → )
   ├─ initInjections → initState(methods → data → computed → watch)
   ├─── Observer 转换 data(响应式 + Dep)
   ├─── 初始化 computed / watch(创建 Watcher)
   ├─ created
   └─ $mount
        ├─ 编译模板 → render 函数(如果没提供)
        ├─ 创建渲染 Watcher(Vue 2) / Effect(Vue 3)
        │    ├─ 执行 _render() → 读取响应式数据 → 依赖收集(数据→Dep→Watcher) → 生成VNode
        │    └─ 执行 _update() → patch → 真实 DOM
        └─ mounted
   ↓
用户交互(修改数据)
   ├─ setter → dep.notify()
   ├─ 渲染 Watcher 被推入异步队列
   ├─ nextTick 执行队列
   │    ├─ 重新执行 _render() → 新 VNode
   │    └─ patch(oldVNode, newVNode) → Diff → 更新 DOM
   └─ updated

🧐 一些有趣的细节(常见疑问)

❓ “模板里没用到的数据,会不会也被依赖收集?”

不会。渲染 Watcher 只收集本次渲染实际访问到的数据。如果 v-if 为 false 导致某个分支从未进入,那分支里的数据就不会被收集。当条件变为 true 时,下一次渲染会自动订阅它们。

❓ “v-showv-if 在依赖收集上有什么不同?”

  • v-if:条件为 false 时,该分支根本不渲染 → 不读取内部数据 → 无依赖收集 → 内部数据变化不会触发更新。
  • v-show:只是 CSS 隐藏,DOM 一直存在 → 每次渲染都会读取内部数据 → 依赖始终存在 → 数据变化会触发重新渲染(即使看不见)。

❓ “Observer 在发布‑订阅里是什么角色?”

它是“装修工人”——在初始化时把普通数据改造成带 getter/setterDep 的响应式对象。它不直接参与发布或订阅,但它是整个系统能够运转的基础。

❓ “Vue 3 比 Vue 2 快在哪?”

  • Proxy 代替 Object.defineProperty,可监听属性添加/删除、数组索引等。
  • 编译优化:静态提升、补丁标记、块树 → 让 diff 跳过静态内容。
  • 快速 diff + 最长递增子序列 → 减少 DOM 移动次数。

🎯 总结:从 URL 到像素的“奇幻漂流”

阶段 核心角色 产出
资源加载 浏览器、HTTP HTML + JS
Vue 初始化 ObserverDepWatcher 响应式数据 + 实例
模板编译 Compiler render 函数
首次渲染 渲染 Watcherrenderpatch 真实 DOM
交互更新 setterDep.notify、调度器、patch + diff 最小化 DOM 更新

总结: 从输入 URL 到 Vue 项目渲染,整个链路是:

URL 输入 → 网络加载(HTML 加载) & 解析 JS → Vue实例初始化(响应式数据、编译)→ 首次渲染 Watcher → 执行 render 生成 VNode → patch 创建真实 DOM → 挂载完成 →用户交互 → 数据变化 → 响应式派发 → 重新渲染 → Diff 更新 DOM

这趟旅程中,Vue 的每一个设计都精妙地平衡了声明式编程的优雅与底层性能的极致。希望这次“共探”,能让你下次启动 Vue 项目时,看到的不只是一个页面,而是一整套精心编排的幕后舞剧。

昨天以前首页

Vue3 日历组件选型指南:五大主流方案深度解析

作者 walking957
2026年4月11日 17:13

在 Vue3 项目开发中,日历组件是日程管理、预约系统、数据可视化等场景的核心组件。不同项目对日历的功能需求差异极大——有的只需基础日期选择,有的需要支持多日程展示、自定义节假日、拖拽调整等复杂功能。本文从「易用性、扩展性、性能」三个维度,深入分析 5 款主流 Vue3 日历组件,并提供选型建议,帮助开发者快速找到适配场景的最佳方案。

一、Vue3 Datepicker:轻量无依赖的基础款

Vue3 Datepicker 是一款纯 Vue3+TypeScript 开发的日历组件,主打轻量与无依赖特性。该组件体积仅约 5KB,却提供了日期范围选择、禁用日期、自定义格式等实用功能。其样式简洁,开发者可通过 CSS 轻松覆盖默认样式,同时完美适配移动端与 PC 端。得益于纯 Vue3 的实现方式,该组件对 Composition API 和 Options API 都有良好的兼容性。

安装命令:

npm install vue3-datepicker --save

使用示例:

<template>
  <div class="basic-calendar">
    <Datepicker
      v-model="selectedDate"
      :disabled-dates="disabledDates"
      format="YYYY-MM-DD"
      placeholder="选择日期"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import Datepicker from 'vue3-datepicker';
import 'vue3-datepicker/dist/index.css';

const selectedDate = ref<Date | null>(null);

const disabledDates = (date: Date) => {
  const day = date.getDay();
  return day === 0 || day === 6;
};
</script>

<style scoped>
.basic-calendar {
  width: 300px;
  margin: 20px;
}
</style>

适用场景:表单中的生日选择、订单日期筛选等轻量级日期选择需求,以及追求极小打包体积的项目。

二、Element Plus Calendar:生态集成的标准化选择

Element Plus Calendar 是饿了么团队出品的企业级日历组件,与 Element Plus 组件库深度集成,视觉风格统一。该组件支持月视图、周视图、日视图三种模式切换,提供日程数据绑定能力,开发者可自定义单元格内容展示。内置国际化功能、日期范围选择、禁用日期等基础能力,并提供完整的 TypeScript 类型定义,可与 Vue3+Vite 开发环境无缝配合。

安装命令:

npm install element-plus --save

使用示例:

<template>
  <div class="el-calendar-demo">
    <el-calendar v-model="currentDate">
      <template #date-cell="{ data }">
        <p :class="data.isSelected ? 'is-selected' : ''">
          {{ data.day.split('-').pop() }}
        </p>
        <span v-if="scheduleMap[data.day]" class="schedule-count">
          {{ scheduleMap[data.day] }}条日程
        </span>
      </template>
    </el-calendar>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElCalendar } from 'element-plus';
import 'element-plus/dist/index.css';

const currentDate = ref<Date>(new Date());

const scheduleMap = ref({
  '2026-02-06': 3,
  '2026-02-08': 1,
  '2026-02-10': 2,
});
</script>

<style scoped>
.is-selected {
  color: #409eff;
  font-weight: bold;
}
.schedule-count {
  font-size: 12px;
  color: #f56c6c;
}
</style>

适用场景:使用 Element Plus 组件库的中后台管理系统,需要快速实现标准化日历和日程功能的项目。

三、FullCalendar Vue3:复杂场景的全功能方案

FullCalendar Vue3 是基于业界知名的 FullCalendar 核心库封装的 Vue3 组件,专为复杂日程管理场景设计。该组件支持月视图、周视图、日视图、列表视图、时间轴视图等 10 余种视图类型,提供了日程拖拽、调整时长、重复日程设置、自定义事件渲染等丰富功能。组件兼容 Vue3 的组合式 API,可与 Pinia 或 Vuex 状态管理库无缝集成。此外,还支持 Google 日历和 iCal 导入,具备国际化与时区切换能力。

安装命令:

npm install @fullcalendar/vue3 @fullcalendar/core @fullcalendar/daygrid @fullcalendar/interaction

使用示例:

<template>
  <div class="full-calendar-demo">
    <FullCalendar
      :plugins="calendarPlugins"
      initialView="dayGridMonth"
      :events="calendarEvents"
      editable="true"
      selectable="true"
      @dateClick="handleDateClick"
      @eventClick="handleEventClick"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';

const calendarPlugins = ref([dayGridPlugin, interactionPlugin]);

const calendarEvents = ref([
  { title: '产品评审会', start: '2026-02-06', end: '2026-02-07', color: '#409eff' },
  { title: '版本发布', start: '2026-02-09', color: '#67c23a' },
]);

const handleDateClick = (info: any) => {
  alert(`选择了日期: ${info.dateStr}`);
};

const handleEventClick = (info: any) => {
  alert(`点击了日程: ${info.event.title}`);
};
</script>

<style scoped>
.full-calendar-demo {
  width: 90%;
  margin: 20px auto;
}
</style>

适用场景:企业 OA 系统、会议室预约、课程表管理等复杂日程管理场景,需支持拖拽操作、多视图切换、复杂事件配置的项目。

四、Vant4 Calendar:移动端友好的轻量选择

Vant4 Calendar 是有赞团队出品的移动端日历组件,专为移动端 H5 和小程序场景优化。该组件在交互设计上充分考虑移动端特性,支持滑动切换月份、手势操作等移动端常见交互方式。功能方面支持日期范围选择、快捷日期选择(如近 7 天、近 30 天)、自定义弹窗样式等实用能力。组件体积仅约 8KB,性能表现优异,支持按需引入,与 Vant4 组件库整体风格保持一致。

安装命令:

npm install vant --save

使用示例:

<template>
  <div class="vant-calendar-demo">
    <van-button @click="showCalendar = true">选择日期</van-button>
    <van-calendar
      v-model:show="showCalendar"
      v-model="selectedDate"
      type="range"
      :min-date="minDate"
      :max-date="maxDate"
      @confirm="handleConfirm"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { VanCalendar, VanButton } from 'vant';
import 'vant/lib/index.css';

const showCalendar = ref(false);
const selectedDate = ref<[Date, Date]>([new Date(), new Date()]);
const minDate = ref(new Date('2026-01-01'));
const maxDate = ref(new Date('2026-12-31'));

const handleConfirm = (dates: [Date, Date]) => {
  console.log('选择的日期范围:', dates);
  showCalendar.value = false;
};
</script>

适用场景:移动端 H5 页面、小程序项目,需轻量级、交互友好的日期选择功能。

五、Vue3 Simple Calendar:极简逻辑的定制基石

Vue3 Simple Calendar 是一款独特的日历组件,它不包含任何样式封装,仅提供核心日历逻辑。该组件基于 Vue3 Composition API 开发,体积仅 3KB,没有任何第三方依赖。开发者可以完全自定义 UI 和交互方式,组件只负责处理日历的基本逻辑,如月份切换、日期选中、日期渲染回调等。

安装命令:

npm install vue3-simple-calendar --save

使用示例:

<template>
  <div class="custom-calendar">
    <simple-calendar
      v-model="currentMonth"
      @date-click="handleDateClick"
    >
      <template #header="{ year, month, prevMonth, nextMonth }">
        <div class="calendar-header">
          <button @click="prevMonth">上一月</button>
          <h3>{{ year }}年{{ month }}月</h3>
          <button @click="nextMonth">下一月</button>
        </div>
      </template>
      <template #day="{ date, isToday, isWeekend }">
        <div
          class="day-cell"
          :class="{ today: isToday, weekend: isWeekend, selected: selectedDate === date }"
        >
          {{ date.getDate() }}
        </div>
      </template>
    </simple-calendar>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import SimpleCalendar from 'vue3-simple-calendar';

const currentMonth = ref<Date>(new Date());
const selectedDate = ref<Date | null>(null);

const handleDateClick = (date: Date) => {
  selectedDate.value = date;
  console.log('选中日期:', date);
};
</script>

<style scoped>
.custom-calendar {
  width: 350px;
  margin: 20px;
}
.calendar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 10px;
}
.day-cell {
  width: 50px;
  height: 50px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.today {
  background-color: #409eff;
  color: white;
  border-radius: 50%;
}
.weekend {
  color: #f56c6c;
}
.selected {
  border: 2px solid #67c23a;
  border-radius: 50%;
}
</style>

适用场景:需要品牌化设计日历 UI、特殊交互效果的项目,或仅需复用核心日历逻辑的定制化开发场景。

六、选型指南与核心原则

面对多种日历组件选择,开发者需要根据项目实际情况做出判断。以下是各组件的核心对比:

组件类型 核心优势 适用场景 打包体积
Vue3 Datepicker 轻量无依赖、易定制 基础日期选择、表单场景 ~5KB
Element Plus Calendar 大厂背书、生态集成 中后台标准化日程功能 ~15KB
FullCalendar Vue3 全功能、复杂交互 企业 OA、预约系统 ~100KB
Vant4 Calendar 移动端适配、交互友好 移动端 H5、小程序 ~8KB
Vue3 Simple Calendar 完全自定义、极简逻辑 个性化 UI、定制化交互 ~3KB

怎么在VS Code 调试vue2 源码

作者 gongzemin
2026年4月11日 14:19

总结一下怎么在VS Code 调试vue2 源码

  • 克隆vue2源码到本地
  • 在源码根目录安装依赖 把项目跑起来 生成/dist/vue.js文件
  • 在/examples/ 下随便找个文件(引入的文件要是我们生成的vue.js) 打上断点
  • 安装Live Server插件 把我们打上断点的文件在浏览器打开
  • 在.vscode文件夹下配置launch.json
  • 点击VS Code的Run and Debug图标 就可以进入了

我们开始吧~

克隆vue2源码到本地

去Github克隆源码,克隆后我们用VS Code打开。

git clone https://github.com/vuejs/vue

image.png

在源码根目录安装依赖 把项目跑起来
pnpm i

image.png

image.png

把项目跑起来

npm/pnpm run dev

image.png

bundles /Users/gongzemin/Documents/GitHub/vue/src/platforms/web/entry-runtime-with-compiler.ts → dist/vue.js...

entry-runtime-with-compiler.ts 这个入口文件打包生成 dist/vue.js 这个最终可用的 Vue 文件

生成了dist文件夹 里面有vue.js

image.png

在examples/ 下随便找个文件 打上断点

我们找examples/classic/commits/app.js 在如图位置打上断点

image.png

commits/index.html 这个文件引入了vue.min.js, 我们刚才构建出来的是vue.js文件,我们把引入的文件改成vue.js

<script src="../../../dist/vue.js"></script>
安装Live Server插件 把我们打上断点的文件在浏览器打开

image.png

安装好插件后,打开文件的上下文菜单 可以看到Open with Live Server

image.png

这样我们就可以打开我们的examples/classic/commits/index.html 文件了 是用服务器打开的

image.png

在.vscode文件夹下配置launch.json

注意这里的URL是我们的要调试URL路径

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Run parser",
      "runtimeExecutable": "parser",
      "cwd": "${workspaceFolder}/packages/reactivity-transform/node_modules/@vue/compiler-core",
      "args": []
    },
    {
      "type": "chrome",
      "request": "launch",
      "name": "调试Vue源码",
      "url": "http://localhost:5501/examples/classic/commits/index.html",
      "webRoot": "${workspaceFolder}",
      "sourceMaps": true,
      "sourceMapPathOverrides": {
        "webpack:///./*": "${webRoot}/*",
        "webpack:///packages/*": "${webRoot}/packages/*",
        "*": "${webRoot}/*"
      },
      "skipFiles": ["<node_internals>/**"]
    }
  ]
}
点击VS Code的Run and Debug图标

点击Run and Debug图标, 选择调试Vue源码(就是我们配置launch.json里面配置的name)

image.png 看到app.js 进入我们打的断点了

image.png

我们点击Step Into

image.png

就进入Vue()构造函数了

image.png

调试vue3源码方法也一样 参考这篇笔记

能够插入 DOM 的输入框

2026年4月11日 11:00

简易富文本编辑器

使用input、textarea 这种输入框会出现一个问题,就是无法在其中写入 DOM 结构,浏览器不会把 DOM 进行渲染,这样的话在某些情况下使用他们只会浪费时间,复制粘贴半天,发现没办法放 UI 内容,无敌了孩子。

如果你的内容需要很多操作可以选择去使用富文本编辑器,这里就说一下怎么写一个简单的富文本编辑器。

     <div
        id="editor"
        contenteditable="true" // 赋予容器可编辑的能力
        ref="editorRef"
      ></div>

只要是 DOM 能放的结构,他都可以。

他也有一些缺点,就是没有input简便,好写,而且它只有一部分 input 对应的方法, 比如以下常见方法:

  • input
  • paste
  • blur、focus
  • keydown、keyup

如何插入 DOM(组件) 和文本

插入 DOM

const textNode = document.createTextNode(featureData.description); // 创建文本
const placeholder = document.createElement('span'); // 创建节点
placeholder.contentEditable = false; // 不可编辑
// 变量记录文本节点
featureData.lastTextNode = textNode;
featureData.lastTagHolder = placeholder; 
// 在编辑器最前方进行插入
editor.insertBefore(textNode, editor.firstChild);
editor.insertBefore(placeholder, editor.firstChild);

在vue的程序里面想要在普通函数中动态创建、挂载、操作组件可以通过vue提供的createApp去创建vue的节点

const app = createApp({
    render: () =>
      h(Tag, {
        text: featureData.title, // 组件 props 
        bgColor: featureData.bgColor, // 组件 props 
        onClose: () => {
          featureData.lastApp?.unmount();
          featureData.lastApp = null;
          featureData.lastTextNode?.remove();
          featureData.lastTagHolder?.remove();
          featureData.lastTextNode = null;
          featureData.lastTagHolder = null;
        },
      }),
  });
  app.mount(placeholder);
  featureData.lastApp = app; // 记录app实例进行卸载

h 函数

用于创建虚拟节点,可以渲染多个/嵌套/动态结构。

  1. 渲染组件 vnode 时 children 参数需要通过插槽函数书写,可以通过设置props为null避免将插槽识别为props。
  2. 渲染为 html 的节点 children 可以随意文本或者数组传递多个节点。
function h(
  type: string | Component,
  props?: object | null,
  children?: Children | Slot | Slots   // 为组件时需要通过插槽函数
): VNode

h( 
    组件 / 标签名, 
    属性、props、事件, 
    子节点/内容            // 子节点不是插槽就可以省略 props 书写
)
// 多个节点
h(
    'div'
    null,
    [
        h('div','文字') 
    ]
)
// 动态结构
h('div', isShow ? h(Tag) : h('span', '无标签') )

// 组件插槽传递 vnode
h(Components,null,{default:()=>'你的内容'})// 默认插槽

// html节点
h('div',null,['文字', h('span', '内容')])

鼠标选中区域

可以通过选中区域对文本区域进行记录,选中区域内容、获取选区范围等等,可以用于加粗、添加标题。

// 创建鼠标选区
  const range = document.createRange();
  // 设定鼠标选中区域
  range.setStartAfter(textNode); // 在 textNode 后面开始
  range.setEndAfter(textNode);   // 在 textNode 后面结束
  // 获取选区管理
  const sel = window.getSelection();
  // 获取选中文字
  const selectedText = sel.toString()
  // 获取第一个选区
  const range = sel.getRangeAt(0)
  // 移除先前选区
  sel.removeAllRanges();
  // 记录当前鼠标选区
  sel.addRange(range);

VTJ.PRO 发布 v2.3.6:开放共享模版、优化发布流程,低代码开发体验再升级

2026年4月11日 09:22

摘要: 基于 Vue 3 的开源 AI 低代码平台 VTJ.PRO 于 2026 年 4 月 10 日正式发布 v2.3.6 版本。本次更新聚焦模版共享与发布体验,开放共享模版功能,整合发布操作链路,并优化了版本控制与自动化截图能力,进一步降低了项目复用与协作的门槛。

未命名.png


开放共享模版,构建可复用的组件生态

v2.3.6 最值得关注的变化是 开放共享模版功能。开发者现在可以将自己设计的页面、模块或完整应用打包为模版,并发布到共享空间供团队或社区复用。同时,发布模版的版本控制机制得到强化,解决了此前模版更新失败的问题,使模版的迭代和回滚更加可靠。

  • 发布模版后,系统会自动在开发项目中创建对应的模版引用页面,实现“一次发布,处处引用”。
  • 模版共享结合原有的 AI 能力(设计稿转代码、自然语言生成页面),可大幅提升团队内部标准化组件的沉淀效率。

发布操作统一化,支持自动截图

为了减少多入口切换的认知负担,新版本将 发布应用、发布模版、项目出码 三个核心操作按钮整合至同一界面,开发者无需在不同菜单间跳转即可完成完整的交付流程。

此外,发布应用现已支持自动生成截图。系统会在发布时自动捕获当前应用界面的关键视图,方便在版本记录、发布日志或团队协作中快速识别应用状态。

默认公开与取消自动启动页,更贴合实际开发习惯

  • 创建应用时,访问权限默认为“公开”。这一调整降低了团队内部或开源项目中的分享门槛,同时也保持了随时可改为私有的灵活性。
  • 取消创建应用时自动新增启动页。此前新建应用会自动生成一个示例启动页,部分开发者反馈会带来额外的删除操作。新版本不再自动生成启动页,应用创建后直接进入空白设计状态,更符合从零开始的开发直觉。

开发者体验:从“可用”到“好用的低代码”

VTJ.PRO 一直强调 “降低复杂度,不降低自由度” ,v2.3.6 的更新再次印证了这一理念——通过优化发布链路和模版共享能力,让团队协作中的资产复用更加自然,同时保持对 Vue 源码的完全控制。

目前,VTJ.PRO 已在 Gitee 收获 9.9K Star,荣获 Gitee 2025 年度“大前端 Top3”。项目基于 Vue 3 + TypeScript + Vite,深度集成 ElementPlus、ECharts 等主流库,并已接入 DeepSeek、Qwen、Gemini、GPT 等 10+ 款大模型。

快速体验与更新方式


关于 VTJ.PRO
VTJ.PRO 是一款开源、基于 Vue 3 的 AI 低代码开发平台,支持可视化设计与手写代码双向转换,并提供私有化部署、多端输出(Web、H5、UniApp)、版本管理与企业级协作能力。项目始终保持“源码透明、无黑盒锁定”,是面向专业开发者的低代码解决方案。

11.png

Vue 迁移 React 实战:VuReact 一键自动化转换方案

作者 Ruihong
2026年4月10日 23:07

一、核心关键词盘点

在 Vue 转 React 的技术迁移场景中,以下核心关键词是开发者必须聚焦的核心,也是本次方案落地的关键抓手:

  • 核心诉求:Vue 3 迁移 React 18+、自动化转换、减少手动重写成本、保留 TypeScript 类型、响应式系统适配
  • 核心工具:VuReact(编译核心 @vureact/compiler-core + 运行时 @vureact/runtime-core@vureact/router
  • 核心能力:智能编译、一键命令行转换、Scoped 样式适配、Composition API 转 React Hook、渐进式迁移
  • 核心痛点:手动改写易出错、响应式系统差异、生命周期不兼容、Scoped 样式迁移、混合开发模式适配

vureact_hero_demo.gif

二、痛点拆解与优化方案

痛点 1:手动迁移成本高、易出错

现状分析

传统 Vue 转 React 需逐行改写组件、模板、响应式逻辑,大型项目耗时数月,且易因语法差异引入 Bug。

优化方案:VuReact 一键自动化编译

通过 VuReact 实现零手动改写的自动化转换,核心步骤如下:

  1. 安装核心依赖
npm install -D @vureact/compiler-core
  1. 配置转换规则 创建 vureact.config.js,精准控制输入/输出/排除规则:
import { defineConfig } from '@vureact/compiler-core';

export default defineConfig({
  input: 'src', // 待迁移的 Vue 源码目录
  exclude: ['src/main.ts'], // 排除 Vue 入口文件
  output: {
    outDir: 'react-app', // React 代码输出目录
  },
});
  1. 执行一键转换
# 完整编译(生产环境)
npx vureact build
# 实时编译(开发调试)
npx vureact watch

痛点 2:Vue 响应式系统与 React Hook 不兼容

现状分析

Vue 的 ref/computed/watch 与 React 的 Hook 模式差异大,手动转换易破坏响应式逻辑。

优化方案:响应式语法自动适配

VuReact 内置专属运行时 Hook,无缝转换 Vue 响应式语法:

Vue 3 原语法 React 转换后语法
ref(0) useVRef(0)
computed(() => {}) useComputed(() => {})
watch(source, callback) useWatch(source, callback)

实战示例

<!-- Vue 原代码 -->
<script setup lang="ts">
// @vr-name: Demo
import { ref, computed, watch } from 'vue';
const price = ref(100);
const quantity = ref(2);
const total = computed(() => price.value * quantity.value);
watch(quantity, (newVal) => console.log('数量变化:', newVal));
</script>
// VuReact 自动转换后的 React 代码:Demo.tsx
import { useVRef, useComputed, useWatch } from '@vureact/runtime-core';

const Demo =  memo(() => {
  const price = useVRef(100);
  const quantity = useVRef(2);
  const total = useComputed(() => price.value * quantity.value);
  useWatch(quantity, (newVal) => console.log('数量变化:', newVal));
});

export default Demo;

痛点 3:Vue Scoped 样式迁移后失效

现状分析

Vue 的 Scoped 样式通过 data-v-hash 隔离,React 无原生支持,手动迁移易导致样式污染。

优化方案:Scoped 样式自动模块化

VuReact 编译时自动生成 CSS Module,零运行时开销实现样式隔离:

<!-- Vue 原代码 -->
<template>
  <div class="container"><h1>标题</h1></div>
</template>
<style scoped>
.container { padding: 20px; background: #f5f5f5; }
h1 { color: #333; }
</style>
// 自动生成的 React 代码
import $style from './Component-abc123.module.css';

const Component = () => {
  return (
    <div className={$style.container} data-css-abc123>
      <h1 data-css-abc123>标题</h1>
    </div>
  );
};
/* 自动生成的 CSS Module 文件 */
.container[data-css-abc123] {
  padding: 20px;
  background: #f5f5f5;
}
h1[data-css-abc123] {
  color: #333;
}

痛点 4:大型项目无法一次性迁移

现状分析

企业级项目直接全量迁移风险高,需支持 Vue/React 混合开发、按模块渐进迁移。

优化方案:渐进式迁移策略

  1. 按目录精准迁移
# 仅迁移组件目录
npx vureact build --input src/components
# 排除遗留代码目录
npx vureact build --exclude "src/legacy/**/*"
  1. 混合开发模式配置
export default defineConfig({
  input: 'src',
  exclude: [
    'src/legacy', // 保留未迁移的 Vue 代码
    'src/main.ts', // 保留 Vue 入口
  ],
  output: { outDir: 'react-app' },
});

痛点 5:工程化配置迁移繁琐

现状分析

迁移后需重新配置 React 项目的依赖、构建工具(Vite/Webpack),耗时且易遗漏。

优化方案:全自动工程化输出

  1. 自动生成依赖清单
{
  "name": "react-app",
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "@vureact/runtime-core": "^1.0.0",
    "@vureact/router": "^2.0.1"
  },
  "devDependencies": {
    "typescript": "~5.8.3",
    "@eslint/js": "^9.25.0",
    "@types/react": "^19.1.2",
    "@types/react-dom": "^19.1.2",
    "@vitejs/plugin-react": "^6.0.1",
    "eslint": "^9.25.0",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.19",
    "globals": "^16.0.0",
    "typescript-eslint": "^8.30.1",
    "vite": "^8.0.0"
  }
}
  1. 自动生成构建配置(以 Vite 为例)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
})

三、完整迁移流程(开箱即用)

# 1. 安装 VuReact
npm install -D @vureact/compiler-core

# 2. 快速创建配置文件
echo "import { defineConfig } from '@vureact/compiler-core';
export default defineConfig({
  input: 'src',
  exclude: ['src/main.ts'],
  output: { outDir: 'react-app' },
});" > vureact.config.js

# 3. 执行迁移编译
npx vureact build

四、核心支持能力汇总

特性 VuReact 支持情况
Vue 3 <script setup> ✅ 完整支持
TypeScript 类型保留 ✅ 零丢失
模板指令(v-if/v-for) ✅ 自动转 JSX
生命周期(onMounted/onUnmounted) ✅ 转专属 Hook
Scoped 样式 ✅ 转 CSS Module
混合开发模式 ✅ 支持
渐进式迁移 ✅ 按目录/文件控制

五、总结

VuReact 作为 Vue 转 React 的一站式自动化工具,核心价值在于:

  1. 降成本:一行命令替代手动重写,迁移效率提升 90%+;
  2. 低风险:保留原有业务逻辑、TypeScript 类型,减少 Bug 引入;
  3. 高灵活:支持渐进式迁移、混合开发,适配大型项目场景;
  4. 全兼容:覆盖响应式、样式、生命周期、模板等全维度语法转换。

无论是中小型组件库迁移,还是大型企业级 Vue 应用升级 React 架构,VuReact 都能实现“无痛迁移”,让前端技术栈升级不再是技术债务,而是高效的架构迭代。

推荐阅读

Vue3 代码编写规范 | 避坑指南+团队协作标准

2026年4月10日 23:05

一、Vue3 通用基础规范(必看!统一编码底线)

1.1 编码格式规范:避免格式混乱,提升代码可读性

  • 缩进:统一使用4个空格缩进(禁止使用Tab),确保不同编辑器渲染一致。
  • 换行:每个独立代码块之间空1行,逻辑相关的代码块紧密排列,提升可读性。
  • 分号:语句结尾统一添加分号,避免因自动分号插入(ASI)导致的语法歧义。
  • 引号:模板内属性使用双引号(""),Script中字符串优先使用单引号(''),特殊场景(如嵌套引号)可灵活切换。
  • 注释:关键逻辑、复杂业务代码必须添加注释,注释需简洁明了,说明“为什么做”而非“做了什么”;组件开头可添加类注释,说明组件功能、Props、使用场景。

1.2 命名规范:一眼看懂用途,降低协作成本

核心原则:JS/TS领域遵循camelCase(小驼峰)/PascalCase(大驼峰),HTML领域使用kebab-case(连字符),保持项目内命名一致性,提升代码可读性与协作效率。

  • 变量/函数:使用camelCase,首字母小写,动词开头命名函数(如handleClick、fetchData),名词开头命名变量(如userInfo、goodsList)。
  • 常量:使用UPPER_SNAKE_CASE(全大写下划线分隔),如const API_BASE_URL = 'api.example.com'。
  • 类/组件:使用PascalCase,首字母大写,组件名需为多个单词(根组件App除外),避免与HTML原生元素冲突,如UserProfile、GoodsCard而非Todo、Button。
  • 自定义指令:使用kebab-case,如v-focus、v-scroll-to,符合HTML属性命名规范。

二、Vue3 单文件组件(SFC)规范(核心重点!避坑关键)

2.1 组件结构规范:固定结构,避免渲染异常

单文件组件(.vue)内部顺序固定为:template → script → style,每个部分独立成块,结构清晰;template内最多包含一个顶级元素,避免多根节点导致的渲染异常。

<!-- 正确示例 -->
<template>
    <div class="user-profile">
        <!-- 组件内容 -->
    </div>
</template>

<script setup>
// 逻辑代码
</script>

<style scoped>
// 样式代码
</style>

2.2 Template 规范:高效渲染,减少性能损耗

  • 指令使用:v-bind、v-on可使用简写(:、@),v-slot使用#简写;指令顺序统一为:v-for → v-if → v-bind → v-on,如<div v-for="item in list" :key="item.id" v-if="item.visible" @click="handleClick">
  • v-for 要求:必须搭配key,key值需为唯一标识(如id),禁止使用index作为key;避免在v-for内使用v-if,可通过计算属性过滤数据后再渲染,提升性能。
  • 组件引用:模板中使用组件时,优先使用PascalCase标签(如),明确区分原生HTML元素;DOM模板中必须使用kebab-case(如),因HTML不区分大小写。
  • 属性绑定:多个属性分行书写,每个属性占一行,提升可读性;布尔属性直接写属性名,如而非。

2.3 Script 规范:简洁高效,符合Vue3最佳实践

2.3.1 语法选择:优先<script setup>,拒绝混合语法

优先使用<script setup>语法(Vue3推荐),简洁高效;复杂组件(如需要生命周期钩子、Props验证、 emits定义)可结合Options API,但同一项目内语法需统一,禁止混合使用。

2.3.2 导入顺序:规范排序,提升代码可维护性

导入语句按以下顺序排列,不同类别之间空1行,提升可读性:

  1. Vue内置API(如ref、computed、watch);
  2. 第三方库(如Pinia、Axios、Element Plus);
  3. 项目内部组件(如子组件、基础组件);
  4. 工具函数、常量、样式文件;
  5. API接口请求函数。
<script setup>
// 1. Vue内置API
import { ref, computed, watch } from 'vue';
// 2. 第三方库
import { useUserStore } from 'pinia';
import axios from 'axios';
// 3. 内部组件
import BaseButton from './BaseButton.vue';
import UserCard from '@/components/UserCard.vue';
// 4. 工具函数/常量
import { formatDate } from '@/utils/format';
import { API_BASE_URL } from '@/constants';
// 5. API接口
import { fetchUserInfo } from '@/api/user';
</script>

2.3.3 Props 规范:严谨定义,避免传参异常

  • 命名:Props定义使用camelCase(如userName),模板中传递时使用kebab-case(如user-name),Vue会自动完成转换。
  • 定义:Props需详细定义,至少指定类型;必填项标注required: true,可选值通过validator验证,提升组件可维护性与容错性。
// 正确示例
const props = defineProps({
    // 基础类型定义
    userId: {
        type: Number,
        required: true,
        validator: (value) => value > 0 // 验证值为正整数
    },
    // 布尔类型,推荐前缀is
    isDisabled: {
        type: Boolean,
        default: false
    },
    // 数组/对象类型,默认值需用函数返回,避免引用共享
    goodsList: {
        type: Array,
        default: () => []
    },
    userInfo: {
        type: Object,
        default: () => ({
            name: '',
            age: 0
        })
    }
});

2.3.4 Emits 规范:明确声明,避免事件混乱

  • 命名:定义时使用camelCase(如updateValue),模板中监听时使用kebab-case(如@update-value),符合HTML属性命名习惯。
  • 定义:通过defineEmits明确声明组件触发的事件,禁止隐式触发事件;事件参数需清晰,避免传递过多参数,复杂参数建议封装为对象。
// 正确示例
const emit = defineEmits(['updateValue', 'deleteItem']);

// 触发事件(传递单个参数)
const handleValueChange = (value) => {
    emit('updateValue', value);
};

// 触发事件(传递复杂参数,封装为对象)
const handleDelete = (id, name) => {
    emit('deleteItem', { id, name });
};

2.3.5 异步逻辑规范:优雅处理,避免报错中断

  • 优先使用async/await语法,禁止使用Promise链式调用(then/catch),代码更易读且便于调试。
  • 所有async/await必须包裹try/catch,或在调用时用.catch()捕获错误,避免控制台报错和逻辑中断;错误处理需友好,可结合UI提示反馈给用户。
  • 高频触发的异步请求(如搜索输入框)必须加防抖,避免无效请求,推荐用组合式函数useDebounce封装复用。
// 正确示例(async/await + try/catch)
const fetchUser = async () => {
    try {
        const res = await fetchUserInfo(); // 调用异步接口
        return res.data;
    } catch (err) {
        console.error('获取用户信息失败:', err);
        ElMessage.error('加载失败,请重试');
        throw err; // 如需上层处理,可重新抛出错误
    }
};

// 错误示例(Promise链式调用)
const fetchUser = () => {
    return fetchUserInfo()
        .then(res => res.data)
        .catch(err => {
            console.error('获取用户信息失败:', err);
            ElMessage.error('加载失败,请重试');
            throw err;
        });
};

2.3.6 TypeScript 规范:强类型约束,减少类型报错

  • 禁止滥用any类型:除非明确兼容所有类型(如第三方库无类型声明),否则必须用具体类型、unknown或泛型;若用any,需加注释说明原因。
  • 接口(interface)与类型别名(type)区分:定义对象/类的结构用interface(支持扩展、实现);定义联合类型、交叉类型或简单类型别名用type。
  • Props/Emits 类型:使用TypeScript时,优先通过泛型定义Props和Emits类型,提升类型安全性。
// 正确示例(interface定义对象结构)
interface Goods {
    id: number;
    name: string;
    price: number;
    stock: number;
}
const goods: Goods = { id: 1, name: '手机', price: 5999, stock: 100 };

// 正确示例(type定义联合类型)
type GoodsCategory = 'electronics' | 'clothes' | 'food';

// Props类型定义
interface Props {
    userId: number;
    isDisabled?: boolean;
}
const props = defineProps<Props>();

// Emits类型定义
const emit = defineEmits<{
    (e: 'updateValue', value: string): void;
    (e: 'deleteItem', params: { id: number; name: string }): void;
}>();

2.4 Style 规范:避免污染,提升样式复用性

  • 作用域:组件样式优先使用scoped(如),避免样式污染;全局样式统一放在src/styles目录下,禁止在组件内写全局样式(除非特殊需求)。
  • 命名:样式类名使用kebab-case,与组件名、功能对应,如.user-profile、goods-card;避免使用无意义的类名(如box1、content2)。
  • 样式顺序:按“布局 → 尺寸 → 样式 → 交互”的顺序编写,如position → width → background → hover。
  • 复用:公共样式(如颜色、字体、间距)提取为变量,统一管理;重复使用的样式封装为Mixin或自定义样式类,提升复用性。

三、Vue3 组件设计规范(高复用+低耦合,团队必守)

3.1 组件拆分原则:拒绝大组件,提升可维护性

  • 单一职责:一个组件只负责一个功能,避免“大组件”(代码超过500行),复杂功能拆分为多个子组件,如将用户列表拆分为UserList(列表容器)、UserItem(列表项)、UserSearch(搜索框)。
  • 高复用低耦合:可复用组件(如按钮、输入框)提取为基础组件(放在src/components/base目录),组件间通过Props传递数据、Emits触发事件,禁止直接操作父/子组件数据。
  • 命名区分:基础组件统一前缀Base(如BaseButton、BaseInput),业务组件按功能命名(如OrderList、PaymentForm),布局组件前缀Layout(如LayoutHeader、LayoutSidebar)。

3.2 组件通信规范:清晰传参,避免数据混乱

  • 父子组件:父传子用Props,子传父用Emits,禁止子组件直接修改Props(单向数据流);复杂数据可通过v-model双向绑定(Vue3支持多v-model)。
  • 跨层级组件:优先使用Pinia状态管理,或使用provide/inject(适用于深层组件通信,需明确注入类型),禁止使用EventBus(易造成事件混乱)。
  • 同级组件:通过父组件中转(子传父 → 父传另一个子),或使用Pinia共享状态,避免直接通信。

四、Vue3 Pinia 状态管理规范(替代Vuex,简洁高效)

4.1 Store 设计原则:模块化拆分,避免冗余

  • 模块化:按业务模块拆分Store(如userStore、cartStore、goodsStore),避免单一Store过大;Store命名统一前缀use(如useUserStore),使用camelCase命名法。
  • 状态划分:State(状态)、Getters(计算属性)、Actions(异步/同步操作)分离,禁止在Getters中修改State,禁止在组件中直接修改Store的State(需通过Actions)。

4.2 状态操作规范:规范调用,避免状态异常

// stores/user.ts 正确示例
import { defineStore } from 'pinia';
import { fetchUserInfo } from '@/api/user';

export const useUserStore = defineStore('user', () => {
    // State:定义状态,使用ref/reactive
    const userInfo = ref({
        id: 0,
        name: '',
        avatar: ''
    });
    const isLogin = ref(false);

    // Getters:计算属性,依赖State,只读
    const userNickname = computed(() => userInfo.value.name || '未知用户');

    // Actions:处理同步/异步操作,修改State
    const setUserInfo = (info) => {
        userInfo.value = info;
        isLogin.value = true;
    };

    const logout = () => {
        userInfo.value = { id: 0, name: '', avatar: '' };
        isLogin.value = false;
    };

    // 异步Action,使用async/await
    const loadUserInfo = async (userId) => {
        try {
            const res = await fetchUserInfo(userId);
            setUserInfo(res.data);
        } catch (err) {
            console.error('加载用户信息失败:', err);
            throw err;
        }
    };

    return { userInfo, isLogin, userNickname, setUserInfo, logout, loadUserInfo };
});

五、Vue3 Vue Router 路由规范(优化体验,避免路由踩坑)

  • 路由命名:路由name使用kebab-case(如user-profile),与组件名、路径对应,提升可读性;路由path使用kebab-case(如/user/profile),符合URL命名规范。
  • 路由懒加载:所有路由组件均使用懒加载(() => import('组件路径')),减少首屏加载时间;基础组件无需懒加载。
  • 路由守卫:全局守卫用于权限控制(如登录验证),路由独享守卫用于单个路由的特殊控制,组件内守卫用于组件内的生命周期控制;避免在守卫中写复杂业务逻辑。
  • 参数传递:路径参数(params)用于必填参数(如/user/:id),查询参数(query)用于可选参数(如/list?page=1&size=10);接收参数时需做类型校验。
// router/index.ts 正确示例
import { createRouter, createWebHistory } from 'vue-router';

const routes = [
    {
        path: '/',
        name: 'home',
        component: () => import('@/views/Home.vue'),
        meta: { title: '首页', requiresAuth: false }
    },
    {
        path: '/user/:id',
        name: 'user-profile',
        component: () => import('@/views/UserProfile.vue'),
        meta: { title: '用户详情', requiresAuth: true },
        props: true // 自动将params转为Props传递给组件
    },
    {
        path: '/404',
        name: '404',
        component: () => import('@/views/404.vue')
    },
    {
        path: '/:pathMatch(.*)*',
        redirect: '/404' // 路由匹配失败,重定向到404
    }
];

const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes
});

// 全局前置守卫:登录验证
router.beforeEach((to, from, next) => {
    const userStore = useUserStore();
    if (to.meta.requiresAuth && !userStore.isLogin) {
        next('/login');
    } else {
        document.title = to.meta.title || 'Vue3 项目';
        next();
    }
});

export default router;

六、Vue3 工程化与协作规范(团队高效协作必备)

6.1 文件目录规范:结构清晰,便于维护

项目目录结构清晰,按功能模块划分,便于维护和协作,推荐目录结构如下:

src/
├── assets/          // 静态资源(图片、字体、图标等),命名使用kebab-case
│   ├── images/
│   ├── fonts/
│   └── icons/
├── components/      // 公共组件
│   ├── base/        // 基础组件(BaseButton、BaseInput等)
│   ├── layout/      // 布局组件(LayoutHeader、LayoutSidebar等)
│   └── business/    // 业务组件(OrderList、GoodsCard等)
├── views/           // 页面视图组件,命名使用PascalCase
│   ├── Home.vue
│   ├── UserProfile.vue
│   └── Order/
│       ├── OrderList.vue
│       └── OrderDetail.vue
├── stores/          // Pinia状态管理,命名使用useXXXStore.ts
│   ├── useUserStore.ts
│   └── useCartStore.ts
├── router/          // 路由配置
│   └── index.ts
├── api/             // API接口封装,按模块划分
│   ├── user.ts
│   └── goods.ts
├── utils/           // 工具函数,命名使用camelCase
│   ├── format.ts
│   └── request.ts
├── constants/       // 常量定义
│   └── index.ts
├── styles/          // 全局样式
│   ├── index.scss
│   └── variables.scss
├── composables/     // 组合式函数,复用逻辑
│   └── useDebounce.ts
└── App.vue          // 根组件

6.2 代码提交规范(Git Commit):清晰可追溯,便于审查

采用Conventional Commits标准,提交信息清晰,便于代码审查和版本回溯,格式为:(): 。

  • type(提交类型):feat(新功能)、fix(Bug修复)、docs(文档变更)、style(代码样式调整,不影响逻辑)、refactor(重构,不修复Bug也不增加功能)、test(测试相关)、chore(构建/工具变更)。
  • scope(范围):指定提交影响的模块(如user、router、goods),无明确范围可省略。
  • subject(描述):简洁明了,说明提交内容,首字母小写,结尾不加句号。
// 示例
feat(user): add password reset UI
fix(router): handle 404 redirect
chore(deps): upgrade axios to 1.2.0
docs: update component usage documentation

6.3 代码校验规范:统一格式,减少冲突

  • 工具配置:项目必须集成ESLint、Prettier,统一代码格式;安装依赖:npm install -D eslint prettier eslint-plugin-vue @typescript-eslint/parser eslint-config-prettier husky lint-staged。
  • 自动校验:配置pre-commit钩子(husky + lint-staged),提交代码时自动校验格式,不符合规范的代码禁止提交;开发过程中使用编辑器插件(如ESLint、Prettier)实时校验。
  • ESLint配置:继承vue3-recommended规范,结合项目需求调整规则,禁止禁用必要的校验规则(如禁止滥用any、禁止Props修改)。

七、Vue3 性能与安全规范(优化体验+规避风险)

7.1 性能优化规范:提速降耗,提升用户体验

  • 响应式优化:避免过度使用reactive,简单数据使用ref;大数据列表使用v-virtual-scroller(虚拟滚动),减少DOM渲染数量。
  • 计算属性与监听:computed用于依赖状态的计算(缓存结果),watch用于监听状态变化并执行副作用(如请求接口);避免在watch中写复杂逻辑,避免监听过多状态。
  • 资源优化:静态资源(图片)压缩,使用CDN加载;路由懒加载、组件懒加载;避免重复请求(添加请求缓存、防抖节流)。
  • DOM优化:减少DOM操作,避免在模板中使用复杂表达式;使用v-show替代v-if(频繁切换场景),v-if替代v-show(一次性渲染场景)。

7.2 安全规范:规避漏洞,保障项目稳定

  • XSS防护:避免直接插入HTML(如v-html),若必须使用,需对内容进行过滤;禁止使用eval、with等危险语法。
  • 接口安全:请求接口时添加token验证;敏感数据(如密码)加密后传输;接口返回数据需做类型校验,避免恶意数据导致的报错。
  • 依赖安全:定期更新项目依赖,避免使用存在安全漏洞的依赖包;安装依赖前检查依赖安全性(如使用npm audit)。

八、Vue3 补充规范(细节拉满,避免踩坑)

  • 兼容性:兼容主流浏览器(Chrome、Edge、Firefox最新版本),如需兼容旧浏览器(如IE11),需添加相应的polyfill。
  • 可维护性:代码书写简洁,避免冗余(如重复代码封装为函数/组件);注释清晰,便于后续维护和他人理解。
  • 一致性:项目内所有代码严格遵循本规范,团队成员需统一认知;新增规范需团队讨论确认后补充,避免个人风格差异导致的代码混乱。
  • 废弃代码:禁止保留无用代码(如注释掉的代码、未使用的变量/函数/组件),提交代码前删除废弃内容,保持代码整洁。

超级好用的三原后台管理v1.0.0发布🎉(Vue3 + Ant Design Vue + Java Spring Boot )附源码

作者 三原
2026年4月10日 17:44

好久没更新文章了,确实有AI后好像大家都不太关注技术了。好像大家的关注点都是那个AI 模型又又又增强。

但是我建议看到这篇文章的同学,还是要持续学习,你面试的时候不能告诉面试官我会用某个AI?如果AI模型花钱包月就能使用那么你的优势又是哪里。 流水不争先,争的是滔滔不绝 持续学习吧,至少现在出去面试还是问你的技术决定你的待遇呢,等真到某天我去面试,面试官查看我使用消耗的TOKEN,那我觉得就可以不用学了哈哈哈~

博主之前更新了一篇java的学习,是的java有所小成了,java spring boot基本上都可以写了,因为有一定的编程思想,也就只有语法不熟练了。

最近看了好多后台管理系统,有免费的RuoYI、纯前端的Naive UI Pro,收费的丰富了大家可以自行查找。

客观评价一下优秀开源作品RuoYi(仅仅个人观点):

  • 优点:功能成熟,可靠、稳定。
  • 缺点:vue2(现在大多都vue3了) 界面不是那么美观,后台代码应该是写的非常不错(我毕竟刚入门不好评价),但是不适合学习,如果你是专业后端我觉得上手难度还可以,如果偏前端的我觉得还是我的这个框架更合适。

Sanyuan VS RuoYi(客观评价)

  • 不足:后端因为我java刚入门,有编程思想,毕竟没那么多的经验,肯定好多地方没那么完善,但是博主再这里说明下,后续会继续学习多多学习。前端:我经验很足,一些细节也都考虑到了,也借鉴了一些别的系统,技术选型啥的肯定没问题。

  • 优点:因为我的java刚入门,没有引入什么特殊的插件,所以想学习或者写项目的同学更好使用。前端:支持Vue3、国际化、界面相对美观些吧(这个每人审美都不一样)

访问地址

访问地址: sanyuan.website/ 用户名:admin 密码:123456

仓库地址: gitee.com/hejingyuan0…

  • 角色管理
  • 日志管理
  • 菜单管理
  • 用户管理
  • 字典管理
  • 暗色主题适配切换
  • 现支持2种布局方式,未来会支持更多常用的布局已经在规划中~
  • 支持适配主题色、色弱模式、灰色模式
  • 支持4种路由过渡动画
  • 支持国际化,内涵轻松适配各种语言的方案
  • 支持权限控制
  • 支持路由缓存
  • 支持菜单外链、内嵌自定义
  • 支持两种标签栏定制

功能演示

  • 主题切换 image.pngimage.png

水平布局、垂直布局

image.png

国际化

image.png

还有一些别的自定义,大家自己去系统中使用吧

国际化

这里使用的是vite-auto-i18n-plugin 传送门 可以直接抽代码,配置好了自动翻译

image.png

使用方式如下图,别忘记再main.js引入生成的lang资源哈

image.png

动态菜单

其实做动态菜单有两种:

一种是再router.js中定义好code,然后根据后端的返回的code,在router.beforeEach中去拦截,遇到每权限的就next(/403)页面。这种实现简单,挺好用的,我们有些项目也在使用这种。

第二种是使用router.addRoute动态的去插入到路由里面,比如RuoYI等后台管理系统都是用的这种方式,再页面上选图标、输入路由路径、对应的前端组件路径。 然后把信息返回给前端,前端去router.addRoute。优点是可以在界面上自动配置,配置完后也可以给权限分配去使用了。如下图:

image.png

再用vue3 router.addRoute的时候也遇到过几个问题,第一点就是怎么匹配前端的组件资源:

const getRouterLoadView = (menu) => {
   //VUE3 要使用import.meta.glob 获取使用 不能使用() => import(`@/views/**/*.vue`)
  const modules = import.meta.glob('@/views/**/*.vue')
  const filePath = `/src/views${menu.component}`
  const componentLoader = modules[filePath]
  return componentLoader
}

还遇到一个问题就是因为我router在没有获取到权限数据的时候是就一些默认的(403、404、500、登录)。再访问https://sanyuan.website/home路径的时候,我在用router.beforeEach(async (to, _, next) => {}) 这个时候的router.to的信息已经是404的路由信息。 那么我怎么知道是Home 路由呢,通过URL的路径定位如下:

function getRoutePath(url = window.location.href) {
  const urlObj = new URL(url)
  return urlObj.pathname || '/'
}

完整的router如下:

router.beforeEach(async (to, _, next) => {
  NProgress.start()
  const { closeGlobalLoading } = useGlobalLoading()
  if (to.path === '/login') {
    next()
    closeGlobalLoading()
    return
  }

  // 没有token 去登录页面
  if (!localStorage.getItem('token')) {
    next('/login')
    return
  }

  const userStore = useUserStore()
  if (!userStore.userInfo.id) {
    // 判断是不是首次登录,首次登录获取用户信息
    try {
      // 这里获取用户信息、用户权限、用户动态菜单
      const { initPreposition } = usePreposition()
      await initPreposition()
      // ps: 因为to的信息这个时候已经变成404的路由信息了,用URL上的路由做为跳转
      closeGlobalLoading()
      next(getRoutePath(), { replace: true })
      return
    } catch (error) {
      console.log(error, '获取用户信息失败')
      localStorage.removeItem('token')
      next('/login')
      return
    }
  } else {
    next()
  }
})

路由缓存

使用keep-alive做缓存include动态做缓存,如果我不是动态的就比较好实现。例如我想要在User、Menu做缓存。我只需要在对应的页面文件定义 defineOptions({name: 'UserPage'})、defineOptions({name: 'MenuPage'}) 然后如下这么使用就生效:

<router-view v-slot="{ Component }">
      <transition :name="appStore.appConfig.transitionName" mode="out-in">
        <keep-alive :include="['UserPage', 'MenuPage']">
          <component :is="Component" :key="currentComponentKey" />
        </keep-alive>
      </transition>
    </router-view>

但是做成动态的就又有点不一样了如下图让用户自己在配置页面可选:

image.png

并且页面还有个刷新按钮

image.png

原本做刷新很简单只需要给component绑定一个动态的KEY,再点击的时候刷新这个动态key就行了。但是需要做keep-alive那这个key 就不能是纯动态了,比如缓存了2个页面,你刷新之后把key变了,做的缓存也失效了。

完整的实现如下: 1、路由中在meta保留缓存标识,因为我的是页面配置的,是在服务端直接处理好返回的noCache如下

{
    "name": "home",
    "path": "/home",
    "hidden": false,
    "component": "/home/index.vue",
    "alwaysShow": null,
    "meta": {
        "title": "首页",
        "titleEn": "Home",
        "frameType": null,
        "icon": "AppstoreOutlined",
        "noCache": true,
        "isFrame": false
    }
}

2、使用router.beforeResolveto里面收集到需要缓存的PageName如下:

import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { defineStore } from 'pinia'
import { uniq } from 'lodash'

function getRouteComponentName(route) {
  const currentRoute = route.matched[route.matched.length - 1]
  const currentRouteComponent = currentRoute?.components?.default
  return currentRouteComponent?.name
}

export const useRouteCacheStore = defineStore('route-cache', () => {
  const router = useRouter()
  const cachedViews = ref([])

  const setRouteCachedViews = (route) => {
    if (!route.meta.noCache) {
      // 需要缓存
      const componentName = getRouteComponentName(route)
      cachedViews.value = uniq([...cachedViews.value, componentName])
    }
  }

  // ps: 【缺陷】 如果首次刷新刚好在缓存的页面, 不会被收集到
  router.beforeResolve((to) => {
    setRouteCachedViews(to)
  })

  return { cachedViews }
})

3、解决刷新影响到keep-alive的问题,做缓存如下:


<template>
  <div class="app-content">
    <router-view v-slot="{ Component }">
      <transition :name="appStore.appConfig.transitionName" mode="out-in">
      // cachedViews 就是缓存收集到的需要 keep-alive的page
        <keep-alive :include="routeCacheStore.cachedViews">
          <component :is="Component" :key="currentComponentKey" />
        </keep-alive>
      </transition>
    </router-view>

    <slot name="footer"></slot>
  </div>
</template>

<script setup>
import { RouterView, useRouter } from 'vue-router'
import { computed, defineOptions, watch, ref } from 'vue'

import { useLayoutStore } from '@/stores/layout'
import { useAppStore } from '@/stores/app.js'
import { useRefreshStore } from '@/stores/refresh'
import { useRouteCacheStore } from '@/stores/route-cache.js'

import { useThemeToken } from '../hooks/use-theme-token.js'

defineOptions({name: 'AppContent',})

const router = useRouter()
const routeCacheStore = useRouteCacheStore()
const { colorBgLayout } = useThemeToken()
const layoutStore = useLayoutStore()
const appStore = useAppStore()
const refreshStore = useRefreshStore()


// 缓存每次的key, 在刷新的时候只更新当前的key
const cachedMap = ref({})

const currentComponentKey = computed(() => {
  const name = router.currentRoute.value.name
  if (cachedMap.value[name]) {
    return cachedMap.value[name]
  }
  cachedMap.value[name] = name
  return name
})

watch(
  () => refreshStore.refreshKey,
  () => {
    const name = router.currentRoute.value.name
    cachedMap.value[name] = `${name}_${refreshStore.refreshKey}`
  },
)
</script>

外链内嵌

可以看到我菜单配置页面有一个外链内嵌也是动态可配置的,这个要怎么实现呢?

1、实现一个iframe内嵌的组件:

<template>
  <a-spin tip="加载中..." :spinning="loading">
    <ProLayout>
      <ProLayoutMain>
        <iframe class="size-full border-none overflow-hidden" :src="url" @load="loading = false" />
      </ProLayoutMain>
    </ProLayout>
  </a-spin>
</template>

<script setup>
import { ProLayout, ProLayoutMain } from '@/components/pro-layout/index'
import { ref } from 'vue'

defineProps({
  url: {
    type: String,
    required: true,
  },
})

const loading = ref(true)
</script>

2、在处理菜单路由的时候把components改成这个使用vue render包裹的,然后把url传给ifrmae组件

import { ref, h } from 'vue'
const renderIframe = (url) => {
  return h(Iframe, { url })
}

function generateDynamicRoutes(menus) {
  if (!menus || !menus.length) return []

  return menus.map((menu) => {
    // 【iframe 外链】 内嵌模式的数据 isFrame = true, frameType = 2
    /**
     * 【iframe 外链】
     * 处理path 默认生成使用/iframe/xxxx 模式
     */
    const isFrameAdnInline = menu?.meta?.frameType === '2' && menu?.meta?.isFrame
    const route = {
      ...menu,
      path: isFrameAdnInline ? `/iframe/${menu.meta.titleEn}` : menu.path,
      name: menu.name || menu.path,
      meta: {
        ...menu.meta,
        title: isEN ? menu.meta?.titleEn : menu.meta?.title,
        icon: menu.meta?.icon ? h(ProIcon, { name: menu.meta.icon }) : '',
        isFrame: isFrameAdnInline ? false : menu.meta.isFrame,
      },
      component: isFrameAdnInline ? renderIframe(menu.path) : getRouterLoadView(menu),
    }
    if (menu.children && menu.children.length > 0) {
      route.children = generateDynamicRoutes(menu.children)
    }
    return route
  })
}

总结

做这个后台管理还是有不少知识点的,像路由过渡动画、缓存、动态菜单路由等等,大家具体可以查询源码。

问答

Q: 现在是vue3 + js写的,为啥不用ts?
A: 因为现在好多单位还是使用的js,相比较来说还是js更方便,编码更快, 只想做简单好用的框架,不增加负担

Q: 现在用的Ant Design Vue UI,后续会不会出Element Plus、Naiveui?
A: 这个要看系统使用的情况,如果比较受大家欢迎,后续会考虑出别的UI的后台管理系统

前端表单构建神器 - formkit初体验

作者 Java小卷
2026年4月10日 16:13

传统表单开发 vs 低代码方案

传统的表单开发,无论是基于dom还是数据驱动的,都离不开手写html模板。尤其对于复杂的表单:关联字段联动、校验、表单字段的排版等等都有相当大的工作量。为此近些年涌现出不少的低代码方案,旨在通过页面拖拽配置的形式来高效的维护表单功能,来代替繁重的代码开发维护。基于JSON Schema的表单构建方案就是在这个背景下诞生,而具有代表性的就是本文要介绍的FormKit

FormKit项目初始化

准备源码路径:D:\2026学习\study\code\formkit示例\001_formkit项目初始化\parent
右键parent,用idea打开

20260410112337.png

20260410112551.png

自动完成依赖安装更新

20260410113807.png

看下默认安装的依赖:

"dependencies": {  
  "@formkit/core": "^2.0.0",  
  "@formkit/icons": "^2.0.0",  
  "@formkit/themes": "^2.0.0",  
  "@formkit/vue": "^2.0.0",  
  "@tailwindcss/vite": "^4.2.2",  
  "tailwindcss": "^4.2.2",  
  "vue": "^3.5.32"  
}

formkit不光是UI框架,更是开箱即用的json schema渲染表单的解决方案。对于UI,formkit直接用Tailwind来构建和维护组件样式。

修复类型引入问题

20260410114017.png

创建pnpm启动项

20260410114231.png

运行dev,访问:http://localhost:5173/,将看到页面:

20260410114451.png

组件的渲染方式

有两种方式:html中编写组件标签和基于schema的集中维护定义。
前者属于传统的组件使用方式,大部分场景下我们的表单开发都是直接用开源组件库如element plus,来编写和维护表单,FormKit也支持这个方式,它提供了内置的常用表单组件,同时提供了非常好的机制让我们扩展自定义组件,包括集成现有的UI组件。

组件定义方式

直接写组件标签,类似于使用Element Plus中的组件来手动构建表单:

<FormKit
  type="form"
  #default="{ value }"
  @submit="submit"
>
  <FormKit
type="text"
name="name"
label="Name"
help="..."
  />
  <FormKit
type="checkbox"
name="flavors"
label="..."
:options="{ ... }"
validation="required|min:2"
  />
  
  <FormKit
type="checkbox"
name="agree"
label="..."
  />
  ...
</FormKit>

基于Schema的定义方式

这种方式方便集中维护表单字段定义,FormKit可以基于表单定义的Schema动态的渲染表单,是低代码表单设计器的构建产物。有了它,我们只要关注于字段配置的扩展以及如何设计和实现表单设计器来在线生成表单定义数据。

<script setup lang="ts">
import {ref} from "vue"

const formSchema = {
  $formkit: 'form',
  children: [{
    $formkit: 'text',
    name: 'name',
    label: 'Name',
    help: '...',
  },
  {
    $formkit: 'checkbox',
    name: 'flavors',
    label: 'Favorite ice cream flavors',
    options: { ... },
    validation: 'required|min:2',
  },
  {
    $formkit: 'checkbox',
    name: 'agree',
    label: '...',
  },
]}

const data = ref({})

async function submit() {
  await new Promise(r => setTimeout(r, 1000))
  alert('Submitted! 🎉')
}
</script>

<template>
  <div class="...">
    <img ...>
    <FormKitSchema :schema="formSchema" v-model="data" @submit="submit" />
    <pre class="...">{{ data }}</pre>
  </div>
</template>

校验

FormKit提供了非常强大的内置校验和自定义扩展的方式,具体可参考校验官方文档
示例中对一个字段启用非空和长度校验非常简单,比如这里的多选框字段,只需简单配置为:validation: 'required|min:2',页面效果:

20260410152500.png

FormKit支持国际化,只需要在formkit.config.ts中进行如下配置:

...
import { zh } from '@formkit/i18n'
const config: DefaultConfigOptions = {
  ...
  locales: { zh },
  locale: 'zh',
}
export default config

会看到系统的校验信息变成了中文

20260410153511.png

官方文档

以上我们的介绍只是FormKit功能特性的九牛一毛,具体的API用法配置请参考FormKit官方文档。个人觉得看了那么多技术文档,FormKit无论是可读性和用户体验都是非常好的,唯一的遗憾是没有中文版。后续的例子也都会从官方文档来扩展。

20260410155356.png

好了,本次的学习分享就到这里。希望本篇能给前端低代码研发的小伙伴一些启示,我是小卷,一个爱学习分享的搬砖老码农,我们下期再见!

@embedpdf/vue-pdf-viewer内网使用避坑

作者 桜吹雪
2026年4月10日 15:18

之前刷到过embedpdf这个新的pdf预览库,就想着把pdfjs-dist或者vue-pdfjs换掉,默认样式非常好看,和前端的设计也很贴近

image.png

默认是使用英语,可以直接配置一下展示中文:

import { PDFViewer } from '@embedpdf/vue-pdf-viewer'

<PDFViewer
  :config="{
    src,
    theme: { preference: 'light' },
    i18n: {
      defaultLocale: 'zh-CN',
      fallbackLocale: 'en',
    },
  }"
/>

但是真实部署测试的时候发现,有些资源会请求cdn

image.png

image.png

经过一段时间在embedpdf文档、npmjs 和 node_modules摸索,可以找到pdfium.wasm在 @embedpdf/snippet 包,manifest.json@embedpdf/default-stamps 包提供

如果使用pnpm这种没有幽灵依赖的包管理,需要手动加一个 @embedpdf/snippet 依赖:

image.png

打包的话,因为我使用了vite,vite本身提供了wasm导入的方式:cn.vitejs.dev/guide/asset…

所以可以直接引入并提供url:

import pdfiumUrl from '@embedpdf/snippet/dist/pdfium.wasm?url'
const wasmUrl = pdfiumUrl.startsWith('http') ? pdfiumUrl : `${window.location.origin}${pdfiumUrl}`

因为dev的时候url引入是/node_modules/xxx的地址,embedpdf似乎会先校验是否是一个正确的URL地址,如果不是就会加载失败。

然后就是manifest.json主要是一些盖章的功能,我感觉大部分人不需要这个,如果不需要的话就可以这样阻止加载文件,解决加载过程中一直阻塞pdf预览的问题。

<PDFViewer
  :config="{
    src,
    theme: { preference: 'light' },
    i18n: {
      defaultLocale: 'zh-CN',
      fallbackLocale: 'en',
    },
    stamp: {
      manifests: [],
    },
    wasmUrl,
  }"
/>

当然你需要加载这个默认json的话可以:

import stampJson from '@embedpdf/default-stamps/zh-CN/manifest.json?no-inline&url'
<PDFViewer
  :config="{
    src,
    theme: { preference: 'light' },
    i18n: {
      defaultLocale: 'zh-CN',
      fallbackLocale: 'en',
    },
    stamp: {
      manifests: [{
          url: stampJson,
        }],
    },
    wasmUrl,
  }"
/>

这个json文件很小,不加no-inline的话打包后会被内联成base64,会和上面的wasm的加载一样报错。json还表示了使用了一个stamp.pdf,如果需要使用盖章还需要把node_modules/@embedpdf/default-stamps/zh-CN/stamps.pdf复制到public/assets/stamps.pdf

至此应该就能完全内网使用了XD

[前端]可折叠容器组件、信息展示卡片组件

作者 焰火1999
2026年4月10日 10:36

分享两个Web前端组件,均为可折叠的内容展示组件,基于Vue3、ElementPlus框架。

可折叠容器组件

组件Vue源码

<!--
  * 可折叠容器组件
  * 
  * Author: GFire
  * Date: 2026/03/23
-->
<template>
  <div class="box-container">
    <div class="title" @click="isCollapse = !isCollapse">
      <el-icon style="margin: 0 5px">
        <ArrowUpBold v-if="isCollapse" />
        <ArrowDownBold v-else />
      </el-icon>
      {{ title }}
    </div>
    <el-collapse-transition>
      <div v-show="!isCollapse" :class="contentContainerClass || 'content-container'">
        <!-- 内容区域默认插槽 -->
        <slot></slot>
      </div>
    </el-collapse-transition>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';

const props = withDefaults(
  defineProps<{
    /**
     * 标题文字
     */
    title?: string;
    /**
     * 内容区域样式class名(用于自定义样式)
     */
    contentContainerClass?: string;
  }>(),
  {
    title: '',
  },
);

// 是否折叠
const isCollapse = ref(false);
</script>

<style scoped>
.box-container {
  border: 1px solid #ececef;
  border-radius: 4px;
}

.title {
  background-color: #f7f8fb;
  padding: 5px;
  border-bottom: 1px solid #ececef;
  cursor: pointer;
}

.content-container {
  padding: 10px;
}
</style>

组件使用示例

使用代码:

<CollapseBox title="张雪820RR资料" style="width: 600px" content-container-class="collapseContent">
  <div>
    张雪820RR搭载张雪机车自研的820cc直列三缸发动机,最大功率99kW,最大扭矩80N·m,零百加速仅需2.81秒,极速高达280km/h。结合其43800元的售价来看,性价比简直爆棚,毕竟价格贵了快三倍的杜卡迪V2也没有如此炸裂的性能。不过张雪820RR最硬核的并非参数,而是背后的技术实力。
  </div>
</CollapseBox>

<style scoped>
/* 自定义内容区域的样式 */
:deep(.collapseContent) {
  padding: 15px;
}
</style>

解释:

  1. title为标题
  2. content-container-class为内容区域样式class名(用于自定义样式)
  3. 内容区域默认展开,点击标题区域切换折叠/展开状态。

显示效果:

image.png

折叠效果:

image.png

信息展示卡片组件

组件Vue源码

<!--
  * 基础组件:信息展示卡片
  * 
  * Author: GFire
  * Date: 2024/10/25
-->
<template>
  <el-card shadow="hover">
    <template #header>
      <div class="header">
        <div class="title" :style="getTitleStyle()">
          {{ props.title }}
        </div>
        <el-button @click="visible = !visible" :icon="visible ? 'Minus' : 'Plus'" text type="primary" size="small"></el-button>
      </div>
    </template>
    <!-- 卡片内容插槽 -->
    <el-collapse-transition>
      <div v-show="visible">
        <slot></slot>
      </div>
    </el-collapse-transition>
  </el-card>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import type { CSSProperties } from 'vue';

const props = withDefaults(
  defineProps<{
    title: string;
    titleSize?: number | string; // 标题字体大小, 传数字时单位为px, 传字符串时原样赋值
  }>(),
  {
    titleSize: 16,
  },
);

const visible = ref(true);

function getTitleStyle(): CSSProperties {
  return {
    fontSize: getTitleSize(),
  };
}

function getTitleSize() {
  const size = props.titleSize;
  if (typeof size === 'number') {
    return size + 'px';
  } else {
    return size;
  }
}
</script>

<style scoped>
:deep(.el-card__header) {
  padding: 10px;
}

:deep(.el-card__body) {
  padding: 12px;
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.title {
  border-left: 7px solid var(--el-color-primary);
  padding: 3px 10px;
  font-weight: bold;
}
</style>

组件使用示例

使用代码:

<InfoCard title="张雪820RR资料" style="width: 600px">
  <div>
    张雪820RR搭载张雪机车自研的820cc直列三缸发动机,最大功率99kW,最大扭矩80N·m,零百加速仅需2.81秒,极速高达280km/h。结合其43800元的售价来看,性价比简直爆棚,毕竟价格贵了快三倍的杜卡迪V2也没有如此炸裂的性能。不过张雪820RR最硬核的并非参数,而是背后的技术实力。
  </div>
</InfoCard>

显示效果:

image.png

点击右上角的“-”按钮可以折叠,效果:

image.png

再点击右上角的“+”按钮则展开

3.响应式系统基础:从发布订阅模式的角度理解 Vue2 的数据响应式原理

作者 Cobyte
2026年4月10日 09:04

前言

关于 Vue2 和 Vue3 的数据响应式原理,相信 Vue 技术栈的同学或多或少都了解过,甚至在简历上写很熟悉,而且我们在第一篇中也已经基本实现过了,但大家是否真的彻底掌握了呢?

正如我们前面所说的那样,Vue2、Vue3、SolidJS、Mobx 它们的数据响应式基本原理都是一样的,具体区别只是设计理念和实现方式不一样。按以前读书时代考试做题一样,它们是同一类型的题目,也就是基于依赖收集和触发的运行时的数据响应式,如果说你只会解答其中一道题,其他的题却不会解答,则说明你并没有真正彻底掌握这一类题。

同样地基于依赖追踪和触发的响应式系统都是通过发布订阅模式进行实现的,那么你知道 Vue 的数据响应式原理中是如何运用发布订阅模式的吗?

所以我们在本篇当中将从发布订阅模式的角度来理解 Vue 的数据响应式原理,彻底掌握数据响应式的基本原理,同时也巩固我们在上一篇中所说的发布订阅模式。我们在上一篇中学习了发布订阅模式,我们都是基于一些 demo 的例子去理解,本篇则真正的把发布订阅模式在实际项目的进行运用。

温馨提示,阅读本本之前最好先阅读前一篇文章,对发布订阅模式有一定的理解

发布订阅模式原理回顾

我们在上一篇中最后是通过 Object.defineProperty 方法对公众号对象 weChatOfficialAccountarticle 属性进行劫持监听,然后在 getter 的时候进行订阅,在 setter 的时候进行发布。

代码如下:

// 定义公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '',
    // 添加订阅者
    addDep(fn) {
        // 把订阅者添加进记录列表
        this.subscribers.push(fn) 
    },
    // 广播信息
    notify(title) {
        // 发布信息时就是把记录列表中的订阅者全部通知一次
        this.subscribers.forEach(fn => fn(this.article))
    },
    // 取消订阅
    remove(fn) { 
        // 找到需要删除的订阅者
        const index = this.subscribers.indexOf(fn)
        // 删除订阅者
        this.subscribers.splice(index, 1 )
    }
}
defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者就进行订阅操作
            if (subscriber) weChatOfficialAccount.addDep(subscriber)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            weChatOfficialAccount.notify()
        }
    })
}

我们可以看到 weChatOfficialAccount 对象上有很多属于发布订阅模式中的功能,如果说还有其他对象也需要实现这样的功能,那么也要实现一遍这些功能,很明显这样是不可接受的,我们可以通过上一篇中实现的消息代理来代替 weChatOfficialAccount 对象中的发布订阅模式的功能。

首先我们对消息代理的实现做如下修改:

class Dep {
  constructor() {
    // 订阅者存储中心
    this.subs = []
  }
  // 添加订阅者
  addSub(sub) {
    this.subs.push(sub)
  }
  // 通知订阅者
  notify() {
    this.subs.forEach(sub => sub())
  }
}

如果熟悉 Vue2 数据响应式原理的同学对上面的代码肯定很熟悉,这个就是 Vue2 源码中的 Dep 类的简易实现,所以 Vue2 源码中的 Dep 其实就是一个事件总线或者叫消息代理,但它又不仅仅是消息代理,在某些时刻它同时又是一个订阅者,这个情况就是我们在上篇当中所说的一个对象既可以是发布者也可以是订阅者,而在 Vue2 源码中 Dep 类既是消息代理中心又是订阅者,具体情况我们将下文中进行详细讲解。

接下来我们继续做如下修改:

const weChatOfficialAccount = {
    // 消息代理对象
    __ob__: new Dep(),
    // 文章内容
    article: '', 
}
defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
    // 获取消息代理对象
    const dep = weChatOfficialAccount.__ob__
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者就进行订阅操作
            if (subscriber) dep.addSub(subscriber)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

我们在公众号对象中通过 __ob__ 属性来存储消息代理对象,这样公众号对象原本属于发布订阅的功能就通过 Dep 类来实现了,这样代码的功能职责就梳理得十分清晰了,也符合代码整洁之道。

我们对修改后的代码进行测试:

// 订阅者小明
let subscriber = () => {
    console.log(`收到的公众号文章:${weChatOfficialAccount.article}`)
}
// 读取一次,触发 getter 进行订阅
weChatOfficialAccount.article
// 设置为 null 防止重复订阅
subscriber = null
// 公众号发布文章则直接通过给属性赋值的方式
weChatOfficialAccount.article = '通过 Object.defineProperty 方法实现订阅发布模式'

上述测试代码的测试结果将会打印:

收到的公众号文章:通过 Object.defineProperty 方法实现订阅发布模式

我们通过 Object.defineProperty 方法实现对 weChatOfficialAccount 对象的属性 article 进行监听实现发布订阅功能,然后订阅的时候需要读取一下,触发 getter 进行订阅,这个行为在上述例子中比较奇怪,我们把它改成我们容易理解的例子。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-9" />
    <title></title>
  </head>
  <body>
    <div id='content'></div>
    <script>
        const weChatOfficialAccount = {
            // 消息代理对象
            __ob__: new Dep(),
            // 文章内容
            article: '文章内容', 
        }
        // 省略 ...
        // 订阅者小明
        let subscriber = () => {
            const el = document.getElementById('content')
            el.textContent = `小明收到的公众号文章:${weChatOfficialAccount.article}`
        }
        // 初始化
        subscriber()
        subscriber = null
    </script>
  <body>
</html>

我们改成我们 web 应用程序中的例子就比较容易理解了。所谓订阅者小明,就是一个 HTML 更新函数,在初始化执行 subscriber() 函数的时候,会读取公众号对象 weChatOfficialAccountarticle 属性的值,这样就会触发 getter 函数进行订阅,在后续当公众号对象 weChatOfficialAccountarticle 属性值发生变化的时候,就会触发 setter 进行发布,也就是重新执行订阅者函数,然后网页内容发生变化。这个也是 Vue2 中的数据响应式的基本原理。

在上述例子中我们只对其中一个属性进行监听,但实际情况很有可能有其他订阅者对其他属性的进行引用。

// 订阅者郭靖
let guojingSubscriber = () => {
    console.log(`郭靖收到的公众号文章作者:${weChatOfficialAccount.author}`)
} 
// 订阅者杨过
let yangguoSubscriber = () => {
    console.log(`杨过收到的公众号发布时间:${weChatOfficialAccount.date}`)
}

然后我们需要对公众号对象进行修改:

const weChatOfficialAccount = {
    // 事件总线对象
    __ob__: new Dep(),
    // 文章内容
    article: '文章内容',
+    author: '作者',
+    date: '日期'
}
defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
+ defineReactive(weChatOfficialAccount, 'author', weChatOfficialAccount.author)
+ defineReactive(weChatOfficialAccount, 'date', weChatOfficialAccount.date)

同时需要对 getter 中的添加订阅者部分进行修改,为了精准添加对应的订阅者,我们需要判断对应的属性:

function defineReactive(data, key, val) {
    // 获取消息代理对象
    const dep = weChatOfficialAccount.__ob__
    Object.defineProperty(data, key, {
        get() {
+            // 为了精准添加对应属性的订阅者,我们需要判断对应的属性
+            if (subscriber && key === 'article') dep.addSub(subscriber)
+            if (guojingSubscriber && key === 'author') dep.addSub(guojingSubscriber)
+            if (yangguoSubscriber && key === 'date') dep.addSub(yangguoSubscriber)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

我们上述代码中为了精准添加对应属性的订阅者,我们需要在 getter 中判断对应的属性,在功能简单的情况下可以,如果功能复杂,对象的属性庞大的情况下,这样肯定是不能接受的。

又因为在触发 getter 的时候,只会是在某个订阅者函数在执行的时候,也就是说在 getter 被触发的时候,这个时候的订阅者是确定的,所以我们可以采用 中间变量 形式来解决这个问题。我们设置一个全局变量 activeEffect,也就是所谓中间变量,然后在初始化执行订阅者函数之前把需要执行的订阅者函数赋值给 activeEffect,然后在 getter 里面就可以把中间变量 activeEffect 通过消息代理对象添加到订阅者记录里面了,然后在执行完该订阅者函数之后则需要把中间变量 activeEffect 设置为 null,防止重复添加。

代码修改如下:

+ // 订阅者中间变量
+ let activeEffect
function defineReactive(data, key, val) {
    // 获取消息代理对象
    const dep = weChatOfficialAccount.__ob__
    Object.defineProperty(data, key, {
        get() {
+            // 存在订阅者中间变量就进行订阅者添加
+            if (activeEffect) dep.addSub(activeEffect)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}
+ // 初始化订阅者
+ activeEffect = subscriber
+ subscriber()
+ activeEffect = null
+ activeEffect = guojingSubscriber
+ guojingSubscriber()
+ activeEffect = null
+ activeEffect = yangguoSubscriber
+ yangguoSubscriber()
+ activeEffect = null

接着我们进行测试:

// 公众号发布文章
weChatOfficialAccount.article = '通过 Object.defineProperty 方法实现订阅发布模式'

打印结果如下:

C01.png

我们就发现我们虽然只对 article 属性进行赋值,但也触发了其他属性的订阅者的执行。那么我们就需要对属性与订阅者之间进行准确关联。那么如何进行准确关联呢?我们通过第一篇文章可以知道,在通过 Object.defineProperty 对每一个属性进行劫持监听的时候,通过闭包的形式把属性值缓存下来的,所以每一个属性的消息代理也放在闭包函数 defineReactive 中。

// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
+    // 通过闭包把每一个属性的消息代理进行缓存
+    const dep = new Dep()
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (activeEffect) dep.addSub(activeEffect)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

这时我们再进行测试的时候,就可以精准触发订阅者了。

我们上面是通过手动调用 defineReactive 函数进行对象的属性劫持的,我们可以通过获取所有对象的属性然后遍历调用 defineReactive 函数进行对象的属性劫持,同时把这个功能封装成一个工具函数 observe。我们在第一篇中也实现过的了,下面我们来重新实现一下:

function observe (data) {
    Object.keys(data).forEach(key => {
        defineReactive(data, key, data[key]) 
    })
}

这样我们就可以通过 observe 函数来定义一个响应式对象了。

const weChatOfficialAccount = {
    // 文章内容
    article: '文章内容',
    author: '作者',
    date: '日期'
}
- defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
- defineReactive(weChatOfficialAccount, 'author', weChatOfficialAccount.author)
- defineReactive(weChatOfficialAccount, 'date', weChatOfficialAccount.date)
+ observe(weChatOfficialAccount)

至此我们便通过发布订阅模式初步实现了 Vue2 的响应式原理。创建一个对象,通过 observe 工具函数遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter,然后在 getter 的时候进行依赖收集,在 setter 的时候进行依赖触发。

从发布订阅模式的角度来说就是每一个对象的 property 都是发布者,然后它的消息代理则通过闭包的形式跟每一个 property 的值一起缓存在 defineReactive 闭包函数创建独立空间中,它们是多对多的关系。

小结

在我们一般的发布订阅模式(也叫观察者模式)中,发布者或者被观察者是很明确的,是一个具体的对象,但正如我们前一篇文章所说的那样,发布订阅模式是没有标准范式的,设计模式也是,辨别一种模式不能通过代码结构,而是代码意图。而在我们上述实现的 Vue2 响应式原理的过程中,我们发现其实每一个对象属性(property)都是一个发布者或者叫被观察者,它的发布者功能则通过消息代理中介进行实现,而这个消息代理对象则通过闭包的形式跟每个属性值一起缓存在闭包当中。我们又可以发现所谓发布订阅模式的触发条件也不是唯一的,我们一般的描述定义是,当一个对象发现变化的时候才去触发所有依赖它的订阅者,其实不然,发布订阅模式的触发条件可以是状态的变化、某个操作的变化、甚至是发布订阅者的通知也可以触发另外一个发布者进行发布操作。如果有在 Vue 中使用过事件总线的同学会很清楚,我们在组件中触发通知(emit)订阅者操作的时候并不一定是组件属性发生了变化,而有可能是某个方法触发了通知(emit)订阅者操作。

对数组进行响应式的处理

可以通过 Object.defineProperty 对数组进行监听,但监听不了数组自身的原型链方法,而 pushpopshiftunshiftsplicesortreverse 对数组进行操作是会改变数组的数据结构的,从发布订阅模式的角度来说数据发生变化后我们需要通知该数组对象的所有订阅者。为了实现这需求我们需要劫持数组的操作方法,即在对数组进行 push 等操作的时候我们能监听到。实现方案就是对数组的原型进行重写,重写的方法就是覆盖数组数组对象上的原型对象 __proto__。我们在第一篇当中是通过粗暴的直接覆盖的方式,但那样会把原来的一些数组方法也覆盖掉了,那样是不可取。

我们可以通过获取数组原型上的对象,然后只修改需要修改的方法即可。我们对 observe 方法修改如下:

function observe (data) {
+    // 如果是数组则重写数组上的原型
+    if (Array.isArray(data)) {
+        // 获取数组原型
+        const arrayProto = Array.prototype
+        // 通过 Object.create 创建一个原型为 arrayProto 的空对象
+        const arrayMethods = Object.create(arrayProto)
+        // 修改 push 方法
+        arrayMethods['push'] = function (...args) {
+            // 获取原始方法
+            const original = arrayProto['push']
+            // 执行原始方法
+            const result = original.apply(this, args)
+            return result
+        }
+        // 覆盖原型对象
+        data.__proto__ = arrayMethods
+    } else {
        Object.keys(data).forEach(key => {
          // 如果属性不是 __ob__ 则进行监听
          if (key !== '__ob__')  defineReactive(data, key) 
        })
+    }
}

上述代码我们通过 Object.create 创建一个原型为 arrayProto 的空对象:arrayMethods。然后给空对象设置 push 属性值为一个函数,最终把 arrayMethods 赋值给 data__proto__。这里就涉及到了一个 JavaScript 原型链的基础知识,当我们获取一个对象的属性值的时候,我们优先从该对象的自身属性上去获取,如果找不到则沿着该对象的 __proto__ 属性上的对象上的属性去查找,如果还找不到,则继续沿着 __proto__ 上的对象去查找。

我们经过上面的代码设置之后,我们通过 observe 设置一个数组,那么这个数组的原型对象则变为了arrayMethods,当执行该数组的 push 方法,根据原型链的规则,它会先执行 arrayMethods 对象上的 push 方法,这样我们就可以对该数组的 push 方法进行了监听,我们最终还是通过原本数组上 push 方法进行操作,但我们可以捕捉到了 push 的动作,这样我们就可以在 push 操作之后,进行通知所有该数组上的订阅者了。

我们从前面的发布订阅模式的知识可以知道,一个发布者对象上需要有一个消息代理对象,所以我们需要继续迭代我们的代码:

function observe (data) {
+    // 不存在消息代理则设置消息代理对象
+    if (!data.__ob__) data.__ob__ = new Dep()
    // 如果是数组则重写数组上的原型
    if (Array.isArray(data)) {
        // 获取数组原型
        const arrayProto = Array.prototype
        // 通过 Object.create 创建一个原型为 arrayProto 的空对象
        const arrayMethods = Object.create(arrayProto)
        // 修改 push 方法
        arrayMethods['push'] = function (...args) {
            // 获取原始方法
            const original = arrayProto['push']
            // 执行原始方法
            const result = original.apply(this, args)
+            // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
+            data.__ob__.notify()
             // 同时对新添加的数据也进行响应式化
+             for (let i = 0, l = args.length; i < l; i++) {
+              observe(args[i])
+             }
        }
        // 覆盖原型对象
        data.__proto__ = arrayMethods
    } else {
        Object.keys(data).forEach(key => {
          // 如果属性不是 __ob__ 则进行监听
          if (key !== '__ob__')  defineReactive(data, key) 
        })
    }
}

那么我们这个发布者对象的订阅者在哪里进行添加呢,从数据响应式的角度就是这个响应式对象的依赖在哪里收集呢?

其实不管是对象还是数组的订阅者都是在 getter 中进行添加的。
例如:{ list: [1,2,3,4] }
你要获取到 list 数组的内容,首先是通过 list 这个 property 进行获取的,所以当通过 list 这个 property 进行获取数组内容的时候,就触发了 list 这个 property 的 getter。

所以我们需要对 defineReactive 函数进行修改:

function defineReactive(data, key) {
    let val = data[key]
    // 获取消息代理对象
    const dep = new Dep()
+    // 对获取到的属性值进行递归 observe 监听
+    const childOb = observe(val)
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (activeEffect){
                dep.addSub(activeEffect)
+                // 如果存在 childOb 则说明属性值是一个对象,则也需要对该对象进行订阅者收集,从发布订阅模式的角度看,这是一个订阅操作
+                if (childOb) childOb.addSub(activeEffect) 
            }
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

在 getter 中会进行 property 的订阅者添加,收集到的订阅者保存在对应 property 的消息代理对象中,同时也会判断,property 的值如果是一个对象,还会对这个对象进行订阅者添加,收集到的订阅者还会保存到这个对象的消息代理对象上。

所以我们还需要对 observe 函数进行修改:

+ // 判断是否是对象
+ function isObject(obj) {
+     return obj !== null && typeof obj === 'object'
+ }
function observe (data) {
+    // 不是对象则直接返回
+    if (!isObject(data)) return 
    
    // 省略 ...
    
+    // 返回消息代理对象
+    return data.__ob__
}

至此我们对数组的响应式也实现了,接下来就是进行测试:

// 定义公众号
const weChatOfficialAccount = {
    // 文章内容
    article: '',
    author: '',
    date: '',
    arr: ['掘金']
}

observe(weChatOfficialAccount)

// 订阅者小明
let subscriber = () => {
    console.log(`收到的公众号文章:${weChatOfficialAccount.arr.join('====')}`)
}

// 初始化订阅者
activeEffect = subscriber
subscriber()
activeEffect = null
// 公众号发布文章则直接通过给属性赋值的方式
weChatOfficialAccount.arr.push('通过 Object.defineProperty 方法实现订阅发布模式')

打印结果如下:

C03.png

我们可以看到正确打印了结果,也就是我们也实现了对数组的响应式。

通过重构实现 Observer 类

我们上述函数 observe 的实现其实职责是很不清晰的,也不利于后续的维护,所以我们需要对它进行重构。软件应该是“自描述”的,代码除了给机器看之外,也要给人看。我们希望写的代码更易读,让代码可以更好地表达自己的意图。

这里我们涉及到一些开发技巧,我们可以先实现具体的功能,然后再重构,在重构的时候通过封装成抽象的类或者其他函数,让代码可以更好地表达自己的意图。那么 observe 函数中可以将对数组响应式的处理,还有对对象属性循环劫持分别封装成不同的函数,然后通过函数名称可以让我们的代码意图更明显。

我们对 observe 函数进行重构,代码如下:

function observe (data) {
    // 不是对象则直接返回
    if (!isObject(data)) return 
    // 不存在消息代理则设置消息代理对象
    if (!data.__ob__) data.__ob__ = new Dep()
    // 如果是数组则重写数组上的原型
    if (Array.isArray(data)) {
+        protoAugment(data)
    } else {
+        walk(data)
    }
    // 返回消息代理对象
    return data.__ob__
}
+ // 对数组进行响应式处理
+ function protoAugment(target) {
+    // 获取数组原型
+    const arrayProto = Array.prototype
+    // 通过 Object.create 创建一个原型为 arrayProto 的空对象
+    const arrayMethods = Object.create(arrayProto)
+    // 修改 push 方法
+    arrayMethods['push'] = function (...args) {
+        // 获取原始方法
+        const original = arrayProto['push']
+        // 执行原始方法
+        const result = original.apply(this, args)
+        // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
+        target.__ob__.notify()
        // 同时对新添加的数据也进行响应式化
        observeArray(args)
+    }
+    // 覆盖原型对象
+    target.__proto__ = arrayMethods
+ }
+ // 对对象进行响应式处理
+ function walk(obj) {
+    const keys = Object.keys(obj)
+    keys.forEach(key => {
+        // 如果属性不是 __ob__ 则进行监听
+        if (key !== '__ob__')  defineReactive(obj, key) 
+    })
+ }
 // 对数组的每一项元素都进行响应式处理
 function observeArray (items) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
 }

经过我们上面对不同的功能代码进行重构后,我们就可以通过函数名称很容易理解代码的意图了。但我们上面的功能函数还是十分的分散,而它们都是同一种功能类型的函数,都是实现对象响应式的功能函数,所以我们可以通过 OOP 的思想把响应式数据和操作封装到一个类里面,这个类我们把它命名为 Observer

Observer 类的代码实现如下:

class Observer {
    constructor(value) {
        this.value = value
        this.dep = new Dep()
        this.value.__ob__ = this
        if (Array.isArray(value)) {
            // 对数组进行响应式处理
            this.protoAugment(value)
            // 对数组的每一项都进行响应式处理
            this.observeArray(value)
        } else {
            // 对对象进行响应式处理
            this.walk(value)
        }
    }
    // 进行原型重写
    protoAugment(target) {
        // 获取数组原型
        const arrayProto = Array.prototype
        // 通过 Object.create 创建一个原型为 arrayProto 的空对象
        const arrayMethods = Object.create(arrayProto)
        // 修改 push 方法
        arrayMethods['push'] = function (...args) {
            const ob = this.__ob__
            // 获取原始方法
            const original = arrayProto['push']
            // 执行原始方法
            const result = original.apply(this, args)
            // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
            ob.dep.notify()
            // 同时对新添加的数据也进行响应式化
            ob.observeArray(args)
        }
        // 覆盖原型对象
        target.__proto__ = arrayMethods
    }

    // 对对象进行响应式处理
    walk(obj) {
        const keys = Object.keys(obj)
        keys.forEach(key => {
            // 如果属性不是 __ob__ 则进行监听
            if (key !== '__ob__')  defineReactive(obj, key) 
        })
    }
    // 对数组的每一项元素都进行响应式处理
    observeArray (items) {
        for (let i = 0, l = items.length; i < l; i++) {
            observe(items[i])
        }
    }
}

function observe (data) {
    // 不是对象则直接返回
    if (!isObject(data)) return
    const ob = new Observer(data)
    // 返回 Observer 实例对象
    return ob
}

defineReactive 函数也需要进行以下修改:

function defineReactive(data, key) {
// ...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (activeEffect){
                dep.addSub(activeEffect)
                // 如果存在 childOb 则说明属性值是一个对象,则也需要对该对象进行订阅者收集,从发布订阅模式的角度看,这是一个订阅操作
-                if (childOb) childOb.addSub(activeEffect) 
+                if (childOb) childOb.dep.addSub(activeEffect) 
            }
            return val
        },
        set(newVal) {
// ...
        }
    })
}

我们修改后进行重新测试也正常打印了结果:

C03.png

我们还可以进行一下性能优化,我们上述代码 protoAugment 函数部分,我们创建了数组原型对象的变量,而这些变量其实是不会变化,我们可以把它们的声明移到 protoAugment 函数外面,这样每一次调用 protoAugment 函数就不会重复重新创建这些变量了。

+ // 获取数组原型
+ const arrayProto = Array.prototype
+ // 通过 Object.create 创建一个原型为 arrayProto 的空对象
+ const arrayMethods = Object.create(arrayProto)
+ // 修改 push 方法
+ arrayMethods['push'] = function (...args) {
+    const ob = this.__ob__
+    // 获取原始方法
+    const original = arrayProto['push']
+    // 执行原始方法
+    const result = original.apply(this, args)
+    // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
+    ob.dep.notify()
+    // 同时对新添加的数据也进行响应式化
+    ob.observeArray(args)
+ }

class Observer {
    constructor(value) {
        // 省略 ...
        if (Array.isArray(value)) {
            // 对数组进行响应式处理
+            this.protoAugment(value, arrayMethods)
            // 对数组的每一项都进行响应式处理
            this.observeArray(value)
        } else {

        }
    }

+    protoAugment(target, src) {
+        // 覆盖原型对象
+        target.__proto__ = src
+    }
    // 省略 ...
}

至此我们通过重构就实现了 Observer 类,这一节没有涉及到发布订阅模式和数据响应式相关的内容,只是一下编程技巧的内容,而之所以有这一节是为了我们的代码结构更贴近 Vue2 源码的实现。通过这一节的实现,我们也可以知道发布订阅模式是如何在 Vue2 数据响应式中实现的。

那么从发布订阅模式的角度来看所谓 Observer 类,其实是一个发布者或者叫被观察者,虽然它的类命叫 Observer 翻译过叫观察者,但从观察者模式的角度来看,它不能叫观察者,因为它并没有向哪个被观察者进行订阅操作。但它又不是一个纯粹的发布者,它主要作用是将数据对象转换为响应式对象,使得当数据发生变化时能够触发相应的更新操作,它同时通过递归遍历数据对象中的所有属性,为每个属性设置 gettersetter 来实现数据的劫持和监听,从功能上来看它是在观察自己的属性。

从代码结构上来看,它的发布订阅模式的实现跟传统标准的发布订阅模式的结构还是存在很大差别的,但正如我们上篇文章中所说的那样,我们并不能从代码结构上去判断是否属于什么模式,而是从代码意图去判断。

订阅者中介实现

我们知道 Vue2 中的订阅者是通过 Watcher 类来实现的,也就是我们上一篇文章中所讲的订阅者中介

我们先实现一个订阅者中介:

class Watcher {
    constructor(fn) {
        // 让每个订阅者所需要做的事情通过参数的形式传进来,这样更灵活,拓展性更强
        this.getter = fn
        // 初始化的时候直接读取触发订阅者收集,因为这样设计符合 web 应用的特性
        this.get()
    }
    get() {
        // 通过 Dep.target 来设置当前的订阅者是谁
        Dep.target = this
        this.getter()
        Dep.target = null
    }
    // 接受发布者通知的更新方法
    update() {
        this.getter()
    }
}

我们这里设计的订阅者中介类的实现跟我们上一篇中的订阅者中介类的实现,最大的不同就是,这里的设计需要在初始化的时候就要去执行一次订阅者所传的参数函数,因为在 web 应用应用中,应用需要初始化。

我们在实例化订阅者的时候,就把该订阅者需要做的事情当成参数传进去:

new Subscriber(() => {
    console.log(`郭靖收到的公众号文章:${weChatOfficialAccount.article}`)
}) 

同时 Dep 类的 notify 方法也需要修改一下:

class Dep {
  // 通知订阅者
  notify() {
-    this.subs.forEach(sub => sub())
+    this.subs.forEach(sub => sub.update())
  }
}

defineReactive 函数也需要修改:

- // 订阅者中间变量
- let activeEffect
// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
    // 省略 ...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
-            if (activeEffect){
+            if (Dep.target){
-                dep.addSub(activeEffect)
+                dep.addSub(Dep.target)
                // 如果存在 childOb 则说明属性值是一个对象,则也需要对该对象进行订阅者收集,从发布订阅模式的角度看,这是一个订阅操作
-                if (childOb) childOb.dep.addSub(activeEffect) 
+                if (childOb) childOb.dep.addSub(Dep.target) 
            }
            return val
        },
        set(newVal) {
            // 省略 ...
        }
    })
}

这样我们就可以很方便通过一下进行测试了:

// 定义公众号
const weChatOfficialAccount = {
    // 文章内容
    article: '',
    author: '',
    date: '',
    arr: ['掘金']
}
observe(weChatOfficialAccount)
// 初始化订阅者
new Watcher(() => {
    console.log(`收到的公众号文章:${weChatOfficialAccount.arr.join('====')}`)
})

// 公众号发布文章则直接通过给属性赋值的方式
weChatOfficialAccount.arr.push('通过 Object.defineProperty 方法实现订阅发布模式')

打印结果如下:

C03.png

小结

Dep 和 Watcher 互为订阅者

我们通过上文知道 Dep 类其实是一个消息代理或者叫事件总线,而 Watcher 则是一个订阅者,但我们前面也留了一个引子,说它们还有一层关系,就是互为订阅者。那么既然 DepWatcher 互为订阅者,也就是说它们其实也是一个发布者的角色。所以现实系统中的应用远远要比我们所学的所谓标准模式要复杂得多。

我们知道在 Vue2 中可以通过 Options 选项设置 watcher 来实现对响应式数据的监听,其实还可以通过 this.$watcher() 来实现对响应式数据的监听,使用方法都是一样的,唯一的不同就是 this.$watcher() 会返回一个函数,这个函数的作用就是停止对响应式数据的监听。

那么要实现停止对响应式数据的监听则需要知道那些 Dep 记录了当前的 Watcher,我们就需要通知那些 Dep 取消订阅当前的 Watcher。那么要实现这个功能,就需要 Watcher 也进行记录自己订阅了哪些 Dep,当取消对响应式数据的监听的时候,就从当前 Watcher 的订阅记录里去通知那些 Dep 取消自己的订阅。

比如说我们在 Vue2 当中有这么一个功能:

const unwatch = this.$watch(function(){
      return this.name + this.age + this.sex
    }, function(newValue, oldValue){
    console.log('新值为:', newValue)
    console.log('旧值为:', oldValue)
    if (newValue === 'cobyte') {
      // 停止监听
      unwatch()
    }
})

我们接下来去实现这个功能,也就是实现 Vue2 的 $watcher() 功能。以下是 Vue2 官网对 $watcher API 的一些参数和功能的介绍。

  • [vm.$watch( expOrFn, callback, options )]

  • 参数

    • {string | Function} expOrFn

    • {Function | Object} callback

    • {Object} [options]
      
      • {boolean} deep
      • {boolean} immediate
  • 返回值{Function} unwatch

  • 用法

    观察 Vue 实例上的一个表达式或者一个函数计算结果的变化。回调函数得到的参数为新值和旧值。表达式只接受简单的键路径。对于更复杂的表达式,用一个函数取代。

这个 $watcher 的功能从发布订阅模式的角度可以看成是,第一个参数是订阅者要做的事情,第二个参数是在做完事情后拿到结果再通过第二参数输出结果,而且是每次所依赖的响应式数据发生变化后都需要执行第二个参数函数,输出新的结果。

那么我们先实现下面的功能:

new Watcher(function() {
   return `要做的事情,获取文章:${weChatOfficialAccount.article}`
}, function(newValue, oldValue) {
   console.log(`新结果是:${newValue},旧结果是:${oldValue}`)
})

我们对 Watcher 类做以下修改:

class Watcher {
-    constructor(fn) {
+    constructor(fn, cb) {
+        this.cb = cb
       // 让每个订阅者所需要做的事情通过参数的形式传进来,这样更灵活,拓展性更强
       this.getter = fn
       // 初始化的时候直接读取触发订阅者收集,因为这样设计符合 web 应用的特性
-        this.get()
+        this.value = this.get()
   }
   get() {
+        let value
       // 通过 Dep.target 来设置当前的订阅者是谁
       Dep.target = this
+        value = this.getter()
       Dep.target = null
+        return value
   }
   // 接受发布者通知的更新方法
   update() {
     // 获取新值
-      this.getter()
+      const value = this.getter()
+      // 设置旧值
+      const oldValue = this.value
+      // 更新值
+      this.value = value
+      if (this.cb) {
+        // 因为是用户写的,有可能存在错误
+        try {
+          this.cb(value, oldValue)
+        } catch(err) {
+          throw err
+        }
+      }
   }
}

我们进行测试:

weChatOfficialAccount.article = '第一次更新'
weChatOfficialAccount.article = '第二次更新'

测试结果如下:

C04.png

我们看到正确打印了结果。

有了以上的基础功能,接下来我们就很容易实现 $watcher API,代码如下:

function $watcher(expOrFn, cb) {
    const watcher = new Watcher(expOrFn, cb)
}

我们知道 $watcher API 是有很多配置选项的,也就是第三个参数,比如立即执行回调就是通过第三个参数配置 immediatetrue 来实现的,下面我们也来实现它:

function $watcher(expOrFn, cb, options) {
    // 因为 options 有可能不存在,要做兼容处理
    options = options || {}
    const watcher = new Watcher(expOrFn, cb, options)
    // 如果 immediate 为 true 则立即执行回调函数 
    if (options.immediate) {
      try {
        cb(watcher.value)
      } catch (error) {
        throw new Error(error)
      }
    }
}

在 Vue2 中 Watcher 实例是分为系统的 Watcher 和用户的 Watcher 的,像我们在组件里面通过配置 watcher,就是用户的 Watcher,怎么体现区分呢?我们下面来设置,其实也很简单:

function $watcher(expOrFn, cb, options) {
    // 因为 options 有可能不存在,要做兼容处理
    options = options || {}
+    // 设置用户级的 Watcher
+    options.user = true
    const watcher = new Watcher(expOrFn, cb, options)
    // 如果 immediate 为 true 则立即执行回调函数 
    if (options.immediate) {
      try {
        cb(watcher.value)
      } catch (error) {
        throw new Error(error)
      }
    }
}

接着修改 Watcher 类:

class Watcher {
-    constructor(fn, cb){
+    constructor(fn, cb, options) {
+      if (options) {
+          this.user = !!options.user
+      } else {
+          this.user = false
+      }
      // 省略...
    }
    update() {
      // 省略...
-      if (this.cb) {
+      if (this.user) {
        // 省略...
      }
    }
}

修改也很简单,从上面的修改可以看得出,只有用户级的 Watcher 才会在更新的时候执行回调函数。

接下来我们测试立即回调功能:

$watcher(function() {
    return `要做的事情,获取文章:${weChatOfficialAccount.article}`
}, function(newValue, oldValue) {
    console.log(`新结果是:${newValue},旧结果是:${oldValue}`)
}, { immediate: true })

我们可以看到初始化的时候,就立即执行回调函数了。

C05.png

立即执行,旧值为 undefined,符合如期。

实现了上面的基础部分的功能,我们就可以实现重要的功能了,取消订阅。

function $watcher(expOrFn, cb, options) {
     // 省略...
+    // 返回一个可以取消订阅的函数
+    return function unwatchFn () {
+      watcher.teardown()
+    }
}

我们这里通过 Watcher 实例对象的 treardown 方法去取消订阅,其实是要去通知那些记录了该 Watcher 的 Dep 去删除其记录中的该 Watcher。那么我们怎么知道哪些 Dep 记录该 Watcher 呢?所以我们就需要在 Watcher 中记录其订阅了的 Dep。从发布订阅模式的角度来说就是 Dep 要对 Watcher 进行订阅,Dep 是订阅者,Watcher 是发布者,而我们之前是 Watcher 对相关的属性的 Dep 进行订阅,Watcher 是订阅者,相关属性的 Dep 是发布者。

我们首先对 Watcher 实现发布订阅的功能,代码迭代如下:

class Watcher {
    constructor(fn, cb, options) {
        // 省略...
+        this.deps = []
        // 省略...
    }
+    addDep(dep) {
+        this.deps.push(dep)
+    }
+    // 取消订阅
+    teardown() {
+        let i = this.deps.length
+        while (i--) {
+            this.deps[i].removeSub(this)
+        }
+    }
}

我们这里的取消订阅是通过 Watcher 所记录的 Dep 实例对象去执行 Dep 上的 removeSub 方法去把自己删除,这样将来 Dep 触发更新的时候,就通知不了自己了,也就执行不了 update 方法了。

接下来我们实现 Dep 类上的 removeSub 方法,迭代代码如下:

class Dep {
  // 省略..

+  // 取消订阅
+  removeSub (sub) {
+    // 找到需要取消的订阅者
+    const index = this.subs.indexOf(sub)
+    if (index > -1) {
+        // 删除订阅者
+        this.subs.splice(index, 1)
+    }
+  }
  // 省略...
}

我们可以看到 Dep 类中的取消订阅功能,跟普通发布订阅中的取消订阅功能是一样的。

我们前面已经实现了 DepWatcher 的订阅,那么接下来就是 Watcher 怎么对 Dep 进行订阅了。我们知道不管是什么数据类型都是在 getter 中进行依赖收集的,所以要实现 WatcherDep 的订阅,也要从 getter 开始。我们在 getter 里面可以通过 Dep.target 获取到当前的 Watcher,也可以获取到当前属性对应的 Dep 实例对象,那么就可以互相添加订阅者了。

代码迭代如下:

function defineReactive(data, key) {
    // 省略...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (Dep.target){
                dep.addSub(Dep.target)
+                // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
+                Dep.target.addDep(dep)
                if (childOb){
                    childOb.dep.addSub(Dep.target)
+                    // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
+                    Dep.target.addDep(childOb.dep)
                }
            }
            return val
        },
        // 省略...
    })
}

接下来我们就可以进行测试了:

const unwatch = $watcher(function(){
    return weChatOfficialAccount.article + weChatOfficialAccount.author
}, function(newValue, oldValue) {
    console.log('新值为:', newValue)
    console.log('旧值为:', oldValue)
    if (newValue === 'cobyte') {
      // 停止监听
      unwatch()
    }
})

console.log('会打印新值旧值')
weChatOfficialAccount.article = 'co'
console.log('会打印新值旧值')
weChatOfficialAccount.author = 'byte'
console.log('不会打印新值旧值')
weChatOfficialAccount.article = 'cobyte'

我们发现如期打印了我们期待的结果:

C06.png

我们上面在 getter 中对 Dep 和 Watcher 进行相互订阅的操作,还可以进行优化一下,让代码更优雅。

class Dep {
    // 省略...
+  // 通过 depend 方法进行依赖收集
+  depend() {
+    if (Dep.target) {
+      // 在 Dep 中进行 Watcher 
+      Dep.target.addDep(this)
+    }
+  }
  // 省略...
}

接着在 Watcher 中调用 Dep 的方法添加自己

class Watcher {
    // 省略...
    addDep(dep) {
        this.deps.push(dep)
+        // 调用 Dep 实例的添加订阅方法添加自己
+        dep.addSub(this)
    }
    // 省略...
}

接着我们修改 getter 中代码:

function defineReactive(data, key) {
    // 省略...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (Dep.target){
-                dep.addSub(Dep.target)
-                // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
-                Dep.target.addDep(dep)
+                dep.depend()
                if (childOb){
-                    childOb.dep.addSub(Dep.target)
-                    // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
-                    Dep.target.addDep(childOb.dep)
+                    childOb.dep.depend()
                }
            }
            return val
        },
        // 省略...
    })
}

经过我们的重构,getter 中的依赖收集相关代码变得清晰多了。

总结

从发布订阅模式的角度来理解 Vue 的数据响应式原理,就是发布订阅模式的具体运用的过程。

上述文章写于:2023 年,由于个人原因今年 2026 年发布。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

前端架构演进:基于AST的常量模块自动化迁移实践

作者 禅思院
2026年4月10日 07:49

前端架构演进:基于AST的常量模块自动化迁移实践

从“硬编码”到“全自动”:一次常量模块重构的工程化探索

在这里插入图片描述

一、背景与痛点

在许多中大型前端项目中,常量管理常常是一个被忽视但又十分重要的环节。随着业务迭代,常量定义方式可能发生变化,历史代码中也可能沉淀出各种“不规范”的模式。

在我们的项目中,常量定义最初采用了一种集中式导出方式:

// src/constants/Constants_expert.ts
export default {
  STATUS_PENDING: 0,
  STATUS_APPROVED: 1,
  // ... 数十个常量
}

而在业务代码中,这些常量通过一个“万能”的 @/locales 模块统一导入,并以 Constants_expert.default.STATUS_PENDING 的形式使用:

// 旧代码片段
import { Constants_expert } from '@/locales';

if (status === Constants_expert.default.STATUS_APPROVED) { ... }

这种模式存在几个严重问题:

  1. Tree Shaking 失效export default 对象导致整个常量对象被打包,无法按需剔除。
  2. 命名空间冗余:每次使用都要写 .default,代码冗长且容易出错。
  3. 模块职责混乱@/locales 本应是国际化模块,却承担了常量聚合的职责。
  4. 可维护性差:新增常量文件需要手动修改 @/locales 的导出,极易遗漏。

为了彻底解决这些问题,我们决定进行两项重构:

  • 常量文件:将 export default { ... } 拆解为多个 export const,实现具名导出。
  • 业务代码:将所有 Constants_xxx.default.PROP 替换为直接使用 PROP,并添加对应的具名导入。

项目涉及 30+ 个常量文件200+ 个业务文件,手工修改不仅耗时,而且极易出错。于是,我们开发了两个基于 AST(抽象语法树) 的自动化迁移脚本,实现了零人工干预的平滑过渡。

本文将从技术实现、难点攻克、工程化落地三个维度,深度剖析这次自动化重构的全过程。


二、整体方案设计

整个迁移流程分为两个独立的阶段,必须严格按顺序执行

graph LR
    A[常量文件] -->|transform-const.js| B[具名导出常量]
    C[业务代码] -->|transform-project.js| D[直接引用+具名导入]
    B -.->|提供导出变量列表| D
  • 第一阶段:扫描 src/constants/*.ts,将每个文件中的 export default 对象转换为多个 export const 语句。
  • 第二阶段:扫描 src/views 下的所有 .vue.ts.js 文件,识别旧的导入模式,分析实际使用的常量,删除旧导入,生成新的具名导入,并替换代码中的引用。

两个脚本均支持 --dry-run 预览模式,并在修改前自动创建 .bak 备份文件,确保操作可逆。


三、第一阶段:常量文件格式转换(transform-const.js)

3.1 核心目标

将这样的代码:

// Constants_expert.ts
export default {
  STATUS_PENDING: 0,
  STATUS_APPROVED: 1,
}

转换为:

export const STATUS_PENDING = 0;
export const STATUS_APPROVED = 1;

同时保留所有注释(文件头注释、属性上方注释等)。

3.2 AST 操作流程

我们使用 Babel 全家桶完成这次转换:

  • @babel/parser:将源码解析为 AST
  • @babel/traverse:遍历和修改 AST 节点
  • @babel/types:构建新的 AST 节点
  • @babel/generator:将 AST 还原为代码

核心步骤:

  1. 解析源码,指定 sourceType: 'module'plugins: ['typescript'] 以支持 TS 语法。
  2. 遍历 AST,找到 ExportDefaultDeclaration 节点,并判断其声明是否为 ObjectExpression
  3. 移除该默认导出节点
  4. 遍历对象的每个属性,对每个属性构建一个 ExportNamedDeclaration 节点,内部包裹 VariableDeclaration 类型为 const
  5. 保留注释:将原属性的 leadingCommentstrailingComments 赋值给新节点。
  6. 重新生成代码,并写回原文件。

关键代码片段:

traverse(ast, {
  ExportDefaultDeclaration(path) {
    if (t.isObjectExpression(path.node.declaration)) {
      defaultExportObject = path.node.declaration;
      path.remove(); // 移除整个 export default
    }
  },
});

defaultExportObject.properties.forEach((prop) => {
  const propName = prop.key.name;
  const propValue = prop.value;
  const exportDecl = t.exportNamedDeclaration(
    t.variableDeclaration('const', [
      t.variableDeclarator(t.identifier(propName), propValue),
    ])
  );
  // 保留注释
  if (prop.leadingComments) exportDecl.leadingComments = prop.leadingComments;
  exportConstNodes.push(exportDecl);
});

3.3 易错点与防御

  • 非对象默认导出:某些常量文件可能已经是 export const 格式,或者导出一个函数。脚本会检测并跳过,避免破坏已有代码。
  • 属性名非标识符:如果对象的键是字符串字面量(如 "my-const": 123),则无法转换为合法的变量名,脚本会给出警告并跳过该属性。
  • 文件备份:转换前自动创建 .bak 文件,防止误操作导致代码丢失。

四、第二阶段:业务代码引用迁移(transform-project.js)

这是整个方案中最复杂的部分,需要同时处理 JavaScript/TypeScriptVue SFC 文件,并且要保证转换后的代码语法正确、依赖完整。

4.1 动态发现常量文件

第一阶段完成后,src/constants 下的每个 .ts 文件都导出了一批具名常量。我们需要知道每个常量文件导出了哪些变量名,以便在第二阶段验证引用的有效性。

function loadAllConstantFiles() {
  const constantFiles = glob.sync(path.join(CONSTANTS_DIR, '*.ts'), { absolute: true });
  const constantMap = new Map(); // key: 文件名(如 Constants_expert), value: { filePath, exportedNames }

  for (const filePath of constantFiles) {
    const ast = parser.parse(fs.readFileSync(filePath, 'utf-8'), { plugins: ['typescript'] });
    const exportedNames = new Set();
    traverse(ast, {
      ExportNamedDeclaration(path) {
        if (t.isVariableDeclaration(path.node.declaration) && path.node.declaration.kind === 'const') {
          path.node.declaration.declarations.forEach(d => {
            if (t.isIdentifier(d.id)) exportedNames.add(d.id.name);
          });
        }
      },
    });
    constantMap.set(path.basename(filePath, '.ts'), { filePath, exportedNames });
  }
  return constantMap;
}

这样我们就获得了所有常量文件的“导出变量白名单”。

4.2 识别旧的导入模式

在业务代码中,旧的导入语句通常长这样:

import { Constants_expert, Constants_supplier_portrait } from '@/locales';

我们需要找到这些导入,并记录每个本地标识符对应的常量集合名(例如 Constants_expert 对应 Constants_expert 集合)。

使用 AST 遍历 ImportDeclaration,匹配 source.value === '@/locales',然后遍历 specifiers,只处理 ImportSpecifier 类型:

traverse(ast, {
  ImportDeclaration(path) {
    if (path.node.source.value === OLD_IMPORT_SOURCE) {
      path.node.specifiers.forEach(spec => {
        if (t.isImportSpecifier(spec)) {
          const importedName = spec.imported.name;
          const localName = spec.local.name;
          if (constantMap.has(importedName)) {
            oldLocalToConstantMap.set(localName, importedName);
            shouldRemove = true;
          }
        }
      });
      if (shouldRemove) path.remove(); // 删除整条导入语句
    }
  },
});

4.3 替换成员访问表达式

旧的引用方式有两种常见形态:

  • Constants_expert.default.STATUS_PENDING
  • Constants_expert.STATUS_PENDING(某些早期代码省略了 .default

我们需要将它们统一替换为 STATUS_PENDING,并记录下该常量名被使用了。

通过 AST 遍历 MemberExpression,找到根标识符,判断是否在 oldLocalToConstantMap 中,然后解析属性链,提取出最终属性名:

traverse(ast, {
  MemberExpression(path) {
    const root = findRootIdentifier(path.node);
    if (!root) return;
    const localName = root.name;
    if (!oldLocalToConstantMap.has(localName)) return;

    const constantSetName = oldLocalToConstantMap.get(localName);
    const chain = getPropertyChain(path.node);
    let propName = null;
    if (chain.length >= 3 && chain[1] === 'default') {
      propName = chain[2];
    } else if (chain.length >= 2) {
      propName = chain[1];
    }

    if (propName && constantMap.get(constantSetName).exportedNames.has(propName)) {
      // 记录需要导入的变量
      neededImports.get(constantSetName).add(propName);
      // 替换整个节点为一个简单的标识符
      path.replaceWith(t.identifier(propName));
    }
  },
});

4.4 Vue SFC 的特殊处理

Vue 单文件组件包含 <template><script><script setup> 等多个块,需要分别处理。

Script 块:将块内的代码提取出来,调用上述的 transformScript 函数,得到新的代码和需要的导入变量。注意一个 SFC 可能同时存在 <script><script setup>,需要分别处理并合并导入变量。

Template 块:模板中也可能直接使用 Constants_expert.default.STATUS_PENDING 表达式。由于模板不是完整的 JavaScript,用 AST 解析成本较高,我们采用正则替换的方式。

但正则替换有几个坑:

  • 常量名可能包含正则元字符(如 +.),需要转义。
  • 需要同时匹配 .default 和没有 .default 的情况。
  • 替换后要记录使用了哪些变量,以便生成导入。

我们构建动态正则:

const safeName = escapeRegExp(constName);
const regexWithDefault = new RegExp(`\\b${safeName}\\.default\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');
const regexWithoutDefault = new RegExp(`\\b${safeName}\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');

匹配后,将 Constants_expert.default.STATUS 替换为 STATUS,并将 STATUS 加入 neededImports。

4.5 生成新的导入语句

经过上述分析,我们得到了每个常量集合需要导入的具名变量列表。但这里有一个隐蔽的坑:不同常量文件可能导出同名的变量(例如 Constants_expertConstants_supplier 都导出了 STATUS),如果直接生成 import { STATUS } from ... 两次,会产生语法错误。

因此,我们必须先检测冲突:

const varToConstMap = new Map();
for (const [constName, vars] of neededImportsTotal) {
  for (const v of vars) {
    if (varToConstMap.has(v) && varToConstMap.get(v) !== constName) {
      throw new Error(`变量名冲突: "${v}" 同时出现在 "${varToConstMap.get(v)}" 和 "${constName}" 中,请手动重命名其中一个导出变量`);
    }
    varToConstMap.set(v, constName);
  }
}

如果没有冲突,再生成导入语句。导入路径需要将绝对路径转换为 @/ 开头的别名:

const srcDir = path.join(rootDir, 'src');
let importPath = constantFilePath.replace(srcDir, '@/').replace(/\.ts$/, '');
importPath = importPath.replace(/\\/g, '/');

最后,将导入语句插入到文件顶部(如果有 script 块则插入到第一个 script 块的开始位置)。


五、技术难点与解决方案

5.1 路径别名动态转换

最初我们使用 path.relative 然后替换 ../@/,但当文件深度超过两层时,会出现 @/../../constants/xxx 的错误路径。解决方案:基于项目根目录的 src 进行绝对路径替换,直接构造 @/constants/xxx,简单可靠。

5.2 多个 <script> 块的替换位置

Vue SFC 可能同时存在 <script><script setup>,它们的起始和结束偏移量不同。我们需要记录每个块的 loc.start.offsetloc.end.offset,分别替换。并且由于替换后文件长度会变化,必须从后往前依次替换,避免位置偏移错误。

5.3 模板正则的精确匹配

模板中可能包含字符串字面量,例如:

<div :title="'Constants_expert.default.STATUS'"></div>

我们不应该替换引号内的内容。由于 Vue 模板语法的复杂性,完全避免误判需要解析模板 AST,成本过高。我们采用了一个折中方案:只替换独立表达式中的匹配,通过正则的单词边界 \b 来减少误判。在实际项目中,常量名很少出现在字符串内部,因此风险可控。

5.4 保留代码格式与注释

AST 转换后重新生成的代码会丢失原格式(空行、缩进等)。为了最小化 diff,我们使用了 generate{ retainLines: true, comments: true } 选项,尽可能保留原始行号和注释位置。对于 template 的正则替换,我们只替换匹配部分,其余原样保留。


六、工程化落地与自动化流程

为了确保迁移过程平滑、可回滚,我们设计了一套完整的执行流程:

# 1. 全量备份(使用 git 分支)
git checkout -b feature/migrate-constants

# 2. 执行常量文件转换(dry-run 预览)
node scripts/transform-const.js --dry-run
node scripts/transform-const.js

# 3. 执行项目引用迁移(dry-run 预览)
node scripts/transform-project.js --dry-run
node scripts/transform-project.js

# 4. 运行类型检查、单元测试,确保无报错
npm run type-check
npm run test

# 5. 提交变更
git add .
git commit -m "refactor: migrate constants to named exports"

两个脚本都内置了 --dry-run 模式和自动 .bak 备份。即便转换出现问题,也可以快速恢复:

# 恢复所有备份文件
find src -name "*.bak" | while read bak; do mv "$bak" "${bak%.bak}"; done

七、成果与思考

通过这两个脚本,我们在 10 分钟内完成了原本需要 2 人天 的手工重构工作,且零失误。转换后的代码:

  • Tree Shaking 友好:打包体积减少约 15%(未使用的常量被自动剔除)。
  • 可读性提升:代码中直接使用 STATUS_PENDING 而非冗长的 Constants_expert.default.STATUS_PENDING
  • 维护成本降低:新增常量文件无需任何额外配置,脚本自动发现。

更重要的是,这次实践让我们深刻体会到 AST 驱动重构 的巨大威力。无论是代码格式化、框架升级,还是架构调整,只要存在“模式化的代码变换”,都可以借助 AST 工具实现自动化。

未来拓展方向

  • 支持更复杂的引用模式:如 Constants_expert['default'].STATUSConstants_expert[someVar].STATUS,这些可以通过增强 MemberExpression 的递归分析来支持。
  • 集成到 CI 流水线:当常量文件结构发生变化时,自动触发迁移脚本,确保代码库始终保持统一风格。
  • 可视化迁移报告:输出每个文件转换前后的 diff,以及冲突变量列表,便于人工审核。

八、总结

本文详细介绍了如何利用 Babel AST 和 Vue 编译器,完成一次大型常量模块的重构迁移。从最初的痛点分析,到两个阶段脚本的设计,再到各种技术坑点的解决方案,我们不仅解决了实际问题,也沉淀了一套可复用的自动化重构方法论。

如果你也面临类似的“技术债务”清理任务,不妨尝试用 AST 武装自己——让机器去处理那些重复、枯燥的代码变换,把人解放出来做更有创造性的工作。

欢迎交流讨论,共同提升前端工程化水平。更多文章

Vue 中实现文字滚动(跑马灯)的多种方式

2026年4月9日 22:51

在 Vue 里实现文字滚动(跑马灯) ,最常用、最稳的就两种:

  1. CSS 动画纯实现(简单、性能好)
  2. JS 控制滚动(可暂停、可控制速度)

下面直接给你可复制粘贴的 Vue 组件代码


方式1:纯 CSS 跑马灯(推荐)

Marquee.vue

<template>
  <div class="marquee-wrap">
    <div class="marquee-content">
      {{ text }}
    </div>
  </div>
</template>

<script setup>
const text = '这里是需要滚动的文字,Vue 跑马灯效果,从右向左无限滚动~';
</script>

<style scoped>
.marquee-wrap {
  width: 100%;
  overflow: hidden;
  white-space: nowrap;
  background: #f5f5f5;
  padding: 8px 16px;
  border-radius: 8px;
}

.marquee-content {
  display: inline-block;
  animation: marquee 15s linear infinite;
}

@keyframes marquee {
  0% {
    transform: translateX(100%);
  }
  100% {
    transform: translateX(-100%);
  }
}
</style>

特点:

  • 一行无限滚动
  • 无 JS,性能最好
  • 鼠标悬浮暂停版往下看

方式2:hover 暂停 + 无缝滚动(更常用)

<template>
  <div class="box">
    <div class="marquee" @mouseenter="pause" @mouseleave="play">
      <div class="text" :style="{ animationPlayState }">
        {{ content }}
      </div>
    </div>
  </div>
</template>

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

const content = 'Vue3 无缝跑马灯,鼠标移入暂停,移出继续滚动~';
const animationPlayState = ref('running');

const pause = () => {
  animationPlayState.value = 'paused';
};
const play = () => {
  animationPlayState.value = 'running';
};
</script>

<style scoped>
.box {
  width: 100%;
  overflow: hidden;
  background: #f9f9f9;
  padding: 10px;
  border-radius: 6px;
}
.marquee {
  white-space: nowrap;
}
.text {
  display: inline-block;
  animation: move 12s linear infinite;
}
@keyframes move {
  0% { transform: translateX(100%); }
  100% { transform: translateX(-100%); }
}
</style>

方式3:真正无缝(无空白,首尾衔接)

适合公告、长文本:

<template>
  <div class="wrap">
    <div class="box">
      <span class="txt1">{{ text }}</span>
      <span class="txt2">{{ text }}</span>
    </div>
  </div>
</template>

<script setup>
const text = '这里是真正无缝跑马灯,没有空白间隔,一直循环滚动';
</script>

<style scoped>
.wrap {
  width: 100%;
  overflow: hidden;
  background: #fff8e1;
  padding: 8px 0;
}
.box {
  display: flex;
  width: max-content;
  animation: scroll 10s linear infinite;
}
.txt1, .txt2 {
  padding: 0 20px;
}
@keyframes scroll {
  0% { transform: translateX(0); }
  100% { transform: translateX(-50%); }
}
</style>

方式4:JS 控制滚动(可变速、可停止)

<template>
  <div class="box" style="overflow: hidden">
    <div class="text" :style="{ marginLeft: `${left}px` }">
      JS 控制跑马灯,可随时停止、加速、减速
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const left = ref(300);
let timer = null;

onMounted(() => {
  timer = setInterval(() => {
    left.value -= 1;
    if (left.value < -300) left.value = 300;
  }, 20);
});
onUnmounted(() => clearInterval(timer));
</script>

❌
❌