louis blog 坚持原著, 深度思考, 力求简单通俗叙事 2019-12-02T04:11:34.106Z http://louiszhai.github.io/ 路易斯 Hexo webpack4编译代码如何完美适配IE内核 http://louiszhai.github.io/2019/12/02/webpack4.ie/ 2019-12-02T03:29:45.000Z 2019-12-02T04:11:34.106Z 为了更好的帮助外部合作渠道开发游戏的营销活动,我们开发了一款 JS SDK。该 SDK 采用 webpack4 编译打包,用于支持移动端 H5 活动快速开发,随着业务拓展,端游活动的开发也需要支持,但由于一些用户 PC 系统版本较低,部分端游 webview 又采用默认 IE 内核、以及用户使用 IE 浏览器参与活动等原因、导致这些用户无法参与到活动中来,从而影响活动效果,那么,该如何完美的适配 IE 内核呢?

对于 JS SDK 而言,适配 IE 内核至少面临 5 大难题:

  1. IE9及以下,XMLHttpRequest 不支持跨域请求,XDomainRequest 不支持 cookie 传递;
  2. IE8 不遵循 W3C 规范,不支持 ECMAScript 5.1, CSS3 支持性低;
  3. IE7 缺少更多 JS API 支持,postMessage 也不支持;
  4. 从 webpack2 起,IE8 及以下版本便不被支持;
  5. SDK 提供的选择器引擎不支持 IE8、IE7;

实际上,以上 5 大难题,并非全部,只是诸多难题中较为突出的部分,下面会重点来讲。

由于IE7、IE8、IE9 内核差异大,适配难度较大,所以我先从 IE9 开始适配,然后是 IE8、IE7。

代码在IE9下运行

webpack4 打包的代码,在 IE9 下直接运行,果然,立即报错:提示 symbol 未定义,当然错误不止一个,这里不一一列举。

最终配置 .babelrc 如下所示,代码基本能在IE9上运行。

.babelrc配置

如上图,transform-es2015-typeof-symbol 插件解决了symbol 未定义的问题。

这里有个问题需要说明下,为什么不使用 babel-polyfill,而使用 transform-runtime,有两点原因:

  1. babel-polyfill size 太大,达到了 60k,sdk 不能接受这么大的垫片包;
  2. babel-polyfill 会污染全局变量,而且一旦 sdk 中引入了 babel-polyfill,使用 sdk 的项目,在不知情的情况下再次引入了 babel-polyfill, 有可能导致冲突;

基于这两点,最终我使用了 transform-runtime 插件。

另外,稍微注意下,IE9 下 console 对象在不开 dev tool 时,并不存在,也要兼容。

IE9如何支持跨域请求

代码能在IE9上运行,起码开了个好头。接下来要面对的是第一个大难点:IE9及以下版本,XMLHttpRequest 不支持跨域请求,XDomainRequest 虽然支持跨域,却又不支持 cookie 传递

解决这个问题之前,不得不提下 xhr 的发展历史。

xhr 一共有两级标准,早在 IE5,微软就支持了 xhr1 标准,直到 IE10,xhr2 标准才得到支持。

xhr1 有如下缺点:

  • 仅支持文本数据传输,无法传输二进制数据
  • 传输数据时,没有进度信息提示,只能提示是否完成
  • 受浏览器 同源策略 限制,只能请求同域资源
  • 没有超时机制,超时了没办法处理

很明显,xhr1 无法支持跨域请求。

xhr2 针对 xhr1 的缺点做了如下改进:

  • 支持二进制数据,可以上传文件,可以使用 FormData 对象管理表单
  • 提供进度提示,可通过 xhr.upload.onprogress 事件回调方法获取传输进度
  • 依然受 同源策略 限制,这个安全机制不会变。xhr2 新提供 Access-Control-Allow-Origin 等 headers,设置为 * 时表示允许任何域名请求,从而支持跨域 CORS 访问
  • 可以设置 timeout 及 ontimeout,方便设置超时时长和超时后续处理

虽然,IE5 - IE9 都不支持 xhr2,但微软并没有闲着,而是在 IE8、IE9 中推出了支持 CORS 请求的 XDomainRequest,很遗憾,这是一个非成熟的解决方案,它有主要有以下限制:

  • 仅可用于发送 GET 和 POST 请求
  • 不支持跨域传输 cookie
  • 只能设置请求头的 Content-Type 字段,且不能访问响应头信息

更多限制,不妨参考 XDomainRequest – Restrictions, Limitations and Workarounds

不能跨域传输 cookie,登录态怎么校验?到目前为止,通过纯 api 的方式,还没有找到跨域解决方案。最终我使用了 flash 的插件,在 IE9 及以下版本浏览器中加载 flash 插件,借助 flash 去发送跨域请求。

那么,是不是说 IE10、IE11 等浏览器 xhr 就没有问题了?并不尽然。xhr 规范细节一直在变化,参照 https://xhr.spec.whatwg.org/,最近一次更新是今年的 9 月 24 日,IE 不可能根据规范实时调整实现。即便是 IE11,它参照的也是 w3c 2011,甚至更早的规范,以下是规范的一段描述:

On setting the withCredentials attribute these steps must be run:

  1. If the state is not OPENED raise an INVALID_STATE_ERR exception and terminate these steps.
  2. If the send() flag is true raise an INVALID_STATE_ERR exception and terminate these steps.
  3. If the anonymous flag is true raise an INVALID_ACCESS_ERR exception and terminate these steps.
  4. Set the withCredentials attribute’s value to the given value.

这意味着,IE11 中,readyState 值为 OPENED 之前(即 open 方法调用前),为 xhr 对象设置 withCredentials 属性就会抛出 INVALID_STATE_ERR 错误。实际开发中,timeout 属性也必须在 open 方法之后调用。

打包文件支持IE8

接下来,第二个需要解决的难题是: 从 webpack2 起,IE8 及以下版本便不被支持。为此我耗费了非常多的时间。

第一次在 IE8 上运行 webpack4 打包代码时,出现了各种各样的错误,远比 IE9 上遇到的多得多,挑重点讲,就是 IE8 下 default、catch 是关键字,而 webpack 打包代码,几乎无处没有 default,而用到 Promise 的地方,大多都会使用 catch 回调。

我们都知道,babel 将 es6 转成了 es5, 而 es3ify-loader 则可以继续将 es5 转成 es3。

function(t) { return t.default; }    // 编译前
function(t) { return t["default"]; } // 编译后

{ catch:function(t){} }   // 编译前
{ "catch":function(t){} } // 编译后

借助 es3ify-loader ,default、catch 等关键字编译后会被加上引号,避免 IE8 报缺少标识符错误。它的配置如下所示:

crossdomain.xml配置

重新编译后运行,还是报了缺少标识符的问题。难道没效果?其实不是的。

原来 UglifyJsPlugin 在压缩时,默认将引号都去掉了。es3ify-loader 好不容易将引号加上,UglifyJsPlugin 顺手就移除了,深藏功与名。为此,需要将 compress.properties 设置为 false,避免使用点符号重写属性访问。如下:

t["default"] -> t.default    // default true
t["default"] -> t["default"] // set false

另外,output.quote_keys 也需要设置为 true,从而保留对象 key 的引号,如下。

{ "catch": xx } -> { catch: xx }   // default false
{ "catch": xx } -> { "catch": xx } // default false

重新编译后后运行,又报 “无法设置未定义或 null 引用的属性” 的错误。原来 UglifyJsPlugin 压缩时,ie8 设置默认为 false,这意味着它又顺手去掉了支持 IE8 的代码。那么,最终配置如下:

UglifyJsPlugin配置

实际调试时,不妨增加 mangle: false 配置,关闭变量名混淆,debug 会更友好,更多配置介绍,请戳 UglifyJs 中文文档

到此,我们解决了 IE8 不被 webpack 支持的问题。

代码在IE8下运行

很明显,不太可能这么容易在 IE8 下成功运行项目。这里还有两个大坑。

  1. webpack4 打包文件的开始部分就用到了 Function.prototype.bind 方法。
  2. IE8 下 Object.defineProperty 实现与规范不同,它只能设置 dom 元素。

提供独立编译的bind垫片

第一点,无论我在 index.js 入口文件中如何添加 bind 方法的垫片,都毫无效果,依然提示 bind 方法不存在。后来终于在编译文件的头部发现了 t.push.bind 的引用,如下。

bind方法被调用

原来 bind 方法的调用如此之早,此时业务逻辑代码远没有开始执行,也就是说,垫片方法也还没有执行。为了解决这个问题,只能避开 webpack 打包,我不得不额外新增了 polyfill.js,用于承载 bind 方法垫片,然后使用 gulp 压缩 polyfill.js,再合并进 webpack 打包文件。gulp 脚本如下所示:

gulp配置

Object.defineProperty兼容方案

第二点,Object.defineProperty 缺陷是硬伤,即便引入了 es5-shim 垫片,由于 IE8 不支持访问器属性,依然会抛异常。那么,到底什么地方用到了 Object.defineProperty 呢?

问题出在 babel 身上,通常情况下,babel 对 export 解析不会设置访问器属性,没有问题,如下。

// 编译前
const a = 1;
export default a;
// 编译后
Object.defineProperty(exports, "__esModule", {
  value: true
});
var a = 1;
exports.default = a;

但 babel 会把 export xx from yy 编译成 Object.defineProperty 形式,并且设置访问器属性,如下。

// 编译前
export { loadScript } from './util/loadScript'
// 编译后
Object.defineProperty(exports, "__esModule", {
    value: true
});
var _loadScript = require('./util/loadScript');
Object.defineProperty(exports, 'loadScript', {
    enumerable: true,
    get: function get() {
        return _loadScript.loadScript;
    }
});

面对上面编译后的代码,es5-shim/es5-sham 会在如下位置抛出错误。

es5-sham报错位置

当 supportsAccessors 为 false 且 get 或者 set 随便一个存在,即抛出 ERR_ACCESSORS_NOT_SUPPORTED 错误,supportsAccessors 用于判断是否支持访问器属性,它的实现如下:

es5-sham报错位置

稍微转换下,即 supportsAccessors = Object.prototype.hasOwnProperty("__defineGetter__"),而 __defineGetter__ IE11 才支持,虽然 IE9、IE10 同样不支持它,但它们已经支持 Object.defineProperty 方法,不需要垫片。另外,es-shim 官方文档也提到了使用 Object.defineProperty 可能会失败。

Object.defineProperty可能失败

如上图,描述符中存在 get 或 set,同时还不支持 defineGetter,将默默失败。

综上,问题出在 export xx from yy 这种写法上,换种写法,先 import 然后再 export 不就行了,当然可以,关键是目前有很多地方都用到了这种写法,贸然改写,然后还要立一个 flag,以后不要这样编写代码巴拉巴拉,确实不太优雅。

有这样的一个 babel 插件—transform-es2015-modules-simple-commonjs 可以使用(这个插件也出现在篇首 .babelrc 配置截图中第 20 行),它可以实现简单的 commonjs 语法转换,避开了访问器属性,一举解决了 Object.defineProperty 报错的问题,如下为它的编译结果。

Object.defineProperty(exports, "__esModule", {
    value: true
});
var _loadScript = require('./util/loadScript');
exports.default = _loadScript.loadScript;

垫片大小权衡

babel 的 transform-runtime 插件虽好,却只会引入 es6 垫片,支持 IE8 还需要额外引入 es5 的垫片 es5-shim,而 es5-shim 又包含了 es5-shim.min.js 和 es5-sham.min.js,对于 IE8 而言,两者都依赖,前者 26k, 后者 6k,共计 32k。同样的问题再次面临抉择,无论如何,不能因为它方便,而去盲目增加 sdk 的 size,事实上,sdk 远远不需要集合型的垫片方案。

所以,我放弃了 es5-shim 垫片,最终选用了司徒正美提供的 object-defineproperty-ie8 解决了 Object.defineProperty 可能报错的问题。

Object.defineProperty-ie8

如上图,具体思路就是,先判断是否支持访问器属性,不支持就忽略非 DOM 元素的访问器属性设置,只保证赋值成功,避免了报错导致执行中断。

到这里,webpack 不支持 IE8 的问题,基本解决了,接下来就需要与 IE8、IE7 斗智斗勇了。

IE8 & IE7 各种缺陷

对于 IE8 & IE7 而言,由于不遵循 w3c 规范,不支持 ECMAScript 5.1,导致有很多 API 实现与标准不一致或没有实现,以下是 SDK 涉及到的一些缺陷介绍。(如未特别说明,即 IE8、IE7 都适用)

缺少全局方法

  1. 不支持 JSON 对象。
  2. 不支持 window.getComputedStyle 方法。
  3. IE7 不支持 window.localStorage 方法。
  4. IE7 不支持 window.querySelectorAll 方法。

另外,IE8 下,document.head 也没有,需要做如下兼容。

document.head

缺少构造器方法

  1. 缺少 Object.keys、Object.create 等方法。
  2. 缺少 Array.isArray 方法。

原型方法缺陷

  1. 缺少 Function.prototype.bind 方法,webpack4 编译代码开头部分就用到了 bind 方法,这个前面解决了。
  2. apply 方法第二个参数不能接受类数组对象,否则会报 “Function.prototype.apply: 缺少 Array 或 arguments 对象” 错误。
  3. Array原型方法如 forEach,map,filter、indexOf … 这些方法都没有。
  4. 数组 toString 方法存在 bug,其它浏览器在调用数组的 toString 方法时,如果遇到继承于数组的就直接使用数组的 toString,如果遇到非数组对象时会切换到 Object 的 toString 上;而在 IE8 中只要非数组对象调用数组的 toString 方法,一律报错。

事件缺陷

  1. 只有全局事件,事件没有target属性,兼容如 e = e || window.Event;target = e.target || e.srcElement

  2. 事件回调内部 this 执行 window。

  3. 不支持 addEventListener API,可由 attachEvent 替代。

    attachEvent

  4. IE7,不支持 stopPropagation、preventDefault 方法。

    cancelBubble

数据类型判断缺陷

  1. typeof 判断原生方法时,会将其当作是 object,而不是 function,如下兼容。

typeof判断缺陷

  1. 不同 IE 版本,基本数据类型判断也存在差异,如下图。

基本数据类型判断缺陷

JS 异步加载缺陷

script load 不执行,需监听 onreadystatechange 事件,判断 readyState 是否等于 loaded 或 complete,兼容如下图。

script load兼容

DOM API 缺陷

  1. innerHTML 属性对于以下标签是只读的:col、colgroup、frameset、head、html、style、table、tbody、tfoot、thead、title、tr。除此之外,下拉框赋值,也非常困难,虽然其 innerHTML 可以写入,但新增的选项不会生效(清空又可以),推荐使用 options.add 方法 添加新选项,如果还不行,可以使用 outerHTML 赋值。

  2. IE7 不支持 window.Element 对象

    Element兜底

  3. IE7 setAttribute 支持性也有问题

    setAttribute兼容

CSS API缺陷

  1. IE9- 不支持 element.classList 属性。
  2. IE8-,CSS 部分属性动态修改后不生效,如:margin-left、margin-top 等。
  3. IE 支持 currentStyle 方法,但只能获取表面的样式,无法获取计算后的,getComputedStyle 垫片也无法准确获取,可采用 element.getBoundingClientRect() 方法获取坐标,然后计算出宽高,如下所示。

获取宽度

透明度设置缺陷

  1. 不支持 opacity 样式,可由 filter 滤镜替代。

透明度兼容

  1. 不支持 rgba 色值,可由 filter 滤镜替代。

rgba兼容

上图中,颜色 “#19000000” 由两部分组成。

第一部是 # 号后面的 19 是 rgba 透明度 0.1 的 IEfilter 值。从 0.1 到 0.9 每个数字对应一个 IEfilter 值。

第二部分是 19 后面的六位。这个是六进制的颜色值,跟 rgb 函数中的取值一致。比如 rgb(0,0,0) 对应 #000000,也就是黑色。

rgba 透明度与 IEfilter 的对应关系如下所示。

rgba-IEfilter

除了透明度外,CSS 动画也需要降级为 gif 实现,如果是 loading 动画,可以使用 loading.io 无缝转换。

到这,IE8、IE7 的兼容性问题基本解决,接下来只需要让选择器引擎支持到 IE7,改造便可完成。

选择器引擎开发

大家都知道,jquery 提供了非常棒的选择器引擎 sizzle,但它的 min 版本达到了 20 k,有点太大了。

对于 JS SDK 而言, 它不但内部使用了大量选择器,同时还需要将选择器的功能开放给第三方开发者,使其能在不使用 jquery、zepto 等类库的情况下,快速完成基本的 DOM 操作,所以搭载一款轻便的选择器引擎很有必要。

一个简单的选择器引擎,至少具备以下 4 点功能。

  1. 选择元素,如 $('#div')
  2. 包装 DOM 对象为 $ 实例,如 $(element);
  3. 包装字符串html模板为 $ 实例,如 $('<div></div>')
  4. 提供选择器 $ 实例对象的原型方法,如 $('#div').html('hello world')

在如今,querySelectorAll 深入人心的背景下,实现一个选择器引擎,并不难,这里我参考的是 balajs,它主要代码如下所示。

balajs

balajs 有如下 2 个问题:

  1. 仅仅支持 IE9+ 浏览器。
  2. 原型是数组,不是对象,直接使用数组做原型,在 IE8 以下,tostring 方法调用时,会调用数组原型的tostring,从而报错(而标准浏览器,会调用对象的 tostring),由于继承数组,其实例 length 属性设置也会失效。

为了解决这 2 个问题,我们需要更换选择器引擎的继承方式,如下代码(参考了 jquery 思想)。

function $(s, context) { // 选择器入口
  if (s instanceof init) { // 如果是已经获取到的元素,直接返回
    return s;
  }
  // 结合 balajs 可以先产生 elements 数组
  const elements = ... // 先略去,后面讲
  // 再将 elements 数组交由 init 方法产生类数组返回值
  return new $.fn.init(s ? elements : []);
}
$.fn = {}; // 选择器对象原型由数组改为空对象
const init = $.fn.init = function (selector) {
  selector.forEach((ele, i) => { // init 方法用于完成kv赋值操作,并产生类数组对象实例
    this[i] = ele;
  });
  this.length = selector.length;
  return this;
}
init.prototype = $.fn; // init 原型指定为 $.fn,使得 init 对象实例能够使用 $.fn 原型中的方法

Object.assign($.fn, { // 往原型中添加方法
  click(handle) {...},
  append(child) {...},
  find(selector) {...},
  ...
})

现在,选择器引擎的雏形出来了,只要完成 elements 数组部分,便能 work。

elements 第一步筛选,可以使用上图 balajs 的主要代码,只需要将 querySelectorAll 垫片添加进去即可,如下图,主要修改了红框中的代码,并移除了 s 为 function 时,dom ready 部分实现(sdk 暂时用不到)。

elements获取

首先奉上 querySelectorAll 垫片。

if (!document.querySelectorAll) { // IE7 中没有 querySelectorAll
  var style = document.createStyleSheet();
  document.querySelectorAll = function (r, c, i, j, a) {
    var a = document.all, c = [], r = r.replace(/\[for\b/gi, '[htmlFor').split(',');
    for (i = r.length; i--;) {
      style.addRule(r[i], 'k:v');
      for (j = a.length; j--;) a[j].currentStyle.k && c.push(a[j]);
      style.removeRule(0);
    }
    return c;
  }
}
// 用于在 IE7 浏览器中,支持 Element.querySelectorAll 方法
window.querySelectorAll = (function () {
  var idAllocator = 10000;
  function qsaWorkerShim(element, selector) {
    var needsID = element.id === "";
    if (needsID) {
      ++idAllocator;
      element.id = "__qsa" + idAllocator;
    }
    try {
      return document.querySelectorAll("#" + element.id + " " + selector);
    }
    finally {
      if (needsID) {
        element.id = "";
      }
    }
  }
  function qsaWorkerWrap(element, selector) {
    return element.querySelectorAll(selector);
  }
  return document.createElement('div').querySelectorAll ? qsaWorkerWrap : qsaWorkerShim;
})();

可以看出,elements 有可能返回类数组对象、NodeList 或 HTMLCollection 对象,第二步便需要将它们转换成数组,然后交由 init 方法处理。鉴于 IE8 apply 的 bug,需要做如下特殊处理。

const isDOM = obj => { // 是否 DOM 元素
  if(typeof HTMLElement === 'object') {
    return obj instanceof HTMLElement;
  } else {
    return obj && typeof obj === 'object' && obj.nodeType === 1;
  }
};
const isArrayLike = collection => { // 是否类数组
    var length = collection.length;
    return typeof length == 'number' && length >= 0 && length <= (Math.pow(2, 53) - 1);
}
// IE8中继承数组的对象,toSting时默认会调用数组的toSting方法,这点与标准不一致,导致会报"缺少 Array 对象"错误,因此需要排除数组
const generalObj = elements instanceof Object && !(elements instanceof Array) && elements.toString() === '[object Object]' && !isArrayLike(elements) // 非类数组的普通对象
if (elements === window || elements === document || isDOM(elements) || generalObj) {
  elements = [elements];
} else {
  // 修复 bug:SCRIPT5028: Function.prototype.apply: 缺少 Array 或 arguments 对象
  try {
    elements = [].slice.apply(elements, [0]); // 类数组在这里完成转换
  } catch (err) {
    try {
      elements = [].concat.apply([], elements); // NodeList 或 HTMLCollection 在这里完成转换
    } catch (e) {
      if (elements.length) { // 兜底
        var tempArray = [];
        for (var i = 0; i < elements.length; i++) {
          tempArray[i] = elements[i];
        }
        elements = tempArray;
      } else {
        elements = [];
      }
    }
  }
}

整合上述代码,简单的选择器引擎便开发完成。

到此,营销活动 JS SDK 终于适配完成。SDK 总 size 仅仅增大了 10k,其中 flash 垫片还占了增长的大部分。

如何调试

除了代码外,IE 内核的适配过程中,调试也是困难重重,调试问题主要集中在以下 4 个方面:

  1. 如何使用 mac 调试 IE ?
  2. 选择哪种操作系统? windows 10 ? Windows 7 ? Windows xp?
  3. 选择哪种版本的 IE ?IE11?IE10?IE9?IE8?IE7?
  4. 选择什么样的代理? Fiddler ?Charles ? Whistle ?

我本地安装的是 Parallels Desktop 虚拟机,基于它,又安装了 win7 sp1 及 win xp 两个操作系统,分别用于测试 IE9、IE10、IE11 及 IE7、IE8。

调试过程中,不要相信 IE 代理模式,效果不太好,很多错误都会被默默吃掉,需要老老实实把 IE 浏览器逐个装一遍,我经常调试完 IE8 发现 IE7 好像有个 bug,然后手动把 IE8 卸载,退回 IE7,测好了再升级,IE7 没有dev tool,调试不太方便,定位不到问题,只能先升到 IE8,这样的过程需要重复很多次。

漫长的调试过程中,代理是非常重要的基础设施,代理频繁断开,或请求不正常转发,https证书问题,都非常消耗时间和精力,上面提到的三个代理软件,我都使用过,Fiddler 很容易断开连接,不建议使用,Charles 兼容性最好,各个版本的 IE 都能正常代理,但配置不是很方便,做为备选,Whistle 配置很方便,基本作为日常代理工具,但在 IE7 及 IE8 下,由于证书问题,导致https 请求不能正常发送。

更多的IE适配问题,欢迎在评论区留言继续交流,谢谢。

]]>
webpack4编译代码如何完美适配IE内核
使用webpack4提升180%编译速度 http://louiszhai.github.io/2019/01/04/webpack4/ 2019-01-04T02:49:08.000Z 2019-12-02T03:28:32.899Z 对于现在的前端项目而言,编译发布几乎是必需操作,有的编译只需要几秒钟,快如闪电,有的却需要10分钟,甚至更多,慢如蜗牛。特别是线上热修复时,分秒必争,响应速度直接影响了用户体验,用户不会有耐心等那么长时间,让你慢慢编译;如果涉及到支付操作,产品损失更是以秒计,每提前哪怕一秒钟发布,在腾讯海量用户面前,都能挽回不小的损失。不仅如此,编译效率的提升,带来的最直观收益就是,开发效率与开发体验双重提升。

那么,到底是什么拖慢了webpack打包效率,我们又能做哪些提升呢?

webpack

webpack 是目前非常受欢迎的打包工具,截止6天前,webpack4 已更新至 4.28.3 版本,10 个月的时间,小版本更新达几十次之多,可见社区之繁荣。

webpack4 发布时,官方也曾表示,其编译速度提升了 60% ~ 98%。

天下武功,唯快不破

由于本地项目升级到 webpack4 有几个月了,为了获得测试数据,手动将 webpack 降级为 3.12.0 版本,其它配置基本不做改动。

测试时,Mac仅运行常用的IM、邮箱、终端、浏览器等,为了尽可能避免插件对数据的影响,我关闭了一些优化插件,只保留常用的loader、js压缩插件。

以下是分别在 webpack@3.12.0 及 webpack@4.26.1 两种场景下各测 5 次的运行截图。

webpack3~4x编译速度对比

数据分析如下(单位ms):

第1次 第2次 第3次 第4次 第5次 平均 速度提升
webpack3 58293 60971 57263 58993 60459 59195.8 -
webpack4 42346 40386 40138 40330 40323 40704.6 45%

纯粹的版本升级,编译速度提升为 45%,这里我选取的是成熟的线上运行项目,构建速度的提升只有建立在成熟项目上才有意义,demo 项目由于编译文件基数小,难以体现出构建环境的复杂性,测试时也可能存在较大误差。同时与官方数据的差距,主要是因为基于的项目及配置不同。

无论如何,近 50% 的编译速度提升,都值得你尝试升级 webpack4!当然,优化才刚刚开始,请继续往下读。

新特性

为了更流畅的升级 webpack4,我们先要了解它。

webpack4 在大幅度提升编译效率同时,引入了多种新特性:

  1. 受 Parcel 启发,支持 0 配置启动项目,不再强制需要 webpack.config.js 配置文件,默认入口 ./src/ 目录,默认entry ./src/index.js ,默认输出 ./dist 目录,默认输出文件 ./dist/main.js

  2. 开箱即用 WebAssembly,webpack4提供了wasm的支持,现在可以引入和导出任何一个 Webassembly 的模块,也可以写一个loader来引入C++、C和Rust。(注:WebAssembly 模块只能在异步chunks中使用)

  3. 提供mode属性,设置为 development 将获得最好的开发体验,设置为 production 将专注项目编译部署,比如说开启 Scope hoisting 和 Tree-shaking 功能。

  4. 全新的插件系统,提供了针对插件和钩子的新API,变化如下:

    • 所有的 hook 由 hooks 对象统一管理,它将所有的hook作为可扩展的类属性
    • 添加插件时,你需要提供一个名字
    • 开发插件时,你可以选择插件的类型(sync/callback/promise之一)
    • 通过 this.hooks = { myHook: new SyncHook(…) } 来注册hook

    更多插件的工作原理,可以参考:新插件系统如何工作

快上车,升级前的准备

首先,webpack-dev-server 插件需要升级至最新,同时,由于webpack-cli 承担了webpack4 命令行相关的功能,因此 webpack-cli 也是必需的。

与以往不同的是,mode属性必须指定,否则按照 约定优于配置 原则,将默认按照 production 生产环境编译,如下是警告原文。

WARNING in configuration
The ‘mode’ option has not been set, webpack will fallback to ‘production’ for this value. Set ‘mode’ option to ‘development’ or ‘production’ to enable defaults for each environment.
You can also set it to ‘none’ to disable any default behavior. Learn more: https://webpack.js.org/concepts/mode/

有两种方式可以加入mode配置。

  • 在package.json script中指定–mode:

    "scripts": {
        "dev": "webpack-dev-server --mode development --inline --progress --config build/webpack.dev.config.js",
        "build": "webpack --mode production --progress --config build/webpack.prod.config.js"
    }
    
  • 在配置文件中加入mode属性

    module.exports = {
      mode: 'production' // 或 development
    };
    

升级至webpack4后,一些默认插件由 optimization 配置替代了,如下:

  • CommonsChunkPlugin废弃,由 optimization.splitChunks 和 optimization.runtimeChunk 替代,前者拆分代码,后者提取runtime代码。原来的CommonsChunkPlugin产出模块时,会包含重复的代码,并且无法优化异步模块,minchunks的配置也较复杂,splitChunks解决了这个问题;另外,将 optimization.runtimeChunk 设置为true(或{name: “manifest”}),便能将入口模块中的runtime部分提取出来。
  • NoEmitOnErrorsPlugin 废弃,由 optimization.noEmitOnErrors 替代,生产环境默认开启。
  • NamedModulesPlugin 废弃,由 optimization.namedModules 替代,生产环境默认开启。
  • ModuleConcatenationPlugin 废弃,由 optimization.concatenateModules 替代,生产环境默认开启。
  • optimize.UglifyJsPlugin 废弃,由 optimization.minimize 替代,生产环境默认开启。

不仅如此,optimization 还提供了如下默认配置:

optimization: {
    minimize: env === 'production' ? true : false, // 开发环境不压缩
    splitChunks: {
        chunks: "async", // 共有三个值可选:initial(初始模块)、async(按需加载模块)和all(全部模块)
        minSize: 30000, // 模块超过30k自动被抽离成公共模块
        minChunks: 1, // 模块被引用>=1次,便分割
        maxAsyncRequests: 5,  // 异步加载chunk的并发请求数量<=5
        maxInitialRequests: 3, // 一个入口并发加载的chunk数量<=3
        name: true, // 默认由模块名+hash命名,名称相同时多个模块将合并为1个,可以设置为function
        automaticNameDelimiter: '~', // 命名分隔符
        cacheGroups: { // 缓存组,会继承和覆盖splitChunks的配置
            default: { // 模块缓存规则,设置为false,默认缓存组将禁用
                minChunks: 2, // 模块被引用>=2次,拆分至vendors公共模块
                priority: -20, // 优先级
                reuseExistingChunk: true, // 默认使用已有的模块
            },
            vendors: {
                test: /[\\/]node_modules[\\/]/, // 表示默认拆分node_modules中的模块
                priority: -10
            }
        }
    }
}

splitChunks是拆包优化的重点,如果你的项目中包含 element-ui 等第三方组件(组件较大),建议单独拆包,如下所示。

splitChunks: {
    // ...
    cacheGroups: {    
        elementUI: {
            name: "chunk-elementUI", // 单独将 elementUI 拆包
            priority: 15, // 权重需大于其它缓存组
            test: /[\/]node_modules[\/]element-ui[\/]/
        }
    }
}

其更多用法,请参考以上注释或官方文档 SplitChunksPlugin

升级避坑指南

webpack4不再支持Node 4,由于使用了JavaScript新语法,Webpack的创始人之一,Tobias,建议用户使用Node版本 >= 8.94,以便使用最优性能。

正式升级后,你可能会遇到各种各样的错误,其中,下面一些问题较为常见。

vue-loader v15 需要在 webpack 中添加 VueLoaderPlugin 插件,参考如下。

const { VueLoaderPlugin } = require("vue-loader"); // const VueLoaderPlugin = require("vue-loader/lib/plugin"); // 两者等同

//...
plugins: [
  new VueLoaderPlugin()
]

升级到 webpack4 后,mini-css-extract-plugin 替代 extract-text-webpack-plugin 成为css打包首选,相比之前,它有如下优势:

  1. 异步加载
  2. 不重复编译,性能更好
  3. 更容易使用

缺陷,不支持css热更新。因此需在开发环境引入 css-hot-loader,以便支持css热更新,如下所示:

{
    test: /\.scss$/,
    use: [
        ...(isDev ? ["css-hot-loader", "style-loader"] : [MiniCssExtractPlugin.loader]),
        "css-loader",
        postcss,
        "sass-loader"
    ]
}

发布到生产环境之前,css是需要优化压缩的,使用 optimize-css-assets-webpack-plugin 插件即可,如下。

const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');

//...
plugins: [
    new OptimizeCssAssetsPlugin({
        cssProcessor: cssnano,
        cssProcessorOptions: {
            discardComments: {
                removeAll: true
            }
        }
    })
]

持续加速

文章开始,我曾提到,优化才刚刚开始。是的,随着项目越来越复杂,webpack也随之变慢,一定有办法可以进一步压榨性能。

经过很长一段时间的多个项目运行以及测试,以下几点经验非常有效

  1. 缩小编译范围,减少不必要的编译工作,即 modules、mainFields、noParse、includes、exclude、alias全部用起来。

    const resolve = dir => path.join(__dirname, '..', dir);
    
    // ...
    resolve: {
        modules: [ // 指定以下目录寻找第三方模块,避免webpack往父级目录递归搜索
            resolve('src'),
            resolve('node_modules'),
            resolve(config.common.layoutPath)
        ],
        mainFields: ['main'], // 只采用main字段作为入口文件描述字段,减少搜索步骤
        alias: {
            vue$: "vue/dist/vue.common",
            "@": resolve("src") // 缓存src目录为@符号,避免重复寻址
        }
    },
    module: {
        noParse: /jquery|lodash/, // 忽略未采用模块化的文件,因此jquery或lodash将不会被下面的loaders解析
        // noParse: function(content) {
        //     return /jquery|lodash/.test(content)
        // },
        rules: [
            {
                test: /\.js$/,
                include: [ // 表示只解析以下目录,减少loader处理范围
                    resolve("src"),
                    resolve(config.common.layoutPath)
                ],
                exclude: file => /test/.test(file), // 排除test目录文件
                loader: "happypack/loader?id=happy-babel" // 后面会介绍
            },
        ]
    }
    
  2. 想要进一步提升编译速度,就要知道瓶颈在哪?通过测试,发现有两个阶段较慢:① babel 等 loaders 解析阶段;② js 压缩阶段。loader 解析稍后会讨论,而 js 压缩是发布编译的最后阶段,通常webpack需要卡好一会,这是因为压缩 JS 需要先将代码解析成 AST 语法树,然后需要根据复杂的规则去分析和处理 AST,最后将 AST 还原成 JS,这个过程涉及到大量计算,因此比较耗时。如下图,编译就看似卡住。

    ParallelUglify

    实际上,搭载 webpack-parallel-uglify-plugin 插件,这个过程可以倍速提升。我们都知道 node 是单线程的,但node能够fork子进程,基于此,webpack-parallel-uglify-plugin 能够把任务分解给多个子进程去并发的执行,子进程处理完后再把结果发送给主进程,从而实现并发编译,进而大幅提升js压缩速度,如下是配置。

    const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');
    
    // ...
    optimization: {
        minimizer: [
            new ParallelUglifyPlugin({ // 多进程压缩
                cacheDir: '.cache/',
                uglifyJS: {
                    output: {
                        comments: false,
                        beautify: false
                    },
                    compress: {
                        warnings: false,
                        drop_console: true,
                        collapse_vars: true,
                        reduce_vars: true
                    }
                }
            }),
        ]
    }
    

    当然,我分别测试了五组数据,如下是截图:

    ParallelUglifyPlugin插件启用后编译速度分析

    数据分析如下(单位ms):

第1次 第2次 第3次 第4次 第5次 平均 速度提升
webpack3 58293 60971 57263 58993 60459 59195.8 -
webpack3搭载ParallelUglifyPlugin插件 44380 39969 39694 39344 39295 40536.4 46%
webpack4 42346 40386 40138 40330 40323 40704.6 -
webpack4搭载ParallelUglifyPlugin插件 31134 29554 31883 29198 29072 30168.2 35%

搭载 webpack-parallel-uglify-plugin 插件后,webpack3 的构建速度能够提升 46%;即使升级到 webpack4 后,构建速度依然能够进一步提升 35%。

  1. 现在我们来看看,loader 解析速度如何提升。同 webpack-parallel-uglify-plugin 插件一样,HappyPack 也能实现并发编译,从而可以大幅提升 loader 的解析速度, 如下是部分配置。

    const HappyPack = require('happypack');
    const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });
    const createHappyPlugin = (id, loaders) => new HappyPack({
        id: id,
        loaders: loaders,
        threadPool: happyThreadPool,
        verbose: process.env.HAPPY_VERBOSE === '1' // make happy more verbose with HAPPY_VERBOSE=1
    });
    

    那么,对于前面 loader: "happypack/loader?id=happy-babel" 这句,便需要在 plugins 中创建一个 happy-babel 的插件实例。

    plugins: [
        createHappyPlugin('happy-babel', [{
            loader: 'babel-loader',
            options: {
                babelrc: true,
                cacheDirectory: true // 启用缓存
            }
        }])
    ]
    

    如下,happyPack开启了3个进程(默认为CPU数-1),运行过程感受下。

    happyPack

    另外,像 vue-loader、css-loader 都支持 happyPack 加速,如下所示。

    plugins: [
        createHappyPlugin('happy-css', ['css-loader', 'vue-style-loader']),
        new HappyPack({
            loaders: [{
                path: 'vue-loader',
                query: {
                    loaders: {
                        scss: 'vue-style-loader!css-loader!postcss-loader!sass-loader?indentedSyntax'
                    }
                }
            }]
        })
    ]
    

    基于 webpack4,搭载 webpack-parallel-uglify-plugin 和 happyPack 插件,测试截图如下:

    搭载两款插件后编译速度分析

    数据分析如下(单位ms):

第1次 第2次 第3次 第4次 第5次 平均 速度提升
仅搭载ParallelUglifyPlugin 31134 29554 31883 29198 29072 30168.2 35%
搭载ParallelUglifyPlugin 和 happyPack 26036 25884 25645 25627 25794 25797.2 17%

可见,在搭载 webpack-parallel-uglify-plugin 插件的基础上,happyPack 插件依然能够提升 17% 的编译速度,实际上由于 sass 等 loaders 不支持 happyPack,happyPack 的性能依然有提升空间。更多介绍不妨参考 happypack 原理解析

  1. 我们都知道,webpack打包时,有一些框架代码是基本不变的,比如说 babel-polyfill、vue、vue-router、vuex、axios、element-ui、fastclick 等,这些模块也有不小的 size,每次编译都要加载一遍,比较费时费力。使用 DLLPlugin 和 DLLReferencePlugin 插件,便可以将这些模块提前打包。

    为了完成 dll 过程,我们需要准备一份新的webpack配置,即 webpack.dll.config.js。

    const webpack = require("webpack");
    const path = require('path');
    const CleanWebpackPlugin = require("clean-webpack-plugin");
    const dllPath = path.resolve(__dirname, "../src/assets/dll"); // dll文件存放的目录
    
    module.exports = {
        entry: {
            // 把 vue 相关模块的放到一个单独的动态链接库
            vue: ["babel-polyfill", "fastclick", "vue", "vue-router", "vuex", "axios", "element-ui"]
        },
        output: {
            filename: "[name]-[hash].dll.js", // 生成vue.dll.js
            path: dllPath,
            library: "_dll_[name]"
        },
        plugins: [
            new CleanWebpackPlugin(["*.js"], { // 清除之前的dll文件
                root: dllPath,
            }),
            new webpack.DllPlugin({
                name: "_dll_[name]",
                // manifest.json 描述动态链接库包含了哪些内容
                path: path.join(__dirname, "./", "[name].dll.manifest.json")
            }),
        ],
    };
    

    接着, 需要在 package.json 中新增 dll 命令。

    "scripts": {
        "dll": "webpack --mode production --config build/webpack.dll.config.js"
    }
    

    运行 npm run dll 后,会生成 ./src/assets/dll/vue.dll-[hash].js 公共js 和 ./build/vue.dll.manifest.json 资源说明文件,至此 dll 准备工作完成,接下来在 webpack 中引用即可。

    externals: {
        'vue': 'Vue',
        'vue-router': 'VueRouter',
        'vuex': 'vuex',
        'elemenct-ui': 'ELEMENT',
        'axios': 'axios',
        'fastclick': 'FastClick'
    },
    plugins: [
        ...(config.common.needDll ? [
            new webpack.DllReferencePlugin({
                manifest: require("./vue.dll.manifest.json")
            })
        ] : [])
    ]
    

    dll 公共js轻易不会变化,假如在将来真的发生了更新,那么新的dll文件名便需要加上新的hash,从而避免浏览器缓存老的文件,造成执行出错。由于 hash 的不确定性,我们在 html 入口文件中没办法指定一个固定链接的 script 脚本,刚好,add-asset-html-webpack-plugin 插件可以帮我们自动引入 dll 文件。

    const autoAddDllRes = () => {
        const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');
        return new AddAssetHtmlPlugin([{ // 往html中注入dll js
            publicPath: config.common.publicPath + "dll/",  // 注入到html中的路径
            outputPath: "dll", // 最终输出的目录
            filepath: resolve("src/assets/dll/*.js"),
            includeSourcemap: false,
            typeOfAsset: "js" // options js、css; default js
        }]);
    };
    
    // ...
    plugins: [
        ...(config.common.needDll ? [autoAddDllRes()] : [])
    ]
    

    搭载 dll 插件后,webpack4 编译速度进一步提升,如下截图:

    搭载三款插件后编译速度分析

    数据分析如下(单位ms):

第1次 第2次 第3次 第4次 第5次 平均 速度提升
搭载ParallelUglifyPlugin 和 happyPack 26036 25884 25645 25627 25794 25797.2 17%
搭载ParallelUglifyPlugin 、happyPack 和 dll 20792 20963 20845 21675 21023 21059.6 22%

可见,搭载 dll 后,webpack4 编译速度仍能提升 22%。

综上,我们汇总上面的多次数据,得到下表:

第1次 第2次 第3次 第4次 第5次 平均 速度提升
webpack3 58293 60971 57263 58993 60459 59195.8 -
webpack4 42346 40386 40138 40330 40323 40704.6 45%
搭载ParallelUglifyPlugin 、happyPack 和 dll 20792 20963 20845 21675 21023 21059.6 181%

升级至 webpack4 后,通过搭载 ParallelUglifyPlugin 、happyPack 和 dll 插件,编译速度可以提升181%,整体编译时间减少了将近 2/3,为开发节省了大量编译时间!而且随着项目发展,这种编译提升越来越可观。

实际上,为了获得上面的测试数据,我关闭了 babel、ParallelUglifyPlugin 的缓存,开启缓存后,第二次编译时间平均为 12.8s,由于之前缓存过,编译速度相对 webpack3 将提升362%,即使你已经升级到 webpack4,搭载上述 3 款插件后,编译速度仍能获得 218% 的提升!

编译结果分析

当然,编译速度作为一项指标,影响的更多是开发者体验,与之相比,编译后文件大小更为重要。webpack4 编译的文件,比之前版本略小一些,为了更好的追踪文件 size 变化,开发环境和生产环境都需要引入 webpack-bundle-analyzer 插件,如下图。

analyzer

文件 size 如下图所示:

analyzer.size

面向tree-shaking,约束编码

sideEffects

从 webpack2 开始,tree-shaking 便用来消除无用模块,依赖的是 ES Module 的静态结构,同时通过在. babelrc 文件中设置 "modules": false 来开启无用的模块检测,相对粗暴。webapck4 灵活扩展了无用代码检测方式,主要通过在 package.json 文件中设置 sideEffects: false 来告诉编译器该项目或模块是 pure 的,可以进行无用模块删除,因此,开发公共组件时,可以尝试设置下。

为了使得 tree-shaking 真正生效,引入资源时,仅仅引入需要的组件尤为重要,如下所示:

import { Button, Input } from "element-ui"; // 只引入需要的组件

结尾

升级 webpack4 的过程,踩坑是必须的,关键是踩坑后,你能得到什么?

另外,除了文中介绍的一些优化方法,更多的优化策略,正在逐步验证中…

]]>
webpack4 使用webpack4提升180%编译速度
Alfred神器使用手册 http://louiszhai.github.io/2018/05/31/alfred/ 2018-05-31T03:46:17.000Z 2019-12-02T03:28:32.871Z 我曾经耗费巨大的精力,试图在计算机的使用效率上找到一条优化的捷径,一直以来都收效甚微。直到遇上 alfred,它强大的工作流机制,彻底解决了输入输出的痛点,极大的减少了程序之间的切换成本和重复按键成本,这才让我明白,原来计算机可以这么玩。

神奇的魔法帽,alfred 初印象。

如何安装alfred

首先可以从 alfred官网 自行下载安装,免费用户可以使用除 workflow 以外的其它功能,如需使用 workflow,则需要购买Powerpack。

不建议:自行搜索破解版,或者点个喜欢,留下邮箱找我要…

一个例子说明为什么要用alfred

以前,使用mac查询一个单词,或者翻译一个单词,我们要么经历五步:

  1. 手动打开浏览器
  2. 进入谷歌首页
  3. 选中输入框
  4. 输入或粘贴查询单词,然后空格并加上”翻译” 两个字,然后再回车
  5. 等待浏览器展示查询结果;

要么经历四步:

  1. 打开翻译应用(比如自带词典)
  2. 输入或粘贴查询单词
  3. 翻译应用输出查询结果
  4. 查询过后,一般都需要Cmd+Q退出应用(或者Cmd+H隐藏词典,亦或Cmd+Tab切换回上一个应用)

查询单词这个场景中,我们至少需要兴师动众,切换或打开一个应用两次,定位输入框一次,输入或复制粘贴一次。且查询结果页也会挡住当前的工作区,使得我们分心,甚至忘记自己刚刚在做啥,总之,体验极不流畅。

alfred 工作流正是为了解决这个问题而设计的。使用 有道词典 workflow,最快只需两次按键便可查询单词. 举个栗子🌰:为了查询单词 “workflow”,我会选中单词所在区域,然后按住 Option+Y 键(我已将有道翻译的快捷键设置为 Option+Y),单词查询结果就出来了,不需要切换应用,同时查询结果也较少的挡住工作区。如下所示:

两次按键就能查询单词,这么好的应用为何不用呢?

alfred能做什么?

对于一个刚刚听说alfred的新手来说,迫切想知道的莫过于了解它能做什么?据我所知,公开的 alfred workflow 至少有 500+,有心网友甚至罗列了一张 [表格][http://www.alfredworkflow.com/]来管理它,表格的每一行都解锁了一项 alfred 技能(注意并非所有的 workflow 都支持最新的 alfred 3.6.1版本)。你可以下载并免费使用其中任何一个 workflow,甚至,还可以基于一些不错的 workflow 样本,加入创意,改造成属于自己的 workflow(前提是已获得 powerpack license)。

默认情况下,alfred 至少能胜任 15 项工作:

  1. 应用搜索
  2. 文件或目录搜索
  3. 文本内容搜索
  4. 标记搜索
  5. 快捷网页搜索
  6. 书签搜索
  7. 计算器
  8. 词典搜索
  9. 通讯录搜索
  10. 剪切板搜索
  11. 代码片段搜索
  12. iTunes管理
  13. 1Password搜索
  14. 系统常用命令快捷操作
  15. 直接唤起指定终端并执行命令

获得 powerpack license 的 alfred 将获得强大的 workflows 功能,后续将专门讲解。

1.应用搜索

输入应用名,列出本地安装的所有相关应用,可以快速唤起。

2. 文件或目录搜索

输入 find 或 open 命令,以及待搜索的文件或目录名,列出磁盘中的相关文件,可以快速定位 finder,相当于一个简易的 EasyFind。

alfred-find

3. 文本内容搜索

输入 in 命令,以及待搜索的文本,列出磁盘中包含该文本的相关文件,可以快速定位文件,相当于简易的终端 find 命令。

alfred-in

4. 标记搜索

输入 tags 命令,以及待搜索的标记颜色中文名称,列出打上相应标记的目录,可以快速定位标记目录。

alfred-tags

以上 2、3、4 展示的搜索能力,仅仅是 alfred 提供的冰山一角的小功能(对应于 alfred preferences 面板(Cmd+,唤起)— features 栏— file search 功能,如下图所示),理论上可以进行全盘搜索,但由于性能原因,截止 alfred 3.6.1,默认至多展示前40个搜索结果。

alfred-features

对于通常的搜索而言,完全没必要进行全盘搜索,因此只将当前用户目录加进去即可,请参考下图添加用户目录:

alfred-default-results

5. 快捷网页搜索

alfred 可以非常方便的打开指定网页(alfred preferences 面板— features 栏— web search),这是一个非常贴心的小功能。默认情况下,alfred 自带了 wiki、twitter、ebay、bing、gmail、yahoo、linkedin、youtube、facebook 等几十种网站的链接,你可以输入关键字如『wiki』空格后再输入搜索内容,最后再回车打开 wiki 网站,如下所示:

alfred-wiki

也可以点击此处右下角『Add Custom Search』按钮新增你常用的网页搜索,如下所示:

alfred-web-search

6. 书签搜索

书签搜索是 alfred3.x 版本中新加的功能,方便用户在浏览器的大量书签中快速搜索。

alfred-web-bookmarks

7. 计算器

alfred 默认提供计算的能力,如下所示。

alfred-calculator

输入=,还能进行复杂运算,如下。

alfred-calculator02

8. 词典搜索

实际上,自带的词典搜索功能不是很理想,建议搭配 有道词典 workflow一起使用。

alfred-dictionary

9. 通讯录搜索

alfred 还可以用来搜索通讯录中的联系人,如下所示。

10. 剪切板搜索

剪切板的管理也是 alfred 的一大亮点,如下所示。

afred-clipboard

如此一来,拷贝多段内容就变得非常容易,借助 alfred,可以在一处连续拷贝,然后另一处连续粘贴,避免了频繁切换应用带来的操作疲劳;同时之前复制过的文本或图片,也不用担心过会找不到。

  1. 代码片段搜索,相对 aText 来说,感觉不是特别方便,略过(aText 是 mac 下输入增强工具,输入关键字,自动补全文本)。

  2. iTunes管理使用得不多,略过。

  3. 1Password由于未安装,也略过。

14. 系统常用命令快捷操作

通过 alfred 可以快捷地操作系统锁屏、关机、重启、休眠等十几种指令,非常便捷。对于强迫症用户来说,唤起屏保、休眠、清空垃圾篓、退出应用等指令可能较为常用。

alfred-system

15.直接唤起指定终端并执行命令

通过 alfred 可以直接唤起终端窗口,并执行命令,如下所示。

以上,Application 若选择『Custom』选项,下方再贴如下一段 applescript 代码,便可以直接在 iTerm 中执行命令。

on alfred_script(q)
    tell application "iTerm"
        set _length to count window
    if _length = 0 then
        create window with default profile
    end if
    set aa to (get miniaturized of current window)
    if aa then
        set miniaturized of current window to false
    end if
    set bb to (get visible of current window)
    if bb is false then
        set visible of current window to true
    end if
    set cc to frontmost
    if cc is false then
        activate
    end if
        (*if _length = 0 then*)
            set theResult to current tab of current window
        (*else
            set theResult to (create tab with default profile) of current window
        end if*)
        write session of theResult text q
end tell
end alfred_script

小结

至此 alfred 的 Features 面板功能介绍完毕。alfred 设置界面一共包含10个面板,还有9个面板如下所示:

  1. General(通用,用于设置是否开机启动,及设置唤起快捷键,通常设置为 Alt+Space 即可)
  2. Workflows(工作流)
  3. Appearance(外观,用于设置 alfred 输入窗口的外观、字体、颜色等)
  4. Advanced(高级)
  5. Remote(远程,用于远程管理,这意味着你需要在 App Store 购买一个 Alfred Remote 的app,然后便可以在手机上远程操作 mac)
  6. Powerpack(许可证,购买 powerpack 的用户便可以使用 workflow 功能)
  7. Usage(使用统计)
  8. Help(帮助,提供快速上手文档、使用文档、反馈bug、用户论坛等链接)
  9. Update(更新日志,可查看更新日志及更新到最新版)

Appearance 面板除了设置输入窗口的外观外,还有一些外观相关的设置,在这里可以设置默认展示行数等。

Advanced 面板包含了一些高级设置,如下所示。

Usage 面板包含了你使用 alfred 的数据统计,如下所示。

由此可见,几乎我每天都会用 alfred,3年来总计使用3W+次,平均每天使用27.8次,剔除节假日,工作日每天平均使用次数高达40+次,可以说,alfred 极大的方便了我的工作和生活。

alfred workflow

基本功能介绍完了,终于,我们要一窥 alfred 的核心功能— workflow。工作流可谓是 alfred 最强大的功能,它是秒杀其他效率应用的核心技术,也是最吸引我的地方。

唯有掌握工作流,mac 才能真正起飞。

常用的workflow

欲了解工作流,先从常用的 workflow 开始,下面简单展示一些典型。

ip查询

指定 qq 好友聊天

指定微信好友聊天

印象笔记搜索

百度地图搜索

点评搜索

豆瓣电影搜索

豆瓣书籍搜索

知乎日报

水木清华社区搜索

php api 搜索

jquery api 搜索

快递查询

finder 设置

举例就到这了,另外,这里有我的一些 afred workflows,欢迎试玩。

workflow是什么

你可能很好奇,上面这些 workflow,都是怎么开发的呢?别急,稳住慢慢来。

先问一个问题,什么是工作流?

我们都知道,任何微小的工作,都可以拆分成多个步骤,这些步骤顺序相连,依次进行,最终输出成果,有些步骤可能存在多个分支,并且最终输出多个成果。这些步骤依次执行,并且向后传递阶段性信息的流,就是工作流。现实生活中的工作流可能更为复杂,但本质还是如此。正是基于这种现实背景,alfred 从 2.0 版本起加入了 workflow,普通的 workflow 如下所示。

这个工作流包含三个步骤:① 查询单词—> ② 格式化输出—> ③ 复制到剪切板。

yd是唤起该工作流的命令,输入yd,然后空格,接着输入待查询的单词,script Filter便开始执行,最终输出查询结果列表(图片见文章开头例子),至此,工作流的步骤①查询单词部分完成。

我们注意到,图中有两条数据流连线,第一条包含节点,这意味着,节点处需要等待用户操作(点击)才能继续下去。一旦用户点击列表项,后续流程②格式化输出,将直接执行,紧接着其后续流程③复制到剪切板也将顺序执行,最终单词查询结果复制到剪切板,工作流结束。

实际上,上图中包含节点的数据流连线,点击时还可指定相应的辅助键,辅助键可选 nonectrlaltcmdfnshift之一,默认为 none,即无须辅助键。指定辅助键的好处在于,不同的辅助键,可以触发不同的后续流程,如上图则只设计一个后续流程(即②格式化输出流程)。设置辅助键的界面如下所示,可以指定相应提示,以及流程执行时是否关闭 alfred 窗口。

如何创建第一个workflow

是不是跃跃欲试了,来创建第一个 workflow 吧。

  1. 首先,打开 alfred preferences 设置界面,选中第三个面板 Workflows。

  2. 点击面板底部左侧的 + 按钮,选择 Blank Workflow。

  3. 补全 workflow 相关信息,最后点 Create 按钮保存,如下所示。

  4. 于是第一个空的 workflow 创建好了,接下来我们来搭建一个 google 搜索的工作流,通过这个工作流,我们能快速的选中文本然后使用 google 搜索该文本,不妨参考以下步骤。

    1)新增热键:右键 - Triggers-Hotkey。

    2)热键设置面板中:Hotkey 设置为 Alt+G(快捷键必须以 CtrlAltShiftCmd 开始,而 Alt 键很少被软件占用,推荐作为 alfred 的常用修饰键);Argument 选择『Selection in macOS』(意味着 mac 任何应用选中的文本都会通过 alfred 传给后面的流程),然后保存。

    4)热键保存后,继续添加google搜索的流程:右键 - Actions - Open URL。

    5)Open URL 设置面板中:URL 设置为 https://www.google.com/search?q={query},{query} 即热键流程中选中的文本(alfred 中,流程通过 {query} 关键字接收前面传递过来的参数),然后保存。

    6)最后,将热键流程和 Open URL 流程连线,至此,google 搜索的工作流完成。

    你还可参考如下图示。

是不是非常简单?到目前为止,完全不需要编程基础。

workflow支持什么功能

截止到 v3.6.1 版本,workflow 支持 Triggers、Inputs、Actions、Utilities(alfred3.x新增)、Outputs 共5项主要功能,如下所示。

这5项功能一共包含39个组件。其中

  • 输入包含 Triggers(触发器)和 Inputs(输入触发);Triggers 中的流程可以触发 Inputs 的流程,反之则不行,同时它们都可以触发其它后续流程。
  • 输出即 Outputs,包含了通知,放大展示、复制到剪切板,写入文本、播放声音、触发其它流程等。
  • 中间 Actions 包含打开文件、在 finder 中展示文件、唤起 app、打开 web search、打开 URL、执行系统命令、执行 iTunes 命令、执行脚本、执行 applescript 脚本、在终端中执行命令等。
  • Utilities 包含了一些公共组件,如变量设置、json 配置、过滤、转换、替换、延时、debug 等。

以上,Hotkey、Keyword、Script Filter 是常用的输入组件,Open URL、Run Script 是高频的 Action 组件,Post Notification、Copy to Clipboard 是受欢迎的输出组件,而 Arg and Vars、Filter、Delay、Debug 是贴心的公共组件。

合理搭配相应的组件,我们就能像搭乐高积木一样搭建 workflow。

哪些语言能编写workflow

你可能会说没有编程的 workflow 有什么意思,是的,alfred 除了使用可视化组件,简化搭建 workflow 的难度外,还内置了多种语言支持。我们不需要关心语言之间的交互细节,只需要使用它们接收输入,提供输出,其它事情统统交给 alfred。

目前,我们可以直接使用如下8种语言编写脚本:

  • bash
  • zsh
  • php
  • ruby
  • python
  • perl
  • applescript
  • javascript

你没看错,javascript 也是默认支持的(jser要疯狂了)。除了上述8种语言外,通过bash或zsh,一样可以唤起其它语言,如 java、c、go 等等。

实际上,python 可能是 alfred workflow 中最常用的编程语言,前人编写了大量的 python 脚本,都可以在 alfred 中大放光彩。

请注意,以上编程语言可以在这两个组件中使用:① Inputs -> Script Filter、② Actions -> Run Script。

workflow的不足

本文聊了这么多,workflow的优势就不多说了。

很明显,workflow 不是万能的,很多场景,v3.6.1 的 alfred 还覆盖不到。比如说:

  1. 无法监听用户操作,自动录入工作流。对于大多数人来说,编码创造工作流的成本太高,alfred 若能监听一段时间用户操作,将之转换成工作流,无疑工作流入门成本会大幅度降低,同时也能弥补 applescript 语言的不足(未提供 applescript 接口的应用几乎无法编程),当然这个要求很高,比如说alfred可能需要获取输入时光标所在的屏幕位置,被操作应用的坐标、宽高以及输入源(键盘、鼠标等)的操作等。
  2. 没有可视化的组件界面,相比 v2.x 版本而言,v3.x 版本中操作依然停留在文本输入输出上,若能多些可视化组件,比如图片展示,图文混排等,那么编程的空间将更大。
  3. 不支持常驻窗口,且常驻窗口上可以二次编程。若能在常驻窗口上放置 todolist、便签,以及监听股票走势等等,那么,几乎就能面向 alfred 开发小程序了。
  4. 不支持触摸板手势或 touchbar 直接唤起工作流,手势输入或 touchbar 的玩法很多,创意也很多,有很大的想象空间。

当然,可能还有更多更好的 idea,现如今的 alfred 暂不支持,欢迎在评论区回复交流,一起畅想 alfred 的未来。

我的一些心得

最后,谈谈我开发 alfred workflow 的一些心得。

关于调试:

alfred 流程报错不会有通知和提示,因此一旦 workflow 没有按照你的期望提供输出,那就要注意了,打开 debug 窗口,或引入 Utilities -> Debug 组件,看看有没有异常输出。

alfred 虽然支持多种语言的执行,但执行过程中无法单步 debug,这给调试带来了挑战。所以,开发 workflow 时需要及时的进行单元测试,待部分功能完善后,再进行后续开发,避免陷入根据错误输出无法第一时间定位问题的窘境。

关于alfred选项列表输出:

我们提供输入,往往是为了获取输出列表,然后选择列表中的一项,执行后续流程。如下所示,列表中的 9 项即选项列表

实际上,选项列表对应一个 xml 配置,工作流中只需输出配置好的 xml 即可,请参考如下格式。

<?xml version="1.0"?>
<items>
    <item uid="" arg="https://www.google.com/search?q={query}&amp;safe=off">
        <title>谷歌一下 {query}</title>
        <subtitle>副标题</subtitle>
        <icon>google-icon.png</icon>
    </item>
    ...
</items>

以上,arg 即往后传递的参数,title 标签内填写标题,subtitle 标签内填写副标题,icon 标签内填写当前选项的图标。然后直接使用 shell 的 echo 打印以上 xml,即可输出以上选项列表。

xml 中如果包含链接,则 & 需要替换为 &amp;

关于选项列表多次输出&流程间调用:

很多时候,一次输入可能不够,若需要多次输入信息,又该如何实现呢?不妨参考如下两种方案:

  1. 选项列表的输出依赖 Inputs -> Script Filter 组件,若流程中包含多次输入,顺序引入多个 Script Filter 组件即可。

  2. 若需要唤起 ① 其它分支流程(同一个 workflow 不同流程)、② 其它 workflow 中的流程(跨 workflow 调用)或 ③ 回到当前流程源头(重复执行、直到退出),则可给需要唤起的流程头部插入 Triggers -> External 组件,然后该组件所在流程便可通过 applescript 脚本唤起。applescript 脚本如下所示:

    tell application "Alfred 3" to run trigger "action" in workflow "com.louis.alfred.CRUD_Module" with argument "test"
    

    这段代码的意思是:让 Alfred 3 应用,带上参数 “test”,去打开 Bundle Id为 “com.louis.alfred.CRUD_Module” 的 workflow 中名称为 “action” 的触发器所在流程。

以上,方案1实现简单,不可复用;方案2实现略复杂,优点是可复用。你可以稍微感受下我之前写的一个CRUD的workflow(主流程使用了 24 个组件),其中 6 次依赖 External 组件串起流程(见图中红色下划线标出部分)。

该 CRUD 的 workflow 使用非常简单,如下演示了新增流程去打开 iTerm 并执行 ll 命令的过程。

注意事项:

根据我的经验,workflow 开发中还需注意以下几点:

  • 流程中的节点往后传递参数非常简单,只需往控制台输出即可。但须注意,多个控制台输出会合并到一起,因此除了往后传递参数外,其他情况下都不要往控制台打印文本。通常控制台输出会包含换行符,为避免换行符带来干扰,推荐使用 echo -n(bash) 或 sys.stdout.write(python);直接执行 js 时,方法内部的return 即往后传递参数,此时 console.log 输出到控制台并不合法。
  • 开发中容易出现 utf-8 编码的问题,建议编程中少用或不用中文注释,或者重载 utf-8 编码(python)。
  • 如果需要携带参数,去唤起其它应用,applescript 会是个不错的选择。

为什么会有这篇文章

到这,文章就快结束了,从 2015 年 3 月 28 日 接触 alfred 起,我便迷上了它的超强工作流。alfred 几乎可以做任何自动化工作流的事情(只要能用代码描述这个工作流就行),它彻底改变了我对 mac 的认知。此后,我曾多次向团队同学安利并分享它的神奇之处,他们鼓励我开一个在线直播,有偿分享,但对我而言,能写一篇介绍它的文章,几乎是我的荣幸!最后,写得不好的地方欢迎批评斧正,感谢您的阅读!


版权声明:转载需注明作者和出处。

本文作者: louis

本文链接: http://louiszhai.github.io/2018/05/31/alfred/

]]>
Alfred workflow 工作流
【Chrome扩展开发】定制HTTP请求响应头域 http://louiszhai.github.io/2017/11/14/iheader/ 2017-11-14T02:40:15.000Z 2019-12-02T03:28:32.885Z 本文首发于《程序员》杂志2017年第9、10、11期,下面的版本又经过进一步的修订。

导读

对于开发而言,搜索是日常工作,为了提升搜索的效率,以便更快的查询信息,我试着同时搜索4个网站,分别是百度、Google、维基、Bing。一个可行的做法就是网页中嵌入4个iframe,通过js拼接前面4个搜索引擎的Search URL并依次在iframe中加载。这个构思丝毫没有问题,简单粗暴。然而就是这么简单的功能,也无法实现。由于Google网站在HTML的response header中添加了X-Frame-Options字段以防止网页被Frame(这项设置常被用来防止Click Cheats),因此我无法将Google Search加入到iframe中来。那么,我会放弃Google吗?

Nginx反向代理Google

显然不会,既然问题出在X-Frame-Options上,我去掉就行了。对于请求或响应头域定制,nginx是个不错的选择,其第三方的ngx_headers_more模块就特别擅长这种处理。由于nginx无法动态加载第三方模块,我动态编译了nginx以便加入ngx_headers_more模块。至此,第一步完成,以下是nginx的部分配置。

location / {
  more_clear_headers 'X-Frame-Options';
}

为了让www.google.com正常访问,我需要使用另外一个域名比如louis.google.com。通过nginx,让louis.google.com转发到www.google.com,转发的同时去掉响应头域中的X-Frame-Options字段。于是nginx配置看起来像这样:

server {
  listen 80;
  server_name louis.google.com;
  location / {
    proxy_pass https://www.google.com/;
    more_clear_headers 'X-Frame-Options';
  }
}

以上的配置有什么问题吗?且不说http直接转https的问题,即使能转发,实际上由于Google的安全策略限制,我们也访问不了Google首页!

最终我使用了一个Nginx Google代理模块ngx_http_google_filter_module),nginx配置如下:

server {
    listen 80;
    server_name louis.google.com;
    resolver 192.168.1.1; # 需要设置为当前路由的网关
    location / {
        google on;
        google_robots_allow on;
        more_clear_headers 'X-Frame-Options';
    }
}

以上,通过实现一个Google网站的反向代理,代理的同时去掉了响应头域中的X-Frame-Options字段。至此,nginx方案完结。

nginx方案有一个明显的缺陷是,配置中resolver对应的网关IP192.168.1.1是随着路由器的改变而改变的,家里和公司就是两个不同的网关(更别说去星巴克了办公了),因此经常需要手动去修改网关然后重启nginx。

IHeader缘起

nginx方案的这个缺陷多少有些麻烦,恰好Chrome Extension可以定制headers,为了解决这个问题,我便尝试开发Chrome Extension。(使用Chrome以来,我下载试用过无数的Chrome Extension。每每看到一款优秀的Extension,都要激动好久,总有一种相见恨晚的感觉。Extension以其强大的定制能力,神奇的运行机制征服了无数的开发者,我也不例外。然而无论多少次的学习和模仿,最终的目的还是为了使用,故开发一款定制请求的Extension势在必行。)由于Chrome浏览器与网页的天然联系,使用Chrome Extension的方式去掉响应头域字段,比其它方案要更加简单高效。

要知道,Chrome Extension提供的API中有chrome.webRequest.onHeadersReceived。它能够添加对响应头的监听并同步修改响应头域,去掉X-Frame-Options似乎是小case。

于是新建项目,取名IHeader。目录结构如下:

目录结构

其中,_locales是国际化配置,目前IHeader支持中文和英文两种语言。

res是资源目录,index.html是extension的首页,options.html是选项页面。

manifest.json是extension的声明配置(总入口),在这里配置extension的名称、版本号、图标、快捷键、资源路径以及权限等。

manifest.json贴出来如下:

{
  "name": "IHeader", // 扩展名称
  "version": "1.1.0", // 扩展版本号
  "icons": { // 上传到chrome webstore需要32px、64px、128px边长的方形图标
    "128": "res/images/lightning_green128.png",
    "32": "res/images/lightning_green.png",
    "64": "res/images/lightning_green64.png"
  },
  "page_action": { // 扩展的一种类型,说明这是页面级的扩展
    "default_title": "IHeader", // 默认名称
    "default_icon": "res/images/lightning_default.png", // 默认图标
    "default_popup": "res/index.html" // 点击时弹出的页面路径
  },
  "background": { // 扩展在后台运行的脚本
    "persistent": true, // 由于后台脚本需要持续运行,需要设置为true,反之扩展不活动时可能被浏览器关闭
    "scripts": ["res/js/message.js", "res/js/background.js"] // 指定运行的脚本,实际上Chrome会启用一个匿名的html去引用这些js脚本。等同于"pages":["background.html"]这种方式(注意这两种互斥,同时设置时,后一种有效)
  },
  "commands": { // 指定快捷键
    "toggle_status": { // 快捷命令的名称
      "suggested_key": { // 快捷命令的热键
        "default": "Alt+H",
        "windows": "Alt+H",
        "mac": "Alt+H",
        "chromeos": "Alt+H",
        "linux": "Alt+H"
      },
      "description": "Toggle IHeader" // 描述
    }
  },
  "content_scripts": [ // 随着每个页面加载的内容脚本,通过它可以访问到页面的DOM
    {
      "all_frames": false, // frame中不加载
      "matches": ["\u003Call_urls>"], // 匹配所有URL
      "js": ["res/js/message.js", "res/js/content.js"] // 内容脚本的路径
    }
  ],
  "default_locale": "en", // 默认语言
  "description": "__MSG_description__", // 扩展描述
  "manifest_version": 2, // Chrome 18及更高版本中,应该指定为2,低于v18版本的Chrome浏览器可以指定为1或不指定
  "minimum_chrome_version": "26.0", // 最低支持到v26版本,主要受制于chrome.runtime api
  "options_page": "res/options.html", // 选项页面的路径
  "permissions": [ "tabs" , "webRequest", "webRequestBlocking", "http://*/*", "https://*/*", "contextMenus", "notifications"] // 扩展需要的权限
}

Chrome Extension简介

开始开发之前,我们先来刷一波基础知识。

Chrome官方明确规定了插件、扩展和应用的区别:

  • 插件(Plugin)是通过调用 Webkit 内核 NPAPI 来扩展内核功能的一种组件,工作在内核层面,理论上可以用任何一种生成本地二进制程序的语言开发,比如 C/C++、Java 等。插件重点在于接入浏览器底层,拥有更多的权限,可调用系统API,因此插件一般都不能跨系统。比如说最近Adobe宣布放弃的Flash,下载资源的迅雷以及网上付款的网银,它们都提供了Chrome插件,用以在特定场景启用并运行,从而实现丰富的功能。
  • 扩展(Extension)是通过调用 Chrome 提供的 Chrome API 来扩展浏览器功能的一种组件,它完全基于Chrome浏览器,借助HTML,CSS,JS等web技术实现功能,是Chrome提供的一种可开发的扩展技术。比如说今年横空出世的微信小程序,它就是微信提供的一种扩展技术。相对于插件而言,扩展程序拥有有限的权限和API,对底层系统不感知,从而具有良好的跨平台特性。注意插件和扩展都只有在Chrome启动后才会运行。
  • 应用(Application)同样是用于扩充Chrome浏览器功能。它与扩展的区别就在于,它拥有独立运行的用户界面,并且Chrome未启动时也能独立调用,就像一个独立的App一样。

不注意区分的话,我们讲到Chrome插件,往往指的就是以上三者之一。为了避免引起误解,本篇将严格区分概念,避免使用插件这种含糊的说法。

如何安装扩展

开发扩展,首先得从安装开始,从Chrome 21起,Chrome浏览器就增加了对扩展安装的限制,默认只允许从 Chrome Web Store (Chrome 网上应用店)安装扩展和应用,这意味着用户一般只能安装Chrome Web Store内的扩展和应用。

如果你拖动一个crx安装文件到Chrome浏览器的任何一个普通网页,将会出现如下提示。

扩展功能、应用和主题背景可能会损害您的计算机

点击继续按钮,则会在浏览器左上角弹出如下警告。

无法添加来自此网站的应用、扩展程序和用于脚本

如果你恰好在Github上发现一个不错的Chrome扩展程序,而Chrome Web Store中没有。是不是就没有办法安装呢?当然不是的,Chrome浏览器还有三种其它的方式可以加载扩展程序。

  • 如果是扩展程序源码目录,点击chrome://extensions/页面的加载已解压的扩展程序按钮就可以直接安装。

  • 如果是crx安装文件,直接拖动至chrome://extensions/页面即可安装。安装过程如下所示:

    1) 拖放安装

拖放以安装

2)点击添加扩展程序

添加扩展

3)添加好的扩展如下所示。

拖放安装后

  • 启动Chrome时添加参数--enable-easy-off-store-extension-install ,用以开启简单的扩展安装模式,然后就能像之前一样随意拖动crx文件到浏览器页面进行安装。

说到安装,自然有人会问,安装了某款扩展后,怎么查看该扩展的源码呢?Mac系统的用户请记住这个目录~/Library/Application Support/Google/Chrome/Default/Extensions/(windows的扩展目录暂无)。

扩展打包和更新

另外,中间的打包扩展程序按钮用于将本地开发的扩展程序打包成crx包,首次打包还会生成秘钥文件(如IHeader.pem),如下所示。

打包扩展程序

已打包好的扩展程序

打包好的扩展程序,可以发送给其他人安装,或发布到Chrome Web Store(开发者注册费用为5$)。

右边的立即更新扩展程序按钮则用于更新扩展。

扩展的基本组成

通常一个Chrome扩展包含如下资源或目录:

  • manifest.json入口配置文件(1个,位于根目录)
  • js文件(至少1个,位于根目录或子级目录)
  • 32px、64px、128px的方形icon各1个(位于根目录或子级目录)
  • _locales目录, 用于提供国际化支持(可选,位于根目录)
  • popup.html 弹出页面(可选,位于根目录或子级目录)
  • background.html 后台运行的页面,主要用于引入多个后台运行的js(可选,位于根目录或子级目录)
  • options.html 选项页面,用于扩展的设置(可选,位于根目录或子级目录)

为了方便管理,个人倾向于将HTML、JS、CSS,ICON等资源分类统一到同一个目录。

扩展的核心功能

从使用场景上看,Chrome扩展常用的有以下三类:

1)Browser Action,浏览器扩展,可通过manifest.json中的browser_action属性设置,如下所示。

"browser_action": {
  "default_title": "Qrcode",
  "default_icon": "images/icon.png",
  "default_popup": "index.html" // 可选的
},

以上是URL生成二维码的Browser Action扩展,运行如下所示:

Browser Action演示

该类扩展特点:全局扩展,icon长期占据浏览器右上角工具栏,每个页面均可用。

2)Page Action,页面级扩展,可通过manifest.json中的page_action属性设置,如下所示。

"page_action": {
  "default_title": "IHeader",
  "default_icon": "res/images/lightning_default.png",
  "default_popup": "res/index.html" // 可选的
},

以上是本篇将要讲解的Page Action的扩展——IHeader,它被指定为所有页面可见,其icon状态切换如下所示。

Page Action演示

该类扩展特点:不同页面可以拥有不同的状态和不同的icon,icon在指定的页面可见,可见时位于浏览器右上角工具栏。

由上可见,Browser Action与Page Action功能上非常相似,配置上各自的内部属性也完全一致,它们不仅可以配置点击时弹出的页面,同时还可以绑定点击事件,如下所示。

chrome.browserAction.onClicked.addListener(function(tab) { // Browser Action
  console.log(tab.id, tab.url);
});
chrome.pageAction.onClicked.addListener(function(tab) { // Page Action
  console.log(tab.id, tab.url);
});

如果非要说两者的差别,开发中能够感受到的就是:前者不需要维护icon状态,后者需要针对每个启用的页面管理不同的icon状态。

3)Omnibox,全能工具条,可通过manifest.json中的omnibox属性设置,如下所示。

"omnibox": {
  "keyword": "mdn-" //URL地址栏输入关键字"mdn-"+空格后,就会触发Omnibox
},

以上是MDN网站快捷查询的Omnibox扩展,运行如下所示:

Omnibox演示

该类扩展特点:运行在URL地址栏,无弹出界面,用户在输入时,扩展就可以显示建议或者自动完成一些工作。

很明显,你可以对地址栏的各种输入做定制,Chrome的URL地址栏只所以强大,omnibox可谓功不可没。

以上三类决定了扩展如何在浏览器中运行。除此之外,每个扩展程序还可以任意搭载如下页面或脚本。

  • Background Page,后台页面,可通过manifest.json中的background属性设置,里面再细分scriptpage,分别表示脚本和页面,如下所示。

    "background": {
      "persistent": true, // 默认为false,指定为true时将在后台持续运行
      "scripts": ["res/js/background.js"] // 指定后台运行的js
      // "page": ["res/background.html"]  // 指定后台运行的html,html中需引入若干个js
    },
    

    Background Page在扩展中之所以重要,主要归功于它可以使用所有的Chrome.* API。借助它popup.jscontent.js 可以随时进行消息通信,并且调用它们原本无法调用的API。

    根据persistent值是否为true,Background Page可分为两类:① Persistent Background Pages,② Event Pages。前者持续运行,随时可访问;后者只有在事件触发时才能访问。

    该页面特点:运行在浏览器后台,无用户界面,后台页面可用于页面间消息通信以及后台监控,一旦浏览器启动,后台页面就会自动运行。

  • Content Script,内容脚本,可通过manifest.json中的content_scripts属性设置,如下所示。

    "content_scripts": [{
      "all_frames": true, // 默认为false,指定为true意味着frame中也加载内容脚本
      "matches": ["\u003Call_urls>"], // 匹配所有URL,意味着任何页面都会加载
      "js": ["res/js/content.js"], // 指定运行的内容脚本
      "run_at": "document_end" // 页面加载完成后执行
    }],
    

    除了配置之外,内容脚本还可以通过js的方式动态载入。

    // 动态载入js文件
    chrome.tabs.executeScript(tabId, {file: 'res/js/content.js'});
    // 动态载入js语句
    chrome.tabs.executeScript(tabId, {code: 'alert("Hello Extension!")'});
    

    该脚本特点:每个页面在加载时都会加载内容脚本,加载时机可以指定为document_startidelend(分别为页面DOM加载开始时,空闲时及完成后);内容脚本是唯一可以访问页面DOM的脚本,通过它可以操作页面的DOM节点,从而影响视觉呈现;基于安全考虑,内容脚本被设计成与页面其他的JS存在于两个不同的沙盒,因此无法互相访问各自的全局变量。

  • Option Html,设置页面,可通过manifest.json中的options_page属性设置,如下所示。

    "options_page": "res/options.html",
    

    该页面特点:点击扩展程序icon的右键菜单上【选项】按钮进入到设置页面,该页面一般用于扩展的选项设置。

  • Override Html,替换新建标签页的空白页面,可通过manifest.json中的chrome_url_overrides属性设置,如下所示。

    "chrome_url_overrides":{
      "newtab": "blank.html"
    },
    

    该页面特点:常用于替换浏览器默认的空白标签页内容,多见于新开标签页时的壁纸程序,基于它你完全可以打造一个属于自己的空白页。

  • Devtool Page,控制台页面,可通过manifest.json中的devtools_page属性设置,如下所示。

    "devtools_page": "debug.html",
    

    该页面特点:随着控制台打开而启动,可用于将扩展收到的消息输出到当前控制台。

总之,对于Chrome扩展而言,Browser Action、Page Action 或 Omnibox之间是互斥的,其它情况下它并不限制你需要添加哪些页面或脚本,只要你愿意,就可以随意组合。

扩展如何运行调试

只要你会写js,就可以开发Chrome扩展程序了。涉及到开发,调试是不可避免的,Chrome扩展的调试也非常简单。我们都知道Chrome浏览器的 chrome://extensions/页面可以查看所有的Chrome扩展,不仅如此,该页面下的加载已解压的扩展程序按钮,便可以直接加载本地开发的扩展程序,如下所示。

加载已解压的扩展程序

注意:需要勾选开发者模式才会出现加载已解压的扩展程序按钮。

成功加载后的扩展跟正常安装的扩展程序,没有什么不同,接下来,我们就可以使用web技术进行调试了。

  • 点击以上的选项背景页按钮,将分别打开选项页面和背景页。选项页面是一个正常的html页面,按⌃+⌘+J 键打开控制台就可以调试了。背景页没有界面,打开的就是控制台。这两个页面都可以断点debug。
  • Browser Action 或 Page Action的扩展通常在Chrome浏览器的右上角会出现一个Icon,右键点击该Icon,点击右键菜单的审查弹出内容按钮,将会在打开弹出页面的同时打开它的控制台。这个控制台也可以直接debug。

Chrome Extension API

Chrome陆续向开发者开放了大量的API。使用这些API,我们可以监听或代理网络请求,存储数据,管理标签页和Cookie,绑定快捷键、设置右键菜单,添加通知和闹钟,获取CPU、电池、内存、显示器的信息等等(还有很多没有列举出来)。具体请阅读Chrome API官方文档。请注意,使用相应的API,往往需要申请对应的权限,如IHeader申请的权限如下所示。

"permissions": [ "tabs" , "webRequest", "webRequestBlocking", "http://*/*", "https://*/*", "contextMenus", "notifications"]

以上,IHeader依次申请了标签页、请求、请求断点、http网站,https网站,右键菜单,桌面通知的权限。

WebRequest API

Chrome Extension API中,能够修改请求的,只有chrome.webRequest了。webRequest能够为请求的不同阶段添加事件监听器,这些事件监听器,可以收集请求的详细信息,甚至修改或取消请求。

事件监听器只在特定阶段触发,它们的触发顺序如下所示。(图片来自MDN

事件监听器的含义如下所示。

  • onBeforeRequest,请求发送之前触发(请求的第1个事件,请求尚未创建,此时可以取消或者重定向请求)。
  • onBeforeSendHeaders,请求头发送之前触发(请求的第2个事件,此时可定制请求头,部分缓存等有关的请求头(Authorization、Cache-Control、Connection、Content-
    Length、Host、If-Modified-Since、If-None-Match、If-Range、Partial-Data、Pragma、Proxy-
    Authorization、Proxy-Connection和Transfer-Encoding)不出现在请求信息中,可以通过添加同名的key覆盖修改其值,但是不能删除)。
  • onSendHeaders,请求头发送之前触发(请求的第3个事件,此时只能查看请求信息,可以确认onBeforeSendHeaders事件中都修改了哪些请求头)。
  • onHeadersReceived,响应头收到之后触发(请求的第4个事件,此时可定制响应头,且只能修改或删除非缓存相关字段或添加字段,由于响应头允许多个同名字段同时存在,因此无法覆盖修改缓存相关的字段)。
  • onResponseStarted,响应内容开始传输之后触发(请求的第5个事件,此时只能查看响应信息,可以确认onHeadersReceived事件中都修改了哪些响应头)。
  • onCompleted,响应接受完成后触发(请求的第6个事件,此时只能查看响应信息)。
  • onBeforeRedirect,onHeadersReceived事件之后,请求重定向之前触发(此时只能查看响应头信息)。
  • onAuthRequired,onHeadersReceived事件之后,收到401或者407状态码时触发(此时可以取消请求、同步提供凭证或异步提供凭证)。

以上,凡是能够修改请求的事件监听器,都能够指定其extraInfoSpec参数数组中包含”blocking”字符串(意味着能阻塞请求并修改),反之则不行。

另外请注意,Chrome对于请求头和响应头的展示有着明确的规定,即控制台中只展示发送出去或刚接收到的字段。因此编辑后的请求字段,控制台的network栏能够正常展示;而编辑后的响应字段由于不属于刚接收到的字段,所以从控制台上就会看不到编辑的痕迹,如同没修改过一样,实际上编辑仍然有效。

事件监听器含义虽不同,但语法却一致。接下来我们就以onHeadersReceived为例,进行深入分析。

如何绑定header监听

还记得我们的目标吗?想要去掉Google网站HTML响应头的X-Frame-Options字段。请看如下代码:

// 监听的回调
var callback = function(details) {
  var headers = details.responseHeaders;
  for (var i = 0; i < headers.length; ++i) {
    // 移除X-Frame-Options字段
    if (headers[i].name === 'X-Frame-Options') {
      headers.splice(i, 1);
      break;
    }
  }
  // 返回修改后的headers列表
  return { responseHeaders: headers };
};
// 监听哪些内容
var filter = {
  urls: ["<all_urls>"]
};
// 额外的信息规范,可选的
var extraInfoSpec = ["blocking", "responseHeaders"];
/* 监听response headers接收事件*/
chrome.webRequest.onHeadersReceived.addListener(callback, filter, extraInfoSpec);

chrome.webRequest.onHeadersReceived.addListener表示添加一个接收响应头的监听。以上代码中的关键参数或属性,下面逐一讲解。

  • callback,即事件触发时的回调,该回调默认传入一个参数(details),details就是请求的详情。
  • filter,Object类型,限制事件回调callback触发的过滤器。filter有四个属性可以指定,分别为①urls(包含指定url的数组)、②types(请求的类型,共8种)、③tabId(标签页id)、④windowId(窗口id)。
  • extraInfoSpec,数组类型,指的是额外的选项列表。对于headersReceived事件而言,包含”blocking”,意味着要求请求同步,基于此才可以修改响应头;包含”responseHeaders”意味着事件回调的默认参数details中将包含responseHeaders字段,该字段指向响应头列表。

既然有了添加监听的方法,自然,还会有移除监听的方法。

chrome.webRequest.onHeadersReceived.removeListener(listener);

除此之外,为了避免重复监听,还可以判断监听是否已经存在。

var bool = chrome.webRequest.onHeadersReceived.hasListener(listener);

为了保证更好的理清以上属性、方法或参数的逻辑关系,请看如下脑图:

headersReceived事件

扩展状态管理

监听器的状态管理

知道了如何绑定监听器,仅仅是第一步。监听器需要在合适的时机绑定,也需要在合适的时机解绑。为了不影响Chrome的访问速度,我们只在需要的标签页创建新的监听器,因此监听器需要依赖filter来区分不同的tabId,考虑到用户可能只需要监听一部分请求类型,types的区分也是不可避免的。又由于一个Tab里不同的时间段可能会加载不同的页面,一个监听器在不同的页面下正常运行也是必须的(因此监听器的filter中不需要指定urls)。

寥寥数语,可能不足以描述出监听器状态管理的原貌,请看下图进一步帮助理解。

页面监听器

以上,一个请求将依次触发上述①②③④⑤五个事件回调,每个事件回调都对应着一个监听器,这些监听器分为两类(从颜色上也可看出端倪)。

  • ②③⑤监听器的主要功能是记录,用于监听页面上每一个Request的请求头和响应头,以及请求响应时间。
  • ①④监听器的主要功能是更新,用于增加、删除或修改指定Request的请求头和响应头。

若Chrome指定的标签页激活了IHeader扩展,②③⑤监听器就会记录当前标签页后续的指定类型的请求信息。若用户在激活了IHeader扩展的标签页更新了Request的请求头或响应头,①或④监听器就会被开启。不用担心监听器开启无限个,我准备了回收机制,单个标签页的所有监听器都会在标签页关闭或IHeader扩展取消激活后释放掉。

首先,为方便管理,先封装下监听器的代码。

/* 独立的监听器 */
var Listener = (function(){
  var webRequest = chrome.webRequest;

  function Listener(type, filter, extraInfoSpec, callback){
    this.type = type; // 事件名称
    this.filter = filter; // 过滤器
    this.extraInfoSpec = extraInfoSpec; // 额外的参数
    this.callback = callback; // 事件回调
    this.init();
  }
  Listener.prototype.init = function(){
    webRequest[this.type].addListener( // 添加一个监听器
      this.callback,
      this.filter,
      this.extraInfoSpec
    );
    return this;
  };
  Listener.prototype.remove = function(){
    webRequest[this.type].removeListener(this.callback); // 移除监听器
    return this;
  };
  Listener.prototype.reload = function(){ // 重启监听器(用于选项页面更新请求类型后重启所有已开启的监听器)
    this.remove().init();
    return this;
  };
  return Listener;
})();

监听器封装好了,剩下的便是管理,监听器控制器基于标签页的维度统一管理标签页上所有的监听器,代码如下。

/* 监听器控制器 */
var ListenerControler = (function(){
  var allListeners = {}; /* 所有的监听器控制器列表 */
  function ListenerControler(tabId){
    if(allListeners[tabId]){ /* 如有就返回已有的实例 */
      return allListeners[tabId];
    }
    if(!(this instanceof ListenerControler)){ /* 强制以构造器方式调用 */
      return new ListenerControler(tabId);
    }

    /* 初始化变量 */
    var _this = this;
    var filter = getFilter(tabId); // 获取当前监听的filter设置
    /* 捕获requestHeaders */
    var l1 = new Listener('onSendHeaders', filter, ['requestHeaders'], function(details){
      _this.saveMesage('request', details); // 记录请求的头域信息
    });
    /* 捕获responseHeaders */
    var l2 = new Listener('onResponseStarted', filter, ['responseHeaders'], function(details){
      _this.saveMesage('response', details); // 记录响应的头域信息
    });
    /* 捕获 Completed Details */
    var l3 = new Listener('onCompleted', filter, ['responseHeaders'], function(details){
      _this.saveMesage('complete', details); // 记录请求完成时的时间等信息
    });

    allListeners[tabId] = this; // 记录当前的标签页控制器
    this.tabId = tabId;
    this.listeners = {  // 记录已开启的监听器
      'onSendHeaders': l1,
      'onResponseStarted': l2,
      'onCompleted': l3
    };
    this.messages = {}; // 当前标签页的请求信息集合
    console.log('tabId=' + tabId + ' listener on');
  }
  ListenerControler.has = function(tabId){...} // 判断是否包含指定标签页的控制器
  ListenerControler.get = function(tabId){...} // 返回指定标签页的控制器
  ListenerControler.getAll = function(){...} // 获取所有的标签页控制器
  ListenerControler.remove = function(tabId){...} // 移除指定标签页下的所有监听器
  ListenerControler.prototype.remove = function(){...} // 移除当前控制器中的所有监听器
  ListenerControler.prototype.saveMesage = function(type, message){...} // 记录请求信息
  return ListenerControler;
})();

通过监听器控制器的统一调度,标签页中的多个监听器才能高效的工作。

实际上,还有很多工作,上述代码还没有体现出来。比方说用户在激活了IHeader扩展的标签页更新了Request的请求头或响应头,①beforeSendHeaders或④headersReceived监听器又是怎么运作的呢?这部分内容,请结合『如何绑定header监听』节点的内容理解。

Page Action图标状态管理

标签页控制器的状态需要由视觉体现出来,因此Page Action图标的管理也是不可避免的。通常,默认的icon可以在manifest.json中指定。

"page_action": {
  "default_icon": "res/images/lightning_default.png", // 默认图标
},

icon有如下3种状态(后两种状态可以互相切换)。

  • 默认状态,展示默认的icon。
  • 初始状态,展示扩展初始化后的icon。
  • 激活状态,展示扩展激活后的icon。

Chrome提供了chrome.pageAction的API供Page Action使用。目前chrome.pageAction拥有如下方法。

  • show,在指定的tab下展示Page Action。
  • hide,在指定的tab下隐藏Page Action。
  • setTitle,设置Page Action的标题(鼠标移动到该Page Action上时会出现设置好的标题提示)
  • getTitle,获取Page Action的标题。
  • setIcon,设置Page Action的图标。
  • setPopup,设置点击时弹出页面的URL。
  • getPopup,获取点击时弹出页面的URL。

以上,setTitle、setIcon 和 show方法比较常用。其中,show方法有两种作用,①展示icon,②更新icon,因此一般是先设置好icon的标题和路径,然后调用show展示出来(或更新)。需要注意的是,Page Action在show方法被调用之前,是不会响应点击的,所以需要在初始化工作结束之前调用show方法。千言万语不如上代码,如下。

/* 声明3种icon状态 */
var UNINIT = 0, // 扩展未初始化
    INITED = 1, // 扩展已初始化,但未激活
    ACTIVE = 2; // 扩展已激活
/* 处理扩展icon状态 */
var PageActionIcon = (function(){
  var pageAction = chrome.pageAction, icons = {}, tips = {};
  icons[INITED] = 'res/images/lightning_green.png'; // 设置不同状态下的icon路径(相对于扩展根目录)
  icons[ACTIVE] = 'res/images/lightning_red.png';

  tips[INITED] = Text('iconTips'); // 其它地方有处理,Text被指向chrome.i18n.getMessage,用以读取_locales中指定语言的对应字段的文本信息
  tips[ACTIVE] = Text('iconHideTips');

  function PageActionIcon(tabId){ // 构造器
    this.tabId  = tabId;
    this.status = UNINIT; // 默认为未初始化状态
    pageAction.show(tabId); // 展示Page Action
  }
  PageActionIcon.prototype.init = function(){...} // 初始化icon
  PageActionIcon.prototype.active = function(){...} // icon切换为激活状态
  PageActionIcon.prototype.hide = function(){...} // 隐藏icon
  PageActionIcon.prototype.setIcon = function(){ // 设置icon
    pageAction.setIcon({ // 设置icon的路径
      tabId : this.tabId,
      path  : icons[this.status]
    });
    pageAction.setTitle({ // 设置icon的标题
      tabId : this.tabId,
      title : tips[this.status]
    });
    return this;
  };
  PageActionIcon.prototype.restore = function(){// 刷新页面后,icon之前的状态会丢失,需要手动恢复
    this.setIcon();
    pageAction.show(this.tabId);
    return this;
  };
  return PageActionIcon;
})();

icon管理的准备工作ok了,剩下的就是使用了,如下。

new PageActionIcon(this.tabId).init();

标签页的状态管理

对于IHeader扩展程序,一个标签页同时包含了监听器状态和icon状态的变化。因此需要再抽象出一个标签页控制器,对两者进行统一管理,从而供外部调用。代码如下。

/* 处理标签页状态 */
var TabControler = (function(){
  var tabs = {}; // 所有的标签页控制器列表
  function TabControler(tabId, url){
    if(tabs[tabId]){ /* 如有就返回已有的实例 */
      return tabs[tabId];
    }
    if(!(this instanceof TabControler)){ /* 强制以构造器方式调用 */
      return new TabControler(tabId);
    }
    /* 初始化属性 */
    tabs[tabId] = this;
    this.tabId = tabId;
    this.url    = url;
    this.init();
  }
  TabControler.get = function(tabId){...} // 获取指定的标签页控制器
  TabControler.remove = function(tabId){
    if(tabs[tabId]){
      delete tabs[tabId]; // 移除指定的标签页控制器
      ListenerControler.remove(tabId); // 移除指定的监听器控制器
    }
  };
  TabControler.prototype.init = function(){...} // 初始化标签页控制器
  TabControler.prototype.switchActive = function(){ // 当前标签页状态切换
    var icon = this.icon;
    if(icon){
      var status = icon.status;
      var tabId = this.tabId;
      switch(status){
        case ACTIVE: // 如果是激活状态,则恢复初始状态,移除监听器控制器
          icon.init(); 
          ListenerControler.remove(tabId);
          Message.send(tabId, 'ListeningCancel'); // 通知内容脚本从而在控制台输出取消提示(后续将讲到消息通信)
          break;
        default: // 如果不是激活状态,则激活之,添加监听器控制器
          icon.active();
          ListenerControler(tabId);
          Message.send(tabId, 'Listening'); // 并通知内容脚本从而在控制台输出监听提示
      }
    }
    return this;
  };
  TabControler.prototype.restore = function(){...} // 恢复标签页控制器的状态(针对页面刷新场景)
  TabControler.prototype.remove = function(){...} // 移除标签页控制器
  return TabControler;
})();

标签页控制器的抽象,有助于封装扩展的内部运行细节,方便了后续各种场景中对扩展的管理 。

标签页关闭或更新的妥善处理

标签页关闭或更新时,为了避免内存泄露和运行稳定,部分数据需要释放或者同步。刚刚封装好的标签页控制器就可以用来做这件事。

首先,Tab关闭时需要释放当前标签页的控制器和监听器对象。

/* 监听tab关闭的事件 */
chrome.tabs.onRemoved.addListener(function(tabId, removeInfo){
  TabControler.remove(tabId); // 释放内存,移除标签页控制器和监听器
});

其次,每次Tab在执行跳转或刷新动作时,Page Action的icon都会回到初始状态并且不可点击,此时需要恢复icon之前的状态。

/* 监听tab更新的事件、包含跳转或刷新的动作 */
chrome.tabs.onUpdated.addListener(function(tabId, changeInfo){
  if(changeInfo.status === 'loading'){ // 页面处于loading时触发
    TabControler(tabId).restore(); // 恢复icon状态
  }
});

以上,页面跳转或刷新时,changeInfo将依次经历两种状态:loadingcomplete(部分页面会包含favIconUrltitle信息),如下所示。

changeInfo

随着状态管理的逐渐完善,那么,是时候进行消息通信了(不知道你注意到上述代码中出现的Message对象没有?它就是消息处理的对象)。

消息通信

扩展内部消息通信

Chrome扩展内的各页面之间的消息通信,有如下四种方式(以下接口省略chrome前缀)。

类型 消息发送 消息接收 支持版本
一次性消息 extension.sendRequest extension.onRequest v33起废弃(早期方案)
一次性消息 extension.sendMessage extension.onMessage v20+(不建议使用)
一次性消息 runtime.sendMessage runtime.onMessage v26+(现在主流,推荐使用)
长期连接 runtime.connect runtime.onConnect v26+

目前以上四种方案都可以使用。其中extension.sendRequest发送的消息,只有extension.onRequest才能接收到(已废弃不建议使用,可选读Issue 9965005)。extension.sendMessageruntime.sendMessage 发送的消息,虽然extension.onMessageruntime.onMessage都可以接收,但是runtime api的优先触发。若多个监听同时存在,只有第一个响应才能触发消息的sendResponse回调,其他响应将被忽略,如下所述。

If multiple pages are listening for onMessage events, only the first to call sendResponse() for a particular event will succeed in sending the response. All other responses to that event will be ignored.

我们先看一次性的消息通信,它的基本规律如下所示。

一次性消息通信图示

图中出现了一种新的消息通信方式,即chrome.extension.getBackgroundPage,通过它能够获取background.js(后台脚本)的window对象,从而调用window下的任意全局方法。严格来说它不是消息通信,但是它完全能够胜任消息通信的工作,之所以出现在图示中,是因为它才是消息从popup.html到background.js的主流沟通方式。那么你可能会问了,为什么content.js中不具有同样的API呢?

这是因为它们的使用方式不同,各自的权限也不同。popup.html或background.js中chrome.extension对象打印如下:

chrome.extension对象

content.js中chrome.extension对象打印如下:

content.js下的chrome.extension对象

可以看出,前者包含了全量的属性,后者只保留少量的属性。content.js中并没有chrome.extension.getBackgroundPage方法,因此content.js不能直接调用background.js中的全局方法。

回到消息通信的话题,请看消息发送和监听的简单示例,如下所示:

// 消息流:弹窗页面、选项页面 或 background.js --> content.js
// 由于每个tab都可能加载内容脚本,因此需要指定tab
chrome.tabs.query( // 查询tab
  { active: true, currentWindow: true }, // 获取当前窗口激活的标签页,即当前tab
  function(tabs) { // 获取的列表是包含一个tab对象的数组
    chrome.tabs.sendMessage( // 向tab发送消息
      tabs[0].id, // 指定tab的id
      { message: 'Hello content.js' }, // 消息内容可以为任意对象
      function(response) { // 收到响应后的回调
        console.log(response);
      }
    );
  }
);

/* 消息流:
 * 1. 弹窗页面或选项页面 --> background.js
 * 2. background.js --> 弹窗页面或选项页面
 * 3. content.js --> 弹窗页面、选项页面 或 background.js
 */
chrome.runtime.sendMessage({ message: 'runtime-message' }, function(response) {
  console.log(response);
});

// 可任意选用runtime或extension的onMessage方法监听消息
chrome.runtime.onMessage.addListener( // 添加消息监听
  function(request, sender, sendResponse) { // 三个参数分别为①消息内容,②消息发送者,③发送响应的方法
    console.log(sender.tab ?
                "from a content script:" + sender.tab.url :
                "from the extension");
    if (request.message === 'Hello content.js'){
      sendResponse({ answer: 'goodbye' }); // 发送响应内容
    }
    // return true; // 如需异步调用sendResponse方法,需要显式返回true
  }
);
一次性消息通信API

上述涉及到的API语法如下:

  • chrome.tabs.query(object queryInfo, function callback),查询符合条件的tab。其中,callback为查询结果的回调,默认传入tabs列表作为参数;queryInfo为标签页的描述信息,包含如下属性。
属性 类型 支持性 描述
active boolean tab是否激活
audible boolean v45+ tab是否允许声音播放
autoDiscardable boolean v54+ tab是否允许被丢弃
currentWindow boolean v19+ tab是否在当前窗口中
discarded boolean v54+ tab是否处于被丢弃状态
highlighted boolean tab是否高亮
index Number v18+ tab在窗口中的序号
muted boolean v45+ tab是否静音
lastFocusedWindow boolean v19+ tab是否位于最后选中的窗口中
pinned boolean tab是否固定
status String tab的状态,可选值为loadingcomplete
title String tab中页面的标题(需要申请tabs权限)
url String or Array tab中页面的链接
windowId Number tab所处窗口的id
windowType String tab所处窗口的类型,值包含normalpopuppanelappordevtools

注:丢弃的tab指的是tab内容已经从内存中卸载,但是tab未关闭。

  • chrome.tabs.sendMessage(integer tabId, any request, object options, function responseCallback),向指定tab下的content.js发送单次消息。其中tabId为标签页的id,request为消息内容,options参数从v41版开始支持,通过它可以指定frameId的值,以便向指定的frame发送消息,responseCallback即收到响应后的回调。
  • chrome.runtime.sendMessage(string extensionId, any message, object options, function responseCallback),向扩展内或指定的其他扩展发送消息。其中extensionId为其他指定扩展的id,扩展内通信可以忽略该参数,message为消息内容,options参数从v32版开始支持,通过它可以指定includeTlsChannelId(boolean)的值,以便决定TLS通道ID是否会传递到onMessageExternal事件监听回调中,responseCallback即收到响应后的回调。
  • chrome.runtime.onMessage.addListener(function callback),添加单次消息通信的监听。其中callback类似function(any message, MessageSender sender, function sendResponse) {…}这种函数,message为消息内容,sender即消息发送者,sendResponse用于向消息发送者回复响应,如果需要异步发送响应,请在callback回调中return true(此时将保持消息通道不关闭直到sendResponse方法被调用)。

综上,我们选用chrome.runtime api即可完美的进行消息通信,对于v25,甚至v20以下的版本,请参考以下兼容代码。

var callback = function(message, sender, sendResponse) {
  // Do something
});
var message = { message: 'hello' }; // message
if (chrome.extension.sendMessage) { // chrome20+
  var runtimeOrExtension = chrome.runtime && chrome.runtime.sendMessage ? 'runtime' : 'extension';
  chrome[runtimeOrExtension].onMessage.addListener(callback); // bind event
  chrome[runtimeOrExtension].sendMessage(message); // send message
} else { // chrome19-
  chrome.extension.onRequest.addListener(callback); // bind event
  chrome.extension.sendRequest(message); // send message
}
长期连接消息通信

想必,一次性的消息通信你已经驾轻就熟了。如果是频繁的通信呢?此时,一次性的消息通信就显得有些复杂。为了满足这种频繁通信的需要,Chrome浏览器专门提供了Chrome.runtime.connect API。基于它,通信的双方就可以建立长期的连接。

长期连接基本规律如下所示:

一次性消息通信图示

以上,与上述一次性消息通信一样,长期连接也可以在popup.html、background.js 和 content.js三者中两两之间建立(注意:无论何时主动与content.js建立连接,都需要指定tabId)。如下是popup.html与content.js之间建立长期连接的举例🌰。

// popup.html 发起长期连接
chrome.tabs.query(
  {active: true, currentWindow: true}, // 获取当前窗口的激活tab
  function(tabs) {
    // 建立连接,如果是与background.js建立连接,应该使用chrome.runtime.connect api
    var port = chrome.tabs.connect( // 返回Port对象
      tabs[0].id, // 指定tabId
      {name: 'call2content.js'} // 连接名称
    );
    port.postMessage({ greeting: 'Hello' }); // 发送消息
    port.onMessage.addListener(function(msg) { // 监听消息
      if (msg.say == 'Hello, who\'s there?') {
        port.postMessage({ say: 'Louis' });
      } else if (msg.say == "Oh, Louis, how\'s it going?") {
        port.postMessage({ say: 'It\'s going well, thanks. How about you?' });
      } else if (msg.say == "Not good, can you lend me five bucks?") {
        port.postMessage({ say: 'What did you say? Inaudible? The signal was terrible' });
        port.disconnect(); // 断开长期连接
      }
    });
  }
);

// content.js 监听并响应长期连接
chrome.runtime.onConnect.addListener(function(port) { // 监听长期连接,默认传入Port对象
  console.assert(port.name == "call2content.js"); // 筛选连接名称
  console.group('Long-lived connection is established, sender:' + JSON.stringify(port.sender));
  port.onMessage.addListener(function(msg) {
    var word;
    if (msg.greeting == 'Hello') {
      word = 'Hello, who\'s there?';
      port.postMessage({ say: word });
    } else if (msg.say == 'Louis') {
      word = 'Oh, Louis, how\'s it going?';
      port.postMessage({ say: word });
    } else if (msg.say == 'It\'s going well, thanks. How about you?') {
      word = 'Not good, can you lend me five bucks?';
      port.postMessage({ say: word });
    } else if (msg.say == 'What did you say? Inaudible? The signal was terrible') {
      word = 'Don\'t hang up!';
      port.postMessage({ say: word });
    }
    console.log(msg);
    console.log(word);
  });
  port.onDisconnect.addListener(function(port) { // 监听长期连接的断开事件
    console.groupEnd();
    console.warn(port.name + ': The phone went dead');
  });
});

控制台输出如下:

扩展内长期连接消息通信输出

建立长期连接涉及到的API语法如下:

  • chrome.tabs.connect(integer tabId, object connectInfo),与content.js建立长期连接。tabId为标签页的id,connectInfo为连接的配置信息,可以指定两个属性,分别为name和frameId。name属性指定连接的名称,frameId属性指定tab中唯一的frame去建立连接。
  • chrome.runtime.connect(string extensionId, object connectInfo),发起长期的连接。其中extensionId为扩展的id,connectInfo为连接的配置信息,目前可以指定两个属性,分别是name和includeTlsChannelId。name属性指定连接的名称,includeTlsChannelId属性从v32版本开始支持,表示TLS通道ID是否会传递到onConnectExternal的监听器中。
  • chrome.runtime.onConnect.addListener(function callback),监听长期连接的建立。callback为连接建立后的事件回调,该回调默认传入Port对象,通过Port对象可进行页面间的双向通信。Port对象结构如下:
属性 类型 描述
name String 连接的名称
disconnect Function 立即断开连接(已经断开的连接再次调用没有效果,连接断开后将不会收到新的消息)
onDisconnect Object 断开连接时触发(可添加监听器)
onMessage Object 收到消息时触发(可添加监听器)
postMessage Function 发送消息
sender MessageSender 连接的发起者(该属性只会出现在连接监听器中,即onConnect 或onConnectExternal中)

扩展程序间消息通信

相对于扩展内部的消息通信而言,扩展间的消息通信更加简单。对于一次性消息通信,共涉及到如下两个API:

  • chrome.runtime.sendMessage,之前讲过,需要特别指定第一个参数extensionId,其它不变。
  • chrome.runtime.onMessageExternal,监听其它扩展的消息,用法与chrome.runtime.onMessage一致。

对于长期连接消息通信,共涉及到如下两个API:

  • chrome.runtime.connect,之前讲过,需要特别指定第一个参数extensionId,其它不变。
  • chrome.runtime.onConnectExternal,监听其它扩展的消息,用法与chrome.runtime.onConnect一致。

发送消息可参考如下代码:

var extensionId = "oknhphbdjjokdjbgnlaikjmfpnhnoend"; // 目标扩展id
// 发起一次性消息通信
chrome.runtime.sendMessage(extensionId, { message: 'hello' }, function(response) {
  console.log(response);
});
// 发起长期连接消息通信
var port = chrome.runtime.connect(extensionId, {name: 'web-page-messages'});
port.postMessage({ greeting: 'Hello' });
port.onMessage.addListener(function(msg) {
  // 通信逻辑见『长期连接消息通信』popup.html示例代码
});

监听消息可参考如下代码:

// 监听一次性消息
chrome.runtime.onMessageExternal.addListener( function(request, sender, sendResponse) {
  console.group('simple request arrived');
  console.log(JSON.stringify(request));
  console.log(JSON.stringify(sender));
  sendResponse('bye');
});
// 监听长期连接
chrome.runtime.onConnect.addListener(function(port) {
  console.assert(port.name == "web-page-messages");
  console.group('Long-lived connection is established, sender:' + JSON.stringify(port.sender));
  port.onMessage.addListener(function(msg) {
    // 通信逻辑见『长期连接消息通信』content.js示例代码
  });
  port.onDisconnect.addListener(function(port) {
    console.groupEnd();
    console.warn(port.name + ': The phone went dead');
  });
});

控制台输出如下:

扩展间消息通信输出

Web页面与扩展间消息通信

除了扩展内部和扩展之间的通信,Web pages 也可以与扩展进行消息通信(单向)。这种通信方式与扩展间的通信非常相似,共需要如下三步便可以通信。

首先,manifest.json指定可接收页面的url规则。

"externally_connectable": {
  "matches": ["https://developer.chrome.com/*"]
}

其次,Web pages 发送信息,比如说在 https://developer.chrome.com/extensions/messaging 页面控制台执行以上『扩展程序间消息通信』小节——消息发送的语句。

最后,扩展监听消息,代码同以上『扩展程序间消息通信』小节——消息监听部分。

至此,扩展程序的消息通信聊得差不多了。基于以上内容,你完全可以自行封装一个message.js,用于简化消息通信。实际上,阅读模式扩展程序就封装了一个message.js,IHeader扩展中的消息通信便基于它。

设置快捷键

一般涉及到状态切换的,快捷键能有效提升使用体验。为此我也为IHeader添加了快捷键功能。

为扩展程序设置快捷键,共需要两步。

  1. manifest.json中添加commands声明(可以指定多个命令)。

    "commands": { // 命令
      "toggle_status": { // 命令名称
        "suggested_key": { // 指定默认的和各个平台上绑定的快捷键
          "default": "Alt+H", 
          "windows": "Alt+H",
          "mac": "Alt+H",
          "chromeos": "Alt+H",
          "linux": "Alt+H"
        }, 
        "description": "Toggle IHeader" // 命令的描述
      }
    },
    
  2. background.js中添加命令的监听。

    /* 监听快捷键 */
    chrome.commands.onCommand.addListener(function(command) {
      if (command == "toggle_status") { // 匹配命令名称
        // 查询当前激活tab
        chrome.tabs.query({active: true, currentWindow: true}, function(tabs) { 
          var tab = tabs[0];
          tab && TabControler(tab.id, tab.url).switchActive(); // 切换tab控制器的状态
        });
      }
    });
    

以上,按下Alt+H键,便可以切换IHeader扩展程序的监听状态了。

设置快捷键时,请注意Mac与Windows、linux等系统的差别,Mac既有Ctrl键又有Command键。另外,若设置的快捷键与Chrome的默认快捷键冲突,那么设置将静默失败,因此请记得绕过以下Chrome快捷键(KeyCue是查看快捷键的应用,请忽略之)。

Chrome快捷键

添加右键菜单

除了快捷键外,还可以为扩展程序添加右键菜单,如IHeader的右键菜单。

IHeader右键菜单演示

为扩展程序添加右键菜单,共需要三步。

  1. 申请菜单权限,需在manifest.json的permissions属性中添加”contextMenus”权限。

    "permissions": ["contextMenus"]
    
  2. 菜单需在background.js中手动创建。

    chrome.contextMenus.removeAll();     // 创建之前建议清空菜单
    chrome.contextMenus.create({         // 创建右键菜单
      title: '切换Header监听模式',         // 指定菜单名称
      id: 'contextMenu-0',                 // 指定菜单id
      contexts: ['all']                 // 所有地方可见
    });
    

    由于chrome.contextMenus.create(object createProperties, function callback)方法默认返回新菜单的id,因此它通过回调(第二个参数callback)来告知是否创建成功,而第一个参数createProperties则为菜单项指定配置信息。

  3. 绑定右键菜单的功能。

    chrome.contextMenus.onClicked.addListener(function (menu, tab){
      TabControler(tab.id, tab.url).switchActive();
    });
    

安装或更新

Chrome为扩展程序提供了丰富的API,比如说,你可以监听扩展安装或更新事件,进行一些初始化处理或给予友好的提示,如下。

/* 安装提示 */
chrome.runtime.onInstalled.addListener(function(data){
  if(data.reason == 'install' || data.reason == 'update'){
    chrome.tabs.query({}, function(tabs){
      tabs.forEach(function(tab){
        TabControler(tab.id).restore(); // 恢复所有tab的状态
      });
    });
    // 初始化时重启全局监听器 ...
    // 动态载入Notification js文件
    setTimeout(function(){
      var partMessage = data.reason == 'install' ? '安装成功' : '更新成功';
      chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
        var tab = tabs[0];
        if (!/chrome:\/\//.test(tab.url)){ // 只能在url不是"Chrome:// URL"开头的页面注入内容脚本
          chrome.tabs.executeScript(tab.id, {file: 'res/js/notification.js'}, function(){
            chrome.tabs.executeScript(tab.id, {code: 'notification("IHeader'+ partMessage +'")'}, function(log){
              log[0] && console.log('[Notification]: 成功弹出通知');
            });
          });
        } else {
          console.log('[Notification]: Cannot access a chrome:// URL');
        }
      });
    },1000); // 延迟1s的目的是为了调试时能够及时切换到其他的tab下,从而弹出Notification。
    console.log('[扩展]:', data.reason);
  }
});

以上,chrome.tabs.executeScript(integer tabId, object details)接口,用于动态注入内容脚本,且只能在url不是”Chrome:// URL”开头的页面注入。其中tabId参数用于指定目标标签页的id,details参数用于指定内容脚本的路径或语句,它的file属性指定脚本路径,code属性指定动态语句。若分别往同一个标签页注入多个脚本或语句,这些注入的脚本或语句处于同一个沙盒,即全局变量可以共享。

notification.js如下所示。

function notification(message) {
  if (!('Notification' in window)) { // 判断浏览器是否支持Notification功能
    console.log('This browser does not support desktop notification');
  } else if (Notification.permission === "granted") { // 判断是否授予通知的权限
    new Notification(message); // 创建通知
    return true;
  } else if (Notification.permission !== 'denied') { // 首次向用户申请权限
    Notification.requestPermission(function (permission) { // 申请权限
      if (permission === "granted") { // 用户授予权限后, 弹出通知
        new Notification(message); // 创建通知
        return true;
      }
    });
  }
}

最终弹出通知如下。

Notification

国际化

为了让全球都能使用你开发的扩展,国际化是必须的。从软件工程的角度讲,国际化就是将产品用户界面中可见的字符串全部存放在资源文件中,然后根据用户所处不同的语言环境,展示相应语言的视觉信息。Chrome从v17版本开始就提供了国际化标准API——chrome.i18n。i18n即internationalization(国际化),由于i和n中间共计18个字母,故简称为i18n。

Chrome扩展预留了_locales目录,用于存放多种语言版本的资源文件——message.json。目录结构为 “_locales/locales_code/message.json”,如下所示:

_locales
|-- en
    |-- message.json
|-- zh_CN
    |-- message.json

locales_code不仅包含以上举例的en(英文)、zh_CN(简体中文)等,还包含全球多种其它语言,具体请参考Choosing locales to support,对于不支持的locale,Chrome会自动忽略。

message.json资源文件如下所示,其中key为关键字,其message属性指定了它对应的值,description属性用于描述该key。

{
  "key": {
    "message": "the value for the key",
    "description": "the description for the key"
  },
  ...
}

根据i18n的官网文档

Important: If an extension has a _locales directory, the manifest must define “default_locale”.

一旦扩展中有了_locales目录,那么就必须要在manifest.json中指定”default_locale”,如下所示。

"default_locale": "en",

如何引用国际化字符串

  • 如需在manifest.json或CSS文件中引用一个名称为”key”的字符串,如下所示:

    __MSG_key__
    
  • 如需在扩展程序的JS中引用key对应的字符串,则需要借助chrome.i18n.getMessage(string messageName, any substitutions)这个API。其中messageName指的是信息的关键字(key),substitutions数组用于存放字符串待替换字符对应的值(该参数可选,且最多不超过9个替换值)。使用如下所示:

    chrome.i18n.getMessage("key");
    

    如果获取不到key对应的值,chrome.i18n.getMessage将返回空字符串"";若messageName不是字符串或者substitutions数组长度超过9,那么该方法将返回undefined

    那么,如何为message.json添加含有占位符的字符串呢?如下就以IHeader中message.json的代码做测试:

    "iconTips": {
      "message": "进入Header监听模式 $a$ $b$",
      "placeholders": {
        "a": {
          "content": "$1"
        },
        "b": {
          "content": "$2"
        }
      }
    },
    

    如上,占位符格式为$key$$key$为字符串待注入标示, key是注入点名称,它需要在placeholders配置中指定第几个substitutions的值注入到这里。如上所述,注入点a的内容指定为$1,即第一个替换的值注入到a所在的位置,注入点b的内容指定为$2,即第二个替换的值注入到b所在的位置,以此类推。

    实际上,我们有如下两种方式去注入。

    // 替换注入点a为"apple",如果只是替换一个占位点的话,传入数组或字符串都行
    chrome.i18n.getMessage('iconTips', 'apple'); 
    chrome.i18n.getMessage('iconTips', ['apple']);
    
    // 替换注入点a为"apple",替换b为"oranges",对于两个或以上的点位的替换,substitutions类型只能为数组
    chrome.i18n.getMessage('iconTips', ['apple', 'oranges']);
    

实际效果如图:

通过占位符注入内容

以上引用过程,如下所示(图片来自MDN):

国际化字符串引用图示

预定义消息

以上,提供了这些API还不够,国际化系统还提供了一些预定义的消息,它们如下。

Message Name Description
@@extension_id 扩展ID,可用于拼接链接,即使没有国际化的扩展也可用,注意不能用于manifest.json文件。
@@ui_locale 当前语言,可用于拼接本地化的链接。
@@bidi_dir 当前语言的文字方向,包含ltrrtl,分别为从左到右、从右到左。
@@bidi_reversed_dir 若@@bidi_dir值为ltr,则它的值为rtl,否则为ltr
@@bidi_start_edge 若@@bidi_dir值为rtl,则它的值为left,否则为right
@@bidi_end_edge 若@@bidi_dir值为ltr,则它的值为right,否则为left

预定义的消息可在Chrome扩展的JavaScript和CSS中使用,如下。

var extensionId = chrome.i18n.getMessage('@@extension_id');
location.href = 'chrome-extension://' + extensionId + '/res/options.html';
body {
  direction: __MSG_@@bidi_dir__;
  background-image:url('chrome-extension://__MSG_@@extension_id__/background.png');
}
div {
  padding-__MSG_@@bidi_start_edge__: 5px;
  padding-__MSG_@@bidi_end_edge__: 10px;
}

其它国际化API

除了chrome.i18n.getMessage外,还有另外三个API。

  • getAcceptLanguages,获取浏览器可接受的语言列表。

    chrome.i18n.getAcceptLanguages(function(languageArray){
        console.log(languageArray);
    });
    // 由于IHeader只支持中文和英文,故输出 ["zh-CN", "zh", "en", "zh-TW"]
    
  • getUILanguage,获取浏览器用户界面的语言(从Chrome v35起支持)。

    chrome.i18n.getUILanguage(); // "zh-CN"
    
  • detectLanguage,使用CLD检测文本对应的语言。

    chrome.i18n.detectLanguage('你好nihaoこんにちは how are you', function(result){
      console.log(result);
    });
    

    输出如下图:

    chrome.i18n.detectLanguage

Chrome扩展开发的心得

到目前为止,IHeader是我业余开发时间最长的一款Chrome扩展。从去年的5月8号始,到6月14号,第一版才完工,然后又经过7月、8月近两个月的陆续修改,最终v1.1.0版才成型,这才达到了我最初的开发初衷。

现在网络上流传的各种扩展开发教程非常之多,甚至API翻译的网站也很多,就我所知道的至少有这些:

通过查看这些资源,基本上就能快速上手Chrome扩展开发。

当然,教程再完善也不及官方文档,开发过程中,最难过的就是Chrome开发者网站连接不稳定,经常无法访问(即使自带梯子),因此查看官方网站的资料有些困难,这点比较影响开发进度,所以本文有意多介绍了一些Chrome API的用法。另外,开发好的扩展发布过程中也需要注意两点:

  1. 注册Chrome开发者需要5$,亲测浦发的visa信用卡可以支付,没有网上讲的那么复杂。
  2. 发布的扩展,为方便用户查看,需要完善的文档。由于Chrome webstore的扩展面向全球用户,所以文档至少要支持两种语言:中文和英文。

总之,Chrome扩展,万变不离其宗,无论扩展多么神奇和强大,最终都是通过HTML、CSS、JS来实现功能,脱离不了Web的小天地。因此理论上,只要你会写JS,就完全可以开发Chrome扩展。甚至,连第一个Demo,Chrome都帮你写好了,下载并安装Sample Extensions - Google Chrome网站的随意一个扩展源码,修修改改你就能运行属于自己的扩展了。

当然,一个好的扩展应该是对工作或生活有帮助的。只要你抓住痛点,用心实现功能,利用业余时间开发出一个强大的扩展自然不是问题。

小结

至此,Chrome扩展有关的介绍差不多了,让我们来看看IHeader的效果。借助IHeader扩展程序,我去掉了 www.google.com 网站response的X-Frame-Options字段,终于解决了文章开头的难题,如下所示。

定制response header

安装好IHeader后,可以戳此链接http://louiszhai.github.io/res/search/index.html?q=123 ,试用IHeader。

不仅如此,IHeader还可以新增、删除或编辑任意指定url的请求响应头,并且即使浏览器重启后,全局监听器依然有效。它适合用于HTTP缓存研究,HTTP接口字段调试,甚至还可以为接口调试时的跨域问题提供临时的解决方案(笔者基于此完成了很多跨域接口的调试工作)。因此,只要您基于HTTP请求响应头去做事情,IHeader都可以帮您简化工作。至于如何使用,这里有一个IHeader-Guide(由于网络原因,Chrome webstore上更新可能不及时,推荐安装Github上的IHeader源码)。

对Chrome扩展感兴趣的同学,欢迎来Github学习交流扩展开发的经验。

本文以IHeader扩展程序为引,逐步讲解Chrome扩展程序的开发,涉及内容较多,难免有所疏漏,欢迎批评斧正,谢谢。


版权声明:转载需注明作者和出处。

本文作者:louis

本文链接:http://louiszhai.github.io/2017/11/14/iheader/

相关文章

]]>
IHeader chrome插件开发 chrome扩展开发 定制请求头响应头 一次性消息 长期连接 pageAction browserAction background.js content.js 扩展国际化 右键菜单 扩展状态管理 chrome.webrequest
Tmux使用手册 http://louiszhai.github.io/2017/09/30/tmux/ 2017-09-30T08:59:36.000Z 2019-12-02T03:28:32.897Z 本文首发于CSDN网站,下面的版本又经过进一步的修订。

导读

我一直信奉简洁至上的原则,桌面窗口的数量越少,我的心情就越放松,开发的效率也就越高。反之,杂乱的桌面,暴涨的Chrome tab数量,或是无数的终端窗口,它们会逐步侵占我的注意力,分散我的思维,最终令我难以专注。因此桌面上我很少放文件,使用Chrome时常点 OneTab 回收标签页,切进终端时使用tmux管理窗口。

那么,有没有可能开机后不需要任何操作,本地的十几种web开发服务就自动运行?当然我不希望连续弹出十几个窗口或是tab,我需要的是静默无感知的启用服务,然后还能快速地进入到现场进行操作,web服务运行时不占据终端窗口,关闭iTem2后操作现场不会被销毁。诸如此类,tmux都能实现,除了这些,tmux还能做得更多更好。

到目前为止,tmux帮助我两年有余,它带给我许多惊喜。独乐不如众乐,愿你也能一同享受tmux带来的快乐。

简介

tmux是一款优秀的终端复用软件,它比Screen更加强大,至于如何强大,网上有大量的文章讨论了这点,本文不再重复。tmux之所以受人们喜爱,主要得益于以下三处功能:

  • 丝滑分屏(split),虽然iTem2也提供了横向和竖向分屏功能,但这种分屏功能非常拙劣,完全等同于屏幕新开一个窗口,新开的pane不会自动进入到当前目录,也没有记住当前登录状态。这意味着如果我ssh进入到远程服务器时,iTem2新开的pane中,我依然要重新走一遍ssh登录的老路(omg)。tmux就不会这样,tmux窗口中,新开的pane,默认进入到之前的路径,如果是ssh连接,登录状态也依旧保持着,如此一来,我就可以随意的增删pane,这种灵活性,好处不言而喻。
  • 保护现场(attach),即使命令行的工作只进行到一半,关闭终端后还可以重新进入到操作现场,继续工作。对于ssh远程连接而言,即使网络不稳定也没有关系,掉线后重新连接,可以直奔现场,之前运行中的任务,依旧在跑,就好像从来没有离开过一样;特别是在远程服务器上运行耗时的任务,tmux可以帮你一直保持住会话。如此一来,你就可以随时随地放心地进行移动办公,只要你附近的计算机装有tmux(没有你也可以花几分钟装一个),你就能继续刚才的工作。
  • 会话共享(适用于结对编程或远程教学),将 tmux 会话的地址分享给他人,这样他们就可以通过 SSH 接入该会话。如果你要给同事演示远程服务器的操作,他不必直勾勾地盯着你的屏幕,借助tmux,他完全可以进入到你的会话,然后静静地看着他桌面上你风骚的键盘走位,只要他愿意,甚至还可以录个屏。

以上,只是主要功能,更多功能还在后头,接下来我将详细地介绍tmux的使用技巧。

安装

首先安装之。

在Mac中安装:

# 先安装Homebrew,有则跳过
ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
# 安装tmux
brew install tmux

在Linux中安装:

sudo apt-get install tmux

基本概念

开始之前,我们先了解下基本概念:

tmux采用C/S模型构建,输入tmux命令就相当于开启了一个服务器,此时默认将新建一个会话,然后会话中默认新建一个窗口,窗口中默认新建一个面板。会话、窗口、面板之间的联系如下:

一个tmux session(会话)可以包含多个window(窗口),窗口默认充满会话界面,因此这些窗口中可以运行相关性不大的任务。

一个window又可以包含多个pane(面板),窗口下的面板,都处于同一界面下,这些面板适合运行相关性高的任务,以便同时观察到它们的运行情况。

基本概念

会话

新建会话

新建一个tmux session非常简单,语法为tmux new -s session-name,也可以简写为tmux,为了方便管理,建议指定会话名称,如下。

tmux # 新建一个无名称的会话
tmux new -s demo # 新建一个名称为demo的会话

断开当前会话

会话中操作了一段时间,我希望断开会话同时下次还能接着用,怎么做?此时可以使用detach命令。

tmux detach # 断开当前会话,会话在后台运行

也许你觉得这个太麻烦了,是的,tmux的会话中,我们已经可以使用tmux快捷键了。使用快捷键组合Ctrl+b + d,三次按键就可以断开当前会话。

进入之前的会话

断开会话后,想要接着上次留下的现场继续工作,就要使用到tmux的attach命令了,语法为tmux attach-session -t session-name,可简写为tmux a -t session-nametmux a。通常我们使用如下两种方式之一即可:

tmux a # 默认进入第一个会话
tmux a -t demo # 进入到名称为demo的会话

关闭会话

会话的使命完成后,一定是要关闭的。我们可以使用tmux的kill命令,kill命令有kill-panekill-serverkill-sessionkill-window共四种,其中kill-session的语法为tmux kill-session -t session-name。如下:

tmux kill-session -t demo # 关闭demo会话
tmux kill-server # 关闭服务器,所有的会话都将关闭

查看所有的会话

管理会话的第一步就是要查看所有的会话,我们可以使用如下命令:

tmux list-session # 查看所有会话
tmux ls # 查看所有会话,提倡使用简写形式

如果刚好处于会话中怎么办?别担心,我们可以使用对应的tmux快捷键Ctrl+b + s,此时tmux将打开一个会话列表,按上下键(⬆︎⬇︎)或者鼠标滚轮,可选中目标会话,按左右键(⬅︎➜)可收起或展开会话的窗口,选中目标会话或窗口后,按回车键即可完成切换。

查看会话

Tmux快捷指令

关于快捷指令,首先要认识到的是:tmux的所有指令,都包含同一个前缀,默认为Ctrl+b,输入完前缀过后,控制台激活,命令按键才能生效。前面tmux会话相关的操作中,我们共用到了两个快捷键Ctrl+b + dCtrl+b + s,但这仅仅是冰山一角,欲窥tmux庞大的快捷键体系,请看下表。

表一:系统指令。

前缀 指令 描述
Ctrl+b ? 显示快捷键帮助文档
Ctrl+b d 断开当前会话
Ctrl+b D 选择要断开的会话
Ctrl+b Ctrl+z 挂起当前会话
Ctrl+b r 强制重载当前会话
Ctrl+b s 显示会话列表用于选择并切换
Ctrl+b : 进入命令行模式,此时可直接输入ls等命令
Ctrl+b [ 进入复制模式,按q退出
Ctrl+b ] 粘贴复制模式中复制的文本
Ctrl+b ~ 列出提示信息缓存

表二:窗口(window)指令。

前缀 指令 描述
Ctrl+b c 新建窗口
Ctrl+b & 关闭当前窗口(关闭前需输入y or n确认)
Ctrl+b 0~9 切换到指定窗口
Ctrl+b p 切换到上一窗口
Ctrl+b n 切换到下一窗口
Ctrl+b w 打开窗口列表,用于且切换窗口
Ctrl+b , 重命名当前窗口
Ctrl+b . 修改当前窗口编号(适用于窗口重新排序)
Ctrl+b f 快速定位到窗口(输入关键字匹配窗口名称)

表三:面板(pane)指令。

前缀 指令 描述
Ctrl+b " 当前面板上下一分为二,下侧新建面板
Ctrl+b % 当前面板左右一分为二,右侧新建面板
Ctrl+b x 关闭当前面板(关闭前需输入y or n确认)
Ctrl+b z 最大化当前面板,再重复一次按键后恢复正常(v1.8版本新增)
Ctrl+b ! 将当前面板移动到新的窗口打开(原窗口中存在两个及以上面板有效)
Ctrl+b ; 切换到最后一次使用的面板
Ctrl+b q 显示面板编号,在编号消失前输入对应的数字可切换到相应的面板
Ctrl+b { 向前置换当前面板
Ctrl+b } 向后置换当前面板
Ctrl+b Ctrl+o 顺时针旋转当前窗口中的所有面板
Ctrl+b 方向键 移动光标切换面板
Ctrl+b o 选择下一面板
Ctrl+b 空格键 在自带的面板布局中循环切换
Ctrl+b Alt+方向键 以5个单元格为单位调整当前面板边缘
Ctrl+b Ctrl+方向键 以1个单元格为单位调整当前面板边缘(Mac下被系统快捷键覆盖)
Ctrl+b t 显示时钟

tmux的丝滑分屏功能正是得益于以上系统、窗口、面板的快捷指令,只要你愿意,你就可以解除任意的快捷指令,然后绑上你喜欢的指令,当然这就涉及到它的可配置性了,请继续往下读。

灵活的配置性

除了快捷指令外,tmux还提供了类似vim的配置性功能。可配置性是软件的一项进阶级功能,只有具备了可配置性,软件才有了鲜活的个性,用户才能体会到操作的快感。

修改指令前缀

相信只要你用过几次tmux,就会发现Ctrl+b指令前缀,着实不太方便。这两个键相距太远,按键成本太高了。因此我们首先需要将它更换为距离更近的Ctrl+a组合键,或者不常用的 ` 键(当然其他键也是可以的)。

tmux的用户级配置文件为~/.tmux.conf(没有的话就创建一个),修改快捷指令,只需要增加如下三行即可。

set -g prefix C-a #
unbind C-b # C-b即Ctrl+b键,unbind意味着解除绑定
bind C-a send-prefix # 绑定Ctrl+a为新的指令前缀

# 从tmux v1.6版起,支持设置第二个指令前缀
set-option -g prefix2 ` # 设置一个不常用的`键作为指令前缀,按键更快些

修改的~/.tmux.conf配置文件有如下两种方式可以令其生效:

  • restart tmux。
  • 在tmux窗口中,先按下Ctrl+b指令前缀,然后按下系统指令:,进入到命令模式后输入source-file ~/.tmux.conf,回车后生效。

既然快捷指令如此方便,更为优雅的做法是新增一个加载配置文件的快捷指令 ,这样就可以随时随地load新的配置了,如下所示。

# 绑定快捷键为r
bind r source-file ~/.tmux.conf \; display-message "Config reloaded.."

请特别注意,在已经创建的窗口中,即使加载了新的配置,旧的配置依然有效(只要你新加的功能没有覆盖旧的配置,因此如果你第一次绑定快捷指令为x键,然后又改为绑定y键,那么xy都将有效),新建会话不受此影响,将直接采用新的配置。

既然我们已经迈出配置化的第一步,那么接下来我们可以做得更多。

新增面板

tmux中,使用最多的功能之一就是新增一个面板。水平方向新增面板的指令是 prefix + " ,垂直方向是 prefix + %"%需要两个键同时按下才能完成,加上指令前缀至少需要3~4次按键才能组成一个完整的指令,同时这个两个键也不够醒目和方便,因此我们可以绑定两个更常用的指令 -|,如下所示:

unbind '"'
bind - splitw -v -c '#{pane_current_path}' # 垂直方向新增面板,默认进入当前目录
unbind %
bind | splitw -h -c '#{pane_current_path}' # 水平方向新增面板,默认进入当前目录

开启鼠标支持

默认情况下,tmux的多窗口之间的切换以及面板大小调整,需要输入指令才能完成,这一过程,涉及到的指令较多,而且操作麻烦,特别是面板大小调整,指令难以一步到位,这个时候开启鼠标支持就完美了。

对于tmux v2.1(2015.10.28)之前的版本,需加入如下配置:

setw -g mode-mouse on # 支持鼠标选取文本等
setw -g mouse-resize-pane on # 支持鼠标拖动调整面板的大小(通过拖动面板间的分割线)
setw -g mouse-select-pane on # 支持鼠标选中并切换面板
setw -g mouse-select-window on # 支持鼠标选中并切换窗口(通过点击状态栏窗口名称)

有的地方可能会出现set-window-option的写法,setw就是它的别名。

对于tmux v2.1及以上的版本,仅需加入如下配置:

set-option -g mouse on # 等同于以上4个指令的效果

需要注意的是,开启鼠标支持后,iTem2默认的鼠标选中即复制功能需要同时按下 Alt 键,才会生效。

快速面板切换

鼠标支持确实能带来很大的便捷性,特别是对于习惯了鼠标操作的tmux新手,但对于键盘爱好者而言,这不是什么好消息,对他们而言,双手不离键盘是基本素质。

虽然指令前缀加方向键可以切换面板,但方向键太远,不够快,不够Geek。没关系,我们可以将面板切换升级为熟悉的hjkl键位。

# 绑定hjkl键为面板切换的上下左右键
bind -r k select-pane -U # 绑定k为↑
bind -r j select-pane -D # 绑定j为↓
bind -r h select-pane -L # 绑定h为←
bind -r l select-pane -R # 绑定l为→

-r表示可重复按键,大概500ms之内,重复的hjkl按键都将有效,完美支持了快速切换的Geek需求。

除了上下左右外, 还有几个快捷指令可以设置。

bind -r e lastp # 选择最后一个面板
bind -r ^e last # 选择最后一个窗口

bind -r ^u swapp -U # 与前一个面板交换位置
bind -r ^d swapp -D # 与后一个面板交换位置

面板大小调整

习惯了全键盘操作后,命令的便捷性不言而喻。既然面板切换的指令都可以升级,面板大小调整的指令自然也不能落后。如下配置就可以升级你的操作:

# 绑定Ctrl+hjkl键为面板上下左右调整边缘的快捷指令
bind -r ^k resizep -U 10 # 绑定Ctrl+k为往↑调整面板边缘10个单元格
bind -r ^j resizep -D 10 # 绑定Ctrl+j为往↓调整面板边缘10个单元格
bind -r ^h resizep -L 10 # 绑定Ctrl+h为往←调整面板边缘10个单元格
bind -r ^l resizep -R 10 # 绑定Ctrl+l为往→调整面板边缘10个单元格

以上,resizepresize-pane的别名。

面板最大化

当窗口中面板的数量逐渐增多时,每个面板的空间就会逐渐减少。为了保证有足够的空间显示内容,tmux从v1.8版本起,提供了面板的最大化功能,输入tmux-prefix+z,就可以最大化当前面板至窗口大小,只要再重复输入一次,便恢复正常。那么tmux v1.8以下的版本,怎么办呢?别急,有大神提供了如下的解决方案。

首先编写一个zoom脚本,该脚本通过新建一个窗口,交换当前面板与新的窗口默认面板位置,来模拟最大的功能;通过重复一次按键,还原面板位置,并关闭新建的窗口,来模拟还原功能,如下所示:

#!/bin/bash -f
currentwindow=`tmux list-window | tr '\t' ' ' | sed -n -e '/(active)/s/^[^:]*: *\([^ ]*\) .*/\1/gp'`;
currentpane=`tmux list-panes | sed -n -e '/(active)/s/^\([^:]*\):.*/\1/gp'`;
panecount=`tmux list-panes | wc | sed -e 's/^ *//g' -e 's/ .*$//g'`;
inzoom=`echo $currentwindow | sed -n -e '/^zoom/p'`;
if [ $panecount -ne 1 ]; then
    inzoom="";
fi
if [ $inzoom ]; then
    lastpane=`echo $currentwindow | rev | cut -f 1 -d '@' | rev`;
    lastwindow=`echo $currentwindow | cut -f 2- -d '@' | rev | cut -f 2- -d '@' | rev`;
    tmux select-window -t $lastwindow;
    tmux select-pane -t $lastpane;
    tmux swap-pane -s $currentwindow;
    tmux kill-window -t $currentwindow;
else
    newwindowname=zoom@$currentwindow@$currentpane;
    tmux new-window -d -n $newwindowname;
    tmux swap-pane -s $newwindowname;
    tmux select-window -t $newwindowname;
fi

不妨将该脚本存放在~/.tmux目录中(没有则新建目录),接下来只需要绑定一个快捷指令就行,如下。

unbind z
bind z run ". ~/.tmux/zoom"

窗口变为面板

通过上面的zoom脚本,面板可以轻松地最大化为一个新的窗口。那么反过来,窗口是不是可以最小化为一个面板呢?

试想这样一个场景:当你打开多个窗口后,然后想将其中几个窗口合并到当前窗口中,以便对比观察输出。

实际上,你的要求就是将其它窗口变成面板,然后合并到当前窗口中。对于这种操作,我们可以在当前窗口,按下prefix + :,打开命令行,然后输入如下命令:

join-pane -s window01 # 合并名称为window01的窗口的默认(第一个)面板到当前窗口中
join-pane -s window01.1 # .1显式指定了第一个面板,.2就是第二个面板(我本地将面板编号起始值设置为1,默认是0)

每次执行join-pane命令都会合并一个面板,并且指定的窗口会减少一个面板,直到面板数量为0,窗口关闭。

除了在当前会话中操作外,join-pane命令甚至可以从其它指定会话中合并面板,格式为join-pane -s [session_name]:[window].[pane],如join-pane -s 2:1.1 即合并第二个会话的第一个窗口的第一个面板到当前窗口,当目标会话的窗口和面板数量为0时,会话便会关闭。

注:上一节中的swap-pane命令与join-pane语法基本一致。

其他配置

bind m command-prompt "splitw -h 'exec man %%'"   # 绑定m键为在新的panel打开man
# 绑定P键为开启日志功能,如下,面板的输出日志将存储到桌面
bind P pipe-pane -o "cat >>~/Desktop/#W.log" \; display "Toggled logging to ~/Desktop/#W.log"

恢复用户空间

tmux会话中,Mac的部分命令如 osascriptopenpbcopypbpaste等可能会失效(失效命令未列全)。

部分bug列表如下:

对此,我们可以通过安装reattach-to-user-namespace包装程序来解决这个问题。

brew install reattach-to-user-namespace

~/.tmux.conf中添加配置:

set -g default-command "reattach-to-user-namespace -l $SHELL"

这样你的交互式shell最终能够重新连接到用户级的命名空间。由于连接状态能够被子进程继承,故以上配置保证了所有从 shell 启动的命令能够被正确地连接。

有些时候,我们可能会在不同的操作系统中共享配置文件,如果你的tmux版本大于1.9,我们还可以使用if-shell来判断是否Mac系统,然后再指定default-command

if-shell 'test "$(uname -s)" = Darwin' 'set-option -g default-command "exec reattach-to-user-namespace -l $SHELL"'

对于tmux v1.8及更早的版本,可以使用如下包装后的配置:

set-option -g default-command 'command -v reattach-to-user-namespace >/dev/null && exec reattach-to-user-namespace -l "$SHELL" || exec "$SHELL"'

以上,$SHELL对应于你的默认Shell,通常是/usr/bin/bash/usr/local/bin/zsh

复制模式

tmux中操作文本,自然离不开复制模式,通常使用复制模式的步骤如下:

  1. 输入 `+[ 进入复制模式
  2. 按下 空格键 开始复制,移动光标选择复制区域
  3. 按下 回车键 复制选中文本并退出复制模式
  4. 按下 `+] 粘贴文本

查看复制模式默认的快捷键风格:

tmux show-window-options -g mode-keys # mode-keys emacs

默认情况下,快捷键为emacs风格。

为了让复制模式更加方便,我们可以将快捷键设置为熟悉的vi风格,如下:

setw -g mode-keys vi # 开启vi风格后,支持vi的C-d、C-u、hjkl等快捷键

自定义复制和选择快捷键

除了快捷键外,复制模式的启用、选择、复制、粘贴等按键也可以向vi风格靠拢。

bind Escape copy-mode # 绑定esc键为进入复制模式
bind -t vi-copy v begin-selection # 绑定v键为开始选择文本
bind -t vi-copy y copy-selection # 绑定y键为复制选中文本
bind p pasteb # 绑定p键为粘贴文本(p键默认用于进入上一个窗口,不建议覆盖)

以上,绑定 vy两键的设置只在tmux v2.4版本以下才有效,对于v2.4及以上的版本,绑定快捷键需要使用 -T 选项,发送指令需要使用 -X 选项,请参考如下设置:

bind -T copy-mode-vi v send-keys -X begin-selection
bind -T copy-mode-vi y send-keys -X copy-selection-and-cancel

Buffer缓存

tmux复制操作的内容默认会存进buffer里,buffer是一个粘贴缓存区,新的缓存总是位于栈顶,它的操作命令如下:

tmux list-buffers # 展示所有的 buffers
tmux show-buffer [-b buffer-name] # 显示指定的 buffer 内容
tmux choose-buffer # 进入 buffer 选择页面(支持jk上下移动选择,回车选中并粘贴 buffer 内容到面板上)
tmux set-buffer # 设置buffer内容
tmux load-buffer [-b buffer-name] file-path # 从文件中加载文本到buffer缓存
tmux save-buffer [-a] [-b buffer-name] path # 保存tmux的buffer缓存到本地
tmux paste-buffer # 粘贴buffer内容到会话中
tmux delete-buffer [-b buffer-name] # 删除指定名称的buffer

以上buffer操作在不指定buffer-name时,默认处理是栈顶的buffer缓存。

在tmux会话的命令行输入时,可以省略上述tmux前缀,其中list-buffers的操作如下所示:

list-buffers

choose-buffer的操作如下所示:

choose-buffer

默认情况下,buffers内容是独立于系统粘贴板的,它存在于tmux进程中,且可以在会话间共享。

使用系统粘贴板

存在于tmux进程中的buffer缓存,虽然可以在会话间共享,但不能直接与系统粘贴板共享,不免有些遗憾。幸运的是,现在我们有成熟的方案来实现这个功能。

在Linux上使用粘贴板

通常,Linux中可以使用xclip工具来接入系统粘贴板。

首先,需要安装xclip

sudo apt-get install xclip

然后,.tmux.conf的配置如下。

# buffer缓存复制到Linux系统粘贴板
bind C-c run " tmux save-buffer - | xclip -i -sel clipboard"
# Linux系统粘贴板内容复制到会话
bind C-v run " tmux set-buffer \"$(xclip -o -sel clipboard)\"; tmux paste-buffer"

按下prefix + Ctrl + c 键,buffer缓存的内容将通过xlip程序复制到粘贴板,按下prefix + Ctrl + v键,tmux将通过xclip访问粘贴板,然后由set-buffer命令设置给buffer缓存,最后由paste-buffer粘贴到tmux会话中。

在Mac上使用粘贴板

我们都知道,Mac自带 pbcopypbpaste命令,分别用于复制和粘贴,但在tmux命令中它们却不能正常运行。这里我将详细介绍下原因:

Mac的粘贴板服务是在引导命名空间注册的。命名空间存在层次之分,更高级别的命名空间拥有访问低级别命名空间(如root引导命名空间)的权限,反之却不行。流程创建的属于Mac登录会话的一部分,它会被自动包含在用户级的引导命名空间中,因此只有用户级的命名空间才能访问粘贴板服务。tmux使用守护进程(3)库函数创建其服务器进程,在Mac OS X 10.5中,苹果改变了守护进程(3)的策略,将生成的过程从最初的引导命名空间移到了根引导命名空间。而根引导命名空间访问权限较低,这意味着tmux服务器,和它的子进程,一同失去了原引导命名空间的访问权限(即无权限访问粘贴板服务)。

如此,我们可以使用一个小小的包装程序来重新连接到合适的命名空间,然后执行访问用户级命名空间的粘贴板服务,这个包装程序就是reattach-to-user-namespace

那么,Mac下.tmux.conf的配置如下:

# buffer缓存复制到Mac系统粘贴板
bind C-c run "tmux save-buffer - | reattach-to-user-namespace pbcopy"
# Mac系统粘贴板内容复制到会话
bind C-v run "reattach-to-user-namespace pbpaste | tmux load-buffer - \; paste-buffer -d"

reattach-to-user-namespace 作为包装程序来访问Mac粘贴板,按下prefix + Ctrl + c 键,buffer缓存的内容将复制到粘贴板,按下prefix + Ctrl + v键,粘贴板的内容将通过 load-buffer 加载,然后由 paste-buffer 粘贴到tmux会话中。

为了在复制模式中使用Mac系统的粘贴板,可做如下配置:

# 绑定y键为复制选中文本到Mac系统粘贴板
bind-key -T copy-mode-vi 'y' send-keys -X copy-pipe-and-cancel 'reattach-to-user-namespace pbcopy'
# 鼠标拖动选中文本,并复制到Mac系统粘贴板
bind-key -T copy-mode-vi MouseDragEnd1Pane send -X copy-pipe-and-cancel "pbcopy"

完成以上配置后记得重启tmux服务器。至此,复制模式中,按y键将保存选中的文本到Mac系统粘贴板,随后按Command + v键便可粘贴。

保存Tmux会话

信息时代,数据尤为重要。tmux保护现场的能力依赖于tmux进程,如果进程退出,则意味着会话数据的丢失,因此关机重启后,tmux中的会话将被清空,这不是我们想要见到的。幸运的是,目前有这样两款插件:Tmux ResurrectTmux Continuum,可以永久保存tmux会话(它们均适用于tmux v1.9及以上版本)。

Tmux Resurrect

Tmux Resurrect无须任何配置,就能够备份tmux会话中的各种细节,包括窗口、面板的顺序、布局、工作目录,运行程序等等数据。因此它能在系统重启后完全地恢复会话。由于其幂等的恢复机制,它不会试图去恢复一个已经存在的窗口或者面板,所以,即使你不小心多恢复了几次会话,它也不会出现问题,这样主动恢复时我们就不必担心手抖多按了一次。另外,如果你是tmuxinator用户,我也建议你迁移到 tmux-resurrect插件上来,具体请参考Migrating from tmuxinator

Tmux Resurrec安装过程如下所示:

cd ~/.tmux
mkdir plugins
git clone https://github.com/tmux-plugins/tmux-resurrect.git

安装后需在~/.tmux.conf中增加一行配置:

run-shell ~/.tmux/plugins/tmux-resurrect/resurrect.tmux

至此安装成功,按下prefix + r重载tmux配置。

Tmux Resurrec提供如下两个操作:

  • 保存,快捷指令是prefix + Ctrl + s,tmux状态栏在保存开始,保存后分别提示”Saving…”,”Tmux environment saved !”。
  • 恢复,快捷指令是prefix + Ctrl + r,tmux状态栏在恢复开始,恢复后分别提示”Restoring…”,”Tmux restore complete !”。

保存时,tmux会话的详细信息会以文本文件的格式保存到~/.tmux/resurrect目录,恢复时则从此处读取,由于数据文件是明文的,因此你完全可以自由管理或者编辑这些会话状态文件(如果备份频繁,记得定期清除历史备份)。

可选的配置

Tmux Resurrec本身是免配置开箱即用的,但同时也提供了如下选项以便修改其默认设置。

set -g @resurrect-save 'S' # 修改保存指令为S
set -g @resurrect-restore 'R' 修改恢复指令为R
# 修改会话数据的保持路径,此处不能使用除了$HOME, $HOSTNAME, ~之外的环境变量
set -g @resurrect-dir '/some/path'

默认情况下只有一个保守的列表项(即vi vim nvim emacs man less more tail top htop irssi mutt)可以恢复,对此 Restoring programs doc 解释了怎么去恢复额外的项目。

进阶的备份

除了基础备份外,Tmux Resurrec还提供进阶的备份功能,如下所示:

  • 恢复vim 和 neovim 会话
  • 恢复面板内容
  • 恢复shell的历史记录(实验性功能)

进阶的备份功能默认不开启,需要特别配置。

1)恢复vim 和 neovim 会话,需要完成如下两步:

  • 通过vim的vim-obsession插件保存vim/neovim会话。

    cd ~/.vim/bundle
    git clone git://github.com/tpope/vim-obsession.git
    vim -u NONE -c "helptags vim-obsession/doc" -c q
    
  • ~/.tmux.conf中增加两行配置:

    set -g @resurrect-strategy-vim 'session' # for vim
    set -g @resurrect-strategy-nvim 'session' # for neovim
    

2)恢复面板内容,需在~/.tmux.conf中增加一行配置:

set -g @resurrect-capture-pane-contents 'on' # 开启恢复面板内容功能

目前使用该功能时,请确保tmux的default-command没有包含&& 或者||操作符,否则将导致bug。(查看default-command的值,请使用命令tmux show -g default-command。)

3)恢复shell的历史记录,需在~/.tmux.conf中增加一行配置:

set -g @resurrect-save-shell-history 'on'

由于技术的限制,保存时,只有无前台任务运行的面板,它的shell历史记录才能被保存。

Tmux Continuum

可能你嫌手动保存和恢复太过麻烦,别担心,这不是问题。Tmux Continuum 在 Tmux Resurrec的基础上更进一步,现在保存和恢复全部自动化了,如你所愿,可以无感使用tmux,不用再担心备份问题。

Tmux Continuum安装过程如下所示(它依赖Tmux Resurrect,请保证已安装Tmux Resurrect插件):

cd ~/.tmux/plugins
git clone https://github.com/tmux-plugins/tmux-continuum.git

安装后需在~/.tmux.conf中增加一行配置:

run-shell ~/.tmux/plugins/tmux-continuum/continuum.tmux

Tmux Continuum默认每隔15mins备份一次,我设置的是一天一次:

set -g @continuum-save-interval '1440'

关闭自动备份,只需设置时间间隔为 0 即可:

set -g @continuum-save-interval '0'

想要在tmux启动时就恢复最后一次保存的会话环境,需增加如下配置:

set -g @continuum-restore 'on' # 启用自动恢复

如果不想要启动时自动恢复的功能了,直接移除上面这行就行。想要绝对确定自动恢复不会发生,就在用户根目录下创建一个tmux_no_auto_restore空文件(创建命令:touch ~/tmux_no_auto_restore),该文件存在时,自动恢复将不触发。

对于tmux高级用户(可能就是你)而言,同时运行多个tmux服务器也是有可能的。你可能并不希望后面启用的几个tmux服务器自动恢复或者自动保存会话。因此Tmux Continuum会优先在第一个启用的tmux服务器中生效,随后启用的tmux服务器不再享受自动恢复或自动保存会话的待遇。

实际上,不管Tmux Continuum功能有没有启用,或者多久保存一次,我们都有办法从状态栏知晓。Tmux Continuum提供了一个查看运行状态的插值#{continuum_status},它支持status-rightstatus-left两种状态栏设置,如下所示:

set -g status-right 'Continuum status: #{continuum_status}'

tmux运行时,#{continuum_status} 将显示保存的时间间隔(单位为分钟),此时状态栏会显示:

Continuum status: 1440

如果其自动保存功能关闭了,那么状态栏会显示:

Continuum status: off

借助Tmux Continuum插件,Mac重启时,我们甚至可以选择在Terminal 或者 iTerm2 中自动全屏启用tmux。

为此,需在~/.tmux.conf中增加一行配置:

set -g @continuum-boot 'on'

Mac下,自动启用tmux还支持如下选项:

  • set -g @continuum-boot-options 'fullscreen'Terminal自动全屏,tmux命令在Terminal中执行。
  • set -g @continuum-boot-options 'iterm'iTerm2 替换 Terminal 应用,tmux命令在iTerm2中执行。
  • set -g @continuum-boot-options 'iterm,fullscreen'iTerm2自动全屏,tmux命令在iTerm2中执行。

Linux中则没有这些选项,它只能设置为自动启用tmux服务器。

Tpm

以上,我们直接安装了tmux插件。这没有问题,可当插件越来越多时,我们就会需要统一的插件管理器。因此官方提供了tpm(支持tmux v1.9及以上版本)。

tpm安装过程如下所示:

cd ~/.tmux/plugins
git clone https://github.com/tmux-plugins/tpm

安装后需在~/.tmux.conf中增加如下配置:

# 默认需要引入的插件
set -g @plugin 'tmux-plugins/tpm'
set -g @plugin 'tmux-plugins/tmux-sensible'

# 引入其他插件的示例
# set -g @plugin 'github_username/plugin_name' # 格式:github用户名/插件名
# set -g @plugin 'git@github.com/user/plugin' # 格式:git@github插件地址

# 初始化tmux插件管理器(保证这行在~/.tmux.conf的非常靠后的位置)
run '~/.tmux/plugins/tpm/tpm'

然后按下prefix + r重载tmux配置,使得tpm生效。

基于tpm插件管理器,安装插件仅需如下两步:

  1. ~/.tmux.conf中增加新的插件,如set -g @plugin '...'
  2. 按下prefix + I键下载插件,并刷新tmux环境。

更新插件,请按下prefix + U 键,选择待更新的插件后,回车确认并更新。

卸载插件,需如下两步:

  1. ~/.tmux.conf中移除插件所在行。
  2. 按下prefix + alt + u 移除插件。

会话共享

结对编程

tmux多会话连接实时同步的功能,使得结对编程成为了可能,这也是开发者最喜欢的功能之一。现在就差一步了,就是借助tmate把tmux会话分享出去。

tmate是tmux的管理工具,它可以轻松的创建tmux会话,并且自动生成ssh链接。

安装tmate

brew install tmate

使用tmate新建一个tmux会话

tmate

此时屏幕下方会显示ssh url,如下所示:

ssh url

查看tmate生成的ssh链接

tmate show-messages

生成的ssh url如下所示,其中一个为只读,另一个可编辑。

ssh url

共享账号&组会话

使用tmate远程共享tmux会话,受制于多方的网络质量,必然会存在些许延迟。如果共享会话的多方拥有同一个远程服务器的账号,那么我们可以使用组会话解决这个问题。

先在远程服务器上新建一个公共会话,命名为groupSession

tmux new -s groupSession

其他用户不去直接连接这个会话,而是通过创建一个新的会话来加入上面的公共会话groupSession

tmux new -t groupSession -s otherSession

此时两个用户都可以在同一个会话里操作,就会好像第二个用户连接到了groupSession的会话一样。此时两个用户都可以创建新建的窗口,新窗口的内容依然会实时同步,但是其中一个用户切换到其它窗口,对另外一个用户没有任何影响,因此在这个共享的组会话中,用户各自的操作可以通过新建窗口来执行。即使第二个用户关闭otherSession会话,共享会话groupSession依然存在。

组会话在共享的同时,又保留了相对的独立,非常适合结对编程场景,它是结对编程最简单的方式,如果账号不能共享,我们就要使用下面的方案了。

独立账号&Socket共享会话

开始之前我们需要确保用户对远程服务器上同一个目录拥有相同的读写权限,假设这个目录为/var/tmux/

使用new-session(简写new)创建会话时,使用的是默认的socket位置,默认socket无法操作,所以我们需要创建一个指定socket文件的会话。

tmux -S /var/tmux/sharefile

另一个用户进入时,需要指定socket文件加入会话。

tmux -S /var/tmux/sharefile attach

这样,两个不同的用户就可以共享同一个会话了。

通常情况下,不同的用户使用不同的配置文件来创建会话,但是,使用指定socket文件创建的tmux会话,会话加载的是第一个创建会话的用户的~/.tmux.conf配置文件,随后加入会话的其他用户,依然使用同一份配置文件。

Tmux优化

要想tmux更加人性化、性能更佳,不妨参考下如下配置。

设置窗口面板起始序号

set -g base-index 1 # 设置窗口的起始下标为1
set -g pane-base-index 1 # 设置面板的起始下标为1

自定义状态栏

set -g status-utf8 on # 状态栏支持utf8
set -g status-interval 1 # 状态栏刷新时间
set -g status-justify left # 状态栏列表左对齐
setw -g monitor-activity on # 非当前窗口有内容更新时在状态栏通知

set -g status-bg black # 设置状态栏背景黑色
set -g status-fg yellow # 设置状态栏前景黄色
set -g status-style "bg=black, fg=yellow" # 状态栏前景背景色

set -g status-left "#[bg=#FF661D] ❐ #S " # 状态栏左侧内容
set -g status-right 'Continuum status: #{continuum_status}' # 状态栏右侧内容
set -g status-left-length 300 # 状态栏左边长度300
set -g status-right-length 500 # 状态栏左边长度500

set -wg window-status-format " #I #W " # 状态栏窗口名称格式
set -wg window-status-current-format " #I:#W#F " # 状态栏当前窗口名称格式(#I:序号,#w:窗口名称,#F:间隔符)
set -wg window-status-separator "" # 状态栏窗口名称之间的间隔
set -wg window-status-current-style "bg=red" # 状态栏当前窗口名称的样式
set -wg window-status-last-style "fg=red" # 状态栏最后一个窗口名称的样式

set -g message-style "bg=#202529, fg=#91A8BA" # 指定消息通知的前景、后景色

开启256 colors支持

默认情况下,tmux中使用vim编辑器,文本内容的配色和直接使用vim时有些差距,此时需要开启256 colors的支持,配置如下。

set -g default-terminal "screen-256color"

或者:

set -g default-terminal "tmux-256color"

或者启动tmux时增加参数-2

alias tmux='tmux -2' # Force tmux to assume the terminal supports 256 colours

关闭默认的rename机制

tmux默认会自动重命名窗口,频繁的命令行操作,将频繁触发重命名,比较浪费CPU性能,性能差的计算机上,问题可能更为明显。建议添加如下配置关闭rename机制。

setw -g automatic-rename off
setw -g allow-rename off

去掉小圆点

tmux默认会同步同一个会话的操作到所有会话连接的终端窗口中,这种同步机制,限制了窗口的大小为最小的会话连接。因此当你开一个大窗口去连接会话时,实际的窗口将自动调整为最小的那个会话连接的窗口,终端剩余的空间将填充排列整齐的小圆点,如下所示。

dot

为了避免这种问题,我们可以在连接会话的时候,断开其他的会话连接。

tmux a -d

如果已经进入了tmux会话中,才发现这种问题,这个时候可以输入命令达到同样的效果。

`: a -d

remove dot

脚本化的Tmux

tmux作为终端复用软件,支持纯命令行操作也是其一大亮点。你既可以启用可视化界面创建会话,也可以运行脚本生成会话,对于tmux依赖者而言,编写几个tmux脚本批量维护会话列表,快速重启、切换、甚至分享部分会话都是非常方便的。可能会有人说为什么不用Tmux Resurrect呢?是的,Tmux Resurrect很好,一键恢复也很诱人,但是对于一个维护大量tmux会话的用户而言,一键恢复可能不见得好,分批次恢复可能是他(她)更想要的,脚本化的tmux就很好地满足了这点。

脚本中创建tmux会话时,由于不需要开启可视化界面,需要输入-d参数指定会话后台运行,如下。

tmux new -s init -d # 后台创建一个名称为init的会话

新建的会话,建议重命令会话的窗口名称,以便后续维护。

# 重命名init会话的第一个窗口名称为service
tmux rename-window -t "init:1" service

现在,可以在刚才的窗口中输入指令了。

# 切换到指定目录并运行python服务
tmux send -t "init:service" "cd ~/workspace/language/python/;python2.7 server.py" Enter

一个面板占用一个窗口可能太浪费了,我们来分个屏吧。

# 默认上下分屏
tmux split-window -t "init:service"
# 切换到指定目录并运行node服务
tmux send -t "init:service" 'cd ~/data/louiszhai/node-webserver/;npm start' Enter

现在一个窗口拥有上下两个面板,是时候创建一个新的窗口来运行更多的程序了。

# 新建一个名称为tool的窗口
tmux neww -a -n tool -t init # neww等同于new window
# 运行weinre调试工具
tmux send -t "init:tool" "weinre --httpPort 8881 --boundHost -all-" Enter

另外新建窗口运行程序,有更方便的方式,比如使用 processes 选项。

tmux neww-n processes ls # 新建窗口并执行命令,命令执行结束后窗口将关闭
tmux neww-n processes top # 由于top命令持续在前台运行,因此窗口将保留,直到top命令退出

新的窗口,我们尝试下水平分屏。

# 水平分屏
tmux split-window -h -t "init:tool"
# 切换到指定目录并启用aria2 web管理后台
tmux send -t "init:tool" "cd ~/data/tools/AriaNg/dist/;python -m SimpleHTTPServer 10108" Enter

类似的脚本,我们可以编写一打,这样快速重启、切换、甚至分享会话都将更加便捷。

开机自动启用Web服务器

开机自动准备工作环境是一个很好的idea,但却不好实现。对于程序员而言,一个开机即用的计算机会节省大量的初始化操作,特别是前端工程师,本地常常会启用多个服务器,每次开机挨个启动将耗时耗力。为此,在遇到tmux之前,我常常拖延重启计算机的时机,一度连续运行Mac一月之久,直到它不堪重负。

有了tmux脚本化的基础,开机自动启用web服务器就不在话下了,接杯水的时间,计算机就重启恢复了满血。如下是操作步骤:

首先,上面的tmux脚本,可以合并到同一个文件中,指定文件权限为可执行,并命名为init.sh(名称可自取)。

chmod u+x ./init.sh

然后,打开 系统偏好设置 - 用户与群组 - 登录项,点击添加按钮+,选择刚刚保存的init.sh脚本,最终效果如下:

init.sh

至此,Mac开机将自动运行 init.sh 脚本,自动启用web服务器。

完成了上面这些配置,就真正实现了一键开机。

最后,附上我本地的配置文件 .tmux.conf,以及启动脚本 init.sh。明天就是国庆了,祝大家国庆快乐!


版权声明:转载需注明作者和出处。

本文作者: louis

本文链接: http://louiszhai.github.io/2017/09/30/tmux/

相关文章

]]>
tmux 终端复用 会话 tmux配置 快捷键 快捷指令 鼠标支持 面板切换 复制模式 使用Mac系统粘贴板 保存tmux会话 tmux resurrect tmux continuum tpm 会话分享 结对编程 tmux脚本化 开机自动启用web服务器
匠心打造canvas签名组件 http://louiszhai.github.io/2017/07/07/canvas-draw/ 2017-07-07T07:36:22.000Z 2019-12-02T03:28:32.876Z 本文首发于CSDN网站,下面的版本又经过进一步的修订。

导读

6月又是项目吃紧的时候,一大波需求袭来,猝不及防。

度过了漫长而煎熬的6月,是时候总结一波。最近移动端的一款产品原计划是引入第三方的签名插件,该插件依赖复杂,若干个js使用document.write顺序加载,插件源码是ES5的,甚至说是ES3都不为过。为了能够顺利嵌入我们的VUE项目,我阅读了两天插件的源码(demo及文档不全,囧),然后花了一天多点的时间使用ES6引用它。鉴于单页应用中,任何非全局资源都不该提前加载的指导性原则,为了做到动态加载,我甚至还专门写了一个simple的vue组件iload.js去顺序加载这些资源并执行回调。一切看似很完美,结果发现demo引用的一个压缩的js中居然写死了插件相关DOM节点的id和style,此刻我的内心几乎是崩溃的。这样的一个插件我怕是无力引入了吧。

虽然嘴上这么说,身体还是很诚实的,费尽千辛万苦我还是把这个插件用在了项目中。随着项目推进,业务上经过多次沟通,我们砍掉了该签名插件的数字证书验证部分。也就是说,这么大的一个插件,只剩下用户签名的功能,我完全可以自己做啊。于是我悄悄移除了这个插件,为这几天的调研和码字过程划上了一个完美的句号(深藏功与名)。

签名是若干操作的集合,起于用户手写姓名,终于签名图片上传,中间还包含图片的处理,比如说减少锯齿、旋转、缩小、预览等。canvas几乎是最适合的解决方案。

手写

从交互上看,用户签名的过程,只有开始的手写部分是有交互的,后面是自动处理。为了完成手写,需要监听画布的两个事件:touchstart、touchmove(移动端touchend在touchmove之后不触发)。前者定义起始点,后者不停地描线。

const canvas = document.getElementById('canvas');
const touchstart = (e) => {
  /* TODO 定义起点 */
};
const touchmove = (e) => {
  /* TODO 连点成线,并且填充颜色 */
};
canvas.addEventListener('touchstart', touchstart);
canvas.addEventListener('touchmove', touchmove);

注: 以下默认canvas和context对象已有。

可以先戳这里体验把后面将要提到的签名组件 canvas-draw

描线

既然要连点成线,自然需要一个变量来存储这些点。

const point = {};

接下来就是画线的部分。canvas画线只需4行代码:

  1. 开始路径(beginPath)
  2. 定位起点(moveTo)
  3. 移动画笔(lineTo)
  4. 绘制路径(stroke)

考虑到start和move两个动作,那么一个描线的方法就呼之欲出了,如下:

const paint = (signal) => {
  switch (signal) {
    case 1: // 开始路径
      context.beginPath();
      context.moveTo(point.x, point.y);
    case 2: // 前面之所以没有break语句,是为了点击时就能描画出一个点
      context.lineTo(point.x, point.y);
      context.stroke();
      break;
  }
};

绑定事件

为了兼容PC端的类似需求,我们有必要区分下平台。移动端,使用手指操作,需要绑定的是touchstart和touchmove;PC端,使用鼠标操作,需要绑定的是mousedown和mousemove。如下一行代码可用于判断是否移动端:

const isMobile = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i.test(navigator.userAgent);

描线的方法准备妥当后,剩下的就是在适当的时候,记录当前划过的点,并且调用paint方法进行绘制。这里可以抽象出一个事件生成器:

let pressed = false; // 标示是否发生鼠标按下或者手指按下事件
const create = signal => (e) => {
  e.preventDefault();
  if (signal === 1) {
    pressed = true;
  }
  if (signal === 1 || pressed) {
    e = isMobile ? e.touches[0] : e;
    point.x = e.clientX - left + 0.5; // 不加0.5,整数坐标处绘制直线,直线宽度将会多1px(不理解的不妨谷歌下)
    point.y = e.clientY - top + 0.5;
    paint(signal);
  }
};

以上代码中的left和top并非内置变量,它们分别表示着画布距屏幕左边和顶部的像素距离,主要用于将屏幕坐标点转换为画布坐标点。以下是一种获取方法:

const { left, top } = canvas.getBoundingClientRect();

很明显,上述的事件生成器是一个高阶函数,用于固化signal参数并返回一个新的Function。基于此,start和move回调便呈现了。

const start = create(1);
const move = create(2);

为了避免UI过度绘制,让move操作执行得更加流畅,requestAnimationFrame优化自然是少不了的。

const requestAnimationFrame = window.requestAnimationFrame;
const optimizedMove = requestAnimationFrame ? (e) => {
  requestAnimationFrame(() => {
    move(e);
  });
} : move;

剩下的也是绑定事件中关键的一步。PC端中,mousedown和mousemove没有先后顺序,不是每一次画布之上的鼠标移动都是有效的操作,因此我们使用pressed变量来保证mousemove事件回调只在mousedown事件之后执行。实际上,设置后的pressed变量总需要还原,还原的契机就是mouseup和mouseleave回调,由于mouseup事件并不总能触发(比如说鼠标移动到别的节点上才弹起,此时触发的是其他节点的mouseup事件),mouseleave便是鼠标移出画布时的兜底逻辑。而移动端的touch事件,其天然的连续性,保证了touchmove只会在touchstart之后触发,因此无须设置pressed变量,也不需要还原它。代码如下:

if (isMobile) {
  canvas.addEventListener('touchstart', start);
  canvas.addEventListener('touchmove', optimizedMove);
} else {
  canvas.addEventListener('mousedown', start);
  canvas.addEventListener('mousemove', optimizedMove);
  ['mouseup', 'mouseleave'].forEach((event) => {
    canvas.addEventListener(event, () => {
      pressed = false;
    });
  });
}

旋转

想要在移动端签名,往往面临着屏幕宽度不够的尴尬。竖屏下写不了几个汉字,甚至三个都够呛。如果app webview或浏览器不支持横屏展示,此时并不是意味着没有了办法,起码我们可以将整个网页旋转90°。

方案一:起初我的想法是将画布也一同旋转90°,后来发现难以处理旋转后的坐标系和屏幕坐标系的对应关系,因此我采取了旋转90°绘制页面,但是正常布局画布的方案,从而保证坐标系的一致性(这样就不用重新纠正canvas画布的坐标系了,关于纠正坐标系后续还有方案二,请耐心阅读)。

由于用户是横屏操作画布的,完成签名后,图片需要逆时针旋转90°才能保上传到服务器。因此还差一个旋转的方法。实际上,rotate方法可以旋转画布,drawImage方法可以在新的画布中绘制一张图片或老的画布,这种绘制的定制化程度很高。

rotate

rotate用于旋转当前的画布。

语法: rotate(angle),angle表示旋转的弧度,这里需要将角度转换为弧度计算,比如顺时针旋转90°,angle的值就等于-90 * Math.PI / 180。ratate旋转时默认以画布左上角为中心,如果需要以画布中心位置为中心,需要在rotate方法执行前将画布的坐标原点移至中心位置,旋转完成后,再移动回来。如下:

const { width, height } = canvas;
context.translate(width / 2, height / 2); // 坐标原点移至画布中心
context.rotate(90 * Math.PI / 180); // 顺时针旋转90°
context.translate(-width / 2, -height / 2); // 坐标原点还原到起始位置

实际上,这种变换处理,使用transform(Math.cos(90 * Math.PI / 180), 1, -1, Math.cos(90 * Math.PI / 180), 0, 0)同样可以顺时针旋转90°。

drawImage

drawImage用于绘制图片、画布或者视频,可自定义宽高、位置、甚至局部裁剪。它有三种形态的api:

  • drawImage(img,x,y),x,y为画布中的坐标,img可以是图片、画布或视频资源,表示在画布的指定坐标处绘制。
  • drawImage(img,x,y,width,height),width,height表示指定图片绘制后的宽高(可以任意缩放或调整宽高比例)。
  • context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height),sx,sy表示从指定的坐标位置裁剪原始图片,并且裁剪swidth的宽度和sheight的高度。

通常情况下,我们可能需要旋转一张图片90°、180°或者-90°。代码如下:

const rotate = (degree, image) => {
  degree = ~~degree;
  if (degree !== 0) {
    const maxDegree = 180;
    const minDegree = -90;
    if (degree > maxDegree) {
      degree = maxDegree;
    } else if (degree < minDegree) {
      degree = minDegree;
    }

    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    const height = image.height;
    const width = image.width;
    const angle = (degree * Math.PI) / 180;

    switch (degree) {
      // 逆时针旋转90°
      case -90:
        canvas.width = height;
        canvas.height = width;
        context.rotate(angle);
        context.drawImage(image, -width, 0);
        break;
      // 顺时针旋转90°
      case 90:
        canvas.width = height;
        canvas.height = width;
        context.rotate(angle);
        context.drawImage(image, 0, -height);
        break;
      // 顺时针旋转180°
      case 180:
        canvas.width = width;
        canvas.height = height;
        context.rotate(angle);
        context.drawImage(image, -width, -height);
        break;
    }
    image = canvas;
  }
  return image;
};

缩放

旋转后的画布,通常需要进一步格式化其宽高才能上传。此处还是利用drawImage去改变画布宽高,以达到缩小和放大的目的。如下:

const scale = (width, height) => {
  const w = canvas.width;
  const h = canvas.height;
  width = width || w;
  height = height || h;
  if (width !== w || height !== h) {
    const tmpCanvas = document.createElement('canvas');
    const tmpContext = tmpCanvas.getContext('2d');
    tmpCanvas.width = width;
    tmpCanvas.height = height;
    tmpContext.drawImage(canvas, 0, 0, w, h, 0, 0, width, height);
    canvas = tmpCanvas;
  }
  return canvas;
};

上传

我们做了这么多的操作和转换,最终的目的还是上传图片。

首先,获取画布中的图片:

const getPNGImage = () => {
  return canvas.toDataURL('image/png');
};

getPNGImage方法返回的是dataURL,需要转换为Blob对象才能上传。如下:

const dataURLtoBlob = (dataURL) => {
  const arr = dataURL.split(',');
  const mime = arr[0].match(/:(.*?);/)[1];
  const bStr = 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 });
};

完成了上面这些,才能一波ajax请求(xhr、fetch、axios都可)带走签名图片。

const upload = (blob, url, callback) => {
  const formData = new FormData();
  const xhr = new XMLHttpRequest();
  xhr.withCredentials = true;
  formData.append('image', blob, 'sign');

  xhr.open('POST', url, true);
  xhr.onload = () => {
    if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
      callback(xhr.responseText);
    }
  };
  xhr.onerror = (e) => {
    console.log(`upload img error: ${e}`);
  };
  xhr.send(formData);
};

设置

完成了上述功能,一个签名插件就已经成型了。除非你迫不及待想要发布,否则,这样的代码我是不建议拿出去的。一些必要的设置通常是不能忽略的。

通常画布中的直线是1px大小,这么细的线,是不能模拟笔触的,可如果你要放大至10px,便会发现,绘制的直线其实是矩形。这在签名过程中也是不合适的,我们期望的是圆滑的笔触,因此需要尽量模拟手写。实际上,lineCap就可指定直线首尾圆滑,lineJoin可以指定线条交汇时的边角圆滑。如下是一个simple的设置:

context.lineWidth = 10;         // 直线宽度
context.strokeStyle = 'black';     // 路径的颜色
context.lineCap = 'round';         // 直线首尾端圆滑
context.lineJoin = 'round';     // 当两条线条交汇时,创建圆形边角
context.shadowBlur = 1;         // 边缘模糊,防止直线边缘出现锯齿
context.shadowColor = 'black';  // 边缘颜色

优化

一切看似很完美,直到遇到了retina屏幕。retina屏是用4个物理像素绘制一个虚拟像素,屏幕宽度相同的画布,其每个像素点都会由4倍物理像素去绘制,画布中点与点之间的距离增加,会产生较为明显的锯齿,可通过放大画布然后压缩展示来解决这个问题。

let { width, height } = window.getComputedStyle(canvas, null);
width = width.replace('px', '');
height = height.replace('px', '');

// 根据设备像素比优化canvas绘图
const devicePixelRatio = window.devicePixelRatio;
if (devicePixelRatio) {
  canvas.style.width = `${width}px`;
  canvas.style.height = `${height}px`;
  canvas.height = height * devicePixelRatio; // 画布宽高放大
  canvas.width = width * devicePixelRatio;
  context.scale(devicePixelRatio, devicePixelRatio); // 画布内容放大相同的倍数
} else {
  canvas.width = width;
  canvas.height = height;
}

重置坐标系

由于采取了方案一,签名的工作流变成了:『页面顺时针旋转90°绘制、画布正常竖屏绘制』—>『手写签名』—>『逆时针旋转画布90°』—> 『合理缩放画布至屏幕宽度』—> 『导出图片并上传』。由此可见方案一流程复杂,处理起来也比较麻烦。

换个角度想想,既然画布是可以旋转的,我刚好可以利用这种坐标系的反向旋转去抵消页面的正向旋转,这样页面上点的坐标就可以映射到画布本身的坐标上。于是有了方案二。

方案二:页面顺时针旋转90°,画布跟随着一起旋转(画布的坐标系也跟着旋转90°);然后再逆向旋转画布90°,重置画布的坐标系,使之与页面坐标系映射起来。

顺时针旋转90°的页面如下所示:

页面顺时针旋转90°

此时canvas画布也随着页面顺时针旋转90°,想要重置画布坐标系,可借由rotate逆向旋转90°,然后由translate平移坐标系。以下代码包含了顺逆时针旋转90°、180° 的处理(为了便于描述,假设画布充满屏幕):

context.rotate((degree * Math.PI) / 180);
switch (degree) {
  // 页面顺时针旋转90°后,画布左上角的原点位置落到了屏幕的右上角(此时宽高互换),围绕原点逆时针旋转90°后,画布与原位置垂直,居于屏幕右侧,需要向左平移画布当前高度相同的距离。
  case -90:
    context.translate(-height, 0);
    break;
  // 页面逆时针旋转90°后,画布左上角的原点位置落到了屏幕的左下角(此时宽高互换),围绕原点顺时针旋转90°后,画布与原位置垂直,居于屏幕下侧,需要向上平移画布当前宽度相同的距离。
  case 90:
    context.translate(0, -width);
    break;
  // 页面顺逆时针旋转180°回到了同一个位置(即页面倒立),画布左上角的原点位置落到了屏幕的右下角(此时宽高不变),围绕原点反方向旋转180°后,画布与原位置平行,居于屏幕右侧的下侧,需要向左平移画布宽度相同的距离,向右平移画布高度的距离。
  case -180:
  case 180:
    context.translate(-width, -height);
}

拥有了对画布坐标系重置的能力,我们能够将画布逆时针旋转90°、甚至180°,都是可行的。如下:

页面逆时针旋转90°

页面顺时针旋转180°

当然重置画布坐标系后,需要注意清屏时,清屏的范围也有可能发生变化,需要稍作如下处理。

const clear = () => {
  let width;
  let height;
  switch (this.degree) { // this.degree是画布坐标系旋转的度数
    case -90:
    case 90:
      width = this.height; // 画布旋转之前的高度
      height = this.width; // 画布选择之前的宽度
      break;
    default:
      width = this.width;
      height = this.height;
  }
  this.context.clearRect(0, 0, width, height);
};

方案一简单粗暴,布局上,canvas画布虽然不需要旋转,但需要单独绝对定位布局,给页面视觉展示带来不便,同时,上传图片之前需要对图片做旋转、缩放等处理,流程复杂。

方案二用纠正画布坐标系的方式,省去了布局和图片上的特殊处理,一步到位,因此方案二更佳。

以上,涉及的代码可以在这里找到:canvas-draw,这是一个借助vue cli 搭建起来的壳,主要是为了方便调试,核心代码见 canvas-draw/draw.js,喜欢的同学不妨轻点star。


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者:louis

本文链接: http://louiszhai.github.io/2017/07/07/canvas-draw/

参考文章:

]]>
canvas 签名 画图 抗锯齿 重置canvas画布坐标系 canvas旋转 canvas缩放 canvas下载
【深度长文】JavaScript数组所有API全解密 http://louiszhai.github.io/2017/04/28/array/ 2017-04-28T05:43:36.000Z 2019-12-02T03:28:32.873Z 本文首发于CSDN网站,下面的版本又经过进一步的修订。

全文共13k+字,系统讲解了JavaScript数组的各种特性和API。

数组是一种非常重要的数据类型,它语法简单、灵活、高效。 在多数编程语言中,数组都充当着至关重要的角色,以至于很难想象没有数组的编程语言会是什么模样。特别是JavaScript,它天生的灵活性,又进一步发挥了数组的特长,丰富了数组的使用场景。可以毫不夸张地说,不深入地了解数组,不足以写JavaScript。

截止ES7规范,数组共包含33个标准的API方法和一个非标准的API方法,使用场景和使用方案纷繁复杂,其中有不少浅坑、深坑、甚至神坑。下面将从Array构造器及ES6新特性开始,逐步帮助你掌握数组。

声明:以下未特别标明的方法均为ES5已实现的方法。

Array构造器

Array构造器用于创建一个新的数组。通常,我们推荐使用对象字面量创建数组,这是一个好习惯,但是总有对象字面量乏力的时候,比如说,我想创建一个长度为8的空数组。请比较如下两种方式:

// 使用Array构造器
var a = Array(8); // [undefined × 8]
// 使用对象字面量
var b = [];
b.length = 8; // [undefined × 8]

Array构造器明显要简洁一些,当然你也许会说,对象字面量也不错啊,那么我保持沉默。

如上,我使用了Array(8)而不是new Array(8),这会有影响吗?实际上,并没有影响,这得益于Array构造器内部对this指针的判断,ELS5_HTML规范是这么说的:

When Array is called as a function rather than as a constructor, it creates and initialises a new Array object. Thus the function call Array(…) is equivalent to the object creation expression new Array(…) with the same arguments.

从规范来看,浏览器内部大致做了如下类似的实现:

function Array(){
  // 如果this不是Array的实例,那就重新new一个实例
  if(!(this instanceof arguments.callee)){
    return new arguments.callee();
  }
}

上面,我似乎跳过了对Array构造器语法的介绍,没事,接下来我补上。

Array构造器根据参数长度的不同,有如下两种不同的处理:

  • new Array(arg1, arg2,…),参数长度为0或长度大于等于2时,传入的参数将按照顺序依次成为新数组的第0至N项(参数长度为0时,返回空数组)。
  • new Array(len),当len不是数值时,处理同上,返回一个只包含len元素一项的数组;当len为数值时,根据如下规范,len最大不能超过32位无符号整型,即需要小于2的32次方(len最大为Math.pow(2,32) -1-1>>>0),否则将抛出RangeError。

If the argument len is a Number and ToUint32(len) is equal to len, then the length property of the newly constructed object is set to ToUint32(len). If the argument len is a Number and ToUint32(len) is not equal to len, a RangeError exception is thrown.

以上,请注意Array构造器对于单个数值参数的特殊处理,如果仅仅需要使用数组包裹📦 若干参数,不妨使用Array.of,具体请移步下一节。

ES6新增的构造函数方法

鉴于数组的常用性,ES6专门扩展了数组构造器Array ,新增2个方法:Array.ofArray.from。下面展开来聊。

Array.of

Array.of用于将参数依次转化为数组中的一项,然后返回这个新数组,而不管这个参数是数字还是其它。它基本上与Array构造器功能一致,唯一的区别就在单个数字参数的处理上。如下:

Array.of(8.0); // [8]
Array(8.0); // [empty × 8]

参数为多个,或单个参数不是数字时,Array.of 与 Array构造器等同。

Array.of(8.0, 5); // [8, 5]
Array(8.0, 5); // [8, 5]

Array.of('8'); // ["8"]
Array('8'); // ["8"]

因此,若是需要使用数组包裹元素,推荐优先使用Array.of方法。

目前,以下版本浏览器提供了对Array.of的支持。

Chrome Firefox Edge Safari
45+ 25+ ✔️ 9.0+

即使其他版本浏览器不支持也不必担心,由于Array.of与Array构造器的这种高度相似性,实现一个polyfill十分简单。如下:

if (!Array.of){
  Array.of = function(){
    return Array.prototype.slice.call(arguments);
  };
}

Array.from

语法:Array.from(arrayLike[, processingFn[, thisArg]])

Array.from的设计初衷是快速便捷的基于其他对象创建新数组,准确来说就是从一个类似数组的可迭代对象创建一个新的数组实例,说人话就是,只要一个对象有迭代器,Array.from就能把它变成一个数组(当然,是返回新的数组,不改变原对象)。

从语法上看,Array.from拥有3个形参,第一个为类似数组的对象,必选。第二个为加工函数,新生成的数组会经过该函数的加工再返回。第三个为this作用域,表示加工函数执行时this的值。后两个参数都是可选的。我们来看看用法。

var obj = {0: 'a', 1: 'b', 2:'c', length: 3};
Array.from(obj, function(value, index){
  console.log(value, index, this, arguments.length);
  return value.repeat(3); //必须指定返回值,否则返回undefined
}, obj);

执行结果如下:

Array.from执行结果

可以看到加工函数的this作用域被obj对象取代,也可以看到加工函数默认拥有两个形参,分别为迭代器当前元素的值和其索引。

注意,一旦使用加工函数,必须明确指定返回值,否则将隐式返回undefined,最终生成的数组也会变成一个只包含若干个undefined元素的空数组。

实际上,如果不需要指定this,加工函数完全可以是一个箭头函数。上述代码可以简化如下:

Array.from(obj, (value) => value.repeat(3));

除了上述obj对象以外,拥有迭代器的对象还包括这些:StringSetMaparguments 等,Array.from统统可以处理。如下所示:

// String
Array.from('abc'); // ["a", "b", "c"]
// Set
Array.from(new Set(['abc', 'def'])); // ["abc", "def"]
// Map
Array.from(new Map([[1, 'abc'], [2, 'def']])); // [[1
, 'abc'], [2, 'def']]
// 天生的类数组对象arguments
function fn(){
  return Array.from(arguments);
}
fn(1, 2, 3); // [1, 2, 3]

到这你可能以为Array.from就讲完了,实际上还有一个重要的扩展场景必须提下。比如说生成一个从0到指定数字的新数组,Array.from就可以轻易的做到。

Array.from({length: 10}, (v, i) => i); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

后面我们将会看到,利用数组的keys方法实现上述功能,可能还要简单一些。

目前,以下版本浏览器提供了对Array.from的支持。

Chrome Firefox Edge Opera Safari
45+ 32+ ✔️ ✔️ 9.0+

Array.isArray

顾名思义,Array.isArray用来判断一个变量是否数组类型。JS的弱类型机制导致判断变量类型是初级前端开发者面试时的必考题,一般我都会将其作为考察候选人第一题,然后基于此展开。在ES5提供该方法之前,我们至少有如下5种方式去判断一个值是否数组:

var a = [];
// 1.基于instanceof
a instanceof Array;
// 2.基于constructor
a.constructor === Array;
// 3.基于Object.prototype.isPrototypeOf
Array.prototype.isPrototypeOf(a);
// 4.基于getPrototypeOf
Object.getPrototypeOf(a) === Array.prototype;
// 5.基于Object.prototype.toString
Object.prototype.toString.apply(a) === '[object Array]';

以上,除了Object.prototype.toString外,其它方法都不能正确判断变量的类型。

要知道,代码的运行环境十分复杂,一个变量可能使用浑身解数去迷惑它的创造者。且看:

var a = {
  __proto__: Array.prototype
};
// 分别在控制台试运行以下代码
// 1.基于instanceof
a instanceof Array; // true
// 2.基于constructor
a.constructor === Array; // true
// 3.基于Object.prototype.isPrototypeOf
Array.prototype.isPrototypeOf(a); // true
// 4.基于getPrototypeOf
Object.getPrototypeOf(a) === Array.prototype; // true

以上,4种方法将全部返回true,为什么呢?我们只是手动指定了某个对象的__proto__属性为Array.prototype,便导致了该对象继承了Array对象,这种毫不负责任的继承方式,使得基于继承的判断方案瞬间土崩瓦解。

不仅如此,我们还知道,Array是堆数据,变量指向的只是它的引用地址,因此每个页面的Array对象引用的地址都是不一样的。iframe中声明的数组,它的构造函数是iframe中的Array对象。如果在iframe声明了一个数组x,将其赋值给父页面的变量y,那么在父页面使用y instanceof Array ,结果一定是false的。而最后一种返回的是字符串,不会存在引用问题。实际上,多页面或系统之间的交互只有字符串能够畅行无阻。

鉴于上述的两点原因,故笔者推荐使用最后一种方法去撩面试官(别提是我说的),如果你还不信,这里恰好有篇文章跟我持有相同的观点:Determining with absolute accuracy whether or not a JavaScript object is an array

相反,使用Array.isArray则非常简单,如下:

Array.isArray([]); // true
Array.isArray({0: 'a', length: 1}); // false

目前,以下版本浏览器提供了对Array.isArray的支持。

Chrome Firefox IE Opera Safari
5+ 4+ 9+ 10.5+ 5+

实际上,通过Object.prototype.toString去判断一个值的类型,也是各大主流库的标准。因此Array.isArray的polyfill通常长这样:

if (!Array.isArray){
  Array.isArray = function(arg){
    return Object.prototype.toString.call(arg) === '[object Array]';
  };
}

数组推导

ES6对数组的增强不止是体现在api上,还包括语法糖。比如说for of,它就是借鉴其它语言而成的语法糖,这种基于原数组使用for of生成新数组的语法糖,叫做数组推导数组推导最初起早在ES6的草案中,但在第27版(2014年8月)中被移除,目前只有Firefox v30+支持,推导有风险,使用需谨慎。所幸如今这些语言都还支持推导:CoffeeScript、Python、Haskell、Clojure,我们可以从中一窥端倪。这里我们以python的for in推导打个比方:

# python for in 推导
a = [1, 2, 3, 4]
print [i * i for i in a if i == 3] # [9]

如下是SpiderMonkey引擎(Firefox)之前基于ES4规范实现的数组推导(与python的推导十分相似):

[i * i for (i of a)] // [1, 4, 9, 16]

ES6中数组有关的for of在ES4的基础上进一步演化,for关键字居首,in在中间,最后才是运算表达式。如下:

[for (i of [1, 2, 3, 4]) i * i] // [1, 4, 9, 16]

同python的示例,ES6中数组有关的for of也可以使用if语句:

// 单个if
[for (i of [1, 2, 3, 4]) if (i == 3) i * i] // [9]
// 甚至是多个if
[for (i of [1, 2, 3, 4]) if (i > 2) if (i < 4) i * i] // [9]

更为强大的是,ES6数组推导还允许多重for of

[for (i of [1, 2, 3]) for (j of [10, 100]) i * j] // [10, 100, 20, 200, 30, 300]

甚至,数组推导还能够嵌入另一个数组推导中。

[for (i of [1, 2, 3]) [for (j of [10, 100]) i * j] ] // [[10, 100], [20, 200], [30, 300]]

对于上述两个表达式,前者和后者唯一的区别,就在于后者的第二个推导是先返回数组,然后与外部的推导再进行一次运算。

除了多个数组推导嵌套外,ES6的数组推导还会为每次迭代分配一个新的作用域(目前Firefox也没有为每次迭代创建新的作用域):

// ES6规范
[for (x of [0, 1, 2]) () => x][0]() // 0
// Firefox运行
[for (x of [0, 1, 2]) () => x][0]() // 2

通过上面的实例,我们看到使用数组推导来创建新数组比forEachmapfilter等遍历方法更加简洁,只是非常可惜,它不是标准规范。

ES6不仅新增了对Array构造器相关API,还新增了8个原型的方法。接下来我会在原型方法的介绍中穿插着ES6相关方法的讲解,请耐心往下读。

原型

继承的常识告诉我们,js中所有的数组方法均来自于Array.prototype,和其他构造函数一样,你可以通过扩展 Arrayprototype 属性上的方法来给所有数组实例增加方法。

值得一说的是,Array.prototype本身就是一个数组。

Array.isArray(Array.prototype); // true
console.log(Array.prototype.length);// 0

以下方法可以进一步验证:

console.log([].__proto__.length);// 0
console.log([].__proto__);// [Symbol(Symbol.unscopables): Object]

有关Symbol(Symbol.unscopables)的知识,这里不做详述,具体请移步后续章节。

方法

数组原型提供的方法非常之多,主要分为三种,一种是会改变自身值的,一种是不会改变自身值的,另外一种是遍历方法。

由于 Array.prototype 的某些属性被设置为[[DontEnum]],因此不能用一般的方法进行遍历,我们可以通过如下方式获取 Array.prototype 的所有方法:

Object.getOwnPropertyNames(Array.prototype); // ["length", "constructor", "toString", "toLocaleString", "join", "pop", "push", "reverse", "shift", "unshift", "slice", "splice", "sort", "filter", "forEach", "some", "every", "map", "indexOf", "lastIndexOf", "reduce", "reduceRight", "copyWithin", "find", "findIndex", "fill", "includes", "entries", "keys", "concat"]

改变自身值的方法(9个)

基于ES6,改变自身值的方法一共有9个,分别为pop、push、reverse、shift、sort、splice、unshift,以及两个ES6新增的方法copyWithin 和 fill。

对于能改变自身值的数组方法,日常开发中需要特别注意,尽量避免在循环遍历中去改变原数组的项。接下来,我们一起来深入地了解这些方法。

pop

pop() 方法删除一个数组中的最后的一个元素,并且返回这个元素。如果是栈的话,这个过程就是栈顶弹出。

var array = ["cat", "dog", "cow", "chicken", "mouse"];
var item = array.pop();
console.log(array); // ["cat", "dog", "cow", "chicken"]
console.log(item); // mouse

由于设计上的巧妙,pop方法可以应用在类数组对象上,即 鸭式辨型. 如下:

var o = {0:"cat", 1:"dog", 2:"cow", 3:"chicken", 4:"mouse", length:5}
var item = Array.prototype.pop.call(o);
console.log(o); // Object {0: "cat", 1: "dog", 2: "cow", 3: "chicken", length: 4}
console.log(item); // mouse

但如果类数组对象不具有length属性,那么该对象将被创建length属性,length值为0。如下:

var o = {0:"cat", 1:"dog", 2:"cow", 3:"chicken", 4:"mouse"}
var item = Array.prototype.pop.call(o);
console.log(array); // Object {0: "cat", 1: "dog", 2: "cow", 3: "chicken", 4: "mouse", length: 0}
console.log(item); // undefined
push

push()方法添加一个或者多个元素到数组末尾,并且返回数组新的长度。如果是栈的话,这个过程就是栈顶压入。

语法:arr.push(element1, …, elementN)

var array = ["football", "basketball", "volleyball", "Table tennis", "badminton"];
var i = array.push("golfball");
console.log(array); // ["football", "basketball", "volleyball", "Table tennis", "badminton", "golfball"]
console.log(i); // 6

同pop方法一样,push方法也可以应用到类数组对象上,如果length不能被转成一个数值或者不存在length属性时,则插入的元素索引为0,且length属性不存在时,将会创建它。

var o = {0:"football", 1:"basketball"};
var i = Array.prototype.push.call(o, "golfball");
console.log(o); // Object {0: "golfball", 1: "basketball", length: 1}
console.log(i); // 1

实际上,push方法是根据length属性来决定从哪里开始插入给定的值。

var o = {0:"football", 1:"basketball",length:1};
var i = Array.prototype.push.call(o,"golfball");
console.log(o); // Object {0: "football", 1: "golfball", length: 2}
console.log(i); // 2

利用push根据length属性插入元素这个特点,可以实现数组的合并,如下:

var array = ["football", "basketball"];
var array2 = ["volleyball", "golfball"];
var i = Array.prototype.push.apply(array,array2);
console.log(array); // ["football", "basketball", "volleyball", "golfball"]
console.log(i); // 4
reverse

reverse()方法颠倒数组中元素的位置,第一个会成为最后一个,最后一个会成为第一个,该方法返回对数组的引用。

语法:arr.reverse()

var array = [1,2,3,4,5];
var array2 = array.reverse();
console.log(array); // [5,4,3,2,1]
console.log(array2===array); // true

同上,reverse 也是鸭式辨型的受益者,颠倒元素的范围受length属性制约。如下:

var o = {0:"a", 1:"b", 2:"c", length:2};
var o2 = Array.prototype.reverse.call(o);
console.log(o); // Object {0: "b", 1: "a", 2: "c", length: 2}
console.log(o === o2); // true

如果 length 属性小于2 或者 length 属性不为数值,那么原类数组对象将没有变化。即使 length 属性不存在,该对象也不会去创建 length 属性。特别的是,当 length 属性较大时,类数组对象的『索引』会尽可能的向 length 看齐。如下:

var o = {0:"a", 1:"b", 2:"c",length:100};
var o2 = Array.prototype.reverse.call(o);
console.log(o); // Object {97: "c", 98: "b", 99: "a", length: 100}
console.log(o === o2); // true
shift

shift()方法删除数组的第一个元素,并返回这个元素。如果是栈的话,这个过程就是栈底弹出。

语法:arr.shift()

var array = [1,2,3,4,5];
var item = array.shift();
console.log(array); // [2,3,4,5]
console.log(item); // 1

同样受益于鸭式辨型,对于类数组对象,shift仍然能够处理。如下:

var o = {0:"a", 1:"b", 2:"c", length:3};
var item = Array.prototype.shift.call(o);
console.log(o); // Object {0: "b", 1: "c", length: 2}
console.log(item); // a

如果类数组对象length属性不存在,将添加length属性,并初始化为0。如下:

var o = {0:"a", 1:"b", 2:"c"};
var item = Array.prototype.shift.call(o);
console.log(o); // Object {0: "a", 1: "b", 2:"c" length: 0}
console.log(item); // undefined
sort

sort()方法对数组元素进行排序,并返回这个数组。sort方法比较复杂,这里我将多花些篇幅来讲这块。

语法:arr.sort([comparefn])

comparefn是可选的,如果省略,数组元素将按照各自转换为字符串的Unicode(万国码)位点顺序排序,例如”Boy”将排到”apple”之前。当对数字排序的时候,25将会排到8之前,因为转换为字符串后,”25”将比”8”靠前。例如:

var array = ["apple","Boy","Cat","dog"];
var array2 = array.sort();
console.log(array); // ["Boy", "Cat", "apple", "dog"]
console.log(array2 == array); // true

array = [10, 1, 3, 20];
var array3 = array.sort();
console.log(array3); // [1, 10, 20, 3]

如果指明了comparefn,数组将按照调用该函数的返回值来排序。若 a 和 b 是两个将要比较的元素:

  • 若 comparefn(a, b) < 0,那么a 将排到 b 前面;
  • 若 comparefn(a, b) = 0,那么a 和 b 相对位置不变;
  • 若 comparefn(a, b) > 0,那么a , b 将调换位置;

如果数组元素为数字,则排序函数comparefn格式如下所示:

function compare(a, b){
  return a-b;
}

如果数组元素为非ASCII字符的字符串(如包含类似 e、é、è、a、ä 或中文字符等非英文字符的字符串),则需要使用String.localeCompare。下面这个函数将排到正确的顺序。

var array = ['互','联','网','改','变','世','界'];
var array2 = array.sort();

var array = ['互','联','网','改','变','世','界']; // 重新赋值,避免干扰array2
var array3 = array.sort(function (a, b) {
  return a.localeCompare(b);
});

console.log(array2); // ["世", "互", "变", "改", "界", "网", "联"]
console.log(array3); // ["变", "改", "互", "界", "联", "世", "网"]

如上,『互联网改变世界』这个数组,sort函数默认按照数组元素unicode字符串形式进行排序,然而实际上,我们期望的是按照拼音先后顺序进行排序,显然String.localeCompare 帮助我们达到了这个目的。

为什么上面测试中需要重新给array赋值呢,这是因为sort每次排序时改变的是数组本身,并且返回数组引用。如果不这么做,经过连续两次排序后,array2 和 array3 将指向同一个数组,最终影响我们测试。array重新赋值后就断开了对原数组的引用。

同上,sort一样受益于鸭式辨型,比如:

var o = {0:'互',1:'联',2:'网',3:'改',4:'变',5:'世',6:'界',length:7};
Array.prototype.sort.call(o,function(a, b){
  return a.localeCompare(b);
});
console.log(o); // Object {0: "变", 1: "改", 2: "互", 3: "界", 4: "联", 5: "世", 6: "网", length: 7}, 可见同上述排序结果一致

注意:使用sort的鸭式辨型特性时,若类数组对象不具有length属性,它并不会进行排序,也不会为其添加length属性。

var o = {0:'互',1:'联',2:'网',3:'改',4:'变',5:'世',6:'界'};
Array.prototype.sort.call(o,function(a, b){
  return a.localeCompare(b);
});
console.log(o); // Object {0: "互", 1: "联", 2: "网", 3: "改", 4: "变", 5: "世", 6: "界"}, 可见并未添加length属性
使用映射改善排序

comparefn 如果需要对数组元素多次转换以实现排序,那么使用map辅助排序将是个不错的选择。基本思想就是将数组中的每个元素实际比较的值取出来,排序后再将数组恢复。

// 需要被排序的数组
var array = ['dog', 'Cat', 'Boy', 'apple'];
// 对需要排序的数字和位置的临时存储
var mapped = array.map(function(el, i) {
  return { index: i, value: el.toLowerCase() };
})
// 按照多个值排序数组
mapped.sort(function(a, b) {
  return +(a.value > b.value) || +(a.value === b.value) - 1;
});
// 根据索引得到排序的结果
var result = mapped.map(function(el){
  return array[el.index];
});
console.log(result); // ["apple", "Boy", "Cat", "dog"]
奇怪的chrome

实际上,ECMAscript规范中并未规定具体的sort算法,这就势必导致各个浏览器不尽相同的sort算法,请看sort方法在Chrome浏览器下表现:

var array = [{ n: "a", v: 1 }, { n: "b", v: 1 }, { n: "c", v: 1 }, { n: "d", v: 1 }, { n: "e", v: 1 }, { n: "f", v: 1 }, { n: "g", v: 1 }, { n: "h", v: 1 }, { n: "i", v: 1 }, { n: "j", v: 1 }, { n: "k", v: 1 }, ];
array.sort(function (a, b) {
    return a.v - b.v;
});
for (var i = 0,len = array.length; i < len; i++) {
    console.log(array[i].n);
}
// f a c d e b g h i j k

由于v值相等,array数组排序前后应该不变,然而Chrome却表现异常,而其他浏览器(如IE 或 Firefox) 表现正常。

这是因为v8引擎为了高效排序(采用了不稳定排序)。即数组长度超过10条时,会调用另一种排序方法(快速排序);而10条及以下采用的是插入排序,此时结果将是稳定的,如下:

var array = [{ n: "a", v: 1 }, { n: "b", v: 1 }, { n: "c", v: 1 }, { n: "d", v: 1 }, { n: "e", v: 1 }, { n: "f", v: 1 }, { n: "g", v: 1 }, { n: "h", v: 1 }, { n: "i", v: 1 }, { n: "j", v: 1 },];
array.sort(function (a, b) {
  return a.v - b.v;
});
for (var i = 0,len = array.length; i < len; i++) {
  console.log(array[i].n);
}
// a b c d e f g h i j

从a 到 j 刚好10条数据。

那么我们该如何规避Chrome浏览器的这种”bug”呢?其实很简单,只需略动手脚,改变排序方法的返回值即可,如下:

// 由于快速排序会打乱值相同的元素的默认排序,因此我们需要先标记元素的默认位置
array.forEach(function(v, k){
  v.__index = k;
});
array.sort(function (a, b) {
  // 由于__index标记了初始顺序,这样的返回才保证了值相同元素的顺序不变,进而使得排序稳定
  return a.v - b.v || a.__index - b.__index;
});

使用数组的sort方法需要注意一点:各浏览器的针对sort方法内部算法实现不尽相同,排序函数尽量只返回-1、0、1三种不同的值,不要尝试返回true或false等其它数值,因为可能导致不可靠的排序结果。

问题分析

sort方法传入的排序函数如果返回布尔值会导致什么样的结果呢?

以下是常见的浏览器以及脚本引擎:

Browser Name ECMAScript Engine
Internet Explorer 6 - 8 JScript
Internet Explorer 9 - 10 Chakra
Firefox SpiderMonkey, IonMonkey, TraceMonkey
Chrome V8
Safair JavaScriptCore(SquirrelFish Extreme)
Opera Carakan

分析以下代码,预期将数组元素进行升序排序:

var array = [7, 6, 5, 4, 3, 2, 1, 0, 8, 9];
var comparefn = function (x, y) {
  return x > y;
};
array.sort(comparefn);

代码中,comparefn 函数返回值为 bool 类型,并非为规范规定的 -1、0、1 值。那么执行此代码,各 JS 脚本引擎实现情况如何?

输出结果 是否符合预期
JScript [2, 3, 5, 1, 4, 6, 7, 0, 8, 9]
Carakan [0, 1, 3, 8, 2, 4, 9, 5, 6, 7]
Chakra & JavaScriptCore [7, 6, 5, 4, 3, 2, 1, 0, 8, 9]
SpiderMonkey [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
V8 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

根据表中数据可见,当数组内元素个数小于等于 10 时,现象如下:

  • JScript & Carakan 排序结果有误
  • Chakra & JavaScriptCore 看起来没有进行排序
  • SpiderMonkey 返回了预期的正确结果
  • V8 暂时看起来排序正确

将数组元素扩大至 11 位,现象如下:

var array = [7, 6, 5, 4, 3, 2, 1, 0, 10, 9, 8];
var comparefn = function (x, y) {
  return x > y;
};
array.sort(comparefn);
JavaScript引擎 输出结果 是否符合预期
JScript [2, 3, 5, 1, 4, 6, 7, 0, 8, 9, 10]
Carakan [0, 1, 3, 8, 2, 4, 9, 5, 10, 6, 7]
Chakra & JavaScriptCore [7, 6, 5, 4, 3, 2, 1, 0, 10, 8, 9]
IonMonkey [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
V8 [5, 0, 1, 2, 3, 4, 6, 7, 8, 9, 10]

根据表中数据可见,当数组内元素个数大于 10 时:

  • JScript & Carakan 排序结果有误
  • Chakra & JavaScriptCore 看起来没有进行排序
  • SpiderMonkey 返回了预期的正确结果
  • V8 排序结果由正确转为不正确
splice

splice()方法用新元素替换旧元素的方式来修改数组。它是一个常用的方法,复杂的数组操作场景通常都会有它的身影,特别是需要维持原数组引用时,就地删除或者新增元素,splice是最适合的。

语法:arr.splice(start,deleteCount[, item1[, item2[, …]]])

start 指定从哪一位开始修改内容。如果超过了数组长度,则从数组末尾开始添加内容;如果是负值,则其指定的索引位置等同于 length+start (length为数组的长度),表示从数组末尾开始的第 -start 位。

deleteCount 指定要删除的元素个数,若等于0,则不删除。这种情况下,至少应该添加一位新元素,若大于start之后的元素总和,则start及之后的元素都将被删除。

itemN 指定新增的元素,如果缺省,则该方法只删除数组元素。

返回值 由原数组中被删除元素组成的数组,如果没有删除,则返回一个空数组。

下面来举栗子说明:

var array = ["apple","boy"];
var splices = array.splice(1,1);
console.log(array); // ["apple"]
console.log(splices); // ["boy"] ,可见是从数组下标为1的元素开始删除,并且删除一个元素,由于itemN缺省,故此时该方法只删除元素

array = ["apple","boy"];
splices = array.splice(2,1,"cat");
console.log(array); // ["apple", "boy", "cat"]
console.log(splices); // [], 可见由于start超过数组长度,此时从数组末尾开始添加元素,并且原数组不会发生删除行为

array = ["apple","boy"];
splices = array.splice(-2,1,"cat");
console.log(array); // ["cat", "boy"]
console.log(splices); // ["apple"], 可见当start为负值时,是从数组末尾开始的第-start位开始删除,删除一个元素,并且从此处插入了一个元素

array = ["apple","boy"];
splices = array.splice(-3,1,"cat");
console.log(array); // ["cat", "boy"]
console.log(splices); // ["apple"], 可见即使-start超出数组长度,数组默认从首位开始删除

array = ["apple","boy"];
splices = array.splice(0,3,"cat");
console.log(array); // ["cat"]
console.log(splices); // ["apple", "boy"], 可见当deleteCount大于数组start之后的元素总和时,start及之后的元素都将被删除

同上, splice一样受益于鸭式辨型, 比如:

var o = {0:"apple",1:"boy",length:2};
var splices = Array.prototype.splice.call(o,1,1);
console.log(o); // Object {0: "apple", length: 1}, 可见对象o删除了一个属性,并且length-1
console.log(splices); // ["boy"]

注意:如果类数组对象没有length属性,splice将为该类数组对象添加length属性,并初始化为0。(此处忽略举例,如果需要请在评论里反馈)

如果需要删除数组中一个已存在的元素,可参考如下:

var array = ['a','b','c'];
array.splice(array.indexOf('b'),1);
unshift

unshift() 方法用于在数组开始处插入一些元素(就像是栈底插入),并返回数组新的长度。

语法:arr.unshift(element1, …, elementN)

var array = ["red", "green", "blue"];
var length = array.unshift("yellow");
console.log(array); // ["yellow", "red", "green", "blue"]
console.log(length); // 4

如果给unshift方法传入一个数组呢?

var array = ["red", "green", "blue"];
var length = array.unshift(["yellow"]);
console.log(array); // [["yellow"], "red", "green", "blue"]
console.log(length); // 4, 可见数组也能成功插入

同上,unshift也受益于鸭式辨型,呈上栗子:

var o = {0:"red", 1:"green", 2:"blue",length:3};
var length = Array.prototype.unshift.call(o,"gray");
console.log(o); // Object {0: "gray", 1: "red", 2: "green", 3: "blue", length: 4}
console.log(length); // 4

注意:如果类数组对象不指定length属性,则返回结果是这样的 Object {0: "gray", 1: "green", 2: "blue", length: 1},shift会认为数组长度为0,此时将从对象下标为0的位置开始插入,相应位置属性将被替换,此时初始化类数组对象的length属性为插入元素个数。

copyWithin(ES6)

copyWithin() 方法基于ECMAScript 2015(ES6)规范,用于数组内元素之间的替换,即替换元素和被替换元素均是数组内的元素。

语法:arr.copyWithin(target, start[, end = this.length])

taget 指定被替换元素的索引,start 指定替换元素起始的索引,end 可选,指的是替换元素结束位置的索引。

如果start为负,则其指定的索引位置等同于length+start,length为数组的长度。end也是如此。

注:目前只有Firefox(版本32及其以上版本)实现了该方法。

var array = [1,2,3,4,5]; 
var array2 = array.copyWithin(0,3);
console.log(array===array2,array2); // true [4, 5, 3, 4, 5]

var array = [1,2,3,4,5]; 
console.log(array.copyWithin(0,3,4)); // [4, 2, 3, 4, 5]

var array = [1,2,3,4,5]; 
console.log(array.copyWithin(0,-2,-1)); // [4, 2, 3, 4, 5]

同上,copyWithin一样受益于鸭式辨型,例如:

var o = {0:1, 1:2, 2:3, 3:4, 4:5,length:5}
var o2 = Array.prototype.copyWithin.call(o,0,3);
console.log(o===o2,o2); // true Object { 0=4,  1=5,  2=3,  更多...}

如需在Firefox之外的浏览器使用copyWithin方法,请参考 Polyfill

fill(ES6)

fill() 方法基于ECMAScript 2015(ES6)规范,它同样用于数组元素替换,但与copyWithin略有不同,它主要用于将数组指定区间内的元素替换为某个值。

语法:arr.fill(value, start[, end = this.length])

value 指定被替换的值,start 指定替换元素起始的索引,end 可选,指的是替换元素结束位置的索引。

如果start为负,则其指定的索引位置等同于length+start,length为数组的长度。end也是如此。

注:目前只有Firefox(版本31及其以上版本)实现了该方法。

var array = [1,2,3,4,5];
var array2 = array.fill(10,0,3);
console.log(array===array2,array2); // true [10, 10, 10, 4, 5], 可见数组区间[0,3]的元素全部替换为10
// 其他的举例请参考copyWithin

同上,fill 一样受益于鸭式辨型,例如:

var o = {0:1, 1:2, 2:3, 3:4, 4:5,length:5}
var o2 = Array.prototype.fill.call(o,10,0,2);
console.log(o===o2,o2); true Object { 0=10,  1=10,  2=3,  更多...}

如需在Firefox之外的浏览器使用fill方法,请参考 Polyfill

不会改变自身的方法(9个)

基于ES7,不会改变自身的方法一共有9个,分别为concat、join、slice、toString、toLocateString、indexOf、lastIndexOf、未标准的toSource以及ES7新增的方法includes。

concat

concat() 方法将传入的数组或者元素与原数组合并,组成一个新的数组并返回。

语法:arr.concat(value1, value2, …, valueN)

var array = [1, 2, 3];
var array2 = array.concat(4,[5,6],[7,8,9]);
console.log(array2); // [1, 2, 3, 4, 5, 6, 7, 8, 9]
console.log(array); // [1, 2, 3], 可见原数组并未被修改

若concat方法中不传入参数,那么将基于原数组浅复制生成一个一模一样的新数组(指向新的地址空间)。

var array = [{a: 1}];
var array3 = array.concat();
console.log(array3); // [{a: 1}]
console.log(array3 === array); // false
console.log(array[0] === array3[0]); // true,新旧数组第一个元素依旧共用一个同一个对象的引用

同上,concat 一样受益于鸭式辨型,但其效果可能达不到我们的期望,如下:

var o = {0:"a", 1:"b", 2:"c",length:3};
var o2 = Array.prototype.concat.call(o,'d',{3:'e',4:'f',length:2},['g','h','i']);
console.log(o2); // [{0:"a", 1:"b", 2:"c", length:3}, 'd', {3:'e', 4:'f', length:2}, 'g', 'h', 'i']

可见,类数组对象合并后返回的是依然是数组,并不是我们期望的对象。

join

join() 方法将数组中的所有元素连接成一个字符串。

语法:arr.join([separator = ‘,’]) separator可选,缺省默认为逗号。

var array = ['We', 'are', 'Chinese'];
console.log(array.join()); // "We,are,Chinese"
console.log(array.join('+')); // "We+are+Chinese"
console.log(array.join('')); // "WeareChinese"

同上,join 一样受益于鸭式辨型,如下:

var o = {0:"We", 1:"are", 2:"Chinese", length:3};
console.log(Array.prototype.join.call(o,'+')); // "We+are+Chinese"
console.log(Array.prototype.join.call('abc')); // "a,b,c"
slice

slice() 方法将数组中一部分元素浅复制存入新的数组对象,并且返回这个数组对象。

语法:arr.slice([start[, end]])

参数 start 指定复制开始位置的索引,end如果有值则表示复制结束位置的索引(不包括此位置)。

如果 start 的值为负数,假如数组长度为 length,则表示从 length+start 的位置开始复制,此时参数 end 如果有值,只能是比 start 大的负数,否则将返回空数组。

slice方法参数为空时,同concat方法一样,都是浅复制生成一个新数组。

var array = ["one", "two", "three","four", "five"];
console.log(array.slice()); // ["one", "two", "three","four", "five"]
console.log(array.slice(2,3)); // ["three"]

浅复制 是指当对象的被复制时,只是复制了对象的引用,指向的依然是同一个对象。下面来说明slice为什么是浅复制。

var array = [{color:"yellow"}, 2, 3];
var array2 = array.slice(0,1);
console.log(array2); // [{color:"yellow"}]
array[0]["color"] = "blue";
console.log(array2); // [{color:"bule"}]

由于slice是浅复制,复制到的对象只是一个引用,改变原数组array的值,array2也随之改变。

同时,稍微利用下 slice 方法第一个参数为负数时的特性,我们可以非常方便的拿到数组的最后一项元素,如下:

console.log([1,2,3].slice(-1));//[3]

同上,slice 一样受益于鸭式辨型。如下:

var o = {0:{"color":"yellow"}, 1:2, 2:3, length:3};
var o2 = Array.prototype.slice.call(o,0,1);
console.log(o2); // [{color:"yellow"}] ,毫无违和感...

鉴于IE9以下版本对于该方法支持性并不是很好,如需更好的支持低版本IE浏览器,请参考polyfill

toString

toString() 方法返回数组的字符串形式,该字符串由数组中的每个元素的 toString() 返回值经调用 join() 方法连接(由逗号隔开)组成。

语法: arr.toString()

var array = ['Jan', 'Feb', 'Mar', 'Apr'];
var str = array.toString();
console.log(str); // Jan,Feb,Mar,Apr

当数组直接和字符串作连接操作时,将会自动调用其toString() 方法。

var str = ['Jan', 'Feb', 'Mar', 'Apr'] + ',May';
console.log(str); // "Jan,Feb,Mar,Apr,May"
// 下面我们来试试鸭式辨型
var o = {0:'Jan', 1:'Feb', 2:'Mar', length:3};
var o2 = Array.prototype.toString.call(o);
console.log(o2); // [object Object]
console.log(o.toString()==o2); // true

可见,Array.prototype.toString()方法处理类数组对象时,跟类数组对象直接调用Object.prototype.toString()方法结果完全一致,说好的鸭式辨型呢?

根据ES5语义,toString() 方法是通用的,可被用于任何对象。如果对象有一个join() 方法,将会被调用,其返回值将被返回,没有则调用Object.prototype.toString(),为此,我们给o对象添加一个join方法。如下:

var o = {
  0:'Jan', 
  1:'Feb', 
  2:'Mar', 
  length:3, 
  join:function(){
    return Array.prototype.join.call(this);
  }
};
console.log(Array.prototype.toString.call(o)); // "Jan,Feb,Mar"
toLocaleString

toLocaleString() 类似toString()的变型,该字符串由数组中的每个元素的 toLocaleString() 返回值经调用 join() 方法连接(由逗号隔开)组成。

语法:arr.toLocaleString()

数组中的元素将调用各自的 toLocaleString 方法:

var array= [{name:'zz'}, 123, "abc", new Date()];
var str = array.toLocaleString();
console.log(str); // [object Object],123,abc,2016/1/5 下午1:06:23

其鸭式辨型的写法也同toString 保持一致,如下:

var o = {
  0:123, 
  1:'abc', 
  2:new Date(), 
  length:3, 
  join:function(){
    return Array.prototype.join.call(this);
  }
};
console.log(Array.prototype.toLocaleString.call(o)); // 123,abc,2016/1/5 下午1:16:50
indexOf

indexOf() 方法用于查找元素在数组中第一次出现时的索引,如果没有,则返回-1。

语法:arr.indexOf(element, fromIndex=0)

element 为需要查找的元素。

fromIndex 为开始查找的位置,缺省默认为0。如果超出数组长度,则返回-1。如果为负值,假设数组长度为length,则从数组的第 length + fromIndex项开始往数组末尾查找,如果length + fromIndex<0 则整个数组都会被查找。

indexOf使用严格相等(即使用 === 去匹配数组中的元素)。

var array = ['abc', 'def', 'ghi','123'];
console.log(array.indexOf('def')); // 1
console.log(array.indexOf('def',-1)); // -1 此时表示从最后一个元素往后查找,因此查找失败返回-1
console.log(array.indexOf('def',-4)); // 1 由于4大于数组长度,此时将查找整个数组,因此返回1
console.log(array.indexOf(123)); // -1, 由于是严格匹配,因此并不会匹配到字符串'123'

得益于鸭式辨型,indexOf 可以处理类数组对象。如下:

var o = {0:'abc', 1:'def', 2:'ghi', length:3};
console.log(Array.prototype.indexOf.call(o,'ghi',-4));//2

然而该方法并不支持IE9以下版本,如需更好的支持低版本IE浏览器(IE6~8), 请参考 Polyfill

lastIndexOf

lastIndexOf() 方法用于查找元素在数组中最后一次出现时的索引,如果没有,则返回-1。并且它是indexOf的逆向查找,即从数组最后一个往前查找。

语法:arr.lastIndexOf(element, fromIndex=length-1)

element 为需要查找的元素。

fromIndex 为开始查找的位置,缺省默认为数组长度length-1。如果超出数组长度,由于是逆向查找,则查找整个数组。如果为负值,则从数组的第 length + fromIndex项开始往数组开头查找,如果length + fromIndex<0 则数组不会被查找。

同 indexOf 一样,lastIndexOf 也是严格匹配数组元素。

举例请参考 indexOf ,不再详述,兼容低版本IE浏览器(IE6~8),请参考 Polyfill

includes(ES7)

includes() 方法基于ECMAScript 2016(ES7)规范,它用来判断当前数组是否包含某个指定的值,如果是,则返回 true,否则返回 false。

语法:arr.includes(element, fromIndex=0)

element 为需要查找的元素。

fromIndex 表示从该索引位置开始查找 element,缺省为0,它是正向查找,即从索引处往数组末尾查找。

var array = [-0, 1, 2];
console.log(array.includes(+0)); // true
console.log(array.includes(1)); // true
console.log(array.includes(2,-4)); // true

以上,includes似乎忽略了 -0+0 的区别,这不是问题,因为JavaScript一直以来都是不区分 -0+0 的。

你可能会问,既然有了indexOf方法,为什么又造一个includes方法,arr.indexOf(x)>-1不就等于arr.includes(x)?看起来是的,几乎所有的时候它们都等同,唯一的区别就是includes能够发现NaN,而indexOf不能。

var array = [NaN];
console.log(array.includes(NaN)); // true
console.log(arra.indexOf(NaN)>-1); // false

该方法同样受益于鸭式辨型。如下:

var o = {0:'a', 1:'b', 2:'c', length:3};
var bool = Array.prototype.includes.call(o, 'a');
console.log(bool); // true

该方法只有在Chrome 47、opera 34、Safari 9版本及其更高版本中才被实现。如需支持其他浏览器,请参考 Polyfill

toSource(非标准)

toSource() 方法是非标准的,该方法返回数组的源代码,目前只有 Firefox 实现了它。

语法:arr.toSource()

var array = ['a', 'b', 'c'];
console.log(array.toSource()); // ["a", "b", "c"]
// 测试鸭式辨型
var o = {0:'a', 1:'b', 2:'c', length:3};
console.log(Array.prototype.toSource.call(o)); // ["a","b","c"]

遍历方法(12个)

基于ES6,不会改变自身的方法一共有12个,分别为forEach、every、some、filter、map、reduce、reduceRight 以及ES6新增的方法entries、find、findIndex、keys、values。

forEach

forEach() 方法指定数组的每项元素都执行一次传入的函数,返回值为undefined。

语法:arr.forEach(fn, thisArg)

fn 表示在数组每一项上执行的函数,接受三个参数:

  • value 当前正在被处理的元素的值
  • index 当前元素的数组索引
  • array 数组本身

thisArg 可选,用来当做fn函数内的this对象。

forEach 将为数组中每一项执行一次 fn 函数,那些已删除,新增或者从未赋值的项将被跳过(但不包括值为 undefined 的项)。

遍历过程中,fn会被传入上述三个参数。

var array = [1, 3, 5];
var obj = {name:'cc'};
var sReturn = array.forEach(function(value, index, array){
  array[index] = value * value;
  console.log(this.name); // cc被打印了三次
},obj);
console.log(array); // [1, 9, 25], 可见原数组改变了
console.log(sReturn); // undefined, 可见返回值为undefined

得益于鸭式辨型,虽然forEach不能直接遍历对象,但它可以通过call方式遍历类数组对象。如下:

var o = {0:1, 1:3, 2:5, length:3};
Array.prototype.forEach.call(o,function(value, index, obj){
  console.log(value,index,obj);
  obj[index] = value * value;
},o);
// 1 0 Object {0: 1, 1: 3, 2: 5, length: 3}
// 3 1 Object {0: 1, 1: 3, 2: 5, length: 3}
// 5 2 Object {0: 1, 1: 9, 2: 5, length: 3}
console.log(o); // Object {0: 1, 1: 9, 2: 25, length: 3}

参考前面的文章 详解JS遍历 中 forEach的讲解,我们知道,forEach无法直接退出循环,只能使用return 来达到for循环中continue的效果,并且forEach不能在低版本IE(6~8)中使用,兼容写法请参考 Polyfill

every

every() 方法使用传入的函数测试所有元素,只要其中有一个函数返回值为 false,那么该方法的结果为 false;如果全部返回 true,那么该方法的结果才为 true。因此 every 方法存在如下规律:

  • 若需检测数组中存在元素大于100 (即 one > 100),那么我们需要在传入的函数中构造 “false” 返回值 (即返回 item <= 100),同时整个方法结果为 false 才表示数组存在元素满足条件;(简单理解为:若是单项判断,可用 one false ===> false)
  • 若需检测数组中是否所有元素都大于100 (即all > 100)那么我们需要在传入的函数中构造 “true” 返回值 (即返回 item > 100),同时整个方法结果为 true 才表示数组所有元素均满足条件。(简单理解为:若是全部判断,可用 all true ===> true)

语法同上述forEach,具体还可以参考 详解JS遍历 中every的讲解。

以下是鸭式辨型的写法:

var o = {0:10, 1:8, 2:25, length:3};
var bool = Array.prototype.every.call(o,function(value, index, obj){
  return value >= 8;
},o);
console.log(bool); // true

every 一样不能在低版本IE(6~8)中使用,兼容写法请参考 Polyfill

some

some() 方法刚好同 every() 方法相反,some 测试数组元素时,只要有一个函数返回值为 true,则该方法返回 true,若全部返回 false,则该方法返回 false。some 方法存在如下规律:

  • 若需检测数组中存在元素大于100 (即 one > 100),那么我们需要在传入的函数中构造 “true” 返回值 (即返回 item > 100),同时整个方法结果为 true 才表示数组存在元素满足条件;(简单理解为:若是单项判断,可用 one true ===> true)
  • 若需检测数组中是否所有元素都大于100(即 all > 100),那么我们需要在传入的函数中构造 “false” 返回值 (即返回 item <= 100),同时整个方法结果为 false 才表示数组所有元素均满足条件。(简单理解为:若是全部判断,可用 all false ===> false)

你注意到没有,some方法与includes方法有着异曲同工之妙,他们都是探测数组中是否拥有满足条件的元素,一旦找到,便返回true。多观察和总结这种微妙的关联关系,能够帮助我们深入理解它们的原理。

some 的鸭式辨型写法可以参照every,同样它也不能在低版本IE(6~8)中使用,兼容写法请参考 Polyfill

filter

filter() 方法使用传入的函数测试所有元素,并返回所有通过测试的元素组成的新数组。它就好比一个过滤器,筛掉不符合条件的元素。

语法:arr.filter(fn, thisArg)

var array = [18, 9, 10, 35, 80];
var array2 = array.filter(function(value, index, array){
  return value > 20;
});
console.log(array2); // [35, 80]

filter一样支持鸭式辨型,具体请参考every方法鸭式辨型写法。其在低版本IE(6~8)的兼容写法请参考 Polyfill

map

map() 方法遍历数组,使用传入函数处理每个元素,并返回函数的返回值组成的新数组。

语法:arr.map(fn, thisArg)

参数介绍同 forEach 方法的参数介绍。

具体用法请参考 详解JS遍历 中 map 的讲解。

map 一样支持鸭式辨型, 具体请参考every方法鸭式辨型写法。

其在低版本IE(6~8)的兼容写法请参考 Polyfill

reduce

reduce() 方法接收一个方法作为累加器,数组中的每个值(从左至右) 开始合并,最终为一个值。

语法:arr.reduce(fn, initialValue)

fn 表示在数组每一项上执行的函数,接受四个参数:

  • previousValue 上一次调用回调返回的值,或者是提供的初始值
  • value 数组中当前被处理元素的值
  • index 当前元素在数组中的索引
  • array 数组自身

initialValue 指定第一次调用 fn 的第一个参数。

当 fn 第一次执行时:

  • 如果 initialValue 在调用 reduce 时被提供,那么第一个 previousValue 将等于 initialValue,此时 item 等于数组中的第一个值;
  • 如果 initialValue 未被提供,那么 previousVaule 等于数组中的第一个值,item 等于数组中的第二个值。此时如果数组为空,那么将抛出 TypeError。
  • 如果数组仅有一个元素,并且没有提供 initialValue,或提供了 initialValue 但数组为空,那么fn不会被执行,数组的唯一值将被返回。
var array = [1, 2, 3, 4];
var s = array.reduce(function(previousValue, value, index, array){
  return previousValue * value;
},1);
console.log(s); // 24
// ES6写法更加简洁
array.reduce((p, v) => p * v); // 24

以上回调被调用4次,每次的参数和返回见下表:

callback previousValue currentValue index array return value
第1次 1 1 0 [1,2,3,4] 1
第2次 1 2 1 [1,2,3,4] 2
第3次 2 3 2 [1,2,3,4] 6
第4次 6 4 3 [1,2,3,4] 24

reduce 一样支持鸭式辨型,具体请参考every方法鸭式辨型写法。

其在低版本IE(6~8)的兼容写法请参考 Polyfill

reduceRight

reduceRight() 方法接收一个方法作为累加器,数组中的每个值(从右至左)开始合并,最终为一个值。除了与reduce执行方向相反外,其他完全与其一致,请参考上述 reduce 方法介绍。

其在低版本IE(6~8)的兼容写法请参考 Polyfill

entries(ES6)

entries() 方法基于ECMAScript 2015(ES6)规范,返回一个数组迭代器对象,该对象包含数组中每个索引的键值对。

语法:arr.entries()

var array = ["a", "b", "c"];
var iterator = array.entries();
console.log(iterator.next().value); // [0, "a"]
console.log(iterator.next().value); // [1, "b"]
console.log(iterator.next().value); // [2, "c"]
console.log(iterator.next().value); // undefined, 迭代器处于数组末尾时, 再迭代就会返回undefined

很明显,entries 也受益于鸭式辨型,如下:

var o = {0:"a", 1:"b", 2:"c", length:3};
var iterator = Array.prototype.entries.call(o);
console.log(iterator.next().value); // [0, "a"]
console.log(iterator.next().value); // [1, "b"]
console.log(iterator.next().value); // [2, "c"]

由于该方法基于ES6,因此目前并不支持所有浏览器,以下是各浏览器支持版本:

Browser Chrome Firefox (Gecko) Internet Explorer Opera Safari
Basic support 38 28 (28) 未实现 25 7.1
find&findIndex(ES6)

find() 方法基于ECMAScript 2015(ES6)规范,返回数组中第一个满足条件的元素(如果有的话), 如果没有,则返回undefined。

findIndex() 方法也基于ECMAScript 2015(ES6)规范,它返回数组中第一个满足条件的元素的索引(如果有的话)否则返回-1。

语法:arr.find(fn, thisArg)arr.findIndex(fn, thisArg)

我们发现它们的语法与forEach等十分相似,其实不光语法,find(或findIndex)在参数及其使用注意事项上,均与forEach一致。因此此处将略去 find(或findIndex)的参数介绍。下面我们来看个例子🌰 :

var array = [1, 3, 5, 7, 8, 9, 10];
function f(value, index, array){
  return value%2==0; // 返回偶数
}
function f2(value, index, array){
  return value > 20; // 返回大于20的数
}
console.log(array.find(f)); // 8
console.log(array.find(f2)); // undefined
console.log(array.findIndex(f)); // 4
console.log(array.findIndex(f2)); // -1

由于其鸭式辨型写法也与forEach方法一致,故此处略去。

兼容性上我没有详测,可以知道的是,最新版的Chrome v47,以及Firefox的版本25均实现了它们。

keys(ES6)

keys() 方法基于ECMAScript 2015(ES6)规范,返回一个数组索引的迭代器。(浏览器实际实现可能会有调整)

语法:arr.keys()

var array = ["abc", "xyz"];
var iterator = array.keys();
console.log(iterator.next()); // Object {value: 0, done: false}
console.log(iterator.next()); // Object {value: 1, done: false}
console.log(iterator.next()); // Object {value: undefined, done: false}

索引迭代器会包含那些没有对应元素的索引,如下:

var array = ["abc", , "xyz"];
var sparseKeys = Object.keys(array);
var denseKeys = [...array.keys()];
console.log(sparseKeys); // ["0", "2"]
console.log(denseKeys);  // [0, 1, 2]

其鸭式辨型写法请参考上述 entries 方法。

前面我们用Array.from生成一个从0到指定数字的新数组,利用keys也很容易实现。

[...Array(10).keys()]; // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[...new Array(10).keys()]; // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

由于Array的特性,new Array 和 Array 对单个数字的处理相同,因此以上两种均可行。

keys基于ES6,并未完全支持,以下是各浏览器支持版本:

Browser Chrome Firefox (Gecko) Internet Explorer Opera Safari
Basic support 38 28 (28) 未实现 25 7.1
values(ES6)

values() 方法基于ECMAScript 2015(ES6)规范,返回一个数组迭代器对象,该对象包含数组中每个索引的值。其用法基本与上述 entries 方法一致。

语法:arr.values()

遗憾的是,现在没有浏览器实现了该方法,因此下面将就着看看吧。

var array = ["abc", "xyz"];
var iterator = array.values();
console.log(iterator.next().value);//abc
console.log(iterator.next().value);//xyz
Symbol.iterator(ES6)

该方法基于ECMAScript 2015(ES6)规范,同 values 方法功能相同。

语法:arr[Symbol.iterator]()

var array = ["abc", "xyz"];
var iterator = array[Symbol.iterator]();
console.log(iterator.next().value); // abc
console.log(iterator.next().value); // xyz

其鸭式辨型写法请参考上述 entries 方法。

由于该方法基于ES6,并未完全支持,以下是各浏览器支持版本:

Browser Chrome Firefox (Gecko) Internet Explorer Opera Safari
Basic support 38 36 (36) 1 未实现 25 未实现

小结

以上,Array.prototype 的各方法基本介绍完毕,这些方法之间存在很多共性。比如:

  • 所有插入元素的方法, 比如 push、unshift,一律返回数组新的长度;
  • 所有删除元素的方法,比如 pop、shift、splice 一律返回删除的元素,或者返回删除的多个元素组成的数组;
  • 部分遍历方法,比如 forEach、every、some、filter、map、find、findIndex,它们都包含function(value,index,array){}thisArg 这样两个形参。

Array.prototype 的所有方法均具有鸭式辨型这种神奇的特性。它们不止可以用来处理数组对象,还可以处理类数组对象。

例如 javascript 中一个纯天然的类数组对象字符串(String),像join方法(不改变当前对象自身)就完全适用,可惜的是 Array.prototype 中很多方法均会去试图修改当前对象的 length 属性,比如说 pop、push、shift, unshift 方法,操作 String 对象时,由于String对象的长度本身不可更改,这将导致抛出TypeError错误。

还记得么,Array.prototype本身就是一个数组,并且它的长度为0。

后续章节我们将继续探索Array的一些事情。感谢您的阅读!


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论

本文作者:louis

本文链接:http://louiszhai.github.io/2017/04/28/array/

参考文章

]]>
JavaScript Array Array.of Array.from Array.isArray length constructor toString toLocaleString join pop push reverse shift unshift slice splice sort filter forEach some every map indexOf lastIndexOf reduce reduceRight copyWithin find findIndex fill includes entries keys concat
webpack与browser-sync热更新原理深度讲解 http://louiszhai.github.io/2017/04/19/hmr/ 2017-04-19T02:10:00.000Z 2019-12-02T03:28:32.883Z 本文首发于CSDN网站,下面的版本又经过进一步的修订。

开发环境页面热更新早已是主流,我们不光要吃着火锅唱着歌,享受热更新高效率的快感,更要深入下去探求其原理。

要知道,触类则旁通,常见的需求如赛事网页推送比赛结果、网页实时展示投票或点赞数据、在线评论或弹幕、在线聊天室等,都需要借助热更新功能,才能达到实时的端对端的极致体验。

刚好,最近解决webpack-hot-middleware热更新延迟问题的过程中,我深入接触了EventSource技术。遂本文由此开篇,进一步讲解webpack-hot-middlewarebrowser-sync背后的技术。

webpack-hot-middleware

webpack-hot-middleware中间件是webpack的一个plugin,通常结合webpack-dev-middleware一起使用。借助它可以实现浏览器的无刷新更新(热更新),即webpack里的HMR(Hot Module Replacement)。如何配置请参考 webpack-hot-middleware,如何理解其相关插件请参考 手把手深入理解 webpack dev middleware 原理與相關 plugins

webpack加入webpack-hot-middleware后,内存中的页面将包含HMR相关js,加载页面后,Network栏可以看到如下请求:

__webpack_hmr

__webpack_hmr是一个type为EventSource的请求, 从Time栏可以看出:默认情况下,服务器每十秒推送一条信息到浏览器。

hmr每10秒推送一条信息

如果此时关闭开发服务器,浏览器由于重连机制,将持续抛出类似GET http://www.test.com/__webpack_hmr 502 (Bad Gateway) 这样的错误。重新启动开发服务器后,重连将会成功,此时便会刷新页面。

以上这些便是我们使用时感受到的最初的印象。当然,停留在使用层面不是我们的目标,接下来我们将跳出该中间件,讲解其所使用到的EventSource技术。

EventSource

EventSource 不是一个新鲜的技术,它早就随着H5规范提出了,正式一点应该叫Server-sent events,即SSE

鉴于传统的通过ajax轮训获取服务器信息的技术方案已经过时,我们迫切需要一个高效的节省资源的方式去获取服务器信息,一旦服务器资源有更新,能够及时地通知到客户端,从而实时地反馈到用户界面上。EventSource就是这样的技术,它本质上还是HTTP,通过response流实时推送服务器信息到客户端。

新建一个EventSource对象非常简单。

const es = new EventSource('/message');// /message是服务端支持EventSource的接口

新创建的EventSource对象拥有如下属性:

属性 描述
url(只读) es对象请求的服务器url
readyState(只读) es对象的状态,初始为0,包含CONNECTING (0),OPEN (1),CLOSED (2)三种状态
withCredentials 是否允许带凭证等,默认为false,即不支持发送cookie

服务端实现/message接口,需要返回类型为 text/event-stream的响应头。

var http = require('http');
http.createServer(function(req,res){
  if(req.url === '/message'){
    res.writeHead(200,{
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive'
    });
    setInterval(function(){
      res.write('data: ' + +new Date() + '\n\n');
    }, 1000);
  }
}).listen(8888);

我们注意到,为了避免缓存,Cache-Control 特别设置成了 no-cache,为了能够发送多个response, Connection被设置成了keep-alive.。发送数据时,请务必保证服务器推送的数据以 data:开始,以\n\n结束,否则推送将会失败(原因就不说了,这是约定的)。

以上,服务器每隔1s主动向客户端发送当前时间戳,为了接受这个信息,客户端需要监听服务器。如下:

es.onmessage = function(e){
  console.log(e.data); // 打印服务器推送的信息
}

如下是消息推送的过程:

response size不断增加

接收消息

你以为es只能监听message事件吗?并不是,message只是缺省的事件类型。实际上,它可以监听任何指定类型的事件。

es.addEventListener("####", function(e) {// 事件类型可以随你定义
  console.log('####:', e.data);
},false);

服务器发送不同类型的事件时,需要指定event字段。

res.write('event: ####\n');
res.write('data: 这是一个自定义的####类型事件\n');
res.write('data: 多个data字段将被解析成一个字段\n\n');

如下所示:

####消息

可以看到,服务端指定event事件名为”####”后,客户端触发了对应的事件回调,同时服务端设置的多个data字段,客户端使用换行符连接成了一个字符串。

不仅如此,事件流中还可以混合多种事件,请看我们是怎么收到消息的,如下:

混合消息

除此之外,es对象还拥有另外3个方法: onopen()onerror()close(),请参考如下实现。

es.onopen = function(e){// 链接打开时的回调
  console.log('当前状态readyState:', es.readyState);// open时readyState===1
}
es.onerror = function(e){// 出错时的回调(网络问题,或者服务下线等都有可能导致出错)
  console.log(es.readyState);// 出错时readyState===0
  es.close();// 出错时,chrome浏览器会每隔3秒向服务器重发原请求,直到成功. 因此出错时,可主动断开原连接.
}

使用EventSource技术实时更新网页信息十分高效。实际使用中,我们几乎不用担心兼容性问题,主流浏览器都了支持EventSource,当然,除了掉队的IE系。对于不支持的浏览器,其PolyFill方案请参考HTML5 Cross Browser Polyfills

CORS

另外,如果需要支持跨域调用,请设置响应头Access-Control-Allow-Origin': '*'

如需支持发送cookie,请设置响应头Access-Control-Allow-Origin': req.headers.originAccess-Control-Allow-Credentials:true,并且创建es对象时,需要明确指定是否发送凭证。如下:

var es = new EventSource('/message', {
  withCredentials: true
}); // 创建时指定配置才是有效的
es.withCredentials = true; // 与ajax不同,这样设置是无效的

以下是主流浏览器对EventSource的CORS的支持:

Firefox Opera Chrome Safari iOS Android
10+ 12+ 26+ 7.0+ 7.0+ 4.4+

nginx配置

既然说到了EventSource,便有必要谈谈遇到的坑,接下来,就说说我遇到的webpack热更新延迟问题。

如我们所知,webpack借助webpack-hot-middleware插件,实现了网页热更新机制,正常情况下,浏览器打开 http://localhost:8080 这样的网页即可开始调试。然而实际开发中,由于远程服务器需要种cookie登录态到特定的域名上等原因,因此本地往往会用nginx做一层反向代理。即把 http://www.test.com 的请求转发到 http://localhost:8080 上(配置过程这里不详述,具体请参考Ajax知识体系大梳理-ajax调试技巧)。转发过后,发现热更新便延迟了。

原因是nginx默认开启的buffer机制缓存了服务器推送的片段信息,缓存达到一定的量才会返回响应内容。只要关闭proxy_buffering即可。配置如下所示:

server {
    listen       80;
    server_name  www.test.company.com;
    location / {
        proxy_pass http://localhost:8080;
        proxy_buffering off;
    }
}

至此,EventSource部分便告一段落。学习讲究由浅入深,循序渐进。后面我将重点讲解的browser-sync热更新机制,请耐心细读。

browser-sync

开发中使用browser-sync插件调试,一个网页里的所有交互动作(包括滚动,输入,点击等等),可以实时地同步到其他所有打开该网页的设备,能够节省大量的手工操作时间,从而带来流畅的开发调试体验。目前browser-sync可以结合GulpGrunt一起使用,其API请参考:Browsersync API

通过上面的了解,我们知道EventSouce的使用是比较便捷的,那为什么browser-sync不使用EventSource技术进行代码推送呢?这是因为browser-sync插件共做了两件事:

  • 开发更新了一段新的逻辑,服务器实时推送代码改动信息。数据流:服务器 —> 浏览器,使用EventSource技术同样能够实现。
  • 用户操作网页,滚动、输入或点击等,操作信息实时发送给服务器,然后再由服务器将操作同步给其他已打开的网页。数据流:浏览器 —> 服务器 —> 浏览器,该部分功能EventSource技术已无能为力。

以上,browser-sync使用WebSocket技术达到实时推送代码改动和用户操作两个目的。至于它是如何计算推送内容,根据不同推送内容采取何种响应策略,不在本次讨论范围之内。下面我们将讲解其核心的WebSocket技术。

WebSocket

WebSocket是基于TCP的全双工通讯的协议,它与EventSource有着本质上的不同.(前者基于TCP,后者依然基于HTTP) 该协议于2011年被IETF定为标准RFC6455,后被RFC7936补充. WebSocket api也被W3C定为标准。

WebSocket使用和HTTP相同的TCP端口,默认为80, 统一资源标志符为ws,运行在TLS之上时,默认使用443,统一资源标志符为wss。它通过101 switch protocol进行一次TCP握手,即从HTTP协议切换成WebSocket通信协议。

相对于HTTP协议,WebSocket拥有如下优点:

  • 全双工,实时性更强。
  • 相对于http携带完整的头部,WebSocket请求头部明显减少。
  • 保持连接状态,不用再验权了。
  • 二进制支持更强,Websocket定义了二进制帧,处理更轻松。
  • Websocket协议支持扩展,可以自定义的子协议,如 permessage-deflate 扩展。

支持性

优秀技术的落地,调研兼容性是必不可少的环节。所幸的是,现代浏览器对WebSocket的支持比较友好,如下是PC端兼容性:

IE/Edge Firefox Chrome Safari Opera
10+ 11+ 16+ 7+ 12.1+

如下是mobile端兼容性:

iOS Safari Android Android Chrome Android UC QQ Browser Opera Mini
7.1+ 4.4+ 57+ 11.4+ 1.2+ -

Frame

根据RFC6455文档,WebSocket协议基于Frame而非Stream(EventSource是基于Stream的)。因此其传输的数据都是Frame(帧)。想要了解数据的往返,弄懂协议处理过程,Frame的解读是必不可少。如下便是Frame的结构:

  0                   1                   2                   3
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
 +-+-+-+-+-------+-+-------------+-------------------------------+
 |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
 |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
 |N|V|V|V|       |S|             |   (if payload len==126/127)   |
 | |1|2|3|       |K|             |                               |
 +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
 |     Extended payload length continued,if payload len == 127  |
 + - - - - - - - - - - - - - - - +-------------------------------+
 |                               |Masking-key,if MASK set to 1  |
 +-------------------------------+-------------------------------+
 | Masking-key (continued)       |          Payload Data         |
 +-------------------------------- - - - - - - - - - - - - - - - +
 :                     Payload Data continued ...                :
 + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
 |                     Payload Data continued ...                |
 +---------------------------------------------------------------+

第一个字节包含FIN、RSV、Opcode。

  • FIN:size为1bit,标示是否最后一帧。%x0表示还有后续帧,%x1表示这是最后一帧。

  • RSV1、2、3,每个size都是1bit,默认值都是0,如果没有定义非零值的含义,却出现了非零值,则WebSocket链接将失败。

  • Opcode,size为4bits,表示『payload data』的类型。如果收到未知的opcode,连接将会断开。已定义的opcode值如下:

    %x0:    代表连续的帧
    %x1:    文本帧
    %x2:    二进制帧
    %x3~7:    预留的非控制帧
    %x8:    关闭握手帧
    %x9:    ping帧,后续心跳连接会讲到
    %xA:    pong帧,后续心跳连接会讲到
    %xB~F:    预留的非控制帧
    

第二个字节包含Mask、Payload len。

  • Mask:size为1bit,标示『payload data』是否添加掩码。所有从客户端发送到服务端的帧都会被置为1,如果置1,Masking-key便会赋值。

    //若server是一个WebSocket服务端实例
    //监听客户端消息
    server.on('message', function(msg, flags) {
      console.log('client say: %s', msg);
      console.log('mask value:', flags.masked);// true,进一步佐证了客户端发送到服务端的Mask帧都会被置为1
    });
    //监听客户端pong帧响应
    server.on('pong', function(msg, flags) {
      console.log('pong data: %s', msg);
      console.log('mask value:', flags.masked);// true,进一步佐证了客户端发送到服务端的Mask帧都会被置为1
    });
    
  • Payload len:size为7bits,即使是当做无符号整型也只能表示0~127的值,所以它不能表示更大的值,因此规定”Payload data”长度小于或等于125的时候才用来描述数据长度。如果Payload len==126,则使用随后的2bytes(16bits)来存储数据长度。如果Payload len==127,则使用随后的8bytes(64bits)来存储数据长度。

以上,扩展的Payload len可能占据第三至第四个或第三至第十个字节。紧随其后的是”Mask-key”。

  • Mask-key:size为0或4bytes(32bits),默认为0,与前面Mask呼应,从客户端发送到服务端的帧都包含4bytes(32bits)的掩码,一旦掩码被设置,所有接收到的”payload data”都必须与该值以一种算法做异或运算来获取真实值。
  • Payload data:size为”Extension data” 和 “Application data” 的总和,一般”Extension data”数据为空。
  • Extension data:默认为0,如果扩展被定义,扩展必须指定”Extension data”的长度。
  • Application data:占据”Extension data”之后剩余帧的空间。

关于Frame的更多理论介绍不妨读读 学习WebSocket协议—从顶层到底层的实现原理(修订版)

关于Frame的数据帧解析不妨读读 WebSocket(贰) 解析数据帧 及其后续文章。

建立连接

了解了Frame的数据结构后,我们来实际练习下。浏览器上,新建一个ws对象十分简单。如下:

let ws = new WebSocket('ws://127.0.0.1:10103/');// 本地使用10103端口进行测试

新建的WebSocket对象如下所示:

Websocket对象

这中间包含了一次Websocket握手的过程,我们分两步来理解。

第一步,客户端请求。

Websocket Request

这是一个GET请求,主要字段如下:

Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key:61x6lFN92sJHgzXzCHfBJQ==
Sec-WebSocket-Version:13

Connection字段指定为Upgrade,表示客户端希望连接升级。

Upgrade字段设置为websocket,表示希望升级至Websocket协议。

Sec-WebSocket-Key字段是随机字符串,服务器根据它来构造一个SHA-1的信息摘要。

Sec-WebSocket-Version表示支持的Websocket版本。RFC6455要求使用的版本是13。

甚至我们可以从请求截图里看出,Origin是file://,而Host是127.0.0.1:10103,明显不是同一个域下,但依然可以请求成功,说明Websocket协议是不受同源策略限制的(同源策略限制的是http协议)。

第二步,服务端响应。

Websocket Response

Status Code: 101 Switching Protocols 表示Websocket协议通过101状态码进行握手。

Sec-WebSocket-Accept字段是由Sec-WebSocket-Key字段加上特定字符串”258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,计算SHA-1摘要,然后再base64编码之后生成的. 该操作可避免普通http请求,被误认为Websocket协议。

Sec-WebSocket-Extensions字段表示服务端对Websocket协议的扩展。

以上,WebSocket构造器不止可以传入url,还能传入一个可选的协议名称字符串或数组。

ws = new WebSocket('ws://127.0.0.1:10103/', ['abc','son_protocols']);

服务端实现

等等,我们慢一点,上面好像漏掉了一步,似乎没有提到服务端是怎么实现的。请继续往下看:

先做一些准备。ws是一个nodejs版的WebSocketServer实现。使用 npm install ws 即可安装。

var WebSocketServer = require('ws').Server,
    server = new WebSocketServer({port: 10103});
server.on('connection', function(s) {
  s.on('message', function(msg) { //监听客户端消息
    console.log('client say: %s', msg);
  });
  s.send('server ready!');// 连接建立好后,向客户端发送一条消息
});

以上,new WebSocketServer()创建服务器时如需权限验证,请指定verifyClient为验权的函数。

server = new WebSocketServer({
  port: 10103,
  verifyClient: verify
});
function verify(info){
  console.log(Object.keys(info));// [ 'origin', 'secure', 'req' ]
  console.log(info.orgin);// "file://"
  return true;// 返回true时表示验权通过,否则客户端将抛出"HTTP Authentication failed"错误
}

以上,verifyClient指定的函数只有一个形参,若为它显式指定两个形参,那么第一个参数同上info,第二个参数将是一个cb回调函数。该函数用于显式指定拒绝时的HTTP状态码等,它默认拥有3个形参,依次为:

  • result,布尔值类型,表示是否通过权限验证。
  • code,数值类型,若result值为false时,表示HTTP的错误状态码。
  • name,字符串类型,若result值为false时,表示HTTP状态码的错误信息。
// 若verify定义如下
function verify(info, cb){
  //一旦拥有第二个形参,如果不调用,默认将通过验权
  cb(false, 401, '权限不够');// 此时表示验权失败,HTTP状态码为401,错误信息为"权限不够"
  return true;// 一旦拥有第二个形参,响应就被cb接管了,返回什么值都不会影响前面的处理结果
}

除了portverifyClient设置外,其它设置项及更多API,请参考文档 ws-doc

发送和监听消息

接下来,我们来实现消息收发。如下是客户端发送消息。

ws.onopen = function(e){
  // 可发送字符串,ArrayBuffer 或者 Blob数据
  ws.send('client ready!);
};

客户端监听信息。

ws.onmessage = function(e){
  console.log('server say:', e.data);
};

如下是浏览器的运行截图。

message

消息的内容都在Frames栏,第一条彩色背景的信息是客户端发送的,第二条是服务端发送的。两条消息的长度都是13。

如下是Timing栏,不止是WebSocket,包括EventSource,都有这样的黄色高亮警告。

Websocket Request

该警告说明:请求还没完成。实际上,直到一方连接close掉,请求才会完成。

关闭连接

说到close,ws的close方法比es的略复杂。

语法:close(short code,string reason);

close默认可传入两个参数。code是数字,表示关闭连接的状态号,默认是1000,即正常关闭。(code取值范围从0到4999,其中有些是保留状态号,正常关闭时只能指定为1000或者3000~4999之间的值,具体请参考CloseEvent - Web APIs)。reason是UTF-8文本,表示关闭的原因(文本长度需小于或等于123字节)。

由于code 和 reason都有限制,因此该方法可能抛出异常,建议catch下.

try{
  ws.close(1001, 'CLOSE_GOING_AWAY');
}catch(e){
  console.log(e);
}

ws对象还拥有onclose和onerror监听器,分别监听关闭和错误事件。(注:EventSource没有onclose监听)

拥有的属性

ws的readyState属性拥有4个值,比es的readyState的多一个CLOSING的状态。

常量 描述 EventSource(值) WebSocket(值)
CONNECTING 连接未初始化 0 0
OPEN 连接已就绪 1 1
CLOSING 连接正在关闭 - 2
CLOSED 连接已关闭 2 3

另外,除了两种都有的url属性外,WebSocket对象还拥有更多的属性。

属性 描述
binaryType 被传输二进制内容的类型,有blob,arraybuffer两种
bufferedAmount 待传输的数据的长度
extensions 表示服务器选用的扩展
protocol 指的是构造器第二个参数传入的子协议名称

文件上传

以前一直是使用ajax做文件上传,实际上,Websocket上传文件也是一把好刀. 其send方法可以发送String,ArrayBuffer,Blob共三种数据类型,发送二进制文件完全不在话下。

由于各个浏览器对Websocket单次发送的数据有限制,所以我们需要将待上传文件切成片段去发送。如下是实现。

1) html。

<input type="file" id="file"/>

2) js。

const ws = new WebSocket('ws://127.0.0.1:10103/');// 连接服务器
const fileSelect = document.getElementById('file');
const size = 1024 * 128;// 分段发送的文件大小(字节)
let curSize, total, file, fileReader;

fileSelect.onchange = function(){
  file = this.files[0];// 选中的待上传文件
  curSize = 0;// 当前已发送的文件大小
  total = file.size;// 文件大小
  ws.send(file.name);// 先发送待上传文件的名称
  fileReader = new FileReader();// 准备读取文件
  fileReader.onload = loadAndSend;
  readFragment();// 读取文件片段
};

function loadAndSend(){
  if(ws.bufferedAmount > size * 5){// 若发送队列中的数据太多,先等一等
    setTimeout(loadAndSend,4);
    return;
  }
  ws.send(fileReader.result);// 发送本次读取的片段内容
  curSize += size;// 更新已发送文件大小
  curSize < total ? readFragment() : console.log('upload successed!');// 下一步操作
}

function readFragment(){
  const blob = file.slice(curSize, curSize + size);// 获取文件指定片段
  fileReader.readAsArrayBuffer(blob);// 读取文件为ArrayBuffer对象
}

3) server(node)。

var WebSocketServer = require('ws').Server,
    server = new WebSocketServer({port: 10103}),// 启动服务器
    fs = require('fs');
server.on('connection', function(wsServer){
  var fileName, i = 0;// 变量定义不可放在全局,因每个连接都不一样,这里才是私有作用域
  server.on('message', function(data, flags){// 监听客户端消息
    if(flags.binary){// 判断是否二进制数据
      var method = i++ ? 'appendFileSync' : 'writeFileSync';
      // 当前目录下写入或者追加写入文件(建议加上try语句捕获可能的错误)
      fs[method]('./' + fileName, data,'utf-8');
    }else{// 非二进制数据则认为是文件名称
      fileName = data;
    }
  });
  wsServer.send('server ready!');// 告知客户端服务器已就绪
});

运行效果如下:

Websocket upload

上述测试代码中没有过多涉及服务器的存储过程。通常,服务器也会有缓存区上限,如果客户端单次发送的数据量超过服务端缓存区上限,那么服务端也需要多次读取。

心跳连接

生产环境下上传一个文件远比本地测试来得复杂。实际上,从客户端到服务端,中间存在着大量的网络链路,如路由器,防火墙等等。一份文件的上传要经过中间的层层路由转发,过滤。这些中间链路可能会认为一段时间没有数据发送,就自发切断两端的连接。这个时候,由于TCP并不定时检测连接是否中断,而通信的双方又相互没有数据发送,客户端和服务端依然会一厢情愿的信任之前的连接,长此以往,将使得大量的服务端资源被WebSocket连接占用。

正常情况下,TCP的四次挥手完全可以通知两端去释放连接。但是上述这种普遍存在的异常场景,将使得连接的释放成为梦幻。

为此,早在websocket协议实现时,设计者们便提供了一种 Ping/Pong Frame的心跳机制。一端发送Ping Frame,另一端以 Pong Frame响应。这种Frame是一种特殊的数据包,它只包含一些元数据,能够在不影响原通信的情况下维持住连接。

根据规范RFC 6455,Ping Frame包含一个值为9的opcode,它可能携带数据。收到Ping Frame后,Pong Frame必须被作为响应发出。Pong Frame包含一个值为10的opcode,它将包含与Ping Frame中相同的数据。

借助ws包,服务端可以这么来发送Ping Frame。

wsServer.ping();

同时,需要监听客户端响应的pong Frame.

wsServer.on('pong', function(data, flags) {
  console.log(data);// ""
  console.log(flags);// { masked: true,binary: true }
});

以上,由于Ping Frame 不带数据,因此作为响应的Pong Frame的data值为空串。遗憾的是,目前浏览器只能被动发送Pong Frame作为响应(Sending websocket ping/pong frame from browser),无法通过JS API主动向服务端发送Ping Frame。因此对于web服务,可以采取服务端主动ping的方式,来保持住链接。实际应用中,服务端还需要设置心跳的周期,以保证心跳连接可以一直持续。同时,还应该有重发机制,若连续几次没有收到心跳连接的回复,则认为连接已经断开,此时便可以关闭Websocket连接了。

Socket.IO

WebSocket出世已久,很多优秀的大神基于此开发出了各式各样的库。其中Socket.IO是一个非常不错的开源WebSocke库,旨在抹平浏览器之间的兼容性问题。它基于Node.js,支持以下方式优雅降级:

  • Websocket
  • Adobe® Flash® Socket
  • AJAX long polling
  • AJAX multipart streaming
  • Forever Iframe
  • JSONP Polling

如何在项目中使用Socket.IO,请参考第一章 socket.io 简介及使用

小结

EventSource,本质依然是HTTP,它仅提供服务端到客户端的单向文本数据传输,不需要心跳连接,连接断开会持续触发重连。

WebSocket协议,基于TCP协议,它提供双向数据传输,支持二进制,需要心跳连接,连接断开不会重连。

EventSource更轻量和简单,WebSocket支持性更好(因其支持IE10+)。通常来说,使用EventSource能够完成的功能,使用WebSocket一样能够做到,反之却不行,使用时若遇到连接断开或抛错,请及时调用各自的close方法主动释放资源。


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论

本文作者: louis

本文链接: http://louiszhai.github.io/2017/04/19/hmr/

参考文章

]]>
EventSource 热更新 webpack热更新延迟 websocket 心跳连接 Frame Socket.IO browser-sync webpack-hot-middleware
浏览器缓存机制剖析 http://louiszhai.github.io/2017/04/07/http-cache/ 2017-04-07T00:00:00.000Z 2020-06-04T06:36:08.672Z 缓存一直是前端优化的主战场, 利用好缓存就成功了一半. 本篇从http请求和响应的头域入手, 让你对浏览器缓存有个整体的概念. 最终你会发现强缓存, 协商缓存 和 启发式缓存是如此的简单.

导读

我不知道拖延症是有多严重, 反正去年3月开的题, 直到今年4月才开始写.(请尽情吐槽吧)

浏览器对于请求资源, 拥有一系列成熟的缓存策略. 按照发生的时间顺序分别为存储策略, 过期策略, 协商策略, 其中存储策略在收到响应后应用, 过期策略, 协商策略在发送请求前应用. 流程图如下所示.

废话不多说, 我们先来看两张表格.

1.http header中与缓存有关的key.

key 描述 存储策略 过期策略 协商策略
Cache-Control 指定缓存机制,覆盖其它设置 ✔️ ✔️
Pragma http1.0字段,指定缓存机制 ✔️
Expires http1.0字段,指定缓存的过期时间 ✔️
Last-Modified 资源最后一次的修改时间 ✔️
ETag 唯一标识请求资源的字符串 ✔️

2.缓存协商策略用于重新验证缓存资源是否有效, 有关的key如下.

key 描述
If-Modified-Since 缓存校验字段, 值为资源最后一次的修改时间, 即上次收到的Last-Modified值
If-Unmodified-Since 同上, 处理方式与之相反
If-Match 缓存校验字段, 值为唯一标识请求资源的字符串, 即上次收到的ETag值
If-None-Match 同上, 处理方式与之相反

下面我们来看下各个头域(key)的作用.

Cache-Control

浏览器缓存里, Cache-Control是金字塔顶尖的规则, 它藐视一切其他设置, 只要其他设置与其抵触, 一律覆盖之.

不仅如此, 它还是一个复合规则, 包含多种值, 横跨 存储策略, 过期策略 两种, 同时在请求头和响应头都可设置.

语法为: “Cache-Control : cache-directive”.

Cache-directive共有如下12种(其中请求中指令7种, 响应中指令9种):

Cache-directive 描述 存储策略 过期策略 请求字段 响应字段
public 资源将被客户端和代理服务器缓存 ✔️ ✔️
private 资源仅被客户端缓存, 代理服务器不缓存 ✔️ ✔️
no-store 请求和响应都不缓存 ✔️ ✔️ ✔️
no-cache 相当于max-age:0,must-revalidate即资源被缓存, 但是缓存立刻过期, 同时下次访问时强制验证资源有效性 ✔️ ✔️ ✔️ ✔️
max-age 缓存资源, 但是在指定时间(单位为秒)后缓存过期 ✔️ ✔️ ✔️ ✔️
s-maxage 同上, 依赖public设置, 覆盖max-age, 且只在代理服务器上有效. ✔️ ✔️ ✔️
max-stale 指定时间内, 即使缓存过时, 资源依然有效 ✔️ ✔️
min-fresh 缓存的资源至少要保持指定时间的新鲜期 ✔️ ✔️
must-revalidation / proxy-revalidation 如果缓存失效, 强制重新向服务器(或代理)发起验证(因为max-stale等字段可能改变缓存的失效时间) ✔️ ✔️
only-if-cached 仅仅返回已经缓存的资源, 不访问网络, 若无缓存则返回504 ✔️
no-transform 强制要求代理服务器不要对资源进行转换, 禁止代理服务器对 Content-Encoding, Content-Range, Content-Type字段的修改(因此代理的gzip压缩将不被允许) ✔️ ✔️

假设所请求资源于4月5日缓存, 且在4月12日过期.

当max-age 与 max-stale 和 min-fresh 同时使用时, 它们的设置相互之间独立生效, 最为保守的缓存策略总是有效. 这意味着, 如果max-age=10 days, max-stale=2 days, min-fresh=3 days, 那么:

  • 根据max-age的设置, 覆盖原缓存周期, 缓存资源将在4月15日失效(5+10=15);
  • 根据max-stale的设置, 缓存过期后两天依然有效, 此时响应将返回110(Response is stale)状态码, 缓存资源将在4月14日失效(12+2=14);
  • 根据min-fresh的设置, 至少要留有3天的新鲜期, 缓存资源将在4月9日失效(12-3=9);

由于客户端总是采用最保守的缓存策略, 因此, 4月9日后, 对于该资源的请求将重新向服务器发起验证.

Pragma

http1.0字段, 通常设置为Pragma:no-cache, 作用同Cache-Control:no-cache. 当一个no-cache请求发送给一个不遵循HTTP/1.1的服务器时, 客户端应该包含pragma指令. 为此, 勾选☑️ 上disable cache时, 浏览器自动带上了pragma字段. 如下:

Pragma:no-cache

Expires

Expires:Wed, 05 Apr 2017 00:55:35 GMT

即到期时间, 以服务器时间为参考系, 其优先级比 Cache-Control:max-age 低, 两者同时出现在响应头时, Expires将被后者覆盖. 如果Expires, Cache-Control: max-age, 或 Cache-Control:s-maxage 都没有在响应头中出现, 并且也没有其它缓存的设置, 那么浏览器默认会采用一个启发式的算法, 通常会取响应头的Date_value - Last-Modified_value值的10%作为缓存时间。(更多资料可参考:Caching in HTTP

如下资源便采取了启发式缓存算法.

启发式缓存生效

其缓存时间为 (Date_value - Last-Modified_value) * 10%, 计算如下:

const Date_value = new Date('Thu, 06 Apr 2017 01:30:56 GMT').getTime();
const LastModified_value = new Date('Thu, 01 Dec 2016 06:23:23 GMT').getTime();
const cacheTime = (Date_value - LastModified_value) / 10;
const Expires_timestamp = Date_value + cacheTime;
const Expires_value = new Date(Expires_timestamp);
console.log('Expires:', Expires_value); // Expires: Tue Apr 18 2017 23:25:41 GMT+0800 (CST)

可见该资源将于2017年4月18日23点25分41秒过期, 尝试以下两步进行验证:

1) 试着把本地时间修改为2017年4月18日23点25分40秒, 迅速刷新页面, 发现强缓存依然有效(依旧是200 OK (from disk cache)).

2) 然后又修改本地时间为2017年4月18日23点26分40秒(即往后拨1分钟), 刷新页面, 发现缓存已过期, 此时浏览器重新向服务器发起了验证, 且命中了304协商缓存, 如下所示.

缓存过期, 重新发起验证, 命中304协商缓存

3) 将本地时间恢复正常(即 2017-04-06 09:54:19). 刷新页面, 发现Date依然是4月18日, 如下所示.

本地时间恢复正常, 缓存依然有效

⚠️ Provisional headers are shown 和Date字段可以看出来, 浏览器并未发出请求, 缓存依然有效, 只不过此时Status Code显示为200 OK. (甚至我还专门打开了charles, 也没有发现该资源的任何请求, 可见这个200 OK多少有些误导人的意味)

可见, 启发式缓存算法采用的缓存时间可长可短, 因此对于常规资源, 建议明确设置缓存时间(如指定max-age 或 expires).

ETag

ETag:"fcb82312d92970bdf0d18a4eca08ebc7efede4fe"

实体标签, 服务器资源的唯一标识符, 浏览器可以根据ETag值缓存数据, 节省带宽. 如果资源已经改变, etag可以帮助防止同步更新资源的相互覆盖. ETag 优先级比 Last-Modified 高.

If-Match

语法: If-Match: ETag_value 或者 If-Match: ETag_value, ETag_value, …

缓存校验字段, 其值为上次收到的一个或多个etag 值. 常用于判断条件是否满足, 如下两种场景:

  • 对于 GET 或 HEAD 请求, 结合 Range 头字段, 它可以保证新范围的请求和前一个来自相同的源, 如果不匹配, 服务器将返回一个416(Range Not Satisfiable)状态码的响应.
  • 对于 PUT 或者其他不安全的请求, If-Match 可用于阻止错误的更新操作, 如果不匹配, 服务器将返回一个412(Precondition Failed)状态码的响应.

If-None-Match

语法: If-None-Match: ETag_value 或者 If-None-Match: ETag_value, ETag_value, …

缓存校验字段, 结合ETag字段, 常用于判断缓存资源是否有效, 优先级比If-Modified-Since高.

  • 对于 GET 或 HEAD 请求, 如果其etags列表均不匹配, 服务器将返回200状态码的响应, 反之, 将返回304(Not Modified)状态码的响应. 无论是200还是304响应, 都至少返回 Cache-Control, Content-Location, Date, ETag, Expires, and Vary 中之一的字段.
  • 对于其他更新服务器资源的请求, 如果其etags列表匹配, 服务器将执行更新, 反之, 将返回412(Precondition Failed)状态码的响应.

Last-Modified

语法: Last-Modified: 星期,日期 月份 年份 时:分:秒 GMT

Last-Modified: Tue, 04 Apr 2017 10:01:15 GMT

用于标记请求资源的最后一次修改时间, 格式为GMT(格林尼治标准时间). 如可用 new Date().toGMTString()获取当前GMT时间. Last-Modified 是 ETag 的fallback机制, 优先级比 ETag 低, 且只能精确到秒, 因此不太适合短时间内频繁改动的资源. 不仅如此, 服务器端的静态资源, 通常需要编译打包, 可能出现资源内容没有改变, 而Last-Modified却改变的情况.

If-Modified-Since

语法同上, 如:

If-Modified-Since: Tue, 04 Apr 2017 10:12:27 GMT

缓存校验字段, 其值为上次响应头的Last-Modified值, 若与请求资源当前的Last-Modified值相同, 那么将返回304状态码的响应, 反之, 将返回200状态码响应.

If-Unmodified-Since

缓存校验字段, 语法同上. 表示资源未修改则正常执行更新, 否则返回412(Precondition Failed)状态码的响应. 常用于如下两种场景:

  • 不安全的请求, 比如说使用post请求更新wiki文档, 文档未修改时才执行更新.
  • 与 If-Range 字段同时使用时, 可以用来保证新的片段请求来自一个未修改的文档.

强缓存

一旦资源命中强缓存, 浏览器便不会向服务器发送请求, 而是直接读取缓存. Chrome下的现象是 200 OK (from disk cache) 或者 200 OK (from memory cache). 如下:

200 OK (from disk cache)

200 OK (from memory cache)

对于常规请求, 只要存在该资源的缓存, 且Cache-Control:max-age 或者expires没有过期, 那么就能命中强缓存.

协商缓存

缓存过期后, 继续请求该资源, 对于现代浏览器, 拥有如下两种做法:

  • 根据上次响应中的ETag_value, 自动往request header中添加If-None-Match字段. 服务器收到请求后, 拿If-None-Match字段的值与资源的ETag值进行比较, 若相同, 则命中协商缓存, 返回304响应.
  • 根据上次响应中的Last-Modified_value, 自动往request header中添加If-Modified-Since字段. 服务器收到请求后, 拿If-Modified-Since字段的值与资源的Last-Modified值进行比较, 若相同, 则命中协商缓存, 返回304响应.

以上, ETag优先级比Last-Modified高, 同时存在时, 前者覆盖后者. 下面通过实例来理解下强缓存和协商缓存.

如下忽略首次访问, 第二次通过 If-Modified-Since 命中了304协商缓存.

304

协商缓存的响应结果, 不仅验证了资源的有效性, 同时还更新了浏览器缓存. 主要更新内容如下:

Age:0
Cache-Control:max-age=600
Date: Wed, 05 Apr 2017 13:09:36 GMT
Expires:Wed, 05 Apr 2017 00:55:35 GMT

Age:0 表示命中了代理服务器的缓存, age值为0表示代理服务器刚刚刷新了一次缓存.

Cache-Control:max-age=600 覆盖 Expires 字段, 表示从Date_value, 即 Wed, 05 Apr 2017 13:09:36 GMT 起, 10分钟之后缓存过期. 因此10分钟之内访问, 将会命中强缓存, 如下所示:

200 from cache

当然, 除了上述与缓存直接相关的字段外, http header中还包括如下间接相关的字段.

Age

出现此字段, 表示命中代理服务器的缓存. 它指的是代理服务器对于请求资源的已缓存时间, 单位为秒. 如下:

Age:2383321
Date:Wed, 08 Mar 2017 16:12:42 GMT

以上指的是, 代理服务器在2017年3月8日16:12:42时向源服务器发起了对该资源的请求, 目前已缓存了该资源2383321秒.

Date

指的是响应生成的时间. 请求经过代理服务器时, 返回的Date未必是最新的, 通常这个时候, 代理服务器将增加一个Age字段告知该资源已缓存了多久.

Vary

对于服务器而言, 资源文件可能不止一个版本, 比如说压缩和未压缩, 针对不同的客户端, 通常需要返回不同的资源版本. 比如说老式的浏览器可能不支持解压缩, 这个时候, 就需要返回一个未压缩的版本; 对于新的浏览器, 支持压缩, 返回一个压缩的版本, 有利于节省带宽, 提升体验. 那么怎么区分这个版本呢, 这个时候就需要Vary了.

服务器通过指定Vary: Accept-Encoding, 告知代理服务器, 对于这个资源, 需要缓存两个版本: 压缩和未压缩. 这样老式浏览器和新的浏览器, 通过代理, 就分别拿到了未压缩和压缩版本的资源, 避免了都拿同一个资源的尴尬.

Vary:Accept-Encoding,User-Agent

如上设置, 代理服务器将针对是否压缩和浏览器类型两个维度去缓存资源. 如此一来, 同一个url, 就能针对PC和Mobile返回不同的缓存内容.

怎么让浏览器不缓存静态资源

实际上, 工作中很多场景都需要避免浏览器缓存, 除了浏览器隐私模式, 请求时想要禁用缓存, 还可以设置请求头: Cache-Control: no-cache, no-store, must-revalidate .

当然, 还有一种常用做法: 即给请求的资源增加一个版本号, 如下:

<link rel="stylesheet" type="text/css" href="../css/style.css?version=1.8.9"/>

这样做的好处就是你可以自由控制什么时候加载最新的资源.

不仅如此, HTML也可以禁用缓存, 即在页面的\节点中加入\标签, 代码如下:

<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"/>

上述虽能禁用缓存, 但只有部分浏览器支持, 而且由于代理不解析HTML文档, 故代理服务器也不支持这种方式.

IE8的异常表现

实际上, 上述缓存有关的规律, 并非所有浏览器都完全遵循. 比如说IE8.

资源缓存是否有效相关.

浏览器 前提 操作 表现 正常表现
IE8 资源缓存有效 新开一个窗口加载网页 重新发送请求(返回200) 展示缓存的页面
IE8 资源缓存失效 原浏览器窗口中单击 Enter 按钮 展示缓存的页面 重新发送请求(返回200)

Last-Modified / E-Tag 相关.

浏览器 前提 操作 表现 正常表现
IE8 资源内容没有修改 新开一个窗口加载网页 浏览器重新发送请求(返回200) 重新发送请求(返回304)
IE8 资源内容已修改 原浏览器窗口中单击 Enter 按钮 浏览器展示缓存的页面 重新发送请求(返回200)

版权声明:转载需注明作者和出处。

本文作者:louis

本文链接:http://louiszhai.github.io/2017/04/07/http-cache/

参考文章

]]>
http-cache 浏览器缓存策略 浏览器缓存机制 304协商缓存 强缓存 Age Date Vary Cache-Control Expires ETag Last-Modified Pragma If-Modified-Since If-Unmodified-Since If-Match If-None-Match no-cache max-age max-stale min-fresh must-revalidation
Promise使用手册 http://louiszhai.github.io/2017/03/12/promise/ 2017-03-12T13:28:23.000Z 2019-12-02T03:28:32.890Z 开始写本篇文字时, 我一直不是很明白任务队列的机制, 故想写篇文章弄懂它. 于是我尝试以Promise为核心, 逐步展开, 最终分析process.nextTick , promise.then , setTimeout , setImmediate 它们的异步机制.

导读

Promise问世已久, 其科普类文章亦不计其数. 遂本篇初衷不为科普, 只为能够温故而知新.

比如说, catch能捕获所有的错误吗? 为什么有些时候会抛出”Uncaught (in promise) …”? Promise.resolvePromise.reject 处理Promise对象时又有什么不一样的地方?

Promise

引子

阅读此篇之前, 我们先体验一下如下代码:

setTimeout(function() {
  console.log(4)
}, 0);
new Promise(function(resolve) {
  console.log(1);
  for (var i = 0; i < 10000; i++) {
    i == 9999 && resolve()
  }
  console.log(2);
}).then(function() {
  console.log(5)
});
console.log(3);

这里先卖个关子, 后续将给出答案并提供详细分析.

和往常文章一样, 我喜欢从api入手, 先具象地了解一个概念, 然后再抽象或扩展这个概念, 接着再谈谈概念的具体应用场景, 通常末尾还会有一个简短的小结. 这样, 查询api的读者可以选择性地阅读上文, 希望深入的读者可以继续剖析概念, 当然我更希望你能耐心地读到应用场景处, 这样便能升华对这个概念或技术的运用, 也能避免踩坑.

new Promise

Promise的设计初衷是避免异步回调地狱. 它提供更简洁的api, 同时展平回调为链式调用, 使得代码更加清爽, 易读.

如下, 即创建一个Promise对象:

const p = new Promise(function(resolve, reject) {
  console.log('Create a new Promise.');
});
console.log(p);

new Promise

创建Promise时, 浏览器同步执行传入的第一个方法, 从而输出log. 新创建的promise实例对象, 初始状态为等待(pending), 除此之外, Promise还有另外两个状态:

  • fulfilled, 表示操作完成, 实现了. 只在resolve方法执行时才进入该状态.
  • rejected, 表示操作失败, 拒绝了. 只在reject方法执行时或抛出错误的情况下才进入该状态.

如下图展示了Promise的状态变化过程(图片来自MDN):

Promise state

从初始状态(pending)到实现(fulfilled)或拒绝(rejected)状态的转换, 这是两个分支, 实现或拒绝即最终状态, 一旦到达其中之一的状态, promise的状态便稳定了. (因此, 不要尝试实现或拒绝状态的互转, 它们都是最终状态, 没法转换)

以上, 创建Promise对象时, 传入的回调函数function(resolve, reject){}默认拥有两个参数, 分别为:

  • resolve, 用于改变该Promise本身的状态为实现, 执行后, 将触发then的onFulfilled回调, 并把resolve的参数传递给onFulfilled回调.
  • reject, 用于改变该Promise本身的状态为拒绝, 执行后, 将触发 then | catch的onRejected回调, 并把reject的参数传递给onRejected回调.

Promise的原型仅有两个自身方法, 分别为 Promise.prototype.then , Promise.prototype.catch . 而它自身仅有四个方法, 分别为 Promise.reject , Promise.resolve , Promise.all , Promise.race .

then

语法: Promise.prototype.then(onFulfilled, onRejected)

用于绑定后续操作. 使用十分简单:

p.then(function(res) {
  console.log('此处执行后续操作');
});
// 当然, then的最大便利之处便是可以链式调用
p.then(function(res) {
  console.log('先做一件事');
}).then(function(res) {
  console.log('再做一件事');
});
// then还可以同时接两个回调,分别处理成功和失败状态
p.then(function(SuccessRes) {
  console.log('处理成功的操作');
}, function(failRes) {
  console.log('处理失败的操作');
});

不仅如此, Promise的then中还可返回一个新的Promise对象, 后续的then将接着继续处理这个新的Promise对象.

p.then(function(){
  return new Promise(function(resolve, reject) {
    console.log('这里是一个新的Promise对象');
    resolve('New Promise resolve.');
  });
}).then(function(res) {
  console.log(res);
});

那么, 如果没有指定返回值, 会怎么样?(没有指定返回值,实际上就默认返回了undefined)

根据Promise规范,

Values returned from the onFulfilled or onRejected callback functions will be automatically wrapped in a resolved promise.**

这意味着:then或catch,它们也总是自动包装一个新的fulfilled状态的promise对象,无论你返回的是promise、或者undefined,它依然会基于返回值再包裹一层fulfilled状态的promise。眼见为实,如下:

var p1 = new Promise(() => {
});
var p2 = new Promise((r) => {
  r();
}).then(() => {
  return p1;
});
console.log(p1 === p2); // false

这点非常重要,如果你在使用vue,那么dispatch返回的promise会在mapgetters返回的promise之后执行。

catch

语法: Promise.prototype.catch(onRejected)

用于捕获并处理异常. 无论是程序抛出的异常, 还是主动reject掉Promise自身, 都会被catch捕获到.

new Promise(function(resolve, reject) {
  reject('该prormise已被拒绝');
}).catch(function(reason) {
  console.log('catch:', reason);
});

同then语句一样, catch也是可以链式调用的.

new Promise(function(resolve, reject){
  reject('该prormise已被拒绝');
}).catch(function(reason){
  console.log('catch:', reason);
  console.log(a);
}).catch(function(reason){
  console.log(reason);
});

以上, 将依次输出两次log, 第一次输出promise被拒绝, 第二次输出”ReferenceError a is not defined”的堆栈信息.

catch能捕获哪些错误

那是不是catch可以捕获所有错误呢? 可以, 怎么不可以, 我以前也这么天真的认为. 直到有一天我执行了如下的语句, 我就学乖了.

new Promise(function(resolve, reject){
  Promise.reject('返回一个拒绝状态的Promise');
}).catch(function(reason){
  console.log('catch:', reason);
});

执行结果如下:

为什么catch没有捕获到该错误呢? 这个问题, 待下一节我们了解了Promise.reject语法后再做分析.

Promise.reject

语法: Promise.reject(value)

该方法返回一个拒绝状态的Promise对象, 同时传入的参数作为PromiseValue.

//params: String
Promise.reject('该prormise已被拒绝')
.catch(function(reason){
  console.log('catch:', reason);
});
//params: Error
Promise.reject(new Error('这是一个error')).then(function(res) {
  console.log('fulfilled:', res);
}, function(reason) {
  console.log('rejected:', reason); // rejected: Error: 这是一个error...
});

即使参数为Promise对象, 它也一样会把Promise当作拒绝的理由, 在外部再包装一个拒绝状态的Promise对象予以返回.

//params: Promise
const p = new Promise(function(resolve) {
  console.log('This is a promise');
});
Promise.reject(p).catch(function(reason) {
  console.log('rejected:', reason);
  console.log(p == reason);
});
// "This is a promise"
// rejected: Promise {[[PromiseStatus]]: "pending", [[PromiseValue]]: undefined}
// true

以上代码片段, Promise.reject(p) 进入到了catch语句中, 说明其返回了一个拒绝状态的Promise, 同时拒绝的理由就是传入的参数p.

错误处理

我们都知道, Promise.reject返回了一个拒绝状态的Promise对象. 对于这样的Promise对象, 如果其后续then | catch中都没有声明onRejected回调, 它将会抛出一个 “Uncaught (in promise) …”的错误. 如上图所示, 原语句是 “Promise.reject(‘返回一个拒绝状态的Promise’);” 其后续并没有跟随任何then | catch语句, 因此它将抛出错误, 且该错外部的Promise无法捕获.

不仅如此, Promise之间泾渭分明, 内部Promise抛出的任何错误, 外部Promise对象都无法感知并捕获. 同时, 由于promise是异步的, try catch语句也无法捕获其错误.

因此养成良好习惯, promise记得写上catch.

除了catch, nodejs下Promise抛出的错误, 还会被进程的unhandledRejectionrejectionHandled事件捕获.

var p = new Promise(function(resolve, reject){
  //console.log(a);
  reject('rejected');
});
setTimeout(function(){
  p.catch(function(reason){
    console.info('promise catch:', reason);
  });
});
process.on('uncaughtException', (e) => {
  console.error('uncaughtException', e);
});
process.on('unhandledRejection', (e) => {
  console.info('unhandledRejection:', e);
});
process.on('rejectionHandled', (e) => {
  console.info('rejectionHandled', e);
});
//unhandledRejection: rejected
//rejectionHandled Promise { <rejected> 'rejected' }
//promise catch: rejected

即使去掉以上代码中的注释, 输出依然一致. 可见, Promise内部抛出的错误, 都不会被uncaughtException事件捕获.

链式写法的好处

请看如下代码:

new Promise(function(resolve, reject) {
  resolve('New Promise resolve.');
}).then(function(str) {
  throw new Error("oops...");
},function(error) {
    console.log('then catch:', error);
}).catch(function(reason) {
    console.log('catch:', reason);
});
//catch: Error: oops...

可见, then语句的onRejected回调并不能捕获onFulfilled回调内抛出的错误, 尾随其后的catch语句却可以, 因此推荐链式写法.

Promise.resolve

语法: Promise.resolve(value | promise | thenable)

thenable 表示一个定义了 then 方法的对象或函数.

参数为promise时, 返回promise本身.

参数为thenable的对象或函数时, 将其then属性作为new promise时的回调, 返回一个包装的promise对象.(注意: 这里与Promise.reject直接包装一个拒绝状态的Promise不同)

其他情况下, 返回一个实现状态的Promise对象, 同时传入的参数作为PromiseValue.

//params: String
//return: fulfilled Promise
Promise.resolve('返回一个fulfilled状态的promise').then(function(res) {
  console.log(res); // "返回一个fulfilled状态的promise"
});

//params: Array
//return: fulfilled Promise
Promise.resolve(['a', 'b', 'c']).then(function(res) {
  console.log(res); // ["a", "b", "c"]
});

//params: Promise
//return: Promise self
let resolveFn;
const p2 = new Promise(function(resolve) {
  resolveFn = resolve;
});
const r2 = Promise.resolve(p2);
r2.then(function(res) {
  console.log(res);
});
resolveFn('xyz'); // "xyz"
console.log(r2 === p2); // true

//params: thenable Object
//return: 根据thenable的最终状态返回不同的promise
const thenable = {
  then: function(resolve, reject) { //作为new promise时的回调函数
    reject('promise rejected!');
  }
};
Promise.resolve(thenable).then(function(res) {
  console.log('res:', res);
}, function(reason) {
  console.log('reason:', reason);
});

可见, Promise.resolve并非返回实现状态的Promise这么简单, 我们还需基于传入的参数动态判断.

至此, 我们基本上不用期望使用Promise全局方法中去改变其某个实例的状态.

  • 对于Promise.reject(promise), 它只是简单地包了一个拒绝状态的promise壳, 参数promise什么都没变.
  • 对于Promise.resolve(promise), 仅仅返回参数promise本身.

Promise.all

语法: Promise.all(iterable)

该方法接一个迭代器(如数组等), 返回一个新的Promise对象. 如果迭代器中所有的Promise对象都被实现, 那么, 返回的Promise对象状态为”fulfilled”, 反之则为”rejected”. 概念上类似Array.prototype.every.

//params: all fulfilled promise
//return: fulfilled promise
Promise.all([1, 2, 3]).then(function(res){
  console.log('promise fulfilled:', res); // promise fulfilled: [1, 2, 3]
});

//params: has rejected promise
//return: rejected promise
const p = new Promise(function(resolve, reject){
  reject('rejected');
});
Promise.all([1, 2, p]).then(function(res){
  console.log('promise fulfilled:', res);
}).catch(function(reason){
  console.log('promise reject:', reason); // promise reject: rejected
});

Promise.all特别适用于处理依赖多个异步请求的结果的场景.

Promise.race

该方法接一个迭代器(如数组等), 返回一个新的Promise对象. 只要迭代器中有一个Promise对象状态改变(被实现或被拒绝), 那么返回的Promise将以相同的值被实现或拒绝, 然后它将忽略迭代器中其他Promise的状态变化.

Promise.race([1, Promise.reject(2)]).then(function(res){
  console.log('promise fulfilled:', res);
}).catch(function(reason){
  console.log('promise reject:', reason);
});
// promise fulfilled: 1

如果调换以上参数的顺序, 结果将输出 “promise reject: 2”. 可见对于状态稳定的Promise(fulfilled 或 rejected状态), 哪个排第一, 将返回哪个.

Promise.race适用于多者中取其一的场景, 比如同时发送多个请求, 只要有一个请求成功, 那么就以该Promise的状态作为最终的状态, 该Promise的值作为最终的值, 包装成一个新的Promise对象予以返回.

Fetch进阶指南 一文中, 我曾利用Promise.race模拟了Promise的abort和timeout机制.

Promises/A+规范的要点

promise.then(onFulfilled, onRejected)中, 参数都是可选的, 如果onFulfilled或onRejected不是函数, 那么将忽略它们.

catch只是then的语法糖, 相当于promise.then(null, onRejected).

任务队列之谜

终于, 我们要一起来看看文章起始的一道题目.

setTimeout(function() {
  console.log(4)
}, 0);
new Promise(function(resolve) {
  console.log(1);
  for (var i = 0; i < 10000; i++) {
    i == 9999 && resolve()
  }
  console.log(2);
}).then(function() {
  console.log(5)
});
console.log(3);

这道题目来自知乎(机智的你可能早已看穿, 但千万别戳破😂), 可以戳此链接 Promise的队列与setTimeout的队列有何关联 围观点赞.

围观完了, 别忘了继续读下去, 这里请允许我站在诸位知乎大神的肩膀上, 继续深入分析.

以上代码, 最终运行结果是1,2,3,5,4. 并不是1,2,3,4,5.

  1. 首先前面有提到, new Promise第一个回调函数内的语句同步执行, 因此控制台将顺序输出1,2, 此处应无异议.
  2. console.log(3), 这里是同步执行, 因此接着将输出3, 此处应无异议.
  3. 剩下便是setTimeout 和 Promise的then的博弈了, 同为异步事件, 为什么then后注册却先于setTimeout执行?

之前, 我们在 Ajax知识体系 一文中有提到:

浏览器中, js引擎线程会循环从 任务队列 中读取事件并且执行, 这种运行机制称作 Event Loop (事件循环).

不仅如此, event loop至少拥有如下两种队列:

  • task queue, 也叫macrotask queue, 指的是宏任务队列, 包括rendering, script(页面脚本), 鼠标, 键盘, 网络请求等事件触发, setTimeout, setInterval, setImmediate(node)等等.
  • microtask queue, 指的是微任务队列, 用于在浏览器重新渲染前执行, 包含Promise, process.nextTick(node), Object.observe, MutationObserver回调等.

如下是HTML规范原文:

An event loop has one or more task queues. A task queue is an ordered list of tasks, which are algorithms that are responsible for such work as: events, parsing, callbacks, using a resource, reacting to DOM manipulation…

Each event loop has a microtask queue. A microtask is a task that is originally to be queued on the microtask queue rather than a task queue.

浏览器(或宿主环境) 遵循队列先进先出原则, 依次遍历macrotask queue中的每一个task, 不过每执行一个macrotask, 并不是立即就执行下一个, 而是执行一遍microtask queue中的任务, 然后切换GUI线程重新渲染或垃圾回收等.

上述代码块可以看做是一个macrotask, 对于其执行过程, 不妨作如下简化:

  1. 首先执行当前macrotask, 将setTimeout回调以一个新的task形式, 加入到macrotask queue末尾.
  2. 当前macrotask继续执行, 创建一个新的Promise, 同步执行其回调函数, 输出1; for循环1w次, 然后执行resolve方法, 将该Promise回调加入到microtask queue末尾, 循环结束, 接着输出2.
  3. 当前macrotask继续执行, 输出3. 至此, 当前macrotask执行完毕.
  4. 开始顺序执行microtask queue中的所有任务, 也包括刚刚加入到队列末尾 Promise回调, 故输出5. 至此, microtask queue任务全部执行完毕, microtask queue清空.
  5. 浏览器挂起js引擎, 可能切换至GUI线程或者执行垃圾回收等.
  6. 切换回js引擎, 继续从macrotask queue取出下一个macrotask, 执行之, 然后再取出microtask queue, 执行之, 后续所有的macrotask均如此重复. 自然, 也包括刚刚加入到队列末尾的setTimeout回调, 故输出4.

这里直接给出事件回调优先级:

process.nextTick > promise.then > setTimeout ? setImmediate

nodejs中每一次event loop称作tick. _tickCallback在macrotask queue中每个task执行完成后触发. 实际上, _tickCallback内部共干了两件事:

  1. 执行nextTick queue中的所有任务, 包括process.nextTick注册的回调.
  2. 第一步完成后执行 _runMicrotasks函数, 即执行microtask queue中的所有任务, 包括promise.then注册的回调.

因此, process.nextTick优先级比promise.then高.

那么setTimeout与setImmediate到底哪个更快呢? 回答是并不确定. 请看如下代码:

setImmediate(function(){
    console.log(1);
});
setTimeout(function(){
    console.log(0);
}, 0);

前后两次的执行结果如下:

测试时, 我本地node版本是v5.7.0.


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文链接: http://louiszhai.github.io/2017/02/25/promise/

参考文章

]]>
promise promise.race promise.all Promise.reject Promise.resolve then catch Promises/A+ process.nextTick setImmediate
为什么Web App的返回逻辑如此复杂 http://louiszhai.github.io/2017/02/24/back/ 2017-02-24T07:59:01.000Z 2019-12-02T03:28:32.873Z 导读

最近我在梳理公司web app新产品线的返回逻辑, 推演了N种方案, 竟然没有一种完全通用的. 这让我迷惑不已. 仔细体验了公司的各种H5页面, 发现返回逻辑还真是五花八门. 那么, 问题来了, 为什么Web App的返回逻辑如此难以设计?

可供尝试的api

相对于较复杂的返回场景, 可用的api少得可怜. webview的history对象中, 仅 back 和 go 方法可用. 它们遵循如下规律:

  • 调用history.back 如果存在历史的话, 只能回到前一个页面, 否则将静默失败.
  • 调用history.go(-n) 如果存在历史的话, 可以回到前n个页面, 否则将静默失败.

从这里看起来, 似乎想回到哪就回到哪. 自然不是, 不然就不会有这篇文章了.

不得不说的是, H5中有个堪称坑爹的设定, 就是history.length属性, 该属性无论何时, 都是当前webview历史栈的总长度. 也就是说, 无论你的网页是第一个打开的, 还是中间打开的, 还是最后一个打开的, 只要返回到你的网页, 你获取到的history.length都是相同的值. 无论如何你都不能直接拿到你的网页在webview历史栈中的位置(或者序号), 这将导致你不知道要往前跳几步(假设要跳过若干个历史), 因此你不能随心所欲的调用history.go方法.

通常情况下,若web app自带返回按钮, 如果其中一个网页A是通过重定向模拟返回到它之前的某个网页B, 用户在新的网页B点击返回按钮, 将返回到网页A, 此时再点击网页A的返回按钮, 那么又将进入到新的网页B2中. 如下:

①—>A —②重定向—> B —③返回—> A —④重定向—> B2 —⑤返回—> A

我们来看看webview历史栈发生了什么, 假设历史栈已经存储了n项历史:

  • ①栈顶压入A页面, 此时当前页面指针后移1位, 指向A页面, 且A页面位于栈顶, 此时历史栈长度为n+1;
  • ②栈顶压入B页面, 此时当前页面指针后移1位, 指向B页面, 且B页面位于栈顶, 此时历史栈长度为n+2;
  • ③当前页面指针前移1位, 指向A页面, 此时B页面位于栈顶, 历史栈长度依然为n+2;
  • ④当前页面指针后的栈被清空, 历史栈长度为n+1, 栈顶压入B2页面, 当前页面指针后移1位, 指向B2页面, 且B2页面位于栈顶, 此时历史栈长度为n+2;
  • ⑤当前页面指针前移1位, 指向A页面, 此时B2页面位于栈顶, 历史栈长度依然为n+2;

B和B2其实是同一个网页, 除了历史栈中的位置不同, 他们没有任何不同. 如此逻辑将使得我们将陷入返回的死循环中. 为避免这种体验上的缺陷, 请尽量不要在返回逻辑中重定向到某个之前的页面.

webview中通过location.href方式跳转链接, 可起到清理浏览历史项的作用, 如此时webview中共存在100个历史项, 我们一路返回至第90个页面(该页面的history.length依然是100), 然后在该页面通过location.href跳转至另一个页面, 那么新的页面将处于历史项的第91项, 原来的第91~100项历史将被清空. 于是新的页面中获取的history.length将准确地标示了该页面处于历史项的第几项.

重定向过后怎么返回

愿望是美好的, 现实是残酷的. 纯H5下想要在返回逻辑中不重定向, 先砍了这些需求再说:

  • 从网页A跳走, 中间经过n个其他域名的网页, 最终不希望用户按步返回, 希望能够直接返回网页A的场景.
  • 从详情页B跳走, 中间需要经过各种支付中间页, 然后进入web版收银台, 弹出支付宝或者微信支付弹框, 支付成功后进入到成功页, 从成功页返回时希望直接回到详情页B的场景.

从产品上看, 这些需求都是合理的. 那么如何从最后一个页面, 成功地返回到初始的A或B页面, 这里我想到了一个解决方案. 思路如下:

history.go方法是可用的. 只要get到了网页处于历史栈的位置, 就可以正常的返回n步. 虽然通常情况下从初始页面A跳出时, history.length并不可靠, 但是从A页面跳到(通过location.href跳转)的第一个页面X中, history.length却是可靠的, 此时该值准确地记录了页面X在历史栈中的位置.(不懂的可以去看上述第④步解析) 只要在页面X中执行如下语句, 便可记录页面A的位置.

//假定原页面A中跳转时执行如下语句
let linkA = "http://www.a.com/a?params=abc";
linkA = window.encodeURIComponent(linkA);
const targetLink = `http://www.x.com/x?from=${linkA}#test`;
location.href = targetLink;

//在页面X执行如下语句
const cursor = history.length - 1;
let params = location.search.match(/from=([^&]*)/),
    from;
if (params instanceof Array && params.length === 2) {
  // 获取原页面A的链接
  from = window.decodeURIComponent(params[1]);
  const a = document.createElement('a');
  a.href = from;
  const paramStr = `historyCursor=${history.length - 1}`;
  // 追加historyCursor=history.length参数
  const searchStr = a.search ? `${a.search}&${paramStr}` : `?${paramStr}`;
  from = a.origin + a.pathname + searchStr + a.hash;
}

页面X经过n次跳转, 其中可能经过了各种支付中间页, 最终又重定向回到页面X’(与页面x链接相同, 但是新的页面), 在页面X’上点击返回按钮时, 此时可以直接重定向回到原页面A’(与页面A链接相似, 仅仅多了参数historyCursor, 是新的页面).

待用户回到了页面A’后, 此时点击返回按钮时, 走的并不是通常的history.back , 而应该是回到页面A的前一个页面, 换句话说 , 此时用户将往回跳n个页面. 这里的主要判断逻辑如下:

function goBack() {
  let start, current, step;
  const params = location.search.match(/historyCursor=(\d+)/);
  if(params instanceof Array && params.length === 2) {
    // 如参数中带有historyCursor, 返回时将回跳n步
    start = +params[1];
    current = history.length;
    step = current - start + 1;
    location.go(-step);
  } else {
    // 默认将返回上一个页面
    history.back();
  }
}

以上, 由多个页面传递historyCursor的值, 基本将需求中的返回逻辑落地了. 美中不足的是, 页面A的返回逻辑依赖了页面X的代码(页面X中需要设置对的historyCursor值), 存在耦合. 这样开发页面A的同学将通知开发页面X的同学, 返回时你要给我加一个historyCursor的参数, 巴拉巴拉. 开发页面X的同学也很纠结, 因为他始终要确认是不是从页面A跳过来的, 如果是, 那他就要加一个historyCursor参数, 并且重定向到页面A. 同时Android系统自带的返回键也会让这套方案更加雪上加霜.

有鉴于此, 以上纯H5的解决方案便不太完美.

H5是否可以打开新的网页窗口

对于非嵌入app的H5应用, 那么使用场景就是各家的浏览器, 应用中对于有可能打乱历史记录的网页, 直接新开窗口就行.

对于嵌入app内的H5应用, 通常来说, H5本身不具备新开webview的能力. 这里需要native辅助. 接下来我们将主要关注嵌入app内的H5的应用.

native提供jsBridge

app内嵌的H5应用, 可借助native的jsBridge新开webview, 从而避免历史记录混乱. 为此, native客户端(包括Android和IOS以及其他)将提供接口以便js打开或关闭webview. 值得考虑的是, 这里面可能带来一个负面影响, js有可能多次申请新开webview, 从而大量消耗内存和电量. 因此, native有必要对webview的个数予以限制.

native维护H5的历史记录

既然开多个webview开销会增大, 基于此, 我突发奇想, 有没有可能由native客户端来维护单个webview的历史记录, 从而所有的页面跳转将由native接管?

我认为这是有可能的. 首先native可以保留每次加载的页面链接, 同时, 页面跳转时可提前设置下一个页面的返回逻辑. 既然历史记录和返回逻辑都在native中注册, 剩下的问题就是, js怎么通知native返回了? 这个也很简单, native不止可以loadUrl, 还可以load页面上的方法. 又页面上用于返回的两个js方法: history.gohistory.back 都是可以重写的. 因此, native可在页面DOMContentLoaded事件回调中重写go和back方法, 改为调用jsBridge接口(此前, 为了解决第三方OAuth2.0登录后返回到空白页的问题, 我写了部分native逻辑, 用于重写js原生go和back方法已在生产环境下使用).

思路如下:

  1. 记录历史栈: native存储webview中加载的每一个页面, 形成一个历史记录栈. 并且标记当前页面处于该历史记录栈的位置.

  2. 重写返回方法: webview中每个页面加载完成后, 重写history.gohistory.back方法, 改为调用jsBridge接口, 方便native感知网页的后退. (下面将详细说明重写的时机)

  3. 设置下一个页面的返回逻辑: 页面跳转之前, 可调用jsBridge强制设置下一个页面的返回url(如从a跳转至b页面, 设置后, 无论b处于历史记录栈的哪一项, 从b返回都将回到a页面)

  4. 回退时检查当前页面的返回逻辑: 一旦H5中调用history.gohistory.back方法返回之前的页面, native自动检查该页面之前是否设置过返回url, 如有则从历史记录栈中捞出该url的位置, 继续调用js原生的history.go方法进行跳转, 同时忽略本次历史; 如无则直接通过原方法跳转页面, 同时忽略本次历史.

  5. 重写Android自带的物理返回键.

    //改写物理返回键的逻辑
    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
      if(keyCode==KeyEvent.KEYCODE_BACK){
        //参照第4步策略实现
      }
    }
    
  6. 激进策略—拦截页面主动发起的重定向(选用): 每次加载url前, 都将检查该url是否在当前页面之前的历史记录栈中出现过, 如有则直接调用js原生的history.go方法, 回退到该url原来的页面.

注: 虽然返回时shouldOverrideUrlLoading事件不会触发, 但onPageStarted和onPageFinished会依次触发一次. 因此上述第4步返回时需要忽略本次历史.

那么如何记录webview历史栈, 并且重写js方法呢?

嫌我啰嗦, 你可能会说 “Talk is cheap, show me the code.” 那么, 请看如下Android代码:

public class History {
  public url;
  public backItem;
  public History(String url){
    this.url = url;
  }
  public History(String url, History backItem){
    this.url = url;
    this.backItem = backItem;
  }
}
/*-----------华丽丽的class分界线------------*/

ArrayList<History> historyList = new ArrayList<History>;

/*-----------华丽丽的class分界线------------*/
public class WebViewManager {
  //此处略去webView元素的获取
  webView.setWebViewClient(new WebViewClient() {
    @Override
    public void onPageStarted(WebView view, String url, Bitmap favicon) {
      // 存储当前页面URL
      History history = new History(url);
      historyList.append(history);
      // 重写js返回方法
      String fnString = "(function(){/*在这里重写history.go和history.back方法*/})()";
      webView.loadUrl("javascript:" + fnString);
    }
  });
}

上面只是Simple的体验, 实际请求中, 一定会有url重定向场景. 接下来我们将着重讨论这种场景.

如何在webview中判断页面是否加载完成

首先, 加载页面有两种方式:

  1. webview主动loadUrl.
  2. 页面上触发的url加载行为.

我们先来模拟一个两次重定向的场景, 通常情况下, 直接访问 http://www.baidu.com 将发生一次重定向. 在此之前用一个短链接重定向到 http://www.baidu.com 这样便多了一次重定向. 下面将基于这个场景进行两次测试.

那么第一种方式, 将依次触发webview的以下事件回调:

webview主动loadUrl

整理如下:

  1. onPageStarted
  2. onPageStarted
  3. shouldOverrideUrlLoading
  4. onPageFinished
  5. onPageStarted
  6. onPageStarted
  7. shouldOverrideUrlLoading
  8. onPageFinished
  9. onPageStarted
  10. onPageFinished

第二种方式, 将依次触发webview的以下事件回调:

页面上触发的url加载行为

整理如下:

  1. shouldOverrideUrlLoading
  2. onPageStarted
  3. onPageStarted
  4. shouldOverrideUrlLoading
  5. onPageFinished
  6. onPageStarted
  7. onPageStarted
  8. shouldOverrideUrlLoading
  9. onPageFinished
  10. onPageStarted
  11. onPageFinished

可见, 除了最后一次onPageFinished事件, 其他的onPageFinished事件都紧跟shouldOverrideUrlLoading事件之后触发.

基于上述现象, 可以设置全局状态位(flag), onPageStarted触发时设置为true, shouldOverrideUrlLoading触发时设置为false, onPageFinished触发时, 判断flag是否为true, 如果为true则意味着页面加载完成, 此时便可放心的记录页面url以及重写js原生返回方法.

基于hash跳转产生的历史项如何记录

上述方法真的可以记录webview所有的历史项吗?

其实还不能. 实际上, webview的网页上进行hash跳转时, onPageStarted 和 shouldOverrideUrlLoading 都不会触发. 所幸的是 onPageFinished 能够感知到hash值的变化. 我们可以在该方法内继续维护历史记录栈.

小结

至此, 我想, 基于native的这套返回方案应该是可行. 但有native的同学告知: 有些页面native无法记录页面url? 这是为什么呢? 至少到目前为止, 我还没有发现这样的场景. 欢迎阅读本文的你留下个脚印, 一起讨论和完善web app返回方案.


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文链接: http://louiszhai.github.io/2017/02/20/back/

参考文章

]]>
web返回 HybirdApp返回 H5返回 app返回
box-shadow属性探秘 http://louiszhai.github.io/2017/02/19/box-shadow/ 2017-02-19T08:23:34.000Z 2019-12-02T03:28:32.875Z 导读

我总是记不住css3的box-shadow属性拥有几个值, 它们的顺序究竟如何? 对我来说, 这是一个大难题. 我们都知道, 使用一个属性, 总是可以不停地在开发者工具上测试UI表现, 直到表现令人满意. 好吧, 有些时候它是奏效的; 而其它时候呢, 我们会消耗时间, 积攒疲劳值, 以及成就感下降等. 一旦短时记忆失效, 我们完全有可能重复一遍之前不愉快地尝试. 因此我选择多花些时间, 用力一次记住它. 如果你恰好也有同样的疑惑, 请读下去.

box-shadow

box-shadow用于创建阴影, 使得元素更有立体感, 它的值由以下六个部分组成:

box-shadow: offsetX offsetY blur spread color position;

它们分别为: x轴偏移 y轴偏移 模糊半径 大小 颜色 位置.

xy轴偏移, 参照css中的坐标系, 水平向右(→)为X轴正方向, 垂直向下(↓)为Y轴正方向.

offsetX

即水平偏移, 值取正数时, 阴影位于元素右边, 值取负数时, 阴影位于元素左边.

为了便于观察到效果, 我将模糊半径默认设置成10px.

box-shadow: 20px 0 10px 0 lightblue; /*阴影向右偏移20px*/

offsetX为正值

box-shadow: -20px 0 10px 0 lightblue; /*阴影向左偏移20px*/

offsetX为负值

offsetY

即垂直偏移, 值取正数时, 阴影位于元素下方, 值取负数时, 阴影位于元素上方.

box-shadow: 0 10px 10px 0 lightblue; /*阴影向下偏移10px*/

offsetY为正值

box-shadow: 0 -10px 10px 0 lightblue; /*阴影向上偏移10px*/

offsetY为负值

blur

设置阴影的模糊半径, 值越大时, 阴影就越模糊, 值为0时则完全不模糊, 值小于0时则按照0处理.

我试着加大模糊半径, 取值为50px, 可以看到阴影变得非常模糊.

box-shadow: 20px 0 50px 0 lightblue; /*阴影向右偏移20px, 模糊半径由10px放大至50px*/

offsetX+blur

同时, 上下边界也有模糊阴影, 理论上讲, 模糊半径在上下左右各个方向应该都有效果, 下面我们来验证之:

box-shadow: 0 0 50px 0 lightblue; /*模糊半径设置为50px, 无偏移*/

blur

如上图, 同猜想一致, 模糊阴影在上下左右4个方向分别发散. 此时, 对角线方向上阴影是最淡的, 要想模糊阴影均匀分布在元素周围, 只需将元素设置为圆形即可.

box-shadow: 0 0 50px 0 lightblue; /*模糊半径设置为50px, 无偏移*/
border-radius: 100%; /*元素设置为圆形*/

blur-circle

spread

设置阴影大小. 当blur值为0时, spread就像是元素背后的一块幕布, spread值越大, 阴影越宽, 当其取负值时, 阴影大小为元素高宽分别减去spread值, 此时, blur设置的模糊阴影则会向内靠拢.

box-shadow: 0 0 0 10px lightblue; /*阴影大小设置为+10px*/

spread

box-shadow: 0 0 10px 10px lightblue; /*模糊半径设置为10px, 阴影大下为+10px*/

spread

box-shadow: 0 0 10px -1px lightblue; /*模糊半径设置为10px, 阴影大下为-1px, 由于模糊阴影部分向内靠拢, 阴影变得非常薄*/

spread+blur

不知道你有没有注意到 ,对于阴影大小, 我使用的是+10px 或者 -1px这样的单位, 这是为什么呢? 这里卖个关子先, 请看如下效果.

box-shadow: 150px 0 0 0px lightblue;

offsetX+spread

仅仅将阴影水平向右移动一段距离, 可见, 阴影是有默认大小的, 并且默认与元素是一般大小. 而这几乎打破了我一度的认知, 说好的阴影呢, 不是环绕吗!

前面提到了圆形阴影, 实际上, 就是border-radius:100%的特例, 那么如果border-radius是一个具体的值呢, 此时阴影又该当如何呈现? 请耐心往下看, 我将在多重阴影的节点给出分析.

color

设置阴影的颜色. 支持常用色值, HEX(16进制), RGB, RGBA, HSL, HSLA等颜色单位. 以下颜色全部都是浅蓝色.

box-shadow: 0 0 10px 0 lightblue;
box-shadow: 0 0 10px 0 #add8e6;
box-shadow: 0 0 10px 0 rgba(173, 216, 230, 1);
box-shadow: 0 0 10px 0 hsla(195, 53%, 79%, 1);

position

设置阴影的位置, 默认为外部阴影, 可通过inset值来设置内部阴影.

box-shadow: 0 0 20px 10px lightblue; /*默认为外部阴影*/
box-shadow: 0 0 20px 10px lightblue inset; /*设置为inset时, 为内部阴影*/

inset

多重阴影

box-shadow同background属性一样, 它们都支持多重效果的设置, 且多重值以逗号分隔.

box-shadow: 0 0 20px 10px lightblue,
            0 0 20px 10px lightblue inset; /*同时设置内外阴影*/

内外阴影

box-shadow: 10px 10px 0px 10px #d0268c,
            -10px -10px 0px 10px rgba(95, 167, 44, 0.56),
            0px 0px 0px 20px lightgrey; /*多重阴影效果*/

多重阴影

上面留下了一个问题, 答案就在下面的样式中.

border-radius: 10px;
box-shadow: 110px 0 0 -10px #ccc, 220px 0 0 0 #808080, 360px 0 0 10px grey;

直接上效果.

border-radius影响spread的效果

从第一个阴影开始(上图左二), 随着阴影spread值的变化, 阴影经历了边框直角, 边框圆角, 边框更圆角(词穷)的过程.

这两个属性的关系如下:

  • spread值越大, border-radius的值就越大, 当spread<0时, border-radius会变小, 最小为0.

有趣的效果

纸张投影效果

先来看以下纸张投影效果是个什么样.

paper-shadow

不就是在纸张底部加个投影吗, 是的, 你没看错. 这样的投影, 实现起来灰常简单, 只需要元素底部左右各加一个box-shadow, 然后佐以transform变换, 稍微改变个角度就大功告成了. 下面我们来用三步实现它.

准备元素
<style>
  .drop-shadow {
    position: relative;
    width: 300px;
    height: 200px;
    background: #CCC;
  }
  .vertical-line{
    position: relative;
    height: 96%;
    width: 0;
    top: 2%;
    margin: 0 auto;
    border: 1px dashed #808080;
  }
</style>
<div class="shadow">
  <div class="vertical-line"></div>
</div>

阶段性效果如下:

paper-shadow-part01

添加阴影样式

先在元素底部左右两边各生成一个阴影, 阴影应该是垂直向下的, 和模糊的, 那么属性如下设置.

.shadow::before, .shadow::after {
    content: "";
    position: absolute;
    bottom: 0;
    left: 0;
    width: 50%;
    height: 10%;
    box-shadow: 0 15px 20px rgba(125, 125, 125, 0.9);
}
.drop-shadow::after{
  right: 0;
  left: auto;
}

阶段性效果如下:

paper-shadow-part02

这个时候, 阴影基本上呈现了, 但有两点不太完美:

  • 左右阴影应该是倾斜的;
  • 底部阴影有些太厚, 且边缘部分应该淡化.

倾斜可使用 transform: rotate(5deg) 实现. 阴影太厚或边缘淡化只需将阴影往内收一些就行, 如 bottom: 20px; left:10px 等.

给阴影加个角度并收缩一些
.shadow::before, .shadow::after {
    content: "";
    position: absolute;
    bottom: 20px;
    left: 10px;
    width: 50%;
    height: 10%;
    box-shadow: 0 20px 30px rgba(125, 125, 125, 0.9);
    transform: rotate(-5deg);
}
.drop-shadow::after{
  right: 10px;
  left: auto;
  transform: rotate(5deg);
}

阶段性效果如下:

paper-shadow-part03

可以看到, 阴影出现在元素之上. 可设置z-index 为-1, 将阴影层级降低一些, 这样就实现了上述所说的纸张阴影效果.

paper-shadow

升起效果

同样, 我们先来看下升起效果长什么样. 如下:

paper-shadow-part02

这是一个简单的动画, 鼠标移入, 元素上移, 同时阴影缩小, 鼠标移出则反之. 这里, 我们分两步来实现它.

准备元素和阴影
<style>
  .rose {
    position: relative;
    width: 80px;
    height: 120px;
    background: rgba(0,0,0,0.2);
    transition: transform 1s;
  }
  .rose::after {
    content: "";
    position: absolute;
    bottom: -30px;
    left: 50%;
    height: 8px;
    width: 100%;
    border-radius: 50%;
    background-color: rgba(0,0,0,0.2);
    transform: translate(-50%, 0);
    transition: transform 1s;
    box-shadow: 0px 0px 15px 0px rgba(0,0,0,0.2);
  }
</style>
<div class="rose"></div>
添加动画
.rose:hover {
    transform: translateY(-40px);
    transition: transform 1s;
}
.rose:hover::after {
  transform: translate(-50%, 40px) scale(0.75);
  transition: transform 1s;
}

box-decoration-break

欲了解这个属性, 我们先读一则规范.

2016年1月14日, W3C发布了CSS片段模块(CSS Fragmentation Module Level 3)的候选推荐标准, 明确定义了在盒间、盒内、行间、页间进行断行的属性和规则.

简言之, 对于一个盒子模型, 如果它被分裂成多个长度不等的小碎片, 那么它将遵循以下规则来调整布局:

  • 布局将在每个碎片中生效, 并在断点处衔接起来. 但是重新计算大小和位置.
  • 后续碎片必须遵循定位规则, 并且不能高于前面碎片的边缘. 如果想延续碎片开始时的边缘, 需指定 box-decoration-breakclone, 那么padding 和 border 将包裹后续碎片的边缘.

也就是说, 当行内元素换行后, border, background将会出现截断现象, box-shadow也会如此. 如下:

box-decoration-break:slice

实际上, 这种效果并非不可改变, 设置 box-decoration-break:clone , 以上各个css效果将每行重新渲染, 彼此相互独立.

box-decoration-break:clone

box-decoration-break属性用于描述盒子碎片(如跨行的inline元素的各个部分)如何渲染上述border, background, box-shadow等css效果. 该属性拥有两个值, slice 和 clone, 默认值为slice, 如上图一效果. 以下是官方原文.

For box-decoration-break: slice, backgrounds (and border-image) are drawn as if applied to a composite box consisting of all of the box’s fragments reassembled in visual order. This theoretical assembly occurs after the element has been laid out (including any justification, bidi reordering, page breaks, etc.). To assemble the composite box…

从box-decoration-break属性的支持性来看, 目前firefox遥遥领先, 它从v32版本开始就已经全部支持. 以下浏览器均需要 -webkit-前缀, 并且不支持跨列和跨页的截断效果, 其他浏览器目前还不支持.

Chrome Safari Opera ios Safari Opera mini Android Chrome Android QQ
22+ 6.1+ 15+ 7.1+ all 4.4+ 55+ 1.2+

注: 不支持box-decoration-break的浏览器默认按照box-decoration-break:slice 效果来渲染盒子碎片.


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文链接: http://louiszhai.github.io/2017/02/12/box-shadow/

参考文章

]]>
box-shadow 阴影 多重阴影 纸张投影 升起效果 box-decoration-break
弹性盒模型Flex指南 http://louiszhai.github.io/2017/01/13/flex/ 2017-01-13T00:42:01.000Z 2020-06-04T06:41:16.511Z 导读

Web layout 是Web UI中的基础架构, 重要性不言而喻. 传统的盒模型, 借助display, position, float 属性应对普通布局游刃有余, 但针对复杂的或自适应布局, 常常捉襟见肘. 比如垂直居中, 就是一个老大难的问题, 借助flex弹性盒模型, 两行代码就可以优雅的实现之. (该方法曾在 16种方法实现水平居中垂直居中 一文中提到). 当然, 本次我们不会只讨论垂直居中的问题, 我将努力尽可能的还原flex的应用场景.

Flex

Flex即弹性盒模型, 该布局方案由W3C于2009年提出. 此后, Flex方案便历经v2009, v2011, v2012, v2014, v2015, v2016等版本, 最近方案是2016年5月26日起草的 CSS Flexible Box Layout Module Level 1.

兼容性

首先, 我们来回顾下如今PC端的兼容性(以下为完全兼容版本).

IE Edge Firefox Chrome Safari Opera
- 12+ 28+ 21+ 6.1+ 12.1+

以上, IE10+仅支持2012版W3C的flex语法, 且存在较多已知的bug, 此时使用flex布局需谨慎.

Chrome浏览器v21~v28版本需要添加 “-webkit-“ 前缀.

Safari浏览器v6.1~v8版本需要添加 “-webkit-“ 前缀.

Opera浏览器v15~v16版本需要添加 “-webkit-“ 前缀.

因此, 看到一些sass编译后的css文件中带有 “-webkit-“ 前缀无需惊慌.

平时开发时最为担心的便是移动端兼容性, 请看:

IOS Safari Opera mini Android Android Chrome UC 微信
7.1+ 4.4+ 55 - 当前支持

微信当前版本已支持flex.

UC不对外提供webview内核, 除去一些H5app的应用, 各种分享页基本(常在微信下打开)基本不需要担心对其兼容性, 实在需要实现, UC还是支持老版本的弹性盒子的, 可以优雅降级. 可见, Android4.4以上基本可以安心使用flex.

Autoprefixer

强记各种浏览器的前缀是没有必要的, 因为autoprefixer该做的, 都帮我们做了. 因此建议尝试下以下三个插件之一.

优势

Flex布局使得子项目能够”弹性”的改变其高宽, 自由填充容器剩余空间, 以适应容器变大, 或者压缩子项目自身, 以适应容器变小; 同时还可以方便的调节子项目方向和顺序. flex常用于高宽需要自适应, 或子项目大小成比例, 或水平垂直对齐等场景.

概念铺垫

Flex弹性盒模型里, 有容器和项目之分. 设置display:flex的为容器, 容器内的元素称作它的子项目, 容器有容器的一套属性, 子项目有子项目的另一套属性. (可以这么理解: father作为弹性盒子, 制定行为规范, son享受盒子的便利, 按照规范划分各自的”辖区”).

以下图片摘自大漠的一个完整的Flexbox指南文中.

flexbox

father制定的规范, 基于两个方向 — 水平和垂直.

  • 水平方向的称之为主轴(main axis), 垂直方向的称之为交叉轴(cross axis).
  • 主轴起始位置, 叫做main start, 末尾位置叫做main end;
  • 交叉轴起始位置, 叫做cross start, 末尾位置叫做cross end.
  • 子项目在主轴上所占的宽(高)度, 叫做main size, 在交叉轴上所占的高(宽)度, 叫做cross size.

属性

display: flex | inline-flex;(元素将升级为弹性盒子). 前者容器升级为块级盒子, 后者容器将升级为行内盒子. 元素采用flex布局以后, 子元素的float, clear, vertical-align属性都将失效.

容器属性

容器具有以下6个属性.

  • flex-direction 指定主轴的方向.
flex-direction的值 描述
row(默认) 指定主轴水平, 子项目从左至右排列➜
row-reverse 指定主轴水平, 子项目从右至左排列⬅︎
column 指定主轴垂直, 子项目从上至下排列⬇︎
column-reverse 指定主轴垂直, 子项目从下至上排列⬆︎
  • flex-wrap 指定如何换行.
flex-wrap的值 描述
nowrap(默认) 默认不换行
wrap 正常换行
wrap-reverse 换行, 且前面的行在底部
  • flex-flow 它是flex-direction 和 flex-wrap的简写形式, 默认值为row nowrap.
  • justify-content 指定主轴上子项目的对齐方式.(通常为水平方向对齐方式)
justify-content的值 描述(子项目–主轴方向)
flex-start(默认) 子项目起始位置与main start位置对齐
flex-end 子项目末尾位置与main end位置对齐
center 在主轴方向居中于容器
space-between 与交叉轴两端对齐, 子项目之间的间隔全部相等
space-around 子项目两侧的距离相等, 它们之间的距离两倍于它们与主轴起始或末尾位置的距离.
  • align-items 指定交叉轴上子项目的对齐方式.(通常为垂直方向对齐方式)
align-items的值 描述(子项目—交叉轴方向)
flex-start 子项目起始位置与cross start位置对齐
flex-end 子项目末尾位置与cross end位置对齐
center 在交叉轴方向居中于容器
baseline 第一行文字的基线对齐
stretch(默认) 高度未定(或auto)时, 将占满容器的高度
  • align-content 指定多根主轴的对齐方式. 若只有一根主轴, 则无效.
align-content的值 描述(子项目)
flex-start 顶部与cross start位置对齐
flex-end 底部与cross end位置对齐
center 在交叉轴方向居中于容器
space-between 与交叉轴两端对齐, 间隔全部相等
space-around 子项目两侧的距离相等, 它们之间的距离两倍于它们与主轴起始或末尾位置的距离.
stretch(默认) 多根主轴上的子项目充满交叉轴

子项目属性

子项目具有以下6个属性.

  • flex-grow 指定子项目的放大比例, 默认为0(即不放大). 该属性可取值为任何正整数. 假设各个子项目的放大比例之和为n, 那么容器内剩余的空间将分配n份, 每个子项目各自分到x/n份. (x为该子项目的放大比例)

  • flex-shrink 指定子项目的缩小比例, 默认为1. 设置为0时, 空间不足该子项目将不缩小. 我们知道, 容器的缩小总宽度=子项目所需要的总宽度-容器实际宽度, 假设容器需要缩小的宽度为W, 某子项目的默认宽度为L, 其缩小比例为p, 那么该子项目实际的宽度为L-p*W.

    • 上面轻描淡写的给出了子项目的缩小比例, 可能会给你一种错觉— “缩小比例很容易计算”, 实际上, 我们在计算元素需要缩小比例时, 总是要考虑到元素自身默认的大小.

    • 假设上述子项目其flex-shrink值为x1, 另一个子项目的默认宽度为R, flex-shrink值为x2, 考虑到元素自身大小. 最终第一个子项目的缩小比例是加权了自身默认大小后的结果, 即rate = L*x1/(L*x1 + R*x2).

    • 为什么计算会如此复杂, 如此不直观??? 这是因为, 子项目的大小各不一致, 假如一个子项目是另一个子项目主轴宽度的9倍, 前者的flex-shrink值为1, 后者为9, 而容器实际上只有他们默认总宽度的一半. 这意味着, 这两个子项目共计要压缩为默认的一半. 如果仅仅按照flex-shrink值来决定比例, 那么第二个子项目需要压缩其默认的9/10, 而我们知道, 它默认是如此的小, 即使全部压缩了, 也无济于事; 而第一个元素仅需要压缩其默认的1/10, 简直就是九牛一毛, 根本达不到默认总宽度压缩一半的效果. 很明显, 这种压缩比例的分配方式是不合理的. 因此最终的压缩比例加入了默认宽度值(即flex-basis值), 表达式的分子为 flex-shrink * flex-basis, 分母为各子项目 flex-shrink * flex-basis 之和.

  • flex-basis 指定子项目分配的默认空间, 默认为auto. 即该子项目的原本大小.

  • flex 是 flex-grow, flex-shrink, flex-basis 3个属性的缩写. 默认为0 1 auto. 该属性取值为auto时等同于设置为1 1 auto, 取值为none时等同于设置为0 0 auto.

  • align-self 指定单个子项目独立的对齐方式. 默认为auto, 表示继承父元素的align-items属性, 如无父元素, 则等同于stretch. 该属性共有6种值, 其他值与上述align-items属性保持一致.

  • order 指定子项目的顺序, 数值越小, 顺序越靠前, 默认为0.

flex属性的优先级

我们可以给input设置flex:1, 使其充满一行, 并且随着父元素大小变化而变化. 也可以给div设置flex:1使其充满剩余高度.

使用flex布局这些都不是难事, 需要注意的是, 这其中有坑. 为了避免踩坑, 我们先来看下flex属性的优先级:

width|height > 自适应文本内容的宽度或高度 > flex:数值

这意味着, 首先是元素宽高的值优先, 其次是内容的宽高, 再次是flex数值. 现在我们来看看坑是什么.

  1. 给input元素设置flex:1时需要注意, 通常input拥有一个默认宽度(用于展示默认数量的字符), 在chrome v55下, 这个宽度默认为126px(同时还包含2px的border). 因此想要实现input宽度自适应, 可以设置其width为0.
  2. 给div元素设置flex:1时, 因div的高度会受子级元素影响, 为了使得该div占满其父元素剩余的高度, 且不超出, 建议将该div的height属性设置为0. 此时PC端表现非常优秀,美中不足的是,对于移动端而言,div的子元素设置为height:100%并没有什么卵用,此时子元素高度依然为0。目前我能想到的比较好的解决方案就是:给div也设置display:flex;align-item:stretch,使得div本身也获得flex布局能力,同时div子元素高度充满div本身。

场景回顾

  1. 想要实现垂直居中的效果, 只需要设置父元素为display:flex;justify-content:center 即可. (当然, 父元素样式采用:display:table;, 子元素样式采用:display:table-cell;vertical-align:middle 也是可以实现的), 如下图.

  2. 想要实现左右两个元素等高(父元素高度由子元素撑开), 并且各占一半的宽度. 如上图.

    • 早期的实现方案, 需要借助负margin. 父元素样式设置为overflow:hidden, 子元素样式设置为margin-bottom:-10000px;padding-bottom:10000px;, 这样, 每个子元素便能借助padding撑开, 同时, 借助负margin和overflow合理裁剪.
    • 第二种方案就是借助IE8都支持的display:table属性, 父元素样式设置为display:table , 子元素设置为display:table-cell. 利用表格的行高一致性, 轻松实现行高一致.
    • 最终, 我们发现, 还是flex弹性盒模型来得方便快捷, 它只需要父级元素样式设置为display:flex.
  3. iphone低版本下,flex与inline-block有兼容性问题,这将导致inline-block的元素脱离flex布局(就好像其父元素没有设置为flex布局一样)。目前没有什么比较好的解决方案,建议在flex布局下慎用inline-block元素或者将其指定为block元素。

有关flex的旧语法, 请戳这篇回顾 Flex布局新旧混合写法详解(兼容微信) .

有关移动端的最佳实践, 请戳这篇围观 移动端全兼容的flexbox速成班 .

当然, 这里还有一个 Flexbugs 列表, github上已有7k+的star, 感兴趣可以前去看看.


本文作者: louis

本文链接: http://louiszhai.github.io/2017/01/13/flex/

参考文章

]]>
弹性盒模型 flex flex-grow flex-shrink flex-basis align-items align-content order flex-flow flex-direction flex-wrap align-self
JS中可能用得到的全部的排序算法 http://louiszhai.github.io/2016/12/23/sort/ 2016-12-23T03:55:14.000Z 2019-12-02T03:28:32.896Z 导读

排序算法可以称得上是我的盲点, 曾几何时当我知道Chrome的Array.prototype.sort使用了快速排序时, 我的内心是奔溃的(啥是快排, 我只知道冒泡啊?!), 要知道学习一门技术最好的时间是三年前, 但愿我现在补习还来得及(捂脸).

因此本篇重拾了出镜概率比较高的十来种排序算法, 逐一分析其排序思想, 并批注注意事项. 欢迎对算法提出改进和讨论.

冒泡排序

冒泡

冒泡排序需要两个嵌套的循环. 其中, 外层循环移动游标; 内层循环遍历游标及之后(或之前)的元素, 通过两两交换的方式, 每次只确保该内循环结束位置排序正确, 然后内层循环周期结束, 交由外层循环往后(或前)移动游标, 随即开始下一轮内层循环, 以此类推, 直至循环结束.

Tips: 由于冒泡排序只在相邻元素大小不符合要求时才调换他们的位置, 它并不改变相同元素之间的相对顺序, 因此它是稳定的排序算法.

由于有两层循环, 因此可以有四种实现方式.

方案 外层循环 内层循环
1 正序 正序
2 正序 逆序
3 逆序 正序
4 逆序 逆序

四种不同循环方向, 实现方式略有差异.

如下是动图效果(对应于方案1: 内/外层循环均是正序遍历.

冒泡排序

如下是上图的算法实现(对应方案一: 内/外层循环均是正序遍历).

//先将交换元素部分抽象出来
function swap(i,j,array){
  var temp = array[j];
  array[j] = array[i];
  array[i] = temp;
}
function bubbleSort(array) {
  var length = array.length, isSwap;
  for (var i = 0; i < length; i++) {            //正序
    isSwap = false;
    for (var j = 0; j < length - 1 - i; j++) {     //正序
      array[j] > array[j+1] && (isSwap = true) && swap(j,j+1,array);
    }
    if(!isSwap)
      break;
  }
  return array;
}

以上, 排序的特点就是: 靠后的元素位置先确定.

方案二: 外循环正序遍历, 内循环逆序遍历, 代码如下:

function bubbleSort(array) {
  var length = array.length, isSwap;
  for (var i = 0; i < length; i++) {            //正序
    isSwap = false;
    for (var j = length - 1; j >= i+1; j--) {     //逆序
      array[j] < array[j-1] && (isSwap = true) && swap(j,j-1,array);
    }
    if(!isSwap)
      break;
  }
  return array;
}

以上, 靠前的元素位置先确定.

方案三: 外循环逆序遍历, 内循环正序遍历, 代码如下:

function bubbleSort(array) {
  var length = array.length, isSwap;
  for (var i = length - 1; i >= 0; i--) {     //逆序
    isSwap = false;
    for (var j = 0; j < i; j++) {            //正序
      array[j] > array[j+1] && (isSwap = true) && swap(j,j+1,array);
    }
    if(!isSwap)
      break;
  }
  return array;
}

以上, 由于内循环是正序遍历, 因此靠后的元素位置先确定.

方案四: 外循环逆序遍历, 内循环逆序遍历, 代码如下:

function bubbleSort(array) {
  var length = array.length, isSwap;
  for (var i = length - 1; i >= 0; i--) {                //逆序
    isSwap = false;
    for (var j = length - 1; j >= length - 1 - i; j--) { //逆序
      array[j] < array[j-1] && (isSwap = true) && swap(j,j-1,array);
    }
    if(!isSwap)
      break;
  }
  return array;
}

以上, 由于内循环是逆序遍历, 因此靠前的元素位置先确定.

以下是其算法复杂度:

平均时间复杂度 最好情况 最坏情况 空间复杂度
O(n²) O(n) O(n²) O(1)

冒泡排序是最容易实现的排序, 最坏的情况是每次都需要交换, 共需遍历并交换将近n²/2次, 时间复杂度为O(n²). 最佳的情况是内循环遍历一次后发现排序是对的, 因此退出循环, 时间复杂度为O(n). 平均来讲, 时间复杂度为O(n²). 由于冒泡排序中只有缓存的temp变量需要内存空间, 因此空间复杂度为常量O(1).

双向冒泡排序

双向冒泡排序是冒泡排序的一个简易升级版, 又称鸡尾酒排序. 冒泡排序是从低到高(或者从高到低)单向排序, 双向冒泡排序顾名思义就是从两个方向分别排序(通常, 先从低到高, 然后从高到低). 因此它比冒泡排序性能稍好一些.

如下是算法实现:

function bothwayBubbleSort(array){
  var tail = array.length-1, i, isSwap = false;
  for(i = 0; i < tail; tail--){
    for(var j = tail; j > i; j--){    //第一轮, 先将最小的数据冒泡到前面
      array[j-1] > array[j] && (isSwap = true) && swap(j,j-1,array);
    }
    i++;
    for(j = i; j < tail; j++){        //第二轮, 将最大的数据冒泡到后面
      array[j] > array[j+1] && (isSwap = true) && swap(j,j+1,array);
    }
  }
  return array;
}

选择排序

从算法逻辑上看, 选择排序是一种简单且直观的排序算法. 它也是两层循环. 内层循环就像工人一样, 它是真正做事情的, 内层循环每执行一遍, 将选出本次待排序的元素中最小(或最大)的一个, 存放在数组的起始位置. 而 外层循环则像老板一样, 它告诉内层循环你需要不停的工作, 直到工作完成(也就是全部的元素排序完成).

Tips: 选择排序每次交换的元素都有可能不是相邻的, 因此它有可能打破原来值为相同的元素之间的顺序. 比如数组[2,2,1,3], 正向排序时, 第一个数字2将与数字1交换, 那么两个数字2之间的顺序将和原来的顺序不一致, 虽然它们的值相同, 但它们相对的顺序却发生了变化. 我们将这种现象称作 不稳定性 .

如下是动图效果:

选择排序

如下是上图的算法实现:

function selectSort(array) {
  var length = array.length, min;
  for (var i = 0; i < length - 1; i++) {
    min = i;
    for (var j = i + 1; j < length; j++) {
      array[j] < array[min] && (min = j); //记住最小数的下标
    }
    min!=i && swap(i,min,array);
  }
  return array;
}

以下是其算法复杂度:

平均时间复杂度 最好情况 最坏情况 空间复杂度
O(n²) O(n²) O(n²) O(1)

选择排序的简单和直观名副其实, 这也造就了它”出了名的慢性子”, 无论是哪种情况, 哪怕原数组已排序完成, 它也将花费将近n²/2次遍历来确认一遍. 即便是这样, 它的排序结果也还是不稳定的. 唯一值得高兴的是, 它并不耗费额外的内存空间.

插入排序

插入排序的设计初衷是往有序的数组中快速插入一个新的元素. 它的算法思想是: 把要排序的数组分为了两个部分, 一部分是数组的全部元素(除去待插入的元素), 另一部分是待插入的元素; 先将第一部分排序完成, 然后再插入这个元素. 其中第一部分的排序也是通过再次拆分为两部分来进行的.

插入排序由于操作不尽相同, 可分为 直接插入排序 , 折半插入排序(又称二分插入排序), 链表插入排序 , 希尔排序 .

直接插入排序

它的基本思想是: 将待排序的元素按照大小顺序, 依次插入到一个已经排好序的数组之中, 直到所有的元素都插入进去.

如下是动图效果:

直接插入排序

如下是上图的算法实现:

function directInsertionSort(array) {
  var length = array.length, index, current;
  for (var i = 1; i < length; i++) {
    index = i - 1;         //待比较元素的下标
    current = array[i];     //当前元素
    while(index >= 0 && array[index] > current) { //前置条件之一:待比较元素比当前元素大
      array[index+1] = array[index];    //将待比较元素后移一位
      index--;                           //游标前移一位
      //console.log(array);
    }
    if(index+1 != i){                   //避免同一个元素赋值给自身
      array[index+1] = current;            //将当前元素插入预留空位
      //console.log(array);
    }        
  }
  return array;
}

为了更好的观察到直接插入排序的实现过程, 我们不妨将上述代码中的注释部分加入. 以数组 [5,4,3,2,1] 为例, 如下便是原数组的演化过程.

可见, 数组的各个元素, 从后往前, 只要比前面的元素小, 都依次插入到了合理的位置.

Tips: 由于直接插入排序每次只移动一个元素的位置, 并不会改变值相同的元素之间的排序, 因此它是一种稳定排序.

折半插入排序

折半插入排序是直接插入排序的升级版. 鉴于插入排序第一部分为已排好序的数组, 我们不必按顺序依次寻找插入点, 只需比较它们的中间值与待插入元素的大小即可.

Tips: 同直接插入排序类似, 折半插入排序每次交换的是相邻的且值为不同的元素, 它并不会改变值相同的元素之间的顺序. 因此它是稳定的.

算法基本思想是:

  1. 取0 ~ i-1的中间点( m = (i-1)>>1 ), array[i] 与 array[m] 进行比较, 若array[i] < array[m] , 则说明待插入的元素array[i] 应该处于数组的 0 ~ m 索引之间; 反之, 则说明它应该处于数组的 m ~ i-1 索引之间.
  2. 重复步骤1, 每次缩小一半的查找范围, 直至找到插入的位置.
  3. 将数组中插入位置之后的元素全部后移一位.
  4. 在指定位置插入第 i 个元素.

注: x>>1 是位运算中的右移运算, 表示右移一位, 等同于x除以2再取整, 即 x>>1 == Math.floor(x/2) .

如下是算法实现:

function binaryInsertionSort(array){
  var current, i, j, low, high, m;
  for(i = 1; i < array.length; i++){
    low = 0;
    high = i - 1;
    current = array[i];

    while(low <= high){            //步骤1&2:折半查找
      m = (low + high)>>1;
      if(array[i] >= array[m]){//值相同时, 切换到高半区,保证稳定性
        low = m + 1;        //插入点在高半区
      }else{
        high = m - 1;        //插入点在低半区
      }
    }
    for(j = i; j > low; j--){     //步骤3:插入位置之后的元素全部后移一位
      array[j] = array[j-1];
    }
    array[low] = current;         //步骤4:插入该元素
  }
  return array;
}

为了便于对比, 同样以数组 [5,4,3,2,1] 举例🌰. 原数组的演化过程如下(与上述一样):

折半插入排序

虽然折半插入排序明显减少了查询的次数, 但是数组元素移动的次数却没有改变. 它们的时间复杂度都是O(n²).

希尔排序

希尔排序也称缩小增量排序, 它是直接插入排序的另外一个升级版, 实质就是分组插入排序. 希尔排序以其设计者希尔(Donald Shell)的名字命名, 并于1959年公布.

算法的基本思想:

  1. 将数组拆分为若干个子分组, 每个分组由相距一定”增量”的元素组成. 比方说将[0,1,2,3,4,5,6,7,8,9,10]的数组拆分为”增量”为5的分组, 那么子分组分别为 [0,5], [1,6], [2,7], [3,8], [4,9] 和 [5,10].
  2. 然后对每个子分组应用直接插入排序.
  3. 逐步减小”增量”, 重复步骤1,2.
  4. 直至”增量”为1, 这是最后一个排序, 此时的排序, 也就是对全数组进行直接插入排序.

如下是排序的示意图:

希尔排序示意图

可见, 希尔排序实际上就是不断的进行直接插入排序, 分组是为了先将局部元素有序化. 因为直接插入排序在元素基本有序的状态下, 效率非常高. 而希尔排序呢, 通过先分组后排序的方式, 制造了直接插入排序高效运行的场景. 因此希尔排序效率更高.

我们试着抽象出共同点, 便不难发现上述希尔排序的第四步就是一次直接插入排序, 而希尔排序原本就是从”增量”为n开始, 直至”增量”为1, 循环应用直接插入排序的一种封装. 因此直接插入排序就可以看做是步长为1的希尔排序. 为此我们先来封装下直接插入排序.

//形参增加步数gap(实际上就相当于gap替换了原来的数字1)
function directInsertionSort(array, gap) {
  gap = (gap == undefined) ? 1 : gap;       //默认从下标为1的元素开始遍历
  var length = array.length, index, current;
  for (var i = gap; i < length; i++) {
    index = i - gap;    //待比较元素的下标
    current = array[i];    //当前元素
    while(index >= 0 && array[index] > current) { //前置条件之一:待比较元素比当前元素大
      array[index + gap] = array[index];    //将待比较元素后移gap位
      index -= gap;                           //游标前移gap位
    }
    if(index + gap != i){                   //避免同一个元素赋值给自身
      array[index + gap] = current;            //将当前元素插入预留空位
    }
  }
  return array;
}

那么希尔排序的算法实现如下:

function shellSort(array){
  var length = array.length, gap = length>>1, current, i, j;
  while(gap > 0){
    directInsertionSort(array, gap); //按指定步长进行直接插入排序
    gap = gap>>1;
  }
  return array;
}

同样以数组[5,4,3,2,1] 举例🌰. 原数组的演化过程如下:

希尔排序

对比上述直接插入排序和折半插入排序, 数组元素的移动次数由14次减少为7次. 通过拆分原数组为粒度更小的子数组, 希尔排序进一步提高了排序的效率.

不仅如此, 以上步长设置为了 {N/2, (N/2)/2, …, 1}. 该序列即希尔增量, 其它的增量序列 还有Hibbard:{1, 3, …, 2^k-1}. 通过合理调节步长, 还能进一步提升排序效率. 实际上已知的最好步长序列是由Sedgewick提出的(1, 5, 19, 41, 109,…). 该序列中的项或者是9*4^i - 9*2^i + 1或者是4^i - 3*2^i + 1. 具体请戳 希尔排序-维基百科 .

Tips: 我们知道, 单次直接插入排序是稳定的, 它不会改变相同元素之间的相对顺序, 但在多次不同的插入排序过程中, 相同的元素可能在各自的插入排序中移动, 可能导致相同元素相对顺序发生变化. 因此, 希尔排序并不稳定.

归并排序

归并排序建立在归并操作之上, 它采取分而治之的思想, 将数组拆分为两个子数组, 分别排序, 最后才将两个子数组合并; 拆分的两个子数组, 再继续递归拆分为更小的子数组, 进而分别排序, 直到数组长度为1, 直接返回该数组为止.

Tips: 归并排序严格按照从左往右(或从右往左)的顺序去合并子数组, 它并不会改变相同元素之间的相对顺序, 因此它也是一种稳定的排序算法.

如下是动图效果:

归并排序

归并排序可通过两种方式实现:

  1. 自上而下的递归
  2. 自下而上的迭代

如下是算法实现(方式1:递归):

function mergeSort(array) {  //采用自上而下的递归方法
  var length = array.length;
  if(length < 2) {
    return array;
  }
  var m = (length >> 1),
      left = array.slice(0, m),
      right = array.slice(m); //拆分为两个子数组
  return merge(mergeSort(left), mergeSort(right));//子数组继续递归拆分,然后再合并
}
function merge(left, right){ //合并两个子数组
  var result = [];
  while (left.length && right.length) {
    var item = left[0] <= right[0] ? left.shift() : right.shift();//注意:判断的条件是小于或等于,如果只是小于,那么排序将不稳定.
    result.push(item);
  }
  return result.concat(left.length ? left : right);
}

由上, 长度为n的数组, 最终会调用mergeSort函数2n-1次. 通过自上而下的递归实现的归并排序, 将存在堆栈溢出的风险. 亲测各浏览器的堆栈溢出所需的递归调用次数大致为:

  • Chrome v55: 15670
  • Firefox v50: 44488
  • Safari v9.1.2: 50755

以下是测试代码:

function computeMaxCallStackSize() {
  try {
    return 1 + computeMaxCallStackSize();
  } catch (e) {
    // Call stack overflow
    return 1;
  }
}
var time = computeMaxCallStackSize();
console.log(time);

为此, ES6规范中提出了尾调优化的思想: 如果一个函数的最后一步也是一个函数调用, 那么该函数所需要的栈空间将被释放, 它将直接进入到下次调用中, 最终调用栈里只保留最后一次的调用记录.

虽然ES6规范如此诱人, 然而目前并没有浏览器支持尾调优化, 相信在不久的将来, 尾调优化就会得到主流浏览器的支持.

以下是其算法复杂度:

平均时间复杂度 最好情况 最坏情况 空间复杂度
O(nlog₂n) O(nlog₂n) O(nlog₂n) O(n)

从效率上看, 归并排序可算是排序算法中的”佼佼者”. 假设数组长度为n, 那么拆分数组共需logn步, 又每步都是一个普通的合并子数组的过程, 时间复杂度为O(n), 故其综合时间复杂度为O(nlogn). 另一方面, 归并排序多次递归过程中拆分的子数组需要保存在内存空间, 其空间复杂度为O(n).

快速排序

快速排序借用了分治的思想, 并且基于冒泡排序做了改进. 它由C. A. R. Hoare在1962年提出. 它将数组拆分为两个子数组, 其中一个子数组的所有元素都比另一个子数组的元素小, 然后对这两个子数组再重复进行上述操作, 直到数组不可拆分, 排序完成.

如下是动图效果:

快速排序

如下是算法实现:

function quickSort(array, left, right) {
  var partitionIndex,
      left = typeof left == 'number' ? left : 0,
      right = typeof right == 'number' ? right : array.length-1;
  if (left < right) {
    partitionIndex = partition(array, left, right);//切分的基准值
    quickSort(array, left, partitionIndex-1);
    quickSort(array, partitionIndex+1, right);
  }
  return array;
}
function partition(array, left ,right) {   //分区操作
  for (var i = left+1, j = left; i <= right; i++) {//j是较小值存储位置的游标
    array[i] < array[left] && swap(i, ++j, array);//以第一个元素为基准
  }
  swap(left, j, array);            //将第一个元素移至中间
  return j;
}

以下是其算法复杂度:

平均时间复杂度 最好情况 最坏情况 空间复杂度
O(nlog₂n) O(nlog₂n) O(n²) O(nlog₂n)

快速排序排序效率非常高. 虽然它运行最糟糕时将达到O(n²)的时间复杂度, 但通常, 平均来看, 它的时间复杂为O(nlogn), 比同样为O(nlogn)时间复杂度的归并排序还要快. 快速排序似乎更偏爱乱序的数列, 越是乱序的数列, 它相比其他排序而言, 相对效率更高. 之前在 捋一捋JS的数组 一文中就提到: Chrome的v8引擎为了高效排序, 在排序数据超过了10条时, 便会采用快速排序. 对于10条及以下的数据采用的便是插入排序.

Tips: 同选择排序相似, 快速排序每次交换的元素都有可能不是相邻的, 因此它有可能打破原来值为相同的元素之间的顺序. 因此, 快速排序并不稳定.

堆排序

1991年的计算机先驱奖获得者、斯坦福大学计算机科学系教授罗伯特·弗洛伊德(Robert W.Floyd) 和威廉姆斯(J.Williams) 在1964年共同发明了著名的堆排序算法(Heap Sort).

堆排序是利用堆这种数据结构所设计的一种排序算法. 它是选择排序的一种. 堆分为大根堆和小根堆. 大根堆要求每个子节点的值都不大于其父节点的值, 即array[childIndex] <= array[parentIndex], 最大的值一定在堆顶. 小根堆与之相反, 即每个子节点的值都不小于其父节点的值, 最小的值一定在堆顶. 因此我们可使用大根堆进行升序排序, 使用小根堆进行降序排序.

并非所有的序列都是堆, 对于序列k1, k2,…kn, 需要满足如下条件才行:

  • ki <= k(2i) 且 ki<=k(2i+1)(1≤i≤ n/2), 即为小根堆, 将<=换成>=, 那么则是大根堆. 我们可以将这里的堆看作完全二叉树, k(i) 相当于是二叉树的非叶子节点, k(2i) 则是左子节点, k(2i+1)是右子节点.

算法的基本思想(以大根堆为例):

  1. 先将初始序列K[1..n]建成一个大根堆, 此堆为初始的无序区.
  2. 再将关键字最大的记录K1 (即堆顶)和无序区的最后一个记录K[n]交换, 由此得到新的无序区K[1..n-1]和有序区K[n], 且满足K[1..n-1].keys≤K[n].key
  3. 交换K1 和 K[n] 后, 堆顶可能违反堆性质, 因此需将K[1..n-1]调整为堆. 然后重复步骤2, 直到无序区只有一个元素时停止.

如下是动图效果:

桶排序示意图

如下是算法实现:

function heapAdjust(array, i, length) {//堆调整
  var left = 2 * i + 1,
      right = 2 * i + 2,
      largest = i;
  if (left < length && array[largest] < array[left]) {
    largest = left;
  }
  if (right < length && array[largest] < array[right]) {
    largest = right;
  }
  if (largest != i) {
    swap(i, largest, array);
    heapAdjust(array, largest, length);
  }
}
function heapSort(array) {
  //建立大顶堆
  length = array.length;
  for (var i = length>>1; i >= 0; i--) {
    heapAdjust(array, i, length);
  }
  //调换第一个与最后一个元素,重新调整为大顶堆
  for (var i = length - 1; i > 0; i--) {
    swap(0, i, array);
    heapAdjust(array, 0, --length);
  }
  return array;
}

以上, ①建立堆的过程, 从length/2 一直处理到0, 时间复杂度为O(n);

②调整堆的过程是沿着堆的父子节点进行调整, 执行次数为堆的深度, 时间复杂度为O(lgn);

③堆排序的过程由n次第②步完成, 时间复杂度为O(nlgn).

Tips: 由于堆排序中初始化堆的过程比较次数较多, 因此它不太适用于小序列. 同时由于多次任意下标相互交换位置, 相同元素之间原本相对的顺序被破坏了, 因此, 它是不稳定的排序.

计数排序

计数排序几乎是唯一一个不基于比较的排序算法, 该算法于1954年由 Harold H. Seward 提出. 使用它处理一定范围内的整数排序时, 时间复杂度为O(n+k), 其中k是整数的范围, 它几乎比任何基于比较的排序算法都要快( 只有当O(k)>O(n*log(n))的时候其效率反而不如基于比较的排序, 如归并排序和堆排序).

使用计数排序需要满足如下条件:

  • 待排序的序列全部为整数
  • 排序需要额外的存储空间

算法的基本思想:

计数排序利用了一个特性, 对于数组的某个元素, 一旦知道了有多少个其它元素比它小(假设为m个), 那么就可以确定出该元素的正确位置(第m+1位)

  1. 获取待排序数组A的最大值, 最小值.
  2. 将最大值与最小值的差值+1作为长度新建计数数组B,并将相同元素的数量作为值存入计数数组.
  3. 对计数数组B累加计数, 存储不同值的初始下标.
  4. 从原数组A挨个取值, 赋值给一个新的数组C相应的下标, 最终返回数组C.

注意: 如果原数组A是包含若干个对象的数组,需要基于对象的某个属性进行排序,那么算法开始时,需要将原数组A处理为一个只包含对象属性值的简单数组simpleA, 接下来便基于simpleA进行计数、累加计数, 其它同上.

如下是动图效果:

计数排序

如下是算法实现:

function countSort(array, keyName){
  var length = array.length,
      output = new Array(length),
      max,
      min,
      simpleArray = keyName ? array.map(function(v){
        return v[keyName];
      }) : array; // 如果keyName是存在的,那么就创建一个只有keyValue的简单数组

  // 获取最大最小值
  max = min = simpleArray[0];
  simpleArray.forEach(function(v){
    v > max && (max = v);
    v < min && (min = v);
  });
  // 获取计数数组的长度
  var k = max - min + 1;
  // 新建并初始化计数数组
  var countArray = new Array(k);
  simpleArray.forEach(function(v){
    countArray[v - min]= (countArray[v - min] || 0) + 1;
  });
  // 累加计数,存储不同值的初始下标
  countArray.reduce(function(prev, current, i, arr){
    arr[i] = prev;
    return prev + current;
  }, 0);
  // 从原数组挨个取值(因取的是原数组的相应值,只能通过遍历原数组来实现)
  simpleArray.forEach(function(v, i){
    var j = countArray[v - min]++;
    output[j] = array[i];
  });
  return output;
}

以上实现不仅支持了数值序列的排序,还支持根据对象的某个属性值来排序。测试如下:

var a = [2, 1, 1, 3, 2, 1, 4, 2],
    b = [
      {id: 2, s:'a'}, 
      {id: 1, s: 'b'}, 
      {id: 1, s: 'c'}, 
      {id: 3, s: 'd'}, 
      {id: 2, s: 'e'}, 
      {id: 1, s: 'f'}, 
      {id: 4, s: 'g'}, 
      {id: 2, s: 'h'}
    ];
countSort(a); // [1, 1, 1, 2, 2, 2, 3, 4]
countSort(b, 'id'); // [{id:1,s:'b'},{id:1,s:'c'},{id:1,s:'f'},{id:2,s:'a'},{id:2,s:'e'},{id:2,s:'h'},{id:3,s:'d'},{id:4,s:'g'}]

Tips: 计数排序不改变相同元素之间原本相对的顺序, 因此它是稳定的排序算法.

桶排序

桶排序即所谓的箱排序, 它是将数组分配到有限数量的桶子里. 每个桶里再各自排序(因此有可能使用别的排序算法或以递归方式继续桶排序). 当每个桶里的元素个数趋于一致时, 桶排序只需花费O(n)的时间. 桶排序通过空间换时间的方式提高了效率, 因此它需要额外的存储空间(即桶的空间).

算法的基本思想:

桶排序的核心就在于怎么把元素平均分配到每个桶里, 合理的分配将大大提高排序的效率.

如下是算法实现:

function bucketSort(array, bucketSize) {
  if (array.length === 0) {
    return array;
  }

  var i = 1,
      min = array[0],
      max = min;
  while (i++ < array.length) {
    if (array[i] < min) {
      min = array[i];                //输入数据的最小值
    } else if (array[i] > max) {
      max = array[i];                //输入数据的最大值
    }
  }

  //桶的初始化
  bucketSize = bucketSize || 5; //设置桶的默认大小为5
  var bucketCount = ~~((max - min) / bucketSize) + 1, //桶的个数
      buckets = new Array(bucketCount); //创建桶
  for (i = 0; i < buckets.length; i++) {
    buckets[i] = []; //初始化桶
  }

  //将数据分配到各个桶中,这里直接按照数据值的分布来分配,一定范围内均匀分布的数据效率最为高效
  for (i = 0; i < array.length; i++) {
    buckets[~~((array[i] - min) / bucketSize)].push(array[i]);
  }

  array.length = 0;
  for (i = 0; i < buckets.length; i++) {
    quickSort(buckets[i]); //对每个桶进行排序,这里使用了快速排序
    for (var j = 0; j < buckets[i].length; j++) {
      array.push(buckets[i][j]); //将已排序的数据写回数组中
    }
  }
  return array;
}

Tips: 桶排序本身是稳定的排序, 因此它的稳定性与桶内排序的稳定性保持一致.

实际上, 桶也只是一个抽象的概念, 它的思想与归并排序,快速排序等类似, 都是通过将大量数据分配到N个不同的容器中, 分别排序, 最后再合并数据. 这种方式大大减少了排序时整体的遍历次数, 提高了算法效率.

基数排序

基数排序源于老式穿孔机, 排序器每次只能看到一个列. 它是基于元素值的每个位上的字符来排序的. 对于数字而言就是分别基于个位, 十位, 百位 或千位等等数字来排序. (不明白不要紧, 我也不懂, 请接着往下读)

按照优先从高位或低位来排序有两种实现方案:

  • MSD: 由高位为基底, 先按k1排序分组, 同一组中记录, 关键码k1相等, 再对各组按k2排序分成子组, 之后, 对后面的关键码继续这样的排序分组, 直到按最次位关键码kd对各子组排序后. 再将各组连接起来, 便得到一个有序序列. MSD方式适用于位数多的序列.
  • LSD: 由低位为基底, 先从kd开始排序,再对kd-1进行排序,依次重复,直到对k1排序后便得到一个有序序列. LSD方式适用于位数少的序列.

如下是LSD的动图效果:

基数排序)

如下是算法实现:

function radixSort(array, max) {
    var buckets = [],
        unit = 10,
        base = 1;
    for (var i = 0; i < max; i++, base *= 10, unit *= 10) {
        for(var j = 0; j < array.length; j++) {
            var index = ~~((array[j] % unit) / base);//依次过滤出个位,十位等等数字
            if(buckets[index] == null) {
                buckets[index] = []; //初始化桶
            }
            buckets[index].push(array[j]);//往不同桶里添加数据
        }
        var pos = 0,
            value;
        for(var j = 0, length = buckets.length; j < length; j++) {
            if(buckets[j] != null) {
                while ((value = buckets[j].shift()) != null) {
                      array[pos++] = value; //将不同桶里数据挨个捞出来,为下一轮高位排序做准备,由于靠近桶底的元素排名靠前,因此从桶底先捞
                }
            }
        }
    }
    return array;
}

以上算法, 如果用来比较时间, 先按日排序, 再按月排序, 最后按年排序, 仅需排序三次.

基数排序更适合用于对时间, 字符串等这些整体权值未知的数据进行排序.

Tips: 基数排序不改变相同元素之间的相对顺序, 因此它是稳定的排序算法.

小结

各种排序性能对比如下:

排序类型 平均情况 最好情况 最坏情况 辅助空间 稳定性
冒泡排序 O(n²) O(n) O(n²) O(1) 稳定
选择排序 O(n²) O(n²) O(n²) O(1) 不稳定
直接插入排序 O(n²) O(n) O(n²) O(1) 稳定
折半插入排序 O(n²) O(n) O(n²) O(1) 稳定
希尔排序 O(n^1.3) O(nlogn) O(n²) O(1) 不稳定
归并排序 O(nlog₂n) O(nlog₂n) O(nlog₂n) O(n) 稳定
快速排序 O(nlog₂n) O(nlog₂n) O(n²) O(nlog₂n) 不稳定
堆排序 O(nlog₂n) O(nlog₂n) O(nlog₂n) O(1) 不稳定
计数排序 O(n+k) O(n+k) O(n+k) O(k) 稳定
桶排序 O(n+k) O(n+k) O(n²) O(n+k) (不)稳定
基数排序 O(d(n+k)) O(d(n+k)) O(d(n+kd)) O(n+kd) 稳定

注: 桶排序的稳定性取决于桶内排序的稳定性, 因此其稳定性不确定. 基数排序中, k代表关键字的基数, d代表长度, n代表关键字的个数.

愿以此文怀念下我那远去的算法课程.

未完待续…

感谢 http://visualgo.net/ 提供图片支持. 特别感谢 不是小羊的肖恩 在简书上发布的 JS家的排序算法 提供的讲解.


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文链接: http://louiszhai.github.io/2016/12/23/sort/

参考文章

]]>
算法 冒泡排序 双向冒泡排序 选择排序 插入排序 直接插入排序 折半插入排序 希尔排序 归并排序 快速排序 堆排序 计数排序 桶排序 基数排序
聊一聊H5应用缓存-Manifest http://louiszhai.github.io/2016/11/25/manifest/ 2016-11-25T00:08:21.000Z 2019-12-02T03:28:32.889Z 导读

Manifest 是 H5提供的一种应用缓存机制, 基于它web应用可以实现离线访问(offline cache). 为此, 浏览器还提供了应用缓存的api–applicationCache. 虽然manifest的技术已被web标准废弃, 但这不影响我们尝试去了解它. 也正是因为manifest的应用缓存机制如此诱人, 饿了么 和 office 365邮箱等都还在使用着它!

描述

对manifest熟悉的同学可以跳过此节.

鉴于manifest应用缓存的技术, 我们可以做到:

  • 离线访问: 即使服务器挂了, 或者没有网络, 用户依然可以正常浏览网页内容.
  • 访问更快: 数据存在于本地, 省去了浏览器发起http请求的时间, 因此访问更快, 移动端效果更为明显.
  • 降低负载: 浏览器只在manifest文件改动时才去服务器下载需要缓存的资源, 大大降低了服务器负载.

manifest缓存的过程如下(来自网络):

manifest

支持性

主流浏览器都支持manifest应用缓存技术. 如下表格:

IE Edge Firefox Chrome Safari Opera ios Android
10+ 12+ 3.5+ 4+ 4+ 11.5+ 7.1+ 2.3+

H5标准中, Offline Web applications 部分有如下描述:

This feature is in the process of being removed from the Web platform. (This is a long process that takes many years.) Using any of the offline Web application features at this time is highly discouraged. Use service workers instead. [SW]

因此后续我将在其他文章中继续介绍 service workers, 本篇继续关注manifest.

如何开启应用缓存

manifest使用缓存清单进行管理, 缓存清单需要与html标签进行关联. 如下:

<html manifest="test.appcache">
  ...
</html>

在html标签中指定manifest文件, 便表示该网页使用manifest进行离线缓存. 该网页内需要缓存的文件列表需要在 test.appcache 文本文件中指定.

manifest缓存清单

就像写作文一样, manifest采用经典的三段式. 分别为: CACHE, NETWORKFALLBACK. 如下, 先看一个栗子🌰:

CACHE MANIFEST
# v1.0.0
content.css

NETWORK:
app.js

FALLBACK:
/other 404.html

其中第一行必须以 CACHE MANIFEST 开头, 后可跟若干字符注释, 注释从#号开始. 跟在 CACHE MANIFEST 行后的文件, 每行列出一个, 这些文件是需要缓存的文件. 因此 content.css 会被缓存, 不需要访问网络.

第二段内容以 NETWORK: 开始, 跟在该行后的文件表示需要访问网络. 如: app.js 将直接从网络上下载, 并不走manifest cache, 如果除了第一段中缓存的文件以外, 其他文件都从网络上获取, 那么此时可将 app.js 改为 * (通配符).

第三段内容以 FALLBACK: 开始, 跟在该行后的文件表示会有一个替代方案. 如: 当访问 /other 路径时, 如果访问失败, 那么将自动加载 404.html 作为替代.

manifest缓存状态

每个manifest缓存都有一个状态, 标示着缓存的情况. 一份缓存清单只有一个缓存状态, 即使它被多个页面引用. 以下是各个缓存状态:

  • UNCACHED(未缓存): 表明应用缓存对象还没有初始化完成.
  • IDLE(空闲): 应用缓存并未处于更新状态.
  • CHECKING(检查): 正在检查是否存在更新.
  • DOWNLOADING(下载): 清单更新后, 重新下载全部资源到临时缓存中.
  • UPDATEREADY(更新就绪): 新版本的缓存下载完成, 全部就绪, 随即触发事件 updateready.
  • OBSOLETE(废弃): 应用缓存已被废弃.

上述缓存状态常量依次取值0, 1, 2, 3, 4, 5.

applicationCache

applicationCache是操作应用缓存的瑞士军刀, 也是唯一的一把刀.

首先我们来获取该对象.

//webview下
var cache = window.applicationCache;
//shared worker中
var cache = self.applicationCache;

以下是其属性和方法介绍(大神请绕过):

  • status: 返回当前页面的应用缓存的状态, 通常开启应用缓存的页面可能返回1, 其他页面则返回0.

  • update(): 手动触发应用缓存的更新.

    (1) 若有更新, 则依次触发①检查事件(Checking event), ②下载事件(Downloading event), ③下载进度事件(Progress event), ④更新完成事件(UpdateReady event);

    (2) 若无更新, 则依次触发①检查事件(Checking event), ②无更新事件(NoUpdate event);

    (3) 在未开启应用缓存的页面调用将抛出Uncaught DOMException 错误.

    update() 方法通常在长时间不关闭的页面使用, 比如说邮箱应用, 用于定期检测可能的更新.

  • abort(): 取消应用缓存的更新. 可用于节省有限的网络带宽.

  • swapCache(): 如果存在一个更新版本的应用缓存, 那么它将切换过去, 否则将抛出 Uncaught DOMException 错误. 通常, 我们会在updateready事件触发之后手动调用swapCache()方法, swapCache的切换只对后续加载的缓存文件有效, 已经加载成功的资源并不会重新加载.

那么如何利用好上述api更新一个页面的应用缓存呢? 别急, Beginner’s Guide to Using the Application Cache 一文中提供了如下的样板方法:

// Check if a new cache is available on page load.
window.addEventListener('load', function(e) {
  window.applicationCache.addEventListener('updateready', function(e) {
    if (window.applicationCache.status == window.applicationCache.UPDATEREADY) {
      // Browser downloaded a new app cache.
      // Swap it in and reload the page to get the new hotness.
      window.applicationCache.swapCache();
      if (confirm('A new version of this site is available. Load it?')) {
        window.location.reload();
      }
    } else {
      // Manifest didn't changed. Nothing new to server.
    }
  }, false);
}, false);

manifest缓存独立性

  1. manifest的缓存和浏览器默认的缓存是两套机制, 相互独立, 并且不受浏览器缓存大小限制(Chrome下测试结果).
  2. 各个manifest文件的缓存相互独立, 各自在独立的区域进行缓存. 即使是缓存同一个文件, 也可能由于缓存的版本不一致, 而造成各个页面资源不一致.

manifest缓存规则

  1. 遵循全量缓存的规律. 即: manifest文件改动后, 将重新缓存一遍所有的文件(包括html本身和动态添加的需要缓存的文件,即使缓存列表中没有该html). 第一次缓存过程中如果出现缓存失败的文件, 那么, 第二访问, 又将重新缓存一遍所有的文件. 以此类推.
  2. manifest文件本身不能写进缓存清单, 否则连同html和资源在其缓存失效之前, 将永远不能获得更新.
  3. 即使manifest文件丢失, 原缓存依然有效. 如果不删除html节点的manifest属性, 引入该manifest的html, 将永远不能获得更新. 因此正确的清除缓存姿势为:删除manifest文件,并且删除html节点的manifest属性,从而彻底解除绑定(此后页面在第二次加载时就会获取最新内容).

webview的缓存现象

通常, webview的缓存有如下三种现象:

  1. 普通网页(无manifest文件), 不受manifest缓存影响, 缓存只走 http cache.
  2. 包含manifest文件的网页, 缓存文件只受manifest缓存影响(只有manifest文件改变时才会更新缓存资源), 缓存资源完全与 http cache 无关, 但是 NETWORK 段落后需要访问网络的文件, 将继续走 http cache.
  3. webview直接加载manifest缓存过的文件时, 优先加载第一个manifest缓存的该文件, 如果没有找到manifest缓存, 那么它将自动寻找 http cache 或者 在线加载.

最佳实践

  1. 通常只使用一个manifest文件, 并保证缓存的文件尽可能的少, 以减小manifest每次更新清单中文件所耗费的时间和流量.
  2. 如果一定要使用两个及以上manifest文件, 缓存文件请尽量不要相同.
  3. 如果以上两条都不能保证, 那么, 请保证尽可能在manifest缓存的状态更新时, 主动去刷新网页.(此时并不能保证不同网页之间同一个缓存文件版本一致)

具体落地步骤

  1. 如果缓存的文件需要加参数运行, 建议将参数内容加到hash中, 如:cached-page.html#parameterName=value

  2. manifest 的引入可以使用绝对路径或者相对路径, 如果你使用的是绝对路径, 那么你的manifest文件必须和你的站点处于同一个域名下.

  3. manifest文件你可以保存为任意的扩展名, 但是响应头中以下字段须取以下定值, 以保证manifest文件正确被解析, 并且它没有http缓存.

    Content-Type: text/cache-manifest
    Cache-Control: max-age=0
    Expires: [CURRENT TIME]
    

如何更新缓存

  1. 更新manifest文件后, webview将自动更新缓存.
  2. js更新缓存(手动触发manifest更新): window.applicationCache.update();

其他

chrome浏览器下通过访问 chrome://appcache-internals/ 可以查看缓存在本地的资源文件.

另外, 除了本文参考的一篇 MDN 的文章以及 HTML5 Rocks的 Beginner’s Guide to Using the Application Cache 一文, 还有如下三个链接可供您比较阅读, 谢谢.


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文链接: http://louiszhai.github.io/2016/11/25/manifest/

参考文章

]]>
manifest offline cache applicationCache
Ajax知识体系大梳理 http://louiszhai.github.io/2016/11/02/ajax/ 2016-11-02T04:48:27.000Z 2019-12-02T03:28:32.870Z 导读

Ajax 全称 Asynchronous JavaScript and XML, 即异步JS与XML. 它最早在IE5中被使用, 然后由Mozilla, Apple, Google推广开来. 典型的代表应用有 Outlook Web Access, 以及 GMail. 现代网页中几乎无ajax不欢. 前后端分离也正是建立在ajax异步通信的基础之上.

浏览器为ajax做了什么

现代浏览器中, 虽然几乎全部支持ajax, 但它们的技术方案却分为两种:

① 标准浏览器通过 XMLHttpRequest 对象实现了ajax的功能. 只需要通过一行语句便可创建一个用于发送ajax请求的对象.

var xhr = new XMLHttpRequest();

② IE浏览器通过 XMLHttpRequest 或者 ActiveXObject 对象同样实现了ajax的功能.

MSXML

鉴于IE系列各种 “神级” 表现, 我们先来看看IE浏览器风骚的走位.

IE下的使用环境略显复杂, IE7及更高版本浏览器可以直接使用BOM的 XMLHttpRequest 对象. MSDN传送门: Native XMLHTTPRequest object. IE6及更低版本浏览器只能使用 ActiveXObject 对象来创建 XMLHttpRequest 对象实例. 创建时需要指明一个类似”Microsoft.XMLHTTP”这样的ProgID. 而实际呢, windows系统环境下, 以下ProgID都应该可以创建XMLHTTP对象:

Microsoft.XMLHTTP
Microsoft.XMLHTTP.1.0
Msxml2.ServerXMLHTTP
Msxml2.ServerXMLHTTP.3.0
Msxml2.ServerXMLHTTP.4.0
Msxml2.ServerXMLHTTP.5.0
Msxml2.ServerXMLHTTP.6.0
Msxml2.XMLHTTP
Msxml2.XMLHTTP.3.0
Msxml2.XMLHTTP.4.0
Msxml2.XMLHTTP.5.0
Msxml2.XMLHTTP.6.0

简言之, Microsoft.XMLHTTP 已经非常老了, 主要用于提供对历史遗留版本的支持, 不建议使用.对于 MSXML4, 它已被 MSXML6 替代; 而 MSXML5 又是专门针对office办公场景, 在没有安装 Microsoft Office 2003 及更高版本办公软件的情况下, MSXML5 未必可用. 相比之下, MSXML6 具有比 MSXML3 更稳定, 更高性能, 更安全的优势, 同时它也提供了一些 MSXML3 中没有的功能, 比如说 XSD schema. 唯一遗憾的是, MSXML6 只在 vista 系统及以上才是默认支持的; 而 MSXML3 在 Win2k SP4及以上系统就是可用的. 因此一般情况下, MSXML3 可以作为 MSXML6 的优雅降级方案, 我们通过指定 PorgID 为 Msxml2.XMLHTTP 即可自动映射到 Msxml2.XMLHTTP.3.0. 如下所示:

var xhr = new ActiveXObject("Msxml2.XMLHTTP");// 即MSXML3,等同于如下语句
var xhr = new ActiveXObject("MSXML2.XMLHTTP.3.0");

MSDN有篇文章专门讲解了各个版本的MSXML. 传送门: Using the right version of MSXML in Internet Explorer.

亲测了 IE5, IE5.5, IE6, IE7, IE8, IE9, IE10, IE edge等浏览器, IE5及之后的浏览器均可以通过如下语句获取xhr对象:

var xhr = new ActiveXObject("Msxml2.XMLHTTP");// 即MSXML3
var xhr = new ActiveXObject("Microsoft.XMLHTTP");// 很老的api,虽然浏览器支持,功能可能不完善,故不建议使用

以上, 思路已经很清晰了, 下面给出个全兼容的方法.

全平台兼容的XMLHttpRequest对象

function getXHR(){
  var xhr = null;
  if(window.XMLHttpRequest) {
    xhr = new XMLHttpRequest();
  } else if (window.ActiveXObject) {
    try {
      xhr = new ActiveXObject("Msxml2.XMLHTTP");
    } catch (e) {
      try {
        xhr = new ActiveXObject("Microsoft.XMLHTTP");
      } catch (e) { 
        alert("您的浏览器暂不支持Ajax!");
      }
    }
  }
  return xhr;
}

ajax有没有破坏js单线程机制

对于这个问题, 我们先看下浏览器线程机制. 一般情况下, 浏览器有如下四种线程:

  • GUI渲染线程
  • javascript引擎线程
  • 浏览器事件触发线程
  • HTTP请求线程

那么这么多线程, 它们究竟是怎么同js引擎线程交互的呢?

通常, 它们的线程间交互以事件的方式发生, 通过事件回调的方式予以通知. 而事件回调, 又是以先进先出的方式添加到任务队列 的末尾 , 等到js引擎空闲时, 任务队列 中排队的任务将会依次被执行. 这些事件回调包括 setTimeout, setInterval, click, ajax异步请求等回调.

浏览器中, js引擎线程会循环从 任务队列 中读取事件并且执行, 这种运行机制称作 Event Loop (事件循环).

对于一个ajax请求, js引擎首先生成 XMLHttpRequest 实例对象, open过后再调用send方法. 至此, 所有的语句都是同步执行. 但从send方法内部开始, 浏览器为将要发生的网络请求创建了新的http请求线程, 这个线程独立于js引擎线程, 于是网络请求异步被发送出去了. 另一方面, js引擎并不会等待 ajax 发起的http请求收到结果, 而是直接顺序往下执行.

当ajax请求被服务器响应并且收到response后, 浏览器事件触发线程捕获到了ajax的回调事件 onreadystatechange (当然也可能触发onload, 或者 onerror等等) . 该回调事件并没有被立即执行, 而是被添加到 任务队列 的末尾. 直到js引擎空闲了, 任务队列 的任务才被捞出来, 按照添加顺序, 挨个执行, 当然也包括刚刚append到队列末尾的 onreadystatechange 事件.

onreadystatechange 事件内部, 有可能对dom进行操作. 此时浏览器便会挂起js引擎线程, 转而执行GUI渲染线程, 进行UI重绘(repaint)或者回流(reflow). 当js引擎重新执行时, GUI渲染线程又会被挂起, GUI更新将被保存起来, 等到js引擎空闲时立即被执行.

以上整个ajax请求过程中, 有涉及到浏览器的4种线程. 其中除了 GUI渲染线程js引擎线程 是互斥的. 其他线程相互之间, 都是可以并行执行的. 通过这样的一种方式, ajax并没有破坏js的单线程机制.

ajax与setTimeout排队问题

通常, ajax 和 setTimeout 的事件回调都被同等的对待, 按照顺序自动的被添加到 任务队列 的末尾, 等待js引擎空闲时执行. 但请注意, 并非xhr的所有回调执行都滞后于setTImeout的回调. 请看如下代码:

setTimeout(function(){
  console.log('setTimeout');
},0);
var resolve;
new Promise(function(r){
  resolve = r;
}).then(function(){
  console.log('promise nextTick');
});
resolve();

function ajax(url, method){
  var xhr = getXHR();
  xhr.onreadystatechange = function(){
      console.log('xhr.readyState:' + this.readyState);
  }
  xhr.onloadstart = function(){
      console.log('onloadStart');
  }
  xhr.onload = function(){
      console.log('onload');
  }
  xhr.open(method, url, true);
  xhr.setRequestHeader('Cache-Control',3600);
  xhr.send();
}
ajax('http://louiszhai.github.io/docImages/ajax01.png','GET');
console.warn('这里的log并不是最先打印出来的.');

上述代码执行结果如下图:

setTimeout & ajax & 同步

由于ajax异步, setTimeout及Promise本应该最先被执行, 然而实际上, 一次ajax请求, 并非所有的部分都是异步的, 至少”readyState==1”的 onreadystatechange 回调以及 onloadstart 回调就是同步执行的. 因此它们的输出排在最前面.

XMLHttpRequest 属性解读

首先在Chrome console下创建一个 XMLHttpRequest 实例对象xhr. 如下所示:

XMLHttpRequest

inherit

试运行以下代码.

var xhr = new XMLHttpRequest(),
    i=0;
for(var key in xhr){
    if(xhr.hasOwnProperty(key)){
       i++;
   }
}
console.log(i);//0
console.log(XMLHttpRequest.prototype.hasOwnProperty('timeout'));//true

可见, XMLHttpRequest 实例对象没有自有属性. 实际上, 它的所有属性均来自于 XMLHttpRequest.prototype .

追根溯源, XMLHttpRequest 实例对象具有如下的继承关系. (下面以a<<b表示a继承b)

xhr << XMLHttpRequest.prototype << XMLHttpRequestEventTarget.prototype << EventTarget.prototype << Object.prototype

由上, xhr也具有Object等原型中的所有方法. 如toString方法.

xhr.toString();//"[object XMLHttpRequest]"

通常, 一个xhr实例对象拥有10个普通属性+9个方法.

readyState

只读属性, readyState属性记录了ajax调用过程中所有可能的状态. 它的取值简单明了, 如下:

readyState 对应常量 描述
0 (未初始化) xhr.UNSENT 请求已建立, 但未初始化(此时未调用open方法)
1 (初始化) xhr.OPENED 请求已建立, 但未发送 (已调用open方法, 但未调用send方法)
2 (发送数据) xhr.HEADERS_RECEIVED 请求已发送 (send方法已调用, 已收到响应头)
3 (数据传送中) xhr.LOADING 请求处理中, 因响应内容不全, 这时通过responseBody和responseText获取可能会出现错误
4 (完成) xhr.DONE 数据接收完毕, 此时可以通过通过responseBody和responseText获取完整的响应数据

注意, readyState 是一个只读属性, 想要改变它的值是不可行的.

onreadystatechange

onreadystatechange事件回调方法在readystate状态改变时触发, 在一个收到响应的ajax请求周期中, onreadystatechange 方法会被触发4次. 因此可以在 onreadystatechange 方法中绑定一些事件回调, 比如:

xhr.onreadystatechange = function(e){
  if(xhr.readyState==4){
    var s = xhr.status;
    if((s >= 200 && s < 300) || s == 304){
      var resp = xhr.responseText;
      //TODO ...
    }
  }
}

注意: onreadystatechange回调中默认会传入Event实例, 如下:

Event

status

只读属性, status表示http请求的状态, 初始值为0. 如果服务器没有显式地指定状态码, 那么status将被设置为默认值, 即200.

statusText

只读属性, statusText表示服务器的响应状态信息, 它是一个 UTF-16 的字符串, 请求成功且status==20X时, 返回大写的 OK . 请求失败时返回空字符串. 其他情况下返回相应的状态描述. 比如: 301的 Moved Permanently , 302的 Found , 303的 See Other , 307 的 Temporary Redirect , 400的 Bad Request , 401的 Unauthorized 等等.

onloadstart

onloadstart事件回调方法在ajax请求发送之前触发, 触发时机在 readyState==1 状态之后, readyState==2 状态之前.

onloadstart方法中默认将传入一个ProgressEvent事件进度对象. 如下:

ProgressEvent

ProgressEvent对象具有三个重要的Read only属性.

  • lengthComputable 表示长度是否可计算, 它是一个布尔值, 初始值为false.
  • loaded 表示已加载资源的大小, 如果使用http下载资源, 它仅仅表示已下载内容的大小, 而不包括http headers等. 它是一个无符号长整型, 初始值为0.
  • total 表示资源总大小, 如果使用http下载资源, 它仅仅表示内容的总大小, 而不包括http headers等, 它同样是一个无符号长整型, 初始值为0.

onprogress

onprogress事件回调方法在 readyState==3 状态时开始触发, 默认传入 ProgressEvent 对象, 可通过 e.loaded/e.total 来计算加载资源的进度, 该方法用于获取资源的下载进度.

注意: 该方法适用于 IE10+ 及其他现代浏览器.

xhr.onprogress = function(e){
  console.log('progress:', e.loaded/e.total);
}

onload

onload事件回调方法在ajax请求成功后触发, 触发时机在 readyState==4 状态之后.

想要捕捉到一个ajax异步请求的成功状态, 并且执行回调, 一般下面的语句就足够了:

xhr.onload = function(){
  var s = xhr.status;
  if((s >= 200 && s < 300) || s == 304){
    var resp = xhr.responseText;
    //TODO ...
  }
}

onloadend

onloadend事件回调方法在ajax请求完成后触发, 触发时机在 readyState==4 状态之后(收到响应时) 或者 readyState==2 状态之后(未收到响应时).

onloadend方法中默认将传入一个ProgressEvent事件进度对象.

timeout

timeout属性用于指定ajax的超时时长. 通过它可以灵活地控制ajax请求时间的上限. timeout的值满足如下规则:

  • 通常设置为0时不生效.
  • 设置为字符串时, 如果字符串中全部为数字, 它会自动将字符串转化为数字, 反之该设置不生效.
  • 设置为对象时, 如果该对象能够转化为数字, 那么将设置为转化后的数字.
xhr.timeout = 0; //不生效
xhr.timeout = '123'; //生效, 值为123
xhr.timeout = '123s'; //不生效
xhr.timeout = ['123']; //生效, 值为123
xhr.timeout = {a:123}; //不生效

ontimeout

ontimeout方法在ajax请求超时时触发, 通过它可以在ajax请求超时时做一些后续处理.

xhr.ontimeout = function(e) {
  console.error("请求超时!!!")
}

response responseText

均为只读属性, response表示服务器的响应内容, 相应的, responseText表示服务器响应内容的文本形式.

responseXML

只读属性, responseXML表示xml形式的响应数据, 缺省为null, 若数据不是有效的xml, 则会报错.

responseType

responseType表示响应的类型, 缺省为空字符串, 可取 "arraybuffer" , "blob" , "document" , "json" , and "text" 共五种类型.

responseURL

responseURL返回ajax请求最终的URL, 如果请求中存在重定向, 那么responseURL表示重定向之后的URL.

withCredentials

withCredentials是一个布尔值, 默认为false, 表示跨域请求中不发送cookies等信息. 当它设置为true时, cookies , authorization headers 或者TLS客户端证书 都可以正常发送和接收. 显然它的值对同域请求没有影响.

但是务必要注意,withCredentials属性什么时机设置,XMLHttpRequest Living Standard(2017)中有明确的规定。

Setting the withCredentials attribute must run these steps:

  1. If state is not unsent or opened, throw an InvalidStateError exception.
  2. If the send() flag is set, throw an InvalidStateError exception.
  3. Set the withCredentials attribute’s value to the given value.

这意味着,readyState为unset或者opened之前,是不能为xhr对象设置withCredentials属性的,实际上,新建的xhr对象,默认就是unset状态,因此这里没有问题。问题出在w3c 2011年的规范,当时是这么描述的:

On setting the withCredentials attribute these steps must be run:

  1. If the state is not OPENED raise an INVALID_STATE_ERR exception and terminate these steps.
  2. If the send() flag is true raise an INVALID_STATE_ERR exception and terminate these steps.
  3. If the anonymous flag is true raise an INVALID_ACCESS_ERR exception and terminate these steps.
  4. Set the withCredentials attribute’s value to the given value.

注意第一条,readyState为unset之前,为xhr对象设置withCredentials属性就会抛出INVALID_STATE_ERR错误。

目前,一些老的浏览器或webview仍然是参考w3c 2011年的规范,因此为了兼容,建议在readyState为opened状态之后才去设置withCredentials属性

之前zepto.js就踩过这个坑,感兴趣不妨阅读前方有坑,请绕道——Zepto 中使用 CORS

注意: 该属性适用于 IE10+, opera12+及其他现代浏览器。Android 4.3及以下版本的webview,采用的是w3c 2011的规范,请务必在open方法调用之后再设置withCredentials的值。

abort

abort方法用于取消ajax请求, 取消后, readyState 状态将被设置为 0 (UNSENT). 如下, 调用abort 方法后, 请求将被取消.

Event

getResponseHeader

getResponseHeader方法用于获取ajax响应头中指定name的值. 如果response headers中存在相同的name, 那么它们的值将自动以字符串的形式连接在一起.

console.log(xhr.getResponseHeader('Content-Type'));//"text/html"

getAllResponseHeaders

getAllResponseHeaders方法用于获取所有安全的ajax响应头, 响应头以字符串形式返回. 每个HTTP报头名称和值用冒号分隔, 如key:value, 并以\r\n结束.

xhr.onreadystatechange = function() {
  if(this.readyState == this.HEADERS_RECEIVED) {
    console.log(this.getAllResponseHeaders());
  }
}
//Content-Type: text/html"

以上, readyState === 2 状态时, 就意味着响应头已接受完整. 此时便可以打印出完整的 response headers.

setRequestHeader

既然可以获取响应头, 那么自然也可以设置请求头, setRequestHeader就是干这个的. 如下:

//指定请求的type为json格式
xhr.setRequestHeader("Content-type", "application/json");
//除此之外, 还可以设置其他的请求头
xhr.setRequestHeader('x-requested-with', '123456');

onerror

onerror方法用于在ajax请求出错后执行. 通常只在网络出现问题时或者ERR_CONNECTION_RESET时触发(如果请求返回的是407状态码, chrome下也会触发onerror).

upload

upload属性默认返回一个 XMLHttpRequestUpload 对象, 用于上传资源. 该对象具有如下方法:

  • onloadstart
  • onprogress
  • onabort
  • onerror
  • onload
  • ontimeout
  • onloadend

上述方法功能同 xhr 对象中同名方法一致. 其中, onprogress 事件回调方法可用于跟踪资源上传的进度.

xhr.upload.onprogress = function(e){
  var percent = 100 * e.loaded / e.total |0;
  console.log('upload: ' + precent + '%');
}

overrideMimeType

overrideMimeType方法用于强制指定response 的 MIME 类型, 即强制修改response的 Content-Type . 如下, 服务器返回的response的 MIME 类型为 text/plain .

response headers

xhr.getResponseHeader('Content-Type');//"text/plain"
xhr.responseXML;//null

通过overrideMimeType方法将response的MIME类型设置为 text/xml;charset=utf-8 , 如下所示:

xhr.overrideMimeType("text/xml; charset = utf-8");
xhr.send();

此时虽然 response headers 如上图, 没有变化, 但 Content-Type 已替换为新值.

xhr.getResponseHeader('Content-Type');//"text/xml; charset = utf-8"

此时, xhr.responseXML 也将返回DOM对象, 如下图.

response headers

XHR一级

XHR1 即 XMLHttpRequest Level 1. XHR1时, xhr对象具有如下缺点:

  • 仅支持文本数据传输, 无法传输二进制数据.
  • 传输数据时, 没有进度信息提示, 只能提示是否完成.
  • 受浏览器 同源策略 限制, 只能请求同域资源.
  • 没有超时机制, 不方便掌控ajax请求节奏.

XHR二级

XHR2 即 XMLHttpRequest Level 2. XHR2针对XHR1的上述缺点做了如下改进:

  • 支持二进制数据, 可以上传文件, 可以使用FormData对象管理表单.
  • 提供进度提示, 可通过 xhr.upload.onprogress 事件回调方法获取传输进度.
  • 依然受 同源策略 限制, 这个安全机制不会变. XHR2新提供 Access-Control-Allow-Origin 等headers, 设置为 * 时表示允许任何域名请求, 从而实现跨域CORS访问(有关CORS详细介绍请耐心往下读).
  • 可以设置timeout 及 ontimeout, 方便设置超时时长和超时后续处理.

这里就H5新增的FormData对象举个例.

//可直接创建FormData实例
var data = new FormData();
data.append("name", "louis");
xhr.send(data);
//还可以通过传入表单DOM对象来创建FormData实例
var form = document.getElementById('form');
var data = new FormData(form);
data.append("password", "123456");
xhr.send(data);

目前, 主流浏览器基本上都支持XHR2, 除了IE系列需要IE10及更高版本. 因此IE10以下是不支持XHR2的.

那么问题来了, IE7, 8,9的用户怎么办? 很遗憾, 这些用户是比较尴尬的. 对于IE8,9而言, 只有一个阉割版的 XDomainRequest 可用,IE7则没有. 估计IE7用户只能哭晕在厕所了.

XDomainRequest

XDomainRequest 对象是IE8,9折腾出来的, 用于支持CORS请求非成熟的解决方案. 以至于IE10中直接移除了它, 并重新回到了 XMLHttpRequest 的怀抱.

XDomainRequest 仅可用于发送 GETPOST 请求. 如下即创建过程.

var xdr = new XDomainRequest();

xdr具有如下属性:

  • timeout
  • responseText

如下方法:

  • open: 只能接收Method,和url两个参数. 只能发送异步请求.
  • send
  • abort

如下事件回调:

  • onprogress
  • ontimeout
  • onerror
  • onload

除了缺少一些方法外, XDomainRequest 基本上就和 XMLHttpRequest 的使用方式保持一致.

必须要明确的是:

  • XDomainRequest 不支持跨域传输cookie.
  • 只能设置请求头的Content-Type字段, 且不能访问响应头信息.

$.ajax

$.ajax是jquery对原生ajax的一次封装. 通过封装ajax, jquery抹平了不同版本浏览器异步http的差异性, 取而代之的是高度统一的api. jquery作为js类库时代的先驱, 对前端发展有着深远的影响. 了解并熟悉其ajax方法, 不可谓不重要.

参数列表

$.ajax() 只有一个参数, 该参数为key-value设置对象. 实际上, jq发送的所有ajax请求, 都是通过调用该ajax方法实现的. 它的详细参数如下表:

序号 参数 类型 描述
1 accepts PlainObject 用于通知服务器该请求需要接收何种类型的返回结果. 如有必要, 推荐在 $.ajaxSetup() 方法中设置一次.
2 async Boolean 默认为true, 即异步.
3 beforeSend Function 请求发送前的回调, 默认传入参数jqXHR和settings. 函数内显式返回false将取消本次请求.
4 cache Boolean 请求是否开启缓存, 默认为true, 如不需要缓存请设置为false. 不过, dataType为”script”和”jsonp”时默认为false.
5 complete Function 请求完成后的回调(请求successerror之后均调用), 默认传入参数jqXHR和textStatus(请求状态, 取值为 “success”,”notmodified”,”error”,”timeout”,”abort”,”parsererror”之一). 从jq1.5开始, complete可以设置为一个包含函数的数组. 如此每个函数将依次被调用.
6 contents PlainObject 一个以”{字符串/正则表达式}”配对的对象, 根据给定的内容类型, 解析请求的返回结果.
7 contentType String 编码类型, 相对应于http请求头域的”Content-Type”字段. 默认值为”application/x-www-form-urlencoded; charset=UTF-8”.
8 context Object 设置ajax回调函数的上下文. 默认上下文为ajax请求传入的参数设置对象. 如设置为document.body, 那么所有ajax回调函数中将以body为上下文.
9 converters PlainObject 一个数据类型到数据类型转换器的对象. 默认为 {"* text": window.String, "text html": true, "text json": jQuery.parseJSON, "text xml": jQuery.parseXML} . 如设置converters:{"json jsonp": function(msg){}}
10 crossDomain Boolean 默认同域请求为false, 跨域请求为true.
11 data Object, Array 发送到服务器的数据, 默认data为键值对格式对象, 若data为数组则按照traditional参数的值, 自动转化为一个同名的多值查询字符串. 如{a:1,b:2}将转换为”&a=1&b=2”.
12 dataFilter Function 处理XMLHttpRequest原始响应数据的回调, 默认传入data和type参数, data是Ajax返回的原始数据, type是调用$.ajax时提供的dataType参数
13 dataType String 预期服务器返回的数据类型, 可设置为”xml”,”html”,”script”,”json”,”jsonp”,”text”之一, 其中设置为”xml”或”text”类型时, 数据不会经过处理.
14 error Function 请求失败时的回调函数, 默认传入jqXHR(jq1.4以前为原生xhr对象),textStatus(请求状态,取值为null,”timeout”,”error”,”abort” 或 “parsererror”),errorString(错误内容), 当一个HTTP错误发生时, errorThrown 接收HTTP状态的文本部分,比如”Not Found”等. 从jq1.5开始, error可以设置为一个包含函数的数组. 如此每个函数将依次被调用.注意: 跨域脚本和JSONP请求时error不被调用.
15 global Boolean 表示是否触发全局ajax事件, 默认为true. 设为false将不再触发ajaxStart,ajaxStop,ajaxSend,ajaxError等. 跨站脚本和jsonp请求, 该值自动设置为false.
16 headers PlainObject 设置请求头, 格式为k-v键值对对象. 由于该设置会在beforeSend函数被调用之前生效, 因此可在beforeSend函数内覆盖该对象.
17 ifModified Boolean 只有上次请求响应改变时, 才允许请求成功. 它使用HTTP包的Last-Modified 头信息判断, 默认为false. 若设置为true, 且数据自从上次请求后没有更改过就会报错.
18 isLocal Boolean 运行当前环境设置为”本地”,默认为false, 若设置为true, 将影响请求发送时的协议.
19 jsonp String 显式指定jsonp请求中的回调函数的名称. 如jsonp:cb, jq会将cb代替callback, 以 “cb=?”传给服务器. 从jq1.5开始, 若设置jsonp:false, 那么需要明确设置jsonpCallback:”callbackName”.
20 jsonpCallback String,Function 为jsonp请求指定一个回调函数名, 以取代jq自动生成的随机函数名. 从jq1.5开始, 可以将该属性设置为一个函数, 函数的返回值就是jsonpCallback的结果.
21 mimeType String 设置一个MIME类型, 以覆盖xhr的MIM类型(jq1.5新增)
22 password String 设置认证请求中的密码
23 processData Boolean jq的ajax方法默认会将传入的data隐式转换为查询字符串(如”&a=1&b=2”), 以配合 默认内容类型 “application/x-www-form-urlencoded”, 如果不希望转换请设置为false. angular中想要禁用默认转换, 需要重写transformRequest方法.
24 scriptCharset String 仅在”script”请求中使用(如跨域jsonp, dataType为”script”类型). 显式指定时, 请求中将在script标签上设置charset属性, 可在发现本地和远程编码不一致时使用.
25 statusCode PlainObject 一组http状态码和回调函数对应的键值对对象. 该对象以 {404:function(){}} 这种形式表示. 可用于根据不同的http状态码, 执行不同的回调.(jq1.5新增)
26 timeout Number 设置超时时间.
27 traditional Boolean 是否按照默认方式序列化data对象, 默认值为false.
28 type String 可以设置为8种http method之一, jq中不区分大小写.
29 url String 请求的uri地址.
30 username String 设置认证请求中的用户名
31 xhr Function 在回调内创建并返回xhr对象
32 xhrFields PlainObject 键值对对象, 用于设置原生的xhr对象, 如可用来设置withCredentials:true(jq1.5.1新增)

支持promise

$.ajax() 方法返回jqXHR对象(jq1.5起), 如果使用的不是XMLHttpRequest对象时, 如jsonp请求, 返回的jqXHR对象将尽可能模拟原生的xhr. 从jq1.5起, 返回的jqXHR对象实现了promise接口, 具有如下新方法.

新方法 被替代的老方法(jq1.8起弃用)
done(function(data, textStatus, jqXHR) {}) success
fail(function(jqXHR, textStatus, errorThrown) {}) error
always(function(data or jqXHR, textStatus, jqXHR or errorThrown) {}) complete

从jq1.6开始, done, fail, always按照FIFO队列可以分配多个回调.

使用转换器

$.ajax() 的转换器可以将支持的数据类型映射到其它数据类型. 如果需要将自定义数据类型映射到已知的类型. 需要使用 contents 选项在响应的 “Content-Type” 和实际数据类型之间添加一个转换函数.

$.ajaxSetup({
  contents: {
    myContentType: /myContentType/
  },
  converters: {
    "myContentType json": function(data) {
      //TODO something
      return newData;
    }
  }
});

转换一个支持的类型为自定义类型, 然后再返回. 如 text—>myContentType—>json.

$.ajaxSetup({
  contents: {
    myContentType: /myContentType/
  },
  converters: {
    "text myContentType": true,
    "myContentType json": function(data) {
      //TODO something
      return newData;
    }
  }
});

事件触发顺序

$.ajax()方法触发的事件纷繁复杂, 有将近20个之多. 为了囊括最多的事件, 这里以一次成功的上传请求为例, 以下是它们的调用顺序(请求出现错误时的顺序, 请自行对应).

序号 事件名称 是否全局事件 是否能关闭 默认形参
1 $.ajaxPrefilter ✔️ function(options, originalOptions, jqXHR){}
2 $(document).ajaxStar ✔️ ✔️ function(){}(只在当前无激活ajax时触发)
3 beforeSend - function(jqXHR, settings){}
4 $(document).ajaxSend ✔️ ✔️ function(){}
5 xhr.onloadstart - - ProgressEvent
6 xhr.upload.onloadstart - - ProgressEvent
7 xhr.upload.onprogress - - ProgressEvent
8 xhr.upload.onload - - ProgressEvent
9 xhr.upload.onloadend - - ProgressEvent
10 xhr.onprogress - - ProgressEvent
11 xhr.onload - - ProgressEvent
12 success(弃用) - function(data, textStatus, jqXHR){}
13 $(document).ajaxSuccess ✔️ ✔️ function(event, jqXHR, options){}
14 complete(弃用) - function(jqXHR, textStatus){}
15 $(document).ajaxComplete ✔️ ✔️ function(event, jqXHR, textStatus)
16 $(document).ajaxStop ✔️ ✔️ function(){}
17 xhr.onloadend - - ProgressEvent

从jq1.8起, 对于函数 ajaxStart, ajaxSend, ajaxSuccess, ajaxComplete, ajaxStop , 只能为document对象绑定事件处理函数, 为其他元素绑定的事件处理函数不会起作用.

Axios

实际上, 如果你仅仅只是想要一个不错的http库, 相比于庞大臃肿的jquery, 短小精悍的Axios可能更加适合你. 原因如下:

  • Axios支持node, jquery并不支持.
  • Axios基于promise语法, jq3.0才开始全面支持.
  • Axios短小精悍, 更加适合http场景, jquery大而全, 加载较慢.
  • vue作者尤大放弃推荐vue-resource, 转向推荐Axios. 以下为尤大原话.

“最近团队讨论了一下, Ajax 本身跟 Vue 并没有什么需要特别整合的地方, 使用 fetch polyfill 或是 axios、superagent 等等都可以起到同等的效果, vue-resource 提供的价值和其维护成本相比并不划算, 所以决定在不久以后取消对 vue-resource 的官方推荐.”

Axios大小仅12k, 目前最新版本号为: npm version

语法上Axios基本就和promise一样, 在then方法中处理回调, 在catch方法中处理异常. 如下:

axios.get("https://api.github.com/users/louiszhai")
  .then(function(response){
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });

除了get, 它还支持post, delete, head, put, patch, request请求. 具体使用攻略, 请戳这里: axios .

如需在网页上引入 Axios, 可以链接CDN axios | Bootstrap中文网开源项目免费 CDN 服务 或者将其下载到本地.

Fetch

说到ajax, 就不得不提及fetch, 由于篇幅较长, fetch已从本文中独立出来, 请戳 Fetch进阶指南 .

ajax跨域请求

什么是CORS

CORS是一个W3C(World Wide Web)标准, 全称是跨域资源共享(Cross-origin resource sharing).它允许浏览器向跨域服务器, 发出异步http请求, 从而克服了ajax受同源策略的限制. 实际上, 浏览器不会拦截不合法的跨域请求, 而是拦截了他们的响应, 因此即使请求不合法, 很多时候, 服务器依然收到了请求.(Chrome和Firefox下https网站不允许发送http异步请求除外)

通常, 一次跨域访问拥有如下流程:

移动端CORS兼容性

当前几乎所有的桌面浏览器(Internet Explorer 8+, Firefox 3.5+, Safari 4+和 Chrome 3+)都可通过名为跨域资源共享的协议支持ajax跨域调用.

那么移动端兼容性又如何呢? 请看下图:

cors-mobile

可见, CORS的技术在IOS Safari7.1及Android webview2.3中就早已支持, 即使低版本下webview的canvas在使用跨域的video或图片时会有问题, 也丝毫不影响CORS的在移动端的使用. 至此, 我们就可以放心大胆的去应用CORS了.

CORS有关的headers

1) HTTP Response Header(服务器提供):

  • Access-Control-Allow-Origin: 指定允许哪些源的网页发送请求.

  • Access-Control-Allow-Credentials: 指定是否允许cookie发送.

  • Access-Control-Allow-Methods: 指定允许哪些请求方法.

  • Access-Control-Allow-Headers: 指定允许哪些常规的头域字段, 比如说 Content-Type.

  • Access-Control-Expose-Headers: 指定允许哪些额外的头域字段, 比如说 X-Custom-Header.

    该字段可省略. CORS请求时, xhr.getResponseHeader() 方法默认只能获取6个基本字段: Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma . 如果需要获取其他字段, 就需要在Access-Control-Expose-Headers 中指定. 如上, 这样xhr.getResponseHeader(‘X-Custom-Header’) 才能返回X-Custom-Header字段的值.(该部分摘自阮一峰老师博客)

  • Access-Control-Max-Age: 指定preflight OPTIONS请求的有效期, 单位为秒.

2) HTTP Request Header(浏览器OPTIONS请求默认自带):

  • Access-Control-Request-Method: 告知服务器,浏览器将发送哪种请求, 比如说POST.
  • Access-Control-Request-Headers: 告知服务器, 浏览器将包含哪些额外的头域字段.

3) 以下所有的header name 是被拒绝的:

  • Accept-Charset
  • Accept-Encoding
  • Access-Control-Request-Headers
  • Access-Control-Request-Method
  • Connection
  • Content-Length
  • Cookie
  • Cookie2
  • Date
  • DNT
  • Expect
  • Host
  • Keep-Alive
  • Origin
  • Referer
  • TE
  • Trailer
  • Transfer-Encoding
  • Upgrade
  • Via
  • 包含以Proxy-Sec- 开头的header name

CORS请求

CORS请求分为两种, ① 简单请求; ② 非简单请求.

满足如下两个条件便是简单请求, 反之则为非简单请求.(CORS请求部分摘自阮一峰老师博客)

1) 请求是以下三种之一:

  • HEAD
  • GET
  • POST

2) http头域不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type字段限三个值 application/x-www-form-urlencodedmultipart/form-datatext/plain

对于简单请求, 浏览器将发送一次http请求, 同时在Request头域中增加 Origin 字段, 用来标示请求发起的源, 服务器根据这个源采取不同的响应策略. 若服务器认为该请求合法, 那么需要往返回的 HTTP Response 中添加 Access-Control-* 等字段.( Access-Control-* 相关字段解析请阅读我之前写的CORS 跨域访问 )

对于非简单请求, 比如Method为POST且Content-Type值为 application/json 的请求或者Method为 PUTDELETE 的请求, 浏览器将发送两次http请求. 第一次为preflight预检(Method: OPTIONS),主要验证来源是否合法. 值得注意的是:OPTION请求响应头同样需要包含 Access-Control-* 字段等. 第二次才是真正的HTTP请求. 所以服务器必须处理OPTIONS应答(通常需要返回20X的状态码, 否则xhr.onerror事件将被触发).

以上请求流程图为:

HTML启用CORS

http-equiv 相当于http的响应头, 它回应给浏览器一些有用的信息,以帮助正确和精确地显示网页内容. 如下html将允许任意域名下的网页跨域访问.

<meta http-equiv="Access-Control-Allow-Origin" content="*">

图片启用CORS

通常, 图片允许跨域访问, 也可以在canvas中使用跨域的图片, 但这样做会污染画布, 一旦画布受污染, 将无法读取其数据. 比如无法调用 toBlob(), toDataURL() 或 getImageData()方法. 浏览器的这种安全机制规避了未经许可的远程服务器图片被滥用的风险.(该部分内容摘自 启用了 CORS 的图片 - HTML(超文本标记语言) | MDN)

因此如需在canvas中使用跨域的图片资源, 请参考如下apache配置片段(来自HTML5 Boilerplate Apache server configs).

<IfModule mod_setenvif.c>
    <IfModule mod_headers.c>
        <FilesMatch "\.(cur|gif|ico|jpe?g|png|svgz?|webp)$">
            SetEnvIf Origin ":" IS_CORS
            Header set Access-Control-Allow-Origin "*" env=IS_CORS
        </FilesMatch>
    </IfModule>
</IfModule>

ajax文件上传

ajax实现文件上传非常简单, 这里我选取原生js, jq, angular 分别来比较下, 并顺便聊聊使用它们时的注意事项.(ajax文件上传的代码已上传至github, 请戳这里预览效果: ajax 文件上传 demo | louis)

1) 为了上传文件, 我们得先选中一个文件. 一个type为file的input框就够了.

<input id="input" type="file">

2) 然后用FormData对象包裹📦选中的文件.

var input = document.getElementById("input"),
    formData = new FormData();
formData.append("file",input.files[0]);//key可以随意定义,只要后台能理解就行

3) 定义上传的URL, 以及方法. github上我搭建了一个 node-webserver, 根据需要可以自行克隆下来npm start后便可调试本篇代码.

var url = "http://localhost:10108/test",
    method = "POST";

js文件上传

4.1) 封装一个用于发送ajax请求的方法.

function ajax(url, method, data){
  var xhr = null;
  if(window.XMLHttpRequest) {
    xhr = new XMLHttpRequest();
  } else if (window.ActiveXObject) {
    try {
      xhr = new ActiveXObject("Msxml2.XMLHTTP");
    } catch (e) {
      try {
        xhr = new ActiveXObject("Microsoft.XMLHTTP");
      } catch (e) { 
        alert("您的浏览器暂不支持Ajax!");
      }
    }
  }
  xhr.onerror = function(e){
    console.log(e);
  }
  xhr.open(method, url);
  try{
    setTimeout(function(){
      xhr.send(data);
    });
  }catch(e){
    console.log('error:',e);
  }
  return xhr;
}

4.2) 上传文件并绑定事件.

var xhr = ajax(url, method, formData);
xhr.upload.onprogress = function(e){
  console.log("upload progress:", e.loaded/e.total*100 + "%");
};
xhr.upload.onload = function(){
  console.log("upload onload.");
};
xhr.onload = function(){
  console.log("onload.");
}

上传结果如下所示:

js file upload

fetch上传

5) fetch只要发送一个post请求, 并且body属性设置为formData即可. 遗憾的是, fetch无法跟踪上传的进度信息.

fetch(url, {
  method: method,
  body: formData
  }).then(function(res){
  console.log(res);
  }).catch(function(e){
  console.log(e);
});

jquery文件上传

jq提供了各式各样的上传插件, 其原理都是利用jq自身的ajax方法.

6) jq的ajax提供了xhr属性用于自定义各种事件.

$.ajax({
  type: method,
  url: url,
  data: formData,
  processData : false,
  contentType : false ,//必须false才会自动加上正确的Content-Type
  xhr: function(){
    var xhr = $.ajaxSettings.xhr();//实际上就是return new window.XMLHttpRequest()对象
    if(xhr.upload) {
      xhr.upload.addEventListener("progress", function(e){
        console.log("jq upload progress:", e.loaded/e.total*100 + "%");
      }, false);
      xhr.upload.addEventListener("load", function(){
        console.log("jq upload onload.");
      });
      xhr.addEventListener("load", function(){
        console.log("jq onload.");
      });
      return xhr;
    }
  }
});

jq上传结果如下所示:

jq file upload

有关jq ajax更多的api, 请参考中文文档 jQuery.ajax() | jQuery API 中文文档 .

angular文件上传

7.1) angular提供了$http方法用于发送http请求, 该方法返回一个promise对象.

$http({
  method: method,
  url: url,
  data: formData,
}).success(function(res) {
  console.log(res);
}).error(function(err, status) {
  console.log(err);
});

angular文件上传的代码已上传至github, 请戳这里预览效果: angular 文件上传 demo | louis.

低版本angular中文件上传的功能并不完整, 直到angular1.5.5才在$http中加入了eventHandler和uploadEventHandlers等方法, 使得它支持上传进度信息. 如下:

$http({
  method: method,
  url: url,
  eventHandlers: {
    progress: function(c) {//下载进度
      console.log('Progress -> ' + c);
    }
  },
  uploadEventHandlers: {
    progress: function(e) {//上传进度
      console.log('UploadProgress -> ' + e);
    }
  },
  data: formData,
}).success(function(res) {
  console.log(res);
}).error(function(err, status) {
  console.log(err);
});

angular1.5.5以下低版本中, 请参考成熟的实现方案 angular-file-upload 以及它提供的demo Simple example .

ajax请求二进制文件

FileReader

处理二进制文件主要使用的是H5的FileReader.

PC支持性如下:

IE Edge Firefox Chrome Safari Opera
10 12 3.6 6 6 11.5

Mobile支持性如下:

IOS Safari Opera Mini Android Browser Chrome/Android UC/Android
7.1 - 4 53 11

以下是其API:

属性/方法名称 描述
error 表示读取文件期间发生的错误.
readyState 表示读取文件的状态.默认有三个值:0表示文件还没有加载;1表示文件正在读取;2表示文件读取完成.
result 读取的文件内容.
abort() 取消文件读取操作, 此时readyState属性将置为2.
readAsArrayBuffer() 读取文件(或blob对象)为类型化数组(ArrayBuffer), 类型化数组允许开发者以数组下标的方式, 直接操作内存, 由于数据以二进制形式传递, 效率非常高.
readAsBinaryString() 读取文件(或blob对象)为二进制字符串, 该方法已移出标准api, 请谨慎使用.
readAsDataURL() 读取文件(或blob对象)为base64编码的URL字符串, 与window.URL.createObjectURL方法效果类似.
readAsText() 读取文件(或blob对象)为文本字符串.
onload() 文件读取完成时的事件回调, 默认传入event事件对象. 该回调内, 可通过this.result 或 event.target.result获取读取的文件内容.

ajax请求二进制图片并预览

var xhr = new XMLHttpRequest(),
    url = "http://louiszhai.github.io/docImages/ajax01.png";
xhr.open("GET", url);
xhr.responseType = "blob";
xhr.onload = function(){
  if(this.status == 200){
    var blob = this.response;
    var img = document.createElement("img");
    //方案一
    img.src = window.URL.createObjectURL(blob);//这里blob依然占据着内存
    img.onload = function() {
      window.URL.revokeObjectURL(img.src);//释放内存
    };
    //方案二
    /*var reader = new FileReader();
    reader.readAsDataURL(blob);//FileReader将返回base64编码的data-uri对象
    reader.onload = function(){
      img.src = this.result;
    }*/
    //方案三
    //img.src = url;//最简单方法
    document.body.appendChild(img);
  }
}
xhr.send();

ajax请求二进制文本并展示

var xhr = new XMLHttpRequest();
xhr.open("GET","http://localhost:8080/Information/download.jsp?data=node-fetch.js");
xhr.responseType = "blob";
xhr.onload = function(){
  if(this.status == 200){
    var blob = this.response;
    var reader = new FileReader();
    reader.readAsBinaryString(blob);//该方法已被移出标准api,建议使用reader.readAsText(blob);
    reader.onload=function(){
      document.body.innerHTML = "<div>" + this.result + "</div>";
    }
  }
}
xhr.send();

有关二进制文件的读取, 请移步这篇博客 HTML5新特性之文件和二进制数据的操作 .

如何等待多个ajax请求完成

原生js可以使用ES6新增的Promise. ES6的Promise基于 Promises/A+ 规范(该部分 Fetch入门指南 一文也有提及).

这里先提供一个解析responses的函数.

function todo(responses){
  responses.forEach(function(response){
    response.json().then(function(res){
      console.log(res);
    });
  });
}

原生js使用 Promise.all 方法. 如下:

var p1 = fetch("http://localhost:10108/test1"),
    p2 = fetch("http://localhost:10108/test2");
Promise.all([p1, p2]).then(function(responses){
  todo(responses);
  //TODO do somethings
});
//"test1"
//"test2"

jquery可以使用$.when方法. 该方法接受一个或多个Deferred对象作为参数, 只有全部成功才调用resolved状态的回调函数, 但只要其中有一个失败,就调用rejected状态的回调函数. 其实, jq的Deferred是基于 Promises/A规范实现, 但并非完全遵循. (传送门: jQuery 中的 Deferred 和 Promises (2) ).

var p1 = $.ajax("http://localhost:10108/test1"),
    p2 = $.ajax("http://localhost:10108/test2");
$.when(p1, p2).then(function(res1, res2){
  console.log(res1);//["test1", "success", Object]
  console.log(res2);//["test2", "success", Object]
  //TODO do somethings
});

如上, $.when默认返回一个jqXHR对象, 可以直接进行链式调用. then方法的回调中默认传入相应的请求结果, 每个请求结果的都是数组, 数组中依次是responseText, 请求状态, 请求的jqXHR对象.

angular中可以借助 $q.all() 来实现. 别忘了, $q 需要在controller中注入. 此外, $q 相关讲解可参考 AngularJS: ng.$qAngular $q service学习笔记 .

var p1 = fetch("http://localhost:10108/test1"),
    p2 = fetch("http://localhost:10108/test2");
$q.all([p1, p2]).then(function(responses){
  todo(responses);
  //TODO do somethings
});
//"test1"
//"test2"

$q.all() 实际上就是对 Promise.all 的封装.

ajax与history的兼容

ajax的一大痛点就是无法支持浏览器前进和后退操作. 因此早期的Gmail 采用 iframe, 来模拟ajax的前进和后退.

如今, H5普及, pjax大行其道. pajax 就是 ajax+history.pushState 组合的一种技术. 使用它便可以无刷新通过浏览器前进和后退来改变页面内容.

先看下兼容性.

IE Edge Firefox Chrome Safari Opera iOS Safari Android Browser Chrome for Android
pushState/replaceState 10 12 4 5 6 11.5 7.1 4.3 53
history.state 10 4 18 6 11.5

可见IE8,9并不能使用 H5的history. 需要使用垫片 HTML5 History API expansion for browsers not supporting pushState, replaceState .

pjax

pjax简单易用, 仅需要如下三个api:

  • history.pushState(obj, title, url) 表示往页面history末尾新增一个历史项(history entry), 此时history.length会+1.
  • history.replaceState(obj, title, url) 表示替换当前历史项为新的历史项. 此时history.length保持不变.
  • window.onpopstate 仅在浏览器前进和后退时触发(history.go(1), history.back() 及location.href=”xxx” 均会触发), 此时可在history.state中拿到刚刚塞进去的state, 即obj对象(其他数据类型亦可).

我们注意到, 首次进入一个页面, 此时 history.length 值为1, history.state 为空. 如下:

history.state

1) 为了在onpopstate事件回调中每次都能拿到 history.state , 此时需要在页面载入完成后, 自动替换下当前url.

history.replaceState("init", title, "xxx.html?state=0");

2) 每次发送ajax请求时, 在请求完成后, 调用如下, 从而实现浏览器history往前进.

history.pushState("ajax请求相关参数", title, "xxx.html?state=标识符");

3) 浏览器前进和后退时, popstate 事件会自动触发, 此时我们手动取出 history.state , 构建参数并重新发送ajax请求或者直接取用state值, 从而实现无刷新还原页面.

window.addEventListener("popstate", function(e) {
    var currentState = history.state;
    //TODO 拼接ajax请求参数并重新发送ajax请求, 从而回到历史页面
      //TODO 或者从state中拿到关键值直接还原历史页面
});

popstate 事件触发时, 默认会传入 PopStateEvent 事件对象. 该对象具有如下属性.

PopStateEvent

如有不懂, 更详细讲解请移步 : ajax与HTML5 history pushState/replaceState实例 « 张鑫旭-鑫空间-鑫生活 .

ajax缓存处理

js中的http缓存没有开关, 受制于浏览器http缓存策略. 原生xhr请求中, 可通过如下设置关闭缓存.

xhr.setRequestHeader("If-Modified-Since","0");
xhr.setRequestHeader("Cache-Control","no-cache");
//或者 URL 参数后加上  "?timestamp=" + new Date().getTime()

jquery的http缓存是否开启可通过在settings中指定cache.

$.ajax({
  url : 'url',
  dataType : "xml",
  cache: true,//true表示缓存开启, false表示缓存不开启
  success : function(xml, status){    
  }
});

同时jquery还可以全局设置是否缓存. 如下将全局关闭ajax缓存.

$.ajaxSetup({cache:false});

除此之外, 调试过程中出现的浏览器缓存尤为可恶. 建议开启隐私浏览器或者勾选☑️控制台的 Disable cache 选项. (这里以Chrome举例, 其他浏览器类似)

PopStateEvent

ajax的错误处理

前面已经提过, 通常只要是ajax请求收到了http状态码, 便不会进入到错误捕获里.(Chrome中407响应头除外)

实际上, $.ajax 方法略有区别, jquery的ajax方法还会在类型解析出错时触发error回调. 最常见的便是: dataType设置为json, 但是返回的data并非json格式, 此时 $.ajax 的error回调便会触发.

ajax调试技巧

有关调试, 如果接口只是做小部分修改. 那么可以使用charles(Mac) 或者fiddler(Windows), 做代理, 将请求的资源替换为本地文件, 或者使用其断点功能, 直接编辑response.

如果是新增接口的调试, 可以本地搭建node服务. 利用hosts文件配置dns + nginx将http请求转发到本地node服务器. 简易的node调试服务器可参考我的 node-webserver . 如下举一个栗子🌰:

hosts+nginx+node-webserver

假设我们要调试的是 www.test.com 的GET接口. 以下所有步骤以Mac为例, 其他系统, 请自行搜索🔍文件路径.

1) hosts配置.

sudo vim /etc/hosts
#新增一行 127.0.0.1 www.test.com

2) nginx 配置

brew install nginx #安装
#安装成功后进入目标目录
cd /usr/local/etc/nginx/
cd servers #默认配置入口为nginx.conf.同时servers目录下*.conf文件已自动加入到配置文件列表中
vim test.conf
#粘贴如下内容
server {
  listen       80;
  server_name  www.test.com;
  index index.html;
  error_page   500 502 503 504  /50x.html;
  location = /50x.html {
      root   html;
  }
  location / {
    proxy_pass http://localhost:10108/;
    proxy_redirect off;
    proxy_set_header Host $host;
    proxy_set_header        X-Read-IP       $remote_addr;
    proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}
#:wq保存并退出
#启动nginx
sudo nginx -s reload #如果启动了只需重启即可
sudo nginx #如果没有启动,便启动之

3) node-webServer 配置

参考 node-webserver . 启动服务前只需更改index.js, 在第9行后插入如下内容:

'get': {
  '/': {
      getKey : 'Welcome to Simple Node  WebServer!'
  },
  '接口api': '你的response内容'//插入的代码                               
},

如需在nginx中配置CORS, 请看这里: Nginx通过CORS实现跨域.

编码问题

XMLHttpRequest 返回的数据默认的字符编码是utf-8, post方法提交数据默认的字符编码也是utf-8. 若页面编码为gbk等中文编码, 那么就会产生乱码.

后端接口测试技巧

通常, 如果后端接口开发OK了, 前端同学需要通过一些手段来确认接口是能正常访问的.

使用命令测试OPTIONS请求

curl -I -X OPTIONS -H "Origin: http://example.com" http://localhost:10108/
# response
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/json;charset=UTF-8
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: x-requested-with,Content-Type
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Origin: http://example.com
Access-Control-Max-Age: 3600
Server: Node WebServer
Website: https://github.com/Louiszhai/node-webserver
Date: Fri, 21 Oct 2016 09:00:40 GMT
Connection: keep-alive
Transfer-Encoding: chunked

以上, http状态码为200, 表示允许OPTIONS请求.

GET, POST 请求与GET类似, 其他请求亦然.

curl -I -X GET -H "Origin: http://example.com" http://localhost:10108/
#HTTP/1.1 200 OK
curl -I -X POST -H "Origin: http://example.com" http://localhost:10108/test
#HTTP/1.1 200 OK

postman

除此之外, 我们还可以通过chrome的postman扩展进行测试. 请看postman素洁的界面:

postman支持所有类型的http请求, 由于其向chrome申请了cookie访问权限及所有http(s)网站的访问权限. 因此可以放心使用它进行各种网站api的测试.

同时, 强烈建议阅读本文的你升级postman的使用技巧, 这里有篇: 基于Postman的API自动化测试 , 拿走不谢.

ajax移动端兼容性

移动端的支持性,请看表.

IOS Safari Opera Mini Android Browser Android Chrome Android UC
XMLHttpRequest 7.1 - 4.1 53 11(part)
fetch - - 52 53 -

其中,IOS Safari 7.1、Android Browser 4.1 4.3 4.4 的webview均是部分支持,存在的缺陷如下:

responseType支持json格式 responseType支持blob格式 支持timeout及ontimeout
IOS Safari 7.1 X
Android 4.1 X X X
Android 4.3 X X X
Android 4.4 X X

本篇为ajax而生,通篇介绍 XMLHTTPRequest 相关的知识,力求简明,本欲为梳理知识,为读者答疑解惑,但因本人理解所限,难免有所局限,希望正在阅读的你取其精华去其糟粕,谢谢。


本文就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文链接: http://louiszhai.github.io/2016/11/02/ajax/

参考文章

]]>
ajax XMLHttpRequest xhr fetch XDomainRequest jquery.ajax overrideMimeType CORS FileReader postman angular withCredentials setRequestHeader getAllResponseHeaders upload ajax与history的兼容 ajax跨域请求 ajax文件上传 ajax缓存处理 ajax错误处理 ajax调试技巧 pjax ajax请求二进制 多个ajax请求 options xhr一级 xhr二级 axios
Fetch进阶指南 http://louiszhai.github.io/2016/11/02/fetch/ 2016-11-02T04:48:08.000Z 2020-07-04T05:48:04.299Z 导读

对于前端来说,Axios应该不陌生,自从尤大推荐后,Axios几乎成了前端必备工具库,Axios的体积也与日俱增,当前最新版本已经达到了14k的size,这样的大小,在sdk中引用是不太合适的,而XMLHttpRequest又过于原始,还不支持promise,需要进一步封装。那么有没有简单便捷的ajax API呢?它就是Fetch。

Fetch 是 web异步通信的未来. 从chrome42, Firefox39, Opera29, EdgeHTML14(并非Edge版本)起, fetch就已经被支持了. 其中chrome42~45版本, fetch对中文支持有问题, 建议从chrome46起使用fetch. 传送门: fetch中文乱码 .

Fetch

先过一遍Fetch原生支持率.


以下是2020年7月4日更新的 Fetch 兼容性统计,除了 IE 系列,Fetch 兼容性基本没什么大问题。

可见要想在IE8/9/10/11中使用fetch还是有些犯难的,毕竟它连 Promise 都不支持, 更别说fetch了. 别急, 这里有polyfill(垫片).

由于IE8基于ES3, IE9、IE10、IE11基于ES5,但支持不完全. 因此IE8+浏览器, 建议依次装载上述垫片.

尝试一个fetch

先来看一个简单的fetch.

var word = '123',
    url = 'https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd='+word+'&json=1&p=3';
fetch(url,{mode: "no-cors"}).then(function(response) {
  return response;
}).then(function(data) {
  console.log(data);
}).catch(function(e) {
  console.log("Oops, error");
});

fetch执行后返回一个 Promise 对象, 执行成功后, 成功打印出 Response 对象.

response headers

该fetch可以在任何域名的网站直接运行, 且能正常返回百度搜索的建议词条. 以下是常规输入时的是界面截图.

response headers

以下是刚才fetch到的部分数据. 其中key name 为”s”的字段的value就是以上的建议词条.(由于有高亮词条”12306”, 最后一条数据”12366”被顶下去了, 故上面截图上看不到)

response headers

看完栗子过后, 就要动真格了. 下面就来扒下 Fetch.

Promise特性

fetch方法返回一个Promise对象, 根据 Promise Api 的特性, fetch可以方便地使用then方法将各个处理逻辑串起来, 使用 Promise.resolve() 或 Promise.reject() 方法将分别返会肯定结果的Promise或否定结果的Promise, 从而调用下一个then 或者 catch. 一但then中的语句出现错误, 也将跳到catch中.

Promise若有疑问, 请阅读 Promises .

① 我们不妨在 https://sp0.baidu.com 域名的网页控制台运行以下代码.

var word = '123',
    url = 'https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd='+word+'&json=1&p=3';
fetch(url).then(function(response){
  console.log('第一次进入then...');
  if(response.status>=200 && response.status<300){
    console.log('Content-Type: ' + response.headers.get('Content-Type'));
    console.log('Date: ' + response.headers.get('Date'));
    console.log('status: ' + response.status);
    console.log('statusText: ' + response.statusText);
    console.log('type: ' + response.type);
    console.log('url: ' + response.url);
    return Promise.resolve(response);
  }else{
    return Promise.reject(new Error(response.statusText));
  }
}).then(function(data){
  console.log('第二次进入then...');
  console.log(data);
}).catch(function(e){
  console.log('抛出的错误如下:');
  console.log(e);
});

运行截图如下:

fetch then

② 我们不妨在非 https://sp0.baidu.com 域名的网页控制台再次运行以上代码.(别忘了给fetch的第二参数传递{mode: “no-cors”})

运行截图如下:

fetch catch

由于第一次进入then分支后, 返回了否定结果的 Promise.reject 对象. 因此代码进入到catch分支, 抛出了错误. 此时, 上述 response.typeopaque .

response type

一个fetch请求的响应类型(response.type)为如下三种之一:

  • basic
  • cors
  • opaque

如上情景①, 同域下, 响应类型为 “basic”.

如上情景②中, 跨域下, 服务器没有返回CORS响应头, 响应类型为 “opaque”. 此时我们几乎不能查看任何有价值的信息, 比如不能查看response, status, url等等等等.

fetch type

同样是跨域下, 如果服务器返回了CORS响应头, 那么响应类型将为 “cors”. 此时响应头中除 Cache-Control , Content-Language , Content-Type , Expores , Last-ModifiedProgma 之外的字段都不可见.

注意: 无论是同域还是跨域, 以上 fetch 请求都到达了服务器.

mode

fetch可以设置不同的模式使得请求有效. 模式可在fetch方法的第二个参数对象中定义.

fetch(url, {mode: 'cors'});

可定义的模式如下:

  • same-origin: 表示同域下可请求成功; 反之, 浏览器将拒绝发送本次fetch, 同时抛出错误 “TypeError: Failed to fetch(…)”.
  • cors: 表示同域和带有CORS响应头的跨域下可请求成功. 其他请求将被拒绝.
  • cors-with-forced-preflight: 表示在发出请求前, 将执行preflight检查.
  • no-cors: 常用于跨域请求不带CORS响应头场景, 此时响应类型为 “opaque”.

除此之外, 还有两种不太常用的mode类型, 分别是 navigate , websocket , 它们是 HTML标准 中特殊的值, 这里不做详细介绍.

fetch获取http响应头非常easy. 如下:

fetch(url).then(function(response) { 
    console.log(response.headers.get('Content-Type'));
});

设置http请求头也一样简单.

var headers = new Headers();
headers.append("Content-Type", "text/html");
fetch(url,{
  headers: headers
});

header的内容也是可以被检索的.

var header = new Headers({
  "Content-Type": "text/plain"
});
console.log(header.has("Content-Type")); //true
console.log(header.has("Content-Length")); //false

post

在fetch中发送post请求, 同样可以在fetch方法的第二个参数对象中设置.

var headers = new Headers();
headers.append("Content-Type", "application/json;charset=UTF-8");
fetch(url, {
  method: 'post',
  headers: headers,
  body: JSON.stringify({
    date: '2016-10-08',
    time: '15:16:00'
  })
});

credentials

跨域请求中需要带有cookie时, 可在fetch方法的第二个参数对象中添加credentials属性, 并将值设置为”include”.

fetch(url,{
  credentials: 'include'
});

除此之外, credentials 还可以取以下值:

  • omit: 缺省值, 默认为该值.
  • same-origin: 同源, 表示同域请求才发送cookie.

catch

同 XMLHttpRequest 一样, 无论服务器返回什么样的状态码(chrome中除407之外的其他状态码), 它们都不会进入到错误捕获里. 也就是说, 此时, XMLHttpRequest 实例不会触发 onerror 事件回调, fetch 不会触发 reject. 通常只在网络出现问题时或者ERR_CONNECTION_RESET时, 它们才会进入到相应的错误捕获里. (其中, 请求返回状态码为407时, chrome浏览器会触发onerror或者reject掉fetch.)

cache

cache表示如何处理缓存, 遵守http规范, 拥有如下几种值:

  • default: 表示fetch请求之前将检查下http的缓存.
  • no-store: 表示fetch请求将完全忽略http缓存的存在. 这意味着请求之前将不再检查下http的缓存, 拿到响应后, 它也不会更新http缓存.
  • no-cache: 如果存在缓存, 那么fetch将发送一个条件查询request和一个正常的request, 拿到响应后, 它会更新http缓存.
  • reload: 表示fetch请求之前将忽略http缓存的存在, 但是请求拿到响应后, 它将主动更新http缓存.
  • force-cache: 表示fetch请求不顾一切的依赖缓存, 即使缓存过期了, 它依然从缓存中读取. 除非没有任何缓存, 那么它将发送一个正常的request.
  • only-if-cached: 表示fetch请求不顾一切的依赖缓存, 即使缓存过期了, 它依然从缓存中读取. 如果没有缓存, 它将抛出网络错误(该设置只在mode为”same-origin”时有效).

如果fetch请求的header里包含 If-Modified-Since, If-None-Match, If-Unmodified-Since, If-Match, 或者 If-Range 之一, 且cache的值为 default , 那么fetch将自动把 cache的值设置为 "no-store" .

async/await

为什么是async/await

回调深渊一直是jser的一块心病, 虽然ES6提供了 Promise, 将嵌套平铺, 但使用起来依然不便.

要说ES6也提供了generator/yield, 它将一个函数执行暂停, 保存上下文, 再次调用时恢复当时的状态.(学习可参考 Generator 函数的含义与用法 - 阮一峰的网络日志) 无论如何, 总感觉别扭. 如下摘自推库的一张图.

我们不难看出其中的差距, callback简单粗暴, 层层回调, 回调越深入, 越不容易捋清楚逻辑. Promise 将异步操作规范化.使用then连接, 使用catch捕获错误, 堪称完美, 美中不足的是, then和catch中传递的依然是回调函数, 与心目中的同步代码不是一个套路.

为此, ES7 提供了更标准的解决方案 — async/await. async/await 几乎没有引入新的语法, 表面上看起来, 它就和alert一样易用, 虽然它尚处于ES7的草案中, 不过这并不影响我们提前使用它.

async/await语法

async 用于声明一个异步函数, 该函数需返回一个 Promise 对象. 而 await 通常后接一个 Promise对象, 需等待该 Promise 对象的 resolve() 方法执行并且返回值后才能继续执行. (如果await后接的是其他对象, 便会立即执行)

因此, async/await 天生可用于处理 fetch请求(毫无违和感). 如下:

var word = '123',
    url = 'https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd='+word+'&json=1&p=3';
(async ()=>{
  try {
    let res = await fetch(url, {mode: 'no-cors'});//等待fetch被resolve()后才能继续执行
    console.log(res);
  } catch(e) {
    console.log(e);
  }
})();

自然, async/await 也可处理 Promise 对象.

let wait = function(ts){
  return new Promise(function(resolve, reject){
    setTimeout(resolve,ts,'Copy that!');
  });
};
(async function(){
  try {
    let res = await wait(1000);//① 等待1s后返回结果
    console.log(res);
    res = await wait(1000);//② 重复执行一次
    console.log(res);
  } catch(e) {
    console.log(e);
  }
})();
//"Copy that!"

可见使用await后, 可以直接得到返回值, 不必写 .then(callback) , 也不必写 .catch(error) 了, 更可以使用 try catch 标准语法捕获错误.

由于await采用的是同步的写法, 看起来它就和alert函数一样, 可以自动阻塞上下文. 因此它可以重复执行多次, 就像上述代码②一样.

可以看到, await/async 同步阻塞式的写法解决了完全使用 Promise 的一大痛点——不同Promise之间共享数据问题. Promise 需要设置上层变量从而实现数据共享, 而 await/async 就不存在这样的问题, 只需要像写alert一样书写就可以了.

值得注意的是, await 只能用于 async 声明的函数上下文中. 如下 forEach 中, 是不能直接使用await的.

let array = [0,1,2,3,4,5];
(async ()=>{
  array.forEach(function(item){
    await wait(1000);//这是错误的写法, 因await不在async声明的函数上下文中
    console.log(item);
  });
})();

如果是试图将async声明的函数作为回调传给forEach,该回调将同时触发多次,回调内部await依然有效,只是多次的await随着回调一起同步执行了,这便不符合我们阻塞循环的初衷。如下:

const fn = async (item)=>{
  await wait(1000); // 循环中的多个await同时执行,因此等待1s后将同时输出数组各个元素
  console.log(item);
};
array.forEach(fn);

正确的写法如下:

(async ()=>{
  for(let i=0,len=array.length;i<len;i++){
    await wait(1000);
    console.log(array[i]);
  }
})();
如何试运行async/await

鉴于目前只有Edge支持 async/await, 我们可以使用以下方法之一运行我们的代码.

  1. 随着node7.0的发布, node中可以使用如下方式直接运行:

    node --harmony-async-await test.js
    
  2. babel在线编译并运行 Babel · The compiler for writing next generation JavaScript .

  3. 本地使用babel编译es6或更高版本es.

    1) 安装.

    由于Babel5默认自带各种转换插件, 不需要手动安装. 然而从Babel6开始, 插件需要手动下载, 因此以下安装babel后需要再顺便安装两个插件.

    npm i babel-cli -g    # babel已更名为babel-cli
    npm install babel-preset-es2015 --save-dev
    npm install babel-preset-stage-0 --save-dev
    

    2) 书写.babelrc配置文件.

    {
        "presets": [
            "es2015",
            "stage-0"
        ],
        "plugins": []
    }
    

    3) 如果不配置.babelrc. 也可在命令行显式指定插件.

    babel es6.js -o es5.js --presets es2015 stage-0 # 指定使用插件es2015和stage-0编译js
    

    4) 编译.

    babel es6.js -o es5.js  # 编译源文件es6.js,输出为es5.js,编译规则在上述.babelrc中指定
    babel es6.js --out-file es5.js # 或者将-o写全为--out-file也行
    bable es6.js # 如果不指定输出文件路径,babel会将编译生成的文本标准输出到控制台
    

    5) 实时编译

    babel es6.js -w -o es5.js # 实时watch es6.js的变化,一旦改变就重新编译
    babel es6.js -watch -o es5.js # -w也可写全为--watch
    

    6) 编译目录输出到其他目录

    babel src -d build # 编译src目录下所有js,并输出到build目录
    babel src --out-dir build # -d也可写全为--out-dir
    

    7) 编译目录输出到单个文件

    babel src -o es5.js # 编译src目录所有js,合并输出为es5.js
    

    8) 想要直接运行es6.js, 可使用babel-node.

    npm i babel-node -g # 全局安装babel-node
    babel-node es6.js # 直接运行js文件
    

    9) 如需在代码中使用fetch, 且使用babel-node运行, 需引入 node-fetch 模块.

    npm i node-fetch --save-dev
    

    然后在es6.js中require node-fetch 模块.

    var fetch = require('node-fetch');
    
  4. 本地使用traceur编译es6或更高版本es.请参考 在项目开发中优雅地使用ES6:Traceur & Babel .

如何弥补Fetch的不足

fetch基于Promise, Promise受限, fetch也难幸免. ES6的Promise基于 Promises/A+ 规范 (对规范感兴趣的同学可选读 剖析源码理解Promises/A规范 ), 它只提供极简的api, 没有 timeout 机制, 没有 progress 提示, 没有 deferred 处理 (这个可以被async/await替代).

fetch-jsonp

除此之外, fetch还不支持jsonp请求. 不过办法总比问题多, 万能的开源作者提供了 fetch-jsonp 库, 解决了这个问题.

fetch-jsonp 使用起来非常简单. 如下是安装:

npm install fetch-jsonp --save-dev

如下是使用:

fetchJsonp(url, {
  timeout: 3000,
  jsonpCallback: 'callback'
}).then(function(response) {
  console.log(response.json());
}).catch(function(e) {
  console.log(e)
});

abort

由于Promise的限制, fetch 并不支持原生的abort机制, 但这并不妨碍我们使用 Promise.race() 实现一个.

Promise.race(iterable) 方法返回一个Promise对象, 只要 iterable 中任意一个Promise 被 resolve 或者 reject 后, 外部的Promise 就会以相同的值被 resolve 或者 reject.

支持性: 从 chrome33, Firefox29, Safari7.1, Opera20, EdgeHTML12(并非Edge版本) 起, Promise就被完整的支持. Promise.race()也随之可用. 下面我们来看下实现.

var _fetch = (function(fetch){
  return function(url,options){
    var abort = null;
    var abort_promise = new Promise((resolve, reject)=>{
      abort = () => {
        reject('abort.');
        console.info('abort done.');
      };
    });
    var promise = Promise.race([
      fetch(url,options),
      abort_promise
    ]);
    promise.abort = abort;
    return promise;
  };
})(fetch);

然后, 使用如下方法测试新的fetch.

var p = _fetch('https://www.baidu.com',{mode:'no-cors'});
p.then(function(res) {
    console.log('response:', res);
}, function(e) {
    console.log('error:', e);
});
p.abort();
//"abort done."
//"error: abort."

以上, fetch请求后, 立即调用abort方法, 该promise被拒绝, 符合预期. 细心的同学可能已经注意到了, “p.abort();” 该语句我是单独写一行的, 没有链式写在then方法之后. 为什么这么干呢? 这是因为then方法调用后, 返回的是新的promise对象. 该对象不具有abort方法, 因此使用时要注意绕开这个坑.

timeout

同上, 由于Promise的限制, fetch 并不支持原生的timeout机制, 但这并不妨碍我们使用 Promise.race() 实现一个.

下面是一个简易的版本.

function timer(t){
  return new Promise(resolve=>setTimeout(resolve, t))
  .then(function(res) {
    console.log('timeout');
  });
}
var p = fetch('https://www.baidu.com',{mode:'no-cors'});
Promise.race([p, timer(1000)]);
//"timeout"

实际上, 无论超时时间设置为多长, 控制台都将输出log “timeout”. 这是因为, 即使fetch执行成功, 外部的promise执行完毕, 此时 setTimeout 所在的那个promise也不会reject.

下面我们来看一个类似xhr版本的timeout.

var _fetch = (function(fetch){
  return function(url,options){
    var abort = null,
        timeout = 0;
    var abort_promise = new Promise((resolve, reject)=>{
      abort = () => {
        reject('timeout.');
        console.info('abort done.');
      };
    });
    var promise = Promise.race([
      fetch(url,options),
      abort_promise
    ]);
    promise.abort = abort;
    Object.defineProperty(promise, 'timeout',{
      set: function(ts){
        if((ts=+ts)){
          timeout = ts;
          setTimeout(abort,ts);
        }
      },
      get: function(){
        return timeout;
      }
    });
    return promise;
  };
})(fetch);

然后, 使用如下方法测试新的fetch.

var p = _fetch('https://www.baidu.com',{mode:'no-cors'});
p.then(function(res) {
    console.log('response:', res);
}, function(e) {
    console.log('error:', e);
});
p.timeout = 1;
//"abort done."
//"error: timeout."

progress

xhr的 onprogress 让我们可以掌控下载进度, fetch显然没有提供原生api 做类似的事情. 不过 Fetch中的Response.body 中实现了getReader()方法用于读取原始字节流, 该字节流可以循环读取, 直到body下载完成. 因此我们完全可以模拟fetch的progress.

以下是 stackoverflow 上的一段代码, 用于模拟fetch的progress事件. 为了方便测试, 请求url已改为本地服务.(原文请戳 javascript - Progress indicators for fetch? - Stack Overflow)

function consume(reader) {
  var total = 0
  return new Promise((resolve, reject) => {
    function pump() {
      reader.read().then(({done, value}) => {
        if (done) {
          resolve();
          return;
        }
        total += value.byteLength;
        console.log(`received ${value.byteLength} bytes (${total} bytes in total)`);
        pump();
      }).catch(reject)
    }
    pump();
  });
}
fetch('http://localhost:10101/notification/',{mode:'no-cors'})
  .then(res => consume(res.body.getReader()))
  .then(() => console.log("consumed the entire body without keeping the whole thing in memory!"))
  .catch(e => console.log("something went wrong: " + e));

以下是日志截图:

刚好github上有个fetch progress的demo, 感兴趣的小伙伴请参看这里: Fetch Progress DEMO .

我们不妨来对比下, 使用xhr的onprogress事件回调, 输出如下:

当适当增加响应body的size, 发现xhr的onprogress事件回调依然只执行两次. 通过多次测试发现其执行频率比较低, 远不及fetch progress.


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文链接: http://louiszhai.github.io/2016/10/19/fetch/

参考文章

]]>
fetch async await mode promise progress abort
全面解读Math对象及位运算 http://louiszhai.github.io/2016/07/01/Math/ 2016-06-30T23:57:48.000Z 2019-12-02T03:28:32.868Z Math方法和位运算几乎是被忽略得最严重的知识点, 和正则一样, 不用不知道, 一用到处查. 为了告别这种低效的编程模式, 我特地总结此篇, 系统梳理了这两个知识点. 以此为册, 助你攻破它们.

导读

截至ES6, JavaScript 中内置(build-in)构造器/对象共有19个, 其中14个是构造器(Number,Boolean, String, Object, Function, Array, RegExp, Error, Date, Set, WeakSet, Map, Proxy, Promise), Global 不能直接访问, Arguments仅在函数调用时由JS引擎创建, 而 Math, JSON, Reflect 是以对象形式存在的, 本篇将带你走进 JS 内置对象-Math以及与之息息相关的位运算, 一探究竟.

为什么Math这么设计

众所周知, 如果需要使用js进行一些常规的数学运算, 是一件十分麻烦的事情. 为了解决这个问题, ECMAScript 在1.1版本中便引入了 Math. Math 之所以被设计成一个对象, 而不是构造器, 是因为对象中的方法或属性可以作为静态方法或常量直接被调用, 方便使用, 同时, Math 也没有创建实例的必要.

Math中的属性

属性名 描述
Math.E 欧拉常数,也是自然对数的底数 约2.718
Math.LN2 2的自然对数 约0.693
Math.LN10 10的自然对数 约2.303
Math.LOG2E 以2为底E的对数 约1.443
Math.LOG10E 以10为底E的对数 约0.434
Math.PI 圆周率 约3.14
Math.SQRT1_2 1/2的平方根 约0.707
Math.SQRT2 2的平方根 约1.414

Math中的方法

Math对象本就有很多用于运算的方法, 值得关注的是, ES6 规范又对Math对象做了一些扩展, 增加了一系列便捷的方法. 而这些方法大致可以分为以下三类.

三角函数

方法名 描述
Math.sin(x) 返回x的正弦值
Math.sinh(x) ES6新增 返回x的双曲正弦值
Math.cos(x) 返回x的余弦值
Math.cosh(x) ES6新增 返回x的双曲余弦值
Math.tan(x) 返回x的正切值
Math.tanh(x) ES6新增 返回x的双曲正切值
Math.asin(x) 返回x的反正弦值
Math.asinh(x) ES6新增 返回x的反双曲正弦值
Math.acos(x) 返回x的反余弦值
Math.atan(x) 返回x的反正切值
Math.atan2(x, y) 返回 y/x 的反正切值
Math.atanh(x) ES6新增 返回 x 的反双曲正切值

数学运算方法

方法名 描述 例子
Math.sqrt(x) 返回x的平方根 Math.sqrt(9);//3
Math.exp(x) 返回欧拉常数(e)的x次幂 Math.exp(1);//约2.718
Math.pow(x,y) 返回x的y次幂, 如果y未初始化, 则返回x Math.pow(2, 3);//8
Math.expm1(x) ES6新增 返回欧拉常数(e)的x次幂减去1的值 Math.exp(1);//约1.718
Math.log(x) 返回x的自然对数 Math.log(1);//0
Math.log1p(x) ES6新增 返回x+1后的自然对数 Math.log1p(0);//0
Math.log2(x) ES6新增 返回x以2为底的对数 Math.log2(8);//3
Math.log10(x) ES6新增 返回x以10为底的对数 Math.log10(100);//2
Math.cbrt(x) ES6新增 返回x的立方根 Math.cbrt(8);//约2
Math.clz32() ES6新增 返回一个数字在转换成 32位无符号整型数字的二进制形式后, 开头的 0 的个数 Math.clz32(2);//30
Math.hypot(x,y,z) ES6新增 返回所有参数的平方和的平方根 Math.hypot(3,4);//5
Math.imul(x,y) ES6新增 返回两个参数的类C的32位整数乘法运算的运算结果 Math.imul(0xffffffff, 5);//-5

数值运算方法

方法名 描述 例子
Math.abs(x) 返回x的绝对值 Math.abs(-5);//5
Math.floor(x) 返回小于x的最大整数 Math.floor(8.2);//8
Math.ceil(x) 返回大于x的最小整数 Math.ceil(8.2);//9
Math.trunc(x) ES6新增 返回x的整数部分 Math.trunc(1.23);//1
Math.fround(x) ES6新增 返回离它最近的单精度浮点数形式的数字 Math.fround(1.1);//1.100000023841858
Math.min(x,y,z) 返回多个数中的最小值 Math.min(3,1,5);//1
Math.max(x,y,z) 返回多个数中的最大值 Math.max(3,1,5);//5
Math.round(x) 返回四舍五入后的整数 Math.round(8.2);//8
Math.random() 返回0到1之间的伪随机数 Math.random();
Math.sign(x) ES6新增 返回一个数的符号( 5种返回值, 分别是 1, -1, 0, -0, NaN. 代表的各是正数, 负数, 正零, 负零, NaN) Math.sign(-5);//-1

附:Number类型的数值运算方法

Number.prototype中有一个方法叫做toFixed(), 用于将数值装换为指定小数位数的形式.

  • 没有参数或者参数为零的情况下, toFixed() 方法返回该数值的四舍五入后的整数形式, 等同于 Math.round(x);
  • 其他情况下, 返回该数的指定小数位数的四舍五入后的结果.
var num = 1234.56789;
console.log(num.toFixed(),num.toFixed(0));//1235,1235
console.log(num.toFixed(1));//1234.6
console.log(-1.235.toFixed(2));//-1.24

Math方法的一些规律

以上, 数值运算中, 存在如下规律:

  1. Math.trunc(x) 方法当 ① x为正数时, 运算结果同 Math.floor(x); ② x为负数时, 运算结果同 Math.ceil(x). 实际上, 它完全可以由位运算替代, 且运算速度更快, 如 2.5&-1 或 2.5|0 或 ~~2.5 或 2.5^0 , 它们的运算结果都为2; 如 -2.5&-1 或 -2.5|0 或 ~~-2.5 或 -2.5^0 , 它们的运算结果都为-2;
  2. Math.min(x,y,z) 与 Math.max(x,y,z) 方法由于可接无限个参数, 可用于求数组元素的最小最大值. 如: Math.max.apply(null,[5,3,8,9]); // 9 . 但是Math.min 不传参数返回 Infinity, Math.max 不传参数返回 -Infinity .
  3. 稍微利用 Math.random() 方法的特性, 就可以生成任意范围的数字. 如: 生成10到80之间的随机数, ~~(Math.random()*70 + 10);// 返回10~80之间的随机数, 包含10不包含80

除去上述方法, Math作为对象, 继承了来之Object对象的方法. 其中一些如下:

Math.valueOf();//返回Math对象本身
+Math; //NaN, 试图转换成数字,由于不能转换为数字,返回NaN
Math.toString();//"[object Math]"

位运算

Math对象提供的方法种类繁多, 且覆盖面非常全面, 基本上能够满足日常开发所需. 但同时我们也都知道, 使用Math对象的方法进行数值运算时, js代码经过解释编译, 最终会以二进制的方式进行运算. 这种运算方式效率较低, 那么能不能进一步提高运算的效率的呢? 如果我们使用位运算就可. 这是因为位运算本就是直接进行二进制运算.

数值的二进制值

由于位运算是基于二进制的, 因此我们需要先获取数值的二进制值. 实际上, toString 方法已经帮我们做好了一部分工作, 如下:

//正整数可通过toString获取
12..toString(2);//1100
//负整数问题就来了
(-12).toString(2);//-1100

已知: 负数在计算机内部是采用补码表示的. 例如 -1, 1的原码是 0000 0001, 那么1的反码是 1111 1110, 补码是 1111 1111.

故: 负数的十进制转换为二进制时,符号位不变,其它位取反后+1. 即: -x的二进制 = x的二进制取反+1 . 由按位取反可借助^运算符, 故负整数的二进制可以借助下面这个函数来获取:

function getBinary(num){
  var s = (-num).toString(2),
      array = [].map.call(s,function(v){
        return v^1;
      });
  array.reduceRight(function(previousValue, value, index, array){
    var v = previousValue ^ value;
    array[index] = v;
    return +!v;
  },1);
  return array.join('');
}
getBinary(-12);//0100, 前面未补全的部分全部为1

然后, 多试几次就会发现:

getBinary(-1) == 1..toString(2); //true
getBinary(-2) == 2..toString(2); //true
getBinary(-4) == 4..toString(2); //true
getBinary(-8) == 8..toString(2); //true

这表明:

  • 2的整数次方的值与它的相对数, 他们后面真正有效的那几位都相同.

同样, 负数的二进制转十进制时, 符号位不变, 其他位取反后+1. 可参考:

function translateBinary2Decimal(binaryString){
  var array = [].map.call(binaryString,function(v){
    return v^1;
  });
  array.reduceRight(function(previousValue, value, index, array){
    var v = previousValue ^ value;
    array[index] = v;
    return +!v;
  },1);
  return parseInt(array.join(''),2);
}
translateBinary2Decimal(getBinary(-12));//12

由上, 二进制转十进制和十进制转二进制的函数, 大部分都可以共用, 因此下面提供一个统一的函数解决它们的互转问题:

function translateBinary(item){
  var s = null,
      array = null,
      type = typeof item,
      symbol = !/^-/.test(item+'');
  switch(type){
    case "number": 
      s = Math.abs(item).toString(2);
      if(symbol){
        return s;
      }
      break;
    case "string":
      if(symbol){
        return parseInt(item,2);
      }
      s = item.substring(1);
      break;
    default:
      return false;
  }
  //按位取反
  array = [].map.call(s,function(v){
    return v^1;
  });
  //+1
  array.reduceRight(function(previousValue, value, index, array){
    var v = (previousValue + value)==2;
    array[index] = previousValue ^ value;
    return +v;
  },1);
  s = array.join('');
  return type=="number"?'-'+s:-parseInt(s,2);
}
translateBinary(-12);//"-0100"
translateBinary('-0100');//-12

常用的二进制数

二进制数 二进制值
0xAAAAAAAA 10101010101010101010101010101010
0x55555555 01010101010101010101010101010101
0xCCCCCCCC 11001100110011001100110011001100
0x33333333 00110011001100110011001100110011
0xF0F0F0F0 11110000111100001111000011110000
0x0F0F0F0F 00001111000011110000111100001111
0xFF00FF00 11111111000000001111111100000000
0x00FF00FF 00000000111111110000000011111111
0xFFFF0000 11111111111111110000000000000000
0x0000FFFF 00000000000000001111111111111111

现在也可以使用上述方法来验证下常用的二进制值对不对. 如下:

translateBinary(0xAAAAAAAA);//"10101010101010101010101010101010"

按位与(&)

&运算符用于连接两个数, 连接的两个数它们二进制补码形式的值每位都将参与运算, 只有相对应的位上都为1时, 该位的运算才返回1. 比如 3 和 9 进行按位与运算, 以下是运算过程:

    0011    //3的二进制补码形式
&    1001    //9的二进制补码形式
--------------------
    0001    //1,相同位数依次运算,除最后一位都是1,返回1以外, 其它位数由于不同时为1都返回0

由上, 3&9的运算结果为1. 实际上, 由于按位与(&)运算同位上返回1的要求较为严苛, 因此, 它是一种趋向减小最大值的运算.(无论最大值是正数还是负数, 参与按位与运算后, 该数总是趋向减少二进制值位上1的数量, 因此总是有值减小的趋势. ) 对于按位与(&)运算, 满足如下规律:

  1. 数值与自身(或者-1)按位与运算返回数值自身.
  2. 2的整数次方的值与它的相对数按位与运算返回它自身.
  3. 任意整数与0进行按位与运算, 都将会返回0.
  4. 任意整数与1进行按位与运算, 都只有0 或1 两个返回值.
  5. 按位与运算的结果不大于两数中的最大值.

由公式1, 我们可以对非整数取整. 即 x&x === x&-1 === Math.trunc(x) 如下:

console.log(5.2&5.2);//5
console.log(-5.2&-1);//-5
console.log(Math.trunc(-5.2)===(-5.2&-1));//true

由公式4, 我们可以由此判断数值是否为奇数. 如下:

if(1 & x){//如果x为奇数,它的二进制补码形式最后一位必然是1,同1进行按位与运算后,将返回1,而1又会隐式转换为true
  console.log("x为奇数");
}

按位或(|)

|不同于&, |运算符连接的两个数, 只要其二进制补码形式的各位上有一个为1, 该位的运算就返回1, 否则返回0. 比如 3 和 12 进行按位或运算, 以下是运算过程:

    0011    //3的二进制补码形式
|    1100    //12的二进制补码形式
--------------------
    1111    //15, 相同位数依次运算,遇1返回1,故最终结果为4个1.

由上, 3|12的运算结果为15. 实际上, 由于按位与(&)运算同位上返回0的要求较为严苛, 因此, 它是一种趋向增大最小值的运算. 对于按位或(|)运算, 满足如下规律:

  1. 数值与自身按位或运算返回数值自身.
  2. 2的整数次方的值与它的相对数按位或运算返回它的相对数.
  3. 任意整数与0进行按位或运算, 都将会返回它本身.
  4. 任意整数与-1进行按位或运算, 都将返回-1.
  5. 按位或运算的结果不小于两数中的最小值.

稍微利用公式1, 我们便可以将非整数取整. 即 x|0 === Math.trunc(x) 如下:

console.log(5.2|0);//5
console.log(-5.2|0);//-5
console.log(Math.trunc(-5.2)===(-5.2|0));//true

为什么 5.2|0 运算后会返回5呢? 这是因为浮点数并不支持位运算, 运算前, 5.2会转换为整数5再和0进行位运算, 故, 最终返回5.

按位非(~)

~运算符, 返回数值二进制补码形式的反码. 什么意思呢, 就是说一个数值二进制补码形式中的每一位都将取反, 如果该位为1, 取反为0, 如果该位为0, 取反为1. 我们来举个例子理解下:

~    0000 0000 0000 0000 0000 0000 0000 0011    //3的32位二进制补码形式
--------------------------------------------
    1111 1111 1111 1111 1111 1111 1111 1100    //按位取反后为负数(最高位(第一位)表示正负,1代表负,0代表正)
--------------------------------------------
    1000 0000 0000 0000 0000 0000 0000 0011    //负数的二进制转换为十进制时,符号位不变,其它位取反(后+1)
    1000 0000 0000 0000 0000 0000 0000 0100 // +1
--------------------------------------------
                                      -4     //最终运算结果为-4

实际上, 按位非(~)操作不需要这么兴师动众地去计算, 它有且仅有一条运算规律:

  • 按位非操作一个数值, 等同于这个数值加1然后符号改变. 即: ~x === -x-1.
~5 ==> -5-1 === -6;
~-2016 ==> 2016-1 === 2015;

由上述公式可推出: ~~x === -(-x-1)-1 === x. 由于位运算摈除小数部分的特性, 连续两次按位非也可用于将非整数取整. 即, ~~x === Math.trunc(x) 如下:

console.log(~~5.2);//5
console.log(~~-5.2);//-5
console.log(Math.trunc(-5.2)===(~~-5.2));//true

按位非(~)运算符只能用来求数值的反码, 并且还不能输出反码的二进制字符串. 我们来稍微扩展下, 使它变得更易用.

function waveExtend(item){
  var s = typeof item == 'number' && translateBinary(~item);
  return typeof s == 'string'?s:[].map.call(item,function(v){
    return v==='-'?v:v^1;
  }).join('').replace(/^-?/,function(m){return m==''?'-':''});
}
waveExtend(-8);//111 -8反码,正数省略的位全部为0
waveExtend(12);//-0011 12的反码,负数省略的位全部为1

实际上, 按位非(~)运算符要求其运算数为整型, 如果运算数不是整型, 它将和其他位运算符一样尝试将其转换为32位整型, 如果无法转换, 就返回NaN. 那么~NaN等于多少呢?

console.log(~function(){alert(20);}());//先alert(20),然后输出-1

以上语句意在打印一个自执行函数的按位非运算结果. 而该自执行函数又没有显式指定返回值, 默认将返回undefined. 因此它实际上是在输出~undefined的值. 而undefined值不能转换成整型, 通过测试, 运算结果为-1(即~NaN === -1). 我们不妨来看看下来测试, 以便加深理解.

console.log(~'abc');//-1
console.log(~[]);//-1
console.log(~{});//-1
console.log(~function(){});//-1
console.log(~/\d/);//-1
console.log(~Infinity);//-1
console.log(~null);//-1
console.log(~undefined);//-1
console.log(~NaN);//-1

按位异或(^)

^运算符连接的两个数, 它们二进制补码形式的值每位参与运算, 只有相对应的每位值不同, 才返回1, 否则返回0.
(相同则消去, 有些类似两两消失的消消乐). 如下:

    0011    //3的二进制补码形式
^    1000    //8的二进制补码形式
--------------------
    1011    //11, 相同位数依次运算, 值不同的返回1

对于按位异或(^)操作, 满足如下规律:

  1. 由于按位异或位运算的特殊性, 数值与自身按位异或运算返回0. 如: 8^8=0 , 公式为 a^a=0 .
  2. 任意整数与0进行按位异或运算, 都将会返回它本身. 如: 0^-98=-98 , 公式为 0^a=a.
  3. 任意整数x与1(2的0次方)进行按位异或运算, 若它为奇数, 则返回 x-1, 若它为偶数, 则返回 x+1 . 如: 1^-9=-10 , 1^100=101 . 公式为 1^奇=奇-1 , 1^偶=偶+1 ; 推而广之, 任意整数x与2的n次方进行按位异或运算, 若它的二进制补码形式的倒数第n+1位是1, 则返回 x-2的n次方, 反之若为0, 则返回 x+2的n次方 .
  4. 任意整数x与-1(负2的1次方+1)进行按位异或运算, 则将返回 -x-1, 相当于~x运算 . 如: -1^100=-101 , -1^-9=8 . 公式为 -1^x=-x-1=~x .
  5. 任意整数连续按位异或两次相同的数值, 返回它本身. 如: 3^8^8=3 , 公式为 a^b^b=aa^b^a=b .
  6. 按位异或满足操作数与运算结果3个数值之间的交换律: 按位异或的两个数值, 以及他们运算的结果, 共三个数值可以两两异或得到另外一个数值 . 如: 3^9=10 , 3^10=9 , 9^10=3 ; 公式为 a^b=c , a^c=b , b^c=a .

以上公式中, 1, 2, 3和4都是由按位异或运算特性推出的, 公式5可由公式1和2推出, 公式6可由公式5推出.

由于按位异或运算的这种可交换的性质, 我们可用它辅助交换两个整数的值. 如下, 假设这两个值为a和b:

var a=1,b=2;
//常规方法
var tmp = a;
a=b;
b=tmp;
console.log(a,b);//2 1

//使用按位异或~的方法
a=a^b;    //假设a,b的原始值分别为a0,b0
b=a^b;    //等价于 b=a0^b0^b0 ==> b=a0
a=a^b;    //等价于 a=a0^b0^a0 ==> a=b0
console.log(a,b);//2 1
//以上可简写为
a^=b;b^=a;a^=b;

位运算小结

由上可以看出:

  • 由于连接两个数值的位运算均是对相同的位进行比较操作, 故运算数值的先后位置并不重要, 这些位运算(& | ^)满足交换律. 即: a操作符b === b操作符a.
  • 位运算中, 数字0和1都比较特殊. 记住它们的规律, 常可简化运算.
  • 位运算(&|~^)可用于取整, 同 Math.trunc().

有符号左移(<<)

<<运算符, 表示将数值的32位二进制补码形式的除符号位之外的其他位都往左移动若干位数. 当x为整数时, 有: x<<n === x*Math.pow(2,n) 如下:

console.log(1<<3);//8
console.log(100<<4);//1600

如此, Math.pow(2,n) 便可简写为 1<<n.

运算符之一为NaN

对于表达式 x<<n , 当运算数x无法被转换为整数时,运算结果为0.

console.log({}<<3);//0
console.log(NaN<<2);//0

当运算数n无法被转换为整数时,运算结果为x. 相当于 x<<0 .

console.log(2<<NaN);//2

当运算数x和n均无法被转换为整数时,运算结果为0.

console.log(NaN<<NaN);//0

有符号右移(>>)

>>运算符, 除了方向向右, 其他同<<运算符. 当x为整数时, 有: x>>n === Math.floor(x*Math.pow(2,-n)) . 如下:

console.log(-5>>2);//-2
console.log(-7>>3);//-1

右移负整数时, 返回值最大为-1.

右移正整数时, 返回值最小为0.

其他规律请参考 有符号左移时运算符之一为NaN的场景.

无符号右移(>>>)

>>>运算符, 表示连同符号也一起右移.

注意:无符号右移(>>>)会把负数的二进制码当成正数的二进制码. 如下:

console.log(-8>>>5);//134217727
console.log(-1>>>0);//4294967295

以上, 虽然-1没有发生向右位移, 但是-1的二进制码, 已经变成了正数的二进制码. 我们来回顾下这个过程.

translateAry(-1);//-1,补全-1的二进制码至32位: 11111111111111111111111111111111
translateAry('11111111111111111111111111111111');//4294967295

可见, -1的二进制原码本就是32个1, 将这32个1当正数的二进制处理, 直接还原成十进制, 刚好就是 4294967295.

由此, 使用 >>>运算符, 即使是右移0位, 对于负数而言也是翻天覆地的变化. 但是对于正数却没有改变. 利用这个特性, 可以判断数值的正负. 如下:

function getSymbol(num){
  return num === (num>>>0)?"正数":"负数";
}
console.log(getSymbol(-100), getSymbol(123));//负数 正数

其他规律请参考 有符号左移时运算符之一为NaN的场景.

运算符优先级

使用运算符, 如果不知道它们的运算优先级. 就像驾驶法拉利却分不清楚油门和刹车一样恐怖. 因此我为您准备了常用运算符的运算优先级表. 请对号入座.

优先级 运算符 描述
1 后置++ , 后置– , [] , () 或 . 后置++,后置–,数组下标,括号 或 属性选择
2 - , 前置++ , 前置– , ! 或 ~ 负号,前置++,前置–, 逻辑非 或 按位非
3 * , / 或 % 乘 , 除 或 取模
4 + 或 - 加 或 减
5 << 或 >> 左移 或 右移
6 > , >= , < 或 <= 大于, 大于等于, 小于 或 小于等于
7 == 或 != 等于 或 不等于
8 & 按位与
9 ^ 按位异或
10 按位或
11 && 逻辑与
12 逻辑或 逻辑或
13 ?: 条件运算符
14 =,/=,*=,%=,+=,-=,<<=,>>=,&=,^=,按位或后赋值 各种运算后赋值
15 , 逗号

可以看到, ① 除了按位非(~)以外, 其他的位运算符的优先级都是低于+-运算符的; ② 按位与(&), 按位异或(^) 或 按位或(|) 的运算优先级均低于比较运算符(>,<,=等); ③位运算符中按位或(|)优先级最低.

综合运用

计算绝对值

使用有符号右移(>>)运算符, 以及按位异或(^)运算符, 我们可以实现一个 Math.abs方法. 如下:

function abs(num){
  var x = num>>31,    //保留32二进制中的符号位,根据num的正负性分别返回0或-1
      y = num^x;    //返回正数,且利用按位异或中的公式2,若num为正数,num^0则返回num本身;若num为负数,则相当于num^-1,利用公式4, 此时返回-num-1
  return y-x;        //若num为正数,则返回num-0即num;若num为负数则返回-num-1-(-1)即|num|
}

比较两数是否符号相同

通常, 比较两个数是否符号相同, 我们使用x*y>0 来判断即可. 但如果利用按位异或(^), 运算速度将更快.

console.log(-17 ^ 9 > 0);//false

对2的n次方取模(n为正整数)

比如 123%8, 实际上就是求一个余数, 并且这个余数还不大于8, 最大为7. 然后剩下的就是比较二进制值里, 123与7有几成相似了. 便不难推出公式: x%(1<<n)==x&(1<<n)-1 .

console.log(123%8);//3
console.log(123&(1<<3)-1);//3 , 为什么-1时不用括号括起来, 这是因为-优先级高于&

统计正数二进制值中1的个数

不妨先判断n的奇偶性, 为奇数时计数器增加1, 然后将n右移一位, 重复上面步骤, 直到递归退出.

function getTotalForOne(n){
      return n?(n&1)+arguments.callee(n>>1):0;
}
getTotalForOne(9);//2

实现加法运算

加法运算, 从二进制值的角度看, 有 ①同位相加 和 ②遇2进1 两种运算(实际上, 十进制运算也是一样, 同位相加, 遇10进1).

首先我们看看第①种, 同位相加, 不考虑②遇2进1.

1 + 1 = 0
1 + 0 = 1
0 + 1 = 1
0 + 0 = 0

以上运算过程有没有很熟悉. 是不是和按位异或(^)运算有着惊人的相似. 如:

1 ^ 1 = 0
1 ^ 0 = 1
0 ^ 1 = 1
0 ^ 0 = 0

因此①同位相加的运算, 完全可由按位异或(^)代替, 即: x^y.

那么②遇2进1 应该怎么实现呢? 实际上, 非位移位运算中, 只有按位与(&)才能满足遇2的场景, 且只有有符号左移(<<)能满足进1的场景.

现在范围缩小了, 就看&和<<运算符能不能真正满足需要了. 值得高兴的是, 按位与(&)只有在同位都是1的情况下才返回1, 其他情况均返回0. 如果对其运算结果再做左移一位的运算, 即: (x&y)<<1. 刚好满足了②遇2进1的场景.

因为我们是将①同位相加和②遇2进1的两种运算分开进行. 那么最终的加法运算结果应该还要做一次加法. 如下:

最终公式: x + y = x^y + (x&y)<<1

这个公式并不完美, 因为它还是使用了加法, 推导公式怎么能直接使用推导结果呢? 太可怕了, 就不怕掉入递归深渊吗? 下面我们就来绕过这个坑. 而绕过这个坑有一个前提, 那就是只要 x^y 或 (x&y)<<1中有一个值为0就行了, 这样便不用进行加法运算了. 讲了这么多, 不如看代码.

function add(x, y){
  var _x = x^y,
      _y = (x&y)<<1;
  return !_x && _y || !_y && _x || arguments.callee(_x,_y);
}
add(12345678,87654321);//999999999
add(9527,-12);//9515

总结

最后补充一点: 位运算一般只适用 [-2^31, 2^31-1] (即 -2147483648~2147483647) 以内的正负数. 超过这个范围, 计算将可能出现错误. 如下:

console.log(1<<31);//-2147483648

由于数值(2^31)超过了31位(加上保留的一个符号位,共32位), 故计算出错, 于是按照负数的方式解释二进制的值了.说好的不改变符号呢!!!

本文啰嗦几千字, 就为了说清楚两个事儿. ① Math对象中, 比较常用的就是数值运算方法, 不妨多看看, 其他的知道有这个api就行了. ② 位运算中, 则需要基本了解每种位运算符的运算方式, 如果能注意运算中 0和1等特殊数值 的一些妙用就更好了. 无论如何, 本文不可能面面俱到. 如果您对负数的位运算不甚理解, 建议去补下计算机的补码. 希望能对您有所帮助.

注解

  1. 相反数 : 只有符号不同的两个数, 我们就说其中一个是另一个的相反数.
  2. 补码: 在计算机系统中, 数值一律用补码来表示和存储, 且正数的原码和补码相同, 负数的补码等于其原码按位取反再加1.

本问就讨论这么多内容, 如果您有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文链接: http://louiszhai.github.io/2016/07/01/Math/

参考文章

]]>
Math floor ~ ~ abs random ceil 位运算 & | ^ << >> >>> 按位与 按位或 按位非 按位异或 有符号左移 有符号右移 无符号右移
正则表达式前端使用手册 http://louiszhai.github.io/2016/06/13/regexp/ 2016-06-13T00:24:55.000Z 2019-12-02T03:28:32.893Z 导读

你有没有在搜索文本的时候绞尽脑汁, 试了一个又一个表达式, 还是不行.

你有没有在表单验证的时候, 只是做做样子(只要不为空就好), 然后烧香拜佛, 虔诚祈祷, 千万不要出错.

你有没有在使用sed 和 grep 命令的时候, 感觉莫名其妙, 明明应该支持的元字符, 却就是匹配不到.

甚至, 你压根没遇到过上述情况, 你只是一遍又一遍的调用 replace 而已 (把非搜索文本全部替换为空, 然后就只剩搜索文本了), 面对别人家的简洁高效的语句, 你只能在心中呐喊, replace 大法好.

为什么要学正则表达式. 有位网友这么说: 江湖传说里, 程序员的正则表达式和医生的处方, 道士的鬼符齐名, 曰: 普通人看不懂的三件神器. 这个传说至少向我们透露了两点信息: 一是正则表达式很牛, 能和医生的处方, 道士的鬼符齐名, 并被大家提起, 可见其江湖地位. 二是正则表达式很难, 这也从侧面说明了, 如果你可以熟练的掌握并应用它, 在装逼的路上, 你将如日中天 (别问我中天是谁……) !

显然, 有关正则表达的介绍, 无须我多言. 这里就借助 Jeffrey Friedl 的《精通正则表达式》一书的序言正式抛个砖.

​ “如果罗列计算机软件领域的伟大发明, 我相信绝对不会超过二十项, 在这个名单当中, 当然应该包括分组交换网络, Web, Lisp, 哈希算法, UNIX, 编译技术, 关系模型, 面向对象, XML这些大名鼎鼎的家伙, 而正则表达式也绝对不应该被漏掉.

​ 对很多实际工作而言, 正则表达式简直是灵丹妙药, 能够成百倍的提高开发效率和程序质量, 正则表达式在生物信息学和人类基因图谱的研究中所发挥的关键作用, 更是被传为佳话. CSDN的创始人蒋涛先生在早年开发专业软件产品时, 就曾经体验过这一工具的巨大威力, 并且一直印象深刻.”

因此, 我们没有理由不去了解正则表达式, 甚至是熟练掌握并运用它.

本文以正则基础语法开篇, 结合具体实例, 逐步讲解正则表达式匹配原理. 代码实例使用语言包括 js, php, python, java(因有些匹配模式, js并未支持, 需要借助其他语言讲解). 内容包括初阶技能和高阶技能, 适合新手学习和进阶. 本文力求简单通俗易懂, 同时为求全面, 涉及知识较多, 共计12k字, 篇幅较长, 请耐心阅读, 如有阅读障碍请及时[联系我][1].

回顾历史

要论正则表达式的渊源, 最早可以追溯至对人类神经系统如何工作的早期研究. Warren McCulloch 和 Walter Pitts 这两位神经大咖 (神经生理学家) 研究出一种数学方式来描述这些神经网络.

1956 年, 一位叫 Stephen Kleene 的数学家在 McCulloch 和 Pitts 早期工作的基础上, 发表了一篇标题为”神经网事件的表示法”的论文, 引入了正则表达式的概念.

随后, 发现可以将这一工作应用于使用 Ken Thompson 的计算搜索算法的一些早期研究中. 而 Ken Thompson 又是 Unix 的主要发明人. 因此半个世纪以前的Unix 中的 qed 编辑器(1966 qed编辑器问世) 成了第一个使用正则表达式的应用程序.

至此之后, 正则表达式成为家喻户晓的文本处理工具, 几乎各大编程语言都以支持正则表达式作为卖点, 当然 JavaScript 也不例外.

正则表达式的定义

正则表达式是由普通字符和特殊字符(也叫元字符或限定符)组成的文字模板. 如下便是简单的匹配连续数字的正则表达式:

/[0-9]+/
/\d+/

“\d” 就是元字符, 而 “+” 则是限定符.

元字符

元字符 描述
. 匹配除换行符以外的任意字符
\d 匹配数字, 等价于字符组[0-9]
\w 匹配字母, 数字, 下划线
\s 匹配任意的空白符(包括制表符,空格,换行等)
\b 匹配单词开始或结束的位置
^ 匹配行首
$ 匹配行尾

反义元字符

元字符 描述
\D 匹配非数字的任意字符, 等价于[^0-9]
\W 匹配除字母,数字,下划线之外的任意字符
\S 匹配非空白的任意字符
\B 匹配非单词开始或结束的位置
[^x] 匹配除x以外的任意字符

可以看出正则表达式严格区分大小写.

重复限定符

限定符共有6个, 假设重复次数为x次, 那么将有如下规则:

限定符 描述
* x>=0
+ x>=1
? x=0 or x=1
{n} x=n
{n,} x>=n
{n,m} n<=x<=m

字符组

[…] 匹配中括号内字符之一. 如: [xyz] 匹配字符 x, y 或 z. 如果中括号中包含元字符, 则元字符降级为普通字符, 不再具有元字符的功能, 如 [+.?] 匹配 加号, 点号或问号.

排除性字符组

[^…] 匹配任何未列出的字符,. 如: [^x] 匹配除x以外的任意字符.

多选结构

| 就是或的意思, 表示两者中的一个. 如: a|b 匹配a或者b字符.

括号

括号 常用来界定重复限定符的范围, 以及将字符分组. 如: (ab)+ 可以匹配abab..等, 其中 ab 便是一个分组.

转义字符

\ 即转义字符, 通常 \ * + ? | { [ ( ) ] }^ $ . # 和 空白 这些字符都需要转义.

操作符的运算优先级

  1. \ 转义符
  2. (), (?:), (?=), [] 圆括号或方括号
  3. *, +, ?, {n}, {n,}, {n,m} 限定符
  4. ^, $ 位置
  5. | “或” 操作

修饰符

javaScript中正则表达式默认有如下五种修饰符:

  • g (全文查找), 如上述截图, 实际上就开启了全文查找模式.
  • i (忽略大小写查找)
  • m (多行查找)
  • y (ES6新增的粘连修饰符)
  • u (ES6新增)

测试

我们来测试下上面的知识点, 写一个匹配手机号码的正则表达式, 如下:

(\+86)?1\d{10}

① “\+86” 匹配文本 “+86”, 后面接元字符问号, 表示可匹配1次或0次, 合起来表示 “(\+86)?” 匹配 “+86” 或者 “”.

② 普通字符”1” 匹配文本 “1”.

③ 元字符 “\d” 匹配数字0到9, 区间量词 “{10}” 表示匹配 10 次, 合起来表示 “\d{10}” 匹配连续的10个数字.

以上, 匹配结果如下:

常用的正则表达式

  1. 汉字: ^[\u4e00-\u9fa5]{0,}$
  2. Email: ^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$
  3. URL: ^https?://([\w-]+.)+[\w-]+(/[\w-./?%&=]*)?$
  4. 手机号码: ^1\d{10}$
  5. 身份证号: ^(\d{15}|\d{17}(\d|X))$
  6. 中国邮政编码: [1-9]\d{5}(?!\d) (邮政编码为6位数字)

密码验证

密码验证是常见的需求, 一般来说, 常规密码大致会满足规律: 6-16位, 数字, 字母, 字符至少包含两种, 同时不能包含中文和空格. 如下便是常规密码验证的正则描述:

var reg = /(?!^[0-9]+$)(?!^[A-z]+$)(?!^[^A-z0-9]+$)^[^\s\u4e00-\u9fa5]{6,16}$/;

正则的几大家族

正则表达式分类

在 linux 和 osx 下, 常见的正则表达式, 至少有以下三种:

  • 基本的正则表达式( Basic Regular Expression 又叫 Basic RegEx 简称 BREs )
  • 扩展的正则表达式( Extended Regular Expression 又叫 Extended RegEx 简称 EREs )
  • Perl 的正则表达式( Perl Regular Expression 又叫 Perl RegEx 简称 PREs )

正则表达式比较

字符 说明 Basic RegEx Extended RegEx python RegEx Perl regEx
转义
^ 匹配行首,例如’^dog’匹配以字符串dog开头的行(注意:awk 指令中,’^’则是匹配字符串的开始) ^ ^ ^ ^
$ 匹配行尾,例如:’^、dog\$’ 匹配以字符串 dog 为结尾的行(注意:awk 指令中,’$’则是匹配字符串的结尾) $ $ $ $
^$ 匹配空行 ^$ ^$ ^$ ^$
^string$ 匹配行,例如:’^dog$’匹配只含一个字符串 dog 的行 ^string$ ^string$ ^string$ ^string$
\< 匹配单词,例如:’\<frog’ (等价于’\bfrog’),匹配以 frog 开头的单词 \< \< 不支持 不支持(但可以使用\b来匹配单词,例如:’\bfrog’)
> 匹配单词,例如:’frog>‘(等价于’frog\b ‘),匹配以 frog 结尾的单词 > > 不支持 不支持(但可以使用\b来匹配单词,例如:’frog\b’)
\ 匹配一个单词或者一个特定字符,例如:’\‘(等价于’\bfrog\b’)、’\ \ \ 不支持 不支持(但可以使用\b来匹配单词,例如:’\bfrog\b’
() 匹配表达式,例如:不支持’(frog)’ 不支持(但可以使用,如:dog () () ()
匹配表达式,例如:不支持’(frog)’ 不支持(同()) 不支持(同()) 不支持(同())
匹配前面的子表达式 0 次或 1 次(等价于{0,1}),例如:where(is)?能匹配”where” 以及”whereis” 不支持(同\?)
\? 匹配前面的子表达式 0 次或 1 次(等价于’{0,1}‘),例如:’whereis\? ‘能匹配 “where”以及”whereis” \? 不支持(同?) 不支持(同?) 不支持(同?)
? 当该字符紧跟在任何一个其他限制符(*, +, ?, {n},{n,}, {n,m}) 后面时,匹配模式是非贪婪的。非贪婪模式尽可能少的匹配所搜索的字符串,而默认的贪婪模式则尽可能多的匹配所搜索的字符串。例如,对于字符串 “oooo”,’o+?’ 将匹配单个”o”,而 ‘o+’ 将匹配所有 ‘o’ 不支持 不支持 不支持 不支持
. 匹配除换行符(’\n’)之外的任意单个字符(注意:awk 指令中的句点能匹配换行符) . .(如果要匹配包括“\n”在内的任何一个字符,请使用: [\s\S] . .(如果要匹配包括“\n”在内的任何一个字符,请使用:’ [.\n] ‘
* 匹配前面的子表达式 0 次或多次(等价于{0, }),例如:zo* 能匹配 “z”以及 “zoo” * * * *
+ 匹配前面的子表达式 1 次或多次(等价于’{1, }‘),例如:’whereis+ ‘能匹配 “whereis”以及”whereisis” + 不支持(同+) 不支持(同+) 不支持(同+)
+ 匹配前面的子表达式 1 次或多次(等价于{1, }),例如:zo+能匹配 “zo”以及 “zoo”,但不能匹配 “z” 不支持(同\+) + + +
{n} n 必须是一个 0 或者正整数,匹配子表达式 n 次,例如:zo{2}能匹配 不支持(同\{n\}) {n} {n} {n}
{n,} “zooz”,但不能匹配 “Bob”n 必须是一个 0 或者正整数,匹配子表达式大于等于 n次,例如:go{2,} 不支持(同\{n,\}) {n,} {n,} {n,}
{n,m} 能匹配 “good”,但不能匹配 godm 和 n 均为非负整数,其中 n <= m,最少匹配 n 次且最多匹配 m 次 ,例如:o{1,3}将配”fooooood” 中的前三个 o(请注意在逗号和两个数之间不能有空格) 不支持(同\{n,m\}) {n,m} {n,m} {n,m}
x l y 匹配 x 或 y 不支持(同x \l y x l y x l y x l y
[0-9] 匹配从 0 到 9 中的任意一个数字字符(注意:要写成递增) [0-9] [0-9] [0-9] [0-9]
[xyz] 字符集合,匹配所包含的任意一个字符,例如:’[abc]’可以匹配”lay” 中的 ‘a’(注意:如果元字符,例如:. *等,它们被放在[ ]中,那么它们将变成一个普通字符) [xyz] [xyz] [xyz] [xyz]
[^xyz] 负值字符集合,匹配未包含的任意一个字符(注意:不包括换行符),例如:’[^abc]’ 可以匹配 “Lay” 中的’L’(注意:[^xyz]在awk 指令中则是匹配未包含的任意一个字符+换行符) [^xyz] [^xyz] [^xyz] [^xyz]
[A-Za-z] 匹配大写字母或者小写字母中的任意一个字符(注意:要写成递增) [A-Za-z] [A-Za-z] [A-Za-z] [A-Za-z]
[^A-Za-z] 匹配除了大写与小写字母之外的任意一个字符(注意:写成递增) [^A-Za-z] [^A-Za-z] [^A-Za-z] [^A-Za-z]
\d 匹配从 0 到 9 中的任意一个数字字符(等价于 [0-9]) 不支持 不支持 \d \d
\D 匹配非数字字符(等价于 [^0-9]) 不支持 不支持 \D \D
\S 匹配任何非空白字符(等价于[^\f\n\r\t\v]) 不支持 不支持 \S \S
\s 匹配任何空白字符,包括空格、制表符、换页符等等(等价于[ \f\n\r\t\v]) 不支持 不支持 \s \s
\W 匹配任何非单词字符 (等价于[^A-Za-z0-9_]) \W \W \W \W
\w 匹配包括下划线的任何单词字符(等价于[A-Za-z0-9_]) \w \w \w \w
\B 匹配非单词边界,例如:’er\B’ 能匹配 “verb” 中的’er’,但不能匹配”never” 中的’er’ \B \B \B \B
\b 匹配一个单词边界,也就是指单词和空格间的位置,例如: ‘er\b’ 可以匹配”never” 中的 ‘er’,但不能匹配 “verb” 中的’er’ \b \b \b \b
\t 匹配一个横向制表符(等价于 \x09和 \cI) 不支持 不支持 \t \t
\v 匹配一个垂直制表符(等价于 \x0b和 \cK) 不支持 不支持 \v \v
\n 匹配一个换行符(等价于 \x0a 和\cJ) 不支持 不支持 \n \n
\f 匹配一个换页符(等价于\x0c 和\cL) 不支持 不支持 \f \f
\r 匹配一个回车符(等价于 \x0d 和\cM) 不支持 不支持 \r \r
\ 匹配转义字符本身”\” \ \ \ \
\cx 匹配由 x 指明的控制字符,例如:\cM匹配一个Control-M 或回车符,x 的值必须为A-Z 或 a-z 之一,否则,将 c 视为一个原义的 ‘c’ 字符 不支持 不支持 \cx
\xn 匹配 n,其中 n 为十六进制转义值。十六进制转义值必须为确定的两个数字长,例如:’\x41’ 匹配 “A”。’\x041’ 则等价于’\x04’ & “1”。正则表达式中可以使用 ASCII 编码 不支持 不支持 \xn
\num 匹配 num,其中 num是一个正整数。表示对所获取的匹配的引用 不支持 \num \num
[:alnum:] 匹配任何一个字母或数字([A-Za-z0-9]),例如:’[[:alnum:]] ‘ [:alnum:] [:alnum:] [:alnum:] [:alnum:]
[:alpha:] 匹配任何一个字母([A-Za-z]), 例如:’ [[:alpha:]] ‘ [:alpha:] [:alpha:] [:alpha:] [:alpha:]
[:digit:] 匹配任何一个数字([0-9]),例如:’[[:digit:]] ‘ [:digit:] [:digit:] [:digit:] [:digit:]
[:lower:] 匹配任何一个小写字母([a-z]), 例如:’ [[:lower:]] ‘ [:lower:] [:lower:] [:lower:] [:lower:]
[:upper:] 匹配任何一个大写字母([A-Z]) [:upper:] [:upper:] [:upper:] [:upper:]
[:space:] 任何一个空白字符: 支持制表符、空格,例如:’ [[:space:]] ‘ [:space:] [:space:] [:space:] [:space:]
[:blank:] 空格和制表符(横向和纵向),例如:’[[:blank:]]’ó’[\s\t\v]’ [:blank:] [:blank:] [:blank:] [:blank:]
[:graph:] 任何一个可以看得见的且可以打印的字符(注意:不包括空格和换行符等),例如:’[[:graph:]] ‘ [:graph:] [:graph:] [:graph:] [:graph:]
[:print:] 任何一个可以打印的字符(注意:不包括:[:cntrl:]、字符串结束符’\0’、EOF 文件结束符(-1), 但包括空格符号),例如:’[[:print:]] ‘ [:print:] [:print:] [:print:] [:print:]
[:cntrl:] 任何一个控制字符(ASCII 字符集中的前 32 个字符,即:用十进制表示为从 0 到31,例如:换行符、制表符等等),例如:’ [[:cntrl:]]’ [:cntrl:] [:cntrl:] [:cntrl:] [:cntrl:]
[:punct:] 任何一个标点符号(不包括:[:alnum:]、[:cntrl:]、[:space:]这些字符集) [:punct:] [:punct:] [:punct:] [:punct:]
[:xdigit:] 任何一个十六进制数(即:0-9,a-f,A-F) [:xdigit:] [:xdigit:] [:xdigit:] [:xdigit:]

注意

  • js中支持的是EREs.
  • 当使用 BREs ( 基本正则表达式 ) 时,必须在下列这些符号(?,+,|,{,},(,))前加上转义字符 \ .
  • 上述[[:xxxx:]] 形式的正则表达式, 是php中内置的通用字符簇, js中并不支持.

linux/osx下常用命令与正则表达式的关系

我曾经尝试在 grep 和 sed 命令中书写正则表达式, 经常发现不能使用元字符, 而且有时候需要转义, 有时候不需要转义, 始终不能摸清它的规律. 如果恰好你也有同样的困惑, 那么请往下看, 相信应该能有所收获.

grep , egrep , sed , awk 正则表达式特点

  1. grep 支持:BREs、EREs、PREs 正则表达式

    grep 指令后不跟任何参数, 则表示要使用 “BREs”

    grep 指令后跟 ”-E” 参数, 则表示要使用 “EREs”

    grep 指令后跟 “-P” 参数, 则表示要使用 “PREs”

  2. egrep 支持:EREs、PREs 正则表达式

    egrep 指令后不跟任何参数, 则表示要使用 “EREs”

    egrep 指令后跟 “-P” 参数, 则表示要使用 “PREs”

  3. sed 支持: BREs、EREs

    sed 指令默认是使用 “BREs”

    sed 指令后跟 “-r” 参数 , 则表示要使用“EREs”

  4. awk 支持 EREs, 并且默认使用 “EREs”

正则表达式初阶技能

贪婪模式与非贪婪模式

默认情况下, 所有的限定词都是贪婪模式, 表示尽可能多的去捕获字符; 而在限定词后增加?, 则是非贪婪模式, 表示尽可能少的去捕获字符. 如下:

var str = "aaab",
    reg1 = /a+/, //贪婪模式
    reg2 = /a+?/;//非贪婪模式
console.log(str.match(reg1)); //["aaa"], 由于是贪婪模式, 捕获了所有的a
console.log(str.match(reg2)); //["a"], 由于是非贪婪模式, 只捕获到第一个a

实际上, 非贪婪模式非常有效, 特别是当匹配html标签时. 比如匹配一个配对出现的div, 方案一可能会匹配到很多的div标签对, 而方案二则只会匹配一个div标签对.

var str = "<div class='v1'><div class='v2'>test</div><input type='text'/></div>";
var reg1 = /<div.*<\/div>/; //方案一,贪婪匹配
var reg2 = /<div.*?<\/div>/;//方案二,非贪婪匹配
console.log(str.match(reg1));//"<div class='v1'><div class='v2'>test</div><input type='text'/></div>"
console.log(str.match(reg2));//"<div class='v1'><div class='v2'>test</div>"
区间量词的非贪婪模式

一般情况下, 非贪婪模式, 我们使用的是”*?”, 或 “+?” 这种形式, 还有一种是 “{n,m}?”.

区间量词”{n,m}” 也是匹配优先, 虽有匹配次数上限, 但是在到达上限之前, 它依然是尽可能多的匹配, 而”{n,m}?” 则表示在区间范围内, 尽可能少的匹配.

需要注意的是:

  • 能达到同样匹配结果的贪婪与非贪婪模式, 通常是贪婪模式的匹配效率较高.
  • 所有的非贪婪模式, 都可以通过修改量词修饰的子表达式, 转换为贪婪模式.
  • 贪婪模式可以与固化分组(后面会讲到)结合,提升匹配效率,而非贪婪模式却不可以.

分组

正则的分组主要通过小括号来实现, 括号包裹的子表达式作为一个分组, 括号后可以紧跟限定词表示重复次数. 如下, 小括号内包裹的abc便是一个分组:

/(abc)+/.test("abc123") == true

那么分组有什么用呢? 一般来说, 分组是为了方便的表示重复次数, 除此之外, 还有一个作用就是用于捕获, 请往下看.

捕获性分组

捕获性分组, 通常由一对小括号加上子表达式组成. 捕获性分组会创建反向引用, 每个反向引用都由一个编号或名称来标识, js中主要是通过 $+编号 或者 \+编号 表示法进行引用. 如下便是一个捕获性分组的例子.

var color = "#808080";
var output = color.replace(/#(\d+)/,"$1"+"~~");//自然也可以写成 "$1~~"
console.log(RegExp.$1);//808080
console.log(output);//808080~~

以上, (\d+) 表示一个捕获性分组, RegExp.$1 指向该分组捕获的内容. $+编号 只能在正则表达式之外使用.

实际上,捕获性分组捕获到的内容不仅可以在正则表达式外部引用,还可以在正则表达式内部引用。

反向引用

能在正则表达式内部使用的引用只有『反向引用』,其格式为\+数字 ,通常用于匹配不同位置相同部分的子串。如下:

var url = "www.google.google.com";
var re = /([a-z]+)\.\1/;
console.log(url.replace(re,"$1"));//"www.google.com"

以上,相同部分的”google”字符串只被替换一次。

实例之后,我们来看看反向引用的原理。

正则表达式匹配时,各个捕获性分组匹配到的内容,会依次保存在内存中一个特定的组里,通过\+数字的方式可以在正则中引用组里的内容,这种引用称作反向引用。捕获性分组匹配成功之前,它的内容的是不确定的,一旦匹配成功,组里的内容也就确定了。

打个比方就是,假如有字符串abcaabcabbcabcc,对于正则表达式/([abc])\1/,捕获性分组中的子表达式[abc],虽然可以匹配”a”、”b” 或 “c”,但是一旦匹配成功了,反向引用的内容也就是确定了,那么相对的,反向引用的子表达式\1将依次匹配”a”、”b” 或 “c”。因此实际上,捕获性分组[abc]和反向引用\1将同时捕获”a”、”b” 或 “c”中的同一个字符,即有三种可能捕获的结果:”aa”,”bb” 或 “cc”。如下:

"abcaabcabbcabcc".match(/([abc])\1/g); // ["aa", "bb", "cc"]

反向引用中\n指向正则表达式前面第n个捕获性分组匹配到的内容,这里面有一个问题,对于子表达式\12,有下面两种可能:

  • 指向第1个捕获性分组匹配到的内容,同时匹配普通字符2,目前IE浏览器按照这种方式解析正则。
  • 指向第12个捕获性分组匹配到的内容,目前非IE等现代浏览器都是这样解析正则的。

有关反向引用,其他非JavaScript语言中,还没有仔细测试,猜测跟现代浏览器的处理方式一致。为避免各语言或者浏览器解析不一致,因此建议反向引用不要索引大于9的捕获性分组。不仅如此,反向引用如果不存在,正则将会匹配失败。如下:

"abcaabcabbcabcc".match(/([abc])\2/g); // null

由于不存在第二个捕获性分组,因此反向引用子表达式\2匹配失败,进而整个表达式匹配失败。

反向引用常用来匹配重复出现的字符串,而不是重复出现的子表达式,这点要尤为注意。因此如果想要匹配4个或2个数字的话,使用如下正则表达式是万万不行的。

"1234567890".match(/(\d){4}|\1{2}/g); // ["1234", "5678", "", "", ""]

以上正则表达式,想用反向引用\1代替前面的捕获性分组\d,这是不可行的。修饰符g表示全文查找,因此前两次匹配到了 “1234” 和 “5678”,此时正则引擎的指针处于数字8的后面,再往后匹配显然子表达式(\d){4}无法匹配了,此时第一个捕获组匹配到空字符串,那么反向引用\1将指向空字符串,也就是一个位置(有些类似后面将要讲到的零宽断言),对于”890”子字符串,8、9或0后面共有3个位置可供反向引用匹配,故最终又匹配到三个空字符串。正确的正则表达式如下:

"1234567890".match(/\d{4}|\d{2}/g); // ["1234", "5678", "90"]

只能重复写一次子表达式\d

非捕获性分组

非捕获性分组, 通常由一对括号加上”?:”加上子表达式组成, 非捕获性分组不会创建反向引用, 就好像没有括号一样. 如下:

var color = "#808080";
var output = color.replace(/#(?:\d+)/,"$1"+"~~");
console.log(RegExp.$1);//""
console.log(output);//$1~~

以上, (?:\d+) 表示一个非捕获性分组, 由于分组不捕获任何内容, 所以, RegExp.$1 就指向了空字符串.

同时, 由于$1 的反向引用不存在, 因此最终它被当成了普通字符串进行替换.

实际上, 捕获性分组和无捕获性分组在搜索效率方面也没什么不同, 没有哪一个比另一个更快.

命名分组

语法: (?…)

命名分组也是捕获性分组, 它将匹配的字符串捕获到一个组名称或编号名称中, 在获得匹配结果后, 可通过分组名进行获取. 如下是一个python的命名分组的例子.

import re
data = "#808080"
regExp = r"#(?P<one>\d+)"
replaceString = "\g<one>" + "~~"
print re.sub(regExp,replaceString,data) # 808080~~

python的命名分组表达式与标准格式相比, 在 ? 后多了一大写的 P 字符, 并且python通过“\g<命名>”表示法进行引用. (如果是捕获性分组, python通过”\g<编号>”表示法进行引用)

与python不同的是, javaScript 中并不支持命名分组.

固化分组

固化分组, 又叫原子组.

语法: (?>…)

如上所述, 我们在使用非贪婪模式时, 匹配过程中可能会进行多次的回溯, 回溯越多, 正则表达式的运行效率就越低. 而固化分组就是用来减少回溯次数的.

实际上, 固化分组(?>…)的匹配与正常的匹配并无分别, 它并不会改变匹配结果. 唯一的不同就是: 固化分组匹配结束时, 它匹配到的文本已经固化为一个单元, 只能作为整体而保留或放弃, 括号内的子表达式中未尝试过的备用状态都会被放弃, 所以回溯永远也不能选择其中的状态(因此不能参与回溯). 下面我们来通过一个例子更好地理解固化分组.

假如要处理一批数据, 原格式为 123.456, 因为浮点数显示问题, 部分数据格式会变为123.456000000789这种, 现要求只保留小数点后2~3位, 但是最后一位不能为0, 那么这个正则怎么写呢?

var str = "123.456000000789";
str = str.replace(/(\.\d\d[1-9]?)\d*/,"$1"); //123.456

以上的正则, 对于”123.456” 这种格式的数据, 将白白处理一遍. 为了提高效率, 我们将正则最后的一个”*”改为”+”. 如下:

var str = "123.456";
str = str.replace(/(\.\d\d[1-9]?)\d+/,"$1"); //123.45

此时, “\d\d[1-9]?” 子表达式, 匹配是 “45”, 而不是 “456”, 这是因为正则末尾使用了”+”, 表示末尾至少要匹配一个数字, 因此末尾的子表达式”\d+” 匹配到了 “6”. 显然 “123.45” 不是我们期望的匹配结果, 那我们应该怎么做呢? 能否让 “[1-9]?” 一旦匹配成功, 便不再进行回溯, 这里就要用到我们上面说的固化分组.

“(\.\d\d(?>[1-9]?))\d+” 便是上述正则的固化分组形式. 由于字符串 “123.456” 不满足该固化分组的正则, 所以, 匹配会失败, 符合我们期望.

下面我们来分析下固化分组的正则 (\.\d\d(?>[1-9]?))\d+ 为什么匹配不到字符串”123.456”.

很明显, 对于上述固化分组, 只存在两种匹配结果.

情况①: 若 [1-9] 匹配失败, 正则会返回 ? 留下的备用状态. 然后匹配脱离固化分组, 继续前进到[\d+]. 当控制权离开固化分组时, 没有备用状态需要放弃(因固化分组中根本没有创建任何备用状态).

情况②: 若 [1-9] 匹配成功, 匹配脱离固化分组之后, ? 保存的备用状态仍然存在, 但是, 由于它属于已经结束的固化分组, 所以会被抛弃.

对于字符串 “123.456”, 由于 [1-9] 能够匹配成功, 所以它符合情况②. 下面我们来还原情况②的执行现场.

  1. 匹配所处的状态: 匹配已经走到了 “6” 的位置, 匹配将继续前进;==>
  2. 子表达式 \d+ 发现无法匹配, 正则引擎便尝试回溯;==>
  3. 查看是否存在备用状态以供回溯?==>
  4. “?” 保存的备用状态属于已经结束的固化分组, 所以该备用状态会被放弃;==>
  5. 此时固化分组匹配到的 “6”, 便不能用于正则引擎的回溯;==>
  6. 尝试回溯失败;==>
  7. 正则匹配失败.==>
  8. 文本 “123.456” 没有被正则表达式匹配上, 符合预期.

相应的流程图如下:

正则表达式流程图

遗憾的是, javaScript, java 和 python中并不支持固化分组的语法, 不过, 它在php和.NET中表现良好. 下面提供了一个php版的固化分组形式的正则表达式, 以供尝试.

$str = "123.456";
echo preg_replace("/(\.\d\d(?>[1-9]?))\d+/","\\1",$str); //固化分组

不仅如此, php还提供了占有量词优先的语法. 如下:

$str = "123.456";
echo preg_replace("/(\.\d\d[1-9]?+)\d+/","\\1",$str); //占有量词优先

虽然java不支持固化分组的语法, 但java也提供了占有量词优先的语法, 同样能够避免正则回溯. 如下:

String str = "123.456";
System.out.println(str.replaceAll("(\\.\\d\\d[1-9]?+)\\d+", "$1"));// 123.456

值得注意的是: java中 replaceAll 方法需要转义反斜杠.

正则表达式高阶技能-零宽断言

如果说正则分组是写轮眼, 那么零宽断言就是万花筒写轮眼终极奥义-须佐能乎(这里借火影忍术打个比方). 合理地使用零宽断言, 能够能分组之不能, 极大地增强正则匹配能力, 它甚至可以帮助你在匹配条件非常模糊的情况下快速地定位文本.

零宽断言, 又叫环视. 环视只进行子表达式的匹配, 匹配到的内容不保存到最终的匹配结果, 由于匹配是零宽度的, 故最终匹配到的只是一个位置.

环视按照方向划分, 有顺序和逆序两种(也叫前瞻和后瞻), 按照是否匹配有肯定和否定两种, 组合之, 便有4种环视. 4种环视并不复杂, 如下便是它们的描述.

字符 描述 示例
(?:pattern) 非捕获性分组, 匹配pattern的位置, 但不捕获匹配结果.也就是说不创建反向引用, 就好像没有括号一样. ‘abcd(?:e)匹配’abcde
(?=pattern) 顺序肯定环视, 匹配后面是pattern 的位置, 不捕获匹配结果. ‘Windows (?=2000)’匹配 “Windows2000” 中的 “Windows”; 不匹配 “Windows3.1” 中的 “Windows”
(?!pattern) 顺序否定环视, 匹配后面不是 pattern 的位置, 不捕获匹配结果. ‘Windows (?!2000)’匹配 “Windows3.1” 中的 “Windows”; 不匹配 “Windows2000” 中的 “Windows”
(?<=pattern) 逆序肯定环视, 匹配前面是 pattern 的位置, 不捕获匹配结果. ‘(?<=Office)2000’匹配 “ Office2000” 中的 “2000”; 不匹配 “Windows2000” 中的 “2000”
(?<!pattern) 逆序否定环视, 匹配前面不是 pattern 的位置, 不捕获匹配结果. ‘(?<!Office)2000’匹配 “ Windows2000” 中的 “2000”; 不匹配 “ Office2000” 中的 “2000”

非捕获性分组由于结构与环视相似, 故列在表中, 以做对比. 以上4种环视中, 目前 javaScript 中只支持前两种, 也就是只支持 顺序肯定环视顺序否定环视. 下面我们通过实例来帮助理解下:

var str = "123abc789",s;
//没有使用环视,abc直接被替换
s = str.replace(/abc/,456);
console.log(s); //123456789

//使用了顺序肯定环视,捕获到了a前面的位置,所以abc没有被替换,只是将3替换成了3456
s = str.replace(/3(?=abc)/,3456);
console.log(s); //123456abc789

//使用了顺序否定环视,由于3后面跟着abc,不满意条件,故捕获失败,所以原字符串没有被替换
s = str.replace(/3(?!abc)/,3456);
console.log(s); //123abc789

下面通过python来演示下 逆序肯定环视逆序否定环视 的用法.

import re
data = "123abc789"
# 使用了逆序肯定环视,替换左边为123的连续的小写英文字母,匹配成功,故abc被替换为456
regExp = r"(?<=123)[a-z]+"
replaceString = "456"
print re.sub(regExp,replaceString,data) # 123456789

# 使用了逆序否定环视,由于英文字母左侧不能为123,故子表达式[a-z]+捕获到bc,最终bc被替换为456
regExp = r"(?<!123)[a-z]+"
replaceString = "456"
print re.sub(regExp,replaceString,data) # 123a456789

需要注意的是: python 和 perl 语言中的 逆序环视 的子表达式只能使用定长的文本. 比如将上述 “(?<=123)” (逆序肯定环视)子表达式写成 “(?<=[0-9]+)”, python解释器将会报错: “error: look-behind requires fixed-width pattern”.

场景回顾

获取html片段

假如现在, js 通过 ajax 获取到一段 html 代码如下:

var responseText = "<div data='dev.xxx.txt'></div><img src='dev.xxx.png' />";

现我们需要替换img标签的src 属性中的 “dev”字符串 为 “test” 字符串.

① 由于上述 responseText 字符串中包含至少两个子字符串 “dev”, 显然不能直接 replace 字符串 “dev”为 “test”.

② 同时由于 js 中不支持逆序环视, 我们也不能在正则中判断前缀为 “src=’”, 然后再替换”dev”.

③ 我们注意到 img 标签的 src 属性以 “.png” 结尾, 基于此, 就可以使用顺序肯定环视. 如下:

var reg = /dev(?=[^']*png)/; //为了防止匹配到第一个dev, 通配符前面需要排除单引号或者是尖括号
var str = responseText.replace(reg,"test");
console.log(str);//<div data='dev.xxx'></div><img src='test.xxx.png' />

当然, 以上不止顺序肯定环视一种解法, 捕获性分组同样可以做到. 那么环视高级在哪里呢? 环视高级的地方就在于它通过一次捕获就可以定位到一个位置, 对于复杂的文本替换场景, 常有奇效, 而分组则需要更多的操作. 请往下看.

千位分割符

千位分隔符, 顾名思义, 就是数字中的逗号. 参考西方的习惯, 数字之中加入一个符号, 避免因数字太长难以直观的看出它的值. 故而数字之中, 每隔三位添加一个逗号, 即千位分隔符.

那么怎么将一串数字转化为千位分隔符形式呢?

var str = "1234567890";
(+str).toLocaleString();//"1,234,567,890"

如上, toLocaleString() 返回当前对象的”本地化”字符串形式.

  • 如果该对象是Number类型, 那么将返回该数值的按照特定符号分割的字符串形式.
  • 如果该对象是Array类型, 那么先将数组中的每项转化为字符串, 然后将这些字符串以指定分隔符连接起来并返回.

toLocaleString 方法特殊, 有本地化特性, 对于天朝, 默认的分隔符是英文逗号. 因此使用它恰好可以将数值转化为千位分隔符形式的字符串. 如果考虑到国际化, 以上方法就有可能会失效了.

我们尝试使用环视来处理下.

function thousand(str){
  return str.replace(/(?!^)(?=([0-9]{3})+$)/g,',');
}
console.log(thousand(str));//"1,234,567,890"
console.log(thousand("123456"));//"123,456"
console.log(thousand("1234567879876543210"));//"1,234,567,879,876,543,210"

上述使用到的正则分为两块. (?!^)(?=([0-9]{3})+$). 我们先来看后面的部分, 然后逐步分析之.

  1. “[0-9]{3}” 表示连续3位数字.
  2. “([0-9]{3})+” 表示连续3位数字至少出现一次或更多次.
  3. “([0-9]{3})+$” 表示连续3的正整数倍的数字, 直到字符串末尾.
  4. 那么 (?=([0-9]{3})+$) 就表示匹配一个零宽度的位置, 并且从这个位置到字符串末尾, 中间拥有3的正整数倍的数字.
  5. 正则表达式使用全局匹配g, 表示匹配到一个位置后, 它会继续匹配, 直至匹配不到.
  6. 将这个位置替换为逗号, 实际上就是每3位数字添加一个逗号.
  7. 当然对于字符串”123456”这种刚好拥有3的正整数倍的数字的, 当然不能在1前面添加逗号. 那么使用 (?!^) 就指定了这个替换的位置不能为起始位置.

千位分隔符实例, 展示了环视的强大, 一步到位.

正则表达式在JS中的应用

ES6对正则的扩展

ES6对正则扩展了又两种修饰符(其他语言可能不支持):

  • y (粘连sticky修饰符), 与g类似, 也是全局匹配, 并且下一次匹配都是从上一次匹配成功的下一个位置开始, 不同之处在于, g修饰符只要剩余位置中存在匹配即可, 而y修饰符确保匹配必须从剩余的第一个位置开始.
var s = "abc_ab_a";
var r1 = /[a-z]+/g;
var r2 = /[a-z]+/y;
console.log(r1.exec(s),r1.lastIndex); // ["abc", index: 0, input: "abc_ab_a"] 3
console.log(r2.exec(s),r2.lastIndex); // ["abc", index: 0, input: "abc_ab_a"] 3

console.log(r1.exec(s),r1.lastIndex); // ["ab", index: 4, input: "abc_ab_a"] 6
console.log(r2.exec(s),r2.lastIndex); // null 0

如上, 由于第二次匹配的开始位置是下标3, 对应的字符串是 “_”, 而使用y修饰符的正则对象r2, 需要从剩余的第一个位置开始, 所以匹配失败, 返回null.

正则对象的 sticky 属性, 表示是否设置了y修饰符. 这点将会在后面讲到.

  • u 修饰符, 提供了对正则表达式添加4字节码点的支持. 比如 “𝌆” 字符是一个4字节字符, 直接使用正则匹配将会失败, 而使用u修饰符后, 将会等到正确的结果.
var s = "𝌆";
console.log(/^.$/.test(s));//false
console.log(/^.$/u.test(s));//true
UCS-2字节码

有关字节码点, 稍微提下. javaScript 只能处理UCS-2编码(js于1995年5月被Brendan Eich花费10天设计出来, 比1996年7月发布的编码规范UTF-16早了一年多, 当时只有UCS-2可选). 由于UCS-2先天不足, 造成了所有字符在js中都是2个字节. 如果是4个字节的字符, 将会默认被当作两个双字节字符处理. 因此 js 的字符处理函数都会受到限制, 无法返回正确结果. 如下:

var s = "𝌆";
console.log(s == "\uD834\uDF06");//true 𝌆相当于UTF-16中的0xD834DF06
console.log(s.length);//2 长度为2, 表示这是4字节字符

幸运的是, ES6可以自动识别4字节的字符.因此遍历字符串可以直接使用for of循环. 同时, js中如果直接使用码点表示Unicode字符, 对于4字节字符, ES5里是没办法识别的. 为此ES6修复了这个问题, 只需将码点放在大括号内即可.

console.log(s === "\u1D306");//false   ES5无法识别𝌆
console.log(s === "\u{1D306}");//true  ES6可以借助大括号识别𝌆
附: ES6新增的处理4字节码的函数
  • String.fromCodePoint():从Unicode码点返回对应字符
  • String.prototype.codePointAt():从字符返回对应的码点
  • String.prototype.at():返回字符串给定位置的字符

有关js中的unicode字符集, 请参考阮一峰老师的 Unicode与JavaScript详解.

以上是ES6对正则的扩展. 另一个方面, 从方法上看, javaScript 中与正则表达式有关的方法有:

方法名 compile test exec match search replace split
所属对象 RegExp RegExp RegExp String String String String

由上, 一共有7个与js相关的方法, 这些方法分别来自于 RegExp 与 String 对象. 首先我们先来看看js中的正则类 RegExp.

RegExp

RegExp 对象表示正则表达式, 主要用于对字符串执行模式匹配.

语法: new RegExp(pattern[, flags])

参数 pattern 是一个字符串, 指定了正则表达式字符串或其他的正则表达式对象.

参数 flags 是一个可选的字符串, 包含属性 “g”、”i” 和 “m”, 分别用于指定全局匹配、区分大小写的匹配和多行匹配. 如果pattern 是正则表达式, 而不是字符串, 则必须省略该参数.

var pattern = "[0-9]";
var reg = new RegExp(pattern,"g");
// 上述创建正则表达式对象,可以用对象字面量形式代替,也推荐下面这种
var reg = /[0-9]/g;

以上, 通过对象字面量和构造函数创建正则表达式, 有个小插曲.

“对于正则表达式的直接量, ECMAscript 3规定在每次它时都会返回同一个RegExp对象, 因此用直接量创建的正则表达式的会共享一个实例. 直到ECMAScript 5才规定每次返回不同的实例.”

所以, 现在我们基本不用担心这个问题, 只需要注意在低版本的非IE浏览器中尽量使用构造函数创建正则(这点上, IE一直遵守ES5规定, 其他浏览器的低级版本遵循ES3规定).

RegExp 实例对象包含如下属性:

实例属性 描述
global 是否包含全局标志(true/false)
ignoreCase 是否包含区分大小写标志(true/false)
multiline 是否包含多行标志(true/false)
source 返回创建RegExp对象实例时指定的表达式文本字符串形式
lastIndex 表示原字符串中匹配的字符串末尾的后一个位置, 默认为0
flags(ES6) 返回正则表达式的修饰符
sticky(ES6) 是否设置了y(粘连)修饰符(true/false)
compile

compile 方法用于在执行过程中改变和重新编译正则表达式.

语法: compile(pattern[, flags])

参数介绍请参考上述 RegExp 构造器. 用法如下:

var reg = new RegExp("abc", "gi"); 
var reg2 = reg.compile("new abc", "g");
console.log(reg);// /new abc/g
console.log(reg2);// undefined

可见 compile 方法会改变原正则表达式对象, 并重新编译, 而且它的返回值为空.

test

test 方法用于检测一个字符串是否匹配某个正则规则, 只要是字符串中含有与正则规则匹配的文本, 该方法就返回true, 否则返回 false.

语法: test(string), 用法如下:

console.log(/[0-9]+/.test("abc123"));//true
console.log(/[0-9]+/.test("abc"));//false

以上, 字符串”abc123” 包含数字, 故 test 方法返回 true; 而 字符串”abc” 不包含数字, 故返回 false.

如果需要使用 test 方法测试字符串是否完成匹配某个正则规则, 那么可以在正则表达式里增加开始(^)和结束($)元字符. 如下:

console.log(/^[0-9]+$/.test("abc123"));//false

以上, 由于字符串”abc123” 并非以数字开始, 也并非以数字结束, 故 test 方法返回false.

实际上, 如果正则表达式带有全局标志(带有参数g)时, test 方法还受正则对象的lastIndex属性影响,如下:

var reg = /[a-z]+/;//正则不带全局标志
console.log(reg.test("abc"));//true
console.log(reg.test("de"));//true

var reg = /[a-z]+/g;//正则带有全局标志g
console.log(reg.test("abc"));//true
console.log(reg.lastIndex);//3, 下次运行test时,将从索引为3的位置开始查找
console.log(reg.test("de"));//false

该影响将在exec 方法讲解中予以分析.

exec

exec 方法用于检测字符串对正则表达式的匹配, 如果找到了匹配的文本, 则返回一个结果数组, 否则返回null.

语法: exec(string)

exec 方法返回的数组中包含两个额外的属性, index 和 input. 并且该数组具有如下特点:

  • 第 0 个项表示正则表达式捕获的文本
  • 第 1~n 项表示第 1~n 个反向引用, 依次指向第 1~n 个分组捕获的文本, 可以使用RegExp.$ + “编号1~n” 依次获取分组中的文本
  • index 表示匹配字符串的初始位置
  • input 表示正在检索的字符串

无论正则表达式有无全局标示”g”, exec 的表现都相同. 但正则表达式对象的表现却有些不同. 下面我们来详细说明下正则表达式对象的表现都有哪些不同.

假设正则表达式对象为 reg , 检测的字符为 string , reg.exec(string) 返回值为 array.

若 reg 包含全局标示”g”, 那么 reg.lastIndex 属性表示原字符串中匹配的字符串末尾的后一个位置, 即下次匹配开始的位置, 此时 reg.lastIndex == array.index(匹配开始的位置) + array[0].length(匹配字符串的长度). 如下:

var reg = /([a-z]+)/gi,
    string = "World Internet Conference";
var array = reg.exec(string);
console.log(array);//["World", "World", index: 0, input: "World Internet Conference"]
console.log(RegExp.$1);//World
console.log(reg.lastIndex);//5, 刚好等于 array.index + array[0].length

随着检索继续, array.index 的值将往后递增, 也就是说, reg.lastIndex 的值也会同步往后递增. 因此, 我们也可以通过反复调用 exec 方法来遍历字符串中所有的匹配文本. 直到 exec 方法再也匹配不到文本时, 它将返回 null, 并把 reg.lastIndex 属性重置为 0.

接着上述例子, 我们继续执行代码, 看看上面说的对不对, 如下所示:

array = reg.exec(string);
console.log(array);//["Internet", "Internet", index: 6, input: "World Internet Conference"]
console.log(reg.lastIndex);//14

array = reg.exec(string);
console.log(array);//["Conference", "Conference", index: 15, input: "World Internet Conference"]
console.log(reg.lastIndex);//25

array = reg.exec(string);
console.log(array);//null
console.log(reg.lastIndex);//0

以上代码中, 随着反复调用 exec 方法, reg.lastIndex 属性最终被重置为 0.

问题回顾

在 test 方法的讲解中, 我们留下了一个问题. 如果正则表达式带有全局标志g, 以上 test 方法的执行结果将受 reg.lastIndex影响, 不仅如此, exec 方法也一样. 由于 reg.lastIndex 的值并不总是为零, 并且它决定了下次匹配开始的位置, 如果在一个字符串中完成了一次匹配之后要开始检索新的字符串, 那就必须要手动地把 lastIndex 属性重置为 0. 避免出现下面这种错误:

var reg = /[0-9]+/g,
    str1 = "123abc",
    str2 = "123456";
reg.exec(str1);
console.log(reg.lastIndex);//3
var array = reg.exec(str2);
console.log(array);//["456", index: 3, input: "123456"]

以上代码, 正确执行结果应该是 “123456”, 因此建议在第二次执行 exec 方法前, 增加一句 “reg.lastIndex = 0;”.

若 reg 不包含全局标示”g”, 那么 exec 方法的执行结果(array)将与 string.match(reg) 方法执行结果完全相同.

String

match, search, replace, split 方法请参考 字符串常用方法 中的讲解.

如下展示了使用捕获性分组处理文本模板, 最终生成完整字符串的过程:

var tmp = "An ${a} a ${b} keeps the ${c} away";
var obj = {
  a:"apple",
  b:"day",
  c:"doctor"
};
function tmpl(t,o){
  return t.replace(/\${(.)}/g,function(m,p){
    console.log('m:'+m+' p:'+p);
    return o[p];
  });
}
tmpl(tmp,obj);

上述功能使用ES6可这么实现:

var obj = {
  a:"apple",
  b:"day",
  c:"doctor"
};
with(obj){
  console.log(`An ${a} a ${b} keeps the ${c} away`);
}

正则表达式在H5中的应用

H5中新增了 pattern 属性, 规定了用于验证输入字段的模式, pattern的模式匹配支持正则表达式的书写方式. 默认 pattern 属性是全部匹配, 即无论正则表达式中有无 “^”, “$” 元字符, 它都是匹配所有文本.

注: pattern 适用于以下 input 类型:text, search, url, telephone, email 以及 password. 如果需要取消表单验证, 在form标签上增加 novalidate 属性即可.

正则引擎

目前正则引擎有两种, DFA 和 NFA, NFA又可以分为传统型NFA和POSIX NFA.

  • DFA Deterministic finite automaton 确定型有穷自动机
  • NFA Non-deterministic finite automaton 非确定型有穷自动机
  • Traditional NFA
  • POSIX NFA

DFA引擎不支持回溯, 匹配快速, 并且不支持捕获组, 因此也就不支持反向引用. 上述awk, egrep命令均支持 DFA引擎.

POSIX NFA主要指符合POSIX标准的NFA引擎, 像 javaScript, java, php, python, c#等语言均实现了NFA引擎.

有关正则表达式详细的匹配原理, 暂时没在网上看到适合的文章, 建议选读 Jeffrey Friedl 的 <精通正则表达式>[第三版] 中第4章-表达式的匹配原理(p143-p183), Jeffrey Friedl 对正则表达式有着深刻的理解, 相信他能够帮助您更好的学习正则.

有关NFA引擎的简单实现, 可以参考文章 基于ε-NFA的正则表达式引擎 - twoon.

总结

在学习正则的初级阶段, 重在理解 ①贪婪与非贪婪模式, ②分组, ③捕获性与非捕获性分组, ④命名分组, ⑤固化分组, 体会设计的精妙之处. 而高级阶段, 主要在于熟练运用⑥零宽断言(或环视)解决问题, 并且熟悉正则匹配的原理.

实际上, 正则在 javaScript 中的功能不算强大, js 仅仅支持了①贪婪与非贪婪模式, ②分组, ③捕获性与非捕获性分组 以及 ⑥零宽断言中的顺序环视. 如果再稍微熟悉些 js 中7种与正则有关的方法(compile, test, exec, match, search, replace, split), 那么处理文本或字符串将游刃有余.

正则表达式, 在文本处理方面天赋异禀, 它的功能十分强大, 很多时候甚至是唯一解决方案. 正则不局限于js, 当下热门的编辑器(比如Sublime, Atom) 以及 IDE(比如WebStorm, IntelliJ IDEA) 都支持它. 您甚至可以在任何时候任何语言中, 尝试使用正则解决问题, 也许之前不能解决的问题, 现在可以轻松的解决.

附其他语言正则资料:


本文就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文简介: 本文断断续续历时两个月而成, 共计1.3W字, 为求简洁全面地还原前端场景中正则的使用规律, 搜集了大量正则相关资料, 并剔除不少冗余字句, 码字不易, 喜欢的请点个赞👍或者收藏, 我将持续保持更新.

本文链接: http://louiszhai.github.io/2016/06/13/regexp/

参考文章

]]>
regexp 正则表达式 反向引用 贪婪模式 非贪婪模式 元字符 字符组 限定符 操作符 ES6扩展 固化分组 占有量词优先 环视 顺序环视 逆序环视
详解IE7以下独有的hasLayout http://louiszhai.github.io/2016/03/31/css-hasLayout/ 2016-03-31T05:57:56.000Z 2019-12-02T03:28:32.879Z 什么是hasLayout

hasLayout property: Gets a value that indicates whether the object has layout.

hasLayout 是IE渲染引擎的一个内部实现. IE中, 一个元素要么自己计算大小组织内容(自己渲染), 要么依赖父元素来计算大小和组织内容(依赖祖先元素渲染). 为了区分两者, 渲染引擎采用了 hasLayout 属性, 该属性可以设置为 true 或 false. 若一个元素的 hasLayout 属性值为 true 时, 这个元素便拥有了一个布局(layout), 该元素便不在依赖某个祖先元素进行渲染, 而是它自己就去渲染自己了, 它会负责对自己和可能的子孙元素进行尺寸计算和定位, 这意味着这个元素需要花更多的代价来维护自身和里面的内容; 相反的, 若元素的 hasLayout 属性值为 false时, 它会直接依赖于某个祖先元素来完成这些工作, 最终造成很多的IE bugs.

默认情况下hasLayout=true的元素

下列元素默认拥有 layout:

  • html body
  • table tr th td
  • img
  • hr
  • input button file select textarea fieldset
  • marquee
  • frameset frame iframe
  • objects applets embed

怎么触发及清除 hasLayout

以下css样式的设置, 会触发元素的 hasLayout:

  • position: absolute(IE5+)
  • float: left|right(IE5+)
  • display: inline-block(IE5+)
  • width|height: “auto”以外的任何值(IE5+; 只对block元素有效)
  • zoom: “normal”以外的任何值(IE5.5+; IE私有属性)
  • writing-mode: tb-rl(IE5+; IE私有属性)
  • overflow: hidden|scroll|auto(IE7; 此属性在IE6及更早版本中不能应用在未触发hasLayout的元素上)
  • overflow-x|-y: hidden|scroll|auto(IE7; 此属性在IE6及更早版本中不触发hasLayout; 此属性在CSS3中才获支持)
  • position: fixed(IE7)
  • min-width: 任何值(IE7; 即使是0)
  • max-width: “none”以外的任何值(IE7)
  • min-height: 任何值(IE7)
  • max-height: “none”以外的任何值(IE7)
  • position: fixed(IE7)

以下css样式的设置, 会清除已经触发的 hasLayout:

  • position: static(IE5+)
  • float: none(IE5+)
  • display: “inline-block”以外的任何值(IE5+)
  • width|height: “auto”(IE5+; 对inline元素无效)
  • zoom: “normal”(IE5.5+; IE私有属性)
  • writing-mode: 从’tb-rl’到’lr-tb’(IE5+; IE私有属性)
  • max-width|max-height: “none”(IE7)
  • overflow: visible(IE7)

hasLayout 的影响

  1. 浮动元素可以被 layout 元素自动包含. 一般情况下, 由于浮动元素脱离普通文档流会造成父元素的坍塌. 但是在IE6-7下, 通过触发父元素的 hasLayout, 可以使得父元素自动包含浮动的子元素, 从而修复坍塌问题. 一般我们设置父元素的 *height:1%; 即可, 1%并不会改变实际高度, 只是触发了 hasLayout, 该方法被称为霍莉破解(Holly hack), 需要注意的是, 当这个元素的 overflow 属性被设置为 visible 时, 这个方法就失效了.
  2. 正常情况下, 浮动元素旁边的元素, 其内容应该环绕该浮动元素. 如果这个元素拥有 layout, 那么这个元素就会表现为一个矩形, 其内容不会环绕浮动元素.
  3. IE独有的滤镜属性(filter) 只适用于 layout 元素, 若一个div 设置了filter:alpha(opacity=90), 又没有触发该div 的 hasLayout, 那么透明的设置将无效.
  4. hasLayout 影响块级元素鼠标的响应区域, 通常 hasLayout=false时, 只有文字区域才有响应, 而 hasLayout=true 时, 整个块级元素都是可以响应的.

hasLayout 引起的bug

  1. IE6 及更低版本的双空白边浮动 bug, 修复方案: display:inline;
  2. IE5-6的 3 像素偏移 bug, 修复方案: _height:1%;
  3. IE6 的躲躲猫(peek-a-boo) bug, 修复方案: _height:1%;
  4. IE6-7负margin隐藏Bug, 修复方案: 去掉父元素的hasLayout; 或者赋hasLayout给子元素, 并添加position:relative;

怎么检测IE下的某个元素是否拥有hasLayout

在 IE Developer Toolbar 下, 拥有 haslayout的元素, 通常显示为 “haslayout = -1”. 也可通过js的方式检测, 如下所示:

var element = document.getElementById("myDiv");
console.log(element.currentStyle.hasLayout);//该方式只能获取值, 而不能设置

以下代码可用于在IE6-7下测试某个元素是否拥有hasLayout:

Code example: http://samples.msdn.microsoft.com/workshop/samples/author/dhtml/refs/hasLayout.htm

<!DOCTYPE html> 
<html> 
<head> 
  <title>hasLayout Property</title> 
</head> 

<body> 
  <h1>hasLayout Property</h1> 
  <p>This example uses the <strong>hasLayout</strong> property of the <strong>currentStyle</strong> object to  
    show that an element has layout when it is absolutely positioned, or when its height and/or width are specified.   
    The <strong>hasLayout</strong> property returns <strong>true</strong> for an object that has layout, and  
    <strong>false</strong> for an object that has no layout.</p> 
  <fieldset style="width: 50%; text-align: center;"> 
    <legend><strong>hasLayout</strong> Property</legend> 
    <p style="text-align: left;"><em>Which DIV element has layout?</em></p> 
    <div id="oWidthSet" style="width: 100%; text-align: left;"><strong>DIV</strong> element A has its <strong>width</strong> set to <strong>100%</strong>.</div> 
    <div id="oNotSet" style="text-align: left;"><strong>DIV</strong> element B is not positioned, and neither its <strong>height</strong> nor <strong>width</strong> is set.</div> 
    <br> 
    <button onclick="document.getElementById('messageBox').textContent = document.getElementById('oWidthSet').currentStyle.hasLayout;">DIV Element A</button> 
    <button onclick="document.getElementById('messageBox').textContent = document.getElementById('oNotSet').currentStyle.hasLayout;">DIV Element B</button> 
  </fieldset> 
  <div id="messageBox" style="padding-top: 1em; font-weight: bold;"></div> 
</body> 
</html>

另外, 若一个元素没有布局(layout), 那么IE下 clientWidth/clientHeight 总是返回0. 基于这点, 可以使用另一种js的方法检测元素是否拥有hasLayout, 如下:

console.log(element.clientHeight==0);//等于true则表示该元素不拥有hasLayout

需要注意的是

  • hasLayout 功能只存在于IE7及低版本的浏览器上, IE8中已删除 hasLayout 功能.
  • hasLayout 触发后, 没有办法直接设置 hasLayout=false, 只有将那些触发 hasLayout 的 css 属性去除, 才能恢复原样.

本文就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文链接: http://louiszhai.github.io/2016/03/31/css-hasLayout/

参考文章

]]>
css IE6 IE7 hasLayout