从画图纸到捏泥巴:从后端到 JavaScript

从画图纸到捏泥巴:从后端到 JavaScript

没有类的对象创建

习惯了用 Python、C# 或 Java 开发应用软件或后端系统时,我们的思维路径通常是高度结构化且以类为核心的。面对一个需求,第一反应往往是定义几个类,这些类应该具备哪些属性和方法,它们之间的继承关系如何,最后实例化并运行。这是一种典型的“蓝图”逻辑,在这种体系下,我们必须先画好图纸(Class),然后才能照着图纸盖房子(Instance)。哪怕只需要一间简陋的小屋,也得先走完画图纸的流程,严谨但略显繁琐。

当我们转向前端 JavaScript 的世界,首先要经历的思维转换就是抛弃这种必须先有蓝图的执念。JS 的逻辑更像是捏泥巴。根本不需要图纸,想要什么直接上手捏即可,捏出来的这个东西就是世间独一无二的实体。在前端编写 JavaScript 时,核心只有对象和函数,以往我们在后端用类来实现的绝大多数需求,在这里全都是靠对象和函数完成的。我们需要建立一个对象时,不再是先编写好一个模板类再去实例化,而是直接使用对象字面量符号 {} 把需要的对象“捏”出来。在这个符号中,对象仅仅是由键值对列表组成的集合,键值之间用冒号分隔,键值对之间用逗号分隔,这种直观的声明方式是 JS 最纯粹的形态。

1
2
3
4
5
6
7
8
9
const yang = {
name: "狂战士 Yang",
age: 27,
sayMotto() {
console.log(`I am ${this.name}!`);
}
};

yang.sayMotto();

我们可能会疑惑,如果失去了类的组织方式,项目结构和生命周期该如何管理?在后端语言里,我们习惯在不同层的文件中定义类,然后通过依赖注入容器来在入口函数中统一实例化。而在 JS 中,模块即单例。一个 .js 文件本身就是一个模块,文件里定义的顶层对象天然就是“模块级私有对象”。当我们把这个对象 export 出去,其他文件通过 import 引用时,得到的就是同一个对象实例。这意味着 JS 的生命周期管理非常扁平且干净,我们几乎不需要像后端那样频繁地去 new Logger(),当引入 logger 模块时,直接就是在操作那个已经存在的单例。

当然,捏泥巴并不意味着我们无法批量生产。假如我们要创造成百上千个具有不同参数的同类对象,在 JS 里我们还是使用函数来解决这个问题:

1
2
3
4
5
6
7
8
function Character(name, age) {
this.name = name;
this.age = age;
this.sayMotto = () => console.log(`I am ${this.name}!`);
}

yang = new Character("狂战士 Yang", 27);
yang.sayMotto();

所以,想要真正接受 JavaScript,首先要接受它那略显随意的对象观,对象就是一堆命名值的动态组合。与我们熟悉的强类型语言不同,JavaScript 的对象不是根据类创建的,它们更像是哈希表或字典的延伸,随时可以被创建、修改和传递。这种灵活性正是前端开发的基石。

没有类的作用域封装

在后端开发中,类通常承担着两个核心职责:一是作为生成对象的模具,二是作为逻辑和数据的封装容器。在上一部分我们讨论了 JavaScript 如何在没有模具的情况下“捏”出对象,现在我们需要解决第二个问题:如果没有类,我们该如何划分边界和封装数据?在 C# 或 Python 中,类天然创建了一个作用域,定义在类里的变量就是类的成员,外部无法随意触碰。但在 JavaScript 中,我们虽然用花括号 {} 来定义对象,但必须明确一个反直觉的事实:对象字面量的花括号并不创建作用域。

对于习惯了块级作用域的后端开发者来说,这是一个极易掉入的陷阱。我们往往会下意识地认为对象字面量 {} 内部是一个独立的小世界,但事实并非如此。最直观的证明是,我们无法在对象定义的内部直接引用它自身的其他属性。比如定义了一个 person 对象,先写了 name: "张三",紧接着想写 nickname: name,这在 JS 里是行不通的。因为在解释器眼里,这个 {} 只是一个正在被构建的数据结构,而非代码块。当执行到 nickname: name 时,解释器还在对象外部的环境中寻找 name 变量,找不到就会报错。同理,如果在对象属性值里使用 this,比如试图用 this.width 去引用同在对象里定义的 width,我们会发现 this 指向的竟然是全局对象(Window)或者 undefined。这是因为对象字面量没有切断作用域,它只是一个存放数据的“袋子”,所有的变量查找都会穿透它,直接去外部环境寻找。

更进一步说,对象字面量中也不允许声明变量。我们不能在里面写 const a = 1,因为它只能包含键值对,不能包含语句。这意味着,单纯依靠对象字面量,我们无法创建所谓的“私有变量”。所有的属性都是公开的,所有的逻辑都是裸露的,这显然无法满足复杂业务逻辑对封装性的要求。

