阅读视图

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

别再被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 进行操作,所以后端必须对所有接口做权限校验。
  • 数据同步滞后:如果后端修改了用户权限,前端可能仍保留旧的权限缓存,导致用户看到不应看到的操作或无法访问新功能。需要设计合适的刷新机制(如定时拉取、权限变更后强制刷新)。

腾讯域名拦截查询 在线工具核心JS实现

这篇只讲功能层 JavaScript/TypeScript 实现,围绕“输入一个域名,得到可读的拦截状态”这一条主链路展开。

工具有两条查询通道(第三方接口):

  • QQ通道:https://cgi.urlsec.qq.com/index.php?m=check&a=gw_check&callback=url_query&url={url}&ticket={ticket}&randstr={randstr}&_={timestamp}
  • 微信通道:https://cgi.urlsec.qq.com/index.php?m=url&a=validUrl&url={url}

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

1. 输入规范化是第一道关口

这个工具不直接信任用户输入,而是统一走 normalizeInput

const normalizeInput = (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 url = new URL(withProtocol)
    if (!['http:', 'https:'].includes(url.protocol)) return ''
    return url.toString()
  } catch {
    return ''
  }
}

这里做了三件关键事:去空白、补协议、用 URL 做结构化校验。后续所有请求都只使用规范化后的值。

2. 查询动作编排

点击查询时,动作顺序是固定的:

  1. 判空
  2. 规范化
  3. 清理旧结果
  4. 标记查询通道
  5. 进入对应通道请求
const startQqQuery = () => {
  if (!input.value.trim()) {
    errorMessage.value = '请输入要查询的域名'
    return
  }

  const normalized = normalizeInput(input.value)
  if (!normalized) {
    errorMessage.value = '请输入有效的网址'
    return
  }

  input.value = normalized
  errorMessage.value = ''
  resultData.value = null
  lastQueryType.value = 'qq'
  submitQqQuery(normalized, ticket, randstr)
}

const startWeChatQuery = () => {
  if (!input.value.trim()) {
    errorMessage.value = '请输入要查询的域名'
    return
  }

  const normalized = normalizeInput(input.value)
  if (!normalized) {
    errorMessage.value = '请输入有效的网址'
    return
  }

  input.value = normalized
  errorMessage.value = ''
  resultData.value = null
  lastQueryType.value = 'wx'
  submitWeChatQuery(normalized, captchaPayload)
}

这一层不做网络请求细节,只负责把交互状态整理干净。

3. 请求提交与异常回传

真正请求在 submit 函数里,统一处理 loading、异常捕获和结果写入。两条通道分别请求不同第三方 API。

const submitQqQuery = async (url, ticket, randstr) => {
  loading.value = true
  errorMessage.value = ''

  try {
    const apiUrl = `https://cgi.urlsec.qq.com/index.php?m=check&a=gw_check&callback=url_query&url=${encodeURIComponent(
      url
    )}&ticket=${encodeURIComponent(ticket)}&randstr=${encodeURIComponent(randstr)}&_=${Date.now()}123`

    const response = await fetch(apiUrl, {
      method: 'GET',
      headers: {
        Referer: 'https://urlsec.qq.com/check.html',
        'User-Agent':
          'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
        Accept: 'application/json',
        'Accept-Language': 'zh-CN,zh;q=0.8',
        Connection: 'close'
      }
    })

    const text = await response.text()
    const data = parseJsonp(text)
    if (!data || data.reCode !== 0 || !data.data?.results) {
      throw new Error(data?.data || '查询失败')
    }

    resultData.value = buildQqResult(url, data.data.results)
  } catch (error) {
    errorMessage.value = error?.message || '查询失败'
  } finally {
    loading.value = false
  }
}

const submitWeChatQuery = async (url, captchaPayload) => {
  loading.value = true
  errorMessage.value = ''

  try {
    const apiUrl = `https://cgi.urlsec.qq.com/index.php?m=url&a=validUrl&url=${encodeURIComponent(url)}`

    const response = await fetch(apiUrl, {
      method: 'GET',
      headers: {
        Referer: 'https://urlsec.qq.com/check.html',
        'User-Agent':
          'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
        Accept: 'application/json',
        'Accept-Language': 'zh-CN,zh;q=0.8',
        Connection: 'close'
      }
    })

    const text = await response.text()
    const data = JSON.parse(text)
    const isBlocked = data.data === 'ok'

    resultData.value = {
      url,
      status: {
        type: isBlocked ? 'wechat_blocked' : 'wechat_safe'
      }
    }
  } catch (error) {
    errorMessage.value = error?.message || '查询失败'
  } finally {
    loading.value = false
  }
}

前端只认统一的返回结构:{ status: 'ok', data: ... }

4. 服务端 URL 清洗与请求拦截

服务端入口先拦截无效请求,再代理到上游接口:

const normalizeUrl = (input: string) => {
  const rawValue = String(input || '').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 ''
  }
}

if (!url) {
  setResponseStatus(event, 400)
  return { status: 'error', message: 'Invalid url' }
}

这样可以保证前后端都执行相同的输入约束,避免脏数据直接进入上游请求。

5. QQ通道:JSONP 解析与状态映射

QQ通道第三方接口:https://cgi.urlsec.qq.com/index.php?m=check&a=gw_check&callback=url_query&url={url}&ticket={ticket}&randstr={randstr}&_={timestamp}

QQ通道返回的是 JSONP,不是纯 JSON,所以先解包:

const parseJsonp = (jsonpStr: string) => {
  const match = jsonpStr.match(/url_query\((.+)\)/)
  if (match && match[1]) {
    try {
      return JSON.parse(match[1])
    } catch {
      return null
    }
  }
  return null
}

拿到对象后,再把复杂字段折叠成前端可消费的数据模型:

if (result.whitetype === 3 || result.whitetype === 4) {
  data.status.type = 'whitelist'
} else if (result.whitetype === 2) {
  data.status.type = 'blocked'
  data.status.wordingTitle = result.WordingTitle || ''
  data.status.wording = result.Wording || ''
} else if (result.whitetype === 1) {
  if (result.eviltype === 2800 || result.eviltype === 2804) {
    data.status.type = 'qq_blocked'
  } else if (result.eviltype && result.eviltype !== 0) {
    data.status.type = 'other_blocked'
    data.status.evilType = result.eviltype
  } else {
    data.status.type = 'safe'
  }
}

这一步的重点不是“原样透传”,而是“转成稳定业务语义”。

6. 微信通道:返回值压平

微信通道第三方接口:https://cgi.urlsec.qq.com/index.php?m=url&a=validUrl&url={url}

微信查询通道返回结构更简单,核心逻辑就是把上游标记转成统一状态:

const isBlocked = data.data === 'ok'

return {
  status: 'ok',
  data: {
    url,
    status: {
      type: isBlocked ? 'wechat_blocked' : 'wechat_safe'
    }
  }
}

两条通道虽然来源不同,但最终都对齐到 data.status.type,前端渲染就能复用同一套逻辑。

7. 结果渲染:从对象到行数据

页面不直接硬编码每一行,而是先把结果对象转换成 resultRows

const resultRows = computed(() => {
  const data = resultData.value
  if (!data) return []

  const rows = []
  const addRow = (key, label, value, extra = {}) => {
    if (value === undefined || value === null || value === '') return
    rows.push({ key, label, value, ...extra })
  }

  addRow('url', '检测地址', data.url)
  addRow('status', '检测结果', statusText.value, { isStatus: true, toneClass: statusTone.value })

  if (data.status?.wordingTitle) addRow('reasonTitle', '原因标题', data.status.wordingTitle)
  if (data.status?.wording) addRow('reasonDetail', '原因详情', data.status.wording)

  return rows
})

这种“先标准化、再渲染”的模式,能让字段增减时只改一处映射逻辑。

到这里,核心链路就是:输入标准化 → 查询编排 → 服务端映射 → 统一结果模型 → 页面渲染。

TypeScript 强力护航:PropType 与组件事件类型的声明

前言

