前言

前两卷我们都介绍的一些比较奇特又比较好玩的示例,这期我们讲的相对来说就属于比较基础的部分了。

1. 块内部的声明提升

// for
for (var i=0; i<10; i++) { 
  // console.log( i );
}
console.log(i);

// if
if(true) {
  var j = 10;
}
console.log(j);

在if 和 for中不存在块作用域,所以当使用 var 声明变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。

在ES6之前,JavaScript只有函数作用域和全局作用域。这意味着在函数内部声明的变量只在该函数内部有效,并且在函数外部是不可访问的。在全局作用域中声明的变量可以在整个程序中访问。

然而,ES6引入了let和const关键字,它们引入了块级作用域。使用let或const声明的变量将在包含该变量的块(例如if语句或for循环)中创建一个作用域。这意味着在这个块之外无法访问这些变量。


2. 函数优先级

函数声明和变量声明都会被提升。

但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。

foo(); // 1
var foo;
function foo() { 
  console.log( 1 );
}
foo = function() { 
  console.log( 2 );
};
// 这个代码片段会被引擎理解为如下形式:
function foo() { 
  console.log( 1 );
}
foo(); // 1
foo = function() { 
  console.log( 2 );
};

注意,var foo 尽管出现在 function foo()… 的声明之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。


3. 闭包处理for循环问题

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

延迟函数的回调会在循环结束时才执行。IIFE 会通过声明并立即执行一个函数来创建作用域。

我们来试一下:

for (var i = 1; i <= 5; i++) {
  (function () {
    setTimeout(function timer() {
      console.log(i);
    }, i * 1000);
  })();
}

这样能行吗?

的确 每个延迟函数都会将 IIFE 在每次迭代中创建的作用域封闭起来。

如果作用域是空的,那么仅仅将它们进行封闭是不够的。仔细看一下,我们的 IIFE 只是一 个什么都没有的空作用域。它需要包含一点实质内容才能为我们所用。

它需要有自己的变量,用来在每个迭代中储存 i 的值

for (var i = 1; i <= 5; i++) {
  (function (j) {
    var j = i;
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })();
}
// 改进
for (var i = 1; i <= 5; i++) {
  (function (j) {
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })(i);
}

4. this指向

谈到 this 指向问题,在我年少轻狂时,由于当初Asp.Net.MVC 写全栈都是 jquery 配合bootstrap ui 完成的,那会的扩展函数都是$.XX = fcuntion 所以当时天真的以为函数的this指向 都是函数本身,直到后面被我的前端大佬同事上了一课。

那么我们返回到this这个问题上:this到底是什么?

this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调 用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包 含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的 其中一个属性,会在函数执行的过程中用到。

比如以下例子:

function baz() { 
  // 当前调用栈是:baz
  // 因此,当前调用位置是全局作用域
  console.log("baz", this);
  bar(); // <-- bar 的调用位置 }
}

function bar() { // 当前调用栈是 baz -> bar
  console.log("bar", this);
  // 因此,当前调用位置在 baz 中
  foo(); // <-- foo 的调用位置 }
}

function foo() {
  // 当前调用栈是 baz -> bar -> foo // 因此,当前调用位置在 bar 中
  console.log("foo", this);
}
baz(); // <-- baz 的调用位置

this全部指向了window(非严格模式)。

因此我们再来看下面这段代码:

function foo(num) {
  console.log("foo: " + num);
  // 记录 foo 被调用的次数
  this.count++;
}
foo.count = 0;
var i;
for (i = 0; i < 10; i++) {
  if (i > 5) {
    foo(i);
  }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log(foo.count);

是不是还没发现问题所在,这段代码在无意中创建了一个全局变量 count,它的值为 NaN,也就是在函数方法体里的this.count++的位置。

而在我们而后的使用中 foo.count 与函数内部的this.count 其实并不是一个东西,由于this 指向了 window,所以 this.count 其实是 window 上的一个属性,与 foo.count 不是一个东西。

如果要按照我们所想的实现方式,那么常见情况下我们一般使用以下两种解决方案

  • 解决方式一:第一个函数被称为具名函数,在它内部可以使用 foo 来引用自身。

function foo(num) {
  console.log("foo: " + num);
  // 记录 foo 被调用的次数
  foo.count++;
}
  • 解决方式二:call改变this指向

for (i=0; i<10; i++) { if (i > 5) {
// 使用 call(..) 可以确保 this 指向函数对象 foo 本身
    foo.call( foo, i );
  }
}

对于使用 new 来调用函数,或者说发生构造函数调用时,this 就会指向了函数本身,在调用时会自动执行下面的操作。

  1. 创建(或者说构造)一个全新的对象。

  2. 这个新对象会被执行[[原型]]连接。

  3. 这个新对象会绑定到函数调用的this。


5. 判断this

现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的
顺序来进行判断:

  1. 函数是否在new中调用(new绑定) ?
    如果是的话this绑定的是新创建的对象
    例如:var bar = new foo()

  2. 函数是否通过call、apply(显式绑定)或者硬绑定调用?
    如果是的话,this绑定的是 指定的对象
    例如:var bar = foo.call(obj2)

  3. 函数是否在某个上下文对象中调用(隐式绑定)?
    如果是的话,this 绑定的是那个上 下文对象
    例如:var bar = obj1.foo()

  4. 如果都不是的话,使用默认绑定
    如果在严格模式下,就绑定到undefined,否则绑定到 全局对象
    例如:var bar = foo()

对于正常的函数调用来说,理解了这些知识你就可以明白 this 的绑定原理了。不过凡事总有例外,也不能全靠这个判断来一概而论。


6. api调用的上下文

第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一 个可选的参数,通常被称为“上下文”(context),其作用和 bind(..) 一样,确保你的回调 函数使用指定的 this。

function foo(el) {
  console.log(el, this.id);
}
var obj = {
  id: "awesome"
};
// 调用 foo(..) 时把 this 绑定到 obj 
[1, 2, 3].forEach( foo, obj );
// 1 awesome 2 awesome 3 awesome

这些函数实际上就是通过 call(..) 或者 apply(..) 实现了显式绑定,这样你可以少写一些代码。


7. 基础三部曲:多态

7.1 函数重载

JavaScript 不支持严格意义上的函数重载。

但可以通过检查参数类型或数量来模拟函数重载

function greet(person) {
    if (typeof person === 'string') {
        console.log(`Hello, ${person}`);
    } else if (Array.isArray(person)) {
        person.forEach(name => console.log(`Hello, ${name}`));
    } else {
        console.log('Hello, Guest');
    }
}

greet("Alice");           // Hello, Alice
greet(["Alice", "Bob"]);   // Hello, Alice; Hello, Bob

7.2 类继承中的多态

JavaScript 的类(class)支持继承,可以通过在子类中重写父类的方法实现多态

class Animal {
    speak() {
        console.log("The animal makes a sound");
    }
}

class Dog extends Animal {
    speak() {
        console.log("The dog barks");
    }
}

class Cat extends Animal {
    speak() {
        console.log("The cat meows");
    }
}

let animals = [new Animal(), new Dog(), new Cat()];
animals.forEach(animal => animal.speak());
// 输出:
// The animal makes a sound
// The dog barks
// The cat meows

通过上述代码可以看出,speak 方法在不同的子类中表现出不同的行为。

7.3 接口的变通方法

虽然 JavaScript 没有正式的接口定义,但可以使用 duck typing(即“鸭子类型”)来实现类似接口的多态

function makeSound(animal) {
    if (typeof animal.speak === "function") {
        animal.speak();
    } else {
        console.log("This animal cannot speak");
    }
}

const bird = {
    speak: () => console.log("The bird chirps")
};

makeSound(new Dog());  // The dog barks
makeSound(bird);       // The bird chirps

7.4 原型链上的多态

JavaScript 的原型继承机制也支持多态性,通过覆盖原型链上的方法可以实现对象间的多态行为。

function Vehicle() {}
Vehicle.prototype.move = function () {
    console.log("The vehicle moves");
};

function Car() {}
Car.prototype = Object.create(Vehicle.prototype);
Car.prototype.move = function () {
    console.log("The car drives");
};

const myCar = new Car();
myCar.move();  // The car drives

8. 基础三部曲:继承

继承是一种实现代码复用和扩展的方式,允许一个类(或对象)从另一个类(或对象)继承属性和方法。

8.1 基于 class 的继承

ES6 引入了 class 语法,让我们可以使用更简洁的语法实现继承。

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

    speak() {
        console.log(`${this.name} makes a sound`);
    }
}

