阅读视图

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

耗时一周,我把可视化+零代码+AI融入到了CRM系统,使用体验超酷!

最近花了一周时间,配合AI,打磨了一款CRM客户管理系统——NO-CRM。

图片

客户关系管理(CRM)系统的核心价值在于以客户为中心,通过数字化手段打通 “获客 - 转化 - 留存 - 复购 - 推荐” 全链路,帮助企业降本增效、提升客户价值与市场竞争力。

做这款CRM系统之前,我研究了市面上比较流行的商业产品,结合了我之前设计的零代码理念,做了一款从客户管理,数据分析,到用户收集,再到工作流设计的一整套解决方案,大家可以直接部署使用。

图片

我们可以直接在CRM中在线设计各种收集表单:

图片

后台自带了表单收集和统计分析功能,同时还能设计工作流:

图片

并自定义工作流和审批条件:

图片

当然还有AI分析模块,我们可以通过AI帮我们分析线索数据:

图片

我已经把这个CRM系统镜像开源,大家可以直接安装或者部署到服务器直接使用。

接下来就和大家一起分享一下我做的这款全栈CRM 系统。

✨ 特性

  • 🎨 现代化 UI - 基于 TDesign Vue Next,提供精美的企业级界面
  • 📊 数据可视化 - ECharts 驱动的数据大屏和图表分析
  • 🔐 完善的权限系统 - RBAC 权限模型,支持角色、部门、用户细粒度权限控制
  • 🤖 AI 智能助手 - 集成 AI 功能,提供智能推荐和辅助决策
  • 🔄 工作流引擎 - 可视化流程设计器,支持复杂业务流程编排
  • 📝 表单设计器 - 拖拽式表单设计,支持多种字段类型和校验规则
  • 📱 移动端适配 - 完美支持各种设备,响应式设计
  • 💾 轻量化存储 - 基于 JSON 文件存储,无需复杂数据库配置
  • 🚀 开箱即用 - 简单配置即可快速部署上线
  • 🔧 高度可定制 - 模块化设计,易于扩展和二次开发

技术栈

image.png

前端技术

技术 版本 说明
Vue 3 3.5.13 渐进式 JavaScript 框架
TypeScript 5.7.3 JavaScript 的超集,提供类型安全
Vite 6.0.5 下一代前端构建工具
TDesign Vue Next 1.10.6 腾讯企业级组件库
Pinia 2.3.0 Vue 官方状态管理库
Vue Router 4.5.0 Vue 官方路由管理器
ECharts 6.0.0 强大的数据可视化库
Vue Flow 1.47.0 流程图编辑器
Axios 1.7.9 HTTP 客户端

后端技术

技术 版本 说明
NestJS 11.0.1 渐进式 Node.js 框架
TypeScript 5.7.3 类型安全的开发体验
Passport JWT 4.0.1 JWT 身份验证策略
Bcrypt 5.1.1 密码加密库
Multer 2.0.2 文件上传中间件
Class Validator 0.14.2 基于装饰器的参数验证

已实现功能

  • 用户认证
    • 用户注册与登录
    • JWT token 认证
    • 角色权限控制(管理员/销售)
  • 客户管理
    • 客户列表查看与搜索
    • 新建、编辑、删除客户
    • 客户详情查看
    • 标签管理
  • 线索管理
    • 线索状态流转(未跟进→跟进中→已合格→已成交/无效)
    • 意向等级管理
    • 线索筛选
  • 跟进记录
    • 多种跟进方式(电话、邮件、会议等)
    • 时间线展示
    • 下次跟进提醒
  • 任务管理
    • 待办事项管理
    • 优先级设置
    • 到期提醒
    • 任务状态切换
  • 文件上传
    • 支持图片、PDF、Word、Excel 文件上传
    • 客户附件管理
    • 文件在线预览和下载
  • 数据大屏
    • 实时统计数据展示
    • Echarts 图表可视化
    • 多维度数据分析
  • 其他功能
    • 分页支持(所有列表)
    • Mock 数据生成
    • 数据搜索和筛选

当然对于企业团队来说,组织部门管理也是必备的,NO-CRM也实现了动态创建组织部门的功能,并能基于组织部门设置单独的权限:

图片

当然还有很多高价值的功能,大家可以线上体验:

好啦,今天的分享就到这,如果你有好的建议,欢迎留言区交流反馈~

Vue响应式原理(13)-ref实现原理解析

Vue 3 中 Ref 实现原理解析

在 Vue 3 中,ref 是组合式 API(Composition API)的核心。很多开发者虽然会用,但对其内部运作机制、refreactive 的关系、以及为什么在scrit中我们访问 ref 数据需要用 .value, 但是在模板里不需要 .value 往往一知半解。

本文将剥离复杂的边界情况,用最精简的代码还原 Vue 3 源码的核心逻辑,带你彻底搞懂这三个问题:

  1. ref 是如何实现的?
  2. toRefs 是如何解决解构丢失响应性问题的?
  3. 为什么在模板中不需要 .value

1. Ref 的原理解析

为什么需要 Ref?

在先前的部分中,我们对响应式数据的原理进行了介绍,我们通过 reactive 函数处理一个对象来使其转变为响应式数据,而对于 JavaScript 中的原始类型(String, Number, Boolean, ...)是值传递的。如果你把一个数字传给一个函数,函数无法追踪这个数字的变化。为了让原始值变成“响应式”,我们需要把它包裹在一个对象中(Wrapper Pattern),利用对象的 gettersetter 来拦截访问和修改。

核心实现:RefImpl

Vue 3 内部通过 RefImpl 类来实现 ref

// 伪代码:简化版的 RefImpl
class RefImpl {
  private _value: any;
  private _rawValue: any;
  public dep: Dep; // 依赖容器
  public __v_isRef = true; // 标记这是一个 ref 对象

  constructor(value) {
    this._rawValue = value;
    // 如果传入的是对象,则通过 reactive 转换,否则保持原值
    this._value = isObject(value) ? reactive(value) : value;
    this.dep = new Set(); // 假设这是依赖收集容器
  }

  get value() {
    // 1. 依赖收集 (Track)
    trackEffects(this.dep); 
    return this._value;
  }

  set value(newVal) {
    // 只有值发生改变时才触发
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal;
      // 如果新值是对象,同样需要转换
      this._value = isObject(newVal) ? reactive(newVal) : newVal;
      // 2. 派发更新 (Trigger)
      triggerEffects(this.dep); 
    }
  }
}

// 暴露出来的 ref 函数
function ref(value) {
  return new RefImpl(value);
}

关键点解析

  1. ref 本质上会返回一个类的实例对象,这个对象拥有 .value 的访问器属性。
  2. __v_isRef:RefImpl 类需要增加一个 __v_isRef 属性用于区别 “Ref对象”与“含有 value 属性的普通对象”。ref 的本质是一个拥有 .value 属性的对象,但并不是所有拥有 .value 的对象都是 ref。如果不增加这个标识位,很难区分下面二者的区别:
// 真正的 ref
const realRef = ref(1); 
// realRef 结构: { value: 1, dep: Set, __v_isRef: true, ... }

// 用户不小心定义的普通对象
const fakeRef = { value: 1 };
// fakeRef 结构: { value: 1 }

Vue 的模板系统或者 reactive 尝试“自动解包”(读取 .value)时,如果没有 __v_isRef,系统可能会错误地把用户定义的 fakeRef 也当作响应式对象处理,去尝试读取它的依赖(dep),这会导致报错或逻辑混乱。

  1. Getter/Setter
    • get value():当访问 .value 时,调用 track 收集当前副作用函数(Effect)。
    • set value():当修改 .value 时,比较新旧值,若变化则调用 trigger 通知视图更新。
  2. 兼容对象参数:如果 ref(obj) 接收的是一个对象,源码中会调用 reactive(obj) 将其转化为深层响应式对象。这就是为什么 ref 可以包裹对象,且对象内部属性变化也能触发更新。

2. toRefs 的原理解析

为什么需要 toRefs?

当我们对一个 reactive 对象进行解构时,会丢失响应性,因为解构出来的只是普通的变量。

const state = reactive({ count: 1 });
const { count } = state; // count 此时只是一个普通数字 1,与 state 断开联系了

toRefs 的作用就是把 reactive 对象的每一个属性都转换成一个 ref,但这个 ref 比较特殊,它链接到了源对象。

核心实现:ObjectRefImpl

toRefs 内部并不是创建标准的 RefImpl,而是创建了 ObjectRefImpl。它不存储值,只是作为源对象属性的“代理”。

class ObjectRefImpl {
  public __v_isRef = true; // 标记为 ref

  constructor(
    private readonly _object, // 源 reactive 对象
    private readonly _key     // 指定的 key
  ) {}

  get value() {
    // 访问时,直接读取源对象的属性
    // 因为 _object 是响应式的,所以这里会自动触发源对象的依赖收集
    return this._object[this._key];
  }

  set value(newVal) {
    // 修改时,直接修改源对象的属性
    // 这里会自动触发源对象的更新派发
    this._object[this._key] = newVal;
  }
}

// toRef 函数:针对单个属性
function toRef(object, key) {
  return new ObjectRefImpl(object, key);
}

// toRefs 函数:遍历对象所有属性
function toRefs(object) {
  const ret = Array.isArray(object) ? new Array(object.length) : {};
  
  for (const key in object) {
    // 为每个属性创建一个 ObjectRefImpl
    ret[key] = toRef(object, key);
  }
  
  return ret;
}

关键点解析

  1. ObjectRefImpl 自身没有任何 tracktrigger 的逻辑。它只是把操作转发给了源 reactive 对象。当我们获取到 toRef 函数返回的对象时,我们对其 .value 属性的读写实际上会转发到对 this._object[this._key] 的读写,自然就会触发其 tracktrigger 的逻辑。
  2. toRefs 返回的是一个普通对象,里面的值全是 ref。这个普通对象可以被解构,解构出来的变量依然是 ObjectRefImpl 实例,依然保持着对源对象的引用。

