一勞永逸的點擊約束解決方案

研發背景,解決什麼問題

  • 點擊約束:某個按鈕觸發一次點擊後,待接口調用有結果都才能再次觸發。避免用戶屢次點擊,觸發屢次接口調用。
  • 常規解決方案:爲每一個按鈕,定義一個變量記錄其點擊狀態,經過變量控制按鈕的可點擊狀態。如 element 庫中的<el-button type="primary" :loading="true">加載中</el-button>。經過 loading 變量控制。
  • 常規方案存在的問題:css

    • 變量冗餘,須要爲每一個按鈕定義一個變量記錄其狀態,使用成本和維護成本都比較高。
    • 兼容性不強,依賴 element 組件庫,使用方法不通用。html

  • 本文章解決方案可解決以上問題,具備如下特色:vue

    • 使用成本低,代碼粘貼複製便可,無需安裝 npm 包,僅 180 行代碼(包含 css 樣式及 js)。
    • 兼容性強,不依賴第三方庫,vue 技術盞項目都可接入。
    • 實現原理簡單,代碼無加密,無混淆。根據業務需求能夠進行定製化樣式調整。
    • 除點擊約束外,還可實現內容區的 loading 遮罩效果。jquery

在線示例

  • 頁面源碼,經過控制檯查看便可