那么,在没有类的 JavaScript 中,我们靠什么来隔离作用域、保护变量不被污染呢?答案依然是函数。在 JS 的设计哲学里,函数不仅仅是逻辑的复用单元,更是作用域的物理边界。只有在函数内部,我们才能声明真正意义上的局部变量,这些变量对于函数外部是不可见的。我们可以利用这一点,配合闭包机制,来实现类似面向对象的封装效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 全局作用域
let globalChaos = "世界和平";

function createHero(heroName, initialWeapon) {
// 函数作用域:这是英雄的“私有空间”
// myWeapon 变量只有在这个函数内部才能被直接访问
let myWeapon = initialWeapon;

// 返回的这个对象,相当于类实例的 Public 接口
return {
// attack 是一个闭包,它拿着通往函数内部的钥匙
attack: function() {
console.log(`${heroName} 拔出了 ${myWeapon} 发起攻击!`);
},

// 这是一个受控的 Setter
changeWeapon: function(newWeapon) {
console.log(`${heroName}${myWeapon} 扔了,换成了 ${newWeapon}`);
myWeapon = newWeapon;
}
};
}

const hero1 = createHero("张无忌", "倚天剑");
const hero2 = createHero("谢逊", "屠龙刀");

hero1.attack();
// 输出: "张无忌 拔出了 倚天剑 发起攻击!"

hero1.changeWeapon("太极剑");
hero1.attack();
// 输出: "张无忌 拔出了 太极剑 发起攻击!"

hero2.attack();
// 输出: "谢逊 拔出了 屠龙刀 发起攻击!" (互不干扰)

console.log(hero1.myWeapon);
// 输出: undefined (成功实现了私有化)

这段代码展示了 JS 独特的封装智慧。当我们调用 createHero 时,JS 引擎为这次执行创建了一个独立的函数执行上下文(Execution Context)。你可以把它想象成一个临时的房间,hero1 的武器和 hero2 的武器分别存放在两个物理隔离的房间里,虽然房间布局一样,但互不干扰。这就解释了为什么修改张无忌的武器不会影响到谢逊。

而这里最精妙的地方在于闭包。理论上,函数执行完毕后,这个临时房间(作用域)应该被销毁。但是,因为 createHero 返回了一个对象,而这个对象里的 attackchangeWeapon 方法引用了房间里的 myWeapon 变量。JS 引擎发现外部还持有这个内部引用,于是即便函数执行结束,这个作用域也被保留了下来。这就像是函数虽然关门了,但给外部留了一把特制的钥匙(返回的方法),只有拿着这把钥匙才能操作屋里的东西。

所以闭包就是内部函数可以访问并记住其外部函数作用域中的变量,即使外部函数已经执行完毕,这些变量也不会被垃圾回收,从而实现数据封装和状态持久化。总结来说,在 JavaScript 中,我们要习惯这种思维方式:我们用函数工厂来模拟类,函数创建了一个局部私有作用域,闭包就是连接公有方法与私有数据的桥梁。

从动态作用域到词法作用域

对于熟悉 Java 或 C# 的开发者来说,this 关键字通常是一个非常清晰且让人安心的概念,它永远指向当前类的实例。然而在 JavaScript 中,this 的设计往往是初学者遇到的最大遗留问题。要彻底理解这个问题,我们需要先区分两个核心概念:词法作用域和动态作用域。JavaScript 的变量查找机制本身是基于词法作用域(也叫静态作用域)的,这意味着一个变量能访问什么,完全取决于代码是写在哪里的,跟代码后续如何被调用毫无关系。但是,唯独 function 关键字定义的函数中的 this,它遵循的是动态作用域的规则。

简单来说,在传统的 JavaScript 函数中,this 不是在定义时确定的,而是在运行时根据调用方式确定的。谁调用这个函数,this 就指向谁。这种设计虽然带来了灵活性,但在将函数作为对象的方法进行传递时,会引发严重的“上下文丢失”问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
name = "cat"; // 假设这是全局变量

const dog = {
name: "dog",
bark: function() {
console.log(this.name);
}
};

const func = dog.bark;

dog.bark();
// 输出 "dog"

func();
// 输出 "cat"(或者在严格模式下报错)

当我们使用 dog.bark() 调用函数时,调用者是 dog,所以 this 指向 dog,输出正常。但是,当我们把 dog.bark 赋值给一个变量 func,然后执行 func() 时,调用者变成了全局环境(或者 undefined),this 随之改变,原来的对象上下文就这样丢失了。这就是典型的动态作用域特征:函数的执行环境依赖于调用栈,而不是定义位置。这在处理回调函数、事件监听或者将方法传递给第三方库时,经常导致意想不到的 Bug。

