重学前端(一)

142 阅读13分钟

前言

  • 程序 = 算法 + 数据结构
  • 运行时:类型就是数据结构,执行过程就是算法

把运行时分为数据结构和算法部分:

  • 数据结构包含类型和实例(JavaScript 的类型系统就是它的 7 种基本类型和 7 种语言类型,实例就是它的内置对象部分)。
    • 基本类型:undefined、null、object、boolean、string、number、symbol
    • 语言类型:List和Record、Set、Completion Record、Reference、Property Descriptor、Lexical Environment和Environment Record、Data Block。
  • 所谓的算法,就是 JavaScript 的执行过程。

JavaScript

类型

基本的数据类型: undefined、null、object、boolean、string、number、symbol

为什么编程规范要求用void0去替代undefined呢?

undefined

  • undefined:undefined 表示的是“类型未被定义”,它的类型就是一个值,任何一个变量在没有被赋值之前类型都是 Undefined,值也是对应的 undefined,对于JavaScript来说,undefined是一个变量而不是一个关键字,所以我们为了避免被篡改,我们需要使用void 0 来替代 undefined。
function foo(){
  var undefined = 1;
  console.log(undefined); // 1,undefined 可用作变量名
}

null

null 表示的“定义了,但是值为空”,但是这个字段与 undefined 还是有一定的区别的,null是JavaScript的关键字,所以在任何的代码中,都不会被赋值,所以可以放心的使用

String

String 有最大长度是 2^53 - 1,javaScript中的字符串是没有办法变更的,一旦字符串被构造出来,用任何方式改动字符串的内容。

var test ='stringShow';
test[0] = 1;
console.log(test) // stringShow

Number

Number 通常意义上就是我们说的数字,这个数字大致对应我们说的有理数,我们需要重要介绍的是接下来几种特别的场景

  • NaN
  • Infinity,无穷大;
  • Infinity,负无穷大。

NaN:NaN属于数值类型(Number),NaN是数值类型,但不是一个正常的数字,是一个非数字,其中本质上 NaN 应该是一个集合,11个指数位为1,52个数据位不全为0的一个集合。

console.log(NaN==NaN); // false
console.log(NaN===NaN); // false

image.png

根据浮点数的定义,非整数有效的整数范围是 -0x1fffffffffffff 0x1fffffffffffff-2^53~2^53,转换成十进制也就是- 90071992547409919007199254740991,大于这个数值的在进行计算或转换的时候会造成数据丢失的问题。

这个问题也同样适用于浮点数,非整数的Number也没有办法使用 ==,

console.log(0.1 + 0.2 == 0.3); // false
console.log(0.1 + 0.2 === 0.3); // false

主要是因为计算机的所有存储都是通过二进制来进行存储的,当计算机计算 0.1 + 0.2 的时候,实际计算的是这两个数字在计算机中存储的二进制,我们先进行转换 0.1 转换成0.0001100110011001100...(1100循环),再将0.2转换成 0.00110011001100...(1100循环)

它的实现遵循IEEE 754标准,使用64位固定长度来表示,双精度浮点的小数部分最多只能保留52位,剩余的舍去,遵从“0舍1入”,那么0.1+ 0.2的二进制舍去之后就是下面的计算过程:

0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010 =
0.0100110011001100110011001100110011001100110011001100111

将其转换成十进制,结果是:0.30000000000000004

那对于浮点数我们应该怎么比较呢?可以使用JavaScript提供的最小精度值:

  console.log( Math.abs(0.1 + 0.2 - 0.3) <= Number.EPSILON); // true

Symbol

Symbol 可以具有字符串类型的描述,但是即使描述相同,Symbol 也不相等。也就是说Symbol保证是唯一的,描述是一个标签,它不影响任何东西,举个例子:

let id1 = Symbol("id");
let id2 = Symbol("id");
console.log(id1 == id2); // false

上面的例子我们就创建了一个Symbol变量

Object

ObjectJavaScript 中最复杂的类型,也是 JavaScript 的核心机制之一。在JavaScript中的定义是属性的集合,基本的结构为key-value结构,其中key可以是字符串或是Symbol类型。

JavaScript 中的几个基本类型,在对象中都有对应的逻辑:

  • Number
  • String
  • Boolean
  • Symbol

我们需要了解 3 和Number(3)是完全不同的值,一个是Number类型,一个是对象类型。 NumberStringBoolean,三个构造器是两用的,当跟 new 搭配时,它们产生对象,当直接调用时,它们表示强制类型转换。

