普通视图

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

你的 Vue v-for,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月18日 11:06

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-for 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-for 指令用法。

编译对照

基础数组遍历

最简单的 v-for 指令,用于遍历数组并渲染列表项。

  • Vue 代码:
<li v-for="(item, i) in list" :key="item.id">{{ i }} - {{ item.name }}</li>
  • VuReact 编译后 React 代码:
{
  list.map((item, i) => (
    <li key={item.id}>
      {i} - {item.name}
    </li>
  ));
}

从示例可以看到:Vue 的 v-for 指令被编译为 React 的 map 函数。VuReact 采用 数组映射编译策略,将模板指令转换为 JSX 数组表达式,完全保持 Vue 的列表渲染语义——遍历数组中的每个元素,生成对应的 JSX 元素,并自动处理 key 属性以保证 React 的渲染性能。


对象遍历

v-for 也可以用于遍历对象的属性和值。

  • Vue 代码:
<li v-for="(val, key, i) in obj" :key="key">{{ i }} - {{ key }}: {{ val }}</li>
  • VuReact 编译后 React 代码:
{
  Object.entries(obj).map(([key, val], i) => (
    <li key={key}>
      {i} - {key}: {val}
    </li>
  ));
}

对于对象遍历,VuReact 采用 Object.entries 转换策略,将 Vue 的对象遍历语法转换为 Object.entries(obj).map() 形式。这种编译方式完全模拟 Vue 的对象遍历语义——按顺序遍历对象的键值对,保持 (值, 键, 索引) 的参数顺序,确保数据渲染的一致性。


嵌套 v-for 循环

复杂的嵌套列表渲染,使用多层 v-for 循环。

  • Vue 代码:
<div v-for="category in categories" :key="category.id">
  <h3>{{ category.name }}</h3>
  <ul>
    <li v-for="product in category.products" :key="product.id">
      {{ product.name }} - ${{ product.price }}
    </li>
  </ul>
</div>
  • VuReact 编译后 React 代码:
{
  categories.map((category) => (
    <div key={category.id}>
      <h3>{category.name}</h3>
      <ul>
        {category.products.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  ));
}

对于嵌套循环,VuReact 采用 嵌套 map 函数编译策略,将 Vue 的嵌套 v-for 转换为嵌套的 map 函数调用。这种编译方式完全保持 Vue 的嵌套循环语义——外层循环的每个迭代都会创建内层循环的完整列表,保持组件结构的层次关系。


v-if + v-for

实际业务中经常需要结合条件进行列表渲染。

  • Vue 代码:
<template v-if="cond" v-for="user in users" :key="user.id">
  <img :src="user.avatar" :alt="user.name" />
  <div class="user-info">
    <h4>{{ user.name }}</h4>
    <p>{{ user.email }}</p>
    <span class="role-badge">{{ user.role }}</span>
  </div>
  <div class="user-actions">
    <button @click="editUser(user.id)">编辑</button>
    <button @click="deleteUser(user.id)" class="danger">删除</button>
  </div>
</template>
  • VuReact 编译后 React 代码:
{
  cond
    ? users.map((user) => (
        <div key={user.id} className="user-card">
          <img src={user.avatar} alt={user.name} />
          <div className="user-info">
            <h4>{user.name}</h4>
            <p>{user.email}</p>
            <span className="role-badge">{user.role}</span>
          </div>
          <div className="user-actions">
            <button onClick={() => editUser(user.id)}>编辑</button>
            <button onClick={() => deleteUser(user.id)} className="danger">
              删除
            </button>
          </div>
        </div>
      ))
    : null;
}

对于带条件的列表渲染,VuReact 展示了智能的条件编译能力

  1. 优先条件编译:将 v-if 转换为三元表达式,包裹整个 v-for 渲染结果
  2. 自动提取 key:当 <template> 标签上存在 :key 属性时,会自动将其传递给内部的第一个子元素
  3. 事件绑定处理@click 转换为 onClick,并自动包装为箭头函数以传递参数
  4. 属性绑定转换:src:alt 等转换为 React 属性语法
  5. 样式类名处理class 转换为 className,符合 React 规范

VuReact 的编译策略完全保持 Vue 的列表渲染语义,同时生成符合 React 最佳实践的代码。


使用 v-for 范围值

Vue 的 v-for 也支持使用数字范围进行迭代。

  • Vue 代码:
<span v-for="n in 5" :key="n">{{ n }}</span>
  • VuReact 编译后 React 代码:
{
  Array.from({ length: 5 }, (_, n) => (
    <span key={n + 1}>{n + 1}</span>
  ));
}

对于范围值迭代,VuReact 采用 Array.from 转换策略,将 Vue 的数字范围语法转换为数组生成和映射。这种编译方式完全模拟 Vue 的范围迭代语义——从 1 开始到指定数字结束(包含),保持迭代顺序和数值的一致性。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

Vue v-if 转 React:VuReact 怎么处理?

作者 Ruihong
2026年4月18日 10:35

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-if/v-else/v-else-if 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的条件指令用法。

编译对照

基础 v-if 条件渲染

最简单的 v-if 指令,用于根据条件显示或隐藏元素。

  • Vue 代码:
<div v-if="cond">内容</div>
  • VuReact 编译后 React 代码:
{
  cond ? <div>内容</div> : null;
}

从示例可以看到:Vue 的 v-if 指令被编译为 React 的三元表达式。VuReact 采用 条件表达式编译策略,将模板指令转换为 JSX 内联表达式,完全保持 Vue 的条件渲染语义——当 cond 为真时渲染 <div>,为假时渲染 null(React 中 null 不会被渲染到 DOM)。


v-if 与 v-else 组合

v-ifv-else 组合使用,实现二选一的条件渲染。

  • Vue 代码:
<div v-if="cond">内容</div>
<div v-else>其他内容</div>
  • VuReact 编译后 React 代码:
{
  cond ? <div>内容</div> : <div>其他内容</div>;
}

VuReact 将 v-if/v-else 组合编译为完整的三元表达式完全模拟 Vue 的条件分支语义——两个分支互斥,确保同一时间只有一个元素被渲染。这种编译方式保持了代码的简洁性和可读性,同时与 React 的表达式渲染模式完美契合。


多条件 v-else-if 链

复杂的多条件判断链,使用 v-ifv-else-ifv-else 组合。

  • Vue 代码:
<div v-if="type === 'A'">内容A</div>
<div v-else-if="type === 'B'">内容B</div>
<div v-else>其他内容</div>
  • VuReact 编译后 React 代码:
{
  type === 'A' ? <div>内容A</div> : type === 'B' ? <div>内容B</div> : <div>其他内容</div>;
}

对于多条件链,VuReact 采用嵌套三元表达式编译策略,将 Vue 的 v-else-if 链转换为嵌套的条件表达式。这种编译方式完全保持 Vue 的条件链语义——按顺序检查条件,第一个满足条件的分支被渲染,后续分支被跳过。


复杂业务场景条件渲染

实际业务中的复杂条件渲染,包含嵌套条件、事件绑定、插值表达式等。

  • Vue 代码:
<div v-if="user.role === 'admin' && (user.permissions.includes('write') || isSuperAdmin)">
  <h1>管理员控制面板</h1>
  <button @click="deleteAll">删除所有数据</button>
</div>
<div v-else-if="user.role === 'editor' && articles.length > 0 && !isSuspended">
  <h2>编辑文章 (共{{ articles.length }}篇)</h2>
  <ul>
    <li v-for="article in articles" :key="article.id">{{ article.title }}</li>
  </ul>
</div>
<div v-else-if="user.role === 'viewer' && hasSubscription">
  <h3>订阅用户视图</h3>
  <p>您的订阅将于{{ subscriptionEndDate }}到期</p>
</div>
<div v-else-if="user.role === 'guest' && showTrial">
  <div class="trial-banner">
    <p>试用用户,剩余{{ trialDays }}天</p>
    <button @click="upgrade">升级账户</button>
  </div>
</div>
<div v-else>
  <div class="error-state">
    <p v-if="isLoading">加载中...</p>
    <p v-else-if="errorMessage">{{ errorMessage }}</p>
    <p v-else>无访问权限或账户状态异常</p>
    <button @click="retry">重试 ({{ retryCount }}/3)</button>
  </div>
</div>
  • VuReact 编译后 React 代码:
{
  user.role === 'admin' && (user.permissions.includes('write') || isSuperAdmin) ? (
    <div>
      <h1>管理员控制面板</h1>
      <button onClick={deleteAll}>删除所有数据</button>
    </div>
  ) : user.role === 'editor' && articles.length > 0 && !isSuspended ? (
    <div>
      <h2>编辑文章 (共{articles.length}篇)</h2>
      <ul>
        {articles.map((article) => (
          <li key={article.id}>{article.title}</li>
        ))}
      </ul>
    </div>
  ) : user.role === 'viewer' && hasSubscription ? (
    <div>
      <h3>订阅用户视图</h3>
      <p>您的订阅将于{subscriptionEndDate}到期</p>
    </div>
  ) : user.role === 'guest' && showTrial ? (
    <div>
      <div className="trial-banner">
        <p>试用用户,剩余{trialDays}天</p>
        <button onClick={upgrade}>升级账户</button>
      </div>
    </div>
  ) : (
    <div>
      <div className="error-state">
        {isLoading ? (
          <p>加载中...</p>
        ) : errorMessage ? (
          <p>{errorMessage}</p>
        ) : (
          <p>无访问权限或账户状态异常</p>
        )}
        <button onClick={retry}>重试 ({retryCount}/3)</button>
      </div>
    </div>
  );
}

对于复杂的业务场景,VuReact 展示了完整的条件编译能力

  1. 复杂条件表达式:将 Vue 的复杂条件逻辑(&&||、函数调用等)原样转换为 JSX 表达式
  2. 事件绑定转换@click 转换为 onClick,保持事件语义
  3. 插值表达式{{ }} 转换为 { },保持数据绑定
  4. 样式类名转换class 转换为 className,符合 React 规范

VuReact 的编译策略完全保持 Vue 的条件渲染语义,同时生成符合 React 最佳实践的代码,提高可维护性。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

Vue 项目高德地图性能优化实战:从卡死到丝滑的完整过程

2026年4月18日 10:30

几千个点位一次渲染就卡爆浏览器?路由切换越用越慢直到内存崩溃?本文将完整还原 Vue 3 + 高德地图项目的优化实战过程,附详细代码示例。

一、问题来了:地图怎么就卡死了?

去年接手了一个数据可视化大屏项目,核心功能是在高德地图上展示全国范围内的实时业务数据点位,大概有三四千个。开发阶段本地测试一切正常,上了测试环境之后,问题一个接一个地冒出来了:页面首次加载白屏时间长、地图缩放拖拽掉帧、切换页面之后浏览器越来越卡,跑几个来回就崩溃了

更让人头疼的是,这个问题在 Chrome 和 Edge 上特别明显,Firefox 反而相对平稳,说明这绝不是简单的代码 bug,而是涉及到浏览器渲染机制、地图 SDK 加载策略和 Vue 生命周期管理的综合性能问题。

经过反复排查,我总结出了导致地图卡顿的几个核心原因:

问题维度 典型表现 排查工具
加载策略 首屏白屏长,LCP 差 Lighthouse、Network 瀑布流
渲染性能 平移/缩放掉帧,交互卡顿 Performance 面板、FPS 监控
内存管理 多页面切换后越用越慢 Memory 面板、堆快照分析

接下来,我按实际优化顺序,逐一给出解决方案。

二、优化一:异步加载,别让地图拖垮首屏

很多教程和快速示例都建议在 index.html 里直接用 <script> 标签同步引入高德地图 JS API。这种方式虽然简单,但有一个致命问题:它会阻塞 HTML 的解析和渲染。在网络不好或 API 服务器响应慢的情况下,用户面对的就是一个长时间的空白页面。

更糟糕的是,整个高德 SDK 会在应用初始化时全部加载,不管当前页面是否真的需要地图。

改进方案:使用 AMapLoader 动态加载

高德官方提供了 @amap/amap-jsapi-loader,但要注意不能在文件顶部直接 import,否则 Vite 打包时这个依赖会被打进首屏 bundle,一样会增加首包体积。

正确的做法是在 onMounted 中动态导入:

// ❌ 错误:在文件顶部 import,会被打进首屏 bundle
import AMapLoader from '@amap/amap-jsapi-loader'

// ✅ 正确:在函数内部动态 import,单独拆分成 chunk
async function initMap() {
  const { default: AMapLoader } = await import('@amap/amap-jsapi-loader')
  
  window._AMapSecurityConfig = {
    securityJsCode: import.meta.env.VITE_AMAP_SECURITY_CODE
  }
  
  const AMap = await AMapLoader.load({
    key: import.meta.env.VITE_AMAP_KEY,
    version: '2.0',
    plugins: ['AMap.Scale', 'AMap.MarkerCluster', 'AMap.Geolocation']
  })
  
  // 初始化地图
  const map = new AMap.Map('map-container', {
    zoom: 10,
    center: [116.397428, 39.90923]
  })
  
  return { AMap, map }
}

这样做的好处是:高德相关代码会被打包成单独的 chunk,只有执行到这个函数时才会加载,首屏 bundle 体积更小,页面渲染更快。而且 SSR 场景下 Node 环境不会执行 onMounted 钩子,自然也就避开了 window is not defined 的问题。

三、优化二:shallowRef,不让 Vue 响应式拖后腿

地图实例、Marker 对象这些数据非常庞大,如果直接用 Vue 的 ref 存储,Vue 会递归地给它们的每个属性添加响应式代理,这会导致严重的性能损耗。

一个真实踩坑场景:有次我把地图实例直接放进了 ref,结果地图缩放时明显感觉掉帧,排查了半天才发现是 Vue 在给地图对象做响应式劫持。

解决方案:用 shallowRef 替代 ref

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

// ✅ 使用 shallowRef 存储地图相关实例
const map = shallowRef(null)
const currentLocationMarker = shallowRef(null)
const cluster = shallowRef(null)

async function initMap() {
  const { AMap, mapInstance } = await loadMap()
  map.value = mapInstance
  
  // 存储插件实例也要用 shallowRef
  const markerCluster = new AMap.MarkerCluster(mapInstance, points, {
    gridSize: 60,
    maxZoom: 18
  })
  cluster.value = markerCluster
}

shallowRef 只追踪 .value 的访问,不会对其内部属性进行响应式代理,对于地图实例这种大型对象来说非常合适。

四、优化三:点聚合,把几千个点变成几十个簇

当数据量超过 500 个点时,直接渲染所有 Marker 会让地图操作变得卡顿。我手上的项目有 8000+ 个点位,首次进入时地图直接崩了。

救星就是 AMap.MarkerCluster 点聚合插件。

它的原理很简单:当地图缩放到较低级别时,把距离相近的点合并成一个带数字的聚合点;放大地图时再自动展开成独立的点标记。官方宣称可以支持 10 万以内的点位保持较好性能。

async function initMapWithCluster(pointsData) {
  const { AMap, map } = await initMap()
  
  // 准备点位数据,格式必须包含 lnglat
  const points = pointsData.map(item => ({
    lnglat: [item.lng, item.lat],
    weight: item.value, // 可选权重
    extData: item       // 附加业务数据
  }))
  
  // 初始化点聚合
  const markerCluster = new AMap.MarkerCluster(map, points, {
    gridSize: 60,        // 聚合网格大小,默认60
    maxZoom: 18,         // 最大聚合级别,18级以上不聚合
    minClusterSize: 2,   // 至少2个点才聚合
    renderClusterMarker: (context) => {
      // 自定义聚合点样式
      const div = document.createElement('div')
      const count = context.count
      div.style.backgroundColor = `rgba(66, 133, 244, ${Math.min(0.8, 0.3 + count / 100)})`
      div.style.width = `${Math.min(60, 30 + count / 5)}px`
      div.style.height = `${Math.min(60, 30 + count / 5)}px`
      div.style.borderRadius = '50%'
      div.style.display = 'flex'
      div.style.alignItems = 'center'
      div.style.justifyContent = 'center'
      div.style.color = 'white'
      div.style.fontWeight = 'bold'
      div.style.fontSize = `${Math.min(18, 12 + count / 10)}px`
      div.innerHTML = count > 99 ? '99+' : count
      context.marker.setContent(div)
    }
  })
  
  return { map, markerCluster }
}

点聚合配置的几个关键参数:

  • gridSize:聚合网格的像素大小,调大可以让更多点被聚合,调小则保留更多独立点
  • minClusterSize:至少多少个点才触发聚合,设置为 2 表示单个点不聚合
  • maxZoom:在哪个缩放级别以上停止聚合,让用户可以查看独立点位

聚合前后的性能差异非常明显:8000 个独立 Marker 会导致地图完全卡死,而通过聚合只需要渲染几十个聚合点和当前视野内的少量 Marker,流畅度天差地别。

五、优化四:动态更新聚合图层,别让旧图层“粘”在地图上

一个容易被忽略的坑:当查询条件变化、点位数据需要全部更新时,旧的聚合图层很难彻底清除。直接调用 map.clearMap() 对聚合图层内部生成的 Marker 无效,因为那些 Marker 是 MarkerCluster 实例管理的,不是直接添加到地图上的。

正确的做法是销毁旧的聚合实例,再重新创建:

// ❌ 错误:直接清地图,聚合图层还在
function updatePointsWrong(newPoints) {
  map.value.clearMap()  // 对聚合图层无效!
  initMapWithCluster(newPoints)  // 新旧图层叠加,地图混乱
}

// ✅ 正确:先销毁旧聚合实例
function updatePointsCorrect(newPoints) {
  // 1. 销毁旧的聚合实例
  if (cluster.value) {
    cluster.value.setMap(null)  // 从地图上移除
    cluster.value = null
  }
  
  // 2. 重新创建聚合图层
  const { map: newMap, markerCluster } = await initMapWithCluster(newPoints)
  map.value = newMap
  cluster.value = markerCluster
}

如果只是部分点位数据变化,不需要完全重建,可以用 setData 方法更新:

// 动态更新点位数据
function refreshPoints(newPointsData) {
  const newPoints = newPointsData.map(item => ({
    lnglat: [item.lng, item.lat],
    extData: item
  }))
  cluster.value.setData(newPoints)  // 高效更新,无需重建实例
}

关键要点MarkerCluster 是一个管理器,它内部生成的 Marker 由它自己管理。要清除聚合图层,必须调用 setMap(null) 销毁整个聚合实例,或者用 setData 更新数据,而不是试图用 map.clearMap() 手动清理。

六、优化五:视口裁剪 + 防抖,只渲染用户看得见的点

即使使用了点聚合,在聚合层级以下(比如缩放到某个城市级别时)仍然可能需要渲染成百上千个独立 Marker。这些点如果不在当前视口内,渲染它们完全是浪费资源。

优化思路:只渲染当前地图视野内的点位,并监听地图的缩放/移动事件动态更新。

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

const map = ref(null)
const visiblePoints = ref([])      // 当前视野内的点位
const allPoints = ref([])          // 全量点位数据
let renderTimer = null

// 计算当前视野内的点位
function updateVisibleMarkers() {
  if (!map.value) return
  
  const bounds = map.value.getBounds()  // 获取当前地图边界
  const sw = bounds.getSouthWest()      // 西南角坐标
  const ne = bounds.getNorthEast()      // 东北角坐标
  
  // 筛选视野内的点
  visiblePoints.value = allPoints.value.filter(point => {
    return point.lng >= sw.lng && point.lng <= ne.lng &&
           point.lat >= sw.lat && point.lat <= ne.lat
  })
  
  // 重新渲染 Marker
  renderMarkersInViewport()
}

// 防抖处理:用户停止操作后再渲染
function onMapViewChange() {
  if (renderTimer) clearTimeout(renderTimer)
  renderTimer = setTimeout(() => {
    updateVisibleMarkers()
  }, 200)  // 200ms 防抖延迟
}

onMounted(() => {
  initMap().then(({ map: mapInstance }) => {
    map.value = mapInstance
    map.value.on('moveend', onMapViewChange)   // 移动结束
    map.value.on('zoomend', onMapViewChange)   // 缩放结束
    updateVisibleMarkers()
  })
})

onUnmounted(() => {
  if (map.value) {
    map.value.off('moveend', onMapViewChange)
    map.value.off('zoomend', onMapViewChange)
    map.value.destroy()  // 组件卸载时销毁地图实例,释放内存
  }
})

这种做法的核心思想是 只渲染用户看得见的内容,配合 200ms 的防抖处理,避免在用户快速拖动时频繁触发渲染。实测下来,地图交互的帧率从 20fps 提升到了 55fps 以上。

七、容易被忽视的两个细节

1. 自定义图标尺寸别太大

如果用了自定义的 Marker 图标,高德官方强烈建议将图标尺寸控制在 60px × 60px 以内。图标太大不仅占内存,每次缩放时的重绘开销也成倍增加。

2. 组件销毁时务必清理干净

Vue 项目中非常隐蔽的一个问题:地图实例、Marker、事件监听器如果在组件销毁时没有正确清理,就会一直驻留在内存中。用户在有地图的多个路由间来回切换几次,内存占用就会像滚雪球一样越来越大,最终触发频繁的 GC 停顿,导致页面卡顿。

onUnmounted(() => {
  // 1. 移除所有事件监听
  if (map.value) {
    map.value.off('moveend', onMapViewChange)
    map.value.off('zoomend', onMapViewChange)
  }
  
  // 2. 销毁聚合实例
  if (cluster.value) {
    cluster.value.setMap(null)
    cluster.value = null
  }
  
  // 3. 销毁地图实例
  if (map.value) {
    map.value.destroy()
    map.value = null
  }
})

八、总结:这些优化让地图起飞了

经过以上几轮优化,项目的性能数据有了质的提升:

  • 首屏加载时间:从 4.2 秒降到 1.5 秒(减少约 65%)
  • 地图操作帧率:从 20fps 左右提升到 55fps 以上
  • 内存占用:路由切换 5 次后内存从 350MB 降到 120MB
  • 最大支持点位:从 2000 个提升到 50000 个

最后把这些要点总结成一张速查表:

优化手段 适用场景 核心代码/配置
动态加载 SDK 首屏优化 await import('@amap/amap-jsapi-loader')
shallowRef 存储 地图实例、Marker const map = shallowRef(null)
MarkerCluster 点聚合 点位 > 500 new AMap.MarkerCluster(map, points, { gridSize: 60 })
销毁旧聚合实例 动态更新点位 cluster.setMap(null) → 重建
视口裁剪 + 防抖 缩放/拖拽时 bounds 筛选 + setTimeout 200ms
组件销毁清理 路由切换 map.destroy() + 解绑事件

性能优化的核心原则其实很朴素:能异步加载的绝不同步加载,能按需渲染的绝不全量渲染,能复用的实例绝不重复创建。希望这篇文章能帮你少踩一些我踩过的坑。

你的 Vue 路由,VuReact 会编译成什么样的 React 路由?

作者 Ruihong
2026年4月17日 21:58

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天我们从 Vue Router 宏观对照入手,看看 Vue 中的路由组件、API 与入口结构,经过 VuReact 编译后会变成什么样的 React 路由代码。

另外,本文仅展示部分路由组件与 API,实际上完整适配还包括路由类型接口等更多内容,详情请查阅 VuReact Router 文档。

前置约定

为避免示例冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue Router API 用法与核心行为。

编译对照

router 组件:<router-link> / <router-view>

Vue 的路由组件在 React 中被映射为 @vureact/router 提供的适配组件。

  • Vue 代码:
<template>
  <router-link to="/home">Home</router-link>
  <router-view />
</template>
  • VuReact 编译后 React 代码:
import { RouterLink, RouterView } from '@vureact/router';

return (
  <>
    <RouterLink to="/home">Home</RouterLink>
    <RouterView />
  </>
);

RouterLink 在 React 中同样支持字符串 to、对象 toactiveClassNamecustomRender 等 Vue 风格用法;RouterView 负责渲染当前匹配路由组件,并保持嵌套路由、路由守卫与元字段的执行顺序。


路由配置:createRouter + history

Vue Router 的创建方式在 VuReact 中保持语义一致,但依赖会替换为 @vureact/router

  • Vue 代码:
import { createRouter, createWebHistory } from 'vue-router';
import Home from './views/Home.vue';

export default createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: Home },
  ],
});
  • VuReact 编译后 React 代码:
