![]()
对于 JS SDK 而言,适配 IE 内核至少面临 5 大难题:
实际上,以上 5 大难题,并非全部,只是诸多难题中较为突出的部分,下面会重点来讲。
由于IE7、IE8、IE9 内核差异大,适配难度较大,所以我先从 IE9 开始适配,然后是 IE8、IE7。
webpack4 打包的代码,在 IE9 下直接运行,果然,立即报错:提示 symbol 未定义,当然错误不止一个,这里不一一列举。
最终配置 .babelrc 如下所示,代码基本能在IE9上运行。

如上图,transform-es2015-typeof-symbol 插件解决了symbol 未定义的问题。
这里有个问题需要说明下,为什么不使用 babel-polyfill,而使用 transform-runtime,有两点原因:
基于这两点,最终我使用了 transform-runtime 插件。
另外,稍微注意下,IE9 下 console 对象在不开 dev tool 时,并不存在,也要兼容。
代码能在IE9上运行,起码开了个好头。接下来要面对的是第一个大难点:IE9及以下版本,XMLHttpRequest 不支持跨域请求,XDomainRequest 虽然支持跨域,却又不支持 cookie 传递。
解决这个问题之前,不得不提下 xhr 的发展历史。
xhr 一共有两级标准,早在 IE5,微软就支持了 xhr1 标准,直到 IE10,xhr2 标准才得到支持。
xhr1 有如下缺点:
很明显,xhr1 无法支持跨域请求。
xhr2 针对 xhr1 的缺点做了如下改进:
虽然,IE5 - IE9 都不支持 xhr2,但微软并没有闲着,而是在 IE8、IE9 中推出了支持 CORS 请求的 XDomainRequest,很遗憾,这是一个非成熟的解决方案,它有主要有以下限制:
更多限制,不妨参考 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
withCredentialsattribute these steps must be run:
- If the state is not OPENED raise an
INVALID_STATE_ERRexception and terminate these steps.- If the
send()flag is true raise anINVALID_STATE_ERRexception and terminate these steps.- If the anonymous flag is true raise an
INVALID_ACCESS_ERRexception and terminate these steps.- Set the
withCredentialsattribute’s value to the given value.
这意味着,IE11 中,readyState 值为 OPENED 之前(即 open 方法调用前),为 xhr 对象设置 withCredentials 属性就会抛出 INVALID_STATE_ERR 错误。实际开发中,timeout 属性也必须在 open 方法之后调用。
接下来,第二个需要解决的难题是: 从 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 报缺少标识符错误。它的配置如下所示:

重新编译后运行,还是报了缺少标识符的问题。难道没效果?其实不是的。
原来 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 的代码。那么,最终配置如下:

实际调试时,不妨增加 mangle: false 配置,关闭变量名混淆,debug 会更友好,更多配置介绍,请戳 UglifyJs 中文文档 。
到此,我们解决了 IE8 不被 webpack 支持的问题。
很明显,不太可能这么容易在 IE8 下成功运行项目。这里还有两个大坑。
第一点,无论我在 index.js 入口文件中如何添加 bind 方法的垫片,都毫无效果,依然提示 bind 方法不存在。后来终于在编译文件的头部发现了 t.push.bind 的引用,如下。

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

第二点,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 会在如下位置抛出错误。

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

稍微转换下,即 supportsAccessors = Object.prototype.hasOwnProperty("__defineGetter__"),而 __defineGetter__ IE11 才支持,虽然 IE9、IE10 同样不支持它,但它们已经支持 Object.defineProperty 方法,不需要垫片。另外,es-shim 官方文档也提到了使用 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 可能报错的问题。

如上图,具体思路就是,先判断是否支持访问器属性,不支持就忽略非 DOM 元素的访问器属性设置,只保证赋值成功,避免了报错导致执行中断。
到这里,webpack 不支持 IE8 的问题,基本解决了,接下来就需要与 IE8、IE7 斗智斗勇了。
对于 IE8 & IE7 而言,由于不遵循 w3c 规范,不支持 ECMAScript 5.1,导致有很多 API 实现与标准不一致或没有实现,以下是 SDK 涉及到的一些缺陷介绍。(如未特别说明,即 IE8、IE7 都适用)
另外,IE8 下,document.head 也没有,需要做如下兼容。

只有全局事件,事件没有target属性,兼容如 e = e || window.Event;target = e.target || e.srcElement 。
事件回调内部 this 执行 window。
不支持 addEventListener API,可由 attachEvent 替代。

IE7,不支持 stopPropagation、preventDefault 方法。



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

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

IE7 setAttribute 支持性也有问题




上图中,颜色 “#19000000” 由两部分组成。
第一部是 # 号后面的 19 是 rgba 透明度 0.1 的 IEfilter 值。从 0.1 到 0.9 每个数字对应一个 IEfilter 值。
第二部分是 19 后面的六位。这个是六进制的颜色值,跟 rgb 函数中的取值一致。比如 rgb(0,0,0) 对应 #000000,也就是黑色。
rgba 透明度与 IEfilter 的对应关系如下所示。

除了透明度外,CSS 动画也需要降级为 gif 实现,如果是 loading 动画,可以使用 loading.io 无缝转换。
到这,IE8、IE7 的兼容性问题基本解决,接下来只需要让选择器引擎支持到 IE7,改造便可完成。
大家都知道,jquery 提供了非常棒的选择器引擎 sizzle,但它的 min 版本达到了 20 k,有点太大了。
对于 JS SDK 而言, 它不但内部使用了大量选择器,同时还需要将选择器的功能开放给第三方开发者,使其能在不使用 jquery、zepto 等类库的情况下,快速完成基本的 DOM 操作,所以搭载一款轻便的选择器引擎很有必要。
一个简单的选择器引擎,至少具备以下 4 点功能。
$('#div')$ 实例,如 $(element);$ 实例,如 $('<div></div>')$ 实例对象的原型方法,如 $('#div').html('hello world')在如今,querySelectorAll 深入人心的背景下,实现一个选择器引擎,并不难,这里我参考的是 balajs,它主要代码如下所示。

balajs 有如下 2 个问题:
为了解决这 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 暂时用不到)。

首先奉上 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 个方面:
我本地安装的是 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适配问题,欢迎在评论区留言继续交流,谢谢。
]]>那么,到底是什么拖慢了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 次的运行截图。

数据分析如下(单位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 在大幅度提升编译效率同时,引入了多种新特性:
受 Parcel 启发,支持 0 配置启动项目,不再强制需要 webpack.config.js 配置文件,默认入口 ./src/ 目录,默认entry ./src/index.js ,默认输出 ./dist 目录,默认输出文件 ./dist/main.js。
开箱即用 WebAssembly,webpack4提供了wasm的支持,现在可以引入和导出任何一个 Webassembly 的模块,也可以写一个loader来引入C++、C和Rust。(注:WebAssembly 模块只能在异步chunks中使用)
提供mode属性,设置为 development 将获得最好的开发体验,设置为 production 将专注项目编译部署,比如说开启 Scope hoisting 和 Tree-shaking 功能。
全新的插件系统,提供了针对插件和钩子的新API,变化如下:
更多插件的工作原理,可以参考:新插件系统如何工作。
首先,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 配置替代了,如下:
不仅如此,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打包首选,相比之前,它有如下优势:
缺陷,不支持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也随之变慢,一定有办法可以进一步压榨性能。
经过很长一段时间的多个项目运行以及测试,以下几点经验非常有效。
缩小编译范围,减少不必要的编译工作,即 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" // 后面会介绍
},
]
}
想要进一步提升编译速度,就要知道瓶颈在哪?通过测试,发现有两个阶段较慢:① babel 等 loaders 解析阶段;② js 压缩阶段。loader 解析稍后会讨论,而 js 压缩是发布编译的最后阶段,通常webpack需要卡好一会,这是因为压缩 JS 需要先将代码解析成 AST 语法树,然后需要根据复杂的规则去分析和处理 AST,最后将 AST 还原成 JS,这个过程涉及到大量计算,因此比较耗时。如下图,编译就看似卡住。

实际上,搭载 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
}
}
}),
]
}
当然,我分别测试了五组数据,如下是截图:

数据分析如下(单位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%。
现在我们来看看,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),运行过程感受下。

另外,像 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 原理解析。
我们都知道,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 插件,如下图。

文件 size 如下图所示:

sideEffects
从 webpack2 开始,tree-shaking 便用来消除无用模块,依赖的是 ES Module 的静态结构,同时通过在. babelrc 文件中设置 "modules": false 来开启无用的模块检测,相对粗暴。webapck4 灵活扩展了无用代码检测方式,主要通过在 package.json 文件中设置 sideEffects: false 来告诉编译器该项目或模块是 pure 的,可以进行无用模块删除,因此,开发公共组件时,可以尝试设置下。
为了使得 tree-shaking 真正生效,引入资源时,仅仅引入需要的组件尤为重要,如下所示:
import { Button, Input } from "element-ui"; // 只引入需要的组件
升级 webpack4 的过程,踩坑是必须的,关键是踩坑后,你能得到什么?
另外,除了文中介绍的一些优化方法,更多的优化策略,正在逐步验证中…
]]>神奇的魔法帽,alfred 初印象。
![]()
首先可以从 alfred官网 自行下载安装,免费用户可以使用除 workflow 以外的其它功能,如需使用 workflow,则需要购买Powerpack。
不建议:自行搜索破解版,或者点个喜欢,留下邮箱找我要…
以前,使用mac查询一个单词,或者翻译一个单词,我们要么经历五步:
要么经历四步:
查询单词这个场景中,我们至少需要兴师动众,切换或打开一个应用两次,定位输入框一次,输入或复制粘贴一次。且查询结果页也会挡住当前的工作区,使得我们分心,甚至忘记自己刚刚在做啥,总之,体验极不流畅。
alfred 工作流正是为了解决这个问题而设计的。使用 有道词典 workflow,最快只需两次按键便可查询单词. 举个栗子🌰:为了查询单词 “workflow”,我会选中单词所在区域,然后按住 Option+Y 键(我已将有道翻译的快捷键设置为 Option+Y),单词查询结果就出来了,不需要切换应用,同时查询结果也较少的挡住工作区。如下所示:

两次按键就能查询单词,这么好的应用为何不用呢?
对于一个刚刚听说alfred的新手来说,迫切想知道的莫过于了解它能做什么?据我所知,公开的 alfred workflow 至少有 500+,有心网友甚至罗列了一张 [表格][http://www.alfredworkflow.com/]来管理它,表格的每一行都解锁了一项 alfred 技能(注意并非所有的 workflow 都支持最新的 alfred 3.6.1版本)。你可以下载并免费使用其中任何一个 workflow,甚至,还可以基于一些不错的 workflow 样本,加入创意,改造成属于自己的 workflow(前提是已获得 powerpack license)。
默认情况下,alfred 至少能胜任 15 项工作:
获得 powerpack license 的 alfred 将获得强大的 workflows 功能,后续将专门讲解。
输入应用名,列出本地安装的所有相关应用,可以快速唤起。

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

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

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

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

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

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

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

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

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

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

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

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

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

如此一来,拷贝多段内容就变得非常容易,借助 alfred,可以在一处连续拷贝,然后另一处连续粘贴,避免了频繁切换应用带来的操作疲劳;同时之前复制过的文本或图片,也不用担心过会找不到。
代码片段搜索,相对 aText 来说,感觉不是特别方便,略过(aText 是 mac 下输入增强工具,输入关键字,自动补全文本)。
iTunes管理使用得不多,略过。
1Password由于未安装,也略过。
通过 alfred 可以快捷地操作系统锁屏、关机、重启、休眠等十几种指令,非常便捷。对于强迫症用户来说,唤起屏保、休眠、清空垃圾篓、退出应用等指令可能较为常用。

通过 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个面板如下所示:
Alt+Space 即可)Appearance 面板除了设置输入窗口的外观外,还有一些外观相关的设置,在这里可以设置默认展示行数等。

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

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

由此可见,几乎我每天都会用 alfred,3年来总计使用3W+次,平均每天使用27.8次,剔除节假日,工作日每天平均使用次数高达40+次,可以说,alfred 极大的方便了我的工作和生活。
基本功能介绍完了,终于,我们要一窥 alfred 的核心功能— workflow。工作流可谓是 alfred 最强大的功能,它是秒杀其他效率应用的核心技术,也是最吸引我的地方。
唯有掌握工作流,mac 才能真正起飞。
欲了解工作流,先从常用的 workflow 开始,下面简单展示一些典型。
ip查询

指定 qq 好友聊天

指定微信好友聊天

印象笔记搜索

百度地图搜索

点评搜索

豆瓣电影搜索

豆瓣书籍搜索

知乎日报

水木清华社区搜索

php api 搜索

jquery api 搜索

快递查询

finder 设置

