阅读视图

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

Vue3 + Element Plus 全局 Message、Notification 封装与规范|Vue生态精选

前端实战:Vue3 + Element Plus 全局 Message、Notification 封装教程,从概念区分、场景选择到统一错误处理、代码落地,一站式学会前端提示框封装,告别混乱代码与重复开发。

📑 文章目录


同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、我们为什么要封装?

很多同学会直接这样写:

// 散落在业务里的各种提示
this.$message.success('保存成功')
ElMessage.error('网络错误')
alert('操作失败')  // 甚至还有人用 alert

看起来能用,但会带来这些问题:

  • 提示风格不统一:有的用 Message,有的用 Notification,有的用 alert
  • 错误处理分散:每个接口各自 try-catch 各自 message
  • 难以维护:改文案、改样式、加埋点,要改很多地方
  • 用户体验差:错误提示不统一,成功/失败没规范

所以需要:把通知和消息系统统一封装,集中管理风格和错误处理

⬆ 返回目录

二、概念扫盲:Message / Notification / Toast 有啥区别?

类型 特点 典型场景
Message 轻量、短暂、通常居中或顶部,自动消失 操作结果反馈:保存成功、删除成功
Notification 带标题、正文,可带操作按钮,位置可配置 系统通知、任务完成、重要提示
Toast 和 Message 概念接近,有些库叫 Toast 同上,多用于移动端

可以简单记:Message 偏轻量,Notification 偏正式、信息更多。封装时建议:

  • 简单反馈 → Message
  • 需要标题、描述、操作 → Notification

⬆ 返回目录

三、典型使用场景

  1. 接口成功/失败:统一用 Message,成功/警告/错误三种类型
  2. 表单校验失败:一般用 Message,文案来自校验规则
  3. 全局错误:如 401、403、500 → 统一错误处理 + Message/Notification
  4. 长时间任务完成:如导出、报表生成 → 用 Notification 更合适
  5. 业务重要事件:如订单状态变更 → Notification + 操作入口

⬆ 返回目录

四、封装思路:三层结构

┌─────────────────────────────────────┐
│  业务层:直接调用 msg.success() 等  
├─────────────────────────────────────┤
│  封装层:msg / notify 统一入口      
│  - 统一风格                       
│  - 统一文案模板                   
│  - 统一埋点/日志                 
├─────────────────────────────────────┤
│  底层:Element Plus / Ant Design 等
└─────────────────────────────────────┘

业务层只调用封装好的 API,不直接接触 UI 库。

⬆ 返回目录

五、统一风格:主题、样式、交互

5.1 风格统一要管什么?

  • 类型:success / warning / error / info
  • 位置:如 Message 顶部居中,Notification 右上角
  • 持续时间:成功 2s,错误 4s 等
  • 样式:颜色、圆角、阴影等
  • 防重复:相同文案不重复弹

⬆ 返回目录

5.2 示例:统一配置

// src/utils/message.config.js

/**
 * Message 统一配置
 * 所有地方用 Message 时都走这套配置,保证风格一致
 */
export const MESSAGE_CONFIG = {
  duration: 2000,           // 默认 2 秒消失
  showClose: false,         // 不显示关闭按钮,靠自动消失
  center: true,             // 水平居中
  offset: 80,               // 距离顶部的距离
  grouping: true,           // 相同内容合并显示,避免刷屏
}

/**
 * 不同类型建议的 duration
 * 成功可以短一点,错误要留足阅读时间
 */
export const DURATION_BY_TYPE = {
  success: 2000,
  warning: 3000,
  error: 4000,
  info: 2500,
}

⬆ 返回目录

六、统一错误处理:拦截、提示、降级

6.1 核心思路

  • HTTP 拦截器:统一捕获 401、403、500 等
  • 业务错误码映射:后端错误码 → 前端文案
  • 兜底:网络异常、超时等给出通用提示

⬆ 返回目录

6.2 错误码与文案映射示例

// src/utils/errorCodeMap.js

/**
 * 后端错误码 → 前端展示文案
 * 避免把后端原始错误直接抛给用户
 */
export const ERROR_CODE_MAP = {
  401: '登录已过期,请重新登录',
  403: '没有权限执行此操作',
  404: '请求的资源不存在',
  500: '服务器异常,请稍后重试',
  10001: '参数错误',
  10002: '数据已存在',
  // ... 按你们项目补充
}

/**
 * 根据错误码获取友好提示
 */
export function getErrorMessage(code, defaultMsg = '操作失败,请稍后重试') {
  return ERROR_CODE_MAP[code] || defaultMsg
}

⬆ 返回目录

6.3 在 axios 里用

// src/api/request.js 示意

import axios from 'axios'
import { ElMessage } from 'element-plus'
import { getErrorMessage } from '@/utils/errorCodeMap'

const request = axios.create({
  baseURL: '/api',
  timeout: 10000,
})

// 响应拦截器:统一错误处理
request.interceptors.response.use(
  (response) => {
    const { code, data, message } = response.data
    // 假设业务成功是 code === 0
    if (code !== 0) {
      ElMessage.error(getErrorMessage(code, message))
      return Promise.reject(new Error(message))
    }
    return data
  },
  (error) => {
    if (error.response) {
      const { status } = error.response
      const msg = getErrorMessage(status)
      ElMessage.error(msg)
      // 401 可以在这里跳转登录
      if (status === 401) {
        // router.push('/login')
      }
    } else {
      ElMessage.error('网络异常,请检查网络后重试')
    }
    return Promise.reject(error)
  }
)

export default request

⬆ 返回目录

七、完整封装示例(Vue 3 + Element Plus)

7.1 封装文件结构

src/
├── utils/
│   ├── message.config.js    # 配置
│   ├── errorCodeMap.js      # 错误码映射
│   └── message.js           # 封装入口

⬆ 返回目录

7.2 封装实现

// src/utils/message.js

import { ElMessage, ElNotification } from 'element-plus'
import { MESSAGE_CONFIG, DURATION_BY_TYPE } from './message.config'
import { getErrorMessage } from './errorCodeMap'

/**
 * 全局 Message 封装
 * 统一风格、统一入口,方便以后替换 UI 库或加埋点
 */

function createMessage(type) {
  return (content, duration) => {
    ElMessage({
      ...MESSAGE_CONFIG,
      type,
      message: typeof content === 'string' ? content : content?.message || '操作成功',
      duration: duration ?? DURATION_BY_TYPE[type] ?? MESSAGE_CONFIG.duration,
    })
  }
}

// 对外暴露的 API
export const msg = {
  success: createMessage('success'),
  warning: createMessage('warning'),
  error: createMessage('error'),
  info: createMessage('info'),
}

/**
 * 全局 Notification 封装
 * 适合需要标题、描述、操作按钮的场景
 */
export const notify = {
  success(title, message, options = {}) {
    ElNotification({
      type: 'success',
      title: title || '成功',
      message: message || '',
      duration: 4000,
      position: 'top-right',
      ...options,
    })
  },
  error(title, message, options = {}) {
    ElNotification({
      type: 'error',
      title: title || '错误',
      message: message || '',
      duration: 5000,
      position: 'top-right',
      ...options,
    })
  },
  // warning、info 同理...
}

/**
 * 统一错误提示入口
 * 支持:错误码、Error 对象、字符串
 */
export function showError(error) {
  let message = '操作失败,请稍后重试'
  if (typeof error === 'number') {
    message = getErrorMessage(error)
  } else if (error?.message) {
    message = error.message
  } else if (typeof error === 'string') {
    message = error
  }
  msg.error(message)
}

⬆ 返回目录

7.3 业务里怎么用

// 业务组件里
import { msg, notify, showError } from '@/utils/message'

// 简单成功反馈
msg.success('保存成功')

// 接口失败时(如果拦截器没处理,可以手动调)
try {
  await saveData()
  msg.success('保存成功')
} catch (e) {
  showError(e)
}

// 重要通知
notify.success('导出完成', '您的报表已生成,请到下载中心查看')

⬆ 返回目录

7.4 全局挂载(可选)

// main.js
import { msg, notify, showError } from '@/utils/message'

app.config.globalProperties.$msg = msg
app.config.globalProperties.$notify = notify
app.config.globalProperties.$showError = showError

// 组件内:this.$msg.success('保存成功')

⬆ 返回目录

八、常见坑点与排查思路

8.1 同一个提示狂弹

  • 原因:接口失败在循环/频繁请求里被多次触发。
  • 做法:开启 grouping,或在封装层做「相同文案节流」。

⬆ 返回目录

8.2 样式跟项目不一致

  • 原因:直接用了 UI 库默认主题,或部分地方用内联样式覆盖。
  • 做法:所有 Message/Notification 都走封装层,在封装里统一传入配置,必要时用 CSS 变量或主题覆盖。

⬆ 返回目录

8.3 错误提示内容太“技术”

  • 原因:直接把后端 messageError 文本展示给用户。
  • 做法:用错误码映射表,把技术信息转成用户可读文案。

⬆ 返回目录

8.4 封装后换 UI 库很痛苦

  • 原因:业务里到处直接调用 ElMessageElNotification
  • 做法:业务只依赖 msgnotify,底层实现集中在 message.js,换库只改这一层。

⬆ 返回目录

8.5 在 setup 里没有 this

  • 做法:用 import { msg } from '@/utils/message' 直接引入,不依赖 this.$msg

⬆ 返回目录

九、实战规范总结

规范 说明
统一入口 只用 msg / notify,不直接调用 UI 库
统一风格 通过 message.config.js 统一 duration、位置、样式
统一错误处理 用错误码映射 + axios 拦截器,业务少写 try-catch
类型区分 简单反馈用 Message,复杂通知用 Notification
文案友好 错误码转成用户能看懂的话,不暴露技术细节
可扩展 封装层预留埋点、日志、国际化等扩展点

⬆ 返回目录

十、小结

封装全局 Message / Notification 的核心是:

  1. 统一入口:所有提示都从 msg / notify 走。
  2. 统一风格:配置集中管理,避免到处写死。
  3. 统一错误处理:拦截器 + 错误码映射,减少重复代码。
  4. 把用户当小白:错误文案要易懂,不吓人。

⬆ 返回目录


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

vue中怎么监测一个div的宽度变化

在 Vue 中监测一个 div 的宽度变化,可以使用以下几种方法,主要结合 ResizeObserver 或其他方式来实现动态监听。以下是具体实现方案:

方法 1:使用 ResizeObserver

ResizeObserver 是现代浏览器提供的 API,专门用于监听元素尺寸变化。它性能高效,适合动态监测 div 的宽度变化。

<template>
  <div ref="targetDiv" class="target-div">
    这是一个可调整大小的 div
  </div>
</template>

<script>
export default {
  data() {
    return {
      divWidth: 0,
    };
  },
  mounted() {
    // 创建 ResizeObserver 实例
    const observer = new ResizeObserver((entries) => {
      for (let entry of entries) {
        // 获取 div 的宽度
        this.divWidth = entry.contentRect.width;
        console.log('Div 宽度变化:', this.divWidth);
      }
    });

    // 监听目标 div
    observer.observe(this.$refs.targetDiv);
    
    // 组件销毁时清理 observer
    this.$on('hook:beforeDestroy', () => {
      observer.disconnect();
    });
  },
};
</script>

<style>
.target-div {
  width: 200px;
  height: 100px;
  background: lightblue;
  resize: horizontal; /* 允许水平拖动调整大小 */
  overflow: auto;
}
</style>

说明

  • ResizeObserver 会在 div 尺寸变化时触发回调,获取最新的宽度。
  • 使用 this.$refs.targetDiv 获取 DOM 元素。
  • 在组件销毁时调用 observer.disconnect() 清理监听,避免内存泄漏。
  • resize: horizontal 是 CSS 属性,方便测试宽度调整(需要配合 overflow: auto)。

方法 2:结合 Vue 的 watch 监听动态宽度

如果 div 的宽度是由响应式数据(如 style 或计算属性)控制的,可以通过 watch 监听相关数据的变化。

<template>
  <div :style="{ width: divWidth + 'px' }" class="target-div">
    宽度: {{ divWidth }}px
  </div>
</template>

<script>
export default {
  data() {
    return {
      divWidth: 200,
    };
  },
  watch: {
    divWidth(newWidth) {
      console.log('Div 宽度变化:', newWidth);
    },
  },
};
</script>

<style>
.target-div {
  height: 100px;
  background: lightcoral;
}
</style>

说明

  • 适用于宽度由 Vue 响应式数据驱动的场景。
  • 如果宽度变化是由外部(如用户拖动或 CSS)引起的,这种方法不适用。

方法 3:使用 window resize 事件(间接监测)

如果 div 的宽度变化与窗口大小相关(例如百分比宽度),可以监听 windowresize 事件。

<template>
  <div ref="targetDiv" class="target-div">
    这是一个宽度随窗口变化的 div
  </div>
</template>

<script>
export default {
  data() {
    return {
      divWidth: 0,
    };
  },
  methods: {
    updateWidth() {
      this.divWidth = this.$refs.targetDiv.offsetWidth;
      console.log('Div 宽度:', this.divWidth);
    },
  },
  mounted() {
    this.updateWidth(); // 初始化宽度
    window.addEventListener('resize', this.updateWidth);
    
    // 清理事件监听
    this.$on('hook:beforeDestroy', () => {
      window.removeEventListener('resize', this.updateWidth);
    });
  },
};
</script>

<style>
.target-div {
  width: 50%; /* 宽度随窗口变化 */
  height: 100px;
  background: lightgreen;
}
</style>

说明

  • 适合 div 宽度依赖窗口大小的场景(如 width: 50%)。
  • 使用 offsetWidth 获取 div 的实际宽度。
  • 注意清理事件监听以防止内存泄漏。

方法 4:使用第三方库(如 element-resize-detector)

如果需要兼容旧浏览器或更复杂的场景,可以使用第三方库如 element-resize-detector

  1. 安装库:

    npm install element-resize-detector
    
  2. 在 Vue 组件中使用:

<template>
  <div ref="targetDiv" class="target-div">
    这是一个可调整大小的 div
  </div>
</template>

<script>
import elementResizeDetectorMaker from 'element-resize-detector';

export default {
  data() {
    return {
      divWidth: 0,
    };
  },
  mounted() {
    const erd = elementResizeDetectorMaker();
    erd.listenTo(this.$refs.targetDiv, (element) => {
      this.divWidth = element.offsetWidth;
      console.log('Div 宽度变化:', this.divWidth);
    });

    // 清理监听
    this.$on('hook:beforeDestroy', () => {
      erd.removeAllListeners(this.$refs.targetDiv);
    });
  },
};
</script>

<style>
.target-div {
  width: 200px;
  height: 100px;
  background: lightyellow;
  resize: horizontal;
  overflow: auto;
}
</style>

说明

  • element-resize-detector 提供了跨浏览器兼容的尺寸变化监听。
  • 适合不支持 ResizeObserver 的旧浏览器。

推荐方案

  • 首选 ResizeObserver:现代、性能高、代码简洁,适合大多数场景。
  • 如果 div 宽度由响应式数据控制,使用 watch
  • 如果宽度与窗口大小相关,使用 window resize 事件。
  • 如果需要兼容旧浏览器,考虑 element-resize-detector

注意事项

  1. 性能:避免在大量元素上绑定监听,可能导致性能问题。
  2. 清理:总是清理 ResizeObserver、事件监听或第三方库的绑定,防止内存泄漏。
  3. 浏览器兼容性ResizeObserver 在现代浏览器(Chrome 64+、Firefox 69+ 等)支持良好,旧浏览器需 polyfill 或使用第三方库。

拒绝 Prop Drilling 与隐式耦合:Vue 组件通讯的全景指南与最佳实践

在 Vue.js 开发中,组件是构建用户界面的基本单元。一个复杂的应用通常由多个组件嵌套组成,而这些组件之间需要频繁地进行数据交换和事件通知,这就是组件通讯。掌握各种组件通讯方式,对于构建可维护、可扩展的 Vue 应用至关重要。

本文将详细介绍 Vue 2 和 Vue 3 中常用的组件通讯方式,并提供实用的代码示例。

一、父子组件通讯

1. Props(父传子)

props 是最基础的父子组件通讯方式,父组件通过属性向子组件传递数据。

Vue 3 示例:

<!-- 父组件 Parent.vue -->
<template>
  <ChildComponent :message="parentMessage" :count="42" />
</template>

<script setup>
import ChildComponent from './ChildComponent.vue'
import { ref } from 'vue'

const parentMessage = ref('Hello from Parent')
</script>
<!-- 子组件 ChildComponent.vue -->
<template>
  <div>
    <p>{{ message }}</p>
    <p>Count: {{ count }}</p>
  </div>
</template>

<script setup>
defineProps({
  message: {
    type: String,
    required: true
  },
  count: {
    type: Number,
    default: 0
  }
})
</script>

最佳实践:

  • 始终为 props 定义类型验证
  • 避免在子组件中直接修改 props(单向数据流原则)
  • 使用默认值处理可选 props

2. Emit(子传父)

子组件通过 $emit 触发事件,将数据传递给父组件。

Vue 3 示例:

<!-- 子组件 ChildComponent.vue -->
<template>
  <button @click="sendMessage">Send to Parent</button>
</template>

<script setup>
const emit = defineEmits(['custom-event', 'update:modelValue'])

const sendMessage = () => {
  emit('custom-event', { data: 'Hello from Child', timestamp: Date.now() })
}
</script>
<!-- 父组件 Parent.vue -->
<template>
  <ChildComponent @custom-event="handleChildEvent" />
</template>

<script setup>
import ChildComponent from './ChildComponent.vue'

const handleChildEvent = (payload) => {
  console.log('Received from child:', payload)
}
</script>

Vue 3.3+ 新特性:  可以使用 defineModel 简化双向绑定:

<!-- 子组件 -->
<script setup>
const modelValue = defineModel() // 自动处理 props 和 emit
</script>

<template>
  <input v-model="modelValue" />
</template>

二、兄弟组件通讯

兄弟组件之间没有直接的通讯方式,通常需要通过共同的父组件作为中介。

方案:状态提升到父组件

<!-- 父组件 -->
<template>
  <div>
    <SiblingA :shared-data="sharedData" @update-data="updateSharedData" />
    <SiblingB :shared-data="sharedData" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import SiblingA from './SiblingA.vue'
import SiblingB from './SiblingB.vue'

const sharedData = ref('Initial data')

const updateSharedData = (newData) => {
  sharedData.value = newData
}
</script>

三、跨层级组件通讯

1. Provide / Inject

适用于祖孙组件或多层嵌套场景,避免 props 逐层传递(prop drilling)。

Vue 3 示例:

<!-- 祖先组件 -->
<template>
  <div>
    <DeepChild />
  </div>
</template>

<script setup>
import { provide, ref } from 'vue'
import DeepChild from './DeepChild.vue'

const theme = ref('dark')
const user = ref({ name: 'Alice', role: 'admin' })

provide('theme', theme)
provide('user', user)
</script>
<!-- 后代组件(任意层级) -->
<template>
  <div>
    <p>Theme: {{ theme }}</p>
    <p>User: {{ user.name }}</p>
  </div>
</template>

<script setup>
import { inject } from 'vue'

const theme = inject('theme')
const user = inject('user')
</script>

注意事项:

  • provide/inject 不是响应式的,除非传递的是响应式对象(ref/reactive)
  • 过度使用会降低组件的可复用性
  • 适合全局配置、主题等场景
“不建议随意使用”或“慎用”的提示,主要是因为它破坏了组件的封装性和可维护性。以下是具体原因的深度解析:

1. 破坏了组件的显式依赖(耦合度高)

  • 问题:使用 props 和 emits 时,组件的输入和输出在代码中是显式声明的。阅读父组件代码,你一眼就能看出子组件需要什么数据、会触发什么事件。

  • 对比provide/inject 建立了一种隐式依赖

    • 祖先组件提供了数据,但不知道哪些后代组件使用了它。
    • 后代组件注入了数据,但不知道数据具体来自哪个祖先组件(只知道 key)。
  • 后果:当项目变大时,这种隐式连接会让数据流向变得难以追踪(“魔术字符串”问题)。如果你修改了 provide 中的某个值,可能会意外影响到深层嵌套中多个未知的组件,导致“牵一发而动全身”。

2. 降低了组件的可复用性

  • 问题:一个高度依赖 inject 的组件,必须要在特定的祖先组件环境下才能正常工作。

  • 后果:如果你想把这个组件复用到另一个页面或另一个项目中,如果那个环境没有提供对应的 provide,组件就会报错或行为异常。这使得组件变成了“环境依赖型”组件,而不是独立的通用组件。

    • 反例:一个按钮组件如果需要 inject('theme') 才能渲染颜色,那它在没有主题上下文的地方就很难单独使用。
    • 正解:更好的做法是通过 props 传入 color 或 theme

3. 调试困难

  • 问题:当数据出现错误时,使用 props 可以通过 Vue DevTools 清晰地看到数据在组件树中的传递路径。
  • 后果:使用 provide/inject 时,数据像是“瞬移”到子组件的。在大型应用中,很难快速定位是哪个祖先组件提供的值出了问题,或者是哪个子组件意外修改了注入的响应式对象。

4. 类型推断支持较弱(相比 Props)

  • 虽然在 Vue 3 + TypeScript 中 provide/inject 有了很好的类型支持,但相比于 defineProps 的自动类型推导,inject 往往需要手动定义类型接口或泛型,稍微繁琐一些,且在重构时(如修改 key 名称)不如 props 那样容易通过 IDE 全局搜索和替换来保证安全。

那么,什么时候应该使用 provide/inject

尽管有上述缺点,它在以下场景是最佳选择

  1. 开发组件库(UI Library)

    • 这是 provide/inject 的主战场。例如,一个 Table 组件和一个 TableCell 组件。你不可能让使用者在每个 TableCell 上都手动写一遍 :table-context="..."。此时,Table 组件 provide 上下文,TableCell inject 上下文,是极其合理且必要的。
  2. 深层嵌套的全局配置

    • 例如:应用的主题(深色/浅色)、当前语言(i18n)、权限配置等。这些数据通常在根组件或布局组件提供,深层的孙子组件需要使用。如果用 props 逐层传递(Prop Drilling),中间层的组件会被迫传递它们自己并不需要的数据,代码非常冗余。
  3. 避免 Prop Drilling

    • 当组件嵌套层级超过 3-4 层,且中间组件不需要使用这些数据,仅仅是透传时,使用 provide/inject 可以显著简化代码结构。

2. �����和attrs和 listeners(Vue 2)/ $ attrs(Vue 3)

用于透传属性和事件,常用于高阶组件或封装场景。

Vue 3 示例:

<!-- WrapperComponent.vue -->
<template>
  <BaseInput v-bind="$attrs" />
</template>

<script setup>
// 默认情况下,$attrs 包含所有未声明的 props
// 如果需要监听事件,需要在 emits 中声明或使用 v-on="$attrs"
</script>

<style>
/* 禁用继承样式 */
:root {
  inheritAttrs: false;
}
</style>

四、全局状态管理

对于大型应用,推荐使用状态管理库。

1. Pinia(Vue 3 推荐)

Pinia 是 Vue 官方推荐的状态管理库,比 Vuex 更简洁、类型友好。