class Dog extends Animal {
    constructor(name, breed) {
        super(name); // 调用父类的构造函数
        this.breed = breed;
    }

    speak() {
        console.log(`${this.name} barks`);
    }
}

const dog = new Dog("Rex", "Labrador");
dog.speak();  // 输出: Rex barks

在上述例子中:

  • Dog 类通过 extends 关键字继承了 Animal 类。

  • super(name) 用于调用父类 Animal 的构造函数。

  • Dog 类重写了 speak 方法。

8.2 基于原型链的继承

在 ES6 之前,JavaScript 通过原型链来实现继承。

function Animal(name) {
    this.name = name;
}

Animal.prototype.speak = function() {
    console.log(`${this.name} makes a sound`);
};

function Dog(name, breed) {
    Animal.call(this, name); // 使用 call 调用父构造函数
    this.breed = breed;
}

Dog.prototype = Object.create(Animal.prototype); // 设置原型链
Dog.prototype.constructor = Dog; // 修复 constructor 指向

Dog.prototype.speak = function() {
    console.log(`${this.name} barks`);
};

const dog = new Dog("Buddy", "Beagle");
dog.speak();  // 输出: Buddy barks

在这个例子中:

  • Animal.call(this, name) 调用 Animal 构造函数,并将 this 绑定到 Dog 实例上。

  • Dog.prototype = Object.create(Animal.prototype)Dog 的原型对象指向 Animal.prototype,从而实现继承。

  • Dog.prototype.constructor = Dog 修复了 constructor 的引用。

8.3 混入(Mixin)实现继承

有时候,我们不需要完整的继承结构,只希望在某个类或对象中添加其他对象的功能,这种方式称为混入

JavaScript 开发者也想出了一个方法来模拟类的复制行为,这个方法就是混入。接下来我们会看到两种类型的混入:显式和隐式。

  • 显式混入

function mixin(sourceObj, targetObj) {
  for (var key in sourceObj) {
    // 只会在不存在的情况下复制 
    if (!(key in targetObj)) {
      targetObj[key] = sourceObj[key];
    }
  }
  return targetObj;
}
var Vehicle = {
  engines: 1,
  ignition: function () {
    console.log("Turning on my engine.");
  },
  drive: function () {
    this.ignition();
    console.log("Steering and moving forward!");
  }
};
var Car = mixin(Vehicle, {
  wheels: 4,
  drive: function () {
    Vehicle.drive.call(this);
    console.log(
      "Rolling on all " + this.wheels + " wheels!"
    );
  }
});

Car.drive();

JavaScript 中的函数无法(用标准、可靠的方法)真正地复制,所以你只能复制对共享 函数对象的引用。如果你修改了共享的函数对象(比如 ignition()),比如添加了一个属性,那 Vehicle 和 Car 都会受到影响。

显式混入实际上无法完全模拟类的复制行为,因为对象(和函数!别忘了函数也 是对象)只能复制引用,无法复制被引用的对象或者函数本身。忽视这一点会导致许多 问题

如果你向目标对象中显式混入超过一个对象,就可以部分模仿多重继承行为,但是仍没有 直接的方式来处理函数和属性的同名问题。有些开发者 / 库提出了“晚绑定”技术和其他的 一些解决方法,但是从根本上来说,使用这些“诡计”通常会(降低性能并且)得不偿失。

  • 隐式混入

var Something = {
  cool: function () {
    this.greeting = "Hello World";
    this.count = this.count ? this.count + 1 : 1;
  }
};
Something.cool();
console.log( Something.greeting);// "Hello World"
Something.count; // 1
var Another = {
  cool: function () {
    // 隐式把 Something 混入 Another
    Something.cool.call(this);
  }
};
Another.cool();
console.log(Another.greeting, Another.count);
 // "Hello World"; 1(count 不是共享状态)

通过在构造函数调用或者方法调用中使用 Something.cool.call(this),实际上我们借用了函数 Something.cool() 并在Another 的上下文中调用了它(通过 this 绑定)。最终的结果是 Something.cool() 中的赋值操作都会应用在 Another 对象上而不是Something 对象上。

