阅读视图

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

Vue 封装 Echarts 组件

为了方便在不同页面使用 echarts,可以封装一个组件。如果不封装,也可以手动实例化 echarts,并且额外处理监听容器尺寸变化的功能。

<script setup lang="ts">
import { useResizeObserver } from "@vueuse/core";
import type { EChartsOption } from "echarts";
import { init, type ECharts, type ECElementEvent } from "echarts/core";

const props = defineProps<{
  /** Echarts 图表配置选项 */
  options?: EChartsOption;
  /** 图表渲染器类型,默认为 svg */
  renderer?: "canvas" | "svg";
}>();
const emit = defineEmits<{
  chartClick: [event: ECElementEvent];
}>();

/** 图表容器元素的引用 */
const container = useTemplateRef("figure-element");
/** Echarts 图表实例 */
const chart = shallowRef<ECharts>();

defineExpose({
  container,
  chart,
});

/**
 * 监听图表配置变化并更新图表
 */
watchEffect(() => {
  if (!chart.value || !props.options) return;
  chart.value.setOption(props.options);
});

/**
 * 监听容器尺寸变化并自动调整图表大小
 */
useResizeObserver(container, () => {
  if (!chart.value || chart.value.isDisposed()) return;
  chart.value.resize();
});

/**
 * 初始化图表实例
 */
watch(container, (container) => {
  if (!container) return;

  const instance = init(container, undefined, {
    renderer: props.renderer || "svg",
    locale: "ZH",
  });

  /** 绑定图表点击事件 */
  instance.on("click", (event) => {
    emit("chartClick", event);
  });

  chart.value = instance;
  onWatcherCleanup(() => instance.dispose());
});
</script>

<template>
  <figure ref="figure-element" :class="$style.figure" />
</template>

<style module>
.figure {
  overflow: hidden;
}
</style>

然后在页面中使用。

<script setup lang="ts">
import type { EChartsOption } from "echarts";
import { BarChart, LineChart, PieChart, ScatterChart } from "echarts/charts";
import {
  GridComponent,
  LegendComponent,
  TitleComponent,
  TooltipComponent,
} from "echarts/components";
import { use } from "echarts/core";
import { UniversalTransition } from "echarts/features";
import { SVGRenderer } from "echarts/renderers";
import EchartsContainer from "@/components/Echarts/EchartsContainer.vue";

use([
  GridComponent,
  LineChart,
  BarChart,
  SVGRenderer,
  PieChart,
  ScatterChart,
  UniversalTransition,
  TitleComponent,
  TooltipComponent,
  LegendComponent,
]);

/** 基础柱状图配置 */
const barChartOption: EChartsOption = {
  tooltip: {
    trigger: "axis",
    axisPointer: {
      type: "shadow",
    },
  },
  xAxis: {
    type: "category",
    data: ["一月", "二月", "三月", "四月", "五月", "六月"],
    axisTick: {
      alignWithLabel: true,
    },
  },
  yAxis: {
    type: "value",
  },
  series: [
    {
      name: "销售额",
      type: "bar",
      data: [120, 200, 150, 80, 70, 110],
    },
  ],
};

/** 折线图配置 */
const lineChartOption: EChartsOption = {
  tooltip: {
    trigger: "axis",
  },
  legend: {
    data: ["新用户", "活跃用户"],
  },
  xAxis: {
    type: "category",
    data: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
  },
  yAxis: {
    type: "value",
  },
  series: [
    {
      name: "新用户",
      type: "line",
      data: [120, 132, 101, 134, 90, 230, 210],
      smooth: true,
      itemStyle: {
        color: "#67C23A",
      },
    },
    {
      name: "活跃用户",
      type: "line",
      data: [220, 182, 191, 234, 290, 330, 310],
      smooth: true,
      itemStyle: {
        color: "#E6A23C",
      },
    },
  ],
};

/** 饼图配置 */
const pieChartOption: EChartsOption = {
  tooltip: {
    trigger: "item",
    formatter: "{a} <br/>{b}: {c} ({d}%)",
  },
  legend: {
    orient: "vertical",
    left: "left",
  },
  series: [
    {
      name: "产品分类",
      type: "pie",
      radius: "50%",
      data: [
        { value: 1048, name: "电子产品" },
        { value: 735, name: "服装配饰" },
        { value: 580, name: "家居用品" },
        { value: 484, name: "食品饮料" },
        { value: 300, name: "其他" },
      ],
    },
  ],
};

