一劳永逸的点击约束解决方案
研发背景,解决什么问题
- 点击约束:某个按钮触发一次点击后,待接口调用有结果都才能再次触发。避免用户多次点击,触发多次接口调用。
- 常规解决方案:为每个按钮,定义一个变量记录其点击状态,通过变量控制按钮的可点击状态。如 element 库中的
<el-button type="primary" :loading="true">加载中</el-button>
。通过 loading 变量控制。 常规方案存在的问题:
- 变量冗余,需要为每个按钮定义一个变量记录其状态,使用成本和维护成本都比较高。
兼容性不强,依赖 element 组件库,使用方法不通用。
本文章解决方案可解决以上问题,具有以下特点:
- 使用成本低,代码粘贴复制即可,无需安装 npm 包,仅 180 行代码(包含 css 样式及 js)。
- 兼容性强,不依赖第三方库,
vue 技术盏项目均可接入。与技术盏无关,前端项目均可使用。 - 实现原理简单,代码无加密,无混淆。根据业务需求可以进行定制化样式调整。
- 除点击约束外,还可实现内容区的 loading 遮罩效果。
支持正则匹配,实现数值匹配,精准匹配等功能
VUE 自定义指令版 在线示例
- 页面源码,通过控制台查看即可
架构无关,原生 JS版 在线示例
- 页面源码,通过控制台查看即可
VUE2 + Element UI 定制版
- 使用 Element UI 默认样式,更加美观大方
VUE3 + Element Plus 定制版
- 使用 Element Plus 默认样式,更加美观大方
原生 JS 版,使用方式(仅需两步)
在所有 js 库之前引入文件
https://blog.luckly-mjw.cn/tool-show/vue-loading/dom-loading.js
- 推荐下载到本地保存
- 在所有 js 库之前引入是为了监听 XMLHttpRequest
在目标 dom 上,添加 data-loading 属性
<div class="btn" data-loading="'get::loading::http://yapi.luckly-mjw.cn/mock/50/test/users?pageIndex=1'">发送单个请求 loading</div>
data-loading 参数格式介绍
data-loading="'get::loading::/mock/50/test/users'"
- 其中「get」表示监听的接口请求类型
- 「loading」 标识点击约束时的 loading 样式。一种有三种,分别是「loading」「waiting」「disabled」。可以不填,默认为「waiting」。
- 「/mock/50/test/users」标识需要监听的接口名,本质是通过
url.indexOf(targetUrl)
,indexOf 来进行字符串匹配。 - 支持数字正则匹配,「/mock/\\d+/test/users」等价于「/mock/50/test/users」
监听多个请求
<div data-loading="['get::waiting::/test/users?pageIndex=2', 'get::/test/users?pageIndex=1']"></div>
- 通过数组形式,传入多个需要监听的请求。
- loading 样式将在接口调用时显示,直到发起请求的所有接口请求均调用完成,才消除。
- 未发起请求的接口,即使写在数组里面,也不会影响。
- 数组的第二条数据,没有指定第二个参数的 loading 样式,该参数是可选的,默认样式为「waiting」
ajax 路径正则匹配
- 【数值匹配】「/mock/\\d+/test/users」,其中「\\d+」为匹配多个数值,「/mock/\\d+/test/users」可匹配「/mock/50/test/users」
- 【精确匹配】通过在结尾添加「$」标识结束,如「/mock/50/test$」将忽略 「/mock/50/test/users」的匹配。只有「/mock/50/test」才会命中匹配
传入类似 axios 的请求配置函数
<div class="btn" v-waiting="importAxiosFun" @click="ajaxFromImport">传递请求函数</div>
- 底层实现原理是,调用 Function.prototype.toString() 获取请求配置函数的源码,解析函数中的请求配置参数
- 适合将接口请求配置独立抽离管理的项目,屏蔽具体的请求 URL 路径
- 【特别注意】由于 Function.prototype.toString 限制,this 被 bind 修改过的函数无法解析。例如,写在 vue methods 中定义的函数,toString 得到的只是
function () { [native code] }
,无法获取函数源代码。但一般接口管理函数不会存在该问题。 原生 JS 版暂不支持该功能
VUE 自定义指令版,使用方式(仅需两步)
注册全局自定义指令(代码量较少,且应对样式进行定制性调整。故不提供 npm 包,直接拷贝代码即可)
/** 核心代码,监听 ajax,自动为匹配到的 dom 元素添加点击约束 **/ // eslint-disable-next-line // <div id="1" v-waiting="['get::waiting::/test/users?pageIndex=2', 'get::/test/users?pageIndex=1']" @click="test"></div> // <div id="2" v-waiting="'get::loading::http://yapi.luckly-mjw.cn/mock/50/test/users?pageIndex=2'" @click="test">copy</div> // <div id="3" v-waiting="'get::disable::http://yapi.luckly-mjw.cn/mock/50/test/users?pageIndex=2'" @click="test">copy</div> // <div v-waiting="userApi.postLogin" @click="test1">兼容传入 axios 请求函数</div> // 兼容转换传入的函数,转化为 URL 字符串 function cmptFunStrToUrl(targetList) { targetList = Array.isArray(targetList) ? targetList : [targetList] // 兼容转化为数组 return targetList.map(targetItem => { if (typeof targetItem === 'function') { // 如果传入的是函数 const funStr = targetItem.toString() // 将函数转化为字符串,进行解析 if (funStr === 'function () { [native code] }') { throw new Error(`点击约束,因 Function.prototype.toString 限制,this 被 bind 修改过的函数无法解析, 请显式输入 url 字符串。 ${targetItem.name},详情可参考 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function/toString`) } const [, method, apiURL] = (funStr.match(/\.(get|post|delete|put|patch)\(['"`]([^'"`]+)/) || []) if (!method || !apiURL) { throw new Error(`点击约束,传入的函数解析失败, ${targetItem.name}`) } return `${method}::${apiURL}` } return targetItem }) } Vue.directive('waiting', { bind: (targetDom, binding) => { // 注入全局方法 (function() { if (window.hadResetAjaxForWaiting) { // 如果已经重置过,则不再进入。解决开发时局部刷新导致重新加载问题 return } window.hadResetAjaxForWaiting = true window.waittingAjaxMap = {} // 接口映射 'get::http://yapi.luckly-mjw.cn/mock/50/test/users?pageIndex=1': dom let OriginXHR = window.XMLHttpRequest let originOpen = OriginXHR.prototype.open // 重置 XMLHttpRequest window.XMLHttpRequest = function() { let targetDomList = [] // 存储本 ajax 请求,影响到的 dom 元素 let realXHR = new OriginXHR() // 重置操作函数,获取请求数据 realXHR.open = function(method, url, asyn) { Object.keys(window.waittingAjaxMap).forEach(key => { let [targetMethod, type, targetUrl] = key.split('::') if (!targetUrl) { // 设置默认类型 targetUrl = type type = 'v-waiting-waiting' } else { // 指定类型 type = `v-waiting-${type}` } if ( targetMethod.toLocaleLowerCase() === method.toLocaleLowerCase() && (url.indexOf(targetUrl) > -1 || new RegExp(targetUrl).test(url)) ) { targetDomList = [...window.waittingAjaxMap[key], ...targetDomList] window.waittingAjaxMap[key].forEach(dom => { if (!dom.classList.contains(type)) { dom.classList.add('v-waiting', type) if (window.getComputedStyle(dom).position === 'static') { // 如果是 static 定位,则修改为 relative,为伪类的绝对定位做准备 dom.style.position = 'relative' } } dom.waitingAjaxNum = dom.waitingAjaxNum || 0 // 不使用 dataset,是应为 dataset 并不实时,在同一个时间内,上一次存储的值不能被保存 dom.waitingAjaxNum++ }) } }) originOpen.call(realXHR, method, url, asyn) } // 监听加载完成,清除 waiting realXHR.addEventListener('loadend', () => { targetDomList.forEach(dom => { dom.waitingAjaxNum-- dom.waitingAjaxNum === 0 && dom.classList.remove( 'v-waiting', 'v-waiting-loading', 'v-waiting-waiting', 'v-waiting-disable', ) }) }, false) return realXHR } })(); // 注入全局 css (() => { if (!document.getElementById('v-waiting')) { let code = ` .v-waiting { pointer-events: none; /*cursor: not-allowed; 与 pointer-events: none 互斥,设置 pointer-events: none 后,设置鼠标样式无效 */ } .v-waiting::before { position: absolute; content: ''; left: 0; top: 0; width: 100%; height: 100%; opacity: 0.7; z-index: 9999; background-color: #ffffff; } .v-waiting-waiting::after { position: absolute; content: '数据加载中'; top: 50%; left: 0; width: 100%; max-width: 100vw; color: #666666; font-size: 20px; text-align: center; transform: translateY(-50%); z-index: 9999; animation: v-waiting-v-waiting-keyframes 1.8s infinite linear; } @-webkit-keyframes v-waiting-v-waiting-keyframes { 20% { content: '数据加载中.'; } 40% { content: '数据加载中..'; } 60% { content: '数据加载中...'; } 80% { content: '数据加载中...'; } } .v-waiting-loading::after { position: absolute; content: ''; left: 50%; top: 50%; width: 30px; height: 30px; z-index: 9999; cursor: not-allowed; animation: v-waiting-v-loading-keyframes 1.1s infinite linear; background-position: center; background-size: 30px 30px; background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAWlBMVEUAAABmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZJqDNWAAAAHXRSTlMAgOKKPykV1K5JDbIf9OzNvGHGpZ5lNi8Hl3RVbc989bAAAAE8SURBVEjH5ZRZcsMgEEQR2li0WbuXvv81k5ARTllAQZV/Un5fAnWbYdwj9iaKXM9Zgr7EDzxav9cw5LGGB4gq0iBArEFZtTb0lIEoQ3oNoN/MoyQ93wP62lb9rOnil9sqxO9y4YCW9mXfnxo2gVC0sannyxZoq9MN/PdsXPs56WtPm8dTT8lwYy5W6YiPadOdxbM/RL6x/4sqk+SNBupb0jxS0sLITNp5NJhlOJ4ZJSVmgiub/gLEENKTrPh7QvjaqgPQmcyPMLSBXFDYaup+fZwWRhXKNmDsppJ9InLu9JKgzwL/9jLPp2iu8Gf2jm+ml80rGbg7ducPygCi8MQOmfuEznuCfLkXGa40tTkf7E/mVKuzJtLT4nBw7piuS9/abXGUHQuHQaQapmiDTiyJWt8rFu8YWy4q9g6+AGYbJ4l/4YQUAAAAAElFTkSuQmCC); } @-webkit-keyframes v-waiting-v-loading-keyframes { from { transform: translate(-50%, -50%) rotate(0deg); } to { transform: translate(-50%, -50%) rotate(360deg); } } ` let style = document.createElement('style') style.id = 'v-waiting' style.type = 'text/css' style.rel = 'stylesheet' style.appendChild(document.createTextNode(code)) let head = document.getElementsByTagName('head')[0] head.appendChild(style) } })() // 添加需要监听的接口,注入对应的 dom /* postLogin(body) { return api.post('/api/operation/user/login', body) } 则传入 postLogin,会自动解析该函数的配置 <div v-waiting="userApi.postLogin" @click="test1">兼容传入 axios 请求函数</div> */ const targetUrlList = cmptFunStrToUrl(binding.value) targetUrlList.forEach(targetUrl => { window.waittingAjaxMap[targetUrl] = [targetDom, ...(window.waittingAjaxMap[targetUrl] || [])] }) }, // 参数变化 update: (targetDom, binding) => { if (binding.oldValue !== binding.value) { const preTargetUrlList = cmptFunStrToUrl(binding.oldValue) preTargetUrlList.forEach(targetUrl => { const index = (window.waittingAjaxMap[targetUrl] || []).indexOf(targetDom) index > -1 && window.waittingAjaxMap[targetUrl].splice(index, 1) }) // 添加需要监听的接口,注入对应的 dom const targetUrlList = cmptFunStrToUrl(binding.value) targetUrlList.forEach(targetUrl => { window.waittingAjaxMap[targetUrl] = [targetDom, ...(window.waittingAjaxMap[targetUrl] || [])] }) } }, // 指令被卸载,消除消息监听 unbind: (targetDom, binding) => { const targetUrlList = typeof binding.value === 'object' ? binding.value : [binding.value] targetUrlList.forEach(targetUrl => { const index = window.waittingAjaxMap[targetUrl].indexOf(targetDom) index > -1 && window.waittingAjaxMap[targetUrl].splice(index, 1) if (window.waittingAjaxMap[targetUrl].length === 0) { delete window.waittingAjaxMap[targetUrl] } }) } })
在目标 dom 上,添加 v-waiting 属性
<div class="btn" v-waiting="'get::loading::http://yapi.luckly-mjw.cn/mock/50/test/users?pageIndex=1'" @click="ajaxSingleTest">发送单个请求 loading</div>
v-waiting 参数格式介绍
v-waiting="'get::loading::/mock/50/test/users'"
- 其中「get」表示监听的接口请求类型
- 「loading」 标识点击约束时的 loading 样式。一种有三种,分别是「loading」「waiting」「disabled」。可以不填,默认为「waiting」。
- 「/mock/50/test/users」标识需要监听的接口名,本质是通过
url.indexOf(targetUrl)
,indexOf 来进行字符串匹配。 - 支持数字正则匹配,「/mock/\\d+/test/users」等价于「/mock/50/test/users」
监听多个请求
<div v-waiting="['get::waiting::/test/users?pageIndex=2', 'get::/test/users?pageIndex=1']" @click="test"></div>
- 通过数组形式,传入多个需要监听的请求。
- loading 样式将在接口调用时显示,直到发起请求的所有接口请求均调用完成,才消除。
- 未发起请求的接口,即使写在数组里面,也不会影响。
- 数组的第二条数据,没有指定第二个参数的 loading 样式,该参数是可选的,默认样式为「waiting」
ajax 路径正则匹配
- 【数值匹配】「/mock/\\d+/test/users」,其中「\\d+」为匹配多个数值,「/mock/\\d+/test/users」可匹配「/mock/50/test/users」
- 【精确匹配】通过在结尾添加「$」标识结束,如「/mock/50/test$」将忽略 「/mock/50/test/users」的匹配。只有「/mock/50/test」才会命中匹配
实现原理
重写 「XMLHttpRequest」,实现 ajax 的底层通用性监听,在接口发起时添加样式,返回结果后消除。
- 屏蔽工具层的差异,无论使用的是 axios,还是 jquery-ajax,还是原生 XMLHttpRequest 均可实现监听。
- 通过字符串匹配来监听不同的请求,
url.indexOf(targetUrl) > -1
。 - 经实际应用经验,暂不考虑同名接口冲突情况。
- 有需求的同学,可以提「issues」,作者会及时反馈。
loading 内容的展示,通过伪类元素「::before」及「::after」来显示。
- 其中「::before」实现遮罩层效果,
- 使用「::after」元素的 content 来实现「加载中...」「旋转 loading icon」 的显示
- 无需插入新的 dom 元素
减少对 dom 布局的影响
原生 JS版,源码如下
// 注入全局方法
(function() {
if (window.hadResetAjaxForWaiting) { // 如果已经重置过,则不再进入。解决开发时局部刷新导致重新加载问题
return
}
window.hadResetAjaxForWaiting = true
window.waittingAjaxMap = {} // 接口映射 'get::http://yapi.luckly-mjw.cn/mock/50/test/users?pageIndex=1': dom
let OriginXHR = window.XMLHttpRequest
let originOpen = OriginXHR.prototype.open
let isSameSpace = false // 是否在同一个宏任务中,避免频繁触发
// 检测使用到的 dom 对象
function checkDom() {
if (!isSameSpace) { // 节流
isSameSpace = true
window.waittingAjaxMap = {} // 重置为空,重新寻找匹配的 dom
const domList = document.querySelectorAll('[data-loading]')
Array.prototype.forEach.call(domList, targetDom => {
targetDom.dataset.loading.split(',').forEach(targetUrl => {
targetUrl = targetUrl
.replace(/['"[\]]/ig, '') // 去除冗余字符
.replace(/\\\\/ig, '\\').trim() // 将双反斜杠转为单反斜杠,适配原生模式正则匹配
window.waittingAjaxMap[targetUrl] = [targetDom, ...(window.waittingAjaxMap[targetUrl] || [])]
})
})
setTimeout(() => isSameSpace = false) // 下一个宏任务中,重新开放该方法
}
}
// 重置 XMLHttpRequest
window.XMLHttpRequest = function() {
let targetDomList = [] // 存储本 ajax 请求,影响到的 dom 元素
let realXHR = new OriginXHR() // 重置操作函数,获取请求数据
realXHR.open = function(method, url) {
checkDom()
Object.keys(window.waittingAjaxMap).forEach(key => {
let [targetMethod, type, targetUrl] = key.split('::')
if (!targetUrl) { // 设置默认类型
targetUrl = type
type = 'v-waiting-waiting'
} else { // 指定类型
type = `v-waiting-${type}`
}
if (
targetMethod.toLocaleLowerCase() === method.toLocaleLowerCase()
&& (url.indexOf(targetUrl) > -1 || new RegExp(targetUrl).test(url))
) {
targetDomList = [...window.waittingAjaxMap[key], ...targetDomList]
window.waittingAjaxMap[key].forEach(dom => {
if (!dom.classList.contains(type)) {
dom.classList.add('v-waiting', type)
if (window.getComputedStyle(dom).position === 'static') { // 如果是 static 定位,则修改为 relative,为伪类的绝对定位做准备
dom.style.position = 'relative'
}
}
dom.waitingAjaxNum = dom.waitingAjaxNum || 0 // 不使用 dataset,是应为 dataset 并不实时,在同一个时间内,上一次存储的值不能被保存
dom.waitingAjaxNum++
})
}
})
console.log(url)
originOpen.call(realXHR, method, url)
}
// 监听加载完成,清除 waiting
realXHR.addEventListener('loadend', () => {
targetDomList.forEach(dom => {
dom.waitingAjaxNum--
dom.waitingAjaxNum === 0 && dom.classList.remove(
'v-waiting',
'v-waiting-loading',
'v-waiting-waiting',
'v-waiting-disable',
)
})
}, false)
return realXHR
}
})();
// 注入全局 css
(() => {
if (!document.getElementById('v-waiting')) {
let code = `
.v-waiting {
pointer-events: none;
/*cursor: not-allowed; 与 pointer-events: none 互斥,设置 pointer-events: none 后,设置鼠标样式无效 */
}
.v-waiting::before {
position: absolute;
content: '';
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: 0.7;
z-index: 9999;
background-color: #ffffff;
}
.v-waiting-waiting::after {
position: absolute;
content: '数据加载中';
top: 50%;
left: 0;
width: 100%;
max-width: 100vw;
color: #666666;
font-size: 20px;
text-align: center;
transform: translateY(-50%);
z-index: 9999;
animation: v-waiting-v-waiting-keyframes 1.8s infinite linear;
}
@-webkit-keyframes v-waiting-v-waiting-keyframes {
20% {
content: '数据加载中.';
}
40% {
content: '数据加载中..';
}
60% {
content: '数据加载中...';
}
80% {
content: '数据加载中...';
}
}
.v-waiting-loading::after {
position: absolute;
content: '';
left: 50%;
top: 50%;
width: 30px;
height: 30px;
z-index: 9999;
cursor: not-allowed;
animation: v-waiting-v-loading-keyframes 1.1s infinite linear;
background-position: center;
background-size: 30px 30px;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAWlBMVEUAAABmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZJqDNWAAAAHXRSTlMAgOKKPykV1K5JDbIf9OzNvGHGpZ5lNi8Hl3RVbc989bAAAAE8SURBVEjH5ZRZcsMgEEQR2li0WbuXvv81k5ARTllAQZV/Un5fAnWbYdwj9iaKXM9Zgr7EDzxav9cw5LGGB4gq0iBArEFZtTb0lIEoQ3oNoN/MoyQ93wP62lb9rOnil9sqxO9y4YCW9mXfnxo2gVC0sannyxZoq9MN/PdsXPs56WtPm8dTT8lwYy5W6YiPadOdxbM/RL6x/4sqk+SNBupb0jxS0sLITNp5NJhlOJ4ZJSVmgiub/gLEENKTrPh7QvjaqgPQmcyPMLSBXFDYaup+fZwWRhXKNmDsppJ9InLu9JKgzwL/9jLPp2iu8Gf2jm+ml80rGbg7ducPygCi8MQOmfuEznuCfLkXGa40tTkf7E/mVKuzJtLT4nBw7piuS9/abXGUHQuHQaQapmiDTiyJWt8rFu8YWy4q9g6+AGYbJ4l/4YQUAAAAAElFTkSuQmCC);
}
@-webkit-keyframes v-waiting-v-loading-keyframes {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
} `
let style = document.createElement('style')
style.id = 'v-waiting'
style.type = 'text/css'
style.rel = 'stylesheet'
style.appendChild(document.createTextNode(code))
let head = document.getElementsByTagName('head')[0]
head.appendChild(style)
}
})()
VUE 自定义指令版,源码如下
/** 核心代码,监听 ajax,自动为匹配到的 dom 元素添加点击约束 **/
// eslint-disable-next-line
// <div id="1" v-waiting="['get::waiting::/test/users?pageIndex=2', 'get::/test/users?pageIndex=1']" @click="test"></div>
// <div id="2" v-waiting="'get::loading::http://yapi.luckly-mjw.cn/mock/50/test/users?pageIndex=2'" @click="test">copy</div>
// <div id="3" v-waiting="'get::disable::http://yapi.luckly-mjw.cn/mock/50/test/users?pageIndex=2'" @click="test">copy</div>
// <div v-waiting="userApi.postLogin" @click="test1">兼容传入 axios 请求函数</div>
// 兼容转换传入的函数,转化为 URL 字符串
function cmptFunStrToUrl(targetList) {
targetList = Array.isArray(targetList) ? targetList : [targetList] // 兼容转化为数组
return targetList.map(targetItem => {
if (typeof targetItem === 'function') { // 如果传入的是函数
const funStr = targetItem.toString() // 将函数转化为字符串,进行解析
if (funStr === 'function () { [native code] }') {
throw new Error(`点击约束,因 Function.prototype.toString 限制,this 被 bind 修改过的函数无法解析, 请显式输入 url 字符串。 ${targetItem.name},详情可参考 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function/toString`)
}
const [, method, apiURL] = (funStr.match(/\.(get|post|delete|put|patch)\(['"`]([^'"`]+)/) || [])
if (!method || !apiURL) {
throw new Error(`点击约束,传入的函数解析失败, ${targetItem.name}`)
}
return `${method}::${apiURL}`
}
return targetItem
})
}
Vue.directive('waiting', {
bind: (targetDom, binding) => {
// 注入全局方法
(function() {
if (window.hadResetAjaxForWaiting) { // 如果已经重置过,则不再进入。解决开发时局部刷新导致重新加载问题
return
}
window.hadResetAjaxForWaiting = true
window.waittingAjaxMap = {} // 接口映射 'get::http://yapi.luckly-mjw.cn/mock/50/test/users?pageIndex=1': dom
let OriginXHR = window.XMLHttpRequest
let originOpen = OriginXHR.prototype.open
// 重置 XMLHttpRequest
window.XMLHttpRequest = function() {
let targetDomList = [] // 存储本 ajax 请求,影响到的 dom 元素
let realXHR = new OriginXHR() // 重置操作函数,获取请求数据
realXHR.open = function(method, url, asyn) {
Object.keys(window.waittingAjaxMap).forEach(key => {
let [targetMethod, type, targetUrl] = key.split('::')
if (!targetUrl) { // 设置默认类型
targetUrl = type
type = 'v-waiting-waiting'
} else { // 指定类型
type = `v-waiting-${type}`
}
if (
targetMethod.toLocaleLowerCase() === method.toLocaleLowerCase()
&& (url.indexOf(targetUrl) > -1 || new RegExp(targetUrl).test(url))
) {
targetDomList = [...window.waittingAjaxMap[key], ...targetDomList]
window.waittingAjaxMap[key].forEach(dom => {
if (!dom.classList.contains(type)) {
dom.classList.add('v-waiting', type)
if (window.getComputedStyle(dom).position === 'static') { // 如果是 static 定位,则修改为 relative,为伪类的绝对定位做准备
dom.style.position = 'relative'
}
}
dom.waitingAjaxNum = dom.waitingAjaxNum || 0 // 不使用 dataset,是应为 dataset 并不实时,在同一个时间内,上一次存储的值不能被保存
dom.waitingAjaxNum++
})
}
})
originOpen.call(realXHR, method, url, asyn)
}
// 监听加载完成,清除 waiting
realXHR.addEventListener('loadend', () => {
targetDomList.forEach(dom => {
dom.waitingAjaxNum--
dom.waitingAjaxNum === 0 && dom.classList.remove(
'v-waiting',
'v-waiting-loading',
'v-waiting-waiting',
'v-waiting-disable',
)
})
}, false)
return realXHR
}
})();
// 注入全局 css
(() => {
if (!document.getElementById('v-waiting')) {
let code = `
.v-waiting {
pointer-events: none;
/*cursor: not-allowed; 与 pointer-events: none 互斥,设置 pointer-events: none 后,设置鼠标样式无效 */
}
.v-waiting::before {
position: absolute;
content: '';
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: 0.7;
z-index: 9999;
background-color: #ffffff;
}
.v-waiting-waiting::after {
position: absolute;
content: '数据加载中';
top: 50%;
left: 0;
width: 100%;
max-width: 100vw;
color: #666666;
font-size: 20px;
text-align: center;
transform: translateY(-50%);
z-index: 9999;
animation: v-waiting-v-waiting-keyframes 1.8s infinite linear;
}
@-webkit-keyframes v-waiting-v-waiting-keyframes {
20% {
content: '数据加载中.';
}
40% {
content: '数据加载中..';
}
60% {
content: '数据加载中...';
}
80% {
content: '数据加载中...';
}
}
.v-waiting-loading::after {
position: absolute;
content: '';
left: 50%;
top: 50%;
width: 30px;
height: 30px;
z-index: 9999;
cursor: not-allowed;
animation: v-waiting-v-loading-keyframes 1.1s infinite linear;
background-position: center;
background-size: 30px 30px;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAWlBMVEUAAABmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZJqDNWAAAAHXRSTlMAgOKKPykV1K5JDbIf9OzNvGHGpZ5lNi8Hl3RVbc989bAAAAE8SURBVEjH5ZRZcsMgEEQR2li0WbuXvv81k5ARTllAQZV/Un5fAnWbYdwj9iaKXM9Zgr7EDzxav9cw5LGGB4gq0iBArEFZtTb0lIEoQ3oNoN/MoyQ93wP62lb9rOnil9sqxO9y4YCW9mXfnxo2gVC0sannyxZoq9MN/PdsXPs56WtPm8dTT8lwYy5W6YiPadOdxbM/RL6x/4sqk+SNBupb0jxS0sLITNp5NJhlOJ4ZJSVmgiub/gLEENKTrPh7QvjaqgPQmcyPMLSBXFDYaup+fZwWRhXKNmDsppJ9InLu9JKgzwL/9jLPp2iu8Gf2jm+ml80rGbg7ducPygCi8MQOmfuEznuCfLkXGa40tTkf7E/mVKuzJtLT4nBw7piuS9/abXGUHQuHQaQapmiDTiyJWt8rFu8YWy4q9g6+AGYbJ4l/4YQUAAAAAElFTkSuQmCC);
}
@-webkit-keyframes v-waiting-v-loading-keyframes {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
} `
let style = document.createElement('style')
style.id = 'v-waiting'
style.type = 'text/css'
style.rel = 'stylesheet'
style.appendChild(document.createTextNode(code))
let head = document.getElementsByTagName('head')[0]
head.appendChild(style)
}
})()
// 添加需要监听的接口,注入对应的 dom
/*
postLogin(body) {
return api.post('/api/operation/user/login', body)
}
则传入 postLogin,会自动解析该函数的配置
<div v-waiting="userApi.postLogin" @click="test1">兼容传入 axios 请求函数</div>
*/
const targetUrlList = cmptFunStrToUrl(binding.value)
targetUrlList.forEach(targetUrl => {
window.waittingAjaxMap[targetUrl] = [targetDom, ...(window.waittingAjaxMap[targetUrl] || [])]
})
},
// 参数变化
update: (targetDom, binding) => {
if (binding.oldValue !== binding.value) {
const preTargetUrlList = cmptFunStrToUrl(binding.oldValue)
preTargetUrlList.forEach(targetUrl => {
const index = (window.waittingAjaxMap[targetUrl] || []).indexOf(targetDom)
index > -1 && window.waittingAjaxMap[targetUrl].splice(index, 1)
})
// 添加需要监听的接口,注入对应的 dom
const targetUrlList = cmptFunStrToUrl(binding.value)
targetUrlList.forEach(targetUrl => {
window.waittingAjaxMap[targetUrl] = [targetDom, ...(window.waittingAjaxMap[targetUrl] || [])]
})
}
},
// 指令被卸载,消除消息监听
unbind: (targetDom, binding) => {
const targetUrlList = typeof binding.value === 'object' ? binding.value : [binding.value]
targetUrlList.forEach(targetUrl => {
const index = window.waittingAjaxMap[targetUrl].indexOf(targetDom)
index > -1 && window.waittingAjaxMap[targetUrl].splice(index, 1)
if (window.waittingAjaxMap[targetUrl].length === 0) {
delete window.waittingAjaxMap[targetUrl]
}
})
}
})
注意事项
- 由于底层是通过伪类「::after」「::before」来填充元素的,故将会覆盖使用 v-waiting 自定义指令的 dom 元素原本的 「::after」「::before」
- 本文仅实现 「XMLHttpRequest」 的重载,未对「fetch」方法进行监听,有这方面需求的同学,欢迎提「issues」。
通用版本(与框架无关,vue,react,jquery 项目均可)
- 属性名改为
data-loading
- react
data-loading={'get::disable::user/ownCompany'}
- 普通
data-loading='get::disable::user/ownCompany'
- 使用方式,在 index.html 中提前加载
dom-loading.js
文件即可
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。