趾柑鹊熬Maui 实践:让 JavaScript 的 this 怪物如同邻居家(强类型)的乖孩子
原创 夏群林 2025.10.20
MAUI,不得不说,优秀。也不能不说,存在不少挑战。
期望一家以 Windows 平台为主导的企业,能够像支持 Windows 一样,将与 Windows 平台紧密结合的 .Net 运行时及其天然语言 C# 支持到其他平台,这有些理想化。尽管如此,由于喜爱 C# 的优雅以及 Visual Studio 几乎完美的体验,我还是选择了相信他们当初承诺的跨平台支持能力。事已至此,不必过多讨论得失。我们不应质疑他们的努力,也不应纠结于他们的动机,毕竟结果已经摆在面前:现实总是不随人愿。
因此,我尝试引入 HTML 5 作为前端技术。并且是纯粹的形式,不使用任何框架,仅依赖原生技术。虽然我偏好强类型语言,但这次不得不面对 JavaScript。幸运的是,各编程语言之间存在很多共通之处,主要概念趋于一致。本文将重点讨论 this,这种既相似又不同的处理方式。
一、C# 中的 this:顺从的孩子
在 C# 这样的强类型语言中,this 是开发者的得力助手,行为稳定且规则明确,从定义到运行始终保持一致。
在编译阶段即确定了指向,this 在编写代码时就已经固定了与当前类实例的关联,无论方法是直接调用、作为委托传递,还是通过其他方式触发,this 都不会偏离目标。
public class Person {
public string Name { get; set; }
public void SayHi() => Console.WriteLine($"Hi, {this.Name}");
}
var person = new Person { Name = "张三" };
Action say = person.SayHi; // 获取方法引用
say(); // 输出 "Hi, 张三"(this 依然指向 person 实例)
在继承机制中,无论是隐式调用父类的无参构造函数,还是显式调用带参数的构造函数,父类的构造函数总是优先执行,确保父类成员先完成初始化,避免子类访问未准备好的父类属性。
// 父类:仅定义带参数的构造函数,没有无参构造
public class Parent {
protected string Name;
public Parent(string name) { // 带参数的构造函数
this.Name = name;
}
}
// 子类:必须显式调用父类的 Parent(string) 构造函数,否则会出错
public class Child : Parent {
public int Age;
// 正确:显式调用父类带参数的构造函数
public Child(string name, int age) : base(name) {
this.Age = age;
Console.WriteLine($"子类初始化:{this.Name},{this.Age}"); // 正常(Name 已由父类初始化)
}
// 错误示例(无法编译):未显式调用 base(name)
// 父类:有隐式的无参构造函数(未定义任何构造函数时,编译器自动生成)
// public class Parent {
// protected string Name; // 父类成员
// }
// public Child(string name, int age) {
// this.Age = age;
// }
// 编译错误:“Parent”不包含接受 0 个参数的构造函数
}
在事件回调中,this 自觉地保持其身份,在 UI 事件(如按钮点击)中,this 始终指向当前组件/页面实例,而不会指向触发事件的控件(如按钮本身)。
// 按钮点击事件中,this 指向当前页面,而不是按钮
button.Click += (s, e) => Console.WriteLine(this.Title);
C# 的 this 被称为“顺从”,主要是因为它采用静态绑定——行为在编译阶段就已确定,运行时无需额外判断。这一核心特性正是早期 JavaScript 的 this 所欠缺的。
二、JavaScript 的 this:似是而非
早期 JavaScript 没有类的概念,通过“构造函数+原型链”来模拟面向对象编程,this 因其“动态绑定”的特性,从强类型语言的角度来看,其行为几乎像是一个怪物,this 的指向完全取决于调用方式,稍有不慎就会出错。
// 早期模拟类的方式,this 容易失控
function Person(name) {
this.name = name; // 构造函数中 this 指向实例(需用 new 调用)
}
Person.prototype.sayHi = function() {
console.log(`Hi, ${this.name}`); // 原型方法中 this 取决于调用者
};
const person = new Person("张三");
person.sayHi(); // 正常(this 指向 person)
const say = person.sayHi;
say(); // 出现错误(this 指向全局)
2015 年 ES6 引入了 class、extends 等功能,显著地借鉴了 C#、Java 等强类型语言的设计理念,使 JavaScript 的面向对象编程更加直观。
// 类似于 C# 的类定义,结构更为明了
class Person {
constructor(name) {
this.name = name; // 构造函数中 this 指向实例
}
sayHi() {
console.log(`Hi, ${this.name}`); // 类方法中的 this
}
}
这种借鉴并不是简单的复制,JavaScript 依旧保留了其作为动态语言的特点,但 class 语法的引入,确实减少了学习的难度。
class 实质上是一种“语法糖”,底层仍然基于原型链(prototype),只是经过包装后更接近 C# 的类:
实例初始化。constructor 相当于 C# 的构造函数,在 new 调用时,this 指向新创建的实例,用于设置实例属性(this.xxx)。
class Person {
constructor(name) {
this.name = name; // name 是实例属性(每个实例独立拥有)
}
}
类方法默认添加到原型上。在类中定义的方法(例如 sayHi)会被添加到类的原型(Person.prototype)上,所有实例共用该方法。这一点与 C# 中“方法定义在类中,实例共享方法”的逻辑相同,不过底层实现机制不同,C# 基于类,而 JavaScript 基于原型链。
const p1 = new Person("张三");
const p2 = new Person("李四");
p1.sayHi === p2.sayHi; // true(共享原型上的方法)
static 方法添加到类本身,而不是原型,this 指向类本身。C# 的静态方法中没有 this,但在逻辑上相似:不依赖实例。
class Person {
static createDefault() {
return new Person("默认名称"); // this 指向 Person 类
}
}
const defaultPerson = Person.createDefault();
基于原型链封装的 extends 继承,class Child extends Parent 实际上是让 Child.prototype.__proto__ 指向 Parent.prototype,但在语法上模仿了 C# 的继承。super 对应 C# 的 base,用于调用父类的构造函数或方法。
三、将 JavaScript 的 this “怪兽”驯化成温顺的孩子
JavaScript 的 this 类似于一个“怪兽”,主要在于其指向取决于函数被调用的方式,属于动态绑定,而非定义时的静态绑定。 动态绑定规则决定了 this 的指向:
绑定类型 调用方式示例 this 指向 与 C# 的对比
默认绑定 fn() 全局对象(非严格模式)/undefined(严格模式) 无对应(C# 无全局 this)
隐式绑定 obj.fn() 调用方法的对象 obj 类似 C# 实例调用方法(this 指向实例)
显式绑定 fn.call(obj)/fn.apply(obj)/fn.bind(obj) 指定的对象 obj 无对应(C# this 无法更改)
new 绑定 new Fn() 新创建的实例对象 类似 C# new 实例化(this 指向实例)
示例:
// 同一函数,不同的调用方式,this 指向也不同**
function showThis() {
console.log(this);
}
const obj = { name: "测试对象", showThis };
showThis(); // 默认绑定 → 全局对象
obj.showThis(); // 隐式绑定 → obj
showThis.call({ custom: "自定义对象" }); // 显式绑定 → 自定义对象
new showThis(); // new 绑定 → 新实例
然而,JavaScript 提供了显式绑定工具,如 call / apply / bind ,允许手动控制 this 的指向,使其行为更类似于 C# 中的 this。
方法 作用 调用时机 适用场景
call 将 this 指向第一个参数,并立即执行函数 立即执行 已知参数数量时调用函数
apply 将 this 指向第一个参数,并立即执行函数 立即执行 参数以数组形式提供时
bind 将 this 指向第一个参数,返回新函数(延迟执行) 延迟执行 固定事件回调、方法提取后调用
1)bind 的优先级最高,一旦绑定后无法更改,类似于 C# 中 this 的不可变性:
function sayHi() {
console.log(`Hi, ${this.name}`);
}
const person = { name: "张三" };
const boundSayHi = sayHi.bind(person); // 连接 this 至 person
boundSayHi(); // 显示 "Hi, 张三"
boundSayHi.call({ name: "李四" }); // 依旧显示 "Hi, 张三"(bind 不可替代)
2)箭头函数,自然继承外部 this,避免动态陷阱
ES6 箭头函数不具备自身的 this,其 this 从外围作用域(定义时的环境)继承,类似于 C# 匿名方法捕捉当前 this 的功能。这是使 this 更加“顺从”的更为简洁的方法。对比常规函数与箭头函数:
class Timer {
constructor() {
this.seconds = 0;
// 常规函数:this 指向调用者(setTimeout 的全局环境)
setInterval(function() {
this.seconds++; // 错误:this.seconds 未定义
}, 1000);
// 箭头函数:this 从 constructor 继承(Timer 实例)
setInterval(() => {
this.seconds++; // 正确:this 指向 Timer 实例
}, 1000);
}
}
使用场合:当需要保留外部 this 时,事件回调、计时器、嵌套函数中应优先采用。
3) 内存泄漏:this 导致的隐蔽问题
尽管 C# 拥有自动垃圾收集机制,但在 JavaScript 中,如果 this 相关的事件回调未能正确解除绑定,则可能导致对象无法被回收,从而引起内存泄漏。
错误示例:动态生成的函数无法解除绑定
class Component {
constructor() {
this.name = "组件";
// 错误:每次 bind 产生新的函数,后续无法解除绑定
document.querySelector('button').addEventListener('click', this.handleClick.bind(this));
}
handleClick() { console.log(this.name); }
destroy() {
// 失败:解除绑定的函数与绑定的不是同一个引用
document.querySelector('button').removeEventListener('click', this.handleClick.bind(this));
}
}
正确做法:存储绑定后的函数引用
class Component {
constructor() {
this.name = "组件";
// 预先绑定并存储引用
this.boundHandleClick = this.handleClick.bind(this);
document.querySelector('button').addEventListener('click', this.boundHandleClick);
}
handleClick() { console.log(this.name); }
destroy() {
// 使用相同的引用解除绑定
document.querySelector('button').removeEventListener('click', this.boundHandleClick);
this.boundHandleClick = null; // 释放引用
}
}
四、HTML 5 定制 UI 组件中 this 标准化
在 HTML5 构建定制 UI 组件(例如按钮、表单控件)的过程中,this 的问题会显著出现。通过结合类与继承,我们能运用标准化技术来解决这些问题。
场景 1:定制按钮组件(基础类)
问题:事件回调中 this 指向 DOM 元素(而不是组件实例)。
class CustomButton {
constructor(label) {
this.label = label; // 组件属性
this.btn = document.createElement('button');
this.btn.textContent = label;
// 问题:点击时 this 指向 btn(DOM 元素)
this.btn.addEventListener('click', this.onClick);
document.body.appendChild(this.btn);
}
onClick() {
console.log(`点击了 ${this.label}`); // 出错:this.label 不存在
}
}
解决方案:利用 bind 或箭头函数确保 this 指向组件实例。
class CustomButton {
constructor(label) {
this.label = label;
this.btn = document.createElement('button');
this.btn.textContent = label;
// 方法 1:bind 绑定
this.btn.addEventListener('click', this.onClick.bind(this));
// 方法 2:箭头函数回调(更为简洁)
// this.btn.addEventListener('click', () => this.onClick());
document.body.appendChild(this.btn);
}
onClick() {
console.log(`点击了 ${this.label}`); // 准确:this 指向组件实例
}
}
情景 2:带图标的按钮(子类继承)
问题:子类构造函数未调用 super() 就使用 this,直接出错。
class IconButton extends CustomButton {
constructor(label, icon) {
this.icon = icon; // 错误:必须先调用 super()
super(label);
}
}
解决:严格遵循“先 super() 后 this”,符合 C# 的 base() 逻辑。
class IconButton extends CustomButton {
constructor(label, icon) {
super(label); // 先调用父类构造
this.icon = icon; // 再初始化子类属性
this.btn.innerHTML = `
${icon}
${label}`; // 增强父类 DOM
}
// 重写父类方法
onClick() {
console.log(`点击了带 ${this.icon} 图标的 ${this.label}`);
}
}
情景 3:组件移除/销毁与资源清理
问题:事件未解除绑定导致内存泄漏。
解决:提供 destroy 方法,手动解除绑定事件并释放引用。
class CustomButton {
// ... 其他代码 ...
destroy() {
// 解除绑定事件(用绑定时期的引用)
this.btn.removeEventListener('click', this.boundOnClick || this.onClick);
this.btn.remove(); // 移除 DOM 元素
// 释放属性引用
this.btn = null;
this.label = null;
}
}
五、几点体会
如果您像我一样,熟悉 C# 或者 Java 这样的强类型语言,只是偶尔使用 JavaScript 配置前端,我的建议,与其花费时间彻底掌握 JavaScript 本身,不如改造它适应自己的思维方式。用强类型思维顺应 this,更加得心应手。具体来说:
直接使用 ES+,忽略传统 JavaScript 语法,尽管语言本身是向后兼容的。
使用 class 对齐结构,借助 class 和 extends,使 JavaScript 类的写法接近 C# ,减少认知负担;
使用 bind 或箭头函数固定 this,抵消动态性,模拟 C# 中“方法与实例强绑定”的特点;
子类构造函数先 super() 后 this,符合 C# 的 base() 调用逻辑;
主动将事件回调的 this 指向组件实例,避免指向 DOM 元素;
显式保留 connectedCallback() / disconnectedCallback() 方法,只要可能,统一在 connectedCallback 中注册事件,在 disconnectedCallback 中解除事件。
确保组件移除/销毁时解除绑定事件,释放 this 相关的引用,类似于 C# 的 Dispose。


雷达卡


京公网安备 11010802022788号