/** 散点图配置 */
const scatterChartOption: EChartsOption = {
  tooltip: {
    trigger: "item",
  },
  xAxis: {
    type: "value",
    name: "X轴",
  },
  yAxis: {
    type: "value",
    name: "Y轴",
  },
  series: [
    {
      name: "数据点",
      type: "scatter",
      data: Array.from({ length: 50 }, () => [Math.random() * 100, Math.random() * 100]),
    },
  ],
};

/** 处理图表点击事件 */
const handleChartClick = (chartType: string) => {
  ElMessage.info(`点击了${chartType}图表`);
};
</script>

<template>
  <div :class="$style.container">
    <!-- 柱状图 -->
    <EchartsContainer
      :class="$style.chart"
      :options="barChartOption"
      @chart-click="handleChartClick('柱状图')"
    />

    <!-- 折线图 -->
    <EchartsContainer
      :class="$style.chart"
      :options="lineChartOption"
      @chart-click="handleChartClick('折线图')"
    />

    <!-- 饼图 -->
    <EchartsContainer
      :class="$style.chart"
      :options="pieChartOption"
      @chart-click="handleChartClick('饼图')"
    />

    <!-- 散点图 -->
    <EchartsContainer
      :class="$style.chart"
      :options="scatterChartOption"
      @chart-click="handleChartClick('散点图')"
    />
  </div>
</template>

<style module>
.container {
  padding: 2rem;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
}

.chart {
  height: 30rem;
}
</style>

Vue v-html 与 v-text 转 React:VuReact 怎么处理?

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

前置约定

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

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

编译对照

v-html:动态 HTML 内容渲染

v-html 是 Vue 中用于将 HTML 字符串动态渲染为 DOM 元素的指令,它会替换元素内的所有内容,并解析 HTML 标签。

  • Vue 代码:
<div v-html="htmlContent"></div>
  • VuReact 编译后 React 代码:
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />

从示例可以看到:Vue 的 v-html 指令被编译为 React 的 dangerouslySetInnerHTML 属性。VuReact 采用 HTML 注入编译策略,将模板指令转换为 React 的特殊属性,完全保持 Vue 的 HTML 渲染语义——将 htmlContent 字符串解析为 HTML 并插入到 DOM 中。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue v-html 的行为,直接渲染 HTML 字符串
  2. 安全警告:React 的 dangerouslySetInnerHTML 属性名本身就提醒开发者注意 XSS 攻击风险
  3. 内容替换:与 Vue 一样,会替换元素内的所有现有内容

v-text:纯文本内容渲染

v-text 是 Vue 中用于将纯文本内容设置到元素内的指令,它会替换元素内的所有内容,但不会解析 HTML 标签。

  • Vue 代码:
<p v-text="message"></p>
  • VuReact 编译后 React 代码:
<p>{message}</p>

从示例可以看到:Vue 的 v-text 指令被编译为 React 的 JSX 插值表达式。VuReact 采用 文本插值编译策略,将模板指令转换为 JSX 的大括号表达式,完全保持 Vue 的文本渲染语义——将 message 作为纯文本内容插入到元素中。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue v-text 的行为,渲染纯文本内容
  2. 自动转义:React 的 JSX 插值会自动转义 HTML 特殊字符,防止 XSS 攻击
  3. 内容替换:与 Vue 一样,会替换元素内的所有现有内容

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写内容渲染逻辑。编译后的代码既保持了 Vue 的语义,又符合 React 的安全最佳实践。

🔗 相关资源


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

Vue路由跳转全场景实战(Vue2+Vue3适配)| 新手必看

Vue路由跳转是前端项目页面切换的核心操作,贯穿整个Vue项目开发(从简单页面跳转,到带参跳转、权限控制跳转)。本文将整合Vue2、Vue3路由跳转的所有常用方式,明确不同场景的使用选择,补充参数传递、导航守卫、常见问题及解决方案,提供可直接复制的实战示例,兼顾新手入门与实战适配。

