几个非常有意思的javascript知识点总结

13,558 阅读19分钟

作为一名前端爱好者, 笔者利用空余时间研究了几个国外网站的源码,发现不管是库,还是业务代码,都会用到了一些比较有意思的API,虽然平时在工作中部分接触过,但是经过这次的研究,觉得很有必要总结一下,毕竟已经2020年了,是时候更新一下技术储备了,本文主要通过实际案例来带大家快速了解以下几个知识点:

  • Observer 原生观察者
  • script标签事件深入 - 移除script标签后事件仍然能执行的原因
  • Proxy/Reflect
  • 自定义事件
  • fileReader API
  • Fullscreen 网页全屏
  • URL API的使用
  • Geolocation 地理位置API的使用
  • Notifications 浏览器原生消息通知
  • Battery Status 设备电量情况

我会对部分API做一些比较有意思的案例,那么开始我们的学习吧~

1. Observer API

Observer是浏览器自带的观察者,它主要提供了Intersection, Mutation, Resize, Performance这四类观察者, 这里笔者重点介绍Intersection Observer.

1.1 Intersection Observer

IntersectionObserver提供了一种异步观察目标元素与其祖先元素交叉状态的方法。当一个IntersectionObserver对象被创建时,其被配置为监听根中一段给定比例的可见区域,并且无法更改其配置,所以一个给定的观察者对象只能用来监听可见区域的特定变化值;然而,我们可以在同一个观察者对象中配置监听多个目标元素。

说简单点就是该api可以异步监听目标元素在根元素里的位置变动,并触发响应事件.我们可以利用它来实现更为高效的图片懒加载, 无限滚动以及内容埋点上报等.接下来我们通过一个例子来说明一下它的使用步骤.

// 1.定义观察者及观察回调
const intersectionObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => { 
      console.log(entry)
      // ...一些操作
    }); 
   },
   {
    root: document.querySelector('#root'),
    rootMargin: '0px',
    threshold: 0.5
   }
)
// 2. 定义要观察的目标对象
const target = document.querySelector(“.target”); 
intersectionObserver.observe(target);

以上代码就实现了一个基本的Intersection Observer,虽然已有代码中还体现不出什么实质性功能. 接下来介绍一下代码中使用到的参数的含义:

  • callback IntersectionObserver实例的第一个参数, 当目标元素与根元素通过阈值🍌时就会触发该回调.回调中第一个参数是被观察对象列表,一旦被观察对象发生突变就会被移入该列表, 列表中每一项都保留有观察者的位置信息;第二个参数为observer,观察者本身.如下图控制台打印:

    其中rootBounds表示根元素的位置信息, boundingClientRect表示目标元素的位置信息,intersectionRect表示叉部分的位置信息, intersectionRatio表示目标元素的可见比例.

  • 配置属性 IntersectionObserver实例的第二个参数,用来配置监听属性,具体有以下三个属性:

    • root 所监听对象的具体祖先元素(element)。如果未传入值或值为null,则默认使用顶级文档的视窗。
    • rootMargin 计算交叉时添加到根(root)边界盒bounding box的矩形偏移量, 可以有效的缩小或扩大根的判定范围从而满足计算需要
    • thresholds 一个包含阈值的列表, 按升序排列, 列表中的每个阈值都是监听对象的交叉区域与边界区域的比率。当监听对象的任何阈值被越过时,都会生成一个通知(Notification)。如果构造器未传入值, 则默认值为0。 以上属性介绍字面上可能很难理解,笔者花几个草图来让大家有个直观的认知:
      当我们设置rootMargin为10px时,我们的root会增大影响范围,但目标元素移动到淡红色区域式就会被监听到,当然我们还可以设置rootMargin为负值来减少影响区域.其支持的值为百分比和px,如下:
rootMargin: '10px'
rootMargin: '10%'
rootMargin: '10px 0px 10px 10px'

thresholds可以如下图理解:

由上图所示,当我们设置阈值为[0.25, 0.5]时, 目标元素的25%和50%进入根元素的影响范围时都会触发回调.利用这个特性我们往往可以实现位差动画,或者更根据目标元素的位置变化做不同的交互. 当然Intersection还提供了以下几个方法来控制观察对象:

  • disconnect() 使IntersectionObserver对象停止监听工作
  • takeRecords() 返回所有观察目标的IntersectionObserverEntry对象数组
  • unobserve() 使IntersectionObserver停止监听特定目标元素

