JS 高级
[TOC]
箭头函数
箭头函数
箭头函数(arrow function)是 ES6 之后增加的一种编写函数的方法,并且它比函数表达式要更加简洁:
特点:
箭头函数不会绑定 this、arguments属性;
箭头函数不能作为构造函数来使用(不能和 new 一起来使用,会抛出错误,因为箭头函数没有原型);
语法:
箭头函数如何编写呢?
const foo = (name, age) => {};
(): 函数的参数
{}: 函数的执行体
箭头函数-缩写
优化一: 如果只有一个参数可以省略()
const foo = (name) => {};
优化二: 如果函数执行体中只有一行代码, 那么可以省略大括号,并且这行代码的返回值会作为整个函数的返回值
const foo = (name) => console.log(name);
const foo = (name) => true;
优化三: 如果函数执行体只返回一个对象, 那么需要给这个对象加上()
const foo = (name) => ({ name: "tom" });
箭头函数-this
之前的代码在 ES6 之前是我们最常用的方式,从 ES6 开始,我们会使用箭头函数:
为什么在 setTimeout 的回调函数中可以直接使用 this 呢?
因为箭头函数并不绑定 this 对象,那么 this 引用就会从上层作用域中找到对应的 this
// 1. ES5中在异步函数中获取this
const obj = {
name: "obj",
data: [],
getData: function () {
+ let _this = this;
setTimeout(function () {
const res = ["tom", "jack", "jerry"];
+ console.log(this) // => window
+ console.log(_this); // => obj
+ _this.data.push(...res);
+ console.log(_this.data);
});
},
};
obj.getData();
// 2. 箭头函数中在异步函数中获取this
const obj2 = {
name: "obj2",
data: [],
getData: function () {
setTimeout(() => {
const res = ["this", "is", "es6"];
+ console.log(this); // => obj2
+ this.data.push(...res);
+ console.log(this.data);
});
},
};
obj2.getData();
思考: 如果 getData 也是一个箭头函数,那么 setTimeout 中的回调函数中的 this 指向谁呢?
答案:window
this
API
- fn.apply(thisArg, args?):
,调用一个具有给定
this
值的函数,以及以一个数组(或一个类数组对象)的形式提供的参数。- thisArg:
any
,要绑定给 this 关键字的值。在函数内部,我们可以通过 this 访问到该值 - args:
any[]
,函数的实参,必须是一个数组
- thisArg:
- fn.call(thisArg, arg1?, arg2?,...):
,使用一个指定的
this
值和单独给出的一个或多个参数来调用一个函数- thisArg:
any
,要绑定给 this 关键字的值。在函数内部,我们可以通过 this 访问到该值 - arg1?, arg2?,...:
any
,函数的实参列表
- thisArg:
- fn.bind(thisArg, arg1?, arg2?,...):
,创建一个新的绑定函数,在
bind()
被调用时,这个新函数的this
被指定为bind()
的第一个参数,而其余参数将作为新函数的参数,供调用时使用。- thisArg:
any
,要绑定给 this 关键字的值。在函数内部,我们可以通过 this 访问到该值 - arg1?, arg2?,...:
any
,函数的实参列表
- thisArg:
this 的绑定规则
this 指向
我们先来看一个让人困惑的问题:
- 定义一个函数,我们采用三种不同的方式对它进行调用,它产生了三种不同的结果
这个的案例可以给我们什么样的启示呢?
1、函数在调用时,JavaScript 会默认给 this 绑定一个值;
2、this 的绑定和定义的位置(编写的位置)没有关系;
3、this 的绑定和调用方式以及调用的位置有关系;
4、this 是在运行时被绑定的;
那么 this 到底是怎么样的绑定规则呢?一起来学习一下吧
绑定一:默认绑定;
绑定二:隐式绑定;
绑定三:显示绑定;
绑定四:new 绑定;
绑定规则-默认绑定
什么情况下使用默认绑定呢?独立函数调用。
独立的函数调用我们可以理解成函数没有被绑定到某个对象上进行调用;
我们通过几个案例来看一下,常见的默认绑定
1、普通的函数被独立调用
// 1. 独立函数调用
function foo() {
console.log("foo: ", this);
}
+foo(); // => window
2、函数定义在对象中,但是独立调用
// 2. 函数定义在对象中,但是独立调用
const obj = {
name: "obj",
bar: function () {
console.log("bar: ", this);
},
};
+ const baz = obj.bar;
+ baz(); // => window
3、严格模式下,独立调用的函数中 this 指向的是 undefined
// 3. 严格函数下,独立函数调用
+"use strict";
function fn() {
console.log("fn: ", this);
}
+fn(); // => undefined
4、在其他函数内部独立调用
// 3. 在其他函数内部,独立调用函数
function test1() {
console.log("test1: ", this);
}
function test2() {
+test1(); // => window
}
test2();
5、在高阶函数函数中独立调用
// 4. 在高阶函数中独立调用
+ function fn1(fn) {
+ fn();
+ }
const obj1 = {
name: "obj1",
fn: function () {
console.log("fn: ", this);
},
};
+ fn1(obj1.fn); // => window
绑定规则-隐式绑定
另外一种比较常见的调用方式是通过某个对象进行调用的:
我们通过几个案例来看一下,常见的隐式绑定
1、通过对象调用函数
// 1. 通过对象调用函数
function foo() {
console.log("foo: ", this);
}
const obj = {
name: "obj",
+ foo: foo,
};
+ obj.foo(); // => obj
2、复杂的对象调用函数
// 2. 复杂的对象调用函数
function fn() {
console.log("fn: ", this);
}
const obj1 = {
name: "obj1",
+ fn: fn,
};
const obj2 = {
name: "obj2",
+ obj1: obj1,
};
+ obj2.obj1.fn(); // => obj1
绑定规则-new 绑定
JavaScript 中的函数可以当做一个类的构造函数来使用,也就是使用 new 关键字。
使用 new 关键字来调用函数是,会执行如下的操作:
1、创建一个全新的对象;
2、这个新对象会被执行 prototype 连接;
3、这个新对象会绑定到函数调用的 this 上(this 的绑定在这个步骤完成);
4、如果函数没有返回其他对象,表达式会返回这个新对象;
// new绑定
function Person(name) {
console.log("赋值前:", this); // => Person {}
this.name = name;
+console.log("赋值后:", this); // => Person {name: 'ray'}
}
const p = new Person("ray");
绑定规则-显式绑定
隐式绑定前提条件:
必须在调用的对象内部有一个对函数的引用(比如一个属性)。如果没有这样的引用,在进行调用时,会报找不到该函数的错误。正是通过这个引用,间接的将 this 绑定到了这个对象上;
如果我们不希望在 对象内部 包含这个函数的引用,同时又希望在这个对象上进行强制调用,该怎么做呢?
JavaScript 所有的函数都可以使用call
和apply
方法。
语法
fn.apply(thisArg, [argsArr])
fn.call(thisArg, arg1, arg2, ...)
参数
第一个参数是相同的,要求传入一个对象;
这个对象的作用是什么呢?就是给 this 准备的。
在调用这个函数时,会将 this 绑定到这个传入的对象上。
后面的参数,apply 为数组,call 为参数列表;
因为上面的过程,我们明确的绑定了 this 指向的对象,所以称之为 显式绑定。
// 2. 显式绑定-call-携带参数
function fn(arg1, arg2) {
console.log("call: ", this, arg1, arg2);
}
+fn.call({ name: "fn" }, 18, 1.88); // => {name: 'fn'} 18 1.88
// 3. 显式绑定-apply-携带参数
function fn2(arg1, arg2) {
console.log("apply: ", this, arg1, arg2);
}
+fn2.apply({ name: "apply" }, ["China", "Anhui"]); // => {name: 'fn'} 18 1.88
注意: fn2
函数的形参是通过参数列表的方式接收传递的数组参数的
bind
通过 call 或者 apply 绑定 this 对象
- 显示绑定后,this 就会明确的指向绑定的对象
// 1. 显式绑定-call-绑定不同的对象
function foo() {
console.log("foo: ", this);
}
+foo.call(window); // => window
+foo.call({ name: "ray" }); // => { name: "ray" }
+foo.call(123); // => Number {123}
如果我们希望一个函数总是显式的绑定到一个对象上,可以怎么做呢?
使用 bind 方法,bind() 方法创建一个新的绑定函数(bound function,BF);
绑定函数是一个 exotic function object(怪异函数对象,ECMAScript 2015 中的术语)
在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
语法:
fn.bind(thisArg, arg1?, arg2?,...)
示例: bind-基本使用
// 4. 显式绑定-bind
function foo2() {
console.log("bind: ", this);
}
const obj = { name: "bind" };
+ const bar = foo2.bind(obj);
+ bar(); // => {name: 'bind'}
示例: bind-传递参数
说明:前 2 个参数在 bind 时传递,后 1 个参数在调用 baz 时传递
// 5. 显式绑定-bind-携带参数
function foo3(arg1, arg2, date) {
console.log("bind2: ", this, arg1, arg2, date);
}
const obj2 = { name: "bind2" };
+ const baz = foo3.bind(obj2, "SiChuan", "Chengdu");
+ baz("2023-6-30"); // => {name: 'bind2'} SiChuan Chengdu
内置函数的 this 绑定
有些时候,我们会调用一些 JavaScript 的内置函数,或者一些第三方库中的内置函数。
这些内置函数会要求我们传入另外一个函数;
我们自己并不会显示的调用这些函数,而是 JavaScript 内部或者第三方库内部会帮助我们执行;
这些函数中的 this 又是如何绑定的呢?
setTimeout、数组的 forEach、div 的点击
setTimeout
结论:this 指向 window
// 1. 内置函数-setTimeout
setTimeout(function () {
console.log("setTimeout: ", this); // => window
});
forEach
结论:this 指向 window 或者参数 2 指定对象
1、forEach-基本使用
// 3. 内置函数-forEach-基本使用
const arr = ["tom", "jack", "jerry", "mike"];
arr.forEach(function (item, index) {
+console.log("forEach: ", this); // => window
});
2、forEach-绑定对象
// 4. 内置函数-forEach-绑定对象
const arr2 = ["tom", "jack", "jerry", "mike"];
const obj2 = { name: "obj2" };
arr.forEach(function (item, index) {
+ console.log("forEach2: ", this); // => obj2
+ }, obj2);
事件函数
结论:this 指向发起事件的对象
// 2. 内置函数-事件处理函数
const eBtn = document.querySelector(".btn");
eBtn.addEventListener("click", function () {
+console.log("hdlClick", this); // => button
});
绑定优先级
学习了四条规则,接下来开发中我们只需要去查找函数的调用应用了哪条规则即可,但是如果一个函数调用位置应用了多条规则,优先级谁更高呢?
总结: new > bind > call、apply > 隐式绑定 > 默认绑定
1、默认规则的优先级最低
- 毫无疑问,默认规则的优先级是最低的,因为存在其他规则时,就会通过其他规则的方式来绑定 this
2、显示绑定优先级高于隐式绑定
// 2. 隐式绑定 < 显式绑定
function foo() {
console.log("foo: ", this);
}
const obj = { name: "obj", foo };
+ obj.foo.call("call"); // => String {'call'}
+ obj.foo.apply("apply"); // => String {'apply'}
+ const bar = obj.foo.bind("bind");
+ bar(); // => String {'bind'}
3、new 绑定优先级高于隐式绑定
说明: 通过new obj2.fn()
调用函数时,this 指向的是 fn 构造函数而不是它的调用对象 obj2
// 3. 隐式绑定 < new绑定
const obj2 = {
name: "obj2",
fn: function () {
console.log("fn: ", this); // => fn {}
console.log(obj2 === this); // false
},
};
new obj2.fn();
4、new 绑定优先级高于 bind
- new 绑定和 call、apply 是不允许同时使用的,所以不存在谁的优先级更高
- new 绑定可以和 bind 一起使用,new 绑定优先级更高
// 4. 显式绑定(bind) < new绑定
const obj3 = {
name: "obj3",
fn: function () {
+ console.log("fn3: ", this); // => fn {}
},
};
+ const baz = obj3.fn.bind("bind");
+ new baz();
5、bind 绑定优先级高于 call、apply
// 5. call、apply < bind
function foo2() {
console.log("foo2: ", this);
}
+ const fnBind = foo2.bind("bind");
+ fnBind.call("call"); // => String {'bind'}
+ fnBind.apply("apply"); // => String {'bind'}
例外规则
例外规则-忽略显式绑定
我们讲到的规则已经足以应付平时的开发,但是总有一些语法,超出了我们的规则之外。(神话故事和动漫中总是有类似这样的人物)
情况一:如果在显式绑定中,我们传入一个null或者undefined,那么这个显式绑定会被忽略,使用默认绑定规则:
<script>
// 1. 例外规则-忽略显式绑定
function foo1() {
console.log("foo1: ", this);
}
+foo1.call(undefined); // window
+foo1.call(null); // window
</script>
<script>
+"use strict";
// 2. 例外规则-忽略显式绑定(严格模式下)
function foo1() {
console.log("foo1: ", this);
}
+foo1.call(undefined); // undefined
+foo1.call(null); // null
</script>
例外规则-间接函数引用
情况二:创建一个函数的间接引用,这种情况使用默认绑定规则。
赋值(obj2.foo = obj1.foo)的结果是 foo 函数;
foo 函数被直接调用,那么是默认绑定;
// 2. 例外规则-间接函数引用
function foo2() {
console.log("foo2: ", this);
}
const obj1 = {
name: "obj1",
foo2,
};
const obj2 = {
name: "obj2",
};
obj1.foo2(); // obj1
+(obj2.foo2 = obj1.foo2)(); // window
例外规则-ES6 箭头函数
箭头函数不使用 this 的四种标准规则(也就是不绑定 this),而是根据外层作用域来决定 this。
this 查找规则: 和变量的查找规则一样,从里到外一层层查找
1、箭头函数中的 this 指向 fn 函数中的 this:obj3
// 3. 例外规则-箭头函数
const obj3 = {
name: "obj3",
+ fn: function () {
console.log("fn: ", this); // => obj3
+ return () => {
console.log("bar: ", this); // => obj3
};
},
};
const bar = obj3.fn();
bar.apply("bar apply");
2、箭头函数中的 this 指向全局中的 this:window
// 3. 例外规则-箭头函数
const obj4 = {
name: "obj4",
+ fn: () => {
console.log("fn: ", this); // => window
+ return () => {
console.log("baz: ", this); // => window
};
},
};
const baz = obj4.fn();
baz.apply("baz apply");
~~应用场景:~~模拟网络请求的案例
这里我使用 setTimeout 来模拟网络请求,请求到数据后如何可以存放到 data 中呢?
我们需要拿到 obj 对象,设置 data;
但是直接拿到的 this 是 window,我们需要在外层定义:var _this = this
在 setTimeout 的回调函数中使用_this 就代表了 obj 对象
1、ES5 中在异步函数中获取 this
// 1. ES5中在异步函数中获取this
const obj = {
name: "obj",
data: [],
getData: function () {
+ let _this = this;
setTimeout(function () {
const res = ["tom", "jack", "jerry"];
+ console.log(this) // => window
+ console.log(_this); // => obj
+ _this.data.push(...res);
+ console.log(_this.data);
});
},
};
obj.getData();
2、箭头函数中在异步函数中获取 this
// 2. 箭头函数中在异步函数中获取this
const obj2 = {
name: "obj2",
data: [],
getData: function () {
setTimeout(() => {
const res = ["this", "is", "es6"];
+console.log(this); // => obj2
+this.data.push(...res);
+console.log(this.data);
});
},
};
obj2.getData();
this 面试题
面试题一:
面试题二:
面试题三:
面试题四:
浏览器渲染原理
网页的解析过程
大家有没有深入思考过:一个网页 URL 从输入到浏览器中,到显示经历过怎么样的解析过程呢?
要想深入理解下载的过程,我们还要先理解,一个 index.html 被下载下来后是如何被解析和显示在浏览器上的.
浏览器渲染流程
浏览器内核
常见的浏览器内核有
Trident ( 三叉戟):IE、360 安全浏览器、搜狗高速浏览器、百度浏览器、UC 浏览器;
Gecko( 壁虎) :Mozilla Firefox;
Presto(急板乐曲)-> Blink (眨眼):Opera
Webkit :Safari、360 极速浏览器、搜狗高速浏览器、移动端浏览器(Android、iOS)
Webkit -> Blink :Google Chrome,Edge
我们经常说的浏览器内核指的是浏览器的排版引擎:
排版引擎(layout engine),也称为浏览器引擎(browser engine)、页面渲染引擎(rendering engine)或样版引擎。
也就是一个网页下载下来后,就是由我们的渲染引擎来帮助我们解析的。
渲染流程
渲染引擎在拿到一个页面后,如何解析整个页面并且最终呈现出我们的网页呢?
我们之前学习过下面的这幅图,现在让我们更加详细的学习它的过程;
更详细的解析过程如下:
https://www.html5rocks.com/en/tutorials/internals/howbrowserswork
https://juejin.cn/post/6844903704206786573
回流和重绘解析
解析一:解析 HTML
因为默认情况下服务器会给浏览器返回 index.html 文件,所以解析 HTML 是所有步骤的开始:
解析 HTML,会构建 DOM Tree:
解析二:生成 CSS 规则
在解析 HTML 的过程中,如果遇到 CSS 的 link 元素,那么会由浏览器负责下载对应的 CSS 文件:
*注意:*下载 CSS 文件是不会影响 DOM 的解析的;
浏览器下载完 CSS 文件后,就会对 CSS 文件进行解析,解析出对应的规则树:
我们可以称之为 CSSOM(CSS Object Model,CSS 对象模型);
解析三:构建 Render Tree
当有了 DOM Tree 和 CSSOM Tree 后,就可以两个结合来构建 Render Tree 了
注意: link 元素不会阻塞 DOM Tree的构建过程,但是会阻塞 Render Tree的构建过程。这是因为 Render Tree 在构建时,需要对应的 CSSOM Tree;
注意: Render Tree 和 DOM Tree 并不是一一对应的关系,比如对于display 为 none的元素,压根不会出现在 render tree 中;
解析四:布局和绘制
第四步是在渲染树(Render Tree)上运行布局(Layout)以计算每个节点的几何体。
渲染树会表示显示哪些节点以及其他样式,但是不表示每个节点的尺寸、位置等信息;
布局是确定呈现树中所有节点的宽度、高度和位置信息;
第五步是将每个节点绘制(Paint)到屏幕上
在绘制阶段,浏览器将布局阶段计算的每个 frame 转为屏幕上实际的像素点;
包括将元素的可见部分进行绘制,比如文本、颜色、边框、阴影、替换元素(比如 img)
回流和重绘
回流(reflow)
理解回流 reflow:(也可以称之为重排)
第一次确定节点的大小和位置,称之为布局(layout)。
之后对节点的大小、位置修改重新计算称之为回流。
引起回流的情况:
比如DOM 结构发生改变(添加新的节点或者移除节点);
比如改变了布局(修改了 width、height、padding、font-size 等值)
比如窗口 resize(修改了窗口的尺寸等)
比如调用getComputedStyle方法获取尺寸、位置信息;
重绘(repaint)
理解重绘 repaint:
第一次渲染内容称之为绘制(paint)。
之后重新渲染称之为重绘。
引起重绘的情况:
- 比如修改背景色、文字颜色、边框颜色、样式等;
回流一定会引起重绘,所以回流是一件很消耗性能的事情。
避免回流:
所以在开发中要尽量避免发生回流:
1、修改样式时尽量一次性修改
- 比如通过 cssText 修改,比如通过添加 class 修改
2、尽量避免频繁的操作 DOM
- 我们可以在一个 DocumentFragment 或者父元素中将要操作的 DOM 操作完成,再一次性的操作;
3、尽量避免通过 getComputedStyle 获取尺寸、位置等信息;
4、对某些元素使用 position 的absolute或者fixed
- 并不是不会引起回流,而是开销相对较小,不会对其他元素造成影响。
合成和性能优化
特殊解析:composite 合成
绘制的过程,可以将布局后的元素绘制到多个合成图层中。这是浏览器的一种优化手段;
默认情况下,标准流中的内容都是被绘制在同一个图层(Layer)中的;
而一些特殊的属性,会创建一个新的合成层( CompositingLayer ),并且新的图层可以利用 GPU 来加速绘制;因为每个合成层都是单独渲染的;
那么哪些属性可以形成新的合成层呢?常见的一些特殊属性:
3D transforms
video、canvas、iframe
opacity 动画转换时;
position: fixed
will-change:一个实验性的属性,提前告诉浏览器元素可能发生哪些变化;
animation 或 transition 设置了 opacity、transform;
分层确实可以提高性能,但是它以内存管理为代价,因此不应作为 web 性能优化策略的一部分过渡使用。
script 元素阻塞
我们现在已经知道了页面的渲染过程,但是 JavaScript 在哪里呢?
事实上,浏览器在解析 HTML 的过程中,遇到了 script 元素是不能继续构建 DOM 树的;
它会停止继续构建,首先下载 JavaScript 代码,并且执行 JavaScript 的脚本;
只有等到 JavaScript 脚本执行结束后,才会继续解析 HTML,构建 DOM 树;
为什么要这样做呢?
这是因为JavaScript的作用之一就是操作 DOM,并且可以修改 DOM;
如果我们等到 DOM 树构建完成并且渲染再执行 JavaScript,会造成严重的回流和重绘,影响页面的性能;
所以会在遇到 script 元素时,优先下载和执行 JavaScript 代码,再继续构建 DOM 树;
但是这个也往往会带来新的问题,特别是现代页面开发中:
在目前的开发模式中(比如 Vue、React),脚本往往比 HTML 页面更“重”,处理时间需要更长;
所以会造成页面的解析阻塞,在脚本下载、执行完成之前,用户在界面上什么都看不到;
为了解决这个问题,script 元素给我们提供了两个属性(attribute):defer和async。
defer 和 async 属性
defer 属性
defer属性告诉浏览器不要等待脚本下载,而是继续解析 HTML,构建 DOM Tree。
特点:
1、脚本会由浏览器来进行下载,但是不会阻塞 DOM Tree的构建过程;
2、defer 脚本中可以获取到 DOM 元素
3、如果脚本提前下载好了,它会等待 DOM Tree 构建完成,在DOMContentLoaded 事件之前先执行 defer 中的代码;
所以 DOMContentLoaded 总是会等待 defer 中的代码先执行完成。
4、多个带 defer 的脚本是可以保持正确的顺序执行的。
5、从某种角度来说,defer 可以提高页面的性能,并且推荐放到 head元素中;
6、*注意:*defer 仅适用于外部脚本,对于内嵌 script 默认内容会被忽略。
async 属性
async 特性与 defer 有些类似,它也能够让脚本不阻塞页面。
特点:
async 是让一个脚本完全独立的:
1、浏览器不会因 async 脚本而阻塞(与 defer 类似);
2、async 脚本不能保证顺序,它是独立下载、独立运行,不会等待其他脚本;
3、async不能保证在 DOMContentLoaded 之前或者之后执行;
defer 和 async 的应用:
defer 通常用于需要在文档解析后操作 DOM 的 JavaScript 代码,并且对多个 script 文件有顺序要求的;
async 通常用于独立的脚本,对其他脚本,甚至 DOM 没有依赖的;
JS 运行原理
V8 引擎原理
JS 代码的执行
JavaScript 代码下载好之后,是如何一步步被执行的呢?
我们知道,浏览器内核是由两部分组成的,以 webkit 为例:
WebCore:负责 HTML 解析、布局、渲染等等相关的工作;
JavaScriptCore:解析、执行 JavaScript 代码;
另外一个强大的 JavaScript 引擎就是 V8 引擎。
V8 引擎-执行原理
我们来看一下官方对 V8 引擎的定义:
V8 是用 C ++编写的 Google 开源高性能 JavaScript 和 WebAssembly 引擎,它用于Chrome和Node.js等。
它实现ECMAScript和WebAssembly,并在 Windows 7 或更高版本,macOS 10.12+和使用 x64,IA-32,ARM 或 MIPS 处理器的 Linux 系统上运行。
V8 可以独立运行,也可以嵌入到任何 C ++应用程序中。
总结: 高性能、跨平台、可独立运行
V8 引擎-架构
V8 引擎本身的源码非常复杂,大概有超过100w 行 C++代码,通过了解它的架构,我们可以知道它是如何对 JavaScript 执行的:
Parse模块会将 JavaScript 代码转换成 AST(抽象语法树),这是因为解释器并不直接认识 JavaScript 代码;
如果函数没有被调用,那么是不会被转换成 AST 的;
Parse 的 V8 官方文档:https://v8.dev/blog/scanner
Ignition是一个解释器,会将 AST 转换成 ByteCode(字节码)
同时会收集 TurboFan 优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
如果函数只调用一次,Ignition 会解释执行 ByteCode;
Ignition 的 V8 官方文档:https://v8.dev/blog/ignition-interpreter
TurboFan是一个编译器,可以将字节码编译为 CPU 可以直接执行的机器码;
如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过 TurboFan转换成优化的机器码,提高代码的执行性能;
但是,机器码实际上也会被还原为 ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如 sum 函数原来执行的是 number 类型,后来执行变成了 string 类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
TurboFan 的 V8 官方文档:https://v8.dev/blog/turbofan-jit
V8 引擎-解析图(官方)
词法分析(英文 lexical analysis)
- 将字符序列转换成 token 序列的过程。
- scanner: 词法分析器(lexical analyzer,简称 lexer),也叫扫描器(scanner)
- token: 是记号化(tokenization)的缩写
语法分析(英语:syntactic analysis,也叫 parsing)
- parser: 语法分析器也可以称之为 。
V8 引擎-解析图
JS 执行上下文
ECMA 版本说明
在 ECMA 早期的版本中(ECMAScript3),代码的执行流程的术语和 ECMAScript5 以及之后的术语会有所区别:
目前网上大多数流行的说法都是基于ECMAScript3版本的解析,并且在面试时问到的大多数都是 ECMAScript3 的版本内容。
但是 ECMAScript3 终将过去, ECMAScript5必然会成为主流,所以最好也理解 ECMAScript5 甚至包括 ECMAScript6 以及更好版本的内容;
事实上在TC39的最新描述中,和 ECMAScript5 之后的版本又出现了一定的差异;
那么我们课程按照如下顺序学习:
通过ECMAScript3中的概念学习JavaScript 执行原理、作用域、作用域链、闭包等概念;
通过ECMAScript5中的概念学习块级作用域、let、const等概念;
事实上,它们只是在对某些概念上的描述不太一样,在整体思路上都是一致的。
JS 执行原理
假如我们有下面一段代码,它在 JavaScript 中是如何被执行的呢?
JS 执行流程
1、初始化全局对象 GO
2、事先存在一个执行上下文栈 ECS
3、执行全局代码:
在 ECS 中创建一个全局执行上下文 GEC
在 GEC 中创建 VO 对象,让它关联到 GO 对象
变量的作用域提升:在转成 AST 树时,会将变量、函数加入到 GO 中,但不赋值
4、执行函数代码:
- 在 ECS 中创建一个函数执行上下文 FEC
- 在 FEC 中创建 VO 对象,让它关联到 AO 对象
- 变量的作用域提升:在转成 AST 树时,会将变量、函数加入到 AO 中,但不赋值
JS 执行-初始化全局对象 GO
JS 引擎会在执行代码之前,在堆内存中创建一个全局对象 GO(Global Object)
GO 对象:
该对象 所有的作用域(scope)都可以访问;
里面会包含 Date、Array、String、Number、setTimeout、setInterval 等等;
其中还有一个 window 属性指向自己;
JS 执行-执行上下文
JS 引擎内部有一个执行上下文栈 ECS(Execution Context Stack),它是用于执行代码的调用栈。
那么现在它要执行谁呢?执行的是全局的代码块:
全局的代码块为了执行会构建一个 全局执行上下文 GEC(Global Execution Context);
GEC 会 被放入到 ECS 中 执行;
GEC 被放入到 ECS 中里面包含两部分内容:
**第一部分:**在代码执行前,在 parser 转成 AST 的过程中,会将全局定义的变量、函数等加入到 GlobalObject 中,但是并不会赋值;
- 这个过程也称之为变量的作用域提升(hoisting)
**第二部分:**在代码执行中,对变量赋值,或者执行其他的函数;
JS 执行-认识 VO 对象
每一个执行上下文会关联一个变量对象 VO(Variable Object),变量和函数声明会被添加到这个 VO 对象中。
当全局代码被执行的时候,VO 就是 GO 对象了
全局代码执行过程
全局代码执行过程(执行前)
全局代码执行过程(执行后)
函数代码执行过程
函数如何被执行呢?
在执行的过程中执行到一个函数时,就会根据函数体创建一个函数执行上下文 FEC(Functional Execution Context),并且压入到 EC Stack 中。
因为每个执行上下文都会关联一个 VO,那么函数执行上下文关联的 VO 是什么呢?
当进入一个函数执行上下文时,会创建一个AO 对象(Activation Object);
这个 AO 对象会使用arguments作为初始化,并且初始值是传入的参数;
这个 AO 对象会作为执行上下文的 VO 来存放变量的初始化;
函数的执行过程(执行前)
函数的执行过程(执行后)
函数的多次执行
函数代码相互调用
作用域和作用域链
全局变量的查找
函数代码变量的查找
1、函数中有定义自己的 message
2、函数中没有自己的 message
作用域和作用域链
当进入到一个执行上下文时,执行上下文也会关联一个作用域链(Scope Chain)
作用域链是一个对象列表,用于变量标识符的求值;
当进入一个执行上下文时,这个作用域链被创建,并且根据代码类型,添加一系列的对象;
函数的作用域链和函数的定义位置有关,与调用位置无关
多层嵌套函数的作用域链
作用域提升面试题
JS 内存管理
JS 内存管理
认识内存管理
不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存:
不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期:
第一步:分配申请你需要的内存(申请);
第二步:使用分配的内存(存放一些东西,比如对象等);
第三步:不需要使用时,对其进行释放;
不同的编程语言对于第一步和第三步会有不同的实现:
手动管理内存:比如 C、C++,包括早期的 OC,都是需要手动来管理内存的申请和释放的(malloc 和 free 函数);
自动管理内存:比如 Java、JavaScript、Python、Swift、Dart 等,它们有自动帮助我们管理内存;
对于开发者来说,JavaScript 的内存管理是自动的、无形的。
我们创建的原始值、对象、函数……这一切都会占用内存;
但是我们并不需要手动来对它们进行管理,JavaScript 引擎会帮助我们处理好它;
JS 的内存管理
JavaScript 会在定义数据时为我们分配内存。
但是内存分配方式是一样的吗?
JS 对于原始数据类型内存的分配会在执行时,直接在栈空间进行分配;
JS 对于复杂数据类型内存的分配会在堆内存中开辟一块空间,并且将这块空间的指针返回值变量引用;
垃圾回收机制算法
JS 的垃圾回收
因为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间。
在手动管理内存的语言中,我们需要通过一些方式自己来释放不再需要的内存,比如 free 函数:
但是这种管理的方式其实非常的低效,影响我们编写逻辑的代码的效率;
并且这种方式对开发者的要求也很高,并且一不小心就会产生内存泄露;
所以大部分现代的编程语言都是有自己的垃圾回收机制:
垃圾回收的英文是Garbage Collection,简称GC;
对于那些不再使用的对象,我们都称之为是垃圾,它需要被回收,以释放更多的内存空间;
而我们的语言运行环境,比如 Java 的运行环境 JVM,JavaScript 的运行环境 js 引擎都会内存垃圾回收器;
垃圾回收器我们也会简称为GC,所以在很多地方你看到 GC 其实指的是垃圾回收器;
但是这里又出现了另外一个很关键的问题:GC 怎么知道哪些对象是不再使用的呢?
- 这里就要用到 GC 的实现以及对应的算法;
常见 GC 算法-引用计数
引用计数(Reference counting):
当一个对象有一个引用指向它时,那么这个对象的引用就+1;
当一个对象的引用为 0 时,这个对象就可以被销毁掉;
这个算法有一个很大的弊端就是会产生循环引用;
当obj1=null
和obj2=null
时,依然会有obj1.info
指向当前对象,引用计数为 1,所以无法销毁
必须通过obj1.info=null
才能取消引用
常见 GC 算法-标记清除
标记清除(mark-Sweep):
标记清除的核心思路是可达性(Reachability)
这个算法是设置一个根对象(root object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象,对于那些没有引用到的对象,就认为是不可用的对象;
这个算法可以很好的解决循环引用的问题;
V8 使用的是该算法
常见 GC 算法-其他算法优化补充
JS 引擎比较广泛的采用的就是可达性中的标记清除算法,当然类似于 V8 引擎为了进行更好的优化,它在算法的实现细节上也会结合一些其他的算法。
标记整理(Mark-Compact) 和“标记-清除”相似;
- 不同的是,回收期间同时会将保留的存储对象搬运汇集到连续的内存空间,从而整合空闲空间,避免内存碎片化;
分代收集(Generational collection)——对象被分成两组:“新的”和“旧的”。
许多对象出现,完成它们的工作并很快死去,它们可以很快被清理;
那些长期存活的对象会变得“老旧”,而且被检查的频次也会减少;
增量收集(Incremental collection)
如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。
所以引擎试图将垃圾收集工作分成几部分来做,然后将这几部分会逐一进行处理,这样会有许多微小的延迟而不是一个大的延迟;
闲时收集(Idle-time collection)
- 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。
V8 引擎详细的内存图
事实上,V8 引擎为了提供内存的管理效率,对内存进行非常详细的划分:
闭包
闭包-概念
又爱又恨的闭包
闭包是 JavaScript 中一个非常容易让人迷惑的知识点:
有同学在深入 JS 高级的交流群中发了这么一张图片;
并且闭包也是群里面大家讨论最多的一个话题;
闭包确实是 JavaScript 中一个很难理解的知识点,接下来我们就对其一步步来进行剖析,看看它到底有什么神奇之处。
JavaScript 的函数式编程
在前面我们说过,JavaScript 是支持函数式编程的
在 JavaScript 中,函数是非常重要的,并且是一等公民:
那么就意味着函数的使用是非常灵活的;
函数可以作为另外一个函数的参数,也可以作为另外一个函数的返回值来使用;
所以 JavaScript 存在很多的高阶函数:
自己编写高阶函数
使用内置的高阶函数
目前在 vue3 和 react 开发中,也都在趋向于函数式编程:
vue3 composition api: setup 函数 -> 代码(函数 hook,定义函数);
react:class -> function -> hooks
闭包的定义
这里先来看一下闭包的定义,分成两个:在计算机科学中和在 JavaScript 中。
在计算机科学中对闭包的定义(维基百科):
闭包(Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures);
是在支持头等函数的编程语言中,实现词法绑定的一种技术;
闭包在实现上是一个结构体,它存储了一个函数和一个关联的环境(相当于一个符号查找表);
闭包跟函数最大的区别在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即使脱离了捕捉时的上下文,它也能照常运行;
闭包的概念出现于 60 年代,最早实现闭包的程序是 Scheme,那么我们就可以理解为什么 JavaScript 中有闭包:
- 因为 JavaScript 中有大量的设计是来源于 Scheme 的;
我们再来看一下MDN对 JavaScript 闭包的解释:
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure);
也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域;
在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来;
那么我的理解和总结:
一个普通的函数function,如果它可以访问外层作用域的自由变量,那么这个函数和周围环境就是一个闭包;
从广义的角度来说:JavaScript 中的函数都是闭包;
从狭义的角度来说:JavaScript 中一个函数,如果访问了外层作用域的变量,那么它是一个闭包;
闭包-形成过程
闭包的访问过程
如果我们编写了如下的代码,它一定是形成了闭包的:
闭包的执行过程
那么函数继续执行呢?
这个时候 makeAdder 函数执行完毕,正常情况下我们的 AO 对象会被释放;
但是因为在 0xb00 的函数中有作用域引用指向了这个 AO 对象,所以它不会被释放掉;
闭包-内存泄露
闭包的内存泄漏
那么我们为什么经常会说闭包是有内存泄露的呢?
在上面的案例中,如果后续我们不再使用 add10 函数了,那么该函数对象应该要被销毁掉,并且其引用着的父作用域 AO 也应该被销毁掉;
但是目前因为在全局作用域下 add10 变量对 0xb00 的函数对象有引用,而 0xb00 的作用域中 AO(0x200)有引用,所以最终会造成这些内存都是无法被释放的;
所以我们经常说的闭包会造成内存泄露,其实就是刚才的引用链中的所有对象都是无法释放的;
那么,怎么解决这个问题呢?
因为当手动将 add10 设置为 null时,就不再对函数对象 0xb00 有引用,那么对应的 AO 对象 0x200 也就不可达了;
在 GC 的下一次检测中,它们就会被销毁掉;
闭包的内存泄漏测试
AO 不使用的属性优化
我们来研究一个问题:AO 对象不会被销毁时,是否里面的所有属性都不会被释放?
下面这段代码中 name 属于闭包的父作用域里面的变量;
我们知道形成闭包之后 count 一定不会被销毁掉,那么 name 是否会被销毁掉呢?
这里我打上了断点,我们可以在浏览器上看看结果;
函数增强
函数属性、arguments
函数对象的属性
我们知道 JavaScript 中函数也是一个对象,那么对象中就可以有属性和方法。
1、自定义函数属性
2、函数内置属性
- name:一个函数的名词我们可以通过 name 来访问;
- length:属性 length 用于返回函数形参的个数;
注意: rest 参数和有默认值的参数是不参与参数的个数的;
认识 arguments
arguments是一个对应于传递给函数的参数的类数组(array-like)对象。
array-like 意味着它不是一个数组类型,而是一个对象类型:
但是它却拥有数组的一些特性,比如说 length,比如可以通过 index 索引来访问;
但是它却没有数组的一些方法,比如 filter、map 等;
arguments 转 Array
在开发中,我们经常需要将 arguments 转成 Array,以便使用数组的一些特性。
常见的转化方式如下:
*转化方式一:*遍历 arguments,添加到一个新数组中;
转化方式二: 调用数组 slice 函数的 call 方法;较难理解(有点绕),了解即可
*转化方式三:*ES6 中的两个方法
Array.from(arguments)
[…arguments]
箭头函数不绑定 arguments
箭头函数是不绑定 arguments 的,所以我们在箭头函数中使用 arguments 会去上层作用域查找:
1、箭头函数不绑定 arguments
2、在箭头函数中使用 arguments 会去上层作用域查找
函数的剩余(rest)参数
ES6 中引用了剩余参数(rest parameter),可以将不定数量的参数放入到一个数组中:
- 如果最后一个参数是 ... 为前缀的,那么它会将剩余的参数放到该参数中,并且作为一个数组;
那么剩余参数和arguments有什么区别呢?
剩余参数只包含那些没有对应形参的实参,而 arguments 对象包含了传给函数的所有实参;
arguments对象不是一个真正的数组,而rest 参数是一个真正的数组,可以进行数组的所有操作;
arguments 是早期的 ECMAScript 中为了方便去获取所有的参数提供的一个数据结构,而 rest 参数是 ES6 中提供并且希望以此来替代 arguments 的;
剩余参数必须放到最后一个位置,否则会报错。
纯函数
理解 JavaScript 纯函数
函数式编程中有一个非常重要的概念叫纯函数(Pure Function),JavaScript 符合函数式编程的范式,所以也有纯函数的概念;
在react开发中纯函数是被多次提及的;
比如react 中组件就被要求像是一个纯函数(为什么是像,因为还有 class 组件),redux 中有一个 reducer 的概念,也是要求必须是一个纯函数;
所以掌握纯函数对于理解很多框架的设计是非常有帮助的;
纯函数的维基百科定义:
在程序设计中,若一个函数符合以下条件,那么这个函数被称为纯函数:
此函数在相同的输入值时,需产生相同的输出。
函数的输出和输入值以外的其他隐藏信息或状态无关,也和由 I/O 设备产生的外部输出无关。
该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。
当然上面的定义会过于的晦涩,所以我简单总结一下:
确定的输入,一定会产生确定的输出;
函数在执行过程中,不能产生副作用;
副作用概念的理解
那么这里又有一个概念,叫做副作用,什么又是副作用呢?
*副作用(side effect)*其实本身是医学的一个概念,比如我们经常说吃什么药本来是为了治病,可能会产生一些其他的副作用;
在计算机科学中,也引用了副作用的概念,表示在执行一个函数时,除了返回函数值之外,还对调用函数产生了附加的影响,比如修改了全局变量,修改参数或者改变外部的存储;
纯函数在执行的过程中就是不能产生这样的副作用:
- 副作用往往是产生 bug 的 “温床”。
纯函数的案例
我们来看一个对数组操作的两个函数:
slice:slice 截取数组时不会对原数组进行任何操作,而是生成一个新的数组;
splice:splice 截取数组, 会返回一个新的数组, 也会对原数组进行修改;
slice 就是一个纯函数,不会修改数组本身,而 splice 函数不是一个纯函数;
判断下面函数是否是纯函数?
纯函数的作用和优势
作用:
为什么纯函数在函数式编程中非常重要呢?
因为你可以安心的编写和安心的使用;
你在写的时候保证了函数的纯度,只是单纯实现自己的业务逻辑即可,不需要关心传入的内容是如何获得的或者依赖其他的外部变量是否已经发生了修改;
你在用的时候,你确定你的输入内容不会被任意篡改,并且自己确定的输入,一定会有确定的输出;
React中就要求我们无论是函数还是 class 声明一个组件,这个组件都必须像纯函数一样,保护它们的 props 不被修改:
柯里化
柯里化概念的理解
柯里化(Currying)也是属于函数式编程里面一个非常重要的概念。
是一种关于函数的高阶技术;
它不仅被用于 JavaScript,还被用于其他编程语言;
我们先来看一下维基百科的解释:
在计算机科学中,柯里化,又译为卡瑞化或加里化;
是把接收多个参数的函数,变成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数,而且返回结果的新函数的技术;
柯里化声称 “如果你固定某些参数,你将得到接受余下参数的一个函数”;
维基百科的解释非常的抽象,我们这里做一个总结:
- 只传递给函数一部分参数来调用它,让它返回一个函数去处理剩余的参数,这个过程就称之为柯里化;
柯里化是一种函数的转换,将一个函数从可调用的 f(a, b, c) 转换为可调用的 f(a)(b)(c)。
- 柯里化不会调用函数。它只是对函数进行转换。
柯里化的代码转换
那么柯里化到底是怎么样的表现呢?
1、普通函数转柯里化函数
2、柯里化函数的箭头函数写法
柯里化优势一-函数的职责单一
那么为什么需要有柯里化呢?
在函数式编程中,我们其实往往希望一个函数处理的问题尽可能的单一,而不是将一大堆的处理过程交给一个函数来处理;
那么我们是否就可以将每次传入的参数在单一的函数中进行处理,处理完后在下一个函数中再使用处理后的结果;
比如上面的案例我们进行一个修改:传入的函数需要分别被进行如下处理
第一个参数 + 2
第二个参数 * 2
第三个参数 ** 2
柯里化优势二-函数的参数复用
另外一个使用柯里化的场景是可以帮助我们可以复用参数逻辑:
makeAdder 函数要求我们传入一个 num(并且如果我们需要的话,可以在这里对 num 进行一些修改);
在之后使用返回的函数时,我们不需要再继续传入 num 了;
柯里化案例练习
这里我们在演示一个案例,需求是打印一些日志:
- 日志包括时间、类型、信息;
普通函数的实现方案如下:
▸柯里化高级 - 手写自动柯里化函数
目前我们有将多个普通的函数,转成柯里化函数:
组合函数
组合函数概念的理解
*组合函数(Compose Function)*是在 JavaScript 开发过程中一种对函数的使用技巧、模式:
比如我们现在需要对某一个数据进行函数的调用,执行两个函数 fn1 和 fn2,这两个函数是依次执行的;
那么如果每次我们都需要进行两个函数的调用,操作上就会显得重复;
那么是否可以将这两个函数组合起来,自动依次调用呢?
这个过程就是对函数的组合,我们称之为组合函数;
▸手写组合函数
刚才我们实现的 compose 函数比较简单
我们需要考虑更加复杂的情况:比如传入了更多的函数,在调用 compose 函数时,传入了更多的参数:
with、eval
with 语句的使用
with语句扩展一个语句的作用域链。
不建议使用 with 语句,因为它可能是混淆错误和兼容性问题的根源。
eval 函数
内建函数 eval 允许执行一个代码字符串。
eval 是一个特殊的函数,它可以将传入的字符串当做 JavaScript 代码来运行;
eval 会将最后一句执行语句的结果,作为返回值;
不建议在开发中使用 eval:
eval 代码的可读性非常的差(代码的可读性是高质量代码的重要原则);
eval 是一个字符串,那么有可能在执行的过程中被刻意篡改,那么可能会造成被攻击的风险;
eval 的执行必须经过 JavaScript 解释器,不能被 JavaScript 引擎优化;
严格模式
认识严格模式
JavaScript 历史的局限性:
长久以来,JavaScript 不断向前发展且并未带来任何兼容性问题;
新的特性被加入,旧的功能也没有改变,这么做有利于兼容旧代码;
但缺点是 JavaScript 创造者的任何错误或不完善的决定也将永远被保留在 JavaScript 语言中;
在 ECMAScript5 标准中,JavaScript 提出了*严格模式(Strict Mode)*的概念:
严格模式很好理解,是一种具有限制性的 JavaScript 模式,从而使代码隐式的脱离了 ”懒散(sloppy)模式“;
支持严格模式的浏览器在检测到代码中有严格模式时,会以更加严格的方式对代码进行检测和执行;
严格模式对正常的 JavaScript 语义进行了一些限制:
严格模式通过 抛出错误 来消除一些原有的静默(silent)错误;
严格模式让JS 引擎在执行代码时可以进行更多的优化(不需要对一些特殊的语法进行处理);
严格模式禁用了在ECMAScript 未来版本中可能会定义的一些语法;
开启严格模式
那么如何开启严格模式呢?严格模式支持粒度话迁移:
可以支持在js 文件中开启严格模式;
也支持对某一个函数开启严格模式;
严格模式通过在文件或者函数开头使用 use strict 来开启。
注意:
没有类似于 "no use strict" 这样的指令可以使程序返回默认模式。
现代 JavaScript 支持 “class” 和 “module” ,它们会自动启用 use strict;
严格模式限制
JavaScript 被设计为新手开发者更容易上手,所以有时候本来错误语法,被认为也是可以正常被解析的;但是这种方式可能给带来留下来安全隐患;在严格模式下,这种失误就会被当做错误,以便可以快速的发现和修正;
这里我们来说几个严格模式下的严格语法限制:
1、无法意外的创建全局变量
2、严格模式会使引起静默失败(silently fail,注:不报错也没有任何效果)的赋值操作抛出异常
3、严格模式下试图删除不可删除的属性
4、严格模式不允许函数参数有相同的名称
5、不允许 0 的八进制语法,要使用 0o
6、在严格模式下,不允许使用 with
7、在严格模式下,eval 不能为上层引用(创建)变量
8、严格模式下,this 绑定不会默认转成对象,也不会绑定 window,而是 undefined
手写 apply、call、bind 函数实现(原型后)
接下来我们来实现一下 apply、call、bind 函数:
- 注意:我们的实现是练习函数、this、调用关系,不会过度考虑一些边界情况
对象增强
Object.defineProperty
对属性操作的控制
在前面我们的属性都是直接定义在对象内部,或者直接添加到对象内部的:
- 但是这样来做的时候我们就不能对这个属性进行一些限制:比如这个属性是否是可以通过 delete 删除的?这个属性是否在 for-in 遍历的时候被遍历出来呢?
如果我们想要对一个属性进行比较精准的操作控制,那么我们就可以使用属性描述符。
通过属性描述符可以精准的添加或修改对象的属性;
属性描述符需要使用 Object.defineProperty 来对属性进行添加或者修改;
分类:
属性描述符分为:
- 数据属性描述符
- 存取属性描述符
Object.defineProperty
- Object.defineProperty(obj, prop, descriptor):在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象
- 参数
- obj:``,要定义属性的对象
- prop:``,要定义或修改的属性名或 Symbol
- descriptor:``,要定义或修改的属性描述符
- 返回值
- :``,被传递给函数的对象
数据属性描述符
属性描述符分类
属性描述符的类型有两种:
数据属性(Data Properties)描述符(Descriptor);
存取属性(Accessor 访问器 Properties)描述符(Descriptor);
数据属性描述符
数据数据描述符有如下四个特性:
[[Configurable]]:表示属性是否可以通过 delete删除属性,是否可以修改它的特性,或者是否可以将它修改为存取属性描述符;
当我们直接在一个对象上定义某个属性时,这个属性的[[Configurable]]为 true;
当我们通过属性描述符定义一个属性时,这个属性的[[Configurable]]默认为 false;
不可删除
不可重新配置
通过属性描述符定义一个属性时,这个属性的[[Configurable]]默认为 false;
[[Enumerable]]:表示属性是否可以通过 for-in 或者 Object.keys()遍历该属性;
当我们直接在一个对象上定义某个属性时,这个属性的[[Enumerable]]为 true;
当我们通过属性描述符定义一个属性时,这个属性的[[Enumerable]]默认为 false;
[[Writable]]:表示是否可以修改属性的值;
当我们直接在一个对象上定义某个属性时,这个属性的[[Writable]]为 true;
当我们通过属性描述符定义一个属性时,这个属性的[[Writable]]默认为 false;
- [[value]]:属性的 value 值,读取属性时会返回该值,修改属性时,会对其进行修改;
- 默认情况下这个值是 undefined;
数据属性描述符测试代码
存取属性描述符
存取属性描述符
存取属性描述符有如下四个特性:
[[Configurable]]:表示属性是否可以通过 delete 删除属性,是否可以修改它的特性,或者是否可以将它修改为存取属性描述符;
和数据属性描述符是一致的;
当我们直接在一个对象上定义某个属性时,这个属性的[[Configurable]]为 true;
当我们通过属性描述符定义一个属性时,这个属性的[[Configurable]]默认为 false;
[[Enumerable]]:表示属性是否可以通过 for-in 或者 Object.keys()返回该属性;
和数据属性描述符是一致的;
当我们直接在一个对象上定义某个属性时,这个属性的[[Enumerable]]为 true;
当我们通过属性描述符定义一个属性时,这个属性的[[Enumerable]]默认为 false;
[[get]]:获取属性时会执行的函数。默认为 undefined
[[set]]:设置属性时会执行的函数。默认为 undefined
存储属性描述符测试代码
Object.defineProperties
Object.defineProperties()方法直接在一个对象上定义多个新的属性或修改现有属性,并且返回该对象。
对象其他方法
获取对象的属性描述符:
getOwnPropertyDescriptor
getOwnPropertyDescriptors
禁止对象扩展新属性:
- preventExtensions:给一个对象添加新的属性会失败(在严格模式下会报错);
密封对象,不允许配置和删除属性:
seal
实际是调用 preventExtensions
并且将现有属性的 configurable:false
冻结对象,不允许修改现有属性:
freeze
实际上是调用 seal
并且将现有属性的 writable: false
示例:
1、getOwnPropertyDescriptor
2、getOwnPropertyDescriptors
3、preventExtensions
4、seal:preventExtensions
+ configurable: false
5、freeze:seal
+ writable: false
原型
对象和函数的原型
对象的原型
JavaScript 当中每个对象都有一个特殊的内置属性*[[Prototype]]*,这个特殊的对象可以指向另外一个对象。
那么这个对象有什么作用呢?
当我们通过引用对象的属性 key 来获取一个 value 时,它会触发*[[Get]]*的操作;
这个操作会首先检查该对象是否有对应的属性,如果有的话就使用它;
如果对象中没有该属性,那么会访问对象[[prototype]]内置属性指向的对象上的属性;
那么如果通过字面量直接创建一个对象,这个对象也会有这样的属性吗?如果有,应该如何获取这个属性呢?
- 答案是有的,只要是对象都会有这样的一个内置属性;
获取对象原型的方式有两种:
方式一:通过对象的 __proto__ 属性可以获取到(但是这个是早期浏览器自己添加的,存在一定的兼容性问题);
方式二:通过 Object.getPrototypeOf() 方法可以获取到;
示例:
1、获取对象的原型
2、对象属性查找顺序
函数的原型
那么我们知道上面的东西对于我们的构造函数创建对象来说有什么作用呢?
- 它的意义是非常重大的,接下来我们继续来探讨;
1、将函数看做一个普通的对象,它具备*__proto__
*(隐式原型)属性
作用:查找 key 对应的 value 时,会找到原型身上
2、将函数看做一个函数时,它具备*prototype
*(显式原型)属性(注意:不是__proto__
或[[Prototype]]
)
作用:当通过 new 创建对象实例时,对象实例的隐式原型会指向这个构造函数的显式原型:foo.__proto__ = Foo.prototype
注意: 箭头函数没有原型prototype
你可能会问题,老师是不是因为函数是一个对象,所以它有 prototype 的属性呢?
不是的,因为它是一个函数,才有了这个特殊的属性;
而不是它是一个对象,所以有这个特殊的属性;
new、constructor
new 操作原型赋值
我们前面讲过 new 关键字的步骤如下:
1、在内存中创建一个新的对象(空对象);
2、这个对象内部的*[[prototype]]属性会被赋值为该构造函数的prototype 属性*;
那么也就意味着我们通过 Person 构造函数创建出来的所有对象的[[prototype]]属性都指向 Person.prototype:
constructor 属性
事实上原型对象上面是有一个属性的:constructor
- 默认情况下原型上都会添加一个属性叫做 constructor,这个 constructor 指向当前的函数对象;
实例方法-构造函数和原型结合
我们在上一个构造函数的方式创建对象时,有一个弊端:会创建出重复的函数,比如 running、eating 这些函数
那么有没有办法让所有的对象去共享这些函数呢?
可以,将这些函数放到 Person.prototype 的对象上即可;
分析:
- 1、p1 的隐式原型是 Person.prototype 对象
- 2、p1.running 查找规则:
- 先在自己身上查找,没有找到
- 再去原型上查找,找到了
作用:
当多个对象拥有共同的值时,可以将该值放到构造函数的对象的显式原型上;由构造函数创建出来的所有对象,都会共享这些方法
内存图-创建实例对象
function Person(name, age) {
this.name = name;
this.age = age;
}
var p1 = new Person("mr", 18);
var p2 = new Person("tom", 20);
内存图-添加原型属性
function Person(name, age) {
this.name = name
this.age = age
}
var p1 = new Person("mr", 18)
var p2 = new Person("tom", 20)
// 添加原型属性
+ Person.prototype.message = "中国"
+ p1.__proto__.info = "中国很美丽"
内存图-添加原型方法
function Person(name, age) {
this.name = name
this.age = age
}
var p1 = new Person("mr", 18)
var p2 = new Person("tom", 20)
Person.prototype.message = "中国"
p1.__proto__.info = "中国很美丽"
// 添加原型方法
+ Person.prototype.running = function() {}
内存图-新增实例属性
function Person(name, age) {
this.name = name
this.age = age
}
var p1 = new Person("mr", 18)
var p2 = new Person("tom", 20)
Person.prototype.message = "中国"
p1.__proto__.info = "中国很美丽"
// 修改p1.message,p2.message是否改变
+ p1.message = "美国"
+ console.log(p2.message) // => "中国"
重写原型对象
在原有的原型对象上添加新的属性
如果我们需要在原型上添加过多的属性,通常我们会重写整个原型对象
前面我们说过, 每创建一个函数, 就会同时创建它的 prototype 对象, 这个对象也会自动获取 constructor 属性;
而我们这里相当于给 prototype 重新赋值了一个对象, 那么这个新对象的 constructor 属性, 会指向 Object 构造函数, 而不是
Person 构造函数
手动添加 constructor
如果希望 constructor 指向 Person,那么可以手动添加 constructor:
上面的方式虽然可以, 但是也会造成 constructor 的[[Enumerable]]特性被设置了 true
默认情况下, 原生的 constructor 属性是不可枚举的
如果希望解决这个问题, 就可以使用我们前面介绍的 Object.defineProperty()函数了
1、手动添加 constructor,指向 Person
2、通过 defineProperty 设置 constructor 属性为不可枚举
构造函数的类方法
添加在构造函数本身的方法,叫类方法
类方法可以在没有实例对象的情况下,调用函数
继承
继承
面向对象有三大特性:封装、继承、多态
封装:我们前面将属性和方法封装到一个类中,可以称之为封装的过程;
继承:继承是面向对象中非常重要的,不仅仅可以减少重复代码的数量,也是多态前提(纯面向对象中);
多态:不同的对象在执行时表现出不同的形态;
那么这里我们核心讲继承。
那么继承是做什么呢?
继承可以帮助我们将重复的代码和逻辑抽取到父类中,子类只需要直接继承过来使用即可;
在很多编程语言中,继承是多态的前提;
那么 JavaScript 当中如何实现继承呢?
不着急,我们先来看一下 JavaScript 原型链的机制;
再利用原型链的机制实现一下继承;
示例:
Student 类
Teacher 类
将共同的属性和方法抽取到父类中
原型链
JS 原型链
在真正实现继承之前,我们先来理解一个非常重要的概念:原型链。
我们知道,从一个对象上获取属性,如果在当前对象中没有获取到就会去它的原型上面获取:
const obj = {
name: "mr",
age: 18,
};
obj.__proto__ = {
// message: 'hello aaa'
};
obj.__proto__ = {
// message: 'hello bbb'
};
obj.__proto__.__proto__ = {
message: "hello ccc",
};
原型链查找顺序图:
const obj = {}
相当于 const obj = new Object()
,所以 obj 是有原型对象的,它的原型对象就是 Object 对象
Object 的原型
那么什么地方是原型链的尽头呢?比如第三个对象是否也是有原型__proto__属性呢?
我们会发现它打印的是 [Object: null prototype] {}
事实上这个原型就是我们最顶层的原型了
从 Object 直接创建出来的对象的原型都是 [Object: null prototype] {}。
那么我们可能会问题: [Object: null prototype] {} 原型有什么特殊吗?
特殊一:该对象有原型属性,但是它的原型属性已经指向的是null,也就是已经是顶层原型了;
特殊二:该对象上有很多默认的属性和方法;
内存图-创建 Object 对象
内存图-原型链关系
Object 是所有类的父类
从我们上面的 Object 原型我们可以得出一个结论:原型链最顶层的原型对象就是 Object 的原型对象
实现继承-原型链-继承方法
通过原型链实现继承
如果我们现在需要实现继承,那么就可以利用原型链来实现了:
目前 stu 的原型是 p 对象,而 p 对象的原型是 Person 默认的原型,里面包含 running 等函数;
注意:步骤 3 和步骤 4 不可以调整顺序,否则会有问题
定义父类 Person
///1、定义父类构造函数
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.running = function () {};
Person.prototype.eating = function () {};
实现继承: 创建一个父类的实例对象new Person()
, 用这个实例对象作为子类的原型对象
///2、定义子类构造函数
function Student(sno) {
this.sno = sno
}
// 3、创建父类实例,用它作为子类的原型对象
+ const p = new Person("mr", 18)
+ Student.prototype = p
// 4、为子类添加原型方法
Student.prototype.studying = function() {}
~~错误的实现继承做法:~~父类的原型直接赋值给子类的原型
问题:父类和子类共享同一个原型对象,修改了任意一个,另外一个也被修改了
原型链继承的弊端
但是目前有一个很大的弊端:某些属性其实是保存在 p 对象上的;
第一,我们通过直接打印对象是看不到这个属性的;
第二,这个属性会被多个对象共享,如果这个对象是一个引用类型,那么就会造成问题;
第三,不能给 Person 传递参数(让每个 stu 有自己的属性),因为这个对象是一次性创建的(没办法定制化);
实现继承-构造函数-继承属性
借用构造函数继承
为了解决原型链继承中存在的问题,开发人员提供了一种新的技术: constructor stealing(有很多名称: 借用构造函数或者称之为经典继承或者称之为伪造对象):
- steal 是偷窃、剽窃的意思,但是这里可以翻译成借用;
借用继承的做法非常简单:在子类型构造函数的内部调用父类型构造函数
- 因为函数可以在任意的时刻被调用;
- 因此通过 apply()和 call()方法也可以在新创建的对象上执行构造函数;
组合借用继承的问题
组合继承是 JavaScript 最常用的继承模式之一:
如果你理解到这里, 点到为止, 那么组合来实现继承只能说问题不大;
但是它依然不是很完美,但是基本已经没有问题了;
组合继承存在什么问题呢?
组合继承最大的问题就是无论在什么情况下,都会调用两次父类构造函数。
- 一次在创建子类原型的时候;
- 另一次在子类构造函数内部(也就是每次创建子类实例的时候);
另外,如果你仔细按照我的流程走了上面的每一个步骤,你会发现:所有的子类实例事实上会拥有两份父类的属性
- 一份在当前的实例自己里面(也就是 person 本身的),另一份在子类对应的原型对象中(也就是 person.__proto__里面);
- 当然,这两份属性我们无需担心访问出现问题,因为默认一定是访问实例本身这一部分的;
实现继承-寄生组合
原型式继承函数
原型式继承的渊源
这种模式要从道格拉斯·克罗克福德(Douglas Crockford,著名的前端大师,JSON 的创立者)在 2006 年写的一篇文章说起:Prototypal Inheritance in JavaScript(在 JavaScript 中使用原型式继承)
在这篇文章中,它介绍了一种继承方法,而且这种继承方法不是通过构造函数来实现的.
为了理解这种方式,我们先再次回顾一下 JavaScript 想实现继承的目的:重复利用另外一个对象的属性和方法.
最终的目的:student 对象的原型指向了 person 对象;
创建中间原型对象的方法:
方法一: 创建父类实例,用它作为子类的原型对象
方法二: 创建空对象,该对象的隐式原型指向父类的原型对象,同时子类的原型对象指向该对象
方法三:
方法四:
封装 1:
封装 2: 寄生组合式继承(最终方案):考虑兼容问题
寄生式继承函数
寄生式(Parasitic)继承
寄生式(Parasitic)继承是与原型式继承紧密相关的一种思想, 并且同样由道格拉斯·克罗克福德(Douglas Crockford)提出和推广的;
寄生式继承的思路是结合原型类继承和工厂模式的一种方式;
即创建一个封装继承过程的函数, 该函数在内部以某种方式来增强对象,最后再将这个对象返回;
寄生组合式继承
现在我们来回顾一下之前提出的比较理想的组合继承
组合继承是比较理想的继承方式, 但是存在两个问题:
问题一: 构造函数会被调用两次: 一次在创建子类型原型对象的时候, 一次在创建子类型实例的时候.
问题二: 父类型中的属性会有两份: 一份在原型对象中, 一份在子类型实例中.
事实上, 我们现在可以利用寄生式继承将这两个问题给解决掉
你需要先明确一点: 当我们在子类型的构造函数中调用父类型.call(this, 参数)这个函数的时候, 就会将父类型中的属性和方法复制一份到了子类型中. 所以父类型本身里面的内容, 我们不再需要.
这个时候, 我们还需要获取到一份父类型的原型对象中的属性和方法.
能不能直接让子类型的原型对象 = 父类型的原型对象呢?
不要这么做, 因为这么做意味着以后修改了子类型原型对象的某个引用类型的时候, 父类型原生对象的引用类型也会被修改.
我们使用前面的寄生式思想就可以了.
寄生组合继承的代码
▸终极方案:寄生组合式继承
使用到的知识点:原型链、借用构造函数、原型式继承(对象之间)、寄生式函数
1、寄生组合式继承
2、使用寄生组合式继承实现继承
3、打印结果
4、内存图
实现继承-ES6
对象的方法补充
- Object.prototype.hasOwnProperty(prop):
返回:boolean
,对象是否有某一个只属于自己的属性(不是在原型上的属性)- 参数
- prop:``,要测试的属性的字符串名称或者 Symbol
- 返回值
- 如果对象有指定属性作为自有属性,则返回
true
;否则返回false
。
- in:
返回:
,判断某个属性是否在某个对象自己或者对象的原型链上 - for...in:
返回:
,遍历某个对象自己上或者其原型链上所有可枚举的属性(除 Symbol 外) - instanceof:
返回:
,用于*检测构造函数*(Person、Student 类)的 pototype,是否出现在某个实例对象的原型链上 - Object.prototype.isPrototypeOf(obj):
返回:boolean
,用于*检测某个对象*,是否出现在某个实例对象的原型链上- 参数
- obj:``,要搜索其原型链的对象。
示例:
1、hasOwnProperty
2、in 操作符
3、for...in 操作符
4、instanceof
5、isPrototypeOf
原型继承关系
内存图
class 类-定义类
我们会发现,按照前面的构造函数形式创建 类,不仅仅和编写普通的函数过于相似,而且代码并不容易理解。
在ES6(ECMAScript2015)新的标准中使用了 class 关键字来直接定义类;
但是类本质上依然是前面所讲的构造函数、原型链的语法糖而已;
所以学好了前面的构造函数、原型链更有利于我们理解类的概念和继承关系;
那么,如何使用 class 来定义一个类呢?
- 可以使用两种方式来声明类:类声明和类表达式;
class 类-构造函数
如果我们希望在创建对象的时候给类传递一些参数,这个时候应该如何做呢?
每个类都可以有一个自己的构造函数(方法),这个方法的名称是固定的constructor;
当我们通过new操作符,操作一个类的时候会调用这个类的构造函数constructor;
每个类只能有一个构造函数,如果包含多个构造函数,那么会抛出异常;
当我们通过new关键字操作类的时候,会调用这个 constructor 函数,并且执行如下操作:
1、在内存中创建一个新的对象(空对象);
2、这个对象内部的[[prototype]]属性会被赋值为该类的 prototype 属性;
3、构造函数内部的 this,会指向创建出来的新对象;
4、执行构造函数的内部代码(函数体代码);
5、如果构造函数没有返回非空对象,则返回创建出来的新对象;
class 类-实例方法
在上面我们定义的属性都是直接放到了 this 上,也就意味着它是放到了创建出来的新对象中:
在前面我们说过对于实例的方法,我们是希望放到原型上的,这样可以被多个实例来共享;
这个时候我们可以直接在类中定义实例方法;
类中定义多个方法,不需要用
,
分割
▸class 类-访问器方法
我们之前讲对象的属性描述符时有讲过对象可以添加setter和getter函数的,那么类也是可以的
▸class 类-静态方法
静态方法通常用于定义直接使用类来执行的方法,不需要有类的实例,使用static 关键字来定义:
class 类和构造函数的异同
我们来研究一下类的一些特性:
- 你会发现它和我们的构造函数的特性其实是一致的;
1、构造函数定义的类
2、class 定义的类
3、相同点
4、不同点
构造函数可以当做普通的函数调用,而 class 类不能
ES6 类的继承-extends
前面我们花了很大的篇幅讨论了在 ES5 中实现继承的方案,虽然最终实现了相对满意的继承机制,但是过程却依然是非常繁琐的。
- 在 ES6 中新增了使用extends 关键字,可以方便的帮助我们实现继承:
ES6 类的继承-super
class 为我们的方法中提供了super关键字
- 执行*super.method(...)*来调用一个父类方法
- 执行*super(...)*来调用一个父类 constructor(只能在子类的 constructor 中执行 super)
我们会发现在上面的代码中我使用了一个 super 关键字,这个 super 关键字有不同的使用方式:
注意:在子(派生)类的构造函数中使用this或者返回默认对象之前,必须先通过 super 调用父类的构造函数!
super 的使用位置有三个:子类的构造方法、实例方法、静态方法;
示例:
1、在子类的构造方法中使用 super
2、在子类的实例方法中使用 super(方法重写)
3、在子类的静态方法中使用 super(方法重写)
继承内置类
我们也可以让我们的类继承自内置类,比如 Array:
▸类的混入-mixin
JavaScript 的类只支持单继承:也就是只能有一个父类
那么在开发中我们我们需要在一个类中添加更多相似的功能时,应该如何来做呢?
这个时候我们可以使用混入(mixin);
~~应用:~~React 中的高阶组件
Babel
babel-ES6 转 ES5(源码)
1、简单类 Person 转 ES5
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
running() {}
eating() {}
static radomPerson() {}
}
const p1 = new Person("tom", 18);
2、继承类 Person 转 ES5
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
running() {}
eating() {}
static radomPerson() {}
}
class Student extends Person {
constructor(name, age, sno, score) {
super(name, age);
this.sno = sno;
this.score = score;
}
studyding() {}
static radomStudent() {}
}
const stu1 = new Student("mr", 18, 110, 100);
ES5 代码
function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function");
}
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: { value: subClass, writable: true, configurable: true },
});
Object.defineProperty(subClass, "prototype", { writable: false });
if (superClass) _setPrototypeOf(subClass, superClass);
}
function _setPrototypeOf(o, p) {
_setPrototypeOf = Object.setPrototypeOf
? Object.setPrototypeOf.bind()
: function _setPrototypeOf(o, p) {
o.__proto__ = p; // Student.__proto__ = Person
return o;
};
return _setPrototypeOf(o, p);
}
function _createSuper(Derived) {
var hasNativeReflectConstruct = _isNativeReflectConstruct();
return function _createSuperInternal() {
var Super = _getPrototypeOf(Derived),
result;
if (hasNativeReflectConstruct) {
var NewTarget = _getPrototypeOf(this).constructor;
result = Reflect.construct(Super, arguments, NewTarget);
} else {
result = Super.apply(this, arguments);
}
return _possibleConstructorReturn(this, result);
};
}
function _possibleConstructorReturn(self, call) {
if (call && (_typeof(call) === "object" || typeof call === "function")) {
return call;
} else if (call !== void 0) {
throw new TypeError(
"Derived constructors may only return object or undefined"
);
}
return _assertThisInitialized(self);
}
function _assertThisInitialized(self) {
if (self === void 0) {
throw new ReferenceError(
"this hasn't been initialised - super() hasn't been called"
);
}
return self;
}
function _isNativeReflectConstruct() {
if (typeof Reflect === "undefined" || !Reflect.construct) return false;
if (Reflect.construct.sham) return false;
if (typeof Proxy === "function") return true;
try {
Boolean.prototype.valueOf.call(
Reflect.construct(Boolean, [], function () {})
);
return true;
} catch (e) {
return false;
}
}
function _getPrototypeOf(o) {
_getPrototypeOf = Object.setPrototypeOf
? Object.getPrototypeOf.bind()
: function _getPrototypeOf(o) {
return o.__proto__ || Object.getPrototypeOf(o);
};
return _getPrototypeOf(o);
}
function _typeof(obj) {
"@babel/helpers - typeof";
return (
(_typeof =
"function" == typeof Symbol && "symbol" == typeof Symbol.iterator
? function (obj) {
return typeof obj;
}
: function (obj) {
return obj &&
"function" == typeof Symbol &&
obj.constructor === Symbol &&
obj !== Symbol.prototype
? "symbol"
: typeof obj;
}),
_typeof(obj)
);
}
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor);
}
}
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
if (staticProps) _defineProperties(Constructor, staticProps);
Object.defineProperty(Constructor, "prototype", { writable: false });
return Constructor;
}
function _toPropertyKey(arg) {
var key = _toPrimitive(arg, "string");
return _typeof(key) === "symbol" ? key : String(key);
}
function _toPrimitive(input, hint) {
if (_typeof(input) !== "object" || input === null) return input;
var prim = input[Symbol.toPrimitive];
if (prim !== undefined) {
var res = prim.call(input, hint || "default");
if (_typeof(res) !== "object") return res;
throw new TypeError("@@toPrimitive must return a primitive value.");
}
return (hint === "string" ? String : Number)(input);
}
var Person = /*#__PURE__*/ (function () {
function Person(name, age) {
_classCallCheck(this, Person);
this.name = name;
this.age = age;
}
_createClass(
Person,
[
{
key: "running",
value: function running() {},
},
{
key: "eating",
value: function eating() {},
},
],
[
{
key: "radomPerson",
value: function radomPerson() {},
},
]
);
return Person;
})();
var Student = /*#__PURE__*/ (function (_Person) {
_inherits(Student, _Person);
var _super = _createSuper(Student);
function Student(name, age, sno, score) {
var _this;
_classCallCheck(this, Student);
_this = _super.call(this, name, age);
_this.sno = sno;
_this.score = score;
return _this;
}
_createClass(
Student,
[
{
key: "studyding",
value: function studyding() {},
},
],
[
{
key: "radomStudent",
value: function radomStudent() {},
},
]
);
return Student;
})(Person);
var stu1 = new Student("mr", 18, 110, 100);
多态
JavaScript 中的多态
面向对象的三大特性:封装、继承、多态。
- 前面两个我们都已经详细解析过了,接下来我们讨论一下 JavaScript 的多态。
JavaScript 有多态吗?
维基百科对多态的定义:多态(英语:polymorphism)指为不同数据类型的实体提供统一的接口,或使用一个单一的符号来表示多个不同的类型。
非常的抽象,个人的总结:不同的数据类型进行同一个操作,表现出不同的行为,就是多态的体现。
那么从上面的定义来看,JavaScript是一定存在多态的。
1、JS中的多态
2、严格语言中的多态条件:
1、必须有继承或接口
2、必须有父类引用指向子类对象
对象字面量增强
1、属性的简写
2、方法的简写
3、计算属性名
解构
1、数组的解构
- 基本使用
- 顺序问题:有严格的顺序
- 解构出数组
- 解构的默认值
2、对象的解构
- 基本使用
- 顺序问题:对象的解构没有顺序,是根据key来解构的
- 重命名变量
- 默认值
- 对象的剩余内容
3、解构的应用
Symbol
symbol 是一种基本数据类型。
语法
Symbol(description?)
参数
- description:
string
,对 symbol 的描述,可用于调试但不是访问 symbol 本身
示例
// 1. 通过Symbol函数创建一个Symbol
const s1 = Symbol();
// 2. 创建的时候传入一个description
const s2 = Symbol("s2");
// 3. Symbol函数每次创建出来的值都是独一无二的
console.log(Symbol() == Symbol()); // falses
console.log(Symbol() === Symbol()); // false
// 4. Symbol作为对象属性的标识符
const obj = {
[s1]: "name",
[s2]: "age",
};
console.log(obj); // {Symbol(): 'name', Symbol(s2): 'age'}
// 5. 获取Symbol对应的key
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(), Symbol(s2)]
// 6. Symbol.for(key)
const s3 = Symbol.for("s3");
console.log(s3); // Symbol(s3)
// 7. 相同的key,通过`Symbol.for()`可以生成相同的Symbol值
const s4 = Symbol.for("ss");
const s5 = Symbol.for("ss");
console.log(s4 === s5); // true
// 8. 通过`Symbol.keyFor()` 可以获取通过Symbol.for()传入的key
console.log(Symbol.keyFor(s2)); // undefined
console.log(Symbol.keyFor(s5)); // ss
API
- Symbol(description?):``,创建一个 Symbol
- Object.getOwnPropertySymbols(obj):``,返回一个给定对象自身的所有 Symbol 属性的数组
- 属性
- Symbol.prototype.description:``,(只读),返回 Symbol 对象的可选描述的字符串
- 方法
- Symbol.for(key):
,会根据给定的键
key
,来从运行时的 symbol 注册表中找到对应的 symbol,如果找到了,则返回它,否则,新建一个与该键关联的 symbol - Symbol.keyFor(sym):``,用来获取全局 symbol 注册表中与某个 symbol 关联的键 key
基本使用
Symbol 是什么呢?Symbol 是 ES6 中新增的一个基本数据类型,翻译为符号。
那么为什么需要 Symbol 呢?
- 在 ES6 之前,对象的属性名都是字符串形式,那么很容易造成属性名的冲突;
- 比如原来有一个对象,我们希望在其中添加一个新的属性和值,但是我们在不确定它原来内部有什么内容的情况下,很容易造成冲突,从而覆盖掉它内部的某个属性;
- 比如我们前面在讲 apply、call、bind 实现时,我们有给其中添加一个 fn 属性,那么如果它内部原来已经有了 fn 属性了呢?
- 比如开发中我们使用混入,那么混入中出现了同名的属性,必然有一个会被覆盖掉;
Symbol 就是为了解决上面的问题,用来生成一个独一无二的值。
- Symbol 值是通过 Symbol 函数来生成的,生成后可以作为属性名;这是该数据类型仅有的目的
- 也就是在 ES6 中,对象的属性名可以使用字符串,也可以使用 Symbol 值;
Symbol 即使多次创建值,它们也是不同的:Symbol 函数执行后每次创建出来的值都是独一无二的;
我们也可以在创建 Symbol 值的时候传入一个描述 description:这个是 ES2019(ES10)新增的特性;
Symbol 作为属性名
我们通常会使用 Symbol 在对象中表示唯一的属性名
相同值的 Symbol
前面我们讲 Symbol 的目的是为了创建一个独一无二的值,那么如果我们现在就是想创建相同的 Symbol应该怎么来做呢?
- 我们可以使用 Symbol.for 方法来做到这一点
- 并且我们可以通过 Symbol.keyFor 方法来获取对应的 key
相同的 key,通过Symbol.for()
可以生成相同的 Symbol 值
const s4 = Symbol.for("ss");
const s5 = Symbol.for("ss");
console.log(s4 === s5); // true
通过Symbol.keyFor()
可以获取通过 Symbol.for()传入的 key
console.log(Symbol.keyFor(s2)); // undefined
console.log(Symbol.keyFor(s5)); // ss
Set
Set 对象允许你存储任何类型的唯一值,无论是原始值或者是对象引用。
语法
new Set(iterable?)
参数
- iterable:``,如果传递一个可迭代对象,它的所有元素将不重复地被添加到新的 Set 中
返回值
- 一个新的
Set
对象
示例
const mySet = new Set();
mySet.add(1); // Set [ 1 ]
mySet.add(5); // Set [ 1, 5 ]
mySet.add(5); // Set [ 1, 5 ]
mySet.add("some text"); // Set [ 1, 5, 'some text' ]
const o = { a: 1, b: 2 };
mySet.add(o);
API
- 属性
- size:``,返回 Set 中元素的个数
- 方法
- add(value):
返回:Set对象
,添加某个元素 - delete(value):
返回:Boolean
,从 set 中删除和这个值相等的元素 - has(value):
返回:Boolean
,判断 set 中是否存在某个元素 - clear():
返回:void
,清空 set 中所有的元素 - forEach(callback, thisArg?):
返回:undefined
,通过 forEach 遍历 set- 参数
- callback:
function(value?, key?, set?)
,为集合中每个元素执行的回调函数 - thisArg:
,在执行
callback
时作为this
使用
注意:Set 支持 for of 的遍历
常见方法
添加元素
// 2. 添加Set - add()
set.add("Tom");
console.log(set); // Set(1) {'Tom'}
// 3. Set中不能放入重复的元素
set.add("Jack");
set.add("Jack");
console.log(set); // Set(2) {'Tom', 'Jack'}
删除元素
// 5. 常见方法 - delete()
console.log(set); // Set(2) {'Tom', 'Jack'}
set.delete("Tom");
console.log(set); // Set(1) {'Jack'}
是否包含某个元素
// 6. 常见方法 - has()
console.log(set.has("Jack")); // true
清空 set
// 7. 常见方法 - clear()
set.clear();
console.log(set); // Set(0) {size: 0}
forEach 遍历
// 8. 常见方法 - forEach()
set2.forEach((item, index, set) => {
console.log(item, index, set); // 刘备 刘备 Set(4) {'刘备', '关羽', '张飞', '吕布'}
});
基本使用
在 ES6 之前,我们存储数据的结构主要有两种:数组、对象。
- 在 ES6 中新增了另外两种数据结构:Set、Map,以及它们的另外形式 WeakSet、WeakMap。
Set 是一个新增的数据结构,可以用来保存数据,类似于数组,但是和数组的区别是元素不能重复。
- 创建 Set 我们需要通过 Set 构造函数(暂时没有字面量创建的方式):
我们可以发现 Set 中存放的元素是不会重复的,那么 Set 有一个非常常用的功能就是给数组去重。
创建 Set
// 1. 创建Set
const set = new Set();
console.log(set); // Set(0) {size: 0}
2 个空对象不是重复的元素
// 10. 2个空对象不是重复的元素
const set4 = new Set();
set4.add({});
set4.add({});
console.log(set4); // Set(2) {{…}, {…}}
应用:数组去重
// 4. 应用:数组去重
const arr = ["刘备", "关羽", "张飞", "吕布", "关羽", "刘备"];
const set2 = new Set(arr);
console.log(set2); // Set(4) {'刘备', '关羽', '张飞', '吕布'}
const set3 = Array.from(set2);
console.log(set3); // (4) ['刘备', '关羽', '张飞', '吕布']
// 简单写法一
console.log(Array.from(new Set(arr))); // (4) ['刘备', '关羽', '张飞', '吕布']
// 或者写法二
console.log([...new Set(arr)]); // (4) ['刘备', '关羽', '张飞', '吕布']
之前数组去重的做法
set 支持 for...of 遍历
只要是可迭代对象都可以通过 for...of 遍历
// 9. 通过for...of遍历Set
for (const item of set2) {
console.log(item); // 刘备 关羽 张飞 吕布
}
WeakSet
WeakSet 对象允许你将弱保持对象存储在一个集合中
语法
API
- 方法
- add(value):
返回:WeakSet对象
,添加某个元素 - delete(value):
返回:Boolean
,从 WeakSet 中删除和这个值相等的元素 - has(value):
返回:Boolean
,判断 WeakSet 中是否存在某个元素
基本使用
和 Set 类似的另外一个数据结构称之为 WeakSet,也是内部元素不能重复的数据结构。
那么和 Set有什么区别呢?
- 区别一:WeakSet 中只能存放对象类型,不能存放基本数据类型;
- 区别二:WeakSet对元素的引用是弱引用,如果没有其他引用对某个对象进行引用,那么 GC 可以对该对象进行回收;
WeakSet 中只能存放对象类型
普通对象的内存图
解释:普通对象被重新赋值为 null 时,就断开了和内存中对象的联系,但是由于之前已经将对象的内存地址赋值给了数组 arr,赋值为 null 后这些对象依然被数组 arr 所引用,所以它们并不会被销毁
WeakSet 内存图
解释: 添加到 WeakSet 中的对象都是弱引用,可能会被 GC 随时回收
注意:WeakSet 不能遍历
因为 WeakSet 只是对对象的弱引用,如果我们遍历获取到其中的元素,那么有可能造成对象不能正常的销毁。
所以存储到 WeakSet 中的对象是没办法获取的;
应用:限制类中方法的调用者
- 事实上这个问题并不好回答,我们来使用一个 Stack Overflow 上的答案;
此处用 WeakSet 的好处:想要销毁实例对象 p 的时候,可以直接通过p = null
销毁,如果使用 Set 的话,由于实例对象一直被 Set 引用,所以无法销毁
Map
基本使用
另外一个新增的数据结构是 Map,用于存储映射关系。
但是我们可能会想,在之前我们可以使用对象来存储映射关系,他们有什么区别呢?
事实上我们对象存储映射关系只能用字符串(ES6 新增了 Symbol)作为属性名(key);
某些情况下我们可能希望通过其他类型作为 key,比如对象,这个时候会自动将对象转成字符串来作为 key;
那么我们就可以使用 Map
常用方法
Map 常见的属性:
- size:返回 Map 中元素的个数;
Map 常见的方法:
- set(key, value):在 Map 中添加 key、value,并且返回整个 Map 对象;
- get(key):根据 key 获取 Map 中的 value;
- has(key):判断是否包括某一个 key,返回 Boolean 类型;
- delete(key):根据 key 删除一个键值对,返回 Boolean 类型;
- clear():清空所有的元素;
- forEach(callback, [, thisArg]):通过 forEach 遍历 Map;
Map 也可以通过 for of 进行遍历。
WeakMap
基本使用
和 Map 类型的另外一个数据结构称之为 WeakMap,也是以键值对的形式存在的。
那么和 Map 有什么区别呢?
- 区别一:WeakMap 的 key 只能使用对象,不接受其他的类型作为 key;
- 区别二:WeakMap 的 key 对对象想的引用是弱引用,如果没有其他引用引用这个对象,那么 GC 可以回收该对象;
WeakMap 常见的方法有四个:
- set(key, value):在 Map 中添加 key、value,并且返回整个 Map 对象;
- get(key):根据 key 获取 Map 中的 value;
- has(key):判断是否包括某一个 key,返回 Boolean 类型;
- delete(key):根据 key 删除一个键值对,返回 Boolean 类型;
应用
注意:WeakMap 也是不能遍历的
- 没有 forEach 方法,也不支持通过 for of 的方式进行遍历;
那么我们的 WeakMap 有什么作用呢?(后续专门讲解)
手写
手写 call,aplly,bind
函数对象原型关系
函数 foo 对象的隐式原型 === Function 的显式原型
// 函数foo对象的隐式原型 === Function的显式原型
console.log(foo.__proto__ === Function.prototype); // true
console.log(Function.prototype.apply); // f apply()
console.log(Function.prototype.call); // f call()
console.log(Function.prototype.bind); // f bind()
console.log(Function.prototype.apply === foo.apply); // true
结论:
- foo对象中的某些属性和方法是来自 Function.prototype 的
- 在 Function.prototype 中添加的属性和方法,可以被所有的函数获取
在 Function 的原型中添加方法 bar
手写 apply 方法
给函数对象添加方法
function foo() {
console.log("foo", this);
}
Function.prototype.mrapply = function (mrthis) {
// 相当于 mrthis.fn = this
Object.defineProperty(mrthis, "fn", {
configurable: true,
value: this,
});
// 隐式调用fn,可以让fn函数的this指向 mrthis
mrthis.fn();
// 删除多出来的临时函数fn
delete mrthis.fn;
};
foo.mrapply({ name: "Tom" });
如果传入的参数是一个 String 或者 Number 的类型,需要将其包裹成对象类型,才能在它上面添加属性
调用 mrapply 时,传递参数
function foo (age, height) {
console.log('foo', this, age, height)
}
+ Function.prototype.mrapply = function(mrthis, args) {
// 当this不是对象时,需要用Object包裹
mrthis = (mrthis === null || mrthis === undefined) ? window : Object(mrthis)
// 相当于 mrthis.fn = this
Object.defineProperty(mrthis, 'fn', {
configurable: true,
value: this
})
// 隐式调用fn,可以让fn函数的this指向 mrthis
+ mrthis.fn(...args)
// 删除多出来的临时函数fn
delete mrthis.fn
}
+ foo.mrapply({name: "Tom"}, [18, 1.88])
foo.mrapply(null, [18, 1.88])
foo.mrapply(undefined, [18, 1.88])
foo.mrapply(true, [18, 1.88])
foo.mrapply(123, [18, 1.88])
foo.mrapply('aaaa', [18, 1.88])
手写 call 方法
function foo(age, height) {
console.log('foo', this, age, height)
}
+ Function.prototype.mrcall = function(mrthis, ...args) {
mrthis = (mrthis === null || mrthis === undefined) ? window : Object(mrthis)
Object.defineProperty(mrthis, 'fn', {
configurable: true,
value: this
})
+ mrthis.fn(...args)
delete mrthis.fn
}
+ foo.mrcall({ name: "张飞" }, 20, 1.77)
抽取封装公共函数
/* 抽取封装的函数 */
+ Function.prototype.mrexec = function(mrthis, args) {
mrthis = (mrthis === null || mrthis === undefined) ? window : Object(mrthis)
// mrthis.fn = this
Object.defineProperty(mrthis, 'fn', {
configurable: true,
value: this
})
mrthis.fn(...args)
delete mrthis.fn
}
/* 手写apply */
Function.prototype.mrapply = function(mrthis, args) {
this.mrexec(mrthis, args)
}
/* 手写call */
Function.prototype.mrcall = function(mrthis, ...args) {
this.mrexec(mrthis, args)
}
// 测试
function foo(age, height) {
console.log('foo', this, age, height)
}
foo.mrapply({name: "Tom"}, [19, 1.66])
foo.mrcall({name: "Jack"}, 22, 1.99)
手写 bind 方法
和 apply, call 不同,bind 执行后是返回一个新的函数 newFoo
基础实现
思路:想办法实现如下:
// 伪代码
{ name: "why" }.foo(name, age)
/* 手写bind */
Function.prototype.mrbind = function(mrthis, ...args) {
+ return (...moreArgs) => {
mrthis = (mrthis === null || mrthis === undefined) ? window : Object(mrthis)
Object.defineProperty(mrthis, 'fn', {
configurable: true,
value: this
})
+ const allArgs = [...args, ...moreArgs]
+ mrthis.fn(...allArgs)
+ delete mrthis.fn // 可以删除fn,因为每次调用newFoo,都会重新生成一个mrthis.fn
}
}
// 测试
function foo(name, age, height, address) {
console.log('foo', this, name, age, height, address)
}
const newFoo = foo.mrbind({name: "Jerry"}, '张飞', 45)
console.log(newFoo)
+ newFoo(1.88, '成都')
+ newFoo(1.88, '成都')
浅拷贝,深拷贝
引用赋值
浅拷贝
方式:
- 解构赋值:
const info = {...obj}
浅拷贝修改 info2.name 后,obj 的 name 依然是"why",被修改的只是 info2
浅拷贝的内存图
如果 obj 对象中有**其他对象(或数组)**时的内存图
深拷贝
方式:
- 1、借助第三方库:
underscore
- 2、利用现有 JS 机制:
JSON
- 3、自己实现:
2、利用现有 JS 机制:JSON
语法:
const info3 = JSON.parse(JSON.stringify(obj));
缺点: 该方法不能实现方法的深拷贝,会忽略 obj 对象中的方法
const obj = {
name: 'Tom',
age: 18,
friend: {
name: 'Jack'
},
run: function() {
console.log(this.name + '在跑步~');
}
}
// 利用JSON机制实现深拷贝
+ const info = JSON.parse(JSON.stringify(obj))
// 测试
console.log(info)
// 修改info的深度属性,obj的深度属性保持不变
+ info.friend.name = '张飞'
+ console.log('obj', obj.friend.name); // obj Jack
+ console.log('info', info.friend.name); // obj 张飞
// 不能实现方法的深拷贝,会忽略obj对象中的方法
+ info.run() // ncaught TypeError: info.run is not a function