3. 模板自动解包 (Unwrapping) 原理解析

现象

setup 中我们需要用 count.value,但在 <template> 中我们直接写 {{ count }} 即可。这是 Vue 在编译和渲染阶段做了特殊处理。

核心实现:proxyRefs

首先需要介绍两个辅助函数:

// 如果是 ref 返回 .value,否则返回原值
function unref(ref) {
  return isRef(ref) ? ref.value : ref;
}
// 根据对象 __v_isRef 属性判断其是否是 ref 对象
export function isRef(r: any): r is Ref {
  return !!(r && r.__v_isRef === true);
}

unref 函数首先判断传入的是否是 ref 对象,如果是则返回 ref.value, 否则返回 ref 本身,这个函数正是模板自动解包原理的核心。

Vue 在完成对模板的解析之后,将 setup 的返回值传递给渲染函数之前,会通过 proxyRefs 函数对其进行一层代理,在代理中拦截了 get 和 set 操作,并通过 unref 函数

const shallowUnwrapHandlers = {
  get: (target, key, receiver) => {
    // 1. 获取真实的值
    const value = Reflect.get(target, key, receiver);
    // 2. 自动解包:如果是 ref 就返回 value.value,否则直接返回
    return unref(value);
  },
  
  set: (target, key, value, receiver) => {
    const oldValue = target[key];
    // 3. 特殊处理:如果旧值是 ref,但新值不是 ref
    // 意味着用户想给 ref 赋值:count.value = 1
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value;
      return true;
    } 
    // 其他情况直接替换
    return Reflect.set(target, key, value, receiver);
  }
};

// Vue 内部会在 setupState 上套这一层 Proxy
function proxyRefs(objectWithRefs) {
  return new Proxy(objectWithRefs, shallowUnwrapHandlers);
}

运行流程

  1. 建立代理:当 setup() 函数返回一个对象(包含 ref)时,Vue 内部调用 handleSetupResult,使用 proxyRefs 包装这个返回对象,生成 render context(渲染上下文)。
  2. 模板读取
    • 当模板渲染遇到 {{ count }} 时,实际上是去在这个 Proxy 对象上取 count
    • 触发 get 拦截:发现 count 是一个 ref,Proxy 自动帮你调用 .value 并返回结果。
  3. 模板赋值(例如 v-model):
    • 如果在模板中写 <input v-model="count">
    • 触发 set 拦截:Proxy 发现 count 原本是 ref,而输入的是普通值,它会将新值赋值给 count.value

总结

特性 核心实现类/函数 关键原理
ref RefImpl 利用 getter/setter 劫持 .value 属性,通过 track/trigger 管理依赖。若值为对象则借助 reactive
toRefs ObjectRefImpl 不存值,仅仅是对源 reactive 对象属性的代理访问。解决了解构导致的响应性丢失问题。
模板解包 proxyRefs 利用 Proxy 拦截 setup 返回对象的访问,遇到 ref 自动返回 .value,实现由模板到数据的无感读写。

通过阅读这部分源码,我们可以看到 Vue 3 在易用性(自动解包)和灵活性(ref/reactive 分离)之间做了非常精妙的设计。

vue2、vue3父子组件嵌套生命周期执行顺序

vue2版本

生命周期:

beforeCreate created beforeMount mounted beforeUpdate updated beforeDestroy destroyed activated deactivated

1. 组件挂载阶段(Mounting)

执行顺序:


// 创建和挂载过程

父组件 beforeCreate

父组件 created

父组件 beforeMount

  子组件 beforeCreate

  子组件 created

  子组件 beforeMount

  子组件 mounted

父组件 mounted

代码示例:

<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <ChildComponent />
  </div>
</template>

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

export default {
  name: 'Parent',
  components: { ChildComponent },
  beforeCreate() {
    console.log('1. 父组件 beforeCreate')
  },
  created() {
    console.log('2. 父组件 created')
  },
  beforeMount() {
    console.log('3. 父组件 beforeMount')
  },
  mounted() {
    console.log('6. 父组件 mounted')
  }
}
</script>

<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
  </div>
</template>

<script>
export default {
  name: 'Child',
  beforeCreate() {
    console.log('4. 子组件 beforeCreate')
  },
  created() {
    console.log('4.1 子组件 created')
  },
  beforeMount() {
    console.log('4.2 子组件 beforeMount')
  },
  mounted() {
    console.log('5. 子组件 mounted')
  }
}
</script>

2. 组件更新阶段(Updating)

总结:

vue2中,只要子组件使用了,父组件传入的值,当该值更新时,子组件的更新生命周期就会执行,其他情况,子组件的更新生命周期都不会执行

更新父组件--子组件使用了,父组件传入的参数

执行顺序:
// 更新过程(数据变化时)

父组件 beforeUpdate

  子组件 beforeUpdate

  子组件 updated

父组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent :childData="parentData" />
  </div>
</template>

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

export default {
  components: { ChildComponent },
  data() {
    return {
      parentData: '初始值'
    }
  },
  methods: {
    changeData() {
      this.parentData = '新值'
    }
  },
  beforeUpdate() {
    console.log('1. 父组件 beforeUpdate')
  },
  updated() {
    console.log('4. 父组件 updated')
  }
}
</script>

<!-- Child.vue -->
<template>
  <div>
    <h3>子组件 - {{ childData }}</h3>
  </div>
</template>

<script>
export default {
  props: ['childData'],
  beforeUpdate() {
    console.log('2. 子组件 beforeUpdate')
  },
  updated() {
    console.log('3. 子组件 updated')
  }
}
</script>

更新父组件--子组件没有使用,父组件传入的参数(即使有传入值给子组件)

执行顺序:
// 更新过程(数据变化时)

父组件 beforeUpdate

父组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent :childData="parentData" />
    <!-- <ChildComponent :childData="parentData" /> -->
  </div>
</template>
<script>
import ChildComponent from '@/components/two/Child.vue'
export default {
  components: { ChildComponent },
  data() {
    return {
      parentData: '初始值'
    }
  },
  methods: {
    changeData() {
      this.parentData = '新值'
    }
  },
  beforeUpdate() {
    console.log('1. 父组件 beforeUpdate')
  },
  updated() {
    console.log('4. 父组件 updated')
  }
}
</script>



<!-- Child.vue -->
<template>
  <div>
    <!-- <h3>子组件 - {{ childData }}</h3> -->
    <h3>子组件</h3>
  </div>
</template>
<script>
export default {
  props: ['childData'],
  beforeUpdate() {
    console.log('2. 子组件 beforeUpdate')
  },
  updated() {
    console.log('3. 子组件 updated')
  }
}
</script>


更新子组件

执行顺序:
// 更新过程(数据变化时)

子组件 beforeUpdate

子组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent/>
  </div>
</template>
<script>
import ChildComponent from '@/components/two/Child.vue'
export default {
  components: { ChildComponent },
  data() {
    return {
      parentData: '初始值'
    }
  },
  methods: {
    changeData() {
      this.parentData = '新值'
    }
  },
  beforeUpdate() {
    console.log('1. 父组件 beforeUpdate')
  },
  updated() {
    console.log('4. 父组件 updated')
  }
}
</script>


<!-- Child.vue -->
<template>
  <div style="border: 1px solid #ccc; padding: 10px; margin: 10px;">
    <h2>Child Component - {{ childMessage }}</h2>
    <button @click="changeChildData">改变子组件数据</button>
    <p>计数: {{ count }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      childMessage: '子组件初始数据',
      count: 0
    }
  },
  methods: {
    changeChildData() {
      this.childMessage = '子组件数据已更新'
      this.count++
      console.log('=== 子组件数据变化触发 ===')
    }
  },
  beforeUpdate() { console.log('Child beforeUpdate') },
  updated() { console.log('Child updated') },

}
</script>



3. 组件销毁阶段(Destroying)

销毁父组件

执行顺序:
// 销毁过程

父组件 beforeDestroy

  子组件 beforeDestroy

  子组件 destroyed

父组件 destroyed
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <ChildComponent v-if="showChild" />
    <button @click="showChild = false">销毁子组件</button>
  </div>
</template>
<script>
import ChildComponent from '@/components/two/Child.vue'
export default {
  components: { ChildComponent },
  data() {
    return {
      showChild: true
    }
  },
  beforeDestroy() {
    console.log('父组件 beforeDestroy')
  },
  destroyed() {
    console.log('父组件 destroyed')
  }
}
</script>

销毁子组件

执行顺序:
// 销毁过程

 子组件 beforeDestroy

 子组件 destroyed

代码示例:见销毁父组件的代码

vue3版本

生命周期:

setup()替代 beforeCreate setup()替代 created onBeforeMount onMounted onBeforeUpdate onUpdated onBeforeUnmount onUnmounted onActivated onDeactivated onRenderTracked(新增) onRenderTriggered(新增)

1. 组件挂载阶段(Mounting)

执行顺序:

1. 父组件 setup (相当于 beforeCreate + created)
2. 父组件 onBeforeMount
    3. 子组件 setup
    3.1 子组件 onBeforeMount
    4. 子组件 onMounted
5. 父组件 onMounted

代码示例:

<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <ChildComponent />
  </div>
</template>
<script setup>
import { onBeforeMount, onMounted } from 'vue'
import ChildComponent from '../Son/s1.vue'
console.log('1. 父组件 setup (相当于 beforeCreate + created)')
onBeforeMount(() => {
  console.log('2. 父组件 onBeforeMount')
})
onMounted(() => {
  console.log('5. 父组件 onMounted')
})
</script>


<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
  </div>