在 Vue 3 + TypeScript 的项目中,组件的类型安全是一个核心话题。很多开发者可能有过这样的经历:使用一个第三方组件时,完全不知道它接受哪些 Props,也不知道事件应该传递什么参数,只能去翻文档。或者在自己的项目中,修改了一个组件的 Props,结果到处报错,不得不全局搜索手动修改。

TypeScript 的出现改变了这一切。通过为组件 Props 和事件声明类型,我们不仅能获得完美的智能提示,还能让编译器在开发阶段就发现类型错误。本文将深入探讨如何在 Vue 3 中为组件定义类型安全的 Props 和事件,包括复杂的泛型组件实现。

Vue 组件类型系统的演进

Options API 中的 Prop 类型:运行时校验

在 Options API 中,我们通过对象形式定义 Props:

export default {
  props: {
    // 基础类型检查
    name: String,
    age: Number,
    
    // 带验证的写法
    email: {
      type: String,
      required: true,
      validator: (value: string) => value.includes('@')
    },
    
    // 复杂类型
    user: {
      type: Object,
      default: () => ({})
    }
  }
}

这种写法存在很多局限性:

  • 运行时类型检查:这些类型只在运行时验证,TypeScript 无法在编译时捕获错误
  • 复杂类型无法表达:user: Object 无法描述对象的内部结构
  • 没有智能提示:在模板中使用 props 时,编辑器不知道有哪些属性

Composition API 带来的类型优势

Composition API 配合 TypeScript,让类型推导变得更加强大:

<script setup lang="ts">
// 现在可以获得类型推导
const props = defineProps({
  name: String,
  age: Number
})

// props.name 被推导为 string | undefined
// props.age 被推导为 number | undefined
</script>

但这种方法仍然有局限性,无法定义复杂的嵌套类型。

为什么需要显式的 PropType?

当 Props 的类型不是简单的 String、Number 等构造函数时,就需要 PropType 来帮助 TypeScript 理解类型。我们先来看一个反例:

// ❌ 这样写,TypeScript 会报错
defineProps({
  user: {
    type: Object as User, // 'User' only refers to a type, but is being used as a value here
    required: true
  }
})

正确写法:

defineProps({
  user: {
    type: Object as PropType<User>, // 告诉 TypeScript 这是一个 User 类型
    required: true
  },
  
  // 联合类型
  status: {
    type: String as PropType<'active' | 'inactive'>,
    default: 'active'
  },
  
  // 复杂对象
  config: {
    type: Object as PropType<{
      theme: string
      fontSize: number
    }>,
    default: () => ({ theme: 'light', fontSize: 14 })
  }
})

Props 定义的三种方式

运行时声明 + 类型推导(基础写法)

<script setup lang="ts">
// 基础类型会自动推导
const props = defineProps({
  name: String,           // props.name: string | undefined
  age: Number,            // props.age: number | undefined
  isActive: Boolean,      // props.isActive: boolean | undefined
  tags: Array,            // props.tags: any[] | undefined
  user: Object            // props.user: Record<string, any> | undefined
})

// 设置默认值
const propsWithDefault = defineProps({
  count: {
    type: Number,
    default: 0
  },                      // props.count: number
  items: {
    type: Array,
    default: () => []
  }                       // props.items: any[]
})
</script>
  • 优点:写法简单,有运行时类型检查
  • 缺点:复杂类型无法表达,如 string[] 会被推导为 any[]

纯类型声明(推荐)

这是 Vue 3.3+ 推荐的方式,使用 TypeScript 接口或类型别名:

<script setup lang="ts">
// 定义 Props 接口
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
}

interface Config {
  theme: 'light' | 'dark'
  fontSize: number
  showAvatar?: boolean
}

interface Props {
  title: string
  count?: number
  user: User
  config: Config
  tags: string[]
  status: 'loading' | 'success' | 'error'
}

// 直接使用接口
const props = defineProps<Props>()

// 需要默认值时,使用 withDefaults
const propsWithDefault = withDefaults(defineProps<Props>(), {
  count: 0,
  tags: () => [],
  config: () => ({ theme: 'light', fontSize: 14 })
})
</script>
  • 优点:

    • 完美的类型推导
    • 支持任何复杂的 TypeScript 类型
    • 编辑器智能提示完美
  • 缺点:

    • 需要 Vue 3.3+ 版本
    • 不能同时使用运行时验证(如 validator 函数)

复杂类型的处理:PropType 工具类型

当需要运行时验证,又想保留类型时,使用 PropType:

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

// 定义复杂类型
interface User {
  id: number
  name: string
  email: string
  preferences: {
    theme: 'light' | 'dark'
    notifications: boolean
  }
}

type Status = 'pending' | 'processing' | 'completed' | 'failed'

// 使用 PropType 辅助类型推导
const props = defineProps({
  // 对象类型
  user: {
    type: Object as PropType<User>,
    required: true,
    validator: (user: User) => user.name.length > 0
  },
  
  // 联合类型
  status: {
    type: String as PropType<Status>,
    default: 'pending'
  },
  
  // 数组类型
  tags: {
    type: Array as PropType<string[]>,
    default: () => []
  },
  
  // 函数类型
  onSave: {
    type: Function as PropType<(data: User) => Promise<void>>,
    required: false
  },
  
  // 复杂的嵌套类型
  config: {
    type: Object as PropType<{
      pagination: {
        pageSize: number
        currentPage: number
      }
      filters: Record<string, any>
    }>,
    default: () => ({
      pagination: { pageSize: 10, currentPage: 1 },
      filters: {}
    })
  }
})
</script>

适用场景:

  • 需要运行时验证(如 validator)
  • 需要设置复杂的默认值逻辑
  • 需要与 Options API 混用

事件发射的类型安全

defineEmits 的基础用法

<script setup lang="ts">
// 基础写法:字符串数组
const emit = defineEmits(['change', 'update', 'delete'])

// 使用时没有任何类型提示
emit('change', 123) // 可以传任意参数
emit('update', 'any', 'thing') // 没问题
</script>

为事件负载定义类型(推荐)

<script setup lang="ts">
// 使用类型声明
interface Emits {
  // 基础事件
  (e: 'change', value: string): void
  (e: 'update:id', id: number): void
  (e: 'delete'): void
  
  // 多个参数
  (e: 'item-move', fromIndex: number, toIndex: number): void
  
  // 联合类型的事件名
  (e: 'success' | 'error', message: string): void
}

const emit = defineEmits<Emits>()

// 使用时的类型检查
emit('change', '新值')      // ✅ 正确
emit('change', 123)         // ❌ 错误:参数类型必须是 string
emit('update:id', 1)        // ✅ 正确
emit('delete')              // ✅ 正确
emit('item-move', 0, 5)     // ✅ 正确
emit('item-move', 0)        // ❌ 错误:缺少第二个参数
</script>

v-model 的类型安全

<script setup lang="ts">
// 单个 v-model
interface Emits {
  (e: 'update:modelValue', value: string): void
  (e: 'update:searchText', value: string): void
  (e: 'update:selectedIds', ids: number[]): void
}

const emit = defineEmits<Emits>()

// 多个 v-model 的使用
function handleInput(value: string) {
  emit('update:modelValue', value)
}

function handleSearch(value: string) {
  emit('update:searchText', value)
}

function handleSelect(ids: number[]) {
  emit('update:selectedIds', ids)
}
</script>

<template>
  <!-- 父组件使用时获得类型提示 -->
  <ChildComponent 
    v-model="text"
    v-model:search-text="searchText"
    v-model:selected-ids="selectedIds"
  />
</template>

泛型组件的实现技巧

使用 defineComponent 配合泛型

在 Vue 3.3 之前,需要使用 defineComponent 来创建泛型组件:

// GenericTable.ts
import { defineComponent, PropType } from 'vue'

