SHANKS

浏览器基础知识

3,591 字
约 9 min
浏览器

概述

浏览器的一些原理

EventTarget

提示

EventTarget是一个DOM接口,由可以接受事件、并且可以创建侦听器的对象实现。Element,document 和 window 是最常见的 event targets ,但是其他对象也可以作为 event targets,比如 XMLHttpRequest,AudioNode,AudioContext 等等。

许多 event targets (包括 elements, documents 和 windows)支持通过 onevent 特性和属性设置事件处理程序 (event handlers)。

实例化

class MyEventTarget extends EventTarget {
  constructor(mySecret) {
    super();
    this._secret = mySecret;
  }

  get secret() { return this._secret; }
};

let myEventTarget = new MyEventTarget(5);
let value = myEventTarget.secret;  // == 5
// 在当前事件目标上添加名为foo的事件侦听器
myEventTarget.addEventListener("foo", function(e) {
  this._secret = e.detail;
});
// 创建名为foo的事件
let event = new CustomEvent("foo", { detail: 7 });
// 派发名为foo的事件,并且把参数传入进去
myEventTarget.dispatchEvent(event);
let newValue = myEventTarget.secret; // == 7

原型链&&方法

window.EventTarget.prototype的方法:

  • addEventListener
  • removeEventListener
  • dispatchEvent

hashchange&&popstate

hashchange

提示