npm install pinia
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubleCount = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return { count, doubleCount, increment }
})
<!-- 组件中使用 -->
<template>
  <div>
    <p>Count: {{ counterStore.count }}</p>
    <p>Double: {{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment">Increment</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
</script>

2. Vuex(Vue 2/3 兼容)

虽然 Pinia 是未来趋势,但许多项目仍在使用 Vuex。

五、其他通讯方式

1. Event Bus(不推荐用于 Vue 3)

在 Vue 2 中常用空的 Vue 实例作为事件总线,但在 Vue 3 中由于移除了 $on$off$once,不再推荐使用。如需类似功能,可使用第三方库如 mitt

npm install mitt
// eventBus.js
import mitt from 'mitt'
export const emitter = mitt()
<!-- 发送方 -->
<script setup>
import { emitter } from '@/eventBus'

const sendData = () => {
  emitter.emit('custom-event', { message: 'Hello' })
}
</script>
<!-- 接收方 -->
<script setup>
import { onMounted, onBeforeUnmount } from 'vue'
import { emitter } from '@/eventBus'

const handleEvent = (data) => {
  console.log('Received:', data)
}

onMounted(() => {
  emitter.on('custom-event', handleEvent)
})

onBeforeUnmount(() => {
  emitter.off('custom-event', handleEvent)
})
</script>

2. 模板 refs

用于父组件直接访问子组件的实例或 DOM 元素。

<template>
  <button @click="callChildMethod">Call Child Method</button>
  <ChildComponent ref="childRef" />
</template>

<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const childRef = ref(null)

const callChildMethod = () => {
  if (childRef.value) {
    childRef.value.childMethod()
  }
}
</script>

六、选择指南

场景 推荐方式
父传子 Props
子传父 Emit / defineModel
兄弟组件 状态提升到共同父组件
跨多层级 Provide/Inject 或 Pinia
全局状态 Pinia(首选)或 Vuex
封装组件透传 $ attrs
直接调用子组件方法 Template Refs

七、最佳实践总结

  1. 遵循单向数据流:永远不要直接修改 props
  2. 优先使用简单方案:能用 props/emits 解决的,不要用全局状态
  3. 类型安全:在 TypeScript 项目中充分利用类型定义
  4. 避免过度耦合:组件间依赖越少越好
  5. 文档化通讯接口:明确组件的输入(props)和输出(events)
  6. 使用组合式 API:Vue 3 的 <script setup> 让组件通讯更清晰

结语

Vue 提供了丰富灵活的组件通讯机制,从简单的 props/emits 到强大的状态管理工具。选择合适的通讯方式取决于具体的应用场景。理解每种方式的优缺点,并在项目中合理运用,是构建高质量 Vue 应用的关键。

随着 Vue 生态的发展,Pinia 已成为状态管理的首选,而组合式 API 也让组件间的逻辑复用变得更加优雅。持续学习并实践这些模式,将帮助你在 Vue 开发道路上走得更远。

以界面重构文字,GenUI 正式发布!

本文由体验技术团队岑灌铭原创。

背景:传统 AI 对话的局限

随着大语言模型(LLM)的不断发展,模型选择越来越多,能力也越来越强。但传统大模型对话,主要依赖纯文本输入和输出,一旦涉及复杂交互、结构化展示或多轮协作,就会暴露出明显的体验瓶颈:

  • 可读性差、表达形式局限:纯文本呈现方式带来了较高的阅读成本,复杂的业务逻辑、多步骤流程、图表和可视化信息,用纯文字难以准确、高效地表达。例如:一张折线图能直观展示趋势,用文字描述则冗长且不直观。
  • 交互闭环断裂:传统对话模式下,用户往往需要经历「先阅读回复 → 理解内容 → 再手动输入下一步指令 → 发送内容继续对话」的流程。
  • 工具调用的体验断层:当LLM需要调用工具但缺少参数时,需要文字提示用户补充。用户需要理解每个参数的含义、类型和格式,自行组织输入,这种体验生硬且容易出错。

这些问题的症结在于纯文本形式难以跟上用户对 “高效完成复杂任务” 的核心诉求,而生成式UI正是解决这一痛点的解决方案。

1.png

生成式 UI 简介

生成式 UI(Generative UI) 是一种创新的人机交互范式:在对话过程中,能够动态生成并实时渲染 UI 界面,让 AI 不再局限于纯文字输出,而是能够"画"出表单、按钮、图表、卡片等丰富的交互组件。用户可以直接在生成的界面中操作,操作行为即时反馈回对话上下文,驱动模型进行下一轮响应,使交互与对话融为一体。

 

GenUI SDK 是 OpenTiny 团队基于生成式 UI 理念打造的解决方案,提供完整的前后端一体化集成能力。它遵循 OpenAI 接口规范,可无缝对接主流大模型服务;内置 Vue 与 Angular 双框架渲染器,支持自定义的组件库、交互行为与主题样式。无论是从零搭建一个 AI 对话应用,还是在现有业务系统中嵌入生成式界面能力,GenUI SDK 都能让开发者开箱即用、灵活扩展。

 

核心亮点

交互范式的三大突破:

1、以界面重构文字:打破文字表达壁垒,用可视化界面释放信息价值。表格、卡片、列表、图表等组件让数据与流程一目了然,用户无需再在文字中"挖矿"。

2、打破两步交互:实现从界面到对话的一站式流转。用户在生成的表单中填写、在按钮上点击,这些操作会即时反馈到对话上下文中,驱动模型的下一轮回复。无需看完再手动输入然后发送,交互与对话融为一体。

3、让 AI 更懂业务:在工具调用缺少参数时,模型可以自动生成交互式 UI 收集所需信息。用户只需在生成好的表单中填写并提交,参数即被正确传递给工具,无需理解参数格式、无需自行翻译需求。结合 MCP 等生态,GenUI 让 AI 真正具备了落地业务场景的交互能力。

SDK 工程能力:

1、现有 AI 生态兼容:遵循 OpenAI 格式,可无缝对接主流 LLM 服务;原生支持 MCP 服务接入,轻松连接丰富的工具生态。

2、定制主题:支持亮色、暗黑等主题切换,也可以完全自定义主题样式,适配不同产品的视觉风格与使用场景。

3、自定义组件:支持传入自定义组件与描述,扩展生成式 UI 的组件库,让生成的界面更贴合自身业务需求。

4、自定义交互:支持配置自定义交互行为,如跳转新页面、下载附件等,满足业务侧的各类个性化需求。

5、多技术栈支持:内置 Vue 与 Angular 渲染器,同时开放自定义渲染扩展接口,便于融入现有项目的技术栈。

6、示例与片段:支持配置自定义示例与片段,帮助模型理解业务最佳实践,进一步提升生成界面的质量。

 

GenUI SDK效果展示

以下是车票查询场景的录屏,能够让您更加深刻地了解 GenUI SDK :

2.gif

演练场体验

您还通过演练场亲自体验车票查询场景:GenUI SDK演练场

注意: 在体验前需先配置12306 MCP工具,此处可以使用 WebAgent 中 MCP 市场提供的12306工具:chat.opentiny.design/api/v1/mcp-…

3.png

快速上手:3 步集成 GenUI SDK

1. 后台服务准备

下载server包

pnpm add @opentiny/genui-sdk-server
# 或 npm install @opentiny/genui-sdk-server
# 或 yarn add @opentiny/genui-sdk-server

启动服务

使用 OpenAI 兼容的 LLM 服务,将下面的API_KEY和BASE_URL替换为您的 LLM 服务配置

export API_KEY=********* BASE_URL=https://your-llm-server.com/api && npx genui-sdk-server

若控制台出现 genui-sdk-server is running on http://localhost:3100 则说明启动成功

2.创建工程

初始化

首先,创建一个新的 Vue 项目,执行以下命令,按默认配置初始化工程:

npm create vue@latest genui-chat

安装依赖

进入项目目录并安装 GenUI SDK:

cd genui-chat
npm install @opentiny/genui-sdk-vue

删除样式

初始化引入的样式会污染组件样式,因此需要删除

修改 src/main.js 或 src/main.ts

// import './assets/main.css'; 删除 Vue 初始化工程引入的样式

import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');

3.使用并配置GenuiChat

结合配置和主题的完整示例如下:

<script setup lang="ts">
import { ref } from 'vue';
import { GenuiChat, GenuiConfigProvider } from '@opentiny/genui-sdk-vue';

const url = 'http://localhost:3100/chat/completions'; // 步骤1启动的服务
const model = ref('deepseek-v3.2'); // 对应模型服务提供商的模型ID
const temperature = ref(0.5);
const theme = ref<'dark' | 'lite' | 'light' | 'auto'>('dark');
</script>

<template>
  <GenuiConfigProvider :theme="theme">
    <GenuiChat :url="url" :model="model" :temperature="temperature">    
      <template #empty>
        <div class="empty-text">欢迎使用生成式UI</div>
      </template>
    </GenuiChat>
  </GenuiConfigProvider>
</template>

<style>
body,
html {
  padding: 0;
  margin: 0;
}
#app {
  position: fixed;
  width: 100vw;
  height: 100vh;
}
.tiny-config-provider {
  height: 100%;
}
.empty-text {
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 30px;
}
</style>

完成以上3步后,即可打开浏览器,立即体验了~

若想进一步了解GenUI SDK的用法,可以前往GenUI SDK 开发文档查看。

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
GenUI 官网:opentiny.design/genui-sdk
OpenTiny 代码仓库:github.com/opentiny

欢迎进入代码仓库 Star🌟TinyVue、TinyEngine、TinyPro、TinyNG、TinyCLI、TinyEditor 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

如果你有任何问题,欢迎在评论区留言交流!

Vue 3 项目核心配置文件详解

你需要了解 Vue 3 项目中最常用、最关键的配置文件,我会按项目根目录配置src 内业务配置分类整理,包含完整用法和示例,直接复制就能用。

一、根目录核心配置文件(项目运行/构建依赖)

1. vite.config.js(Vite 构建工具,Vue3 官方推荐)

这是 Vue 3 + Vite 项目最重要的配置文件,配置开发服务、打包、代理、路径别名等。

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  // 1. 插件配置
  plugins: [vue()],
  
  // 2. 路径别名(简化 import 路径)
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'), // @ 代表 src 目录
      '@assets': resolve(__dirname, 'src/assets')
    }
  },

  // 3. 开发服务器配置
  server: {
    host: '0.0.0.0', // 允许局域网访问
    port: 3000,      // 端口号
    open: true,      // 自动打开浏览器
    https: false,    // 关闭 https
    // 接口代理(解决跨域)
    proxy: {
      '/api': {
        target: 'http://localhost:8080', // 后端接口地址
        changeOrigin: true,              // 允许跨域
        rewrite: (path) => path.replace(/^\/api/, '') // 重写路径
      }
    }
  },

  // 4. 打包配置
  build: {
    outDir: 'dist',      // 打包输出目录
    assetsDir: 'assets', // 静态资源目录
    minify: 'terser',    // 代码压缩
    sourcemap: false     // 关闭 sourcemap(生产环境)
  }
})

2. package.json(项目依赖/脚本配置)

管理项目依赖、运行/打包命令,Vue3 标准配置:

{
  "name": "vue3-project",
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",                // 启动开发环境
    "build": "vite build",        // 生产打包
    "preview": "vite preview"     // 预览打包结果
  },
  "dependencies": {
    "vue": "^3.4.0"               // Vue3 核心依赖
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.0",
    "vite": "^5.0.0"
  }
}

3. .env 环境变量配置(多环境必备)

Vite 支持三种环境文件,放在项目根目录:

  • .env:全局公共变量(所有环境生效)
  • .env.development:开发环境变量(npm run dev
  • .env.production:生产环境变量(npm run build

变量规则:必须以 VITE_ 开头

# .env.development
VITE_APP_TITLE = Vue3 开发环境
VITE_API_BASE_URL = /api
VITE_APP_DEBUG = true

使用方式

<script setup>
console.log(import.meta.env.VITE_APP_TITLE)
</script>

4. .eslintrc.cjs(代码规范检查)

统一团队代码风格,避免语法错误:

module.exports = {
  root: true,
  extends: [
    'plugin:vue/vue3-essential',
    'eslint:recommended'
  ],
  parserOptions: {
    ecmaVersion: 'latest'
  },
  rules: {
    'vue/no-unused-vars': 'warn',
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
  }
}

5. prettier.config.cjs(代码格式化)

自动格式化代码(缩进、引号、分号):

module.exports = {
  semi: false,        // 关闭分号
  singleQuote: true,  // 使用单引号
  tabWidth: 2,        // 缩进 2 格
  trailingComma: 'none'
}

二、src 目录内业务配置文件

1. src/main.js(项目入口配置)

Vue 3 入口文件,挂载全局组件、插件、样式:

import { createApp } from 'vue'
// 根组件
import App from './App.vue'
// 全局样式
import './style.css'

// 创建应用实例
const app = createApp(App)

// 全局配置(示例:全局指令/组件)
// app.directive('focus', { ... })
// app.component('GlobalButton', { ... })

// 挂载到 DOM
app.mount('#app')

2. src/router/index.js(路由配置 Vue Router)

Vue 3 路由标准配置(需先安装:npm install vue-router):

import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: About
  }
]

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

export default router

在 main.js 中挂载

import router from './router'
app.use(router)

3. src/store/index.js(状态管理 Pinia 配置)

Vue 3 官方推荐状态库(替代 Vuex):

import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

在 main.js 中挂载

import pinia from './store'
app.use(pinia)

三、极简配置清单(快速复制)

  1. 基础运行vite.config.js + package.json
  2. 多环境.env / .env.development / .env.production
  3. 路由src/router/index.js
  4. 状态管理src/store/index.js
  5. 代码规范.eslintrc.cjs + prettier.config.cjs

总结

  1. Vue 3 + Vite 核心配置是 vite.config.js,负责服务、代理、打包;
  2. 环境变量必须以 VITE_ 开头,用 import.meta.env 调用;
  3. 业务核心配置:main.js(入口)、router(路由)、pinia(状态)。

别再被setTimeout闭包坑了!90% 的人都写错过这个经典循环

你以为只是“延迟执行”?其实变量早就被偷换了!

在 JavaScript 中,setTimeout 是最常用的异步工具之一。但当它和 for 循环、闭包一起出现时,无数开发者都踩过同一个坑

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 你期待输出 0,1,2?实际却是 3,3,3!
  }, 100);
}

图片

为什么?
因为 var + setTimeout + 闭包 = 变量共享陷阱

今天我们就彻底拆解这个经典问题,并告诉你如何用现代 JS 写出正确、安全、可维护的延迟逻辑。


问题根源:var 的函数作用域 + 异步执行

关键点有二:

1. var 没有块级作用域

for 循环中的 var i 实际上是在整个函数(或全局)作用域中声明一次,所有循环迭代共享同一个 i

2. setTimeout 是异步的

setTimeout 的回调真正执行时,for 循环早已结束,此时 i 的值已经是 3(循环终止条件)。

所以三个回调都引用了同一个已经变成 3 的变量i


常见错误解法(别再用了!)

解法一:用 setTimeout 第三个参数传参(可行但不推荐)

for (var i = 0; i < 3; i++) {
  setTimeout((x) => {
    console.log(x);
  }, 100, i); // 把 i 作为参数传入
}

虽然能工作,但:

  • 语义不直观;
  • 回调函数签名被污染;
  • 在复杂逻辑中难以维护。

解法二:立即执行函数(IIFE)——过时方案

for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(() => {
      console.log(j);
    }, 100);
  })(i);
}

这确实能创建新作用域,但:

  • 代码冗长;
  • 阅读成本高;
  • ES6 之后已有更优雅方案

正确姿势:用 let 声明循环变量

这是最简单、最现代、最推荐的方式:

for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 输出 0, 1, 2 
  }, 100);
}

图片

为什么 let 能解决?

  • let 具有块级作用域
  • 每次循环迭代都会创建一个新的绑定(binding)
  • 每个 setTimeout 回调捕获的是当前迭代的独立 i,互不干扰。

这不是“魔法”,而是 ES6 规范明确规定的语义。


更复杂的场景:循环中创建函数数组

陷阱不止出现在 setTimeout,任何异步回调或延迟执行的函数都可能中招:

const handlers = [];
for (var i = 0; i < 3; i++) {
  handlers.push(() => console.log(i));
}

handlers.forEach(fn => fn()); // 输出 3,3,3 

修复方式同样简单:

const handlers = [];
for (let i = 0; i < 3; i++) {
  handlers.push(() => console.log(i)); // 输出 0,1,2 
}

或者用 Array.map 等函数式写法,天然避免问题:

const handlers = [0, 1, 2].map(i => () => console.log(i));

特别提醒:Node.js 和浏览器都一样!

这个陷阱与运行环境无关,无论是:

  • 浏览器中的事件监听;
  • Node.js 中的定时任务;
  • React/Vue 中的副作用处理;

只要涉及 var + 异步 + 循环,就可能出错。


终极建议:彻底告别 var

在现代 JavaScript 工程中:

  • 默认使用const(不可变绑定);
  • 需要重赋值时用let
  • 永远不要用var(除非维护老代码)。

配合 ESLint 规则:

{
  "rules": {
    "no-var": "error"
  }
}

从源头杜绝此类问题。


结语

setTimeout 本身没有错,错的是我们对作用域和闭包的理解偏差。
let 的出现,正是为了终结这类“反直觉”的陷阱。

下次当你写循环+异步时,请记住:

不是代码跑错了,是你还在用十年前的变量声明方式。

升级你的语法,远离闭包陷阱!

转发给那个还在用 var 写循环的同事吧!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

vue3使用

vue是渐进式框架

  • 使用方式渐进:从CDN引入写简单交互,到CLI创建完整项目,再到Nuxt做SSR,每一步都是可选的。
  • 功能模块渐进:核心库只负责视图层,需要路由加Vue Router,需要状态管理加Pinia,不强求一次性配齐。
  • 学习曲线渐进:新手只需要会HTML/JS就能上手,随着项目复杂度提升,再逐步学习进阶特性。

Vue采用自动追踪的方式。它通过Proxy(Vue3)或Object.defineProperty(Vue2)拦截数据的读取和修改,在读取时收集依赖(当前正在运行的函数),在修改时通知所有依赖更新。这种方式的优点是精确——只有真正依赖这个数据的组件才会更新,而且开发者可以直接修改数据,不需要额外操作。

React则采用显式触发的方式。它没有自动追踪,而是通过setState手动触发更新。一旦setState调用,整个组件函数会重新执行,生成新的虚拟DOM,然后通过Diff算法找出变化的部分更新真实DOM。这种方式的优点是简单直观——数据变了就重新渲染,但缺点是需要开发者手动优化(memo/useMemo)避免不必要的渲染。

<script>
    export default {
        name: 'PP',
        // setup函数中的this是undefined,vue3中已经弱化this了,里边变量方法必须返回
        // 执行时机  早于beforeCreated()
        // setup返回对象,也可直接返回函数,页面直接渲染返回的内容
        // setup 和 data和method关系
        // setup()能和data\method同时存在
        // data和methods可以读取setup()中数据this.name,setup先执行,setup里读不到data里数据
        setup() {
            let name = ref('lili');
            let age = ref(18);
            function changeName {
                name.value = 'alice';
            }
            return {
                name,
                age,
                changeName,
            }
            // return () => 'hahhahahah' // 这个组件直接渲染hahahahah
        }
    }
</script>
// setup函数语法糖
// 设置组件名,可与setup语法糖同时存在
<script>
    export default {
        name: 'PP',
    }
</script>
// 上边不想再写个script单独设置组件名字,可以借助一个插件
// vite-plugin-vue-setup-extend  安装后在vite.config.ts中配置插件,即可name="person-123"
<script setup lang="ts" name="person-123">
    let name = 'lili';
    let age = 18;

    function fn() {}
</script>

ref和reactive

vue2中,数据写在data(){return {}}中就是响应式的,原理defineProperty劫持。
vue3响应式 数据实现响应式使
基本类型 + 对象类型 使用ref(初始值) let name = ref('ddd') name.value 需要.value取值
对象类型 let obj = reactive(初始值) 直接访问;嵌套深层的对象,建议用reactive,也可用ref
reactive定义后,不能直接再赋值整个对象。

let car = reactive({brand: 'bwp', price: 200});
// 错误
car = {brand: 'benci', price:300} // 错误,失去响应式,页面不更新
car = reactive({brand: 'aodi', price:300}) // 错误,原先的对象失去响应式,页面不更新
// 正确
Object.assign(car, {brand: 'aodi', price:300}) // 正确,页面更新,没有更新person的地址

// 如下可以,正确
const obj = ref({a: 123});
obj.value = {a: 567}; // 一个新对象赋值,obj的地址变了

toRefs和toRef

let person = reactive({name: 'll', age:18}); //将响应式对象所有属性都变成响应式
let { name, age } = toRefs(person);
console.log(name, age);
let n = toRef(person, 'name');一个一个解构成响应式

image.png

computed vue3的

计算属性有缓存

// 这么定义的计算属性不能修改
let fullName = computed(() => {
    return firstName.value + lastName.value;
})

// 这么定义的,可读可写
let fullName = computed({
    get() {
        return firstName.value + lastName.value;
    },
    // 赋值时调用
    set(newVal) {
        
    }
})

image.png

watch

监听数据变化,Vue3只能监听4种数据

  • ref定义的数据。
let sum = ref(0);
const addSum = () => {
  sum.value += 1;
};
// 解除监听
// 监听【ref】定义的【基本类型】
const stopWatch = watch(sum, (newVal, oldVal) => {
  console.log(newVal, oldVal);

  if (oldVal > 10) {
    stopWatch(); // 调用该函数解除监听
  }
});
// 监视【ref】定义的【对象类型】数据,监视的是对象的地址值,
// 若想监听对象内部属性发生的变化,需要【手动开启深度监听】
/** 监视ref定影的对象类型数据,监视的是对象的地址值,
    若想监听对象内部属性发生的变化,需要手动开启深度监听
    watch第一个参数:被监视的数据;
    第二个参数:监视的回调 
    第三个:配置的对象deep、immediate等
    */
let person = ref({ name: 'lisi', age: 18 });
watch(
  person,
  (newVal, oldVal) => {
    console.log(newVal, oldVal);
  },
  { deep: true, immediate: true }
  // deep开启,监听内部属性,
  // immediate值表示立即执行一次,数据未变化时就执行一次
);
  • reactive定义的数据