export default defineComponent({
  name: 'GenericTable',
  
  props: {
    data: {
      type: Array as PropType<any[]>,
      required: true
    },
    columns: {
      type: Array as PropType<TableColumn<any>[]>,
      required: true
    },
    rowKey: {
      type: [String, Function] as PropType<string | ((row: any) => string)>,
      required: true
    }
  },
  
  emits: {
    'sort-change': (sort: SortState) => true,
    'row-click': (row: any, index: number) => true
  },
  
  setup(props, { emit }) {
    // 实现逻辑
    return () => {
      // 渲染函数
    }
  }
})

// 使用时需要手动指定类型
const table = GenericTable as <T extends Record<string, any>>(
  new () => {
    $props: TableProps<T>
  }
)

在 SFC 中使用

Vue 3.3 引入了 generic 属性,让泛型组件的实现变得简单:

<script setup lang="ts" generic="T extends { id: string | number }">
// T 必须包含 id 属性
defineProps<{
  items: T[]
  selectedId?: T['id']
}>()

defineEmits<{
  select: [id: T['id']]
}>()
</script>

类型推导的局限性及解决方案

问题 1:模板中的类型推导

<script setup lang="ts" generic="T">
defineProps<{
  data: T[]
  format: (item: T) => string
}>()
</script>

<template>
  <div v-for="item in data" :key="item.id">
    <!-- ❌ item.id 可能不存在于 T 上 -->
    {{ format(item) }}
  </div>
</template>
解决方案:添加泛型约束
<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{
  data: T[]
  format: (item: T) => string
}>()
</script>

问题 2:事件参数的类型推导

<script setup lang="ts" generic="T">
const emit = defineEmits<{
  (e: 'update', item: T): void  // ❌ T 在这里无法推导
}>()
</script>
解决方案:使用运行时声明 + PropType
<script setup lang="ts">
import type { PropType } from 'vue'

const props = defineProps({
  items: {
    type: Array as PropType<T[]>,
    required: true
  }
})

const emit = defineEmits({
  'update': (item: any) => true
})
</script>

类型安全组件的收益

使用组件时的智能提示

当其他开发者在使用我们的组件时,VS Code 会提供完美的智能提示:

<template>
  <!-- 输入 <Table 就会弹出所有 Props 提示 -->
  <Table
    :data="users"
    :columns="columns"
    :row-key="'id'"
    @sort-change="handleSortChange"
    @row-click="handleRowClick"
  />
</template>

错误提前暴露

<script setup>
// ❌ 编译时报错:Property 'nme' does not exist on type 'User'
const columns = [
  { key: 'nme', title: '姓名' } // 拼写错误
]

// ❌ 编译时报错:Type 'string' is not assignable to type 'number'
const handleSortChange = (sort: SortState) => {
  sort.field = 123 // 类型错误
}
</script>

更好的可维护性

当需要修改组件 Props 时,TypeScript 会标记所有使用错误的地方:

// 将 Props 从 TableColumn 改为 ColumnConfig
interface TableProps<T> {
  columns: ColumnConfig<T>[] // 修改了类型
  // ...
}

// 所有使用了旧类型的地方都会报错,不需要手动查找

类型安全组件的最佳实践清单

  • 优先使用纯类型声明(defineProps())
  • 复杂类型使用 PropType 辅助
  • 为所有事件定义类型,包括负载参数
  • 使用泛型创建可复用组件,并添加必要约束
  • 导出组件的 Props 和 Emits 类型,方便使用者
  • 为插槽定义类型,提供更好的使用体验

结语

类型安全不是一蹴而就的,而是在开发过程中逐步完善的。它不仅是为了迎合 TypeScript ,更是为了让我们的代码更加健壮,让团队协作更加顺畅。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

组件设计原则:如何设计一个高内聚、低耦合的 Vue 组件

前言

在 Vue 应用开发中,组件就像是乐高积木,组件设计可以决定这些积木的形状和接口。好的设计可以让积木自由组合,构建出各种复杂的应用;而一个坏的设计则让积木之间互不兼容,最终导致代码难以维护、难以复用、难以测试。

尤其是随着项目规模的增长,组件设计的重要性愈发凸显。本文将深入探讨高内聚低耦合的核心概念,通过大量实战案例,帮助我们掌握 Vue 组件设计的精髓。

为什么组件设计如此重要?

现实痛点

开篇之前,我们先来看一个设计不良的组件会带来哪些问题:

<!-- ❌ 反例:一个上千行的 "上帝组件" -->
<template>
  <div>
    <!-- 用户信息区域 -->
    <div class="user-section">
      <img :src="user.avatar">
      <h2>{{ user.name }}</h2>
      <!-- 几百行用户相关代码 -->
    </div>
    
    <!-- 好友列表区域 -->
    <div class="friends-section">
      <!-- 又是几百行好友列表代码 -->
    </div>
    
    <!-- 动态列表区域 -->
    <div class="activities-section">
      <!-- 还有几百行动态列表代码 -->
    </div>
  </div>
</template>

<script>
export default {
  props: ['user'], // 什么类型?不知道
  data() {
    return {
      user: {},
      friends: [],
      activities: [],
      loading: false,
      error: null,
      // ... 还有诸多数据字段
    }
  },
  methods: {
    // 所有方法全部混在一起
    fetchUser() { /* ... */ },
    fetchFriends() { /* ... */ },
    fetchActivities() { /* ... */ },
    followUser() { /* ... */ },
    unfollowUser() { /* ... */ },
    likeActivity() { /* ... */ },
    // ... 其他方法
  }
}
</script>

这个组件存在的问题:

  • 牵一发而动全身:修改用户信息的样式,可能会意外影响好友列表
  • 难以复用:想在另一个页面显示好友列表?那只能复制粘贴上百行代码
  • 难以理解:新接手的人需要花一天时间才能理清逻辑
  • 难以测试:如何单独测试好友列表的功能?

好的组件设计带来的收益

<!-- ✅ 好的设计:拆分为独立组件 -->
<template>
  <div class="user-profile-page">
    <UserInfoCard :user="user" />
    <FriendList :friends="friends" @follow="handleFollow" />
    <ActivityFeed :activities="activities" @like="handleLike" />
  </div>
</template>

<script setup>
// 容器组件:只负责数据获取和组合
const { user, friends, activities } = await fetchUserData(props.userId)

function handleFollow(userId) { /* ... */ }
function handleLike(activityId) { /* ... */ }
</script>

这个组件带来的好处:

  • 可维护性:每个组件独立修改,互不影响
  • 可复用性:这个组件可以在任何地方使用
  • 可测试性:可以为每个组件编写独立的单元测试
  • 可读性:代码即文档,一目了然

高内聚低耦合:组件设计的黄金法则

什么是高内聚?

高内聚是指组件内部的元素(数据、方法、模板等)紧密相关,共同完成一个明确的职责:

<!-- ✅ 高内聚的计数器组件:所有逻辑都服务于"计数"这个单一职责 -->
<template>
  <div class="counter">
    <button @click="decrement" :disabled="count <= min">-</button>
    <span class="count">{{ count }}</span>
    <button @click="increment" :disabled="count >= max">+</button>
  </div>
</template>

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

const props = defineProps<{
  min?: number
  max?: number
  initial?: number
}>()

// 所有数据和方法都围绕 count 展开
const count = ref(props.initial ?? 0)

function increment() {
  if (count.value < (props.max ?? Infinity)) {
    count.value++
  }
}

function decrement() {
  if (count.value > (props.min ?? -Infinity)) {
    count.value--
  }
}
</script>

<style scoped>
/* 样式也只服务于这个组件 */
.counter {
  display: flex;
  align-items: center;
  gap: 8px;
}
</style>

高内聚的特征

  • 组件名称准确地描述了它的功能
  • 组件的所有代码都是为了实现这个功能
  • 移除任何一个部分都会影响核心功能

什么是低耦合?

低耦合是指组件之间的依赖关系简单、明确,修改一个组件不需要修改另一个组件:

<!-- 父组件 -->
<template>
  <div>
    <UserCard
      :user="user"
      @follow="handleFollow"
      @unfollow="handleUnfollow"
    />
  </div>
</template>

