七:JavaScript 简史与面向对象之道

一、一门在 10 天内创造出来的语言

1995 年,网景公司的 Brendan Eich 接到一个任务:给浏览器开发一门脚本语言,让网页能“动起来”。上层给他的时间只有 10 天。

10 天能做出来的东西,注定不可能完美。但 Eich 在这 10 天里做的设计决定,影响了此后近三十年的 Web 开发格局。他选择了三个设计方向:

  • 语法像 Java:当时 Java 正如日中天,网景希望借 Java 的热度推广这门新语言。
  • 函数式编程的特性:函数是第一等公民(first-class function),可以赋值给变量、作为参数传递、作为返回值返回。
  • 基于原型的面向对象:不需要“类”的概念,对象可以直接继承自另一个对象。

语言最初叫 LiveScript,后来因为营销策略改名为 JavaScript。但JavaScript 和 Java 除了名字相似和表面语法相近,本质上完全是两门语言。这个命名的后遗症延续至今——无数人因为这个名字以为“JavaScript 是 Java 的简化版”,这是对 JavaScript 最大的误解之一。

二、原型继承:JavaScript 的面向对象之道

大多数主流语言(Java、C++、C#)使用类继承:先定义类(Class),然后从类实例化出对象(Instance)。类是模板,对象是产品。继承是类与类之间的关系。

JavaScript 走了完全不同的一条路:原型继承。在 JavaScript 中,没有类的概念(ES6 的 class 只是语法糖,后面会详讲),只有对象。对象可以直接继承自另一个对象。被继承的对象称为原型

原型链:查找属性的路径

当你访问一个对象的属性时,JavaScript 引擎会:

  1. 先在对象本身上查找这个属性。
  2. 如果找不到,就去对象的原型__proto__ 指向的对象)上找。
  3. 如果还找不到,就去原型的原型上找。
  4. 一直沿着这条“原型链”往上找,直到找到属性或到达链的顶端(null)。

这条查找路径,就是原型链

用代码理解原型链

// 创建一个普通对象作为原型
let animal = {
  type: '动物',
  breathe: function() {
    console.log('呼吸中...');
  }
};

// 以 animal 为原型创建一个新对象
let dog = Object.create(animal);
dog.name = '旺财';
dog.bark = function() {
  console.log('汪汪!');
};

// 属性查找
console.log(dog.name);    // '旺财' —— 在 dog 自身找到
console.log(dog.type);    // '动物' —— dog 没有,沿原型链在 animal 上找到
dog.breathe();            // '呼吸中...' —— 同样沿原型链找到
console.log(dog.toString()); // '[object Object]' —— animal 也没有,继续沿链在 Object.prototype 上找到

关键认知dog 自己没有 typebreathe,但通过原型链,它可以“借用” animal 上的属性和方法。这不是复制——doganimal 之间是引用关系。如果你修改了 animal.typedog.type 的读取结果也会变(除非 dog 自己有同名的属性覆盖了它)。

__proto__ vs prototype:最容易混淆的两个属性

这是 JavaScript 中最让初学者困惑的概念对。用一句话区分:

  • __proto__:每个对象都有这个属性(更准确地说是内部槽 [[Prototype]]),它指向该对象的原型。这是原型链的“链接”。
  • prototype:只有函数才有这个属性(更准确地说,是函数对象才有)。它指向一个对象,当这个函数被用作构造函数(用 new 调用)时,新创建的对象的 __proto__ 会指向这个 prototype 对象。
function Person(name) {
  this.name = name;
}

// Person 是函数,所以有 prototype 属性
console.log(Person.prototype);  // { constructor: Person }

// 用 new 调用 Person,创建实例
let p = new Person('张三');

// p 是普通对象,它的 __proto__ 指向 Person.prototype
console.log(p.__proto__ === Person.prototype);  // true

// Person.prototype 本身也是对象,它的 __proto__ 指向 Object.prototype
console.log(Person.prototype.__proto__ === Object.prototype);  // true

// Object.prototype 的原型是 null,原型链的终点
console.log(Object.prototype.__proto__);  // null

图解原型链

p (实例) 
  → __proto__ → Person.prototype 
    → __proto__ → Object.prototype 
      → __proto__ → null