import { createRouter, createWebHistory } from '@vureact/router';
import Home from './views/Home';

export default createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: Home },
  ],
});

这说明:

  • createRouter / createWebHistory 等 API 名称保持不变;
  • 仅依赖路径会被替换成 @vureact/router
  • Vue Router 的路由记录、嵌套路由、meta 字段可直接保留。

入口注入:RouterProvider

如果启用了自动适配,VuReact 会在编译后自动调整入口文件,将原 <App /> 替换为路由实例的 RouterProvider

  • 生成后的 React 入口文件:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import RouterInstance from './router/index';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterInstance.RouterProvider />
  </StrictMode>,
);

该入口结构体现了 Vue 路由到 React 路由适配的宏观变化:

  • Vue 的路由配置文件继续作为路由实例入口;
  • React 入口通过 RouterProvider 挂载路由上下文;
  • 因此无需手动改写业务路由逻辑,只需保证路由定义规范。

运行时 API:useRouter / useRoute

Vue 的组合式路由 API 在 React 中仍保留相同语义。

  • Vue 代码:
const router = useRouter();
const route = useRoute();

const goHome = () => {
  router.push('/home');
};
  • VuReact 编译后 React 代码:
import { useRouter, useRoute } from '@vureact/router';

const router = useRouter();
const route = useRoute();

const goHome = useCallback(() => {
  router.push('/home');
}, [router]);

useRouter()useRoute() 仍然支持编程式导航、参数读取、meta 等字段,且使用方式与 Vue Router 组合式 API 语义保持一致。


自动适配

当编译器检测到项目中使用 Vue Router 时,会自动:

  • import ... from 'vue-router' 替换为 import ... from '@vureact/router'
  • 将路由配置文件产物变更为 @vureact/router 的路由实例;
  • 将入口文件自动改写为 RouterProvider 渲染。

配置示例:

import { defineConfig } from '@vureact/compiler-core';

export default defineConfig({
  router: {
    // 路由入口文件路径(即调用并默认导出 createRouter() 的地方)
    configFile: 'src/router/index.ts',
  },
});

手动适配

以下方案为通用建议,具体实现细节请开发者根据实际项目需求进行调整。

当选项 output.bootstrapVite 或者 router.autoSetupfalse 时,自动适配不可用,需要手动完成:

  • 导出 Vue Router 的 createRouter() 实例;
  • 在 React 入口文件中,将原本渲染 <App /> 的代码替换为 @vureact/router 路由实例所提供的 <RouterProvider /> 组件。

手动适配的核心是:保留 Vue Router 的路由定义与嵌套路由结构,导出路由器实例,替换 React 入口渲染方式。

相关资源


如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

你的 Vue 3 defineAsyncComponent(),VuReact 会编译成什么样的 React?

作者 Ruihong
2026年4月17日 21:54

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中用于异步组件的 defineAsyncComponent() 经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中 defineAsyncComponent 的 API 用法与核心行为。

编译对照

Vue defineAsyncComponent() → React defineAsyncComponent()

defineAsyncComponent 是 Vue 3 中用于定义异步组件的 API,它允许你按需加载组件,优化应用性能。VuReact 会将其编译为同名的 defineAsyncComponent,让 React 中也能获得同样的异步组件能力。

  • Vue 代码:
<script setup>
  import { defineAsyncComponent } from 'vue';

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

<template>
  <AsyncComponent />
</template>
  • VuReact 编译后 React 代码:
import { defineAsyncComponent } from '@vureact/runtime-core';

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

function MyComponent() {
  return <AsyncComponent />;
}

VuReact 提供的 defineAsyncComponentVue defineAsyncComponent 的适配 API,可理解为「React 版的 Vue defineAsyncComponent」,完全模拟 Vue defineAsyncComponent 的异步加载行为——支持懒加载、加载状态处理、错误处理等完整功能。

defineAsyncComponent 高级用法

defineAsyncComponent 在 Vue 3 中支持多种配置选项,如加载状态组件、错误处理组件、超时设置等。VuReact 会将其编译为相应的 React 配置,保持功能一致性。

  • Vue 代码:
<script setup>
  import { defineAsyncComponent } from 'vue';

  const AsyncComponent = defineAsyncComponent({
    loader: () => import('./components/HeavyComponent.vue'),
    loadingComponent: LoadingSpinner,
    errorComponent: ErrorDisplay,
    delay: 200,
    timeout: 3000,
    suspensible: true,
  });
</script>
  • VuReact 编译后 React 代码:
import { defineAsyncComponent } from '@vureact/runtime-core';
import LoadingSpinner from './components/LoadingSpinner';
import ErrorDisplay from './components/ErrorDisplay';

const AsyncComponent = defineAsyncComponent({
  loader: () => import('./components/HeavyComponent'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,
  timeout: 3000,
  suspensible: true,
});

VuReact 提供的 defineAsyncComponent 支持 所有 Vue defineAsyncComponent 的配置选项,包括 loaderloadingComponenterrorComponentdelaytimeoutsuspensible 等,完全模拟 Vue defineAsyncComponent 的高级功能——在 React 中实现与 Vue 一致的异步组件体验。

请注意,hydrate 选项不支持,但保留了该选项进行兼容,无实际功能。

相关资源


如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

Element Plus 组件库实战技巧与踩坑记录

作者 晴天丨
2026年4月17日 20:18

🎨 Element Plus 组件库实战技巧与踩坑记录

分享我在Vue 3项目中使用Element Plus的经验技巧和踩坑记录

前言

Element Plus是Vue 3生态中最流行的UI组件库之一,提供了丰富的组件和良好的设计。在开发博客项目的过程中,我积累了很多使用Element Plus的经验和技巧,也踩过一些坑。本文将分享这些实战经验。

快速上手

1. 安装与配置

# 安装Element Plus
npm install element-plus

# 安装图标库
npm install @element-plus/icons-vue
// main.ts
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'

const app = createApp(App)

// 注册所有组件
app.use(ElementPlus)

// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.mount('#app')

2. 按需引入(推荐)

为了减小包体积,建议按需引入组件:

# 安装按需引入插件
npm install -D unplugin-vue-components unplugin-auto-import
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
})

这样配置后,使用组件时会自动按需引入,无需手动import。

常用组件技巧

1. 表单组件

el-form深度验证
<template>
  <el-form
    ref="formRef"
    :model="formData"
    :rules="rules"
    label-width="120px"
  >
    <el-form-item label="标题" prop="title">
      <el-input v-model="formData.title" />
    </el-form-item>

    <el-form-item label="邮箱" prop="email">
      <el-input v-model="formData.email" />
    </el-form-item>

    <el-form-item label="密码" prop="password">
      <el-input
        v-model="formData.password"
        type="password"
        show-password
      />
    </el-form-item>

    <el-form-item>
      <el-button type="primary" @click="handleSubmit">
        提交
      </el-button>
      <el-button @click="handleReset">
        重置
      </el-button>
    </el-form-item>
  </el-form>
</template>

<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'

const formRef = ref<FormInstance>()

const formData = reactive({
  title: '',
  email: '',
  password: ''
})

const rules = reactive<FormRules>({
  title: [
    { required: true, message: '请输入标题', trigger: 'blur' },
    { min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
  ],
  email: [
    { required: true, message: '请输入邮箱地址', trigger: 'blur' },
    { type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change'] }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' }
  ]
})

const handleSubmit = async () => {
  if (!formRef.value) return

  await formRef.value.validate((valid, fields) => {
    if (valid) {
      // 验证通过,提交表单
      console.log('提交:', formData)
    } else {
      console.log('验证失败:', fields)
    }
  })
}

const handleReset = () => {
  formRef.value?.resetFields()
}
</script>
动态表单
<template>
  <el-form :model="formData">
    <el-form-item
      v-for="(item, index) in formData.items"
      :key="index"
      :label="'项目 ' + (index + 1)"
    >
      <el-input v-model="item.value" />
      <el-button
        @click="removeItem(index)"
        icon="Delete"
        type="danger"
      >
        删除
      </el-button>
    </el-form-item>

    <el-button @click="addItem" icon="Plus">
      添加项目
    </el-button>
  </el-form>
</template>

<script setup lang="ts">
const formData = reactive({
  items: [{ value: '' }]
})

const addItem = () => {
  formData.items.push({ value: '' })
}

const removeItem = (index: number) => {
  formData.items.splice(index, 1)
}
</script>

2. 表格组件

表格排序和筛选
<template>
  <el-table
    :data="filteredData"
    :default-sort="{ prop: 'date', order: 'descending' }"
    @sort-change="handleSortChange"
  >
    <el-table-column prop="title" label="标题" sortable />
    <el-table-column
      prop="category"
      label="分类"
      :filters="categoryFilters"
      :filter-method="filterCategory"
    />
    <el-table-column prop="views" label="浏览量" sortable />
    <el-table-column prop="date" label="日期" sortable />
  </el-table>
</template>

<script setup lang="ts">
const articles = ref<Article[]>([])

const filteredData = computed(() => {
  return articles.value
})

const categoryFilters = [
  { text: 'Vue', value: 'Vue' },
  { text: 'React', value: 'React' },
  { text: 'TypeScript', value: 'TypeScript' }
]

const filterCategory = (value: string, row: Article) => {
  return row.category === value
}

const handleSortChange = (sort: any) => {
  console.log('排序改变:', sort)
}
</script>
表格分页
<template>
  <el-table :data="paginatedData">
    <!-- 列定义 -->
  </el-table>

  <el-pagination
    v-model:current-page="currentPage"
    v-model:page-size="pageSize"
    :total="total"
    :page-sizes="[10, 20, 50, 100]"
    layout="total, sizes, prev, pager, next, jumper"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  />
</template>

<script setup lang="ts">
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)

const paginatedData = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  const end = start + pageSize.value
  return articles.value.slice(start, end)
})

const handleSizeChange = (size: number) => {
  pageSize.value = size
}

const handleCurrentChange = (page: number) => {
  currentPage.value = page
}
</script>

3. 弹窗组件

对话框嵌套
<template>
  <el-button @click="showDialog = true">打开对话框</el-button>

  <el-dialog v-model="showDialog" title="父对话框">
    <p>这是父对话框的内容</p>

    <el-button @click="showChildDialog = true">
      打开子对话框
    </el-button>

    <el-dialog
      v-model="showChildDialog"
      title="子对话框"
      append-to-body
    >
      <p>这是子对话框的内容</p>
    </el-dialog>
  </el-dialog>
</template>

<script setup lang="ts">
const showDialog = ref(false)
const showChildDialog = ref(false)
</script>

注意:嵌套对话框时,子对话框需要添加append-to-body属性。

4. 树形组件

异步加载树
<template>
  <el-tree
    :props="defaultProps"
    :load="loadNode"
    lazy
    show-checkbox
  />
</template>

<script setup lang="ts">
const defaultProps = {
  label: 'name',
  children: 'children',
  isLeaf: 'leaf'
}

const loadNode = async (node: Node, resolve: (data: TreeData[]) => void) => {
  if (node.level === 0) {
    // 加载根节点
    const data = await loadRootNodes()
    resolve(data)
  } else {
    // 加载子节点
    const data = await loadChildNodes(node.data.id)
    resolve(data)
  }
}

const loadRootNodes = async () => {
  // 异步加载数据
  return [
    { name: '节点1', id: 1 },
    { name: '节点2', id: 2 }
  ]
}
</script>

主题定制

1. 使用CSS变量

// styles/theme.scss
:root {
  --el-color-primary: #409eff;
  --el-color-success: #67c23a;
  --el-color-warning: #e6a23c;
  --el-color-danger: #f56c6c;
  --el-color-info: #909399;
}

// 使用自定义主题
$--color-primary: var(--el-color-primary);

2. SCSS变量覆盖

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ElementPlus from 'unplugin-element-plus/vite'

export default defineConfig({
  plugins: [
    vue(),
    ElementPlus({
      // 使用scss样式
      useSource: true
    })
  ]
})
// styles/element-variables.scss
/* 改变主题色变量 */
$--color-primary: #1890ff;
$--color-success: #52c41a;
$--color-warning: #faad14;
$--color-danger: #f5222d;
$--color-info: #909399;

/* 改变icon字体路径变量,必需 */
$--font-path: '~element-plus/lib/theme-chalk/fonts';

@import "~element-plus/packages/theme-chalk/src/index";

3. 暗黑模式

<template>
  <el-switch
    v-model="isDark"
    @change="toggleDark"
    inline-prompt
    active-text="暗"
    inactive-text="亮"
  />
</template>

<script setup lang="ts">
const isDark = ref(false)

const toggleDark = (value: boolean) => {
  if (value) {
    document.documentElement.classList.add('dark')
  } else {
    document.documentElement.classList.remove('dark')
  }
}
</script>

<style>
/* 暗黑模式样式 */
html.dark {
  --el-bg-color: #141414;
  --el-text-color-primary: #e5eaf3;
  --el-border-color: #4c4d4f;
  --el-border-color-light: #414243;
}
</style>

性能优化

1. 图标按需加载

// utils/icons.ts
import { registerIcons } from 'element-plus/es/components/icon'

// 只注册需要的图标
export function lazyRegisterIcons() {
  const icons = [
    'Edit',
    'Delete',
    'View',
    'Download',
    'Share',
    'Star',
    'Plus',
    'Search',
    'Home'
  ]

  // 使用requestIdleCallback在空闲时注册
  const idleCallback = window.requestIdleCallback || window.setTimeout

  idleCallback(() => {
    registerIcons(icons)
  })
}

// main.ts
import { lazyRegisterIcons } from './utils/icons'
lazyRegisterIcons()

2. 虚拟滚动

<template>
  <el-virtual-list
    :data="items"
    :height="400"
    :item-size="50"
  >
    <template #default="{ item, index }">
      <div class="item">
        {{ index }} - {{ item.name }}
      </div>
    </template>
  </el-virtual-list>
</template>

<script setup lang="ts">
import { ElVirtualList } from 'element-plus'

// 生成大量数据
const items = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `Item ${i}`
}))
</script>

踩坑记录

1. Dialog关闭不触发事件

问题:点击遮罩层关闭Dialog时,没有触发关闭事件。

解决:使用before-close属性:

<el-dialog
  v-model="visible"
  :before-close="handleClose"
>
  <template #header>
    <span>标题</span>
  </template>
</el-dialog>

<script setup lang="ts">
const handleClose = (done: () => void) => {
  // 执行关闭前的逻辑
  done()
}
</script>

2. Table固定列错位

问题:表格固定列在滚动时出现错位。

解决:监听窗口大小变化,调用doLayout方法:

<template>
  <el-table
    ref="tableRef"
    :data="tableData"
  >
    <el-table-column prop="date" label="日期" fixed />
    <el-table-column prop="name" label="姓名" />
  </el-table>
</template>

<script setup lang="ts">
const tableRef = ref()

onMounted(() => {
  window.addEventListener('resize', () => {
    tableRef.value?.doLayout()
  })
})
</script>

3. Select下拉框显示位置错误

问题:Select组件的下拉框在页面滚动后显示位置错误。

解决:使用popper-options配置:

<el-select
  v-model="value"
  :popper-options="{
    modifiers: [
      {
        name: 'flip',
        options: {
          fallbackPlacements: ['bottom-start', 'top-start']
        }
      }
    ]
  }"
>
  <el-option
    v-for="item in options"
    :key="item.value"
    :label="item.label"
    :value="item.value"
  />
</el-select>

4. DatePicker时间格式问题

问题:DatePicker返回的日期格式不符合预期。

解决:使用value-format属性:

<el-date-picker
  v-model="date"
  type="datetime"
  value-format="YYYY-MM-DD HH:mm:ss"
  placeholder="选择日期时间"
/>

5. Upload组件上传失败

问题:Upload组件在某些情况下上传失败。

解决:正确处理on-successon-error回调:

<el-upload
  action="/api/upload"
  :on-success="handleSuccess"
  :on-error="handleError"
  :before-upload="beforeUpload"
>
  <el-button type="primary">上传文件</el-button>
</el-upload>

<script setup lang="ts">
const handleSuccess = (response: any, file: any) => {
  if (response.code === 200) {
    ElMessage.success('上传成功')
  } else {
    ElMessage.error(response.message)
  }
}

const handleError = (error: any) => {
  ElMessage.error('上传失败:' + error.message)
}

const beforeUpload = (file: File) => {
  const isJPG = file.type === 'image/jpeg' || file.type === 'image/png'
  const isLt2M = file.size / 1024 / 1024 < 2

  if (!isJPG) {
    ElMessage.error('只能上传JPG/PNG图片!')
  }
  if (!isLt2M) {
    ElMessage.error('图片大小不能超过2MB!')
  }
  return isJPG && isLt2M
}
</script>

最佳实践

1. 统一配置

// config/element-plus.ts
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'

export default {
  locale: zhCn,
  size: 'default',
  zIndex: 3000
}
<!-- App.vue -->
<template>
  <el-config-provider :locale="locale">
    <router-view />
  </el-config-provider>
</template>

<script setup lang="ts">
import zhCn from 'element-plus/es/locale/lang/zh-cn'
const locale = zhCn
</script>

2. 封装常用组件

<!-- components/SearchInput.vue -->
<template>
  <el-input
    v-model="searchText"
    :placeholder="placeholder"
    clearable
    @clear="handleClear"
    @input="handleInput"
  >
    <template #prefix>
      <el-icon><Search /></el-icon>
    </template>
    <template #suffix>
      <el-button
        v-if="searchText"
        link
        icon="Close"
        @click="handleClear"
      />
    </template>
  </el-input>
</template>

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

interface Props {
  modelValue: string
  placeholder?: string
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: '请输入搜索内容'
})

const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void
  (e: 'search', value: string): void
}>()

const searchText = ref(props.modelValue)

watch(() => props.modelValue, (val) => {
  searchText.value = val
})

watch(searchText, (val) => {
  emit('update:modelValue', val)
})

const handleClear = () => {
  searchText.value = ''
  emit('search', '')
}

const handleInput = debounce((value: string) => {
  emit('search', value)
}, 300)
</script>

3. 全局样式覆盖

// styles/element-overrides.scss

// 全局修改el-button样式
.el-button {
  border-radius: 4px;
  font-weight: 500;

  &--primary {
    background-color: #1890ff;
    border-color: #1890ff;

    &:hover {
      background-color: #40a9ff;
      border-color: #40a9ff;
    }
  }
}

// 修改el-dialog样式
.el-dialog {
  border-radius: 8px;
  overflow: hidden;

  .el-dialog__header {
    padding: 20px 20px 10px;
    border-bottom: 1px solid #f0f0f0;
  }

  .el-dialog__body {
    padding: 20px;
  }
}

总结

Element Plus是一个功能强大、设计优秀的UI组件库,掌握以下要点可以更好地使用它:

  1. 按需引入 - 减小包体积
  2. 主题定制 - 符合项目风格
  3. 性能优化 - 图标懒加载、虚拟滚动
  4. 踩坑经验 - 了解常见问题和解决方案
  5. 最佳实践 - 封装常用组件、统一配置

希望这些经验能帮助你在Vue 3项目中更好地使用Element Plus!


标签:#ElementPlus #Vue3 #UI组件库 #前端 #实战技巧

点赞❤️ + 收藏⭐️ + 评论💬,你的支持是我创作的动力!

Vue 3项目架构设计:从2200行单文件到24个组件

作者 晴天丨
2026年4月17日 20:17

🏗️ Vue 3项目架构设计:从2200行单文件到24个组件

分享我在Vue 3博客项目中的架构重构经验,代码可维护性大幅提升

前言

在项目初期,为了快速实现功能,我把大部分代码都写在了App.vue中,导致单文件达到了2200多行。随着功能增多,代码越来越难以维护。于是我开始进行架构重构,将代码拆分成24个独立组件,最终实现了更好的代码组织和可维护性。

重构前后对比

代码结构对比

重构前:

App.vue (2200+ 行)
├── 布局代码
├── 业务逻辑
├── 组件代码
└── 工具函数

重构后:

src/
├── components/
│   ├── layout/        (5个组件)
│   ├── features/      (4个组件)
│   ├── gamification/  (4个组件)
│   └── article/       (6个组件)
├── composables/       (5个组合函数)
├── utils/             (3个工具模块)
└── views/             (5个页面组件)

数据对比

指标 重构前 重构后 改善
单文件最大行数 2200+ 400 ⬇️ 82%
组件数量 1 24 ⬆️ 24倍
代码复用率 0% 40%+ ⬆️ 40%
可维护性 ⬆⬆⬆

架构设计原则

1. 单一职责原则

每个组件只负责一个功能模块。

<!-- ❌ 错误:一个组件包含多个职责 -->
<template>
  <div>
    <Header />
    <ArticleList />
    <Sidebar />
    <MusicPlayer />
    <Notification />
    <Footer />
  </div>
</template>

<!-- ✅ 正确:每个组件单一职责 -->
<template>
  <div>
    <AppBackground />
    <TheHeader />
    <TheMain>
      <RouterView />
    </TheMain>
    <TheFooter />
    <BackToTop />
    <Notification />
  </div>
</template>

2. 开闭原则

通过props和emits扩展组件功能,不修改组件内部代码。

<!-- ArticleCard.vue -->
<template>
  <article :class="['article-card', variant]">
    <ArticleMeta :article="article" />
    <ArticleContent :article="article" />
    <slot name="actions">
      <ArticleActions :article="article" />
    </slot>
  </article>
</template>

<script setup lang="ts">
interface Props {
  article: Article
  variant?: 'default' | 'compact' | 'featured'
}

defineProps<Props>()
</script>

3. 依赖倒置原则

组件依赖于抽象的接口(props/emits),而非具体实现。