</template>
<script setup>
import { onBeforeMount, onMounted } from 'vue'
console.log('3. 子组件 setup')
onBeforeMount(() => {
  console.log('3.1 子组件 onBeforeMount')
})
onMounted(() => {
  console.log('4. 子组件 onMounted')
})
</script>

2. 组件更新阶段(Updating)

总结:

vue3中,只要父组件往子组件传入了值,当该值更新时,子组件的更新生命周期就会执行,其他情况,子组件的更新生命周期都不会执行

更新父组件--子组件使用了,父组件传入的参数

执行顺序:
// 更新过程(数据变化时)

父组件 beforeUpdate

  子组件 beforeUpdate

  子组件 updated

父组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent :childData="parentData" />
  </div>
</template>
<script setup>
import { ref, onBeforeUpdate, onUpdated } from 'vue'
import ChildComponent from '../Son/s1.vue'
const parentData = ref('初始值')
const changeData = () => {
  parentData.value = '新值'
}
onBeforeUpdate(() => {
  console.log('1. 父组件 onBeforeUpdate')
})
onUpdated(() => {
  console.log('4. 父组件 onUpdated')
})
</script>


<!-- Child.vue -->
<template>
  <div>
    <h3>子组件 - {{ props.childData }}</h3>
  </div>
</template>
<script setup>
import { onBeforeUpdate, onUpdated, defineProps } from 'vue'

const props = defineProps(['childData'])

onBeforeUpdate(() => {
  console.log('2. 子组件 onBeforeUpdate')
})

onUpdated(() => {
  console.log('3. 子组件 onUpdated')
})

</script>

更新父组件--父组件传值给子组件,即使子组件没有使用 和 定义对应的属性

如果父组件没有传值给子组件,即使子组件定义 和 使用了值,当父组件更新时,子组件也不会更新,即:生命周期是:

父组件 beforeUpdate -> 父组件 updated

执行顺序:
// 更新过程(数据变化时)

父组件 beforeUpdate

  子组件 beforeUpdate

  子组件 updated

父组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent :childData="parentData" />
    <!-- <ChildComponent  /> -->
  </div>
</template>
<script setup>
import { ref, onBeforeUpdate, onUpdated } from 'vue'
import ChildComponent from '../Son/s1.vue'
const parentData = ref('初始值')
const changeData = () => {
  parentData.value = '新值'
}
onBeforeUpdate(() => {
  console.log('1. 父组件 onBeforeUpdate')
})
onUpdated(() => {
  console.log('4. 父组件 onUpdated')
})
</script>


<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
    <!-- <h3>子组件 - {{ props.childData }}</h3> -->
  </div>
</template>
<script setup>
import { onBeforeUpdate, onUpdated, defineProps } from 'vue'

// const props = defineProps(['childData'])

onBeforeUpdate(() => {
  console.log('2. 子组件 onBeforeUpdate')
})

onUpdated(() => {
  console.log('3. 子组件 onUpdated')
})
</script>

更新子组件

执行顺序:
// 更新过程(数据变化时)

子组件 beforeUpdate

子组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <!-- <h2>父组件 - {{ parentData }}</h2> -->
    <!-- <button @click="changeData">改变数据</button> -->
    <ChildComponent :childData="parentData" />
    <!-- <ChildComponent  /> -->
  </div>
</template>
<script setup>
import { ref, onBeforeUpdate, onUpdated } from 'vue'
import ChildComponent from '../Son/s1.vue'
const parentData = ref('初始值')
const changeData = () => {
  parentData.value = '新值'
}
onBeforeUpdate(() => {
  console.log('1. 父组件 onBeforeUpdate')
})
onUpdated(() => {
  console.log('4. 父组件 onUpdated')
})
</script>


<!-- Child.vue -->
<template>
  <div>
    <!-- <h3>子组件</h3> -->
    <h2>子组件 - {{ sonData }}</h2>
    <button @click="changeData">改变数据</button>
  </div>
</template>
<script setup>
import { onBeforeUpdate, onUpdated,ref, defineProps } from 'vue'

// const props = defineProps(['childData'])

const sonData = ref('初始值')
const changeData = () => {
  sonData.value = '新值'
}

onBeforeUpdate(() => {
  console.log('2. 子组件 onBeforeUpdate')
})

onUpdated(() => {
  console.log('3. 子组件 onUpdated')
})
</script>

3. 组件销毁阶段(Destroying)

销毁父组件

执行顺序:
// 销毁过程

父组件 beforeDestroy

  子组件 beforeDestroy

  子组件 destroyed

父组件 destroyed
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <ChildComponent v-if="showChild" />
    <button @click="showChild = false">卸载子组件</button>
  </div>
</template>
<script setup>
import { ref, onBeforeUnmount, onUnmounted } from 'vue'
import ChildComponent from '../Son/s1.vue'
const showChild = ref(true)
onBeforeUnmount(() => {
  console.log('父组件 onBeforeUnmount')
})
onUnmounted(() => {
  console.log('父组件 onUnmounted')
})
</script>



<!-- Child.vue -->
<template>
  <div>
    <!-- <h3>子组件</h3> -->
    <h2>子组件</h2>
  </div>
</template>
<script setup>
import { onBeforeUnmount, onUnmounted,ref, defineProps } from 'vue'

onBeforeUnmount(() => {
  console.log('子组件 onBeforeUnmount')
})
onUnmounted(() => {
  console.log('子父组件 onUnmounted')
})
</script>

销毁子组件

执行顺序:
// 销毁过程

 子组件 beforeDestroy

 子组件 destroyed

代码示例:见销毁父组件的代码

Vue 3 定时器清理的最佳实践

Vue 3 定时器清理的最佳实践

在 Vue 3 中,清理定时器的最佳位置取决于组件的使用场景和定时器的用途。我将设计一个直观的示例来演示不同生命周期钩子中定时器的清理方式。设计的定时器截图如下

image.png

设计思路

  • 展示不同生命周期钩子中定时器的创建和清理
  • 提供可视化界面展示定时器状态
  • 允许用户手动创建和清理定时器
  • 演示组件卸载时的自动清理

功能说明

这个示例演示了在 Vue 3 中管理定时器的最佳实践:

  1. 定时器创建和清理

    • onMounted 钩子中创建定时器
    • onUnmounted 钩子中清理定时器(必须)
    • 也可在 onBeforeUnmount 中清理
  2. 生命周期演示

    • 通过切换子组件显示/隐藏来演示组件卸载时的定时器清理
    • 在控制台输出生命周期事件
  3. 手动管理

    • 提供手动创建和清理定时器的功能
    • 显示所有活动定时器的状态和进度
  4. 最佳实践代码示例

    • 展示在 Vue 3 组件中正确管理定时器的代码模式

这个示例强调了在 Vue 3 中,无论定时器是在哪个生命周期创建的,都必须在 onUnmountedonBeforeUnmount 中清理,以防止内存泄漏。

最终实现代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vue 3 定时器生命周期管理</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        }
        
        body {
            background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
            color: #fff;
            min-height: 100vh;
            padding: 20px;
        }
        
        .container {
            max-width: 1200px;
            margin: 0 auto;
            background: rgba(0, 0, 0, 0.7);
            border-radius: 15px;
            padding: 30px;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
        }
        
        header {
            text-align: center;
            margin-bottom: 30px;
        }
        
        h1 {
            font-size: 2.5rem;
            margin-bottom: 10px;
            text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
        }
        
        .subtitle {
            font-size: 1.2rem;
            opacity: 0.8;
            margin-bottom: 20px;
        }
        
        .content {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 30px;
        }
        
        @media (max-width: 768px) {
            .content {
                grid-template-columns: 1fr;
            }
        }
        
        .card {
            background: rgba(255, 255, 255, 0.1);
            border-radius: 10px;
            padding: 20px;
            margin-bottom: 20px;
        }
        
        .card h2 {
            margin-bottom: 15px;
            color: #fdbb2d;
            border-bottom: 1px solid rgba(255, 255, 255, 0.2);
            padding-bottom: 10px;
        }
        
        .timer-controls {
            display: flex;
            flex-wrap: wrap;
            gap: 15px;
            margin-bottom: 20px;
        }
        
        .control-group {
            flex: 1;
            min-width: 200px;
        }
        
        label {
            display: block;
            margin-bottom: 8px;
            font-weight: 600;
        }
        
        input, select, button {
            width: 100%;
            padding: 12px;
            border: none;
            border-radius: 5px;
            font-size: 1rem;
        }
        
        input, select {
            background: rgba(255, 255, 255, 0.9);
        }
        
        button {
            background: #4CAF50;
            color: white;
            cursor: pointer;
            transition: all 0.3s;
            font-weight: bold;
            margin-top: 10px;
        }
        
        button:hover {
            background: #45a049;
            transform: translateY(-2px);
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
        }
        
        .danger-btn {
            background: #f44336;
        }
        
        .danger-btn:hover {
            background: #d32f2f;
        }
        
        .warning-btn {
            background: #ff9800;
        }
        
        .warning-btn:hover {
            background: #f57c00;
        }
        
        .timer-list {
            margin-top: 20px;
        }
        
        .timer-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            background: rgba(255, 255, 255, 0.1);
            padding: 15px;
            margin-bottom: 10px;
            border-radius: 8px;
            transition: all 0.3s;
        }
        
        .timer-item:hover {
            background: rgba(255, 255, 255, 0.15);
            transform: translateX(5px);
        }
        
        .timer-info {
            flex: 1;
        }
        
        .timer-id {
            font-weight: bold;
            font-size: 1.1rem;
        }
        
        .timer-details {
            display: flex;
            gap: 15px;
            margin-top: 5px;
            font-size: 0.9rem;
            opacity: 0.8;
        }
        
        .timer-actions {
            display: flex;
            gap: 10px;
        }
        
        .timer-actions button {
            margin: 0;
            padding: 8px 15px;
            width: auto;
        }
        
        .status {
            padding: 5px 10px;
            border-radius: 20px;
            font-size: 0.8rem;
            font-weight: bold;
        }
        
        .status-active {
            background: #4CAF50;
        }
        
        .status-cleared {
            background: #f44336;
        }
        
        .lifecycle-info {
            margin-top: 30px;
            padding: 20px;
            background: rgba(0, 0, 0, 0.3);
            border-radius: 10px;
        }
        
        .lifecycle-info h3 {
            margin-bottom: 15px;
            color: #fdbb2d;
        }
        
        .lifecycle-info ul {
            padding-left: 20px;
            margin-bottom: 15px;
        }
        
        .lifecycle-info li {
            margin-bottom: 8px;
            line-height: 1.5;
        }
        
        .highlight {
            color: #fdbb2d;
            font-weight: bold;
        }
        
        .component-demo {
            margin-top: 30px;
            padding: 20px;
            border: 2px dashed rgba(255, 255, 255, 0.3);
            border-radius: 10px;
        }
        
        .toggle-btn {
            background: #2196F3;
            width: 100%;
        }
        
        .toggle-btn:hover {
            background: #0b7dda;
        }
        
        .stats {
            display: flex;
            justify-content: space-between;
            margin-top: 30px;
            padding-top: 20px;
            border-top: 1px solid rgba(255, 255, 255, 0.2);
        }
        
        .stat-box {
            text-align: center;
            flex: 1;
        }
        
        .stat-value {
            font-size: 2rem;
            font-weight: bold;
            margin-bottom: 5px;
        }
        
        .stat-label {
            font-size: 0.9rem;
            opacity: 0.8;
        }
        
        .empty-state {
            text-align: center;
            padding: 40px;
            opacity: 0.7;
        }
        
        .pulse {
            animation: pulse 2s infinite;
        }
        
        @keyframes pulse {
            0% { transform: scale(1); }
            50% { transform: scale(1.05); }
            100% { transform: scale(1); }
        }
        
        .progress-bar {
            height: 5px;
            background: rgba(255, 255, 255, 0.2);
            border-radius: 5px;
            margin-top: 10px;
            overflow: hidden;
        }
        
        .progress {
            height: 100%;
            background: #4CAF50;
            width: 0%;
            transition: width 0.5s;
        }
        
        .code-block {
            background: rgba(0, 0, 0, 0.5);
            padding: 15px;
            border-radius: 5px;
            font-family: monospace;
            margin: 15px 0;
            overflow-x: auto;
        }
    </style>