了解了使用方法和api之后,我们来看看一个实际应用--实现图片懒加载:

<img src="loading.gif" data-src="absolute.jpg">
<img src="loading.gif" data-src="relative.jpg">
<img src="loading.gif" data-src="fixed.jpg">

<script>
let observerImg = new IntersectionObserver(
(entries, observer) => { 
    if (entries[0].intersectionRatio = 0) return
    entries.forEach(entry => {
        // 替换为正式的图片
        entry.target.src = entry.target.dataset.src;
        // 停止监听
        observer.unobserve(entry.target);
      });
    }, 
    {
      root: document.getElementById('scrollView'),
      threshold: 0.3
    }
);

document.querySelectorAll('img').forEach(img => { observerImg.observe(img) });
</script>

以上代码就实现了一个图片懒加载功能, 当图片的30%进入根元素时才加载真实的图片,这又让我想起了之前在某条做广告埋点上报时使用react-lazyload的画面.大家还可以利用它实现无限滚动, H5视差动画等有意思的交互场景.

1.2 Mutation Observer和Resize Observer

Mutation Observer主要用来实现dom变动时的监听,同样也是异步触发,对监听性能非常友好. Resize Observer主要用来监听元素大小的变化,相比于每次窗口变动都触发的window.resize事件, Resize Observer有更好的性能和对dom有更细粒度的控制,它只会在绘制前或布局后触发调用. 以上两个api的使用和Intersection使用非常类似,官方资料也写得很全,大家可以好好研究一下.

2. 移除script标签后事件仍然能执行的原因

这个问题主要是之前有朋友问过我,当时的想法就是简单的认为script内的代码执行完之后以及与dom绑定了,存放在了浏览器内存中,最近查了很多资料发现有一个有点意思的解释,放出来大家可以感受一下:

JavaScript解释器在执行脚本时,是按块来执行的,也就是说浏览器在解析HTML文档流时,如果遇到一个script标签,javascript解释器会等待这个代码块都加载完了,才进行预编译,然后才执行。所以,当开始执行这个代码块的代码时,这个代码段已经被解析完了。这时再从DOM中删去也就不影响代码的执行了。

3. Proxy/Reflect

Proxy/Reflect虽然是es6的api,出现也已经有几年了,但是在项目中用的还是比较少,如果是做底层架构方面的工作,还是建议大家多去使用,毕竟vue/react这种框架源码把这些api玩的如火纯青,还是很有必要掌握一下的。

其实我们认真看mdn的介绍或者阮一峰老师的文章,还是很好理解这些api的用法的,接下来我们详细介绍一下这两个api以及应用场景.

3.1 Proxy

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy在很多场景中都会和Reflect一起使用. 用法也很简单,我们看看Proxy的基本用法:

const obj = {
   name: '徐小夕',
   age: '120'
 }
 const proxy = new Proxy(obj, {
   get(target, propKey, receiver) {
    console.log('get:' + propKey)
    return Reflect.get(target, propKey, receiver)
   },
   set(target, propKey, value, receiver) {
    console.log('set:' + propKey)
    return Reflect.set(target, propKey, value, receiver)
   }
 })
 console.log(proxy.name) // get:name 徐小夕
 proxy.work = 'frontend' // set:work frontend

以上代码拦截了obj对象,并重新定义了读写(get/set)方法,这样我们就可以在访问对象时进行额外的操作了.

Proxy还有apply(拦截 Proxy 实例作为函数调用的操作)和construct(拦截 Proxy 实例作为构造函数调用的操作)等属性可以使用,我们可以在对象操作的不同阶段进行拦截,这里我就不一一样举例了.接下来看看Proxy的实际应用场景.

  • 实现数组读取负数的索引

我们一般操作数组大多数都是正向操作的,不能通过指定负数来逆向查找数组,如下图:

我们不能通过arr[-1]来拿到数组的尾部元素(字符串同理),这个时候我们就可以用Proxy来实现这一功能,这是我们的结构有点像环状:
这种实现的好处是如果我们想访问数组的最后一个元素时,我们不需要先拿到长度,再通过索引访问了:

// 原始写法
arr[arr.length -1]
// 通过proxy改造后写法
arr[-1]

实现代码如下:

function createArray(...elements) {
  let handler = {
    get(target, propKey, receiver) {
      let index = Number(propKey);
      if (index < 0) {
        propKey = String(target.length + index);
      }
      return Reflect.get(target, propKey, receiver);
    }
  };

  let target = [];
  target.push(...elements);
  return new Proxy(target, handler);
}

我们可以发现以上代码使用proxy来代理数组的读取操作,在内部封装了支持负值查找的功能,当然我们也可以不用proxy来实现同样的功能,这里实现参考阮一峰老师的实现.

  • 利用proxy实现更优雅的校验器

一般我们在做表单校验的时候会写一些if else或者switch判断来实现对不同属性值的校验,同样我们也可以用proxy来优雅的实现它,代码如下:

const formData = {
   name: 'xuxi',
   age: 120,
   label: ['react', 'vue', 'node', 'javascript']
 }
 // 校验器
 const validators = {
   name(v) {
     // 检验name是否为字符串并且长度是否大于3
     return typeof v === 'string' && v.length > 3
   },
   age(v) {
     // 检验age是否为数值
     return typeof v === 'number'
   },
   label(v) {
     // 检验label是否为数组并且长度是否大于0
     return Array.isArray(v) && v.length > 0
   }
 }
 // 代理校验对象
 function proxyValidator(target, validator) {
  return new Proxy(target, {
    set(target, propKey, value, receiver) {
      if(target.hasOwnProperty(propKey)) {
        let valid = validator[propKey]
        if(!!valid(value)) {
          return Reflect.set(target, propKey, value, receiver)
        }else {
          // 一些其他错误业务...
          throw Error(`值验证错误${propKey}:${value}`)
        }
      }
    }
  })
 }

有了以上实现模式,我们就可以实现对表单中某个值进行设置时进行校验了,用法如下:

let formObj = proxyValidator(formData, validators)
formObj.name = 333;   // Uncaught Error: 值验证错误name:f
formObj.age = 'ddd'   // Uncaught Error: 值验证错误age:f

以上代码中当设置了不合法的值时,控制台将会剖出错误,如果在实际业务中,我们可以给用户做出适当的提醒.

  • 实现请求拦截和错误上报
  • 实现数据过滤

以上几点笔者在之前的文章中也写过,所以这里不在详细介绍了.大家也可以根据实际情况自己实现更加灵活的拦截操作.当然Proxy提供的API远远不止这几个,我们可以在MDN或者其他渠道了解更多高级用法.

3.2 Reflect

Reflect对象与Proxy对象一样,也是 ES6 为了操作对象而提供的新 API,更多的应用场景是配合proxy一起使用,在上文中已经用到了.可以将Object对象的一些明显属于语言内部的方法放到Reflect对象上,并修改某些Object方法的返回结果. Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。

4. 自定义事件

CustomEvent API是个非常有意思的api, 而且非常实用, 更重要的是学起来非常简单,而且被大部分现代浏览器支持.我们可以让任意dom元素监听和触发自定义事件,只需要如下操作:

// 添加一个适当的事件监听器
dom1.addEventListener("boom", function(e) { something(e.detail.num) })

// 创建并分发事件
var event = new CustomEvent("boom", {"detail":{"num":10}})
dom1.dispatchEvent(event)

我们来看看CustomEvent的参数介绍:

  • type 事件的类型名称,如上面代码中的'boom'
  • CustomEventInit 提供了事件的配置信息,具体有以下几个属性
    • bubbles 一个布尔值,表明该事件是否会冒泡
    • cancelable 一个布尔值,表明该事件是否可以被取消
    • detail 当事件初始化时传递的数据

我们可以通过dispatchEvent来触发自定义事件.其实他的用途有很多,比如创建观察者模式, 实现数据双向绑定, 亦或者在游戏开发中实现打怪掉血,比如下面的例子:

笔者上面画了一个打boss的草图, 现在的场景是两个玩家一起打boss, 我们可以在玩家发动攻击的时候触发dispatch掉血的自定义事件, boss监听到事件后将血量自动扣除, 至于不同角色的伤害值,我们可以存放在detail中,然后通过策略模式去分发伤害.笔者曾今在学校开发的H5游戏时就大量采用类似的模式,还是非常有意思的.