<!-- 子组件:不知道父组件的任何信息 -->
<template>
  <div class="user-card">
    <img :src="user.avatar" :alt="user.name">
    <h3>{{ user.name }}</h3>
    <button 
      v-if="!isFollowing"
      @click="$emit('follow', user.id)"
    >
      关注
    </button>
    <button 
      v-else
      @click="$emit('unfollow', user.id)"
    >
      取消关注
    </button>
  </div>
</template>

<script setup>
defineProps<{
  user: { id: number; name: string; avatar: string }
  isFollowing?: boolean
}>()

defineEmits<{
  follow: [userId: number]
  unfollow: [userId: number]
}>()
</script>

低耦合的特征

  • 组件只通过 Props 接收数据,通过 Events 发送消息
  • 组件内部不依赖全局状态(除非必要)
  • 修改组件内部实现,不需要修改使用它的地方

内聚与耦合的关系

高内聚和低耦合是相辅相成的:

  • 高内聚是低耦合的基础:只有组件内部职责清晰,才能设计出清晰的接口
  • 低耦合让高内聚更有价值:如果组件之间耦合度高,即使每个组件内聚再好,系统也难以维护

组件划分的边界艺术

如何判断一个组件是否应该拆分?

当我们在犹豫是否要拆分一个组件时,可以问问自己这几个问题:

  • 独立复用:这个部分能否在其他地方使用?
  • 独立逻辑:这个部分是否有独立的业务逻辑?
  • 频繁变化:这个部分是否会频繁修改?
  • 代码规模:代码是否过长,如是否超过 300 行?
  • 过度拆分:是否为了拆分而拆分,导致组件冗余?

原子设计方法论

原子设计方法论是由 Brad Frost 提出的一种用于构建设计系统的方法论。它借鉴了化学中的基本概念,认为所有的用户界面(UI)都可以由一系列基本的、不可再分的元素(原子)组合而成。其核心思想是分层构建,就像搭积木一样,从最小的单元开始,逐步组合成越来越复杂的结构,这个过程分为五个层次:

原子(Atoms)→ 分子(Molecules)→ 组织(Organisms)→ 模板(Templates)→ 页面(Pages)

原子

原子 是构成用户界面的最基本、最小的元素,无法再进一步细分。其本身不具备独立的功能性,但它们定义了所有设计元素的基础样式和属性。比如一个 <label> 标签、一个 <input> 输入框、一个 <button> 按钮、颜色调色板、字体、动画等:

<template>
  <button>原子按钮</button>
</template>

分子

分子 由多个原子组合在一起形成的相对简单的 UI 组件,具有简单、明确的功能,遵循“单一职责原则”,即:只做一件事,且把这件事做得很好。比如一个“搜索框”分子可以由一个 <label> 原子(“搜索”文字)、一个 <input> 原子(输入框)和一个 <button> 原子(“搜索”按钮)组合而成。这三个原子结合在一起,就形成了一个能执行搜索功能的最小单元:

<template>
  <div class="search-bar">
    <label>搜索:<label>
    <input v-model="searchText" />
    <button @click="search">搜索</button>
  </div>
</template>

组织

组织 由分子、原子以及其他组织组合而成的相对复杂的 UI 结构。它们构成了页面中一个独立的区域,作为页面中功能完善的模块,但本身还不是一个完整的页面。比如“用户列表”,由多个“用户卡片”分子构成:

<template>
  <div class="user-list">
    <UserCard v-for="user in users" :key="user.id" :user="user" />
  </div>
</template>

模板

模板 将多个组织、分子和原子组合在一起,形成页面的 骨架和布局结构。其关注的是内容在页面上的 排布方式,展示了各组件的相对位置和功能。如一个“管理布局”模板,定义了头部组织、正文内容区域和底部组织分别放在什么位置:

<template>
  <div class="layout">
    <header />
    <main>
      <SearchBar @search="handleSearch" />
      <UserList :users="filteredUsers" />
    </main>
    <footer />
  </div>
</template>

注:模板是 抽象 的,它没有填充真实的内容,只有占位符。只是定义了 布局结构

页面

页面 是模板的具体实例。它将真实的内容(文本、图片等)填充到模板中,并精确地调整整个界面的样式和逻辑,最终呈现给用户的样子。

原子设计方法论与 Vue3 的结合

Vue3 的原子:Vue3 中的基础元素组件

在 Vue3 中,原子通常对应那些只封装了最基础 HTML 元素和样式的组件。它们通常只通过 props 接收数据,并通过 $emitv-model 向外发送事件:

<!-- 1. 原子:BaseInput.vue -->
<template>
  <div class="base-input">
    <input
      :id="id"
      :type="type"
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      v-bind="$attrs"
    />
  </div>
</template>

<script setup lang="ts">
defineProps({
  id: String,
  type: { type: String, default: 'text' },
  modelValue: [String, Number]
})
defineEmits(['update:modelValue'])
</script>

Vue3 的分子:Vue3 中的功能组件

<!-- 分子:SearchForm.vue -->
<template>
  <form class="search-form" @submit.prevent="handleSubmit">
    <BaseInput
      v-model="searchText"
      label="搜索"
      placeholder="请输入关键词..."
    />
    <BaseButton type="submit">搜索</BaseButton>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'
import BaseButton from './BaseButton.vue'

const searchText = ref('')
const emit = defineEmits(['search'])

const handleSubmit = () => {
  emit('search', searchText.value)
}
</script>

Vue3 的组织:Vue3 中的区块组件

<!-- 组织:HeaderOrganism.vue -->
<template>
  <header class="site-header">
    <div class="logo">
      <img src="/logo.png" alt="Logo" />
      <span>My App</span>
    </div>
    <nav class="nav-menu">
      <a v-for="item in navItems" :key="item.link" :href="item.link">{{ item.text }}</a>
    </nav>
    <SearchForm @search="handleGlobalSearch" />
  </header>
</template>

<script setup lang="ts">
import SearchForm from './SearchForm.vue' // 导入分子

const navItems = [ /* ... */ ]
const handleGlobalSearch = (query) => { /* 处理全局搜索 */ }
</script>

Vue3 中的模板:Vue3 中的布局或页面组件(此时无数据)

模板在 Vue 中通常对应一个布局组件或一个无具体数据的页面级组件。它负责定义页面的骨架结构,引入各种组织组件,并将它们摆放在正确的位置。此时,组件接收的 propsslot 插槽内容都是抽象的占位符:

<!-- 模板:ArticlePageTemplate.vue -->
<template>
  <div class="article-page">
    <HeaderOrganism />
    <main class="content-wrapper">
      <aside class="sidebar">
        <!-- 这里是一个插槽,用于放置侧边栏内容,具体内容由页面填充 -->
        <slot name="sidebar" />
      </aside>
      <article class="main-content">
        <!-- 这里是主要内容插槽 -->
        <slot />
      </article>
    </main>
    <FooterOrganism />
  </div>
</template>

<script setup lang="ts">
import HeaderOrganism from './HeaderOrganism.vue'
import FooterOrganism from './FooterOrganism.vue'
</script>

Vue3 中的页面:Vue2 中的完整页面组件(有数据)

<!-- 页面:ArticlePage.vue -->
<template>
  <ArticlePageTemplate>
    <!-- 向模板的 sidebar 插槽填充真实内容 -->
    <template #sidebar>
      <AuthorCard :author="article.author" />
      <RelatedArticles :articles="article.related" />
    </template>

    <!-- 向默认插槽填充文章正文 -->
    <ArticleContent :article="article" />
  </ArticlePageTemplate>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ArticlePageTemplate from './ArticlePageTemplate.vue'
import AuthorCard from './AuthorCard.vue'
import RelatedArticles from './RelatedArticles.vue'
import ArticleContent from './ArticleContent.vue'

const article = ref({})
onMounted(async () => {
  article.value = await fetchArticleData()
})
</script>

Props 设计:定义组件的公开 API

Props 设计的黄金法则

法则一:尽可能少,尽可能明确

只接收必要的数据,不要接收和组件不相关的数据:

defineProps<{
  user: User
  isEditable?: boolean
}>()

法则二:提供合理的默认值

