下面的大部分问题来自Github的这个仓库,排名不分先后

1. JS中如何定义自定义事件,实现订阅/发布模式

明确需求:可以通过onemit绑定和触发事件。

方案:创建全局事件管理器events,构建事件名和回调函数数组的键值对。onemit分别写和读events。大概像下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var EventUtil = {
// 全局事件管理
var events = {},
// 注册事件
on = function (type, handler) {
if (events[type]) {
events[type].push(handler);
} else {
events[type] = [handler];
}
},
// 触发事件
emit = function (type) {
if (!events[type]) return;
for (var i = 0, len = events[type].length; i < len; i++) {
events[type][i];
}
};
};

当使用Object.assign实现继承时,会出现events共享的问题。可以通过在第一次调用on时,通过Object.defineProperty的方式创建避免共享。

2. js中的this

首先,this永远是对象。

  • 全局上下文内,this全局对象
  • 函数上下文内,根据调用场景分情况讨论
    • 直接调用:全局对象
    • 通过对象的方法调用:调用方法的对象
    • 构造函数中:即将被创建的对象,有return语句时,以return的返回值为准
    • call和apply:传入的第一个值
    • bind方法:永久绑定到第一个参数

3. js跨域问题和解决方案

跨域(Cross-domain)是网景最初基于安全性考虑提出的策略。意为不同域名不同协议不同端口间的Ajax通信是被禁止的。根据使用需求,可以分为跨站请求资源和跨页面共享资源(我自己发明的说法)

跨站请求资源

  • jsonp(json with padding)跨域,利用了<script>标签的可跨域完成,自己写一遍就能搞懂
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    function getJSONP(url, success) {

    var ud = '_' + +new Date,
    script = document.createElement('script'),
    head = document.getElementsByTagName('head')[0]
    || document.documentElement;

    window[ud] = function(data) {
    head.removeChild(script);
    success && success(data);
    };

    script.src = url.replace('callback=?', 'callback=' + ud);
    head.appendChild(script);
    }
  • CORS,使用CORS进行跨域请求时,在现代浏览器中已经可以像普通Ajax请求那样使用XMLHttpRequest即可,可以参考这个。需要后台服务器支持
  • 后台反向代理,需要一台中转的服务器
  • 建立websocket通信,需要后台服务器支持

跨页面共享资源,结合<iframe>有以下几种方案

  • 修改document.domain,使两个页面位于同一域名下,注意只能从精确->模糊修改域名
  • 通过window.name传递消息,利用了iframe location变化后,window.name不变的特点
  • location.hash
  • html5中的postMessage API在不同window间传递消息

这里附上一个链接.

4. js的作用域链

这是JavaScript最有特点同时也是最基础的内涵之一。红宝书和犀牛书都做了详尽和透彻的解释。这个问题理解了,什么是闭包就能很好地理解了。

执行环境是JavaScript中最为重要的一个概念。执行环境定义了变量或函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。虽然我们便习得代码无法访问这个对象,但解析器在处理数据时会在后台使用它。

全局执行环境是最外围的一个执行环境。…。某个执行环境中的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也随之销毁(全局环境知道应用程序退出时才会销毁)
每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。而在函数执行后,栈将其环境弹出,把控制权返回给之前的执行环境。ECMAScript程序中的执行流正是有这个方便的机制控制着。

当代码在一个环境中执行时,会创建变量对象的一个作用域链(scope chain)。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,时钟都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其活动对象做为变量对象。活动对象在最开始时只包含一个变量,即arguments对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境,而在下一个变量对象则来自下一个包含环境。这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中最后一个对象。

5. Function.callFunction.apply的区别

callapply同为改变this的方法,前者一个一个接收输入参数,后者以数组的形式接收。

6. 浏览器渲染页面的原理

可以参考经典的文章how browsers work,或者中译版.

7. 列举一些HTML5的改进

可以参考MDN给出的summary。比如:

  • 语义化,语义化标签<header>, <article>,语义化元素<figure>, <time>等和新的多媒体标签<audio>, <video>
  • 网络通信,Websocket,WebRTC
  • 图像,Canvas和WebGL
  • 离线存储,Storage接口和IndexDB
  • 性能,Web Worker,XMLHttpRequest2(支持进度等新特性),History API,Fullscreen,PointerLock,requestAnimationFrame等
  • CSS,CSS3的特性,有些甚至演进到了Level 4

8. HTML5中的定位API

Geolocation API,新的API,红宝书中有提到。通过navigator.geolocation对象实现,允许用户提供自己的所在地理位置。需要用户确认同意才可使用。最常用的方法是getCurrentPosition()。这个方法接受三个参数——成功回调、可选的失败回调、可选的选项。

类似的不常见的API还有Battery APIFile APIperformance等。

9. 一些前端框架的双向绑定原理

不是所有框架都提倡双向绑定。有的框架如Angular使用数据双向绑定,适合于表单很多的站点,React和Vue这样的使用的是单向绑定。在单向绑定背景下,可以通过addEventListener实现双向绑定。

实现原理上分为几种:

  • 发布-订阅模式,显式地绑定事件和监听函数。backbone就是这么做的,显式地通过Model.set修改数据触发change事件来更新视图。React也是通过setState显式地触发虚拟DOM树更新和重新渲染的。
  • 脏检查(digest cycle),通过特定事件触发脏检查。脏检查即一种不关心你如何以及何时改变的数据,只关心在特定的检查阶段数据是否改变的数据监听技术。过程大致是$update或其他手段触发digest阶段,遍历通过$watch绑定的watcher。对比值是否改变触发更新。优点是无需知道更改数据的方式,可以统一更新view,缺点是watcher较多时会有严重的性能问题。
  • 数据劫持Object.defineProperty,Vue使用这种方式实现隐式的绑定(当然在具体实现中复杂了许多)。这么做的问题是版本只支持到IE9+,且在数组更新时有所局限。

10. webpack的配置文件写法

除了常用的entry, output, module, plugins外,webpack的使用方法实在太多,建议去官网查看完整的配置信息。

11. node文件和网络API

文件操作上,常用的有fs.readFileSyncfs.writeFileSync,或通过流的方式使用fs.createReadStreamfs.createWriteStream。还有pipe将流连接在一起。除此之外,pathjoinnormalize常用在处理文件路径。

和网络操作相关的包包括http, https, url, querystring, zlib等。其中前两个包更为常用,尤其是http.createServer方法。

另外,在进程上有process, child_process等包。这里 有一篇文章做了比较详细的介绍。当然,有空最好还是去官方文档.

12. 和@import的区别

它们的最常见的使用方式都是引入CSS文件到html中。它们的区别在于

  • link是XHTML标签,除了加载CSS外,还可以引入RSS等其他资源;@import属于CSS范畴,只能加载CSS。
  • link引用CSS时,在页面载入时同时加载;@import需要页面网页完全载入以后加载。
  • link是XHTML标签,无兼容问题;@import是在CSS2.1提出的,低版本的浏览器不支持。
  • 由于link是标签,可以通过JavaScript控制来改变样式,后者不行。

13. cookie,localStorage和sessionStorage的区别和联系

cookie设计的初衷是用来为无状态的http访问添加用户状态消息的。大小4KB以下,会携带在请求头中。大多包含敏感信息,会和服务器端的session配合使用。

Storage API是HTML5的新API。又可以细分为localStorage和sessionStorage。它们一般只存储在客户端,用来缓存用户非敏感数据,大小因浏览器而异,容量约可达到5MB。sessionStorage在浏览器关闭后清除,localStorage则在超过时限或手动clear后清除。

cookie中的内容很少变化,且最好秘文储存,并通过HttpOnly添加限制(后台修改set-cookie头)。Storage则很可能会频繁读写。

14. HTTP状态码

根据状态码开头的数字确定状态码类型。下面列举一些常用的。

1xx 信息:这一类型的状态码,代表请求已被接受,需要继续处理。这类响应是临时响应。

  • 100 继续:客户端应当继续发送请求。
  • 101 切换协议:将通过Upgrade消息头通知客户端采用不同的协议来完成这个请求。

2xx 成功:这一类型的状态码,代表请求已成功被服务器接收、理解、并接受。

  • 200 OK:请求已成功,请求所希望的响应头或数据体将随此响应返回
  • 201 已创建:请求已经被实现,而且有一个新的资源已经依据请求的需要而创建,且其URI已经随Location头信息返回
  • 202 已接受:服务器已接受请求,但尚未处理
  • 204 No Content:服务器成功处理了请求,但不需要返回任何实体内容,用户浏览器应保留发送了该请求的页面
  • 205 Reset Content:和204的唯一不同是返回此状态码的响应要求请求者重置文档视图
  • 206 服务器已经成功处理了部分GET请求。该请求必须包含Range头信息来指示客户端希望得到的内容范围,多用于下载工具

