本文基于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

下篇传送门: 浏览器是如何工作的 下 布局与绘制

前言

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

浏览器

浏览器可以说是PC和移动设备上最常用的软件应用了。目前的主流浏览器有5个:IE,Firefox,Safari,Chrome和Opera。浏览器的主要功能是协助用户向服务器发起请求,并在窗口中展示请求的网络资源(HTML文档,或图片,或pdf文档,或其他可以被URI指定位置的类型)。

浏览器解释并展示HTML稳当的方式规定在HTML和CSS规范中。这些规范由W3C组织制订和维护。但是多年来,各浏览器并没有完全遵从这些规范(直到现在也是如此),从而带来兼容性的问题。

从高层来看,浏览器的用户界面(并没有规范去规定它该如何设计)是类似的,有用来输入URI的地址栏,前进和后退按钮,书签管理选项,刷新和停止刷新按钮,主页按钮等。这些最佳实践是自然发展和相互模仿的结果。它的背后由下面的一些组件组成:

  • 用户界面和界面后端,即UI
  • 浏览器引擎,负责在UI和浏览器各组件间传递指令
  • 渲染引擎,负责渲染请求的内容
  • 网络,底层的网络调用
  • JavaScript引擎
  • 数据存储,持久化存储浏览器的各种数据

值得注意的是,Chrome的每个标签页分别对应一个渲染引擎实例,且都是一个独立的进程。(知道为什么Chrome那么吃内存了么😂)

渲染引擎

渲染引擎的工作是渲染(文章原话),默认情况下,渲染引擎能呈现HTML、XML文档和图片。通过插件还可以展示其他类型内容(如PDF查看器插件显示PDF文档)。文章主要讨论了Firefox的Gecko引擎和Chrome的Webkit引擎。

渲染引擎的主要流程大致是获取并解析HTML文档构建DOM树,之后创建呈现树,呈现树包含有视觉属性(如颜色和尺寸),最后进入布局和绘制阶段。需要指出的是,这是个复杂和渐进的过程,为了更好地用户体验,渲染引擎通常会先将解析完成的部分HTML显示出来。下面是Webkit和Gecko的渲染引擎主流程。

Webkit

Gecko

Gecko和Webkit的术语略有不同,整体流程却是相似的。Gecko将视觉格式化元素组成的树称为“框架树”。每个元素都是一个框架。WebKit使用的术语是“呈现树”,它由“呈现对象”组成。对于元素的放置,WebKit 使用的术语是“布局”,而Gecko称之为“重排”。另外,Gecko在HTML和DOM树间还有一个称为“内容槽”的层用于生成DOM元素。

解析

关于解析HTML文档,原文做了详尽深入的讲解。由于侧重点不同,这里做些精炼。

解析文档是指将文档转化成为有意义的结构,也就是可让代码理解和使用的结构。解析得到的结果通常是代表了文档结构的节点树,它称作解析树或者语法树。

解析的过程通常分为词法分析语法分析。前者是指将内容拆解成合法标记的过程,由词法分析器完成;后者指应用语言的语法规则,由解析器完成。

词汇通常用正则表达式表示。例如,我们的示例语言可以定义如下:

1
2
3
INTEGER :0|[1-9][0-9]*
PLUS : +
MINUS: -

正如您所看到的,这里用正则表达式给出了整数的定义。语法通常使用一种称为BNF的格式来定义。我们的示例语言可以定义如下:

1
2
3
expression :=  term  operation  term
operation := PLUS | MINUS
term := INTEGER | expression

之前我们说过,如果语言的语法是与上下文无关的语法,就可以由常规解析器进行解析。与上下文无关的语法的直观定义就是可以完全用BNF格式表达的语法。

解析器

有两种基本类型的解析器:自上而下解析器自下而上解析器。直观地来说,自上而下的解析器从语法的高层结构出发,尝试从中找到匹配的结构。而自下而上的解析器从低层规则出发,将输入内容逐步转化为语法规则,直至满足高层规则。

例如,我们要解析一个2 + 3 - 1的表达式,自上而下的解析器会从高层的规则开始:首先将2 + 3标识为一个表达式,然后将2 + 3 - 1标识为一个表达式。自下而上的解析器将扫描输入内容,找到匹配的规则后,将匹配的输入内容替换成规则。如此继续替换,直到输入内容的结尾。部分匹配的表达式保存在解析器的堆栈中。