因此,我们把 Something 的行为“混入”到了 Another 中。

虽然这类技术利用了 this 的重新绑定功能,但是 Something.cool.call( this ) 仍然无法 变成相对(而且更灵活的)引用,所以使用时千万要小心。通常来说,尽量避免使用这样 的结构,以保证代码的整洁和可维护性。


9. 基础三部曲:封装

在 JavaScript 中,封装是一种将对象的状态(属性)和行为(方法)封装在一起的面向对象编程概念。通过封装,可以隐藏对象的内部实现,只暴露必要的接口,从而保护数据,简化使用和维护,其实不单单是 JavaScript 其他语言如:java,C#也是如此。

9.1 使用构造函数和闭包实现封装

JavaScript 可以通过闭包实现封装,将私有变量和方法隐藏在构造函数的作用域中,使外部无法访问。

function Person(name) {
    // 私有变量
    let _name = name;

    // 公有方法,访问私有变量
    this.getName = function() {
        return _name;
    };

    this.setName = function(newName) {
        _name = newName;
    };
}

const person = new Person("Alice");
console.log(person.getName()); // 输出: Alice
person.setName("Bob");
console.log(person.getName()); // 输出: Bob
console.log(person._name);     // 输出: undefined

在此示例中,_name 是一个私有变量,外部无法直接访问,而只能通过 getNamesetName 方法来访问和修改它。

9.2 使用 ES6 class# 私有字段

ES6 引入了 class 语法,可以让代码更加简洁,且在更高版本的 JavaScript 中(如 ES2020),可以通过 # 符号来定义真正的私有属性和方法。

class Person {
    // 使用 # 定义私有属性
    #name;

    constructor(name) {
        this.#name = name;
    }

    getName() {
        return this.#name;
    }

    setName(newName) {
        this.#name = newName;
    }
}

const person = new Person("Alice");
console.log(person.getName()); // 输出: Alice
person.setName("Bob");
console.log(person.getName()); // 输出: Bob
console.log(person.#name);     // 报错:SyntaxError: Private field '#name' must be declared in an enclosing class

在此示例中,#name 是私有字段,外部无法直接访问,因此直接访问 person.#name 会报错。

9.3 使用 Symbol 实现“伪私有”属性

Symbol 可以创建独一无二的标识符,可以用作“伪私有”属性。虽然它不是真正的私有,但在对象外部不容易被访问到。

const _name = Symbol("name");

class Person {
    constructor(name) {
        this[_name] = name;
    }

    getName() {
        return this[_name];
    }

    setName(newName) {
        this[_name] = newName;
    }
}

const person = new Person("Alice");
console.log(person.getName()); // 输出: Alice
person.setName("Bob");
console.log(person.getName()); // 输出: Bob
console.log(person[_name]);    // 输出: Bob(不建议直接访问)

虽然可以通过 person[_name] 访问 Symbol 属性,但它不容易被意外访问或修改,因此可以在一定程度上实现封装效果。

9.4 使用模块化实现封装

在 JavaScript 中,可以使用模块化(例如 ES6 模块、CommonJS)实现封装。模块中的变量和方法默认是私有的,只有通过 export 导出的内容才是公开的。

// person.js
const _name = Symbol("name");

export class Person {
    constructor(name) {
        this[_name] = name;
    }

    getName() {
        return this[_name];
    }

    setName(newName) {
        this[_name] = newName;
    }
}

// use.js
// main.js
import { Person } from './person.js';

const person = new Person("Alice");
console.log(person.getName()); // 输出: Alice

通过模块化,可以将 _name 和其他内部细节保留在模块内部,只有 Person 类作为公共接口导出,其他模块无法直接访问内部实现。

封装的优点

  • 数据保护:通过私有属性保护数据,防止外部代码对数据的直接修改。

  • 代码简洁:只暴露必要的接口,简化了使用和维护。

  • 内聚性:将相关数据和方法封装在一起,提高代码的模块化和可维护性。

以上内容选摘自 `你不知道的JavaScript` ,内容部分有部分做删改,根据博主自己的理解进行了一些文案上的调整,基本意思不变,如果有内容误导,可通过邮箱通知博主加以改正,谢谢合作