浏览器相关原理(面试题)详细总结二

4,871 阅读10分钟

1. 浏览器渲染过程是怎样的?

按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树样式计算布局阶段分层栅格化显示

  1. 渲染进程将 HTML 内容转换为能够读懂DOM 树结构。
  2. 渲染引擎将 CSS 样式表转化为浏览器可以理解的styleSheets,计算出 DOM 节点的样式。
  3. 创建布局树,并计算元素的布局信息。
  4. 对布局树进行分层,并生成分层树。
  5. 为每个图层生成绘制列表,并将其提交到合成线程。合成线程将图层分图块,并栅格化将图块转换成位图。
  6. 合成线程发送绘制图块命令给浏览器进程。浏览器进程根据指令生成页面,并显示到显示器上。

浏览器从网络或硬盘中获得HTML字节数据后会经过一个流程将字节解析为DOM树,先将HTML的原始字节数据转换为文件指定编码的字符,然后浏览器会根据HTML规范来将字符串转换成各种令牌标签,如html、body等。最终解析成一个树状的对象模型,就是dom树;

获取css,获取style标签内的css、或者内嵌的css,或者当HTML代码遇见标签时,浏览器会发送请求获得该标签中标记的CSS,当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的styleSheets

创建布局树,遍历 DOM 树中的所有可见节点,并把这些节点加到布局中;而不可见的节点会被布局树忽略掉,如 head 标签下面的全部内容,再比如 body.p.span 这个元素,因为它的属性包含 dispaly:none,所以这个元素也没有被包进布局树。最后计算 DOM 元素的布局信息,使其都保存在布局树中。布局完成过程中,如果有js操作或者其他操作,对元素的颜色,背景等作出改变就会引起重绘,如果有对元素的大小、定位等有改变则会引起回流。

因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树。

渲染引擎实现图层的绘制,把一个图层的绘制拆分成很多小的绘制指令然后再把这些指令按照顺序组成一个待绘制列表,当图层的绘制列表准备好之后,主线程会把该绘制列表提交给合成线程,合成线程会将图层划分为图块,然后按照视口附近的图块来优先生成位图(实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图)

一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令,然后将该命令提交给浏览器进程,浏览器最后进行显示。

2.如何理解回流和重绘?

回流:当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来。这个过程就是回流(也叫重排)。

重绘:当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节)。这个过程叫做重绘。 由此我们可以看出,重绘不一定导致回流,回流一定会导致重绘。

常见的会导致回流的元素:
  • 常见的几何属性有 width、height、padding、margin、left、top、border 等等。
  • 最容易被忽略的操作:获取一些需要通过即时计算得到的属性,当你要用到像这样的属性:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight 时,浏览器为了获取这些值,也会进行回流。
  • 当我们调用了 getComputedStyle 方法,或者 IE 里的 currentStyle 时,也会触发回流。原理是一样的,都为求一个“即时性”和“准确性”。
避免方式:
  1. 避免逐条改变样式,使用类名去合并样式
  2. 将 DOM “离线”,使用DocumentFragment
  3. 提升为合成层,如使用will-change
#divId {
  will-change: transform;
}

优点

  • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
  • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
  • 对于 transform 和 opacity 效果,不会触发 layout 和 paint

注意:

部分浏览器缓存了一个 flush 队列,把我们触发的回流与重绘任务都塞进去,待到队列里的任务多起来、或者达到了一定的时间间隔,或者“不得已”的时候,再将这些任务一口气出队。但是当我们访问一些即使属性时,浏览器会为了获得此时此刻的、最准确的属性值,而提前将 flush 队列的任务出队。

3.渲染引擎什么情况下才会为特定的节点创建新的图层?

层叠上下文是HTML元素的三维概念,这些HTML元素在一条假想的相对于面向(电脑屏幕的)视窗或者网页的用户的z轴上延伸,HTML元素依据其自身属性按照优先级顺序占用层叠上下文的空间。

  1. 拥有层叠上下文属性的元素会被提升为单独的一层。