有一些工具可以帮助您生成解析器,它们称为解析器生成器。您只要向其提供您所用语言的语法(词汇和语法规则),它就会生成相应的解析器。WebKit使用了两种非常有名的解析器生成器:用于创建词法分析器的Flex以及用于创建解析器的Bison。Flex的输入是包含标记的正则表达式定义的文件。Bison的输入是采用BNF格式的语言语法规则。

事情到了HTML这里变得麻烦了些。首先,HTML解析器的任务是将HTML标记解析成解析树。HTML词汇和语法在W3C的规范(目前版本是HTML5)中有着定义。但是HTML并不能很容易地用解析器所需的与上下文无关的语法来定义。HTML的正规格式DTD(Document Type Definition)并不是一种上下文无关的语法。

原因就是HTML并不是XML。HTML最初野蛮生长的日子里,实现方式不一而足,HTML的严格版变体XHTML并没有得到广泛的支持。为了保证兼容性,饶了诸多浏览器一命,包容许多并不合适的使用方式,简化网络开发。DTD中的严格模式下是完全遵守HTML规范的。

HTML解析

解析器的输出解析树是由DOM元素和属性节点构成的树结构。DOM是文档对象模型 (Document Object Model) 的缩写。它是HTML文档的对象表示,同时也是JavaScript与HTML元素之间的接口。解析树的根节点是Document对象。DOM与标记tag间几乎是一一对应的关系。

如上文所说,由于语言的宽容性和原内容的可更改性,HTML无法用常规的自上而下或自下而上的解析器来解析。根据原文的说法,HTML的解析算法标记化树构建组成。

标记化是词法分析过程,将输入内容解析成多个标记。HTML标记包括起始标记、结束标记、属性名称和属性值。标记生成器识别标记,传递给树构造器,然后接受下一个字符以识别下一个标记;如此反复直到输入的结束。

其中标记化算法的输出结果是HTML标记。该算法使用状态机来表示。每一个状态接收来自输入信息流的一个或多个字符,并根据这些字符更新下一个状态。当前的标记化状态和树结构状态会影响进入下一状态的决定。算法相当复杂,无法在此详述,下面给出一个简要的示例(来自原文)

1
2
3
4
5
<html>
<body>
Hello world
</body>
</html>

初始状态是数据状态。遇到字符<时状态更改为“标记打开状态”。接收一个a-z字符会创建“起始标记”,状态更改为“标记名称状态”。这个状态会一直保持到接收>字符。在此期间接收的每个字符都会附加到新的标记名称上。在本例中,我们创建的标记是html标记。

遇到>标记时,会发送当前的标记,状态改回“数据状态”。<body>标记也会进行同样的处理。目前htmlbody标记均已发出。现在我们回到“数据状态”。接收到 Hello world中的H字符时,将创建并发送字符标记,直到接收</body>中的<。我们将为Hello world中的每个字符都发送一个字符标记。

现在我们回到“标记打开状态”。接收下一个输入字符/时,会创建end tag token并改为“标记名称状态”。我们会再次保持这个状态,直到接收>。然后将发送新的标记,并回到“数据状态”。</html>输入也会进行同样的处理。

树构造器中运行着树构造算法。在创建解析器的同时,也会创建Document对象。在树构建阶段,以Document为根节点的DOM树也会不断进行修改,向其中添加各种元素。标记生成器发送的每个节点都会由树构建器进行处理。规范中定义了每个标记所对应的DOM元素,这些元素会在接收到相应的标记时创建。这些元素不仅会添加到DOM树中,还会添加到开放元素的堆栈中。此堆栈用于纠正嵌套错误和处理未关闭的标记。其算法类似标记化算法,也可以用状态机来描述。

我们同样以上面的代码为例,树构建阶段的输入是一个来自标记化阶段的标记序列。第一个模式是initial mode。接收HTML标记后转为before html模式,并在这个模式下重新处理此标记。这样会创建一个HTMLHtmlElement元素,并将其附加到Document根对象上。

然后状态将改为before head。此时我们接收body标记。即使我们的示例中没有head标记,系统也会隐式创建一个HTMLHeadElement,并将其添加到树中。

现在我们进入了in head模式,然后转入after head模式。系统对body标记进行重新处理,创建并插入HTMLBodyElement,同时模式转变为in body

现在,接收由“Hello world”字符串生成的一系列字符标记。接收第一个字符时会创建并插入Text节点,而其他字符也将附加到该节点。