一、Vue路由跳转核心前提(必看)

无论Vue2还是Vue3,路由跳转前需确保已完成路由配置(引入Vue Router、创建路由实例、挂载路由),基础配置如下(简化版,可直接复用):

// Vue2 基础路由配置(router/index.js)
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)

const routes = [
  { path: '/', name: 'Home', component: () => import('../views/Home.vue') },
  { path: '/about', name: 'About', component: () => import('../views/About.vue') },
  { path: '/user/:id', name: 'User', component: () => import('../views/User.vue') }
]

const router = new VueRouter({ mode: 'history', routes })
export default router

// Vue3 基础路由配置(router/index.js)
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
  { path: '/', name: 'Home', component: () => import('../views/Home.vue') },
  { path: '/about', name: 'About', component: () => import('../views/About.vue') },
  { path: '/user/:id', name: 'User', component: () => import('../views/User.vue') }
]

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

说明:Vue2需通过Vue.use(VueRouter)注册路由,Vue3通过createRouter创建路由实例,二者跳转语法有细微差异,下文将分别说明并标注适配版本。

二、Vue路由跳转3种核心方式(实战常用)

Vue路由跳转主要分为「声明式跳转」和「编程式跳转」,其中编程式跳转更灵活,可结合业务逻辑(如登录判断)使用,声明式跳转适合简单页面切换。

方式1:声明式跳转( 标签,最简洁)

核心:通过Vue Router提供的<router-link>标签实现跳转,无需写JS逻辑,自动渲染为a标签(可通过tag属性修改标签类型),适配Vue2、Vue3,用法完全一致。

1. 基础跳转(无参数)

<!-- 方式1:通过path跳转(推荐,简洁直观) -->
<router-link to="/">首页</router-link>
<router-link to="/about">关于我们</router-link>

<!-- 方式2:通过name跳转(需配置路由name,适合路径较长场景) -->
<router-link :to="{ name: 'Home' }">首页</router-link>
<router-link :to="{ name: 'About' }">关于我们</router-link>

<!-- 可选:修改渲染标签(默认a标签,改为button) -->
<router-link to="/" tag="button">首页(按钮形式)</router-link>

2. 带参数跳转(query参数 / params参数)

跳转时传递参数,用于页面间数据交互,两种参数类型用法不同,需区分场景:

<!-- 1. query参数(暴露在URL上,可刷新保留,适合简单数据) -->
<!-- 方式:path/name + query对象 -->
<router-link :to="{ path: '/user', query: { id: 1, name: '张三' } }">
  进入用户页(query参数)
</router-link>
<router-link :to="{ name: 'User', query: { id: 1, name: '张三' } }">
  进入用户页(name+query)
</router-link>
<!-- 跳转后URL:http://localhost:8080/user?id=1&name=张三 -->

<!-- 2. params参数(不暴露在URL上,刷新丢失,适合敏感数据) -->
<!-- 注意:params必须配合name跳转,不能配合path -->
<router-link :to="{ name: 'User', params: { id: 1, name: '张三' } }">
  进入用户页(params参数)
</router-link>
<!-- 跳转后URL:http://localhost:8080/user/1(需配置路由path为/user/:id) -->

方式2:编程式跳转(router.push / router.replace,最灵活)

核心:通过JS代码调用router.push(保留历史记录)或router.replace(不保留历史记录,无法返回上一页)实现跳转,适合结合业务逻辑(如登录成功后跳转、按钮点击跳转),Vue2和Vue3用法略有差异。

1. Vue2 编程式跳转

// 1. 基础跳转(无参数)
this.$router.push('/') // path跳转
this.$router.push({ name: 'Home' }) // name跳转

// 2. 带参数跳转(query / params)
// query参数
this.$router.push({
  path: '/user',
  query: { id: 1, name: '张三' }
})
// params参数(需配合name)
this.$router.push({
  name: 'User',
  params: { id: 1, name: '张三' }
})

// 3. 替换跳转(不保留历史记录)
this.$router.replace('/about')

// 4. 后退/前进(操作历史记录)
this.$router.go(-1) // 后退1页(类似浏览器返回键)
this.$router.back() // 等同于go(-1)
this.$router.go(1) // 前进1页

2. Vue3 编程式跳转

