lisongfeng 发布的文章

本文通过分析一个小案例帮大家从一个侧面理解JavaScript的异步执行机制,从而可以在实践中避免类似的尴尬。

小背景

我们都知道,alert这种内置弹框会阻塞后续代码执行:

之所以如此,就是因为JavaScript代码在浏览器中是单线程执行的。换句话说,浏览器中只有一个主线程负责运行所有JavaScript代码(不考虑Web Worker)。

提到浏览器中的JavaScript,基本上只有三个来源:

  • BOM API的代码,让我们可以操作并利用浏览器提供的能力
  • DOM API的代码,让我们可以操作网页内容
  • 我们自己写的ECMAScript代码

这没什么。我们也知道,setTimeout用于“定时”执行代码,比如这样可以定时在3秒钟之后执行一段代码(函数):

setTimeout(delayCode, 3000)

当然,我们也都知道,setTimeout的“定时”并不精确,它只能保证delayCode函数在3秒以后执行,至于在3秒以后还要等多长时间才能执行,就跟它没关系了。

那跟什么有关系?我们知道,任务队列/事件循环是JavaScript执行异步任务的机制。setTimeout作为BOM API,它只负责设定一个计时器,到点把要执行的函数添加到任务队列。这样它就完成任务了。而把函数添加到任务队列并不能保证立即执行。什么时候能执行取决于事件循环什么时候把这个函数调度到主线程。事件循环调度异步函数的前提是主线程空闲。如果主线程被阻塞了,即使把函数添加到事件对列,事件循环也不会立即调度它到主线程。这就是setTimeout不能精确定时执行某个函数的原因。

显然,如果你的代码中存在依赖setTimeout精确定时的逻辑,就有可能遭遇尴尬。为此我们自己写代码时,除非绝对有把握,一定尽量不要依赖setTimout的精确定时。可是,问题在于我们能保证自己写的代码不依赖它,却很难保证我们代码依赖的第三方代码不依赖它。

小案例

下面我们就来介绍一个遭遇这种尴尬的真实案例。这个案例涉及的功能很简单,就是jQuery的$.ajax()函数在加载数据失败时重发请求。由于其超时逻辑依赖setTimeout的精确定时,结果导致超时设置失效。

说明一下,这个案例是在调试代码时把请求超时时间设置得很小(比如15毫秒)造成的。正常情况下应该设置为一个合理的值(如5000毫秒)。

相关代码也很简单,主要涉及3个函数:

function asyncRequest() {
  $.ajax({
    url: 'https://api.example.rs',
    timeout: 15
  }).then(success, fail)
}

function success(data) {
  // 正常处理数据
}

function fail(xhr, errtext, errthrown) {
  // 重发请求
  asyncRequest()
  // 弹框提示;阻塞主进程
  alert('请求超时')
}
// 首次调用
asyncRequest()
  • asyncRequest:包含Ajax请求的函数,会在fail中再次调用
  • success:Ajax请求成功的回调
  • fail:Ajax请求失败的回调

正常逻辑是这样的:调用asyncRequest发送请求,成功则浏览器将success添加到任务队列,失败则浏览器将fail添加到任务队列。之后由事件循环将它们调度到主线程执行。success就是正常处理数据,而fail会先调用asyncRequest重发请求,再调用alert弹框提示。

测试环境下Ajax请求100毫秒左右可以返回。而为了测试超时失败后的逻辑,我们故意将超时时间设置为15毫秒,确保一定会超时。实际测试时,首次请求超时,走fail分支,重发请求、弹框,都没问题。但是,在鼠标点击关闭弹框后,却发现重发的请求正常返回了,并没有因超时被取消掉。反复测试都是如此。

这就尴尬了,到底为什么呢?研究发现,jQuery干掉超时请求的代码是这样的(https://j11y.io/jquery/#v=git&fn=jQuery.ajax):

// Timeout
if (s.async && s.timeout > 0) {
  timeoutTimer = window.setTimeout(function () {
      jqXHR.abort("timeout");
  },
  s.timeout);
}

也就是说,在我们设置了timeout选项的情况下,jQuery会通过setTimeout设置一个15毫秒后定时执行的函数,用来中断(abort)请求,我们称其为中断函数

正常情况下,执行完上面的代码,浏览器会在15毫秒后把中断函数添加到任务队列上。此时如果主线程是空闲的,则事件循环会立即把这个函数调度到主线程去执行,请求被取消,浏览器把fail添加到任务队列,事件循环把它调度到主线程执行。这正是首次调用asyncRequet的情况。

第二次调用asyncRequest时有什么不同呢?不同之处在于这次调用完asyncRequest之后,还弹框阻塞了主线程。调用asyncRequest的结果跟之前一样,浏览器仍然会在15毫秒后把中断函数添加到任务队列。但是,这里要注意,由于此时主线程因弹框阻塞一直处于被占用状态,事件循环只能等待。直到我们手拿鼠标花一两秒时间把弹框关闭,主线程空闲出来,中断函数才会被调度到主线程上执行。而在此之前,Ajax请求早已成功返回,同时浏览器把success添加到任务队列。

理论上,Ajax请求返回后jqXHRXMLHttpRequest)对象的状态不应再有任何改变(改变也没意义)。因此,中断函数的执行并不会改变“请求已经成功返回”这个事实。更为尴尬的是——中断函数执行后,紧接着,事件循环又把success函数调度到主线程。而fail函数根本就没有进入任务队列,更谈不上执行了。

小收获

通过上面的案例分析,我们看到本该“超时”失败的请求,因为中断函数被耽误在任务队列上迟迟得不到执行,最终反而成功返回了数据。当然,问题的根源在于alert弹框阻塞了主线程,以及JavaScript的异步机制(事件循环)。

至于jQuery依赖setTimeout取消超时请求的逻辑,只要不是遇到像本文案例这样长时间阻塞主进程的情况就不会有问题。在本案例中,如果不是为了测试而把超时时间设置得那么短,而是设置为比如5000毫秒,这个尴尬的局面也不会出现。假如实际的服务器响应时间真超过了5秒,只要我们在Ajax请求返回前关掉弹框,中断函数还是会先一步执行,从而取消未完成的请求。当然,实践中使用系统弹框阻塞主进程本来也不是推荐的做法。

不管怎么样,机缘巧合,我们还是借这个小尴尬(重温或者)深入理解了setTimeout乃至JavaScript(应该说浏览器提供的JavaScript运行时)的异步代码执行机制。那么在今后的编程实践中,我们就可以有意识地在逻辑中避免依赖setTimeout精确定时,因为它的定时真的不可靠啊!


感谢hax帮忙审校本文

提到环境变量,我们都知道PATH中包含着常用可执行文件的路径,有了它在命令行程序中直接输入文件名就可以运行程序。在Node.js环境中,我们也经常使用process.env.NODE_ENV来区分开发和线上环境,比如在开发环境下可以打印日志、不压缩资源,以方便调试。

但是,除了这些,你对环境变量还知道多少?本文将带领大家全面了解一下环境变量。

一点背景

我们现在使用的环境变量是在1979年Version 7 Unix中成型的,此后所有Unix系统包括Linux和macOS都实现了同样的特性。1982年,从PC DOS 2.0起,所有Windows操作系统也包含了环境变量,只是语法、用法和标准变量名有所差异。