JavaScript 是弱类型的语言,它试图模糊对象和基本类型之间的关系,我们日常代码可以把对象的方法用在基本类型上面:

console.log("abc".charAt(0)); //a

当我们在原型上添加方法,都可以应用于基本使用类型,比如下面的代码:

String.prototype.hello = () => console.log("hello");
var a = String("a");
console.log(typeof a); //string,a并非对象
a.hello(); //hello,有效

那么为什么给对象添加的方法能用在基本类型上呢?

因为运算符提供了装箱操作,它会根据基础类型构造一个临时对象,使得我们能在基础类型上调用对应对象的方法。

类型转换

因为JS是弱类型语言,所以转换类型是十分频繁的,大部分的转换是符合人类直觉的,但是我们不理解类型转换的严格定义,就会有一些误判

image.png

StringToNumber

字符串和数字类型的转化,类型转换支持十进制、二进制、八进制和十六进制:

Number("30") // 30
Number("0b111") // 7
Number("0o13") // 11
Number("0xFF") // 255

JavaScript 支持的字符串语法还包括正负号科学计数法,可以使用大写或者小写的 e 来表示:

Number("1e3") // 1000
Number("-1e-2") // 0.01

parseIntparseFloat 并不使用这个转换,parseInt在不穿入第二个参数的情况下支持十六进制,而且会忽略非数字的字符,也不支持科学计数法。所以在任何情况下使用该方法都建议传入第二个参数。而 parseFloat 则直接把原字符串作为十进制来解析,它不会引入任何的其他进制。

装箱转换

Number、String、Boolean、Symbol 在对象中都有对应的类,装箱的意思就是将基本类型转换成对应的对象,这个是类型转换中一种相当重要的种类。

装箱机制会频繁产生临时对象,在一些对性能要求较高的场景下,我们应该尽量避免对基本类型做装箱转换。

使用内置函数,我们可以在JavaScript中进行显示的装箱操作

ps:instanceof 运算符用于检测构造函数的 prototype 是否出现在某个对象的原型链上。

var symbolObject = Object(Symbol("a"));

console.log(typeof symbolObject); //object
console.log(symbolObject instanceof Symbol); //true
console.log(symbolObject.constructor == Symbol); //true

拆箱转换

对象到 String 和 Number 的转换都遵循“先拆箱再转换”的规则。通过拆箱转换,把对象变成基本类型,再从基本类型转换为对应的 String 或者 Number。

拆箱转换会尝试调用 valueOf 和 toString 来获得拆箱后的基本类型。如果 valueOf 和 toString 都不存在,或者没有返回基本类型,则会产生类型错误 TypeError。

JavaScript 是基于对象还是面向对象

先了解一下 JavaScript 标准对基于对象的定义是什么:“语言和宿主的基础设施由对象来提供,并且 JavaScript程序就是一系列互相通讯的对象的集合”

什么是面向对象呢

对象,在英文中和中文都对其有一定的定义,对于计算机来说,我们最好将其理解成是专业名词。它表述的是顺着人类思维的一种抽象。大概的认知像是我们小时候认知过程,流程如下。

  • 认识一个苹果能吃(对象)
  • 说有的苹果都能吃(类)
  • 意识到三个苹果和三个梨之间的联系,进而产生数字“3”(值)

《面向对象分析与设计》这本书中,Grady Booch 替我们抽象了上面的概念,认为在人类的认知角度对象是下面的事物之一。

  • 一个可以接触或事看到的东西
  • 人的智力可以理解的东西
  • 可以指导思考或行动的东西

JavaScript 没有使用“类”,而是使用了相对冷门的“原型”

JavaScript 对象的特征

根据总结,对象具有以下的特质

  • 具有唯一的标识符:即使两个完全相同的对象,也不是同一个对象
  • 有状态:同一个对象可能处于不同的状态中
  • 有行为:对象的状态可能因为行为二产生改变

唯一标识

一般来说,各个语言的唯一标识都是通过内存地址来体现的,对象具有唯一标识的内存地址。

let obj1={a:1};
let obj2={2:2};
console.log(obj1==obj2); // false

有状态和有行为

对于不同的语言,体现的方式不同,C++称他们为 “成员变量”和“成员函数”,Java 中则称它们为“属性”和“方法”。

JavaScript 讲函数设计成了一种特殊的对象。我们举个将普通属性和函数属性作为属性的例子,其中o为一个对象,d是一个属性,f是一个函数属性。

var o ={
  d:1,
  f(){
    console.log(this.d)
  }
}

