Skip to content

输入 url 到展示页面过程发生了什么?

  1. URL 解析
  2. 检查缓存资源
  3. DNS 解析
  4. TCP 握手
  5. TLS 协商密钥
  6. 发送请求 & 接收响应
  7. TCP 挥手,关闭连接

在第 6 阶段,若服务端响应的为 html,那么浏览器会开始渲染进程:

  • 构建 DOM 树 如果遇到 script 标签的话,会判断是否存在 async 或者 defer,前者会并行下载并立即执行 JS(不保证执行顺序),后者会先并行下载资源,然后延迟到 DOM 解析完成后顺序执行。如果以上都没有,就会阻塞住渲染流程直到 JS 执行完毕。遇到文件下载的会去下载文件。
  • 构建 CSSOM 树
  • DOM, CSSOM 生成 Render 树
  • 布局定位
  • 合成图层
  • 显式

渲染进程中又细分为「GUI 渲染线程」和「JS 线程」。

解析 HTML 生成 DOM 树,解析 CSS 生成样式表以及后面去生成布局树、图层树都是由「GUI 渲染线程」去完成的,这个线程可以一边解析 HTML,一边解析 CSS,这两个是不会冲突的,所以也提倡把 CSS 在头部引入。

但是在「JS 线程」执行时,GUI 渲染线程没有办法去解析 HTML,这是因为 JS 可以操作 DOM,如果两者同时进行可能引起冲突。如果这时 JS 去修改了样式,那此时 CSS 的解析和 JS 的执行也没法同时进行了,会先等 CSS 解析完成,再去执行 JS,最后再去解析 HTML。

参考资料:

重绘与回流

重绘(repaint):当元素样式的改变不影响布局时,浏览器将使用重绘对元素进行更新,此时由于只需要UI层面的重新像素绘制,因此 损耗较少 回流(reflow):当元素的尺寸、结构或触发某些属性时,浏览器会重新渲染页面,称为回流。此时,浏览器需要重新经过计算,计算后还需要重新页面布局,因此是较重的操作。会触发回流的操作:

  • 页面初次渲染
  • 浏览器窗口大小改变
  • 元素尺寸、位置、内容发生改变
  • 元素字体大小变化
  • 添加或者删除可见的 dom 元素
  • 激活 CSS 伪类(例如::hover)
  • 查询某些属性或调用某些方法
  • clientWidth、clientHeight、clientTop、clientLeft
  • offsetWidth、offsetHeight、offsetTop、offsetLeft
  • scrollWidth、scrollHeight、scrollTop、scrollLeft
  • getComputedStyle()
  • getBoundingClientRect()
  • scrollTo()

回流必定触发重绘,重绘不一定触发回流。重绘的开销较小,回流的代价较高。

如何减少重绘&回流:

  1. 集中改变样式(比如通过改变 class 的方式来集中改变样式)
  2. DOM 离线修改
    • 通过 createDocumentFragment 创建一个游离于 DOM 树之外的节点,然后在此节点上批量操作,最后插入 DOM 树中,只触发一次重排
    • 把 DOM 给 display:none (有一次 Reflow),然后修改,再把它显示出来
  3. 提升为合成层(使用 CSS 的 will-change 属性) 将元素提升为合成层有以下优点:
    • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
    • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
    • 对于 transformopacity 效果,不会触发 layout 和 paint

防抖与节流

定义:

节流(throttle):所谓节流,就是指连续触发事件但是在 n 秒中只执行一次函数。节流会稀释函数的执行频率。

防抖(debounce):所谓防抖,就是指触发事件后在 n 秒内函数只能执行一次, 如果在 n 秒内又触发了事件,则会重新计算函数执行时间。

防抖和节流的作用都是防止函数多次调用。区别在于,假设一个用户一直触发这个函数,且每次触发函数的间隔小于 wait 时间,防抖的情况下只会调用一次,而节流的情况会每隔一定时间(参数 wait)调用函数。

js
/**
 * @desc 函数节流
 * 一定时间内多次触发只会执行第一次
 */
Util.throttle = (fn, wait = 300, ctx) => {
    ctx = ctx || this;

    let flag = false;

    return (...args) => {
        if (flag) return;

        flag = true;
        setTimeout(() => {
            flag = false;
            fn.apply(ctx, args);
        }, wait);
    }
};

