Giter VIP home page Giter VIP logo

garden's Introduction

Hi there 👋



I'm iotale(LI Yongle)

I work as a Web 🌐 developer!

临渊羡鱼,不如退而结网!

garden's People

Contributors

iotale avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

garden's Issues

requestAnimationFrame 必知必会

随着技术与设备的发展,用户的终端对动画的表现能力越来越强,更多的场景开始大量使用动画。在 Web 应用中,实现动画效果的方法比较多,JavaScript 中可以通过定时器 setTimeout 来实现,css3 可以使用 transitionanimation 来实现,html5 中的 canvas 也可以实现。除此之外,html5 还提供一个专门用于请求动画的 API,即 requestAnimationFrame

本文内容均非原创,而是在知识点的收集与搬运中学习与理解,也欢迎大家收集与搬运本篇文章!

1. 是什么

  • HTML5 新增加的 API,类似于 setTimeout 定时器

  • window 对象的一个方法,window.requestAnimationFrame

    partial interface Window {
      long requestAnimationFrame(FrameRequestCallback callback);
      void cancelAnimationFrame(long handle);
    };
  • 浏览器(所以只能在浏览器中使用)专门为动画提供的 API,让 DOM 动画、Canvas 动画、SVG 动画、WebGL 动画等有一个统一的刷新机制

2. 做什么

  • 浏览器重绘频率一般会和显示器的刷新率保持同步。大多数浏览器采取 W3C 规范的建议,浏览器的渲染页面的标准帧率也为 60FPS(frames/ per second)
  • 按帧对网页进行重绘。该方法告诉浏览器希望执行动画并请求浏览器在下一次重绘之前调用回调函数来更新动画

  • 由系统来决定回调函数的执行时机,在运行时浏览器会自动优化方法的调用

    • 显示器有固定的刷新频率(60Hz 或 75Hz),也就是说,每秒最多只能重绘 60 次或 75 次,requestAnimationFrame 的基本**让页面重绘的频率与这个刷新频率保持同步

      比如显示器屏幕刷新率为 60Hz,使用requestAnimationFrame API,那么回调函数就每1000ms / 60 ≈ 16.7ms执行一次;如果显示器屏幕的刷新率为 75Hz,那么回调函数就每1000ms / 75 ≈ 13.3ms执行一次。

    • 通过requestAnimationFrame调用回调函数引起的页面重绘或回流的时间间隔和显示器的刷新时间间隔相同。所以 requestAnimationFrame 不需要像setTimeout那样传递时间间隔,而是浏览器通过系统获取并使用显示器刷新频率

      比如一个动画,宽度从 0px 加一递增到 100px。无缓动效果的情况下,浏览器重绘一次,宽度就加 1。

3. 用法

动画帧请求回调函数列表:每个 Document 都有一个动画帧请求回调函数列表,该列表可以看成是由<handle, callback>元组组成的集合。

  • handle 是一个整数,唯一地标识了元组在列表中的位置,cancelAnimationFrame()可以通过它停止动画
  • callback 是一个无返回值的、形参为一个时间值的函数(该时间值为由浏览器传入的从 1970 年 1 月 1 日到当前所经过的毫秒数)。
  • 刚开始该列表为空。

页面可见性 API

  • 当页面被最小化或者被切换成后台标签页时,页面为不可见,浏览器会触发一个visibilitychange事件,并设置document.hidden属性为true
  • 当页面切换到显示状态,页面变为可见,同时触发一个visibilitychange事件,设置document.hidden属性为false
  • 调用操作。与setTimeout相似,但是不需要设置间隔时间,使用一个回调函数作为参数,返回一个大于 0 的整数

    handle = requestAnimationFrame(callback);
    • 参数callback,是一个回调函数,在下次重新绘制动画时调用。该回调函数接收唯一参数,是一个高精度时间戳(performance.now()),指触发回调函数的当前时间(不用手动传入)
    • 返回值是一个long型的非零整数,是requestAnimationFrame回调函数列表中唯一的标识,表示定时器的编号,无其他意义
  • 取消操作

    cancelAnimationFrame(handle);
    • 参数是调用requestAnimationFrame时的返回值
    • 取消操作没有返回值
  • 浏览器执行过程

    • 首先判断document.hidden属性是否为true(页面是否可见),页面处于可见状态才会执行后面步骤

    • 浏览器清空上一轮的动画函数

    • requestAnimationFrame将回调函数追加到动画帧请求回调函数列表的末尾

      当执行requestAnimationFrame(callback)的时候,不会立即调用 callback 函数,只是将其放入队列。每个回调函数都有一个布尔标识cancelled,该标识初始值为false,并且对外不可见。

    • 当浏览器再执行列表中的回调函数的时候,判断每个元组的 callback 的cancelled,如果为false,则执行 callback

      当页面可见并且动画帧请求回调函数列表不为空,浏览器会定期将这些回调函数加入到浏览器 UI 线程的队列中

    • 博客园上yyc 元超的文章深入理解 requestAnimationFrame中提供了让一个伪代码,用来说明“采样所有动画”任务的执行步骤

      var list = {};
      var browsingContexts = 浏览器顶级上下文及其下属的浏览器上下文;
      for (var browsingContext in browsingContexts) {
      /* !将时间值从 DOMTimeStamp 更改为 DOMHighResTimeStamp 是 W3C 针对基于脚本动画计时控制规范的最新编辑草案中的最新更改,
       * 并且某些供应商仍将其作为 DOMTimeStamp 实现。
       * 较早版本的 W3C 规范使用 DOMTimeStamp,允许你将 Date.now 用于当前时间。
       * 如上所述,某些浏览器供应商可能仍实现 DOMTimeStamp 参数,或者尚未实现 window.performance.now 计时函数。
       * 因此需要用户进行polyfill
       */
          var time = DOMHighResTimeStamp   //从页面导航开始时测量的高精确度时间。DOMHighResTimeStamp 以毫秒为单位,精确到千分之一毫秒。此时间值不直接与 Date.now() 进行比较,后者测量自 1970 年 1 月 1 日至今以毫秒为单位的时间。如果你希望将 time 参数与当前时间进行比较,请使用当前时间的 window.performance.now。
        var d = browsingContext  active document;   //即当前浏览器上下文中的Document节点
          //如果该active document可见
          if (d.hidden !== true) {
              //拷贝 active document 的动画帧请求回调函数列表到 list 中,并清空该列表
              var doclist = d的动画帧请求回调函数列表
              doclist.appendTo(list);
              clear(doclist);
          }
          //遍历动画帧请求回调函数列表的元组中的回调函数
          for (var callback in list) {
              if (callback.cancelled !== true) {
                  try {
                      //每个 browsingContext 都有一个对应的 WindowProxy 对象,WindowProxy 对象会将 callback 指向 active document 关联的 window 对象。
                      //传入时间值time
                      callback.call(window, time);
                  }
                  //忽略异常
                  catch (e) {
                  }
              }
          }
      }
    • 当调用cancelAnimationFrame(handle)时,浏览器会设置该 handle 指向的回调函数的cancelledtrue(无论该回调函数是否在动画帧请求回调函数列表中)。如果该 handle 没有指向任何回调函数,则什么也不会发生。

  • 递归调用。要想实现一个完整的动画,应该在回调函数中递归调用回调函数

    let count = 0;
    let rafId = null;
    /**
     * 回调函数
     * @param time requestAnimationFrame 调用该函数时,自动传入的一个时间
     */
    function requestAnimation(time) {
      console.log(time);
      // 动画没有执行完,则递归渲染
      if (count < 50) {
        count++;
        // 渲染下一帧
        rafId = requestAnimationFrame(requestAnimation);
      }
    }
    // 渲染第一帧
    requestAnimationFrame(requestAnimation);
  • 如果在执行回调函数或者 Document 的动画帧请求回调函数列表被清空之前多次调用 requestAnimationFrame 调用同一个回调函数,那么列表中会有多个元组指向该回调函数(它们的 handle 不同,但 callback 都为该回调函数),“采集所有动画”任务会执行多次该回调函数。(类比定时器setTimeout

    function counter() {
      let count = 0;
      function animate(time) {
        if (count < 50) {
          count++;
          console.log(count);
          requestAnimationFrame(animate);
        }
      }
      requestAnimationFrame(animate);
    }
    btn.addEventListener("click", counter, false);
    • 多次点击按钮,会发现打印出来多个序列数值(下图中,连续触发三次,打印了三个有序列)

      多次调用回调函数

    • 如果是作用于动画,动画会出现突变的情况

4. 兼容性

来源:Polyfill for requestAnimationFrame/cancelAnimationFrame

在浏览器初次加载的时候执行下面的代码即可。

// 使用 Date.now 获取时间戳性能比使用 new Date().getTime 更高效
if (!Date.now)
  Date.now = function() {
    return new Date().getTime();
  };

(function() {
  "use strict";

  var vendors = ["webkit", "moz"];
  for (var i = 0; i < vendors.length && !window.requestAnimationFrame; ++i) {
    var vp = vendors[i];
    window.requestAnimationFrame = window[vp + "RequestAnimationFrame"];
    window.cancelAnimationFrame =
      window[vp + "CancelAnimationFrame"] ||
      window[vp + "CancelRequestAnimationFrame"];
  }
  // 上面方法都不支持的情况,以及IOS6的设备
  // 使用 setTimeout 模拟实现
  if (
    /iP(ad|hone|od).*OS 6/.test(window.navigator.userAgent) ||
    !window.requestAnimationFrame ||
    !window.cancelAnimationFrame
  ) {
    var lastTime = 0;
    // 和通过时间戳实现节流功能的函数相似
    window.requestAnimationFrame = function(callback) {
      var now = Date.now();
      var nextTime = Math.max(lastTime + 16, now);
      // 实际上第1帧是不准确的,首次nextTime - now = 0
      return setTimeout(function() {
        callback((lastTime = nextTime));
      }, nextTime - now);
    };
    window.cancelAnimationFrame = clearTimeout;
  }
})();

5. 优势

requestAnimationFrame采用系统时间间隔,保持最佳绘制效率。不会因为间隔时间过短,造成过度绘制,增加开销;也不会因为间隔时间过长,使动画卡顿。

从实现的功能和使用方法上,requestAnimationFrame与定时器setTimeout都相似,所以说其优势是同setTimeout实现的动画相比。

a. 提升性能,防止掉帧

  • 浏览器 UI 线程:浏览器让执行 JavaScript 和更新用户界面(包括重绘和回流)共用同一个单线程,称为“浏览器 UI 线程”
  • 浏览器 UI 线程的工作基于一个简单的队列系统,任务会被保存到队列中直到进程空闲。一旦空闲,队列中的下一个任务就被重新提取出来并运行。这些任务要么是运行 JavaScript 代码,要么执行 UI 更新。
  • 通过setTimeout实现动画

    • setTimeout通过设置一个间隔时间不断改变图像,达到动画效果。该方法在一些低端机上会出现卡顿、抖动现象。这种现象一般有两个原因:

      • setTimeout的执行时间并不是确定的。

        在 JavaScript 中,setTimeout任务被放进异步队列中,只有当主线程上的任务执行完以后,才会去检查该队列的任务是否需要开始执行。所以,setTimeout的实际执行时间一般比其设定的时间晚一些。这种运行机制决定了时间间隔参数实际上只是指定了把动画代码添加到【浏览器 UI 线程队列】中以等待执行的时间。如果队列前面已经加入了其他任务,那动画代码就要等前面的任务完成后再执行

        let startTime = performance.now();
        setTimeout(() => {
          let endTime = performance.now();
          console.log(endTime - startTime);
        }, 50);
        /* 一个非常耗时的任务 */
        for (let i = 0; i < 20000; i++) {
          console.log(0);
        }

        定时器

      • 刷新频率受屏幕分辨率和屏幕尺寸影响,不同设备的屏幕刷新率可能不同,setTimeout只能设置固定的时间间隔,这个时间和屏幕刷新间隔可能不同

    • 以上两种情况都会导致setTimeout的执行步调和屏幕的刷新步调不一致,从而引起丢帧现象。

      • setTimeout的执行只是在内存中对图像属性进行改变,这个改变必须要等到下次浏览器重绘时才会被更新到屏幕上。如果和屏幕刷新步调不一致,就可能导致中间某些帧的操作被跨越过去,直接更新下下一帧的图像。

        假如使用定时器设置间隔 10ms 执行一个帧,而浏览器刷新间隔是 16.6ms(即 60FPS)

        丢帧

        由图可知,在 20ms 时,setTimeout调用回调函数在内存中将图像的属性进行了修改,但是此时浏览器下次刷新是在 33.2ms 的时候,所以 20ms 修改的图像没有更新到屏幕上。
        而到了 30ms 的时候,setTimeout又一次调用回调函数并改变了内存中图像的属性,之后浏览器就刷新了,20ms 更新的状态被 30ms 的图像覆盖了,屏幕上展示的是 30ms 时的图像,所以 20ms 的这一帧就丢失了。丢失的帧多了,画面就卡顿了。

  • 使用 requestAnimationFrame 执行动画,最大优势是能保证回调函数在屏幕每一次刷新间隔中只被执行一次,这样就不会引起丢帧,动画也就不会卡顿

b. 节约资源,节省电源

  • 使用 setTimeout 实现的动画,当页面被隐藏或最小化时,定时器setTimeout仍在后台执行动画任务,此时刷新动画是完全没有意义的(实际上 FireFox/Chrome 浏览器对定时器做了优化:页面闲置时,如果时间间隔小于 1000ms,则停止定时器,与requestAnimationFrame行为类似。如果时间间隔>=1000ms,定时器依然在后台执行)

    // 在浏览器开发者工具的Console页执行下面代码。
    // 当开始输出count后,切换浏览器tab页,再切换回来,可以发现打印的值没有停止,甚至可能已经执行完了
    let count = 0;
    let timer = setInterval(() => {
      if (count < 20) {
        count++;
        console.log(count);
      } else {
        clearInterval(timer);
        timer = null;
      }
    }, 2000);
  • 使用requestAnimationFrame,当页面处于未激活的状态下,该页面的屏幕刷新任务会被系统暂停,由于requestAnimationFrame保持和屏幕刷新同步执行,所以也会被暂停。当页面被激活时,动画从上次停留的地方继续执行,节约 CPU 开销。

    // 在浏览器开发者工具的Console页执行下面代码。
    // 当开始输出count后,切换浏览器tab页,再切换回来,可以发现打印的值从离开前的值继续输出
    let count = 0;
    function requestAnimation() {
      if (count < 500) {
        count++;
        console.log(count);
        requestAnimationFrame(requestAnimation);
      }
    }
    requestAnimationFrame(requestAnimation);

c. 函数节流

  • 一个刷新间隔内函数执行多次时没有意义的,因为显示器每 16.7ms 刷新一次,多次绘制并不会在屏幕上体现出来
  • 在高频事件(resizescroll等)中,使用requestAnimationFrame可以防止在一个刷新间隔内发生多次函数执行,这样保证了流畅性,也节省了函数执行的开销
  • 某些情况下可以直接使用requestAnimationFrame替代 Throttle 函数,都是限制回调函数执行的频率

6. 应用

  • 简单的进度条动画

    function loadingBar(ele) {
      // 使用闭包保存定时器的编号
      let handle;
      return () => {
        // 每次触发将进度清空
        ele.style.width = "0";
        // 开始动画前清除上一次的动画定时器
        // 否则会开启多个定时器
        cancelAnimationFrame(handle);
        // 回调函数
        let _progress = () => {
          let eleWidth = parseInt(ele.style.width);
          if (eleWidth < 200) {
            ele.style.width = `${eleWidth + 5}px`;
            handle = requestAnimationFrame(_progress);
          } else {
            cancelAnimationFrame(handle);
          }
        };
        handle = requestAnimationFrame(_progress);
      };
    }
  • 添加缓动效果,实现一个元素块按照三阶贝塞尔曲线的ease-in-out缓动特效参数运动。如何使用 Javascript 实现缓动特效

缓动动画:指定动画效果在执行时的速度,使其看起来更加真实。

/**
 * @param {HTMLElement} ele 元素节点
 * @param {number} change 改变量
 * @param {number} duration 动画持续时长
 */
function moveBox(ele, change, duration) {
  // 使用闭包保存定时器标识
  let handle;
  // 返回动画函数
  return () => {
    // 开始时间
    let startTime = performance.now();
    // 防止启动多个定时器
    cancelAnimationFrame(handle);
    // 回调函数
    function _animation() {
      // 这一帧开始的时间
      let current = performance.now();
      let eleTop = ele.offsetLeft;
      // 这一帧内元素移动的距离
      let left = change * easeInOutCubic((current - startTime) / duration);
      ele.style.left = `${~~left}px`;
      // 判断动画是否执行完
      if ((current - startTime) / duration < 1) {
        handle = requestAnimationFrame(_animation);
      } else {
        cancelAnimationFrame(handle);
      }
    }
    // 第一帧开始
    handle = requestAnimationFrame(_animation);
  };
}
/**
 * 三阶贝塞尔曲线ease-in-out
 * @param {number} k
 */
function easeInOutCubic(k) {
  return (k *= 2) < 1 ? 0.5 * k * k * k : 0.5 * ((k -= 2) * k * k + 2);
}

7. 相关

本文内容均非原创,而是在知识点的收集与搬运中学习与理解,也欢迎大家收集与搬运本篇文章!

http DELETE 方法传参问题

根据HTTP标准,HTTP请求可以使用多种方法,其功能描述如下所示。

  • HTTP1.0定义了三种请求方法: GET、POST、HEAD
  • HTTP1.1新增了五种请求方法:OPTIONS、PUT、DELETE、TRACE 、CONNECT

HTTP DELETE

HTTP DELETE 请求方法用于删除指定的资源。DELETE 可以直接理解动词删除、移除。但是HTTP没有规范DELETE的具体实现方式。

请求是否有主体 可以有
成功的返回是否有主体 可以有
安全
幂等性
可缓存
幂等

Methods can also have the property of "idempotence" in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request.

HTTP方法的幂等性是指一次和多次请求某一个资源应该具有同样的副作用。

幂等性原本是数学上的概念,即使公式:f(x)=f(f(x)) 能够成立的数学性质。

DELETE 方法传参数

使用 DELETE 方法,需要提供额外的参数时,大多以**键值对(key-value)**的形式,置于URI的查询部分,如:下面的 ?gender=female

https://echo.paw.cloud:443/hello/world?age=24&gender=female

有时候,后端可能希望前端能够传递复杂的数据。那么,将参数放在 entity body 中可以吗?

不建议这样做。但是也不是不可以,因为标准规范中并没有明确禁止。

最新的 HTTP1.1 规范 RFC 7231 在 DELETE 一节中有这样的描述:

A payload within a DELETE request message has no defined semantics; sending a payload body on a DELETE request might cause some existing implementations to reject the request.

DELETE请求消息中的有效负载没有定义的语义;在DELETE请求上发送有效内容正文可能会导致某些现有实现拒绝该请求。

这个意思就是说:是的!DELETE 可以有 entity body。

目前由于规范的含糊不清,反对使用 DELETE 实体主体的主要认为:因为没有针对实体的定义的语义,这暗示着应该忽略它。即body是允许的,但与请求无关,使用它绝对没有意义。就如同,可以通过 GET 传递 body,但是这样做毫无意义。并且,如果网络中存在某些代理服务器,可能造成entity body丢失。

实际上我自己开发的时候,后端的设计的确是用到了在 DELETE 方法中传 body 的情况,所以可以看出,通过 body 传复杂参数绝对是可以实现的,并且支不支持,其实取决于后端设计。并且我觉得我们后端有偷懒的嫌疑,后端为了少些代码逻辑,总是直接让前端去搞些有的没的。

还是要思考几个问题:

  1. 如果 DELETE 传递实体是为了与 API 紧密耦合,这与 REST 是相悖的,如果不希望未经授权的客户端删除资源,应该使用身份验证机制来防止此类情况
  2. 如果确实需要向服务器发送复杂的参数来决定是否删除资源,要么就是设计的人没读过规范,要么就是偷懒的嫌疑。

PS:

HTML5 的表单(form)仅支持 getpost 方法。如果将表单的 method 改为 delete,HTML5 会以预设的 get 方法取代

axios 实现 delete 方法传参

Axios 是一个基于 promise 的 HTTP 库,从浏览器中创建 XMLHttpRequests 发送请求。其实在浏览器中 Axios 就是 AJAX 技术的一种实现。

AJAX(Asynchronous JavaScript And XML)是一种异步请求的技术,是实现网页的局部数据刷新的技术,可以通过XHR、Fetch、WebSocket等API实现。

注意:需要和Jquery的 $.ajax 区分开,前者是一种技术,而后者是 Jquery 通过 XHR 对前者的实现。

同理

Axios 是通过 Promise 实现 XHR 封装,其中Promise是控制手段,XHR 是实际发送Http请求的客户端。就像$.ajax是通过callback+XHR实现一样,你也可以造个轮子叫XXX的,都是AJAX技术的一种具体实现。

axios和ajax的区别? - 轰隆隆的回答 - 知乎

axios 实例方法 DELETE 的定义

axios#delete(url[, config])

可以看到,DELETE 方法除了接受一个url,还可接受一个 config 参数。这个 config 就是请求配置。

{
  // ...
  
  // `params` 是即将与请求一起发送的 URL 参数
  // 必须是一个无格式对象(plain object)或 URLSearchParams 对象
  params: {
    ID: 12345
  },
  // `data` is the data to be sent as the request body
  // Only applicable for request methods 'PUT', 'POST', 'DELETE , and 'PATCH'
  // When no `transformRequest` is set, must be of one of the following types:
  // - string, plain object, ArrayBuffer, ArrayBufferView, URLSearchParams
  // - Browser only: FormData, File, Blob
  // - Node only: Stream, Buffer
  data: {
    firstName: 'Fred'
  },
  
  // ...
}

通过观察 config 参数可以知道

  • 可以将参数拼接在 url 上,即用 params

    axios.delete('/delete', {
      params: {    // 请求参数拼接在url上
        id: 12
      }
    })
  • 请求参数放在请求体用 data,虽然文档中说“只适用于这些请求方法 'PUT', 'POST', 和 'PATCH'”,但是并没有说不允许 DELETE方法使用,所以下面也是可以传参数的,只是不符合HTTP1.1的规范罢了。

    最新的文档已经修改,将 DELETE 方法也加上了,所以说 axios 已经支持 DELETE 方法通过 body 传参

    axios.delete('/delete', {    // 请求参数放在请求体
      data: {
        id: 12
      }
    })

也可以不使用方法别名,直接通过向 axios 传递相关配置来创建请求,其实只是看着不一样而已。

axios({
  method: 'delete',
  url: '/delete',
  params: {}, // 请求参数拼接在url上
  data: {},   // 请求参数放在请求体
});

axios 如何实现的?

跟踪config的处理

// https://github.com/axios/axios/blob/16aa2ce7fa42e7c46407b78966b7521d8e588a72/lib/core/Axios.js#L73

utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData(method) {
  /*eslint func-names:0*/
  Axios.prototype[method] = function(url, config) {
    return this.request(mergeConfig(config || {}, {
      method: method,
      url: url
    }));
  };
});

可以看到,请求参数传给了 this.request 方法

function Axios(instanceConfig) {
  this.defaults = instanceConfig;
  this.interceptors = {
    request: new InterceptorManager(),
    response: new InterceptorManager()
  };
}

// https://github.com/axios/axios/blob/16aa2ce7fa42e7c46407b78966b7521d8e588a72/lib/core/Axios.js#L27
Axios.prototype.request = function request(config) {
  // ...

  // 合并请求配置
  config = mergeConfig(this.defaults, config);

  // ...

  // 连接拦截器中间件
  // 设置一个队列,用来放请求拦截器和反应拦截器
  var chain = [dispatchRequest, undefined];
  var promise = Promise.resolve(config);
  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    // 将请求拦截器成功与失败的处理放在队列头
    chain.unshift(interceptor.fulfilled, interceptor.rejected);
  });
  
  // chain.shift() 会从队列头取元素,所以会先执行 dispatchRequest
  while (chain.length) {
    promise = promise.then(chain.shift(), chain.shift());
  }
  
  // ...
};

dispatchRequest 这个方法,基本上就可以看到,body 参数的去向了

// https://github.com/axios/axios/blob/16aa2ce7fa42e7c46407b78966b7521d8e588a72/lib/core/dispatchRequest.js#L23
module.exports = function dispatchRequest(config) {
  // ...
  
  // Ensure headers exist
  config.headers = config.headers || {};

  // Transform request data
  config.data = transformData(
    config.data,
    config.headers,
    config.transformRequest
  );
  
  // ...
}

transformData 方法是将 config 传给拦截器进行执行,到此,其实发送请求前的 header 和实体 body 都已经构造完毕。

接下来就是 axios 如何将参数通过XHR发送出去了。也就是axios封装的 xhr

// axios/lib/adapters/xhr.js

module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    var requestData = config.data;
    
    // ...
    
    // 创建XMLHttpRequest实例 
    var request = new XMLHttpRequest();
    
    // ...

    // 发送请求
    request.send(requestData);
  });
}

参考:

setState 是异步还是同步更新 this.state

所谓同步还是异步指的是调用 setState 之后是否马上能得到最新的 state

setState 的异步

setState 的“异步”并不是说内部由异步代码实现,相反其执行的过程和代码都是同步的,只是合成事件和钩子函数的调用顺序在更新之前,导致在合成事件和钩子函数中没法立马拿到更新后的值,形式了所谓的“异步”。

可以通过第二个参数 setState(partialState, callback) 中的 callback 拿到更新后的结果。:

this.setState((state, props) => ({
  counter: state.counter + props.increment
}));

一个具体例子:

handleClickOnLikeButton () {
  this.setState((prevState) => {
    return { count: 0 }
  })
  this.setState((prevState) => {
    return { count: prevState.count + 1 } // 上一个 setState 的返回是 count 为 0,当前返回 1
  })
  this.setState((prevState) => {
    return { count: prevState.count + 2 } // 上一个 setState 的返回是 count 为 1,当前返回 3
  })
  // 最后的结果是 this.state.count 为 3
}

先说结论:既可以异步更新也可以同步更新

  • 异步:

    • 合成事件的回调中或在生命周期函数中直接调用
    • concurrent 模式下都为异步 concurrent 模式Demo
  • 同步:(legacy 模式下才生效)

    • 异步代码中调用(如setTimeout, Promise, MessageChannel等)

      异步代码中调用 setState,由于js的异步处理机制,异步代码会暂存,等待同步代码执行完毕再执行,此时 React 的批处理机制已经结束,因而直接更新。

    • 监听原生事件而非 React 的合成事件,在原生事件的回调函数中执行 setState 就是同步的。原因是原生事件不会触发 React 的批处理机制,因而调用 setState 会直接更新

三种模式(当前 v17)

  • legacy 模式: ReactDOM.render(<App />, rootNode)。这是当前 React app 使用的方式。当前没有计划删除本模式,但是这个模式可能不支持这些新功能。
  • blocking 模式: ReactDOM.createBlockingRoot(rootNode).render(<App />)。目前正在实验中。作为迁移到 concurrent 模式的第一个步骤。
  • concurrent 模式: ReactDOM.createRoot(rootNode).render(<App />)。目前在实验中,未来稳定之后,打算作为 React 的默认开发模式。这个模式开启了所有的新功能。
    • 可中断渲染模式。在 Concurrent 模式中,渲染不是阻塞的,优先级高的任务可以中断渲染从而优先执行
    • React 可以在不同版本的树上进行切换,一个是你屏幕上看到的那个版本,另一个是它“准备”接下来给你显示的版本
    • Concurrent 模式减少了防抖和节流在 UI 中的需求

原因

出于性能考虑,React 可能会把多个 setState() 调用合并成一个调用。

this.setState() 被调用的时候,React 会调用 render 方法来重新渲染UI,渲染 UI 的过程其实就是操作 DOM 的过程,DOM 的操作对性能的损耗是非常严重的,所以 React 为了提高整体的渲染性能,会将一次渲染周期中的 state 进行合并,再一次性的渲染,这样可以避免频繁调用 setState 导致频繁的操作 DOM 了,从而提高渲染性能。

至于实现原理,由于代码不断的更新优化,也许以后还会更新,所以知道有这个逻辑就好,需要的时候再去阅读当前版本的代码。平时只要知道不需要担心多次进行 setState 会带来性能问题就行。

比如之前是通过 isBatchingUpdates 这个布尔属性判断是否合并更新。到后来更新 Fiber 架构后,是否同步更新的判断逻辑放进了 ReactFiberWorkLoop 中,最新又有变动,重构了 Fiber.expirationTime 并引入 Fiber.lanes源码位置

在线查看

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
    this.handleAddCount = this.handleAddCount.bind(this);
    this.btnRef = React.createRef();
  }
  componentDidMount() {
    this.handleAddCount(null, 'a');

    setTimeout(this.handleAddCount, 1 * 1000);

    Promise.resolve().then(() => {
      this.handleAddCount(null, 'b');
    });

    const button = this.btnRef.current;
    if (button) {
      button.addEventListener("click", this.handleAddCount);
    }
  }
  componentWillUnmount() {
    const button = this.btnRef.current;
    if (button) {
      button.removeEventListener("click", this.handleAddCount);
    }
  }
  handleAddCount(e, key = 'c') {
    console.log(`${key}-0`, this.state.count);
    this.setState({ count: this.state.count + 1 });
    console.log(`${key}-1`, this.state.count);
    this.setState({ count: this.state.count + 100 });
    console.log(`${key}-2`, this.state.count);
  }
  render() {
    return (
      <div>
        <p>count: {this.state.count}</p>
        <div>
          <button onClick={this.handleAddCount}>React 合成事件改变 state</button>
          <button ref={this.btnRef}>addEventListener 事件改变 state</button>
        </div>
      </div>
    );
  }
}