// 监视【reactive】定义的对象,默认开启深度监听,不用手动开启,不能关闭
let person = reactive({ name: 'lisi', age: 18 });
watch(
  person,
  (newVal, oldVal) => {
    console.log(newVal, oldVal);
  },
  { immediate: true }
  // 此时deep默认开启,可监听内部属性
  // immediate值表示立即执行一次,数据未变化时就执行一次
);
  • 函数返回的一个值 -》getter函数(能返回一个值的函数)。 监视ref或reactive定义的对象类型中的某个属性(属性为基本类型的或者对象类型,属性为对象的也可以直接监视这个属性,建议写成函数式
let person = reactive({
    name: 'lisi',
    car: {c1: 'yadi', c2: 'baoma'}
})
watch(() => person.name, () => {}, {})
// 下面情况能监听到car中单个属性的变化,但是car整体赋值监听不到,car = {c1; 'rr', c2: 'ee'}
watch(person.car, () => {}, {})
// 下面情况能监听到car整体赋值,不加deep参数,car的单个属性变化监听不到,所以要加deep参数
// 函数的写法,要深度监听,写deep参数。即地址上想监听内部属性变化,需加deep参数
watch(() => person.car /** 该函数返回car的地址 */, () => {}, {deep: true})
  • 上述组成的数组
let person = reactive({
    name: 'lisi',
    age: 18,
    car: {c1: 'yadi', c2: 'baoma'}
})

watch([() => person.name, () => person.car], () => {}, {deep: true})

watchEffect 副作用

watch必须明确指出监视谁。 watchEffect不用写监视谁,直接回调,回调中用哪些属性到就监视哪些

let height = ref(0);
let width = ref(0);
// 会立即调用回调函数,响应式追踪变化
watchEffect(() => {
    if (heigth.value > 10 || width.value > 5) {
        console.log('超过标准了');
    }
})

ref容器

<h2 ref='title'>nihao</h2>

let title = ref(); // title.value就是拿到h2这个Dom元素【普通标签】

<Person ref='personRef'></Person>

let personRef = ref(null);
personNull.value 就是person组件实例,可以拿到该组件defineExpose的东西【组件】

ts规范

// 接口,用于限制person对象的具体属性
// src/types/index.ts
export interface PersonInterface {
    name: string;
    age: number;
}
// 一个自定义类型
export type Persons = Array<PersonInterface>
// export type Persons = PersonInterface[] // 或者这种写法


// src/components/Person.vue
import {type PersonInterface, type Persons} from '@/types'

let person:PersonInterface = {age: 19, name: 'lisi'};
let personList2 = reactive<Persons>([]);
let personList: Persons = [];
let personList1: Array<PersonInterface> = [];

组件生命周期

v-if 创建销毁组件 v-show 隐藏使用display:none 元素还在
生命周期函数,生命周期钩子
vue2的生命周期 创建:created(创建前beforeCreate,创建完毕created)
挂载:mounted(挂载前beforeMount,挂载完毕-组件显示在页面上mounted)
更新:updated(更新前beforeUpdate,更新完毕 updated)
销毁:destroyed(销毁前beforeDestory,销毁完毕destroyed)

vue3的生命周期
创建:setup()替代了,模拟创建前和创建完
挂载:onBeforeMount(() => {}) onMounted(() => {})
更新:onBeforeUpdate(() => {}) onUpdated(() => {})
卸载:onBeforeUnmount(() => {}) onUnmounted(() => {})

父子生命周期顺序:
子挂载完--》父挂载完 父组件是最后挂载完的

hooks

本质是一个返回值的函数。 使用时引入,可解构获取hook中暴露的数据

// 将逻辑抽离出来,放到一个ts或js文件中
// 里边可以使用生命周期函数、或者computed、watch等vue中的东西
// src/hooks/sumHook.ts
import { ref } from 'vue'
export default function() {
    let sum = ref('')
    let add = () => {
        sum.value += 1;
    }
    
    return {
        sum,
        add
    }
}

// 引用处
import useSum from '@/hooks/sumHook.ts'
let { sum, add } = useSum();

路由router

import { RouterView, RouterLink} from 'vue-router'
 
<RouterView></RouterView> // 加载的路由组件显示区域占位

// 路由跳转组件
<RouterLink to='/home' active-class='actived-class'></RouterLink>
<RouterLink :to={path: '/home'} active-class='actived-class'></RouterLink>
<RouterLink :to={name: '/zhuye'} active-class='actived-class'></RouterLink>

路由组件:靠路由规则渲染出来的。一般写在pages或view文件夹下
routes: [{ path: '/home', component: Home, name='zhuye' }]
路由切换时,视觉消失的路由组件,是被卸载了
一般组件:手动写标签,一般写在components下 <person></person>

路由工作模式
history模式
优点:URL更美观,不带#,更接近传统网站的URL。 缺点:后期项目上线,需要服务端配合处理路径问题,否则刷新会有404错误,可在nginx等服务器上配置

vue2: mode: 'history'  
vue3: history: createWebHistory()  
const router = createRouter({
     history: createWebHistory(),
     routes: [],
})

hash模式
优点:兼容性更好,因为不需要服务器处理路径
缺点:url上带#不美观,且在SEO优化方面相对较差

vue2: mode: 'hash'  
vue3: history: createWebHashHistory() 
const router = createRouter({
     history: createWebHashHistory(),
     routes: [],
})

路由参数

import { useRoute, useRouter } from 'vue-router';
let route = useRoute();
// route.query
<RouterLink :to={path: '/zhuye', query: {id: xxx, title: xxx}} active-class='actived-class'></RouterLink>
<RouterLink :to=`/news/detail?id=${id}&title=${title}` active-class='actived-class'></RouterLink>
// /news/detail?id=119&title=万万没想到 // id=119&title=万万没想到 query参数

// parmas传参 to中路由必须写name,不能是path;且params中不能传对象和数组
<RouterLink :to={name: '/zhuye', params: {id: xx, title: xx}} active-class='actived-class'></RouterLink>
<RouterLink :to=`/new/detail/${id}/${title}` active-class='actived-class'></RouterLink>
// route.params    路由处占位: /news/detail/:id/:title

路由的props

routes: [{ 
    path: 'news',
    component: News,
    name='zhuye',
    children: [
        {
            name: 'xiang',
            path: 'detail/:id/:title',
            component: Detail,
            // 第一种写法:将路由收到的所有【params参数】作为props传给路由组件
            // <Detail id=xx title=xx />
            // props: true, 
            
            // 第二种写法:函数写法,可以自己决定将什么作为props传给路由组件
            //props(route){ // 参数为route路由信息
            //    return route.query
            //}
            
            // 第三种写法:对象写法,可以自己决定将什么作为props传给路由组件
            //props: { // 这种写法传固定值
            //    a: 100
            //    b: 200
            //}
        }
    ]
}] 

路由的replace属性

// replace替换,不能回退到上一个访问的路由 ;不加默认是push,可以回到上一个访问的路由
<RouterLink replace :to=`/new/detail/${id}/${title}` active-class='actived-class'></RouterLink>

编程式路由导航

import { useRouter } from 'vue-router';
const router = useRouter();

router.push('/news');
router.replace('/news');

vuex与pinia 集中式状态(数据)管理

多个组件共享数据

import { defineStore } from 'pinia';
// 选项式
export const useCountStore = defineStore('count', {
    state() {
        return {
            sum: 6,
            school: 'cc',
            address: 'ww'
        }
    },
    // actions中放置的一个一个的方法,用于响应组件中的动作
    actions: {
            increment(value) {
                console.log('ii调用了', value);
            }
    }

});

// setup写法 组合式
export const useCountStore = defineStore('count', () => {
    // state
    let sum = ref(6),
    let school = ref('cc'),
    let address = ref('ww')

    // actions
    const increment = (value) => {
       console.log('ii调用了', value);
    }
    
    return {
        sum,
        school,
        address,
        increment,
    }
});
import { useCountStore } from '@/store/count';
const countStore = useCountStore();
// 拿到store中数据
// countStore 是Proxy包裹的对象,里面的ref会自动解包,不用再.value
console.log(countStore.sum)
// 第一种修改方法
countStore.sum = 9;
// // 第一种修改方法, 批量变更 store
countStore.$patch({
    sum: 8,
    school: 'dd'
});
// 第三种修改方法,调用store的actions中定义的修改方法
countStore.increment('+++');

// import { storeToRefs } from 'pinia';
// storeToRefs 只会关注store中的数据,不会对方法进行ref包裹

const { sum, scheool } = storeToRefs(useCountStore());

组件间通信

  • props,emit 父子组件
  • mitt 引入mitt,订阅取消订阅;事件总线
  • v-model 此通信方式在UI组件库大量使用双向绑定
<input type='text' v-model="username"> 等价于下边  
<input type='text' :value="username" @input="username = (<HTMLInputElement>$event.target).value">  
<my-input v-model="username">
<my-input :modelValue="username" @update:modelValue="username = $event">
<input type='text' :value="username" @input="username = (<HTMLInputElement>$event.target).value">  

defineProps(['modelValue])
  • $attrs 用在模版中,子组件用这个获取副组件传过来的未使用props接收的其他所有属性 然后子组件可以使用v-bind=attrs将其未显示接收的参数传给他的子组件,及父传孙子组件vbind=key:value,....===>vbind=attrs将其未显示接收的参数传给他的子组件,及父传孙子组件 `v-bind={key: value, ....}` ===> `v-bind=attrs`
    用在js上时
<script setup>
import { useAttrs } from 'vue' 
const attrs = useAttrs() 
</script>
// 或
export default { 
    setup(props, ctx) { // 透传 attribute 被暴露为 ctx.attrs 
        console.log(ctx.attrs) 
    }
}
  • $ref $parents $ref 父组件获取所有的子组件;父-》子 子组件使用ref <child ref='child1Ref'/> $parents 子组件中获取到父组件 子-》父
    注意点: 一个响应式对象中的属性是ref()定义,读取时不用再.value,底层会自动获取数据
  • provide/reject 嵌套较深的组件间 祖先-子孙 project('moneyContext', {money, updateMoney}); 父 let {money, updateMoney} = reject('moneyContext', {}) // 可以给个默认值,孙子组件可以使用updateMoney通信给父组件

插槽
默认插槽
<slot>默认内容</slot> ==> <slot name='default'>默认内容</slot> 插槽没用到就显示默认内容
具名插槽

<slot name='header'></slot>

<template v-slot:header><div>menu</div></template>  
<Category v-slot:header><div>menu</div></Category>

作用域插槽 v-slot="params"
数据在子那边,但根据数据生成的结构,却由父决定,即需要用到zi的数据

// 子组件的数据可以绑定到slot上,传给父组件使用
<slot name='header' :youxi=games :a='123'></slot>
// 使用
<template v-slot:header><div>menu</div></template>  
<Category v-slot="params"><div>{{params.youxi}}</div></Category> // 默认插槽
<Category v-slot:header="{youxi}"><div>{{params.youxi}}</div></Category> // 解构 header插槽
v-slot:header="{youxi}" ===》 #header={youxi}

shallowRef与shallowReactive 用法和ref和reactive一样,只是监听的顶层属性

两者用来绕开深度响应,避免每个内部属性都做响应式带来的性能成本,使得属性访问更快,可提升性能。

  • shallowRef:浅层ref 只关注引用层的变化,不关心内部属性的变化; 只监听.value这层的改变,如果是对象,car.value.a,这个监听不到
  • shallowReactive:对象的顶层属性是响应式的,但嵌套属性不是。

readonly及shallowReadonly

readonly所有层都只读

let sum1 = ref(0);
let sum2 = readonly(sum1); // sum2关联了sum1为只读,但sum1变化时,sum2也会变化,sum1自己维护,sum2给别人使用,防止改坏了

shallowReadonly只限制第一层为只读,可以修改第二层数据

toRaw与markRaw

let person = ref({name: 'ii', age: 18});
let p2 = toRaw(person); // 变成了普通对象,无响应式了,用在作为参数传给非vue库去做处理,如lodash库的函数处理数据

let c = {a: 99, b:0};
let c1 = reactive(c); // 响应式
// markRaw 标记一个对象,使其永远不能成为响应式
let car = markRaw({b: ''qq', c: 22});

customRef

自定义ref

let initValue = '你好‘;
// track跟踪, trigger触发
let msg = customRef((track, trigger) => {
    // 读取
    get() {
        track(); // 告诉vue数据msg很重要,你要对msg进行持续关注,一旦msg变化就去更新
        reutrn initValue;
    },
    // 修改
    set(value) {
        initValue = value;
        trigger(); // 通知vue一下数据msg变化了
    }
})

Teleport 传送

将结构传送到body下,里面的元素就能插入到body元素标签下
<Teleport to='body'>
    <div>你好</div>
</Teleport>

<Teleport to='.m-box'>
    <div>你好</div>
</Teleport>

vxe-table 如何实现分组列头折叠列功能

实现 vxe-table 分组列头折叠列功能非常简单,只需改变列的 visible 就可以实现

vxetable.cn

Video_2026-03-09_104017-ezgif.com-video-to-gif-converter

通过修改列的 visible 属性来精确控制列的显示隐藏

<template>
  <div>
    <vxe-table
      border
      height="400"
      :data="tableData">
      <vxe-column type="checkbox" width="60"></vxe-column>
      <vxe-colgroup field="g1" title="分组1">
        <template #header="{ column }">
          <vxe-button mode="text" :icon="foldMaps.g1 ? 'vxe-icon-square-minus' : 'vxe-icon-square-plus'" @click="collapsable('g1')"></vxe-button>
          <span>{{ column.title }}</span>
        </template>

        <vxe-column field="name" title="Name" width="200"></vxe-column>
        <vxe-column field="role" title="Role" :visible="foldMaps.g1" width="200"></vxe-column>
        <vxe-column field="sex" title="Sex" :visible="foldMaps.g1" width="200"></vxe-column>
      </vxe-colgroup>
      <vxe-colgroup field="g2" title="分组2">
        <template #header="{ column }">
          <vxe-button mode="text" :icon="foldMaps.g2 ? 'vxe-icon-square-minus' : 'vxe-icon-square-plus'" @click="collapsable('g2')"></vxe-button>
          <span>{{ column.title }}</span>
        </template>

        <vxe-column field="age" title="Age" width="200"></vxe-column>
        <vxe-column field="rate" title="Rate" :visible="foldMaps.g2" width="200"></vxe-column>
        <vxe-column field="address" title="Address" :visible="foldMaps.g2" width="200"></vxe-column>
      </vxe-colgroup>
    </vxe-table>
  </div>
</template>

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

const foldMaps = reactive({
  g1: true,
  g2: true
})

const tableData = ref([
  { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'test abc' },
  { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
  { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
  { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
  { id: 10005, name: 'Test5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
  { id: 10006, name: 'Test6', role: 'Designer', sex: 'Women', age: 21, address: 'test abc' },
  { id: 10007, name: 'Test7', role: 'Test', sex: 'Man', age: 29, address: 'test abc' },
  { id: 10008, name: 'Test8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' }
])

const collapsable = (key) => {
  foldMaps[key] = !foldMaps[key]
}
</script>

gitee.com/x-extends/v…

Vue 3 核心函数全解(组合式 API + 常用工具函数)

本文按最常用优先级分类整理,包含用法、场景和示例,覆盖开发 99% 的需求。

Vue 3 核心函数分为两大类:组合式 API 核心函数(写业务必用)、工具函数(辅助开发)。


一、组合式 API 核心函数(<script setup> 中必用)

1. ref() —— 定义基础类型响应式数据

  • 作用:把字符串、数字、布尔值等基础类型变成响应式
  • 取值/赋值:必须用 .value(模板中可省略)
  • 也可用于引用 DOM、组件实例
<script setup>
// 1. 导入函数
import { ref } from 'vue'

// 2. 定义响应式数据
const count = ref(0)
const msg = ref('Hello Vue3')

// 3. 修改数据(必须加 .value)
const add = () => count.value++
</script>

<template>
  <!-- 模板中直接用,无需 .value -->
  <p>{{ msg }}</p>
  <button @click="add">{{ count }}</button>
</template>

2. reactive() —— 定义对象/数组响应式数据

  • 作用:深度响应式,适用于对象、数组、复杂数据结构
  • 取值/赋值:无需 .value,直接操作
<script setup>
import { reactive } from 'vue'

// 定义对象/数组
const user = reactive({
  name: '张三',
  age: 18,
  hobbies: ['编程', '读书']
})

// 直接修改
const updateUser = () => {
  user.age++
  user.hobbies.push('运动')
}
</script>

3. computed() —— 计算属性

  • 作用:基于响应式数据派生新数据,自带缓存
  • 用法:只读计算属性、可写计算属性
<script setup>
import { ref, computed } from 'vue'
const count = ref(1)

// 只读计算属性(最常用)
const doubleCount = computed(() => count.value * 2)

// 可写计算属性
const writableCount = computed({
  get() { return count.value },
  set(val) { count.value = val }
})
</script>

4. watch() —— 侦听器

  • 作用:监听响应式数据变化,执行异步/复杂逻辑
  • 可监听:refreactive、多个数据源
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)

// 基础监听
watch(count, (newVal, oldVal) => {
  console.log('count变化:', newVal, oldVal)
})

// 监听 reactive 对象(必须指定属性/用 getter)
const user = reactive({ age: 18 })
watch(() => user.age, (newVal) => {})

// 立即执行 + 深度监听
watch(count, () => {}, {
  immediate: true,  // 初始化立即执行一次
  deep: true        // 深度监听(对象嵌套数据)
})
</script>

5. watchEffect() —— 自动追踪依赖侦听器

  • 优势:无需指定监听目标,自动追踪内部使用的响应式数据
  • 适用:简单监听、依赖不固定的场景
<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)

// 自动监听 count,变化立即执行
watchEffect(() => {
  console.log('最新count:', count.value)
})
</script>

6. defineProps() —— 子组件接收父组件传值

  • 专属 <script setup>无需导入
  • 用于定义组件 props(类型校验、默认值、必传)
<script setup>
// 子组件
const props = defineProps({
  title: {
    type: String,
    default: '默认标题',
    required: true
  },
  list: Array
})
// 直接使用 props.title
</script>

7. defineEmits() —— 子组件向父组件发送事件

  • 专属 <script setup>无需导入
  • 子组件触发事件,父组件监听接收数据
<script setup>
// 子组件:定义事件名
const emit = defineEmits(['update-count'])

// 触发事件
const sendToParent = () => {
  emit('update-count', 100)
}
</script>

8. defineExpose() —— 子组件暴露属性/方法给父组件

  • 作用:子组件主动暴露数据/方法,父组件通过 ref 调用
<script setup>
// 子组件
const childFn = () => console.log('子组件方法')
// 暴露出去
defineExpose({ childFn })
</script>

<!-- 父组件调用 -->
<Child ref="childRef" />
<script setup>
import { ref } from 'vue'
const childRef = ref(null)
// 调用子组件方法
childRef.value.childFn()
</script>

二、Vue 3 生命周期函数(组合式 API)

Vue 3 组合式 API 用函数形式调用,常用 4 个:

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

// 1. 组件挂载完成(DOM 渲染完毕,请求数据、操作DOM)
onMounted(() => {
  console.log('组件挂载')
  // 这里发接口请求最佳
})

// 2. 组件更新完成
onUpdated(() => {})

// 3. 组件卸载(清除定时器、解绑事件)
onUnmounted(() => {
  clearInterval(timer)
})
</script>

三、工具函数(高频实用)

1. toRefs() —— 解构 reactive 不丢失响应式

  • 问题:直接解构 reactive 对象会失去响应式
  • 解决:用 toRefs 转为响应式 ref
<script setup>
import { reactive, toRefs } from 'vue'
const user = reactive({ name: '张三', age: 18 })

// 正确:解构后仍响应式
const { name, age } = toRefs(user)
</script>

2. toRef() —— 提取对象单个属性为响应式

const age = toRef(user, 'age')

3. nextTick() —— DOM 更新后执行回调

  • 适用:修改数据后,立即操作最新 DOM
import { nextTick } from 'vue'
const updateData = async () => {
  count.value++
  // 等待 DOM 更新完成
  await nextTick()
  // 操作最新 DOM
}

四、完整示例(整合核心函数)

<template>
  <div>
    <h2>{{ title }}</h2>
    <p>计数:{{ count }}</p>
    <p>双倍计数:{{ doubleCount }}</p>
    <button @click="add">+1</button>
  </div>
</template>

<script setup>
// 1. 导入核心函数
import { ref, computed, watch, onMounted } from 'vue'

// 2. Props 接收
defineProps({
  title: String
})

// 3. 响应式数据
const count = ref(0)

// 4. 计算属性
const doubleCount = computed(() => count.value * 2)

// 5. 方法
const add = () => count.value++

// 6. 侦听
watch(count, (val) => {
  console.log('计数变为:', val)
})

// 7. 生命周期
onMounted(() => {
  console.log('组件初始化完成')
})
</script>

总结

  1. 基础数据用 ref,对象/数组用 reactive
  2. 派生数据用 computed,监听变化用 watch/watchEffect
  3. 组件通信:defineProps(父→子)、defineEmits(子→父)
  4. 生命周期核心:onMounted(请求数据)、onUnmounted(清理)
  5. 解构响应式对象:必用 toRefs

这些是 Vue 3 开发最核心、最常用的函数,掌握它们就能完成绝大多数业务开发。

HTTP状态查询 在线工具核心JS实现

这篇文章只讲本项目里“HTTP状态查询”工具的功能 JavaScript 实现。它的目标很明确:用户输入一个网址后,返回当前状态码、重定向链路、响应头、页面标题、IP 和耗时等信息。

在线工具网址:see-tool.com/http-status…
工具截图:
工具截图.png

整个实现可以拆成 4 段:输入规范化、请求触发、服务端逐跳探测、结果整理展示。

1)输入先做规范化

这个工具不会直接拿用户原始输入去请求,而是先统一处理:

  • 去掉首尾空格和中间多余空白
  • 如果没写协议,自动补上 http://
  • URL 构造函数校验格式
  • 只允许 httphttps

这样做的好处是,像 example.comhttps://example.com 这种输入都能被转换成稳定可请求的地址,非法内容则会被提前拦下。

const normalizeUrl = (value) => {
  const rawValue = String(value || "").trim();
  if (!rawValue) return "";

  const cleaned = rawValue.replace(/\s+/g, "");
  const withProtocol = /^https?:\/\//i.test(cleaned)
    ? cleaned
    : `http://${cleaned}`;

  try {
    const target = new URL(withProtocol);
    if (!["http:", "https:"].includes(target.protocol)) return "";
    return target.toString();
  } catch {
    return "";
  }
};

2)前端状态围绕“查询过程”设计

前端没有把逻辑拆得很散,而是直接围绕一次查询需要的状态来组织:

  • urlInput:输入框内容
  • isLoading:是否正在查询
  • errorMessage:错误提示
  • resultData:接口返回的完整结果
  • pendingUrl:当前准备发送的规范化 URL

结果展示时,再通过计算属性把 resultData 拆成页面标题、结果列表和摘要文案。这样界面层只负责渲染,不需要重复处理原始数据。

3)服务端核心是“手动接管跳转链”

真正的核心不在于请求一次 URL,而在于把每一跳都查出来。实现上使用循环逐跳请求,并把 redirect 设为 manual,这样程序不会自动跟随跳转,而是自己读取 Location,再决定下一跳。

const isRedirectStatus = (statusCode) =>
  [301, 302, 303, 307, 308].includes(statusCode);

for (let i = 0; i <= MAX_REDIRECTS; i += 1) {
  if (visited.has(currentUrl)) break;
  visited.add(currentUrl);

  const { result, title: pageTitle } = await requestOnce(currentUrl, i + 1);
  results.push(result);

  if (!isRedirectStatus(result.code) || !result.location) {
    break;
  }

  currentUrl = new URL(result.location, currentUrl).toString();
}

这里有两个关键点:

  • visited 记录已经访问过的地址,避免循环跳转
  • new URL(result.location, currentUrl) 兼容相对跳转地址

所以用户最后看到的不是单个状态码,而是一整条请求链路。

4)单次请求会提取多种信息

每请求一跳,都会同时收集一组结构化结果:

  • codestatusText
  • contentType
  • cacheControl
  • responseDate
  • server
  • location
  • totalTime
  • head(原始响应头文本)

耗时的计算方式也很直接:请求前记开始时间,响应结束后减一次时间戳,最后拼成 123ms 这种格式。

5)页面标题不是直接取字符串,而是先按内容类型解码

如果响应是 HTML,工具还会继续读取正文前一部分内容,用于提取页面 <title>。这里有两个步骤:

第一步,先根据 content-type 里的 charset 选择解码方式;第二步,再从 HTML 里匹配标题标签。

const extractTitleFromHtml = (html) => {
  const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
  if (!match) return "";
  return match[1].replace(/\s+/g, " ").trim();
};

这样即使页面不是 UTF-8,只要响应头里带了字符集,标题也能尽量正确显示。

6)IP 和端口信息来自额外解析

HTTP 响应本身不会直接告诉你目标域名解析到了哪个 IP,所以这里额外做了一次域名解析。协议是 https 时默认端口记为 443,否则记为 80。这样结果里除了状态码,还能把访问目标的基础网络信息一起展示出来。

7)前端会再做一层结果归纳

查询结果返回后,前端不是机械地把数组打印出来,而是根据最后一个状态码和是否发生重定向,生成更容易理解的摘要:

  • 2xx:访问成功
  • 3xx 且有跳转:发生重定向
  • 4xx:客户端错误
  • 5xx:服务器错误

同时还会按状态码给文字加不同颜色,让用户一眼区分成功、跳转和异常结果。

8)这套实现的关键点

这个工具的功能 JS,本质上是在做一条清晰的数据链:

输入 URL -> 规范化 -> 逐跳请求 -> 提取状态与响应头 -> 解析标题/IP/耗时 -> 生成可读结果

从实现角度看,最关键的不是“发起请求”本身,而是把跳转链、响应头、标题和状态归纳成一份普通用户也能看懂的结果。这也是这个 HTTP状态查询 工具的核心实现思路。