/**
 * @desc 函数防抖
 * 一定时间内多次触发只会执行最后一次
 */
Util.debounce = (fn, wait = 200, ctx) => {
    ctx = ctx || this;

    let timer = null;

    return (...args) => {
        clearTimeout(timer);

        timer = setTimeout(() => {
            fn.apply(ctx, args);
        }, wait);
    }
};

共同点:都是保存在浏览器端,且同源的。

区别:

特性cookielocalStoragesessionStorageindexDB
数据生命周期一般由服务器生成,可以设置过期时间除非被清理,否则一直存在页面关闭就清理除非被清理,否则一直存在
数据存储大小4K5M5M无限
与服务端通信每次都会携带在 header 中,对于请求性能影响不参与不参与不参与

从上表可以看到,cookie 已经不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStoragesessionStorage 。对于不怎么改变的数据尽量使用 localStorage 存储,否则可以用 sessionStorage 存储。

对于 cookie,我们还需要注意安全性:

属性作用
nameCookie 名称
value如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识
path允许访问此 Cookie 的页面路径
domain允许访问此 Cookie 的域名
expires/max-age指定 Cookie 的生存期
http-only不能通过 JS 访问 Cookie,减少 XSS 攻击
secure只能在协议为 HTTPS 的请求中携带
same-site规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击

如果不设置 expires/max-age 这个 cookie 默认是 Session 的,也就是关闭浏览器该 cookie 就消失了

sameSite 可以设置 3 类值:

  • Strict: 严格模式,完全禁止第三方 Cookie,跨站点时,任何情况下都不会发送 Cookie
  • Lax: 宽松模式,大多数情况不发送第三方 Cookie,但是导航到目标网址的 Get 请求除外
  • None: Chrome 计划将 Lax 变为默认设置。这时,网站可以选择显式关闭 SameSite 属性,将其设为 None。不过,前提是必须同时设置 Secure 属性(Cookie 只能通过 HTTPS 协议发送),否则无效

参考资料:

浏览器内核

  • IE 浏览器:Trident
  • Edge: EdgeHTML(已转向谷歌 Chromium 内核)
  • Chrome: Chromium (Webkit, Blink)
  • Firefox: Gecko
  • Safari: Webkit

跨域

为什么会有同源策略?

浏览器出于安全考虑,有同源策略。也就是说,如果协议、域名或者端口有一个不同就是跨域,Ajax 请求会失败。

什么情况会跨域:

  1. 主域不同
  2. 主域相同,子域不同
  3. 域名相同,协议不同(http / https)
  4. 域名相同,端口不同

跨域解决方案:

  1. JSONP
  2. document.domain + iframe (适用于主域相同而子域不同的场景)
  3. location.hash + iframe
  4. window.name + iframe (window.name 在一个窗口(标签)的生命周期之内是共享的,利用这点结合 iframe:当在 iframe 中加载新页面时,name 的属性值依旧保持不变)
  5. HTML5 postMessage
  6. CORS (Access-Control-Allow-Origin)
  7. Node 中间层代理
js
const loadJsonp = (function() {
    const seq = +new Date();
    const head = document.getElementByTag('head');
    const script = document.createElement('script');

    return (url, params = {}, callback) => {
        const funName = `XJsop-${seq++}`;
        params.callback = funName

        for (let key in params) {
            url = `${url}${/\?/.test(url) ? '&' : '?'}${key}=${encodeURIComponent(params[key])}`;
            // url += (/\?/.test(url) ? '&' : '?') + key + '=' + encodeURIComponent(params[key]);
        }

        window[funName] = function (resp) {
            window[funName] = undefined;

            try {
                delete window[funName];
            } catch (err) {}

            if (head) {
                head.removeChild(script);
            }

            callback(resp);
        }

        script.charset = 'UTF-8';
        script.url = url;
        head.appendChild(script);
    }
}());

浏览器的多进程与 JS 的单线程

GUI渲染线程与JS引擎线程互斥:

因为 JS 引擎可以修改 DOM 树,那么如果 JS 引擎在执行修改了 DOM 结构的同时,GUI 线程也在渲染页面,那么这样就会导致渲染线程获取的 DOM 的元素信息可能与 JS 引擎操作 DOM 后的结果不一致。为了防止这种现象,GUI 线程与 JS 线程需要设计为互斥关系,当 JS 引擎执行的时候,GUI 线程需要被冻结,但是 GUI 的渲染会被保存在一个队列当中,等待 JS 引擎空闲的时候执行渲染。