const domContainer = document.querySelector("#app");

// legacy 模式
ReactDOM.render(<App />, domContainer);

扩展:下面 4 次打印都是什么值?详细讨论

class Example extends React.Component {
  constructor() {
    super();
    this.state = {
      val: 0
    };
  }
  componentDidMount() {
    this.setState({val: this.state.val + 1});
    console.log(this.state.val);    // 第 1 次 log

    this.setState({val: this.state.val + 1});
    console.log(this.state.val);    // 第 2 次 log

    setTimeout(() => {
      this.setState({val: this.state.val + 1});
      console.log(this.state.val);  // 第 3 次 log

      this.setState({val: this.state.val + 1});
      console.log(this.state.val);  // 第 4 次 log
    }, 0);
  }
  render() {
    return null;
  }
};

分情况:

  • legacy 模式:0 0 2 3
  • concurrent 模式:0 0 1 1

参考

Puppeteer 安装加速

Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools Protocol 控制 Chromium 或 Chrome。Puppeteer 默认以 headless 模式运行,但是可以通过修改配置文件运行“有头”模式。

Puppeteer 可以做的事情很多,常见的有抓取页面(做爬虫)、SSR、自动化测试以及前端页面性能分析等等。它其实就是一个“运行在 node 端的 Chrome 浏览器”。

安装 Puppeteer 的时候默认会下载最新版本的 Chromium,这可是上百 MB 的大家伙,国内网络环境不理想的情况下,很难下载成功。所以经常会出现因为下载 Chromium 失败而导致整个安装过程失败的问题。就像下面这样:(npm 源是官方源,未使用代理)

Puppeteer Environment Variables

Puppeteer 寻找某些环境变量来帮助其操作。 下面是 node env 中使用的变量,另一种就是在 npm config 中使用全小写的变量名设定。

  • HTTP_PROXY, HTTPS_PROXY, NO_PROXY - 定义用于下载和运行 Chromium 的 HTTP 代理设置。
  • PUPPETEER_SKIP_CHROMIUM_DOWNLOAD - 请勿在安装步骤中下载绑定的 Chromium。
  • PUPPETEER_DOWNLOAD_HOST - 覆盖用于下载 Chromium 的 URL 的主机部分。
  • PUPPETEER_CHROMIUM_REVISION - 在安装步骤中指定一个你喜欢 puppeteer 使用的特定版本的 Chromium。
  • PUPPETEER_EXECUTABLE_PATH - 指定一个 Chrome 或者 Chromium 的可执行路径,会被用于 puppeteer.launch

解决

1 使用 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD 环境变量跳过 Chromium 的下载

首先造成 Puppeteer 安装失败的原因是 Chromium 下载失败导致的,所以可以先跳过 Chromium 的下载。上图在安装的输出信息中也能看到提示 ERROR: Failed to set up Chromium r848005! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.

使用下面的安装命令:

env PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm i puppeteer

顺利完成 Puppeteer 的安装了,但是现在还不能使用,运行代码会报错

➜ node index.js
(node:56008) UnhandledPromiseRejectionWarning: Error: Could not find expected browser (chrome) locally. Run `npm install` to download the correct Chromium revision (848005).

因为 Puppeteer 运行依赖 Chromium ,所以需要在项目中指定 Chromium 的路径,我们可以手动下载当前 Puppeteer 所依赖的版本(报错信息中有版本号),也可以在 node_modules/puppeteer/lib/cjs/puppeteer/revisions.js 中找到

exports.PUPPETEER_REVISIONS = {
    chromium: '848005',
    firefox: 'latest',
};

然后通过 taobao 镜像网站下载对应版本的 Chromium 文件。下载下来的文件直接解压,尽量不要重命名,比如 macOS 对应解压出来后的文件名是 “chrome-mac”。

有三种方式让 Puppeteer 使用我们下载好的 Chromium:

  1. puppeteer.launch([options]) 设置 options 中的 executablePath 属性,传入 Chromium 可执行文件的路径即可
const browser = await puppeteer.launch({executablePath: 【路径】});
  1. 通过 env 设置环境变量运行 node

    env PUPPETEER_EXECUTABLE_PATH=./chrome-mac/Chromium.app/Contents/MacOS/Chromium node index.js
  2. 将下载的文件放入 Puppeteer 模块内,比如 macOS 中路径为 node_modules/puppeteer/.local-chromium/mac-848005/chrome-mac/Chromium.app/Contents/MacOS/Chromium

2 通过 PUPPETEER_DOWNLOAD_HOST 设置国内镜像【推荐】

运行安装命令的时候,使用环境变量

env PUPPETEER_DOWNLOAD_HOST=https://npm.taobao.org/mirrors npm i puppeteer

或者在 .npmrc 中设置镜像地址

# .npmrc
puppeteer_download_host=https://npm.taobao.org/mirrors

对比发现不设置下载镜像地址时,我本地安装时间非常长,并且很容易中间断掉而失败

> node install.js

Downloading Chromium r848005 - 110.5 Mb [=                   ] 2% 17198.2s ^C

但是设置了镜像地址后,总的安装时间只需要二三十秒。

3 设置 npm registry 为 taobao 源

# .npmrc

home=https://npm.taobao.org

亲测,时间也缩短为几十秒,且成功下载。

对数公式及证明

采用LaTex语法写数学公式,github issue 暂不支持该语法

什么是对数函数

对数函数是指数函数的反函数,指数函数 $Y=a^X$ 对应的对数函数形式为 $X = \log_aY$

**根据 $a^X = a^{\log_aY}$ ,可得 $a^{\log_aY} = Y$ **。【①】

这个结论很重要,下面的证明过程需要用到。

下面的证明默认条件成立的前提 $a&gt;0$,且 $a≠1$

$\log_aMN = \log_aM + \log_aN$

[证明]

$a^{log_aMN} = MN$ 【根据①式得】

$a^{\log_aMN} = a^{\log_aM} × a^{\log_aN}$ 【根据①式替换上式等号右边】

$a^{\log_aMN} = a^{\log_aM + \log_aN}$ 【根据同底指数幂想加 $a^{m+n} = a^m × a^n $ 替换上式等号右边】

证得 $\log_aMN = \log_aM + \log_aN$ 【②】

$\log_a\dfrac{M}{N} = \log_aM - \log_aN$

证明过程同上,依据①式和 $a^{m-n} = \frac{a^m}{a^n}$ 【③】

$\log_aM^n = n\log_aM$

[证明]

$a^{\log_aM^n} = M^n$

$a^{\log_aM^n} = (a^{\log_aM})^n$ 【变换上式等号右边】

$a^{\log_aM^n} = a^{(\log_aM) × n}$ 【根据 $(a^m)^n = a^{m × n}$

$a^{\log_aM^n} = a^{n\log_aM}$

证得 $\log_aM^n = n\log_aM$ 【④】

$\log_{a^n}M = \dfrac{1}{n}\log_aM$

[证明]

$(a^n)^{\log_{a^n}M} = M$

$a^{n × (\log_{a^n}M)} = a^{\log_aM}$

$n × \log_{a^n}M = \log_aM$ 【上式的指数】

证得 $\log_{a^n} = \dfrac{1}{n}\log_aM$ 【⑤】

换底公式 $\log_ba = \dfrac{\log_ca}{\log_cb}$

[证明]

$a = c^m,b=c^n$

$\log_ba = \log_{c^n}c^m$

根据 ④ 和 ⑤,可得

$\log_ba = \dfrac{m}{n}$

$∵ m = \log_ca, n=\log_cb$

$∴ \log_ba = \dfrac{\log_ca}{\log_cb}$ 【⑥】

换底公式还有很多变换形式,掌握一种就行

倒数公式 $\frac{1}{\log_ab} = \log_ba$

[证明]

根据 ⑥,$\log_ab = \dfrac{\log_cb}{\log_ca}$

$∴ \dfrac{1}{\log_ab} = \dfrac{\log_ca}{\log_cb}$

再根据 ⑥, $\dfrac{\log_ca}{\log_cb} = \log_ba$

$∴ \dfrac{1}{\log_ab} = \log_ba$

Capture Value 特性

下面代码打印值是什么?

const Example = () => {
  const [val, setVal] = React.useState(0);
  React.useEffect(() => {
    setVal(val + 1);
    console.log(val);
    setVal(val + 1);
    console.log(val);
    setTimeout(() => {
      setVal(val + 1);
      console.log(val);
      setVal(val + 1);
      console.log(val);
    }, 2000);
  }, []);
  return (<div>{val}</div>);
};

ReactDOM.render(<Example />, document.querySelector("#example"));

打印值为 0 0 0 0。这涉及到了一个概念 Capture Value

Capture Value 特性

现在可以忘记 Class 组件的那一套理论。

Capture Value 的特性不容易想象,可以借助一个简单例子解释:

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

在每次点击时,count 只是一个不会变的常量,而且也不存在利用 Proxy 的双向绑定,只是一个常量存在于每次 Render 中。

例如,每次点击按钮的时候,count 值都会固化为 1,2,3,... 保存在每次渲染中,就像下面的伪代码一样:

// 初次渲染,获取的 count 的初始值
function Counter() {
  const count = 0; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>;
  // ...
}

// 点击一次,函数被调用一次
function Counter() {
  const count = 1; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>;
  // ...
}

// 再次点击,函数再次被调用
function Counter() {
  const count = 2; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>;
  // ...
}

可以认为每次 Render 的内容都会形成一个快照并保留下来,因此当状态变更而 Rerender 时,就形成了 N 个 Render 状态,而每个 Render 状态都拥有自己固定不变的 Props 与 State。这就是 Capture Value 特性。

其实不仅 propsstate 有 Capture Value 特性,函数在每次渲染时也是独立的:

const Example = () => {
  const [temp, setTemp] = React.useState(5);

  const log = () => {
    setTimeout(() => {
      console.log("3 秒前 temp = 5,现在 temp =", temp);
    }, 3000);
  };

  return (
    <div
      onClick={() => {
        log();
        setTemp(3);
        // 3 秒前 temp = 5,现在 temp = 5
      }}
    >
      render
    </div>
  );
};

如果连点两次,会得到这样的结果:

"3 秒前 temp = 5,现在 temp =" 5
"3 秒前 temp = 5,现在 temp =" 3

第一次点击按钮时,temp 的值可以看作常量 5 保存在本次 Rerender 中,当执行 setTemp(3) 时由于引起状态变更,会交由一个全新的 Render 渲染,但是在当前 Rerender 中执行了 log 函数,所以函数内拿到的是本次 Rerender 中的 temp,也就是 5。这也就是为什么第一次输出的值是"3 秒前 temp = 5,现在 temp =" 5。可以看出 templog 都拥有 Capture Value 特性。

useEffect 也一样具有 Capture Value 的特性

const Example = () => {
  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    setTimeout(() => {
      alert("count: " + count);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>增加 count</button>
      <button onClick={() => setCount(count - 1)}>减少 count</button>
    </div>
  );
};

当点击“增加”后紧接着点击"减少",等待一会儿,浏览器先弹出 count: 1 然后弹出 count: 0

很容易理解,useEffect 在实际 DOM 渲染完毕后执行,由于 useEffect 也一样具有 Capture Value 的特性,也就是说每次 Render 都有自己的 Effects,所以每次 Render 过程中,useEffect 拿到的也都是 count 固化下来的“常量”。

由于 Capture Value 特性,所以在 useEffect 中每次 “注册” “回收” 拿到的都是成对的固定值。

这也就是为什么可以在 useEffect 的返回函数中对注册的监听进行销毁了。

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
  };
});

回到一开始的代码中,现在就很容易理解为什么打印值都为 0 了:(先不管 Dependencies)这四次打印其实都是在同一 Render 的 Effects 中,而同一 Render 中的 count 其实就是固定的一个值,即使有异步执行函数,拿到的也都是相同的值。

绕过 Capture Value

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。也就是说,useRef 会在每次渲染时返回同一个 ref 对象,并且变更 .current 属性不会引发组件重新渲染。

根据这一点可以绕过 Capture Value 的特性。例如将上面的例子修改下