使用方式(僅需兩步)

  • 註冊全局自定義指令(代碼量較少,且應對樣式進行定製性調整。故不提供 npm 包,直接拷貝代碼便可)ios

    Vue.directive('waiting', {
      bind: (targetDom, binding) => {
        // 注入全局方法
        (function() {
          if (window.hadResetAjaxForWaiting) { // 若是已經重置過,則再也不進入。解決開發時局部刷新致使從新加載問題
            return
          }
          window.hadResetAjaxForWaiting = true
          window.waitingAjaxMap = {} // 接口映射
    
          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.waitingAjaxMap).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) {
                  targetDomList = [...window.waitingAjaxMap[key], ...targetDomList]
                  window.waitingAjaxMap[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
        const targetUrlList = Array.isArray(binding.value) ? binding.value : [binding.value]
        targetUrlList.forEach(targetUrl => {
          window.waitingAjaxMap[targetUrl] = [targetDom, ...(window.waitingAjaxMap[targetUrl] || [])]
        })
      },
    
      // 參數變化
      update: (targetDom, binding) => {
        if (binding.oldValue !== binding.value) {
          const preTargetUrlList = Array.isArray(binding.oldValue) ? binding.oldValue : [binding.oldValue]
          preTargetUrlList.forEach(targetUrl => {
            const index = (window.waitingAjaxMap[targetUrl] || []).indexOf(targetDom)
            index > -1 && window.waitingAjaxMap[targetUrl].splice(index, 1)
          })
    
          // 添加須要監聽的接口,注入對應的 dom
          const targetUrlList = Array.isArray(binding.value) ? binding.value : [binding.value]
          targetUrlList.forEach(targetUrl => {
            window.waitingAjaxMap[targetUrl] = [targetDom, ...(window.waitingAjaxMap[targetUrl] || [])]
          })
        }
      },
    
      // 指令被卸載,消除消息監聽
      unbind: (targetDom, binding) => {
        const targetUrlList = typeof binding.value === 'object' ? binding.value : [binding.value]
        targetUrlList.forEach(targetUrl => {
          const index = window.waitingAjaxMap[targetUrl].indexOf(targetDom)
          index > -1 && window.waitingAjaxMap[targetUrl].splice(index, 1)
          if (window.waitingAjaxMap[targetUrl].length === 0) {
            delete window.waitingAjaxMap[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>web

    • v-waiting 參數格式介紹ajax

      • v-waiting="'get::loading::/mock/50/test/users'"
      • 其中「get」表示監聽的接口請求類型
      • 「loading」 標識點擊約束時的 loading 樣式。一種有三種,分別是「loading」「waiting」「disabled」。能夠不填,默認爲「waiting」。
      • 「/mock/50/test/users」標識須要監聽的接口名,本質是經過url.indexOf(targetUrl),indexOf 來進行字符串匹配。
    • 監聽多個請求npm

      • <div v-waiting="['get::waiting::/test/users?pageIndex=2', 'get::/test/users?pageIndex=1']" @click="test"></div>
      • 經過數組形式,傳入多個須要監聽的請求。
      • loading 樣式將在接口調用時顯示,直到發起請求的全部接口請求均調用完成,才消除。
      • 未發起請求的接口,即便寫在數組裏面,也不會影響。
      • 數組的第二條數據,沒有指定第二個參數的 loading 樣式,該參數是可選的,默認樣式爲「waiting」axios

實現原理

  • 重寫 「XMLHttpRequest」,實現 ajax 的底層通用性監聽,在接口發起時添加樣式,返回結果後消除。api

    • 屏蔽工具層的差別,不管使用的是 axios,仍是 jquery-ajax,仍是原生 XMLHttpRequest 都可實現監聽。
    • 經過字符串匹配來監聽不一樣的請求,url.indexOf(targetUrl) > -1
    • 經實際應用經驗,暫不考慮同名接口衝突狀況。
    • 有需求的同窗,能夠提「issues」,做者會及時反饋。
  • loading 內容的展現,經過僞類元素「::before」及「::after」來顯示。

    • 其中「::before」實現遮罩層效果,
    • 使用「::after」元素的 content 來實現「加載中...」「旋轉 loading icon」 的顯示
    • 無需插入新的 dom 元素
    • 減小對 dom 佈局的影響

源碼以下

Vue.directive('waiting', {
  bind: (targetDom, binding) => {
    // 注入全局方法
    (function() {
      if (window.hadResetAjaxForWaiting) { // 若是已經重置過,則再也不進入。解決開發時局部刷新致使從新加載問題
        return
      }
      window.hadResetAjaxForWaiting = true
      window.waitingAjaxMap = {} // 接口映射 '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.waitingAjaxMap).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) {
              targetDomList = [...window.waitingAjaxMap[key], ...targetDomList]
              window.waitingAjaxMap[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
    const targetUrlList = Array.isArray(binding.value) ? binding.value : [binding.value]
    targetUrlList.forEach(targetUrl => {
      window.waitingAjaxMap[targetUrl] = [targetDom, ...(window.waitingAjaxMap[targetUrl] || [])]
    })
  },

  // 參數變化
  update: (targetDom, binding) => {
    if (binding.oldValue !== binding.value) {
      const preTargetUrlList = Array.isArray(binding.oldValue) ? binding.oldValue : [binding.oldValue]
      preTargetUrlList.forEach(targetUrl => {
        const index = (window.waitingAjaxMap[targetUrl] || []).indexOf(targetDom)
        index > -1 && window.waitingAjaxMap[targetUrl].splice(index, 1)
      })

      // 添加須要監聽的接口,注入對應的 dom
      const targetUrlList = Array.isArray(binding.value) ? binding.value : [binding.value]
      targetUrlList.forEach(targetUrl => {
        window.waitingAjaxMap[targetUrl] = [targetDom, ...(window.waitingAjaxMap[targetUrl] || [])]
      })
    }
  },

  // 指令被卸載,消除消息監聽
  unbind: (targetDom, binding) => {
    const targetUrlList = typeof binding.value === 'object' ? binding.value : [binding.value]
    targetUrlList.forEach(targetUrl => {
      const index = window.waitingAjaxMap[targetUrl].indexOf(targetDom)
      index > -1 && window.waitingAjaxMap[targetUrl].splice(index, 1)
      if (window.waitingAjaxMap[targetUrl].length === 0) {
        delete window.waitingAjaxMap[targetUrl]
      }
    })
  }
})

注意事項

  • 因爲底層是經過僞類「::after」「::before」來填充元素的,故將會覆蓋使用 v-waiting 自定義指令的 dom 元素本來的 「::after」「::before」
  • 本文僅實現 「XMLHttpRequest」 的重載,未對「fetch」方法進行監聽,有這方面需求的同窗,歡迎提「issues」。

完結撒花,感謝閱讀。

相關文章
相關標籤/搜索