为了解决这个头疼的问题,ES6 引入了箭头函数。箭头函数彻底改变了 this 的绑定规则,它不再拥有自己的 this,而是“捕获”定义时所在上下文的 this 值。也就是说,箭头函数让 this 回归了词法作用域(静态作用域)的特性——代码写在哪里,this 就锁定在哪里,不再随调用方式改变。我们来看一个结合了构造函数和箭头函数的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
name = "cat";

function Dog() {
// 1. 因为用了 new,这里的 'this' 就是正在创建的新实例
this.name = "dog";

// 2. 箭头函数定义时,捕获了外层的 'this'
// 此时外层的 'this' 正是那个新实例,于是被永久锁死
this.bark = () => {
console.log(this.name);
};
}

// 必须使用 new
const dogInstance = new Dog();
const detachedFunc = dogInstance.bark;

dogInstance.bark(); // 输出 "dog"
detachedFunc(); // 输出 "dog"

在这个例子中,bark 被定义为箭头函数。关键点在于 new 关键字的使用。当我们执行 new Dog() 时,JS 首先创建了一个全新的空对象,然后将 Dog 函数内部的 this 强行指向这个新对象。正因为有了这一步,箭头函数在定义的那一刻,它向外张望,看到的“外层作用域的 this”就是这个新创建的对象,于是它便将 this 永久绑定到了这个实例上。

这里必须强调 new 的重要性。如果我们不使用 new 而直接调用 Dog(),情况就完全不同了。普通函数调用时,函数体内的 this 默认指向全局对象(在严格模式下是 undefined)。此时,箭头函数在定义时捕获到的 this 自然也就是全局对象。在这种情况下,this.name = "dog" 实际上是在修改全局变量,而 this.bark 里的 this 锁定的也是全局。

当然,我们也可以结合上一章提到的“工厂函数”和“闭包”,直接抛弃 this,转而使用更加直观的变量捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
name = "cat";

function createDog() {
// 这里的 name 是函数作用域内的局部变量
const name = "dog";

return {
// 直接返回一个对象
name: name,
// 在这里,我们通过闭包直接捕获了上面的 name 变量
// 我们甚至不需要写 this.name,而是直接写 name
bark: () => console.log(name)
};
}

const dogInstance = createDog();
const detachedFunc = dogInstance.bark;

dogInstance.bark(); // 输出 "dog"
detachedFunc(); // 输出 "dog"

在这个改进的方案中,观察 bark 函数内部的代码:是 console.log(name) 而不是 console.log(this.name)。这有着本质的区别。前者是在查找闭包作用域中的变量 name,而后者是在查找对象的属性引用。因为 name 变量被闭包牢牢地“锁”在了 createDog 的函数作用域中,无论 bark 函数被如何传递、在哪里调用,它永远都能找到当初创建它的那个作用域里的 name。通过这种方式,我们绕开了 this 指向不确定的深坑,还顺便实现了数据的私有化。这再次印证了在 JavaScript 中,利用函数作用域和闭包来构建对象,往往比模仿传统的类与 this 模式更加稳健。

总结:后端到前端的思维范式转变

JavaScript 的世界里没有复杂的“蓝图”与“构建”,只有函数与对象的直接共舞。

  1. 世界始于虚空:一切始于全局对象(Window/Global),而非预设的架构。
  2. 没有上帝的模具:没有真正的“类”(Class)去定义万物,只有原型与实例。
  3. 万物皆是“哈希表”:对象不过是 Key-Value 的集合,极其灵活,随捏随用。
  4. 想要新对象? 直接捏一个字面量 {},所见即所得。
  5. 想要复用逻辑? 编写工厂函数,每次调用都吐出一个“出厂设置”好的新对象。
  6. 想要隔离数据? 利用函数作用域,用函数将逻辑包裹起来,形成天然的保护层。

“对象是本体,函数是造物主。”

这是 JavaScript 最迷人,也是后端开发者最需要转弯的地方:每次执行工厂函数 createXxx(...),JS 引擎都会开辟一个全新的函数作用域。虽然函数执行完毕后理论上应该销毁,但因为返回的对象(本体)依然握有内部变量的引用,这个作用域被引擎“特赦”并打包保留了下来。这就是闭包。它是打通外部对象与内部私有作用域的唯一“秘密通道”。

在没有 Class 的原生 JS 思维中,请记住这组映射关系:

  • 函数 (Function) \(\approx\) 类 (Class)
  • 作用域 (Scope) \(\approx\) 私有空间 (Private Fields)
  • 闭包 (Closure) \(\approx\) 访问私有数据的桥梁 (Getter/Setter)

在 JavaScript 里,扛起“数据隔离”大旗的不是访问修饰符(public/private),而是函数作用域本身。