接收body结束标记会触发after body模式。现在我们将接收HTML结束标记,然后进入after after body模式。接收到文件结束标记后,解析过程就此结束。

解析后与容错机制

在此阶段,浏览器会将文档标注为交互状态,并开始解析那些处于deferred模式的脚本,也就是那些应在文档解析完成后才执行的脚本。然后,文档状态将设置为“完成”,一个load事件随之触发。

我们在浏览HTML网页时从来不会看到“语法无效”的错误。这是因为浏览器会纠正任何无效内容,然后继续工作。不同浏览器的错误处理机制相当一致,但这种机制却并不是HTML当前规范的一部分。和书签管理以及前进/后退按钮一样,它也是浏览器在多年发展中的产物。很多网站都普遍存在着一些已知的无效HTML结构,每一种浏览器都会尝试通过和其他浏览器一样的方式来修复这些无效结构。

HTML5规范定义了一部分这样的要求。WebKit在HTML解析器类的开头注释中对此做了很好的概括。

解析器对标记化输入内容进行解析,以构建文档树。如果文档的格式正确,就直接进行解析。

遗憾的是,我们不得不处理很多格式错误的 HTML 文档,所以解析器必须具备一定的容错性。

我们至少要能够处理以下错误情况:

  1. 明显不能在某些外部标记中添加的元素。在此情况下,我们应该关闭所有标记,直到出现禁止添加的元素,然后再加入该元素。
  2. 我们不能直接添加的元素。这很可能是网页作者忘记添加了其中的一些标记(或者其中的标记是可选的)。这些标签可能包括:HTML HEAD BODY TBODY TR TD LI(还有遗漏的吗?)。
  3. 向inline元素内添加block元素。关闭所有inline元素,直到出现下一个较高级的block元素。
  4. 如果这样仍然无效,可关闭所有元素,直到可以添加元素为止,或者忽略该标记。

错误的情况包括错误使用<br>,离散表格,过于复杂的标记层级结构。错误的html或body结束标记等。Webkit的具体代码展示略。

CSS解析

和HTML不同,CSS是上下文无关的语法,可以使用简介中描述的各种解析器进行解析。词法语法(词汇)是针对各个标记用正则表达式定义的:

1
2
3
4
5
6
7
comment   \/\*[^*]*\*+([^/*][^*]*\*+)*\/
num [0-9]+|[0-9]*"."[0-9]+
nonascii [\200-\377]
nmstart [_a-z]|{nonascii}|{escape}
nmchar [_a-z0-9-]|{nonascii}|{escape}
name {nmchar}+
ident {nmstart}{nmchar}*

其中ident表示标识符,如类名。name是元素ID。

语法则是采用 BNF 格式描述的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ruleset
: selector [ ',' S* selector ]*
'{' S* declaration [ ';' S* declaration ]* '}' S*
;
selector
: simple_selector [ combinator selector | S+ [ combinator? selector ]? ]?
;
simple_selector
: element_name [ HASH | class | attrib | pseudo ]*
| [ HASH | class | attrib | pseudo ]+
;
class
: '.' IDENT
;
element_name
: IDENT | '*'
;
attrib
: '[' S* IDENT S* [ [ '=' | INCLUDES | DASHMATCH ] S*
[ IDENT | STRING ] S* ] ']'
;
pseudo
: ':' [ IDENT | FUNCTION S* [IDENT S*] ')' ]
;

如其中的ruleset表示,这个规则集是一个选择器,或者由逗号和空格(S表示空格)分隔的多个(数量可选)选择器。规则集包含了大括号,以及其中的一个或多个(数量可选)由分号分隔的声明。

Webkit使用Flex和Bison解析器生成器,通过 CSS 语法文件自动创建解析器。Firefox 使用的是人工编写的自上而下的解析器。这两种解析器都会将CSS文件解析成StyleSheet对象,且每个对象都包含CSS规则。

JS和CSS的处理

这也是经常的考点

脚本

解析器遇到<script>标记时会立即解析并执行脚本。文档的解析将停止,直到脚本执行完毕。如果脚本是外部的,那么解析过程会停止,直到从网络同步抓取资源完成后再继续。此模型已经使用了多年,也在HTML4和HTML5规范中进行了指定。

脚本标注为defer时,它不会停止文档解析,而是等到解析结束才执行。HTML5中还增加了一个选项async,可将脚本标记为异步,在资源下载完毕后立即执行。

预解析

WebKit和Firefox都进行了这项优化。在执行脚本时,其他线程会解析文档的其余部分,找出并加载需要通过网络加载的其他资源。通过这种方式,资源可以在并行连接上加载,从而提高总体速度。但是,预解析器不会修改DOM树,而是将这项工作交由主解析器处理;预解析器只会解析外部资源(例如外部脚本、样式表和图片)的引用。

样式表

理论上来说,应用样式表不会更改DOM树,因此似乎没有必要等待样式表并停止文档解析。但这涉及到一个问题,就是脚本在文档解析阶段会请求样式信息。如果当时还没有加载和解析样式,脚本就会获得错误的回复。

从而,Firefox在样式表加载和解析的过程中,会禁止所有脚本。而对于WebKit而言,仅当脚本尝试访问的样式属性可能受尚未加载的样式表影响时,它才会禁止该脚本。

呈现树

在DOM树构建的同时,浏览器还会构建另一个树结构:呈现树(Render Tree)。这是由可视化元素按照其显示顺序而组成的树,也是文档的可视化表示。它的作用是让您按照正确的顺序绘制内容。

Firefox将呈现树中的元素称为“框架”。WebKit使用的术语是呈现器或呈现对象。呈现器知道如何布局并将自身及其子元素绘制出来。

每个呈现器都表示一个矩形区域,通常对应于相关节点的CSS框,这一点在CSS2规范中有所描述。它包含诸如宽度、高度和位置等几何信息。框的类型会受到与节点相关的display样式属性的影响。下面是Webkit根据display属性的不同,针对同一个DOM节点创建不同呈现器的例子:

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
RenderObject* RenderObject::createObject(Node* node, RenderStyle* style)
{
Document* doc = node->document();
RenderArena* arena = doc->renderArena();
...
RenderObject* o = 0;

switch (style->display()) {
case NONE:
break;
case INLINE:
o = new (arena) RenderInline(node);
break;
case BLOCK:
o = new (arena) RenderBlock(node);
break;
case INLINE_BLOCK:
o = new (arena) RenderBlock(node);
break;
case LIST_ITEM:
o = new (arena) RenderListItem(node);
break;
...
}

return o;
}
和DOM树关系

呈现器和DOM元素是一一对应的,但是呈现树则不是。非可视化的DOM元素不会插入在呈现树中,例如head元素,或是display设为none的元素。同时,还有一些DOM元素对应着多个可视化对象,例如select元素。格式无效的HTML元素会根据CSS规范作出调整,如inline元素中同时包裹了block和inline元素(只能包含其中一种)。最后,有些呈现对象的位置和DOM节点位置不同,如浮动定位和绝对定位这样脱离文档流的元素。

构建过程

在Firefox中,展示层被注册为DOM更新的监听器。在监听到DOM改动后,展示层会把将框架创建工作委派给FrameConstructor,由其构造器解析样式并创建框架。

在WebKit中,解析样式和创建呈现器的过程称为attachment。每个DOM节点都有一个attach方法用于完成此项工作。attach是同步进行的,将节点插入DOM树需要调用新的节点attach方法。

处理htmlbody标记会构建呈现树根节点。这个根节点呈现对象对应于CSS规范中所说的容器block,这是最上层的 block,包含了其他所有block。它的尺寸就是视口,即浏览器窗口显示区域的尺寸。Firefox称之为ViewPortFrame,而WebKit称之为RenderView。这就是文档所指向的呈现对象。呈现树的其余部分以DOM树节点插入的形式来构建。

W3C的CSS2.1规范中有提到解析模型的问题。

样式计算

构建呈现树时,需要计算每个呈现对象的可视化属性。这是通过每个元素的样式来完成的。样式包括来自各种来源的样式表、inline样式元素(即style属性)和HTML中的可视化属性(如bgColor, width)。样式表的来源包括浏览器的默认样式表、由网页作者提供的样式表以及由浏览器用户提供的用户样式表等。

样式计算存在着许多困难:1)样式数据庞大,2)为元素查找匹配规则的过程复杂,3)CSS的层叠规则复杂。针对这些问题,firefox和Webkit有不同的处理方法。

Webkit会引用样式对象(RenderStyle)。这些对象在某些情况下可被不同同级节点共享,这些节点还有下面的要求:

  • 鼠标状态相同,如都是:hover
  • 没有元素ID
  • tag名应匹配
  • class属性应匹配
  • 链接状态(如:active)和焦点状态(如:focus)相同
  • 映射属性的集合完全相同
  • 不应被属性选择器匹配
  • 不能有任何inline样式属性
  • 不能使用同级选择器

Firefox为简化运算,使用了另外两种树,规则树样式上下文树,如下图所示。Webkit则通过DOM节点指向样式对象来实现。样式上下文包含端值。要计算出这些值,应按照正确顺序应用所有的匹配规则,并将其从逻辑值转化为具体的值。例如,如果逻辑值是屏幕大小的百分比,则需要换算成绝对的单位。

规则树

规则树的设计将所有匹配规则都存储在树中,它包含了所有匹配规则。路径的路径中的底层节点拥有较高的优先级。规则树包含了所有已知规则匹配的路径。规则的存储是延迟进行的。规则树不会在开始的时候就为所有的节点进行计算,而是只有当某个节点样式需要进行计算时,才会向规则树添加计算的路径。

原文从两个角度分析了规则树如何减少工作量:结构划分通过规则树计算样式上下文

样式上下文可分割成多个结构。这些结构体包含了特定类别(如bordercolor)的样式信息。结构中的属性都是继承的或非继承的。继承属性如果未由元素定义,则继承自其父代。非继承属性(也称为“重置”属性)如果未进行定义,则使用默认值。

规则树通过缓存整个结构(包含计算出的端值)为我们提供帮助。这一想法假定底层节点没有提供结构的定义,则可使用上层节点中的缓存结构。

在计算某个特定元素的样式上下文时,我们首先计算规则树中的一条对应路径,或者使用一条现有的路径。然后我们沿此路径应用规则,在这个样式上下文中填充结构。我们从路径中拥有最高优先级的底层节点(通常也是最特殊的选择器)开始,并向上遍历规则树,直到结构填充完毕。

如果该规则节点对于此结构没有任何规范,那么我们可以实现更好的优化:寻找路径更上层的节点,找到后指定完整的规范并指向相关节点即可。这是最好的优化方法,因为整个结构都能共享。这可以减少端值的计算量并节约内存。

如果我们找到了部分定义,就会向上遍历规则树,直到结构填充完毕。如果我们找不到结构的任何定义,那么假如该结构是继承类型,我们会在上下文树中指向父代的结构,这样也可以共享结构。如果是reset类型的结构,则会使用默认值。

如果最特殊的节点确实添加了值,那么我们需要另外进行一些计算,以便将这些值转化成实际值。然后我们将结果缓存在树节点中,供子代使用。如果某个元素与其同级元素都指向同一个树节点,那么它们就可以共享整个样式上下文

下面用一个例子来讲解上面晦涩的说明:

1
2
3
4
5
6
7
8
9
10
11
12
<html>
<body>
<div class="err" id="div1">
<p>
this is a <span class="big"> big error </span>
this is also a
<span class="big"> very big error</span> error
</p>
</div>
<div class="err" id="div2">another error</div>
</body>
</html>
1
2
3
4
5
6
div {margin:5px;color:black}
.err {color:red}
.big {margin-top:3px}
div span {margin-bottom:4px}
#div1 {color:blue}
#div2 {color:green}

为了简便起见,我们只需要填充两个结构:color 结构和 margin 结构。color 结构只包含一个成员(即“color”),而 margin 结构包含四条边。 形成的规则树如下图所示

假设我们解析 HTML 时遇到了第二个<div>标记,我们需要为此节点创建样式上下文,并填充其样式结构。经过规则匹配,我们发现该<div>的匹配规则是第1、2和6条。这意味着规则树中已有一条路径可供我们的元素使用,我们只需要再为其添加一个节点以匹配第6条规则(规则树中的F节点)。我们将创建样式上下文并将其放入上下文树中。新的样式上下文将指向规则树中的F节点。

现在我们需要填充样式结构。首先要填充的是margin结构。由于最后的规则节点(F)并没有添加到margin结构,我们需要上溯规则树,直至找到在先前节点插入中计算过的缓存结构,然后使用该结构。我们会在指定margin规则的最上层节点(即B节点)上找到该结构。

我们已经有了color结构的定义,因此不能使用缓存的结构。由于color有一个属性,我们无需上溯规则树以填充其他属性。我们将计算端值(将字符串转化为RGB等)并在此节点上缓存经过计算的结构。

第二个<span>元素处理起来更加简单。我们将匹配规则,最终发现它和之前的span一样指向规则G。由于我们找到了指向同一节点的同级,就可以共享整个样式上下文了,只需指向之span的上下文即可。

处理规则简化匹配

样式规则来源于外部样式表、inline样式属性、HTML可视化属性。后两者很容易匹配。CSS规则可能会棘手,可以对它进行一些处理,便于访问。

样式表解析完毕后,系统会根据选择器将CSS规则添加到某个哈希表中。这些哈希表的选择器各不相同,包括ID、类名称、标记名称等,还有一种通用哈希表,适合不属于上述类别的规则。如果选择器是ID,规则就会添加到ID表中;如果选择器是类,规则就会添加到类表中,依此类推。这种处理可以大大简化规则匹配。我们无需查看每一条声明,只要从哈希表中提取元素的相关规则即可。以下面的CSS为例

1
2
3
p.error {color:red}
#messageDiv {height:50px}
div {margin:5px}

第一条规则将插入类表,第二条将插入ID表,而第三条将插入标记表。对于下面的HTML代码段:

1
2
<p class="error">an error occurred </p>
<div id="messageDiv">this is a message</div>

我们首先会为p元素寻找匹配的规则。类表中有一个error键,在下面可以找到p.error的规则。div元素在ID表(键为 ID)和标记表中有相关的规则。剩下的工作就是找出哪些根据键提取的规则是真正匹配的了。

正确的层叠顺序

样式对象具有与可视化属性一一对应的属性(均为CSS属性但更为通用)。如果某个属性未由任何匹配规则所定义,那么部分属性可由父代元素样式对象继承。其他属性具有默认值。不过如果定义不止一个,就会出现麻烦,这时需要通过层叠顺序来解决。

某个样式属性的声明可能会出现在多个样式表中,也可能在同一个样式表中出现多次。层叠顺序的重要性正体现在这里。根据CSS2规范,层叠的顺序为(优先级从低到高):

  1. Browser declarations
  2. User normal declarations
  3. Author normal declarations
  4. Author important declarations
  5. User important declarations

同样顺序的声明则会根据特异性(specity)进行排序,然后再是其指定顺序。HTML可视化属性会转换成匹配的CSS声明。它们被视为低优先级的Author normal declaration。

那么特异性是什么意思呢?根据CSS3 selectors specificity中的定义(和CSS2.1几乎一样),一个选择器的优先级计算如下:

  1. 如果声明来自于style属性,而不是带有选择器的规则,则记为1,否则记为0 (= a)
  2. 记为选择器中ID属性的个数 (= b)
  3. 记为选择器中其他属性和伪类的个数 (= c)
  4. 记为选择器中元素名称和伪元素的个数 (= d)

将四个数字按a-b-c-d这样连接起来(位于大数进制的数字系统中),构成特异性。所使用的进制取决于上述类别中的最高计数。例如,如果a=14,可以使用十六进制。如果a=17,那么需要使用十七进制;不过在正常使用中,几乎不会使用到如此深嵌套层级的选择器。规范中给出的例子很好地演示了这种系统的工作方式。

1
2
3
4
5
6
7
8
9
*             {}  /* a=0 b=0 c=0 d=0 -> specificity = 0,0,0,0 */
li {} /* a=0 b=0 c=0 d=1 -> specificity = 0,0,0,1 */
li:first-line {} /* a=0 b=0 c=0 d=2 -> specificity = 0,0,0,2 */
ul li {} /* a=0 b=0 c=0 d=2 -> specificity = 0,0,0,2 */
ul ol+li {} /* a=0 b=0 c=0 d=3 -> specificity = 0,0,0,3 */
h1 + *[rel=up]{} /* a=0 b=0 c=1 d=1 -> specificity = 0,0,1,1 */
ul ol li.red {} /* a=0 b=0 c=1 d=3 -> specificity = 0,0,1,3 */
li.red.level {} /* a=0 b=0 c=2 d=1 -> specificity = 0,0,2,1 */
#x34y {} /* a=0 b=1 c=0 d=0 -> specificity = 0,1,0,0 */

找到匹配的规则之后,应根据级联顺序将其排序。WebKit对于较小的列表会使用冒泡排序,对较大的列表使用归并排序。

0%