Skip to content

浏览器环境概述

浏览器内置了 JS 引擎,并提供了一系列接口,以供网页中的 JS 脚本可以控制浏览器的各种功能。

嵌入 JS 到网页的方法

  • script 标签 , 直接嵌入代码或通过 src 属性加载外部脚本,
    • 又有内嵌代码,又有 src 属性时,浏览器会加载外部脚本,忽略内部代码。
  • 事件属性,元素的的事件可以直接写 JS 代码
  • URL 协议 javascript: 也是一种协议 <a href="javascript: void(submit())">文字</a>

script 元素

常规的 script 元素

通常,网页加载的流程如下:

  1. 浏览器下载 HTML 并解析
  2. 如果遇到 <script> 元素,就暂停解析,把控制权交给 JS 引擎
  3. 如果 script 标签指向外部脚本,那么下载外部脚本再执行,否则直接执行代码
  4. JS 引擎执行完毕,控制权还给渲染引擎,继续解析 HTML

由于 JS 代码可以修改 DOM,所以需要等待 JS 下载并执行之后才恢复 HTML 解析,如果外部脚本一直未能下载下来,网页就会进入假死状态,长时间无响应。

所以,script 标签通常放在底部,才可以访问现有的 DOM 元素。当然,把逻辑放在DOMContentLoaded事件里也是一种方法。

当 HTML 中有多个 script 时,浏览器会同时下载多个 js 文件,但执行顺序由 HTML 中的顺序一致,只有这样,才保证了脚本间依赖关系不被破坏。

defer 属性

html
<script src="a.js"></script>
<script src="b.js" defer></script>

由于 JS 会阻塞 HTML 解析,即解析 DOM 树的构建,所以出现了 defer 属性,遇到 defer 属性的script,网页加载的行为如下:

  1. 下载并解析 HTML
  2. 遇到 defer 属性的 JS 脚本b,并行下载,同时继续往下解析 HTML
  3. 解析完 HTML,DOM树构建完毕后,执行脚本b
  4. 然后才触发 DOMContentLoaded 事件

经测试,如果不加 defer,那么:

  • 先出现 hello
  • 等待 deferScript.js 加载完毕,打印 deferScript Call !,再打印 small call!
  • 再渲染 what (很快)
  • 再打印 DOMContentLoaded !! 也就是 script 堵塞 HTML 解析了

如果加defer,那么:

  • hello what 基本同时出来,但是标签页还在转圈
  • 脚本加载完毕后,打印deferScript Call ! ,再打印 small call!
  • 再打印 DOMContentLoaded !! 也就是没有堵塞 HTML 解析,且多个 defer 脚本执行顺序按照文档顺序,但是要注意 DOMContentLoaded 是在脚本加载完毕后才触发的。
html
<!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 的应用场景

  1. 使用 CDN 引入第三方库时,就应该使用 defer 保证项目入口脚本文件在第三方库脚本之后。
  2. 用户行为埋点,比如Google Analytics,这种独立的脚本就可以async,比如我博客中的这个埋点。

脚本动态加载

JS 可以操作 DOM,那么也就可以通过动态添加 script 元素来实现脚本的动态加载。

js
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

浏览器的组成

  1. 用户界面:包括地址栏、前进/后退按钮、书签菜单等,即我们与浏览器交互的所有部分。是使用操作系统提供的接口绘制的窗体应用。

  2. 浏览器引擎(Browser Engine):调度中控内核,在不同模块间传递消息和指令。

  3. 渲染引擎(Rendering Engine):负责解析 HTML、CSS 等代码,并将其渲染为视觉上的页面。

  4. JS 引擎(JavaScript Engine):负责解析和执行 JavaScript 代码。

  5. 网络模块:负责处理网络请求,比如发送 HTTP 请求、处理文件下载等。

浏览器的核心是两部分 渲染引擎JS 引擎/解释器

渲染引擎

