浏览器环境概述
浏览器内置了 JS 引擎,并提供了一系列接口,以供网页中的 JS 脚本可以控制浏览器的各种功能。
嵌入 JS 到网页的方法
script标签 , 直接嵌入代码或通过 src 属性加载外部脚本,- 又有内嵌代码,又有 src 属性时,浏览器会加载外部脚本,忽略内部代码。
- 事件属性,元素的的事件可以直接写 JS 代码
- URL 协议
javascript:也是一种协议<a href="javascript: void(submit())">文字</a>
script 元素
常规的 script 元素
通常,网页加载的流程如下:
- 浏览器下载 HTML 并解析
- 如果遇到
<script>元素,就暂停解析,把控制权交给 JS 引擎 - 如果 script 标签指向外部脚本,那么下载外部脚本再执行,否则直接执行代码
- JS 引擎执行完毕,控制权还给渲染引擎,继续解析 HTML
由于 JS 代码可以修改 DOM,所以需要等待 JS 下载并执行之后才恢复 HTML 解析,如果外部脚本一直未能下载下来,网页就会进入假死状态,长时间无响应。
所以,script 标签通常放在底部,才可以访问现有的 DOM 元素。当然,把逻辑放在DOMContentLoaded事件里也是一种方法。
当 HTML 中有多个 script 时,浏览器会同时下载多个 js 文件,但执行顺序由 HTML 中的顺序一致,只有这样,才保证了脚本间依赖关系不被破坏。
defer 属性
<script src="a.js"></script>
<script src="b.js" defer></script>由于 JS 会阻塞 HTML 解析,即解析 DOM 树的构建,所以出现了 defer 属性,遇到 defer 属性的script,网页加载的行为如下:
- 下载并解析 HTML
- 遇到 defer 属性的 JS 脚本b,并行下载,同时继续往下解析 HTML
- 解析完 HTML,DOM树构建完毕后,执行脚本b
- 然后才触发
DOMContentLoaded事件
经测试,如果不加 defer,那么:
- 先出现
hello - 等待 deferScript.js 加载完毕,打印
deferScript Call !,再打印small call! - 再渲染
what(很快) - 再打印
DOMContentLoaded !!也就是 script 堵塞 HTML 解析了
如果加defer,那么:
hellowhat基本同时出来,但是标签页还在转圈- 脚本加载完毕后,打印
deferScript Call ! ,再打印small call! - 再打印
DOMContentLoaded !!也就是没有堵塞 HTML 解析,且多个 defer 脚本执行顺序按照文档顺序,但是要注意DOMContentLoaded是在脚本加载完毕后才触发的。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script>
document.addEventListener('DOMContentLoaded', (e) => {
console.log('DOMContentLoaded !!!', e)
})
</script>
</head>
<body>
<h1>hello</h1>
<script src="./deferScript.js"></script>
<script defer src="./small.js"></script>
<h2>waht ?</h2>
</body>
</html>async 属性
async 属性也是解决堵塞问题的一个方法,不堵塞 HTML 解析,在解析HTML的同时并行下载脚本,一旦下载完毕,暂停解析并执行脚本。
显然,与defer相比,带async属性的脚本执行顺序不确定,体量小,下载速度快的脚本会得到优先执行。
所以,一般来说,如果脚本间没有依赖关系,就 async,有依赖关系的话,就 defer
defer 和 async 的应用场景
- 使用 CDN 引入第三方库时,就应该使用
defer保证项目入口脚本文件在第三方库脚本之后。 - 用户行为埋点,比如
Google Analytics,这种独立的脚本就可以async,比如我博客中的这个埋点。