interface Props {
  placeholder?: string
  disabled?: boolean
  maxLength?: number
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: '请输入',
  disabled: false,
  maxLength: 100
})

法则三:使用 TypeScript 定义类型

interface User {
  id: number
  name: string
  avatar: string
  role: 'admin' | 'user' | 'guest'
}

defineProps<{
  user: User
  permissions: string[]
}>()

法则四:避免传递不必要的 props

<ChildComponent :user="user" />

Props 的 4 种类型及使用场景

1. 数据型 Props:单纯的数据展示

<UserCard 
  :user="user"
  :posts="userPosts"
/>

2. 配置型 Props:控制组件行为

<DataTable
  :show-header="true"
  :allow-sort="true"
  :page-size="20"
  :theme="'dark'"
/>

3. 回调型 Props:事件处理

<FormComponent
  @submit="handleSubmit"
  @cancel="handleCancel"
/>

4. 节点型 Props:自定义渲染

<ModalComponent>
  <template #header>
    <h2>自定义标题</h2>
  </template>
  <template #footer>
    <button>确认</button>
  </template>
</ModalComponent>

Props 命名的最佳实践

1. 使用完整单词

defineProps<{
  userName: string      // 不是 uname
  userAvatar: string    // 不是 uavatar(除非是标准术语)
}>()

2. 布尔值用 is/has/should 开头

defineProps<{
  isActive: boolean     // 状态
  hasPermission: boolean // 拥有
  shouldShow: boolean   // 应该
}>()

3. 回调函数用 on 开头

defineProps<{
  onSubmit: () => void
  onClose: () => void
}>()

4. 数组等用复数

defineProps<{
  users: User[]
}>()

事件通信:让组件之间优雅地对话

组件通信的 5 种方式及选择策略

1. Props + Events:父子组件直接通信(最常用)

<!-- 父组件 -->
<ChildComponent 
  :data="parentData"
  @update="handleUpdate"
/>

<!-- 子组件 -->
<script setup>
defineProps<{ data: string }>()
const emit = defineEmits<{
  update: [value: string]
}>()
</script>

2. v-model:双向绑定的场景(表单类)

<InputComponent v-model="searchText" />

3. Slots:父组件控制渲染内容(布局类)

<CardComponent>
  <template #header>标题</template>
  内容
  <template #footer>底部</template>
</CardComponent>

4. Provide/Inject:跨多层组件传递(主题、用户信息)

// 祖先组件
provide('theme', 'dark')
// 后代组件
const theme = inject('theme')

5. Pinia:全局状态(用户信息、购物车)

const userStore = useUserStore()

事件设计的 3 个原则

原则一:只通知,不下命令

子组件只需要告诉父组件发生了什么,至于事件发生后该做什么,要怎么做,由父组件决定,子组件不作任何处理:

const emit = defineEmits<{
  'item-selected': [item: Item]
  'form-submitted': [data: FormData]
}>()

原则二:事件粒度适中

一个操作对应一个事件,不要把所有操作放在一个事件中(太粗),也不要把不需要处理的操作放在事件中(太细):

// ✅ 好:一个操作一个事件
const emit = defineEmits<{
  'save-success': []
  'save-error': [error: Error]
}>()

// ❌ 差:太细或太粗
const emit = defineEmits<{
  'button-mousedown': []      // 太细,外部不需要知道
  'button-mouseup': []        // 太细
  'data-operation': [         // 太粗,不知道发生了什么
    type: 'create' | 'update' | 'delete',
    data: any
  ]
}>()

原则三:保持一致性

统一的命名风格,使用冒号 : 分隔命名空间:

const emit = defineEmits<{
  'user:created': [user: User]
  'user:updated': [user: User]
  'user:deleted': [userId: string]
}>()

插槽设计:让组件拥有无限可能

插槽的 3 种形式及适用场景

1. 默认插槽:简单的内容占位

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-content">
      <slot>
        <!-- 提供默认内容 -->
        <p>暂无内容</p>
      </slot>
    </div>
  </div>
</template>

<!-- 使用 -->
<Card>
  <p>这是卡片内容</p>
</Card>

2. 具名插槽:多个位置的定制

<!-- Modal.vue -->
<template>
  <div class="modal">
    <header>
      <slot name="header">默认标题</slot>
    </header>
    
    <main>
      <slot name="content">默认内容</slot>
    </main>
    
    <footer>
      <slot name="footer">
        <button @click="close">关闭</button>
      </slot>
    </footer>
  </div>
</template>

<!-- 使用 -->
<Modal>
  <template #header>
    <h2>自定义标题</h2>
  </template>
  
  <template #content>
    <p>自定义内容</p>
  </template>
  
  <template #footer>
    <button @click="confirm">确认</button>
    <button @click="cancel">取消</button>
  </template>
</Modal>

3. 作用域插槽:让父组件访问子组件数据

<!-- DataTable.vue -->
<template>
  <div class="data-table">
    <table>
      <tbody>
        <tr v-for="(item, index) in data" :key="index">
          <td v-for="col in columns" :key="col.key">
            <slot 
              :name="`column-${col.key}`"
              :value="item[col.key]"
              :row="item"
              :index="index"
            >
              {{ item[col.key] }}
            </slot>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<!-- 使用 -->
<DataTable :data="users" :columns="columns">
  <template #column-status="{ value, row }">
    <Badge :type="value === 'active' ? 'success' : 'default'">
      {{ value }}
    </Badge>
  </template>
</DataTable>

插槽设计的 3 个最佳实践

1. 提供合理的默认内容

<template>
  <div class="empty-state">
    <slot name="icon">
      <EmptyIcon />
    </slot>
    
    <slot name="message">
      <p>暂无数据</p>
    </slot>
    
    <slot name="action">
      <button @click="$emit('refresh')">刷新</button>
    </slot>
  </div>
</template>

2. 保持作用域数据的精简

<template>
  <!-- ✅ 好:只暴露必要的数据 -->
  <slot 
    :item="item"
    :index="index"
    :is-first="index === 0"
    :is-last="index === items.length - 1"
  />
  
  <!-- ❌ 差:暴露整个组件实例 -->
  <slot :this="this" :$el="$el" :$props="$props" />
</template>

3. 使用 TypeScript 定义插槽类型

<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}

defineSlots<{
  // 默认插槽不接受 props
  default(props: {}): any
  
  // 具名插槽
  header(props: {}): any
  
  // 作用域插槽
  'user-item'(props: { 
    user: User
    index: number
    isSelected: boolean
  }): any
  
  // 可选插槽
  footer?(props: {}): any
}>()
</script>

组件设计的 SOLID 原则(Vue 视角)

SOLID 原则 Vue 中的体现 实践建议
单一职责 一个组件只做一件事 组件代码不超过 300 行,功能单一明确
开闭原则 对扩展开放,对修改关闭 多用插槽,少改内部逻辑;通过 Props 配置行为
里氏替换 子组件可替换父组件 保持 Props 接口一致,遵循相同的契约
接口隔离 Props 尽可能少 避免传递整个对象,只传必要字段;用多个小 Props 替代一个大对象
依赖倒置 依赖抽象,不依赖实现 用事件通信,不直接调用父组件方法;用 provide/inject 解耦

组件设计的 10 个坏味道(Anti-Patterns)

  1. 上帝组件:超过 500 行的组件
  2. Props 泛滥:超过 10 个 props
  3. 多层级 Props 透传:props 穿过 3 层以上
  4. 组件内直接修改 props:违反了单向数据流
  5. 模板内复杂逻辑:模板中有三元运算符嵌套
  6. CSS 全局污染:没有使用 scoped 或 CSS Modules
  7. 依赖父组件结构:组件假设父组件一定有某个 DOM 结构
  8. 过度抽象:为了复用而拆分,反而更难用
  9. 隐式通信:通过修改 store 来通知兄弟组件
  10. 没有 TypeScript:组件 API 全靠文档记忆

组件设计的检查清单

设计前思考

  • 这个组件的职责是否单一?
  • 是否真的需要拆分成独立组件?
  • 这个组件会在哪些地方被使用?