拥有层叠上下文属性:

  • 根元素 (HTML),
  • z-index 值不为 "auto"的 绝对/相对定位元素,
  • position,固定(fixed) / 沾滞(sticky)定位(沾滞定位适配所有移动设备上的浏览器,但老的桌面浏览器不支持)
  • z-index值不为 "auto"的 flex 子项 (flex item),即:父元素 display: flex|inline-flex,
  • z-index值不为"auto"的grid子项,即:父元素display:grid
  • opacity 属性值小于 1 的元素(参考 the specification for opacity),
  • transform 属性值不为 "none"的元素,
  • mix-blend-mode 属性值不为 "normal"的元素,
  • filter值不为"none"的元素,
  • perspective值不为"none"的元素,
  • clip-path值不为"none"的元素
  • mask / mask-image / mask-border不为"none"的元素
  • isolation 属性被设置为 "isolate"的元素
  • 在 will-change 中指定了任意CSS属性(参考 这篇文章
  • -webkit-overflow-scrolling 属性被设置 "touch"的元素
  • contain属性值为"layout","paint",或者综合值比如"strict","content"
  1. 需要剪裁(clip)的地方也会被创建为图层。

这里的剪裁指的是,假如我们把 div 的大小限定为 200 * 200 像素,而 div 里面的文字内容比较多,文字所显示的区域肯定会超出 200 * 200 的面积,这时候就产生了剪裁,渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域。出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。

4.JavaScript 是如何支持块级作用域的?

块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。

词法环境跟函数上下文,都是通过栈结构实现的。函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境(函数上下文)中,而通过let和const申明的变量会被追加到词法环境中,当这个块执行结束之后,追加到词法作用域的内容又会销毁掉。

举个例子:

function foo() {
    var test = 1
    let myname= 'LuckyWinty'
    {
        console.log(myname) 
        let myname= 'winty'
    }
    console.log(test,'---',myname) 
}
foo()
//思考一下会输出什么?

执行到第一个console.log前的执行上下文是这样的:

GitHub

从图中看,第一个console.log理论上应该输出 undefined。但是语法规定了一个"暂时性死区(TDZ,当进入它的作用域,它不能被访问(获取或设置)直到执行到达声明)",也就是说虽然通过let声明的变量已经在词法环境中了,但是在没有赋值之前,访问该变量JavaScript引擎就会抛出一个错误。

因此,第一个console.log会抛错,[Uncaught ReferenceError: Cannot access 'myname' before initialization]。抛错则函数会中断执行,为了能让我们的代码继续分析,我们先加个 try-catch ,然后继续分析:

function foo() {
    var test = 1
    let myname= 'LuckyWinty'
    try{
        {
            console.log(myname) 
            let myname= 'winty'
        }
    }catch(ex){
        console.error(ex)
    }
    console.log(test,'---',myname) 
}
foo()
//思考一下会输出什么?

执行到第二个console.log前的执行上下文是这样的:

GitHub

此时,{}块作用域中的内容已执行完毕,被销毁掉了。第二个console.log会输出1 "---" "LuckyWinty"

5. JavaScript 中的数据是如何存储在内存中的?

在 JavaScript 中,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。

在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间栈空间堆空间。 其中的代码空间主要是存储可执行代码的,原始类型(Number、String、Null、Undefined、Boolean、Symbol、BigInt)的数据值都是直接保存在“栈”中的,引用类型(Object)的值是存放在“堆”中的。因此在栈空间中(执行上下文),原始类型存储的是变量的值,而引用类型存储的是其在"堆空间"中的地址,当 JavaScript 需要访问该数据的时候,是通过栈中的引用地址来访问的,相当于多了一道转手流程。

在编译过程中,如果 JavaScript 引擎判断到一个闭包,也会在堆空间创建换一个“closure(fn)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存闭包中的变量。所以闭包中的变量是存储在“堆空间”中的。

JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。因此需要“栈”和“堆”两种空间。

参考资料

  • 极客时间《浏览器工作原理与实践》

最后

  • 整理不易,麻烦各位给我点个赞啦~~
  • 了解更多内容,欢迎关注我的blog, 给我个star~~
  • 欢迎加我微信(winty230),拉你进技术群,长期交流学习~~
  • 觉得内容有帮助可以关注下我的公众号 「前端Q」,一起学习成长~~
    GitHub