脚本动态加载
JS 可以操作 DOM,那么也就可以通过动态添加 script 元素来实现脚本的动态加载。
function loadScript(url, done){
const js = document.createElement('script')
js.src = url
js.onload = ()=>{
done()
}
js.onerror = (e)=>{
console.log('error')
}
document.head.appendChild(js)
}上面的脚本加载方式默认是加载完立即执行(sync 行为)。当需要加载多个时,应手动设置 async 属性 js.async = false
浏览器的组成
用户界面:包括地址栏、前进/后退按钮、书签菜单等,即我们与浏览器交互的所有部分。是使用操作系统提供的接口绘制的窗体应用。
浏览器引擎(Browser Engine):调度中控内核,在不同模块间传递消息和指令。
渲染引擎(Rendering Engine):负责解析 HTML、CSS 等代码,并将其渲染为视觉上的页面。
JS 引擎(JavaScript Engine):负责解析和执行 JavaScript 代码。
网络模块:负责处理网络请求,比如发送 HTTP 请求、处理文件下载等。
浏览器的核心是两部分 渲染引擎 和 JS 引擎/解释器
渲染引擎
渲染引擎负责把网页代码呈现为视觉感知的平面文档。
| 浏览器 | 渲染引擎 | 说明 |
|---|---|---|
| Firefox | Gecko | Firefox 一直使用 Gecko 引擎,它是 Mozilla 基金会开发的开源引擎。 |
| Safari | WebKit | Safari 使用 WebKit 引擎,这是由苹果公司从 KHTML 引擎分支出来的。 |
| Chrome | Blink | 最初 Chrome 使用 WebKit,但在 2013 年,Google 从 WebKit 分支出了 Blink 引擎,并一直沿用至今。 |
| Microsoft Edge (旧版) | EdgeHTML | 旧版 Edge 使用的是微软自己开发的 EdgeHTML 引擎,它脱胎于 Trident。 |
| Microsoft Edge (新版) | Blink | 在 2018 年,微软宣布放弃 EdgeHTML,转而使用 Chromium(一个开源浏览器项目,其核心就是 Blink 引擎),这就是新版 Edge。 |
| Internet Explorer (IE) | Trident | IE 浏览器使用的就是 Trident 引擎。 |
现在,大部分主流浏览器(Chrome、新版 Edge、Opera 等)都使用 Blink 引擎,这使得 Web 开发变得更加统一,但在开发时,我们仍然需要关注 Safari 的 WebKit 和 Firefox 的 Gecko,以确保代码的兼容性。
渲染流程
- 从浏览器获取到 HTML 开始
- 浏览器开始解析 HTML,请求外部资源,构建
DOM 树,CSSOM 树 - 基于 DOM 树和 CSSOM 树,构建
Render 树 - 布局计算
Layout,得到每个节点的严格位置大小 - 绘制
Paint,生成绘制指令 - 分层与合成(z-index,transform)、光栅化与显示
布局/回流/重流 和 重绘
从渲染树到网页布局,称为布局流,所谓的布局/回流/重流都是指代这个过程。从布局到页面显示的过程叫做绘制。他们都具有阻塞效应,回流必然会伴随着重绘,对性能有较大影响。
作为开发者,应尽可能降低回流和重绘的成本,比如
- 改动叶子上的节点就比改动根节点要好
- DOM 统一读取,统一写入,许多涉及元素尺寸位置的属性会强制触发回流(
offsetTopscrollHeightclientWidthgetComputedStyle(),getBoundingClientRect()) - 采用文档片段统一写入
transform不影响布局,只在分层和合成时做操作,动画优先考虑transform- 涉及回流的操作在
requestAnimationFrame()中去做,避免立即回流造成堵塞。 - 虚拟 DOM
JS 引擎
JS 是一种解释型语言,不需要编译,由解释器试试运行,好处是简单方便,坏处是依赖解释器,运行速度慢于编译型语言。
像 Cpp 这样的语言,从 .cpp 代码,需要通过编译、汇编、链接最后形成二进制可执行文件,由操作系统直接执行。
Java 就是半编译型,需要把.java 编译为.class字节码,再由 JVM 去执行。
目前,现代浏览器(如 V8 引擎)已经对 JS 的运行做了充分优化,会进行一定程度的编译,生成类似字节码的中间文件,以提高运行速度。其执行流程大致如下:
- 词法分析
Lexical Analysis- 将源代码拆为词元
token
- 将源代码拆为词元
- 语法分析
Parsing- 将
tokens构建为抽象语法树AST
- 将
- 生成字节码
Bytecode- V8 采用 Ignition 作为解释器,把 AST 编译为字节码
- 执行字节码 + JIT 即时编译(Just In Time compiler)
- 按函数和代码块为单位编译,JIT 编译为高度优化的机器码
- 解释执行 + JIT 编译热点代码 + 缓存
补充
CSS 是否阻塞页面加载渲染嘞?
对于外部的 CSS 文件,CSS 不阻塞 DOM 树解析,但是会堵塞 DOM 树渲染。
<!DOCTYPE html>
<html lang="en">
<head>
<title>css阻塞</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
h1 {
color: rgb(0, 255, 0);
}
</style>
<script>
setTimeout(() => {
console.log('head script', document.querySelector('#hh').textContent)
}, 1);
</script>
<link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.6/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<h1 id="hh">hhhh</h1>
<script>
console.log('body script', document.querySelector('#hh').textContent)
</script>
</body>
</html>域名分片
在 HTTP/1.1 时代,浏览器对同一域名的并发 TCP 连接数有限制(通常是 6 个左右)。如果页面有很多资源都在同一个域名下,浏览器只能同时下载 6 个文件,其余的需要排队。
浏览器对同一个域名的并发资源下载数量是有限制的,所以网站通常会把资源分布到多个子域名,以突破这个限制、提高加载速度,可以看看淘宝首页。
域名收敛
将所有静态资源尽量集中到同一个域名下,减少域名数量,以利用现代协议(如 HTTP/2 或 HTTP/3)的多路复用特性,减少连接开销。
HTTP/2 引入了二进制分帧层和多路复用(Multiplexing),允许在同一个 TCP 连接上并发传输多个请求和响应,不再需要多个连接来实现并行。此时,使用多个域名反而会增加 DNS 查询、TCP 和 TLS 握手开销,得不偿失。