设计时检查

  • Props 命名是否清晰易懂?
  • 是否提供了合理的默认值?
  • 是否使用了 TypeScript 定义类型?
  • 事件命名是否表达了发生了什么?
  • 插槽是否有合理的默认内容?
  • 样式是否 scoped?

设计后验证

  • 组件能否独立运行?(不依赖外部数据)
  • 修改组件内部,会影响外部吗?(低耦合验证)
  • 其他开发者能看懂这个组件吗?(可读性验证)
  • 能否为这个组件写单元测试?(可测试性验证)
  • 组件文档是否清晰?(可用性验证)

结语

好的组件设计不是一蹴而就的,而是在每一次重构中不断完善的过程。当我们开始思考"这个组件是否应该拆分"、"这个 Props 命名是否合理"的时候,我们就已经走在了正确的道路上了。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Vue调试神器:Vue DevTools使用指南

image

一、初识Vue Devtools

Vue DevTools 概述

  在现代前端开发中,Vue.js 应用的组件化架构虽然提升了代码复用性,但也带来了复杂的状态管理和组件交互问题。当应用包含数十个嵌套组件时,传统的 console.log 调试方式如同在黑暗中摸索。Vue.js Devtools 作为官方调试工具,通过可视化界面将组件结构、状态变化和性能数据直观呈现,让开发者能够像"透视"一样观察应用内部运行机制。

image

  Vue Devtools 是 Vue 官方发布的调试浏览器插件,可以安装在 Chrome、Firefox、Edge等浏览器上,可以帮助我们监控和管理 Vue 应用的状态、事件和性能。通过 Vue Devtools,我们可以查看组件的结构、属性和方法,以及父子组件之间的关系。此外,Vue Devtools 还提供了时间轴功能,让我们可以更好地了解应用的状态变化。

Vue DevTools 功能说明

  1. 组件树检视:能够清晰展示出应用中的组件层级结构,方便开发者理解和导航。
  2. 状态和数据查看:可以检查组件的状态,包括props、data、computed properties等。
  3. 调试事件:可以监听和触发事件,便于开发者查看事件的响应和效果。
  4. 时间旅行:这是 Vue DevTools 的高级功能之一,能够记录组件的快照,允许开发者在不同的快照之间切换,观察应用状态的变化。
  5. 控制台集成:Vue DevTools 提供了集成到浏览器控制台的能力,可以通过控制台直接与Vue实例交互。
  6. 组件信息展示:可以查看每个组件所对应的虚拟DOM结构和渲染细节。

二、环境适配:多场景下的安装与配置

浏览器扩展

  目前 Vue DevTools 主要支持 Chrome 浏览器和 Firefox 浏览器,并提供对应的浏览器扩展。对于其他平台(如Safari或Edge)的支持情况,可以通过各种主流浏览器的扩展商店进行安装。

插件:www.chajianxw.com/developer/1…

  打开 Chrome 浏览器,选择菜单“更多程序”→“扩展程序”,打开扩展程序界面,打开开发者模式,单击“加载已解压的扩展程序”按钮,将vue-devtools插件安装到Chrome 浏览器,安装结果如图:

image

  安装完成后,开发者需要在浏览器的扩展管理页面启用Vue DevTools。在使用Vue DevTools时,通常需要在Vue应用中直接运行,这时DevTools会自动识别并展示调试信息。若未看到,刷新页面或检查是否为 Vue 应用。

image

Vite Plugin

单体应用

对于Electron应用、移动端应用(NativeScript/Capacitor)或者服务端渲染应用,浏览器扩展可能无法直接使用。别担心,Vue Devtools还提供了NPM包版本

npm install -g @vue/devtools

Vue DevTools 默认仅适用于 Vue 的开发版本(非压缩版),在生产环境中默认禁用,否则就好比把家里的“透视眼镜”给小偷戴上,会暴露应用内部状态。

三、功能解析:掌握调试工具的核心能力

  在安装了 Vue Devtools 的浏览器中,打开你的 Vue 应用。然后右键点击页面,选择“Inspect”,在弹出的开发者工具中找到“Vue”选项卡,点击即可打开 Vue Devtools。

3.1 Components面板:组件世界的“上帝视角”

  在现代的前端开发中,组件化已经成为一种标准的实践方式。Vue.js 也不例外,它提供了一种灵活的方式来构建用户界面,通过组件树的层级结构来组织界面的不同部分。在 Vue 应用中,组件的父子关系是通过组件嵌套和属性传递来定义的。父组件通过在模板中声明子组件标签,并通过 props 将数据传递给子组件,从而建立起父子关系,Vue Devtools 提供了一个直观的方式来查看组件之间的这种层级结构。

  在 Vue DevTools 的“Components”标签页中,可以直观地看到整个应用的组件树结构,类似于文件系统的目录结构,从根组件(Root)开始,层层展开,让我们可以更好地了解组件的结构。每个组件都是一个节点,父组件之下包含子组件,形成清晰的层级关系。通过展开组件节点,可以查看其子组件,帮助开发者快速定位问题发生的组件区域。在组件树视图中,可以通过输入关键字来筛选组件,快速定位到关心的组件,这对于大型应用中组件众多的情况非常实用。

image

  在组件树中,选中某个组件后,右侧面板会显示该组件的属性、数据、计算属性和方法等信息。开发者可以实时查看组件状态的变化,无需在控制台中进行繁琐的打印操作。

image

  组件树中的每个组件节点不仅显示了组件的类型,还可以展开来查看其详细信息,包括组件的属性、数据、计算属性以及样式等。最刺激的是实时编辑功能——直接在Devtools中直接修改组件的 data 属性值,比如把一个按钮的 disabled 从 true 改为 false ,页面上的按钮立即变得可点击!无需刷新页面,无需重新编译,就像用手指直接拨动乐高积木一样神奇。这对于调试数据驱动的问题非常有帮助,能够快速验证数据的正确性和对组件的影响。

image

3.2 Events面板:事件流的“监听器”

  在 Vue Devtools 中,Events 面板用来监控Vue实例的所有事件。

  • 事件历史:按时间顺序显示所有触发的Vue事件(包括自定义事件)
  • 按组件筛选:只看某个特定组件触发的事件
  • 事件详情:点击事件可查看事件名称、目标组件、传递参数等信息
  • 复制数据:支持将事件数据复制到剪贴板

这对于调试复杂的组件通信(比如爷孙组件传值、兄弟组件通信)非常有用,帮助我们更好地了解事件的处理情况。

3.3 状态追踪:应用数据的"黑匣子记录仪"

  如果应用使用了Vuex(Vue 2)或Pinia(Vue 3官方推荐),Vue Devtools 会自动显示状态面板,这个面板就是你的“中央监控室”。左侧显示完整的 store 状态树,所有数据一目了然。可以展开每一个节点,查看当前所有共享状态的值。在这里,我们可以查看state、getters、mutations(Vuex)或actions(Pinia),以及它们的详细信息。通过时间线视图,开发者可以查看状态树是如何随时间变化的,帮助理解状态变化的流程。

3.4 最炫酷的“时间旅行”

  Vue Devtools 提供了一个时间轴功能,可以让我们更好地了解应用的状态变化。在时间轴中,我们可以查看每个组件的状态变化,以及它们之间的依赖关系。开发者可以回溯到过去的状态,进行状态差异的比较分析。这对于调试复杂的状态管理逻辑非常有用,能够快速定位状态变化导致的问题。

3.5 Router面板:路由导航的“导航仪”

  如果应用使用了Vue Router,Router 面板就是你的“导航仪”。在“Router”标签页中,可以查看当前路由的信息,包括路径、查询参数、路由参数等,如下图所示。

image

  同时,还能看到路由的历史记录,方便开发者了解应用的导航流程。通过观察路由的变化,开发者可以调试路由跳转、参数传递等问题。例如,当遇到路由跳转后页面不更新的问题时,可以通过查看路由变化记录,分析错误发生的原因。

3.6 Timeline面板:应用优化的"体检报告"