// composables/usePagination.ts
export function usePagination(options: PaginationOptions) {
  const currentPage = ref(options.page || 1)
  const pageSize = ref(options.pageSize || 10)

  const nextPage = () => {
    currentPage.value++
  }

  const prevPage = () => {
    currentPage.value--
  }

  return {
    currentPage,
    pageSize,
    nextPage,
    prevPage
  }
}

组件分类体系

1. 布局组件(5个)

AppBackground
<!-- components/layout/AppBackground.vue -->
<template>
  <div class="app-background">
    <div class="gradient-bg"></div>
    <div class="particles"></div>
  </div>
</template>

<script setup lang="ts">
// 背景动画逻辑
</script>

<style scoped>
.app-background {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: -1;
}
</style>
TheHeader
<!-- components/layout/TheHeader.vue -->
<template>
  <header class="header">
    <Logo />
    <Navigation />
    <SearchTrigger />
    <SettingsTrigger />
    <NotificationTrigger />
  </header>
</template>

<script setup lang="ts">
import Logo from './Logo.vue'
import Navigation from './Navigation.vue'
import SearchTrigger from './SearchTrigger.vue'
</script>
TheFooter
<!-- components/layout/TheFooter.vue -->
<template>
  <footer class="footer">
    <Copyright />
    <SocialLinks />
    <Links />
  </footer>
</template>
BackToTop
<!-- components/layout/BackToTop.vue -->
<template>
  <transition name="fade">
    <button
      v-show="visible"
      @click="scrollToTop"
      class="back-to-top"
    >
      <el-icon><ArrowUp /></el-icon>
    </button>
  </transition>
</template>

<script setup lang="ts">
const visible = ref(false)

onMounted(() => {
  window.addEventListener('scroll', handleScroll)
})

const handleScroll = () => {
  visible.value = window.scrollY > 300
}

const scrollToTop = () => {
  window.scrollTo({ top: 0, behavior: 'smooth' })
}
</script>
ReadingProgressBar
<!-- components/layout/ReadingProgressBar.vue -->
<template>
  <div class="reading-progress">
    <div
      class="progress-bar"
      :style="{ width: progress + '%' }"
    ></div>
  </div>
</template>

<script setup lang="ts">
const progress = ref(0)

const updateProgress = () => {
  const scrollTop = window.scrollY
  const docHeight = document.documentElement.scrollHeight - window.innerHeight
  progress.value = (scrollTop / docHeight) * 100
}

onMounted(() => {
  window.addEventListener('scroll', updateProgress)
})
</script>

2. 功能组件(4个)

Notification
<!-- components/features/Notification.vue -->
<template>
  <transition-group name="notification">
    <div
      v-for="notif in notifications"
      :key="notif.id"
      :class="['notification', notif.type]"
    >
      <el-icon><component :is="notif.icon" /></el-icon>
      <span>{{ notif.message }}</span>
      <el-button
        icon="Close"
        @click="remove(notif.id)"
      />
    </div>
  </transition-group>
</template>

<script setup lang="ts">
import { useNotification } from '@/composables/useNotification'

const { notifications, remove } = useNotification()
</script>
SearchPanel
<!-- components/features/SearchPanel.vue -->
<template>
  <div class="search-panel">
    <el-input
      v-model="searchText"
      placeholder="搜索文章..."
      @input="handleSearch"
    >
      <template #prefix>
        <el-icon><Search /></el-icon>
      </template>
    </el-input>

    <div class="search-results">
      <ArticleCard
        v-for="article in results"
        :key="article.id"
        :article="article"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
const searchText = ref('')
const results = ref<Article[]>([])

const handleSearch = debounce(async (text: string) => {
  if (!text) {
    results.value = []
    return
  }
  results.value = await searchArticles(text)
}, 300)
</script>
SettingsPanel
<!-- components/features/SettingsPanel.vue -->
<template>
  <div class="settings-panel">
    <SettingSection title="主题">
      <ThemeToggle />
    </SettingSection>

    <SettingSection title="字体">
      <FontSizeSlider />
    </SettingSection>

    <SettingSection title="其他">
      <el-checkbox v-model="settings.enableMusic">
        启用背景音乐
      </el-checkbox>
    </SettingSection>
  </div>
</template>

<script setup lang="ts">
const settings = useSettings()
</script>
KeyboardHints
<!-- components/features/KeyboardHints.vue -->
<template>
  <div class="keyboard-hints">
    <kbd v-for="hint in hints" :key="hint.key">
      {{ hint.key }}
      <span>{{ hint.action }}</span>
    </kbd>
  </div>
</template>

<script setup lang="ts">
const hints = [
  { key: 'K', action: '搜索' },
  { key: 'N', action: '下一篇' },
  { key: 'P', action: '上一篇' }
]
</script>

3. 游戏化组件(4个)

EnergyDisplay
<!-- components/gamification/EnergyDisplay.vue -->
<template>
  <div class="energy-display">
    <div class="energy-bar">
      <div
        class="energy-fill"
        :style="{ width: energyPercentage + '%' }"
      ></div>
    </div>
    <div class="energy-value">{{ energy }}/100</div>
  </div>
</template>

<script setup lang="ts">
const { energy } = useEnergy()
const energyPercentage = computed(() => energy.value)
</script>
SignDialog
<!-- components/gamification/SignDialog.vue -->
<template>
  <el-dialog v-model="visible" title="每日签到">
    <div class="sign-calendar">
      <div
        v-for="day in 7"
        :key="day"
        :class="['sign-day', signedDays.includes(day) ? 'signed' : '']"
      >
        {{ day }}
      </div>
    </div>

    <el-button
      type="primary"
      :disabled="signedToday"
      @click="handleSign"
    >
      {{ signedToday ? '已签到' : '签到' }}
    </el-button>
  </el-dialog>
</template>

<script setup lang="ts">
const { signedDays, signedToday, sign } = useSign()
const visible = ref(false)

const handleSign = () => {
  sign()
}
</script>
MusicPlayer
<!-- components/gamification/MusicPlayer.vue -->
<template>
  <div class="music-player">
    <div class="player-info">
      <img :src="currentTrack.cover" :alt="currentTrack.name" />
      <div class="track-info">
        <div class="track-name">{{ currentTrack.name }}</div>
        <div class="track-artist">{{ currentTrack.artist }}</div>
      </div>
    </div>

    <div class="player-controls">
      <button @click="prevTrack">
        <el-icon><DArrowLeft /></el-icon>
      </button>
      <button @click="togglePlay">
        <el-icon><component :is="isPlaying ? VideoPause : VideoPlay" /></el-icon>
      </button>
      <button @click="nextTrack">
        <el-icon><DArrowRight /></el-icon>
      </button>
    </div>

    <div class="player-progress">
      <div
        class="progress-bar"
        :style="{ width: progress + '%' }"
      ></div>
    </div>
  </div>
</template>

<script setup lang="ts">
const {
  currentTrack,
  isPlaying,
  progress,
  togglePlay,
  prevTrack,
  nextTrack
} = useMusicPlayer()
</script>

4. 文章组件(6个)

ArticleCard
<!-- components/article/ArticleCard.vue -->
<template>
  <article class="article-card">
    <ArticleMeta :article="article" />
    <ArticleContent :article="article" />
    <ArticleActions :article="article" />
  </article>
</template>

<script setup lang="ts">
import ArticleMeta from './ArticleMeta.vue'
import ArticleContent from './ArticleContent.vue'
import ArticleActions from './ArticleActions.vue'

defineProps<{ article: Article }>()
</script>
ArticleMeta
<!-- components/article/ArticleMeta.vue -->
<template>
  <div class="article-meta">
    <div class="meta-row">
      <span class="author">{{ article.author }}</span>
      <span class="date">{{ formatDate(article.date) }}</span>
    </div>

    <div class="tags">
      <el-tag
        v-for="tag in article.tags"
        :key="tag"
        size="small"
      >
        {{ tag }}
      </el-tag>
    </div>
  </div>
</template>

<script setup lang="ts">
import { formatDate } from '@/utils/format'

defineProps<{ article: Article }>()
</script>

Composables设计

useArticle

// composables/useArticle.ts
export function useArticle() {
  const articles = ref<Article[]>([])
  const loading = ref(false)
  const error = ref<Error | null>(null)

  const fetchArticles = async () => {
    loading.value = true
    try {
      articles.value = await getArticles()
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  const getArticleById = (id: number) => {
    return articles.value.find(a => a.id === id)
  }

  return {
    articles,
    loading,
    error,
    fetchArticles,
    getArticleById
  }
}

useTheme

// composables/useTheme.ts
export function useTheme() {
  const isDark = ref(false)

  const toggleTheme = () => {
    isDark.value = !isDark.value
    document.documentElement.classList.toggle('dark')
  }

  return {
    isDark,
    toggleTheme
  }
}

useLocalStorage

// composables/useLocalStorage.ts
export function useLocalStorage<T>(key: string, defaultValue: T) {
  const stored = ref<T>(defaultValue)

  // 初始化时读取
  const init = () => {
    const item = localStorage.getItem(key)
    if (item) {
      try {
        stored.value = JSON.parse(item)
      } catch (e) {
        console.error('Failed to parse localStorage', e)
      }
    }
  }

  // 监听变化并保存
  watch(stored, (value) => {
    localStorage.setItem(key, JSON.stringify(value))
  }, { deep: true })

  init()

  return stored
}

组件通信方式

1. Props Down

<!-- 父组件 -->
<ArticleCard :article="article" variant="featured" />

<!-- 子组件 -->
<script setup lang="ts">
interface Props {
  article: Article
  variant?: 'default' | 'compact' | 'featured'
}

defineProps<Props>()
</script>

2. Emits Up

<!-- 子组件 -->
<script setup lang="ts">
const emit = defineEmits<{
  (e: 'like', articleId: number): void
  (e: 'collect', articleId: number): void
}>()

const handleLike = () => {
  emit('like', props.article.id)
}
</script>

<!-- 父组件 -->
<ArticleCard @like="handleLike" />

3. Provide/Inject

// 祖先组件
provide('theme', isDark)

// 后代组件
const theme = inject('theme')

4. Event Bus

// utils/eventBus.ts
import mitt from 'mitt'

export const eventBus = mitt<{
  notification: NotificationEvent
  refresh: void
}>()

// 发送事件
eventBus.emit('notification', { type: 'success', message: '操作成功' })

// 监听事件
eventBus.on('notification', (event) => {
  // 处理通知
})

性能优化

1. 组件懒加载

const HeavyComponent = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorComponent,
  delay: 200,
  timeout: 3000
})

2. 虚拟滚动

<VirtualList
  :data-sources="articles"
  :data-key="'id'"
  :keeps="30"
/>

3. 计算属性缓存

const hotArticles = computed(() => {
  return articles.value
    .filter(a => a.views > 1000)
    .sort((a, b) => b.views - a.views)
})

最佳实践

1. 组件命名

  • 使用PascalCase
  • 组件名与文件名保持一致
  • 使用语义化的名称

2. Props定义

  • 明确定义类型
  • 提供合理的默认值
  • 使用TypeScript类型检查

3. 样式管理

  • 使用scoped CSS
  • 避免样式污染
  • 使用CSS变量

总结

通过合理的架构设计和组件拆分,我们实现了:

  1. 更好的代码组织 - 职责清晰,易于理解
  2. 更高的可维护性 - 修改某个功能只需修改对应组件
  3. 更强的可复用性 - 组件可在多个页面中复用
  4. 更好的可测试性 - 独立组件更容易编写单元测试
  5. 更高的开发效率 - 团队成员可同时开发不同组件

标签:#Vue3 #组件化 #架构设计 #前端 #代码重构

点赞❤️ + 收藏⭐️ + 评论💬,你的支持是我创作的动力!

昨天 — 2026年4月17日首页

vue3 数据响应式遇到的问题

2026年4月17日 17:56

问题背景

是在vue2项目升级vue3项目中遇到的,因为升级项目并没有使用vue的Composition api 而是使用Options api,所有复杂类型变量默认使用reactive进行响应式,问题也是从这出现的

  1. 对象数组中,使用索引值更改数据,数据变化了页面没有变化
  • 类似代码 - options api使用的是this指针方式,但是问题是一样的
cosnt arr = reactive({
  arr1:[
    {
        "name": "test",
        "name2": "test2",
        "name3": "test3",
    },
    {
        "name": "test",
        "name2": "test2",
        "name3": "test3",
    }
]  
})
arr["arr1"][0] = {"name": "test11","name2": "test22","name3": "test33",}

这个时候我们从vue3的源代码入手,分析原因,具体只需要看proxy 的 get方法

源代码地址 packages\reactivity\src\baseHandlers.ts

有个BaseReactiveHandler方法

a48ac8538ee92c0bebe96aed437525ae.png

当我们触发get方法时,如果还是复杂类型,需要在调用reactive将其转化成响应式,所以Vue的依赖收集是"按需"的,具有一种懒惰性质,层级较深的复杂类型数据不是在声明式就被全部转化成响应式,而是在获取时逐层转化的

这个时候我们回看我们的问题,当我们执行arr["arr1"][0] = {"name": "test11","name2": "test22","name3": "test33",}的时候,只触发了arr["arr1"],但是再往下层级的并没有被转化成响应式,所以此时我们可以这样去解决

consr newArr = arr["arr1"]
newArr[0] = {"name": "test11","name2": "test22","name3": "test33",}
arr["arr1"] = newArr[0]

2. 解构失去响应式

    const test = reactive({
      arr: { name: '111'}
    })
    // 解构会失去响应性!
    const { arr } = test
    // 修改 user 不会触发界面更新
    arr.name = '李四'  //界面不更新

    //解决方案
    // 1、
    const { arr } = toRefs(test)
    // 2、尽量不要解构响应式数据

3. 新增属性不响应

    const test = reactive({
      arr: { name: '111'}
    })

    // 修改 user 不会触发界面更新
    test.arr.age= 22  //界面不更新

    // 解决方法 使用 Object.assign
    Object.assign(test.arr, { age: 22 }) 

总结

所以在vue3 开发中,如果使用options api方式,就需要尽量注意多层嵌套对象,如果使用Composition api,尽量使用 ref 去定义变量,并且针对嵌套层级较深的变量最好使用ShallowRef ShallowReactive 用于优化深层嵌套对象的性能问题

都知道AI大模型能生成文本内容,那你知道大模型是怎样生成文本的吗?

2026年4月17日 17:16

举个例子

想象一下,AI大模型用 0.5秒生成了这样一段文本:

"今天天气真好,我决定去公园散步,呼吸一下新鲜空气,放松身心。"

那么问题来了:这个AI怎样在这么短的时间内,"想出来"这条段文本的?

答案可能会颠覆你的想象。它并不是在"思考",而是在进行一个机械的、分步的数学运算。

今天,我就用你能理解的方式,把大模型这5个神奇的步骤拆解给你看。


第一步:你说什么,我就听什么

01 | 输入与拆分(Input & Tokenization)

场景还原:

假如你现在对大模型说:

"用最有趣的方式讲解一个笑话"

大模型听到这句话时,它不是像我们一样理解语义,而是做一个非常机械的动作:把你的文字拆成一个个最小的单元

这个最小单元叫做 Token(令牌)

Token 是什么?

你可以把它理解为"汉字"或"词"。比如:

  • "用" = 1个Token
  • "最" = 1个Token
  • "有趣" = 1个Token
  • "的" = 1个Token
  • "方式" = 1个Token
  • "讲解" = 1个Token

所以你的一句话被拆成了一串Token序列:

[用][最][有趣][的][方式][讲解][一][个][笑话]

为什么要这样拆?

因为大模型的"大脑"(神经网络)只能理解数字,不能直接理解文字。

每个Token都被转换成一串数字(向量),看起来像这样:

"用" → [0.2, -0.5, 0.8, 0.1, ...](几百个数字)
"最" → [0.3, -0.2, 0.5, 0.9, ...](几百个数字)
...

这就像把人类的语言翻译成计算机能理解的"密码"。


第二步:我是怎样理解你的意思的?

02 | 上下文编码(Context Encoding)

场景还原:

现在大模型有了一串数字(Token的向量表示),接下来它要做的是:理解这些数字之间的关系

这一步发生在大模型的"大脑"——Transformer架构中。

Transformer在做什么?

Transformer是一个特殊的神经网络结构,它做的事情听起来很复杂,但核心思想很简单:

它在计算你输入的每个词与其他词之间的"关系强度"。

具体例子:

如果你说:"小王很聪明,他喜欢编程,但他讨厌数学。"

Transformer会这样计算:

  • "他" 和 "小王" 的关系强度:95%("他"指代"小王")
  • "他" 和 "数学" 的关系强度:30%(有关系但不是指代)
  • "他" 和 "讨厌" 的关系强度:80%("他"是"讨厌"的主语)

通过这种"关系计算",Transformer把你的输入文本转化成一个包含丰富上下文信息的数学表示

换句话说,大模型现在"懂"你说的是什么意思了——不是真的懂,而是把你的意思转化成了数学。

一个有趣的观察:

这就是为什么大模型有时候能推断你没有明说的东西。因为Transformer计算了所有词之间的关系,它能从这些关系中"推断"出隐含的意思。


第三步:我给出每个词的概率

03 | 概率计算(Probability Calculation)

场景还原:

现在,大模型已经理解了你的意思(至少在数学层面)。

接下来,它要做一个关键的决定:下一个词应该是什么?

但大模型不是"想"出来的,而是通过计算所有可能词汇的概率

具体过程:

假如你说:"今天天气真好,我想……"

大模型此时会计算:在"我想"之后,所有词出现的概率。

结果可能是这样的:

[去散步]32%
[出门]28%
[休息]18%
[唱歌]8%
[吃饭]7%
[睡觉]4%
[其他]3%

这就像在说:"根据我读过的所有文本,当有人说'我想'的时候,接下来最有可能说'去散步'(32%的概率)。"

为什么是这个概率?

因为大模型在预训练时,接触过数万亿个词的组合。它把这些统计规律"记录"在自己的参数中。当你说"我想"时,它就从记忆中翻出:

  • 在所有"我想"的后面,"去散步"出现了多少次?
  • "出门"出现了多少次?
  • ...以此类推

重要的认识:

大模型这一步做的是统计,而不是思考。它问的不是"逻辑上下一个词应该是什么",而是"历史上这种情况下下一个词通常是什么"。

这解释了为什么大模型有时候会说出很聪明的话(因为统计规律确实反映了正确的知识),有时候也会说出荒唐的话(因为它只是在"赌概率")。


第四步:我选一个最可能的词

04 | 采样输出(Sampling Output)

场景还原:

现在大模型有了一个概率分布(如上面所示)。接下来的问题是:怎样选择?

有两种策略:

策略A:贪心采样(Greedy Sampling)

规则很简单:选概率最高的。

[去散步] → 32% ← 选这个!
[出门] → 28%
[休息] → 18%
...

这样做的好处是:结果最稳定,最有可能"正确"

坏处是:内容容易重复和千篇一律

如果你每次问大模型同一个问题,它会给你几乎完全相同的答案。

策略B:随机采样(Random Sampling)

按照概率,随机选择一个词。

就像转一个转盘,32%的区域里"去散步",28%的区域里"出门",然后随机转动指针。

这样做的好处是:结果多样化,每次回答都不一样

坏处是:有时候会选出一些"概率很低但突然出现"的词,导致内容有点奇怪

实际应用:

大多数大模型使用的是"温度"参数来控制这两个极端之间的平衡:

  • 温度=0:完全贪心采样(最稳定)
  • 温度=1:完全随机采样(最多样)
  • 温度=0.7:介于两者之间(大多数应用的默认值)

第五步:我把新词加到你的话里,然后重复

05 | 迭代生成(Iterative Generation)

场景还原:

现在大模型选了一个词——比如"去散步"。

接下来发生的事情很简单,但非常强大:

它把这个新词加到原文本的末尾,然后重复第2-4步。

让我展示整个过程:

1次循环:
输入:"今天天气真好,我想"
↓(经过第2-4步)
选择:"去"
结果:"今天天气真好,我想去"2次循环:
输入:"今天天气真好,我想去"
↓(经过第2-4步)
选择:"散"
结果:"今天天气真好,我想去散"3次循环:
输入:"今天天气真好,我想去散"
↓(经过第2-4步)
选择:"步"
结果:"今天天气真好,我想去散步"4次循环:
输入:"今天天气真好,我想去散步"
↓(经过第2-4步)
选择:","
结果:"今天天气真好,我想去散步,"5次循环:
输入:"今天天气真好,我想去散步,"
↓(经过第2-4步)
选择:"呼"
结果:"今天天气真好,我想去散步,呼"

...继续循环...

直到大模型选出了[结束标记],生成过程才停止。

最终结果:

"今天天气真好,我想去散步,呼吸一下新鲜空气,放松身心。"

完整的微博在你眼睛一眨眼的功夫就生成好了。


深层理解:为什么看起来这么聪明?

现在让我们回到最开始的问题:大模型为什么能生成这么连贯、这么"有意义"的文本?

答案其实很意外:它根本不是在思考,而是在进行一个机械但高度优化的数学运算。

具体来说:

1. 神奇的统计规律

大模型在训练时,接触过数万亿个词的组合。这创建了一个巨大的"统计记忆":

  • 在所有文本中,"今天天气很好"后面跟"去散步"的频率有多高?
  • "我想"后面通常跟什么词?
  • "放松身心"通常怎样结尾?

正是这些统计规律,使得大模型能生成"看起来很自然"的文本。

2. 参数的力量

大模型有数十亿甚至数万亿个参数(可调节的权重)。这些参数共同作用,把这些统计规律"压缩"存储在神经网络中。

所以当你输入一个问题时,大模型实际上是在:

  • 调用这些参数
  • 执行数学运算
  • 从概率分布中采样

3. 涌现能力

有趣的是,当参数数量足够多、训练数据足够大时,一些"意想不到"的能力会出现:

  • 模型能回答从未在训练数据中见过的问题
  • 模型能理解"含义"(虽然它实际上只是在做数学)
  • 模型能执行多步骤的逻辑推理

这些被称为"涌现能力"——大模型做的是统计,但统计足够复杂时,就呈现出了"智能"的样子。


这个过程有什么局限?

理解了这个5步过程后,你也就理解了为什么大模型有时候会:

1. 编造信息

因为它只是在"填概率",如果某个词的概率是正数,它就可能被选中——即使这个词在这个上下文里没有根据。

2. 处理数学计算很差

因为"1+1=2"这样的计算,根本不是概率问题。大模型没有专门的计算模块,只能靠概率去"猜"答案。

3. 知识过期

因为大模型的知识来自训练数据的统计。2024年的新闻事件,如果没有在训练数据中出现过,大模型就无从知晓。

4. 容易被欺骗

因为它只是在做模式匹配。如果你用巧妙的prompt,可以让它做出不该做的事情。


最后的思考

当你看到一条"聪明的AI回答"时,不妨停下来想一想:

这真的是AI在思考吗?还是它只是在用难以想象的复杂性来做统计?

答案是:两者都是。