</head>
<body>
    <div id="app">
        <div class="container">
            <header>
                <h1>Vue 3 定时器生命周期管理</h1>
                <p class="subtitle">演示在不同生命周期钩子中创建和清理定时器的最佳实践</p>
            </header>
            
            <div class="content">
                <div>
                    <div class="card">
                        <h2>定时器控制面板</h2>
                        <div class="timer-controls">
                            <div class="control-group">
                                <label for="timerType">定时器类型</label>
                                <select id="timerType" v-model="timerType">
                                    <option value="timeout">setTimeout (一次性)</option>
                                    <option value="interval">setInterval (重复)</option>
                                </select>
                            </div>
                            
                            <div class="control-group">
                                <label for="timerDuration">持续时间 (毫秒)</label>
                                <input type="number" id="timerDuration" v-model.number="timerDuration" min="100" max="100000">
                            </div>
                            
                            <div class="control-group">
                                <label for="timerMessage">定时器消息</label>
                                <input type="text" id="timerMessage" v-model="timerMessage" placeholder="输入定时器执行时显示的消息">
                            </div>
                        </div>
                        
                        <button @click="addTimer" class="pulse">添加定时器</button>
                        <button @click="clearAllTimers" class="danger-btn">清理所有定时器</button>
                    </div>
                    
                    <div class="card">
                        <h2>活动定时器 ({{ activeTimersCount }})</h2>
                        <div class="timer-list">
                            <div v-if="activeTimers.length === 0" class="empty-state">
                                暂无活动定时器
                            </div>
                            <div v-else v-for="timer in activeTimers" :key="timer.id" class="timer-item">
                                <div class="timer-info">
                                    <div class="timer-id">定时器 #{{ timer.id }}</div>
                                    <div class="timer-details">
                                        <span>类型: {{ timer.type === 'timeout' ? 'setTimeout' : 'setInterval' }}</span>
                                        <span>持续时间: {{ timer.duration }}ms</span>
                                        <span>消息: "{{ timer.message }}"</span>
                                    </div>
                                    <div class="progress-bar">
                                        <div class="progress" :style="{ width: timer.progress + '%' }"></div>
                                    </div>
                                </div>
                                <div class="timer-actions">
                                    <span class="status status-active">活动</span>
                                    <button class="danger-btn" @click="clearTimer(timer.id)">清理</button>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
                
                <div>
                    <div class="card">
                        <h2>生命周期演示</h2>
                        <div class="lifecycle-info">
                            <h3>Vue 3 定时器清理最佳实践</h3>
                            <ul>
                                <li><span class="highlight">onMounted</span> - 在组件挂载后创建定时器</li>
                                <li><span class="highlight">onUnmounted</span> - 在组件卸载前清理定时器(必须)</li>
                                <li><span class="highlight">onBeforeUnmount</span> - 在组件卸载前清理定时器的替代方案</li>
                                <li><span class="highlight">watchEffect</span> - 响应式地创建和清理定时器</li>
                                <li><span class="highlight">手动清理</span> - 在需要时手动清理特定定时器</li>
                            </ul>
                            
                            <div class="code-block">
// 最佳实践示例<br>
import { onMounted, onUnmounted, ref } from 'vue'<br><br>

const timerId = ref(null)<br><br>

onMounted(() => {<br>
&nbsp;&nbsp;// 创建定时器<br>
&nbsp;&nbsp;timerId.value = setInterval(() => {<br>
&nbsp;&nbsp;&nbsp;&nbsp;// 定时器逻辑<br>
&nbsp;&nbsp;}, 1000)<br>
})<br><br>

onUnmounted(() => {<br>
&nbsp;&nbsp;// 清理定时器<br>
&nbsp;&nbsp;if (timerId.value) {<br>
&nbsp;&nbsp;&nbsp;&nbsp;clearInterval(timerId.value)<br>
&nbsp;&nbsp;}<br>
})
                            </div>
                        </div>
                        
                        <div class="component-demo">
                            <h3>组件卸载演示</h3>
                            <p>点击按钮切换子组件显示/隐藏,观察控制台输出</p>
                            <button class="toggle-btn" @click="toggleComponent">
                                {{ showChildComponent ? '隐藏' : '显示' }}子组件
                            </button>
                            
                            <div v-if="showChildComponent">
                                <child-component></child-component>
                            </div>
                        </div>
                    </div>
                    
                    <div class="stats">
                        <div class="stat-box">
                            <div class="stat-value">{{ activeTimersCount }}</div>
                            <div class="stat-label">活动定时器</div>
                        </div>
                        <div class="stat-box">
                            <div class="stat-value">{{ clearedTimersCount }}</div>
                            <div class="stat-label">已清理定时器</div>
                        </div>
                        <div class="stat-box">
                            <div class="stat-value">{{ totalTimersCount }}</div>
                            <div class="stat-label">总定时器</div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        const { createApp, ref, onMounted, onUnmounted, computed, onBeforeUnmount } = Vue;
        
        // 子组件演示生命周期
        const ChildComponent = {
            template: `
                <div class="card" style="margin-top: 15px;">
                    <h3>子组件生命周期演示</h3>
                    <p>组件状态: <span style="color: #4CAF50;">已挂载</span></p>
                    <p>定时器ID: {{ timerId || '无' }}</p>
                    <p>计时: {{ count }} 秒</p>
                    <button class="warning-btn" @click="stopTimer">停止定时器</button>
                </div>
            `,
            setup() {
                const timerId = ref(null);
                const count = ref(0);
                
                // 在 onMounted 中创建定时器
                onMounted(() => {
                    console.log('子组件已挂载,创建定时器');
                    timerId.value = setInterval(() => {
                        count.value++;
                        console.log(`子组件定时器执行: ${count.value} 秒`);
                    }, 1000);
                });
                
                // 在 onUnmounted 中清理定时器 (最佳实践)
                onUnmounted(() => {
                    console.log('子组件即将卸载,清理定时器');
                    if (timerId.value) {
                        clearInterval(timerId.value);
                        console.log('定时器已清理');
                    }
                });
                
                // 也可以在 onBeforeUnmount 中清理
                onBeforeUnmount(() => {
                    console.log('onBeforeUnmount: 清理定时器');
                });
                
                const stopTimer = () => {
                    if (timerId.value) {
                        clearInterval(timerId.value);
                        timerId.value = null;
                        console.log('手动停止定时器');
                    }
                };
                
                return {
                    timerId,
                    count,
                    stopTimer
                };
            }
        };
        
        const app = createApp({
            components: {
                ChildComponent
            },
            setup() {
                // 定时器数据
                const timers = ref([]);
                const timerIdCounter = ref(1);
                const timerType = ref('timeout');
                const timerDuration = ref(3000);
                const timerMessage = ref('定时器已触发!');
                const showChildComponent = ref(false);
                
                // 计算属性
                const activeTimers = computed(() => 
                    timers.value.filter(t => t.status === 'active')
                );
                
                const activeTimersCount = computed(() => activeTimers.value.length);
                
                const clearedTimersCount = computed(() => 
                    timers.value.filter(t => t.status === 'cleared').length
                );
                
                const totalTimersCount = computed(() => timers.value.length);
                
                // 添加定时器
                const addTimer = () => {
                    if (timerDuration.value < 100) {
                        alert('请输入有效的持续时间(至少100毫秒)');
                        return;
                    }
                    
                    const timerId = timerIdCounter.value++;
                    let timerRef;
                    
                    // 创建定时器对象
                    const timerObj = {
                        id: timerId,
                        type: timerType.value,
                        duration: timerDuration.value,
                        message: timerMessage.value,
                        status: 'active',
                        startTime: Date.now(),
                        progress: 0
                    };
                    
                    // 根据类型设置定时器
                    if (timerType.value === 'timeout') {
                        timerRef = setTimeout(() => {
                            handleTimerCompletion(timerId);
                            console.log(`定时器 #${timerId}: ${timerMessage.value}`);
                        }, timerDuration.value);
                        
                        timerObj.ref = timerRef;
                    } else {
                        timerRef = setInterval(() => {
                            console.log(`定时器 #${timerId}: ${timerMessage.value}`);
                        }, timerDuration.value);
                        
                        timerObj.ref = timerRef;
                    }
                    
                    timers.value.push(timerObj);
                    updateProgressBars();
                };
                
                // 处理定时器完成
                const handleTimerCompletion = (timerId) => {
                    const timer = timers.value.find(t => t.id === timerId);
                    if (timer) {
                        timer.status = 'completed';
                    }
                };
                
                // 清理单个定时器
                const clearTimer = (timerId) => {
                    const timer = timers.value.find(t => t.id === timerId);
                    if (timer && timer.status === 'active') {
                        if (timer.type === 'timeout') {
                            clearTimeout(timer.ref);
                        } else {
                            clearInterval(timer.ref);
                        }
                        timer.status = 'cleared';
                        console.log(`定时器 #${timerId} 已清理`);
                    }
                };
                
                // 清理所有定时器
                const clearAllTimers = () => {
                    if (timers.value.length === 0) {
                        alert('没有活动定时器可清理');
                        return;
                    }
                    
                    if (confirm(`确定要清理所有 ${timers.value.length} 个定时器吗?`)) {
                        timers.value.forEach(timer => {
                            if (timer.status === 'active') {
                                if (timer.type === 'timeout') {
                                    clearTimeout(timer.ref);
                                } else {
                                    clearInterval(timer.ref);
                                }
                                timer.status = 'cleared';
                            }
                        });
                        
                        console.log('所有定时器已清理');
                    }
                };
                
                // 更新进度条
                const updateProgressBars = () => {
                    const activeTimersList = timers.value.filter(t => t.status === 'active');
                    
                    activeTimersList.forEach(timer => {
                        const elapsed = Date.now() - timer.startTime;
                        const progress = Math.min(100, (elapsed / timer.duration) * 100);
                        timer.progress = progress;
                        
                        // 如果是interval类型,进度条会循环
                        if (timer.type === 'interval' && progress >= 100) {
                            timer.startTime = Date.now();
                        }
                    });
                };
                
                // 切换子组件显示
                const toggleComponent = () => {
                    showChildComponent.value = !showChildComponent.value;
                };
                
                // 设置一个定时器来更新进度条
                onMounted(() => {
                    setInterval(updateProgressBars, 100);
                });
                
                return {
                    timers,
                    timerType,
                    timerDuration,
                    timerMessage,
                    showChildComponent,
                    activeTimers,
                    activeTimersCount,
                    clearedTimersCount,
                    totalTimersCount,
                    addTimer,
                    clearTimer,
                    clearAllTimers,
                    toggleComponent
                };
            }
        });
        
        app.mount('#app');
    </script>