5. fileReader

File API使得我们在浏览器端可以访问文件的数据,比如预览文件,获取文件信息(比如文件名,文件内容,文件大小等), 并且可以在前端实现文件下载(可以借助canvas和 window.URL.revokeObjectURL的一些能力).当然我们还可以实现拖拽上传文件这样高用户体验的操作.接下来我们来看看几个实际例子.

  • 显示缩略图
function previewFiles(files, previewBox) {
    for (var i = 0; i < files.length; i++) {
      var file = files[i];
      var imageType = /^image\//;
      
      if (!imageType.test(file.type)) {
        continue;
      }
      
      var img = document.createElement("img");
      previewBox.appendChild(img); // 假设"preview"就是用来显示内容的div
      
      var reader = new FileReader();
      reader.onload = (function(imgEl) { 
        return function(e) { imgEl.src = e.target.result; }; 
      })(img);
      reader.readAsDataURL(file);
    }
  }

以上代码可以在reviewBox容器中显示已上传好的图片,当然我们还可以基于此来扩展,利用canvas将图片画到canvas上,然后进行图片压缩,最后再把压缩后的图片上传到服务器.这中方式其实目前很多工具型网站都在用,比如在线图片处理网站,提供的批量压缩图片,批处理水印等功能,套路都差不多,感兴趣的朋友可以尝试研究一下.

  • 封装文件上传组件

这块笔者之前也写过详细的文章,这里就不一一举例了, 文章地址:

6. Fullscreen

全屏API主要是让网页能在电脑屏幕中全屏显示,它允许我们打开或者退出全屏模式,以便我们根据需要进行对应的操作,比如我们常用的网页图形编辑器或者富文本编辑器, 为了让用户专心于内容设计,我们往往提供切换全屏的功能供用户使用.由于全屏API比较简单,这里我们直接上代码:

// 开启全屏
document.documentElement.requestFullscreen();
// 退出全屏
document.exitFullscreen();

以上代码的document.documentElement也可以换成任何一个你想让其全屏的元素.默认情况下我们还可以通过document.fullscreenElement来判断当前页面是否处于全屏状态,来实现屏幕切换的效果.如果是react开发者,我们也可以将其封装成一个自定义hooks来实现与业务相关的全屏切换功能.

7. URL

URL API是URL标准的组成部分,URL标准定义了构成有效统一资源定位符的内容以及访问和操作URL的API。

我们利用URL组件可以做很多有意思的事情.比如我们有个需求需要提取url的参数传给后台,传统的做法是自己写一个方法来解析url字符串,手动返回一个query对象.但是利用URL对象,我们可以很方便的拿到url参数,如下:

let addr = new URL(window.location.href)
let host = addr.host  // 获取主机地址
let path = addr.pathname  // 获取路径名
let user = addr.searchParams.get("user")  // 获取参数为user对应的值

以上代码可知,我们如果将url转化为URL对象,那么我们就可以很方便的通过searchParams提供的api来拿到url参数而无需自己再写一个方法了.

另一方面,如果网站安全性比较高,我们还可以对参数进行自然数排序然后再加密上传给后端.具体代码如下:

function sortMD5WithParameters() {
    let url = new URL(document.location.href);
    url.searchParams.sort();
    let keys = url.searchParams.keys();
    let params = {}
  
    for (let key of keys) {
      let val = url.searchParams.get(key);
      params[key] = val
    };
    // ...md5加密
    return MD5(params)
 }

8. Geolocation

地理位置 API 通过 navigator.geolocation 提供, 这个浏览器API也比较实用, 我们在网站中可以用此方式确定用户的位置信息,从而让网站有不同的展现,增强用户体验.

举几个有意思的例子可以让大家感受一下:

  • 根据不同地区,网站展示不同的主题:
  • 根据用户所在地区,展示不同推荐内容 这一点电商网站或者内容网站用的比较多, 比如用户在新疆,则给他推荐瓜果类广告, 在北京,则给他推荐旅游景点类广告等,虽然实际应用中往往会更复杂,但是也是一种思路.