从某种意义上,统计足够复杂,就变成了智能。正如人类的思维也是由神经元的电化学过程组成的,但我们说人在"思考"一样。

大模型的5步生成过程看似简单:

  1. 拆成Token
  2. 理解上下文
  3. 计算概率
  4. 选择词汇
  5. 重复迭代

但这个过程重复数百次、数千次,加上数万亿个参数的协同作用,就产生了让人惊叹的结果。

这也是为什么有人说:大模型是"大力出奇迹"——因为它真的就是靠着巨量的参数、巨量的数据、和巨量的计算,实现了这种表面看起来"智能"的行为。

下次当大模型给你一个答案时,你会想到它在背后经历的这5个步骤吗?


想了解如何开发设计图中的AI应用?右下角扫码了解

设计图(带二维码).png

6.png

vxe-table 自定义数字行主键,解决默认字符串主键与后端类型不匹配问题

作者 卤蛋fg6
2026年4月17日 17:14

vxe-table 自定义数字行主键,解决默认字符串主键与后端类型不匹配问题 在使用 vxe-table 表格组件时,组件默认自动生成的行主键为字符串类型,但后端接口通常要求主键为数值(number)类型,直接提交会因数据类型不匹配导致接口报错。 有两种最优解决方案,支持局部配置和全局统一配置,彻底解决类型不兼容问题。

核心解决方案

vxe-table 提供了灵活的主键配置能力,推荐两种实用方案:

  1. 指定业务字段为主键:直接使用后端返回的数字 ID 作为行主键(推荐已有数据场景)
  2. 自定义主键生成方法:自定义生成数字类型的自增主键(推荐新增行场景)

代码

定义行主键生成逻辑,生成规则可以通过 row-config.createKeyMethod 来自定义,也可以全局定义。

<template>
  <div>
    <!-- 新增行按钮 -->
    <vxe-button type="primary" @click="addEvent">新增数据</vxe-button>

    <!-- vxe-table 表格 -->
    <vxe-grid ref="gridRef" v-bind="gridOptions"></vxe-grid>
  </div>
</template>

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

// 表格行数据类型定义
interface TableRow {
  id: number; // 明确指定为数字类型主键
  name: string;
  role?: string;
  sex?: string;
  age?: number;
  address?: string;
}

// 表格实例引用
const gridRef = ref<InstanceType<typeof import('vxe-table')> | null>(null)

// 数字主键自增初始值(可根据业务调整)
let idSeed = 1000000000

// 表格配置项
const gridOptions = reactive({
  border: true,
  showOverflow: true,
  keepSource: true,
  height: 400,
  // 核心:自定义行主键配置
  rowConfig: {
    keyField: 'id', // 指定 id 字段作为行唯一主键
    // 自定义主键生成方法:返回数字类型,实现自增
    createKeyMethod: () => idSeed++
  },
  // 单元格编辑配置
  editConfig: {
    trigger: 'click',
    mode: 'cell',
    showStatus: true
  },
  // 表格列配置
  columns: [
    { type: 'seq', width: 70, title: '序号' },
    { field: 'name', title: '姓名', editRender: { name: 'input' } },
    { field: 'sex', title: '性别', editRender: { name: 'input' } },
    { field: 'age', title: '年龄', editRender: { name: 'input' } },
    { field: 'address', title: '地址', editRender: { name: 'input' } }
  ],
  // 初始化数据(id 均为数字类型)
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: '男', age: 28, address: 'test abc' },
    { id: 10002, name: 'Test2', role: 'Test', sex: '女', age: 22, address: '广州' },
    { id: 10003, name: 'Test3', role: 'PM', sex: '男', age: 32, address: '上海' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: '女', age: 24, address: '上海' }
  ]
})

// 新增行事件
const addEvent = async () => {
  const $grid = gridRef.value
  if (!$grid) return

  // 新增空数据,主键由自定义方法自动生成
  const newRecord = { name: `Name_${Date.now()}` }
  const { row: newRow } = await $grid.insert(newRecord)

  // 验证:主键为数字类型
  console.log('新增行主键类型:', typeof newRow.id, '主键值:', newRow.id)
  console.log('新增行数据:', newRow)

  // 自动聚焦编辑姓名单元格
  $grid.setEditCell(newRow, 'name')
}
</script>

image

关键配置说明

参数作用

rowConfig.keyField指定表格行的唯一主键字段(如 id),替代默认主键 rowConfig.createKeyMethod自定义主键生成函数,返回值即为最终主键

全局配置(推荐多页面复用)

// main.ts
import { VxeUI } from 'vxe-table'

let globalIdSeed = 1000000000

VxeUI.setConfig({
  table: {
    rowConfig: {
      keyField: 'id',
      createKeyMethod: () => globalIdSeed++
    }
  }
})

方案对比与选择

  • 指定业务字段为主键
    • 适用场景:表格数据由后端返回,自带数字 ID
    • 优点:无额外逻辑,直接复用后端 ID
    • 配置:仅需设置 rowConfig: { keyField: 'id' }
  • 自定义主键生成方法
    • 适用场景:前端新增临时数据、无后端 ID 场景
    • 优点:完全可控,强制生成数字类型,避免类型报错
    • 配置:keyField + createKeyMethod 组合使用

github文档: github.com/x-extends/v…
vxetable.cn

Vue 组件间通信

2026年4月17日 14:19

Vue 组件间通信

props 传递数据(父 → 子)

父组件将要传递的数据写入子组件的标签属性中,子组件通过 props 来获取父组件传递的数据。

<template>
  <Children name="jack" :age="18" />
</template>
<script>
export default {
  props: ['name', age],
};
</script>

$emit 触发自定义事件(子 → 父)

父组件将逻辑函数传递给子组件,子组件接受后触发逻辑函数返回给父子间信息。

<template>
  <Children @add="addUser" />
</template>
<script>
export default {
  // ...
  methods: {
    addUser(user) {
      // ...
    },
  },
};
</script>
<template>
  <button @click="addUser">添加用户</button>
</template>
<script>
export default {
  // ...
  methods: {
    addUser() {
      this.$emit('add', { name: 'wendy', age: 18 });
    },
  },
};
</script>

$refs 与 $children(子 → 父)

父组件可以通过 $refs$children 获取到子组件实例,通过实例直接获取子组件的数据信息。

可以通过多个 $refs$children 传递获取到各个后代的数据信息。

<template>
  <Children ref="children" />
</template>
<script>
export default {
  // ...
  methods: {
    getChildrenData(key) {
      // this.$children ...
      return this.$refs.children[key];
    },
  },
};
</script>

$parent 或 $root (父 → 子)

$refs类似 vue 组件可以访问this.$parent获取到直接父级实例,访问this.$root获取到根节点实例,并通过实例获取到组件信息。

由于可以直接拿到父级或根节点实例,那么也可以直接使用其实例的方法事件等,可以结合其他的传送方式达到同级传输信息的效果。但是这样通常会让项目信息传递变得混乱。

<script>
export default {
  // ...
  mounted() {
    // 在 mounted 后才可以访问当实例
    // this.$parent...
    // this.$root...
  },
};
</script>

$attrs 与 $listeners (父 → 子)

这里在讨论 vue3 中的$attrs$listeners。vue2 的$attrs$listeners可以转至 vue2 中的attrsattrs 与listeners

Vue3 已经不支持 $listeners 了。

在 vue 组件通过props传递数据时,子组件可能没有通过props接收,这部分没有被props接收的组件会保存在子组件的$attrs中(style 和 class 也会保存)

<template>
  <Children t0="t0" t1="t1" t2="t2" class="children" @click="clickCallback"></Children>
</template>
<script>
export default {
  // ...
props: ['t0']
  created() {
    console.log("Children", this.$attrs);
  },
};
</script>

输出如图:

alt text转存失败,建议直接上传图片文件

如果需要多层传递数据信息,可以使用v-bind批量绑定$attrs中的属性:

<template>
  <Children class="children" v-bind="$attrs"></Children>
</template>

provide 与 inject (父 → 孙)

vue2 provide-inject 介绍 / vue3 provide-inject 介绍

provideinject可以直接绕过目标组件之间的其他组件直接进行数据信息传递:

alt text转存失败,建议直接上传图片文件

vue2 中简单的使用:

<script>
export default {
  // ...
  provide: {
    name: 'Wendy',
    age: 18,
  },
};
</script>
<script>
export default {
  // ...
  // inject: ['name', 'age'], 使用数组接收
  inject: {
    userName: {
      from: 'name',
      default: 'noUser',
    },
    userAge: {
      from: 'age',
      default: 18,
    },
  },
};
</script>

EventBus 事件总线(任意 → 任意)

EventBus基于订阅/发布模式实现,相当于一个独立于各个组件的事件处理中心。每个组件都可以通过EventBus进行信息传递。

在创建好EventBus后,组件可以通过$on接收指定类型的信息,也可以通过$emit发送指定类型的信息。

// 通过 Vue 创建 EventBus
Vue.prototype.$bus = new Vue();

发送名为 eventName 的事件。

<script>
export default {
  // ...
  methods: {
    sendEvent(data) {
      this.$bus.$emit('eventName', data);
    },
  },
};
</script>

接收名为 eventName 的事件。

<script>
export default {
  // ...
  created() {
    this.$bus.$on('eventName', (data) => {
      // ...
    });
  },
};
</script>

vuex (任意 → 任意)

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式 + 库。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。

在 vue 组件中可以通过 watch 监听 vuex 数据的变化并作出相应改变:

<script>
export default {
  // ...
  watch: {
    '$store.state.count'(newVal, oldVal) {
      // ...
    },
    '$store.state.message'(newVal, oldVal) {
      // ...
    },
  },
};
</script>

参考

  1. 组件间通信的方案

Element Plus 主题构建方案

作者 一壶纱
2026年4月17日 11:33

Element Plus 主题构建步骤

第一步:安装依赖

项目里需要 sass 来编译 element-plus/theme-chalk 的 SCSS 源码。

pnpm add -D sass

第二步:新建主题构建插件文件

新建文件:

build/plugins/element-plus-theme.ts

最终代码如下:

import path from 'node:path'
import { promises as fs } from 'node:fs'

import { compileAsync } from 'sass'
import type { Plugin } from 'vite'

const OUTPUT_DIR = 'src/assets/generated'
const OUTPUT_FILE = 'element-plus-theme.css'
const TEMP_SCSS_FILE = 'element-plus-theme.scss'

const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']
const BASE_COMPONENTS = ['base']

const THEME_COLORS = {
  primary: '#215476',
  success: '#67c23a',
  warning: '#e6a23c',
  danger: '#f56c6c',
  error: '#f56c6c',
  info: '#909399',
}

const normalizePath = (targetPath: string) => targetPath.replace(/\\/g, '/')

const ensureRelativeImportPath = (targetPath: string) => {
  const normalizedPath = normalizePath(targetPath)

  if (normalizedPath.startsWith('./') || normalizedPath.startsWith('../')) {
    return normalizedPath
  }

  return `./${normalizedPath}`
}

const tagToComponentName = (tagName: string) => {
  return tagName.replace(/^el-/, '')
}

const scriptToComponentName = (componentName: string) => {
  return componentName
    .replace(/^El/, '')
    .replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)
    .replace(/^-/, '')
}

const createScssEntry = (themeChalkSrcImportPath: string, componentNames: string[]) => {
  const imports = componentNames
    .map(name => `@use "${themeChalkSrcImportPath}/${name}.scss" as *;`)
    .join('\n')

  return `@forward "${themeChalkSrcImportPath}/common/var.scss" with (
  $colors: (
    "primary": ("base": ${THEME_COLORS.primary}),
    "success": ("base": ${THEME_COLORS.success}),
    "warning": ("base": ${THEME_COLORS.warning}),
    "danger": ("base": ${THEME_COLORS.danger}),
    "error": ("base": ${THEME_COLORS.error}),
    "info": ("base": ${THEME_COLORS.info})
  )
);

${imports}
`
}

const scanUsedComponents = async (root: string) => {
  const srcDir = path.resolve(root, 'src')
  const usedComponents = new Set<string>([...BASE_COMPONENTS, ...SERVICE_COMPONENTS])

  const visit = async (targetPath: string): Promise<void> => {
    const stat = await fs.stat(targetPath)

    if (stat.isDirectory()) {
      const children = await fs.readdir(targetPath)
      await Promise.all(children.map(name => visit(path.join(targetPath, name))))
      return
    }

    if (!/\.(vue|ts|tsx|js|jsx)$/.test(targetPath)) {
      return
    }

    const source = await fs.readFile(targetPath, 'utf-8')
    const templateMatches = source.matchAll(/<\s*(el-[a-z0-9-]+)/g)
    const scriptMatches = source.matchAll(/\b(El[A-Z][A-Za-z]+)\b/g)

    for (const match of templateMatches) {
      usedComponents.add(tagToComponentName(match[1]))
    }

    for (const match of scriptMatches) {
      usedComponents.add(scriptToComponentName(match[1]))
    }
  }

  await visit(srcDir)
  return [...usedComponents].sort()
}

const buildThemeCss = async (root: string, command: 'serve' | 'build') => {
  const outputDir = path.resolve(root, OUTPUT_DIR)
  const outputPath = path.resolve(outputDir, OUTPUT_FILE)
  const tempScssPath = path.resolve(outputDir, TEMP_SCSS_FILE)
  const themeChalkSrcPath = path.resolve(root, 'node_modules/element-plus/theme-chalk/src')
  const themeChalkSrcImportPath = ensureRelativeImportPath(
    path.relative(outputDir, themeChalkSrcPath),
  )
  const componentNames = command === 'serve' ? ['index'] : await scanUsedComponents(root)
  const source = createScssEntry(themeChalkSrcImportPath, componentNames)

  await fs.mkdir(outputDir, { recursive: true })
  await fs.writeFile(tempScssPath, source, 'utf-8')

  const result = await compileAsync(tempScssPath, {
    loadPaths: [root],
    sourceMap: command === 'serve',
    style: command === 'serve' ? 'expanded' : 'compressed',
  })

  await fs.writeFile(outputPath, result.css, 'utf-8')
}

export const elementPlusThemePlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-plugin',
    apply: 'serve',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'serve')
    },
  }
}

export const elementPlusThemeBuildPlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-build-plugin',
    apply: 'build',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'build')
    },
  }
}

第三步:先看懂这几个常量

1. 输出目录

const OUTPUT_DIR = 'src/assets/generated'
const OUTPUT_FILE = 'element-plus-theme.css'
const TEMP_SCSS_FILE = 'element-plus-theme.scss'

最终会生成两个文件:

src/assets/generated/element-plus-theme.scss
src/assets/generated/element-plus-theme.css
  • scss 是临时入口文件
  • css 是项目真正引入的文件

2. 默认保留的组件

const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']
const BASE_COMPONENTS = ['base']

即使源码没扫到,也会保留这些样式。

原因:

  • base 是基础样式
  • messageloading 这类服务型组件容易漏

3. 主题色配置

const THEME_COLORS = {
  primary: '#215476',
  success: '#67c23a',
  warning: '#e6a23c',
  danger: '#f56c6c',
  error: '#f56c6c',
  info: '#909399',
}

后面改主题色,优先改这里。


第四步:处理 Sass 导入路径

看这两个函数:

const normalizePath = (targetPath: string) => targetPath.replace(/\\/g, '/')

const ensureRelativeImportPath = (targetPath: string) => {
  const normalizedPath = normalizePath(targetPath)

  if (normalizedPath.startsWith('./') || normalizedPath.startsWith('../')) {
    return normalizedPath
  }

  return `./${normalizedPath}`
}

这一步必须有。

因为 Sass 在 Windows 下处理路径时,如果路径不是:

./xxx
../xxx

就可能把它当成包名,然后报错:

Can't find stylesheet to import

所以这里做了两件事:

  1. 统一把 \ 转成 /
  2. 强制路径带上相对前缀

第五步:生成临时 SCSS 入口文件

核心函数:

const createScssEntry = (themeChalkSrcImportPath: string, componentNames: string[]) => {
  const imports = componentNames
    .map(name => `@use "${themeChalkSrcImportPath}/${name}.scss" as *;`)
    .join('\n')

  return `@forward "${themeChalkSrcImportPath}/common/var.scss" with (
  $colors: (
    "primary": ("base": ${THEME_COLORS.primary}),
    "success": ("base": ${THEME_COLORS.success}),
    "warning": ("base": ${THEME_COLORS.warning}),
    "danger": ("base": ${THEME_COLORS.danger}),
    "error": ("base": ${THEME_COLORS.error}),
    "info": ("base": ${THEME_COLORS.info})
  )
);

${imports}
`
}

这段代码做两件事:

1. 用 @forward 覆盖变量

最终会生成类似:

@forward "../../../node_modules/element-plus/theme-chalk/src/common/var.scss" with (
  $colors: (
    "primary": ("base": #215476),
    "success": ("base": #67c23a),
    "warning": ("base": #e6a23c),
    "danger": ("base": #f56c6c),
    "error": ("base": #f56c6c),
    "info": ("base": #909399)
  )
);

2. 用 @use 引入组件样式

开发环境会生成:

@use "../../../node_modules/element-plus/theme-chalk/src/index.scss" as *;

生产环境会生成:

@use "../../../node_modules/element-plus/theme-chalk/src/base.scss" as *;
@use "../../../node_modules/element-plus/theme-chalk/src/button.scss" as *;
@use "../../../node_modules/element-plus/theme-chalk/src/form.scss" as *;

所以这个函数的作用就是:

  • 先覆盖变量
  • 再拼接本次要构建的样式入口

第六步:扫描项目里实际使用到的 Element Plus 组件

核心函数:

const scanUsedComponents = async (root: string) => {
  const srcDir = path.resolve(root, 'src')
  const usedComponents = new Set<string>([...BASE_COMPONENTS, ...SERVICE_COMPONENTS])

  const visit = async (targetPath: string): Promise<void> => {
    const stat = await fs.stat(targetPath)

    if (stat.isDirectory()) {
      const children = await fs.readdir(targetPath)
      await Promise.all(children.map(name => visit(path.join(targetPath, name))))
      return
    }

    if (!/\.(vue|ts|tsx|js|jsx)$/.test(targetPath)) {
      return
    }

    const source = await fs.readFile(targetPath, 'utf-8')
    const templateMatches = source.matchAll(/<\s*(el-[a-z0-9-]+)/g)
    const scriptMatches = source.matchAll(/\b(El[A-Z][A-Za-z]+)\b/g)

    for (const match of templateMatches) {
      usedComponents.add(tagToComponentName(match[1]))
    }

    for (const match of scriptMatches) {
      usedComponents.add(scriptToComponentName(match[1]))
    }
  }

  await visit(srcDir)
  return [...usedComponents].sort()
}

它扫描哪些文件

  • .vue
  • .ts
  • .tsx
  • .js
  • .jsx

它怎么识别模板中的组件

靠这个正则:

/<\s*(el-[a-z0-9-]+)/g

比如:

<el-form />
<el-input />
<el-button />

会识别成:

  • form
  • input
  • button

它怎么识别服务型组件

靠这个正则:

/\b(El[A-Z][A-Za-z]+)\b/g

比如:

ElMessage.success('成功')
ElLoading.service(...)

会识别成:

  • message
  • loading

组件名为什么还要转换

因为模板和脚本中的名字,不是 theme-chalk 的文件名格式。

比如:

  • <el-form> 需要变成 form.scss
  • ElMessageBox 需要变成 message-box.scss

所以这里配了两个转换函数:

const tagToComponentName = (tagName: string) => {
  return tagName.replace(/^el-/, '')
}

const scriptToComponentName = (componentName: string) => {
  return componentName
    .replace(/^El/, '')
    .replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)
    .replace(/^-/, '')
}

第七步:真正执行主题构建

核心函数:

const buildThemeCss = async (root: string, command: 'serve' | 'build') => {
  const outputDir = path.resolve(root, OUTPUT_DIR)
  const outputPath = path.resolve(outputDir, OUTPUT_FILE)
  const tempScssPath = path.resolve(outputDir, TEMP_SCSS_FILE)
  const themeChalkSrcPath = path.resolve(root, 'node_modules/element-plus/theme-chalk/src')
  const themeChalkSrcImportPath = ensureRelativeImportPath(
    path.relative(outputDir, themeChalkSrcPath),
  )
  const componentNames = command === 'serve' ? ['index'] : await scanUsedComponents(root)
  const source = createScssEntry(themeChalkSrcImportPath, componentNames)

  await fs.mkdir(outputDir, { recursive: true })
  await fs.writeFile(tempScssPath, source, 'utf-8')

  const result = await compileAsync(tempScssPath, {
    loadPaths: [root],
    sourceMap: command === 'serve',
    style: command === 'serve' ? 'expanded' : 'compressed',
  })

  await fs.writeFile(outputPath, result.css, 'utf-8')
}

按执行顺序看:

1. 找到输出目录和输出文件

const outputDir = path.resolve(root, OUTPUT_DIR)
const outputPath = path.resolve(outputDir, OUTPUT_FILE)
const tempScssPath = path.resolve(outputDir, TEMP_SCSS_FILE)

2. 找到 theme-chalk/src

const themeChalkSrcPath = path.resolve(root, 'node_modules/element-plus/theme-chalk/src')

3. 转成 Sass 可识别的相对导入路径

const themeChalkSrcImportPath = ensureRelativeImportPath(
  path.relative(outputDir, themeChalkSrcPath),
)

4. 决定是全量还是按需

const componentNames = command === 'serve' ? ['index'] : await scanUsedComponents(root)
  • serve 时:直接用 index.scss
  • build 时:扫描后按需引入组件样式

5. 生成临时 SCSS 入口源码

const source = createScssEntry(themeChalkSrcImportPath, componentNames)

6. 写入临时文件

await fs.mkdir(outputDir, { recursive: true })
await fs.writeFile(tempScssPath, source, 'utf-8')

7. 调用 Sass 编译

const result = await compileAsync(tempScssPath, {
  loadPaths: [root],
  sourceMap: command === 'serve',
  style: command === 'serve' ? 'expanded' : 'compressed',
})

这里注意两个配置:

  • sourceMap: command === 'serve' 开发环境保留 sourceMap,方便调试。
  • style: command === 'serve' ? 'expanded' : 'compressed' 开发环境不压缩,生产环境压缩。

8. 输出最终 CSS

await fs.writeFile(outputPath, result.css, 'utf-8')

第八步:分别在开发和生产环境触发它

开发环境插件

export const elementPlusThemePlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-plugin',
    apply: 'serve',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'serve')
    },
  }
}

这段代码的意思是:

  • 只在 vite dev 时执行
  • 启动时构建一次全量主题

注意:

  • 不跟着业务源码热更新反复执行
  • 因为这里构建的是 Element Plus 主题,不是业务页面样式

生产环境插件

export const elementPlusThemeBuildPlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-build-plugin',
    apply: 'build',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'build')
    },
  }
}

这段代码的意思是:

  • 只在 vite build 时执行
  • 构建前按需扫描组件,再生成 CSS

第九步:在 Vite 中注册插件

文件: vite.config.ts

