浏览器

# 浏览器

# 1. 你知道哪些跨页面通信的方式呢?

面试官:前端跨页面通信,你知道哪些方法? - 掘金 (opens new window)

同源页面:

  1. BroadcastChannel允许同源的不同浏览器窗口,Tab页,frame或者 iframe 下的不同文档之间相互通信。
// A
const channel = new BroadcastChannel('channel-name');
channel.postMessage('Hello from A!');


// B
const channel = new BroadcastChannel('channel-name');
channel.addEventListener('message', event => {
  console.log(event.data); // "Hello from A!"
});
  1. localStoragestorage事件
localStorage.setItem('key', 'value');

window.addEventListener('storage', function(e) {
  console.log('localStorage值被修改了:', e.newValue);
});
  1. SharedWorker

通过new SharedWorker(脚本)传入同一脚本可以创建一个多页面共享的worker线程。这个worker脚本作为一个信息中转中心,

当接收到消息时向所有页面发送消息。

  • 广播模式:Broadcast Channe / Service Worker / LocalStorage + StorageEvent
  • 共享存储模式:Shared Worker / IndexedDB / cookie
  • 口口相传模式:window.open + window.opener
  • 基于服务端:Websocket / Comet / SSE 等

非同源页面

  1. 内嵌iframe

使用一个用户不可见的 **iframe **作为“桥”。由于 iframe 与父页面间可以通过指定origin来忽略同源限制,因此可以在每个页面中嵌入一个 iframe (例如:http://sample.com/bridge.html),而这些 iframe 由于使用的是一个 url,因此属于同源页面,其通信方式可以复用上面第一部分提到的各种方式。

# 2. 浏览器加载白屏是什么原因?

  • 请求发出前可能有:

    • 资源不合法,如跨域,https 用 http 等等,csp 等等

    • 请求队列太多或者本资源优先级不够高,资源被 delay

    • dns 出问题,劫持,自己开了代理,dns 服务器挂了等等

  • 请求发送过程中可能有:

    • 没有到达服务器,如 cdn 挂了

    • 到达服务器但是没有到达处理程序,如在队列里堆积,403,401 等等

    • 到达了处理程序,但 500 了(假设 500 也算白屏的话,因为可能有只是 catch 了错误,啥也没干)

    • 成功运行,但是迟迟没有返回,如代码本身耗时久,死循环,rpc 慢等等

  • 请求返回之后可能有:

    • 解析阶段跪了,如要 json 返回了一段 text,json/js 不合规范等等

    • 执行阶段跪了,如 render 挂了(假设没有针对这一情况做处理,那么就白屏了),死循环,也有可能是纯粹代码写错了,比如 data 写成了 date,css 写错比如设置了 display:none 等等

  • 其它:

    • 突然断网了

    • 客户端的锅

  • Roast:

这个题分阶段阐述更为清晰,笼统的讲述容易有所遗漏。 思考时,需要考虑到用户环境、前端部署、后端服务三个方面的内容。

# 3. 如何理解重排和重绘?

  1. 重排和重绘是浏览器关键渲染路径上的两个节点, 浏览器的关键渲染路径就是 DOM 和 CSSOM 生成渲染树,然后根据渲染树通过一个布局(也叫 layout)步骤来确定页面上所有内容的大小和位置,确定布局后,将像素 绘制 (也叫 Paint)到屏幕上。

  2. 其中重排就是当元素的位置发生变动的时候,浏览器重新执行布局这个步骤,来重新确定页面上内容的大小和位置,确定完之后就会进行重新绘制到屏幕上,所以重排一定会导致重绘。

  3. 如果元素位置没有发生变动,仅仅只是样式发生变动,这个时候浏览器重新渲染的时候会跳过布局步骤,直接进入绘制步骤,这就是重绘,所以重绘不一定会导致重排。

这道题一般考察两个点:

  1. 浏览器的关键渲染路径。如果答不到这上面,一般这个题就凉了。
  2. 性能优化,如何减少重绘和回流,当然这个点肯定也是要基于对 关键渲染路径 的理解(这点不是关键点)。

参考文章: 【面试系列一】如何回答如何理解重排和重绘 (opens new window)

# 4. V8执行代码过程

  1. tokenize,分词也叫词法分析,生成token

  2. parse,解析,也叫语法分析,根据语法规则将token转换为AST

  3. 有了AST,V8会创建执行上下文

  4. 生成字节码,解释器根据AST生成字节码

  5. 代码执行,解释器逐条解释执行字节码,如果发现热点代码(HotSpot),编译器会将该代码编译为高效的机器码,之后再执行相同代码就直接执行机器码。

  6. 由于js的动态特性,对象的属性和结构可能发生改变,那么之前优化生成的机器码就无用了,需要执行反优化操作回退到解释器解释执行。

# 5. 垃圾回收机制

堆分区、新生代、老生代、一区一器、先标记,再回收,后整理,全停顿,并发式回收,并行式回收,增量式回收。

  1. 垃圾回收主要针对堆中的内存进行回收。

  2. 堆分为新生代和老生代两个区域,新生代空间小(1-8M),存放生命周期短的对象。老生代空间大,存放生命周期长的对象和大对象。

  3. 副垃圾回收器负责新生代的垃圾回收,主垃圾回收器负责老生代的垃圾回收。

  4. 回收的执行流程都遵循以下过程:

    1. 标记活动对象和非活动对象,活动对象就是还在使用的对象,非活动对象就是可以回收掉的对象。

    2. 回收非活动对象占据的内存。

    3. 整理回收之后产生的内存碎片。内存碎片可能会造成没有足够的连续空间来存放大对象。

  5. 副垃圾回收器采用Scavenge算法,将新生代空间对半分为两个区域,一半对象区域一半空闲区域。新加入的对象放入对象区域,如果对象区域要满了就要执行一次垃圾回收。回收时是将对象区域的对象复制到空闲区域中并有序排列起来解决内存碎片问题,然后对象区域和空闲区域角色互换,完成垃圾回收。

  6. js引擎还采用了对象晋升策略,如果经过两次垃圾回收仍然存活的对象会被移到老生区。

  7. 主垃圾回收器采用标记清除算法,标记完后原地清除,但是这样会导致内存碎片问题,所以还有另一种算法:标记整理算法,就是将存活对象移动到老生代的一端,然后将另一端的非活动对象回收。

  8. 全停顿现象是指由于js单线程的原因,当执行垃圾回收时,js线程暂停执行的现象。因此采用了增量标记算法,将一次完整的回收过程分成多个小的过程。同时还有其他一些方式如采用多个辅助垃圾回收线程。

# 6. 如何写一个会过期的localStorage,说说想法

有两种方案:惰性删除定时删除

惰性删除

  • 惰性删除是指某个键值过期以后不会被立刻删除,而是在下次被使用的时候才会检查是否过期,如果过期就删除。

  • 惰性删除实现了可过期的localStorage缓存,但是也有比较明显的缺点:如果一个key一直未被使用,那么这个key即使过期了也会一直存在。为了弥补这样缺点,可以使用另一种清理过期缓存的策略,即定时删除

定时删除

  • 定时删除是指,每隔一段时间执行一次删除操作,并通过限制删除操作执行的次数和频率,来减少删除操作对CPU的长期占用。另一方面定时删除也有效的减少了因惰性删除带来的对localStorage空间的浪费。
  • 具体实现时可以采取以下策略
    • 首先通过Object.keys(localStorage)来获取所有的key,然后遍历key用正则表达式匹配出可过期的key
    • 每隔一秒执行一次定时删除,操作如下:
      1. 随机测试20个设置了过期时间的key。
      2. 删除所有发现的已过期的key。
      3. 若删除的key超过5个则重复步骤1,直至重复500次。

代码

  • 惰性删除

    var lsc = (function (self) {
    var prefix = 'one_more_lsc_'
    
    // 写入
    self.set = function (key, val, expires) {
      key = prefix + key
      val = JSON.stringify({
        val: val,
        expires: Date.now() + expires * 1000
      })
      localStorage.setItem(key, val)
    }
    
    // 读取
    self.get = function (key) {
      key = prefix + key
      let val = localStorage.getItem(key)
      if (!val) {
        return null
      }
      val = JSON.parse(val)
      if (val.expires < Date.now()) {
        localStorage.removeItem(key)
        return null
      }
      return val.val
    }
    
    return self
    })(lsc || {})
    
    • 定时删除
    var list = []
    // 初始化list
    self.init = function () {
       var keys = Object.keys(localStorage)
       var reg = new RegExp('^' + prefix)
       for (var i = 0; i < keys.length; i++) {
           if (reg.test(keys[i])) {
               list.push(keys[i])
           }
       }
    }
    self.init()
    
    // 检查函数
    self.check = function () {
       if (!list || list.length === 0) {
           return
       }
       var checkCount = 500
       while (checkCount--) {
           // 随机测试20个设置了过期时间的key
           var expiresCount
           for (var i = 0; i < 20; i++) {
               if (list.length === 0) break
               var index = Math.random() * list.length
               var key = list[index]
               var val = localStorage.getItem(key)
               if (!val) {
                   list.splice(index, 1)
                   expiresCount++
                   continue
               }
               val = JSON.parse(val)
               if (val.expires < Date.now()) {
                   localStorage.removeItem(key)
                   list.splice(index, 1)
                   expiresCount++
               }
           }
    
           if (expiresCount < 5 || list.length === 0) {
               break
           }
       }
    }
    
    // 每个一秒执行一次
    window.setInterval(() => {
       self.check()
    }, 1000)
    
    

# 7. localStorage 能跨域吗?

不能。

解决方案

  • 通过postMessage实现跨域通信
  • 通过创建一个公共的iframe并部署在某个域名下,作为共享域
  • 将需要进行localStorage跨域通信的页面嵌入该iframe
  • 接入对应的SDK(Software Development Kit , 软件开发工具包)操作共享域,从而实现localStorage跨域通信

# 8.事件循环

事件循环的过程(简化):

  1. 从 宏任务 队列(例如 “script”)中出队(dequeue)并执行最早的任务。
  2. 执行所有 微任务
    • 当微任务队列非空时:
      • 出队(dequeue)并执行最早的微任务。
  3. 如果有变更,则将变更渲染出来。
  4. 如果宏任务队列为空,则休眠直到出现宏任务。
  5. 转到步骤 1。

# 9. 如何安排一个新的宏任务?有哪些应用?

  • 使用零延迟的 setTimeout(f)

它可被用于将繁重的计算任务拆分成多个部分,以使浏览器能够对用户事件作出反应,并在任务的各部分之间显示任务进度。

此外,也被用于在事件处理程序中,将一个行为(action)安排(schedule)在事件被完全处理(冒泡完成)后。

# 10. 如何安排一个微任务?

两种方式:

  • 使用 queueMicrotask(f)
  • promise 处理程序也会通过微任务队列。

在微任务之间没有 UI 或网络事件的处理:它们一个立即接一个地执行。

所以,我们可以使用 queueMicrotask 来在保持环境状态一致的情况下,异步地执行一个函数。

# 10. 说说严格模式

严格模式是ES5提出的,IE10+支持,旧版本浏览器会忽略该模式。严格模式主要为了解决三方面的问题:

  1. 语法:消除不严谨的语法,减少怪异行为,例如:
  • 变量先声明再使用

  • 不能删除声明好的变量

  • 全局作用域下的this指向undefined,不过定时器this仍指向window

  • 不允许函数重名,不允许在非函数代码块中声明函数

  1. 安全:消除允行时的不安全之处

  2. 效率:提升编译器的效率和速度

严格模式可以在脚本第一行添加"use strict"全局开启,也可以在某个函数体第一行添加局部开启。

# 11. 浏览器内核由哪几个线程组成?

浏览器内核由主线程, GUI线程, 事件线程, 定时器线程, 异步HTTP请求线程, Web Workers线程组成。

  1. 主线程(UI线程):负责JS执行,样式计算,布局计算,绘制等。
  2. GUI线程: 负责渲染浏览器界面,处理用户的输入和交互操作,并将这些操作发送给主线程处理。
  3. 事件线程:负责处理事件,例如鼠标点击、键盘输入、网络请求等操作。
  4. 定时器线程:负责管理定时器,当定时器到达指定的时间时,会将指定的代码块添加到消息队列中,等待主线程的处理。
  5. 异步HTTP请求线程:负责发送HTTP请求,防止网络请求阻塞用户界面
  6. Web Workers线程:负责执行一些脚本,不与主线程共享内存空间。

这些线程之间通过消息队列来传递消息和任务,例如定时器线程在定时器到达指定时间后会将任务添加到消息队列中,主线程会在空闲时从消息队列中获取任务并执行,GUI 线程会将用户的输入和交互操作发送到主线程处理。这样,各个线程之间的任务可以分配得更加合理,不会互相影响,从而提高了浏览器的性能和稳定性。

举例来说,当用户在浏览器中输入一个网址时,GUI 线程会将输入的信息发送给主线程,主线程会通过异步 HTTP 请求线程发送请求,获取服务器返回的 HTML、CSS、JavaScript 文件,并通过解析器线程解析 HTML 文件,构建 DOM 树和 CSSOM 树。当主线程执行 JavaScript 代码时,如果该代码执行时间过长,就会导致页面卡顿,此时主线程会将该任务挂起,处理其他任务,等到任务队列中没有任务时,再回到该任务继续执行。最终,GUI 线程会将解析好的页面渲染到屏幕上,完成页面加载。