记忆口诀prototype 是函数给未来实例准备的“模板”,__proto__ 是对象指向它实际原型的“链接”。

三、“一切皆对象”这句话对吗?

你经常听到“JavaScript 中一切皆对象”。这句话既对又不对

对的部分:JavaScript 中几乎所有东西,最终都能追溯到 Object.prototype。数组、函数、正则表达式,它们本质上都是对象:

console.log(typeof []);              // 'object'
console.log(typeof {});              // 'object'
console.log(typeof /regex/);         // 'object'
console.log(typeof function(){});    // 'function' —— 但函数本身也是对象,可以加属性

不对的部分:JavaScript 有七种基本类型(primitive types),它们不是对象:

  • string:字符串
  • number:数字
  • boolean:布尔值
  • null
  • undefined
  • symbol(ES6 新增)
  • bigint(ES2020 新增)

基本类型不是对象,没有属性。但你可能会问:为什么 'hello'.toUpperCase() 能工作?因为 JavaScript 引擎在调用方法时会临时包装:把基本类型 'hello' 包装成 String 对象,调用方法,然后立即销毁包装对象。这就是“基本类型包装对象”机制。

let str = 'hello';
// 当执行 str.toUpperCase() 时,引擎内部做了类似以下操作:
// let temp = new String(str);  // 临时包装
// temp.toUpperCase();          // 调用方法
// temp = null;                 // 销毁包装

更准确的说法:JavaScript 中,除了基本类型以外的所有值,都是对象。

四、构造函数与 new 关键字

在 ES6 引入 class 之前,创建“类”的方式是构造函数:

function Person(name, age) {
  this.name = name;
  this.age = age;
}

// 在 prototype 上定义方法,让所有实例共享
Person.prototype.sayHi = function() {
  console.log('你好,我是' + this.name);
};

let p1 = new Person('张三', 28);
let p2 = new Person('李四', 22);

p1.sayHi();  // '你好,我是张三'
p2.sayHi();  // '你好,我是李四'

console.log(p1.sayHi === p2.sayHi);  // true —— 共享同一个方法,节省内存

new 到底做了什么?

当你写 new Person('张三', 28) 时,引擎执行了以下步骤:

  1. 创建一个空对象 {}
  2. 把这个空对象的 __proto__ 指向 Person.prototype
  3. 以这个空对象为 this,调用 Person 函数(执行 this.name = '张三' 等赋值)。
  4. 如果 Person 函数有 return 且返回了一个对象,就返回那个对象;否则返回这个新创建的对象。

理解 new 的这四步,你就能看穿 class 语法糖的本质——下一节就讲。

五、ES6 class:语法糖还是真正的类?

ES6(2015 年)引入了 class 关键字,让从 Java/C++ 转过来的开发者倍感亲切:

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  sayHi() {
    console.log('你好,我是' + this.name);
  }
}

class Student extends Person {
  constructor(name, age, school) {
    super(name, age);     // 调用父类的 constructor
    this.school = school;
  }

  sayHi() {
    super.sayHi();        // 调用父类的方法
    console.log('我在' + this.school + '上学');
  }
}

let s = new Student('王五', 18, '清华大学');
s.sayHi();
// 你好,我是王五
// 我在清华大学上学

看起来很优雅,类、继承、superextends 一应俱全。但本质上,这仍然是原型继承

console.log(typeof Student);              // 'function' —— class 本质是函数
console.log(s.__proto__ === Student.prototype);  // true
console.log(Student.prototype.__proto__ === Person.prototype);  // true

class 只是对构造函数 + 原型链模式的语法封装。底层机制没变,变的是写法。理解这一点很重要:当你用 class 时,你实际在操作的东西仍然是原型链。

原生原型继承 vs class 语法:

机制 原生方式(ES5) class 方式(ES6+)
定义构造函数 function Person() {} class Person { constructor() {} }
定义方法 Person.prototype.sayHi = function() {} 直接在 class 体内写 sayHi() {}
继承 Student.prototype = Object.create(Person.prototype) class Student extends Person {}
调用父类构造 Person.call(this, name, age) super(name, age)

对于日常开发,使用 class 是更好的选择——代码更清晰、更不易出错。但理解底层原型链,能帮你解释为什么某些“看起来应该对”的代码却出了问题。