举例就到这了,另外,这里有我的一些 afred workflows,欢迎试玩。
你可能很好奇,上面这些 workflow,都是怎么开发的呢?别急,稳住慢慢来。
先问一个问题,什么是工作流?
我们都知道,任何微小的工作,都可以拆分成多个步骤,这些步骤顺序相连,依次进行,最终输出成果,有些步骤可能存在多个分支,并且最终输出多个成果。这些步骤依次执行,并且向后传递阶段性信息的流,就是工作流。现实生活中的工作流可能更为复杂,但本质还是如此。正是基于这种现实背景,alfred 从 2.0 版本起加入了 workflow,普通的 workflow 如下所示。

这个工作流包含三个步骤:① 查询单词—> ② 格式化输出—> ③ 复制到剪切板。
yd是唤起该工作流的命令,输入yd,然后空格,接着输入待查询的单词,script Filter便开始执行,最终输出查询结果列表(图片见文章开头例子),至此,工作流的步骤①查询单词部分完成。
我们注意到,图中有两条数据流连线,第一条包含节点,这意味着,节点处需要等待用户操作(点击)才能继续下去。一旦用户点击列表项,后续流程②格式化输出,将直接执行,紧接着其后续流程③复制到剪切板也将顺序执行,最终单词查询结果复制到剪切板,工作流结束。
实际上,上图中包含节点的数据流连线,点击时还可指定相应的辅助键,辅助键可选 none、ctrl、alt、cmd、fn、shift之一,默认为 none,即无须辅助键。指定辅助键的好处在于,不同的辅助键,可以触发不同的后续流程,如上图则只设计一个后续流程(即②格式化输出流程)。设置辅助键的界面如下所示,可以指定相应提示,以及流程执行时是否关闭 alfred 窗口。

是不是跃跃欲试了,来创建第一个 workflow 吧。
首先,打开 alfred preferences 设置界面,选中第三个面板 Workflows。
点击面板底部左侧的 + 按钮,选择 Blank Workflow。

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

于是第一个空的 workflow 创建好了,接下来我们来搭建一个 google 搜索的工作流,通过这个工作流,我们能快速的选中文本然后使用 google 搜索该文本,不妨参考以下步骤。
1)新增热键:右键 - Triggers-Hotkey。
2)热键设置面板中:Hotkey 设置为 Alt+G(快捷键必须以 Ctrl、Alt、Shift 或 Cmd 开始,而 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 搜索的工作流完成。
你还可参考如下图示。

是不是非常简单?到目前为止,完全不需要编程基础。
截止到 v3.6.1 版本,workflow 支持 Triggers、Inputs、Actions、Utilities(alfred3.x新增)、Outputs 共5项主要功能,如下所示。

这5项功能一共包含39个组件。其中
以上,Hotkey、Keyword、Script Filter 是常用的输入组件,Open URL、Run Script 是高频的 Action 组件,Post Notification、Copy to Clipboard 是受欢迎的输出组件,而 Arg and Vars、Filter、Delay、Debug 是贴心的公共组件。
合理搭配相应的组件,我们就能像搭乐高积木一样搭建 workflow。
你可能会说没有编程的 workflow 有什么意思,是的,alfred 除了使用可视化组件,简化搭建 workflow 的难度外,还内置了多种语言支持。我们不需要关心语言之间的交互细节,只需要使用它们接收输入,提供输出,其它事情统统交给 alfred。
目前,我们可以直接使用如下8种语言编写脚本:
你没看错,javascript 也是默认支持的(jser要疯狂了)。除了上述8种语言外,通过bash或zsh,一样可以唤起其它语言,如 java、c、go 等等。
实际上,python 可能是 alfred workflow 中最常用的编程语言,前人编写了大量的 python 脚本,都可以在 alfred 中大放光彩。
请注意,以上编程语言可以在这两个组件中使用:① Inputs -> Script Filter、② Actions -> Run Script。
本文聊了这么多,workflow的优势就不多说了。
很明显,workflow 不是万能的,很多场景,v3.6.1 的 alfred 还覆盖不到。比如说:
当然,可能还有更多更好的 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}&safe=off">
<title>谷歌一下 {query}</title>
<subtitle>副标题</subtitle>
<icon>google-icon.png</icon>
</item>
...
</items>
以上,arg 即往后传递的参数,title 标签内填写标题,subtitle 标签内填写副标题,icon 标签内填写当前选项的图标。然后直接使用 shell 的 echo 打印以上 xml,即可输出以上选项列表。
xml 中如果包含链接,则 & 需要替换为 &。
关于选项列表多次输出&流程间调用:
很多时候,一次输入可能不够,若需要多次输入信息,又该如何实现呢?不妨参考如下两种方案:
选项列表的输出依赖 Inputs -> Script Filter 组件,若流程中包含多次输入,顺序引入多个 Script Filter 组件即可。
若需要唤起 ① 其它分支流程(同一个 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 输出到控制台并不合法。到这,文章就快结束了,从 2015 年 3 月 28 日 接触 alfred 起,我便迷上了它的超强工作流。alfred 几乎可以做任何自动化工作流的事情(只要能用代码描述这个工作流就行),它彻底改变了我对 mac 的认知。此后,我曾多次向团队同学安利并分享它的神奇之处,他们鼓励我开一个在线直播,有偿分享,但对我而言,能写一篇介绍它的文章,几乎是我的荣幸!最后,写得不好的地方欢迎批评斧正,感谢您的阅读!
版权声明:转载需注明作者和出处。
本文作者: louis
]]>对于开发而言,搜索是日常工作,为了提升搜索的效率,以便更快的查询信息,我试着同时搜索4个网站,分别是百度、Google、维基、Bing。一个可行的做法就是网页中嵌入4个iframe,通过js拼接前面4个搜索引擎的Search URL并依次在iframe中加载。这个构思丝毫没有问题,简单粗暴。然而就是这么简单的功能,也无法实现。由于Google网站在HTML的response header中添加了X-Frame-Options字段以防止网页被Frame(这项设置常被用来防止Click Cheats),因此我无法将Google Search加入到iframe中来。那么,我会放弃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。
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官方明确规定了插件、扩展和应用的区别:
不注意区分的话,我们讲到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)添加好的扩展如下所示。

--enable-easy-off-store-extension-install ,用以开启简单的扩展安装模式,然后就能像之前一样随意拖动crx文件到浏览器页面进行安装。说到安装,自然有人会问,安装了某款扩展后,怎么查看该扩展的源码呢?Mac系统的用户请记住这个目录~/Library/Application Support/Google/Chrome/Default/Extensions/(windows的扩展目录暂无)。
另外,中间的打包扩展程序按钮用于将本地开发的扩展程序打包成crx包,首次打包还会生成秘钥文件(如IHeader.pem),如下所示。


打包好的扩展程序,可以发送给其他人安装,或发布到Chrome Web Store(开发者注册费用为5$)。
右边的立即更新扩展程序按钮则用于更新扩展。
通常一个Chrome扩展包含如下资源或目录:
为了方便管理,个人倾向于将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扩展,运行如下所示:

该类扩展特点:全局扩展,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状态切换如下所示。

该类扩展特点:不同页面可以拥有不同的状态和不同的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扩展,运行如下所示:

该类扩展特点:运行在URL地址栏,无弹出界面,用户在输入时,扩展就可以显示建议或者自动完成一些工作。
很明显,你可以对地址栏的各种输入做定制,Chrome的URL地址栏只所以强大,omnibox可谓功不可没。
以上三类决定了扩展如何在浏览器中运行。除此之外,每个扩展程序还可以任意搭载如下页面或脚本。
Background Page,后台页面,可通过manifest.json中的background属性设置,里面再细分script或page,分别表示脚本和页面,如下所示。
"background": {
"persistent": true, // 默认为false,指定为true时将在后台持续运行
"scripts": ["res/js/background.js"] // 指定后台运行的js
// "page": ["res/background.html"] // 指定后台运行的html,html中需引入若干个js
},
Background Page在扩展中之所以重要,主要归功于它可以使用所有的Chrome.* API。借助它popup.js 和 content.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_start、idel或end(分别为页面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。审查弹出内容按钮,将会在打开弹出页面的同时打开它的控制台。这个控制台也可以直接debug。Chrome陆续向开发者开放了大量的API。使用这些API,我们可以监听或代理网络请求,存储数据,管理标签页和Cookie,绑定快捷键、设置右键菜单,添加通知和闹钟,获取CPU、电池、内存、显示器的信息等等(还有很多没有列举出来)。具体请阅读Chrome API官方文档。请注意,使用相应的API,往往需要申请对应的权限,如IHeader申请的权限如下所示。
"permissions": [ "tabs" , "webRequest", "webRequestBlocking", "http://*/*", "https://*/*", "contextMenus", "notifications"]
以上,IHeader依次申请了标签页、请求、请求断点、http网站,https网站,右键菜单,桌面通知的权限。
Chrome Extension API中,能够修改请求的,只有chrome.webRequest了。webRequest能够为请求的不同阶段添加事件监听器,这些事件监听器,可以收集请求的详细信息,甚至修改或取消请求。
事件监听器只在特定阶段触发,它们的触发顺序如下所示。(图片来自MDN)

事件监听器的含义如下所示。
以上,凡是能够修改请求的事件监听器,都能够指定其extraInfoSpec参数数组中包含”blocking”字符串(意味着能阻塞请求并修改),反之则不行。
另外请注意,Chrome对于请求头和响应头的展示有着明确的规定,即控制台中只展示发送出去或刚接收到的字段。因此编辑后的请求字段,控制台的network栏能够正常展示;而编辑后的响应字段由于不属于刚接收到的字段,所以从控制台上就会看不到编辑的痕迹,如同没修改过一样,实际上编辑仍然有效。
事件监听器含义虽不同,但语法却一致。接下来我们就以onHeadersReceived为例,进行深入分析。
还记得我们的目标吗?想要去掉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表示添加一个接收响应头的监听。以上代码中的关键参数或属性,下面逐一讲解。
既然有了添加监听的方法,自然,还会有移除监听的方法。
chrome.webRequest.onHeadersReceived.removeListener(listener);
除此之外,为了避免重复监听,还可以判断监听是否已经存在。
var bool = chrome.webRequest.onHeadersReceived.hasListener(listener);
为了保证更好的理清以上属性、方法或参数的逻辑关系,请看如下脑图:

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

以上,一个请求将依次触发上述①②③④⑤五个事件回调,每个事件回调都对应着一个监听器,这些监听器分为两类(从颜色上也可看出端倪)。
若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图标的管理也是不可避免的。通常,默认的icon可以在manifest.json中指定。
"page_action": {
"default_icon": "res/images/lightning_default.png", // 默认图标
},
icon有如下3种状态(后两种状态可以互相切换)。
Chrome提供了chrome.pageAction的API供Page Action使用。目前chrome.pageAction拥有如下方法。
以上,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将依次经历两种状态:loading 和complete(部分页面会包含favIconUrl或title信息),如下所示。

随着状态管理的逐渐完善,那么,是时候进行消息通信了(不知道你注意到上述代码中出现的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.sendMessage 或 runtime.sendMessage 发送的消息,虽然extension.onMessage 和 runtime.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对象打印如下:

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语法如下:
| 属性 | 类型 | 支持性 | 描述 |
|---|---|---|---|
| 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的状态,可选值为loading或complete |
|
| title | String | tab中页面的标题(需要申请tabs权限) | |
| url | String or Array | tab中页面的链接 | |
| windowId | Number | tab所处窗口的id | |
| windowType | String | tab所处窗口的类型,值包含normal、popup、panel、appordevtools |
注:丢弃的tab指的是tab内容已经从内存中卸载,但是tab未关闭。
综上,我们选用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语法如下:
| 属性 | 类型 | 描述 |
|---|---|---|
| name | String | 连接的名称 |
| disconnect | Function | 立即断开连接(已经断开的连接再次调用没有效果,连接断开后将不会收到新的消息) |
| onDisconnect | Object | 断开连接时触发(可添加监听器) |
| onMessage | Object | 收到消息时触发(可添加监听器) |
| postMessage | Function | 发送消息 |
| sender | MessageSender | 连接的发起者(该属性只会出现在连接监听器中,即onConnect 或onConnectExternal中) |
相对于扩展内部的消息通信而言,扩展间的消息通信更加简单。对于一次性消息通信,共涉及到如下两个API:
对于长期连接消息通信,共涉及到如下两个API:
发送消息可参考如下代码:
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 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添加了快捷键功能。
为扩展程序设置快捷键,共需要两步。
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" // 命令的描述
}
},
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是查看快捷键的应用,请忽略之)。

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

为扩展程序添加右键菜单,共需要三步。
申请菜单权限,需在manifest.json的permissions属性中添加”contextMenus”权限。
"permissions": ["contextMenus"]
菜单需在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则为菜单项指定配置信息。
绑定右键菜单的功能。
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;
}
});
}
}
最终弹出通知如下。