Vue3 setup语法中,无this,需通过useRouter引入路由实例,用法如下:

// 1. 引入路由实例(必须先引入)
import { useRouter } from 'vue-router'
const router = useRouter()

// 2. 基础跳转(无参数)
router.push('/')
router.push({ name: 'Home' })

// 3. 带参数跳转(query / params)
router.push({
  path: '/user',
  query: { id: 1, name: '张三' }
})
router.push({
  name: 'User',
  params: { id: 1, name: '张三' }
})

// 4. 替换跳转(不保留历史记录)
router.replace('/about')

// 5. 后退/前进
router.go(-1)
router.back()
router.go(1)

方式3:路由重定向(redirect,自动跳转)

核心:在路由配置中通过redirect属性,实现页面自动跳转(无需用户操作),适合默认页面、404页面、旧路径跳转新路径场景,Vue2、Vue3用法一致。

// 路由配置中添加redirect
const routes = [
  // 1. 默认跳转(访问根路径,自动跳转到首页)
  { path: '/', redirect: '/home' },
  // 2. 通过name重定向
  { path: '/index', redirect: { name: 'Home' } },
  // 3. 旧路径跳转新路径(兼容旧链接)
  { path: '/old-user', redirect: '/user' },
  // 4. 404页面(匹配所有未定义路径,跳转到404组件)
  { path: '/:pathMatch(.*)*', redirect: '/404' }
]

三、路由跳转参数接收(配套必备)

跳转时传递的query/params参数,需在目标页面接收后使用,Vue2和Vue3接收方式不同,以下是完整示例:

1. Vue2 参数接收

// 1. 接收query参数
export default {
  mounted() {
    const id = this.$route.query.id // 接收query参数id
    const name = this.$route.query.name // 接收query参数name
    console.log(id, name) // 输出:1 张三
  }
}

// 2. 接收params参数
export default {
  mounted() {
    const id = this.$route.params.id // 接收params参数id
    const name = this.$route.params.name // 接收params参数name
    console.log(id, name) // 输出:1 张三
  }
}

2. Vue3 参数接收

Vue3 setup语法中,需通过useRoute引入路由对象,接收参数:

// 引入路由对象
import { useRoute } from 'vue-router'
const route = useRoute()

// 接收参数(可在setup中直接使用,或在生命周期中使用)
const id = route.query.id // query参数
const name = route.query.name

const paramsId = route.params.id // params参数
const paramsName = route.params.name

console.log(id, paramsId) // 输出:1 1

四、路由跳转进阶:导航守卫(权限控制)

实际开发中,常需要对路由跳转进行权限控制(如未登录不能访问个人中心),此时需使用导航守卫,拦截跳转并判断权限,Vue2、Vue3用法基本一致,以下是实战示例:

1. 全局导航守卫(控制所有路由跳转)

// Vue2 全局导航守卫(router/index.js)
router.beforeEach((to, from, next) => {
  // to:目标路由对象
  // from:当前跳转前的路由对象
  // next:放行/拦截方法
  
  // 示例:未登录不能访问/user路径
  const token = localStorage.getItem('token') // 模拟登录状态
  if (to.path === '/user' && !token) {
    next('/login') // 未登录,拦截并跳转到登录页
  } else {
    next() // 已登录,放行
  }
})

// Vue3 全局导航守卫(用法完全一致)
router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')
  if (to.meta.requireAuth && !token) { // 结合路由元信息,更灵活
    next('/login')
  } else {
    next()
  }
})

// 路由元信息配置(标记需要权限的路由)
const routes = [
  {
    path: '/user',
    name: 'User',
    component: () => import('../views/User.vue'),
    meta: { requireAuth: true } // 标记:需要登录才能访问
  }
]

2. 组件内导航守卫(控制单个组件跳转)

仅对当前组件的跳转进行拦截,适合单个组件的特殊权限控制:

// Vue2 组件内守卫
export default {
  // 进入组件前拦截
  beforeRouteEnter(to, from, next) {
    const token = localStorage.getItem('token')
    if (!token) {
      next('/login')
    } else {
      next()
    }
  },
  // 离开组件前拦截(如提示用户未保存内容)
  beforeRouteLeave(to, from, next) {
    if (confirm('确定要离开吗?内容未保存')) {
      next()
    } else {
      next(false) // 取消跳转
    }
  }
}