简言之,JavaScript的对象的状态和行为其实都被抽象为了属性。

对象具有高度的动态性,这是因为 JavaScript 赋予了使用者在运行时为对象添改状态和行为的能力。

var o = { a: 1 };
o.b = 2;
console.log(o.a, o.b); //1 2

JavaScript 对象的两类属性

  • 数据属性
  • 访问器属性(getter/setter)

数据属性

它比较接近于其它语言的属性概念。数据属性具有四个特征。

  • value:就是属性的值。
  • writable:决定属性能否被赋值。
  • enumerable:决定 for in 能否枚举该属性。
  • configurable:决定该属性能否被删除或者改变特征值。

访问器属性

  • getter:函数或 undefined,在取属性值时被调用。
  • setter:函数或 undefined,在设置属性值时被调用。
  • enumerable:决定 for in 能否枚举该属性。
  • configurable:决定该属性能否被删除或者改变特征值。

访问器属性使得属性在读和写时执行代码,它允许使用者在写和读属性时,得到完全不同的值,它可以视为一种函数的语法糖。

当我们定义属性的时候,会产生数据属性,其中writeable、enumerable、configurable 都是默认值为 true。我们验证的时候可以使用内置函数 getOwnPropertyDescriptor。

    var o = { a: 1 };
    o.b = 2;
    Object.getOwnPropertyDescriptor(o,"a") // {value: 1, writable: true, enumerable: true, configurable: true}
    Object.getOwnPropertyDescriptor(o,"b") // {value: 2, writable: true, enumerable: true, configurable: true}

当我们想要改变属性的特征,或事定义访问器属性的时候,可以使用 Object.defineProperty

    var o = { a: 1 };
    Object.defineProperty(o, "b", {value: 2, writable: false, enumerable: false, configurable: true});
    Object.getOwnPropertyDescriptor(o,"a") // {value: 1, writable: true, enumerable: true, configurable: true}
    Object.getOwnPropertyDescriptor(o,"b") // {value: 2, writable: false, enumerable: false, configurable: false}

我们使用 defineProperty定义b属性的时候,修改了属性的 writable 和 enumerable。因为我们修改属性的 writable 为false,所以再次赋值是不生效的。

o.b =1111;
console.log(o);//{a: 1, b: 2}

综上,我们就理解了为什么JavaScript 不是面向对象,。这是由于 JavaScript 的对象设计跟目前主流基于类的面向对象差异非常大。

原型

原型系统的“复制操作”有两种实现思路,其中JavaScript 显然选择了前一种方式。

  • 不真正的复制另外一个原型对象,而是使用新对象持有一个原型的引用
  • 真正的复制对象,从而使两个对象再无关联

JavaScript 的原型

抛开 JavaScript 模拟 Java的复杂语法设施(new,Function,Object),原型系统相当的简单

  • 要是所有的对象都有私有字段[[prototype]]
  • 当读一个属性的时候,要是对象本身是没有的,会继续向上查找,继续访问对象的原型,直到原型为空或者找到为止。这一点有点类似于Java的继承的查找机制。

这个模型在 ES 的各个历史版本中并没有很大改变,在ES6 之后提供了一系列的内置函数,方便更加直接的访问和操作原型。

  • Object.create: 根据指定的原型创建新的对象,原型可以是null
  • Object.getPrototypeOf : 获取对象的原型
  • Object.setPrototypeOf : 设置对象的原型

我们使用这三个方法,可以完全抛开类的思维,利用原型来实现抽象和复用,让我们来实现一下“照猫画虎”的例子

var cat = {
  say(){
    console.log("miaomiao~");
  },
  jump(){
    console.log("jump");
  }
}

var tiger = Object.create(cat,{
  say:{
    writable:true,
    configurable:true,
    enumerable:true,
    value: function(){
      console.log("aoaoao~")
    }
  }
})

var anotherCat = Object.create(cat);

anotherCat.say(); // miaomiao~

var anotherTiger = Object.create(tiger);

anotherTiger.say(); // aoaoao~

cat.aaa = "1111"

console.log(anotherCat.aaa) // 1111

anotherCat.test = 4444

console.log(cat.test) // undefined

在上面的这段代码中,我们创建了一个猫的对象,有根据猫对象做了一些修改生成了一个虎的对象,之后我们就可以使用 Object.create来创建另外的猫和虎的对象,然后我们通过原始的猫对象和原始的虎对象来控制所有的猫对象和虎对象.

早期版本中的类和原型