写法如下:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import vueJsx from '@vitejs/plugin-vue-jsx'
import UnoCSS from 'unocss/vite'
import { elementPlusThemeBuildPlugin, elementPlusThemePlugin } from './build/plugins/element-plus-theme'

export default defineConfig({
  resolve: {
    alias: {
      '@': '/src',
    },
  },
  plugins: [
    elementPlusThemePlugin(),
    elementPlusThemeBuildPlugin(),
    vue(),
    vueJsx(),
    UnoCSS(),
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/types/auto-imports.d.ts',
    }),
  ],
})

虽然这里写了两个插件,但不会同时执行:

  • pnpm dev 只执行 elementPlusThemePlugin
  • pnpm build 只执行 elementPlusThemeBuildPlugin

第十步:在应用入口引入生成后的 CSS

文件: main.ts

最终写法:

import { createApp } from 'vue'
import ElementPlus from 'element-plus'

import App from './App.vue'
import './assets/styles/ress.min.css'
import './assets/generated/element-plus-theme.css'
import 'virtual:uno.css'
import i18n from './i18n'

import router from './router'
import pinia from './store'

const app = createApp(App)

app.use(ElementPlus)
app.use(router)
app.use(pinia)
app.use(i18n)
app.mount('#app')

这里最关键的是:

import './assets/generated/element-plus-theme.css'

如果你之前有:

import 'element-plus/dist/index.css'

要把它删掉,不然就会和生成后的主题文件重复。


第十一步:怎么验证它是否生效

开发环境验证

执行:

pnpm dev

检查:

  1. 是否生成了文件:
src/assets/generated/element-plus-theme.scss
src/assets/generated/element-plus-theme.css
  1. 页面中的 Element Plus 组件是否使用了新的主题色。

生产环境验证

执行:

pnpm build

检查:

  1. 构建是否通过。
  2. 生成后的 element-plus-theme.css 是否存在。
  3. 打包后的页面中 Element Plus 样式是否正常。

第十二步:这套实现的边界

1. 动态拼接组件名,可能扫不到

比如:

const name = 'Button'
const component = `El${name}`

这种写法不一定能被正则准确识别。

2. 新的服务型组件如果没进白名单,可能漏样式

如果以后用了新的服务型组件,而当前正则又没扫到,就要把它补到:

const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']

里。

3. 主题变量扩展时,继续沿用 @forward ... with (...)

如果后面要覆盖的不只是颜色,比如圆角、边框、文字颜色,也继续在 createScssEntry 里往下扩。

你的 Vue 3 TS 类型声明,VuReact 会处理成什么样的 React?

作者 Ruihong
2026年4月17日 10:09

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:VuReact 如何自动分析 Vue 3 中的响应式依赖,精准生成 React Hooks 的依赖数组

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 和 React 的响应式与依赖追踪机制。

编译对照

Vue 自动依赖分析 → React Hook 依赖数组生成

VuReact 编译器内置了自动依赖分析能力,遵循 React 规则,智能分析顶层箭头函数顶层变量声明中的响应式访问,并生成准确的依赖数组。

  • Vue 代码:
<script setup lang="ts">
  import { reactive, ref } from 'vue';

  const count = ref(0);
  const foo = ref(0);
  const state = reactive({ foo: 'bar', bar: { c: 1 } });

  const fn1 = () => {
    count.value += state.bar.c;
    console.log(count.value);
  };

  const fn = () => {};

  const fn2 = () => {
    const c = foo.value;
    fn();

    const fn4 = () => {
      state.bar.c--;
      c + count.value;
    };
  };

  const fn3 = () => {
    foo.value++;

    const state = ref('fake');
    const count = state.value + 'yoxi';
    count.charAt(1);
  };
</script>
  • VuReact 编译后 React 代码:
const count = useVRef(0);
const foo = useVRef(0);
const state = useReactive({ foo: 'bar', bar: { c: 1 } });

const fn1 = useCallback(() => {
  count.value += state.bar.c;
  console.log(count.value);
}, [count.value, state.bar?.c]);

const fn = () => {};

const fn2 = useCallback(() => {
  const c = foo.value;
  fn();

  const fn4 = () => {
    state.bar.c--;
    c + count.value;
  };
}, [foo.value, state.bar?.c, count.value]);

const fn3 = useCallback(() => {
  foo.value++;

  const state = useVRef('fake');
  const count = state.value + 'yoxi';
  count.charAt(1);
}, [foo.value]);

这段对比展示了:

  • fn1 会被识别为顶层箭头函数并收集 count.valuestate.bar.c
  • fn2 会溯源 c 并忽略局部函数 fn4
  • fn3 会忽略函数内部新建的响应式变量,只收集外部依赖 foo.value

Vue 组合访问与别名追踪

VuReact 也会对复杂别名链和解构访问进行溯源。

  • Vue 代码:
<script setup lang="ts">
  const objRef = ref({ a: 1, b: { c: 1 } });
  const listRef = ref([1, 2, 3]);
  const aliasA = state.foo;
  const aliasB = aliasA;
  const aliasC = aliasB;
  const { foo: stateFoo } = state;
  const [first] = listRef.value;

  const traceFn = () => {
    aliasC;
  };

  const destructureFn = () => {
    stateFoo;
    first;
  };
</script>
  • VuReact 编译后 React 代码:
const objRef = useVRef({ a: 1, b: { c: 1 } });
const listRef = useVRef([1, 2, 3]);
const aliasA = useMemo(() => state.foo, [state.foo]);
const aliasB = useMemo(() => aliasA, [aliasA]);
const aliasC = useMemo(() => aliasB, [aliasB]);
const { foo: stateFoo } = useMemo(() => state, [state]);
const [first] = useMemo(() => listRef.value, [listRef.value]);

const traceFn = useCallback(() => {
  aliasC;
}, [aliasC]);

const destructureFn = useCallback(() => {
  stateFoo;
  first;
}, [stateFoo, first]);

这样可见:

  • alias 链会被逐层溯源到真实响应式来源;
  • 解构后的变量也会通过 useMemo 转换为可追踪依赖。

Vue 顶层变量声明 → React useMemo 依赖数组生成

  • Vue 代码:
<script setup lang="ts">
  const fooRef = ref(0);
  const reactiveState = reactive({ foo: 'bar', bar: { c: 1 } });

  const memoizedObj = {
    title: 'test',
    bar: fooRef.value,
    add: () => {
      reactiveState.bar.c++;
    },
  };

  let staticObj = {
    foo: 1,
    state: { bar: { c: 1 } },
  };

  const reactiveList = [fooRef.value, 1, 2];

  const mixedList = [
    { name: reactiveState.foo, age: fooRef.value },
    { name: 'A', age: 20 },
  ];

  const nestedObj = {
    a: {
      b: {
        c: reactiveList[0],
        d: () => {
          return memoizedObj.bar;
        },
      },
      e: mixedList,
    },
  };
</script>
  • VuReact 编译后 React 代码:
const memoizedObj = useMemo(
  () => ({
    title: 'test',
    bar: fooRef.value,
    add: () => {
      reactiveState.bar.c++;
    },
  }),
  [fooRef.value, reactiveState.bar?.c],
);

let staticObj = {
  foo: 1,
  state: {
    bar: {
      c: 1,
    },
  },
};

const reactiveList = useMemo(() => [fooRef.value, 1, 2], [fooRef.value]);

const mixedList = useMemo(
  () => [
    { name: reactiveState.foo, age: fooRef.value },
    { name: 'A', age: 20 },
  ],
  [reactiveState.foo, fooRef.value],
);

const nestedObj = useMemo(
  () => ({
    a: {
      b: {
        c: reactiveList[0],
        d: () => {
          return memoizedObj.bar;
        },
      },
      e: mixedList,
    },
  }),
  [reactiveList[0], memoizedObj.bar, mixedList],
);

这里的核心对比是:

  • memoizedObj 会收集对象内部的响应式字段与方法依赖;
  • staticObj 因为不含响应式访问,不会被优化为 useMemo
  • reactiveListmixedListnestedObj 会根据结构递归补齐依赖数组。

自动依赖分析的三大原则

  1. 仅分析顶层可优化表达式:局部函数、嵌套作用域不纳入顶层 Hook 自动优化;
  2. 遵循 React 依赖规则:只收集函数/变量外部的响应式访问,而非内部局部变量;
  3. 避免过度优化:无外部响应式依赖的顶层箭头函数和变量不会被强制转换为 Hook。

为什么这很关键?

在 React 中,函数组件每次渲染会重新创建顶层函数与变量。如果这些顶层表达式依赖响应式状态且未获得稳定性处理,会带来:

  • 不必要的子组件重新渲染;
  • 频繁的 Hook 重新计算;
  • 性能不可控的回调变化。

VuReact 在编译阶段自动生成准确依赖数组,既保留了 Vue 写法的简洁性,又实现了 React 端的性能优化。

相关资源


如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

5.响应式系统比对:手写 React 响应式状态库 Mobx

作者 Cobyte
2026年4月17日 08:47

前言

我们从前几篇文章中学到了数据响应式的实现原理,虽然它们的实现方式并不相同,但本质原理都是一样的,都是在数据读取的时候进行依赖收集,在数据更改的时候触发依赖。我们知道在 React 的技术栈中也有一个状态管理库 —— Mobx 也是通过数据响应式的方式实现的,那么既然也是数据响应式,那么它的实现本质原理应该都跟 Vue 是一致的,但我们不应该它的代码设计方式改变了,就看不懂了,而恰恰相反正因为我们熟悉 Vue 的数据响应式原理,所以我们 Vue 技术栈的同学应该更容易理解 Mobx 的实现原理才对,不然你不能说你精通了 Vue 的数据响应式原理。

Mobx 与 Vue 的响应式数据的差异

具体来说就是如果在 Vue 中你创建了一个引用类型的响应式数据,你可以直接修改它:

const vueProxy = reactive({ name: 'Cobyte' })
// 直接修改
vueProxy.name = '掘金签约作者'

在 Vue 这种操作是很正常的,但在 Mobx 中这种行为却是不提倡的。那么在 Mobx 中需要怎么修改呢?在 Mobx 中你需要定义一个函数来进行修改:

const mobxProxy = observable({ name: 'Cobyte', update(value) { this.name = value }})
// 通过函数进行修改
mobxProxy.update('掘金签约作者')

当然,在 Mobx 中你也可以像 Vue 那样操作数据,但 Mobx 并不提倡,所以既然你使用了 Mobx 那就就要遵循它的规则,并学习它的优秀设计原理,然后融化为你知识的一部分,在将来你设计代码架构的时候,你所学习到的知识将在无形中响应式着你。

Mobx 的设计原理

我们知道虽然 Vue2 和 Vue3 数据响应式部分的实现有所不同,但实现思路还是一致的。那么跟 Vue 相比同样是实现响应式数据的 Mobx 最大的区别是什么呢?那么要了解这个就去了解 Mobx 的设计原理了。Mobx 的最核心设计原则就是跟 React 的单向数据流设计一致,也同样是单向数据流。也正是基于这个原则导致 Mobx 的代码架构跟 Vue 的数据响应式部分差别比较大。当然 Vue 也是单向数据流设计,并且 Vue 官方也提倡单向数据流,但只是从 Vue 框架层限制了组件的 props 的第一层,而并没有从数据响应式的底层进行限制,而 Mobx 则是从数据响应式的底层就进行限制,所以 Mobx 的单向数据流更为彻底。

我们在 Vue 中创建了一个响应式数据,如果这个响应式数据是引用类型的话,你可以在组件及任何一个其后代组件任何一个角度去修改它,这种方式对于开发功能的人员来说是非常方便的,但对于维护人员来说很可能就是灾难,因为维护人员有时候需要监听数据的更新行为,可并不知道这个响应式数据都在什么地方进行更新。

而在 Mobx 中你创建了一个响应式数据,即便这个响应式数据也是引用类型,在 Mobx 中如果你直接对响应式数据进行修改的话,Mobx 会发出警告,因为在 Mobx 中你需要 React 那样通过一个函数来进行修改,这样就保证了单向数据流的使用规范。

默认情况下,不允许在 actions 之外改变 state。这有助于在代码中清楚地对状态更新发生的位置进行定位。

上述引用来自 Mobx 中文官网,那么怎么可以做到直接修改响应式数据的属性值就发出警告,而通过响应式数据的函数就不会呢?其实原理很简单,我们可以设置一个全局开关,当这个开关打开的时候,我们就可以进行修改,否则就提示警告。在 Mobx 中会对修改函数进行一层封装,变成成一个高阶函数,在执行修改函数之前会打开开关,这样再去修改就不会提示警告了。

Mobx 的初步实现(observable 实现)

我们知道 Mobx 是参考了 Vue 的数据响应式原理,那么最初肯定是只有参考 Vue2 了,那么根据 Vue2 的数据响应式原理,我们很清楚知道一个对象要观察它的数据变化,需要通过 Object.defineProperty 劫持每一个属性的 gettersetter 的操作,同时属性值需要通过闭包进行缓存,还需要通过发布订阅模式来实现依赖(订阅者)和响应式数据之间的通信,具体就是在 getter 的时候进行订阅,在 setter 的时候进行发布,那么在 Vue2 数据响应式中每一个属性所形成的闭包就是一个发布者。那么在 Mobx 的属性值是否也需要通过闭包进行缓存呢?

在 Vue2 中需要一个 Observer 的观察器类来管理响应式数据的相关操作,在 Mobx 中同样需要一个观察器类来管理响应式数据的相关操作,它就是 ObservableObjectAdministration。那么根据我们前面的所学的经验可以很快得到 ObservableObjectAdministration 类的基础代码。如下:

// 对象观察器类
class ObservableObjectAdministration{
    constructor(target) {
        // 原始值保存
        this.target_ = target
        // 订阅者存储中心
        this.values_ = new Map()
    }
}

根据 Vue2 的数据响应式原理我们知道需要通过一个 observe 的函数创建响应式数据,在 Mobx 中也提供了一个叫 observable API 来创建响应式数据。那么根据 Vue2 我们知道需要实例化一个观察器对象,并且把观察器实例对象设置到需要观察的数据上,这样该数据就是响应式数据了。

function observable(target) {
    const adm = new ObservableObjectAdministration(target)
    // 把观察器实例对象设置到需要观察的数据上
    target.__ob__ = adm
    return target
}

在 Vue2 中是在观察器内部进行初始化对被观察数据进行遍历其属性通过 Object.defineProperty 劫持每一个属性的 gettersetter 操作。同样 Mobx 也需要这样,但 Mobx 的设计是在外部进行遍历属性,而不是在观察器内部进行遍历。

function observable(target) {
    const adm = new ObservableObjectAdministration(target)
    // 把观察器实例对象设置到需要观察的数据上
    target.__ob__ = adm
+    Object.keys(target).forEach(key => {
+        // 在这里通过 Object.defineProperty 劫持每一个属性的 `getter`、`setter` 操作
+        adm.defineObservableProperty_(key, target[key])
+    })
    return target
}

// 对象观察器
class ObservableObjectAdministration{
    constructor(target) {
        // 原始值保存
        this.target_ = target
        // 订阅者存储中心
        this.values_ = new Map()
    }
+    // 劫持属性的 getter、setter
+    defineObservableProperty_(key, value) {
+        Object.defineProperty(this.target_, key, {
+            get: () => {
+                // 获取值
+            },
+            set: (val) => {
+                // 设置值
+            }
+        })
+    }
}

在 Vue2 中循环劫持响应式对象的属性时是通过闭包的方式的,即每一个属性都会形成自己的一个闭包,最后读取和设置的值都是闭包中的变量值。而 Mobx 中则把每一个属性的值都包装成一个对象,本质上是通过沙箱模式将每个属性值进行隔离。

那么下面就让我们来实现 Mobx 中的属性劫持吧。

// 对象观察器
class ObservableObjectAdministration{
    constructor(target) {
        // 原始值保存
        this.target_ = target
        // 订阅者存储中心
        this.values_ = new Map()
    }
    // 劫持属性的 getter、setter
    defineObservableProperty_(key, value) {
+        // 将属性值包装成响应式对象
+        const observable = new ObservableValue(value)
        // 将每一个属性和属性值进行记录起来
+        this.values_.set(key, observable)
        Object.defineProperty(this.target_, key, {
            get: () => {
                // 获取值
+                return this.values_.get(key).get()
            },
            set: (val) => {
                // 设置值
+                this.values_.get(key).setNewVal(val)
            }
        })
    }
}
+ // 将属性值包装成响应式对象
+ class ObservableValue {
+    constructor(value) {
+        this.value_ = value
+    }

+    get() {
+        // 在这里进行依赖收集
+        console.log('依赖收集')
+        return this.value_
+    }

+    setNewVal(val) {
+        this.value_ = val
+        // 在这里进行依赖触发
+        console.log('依赖触发')
+    }
+ }

通过上面的代码我们可以看到 Mobx 在通过 Object.defineProperty 劫持对象属性的时候会把属性值通过一个对象进行包裹,也就是 ObservableValue 的实例对象 observable,并且通过键值对的方式保存在 ObservableObjectAdministrationthis.values_ 上,然后在 getter 的时候实际获取的是对应 keyobservable 对象中的值。那么很容易看出来每一个 ObservableValue 的实例对象 observable 都是一个发布者,或者叫被观察者更为贴切一些,反正是一个被观察的对象。

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

// 创建响应式对象
const mobxProxy = observable({ name: 'Cobyte' })
// 读取触发依赖收集
mobxProxy.name
// 设置值触发依赖
mobxProxy.name = '我是掘金签约作者'

打印结果如下:

A01.png

小结

在前面的讲解 Vue2 的数据响应式原理的文章中,我们说其实每一个属性所形成的闭包就是一个发布者,可能大家还有点难以理解,那么在 Mobx 中每一个属性都通过一个沙箱对象进行包裹,那么这个沙箱对象就是一个发布者,而且代码结构和所谓传统发布订阅模式的代码结构也是比较相似。

在 Mobx 中实现发布订阅模式

那么根据上文我们知道 ObservableValue 是一个发布者,那么我们根据前面的所学的知识,可以很容易完善发布订阅模式的功能。代码如下:

+ // 全局属性
+ const globalState = {
+     trackingDerivation: null // Mobx 中的订阅者全局变量
+ }
 // 将属性值包装成响应式对象
class ObservableValue {
    constructor(value) {
        this.value_ = value,
+        // 订阅者存储中心
+        this.observers_ = new Set()
    }

    get() {
        // 在这里进行依赖收集
+        if (globalState.trackingDerivation) {
+            this.observers_.add(globalState.trackingDerivation)
+        }
        return this.value_
    }

    setNewVal(val) {
        this.value_ = val
        // 在这里进行依赖触发
+        this.observers_.forEach(derivation => derivation())
    }
}

我们经过上面的功能完善,我们从代码结构可以看得出 ObservableValue 是一个发布者。那么接下来我们就可以进行最简单的功能测试了。测试代码如下:

const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}

globalState.trackingDerivation = subscriber
subscriber()
globalState.trackingDerivation = null

// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

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

A02.png

实现订阅者中介 Reaction

接下来我们继续完善订阅者功能,根据我们前面所学习的知识,我们知道需要一个订阅者中介类,在 Mobx 中同样存在一个订阅者中介类,也就是 Reaction,那么根据 Vue2 的 Watcher 功能我们很快可以实现如下代码:

class Reaction {
    constructor(fn) {
        this._fn = fn
        this.get()
    }
    get() {
        globalState.trackingDerivation = this
        this._fn()
        globalState.trackingDerivation = null
    }
    update() {
        this._fn()
    }
}

因为订阅者的功能修改了,所以同时需要修改一下 ObservableValue 类:

// 将属性值包装成响应式对象
class ObservableValue {
    // 省略...
    setNewVal(val) {
        this.value_ = val
        // 在这里进行依赖触发
-        this.observers_.forEach(derivation => derivation())
+        this.observers_.forEach(derivation => derivation.update())
    }
}

接着我们进行测试:

const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}

new Reaction(subscriber)

// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

我们可以看到也是正确打印了我们期待的结果:

A02.png

但上述 Reaction 的实现是根据 Vue2 的 Watcher 类实现的,实现的特点是在初始化的时候进行传进来的副作用函数,并且进行依赖收集,在更新的时候则不再进行依赖收集。而 Mobx 中的实现并不是这样的,但基本原理是一致的,就是在初始化的时候进行依赖收集,更新的时候则不再进行依赖收集,所以我们根据 Mobx 中的实现重新改造一下 Reaction 类。

Reaction 改造如下:

class Reaction {
    constructor(onInvalidate) {
        this.onInvalidate_ = onInvalidate
    }

    track(fn) {
        globalState.trackingDerivation = this
        fn()
        globalState.trackingDerivation = null
    }
    // 更新的时候执行
    schedule_() {
        this.onInvalidate_()
    }
}

我们可以看到在 Reaction 初始化的时候会传进来一个回调函数,这个回调函数会在更新的时候进行,而依赖收集则在 track 函数中进行,看函数名都可以顾名思义了。

实现 autorun 函数

因为 Reaction 更新执行的函数变了,所以我们也需要修改 ObservableValue 类相关功能:

// 将属性值包装成响应式对象
class ObservableValue {
    // 省略...
    setNewVal(val) {
        this.value_ = val
        // 在这里进行依赖触发
-        this.observers_.forEach(derivation => derivation.update())
+        this.observers_.forEach(derivation => derivation.schedule_())
    }
}

那么接下来需要重新修改测试代码:

const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}
// 实例化订阅者中介
const reaction = new Reaction(
    () => {
        // 回调函数中执行依赖收集函数
        reaction.track(subscriber)
    }
)
// 立即执行
reaction.schedule_()
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

重新执行也同样打印了正确的结果:

A02.png

我们可以看到要像之前那样实现自动执行订阅者函数,需要在实例化 Reaction 的时候设置回调函数 onInvalidate,然后把依赖收集函数的执行放到 onInvalidate 函数中,然后需要开始的时候就立即执行更新方法。这部分相对 Vue2 的 Watcher 类的实现就没有那么容易理解,这主要是因为 Mobx 主要是服务于 React,受 React 的特点影响,所以才这么设计。在后续我们再详细讲解为什么这么设计。

其实上述对订阅者的实现方法,就是 Mobx 的 autorun API 的实现原理。我们将其进行封装实现。

function autorun(view) {
    // 实例化订阅者中介
    const reaction = new Reaction(
        () => {
            // 回调函数中执行依赖收集函数
            reaction.track(view)
        }
    )
    // 立即执行
    reaction.schedule_()  
}

然后我们的测试代码就可以修改成:

const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}
autoruo(subscriber)
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

修改之后,同样打印了正确的结果:

A02.png

实现使用 actions 更新 state

通过上文对 Mobx 的设计原理的讲解,我们知道为了帮助开发人员清楚地知道状态修改的位置,默认情况下,Mobx 不允许在 actions 之外改变状态。

Mobx 使用单向数据流,利用 action 改变 state ,进而更新所有受影响的 view