// Vue3 组件内守卫(setup语法)
import { onBeforeRouteEnter, onBeforeRouteLeave } from 'vue-router'

// 进入组件前拦截
onBeforeRouteEnter((to, from, next) => {
  const token = localStorage.getItem('token')
  if (!token) {
    next('/login')
  } else {
    next()
  }
})

// 离开组件前拦截
onBeforeRouteLeave((to, from, next) => {
  if (confirm('确定要离开吗?内容未保存')) {
    next()
  } else {
    next(false)
  }
})

五、路由跳转常见问题及解决方案

1. 跳转后页面不刷新

原因:路由参数变化(如从/user/1跳转到/user/2),组件会复用,不会重新触发mounted生命周期。

解决方案:监听路由变化,触发数据重新请求:

// Vue2 监听路由
watch: {
  '$route'(to, from) {
    // 路由变化时,重新请求数据
    this.getUserData(to.params.id)
  }
}

// Vue3 监听路由
import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()

watch(
  () => route.params,
  (newParams) => {
    // 监听params变化,重新请求数据
    getUserData(newParams.id)
  },
  { deep: true }
)

2. params参数刷新后丢失

原因:params参数不暴露在URL上,页面刷新后,路由信息重置,参数丢失。

解决方案:1. 改用query参数(适合非敏感数据);2. 将params参数存储到localStorage/sessionStorage,刷新后重新读取。

3. 路由跳转后,URL正确但页面空白

常见原因:1. 路由配置错误(path拼写错误、component路径错误);2. 未在页面中添加<router-view>标签(路由出口,用于渲染跳转后的组件)。

解决方案:核对路由path和component路径,确保App.vue中包含<router-view>

<!-- App.vue 必须添加路由出口 -->
<template>
  <div id="app">
    <router-link to="/"&gt;首页&lt;/router-link&gt;
    &lt;router-view /&gt; <!-- 路由跳转后的组件会渲染在这里 -->
  </div>
</template>

4. Vue3中报错“Cannot read property 'push' of undefined”

原因:Vue3 setup语法中,未通过useRouter引入路由实例,直接使用this.$router(setup中无this)。

解决方案:正确引入useRouter,创建路由实例后再使用:

// 正确用法
import { useRouter } from 'vue-router'
const router = useRouter()
router.push('/about') // 无报错

六、总结

Vue路由跳转核心分为3种方式,结合场景选择即可:

  • 简单页面切换:用声明式跳转() ,简洁高效;
  • 需结合业务逻辑(登录、判断):用编程式跳转(router.push) ,灵活可控;
  • 自动跳转(默认页、404):用路由重定向(redirect) ,无需用户操作。

关键注意点:Vue2和Vue3的跳转语法差异主要在“是否使用this”,Vue3需通过useRouter/useRoute引入路由实例和路由对象;参数传递需区分query(刷新保留)和params(刷新丢失);权限控制用导航守卫,避免未授权访问。

本文所有示例均可直接复制到项目中使用,只需根据自身项目的路由配置,修改路径和组件名称即可快速适配。

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

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 怎么处理?

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 项目高德地图性能优化实战:从卡死到丝滑的完整过程

几千个点位一次渲染就卡爆浏览器?路由切换越用越慢直到内存崩溃?本文将完整还原 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 路由?

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?

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 组件库实战技巧与踩坑记录

🎨 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个组件

🏗️ 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 #组件化 #架构设计 #前端 #代码重构

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

vue3 数据响应式遇到的问题

问题背景

是在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大模型能生成文本内容,那你知道大模型是怎样生成文本的吗?

举个例子

想象一下,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 自定义数字行主键,解决默认字符串主键与后端类型不匹配问题

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 组件间通信

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 主题构建方案

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?

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

前言

我们从前几篇文章中学到了数据响应式的实现原理,虽然它们的实现方式并不相同,但本质原理都是一样的,都是在数据读取的时候进行依赖收集,在数据更改的时候触发依赖。我们知道在 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 的前端工具链

写在前面

掘金的同学们大家好呀,作者是 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 动态路由加载方法实现

最近在改老项目,将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

❌