说到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对于较小的列表会使用冒泡排序,对较大的列表使用归并排序。

最近在做活动页切图时,遇到了针对不同尺寸移动设备屏幕适配的问题。相信这个问题是有移动端前端开发经验的攻城狮们都遇到过的。再加上一些优化体验,这里简单写一些经验。

像素完美

像素完美是UI设计师的终极目标之一(其余的还有分辨率无关,多平台体验一致等)。但是切图中不可避免地会使用到类似background-image这样设置背景CSS样式的属性,之后再将一些交互元素通过绝对定位或是其他方式放在它视觉上应该出现的位置。在移动端设备宽度不一时,交互元素的位置如何保值像素完美的一致呢。

最容易想到的方法是百分比布局,保证设计师设计的美美的UI界面可以自适应地等比扩充到整个页面。不过这样会让交互元素的位置摆放很难办,难以保证设备兼容性。flex布局也是种常见的解决方案,但距离像素完美还有距离。

于是,我在上一次实践中,采用了固定宽度的处理方式(这并不是一种好的实践),CSS样式写起来大概是下面这样的。

1
2
3
4
5
6
7
8
9
10
.container {
display: block;
width: 414px;
margin: 0 auto;
}
.bkg {
background-image: url('./bkg.png');
background-size: 100%;
background-repeat: repeat-y;
}

margin: 0 auto是为了保证PC端居中的兼容性。这么写可以让交互元素定位时没有了设备宽度不同的后顾之忧。不过,在页面打开后,宽度较大使得页面出现横向滚动条,在绝大多数的移动端页面都不是种好的体验。的确,可以在meta标签中,设置user-scalable=yes来实现宽度上的自适应。但是,放开了页面缩放的限制也不是好的实践方式。

这次活动页参考的使用rem的方式(淘宝最早实践的)是一种明显更好的方法。rem是CSS3中出现的新属性,它是root em的缩写。和em不同的是,rem使用整个html的font-size作为尺寸的参考。只需要改变html的font-size,所有使用了rem属性的元素大小都会等比例地变化。于是,下面的一段代码轻松地实现了移动端的自适应方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(function(win, doc){
var docEl = doc.documentElement,
resizeEvt = 'onorientationchange' in window ? 'orientationchange' : 'resize',
recalc = function() {
var clientWidth = docEl.clientWidth;
if (!clientWidth) return;
if (clientWidth >= 750) {
docEl.style.fontSize = '100px';
} else {
docEl.style.fontSize = 100 * (clientWidth / 750) + 'px';
}
};
//省去addEventListener兼容性处理
win.addEventListener(resizeEvt, recalc, false);
doc.addEventListener('DOMContentLoaded', recalc, false);
})(window, document);

代码里假设UI设计稿原始宽度为750px。大意是:

  • 页面宽度超过750px时,document的字体大小恒为100px,此时页面中涉及尺寸的CSS样式部分统统用{像素值} / 100 rem书写。之所以使用100px正是为了换算时方便
  • 页面宽度小于750px时,document等比缩小字体大小,页面中使用rem的元素都会等比缩小。从而保证交互元素不错位。

效果类似淘宝手机站。使用rem布局需要注意:

  • <head>中放置这段代码,保证在页面元素渲染前确定font-size大小,避免闪屏现象出现影响体验。
  • PC端可以使用媒体查询rem在移动端使用较多
  • rem方案可以使用在页面的一部分,在宽度固定的元素中使用flex这样的方案

用户体验

上个活动页中遇到了一些优化体验的小地方,列在下面。

  • 禁止用户选中页面元素。这么做可以避免长按屏幕的选中,很明显地提升体验。
    1
    2
    3
    4
    5
    6
    7
    8
    body {
    -moz-user-select: none;
    -webkit-user-select: none;
    -ms-user-select: none;
    -khtml-user-select: none;
    user-select: none;
    margin: 0 auto;
    }
  • 禁止一些手机上为手机号码和邮箱地址进行的特殊处理
    1
    2
    <meta name="format-detection" content="telephone=no">
    <meta name="format-detection" content="email=no">
  • 禁止页面缩放,很常见的需求
    1
    <meta name="viewport" content="width=device-width, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
  • 为二维码单独切图,可以解决长按二维码无法识别的问题
  • 输入框获得焦点时自动上移。可以通过document.scrollTop或设置输入框部分position:fixed实现
0%