上述引用来自 Mobx 中文官网,所谓 action 其实就是一个函数,例如下面的例子:

const mobxProxy = observable({ 
    name: 'Cobyte',
    update(value) { 
        this.name = value 
    }
})
// 通过函数进行修改
mobxProxy.update('掘金签约作者')

通过上文我们知道它的基本原理就是设置一个全局开关,当这个开关打开的时候,我们就可以进行修改,否则就提示警告。其实对修改函数会进行一层封装,变成成一个高阶函数,在执行修改函数之前会打开开关,这样再去修改就不会提示警告了。

我们知道每一个属性值都被封装成了一个 observable 对象,那么我们就可以在 ObservableValue 类中对包装的值进行处理,如果是函数的话,就封装成一个高阶函数(高阶函数(higher-order function)—— 如果一个函数接收的参数为或返回的值为函数,那么我们可以将这个函数称为高阶函数)。

首先我们添加一个全局开关变量:

const globalState = {
    trackingDerivation: null,
+    // 是否允许修改状态的开关
+    allowStateChanges: false
}

那么我们就可以在 ObservableValue 类中对包装的值进行处理:

class ObservableValue {
    constructor(value) {
+        let action
+        // 如果是函数则封装 action 高阶函数
+        if (typeof value === 'function') {
+            action = function(...agrs) {
+                // 在执行原始函数之前开启允许修改开关
+                globalState.allowStateChanges = true
+                // 通过 apply 执行原始函数
+                value.apply(this, agrs)
+                // 执行完原始函数后又关闭开关
+                globalState.allowStateChanges = false
+            }
+        }
-        this.value_ = value
+        // 判断如果是函数则使用封装的 action 高阶函数
+        this.value_ = typeof value === 'function' ? action : value,
        this.observers_ = new Set()
    }
}

接着我们就可以设置值的时候进行判断了:

class ObservableValue {
    setNewVal(val) {
+       if (!globalState.allowStateChanges) {
+          console.warn('Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed')
+       }
        this.value_ = val
        // 在这里进行依赖触发
        this.observers_.forEach(derivation => derivation.schedule_())
    }
}

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

const mobxProxy = observable({ 
    name: 'Cobyte',
    update(val) {
        this.name = val
    }
})
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}
autorun(subscriber)
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

A03.png 这个时候我们就可以看到直接通过属性进行修改值会发出警告了,然后我们再通过函数修改,则不会了。

mobxProxy.update('掘金签约作者')

通过函数修改则不会发出警告了。

接下来,我们再对我们的代码进行重构一下,让代码结构更接近 Mobx 源码。

+ function createAction(fn) {
+     // 这里有一个需要注意的点,返回函数需要使用 function 进行声明会比较方便获取原生对象的上下文,这里涉及到 this 的问题
+     function res() {
+         // 最后通过 executeAction 执行
+         return executeAction(fn, this, arguments)
+     }
+     return res
+ }

+ function executeAction(fn, scope, args) {
+     // 在执行原始函数之前开启允许修改开关
+     globalState.allowStateChanges = true
+     // 因为是用户写的函数,可能会存在错误,所以使用 try
+     try {
+         // 通过 apply 执行原始函数
+         return fn.apply(scope, args)
+    } catch (err) {
+         throw err
+     } finally {
+         // 执行完原始函数后又关闭开关
+         globalState.allowStateChanges = false
+     }
+ }

+ function deepEnhancer(value) {
+     // 如果是函数则封装 action 高阶函数
+     if (typeof value === 'function') {
+         return createAction(value) 
+     }

+     // todo

+     // 如果是 observable 对象就返回,不处理
+     // 如果是对象进行递归处理
+     // 如果是数组也进行数组的递归处理

+     return value
+ }

class ObservableValue {
    constructor(value) {
+        // 通过 deepEnhancer 处理 value 值
+        this.value_ = deepEnhancer(value)
        this.observers_ = new Set()
    }

    setNewVal(val) {
+        // 设置值之前进行判断是否允许修改
+        checkIfStateModificationsAreAllowed(this)
        this.value_ = val
        // 在这里进行依赖触发
        this.observers_.forEach(derivation => derivation.schedule_())
    }
}

+ function checkIfStateModificationsAreAllowed(atom) {
+    if (!globalState.allowStateChanges) {
+        console.warn('Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed')
+    }
+ }

经过上面的重构我们的代码结构就更接近 Mobx 源码了,所以重构是我们日常编程中非常重要的组成部分。

实现 makeAutoObservable

我们知道 Redux 是函数式编程的推崇者,API 的设计对喜欢函数式编程的开发者非常友好,而 Mobx 的设计则更多偏向于面向对象编程(OOP),在 Mobx 中 class 是一等公民,这对喜欢 OOP 思想的开发者则非常友好。甚至于在 Mobx 的官网给出的实例代都是 OOP 实现的。

Mobx 官网 OOP 例子:

import { makeAutoObservable } from "mobx"

class Timer {
    secondsPassed = 0
    constructor() {
        makeAutoObservable(this)
    }
    increase() {
        this.secondsPassed += 1
    }
    reset() {
        this.secondsPassed = 0
    }
}

const myTimer = new Timer()

我们通过上面的例子可以看到 class 对象的响应式是通过 makeAutoObservable 这个 API 实现的,我们有了上述实现的 Mobx 基本原理的代码基础,再去实现 makeAutoObservable API 是很容易的。

在实现之前,我们需要对 ES class 的基础知识复习一下,class 中的属性在实例化是在实例化对象上的,而 class 的方法则是在原型上的,也就是说上述例子的实现等同于下面的实现:

class Timer {
    secondsPassed = 0
    constructor() {
        makeAutoObservable(this)
    }
}
Timer.prototype.increase = function() {
    this.secondsPassed += 1
}
Timer.prototype.reset = function(){
    this.secondsPassed = 0
}

这些是属于 JavaScript 的面向对象与继承部分的基础知识,这里不作过度深入说明。

通过上文我们知道在 Mobx 中实现数据响应式跟 Vue2 中的基本原理是一样的,也就是遍历要实现响应式的对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter。但 通过 class 实例化的对象除了要获取自身的属性之外,还要获取原型对象上的属性,因为 class 中的方法是设置在原型上的。那么理解了这些之后我们就可以实现 makeAutoObservable API 了。

接下来我们实现一下:

function makeAutoObservable(target) {
    const adm = new ObservableObjectAdministration(target)
    target.__ob__ = adm
    // 获取实例的原型对象
    const proto = Object.getPrototypeOf(target)
    // 同时获取实例对象上的 key 和 原型对象上的 key,才能完整获取 class 中的属性和方法,同时通过 Set 进行去重
    const keys = new Set([...Reflect.ownKeys(target), ...Reflect.ownKeys(proto)])
    // 删除不需要监听的属性
    keys.delete("constructor")
    keys.delete('__ob__')
    // 遍历所有属性进行监听
    keys.forEach(key => {
        adm.defineObservableProperty_(key, target[key])
    })
    return target
}

我们可以看到有了之前实现 Mobx 的基础,再实现 makeAutoObservable 是非常容易的。相比较上面实现的 observablemakeAutoObservable 的实现最大的不同就是属性的获取,因为 makeAutoObservable 是应用在 class 类上的,所以除了获取对象自身上的属性之外,还要获取原型对象上的属性才能完整获取 class 中的属性和方法,同时还需要对所获取的属性和方法进行去重,最后去掉不需要监听的属性。

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

class Timer {
    secondsPassed = 0
    constructor() {
        makeAutoObservable(this)
    }
    increase() {
        this.secondsPassed += 1
    }
    reset() {
        this.secondsPassed = 0
    }
}

const myTimer = new Timer()     
// 设置订阅者
const subscriber = function() {
    console.log(`现在的秒数:${myTimer.secondsPassed}`)
}

autorun(subscriber)

// 每秒更新一次
setInterval(() => {
    myTimer.increase()
}, 1000)

打印结果如下:

A04.png

可以看到我们实现的 makeAutoObservable 方法可以正确应用在 class 上了。

将手写的 Mobx 应用到 React 上

这小结对 React 不太熟悉的同学也没关太大关系,跟着敲就可以了。首先我们通过 create-react-app 这个脚手架快速创建一个 React 项目。

npx create-react-app react-app

我们把上面实现的 Mobx 功能内容设置到 ./src/mini-mobx.js 中,并且把使用到的函数进行导出。

接着我们把 App.js 文件的内容修改如下:

import { makeAutoObservable, observer } from "./mini-mobx"

class Timer {
  secondsPassed = 0

  constructor() {
    makeAutoObservable(this)
  }

  increaseTimer() {
    this.secondsPassed += 1
  }
}

const myTimer = new Timer()

const TimerView = observer(({ timer }) => <span>Seconds passed: {timer.secondsPassed}</span>)

function App() {
  return (
    <TimerView timer={myTimer}></TimerView>
  );
}

setInterval(() => {
    myTimer.increaseTimer()
}, 1000)

export default App;

我们看到上述的例子其实就是 Mobx 官网的例子,我们把 Mobx 官网的例子跑起来,就说明我们手写的 Mobx 功能是成功的了。上述例子中,我们还需要实现一个函数 observer,我们可以参考上面实现过的 autorun 函数。

我们可以看到 observer 接受的是一个函数组件,返回的也是一个函数组件,那么这就是一个典型的高阶组件,所谓高阶组件,也就是高阶函数,因为函数组件本质就是一个函数。那么我们根据这些特点,我们很容易就构造出 observer 函数基础架构。代码如下:

export function observer(baseComponent) {
    return (props) => {
        return baseComponent(props)
    }
}

页面正常渲染出来了,但还不能自动更新。

A05.png

从发布订阅的角度来说在 React 应用 Mobx 后,所写的函数组件就是一个订阅者,那么根据我们上面实现的 autorun 函数,我们先要实例化一个 Reaction 对象,而不管在 Vue 中还是 React 中函数组件在更新的时候,都是重新执行整个函数组件的,所以我们实例化的 Reaction 对象需要保存起来,那么在 React 里面有提供了一个 useRef 的 Hook,它可以创建一个 mutable ref 对象,在组件的整个生命周期内该对象保持不变。简单来说就是 useRef 可以创建一个可以保存状态的 Hook,即使组件重新渲染,其内部的值也不会变化。

import { useRef } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
    return (props) => {
        const admRef = useRef(null)
        if (!admRef.current) {
            // 实例化订阅者中介
            const reaction = new Reaction(
                () => {
                    // 回调函数中执行依赖收集函数
                    reaction.track(baseComponent)
                }
            )
            admRef.current = reaction
        }
        const reaction = admRef.current
        // 立即执行
        reaction.schedule_()
    }
}

页面显示如下:

A06.png

我们根据 autorun 的实现原理初步实现了上述功能,但报错了,原因是组件的 props 没有传进去,所以我们进行以下修改:

import { useRef } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
    return (props) => {
        const admRef = useRef(null)
        if (!admRef.current) {
            // 实例化订阅者中介
            const reaction = new Reaction(
                () => {
                    // 回调函数中执行依赖收集函数
+                    reaction.track(() => {
+                        baseComponent(props)
+                    })
                }
            )
            admRef.current = reaction
        }
        const reaction = admRef.current
        // 立即执行
        reaction.schedule_()
    }
}

页面显示如下:

A07.png

我们发现不报错了,但页面并没有渲染,没渲染的原因是我们并没有把函数组件执行的内容返回,所以我们继续进行以下修改:

import { useRef } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
    return (props) => {
+        let renderResult
        const admRef = useRef(null)
        if (!admRef.current) {
            // 实例化订阅者中介
            const reaction = new Reaction(
                () => {
                    // 回调函数中执行依赖收集函数
                    reaction.track(() => {
+                      renderResult = baseComponent(props)
                    })
                }
            )
            admRef.current = reaction
        }
        const reaction = admRef.current
        // 立即执行
        reaction.schedule_()
+        return renderResult
    }
}

修改后页面显示如下:

A08.png

经过上面修改,我们的页面可以渲染出来了,但又遇到新的问题了,页面并没有更新。按理来说,我们上面的 observer 是已经根据 autorun 的实现方式进行实现了。我们可以在 Reaction 的回调函数中进行打印,

export function observer(baseComponent) {
    return (props) => {

        if (!admRef.current) {
            const reaction = new Reaction(
                () => {
                    // 回调函数中执行依赖收集函数
                    reaction.track(() => {
                      renderResult = baseComponent(props)
+                      console.log('renderResult', renderResult)
                    })
                }
            )
            admRef.current = reaction
        }
+        console.log('outer')
    }
}

打印显示如下:

A09.png

我们发现其实我们的 Reaction 的回调函数已经重新执行了,但整个组件函数并没有重新执行,所以并没重新渲染内容。所以我们现在只要考虑把整个组件实现重新渲染就可以了。那么熟悉 React 的同学可能会知道在 React 函数组件中可以通过 useState 改变 state 值来触发组件的重新渲染。这个也是 Vue 和 React 区别非常大的一个地方。那么我们可以在 Reaction 的回调函数中执行更新函数,把依赖收集的相关代码放到外面执行。

代码修改如下:

import { useRef, useState } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
    return (props) => {
+        const [, setState] = useState()
        let renderResult
        const admRef = useRef(null)
        if (!admRef.current) {
            // 实例化订阅者中介
            const reaction = new Reaction(
                () => {
+                    // 执行更新
+                    setState(Symbol())
               }
            )
            admRef.current = reaction
        }
        const reaction = admRef.current
+        // 执行依赖收集函数
+        reaction.track(() => {
+          renderResult = baseComponent(props)
+        })
 
        return renderResult
    }
}

页面渲染如下:

01.gif

我们重新修改后,可以正常如期执行了。至此我们手写的 Mobx 也实现了在真实 React 环境中执行了。

总结

本文通过相对比较简洁的代码实现了 Mobx 的核心原理,同时对比了同时响应式的 Vue 和 Mobx 的最大设计区别,在 Vue 中创建的响应式数据,是可以随意在任何地方通过普通属性访问器进行修改的,但 Mobx 中则不提倡这种可以随意修改 state 的方式,在 Mobx 中希望开发者通过 actions 来改变 state,本质是像 React 那样通过一个函数来修改 state,或者说是遵循 Flux 和 Redux 的单向数据流思想。同时 Mobx 中的订阅者中介 Reaction 和 Vue 中的订阅者中介实现则有比较大的区别,主要是因为 Mobx 主要的设计受 React 的影响,在更新的时候需要特别的设置,而不像 Vue 那样直接重新运行副作用函数就可以了,这个说到底也是因为 React 不是靠依赖追踪来实现响应式的缘故。

那么具体 Mobx 的 Reaction 的要这样设计,而不能像 Vue 那样简洁呢,我们下一篇文章中继续探讨。

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

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

🔧 Rattail | 面向 Vite+ 和 AI Agent 的前端工具链

作者 耗子君QAQ
2026年4月17日 00:17

写在前面

掘金的同学们大家好呀,作者是 Varlet UI 的作者。掘金文章已经一年没更新了,去年跳槽到了一家创业公司负责前端架构工作,写文章这件事就一直搁置了。最近稍微缓过来了一点点(其实还是压力很大...),但不妨碍今天来给大家分享一下我们最新的开源项目 rattail

先聊聊 Vite Plus

上个月 VoidZero 正式以 MIT 协议开源了 Vite+,它把 ViteVitestOxlintOxfmtRolldowntsdown 统一收拢到了一个 vp 命令下面,一套工具链覆盖 devbuildtestlintfmtpack 等所有工程化环节。作者第一时间就把 varlet 周边的项目迁移到了 Vite+ 上面试试水,迁移下来发现效果特别好。以前那些散落在各处的 eslint 配置、prettier 配置、lint-staged 配置、commitlint 配置可以统一收拢到一个 vite.config.ts 里面,项目根目录一下子干净了不少(以前打开根目录看到十几个 .xxxrc 文件的日子终于结束了)。而且因为工具链统一了,AI Agent 在理解项目配置的时候幻觉也少了很多。

公司项目迁移

既然体验这么好,作者就决定把公司内部的前端项目也都迁移到 Vite+ 上。迁移的过程中也让作者重新审视了一下 rattail,我们在 varlet 生态里积累了大量的工具函数、请求库、校验规则工厂、CLI 工具链,之前一直是分散在各个包里的,正好借这次机会做一次大整合,于是就有了 rattail 2.0——一个面向 Vite+、对 AI Agent 非常友好的前端工具链。140+ 工具函数、渐进式请求库、链式校验规则工厂、CLI 工具链、类型安全枚举,pnpm add rattail 一条命令全部拉齐。

目前作者也在公司项目中全面使用了 Vite+ + rattail 这套技术栈,体验下来非常舒服。另外值得一提的是,这次 rattail 2.0 的迁移和开发过程中,作者大量使用了 AI 辅助编程,包括工具函数的编写、单元测试的补全、文档的生成等等,效率提升非常明显。配合 rattail 提供的 Agent Skills,AI Agent 能够很好的理解项目上下文并正确使用 rattail 的 API,整个工作流跑下来还是相当丝滑的。后续在业务开发中也明显感觉到,因为 rattail 把工具函数、请求库、校验规则这些东西 all in one 了,AI 在生成代码的时候幻觉变得特别少,而且很会按照规范做事。

相关链接

特性一览

  • ⚙️ 面向 Vite+ 的开箱即用配置预设
  • 🔧 CLI 工具链,支持发布、日志、Git Hooks、Commit Lint、API 生成
  • 🧰 140+ 工具函数,覆盖通用、字符串、数字、数组、对象、数学等场景
  • 🚀 基于 axios 的渐进式请求工具,支持 Vue 组合式 API
  • 📏 链式校验规则工厂,适配任意 UI 框架
  • 🏷️ 类型安全的枚举工具
  • 🤖 提供 Agent Skills,帮助 AI 编程助手理解和使用 Rattail
  • 🌲 可 Tree-shake,轻量,TypeScript 完整类型支持
  • 💪 90%+ 单元测试覆盖率

下面作者挑几个有意思的能力展开聊聊。

Vite+ 配置预设

做过前端工程化的同学应该都有体会,每次新项目光配 eslintprettier 这些东西就够喝一壶的了,配好了还得处理各种冲突。rattail 内置了面向 Vite+ 的开箱即用预设,一个 vite.config.ts 搞定 lintformatstagedgit hooks 等所有工程化配置。

import { lint, fmt, staged, clean, hook, defineConfig } from 'rattail/vite-plus'

export default defineConfig({
  lint: lint(),

  fmt: fmt(),

  staged: staged(),

  rattail: {
    clean: clean(),

    hook: hook(),
    
    api: {},

    release: {},

    changelog: {}
  },
})

之前作者为了配这些东西写了好几个配置文件,现在一个文件就够了(少写代码是第一生产力)。

CLI 工具链

安装 rattail 后会注册一个 rt 命令,覆盖了作者日常开发中最常用的几个场景。

# 清理产物
rt clean

# 安装 git hooks
rt hook

# 发布
rt release

# 生成 changelog
rt changelog

# 从 OpenAPI 生成 API 模块
rt api

这些命令都支持通过 vite.config.ts 中的 rattail 字段进行配置,也就是说项目根目录不需要再多出一堆 .xxxrc 文件了。这一点作者是比较在意的,毕竟谁也不想打开项目根目录看到十几个配置文件吧(有些项目根目录比 node_modules 还热闹)。

140+ 工具函数

lodash 大家都耳熟能详了,rattail 里的工具函数覆盖的场景和 lodash 类似,包括类型判断数组对象字符串数学函数集合文件等分类,用法就不逐个列举了。和 lodash 不同的是,这些函数从第一天就是用 TypeScript 写的,类型推导是第一优先级,全部可 Tree-shake。除了 lodash 风格的工具函数以外,rattail 还内置了一些前端项目中常用的实用工具,比如 sumHash 计算哈希、uuid 生成唯一 ID、mitt 事件总线、duration 时间格式化、storage / cookieStorage 存储封装、copyText 复制文本、download 文件下载等等,省得同学们每次都要单独装一堆小包。更多的可以去文档里查看完整的 API 列表。

类型安全的枚举工具

这个是作者个人比较喜欢的一个工具。前端项目里到处都是枚举值,比如订单状态、用户角色之类的。一般我们用 enum 或者常量对象来管理它们,但是 labeldescription 这些配套信息就只能另外维护了。enumOf 把值和它的元信息放在一起管理,并且类型推导是完备的。

import { enumOf } from 'rattail'

const Status = enumOf({
  Pending: { value: 0, label: '待处理' },
  Active: { value: 1, label: '进行中' },
  Done: { value: 2, label: '已完成' },
})

Status.Pending        // 0
Status.Active         // 1
Status.values()       // [0, 1, 2]
Status.labels()       // ['待处理', '进行中', '已完成']
Status.label(Status.Pending) // '待处理'
Status.options()      // [{ value: 0, label: '待处理' }, ...]

// 直接丢给 select 组件的 options,再也不用手动维护了

前端项目里到处都需要枚举值和它对应的文案,以前每次都要写个 map 或者 switch,现在一个 enumOf 就够了。另外 enumOflabeldescription 支持传入一个 getter 函数,配合 vue-i18n 之类的国际化方案可以很方便的实现多语言:

const Status = enumOf({
  Pending: { value: 0, label: () => t('status.pending') },
  Active: { value: 1, label: () => t('status.active') },
  Done: { value: 2, label: () => t('status.done') },
})

基于 axios 的渐进式请求工具

这个能力来自于作者之前开源的 @varlet/axle,现在通过 rattail/axle 直接引入。熟悉作者的同学可能看过之前介绍 axle 的文章,它在兼容 axios 的同时,天然支持 Vue3 Composition API

import { createAxle } from 'rattail/axle'
import { createUseAxle } from 'rattail/axle/use'

const axle = createAxle({ baseURL: '/api' })
const useAxle = createUseAxle({ axle })

const [users, getUsers, { loading, error }] = useAxle({
  method: 'get',
  url: '/user',
  params: { current: 1, pageSize: 10 },
})

作者一直觉得前端请求库和 Vue 的响应式系统应该有更好的结合方式,axle 就是在这个方向上的一个尝试。如果你不喜欢 axle 也完全没问题,rattail 的其他能力和请求库是解耦的,换成你喜欢的方案就好。

OpenAPI 生成 API 模块

rt api 可以直接解析后端提供的 OpenAPI / Swagger schema 文件,自动生成类型安全的 API 调用代码,这个在实际项目里把工作流做通之后体验可太好了。

vite.config.ts 里配置好 schema 路径和输出目录:

import { defineConfig } from 'rattail/vite-plus'

export default defineConfig({
  rattail: {
    api: {
      input: './openapi.json'
    },
  },
})

执行 rt api 后会自动生成这样的代码:

import { api } from '@/request'
import { type paths } from './_types'

export type ApiGetUsers = paths['/users']['get']
export type ApiCreateUser = paths['/users']['post']
export type ApiGetUser = paths['/users/{uuid}']['get']
export type ApiUpdateUser = paths['/users/{uuid}']['put']
export type ApiDeleteUser = paths['/users/{uuid}']['delete']

