1.封装组件
1.二次封装组件属性事件插槽的透传
-
常规做法
// Comp.vue
<Comp v-bind="$attrs">
<template v-for="(_,slot) in $slots" #[slot]="slotProps">
<slot :name="slot" v-bind="slotProps"></slot>
</template>
</Comp>
-
js实现
<Comp/>
// script
import {h,useAttrs,useSlots} from 'vue'
const Comp = ()=>h(Comp,useAttrs(),useSlots())
通过函数使组件透传props和slots,弊端是会缺失双向绑定(没有透传事件)
-
终极版
<template>
<component :is="h(Comp,{...$Attrs,...props},$slots)"/>
// === <el-input :ref="getInstance" v-bind="mergeProps($attrs, props)"></el-input>
// mergeProps(vue提供,和解构一样效果) el-input这样写无法透传插槽
</template>
import {type InputProps } from "element-plus"
const props = defineProps<Partial<InputProps>>({})
// Partial如果不写,父组件编辑器会飘红,因为很多必传参数,Partial将所有参数变为?可选
2. 暴露子组件的方法给父组件
-
常规做法
// 使用 ref给子组件绑定,获取实例,再通过defineExpose手动抛出
比较简单,但是弊端很大,一旦子组件被v-if销毁父组件调用会报错。并且会造成内存泄漏,
-
借助代理
defineExpose(new Proxy({
get(target,key){
return ()=> inputRef.value?.[key]()
},
// proxy的方法外面如果调in会走这里 返回布尔值
// 调用has是因为Proxy代理的是空对象,如果不加has vue不知道能访问到这个key会报错
has(target,key){
return key in inputRef.value
}
}))
<el-input ref="inputRef"/>
-
vue源码思维vm实现
const vm = getCurrentInstance()
// ref可以传入一个函数,参数即为解包后的组件实例,给默认值为{}是因为组件可能会v-if卸载
const changeRef = (value)=>{
vm.expose = value || {}
}
<el-input :ref="changeRef">
3.子组件获取父组件传入的插槽中的ref
思路:
// 父组件
<Comp>
<template #default="slotProps">
<div :ref="slotProps">此处也可以是组件</div>
</template>
</Comp>
// 子组件
<slot :slotProps="slotProps"></slot>
const slotProps = (instance)=>{
instance.value 即为插槽的实例
}
4.插槽的实现原理
子组件负责定义插槽,作用域插槽传值。最终会渲染成函数,调用函数传参数对象,父组件 声明函数接收参数。
// 子组件 slotComp
<slot name="header" title="title">最终会被渲染成 slot.header({title})
useSlots() 或者setup的第二个参数可以获取到三个插槽
// 函数式组件 (最终编译)
export const helloSlot:FunctionalComponent<foo:Function> =
(props,{slots,attrs})=>{
return h('div',null,[
props.foo() // 父组件也可以传递props为一个函数返回虚拟节点放入插槽中渲染
slots.header({title:'title'}),
slots.default?slots.default():'默认内容',
slots.footer()
])
}
// 父组件
<slotComp>
<template #header="{title}">
<h1>{{title}}</h1>
<template>
</slotComp>
// 函数式组件
const Comp = h(slotComp,{
foo(){
return h('div',null,'foo')
}
},{
header:({title})=> h('h1',null,title) //返回一个vnode
})
2.封装权限组件
指令销毁只销毁了dom,但是js全部会执行,dom虽然销毁了,但是创建的对象还在,引用还在,ref暴露的方法依然会被外部引用执行。 比如
比如列表上加了v-auth,那列表的dom还在, v-if是编译时,自己写的指令是运行时。所以v-auth做不到v-if的效果
-
v-auth指令
const a = document.createElement('div') document.body.appendChild(a) document.body.remoreChild(a) // 虽然把a dom移除了但是创建的a还在 所以创建一个 v-auth函数式组件,使用插槽 控制slot显示。
-
插槽实现
思路:定义一个组件,父组件使用V-Auth如果有权限就渲染 默认插槽,否则返回空节点
const haveAuth = isAdmin
if(!haveAuth) return ()=>null
const VAuth = (props,{slots})=>{
return ()=> haveAuth ? slots.default() :null
}
3.同一个引用变化函数才执行可以写meorize函数
4.写动态组件时,如果每次都getComponent 那么由于 渲染时组件的type 看起来都一样,实际会重新执行,
5. 项目亮点
-
状态管理
有个项目起步需要用到状态管理,考虑到项目不是很大,不想加一些损耗不考虑用库,同事建议我用一个全局的ref。我想起来pinia是传入函数导出一个对象,用ref不是很合适。所以我想用闭包实现一个类似pinia的状态管理。
思路:函数执行时将结果用闭包存起来,有则返回。保证返回同一个对象。后面因为我的逻辑有打开弹窗的逻辑(CreateApp),写了很多watch的业务逻辑,组件销毁时如果每个watch都单独调stop太麻烦,所以我想到了用effectscope(true 代表隔离上下effectscope作用域互不影响,flase作关联清除) ,把函数用scope.run包一下,unMounted里调effect.stop 清除所有effect。 为什么要手动调watch因为非组件上下文中的watch不会自动销毁,比如js函数的watch或者createApp弹窗中的watch
const createGlobalState = (fn)=>{
let state;
const scope = effectScope(true)
return () => {
if(state)return state
state = scope.run(fn)
return state
}
}
const useGlobalState = (fn)=>{
const count = ref(0)
function increment() {
count.value++
}
const currentScope = getCurrentScope()
return {count, increment,stop:currentScope.stop}
}
-
实现预加载插件
6.vei的妙用和symbol带来的思考
-
invoker
常规事件换绑 document.addEventListener('click',handler) 如果patch时事件函数变成了handler2,此时需要先进行removeListener
-
dom监听事件换绑
vei是vue event invocker的缩写,用来做patch event时事件的换绑。
function createInvocker(handler){
const invocker = (e)=>{invoker.value(e)}
invocker.value = handler
}
const invoker = createInvoker(newHandler)
document.addEventListener('click',invoker) 只绑定一次即可换绑事件
vei做了存储 const invokers = el.evi??={} 通过存起来事件名和事件处理函数,
下次进来先找有没有之前的有就直接替换,没有就正常创建invocker。
思考:为什么vei会用invoker这样做呢,因为在很多场景下需要 保持同一个函数引用但支持逻辑替换
譬如上面的事件换绑,保持同一个引用才可以被remove移除掉监听,由此我想了下,定时器也可以采用invoker的模式,
-
定时器换绑
function createInvoker(val){
const invoker = (...args)=>invoker.value(...args) // 为了保持通用 是传一下参数
invoker.value = val
return invoker
}
const invoker=ceateInvoker(()=>{console.log('执行逻辑1')})
const timer = setInteval(()=> invoker,1000)
// 此时如果需要更换定时器的逻辑可以直接修改invoker.value 不要换绑时每次都清楚定时器
invoker.value = ()=>{console.log('执行逻辑2')}
unMounted(){clearInterVal(timer)}
-
eventBus.on的handler
// 初始化函数返回true
// createInvoker和上面一样就不写了
const authInvoker = createInvoker = ()=>{return true}
function getAuth(){
... 获取权限接口
authInvoker.value = (user)=>{user.row === 'admin'}
}
EventBus.on('action',authInvoker)
// 这样做下次需要更换获取权限逻辑时不需要解绑之前的on监听
-
拓展 实现可支持换绑的debounce
function createInvokerDebounce(fn,delay){
const invoker = (...args)=>{
clearTimeout(invoker._timer)
invoker._timer =setTimeout(()=>{
invoker.value(...args)
},delay)
}
invoker._timer = null // 保证结构一致,否则初始化调用 clearTimeout可能会报错
invoker.value = fn
const timer = setTimeout(()=>invoker,delay)
}
案例
const searchInvoker = createDebouncedInvoker((e) => {
console.log('搜索:', e.target.value);
}, 500);
input.addEventListener('input', searchInvoker);
// 想动态切换逻辑?只需:
searchInvoker.value = (e) => {
console.log('新逻辑:记录历史输入 →', e.target.value);
};
当然这是原生操作需要这样做,vue中onclick 事件经过了vei优化,已经具备了动态更换事件
-
symbol
这里将el.evi优化成el[eviKey] eviKey = new Symbol('evi') 原因是此属性不要显示被用户看到,不能被.访问,同时由于symbol属性不能被 for in Object.keys()拿到。相对安全(Object.getOwnPropertySymbols(obj) 依然可以拿到)Object只能用字符串和Symbol当key,所以面试时候如果问起Symbol的用法,或许此处是一个不错的回答。