如何录制性能数据

  1. 切换到Timeline面板
  2. 点击左上角的“Start recording”(开始录制)按钮
  3. 在页面上执行你想要分析的操作(比如点击一个会加载大量数据的按钮)
  4. 点击“Stop recording”停止录制

数据解读:谁在“摸鱼”?

录制完成后,你会看到类似心电图的时间轴:

  • 组件渲染时间:每个组件从开始渲染到完成花了多久
  • 组件更新次数:某些组件是不是在“无效加班”(频繁无意义地重新渲染)
  • 生命周期钩子执行时间:比如mounted钩子里是不是放了太多代码导致阻塞

性能优化实战案例

通过Timeline面板,你可能会发现:

  • 某个表格组件渲染要500ms → 考虑使用虚拟滚动
  • 某个computed属性被频繁重新计算 → 考虑使用缓存或shallowRef
  • 某个组件在父组件更新时跟着乱更新 → 添加v-once或合理使用key

四、总结

  Vue Devtools是一款非常实用的工具,可以帮助我们更好地理解和管理Vue应用。使用 Vue DevTools 进行调试与性能优化,能够极大地方便开发者的工作。通过可视化 的组件树、实时数据修改、Vuex 状态跟踪及时间旅行功能,我们可以更加高效地定位问题,优化处理逻辑,提升应用性能。

image

基于腾讯地图实现电子围栏绘制与校验

需求背景:在安全巡检系统中,为巡检人员配置“电子围栏”,当人员在围栏内(或异常停留超时)触发告警。业务需要一个可配置、可编辑、可校验的围栏编辑器,支持多边形/矩形绘制、相交检测、搜索定位、缩略图生成上传和启停状态设置。

image.png

1. 组件背景与业务场景

  • 业务目标:为巡检系统配置“电子围栏”,限定巡检活动区域,配合异常停留时限与启停状态形成完整的策略。
  • 使用人群:业务管理员/调度人员;交互上要求“易绘制、可编辑、易清空、可搜索定位”。
  • 数据形态:围栏区域以坐标序列存储(多边形/矩形路径),序列化为 JSON 持久化到后端。
  • 辅助要素:提交前需校验围栏是否相交,生成围栏缩略图用于列表/详情展示。

界面入口为对话框模式(Dialog),包含基础表单与地图绘制区:

  • 围栏区域名称、异常停留时限(分钟)、启停状态;
  • 地图区域提供绘制/编辑/删除/一键删除、形状切换(多边形/矩形)、地点搜索。

image.png


2. 核心功能点与交互流程拆解

  • 模式切换:绘制模式(DRAW)/编辑模式(INTERACT)/删除单个/一键删除全部。
  • 工具切换:多边形与矩形两类覆盖物的快速切换。
  • 搜索定位:联想输入+节流调用,点击候选项在地图上定位并弹出信息窗。
  • 坐标收集:监听绘制与编辑完成事件,实时收集 polygon/rectangle 的路径点,序列化到表单字段 fenceArea。
  • 相交检测:提交前对所有区域两两进行相交判断,避免配置出重叠区域。
  • 缩略图生成:使用 Canvas 将围栏几何映射到可视缩略图,上传并记录返回的 URL。
  • 资源清理:组件卸载时销毁编辑器与地图实例,释放内存。

基本链路如下:

  1. 打开弹窗 → 根据类型(新建/编辑/查看)设置标题与编辑模式
  2. 初始化地图与编辑器 → 注入已有几何 → 绑定 draw/adjust 完成事件
  3. 绘制/编辑过程中更新 fenceArea → 搜索定位辅助操作
  4. 提交:停止编辑器 → 收集/校验坐标 → 生成并上传缩略图 → 调用创建/更新接口

3. 技术选型与实现要点

3.1 地图与几何编辑:TMap GeometryEditor

  • 地图基座:TMap.Map
  • 覆盖物:TMap.MultiPolygon(多边形) 与 TMap.MultiRectangle(矩形)
  • 编辑器:TMap.tools.GeometryEditor,支持 actionMode(激活模式)、activeOverlay(激活覆盖物)、snappable/selectable 等配置
  • 事件监听:draw_complete(绘制完成)、adjust_complete(编辑完成)

示例代码initMap:

const initMap = () => {
  map = new TMap.Map("map-container", {
    zoom: 16,
    center: new TMap.LatLng(latitude.value, longitude.value),
    showControl: false,
  });

  // 已有几何解析与注入(编辑/查看)
  const polygonGeometries: any[] = [];
  if ((formType.value === "update" || formType.value === "view") && formData.value.fenceArea) {
    const geometries = JSON.parse(formData.value.fenceArea);
    geometries.forEach((geo) => {
      polygonGeometries.push({
        id: `polygon_${polygonGeometries.length}`,
        paths: geo.paths.map((p) => new TMap.LatLng(p.lat, p.lng)),
      });
    });
  }

  // 多边形与矩形覆盖物
  polygon = new TMap.MultiPolygon({ map, geometries: polygonGeometries });
  rectangle = new TMap.MultiRectangle({ map, geometries: [] });

  // 编辑器绑定
  editor = new TMap.tools.GeometryEditor({
    map,
    overlayList: [
      { overlay: polygon, id: "polygon", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
      { overlay: rectangle, id: "rectangle", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
    ],
    actionMode: "", // 由外部模式切换驱动
    activeOverlayId: activeType.value,
    snappable: !isViewMode.value,
    selectable: !isViewMode.value,
  });

  // 绘制/编辑完成后更新数据
  editor.on("draw_complete", updateFenceArea);
  editor.on("adjust_complete", updateFenceArea);
};

模式切换实现(绘制/编辑/删除/一键删除):

const handleModeChange = (id: "draw"|"edit"|"delete"|"deletes") => {
  if (activeMode.value === id && id !== "delete" && id !== "deletes") return;

  switch (id) {
    case "draw":
      editor.stop();
      editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
      activeMode.value = id;
      break;
    case "edit":
      editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
      activeMode.value = id;
      break;
    case "delete":
      editor.delete();
      updateFenceArea();
      break;
    case "deletes":
      // 临时切换到编辑模式,批量选择并删除所有几何
      const wasInDrawMode = activeMode.value === "draw";
      if (wasInDrawMode) {
        activeMode.value = "edit";
        editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
      }
      editor.select([]);
      const polygonIds = polygon?.geometries?.map((g) => g.id) || [];
      const rectIds = rectangle?.geometries?.map((g) => g.id) || [];
      if (polygonIds.length) { editor.setActiveOverlay("polygon"); editor.select(polygonIds); editor.delete(); }
      if (rectIds.length) { editor.setActiveOverlay("rectangle"); editor.select(rectIds); editor.delete(); }
      updateFenceArea();
      if (wasInDrawMode) {
        activeMode.value = "draw";
        editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
      }
      break;
  }
};

工具切换(多边形/矩形)仅需切换 activeOverlayId:

const handleToolChange = (id: "polygon"|"rectangle") => {
  if (activeType.value === id) return;
  activeType.value = id;
  editor.setActiveOverlay(id);
};

3.2 坐标收集与相交检测

  • 目标:统一收集 polygon/rectangle 的路径坐标,序列化为字符串到 fenceArea

  • 相交检测:两两比较所有多边形路径,借助 TMap.geometry.computePolygonIntersection 判断是否相交,若相交阻断提交

const updateFenceArea = () => {
  const geometries: any[] = [];
  const allPolygons: any[] = [];

  if (polygon?.geometries?.length) {
    polygon.geometries.forEach((geo) => {
      geometries.push({ type: "polygon", paths: geo.paths });
      allPolygons.push(geo.paths);
    });
  }
  if (rectangle?.geometries?.length) {
    rectangle.geometries.forEach((geo) => {
      geometries.push({ type: "rectangle", paths: geo.paths });
      allPolygons.push(geo.paths);
    });
  }

  // 多边形两两相交检测
  if (allPolygons.length > 1) {
    let hasIntersection = false;
    for (let i = 0; i < allPolygons.length - 1; i++) {
      for (let j = i + 1; j < allPolygons.length; j++) {
        const inter = TMap.geometry.computePolygonIntersection(
          allPolygons[i].map((p) => new TMap.LatLng(p.lat, p.lng)),
          allPolygons[j].map((p) => new TMap.LatLng(p.lat, p.lng))
        );
        if (inter && inter.length > 0) { hasIntersection = true; break; }
      }
      if (hasIntersection) break;
    }
    if (hasIntersection) {
      message.error("围栏区域不能相交或重叠,请调整区域位置!");
      return false;
    }
  }

  formData.value.fenceArea = geometries.length ? JSON.stringify(geometries) : undefined;
  return true;
};

3.3 缩略图绘制与上传

  • 动机:列表/详情等界面快速预览围栏形状,减少进入地图的成本

  • 方法:将所有几何的经纬度投影到 canvas 坐标系;取坐标极值计算缩放与居中,绘制填充+描边

const drawFenceThumbnail = async () => {
  if (!formData.value.fenceArea) return;

  const canvas = document.createElement("canvas");
  canvas.width = 384; canvas.height = 216;
  const ctx = canvas.getContext("2d"); if (!ctx) return;

  // 背景图可替换为项目默认底图
  const bg = await new Promise<HTMLImageElement>((res, rej) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => res(img);
    img.onerror = rej;
    img.src = "https://via.placeholder.com/384x216.png?text=BG";
  });
  ctx.drawImage(bg, 0, 0, canvas.width, canvas.height);

  const geometries = JSON.parse(formData.value.fenceArea);
  let minLat=Infinity,maxLat=-Infinity,minLng=Infinity,maxLng=-Infinity;
  geometries.forEach((g) => g.paths.forEach((p:any) => {
    const lat = p.lat || p.latitude; const lng = p.lng || p.longitude;
    minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat);
    minLng = Math.min(minLng, lng); maxLng = Math.max(maxLng, lng);
  }));

  const padding = 10;
  const contentW = canvas.width - padding * 2;
  const contentH = canvas.height - padding * 2;
  const latRange = maxLat - minLat; const lngRange = maxLng - minLng;
  let scale = Math.min(contentW / lngRange, contentH / latRange) * 0.9; // 安全边距
  const centerLng = (minLng + maxLng) / 2; const centerLat = (minLat + maxLat) / 2;
  const cx = canvas.width / 2; const cy = canvas.height / 2;

  ctx.strokeStyle = "rgba(252,193,31,.70)";
  ctx.lineWidth = 2; ctx.fillStyle = "rgba(219,132,38,.40)";

  geometries.forEach((g:any) => {
    ctx.beginPath();
    g.paths.forEach((p:any, idx:number) => {
      const x = cx + ( (p.lng||p.longitude) - centerLng ) * scale;
      const y = cy - ( (p.lat||p.latitude) - centerLat ) * scale;
      idx === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    });
    ctx.closePath(); ctx.fill(); ctx.stroke();
  });

  const blob = await new Promise<Blob|null>((res) => canvas.toBlob(res, "image/png"));
  if (!blob) return;
  const file = new File([blob], `fence-thumbnail-${Date.now()}.png`, { type: "image/png" });
  const uploadResult = await httpRequest({ file: file as any, action: uploadUrl, method: "POST", filename: "file", data: {} });
  if (uploadResult?.data) formData.value.thumbnail = uploadResult.data;
};