3xx 重定向:这类状态码代表需要客户端采取进一步的操作才能完成请求。通常,这些状态码用来重定向,后续的请求地址(重定向目标)在本次响应的Location域中指明。

  • 300 多选择:被请求的资源有一系列可供选择的回馈信息,每个都有自己特定的地址和浏览器驱动的商议信息。用户或浏览器能够自行选择一个首选的地址进行重定向。
  • 301 永久移动:被请求的资源已永久移动到新位置
  • 302 临时移动:请求的资源现在临时从不同的URI响应请求
  • 303 重定向:对应当前请求的响应可以在另一个URI上被找到,而且客户端应当采用GET的方式访问那个资源
  • 304 如果客户端发送了一个带条件的GET请求且该请求已被允许,而文档的内容(自上次访问以来或者根据请求的条件)并没有改变
  • 305 使用中介:被请求的资源必须通过指定的代理才能被访问

4xx 客户端错误:代表了客户端看起来可能发生了错误,妨碍了服务器的处理

  • 400 无法理解的请求:由于包含语法错误,当前请求无法被服务器理解
  • 401 需要验证:当前请求需要用户验证。响应必须包含一个适用于被请求资源的WWW-Authenticate信息头用以询问用户信息。
  • 403 禁止访问:服务器已经理解请求,但是拒绝执行它
  • 404 未找到:请求所希望得到的资源未被在服务器上发现
  • 405 方法不允许:请求行中指定的请求方法不能被用于请求相应的资源,响应中必须返回一个Allow头信息用以表示出当前资源能够接受的请求方法的列表
  • 406 头部不对:请求的资源的内容特性无法满足请求头中的条件
  • 408 请求超时:客户端没有在服务器预备等待的时间内完成一个请求的发送
  • 411 需要指定长度:服务器拒绝在没有定义Content-Length头的情况下接受请求
  • 413 请求实体太长
  • 414 URI太长

5xx 服务器错误:代表了服务器在处理请求的过程中有错误或者异常状态发生

  • 500 内部错误:一般来说,这个问题会在服务器的代码出错时出现
  • 501 未实现:服务器不支持当前请求所需要的某个功能
  • 502 Bad GateWay:作为网关或者代理工作的服务器尝试执行请求时,从上游服务器接收到无效的响应
  • 503 服务不可达:由于临时的服务器维护或者过载,服务器当前无法处理请求。这个状况是临时的,并且将在一段时间以后恢复。
  • 504 网关超时:作为网关或者代理工作的服务器尝试执行请求时,未能及时从上游服务器或者辅助服务器收到响应
  • 505 HTTP协议版本不正确

15. URL去参数

location.search, location.href, location.origin分别代表url中的querystring,完整url和域名。再结合location.pathname, location.portlocation.protocol可以得到任意想要的URL参数。

另外,新的APIURLSearchParams中有些方法可以对querystring做方便的增删改查的操作。

  • append增加一个检索参数
  • delete删除一个检索参数
  • get获取检索参数的第一个值
  • getAll获取检索参数的所有值
  • has检查是否存在某检索参数
  • set设置一个检索参数的新值,会覆盖原值
  • keysvalues分别返回键和值组成的数组

16. js中的正则匹配

js中的正则匹配和Perl的正则匹配规则基本类似。在js中,使用一个正则表达式字面量,由包含在斜杠之间的模式组成。正则表达式同时也是RegExp对象。除了简单模式外,考察对正则表达式的熟悉在它的特殊字符使用上。