export type ApiGetUsersQuery = ApiGetUsers['parameters']['query']
export type ApiGetUsersRequestBody = undefined
export type ApiGetUsersResponseBody = ApiGetUsers['responses']['200']['content']['application/json']
// ... 其他类型同理

export const apiGetUsers = api<
  ApiGetUsersResponseBody, ApiGetUsersQuery, ApiGetUsersRequestBody>('/users', 'get')
export const apiCreateUser = api<
  ApiCreateUserResponseBody, ApiCreateUserQuery, ApiCreateUserRequestBody>('/users', 'post')
export const apiGetUser = api<
  ApiGetUserResponseBody, ApiGetUserQuery, ApiGetUserRequestBody>('/users/:uuid', 'get')
export const apiUpdateUser = api<
  ApiUpdateUserResponseBody, ApiUpdateUserQuery, ApiUpdateUserRequestBody>('/users/:uuid', 'put')
export const apiDeleteUser = api<
  ApiDeleteUserResponseBody, ApiDeleteUserQuery, ApiDeleteUserRequestBody>('/users/:uuid', 'delete')

请求类型、响应类型全部从 schema 里提取,不需要手写。后端接口变了,重新跑一遍 rt api 就行,前后端的类型始终保持同步。这个工作流对 AI Agent 也特别友好,AI 可以直接基于生成的类型去写业务代码,不会出现参数类型对不上的问题。甚至 AI Agent 可以通过 api 定义的变化,推测出你接下来要写什么业务。默认使用 axle,也支持 axios 的预设,同时支持 自定义输出

链式校验规则工厂

做表单的同学应该都写过类似 requiredminmax 这些校验规则。不同的 UI 框架校验规则的格式还不一样,每个项目都要适配一遍。rattail 提供了一个链式校验规则工厂,写起来很流畅,并且可以适配任意 UI 框架。这种内联的声明式写法和 TailwindCSS 的思路类似,可读性和可迁移性都非常好,对 AI 也特别友好,AI 可以直接从模板里读懂校验意图,生成和修改规则的准确度很高。

Naive UIElement Plus 为例:

<!-- Naive UI -->
<script setup lang="ts">
import type { FormItemRule } from 'naive-ui'
import { rulerFactory } from 'rattail/ruler'

const r = rulerFactory<FormItemRule>((validator, params = {}) => ({
  trigger: ['blur', 'change', 'input'],
  validator: (_, value) => validator(value),
  ...params,
}))
</script>

<template>
  <n-form :model>
    <n-form-item 
      path="name" 
      label="姓名"
      :rule="r().required('必填').min(2, '长度不正确').done()"
    >
      <n-input v-model:value="model.name" />
    </n-form-item>
  </n-form>
</template>
<!-- Element Plus -->
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { rulerFactory } from 'rattail/ruler'

const r = rulerFactory<FormItemRule>((validator, params) => ({
  validator(_, value, callback) {
    const e = validator(value)
    e ? callback(e) : callback()
  },
  trigger: ['blur', 'change', 'input'],
  ...params,
}))
</script>

<template>
  <el-form :model>
    <el-form-item 
      prop="email" 
      label="邮箱"
      :rules="r().email('必须是邮箱格式').done()"
    >
      <el-input v-model="model.email" />
    </el-form-item>
  </el-form>
</template>

AI Agent Skills

rattail 提供了一套 Agent Skills,说白了就是给 AI 写了一份"说明书",让 AI Agent 知道 rattail 有哪些能力、怎么用,不用你每次都手动告诉 AI。作者觉得未来的开源库都应该考虑对 AI Agent 的友好度。

写在最后

rattail 的工具函数和能力大多来自前端社区的通用实践。感谢同学们能看到这里,但是希望 rattail 能够帮助到大家。项目基于 MIT 协议。如果在使用的过程中遇到任何问题,欢迎在 issue 里反馈给我们,同时也十分欢迎对项目有兴趣的同学给我们发 pull request

支持我们的话留下一个 star 就好~

昨天以前首页

vite+vue2 动态路由加载方法实现

2026年4月16日 17:33

最近在改老项目,将webpack迁移到vite提高下速度 首先来看下默认静态加载路由,我们只需要在router/index.js直接配置好就可以了

dynamicRoutes_01.png

当然默认的情况 component: () => import('../views/HomeView.vue') 是这样的如果需要用@替代..需要在在vite.config.js中增加下配置

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

dynamicRoutes_02.png

在webpack中动态加载使用如下,就可以了

export const loadView = (view) => {
  if (process.env.NODE_ENV === 'development') {
    return (resolve) => require([`@/views/${view}`], resolve)
  } else {
    // 使用 import 实现生产环境的路由懒加载
    return () => import(`@/views/${view}`)
  }
}

但是在vite中语法就变了,require() 是 CommonJS 语法,Vite 不支持,需要用import.meta.glob来实现,下面是相对路径使用,相对路径使用要注意当前方法引入对应根目录的层级

//代码文件顶部增加
const modules = import.meta.glob('../views/**/*')
//然后定义loadView 方法
export const loadView = (view) => {
    return modules[`../views/${viewPath}.vue`]  // 使用相对路径
}

在这里比较推荐使用相对路径来实现

//代码文件顶部增加
const modules = import.meta.glob('/src/views/**/*')
export const loadView = (view) => {
    return modules[`/src/views/${viewPath}.vue`]  // 使用绝对路径
}

说明一下如果从后端获取的动态组件路径是带.vue文件名字的可以忽略

最后又完善了一下增加了一些模糊匹配规则

/src/views/${viewName}.vue
/src/views/${viewName}/index.vue
/src/views/${viewName}/${viewName}.vue

完整的loadView 方法

export const loadView = (view) => {
    // 统一处理:无论后端是否带 .vue,都确保有后缀
    const viewName = view.replace(/\.vue$/, '')
    const possiblePaths = [
        `/src/views/${viewName}.vue`,
        `/src/views/${viewName}/index.vue`,
        `/src/views/${viewName}/${viewName}.vue`,
    ]

    for (const path of possiblePaths) {
        // const loader = modules[path]
        if (modules[path]) {
            console.log('✅ 匹配组件:', path)
            return modules[path]
        }
    }
    console.error(`未找到页面组件: ${viewName}`)
    console.log('可用页面组件:', Object.keys(modules))
    return null
}

演示demo

dynamicRoutes.gif

原文 www.liweiliang.com/1204.html

Vue<前端页面装修组件>

2026年4月16日 17:25

一个基于 Vue 2 和 Ant Design Vue 1.x 的可视化组件装修工具,支持拖拽排序、属性编辑和实时预览。

gif_1.gif

功能特性

  • 📱 移动端预览:支持自定义尺寸的移动端预览界面
  • 🎨 组件编辑:实时编辑组件属性,所见即所得
  • 📦 组件库:可扩展的组件库系统
  • 🎯 拖拽排序:支持组件的拖拽排序功能
  • 🔧 尺寸调整:可自定义预览区域尺寸,保持等比例缩放

技术栈

  • Vue 2
  • Ant Design Vue 1.x
  • vuedraggable

目录结构

src/
├── components/
│   └── DecorationBuilder/          # 装修工具主目录
│       ├── bases/                  # 基础组件
│       │   ├── Editor/             # 属性编辑器
│       │   ├── Preview/            # 移动端预览组件
│       │   │   ├── components/     # 预览组件的子组件
│       │   │   │   ├── BrowserToolbar/ # 浏览器工具栏
│       │   │   │   └── SizeEditor/     # 尺寸编辑器
│       │   └── Selector/           # 组件选择器
│       ├── config/                 # 配置文件
│       │   ├── componentTypes.js   # 组件类型定义
│       │   └── settings.js         # 全局设置
│       ├── widgets/                # 自定义组件
│       │   ├── Banner/             # 轮播图组件
│       │   ├── News/               # 新闻列表组件
│       │   └── index.js            # 组件注册表
│       └── index.vue               # 装修工具主入口
└── utils/
    ├── componentUtils.js           # 组件相关工具函数
    └── index.js                    # 通用工具函数
graph TD
    A[DecorationBuilder] --> B[bases]
    A --> C[config]
    A --> D[widgets]
    A --> E[index.vue]
    
    B --> F[Editor]
    B --> G[Preview]
    B --> H[Selector]
    
    G --> I[components]
    I --> J[BrowserToolbar]
    I --> K[SizeEditor]
    
    C --> L[componentTypes.js]
    C --> M[settings.js]
    
    D --> N[Banner]
    D --> O[News]
    D --> P[index.js]

核心组件说明

1. DecorationBuilder (主入口)

  • 文件:src/components/DecorationBuilder/index.vue
  • 功能:整合预览、编辑器和选择器组件,管理组件数据和交互逻辑

2. Preview (预览组件)

image.png

  • 文件:src/components/DecorationBuilder/bases/Preview/index.vue
  • 功能:展示移动端预览界面,支持组件拖拽排序
  • 子组件:
    • BrowserToolbar:浏览器工具栏,包含预览、添加组件、发布等功能
    • SizeEditor:尺寸编辑器,用于调整预览区域大小

3. Editor (属性编辑器)

image.png

  • 文件:src/components/DecorationBuilder/bases/Editor/index.vue
  • 功能:动态加载组件编辑器,允许编辑组件属性

4. Selector (组件选择器)

image.png

  • 文件:src/components/DecorationBuilder/bases/Selector/index.vue
  • 功能:展示所有可用组件,支持选择组件添加到预览区

配置文件说明

componentTypes.js

  • 定义组件类型枚举和元数据
  • 包含组件的显示名称、描述、图标等信息
export const COMPONENT_TYPES = {
  BANNER: 'banner',          // 轮播图
  NEWS_LIST: 'news-list'     // 新闻列表
}

export const COMPONENT_METADATA = {
  [COMPONENT_TYPES.BANNER]: {
    name: '轮播图',
    description: '支持多张图片轮播展示',
    icon: 'picture',
    category: '基础组件'
  }
  // ...
}

settings.js

  • 全局配置文件,包含预览设置等
export const PREVIEW_SETTINGS = {
  MOBILE_WIDTH: 375,         // 默认移动端宽度
  MOBILE_HEIGHT: 812         // 默认移动端高度
}

组件工具函数

文件:src/utils/componentUtils.js

主要功能:

  • getComponentMetadata():获取组件元数据
  • getAllComponentTypes():获取所有组件类型
  • getWidgetConfig():获取组件配置
  • getWidgetDefaultProps():获取组件默认属性
  • getWidgetPreview():获取预览组件
  • getWidgetEditor():获取编辑组件

组件映射关系

组件类型 组件名称 预览组件 编辑组件
banner 轮播图 BannerPreview BannerEditor
news-list 新闻列表 NewsPreview NewsEditor

数据格式说明

标准组件数据格式

[
  {
    "id": "1234567890",
    "type": "banner",
    "props": {
      "images": [
        { "url": "https://example.com/image1.jpg", "link": "" },
        { "url": "https://example.com/image2.jpg", "link": "" }
      ],
      "autoPlay": true,
      "interval": 3000,
      "dots": true,
      "arrows": false
    }
  },
  {
    "id": "0987654321",
    "type": "news-list",
    "props": {
      "title": "最新资讯",
      "news": [
        { "id": 1, "title": "新闻标题1", "date": "2026-04-16", "link": "" },
        { "id": 2, "title": "新闻标题2", "date": "2026-04-17", "link": "" }
      ],
      "showDate": true,
      "showArrow": true,
      "maxItems": 5
    }
  }
]

字段说明

  • id:组件唯一标识符,由系统自动生成
  • type:组件类型,对应 COMPONENT_TYPES 中的值
  • props:组件属性,包含所有可配置的参数

数据来源

  1. 默认数据:组件的初始默认属性来自各组件的 index.js 文件中的 defaultProps
  2. 用户配置:用户在编辑器中修改的属性会覆盖默认属性
  3. 保存/发布:最终的组件数据会以标准JSON格式保存或发布

使用方式

  • 前端渲染:通过组件类型动态加载对应的预览组件,并传入props进行渲染
  • 后端存储:可以将JSON数据存储到后端数据库中
  • 页面加载:从后端获取JSON数据后,可以直接传递给DecorationBuilder组件进行渲染

添加新组件指南

以添加一个"轮播的通知公告"组件为例:

1. 创建组件目录和文件

src/components/DecorationBuilder/widgets/ 下创建新组件目录:

NotificationBanner/
├── index.js           # 组件配置文件
├── preview.vue        # 预览组件
└── editor.vue         # 编辑组件

2. 编写组件配置文件 (index.js)

import NotificationBannerPreview from './preview.vue'
import NotificationBannerEditor from './editor.vue'
import { COMPONENT_TYPES } from '../../config/componentTypes'

export default {
  type: COMPONENT_TYPES.NOTIFICATION_BANNER,  // 需要在componentTypes.js中定义
  Preview: NotificationBannerPreview,
  Editor: NotificationBannerEditor,
  defaultProps: {
    // 组件默认属性
    notifications: [
      { id: 1, content: '通知内容1' },
      { id: 2, content: '通知内容2' }
    ],
    autoPlay: true,
    interval: 2000
  }
}

3. 编写预览组件 (preview.vue)

<template>
  <div class="notification-banner">
    <!-- 轮播的通知内容 -->
  </div>
</template>

<script>
export default {
  name: 'NotificationBannerPreview',
  props: {
    component: {
      type: Object,
      required: true
    }
  }
}
</script>

<style scoped>
/* 组件样式 */
</style>

4. 编写编辑组件 (editor.vue)

<template>
  <div class="notification-banner-editor">
    <!-- 属性编辑表单 -->
  </div>
</template>

<script>
export default {
  name: 'NotificationBannerEditor',
  props: {
    component: {
      type: Object,
      required: true
    }
  }
}
</script>

5. 注册组件类型

src/components/DecorationBuilder/config/componentTypes.js 中添加组件类型:

export const COMPONENT_TYPES = {
  // ... 现有类型
  NOTIFICATION_BANNER: 'notification-banner'  // 新增通知公告类型
}

export const COMPONENT_METADATA = {
  // ... 现有元数据
  [COMPONENT_TYPES.NOTIFICATION_BANNER]: {
    name: '通知公告',
    description: '轮播展示通知内容',
    icon: 'bell',
    category: '基础组件'
  }
}

6. 注册组件

src/components/DecorationBuilder/widgets/index.js 中导入并注册新组件:

import BannerComponent from './Banner'
import NewsComponent from './News'
import NotificationBannerComponent from './NotificationBanner'  // 导入新组件

export const widgets = [
  BannerComponent,
  NewsComponent,
  NotificationBannerComponent  // 注册新组件
]

注意事项

  1. 所有组件必须遵循相同的命名和目录结构
  2. 新组件必须在 componentTypes.js 中定义类型和元数据
  3. 预览组件和编辑组件必须正确导出
  4. 默认属性应该在组件的 index.js 中定义

Vue3 KeepAlive 深度揭秘:组件缓存的魔法是如何实现的?

2026年4月16日 11:29

Vue3 KeepAlive 深度揭秘:组件缓存的魔法是如何实现的?

本文将带你深入 Vue3 内核,从源码层面彻底搞懂 KeepAlive 组件的缓存机制、LRU 淘汰策略以及组件"失活"与"激活"的底层实现原理。

📋 文章导航


1. 为什么需要 KeepAlive?

1.1 实际业务场景

在开发后台管理系统或多标签页应用时,我们经常会遇到这样的需求:

  • 表单页面:用户填写了一半的表单,切换到其他页面查看资料,返回时期望表单数据还在
  • 列表页面:滚动到第 N 页,查看详情后返回,期望回到原来的滚动位置
  • 地图应用:地图已经缩放和平移到特定位置,切换页面后返回保持原状

1.2 没有 KeepAlive 的问题

<template>
  <button @click="currentView = 'A'">页面A</button>
  <button @click="currentView = 'B'">页面B</button>

  <!-- 普通动态组件切换 -->
  <component :is="currentView" />
</template>

<script setup>
import { ref } from "vue";
import ViewA from "./ViewA.vue";
import ViewB from "./ViewB.vue";

const currentView = ref("ViewA");
</script>

问题:当从 A 切换到 B 时,A 组件会被完全销毁(触发 onUnmounted),状态全部丢失。再切回 A 时,组件重新创建,所有数据重置。

1.3 KeepAlive 的解决方案

KeepAlive 通过组件级缓存完美解决这个问题:

  • 组件切换时不会销毁,而是进入"失活"状态
  • 组件实例、响应式数据、DOM 状态全部保留
  • 切换回来时"激活",瞬间恢复,无需重新渲染

2. KeepAlive 基础使用

2.1 基本用法

<template>
  <button
    v-for="tab in tabs"
    :key="tab"
    @click="currentTab = tab"
    :class="{ active: currentTab === tab }"
  >
    {{ tab }}
  </button>

  <!-- 使用 KeepAlive 包裹动态组件 -->
  <KeepAlive>
    <component :is="currentTab" />
  </KeepAlive>
</template>

<script setup>
import { ref } from "vue";
import Home from "./Home.vue";
import Posts from "./Posts.vue";
import Archive from "./Archive.vue";

const currentTab = ref("Home");
const tabs = ["Home", "Posts", "Archive"];
</script>

2.2 重要限制

⚠️ KeepAlive 只能缓存单个直接子节点

<!-- ❌ 错误:多个根节点 -->
<KeepAlive>
  <CompA />
  <CompB />
</KeepAlive>

<!-- ✅ 正确:使用动态组件包裹 -->
<KeepAlive>
  <component :is="activeComponent" />
</KeepAlive>

<!-- ✅ 正确:使用 v-if 切换单个组件 -->
<KeepAlive>
  <CompA v-if="showA" />
  <CompB v-else />
</KeepAlive>

3. 核心属性详解

3.1 属性一览表

属性 类型 说明
include string | RegExp | Array 只有名称匹配的组件会被缓存
exclude string | RegExp | Array 任何名称匹配的组件都不会被缓存
max number | string 最多可以缓存多少组件实例

3.2 include - 白名单缓存

<!-- 字符串形式(逗号分隔) -->
<KeepAlive include="Home,Posts">
  <component :is="currentTab" />
</KeepAlive>

<!-- 数组形式 -->
<KeepAlive :include="['Home', 'Posts']">
  <component :is="currentTab" />
</KeepAlive>

<!-- 正则表达式 -->
<KeepAlive :include="/^User/">
  <component :is="currentTab" />
</KeepAlive>

匹配规则:与组件的 name 选项进行匹配

<script>
export default {
  name: "Home", // 这个名字用于 include/exclude 匹配
  // ...
};
</script>

<!-- 或者使用 script setup -->
<script setup>
defineOptions({
  name: "Home",
});
</script>

3.3 exclude - 黑名单排除

<!-- 不缓存 Archive 组件 -->
<KeepAlive exclude="Archive">
  <component :is="currentTab" />
</KeepAlive>

<!-- 排除多个 -->
<KeepAlive :exclude="['Archive', 'Settings']">
  <component :is="currentTab" />
</KeepAlive>

3.4 max - LRU 缓存淘汰

<KeepAlive :max="5">
  <component :is="currentTab" />
</KeepAlive>

LRU (Least Recently Used) 算法

  1. 设置最大缓存数为 5
  2. 依次访问 A → B → C → D → E,全部缓存
  3. 访问 F 时,缓存已满,淘汰最久未使用的 A
  4. 访问 B,B 变为最近使用
  5. 访问 G,淘汰 C(现在 C 是最久未使用的)
缓存状态变化示意:

初始: []
访问A: [A]
访问B: [A, B]
访问C: [A, B, C]
访问D: [A, B, C, D]
访问E: [A, B, C, D, E]  ← 达到 max
访问F: [B, C, D, E, F]A 被淘汰
访问B: [C, D, E, F, B]B 移到最近使用
访问G: [D, E, F, B, G]C 被淘汰

4. 专属生命周期钩子

被 KeepAlive 缓存的组件会新增两个生命周期钩子:

4.1 生命周期对比

普通组件:          KeepAlive 缓存组件:
   onMounted           onMounted (首次)
       ↓                   ↓
   onUnmounted      onActivated (每次激活)
                          ↓
                     onDeactivated (失活)
                          ↓
                     onActivated (再次激活)
                          ↓
                     onDeactivated
                          ↓
                     onUnmounted (真正销毁时)

4.2 钩子函数详解

<script setup>
import { onMounted, onUnmounted, onActivated, onDeactivated } from "vue";

// 首次挂载时触发(仅一次)
onMounted(() => {
  console.log("组件首次挂载");
  // 适合执行一次性初始化:建立 WebSocket 连接、获取基础配置等
});

// 每次从缓存激活时触发
onActivated(() => {
  console.log("组件被激活");
  // 适合执行:恢复定时器、重新获取最新数据、恢复滚动位置等
});

// 组件被缓存时触发
onDeactivated(() => {
  console.log("组件被失活(进入缓存)");
  // 适合执行:暂停定时器、保存临时状态等
});

// 组件真正被销毁时触发(仅一次)
onUnmounted(() => {
  console.log("组件被销毁");
  // 清理工作:关闭 WebSocket、清除全局事件监听等
});
</script>

4.3 实际应用示例

<script setup>
import { ref, onActivated, onDeactivated } from "vue";

const scrollTop = ref(0);
const timer = ref(null);
const listData = ref([]);

// 激活时恢复状态
onActivated(() => {
  // 恢复滚动位置
  const container = document.querySelector(".list-container");
  if (container) {
    container.scrollTop = scrollTop.value;
  }

  // 重启定时刷新
  timer.value = setInterval(fetchLatestData, 5000);

  // 重新获取最新数据(可选)
  fetchLatestData();
});

// 失活时保存状态
onDeactivated(() => {
  // 保存滚动位置
  const container = document.querySelector(".list-container");
  if (container) {
    scrollTop.value = container.scrollTop;
  }

  // 暂停定时刷新
  if (timer.value) {
    clearInterval(timer.value);
    timer.value = null;
  }
});

async function fetchLatestData() {
  // 获取最新数据...
}
</script>

5. 底层实现原理

5.1 核心问题拆解

KeepAlive 要实现组件缓存,必须解决三个核心问题:

问题 解决方案
如何保存组件状态? 使用 Map 缓存组件的 VNode
如何识别缓存组件? 通过 shapeFlag 标记组件状态
如何让组件"隐藏"而不是销毁? 使用 move 函数将 DOM 移入隐藏容器

5.2 组件状态标记

Vue3 使用 shapeFlag 来标记 VNode 的类型和状态:

// 组件需要被缓存(进入缓存流程)
const COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8; // 256

// 组件已被缓存(从缓存恢复)
const COMPONENT_KEPT_ALIVE = 1 << 9; // 512

标记的作用

  1. COMPONENT_SHOULD_KEEP_ALIVE:告诉渲染器这个组件不应该被销毁,而是执行失活流程
  2. COMPONENT_KEPT_ALIVE:告诉渲染器这个组件来自缓存,不需要重新创建实例

5.3 缓存与隐藏机制