uniapp + Vue 自定义组件封装:自定义样式从入门到实战

uniapp + Vue 自定义组件封装:自定义样式从入门到实战

今天沉浸式学习了 uniapp 中 Vue 自定义组件的封装,重点突破了「自定义样式」这个核心难点——很多新手封装组件时,要么样式冲突、要么无法灵活适配不同场景,其实掌握关键技巧后,自定义样式可以做到既规范又灵活。这篇笔记就把今天的学习成果整理出来,从基础封装到样式自定义,一步步拆解,适合和我一样正在入门的小伙伴参考~

先明确核心目标:封装的自定义组件,不仅要实现复用性,还要支持外部灵活修改样式,同时避免样式污染全局,兼顾易用性和规范性。下面从「组件基础封装」→「自定义样式实现」→「避坑实战」三个维度,结合具体代码讲解,全程可复制实操。

一、基础铺垫:自定义组件的基本封装流程

在 uniapp 中封装 Vue 自定义组件,和纯 Vue 项目思路一致,但要适配 uniapp 的页面结构和语法规范,核心步骤就3步,先搭好基础框架:

1. 新建组件文件

在项目的 components 目录下,新建组件文件夹(如 my-custom-btn),创建 my-custom-btn.vue 文件,这是组件的核心文件。

2. 编写组件基础结构

组件由 template(结构)、script(逻辑)、style(样式)三部分组成,先写一个简单的按钮组件作为示例,后续逐步完善样式自定义:

<template>
  <!-- 组件基础结构 -->
  <view class="custom-btn" @click="handleClick"&gt;
    &lt;slot&gt;默认按钮&lt;/slot&gt; <!-- 插槽支持外部传入按钮文本 -->
  </view>
</template>

<script>
export default {
  name: 'MyCustomBtn', // 组件名称,必填(便于注册和识别)
  props: {
    // 先定义基础props,后续添加样式相关props
    type: {
      type: String,
      default: 'primary' // 按钮默认类型
    }
  },
  methods: {
    handleClick() {
      // 组件点击事件,通过$emit向父组件传值
      this.$emit('click', '按钮被点击啦')
    }
  }
}
</script>

<style scoped>
/* 基础样式,先写固定样式,后续改为可自定义 */
.custom-btn {
  width: 120rpx;
  height: 60rpx;
  line-height: 60rpx;
  text-align: center;
  border-radius: 30rpx;
  background-color: #007aff; /* 默认蓝色 */
  color: #fff;
  font-size: 28rpx;
}
</style>

3. 注册并使用组件

组件封装好后,需要在页面中注册才能使用,有两种注册方式,根据复用频率选择:

方式1:局部注册(仅当前页面使用)
<template>
  &lt;view&gt;
    <!-- 使用自定义组件 -->
    <my-custom-btn @click="handleBtnClick">点击我</my-custom-btn>
  </view>
</template>

<script>
// 引入组件
import MyCustomBtn from '@/components/my-custom-btn/my-custom-btn.vue'
export default {
  components: {
    MyCustomBtn // 注册组件
  },
  methods: {
    handleBtnClick(msg) {
      console.log(msg) // 接收组件传过来的事件
    }
  }
}
</script>
方式2:全局注册(所有页面可使用)

main.js 中注册,无需在每个页面单独引入:

import Vue from 'vue'
import MyCustomBtn from '@/components/my-custom-btn/my-custom-btn.vue'
// 全局注册组件
Vue.component('MyCustomBtn', MyCustomBtn)

二、核心重点:自定义样式的3种实现方式

这是今天学习的核心!封装组件时,固定样式无法满足不同页面的需求(比如有的页面需要红色按钮,有的需要圆角更大),因此需要支持「外部传入样式」,同时避免样式污染。推荐3种实用方式,从简单到灵活,按需选择。

方式1:通过 props 传值控制样式(最基础、最常用)

核心思路:在组件中定义样式相关的 props(如背景色、字体大小、圆角等),外部使用组件时,通过传入 props 覆盖默认样式,适合样式修改场景较少的情况。

修改上面的按钮组件,添加样式相关 props:

<template>
  <view 
    class="custom-btn" 
    @click="handleClick"
    :style="{
      backgroundColor: bgColor,
      color: textColor,
      borderRadius: borderRadius,
      fontSize: fontSize + 'rpx'
    }"
  >
    <slot>默认按钮</slot>
  </view>
</template>

<script>
export default {
  name: 'MyCustomBtn',
  props: {
    type: {
      type: String,
      default: 'primary'
    },
    // 样式相关props,都设置默认值,保证外部不传入时也能正常显示
    bgColor: {
      type: String,
      default: '#007aff' // 默认蓝色
    },
    textColor: {
      type: String,
      default: '#fff' // 默认白色文本
    },
    borderRadius: {
      type: String,
      default: '30rpx' // 默认圆角
    },
    fontSize: {
      type: Number,
      default: 28 // 默认字体大小(单位rpx,外部传入数字即可)
    }
  },
  methods: {
    handleClick() {
      this.$emit('click', '按钮被点击啦')
    }
  }
}
</script>

<style scoped>
/* 保留基础样式,动态样式通过:style绑定 */
.custom-btn {
  width: 120rpx;
  height: 60rpx;
  line-height: 60rpx;
  text-align: center;
}
</style>

外部使用时,传入需要修改的样式 props 即可,未传入的会使用默认值:

<!-- 自定义背景色和文本色 -->
<my-custom-btn 
  bgColor="#ff3333" 
  textColor="#fff"
  @click="handleBtnClick"
>
  红色按钮
</my-custom-btn>

<!-- 自定义圆角和字体大小 -->
<my-custom-btn 
  borderRadius="10rpx" 
  fontSize="32"
  @click="handleBtnClick"
>
  小字体按钮
</my-custom-btn>

方式2:通过 style 传入自定义类(灵活度更高)

核心思路:组件支持外部传入自定义 class,通过 :class 绑定,实现更复杂的样式自定义(比如渐变、阴影、hover效果),适合样式差异较大的场景。

修改组件,添加 customClass props,用于接收外部传入的类名:

<template>
  <view 
    class="custom-btn" 
    :class="customClass" // 绑定外部传入的类
    @click="handleClick"
  >
    <slot>默认按钮</slot>
  </view>
</template>

<script>
export default {
  name: 'MyCustomBtn',
  props: {
    // 新增:接收外部自定义类名
    customClass: {
      type: String,
      default: ''
    },
    // 保留之前的基础props
    bgColor: {
      type: String,
      default: '#007aff'
    }
  },
  // ... 其他代码不变
}
</script>

<style scoped>
.custom-btn {
  width: 120rpx;
  height: 60rpx;
  line-height: 60rpx;
  text-align: center;
  border-radius: 30rpx;
  background-color: v-bind(bgColor); // 也可以用v-bind绑定props中的样式
  color: #fff;
  font-size: 28rpx;
}
</style>

外部页面中,先定义自定义样式类,再传入组件:

<template>
  <view>
    <my-custom-btn 
      customClass="gradient-btn" 
      @click="handleBtnClick"
    >
      渐变按钮
    </my-custom-btn>
  </view>
</template>

<style scoped>
/* 外部自定义样式类 */
.gradient-btn {
  background: linear-gradient(to right, #ff3366, #ff9900); /* 渐变背景 */
  box-shadow: 0 2rpx 10rpx rgba(255, 51, 102, 0.3); /* 阴影效果 */
}
.gradient-btn:hover {
  opacity: 0.9; /*  hover效果 */
}
</style>

注意:如果组件样式用了 scoped(避免样式污染),外部传入的类名可能无法生效,此时有两种解决方案:

  • 方案1:外部样式类不使用 scoped(不推荐,可能污染全局);
  • 方案2:组件中使用深度选择器 ::v-deep(推荐),修改组件样式如下:
<style scoped>
.custom-btn {
  /* 基础样式不变 */
}
/* 深度选择器:穿透scoped,让外部传入的类生效 */
::v-deep .gradient-btn {
  background: linear-gradient(to right, #ff3366, #ff9900);
  box-shadow: 0 2rpx 10rpx rgba(255, 51, 102, 0.3);
}
</style>

方式3:通过 slot 插入样式(极致灵活)

核心思路:如果组件的样式差异极大,甚至结构也有变化,可通过 slot 插入自定义样式(或整个结构),适合复杂场景,比如组件内部部分区域需要完全自定义。

修改组件,添加样式插槽(或结构插槽):

<template>
  &lt;view class="custom-btn" @click="handleClick"&gt;
    <!-- 插槽:支持外部传入整个内容(包括样式) -->
    <slot name="content">
      <view class="default-content">默认按钮</view>
    </slot>
  </view>
</template>

<script>
// ... 逻辑不变
</script>

<style scoped>
.custom-btn {
  width: 120rpx;
  height: 60rpx;
  line-height: 60rpx;
  text-align: center;
  border-radius: 30rpx;
  background-color: #007aff;
}
.default-content {
  color: #fff;
  font-size: 28rpx;
}
</style>

外部使用时,通过插槽插入自定义内容和样式,完全覆盖默认内容:

<my-custom-btn @click="handleBtnClick">
  <template #content>
    <view class="custom-content">
      <image src="/static/btn-icon.png" mode="widthFix" class="btn-icon"></image>
      <text class="btn-text">带图按钮</text>
    </view>
  </template>
</my-custom-btn>

<style scoped>
.custom-content {
  display: flex;
  align-items: center;
  justify-content: center;
  color: #333;
  font-weight: bold;
}
.btn-icon {
  width: 30rpx;
  height: 30rpx;
  margin-right: 8rpx;
}
</style>

三、避坑指南:今天踩过的3个小坑

学习过程中遇到了几个常见问题,整理出来,帮大家少走弯路:

  1. 样式污染问题:忘记给组件样式加 scoped,导致组件样式影响全局页面,解决:给组件的 style 标签加上 scoped,如需穿透,用 ::v-deep
  2. props 传值类型错误:传入字体大小时,误传字符串(如 fontSize="32"),导致样式不生效,解决:props 中定义 fontSize 为 Number 类型,外部传入数字(如 fontSize="32" 改为 :fontSize="32",绑定数字)。
  3. uniapp 样式单位问题:习惯用 px 单位,导致不同设备适配异常,解决:uniapp 中推荐用 rpx 单位,自动适配不同屏幕,组件样式统一用 rpx。

四、学习总结

今天通过实操掌握了 uniapp 中 Vue 自定义组件封装的核心,尤其是自定义样式的3种实现方式,总结下来:

  • 简单样式修改:用 props 传值绑定 inline-style,高效快捷;
  • 复杂样式修改:用 props 传自定义类名 + 深度选择器,灵活度高;
  • 极致灵活场景:用 slot 插入自定义内容和样式,适配各种复杂需求。

其实自定义组件封装的核心就是「复用性」和「灵活性」,样式自定义更是如此——既要保证组件本身的规范性,又要支持外部按需修改。后续还要继续学习组件的生命周期、props 校验、事件传值等进阶内容,慢慢打磨组件封装能力~

如果小伙伴们有更好的样式自定义技巧,欢迎在评论区交流,一起进步!💪

Canvas 直线点击事件处理优化

    在平常Canvas开发中,经常会遇到直线的点击事件问题,对于这类问题通常的做法就是使用isPointInStroke,但直接使用存在一个问题就是直线的宽度较小时,鼠标点击不太容易选中。下面是针对这类问题总结的一些优化方法。

使用isPointInStroke

    平常开发中,经常使用isPointInStroke方法判断鼠标点击位置是否位于直线上,常规代码如下:

<script setup>
    import { ref, onMounted } from 'vue';
    
    const canvasRef = ref();
    let ctx;
    let isLineSelected = false;
    
    // 直线的起点和终点坐标
    const lineStart = { x: 100, y: 200 };
    const lineEnd = { x: 500, y: 200 };
    
    const clear = () => {
        // 清除画布
        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    }
    
    // 绘制直线的函数
    const drawLine = () => {
    
        // 设置线条样式
        ctx.strokeStyle = isLineSelected ? '#ff0000' : '#000000';
        ctx.lineWidth = isLineSelected ? 4 : 2;
    
        // 绘制直线
        ctx.beginPath();
        ctx.moveTo(lineStart.x, lineStart.y);
        ctx.lineTo(lineEnd.x, lineEnd.y);
        ctx.stroke();
    };
    onMounted(() => {
        if (canvasRef.value) {
            const canvas = canvasRef.value;
            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight;
            ctx = canvas.getContext('2d');
    
            drawLine();
    
            // 添加鼠标点击事件监听器
            canvasRef.value.addEventListener('click', e => {
                const rect = canvasRef.value.getBoundingClientRect();
                const x = e.clientX - rect.left;
                const y = e.clientY - rect.top;
    
                if (ctx.isPointInStroke(x, y)) {
                    isLineSelected = !isLineSelected;
                    clear()
                    drawLine();
                }
            });
        }
    });
</script>    

    这样我们就可以实现鼠标点击的选中效果,但是这种方法并不完美,当线的宽度较小时,这是就很难选中这条线。 下面我们来优化一下,依旧使用isPointInStroke这个方法,代码如下:

// 检测点击是否在直线上的函数
const isPointOnLine = (x, y) => {
    if (!ctx) return false;

    // 创建直线路径
    ctx.beginPath();
    ctx.moveTo(lineStart.x, lineStart.y);
    ctx.lineTo(lineEnd.x, lineEnd.y);

    // 设置鼠标点击时的容错率
    ctx.lineWidth = 10;

    // 使用 Canvas API 的 isPointInStroke 方法检测点击是否在直线上
    return ctx.isPointInStroke(x, y);
};
if (isPointOnLine(x, y)) {
    isLineSelected = !isLineSelected;
    clear()
    drawLine();
}

    我们把判断条件写成一个方法,在判断之前模拟一条起始坐标和终点坐标相同的线,为了解决线的宽度较小时不太容易选中的问题, 我们在模拟这条线是设置一个较大的宽度,这样就可以优化鼠标点击时不容易选中的问题了。

使用点到直线的距离公式

    除了使用isPointInStroke方法判断鼠标点击位置是否位于直线上,我们还可以使用点到直线的距离公式判断鼠标点击位置是否位于直线上。计算点到直线的距离公式有很多种方法,比如一般式、参数式、向量式等。因为这里我们已知直线的两个坐标和鼠标点击 位置的坐标,使用向量叉积来计算点到直线的距离更为方便。
    点到直线的距离公式如下: iShot_2026-03-07_17.26.21.png     其中,(x1,y1)(x1, y1)(x2,y2)(x2, y2) 是直线的两个坐标,(x0,y0)(x0, y0) 是鼠标点击位置的坐标。代码实现如下:

/**
 * 计算点到直线的距离
 * @param x0 点的 x 坐标
 * @param y0 点的 y 坐标
 * @param x1 直线上一点的 x 坐标
 * @param y1 直线上一点的 y 坐标
 * @param x2 直线上另一点的 x 坐标
 * @param y2 直线上另一点的 y 坐标
 * @param threshold 距离阈值,默认为 10
 * @returns 点到直线的距离是否小于阈值
 */
function pointToLineDistance(x0, y0, x1, y1, x2, y2, threshold = 10) {
  // 计算向量 AB
  const vectorABx = x2 - x1;
  const vectorABy = y2 - y1;

  // 计算向量 AP
  const vectorAPx = x0 - x1;
  const vectorAPy = y0 - y1;

  // 计算叉乘的绝对值(点到直线的距离的分子)
  const crossProduct = Math.abs(vectorABx * vectorAPy - vectorABy * vectorAPx);

  // 计算线段 AB 的长度
  const segmentLength = Math.hypot(vectorABx, vectorABy);

  // 处理线段长度为 0 的情况(两点重合)
  if (segmentLength < 1e-6) {
    // 计算点到点的距离
    const pointDistance = Math.hypot(vectorAPx, vectorAPy);
    return pointDistance < threshold;
  }

  // 计算点到直线的距离
  const distance = crossProduct / segmentLength;

  return distance < threshold;
}

总结

    这两种方法都可以解决线的宽度较小时鼠标点击不容易选中的问题。在数据量不是很大的时候,推荐使用isPointInStroke方法, 在Canvas中直线是最小的单位,创建和绘制直线都是非常快的操作,不会对性能造成太大影响。当数据量大的时候,频繁的创建也会导致性能问题,这时候使用数学方法计算点到直线的距离会更加高效,不依赖 Canvas 状态,计算精确,可定制性强。

从递归组件到 DSL 引擎:我造了一个让 AI 能"搭 UI"的运行时

从递归组件到 DSL 引擎:我造了一个让 AI 能"搭 UI"的运行时

我最初只是想用 Vue 递归组件做动态渲染,后来发现这条路的天花板比想象中低得多。这篇文章记录了我从零设计一个 Schema-Driven 渲染引擎的过程——踩过的坑、做过的取舍、以及为什么我认为这种架构天然适合 AI 时代。


一、起点:递归组件的天花板

故事的起点很简单。我要做一个低代码平台,需要根据 JSON 配置动态渲染 UI。最直觉的方案是 Vue 的递归组件:

<template>
  <component :is="node.type" v-bind="node.props">
    <DynamicRenderer
      v-for="child in node.children"
      :key="child.id"
      :node="child"
    />
  </component>
</template>

一开始能跑通。但随着需求复杂度上升,问题一个接一个冒出来:

性能没法深入优化——每个递归组件都是一个完整的 Vue 组件实例,有自己的生命周期、reactive 系统开销。100 个节点就是 100 个组件实例,1000 个节点时页面已经开始卡了。你没有办法跳过没变化的子树,因为 Vue 的响应式系统是按组件粒度工作的。

事件处理不好做——JSON 里写的是 { event: 'click', handler: 'submitForm' },但递归组件要把这个字符串映射成真实的函数调用,你得自己写一套 $emit 转发链,越写越像在造一个 mini 框架。

双向绑定更麻烦——v-model 在递归组件里要一层层 $emit('update:modelValue') 往上冒泡,或者搞一个全局 store 做中间层,写法又丑又容易出 bug。

表达式求值是个坑——JSON 里写 "disabled": "{{ !isValid }}",你要么 eval() 一下(安全隐患),要么自己写个表达式解析器(工作量巨大),反正递归组件本身帮不了你。

我意识到,递归组件方案的本质问题是:它还是在用"组件"的粒度思考,但 Schema 驱动的 UI 需要的是"节点"粒度的控制权

于是我开始想:如果不用递归组件,而是直接把 Schema 编译成 VNode 呢?如果把"事件处理"抽成一个指令集虚拟机呢?如果把表达式解析做成一个安全沙箱呢?

这就是 Vario 的起点。


二、Vario 全貌:三层解耦的 Schema 渲染运行时

先交代 Vario 的完整架构。它不是一个组件库,不是一个低代码平台,是一个 Schema 渲染运行时——由 4 个包组成的 monorepo,总共约 10,000 行 TypeScript 源码,579 个单元/集成测试全部通过。

@variojs/types   — 跨包共享类型(无业务逻辑,消除循环依赖)
@variojs/core    — Action VM + 表达式引擎 + RuntimeContext(零 Vue 依赖)
@variojs/schema  — defineSchema + 验证 + 规范化
@variojs/vue     — useVario composable + VNode 渲染器

数据流是单向的:

Schema (JSON 对象)
     ↓  normalizeSchemaNode()  规范化(空格/格式统一,WeakMap 缓存)
     ↓  validateSchema()       结构验证 + 表达式 AST 白名单校验
     ↓
@variojs/core
     ↓  createRuntimeContext()  创建状态上下文(Proxy 保护系统 API)
     ↓  evaluate()             表达式求值(Babel AST → 白名单 → 编译/解释)
     ↓  execute()              Action VM 执行指令序列(超时 5s,最大 10000 步)
     ↓
@variojs/vue
     ↓  useVario()             Composition API 入口
     ↓  VueRenderer.render()   Schema 递归 → VNode 树
     ↓  Path Memo              缓存无变化的子树 VNode
     ↓
Vue 3 接管渲染

关键架构约束@variojs/core 零 Vue 依赖,这是从第一天就定下的硬性要求。Core 里的 Action VM、表达式引擎、RuntimeContext 完全不知道 Vue 的存在——这意味着将来换成 React、Solid、甚至 Node.js 服务端渲染,Core 层不需要改一行代码。


三、先看看 Vario 写出来长什么样

直接上代码。一个带交互逻辑的表单:

import { useVario } from '@variojs/vue'

const { vnode, state } = useVario({
  type: 'ElForm',
  props: { labelWidth: '100px' },
  children: [
    {
      type: 'ElFormItem', props: { label: '姓名' },
      children: [{ type: 'ElInput', model: 'name', props: { clearable: true } }]
    },
    {
      type: 'ElFormItem', props: { label: '邮箱' },
      children: [{ type: 'ElInput', model: 'email', props: { type: 'email' } }]
    },
    {
      type: 'ElButton',
      props: { type: 'primary', disabled: '{{ !(name && email) }}' },
      events: { 'click.prevent': [{ type: 'call', method: 'submit' }] },
      children: '提交'
    }
  ]
}, {
  state: { name: '', email: '' },
  computed: { isValid: (s) => !!(s.name && s.email) },
  methods: {
    submit: ({ state }) => { console.log('提交:', state.name, state.email) }
  }
})

如果你写过 Vue,你会发现:ElInputElButtonElFormItem 就是 Element Plus 的组件名,model: 'name' 就是 v-modelclick.prevent 就是 @click.preventuseVario() 返回的 { vnode, state } 就是标准的 Composition API 用法。

这是有意为之的设计。


四、深入 VueRenderer——Schema 如何变成 VNode

VueRenderer 是整个渲染链的核心,638 行代码,内部采用 DI 风格拆分为 9 个专职模块:

模块 职责
ComponentResolver 组件类型解析(80+ 原生 HTML 标签 Set + 全局组件 Map 缓存)
ModelPathResolver model 路径解析(228 行,支持嵌套循环变量 $item 解析、路径栈拼接)
ExpressionEvaluator 表达式求值(桥接 @variojs/core 的 evaluate)
EventHandler 事件绑定(366 行,6 种事件处理器格式规范化,修饰符解析)
AttrsBuilder 属性构建(props 表达式求值 + model 绑定 + 事件合并)
LoopHandler 循环渲染(createLoopContext 对象池复用 + Fragment 包裹)
ChildrenResolver 子节点解析(文本插值 / 作用域插槽 / VNode 子树)
LifecycleWrapper 生命周期包装(6 个 Vue 生命周期钩子 + provide/inject)
PathMemoCache VNode 缓存(路径 + schemaId + 依赖键三级缓存键)

一个 createVNode() 调用的完整流程(20 个步骤):

createVNode(schema, ctx, path)
 1. ─ 验证 schema.type 存在
 2. ─ cond 条件渲染:表达式 falsy → return null
 3. ─ show 预求值:计算可见性用于依赖追踪
 4. ─ Path Memo 判断:无 loop/model/表达式的静态子树 → 直接返回缓存
 5. ─ 子树组件化判断:shouldComponentize() → VarioNode 独立组件
 6. ─ Loop 处理:委托 LoopHandler → Fragment(循环项VNode[])
 7. ─ 组件解析:原生标签返回字符串,自定义组件 markRaw() 防响应式
 8. ─ Model 路径栈更新:嵌套 model 路径拼接
 9. ─ 属性构建:props 表达式求值 + model 双向绑定 + 事件处理器
10. ─ 子节点解析:递归 VNode / 插值文本 / 作用域插槽
11. ─ show 可见性:{display: 'none'} 合并到 style
12. ─ Children 格式化:原生元素用数组,组件用函数插槽
13. ─ 生命周期/provide-inject:有则创建 LifecycleWrapper 组件
14. ─ ref 绑定:attachRef 到 RefsRegistry
15. ─ 自定义指令:withDirectives() 应用
16. ─ KeepAlive 包裹
17. ─ Transition 包裹
18. ─ Teleport 包裹
19. ─ Path Memo 写入缓存
20. ─ 返回 VNode

这 20 步的排列顺序不是随意的——Teleport 必须是最外层包裹(否则内部元素不会被传送),KeepAlive 必须在 Transition 之前(Vue 的渲染约束),Path Memo 的缓存判断必须在 Loop 之前(带循环的子树不能缓存)。

双向绑定是怎么做的

createModelBinding() 是整个渲染器最复杂的单个函数(310 行),需要处理:

  • 原生表单元素 (input/textarea/select)——不同元素用不同事件名和属性名
  • Vue 3 组件——modelValue + update:modelValue 协议
  • 具名 model——model:checkedmodel:value 支持一个组件绑定多个 model
  • 修饰符——.trim(去空格),.number(parseFloat),.lazy(change 替代 input)
  • lazy 模式——setTimeout(() => isActive = true, 0) 延迟激活,挂载期间不写 state
  • 自定义绑定协议——通过 registerModelConfig() 注册

ctx ↔ Vue 状态同步——ReactiveAdapter 单一数据源

早期版本中,useVario() 需要在 RuntimeContext 的 plain object 和 Vue 的 reactive state 之间维护双向同步,靠三把锁(syncing / syncingPaths / watchSyncing)防止循环触发。这套机制能跑,但脆弱且难以理解。

当前版本已经用 ReactiveAdapter 协议彻底消灭了这个问题。核心思路受 Zustand 启发——状态只有一份:

// @variojs/types 中定义协议
interface ReactiveAdapter {
  get(path: string): unknown
  set(path: string, value: unknown): void
  getProperty(key: string): unknown
  setProperty(key: string, value: unknown): void
  has(key: string): boolean
  keys(): string[]
}

Vue 层提供 createVueReactiveAdapter(reactiveState),内部直接操作 reactive() 对象。Core 的 createRuntimeContext 接受 adapter 参数后,_get/_set 通过 adapter 读写,Proxy 的 5 个 trap(get/set/has/ownKeys/getOwnPropertyDescriptor)也路由到 adapter。

// useVario 中,三重锁被替换为两行代码:
const adapter = createVueReactiveAdapter<TState>(reactiveState)
const ctx = createRuntimeContext<TState>({}, { adapter, onStateChange, ... })

没有双份状态 = 没有同步 = 没有循环 = 不需要锁。 ctx._set('name', 'Alice') 直接写入 Vue 的 reactive 对象,onStateChange 只做缓存失效和渲染调度,不再做状态搬运。useVario 从 636 行减到 570 行,核心同步逻辑从 ~65 行减到 ~10 行。


五、Action VM:不用 eval 的动作执行引擎

传统方案处理"交互逻辑"的方式是往框架里挂副作用——watch、reaction、onChange。Vario 走的是完全不同的路:指令集虚拟机

当前支持 13 种指令,分 5 个类别:

类别 指令
状态 set { type: 'set', path: 'user.name', value: '{{ input }}' }
数组 push pop shift unshift splice { type: 'push', path: 'todos', value: { text: '{{ newText }}' } }
调用 call { type: 'call', method: 'submit', params: { id: '{{ userId }}' }, resultTo: 'result' }
流控 if loop batch { type: 'if', cond: '{{ isValid }}', then: [...], else: [...] }
通信 emit navigate log { type: 'navigate', to: '{{ targetUrl }}' }

这些指令之间是正交组合的关系——ifthen/else 分支里可以嵌套任何指令,loopbody 里也可以,batch 可以包裹一组指令并做错误聚合(所有指令都执行,收集所有错误,最后统一抛出 BatchError)。

执行器的核心设计:不是 switch/case——所有动作(包括内置的 13 种)通过 ctx.$methods[action.type] 统一分派。这意味着你可以注册自定义指令类型,和内置指令完全平等。

一个真实的 Todo App 中"按下 Enter 添加待办"的事件定义:

{
  "events": {
    "keyup": [{
      "type": "if",
      "cond": "{{ $event.key === 'Enter' }}",
      "then": [{ "type": "call", "method": "addTodo" }]
    }]
  }
}

这里 $event 是运行时注入的 DOM 事件对象。if 指令先用表达式引擎求值 cond,为 true 时执行 then 分支里的 call 指令。整个过程不需要一行 JavaScript 事件处理代码。

call 指令的三种参数形式

// 字符串表达式——整个 params 是一个表达式求值结果
{ "type": "call", "method": "search", "params": "{{ keyword }}" }

// 对象命名参数——逐属性求值
{ "type": "call", "method": "addToCart", "params": { "id": "{{ product.id }}", "qty": 1 } }

// 数组位置参数——逐元素求值
{ "type": "call", "method": "calc", "params": ["{{ a }}", "{{ b }}"] }

resultTo 字段可以把方法返回值写回状态:{ type: 'call', method: 'fetchUser', resultTo: 'currentUser' } —— 这让你可以在纯 JSON 中编排异步数据流。

安全保护

  • 超时 5 秒(AbortController + Date.now 双重保护)
  • 最大执行步数 10000 步
  • 独立的错误类型层级:VarioError → ActionError / ExpressionError / ServiceError / BatchError
  • 18 个标准错误码(ACTION_TIMEOUTSERVICE_NOT_FOUNDEXPRESSION_UNSAFE_ACCESS 等)

Schema 和 methods 的刻意分离

这里要说清楚一个设计边界——Schema 是"做什么"(纯数据,可序列化),methods 是"怎么做"(JS 函数,在代码库里,走 git 管理)。

{ type: 'call', method: 'addTodo' } 这条指令可以存进数据库、被 AI 生成、被服务端下发。但 addTodo 这个函数本身不在 Schema 里——它是你预先注册的业务代码。这不是缺陷,这是安全边界。 如果函数也能动态下发执行,等于在数据库里存了可执行代码,这是经典的安全漏洞。


六、表达式沙箱:Babel AST + 白名单 + 编译器 + LRU 缓存

在 Schema 里你可以写表达式:

{ "children": "Hello {{ name }}" }
{ "props": { "disabled": "{{ !(name && email) }}" } }
{ "cond": "{{ user.role === 'admin' }}" }
{ "children": "{{ items.filter(i => i.active).length }} 项激活" }

表达式引擎是整个 Core 里最大的模块(1,450 行),完整的处理流水线是:

"{{ user.name || 'Guest' }}"
    ↓ extractExpression()
"user.name || 'Guest'"
    ↓ getCachedExpression() → 命中? → 直接返回
    ↓ parseExpression() → @babel/parser
AST: LogicalExpression { left: MemberExpression, right: StringLiteral }
    ↓ validateAST() → 白名单逐节点检查
    ↓ compileSimpleExpression() → 简单表达式? → (ctx) => ctx._get("user.name") 快速路径
    ↓ evaluateExpression() → 复杂表达式? → AST 解释执行(682 行完整求值器)
    ↓ extractDependencies() + setCachedExpression() → LRU 缓存
→ "Alice"

白名单验证——逐 AST 节点检查

允许的(17 种节点类型)MemberExpressionOptionalMemberExpressionArrayExpressionObjectExpressionIdentifierBinaryExpressionLogicalExpressionUnaryExpressionConditionalExpressionCallExpressionTemplateLiteral 等。

永久禁止的(10 种节点类型)AssignmentExpression(赋值)、ArrowFunctionExpression(箭头函数)、ThisExpressionNewExpressionAwaitExpressionImportExpressionUpdateExpression++/--)、YieldExpressionMetaPropertySpreadElement

函数调用安全模型

  • 白名单全局函数:Math.*(abs/round/floor/ceil/random/max/min)、Array.isArrayObject.isNumber.isFinite/isInteger/isNaNDate.now
  • 数组实例方法:30 个安全方法(filter/map/find/includes/slice/concat/join/sort/at 等),push/pop/splice 等修改型方法被排除
  • 全局对象访问:window/document/global/ globalThis/self 引用被永久阻止
  • 危险属性:constructor/prototype/__proto__ 访问被禁止
  • 危险函数:eval/Function/setTimeout/setInterval 被永久禁止

编译器——简单表达式的快速路径

对于 {{ count }}{{ user.name }}{{ 42 }} 这种简单表达式,不需要走完整的 AST 解释器。编译器会把它们直接编译为:

// {{ count }}  →  (ctx) => ctx._get("count")
// {{ user.name }}  →  (ctx) => ctx._get("user.name")
// {{ 42 }}  →  () => 42

这些编译后的函数缓存在 Map<string, CompiledExpression> 中,后续调用直接执行函数,跳过 AST 解析和解释,执行耗时 <1ms。

缓存系统——按上下文隔离的 LRU

WeakMap<RuntimeContext, Map<string, ExpressionCache>>
  • 每个 RuntimeContext 有独立缓存,上下文被 GC 时缓存自动回收
  • 最大 100 条,超限 LRU 淘汰
  • 依赖驱动失效:invalidateCache('user.name', ctx) 会遍历缓存,清除所有依赖链中包含 user.nameuser.* 的条目

实际的 trade-off

要诚实面对:

  • 你不能在 {{ }} 里写 (() => { ... })(),因为箭头函数被禁了
  • 数组的修改型方法(push/pop)不能在表达式里用,要搬到 Action 指令或 methods 里
  • 没有 Formily 的 x-reactions 那种开箱即用的联动语法

这些限制是刻意的。 如果 Schema 是开发者手写的,限制确实增加了摩擦。但如果 Schema 来自数据库、AI 生成、用户可视化配置——白名单就是最后的安全防线。


七、Path Memo——让"1000 个节点只更新 1 个"成为可能

这是我在性能优化上投入最多的部分。Vario 提供 4 层可组合的渲染优化策略:

方案 A:Path Memo(默认启用)

核心思路:缓存每个路径的 VNode,下次渲染时判断依赖有没有变,没变直接返回缓存

Schema 树                    依赖追踪
───────────                  ──────────
root                         [](无依赖,静态容器)
├── header                   [](纯静态)
├── form
│   ├── input[username]      ["username"]
│   ├── input[email]         ["email"]
│   └── submit-btn           ["isValid"]
└── footer                   [](纯静态)

当 username 变化时:
→ input[username] → 依赖命中 → 重渲染
→ header/footer/email/submit-btn → 依赖未变 → 走缓存 ✅

哪些子树不能缓存:三个递归检测函数——hasExpressionInSubtree()hasLoopInSubtree()hasModelInSubtree()。任何含动态绑定的子树都跳过缓存。

缓存键由三部分组成:path + buildSchemaId(type|cond|show|loop|childrenLen) + buildDepsKey(condValue, showValue) ——确保同一路径在不同条件分支下不会返回错误的缓存。

方案 B:LoopItemAsComponent(循环场景推荐)

循环每项渲染为独立的 LoopItemCell 组件(82 行的 defineComponent),Vue 对 props 未变的组件自动跳过 re-render。

循环上下文通过 createLoopContext() 创建——使用 Object.create(parentCtx) 原型链继承,对象池复用(maxSize=10),finally 块确保归还。

方案 C:SubtreeComponent(大规模深嵌套场景)

每个 Schema 节点(或组件边界)渲染为 VarioNode 独立 Vue 组件(350 行),shouldComponentize() 根据粒度('all''boundary')和 maxDepth 决定哪些节点升级为组件。

方案 D:SchemaFragment(实验性,精确 Schema 更新)

不给整棵 Schema 树套一个大 reactive(),而是按路径碎片化存储:path → shallowReactive(node)patch(path, partialNode) 只触发依赖该 path 的 Vue effect。

实测数据

场景 无优化 Path Memo 加速
100 静态 + 1 动态 全量 只渲 1 个 88x
复杂嵌套表单 基线 缓存命中 2-15x
大表格单行更新 基线 精准行更新 4-29x

1772387082094-dflyfiu5.png

▲ 内置的性能测试仪表盘,可以对比开关各种优化策略的渲染耗时


八、Vue 开发者的上手成本——四种方案写同一个表单

这是 Vario 最在意的一件事:渐进式接入,对 Vue 开发者来说切换到 Schema 写法的心智负担应该尽可能低。

同一个表单,四种方案对比:

原生 Vue 3

<template>
  <el-form label-width="100px">
    <el-form-item label="姓名">
      <el-input v-model="name" clearable />
    </el-form-item>
    <el-button @click.stop="submit" :disabled="!isValid">提交</el-button>
  </el-form>
</template>
<script setup>
const name = ref('')
const isValid = computed(() => !!name.value)
const submit = () => { /* ... */ }
</script>

Formily

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "title": "姓名",
      "x-decorator": "FormItem",
      "x-component": "Input",
      "x-component-props": { "clearable": true }
    }
  }
}

