对象创建三种方式、new 的四步原理、包装类的自动装箱拆箱机制
前言
学 JavaScript 的时候,有几个问题一直困扰我:
- 为什么字符串明明是原始类型,却能调用
.length、.split()这些方法? new关键字到底做了什么?为什么函数既能普通调用又能new调用?- 原始类型和引用类型的区别到底是什么?
后来搞懂了对象 和包装类 的底层机制,这些问题才全部串起来。这篇文章把对象创建、new 原理、包装类机制一次性讲清楚。
最初我以为字符串明明是原始类型,却能调用 .length、.split() 是被设计成这样的,增加了易用性,但并不知道为什么?
一、JavaScript 的类型系统
JavaScript 中一切皆对象(这句话虽然不完全准确,但日常开发中大部分操作都围绕对象展开)。
1.1 两大类型
| 类型分类 | 包含类型 | 特点 |
|---|---|---|
| 原始类型(基本类型) | string、number、boolean、null、undefined、symbol、bigint |
存储值本身,按值访问 |
| 引用类型 | object、function、array |
存储地址(引用),按引用访问 |
1.2 核心区别
javascript
// 原始类型不能添加属性
let str = 'hello';
str.name = '我的字符串';
console.log(str.name); // undefined ← 属性加不上去
// 引用类型可以随意添加属性
let obj = { name: '张三' };
obj.age = 18;
console.log(obj.age); // 18 ← 属性正常添加
这是理解包装类的关键前提:原始类型不能添加属性和方法。
二、创建对象的三种方式
2.1 字面量(最常用)
javascript
// 对象字面量
const person = {
name: '张三',
age: 18,
sayHello() {
console.log('你好,我是' + this.name);
}
};
// 数组字面量
const list = [1, 2, 3];
// 函数字面量
const fn = function() {
console.log('hello');
};
优点:简洁直观,日常开发 90% 都用字面量。
2.2 new Object()
javascript
const obj = new Object();
obj.name = '张三';
obj.age = 18;
// 等价于
const obj2 = {
name: '张三',
age: 18
};
特点:和字面量效果一样,但写法更啰嗦,而且字面量的性能还更好,实际开发中很少用。
2.3 new 构造函数(批量创建对象)
当需要创建多个结构相似的对象时,用构造函数:
javascript
function Person(name, age) {
this.name = name;
this.age = age;
this.sayHello = function() {
console.log('你好,我是' + this.name);
};
}
const p1 = new Person('张三', 18);
const p2 = new Person('李四', 22);
p1.sayHello(); // "你好,我是张三"
p2.sayHello(); // "你好,我是李四"
优点:可以批量创建结构相同的对象,是面向对象编程的基础。
三、JS函数的二义性:普通调用 vs new 调用
JavaScript 中的函数很特殊:既可以当普通函数调用,也可以当构造函数用 new 调用。
javascript
function Foo() {
this.name = 'bar';
console.log(this);
}
// 普通调用:this 指向调用者(非严格模式下指向 window)
Foo(); // Window {name: 'bar', ...}
// new 调用:this 指向新创建的对象
const f = new Foo(); // Foo {name: 'bar'}
同一个函数,两种调用方式,this 指向完全不同。
javascript
function User(name) {
this.name = name;
}
// ❌ 普通调用:this 指向 window,污染全局变量
User('张三');
console.log(window.name); // '张三' ← 意外修改了全局变量
// ✅ new 调用:this 指向新对象,不会污染全局
const user = new User('李四');
console.log(user.name); // '李四'
⚠️ 注意 :构造函数命名习惯首字母大写(Person、User),用来和普通函数区分。
传统 Java/TS 是 类→实例 的面向对象; JS 早期只有函数 + 对象 + 原型,用 new 函数() 假装成类。
② new 到底干了 4 件事(必理解)
- 创建一个空新对象
- 把函数里的 this 绑定到这个新对象
- 把新对象的 proto 指向函数的 prototype (原型链)
- 函数默认 return 这个新对象
所以函数普通调用就是直接执行,new 调用 经过一系列事情返回了对象
四、new 的工作原理(重点)
new 关键字在调用函数时,背后做了四件事:
4.1 四步原理
arduino
new Foo('张三')
第一步:创建一个空对象
javascript
// new 内部自动做了这件事
const obj = {};
第二步 :执行函数体,把 this 指向这个空对象
javascript
// 相当于
Foo.call(obj, '张三');
// 函数体内的 this.name = '张三' 就变成了 obj.name = '张三'
第三步:设置对象的原型
javascript
// 让新对象的原型指向构造函数的 prototype
obj.__proto__ = Foo.prototype;
第四步:返回这个对象
javascript
// 如果函数没有手动 return,默认返回 this(即新创建的对象)
return obj;
4.2 用代码模拟 new
javascript
function myNew(Constructor, ...args) {
// 第一步:创建空对象
const obj = {};
// 第二步:执行函数,this 指向空对象
const result = Constructor.apply(obj, args);
// 第三步:设置原型
obj.__proto__ = Constructor.prototype;
// 第四步:返回对象(如果函数手动返回了对象,则返回手动返回的对象)
return result instanceof Object ? result : obj;
}
javascript
// 用模拟的 myNew 测试
function Person(name, age) {
this.name = name;
this.age = age;
}
const p = myNew(Person, '张三', 18);
console.log(p.name); // '张三'
console.log(p.age); // 18
console.log(p instanceof Person); // true
4.3 new 的特殊情况
javascript
// 如果构造函数手动 return 了一个对象,new 返回的是那个对象
function Foo() {
this.name = 'bar';
return { name: 'baz' }; // 手动返回一个对象
}
const f = new Foo();
console.log(f.name); // 'baz' ← 不是 'bar'!
javascript
// 如果 return 的是原始类型,则忽略,仍然返回 this
function Bar() {
this.name = 'bar';
return 123; // 返回原始类型,被忽略
}
const b = new Bar();
console.log(b.name); // 'bar' ← 正常返回 this
五、包装类:原始类型的"替身"
5.1 一个让人困惑的现象
前面说过,原始类型不能添加属性和方法。那为什么这样能行?
javascript
let str = 'hello';
console.log(str.length); // 5 ← 字符串有 length?
console.log(str.toUpperCase()); // 'HELLO' ← 字符串能调方法?
let num = 3.14159;
console.log(num.toFixed(2)); // '3.14' ← 数字也能调方法?
原始类型明明不是对象,为什么能调用属性和方法?
答案:包装类(Wrapper Class)。
5.2 包装类是什么
JavaScript 有三种包装类构造函数:
| 包装类 | 对应原始类型 | 原型上的属性/方法 |
|---|---|---|
String |
string |
.length、.split()、.toUpperCase()、.charAt() ... |
Number |
number |
.toFixed()、.toString()、.valueOf() ... |
Boolean |
boolean |
.toString()、.valueOf() ... |
当你对原始类型的值进行属性/方法操作时,JavaScript 引擎会自动进行装箱操作。
5.3 装箱与拆箱的过程
以 str.length 为例:
javascript
let str = 'hello';
console.log(str.length); // 5
V8 引擎实际执行的过程:
javascript
1. 发现 str 是原始类型,但尝试访问 .length 属性
2. 自动执行装箱:new String('hello')
→ 创建一个临时的 String 对象
3. 在这个临时对象上查找 .length
→ 找到了 String.prototype.length,值为 5
4. 立即执行拆箱操作
→ 销毁这个临时对象
5. 返回 5
用代码模拟就是:
javascript
let str = 'hello';
// V8 实际做了类似这样的事:
const tempObj = new String('hello'); // 装箱:创建临时对象
const result = tempObj.length; // 在临时对象上查找属性
// tempObj 被立即销毁 // 拆箱:销毁临时对象
console.log(result); // 5
5.4 为什么原始类型不能添加属性?
因为装箱创建的临时对象用完即销毁,你添加的属性跟着临时对象一起消失了。
javascript
let str = 'hello';
// 你以为在给字符串添加属性
str.name = '我的字符串';
// 实际发生的是:
// 1. new String('hello') → 创建临时对象
// 2. 临时对象.name = '我的字符串' → 给临时对象添加属性
// 3. 临时对象被销毁 → 属性跟着消失
// 下次访问时,又创建了一个新的临时对象
console.log(str.name); // undefined ← 新临时对象上没有 name 属性
javascript
// 对比:真正的对象可以持久保存属性
let obj = new String('hello');
obj.name = '我的字符串';
console.log(obj.name); // '我的字符串' ← 对象没被销毁,属性还在
5.5 原型链查找过程
V8 在对象上查找属性的规则:
markdown
1. 先在对象自身查找
2. 找不到?去对象的原型(__proto__)上查找
3. 还找不到?继续沿原型链向上找
4. 直到找到属性,或到达原型链顶端(null)
对于 'hello'.length:
javascript
临时 String 对象自身 → 没有 length
↓
String.prototype → 找到了 length!值为 5
↓
返回 5
javascript
// 验证:length 确实在 String.prototype 上
console.log(String.prototype.hasOwnProperty('length')); // false
// 注意:length 不是 String.prototype 自身的属性
// 它是 String 实例的内部属性,通过原型链访问
// 但 toUpperCase 确实在原型上
console.log(String.prototype.hasOwnProperty('toUpperCase')); // true
六、总结
核心概念回顾
| 概念 | 一句话解释 |
|---|---|
| 原始类型 | 存储值本身,不能添加属性和方法 |
| 引用类型 | 存储地址,可以自由添加属性和方法 |
| 函数二义性 | 同一个函数既能普通调用,也能 new 调用 |
| new 四步 | 创建空对象 → 执行函数(this指向空对象) → 设置原型 → 返回对象 |
| 包装类 | V8 自动将原始类型临时包装成对象,以便调用属性和方法 |
| 装箱 | 原始类型 → 临时对象(new String/Number/Boolean) |
| 拆箱 | 临时对象用完即销毁,回到原始类型 |
一张图理解包装类
lua
'hello'.toUpperCase()
原始值 'hello'
↓ V8 发现原始值调用了方法
↓ 自动装箱
临时 String 对象 { [[PrimitiveValue]]: 'hello' }
↓ 在对象上查找 toUpperCase
↓ 没找到,去原型上找
String.prototype.toUpperCase → 找到了!
↓ 执行方法,返回 'HELLO'
↓ 自动拆箱
临时 String 对象被销毁
↓
返回 'HELLO'
一句话记住
原始类型本身没有属性和方法,能调用是因为 V8 在背后偷偷帮你创建了临时对象(包装类),用完立刻销毁。