zoukankan      html  css  js  c++  java
  • 浅析浏览器是如何工作的(二):一等公民、闭包惰性解析与预解析器、V8存储对象的快属性与慢属性、栈空间与堆空间、继承(隐藏属性__proto__)、构造函数怎样创建对象

      最近看到一篇文章,详细讲述了浏览器是如何工作的,感觉非常好,所以决定一点点摘录及研究下。

      接上篇:浅析浏览器是如何工作的(一):V8引擎、JIT机制、JS代码解释执行与编译执行

    一、一等公民与闭包

    1、一等公民的定义

    • 在编程语言中,一等公民可以作为函数参数,可以作为函数返回值,也可以赋值给变量。
    • 如果某个编程语言的函数,可以和这个语言的数据类型做一样的事情,我们就把这个语言中的函数称为一等公民。例如,字符串在几乎所有编程语言中都是一等公民,字符串可以做为函数参数,字符串可以作为函数返回值,字符串也可以赋值给变量。对于各种编程语言来说,函数就不一定是一等公民了,比如 Java 8 之前的版本。
    • 对于 JavaScript 来说,函数可以赋值给变量,也可以作为函数参数,还可以作为函数返回值,因此 JavaScript 中函数是一等公民

    2、动态作用域与静态作用域

    • 如果一门语言的作用域是静态作用域,那么符号之间的引用关系能够根据程序代码在编译时就确定清楚,在运行时不会变。某个函数是在哪声明的,就具有它所在位置的作用域。它能够访问哪些变量,那么就跟这些变量绑定了,在运行时就一直能访问这些变量。即静态作用域可以由程序代码决定,在编译时就能完全确定。大多数语言都是静态作用域的。
    • 动态作用域(Dynamic Scope)。也就是说,变量引用跟变量声明不是在编译时就绑定死了的。在运行时,它是在运行环境中动态地找一个相同名称的变量。在 macOS 或 Linux 中用的 bash 脚本语言,就是动态作用域的。

    3、闭包的三个基础特性

    • JavaScript 语言允许在函数内部定义新的函数
    • 可以在内部函数中访问父函数中定义的变量
    • 因为 JavaScript 中的函数是一等公民,所以函数可以作为另外一个函数的返回值
    // 闭包(静态作用域,一等公民,调用栈的矛盾体)
    function foo() {
      var d = 20;
      return function inner(a, b) {
        const c = a + b + d;
        return c;
      };
    }
    const f = foo();

      下面介绍下闭包给 Chrome V8 带来的问题及其解决策略。

    4、惰性解析

      所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。

    (1)在编译 JavaScript 代码的过程中,V8 并不会一次性将所有的 JavaScript 解析为中间代码,这主要是基于以下两点:

      首先,如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿。因为有时候一个页面的 JavaScript 代码很大,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间;

      其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存

    (2)基于以上的原因,所有主流的 JavaScript 虚拟机都实现了惰性解析。

    (3)闭包给惰性解析带来的问题:上文的 d 不能随着 foo 函数的执行上下文被销毁掉。

    5、预解析器

      V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析。

    (1)判断当前函数是不是存在一些语法上的错误,发现了语法错误,那么就会向 V8 抛出语法错误;

    (2)检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题

    二、V8 内部是如何存储对象的:快属性和慢属性

      下面的代码会输出什么:

    // test.js
    function Foo() {
      this[200] = 'test-200';
      this[1] = 'test-1';
      this[100] = 'test-100';
      this['B'] = 'bar-B';
      this[50] = 'test-50';
      this[9] = 'test-9';
      this[8] = 'test-8';
      this[3] = 'test-3';
      this[5] = 'test-5';
      this['D'] = 'bar-D';
      this['C'] = 'bar-C';
    }
    var bar = new Foo();
    
    for (key in bar) {
      console.log(`index:${key}  value:${bar[key]}`);
    }
    //输出:
    // index:1  value:test-1
    // index:3  value:test-3
    // index:5  value:test-5
    // index:8  value:test-8
    // index:9  value:test-9
    // index:50  value:test-50
    // index:100  value:test-100
    // index:200  value:test-200
    // index:B  value:bar-B
    // index:D  value:bar-D
    // index:C  value:bar-C

      在ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。在这里我们把对象中的数字属性称为排序属性,在 V8 中被称为 elements,字符串属性就被称为常规属性,在 V8 中被称为 properties。在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性。同时 v8 将部分常规属性直接存储到对象本身,我们把这称为对象内属性 (in-object properties),不过对象内属性的数量是固定的,默认是 10 个。

    function Foo(property_num, element_num) {
      //添加可索引属性
      for (let i = 0; i < element_num; i++) {
        this[i] = `element${i}`;
      }
      //添加常规属性
      for (let i = 0; i < property_num; i++) {
        let ppt = `property${i}`;
        this[ppt] = ppt;
      }
    }
    var bar = new Foo(10, 10);

      可以通过 Chrome 开发者工具的 Memory 标签,捕获查看当前的内存快照。通过增大第一个参数来查看存储变化。(Console面板运行以上代码,打开Memory面板,通过点击Take heap snapshot记录内存快照,点击快照,筛选出Foo进行查看。可参考使用 chrome-devtools Memory 面板了解Memory面板。)

      我们将保存在线性数据结构中的属性称之为“快属性”,因为线性数据结构中只需要通过索引即可以访问到属性,虽然访问线性结构的速度快,但是如果从线性结构中添加或者删除大量的属性时,则执行效率会非常低,这主要因为会产生大量时间和内存开销。因此,如果一个对象的属性过多时,V8 就会采取另外一种存储策略,那就是“慢属性”策略,但慢属性的对象内部会有独立的非线性数据结构 (字典) 作为属性存储容器。所有的属性元信息不再是线性存储的,而是直接保存在属性字典中。

      v8 属性存储:

      总结:

      因为 JavaScript 中的对象是由一组组属性和值组成的,所以最简单的方式是使用一个字典来保存属性和值,但是由于字典是非线性结构,所以如果使用字典,读取效率会大大降低。为了提升查找效率,V8 在对象中添加了两个隐藏属性,排序属性和常规属性element 属性指向了 elements 对象,在 elements 对象中,会按照顺序存放排序属性。properties 属性则指向了 properties 对象,在 properties 对象中,会按照创建时的顺序保存常规属性

      通过引入这两个属性,加速了 V8 查找属性的速度,为了更加进一步提升查找效率,V8 还实现了内置内属性的策略,当常规属性少于一定数量时,V8 就会将这些常规属性直接写进对象中,这样又节省了一个中间步骤。

      但是如果对象中的属性过多时,或者存在反复添加或者删除属性的操作,那么 V8 就会将线性的存储模式降级为非线性的字典存储模式,这样虽然降低了查找速度,但是却提升了修改对象的属性的速度

    三、堆空间和栈空间

    1、栈空间

      现代语言都是基于函数的,每个函数在执行过程中,都有自己的生命周期和作用域,当函数执行结束时,其作用域也会被销毁,因此,我们会使用栈这种数据结构来管理函数的调用过程,我们也把管理函数调用过程的栈结构称之为调用栈

      栈空间主要是用来管理 JavaScript 函数调用的,栈是内存中连续的一块空间,同时栈结构是“先进后出”的策略。在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用到的对象的地址、函数的执行状态、this 值等都会存在在栈上。当一个函数执行结束,那么该函数的执行上下文便会被销毁掉。

      栈空间的最大的特点是空间连续,所以在栈中每个元素的地址都是固定的,因此栈空间的查找效率非常高,但是通常在内存中,很难分配到一块很大的连续空间,因此,V8 对栈空间的大小做了限制,如果函数调用层过深,那么 V8 就有可能抛出栈溢出的错误。

      栈的优势和缺点:

    (1)栈的结构非常适合函数调用过程。

    (2)在栈上分配资源和销毁资源的速度非常快,这主要归结于栈空间是连续的,分配空间和销毁空间只需要移动下指针就可以了。

    (3)虽然操作速度非常快,但是栈也是有缺点的,其中最大的缺点也是它的优点所造成的,那就是栈是连续的,所以要想在内存中分配一块连续的大空间是非常难的,因此栈空间是有限的

    // 栈溢出
    function factorial(n) {
      if (n === 1) {
        return 1;
      }
      return n * factorial(n - 1);
    }
    console.log(factorial(50000));

    2、堆空间

      堆空间是一种树形的存储结构用来存储对象类型的离散的数据,JavaScript 中除了原生类型的数据,其他的都是对象类型,诸如函数、数组,在浏览器中还有 window 对象、document 对象等,这些都是存在堆空间的。

      宿主在启动 V8 的过程中,会同时创建堆空间和栈空间,再继续往下执行,产生的新数据都会存放在这两个空间中。

    四、继承

      继承就是一个对象可以访问另外一个对象中的属性和方法,在 JavaScript 中,我们通过原型和原型链的方式来实现了继承特性

      JavaScript 的每个对象都包含了一个隐藏属性 __proto__ ,我们就把该隐藏属性 __proto__ 称之为该对象的原型 (prototype),__proto__ 指向了内存中的另外一个对象,我们就把 __proto__ 指向的对象称为该对象的原型对象,那么该对象就可以直接访问其原型对象的方法或者属性。

      JavaScript 中的继承非常简洁,就是每个对象都有一个原型属性,该属性指向了原型对象,查找属性的时候,JavaScript 虚拟机会沿着原型一层一层向上查找,直至找到正确的属性。

    1、隐藏属性__proto__

    var animal = {
      type: 'Default',
      color: 'Default',
      getInfo: function () {
        return `Type is: ${this.type},color is ${this.color}.`;
      },
    };
    var dog = {
      type: 'Dog',
      color: 'Black',
    };

      利用__proto__实现继承:

    dog.__proto__ = animal;
    dog.getInfo();

      通常隐藏属性是不能使用 JavaScript 来直接与之交互的。虽然现代浏览器都开了一个口子,让 JavaScript 可以访问隐藏属性 __proto__,但是在实际项目中,我们不应该直接通过 __proto__ 来访问或者修改该属性,其主要原因有两个:

    • 首先,这是隐藏属性,并不是标准定义的;
    • 其次,使用该属性会造成严重的性能问题。因为 JavaScript 通过隐藏类优化了很多原有的对象结构,所以通过直接修改__proto__会直接破坏现有已经优化的结构,触发 V8 重构该对象的隐藏类!

    五、构造函数是怎么创建对象的?

      在 JavaScript 中,使用 new 加上构造函数的这种组合来创建对象和实现对象的继承。不过使用这种方式隐含的语义过于隐晦。其实是 JavaScript 为了吸引 Java 程序员、在语法层面去蹭 Java 热点,所以就被硬生生地强制加入了非常不协调的关键字 new。

    function DogFactory(type, color) {
      this.type = type;
      this.color = color;
    }
    var dog = new DogFactory('Dog', 'Black');

      其实当 V8 执行上面这段代码时,V8 在背后悄悄地做了以下几件事情:

    var dog = {};
    dog.__proto__ = DogFactory.prototype;
    DogFactory.call(dog, 'Dog', 'Black');

    (1)声明一个空对象

    (2)空对象的原型对象指向为所继承对象的原型

    (3)将this绑定为该声明的对象

    作者:独钓寒江雪

    原文链接:https://segmentfault.com/a/1190000037435824

  • 相关阅读:
    Cookie与Session
    发布网站
    WCF服务寄宿Windows
    JQuery:各种操作表单元素方法小结
    setTimeout()与 setInterval()
    CSS样式
    循环获取<ul>下拉列表的的值。进行对比,正确的家样式
    js定时器 实现提交成功提示
    flask 实现登录 登出 检查登录状态 的两种方法的总结
    flask 状态保持session和上下文session的区别
  • 原文地址:https://www.cnblogs.com/goloving/p/14354396.html
Copyright © 2011-2022 走看看