</body>
</html>

搞懂虚拟列表实现原理与步骤

虚拟列表已经说烂了,此篇文章仅作记录使用,通俗的拆解每一步的逻辑和每一个变量的意义。

一、原理和基本构成

首先,虚拟滚动就是为了解决渲染大量数据到页面上造成的性能问题,一千个dom元素同时渲染到页面上必然出现卡顿,但是一千条或者一万条真的渲染出来了,一般的显示器也是显示不出来的,那我们能不能只渲染可视区域中出现的数据缩小渲染数据量呢,比如我的可视区域只能展示十条,那我把这十条拿出来只渲染这十条不就好了。那这种虚拟列表就应运而生了。

他的结构如下图:

image.png

没看懂没关系,它的结构代码如下:

<template>
 <!-- 可视区域 -->
  <div class="virtua_main">
    <!-- 虚拟元素 -->
    <div class="occupy_pace"></div>
     <!-- 内容区域 -->
    <div class="virtua_content">
     
      <p class="virtua_item">
        item1
      </p>
      
    </div>
    
  </div>
</template>

<style scoped>
.virtua_main {
  width: 500px;
  height: 500px;
  overflow-y: auto;
  position: relative;
  background: greenyellow;
  color: red;
}

.virtua_content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtua_item {
  margin: 0;
  border: 1px solid #e0e0e0;
  box-sizing: border-box;
}
</style>

首先明确,可视区域(virtua_main)的高度是固定的,超出高度出现滚动条,所以需要一个虚拟元素(occupy_pace)撑起高度从而显示滚动条,而且滚动高度要与数据条数相匹配。内容区域(virtua_content)通过绝对定位覆盖在虚拟元素之上。

二、需要思考的几个数字

到目前为止,只有可视区域的高度是固定的,那么内容区域的高度呢,虚拟元素的高度呢;下面列阵:

内容区域的高度 = 要展示的条数 * 每一条内容的高度

虚拟元素的高度 = 需要渲染数组的总长度 * 每一条内容的高度

截取展示内容数据开始的索引 = 滚动条移动的高度 / 每一条内容的高度

截取展示内容数据结束的索引 = 截取展示内容数据开始的索引 + 要展示的条数

三、代码实现基础逻辑

虽然但是那我还是不知道,要展示的条数、每一条内容的高度、滚动条移动的高度、需要渲染数组的总长度从哪里来呢?那还是直接上代码吧🙄🙄🙄

<template>
  <!-- 可视区域 -->
  <div class="virtua_main" @scroll="onScroll">
    <!-- 虚拟元素 -->
    <div class="occupy_pace" :style="{ height: virtualHeight }"></div>
    <!-- 内容区域 -->
    <div
      class="virtua_content"
      :style="{ height: contentHeight, '--row-height': rowHeight + 'px' }"
    >
      <p class="virtua_item" v-for="item in visibleItems" :key="item.id">
        {{ item.label }}
      </p>
    </div>
  </div>
</template>

<script setup>
import { ref } from "vue";
//模拟一千条数据
const allList = ref(
  Array.from({ length: 1000 }).map((_, index) => ({
    id: index,
    label: `Item ${index + 1}`,
  }))
);
//可视区域显示条数
const showSize = ref(10);
//每行高度
const rowHeight = ref(50);
//当前滚动高度
const scrollTop = ref(0);
//虚拟元素高度
const virtualHeight = computed(() => {
  return allList.value.length * rowHeight.value + "px";
});
//内容区高度
const contentHeight = computed(() => {
  return showSize.value * rowHeight.value + "px";
});
//截取展示内容数据开始的索引
const startIndex = computed(() => {
  //开始的索引要考虑边界问题,不能比0小,同时不能大于数据总长度减显示条数
  //使用floor向下取整数
  const index = Math.floor(scrollTop.value / rowHeight.value);
  const maxStartIndex = Math.max(0, allList.value.length - showSize.value);
  return Math.min(index, maxStartIndex);
});
//截取展示内容数据结束的索引
const endIndex = computed(() => {
  //也有边界问题,不能大于数据总长度
  return Math.min(startIndex.value + showSize.value, allList.value.length);
});
//内容区域展示的数据,从所有数据中截取showSize条
const visibleItems = computed(() => {
  return allList.value.slice(startIndex.value, endIndex.value);
});
//滚动事件
const onScroll = (event) => {
  //将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
  scrollTop.value = event.target.scrollTop;
};
</script>

<style scoped>
.virtua_main {
  width: 300px;
  height: 500px;
  overflow-y: auto;
  position: relative;
  background: #e0e0e0;
  color: red;
}

.virtua_content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtua_item {
  height: var(--row-height);
  margin: 0;
  border: 1px solid #000;
  box-sizing: border-box;
}
</style>

这样更直观的回答了数据从哪来、如何计算、如何使用、何时更新的问题。但是但是但是如果这样写了之后会发现一个问题,内容数据确实一直在变,但是内容区域怎么上去了,位置不对,如图:

嘻嘻嘻.gif

上图出现的原因是因为虽然内容区域已经开启了定位,但是它处于virtua_main的滚动空间之中,所以也会随着滚动,那这样,既然他要向上滚动,那我们就再加一步,手动通过transform:translateY()把他纵向向下移动,那么应该移动的距离怎么计算呢,理一理,比如当前从0到19展示了10条,我们向下滚动到1此时0隐藏了,那他是不是就向上滚动了1 * rowHeight的距离呢,1是当前切割数据的开始索引,由此推断 位移距离 = startIndex * rowHeight==

在滚动事件增加这么一行

//通过ref的方式取到内容区域
const virtuaContent = ref(null)
//滚动事件
const onScroll = (event) => {
  //将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
  scrollTop.value = event.target.scrollTop;
  //向下位移掩盖空白
  virtuaContent.value.style.transform = `translateY(${
    startIndex.value * rowHeight.value
  }px)`;
};

目前为止,完成了一个基础的虚拟滚动。

四、细节问题

1.空白

大家是否想过,如果我滚动高度挪到了一个不能整除rowHeight的数字怎么办,当然我们使用了Math.floor向下取整,比如我挪动到了624,我设置的rowHeight是50,startIndex = 624 / 50结果是12.48,Math.floor(12.48)得出12,很好数字没有问题,但是我们使用transform进行位移是用12 * 50得600,也就是说我向上滚动了624px但是向下却位移了600px,那尾部自然会出现24px的空白,如图:

image.png

两种解决方案

其一,简单粗暴,算位移距离直接用scrollTop:

//通过ref的方式取到内容区域
const virtuaContent = ref(null)
//滚动事件
const onScroll = (event) => {
  //将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
  scrollTop.value = event.target.scrollTop;
  //向下位移掩盖空白
  virtuaContent.value.style.transform = `translateY(${
    (scrollTop.value / rowHeight.value) * rowHeight.value
  }px)`;
};

其二、更简单更粗暴,尾部多切几条数据:

//定义缓冲数据大小
const buffer = 3;
//截取展示内容数据结束的索引
const endIndex = computed(() => {
  //也有边界问题,不能大于数据总长度
  //每次多加载buffer条数据
  return Math.min(
    startIndex.value + showSize.value + buffer,
    allList.value.length
  );
});

这两种方式都能用,但使用 scrollTop 会产生半行高度空白,需要 buffer 补齐

2.加载更多

我们做到这里基本能用了,但是都说了大数据量了,只有一千条吗,我有十万条!一百万!但是一个接口返回给你这个请求的时间是不是有点长呢,那么一页一千条拉到底部继续加载是不是极好的呢,如果再加一个等待效果是不是极好的呢。如果马上滑到底部就发送请求到底部时数据已经加载出来了是不是堪称完美,那我们来实现他。

想一想,我们如何来判断要到底部或者即将到底部了呢,有小伙伴说如果scrollTop如果大于等于虚拟元素的总高不就说明到底了吗,no scrollTop可以理解为虚拟元素顶部到可视区域顶部的距离,还差一个可视区域的高度,所以,scrollTop + clientHeight >= virtualHeight才说明到底了,此时走过来一个老伙伴问,那我怎么才能实现即将到底时就发送求情呢,问得好,比如我们想距离底部100px时就发送请求,我们只要再加100就好了呀,又又又来了个小东西问加载效果呢,问的也好,但是没有刚才好,答:只要在内容区最下方加一条内容用一个状态来控制它的显隐就好了呀。完整版如下:

<template>
  {{ `startindex:${startIndex}` }}&nbsp;&nbsp;&nbsp;&nbsp;{{
    `endIndex:${endIndex}`
  }}&nbsp;&nbsp;&nbsp;&nbsp;{{ `scrollTop:${scrollTop}` }}
  {{ currentPage }}
  <!-- 可视区域 -->
  <div class="virtua_main" @scroll="onScroll">
    <!-- 虚拟元素 -->
    <div class="occupy_pace" :style="{ height: virtualHeight }"></div>
    <!-- 内容区域 -->
    <div
      class="virtua_content"
      ref="virtuaContent"
      :style="{ height: contentHeight, '--row-height': rowHeight + 'px' }"
    >
      <p class="virtua_item" v-for="item in visibleItems" :key="item.id">
        {{ item.label }}
      </p>
      <p v-if="loading" class="virtua_item loading-indicator">加载中。。。</p>
    </div>
  </div>
</template>

<script setup>
import { ref } from "vue";
//模拟一千条数据
const allList = ref(
  Array.from({ length: 1000 }).map((_, index) => ({
    id: index,
    label: `Item ${index + 1}`,
  }))
);
//内容区域元素
const virtuaContent = ref(null);
//可视区域显示条数
const showSize = ref(10);
//每行高度
const rowHeight = ref(50);
//当前滚动高度
const scrollTop = ref(0);
//虚拟元素高度
const virtualHeight = computed(() => {
  return allList.value.length * rowHeight.value + "px";
});
//内容区高度
const contentHeight = computed(() => {
  return showSize.value * rowHeight.value + "px";
});
//截取展示内容数据开始的索引
const startIndex = computed(() => {
  //开始的索引要考虑边界问题,不能比0小,同时不能大于数据总长度减显示条数
  const index = Math.floor(scrollTop.value / rowHeight.value);
  const maxStartIndex = Math.max(0, allList.value.length - showSize.value);
  return Math.min(index, maxStartIndex);
});
//定义缓冲数据大小
const buffer = 3;
//截取展示内容数据结束的索引
const endIndex = computed(() => {
  //也有边界问题,不能大于数据总长度
  //每次多加载buffer条数据
  return Math.min(
    startIndex.value + showSize.value + buffer,
    allList.value.length
  );
});
//内容区域展示的数据,从所有数据中截取showSize条
const visibleItems = computed(() => {
  return allList.value.slice(startIndex.value, endIndex.value);
});
//记录当前页,模拟状态下没啥用,真实情况下要发送给后端一般还要带一个pageSize
const currentPage = ref(1);
//定义加载状态
const loading = ref(false);
//定义距离下边界多少像素时触发加载更多
const loadMoreThreshold = 100;
//模拟一个加载更多数据函数
const loadMoreData = () => {
  loading.value = true;
  setTimeout(() => {
    //模拟网络请求
    allList.value.push(
      ...Array.from({ length: 1000 }).map((_, index) => ({
        id: allList.value.length + index,
        label: `Item ${allList.value.length + index + 1}`,
      }))
    );
    currentPage.value++;
    loading.value = false;
    scrollTop.value -= 10;
  }, 200);
};
//滚动事件
const onScroll = (event) => {
  const target = event.target;
  //将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
  scrollTop.value = target.scrollTop;
  virtuaContent.value.style.transform = `translateY(${
    startIndex.value * rowHeight.value
  }px)`;
  //判断是否触底
  const isBoundary =
    target.scrollTop + target.clientHeight + loadMoreThreshold >=
    virtualHeight.value.replace("px", "");

  if (isBoundary && !loading.value) {
    loadMoreData();
  }
};
</script>

<style scoped>
.virtua_main {
  width: 300px;
  height: 500px;
  overflow-y: auto;
  position: relative;
  background: #e0e0e0;
  color: red;
}

.virtua_content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtua_item {
  height: var(--row-height);
  margin: 0;
  border: 1px solid #000;
  box-sizing: border-box;
}
</style>

四、结语

这个代码很基础,我们可以把它运用到业务场景中,封装一个组件,或者把计算逻辑抽离出hooks,还有可以改为动态高度的版本,并且滚动事件应该添加防抖。有些地方可以更为精简,但是为了一些好兄弟能看的更明白所以这样写了。 代码中还隐藏了一些bug,老伙子们如果发现了可以给我留言,因为第一次写这么长的文章,文中如果有些逻辑不严谨或者错误、还有可以有优化的地方,也欢迎留言指正一起交流。

Vue 3 defineProps 与 defineEmits 深度解析

还在为 Vue 组件间的类型安全头疼吗?每次传参都像在玩“猜猜我是谁”,运行时错误频出,调试起来让人抓狂?别担心,今天我要带你彻底掌握 Vue 3 中的 defineProps 和 defineEmits,这对 TypeScript 的完美搭档将彻底改变你的开发体验。

读完本文,你将获得一套完整的类型安全组件通信方案,从基础用法到高级技巧,再到实战中的最佳实践。更重要的是,你会发现自己写出的代码更加健壮、可维护,再也不用担心那些烦人的类型错误了。

为什么需要 defineProps 和 defineEmits?

在 Vue 2 时代,我们在组件中定义 props 和 emits 时,类型检查往往不够完善。虽然可以用 PropTypes,但和 TypeScript 的配合总是差那么点意思。很多时候,我们只能在运行时才发现传递了错误类型的数据,这时候已经为时已晚。

想象一下这样的场景:你写了一个按钮组件,期望接收一个 size 属性,只能是 'small'、'medium' 或 'large' 中的一个。但在使用时,同事传了个 'big',TypeScript 编译时没报错,直到用户点击时才发现样式不对劲。这种问题在大型项目中尤其致命。

Vue 3 的 Composition API 与 TypeScript 的深度集成解决了这个问题。defineProps 和 defineEmits 这两个编译器宏,让组件的输入输出都有了完整的类型推导和检查。

defineProps:让组件输入类型安全

defineProps 用于定义组件的 props,它最大的优势就是与 TypeScript 的无缝集成。我们来看几种不同的用法。

基础用法很简单,但功能强大:

// 定义一个按钮组件
// 使用类型字面量定义 props
const props = defineProps<{
  size: 'small' | 'medium' | 'large'
  disabled?: boolean
  loading?: boolean
}>()

// 在模板中直接使用
// 现在有了完整的类型提示和检查

这种写法的好处是,当你使用这个组件时,TypeScript 会严格检查传入的 size 值。如果你试图传递 'big',编译器会立即报错,而不是等到运行时。

但有时候我们需要给 props 设置默认值,这时候可以这样写:

// 使用 withDefaults 辅助函数设置默认值
interface ButtonProps {
  size: 'small' | 'medium' | 'large'
  disabled?: boolean
  loading?: boolean
}

const props = withDefaults(defineProps<ButtonProps>(), {
  size: 'medium',
  disabled: false,
  loading: false
})

withDefaults 帮我们处理了默认值,同时保持了类型的完整性。这样即使父组件没有传递这些 props,子组件也能正常工作。

还有一种情况,我们需要混合使用运行时声明和类型声明:

// 运行时声明与类型声明结合
const props = defineProps({
  // 运行时声明
  label: {
    type: String,
    required: true
  },
  // 类型声明
  count: {
    type: Number,
    default: 0
  }
})

// 定义类型
interface Props {
  label: string
  count?: number
}

// 这种写法在某些复杂场景下很有用

这种混合写法在处理一些动态 prop 时特别有用,比如需要根据某些条件决定 prop 的类型。

defineEmits:组件输出的类型守卫

defineEmits 用于定义组件发出的事件,同样提供了完整的类型支持。这确保了我们在触发事件时传递正确的数据,也让使用者知道应该如何处理这些事件。

先看一个基础示例:

// 定义表单组件的事件
// 使用类型字面量定义 emits
const emit = defineEmits<{
  // submit 事件携带一个表单数据对象
  submit: [formData: FormData]
  // cancel 事件不携带数据
  cancel: []
  // input 事件携带字符串值
  input: [value: string]
}>()