还需要 createForm()FormProviderSchemaField 等包裹层。组件名是 Formily 注册名(Input),不是 Element Plus 原生名。

amis

{
  "type": "form",
  "body": [
    { "type": "input-text", "name": "name", "label": "姓名" }
  ]
}

极简,但组件名是 amis 自己的类型系统(input-text)。

Vario

const { vnode, state } = useVario({
  type: 'ElForm', props: { labelWidth: '100px' },
  children: [
    {
      type: 'ElFormItem', props: { label: '姓名' },
      children: [{ type: 'ElInput', model: 'name', props: { clearable: true } }]
    },
    {
      type: 'ElButton',
      props: { disabled: '{{ !isValid }}' },
      events: { 'click.stop': [{ type: 'call', method: 'submit' }] },
      children: '提交'
    }
  ]
}, {
  state: { name: '' },
  computed: { isValid: (s) => !!s.name },
  methods: { submit: ({ state }) => { /* ... */ } }
})

Vario 对齐了 Vue 的哪些概念:

Vue 概念 Vario 对应 说明
v-model="name" model: 'name' 一个字符串搞定
@click.stop.prevent events: { 'click.stop.prevent': [...] } 点语法完全一致
ref="myInput" ref: 'myInput' 模板引用同名
Element Plus ElInput type: 'ElInput' 直接用注册的组件名
:disabled="!isValid" props: { disabled: '{{ !isValid }}' } 表达式换了个括号
computed computed: { isValid: (s) => ... } Options 风格函数
v-show show: '{{ condition }}' 条件显示
v-if cond: '{{ condition }}' 条件渲染
v-for loop: { items: '{{ list }}', itemKey: 'item' } 循环渲染
provide/inject provide: {...} / inject: [...] 依赖注入
<Teleport> teleport: '#target' 传送门
<Transition> transition: { name: 'fade' } 过渡动画
<KeepAlive> keepAlive: true 组件缓存
生命周期 onMounted: 'initMethod' 6 个 Vue 生命周期钩子
useVario() 返回值 { vnode, state, ctx, refs, error, stats, retry, find, findAll, findById } 完整的 Composition API

你只需要接受一个新概念:把模板写成 JS 对象。 其他所有东西——组件名、prop 名、事件名、修饰符——都跟你平时写 Vue 一模一样。

代价也要说清楚:

  • IDE 支持弱于 .vue 文件——只有类型提示,没有模板语法高亮和组件标签补全
  • 比 amis 啰嗦——同样的表单 amis 4 行搞定
  • 校验联动目前要手动实现——Formily 的 x-validatorx-reactions 是开箱即用的

九、为什么不直接用 h() 函数?

这个问题是理解 Vario 架构的关键。

Vue 的 h() 函数完全可以做到 Schema → VNode 的映射:

// h() 写法
const vnode = h('div', {}, [
  h(ElInput, { modelValue: state.name, 'onUpdate:modelValue': v => state.name = v }),
  h(ElButton, { onClick: () => submit() }, '提交')
])

渲染结果完全一样。h() 更直接,TypeScript 支持更好(完整的 prop 类型推导),性能也更好(少了一层解析)。

那 Schema 多这一层解析换来了什么?

答案是:h() 是代码,Schema 是数据。

h() 函数 Schema 对象
本质 函数调用——指令 普通 JS 对象——描述
能否 JSON.stringify ❌ 函数不可序列化 ✅ 纯 JSON
静态分析 ❌ 必须执行才知道结构 ✅ 不执行就能遍历、验证、转换
AI 生成 ⚠️ 要生成合法 JS ✅ 生成 JSON,格式可约束
运行时增量修改 ⚠️ 重新组装函数 SchemaStore.patch('children.0.props', { disabled: true })
路径级缓存 ❌ 每次全量重执行 ✅ Path Memo 跳过未变子树
存数据库 / 服务端下发 ❌ 不能下发代码 ✅ 下发 JSON
查询 / 检索 ❌ 无法对函数调用做 findById find(n => n.type === 'ElInput') 查询引擎

如果你的 Schema 永远只在 .ts 文件里手写,那 h() 确实更直接。 但如果 Schema 来自数据库、来自 AI 生成、来自可视化配置后台——"数据 vs 代码"的区别就是一切。

Path Memo、SchemaStore.patch、QueryEngine、Schema 验证器——这些能力全都依赖于"Schema 是数据"这个基础假设。


十、AI + Schema:为什么这个架构天然适合 AI 时代

这是我做 Vario 最深层的动机,也是我认为它最大的潜力所在。

现在 AI 生成代码已经很成熟了。但你让 AI 生成一个完整的 Vue SFC——template、script、style——它经常会出错:import 写错、ref 和 reactive 混淆、生命周期用错地方、组件名不存在……

但如果让 AI 生成的不是代码,而是 JSON 呢?

{
  "type": "ElCard",
  "children": [
    { "type": "ElInput", "model": "keyword", "props": { "placeholder": "搜索..." } },
    {
      "type": "div",
      "loop": { "items": "{{ results }}", "itemKey": "item" },
      "children": [{ "type": "span", "children": "{{ item.title }}" }]
    }
  ]
}

这个 JSON:

  1. 格式可约束——你可以给 AI 一个 SchemaNode 的类型定义,生成结果一定符合格式
  2. 可校验——validateSchema() 会对每个节点做结构验证 + 表达式 AST 白名单校验,不存在的组件类型、非法表达式都会被捕获
  3. 安全——即使这个 JSON 来自用户对话、来自远程接口,AST 白名单保证它不能执行 eval()、不能访问 window、不能 import() 动态加载
  4. 可增量修改——AI 不需要每次重新生成整个 UI,通过 SchemaStore.patch(path, partialNode) 做外科手术式更新,只触发依赖该 path 的 Vue effect

你可以想象这样一个工作流:

用户说:「帮我做一个商品搜索页面」
    ↓
AI 生成一份 Schema JSON
    ↓
validateSchema() 验证结构和表达式安全性
    ↓
Vario 运行时直接渲染
    ↓
用户说:「把搜索结果改成卡片布局」
    ↓
AI 生成一个 patch(只修改 layout 相关的节点)
    ↓
SchemaStore.patch() 增量更新,只有受影响的 VNode 重渲染

这个工作流中,AI 从头到尾不需要生成一行 JavaScript——它只生成 JSON 结构和指令序列。业务逻辑函数(methods)是人预先注册好的,AI 通过 { type: 'call', method: 'search' } 去调用。

方法层扮演的角色类似于 AI Agent 的 "Tools"——预定义好的能力接口,AI 只负责编排调用顺序和参数。


十一、竞品横向对比

做之前我认真看了现有的方案。这里不是要说"我比他们好"——他们是大厂几百人维护了好几年的项目,我一个人做的东西没资格这样说。但设计选择确实不同,值得讨论。

维度 Vario Formily(阿里) amis(百度)
GitHub Stars 新项目 12.6k ⭐ 18.8k ⭐
贡献者 个人 207 266
定位 Schema 渲染运行时 Schema Form 引擎 低代码平台
组件名 Vue 原生组件名 Formily 注册名 amis 类型系统
接入方式 渐进式(单页可用) 需包裹 Provider All-in-one
表单校验 手动 内置 x-validator 内置
表达式 AST 白名单沙箱 reaction 副作用 公式引擎
动作模型 13 指令正交组合 x-reactions 60+ actionType
渲染优化 4 层可组合优化 React/Vue 各自机制 内部优化
Schema 可序列化 ✅ 纯 JSON ✅ 基本支持 ✅ 纯 JSON
Bundle 大小 轻量 中等 ≈2MB
适合谁 搭平台的技术团队 复杂表单场景 快速交付内部工具

如果你要做复杂表单,Formily 的 x-validator + x-reactions 开箱即用,比 Vario 省力得多。选 Formily。

如果你要快速交付内部运营工具,amis 的 4 行 JSON 出页面是真实的生产力。选 amis。

如果你要在自己的项目里引入 Schema 驱动能力、保持对技术栈的完全控制、或者在构建一个低代码平台需要底层渲染引擎——Vario 提供的是一个干净的、可嵌入的运行时。


十二、测试与质量

┌──────────────────────────────────────────────────────┐
│  Test Files  50 passed (50)                          │
│       Tests  579 passed (579)                        │
│   跨 5 个包:types / core / schema / vue / cli       │
│   含 3 个集成测试文件(core↔schema / schema↔vm / vue↔element-plus)│
│   性能基准测试覆盖 4 种优化策略对比                     │
└──────────────────────────────────────────────────────┘

集成测试覆盖了三层的打通:

// basic-integration.test.ts — core 和 schema 能协作
const view = defineSchema({ state: { count: 0 }, schema() { return { type: 'div', children: [] } } })
const ctx = createRuntimeContext(view.stateType)
expect(ctx.count).toBe(0)

// schema-vm-integration.test.ts — Schema 中定义的 Action 能被 VM 执行
const instructions = view.schema.events?.click || []
await execute(instructions, ctx)
expect(ctx.count).toBe(1)

// vue-element-plus.test.ts — Vue 渲染器能正确处理 Element Plus 组件
const renderer = new VueRenderer()
const vnode = renderer.render(view.schema, ctx)
expect(vnode.props.modelValue).toBeDefined()
expect(vnode.props['onUpdate:modelValue']).toBeDefined()

十三、Demo 展示

1772387082111-j3lto3xl.png▲ play 演示站首页

下载.png▲ 内置了 Todo App、购物车、搜索过滤、表单、ECharts 图表等完整示例,每个示例可切换"预览"和"Schema JSON"视图

1772387082112-t7nh8y5j.png

▲ 代码靶场——浏览器里直接编辑 Schema,实时预览渲染结果

1772387082113-rbus1bmb.png

▲ 独立的文档站(VitePress),覆盖 API 文档、架构说明、表达式语法、性能调优指南


十四、自问自答——预判你心里可能已经有的问题

Q1:Schema 驱动和"把 template 写成 JSON"有什么本质区别?如果只是换了个语法糖,那工程价值在哪?

这是最核心的问题。如果 Schema 只是 template 的另一种写法,那确实没有意义——反而丢掉了 SFC 的 IDE 支持、语法高亮、组件类型推导。

区别在于 Schema 是可操作的数据,template 是编译后消失的 DSL

Vue 的 <template> 经过编译器后变成 render function,在运行时你拿不到"这里有一个 <ElInput>,它的 model 绑定到 name"这个结构信息了。但 Schema 始终存在于内存里,你可以在运行时做这些事:

  1. findAll(n => n.model) ——找出所有有双向绑定的节点,自动生成表单校验规则
  2. patch('children.2.props', { disabled: true }) ——服务端推送一条消息就能禁用某个按钮
  3. analyzeSchema(){ nodeCount: 234, maxDepth: 8 } ——统计 Schema 复杂度,自动决定启用哪种优化策略
  4. JSON.stringify(schema) → 存 DB → 下次 JSON.parse() → 直接渲染 ——零代码生成,零编译

这不是"换了个语法糖",这是从"编译时产物"变成了"运行时一等公民"的根本转变。

Q2:表达式白名单会不会过于严格?实际项目中遇到需要写复杂逻辑的表达式怎么办?

会。你不能在表达式里写 items.sort((a, b) => a.price - b.price),因为箭头函数被禁了。

设计意图是"表达式只做读取和条件判断,逻辑在 methods 和 computed 里"。 这意味着你需要:

// 不能这样写
{ children: '{{ items.sort((a, b) => a.price - b.price) }}' }

// 要这样写
computed: { sortedItems: (s) => [...s.items].sort((a, b) => a.price - b.price) }
// Schema 里用 {{ sortedItems }}

这多了一步,但换来的是:表达式永远是"安全的只读求值",不需要人工 review 每个 {{ }} 里写了什么。对于 Schema 来源不可信的场景(AI 生成、用户配置),这是刚性需求。

对于开发者手写 Schema 的场景,这确实增加了摩擦。如果你 100% 确定 Schema 只会出现在你的代码仓库里,白名单的安全价值就不那么明显了。这是一个架构赌注,赌的是 Schema 将来会来自更多来源。

Q3:双向绑定的"三重锁"是怎么被消灭的?

早期版本中,useVario 靠三把布尔锁(syncing / syncingPaths / watchSyncing)在 RuntimeContext 和 Vue reactive 之间做双向同步。能跑,但本质是 hack——三把锁意味着有三种循环路径需要手动屏蔽。

问题的根因不是"锁不够精确",而是存在两份状态本身就是错误。Core 的 RuntimeContext 维护一份 plain object,Vue 维护一份 reactive(),任何一侧修改都要同步到另一侧——这就是经典的"双写一致性"问题,在分布式系统里也没有优雅解法。

唯一真正优雅的方案是:消灭第二份状态。

受 Zustand 启发(一个 store 接口 + 各框架各自适配),当前版本引入了 ReactiveAdapter 协议,已经在源码中实现并通过全部 590 个测试

// @variojs/types/src/runtime.ts — 真实代码
export interface ReactiveAdapter {
  get(path: string): unknown        // 路径读取('user.name')
  set(path: string, value: unknown): void  // 路径写入
  getProperty(key: string): unknown  // 顶层属性读(Proxy get trap)
  setProperty(key: string, value: unknown): void  // 顶层属性写(Proxy set trap)
  has(key: string): boolean          // 属性存在检查(Proxy has trap)
  keys(): string[]                   // 所有 key(Proxy ownKeys trap)
}

改动涉及 5 个文件,核心变化:

1. @variojs/corecreateRuntimeContext 接受可选 adapter 参数。当 adapter 存在时:

  • _get(path)adapter.get(path),直接从 Vue reactive 读
  • _set(path, value)adapter.set(path, value),直接写入 Vue reactive
  • 初始状态不拷贝到 ctx 对象上(adapter ? {} : initialState

2. @variojs/core 的 Proxy 5 个 trap 全部路由到 adapter:

  • getadapter.getProperty(key)
  • setadapter.setProperty(key, value)
  • hasadapter.has(key)
  • ownKeys → 合并 adapter.keys() 与系统 API keys
  • getOwnPropertyDescriptor → 为 adapter 管理的 key 返回正确的描述符

3. @variojs/vuecreateVueReactiveAdapter 将 Vue reactive() 对象适配为协议:

// packages/vario-vue/src/adapter.ts — 真实代码
export function createVueReactiveAdapter<TState extends Record<string, unknown>>(
  state: TState
): ReactiveAdapter {
  return {
    get: (path) => getPathValue(state, path),
    set: (path, value) => setPathValue(state, path, value, {
      createObject: () => reactive({}),
      createArray: () => reactive([]),
      createIntermediate: true
    }),
    getProperty: (key) => state[key],
    setProperty: (key, value) => { state[key] = value },
    has: (key) => key in state,
    keys: () => Object.keys(state)
  }
}

4. useVario 从 636 行减至 570 行,删除了:

  • 3 个同步锁变量(syncing / syncingPaths / watchSyncing
  • onStateChange 中 20 行的 setPathValue 同步逻辑
  • watch(reactiveState) 中 20 行的 syncStateToContext 反向同步
  • syncStateToContext() 函数本身(16 行 + 深度比较)
  • 初始状态拷贝循环(5 行)

替换后的 onStateChange 只有 4 行——缓存失效 + 渲染调度:

onStateChange: (path, _value, runtimeCtx) => {
  invalidateCache(path, runtimeCtx)
  scheduleRender()
}

数据流变化:

重构前:ctx._set('x', 1) → 写入 ctx 内部 → onStateChange → setPathValue(reactive) → 触发 watch → 🔒 被锁拦截
重构后:ctx._set('x', 1) → adapter.set('x', 1) → 直接写入 reactive → onStateChange → invalidateCache + scheduleRender → 完毕

向后兼容: 当不传 adapter 时,行为与旧版完全一致——所有 153 个 Core 测试无需修改。adapter 是纯增量,不是 breaking change。

额外收益: 这个协议直接为 React Renderer 铺路(见 Q7)。React 侧只需实现一个基于不可变快照的 ReactReactiveAdapter,Core 层完全不用动。

Q4:Schema 存数据库之后,版本迁移怎么办?老版本的 Schema 在新版本的渲染引擎上能跑吗?

这是一个真实的工程问题,而且 Vario 目前没有完整的答案。

Schema 的结构由 SchemaNode 接口定义,这是一个 readonly 接口。新版本如果加了新字段(比如已经有的 transitionkeepAlive),老 Schema 没有这些字段,渲染器会按默认值处理,通常不会挂。

但如果某个字段的语义变了(比如 model 从只支持字符串变成支持 { path, scope, default, modifiers } 对象),normalizeSchemaNode() 需要处理兼容性转换。当前的规范化器已经在做这件事——它处理字符串 model 和对象 model 两种形态,统一为标准格式。

真正危险的是 Action 指令集的变更。 如果某个指令的参数结构变了,存在数据库里的 Schema 中引用的旧格式指令就会执行出错。Action VM 的错误保护(超时、步数限制、类型化错误码)可以兜底不让程序崩溃,但业务逻辑会失效。

长期来看,需要的是一个 Schema 版本号 + 迁移脚本的机制(类似数据库 migration),但这目前还在规划中。

Q5:你自己在实际项目中用 Vario 了吗?踩过什么真实的坑?

用了。Vario 最初就是从实际的低代码平台项目中抽出来的。踩过的最大的坑是 model 路径在嵌套循环中的解析

考虑这个场景:

{
  "loop": { "items": "{{ categories }}", "itemKey": "cat" },
  "children": [{
    "loop": { "items": "{{ cat.products }}", "itemKey": "product" },
    "children": [{
      "type": "ElInput",
      "model": "product.name"
    }]
  }]
}

product.name 需要解析为 categories.0.products.2.name 这样的绝对路径,才能正确写回状态。这需要一个路径栈(modelPathStack),每层循环压一层,每次解析 model 路径时从栈顶开始拼接。

ModelPathResolver 的 228 行代码大部分在处理这个问题的各种边界情况:"." 表示当前路径栈(循环项是基本类型时绑定自身)、$item 动态解析、-1 索引(动态数组追加)、表达式内嵌的 model 路径(model: '{{ dynamicField }}')。

vario-vue 有 750 行专门测试 model 路径解析的测试用例(model-path-comprehensive.test.ts),这是项目里最长的单个测试文件。

Q6:对比大厂的 Formily 和 amis,你一个人做的项目,凭什么让别人用?

这个问题的诚实答案是:如果有人问"我要选一个做生产项目用",我没有立场推荐 Vario 而不推荐 Formily。

Formily 有 207 位贡献者、多年的生产环境打磨、完整的表单验证/联动生态。amis 有百度内部大量业务场景验证、几百个内置组件类型。这些是个人项目无法比拟的。

Vario 的价值不在于"比他们好",而在于:

  1. 不同的抽象层次——Formily 是"表单引擎",amis 是"低代码平台",Vario 是"渲染运行时"。如果你要自己搭平台、自己做编辑器,你需要的是运行时这一层,而不是一个成品平台。
  2. 完全的控制权——Vario 不绑定任何组件库、不内置任何业务组件,你的组件就是你的。amis 接受就要全盘接受它的组件体系。
  3. 作为学习和参考——从零造一个 Schema 渲染引擎的过程中,我理解了为什么 Formily 要那样设计 x-reactions、为什么 amis 要搞 60+ 种 actionType。这个过程本身就值得分享。

如果你在选型——评估你的场景,做表单选 Formily,做内部工具选 amis,做平台底座或者想深入理解这个领域,来看看 Vario。

Q7:如果 Core 层零 Vue 依赖,那 React Renderer 真的能做出来吗?代价是什么?

架构上已经预留了。Core 层的所有 API——createRuntimeContext()execute()evaluate()——不依赖任何 UI 框架。但上一版的回答太保守了,只列了"React 缺什么"。深入想之后,我认为这件事比"能做但体验差"要更乐观。

VNode 创建层——映射是直接的:

Vue 的 h() 和 React 的 createElement() 在 API 层面几乎同构:

// Vue
h('div', { class: 'box', onClick: handler }, [h('span', {}, 'text')])

// React
createElement('div', { className: 'box', onClick: handler }, createElement('span', {}, 'text'))

差异只在属性名(class → classNamefor → htmlFor、事件名大小写),用一个 20 行的 prop adapter 就能搞定。当前 VueRenderer 的 638 行代码中,真正 Vue 特有的与其说是 h() 调用,不如说是围绕 h() 的那些 Vue 特性包裹(Teleport / Transition / KeepAlive / v-show / withDirectives)。

Vue 特性的 React 对应物——比想象中完整:

Vue 特性 React 对应 实现复杂度
h() createElement() 低(prop 名映射)
Teleport ReactDOM.createPortal() 低(API 对等)
Transition react-transition-group 或 Framer Motion 中(API 不同但能力对等)
KeepAlive 无原生等价物 高(需手动 display:none + 状态缓存,或用 react-activation)
v-show style={{ display: 'none' }} 低(trivial)
v-model value + onChange 低(React 反而更简单,不需要 onUpdate:modelValue 这种协议)
withDirectives 无等价物 高(需要自实 ref callback pattern)
provide/inject React.createContext + useContext 中(概念对等,API 不同)

真正的难题不在 API 映射,在状态同步——而 Q3 的 ReactiveAdapter 已经落地解决了这个问题。

Core 的 createRuntimeContext 现在接受 ReactiveAdapter 参数。Vue 侧的 createVueReactiveAdapter 已经证明了这个协议的可行性(590 个测试全部通过)。React 侧只需实现同一接口的不可变快照版本:

function createReactAdapter<T>(initialState: T): ReactiveAdapter & { getSnapshot: () => T, subscribe: (l: () => void) => () => void } {
  let state = structuredClone(initialState)
  const listeners = new Set<() => void>()

  return {
    get: (path) => getPathValue(state, path),
    set: (path, value) => {
      // 不可变更新——新引用触发 React re-render
      state = produce(state, draft => { setPathValue(draft, path, value) })
      listeners.forEach(l => l())
    },
    getProperty: (key) => state[key],
    setProperty: (key, value) => {
      state = { ...state, [key]: value }
      listeners.forEach(l => l())
    },
    has: (key) => key in state,
    keys: () => Object.keys(state),
    subscribe: (listener) => {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
    getSnapshot: () => state
  }
}

React 侧的 useVario Hook:

function useVario(schema, options) {
  const adapter = useMemo(() => createReactAdapter(options.state), [])
  const state = useSyncExternalStore(adapter.subscribe, adapter.getSnapshot)
  const ctx = useMemo(() => createRuntimeContext({}, { adapter }), [adapter])

  return useMemo(() => {
    const renderer = new ReactRenderer()
    return renderer.render(schema, ctx)
  }, [schema, state])  // state 引用变化时触发重渲染
}

注意 createReactAdapter 实现的 get/set/getProperty/setProperty/has/keys 与 Vue 侧的 createVueReactiveAdapter 签名完全一致——因为它们实现的是同一个 ReactiveAdapter 接口。差异只在实现策略:Vue 用可变 reactive proxy,React 用不可变快照 + useSyncExternalStore

useSyncExternalStore(React 18+)是关键。 它是 React 官方提供的"外部状态 → React 渲染"的标准桥接方案,不需要 deep reactive proxy,也不需要 useEffect + 手动 diff。每次 set() 产生新的不可变快照,useSyncExternalStore 检测到引用变化,触发组件 re-render。

这里借鉴了 Zustand 的核心设计:store 是外部的,React 通过 useSyncExternalStore 订阅。但 Zustand 的 store 是用户手写的,Vario 的 store 是 RuntimeContext——由 Schema 驱动、Action VM 修改。

我现在的判断是:React Renderer 的工程量大约是 Vue Renderer 的 60%——不是因为 React 比 Vue 简单,而是因为 React 不需要三重锁。 Vue 的 deep reactive 带来了自动依赖追踪的便利,但也引入了双向同步的复杂度;React 的不可变模型虽然需要多写 immutable update,但状态流向是单向的——不存在回声问题。

具体的实施路线:

  1. 第一步:从 Core 中抽取 RendererProtocol 接口(createElement / createFragment / createPortal / wrapTransition),让 VueRenderer 和 ReactRenderer 都实现同一接口
  2. 第二步:实现 ReactReactiveAdapter,基于 useSyncExternalStore + 不可变快照
  3. 第三步:实现 ReactRenderer 基础版(createElement + 事件 + model 绑定),跳过 KeepAlive / Directive
  4. 第四步:补齐 Transition(react-transition-group)和 KeepAlive(react-activation 或自实现)

最大的技术风险不是"能不能做",而是性能。Vue 的 watch(state, { deep: true }) 可以精确知道哪个 path 变了(配合 Path Memo 做精准跳过),React 的不可变快照每次都是完整引用比较。在大规模 Schema(1000+ 节点)下,React 的渲染粒度控制可能不如 Vue fine-grained。这需要实际 benchmark 验证——理论推演到这一步就到极限了。


十五、欢迎参与

Vario 目前已开源,文档和示例都比较完整。但一个人做的项目终归有视野和精力的局限。如果你对 Schema 驱动 UI、AI + 低代码、渲染引擎设计这些方向感兴趣,非常欢迎参与:

🔧 提 Issue

  • 发现 bug?Schema 验证/表达式引擎/双向绑定/循环渲染——任何场景的问题都欢迎报告
  • 有功能建议?比如新增白名单函数、新的 Action 指令类型、更好的错误提示
  • 文档不清楚的地方?告诉我哪里看不懂

🚀 提 Pull Request

  • Good First Issues 适合初次贡献
  • 新的 Action 指令处理器(在 packages/vario-core/src/vm/handlers/ 下添加)
  • 新的表达式白名单函数(在 packages/vario-core/src/expression/whitelist.ts 中注册)
  • play 示例(在 play/src/examples/ 下添加 .vario.ts 文件)
  • 文档改进(在 docs/ 下修改 Markdown)
  • React Renderer(这是最大的待做项)

💬 参与讨论

  • 架构决策讨论——比如"表达式白名单应不应该开放 .sort() 带回调的用法?"
  • 性能优化方向——比如"SchemaFragment 方案的 API 应该怎么设计?"
  • AI 集成方案——比如"怎么为 Schema 生成约束 AI 的 JSON Schema 定义文件?"
git clone https://github.com/YuluoY/vario.git
cd vario
pnpm install
pnpm start  # 构建 + 启动 play(:5173) 和 docs(:5174)
pnpm test   # 跑一遍 579 个测试,确认环境正常

GitHub:github.com/YuluoY/vari…

在线演示:yuluoy.github.io/vario/

文档:yuluoy.github.io/vario/docs/


5 分钟快速上手

pnpm add @variojs/vue @variojs/core @variojs/schema
<template>
  <component :is="vnode" />
</template>

<script setup>
import { useVario } from '@variojs/vue'

const { vnode, state } = useVario({
  type: 'div',
  children: [
    { type: 'input', model: 'name', props: { placeholder: '你的名字' } },
    { type: 'p', children: 'Hello {{ name }}!' }
  ]
}, {
  state: { name: '' }
})
</script>

就这样。没有 Provider,没有额外的 store,没有新的模板语法——Schema 即 UI,状态即数据。


更多文章

介绍一个手势识别库——AlloyFinger

移动端触摸手势库 AlloyFinger,配上 Vue 的 v-finger 指令,让「点、滑、捏、转」都能用声明式写法搞定,一起看看吧。


一、为什么需要 AlloyFinger?

在 H5 里,原生 touchstart / touchmove / touchend 只能告诉你「手指动了」,至于用户是单击、双击、长按、滑动、双指缩放还是旋转,都要自己算时间差、距离、角度——既难写又容易出 bug。

AlloyFinger 是腾讯 AlloyTeam 开源的轻量级手势库,把这些常见手势都封装好了,并且提供了 Vue 插件,以自定义指令 v-finger 的形式在模板里绑定,写法清晰、易维护。


二、安装依赖

在项目根目录执行:

npm install alloyfinger

三、在入口文件中注册插件

Vue 入口文件(如 src/main.js)中做两件事:

  1. 引入 AlloyFinger 本体和其 Vue 插件;
  2. 使用 Vue.use(AlloyFingerPlugin, { AlloyFinger }) 注册。

这样全局就可以在任意组件的模板里使用 v-finger 指令。

// 引入 alloy-finger
import AlloyFinger from 'alloyfinger'
import AlloyFingerPlugin from 'alloyfinger/vue/alloy_finger_vue'
Vue.use(AlloyFingerPlugin, {
  AlloyFinger
})

注意:

  • 插件路径是 alloyfinger/vue/alloy_finger_vue
  • 必须把 AlloyFinger 通过 Vue.use 的第二个参数传进去,插件内部会用它来创建手势实例。

四、在模板里使用 v-finger

注册完成后,在任意 Vue 组件的模板中,给需要绑定手势的单个根元素写上 v-finger:事件名="方法名" 即可。

4.1 语法形式

<div
  v-finger:tap="onTap"
  v-finger:swipe="onSwipe"
  v-finger:long-tap="onLongTap"
>
  可触摸区域
</div>
  • 指令名v-finger
  • 修饰符:冒号后面是事件类型,如 tapswipelong-tappinchrotate 等。
  • :当前 Vue 实例上的方法名,与普通 @click 一样写在 methods 里即可。

4.2 支持的事件

事件名 说明
tap 单击
double-tap 双击
single-tap 单击(与 double-tap 区分时用)
long-tap 长按
swipe 滑动手势(可结合 evt.direction)
pinch 双指缩放(evt.zoom)
rotate 双指旋转(evt.angle)
press-move 按住拖动(evt.deltaX / deltaY)
multipoint-start 多指开始
multipoint-end 多指结束
touch-start / touch-move / touch-end / touch-cancel 原生触摸事件封装

需要传参时,在方法里接收事件对象即可(如 swipe(evt) 中的 evt.directionpinch(evt) 中的 evt.zoom)。

4.3 完整示例

模板:

<template>
  <div
    class="touch-area"
    v-finger:tap="tap"
    v-finger:long-tap="longTap"
    v-finger:swipe="swipe"
    v-finger:pinch="pinch"
    v-finger:rotate="rotate"
    v-finger:double-tap="doubleTap"
    v-finger:single-tap="singleTap"
  >
    <div>点我、长按、滑动或双指操作</div>
  </div>
</template>

脚本:

export default {
  methods: {
    tap() {
      console.log('单击')
    },
    longTap() {
      console.log('长按')
    },
    swipe(evt) {
      console.log('滑动方向:', evt.direction)
    },
    pinch(evt) {
      console.log('缩放比例:', evt.zoom)
    },
    rotate(evt) {
      console.log('旋转角度:', evt.angle)
    },
    doubleTap() {
      console.log('双击')
    },
    singleTap() {
      console.log('单击(与双击区分)')
    }
  }
}

按需绑定自己用到的几个事件即可,不必全部写上。


五、用法很简单,那AlloyFinger是怎么实现的呢?

了解实现原理,有助于我们更放心地使用、排查问题,甚至做简单扩展。
AlloyFinger 的实现可以拆成两层:底层手势识别(alloy_finger.js)Vue 指令封装(alloy_finger_vue.js)

5.1 底层:基于原生 Touch 事件 + 向量运算

AlloyFinger 不依赖任何框架,核心就是给一个 DOM 元素绑定四个原生事件:

this.element.addEventListener("touchstart", this.start, false);
this.element.addEventListener("touchmove", this.move, false);
this.element.addEventListener("touchend", this.end, false);
this.element.addEventListener("touchcancel", this.cancel, false);

start 里:

  • 记录第一个触点的坐标 (x1, y1)和当前时间戳;
  • 用「上次 tap 的时间」和「两次点击的位移」判断是否构成双击(例如 250ms 内、位移 30px 以内);
  • 若检测到多指(evt.touches.length > 1),则计算两指构成的向量长度,作为后续 pinch 缩放的基准,并触发 multipointStart
  • 同时启动一个 750ms 的定时器,到时即触发 longTap

move 里:

  • 若是单指,则用当前点与上一帧点的差值得到 deltaXdeltaY,触发 pressMove
  • 若移动距离超过约 10px,会置位 _preventTap,避免误触 tap
  • 若是双指,则用两指构成的向量做向量长度比得到 evt.zoom(pinch),用向量夹角得到 evt.angle(rotate),这里用到简单的向量数学(点积、叉积、夹角),核心逻辑类似:
// 向量长度
function getLen(v) {
  return Math.sqrt(v.x * v.x + v.y * v.y);
}
// 缩放:当前两指距离 / 起始两指距离
evt.zoom = getLen(v) / this.pinchStartLen;
// 旋转:当前向量相对上一帧向量的角度
evt.angle = getRotateAngle(v, preV);

end 里:

  • 若「起点到终点的位移」超过约 30px,则根据 x、y 方向位移谁更大来判定 swipe 方向(Left/Right/Up/Down),并触发 swipe
  • 否则在下一个「事件循环」里触发 tap,并根据之前的双击标记决定是否再触发 doubleTap 或延迟 250ms 触发 singleTap
  • 同时会清除 longTap 定时器、重置双指相关的状态。

也就是说:tap / longTap / doubleTap / swipe / pinch / rotate / pressMove 等,都是在同一套 touch 生命周期里,用「时间差 + 位移 + 向量运算」推导出来的,没有黑魔法。

5.2 回调管理:HandlerAdmin

每种手势对应一个「回调列表」,用 HandlerAdmin 统一管理:add 注册、del 移除、dispatch 时对该元素上的所有回调依次 apply。这样同一个元素上可以挂多个监听(例如 Vue 插件里对同一元素绑定多个 v-finger:xxx),彼此也不会互相覆盖。

5.3 Vue 插件层:v-finger 如何挂到 DOM 上

插件在 install 时执行 Vue.directive('finger', directiveOpts),因此模板里的 v-finger 会变成对自定义指令 finger 的调用。

  • 事件名映射:模板里写的是 kebab-case(如 v-finger:long-tap),插件里用 EVENTMAP 转成 AlloyFinger 的 camelCase(如 longTap),再交给底层。
  • 一元素一实例:用一个全局 CACHE 数组,按 DOM 元素存 { elem, alloyFinger }。同一元素上多条 v-finger:tapv-finger:swipe 等,共用一个 AlloyFinger 实例;第一次绑定时 new AlloyFinger(elem, options),之后同元素再绑其他事件时,不再 new,而是 alloyFinger.on(eventName, func) 往该实例上追加回调。
  • 指令生命周期:Vue2 下 bind / update 时执行 doBindEvent(绑定或更新回调),unbind 时从 CACHE 里取出实例并调用 alloyFinger.destroy(),移除原生事件监听和所有定时器,避免内存泄漏。

核心片段:

// 同一元素多次 v-finger:xxx 共用一个 AlloyFinger 实例
var cacheObj = CACHE[getElemCacheIndex(elem)];
if (cacheObj && cacheObj.alloyFinger) {
  if (oldFunc) cacheObj.alloyFinger.off(eventName, oldFunc);
  if (func) cacheObj.alloyFinger.on(eventName, func);
} else {
  CACHE.push({
    elem: elem,
    alloyFinger: new AlloyFinger(elem, { [eventName]: func })
  });
}

5.4 小结

  • 手势识别:完全基于 touchstart / touchmove / touchend,用时间、位移和向量运算区分 tap、doubleTap、longTap、swipe、pinch、rotate、pressMove 等。
  • Vue 层:通过自定义指令 v-finger 和元素级 AlloyFinger 实例缓存,把「模板里的 v-finger:事件名」映射到「底层 AlloyFinger 的 on/off」,实现声明式绑定与组件销毁时的清理。

参考

VTJ.PRO 双向代码转换原理揭秘

在低代码平台层出不穷的今天,如何平衡可视化开发的便利性与代码的灵活性、可控性,一直是行业难题。VTJ.PRO 作为一个面向 Vue 3 开发者的 AI 驱动开发平台,给出了一个独特的答案:双向代码转换。它不仅支持从 Vue 源码到低代码 DSL 的“向上”转换,也支持从 DSL 到标准 Vue 源码的“向下”生成,并且两个方向可以反复进行,实现了真正意义上的“代码双向自由”。

本文将深入剖析 VTJ.PRO 双向代码转换系统的核心原理,揭开其如何实现 Vue SFC(单文件组件)与平台内部 DSL 之间无损、可逆转换的技术面纱。

1. 双向转换系统架构总览

VTJ.PRO 的代码转换系统由两大核心模块构成:

  • Parser(解析器):将 Vue SFC 源码解析为平台内部的 BlockSchema DSL 对象。
  • Generator(生成器):将 BlockSchema DSL 对象重新生成为标准 Vue SFC 源码。

这两个模块共同构成了一个闭环,使得开发者可以在“源码编辑”与“可视化设计”两种模式间无缝切换,且任意一方的修改都能被另一方完整理解和承载。

整体工作流程如下图所示:

flowchart TD
    A[Vue SFC 源码] -->|输入| B[Parser 解析器]
    B -->|输出| C[BlockSchema DSL]
    C -->|输入| D[Generator 生成器]
    D -->|输出| E[Vue SFC 源码]

    B -.->|验证/修复| A
    D -.->|格式化/平台适配| E

2. 解析器:从 Vue SFC 到 DSL

解析器的入口是 parseVue 函数,它接收 Vue 源码,经过多阶段处理,最终输出一个结构化的 BlockSchema 对象。整个过程可以分为:输入验证与自动修复SFC 拆分脚本解析模板解析上下文跟踪与代码修补五个主要阶段。

2.1 输入验证与自动修复

在解析之前,系统会使用 ComponentValidator 对源码进行质量检查,确保其符合平台的预期格式。验证规则包括:

  • SFC 结构完整性:必须包含 <template><script><style> 块。
  • JavaScript 语法正确性:使用 Babel 检查脚本部分是否有语法错误。
  • setup 函数格式setup() 必须恰好包含三句代码(provider 初始化、state 声明、return)。
  • 图标名称合法性:检查 Vant 和 VTJ 图标库的图标名是否在白名单内。

如果检测到可自动修复的问题(如非法的图标名、模板中缺少 state. 前缀),AutoFixer 会介入修正。例如,checkAndFixStatePrefix 函数会遍历模板中的插值、绑定、指令,自动为响应式变量添加 state. 前缀:

// 修复前
<div>{{ username }}</div>
<button @click="count++">Click</button>

// 修复后
<div>{{ state.username }}</div>
<button @click="state.count++">Click</button>

2.2 SFC 解析

通过 Vue 官方编译器将源码拆分为 <template><script><style> 三部分。parseSFC 函数会优先识别 <script setup>,并收集所有样式块(支持多 <style>)。

2.3 脚本解析:Babel 提取

parseScripts 函数利用 Babel 对脚本代码进行 AST 遍历,提取组件逻辑元数据。关键提取点包括:

  • 状态(State):识别 const state = reactive({...}) 语句,提取初始状态对象。
  • 方法(Methods):收集 methods 对象中的函数。
  • 事件处理器(Event Handlers):方法名若匹配特定后缀模式(如 click_abc123),会被归类为事件处理器,并生成唯一 ID。
  • 计算属性(Computed):提取 computed 对象中的函数。
  • 侦听器(Watchers):方法名以 watcher_ 开头则视为侦听器源。
  • 数据源(Data Sources):识别调用 provider.apiscreateMock 的方法,并解析其 transform 逻辑。
  • 生命周期(LifeCycles):提取 mountedcreated 等方法。

这些提取出的信息将分别存入 BlockSchemastatemethodscomputedwatch 等字段。

2.4 模板解析:AST 转换

模板解析是核心中的核心,parseTemplate 函数将 Vue 模板 AST 转换为平台内部的 NodeSchema 节点树。转换过程中,每个 AST 节点都会调用 transformNode,生成对应的 NodeSchema 对象,并递归处理子节点。

关键转换规则:

  • 属性(Props):静态属性直接转为键值对;动态绑定(v-bind)转换为 JSExpression 类型;同时处理 class/style 的合并。
  • 事件(Events)v-on 指令转换为 events 对象,事件表达式会被包装成函数,并与脚本中提取的事件处理器 ID 关联。
  • 指令(Directives)v-ifv-forv-modelv-show 等都被提取为 directives 数组,保留其表达式和参数。
  • 插槽(Slots):识别 <template #slotName> 和组件上的 v-slot,生成 slot 元数据。

模板解析流程图如下:

flowchart TD
    A[模板源码] -->|Vue Compiler| B[AST]
    B --> C[transformNode 递归转换]
    C --> D{节点类型}
    D -->|元素节点| E[getProps 提取属性]
    D -->|元素节点| F[getEvents 提取事件]
    D -->|元素节点| G[getDirectives 提取指令]
    D -->|文本节点| H[生成文本节点]
    E --> I[创建NodeSchema]
    F --> I
    G --> I
    H --> I
    I --> J[递归处理子节点]
    J --> K[输出NodeSchema树]

2.5 上下文跟踪与代码修补

在模板中,变量可能来自多个作用域:组件状态(state)、计算属性(computed)、v-for 循环变量、插槽作用域变量等。为了保证在运行时能正确访问这些变量,解析器必须记录每个节点的上下文

pickContext 函数在遍历 AST 时动态维护一个上下文映射:遇到 v-for 时,将迭代变量(如 item, index)加入当前上下文;遇到具名插槽时,将插槽参数加入子节点上下文。

随后,系统调用 patchCode 对所有 JavaScript 表达式(如 JSExpressionJSFunction)进行上下文注入。注入的核心是 replacer 函数,它通过一个状态机逐字符扫描表达式,智能地决定哪些标识符需要添加前缀(如 this.context.this.)。判断规则包括:

  • 字符串字面量内:不替换。
  • 对象属性访问.key 形式不替换,[key] 形式替换。
  • 变量声明:不替换。
  • 函数参数:不替换。
  • 展开运算符...key 替换。
  • 正则表达式内:不替换。

这种精细的替换策略确保了修补后的代码既能正确引用上下文,又不会破坏原有的语法结构。

2.6 输出 BlockSchema

经过上述所有阶段,解析器最终组装出一个完整的 BlockSchema 对象。该对象包含了组件的所有信息:ID、名称、状态、方法、计算属性、侦听器、数据源、生命周期、节点树以及 CSS 样式。这个 DSL 对象可以被可视化设计器直接消费,也可以存入数据库或文件。

3. 代码生成器:从 DSL 到 Vue SFC

代码生成器是解析器的逆过程,其核心函数 generator() 接收 BlockSchema 对象,输出格式化的 Vue SFC 源码。生成过程分为模板生成脚本生成样式生成格式化四个阶段,并支持多平台适配。

3.1 生成器架构

flowchart TD
    A[BlockSchema] --> B[模板生成]
    A --> C[脚本生成]
    A --> D[样式生成]
    B --> E[组合SFC]
    C --> E
    D --> E
    E --> F[Prettier格式化]
    F --> G[平台适配转换]
    G --> H[最终Vue源码]

3.2 模板生成

模板生成器遍历 BlockSchema.nodes 树,为每个 NodeSchema 节点生成对应的 Vue 模板标签。生成规则如下:

  • 标签名:根据节点 namefrom(组件来源)决定标签名。
  • 静态属性:直接输出 key="value"
  • 动态属性v-bind:key="表达式":key="表达式"
  • 事件v-on:click="handler"@click="handler"
  • 指令:将 directives 数组还原为 v-ifv-forv-model 等指令。
  • 插槽:为带有 slot 元数据的节点生成 <template #slotName> 包裹。

特别地,v-for 指令需要根据其 iterator 结构还原出 (item, index) in list 的语法。

3.3 脚本生成

脚本生成的目标是输出一个符合 Vue 3 选项式 API 或组合式 API 的 <script> 块。VTJ.PRO 默认采用组合式 API 风格,但最终输出会根据配置选择。

脚本生成的步骤包括:

  1. 导入语句生成:根据组件使用的物料(UI 库、自定义组件)生成 import 语句,并处理平台依赖(如 @element-plus/icons-vue 可能被映射为 @vtj/icons)。
  2. setup 函数构造
    • 调用 useProvider 初始化 provider。
    • 声明 reactivestate 对象。
    • 定义计算属性、方法、侦听器、生命周期函数。
    • 返回需要暴露给模板的变量(statepropsprovider 等)。
  3. 方法体生成methodscomputedwatch 等字段中的 JSFunction 对象会被还原为函数代码,并经过 patchCode 的逆过程(移除上下文前缀)吗?实际上,生成器不再需要逆向 patch,因为 DSL 中的表达式已经是经过上下文修补的,生成器只需直接输出这些表达式即可,但在输出前会确保它们符合 Vue 运行时的要求(例如,模板中访问 state.xxx 是合法的,而在 methods 中可能需要通过 this.state.xxx 访问,这取决于最终代码的结构)。生成器会依据上下文适当调整引用方式。

3.4 样式生成

样式生成最简单:直接将 BlockSchema.css 字符串插入 <style scoped> 块中。若存在多个样式块,则会合并或分别输出。

3.5 格式化与平台适配

所有生成的代码都会通过 Prettier 进行格式化,确保缩进、引号、分号等风格一致。VTJ.PRO 内置了 vueFormattertsFormatterhtmlFormattercssFormatter,分别处理不同类型的代码块。

最后,根据目标平台(webh5uniapp)对标签和依赖进行适配转换。例如,在 UniApp 平台下,<div> 会被转换为 <view><span> 转换为 <text>,并且只导入支持该平台的依赖包。

4. 关键数据结构与设计哲学

理解双向转换,必须掌握几个核心数据结构:

  • BlockSchema:整个组件的 DSL 表示,包含元数据、逻辑、节点树和样式。
  • NodeSchema:单个节点的 DSL 表示,包含标签名、属性、事件、指令、子节点等。
  • JSExpression / JSFunction:包裹 JavaScript 表达式的类型,带有 typevalue 字段,便于序列化和解析。

VTJ.PRO 的双向转换设计遵循以下哲学:

  • 无平台锁定:生成的是标准 Vue 源码,开发者可以随时脱离平台手工修改,修改后的代码仍可被平台重新解析利用。
  • 可逆性parseVuegenVueCode 构成一对可逆操作,多次转换后语义保持不变(通过测试用例保证)。
  • 开发者友好:所有转换都尽可能保留原代码的格式和注释,生成的代码可读性强,符合开发者的编码习惯。

5. 总结与展望

VTJ.PRO 的双向代码转换系统,通过在抽象语法树层面的精细操作,实现了低代码 DSL 与标准 Vue 源码之间的双向映射。它不仅为可视化设计器提供了数据基础,也确保了开发者随时可以“下车”手写代码,享受完整的开发自由度。

未来,随着 AI 能力的进一步集成(如通过自然语言生成代码片段),这种双向转换能力将成为连接人类开发者与 AI 助手的桥梁,让软件开发进入“随心所欲、不逾矩”的新时代。


参考文档

  • VTJ.PRO 源码仓库:gitee.com/newgateway/…
  • 《Code Transformation System》
  • 《Parser: Vue SFC to DSL》
  • 《Code Generator: DSL to Vue》

高效的数据解构:用 toRefs 和 toRef 保持响应性

前言

在 Vue3 的开发中,解构赋值是比较常用的语法特性。它能让代码更简洁,变量命名更自由。但当解构遇到 reactive 响应式数据时,一个常见的陷阱就出现了:解构后的变量失去了响应性

为什么会这样?如何既享受解构的便利,又保持数据的响应性?本文将深入探讨 toRefstoRef 这两个 API 的工作原理和使用技巧,帮你彻底解决解构带来的响应式丢失问题。

解构的诱惑与陷阱

为什么我们喜欢解构赋值?

解构赋值是 ES6 带来的语法糖,它让代码变得更加简洁优雅:

const user = reactive({ name: '张三', age: 18 })

// 没有解构之前,只能属性调用
console.log(user.name)
console.log(user.age)

// 有解构之后
const { name, age } = user
console.log(name)
console.log(age)

解构的优势

  • 按需引入:只取需要的属性
  • 命名自由:可以重命名变量
  • 代码简洁:减少重复的前缀

解构带来的问题

当我们对 reactive 响应式对象进行解构时,会丢失响应式。

这部分的内容,在上一篇文章《响应式探秘:ref vs reactive,我该选谁?》中有详细讲解,本文不再赘述!

toRefs 的魔法

原理:将 reactive 对象的每个属性都转换为 ref

toRefs 的出现正是为了解决 reactive 的解构问题。它的工作原理是:遍历 reactive 对象的所有属性,为每个属性都单独创建一个 ref,这些 ref 会保持与原对象的响应式连接:

// 简化的 toRefs 实现
function toRefs(obj) {
  const result = {}
  
  for (const key in obj) {
    // 为每个属性创建 ref
    result[key] = {
      __v_isRef: true,
      get value() {
        return obj[key]  // 读取时访问原对象
      },
      set value(newVal) {
        obj[key] = newVal // 设置时修改原对象
      }
    }
  }
  return result
}

// 使用
const user = reactive({
  name: '张三',
  age: 18
})

const refs = toRefs(user)

user 使用 toRefs 转换后,其结构是这样的:

// toRefs转换后的结构
{
  name: RefImpl { ... },
  age: RefImpl { ... }
}

有了这个结构之后,我们就可以放心、安全地解构了:

const { name, age } = refs
name.value = '李四' // 会触发 user.name 的更新
age.value++        // 会触发 user.age 的更新

使用场景:从组合式函数返回多个值时

toRefs 最常见的应用场景就是当组合式函数中返回多个响应式值时,进行处理:

import { reactive, toRefs } from 'vue'

export function useUser() {
  const state = reactive({
    user: null,
    loading: false,
    error: null,
    permissions: []
  })

  async function fetchUser(id) {
    state.loading = true
    try {
      state.user = await api.getUser(id)
      state.permissions = await api.getPermissions(id)
      state.error = null
    } catch (e) {
      state.error = e
    } finally {
      state.loading = false
    }
  }

  function updateUser(data) {
    Object.assign(state.user, data)
  }

  // ✅ 返回时使用 toRefs,让使用者可以解构
  return {
    ...toRefs(state),
    fetchUser,
    updateUser
  }
}

注意事项:响应式连接是双向的

我们一定要注意:toRefs 创建的是响应式连接是双向的,它并不是复制了一份数据,而是指向原对象属性的引用。这也是一个很常见的开发误区。

const original = reactive({
  name: '张三',
  age: 18
})

const { name, age } = toRefs(original)

// 修改 ref 会影响原对象
name.value = '李四'
console.log(original.name) // '李四'

// 修改原对象会影响 ref
original.age = 20
console.log(age.value) // 20

// 这种连接是持久的
original.name = '王五'
console.log(name.value) // '王五'

// 即使重新赋值原对象的属性,连接依然保持
original.name = '赵六'
console.log(name.value) // '赵六'

toRef 的精简用法

场景:只想处理 reactive 对象中的某一个属性

使用 toRefs 会把 reactive 对象中的所有属性都转换成 ref;但有时候我们只需要处理 reactive 对象中的某些属性,这时使用 toRef 会更加精准。toRef 是用于将 reactive 对象的指定的属性转成 ref,一次只能转换一个属性。在 toRefs 源码实现中,其本质就是通过遍历对象的属性,再通过 toRef 逐个转换。

import { reactive, toRef } from 'vue'

const state = reactive({
  count: 0,
  name: '张三',
  age: 18,
  email: 'zhang@example.com',
  // ... 可能还有很多其他属性
})

// 只关心 count 属性
const countRef = toRef(state, 'count')

// 现在可以像使用 ref 一样使用 countRef
countRef.value++ // 修改 state.count
console.log(state.count) // 1

// 修改原对象也会影响 countRef
state.count = 10
console.log(countRef.value) // 10

优势:性能更好,只创建一个 ref

相比 toRefs 会为所有属性创建 reftoRef 只创建需要属性的 ref,性能开销更小。

toRef 的另一个妙用:创建可选的响应式引用

toRef 还有个好处,可以用来处理可能不存在的属性:

const state = reactive({
  user: {
    name: '张三'
  }
})

当前 user 只存在 name 属性,如果我们直接给它添加一个新属性会怎么样呢?

state.user.profile.gender = '男'

上述代码毫无疑问会报错:Cannot set properties of undefined (setting 'gender')。但通过 toRef 我们可以安全赋值:

// 即使 profile 不存在,也能创建响应式引用
const profile = toRef(state.user, 'profile')

// 可以安全地赋值
profile.value = { gender : '男' }

性能考量

toRefs 的性能开销

toRefs 会遍历对象的所有属性,为每个属性创建一个 ref 对象。对于大型对象来说,这确实会有一定的性能开销。性能开销主要来源于以下几点:

  • 遍历开销:需要遍历所有属性
  • 内存开销:每个 ref 都是一个对象,占用内存
  • 响应式连接:每个 ref 都需要建立响应式连接

因此基于性能考虑,我们应该遵循按需使用的原则,只有在需要的时候才使用 toRefs

何时不该使用 toRefs

有些场景下,使用 toRefs 也确实可能不是最佳选择:

场景1:性能敏感的高频操作

这就是上述提到的性能开销问题。

场景2:对象在组件内部使用,不需要暴露给外部

function internalFeature() {
  const internalState = reactive({ ... })
  
  // 不需要 toRefs,直接在内部使用 state
  function doSomething() {
    internalState.prop = value
  }
  
  return {
    doSomething
  }
}

场景3:返回整个对象

function useConfig() {
  const config = reactive({
    theme: 'dark',
    language: 'zh',
    features: {...}
  })
  
  // 如果使用者很少需要解构,直接返回 reactive 更好
  return {
    config,
    updateConfig
  }
}

结语

toRefstoRef 解决了在享受解构便利的同时,又不失去 Vue 响应式系统的强大能力。理解并善用它们,我们的代码将既简洁又可靠!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Vue生态精选篇:Element Plus 的“企业后台常用组件”用法扫盲

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、选型与定位

  • Element Plus:面向 Vue 3 + TypeScript 的 UI 组件库,适合管理后台、中台、后台系统。
  • 为什么用组件库而不是手写? 统一规范、减少重复开发、内置表单校验、表格、弹窗等常见能力。
  • 本文涉及组件:Form、Table、Dialog、Message/MessageBox、Upload。

二、表单 Form:数据收集与校验

2.1 核心概念

Form 的作用:收集、校验、提交 数据,包含输入框、选择器、日期等。

表单的三层结构:

  1. el-form:表单容器,绑定数据和校验规则
  2. el-form-item:单个表单项,承载 label、校验、布局
  3. el-input / el-select 等:具体输入控件

2.2 正确用法示例

<template>
  <el-form 
    ref="formRef" 
    :model="form" 
    :rules="rules" 
    label-width="100px"
    @submit.prevent
  >
    <el-form-item label="用户名" prop="username">
      <el-input v-model="form.username" placeholder="请输入用户名" />
    </el-form-item>
    
    <el-form-item label="密码" prop="password">
      <el-input v-model="form.password" type="password" placeholder="请输入密码" />
    </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>
import { ref, reactive } from 'vue'

const formRef = ref()
const form = reactive({
  username: '',
  password: ''
})

// 校验规则:字段名要与 form 中的属性、el-form-item 的 prop 完全一致
const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码至少 6 位', trigger: 'blur' }
  ]
}

const handleSubmit = async () => {
  // validate 返回 Promise,通过则无参数,失败则返回校验错误
  try {
    await formRef.value.validate()
    console.log('校验通过,提交数据:', form)
    // 这里调用接口提交
  } catch (error) {
    console.log('校验失败')
  }
}

const handleReset = () => {
  formRef.value.resetFields()
}
</script>

说明要点:

  • :model="form" 绑定表单数据,注意是 :model,不是 v-model
  • :rules="rules" 绑定校验规则
  • prop="username" 绑定到表单项,用于关联 rules 中的字段
  • @submit.prevent 防止回车键意外提交表单

2.3 常见踩坑

错误写法 正确写法
Form 绑定 v-model="form" :model="form"
不写 prop <el-form-item> 无 prop <el-form-item prop="username">
prop 写错位置 写在 el-input 必须写在 el-form-item
prop 与 rules 不一致 rules 里是 name,prop 是 username 两者字段名完全一致

记住:el-form 用 :model、el-form-item 必须有 prop、prop 与 rules 字段名一致

2.4 常用 API

  • validate():整表校验
  • validateField(prop):校验单个字段
  • resetFields():重置表单
  • clearValidate():清除校验状态

三、表格 Table:列表展示

3.1 核心概念

Table 用于展示列表数据,支持排序、分页、选择、展开等。

3.2 基础用法示例

<template>
  <el-table 
    :data="tableData" 
    stripe 
    border
    style="width: 100%"
    @selection-change="handleSelectionChange"
  >
    <!-- 多选列 -->
    <el-table-column type="selection" width="55" />
    
    <!-- 普通列 -->
    <el-table-column prop="name" label="姓名" width="120" />
    <el-table-column prop="age" label="年龄" width="80" />
    <el-table-column prop="address" label="地址" show-overflow-tooltip />
    
    <!-- 自定义列 -->
    <el-table-column label="状态" width="100">
      <template #default="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'info'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>
    </el-table-column>
    
    <!-- 操作列 -->
    <el-table-column label="操作" width="180" fixed="right">
      <template #default="{ row }">
        <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
        <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
      </template>
    </el-table-column>
  </el-table>
</template>

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

const tableData = ref([
  { id: 1, name: '张三', age: 28, address: '上海市浦东新区某某路100号', status: 1 },
  { id: 2, name: '李四', age: 32, address: '北京市朝阳区某某大街200号', status: 0 }
])

const handleSelectionChange = (selection) => {
  console.log('选中的行:', selection)
}

const handleEdit = (row) => {
  console.log('编辑', row)
}

const handleDelete = (row) => {
  console.log('删除', row)
}
</script>

说明要点:

  • :data 绑定数据数组,每一行是一个对象
  • prop 对应数据字段名,决定显示哪个字段
  • show-overflow-tooltip:内容过长时显示省略号并悬浮显示完整内容
  • #default="{ row }":插槽提供当前行数据

3.3 配置选型建议

场景 推荐配置
数据较多 heightmax-height 固定高度,出现纵向滚动
树形数据 使用 row-key + tree-props
需要合计 show-summary + summary-method
列宽不稳定 设置 widthmin-width,避免抖动
多选 type="selection" + @selection-change

3.4 常见踩坑

  • 表格数据不更新:确保 tableData 是响应式的(如 ref),修改后要触发更新
  • 树形表格:必须设置 row-key 为唯一字段(如 id
  • 固定列fixed="right"fixed="left" 时,注意右侧固定列写在最后

四、弹窗 Dialog:模态对话框

4.1 核心概念

Dialog 用于在保留当前页面的前提下,弹出一个模态层展示内容,常用于表单弹窗、详情、确认等。

4.2 基础用法示例

<template>
  <el-button @click="dialogVisible = true">打开弹窗</el-button>
  
  <el-dialog
    v-model="dialogVisible"
    title="编辑用户"
    width="500px"
    :close-on-click-modal="false"
    :before-close="handleBeforeClose"
    @opened="handleOpened"
  >
    <!-- 弹窗内容 -->
    <el-form ref="formRef" :model="form" :rules="rules">
      <el-form-item label="用户名" prop="username">
        <el-input v-model="form.username" />
      </el-form-item>
    </el-form>
    
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </template>
    </template>
  </el-dialog>
</template>

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

const dialogVisible = ref(false)
const formRef = ref()
const form = reactive({ username: '' })
const rules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }] }