六、this 的动态绑定

JavaScript 的 this 和其他语言的行为不同。在 Java 或 C++ 中,this 始终指向当前类的实例,是静态确定的。在 JavaScript 中,this 的值取决于函数如何被调用,而不是函数在哪里定义。

这被称为动态绑定。调用方式决定了 this 的指向:

function showThis() {
  console.log(this);
}

// 1. 作为普通函数调用 → this 指向全局对象(严格模式下为 undefined)
showThis();  // window(浏览器环境)

// 2. 作为对象的方法调用 → this 指向该对象
let obj = { name: 'obj', show: showThis };
obj.show();  // { name: 'obj', show: f }

// 3. 作为构造函数调用(用 new)→ this 指向新创建的对象
new showThis();  // showThis {}

// 4. 用 call / apply / bind 显式指定 this
showThis.call({ name: 'custom' });  // { name: 'custom' }

最常见的陷阱:把方法作为回调传递时,this 会丢失:

let user = {
  name: '张三',
  sayHi: function() {
    console.log('你好,' + this.name);
  }
};

// 正常调用
user.sayHi();  // '你好,张三'

// 作为回调传递——this 丢失
setTimeout(user.sayHi, 1000);  // '你好,undefined' —— this 指向了全局对象

// 解决方案一:用 bind 绑定 this
setTimeout(user.sayHi.bind(user), 1000);  // '你好,张三'

// 解决方案二:用箭头函数(箭头函数不绑定自己的 this)
setTimeout(() => user.sayHi(), 1000);  // '你好,张三'

关于 this 的完整分析,我们会在执行上下文篇中深入展开。现在只需要记住核心原则:this 取决于调用方式,而不是定义位置。

七、JavaScript 的类型体系全景

最后,我们用一张全景图来总结 JavaScript 的类型体系:

JavaScript 中的值
├── 基本类型(Primitive Types)
│   ├── string
│   ├── number
│   ├── boolean
│   ├── null
│   ├── undefined
│   ├── symbol(ES6)
│   └── bigint(ES2020)
│
└── 对象类型(Object Types)
    ├── 普通对象(Object)
    ├── 数组(Array)—— 原型链:[] → Array.prototype → Object.prototype → null
    ├── 函数(Function)—— 原型链:function → Function.prototype → Object.prototype → null
    ├── 正则(RegExp)
    ├── 日期(Date)
    ├── 包装对象(String、Number、Boolean)
    ├── Map、Set、WeakMap、WeakSet(ES6)
    └── Promise、Proxy、Reflect 等(ES6+)

所有对象类型最终都通过原型链连接到 Object.prototype,而 Object.prototype.__proto__null。这就是原型链的终点。

八、本篇小结

这一篇我们从 JavaScript 的历史出发,深入了它的面向对象机制:

  • JavaScript 在 10 天内被创造,采用了基于原型的面向对象设计,与主流类继承语言有本质区别。
  • 原型链是属性查找的路径:对象自身 → __proto__ → 原型的 __proto__ → … → Object.prototypenull
  • __proto__ vs prototype:前者是每个对象都有的原型链接(内部槽 [[Prototype]]),后者是函数才有的属性,用来给通过 new 创建的实例设置原型。
  • “一切皆对象”不准确:七种基本类型不是对象。它们可以临时被包装成对象来调用方法。
  • new 的四步:创建空对象 → 绑定原型 → 执行构造函数 → 返回对象。
  • class 是语法糖:底层仍然是原型继承。用 class 写代码更清晰,但理解原型链才能调试深层问题。
  • this 是动态绑定:取决于函数如何被调用,而不是在哪里定义。四种调用方式(普通、方法、构造函数、显式绑定)决定了 this 的值。

理解了原型链,你就理解了 JavaScript 对象模型的内核。下一篇,我们将深入 JavaScript 的数据类型系统,剖析 typeofinstanceof 的原理,以及基本类型和引用类型在内存中的本质区别。

下一篇预告

下一篇——《JavaScript 数据类型》:typeof 为什么对 null 返回 'object'instanceof 的内部机制是什么?基本类型和引用类型在内存中如何存储?为什么 0.1 + 0.2 !== 0.3?这些问题,下一篇一一拆解。

前端,每周更新。

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容