环境变量是与进程紧密相关的一个特性。进程是什么?通俗地理解,进程就是“进行中的程序”,书面说法则是“运行中程序的一个实例”。意思其实都一样。之所以要抽象出进程这个概念,部分原因是为了隐藏使用CPU和内存资源的复杂度。有了进程的概念,一个程序在运行时就好像可以占有全部CPU和内存一样,不必考虑其他同时运行的程序。而每个程序都是在一定的上下文中运行,这个上下文中包含了程序正确运行所需的状态。这里所说的状态包括程序的代码、数据、栈、通用寄存器的内容、程序计数器、环境变量及打开的文件描述符,等等。

访问环境变量

环境变量可以在脚本中使用,也可以在命令行中使用。通常需要在变量名前面或两侧添加特殊符号来引用某个环境变量。比如,要显示用户的主目录,在大多数脚本环境中必须使用:

echo $HOME

在DOS/Windows命令行解释器(如cmd.exe)中,要这样写:

ECHO %HOME%

在Windows PowerShell中,则要这样写:

Write-Output $env:HOMEPATH

命令行程序有3个内置命令,可以列出环境变量及它们的值:

  • env
  • set
  • printenv
在Unix和类Unix系统中,环境变量区分大小写。

fork,exec

在Unix中,环境变量通常在系统启动时由初始化脚本进行初始化,然后由系统中的所有其他进程继承。同样,当在一个程序中打开另一个程序时,调用程序会先复制一个与自身完全一样的进程,即子进程。子进程可以根据需要修改环境变量。最后,子进程再通过执行被调用的程序来覆盖自己。其中,复制进程对应fork,执行程序对应exec

  • fork:是由操作系统内核实现的系统调用,用于创建当前进程自身的一个副本;
  • exec:同样是由操作系统内核实现的系统调用,用于在已有进程的上下文中运行一个可执行文件。

exec在实际实现中通常是一组函数的代称,比如在C语言中就有execlexecleexeclpexecvexecveexecvp等几个函数,它们共用exec这个名字,只是分别在末尾追加了一两个字母,表示自己接受的参数不同(比如,e表示接收环境变量的指针数组,l表示一个一个地接收命令行参数,即参数列表,p表示使用PATH环境变量查找文件,v表示接收命令行参数的指针数组或者叫向量)。Linux内核只有一个叫execve的实现,前面所有其他的函数都是在用户空间中对这个系统调用的封装。

下面我们通过分析execve来理解父进程在创建子进程时如何传递环境变量。先看一看execve的接口:

#include <unistd.h>

int execve(const char *pathname, char *const argv[],
           char *const envp[]);