// 在方法中触发事件
function handleSubmit() {
  const formData = gatherFormData()
  // TypeScript 会检查 formData 是否符合 FormData 类型
  emit('submit', formData)
}

function handleCancel() {
  // 不传递参数,符合类型定义
  emit('cancel')
}

这种写法的优势在于,当你在组件内调用 emit 时,TypeScript 会严格检查参数的类型和数量。如果你试图 emit('submit') 而不传递 formData,或者传递错误类型的参数,编译器会立即提醒你。

对于更复杂的场景,我们可以使用接口来定义事件:

// 使用接口定义事件类型
interface FormEvents {
  submit: (data: FormData) => void
  cancel: () => void
  validate: (isValid: boolean, errors: string[]) => void
}

const emit = defineEmits<FormEvents>()

// 在验证方法中触发复杂事件
function performValidation() {
  const isValid = validateForm()
  const errors = getValidationErrors()
  
  // TypeScript 确保我们传递正确的参数类型
  emit('validate', isValid, errors)
}

这种接口方式的定义让代码更加清晰,特别是当事件类型比较复杂时。你可以把所有的事件定义放在一个地方,便于维护和理解。

实战技巧:高级用法与最佳实践

在实际项目中,我们经常会遇到一些复杂场景,这时候就需要一些高级技巧来应对。

一个常见的需求是,我们需要基于已有的 props 类型来定义事件。比如在一个可搜索的表格组件中:

// 定义表格组件的 props 和 emits
interface TableProps {
  data: any[]
  columns: Column[]
  searchable?: boolean
  pagination?: boolean
}

const props = defineProps<TableProps>()

// 事件定义基于 props 的某些特性
const emit = defineEmits<{
  // 只有当 searchable 为 true 时才会有 search 事件
  search: [query: string]
  // 只有当 pagination 为 true 时才会有 pageChange 事件
  pageChange: [page: number]
  // 始终存在的选择事件
  rowSelect: [row: any]
}>()

// 在搜索方法中条件性触发事件
function handleSearch(query: string) {
  if (props.searchable) {
    // TypeScript 知道这个事件是有效的
    emit('search', query)
  }
}

另一个有用的技巧是泛型组件的定义。当我们想要创建可重用的通用组件时:

// 定义一个通用的列表组件
interface ListProps<T> {
  items: T[]
  keyField: keyof T
  renderItem?: (item: T) => any
}

// 使用泛型定义 props
function defineListProps<T>() {
  return defineProps<ListProps<T>>()
}

// 在具体组件中使用
interface User {
  id: number
  name: string
  email: string
}

// 为 User 类型特化组件
const props = defineListProps<User>()

这种泛型组件的方式在组件库开发中特别有用,它提供了极大的灵活性,同时保持了类型安全。

在处理异步操作时,我们通常需要定义加载状态和错误处理:

// 异步操作组件的完整类型定义
interface AsyncProps {
  data?: any
  loading?: boolean
  error?: string | null
}

interface AsyncEmits {
  retry: []
  reload: [force?: boolean]
  success: [data: any]
}

const props = defineProps<AsyncProps>()
const emit = defineEmits<AsyncEmits>()

// 在异步操作完成时触发事件
async function fetchData() {
  try {
    const result = await api.fetch()
    emit('success', result)
  } catch (error) {
    // 错误处理
  }
}

常见陷阱与解决方案

虽然 defineProps 和 defineEmits 很强大,但在使用过程中还是有一些需要注意的地方。

一个常见的错误是试图在运行时访问类型信息:

// 错误的做法:试图在运行时使用类型
const props = defineProps<{
  count: number
}>()

// 这在运行时是 undefined,因为类型信息在编译时就被移除了
console.log(props.count.type) // undefined

// 正确的做法:使用运行时声明
const props = defineProps({
  count: {
    type: Number,
    required: true
  }
})

另一个陷阱是关于可选参数的处理:

// 定义带有可选参数的事件
const emit = defineEmits<{
  // 第二个参数是可选的
  search: [query: string, options?: SearchOptions]
}>()

// 使用时要注意参数顺序
function handleSearch(query: string) {
  // 可以只传递必填参数
  emit('search', query)
}

function handleAdvancedSearch(query: string, options: SearchOptions) {
  // 也可以传递所有参数
  emit('search', query, options)
}

在处理复杂的嵌套对象时,类型定义可能会变得冗长:

// 使用类型别名简化复杂类型
type UserProfile = {
  personal: {
    name: string
    age: number
  }
  preferences: {
    theme: 'light' | 'dark'
    language: string
  }
}

const props = defineProps<{
  profile: UserProfile
}>()

// 这样既保持了类型安全,又让代码更清晰

与其它 Composition API 的配合

defineProps 和 defineEmits 可以很好地与 Vue 3 的其它 Composition API 配合使用,创造出强大的组合逻辑。

比如与 provide/inject 的配合:

// 父组件提供数据
const props = defineProps<{
  theme: 'light' | 'dark'
  locale: string
}>()

// 基于 props 提供全局配置
provide('appConfig', {
  theme: props.theme,
  locale: props.locale
})

// 子组件注入并使用
const config = inject('appConfig')

与 watch 和 computed 的配合:

const props = defineProps<{
  items: any[]
  filter: string
}>()

const emit = defineEmits<{
  filtered: [results: any[]]
}>()

// 监听 props 变化并触发事件
watch(() => props.filter, (newFilter) => {
  const filtered = filterItems(props.items, newFilter)
  emit('filtered', filtered)
})

// 基于 props 计算衍生数据
const sortedItems = computed(() => {
  return props.items.sort(sortFunction)
})

性能优化与最佳实践

虽然类型安全很重要,但我们也要注意性能影响。以下是一些优化建议:

对于大型对象,考虑使用浅层响应式:

const props = defineProps<{
  // 对于大型配置对象,使用 shallowRef 避免不必要的响应式开销
  config: AppConfig
  // 对于频繁变化的数据,保持深度响应式
  items: any[]
}>()

合理使用 PropType 进行复杂类型验证:

import type { PropType } from 'vue'

const props = defineProps({
  // 使用 PropType 进行运行时类型验证
  complexData: {
    type: Object as PropType<ComplexData>,
    required: true,
    validator: (value: ComplexData) => {
      return validateComplexData(value)
    }
  }
})

总结

defineProps 和 defineEmits 是 Vue 3 与 TypeScript 完美结合的代表作。它们不仅提供了编译时的类型安全,还大大提升了开发体验。通过本文的学习,你应该能够在组件中正确定义类型安全的 props 和 emits,充分利用 TypeScript 的类型推导能力,处理各种复杂场景下的类型需求,避免常见的陷阱和错误。

前端日常工作开发技巧汇总

一、JS篇

1. structuredClone 深拷贝

JavaScript 内置了一个 structuredClone() 的方法, 此方法提供了一种简单有效的方法来深度克隆对象,支持复杂数据类型,包括 DateRegExpMapSetArrayBufferBlobFile 等。浏览器底层实现,通常比手动递归或 JSON 方法更高效。

兼容性 image.png

2. 函数式编程

ES14 更新了许多数组方法或者为原有的数组方法增加不会带来突变(without mutation) 的互补方法。意味着它们会基于原数组创建新的数组,而不是直接修改原数组。

新增的互补方法有

  • Array.sort() -> Array.toSorted()
  • Array.splice() -> Array.toSpliced()
  • Array.reverse() -> Array.toReversed()

新增的新数组方法有:Array.with()Array.findLast()Array.findLastIndex()

  • Array.with()
    返回一个新数组,将原数组中指定索引 index 的元素替换为 value不修改原数组

语法
index:要替换的元素的索引(可以是负数,表示从末尾开始计数)。
value:替换后的新值

const newArray = array.with(index, value)

const arr = [1, 2, 3, 4];
const newArr = arr.with(1, "hello"); // 替换索引 1 的元素 

console.log(arr);    // [1, 2, 3, 4](原数组不变)
console.log(newArr); // [1, "hello", 3, 4](新数组)

// 支持负数索引(从末尾开始)
const newArr2 = arr.with(-2, "world"); // 替换倒数第 2 个元素
console.log(newArr2); // [1, 2, "world", 4]
  • Array.findLast()
    从数组末尾向前查找第一个满足 callback 条件的元素,并返回该元素。如果未找到,返回 undefined

  • Array.findLastIndex()
    从数组末尾向前查找第一个满足 callback 条件的元素,并返回其索引。如果未找到,返回 -1

3. 惰性函数

JavaScript 中的 惰性函数(Lazy Function) 是一种优化技术,其核心思想是:函数在第一次调用时执行一些初始化或判断逻辑,并在执行后将自身重定义为一个更高效或更简单的版本,后续调用就直接使用这个新版本,避免重复开销

普通写法

function copyToClipboard(text) {
    // 优先使用Clipboard API
    if (navigator.clipboard) {
      return navigator.clipboard
        .writeText(text)
        .then(() => {
          Message.success('复制成功')
          return true
        })
        .catch((err) => {
          console.error('使用Clipboard API复制失败: ', err)
          // 如果Clipboard API失败,尝试使用降级方案
          return copyUsingExecCommand(text)
        })
    } else {
      // 如果不支持Clipboard API,直接使用降级方案
      return copyUsingExecCommand(text)
    }
}

惰性写法

function copyToClipboard(text) {
  // 第一次调用时进行能力检测,并重定义自身
  if (navigator.clipboard) {
    // 支持 Clipboard API
    copyToClipboard = function (text) {
      return navigator.clipboard
        .writeText(text)
        .then(() => {
          console.log('文本已成功复制到剪贴板');
          return true;
        })
        .catch((err) => {
          console.error('Clipboard API 复制失败:', err);
          return false;
        });
    };
  } else {
    // 不支持 Clipboard API,使用 execCommand 降级方案
    copyToClipboard = function (text) {
      return copyUsingExecCommand(text);
    };
  }

  // 执行第一次调用
  return copyToClipboard(text);
}

