仲灏小栈 仲灏小栈
首页
大前端
后端&运维
其他技术
生活
关于我
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

仲灏

诚意, 正心, 格物, 致知
首页
大前端
后端&运维
其他技术
生活
关于我
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • 《前端项目基础建设》
  • HTML&CSS

  • JavaScript&TypeScript

    • typescript 万金油
    • 缓缓滚动到顶部实现方式
    • js 精准和模糊获取dom
    • iframe 笔记
    • 文件上传安全问题
    • 时间处理
    • Web H5 接入海康录像机 (rtsp转flv)
    • 全局数据
    • 持久化数据
    • typescript 类型库
    • type
    • 前端开发这些年碰到不常见的api(不论新旧 持续更新)
    • js api常用详解
    • 搭建typescript环境刷LeetCode算法
    • typescript 类型联动
    • 前端避免一直try...catch...(ts装饰器)
    • 高效操作DOM
    • 用好DOM事件
      • 理解 AST 实现和编译原理
      • 常用正则
      • js面试技能拼图 this
      • for...in for...of 区别
      • js 获取元素大小
    • Node

    • 构建

    • Vue

    • React

    • 小程序

    • 跨端

    • Electron

    • WebGL&GIS

    • 浏览器

    • 面经

    • 其他

    • 大前端
    • JavaScript&TypeScript
    仲灏
    2020-11-01
    目录

    用好DOM事件

    # 防抖

    const ipt = document.querySelector('input')
    ipt.addEventListener('input', e => {
      search(e.target.value).then(resp => {
        // ...
      }, e => {
        // ...
      })
    })
    
    1
    2
    3
    4
    5
    6
    7
    8

    但其实这样的写法很容易造成性能问题。每一次输入都会触发搜索, 实际上,只有最后一次搜索结果是用户想要的,前面进行了多次无效查询,浪费了网络带宽和服务器资源。

    所以对于这类连续触发的事件,需要添加一个**“防抖”功能**,为函数的执行设置一个合理的时间间隔,避免事件在时间间隔内频繁触发,同时又保证用户输入后能即时看到搜索结果。

    要实现这样一个功能我们很容易想到使用 setTimeout() 函数来让函数延迟执行。就像下面的伪代码,当每次调用函数时,先判断 timeout 实例是否存在,如果存在则销毁,然后创建一个新的定时器。

    // 代码1
    const ipt = document.querySelector('input')
    let timeout = null
    ipt.addEventListener('input', e => {
      if(timeout) {
        clearTimeout(timeout)
        timeout = null
      }
      timeout = setTimeout(() => {
        search(e.target.value).then(resp => {
          // ...
        }, e => {
          // ...
        })
      }, 500)
    })
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16

    问题确实是解决了,但这并不是最优答案,或者说我们需对这个防抖操作进行一些“优化”。

    试想一下,如果另一个搜索框也需要添加防抖,是不是也要把 timeout 相关的代码再编写一次?而其实这个操作是完全可以抽取成公共函数的。

    在抽取成公共函数的同时,还需要考虑更复杂的情况:

    • 参数和返回值如何传递?
    • 防抖化之后的函数是否可以立即执行?
    • 防抖化的函数是否可以手动取消?
    • 等等

    具体代码如下所示

    // 代码2
    const debounce = (func, wait = 0) => {
      let timeout = null
      let args
      function debounced(...arg) {
        args = arg
        if(timeout) {
          clearTimeout(timeout)
          timeout = null
        }
        // 以Promise的形式返回函数执行结果
        return new Promise((res, rej) => {
          timeout = setTimeout(async () => {
            try {
              const result = await func.apply(this, args)
              res(result)
            } catch(e) {
              rej(e)
            }
          }, wait)
        })
      }
      // 允许取消
      function cancel() {
        clearTimeout(timeout)
        timeout = null
      }
      // 允许立即执行
      function flush() {
        cancel()
        return func.apply(this, args)
      }
      debounced.cancel = cancel
      debounced.flush = flush
      return debounced
    }
    // 防抖处理之后的事件绑定
    const ipt = document.querySelector('input')
    ipt.addEventListener('input', debounce(e => {
      search(e.target.value).then(resp => {
        // ...
      }, e => {
        // ...
      })
    }, 500))
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45

    关于防抖函数还有功能更丰富的版本,比如 lodash (opens new window) 的 debounce (opens new window)() 函数。

    # 节流

    现在来考虑另外一个场景,一个左右两列布局的查看文章页面,左侧为文章大纲结构,右侧为文章内容。现在需要添加一个功能,就是当用户滚动阅读右侧文章内容时,左侧大纲相对应部分高亮显示,提示用户当前阅读位置。

    这个功能的实现思路比较简单,滚动前先记录大纲中各个章节的垂直距离,然后监听 scroll 事件的滚动距离,根据距离的比较来判断需要高亮的章节。伪代码如下:

    // 监听scroll事件
    wrap.addEventListener('scroll', e => {
      let highlightId = ''
      // 遍历大纲章节位置,与滚动距离比较,得到当前高亮章节id
      for (let id in offsetMap) {
        if (e.target.scrollTop <= offsetMap[id].offsetTop) {
          highlightId = id
          break
        }
      }
      const lastDom = document.querySelector('.highlight')
      const currentElem = document.querySelector(`a[href="#${highlightId}"]`)
      // 修改高亮样式
      if (lastDom && lastDom.id !== highlightId) {
        lastDom.classList.remove('highlight')
        currentElem.classList.add('highlight')
      } else {
        currentElem.classList.add('highlight')
      }
    })
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20

    功能是实现了,但这并不是最优方法,因为滚动事件的触发频率是很高的,持续调用判断函数很可能会影响渲染性能。实际上也不需要过于频繁地调用,因为当鼠标滚动 1 像素的时候,很有可能当前章节的阅读并没有发生变化。所以我们可以设置在指定一段时间内只调用一次函数,从而降低函数调用频率,这种方式我们称之为“节流”。

    实现节流函数的过程和防抖函数有些类似,只是对于节流函数而言,有两种执行方式,在调用函数时执行最先一次调用还是最近一次调用,所以需要设置时间戳加以判断。我们可以基于 debounce() 函数加以修改,代码如下所示:

    const throttle = (func, wait = 0, execFirstCall) => {
      let timeout = null
      let args
      let firstCallTimestamp
      function throttled(...arg) {
        if (!firstCallTimestamp) firstCallTimestamp = new Date().getTime()
        if (!execFirstCall || !args) {
          console.log('set args:', arg)
          args = arg
        }
    
        if (timeout) {
          clearTimeout(timeout)
          timeout = null
        }
    
        // 以Promise的形式返回函数执行结果
        return new Promise(async (res, rej) => {
          if (new Date().getTime() - firstCallTimestamp >= wait) {
            try {
              const result = await func.apply(this, args)
              res(result)
            } catch (e) {
              rej(e)
            } finally {
              cancel()
            }
          } else {
            timeout = setTimeout(async () => {
              try {
                const result = await func.apply(this, args)
    
                res(result)
              } catch (e) {
                rej(e)
              } finally {
                cancel()
              }
            }, firstCallTimestamp + wait - new Date().getTime())
          }
        })
      }
    
      // 允许取消
      function cancel() {
        clearTimeout(timeout)
        args = null
        timeout = null
        firstCallTimestamp = null
      }
    
      // 允许立即执行
      function flush() {
        cancel()
        return func.apply(this, args)
      }
      
      throttled.cancel = cancel
      throttled.flush = flush
      return throttled
    }
    
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62

    节流与防抖都是通过延迟执行,减少调用次数,来优化频繁调用函数时的性能。不同的是,对于一段时间内的频繁调用,防抖是延迟执行后一次调用,节流是延迟定时多次调用。

    # 代理

    下面的 HTML 代码是一个简单的无序列表,现在希望点击每个项目的时候调用 getInfo() 函数,当点击“编辑”时,调用一个 edit() 函数,当点击“删除”时,调用一个 del() 函数。

    <ul class="list">
      <li class="item" id="item1">项目1<span class="edit">编辑</span><span class="delete">删除</span></li>
      <li class="item" id="item2">项目2<span class="edit">编辑</span><span class="delete" >删除</span></li>
      <li class="item" id="item3">项目3<span class="edit">编辑</span><span class="delete">删除</span></li>
      ...
    </ul>
    
    1
    2
    3
    4
    5
    6

    要实现这个功能并不难,只需要对列表中每一项,分别监听 3 个元素的 click 事件即可。

    但如果数据量一旦增大,事件绑定占用的内存以及执行时间将会成线性增加,而其实这些事件监听函数逻辑一致,只是参数不同而已。此时我们可以以事件代理或事件委托来进行优化。不过在此之前,我们必须先复习一下 DOM 事件的触发流程。

    事件触发流程如图 1 所示,主要分为 3 个阶段:

    • 捕获,事件对象 Window 传播到目标的父对象,图 1 的红色过程;
    • 目标,事件对象到达事件对象的事件目标,图 1 的蓝色过程;
    • 冒泡,事件对象从目标的父节点开始传播到 Window,图 1 的绿色过程。

    1.png

    例如,在下面的代码中,虽然我们第二次进行事件监听时设置为捕获阶段,但点击事件时仍会按照监听顺序进行执行。

    <body>
      <button>click</button>
    </body>
    <script>
    document.querySelector('button').addEventListener('click', function () {
      console.log('bubble')
    })
    document.querySelector('button').addEventListener('click', function () {
      console.log('capture')
    }, true)
    // 执行结果
    // buble
    // capture
    </script>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14

    我们再回到事件代理,事件代理的实现原理就是利用上述 DOM 事件的触发流程来对一类事件进行统一处理。比如对于上面的列表,我们在 ul 元素上绑定事件统一处理,通过得到的事件对象来获取参数,调用对应的函数。

    const ul = document.querySelector('.list')
    ul.addEventListener('click', e => {
      const t = e.target || e.srcElement
      if (t.classList.contains('item')) {
        getInfo(t.id)
      } else {
        id = t.parentElement.id
        if (t.classList.contains('edit')) {
          edit(id)
        } else if (t.classList.contains('delete')) {
          del(id)
        }
      }
    })
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14

    虽然这里我们选择了默认在冒泡阶段监听事件,但和捕获阶段监听并没有区别。对于其他情况还需要具体情况具体细分析,比如有些列表项目需要在目标阶段进行一些预处理操作,那么可以选择冒泡阶段进行事件代理。

    # DOM 事件标准

    // 方式1
    <input type="text" onclick="click()"/>
    // 方式2
    document.querySelector('input').onClick = function(e) {
      // ...
    }
    // 方式3
    document.querySelector('input').addEventListener('click', function(e) {
      //...
    })
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10

    方式 1 和方式 2 同属于 DOM0 标准,通过这种方式进行事件监会覆盖之前的事件监听函数。

    方式 3 属于 DOM2 标准,推荐使用这种方式。同一元素上的事件监听函数互不影响,而且可以独立取消,调用顺序和监听顺序一致。

    上次更新: 2022/11/09, 11:51:56
    高效操作DOM
    理解 AST 实现和编译原理

    ← 高效操作DOM 理解 AST 实现和编译原理→

    最近更新
    01
    vim日常使用记录
    04-02
    02
    滑动窗口最大值
    04-02
    03
    有效的字母异位词
    04-02
    更多文章>
    Theme by Vdoing | Copyright © 2021-2025 izhaong | github | 蜀ICP备2021031194号
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式