┌─────────────────────────────────────────────────────────────┐
│                      KeepAlive 组件                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────────────┐      ┌──────────────────────────┐    │
│   │   cache (Map)   │      │   storageContainer       │    │
│   │                 │      │   (隐藏的 div 容器)       │    │
│   │  key → VNode    │      │                          │    │
│   │  key → VNode    │      │  ┌──────────────────┐    │    │
│   │  key → VNode    │      │  │  被缓存的 DOM    │    │    │
│   │                 │      │  │  ┌──┐ ┌──┐ ┌──┐  │    │    │
│   └─────────────────┘      │  │  │A │ │B │ │C │  │    │    │
│                            │  │  └──┘ └──┘ └──┘  │    │    │
│   ┌─────────────────┐      │  └──────────────────┘    │    │
│   │   keys (Set)    │      │                          │    │
│   │                 │      └──────────────────────────┘    │
│   │  [A, B, C]      │                                      │
│   │  ↑  LRU 顺序    │                                      │
│   └─────────────────┘                                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

5.4 流程图解

首次渲染组件 A:
    │
    ▼
┌─────────────────┐
│  检查 cache     │
│  是否已有 A?   │
└────────┬────────┘
         │ 否
         ▼
┌─────────────────┐
│  正常创建组件 A  │
│  渲染 DOM        │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  存入 cache     │
│  A → VNode      │
│  keys.add(A)    │
└─────────────────┘

切换到组件 B:
    │
    ▼
┌─────────────────┐
│  组件 A 失活     │
│  (不是销毁!)    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  调用 _deActivate│
│  将 A 的 DOM    │
│  移入隐藏容器    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  渲染组件 B      │
└─────────────────┘

切回组件 A:
    │
    ▼
┌─────────────────┐
│  检查 cache     │
│  是否已有 A?   │
└────────┬────────┘
         │ 是
         ▼
┌─────────────────┐
│  命中缓存!      │
│  复用 VNode     │
│  复用组件实例    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  调用 _activate │
│  将 A 的 DOM    │
│  从隐藏容器移出  │
│  插入页面        │
└─────────────────┘
         │
         ▼
┌─────────────────┐
│  触发 onActivated│
└─────────────────┘

6. 源码深度解析

6.1 完整源码注释版

// packages/runtime-core/src/components/KeepAlive.ts

import {
  type VNode,
  type ComponentInternalInstance,
  type SetupContext,
  type RendererInternals,
  type RendererElement,
  type RendererNode,
  ShapeFlags,
  currentInstance,
  unmountComponent,
  callWithAsyncErrorHandling,
  onBeforeUnmount,
  type Slots,
  type FunctionalComponent,
  type Component,
  type ComponentOptions,
  type VNodeNormalizedChildren,
  type VNodeChild,
  setTransitionHooks,
  type TransitionHooks,
} from "@vue/runtime-core";

export interface KeepAliveProps {
  include?: MatchPattern;
  exclude?: MatchPattern;
  max?: number | string;
}

type MatchPattern = string | RegExp | (string | RegExp)[];

export const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,

  // 标记这是一个 KeepAlive 组件
  __isKeepAlive: true,

  props: {
    include: [String, RegExp, Array] as PropType<MatchPattern>,
    exclude: [String, RegExp, Array] as PropType<MatchPattern>,
    max: [String, Number],
  },

  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // ==================== 1. 获取组件实例和渲染器方法 ====================
    const instance = currentInstance!;

    // 从组件实例中获取渲染器注入的方法
    // move: 移动 DOM 节点
    // createElement: 创建 DOM 元素
    const { move, createElement } = instance.ctx.renderer as RendererInternals<
      RendererNode,
      RendererElement
    >;

    // ==================== 2. 创建存储容器 ====================
    // storageContainer 是一个普通的 div,用于存放被失活的组件 DOM
    const storageContainer = createElement("div");

    // ==================== 3. 定义激活/失活方法 ====================

    /**
     * 失活组件:将组件的 DOM 移动到隐藏容器
     * @param vnode 被失活的组件 VNode
     * @param container 当前容器(未使用,保持一致性)
     * @param anchor 锚点(未使用)
     */
    instance.ctx.deactivate = (vnode: VNode) => {
      move(vnode, storageContainer, null, MoveType.LEAVE);
    };

    /**
     * 激活组件:将组件的 DOM 从隐藏容器移回页面
     * @param vnode 被激活的组件 VNode
     * @param container 目标容器
     * @param anchor 锚点位置
     * @param isSVG 是否是 SVG
     * @param optimized 是否优化模式
     */
    instance.ctx.activate = (
      vnode: VNode,
      container: RendererElement,
      anchor: RendererNode | null,
      isSVG: boolean,
      optimized: boolean,
    ) => {
      const vnodeComponent = vnode.component!;

      // 将 DOM 移回页面
      move(vnode, container, anchor, MoveType.ENTER, isSVG);

      // 处理过渡动画
      if (vnodeComponent.da) {
        // 延迟激活(等待延迟显示动画完成)
        queuePostRenderEffect(() => {
          vnodeComponent.da!(vnodeComponent.vnode);
        }, instance.suspense);
      }
    };

    // ==================== 4. 缓存相关变量 ====================
    const cache: Map<string, VNode> = new Map(); // 缓存容器:key -> VNode
    const keys: Set<string> = new Set(); // 记录缓存顺序,用于 LRU
    let current: VNode | null = null; // 当前正在渲染的组件
    let pendingCacheKey: string | null = null; // 待缓存的 key

    // ==================== 5. 缓存清理函数 ====================

    /**
     * 根据 key 淘汰缓存条目
     * 当缓存超过 max 时,淘汰最久未使用的组件
     */
    function pruneCacheEntry(key: string) {
      const cached = cache.get(key);
      if (!cached) return;

      // 如果当前正在渲染的组件不是要淘汰的,触发 deactivated 钩子
      if (current !== cached) {
        const comp = cached.component!;
        if (!comp.isDeactivated) {
          // 调用 deactivated 生命周期钩子
          callWithAsyncErrorHandling(
            comp.type.deactivated,
            comp,
            ErrorCodes.COMPONENT_DEACTIVATED,
          );
          comp.isDeactivated = true;
        }
      }

      // 从缓存中移除
      cache.delete(key);
      keys.delete(key);
    }

    /**
     * 清空所有缓存
     */
    function pruneCache() {
      cache.forEach((cached, key) => {
        pruneCacheEntry(key);
      });
    }

    // ==================== 6. 监听 props 变化 ====================

    // 当 include/exclude 变化时,清理不再匹配的缓存
    watch(
      () => [props.include, props.exclude],
      ([include, exclude]) => {
        // 清理不再满足 include/exclude 条件的缓存
        cache.forEach((vnode, key) => {
          const name = getName(vnode);
          if (
            name &&
            (!include || !matches(include, name)) &&
            exclude &&
            matches(exclude, name)
          ) {
            pruneCacheEntry(key);
          }
        });
      },
      { flush: "post", deep: true },
    );

    // ==================== 7. 组件卸载时清理 ====================

    onBeforeUnmount(() => {
      cache.forEach((vnode) => {
        const { shapeFlag, component } = vnode;
        // 如果组件还在激活状态,需要手动卸载
        if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
          unmountComponent(component!);
        }
      });
    });

    // ==================== 8. 核心渲染逻辑 ====================

    return () => {
      // 获取默认插槽的第一个子节点
      const rawVNode = slots.default && slots.default();

      // 如果没有子节点,直接返回
      if (!rawVNode || rawVNode.length !== 1) {
        if (__DEV__ && rawVNode && rawVNode.length > 1) {
          warn(`KeepAlive should contain exactly one component child.`);
        }
        current = null;
        return rawVNode;
      }

      // 获取内部真实组件(处理 Teleport 等包裹情况)
      const vnode = getInnerChild(rawVNode[0]);
      const comp = vnode.type as Component;

      // 获取组件名称用于 include/exclude 匹配
      const name = getName(vnode);

      // 检查是否应该缓存
      const shouldCache = !(
        name &&
        ((props.include && !matches(props.include, name)) ||
          (props.exclude && matches(props.exclude, name)))
      );

      // 获取缓存 key
      const key = vnode.key == null ? comp : vnode.key;
      const cachedVNode = cache.get(key);

      // ==================== 8.1 命中缓存 ====================
      if (cachedVNode) {
        // 复用缓存的组件实例
        vnode.component = cachedVNode.component;
        vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE;

        // 更新 LRU 顺序:先删除再添加,确保在 Set 末尾(最近使用)
        keys.delete(key);
        keys.add(key);
      }
      // ==================== 8.2 未命中缓存 ====================
      else if (shouldCache) {
        // 存入新缓存
        cache.set(key, vnode);
        keys.add(key);

        // LRU 淘汰:如果超过 max,删除最久未使用的
        if (props.max && keys.size > parseInt(props.max as string, 10)) {
          pruneCacheEntry(keys.values().next().value);
        }
      }

      // 标记组件需要被缓存(影响卸载流程)
      vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE;
      current = vnode;

      return rawVNode;
    };
  },
};

// 辅助函数:获取组件名称
function getName(vnode: VNode): string | undefined {
  return (
    (vnode.type as ComponentOptions).name ||
    (vnode.type as ComponentOptions).__name ||
    (typeof vnode.type === "function" &&
      (vnode.type as FunctionalComponent).name)
  );
}

// 辅助函数:匹配模式
function matches(pattern: MatchPattern, name: string): boolean {
  if (isArray(pattern)) {
    return pattern.some((p) => matches(p, name));
  } else if (isString(pattern)) {
    return pattern.split(",").includes(name);
  } else if (isRegExp(pattern)) {
    return pattern.test(name);
  }
  return false;
}

6.2 关键逻辑解析

6.2.1 为什么使用 Map 和 Set?
const cache: Map<string, VNode> = new Map(); // 快速查找:O(1)
const keys: Set<string> = new Set(); // 保持插入顺序,支持 LRU
  • Map:提供 O(1) 的查找效率,适合频繁读取缓存
  • Set:保持插入顺序,且可以方便地获取"第一个"元素(最久未使用)
6.2.2 LRU 淘汰实现
// 更新 LRU 顺序
keys.delete(key); // 先删除旧位置
keys.add(key); // 再添加到末尾(最近使用)

// 淘汰最久未使用的
if (max && keys.size > max) {
  pruneCacheEntry(keys.values().next().value); // 获取并删除第一个
}
6.2.3 渲染器如何配合 KeepAlive?
// packages/runtime-core/src/renderer.ts

// 在组件卸载流程中
function unmountComponent(instance) {
  const { shapeFlag } = instance.vnode;

  // 检查是否是 KeepAlive 缓存的组件
  if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
    // 不销毁,而是调用 deactivate
    const { deactivate } = instance.parent?.ctx || {};
    if (deactivate) {
      deactivate(instance.vnode);
    }
    return;
  }

  // 普通组件:正常销毁流程
  // ...
}

// 在组件挂载流程中
function mountComponent(vnode, container, anchor) {
  // 检查是否来自缓存
  if (vnode.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
    // 复用已有实例,不需要重新创建
    const instance = vnode.component;

    // 调用 activate 将 DOM 移回页面
    const { activate } = instance.parent?.ctx || {};
    if (activate) {
      activate(vnode, container, anchor);
    }
    return;
  }

  // 普通组件:正常创建流程
  // ...
}

7. 实战应用场景

7.1 多标签页缓存

<template>
  <div class="tabs">
    <div
      v-for="tab in tabs"
      :key="tab.name"
      class="tab-item"
      :class="{ active: currentTab === tab.name }"
      @click="currentTab = tab.name"
    >
      {{ tab.label }}
      <span class="close" @click.stop="closeTab(tab.name)">×</span>
    </div>
  </div>

  <div class="tab-content">
    <KeepAlive :include="cachedTabs" :max="10">
      <component :is="currentTabComponent" :key="currentTab" />
    </KeepAlive>
  </div>
</template>

<script setup>
import { ref, computed, watch } from "vue";
import UserList from "./UserList.vue";
import OrderList from "./OrderList.vue";
import Settings from "./Settings.vue";

const tabs = ref([
  { name: "UserList", label: "用户管理", component: UserList },
  { name: "OrderList", label: "订单管理", component: OrderList },
  { name: "Settings", label: "系统设置", component: Settings },
]);

const currentTab = ref("UserList");
const cachedTabs = ref(["UserList", "OrderList"]); // 只缓存特定标签

const currentTabComponent = computed(() => {
  const tab = tabs.value.find((t) => t.name === currentTab.value);
  return tab?.component;
});

function closeTab(tabName) {
  // 关闭标签时从缓存列表移除
  const index = cachedTabs.value.indexOf(tabName);
  if (index > -1) {
    cachedTabs.value.splice(index, 1);
  }
  // 切换到其他标签...
}
</script>

7.2 表单数据保持

<template>
  <KeepAlive :include="['UserForm']">
    <UserForm v-if="showForm" @submit="handleSubmit" />
    <UserDetail v-else :user="currentUser" @edit="showForm = true" />
  </KeepAlive>
</template>

<script setup>
import { ref } from "vue";
import UserForm from "./UserForm.vue";
import UserDetail from "./UserDetail.vue";

const showForm = ref(true);
const currentUser = ref(null);

function handleSubmit(userData) {
  // 提交表单后切换到详情页
  currentUser.value = userData;
  showForm.value = false;
}
</script>

7.3 列表页状态保持

<!-- ListPage.vue -->
<template>
  <div class="list-page">
    <!-- 搜索条件 -->
    <SearchForm v-model="searchParams" @search="handleSearch" />

    <!-- 列表 -->
    <div class="list-container" ref="listRef">
      <div
        v-for="item in listData"
        :key="item.id"
        class="list-item"
        @click="goToDetail(item)"
      >
        {{ item.name }}
      </div>
    </div>

    <!-- 分页 -->
    <Pagination v-model:page="page" v-model:size="pageSize" :total="total" />
  </div>
</template>

<script setup>
import { ref, onActivated, onDeactivated } from "vue";
import { useRouter } from "vue-router";

const router = useRouter();
const listRef = ref(null);

// 状态数据
const searchParams = ref({});
const listData = ref([]);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const scrollTop = ref(0);

// 激活时恢复状态
onActivated(() => {
  // 恢复滚动位置
  if (listRef.value) {
    listRef.value.scrollTop = scrollTop.value;
  }

  // 可选:刷新数据(如果需要保持最新)
  // fetchData()
});

// 失活时保存状态
onDeactivated(() => {
  if (listRef.value) {
    scrollTop.value = listRef.value.scrollTop;
  }
});

function goToDetail(item) {
  router.push(`/detail/${item.id}`);
}

async function handleSearch() {
  // 搜索逻辑...
}
</script>

8. 性能优化建议

8.1 合理设置 max

<!-- ❌ 不设置 max,可能无限增长导致内存泄漏 -->
<KeepAlive>
  <component :is="currentTab" />
</KeepAlive>

<!-- ✅ 根据业务场景设置合理的 max -->
<KeepAlive :max="5">
  <component :is="currentTab" />
</KeepAlive>

8.2 使用 include/exclude 精确控制

<!-- 只缓存必要的组件,减少内存占用 -->
<KeepAlive :include="['UserList', 'OrderList']" :max="5">
  <component :is="currentTab" />
</KeepAlive>

8.3 避免缓存大型组件

<script setup>
// 对于包含大量数据或复杂图表的组件,考虑不缓存
defineOptions({
  name: "HeavyDataChart", // 在 exclude 中排除
});
</script>

8.4 及时清理缓存

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

const includeList = ref(["TabA", "TabB", "TabC"]);
const currentTab = ref("TabA");
const keepAliveRef = ref(null);

// 方法1:通过修改 include 排除特定组件
function clearCache(componentName) {
  const index = includeList.value.indexOf(componentName);
  if (index > -1) {
    includeList.value.splice(index, 1);
  }
}

// 方法2:使用 v-if 强制重新创建 KeepAlive(清空所有缓存)
async function clearAllCache() {
  keepAliveRef.value = false;
  await nextTick();
  keepAliveRef.value = true;
}
</script>

<template>
  <KeepAlive v-if="keepAliveRef" :include="includeList">
    <component :is="currentTab" />
  </KeepAlive>
</template>

9. 常见问题与避坑指南

9.1 组件 name 未设置导致缓存失效

<script setup>
// ❌ 错误:没有设置 name,include/exclude 无法匹配
// 组件会被缓存,但无法通过 include/exclude 控制

// ✅ 正确:显式设置 name
defineOptions({
  name: "MyComponent",
});
</script>

9.2 动态组件 key 问题

<template>
  <!-- ❌ 错误:key 变化会导致缓存失效 -->
  <KeepAlive>
    <component :is="currentTab" :key="Date.now()" />
  </KeepAlive>

  <!-- ✅ 正确:使用稳定的 key 或组件名作为 key -->
  <KeepAlive>
    <component :is="currentTab" :key="currentTab" />
  </KeepAlive>
</template>

9.3 异步组件的缓存

<script setup>
import { defineAsyncComponent } from "vue";

const AsyncComp = defineAsyncComponent(() => import("./AsyncComp.vue"));
</script>

<template>
  <!-- ✅ 异步组件也可以被缓存 -->
  <KeepAlive>
    <AsyncComp />
  </KeepAlive>
</template>

9.4 与 Transition 一起使用

<template>
  <!-- ✅ KeepAlive 应该包裹在 Transition 内部 -->
  <Transition name="fade" mode="out-in">
    <KeepAlive>
      <component :is="currentTab" />
    </KeepAlive>
  </Transition>

  <!-- ❌ 不要这样:KeepAlive 包裹 Transition -->
</template>

9.5 缓存后数据不更新问题

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

const data = ref([]);

// ✅ 在 onActivated 中刷新数据
onActivated(() => {
  // 组件从缓存激活时,重新获取最新数据
  fetchLatestData();
});

// 或者使用 watch 监听路由参数变化
import { watch } from "vue";
import { useRoute } from "vue-router";

const route = useRoute();

watch(
  () => route.params.id,
  (newId) => {
    if (newId) {
      fetchData(newId);
    }
  },
  { immediate: true },
);
</script>

10. 总结与思考

10.1 核心要点回顾

要点 说明
缓存机制 使用 Map 存储 VNode,Set 管理 LRU 顺序
状态标记 COMPONENT_SHOULD_KEEP_ALIVECOMPONENT_KEPT_ALIVE shapeFlag
隐藏实现 通过 move 函数将 DOM 移入隐藏的 div 容器
生命周期 onActivated / onDeactivated 用于状态恢复和保存
淘汰策略 LRU 算法,当缓存超过 max 时淘汰最久未使用的组件

10.2 设计思想

KeepAlive 的设计体现了 Vue3 的几个重要思想:

  1. 声明式编程:开发者只需声明要缓存的组件,无需关心实现细节
  2. 可组合性:与动态组件、Transition、异步组件无缝配合
  3. 性能优先:LRU 策略防止内存无限增长,DOM 移动而非重建保证性能
  4. 扩展性:通过 include / exclude 提供精细的控制能力

10.3 思考题

  1. 为什么 KeepAlive 使用 DOM 移动而不是 display: none

    • 提示:考虑 CSS 样式继承、布局计算、内存占用等因素
  2. 如何实现一个自定义的缓存策略(如 FIFO)?

    • 提示:研究 KeepAlive 的源码结构,尝试扩展
  3. KeepAlive 与 Pinia/Vuex 状态管理如何配合?

    • 思考:什么时候用 KeepAlive 缓存状态,什么时候用全局状态管理?
  4. 在 SSR 场景下,KeepAlive 会有什么问题?

    • 提示:服务端没有 DOM,组件如何"失活"?

📚 扩展阅读

  1. Vue3 官方文档 - KeepAlive
  2. Vue3 源码解读 - KeepAlive 实现
  3. LRU 缓存算法详解
  4. Vue3 渲染器原理

💡 如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。

AI 打字跟随优化

2026年4月16日 09:37

前文提到通过API去监听滚动容器或容器尺寸去触发打字跟随,监听用户的滚动去读取造成重排的属性来实现用户是否跟随打字实际会造成浏览器多次重排。

重排

浏览器重排(回流)是浏览器对DOM元素计算位置、尺寸、布局的过程。 浏览器解析HTML合成DOM树,解析CSS合成CSSOM树,它们会一步步合成渲染树,进行布局。页面、结构、尺寸发生变化就会再走一遍布局的计算流程,称为重排。

为什么重排会造成性能开销?

  • 当一个元素变化后,可能影响父元素、子元素、兄弟元素等,浏览器需要进行递归遍历。
  • 重排需要在浏览器主线程发生,阻塞JS、渲染。
  • 频繁重排会造成浏览器卡顿,浏览器的刷新率是60fps,每帧近16ms,一次重排就会占据一部分时间;多次重排会导致掉帧。

哪些操作会导致重排?

  • 元素几何属性发生变化。
  • 增删、移动DOM。
  • 窗口变化(resize、scroll页面等)
  • 获取布局相关属性:
    • offsetTop / offsetLeft / offsetWidth / offsetHeight

    • scrollTop / scrollHeight

    • clientTop / clientWidth

    • getComputedStyle()

    • getBoundingClientRect()

IntersectionObserver 哨兵模式

这里直接取消滚动事件的监听,在容器的最底部放一个哨兵容器,通过 IntersectionObserver 去监听哨兵在监听的父元素在可视区域的交叉值来判断用户是否滚动。让哨兵通过 scrollIntoView 直接暴露在可视区域,实现打字跟随。

<div class="chat-scroll-container" ref="scrollContainerRef">
    <div class="chat-container" id="messagesRef"></div>
    <div class="scroll-sentinel" ref="sentinelRef"></div> // 哨兵
</div>

threshold: 1 // 1 :表示全部进入,0 :露头就秒

onMounted(() => {
  const ro = new ResizeObserver(() => {
    if (enableAutoScroll.value) {
      scrollToBottom();
    }
  });
  ro.observe(chatMessagesRef.value);

  observer = new IntersectionObserver(
    (entries) => {
      const isIntersecting = entries[0].isIntersecting;
      enableAutoScroll.value = isIntersecting; 
    },
    {
      root: scrollContainerRef.value, // 监听父元素,默认为 root
      threshold: 1,       // 1表示全部进入,0 :露头就秒
      rootMargin: '10px', // 提前10px开始生效
    }
  );

  if (sentinelRef.value) observer.observe(sentinelRef.value);
});

  
  const scrollToBottom = () => {
  nextTick(() => {
    const el = sentinelRef.value;
    if (!el) return;
    if (enableAutoScroll.value) {
      sentinelRef.value.scrollIntoView({ behavior: 'instant' });
    }
  });
};

在实践过程中,如果使用 behavior: 'smooth' ,浏览器在触发 scrollToBottom 时发生的动画会频繁抖动,将哨兵挤到父容器的可视区域外,导致 IntersectionObserver 频繁触发,可能产生 bug,使用 instant 取消浏览器动画抖动,直接抵达底部,避免该情况产生。

❌
❌