const Example = () => {
  const [count, setCount] = React.useState(0);
  const latestCount = React.useRef(count);

  React.useEffect(() => {
    latestCount.current = count;
    setTimeout(() => {
      alert("count: " + latestCount.current);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>增加 count</button>
      <button onClick={() => setCount(count - 1)}>减少 count</button>
    </div>
  );
};

现在比如连续点击三下增加按钮,等待一段时间后,会连续弹出三个浏览器弹框,且弹框内容都是 count: 3。原因是 latestCount 不受 Capture Value 特性的影响,每次 Render 都是获取的同一个值,当 setTimeout 事件循环开始的时候,latestCount.current 已经变成 3 了。

useState 更新的问题

请思考下面代码:

function App() {
  const [count, setCount] = React.useState(0);
  const onClick = () => {
    setCount(count + 1);
    setCount(count + 1);
  };
  return (
    <div className="App">
      <h1>count: {count}</h1>
      <button onClick={onClick}>click me</button>
    </div>
  );
}

点击一次按钮,页面上显示的是多少?答案是 1。为什么不是 2

由于 Function Component 内的函数拥有 Capture Value 特性的原因,虽然 setCount 执行了两次,但是入参中的 count 都是 0。在 setCount 之后,会分别生成两个新的 count,值都等于 0 + 1,但彼此互不影响。两次 setCount 会导致两次 render,但是只有最新一次的 render 有效,也就是只有最后一次 setCount 改变的状态值。

这看起来就像是 useState 的异步更新(我也不知道该不该这样认为,虽然这有 Capture Value 的原因)。那么类比 Class Component 的 setState,如果下一次状态更新依赖前一个状态值,可以通过传入一个函数来获取旧的 state 值:

const onClick = () => {
  setCount(c => c + 1);
  setCount(c => c + 1);
};

参考

小程序本地文件上传

微信小程序上传文件

选择图片 wx.chooseImage(Object object)

wx.chooseImage({
  count: 1,               								 // 最多可以选择的图片张数
  sizeType: ['original', 'compressed'],    // 图片的尺寸:original-原图,compressed-压缩图
  sourceType: ['album', 'camera'],         // 图片来源选项:album-相册,camera-相机
  success (res) {
    // tempFilePath 可以作为 img 标签的 src 属性显示图片,图片的本地临时文件路径列表
    const tempFilePaths = res.tempFilePaths
  }
})

效果如下:

image-20220613010254491 WechatIMG53

左苹果,右安卓

选择图片没什么好说的,功能基本一致,也体验也良好。

选择非图片文件

使用 wx.chooseMessageFile(Object object) API【官方】

wx.chooseMessageFile(Object object) 方法是官方给出的 API,用来解决上传非图片类型的文件的问题。

用户体验比较差,甚至很多用户不买账,开发者也很头痛。

说白了,就是现在只能通过聊天记录选择文件,期待 wx.choosFile() 的出现。

它的作用是弹出微信的会话列表,然后让用户选择一个会话(可以是一个人、微信群或者文件传输助手等),然后再选择这个会话中的文件进行上传。

image-20220613003910141 image-20220613004028889

开发者工具中的模拟器使用 wx.chooseMessageFile(Object object) 会报错。

<web-view> 嵌套 H5 页面使用 <input type="file"> 实现

个人开发类型的小程序不支持使用 web-view

如果不想走微信的文件传输助手中转一下,或者用户非得要直接调用手机的文件管理器,目前可以使用这种方案。

1. 添加业务域名

将 H5 所在页面的域名添加到小程序开发设置的【业务域名】中,否则小程序的 <web-view> 会限制打开非业务域名的页面:

image-20220615190644766 image-20220620100032363

<web-view> 打开的页面必须为 https 服务,打开的页面如果有重定向(301或302)则重定向经历的页面也要添加进业务域名;webview 中可以嵌套 <iframe> 元素,但是 iframe 的地址必须是业务域名。

2. H5 页面相关
  • H5 页面中 html 的 title 会自动放到小程序的头部作为标题,所以需要在 H5 页面中修改 html 的 <title> 才能修改实际显示标题

  • 如果要改变导航栏样式,就在该页面的 json 配置文件中配置即可,但标题文字会被覆盖,所以只能改标题文字颜色(黑,白)和标题背景色

  • 在 H5 中可以无限跳转,对于导航条返回和物理键返回都会回到上一个页面直到退出 webview,就像 history.back

  • webview 中可以正常使用 ajax 之类的操作,所以我们可以用 axios 这样的第三方库进行请求。这也是使用 H5 做表单上传的基础 [H5 上传代码](##基于 Vue + vant2 的简单上传表单)

3. 在小程序中使用 <web-view>

将 webview 放到单独的一个 Page 中,也就是一个微信小程序页面中只放一个 web-view

<web-view src="{{src}}"></web-view>

然后配合 Page 实例的 onLoad 方法来获取 url

Page({
  data: {
    src: "",
  },
  onLoad(options) {
    const { src } = options;
    if(src) {
      this.setData({
        src: decodeURIComponent(src),
      })
    } else {
      // ...
    }
  },
})

在其他页面就可以通过路由跳转打开 webview 了

wx.navigateTo({
  url: `/src/components/webview/index?src=${encodeURIComponent(url)}`,
})

PS: <web-view> 的页面中可不可以有其他组件?可以,但是没有意义

<web-view/> 会自动铺满整个页面,并覆盖其他组件,所以添加其他组件(包括自己的导航栏)都不会被显示出来。

4. 关闭 webview 并回到当前页面

在 H5 页面中引入 jweixin,这样就可以在 H5 页面中调用小程序的一些功能,例如跳转到小程序内的路由:

// 这里使用动态引入 script(因为这个 H5 所在服务的其它页面都不需要 jweixin),也可以直接在入口的 html 文件中引入
const script = document.createElement('script')
script.type = 'text/javascript'
script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
document.body.appendChild(script)

然后在页面中就可以调用小程序提供的方法了

wx.miniProgram.navigateTo({url: '/pages/index/index'})
// 或者
wx.miniProgram.redirectTo({url: '/pages/index/index'});

如果想要保持小程序来源页面中的数据不被清除掉,可以通过返回操作回到来源页面

wx.miniProgram.navigateBack()

效果就和点击导航头上的返回按钮一样

image-20220620111108725

5. 小程序和 web-view 的通信
  • 小程序到 webview 通信,通过给 <web-view>src 属性的 url 带上查询参数传参

    // const src = `${HTTPS_HOST}candidate/#/publicUpload?token=${token}&access_token=${access_token}&fieldKey=${_that.properties.fieldKey}`
    <web-view src="{{src}}"></web-view>

    在 H5 页面就可以通过 window.location 拿到这些参数(基于 Vue2 的 H5 页面)

    export default {
      mounted () {
        const { hash } = window.location
        const { token, access_token, fieldKey } = qs(hash);
        this.token = token;
        this.access_token = access_token;
        this.fieldKey = fieldKey;
      },
    }
  • webview 到小程序的通信有两种情况

    • 简单且量少

      可以在 H5 页面跳转回小程序的时候,在 url 上带上查询参数即可,和上面小程序到 webview 通信一样

      wx.miniProgram.navigateTo({
        url:'/pages/index/index?test=testtest',
      })
    • 复杂且量大,官方提供的唯一接口是 postMessage 方法

      • 首先要在小程序中,在 <web-view> 组件里绑定事件

        <web-view src="{{src}}" bindmessage="postMessage"></web-view>
      • 定义事件处理方法

        Page({
          // ...
          postMessage(msg) {
            // ...
          },
        })
      • 然后 H5 页面中调用 postMessage 方法

        wx.miniProgram.postMessage 向小程序发送消息,会在特定时机(小程序后退、组件销毁、分享)触发组件的 message 事件

        wx.miniProgram.postMessage({ data: res.data })

需要特别注意的是,网页在使用 postMessage 给小程序发送消息时,小程序不一定会立即收到,而是会在特定时机触发:

image-20220620131839711

PS:【业务场景】小程序接收到的来自 webview 的信息如何传给其他页面

那就需要使用小程序的 【页面间通信】:

如果一个页面由另一个页面通过 wx.navigateTo 打开,这两个页面间将建立一条数据通道:

  • 被打开的页面可以通过 this.getOpenerEventChannel() 方法来获得一个 EventChannel 对象;
  • wx.navigateTosuccess 回调中也包含一个 EventChannel 对象。

这两个 EventChannel 对象间可以使用 emiton 方法相互发送、监听事件。

正好我们在上面说过,将 <web-view> 放在一个单独的页面,需要使用 webview 的时候就通过路由跳转打开,所以可以使用页面间通信。

在 webview 页面:

Page({
  // ...
  postMessage(msg) {
    const eventChannel = this.getOpenerEventChannel()
    const data = msg && msg.detail ? msg.detail : {}
    eventChannel.emit('getFileRes', data);
  },
})

在跳转前的页面

wx.navigateTo({
  url: `/src/components/webview/index?src=${encodeURIComponent(url)}`,
  events: {
    getFileRes: function(data) {
      if(data && data.data) {
        const fileList = data.data;
        _that.setData({
          fileList: fileList,
        })
        const file = fileList[0];
        if(file) {
          _that.setData({
            fileName: file.fileName || '',
            fileUrl: file.fileUrl || '',
          })
        }
      }
    }
  },
})

优化用户体验

文件选择时,由用户决定哪种方式

目前见到处理小程序上传最好的一个方案,虽然无法突破微信自身的限制,但是用户体验已经得到最大程度的提升:

WechatIMG46

H5 页面上传操作提示

没有合适的图,拿人家做的比较好的做个参考**【侵删】**:

image-20220620130252633

基于 Vue + vant2 的 H5 上传表单

<template>
  <!-- accept="" 不限制文件类型 -->
  <div class="heeou-public-uploader">
    <van-uploader
      upload-icon="plus"
      accept="*"
      v-model="fileList"
      :before-read="beforeRead"
      :after-read="afterRead"
      :max-size="maxSize"
      @oversize="onOversize"
      :show-upload="showUpload"
      :disabled="uploading"
      :deletable="false"
    />
    <div v-if="showUpload" class="heeou-public-uploader-text">选择文件</div>
    <div v-else class="heeou-public-uploader-btns">
      <van-button
        :disabled="uploading"
        color="#2f54eb"
        @click="uploadFile"
      >开始上传
      </van-button>
      <van-button
        plain
        :disabled="uploading"
        color="#323233"
        @click="clearChoose"
      >清除选择
      </van-button>
    </div>
  </div>
</template>

<script>
import { Toast } from 'vant'
import axios from 'axios'

export default {
  mounted () {
    const script = document.createElement('script')
    script.type = 'text/javascript'
    script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
    document.body.appendChild(script)
  },
  data () {
    return {
      fileList: [],
      maxSize: 30 * 1024 * 1024,
      uploading: false,
    }
  },
  computed: {
    showUpload () {
      return this.fileList.length === 0
    },
  },
  methods: {
    beforeRead (file) {
      this.$toast.loading({
        duration: 0, // 持续展示 toast
        message: '加载中...',
        forbidClick: true,
        loadingType: 'spinner',
      })
      this.uploading = true
      return true
    },
    afterRead (file) {
      this.$toast.clear()
      this.uploading = false
    },
    onOversize () {
      Toast('文件大小不能超过 30 MB')
    },
    clearChoose () {
      this.fileList = []
    },
    async uploadFile () {
      try {
        if (this.fileList.length !== 1) {
          // 只支持单文件上传
          return false
        }
        const toast = this.$toast.loading({
          duration: 0, // 持续展示 toast
          message: '上传中...',
          forbidClick: true,
          loadingType: 'spinner',
        })
        this.uploading = true
        const { file } = this.fileList[0]
        const formData = new FormData()
        formData.append('file', file, file.name)

        const actionUrl = `xxx`
        const res = await axios.post(actionUrl, formData, {
          baseURL: process.env.baseUrl,
          timeout: 600000,
          headers: {
            'Accept': '*/*',
            'Authorization': `Bearer ${access_token}`,
          },
          withCredentials: true,
          onUploadProgress: progressEvent => {
            const complete = (progressEvent.loaded / progressEvent.total * 100 | 0)
            toast.message = `已上传 ${complete}%`
          },
        })
        this.$toast.clear()
        this.uploading = false
        if (res.data && res.data.fileUrl) {
          this.$toast.success('上传成功')
          this.fileList = [];
        } else {
          this.$toast.fail('上传失败')
        }
      } catch (e) {
        this.$toast.clear()
        this.uploading = false
        this.$toast.fail('上传失败')
      }
    },
  },
}
</script>

<style type="text/scss" lang="scss" rel="stylesheet/scss" scoped>
$color: #2f54eb;
.heeou-public-uploader {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 100vw;
  height: 100vh;

  &-text {
    color: $color;
  }

  /deep/ .van-uploader {
    margin-bottom: 16px;

    &__upload {
      margin: 0;
      border: 1px solid $color;
      background: transparent;
      border-radius: 50%;

      &-icon {
        font-size: 36px;
        color: $color;
      }
    }

    &__file {
      width: 200px;
      height: 200px;
    }

    &__preview {
      margin: 0;

      &-image {
        width: 200px;
        height: 200px;
      }
    }
  }
}
</style>

参考

less 实现在一定范围内循环

与Sass不同,Less (目前v3.12.0)中没有用于编写循环的内置 @for@each 指令,但它仍然可以使用递归 mixins 编写。递归mixins 只不过是一个不断调用自己的混合。

使用 Less 编写的循环有四个关键条件:

  1. 带有 guard 表达式的 mixin,当满足循环的退出标准时,用于终止循环。类似 JavaScript for 循环语句( for([initialization]; [condition]; [final-expression]) )中的 [condition]
  2. 对 mixin 执行第一次迭代的调用
  3. 从内部调用 mixin 以使其递归
  4. mixin 的其他内容等同于 JS for 循环语句的 statement 部分,即需要在循环内执行的部分

下面这个例子,是实现在一定范围内进行循环。注意动态的选择器写法和content的值必须是字符串。

// when 后面跟着 guard 表达式,用来设定循环范围,并在条件满足时终止循环
.for-loop(@index, @min, @max) when ((@index >= @min) and (@index <= @max)) {
  // statement
  .item {
    &[data-value="@{index}px"]::before {
      content: "@{index}px";
      font-size: @index + 0px;
    }
  }
  // 内部调用,递归
  .for-loop(@index + 1, @min, @max);
}
// 第一次迭代调用
.for-loop(20, 20, 22);

编译为CSS:

.item[data-value="20px"]::before {
  content: "20px";
  font-size: 20px;
}
.item[data-value="21px"]::before {
  content: "21px";
  font-size: 21px;
}
.item[data-value="22px"]::before {
  content: "22px";
  font-size: 22px;
}

节流和防抖

Debounce 和 throttle 是我们在 JavaScript 中使用的两个概念,用于增强对函数执行的控制,这在事件处理程序中特别有用。这两种技术都回答了同一个问题“一段时间内某个函数的调用频率是多少?”

📚 相关链接

文中内容多数来自以下文章,侵删!

📚 Debounce

1. 概念

  • 本是机械开关的“去弹跳”概念,弹簧开关按下后,由于簧片的作用,接触点会连续接触断开好多次,如果每次接触都通电对用电器不好,所以就要控制按下到稳定的这段时间不通电

  • 前端开发中则是一些频繁的事件触发

    • 鼠标(mousemove...)键盘(keydown...)事件等
    • 表单的实时校验(频繁发送验证请求)
  • 在 debounce 函数没有再被调用的情况下经过 delay 毫秒后才执行回调函数,例如

    • mousemove事件中,确保多次触发只调用一次监听函数
    • 在表单校验的时候,不加防抖,依次输入user,就会分成uususe,user四次发出请求;而添加防抖,设置好时间,可以实现完整输入user才发出校验请求

2. 思路

  • 由 debounce 的功能可知防抖函数至少接收两个参数(流行类库中都是 3 个参数)

    • 回调函数fn
    • 延时时间delay
  • debounce 函数返回一个闭包,闭包被频繁的调用

    • debounce 函数只调用一次,之后调用的都是它返回的闭包函数
    • 在闭包内部限制了回调函数fn的执行,强制只有连续操作停止后执行一次
  • 使用闭包是为了使指向定时器的变量不被gc回收

    • 实现在延时时间delay内的连续触发都不执行回调函数fn,使用的是在闭包内设置定时器setTimeOut
    • 频繁调用这个闭包,在每次调用时都要将上次调用的定时器清除
    • 被闭包保存的变量就是指向上一次设置的定时器

3. 实现

  • 符合原理的简单实现

    function debounce(fn, delay) {
      var timer;
      return function() {
        // 清除上一次调用时设置的定时器
        // 计时器清零
        clearTimeout(timer);
        // 重新设置计时器
        timer = setTimeout(fn, delay);
      };
    }
  • 简单实现的代码,可能会造成两个问题

    • this指向问题。debounce 函数在定时器中调用回调函数fn,所以fn执行的时候this指向全局对象(浏览器中window),需要在外层用变量将this保存下来,使用apply进行显式绑定

      function debounce(fn, delay) {
        var timer;
        return function() {
          // 保存调用时的this
          var context = this;
          clearTimeout(timer);
          timer = setTimeout(function() {
            // 修正 this 的指向
            fn.apply(this);
          }, delay);
        };
      }
    • event对象。JavaScript 的事件处理函数中会提供事件对象event,在闭包中调用时需要将这个事件对象传入

      function debounce(fn, delay) {
        var timer;
        return function() {
          // 保存调用时的this
          var context = this;
          // 保存参数
          var args = arguments;
          clearTimeout(timer);
          timer = setTimeout(function() {
            console.log(context);
            // 修正this,并传入参数
            fn.apply(context, args);
          }, delay);
        };
      }

4. 完善(underscore的实现)

  • 立刻执行。增加第三个参数,两种情况

    • 先执行回调函数fn,等到停止触发后的delay毫秒,才可以再次触发(先执行
    • 连续的调用 debounce 函数不触发回调函数,停止调用经过delay毫秒后才执行回调函数(后执行
    • clearTimeout(timer)后,timer并不会变成null,而是依然指向定时器对象
    function debounce(fn, delay, immediate) {
      var timer;
      return function() {
        var context = this;
        var args = arguments;
        // 停止定时器
        if (timer) clearTimeout(timer);
        // 回调函数执行的时机
        if (immediate) {
          // 是否已经执行过
          // 执行过,则timer指向定时器对象,callNow 为 false
          // 未执行,则timer 为 null,callNow 为 true
          var callNow = !timer;
          // 设置延时
          timer = setTimeout(function() {
            timer = null;
          }, delay);
          if (callNow) fn.apply(context, args);
        } else {
          // 停止调用后delay时间才执行回调函数
          timer = setTimeout(function() {
            fn.apply(context, args);
          }, delay);
        }
      };
    }
  • 返回值与取消 debounce 函数

    • 回调函数可能有返回值。
      • 后执行情况可以不考虑返回值,因为在执行回调函数前的这段时间里,返回值一直是undefined
      • 先执行情况,会先得到返回值
    • 能取消 debounce 函数。一般当immediatetrue的时候,触发一次后要等待delay时间后才能再次触发,但是想要在这个时间段内想要再次触发,可以先取消掉之前的 debounce 函数
    function debounce(fn, delay, immediate) {
      var timer, result;
      var debounced = function() {
        var context = this;
        var args = arguments;
        // 停止定时器
        if (timer) clearTimeout(timer);
        // 回调函数执行的时机
        if (immediate) {
          // 是否已经执行过
          // 执行过,则timer指向定时器对象,callNow 为 false
          // 未执行,则timer 为 null,callNow 为 true
          var callNow = !timer;
          // 设置延时
          timer = setTimeout(function() {
            timer = null;
          }, delay);
          if (callNow) result = fn.apply(context, args);
        } else {
          // 停止调用后delay时间才执行回调函数
          timer = setTimeout(function() {
            fn.apply(context, args);
          }, delay);
        }
        // 返回回调函数的返回值
        return result;
      };
    
      // 取消操作
      debounced.cancel = function() {
        clearTimeout(timer);
        timer = null;
      };
    
      return debounced;
    }
  • ES6 写法

    function debounce(fn, delay, immediate) {
      let timer, result;
      // 这里不能使用箭头函数,不然 this 依然会指向 Windows对象
      // 使用rest参数,获取函数的多余参数
      const debounced = function(...args) {
        if (timer) clearTimeout(timer);
        if (immediate) {
          const callNow = !timer;
          timer = setTimeout(() => {
            timer = null;
          }, delay);
          if (callNow) result = fn.apply(this, args);
        } else {
          timer = setTimeout(() => {
            fn.apply(this, args);
          }, delay);
        }
        return result;
      };
    
      debounced.cancel = () => {
        clearTimeout(timer);
        timer = null;
      };
    
      return debounced;
    }

📚 throttle

1. 概念

  • 固定函数执行的速率

  • 如果持续触发事件,每隔一段时间,执行一次事件

    • 例如监听mousemove事件时,不管鼠标移动的速度,【节流】后的监听函数会在 wait 秒内最多执行一次,并以此【匀速】触发执行
  • windowresizescroll事件的优化等

2. 思路

  • 有两种主流实现方式

    • 使用时间戳
    • 设置定时器
  • 节流函数 throttle 调用后返回一个闭包

    • 闭包用来保存之前的时间戳或者定时器变量(因为变量被返回的函数引用,所以无法被垃圾回收机制回收
  • 时间戳方式

    • 当触发事件的时候,取出当前的时间戳,然后减去之前的时间戳(初始设置为 0)
    • 结果大于设置的时间周期,则执行函数,然后更新时间戳为当前时间戳
    • 结果小于设置的时间周期,则不执行函数
  • 定时器方式

    • 当触发事件的时候,设置一个定时器
    • 再次触发事件的时候,如果定时器存在,就不执行,知道定时器执行,然后执行函数,清空定时器
    • 设置下个定时器
  • 将两种方式结合,可以实现兼并立刻执行和停止触发后依然执行一次的效果

3. 实现

  • 时间戳实现

    function throttle(fn, wait) {
      var args;
      // 前一次执行的时间戳
      var previous = 0;
      return function() {
        // 将时间转为时间戳
        var now = +new Date();
        args = arguments;
        // 时间间隔大于延迟时间才执行
        if (now - previous > wait) {
          fn.apply(this, args);
          previous = now;
        }
      };
    }
    • 触发监听事件,回调函数会立刻执行(初始的previous为 0,除非设置的时间间隔大于当前时间的时间戳,否则差值肯定大于时间间隔)
    • 停止触发后,无论停止时间在哪,都不会再执行。例如,1 秒执行 1 次,在 4.2 秒停止,则第 5 秒不会再执行 1 次
  • 定时器实现

    function throttle(fn, wait) {
      var timer, context, args;
      return function() {
        context = this;
        args = arguments;
        // 如果定时器存在,则不执行
        if (!timer) {
          timer = setTimeout(function() {
            // 执行后释放定时器变量
            timer = null;
            fn.apply(context, args);
          }, wait);
        }
      };
    }
    • 回调函数不会立刻执行,要在 wait 秒后第一次执行,停止触发闭包后,如果停止时间在两次执行之间,则还会执行一次
  • 结合时间戳和定时器实现

    function throttle(fn, wait) {
      var timer, context, args;
      var previous = 0;
      // 延时执行函数
      var later = function() {
        previous = +new Date();
        // 执行后释放定时器变量
        timer = null;
        fn.apply(context, args);
        if (!timeout) context = args = null;
      };
      var throttled = function() {
        var now = +new Date();
        // 距离下次执行 fn 的时间
        // 如果人为修改系统时间,可能出现 now 小于 previous 情况
        // 则剩余时间可能超过时间周期 wait
        var remaining = wait - (now - previous);
        context = this;
        args = arguments;
        // 没有剩余时间 || 修改系统时间导致时间异常,则会立即执行回调函数fn
        // 初次调用时,previous为0,除非wait大于当前时间的时间戳,否则剩余时间一定小于0
        if (remaining <= 0 || remaining > wait) {
          // 如果存在延时执行定时器,将其取消掉
          if (timer) {
            clearTimeout(timer);
            timer = null;
          }
          previous = now;
          fn.apply(context, args);
          if (!timeout) context = args = null;
        } else if (!timer) {
          // 设置延时执行
          timer = setTimeout(later, remaining);
        }
      };
      return throttled;
    }
    • 过程中的节流功能是由时间戳的原理实现,同时实现了立刻执行
    • 定时器只是用来设置在最后退出时增加一个延时执行
    • 定时器在每次触发时都会重新计时,但是只要不停止触发,就不会去执行回调函数 fn

4. 优化完善

  • 增加第三个参数,让用户可以自己选择模式

    • 忽略开始边界上的调用,传入{ leading: false }
    • 忽略结尾边界上的调用,传入{ trailing: false }
  • 增加返回值功能

  • 增加取消功能

    function throttle(func, wait, options) {
      var context, args, result;
      var timeout = null;
      // 上次执行时间点
      var previous = 0;
      if (!options) options = {};
      // 延迟执行函数
      var later = function() {
        // 若设定了开始边界不执行选项,上次执行时间始终为0
        previous = options.leading === false ? 0 : new Date().getTime();
        timeout = null;
        // func 可能会修改 timeout 变量
        result = func.apply(context, args);
        // 定时器变量引用为空,表示最后一次执行,则要清除闭包引用的变量
        if (!timeout) context = args = null;
      };
      var throttled = function() {
        var now = new Date().getTime();
        // 首次执行时,如果设定了开始边界不执行选项,将上次执行时间设定为当前时间。
        if (!previous && options.leading === false) previous = now;
        // 延迟执行时间间隔
        var remaining = wait - (now - previous);
        context = this;
        args = arguments;
        // 延迟时间间隔remaining小于等于0,表示上次执行至此所间隔时间已经超过一个时间窗口
        // remaining 大于时间窗口 wait,表示客户端系统时间被调整过
        if (remaining <= 0 || remaining > wait) {
          if (timeout) {
            clearTimeout(timeout);
            timeout = null;
          }
          previous = now;
          result = func.apply(context, args);
          if (!timeout) context = args = null;
        } else if (!timeout && options.trailing !== false) {
          timeout = setTimeout(later, remaining);
        }
        // 返回回调函数执行后的返回值
        return result;
      };
      throttled.cancel = function() {
        clearTimeout(timeout);
        previous = 0;
        timeout = context = args = null;
      };
      return throttled;
    }
    • 有个问题,leading: falsetrailing: false 不能同时设置
      • 第一次开始边界不执行,但是,第一次触发时,previous为 0,则remaining值和wait相等。所以,if (!previous && options.leading === false)为真,改变了previous的值,而if (remaining <= 0 || remaining > wait)为假
      • 以后再触发就会导致if (!previous && options.leading === false)为假,而if (remaining <= 0 || remaining > wait)为真。就变成了开始边界执行。这样就和leading: false冲突了

📚 总结

对比

  • throttle 和 debounce 是解决请求和响应速度不匹配问题的两个方案。二者的差异在于选择不同的策略

  • 电梯超时现象解释两者区别。假设电梯设定为 15 秒,不考虑容量限制

    • throttle策略:保证如果电梯第 1 个人进来后,15 秒后准时送一次,不等待。如果没有人,则待机、
    • debounce策略:如果电梯有人进来,等待 15 秒,如果又有人进来,重新计时 15 秒,直到 15 秒超时都没有人再进来,则开始运送

ItemSeparatorComponent 设置分割线不显示或粗细不均匀

在FlatList 中我们会用到 ItemSeparatorComponent 设置行与行之间的分隔线组件。并且让该分割线显示为设备屏幕可以显示的最小宽度。

最简单的办法就是设置一个View元素,然后将该元素的下(或上)边框宽度设置为 StyleSheet.hairlineWidth。【当然,如果分割线宽度不是这样的一条细线,也就不会有下面问题】

StyleSheet.hairlineWidth,This constant will always be a round number of pixels (so a line defined by it can look crisp) and will try to match the standard width of a thin line on the underlying platform.

StyleSheet.hairlineWidth是一个常数,始终是像素的整数,并将尝试匹配上设备上细线的标准宽度

import React from "react";
import { StyleSheet, Text, View } from "react-native";

const Separator = () => (
  <View style={styles.Separator} />
);

const styles = StyleSheet.create({
  Separator: {
    borderBottomColor: '#e0e0e0',
    borderBottomWidth: StyleSheet.hairlineWidth
  }
});

但是在实际效果中并不理想,会出现有些分割线不显示,有些分割线变粗,如下:

separator

在stackoverflow上有相应的问题并且尝试了几种办法 ReactNative ListView inconsistent separator lines

const styles = StyleSheet.create({
  Separator: {
    // 无论哪种方案都需要使用marginBottom做hack,注意不是margin
    marginBottom: StyleSheet.hairlineWidth,

    // 【方法一】用border做分割线
    borderBottomColor: '#e0e0e0',
    borderBottomWidth: StyleSheet.hairlineWidth,

   // 【方法二】设置分割线组件高度,然后用背景色填充
   // height: StyleSheet.hairlineWidth,
   // backgroundColor: '#e0e0e0',

   // 【方法三:无效】只用上面的marginBottom,造成一个空隙,然后让父级背景透出来做分隔,这种方法依然是粗细不均匀
  },
});

不使用 ItemSeparatorComponent 设置分隔组件

例如文档中的列表展示示例 https://reactnative.dev/docs/stylesheet#hairlinewidth,通过给每一个元素设置一个下边框来分隔两个元素,而不是使用额外的分隔组件。稍微修改,使其在FlatList中使用,即通过renderItem,在每个元素渲染时加上这个边框【测试在Iphone11Pro和HWP40Pro上均可以正常显示】

import React from 'react';
import { View, FlatList, StyleSheet, Text } from 'react-native';

const App = () => {
  const renderItem = ({ item }) => (
    <View style={styles.item}>
      <Text>{item.title}</Text>
    </View>
  );
  return (
    <FlatList
      data={DATA}
      renderItem={renderItem}
      keyExtractor={item => item.id}
    />
  );
}

const styles = StyleSheet.create({
  item: {
    borderBottomColor: '#e0e0e0',
    borderBottomWidth: StyleSheet.hairlineWidth,
  },
});

问题出现的原因

文档中有说明一种原因,是因为缩小模拟器(这里缩小是指视图的缩放,而不是单纯的尺寸改变),则带有hairline宽度的线可能不可见。在issue中 【StyleSheet.hairlineWidth not visible on devices with decimal PixelRatio #22927】似乎也存在着联系。

可能是因为缩小视图导致,或者某些设备本身的 PixelRatio 就是小数,造成这个常数并没有匹配上设备上细线的标准宽度。从而导致未显示。

这里有一个待验证的做法 https://github.com/facebook/react-native/issues/22927#issuecomment-512158402。

在IOS上,通过nativeScale和scale属性重新计算hairlineWidth的值,已达到可以显示的最终效果

/* * scale / nativeScale = 3 / 2.608 ≈ 1.2 */
const newHairlineWidth = StyleSheet.hairlineWidth * 1.2

web 设备指纹

前段时间,同事问了一个问题:有三台主机,管理页面需要将一个页面发送给某一个设备显示,怎么区分这三个设备?

其实业务需求很简单,就是发请求告诉后台目标设备的唯一标识符。

第一时间想到的是mac地址。但是同事说后端资源有限,需要前端获取。

MAC地址是具有唯一性的地址,它被分配给我们的蓝牙、WiFi和以太网卡,直接被“埋”在我们的设备中。在本地网络中,它也被各个设备用来进行相互通信。

MAC地址也被称作设备的 “物理”地址。对众多设备来说,MAC地址标明了哪个是哪个。而IP地址则表示,设备在不计其数的网络链中的接入位置。

mac 地址作为物理层信息,的确可以唯一标识设备(除非盗取硬件)。但是目前来说这个方案是无法实现的:

  • 浏览器沙盒不可能让你直接访问硬件,更何况是 MAC 这种跟浏览器没关系的信息

  • 使用控件(如 ActiveX 和 Flash,ActiveX 控件有权使用操作系统级别的API),但是可能要回归 IE。。。

  • 除非是桌面端应用(前端的话可以使用 electron 打包一个桌面端应用)

这个方案显然是行不通了。不过同事提出了一个我不曾听过的知识点:设备指纹

其实前面说的需求不重要,最终如何解决的也不重要(最终也没有使用设备指纹)。一切都是为了引出『设备指纹』。

设备指纹

设备指纹是指可以用于唯一标识出该设备的设备特征或者独特的设备标识。 ——百度百科

以下内容来源于知乎专栏:设备指纹详解

早期,在一些对安全要求非常高的线上场景中,例如网上银行在线交易,常常使用纯U盾这样的纯硬件技术去追踪业务主体,也就是定位’’你是谁’’。同时,业务往往都是发生在浏览器页面中,而浏览器是属于操作系统上层的应用程序,运行在其中的脚本代码受到沙盒的限制,所以用户也需要安装一个可以跳出浏览器沙盒直接跟操作系统对接的控件,来读取U盾里面的安全数据。

相对来讲,这很安全。不过随着互联网的发展,这种“控件”+“U盾”的结合方式已经越来越落伍:

  1. 使用控件的用户体验非常差,需要冗长安装、更新流程,普通用户难以操作,使用不够友好

  2. 移动互联网已成为绝对主流,而iOS,Android等移动互联网入口都不支持控件

  3. 不仅仅在移动端,某些控件在pc端适用范围都很小,很多只支持PC上的IE内核浏览器。同时Chrome和Firefox等份额较大的桌面浏览器也在逐步淘汰控件的使用;

  4. 基于控件的本地溢出漏洞层出不穷,用户很容易中木马或者被钓鱼,反而给系统的安全造成严重危害。

由于业务场景实际需要,设备指纹产品应运而生。设备指纹技术可以为每一个操作设备生成一个全球唯一的设备ID,用于唯一表示出该设备特征。

不过无论网络安全如何升级,总会有对抗以及相应的对策。

就算是设备指纹,黑产往往也可以通过伪造新设备或者伪造某些系统底层参数(比如地理位置,imei号等等)的方式来绕过业务的限制,上层设备指纹获取的所有参数都是伪造的,基于这些伪造的数据计算得到的设备ID也就毫无意义了。

设备指纹的生成方式

主动式

主动采集设备信息,比如UA、MAC地址、设备IMEI号、广告追踪ID等与客户端上生成唯一的device_id。

局限性:

  • 不同生态的平台对用户隐私数据开放权限不同,难统一生成唯一识别码;
  • 无法实现Web和App跨域统一
  • 强依赖客户端代码,在反欺诈的场景中对抗性较弱
被动式

在终端设备与服务器通信的过程中,从数据报文的OSI七层协议中,提取出该终端设备的OS、协议栈和网络状态相关的特征集,结合机器学习算法以标识和跟踪具体的终端设备。

避免了主动式的局限性。

混合式

即既有主动采集部分,又有服务端算法生成部分。不多说。

背景以及简单的生成方式搬完了,下面才是笔记的重要部分:web 生成和获取设备指纹

想要了解更多,可以继续阅读这篇文章 设备指纹指南

web 设备指纹

FingerprintJS

FingerprintJS 是一个生成浏览器端指纹的库。原理其实就是通过收集浏览器的诸多属性和可以拿到的操作系统层的数据,通过算法生成一个哈希值作为设备指纹。

可以在 Fingerprintjs2 的代码中查看使用的属性。

Fingerprintjs2 是 FingerprintJS 发布不可兼容性修改前的版本。

上面这些条件只是从概率上降低了重复的可能性,且要保持现在浏览器及上述条件都不变的状态,才能得到相同的浏览器指纹,并不是绝对意义上的唯一性。

介绍 FingerprintJS 并不是为了写怎么使用它,也不是为了推荐它(pro版本不开源)。而是想说通过其代码可以学习到前端生成设备指纹的方法。

web 技术在浏览器中生成指纹

纯前端生成的指纹其实并不稳定,并且对抗性和唯一性都不强。建议只作为学习,不建议上生产。

设备指纹分类

第一类:浏览器提供的信息。浏览器明确提供(例如 JavaScript)多种系统信息,这些信息已知向量如下:

(a) 主要软硬件细节。navigator 和浏览器对象模型 (BOM) 公开了浏览器 / 操作系统厂商和版本、系统语言、平台、user-agent (有时还包括设备型号之类)、已安装插件、浏览器支持的存储机制(如本地存储(localStorage)、索引数据库(indexedDB)、会话存储(sessionStorage)、通过 openDatabase 的 WebSQL)、屏幕分辨率、颜色深度和像素等属性。

(b) WebGL 信息。WebGL 是用来在浏览器内渲染图形的 JavaScript API,公开了底层浏览器和硬件各种属性 (如 GL 版本、最大纹理大小、渲染缓冲区大小、支持的 WebGL 扩展、供应商 / 渲染器字符串)。

(c) 系统时间和时钟漂移。设备的系统时间可通过 JavaScript 访问,并用于推断设备的时区、是否遵守夏令时以及 UTC 时钟漂移。

(d) 电池信息。当提供足够精确的读数时,HTML5 电池状态 API 适用于指纹识别。电池电量可用于在不同网站上对用户端进行短期跟踪,电池容量随着电池老化缓慢下降,但在相对较短的时间内,例如一天内变化不大,可通过监测约 30 秒的放电速率来估计,并用于辅助识别。

(e) 永续 cookie。Evercookie 通过使用 HTML5 本地存储、HTTP ETags 或 Flash Cookie 等多种技术,将用户端标识符存储在设备上,从而允许网站重建用户删除的 cookie。

(f) WebRTC。WebRTC 是一套 W3C 标准,支持原生 (无插件) 浏览器应用,如语音和视频聊天。设备可通过枚举支持的 WebRTC 功能和媒体设备(如麦克风和网络摄像头)来进行指纹识别。对于哪些类型的设备可以在未经用户许可的情况下进行枚举,各浏览器的做法不同。WebRTC 还公开了分配给设备上所有网络接口的 IP 地址,包括由用户分配的私有 IP 地址、NAT 路由器或 VPN。

(g) 密码自动填充。JavaScript 可以用来检测密码是用户输入,还是被浏览器或密码管理器自动填充。使用事件监听器来检测用户是否在密码字段中输入字符,为 keydown 和 keypress 分配一个事件监听器,由于事件是由物理按键触发的,缺失则表明密码是通过自动填充输入。

第二类:基于设备行为推断。不仅可以通过浏览器提供信息,还可在浏览器上执行特定 JavaScript 代码观察效果,如测量执行时间或分析输出来收集设备的信息,包括:

(a) HTML5 画布指纹。通过 JavaScript 在用户端执行 HTML5 画布渲染各种文本和图形,并向服务器发送位图图像的哈希。不同软 / 硬件设备生成的图像有细微不同,例如字体和抗锯齿会随操作系统和显卡驱动变化,表情符号随操作系统和手机厂商变化。使用预定义字体列表渲染文本,可以进行字体检测。使用 WebGL 渲染复杂的图形,可进一步提供指纹多样性。

(b) 系统性能。在一系列计算密集型操作上运行 JavaScript 引擎基准,对运行时间进行测量,可推断设备性能特点;

(c) 硬件传感器。移动设备传感器可以根据制造和工厂校准变化进行指纹识别,例如,测量手机加速度计的校准误差(通过 JavaScript 访问)或扬声器 - 麦克风系统的频率响应;

(d) 滚轮指纹。监听 WheelEvent 事件,可通过 JavaScript 推断用户设备,当用户使用鼠标滚轮或触摸板滚动时,就会触发该事件。鼠标滚轮在触发时以固定增量滚动页面,触摸板则以不同增量滚动。测量文档的滚动速度可以显示用户滚动行为的信息和操作系统的滚动速度值。

(e) CSS 特征检测。浏览器厂商和版本可通过检测 CSS 特征来推断,因为各浏览器不统一。在目标元素上设置所需的 CSS 属性,然后查询该元素判断是否应用更改。这个向量可从 user-agent 获取。如果设备指纹已经通过另一个向量提取了 user-agent,那么这里也用来测试信息是否被篡改。

(f) JavaScript 标准的一致性。浏览器对 JavaScript 标准的符合性不同,各种 JavaScript 一致性测试要数千个测试用例,加起来可能需要 30 多分钟。Mulazzani 等人开发了一种技术,方法是使用决策树来选择一个非常小的子集,这些子集的运行时间可以忽略不计,可用来验证 user-agent 中报告的浏览器供应商和版本。

(g) URL scheme handler。有些浏览器在访问本地资源时使用了非标准方案。例如,res:// 在 Microsoft IE 中是存储在 Windows 系统目录下 DLL 文件,Firefox 中的 moz-icon://、jar:resource:// 和 resource:// 公开了内置浏览器和操作系统资源。因此网站可以创建 HTML 图片标签,将源地址设置为本地资源,并使用 onerror 事件处理来检测图片是否加载。通过迭代不同浏览器或操作系统版本预加载资源列表,向量可以列举。这算是一个替代方案,因为很多新版本浏览器出于隐私考虑,不再支持。

(h) 显卡 RAM 检测。GPU 可用 RAM(VRAM) 数量,虽然不能通过 WebGL API 明确获得,但可以通过反复分配纹理来推断,直到 VRAM 满了,之后纹理开始被交换到系统主内存。通过每次纹理分配的时间长度,并记录观察到的较大峰值,可推断 GPU VRAM 已达到充分利用的状态。在这之后,浏览器可以继续分配纹理,直到出现 OUT OF MEMORY 错误。

(i) 字体检测。虽然不能通过 JavaScript 枚举已安装的字体,但可以用预定义列表中的字体来格式化文本,产生的文本尺寸可以区分不同的字体渲染设置,因此推断每种字体的存在。

(j) 音频处理。HTML5 AudioContext API 通过提供音频播放的实时频域和时域分析接口,允许创建音频可视化。和 HTML5 画布指纹一样,音频处理因浏览器和软 / 硬件不同而不同。

第三类:浏览器扩展插件。包括:

(a) 浏览器插件指纹。浏览器插件,如 Java、Flash 和 Silverlight,可以被查询 (通过嵌入网页插件对象),以采集系统信息,而且比 JavaScript 提供的信息更详细。例如,Flash 提供了完整的操作系统内核版本,Flash 和 Java 插件都允许枚举所有系统字体,甚至系统字体的列举顺序在不同的系统中也会有所不同,增加了指纹的可区分性。

(b) 浏览器扩展指纹。如果安装了 NoScript 扩展 (默认情况下,除了用户白名单上外,所有网站都禁用 JavaScript),网站可以尝试从一大批网站(如 Alexa Top 1000) 加载脚本,检测哪些网站在用户白名单上。同样,广告拦截器也可以通过嵌入一个虚假广告来检测,比如一个隐藏的图片或 iframe,其源 URL 中包含广告拦截器常用的黑名单词(比如 “广告”),然后 JavaScript 可以检测假广告是否被加载,并将结果返回服务器。其他扩展也有不同方法进行指纹识别,比如一些浏览器扩展会添加自定义 HTTP headers。

(c) 系统指纹插件。网站可能会安装专门的插件,例如早年的网上银行,这样可提供更强大指纹信息,包括硬件标识符、操作系统安装日期和已安装驱动程序版本,不过这种插件现在一般会被杀毒软件报出。

第四类:网络和协议级技术。前面几类涉及在客户端上访问 API,而网络和协议层面的技术也可给设备打指纹,包括:

(a) IP 地址。众所周知 IP 可用来做判断,也可查询 WHOIS 获得更多信息,比如所在自治系统和注册组织名称。虽然 IP 地址比 AS 号更精确,但 AS 更稳定,在校验用户位置时可以作为交叉检查。

(b) Geolocation。地理位置可以通过几种机制来确定,浏览器通常会暴露 API(例如通过 navigator BOM 对象),通过这些 API,可以请求用户允许获取当前位置 (GPS 硬件、蜂窝三角、WiFi 信息或用户提供的信息)。基于网络的机制也包括基于 IP 地址的 WHOIS 查询、基于路由数据的推理以及基于地理定位。

(c) 主动式 TCP/IP 协议栈指纹。由于网络和操作系统 TCP/IP 实现之间的差异,可以通过向设备发送针对性探针并分析响应包头字段 (如 RTT、TCP 初始窗口大小) 或链路特征 (如 MTU、延迟) 来确定指纹。这种方法与浏览器无关,可以在任何互联网主机使用。可以理解为 Nmap 之类的扫描,具有主机发现、端口扫描和操作系统检测能力,可以发送各种探测数据包,通过内置数据库中的启发式方法来区分成千上万的系统。这种向用户端发送特殊的探测数据包,称之为主动指纹,但可能会触发防火墙、IDS 警报。

(d) 被动 TCP/IP 协议栈指纹。被动指纹是侵入性较低的方法,但效果也较弱,通过嗅探网络通信,但使用主动指纹的启发式方法来识别主机,例如 p0f 这种工具。被动方法是比较合适的指纹向量,因为对现有 header 分析不需要制造新数据包,不具有侵入性。

(e) 协议指纹。协议指纹用于更高级别的协议,用来区分浏览器软件、版本、配置,例如 HTTP header、user-agent、支持语言、字符编码列表以及 DoNotTrack 参数。此外,浏览器的 TLS 库可以用 ClientHello 数据包从协商参数的握手序列中获得指纹,相关信息包括用户端 TLS 版本、支持的密码套件、它们的顺序、压缩选项和扩展列表 (相关参数如椭圆曲线参数)。

(f) DNS 解析。很多用户默认 DNS 解析器是运营商配置的,但少数用户可能会设置其他 DNS,如阿里云或 OpenDNS。于是就产生了一种比较*的方法,服务器向浏览器发送一份文件,文件包含一份随机生成子域名,但该域名的权威 DNS 服务器由网站所有者控制。当用户端试图解析时,网站的 DNS 服务器会收到来自用户端 DNS 解析请求,然后将随机生成子域与最初为用户生成的进行关联。

(g) 时钟偏移。可以被动分析 TCP 时间戳,来测量用户时钟偏移—用户时钟和真实时间的偏离率。

(h) 计算 NAT 后面的主机。Bellovin 最早提出来计算 NAT 后面的主机数量,通过被动分析 IPv4 ID 字段 (用于片段重构) 来计算 NAT 后面的主机数量。Kohno 则又提出使用时钟偏移来区分 NAT 后面的主机。这些技术可以通过上层信息来增强,例如在指纹中加入从同一 IP 地址访问的其他用户账户。

(i) 广告拦截器检测。虽然广告拦截器检测可以用 JavaScript 在用户端进行,但也可以在服务器监控用户端是否请求了虚假广告。

这里只说三种有研究意义的指纹类型:

HTML5 Canvas Fingerprinting

Canvas 是一种 HTML5 API 和它的功能就不赘述了。这里只说它可以用作 Web 浏览器指纹识别中的附加熵,并用于在线跟踪目的

实现原理

相同的 Canvas 图像在不同的计算机上可能会以不同的方式呈现

就如下面动图所示:

发生这种情况有几个原因:

  • 在图像格式级别——Web 浏览器使用不同的图像处理引擎、图像导出选项、压缩级别,即使它们像素相同,最终图像也可能获得不同的校验和
  • 系统层面——操作系统有不同的字体,它们使用不同的算法和设置来进行抗锯齿和亚像素渲染;另外,不同的显卡驱动程序有时也会影响常规字体渲染。

其实一段简单的 JavaScript 代码就能实现:

// 带有小写/大写/标点符号的文本
var txt = "BrowserLeaks,com <canvas> 1.0" ;
ctx.textBaseline = "顶" ;
// 最常见的类型
ctx.font = "14px 'Arial'" ;
ctx.textBaseline = "字母" ;
ctx.fillStyle = "#f60" ;
ctx.fillRect(125,1,62,20);
// 一些混色的技巧,以增加渲染的差异
ctx.fillStyle = "#069" ;
ctx.fillText(txt, 2, 15);
ctx.fillStyle = "rgba(102, 204, 0, 0.7)" ;
ctx.fillText(txt, 4, 17);

然而,要从画布创建签名,我们必须使用 toDataURL() 函数从应用程序的内存中导出像素,该函数将返回二进制图像文件的 base64 编码字符串。然后我们可以创建这个字符串的 MD5 哈希,或者甚至从 IDAT 块中提取 CRC 校验和,IDAT 块位于每个 PNG 文件末尾的 16 到 12 字节,这将是我们的 Canvas 指纹。

图像数据块IDAT(image data chunk):IDAT 数据块用于存储各像素点的数据,具体数据由 IHDR 中声明的 Filter methodCompression method 共同决定。

AudioContext Fingerprinting

HTML5提供给 JavaScript 编程用的 Audio API 可以让开发者有能力在代码中直接操作原始的音频流数据,对其进行任意生成、加工、再造,诸如提高音色,改变音调,音频分割等多种操作,甚至可称为网页版的 Adobe Audition。

实现
  1. 方法一:生成音频信息流(三角波),对其进行 FFT 变换,计算 SHA 值作为指纹,音频输出到音频设备之前进行清除,用户毫无察觉。
  2. 方法二:生成音频信息流(正弦波),进行动态压缩处理,计算MD5值

快速傅里叶变换(英语:Fast Fourier Transform, FFT

原理

主机或浏览器硬件或软件的细微差别,导致音频信号的处理上的差异,相同器上的同款浏览器产生相同的音频输出,不同机器或不同浏览器产生的音频输出会存在差异。

Font Fingerprinting

字体指纹技术基于测量填充文本片段或单个 Unicode 字形的 HTML 元素的屏幕尺寸。Web 浏览器中的字体呈现受许多因素影响,这些测量值可能略有不同。

实现
  • 字体枚举攻击是一种蛮力方法,它尝试从相当大的字体系列字典中使用不同的字体。如果渲染元素的大小与默认值不同,则意味着系统中存在替换字体。
  • Unicode 字形测量几乎可以完成相同的工作。它不使用文本行,而是使用单个、专门选择的具有大字体的 Unicode 字符,并且仅使用默认字体作为字体系列。对获得的测量结果进行哈希运算后形成指纹。

WebGL

实现:

  • WebGL报告——完整的WebGL浏览器报告表是可获取、可被检测的。在一些情况下,它会被转换成为哈希值以便更快地进行分析。
  • WebGL图像 ——渲染和转换为哈希值的隐藏3D图像。由于最终结果取决于进行计算的硬件设备,因此此方法会为设备及其驱动程序的不同组合生成唯一值。这种方式为不同的设备组合和驱动程序生成了唯一值。

参考

ISO 周日历

基础知识

  • 阳历: 就是以太阳来计算日期的一类历法;
  • 阴历: 就是以月亮来计算日期的一类历法;
  • 公历: 属阳历的一种,我国现在使用的就是公历;
  • 农历: 我国的农历是一种阴阳合历,用来指导农业十分方便。

所以,阳历、阴历是一类历法,而公历、农历是一种历法。公历和农历的表述方法也是不一样的

  • 公历: 用阿拉伯数字,如2019年1月9日;
  • 农历: 用汉字,干支纪年,如戊戌年乙丑月丙午日,或戊戌年腊月初四(农历中,一月、十一月、十二月分别称为正月、冬月,腊月)

好吧,以前总觉得公历就是阳历,农历就是阴历。实际上只是老百姓这样说。从理论上是无法等同的。

公历

我们熟知的是公历,公历分为周期为 365个日历日的平年以及周期为 366个 日历日的闰年。闰年是能被 4 整除的年, 然而,百年并不一定是闰年,除非它们能被 400整除。

公历是一种历法系统,其中的年又叫日历年,日又叫日历日。这种历法系统由一系列连续的日历年(可能是无限的)组成,其中每年又划分成 12个顺序的日历月。

周日历

周日历是日常生活中不常用到的历法系统,一般用于政府、商务的会计年度或者学校教学日历中。

国际标准ISO 8601(数据存储和交换形式·信息交换·日期和时间的表示方法)中定义的ISO周日历系统:

  • 一个ISO周数年(也可以简称为 ISO年)有52或53个完整的星期
  • 以364天或371天取代了常用的365或366天
  • 额外增加出来的一个星期称为闰周
  • 每个星期从星期一开始
  • 每年的第一个星期包含当年的第一个星期四(并且总是包含1月4日)

国内是采用【GB/T 7408-2005/ISO 8601:2000】标准(位于 4.3.2.2 日历星期,实际上还是采用的ISO 8601:2000年版本的标准)。定义如下:

  • 基于一系列无限连续的日历星期的历法系统
  • 每个日历星期有 7个 日历日
  • 参考点是把 200。年 1月 1日定为星期六
  • 即一年中的第一个日历星期包括该年的第一个星期四
  • 定一个日历年有 52或 53个日历星期
  • 日历年的第一个日历星期可能包含前一个日历年中的三天,日历年的最后一个日历星期可能包含下一个日历年的三天

书写格式

公历中的2019年12月30日星期一是ISO日历中2020年第1周的第一天,写为2020-W01-12020W011

每年的第一个日历星期有以下四种等效说法:

  1. 本年度第一个星期四所在的星期
  2. 1月4日所在的星期
  3. 本年度第一个至少有4天在同一星期内的星期
  4. 星期一在去年12月29日至今年1月4日以内的星期

推理可得:

  • 如果1月1日是星期一、星期二、星期三或者星期四,它所在的星期就是第一个日历星期
  • 如果1月1日是星期五、星期六或者星期日,它所在的星期就是上一年第52或者53个日历星期
  • 12月28日总是在一年最后一个日历星期。

一周的开始是星期一还是星期日

按照国际标准 ISO 8601 的说法,星期一是一周的开始,而星期日是一周的结束。虽然已经有了国际标准,但是很多国家,比如「美国」、「加拿大」和「澳大利亚」等国家,依然以星期日作为一周的开始。

所以在计算一年的第一周的时候,国内日历和欧美一些国家存在差异。

长年,是有53星期的年

  • 任何从星期四开始的年(主日字母D或DC)和以星期三开始的闰年(ED)
  • 任何以星期四结束的年(D、ED)和以星期五结束的闰年(DC)
  • 在1月1日和12月31日(在平年)或其中之一(在闰年)是星期四的年度

相关计算

1. 计算给定年份总周数

(符号向上取整)

/**
 * 根据年份计算当年周数
 * @param {number} y 年
 */
function computeWeeks(y) {
  const leapDay = p(y) === 4 || p(y - 1) === 3 ? 1 : 0
  return 52 + leapDay;
}

function p(y) {
  return (y + Math.ceil(y / 4) + Math.ceil(y / 100) + Math.ceil(y / 400)) % 7;
}

/**
 * 实际上 JavaScript 中获取一年的周数更简单
 * 12月28日所在的周数,始终是一年中的最后一周
 * 求出12月28日是星期几,如果早于或等于周四,那该年有53周
 * Date.prototype.getDay 结果中 0 表示星期天
 * @param {number} y 年份
 */
function getWeeks(y) {
  const day = new Date(`${y}/12/28`).getDay();
  return day !== 0 && day <= 4 ? 53 : 52
}

2. 计算当天ISO周日历表达

来自The Mathematics of the ISO 8601 Calendar

/**
 * 计算自0年1月0日起,CE的天数(Gregorian)
 */
function gregdaynumber(year, month, day) {
  y = year;
  m = month;
  if (month < 3) y = y - 1;
  if (month < 3) m = m + 12;
  return Math.floor(365.25 * y) - Math.floor(y / 100) + Math.floor(y / 400) + Math.floor(30.6 * (m + 1)) + day - 62;
}

/**
 * 根据当前公历日期计算ISO日历日期
 */
function isocalendar1() {
  var today = new Date();

  year = today.getFullYear();
  month = today.getMonth(); // 0=January, 1=February, etc.
  day = today.getDate();
  wday = today.getDay();

  weekday = ((wday + 6) % 7) + 1; // getDay 返回的值是 0 ~ 6,这里转为1 ~ 7

  isoyear = year;

  d0 = gregdaynumber(year, 1, 0);
  weekday0 = ((d0 + 4) % 7) + 1;

  d = gregdaynumber(year, month + 1, day);
  isoweeknr = Math.floor((d - d0 + weekday0 + 6) / 7) - Math.floor((weekday0 + 3) / 7);

  // 检查12月的最后几天是否属于下一年的ISO周

  if ((month == 11) && ((day - weekday) > 27)) {
    isoweeknr = 1;
    isoyear = isoyear + 1;
  }

  // 检查一月的前几天是否属于上一年的ISO周

  if ((month == 0) && ((weekday - day) > 3)) {
    d0 = gregdaynumber(year - 1, 1, 0);
    weekday0 = ((d0 + 4) % 7) + 1;
    isoweeknr = Math.floor((d - d0 + weekday0 + 6) / 7) - Math.floor((weekday0 + 3) / 7);
    isoyear = isoyear - 1;
  }

  if (isoweeknr < 10) return isoyear + "-W0" + isoweeknr + "-" + weekday;
  if (isoweeknr > 9) return isoyear + "-W" + isoweeknr + "-" + weekday;
}

. 给定某一日期,获取其ISO周日历表达方式

weeks是第一个计算中的方法)

  • 常数 10

  • woy 指 week of year

  • doy 指 day of the year,就是当年的第几天,取值doy = 1 → 365/366

  • dow 值 day of the week,就是星期几。使用JavaScript的 Date.prototype.getDay 方法取值范围为 0到6,对应周日到周六,但是dow 的值范围为 1~7,需要相应转换

  • 如果这样获得的星期数等于0,则意味着给定的日期属于上一个(基于周)的年份

  • 如果获得的星期数为53,则必须检查日期是否是第二年的第1周

  • 每月基于1月1日的偏移量

    Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
    平年 0 31 59 90 120 151 181 212 243 273 304 334
    闰年 0 31 60 91 121 152 182 213 244 274 305 335

例如查找2016年11月5日星期六的星期数

// 使用每月基于当年的1月1日的偏移量计算
woy = Math.floor((10 + (305 + 5)  6) / 7)
woy = Math.floor(314 / 7) = 44
// 既不是 0 也不是 53,所以就是当前周数

参考

了解 ANSI 转义码的 color 设置

信令

在日常生活中,我们经常打电话。当拿起送受话器,话机便向交换机发出了摘机信息,紧接着我们就会听到一种连续的“嗡嗡”声,这是交换机发出的,告诉我们可以拨号的信息。当拨通对方后,又会听到“哒-哒-”的呼叫对方的声音,这是交换局发出的,告诉我们正在呼叫对方接电话的信息……。

这里所说的摘机信息、允许拨号的信息、呼叫对方的回铃信息等等,主要用于建立双方的通信关系,我们把这类信息称为信令。(信令是交换机之间通信的语言)

in-bind signling

中文翻译叫带内信令。上面说了信令是用来建立双方的通信关系的信息。而通信建立后传递的消息就是通信的内容。那么带内信令就是指信令和通信内容使用同一通道。具体原理和知识很复杂,这里只做了解。

ANSI 转义码

ANSI 转义序列是命令行终端下用来控制光标位置、字体颜色以及其他终端选项的一项 in-bind signaling 标准。通常是在文本中嵌入确定的字节序列(符合带内信令的定义),大部分以 ESC 转义字符和 "[" 字符开始,终端会把这些字节序列解释为相应的指令,而不是普通的字符编码

在终端中,ASCII编码中有些字符是不能用来打印显示的,比如 '\a' ( 0x7 ) 代表响铃,'\n' (0x0A) 代表换行,这些字符被称为控制符。控制符 '\e' (0x1B),这个字符代表 ESC ,即键盘上 ESC 按键的作用。ESC 是单词 escape 的缩写,即逃逸的意思。文本中出现这个转义字符,代表其后方的字符是 ANSI Escape code 编码

如果终端输出不支持 ANSI 转义码,那么可能会看下下面的信息:

ESC[1;Foreground
�[1;30m 30  �[1;30m 30  �[1;30m 30  �[1;30m 30  �[1;30m 30  �[1;30m 30  �[1;30m 30  �[1;30m 30  �[0m
�[1;31m 31  �[1;31m 31  �[1;31m 31  �[1;31m 31  �[1;31m 31  �[1;31m 31  �[1;31m 31  �[1;31m 31  �[0m
�[1;32m 32  �[1;32m 32  �[1;32m 32  �[1;32m 32  �[1;32m 32  �[1;32m 32  �[1;32m 32  �[1;32m 32  �[0m
...

ANSI color

ANSI Escape code编码中有专门控制字符颜色的控制符,例如:

\e[37;44;3;1m
  • \e 代表开始ANSI Escape code
  • [ 代表转义序列开始符 CSI,Control Sequence Introducer
  • 37;44;4;1 代表以; 分隔的文本样式控制符,其中 37 代表文本前景色为白色,44代表背景为蓝色,3代表斜体,1代表加粗
  • m 代表结束控制符序列

比如在终端命令行中执行

echo -e "\e[37;44;3;1mLYL\e[0m"

-e 参数用于启用 echo 命令控制符转码,结尾的 \e[0m 代表重置文本样式。

因为 \e 控制符的16进制码为0x1B , 8 进制码为 033 ,所以以下表示方式等价:

  • \e[0m
  • \x1b[0m
  • \x1B[0m
  • \033[0m
  • \u001b[0m

常用文本样式控制符

代码 作用 备注
0 重置/正常 关闭所有属性。
1 粗体或增加强度
2 弱化(降低强度) 未广泛支持。
3 斜体 未广泛支持。有时视为反相显示。
4 下划线
5 缓慢闪烁 低于每分钟150次。
6 快速闪烁 MS-DOS ANSI.SYS;每分钟150以上;未广泛支持。
7 反显 前景色与背景色交换。
8 隐藏 未广泛支持。
9 划除 字符清晰,但标记为删除。未广泛支持。
10 主要(默认)字体
11–19 替代字体 选择替代字体
20 尖角体 几乎无支持。
21 关闭粗体或双下划线 关闭粗体未广泛支持;双下划线几乎无支持。
22 正常颜色或强度 不强不弱。
23 非斜体、非尖角体
24 关闭下划线 去掉单双下划线。
25 关闭闪烁
27 关闭反显
28 关闭隐藏
29 关闭划除
30–37 设置前景色 参见下面的颜色表。
38 设置前景色 下一个参数是5;n或2;r;g;b,见下。
39 默认前景色 由具体实现定义(按照标准)。
40–47 设置背景色 参见下面的颜色表。
48 设置背景色 下一个参数是5;n或2;r;g;b,见下。
49 默认背景色 由具体实现定义(按照标准)。
51 Framed
52 Encircled
53 上划线
54 Not framed or encircled
55 关闭上划线
60 表意文字下划线或右边线 几乎无支持。
61 表意文字双下划线或双右边线
62 表意文字上划线或左边线
63 表意文字双上划线或双左边线
64 表意文字着重标志
65 表意文字属性关闭 重置60–64的所有效果。
90–97 设置明亮的前景色 aixterm(非标准)。
100–107 设置明亮的背景色 aixterm(非标准)。

下图对应上表只有一个颜色代码的情况(颜色编码只支持 3 或 4 位,也就是只有8种或16种)

为什么是8或16种?因为初始的规格只有8种颜色,只给了它们的名字。SGR参数30-37选择前景色,40-47选择背景色。相当多的终端将“粗体”(SGR代码1)实现为更明亮的颜色而不是不同的字体,从而提供了8种额外的前景色,但通常情况下并不能用于背景色

3bit-color

下图对应代码为 38 或者 48 的设置背景的颜色选择(即8位编码,共256种颜色(2^8) )

8bit-color

  • 0-7:标准颜色(同ESC [ 30–37 m)
  • 8-15:高强度颜色(同ESC [ 90–97 m)
  • 16-231(6 × 6 × 6 共 216色): 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
  • 232-255:从黑到白的24阶灰度色

代码格式如下

\e[ … 38;5;<n> … m    # 选择前景色
\e[ … 48;5;<n> … m    # 选择背景色

随着显卡的更新迭代,某些终端已经支持渲染 24 位色彩。格式如下

\e[ … 38;2;<r>;<g>;<b> … m    # 选择RGB前景色
\e[ … 48;2;<r>;<g>;<b> … m    # 选择RGB背景色

上面的字体样式控制可以混合使用,下面同时设置前景和背景色

本篇着重说明 ANSI color,其他终端控制输入输出等内容不再研究。

实际应用

在开发web端的日志组件或Web页面上的命令行终端时,知道 ANSI 转义码会让工作更加轻松。因为这可能会避免你对着一堆的乱码发呆。当然还可以使用一些第三方库将 ANSI color 转译成 html 输出,这样操作起来会更加方便。例如 https://github.com/drudru/ansi_up

参考

由 “background-size 位置对 background 效果的影响“看 CSS 的书写顺序

问题描述

在使用 CSS3 的 linear-gradient() 函数绘制一个网格背景的时候,遇到了一个奇怪的问题:当将 background-size 放到 background: linear-gradient() 前面时,渐变背景的效果就失效了。放在后面就又正常了。

正常显示时代码:

.example {
  width: 300px;
  height: 300px;
  background: linear-gradient(to top, transparent 39px, blue 40px),
    linear-gradient(to left, transparent 39px, blue 40px);
  background-size: 40px 40px;
}

网格线背景

但是当将 background-size 移到 background: linear-gradient() 前面时:

.example {
  background-size: 40px 40px;
  background: linear-gradient(to top, transparent 39px, blue 40px),
    linear-gradient(to left, transparent 39px, blue 40px);
}

渐变失效

其实不仅是渐变背景,使用图片背景时,也会因此而导致背景图片的大小和 repeat 特性变得不受控制。毕竟渐变其实是一种特殊的 image 背景。

CSS linear-gradient() 函数用于创建一个表示两种或多种颜色线性渐变的图片。其结果属于 <gradient> 数据类型,是一种特别的 <image> 数据类型。—— MDN

原因分析

原因很简单,其实就是 background 属性简写造成的。

我们知道 CSS 中只写 background 时,是一种属性的简写,会隐式声明背景的多个属性,包括 background-clipbackground-colorbackground-imagebackground-originbackground-positionbackground-repeatbackground-size,和 background-attachment

另外,在同一选择器中重复声明多条相同的属性(如果不在意编辑器的警告),后面声明的属性值会覆盖前面的属性值。因为 CSS 的语句是顺序‘执行’的。

因此,如果将 background-size 放在 background 前面,实际上 background 中隐式声明的 background-size 会覆盖前面的显式声明,就像这样:

.example {
  background-size: 40px 40px;
  background-size: auto auto;
  /* background-size 的初始值是 auto auto */
}

而如果将显式声明的 background-size 放在 background 后面,那就是显式声明覆盖属性简写中的隐式声明,所以显式声明的属性就生效了

.example {
  background-size: auto auto;
  background-size: 40px 40px;
}

解决

  • 原因分析中提到的,修正覆盖顺序,将显式声明的 background-size 放在 background 后面

  • 不使用 background 简写,而是改成 background-image,此时不会发生覆盖,所以顺序可以是任意的了

    .example {
      background-size: 40px 40px;
      background-image: linear-gradient(to top, transparent 39px, blue 40px),
        linear-gradient(to left, transparent 39px, blue 40px);
    }
  • 只用简写

    background 的简写还需要注意一点,“” 只能紧接着 “” 出现,并且以"/"分割,比如 0 0/40px 40px 表示 background-position-x background-position-y/background-size

另外要注意一点,background-size 如果仅有一个数值被给定,这个数值将作为宽度值大小,高度值将被设定为 auto

background: linear-gradient(to right, transparent 39px, blue 40px) 0 0/40px 40px,
  linear-gradient(to bottom, transparent 39px, blue 40px) 0 0/40px 40px;

书写顺序

说到 CSS 的优先级规则,一般想到的是“选择器的权重”或者“同名选择器时的优先级”,又或者“同一元素有多个选择器时的样式层叠效果”。这里实际上是在同一选择器中声明属性时区分顺序的情况,只有两条:

  • 相同属性:根据代码先后顺序,后面声明的属性值会覆盖前面的
  • 一个属性是另一个属性和其他属性的简写:如上 background 发生的隐式声明和显式声明之间的覆盖,其实还是相同属性的书写顺序问题

PS:linear-gradient 语法说明

最终语法:

linear-gradient([ [ [ <angle> | to [top | bottom] || [left | right] ],]? <color-stop>[, <color-stop>]+);

当使用带前缀的规则时,不要加“to”关键字。不带前缀且没有 to 关键词的语法会被丢弃。to top, to bottom, to left 和 to right这些值会被转换成角度0度、180度、270度和90度。其余值会被转换为一个以向顶部**方向为起点顺时针旋转的角度。

.grad {
  background-color: #F07575; /* 不支持渐变的浏览器回退方案 */
  background-image: -webkit-linear-gradient(top, hsl(0, 80%, 70%), #bada55); /* 支持 Chrome 25 and Safari 6, iOS 6.1, Android 4.3 */
  background-image:    -moz-linear-gradient(top, hsl(0, 80%, 70%), #bada55); /* 支持 Firefox (3.6 to 15) */
  background-image:      -o-linear-gradient(top, hsl(0, 80%, 70%), #bada55); /* 支持旧 Opera (11.1 to 12.0) */
  background-image:         linear-gradient(to bottom, hsl(0, 80%, 70%), #bada55); /* 标准语法; 需要最新版本 */
}

类型判断

基础知识回顾

当前版本(ECMAScript 2021)ECMAScript 标准定义了 8 种类型:

简单数据类型(原始类型)

共 7 种:StringNumberBooleanUndefinedNullSymbolBigInt

原始类型的值特点:值不可变,无属性无方法,保存在栈内存中、值比较。

无属性无方法可能会让人迷惑:

let s1 = "some text";
let s2 = s1.substring(2);

原始值本身不是对象,因此逻辑上不应该有方法,实际上这个例子确实没有报错,且得到了结果。原因是第二行访问 s1 时,是以读模式访问的,也就是要从内存中读取变量保存的值。

当以读模式访问原始值的时候,后台会执行以下三步(以上面代码为例):

  1. 创建一个 String 类型的实例
  2. 调用实例上的特定方法
  3. 销毁实例

等同于后台自动执行了下面的代码

let s1 = new String("some text");
let s2 = s1.substring(2);
s1 = null;

因此原始值拥有了对象的行为。

复杂数据类型(引用类型)

除了上面的 7 种基本数据类型外,剩下的就是引用类型,统称为 Object 类型

  • 基本引用类型

    • DataRegExp 等ECMAScript 提供的原 生引用类型

    • 原始值包装类型,ECMAScript 提供了 3 种特殊的引用类型:BooleanNumberString (只有这三种)

      let stringObject = new String('hello');
      // 如果在使用包装对象时,忘了在前面加 “new” 操作符,会等被当做普通函数执行,作用是把任何类型的数据转换为对应的类型
    • 函数

    • 单例内置对象:GlobalMath

      ECMA-262中定义:任何由 ECMAScript 实现提供、与宿主环境无关,并在 ECMAScript 程序开始执行时就存在的对象

      • Global 对象在浏览器中被实现为 window 对象。所有全局变量和函数都是 Global 对象的属性
      • Math 对象包含辅助完成复杂计算的属性和方法
  • 集合引用类型

    • 最常见的 ObjectArray

    • MapWeakMapSet 以及 WeakSet 类型

    • 定型数组:例如 Int32ArrayFloat64Array 等等

      定型数组(typed array)是 ECMAScript 新增的结构,目的是提升向原生库传输数据的效率。实际上, JavaScript 并没有“TypedArray”类型,它所指的其实是一种特殊的包含数值类型的数组。设计定型数组的目的就是提高与 WebGL 等原生库交换二进制数据的效率。

引用类型的值特点:可变、引用比较、值保存在堆(Heap)内存

类型判断

typeof

typeof 对判断原始值的类型简单有效。

typeof 37 === 'number';
typeof NaN === 'number';
typeof 42n === 'bigint';
typeof 'bla' === 'string';
typeof '1' === 'string';
typeof undefined === 'undefined';
typeof Symbol('foo') === 'symbol';
typeof false === 'boolean';

但是对引用值的作用不大,因为无论什么类型的对象(除了 Function 类型),使用typeof 都会返回 "object"。但是我们往往关心的不是这个值是不是对象,而是这个值是什么类型的对象。

typeof {a: 1} === 'object';
typeof [1, 2, 4] === 'object';
typeof new Date() === 'object';
typeof /regex/ === 'object';

除了正常的功能外,经常还会提起 typeof 的一些其它特性和例外:

  • null 是简单类型的值,它有自己的类型 Null,但是 typeof null 返回的却是 object

在 JavaScript 最初的实现中,值是由一个表示类型的标签和实际数据值表示的。对象的类型标签是 0。由于 null 代表的是空指针(大多数平台下值为 0x00),因此,null 的类型标签是 0typeof null 也因此返回 "object"

之后也是为了不得罪人,也就一直保留了这个问题特性。

  • 判断函数返回的是 "function"

    typeof function() {} // "function"
    typeof class C {}    // "function"
    typeof Math.sin      // "function"
    typeof (() => {})    // "function"

    这里不好理解的是为什么 class C {} 返回的也是 "function"。其实 ECMAScript 中的 class 只不过是构造函数的语法糖而已。

  • 括号?

    typeof 是操作符,语法上后面直接跟操作数,不需要括号。括号本身也是操作符,并且括号的优先级要高于 typeof。所以当 typeof 后面跟上括号时,其实优先执行括号里的表达式并返回操作数,typeof 判断的是这个返回的操作数的类型。

    typeof 42 + ' hello'
    // "number hello"
    typeof (42 + ' hello')
    // "string"
    (42 + ' hello')
    // "42 hello"
  • 暂时性死区导致的报错

    • 在 ES6 之前,typeof 总能保证对任何所给的操作数返回一个字符串。使用 typeof 永远不会抛出错误。

    • ES6 新增了 letconst 用来声明变量,使用它们会形成块级作用域,那么在变量声明之前对块中的 letconst 变量使用 typeof 会抛出一个 ReferenceError(暂时性死区的特性)。

      typeof foo;
      let foo = 'bar';
      // Uncaught ReferenceError: foo is not defined
  • 一个“挑衅意味”的例外,对,就是各大浏览厂商故意这么做的(不过 document.all 在HTML5 中已经被废弃,但是现在所有浏览器依然支持)

    console.log(document.all)
    // HTMLAllCollection(1713) [html,... 页面上的所有元素
    
    typeof document.all
    // "undefined"

因为 typeof 无法很好的区分引用值是什么类型的对象,所以ECMAScript 提供了 instanceof 操作符来解决这个问题。

instanceof

如果变量是给定引用类型的实例,则 instanceof 操作符返回 trueinstanceof 检测的是构造函数的 prototype (原型)是否存在于给定的对象实例的原型链上。

使用 instanceof 来判断原生对象类型,像 ObjectArray 这样的原生构造函数,运行时可以直接在执行环境中使用:

var arr = [];
var arr1 = new Array(10);
console.log(arr instanceof Array); // true
console.log(arr1 instanceof Array); // true

ECMAScript 中可以使用自定义构造函数创建自定义类型的对象,那么依然可以使用 instanceof 判断自定义引用类型:

function Foo() {}
let f = new Foo();
console.log(f instanceof Foo); // true

class Bar {}
let b = new Bar();
console.log(b instanceof Bar); // true

我们知道,每个对象实例都有一个 constructor 属性,指向用于创建当前对象的函数(constructor 属性是在构造函数的 prototype 上定义的)

Array.prototype.constructor === Array; // true
[].constructor === Array; // true

// 为什么原始值也有 constructor ? 可以看 typeof 那一节有解释
''.constructor === String // true

// null 和 undefined 不存在 constructor 的

constructor 本来是用于标识对象类型的。

function Person(name, age, job){
  // ...
}
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");

console.log(person1.constructor == Person); // true
console.log(person2.constructor == Person);  // true

但是对于自定义构造函数一旦重写 prototype,可能会造成原有的 constructor 引用丢失,从而导致 constaructor 会变成默认的 Object

function F() {}
// { foo: 'bar' } 是对象字面量,本身是一个 `Object` 实例,它自身的 constructor 指向 Object
F.prototype = { foo: 'bar' };
f.constructor === F
// false
f.constructor
// ƒ Object() { [native code] }

所以认为 instanceof 操作符是确定对象类型更可靠的方式。但是 instanceof 真的可靠吗?

并不可靠,就像上面说的,因为构造函数的 prototype 是可以改变的,所以在创建实例之后,修改构造函数的 prototype 属性,如果修改后的原型不在实例对象的原型链上了,那就会返回 false

function F() {}
var f = new F;
F.prototype = {}
f instanceof F  // false

还有一种方法是借助非标准的 __proto__ 伪属性来修改实例的原型链,使实例的原型链上不再出现创建它的构造函数的原型:

function F() {}
var f = new F;
f.__proto__ = {};
f instanceof F  // false

使用 instanceof 还有一些其他问题需要注意:

  • 不适用于原始值

    var simpleStr = "This is a simple string";
    var newStr    = new String("String created with constructor");
    
    simpleStr instanceof String; // 返回 false, 非对象实例,因此返回 false
    newStr    instanceof String; // 返回 true
  • Object.create(null) instanceof Object 返回 false。这是一种创建非 Object 实例的对象的方法

  • 如果一个对象实例的原型链上不止一种构造函数的原型,那么使用 instanceof 操作符检查这两种类型都会返回 true

function foo() {}
foo instanceof Function  // true
foo instanceof Object    // true

上面的 Person 自定义构造函数创建的对象实例 person1person2,既属于 Person 类型,又属于 Object 类型

console.log(person1 instanceof Object);  // true
console.log(person1 instanceof Person);  // true
console.log(person2 instanceof Object);  // true
console.log(person2 instanceof Person);  // true
  • 多全局对象引起的问题

    在浏览器中,我们的脚本可能需要在多个窗口之间进行交互。多个窗口意味着多个全局环境,不同的全局环境拥有不同的全局对象,从而拥有不同的内置类型构造函数

    这可能会引发一些问题。例如:

    [] instanceof window.frames[0].Array // false

    因为 Array.prototype !== window.frames[0].Array.prototype,意思就是 window.frames[0].Array 的原型不在前者 [] 的原型链上。

  • instanceof 是操作符,所以要注意操作符的优先级,例如检测对象不是某个构造函数的实例时:

    if (!(mycar instanceof Car)) {
      // ...
    }
    
    // 而不是使用 if (!mycar instanceof Car),逻辑非的优先级高于 instanceof

Symbol.hasInstance(ES6)

上一节说到 instanceof 操作符可以用来确定一个对象实例的原型链上是否有原型。在 ES6 中,instanceof 操作符会使用 Symbol.hasInstance 函数来确定关系。

function Foo() {}
let f = new Foo();
// 使用 instanceof
console.log(f instanceof Foo); // true
// 使用 Symbol. hasInstance
console.log(Foo[Symbol.hasInstance](f)); // true

这个属性定义在 Function 的原型上,因此默认在所有函数和类上都可以调用。由于 instanceof 操作符会在原型链上寻找这个属性定义,因此可以在继承的类上通过静态方法重新定义这个函数,从而可以用它自定义 instanceof 操作符在某个类上的行为:

class Array1 {
  static [Symbol.hasInstance](instance) {
    return Array.isArray(instance);
  }
}

console.log([] instanceof Array1); // true

Object.prototype.toString()

每个对象都有一个 toString() 方法,默认情况下,toString() 方法被每个 Object 对象继承。如果此方法在自定义对象中未被覆盖,toString() 返回一个字符串 "[object type]",其中 type 是对象的类型。

var o = new Object();
o.toString(); // [object Object]

为了获取准确的数据类型,我们最常用的方法也是目前最全最有效的方法就是使用 Object.prototype.toString() 来检测。需要注意的是:

  • 不是直接使用 toString 方法,而是使用 Object 构造函数原型上的方法,因为其它内置对象或者自定义类型的对象可能会实现自己的 toString 方法或者根本没有定义该方法,导致返回的不再是 “[object type]” 或者直接报错

    var str = new String('hello')
    str.toString()  // "hello"
    123..toString() // "123"
    null.toString() //  Uncaught TypeError: Cannot read property 'toString' of null
    undefined.toString() // Uncaught TypeError: Cannot read property 'toString' of undefined
  • 需要以 Function.prototype.call() 或者 Function.prototype.apply() 的形式来调用(不陌生吧,改变 this 指向)

    var toString = Object.prototype.toString;
    toString.call(new String); // "[object String]"
    toString.call('hello'); // "[object String]"
    toString.call(Math); // "[object Math]"
    toString.call(123); // "[object Number]"
    toString.call(true); // "[object Boolean]"
    toString.call(123n); // "[object BigInt]"
    toString.call(new Date); // "[object Date]"
    toString.call(Symbol()) // "[object Symbol]"
    toString.call(() => {}) // "[object Function]"
    toString.call([1, 2]);    // "[object Array]"
    toString.call(new Map) // "[object Map]"
    toString.call(function* () {}); // "[object GeneratorFunction]"
    toString.call(Promise.resolve()); // "[object Promise]"
    // ES5 中才实现下面的功能
    toString.call(undefined); // "[object Undefined]"
    toString.call(null); // "[object Null]"

这种方法对于日常开发中的大部分开发场景都适用了,但是对于开发者创建的类,默认情况下 toString() 只会返回默认的 Object 标签:

class ValidatorClass {}
Object.prototype.toString.call(new ValidatorClass()); // "[object Object]"

那有没有方法让自定义的类实例返回我们想要的结果呢?在 ES2015 标准中有一个内置 SymbolSymbol.toStringTag

Symbol.toStringTag(ES6)

Symbol.toStringTag 的值是一个字符串,这个字符串表示该对象的自定义类型标签。上面说到 Object.prototype.toString() 默认会返回返回一个字符串 "[object type]",其中的“type”有时候就是读取的 Symbol.toStringTag 的值。

不是所有的内置 JavaScript 对象类型都有 Symbol.toStringTag 属性,比如 ES6 之前使用 Object.prototype.toString() 也能返回类型:

Object.prototype.toString.call('foo');     // "[object String]"
Object.prototype.toString.call([1, 2]);    // "[object Array]"
Object.prototype.toString.call(3);         // "[object Number]"
Object.prototype.toString.call(true);      // "[object Boolean]"
Object.prototype.toString.call(undefined); // "[object Undefined]"
Object.prototype.toString.call(null);      // "[object Null]"
// ...

var o = new Object;
console.log(o[Symbol.toStringTag]); // undefined

JavaScript 引擎会为一些对象类型设置好 toStringTag 标签:

Object.prototype.toString.call(new Map());       // "[object Map]"
Object.prototype.toString.call(function* () {}); // "[object GeneratorFunction]"
Object.prototype.toString.call(Promise.resolve()); // "[object Promise]"
// ...

var m = new Map;
console.log(Map.prototype[Symbol.toStringTag]); // 'Map'
console.log(m[Symbol.toStringTag]); // 'Map'

但是开发者自己创建的类需要手动添加 toStringTag 属性,否则引擎无法识别只能返回默认的 [object Object]

class ValidatorClass {
  get [Symbol.toStringTag]() {
    return "Validator";
  }
}
Object.prototype.toString.call(new ValidatorClass()); // "[object Validator]"

Symbol.toStringTag 的属性特性:是【不可写】、【不可枚举】并且【不可配置】

Map.prototype[Symbol.toStringTag];  // "Map"
Map.prototype[Symbol.toStringTag] = 'map';
var m = new Map;
m[Symbol.toStringTag]; // "Map"
m[Symbol.toStringTag] = 'map';

m[Symbol.toStringTag]  // "Map"
Map.prototype[Symbol.toStringTag] // "Map"

// ---
var va = new ValidatorClass
va[Symbol.toStringTag] = 'va'
va[Symbol.toStringTag] // "Validator"

但是在测试的时候发现,内置的 JavaScript 对象类型,如果本身没有设置 Symbol.toStringTag 属性的话,我们是可以赋值并改写它的值的

Object.prototype[Symbol.toStringTag] = 'o'
var o = new Object
o[Symbol.toStringTag]  // "o"
o[Symbol.toStringTag] = "object"
o[Symbol.toStringTag] // "object"
Object.prototype[Symbol.toStringTag] // "o"
Object.prototype.toString.call(o);   // "[object object]"

目前不知原因。

ECMAScript 内置的针对某个类型的判断方法

  • Array.isArray() 用于确定传递的值是否是一个 Array

    Array.isArray([1]); // true
    Array.isArray(new Array('a', 'b', 'c', 'd'));  // true

    有个非常细节的知识点,Array.prototype 是一个数组

    Array.isArray(Array.prototype);  // true
    
    console.log(Array.prototype);
    // [constructor: ƒ, concat: ƒ, copyWithin: ƒ, fill: ƒ, find: ƒ, …]

    对于定型数组(TypedArray)实例,总是返回 false

    Array.isArray(new Uint8Array(32)); // false

    Array.isArray() 能检测iframes,所以针对数据的检查,应该使用它而不要使用instanceof

    // 偷懒,直接搬 MDN 上的
    var iframe = document.createElement('iframe');
    document.body.appendChild(iframe);
    xArray = window.frames[window.frames.length-1].Array;
    var arr = new xArray(1,2,3); // [1,2,3]
    
    Array.isArray(arr);  // true
    arr instanceof Array; // false
  • isNaN()Number.isNaN() 用来确定一个值是否为 NaN

    全局对象的 isNaN 方法会将不是 Number 类型的参数转换为数值(空字符串和布尔值分别会被强制转换为数值 0 和 1),然后才会对转换后的结果是否是 NaN 进行判断。这就产生了有趣的现象

    isNaN(NaN);       // true
    isNaN(undefined); // true
    isNaN({});        // true
    
    isNaN(true);      // false
    isNaN(null);      // false
    isNaN(37);        // false
    
    // strings
    isNaN("37");      // false: 可以被转换成数值37
    isNaN("37.37");   // false: 可以被转换成数值37.37
    isNaN("37,5");    // true
    isNaN('123ABC');  // true:  parseInt("123ABC")的结果是 123, 但是Number("123ABC")结果是 NaN
    isNaN("");        // false: 空字符串被转换成0
    isNaN(" ");       // false: 包含空格的字符串被转换成0
    
    // dates
    isNaN(new Date());                // false
    isNaN(new Date().toString());     // true
    
    isNaN("blabla")   // true: "blabla"不能转换成数值
                      // 转换成数值失败, 返回NaN

    所以建议使用 Number.isNaN() 方法来确定传递的值是否为 NaN,它是比原来的全局 isNaN() 的更安全的版本。它不会将

    Number.isNaN("NaN");      // false,字符串 "NaN" 不会被隐式转换成数字 NaN。
    Number.isNaN(undefined);  // false
    Number.isNaN({});         // false
    Number.isNaN("blabla");   // false

总结

  • ECMAScript 标准定义了 8 种类型,包括7种基本数据类型和1种复杂(引用)类型
  • 有明确期望的类型判断,可以使用内置方法,例如 Array.isArray()isNaN
  • 对于原始值可以使用 typeof 操作符,对于引用类型的值可以使用 instanceof 操作符确定对象类型。对于ES6的代码,可以通过重写类的 Symbol.hasInstance 静态方法来自定义 instanceof 的行为
  • 更全更有效的检查方法 Object.prototype.toString(),调用的时候需要使用 .call() 或者 .apply()
  • 自定义类型的实例对象如何通过 Object.prototype.toString() 返回期望的类型字符串,可以通过添加 Symbol.toStringTag 属性。

参考

ESTree与Parser API

Parser API

最开始 Mozilla JS Parser API 是 Mozilla 工程师在 Firefox 中创建的 SpiderMonkey 引擎输出 JavaScript AST 的规范文档,文档所描述的格式被用作操作 JAvaScript 源代码的通用语言。

随着 JavaScript 的发展,更多新的语法被加入,为了帮助发展这种格式以跟上 JavaScript 语言的发展。The ESTree Spec 就诞生了,作为参与构建和使用这些工具的人员的社区标准

Parser API 中描述了一些特定于 SpiderMonkey 引擎的行为,而 ESTree spec 是社区规范,并且向后兼容 SpiderMonkey 格式。

现在大部分 JS 解析器都是基于这两个中的一个规范实现的,大多数情况下生成的 AST 是兼容的。

ESTree

1. 概述

ESTree 是JS社区所遵循的一种语法表达标准,目的是让遵循该标准的代码工具生成一种 JSON 风格的 AST。GitHub 地址是 https://github.com/estree/estree。这个仓库包含从 ES5 到最新的 ECMAScript 标准的语法表达。通过这些表达规范,更容易用JavaScript写出处理JavaScript源代码的工具(比如语法高亮工具,静态分析工具, 翻译器,编译器,混淆器等等)。

2. 常用的源码工具

  • Acorn 是一个流行的JS解析器,它的核心接口 parse(input, options) 返回的值符合 ESTree spec 描述的 AST 对象
  • Esprima 是另一个JS解析器,Esprima语法树格式源自Mozilla Parser API的原始版本,然后将其形式化并扩展为ESTree规范
  • @babel/parser (以前的babylon)重度依赖 Acorn。遵循 ESTree 的规范
  • eslint/espree 这个是 ESlint 的解析器,一开始是从 Esprima 的一个版本 fork 来的。但是现在它是基于 Acorn 构建的了
  • Webpack 的 Parser类 中调用 acron 插件生成 AST 对象

至于其他的源码工具无外乎遵循社区认同的规范,或者自己实现 AST 的规范,这里不再一一查看。

Acron 比 Esprima 后出现,两者相比,前者实现的代码更少,而且速度和后者相差无几。所以更多的源码工具开始基于 Acron 进行解析器的开发。

3. ESTree 的作用

ESTree 只是一种规范,本身的意义只是用来描述一种语法表达,也就是 AST 对象。而源码处理工具真正用到的就是 AST。

例如Webpack 中,Parser 类生产 AST 语法树后调用 walkStatements 方法分析语法树,根据 AST 的 node type来递归查找每一个 node 的类型和执行不同的逻辑,并创建依赖。

ES6 作为 JS 的新规范,加入了很多新的语法和 API,而市面上的浏览器并没有全部兼容,所以需要将 ES6 语法代码转为 ES5 的代码。Babel 是一款非常流行的 ES6 转 ES5 的工具,其大致流程:

  1. 解析:解析代码字符串,生成 AST;

    • 分词:将整个代码字符串分割成_语法单元_数组,也有叫 Token 生成过程。语法单元是被解析语法当中具备实际意义的最小单元,例如将代码字符串拆分成空白、注释、字符串、数字、标识符等等。经过分词处理后的数据结构更方便后面的处理。类似于这样:

      [
       { type: "whitespace", value: "\n" },
       { type: "identifier", value: "if" },
       { type: "whitespace", value: " " },
       ...
      ]
    • 语义分析:在分词结果的基础上分析语法单元之间的关系,确定有多重意义的词语最终是什么意思、多个词语之间有什么关系以及又应该再哪里断句等。语义分析的过程又是个遍历语法单元的过程,不过相比较而言更复杂,因为分词过程中,每个语法单元都是独立平铺的,而语法分析中,语句和表达式会以树状的结构互相包含。

  2. 转换:按一定的规则转换、修改 AST;

  3. 生成:将修改后的 AST 转换成普通代码。

上面只是大致流程,实际上执行的过程更加复杂。

举个例子

例如有下面一段代码:

const num = 123;

通过 https://astexplorer.net/ 在线使用 babylon7 转译后的结果如下:

image

JSON 格式的 AST:

{
  "type": "VariableDeclaration",
  "start": 0,
  "end": 16,
  "loc": {
    "start": {
      "line": 1,
      "column": 0
    },
    "end": {
      "line": 1,
      "column": 16
    }
  },
  "declarations": [
    {
      "type": "VariableDeclarator",
      "start": 6,
      "end": 15,
      "loc": {
        "start": {
          "line": 1,
          "column": 6
        },
        "end": {
          "line": 1,
          "column": 15
        }
      },
      "id": {
        "type": "Identifier",
        "start": 6,
        "end": 9,
        "loc": {
          "start": {
            "line": 1,
            "column": 6
          },
          "end": {
            "line": 1,
            "column": 9
          },
          "identifierName": "num"
        },
        "name": "num"
      },
      "init": {
        "type": "NumericLiteral",
        "start": 12,
        "end": 15,
        "loc": {
          "start": {
            "line": 1,
            "column": 12
          },
          "end": {
            "line": 1,
            "column": 15
          }
        },
        "extra": {
          "rawValue": 123,
          "raw": "123"
        },
        "value": 123
      }
    }
  ],
  "kind": "const"
}

对比着ESTree spec 中的描述,ESTree 中的描述使用的是 TypeScript 的接口描述方式 interface

/* VariableDeclaration */
// es5.md
interface VariableDeclaration <: Declaration {
    type: "VariableDeclaration";
    declarations: [ VariableDeclarator ];
    kind: "var";
}
// es2015.md
extend interface VariableDeclaration {
    kind: "var" | "let" | "const";
}
/* VariableDeclarator */
interface VariableDeclarator <: Node {
    type: "VariableDeclarator";
    id: Pattern;
    init: Expression | null;
}

上面的 JSON 中有 "kind": "const" ,好了,对接上了,type、declarations 字段都对接上了。

略加思考,Babel 的解析器其实做的事情就是按照 ESTree 的规范将源代码拆分解析生成这样的一个树或者 JSON 数据。ESTree就是一本转换参考指南,设计解析器的时候,根据这本指南将相应的代码拆到对应的位置。可以说 Babel 的解析器是对 ESTree spec 的实践,解析器可以按照不同的规范设计,甚至自定义一套 AST 的格式让解析器去遵循。

完成第一步的解析,生成了 AST,接下来 Babel 对这个 AST 进行规则转换和修改。例如将 const 转为 var,就是通过遍历 AST 树,通过查找 node 的 type,将 VariableDeclaration 类型节点的 kind 的值由 cont 替换成 var。babel 内部应该做了更多的事情,但是大致意思就是这样了。

当然 Babel 的 parser 并不是完全按照 ESTree 的 spec 进行解析的,工具可以添加自定义的 node type 或修改相应的规则,以辅助后面步骤的执行。Babel 官网中有提到

The Babel parser generates AST according to Babel AST format. It is based on ESTree spec with the following deviations:

修改完成后需要将 AST 重新转为源代码,这时候需要一个能读懂生成 AST 时所遵循的 spec 的代码生成器。比如 Escodegen,Escodegen(escodegen)是来自Mozilla的Parser API AST的ECMAScript(也称为JavaScript)代码生成器。

总结

ESTree spec 和 Parser API 都是定义一种语法表达的标准,这种标准生成的结构就是 AST。大部分流行的JS源码操作工具都是基于 AST 实现的,那么它们就需要使用解析器生成 AST,而解析器就是这些规范的实现,大多数工具依赖

相关链接

React Portal传送门 - 将子节点渲染到存在于父组件以外的 DOM 节点

React Portal 是一种将子节点渲染到其父组件以外的 DOM 节点的方案。 -- React 文档

默认情况,组件的 render 方法返回一个元素时,会被挂在到最近的 DOM 节点上,也就是其父节点。比如这样

class Item extends React.Component {
    // ...
    render() {
        return (
            <div>xxx</div>
        );
    }
}

class App extends React.Component {
    // ...
    render() {
        return (
            <div className="wrap">
              <Item />
            </div>
        );
    }
}

Item 组件会被挂载在 className 为 “wrap” 的 div 节点上,Item 返回的内容会被渲染在 App 组件渲染的区域内。

但是有时候我们希望在父组件内使用子组件,但是在子组件的渲染内容不会出现在该父组件渲染区域内,而是出现在别的地方,甚至挂载的DOM节点并不在该父组件的子节点中。

如下图的需求:

image

  • Button 区是一个组件,其内容根据导航的不同显示内容不同;
  • Button 组件的渲染结果还受内容区中当前活跃Tab(基本信息、部署配置、权限分配)页的不同而不同
  • Button 区组件和内容区组件不是父子组件关系,而是兄弟节点的关系

不使用传送门的实现

实现的关键是:每个 Tab 页都单独定义一个 Button 区域组件,通过 CSS 绝对定位定位到指定位置。

这样做可以保证 Button 区域的按钮随着内容区的Tab也切换而改变,因为它们本身就是挂载在下面的Tab页的DOM节点上的。

但是,由于是通过绝对定位将渲染的视觉位置改变,所以需要梳理好父节点及其兄弟节点间的样式关系。比如内容区不能设置 overflow: hidden; 样式;还有在 Tab 组件到 button 区之间可能有其他 position: relative(或 absolute )元素的影响等等。

使用传送门

React Portal 用法

ReactDOM.createPortal(child, container)

该方法定义在 react-dom 上而不是 react 中

child 是被传送过去要渲染的内容,是任何可渲染的 React 子元素,例如一个元素,字符串或 fragment

React 不会创建一个新的 div。它只是把第一个参数的子元素渲染到“container”中。“container” 是一个可以在任何位置的有效 DOM 节点。就如同官方例子中所示

  • 先通过 document.createElement 创建一个没有挂载在任何地方的 div 元素
  • 将这个 div 元素通过 appendChild 方法添加到指定 DOM 节点下
  • 再将这个 div 元素作为 createPortal 的第二个参数,其实就是将子元素渲染进这个 div 中,那么也就将想要渲染的内容渲染在指定位置了。

先看一个官方例子

// 入口 index.html
<!DOCTYPE html>
<html>
  <body>
    <div id="app-root"></div>
    <div id="modal-root"></div>
  </body>
</html>

// 组件实现
import React from 'react';
import ReactDOM from 'react-dom';

// 获取两个DOM节点
const appRoot = document.getElementById('app-root');
const modalRoot = document.getElementById('modal-root');

// 创建一个模态组件,它是 Portal API的实现
class Modal extends React.Component {
  constructor(props) {
    super(props);
    // 创建一个div,我们将把modal呈现到其中。因为每个模态组件都有自己的元素,
    // 所以我们可以将多个模态组件呈现到模态容器中。
    this.el = document.createElement('div');
  }

  componentDidMount() {
    // 将元素附加到mount上的DOM中。我们将呈现到模态容器元素中
    modalRoot.appendChild(this.el);
  }

  componentWillUnmount() {
    // 卸载组件的时候,移除手动创建的 DOM
    modalRoot.removeChild(this.el);
  }
  
  render() {
    // 使用传送门将 children 渲染进元素中
    return ReactDOM.createPortal(
      // 任意有效的 React 子节点:JSX,字符串,数组等等
      this.props.children,
      // DOM 元素
      this.el,
    );
  }
}

// Modal 组件是一个普通的 React 组件,因此我们可以在任何地方呈现它,用户不需要知道它是用门户实现的。
class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {showModal: false};
    
    this.handleShow = this.handleShow.bind(this);
    this.handleHide = this.handleHide.bind(this);
  }

  handleShow() {
    this.setState({showModal: true});
  }
  
  handleHide() {
    this.setState({showModal: false});
  }

  render() {
    // 点击时展示 Modal
    const modal = this.state.showModal ? (
      <Modal>
        <div className="modal">
          xxx
          <button onClick={this.handleHide}>Hide modal</button>
        </div>
      </Modal>
    ) : null;

    return (
      <div className="app">
        This div has overflow: hidden.
        <button onClick={this.handleShow}>Show modal</button>
        {modal}
      </div>
    );
  }
}

ReactDOM.render(<App />, appRoot);

PS: 这里有个疑问,为什么不直接将目标DOM节点当做第二个参数传进去?

实际测试,直接传目标 DOM 节点是完全可以的(看这里,Fork的官方例子修改)。但问题是,就如同描述的那样第二个参数可以在任何位置。也就是说我们无法保证传进去的DOM节点已经被渲染,所以要手动加一些校验,防止出现“当传送时目标DOM还没有被渲染”的情况。

有了基础知识,那么大概思路就有了:

  • 先搭建好 Button 区域和 Content 区域的DOM结构
  • 创建一个通用的 ButtonPortal 组件,通过 props.children 接受需要渲染的内容,然后使用传送门发送并挂载到 Buttons 区域中用来占位的DOM元素上
  • 在 Content 组件内有三个 Tab,每个Tab Panel都是一个单独的组件,不同的 Panel 调用 ButtonPortal 组件 ,并将渲染的按钮信息通过props传递给 ButtonPortal
    思路有了,根据思路的大致代码结构也就可以写出来了(在线效果
import React, { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import "./styles.css";

function Target(props) {
  const modalRoot = document.getElementById("modal-root");
  const eleRef = useRef(document.createElement("div"));

  useEffect(() => {
    if (modalRoot) {
      modalRoot.appendChild(eleRef.current);
      return () => {
        if (modalRoot) {
          modalRoot.removeChild(eleRef.current);
        }
      };
    }
  }, [modalRoot]);
  return ReactDOM.createPortal(
    <div>
      hello world!
      {props.children}
    </div>,
    eleRef.current
  );
}

function View() {
  const [show, setShow] = useState(false);
  return (
    <div>
      内容将传送至上方红色区域
      <button onClick={() => setShow(true)}>开启传送</button>
      {show && (
        <Target>
          <div className="modal">
            <div>
              通过Portal,我们可以将内容呈现到DOM的不同部分,就像它是任何其他React子级一样。
            </div>
            <button onClick={() => setShow(false)}>销毁目标</button>
          </div>
        </Target>
      )}
    </div>
  );
}
export default function App() {
  return (
    <div className="app">
      <div id="modal-root"></div>
      <View />
    </div>
  );
}

通过 Portal 进行事件冒泡

官方文档的示例

虽然 portal 可以被放置在 DOM 树中的任何地方,但是其行为和普通的 React 子节点行为一致。比如 context 功能、事件冒泡等等。

拿事件冒泡来说,(React v16 之后)在 portal 渲染的 DOM 内部触发的事件会一直冒泡到开启传送的源位置(不是实际渲染挂载的DOM位置)。比如官方文档的示例中,在 #app-root 里的 Parent 组件能够捕获到未被捕获的从兄弟节点 #modal-root 冒泡上来的事件。


以下部分内容来自程墨Morgan传送门:React Portal(侵删)

为什么 React 需要传送门?

React Portal之所以叫Portal,因为做的就是和“传送门”一样的事情:render 到一个组件里面去,实际改变的是网页上另一处的DOM结构。

比如,某个组件在渲染时,在某种条件下需要显示一个对话框(Dialog),这该怎么做呢?
而 portal 的典型用例就是当父组件有 overflow: hiddenz-index 样式时,但需要子组件能够在视觉上“跳出”其容器。例如,对话框、悬浮卡以及提示框。

React在v16之前的传送门实现方法

在v16之前,实现“传送门”,要用到两个秘而不宣的React API

  • unstable_renderSubtreeIntoContainer
  • unmountComponentAtNode

第一个 unstable_renderSubtreeIntoContainer,都带上前缀 unstable 了,就知道并不鼓励使用,但是没办法,不用也得用,还好 React 一直没有 deprecate 这个 API,一直挺到 v16 直接支持 portal。这个API的作用就是建立“传送门”,可以把JSX代表的组件结构塞到传送门里面去,让他们在传送门的另一端渲染出来。

第二个 unmountComponentAtNode 用来清理第一个 API 的副作用,通常在 unmount 的时候调用,不调用的话会造成资源泄露的。

一个通用的Dialog组件的实现差不多是这样,注意看 renderPortal 中的注释。

v16之前的React Portal实现方法,有一个小小的缺陷,就是Portal是单向的,内容通过Portal传到另一个出口,在那个出口DOM上发生的事件是不会冒泡传送回进入那一端的。

import React from 'react';
import {unstable_renderSubtreeIntoContainer, unmountComponentAtNode} 
  from 'react-dom';

class Dialog extends React.Component {
  render() {
    return null;
  }

  componentDidMount() {
    const doc = window.document;
    this.node = doc.createElement('div');
    doc.body.appendChild(this.node);

    this.renderPortal(this.props);
  }

  componentDidUpdate() {
    this.renderPortal(this.props);
  }

  componentWillUnmount() {
    unmountComponentAtNode(this.node);
    window.document.body.removeChild(this.node);
  }

  renderPortal(props) {
    unstable_renderSubtreeIntoContainer(
      this, //代表当前组件
      <div class="dialog">
        {props.children}
      </div>, // 塞进传送门的JSX
      this.node // 传送门另一端的DOM node
    );
  }
}
  1. 首先,render 函数不要返回有意义的 JSX,也就说说这个组件通过正常生命周期什么都不画,要是画了,那画出来的 HTML/DOM 就直接出现在使用 Dialog 的位置了,这不是我们想要的。
  2. componentDidMount 里面,利用原生 API 来在 body 上创建一个 div,这个 div 的样式绝对不会被其他元素的样式干扰。
  3. 然后,无论 componentDidMount 还是 componentDidUpdate,都调用一个 renderPortal 来往“传送门”里塞东西。

总结,这个Dialog组件做得事情是这样:

  • 它什么都不给自己画,render 返回一个 null 就够了;
  • 它做得事情是通过调用 renderPortal 把要画的东西画在DOM树上另一个角落。

参考

双重转义

正则中的特殊字符

特殊字符其实就是在正则表达式中有特殊含义或者功能的字符,它们不能被简单的理解为字面意义。例如 * 的意思是出现零次或者多次。正则表达式中常见的特殊字符有 [ \ ^ $ . | ? * + ( )(这里重点不是特殊字符,更多的特殊字符及其用法示例可以查看 Regular expression syntax cheatsheet)。

需要注意的是 “/” 不是特殊字符。

转义

就是把特殊字符变成普通(常规)字符的方法,具体做法是在特殊字符前加上 \\ 也叫转义字符。

例如:想要通过正则匹配字符串中的 . ,但是 . 在正则表达式中属于特殊字符, 默认含义是匹配除了换行符以外的任意单个字符,所以可以使用 \. 将小数点号变成普通字符意义,例如匹配小数的正则表达式 /^([0-9]{1,}\.[0-9]*)$/

// javascript
var myRe = /^([0-9]{1,}\.[0-9]*)$/;
/**
 * 题外话:
 *   - 由于点(.)和星号(*)这样的特殊符号在一个字符集(就是用中括号“[]”括起来)中没有特殊的意义,
 *     所以还可以写成 /^([0-9]{1,}[.][0-9]*)$/,这样就不用转义了
 *   - 但是转义也是起作用的,所以这样写 /^([0-9]{1,}[\.][0-9]*)$/ 效果都一样
 */
myRe.test("42.0"); // true
myRe.test("42");   // false

特殊字符前加上转义字符 \ 时,表示该字符不再是特殊字符,而是普通字符。那如果在非特殊字符前加上 \ 会怎样呢?

在非特殊字符之前的反斜杠表示下一个字符是特殊字符,不能按照字面理解(emmm,有点“劝失足从良,拉良家下水”的感觉)。例如:正则表达式中 \b 表示匹配一个词的边界,举例

var myRe = /\bm/;
'moon'.match(myRe); // ["m", index: 0, input: "moon", groups: undefined]

上面虽然说 / 不是特殊字符, 但是在 Javascript 中,它表示正则表达式字面量的开始和结束:/...pattern.../ 。所以如果想要匹配斜杠符号本身,也需要转义

"/".match(/\//); // ["/", index: 0, input: "/", groups: undefined]

/* 下面这种写法是无法正常运行的 */
"/".match(///)

另外由于 \ 是特殊字符,所以要想匹配字符 \ 本身含义,需要转义:

'\\'.match(/\\/); // ["\", index: 0, input: "\", groups: undefined]
// \ 在JavaScript的字符串中也是转义功能,所以也需要转义自身,后面会说

JavaScript 中的特殊字符

在 JavaScript 中,字符串也有特殊字符,并且在字符串字面量中反斜杠 \ 也是转义字符。

var txt="We are the so-called "Vikings" from the north."
// Error: Uncaught SyntaxError: Unexpected identifier

在 JavaScript 中,字符串使用单引号或者双引号来起始或者结束。这意味着上面的字符串将被截为:We are the so-called。

要解决这个问题,就必须把在 "Viking" 中的引号前面加上反斜杠 (\)。这样就可以把每个双引号转换为字面上的字符。

var txt="We are the so-called \"Vikings\" from the north."
console.log(txt); // "We are the so-called "Vikings" from the north."

/**
 * 题外话:
 *   如果只是在字符串中显示引号本身,可以使用单双引号嵌套的方式达到效果
 */
var txt='We are the so-called "Vikings" from the north.'

在字符串中的反斜杠 \ 也表示转义或者类似 \n 这种只能在字符串中使用的特殊字符。这个引用会“消费”并且解释这些字符,比如:

  • \n 变成一个换行字符
  • \u1234 变成包含该码位的 Unicode 字符
  • 还有一些没有特殊的含义,就像 \d 或者 \z,碰到这种情况的话会把反斜杠移除

在 JavaScript 的字符串中要获取 \ 字符本身要使用 var str = "\\"; 这样写 var str = "\" 会报错。其实就是需要“转义”。

双重转义

上面说了 \ 在正则表达式中和 JavaScript 的字符串字面量中都是转义字符

在 JavaScript 中有两种方法创建正则表达式:

正则表达式字面量
var re = /ab+c/;

// 复杂点,包含了转义特殊字符
var myRe = /[a-z]:\\/i

正则表达式字面量中使用转义,直接在特殊字符前加 \ 就可以。

调用RegExp对象的构造函数

RegExp 构造函数接收一个字符串作为参数,这个字符串参数很像正则表达式字面量,但是不能直接将字面量的开关标记 / 替换成字符串的引号,因为正如上面所说:“字符串字面量中反斜杠也是转义字符”。

var regStr = "\d\.\d";
console.log(regStr); // "d.d"
var regexp = new RegExp(regStr);
"Chapter 5.1".match(regexp); // null

通过上面的代码可以知道,字符串引号会消费 \,所以在给 new RehExp 传递参数的时候,需要双倍反斜杠 \\,这也就是双重转义的含义。一重字符串转义,一重正则表达式转义。

复杂点的双重转义:

var regexp1 = new RegExp("node_modules[/\\\\]");  // /node_modules[/\\]/
var regexp2 = new RegExp("[a-z]:\\\\","i");   // /[a-z]:\\/i

偏个题,在 ES5 中,RegExp构造函数的参数有两种情况。

  • 参数是字符串,这时第二个参数表示正则表达式的修饰符(flag)

    var regex = new RegExp('xyz', 'i');
    // 等价于
    var regex = /xyz/i;
  • 参数是一个正则表示式,但是不允许使用第二个参数添加修饰符

    var regex = new RegExp(/xyz/i);
    // 等价于
    var regex = /xyz/i;
    // 无法使用第二个参数
    var regex = new RegExp(/xyz/, 'i');
    // Uncaught TypeError: Cannot supply flags when constructing one RegExp from another

ES6 改变了这种行为。如果RegExp构造函数第一个参数是一个正则对象,那么可以使用第二个参数指定修饰符。而且,返回的正则表达式会忽略原有的正则表达式的修饰符,只使用新指定的修饰符。

new RegExp(/abc/ig, 'i')
// 等价于 /abc/i

参考

typeof null 的历史问题

JavaScript 诞生以来便如此

引用 MDN 上的原话

// JavaScript 诞生以来便如此
typeof null === 'object';

在 JavaScript 第一版的实现中,值是以 32 位二进制存储(主要是那时候只有 32 位机),分为两部分:一个表示类型的标签(1 或者 3bits)和实际数据值表示。

类型标签存储在单元的低位,有五个值:

  • 000:对象。数据是对对象的引用
  • 1int。数据是一个 31 位有符号整数
  • 010double。 该数据是对双浮点数的引用
  • 100:字符串。数据是对字符串的引用
  • 110:布尔。 数据是布尔值

从中可以知道,如果最低位是 1,那么这个标签就只有 1 位,剩下 31 位都是数据;如果最低位为 0,那么标签长度为 3 位,高位是给四种类型提供的附加位。例如:表示字符串的标签是 100,其中最后一个 0 是类型标签,而 10 这两位是附加位。

现象解释

为什么 typeof 认为 null 是一个对象:它检查了它的类型标签,而类型标签说“object”。

由于 null 代表的是空指针(大多数平台下值为 0x00),即全为 0,因此 null 的类型标签是 0typeof null 也因此返回 "object"

typeof 在 SpiderMonkey 引擎中的实现

JS_PUBLIC_API(JSType)
JS_TypeOfValue(JSContext *cx, jsval v)
{
  JSType type = JSTYPE_VOID;
  JSObject *obj;
  JSObjectOps *ops;
  JSClass *clasp;

  CHECK_REQUEST(cx);
  if (JSVAL_IS_VOID(v)) {  // (1)
    type = JSTYPE_VOID;
  } else if (JSVAL_IS_OBJECT(v)) {  // (2)
    obj = JSVAL_TO_OBJECT(v);
    if (obj &&
      (ops = obj->map->ops,
        ops == &js_ObjectOps
          ? (clasp = OBJ_GET_CLASS(cx, obj),
             clasp->call || clasp == &js_FunctionClass) // (3,4)
      : ops->call != 0)) {  // (3)
      type = JSTYPE_FUNCTION;
    } else {
      type = JSTYPE_OBJECT;
    }
  } else if (JSVAL_IS_NUMBER(v)) {
    type = JSTYPE_NUMBER;
  } else if (JSVAL_IS_STRING(v)) {
    type = JSTYPE_STRING;
  } else if (JSVAL_IS_BOOLEAN(v)) {
    type = JSTYPE_BOOLEAN;
  }
  return type;
}
  • 在(1)处,首先检查值 v 是否定义。这个方式的实现:#define JSVAL_IS_VOID(v) ((v) == JSVAL_VOID)
  • 在(2)处,检查该值是否有对象标签
    • 满足(3)处的条件或者它内部属性[[Class]]将其标记为一个函数,那么 v 类型是function
    • 否则,v 是一个对象。此处导致typeof null产生错误的结果
  • 随后的检查是数字,字符串和布尔值。但是没有显示的检查 null,这是明显的设计错误(此处没有任何别的意思,而且个人很敬佩作者)。

修复?不存在的

曾有一个 ECMAScript 的修复提案(通过选择性加入的方式),但被拒绝(开发者不想得罪人,因为它会破坏现有的代码)了,该提案试图改成 typeof null === 'null'

HTML 文档的基础认识

在一些比较早的 web 页面,检查源代码经常能看到 html 的首行是这样的代码,或者类似代码

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
// 或
<html xmlns="http://www.w3.org/1999/xhtml">

<!DOCTYPE>

DOCTYPE 声明(全称 Document Type Declaration)。通常情况下,DOCTYPE 位于一个 HTML 文档的最开始的位置,位于根元素 HTML 的起始标记之前。因为浏览器必须在解析 HTML 文档正文之前就确定当前文档的类型,以决定其需要采用的渲染模式,不同的渲染模式会影响到浏览器对于 CSS 代码甚至 JavaScript 脚本的解析(尤其是在 IE 系列浏览器中,由 DOCTYPE 所决定的 HTML 页面的渲染模式至关重要)。

  • <!DOCTYPE> 虽然是标记的写法,但是它不是 HTML 标记,也没有结束标记
  • <!DOCTYPE> 声明必须是 HTML 文档的第一行,位于 <html> 标记之前
  • 不做 DOCTYPE 声明或声明不正确,可能导致 HTML 标记与 CSS 失效,从而令网页的布局变乱,造成网页在浏览器中不能正常的显示
  • 所有浏览器都支持 <!DOCTYPE> 声明,<!DOCTYPE> 声明对大小写不敏感,不过推荐大写

浏览器渲染模式(rendering modes)

早期浏览器对标准的错误实现、私有扩展的大量滋生和为了向前兼容以及早期标准本身的混乱等导致了那时的文档既没有 doctype 也没有对 DTD 的直接引用,也导致了新的标准难以得到应用和普及,因为浏览器无法区分它们。为了处理根据Web标准创作的网页和根据陈旧实践创作的网页,Todd Fahrner 在1998年提出了“came up with a toggle”方法1允许浏览器提供两套渲染模式: 即有完整的doctype的文档使用W3C的标准进行解析,否则使用旧的方式解析。

在 Internet Explorer 5 for Mac 工作的时代,Microsoft 的开发人员在更新浏览器版本时,针对当时的标准文档的支持进行了优化,但是这一优化致使旧的页面无法正确显示。或更确切地说,它们的渲染正确(根据规范),但是人们期望它们的渲染不正确。这些页面本身是根据当时主要的浏览器(主要是Netscape 4和Internet Explorer 4)的怪癖编写的。

微软首先采用了 Todd Fahrner 提出的解决方案。在呈现页面之前先查看“ doctype”,较旧的页面(依赖于较旧的浏览器的渲染功能)通常根本没有doctype,所以就像老式浏览器一样呈现这些页面。而为了“激活”新的标准支持,网页开发者必须通过在<html>元素之前提供正确的doctype。这就诞生了[doctype嗅探(doctype sniffing或doctype switching)](#Doctype Sniffing)

很快所有主要的浏览器都有了两种模式:“混杂模式”(又叫怪异模式,Quirks mode)和“标准模式”(又叫严格模式,Standards mode 或者 Strict mode),用以把能符合新规范的网站和老旧网站区分开。

后来,Mozilla 发布 Netscape 1.1时,他们无意间破坏了数千页,这些页依赖于一两个特定的怪癖。他们的解决方案便是后来的“几乎标准的模式”。

The HTML specification defines when a document is set to quirks mode, limited-quirks mode or no-quirks mode. [HTML]

目前现代浏览器的排版引擎都包含三种模式:

  • 标准模式(Standards mode,HTML规范中又叫 no quirks mode):遵循最新标准(由于不同的浏览器处于合规性的不同阶段,因此“标准”模式也不是一个单一目标)
  • 怪异模式(Quirks mode):浏览器会违反当代Web格式规范,为的只是避免“破坏”根据1990年代后期普遍做法(为遵循标准)编写的页面
    • 在Internet Explorer 6、7、8和9中,Quirks 模式实际上是冻结的IE 5.5
    • IE10和IE11的主要 Quirks 模式不再是IE 5.5的模仿,而是寻求与其他浏览器的 Quirks 模式互操作
    • 而在其他浏览器中,Quirks 模式与 Almost Standards 模式只有些许(handful)的不同
  • 接近标准模式(Almost standards mode,HTML规范中又叫 limited quirks mode):针对标准的某个老版本设计的网页,只有少数的怪异行为被实现

HTML 4.01 与 HTML5 的 <!DOCTYPE> 声明的差异

Web文档的呈现需要阅读程序通过某种规则解释文档中的标识。阅读程序可以是浏览器或者校验器等。

HTML 4.01 基于SGML, <!DOCTYPE> 声明通过引用 DTD 来告诉阅读程序如何解析标识,而 DTD 规定了标记语言的规则,这样浏览器就能正确地呈现内容。

在 HTML 4.01 中有三种 <!DOCTYPE> 声明类型:Strict、Transitional 以及 Frameset类型

<!-- Strict DTD类型,干净的标记,免于表现层的混乱 -->
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" " http://www.w3.org/TR/html4/strict.dtd">
<!-- Transitional DTD 类型,用户使用了不支持层叠样式表(CSS)的浏览器以至于不得不使用 HTML 的呈现特性时 -->
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" " http://www.w3.org/TR/html4/loose.dtd">
<!-- Frameset DTD类型,除 frameset 元素取代了 body 元素之外,Frameset DTD 等同于 Transitional DTD -->
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" " http://www.w3.org/TR/html4/frameset.dtd">

HTML5 基于自己的标准,而不是 SGML,所以不需要引用 DTD。但是 HTML5 语法要求声明 DOCTYPE,以确保浏览器以标准模式渲染页面,此外再没有其他用途

由于遗留原因,HTML5 需要 DOCTYPE。如果省略,浏览器倾向于使用与某些规范不兼容的呈现模式。在文档中包含 DOCTYPE 可确保浏览器尽最大努力遵循相关规范。

在 HTML5 中只有一种 <!DOCTYPE> 声明:

<!DOCTYPE html>

其实 HTML5 还有兼容旧版的 DOCTYPE 声明。

兼容旧版不是指与旧版浏览器的兼容性,因为旧版浏览器实际上忽略了系统标识符和DTD。以下写法是为了与HTML的旧版生产商(即输出HTML文档的软件)兼容,可以使用替代的旧版兼容性DOCTYPE。一般用在希望 DOCTYPE 包含 PUBLICSYSTEM 标识符且无法忽略它们的软件中。

<!DOCTYPE html SYSTEM "about:legacy-compat">

除此之外,常见的还有 XHTML、MathMl 及 Svg 等文档类型声明 valid-dtd-list

IE 条件注释

IE条件注释是微软从IE5开始就提供的一种非标准逻辑语句,作用是可以灵活的为不同IE版本浏览器导入不同html元素,如:样式表,html标记等

IE 条件注释在非 IE 浏览器中,可能完全被忽略,也可能被解释为普通 HTML 注释。但是在 IE 中(IE5 以上的版本才开始支持IE条件注释,所以只有 IE5 版本以上)它们才会起作用,而这就是 IE 条件注释的作用。所以这也是目前比较合适的在 DOCTYPE 之前写点什么又保证所有浏览器均为标准模式的做法。

<![if !IE]><!-- some comments --><![endif]>
<![if false]><!-- some comments --><![endif]>
或者
<!--[if !IE]>some text<![endif]-->

关键词解释:

  • lt :Less than,也就是小于的意思
  • lte :Less than or equal to,也就是小于或等于的意思
  • gt :Greater than,也就是大于的意思
  • gte:Greater than or equal to,也就是大于或等于的意思。
  • !:逻辑非,就是不等于的意思

通常用 IE 条件注释根据浏览器不同载入不同 css,从而解决样式兼容性问题的。其实它可以做的更多。它可以保护任何代码块——HTML代码块、JavaScript 代码块、服务器端代码等等。如下代码只有在大于 IE7 版本的 IE 浏览器中才会弹出弹框并显示内容:

<!--[if gte IE 7]>
<script>
  alert("Congratulations! You are running Internet Explorer 7 or a later version of Internet Explorer.");
</script>
<p>Thank you for closing the message box.</p>
<![endif]-->
不推荐在 DOCTYPE 之前加入任何非空白内容,即使这些内容可能是注释,因为某些浏览器会进入Quirks模式。针对 IE 各版本的样式差异,更好的解决方案是使用以下 hack 方式:
<!DOCTYPE html>
<!--[if IE 6]><html class="ie ielt9 ielt8 ielt7 ie6" lang="en-US"><![endif]-->
<!--[if IE 7]><html class="ie ielt9 ielt8 ie7" lang="en-US"><![endif]-->
<!--[if IE 8]><html class="ie ielt9 ie8" lang="en-US"><![endif]-->
<!--[if IE 9]><html class="ie ie9" lang="en-US"><![endif]-->
<!--[if (gt IE 9)|!(IE)]><!--><html lang="en-US"><!--<![endif]-->
  <head></head>
  <body>
  文档内容......
  </body>
</html>

先判断用户用的哪个 IE 版本,然后在标记上加上该版本的 class,这样可以方便 hack,css 文件则针对不同版本进行编写

.ie6 xxx {};
.ie7 xxx {};

DOCTYPE 声明的语法

<!DOCTYPE 根元素 可用性 "注册//组织//类型 标记 定义//语言" "URL">

准确来说,对于HTML文档来说,DOCTYPE 、根元素和可用性字段都是大小写不敏感的(也就是除了两个引号内的内容);但是XHTML 是区分大小写的

  • 根元素,指定 DTD 中声明的顶级元素类型,这与声明的 SGML 文档类型相对应,默认HTML
  • 可用性,指定正式公开标识符(FPI)是可公开访问的对象还是系统资源。 默认 PUBLIC 可公开访问的对象; SYSTEM 系统资源,如本地文件或 URL。
  • 第一个引号内的内容2,例如 -//W3C//DTD XHTML 1.0 Strict//EN
    • 注册,默认为 + ,表示组织名称已注册;- : 表示组织名称未注册
    • 组织, DOCTYPE 声明引用的 DTD 的创建和维护的团体或组织的名称,即 OwnderID。 IETF 或 W3C
    • 类型,指定公开文本类,即所引用的对象类型。默认 DTD
    • 标记,指定公开文本描述,即对所引用的公开文本的唯一描述性名称,后面可附带版本号。默认 HTML,其它可选如 XHTML
    • 定义,指定文档类型定义
      • Frameset 框架集文档
      • Strict 排除所有 W3C 专家希望逐步淘汰的代表性属性和元素,因为样式表已经很完善了
      • Transitional 包含除 frameSet 元素的全部内容。
    • 语言,指定公开文本语言,即用于创建所引用对象的自然语言编码系统。该语言定义已编写为 ISO 639 语言代码(大写两个字母)。 默认 EN。
  • 如果通过上面的方法找不到 DTD,浏览器将使用公共标识符后面的 URL 作为寻找 DTD 的位置。

PS: 记住只要紧紧拥抱 HTML5 就行。现代开发没必要去记 HTML 4.01 和 XHTML 的 <!DOCTYPE> 声明,只要知道有这些东西就行,如果真的要用,很多编辑器都有提示或者模版。也可以现查现用常用的 DOCTYPE 声明

<!DOCTYPE html>, is the simplest possible, and the one recommended by HTML5. Earlier versions of the HTML standard recommended other variants, but all existing browsers today will use full standards mode for this DOCTYPE, even the dated Internet Explorer 6. There are no valid reasons to use a more complicated DOCTYPE. If you do use another DOCTYPE, you may risk choosing one which triggers almost standards mode or quirks mode.3

Doctype Sniffing

Doctype Sniffing (又叫 Doctype Switching)。现代浏览器使用 doctype Sniffing(doctype 嗅探)来确定 text/html 文档的引擎模式。也就是基于HTML文档开头的文档类型声明(或缺少文档类型声明)来选择模式。

值得一提的是 IE8 为了解决向前兼容采用了 X-UA-Compatible 声明,导致在 IE8 中浏览器的渲染模式不仅仅取决于 doctype 嗅探还取决于 X-UA-Compatible 声明,这个不仅仅导致了模式判断更加复杂,也违背了web设计的逐渐增强(progressive enhancement)**4

注意:这不适于使用XML文档类型的文档。至于为什么,Addendum: A Plea to Implementors and Spec Writers Working with XML 中有更好的解释。

HTML4.01规范和ISO 8879(SGML)都没有说关于使用文档类型声明作为引擎模式转换的事情。而 Doctype 嗅探出现的背景可见上文"[那时的文档既没有 doctype 也没有对 DTD 的直接引用](#浏览器渲染模式(rendering modes))"

HTML5 并不基于SGML也没有DTD,但它为了向前兼容,接受了doctype嗅探这个事实,定义了在 text/html<!DOCTYPE> 是唯一的且唯一作用也只有模式转换声明,除此外没有什么别的用处。

选择合适的 Doctype

以下是为新 text/html 文档选择文档类型的简单准则:

  • 标准模式,最新验证

    <!DOCTYPE html>

    推荐总是使用此文档类型。这样做会让目前还不支持 HTML5 的浏览器也采用标准模式解析,这意味着他们会解析那些 HTML5 中兼容的旧 HTML 标签的部分,而忽略他们不支持的 HTML5 新特性。(可以在支持 HTML5 的浏览器中验证 <video><canvas> 和 ARIA 等新功能)

  • 标准模式,旧版验证目标

    <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">

    此文档类型也会触发“标准”模式,但是这是不太精确的旧版验证,因为它不了解一些 HTML5 新功能,也许应该及时更新文档了。

  • 使用“标准”模式,但是在表布局中使用切片图像,并且不想对其进行修复

    <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">

    提供了几乎标准模式,如果以后再使用HTML5(并因此采用完整的标准模式),则基于表中切片图像的布局可能会中断,因此最好使现在就与标准模式兼容。

  • 故意要使用 Quirks模式

    不写文档类型声明。

    如果非要对 IE 旧版本进行兼容,那么最好使用条件注释对旧版本应用特定的hack,而不是在Quirks模式下寻求通用性。

  • 不建议给 text/html 的文档使用任何 XHTML 文档类型

X-UA-Compatible 声明

X-UA-Compatible 属性是 IE 浏览器在 IE8 版本开始提供的一个特性,允许开发者通过在 meta 元素或 HTTP 响应 headers 中包含该属性说明来指定文档模式

所有 <meta http-equiv="X-UA-Compatible" content="IE=xx"/> 都可以改为在 HTTP 响应头添加属性值,两者等效

通俗讲,通过 X-UA-Compatible 声明可以实现 IE 浏览器版本模拟。但是只能模拟比当前版本更低的版本,并不能模拟高版本。使用时其必须放在 <head></head> 标记内,并且应该尽可能靠前,除了 TITLE 元素和其它 META 元素外,它应该放在所有其它元素之前。

X-UA-Compatible 语法
  • 直接指定 IE 某个版本的标准文档模式

    <meta http-equiv="X-UA-Compatible" content="IE=8"/>
    <!-- 这在IE7、IE6中无效,因为 X-UA-Compatible 是 IE8 才开始支持的 -->
    
    <meta http-equiv="X-UA-Compatible" content="IE=9"/>
    <!-- 这不仅在IE7、IE6中无效,在 IE8 中也无效,因为不能模拟高于当前的版本 -->
  • 指定某个IE版本,但也允许例外

    在IE版本号前面加上 Emulate ,代表,如果网页开头有 <!DOCTYPE> 标记就使用该IE版本的标准文档模式,否则使用怪异模式(即等同于 IE=IE5),不建议使用该语法。

    <meta http-equiv="X-UA-Compatible" content="IE=EmulateIE8"/>
    <!-- 如果网页开头带有 <!DOCTYPE> 标记,则模拟 IE8,网页开头没有 <!DOCTYPE> 标记,则模拟 IE6 -->
  • 使用最新版本文档模式

    IE=edge 表示 IE 应该使用其渲染引擎的最新(Edge)版本,即使用其可用的最高模式,如用 IE8 访问就是 IE8 文档模式,用 IE9 访问就是 IE9 模式

    <meta http-equiv="X-UA-Compatible" content="IE=edge">
  • 一种特殊的用法

    安装了 Google Chrome Frame(谷歌浏览器內嵌框架) 则使用谷歌浏览器内核模式,否则使用最新的IE模式

    此项目已于2014年2月25日正式停止维护与更新6。淘汰原因也比较符合认知:“如今,大多数人都在使用支持大多数最新Web技术的现代浏览器。更好的是,旧版浏览器的使用率显着下降,而新版浏览器会自动保持最新状态,这意味着领先优势已成为主流。7

    <meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1">
X-UA-Compatible 用法

例如我们要往下兼容到 IE8

  • 我们网页应该提前添加好标记:

    <meta http-equiv="X-UA-Compatible" content="IE=IE8"/>
  • 然后开发者按 IE8 浏览器文档模式对 HTML/CSS/JS 代码进行兼容性处理,不使用超过 IE8 文档模式的特性

  • 之后,我们就只需要维护一份 IE8 兼容代码,而用户无论是在 IE9 / IE10 / IE11 访问都是如同 IE8 访问一样(要往下兼容到 IE9 或者 IE10 都以此类推)

  • 最后,为 IE8 以下版本添加旧版IE浏览器升级提示,或跳转到IE浏览器升级提示页,如在 X-UA-Compatible 声明代码下添加 IE 条件注释:

    <!--[if lte IE 7]><script>window.location.href='http://support.dmeng.net/upgrade-your-browser.html?referrer='+encodeURIComponent(window.location.href);</script><![endif]-->

    IE10不支持 IE 条件注释,但 @cc_on 是 IE10 及更旧版IE特有的条件编译语句,因此可以用来判断是否除 IE11 的其他IE版本。

    所以如果为 IE11 以下版本添加旧版IE浏览器升级提示,或跳转到IE浏览器升级提示页,应该在 X-UA-Compatible 声明代码下添加:

    <script>/*@cc_on window.location.href="http://support.dmeng.net/upgrade-your-browser.html?referrer="+encodeURIComponent(window.location.href); @*/</script>
国内双核浏览器兼容思路

国内很多浏览器其实就是给 Chromium 内核和 IE 内核套层壳,国内大多浏览器都自称为双核引擎(基于 Webkit 的内核用于常用网站的高速浏览,基于 IE 的内核主要用于部分网银、政府、办公系统等网站的正常使用)。用户访问网页时双核浏览器根据网页内容自动选择内核。不过,双核浏览器也提供了类似 X-UA-Compatible 特性的 meta 标记,允许网页开发者通过标记选择内核

  • 使用 Chromium 内核(极速模式)

    <meta name="renderer" content="webkit"/>
    <!-- 下面代码可以强制令其使用 Chromium 内核(也就是 Webkit 内核)渲染 -->
    <meta name="force-rendering" content="webkit"/>
  • 使用 IE 8/9/10/10 内核(IE标准模式,部分支持 HTML5)

    <meta name="renderer" content="ie-stand"/>
  • 使用 IE 6/7 内核(IE兼容模式,不支持 HTML5)

    <meta name="renderer" content="ie-comp"/>

没有理由再在页面上添加 X-UA-Compatible

实际上,由于 IE11 以下版本都已停止更新5,如无特殊情况,无论是从代码工作量还是从用户安全的角度来讲,我们都不应该再兼容 IE11 以下版本。不仅 IE11 对该属性不再重视(甚至已经从明显的地方移除),其特殊用法使用谷歌浏览器内核模式中的谷歌浏览器内核插件也早已停止维护。

  • 当 Internet Explorer 遇到 X-UA-Compatible 的 META 标记时,它将使用指定版本的引擎重新开始这是性能下降的原因,因为浏览器必须停止并重新开始分析内容
  • 其设计出来仅是为了临时使用。最佳实践是给HTTP 标头添加 X-UA-Compatible。将指令添加到响应标头中将告诉 Internet Explorer 在解析内容开始之前使用哪个引擎。必须在网站的服务器中进行配置
  • 在HTML中包含 X-UA-Compatible 元标记的唯一原因是要在网站的IE8、9和10中覆盖用户的"兼容性视图"设置

小结

在 HTML5 之前,为了获得正确的 doctype 声明,关键就是让dtd与文档所遵循的标准对应。例如,假定文档遵循的是xhtml 1.0 strict标准,文档的doctype声明就应该引用相应的dtd。

HTML5 时代,请使用HTML5文档类型 <!DOCTYPE html>,该文档类型更短,更甜美,并在所有现代浏览器中触发标准模式

SGML 与 HTML

这一节只是出于历史完整性和对遗留技术的简单记录。可能存在技术的不严谨和历史的错误描述。

SGML

The Standard Generalized Markup Language (SGML), is a language for defining markup languages.

标准通用标记语言(SGML)是用于开发标记语言的语言。制定 SGML 的基本**是把文档的内容与样式分开。

一个SGML文件通常分三个层次:结构、内容和样式。结构为组织文档的元素提供框架,内容是信息本身,样式控制内容的显示。

SGML 中定义的每种标记语言都称为 SGML 应用程序。SGML 应用程序通常具有以下特征:

  1. SGML 声明。SGML声明指定哪些字符和分隔符可以出现在应用程序中,例如 SGML HTML 4声明

  2. 文档类型定义(Document Type Definition, DTD)。DTD 定义了标记构造的语法,例如 HTML 4.01 Strict DTD

    DTD 还可以包括其他定义,比如数字和命名字符实体(常见的有 &lt; 代表符号 <&gt; 代表符号>,又如 &#229; 代表 å,以及 &#x6C34; 表示汉字 “水”等等)

  3. 描述归因于 markup 的语义的规范。该规范规定了 DTD 中无法表达的语法限制(相当于 DTD 的补充)

  4. 包含数据(内容)和标记的文档实例。其实就是加上标记处理后的文件。

另外,可扩展标记语言(XML)是从 SGML派生的一种简单、灵活的文本格式。

HTML DTD

DTD 用来声明该份文件的结构与语法参数,不同的“文件内容”使用不同的“标记”来描述。在这里所谓“标记”(Tag)是指用一特定符号将信息内容中的某一部分加以注记,而此特定符号就称为“标记”。如 都是一种标记。当然标记也可以是任何一小段文字。如 <NAME></NAME>,而 <NAME>Iamstudent</NAME> 则是一段加上标记的字串。

HTML DTD 中大部分内容都是元素类型及其属性的声明,剩下是 SGML 语法本身的注释和一系列参数实体定义。

下面是 ul 元素类型声明的例子(属性声明等等语法就不再展示)

 <!ELEMENT UL - - (LI)+>
  • <!ELEMENT 关键字开始一个声明,而 > 字符结束它
  • 声明的元素类型为UL
  • 两个连字符- -表示此元素类型的开始标记 <UL> 和结束标记 </ UL> 都是必需的
  • (LI)+ 声明此元素类型的内容模型为“至少一个LI元素”,这里的 + 与正则里的通配符很像

SGML 的语法不是本文的目标。对此感兴趣可以自行查找。

HTML5 没有 DTD

HTML 的某些早期版本(尤其是从HTML2到HTML4)基于 SGML,并使用 SGML 解析规则。但是,很少(如果有的话)Web浏览器对 HTML 文档实施过真正的 SGML 解析。过去,唯一严格将 HTML 作为 SGML 应用程序处理的用户代理就是验证器。由此产生的混乱-验证者声称文档具有一种表示形式,而广泛部署的Web浏览器可互操作地实现另一种表示形式-浪费了数十年的生产力。因此,HTML5 版本的 HTML 返回到非 SGML 基础。

HTML5 是没有对应的 DTD 的。HTML5 的设计者认为 DTD 的表达能力太有限,因此使用 HTML5 验证器(https://validator.nu/ 和w3c的副本https://validator.w3.org/nu/ 以及WHATWG的验证器https://whatwg.org/validator/)的模式进行验证,而不再是基于 DTD 的验证。

而且,HTML5 的设计使其不可能编写 DTD。例如,在 HTML5 中任何以 data- 开头并符合某些通用规则的属性名称都应是有效的,开发者可以写出无数个不同的这样的属性。然而在 SGML 中,要求每条属性都需要单独列出,想要实现 data- 这一特性,那么 DTD 就必须是无限的。这显然是做不到的。

根元素

一个 HTML 页面是一系列嵌套的元素。页面的整个结构就像一棵树。最外面的元素是页面上所有其他元素的祖先,被称为“根元素”。HTML 页面的根元素始终为 <html>。根元素示例:

<html xmlns="http://www.w3.org/1999/xhtml"
      lang="en"
      xml:lang="en">

这个标记没有错。它是有效的 HTML5。但是 HTML5 中不再需要其中的一部分,因此可以通过删除它们来节省一些字节。

HTML4 及以前版本则都需要按照开头这种格式,没有可以省略属性

xmlns属性

xml namespace 的缩写,XHTML 是 HTML 向 xml 过度的标记语言,它需要符合 xml 文档规则,因此,需要定义命名空间。

当 XHTML 文档中缺少 xmlns 属性时,位于 http://w3.org的HTML验证程序不会发出警告。因为名称空间 xmlns = http://www.w3.org/1999/xhtml 是默认命名空间,即使不包含它也将添加到 <html> 标记中

上面代码表示此页面中的元素位于 XHTML 命名空间 http://www.w3.org/1999/xhtml 中,但是HTML5 中的元素始终在此命名空间中8。因此不再需要显式声明它,无论此属性是否存在,HTML5 页面在所有浏览器中的工作方式都将完全相同。

删除 xmlns 属性后根元素:

<html lang="en" xml:lang="en">

langxml:lang 属性

这两个属性都是定义当前 HTML 页面的语言。它们的值都是 ISO 639-1 中定义标准两字符语言代码9。例如,英语 English 代码为 en,即写作 lang=en。如果希望指定某种语言的方言,可以在语言代码后面紧跟一个破折号和一个子代码名称。例如,简体中文 Chinese (Simplified) 的代码就是 zh-CN

有两个语言属性,这也是 XHTML 遗留的问题,xml:lang属性是 XHTML 中新定义的,不能在HTML中使用,所以 HTML5 中可以直接省略。如果要保留,必须得保证它跟 lang 的属性值相同。

在 XHTML 文档中, lang 属性已从 XHTML 1.1 中删除。但是,XHTML规范建议在 XHTML 1.0 文档的 <html> 元素中同时使用 lang 属性和 xml:lang 属性,以在不同的浏览器之间获得最大的兼容性。如果网页定义为 XHTML1.1 或者XML格式,那么可以使用 xml:lang 属性(因为 xml:lang 属性是在XML中确定语言信息的标准用法)。

只有lang属性在 HTML5 中有效,所以 HTML5 最佳的根元素书写如下:
<html lang="en">

lang 属性用于 <html> 元素中时,它将作用于整个文档;而在用于其他元素中时,它将仅作用于这些元素的内容。如果一个页面用到了多种语言,那么可以给相应的元素类型添加 lang 属性,它会覆盖添加在 <html> 上的 lang 属性值

<p lang="en-GB">This paragraph is defined as British English.</p>
<p lang="fr">Ce paragraphe est défini en français.</p>

参考链接

DOCTYPE 与浏览器模式分析

你所未必知道的关于标记细节

怪异模式和标准模式

Quirks Mode

Dive Into HTML5

About conditional comments

Activating Browser Modes with Doctype

DTD文档类型声明doctype

X-UA-Compatibility Meta Tag and HTTP Response Header

Why use X-UA-Compatible IE=Edge anymore?

IE 兼容性标记 X-UA-Compatible 解释和用法

浏览器内核控制标记meta说明

DTD Tutorial

Where is the HTML5 Document Type Definition?

On SGML and HTML

SGML

The lang and xml:lang attributes

localStorage 一次全知道

API 使用

首先 localStorage 对象是简单的键值存储,类似于对象:

// 增加一个数据项,以下写法等价,推荐使用 setItem() 方法
localStorage.setItem('myCat', 'Tom');
localStorage.myCat = 'Tom';
localStorage['myCat'] = 'Tom';

// 获取 'myCat' 数据项的值
let cat = localStorage.getItem('myCat');
let cat = localStorage.myCat;
let cat = localStorage.['myCat'];

// 已存储数量
localStorage.length;

// 移除 'myCat'
localStorage.removeItem('myCat');
delete localStorage.myCat;

// 清空所有
localStorage.clear();

/**
 *  下面是一些不常见的方法
 */
// localStorage.hasOwnProperty() 检查 localStorage 中存储的数据里是否保存某个值
// 和对象一样,用来检查自身属性中是否具有指定的属性
localStorage.hasOwnProperty('youCat'); // false

// 可以使用 Object.keys(localStorage) 遍历所有键,没啥奇怪的,毕竟 localStorage 对象其实就是 Storage 对象的实例对象。

// localStorage.key(index),这个方法感觉有意思
localStorage.key(2);  // "myCat"
为什么推荐使用 setItem()getItem()removeItem() 等方法,而不推荐用类对象方式?
  • 不使用 setItem() 方法会有个小问题,有些键不会生效,例如 lengthtoString
  • 无法触发 storage 事件。

监听 storage 事件

localStoragesessionStorage 中的数据更新后,会触发 storage 事件:

window.addEventListener('storage', () => {
  if (event.key !== 'now') return;
  console.log(`${event.key }:${ event.newValue } at ${ event.url}`);
});
localStorage.setItem('now', Date.now());

容易忽略的点: 如果在当前页面监听了 storage 事件,然后在当前页面修改 loacalStorage 的数据,那么当前页面(除了 iframe 中)是不会触发该事件的。在相同域名下的其他页面(如一个新标签或 iframe)发生的改变才会起作用

必知必会

  1. 存储格式

    简单的键值对格式,类似于对象。

  2. 存储的键值对采用什么编码

    键和值始终采用 UTF-16 DOMString 格式,或者说就是**字符串。**如果键是数字类型,也会自动转为字符串。

  3. 值可不可以直接存对象

    不可以,目前仅支持字符串,如果要存对象需要使用 JSON.stringifyJSON.parse 方法转换。如果直接存一个对象,会被自动转为 "[object Object]"

  4. localStorage 最大容量 5M 是什么意思,M 的单位是什么

    • 存储容量限制,最大存储容量为 5M。不同浏览器可能不同,但是最大也不会超过 5M。可以使用 Web Storage Support Test 跑下试试。

    • 最大容量 5M 的意思是同源(域名包括子域名、协议、端口必须都要相同)下所有页面使用同一储存区域,且最大容量为 5M

    • 这个 5M字符串的长度值(就是 length,或者是 utf-16 编码单元的个数,或者是 10MB(yte)。

      为什么是 10MB?

      这是因为 UTF-16 编码规则的原因。码点小于 0xffff(65535) 的字符占用两个字节,大于这个码点值的字符则占用四个字节。

      那不应该是 5MB ~ 10MB 之间吗?

      ,码点大于 0xffff 的字符长度(length)为 2,所以就算有非基本平面的字符,1个长度依然对应两个字节。

      "a".length // 1
      "人".length // 1
      "𠮷".length // 2
      "😂".length // 2

      所以换算成字节数就是 5M * 2B = 10MB

  5. 储存满了会怎样,如何存储超过 5M 的数据

    超出容量会抛出错误 QuotaExceededError(超出配额),且本次操作不会成功。

    首先,尽量不要存储这么大的数据。如果非要存储,且超过了最大容量,可以在当前页面创建一个不同源的 <iframe>,然后使用 window.postMessage(message, targetOrigin, [transfer]) 方法进行跨源通信。

    • message:发送的数据,需要使用 JSON 字符串化
    • targetOrigin:指定哪些窗口能接收到消息事件,如果是一个 URI,那么目标窗口的协议、主机地址或端口需要完全匹配。
    • transfer:可选

    这个方法也可以用来解决跨域共享 localStorage 数据的问题。

  6. 键占不占内存

    ,容量应该是键值共用的。

  7. 键的数量,对读写的性能影响

    键的数量对读取性能有影响,但是不大。值的大小对性能影响更大,不建议保存大的数据(纯大数据,可以用 indexedDB)。

  8. localStorage 如何做到持久化储存的

    对于 localStorage 数据的存储,是存在于本地的文件系统中的,也就是存在硬盘里面(存储位置)的,所以关闭浏览器也不会消失。

  9. 数据共享

    • 不同浏览器无法共享 localStoragesessionStorage 中的信息
    • 满足同源策略的不同页面可以共享相同的 localStorage
    • 不同页面间无法共享 sessionStorage 的信息
  10. 读取是同步还是异步

    读取都是同步的。如果存储数据比较大,会有明显的延迟感知。而且可能会阻塞 UI 渲染。

  11. sessionStorage 的区别

    • 属性和方法都一样
    • sessionStorage 数据在页面刷新后仍然保留,但在关闭/重新打开浏览器标签页后不会被保留;而 localStorage 除非人为删除数据,数据不会过期。浏览器重启甚至系统重启后仍然存在。
    • sessionStorage 的数据是不能在不同页面间共享的,即使同源也不行。但是在同一页面下同源的 iframe 可以。
  12. cookie 比较

    • 存储数据大小不同,cookie 一般是不超过 4KB 的小型文本数据
    • cookie 默认会随着请求发送给服务器,Web 存储数据不会
    • Web Storage 对象都是绑定到源(协议、域和端口),不同协议或子域对应不同的存储对象,它们之间无法访问彼此数据。cookie没有限制,但是可以设置 SameSite 属性。
    • 服务器无法通过 HTTP header 操纵存储对象却可以修改 cookie。Web存储的一切都是在 JavaScript 中完成的。

封装方法

  • 统计 localStorage 已使用空间
function sieOfLS() {
    return Object.entries(localStorage).map(v => v.join('')).join('').length;
}
  • 带失效时间的 localStorage。其实就是使用 js 在每次读取数据时判断:
/**
 * 封装原生 localStorage API, 支持失效时间
 */

class Storage {
  // 设置默认失效时间7天
  age = 7 * 24 * 60 * 60 * 1000;

  /**
   * 设置生存周期
   * @param {number} age 
   */
  setAge(age) {
    this.age = age;
    // 返回this,使其可以链式调用
    return this;
  }

  /**
   * 存储数据
   * @param {string} key 
   * @param {*} content 
   */
  set(key, content) {
    // 先清除相同key的数据
    // ios 中不能设置已存在的key值,会报错
    localStorage.removeItem(key);
    // 创建时间
    const _time = new Date().getTime();
    const _age = this.age;

    // 将数据和生存周期当做对象属性一并存储
    const value = {};
    value._time = _time;
    // 失效时间的临界点
    value._age = _age + _time;
    value._value = content;

    // localStorage 存储的只能是字符串
    // 使用JSON字符串化数据
    localStorage.setItem(key, JSON.stringify(value));
    return this;
  }

  /**
   * 判断是否过期
   * @param {string} key 
   */
  isExpire(key) {
    let isExpire = true;
    let value = localStorage.getItem(key);
    const now = new Date().getTime();

    // 有数据才判断是否过期
    if (value) {
      value = JSON.parse(value);
      return now > value._age;
    }
    
    // 没有数据直接返回false
    return isExpire;
  }

  /**
   * 获取存储的数据
   * @param {string} key 
   */
  get(key) {
    let value = null;
    // 判断是否过期
    const isExpire = this.isExpire(key);

    // 如果已经过期,返回 null
    if (isExpire) {
      return value;
    }

    const content = localStorage.getItem(key);

    value = JSON.stringify(content);
    
    return value._value;
  }
}

const storage = new Storage();

export default storage;

参考

初识 Chrome DevTools Protocol

最近想学习前端爬虫,在使用无头浏览器 Puppeteer 的时候,知道了Puppeteer 是通过 Chrome DevTools Protocol 来控制 Chrome 或 Chromium。

Chrome DevTools Protocol (非官方叫法还有 Remote Debugging Protocol、Chrome Debugging Protocol 其实指的都是该协议),也可以简称为 CDP。该协议允许工具对 Chromium,Chrome 和其它基于 Blink 的浏览器进行测试、检查、调试以及配置。作为前端,对浏览器的开发者工具 Chrome DevTools (开发时总是要打开的那个可以看console、网络和原码的面板)一定不陌生,它就是基于此协议实现的小应用。第三方开发者也允许调用这个协议来与页面进行交互调试

Chrome DevTools Protocol 被分为多个域(DOM,Debugger,Network 等),每个域定义了相应支持的 commands 和相关的 events,commands 和 events 都是固定结构的序列化 JSON 对象。

协议使用 Websocket 来与页面建立通信,相应数据由发送给页面的 commands 和页面自身产生的 events 共同组成。

使用 Chrome DevTools 作为协议客户端

使用命令行运行 Chrome 浏览器并增加启动参数 --remote-debugging-port,一般这个端口设置为 9222

# windows
chrome.exe --remote-debugging-port=9222
# linux
google-chrome --remote-debugging-port=9222
# macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222

Tips: macOS 中使用上述方式打开浏览器前,需要完全退出 Chrome 浏览器,否则无法启动监听页面

在浏览器中输入地址 localhost:9222 可以打开下面页面

页面上显示的是当前浏览器打开的所有页面(包括正在运行的 chrome 插件)。点击其中一个页面,就会跳转到这个页面地址(注意是打开新的页面而不是跳转到已经打开的该页面),并且默认打开了开发者工具页面。

当运行浏览器时,如果添加远程调试端口,那么 Chrome 作为一个服务器提供数据,而 Developer Tools front-end 作为数据展示的前端页面。它们之间使用 Websocket 进行链接,并且传递 json 数据。远程调试协议版的 DevTools 和内建的开发者工具功能一致。

访问与操作

以添加远程调试参数的方式运行 Chrome,然后打开 http://localhost:9222/json/ 地址,可以看到当前所有页面的描述信息。可以通过该地址对 Chrome 进行远程调试。

  • list 列举所以标签页 http://localhost:9222/json/list ,其实http://localhost:9222/json/http://localhost:9222/json/list 的省略写法。会返回一系列 json 数据,内容格式如下:

    {
       "description": 页面描述,基本都为空,
       "devtoolsFrontendUrl": 相对应开发者工具页面的 URL,
       "id": 页面 id,每个页面唯一,
       "title": 页面标题,
       "type": 页面类型,最常见的是 page,
       "url": 页面 URL,
       "webSocketDebuggerUrl": websocket 对应的 URL,
    }
  • new 新标签页,浏览器会创建一个新的 tab 页并将当前页面的信息以 JSON 格式返回

    # 打开一个空白页面
    http://localhost:9222/json/new
    # 打开一个指定 url 的页面
    http://localhost:9222/json/new?https://github.com/
    
  • close 关闭标签页,通过访问 http://localhost:9222/json/close/(xxxx_id) 的方式,关闭相应 id 所对应的 tab。

  • activate 激活标签页,使用方法类似 close,可以通过访问 http://localhost:9222/json/activate/(xxxx_id) 的方式来重新启用该页面

  • version 返回浏览器版本

    {
       "Browser": "Chrome/88.0.4324.182",
       "Protocol-Version": "1.3",
       "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36",
       "V8-Version": "8.8.278.17",
       "WebKit-Version": "537.36 (@73ee5087001dcef33047c4ed650471b225dd8caf)",
       "webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/ee79aa28-80d2-44fa-a434-04beaaa9c709"
    }

使用第三方应用或更高级 API

协议本身定义了很多内容,但是对于日常开发来说,几乎不会接触到直接使用其协议定义的原始交互功能,而是通过更高级的封装应用来使用。比如 Chrome DevTools 和 Puppeteer,它们都将协议的功能封装成比较高级的 API,方便开发者调用。

参考

Blob 与 dataURL 转换

Data URLs

过去被叫做data URIs,直到 WHATWG 将其更名为data URL(s)

Data URLs,即前缀为 data: 协议的URL。目的是为了将一些小文件直接嵌入进文档中,而不是通过发起网络请求获取。

不严谨的说就是一种特殊格式的 url,但是 Data URL与传统的url不同:

  • 传统的 url在浏览器地址栏中输入,可以直接导航到目标地址
  • 而data URL则是一个data 的 url 表现,可以理解为用url代表数据。如果在浏览器地址栏输入 Data URL 字符串,可能会一文档的形式预览数据

组成

Data URLs 由四个部分组成:前缀(data:)、指示数据类型的MIME类型、如果非文本则为可选的 base64 标记、数据本身:

data:[<mediatype>][;base64],<data>
  • mediatype 是个 MIME 类型的字符串,例如 "image/jpeg" 表示 JPEG 图像文件。如果被省略,则默认值为 text/plain;charset=US-ASCII

    注意,使用 canvas.toDataURL() 得到的 Data URL 默认MIMIE type为"image/png"

  • 如果 data 是纯文本,可以简单的嵌入文本,甚至可以省略 MIME type 和 ;base64,data和数据间只有:,

    data:,Hello%2C%20World!
  • 当然普通的文本数据也可以嵌入base64 编码的数据

    data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==
  • 如果data不是纯文本的话,可以标识为 base64,并且嵌入base64编码的二进制数据。比如图片:

    data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAADklEQVQYV2NkgAJGGAMAAC0AA03DhRMAAAAASUVORK5CYII=

数据前面的逗号 , 是一定存在的,无论有没有 base64 选项。

使用问题

  • 前面说了 Data URL 是为了小文件嵌入文档而产生的。虽然可以支持无限长度,但是支不支持无限长度取决于浏览器厂商。而且太长的 Data URL 对于代码的格式化,显示都不友好。

  • 如果图片较大,图片的色彩层次比较丰富,则不适合使用这种方式,因为其base64编码后的字符串非常大,会明显增大HTML页面,影响加载速度

  • MIME 类型错误或者 base64 编码错误,都会造成 data URL 无法被正常解析,但不会有任何相关错误提示

  • 一个data URL的数据字段是没有结束标记的,所以尝试在一个data URI后面添加查询字符串会导致,查询字符串也一并被当作数据字段

    data:text/html,lots of text...<p><a name%3D"bottom">bottom</a>?arg=val

    ?arg=val 会被当做数据的一部分,从而造成数据无法正确解析。

使用 base64 来编码 data URL 中的 <data>

由于 base64 仅仅是通过ASCII字符组成,所以 base64 字符串是 **url-safe ** 的,因此才将base64应用于data URL的<data>中。

base64

base64编码本质上是一种【将二进制数据转成文本数据】的编码规则。对于非二进制数据,是先将其转换成二进制形式,然后每连续6比特(2的6次方=64)计算其十进制值,根据该值在base64索引表中找到对应的字符,最终得到一个只包含可打印ASCII字符的文本字符串。

早期的一些传输协议,例如邮件传输协议SMTP,只能传输可打印的ASCII字符。那么那些控制字符怎么传输就成了问题,比如图片二进制流的每个字节不可能全部是可见字符,所以就传送不了。

怎么办?【把不可打印的字符也能用可打印字符来表示,问题就解决了】。所以 base64 编码应运而生。

标准base64协议规定 base64 包含 64 个ASCII可打印字符(A-Z、a-z、0-9、+、/),所以叫 base64。(可打印字符有 95个,为什么只选 64 个我也不知道)。

ASCII 码的范围是0-127,其中0-31和127是控制字符,共33个。其余95个,即32-126是可打印字符

下面是 base64 的索引表,数值代表字符的索引(对应 2的6次方),所以 base64 是将每连续 6bit 表示一个字符。

img

base64 编码原理

假设我们要对 Hello! 进行base64编码,按照ASCII表,其转换过程如下图所示:

原始字符串长度为6个字符,编码后长度为8个字符,每3个原始字符经base64编码成4个字符,也就是编码后比源码多了1/3。

编码前后长度比4/3,这个长度比很重要:

  • 比原始字符串长度短,则需要使用更大的编码字符集,这并不我们想要的

  • 长度比越大,则需要传输越多的字符,传输时间越长

base64应用广泛的原因是在字符集大小与长度比之间取得一个较好的平衡,适用于各种场景。

base64编码是每3个原始字符编码成4个字符,如果原始字符串长度不能被3整除,那怎么办?使用0值来补充原始字符串。

Hello!! 为例,其转换过程为:

注:图表中蓝色背景的二进制0值是额外补充的。

最后2个零值只是为了base64 编码而补充的,在原始字符中并没有对应的字符,那么base64编码结果中的最后两个字符 AA 实际不带有效信息,所以需要特殊处理,以免解码错误。

标准base64编码通常= 字符来替换结尾处的 A,即编码结果为 SGVsbG8hIQ==。因为 = 字符并不在base64编码索引表中,其意义在于结束符号,在base64解码时遇到 = 时即可知道一个base64编码字符串结束。【重要】

如果base64编码字符串不会相互拼接再传输,那么最后的 = 也可以省略,解码时如果发现base64编码字符串长度不能被4整除,则先补充 = 字符,再解码即可。

解码是对编码的逆向操作,但注意一点:对于最后的两个 = 字符,转换成两个 A 字符,再转成对应的两个6比特二进制0值,接着转成原始字符之前,需要将最后的两个6比特二进制0值丢弃,因为它们实际上不携带有效信息

在 JavaScript 中,有两个函数被分别用来处理解码和编码 base64 字符串:

  • atob() 解码,ascii to binary
  • btoa() 编码,binary to ascii
let encodedData = window.btoa("Hello, world"); // 编码
let decodedData = window.atob(encodedData);    // 解码

大多数的编码都是由字符转化成二进制的过程,而从二进制转成字符的过程称为解码。而base64的概念就恰好反了,由二进制转到字符称为编码,由字符到二进制称为解码。

unicode 问题

由于 DOMString 是16位编码的字符串,所以如果有字符超出了8位ASCII编码的字符范围时,在大多数的浏览器中对Unicode字符串调用 window.btoa 将会造成一个 Character Out Of Range 的异常。

扩展:百分号编码

**方法一:**在编码前对数据进行转译:

function b64EncodeUnicode(str) {
  /**
   * - 通过 encodeURIComponent 会将目标字符串转为 percent-encoded 的字符串
   *    例如 encodeURIComponent('你') 结果为 '%E4%BD%A0'
   * - 然后将百分比编码里的 % 都替换成 0x (十六进制前缀)
   *    现在得到三个十六进制表示的ASCII码点(0x00 ~ 0xFF) 0xE4 0xBD 0xA0
   * - String.fromCharCode() 方法返回ASCII字符
   *
   * btoa() 现在可以对0x00 ~ 0xFF范围内的二进制数据进行编码了
   */
  return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
    (match, p1) => {
      return String.fromCharCode(`0x${ p1}`);
    }));
}

百分号编码:“%” 后跟替换字符的ASCII的十六进制表示

解码基本思路就是编码的逆转

  • 首先通过 atob() 方法将ASCII字符转为二进制,获取一串用十六进制表示的二进制字符串
  • 然后将十六进制转为百分号编码,其实就是一串 URI 字符串
  • 最后还要使用 decodeURIComponent() 方法对 URI 字符串进行解码。
function b64DecodeUnicode(str) {
  return decodeURIComponent(atob(str).split('').map((c) => {
    return `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`;
  }).join(''));
}

charCodeAt() 方法返回 065535 之间的整数,表示给定索引处字符的 UTF-16 代码。

方法二: 用JavaScript的 TypedArray 和 UTF-8重写DOM的 atob()btoa()。思路就是手动构建base64字符集索引表,利用
Uint8Array (8-bit unsigned integer,0 to 255)创建类型数组视图,然后按位运算等等。具体代码实现查看

**方案三:**最简单,最轻量级的解决方法就是使用 TextEncoderLitebase64-js

Blob

二进制大型对象(英语:binary large object ,或英语:basic large object,缩写为Blob、BLOB、BLOb),在数据库管理系统中,将二进制资料存储为一个单一个体的集合。Blob通常是影像、声音或多媒体文件。——维基百科

Blob 对象表示一个不可变、原始数据的类文件对象Blob 由一个可选的字符串 type(通常是 MIME 类型)和 blobParts 组成。

使用语法:

new Blob(blobParts, options);
  • blobPartsBlob/BufferSource/String 类型的值的数组
  • options 可选对象:
    • type —— Blob 类型,通常是 MIME 类型,例如 image/png
    • endings —— 是否转换换行符,使 Blob 对应于当前操作系统的换行符(\r\n\n)。默认为 "transparent"(啥也不做),不过也可以是 "native"(转换)

创建 blob 的几个示例:

// 从字符串创建 Blob
let blob = new Blob(["<html>…</html>"], {type: 'text/html'});
// 从类型化数组(typed array)和字符串创建 Blob
let hello = new Uint8Array([72, 101, 108, 108, 111]); // 二进制格式的 "hello"
let blob = new Blob([hello, ' ', 'world'], {type: 'text/plain'});
// 创建JSON文件的 Blob
let data = { "name": "abc" };
let blob = new Blob([JSON.stringify(data)], { type: 'application/json' });

PS: 如果上传或下载 Blob 对象时,不知道数据的类型时,可以设置 typeapplication/octet-stream

在网络请求中,type 自然地变成了 Content-Type

Blob 对象是不可改变的

Blob 中的数据是无法直接更改的,但可以通过 slice 获得 Blob 的多个部分,从这些部分创建新的 Blob 对象,将它们组成新的 Blob

这种行为类似于 JavaScript 字符串:无法更改字符串中的字符,但可以生成一个新的改动过的字符串。

blob.slice([byteStart], [byteEnd], [contentType]);
  • byteStart —— 起始字节,默认为 0。
  • byteEnd —— 最后一个字节(专有,默认为最后)
  • contentType —— 新 blob 的 type,默认与源 blob 相同

参数值类似于 array.slice,也允许是负数。

File 对象是特殊类型的 Blob

File 接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

所以 File 也有一个构造器:

new File(fileParts, fileName, [options])
  • fileParts —— Blob/BufferSource/String 类型值的数组
  • fileName —— 文件名字符串
  • options —— 可选对象:
    • lastModified —— 最后一次修改的时间戳(整数日期)

File 对象还可以是来自用户在一个 <input> 元素上选择文件后返回的 FileList 对象,也可以来自拖放操作生成的 DataTransfer 对象,还可以是来自在一个HTMLCanvasElement 上执行 mozGetAsFile()方法后返回结果。

File 对象的应用“分片上传文件”示例:

function uploadFile(file) {
  let blob;
  const chunkSize = 1024 * 1024; // 每片1M大小
  const totalSize = file.size;
  const chunkQuantity = Math.ceil(totalSize / chunkSize); // 分片总数
  let offset = 0; // 偏移量
  const reader = new FileReader();
  reader.onload = function (e) {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', `http://xxxx/upload?fileName=${ file.name}`);
    xhr.overrideMimeType('application/octet-stream');
    xhr.onreadystatechange = function () {
      if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
        ++offset;
        if (offset === chunkQuantity) {
          alert('上传完成');
        } else if (offset === chunkQuantity - 1) {
          blob = file.slice(offset * chunkSize, totalSize); // 上传最后一片
          reader.readAsBinaryString(blob);
        } else {
          blob = file.slice(offset * chunkSize, (offset + 1) * chunkSize);
          reader.readAsBinaryString(blob);
        }
      } else {
        alert('上传出错');
      }
    };
    if (xhr.sendAsBinary) {
      xhr.sendAsBinary(e.target.result); // e.target.result是此次读取的分片二进制数据
    } else {
      xhr.send(e.target.result);
    }
  };
  blob = file.slice(0, chunkSize);
  reader.readAsBinaryString(blob);
}

Blob 对象的“转换通道” —— FileReader 对象

FileReader 对象,其唯一目的是从 Blob(或 File)对象中读取数据。

const reader = new FileReader(); // 没有参数

它使用事件来传递数据。之所以称它为 “转换通道”,是因为通过FileReader 对象的 read* 方法读取 Blob 数据,可以直接得到想要的数据类型:

主要读取方法:

  • readAsArrayBuffer(blob) —— 转换为 ArrayBuffer。用于二进制文件,执行低级别的二进制操作(对于诸如切片之类的高级别的操作,File 是继承自 Blob 的,可以直接调用它们,所以不需要使用该方法再读取)。
  • readAsText(blob, [encoding]) —— 将数据读取为给定编码(默认为 utf-8 编码)的文本字符串。
  • readAsDataURL(blob) —— 读取二进制数据,并将其编码为 base64 的 data url。
// 一个最简单的示例
function readFile(input) {
  let file = input.files[0];
  let reader = new FileReader();
  reader.onload = function() {
    // reader.result 是结果(如果成功)
    console.log(reader.result);
  };
  reader.onerror = function() {
    // reader.error 是 error(如果失败)
    console.log(reader.error);
  };
  reader.readAsText(file);
}

但是,在很多情况下,我们不必读取文件内容。可以使用 URL.createObjectURL(file) 创建一个短的 url,并将其赋给 <a><img>。这样,文件便可以下载文件或者将其呈现为图像,作为 canvas 等的一部分。

另外,下面两种方法可以异步读取 Blob 数据,返回 Promise。功能上等同于 FileReader.readAsText() 方法,但略有些差异:

const text = await (new Response(blob)).text();
// 或者
const text = await blob.text();
// 和 FileReader.readAsText() 差异的地方:Blob.text() always uses UTF-8 as encoding, while FileReader.readAsText() can use a different encoding depending on the blob's type and a specified encoding name

URL.createObjectURL 为 Blob 创建 URL

URL.createObjectURL() 静态方法会创建一个 DOMString,其中包含一个表示参数中给出的对象的 URL。这个 URL 的生命周期和创建它的窗口中的 document 绑定。这个新的URL 对象表示指定的 File 对象或 Blob 对象。—— MDN

URL.createObjectURL(blob)方法参数传入一个 Blob,并为其创建一个唯一的 URL,形式为 blob:<origin>/<uuid>。例如:

blob:https://developer.mozilla.org/2e07b873-109d-4781-a321-6fbff8ce89d4

Blob URL 和 Data URL 一样,都可以直接在浏览器中输入,但是 Blob URL 是“临时”的,当创建它的文档被刷新或者关闭后,这个 URL 也就不存在了;但是 Data URL 不同,可以理解为自带内容(数据)😂。

这个方法很实用,在点击下载动态获取的文件的时候经常这样实现代码:

let link = document.createElement('a');
link.download = 'hello.txt';
let blob = new Blob(【请求到的blob数据】, {type: 'xxxx'});
link.href = URL.createObjectURL(blob);
link.click();
URL.revokeObjectURL(link.href);

先说结论:如果我们创建一个 URL,那么即使我们不再需要该 Blob 了,它也会被挂在内存中

  • 浏览器内部为每个通过 URL.createObjectURL 生成的 URL 存储了一个 URL → Blob 映射。因此,此类 URL 很短,但可以访问 Blob
  • 生成的 URL(即其链接)仅在当前文档打开的状态下才有效。它允许引用 <img><a> 中的 Blob,以及基本上任何其他期望 URL 的对象。
  • 有个副作用。虽然这里有 Blob 的映射,但 Blob 本身只保存在内存中,浏览器无法释放它
  • 在文档退出时(unload),该映射会被自动清除,因此 Blob 也相应被释放了。但是,如果应用程序寿命很长,那这个释放就不会很快发生(可能的内存泄漏)。

比较好的习惯是,手动释放引用。通过 URL.revokeObjectURL(url) 方法从内部映射中移除引用,因此允许 Blob 被删除(如果没有其他引用的话),并释放内存。

Image 转换为 blob 的一种方法:canvas

通过 canvas 绘制图像,然后使用 toBlob() 方法导出为 Blob 数据:

let img = /* someone img */
let canvas = document.createElement('canvas');
let context = canvas.getContext('2d');
context.drawImage(img, 0, 0);
// toBlob 是异步操作,结束后会调用 callback
canvas.toBlob((blob) => {
  // ...
}, 'image/png');

这个方法可以用在前端生成屏幕快照(截屏)的实现上,或者前端实现 svg 转 png 等等。

Blob 与 dataURL 互相转换

现在已经深入了解了 Blob 和 base64,也要回归正题了。

Blob 转为 dataURL

这个最简单,通过 Filereader 对象的 readAsDataURL 方法可以直接转换:

function blobToDataURL(blob, callback) {
  const reader = new FileReader();
  reader.onload = () => {
    callback(reader.result);
  };
  reader.readAsDataURL(blob);
}

演示:

const blob = new Blob(['hello world!'], {type: 'text/plain'});
blobToDataURL(blob, (result) => console.log(result));
// data:text/plain;base64,aGVsbG8gd29ybGQh

dataURL 转为 Blob

function dataURLtoBlob(dataurl) {
  const arr = dataurl.split(',');
  const mime = arr[0].match(/:(.*?);/)[1];
  const bstr = window.atob(arr[1]);
  let n = bstr.length;
  const u8arr = new Uint8Array(n);
  while (n--) {
    u8arr[n] = bstr.charCodeAt(n);
  }
  return new Blob([u8arr], { type: mime });
}

演示:

await dataURLtoBlob('data:text/plain;base64,aGVsbG8gd29ybGQh').text()
// 'hello world!'

参考

input[type=file] 调用原生相机和相册功能

最近遇到一个业务场景,需要在 H5 页面中调用手机摄像头。思来想去没有头绪(基础知识不扎实啊)。后来看到 <input> 可以实现。学习了。

HTMLInputElement API: type="file"

<input> 元素有个选择文件的功能,它会打开系统的文件选择器:

<input type="file" />

但是如果只是一个 type="file" 属性还不足以让手机打开相机功能,还要设置 input 接受的文件类型。

accept 属性

如果 accept 属性指出了 input 是图片或者视频类型,那么移动端在上传文件的时候,操作系统会让用户做出选择,通过拍照或者通过选择系统文件来实现上传。使用下面代码就可以实现:

<!-- 在Android和IOS中均会先显示打开相机还是打开系统文件 -->
<input type="file" accept="image/*"  />

capture 属性

如果只是想要打开相机,不需要打开系统文件选择的话,可以设置 capture 属性为一个字符串。(测试 IOS14 和 Android10 自带 browser 均只打开相机)

<input type="file" accept="image/*" capture="camera" />

Note these work better on mobile devices; if your device is a desktop computer, you'll likely get a typical file picker.

从 caniuse 中可以看到,web 端全部阵亡,移动端基本支持:

实际上新的 capture 属性只有两个值:

  • user 表示应该使用前置摄像头和/或麦克风
  • environment 表示应该使用后置摄像头和/或麦克风

缺少此属性,则 user agent 可以自由决定做什么。如果请求的前置模式不可用,则用户代理可能退回到其首选的默认模式。

如果是其它非空字符串值(例如capture="camera"),会当做布尔值 true 处理,则该布尔属性要求使用设备的媒体捕获设备(例如相机或麦克风)而不是请求文件输入。

样式修改

默认样式多少有点不满足我们的业务需求,所以需要对控件进行样式修改。

优化方法有很多,这里只用最简单的方法:通过外层嵌套一个容器,将想要的样式和内容放在这个容器,然后将 input[type=file] 的元素通过 opacity:0 隐藏掉即可。opacity:0 隐藏的好处是元素依然存在DOM树上,且点击事件正常。

<span class="box">打开相机
  <input class="input" id="camera" type="file" accept="image/*"  />
</span>

CSS样式修改

.box {
  position: relative;
  display: inline-block;
  overflow: hidden;
  background: lightblue;
  color: darkred;
  padding: 20px;
  border-radius: 4px;
  margin: 4px;
  font-size: 16px;
  font-weight: 600;
}
.box .input {
  position: absolute;
  right: 0;
  top: 0;
  opacity: 0;
  filter: alpha(opacity=0);/兼容ie/
  cursor: pointer;
  height: 100%;
}

最终效果:

参考:

使用 display:table-cell; 实现冒号居中对齐

类似于表单可以将表单项名称根据后面的冒号对其。效果如下:
image

重点是 CSS 样式,和 React 无关,只是方便渲染

结构

const mockData = [
  {name: '1号窗口', value: '001'},
  {name: '11号窗口', value: '003'},
  {name: '91号窗口', value: '——'},
  {name: '471号窗口', value: '178'},
  {name: '33号窗口', value: '999'},
  {name: '2号窗口', value: '061'},
];
// ...
function App() {
  // ...
  return (
    <div className="box">
       {
         mockData.map(item => (
             <div key={item.name} className="item">
               <div className="name">{item.name}:&nbsp;</div>
              <div className="value">{`${item.value}号客户`}</div>
            </div>
        ))
      }
    </div>
  );
}

样式

.item {
  display: table-row;
}
.name {
  display: table-cell;
  text-align: right;
}
.value {
  display: table-cell;
}

很基础的实现,但是效果很好,代码也很简单。
需要注意的是要 显式 的给类名为 item 的元素设置 display: table-row;。否 text-align: right; 不生效。

为什么不要使用 return await

在 ESLint 中有一条规则 no-return-await。下面的代码会触发该条规则校验不通过:

async function foo() {
  return await bar();
}

这条规则的解释是,async function 的返回值总是封装在 Promise.resolvereturn await 实际上并没有做任何事情,只是在 Promise resolve 或 reject 之前增加了额外的时间。

Promise.resolve()

了解 Promise 对象的知道 Promise.resolve() 作用是将给定的参数转换为 Promise 对象

Promise.resolve('foo')
// 等价于
new Promise(resolve => resolve('foo'))

那么其实上面的问题所在就是 Promise.resolve() 的参数情况:

下面截取自 《ECMAScript 6 入门》的 Promise.resolve()

  • Promise 实例

    如果参数是一个 Promise 实例,Promise.resolve将不做任何修改、原封不动地返回这个实例。

  • thenable 对象

    thenable对象指的是具有then方法的对象

    let thenable = {
      then: function(resolve, reject) {
        resolve(42);
      }
    };

    Promise.resolve()方法会将这个对象转为 Promise 对象,然后就立即执行thenable对象的then()方法

    let thenable = {
      then: function(resolve, reject) {
        resolve(42);
      }
    };
    
    let p1 = Promise.resolve(thenable);
    p1.then(function (value) {
      console.log(value);  // 42
    });
  • then() 方法的对象,或基础类型值

    如果参数是一个原始值,或者是一个不具有then()方法的对象,则Promise.resolve()方法返回一个新的 Promise 对象,状态为resolved

    const p = Promise.resolve('Hello');
    
    p.then(function (s) {
      console.log(s)
    });
    // Hello

    由于字符串Hello不属于异步操作(判断方法是字符串对象不具有 then 方法),返回 Promise 实例的状态从一生成就是resolved,所以回调函数会立即执行。

  • 无参数

    Promise.resolve()方法允许调用时不带参数,直接返回一个resolved状态的 Promise 对象。

    Promise.resolve()
    // Promise {<fulfilled>: undefined}

问题分析

函数 foo() 是一个 async function,会返回一个 Promise 对象,这个 Promise 对象其实是将函数内 return 语句后面的值使用 Promise.resolve 封装后返回。所以,执行 foo() 之后,我们明确知道得到的结果是一个 Promise 对象,并且在 foo() 的外部我们还会以某种方式比如await 来继续使用这个结果。

另外,我们知道 await 命令后面如果是一个 Promise 对象,则返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。所以函数内 return 后的 await 的作用就是获取 bar() 的执行结果。

如此一来,async function 中使用 return await 相当于先获取结果然后又将结果变成 Promise 实例。

无论 return await 后面跟的是不是 Promise 对象,那么我们在函数外面都会得到一个 Promise 对象,所以函数内 return 后面的 await 就显得多余了。

虽然上面的写法不推荐使用,但是并不会产生运行错误,只是会造成性能上的损失。唯一影响,可能是其他开发者看到后,会笑话你基础不扎实吧。

推荐写法

首先,如果 async function 没有 return 语句的话,默认返回 undefined,在 Devtool 中测试如下:

async function foo() {}
foo();
// Promise {<fulfilled>: undefined}
//  [[PromiseState]]: "fulfilled"
//  [[PromiseResult]]: undefined

既然 return await 中的 await 是多余的,那么最直接的推荐写法就是去掉 await

async function foo() {
    return bar();
}

无论 bar() 执行结果是一个 Promise 还是一个普通值,我们将它交给 foo 函数外面的程序去处理。

如果非要在 foo 函数中求得 bar() 的执行结果,那么也可以像下面这样写:

async function foo() {
    const x = await bar();
    return x;
}

但是我觉得这完全只是为了规避 ESLint 的报错而写的一种障眼法,实际它return await bar() 一样没有任何好处。

return await promiseValuereturn promiseValue 的比较

返回值隐式的传递给 Promise.resolve,并不意味着 return await promiseValue;return promiseValue; 在功能上相同。return foo;return await foo;,有一些细微的差异:

  • return foo; 不管 foo 是 promise 还是 rejects 都将会直接返回 foo
  • 相反地,如果 foo是一个 Promisereturn await foo; 将等待 foo 执行(resolve)或拒绝(reject),如果是拒绝,将会在返回前抛出异常

看下面的代码:

function bar() {
  throw new Error('报错了!');
}

async function foo() {
    try {
        return await bar();
    } catch (error) {
      console.log('知道了');
    }
}
foo();
// 知道了
// Promise {<fulfilled>: undefined}

async function foo2() {
    try {
        return bar();
    } catch (error) {
      console.log('我不知道');
    }
}
foo2();
// undefined

如果想要在调试的堆栈中得到 bar() 抛出的错误信息,那么此时应该使用 return await

参考

EditorConfig

为什么要使用 EditorConfig

开发过程中要求代码风格统一,这个统一又可以分成很多方面的统一,经常提到的有代码语法的风格和代码书写的风格。

在一个项目开始之前,一般会有架构师制定相关的规则(或者指定已有的规则文件),然后发出一份代码规范的说明,用来约束开发人员的代码风格,减少因为代码风格不同而带来的 differ。这样的规范有很多,例如:

但是规范是给人看的,如果团队的 review 不是那么严格,那么规范的约束力就会大打折扣,所以就有了各种代码检查器对开发人员进行硬核约束。

现在检查器有很多,JavaScript 最早的语法检查器可能是 Douglas Crockford 的 JSLint ,之后出现了基于 JSLint 的代码实现的开源项目 JSHint,再后来就是我现在项目使用的由 Nicholas C. Zakas 开发的 Eslint。当然除了 JavaScript 的代码检查工具,一个项目还会配置如 stylelint 这样的样式检查工具和 Bootlint、AriaLinter、htmllint、HTMLHint 及 htmlcs 等 html 代码检查工具。

代码检查工具的作用是强制执行约定好的规则和帮助开发人员避免错误语法,但是偏偏就会有些开发人员为了方便而绕开规则(我身边有,不针对任何人),再加上如果团队 review 的观念不足,那么就拿我使用的 eslint 和 stylelint 来说,配置文件中总会出现一些关闭规则的配置项,甚至在代码文件中出现 disable rule 的注释(我也干过)。这样 lint 的工具

除了代码语法风格,还有一个重要的统一就是代码书写风格的统一。说两个老掉牙的例子:圣战之花括号换行还是不换行写不写分号

而 editorConfig 可以帮助开发人员在不同的编辑器和 IDE 中定义和维护一致的编码风格。

EditorConfig

首先,editorConfig 不是软件,准确来说 EditorConfig 其实就是针对编辑器的一些配置项,再笼统点说就是让编辑器遵循某种风格的工具。一般 EditorConfig 项目包括一个用于定义编码格式的配置文件 .editorconfig ,还有一个能读懂这个文件的插件(可以去官网查看需要插件的编辑器),当然有些编辑器是默认支持 EditorConfig 的,例如 WebStorm。

EditorConfig 如何生效

  1. 打开一个文件时,EditorConfig 的插件会在当前文件所在目录和其父目录中搜索 .editorconfig 文件,直到找到一个有 root=true 的配置文件或者到达根目录才会停止搜索
  2. 配置文件遵循就近原则,先匹配到的配置文件优先应用
  3. 配置文件内容的读取是从上到下顺序读取,后面读取的配置项会覆盖之前读取到的
  4. 如果没有匹配到.editorconfig文件,或者没有配置文件,则使用编辑器默认的设置

在 Windows 系统中,创建配置文件时应该命名为 .editorconfig.,系统会自动重命名为 .editorconfig

语法

  1. .editorconfig 文件使用INI 格式(以简单的文字与简单的结构组成)并且兼容Python ConfigParser
    • 注释使用 ;#,直达行尾都会被注释,注释只能注释一行
    • [] 括起来的内容称为节,节的写法允许使用路径匹配,就像 .gitignore 文件中一样。但是只能使用 / 符号作为路径分隔符
  2. 配置文件采用 UTF-8 编码,并且每行的分隔符是 CRLF 或者 LF

通配符

*                匹配除 / 之外的任意字符串
**               匹配任意字符串
?                匹配任意单个字符
[name]           匹配 name 中的任意一个单一字符
[!name]          匹配不存在 name 中的任意一个单一字符
{s1,s2,s3}       匹配给定的字符串中的任意一个(用逗号分隔)
{num1..num2}    匹配 num1 到 num2 之间的任意一个整数, 这里的 num1 和num2 可以为正整数也可以为负整数

属性

indent_style    设置缩进风格(tab是硬缩进,space为软缩进)
indent_size     用一个整数定义的列数来设置缩进的宽度,如果indent_style为tab,则此属性默认为tab_width
tab_width       用一个整数来设置tab缩进的列数。默认是indent_size
end_of_line     设置换行符,值为lf、cr和crlf
charset         设置编码,值为latin1、utf-8、utf-8-bom、utf-16be和utf-16le,不建议使用utf-8-bom
trim_trailing_whitespace  设为true表示会去除换行行首的任意空白字符。
insert_final_newline      设为true表示使文件以一个空白行结尾
root           表示是最顶层的配置文件,发现设为true时,才会停止查找.editorconfig文件
  • 所有的属性和值都是忽略大小写的。解析时插件会将它们转换为小写
  • 没有显式指定的属性,会使用编辑器默认的值
  • 如果想删除某属性的效果,可以给其赋值 unset(会使用编辑器默认属性)

经常使用的一份配置

# editorconfig.org

root = true

[react/*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf
# editorconfig-tools is unable to ignore longs strings or urls
max_line_length = unset

参考

macOS 版本

原文链接:macOS Version Big Sur Update

时间线

  • 在叫 “Mac OS” 之前,Macintosh 操作系统称为 “System 7

  • “Mac OS 8” 原本代号为 “Copland”,该版本具有所有新功能的新操作系统,但项目被取消

  • “System 7.7” 更名为 “Mac OS 8”,目的是为了让 Apple 脱离定于版本8到期的第三方协议

  • 上世纪90年代末,苹果公司宣布对Macintosh进行基于NextSTEP的重大修改时,选择将新系统标记为 “Mac OS X”,其中**“X”为罗马数字10**

  • Mac OS X 的第一个版本具有数字版本号:“10.0.0”,并对其进行了四次“更新”直到“10.0.4”,然后对其进行了第一个主要的“10.1 升级”

  • Apple 认为 “Mac OS X”已经是一个品牌代名词,并且坚持使用“10”为系统版本号

    • 前置 “X” 发音是 “ecks”,借助“ Xserve”,“ X”成为了代名词,代表了苹果的“专业”或“ EXpert”硬件。包括之后的专业软件工具(例如“ Xcode”,“ Xsan”和“ Xgrid”)也使用的 “X” 前缀
    • 后置 “X” 表示 “ten”,即“X-as-ten”。猜测是与先前版本(“ Mac OS 8”和“ Mac OS 9”)的连续性的一种市场选择。
    • 使用“X”前缀以此与用于消费者友好型设备和软件的“ i”前缀区分开(例如 iMac,iBook,iPod,iTunes,iMovie,iPhoto等)。
  • 2002年WWDC上宣布“ Mac OS X 10.2 Jaguar”

  • 2012年,苹果从 “ OS X Mountain Lion”(10.8)的操作系统中删除了“ Mac”

  • 2016年,苹果放弃了 “ X-as-ten” ,并且重返 “Mac”,新的版本代号 “ macOS Sierra”(10.12)

  • 之后的系统名称中不再包含 “ X”,但是在 macOS 10.15 Catalina 之前的版本号中仍保留“ 10

  • 2020年,苹果终于放弃了“10”,并将macOS的主要版本提高到 11,代号 “Big Sur

macOS Big Sur 版本来到 11

通过命令行也可以看到相同结果:

➜ sw_vers -productVersion 11.1

但是有一个陷阱!

10.16 和 11.0

在 Big Sur 中,如果查询 macOS 版本,返回的不总是 11,在某些情况下会显示 10.16

Apple提供了两个基本规则:

  • 在编译语言中,macOS 返回的版本取决于软件所针对的 SDK

    • 针对 10.15 SDK 或更早版本构建时,Big Sur 返回 10.16 以与先前的编号和所有现有应用兼容
    • 根据 11.0 SDK 构建时,为了向前兼容,它返回11.0
  • 在Shell环境中运行的脚本语言中,有一个环境变量控制给定的版本号

    • 设置 SYSTEM_VERSION_COMPAT = 1,Big Sur返回10.16
    • 将该变量保留为未设置状态,或者 SYSTEM_VERSION_COMPAT = 0,则返回11.0
export SYSTEM_VERSION_COMPAT=1
➜ sw_vers -productVersion
10.16

有些软件可能只检查版本小数点后面的数字,这就造成软件无法正常启动,就像下面的情况:

在 Shell 环境中检查版本

macOS 10.15 及以前要查看macOS是Mojave还是更高版本,可以安全地忽略产品版本的第一个数字,而仅比较第二个数字。所以可能会执行以下操作:

# .test
minorVersion=$(sw_vers -productVersion | awk -F. '{ print $2; }')
if [[ $minorVersion -ge 14 ]]; then
  echo "Mojave or higher"
fi

➜ sh .test
# 10.15 版本以前
Mojave or higher
# Big Sur 版本则无任何输出

在macOS 11.0发行版中,minorVersion 返回的是 0,条件不成立,所以没有输出。

解决方案是首先提取 majorVersion 并进行比较:

# .test
majorVersion=$(sw_vers -productVersion | awk -F. '{ print $1; }')
minorVersion=$(sw_vers -productVersion | awk -F. '{ print $2; }')
if [[ $majorVersion -ge 11 || $minorVersion -ge 14 ]]; then
  echo "Mojave or higher"
fi

上面的检查脚本也有问题,对于比 Big Sur 更高版本的时候,上面的条件显然不足以判断,就要加更多的条件语句进行区分。

那么还有什么更好的方法进行版本比较?

内部版本

用户可见的版本可能不够细致,无法满足开发人员的需求。因此 macOS 提供了另一个版本,称为“内部版本”

# 此时版本为 macOS Big Sur
➜ sw_vers -buildVersion
20C69

在下面的界面,默认内部版本号是隐藏的,点击版本号才会显示出内部版本


构建版本包括三个部分和第四个可选部分:

第一个数字是达尔文版本

  • 达尔文版本是在主要 macOS 版本上递增的数字

  • Mac OS X 10.2 Jaguar 是 Mac OS X 的第一个发行版,该版本的达尔文版本始终为 6

    为什么第一个办版本从 6 开始?

    因为 10.0 是 Mac OS X 所基于的 NextSTEP 的第四版,那么 10.0 的达尔文版本号实际应该是 4,所以 10.2 版本开始出现的达尔文版就从 6 开始了。

  • macOS 10.15 Catalina,具有 19 和 Big Sur 20 这两个Darwin版本。

可以从OSTYPE环境变量获取Shell中的Darwin版本:

echo $OSTYPE darwin20.0

但是可能未设置环境变量,所以获取Darwin版本的更安全方法是使用 uname 命令:

➜ uname -r 20.2.0

紧跟的大写字母表示跟踪更新

字母 A 代表 .0 或主要版本的第一个发行版。B表示第二个版本或第一个更新或 .1 版本,依此类推。

之后的数字(最多四位数)是特定的内部版本号

  • 内部版本号的重要性通常是在beta阶段看到的

  • Beta版本中的Darwin版本和更新字母是固定的

  • 内部版本号随每个Beta版本的增加而增加

  • 通常来说,两位数和三位数的内部版本号与四位数的内部版本号有所不同。

    • 数字位数较低的版本是常规版本,可在支持此版本macOS的所有Mac上运行
    • 四位数的内部版本号指定了安全更新或特定于硬件的内部版本

第四部分部分,可选的小写字母

某些版本的macOS的内部版本号后面带有小写字母,尚不清楚这个字母的确切含义,猜测可能表明安装程序应用程序已被重建一次或多次。

使用内部版本

对于大多数比较,我们只需要Darwin版本或更新即可:

# check if Mojave or higher 
if [[ $(sw_vers -buildVersion) > "18" ]]; then
...

由于Mojave的所有版本18A...均以字母开头,因此均大于18。如果要检查macOS的最高版本,将采取同样的措施:

# check if Mojave or earlier
if [[ $(sw_vers -buildVersion) < "19" ]]; then
...

还可以过滤特定的最小更新:

# check if Mojave 10.14.6 or later 
if [[ $(sw_vers -buildVersion) > "18E" ]]; then
...

总结

macOS 11 Big Sur中版本号的更改可能会影响甚至破坏部署和管理脚本中的某些系统版本检查。

IIFE 作用域问题 01

在ES5中主要使用匿名函数【IIFE】的方式来达到块级作用域的效果

var name = 'Tom';
(() => {
  if (typeof name === 'undefined') {
    var name = 'jeck';
    console.log('1:', name);
  } else {
    console.log('2:', name);
  }
})()

// 结果输出 “1: jeck”
  • 从外部看,iife形成块级作用域,将函数内部与全局的环境隔离开(所以立即执行函数在查看内部有没有name变量的时候不会受到全局变量的影响)
  • 在iife形成的块级作用域内
    • ES5不存在块级作用域,也就是if语句后面的{}并没有形成块级作用域
    • var声明的变量存在变量提升,虽然 var name 在if条件语句内,但是也会发生变量提升
    • 由于iife形成了块级作用域,所以var name只会提升到这个作用域的最上面
      所以,上面代码实际执行顺序如下:
var name = 'Tom';
(() => {
  var name;
  if (typeof name === 'undefined') {
    name = 'jeck';
    console.log('1:', name);
  } else {
    console.log('2:', name);
  }
})()

变换

var name = 'Tom';
(() => {
  if (typeof name === 'undefined') {
    name = 'jeck';
    console.log(name);
  } else {
    console.log(name);
  }
})()

// 结果是 Tom
  • 首先在进入函数作用域当中,获取name属性
  • 在当前作用域没有找到name(因为没有使用var声明name,也就不存在变量提升,所以执行 typeof name 时在当前作用域内找不到name)
  • 通过作用域链找到最外层,得到name属性
  • 由于在外层作用域name已经赋值 name = 'Tom',所以条件语句执行else的内容,得到Tom

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.