渲染引擎负责把网页代码呈现为视觉感知的平面文档。

浏览器渲染引擎说明
FirefoxGeckoFirefox 一直使用 Gecko 引擎,它是 Mozilla 基金会开发的开源引擎。
SafariWebKitSafari 使用 WebKit 引擎,这是由苹果公司从 KHTML 引擎分支出来的。
ChromeBlink最初 Chrome 使用 WebKit,但在 2013 年,Google 从 WebKit 分支出了 Blink 引擎,并一直沿用至今。
Microsoft Edge (旧版)EdgeHTML旧版 Edge 使用的是微软自己开发的 EdgeHTML 引擎,它脱胎于 Trident。
Microsoft Edge (新版)Blink在 2018 年,微软宣布放弃 EdgeHTML,转而使用 Chromium(一个开源浏览器项目,其核心就是 Blink 引擎),这就是新版 Edge。
Internet Explorer (IE)TridentIE 浏览器使用的就是 Trident 引擎。

现在,大部分主流浏览器(Chrome、新版 Edge、Opera 等)都使用 Blink 引擎,这使得 Web 开发变得更加统一,但在开发时,我们仍然需要关注 Safari 的 WebKit 和 Firefox 的 Gecko,以确保代码的兼容性。

渲染流程

  1. 从浏览器获取到 HTML 开始
  2. 浏览器开始解析 HTML,请求外部资源,构建 DOM 树CSSOM 树
  3. 基于 DOM 树和 CSSOM 树,构建 Render 树
  4. 布局计算Layout,得到每个节点的严格位置大小
  5. 绘制Paint,生成绘制指令
  6. 分层与合成(z-index,transform)、光栅化与显示

布局/回流/重流 和 重绘

从渲染树到网页布局,称为布局流,所谓的布局/回流/重流都是指代这个过程。从布局到页面显示的过程叫做绘制。他们都具有阻塞效应,回流必然会伴随着重绘,对性能有较大影响。

作为开发者,应尽可能降低回流和重绘的成本,比如

  • 改动叶子上的节点就比改动根节点要好
  • DOM 统一读取,统一写入,许多涉及元素尺寸位置的属性会强制触发回流(offsetTop scrollHeight clientWidth getComputedStyle(), getBoundingClientRect() )
  • 采用文档片段统一写入
  • transform 不影响布局,只在分层和合成时做操作,动画优先考虑transform
  • 涉及回流的操作在 requestAnimationFrame() 中去做,避免立即回流造成堵塞。
  • 虚拟 DOM

JS 引擎

JS 是一种解释型语言,不需要编译,由解释器试试运行,好处是简单方便,坏处是依赖解释器,运行速度慢于编译型语言。

Cpp 这样的语言,从 .cpp 代码,需要通过编译、汇编、链接最后形成二进制可执行文件,由操作系统直接执行。

Java 就是半编译型,需要把.java 编译为.class字节码,再由 JVM 去执行。


目前,现代浏览器(如 V8 引擎)已经对 JS 的运行做了充分优化,会进行一定程度的编译,生成类似字节码的中间文件,以提高运行速度。其执行流程大致如下:

  1. 词法分析 Lexical Analysis
    1. 将源代码拆为词元 token
  2. 语法分析 Parsing
    1. tokens 构建为抽象语法树 AST
  3. 生成字节码 Bytecode
    1. V8 采用 Ignition 作为解释器,把 AST 编译为字节码
  4. 执行字节码 + JIT 即时编译(Just In Time compiler)
    1. 按函数和代码块为单位编译,JIT 编译为高度优化的机器码
    2. 解释执行 + JIT 编译热点代码 + 缓存

补充

CSS 是否阻塞页面加载渲染嘞?

对于外部的 CSS 文件,CSS 不阻塞 DOM 树解析,但是会堵塞 DOM 树渲染

html
<!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 握手开销,得不偿失。