当URL的片段标识符更改时,将触发hashchange事件 (跟在#符号后面的URL部分,包括#符号),也就是说在history模式下,这个事件是不不会触发的。

popstate

提示

当活动历史记录条目更改时,将触发popstate事件。需要注意的是调用history.pushState()或history.replaceState()不会触发popstate事件。 在我自己测试的几种环境下,基本上popstate的事件是包含hashchange事件所反馈的。不管在history模式还是hash模型下,都会触发popstate事件。

触发情况

(hash模式下):

  1. location.hash += '123'两者都能触发

  2. 调用history.back()或者history.forward()方法时两者都能触发

(history模式下):

  1. 用pushState进入一个/test页面,然后用浏览器本身的返回时触发popstate

  2. 调用history.back()或者history.forward()方法时触发popstate

  3. location.hash += '123'两者都触发

模式

并不是说在react或者vue中的route指定了哪一种模式,当前页面就一定永远是这种模式,判断这种模式要看路由地址后面是否跟了#,如果有则是hash模式,反之是history

browser history

  1. 告诉浏览器这是一个history模式,不需重新加载页面资源
  2. 需要配置服务器返回的index.html处理应用启动最初的 / 这样的请求应该没问题,但当用户来回跳转并在 /accounts/123 刷新时,服务器就会收到来自 /accounts/123 的请求

如果你的服务器是nginx,使用try_files

server {
  ...
  location / {
    try_files $uri /index.html
  }
  // 不管根路径后面的参数是什么,都返回index.html文件
  // 按指定的file顺序查找存在的文件,并使用第一个找到的文件进行请求处理
}

内存泄露

  1. 意外的全局变量 由于 js 对未声明变量的处理方式是在全局对象上创建该变量的引用。如果在浏览器中,全局对象就是 window 对象。变量在窗口关闭或重新刷新页面之前都不会被释放,如果未声明的变量缓存大量的数据,就会导致内存泄露。
  • 未声明变量
function fn() {
  // 此时this指向window
  a = 'global variable'
}
fn()

解决方法:

避免创建全局变量,使用严格模式,在 JavaScript 文件头部或者函数的顶部加上 use strict。

  1. 闭包引起的内存泄漏 原因:闭包可以读取函数内部的变量,然后让这些变量始终保存在内存中。如果在使用结束后没有将局部变量清除,就可能导致内存泄露。
function fn () {
  var a = "I'm a";
  return function () {
    console.log(a);
  };
}

解决:将事件处理函数定义在外部,解除闭包,或者在定义事件处理函数的外部函数中。

  1. 没有清理的DOM元素引用 原因:虽然别的地方删除了,但是对象中还存在dom的引用
// 在对象中引用DOM
var elements = {
  btn: document.getElementById('btn'),
}
function doSomeThing() {
  elements.btn.click()
}

function removeBtn() {
  // 将body中的btn移除, 也就是移除 DOM树中的btn
  document.body.removeChild(document.getElementById('button'))
  // 但是此时全局变量elements还是保留了对btn的引用, btn还是存在于内存中,不能被GC回收
}

还可以用weakMap的key作为引用

let myWeakmap = new WeakMap();

myWeakmap.set(
  document.getElementById('logo'),
  {timesClicked: 0})
;

document.getElementById('logo').addEventListener('click', function() {
  let logoData = myWeakmap.get(document.getElementById('logo'));
  logoData.timesClicked++;
}, false);

上面代码中,document.getElementById(‘logo’)是一个 DOM 节点,每当发生click事件,就更新一下状态。我们将这个状态作为键值放在 WeakMap 里,对应的键名就是这个节点对象。一旦这个 DOM 节点删除,该状态就会自动消失,不存在内存泄漏风险。

  1. 被遗忘的计时器或回调函数
var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // 处理 node 和 someResource
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

这样的代码很常见,如果id为Node的元素从DOM中移除,该定时器仍会存在,同时,因为回调函数中包含对someResource的引用,定时器外面的someResource也不会被释放。

注意:虽然逻辑上没有走到if条件的,但是引擎里面还是会将someResource的引用计数+1

垃圾回收的使用场景优化

  1. 数组array优化 将[]赋值给一个数组对象,是清空数组的捷径(例如: arr = []),但是需要注意的是,这种方式又创建了一个新的空对象,并且将原来的数组对象变成了一小片内存垃圾!实际上,将数组长度赋值为0(arr.length = 0)也能达到清空数组的目的,并且同时能实现数组重用,减少内存垃圾的产生。
const arr = [1, 2, 3, 4];
console.log('浪里行舟');
arr.length = 0  // 可以直接让数字清空,而且数组类型不变。
// arr = []; 虽然让a变量成一个空数组,但是在堆上重新申请了一个空数组对象。
  1. 对象尽量复用 对象尽量复用,尤其是在循环等地方出现创建新对象,能复用就复用。不用的对象,尽可能设置为null,尽快被垃圾回收掉。
var t = {} // 每次循环都会创建一个新对象。
for (var i = 0; i < 10; i++) {
  // var t = {};// 每次循环都会创建一个新对象。
  t.age = 19
  t.name = '123'
  t.index = i
  console.log(t)
}
t = null //对象如果已经不用了,那就立即设置为null;等待垃圾回收。
  1. 在循环中的函数表达式,能复用最好放到循环外面。
// bad
// 在循环中最好也别使用函数表达式。
for (var k = 0; k < 10; k++) {
  var t = function(a) {
    // 创建了10次  函数对象。
    console.log(a)
  }
  t(k)
}
// good
function t(a) {
  console.log(a)
}
for (var k = 0; k < 10; k++) {
  t(k)
}
t = null

垃圾回收机制

找出不再使用的变量,然后释放掉其占用的内存,但是这个过程不是时时的,因为其开销比较大,所以垃圾回收器会按照固定的时间间隔周期性的执行。

回收方法

  • 标记清除 这是javascript常用的垃圾回收方式。当变量进入执行环境时,就标记这个变量为”进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到他们。

垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记。然后,它会去掉环境中的变量以及被环境中的变量引用的标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后。垃圾收集器完成内存清除工作,销毁那些带标记的值,并回收他们所占用的内存空间。

var m = 0,n = 19 // 把 m,n,add() 标记为进入环境。
add(m, n) // 把 a, b, c标记为进入环境。
console.log(n) // a,b,c标记为离开环境,等待垃圾回收。
function add(a, b) {
  a++
  var c = a + b
  return c
}
  • 引用计数 所谓”引用计数”是指语言引擎有一张”引用表”,保存了内存里面的资源(通常是各种值)的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放。
var arr = [1, 2, 3, 4];
arr = [2, 4, 5]
console.log('浪里行舟');

上面代码中,数组[1, 2, 3, 4]是一个值,会占用内存。变量arr是仅有的对这个值的引用,因此引用次数为1。后续arr又被赋了一个值,则数组[1,2,3,4]的引用次数就减1,此时它引用次数变成0,则说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。

但是引用计数有个最大的问题:循环引用

function func() {
    let obj1 = {};
    let obj2 = {};

    obj1.a = obj2; // obj1 引用 obj2
    obj2.a = obj1; // obj2 引用 obj1
}

当函数 func 执行结束后,返回值为 undefined,所以整个函数以及内部的变量都应该被回收,但根据引用计数方法,obj1 和 obj2 的引用次数都不为 0,所以他们不会被回收。

要解决循环引用的问题,最好是在不使用它们的时候手工将它们设为空。上面的例子可以这么做:

obj1 = null;
obj2 = null;

打开一个页面至少需要4个进程

  1. 浏览器进程
    、用户交互、子进程管理,同时提供存储等功能
  2. 网络进程
    ,之前是作为一个模块运行在浏览器进程里面 的,直至最近才独立出来,成为一个单独的进程
  3. GPU进程
    ,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷 是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘 制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程
  4. 渲染进程
    HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页, 排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会 为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下
  5. 插件进程(如果该页面包含插件的话)
    ,因插件易崩溃,所以需要通过插件进程来隔离,以保 证插件进程崩溃不会对浏览器和页面造成影响

说说从输入URL到页面呈现发生了什么?

在浏览器输入了https://www.baidu.com

注意

为什么在浏览器的地址栏里面输入了一个地址后,之前的页面没有立马消失,而是要加载一会儿才会更新页面:触发当前页面的卸载事件和收集需要释放内存,这也占用了一些时间,但大部分时间是构建请求到接受到请求

  1. URL请求过程
  • 进入页面资源请求过程。此时,浏览器进程会通过进程间通信(IPC:Inter-Process Communication)把URL请求发送至网络进程,网络进程接收到URL请求后,会在这里发起真正的请求流程
  • 先检查强缓存,如果命中直接使用,否则进入下一步
  • DNS解析:以获取请求域名的服务器 IP 地址(值得注意的是浏览器提供了DNS数据缓存功能。即如果一个域名已经解析过,就会把解析的结果缓存下来,下次处理直接走缓存,不需要走DNS解析)
  • 如果是https则先建立SSL(TLS)协议
  • IP 地址和服务器建立 TCP (Transmission Control Protocol传输控制协议)连接
  • 服务器接收到请求信息后,会根据请求信息生成响应数据(包括响应行、响应头和响应体等信息),发给网络进程。等网络进程接收了响应行和响应头之后,就开始解析响应头的内容
  • 不同的响应体会对应做些不同的处理流程,比如 Content-Type:text/html,就是开始用渲染进程解析页面。如果是Content-Type: application/octet-stream类型,那么浏览器就会变成了一个下载文件。
  1. 准备渲染进程
  • Chrome 会为每个页面分配一个渲染进程,也就是说,每打开一个新页面就会配套创建一个新的渲染进程。但是,也有一些例外,在某些情况下,浏览器会让多个页面直接运行在同一个渲染进程中
  • 构建DOM树:将HTML转成浏览器能理解和使用的结构
  • 样式计算:计算出 DOM 节点中每个元素的具体样式。分为三个步骤。
    • 把CSS转换成浏览器能够理解的结构,也就是styleSheets
    • 转换样式表中的属性值,使其标准化,比如2em转成32px,颜色blue转成rgb(0, 0, 255)
    • 计算出DOM树中每个节点的具体样式
  • 构建布局树
    • 创建布局树:遍历DOM树中的所有可见节点,并把这些节点加到布局中,而不可见的节点会被布局树忽略掉,比如display
    • 布局计算
  1. 分层:根据布局树最终生成图层树
  • 因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing、做 z 轴排序等,为了更加方便地实现这些效果渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)
  1. 图层绘制:渲染引擎实现图层的绘制与之类似,会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表

  2. 光栅化

    。实际上浏览器不是直接对整个图层进行光栅化,它会将图层分块,然后以块为单位进行光栅化。

  3. 合成和显示

  • 一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。
  • 浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

CSS样式表不会影响DOM解析

浏览器渲染时会先生成DOM树,然后根据CSS的styleSheets进行样式计算生成布局树和分层树

浏览器中的setTimeout是怎么实现的

浏览器会有两个队列:

  1. 消息队列:用来存储浏览器的垃圾回收、计算任务、用户触发的事件回调等等
  2. 延迟队列:用来存储setTimeout、setInterval的回调和时间参数
void ProcessTimerTask(){
 // 从 delayed_incoming_queue 中取出已经到期的定时器任务
// 依次执行这些任务
}
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
  for(;;){
 // 执行消息队列中的任务
  Task task = task_queue.takeTask();
  ProcessTask(task);

 // 执行延迟队列中的任务
  ProcessDelayTask()

  if(!keep_running) // 如果设置了退出标志,那么直接退出线程循环
    break;
    }
 }

处理完消息队列中的一个任务之后,就开始执行 ProcessDelayTask 函数。ProcessDelayTask 函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,再继续下一个循环过程。通过这样的方式,一个完整的定时器就实现了。

Loading comments...