二、CSS篇

1. 滚动吸附

<template>
  <div>
    <div class="container">
      <div class="item">1</div>
      <div class="item">2</div>
      <div class="item">3</div>
    </div>
  </div>
</template>

<script setup name="Snap"></script>

<style lang="scss" scoped>
.container {
  width: 100%;
  height: 300px;
  display: flex;
  overflow-x: scroll;
  // 吸附效果 mandatory: 必须吸附  proximity: 靠近时吸附
  scroll-snap-type: x mandatory;
  .item {
    flex-shrink: 0;
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 30px;
    color: #fff;
    background-color: #ccc;
    // 吸附位置
    scroll-snap-align: start;
    scroll-snap-stop: always;
    &:nth-child(1) {
      background-color: #f56c6c;
    }
    &:nth-child(2) {
      background-color: #67c23a;
    }
    &:nth-child(3) {
      background-color: #409eff;
    }
  }
}
</style>
兼容性较高

image.png

2. 字体自适应容器大小

<template>
  <div>
    <div class="container">
      <p>字体自适应容器大小</p>
    </div>
  </div>
</template>

<script setup name=""></script>

<style lang="scss" scoped>
.container {
  width: 500px;
  height: 300px;
  padding: 15px;
  resize: both;
  overflow: hidden;
  background-color: aquamarine;
  container-type: inline-size; // 启用容器查询 size:基于宽高 / inline-size 基于宽度 / normal 不启用
  p {
    font-size: 5cqh;
  }
}
</style>
兼容性还行

image.png

3. 选择器

  • 选择器特定性(通常叫做选择器权重)

当希望某个css属性优先级高于其他属性值时,尽量不要使用!important!important会打破这些固有的级联规则,使得样式的应用变得不那么可预测。这可能会导致样式表难以维护和理解,尤其是在大型项目中。增加调试难度,也限制了样式的灵活性。

替代方案: 通过编写更具体(或更精确)的选择器来覆盖样式,或者叠加选择器,比如:222

.el-button.el-button {
    color: red;
}
  • 新型选择器

:has()选择器: 根据一个元素是否包含某些特定的后代元素,或者其后的同级元素是否满足某些条件,来选中该元素本身。这实现了“向下”观察的能力。

示例1: 选择包含 <img><div>

<div>这个 div 没有图片,不会被选中</div>
<div>
    <img src="example.jpg" alt="示例图片">
    这个 div 包含图片,会被红色边框包围
</div>
/* 选择包含 <img> 的 div */ 
div:has(img) {
    border: 3px solid red; padding: 10px;
}

示例2: 选择紧跟着 <p><h2>

<h2>这个 h2 后面没有紧跟着 p,不会被选中</h2>
<div>分隔内容</div>
<h2>这个 h2 后面紧跟着 p</h2>
<p>这个 p 是 h2 的紧邻兄弟元素,因此 h2 会变成蓝色斜体</p>
/* 选择后面紧跟着 <p> 的 h2 */
h2:has(+ p) {
    color: blue; font-style: italic;
}

兼容性 image.png

:is()选择器: 它接受一个逗号分隔的选择器列表作为参数,并匹配其中任意一个选择器。这有助于减少冗余代码,提高可读性。:is() 的权重等于它括号里所有选择器中权重最高的那个。

示例:

<header>
    <h1>这是 header 的 h1(紫色)</h1>
</header>
<main>
    <h1>这是 main 的 h1(紫色)</h1>
</main>
<footer>
    <h1>这是 footer 的 h1(紫色)</h1>
</footer>
<section>
    <h1>这个 h1 不在 :is() 范围内,保持默认颜色</h1>
</section>
/* 统一设置 header、main、footer 下的 h1 样式 */
:is(header, main, footer) h1 {
    color: purple; font-family: Arial, sans-serif;
}

兼容性 image.png

:where()选择器: 与 :is() 类似,但权重永远为 0,适合默认样式。

兼容性 image.png

三、VUE篇

1. v-memo

Vue 3 提供的性能优化指令,其作用是通过缓存模板子树的渲染结果,仅在依赖项变化时重新渲染,从而减少不必要的虚拟 DOM 计算和更新操作。

v-memo 接收一个依赖数组,只有当数组中的值发生变化时才会重新渲染。

示例:优化大型列表渲染,避免全量更新。 当 item.id 或 item.status 变化时,仅更新对应项;其他项复用缓存结果

<div v-for="item in list" :key="item.id" v-memo="[item.id, item.status]">
  {{ item.content }}
  <StatusBadge :status="item.status" />
</div>

2. watch —— 副作用和深度监听

3. customRef ——— 自定义响应式依赖追踪

4. 组件的二次封装

四、Chrome浏览器调试技巧

1. $0

2. 模拟聚焦网页

3. 重放XHR

五、VSCode编辑器插件分享

1. i18n Ally

  • 代码内直接预览翻译文本
  • 快速生成初始翻译
  • 一键跳转至对应翻译条目
  • 集中管理
  "i18n-ally.localesPaths": ["./src/i18n/lang/locales"], // 翻译文件夹路径
  "i18n-ally.pathMatcher": "{locale}/**/{namespace}.json", // 翻译目标文件路径匹配
  "i18n-ally.keystyle": "nested", // 翻译路径格式,
  "i18n-ally.sourceLanguage": "zh-CN", // 翻译源语言
  "i18n-ally.displayLanguage": "zh-CN", //显示语言, 这里也可以设置显示英文为en
  "i18n-ally.sortKeys": true, // 是否自动排序
  "i18n-ally.namespace": false, // 是否启用命名空间,一般在积攒多个待翻译文案时启用,可以自动编辑至对应文件中
  "i18n-ally.enabledParsers": ["ts", "js", "json"], // 翻译文件可允许的格式,默认json

2. koroFileHeader @4.9.2

用于生成文件头部注释和函数注释的插件

快捷键‌:

  • 头部注释:Ctrl+Win+I(Windows/Linux)或 Ctrl+Cmd+I(Mac)
  • 函数注释:Ctrl+Win+T(Windows/Linux)或 Ctrl+Cmd+T(Mac)
// 头部注释
"fileheader.customMade": {
"Author": "git config user.name && git config user.email", // 同时获取用户名与邮箱
"Date": "Do not edit", // 文件创建时间
"LastEditors": "git config user.name && git config user.email", // 文件最后编辑者 与Author字段一致
"LastEditTime": "Do not edit", // 文件最后编辑时间
"FilePath": "Do not edit", // 文件在项目中的相对路径 自动更新
"Description": "" // 文件描述
},

Vue2实现语音报警

Vue2 实现消息语音报警功能 下面是一个完整的Vue2消息语音报警实现方案,包含多种语音播报方式和自定义配置。 1. 安装依赖 2. 语音报警组件 核心语音服务类 Vue2语音报警组件 3. 全局事

Ant Design Vue 日期选择器英文不变更中文问题

Ant Design Vue 日期选择器中英文混杂问题分析与解决

项目背景

  • 技术栈:Vue 3.5.24 + Ant Design Vue 4.2.6
  • 日期库:从 v3 起 Ant Design Vue 默认使用 dayjs

问题描述

在全局已经配置中文(ConfigProvider + dayjs.locale('zh-cn'))的情况下,DatePicker 组件仍出现“中英文混杂”:

  • “年”“今天”等字样为中文
  • 月份(Jan/Feb…)与星期(Mon/Tue…)依旧显示英文
  • 无论全局注入还是局部覆盖 locale 均无效 企业微信截图_1b148f4c-d467-42a8-a4b8-ae1c3b81d0eb.png

深层原因剖析

  1. dayjs 版本过旧
    早期 dayjszh-cn 语言包缺失 months/weekdays 的中文定义,或补丁未完全下发。

  2. 多版本 dayjs 共存
    pnpm 的去重策略可能导致锁文件里存在多个 dayjs 版本,入口文件设置的 dayjs.locale 未必作用于 Ant Design Vue 内部使用的实例。

  3. 执行顺序/Tree-shaking 问题
    Vite 的懒加载或 chunk 切分可能使 import 'dayjs/locale/zh-cn' 未及时执行;若没有紧跟 dayjs.locale('zh-cn'),组件渲染阶段仍使用默认英文。

  4. 语言包字段缺失
    旧版本 dayjszh-cn 语言包里 weekdaysShortmonthsShort 等字段为空,Antd 组件 fallback 为英文。

排查步骤(建议流程)

  1. 确认全局中文配置

    import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; dayjs.locale('zh-cn');

  2. 检查 dayjs 版本

    pnpm list dayjs pnpm why dayjs 关注是否存在多个版本或锁定在 1.11.0 之前。

  3. 查看本地语言包
    打开 node_modules/dayjs/locale/zh-cn.js,确认 monthsweekdays 等数组是否为中文。

解决方案

  • 结论:升级 dayjs 至 ≥ 1.11.19

  • 操作步骤: 企业微信截图_bf2e42f6-e949-4fb7-8c95-51029d1df296.png pnpm add dayjs@1.11.19 -w

    import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; dayjs.locale('zh-cn');

    • 重启 dev server 并清理缓存,确认 DatePicker 面板的月份、星期、按钮均已中文化。

可选补充

  • Ant Design Vue 的日期国际化完全依赖 dayjs,语言异常优先排查 dayjs 版本和语言包
  • Monorepo/多包环境需确保 dayjs 版本统一,避免多版本导致的 locale 失效
  • import 'dayjs/locale/zh-cn' 后务必紧接 dayjs.locale('zh-cn'),并确保在入口同步执行

参考资料

❌