(http://man7.org/linux/man-pages/man2/execve.2.html)

execve函数接收3个参数,第一个是可执行文件的路径pathname,第二个是参数的指针数组argv,第三个是环境变量的指针数组envp。下图展示了参数数组的数据结构:

如图所示,变量argv指向一个NULL结尾的指针数组,前面的每个元素都是一个指向参数字符串的指针。按照约定,argv[0]是可执行文件的名称。下面是环境变量指针数组的数据结构:

两上数据结构类似。唯一的区别是,环境变量数组元素指向的字符串都是名-值对形式的,比如"PWD=/usr/droh"

在找到pathname对应的可执行文件后,execve会调用操作系统永驻内存的loader代码,把可执行文件的代码和数据从磁盘复制到内存。然后,跳到其第一个指令或“入口点”开始执行该程序。这个过程叫加载。

加载之后,就是通过系统启动函数来运行用户的main函数:

int main(int argc, char *argv[], char *envp[]);

同样,main函数也接收3个参数,其中最后一个参数envp就是新进程或子进程继承的环境变量。

命令行程序

命令行程序是我们最常用的启用其他程序的工具,因此会频繁地使用上述的fork/exec过程。比如:

node ./index.js

就会启用一个新的Node.js进程,运行index.js。与此同时,父进程的环境变量也会传递给这个新的子进程。

在Unix中,脚本或编译的程序修改的环境变量只会影响当前进程及其子进程。父进程及其他不相关的进程不受影响。

在命令行程序中,内置的export命令用来在当前进程中创建环境变量(自然也会被子进程继承):

export API_URL=http://example.com/api

不使用export关键字也可以创建变量,虽然以这种方式定义的变量可以通过set命令显示,但却不是真正的环境变量。这种变量只是命令行程序存储的,操作系统内核并不认。因此envprintenv命令不会显示它们,子进程也不会继承它们:

API_URL=http://example.com/api

然而,在命令行中调用其他程序时,在前面添加类似上面的变量赋值,则会将该变量添加到子进程的环境变量中:

API_URL=http://example.com/api node ./index.js

这样,index.js就可以通过process.env.API_URL取得传入的API地址了。

用户可以在自己所用的命令行工具的配置脚本(profile script)中添加或修改环境变量。

在DOS和Windows命令行解释器中,使用SET命令创建环境变量并赋值:

SET VAR_NAME=VALUE

只有SET命令会显示所有环境变量及它们的值。

hashbang

命令行脚本除了可以直接使用环境变量,还有一种常见的用法,就是“hashbang”。比如,下面这个脚本使用Node.js作为解释器:

#!/usr/bin/env node

console.log('Hello, You Got It!')

第一行中的#!/usr/bin/env是命令行程序内置env命令的完整路径,后面跟着运行当前脚本的程序名node。意思是在env的环境变量PATH中去查找解释程序node

当然,不使用env而直接给出node的路径也是可以的:

#!/usr/local/bin/node

console.log('Hello, You Got It!')

但这样是不是就不灵活了?毕竟不同环境下node的位置可能不一样。使用env就可以做到在运行时再定位解释器的位置,从而让脚本的兼容性更好。

当然,使用env也有缺点:不同机器上env的位置同样可能不一样!

[完]

参考文献

各位,如果你的职业是开挖掘机,你说要不要深入理解挖掘机?通常来说,深入理解你操纵的机器才能最终达到人机一体的境界。

当然,你可以说:不用,因为如果挖掘机不好使,我可以换一台。嗯,也有道理。不过,假如你同时又是一名前端开发者,那你要不要深入理解浏览器呢?注意,身为前端,你不太可能有机会因为浏览器不好使就强迫用户换一个你认为好使的。这时候,你好像别无选择了。

不过也不用害怕,今天我们的现代浏览器深度游会非常轻松、快乐。这首先必须感谢一位名叫Mariko Kosaka(小坂真子,https://kosamari.com/)的同行。她在Scripto工作,2018年9月在Google开发者网站上发表了“Inside look at modern web browser”系列文章。本文就是她那4篇文章的“集合版”。为什么搞这个“集合版”?因为她的4篇文章写得实在太好,更难得的是人家亲手绘制了一大堆生动的配图和动画,这让深入理解现代浏览器变得更加轻松愉快。

好了,言归正传。本文分4个部分,对应上述4篇文章(原文链接附后)。

  • 架构:以Chrome为例,介绍现代浏览器的实现架构。
  • 导航:从输入URL到获到HTML响应称为导航。
  • 渲染:浏览器解析HTML、下载外部资源、计算样式并把网页绘制到屏幕上。
  • 交互:用户输入事件的处理与优化。

先来个小小的序言。很多人在开发网站时,只关注怎么写自己的代码,关注怎么提升自己的开发效率。这些当然重要,但是写到一定的阶段,就应该停下来想想:浏览器到底会怎么运行你写的代码。如果你能多了解一些浏览器,然后对它好一点,那么就会更容易达成你提升用户体验的目标。

架构

Web浏览器的架构,可以实现为一个进程包含多个线程,也可以实现为很多进程包含少数线程通过IPC通信。如何实现浏览器,并没有统一的标准。Chrome最新的架构:最上层是浏览器进程,负责协调承担各项工作的其他进程,比如实用程序进程、渲染器进程、GPU进程、插件进程等,如下图所示。

渲染器进程对应新开的标签页,每新开一个标签页,就会创建一个新的渲染器进程。不仅如此,Chrome还会尽量给每个站点新开一个渲染器进程,包括iframe中的站点,以实现站点隔离。

下面详细了解一下每个进程的作用,可以参考下图。

  • 浏览器进程:控制浏览器这个应用的chrome(主框架)部分,包括地址栏、书签、前进/后退按钮等,同时也会处理浏览器不可见的高权限任务,如发送网络请求、访问文件。
  • 渲染器进程:负责在标签页中显示网站及处理事件。
  • 插件进程:控制网站用到的所有插件。
  • GPU进程:在独立的进程中处理GPU任务。之所以放到独立的进程,是因为GPU要处理来自多个应用的请求,但要在同一个界面上绘制图形。

当然,还有其他进程,比如扩展进程、实用程序进程。要知道你的Chrome当前打开了多少个进程,点击右上角的按钮,选择“更多工具”,再选择“任务管理器”。

Chrome的多进程架构有哪些优点呢?

最简单的情况下,可以想像一个标签页就是一个渲染器进程,比如3个标签页就是3个渲染器进程。这时候,如果有一个渲染器崩溃了,只要把它关掉即可,不会影响其他标签页。如果所有标签页都运行在一个进程中,那只要有一个标签页卡住,所有标签页都会卡住。

除此之外,多进程架构还有助于安全和隔离。因为操作系统有限制进程特权的机制,浏览器可以借此限制某些进程的能力。比如,Chrome会限制处理任意用户输入的渲染器进程,不让它任意访问文件。

由于进程都有自己私有的内存空间,因此每个进程可能都会保存某个公共基础设施(比如Chrome的JavaScript引擎V8)的多个副本。这会导致内存占用增多。为节省内存,Chrome会限制自己可以打开的进程数量。限制的条件取决于设备内存和CPU配置。达到限制条件后,Chrome会用一个进程处理同一个站点的多个标签页。

Chrome架构进化的目标是将整个浏览器程序的不同部分服务化,便于分割或合并。基本思路是在高配设备中,每个服务独立开进程,保证稳定;在低配设备中,多个服务合并为一个进程,节约资源。同样的思路也应用到了Android上。

重点说一说站点隔离(http://t.cn/RgNAwLC)。站点隔离是新近引入Chrome的一个里程碑式特性,即每个跨站点iframe都运行一个独立的渲染器进程。即便像前面说的那样,每个标签页单开一个渲染器进程,但允许跨站点的iframe运行在同一个渲染器进程中并共享内存空间,那安全攻击仍然有可能绕开同源策略(http://t.cn/8s1ySzx),而且有人发现在现代CPU中,进程有可能读取任意内存(http://t.cn/R8FwHoX)。

进程隔离是隔离站点、确保上网安全最有效的方式。Chrome 67桌面版默认采用站点隔离。站点隔离是多年工程化努力的结果,它并非多开几个渲染器进程那么简单。比如,不同的iframe运行在不同进程中,开发工具在后台仍然要做到无缝切换,而且即便简单地Ctrl+F查找也会涉及在不同进程中搜索。

导航

导航涉及浏览器进程与线程间为显示网页而通信。一切从用户在浏览器中输入一个URL开始。输入URL之后,浏览器会通过互联网获取数据并显示网页。从请求网页到浏览器准备渲染网页的过程,叫做导航。

如前所述,标签页外面的一切都由浏览器进程处理。浏览器进程中有线程(UI线程)负责绘制浏览器的按钮和地址栏,有线程(网络线程)负责处理网络请求并从互联网接收数据,有线程(存储线程)负责访问文件和存储数据。

下面我们逐步看一看导航的几个步骤。

第一步:处理输入。UI线程会判断用户输入的是查询字符串还是URL。因为Chrome的地址栏同时也是搜索框。

第二步:开始导航。如果输入的是URL,UI线程会通知网络线程发起网络调用,获取网站内容。此时标签页左端显示旋转图标,网络线程进行DNS查询、建立TLS连接(对于HTTPS)。网络线程可能收到服务器的重定向头部,如HTTP 301。此时网络线程会跟UI线程沟通,告诉它服务器要求重定向。然后,再发起对另一个URL的请求。

第三步:读取响应。服务器返回的响应体到来之后,网络线程会检查接收到的前几个字节。响应的Content-Type头部应该包含数据类型,如果没有这个字段,则需要MIME类型嗅探(http://t.cn/Rt2gG2J)。看看Chrome源码(http://t.cn/Ai9cZI7D)中的注释就知道这一块有多难搞。

如果响应是HTML文件,那下一步就是把数据交给渲染器进程。但如果是一个zip文件或其他文件,那就意味着是一个下载请求,需要把数据传给下载管理器。

此时也是“安全浏览”(https://safebrowsing.google.com/)检查的环节。如果域名和响应数据匹配已知的恶意网站,网络线程会显示警告页。此外,CORB(Cross Origin Read Blocking,https://www.chromium.org/Home/chromium-security/corb-for-developers)检查也会执行,以确保敏感的跨站点数据不会发送给渲染器进程。

第四步:联系渲染器进程。所有查检完毕,网络线程确认浏览器可以导航到用户请求的网站,于是会通知UI线程数据已经准备好了。UI线程会联系渲染器进程渲染网页。

由于网络请求可能要花几百毫秒才能拿到响应,这里还会应用一个优化策略。第二步UI线程要求网络线程发送请求后,已经知道可能要导航到哪个网站去了。因此在发送网络请求的同时,UI线程会提前联系或并行启动一个渲染器进程。这样在网络线程收到数据后,就已经有渲染器进程原地待命了。如果发生了重定向,这个待命进程可能用不上,而是换作其他进程去处理。

第五步:提交导航。数据和渲染器进程都有了,就可以通过IPC从浏览器进程向渲染器进程提交导航。渲染器进程也会同时接收到不间断的HTML数据流。当浏览器进程收到渲染器进程的确认消息后,导航完成,文档加载阶段开始。

此时,地址栏会更新,安全指示图标和网站设置UI也会反映新页面的信息。当前标签页面的会话历史会更新,后退/前进按钮起作用。为便于标签页/会话在关闭标签页或窗口后恢复,会话历史会写入磁盘。

最后一步:初始加载完成。提交导航之后,渲染器进程将负责加载资源和渲染页面(具体细节后面介绍)。而在“完成”渲染后(在所有iframe中的onload事件触发且执行完成后),渲染器进程会通过IPC给浏览器进程发送一个消息。此时,UI线程停止标签页上的旋转图标。

初始加载完成后,客户端JavaScript仍然可能加载额外资源并重新渲染页面。

如果此时用户在地址又输入了其他URL呢?浏览器进程还会重复上述步骤,导航到新站点。不过在此之前,需要确认已渲染的网站是否关注beforeunload事件。因为标签页中的一切,包括JavaScript代码都由渲染器进程处理,所以浏览器进程必须与当前的渲染器进程确认后再导航到新站点。

如果导航请求来自当前渲染器进程(用户点击了链接或JavaScript运行了window.location = "https://newsite.com"),渲染器进程首先会检查beforeunload处理程序。然后,它会走一遍与浏览器进程触发导航同样的过程。唯一的区别在于导航请求是由渲染器进程提交给浏览器进程的。

导航到不同的网站时,会有一个新的独立渲染器进程负责处理新导航,而老的渲染器进程要负责处理unload之类的事件。更多细节,可以参考“页面生命周期API”:http://t.cn/Rey7RIE

另外,导航阶段还可能涉及Service Worker,即网页应用中的网络代理服务(http://t.cn/R3SH3HL),开发者可以通过它控制什么缓存在本地,何时从网络获取新数据。Service Worker说到底也是需要渲染器进程运行的JavaScript代码。如果网站注册了Server Worker,那么导航请求到来时,网络线程会根据URL将其匹配出来,此时UI线程就会联系一个渲染器进程来执行Service Worker的代码:可能只要从本地缓存读取数据,也可能需要发送网络请求。

如果Service Worker最终决定从网络请求数据,浏览器进程与渲染器进程间的这种往返通信会导致延迟。因此,这里会有一个“导航预加载”的优化(http://t.cn/Ai9qGJ66),即在Service Worker启动同时预先加载资源,加载请求通过HTTP头部与服务器沟通,服务器决定是否完全更新内容。

渲染

渲染是渲染器进程内部的工作,涉及Web性能的诸多方面(详细内容可以参考这里http://t.cn/Ai9c4nUu)。标签页中的一切都由渲染器进程负责处理,其中主线程负责运行大多数客户端JavaScript代码,少量代码可能会由工作线程处理(如果用到了Web Worker或Service Worker)。合成器(compositor)线程和栅格化(raster)线程负责高效、平滑地渲染页面。

渲染器进程的核心任务是把HTML、CSS和JavaScript转换成用户可以交互的网页接下来,我们从整体上过一遍渲染器进程处理Web内容的各个阶段。

解析HTML

构建DOM。渲染器进程收到导航的提交消息后,开始接收HTML,其主线程开始解析文本字符串(HTML),并将它转换为DOM(Document Object Model,文档对象模型)。

DOM是浏览器内部对页面的表示,也是JavaScript与之交互的数据结构和API。

如何将HTML解析为DOM由HTML标准(http://t.cn/R2NREUt)定义。HTML标准要求浏览器兼容错误的HTML写法,因此浏览器会“忍气吞声”,绝不报错。详情可以看看“解析器错误处理及怪异情形简介”(http://t.cn/Ai9c8i5D)。

加载子资源。网站都会用到图片、CSS和JavaScript等外部资源。浏览器需要从缓存或网络加载这些文件。主线程可以在解析并构建DOM的过程中发现一个加载一个,但这样效率太低。为此,Chrome会在解析同时并发运行“预加载扫描器”,当发现HTML文档中有<img><link>时,预加载扫描器会将请求提交给浏览器进程中的网络线程。

JavaScript可能阻塞解析。如果HTML解析器碰到<script>标签,会暂停解析HTML文档并加载、解析和执行JavaScript代码。因为JavaScript有可能通过document.write()修改文档,进而改变DOM结构(HTML标准的“解析模型”有一张图可以一目了然:http://t.cn/Ai9cupLc)。所以HTML解析器必须停下来执行JavaScript,然后再恢复解析HTML。至于执行JavaScript的细节,大家可以关注V8团队相关的分享:http://t.cn/RB9qP51

提示浏览器你要加载资源

为了更好地加载资源,可以通过很多方式告诉浏览器。如果JavaScript没有用到document.write(),可以在<script>标签上添加asyncdefer属性。这样浏览器就会异步运行JavaScript代码,不会阻塞解析。合适的话,可以考虑使用JavaScript模块(http://t.cn/RDXCctZ)。再比如,<link rel="preload">告诉浏览器该资源对于当前导航绝对必要,应该尽快下载。关于资源加载优先级,可以参考这里:http://t.cn/EVlIx31

计算样式

光有DOM还不行,因为并不知道页面应该长啥样。所以接下来,主线程要解析CSS并计算每个DOM节点的样式。这个过程就是根据CSS选择符,确定每个元素要应用什么样式。在Chrome开发工具“计算的样式”(computed)中可以看每个元素计算后的样式。

就算网页没有提供任何CSS,每个DOM节点仍然会有计算的样式。这是因为浏览器有一个默认的样式表,Chrome默认的样式在这里:http://t.cn/Ai9VALCy

布局

到这一步,渲染器进程知道了文档的结构,也知道了每个节点的样式。但基于这些信息仍然不足以渲染页面。比如,你通过电话跟朋友说:“画一个红色的大圆形,还有一个蓝色的小方形”,你的朋友仍然不知道该画成什么样。

布局,就是要找到元素间的几何位置关系。主线程会遍历DOM元素及其计算样式,然后构造一棵布局树,这棵树的每个节点将带有坐标和大小信息。布局树与DOM树的结构类似,但只包含页面中可见元素的信息。如果元素被应用了display: none,则布局树中不会包含它(visibility: hidden的元素会包含在内)。类似地,通过伪类p::before{content: 'Hi!'}添加的内容会包含在布局树中,但DOM树中却没有。

确定页面的布局要考虑很多很多因素,并不简单。比如,字体大小、文本换行都会影响段落的形状,进而影响后续段落的布局。CSS可让元素浮动到一边、隐藏溢出边界的内容、改变文本显示方向。可想而知,布局阶段的任务是非常艰巨的。Chrome有一个工程师团队专司布局,感兴起的话,可以看看他们这个分享:http://t.cn/Ai9VcjFn(在YouTube上)。

绘制

有了DOM、样式和布局,仍然不足以渲染页面。还要解决先画什么后画什么,即绘制顺序的问题。比如,z-index影响元素叠放,如果有这个属性,那简单地按元素在HTML中出现的顺序绘制就会出错。

因此,在这一步,主线程会遍历布局树并创建绘制记录。绘制记录是对绘制过程的注解,比如“先画背景,然后画文本,最后画矩形”。如果你用过<canvas>,应该更容易理解这一点。

渲染是一个流水线作业(pipeline):前一道工序的输出就是下一道工序的输入。这意味着如果布局树有变化,则相应的绘制记录也要重新生成。

如果元素有动画,浏览器就需要每帧运行一次渲染流水线。目前显示器的刷新率为每秒60次(60fps),也就是说每秒更新60帧,动画会显得很流畅。如果中间缺了帧,那页面看起来就会“闪眼睛”。

即便渲染操作的频率能跟上屏幕刷新率,但由于计算发生在主线程上,而主线程可能因为运行JavaScript被阻塞。此时动画会因为阻塞被卡住。

此时,可以使用requestAnimationFrame()将涉及动画的JavaScript操作分块并调度到每一帧的开始去运行。对于耗时的不必操作DOM的JavaScript操作,可以考虑Web Worker(http://t.cn/Ai9VBqs9),避免阻塞主线程。

合成

知道了文档结构、每个元素的样式、页面的几何关系,以及绘制顺序,接下来就该绘制页面了。具体该怎么绘制呢?把上述信息转换为屏幕上的像素叫做栅格化。

最简单的方式,可能就是把页面在当前视口中的部分先转换为像素。然后随着用户滚动页面,再移动栅格化的画框(frame),填补缺失的部分。Chrome最早的版本就是这样干的。

但现代浏览器会使用一个更高级的步骤叫合成。什么是合成?合成(composite)是将页面不同部分先分层并分别栅格化,然后再通过独立的合成器线程合成页面。这样当用户滚动页面时,因为层都已经栅格化,所以浏览器唯一要做的就是合成一个新的帧。而动画也可以用同样的方式实现:先移动层,再合成帧。

怎么分层?为了确定哪个元素应该在哪一层,主线程会遍历布局树并创建分层树(这一部分在开发工具的“性能”面板中叫“Update Layer Tree”)。如果页面某些部分应该独立一层(如滑入的菜单)但却没有,那你可以在CSS中给它加上will-change属性(http://t.cn/R7IJCx2)来提醒浏览器。

分层并不是越多越好,合成过多的层有可能还不如每帧都对页面中的一小部分执行一次栅格化更快。关于这里边的权衡,可以参考:http://t.cn/Ai9fiJiM

创建了分层树,确定了绘制顺序,主线程就会把这些信息提交给合成器线程。合成器线程接下来负责将每一层转换为像素——栅格化。一层有可能跟页面一样大,此时合成器线程会将它切成小片(tile),再把每一片发给栅格化线程。栅格化线程将每一小片转换为像素后将它们保存在GPU的内存中。

合成器线程会安排栅格化线程优先转换视口(及附近)的小片。而构成一层的小片也会转换为不同分辨率的版本,以便在用户缩放时使用。

所有小片都栅格化以后,合成器线程会收集叫做“绘制方块”(draw quad)的小片信息,以创建合成器帧。

  • 绘制方块:包含小片的内存地址、页面位置等合成页面相关的信息
  • 合成器帧:由从多绘制方块拼成的页面中的一帧

创建好的合成器帧会通过IPC提交给浏览器进程。与此同时,为更新浏览器界面,UI线程可能还会添加另一个合成器帧;或者因为有扩展,其他渲染器进程也可能添加额外的合成器帧。所有这些合成器帧都会发送给GPU,以便最终显示在屏幕上。如果发生滚动事件,合成器线程会再创建新的合成器帧并发送给GPU。

使用合成的好处是不用牵扯主线程。合成器线程不用等待样式计算或JavaScript执行。这也是为什么“只需合成的动画”(http://t.cn/Ai9fO8OW)被认为性能最佳的原因。因为如果布局和绘制需要再次计算,那还得用到主线程。

交互

最后,我们看一看合成器如何处理用户交互。说到用户交互,有人可能只会想到在文本框里打字或点击鼠标。实际上,从浏览器的角度看,交互意味着来自用户的任何输入:鼠标滚轮转动、触摸屏幕、鼠标悬停,这些都是交互。

当用户交互比如触摸事件发生时,浏览器进程首先接收到该手势。但是,浏览器进程仅仅知道手势发生在哪里,因为标签页中的内容是渲染器进程处理。因此浏览器进程会把事件类型(如touchstart)及其坐标发送给渲染器进程。渲染器进程会处理这个事件,即根据事件目标来运行注册的监听程序。

具体来说,输入事件是由渲染器进程中的合成器线程处理的。如前所述,如果页面上没有注册事件监听程序,那合成器线程可以完全独立于主线程生成新的合成器帧。但是如果页面上注册了事件监听程序呢?此时合成器线程怎么知道是否有事件要处理?

这就涉及一个概念,叫“非快速滚动区”(non-fast scrollable region)。我们知道,运行JavaScript是主线程的活儿。在页面合成后,合成器线程会给附加了事件处理程序的页面区域打上“Non-Fast Scrollable Region”的记号。有了这个记号,合成器线程就可以在该区域发生事件时把事件发送给主线程。

如果事件发生在这个区域外,那合成器线程会继续合成新帧而不会等待主线程。

提到注册事件,有一个常见的问题要注意。很多人喜欢使用事件委托来注册处理程序。这是利用事件冒泡原理,把事件注册到最外层元素上,然后再根据事件目标决定是否执行任务。

document.body.addEventListener('touchstart', evt => {
    if (evt.target === area) {
        evt.preventDefault()
    }
})

一个事件处理程序就可以面向多个元素,这种高效的写法因此很流行。然而,从浏览器的角度来看,这样会导致整个页面被标记为“非快速滚动区”。这也就意味着,即便事件发生在那些不需要处理的元素上,合成器线程也要每次都跟主线程沟通,并等待它的回应。于是,合成器线程平滑滚动的优点就被抵销了。

为缓冲使用事件委托带来的副作用,可以在注册事件时传入passive: true。这个选项会提醒浏览器,你仍然希望主线程处理事件,但与此同时合成器线程也可以继续合成新的帧。

document.body.addEventListener('touchstart', evt => {
  ...
}, { passive: true })

此外,检查事件是否可以取消也是一个优化策略。假设页面中有一个盒子,你想限制盒子中的内容只能水平滚动。

使用passive: true可以让页面平滑滚动,但为了限制滚动方向而调用prevenDefault则不会避免垂直滚动。此时可以检查evt.cancelable

document.body.addEventListener('pointermove', evt => {
    if (evt.cancelable) {
        evt.preventDefault(); // 阻止原生滚动
        /*
        *  其他操作
        */
    }
}, { passive: true });

当然,也可以使用CSS规则如touch-action完全避免使用事件处理程序。

#area {
    touch-action: pan-x;
}

合成器线程把事件发送给主线程以后,要做的第一件事就是通过命中测试(hit test)找到事件目标。命中测试就是根据渲染进程生成的绘制记录数据和事件坐标找到下方的元素。

另外,事件还有一个触发频率的问题。通常的触屏设备每秒会产生60~120次触碰事件,而鼠标每秒会产生约100次事件。换句话说,输入事件具有比每秒刷新60次的屏幕更高的保真度。

如果像touchmove这种连续事件,以每秒120次的频率发送到主线程,相比更慢的屏幕刷新率而言,就会导致过多的命中测试和JavaScript执行。

为把对主线程过多的调用降至最少,Chrome会合并(coalesce)连续触发的事件(如wheelmousewheelmousemovepointermovetouchmove),并将它们延迟到恰好在下一次requestAnimationFrame之前派发。

对于其他离散触发的事件,像keydownkeyupmouseupmousedowntouchstarttouchend会立即派发。

合并后的事件在多数情况下足以保证不错的用户体验。但是,在一些特殊应用场景下,比如需要基于touchmove事件的坐标生成轨迹的绘图应用,合并事件就会导致丢失一些坐标,影响所绘线条的平滑度。

此时,可以使用指针事件的getCoalescedEvents方法,取得被合并事件的信息:

window.addEventListener('pointermove', event => {
    const events = event.getCoalescedEvents();
    for (let event of events) {
        const x = event.pageX;
        const y = event.pageY;
        // 使用x和y坐标画线
    }
});

这是个小小的结尾。相信不少前端开发者早已知道给<script>标签添加deferasync属性的作用。通过阅读本文,你应该也知道了为什么在注册事件监听器时最好传入passive: true选项,知道了CSS的will-change属性会让浏览器做出不同的决策。事实上,不止上面这些,看完看懂篇文章,你甚至也会对其他关于浏览器性能优化的细节感到豁然开朗,从而对更多关于网页性能的话题会产生兴起。而这正是深入理解现代浏览器的重要意义和价值所在,因为它为我们打开了一扇大门。

原文链接:

再次感谢原文作者:Mariko Kosaka

[完]

我曾在本系列第二篇“H5必知必会之像素级还原设计稿”的最后写道:

……读者可能会不由自主地猜测这个系列第三篇的主题是什么?……还有可能会写一写基于某个脚手架快速从头搭建一个H5项目以及实现前端工程化……。

那么,今天我们这一篇的主题就是“快速搭建开发环境”。快速,有多快?答案:取决于你的网速。因为我们真的有这么一个脚手架:qvk。

什么是qvk

qvk是一个集成现代前端工程化最佳实践的通用Web开发环境,可用于开发传统C/S架构的Web应用、SPA(单页应用)、H5(App内嵌页)等。

qvk脱胎于多个360前端项目,集成了开源社区中涌现的优秀框架、工具和最佳实践。qvk初始版集成以下Web框架和打包工具。

  • ThinkJS:基于MVC模式的简单易用、功能强大的Node.js开发框架。
  • Vue.js:渐进式JavaScript框架,前端组件式开发主流选择。
  • Webpack:目前使用最广泛的前端资源模块打包工具。

什么,这里有些新面孔?没关系,不久你就会发现它们都很友好、简单。

搭建开发环境

qvk的Github仓库地址是:https://github.com/qqvk/qvk

打开这个页面,点击“Clone or download”按钮:

点击“复制到剪贴板”图标。打开常用的终端,进入或新建某个项目目录,比如myProject,克隆qvk的代码:

注意,我们给新项目起了一个名字,叫h5project。进入这个目录:

LICENSE和README.md,当然一个是协议,一个是项目介绍。而client、server两个目录,则分别用于保存前端和服务端代码(及依赖)。

为方便开发,我们建议使用VSCode,并通过把code命令添加到路径,从命令行启动VSCode(参见这个链接:https://code.visualstudio.com/docs/setup/mac):

这样,在项目路径下运行code .就可以启动VSCode:

VSCode启动后,按快捷键Ctrl+`切换到终端,再按Cmd+拆分终端(或者鼠标点击图标进行拆分),结果如下:

接下来,分别安装前端和服务端的依赖。左、右终端窗口分别进入server和client目录,并运行npm install安装依赖:

根据每个人的网络状况不同,安装依赖大约耗时10~30秒不等。事实上,这是我们搭建环境中最慢的一步。至于如何加速这个过程,大家可以自行上网搜索相关文章。

安装完依赖,还需要构建一下前端代码,以便生成服务端所需的模板文件和静态资源。简单,只要在client目录下运行npm run build即可:

这一步执行完之后,会在server目录(没错,是在server目录,本文后面会解释)下生成两个目录。

  • view:保存构建生成的模板,供服务端使用
  • www:保存构建生成的模板引用的静态资源(js/css等)

好了,现在我们的开发环境已经具备启动条件了!稍等,在启动之前,还需要说明一下。

如前所述,qvk默认将开发环境分成了前端(client)和服务端(server),两端分别启动Web服务器,端口默认为:

  • 前端:9090
  • 服务端:8900

启动服务后,前端会把所有浏览器请求代理到服务端,即浏览器会自动打开:http://localhost:9090,而所有请求实际上由服务端8900端口响应。之所以这样配置,是为了实现前、后端开发的实时编译和刷新(为什么选这两个端口?没有原因,就是随便选的而已)。

说明结束。接下来就启动服务吧——由于前端服务要代理到后端服务,所以启动顺序是……?没错:先启动后端,后启动前端。

怎么启动?npm start

先启动服务端:

再启动前端:

启动前端,会触发Webpack的初始构建过程,构建完成后,会自动在浏览器中打开http://localhost:9090/

恭喜,看到这个网页,就说明你的开发环境搭建完成了!你说快不快?

上手开发

光搭建好环境还不成,关键是要知道怎么使用这个环境开发自己的项目。

ThinkJS是一个优秀的Node.js MVC开发框架,上手非常容易。这里,server目录其实就是ThinkJS的工作目录。如果忽略保存依赖的node_modules和用于保存运行时配置的runtime,开发中实际用到的只有src、view和www目录,如下图所示:

(考虑到view和www是前端构建生成的,所以这里真正用到的就只有src目录了,哈哈哈。是不是又简单了?)

用ThinkJS开发一个页面,只要简单两步:

  • 根据确定的路由编写控制器方法
  • 在控制器方法中指定渲染页面的模板

我们就以现有的代码为例。src/controller里保存所有控制器代码,其中index.js是默认控制器,而index.js中的indexAction是默认的响应处理函数(如果方法不带Action,则不能响应外部请求,可作为内部方法)。

src/controller/index.js

const Base = require("./base.js");

module.exports = class extends Base {
  async indexAction() {
    return this.display()
  }
  pixelAction() {
    return this.display('pixel_demo')
  }
};

所谓默认,意思就是http://localhost:9090/这个请求,默认会由index.js的indexAction方法来处理。怎么处理的呢?这里倒是极其简单,只有一行代码:

return this.display()

this.display()方法会返回使用默认模板引擎渲染默认模板之后的HTML,作为服务器的响应返回给浏览器。默认模板引擎是Nunjucks(https://mozilla.github.io/nunjucks/),而默认的模板文件就是view/index_index.html:

view/index_index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
  <title>qvk: A Web integrated development environment.</title>
<link href="/static/css/index_index_79ed.css" rel="stylesheet"></head>
<body>
<div class="welcome">
  <div reminder>
    <h2>欢迎使用qvk创建开发环境</h2>
    <p><a href="/index/pixel">H5必知必会之像素级还级设计稿DEMO</a></p>
  </div>
</div>
<script type="text/javascript" src="/static/js/commons_6a84.js"></script><script type="text/javascript" src="/static/js/index_index_79ed.js"></script></body>
</html>

当然,这个HTML模板中可以使用模板引擎支持的各种语法,嵌入变量和语句,以及其他服务端逻辑(这里没有,哈哈,具体可以参考模板引擎的文档:https://mozilla.github.io/nunjucks/)。

服务端开发说完了,接下来看前端。前端代码在client目录中,同样开发中真正用到的只有src目录:

如图所示,src目录下面有一个Webpack的入口文件:index_index.js,而在模板目录_templates_ssr中,有一个同名但不同扩展名的index_index.njk文件(njk就是Nunjucks的简写)。

Webpack会根据这个命名规则,以index_index.njk为模板生成ThinkJS渲染所需的server/view/index_index.html模板,同时会将入口文件中指定的依赖打包后注入到该模板中。

我们看一下入口文件index_index.js:

client/src/index_index.js

import './_static/css/index_index.scss'

哇,只有一行,使用import模块语法引入了一个SCSS文件。这个文件在通过相应的转换生成普通CSS后,会被注入index_index.html模板中:

<link href="/static/css/index_index_79ed.css" rel="stylesheet">

如果这个页面有交互逻辑呢?那就要在入口文件中引入相应的JavaScript文件(可以放在./_static/js/目录下)。同样,Webpack会在构建时把JavaScript打包注入到模板中。

事实上,在开发中,Webpack会自动监控所有入口文件中引入的资源,只要这些资源有修改和保存,就会触发重新构建并刷新浏览器,让我们实时看到修改的结果。

写在最后

本文到此就结束了,真的结束了。此时,难免有读者会问,我们搭建的是H5开发环境吗?是的。你可以点击这个页面中的链接看一看:

你会发现,我们card组件中的所有CSS像素单位都被转换成了视口宽度单位:

如果读者不明白“把像素单位转换成视口宽度单位”是什么意思,请参考本系列第二篇文章“H5必知必会之像素级还原设计稿”,当然还有这个项目源代码。

关于qvk

最后,qvk还很弱小(事实上,它就诞生于本文撰写前几分钟),算不上有新颖创意,它只是一个集成了众多前端优秀开源项目及最佳实践的脚手架。当然,我们还会继续努力为它增加更多有用的配置和更丰富选项,如果你也有类似的想法,欢迎加入qvk贡献者的行列。

大家有什么想法,欢迎留言吧。

本文是“H5必知必会”系列第二篇。在第一篇“H5必知必会之与App交互”(https://lisongfeng.cn/index.php/2019/03/06/mobile-web-development-2-interactive-with-native-app.html)中我们提到过,所谓“H5”本来应该是HTML5的简称。但是,在中国,“H5”并不是HTML5的简称那么简单,它更多地被用于指代内嵌在手机App中的网页。无论国外有没有“H5”这个说法(应该没有),反正在国内只要说到App内嵌的网页或网页应用,你完全可以说它是“H5”。一说“H5”,产品经理、设计师、Android/iOS应用开发者、后端工程师全都明白。

不能不说,这个现象很神奇。也许,在前端行业之外人的眼里,手机屏幕上的网页就应该有一个跟大屏幕上的网页不一样的名字,这样才好区分。事实上,就算是在前端开发行业内,有这么一个词让我们瞬间就可以定位到一种应用形态,至少也是一件非常便利的好事。

那么手机上的网页跟大屏幕上的网页有什么不同呢?

首先,手机上的网页运行在手机浏览器或者App内嵌的WebView中,可以使用手机特有的硬件能力,比如GPS、加速计、陀螺仪等传感器;此外,如果加载了宿主App的JSSDK,还可以访问App暴露给网页的各种能力,比如获取登录用户的信息、拍照上传和分享,等等。

其次,手机上的网页布局设计要服从于原生App的界面(UI)设计规范,比如谷歌的扁平化设计Material Design,苹果的人机界面指南(Human Interface Guide)等;总之,手机上的网页看起来必须像原生App的界面,包括使用与原生App一致的布局组件、字体图标、配色方案,乃至交互模式等,比如要支持触摸而非鼠标点击交互。

最后,手机上的网页,特别是运行于App内的网页,构成混合移动应用的一部分,通常都需要与原生App互操作;需要H5与原生应用双方共同商定基础接口,并针对特定的交互场景确定具体的交互模式,包括单向调用还是双向调用,同步还是异步,如何传递数据和凭据,等等。

上述第三点,正是本系列第一篇文章“H5必知必会之一App交互”讨论的内容,感兴趣的同学稍后可以参考。那么今天,本文要讨论的则是上述第二点,即H5在构造页面布局时,如何逼真、像素级还原设计稿。

适配与还原:概念阐述

要说还原,必须先从适配说起。

H5适配手机主要有两个维度:

  • 适配不同像素密度
  • 适配不同屏幕大小

像素密度,顾名思义就是CSS中的1像素对应多少物理像素。我们以这张iPhone各代屏幕对照图(来源:https://www.paintcodeapp.com/news)为例:

先看iPhone Xs Max和iPone XR。前者屏幕用CSS像素度量是414x896,而用实际渲染的物理像素度量则是1242x2688像素,简单换算可知,1242/414 = 3(或2688/896 = 3)。换句话说,iPhone Xs Max是人们常说的3倍屏(或@3x),即每个CSS像素对应9个物理像素(因为宽高均为3像素)。而iPhone XR呢,则是人们常说的2倍屏(或@2x),每个CSS像素对应4个物理像素。

当然还有1倍屏。上图最后一个iPhone 2G/3G/3GS,就是1倍屏,即每个CSS像素对应1个物理像素。像这种估计市面上已经绝迹的初代iPhone是不用适配的,因为1个CSS像素就对应屏幕上的1个物理像素。(当然,笔记本和电脑外接的大屏幕至今都是1倍屏,所以目前还不需要我们适配像素密度。)

我们常说的适配像素密度,通常指图片如何在3倍屏和2倍屏上显示不失真。当然,适配原则非常简单:1个图片像素对应1个物理像素,图片就不会失真。具体来说,假设原始图片是500x300像素(如image@1x.jpg),那么适配高密度屏的版本则分别是1500x900像素(如image@3x.jpg)和1000x600像素(如image@2x.jpg)。这样才能做到1个物理像素对应到1个图片像素。

当然,也有一个简单粗暴的适配方案,就是针对所有屏幕,都只提供最高清图片。虽然低密度屏幕用不到那么多图片像素,而且会因为下载多余的像素造成带宽浪费和下载延迟,但从结果上说能保证图片在所有屏幕上都不会失真。

适配像素密度的具体技术方案本文就忽略了,因为它不是本文的重点。本文的重点其实是适配不同屏幕大小。适配不同屏幕大小的原则非常简单:确保页面布局的度量与屏幕大小保持一定比例

听起来简单,做起来可不容易。因为这里就涉及还设计稿的问题了。比如,设计稿宽度通常是750像素:

在这个宽度下,不同页面组件都有各自的度量值,比如,“对音箱说”这个标题的度量为112x28像素:

而标题的上、右、下、左,距离当前组件的边界,分别是38像素、289像素、394像素和289像素:

当然,每个组件与屏幕边界、组件与组件之间,乃至文本大小、间距,在这个设计稿中也都有确定的像素值度量。那么问题来了,面对设计稿中林林总总的像素值,我们如何在H5页面中还原它们?

答案很简单:按比例还原。

仍然以前面的设计图为例,我们知道设计稿假定的屏幕宽度为750像素(实际上多少像素都没问题,因为我们是按比例还原设计稿),而如下面几张图所示,这个圆角矩形组件的宽度是690像素:

它与上方距离为30像素(与左、右边界同样是30像素):

有了这些像素数据,经过简单的数学换算,不难得出:

  • 组件宽度占屏幕宽度的百分比为:690/750 = 0.92,即92%;
  • 组件上、右、左边距占屏幕宽度的百分比为:30/750 = 0.04,即4%。

好了,现在只要能让浏览器(WebView)按照这个比例去渲染,我们的组件就能做到对设计稿的像素级还原——在750像素宽的屏幕上,组件及其子组件的所有像素度量都能严格与设计稿保持一致!

更重要的是,在屏幕不是750像素的情况下(事实上750像素宽的手机屏幕至今还没出现过,注意我是CSS像素宽度),页面的布局依旧可以按照设计稿的既定比例完美适配,即在大一点的屏幕上,页面的方方面面都会显示得大一些;而在小一点的屏幕上,则整个布局都会按比例缩小一些。

事实上,除了宽度、高度和间距,文本大小(即font-size)、边框、阴影等度量也可以按这个思路把像素转换为百分比。

好了,以上就是关于适配和还原的概念阐述。接下来,我们从理念到实践,看一看技术上如何实现吧。

一个全局性CSS单位

上一节概念阐述提到按比例适配可以做到像素级还原设计稿。但是,实践中可以直接在CSS里使用百分比单位吗?很可惜,不能

我们知道,根据CSS Values and Units Module Level 4(https://www.w3.org/TR/css-values-4/#percentages)的定义:

百分比值总要相对于另一个量,比如长度。每个允许使用百分比值的属性,同时也要定义百分比值参照的那个量。这个量可以是相同元素的另一个属性的值,也可以是祖先元素的某个属性的值,甚至是格式化上下文的一个度量(比如包含块的宽度)。

那我们也知道:

  • 宽度(width)、高度(height)、间距(maring/padding)支持百分比值,但默认的相对参考值是包含块的宽度;
  • 边框(border)不支持百分值;
  • 边框圆角半径(border-radius)支持百分比值,但水平方向相对参考值是盒子的宽度,垂直方向相对参考值是盒子的高度;
  • 文本大小(font-size)支持百分比值,但相对参考值是父元素的font-size的值;
  • 盒阴影(box-shadow)和文本阴影(text-shadow)不支持百分比值;
  • ……

可见,即使支持使用百分比值的属性,其百分比参考值也都不是我们想要的屏幕宽度!换句话说,要实现屏幕级的适配,必须有一个统一的全局性单位供我们参照。我们有吗?有。

首先,有一个rem。同样,根据CSS Values and Units Module Level 4(https://www.w3.org/TR/css-values-4/#font-relative-lengths):

(rem)等于根元素(也就是html元素)font-size属性的计算值。

rem归根结底是一个font-size,但却是一个全局性的度量单位,而且其背后还是像素。如果为了适配目的,根据不同屏幕密度动态修改rem的值(比如,document.documentElement.style.fontSize = rem + 'px'),不就可以实现按比例适配了吗?事实上,阿里手机淘宝团队专门为此研发了一个框架:https://github.com/amfe/lib-flexible。从Github仓库来看,这个框架用了大概有两年多时间,2019年年初正式宣布退役。因为又找到了新的更好的全局性参照单位:vw

什么是vw?同样根据CSS Values and Units Module Level 4:

(vw)等于初始包含块(html元素)宽度的1%。

换句话说,可以认为:1vw就等于屏幕宽度的1%。哇,这个单位似乎是专门为我们适配量身打造的。首先,它本质上就是一个百分比单位,比如3vw就相当“屏幕宽度的3个百分点”;其次,它又是全局性的与屏幕宽度直接相关的单位。那么上一节概念阐述中举的两个百分比的例子,用vw单位可以直接写成:

  • 组件宽度占屏幕宽度的百分比为:690/750 = 0.92,即92vw
  • 组件上、右、左边距占屏幕宽度的百分比为:30/750 = 0.04,即4vw

那么移动浏览器对vw的支持度如何呢?根据caniuse.com(https://caniuse.com/#search=vw):

即iOS 8+和Android 4.4+都支持vw,而目前的手机应用通常支持iOS 9+和Android 5+。所以,实践中使用vw单位完全可行。而这也正是手机淘宝团队今年年初正式转向使用vw单位适配的原因。

那么,现在唯一的问题就是人工进行设计稿的pxvw单位的转换太麻烦。不过,如果你在开发中使用Webpack编译打包,那么已经有人开发了postcss的插件:postcss-px-to-viewport(https://www.npmjs.com/package/postcss-px-to-viewport)。配置好这个插件,你写CSS的时候可以严格按照设计稿上的像素值去写,这个插件负责将你写的px转换为vw。这样一来,我们所说的“像素级还原设计稿”,就彻底打通了开发和呈现,真的做到了“像素级”!

(假如,我只是说假如你没有或不能使用Webpack打包,那么你可能还需要人工计算。)

使用postcss-px-to-viewport

这一节实际是没有必要的。不过,为了完整起见,我们就简单说一下postcss-px-to-viewport插件的配置。以下内容截取自.postcssrc.js配置文件:

module.exports = {
  "plugins": {
      // ...
    "postcss-px-to-viewport": {
      viewportWidth: 750,
      viewportHeight: 1334,
      unitPrecision: 3,
      viewportUnit: 'vw',
      selectorBlackList: ['.usepixel'],
      minPixelValue: 1,
      mediaQuery: false
    },
    // ...
  }
}

其中几个配置项的含义如下:

  • viewportWidth:视口宽度,这里设置为跟设计稿宽度一致;
  • viewportHeight:视口高度,随便设置一个就可以;
  • unitPrecision:转换后值的精度,3表示保留3位小数;
  • viewportUnit:转换成什么视口单位,这里当然是vw
  • selectorBlackList:是一个选择符数组,对应声明中的像素单位不会转换;
  • minPixelValue:最小像素值,大于等于这个值才会转换;
  • mediaQuery:是否转换媒体查询中的像素。

最后,我们以一个实例展示结束本篇文章。仍然是本文开头的设计稿,还是那个圆角矩形组件,在我们这个应用里的类名是.card。下面是它的CSS源代码:

section.card {
  margin: 0 auto;
  margin-bottom: 20px;
  background-color: #fff;
  border-radius: 10px;
  box-shadow: 0 6px 6px 0 rgba(0,0,0,0.02);
  text-align: center;
  padding: 38px 70px 46px;
  font-weight: 300;
 }

没错,所有这些px值都从设计稿中实测得到的(事实上是标注页面自动给出的)。而经过postcss-px-to-viewport插件转换之后,可以看到这些值都被转换成了相对于视口宽度的vw单位:

结束语

作为“H5必知必会”系列的第二篇,本文主要从两个方面讨论了如何高保真还原设计稿,同时适配形态各异的手机屏幕。首先,我们从理论或概念上先搞清楚了适配和还原的内容和目。H5适配主要分适配不同像素密度和适配不同屏幕大小。而适配的目的是为保证用户体验,比如图片不失真;以及在不同大小的屏幕上呈现比例相同的页面布局,这也就是所谓的百分之百还原设计稿,或像素级还原设计稿。

明确了适配和还原的含义及原理,接下来技术实现层面的关键是找到一个全局性的CSS单位。我们先介绍了阿里手机淘宝团队基于rem的适配框架(这个框架除了动态修改rem,还解决了真1像素边框的问题),然后介绍了基于更普适的vw单位实现适配。最后简单介绍了自动实现pxvw单位转换的插件postcss-px-to-viewport的配置。

看到这里,读者可能会不由自主地猜测这个系列第三篇的主题是什么?是讨论H5如何使用手机或宿主App赋予它的特殊能力吗?我也不知道。因为也有可能会跟大家分享多版本下的H5部署与运维策略。当然,还有可能会写一写基于某个脚手架快速从头搭建一个H5项目以及实现前端工程化。总之,未来有多种可能。其实我也想知道大家都想看哪个主题?如果你有想法,那欢迎留言吧。