// 弹窗关闭前:可做二次确认、校验等
const handleBeforeClose = (done) => {
  // 简单示例:直接关闭
  done()
  // 如需确认:ElMessageBox.confirm('确定关闭?').then(() => done()).catch(() => {})
}

// 弹窗打开动画结束后
const handleOpened = () => {
  formRef.value?.clearValidate()
}

// 关闭时清空表单(按需)
watch(dialogVisible, (val) => {
  if (!val) {
    form.username = ''
  }
})

const handleConfirm = async () => {
  try {
    await formRef.value.validate()
    // 提交逻辑
    dialogVisible.value = false
  } catch (e) {
    // 校验失败
  }
}
</script>

说明要点:

  • v-model="dialogVisible" 控制显示/隐藏
  • :close-on-click-modal="false":点击遮罩不关闭,避免误关
  • before-close:可做二次确认、阻止关闭
  • #footer:自定义底部按钮

4.3 常见配置选型

配置 说明 建议
destroy-on-close 关闭时销毁内容 表单弹窗建议开启,避免数据残留
close-on-click-modal 点击遮罩关闭 表单弹窗建议关闭
append-to-body 挂载到 body 有嵌套弹窗时建议开启

五、消息 Message 与 MessageBox

5.1 ElMessage:轻量提示

用于操作后的简单反馈(成功、失败、警告等),通常显示几秒后自动消失。

import { ElMessage } from 'element-plus'

// 成功
ElMessage.success('保存成功')

// 错误
ElMessage.error('保存失败,请重试')

// 警告
ElMessage.warning('请先填写必填项')

// 自定义
ElMessage({
  message: '操作成功',
  type: 'success',
  duration: 3000,
  showClose: true
})

5.2 ElMessageBox:确认与输入

用于需要用户确认或输入的场景,比 Dialog 更轻量。

import { ElMessageBox } from 'element-plus'

// 确认删除
const handleDelete = async (row) => {
  try {
    await ElMessageBox.confirm(
      `确定要删除「${row.name}」吗?`,
      '提示',
      {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }
    )
    // 用户点击确定
    await deleteApi(row.id)
    ElMessage.success('删除成功')
  } catch (e) {
    // 用户点击取消或关闭
  }
}

// 简单提示(类似 alert)
ElMessageBox.alert('操作完成', '提示')

5.3 选型建议

场景 用 Message 用 MessageBox
保存成功、失败提示
删除前确认
需要用户输入 ✅(prompt)
复杂表单、多内容 改用 Dialog

六、上传 Upload:文件上传

6.1 核心概念

Upload 支持自动上传和手动上传:自动上传是选完即传,手动上传是选完后由按钮触发上传。

6.2 自动上传(选完即传)

<template>
  <el-upload
    action="/api/upload"
    :headers="uploadHeaders"
    :on-success="handleSuccess"
    :on-error="handleError"
    :before-upload="beforeUpload"
  >
    <el-button type="primary">点击上传</el-button>
  </el-upload>
</template>

<script setup>
import { reactive } from 'vue'

// 请求头,常用于 Token
const uploadHeaders = reactive({
  Authorization: `Bearer ${localStorage.getItem('token')}`
})

// 上传前:校验格式、大小
const beforeUpload = (file) => {
  const isJPG = file.type === 'image/jpeg' || file.type === 'image/png'
  const isLt2M = file.size / 1024 / 1024 < 2

  if (!isJPG) {
    ElMessage.error('只能上传 JPG/PNG 格式')
    return false  // 阻止上传
  }
  if (!isLt2M) {
    ElMessage.error('图片大小不能超过 2MB')
    return false
  }
  return true
}

const handleSuccess = (response, file, fileList) => {
  ElMessage.success('上传成功')
  // response 一般为后端返回的 URL 等
}

const handleError = () => {
  ElMessage.error('上传失败')
}
</script>

6.3 手动上传(和表单一起提交)

<template>
  <el-form :model="form">
    <el-form-item label="附件">
      <el-upload
        ref="uploadRef"
        :auto-upload="false"
        :limit="3"
        :on-exceed="handleExceed"
        :on-change="handleChange"
      >
        <el-button type="primary">选择文件</el-button>
      </el-upload>
    </el-form-item>
    <el-button @click="submitForm">提交表单(含文件)</el-button>
  </el-form>
</template>

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

const uploadRef = ref()
const form = ref({ files: [] })

// 手动上传时,选中的文件会进入 fileList,需要自己调用接口上传
const handleChange = (file, fileList) => {
  form.value.files = fileList
}

const handleExceed = () => {
  ElMessage.warning('最多上传 3 个文件')
}

const submitForm = async () => {
  const formData = new FormData()
  form.value.files.forEach(f => {
    formData.append('files', f.raw)
  })
  // 再 append 其他表单字段...
  // await uploadApi(formData)
}
</script>

说明要点:

  • :auto-upload="false" 关闭自动上传
  • on-change 拿到选中的文件列表
  • 手动上传时用 FormData 组装并调用自己的接口

6.4 常见踩坑

原因 处理
before-upload 返回 false 仍上传 理解错误 返回 falsePromise.reject() 会阻止上传
上传后列表不更新 未绑定 file-list v-model:file-list:file-list 绑定
跨域、Cookie 未带凭证 设置 :with-credentials="true"
需要 Token 接口要鉴权 通过 :headers 传入

七、小结

  • Form:用 :model + prop + rules,三者字段名一致
  • Tableprop 对数据字段,复杂展示用 #default 插槽
  • Dialog:用 v-model 控制显隐,表单弹窗建议 destroy-on-close
  • Message:轻量提示;MessageBox:确认、输入
  • Upload:自动上传用 action + 钩子;手动上传用 :auto-upload="false" + 自定义提交

按上述方式选型和编码,可以避开大部分常见坑。如果你希望我按某一块(比如 Form、Table、Upload)再单独细化成一篇更长的教程,可以说明一下侧重点(例如:复杂表单、动态表格、多图上传等)。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

Vue 3 Composition API深度解析:构建可复用逻辑的终极方案

引言

Vue 3的Composition API是Vue框架最重大的更新之一,它提供了一种全新的组件逻辑组织方式。与传统的Options API相比,Composition API让我们能够更灵活地组织和复用代码逻辑。本文将深入探讨Vue 3 Composition API的8大核心特性,帮助你掌握这个构建可复用逻辑的终极方案。

setup函数基础

1. setup函数的基本使用

setup函数是Composition API的入口点,它在组件创建之前执行。

import { ref, reactive } from 'vue';

export default {
  setup() {
    // 定义响应式数据
    const count = ref(0);
    const user = reactive({
      name: 'Vue 3',
      version: '3.0'
    });

    // 定义方法
    const increment = () => {
      count.value++;
    };

    // 返回给模板使用
    return {
      count,
      user,
      increment
    };
  }
};

2. setup函数的参数

setup函数接收两个参数:props和context。

export default {
  props: {
    title: String,
    initialCount: {
      type: Number,
      default: 0
    }
  },
  setup(props, context) {
    // props是响应式的,不能解构
    console.log(props.title);
    
    // context包含attrs、slots、emit等
    const { attrs, slots, emit } = context;
    
    // 触发事件
    const handleClick = () => {
      emit('update', props.initialCount + 1);
    };
    
    return { handleClick };
  }
};

响应式API详解

3. ref与reactive的选择

ref和reactive是创建响应式数据的两种方式,各有适用场景。

import { ref, reactive, toRefs } from 'vue';

// ref - 适合基本类型和单一对象
const count = ref(0);
const message = ref('Hello');

// 访问ref的值需要.value
console.log(count.value);
count.value++;

// reactive - 适合复杂对象
const state = reactive({
  count: 0,
  user: {
    name: 'Vue',
    age: 3
  }
});

// 访问reactive的值不需要.value
console.log(state.count);
state.count++;

// 在模板中自动解包,不需要.value
// <template>
//   <div>{{ count }}</div>
//   <div>{{ state.count }}</div>
// </template>

4. toRefs的使用

当需要从reactive对象中解构属性时,使用toRefs保持响应性。

import { reactive, toRefs } from 'vue';

export default {
  setup() {
    const state = reactive({
      count: 0,
      name: 'Vue 3',
      isActive: true
    });

    // 不推荐 - 失去响应性
    // const { count, name } = state;

    // 推荐 - 使用toRefs保持响应性
    const { count, name, isActive } = toRefs(state);

    const increment = () => {
      count.value++;
    };

    return {
      count,
      name,
      isActive,
      increment
    };
  }
};

计算属性与侦听器

5. computed计算属性

computed用于创建计算属性,支持getter和setter。

import { ref, computed } from 'vue';

export default {
  setup() {
    const firstName = ref('John');
    const lastName = ref('Doe');

    // 只读计算属性
    const fullName = computed(() => {
      return firstName.value + ' ' + lastName.value;
    });

    // 可写计算属性
    const writableFullName = computed({
      get() {
        return firstName.value + ' ' + lastName.value;
      },
      set(value) {
        const [first, last] = value.split(' ');
        firstName.value = first;
        lastName.value = last;
      }
    });

    return {
      firstName,
      lastName,
      fullName,
      writableFullName
    };
  }
};

6. watch与watchEffect

watch和watchEffect用于侦听数据变化。

import { ref, reactive, watch, watchEffect } from 'vue';

export default {
  setup() {
    const count = ref(0);
    const user = reactive({
      name: 'Vue',
      age: 3
    });

    // watchEffect - 自动追踪依赖
    watchEffect(() => {
      console.log(`Count is: ${count.value}`);
      console.log(`User is: ${user.name}`);
    });

    // watch - 显式指定侦听源
    watch(count, (newValue, oldValue) => {
      console.log(`Count changed from ${oldValue} to ${newValue}`);
    });

    // 侦听多个源
    watch([count, () => user.name], ([newCount, newName], [oldCount, oldName]) => {
      console.log(`Count: ${oldCount} -> ${newCount}, Name: ${oldName} -> ${newName}`);
    });

    // watch的配置选项
    watch(
      () => user.name,
      (newValue) => {
        console.log(`Name changed to: ${newValue}`);
      },
      {
        immediate: true,  // 立即执行
        deep: true        // 深度侦听
      }
    );

    return { count, user };
  }
};

生命周期钩子

7. 生命周期钩子的使用

Composition API中的生命周期钩子以on开头。

import { 
  onMounted, 
  onUpdated, 
  onUnmounted,
  onBeforeMount,
  onBeforeUpdate,
  onBeforeUnmount
} from 'vue';

export default {
  setup() {
    onBeforeMount(() => {
      console.log('组件挂载前');
    });

    onMounted(() => {
      console.log('组件已挂载');
      // 可以在这里访问DOM
    });

    onBeforeUpdate(() => {
      console.log('组件更新前');
    });

    onUpdated(() => {
      console.log('组件已更新');
    });

    onBeforeUnmount(() => {
      console.log('组件卸载前');
    });

    onUnmounted(() => {
      console.log('组件已卸载');
      // 清理工作
    });

    return {};
  }
};

自定义组合函数

8. 创建可复用的逻辑

自定义组合函数是Composition API的核心优势,让我们能够提取和复用逻辑。

// useCounter.js - 计数器逻辑
import { ref, computed } from 'vue';

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);

  const increment = () => {
    count.value++;
  };

  const decrement = () => {
    count.value--;
  };

  const reset = () => {
    count.value = initialValue;
  };

  const double = computed(() => count.value * 2);

  return {
    count,
    increment,
    decrement,
    reset,
    double
  };
}

// useMouse.js - 鼠标位置追踪
import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
  const x = ref(0);
  const y = ref(0);

  const update = (event) => {
    x.value = event.pageX;
    y.value = event.pageY;
  };

  onMounted(() => {
    window.addEventListener('mousemove', update);
  });

  onUnmounted(() => {
    window.removeEventListener('mousemove', update);
  });

  return { x, y };
}

// 在组件中使用
import { useCounter, useMouse } from './composables';

export default {
  setup() {
    const { count, increment, decrement, double } = useCounter(10);
    const { x, y } = useMouse();

    return {
      count,
      increment,
      decrement,
      double,
      x,
      y
    };
  }
};

依赖注入

9. provide与inject

provide和inject用于跨组件层级传递数据。

// 父组件
import { provide, ref } from 'vue';

export default {
  setup() {
    const theme = ref('dark');
    const user = ref({
      name: 'Vue User',
      role: 'admin'
    });

    // 提供数据
    provide('theme', theme);
);
    provide('user', user);

    return { theme };
  }
};

// 子组件
import { inject } from 'vue';

export default {
  setup() {
    // 注入数据
    const theme = inject('theme');
    const user = inject('user');

    // 提供默认值
    const config = inject('config', {
      debug: false,
      version: '1.0'
    });

    return { theme, user, config };
  }
};

模板引用

10. 使用ref获取DOM元素

在Composition API中使用ref获取模板引用。

import { ref, onMounted } from 'vue';

export default {
  setup() {
    // 创建模板引用
    const inputRef = ref(null);
    const listRef = ref(null);

    onMounted(() => {
      // 访问DOM元素
      inputRef.value.focus();
      
      // 访问组件实例
      console.log(listRef.value.items);
    });

    const focusInput = () => {
      inputRef.value.focus();
    };

    return {
      inputRef,
      listRef,
      focusInput
    };
  }
};

// 模板中使用
// <template>
//   <input ref="inputRef" />
//   <MyList ref="listRef" />
// </template>

实战案例

11. 表单处理组合函数

// useForm.js
import { ref, reactive } from 'vue';

export function useForm(initialValues, validationRules) {
  const values = reactive({ ...initialValues });
  const errors = reactive({});
  const touched = reactive({});

  const validate = () => {
    let isValid = true;
    
    for (const field in validationRules) {
      const rules = validationRules[field];
      const value = values[field];
      
      for (const rule of rules) {
        if (rule.required && !value) {
          errors[field] = rule.message || '此字段必填';
          isValid = false;
          break;
        }
        
        if (rule.pattern && !rule.pattern.test(value)) {
          errors[field] = rule.message || '格式不正确';
          isValid = false;
          break;
        }
        
        if (rule.validator && !rule.validator(value)) {
          errors[field] = rule.message || '验证失败';
          isValid = false;
          break;
        }
      }
    }
    
    return isValid;
  };

  const handleChange = (field) => (event) => {
    values[field] = event.target.value;
    touched[field] = true;
    
    if (errors[field]) {
      validate();
    }
  };

  const handleBlur = (field) => () => {
    touched[field] = true;
    validate();
  };

  const reset = () => {
    Object.assign(values, initialValues);
    Object.keys(errors).forEach(key => {
      errors[key] = '';
    });
    Object.keys(touched).forEach(key => {
      touched[key] = false;
    });
  };

  const submit = (callback) => () => {
    if (validate()) {
      callback(values);
    }
  };

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    validate,
    reset,
    submit
  };
}

// 使用示例
export default {
  setup() {
    const { values, errors, handleChange, handleBlur, submit } = useForm(
      {
        username: '',
        email: '',
        password: ''
      },
      {
        username: [
          { required: true, message: '用户名必填' },
          { pattern: /^[a-zA-Z0-9_]{3,20}$/, message: '用户名格式不正确' }
        ],
        email: [
          { required: true, message: '邮箱必填' },
          { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '邮箱格式不正确' }
        ],
        password: [
          { required: true, message: '密码必填' },
          { validator: (value) => value.length >= 6, message: '密码至少6位' }
        ]
      }
    );

    const handleSubmit = submit((formData) => {
      console.log('表单提交:', formData);
      // 发送API请求
    });

    return {
      values,
      errors,
      handleChange,
      handleBlur,
      handleSubmit
    };
  }
};

12. 异步数据获取组合函数

// useAsyncData.js
import { ref, onMounted } from 'vue';

export function useAsyncData(fetchFn, options = {}) {
  const {
    immediate = true,
    initialData = null,
    onSuccess,
    onError
  } = options;

  const data = ref(initialData);
  const loading = ref(false);
  const error = ref(null);

  const execute = async (...args) => {
    loading.value = true;
    error.value = null;

    try {
      const result = await fetchFn(...args);
      data.value = result;
      
      if (onSuccess) {
        onSuccess(result);
      }
      
      return result;
    } catch (err) {
      error.value = err;
      
      if (onError) {
        onError(err);
      }
      
      throw err;
    } finally {
      loading.value = false;
    }
  };

  if (immediate) {
    onMounted(execute);
  }

  return {
    data: data,
    loading: loading,
    error: error,
    execute: execute,
    refresh: execute
  };
}

// 使用示例
export default {
  setup() {
    const { data, loading, error, refresh } = useAsyncData(
      async (userId) => {
        const response = await fetch(`/api/users/${userId}`);
        return response.json();
      },
      {
        immediate: true,
        onSuccess: (data) => {
          console.log('数据加载成功:', data);
        },
        onError: (error) => {
          console.error('数据加载失败:', error);
        }
      }
    );

    return {
      data,
      loading,
      error,
      refresh
    };
  }
};

总结

Vue 3 Composition API为我们提供了更强大、更灵活的代码组织方式:

核心优势

  1. 逻辑复用:通过自定义组合函数轻松复用逻辑
  2. 代码组织:相关逻辑可以组织在一起,而不是分散在options中
  3. 类型推断:更好的TypeScript支持
  4. 灵活性:更灵活的代码组织方式

最佳实践

  1. 合理使用ref和reactive:基本类型用ref,复杂对象用reactive
  2. 提取组合函数:将可复用逻辑提取为独立的组合函数
  3. 保持单一职责:每个组合函数只负责一个功能
  4. 善用toRefs:解构reactive对象时使用toRefs保持响应性
  5. 合理使用生命周期:在setup中正确使用生命周期钩子

学习路径

  1. 掌握setup函数和响应式API
  2. 学习computed和watch的使用
  3. 理解生命周期钩子
  4. 实践自定义组合函数
  5. 掌握依赖注入和模板引用

Composition API不仅是一种新的API,更是一种新的思维方式。它让我们能够以更函数式、更模块化的方式组织代码,提高了代码的可维护性和可测试性。开始在你的项目中使用Composition API吧,体验Vue 3带来的全新开发体验!


本文首发于掘金,欢迎关注我的专栏获取更多前端技术干货!

前端权限控制设计

一、展示控制

前端权限控制的目的是,根据当前用户的身份控制其能访问的页面和可执行的操作。需要注意的是:前端权限控制主要是为了提升用户体验(如隐藏无权限的菜单,按钮),正真的数据安全必须依赖后端实现。

二、RBAC

业界主流的权限管理模型是RBAC(基于角色的访问控制),其核心思想是将"权限"授予"角色",将"角色"授予"用户",实现了用户与权限的逻辑分离,极大的简化了权限的分配与管理。

三、主要流程

主要包括用户身份认证、权限分配、权限校验和页面展示控制。

  • 用户登录后,前端从后端获取用户的权限列表。
  • 前端根据用户权限信息,决定展示哪些菜单或按钮。
  • 路由级别做权限校验,未授权用户访问受限页面时自动跳转到无权限提示页或登录页。
  • 组件级别做权限控制,操作按钮或表单项根据权限动态展示或禁用。

四、实现要点

1.获取用户权限信息

// context/AuthProvider

const AuthContext = createContext(undefined);

export const useAuth = () => useContext(AuthContext);
export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);

  // 从本地存储中恢复用户权限信息
  useEffect(() => {
    const user = localStorage.getItem('user');
    if (user) {
      setUser(JSON.parse(user));
    }
  }, []);

  const login = async (username, password) => {
    const user = await loginApi(username,password);
    setUser(user);
    // 登录后缓存用户权限信息
    localStorage.setItem('user', JSON.stringify(user));
  };

  const logout = () => {
    setUser(null);
    // 登出后清除本地缓存
    localStorage.removeItem('user');
  };

  const hasPermission = (permission: string | string[]): boolean => {
    if (!user) return false;
    if (Array.isArray(permission)) {
      return permission.some(p => user.permissions.includes(p));
    }
    
    return user.permissions.includes(permission);
  };

  const value = {
    user,
    login,
    logout,
    hasPermission
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

2.封装路由权限校验组件

// components/AuthRoute.js
import { useAuth } from '../context/AuthProvider'; // 自定义 hook,获取用户信息

const AuthRoute = ({ children, meta }) => {
  const { user, hasPermission } = useAuth();

   // 用户未登录,重定向到登录页面
  if (meta.requiresAuth && !user) {
    return <Navigate to="/login" replace />;
  }

  // 用户没有权限,重定向到未授权页面
  if (meta.permission && !hasPermission(meta.permission)) {
    return <Navigate to="/403" replace />;
  }

  // 权限通过,渲染子组件
  return children;
};

export default AuthRoute;

3.创建路由

// router/index.js
import AuthRoute from '../components/AuthRoute';

const Router = () => {
  const element = routes.map(({ path, element:Component, meta }) => ({
      path,
      element: (
        <AuthRoute meta={meta}>
          <Component />
        </AuthRoute>
      )
  }));
  return <RouterProvider router={createBrowserRouter(routers)} />;
};

export default Router;

4.封装按钮权限校验组件


import { useAuth } from '../context/AuthProvider'; // 自定义 hook,获取用户信息

export const AuthButton = ({
  permission,
  children,
  onClick,
}) => {
  const { hasPermission } = useAuth();
  const hasAccess = hasPermission(permission);

  if (!hasAccess) {
    return null;
  }

  return (
    <button 
      onClick={onClick} 
    >
      {children}
    </button>
  );
};

5.按钮权限控制

import { AuthButton } from '../components/AuthButton';

export const ContentManagement = () => {
  
  return (
     <AuthButton 
        permission="content.edit"
        onClick={() => handleEdit(item.id)}
     >
        编辑
     </AuthButton>
  );
};

五、技术难点

1.多粒度权限控制

  • 页面级权限控制:通过前端路由守卫实现,例如,React Router的高阶组件、Vue Router 的beforeEach钩子。
  • 组件级权限控制:通过条件渲染隐藏或禁用无权限的按钮。

2.细粒度权限控制

按钮、表单项等细粒度权限控制,难点在于检查点分散,如果每个按钮都要添加额外的权限控制逻辑,维护成本高;另外权限检查函数频繁执行(如在列表中渲染几十个按钮),可能造成性能问题。

常用的做法是封装自定义 Hook(如 usePermission)或高阶组件,并且缓存组件的权限检查结果。

3.状态管理的复杂性

用户权限信息需要全局共享且保持一致性。难点在于:

  • 初始化时机:页面渲染时可能还没拿到用户信息,容易导致未授权页面闪现。
  • Token 过期:接口返回Token过期,需要自动跳转登录,同时清空本地缓存。
  • 多标签页同步:如果一个标签页登出,其他标签页也需要更新状态,否则可能操作报错。

解决方案通常是利用 Context全局共享,使用webStorage本地缓存,利用广播实现多标签页同步。

4.前后端权限一致性

前端权限控制本质是提升用户体验,正真的数据安全必须依赖后端实现。但难点在于:

  • 双重校验的一致性:前端隐藏了按钮,用户仍可能通过直接访问 API 进行操作,所以后端必须对所有接口做权限校验。
  • 数据同步滞后:如果后端修改了用户权限,前端可能仍保留旧的权限缓存,导致用户看到不应看到的操作或无法访问新功能。需要设计合适的刷新机制(如定时拉取、权限变更后强制刷新)。
❌