由此也可以推出,如果 JS 引擎正在进行 CPU 密集型计算,那么 JS 引擎将会阻塞,长时间不空闲,导致渲染进程一直不能执行渲染,页面就会看起来卡顿卡顿的,渲染不连贯,所以,要尽量避免 JS 执行时间过长。

参考资料:

CORS 发送两次请求的原因?

在跨域请求中,如果浏览器判断为非简单请求,就会发送一次 Options 预检请求。浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的 XMLHttpRequest 请求,否则就报错。

简单请求需满足三个条件:

  1. 请求方法为 get/post/head
  2. 无自定义请求头
  3. Content-Type 为以下其中一种:
    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded

参考资料:

mouseover&mouseenter, mouseout&mouseleave 区别

  • 只要鼠标指针移入(或移出)事件所绑定的元素或其子元素,都会触发 mouseover(或mouseout)事件
  • 只有鼠标指针移入(或移出)事件所绑定的元素时,才会触发 mouseenter(或 mouseleave)事件

假设给一个 div 绑定了 mouseover 事件,则其子元素都可以响应该事件,也就是说一旦鼠标由父级进入子级也会触发 mouseover,由子级回到父级也会触发 mouseover

因此,可以这么理解:mouseenter, mouseleave 只作用于目标元素,而 mouseover, mouseout 可作用域目标元素及其后代元素。

如何实现多个标签页之间通信

postMessage

两个需要交互的 tab 页面具有依赖关系。

如 A 页面中通过 JavaScript 的 window.open 打开 B 页面,或者 B 页面通过 iframe 嵌入至 A 页面,此种情形最简单,可以通过 HTML5 的 window.postMessage API 完成通信,由于 postMessage 函数是绑定在 window 全局对象下,因此通信的页面中必须有一个页面(如 A 页面)可以获取另一个页面(如 B 页面)的 window 对象,这样才可以完成单向通信;B 页面无需获取 A 页面的 window 对象,如果需要 B 页面对 A 页面的通信,只需要在 B 页面侦听 message 事件,获取事件中传递的 source 对象,该对象即为 A 页面 window 对象的引用:

js
// B 页面
window.addEventListner('message',(e)=>{
    let {data, source, origin} = e;
    source.postMessage('message echo','/');
});

localStorage

localstorage 是浏览器多个标签共用的存储空间,可以通过监听 storage 事件,实现标签页间的通信。

js
// Tab 1
window.addEventListener('storage', (e) => console.log(e));
localStorage.setItem('a', 'a');

// Tab 2
localStorage.setItem('a', 'b');

触发 storage 事件需满足几个条件:

  1. 标签页是同源的
  2. 只在非当前标签页对 localStorage 进行修改时才会触发
  3. 只在数据新增,或者原有值发生变更时才会触发

ServiceWorker

Service Worker 是一个可以长期运行在后台的 Worker,能够实现与页面的双向通信。多页面间的 Service Worker 可以共享,将 Service Worker 作为消息的处理中心(中央站)即可实现广播效果。

SharedWorker

SharedWorker 可以被多个 window 共同使用,但必须保证这些标签页都是同源的(相同的协议,主机和端口号),具体可参考文档

BroadCast Channel

BroadcastChannel 接口代理了一个命名频道,可以让指定 origin 下的任意 browsing context 来订阅它。它允许同源的不同浏览器窗口,Tab 页,frame 或者 iframe 下的不同文档之间相互通信。通过触发一个 message 事件,消息可以广播到所有监听了该频道的 BroadcastChannel 对象。

创建标识为 test 的频道:

js
const bc = new BroadcastChannel('test');

各个页面可以通过 onmessage 来监听被广播的消息:

js
bc.onmessage = function (e) {
    const data = e.data;
    const text = '[receive] ' + data.msg + ' —— tab ' + data.from;
    console.log('[BroadcastChannel] receive message:', text);
};

要发送消息时只需要调用实例上的 postMessage 方法即可:

js
bc.postMessage(data);

WebSocket

WebSocket 作为全双工通信,自然可以实现多个标签页之间的通信;WebSocket 是 HTML5 新增的协议,它的目的是在浏览器和服务器之间建立一个不受限的双向通信的通道。