(背景图为示例图片) image.png

3.4 搜索联想与定位

  • 关键点:节流调用、错误码处理(如频率限制)、定位后居中并显示信息窗
const getSuggestions = throttle(() => {
  if (!address.value) { suggestionList.value = []; return; }
  suggest.getSuggestions({ keyword: address.value, location: map.getCenter() })
    .then((result) => { suggestionList.value = result.data; })
    .catch((error) => {
      if (error.status == 120) message.error("搜索过于频繁,请稍后再试");
      else message.error("搜索失败," + error.message + ",请联系系统管理员");
    });
}, 500);

function setSuggestion(item) {
  suggestionList.value = [];
  infoWindowList.forEach((w) => w.close()); infoWindowList.length = 0;
  address.value = item.title;
  const w = new TMap.InfoWindow({ map, position: item.location, content: `<h3>${item.title}</h3><p>地址:${item.address}</p>` });
  infoWindowList.push(w);
  map.setCenter(item.location);
}

3.5 打开弹窗、提交与资源清理

  • 打开弹窗时设置标题与编辑模式:
const open = async (type: "create"|"update"|"view", id?: number) => {
  dialogVisible.value = true; formType.value = type; resetForm();
  if (id) { formLoading.value = true; try { formData.value = await PatrolEfenceApi.getPatrolEfence(id); } finally { formLoading.value = false; } }
  nextTick(() => {
    initMap();
    if (type === "update") { dialogTitle.value = "编辑围栏区域"; activeMode.value = "edit"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT); }
    else if (type === "create") { dialogTitle.value = "新建围栏区域"; activeMode.value = "draw"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW); }
    else { dialogTitle.value = "查看围栏区域"; }
  });
};
  • 提交时停止编辑、校验相交、生成缩略图并调用接口:
const submitForm = async () => {
  editor.stop();
  const isValid = updateFenceArea();
  if (!isValid) return;

  await formRef.value.validate();
  formLoading.value = true;
  await drawFenceThumbnail();

  try {
    const data = formData.value as unknown as PatrolEfenceVO;
    if (formType.value === "create") { await PatrolEfenceApi.createPatrolEfence(data); message.success(t("common.createSuccess")); }
    else { await PatrolEfenceApi.updatePatrolEfence(data); message.success(t("common.updateSuccess")); }
    dialogVisible.value = false; emit("success");
  } finally { formLoading.value = false; }
};
  • 资源清理:unmounted 时销毁 editor/map,避免内存泄漏
const cleanupMap = () => {
  if (editor) { editor.destroy(); editor = null; }
  if (map) { map.destroy(); map = null; }
};
onUnmounted(cleanupMap);

4. 踩坑记录与性能优化经验

  • 编辑器状态一致性

    • 删除“全部”前需临时切到编辑模式以支持批量选择,否则在绘制模式下 delete 不生效。
    • 删除后务必调用 updateFenceArea 刷新序列化数据,避免表单残留旧坐标。
  • 绘制结束与提交时机

    • 提交前调用 editor.stop(),确保几何最新状态已落在 overlay 上,避免“拖动中提交”的状态差异。
  • 缩略图映射边界

    • 经纬度与屏幕坐标是不同空间,先算极值与中心,再缩放至画布;额外乘以 0.9 “安全边距”系数,避免贴边截断。
    • y 轴方向需反转(屏幕坐标向下为正,纬度向上为正)。
  • 搜索联想与调用频率

    • 使用 lodash-es throttle(500ms)降低接口压力。
    • 明确错误码(如 120 过频),给出清晰提示;无结果时清空建议列表。
  • 只读模式开关

    • isViewMode 下将编辑器 snappable/selectable 关闭,减少误触,并减少内部命中测试消耗。
  • 资源释放

    • 组件卸载时销毁 editor/map,防止多次进入弹窗导致堆叠与内存泄漏。

5. 可复用的最佳实践总结

  • 绘制/编辑器模式解耦:用 activeMode/activeType 显式切换 actionMode 与 activeOverlay,状态一目了然。
  • 数据唯一真源:任何绘制/编辑完成后立刻同步到 formData.fenceArea,避免 UI 与数据不同步。
  • 提交防御:提交前停止编辑器 + 相交校验 + 表单校验,条条把关。
  • 缩略图抽象:将“坐标→画布”的映射封装为通用函数,缩略图生成可用于列表/详情/导出。
  • 异步节流与错误处理:联想搜索加节流、提示错误码;降低接口风险提升体验。
  • 组件内清理:onUnmounted 清理地图与编辑器资源,确保弹窗多次打开稳定。
  • 只读模式优化:查看模式下关闭可交互能力,既安全又省资源。

❌