一些常见的特殊字符:

  • \ 用于转义

  • ^ 用于匹配开始或表示一个反向字符集(如[^xyz]

  • $ 用于匹配结尾

  • * 匹配前一个表达式0或多次 = {0,}

  • + 匹配前一个表达式1或多次 = {1,}

  • ? 匹配0或1次 = {0,1};紧跟量词后使匹配非贪婪

  • . 匹配除换行符外任何单字符

  • (x) 捕获匹配,会包括在最后结果中,也可以通过$1, $n来访问

  • (?:x) 非捕获分组,匹配但不捕获

  • x(?=y) 断言匹配,捕获后跟y的x

  • x|y 匹配x或y

  • {n} 量词,匹配n次,还有{n,m}和{n,}的用法

  • [xyz] 字符集,可以使用-连接,如[x-z]

  • \d 一个数字

  • \D 一个非数字

  • \s 一个空白字符,包含空格,制表符,分页符,换行符

  • \S 一个非空白字符

  • \w 一个单字字符,等价于[A-Za-z0-9_]

  • \W 一个非单字字符

另外,正则表达式还有几个可选参数辅助搜索类型

  • g 全局搜索
  • i 不区分大小写
  • m 多行搜索
  • y 粘性搜索

有一些方法用于和正则表达式相关

  • exec 在字符串中执行匹配,返回匹配结果
  • test 测试是否能匹配RegExp,返回true或false
  • match 对字符串执行查找匹配的String方法,返回匹配结果
  • search 在字符串中测试匹配,返回位置索引或-1
  • replace 在字符串中执行查找匹配,并使用替换字符串替换匹配的子字符串
  • split 使用一个正则表达式或字符串分割一个字符串,并储存在数组中

常见的考法有,书写一个邮箱或手机号的正则表达式:

  • 邮箱 /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
  • 手机号 /^0*(13|15|18|14|17)(\d{9}|\d-\d{3}-\d{5}|\d-\d{4}-\d{4}|\d{2}-\d{3}-\d{4}|\d{2}-\d{4}-\d{3})$/

17. MVC和MVVM框架

框架模式不是一门写代码的学问,而是一门管理与组织代码的学问。其本质是一种软件开发的模型。与设计模式不同,设计模式是在解决某类特定问题时总结抽象出的公共方法,是方法论的范畴,一种框架模式往往使用了多种设计模式,且和技术栈有耦合的关系。

视图(View)从本质上讲是数据在图像上的一种体现和映射。用户在操作图像时可以达到操作数据的目的,在数据更改后,需要重新将数据映射到视图上。这实际上就是MVC的出发点。

  • View: 放置视图相关的代码,原则上里面不应该有任何业务逻辑。
  • Controller: 放置视图与模型之间的映射,原则上这里应该很薄,他只放一些事件绑定相关的代码(router),但并不实现真正的功能,他只是一个桥梁。
  • Model: 这里的model不是说实体类,它是主要实现业务逻辑的地方。

开发流程是先创建视图组件,再将之关联到Model上,通过View修改Model中的值时,Model会触发绑定在之上的所有View的更新。Backbone是个典型的例子。这么做部分分离了视图和逻辑。但是,在情况复杂时,Model的代码量将会大大膨胀。

MVP因此而生,其中Presenter(分发器)代替了原来的Controller,分担了Model的部分功能。针对上面的问题,Presetner隔断了Model和View,当M改变时,会通知P去更新视图。业务逻辑和绑定逻辑从V和M中分离出来到P中。使得MVP三方分工更加鲜明。绝大多数的PHP框架都是MVP类型的。

MVVM是Model-View-ViewModel的缩写。在MVVM中,View和ViewModel是双向或单向数据绑定的关系。当ViewModel反应了Model中的数据模型,并绑定到视图属性上,反过来,视图属性变化后也会通过ViewModel影响Model。React,Vue这些流行的前端框架都是MVVM类型的。

不管是MVC还是MVP或MVVM,他们都是数据驱动的。核心上基于M推送消息,V或P来订阅这个模型。使用者需要维护的不再是UI树,而是抽象的数据。当UI的状态一旦多起来,这种框架模式的优势就很明显了。

本文基于Express 4.15.2

我之前的Express学习笔记还在这里

Express常被用来进行Node.js后台的快速搭建。它仅仅对Node.js进行了简单的封装,结合中间件等很自然和好用的概念,很容易上手和学习。Express的API主要包括Application,Request,Response,Router几个部分,这是Express代码主要实现的部分。在我看来,Express贵在它的中间件,它提供了足够自由的空间但也做出规范,提供req, res, next, err给中间件操作。它的生态系统也是围绕这个展开的。

创建服务器

让我们从头回忆,用Express创建一个简单的服务器。像下面这样。

1
2
3
4
5
6
7
8
9
10
var express = require('express')
var app = express()

app.get('/', function (req, res) {
res.send('Hello World!')
})

app.listen(3000, function () {
console.log('Hello world!')
})

先通过构造函数创建一个Express应用(Application)。接着为它指定路由规则。最后通过app.listen()的方式启动服务器。对比下Node.js原生的写法:

1
2
3
4
5
6
var http = require('http');

var server = http.createServer(function(request, response) {
res.write('Hello world');
res.end();
}).listen(3000);

那么Express的app究竟是什么,路由中间件又是如何绑定上去的。这些问题需要通过源码来解答。

代码结构

Express代码整体设计并不复杂(相对于Vue这样的复杂设计),比较容易看懂,一些常见的功能已经事先写成依赖包抽取出来,如debug(打印debug信息)和deprecate(显示API已废弃)等。Express的源码部分位于lib/路径下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
- lib/
- middleware/
- init.js
- query.js
- router/
- index.js
- layer.js
- route.js
- application.js
- express.js
- request.js
- response.js
- utils.js
- view.js
...
- index.js
...
1
2
...
module.exports = require('./lib/express');

根目录下的index.js是整个项目的入口,所做的工作只是引入lib/中的express.jslib/目录下middleware目录下放置了内置的中间件,router中放置中间件的功能实现。下面的几个文件中

  • application.js 应用的定义,app对象的API
  • express.js,对app,router等功能的封装
  • request.js和response.js是对http中res以及req的封装和增强
  • utils.js 常用工具函数的封装
  • view.js 建立拓展名和渲染引擎的联系

Application

express.jsApplication.js大致告诉我们了expressapp究竟为何物。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//express.js
exports = module.exports = createApplication;

function createApplication() {
// 创建一个app
var app = function(req, res, next) {
app.handle(req, res, next);
};

//继承EventEmitter和Application.js中定义的app对象
mixin(app, EventEmitter.prototype, false);
mixin(app, proto, false);

...
// 初始化app
app.init();
return app;
}

/*
* 一些暴露公用接口的操作
*/

/*
* 告知一些方法已废弃
*/

上面的mixin引入自merge-description,功能非常简单——通过描述符融合两个对象并返回,源码也很简单,主要由getOwnPropertyDescriptordefineProperty方法实现,感兴趣的可以一看。

app通过mixin继承了两个预定义对象,其中EventEmitter来自Node.js的API,继承后app将获得事件发布订阅功能。protoApplication.js导出定义。其中定义了app.listen()方法。

1
2
3
4
app.listen = function listen() {
var server = http.createServer(this);
return server.listen.apply(server, arguments);
};

综上就很明白了

  • 首先,express()返回的app其实是一个函数,可以接受req, res以及定义在Express中的next
  • 之后,app.listen()方法实际上执行了http.createServer(app).listen()

总结,**express()是个工厂函数,负责生产作为http.createServer(app)的参数**。之后,让我们继续深入看看Application.js中的代码。

app的工作主要是在Application.js中进行的。下面介绍一些它的属性和相关方法

  • cacheObject,缓存视图信息
  • engineObject,视图渲染引擎
    • engine(ext, fn),绑定拓展名和渲染方法,返回app自身用于链式调用
  • settings,app的设置信息,可以通过setget方法设置和获取。
    • get(setting)(或set(setting))获取配置值,这里是完整的配置表
    • set(setting, value)设置配置值
    • enabled(setting)disabled(setting)语法糖,获取Boolean类型配置值
    • enable(setting)disable(setting)语法糖,设置Boolean类型配置值
  • locals在app生命周期内存储用户信息
  • mountpath 顶级app挂载路径
    • path()返回app的挂载路径
    • use(fn)挂载中间件时可以指定第一个参数为挂载路径
  • router 该路由中间件属性在4.x中已废弃,由route(path)方法替代
  • _routerlazyrouter内部方法中加载,Router对象,中间件的容器。详细介绍在下文的Router一节中。

除了这些属性外,还有一些常见或内部使用的方法

  • defaultConfiguration初始化默认配置
  • lazyrouter自身路由的懒加载,原因写在了源码注释
  • handle(req, res, cb)最关键的app方法,用于分发请求完成路由功能。但它实际上只是对router.handle做了简单的封装。并在最后通过finalhandler来做兜底。finalhandler是个很轻量级的依赖包,用于生成404页面和记录错误。详细文档见github.
  • use(fn)最关键的app方法,用于添加中间件,主要逻辑是将arguments中的pathfn列表拆开来,再调用router的use方法注册
  • route(path)调用router的route方法注册一个路由中间件
  • param(name, fn)在自身的router对象中注册参数匹配回调
  • VERB(path, fn)为HTTP动词绑定路径和处理函数,主要功能也是由router代劳。有意思的是,HTTP动词是通过methods这个依赖包来返回的,这个包原理也非常简单——http.METHODS
  • all(path)用来匹配任意一个HTTP动词,其余功能和上面相同,实现上即将该路由中间件的所有动词都绑定上处理函数。
  • del 已废弃
  • render(name, options, cb)调用渲染引擎渲染模板
  • listen()即上文提到的方法,对http.listen的封装

中间件

中间件是Express设计的最精妙的地方,也是工作量最大的地方。之前Express利用了Connect来做这项工作。在当前版本中放在了router目录下去实现。整体来看,一个Express应用就是一系列的中间件首尾相接组成的。那么中间件是什么呢?用官网的话说就是“Middleware functions are functions that have access to the request object (req), the response object (res), and the next function in the application’s request-response cycle.”从code的角度来看,就是下面这样

1
2
3
4
var myMiddleware = function(req, res, next){
// do something
next();
};

如果是错误处理的中间件,需要将err传入为第一个参数

1
2
3
4
var myErrorMiddleware = function(req, res, next){
// do something
next();
};

所以,一个请求进入Express后,处理的流程大致是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13

---------------
| middleware1 |
---------------

---------------
| ... ... ... |
---------------

---------------
| middlewareN |
---------------

其中每个中间件能拿到reqres抑或err,在完成自身工作后调用next()执行下一个中间件。那么这些middleware是怎么放置在这条链上,请求又是如何通过next()一步步向下传递的呢?

中间件分两种:普通中间件和路由中间件。它们都通过handle方法处理;前者通过use方法注册,后者通过VERB(或all)方法注册。其中

  • 前者匹配所有以path开始的路径,而后者会精确匹配path路径;
  • 前者对于请求的方法不做要求,而后者只能处理指定的请求。
1
2
3
4
5
6
7
app.use('/user', function(req, res, next) {
// do something
});

app.get('/user', function(req, res, next) {
// do something
});

app层面

在app层面,即Application.js中,是由app.use(fn), app.VERB(或app.all)和app.handle(req, res, cb)完成的。而它们只是router的同名方法的简单封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
app.use = function use(fn) {
var offset = 0;
var path = '/';

// 由于这个API支持多参数,需要先判断第一个参数是否为路径
// 并通过offset存储结果
if (typeof fn !== 'function') {
var arg = fn;

while (Array.isArray(arg) && arg.length !== 0) {
arg = arg[0];
}

// first arg is the path
if (typeof arg !== 'function') {
offset = 1;
path = fn;
}
}

// 根据offset获取中间件函数
var fns = flatten(slice.call(arguments, offset));

...

// 调用lazyrouter
...

fns.forEach(function (fn) {
// 原生函数时,调用router.use注册即可
if (!fn || !fn.handle || !fn.set) {
return router.use(path, fn);
}

...

// 中间件为router或app时,handle方法才是我们需要的
// 储存req和res的app属性到处理它的app
router.use(path, function mounted_app(req, res, next) {
var orig = req.app;
fn.handle(req, res, function (err) {
setPrototypeOf(req, orig.request)
setPrototypeOf(res, orig.response)
next(err);
});
});

...
}, this);

return this;
};

其中flatten用于将多层嵌套数组扁平化为1层。可见到,app的use方法在做了预处理工作后,调用router的use完成注册工作。

需要路由中间件时,我们需要使用动词对应的方法(或all)去注册。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
methods.forEach(function(method){
// 根据形参个数避免app.get带来的歧义
app[method] = function(path){
if (method === 'get' && arguments.length === 1) {
// app.get(setting)
return this.set(path);
}

this.lazyrouter();

// 调用router的route方法构造一个路由中间件
var route = this._router.route(path);
// 为路由中间件特定方法指定处理函数
route[method].apply(route, slice.call(arguments, 1));
return this;
};
});

app的handle方法就比较简单了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
app.handle = function handle(req, res, callback) {
var router = this._router;

// 创建兜底的final handler
var done = callback || finalhandler(req, res, {
env: this.get('env'),
onerror: logerror.bind(this)
});

// 没有路由对象时,就可以结束了
if (!router) {
debug('no routes defined on app');
done();
return;
}

// 调用router的handle方法
router.handle(req, res, done);
};

Router层面


Express里中间件的具体实现在Router对象中。Router包含了Express中最为核心的概念。app中的许多API都是对Router API的简单封装。可以通过app._router来访问app的Router对象。Router的源码位于lib/router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var proto = module.exports = function(options) {
var opts = options || {};

//Router本身也是一个函数
function router(req, res, next) {
router.handle(req, res, next);
}

// 将下文中所写的方法指定为router的prototype
setPrototypeOf(router, proto)

// Router的属性初始化
...
// 存储中间件的stack属性
router.stack = [];

return router;
};

Router对象有一个关键属性stack,为一个数组,存放着所有的中间件。每一个中间件都是一个Layer对象,如果该中间件是一个路由中间件,则相应的Layer对象的route属性会指向一个Route对象,表示一条路由。

注册

每次调用app.use()时,会执行router.use()stack属性添加一个新的中间件,这个中间件是由Layer对象包装的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
proto.use = function use(fn) {
// 处理输入参数,抽出path和fn
// 过程同app.use
...

if (callbacks.length === 0) {
throw new TypeError('Router.use() requires middleware functions');
}

// 循环fn数组,添加中间件
for (var i = 0; i < callbacks.length; i++) {
var fn = callbacks[i];

// 错误检测和打印信息
...

// 创建Layer对象
var layer = new Layer(path, {
sensitive: this.caseSensitive,
strict: false,
end: false
}, fn);

// 指定route属性为undefined,表示是普通中间件
layer.route = undefined;

// 入栈
this.stack.push(layer);
}

return this;
};

对于路由中间件要复杂些,路由中间件是通过router.route()方法注册的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
proto.route = function route(path) {
// 创建新的Route对象
var route = new Route(path);

// 创建Layer对象
var layer = new Layer(path, {
sensitive: this.caseSensitive,
strict: this.strict,
end: true
}, route.dispatch.bind(route)); // 绑定this为route对象

// 指定layer的route属性为当前的路由组件,表示是路由中间件
layer.route = route;

// 入栈
this.stack.push(layer);
return route;
};

看来,还需要去route.js中看看这个对象的定义。

1
2
3
4
5
6
7
8
9
10
function Route(path) {
this.path = path;
// 存放路由处理函数的stack
this.stack = [];

debug('new %o', path)

// 方法名和对应handler的键值对
this.methods = {};
}

可以看到,Route对象也有一个stack属性,为一个数组,其中的每一项也是一个Layer对象,是对路由处理函数的包装。我们可以把它理解成一个路由中间件对象。每次调用router.route()的时候,实际上是新建了一个layer放在router.stack中;并设置layer.route为新建的Route对象。

之后,通过route[method].apply(route, slice.call(arguments, 1))为特定方法绑定handler,route[method]定义在route.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
methods.forEach(function(method){
Route.prototype[method] = function(){
var handles = flatten(slice.call(arguments));

for (var i = 0; i < handles.length; i++) {
// handle即用户指定的处理函数数组
var handle = handles[i];

// 检测handle合法性
...
// 新建layer对象
var layer = Layer('/', {}, handle);
layer.method = method;

// 更新this.methods数组,并将layer入栈
this.methods[method] = true;
this.stack.push(layer);
}

return this;
};
});

即,当调用route.VERB()的时候,新建一个layer放在route.stack中。

通过上面的分析可以发现,Router其实是一个二维的结构。一个可能的router.stack结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
----------------
| layer1 |
----------------

---------------- layer2.route.stack ------------ ------------ ------------
| layer2 | ------------------> | layer2-1 |-->| layer2-2 |-->| layer2-3 |
---------------- ------------ ------------ ------------

---------------- layer3.route.stack ------------ ------------
| layer3 | ------------------> | layer3-1 |-->| layer3-2 |
---------------- ------------ ------------

----------------
| ...... |
----------------

----------------
| layerN |
----------------

Layer

Layer对象的构造函数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Layer(path, options, fn) {
// 实现函数名前有无new返回相同的效果
if (!(this instanceof Layer)) {
return new Layer(path, options, fn);
}

debug('new %o', path)
var opts = options || {};

this.handle = fn;
this.name = fn.name || '<anonymous>';
this.params = undefined;
this.path = undefined;
this.regexp = pathRegexp(path, this.keys = [], opts);

// 设置特殊情况的快速匹配
this.regexp.fast_star = path === '*'
this.regexp.fast_slash = path === '/' && opts.end === false
}

Layer的属性和一些方法介绍如下

  • handle 用户指定的中间件函数
  • name 函数名
  • params 参数名,在执行match时赋值
  • path 路径名,在执行match时赋值
  • regexp 路径的正则表达形式,由pathRegexp转换完成
  • keys 路径匹配结果信息
  • route 路由中间件的Route对象,或undefined

上面看到,普通中间件和路由中间件都通过Layer的形式插入在stack中。尽管它们都有构造函数中声明的哪些属性,这两种Layer还是有所区别:

  • Router中的Layer对象具有route属性,如果该属性不为undefined,则表明为一个路由中间件;而Route中的Layer对象没有route属性
  • Route中的Layer对象具有method属性,表明该路由函数的HTTP方法;而Router中的Layer对象没有method属性
  • Route中的Layer对象的keys属性值均为[]regexp属性值均为/^\/?$/i,因为在Route模块中创建Layer对象时使用的是Layer('/', {}, fn)

请求处理

在中间件注册完成后,剩下的工作都是由app.handle()或者说router.handle()完成的。这部分代码比较复杂。大致结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
proto.handle = function handle(req, res, out) {

...

// middleware and routes
var stack = self.stack;

...

next();

function next(err) {
...

while (match !== true && idx < stack.length) {
...
}

// no match
if (match !== true) {
return done(layerError);
}

...
}

function trim_prefix(layer, layerError, layerPath, path) {
...
}
};

在初始化和预处理后,调用了next()函数。在next()函数中,主要的部分是while语句判断是否遍历完成整个stack,完成后执行done()。这部分代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
while (match !== true && idx < stack.length) {
layer = stack[idx++];
match = matchLayer(layer, path);
route = layer.route;

if (typeof match !== 'boolean') {
// hold on to layerError
layerError = layerError || match;
}

if (match !== true) {
continue;
}

if (!route) {
// process non-route handlers normally
continue;
}

if (layerError) {
// routes do not match with a pending error
match = false;
continue;
}

var method = req.method;
var has_method = route._handles_method(method);

// build up automatic options response
if (!has_method && method === 'OPTIONS') {
appendMethods(options, route._options());
}

// don't even bother matching route
if (!has_method && method !== 'HEAD') {
match = false;
continue;
}
}

其中layer表示当前中间件,调用matchLayer方法即layer.match(path)判断是否和当前路径匹配(这个过程会更新layer中的pathparams)。之后的逻辑如下:

  1. 如果match不为true,即中间件和路径不匹配,则处理下一个中间件
  2. 如果matchtrueroute不存在,表示不是一个路由中间件,执行continue,之后跳出循环
  3. 如果matchtrue,且route存在。说明是需要的中间件。
    3.1 如果has_methodfalse且HTTP方法为OPTIONS,则执行appendMethods(options, route._options())添加支持方法
    3.2 如果has_methodfalse且HTTP方法不为HEAD,则设置match为false,即该路由无法处理该请求,此时由于match依然满足循环条件,因此会对下一个中间件进行判断
    3.3 如果has_methodtrue,则由于match不再满足循环条件,因此会跳出循环

整体来看,循环的主要作用就是从当前下标开始找出第一个能够处理该HTTP请求的中间件。如果是非路由中间件,则只要匹配路径即可;如果是路由中间件,则需要同时匹配路径和HTTP请求方法。

while语句后,如果matchtrue,说明遍历完成,直接执行done()。否则将匹配中得到的pathparams交给process_params方法作参数预处理。

1
2
3
4
5
6
7
8
9
10
11
self.process_params(layer, paramcalled, req, res, function (err) {
if (err) {
return next(layerError || err);
}

if (route) {
return layer.handle_request(req, res, next);
}

trim_prefix(layer, layerError, layerPath, path);
});

从回调函数中可以看到,如果是路由中间件可以直接调用layer.handle_request(req, res, next)执行真正的中间件函数。如果是普通中间件,还需要在trim_prefix中对路径处理后才会调用layer.handle_request(req, res, next)

1
2
3
4
5
6
7
8
9
function trim_prefix(layer, layerError, layerPath, path) {
...

if (layerError) {
layer.handle_error(layerError, req, res, next);
} else {
layer.handle_request(req, res, next);
}
}

在路由中间件中,layer.handle_request(req, res, next)调用的中间函数实际上是route.dispatch方法,在Route对象内容分发请求,它的逻辑相对router.handle要简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Route.prototype.dispatch = function dispatch(req, res, done) {
var idx = 0;
var stack = this.stack;
if (stack.length === 0) {
return done();
}

var method = req.method.toLowerCase();
if (method === 'head' && !this.methods['head']) {
method = 'get';
}

req.route = this;

next();

function next(err) {
// signal to exit route
if (err && err === 'route') {
return done();
}

// signal to exit router
if (err && err === 'router') {
return done(err)
}

var layer = stack[idx++];
if (!layer) {
return done(err);
}

if (layer.method && layer.method !== method) {
return next(err);
}

if (err) {
layer.handle_error(err, req, res, next);
} else {
layer.handle_request(req, res, next);
}
}
};

可以看到,next函数逻辑像下面这样

  1. 如果有错,直接调用done()传递错误
  2. 获取layer,如果不存在,调用done()
  3. layer和当前方法不匹配时,跳过当前layer,执行next(),继续下一个route函数
  4. layer和当前方法匹配时,根据有无错误执行layer.handle_error(err, req, res, next)或是layer.handle_request(req, res, next)

综上,一个请求到达时,流程顺序像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

----------------
| layer1 |
----------------

----------------
| layer2 |
----------------

---------------- layer3.route.stack ------------ ------------ ------------
| layer3 | ------------------> | layer3-1 |-->| layer3-2 |-->| layer3-3 | ---
---------------- ------------ ------------ ------------ |
|
---------------------------------------------------------------------------

---------------- layer4.route.stack ------------ ------------
| layer4 | ------------------> | layer4-1 |-->| layer4-2 | ---
---------------- ------------ ------------ |
|
------------------------------------------------------------

----------------
| ...... |
----------------

----------------
| layerN |
----------------

每个中间件的处理过程则像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

No --------------------
------| path match |
| --------------------
| ↓ Yes
| -------------------- Yes --------------------- No
| | has route |-------| http method match |------
| -------------------- --------------------- |
| ↓ No | Yes |
| -------------------- | |
| | process params |<----------------- |
| -------------------- |
| ↓ |
| -------------------- |
| | execute function | |
| -------------------- |
| ↓ |
| -------------------- |
----->| next layer |<---------------------------------
--------------------

在参数处理的过程中,每个参数的处理函数只会执行一次,并将结果保存在缓存中。在处理同一个请求的过程中,如果需要处理某个参数,会首先检查缓存,如果缓存中不存在,才会执行其处理函数。

内置中间件

middleware目录下,放置了两个Express内置中间件,在app.lazyRouter中自动加载。

1
2
3
4
5
6
7
8
9
10
11
app.lazyrouter = function lazyrouter() {
if (!this._router) {
this._router = new Router({
caseSensitive: this.enabled('case sensitive routing'),
strict: this.enabled('strict routing')
});

this._router.use(query(this.get('query parser fn')));
this._router.use(middleware.init(this));
}
};

其中第一个的作用是解析URL query,query(this.get('query parser fn'))用于设置URL query解析器。第二个的作用是将req和res分别暴露给对方,并让它们分别继承自express定义的app.requestapp.response

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
exports.init = function(app){
return function expressInit(req, res, next){
if (app.enabled('x-powered-by')) res.setHeader('X-Powered-By', 'Express');
req.res = res;
res.req = req;
req.next = next;

setPrototypeOf(req, app.request)
setPrototypeOf(res, app.response)

res.locals = res.locals || Object.create(null);

next();
};
};

express.js中,reqres分别继承自了request.jsresponse.js的导出对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createApplication() {
...

// expose the prototype that will get set on requests
app.request = Object.create(req, {
app: { configurable: true, enumerable: true, writable: true, value: app }
})

// expose the prototype that will get set on responses
app.response = Object.create(res, {
app: { configurable: true, enumerable: true, writable: true, value: app }
})

...
}

其他

关于参数处理和视图渲染,我看得不是很仔细,就不再赘述了。有兴趣的可以自行去参考中的链接学习。

参考

说到CORS,要从下面是一段很常见的前端面试对话

问: 之前实践中有遇到过跨域的需求吗?

答: 遇到过。

问: 那你是怎么解决的呢?

答: 跨域吧,大概有8种方法,很平衡,jsonp,CORS,反向代理,Websocket。结合iframe,还有使用document.domain, window.name, location.hash, window.postMessage等方法。

jsonp

jsonp是此前最为常用的一种跨域方法,利用了<script>标签的非跨域性,实现起来大概是下面这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function jsonp(url, success) {

var ud = '_' + +new Date,
script = document.createElement('script'),
head = document.getElementsByTagName('head')[0]
|| document.documentElement;

window[ud] = function(data) {
head.removeChild(script);
success && success(data);
};

script.src = url.replace('callback=?', 'callback=' + ud);
head.appendChild(script);
}

jsonp('http://soundcloud.com/oembed?url=http%3A//soundcloud.com/forss/flickermood&format=js&callback=?', function(data){
console.log(data);
});

CORS

CORS(Cross-Origin Resource Sharing)是W3C规定的在客户端用来进行跨站通信的标准。随着XMLHttpRequest2的出现,大部分浏览器下,可以像普通同域那样使用xhr对象来发起跨域请求。

构造一个CORS请求

CORS被下列浏览器支持

  • Chrome 3+
  • Firefox 3.5+
  • Opera 12+
  • Safari 4+
  • Internet Explorer 8+

其中Chrome,FF,Opera,Safari都通过XMLHttpRequest2对象来实现CORS。不一般的IE通过XDomainRequest对象实现,不过工作方式和xhr对象大同小异。Nicolas Zakas(《JavaScript高级程序设计》的作者)写了一个helper函数,保证了浏览器兼容性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function createCORSRequest(method, url) {
var xhr = new XMLHttpRequest();
if ("withCredentials" in xhr) {

// Check if the XMLHttpRequest object has a "withCredentials" property.
// "withCredentials" only exists on XMLHTTPRequest2 objects.
xhr.open(method, url, true);

} else if (typeof XDomainRequest != "undefined") {

// Otherwise, check if XDomainRequest.
// XDomainRequest only exists in IE, and is IE's way of making CORS requests.
xhr = new XDomainRequest();
xhr.open(method, url);

} else {

// Otherwise, CORS is not supported by the browser.
xhr = null;

}
return xhr;
}

var xhr = createCORSRequest('GET', url);
if (!xhr) {
throw new Error('CORS not supported');
}

withCredentials

标准的CORS请求默认不携带cookie,如果需要在请求中携带cookie信息,需要在为xhr对象指定withCredentials属性。

1
xhr.withCredentials = true

同时,服务器端也要在响应头上设置Access-Control-Allow-Credentials字段为true。像下面这样:

1
Access-Control-Allow-Credentials: true

值得注意的是,cookie同样遵守同源法则,即你的JS代码无法获取和设置远端的cookie。

发起CORS请求

CORS请求发起方式和xhr没有什么区别,调用xhr.send()即可。综合来看,一个End to End的例子像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Create the XHR object.
function createCORSRequest(method, url) {
var xhr = new XMLHttpRequest();
if ("withCredentials" in xhr) {
// XHR for Chrome/Firefox/Opera/Safari.
xhr.open(method, url, true);
} else if (typeof XDomainRequest != "undefined") {
// XDomainRequest for IE.
xhr = new XDomainRequest();
xhr.open(method, url);
} else {
// CORS not supported.
xhr = null;
}
return xhr;
}

// Helper method to parse the title tag from the response.
function getTitle(text) {
return text.match('<title>(.*)?</title>')[1];
}

// Make the actual CORS request.
function makeCorsRequest() {
// This is a sample server that supports CORS.
var url = 'http://html5rocks-cors.s3-website-us-east-1.amazonaws.com/index.html';

var xhr = createCORSRequest('GET', url);
if (!xhr) {
alert('CORS not supported');
return;
}

// Response handlers.
xhr.onload = function() {
var text = xhr.responseText;
var title = getTitle(text);
alert('Response from CORS request to ' + url + ': ' + title);
};

xhr.onerror = function() {
alert('Woops, there was an error making the request.');
};

xhr.send();
}

背后

CORS背后的脏活累活包括额外的包头以及额外的报文,这些由浏览器帮我们代劳了。CORS请求分为“简单的请求”和“没那么简单的请求”。简单的请求包含下列特征:

  • HTTP方法名是GET, POST, HEAD之一
  • HTTP包头包括
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type为application/x-www-form-urlencodedmultipart/form-datatext/plain

这种普通的CORS请求可以不必选择CORS,而可以通过jsonp或表单提交的方式解决。剩下的不那么简单的请求则需要浏览器和服务器进行额外的报文交换(prelight request)。

我们先从一个简单的请求开始,利用上面封装好的函数,写出下面的一段代码

1
2
3
var url = "http://api.foo.com/cors",
xhr = createCORSRequest('GET', url);
xhr.send();

在它的背后的HTTP包头像下面这样,请注意其中的Origin字段。

1
2
3
4
5
6
GET /cors HTTP/1.1
Origin: http://api.bar.com
Host: api.foo.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0 ...

一个合理的CORS请求必须包含一个Origin包头。这个包头由浏览器添加,用户不可自行修改。这部分由协议,域名和端口三部分组成。三者任意一个与Host不一致就算做跨域。非跨域时,不同浏览器对这个字段的处理方式不同,如FF会省去Origin字段,Chrome和Safari会在POST/DELETE/PUT时包括这个字段。

幸运的是,在跨域时一定会带上这个字段。支持CORS会根据客户端的Origin是否在allow list中做出回应。下面是一个样例

1
2
3
4
Access-Control-Allow-Origin: http://appi.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

其中前三个以Access-Control-开头的字段和CORS相关。

  • Access-Control-Allow-Origin(必须),这个字段必须附加在合法的CORS响应中,像上面例子所写的那样,或是设置为*表示允许任意源的请求(不过一般不会这样设置)
  • Access-Control-Allow-Credentials(可选),默认情况下,cookie不被包含在CORS请求中,设置此字段为true表示包含cookie在请求中。这个字段需要和XMLHttpRequest2中的withCredentials属性配合保证成功。
  • Access-Control-Expose-Headers(可选),XMLHttpRequest2的getResponseHeader()方法可以获取下面这些属性
    • Cache-Control
    • Content-Language
    • Content-Type
    • Expires
    • Last-Modified
    • Pragma
      如果想访问其他属性时,需要设置这个字段,属性间用逗号隔开。

当请求“没那么简单”时,比如徐需要使用PUT或DELETE,或是需要支持JSON的返回资源类型时,浏览器需要先发起一次prelight request。在收到服务器允许的回复后,真实的请求再发出。不过这个过程对于用户是透明的。下面是一个例子

1
2
3
4
var url = "http://api.foo.com",
xhr = createCORSRequest('PUT', url);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();

Prelight request如下:

1
2
3
4
5
6
7
8
OPTIONS /cors HTTP/1.1
Origin: http://api.bar.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.foo.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0 ...

除了我们上面已经提过的Origin字段外,又新增了两个字段

  • Access-Control-Request-Method 真实的HTTP请求方法。是始终包含在包头的
  • Access-Control-Request-Headers 用逗号分隔的真实HTTP请求的包头。

服务器接收到这些后,会根据方法和包头,结合Origin检验合法性。在验证合法后,返回Prelight Response

1
2
3
4
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8

它们的含义从名字中就可以推出一二

  • Access-Control-Allow-Origin(必须) 略
  • Access-Control-Allow-Methods(必须),用逗号分隔的支持的HTTP方法列表。尽管可能请求中只写了一种方法。这么做避免了多次请求
  • Access-Control-Allow-Headers (若客户端填写对应的头部则为必须),逗号分割的支持的所有头部类型
  • Access-Control-Allow-Credentials(可选) 略
  • Access-Control-Max-Age(可选),prelight response的缓存时间,以秒为单位。

Prelight requestPrelight response交换完成后,就是真正的请求和响应。此时的请求头部可以带上之前商议中所允许的字段,大致像下面这样:

1
2
3
4
5
6
7
PUT /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

当请求成功发出时,可以在响应头部看到CORS相关的字段,如Access-Control-Allow-Origin。请求失败时,会在console上报错,但不会给出具体信息。

CORS from jQuery

关于使用jQuery发起CORS请求,可以参加后文参考的第一条或jQuery相关文档。

参考

软件生命周期中80%的成本消耗在了维护上

《Java语言编码规范》

在前端编码时,经常遇到多人协作的情况,一些工具可以很好地提升代码维护成本。这里把最近的学习中遇到的几个分享在下面。

EditorConfig

EditorConfig是一套在编辑器间统一代码格式的解决方案。一个EditorConfig项目由.editorconfig自定义文件格式。相应的编辑器插件会按照配置文件格式化文档。

EditorConfig的语法类似.gitignore,比较好理解。下面是官网给出了规定Python和JavaScript文件格式的.editorconfig文件样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# EditorConfig is awesome: http://EditorConfig.org

# top-most EditorConfig file
root = true

# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true

# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,py}]
charset = utf-8

# 4 space indentation
[*.py]
indent_style = space
indent_size = 4

# Tab indentation (no size specified)
[Makefile]
indent_style = tab

# Indentation override for all JS under lib directory
[lib/**.js]
indent_style = space
indent_size = 2

# Matches the exact files either package.json or .travis.yml
[{package.json,.travis.yml}]
indent_style = space
indent_size = 2

不过通常的项目用不到这么复杂的配置文件。这里是Angular的配置文件,这里是曾经Vue.js的配置文件。官网给出了完整的使用EditorConfig的工程列表

存放位置

打开一个文件时,EditorConfig插件会去打开文件的目录和其每一级父目录查找.editorconfig文件,直到有一个配置文件root=true

读取顺序从上到下,路径最短的文件最后被读取,优先级最高。

关于文件格式

EditorConfig文件使用INI格式,目的是可以和Python Config Library兼容。每个分段(原文:‘section’)由一个globs开头。斜杠(/)作为路径分隔符,#或者;作为注释。注释应该单独占一行。EditorConfig文件使用UTF-8格式、CRLF或LF作为换行符。

通配符

EditorConfig目前支持下面这些通配符:

  • * 匹配除/之外的任意字符串
  • ** 匹配任意字符串
  • ? 匹配任意单个字符
  • [name] 匹配name字符
  • [!name] 匹配非name字符
  • {s1,s3,s3} 匹配任意给定的字符串(0.11.0起支持)
  • {num1..num2}匹配num1num2间的整数

最后特殊字符可以用\转义.

属性

目前普遍支持的属性包括下面这些:

  • root:表明是最顶层的配置文件,发现设为true时,才会停止查找.editorconfig文件。
  • indent_style:可以选择tab或space
  • indent_size:设置整数表示规定每级缩进的列数或空格数。如果设定为tab,则会使用tab_width的值(如果已指定)。
  • tab_width:设置整数用于指定替代tab的列数。默认值就是indent_size的值,一般无需指定。
  • end_of_line:定义换行符,支持lf、cr和crlf。
  • charset:编码格式,支持latin1、utf-8、utf-8-bom、utf-16be和utf-16le,不建议使用uft-8-bom。
  • trim_trailing_whitespace:设为true表示会除去换行行首的任意空白字符,false反之。
  • insert_final_newline:设为true表明使文件以一个空白行结尾,false反之。

支持情况

目前已有大量的IDE或文本编辑器支持EditorConfig配置。有些不需要下载插件,有些则需要。详情可参见官网

eslint

ESLint是非常流行的一个JavaScript代码检查器。便于在运行前检查出代码中潜在的错误。它的作者是Nicholas Zakas,红宝书的作者。网站也有中译版

安装

安装eslint前,需要有node.js的环境,之后通过npm安装即可

1
npm install -g eslint

当然也可以本地安装

1
npm install eslint --save-dev

配置

安装完成后,需要在项目目录下生成.eslintrc配置文件才可以使用eslint命令。这一步可以通过eslint --init按着引导完成,也可以根据自己需要修改。eslint推荐使用了一些规则,可以通过下面这样开启(extends的属性还可以是all,即启用所有规则,不推荐使用):

1
2
3
4
{
"extends": [ "eslint:recommended" ]
...
}

配置项里,还可以通过env指令代码环境,像下面这样:

1
2
3
4
5
6
{
"env": {
"browser": true,
"node": true
}
}

同样,具体的规则也是可以配置的,每个规则的配置项都有一个默认值,规则键对应的值为数值时,是下面的意思

  • 0 Disable the rule
  • 1 Warn about the rule
  • 2 Throw error about the rule

对应的值为数组时,则会更改规则配置项的原默认值,如下面例子中的quote规则:

1
2
3
4
5
6
7
8
{
"rules": {
// 使用默认的分号规则,违背时会有警告消息
"semi": 1
// 使用双引号包裹字符串,违背是会抛出错误
"quotes": [2, "double"],
}
}

关于eslint的更多配置项,可参考官网

若项目中使用到了ES6语法,则还需要安装babel-eslint包,并指定.eslintrcparserparseOptions两项。具体的配置大概是下面这样:

1
2
3
4
5
6
7
8
{
"extends": [ "eslint:recommended" ],
"parser": "babel-eslint",
"parserOptions": {
ecmaVersion: 6
},
...
}

ESLint 支持几种格式的配置文件:

  • JavaScript - 使用.eslintrc.js然后输出一个配置对象。
  • YAML - 使用.eslintrc.yaml.eslintrc.yml去定义配置的结构。
  • JSON - 使用.eslintrc.json去定义配置的结构ESLint的JSON文件允许JavaScript风格的注释。
  • Deprecated - 使用.eslintrc,可以使JSON也可以是YAML。
  • package.json - 在package.json里创建一个eslintConfig属性,在那里定义你的配置。

如果同一个目录下有多个配置文件,ESLint只会使用一个,优先级是上面列表从上到下的顺序。

注释

可以在文件中书写注释在运行时更改eslint的配置(实际上几乎所有的配置项都可以在注释中通过eslint-xxx这样的形式修改)。

当文件中出现已考虑到的规则例外时,可以通过/*eslint quotes: ["error", "double"]*//*eslint eqeqeq: 0, curly: 2*/这样的形式临时添加例外。

当文件出现不想被检测到的规则例外时,可以通过/*eslint-disable*//*eslint-enable*/避免警告。单行例外可以使用/*eslint-disable-line*/更详细的配置可以参见文档

sublime插件

上面说的这些工作,在配置完成后,需要在命令行中通过eslint xxx.file这样的形式lint。借助编辑器的插件可以获得可视化的lint结果,妈妈再也不用担心我的找不到错误了(误)。因为个人原因,下面仅以sublime为例。

下载eslint for sublime插件前,需要下载Sublime-Linter。因为前者利用了后者作为lint的平台。在Ctrl+Shift+P找到Package Controll: Install Packages后(什么?你没有装Package Control?),搜索Sublime-Linter下载安装即可。完成后,可以在Prefences -> Package SettingsTools选项卡中找到Sublime Linter的身影。

之后同样的方式搜索Sublime-contrib-eslint下载安装即可。建议在安装前去官网看看,避免遇到不必要的问题。

这些工作完成后了,可以选择SublimeLinter的mode为load/save,之后在文件载入和保存时都会对文件进行lint操作,并将违背规则的地方标出。

Commit message规范

git每次修改后需要填写commit message才能提交。这一步可以通过给git commit添加-m参数完成,像下面那样,也可以在git commit打开的vi界面下填写多行文本。

1
git commit -m 'some commit message'

git并没有对commit message的风格做出规范,可以用中文,可以用英文,甚至当你不知道该写些什么的时候,还可以去某些网站参考。

但是在团队协作中,还是建议清晰明了地书写此次commit的目的和做的修改。实际上,commit message规范这种事一直在做。比如egg.js, Angular或者更加简洁的规范:这样这样。其中Angular的规范应用较广,还有commitizen工具帮助生成changelog和检查commit message样式。

格式

根据Angular的规范,commmit message包括三个部分:Header, BodyFooter。其中Header是必需的,Body和Footer则不是。模板像下面这样:

1
2
3
4
5
<type>(<scope>): <subject>
// 空行
<body>
// 空行
<footer>

模板中,type为提交commit的类型,只有下面这些选择:

  • feat: 新功能
  • fix: 修复问题
  • docs: 修改文档
  • style: 修改代码格式,不影响代码逻辑
  • refactor: 重构代码,理论上不影响现有功能
  • perf: 提升性能
  • test: 增加修改测试用例
  • chore: 修改工具相关(包括但不限于文档、代码生成等)
  • deps: 升级依赖

其中前两种commit一定会出现在changelog中。

scope为修改文件的范围(包括但不限于doc, middleware, core, config, plugin);subject用一句话清楚的描述这次提交做了什么,首字母小写;body作为subject的补充,增加原因和目的等具体内容,可以不写。

footer部分中,当有非兼容修改(Breaking Change)时必须在这里描述清楚,或者描述关联issue。下面是一个完整的例子:

1
2
3
4
5
6
7
8
fix($compile): [BREAKING_CHANGE] couple of unit tests for IE9
Older IEs serialize html uppercased, but IE9 does not...
Would be better to expect case insensitive, unfortunately jasmine does
not allow to user regexps for throw expectations.
Document change on eggjs/egg#123
Closes #392
BREAKING CHANGE:
Breaks foo.bar api, foo.baz should be used instead

代码用于撤销此前commit所做修改时,message用revert开头,后面跟着被撤销commit的Header。像下面这样:

1
2
3
revert: feat(pencil): add 'graphiteWidth' option

This reverts commit 667ecc1654a317a13331b17617d973392f415f02.

如果当前commit与被撤销的commit,在同一个发布(release)里面,那么它们都不会出现在Change log中。

Commitizen

Commitizen就是方便你做出上面提交的工具,可以通过npm安装。

1
npm install -g commitizen

安装完成后,使用git cz代替git commit命令来提交改动。之后会出现指引帮助你完成一次合格的提交。

commitizen的插件cz-conventional-changelog可以帮助我们完成commit message,首先通过下面的命令安装并配置cz-conventional-changelog。

1
2
npm install -g cz-conventional-changelog
echo '{ "path": "cz-conventional-changelog" }' > ~/.czrc

之后运行下面的命令即可

1
commitizen init cz-conventional-changelog --save-dev --save-exact

参考

上篇传送门:浏览器是如何工作的 上 解析与呈现

前言

本文主要翻译于Tali Garsiel在2009年10月的一篇介绍Webkit和Gecko内核的经典文章How browsers work。尽管在面试和工作上用不到这么细节,但是学习浏览器的内部原理将让我们可以更好地理解一些最优开发实践的道理。

布局

呈现器在创建完成并添加到呈现树时,并不包含位置和大小信息。计算这些值的过程称为布局或重排。

HTML 采用基于流的布局模型,这意味着大多数情况下只要一次遍历就能计算出几何信息。处于流中靠后位置元素通常不会影响靠前位置元素的几何特征,因此布局可以按从左至右、从上至下的顺序遍历文档。但是也有例外情况,比如HTML表格的计算就需要不止一次的遍历。

布局是一个递归的过程。它从根呈现器(对应于HTML文档的<html>元素)开始,然后递归遍历部分或所有的框架层次结构,为每一个需要计算的呈现器计算几何信息。

根呈现器的位置是0,0,其尺寸为视口(也就是浏览器窗口的可见区域)。所有的呈现器都有一个layout或者reflow方法,每一个呈现器都会调用其需要进行布局的子代的layout方法。

Dirty Bit系统

为避免对所有细小更改都进行整体布局,浏览器采用了一种dirty bit系统。如果某个呈现器发生了更改,或者将自身及其子代标注为dirty,则需要进行布局。

有两种标记:dirtychildren are dirtychildren are dirty表示尽管呈现器自身没有变化,但它至少有一个子代需要布局。

全局与增量布局

全局布局是指触发了整个呈现树范围的布局,触发原因可能包括:

  • 影响所有呈现器的全局样式更改,例如字体大小更改(因此这里提到的自适应JavaScript代码一定要放在body前,否则会有白屏闪动现象出现)。
  • 屏幕大小调整,即resize事件。

增量布局是指只对dirty呈现器进行布局(这样可能存在需要进行额外布局的弊端)。当呈现器为 dirty 时,会异步触发增量布局。例如,当来自网络的额外内容添加到 DOM 树之后,新的呈现器附加到了呈现树中。

增量布局是异步执行的。Firefox 将增量布局的”reflow命令”加入队列,而调度程序会触发这些命令的批量执行。WebKit也有用于执行增量布局的计时器:对呈现树进行遍历,并对dirty呈现器进行布局。请求样式信息(例如offsetHeight)的脚本可同步触发增量布局。

如果布局是由“大小调整”或呈现器的位置(而非大小)改变而触发的,那么可以从缓存中获取呈现器的大小,而无需重新计算。
另外,在某些情况下,只有一个子树进行了修改,因此无需从根节点开始布局。这适用于在本地进行更改而不影响周围元素的情况,例如在文本字段中插入文本(否则每次键盘输入都将触发从根节点开始的布局)。

布局过程

布局过程通常遵守下面的模式:

  1. 父呈现器确定自己的宽度。
  2. 父呈现器依次处理子呈现器,并且:
  3. 放置子呈现器(设置 x,y 坐标)。
  4. 如果有必要,调用子呈现器的布局(如果子呈现器是dirty的,或者这是全局布局,或出于其他某些原因),这会计算子呈现器的高度。
  5. 父呈现器根据子呈现器的累加高度以及边距和补白的高度来设置自身高度,此值也可供父呈现器的父呈现器使用。
  6. 将其 dirty 位设置为 false。

宽度和换行

呈现器宽度是根据容器块的宽度、呈现器样式中的“width”属性以及边距和边框计算得出的。例如,下面有一个div标签:

1
<div style="width:30%"/>

Webkit将像下面一样计算它的宽度(RenderBox类的calcWidth方法):

  1. 容器的宽度取容器的availableWidth和0中的较大值。availableWidth在本例中相当于contentWidth
  2. 元素的宽度是width样式属性。它会根据容器宽度的百分比计算得出一个绝对值。
  3. 然后再加上水平方向的边框和补白。

现在计算得出的是preferred width。然后需要计算最小宽度和最大宽度。如果首选宽度大于最大宽度,那么应使用最大宽度。如果首选宽度小于最小宽度(最小的不可分单位),那么应使用最小宽度。最后,这些值会缓存起来,以用于需要布局而宽度不变的情况。

如果呈现器在布局过程中需要换行,会立即停止布局,并告知其父代需要换行。父代会创建额外的呈现器,并在其上布局。

绘制

在绘制阶段,系统会遍历呈现树,并调用呈现器的paint方法,将呈现器的内容显示在屏幕上。和布局一样,绘制也分为全局(绘制整个呈现树)和增量两种。在增量绘制中,部分呈现器发生了更改,但是不会影响整个树。更改后的呈现器将其在屏幕上对应的矩形区域设为无效,这导致 OS 将其视为一块“dirty 区域”,并生成“paint”事件。OS会很巧妙地将多个区域合并成一个。

CSS2.1规范描述了绘制的顺序,这个顺序实际上也是堆栈上下文的顺序。这些堆栈会从后往前绘制,因此这样的顺序会影响绘制。块呈现器的堆栈顺序如下:

  1. 背景颜色
  2. 背景图片
  3. 边框
  4. 子代元素
  5. 轮廓(outline)

浏览器特点

Firefox会遍历整个呈现树,为绘制的矩形建立一个显示列表。列表中按照正确的绘制顺序(先是呈现器的背景,然后是边框等等)包含了与矩形相关的呈现器。这样等到重新绘制的时候,只需遍历一次呈现树,而不用多次遍历(绘制所有背景,然后绘制所有图片,再绘制所有边框等等)。同时Firefox对此过程进行了优化,也就是不添加隐藏的元素,例如被不透明元素完全遮挡住的元素。

Webkit在重新绘制之前,则会将原来的矩形另存为一张位图,然后只绘制新旧矩形之间的差异部分。

动态变化

在发生变化时,回流(reflow)和重绘(repaint)可能会被触发,浏览器会尽可能做出最小的响应。例如,元素的颜色改变后,只会对该元素进行重绘。元素的位置改变后,只会对该元素及其子元素(可能还有同级元素)进行布局和重绘。添加DOM节点后,会对该节点进行布局和重绘。一些重大变化(例如增大html元素的字体)会导致缓存无效,使得整个呈现树都会进行重新布局和绘制。

无论是回流还是重绘都会增加浏览器的工作负担,带来响应时间。其中回流的计算代价更大,应该尽量避免。有一些方法可以用来减少回流的出现。

渲染引擎的线程

呈现引擎采用了单线程。几乎所有操作(除了网络操作)都是在单线程中进行的。在Firefox和Safari中,该线程就是浏览器的主线程。而在Chrome浏览器中,该线程是标签进程的主线程。

网络操作可由多个并行线程执行。并行连接数是有限的(通常为2至6个,以Firefox为例是6个)。

Event Loop

浏览器的主线程是事件循环(Event Loop)。它是一个无限循环,永远处于接受活动状态,并等待事件(如布局和绘制事件)发生,并进行处理。这是Firefox中关于主事件循环的代码:

1
2
while (!mExiting)
NS_ProcessNextEvent(thread);

CSS2.1可视化模型

注:CSS3是将CSS2模块化并对其中部分模块进行更新的版本。它仍使用CSS2.1规范作为其核心。改进的模块并不会与CSS2.1相冲突。因此这里原文关于CSS2.1的描述并不算过时

盒模型

CSS盒模型描述的是针对文档树中的元素而生成,并根据可视化格式模型进行布局的矩形框。每个框都有一个内容区域(例如文本、图片等),还有可选的周围补白、边框和边距区域。如上图所示。

每一个节点都会生成 0..n 个这样的框。所有元素都有一个“display”属性,决定了它们所对应生成的框类型。默认值是inline,但是浏览器样式表设置了其他默认值。例如,<div>元素的display属性默认值是block。W3C上有全面的默认样式表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
html, address,
blockquote,
body, dd, div,
dl, dt, fieldset, form,
frame, frameset,
h1, h2, h3, h4,
h5, h6, noframes,
ol, p, ul, center,
dir, hr, menu, pre { display: block; unicode-bidi: embed }
li { display: list-item }
head { display: none }
table { display: table }
tr { display: table-row }
thead { display: table-header-group }
tbody { display: table-row-group }
tfoot { display: table-footer-group }
col { display: table-column }
colgroup { display: table-column-group }
td, th { display: table-cell }
caption { display: table-caption }
th { font-weight: bolder; text-align: center }
caption { text-align: center }
body { margin: 8px }
h1 { font-size: 2em; margin: .67em 0 }
h2 { font-size: 1.5em; margin: .75em 0 }
h3 { font-size: 1.17em; margin: .83em 0 }
h4, p,
blockquote, ul,
fieldset, form,
ol, dl, dir,
menu { margin: 1.12em 0 }
h5 { font-size: .83em; margin: 1.5em 0 }
h6 { font-size: .75em; margin: 1.67em 0 }
h1, h2, h3, h4,
h5, h6, b,
strong { font-weight: bolder }
blockquote { margin-left: 40px; margin-right: 40px }
i, cite, em,
var, address { font-style: italic }
pre, tt, code,
kbd, samp { font-family: monospace }
pre { white-space: pre }
button, textarea,
input, select { display: inline-block }
big { font-size: 1.17em }
small, sub, sup { font-size: .83em }
sub { vertical-align: sub }
sup { vertical-align: super }
table { border-spacing: 2px; }
thead, tbody,
tfoot { vertical-align: middle }
td, th, tr { vertical-align: inherit }
s, strike, del { text-decoration: line-through }
hr { border: 1px inset }
ol, ul, dir,
menu, dd { margin-left: 40px }
ol { list-style-type: decimal }
ol ul, ul ol,
ul ul, ol ol { margin-top: 0; margin-bottom: 0 }
u, ins { text-decoration: underline }
br:before { content: "\A"; white-space: pre-line }
center { text-align: center }
:link, :visited { text-decoration: underline }
:focus { outline: thin dotted invert }

/* Begin bidirectionality settings (do not change) */
BDO[DIR="ltr"] { direction: ltr; unicode-bidi: bidi-override }
BDO[DIR="rtl"] { direction: rtl; unicode-bidi: bidi-override }

*[DIR="ltr"] { direction: ltr; unicode-bidi: embed }
*[DIR="rtl"] { direction: rtl; unicode-bidi: embed }

@media print {
h1 { page-break-before: always }
h1, h2, h3,
h4, h5, h6 { page-break-after: avoid }
ul, ol, dl { page-break-before: avoid }
}

定位方案

CSS中有三种定位方案:

  • 普通:根据对象在文档中的位置进行定位,也就是说对象在呈现树中的位置和它在DOM树中的位置相似,并根据其框类型和尺寸进行布局。
  • 浮动:脱离文档流,对象先按照普通流进行布局,然后尽可能地向左或向右移动。
  • 绝对:完全脱离文档流,对象在呈现树中的位置和它在DOM树中的位置不同。

定位方案是由“position”属性和“float”属性设置的。如果值是staticrelative,就是普通流,如果值是 absolutefixed,就是绝对定位。

盒类型

  • block:形成一个block,在浏览器窗口中拥有其自己的矩形区域。
  • inline:没有自己的 block,但是位于容器 block 内。

block一个接一个地垂直排布,而inline则是水平排布。

定位

  • 相对定位:先按照普通方式定位,然后根据所需偏移量进行移动。
  • 浮动:浮动框会移动到行的左边或右边。有趣的特征在于,其他框会浮动在它的周围。
  • 绝对定位和固定定位:这种布局是准确定义的,与普通流无关。元素不参与普通流。尺寸是相对于容器而言的。在固定定位中,容器就是可视区域。

更具体的分析可以参见CSS2.1中对normal flow,float和absolute做的讲解

层级

这是由z-index属性指定的。它代表了框的第三个维度,也就是沿“z 轴”方向的位置。这些框分散到多个堆栈(称为堆栈上下文)中。在每一个堆栈中,会首先绘制后面的元素,然后在顶部绘制前面的元素,以便更靠近用户。如果出现重叠,新绘制的元素就会覆盖之前的元素。

堆栈是按照z-index属性进行排序的。具有z-index属性的框形成了本地堆栈。视口具有外部堆栈。更多描述参考CSS2.1 z-index

0%