其实应用远远不止如此,程序员可以发挥想象来实现更有意思的事情,让自己的网站更智能.接下来笔者就基于promise写一段获取用户位置的代码:

function getUserLocation() {
    return new Promise((resolve, reject) => {
      if (!navigator.geolocation) {
        reject()
      } else {
        navigator.geolocation.getCurrentPosition(success, error);
      }

      function success(position) {
        const latitude  = position.coords.latitude;
        const longitude = position.coords.longitude;
        resolve({latitude, longitude})
      }

      function error() {
        reject()
      }
    })
  }

使用方式和结果如下图所示:

我们基于获取到的经纬度调用第三方api(比如百度,高德)就可以获取用户所在为精确位置信息了.

9. Notifications

Notifications API 允许网页或应用程序在系统级别发送在页面外部显示的通知;这样即使应用程序空闲或在后台,Web应用程序也会向用户发送信息。

我们举个实际的例子,比如我们网站内容有更新,通知用户,效果如下:

相关代码如下:

Notification.requestPermission( function(status) {
  console.log(status); // 仅当值为 "granted" 时显示通知
  var n = new Notification("趣谈前端", {body: "从零搭建一个CMS全栈项目"}); // 显示通知
});

当然浏览器的Notification还给我们提供了4个事件触发api方便我们做更全面的控制:

  • onshow 当通知被显示给用户时触发 (已废弃, 但部分浏览器仍然能用)
  • onclick 当用户点击通知时触发
  • onclose 当通知被关闭时触发(已废弃, 但部分浏览器仍然能用)
  • onerror 当通知发生错误的时候触发

有了这样的事件监听,我们就可以控制当用户点击通知时, 跳转到对应的页面或者执行相关的业务逻辑.如下代码所示:

Notification.requestPermission( function(status) {
  console.log(status); // 仅当值为 "granted" 时显示通知
  var n = new Notification("趣谈前端", {body: "从零搭建一个CMS全栈项目"}); // 显示通知
      n.onshow = function () { 
        // 消息显示时执行的逻辑
        console.log('show') 
      }
      n.onclick = function () { 
        // 消息被点击时执行的逻辑
        history.push('/detail/1232432')
      }
      n.onclose = function () { 
        // 消息关闭时执行的逻辑
        console.log('close')
      }
});

当然我们在使用前需要获取权限,方式也很简单,大家可以在mdn上学习了解.

10. Battery Status

Battery Status API提供了有关系统充电级别的信息并提供了通过电池等级或者充电状态的改变提醒用户的事件。 这个可以在设备电量低的时候调整应用的资源使用状态,或者在电池用尽前保存应用中的修改以防数据丢失。

之前的版本中Battery Status API提供了几个事件监听函数来监听电量的变化以及监听设备是否充电,但是笔者看文档时这些api都已经废弃,如下:

  • chargingchange 监听设别是否充电
  • levelchange 监听电量充电等级
  • chargingtimechange 充电时间变化
  • dischargingtimechange 放电时间变化

虽然以上几个看似有用的api已经被弃用,但是笔者亲测谷歌还是可以正常使用的,但是为了让自己代码更可靠,我们可以用其他方式代替,比如用定时器定期去检测电量情况,进而对用户做出不同的提醒.

接下来我们看看基本的用法:

navigator.getBattery().then(function(battery) {
  console.log("是否在充电? " + (battery.charging ? "是" : "否"));
  console.log("电量等级: " + battery.level * 100 + "%");
  console.log("充电时间: " + battery.chargingTime + " s");
  console.log("放电时间: " + battery.dischargingTime + "s");
});

我们可以通过getBattery拿到设备电池信息,这个api非常有用,比如我们可以在用户电量不足时禁用网站动画或者停用一些耗时任务,亦或者是对用户做适当的提醒,改变网站颜色等,对于webapp中播放视频或者直播时,我们也可以用css画一个电量条,当电量告急时提醒用户.作为一个优秀的网站体验师,这一块还是不容忽视的.

参考文献

最后

如果想学习更多H5游戏, webpacknodegulpcss3javascriptnodeJScanvas数据可视化等前端知识和实战,欢迎在公号《趣谈前端》加入我们的技术群一起学习讨论,共同探索前端的边界。

更多推荐