Harmony鸿蒙开发0基础入门到精通Day09--JavaScript篇
继承
在面向对象编程中,继承是核心特性之一,核心目的是实现代码复用(子类复用父类的属性和方法)并建立类之间的层次关系(如 “学生” 是 “人” 的子类,“狗” 是 “动物” 的子类)。ES6 通过 extends 和 super 关键字简化了继承实现,比 ES5 的 “原型链继承” 更直观、更不易出错。
继承的本质是:子类(派生类)通过继承父类(基类),获得父类的所有属性和方法,同时可以添加自己的属性 / 方法,或重写父类的方法。
- 复用:减少重复代码(父类定义通用逻辑,子类直接使用);
- 扩展:子类在父类基础上增加新功能(符合 “开闭原则”:对扩展开放,对修改封闭)。
原型链继承(最基础,有缺陷)
原理:让子类的原型(Sub.prototype)指向父类的实例(new Parent()),使子类实例能通过原型链访问父类的属性和方法。
// 父类构造函数
function Parent(name) {this.name = name; // 父类实例属性this.hobbies = ["reading"]; // 父类引用类型属性
}// 父类原型方法(需要复用的方法)
Parent.prototype.sayName = function() {console.log(this.name);
};// 子类构造函数
function Child(age) {this.age = age; // 子类实例属性
}// 核心:子类原型指向父类实例(建立原型链)
Child.prototype = new Parent();
// 修复子类原型的 constructor 指向(否则会指向 Parent)
Child.prototype.constructor = Child;// 子类原型新增方法(仅子类可用)
Child.prototype.sayAge = function() {console.log(this.age);
};
const child1 = new Child(18);
child1.name = "Alice"; // 给父类属性赋值
child1.hobbies.push("running"); // 修改父类的引用类型属性console.log(child1.name); // "Alice"(继承父类属性)
child1.sayName(); // "Alice"(继承父类原型方法)
child1.sayAge(); // 18(子类自己的方法)// 问题1:子类实例修改引用类型属性会影响其他实例
const child2 = new Child(20);
console.log(child2.hobbies); // ["reading", "running"](被 child1 影响)// 问题2:无法向父类构造函数传递参数(创建 Child 时不能直接传 name 给 Parent)缺点:
- 子类实例共享父类的引用类型属性(修改一个会影响所有);
- 无法在创建子类实例时向父类构造函数传递参数;
- 子类原型的
constructor会被篡改,需手动修复。
借用构造函数(解决属性继承问题)
原理:在子类构造函数中,通过 Parent.call(this, 参数) 调用父类构造函数,强制将父类的属性绑定到子类实例上(避免共享引用类型)。
function Parent(name) {this.name = name;this.hobbies = ["reading"]; // 引用类型属性
}Parent.prototype.sayName = function() {console.log(this.name);
};function Child(name, age) {// 核心:借用父类构造函数,给子类实例绑定父类属性Parent.call(this, name); // 相当于:this.name = name; this.hobbies = ["reading"]this.age = age;
}// 子类原型方法(需单独定义,无法复用父类原型方法)
Child.prototype.sayAge = function() {console.log(this.age);
};
const child1 = new Child("Alice", 18);
child1.hobbies.push("running");
console.log(child1.hobbies); // ["reading", "running"]const child2 = new Child("Bob", 20);
console.log(child2.hobbies); // ["reading"](不被 child1 影响,解决引用类型共享问题)child1.sayName(); // 报错:child1.sayName is not a function(未继承父类原型方法)优点:
- 解决了引用类型属性共享的问题(每个子类实例有独立的属性);
- 能向父类构造函数传递参数(
Child中通过Parent.call传name)。
缺点:
- 父类原型上的方法无法被继承(子类实例不能调用
Parent.prototype上的方法); - 父类构造函数中的方法会被每个子类实例重复创建(不共享,浪费内存)。
组合继承(原型链 + 借用构造函数,常用但有优化空间)
原理:结合 “原型链继承” 和 “借用构造函数”,用借用构造函数继承属性,用原型链继承方法,兼顾两者优点。
function Parent(name) {this.name = name;this.hobbies = ["reading"];
}Parent.prototype.sayName = function() {console.log(this.name);
};function Child(name, age) {// 1. 借用构造函数:继承属性(解决共享问题+传参)Parent.call(this, name); this.age = age;
}// 2. 原型链:继承方法(复用父类原型方法)
Child.prototype = new Parent();
Child.prototype.constructor = Child; // 修复 constructor// 子类原型新增方法
Child.prototype.sayAge = function() {console.log(this.age);
};
const child = new Child("Charlie", 19);
child.sayName(); // "Charlie"(继承父类原型方法)
child.sayAge(); // 19(子类方法)
console.log(child.hobbies); // ["reading"]const child2 = new Child("Dave", 20);
child2.hobbies.push("coding");
console.log(child2.hobbies); // ["reading", "coding"](不影响 child)优点:
- 既继承了属性(独立不共享),又继承了方法(共享复用);
- 支持向父类构造函数传递参数。
缺点:
- 父类构造函数会被调用两次:一次是
new Parent()给子类原型赋值时,一次是Parent.call(this)给子类实例赋值时; - 子类原型上会有多余的父类属性(
new Parent()产生的,虽不影响实例,但冗余)。
拷贝继承
“拷贝继承” 是一种通过复制父类(或父对象)的属性和方法到子类(或子对象) 实现继承的方式,核心是 “直接拷贝成员” 而非依赖原型链关联。它不涉及复杂的原型链操作,实现简单,但也存在明显的局限性。
实例拷贝:拷贝父类实例成员到子类实例
// 父类构造函数
function Parent(name, age) {this.name = name;this.age = age;this.init = function() {console.log(`Parent init: ${this.name}`);};
}
Parent.prototype.sayHi = function() {console.log(`Hi, I'm ${this.name}`);
};// 子类构造函数(实例拷贝)
function Child(name, age, num) {// 1. 创建父类实例const parent = new Parent(name, age);// 2. 拷贝父类实例的所有可枚举成员到子类实例(this)for (const key in parent) {this[key] = parent[key]; // 包括 name、age、init(自身成员)和 sayHi(原型成员)}// 3. 子类自身属性this.num = num;
}// 测试
const child = new Child("Alice", 18, 1001);
console.log(child.name); // "Alice"(拷贝自父类实例)
child.init(); // "Parent init: Alice"(拷贝自父类实例)
child.sayHi(); // "Hi, I'm Alice"(拷贝自父类原型)原型拷贝:拷贝父类成员到子类原型
// 父类同上(Parent)// 子类构造函数(原型拷贝)
function Child(num) {this.num = num; // 子类自身属性
}// 1. 创建父类实例
const parent = new Parent("Bob", 20);
// 2. 拷贝父类成员到子类原型(所有子类实例共享)
for (const key in parent) {Child.prototype[key] = parent[key];
}// 测试
const child1 = new Child(1002);
const child2 = new Child(1003);console.log(child1.name); // "Bob"(共享父类实例的固定值)
console.log(child2.name); // "Bob"(同一份拷贝,值固定)
child1.sayHi(); // "Hi, I'm Bob"优点:
- 实现简单:无需理解原型链,通过循环拷贝即可实现继承,对新手友好;
- 避免原型链陷阱:不依赖原型链,不会出现 “子类实例修改原型属性影响所有实例” 的问题(实例拷贝时,每个实例有独立的父类成员);
- 直接访问父类成员:子类实例可直接通过
this访问父类成员,无需原型链查找。
缺点(核心局限性):
父类属性参数固定(原型拷贝的致命问题)原型拷贝时,父类实例是固定的(如
new Parent("Bob", 20)),所有子类实例的父类属性(name、age)都是同一个值,无法在创建子类实例时动态传递参数(比如想让child1叫 “Alice”,child2叫 “Bob” 做不到)。引用类型成员仍可能共享(浅拷贝问题)拷贝是 “浅拷贝”:如果父类有引用类型成员(如数组、对象),拷贝的是引用地址,子类实例修改后会影响其他实例(实例拷贝也可能有此问题)。
function Parent() {this.hobbies = ["reading"]; // 引用类型 } function Child() {const parent = new Parent();for (const key in parent) {this[key] = parent[key]; // 浅拷贝,hobbies 是引用} } const c1 = new Child(); const c2 = new Child(); c1.hobbies.push("running"); console.log(c2.hobbies); // ["reading", "running"](被 c1 影响)效率低,重复拷贝实例拷贝时,每次创建子类实例都会重复创建父类实例并拷贝成员(比如创建 100 个
Child实例,会执行 100 次拷贝),浪费内存和性能。无法继承父类原型的动态更新如果后续修改父类原型的方法(如
Parent.prototype.sayHi = function() { ... }),子类实例不会同步更新(因为拷贝的是旧版本方法)。可能拷贝不必要的成员
for...in循环会遍历父类原型链上的所有可枚举成员(包括继承的属性),可能拷贝不需要的内容(可通过hasOwnProperty过滤,但增加复杂度)。
ES6 用 extends 声明继承关系,用 super 关联父类,语法简洁且语义清晰。
// 父类(基类):定义通用属性和方法
class Person {// 父类构造方法:初始化通用属性constructor(name, age) {this.name = name; // 姓名(通用属性)this.age = age; // 年龄(通用属性)}// 父类实例方法:通用行为sayHi() {console.log(`我是${this.name},${this.age}岁`);}// 父类静态方法:通用工具方法static create(name, age) {return new Person(name, age); // 工厂方法:创建实例}
}// 子类(派生类):用 extends 继承 Person
class Student extends Person {// 子类构造方法:初始化子类特有属性constructor(name, age, grade) {// 必须先调用 super() 才能使用 this(super 代表父类的构造方法)super(name, age); // 调用父类构造方法,传递 name 和 agethis.grade = grade; // 年级(子类特有属性)}// 子类实例方法:扩展新行为study() {console.log(`${this.name}在${this.grade}年级学习`);}// 子类重写父类方法:覆盖父类的 sayHi,添加子类逻辑sayHi() {super.sayHi(); // 调用父类的 sayHi 方法(复用父类逻辑)console.log(`我在${this.grade}年级`); // 新增子类逻辑}// 子类静态方法:继承父类静态方法,也可新增static createStudent(name, age, grade) {return new Student(name, age, grade);}
}extends:声明继承关系
class 子类 extends 父类 表示 “子类继承自父类”,子类会自动获得父类的所有实例属性、实例方法、静态方法(私有成员除外)。
// 子类实例可以访问父类的属性和方法
const student = new Student("Alice", 16, 10);
console.log(student.name); // "Alice"(继承父类的 name 属性)
student.sayHi(); // 调用重写后的 sayHi(同时复用父类逻辑)
student.study(); // 调用子类新增的 study 方法super:连接父类的 “桥梁”
super 是继承的核心,有两种关键用法:
super(参数):在子类constructor中调用,代表父类的构造方法,用于初始化父类的属性。✅ 必须在子类构造方法中先调用super(),再使用this(否则报错,因为子类实例的创建依赖父类初始化)。super.方法名():在子类方法中调用,代表父类的原型方法,用于复用父类的方法逻辑。
私有成员的继承限制
父类的私有成员(用 # 定义)不会被子类继承,子类无法访问(即使在内部也不行),确保父类的封装性。
class Parent {#privateProp = "父类私有属性"; // 私有属性getPrivate() {return this.#privateProp; // 父类内部可访问}
}class Child extends Parent {tryAccessParentPrivate() {// return this.#privateProp; // 报错:子类无法访问父类私有属性}
}const child = new Child();
console.log(child.getPrivate()); // "父类私有属性"(通过父类公共方法间接访问)闭包
闭包(Closure)是 JavaScript 中函数及其关联的作用域链的组合,核心特性是:内部函数能够访问并操作其外部函数(即使外部函数已经执行完毕)中的变量。简单说,闭包让函数 “记住” 了它诞生时所处的环境(外部变量)。
形式:函数套函数
特点:包含最里层的变量不受外界影响
面试题:
<script>
function fun(n, o) {
console.log(o)
const obj = {
fun: function (m) {
return fun(m, n)
}
}
return obj
}
var a = fun(0)//undefined
a.fun(1)//0
a.fun(2)//0
a.fun(3)//0
</script>
先写个简单的案例,让我们来看看闭包的使用
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title>
</head>
<body><input type="button" class="left" value="-"><input type="text" class="t" value="0"><input type="button" class="right" value="+">
</body>
<script>let oLeft = document.querySelector('.left')let oRight = document.querySelector('.right')let oT = document.querySelector('.t')function fn(v){let n = v;const obj = {add:()=>++n,reduce:()=>--n}return obj}let res = fn(oT.value)oLeft.onclick = ()=>oT.value = res.reduce()oRight.onclick = ()=>oT.value = res.add()
</script>
</html>闭包的形成需要同时满足以下 3 个条件,缺一不可:
- 嵌套函数:存在内部函数(子函数)嵌套在外部函数(父函数)中;
- 引用外部变量:内部函数引用了外部函数中的变量(或参数);
- 外部访问内部函数:内部函数被外部函数返回,或通过其他方式暴露到外部(使得外部函数执行后,内部函数仍能被调用)。
直观理解:闭包的经典示例
通过一个 “计数器” 例子,直观感受闭包的作用:
// 外部函数:创建计数器环境
function createCounter() {let count = 0; // 外部函数的局部变量(被内部函数引用)// 内部函数:引用并操作外部变量 countfunction increment() {count++; // 访问外部函数的 countreturn count;}// 返回内部函数(暴露到外部)return increment;
}// 调用外部函数,得到内部函数(此时 createCounter 已执行完毕)
const counter = createCounter(); // 多次调用内部函数,发现它仍能访问 count
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3关键现象:createCounter 执行完毕后,理论上其内部的 count 变量应该被销毁(函数执行上下文出栈),但由于内部函数 increment 引用了 count 且被外部的 counter 保存,count 被 “保留” 下来,这就是闭包的作用 ——延长外部变量的生命周期。
闭包的本质:作用域链的持久化
JavaScript 中,函数在创建时会关联一个作用域链(包含自身作用域、外部函数作用域、全局作用域)。当内部函数被外部引用时,这个作用域链不会被销毁,因此内部函数始终能通过作用域链访问外部变量。
简单说:闭包 = 内部函数 + 外部函数的作用域(含变量)。
闭包的典型用途(为什么需要闭包?)
闭包的核心价值是 **“封装私有变量”和“保存状态”**,在实际开发中有 3 个高频场景:
1. 实现私有变量(模块化)
JavaScript 没有原生的 “私有变量” 语法(ES6 的 # 私有属性是后来新增的),闭包可模拟私有变量 —— 外部无法直接访问,只能通过内部函数暴露的接口操作。
function createUser(name) {// 私有变量(外部无法直接访问)let age = 18; // 暴露操作私有变量的接口(闭包)return {getName: () => name, // 访问私有变量 namegetAge: () => age, // 访问私有变量 agegrow: () => age++ // 修改私有变量 age};
}const user = createUser("Alice");
console.log(user.getName()); // "Alice"(通过接口访问)
console.log(user.getAge()); // 18
user.grow();
console.log(user.getAge()); // 19// 无法直接访问私有变量
console.log(user.age); // undefined(外部访问不到)2.保存函数状态(如防抖、节流、柯里化)
闭包能 “记住” 函数上一次执行的状态,这是防抖、节流、函数柯里化的核心原理。
示例:简单防抖(限制高频操作)
function debounce(fn, delay) {let timer = null; // 闭包保存定时器状态// 内部函数(闭包)return function(...args) {// 清除上一次的定时器if (timer) clearTimeout(timer); // 重新计时timer = setTimeout(() => {fn.apply(this, args);}, delay);};
}// 使用:输入框输入后 500ms 才执行搜索
const input = document.querySelector("input");
input.oninput = debounce(function(e) {console.log("搜索:", e.target.value);
}, 500);这里 timer 被闭包保存,每次输入时能 “记住” 上一次的定时器,从而实现防抖。
3. 回调函数中保留上下文
在异步回调(如 setTimeout、事件监听)中,闭包可保留回调执行时需要的上下文变量。
function setup() {const message = "Hello, 3秒后执行";// 定时器回调是闭包,记住 messagesetTimeout(function() {console.log(message); // 3秒后输出 "Hello, 3秒后执行"}, 3000);
}setup(); // 调用后,message 被闭包保留,3秒后仍能访问沙箱
在 JavaScript 中实现沙箱的核心目标是隔离不可信代码,限制其对全局环境、敏感 API(如 localStorage、fetch、DOM 操作)的访问,防止恶意行为。根据运行环境(浏览器 / Node.js)和隔离强度的不同,有多种实现方案,以下是最常用的几种实践方式:
浏览器端沙箱实现(前端场景)
浏览器端沙箱主要解决 “用户脚本污染全局作用域、篡改页面或窃取浏览器资源” 的问题,常用方案如下:
1. iframe 沙箱(隔离性最强,推荐)
利用 iframe 的原生隔离能力和 sandbox 属性,为不可信代码创建独立的全局环境,限制其权限。原理:iframe 拥有独立的 window 和 document,sandbox 属性可精确控制允许的操作(如是否允许脚本、表单提交、同源访问等)。
示例代码:
<!-- 主页面 -->
<button id="runBtn">运行用户代码</button>
<div id="output"></div><!-- 隐藏的 iframe 作为沙箱容器 -->
<iframe id="sandboxFrame" style="display: none"sandbox="allow-scripts" <!-- 仅允许执行脚本,其他权限(如表单、同源访问)默认禁用 -->
></iframe><script>const frame = document.getElementById('sandboxFrame');const output = document.getElementById('output');const runBtn = document.getElementById('runBtn');// 初始化 iframe 沙箱环境frame.onload = () => {// 向沙箱注入必要的工具(如输出函数)frame.contentWindow.log = (msg) => {output.textContent += `\n${msg}`; // 沙箱内代码通过 log 输出到主页面};};// 加载空白页面(确保沙箱环境干净)frame.src = 'about:blank';// 运行用户代码runBtn.onclick = () => {const userCode = `// 用户提交的代码(在沙箱内执行)log('用户代码执行中...');log('沙箱内的 window:', window === parent.window); // false(独立 window)// 尝试访问敏感 API(会被 sandbox 限制)try {localStorage.setItem('test', '123'); // 报错:localStorage 被禁用} catch (e) {log('禁止访问 localStorage:', e.message);}`;// 在 iframe 中执行代码frame.contentWindow.eval(userCode);};
</script>核心配置(sandbox 属性):
allow-scripts:允许 iframe 内执行 JavaScript(必须显式开启,否则脚本无法运行);allow-same-origin:允许 iframe 访问与主页面同源的资源(谨慎开启,可能突破隔离);allow-top-navigation:允许 iframe 导航到顶层页面(禁止开启,防止恶意跳转)。
优点:隔离性强(原生浏览器支持),安全性高;缺点:与主页面通信需通过 postMessage 或注入函数,略复杂。
2. Web Workers 沙箱(适合计算密集型代码)
Web Workers 是独立于主线程的线程,与主线程通过消息通信,天然隔离 DOM 和主线程全局变量,适合执行耗时或不可信的计算逻辑。
原理:Worker 线程无法访问 window、document 等 DOM 相关对象,只能通过 postMessage 与主线程交互,避免篡改页面。
示例代码:
<!-- 主页面 -->
<input type="text" id="codeInput" placeholder="输入代码,如 1+1">
<button id="runBtn">执行</button>
<div id="result"></div><script>const runBtn = document.getElementById('runBtn');const codeInput = document.getElementById('codeInput');const result = document.getElementById('result');// 创建 Worker 沙箱(独立线程)const worker = new Worker('sandbox-worker.js');// 接收 Worker 的执行结果worker.onmessage = (e) => {result.textContent = `结果:${e.data}`;};// 运行用户代码runBtn.onclick = () => {const userCode = codeInput.value;worker.postMessage(userCode); // 发送代码到 Worker 执行};
</script><!-- sandbox-worker.js(Worker 脚本) -->
self.onmessage = (e) => {const code = e.data;try {// 限制代码仅为表达式(避免复杂逻辑)const result = eval(`(${code})`); // 用括号包裹确保表达式执行self.postMessage(result); // 发送结果回主线程} catch (err) {self.postMessage(`错误:${err.message}`);}
};限制:
- 无法访问 DOM(
document、window均不可用); - 无法访问主线程的全局变量,只能通过消息传递数据;
- 适合纯计算场景(如公式计算、数据处理),不适合需要 DOM 操作的代码。
优点:非阻塞主线程,隔离性好;缺点:功能受限(无 DOM 访问),通信成本高。
3. Proxy + 作用域隔离(轻量隔离,适合简单场景)
通过 Proxy 拦截全局变量访问,结合函数作用域限制代码只能操作 “允许的资源”,模拟一个受限的全局环境。
原理:用 Proxy 包装一个 “伪全局对象”,代码执行时通过 with 语句将作用域绑定到该对象,所有变量访问都会被 Proxy 拦截,禁止访问敏感资源。
示例代码:
// 创建沙箱函数
function createSandbox() {// 允许访问的全局变量白名单const allowedGlobals = new Set(['console', 'Math', 'Date']);// 沙箱内的伪全局对象const fakeGlobal = {console: { log: (...args) => console.log('[沙箱]', ...args) }, // 包装 consoleMath,Date};// 用 Proxy 拦截变量访问const proxy = new Proxy(fakeGlobal, {get(target, key) {if (!allowedGlobals.has(key)) {throw new Error(`禁止访问全局变量:${key}`); // 拦截敏感变量}return target[key];},set(target, key, value) {if (!allowedGlobals.has(key)) {throw new Error(`禁止修改全局变量:${key}`); // 拦截敏感变量修改}target[key] = value;return true;}});// 执行沙箱内代码的函数return function runCode(code) {// 用 with 绑定作用域到 proxy,限制变量查找范围with (proxy) {// 用立即执行函数包裹,避免变量泄漏(function() {eval(code);})();}};
}// 使用沙箱
const runInSandbox = createSandbox();// 测试安全代码
runInSandbox(`console.log('计算结果:', Math.max(1, 2, 3)); // 允许console.log('当前时间:', new Date().toLocaleString()); // 允许
`);// 测试恶意代码(会被拦截)
try {runInSandbox(`window.alert('恶意弹窗'); // 禁止访问 windowlocalStorage.setItem('test', '123'); // 禁止访问 localStorage`);
} catch (e) {console.error('被拦截:', e.message); // 输出:禁止访问全局变量:window
}注意:
with语句在严格模式('use strict')下会被禁止,需确保代码在非严格模式执行;- 无法完全阻止闭包逃逸(若代码中形成闭包引用外部作用域变量,仍可能突破隔离)。
优点:轻量、无需额外 DOM 元素,适合简单脚本;缺点:隔离性较弱(存在闭包逃逸风险),不适合高风险场景。
防抖与节流
防抖(Debounce)和节流(Throttle)是 JavaScript 中优化高频事件触发的核心技术,用于减少不必要的函数执行,提升性能(如减少网络请求、避免 DOM 频繁操作)。两者核心差异在于:防抖是 “等待稳定后执行”,节流是 “固定间隔内只执行一次”。
一、防抖(Debounce):等待稳定后执行
1. 核心原理
高频事件(如输入、窗口缩放)触发后,等待指定时间(n 秒)再执行函数;若 n 秒内事件再次触发,则重置定时器,重新等待 n 秒。形象理解:“等用户停下来再做事”,比如搜索框输入时,用户停止输入后才发请求,避免输入过程中频繁请求。
2. 实现代码
// 模拟搜索请求
function search(keyword) {console.log(`搜索关键词:${keyword}`);// 实际场景:axios.get(`/api/search?kw=${keyword}`)
}// 给输入框绑定防抖事件
const input = document.querySelector('input');
input.addEventListener('input', debounce(search, 500));
// 效果:输入时不触发,停止输入500ms后执行搜索适用场景
- 搜索框输入联想(停止输入后发请求);
- 窗口
resize事件(窗口调整稳定后再计算布局); - 文本编辑器自动保存(停止输入后保存);
- 按钮防重复点击(立即执行版:点击一次后,n 秒内再次点击无效)。
//自己定义一个标识用来判断定时器是否应该要重新执行let flag = trueoInpt3.oninqut = function () {// clearTimeout(timer2)if (flag == false) returnflag = falsetimer2 = setTimeout(function () {console.log(`3秒后输出结果${oInpt3.value}`)//这是定时器结束后需要还原回去flag = true}, 3000)}