为了让全球都能使用你开发的扩展,国际化是必须的。从软件工程的角度讲,国际化就是将产品用户界面中可见的字符串全部存放在资源文件中,然后根据用户所处不同的语言环境,展示相应语言的视觉信息。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
_localesdirectory, 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 | 当前语言的文字方向,包含ltr、rtl,分别为从左到右、从右到左。 |
| @@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;
}
除了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);
});
输出如下图:

到目前为止,IHeader是我业余开发时间最长的一款Chrome扩展。从去年的5月8号始,到6月14号,第一版才完工,然后又经过7月、8月近两个月的陆续修改,最终v1.1.0版才成型,这才达到了我最初的开发初衷。
现在网络上流传的各种扩展开发教程非常之多,甚至API翻译的网站也很多,就我所知道的至少有这些:
通过查看这些资源,基本上就能快速上手Chrome扩展开发。
当然,教程再完善也不及官方文档,开发过程中,最难过的就是Chrome开发者网站连接不稳定,经常无法访问(即使自带梯子),因此查看官方网站的资料有些困难,这点比较影响开发进度,所以本文有意多介绍了一些Chrome API的用法。另外,开发好的扩展发布过程中也需要注意两点:
总之,Chrome扩展,万变不离其宗,无论扩展多么神奇和强大,最终都是通过HTML、CSS、JS来实现功能,脱离不了Web的小天地。因此理论上,只要你会写JS,就完全可以开发Chrome扩展。甚至,连第一个Demo,Chrome都帮你写好了,下载并安装Sample Extensions - Google Chrome网站的随意一个扩展源码,修修改改你就能运行属于自己的扩展了。
当然,一个好的扩展应该是对工作或生活有帮助的。只要你抓住痛点,用心实现功能,利用业余时间开发出一个强大的扩展自然不是问题。
至此,Chrome扩展有关的介绍差不多了,让我们来看看IHeader的效果。借助IHeader扩展程序,我去掉了 www.google.com 网站response的X-Frame-Options字段,终于解决了文章开头的难题,如下所示。

安装好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/
相关文章
]]>我一直信奉简洁至上的原则,桌面窗口的数量越少,我的心情就越放松,开发的效率也就越高。反之,杂乱的桌面,暴涨的Chrome tab数量,或是无数的终端窗口,它们会逐步侵占我的注意力,分散我的思维,最终令我难以专注。因此桌面上我很少放文件,使用Chrome时常点 OneTab 回收标签页,切进终端时使用tmux管理窗口。
那么,有没有可能开机后不需要任何操作,本地的十几种web开发服务就自动运行?当然我不希望连续弹出十几个窗口或是tab,我需要的是静默无感知的启用服务,然后还能快速地进入到现场进行操作,web服务运行时不占据终端窗口,关闭iTem2后操作现场不会被销毁。诸如此类,tmux都能实现,除了这些,tmux还能做得更多更好。
到目前为止,tmux帮助我两年有余,它带给我许多惊喜。独乐不如众乐,愿你也能一同享受tmux带来的快乐。
tmux是一款优秀的终端复用软件,它比Screen更加强大,至于如何强大,网上有大量的文章讨论了这点,本文不再重复。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-name 或 tmux a。通常我们使用如下两种方式之一即可:
tmux a # 默认进入第一个会话
tmux a -t demo # 进入到名称为demo的会话
会话的使命完成后,一定是要关闭的。我们可以使用tmux的kill命令,kill命令有kill-pane、kill-server、kill-session 和 kill-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的所有指令,都包含同一个前缀,默认为Ctrl+b,输入完前缀过后,控制台激活,命令按键才能生效。前面tmux会话相关的操作中,我们共用到了两个快捷键Ctrl+b + d、Ctrl+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配置文件有如下两种方式可以令其生效:
Ctrl+b指令前缀,然后按下系统指令:,进入到命令模式后输入source-file ~/.tmux.conf,回车后生效。既然快捷指令如此方便,更为优雅的做法是新增一个加载配置文件的快捷指令 ,这样就可以随时随地load新的配置了,如下所示。
# 绑定快捷键为r
bind r source-file ~/.tmux.conf \; display-message "Config reloaded.."
请特别注意,在已经创建的窗口中,即使加载了新的配置,旧的配置依然有效(只要你新加的功能没有覆盖旧的配置,因此如果你第一次绑定快捷指令为x键,然后又改为绑定y键,那么x和y都将有效),新建会话不受此影响,将直接采用新的配置。
既然我们已经迈出配置化的第一步,那么接下来我们可以做得更多。
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。没关系,我们可以将面板切换升级为熟悉的h、j、k、l键位。
# 绑定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之内,重复的h、j、k、l按键都将有效,完美支持了快速切换的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个单元格
以上,resizep即resize-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的部分命令如 osascript、open、pbcopy 或 pbpaste等可能会失效(失效命令未列全)。
部分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中操作文本,自然离不开复制模式,通常使用复制模式的步骤如下:
`+[ 进入复制模式空格键 开始复制,移动光标选择复制区域回车键 复制选中文本并退出复制模式`+] 粘贴文本查看复制模式默认的快捷键风格:
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键默认用于进入上一个窗口,不建议覆盖)
以上,绑定 v、y两键的设置只在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
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的操作如下所示:

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自带 pbcopy 和 pbpaste命令,分别用于复制和粘贴,但在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 Resurrect 和 Tmux Continuum,可以永久保存tmux会话(它们均适用于tmux v1.9及以上版本)。
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还提供进阶的备份功能,如下所示:
进阶的备份功能默认不开启,需要特别配置。
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 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-right 和 status-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服务器。
以上,我们直接安装了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插件管理器,安装插件仅需如下两步:
~/.tmux.conf中增加新的插件,如set -g @plugin '...'。prefix + I键下载插件,并刷新tmux环境。更新插件,请按下prefix + U 键,选择待更新的插件后,回车确认并更新。
卸载插件,需如下两步:
~/.tmux.conf中移除插件所在行。prefix + alt + u 移除插件。tmux多会话连接实时同步的功能,使得结对编程成为了可能,这也是开发者最喜欢的功能之一。现在就差一步了,就是借助tmate把tmux会话分享出去。
tmate是tmux的管理工具,它可以轻松的创建tmux会话,并且自动生成ssh链接。
安装tmate
brew install tmate
使用tmate新建一个tmux会话
tmate
此时屏幕下方会显示ssh url,如下所示:

查看tmate生成的ssh链接
tmate show-messages
生成的ssh url如下所示,其中一个为只读,另一个可编辑。

使用tmate远程共享tmux会话,受制于多方的网络质量,必然会存在些许延迟。如果共享会话的多方拥有同一个远程服务器的账号,那么我们可以使用组会话解决这个问题。
先在远程服务器上新建一个公共会话,命名为groupSession。
tmux new -s groupSession
其他用户不去直接连接这个会话,而是通过创建一个新的会话来加入上面的公共会话groupSession。
tmux new -t groupSession -s otherSession
此时两个用户都可以在同一个会话里操作,就会好像第二个用户连接到了groupSession的会话一样。此时两个用户都可以创建新建的窗口,新窗口的内容依然会实时同步,但是其中一个用户切换到其它窗口,对另外一个用户没有任何影响,因此在这个共享的组会话中,用户各自的操作可以通过新建窗口来执行。即使第二个用户关闭otherSession会话,共享会话groupSession依然存在。
组会话在共享的同时,又保留了相对的独立,非常适合结对编程场景,它是结对编程最简单的方式,如果账号不能共享,我们就要使用下面的方案了。
开始之前我们需要确保用户对远程服务器上同一个目录拥有相同的读写权限,假设这个目录为/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更加人性化、性能更佳,不妨参考下如下配置。
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" # 指定消息通知的前景、后景色
默认情况下,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
tmux默认会自动重命名窗口,频繁的命令行操作,将频繁触发重命名,比较浪费CPU性能,性能差的计算机上,问题可能更为明显。建议添加如下配置关闭rename机制。
setw -g automatic-rename off
setw -g allow-rename off
tmux默认会同步同一个会话的操作到所有会话连接的终端窗口中,这种同步机制,限制了窗口的大小为最小的会话连接。因此当你开一个大窗口去连接会话时,实际的窗口将自动调整为最小的那个会话连接的窗口,终端剩余的空间将填充排列整齐的小圆点,如下所示。