早期版本中,类的定义是一个私有属性[[class]],语言标准是内置对象诸如Number、String,Date等制定了[[class]]属性。表示他们的类,语言使用者唯一可以访问[[class]]属性的方式是使用, Object.prototype.toString

    var o = new Object;
    var n = new Number;
    var s = new String;
    var b = new Boolean;
    var d = new Date;
    var arg = function(){ return arguments }();
    var r = new RegExp;
    var f = new Function;
    var arr = new Array;
    var e = new Error;
    console.log([o, n, s, b, d, arg, r, f, arr, e].map(v => Object.prototype.toString.call(v)));
    //['[object Object]', '[object Number]', '[object String]', '[object Boolean]', '[object Date]', '[object Arguments]', '[object RegExp]', '[object Function]', '[object Array]', '[object Error]']

在ES3和之前的版本,Js中的类的概念是很弱的,仅仅是运行时的一个字符串属性,但是在ES5开始[[class]]私有属性被Symbol.toStringTag 代替了,Object.prototype.toString的意义从命名上不再和class 相关。我们甚至可以自定义 Object.prototype.toString行为,让我们看一我们是如何使用 Symbol.toStringTag来自定义Object.prototype.toString

var o = {[Symbol.toStringTag]:'MyObject'}
console.log(o + "") // [object MyObject]

我们在上面的代码中创建了一个新的对象,并且给它一个唯一的属性 Symbol.toStringTag,然后我们用字符串加法触发了Object.prototype.toString的调用,发现这个属性最终对Object.prototype.toString的结果产生了影响。

让我们理解一下我们使用 new 操作具体做了什么事情,new 运算接受一个构造器和一组调用参数,实际上做了几件事:

  • 以构造器的 prototype 属性为原型,创建一个对象
  • 将this 和调用的参数传给构造器执行
  • 要是构造器返回的是对象,就直接返回,否者返回第一步创建的对象。

new 这样的行为,试图让函数对象在语法上变得相似,但是,在客观上提供了两种方式,一种是在构造器中添加属性,而是构造器的 prototype 属性上添加属性。

下面我们演示一下用构造器模拟类的方法:

function c1(){
  this.p1 = 1;
  this.p2 = function(){
    console.log(this.p1)
  }
}

var o1= new c1;
o1.p2(); // 1

function c2(){
}

c2.prototype.p1 = 1;
c2.prototype.p2 = function(){
  console.log(this.p1);
  }
  var o2 = new c2;
  o2.p2() // 1

第一种方法是直接在构造器中修改this,给this添加属性。

第二种方法是修改构造器的 prototype 属性指向对象,它从这个构造器造出来所有对象的原型。

在没有 Object.create,Object.setPrototypeOf的早期版本中,new 运算符是唯一一个可以指定 [[prototype]]的方法,所以,当时已经有人试图用它来代替后来的 Object.create,我们甚至可以用它来实现一个 Object.create 的不完整的 polyfill。


Object.create = function(prototype){
    var cls = function(){}
    cls.prototype = prototype;
    return new cls;
}

方法不叫简陋,而且没有和当前的 Object.create保持一致,现在使用意义已经不大了。

ES6 中的类

在ES6中添加的class,new 和function搭配的怪异用法可以退出历史舞台了。让Function 回归函数的本来的语义。

ES6 中引入了 class 关键字,并且在标准中删除了所有[[class]]相关的私有属性描述,类的概念正式从属性升级成语言的基础设施。

class Rectangle{
  constructor(height,width){
    this.height = height;
    this.width = width;
  }

  get area(){
    return this.calcArea();
  }

  calcArea(){
    return this.height * this.width;
  }
}

我们通过 get/set 关键字来创建 getter,通过括号和大括号来创建方法,数据型成员最好写在构造器里面,javaScript写法实际上也是用原型来承载的,JavaScript 认为每个类是有共同原型的一组对象,类中定义的方法和属性则会被写在原型对象之上,类中定义的方法和属性则会被写在原型对象之上。而且类也提供了继承的能力。


class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(this.name + ' makes a noise.');
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name); // call the super class constructor and pass in the name parameter
  }

  speak() {
    console.log(this.name + ' barks.');
  }
}

let d = new Dog('Mitzie');
d.speak(); // Mitzie barks.

上面的代码创建了一个 Animal的类,然后通过 extends关键字让Dog基层了它,最后的结果是调用的子类的speak方法获取了父类的name。使用 extends 关键字自动设置了 constructor,并且会自动调用父类的构造函数。