Common directives/plugins in Vue
总结一些常用的指令、插件
防重复点击
/**
* Prevent repeated clicks
* @example v-throttle="500"
*/
import type { App, Directive, DirectiveBinding } from 'vue';
import { on } from './utils';
const throttleDirective: Directive = {
beforeMount(el: Element, binding: DirectiveBinding<any>) {
let timer
const threshold = binding.value || 1000
on(el, 'click', (e) => {
if ((e as any).button !== 0) return;
if (!timer) {
timer = setTimeout(() => {
clearTimeout(timer)
timer = null
}, threshold);
} else {
e.stopImmediatePropagation()
}
}, true)
},
}
export function setupRepeatDirective(app: App) {
app.directive('throttle', throttleDirective)
}
Then We can use v-throttle
like this
<button v-throttle>Submit</button>
文本高亮
import type { App, Directive, DirectiveBinding } from 'vue'
function highlightMe(el, keyword) {
if (!keyword) return
const text = el.textContent
const kwdRegExp = new RegExp(keyword, 'gi')
el.innerHTML = text.replace(kwdRegExp, (match) => '<em class="highlight">' + match + '</em>')
}
const highlightDirective: Directive = {
mounted(el: Element, binding: DirectiveBinding<string>) {
highlightMe(el, binding.value)
},
updated(el: Element, binding: DirectiveBinding<string>) {
highlightMe(el, binding.value)
},
}
export function setupHighlightDirective(app: App) {
app.directive('highlight', highlightDirective)
}
一键复制
/**
* https://clipboardjs.com/
* @example v-copy
*/
import type { App, Directive, DirectiveBinding } from 'vue'
import Clipboard from 'clipboard'
import { Message } from './components/message'
import { on } from './utils'
const copyDirective: Directive = {
beforeMount(el: Element, binding: DirectiveBinding<any>) {
on(el, 'click', () => {
const clipboard = new Clipboard(el)
clipboard.on('success', () => {
Message.success('复制成功')
clipboard.destroy()
})
clipboard.on('error', () => {
Message.success('复制失败')
clipboard.destroy()
})
}, true)
},
}
export function setupCopyDirective(app: App) {
app.directive('copy', copyDirective)
}
Then We can use v-copy
like this
<div v-copy data-clipboard-text="you've copied me">
copy me
</div>
button-level access
需求
通常,业务需要支持按钮级别权限控制,一般情况控制显隐就足够了,但是我们这个有点麻烦,需要做到3点
disabled
置灰tooltip
显示 『无权限』『不可下载』等提示文案- 不可点击
直观的做法就是在各需要的地方,加上对应逻辑,比如
import { shouldDel } from 'xxx'
<a-tooltip :title="shouldDel() ? '删除' : '无权限'">
<span :class="shouldDel() ? '' : 'disabled'" @click="onDel">删除</span>
</a-tooltip>
<a-tooltip :title="shouldDel() ? '删除' : '无权限'">
<button :disabled="shouldDel()" @click="onDel">删除</button>
</a-tooltip>
function onDel() {
if (!shouldDel()) return
}
这种写法有几个问题
button
有disabled
属性,可以直接屏蔽点击,但是普通标签div/span
必须在事件中进行规避,缺少统一性shouldXX()
会被多次使用在html/js
中,混乱且冗余- 提示文案使用
a-tooltip
不仅 html 冗余,也和业务逻辑判断杂糅在一起
改进
这种需求用指令来封装再合适不过了,同样的功能但是只需要这样写就行
<span v-access.del="permission">删除</span>
<button v-access.edit="permission">修改</button>
其中 permission
就是业务指定的权限,当在 mounted
时候,会判断是否需要 disabled
const accessDirective: Directive = {
mounted: (el: Element, binding: DirectiveBinding<any>) => {
checkAccess(el, binding)
toggleDisableClass()
disableClick()
toggleTooltip()
},
updated: (el: Element, binding: DirectiveBinding<any>) => {
checkAccess(el, binding)
toggleDisableClass()
},
}
function checkAccess(el, binding) {
el.shouldDisable = shouldXX(binding.value)
}
指令 v-access
是怎么实现的呢?仔细分析一下这3个功能,其实最核心的是 不可点击,其他2个都可以用 css
解决
1、屏蔽点击事件DOM
本身就支持
function disableClick() {
el.addEventListener('click', () => {
if (el.shouldDisable) {
e.preventDefault()
e.stopImmediatePropagation()
e.stopPropagation()
}
}, true)
}
stopImmediatePropagation
会阻止监听同一事件的其他event handler
被调用stopPropagation
则阻止事件进一步向上冒泡e.preventDefault()
阻止标签的默认行为
2、样式置灰
写一个全局的置灰样式 .global-access-disabled
,根据权限动态修改 class
function toggleDisableClass() {
if (el.shouldDisable) {
el.classList.add('global-access-disabled')
} else {
el.classList.remove('global-access-disabled')
}
}
3、提示文案
使用原生 js
实现,其实就是动态实现 a-tooltip
,难点在于根据触发的元素设置 tooltip
的位置
function toggleTooltip() {
const { showTooltip, hideTooltip } = createTooltip()
el.addEventListener('mouseenter', () => { showTooltip(el.shouldDisable ? '无权限' : '删除') })
el.addEventListener('mouseleave', hideTooltip)
}
如何计算 tooltip
位置?首先要知道当前元素的位置,可以使用 Element.getBoundingClientRect()
获取元素的 top/left/width/height
在根据业务需要是否居中、偏左、偏上等计算
function createTooltip() {
if (!toolTipElem) {
toolTipElem = document.createElement('span')
document.body.appendChild(toolTipElem)
toolTipElem.style.position = 'absolute'
}
function showTooltip(t: string) {
if (toolTipElem) {
toolTipElem.textContent = t
toolTipElem.style.display = 'block'
// 计算位置
setPosition()
}
}
function hideTooltip() {
toolTipElem.style.display = 'none'
}
function setPosition() {
const { left, top, width, height } = el.getBoundingClientRect()
let tipTop = 0, tipLeft = 0
if (placement == 'top') { // 上方居中
tipTop = top
tipLeft = left + width / 2
}
toolTipElem!.style.top = tipTop + 'px'
toolTipElem!.style.left = tipLeft + 'px'
toolTipElem!.classList.add(`global-access-tooltip-${placement}`)
}
return {
showTooltip,
hideTooltip,
}
}
懒加载
按需加载不仅仅是指图片,任何资源如js、css都可以懒加载。比如只有1、2个页面才有上传的功能,那么可以按需加载对应的依赖库(比如ali-oss)。
图片
使用最新的 intersectionObserver API
,如果不支持,全部加载不考虑降级(业务不需要,面向 chrome
开发)
let enableWebp
let previewHost
class Lazyload {
io: any
constructor() {
this.io = this.initObserve()
}
initObserve() {
if (!intersectionObserverEnabled) {
return
}
return new IntersectionObserver(entries => {
entries.forEach(item => {
const elem = item.target as HTMLElement
if (item.isIntersecting) {
this.load(elem)
.then(() => {
this.io.unobserve(elem)
})
.catch(() => {
console.log('loading image error');
})
}
})
})
}
observe(elem: HTMLElement) {
if (!elem.getAttribute('data-src')) return
if (this.io) {
this.io.observe(elem)
} else {
this.load(elem)
}
}
load(elem: HTMLElement) {
return new Promise<void>((resolve, reject) => {
let src = elem.getAttribute('data-src')
if (!src) return resolve()
const isImageNode = elem.nodeName.toLowerCase() === 'img'
const img = isImageNode ? elem : new Image()
// 使用 webp 的话
// @ts-ignore
const webpUrl = enableWebp || elem.webp ? (webpSupported() ? src + webpExt : src) : src
on(img, 'load', () => {
if (!isImageNode) {
// @ts-ignore
elem.style['background-image'] = 'url(' + webpUrl + ')'
}
elem.removeAttribute('data-src')
resolve()
})
on(img, 'error', () => {
elem.removeAttribute('src')
reject()
})
img.setAttribute('src', webpUrl)
})
}
}
let lazy
const lazyDirective: Directive = {
beforeMount() {
if (!lazy) {
lazy = new Lazyload()
}
},
mounted(el: HTMLElement, binding: DirectiveBinding<any>) {
lazy.observe(el)
},
updated(el, binding: DirectiveBinding<any>) {
lazy.observe(el)
},
beforeUnmount() {
lazy.io?.disconnect()
}
}
js
正如之前提到上传功能,
- 只有用户使用了上传才加载额外的第三方库
- 上传本身就会
loading
,所以延迟加载不会有什么体验问题
const customCache = new Set()
function loadScriptFromRemote() {
if (customCache.has(scriptUrl)) {
return resolve()
}
const script = document.createElement('script')
script.setAttribute('src', scriptUrl)
script.onload = () => {
resolve()
}
script.onerror = () => {
reject()
}
customCache.add(scriptUrl)
document.body.appendChild(script)
}
let url = ''
const remoteJSDirective: Directive = {
beforeMount(el: Element, binding: DirectiveBinding<any>) {
loadScriptFromRemote(url)
},
}
export default {
install: (app: App, options) => {
// TODO: 可以扩展到 url collections
if (!options?.url) {
return console.error('url is required')
}
url = options?.url
app.directive('v-remote', remoteJSDirective)
}
}