为了避免这种问题,我们可以在连接会话的时候,断开其他的会话连接。
tmux a -d
如果已经进入了tmux会话中,才发现这种问题,这个时候可以输入命令达到同样的效果。
`: a -d

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
类似的脚本,我们可以编写一打,这样快速重启、切换、甚至分享会话都将更加便捷。
开机自动准备工作环境是一个很好的idea,但却不好实现。对于程序员而言,一个开机即用的计算机会节省大量的初始化操作,特别是前端工程师,本地常常会启用多个服务器,每次开机挨个启动将耗时耗力。为此,在遇到tmux之前,我常常拖延重启计算机的时机,一度连续运行Mac一月之久,直到它不堪重负。
有了tmux脚本化的基础,开机自动启用web服务器就不在话下了,接杯水的时间,计算机就重启恢复了满血。如下是操作步骤:
首先,上面的tmux脚本,可以合并到同一个文件中,指定文件权限为可执行,并命名为init.sh(名称可自取)。
chmod u+x ./init.sh
然后,打开 系统偏好设置 - 用户与群组 - 登录项,点击添加按钮+,选择刚刚保存的init.sh脚本,最终效果如下:

至此,Mac开机将自动运行 init.sh 脚本,自动启用web服务器。
完成了上面这些配置,就真正实现了一键开机。
最后,附上我本地的配置文件 .tmux.conf,以及启动脚本 init.sh。明天就是国庆了,祝大家国庆快乐!
版权声明:转载需注明作者和出处。
本文作者: louis
本文链接: http://louiszhai.github.io/2017/09/30/tmux/
相关文章
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行代码:
考虑到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(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用于绘制图片、画布或者视频,可自定义宽高、位置、甚至局部裁剪。它有三种形态的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°的页面如下所示:

此时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°,都是可行的。如下:


当然重置画布坐标系后,需要注意清屏时,清屏的范围也有可能发生变化,需要稍作如下处理。
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/
参考文章:
]]>全文共13k+字,系统讲解了JavaScript数组的各种特性和API。
数组是一种非常重要的数据类型,它语法简单、灵活、高效。 在多数编程语言中,数组都充当着至关重要的角色,以至于很难想象没有数组的编程语言会是什么模样。特别是JavaScript,它天生的灵活性,又进一步发挥了数组的特长,丰富了数组的使用场景。可以毫不夸张地说,不深入地了解数组,不足以写JavaScript。
截止ES7规范,数组共包含33个标准的API方法和一个非标准的API方法,使用场景和使用方案纷繁复杂,其中有不少浅坑、深坑、甚至神坑。下面将从Array构造器及ES6新特性开始,逐步帮助你掌握数组。
声明:以下未特别标明的方法均为ES5已实现的方法。
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
Arrayis called as a function rather than as a constructor, it creates and initialises a new Array object. Thus the function callArray(…)is equivalent to the object creation expressionnew Array(…)with the same arguments.
从规范来看,浏览器内部大致做了如下类似的实现:
function Array(){
// 如果this不是Array的实例,那就重新new一个实例
if(!(this instanceof arguments.callee)){
return new arguments.callee();
}
}
上面,我似乎跳过了对Array构造器语法的介绍,没事,接下来我补上。
Array构造器根据参数长度的不同,有如下两种不同的处理:
Math.pow(2,32) -1或-1>>>0),否则将抛出RangeError。If the argument len is a Number and ToUint32(len) is equal to len, then the
lengthproperty 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专门扩展了数组构造器Array ,新增2个方法:Array.of、Array.from。下面展开来聊。
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(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);
执行结果如下:

可以看到加工函数的this作用域被obj对象取代,也可以看到加工函数默认拥有两个形参,分别为迭代器当前元素的值和其索引。
注意,一旦使用加工函数,必须明确指定返回值,否则将隐式返回undefined,最终生成的数组也会变成一个只包含若干个undefined元素的空数组。
实际上,如果不需要指定this,加工函数完全可以是一个箭头函数。上述代码可以简化如下:
Array.from(obj, (value) => value.repeat(3));
除了上述obj对象以外,拥有迭代器的对象还包括这些:String,Set,Map,arguments 等,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用来判断一个变量是否数组类型。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
通过上面的实例,我们看到使用数组推导来创建新数组比forEach,map,filter等遍历方法更加简洁,只是非常可惜,它不是标准规范。
ES6不仅新增了对Array构造器相关API,还新增了8个原型的方法。接下来我会在原型方法的介绍中穿插着ES6相关方法的讲解,请耐心往下读。
继承的常识告诉我们,js中所有的数组方法均来自于Array.prototype,和其他构造函数一样,你可以通过扩展 Array 的 prototype 属性上的方法来给所有数组实例增加方法。
值得一说的是,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"]
基于ES6,改变自身值的方法一共有9个,分别为pop、push、reverse、shift、sort、splice、unshift,以及两个ES6新增的方法copyWithin 和 fill。
对于能改变自身值的数组方法,日常开发中需要特别注意,尽量避免在循环遍历中去改变原数组的项。接下来,我们一起来深入地了解这些方法。
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()方法添加一个或者多个元素到数组末尾,并且返回数组新的长度。如果是栈的话,这个过程就是栈顶压入。
语法: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()方法颠倒数组中元素的位置,第一个会成为最后一个,最后一个会成为第一个,该方法返回对数组的引用。
语法: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()方法删除数组的第一个元素,并返回这个元素。如果是栈的话,这个过程就是栈底弹出。
语法: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方法比较复杂,这里我将多花些篇幅来讲这块。
语法: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格式如下所示:
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"]
实际上,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 时,现象如下:
将数组元素扩大至 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 时:
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() 方法用于在数组开始处插入一些元素(就像是栈底插入),并返回数组新的长度。
语法: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() 方法基于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() 方法基于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。
基于ES7,不会改变自身的方法一共有9个,分别为concat、join、slice、toString、toLocateString、indexOf、lastIndexOf、未标准的toSource以及ES7新增的方法includes。
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() 方法将数组中的所有元素连接成一个字符串。
语法: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() 方法将数组中一部分元素浅复制存入新的数组对象,并且返回这个数组对象。
语法: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() 返回值经调用 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() 类似toString()的变型,该字符串由数组中的每个元素的 toLocaleString() 返回值经调用 join() 方法连接(由逗号隔开)组成。
语法:arr.toLocaleString()
数组中的元素将调用各自的 toLocaleString 方法:
Object:Object.prototype.toLocaleString()Number:Number.prototype.toLocaleString()Date:Date.prototype.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() 方法用于查找元素在数组中第一次出现时的索引,如果没有,则返回-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() 方法用于查找元素在数组中最后一次出现时的索引,如果没有,则返回-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() 方法基于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() 方法是非标准的,该方法返回数组的源代码,目前只有 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"]
基于ES6,不会改变自身的方法一共有12个,分别为forEach、every、some、filter、map、reduce、reduceRight 以及ES6新增的方法entries、find、findIndex、keys、values。
forEach() 方法指定数组的每项元素都执行一次传入的函数,返回值为undefined。
语法:arr.forEach(fn, thisArg)
fn 表示在数组每一项上执行的函数,接受三个参数:
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() 方法使用传入的函数测试所有元素,只要其中有一个函数返回值为 false,那么该方法的结果为 false;如果全部返回 true,那么该方法的结果才为 true。因此 every 方法存在如下规律:
语法同上述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() 方法刚好同 every() 方法相反,some 测试数组元素时,只要有一个函数返回值为 true,则该方法返回 true,若全部返回 false,则该方法返回 false。some 方法存在如下规律:
你注意到没有,some方法与includes方法有着异曲同工之妙,他们都是探测数组中是否拥有满足条件的元素,一旦找到,便返回true。多观察和总结这种微妙的关联关系,能够帮助我们深入理解它们的原理。
some 的鸭式辨型写法可以参照every,同样它也不能在低版本IE(6~8)中使用,兼容写法请参考 Polyfill。
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() 方法遍历数组,使用传入函数处理每个元素,并返回函数的返回值组成的新数组。
语法:arr.map(fn, thisArg)
参数介绍同 forEach 方法的参数介绍。
具体用法请参考 详解JS遍历 中 map 的讲解。
map 一样支持鸭式辨型, 具体请参考every方法鸭式辨型写法。
其在低版本IE(6~8)的兼容写法请参考 Polyfill。
reduce() 方法接收一个方法作为累加器,数组中的每个值(从左至右) 开始合并,最终为一个值。
语法:arr.reduce(fn, initialValue)
fn 表示在数组每一项上执行的函数,接受四个参数:
initialValue 指定第一次调用 fn 的第一个参数。
当 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() 方法接收一个方法作为累加器,数组中的每个值(从右至左)开始合并,最终为一个值。除了与reduce执行方向相反外,其他完全与其一致,请参考上述 reduce 方法介绍。
其在低版本IE(6~8)的兼容写法请参考 Polyfill。
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() 方法基于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() 方法基于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() 方法基于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
该方法基于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 的各方法基本介绍完毕,这些方法之间存在很多共性。比如:
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/
参考文章
]]>开发环境页面热更新早已是主流,我们不光要吃着火锅唱着歌,享受热更新高效率的快感,更要深入下去探求其原理。
要知道,触类则旁通,常见的需求如赛事网页推送比赛结果、网页实时展示投票或点赞数据、在线评论或弹幕、在线聊天室等,都需要借助热更新功能,才能达到实时的端对端的极致体验。
刚好,最近解决webpack-hot-middleware热更新延迟问题的过程中,我深入接触了EventSource技术。遂本文由此开篇,进一步讲解webpack-hot-middleware,browser-sync背后的技术。
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是一个type为EventSource的请求, 从Time栏可以看出:默认情况下,服务器每十秒推送一条信息到浏览器。

如果此时关闭开发服务器,浏览器由于重连机制,将持续抛出类似GET http://www.test.com/__webpack_hmr 502 (Bad Gateway) 这样的错误。重新启动开发服务器后,重连将会成功,此时便会刷新页面。
以上这些便是我们使用时感受到的最初的印象。当然,停留在使用层面不是我们的目标,接下来我们将跳出该中间件,讲解其所使用到的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); // 打印服务器推送的信息
}
如下是消息推送的过程:


你以为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。
另外,如果需要支持跨域调用,请设置响应头Access-Control-Allow-Origin': '*'。
如需支持发送cookie,请设置响应头Access-Control-Allow-Origin': req.headers.origin 和 Access-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+ |
既然说到了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可以结合Gulp或Grunt一起使用,其API请参考:Browsersync API。
通过上面的了解,我们知道EventSouce的使用是比较便捷的,那为什么browser-sync不使用EventSource技术进行代码推送呢?这是因为browser-sync插件共做了两件事:
以上,browser-sync使用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拥有如下优点:
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+ | - |
根据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”。
关于Frame的更多理论介绍不妨读读 学习WebSocket协议—从顶层到底层的实现原理(修订版)。
关于Frame的数据帧解析不妨读读 WebSocket(贰) 解析数据帧 及其后续文章。
了解了Frame的数据结构后,我们来实际练习下。浏览器上,新建一个ws对象十分简单。如下:
let ws = new WebSocket('ws://127.0.0.1:10103/');// 本地使用10103端口进行测试
新建的WebSocket对象如下所示:

这中间包含了一次Websocket握手的过程,我们分两步来理解。
第一步,客户端请求。

这是一个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协议)。
第二步,服务端响应。

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个形参,依次为:
// 若verify定义如下
function verify(info, cb){
//一旦拥有第二个形参,如果不调用,默认将通过验权
cb(false, 401, '权限不够');// 此时表示验权失败,HTTP状态码为401,错误信息为"权限不够"
return true;// 一旦拥有第二个形参,响应就被cb接管了,返回什么值都不会影响前面的处理结果
}
除了port 和 verifyClient设置外,其它设置项及更多API,请参考文档 ws-doc。
接下来,我们来实现消息收发。如下是客户端发送消息。
ws.onopen = function(e){
// 可发送字符串,ArrayBuffer 或者 Blob数据
ws.send('client ready!);
};
客户端监听信息。
ws.onmessage = function(e){
console.log('server say:', e.data);
};
如下是浏览器的运行截图。

消息的内容都在Frames栏,第一条彩色背景的信息是客户端发送的,第二条是服务端发送的。两条消息的长度都是13。
如下是Timing栏,不止是WebSocket,包括EventSource,都有这样的黄色高亮警告。

该警告说明:请求还没完成。实际上,直到一方连接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!');// 告知客户端服务器已就绪
});
运行效果如下:

上述测试代码中没有过多涉及服务器的存储过程。通常,服务器也会有缓存区上限,如果客户端单次发送的数据量超过服务端缓存区上限,那么服务端也需要多次读取。
生产环境下上传一个文件远比本地测试来得复杂。实际上,从客户端到服务端,中间存在着大量的网络链路,如路由器,防火墙等等。一份文件的上传要经过中间的层层路由转发,过滤。这些中间链路可能会认为一段时间没有数据发送,就自发切断两端的连接。这个时候,由于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连接了。
WebSocket出世已久,很多优秀的大神基于此开发出了各式各样的库。其中Socket.IO是一个非常不错的开源WebSocke库,旨在抹平浏览器之间的兼容性问题。它基于Node.js,支持以下方式优雅降级:
如何在项目中使用Socket.IO,请参考第一章 socket.io 简介及使用。
EventSource,本质依然是HTTP,它仅提供服务端到客户端的单向文本数据传输,不需要心跳连接,连接断开会持续触发重连。
WebSocket协议,基于TCP协议,它提供双向数据传输,支持二进制,需要心跳连接,连接断开不会重连。
EventSource更轻量和简单,WebSocket支持性更好(因其支持IE10+)。通常来说,使用EventSource能够完成的功能,使用WebSocket一样能够做到,反之却不行,使用时若遇到连接断开或抛错,请及时调用各自的close方法主动释放资源。
本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论。
本文作者: louis
本文链接: http://louiszhai.github.io/2017/04/19/hmr/
参考文章
]]>我不知道拖延症是有多严重, 反正去年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-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, 那么:
由于客户端总是采用最保守的缓存策略, 因此, 4月9日后, 对于该资源的请求将重新向服务器发起验证.
http1.0字段, 通常设置为Pragma:no-cache, 作用同Cache-Control:no-cache. 当一个no-cache请求发送给一个不遵循HTTP/1.1的服务器时, 客户端应该包含pragma指令. 为此, 勾选☑️ 上disable cache时, 浏览器自动带上了pragma字段. 如下:

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协商缓存, 如下所示.

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:"fcb82312d92970bdf0d18a4eca08ebc7efede4fe"
实体标签, 服务器资源的唯一标识符, 浏览器可以根据ETag值缓存数据, 节省带宽. 如果资源已经改变, etag可以帮助防止同步更新资源的相互覆盖. ETag 优先级比 Last-Modified 高.
语法: If-Match: ETag_value 或者 If-Match: ETag_value, ETag_value, …
缓存校验字段, 其值为上次收到的一个或多个etag 值. 常用于判断条件是否满足, 如下两种场景:
If-Match 可用于阻止错误的更新操作, 如果不匹配, 服务器将返回一个412(Precondition Failed)状态码的响应.语法: If-None-Match: ETag_value 或者 If-None-Match: ETag_value, ETag_value, …
缓存校验字段, 结合ETag字段, 常用于判断缓存资源是否有效, 优先级比If-Modified-Since高.
Cache-Control, Content-Location, Date, ETag, Expires, and Vary 中之一的字段.语法: 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: Tue, 04 Apr 2017 10:12:27 GMT
缓存校验字段, 其值为上次响应头的Last-Modified值, 若与请求资源当前的Last-Modified值相同, 那么将返回304状态码的响应, 反之, 将返回200状态码响应.
缓存校验字段, 语法同上. 表示资源未修改则正常执行更新, 否则返回412(Precondition Failed)状态码的响应. 常用于如下两种场景:
一旦资源命中强缓存, 浏览器便不会向服务器发送请求, 而是直接读取缓存. Chrome下的现象是 200 OK (from disk cache) 或者 200 OK (from memory cache). 如下:


对于常规请求, 只要存在该资源的缓存, 且Cache-Control:max-age 或者expires没有过期, 那么就能命中强缓存.
缓存过期后, 继续请求该资源, 对于现代浏览器, 拥有如下两种做法:
If-None-Match字段. 服务器收到请求后, 拿If-None-Match字段的值与资源的ETag值进行比较, 若相同, 则命中协商缓存, 返回304响应.If-Modified-Since字段. 服务器收到请求后, 拿If-Modified-Since字段的值与资源的Last-Modified值进行比较, 若相同, 则命中协商缓存, 返回304响应.以上, ETag优先级比Last-Modified高, 同时存在时, 前者覆盖后者. 下面通过实例来理解下强缓存和协商缓存.
如下忽略首次访问, 第二次通过 If-Modified-Since 命中了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分钟之内访问, 将会命中强缓存, 如下所示:

当然, 除了上述与缓存直接相关的字段外, http header中还包括如下间接相关的字段.
出现此字段, 表示命中代理服务器的缓存. 它指的是代理服务器对于请求资源的已缓存时间, 单位为秒. 如下:
Age:2383321
Date:Wed, 08 Mar 2017 16:12:42 GMT
以上指的是, 代理服务器在2017年3月8日16:12:42时向源服务器发起了对该资源的请求, 目前已缓存了该资源2383321秒.
指的是响应生成的时间. 请求经过代理服务器时, 返回的Date未必是最新的, 通常这个时候, 代理服务器将增加一个Age字段告知该资源已缓存了多久.
对于服务器而言, 资源文件可能不止一个版本, 比如说压缩和未压缩, 针对不同的客户端, 通常需要返回不同的资源版本. 比如说老式的浏览器可能不支持解压缩, 这个时候, 就需要返回一个未压缩的版本; 对于新的浏览器, 支持压缩, 返回一个压缩的版本, 有利于节省带宽, 提升体验. 那么怎么区分这个版本呢, 这个时候就需要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 | 资源缓存有效 | 新开一个窗口加载网页 | 重新发送请求(返回200) | 展示缓存的页面 |
| IE8 | 资源缓存失效 | 原浏览器窗口中单击 Enter 按钮 | 展示缓存的页面 | 重新发送请求(返回200) |
Last-Modified / E-Tag 相关.
| 浏览器 | 前提 | 操作 | 表现 | 正常表现 |
|---|---|---|---|---|
| IE8 | 资源内容没有修改 | 新开一个窗口加载网页 | 浏览器重新发送请求(返回200) | 重新发送请求(返回304) |
| IE8 | 资源内容已修改 | 原浏览器窗口中单击 Enter 按钮 | 浏览器展示缓存的页面 | 重新发送请求(返回200) |
版权声明:转载需注明作者和出处。
本文作者:louis
本文链接:http://louiszhai.github.io/2017/04/07/http-cache/
参考文章
]]>Promise问世已久, 其科普类文章亦不计其数. 遂本篇初衷不为科普, 只为能够温故而知新.
比如说, catch能捕获所有的错误吗? 为什么有些时候会抛出”Uncaught (in promise) …”? Promise.resolve 和 Promise.reject 处理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的读者可以选择性地阅读上文, 希望深入的读者可以继续剖析概念, 当然我更希望你能耐心地读到应用场景处, 这样便能升华对这个概念或技术的运用, 也能避免踩坑.
Promise的设计初衷是避免异步回调地狱. 它提供更简洁的api, 同时展平回调为链式调用, 使得代码更加清爽, 易读.
如下, 即创建一个Promise对象:
const p = new Promise(function(resolve, reject) {
console.log('Create a new Promise.');
});
console.log(p);

创建Promise时, 浏览器同步执行传入的第一个方法, 从而输出log. 新创建的promise实例对象, 初始状态为等待(pending), 除此之外, Promise还有另外两个状态:
如下图展示了Promise的状态变化过程(图片来自MDN):

从初始状态(pending)到实现(fulfilled)或拒绝(rejected)状态的转换, 这是两个分支, 实现或拒绝即最终状态, 一旦到达其中之一的状态, promise的状态便稳定了. (因此, 不要尝试实现或拒绝状态的互转, 它们都是最终状态, 没法转换)
以上, 创建Promise对象时, 传入的回调函数function(resolve, reject){}默认拥有两个参数, 分别为:
Promise的原型仅有两个自身方法, 分别为 Promise.prototype.then , Promise.prototype.catch . 而它自身仅有四个方法, 分别为 Promise.reject , Promise.resolve , Promise.all , Promise.race .
语法: 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之后执行。
语法: 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可以捕获所有错误呢? 可以, 怎么不可以, 我以前也这么天真的认为. 直到有一天我执行了如下的语句, 我就学乖了.
new Promise(function(resolve, reject){
Promise.reject('返回一个拒绝状态的Promise');
}).catch(function(reason){
console.log('catch:', reason);
});
执行结果如下:

为什么catch没有捕获到该错误呢? 这个问题, 待下一节我们了解了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抛出的错误, 还会被进程的unhandledRejection 和 rejectionHandled事件捕获.
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(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.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对象. 只要迭代器中有一个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机制.
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.
console.log(3), 这里是同步执行, 因此接着将输出3, 此处应无异议.之前, 我们在 Ajax知识体系 一文中有提到:
浏览器中, js引擎线程会循环从
任务队列中读取事件并且执行, 这种运行机制称作Event Loop(事件循环).
不仅如此, event loop至少拥有如下两种队列:
如下是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, 对于其执行过程, 不妨作如下简化:
这里直接给出事件回调优先级:
process.nextTick > promise.then > setTimeout ? setImmediate
nodejs中每一次event loop称作tick. _tickCallback在macrotask queue中每个task执行完成后触发. 实际上, _tickCallback内部共干了两件事:
因此, 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/
参考文章
]]>最近我在梳理公司web app新产品线的返回逻辑, 推演了N种方案, 竟然没有一种完全通用的. 这让我迷惑不已. 仔细体验了公司的各种H5页面, 发现返回逻辑还真是五花八门. 那么, 问题来了, 为什么Web App的返回逻辑如此难以设计?
相对于较复杂的返回场景, 可用的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项历史:
B和B2其实是同一个网页, 除了历史栈中的位置不同, 他们没有任何不同. 如此逻辑将使得我们将陷入返回的死循环中. 为避免这种体验上的缺陷, 请尽量不要在返回逻辑中重定向到某个之前的页面.
webview中通过location.href方式跳转链接, 可起到清理浏览历史项的作用, 如此时webview中共存在100个历史项, 我们一路返回至第90个页面(该页面的history.length依然是100), 然后在该页面通过location.href跳转至另一个页面, 那么新的页面将处于历史项的第91项, 原来的第91~100项历史将被清空. 于是新的页面中获取的history.length将准确地标示了该页面处于历史项的第几项.
愿望是美好的, 现实是残酷的. 纯H5下想要在返回逻辑中不重定向, 先砍了这些需求再说:
从产品上看, 这些需求都是合理的. 那么如何从最后一个页面, 成功地返回到初始的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的解决方案便不太完美.
对于非嵌入app的H5应用, 那么使用场景就是各家的浏览器, 应用中对于有可能打乱历史记录的网页, 直接新开窗口就行.
对于嵌入app内的H5应用, 通常来说, H5本身不具备新开webview的能力. 这里需要native辅助. 接下来我们将主要关注嵌入app内的H5的应用.
app内嵌的H5应用, 可借助native的jsBridge新开webview, 从而避免历史记录混乱. 为此, native客户端(包括Android和IOS以及其他)将提供接口以便js打开或关闭webview. 值得考虑的是, 这里面可能带来一个负面影响, js有可能多次申请新开webview, 从而大量消耗内存和电量. 因此, native有必要对webview的个数予以限制.
既然开多个webview开销会增大, 基于此, 我突发奇想, 有没有可能由native客户端来维护单个webview的历史记录, 从而所有的页面跳转将由native接管?
我认为这是有可能的. 首先native可以保留每次加载的页面链接, 同时, 页面跳转时可提前设置下一个页面的返回逻辑. 既然历史记录和返回逻辑都在native中注册, 剩下的问题就是, js怎么通知native返回了? 这个也很简单, native不止可以loadUrl, 还可以load页面上的方法. 又页面上用于返回的两个js方法: history.go 和 history.back 都是可以重写的. 因此, native可在页面DOMContentLoaded事件回调中重写go和back方法, 改为调用jsBridge接口(此前, 为了解决第三方OAuth2.0登录后返回到空白页的问题, 我写了部分native逻辑, 用于重写js原生go和back方法已在生产环境下使用).
思路如下:
记录历史栈: native存储webview中加载的每一个页面, 形成一个历史记录栈. 并且标记当前页面处于该历史记录栈的位置.
重写返回方法: webview中每个页面加载完成后, 重写history.go 和 history.back方法, 改为调用jsBridge接口, 方便native感知网页的后退. (下面将详细说明重写的时机)
设置下一个页面的返回逻辑: 页面跳转之前, 可调用jsBridge强制设置下一个页面的返回url(如从a跳转至b页面, 设置后, 无论b处于历史记录栈的哪一项, 从b返回都将回到a页面)
回退时检查当前页面的返回逻辑: 一旦H5中调用history.go 和 history.back方法返回之前的页面, native自动检查该页面之前是否设置过返回url, 如有则从历史记录栈中捞出该url的位置, 继续调用js原生的history.go方法进行跳转, 同时忽略本次历史; 如无则直接通过原方法跳转页面, 同时忽略本次历史.
重写Android自带的物理返回键.
//改写物理返回键的逻辑
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if(keyCode==KeyEvent.KEYCODE_BACK){
//参照第4步策略实现
}
}
激进策略—拦截页面主动发起的重定向(选用): 每次加载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重定向场景. 接下来我们将着重讨论这种场景.
首先, 加载页面有两种方式:
我们先来模拟一个两次重定向的场景, 通常情况下, 直接访问 http://www.baidu.com 将发生一次重定向. 在此之前用一个短链接重定向到 http://www.baidu.com 这样便多了一次重定向. 下面将基于这个场景进行两次测试.
那么第一种方式, 将依次触发webview的以下事件回调:

整理如下:
第二种方式, 将依次触发webview的以下事件回调:

整理如下:
可见, 除了最后一次onPageFinished事件, 其他的onPageFinished事件都紧跟shouldOverrideUrlLoading事件之后触发.
基于上述现象, 可以设置全局状态位(flag), onPageStarted触发时设置为true, shouldOverrideUrlLoading触发时设置为false, onPageFinished触发时, 判断flag是否为true, 如果为true则意味着页面加载完成, 此时便可放心的记录页面url以及重写js原生返回方法.
上述方法真的可以记录webview所有的历史项吗?
其实还不能. 实际上, webview的网页上进行hash跳转时, onPageStarted 和 shouldOverrideUrlLoading 都不会触发. 所幸的是 onPageFinished 能够感知到hash值的变化. 我们可以在该方法内继续维护历史记录栈.
至此, 我想, 基于native的这套返回方案应该是可行. 但有native的同学告知: 有些页面native无法记录页面url? 这是为什么呢? 至少到目前为止, 我还没有发现这样的场景. 欢迎阅读本文的你留下个脚印, 一起讨论和完善web app返回方案.
本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.
本文作者: louis
本文链接: http://louiszhai.github.io/2017/02/20/back/
参考文章
]]>我总是记不住css3的box-shadow属性拥有几个值, 它们的顺序究竟如何? 对我来说, 这是一个大难题. 我们都知道, 使用一个属性, 总是可以不停地在开发者工具上测试UI表现, 直到表现令人满意. 好吧, 有些时候它是奏效的; 而其它时候呢, 我们会消耗时间, 积攒疲劳值, 以及成就感下降等. 一旦短时记忆失效, 我们完全有可能重复一遍之前不愉快地尝试. 因此我选择多花些时间, 用力一次记住它. 如果你恰好也有同样的疑惑, 请读下去.
box-shadow用于创建阴影, 使得元素更有立体感, 它的值由以下六个部分组成:
box-shadow: offsetX offsetY blur spread color position;
它们分别为: x轴偏移 y轴偏移 模糊半径 大小 颜色 位置.
xy轴偏移, 参照css中的坐标系, 水平向右(→)为X轴正方向, 垂直向下(↓)为Y轴正方向.
即水平偏移, 值取正数时, 阴影位于元素右边, 值取负数时, 阴影位于元素左边.
为了便于观察到效果, 我将模糊半径默认设置成10px.
box-shadow: 20px 0 10px 0 lightblue; /*阴影向右偏移20px*/

box-shadow: -20px 0 10px 0 lightblue; /*阴影向左偏移20px*/

即垂直偏移, 值取正数时, 阴影位于元素下方, 值取负数时, 阴影位于元素上方.
box-shadow: 0 10px 10px 0 lightblue; /*阴影向下偏移10px*/

box-shadow: 0 -10px 10px 0 lightblue; /*阴影向上偏移10px*/

设置阴影的模糊半径, 值越大时, 阴影就越模糊, 值为0时则完全不模糊, 值小于0时则按照0处理.
我试着加大模糊半径, 取值为50px, 可以看到阴影变得非常模糊.
box-shadow: 20px 0 50px 0 lightblue; /*阴影向右偏移20px, 模糊半径由10px放大至50px*/

同时, 上下边界也有模糊阴影, 理论上讲, 模糊半径在上下左右各个方向应该都有效果, 下面我们来验证之:
box-shadow: 0 0 50px 0 lightblue; /*模糊半径设置为50px, 无偏移*/

如上图, 同猜想一致, 模糊阴影在上下左右4个方向分别发散. 此时, 对角线方向上阴影是最淡的, 要想模糊阴影均匀分布在元素周围, 只需将元素设置为圆形即可.
box-shadow: 0 0 50px 0 lightblue; /*模糊半径设置为50px, 无偏移*/
border-radius: 100%; /*元素设置为圆形*/

设置阴影大小. 当blur值为0时, spread就像是元素背后的一块幕布, spread值越大, 阴影越宽, 当其取负值时, 阴影大小为元素高宽分别减去spread值, 此时, blur设置的模糊阴影则会向内靠拢.
box-shadow: 0 0 0 10px lightblue; /*阴影大小设置为+10px*/

box-shadow: 0 0 10px 10px lightblue; /*模糊半径设置为10px, 阴影大下为+10px*/

box-shadow: 0 0 10px -1px lightblue; /*模糊半径设置为10px, 阴影大下为-1px, 由于模糊阴影部分向内靠拢, 阴影变得非常薄*/

不知道你有没有注意到 ,对于阴影大小, 我使用的是+10px 或者 -1px这样的单位, 这是为什么呢? 这里卖个关子先, 请看如下效果.
box-shadow: 150px 0 0 0px lightblue;

仅仅将阴影水平向右移动一段距离, 可见, 阴影是有默认大小的, 并且默认与元素是一般大小. 而这几乎打破了我一度的认知, 说好的阴影呢, 不是环绕吗!
前面提到了圆形阴影, 实际上, 就是border-radius:100%的特例, 那么如果border-radius是一个具体的值呢, 此时阴影又该当如何呈现? 请耐心往下看, 我将在多重阴影的节点给出分析.
设置阴影的颜色. 支持常用色值, 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);
设置阴影的位置, 默认为外部阴影, 可通过inset值来设置内部阴影.
box-shadow: 0 0 20px 10px lightblue; /*默认为外部阴影*/
box-shadow: 0 0 20px 10px lightblue 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;
直接上效果.

从第一个阴影开始(上图左二), 随着阴影spread值的变化, 阴影经历了边框直角, 边框圆角, 边框更圆角(词穷)的过程.
这两个属性的关系如下:
先来看以下纸张投影效果是个什么样.

不就是在纸张底部加个投影吗, 是的, 你没看错. 这样的投影, 实现起来灰常简单, 只需要元素底部左右各加一个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>
阶段性效果如下:

先在元素底部左右两边各生成一个阴影, 阴影应该是垂直向下的, 和模糊的, 那么属性如下设置.
.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;
}
阶段性效果如下:

这个时候, 阴影基本上呈现了, 但有两点不太完美:
倾斜可使用 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);
}
阶段性效果如下:

可以看到, 阴影出现在元素之上. 可设置z-index 为-1, 将阴影层级降低一些, 这样就实现了上述所说的纸张阴影效果.

同样, 我们先来看下升起效果长什么样. 如下:

这是一个简单的动画, 鼠标移入, 元素上移, 同时阴影缩小, 鼠标移出则反之. 这里, 我们分两步来实现它.
<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;
}
欲了解这个属性, 我们先读一则规范.
2016年1月14日, W3C发布了CSS片段模块(CSS Fragmentation Module Level 3)的候选推荐标准, 明确定义了在盒间、盒内、行间、页间进行断行的属性和规则.
简言之, 对于一个盒子模型, 如果它被分裂成多个长度不等的小碎片, 那么它将遵循以下规则来调整布局:
box-decoration-break 为 clone, 那么padding 和 border 将包裹后续碎片的边缘.也就是说, 当行内元素换行后, border, background将会出现截断现象, box-shadow也会如此. 如下:

实际上, 这种效果并非不可改变, 设置 box-decoration-break:clone , 以上各个css效果将每行重新渲染, 彼此相互独立.

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 | |
|---|---|---|---|---|---|---|---|
| 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/
参考文章
]]>Web layout 是Web UI中的基础架构, 重要性不言而喻. 传统的盒模型, 借助display, position, float 属性应对普通布局游刃有余, 但针对复杂的或自适应布局, 常常捉襟见肘. 比如垂直居中, 就是一个老大难的问题, 借助flex弹性盒模型, 两行代码就可以优雅的实现之. (该方法曾在 16种方法实现水平居中垂直居中 一文中提到). 当然, 本次我们不会只讨论垂直居中的问题, 我将努力尽可能的还原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该做的, 都帮我们做了. 因此建议尝试下以下三个插件之一.
Flex布局使得子项目能够”弹性”的改变其高宽, 自由填充容器剩余空间, 以适应容器变大, 或者压缩子项目自身, 以适应容器变小; 同时还可以方便的调节子项目方向和顺序. flex常用于高宽需要自适应, 或子项目大小成比例, 或水平垂直对齐等场景.
Flex弹性盒模型里, 有容器和项目之分. 设置display:flex的为容器, 容器内的元素称作它的子项目, 容器有容器的一套属性, 子项目有子项目的另一套属性. (可以这么理解: father作为弹性盒子, 制定行为规范, son享受盒子的便利, 按照规范划分各自的”辖区”).
以下图片摘自大漠的一个完整的Flexbox指南文中.

father制定的规范, 基于两个方向 — 水平和垂直.
main start, 末尾位置叫做main end; cross start, 末尾位置叫做cross end.main size, 在交叉轴上所占的高(宽)度, 叫做cross size.display: flex | inline-flex;(元素将升级为弹性盒子). 前者容器升级为块级盒子, 后者容器将升级为行内盒子. 元素采用flex布局以后, 子元素的float, clear, vertical-align属性都将失效.
容器具有以下6个属性.
| flex-direction的值 | 描述 |
|---|---|
| row(默认) | 指定主轴水平, 子项目从左至右排列➜ |
| row-reverse | 指定主轴水平, 子项目从右至左排列⬅︎ |
| column | 指定主轴垂直, 子项目从上至下排列⬇︎ |
| column-reverse | 指定主轴垂直, 子项目从下至上排列⬆︎ |
| flex-wrap的值 | 描述 |
|---|---|
| nowrap(默认) | 默认不换行 |
| wrap | 正常换行 |
| wrap-reverse | 换行, 且前面的行在底部 |
row nowrap.| justify-content的值 | 描述(子项目–主轴方向) |
|---|---|
| flex-start(默认) | 子项目起始位置与main start位置对齐 |
| flex-end | 子项目末尾位置与main end位置对齐 |
| center | 在主轴方向居中于容器 |
| space-between | 与交叉轴两端对齐, 子项目之间的间隔全部相等 |
| space-around | 子项目两侧的距离相等, 它们之间的距离两倍于它们与主轴起始或末尾位置的距离. |
| align-items的值 | 描述(子项目—交叉轴方向) |
|---|---|
| flex-start | 子项目起始位置与cross start位置对齐 |
| flex-end | 子项目末尾位置与cross end位置对齐 |
| center | 在交叉轴方向居中于容器 |
| baseline | 第一行文字的基线对齐 |
| stretch(默认) | 高度未定(或auto)时, 将占满容器的高度 |
| 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.
我们可以给input设置flex:1, 使其充满一行, 并且随着父元素大小变化而变化. 也可以给div设置flex:1使其充满剩余高度.
使用flex布局这些都不是难事, 需要注意的是, 这其中有坑. 为了避免踩坑, 我们先来看下flex属性的优先级:
width|height > 自适应文本内容的宽度或高度 > flex:数值
这意味着, 首先是元素宽高的值优先, 其次是内容的宽高, 再次是flex数值. 现在我们来看看坑是什么.
flex:1时需要注意, 通常input拥有一个默认宽度(用于展示默认数量的字符), 在chrome v55下, 这个宽度默认为126px(同时还包含2px的border). 因此想要实现input宽度自适应, 可以设置其width为0.flex:1时, 因div的高度会受子级元素影响, 为了使得该div占满其父元素剩余的高度, 且不超出, 建议将该div的height属性设置为0. 此时PC端表现非常优秀,美中不足的是,对于移动端而言,div的子元素设置为height:100%并没有什么卵用,此时子元素高度依然为0。目前我能想到的比较好的解决方案就是:给div也设置display:flex;align-item:stretch,使得div本身也获得flex布局能力,同时div子元素高度充满div本身。想要实现垂直居中的效果, 只需要设置父元素为display:flex;justify-content:center 即可. (当然, 父元素样式采用:display:table;, 子元素样式采用:display:table-cell;vertical-align:middle 也是可以实现的), 如下图.

想要实现左右两个元素等高(父元素高度由子元素撑开), 并且各占一半的宽度. 如上图.
overflow:hidden, 子元素样式设置为margin-bottom:-10000px;padding-bottom:10000px;, 这样, 每个子元素便能借助padding撑开, 同时, 借助负margin和overflow合理裁剪.display:table属性, 父元素样式设置为display:table , 子元素设置为display:table-cell. 利用表格的行高一致性, 轻松实现行高一致.display:flex.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/
参考文章
排序算法可以称得上是我的盲点, 曾几何时当我知道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: 同直接插入排序类似, 折半插入排序每次交换的是相邻的且值为不同的元素, 它并不会改变值相同的元素之间的顺序. 因此它是稳定的.
算法基本思想是:
m = (i-1)>>1 ), array[i] 与 array[m] 进行比较, 若array[i] < array[m] , 则说明待插入的元素array[i] 应该处于数组的 0 ~ m 索引之间; 反之, 则说明它应该处于数组的 m ~ i-1 索引之间.注:
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年公布.
算法的基本思想:
如下是排序的示意图:

可见, 希尔排序实际上就是不断的进行直接插入排序, 分组是为了先将局部元素有序化. 因为直接插入排序在元素基本有序的状态下, 效率非常高. 而希尔排序呢, 通过先分组后排序的方式, 制造了直接插入排序高效运行的场景. 因此希尔排序效率更高.
我们试着抽象出共同点, 便不难发现上述希尔排序的第四步就是一次直接插入排序, 而希尔排序原本就是从”增量”为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:递归):
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次. 通过自上而下的递归实现的归并排序, 将存在堆栈溢出的风险. 亲测各浏览器的堆栈溢出所需的递归调用次数大致为:
以下是测试代码:
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, 需要满足如下条件才行:
算法的基本思想(以大根堆为例):
如下是动图效果:

如下是算法实现:
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位)
注意: 如果原数组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个不同的容器中, 分别排序, 最后再合并数据. 这种方式大大减少了排序时整体的遍历次数, 提高了算法效率.
基数排序源于老式穿孔机, 排序器每次只能看到一个列. 它是基于元素值的每个位上的字符来排序的. 对于数字而言就是分别基于个位, 十位, 百位 或千位等等数字来排序. (不明白不要紧, 我也不懂, 请接着往下读)
按照优先从高位或低位来排序有两种实现方案:
如下是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/
参考文章
]]>Manifest 是 H5提供的一种应用缓存机制, 基于它web应用可以实现离线访问(offline cache). 为此, 浏览器还提供了应用缓存的api–applicationCache. 虽然manifest的技术已被web标准废弃, 但这不影响我们尝试去了解它. 也正是因为manifest的应用缓存机制如此诱人, 饿了么 和 office 365邮箱等都还在使用着它!
对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采用经典的三段式. 分别为: CACHE, NETWORK 和 FALLBACK. 如下, 先看一个栗子🌰:
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缓存都有一个状态, 标示着缓存的情况. 一份缓存清单只有一个缓存状态, 即使它被多个页面引用. 以下是各个缓存状态:
上述缓存状态常量依次取值0, 1, 2, 3, 4, 5.
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);
通常, webview的缓存有如下三种现象:
NETWORK 段落后需要访问网络的文件, 将继续走 http cache.如果缓存的文件需要加参数运行, 建议将参数内容加到hash中, 如:cached-page.html#parameterName=value
manifest 的引入可以使用绝对路径或者相对路径, 如果你使用的是绝对路径, 那么你的manifest文件必须和你的站点处于同一个域名下.
manifest文件你可以保存为任意的扩展名, 但是响应头中以下字段须取以下定值, 以保证manifest文件正确被解析, 并且它没有http缓存.
Content-Type: text/cache-manifest
Cache-Control: max-age=0
Expires: [CURRENT TIME]
chrome浏览器下通过访问 chrome://appcache-internals/ 可以查看缓存在本地的资源文件.
另外, 除了本文参考的一篇 MDN 的文章以及 HTML5 Rocks的 Beginner’s Guide to Using the Application Cache 一文, 还有如下三个链接可供您比较阅读, 谢谢.
本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.
本文作者: louis
本文链接: http://louiszhai.github.io/2016/11/25/manifest/
参考文章
]]>Ajax 全称 Asynchronous JavaScript and XML, 即异步JS与XML. 它最早在IE5中被使用, 然后由Mozilla, Apple, Google推广开来. 典型的代表应用有 Outlook Web Access, 以及 GMail. 现代网页中几乎无ajax不欢. 前后端分离也正是建立在ajax异步通信的基础之上.
现代浏览器中, 虽然几乎全部支持ajax, 但它们的技术方案却分为两种:
① 标准浏览器通过 XMLHttpRequest 对象实现了ajax的功能. 只需要通过一行语句便可创建一个用于发送ajax请求的对象.
var xhr = new XMLHttpRequest();
② IE浏览器通过 XMLHttpRequest 或者 ActiveXObject 对象同样实现了ajax的功能.
鉴于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,虽然浏览器支持,功能可能不完善,故不建议使用
以上, 思路已经很清晰了, 下面给出个全兼容的方法.
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;
}
对于这个问题, 我们先看下浏览器线程机制. 一般情况下, 浏览器有如下四种线程:
那么这么多线程, 它们究竟是怎么同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 的事件回调都被同等的对待, 按照顺序自动的被添加到 任务队列 的末尾, 等待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并不是最先打印出来的.');
上述代码执行结果如下图:

由于ajax异步, setTimeout及Promise本应该最先被执行, 然而实际上, 一次ajax请求, 并非所有的部分都是异步的, 至少”readyState==1”的 onreadystatechange 回调以及 onloadstart 回调就是同步执行的. 因此它们的输出排在最前面.
首先在Chrome console下创建一个 XMLHttpRequest 实例对象xhr. 如下所示:

试运行以下代码.
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属性记录了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事件回调方法在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实例, 如下:

只读属性, status表示http请求的状态, 初始值为0. 如果服务器没有显式地指定状态码, 那么status将被设置为默认值, 即200.
只读属性, statusText表示服务器的响应状态信息, 它是一个 UTF-16 的字符串, 请求成功且status==20X时, 返回大写的 OK . 请求失败时返回空字符串. 其他情况下返回相应的状态描述. 比如: 301的 Moved Permanently , 302的 Found , 303的 See Other , 307 的 Temporary Redirect , 400的 Bad Request , 401的 Unauthorized 等等.
onloadstart事件回调方法在ajax请求发送之前触发, 触发时机在 readyState==1 状态之后, readyState==2 状态之前.
onloadstart方法中默认将传入一个ProgressEvent事件进度对象. 如下:
ProgressEvent对象具有三个重要的Read only属性.
onprogress事件回调方法在 readyState==3 状态时开始触发, 默认传入 ProgressEvent 对象, 可通过 e.loaded/e.total 来计算加载资源的进度, 该方法用于获取资源的下载进度.
注意: 该方法适用于 IE10+ 及其他现代浏览器.
xhr.onprogress = function(e){
console.log('progress:', e.loaded/e.total);
}
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事件回调方法在ajax请求完成后触发, 触发时机在 readyState==4 状态之后(收到响应时) 或者 readyState==2 状态之后(未收到响应时).
onloadend方法中默认将传入一个ProgressEvent事件进度对象.
timeout属性用于指定ajax的超时时长. 通过它可以灵活地控制ajax请求时间的上限. timeout的值满足如下规则:
xhr.timeout = 0; //不生效
xhr.timeout = '123'; //生效, 值为123
xhr.timeout = '123s'; //不生效
xhr.timeout = ['123']; //生效, 值为123
xhr.timeout = {a:123}; //不生效
ontimeout方法在ajax请求超时时触发, 通过它可以在ajax请求超时时做一些后续处理.
xhr.ontimeout = function(e) {
console.error("请求超时!!!")
}
均为只读属性, response表示服务器的响应内容, 相应的, responseText表示服务器响应内容的文本形式.
只读属性, responseXML表示xml形式的响应数据, 缺省为null, 若数据不是有效的xml, 则会报错.
responseType表示响应的类型, 缺省为空字符串, 可取 "arraybuffer" , "blob" , "document" , "json" , and "text" 共五种类型.
responseURL返回ajax请求最终的URL, 如果请求中存在重定向, 那么responseURL表示重定向之后的URL.
withCredentials是一个布尔值, 默认为false, 表示跨域请求中不发送cookies等信息. 当它设置为true时, cookies , authorization headers 或者TLS客户端证书 都可以正常发送和接收. 显然它的值对同域请求没有影响.
但是务必要注意,withCredentials属性什么时机设置,XMLHttpRequest Living Standard(2017)中有明确的规定。
Setting the
withCredentialsattribute must run these steps:
- If state is not unsent or opened, throw an
InvalidStateErrorexception.- If the
send()flag is set, throw anInvalidStateErrorexception.- Set the
withCredentialsattribute’s value to the given value.
这意味着,readyState为unset或者opened之前,是不能为xhr对象设置withCredentials属性的,实际上,新建的xhr对象,默认就是unset状态,因此这里没有问题。问题出在w3c 2011年的规范,当时是这么描述的:
On setting the
withCredentialsattribute these steps must be run:
- If the state is not OPENED raise an
INVALID_STATE_ERRexception and terminate these steps.- If the
send()flag is true raise anINVALID_STATE_ERRexception and terminate these steps.- If the anonymous flag is true raise an
INVALID_ACCESS_ERRexception and terminate these steps.- Set the
withCredentialsattribute’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方法用于取消ajax请求, 取消后, readyState 状态将被设置为 0 (UNSENT). 如下, 调用abort 方法后, 请求将被取消.

getResponseHeader方法用于获取ajax响应头中指定name的值. 如果response headers中存在相同的name, 那么它们的值将自动以字符串的形式连接在一起.
console.log(xhr.getResponseHeader('Content-Type'));//"text/html"
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就是干这个的. 如下:
//指定请求的type为json格式
xhr.setRequestHeader("Content-type", "application/json");
//除此之外, 还可以设置其他的请求头
xhr.setRequestHeader('x-requested-with', '123456');
onerror方法用于在ajax请求出错后执行. 通常只在网络出现问题时或者ERR_CONNECTION_RESET时触发(如果请求返回的是407状态码, chrome下也会触发onerror).
upload属性默认返回一个 XMLHttpRequestUpload 对象, 用于上传资源. 该对象具有如下方法:
上述方法功能同 xhr 对象中同名方法一致. 其中, onprogress 事件回调方法可用于跟踪资源上传的进度.
xhr.upload.onprogress = function(e){
var percent = 100 * e.loaded / e.total |0;
console.log('upload: ' + precent + '%');
}
overrideMimeType方法用于强制指定response 的 MIME 类型, 即强制修改response的 Content-Type . 如下, 服务器返回的response的 MIME 类型为 text/plain .

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对象, 如下图.

XHR1 即 XMLHttpRequest Level 1. XHR1时, xhr对象具有如下缺点:
同源策略 限制, 只能请求同域资源.XHR2 即 XMLHttpRequest Level 2. XHR2针对XHR1的上述缺点做了如下改进:
xhr.upload.onprogress 事件回调方法获取传输进度.同源策略 限制, 这个安全机制不会变. XHR2新提供 Access-Control-Allow-Origin 等headers, 设置为 * 时表示允许任何域名请求, 从而实现跨域CORS访问(有关CORS详细介绍请耐心往下读).这里就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 对象是IE8,9折腾出来的, 用于支持CORS请求非成熟的解决方案. 以至于IE10中直接移除了它, 并重新回到了 XMLHttpRequest 的怀抱.
XDomainRequest 仅可用于发送 GET和 POST 请求. 如下即创建过程.
var xdr = new XDomainRequest();
xdr具有如下属性:
如下方法:
如下事件回调:
除了缺少一些方法外, XDomainRequest 基本上就和 XMLHttpRequest 的使用方式保持一致.
必须要明确的是:
$.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 | 请求完成后的回调(请求success 和 error之后均调用), 默认传入参数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新增) |
$.ajax() 方法返回jqXHR对象(jq1.5起), 如果使用的不是XMLHttpRequest对象时, 如jsonp请求, 返回的jqXHR对象将尽可能模拟原生的xhr. 从jq1.5起, 返回的jqXHR对象实现了promise接口, 具有如下新方法.
| 新方法 | 被替代的老方法(jq1.8起弃用) |
|---|---|
| done(function(data, textStatus, jqXHR) {}) | |
| fail(function(jqXHR, textStatus, errorThrown) {}) | |
| always(function(data or jqXHR, textStatus, jqXHR or errorThrown) {}) |
从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 | ❌ | - | function(data, textStatus, jqXHR){} | |
| 13 | $(document).ajaxSuccess | ✔️ | ✔️ | function(event, jqXHR, options){} |
| 14 | ❌ | - | 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对象绑定事件处理函数, 为其他元素绑定的事件处理函数不会起作用.
实际上, 如果你仅仅只是想要一个不错的http库, 相比于庞大臃肿的jquery, 短小精悍的Axios可能更加适合你. 原因如下:
“最近团队讨论了一下, Ajax 本身跟 Vue 并没有什么需要特别整合的地方, 使用 fetch polyfill 或是 axios、superagent 等等都可以起到同等的效果, vue-resource 提供的价值和其维护成本相比并不划算, 所以决定在不久以后取消对 vue-resource 的官方推荐.”
语法上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 服务 或者将其下载到本地.
说到ajax, 就不得不提及fetch, 由于篇幅较长, fetch已从本文中独立出来, 请戳 Fetch进阶指南 .
CORS是一个W3C(World Wide Web)标准, 全称是跨域资源共享(Cross-origin resource sharing).它允许浏览器向跨域服务器, 发出异步http请求, 从而克服了ajax受同源策略的限制. 实际上, 浏览器不会拦截不合法的跨域请求, 而是拦截了他们的响应, 因此即使请求不合法, 很多时候, 服务器依然收到了请求.(Chrome和Firefox下https网站不允许发送http异步请求除外)
通常, 一次跨域访问拥有如下流程:

当前几乎所有的桌面浏览器(Internet Explorer 8+, Firefox 3.5+, Safari 4+和 Chrome 3+)都可通过名为跨域资源共享的协议支持ajax跨域调用.
那么移动端兼容性又如何呢? 请看下图:

可见, CORS的技术在IOS Safari7.1及Android webview2.3中就早已支持, 即使低版本下webview的canvas在使用跨域的video或图片时会有问题, 也丝毫不影响CORS的在移动端的使用. 至此, 我们就可以放心大胆的去应用CORS了.
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-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma . 如果需要获取其他字段, 就需要在Access-Control-Expose-Headers 中指定. 如上, 这样xhr.getResponseHeader(‘X-Custom-Header’) 才能返回X-Custom-Header字段的值.(该部分摘自阮一峰老师博客)
Access-Control-Max-Age: 指定preflight OPTIONS请求的有效期, 单位为秒.
2) HTTP Request Header(浏览器OPTIONS请求默认自带):
3) 以下所有的header name 是被拒绝的:
Proxy- 或 Sec- 开头的header nameCORS请求分为两种, ① 简单请求; ② 非简单请求.
满足如下两个条件便是简单请求, 反之则为非简单请求.(CORS请求部分摘自阮一峰老师博客)
1) 请求是以下三种之一:
2) http头域不超出以下几种字段:
application/x-www-form-urlencoded、multipart/form-data、text/plain对于简单请求, 浏览器将发送一次http请求, 同时在Request头域中增加 Origin 字段, 用来标示请求发起的源, 服务器根据这个源采取不同的响应策略. 若服务器认为该请求合法, 那么需要往返回的 HTTP Response 中添加 Access-Control-* 等字段.( Access-Control-* 相关字段解析请阅读我之前写的CORS 跨域访问 )
对于非简单请求, 比如Method为POST且Content-Type值为 application/json 的请求或者Method为 PUT 或 DELETE 的请求, 浏览器将发送两次http请求. 第一次为preflight预检(Method: OPTIONS),主要验证来源是否合法. 值得注意的是:OPTION请求响应头同样需要包含 Access-Control-* 字段等. 第二次才是真正的HTTP请求. 所以服务器必须处理OPTIONS应答(通常需要返回20X的状态码, 否则xhr.onerror事件将被触发).
以上请求流程图为:

http-equiv 相当于http的响应头, 它回应给浏览器一些有用的信息,以帮助正确和精确地显示网页内容. 如下html将允许任意域名下的网页跨域访问.
<meta http-equiv="Access-Control-Allow-Origin" content="*">
通常, 图片允许跨域访问, 也可以在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实现文件上传非常简单, 这里我选取原生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";
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.");
}
上传结果如下所示:

5) fetch只要发送一个post请求, 并且body属性设置为formData即可. 遗憾的是, fetch无法跟踪上传的进度信息.
fetch(url, {
method: method,
body: formData
}).then(function(res){
console.log(res);
}).catch(function(e){
console.log(e);
});
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 ajax更多的api, 请参考中文文档 jQuery.ajax() | jQuery API 中文文档 .
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 .
处理二进制文件主要使用的是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), 类型化数组允许开发者以数组下标的方式, 直接操作内存, 由于数据以二进制形式传递, 效率非常高. |
| 读取文件(或blob对象)为二进制字符串, 该方法已移出标准api, 请谨慎使用. | |
| readAsDataURL() | 读取文件(或blob对象)为base64编码的URL字符串, 与window.URL.createObjectURL方法效果类似. |
| readAsText() | 读取文件(或blob对象)为文本字符串. |
| onload() | 文件读取完成时的事件回调, 默认传入event事件对象. 该回调内, 可通过this.result 或 event.target.result获取读取的文件内容. |
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();
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新特性之文件和二进制数据的操作 .
原生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.$q 或 Angular $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的一大痛点就是无法支持浏览器前进和后退操作. 因此早期的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简单易用, 仅需要如下三个api:
我们注意到, 首次进入一个页面, 此时 history.length 值为1, 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 事件对象. 该对象具有如下属性.

如有不懂, 更详细讲解请移步 : ajax与HTML5 history pushState/replaceState实例 « 张鑫旭-鑫空间-鑫生活 .
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举例, 其他浏览器类似)

前面已经提过, 通常只要是ajax请求收到了http状态码, 便不会进入到错误捕获里.(Chrome中407响应头除外)
实际上, $.ajax 方法略有区别, jquery的ajax方法还会在类型解析出错时触发error回调. 最常见的便是: dataType设置为json, 但是返回的data并非json格式, 此时 $.ajax 的error回调便会触发.
有关调试, 如果接口只是做小部分修改. 那么可以使用charles(Mac) 或者fiddler(Windows), 做代理, 将请求的资源替换为本地文件, 或者使用其断点功能, 直接编辑response.
如果是新增接口的调试, 可以本地搭建node服务. 利用hosts文件配置dns + nginx将http请求转发到本地node服务器. 简易的node调试服务器可参考我的 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了, 前端同学需要通过一些手段来确认接口是能正常访问的.
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
除此之外, 我们还可以通过chrome的postman扩展进行测试. 请看postman素洁的界面:

postman支持所有类型的http请求, 由于其向chrome申请了cookie访问权限及所有http(s)网站的访问权限. 因此可以放心使用它进行各种网站api的测试.
同时, 强烈建议阅读本文的你升级postman的使用技巧, 这里有篇: 基于Postman的API自动化测试 , 拿走不谢.
移动端的支持性,请看表.
| 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/
参考文章
]]>对于前端来说,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原生支持率.


以下是2020年7月4日更新的 Fetch 兼容性统计,除了 IE 系列,Fetch 兼容性基本没什么大问题。

可见要想在IE8/9/10/11中使用fetch还是有些犯难的,毕竟它连 Promise 都不支持, 更别说fetch了. 别急, 这里有polyfill(垫片).
es5-shim, es5-sham .es6-promise .fetch-ie8 .由于IE8基于ES3, IE9、IE10、IE11基于ES5,但支持不完全. 因此IE8+浏览器, 建议依次装载上述垫片.
先来看一个简单的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 对象.

该fetch可以在任何域名的网站直接运行, 且能正常返回百度搜索的建议词条. 以下是常规输入时的是界面截图.

以下是刚才fetch到的部分数据. 其中key name 为”s”的字段的value就是以上的建议词条.(由于有高亮词条”12306”, 最后一条数据”12366”被顶下去了, 故上面截图上看不到)

看完栗子过后, 就要动真格了. 下面就来扒下 Fetch.
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);
});
运行截图如下:

② 我们不妨在非 https://sp0.baidu.com 域名的网页控制台再次运行以上代码.(别忘了给fetch的第二参数传递{mode: “no-cors”})
运行截图如下:

由于第一次进入then分支后, 返回了否定结果的 Promise.reject 对象. 因此代码进入到catch分支, 抛出了错误. 此时, 上述 response.type 为 opaque .
一个fetch请求的响应类型(response.type)为如下三种之一:
如上情景①, 同域下, 响应类型为 “basic”.
如上情景②中, 跨域下, 服务器没有返回CORS响应头, 响应类型为 “opaque”. 此时我们几乎不能查看任何有价值的信息, 比如不能查看response, status, url等等等等.

同样是跨域下, 如果服务器返回了CORS响应头, 那么响应类型将为 “cors”. 此时响应头中除 Cache-Control , Content-Language , Content-Type , Expores , Last-Modified 和 Progma 之外的字段都不可见.
注意: 无论是同域还是跨域, 以上 fetch 请求都到达了服务器.
fetch可以设置不同的模式使得请求有效. 模式可在fetch方法的第二个参数对象中定义.
fetch(url, {mode: 'cors'});
可定义的模式如下:
除此之外, 还有两种不太常用的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
在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'
})
});
跨域请求中需要带有cookie时, 可在fetch方法的第二个参数对象中添加credentials属性, 并将值设置为”include”.
fetch(url,{
credentials: 'include'
});
除此之外, credentials 还可以取以下值:
同 XMLHttpRequest 一样, 无论服务器返回什么样的状态码(chrome中除407之外的其他状态码), 它们都不会进入到错误捕获里. 也就是说, 此时, XMLHttpRequest 实例不会触发 onerror 事件回调, fetch 不会触发 reject. 通常只在网络出现问题时或者ERR_CONNECTION_RESET时, 它们才会进入到相应的错误捕获里. (其中, 请求返回状态码为407时, chrome浏览器会触发onerror或者reject掉fetch.)
cache表示如何处理缓存, 遵守http规范, 拥有如下几种值:
如果fetch请求的header里包含 If-Modified-Since, If-None-Match, If-Unmodified-Since, If-Match, 或者 If-Range 之一, 且cache的值为 default , 那么fetch将自动把 cache的值设置为 "no-store" .
回调深渊一直是jser的一块心病, 虽然ES6提供了 Promise, 将嵌套平铺, 但使用起来依然不便.
要说ES6也提供了generator/yield, 它将一个函数执行暂停, 保存上下文, 再次调用时恢复当时的状态.(学习可参考 Generator 函数的含义与用法 - 阮一峰的网络日志) 无论如何, 总感觉别扭. 如下摘自推库的一张图.

我们不难看出其中的差距, callback简单粗暴, 层层回调, 回调越深入, 越不容易捋清楚逻辑. Promise 将异步操作规范化.使用then连接, 使用catch捕获错误, 堪称完美, 美中不足的是, then和catch中传递的依然是回调函数, 与心目中的同步代码不是一个套路.
为此, ES7 提供了更标准的解决方案 — async/await. async/await 几乎没有引入新的语法, 表面上看起来, 它就和alert一样易用, 虽然它尚处于ES7的草案中, 不过这并不影响我们提前使用它.
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]);
}
})();
鉴于目前只有Edge支持 async/await, 我们可以使用以下方法之一运行我们的代码.
随着node7.0的发布, node中可以使用如下方式直接运行:
node --harmony-async-await test.js
babel在线编译并运行 Babel · The compiler for writing next generation JavaScript .
本地使用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');
本地使用traceur编译es6或更高版本es.请参考 在项目开发中优雅地使用ES6:Traceur & Babel .
fetch基于Promise, Promise受限, fetch也难幸免. ES6的Promise基于 Promises/A+ 规范 (对规范感兴趣的同学可选读 剖析源码理解Promises/A规范 ), 它只提供极简的api, 没有 timeout 机制, 没有 progress 提示, 没有 deferred 处理 (这个可以被async/await替代).
除此之外, 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)
});
由于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方法, 因此使用时要注意绕开这个坑.
同上, 由于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."
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/
参考文章
]]>截至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以及与之息息相关的位运算, 一探究竟.
众所周知, 如果需要使用js进行一些常规的数学运算, 是一件十分麻烦的事情. 为了解决这个问题, ECMAScript 在1.1版本中便引入了 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对象本就有很多用于运算的方法, 值得关注的是, 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.prototype中有一个方法叫做toFixed(), 用于将数值装换为指定小数位数的形式.
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.max.apply(null,[5,3,8,9]); // 9 . 但是Math.min 不传参数返回 Infinity, Math.max 不传参数返回 -Infinity .除去上述方法, 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
这表明:
同样, 负数的二进制转十进制时, 符号位不变, 其他位取反后+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, 我们可以对非整数取整. 即 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, 我们便可以将非整数取整. 即 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
实际上, 按位非(~)操作不需要这么兴师动众地去计算, 它有且仅有一条运算规律:
~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
对于按位异或(^)操作, 满足如下规律:
8^8=0 , 公式为 a^a=0 .0^-98=-98 , 公式为 0^a=a.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次方 .-x-1, 相当于~x运算 . 如: -1^100=-101 , -1^-9=8 . 公式为 -1^x=-x-1=~x .3^8^8=3 , 公式为 a^b^b=a 或 a^b^a=b .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.<<运算符, 表示将数值的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.
对于表达式 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
比如 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时不用括号括起来, 这是因为-优先级高于&
不妨先判断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.本问就讨论这么多内容, 如果您有什么问题或好的想法欢迎在下方参与留言和评论.
本文作者: louis
本文链接: http://louiszhai.github.io/2016/07/01/Math/
参考文章
]]>你有没有在搜索文本的时候绞尽脑汁, 试了一个又一个表达式, 还是不行.
你有没有在表单验证的时候, 只是做做样子(只要不为空就好), 然后烧香拜佛, 虔诚祈祷, 千万不要出错.
你有没有在使用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 便是一个分组.
\ 即转义字符, 通常 \ * + ? | { [ ( ) ] }^ $ . # 和 空白 这些字符都需要转义.
javaScript中正则表达式默认有如下五种修饰符:
我们来测试下上面的知识点, 写一个匹配手机号码的正则表达式, 如下:
(\+86)?1\d{10}
① “\+86” 匹配文本 “+86”, 后面接元字符问号, 表示可匹配1次或0次, 合起来表示 “(\+86)?” 匹配 “+86” 或者 “”.
② 普通字符”1” 匹配文本 “1”.
③ 元字符 “\d” 匹配数字0到9, 区间量词 “{10}” 表示匹配 10 次, 合起来表示 “\d{10}” 匹配连续的10个数字.
以上, 匹配结果如下:

密码验证是常见的需求, 一般来说, 常规密码大致会满足规律: 6-16位, 数字, 字母, 字符至少包含两种, 同时不能包含中文和空格. 如下便是常规密码验证的正则描述:
var reg = /(?!^[0-9]+$)(?!^[A-z]+$)(?!^[^A-z0-9]+$)^[^\s\u4e00-\u9fa5]{6,16}$/;
在 linux 和 osx 下, 常见的正则表达式, 至少有以下三种:
| 字符 | 说明 | 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’) |
| \ |
匹配一个单词或者一个特定字符,例如:’\ |
\ |
\ |
不支持 | 不支持(但可以使用\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:] |
注意
我曾经尝试在 grep 和 sed 命令中书写正则表达式, 经常发现不能使用元字符, 而且有时候需要转义, 有时候不需要转义, 始终不能摸清它的规律. 如果恰好你也有同样的困惑, 那么请往下看, 相信应该能有所收获.
grep 支持:BREs、EREs、PREs 正则表达式
grep 指令后不跟任何参数, 则表示要使用 “BREs”
grep 指令后跟 ”-E” 参数, 则表示要使用 “EREs”
grep 指令后跟 “-P” 参数, 则表示要使用 “PREs”
egrep 支持:EREs、PREs 正则表达式
egrep 指令后不跟任何参数, 则表示要使用 “EREs”
egrep 指令后跟 “-P” 参数, 则表示要使用 “PREs”
sed 支持: BREs、EREs
sed 指令默认是使用 “BREs”
sed 指令后跟 “-r” 参数 , 则表示要使用“EREs”
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,有下面两种可能:
有关反向引用,其他非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] 能够匹配成功, 所以它符合情况②. 下面我们来还原情况②的执行现场.
相应的流程图如下:

遗憾的是, 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”.
假如现在, 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() 返回当前对象的”本地化”字符串形式.
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})+$). 我们先来看后面的部分, 然后逐步分析之.
(?=([0-9]{3})+$) 就表示匹配一个零宽度的位置, 并且从这个位置到字符串末尾, 中间拥有3的正整数倍的数字.(?!^) 就指定了这个替换的位置不能为起始位置.千位分隔符实例, 展示了环视的强大, 一步到位.
ES6对正则扩展了又两种修饰符(其他语言可能不支持):
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修饰符. 这点将会在后面讲到.
var s = "𝌆";
console.log(/^.$/.test(s));//false
console.log(/^.$/u.test(s));//true
有关字节码点, 稍微提下. 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可以借助大括号识别𝌆
有关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 对象表示正则表达式, 主要用于对字符串执行模式匹配.
语法: 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(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 方法用于检测一个字符串是否匹配某个正则规则, 只要是字符串中含有与正则规则匹配的文本, 该方法就返回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 方法用于检测字符串对正则表达式的匹配, 如果找到了匹配的文本, 则返回一个结果数组, 否则返回null.
语法: exec(string)
exec 方法返回的数组中包含两个额外的属性, 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) 方法执行结果完全相同.
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中新增了 pattern 属性, 规定了用于验证输入字段的模式, pattern的模式匹配支持正则表达式的书写方式. 默认 pattern 属性是全部匹配, 即无论正则表达式中有无 “^”, “$” 元字符, 它都是匹配所有文本.
注: pattern 适用于以下 input 类型:text, search, url, telephone, email 以及 password. 如果需要取消表单验证, 在form标签上增加 novalidate 属性即可.
目前正则引擎有两种, DFA 和 NFA, NFA又可以分为传统型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/
参考文章
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.
下列元素默认拥有 layout:
以下css样式的设置, 会触发元素的 hasLayout:
以下css样式的设置, 会清除已经触发的 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
本文就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.
本文作者: louis
本文链接: http://louiszhai.github.io/2016/03/31/css-hasLayout/
参考文章
]]>