数据类型检测
typeof
console.log(typeof undefined); // undefined
console.log(typeof []); // object
console.log(typeof {}); // object
console.log(typeof function(){}); // function
console.log(typeof null); // object
优点:能够快速区分基本数据类型
缺点:不能将Object、Array和Null区分,都返回object
instanceof
console.log(2 instanceof Number); // false
console.log(true instanceof Boolean); // false
console.log('str' instanceof String); // false
console.log([] instanceof Array); // true
console.log(function(){} instanceof Function); // true
console.log({} instanceof Object); // true
优点:能够区分Array、Object和Function,适合用于判断自定义的类实例对象
缺点:Number,Boolean,String基本数据类型不能判断
instanceof 可以正确的判断对象的类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的 iprototype
// 我们也可以试着实现一下 instanceof
function instanceof(left, right) {
// 获得类型的原型
let prototype = right.prototype
// 获得对象的原型
left = left.__proto__
// 判断对象的类型是否等于类型的原型
while (true) {
if (left === null)
return false
if (prototype === left)
return true
left = left.__proto__
}
}
Object.prototype.toString.call()
var toString = Object.prototype.toString;
console.log(toString.call(2)); //[object Number]
console.log(toString.call(true)); //[object Boolean]
console.log(toString.call('str')); //[object String]
console.log(toString.call([])); //[object Array]
console.log(toString.call(function(){})); //[object Function]
console.log(toString.call({})); //[object Object]
console.log(toString.call(undefined)); //[object Undefined]
console.log(toString.call(null)); //[object Null]
优点:精准判断数据类型
缺点:写法繁琐不容易记,推荐进行封装后使用
判断是否是promise对象
function isPromise (val) {
return (
typeof val.then === 'function' &&
typeof val.catch === 'function'
)
}
总结
typeof- 直接在计算机底层基于数据类型的值(二进制)进行检测
typeof null为object原因是对象存在在计算机中,都是以000开始的二进制存储,所以检测出来的结果是对象typeof普通对象/数组对象/正则对象/日期对象 都是objecttypeof NaN === 'number'
instanceof- 检测当前实例是否属于这个类的
- 底层机制:只要当前类出现在实例的原型上,结果都是true
- 不能检测基本数据类型
constructor- 支持基本类型
- constructor可以随便改,也不准
Object.prototype.toString.call([val])- 返回当前实例所属类信息
变量提升(Hoisting)
不过在介绍变量提升之前,我们先通过下面这段代码,来看看什么是JavaScript中的声明和赋值。
var myname = '极客时间'
这段代码你可以把它看成是两行代码组成的:
var myname //声明部分
myname = '极客时间' //赋值部分
上面是变量的声明和赋值,那接下来我们再来看看函数的声明和赋值,结合下面这段代码:
function foo(){
console.log('foo')
}
var bar = function(){
console.log('bar')
}
第一个函数foo是一个完整的函数声明,也就是说没有涉及到赋值操作;第二个函数是先声明变量bar,再把function(){console.log('bar')}赋值给bar。
好了,理解了声明和赋值操作,那接下来我们就可以聊聊什么是变量提升了。
所谓的变量提升,是指在JavaScript代码执行过程中,JavaScript引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的undefined。
下面我们来模拟下实现:
/*
* 变量提升部分
*/
// 把变量 myname提升到开头,
// 同时给myname赋值为undefined
var myname = undefined
// 把函数showName提升到开头
function showName() {
console.log('showName被调用');
}
/*
* 可执行代码部分
*/
showName()
console.log(myname)
// 去掉var声明部分,保留赋值语句
myname = '极客时间'
为了模拟变量提升的效果,我们对代码做了以下调整,如下图:
从图中可以看出,对原来的代码主要做了两处调整:
- 第一处是把声明的部分都提升到了代码开头,如变量
myname和函数showName,并给变量设置默认值undefined; - 第二处是移除原本声明的变量和函数,如var myname = '极客时间'的语句,移除了
var声明,整个移除showName的函数声明。 - 通过这两步,就可以实现变量提升的效果。你也可以执行这段模拟变量提升的代码,其输出结果和第一段代码应该是完全一样的。
JavaScript代码的执行流程
从概念的字面意义上来看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,正如我们所模拟的那样。但,这并不准确。实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被JavaScript引擎放入内存中。对,你没听错,一段JavaScript代码在执行之前需要被JavaScript引擎编译,编译完成之后,才会进入执行阶段。大致流程你可以参考下图:
编译阶段
那么编译阶段和变量提升存在什么关系呢?
为了搞清楚这个问题,我们还是回过头来看上面那段模拟变量提升的代码,为了方便介绍,可以把这段代码分成两部分。
第一部分:变量提升部分的代码。
var myname = undefined
function showName() {
console.log('函数showName被执行');
}
第二部分:执行部分的代码。
showName()
console.log(myname)
myname = '极客时间'
下面我们就可以把JavaScript的执行流程细化,如下图所示:
从上图可以看出,输入一段代码,经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。
你可以简单地把变量环境对象看成是如下结构
VariableEnvironment:
myname -> undefined,
showName ->function : {console.log(myname)
了解完变量环境对象的结构后,接下来,我们再结合下面这段代码来分析下是如何生成变量环境对象的。
showName()
console.log(myname)
var myname = '极客时间'
function showName() {
console.log('函数showName被执行');
}
我们可以一行一行来分析上述代码:
- 第1行和第2行,由于这两行代码不是声明操作,所以JavaScript引擎不会做任何处理;
- 第3行,由于这行是经过var声明的,因此JavaScript引擎将在环境对象中创建一个名为myname的属性,并使用undefined对其初始化;
- 第4行,JavaScript引擎发现了一个通过function定义的函数,所以它将函数定义存储到堆(HEAP)中,并在环境对象中创建一个showName的属性,然后将该属性值指向堆中函数的位置(不了解堆也没关系,JavaScript的执行堆和执行栈我会在后续文章中介绍)。 这样就生成了变量环境对象。接下来JavaScript引擎会把声明以外的代码编译为字节码,至于字节码的细节,我也会在后面文章中做详细介绍,你可以类比如下的模拟代码
showName()
console.log(myname)
myname = '极客时间'
好了,现在有了执行上下文和可执行代码了,那么接下来就到了执行阶段了。
执行阶段
JavaScript引擎开始执行“可执行代码”,按照顺序一行一行地执行。下面我们就来一行一行分析下这个执行过程
- 当执行到showName函数时,JavaScript引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以JavaScript引擎便开始执行该函数,并输出“函数showName被执行”结果。
- 接下来打印“myname”信息,JavaScript引擎继续在变量环境对象中查找该对象,由于变量环境存在myname变量,并且其值为undefined,所以这时候就输出undefined。
- 接下来执行第3行,把“极客时间”赋给myname变量,赋值后变量环境中的myname属性值改变为“极客时间”,变量环境如下所示:
VariableEnvironment:
myname -> "极客时间",
showName ->function : {console.log(myname)
代码中出现相同的变量或者函数怎么办?
现在你已经知道了,在执行一段JavaScript代码之前,会编译代码,并将代码中的函数和变量保存到执行上下文的变量环境中,那么如果代码中出现了重名的函数或者变量,JavaScript引擎会如何处理?
我们先看下面这样一段代码
function showName() {
console.log('极客邦');
}
showName();
function showName() {
console.log('极客时间');
}
showName();
在上面代码中,我们先定义了一个showName的函数,该函数打印出来“极客邦”;然后调用showName,并定义了一个showName函数,这个showName函数打印出来的是“极客时间”;最后接着继续调用showName。那么你能分析出来这两次调用打印出来的值是什么吗?
我们来分析下其完整执行流程:
- 首先是编译阶段。遇到了第一个showName函数,会将该函数体存放到变量环境中。接下来是第二个showName函数,继续存放至变量环境中,但是变量环境中已经存在一个showName函数了,此时,第二个showName函数会将第一个showName函数覆盖掉。这样变量环境中就只存在第二个showName函数了。
- 接下来是执行阶段。先执行第一个showName函数,但由于是从变量环境中查找showName函数,而变量环境中只保存了第二个showName函数,所以最终调用的是第二个函数,打印的内容是“极客时间”。第二次执行showName函数也是走同样的流程,所以输出的结果也是“极客时间”
综上所述,一段代码如果定义了两个相同名字的函数,那么最终生效的是最后一个函数。
执行上下文
当执行 JS 代码时,会产生三种执行上下文
- 全局执行上下文
- 函数执行上下文
eval执行上下文
每个执行上下文中都有三个重要的属性
- 变量对象(
VO),包含变量、函数声明和函数的形参,该属性只能在全局上下文中访问 - 作用域链(
JS采用词法作用域,也就是说变量的作用域是在定义时就决定了) this
var a = 10
function foo(i) {
var b = 20
}
foo()
对于上述代码,执行栈中有两个上下文:全局上下文和函数 foo 上下文。
stack = [
globalContext,
fooContext
]
对于全局上下文来说,VO大概是这样的
globalContext.VO === globe
globalContext.VO = {
a: undefined,
foo: <Function>,
}
对于函数 foo 来说,VO 不能访问,只能访问到活动对象(AO)
fooContext.VO === foo.AO
fooContext.AO {
i: undefined,
b: undefined,
arguments: <>
}
// arguments 是函数独有的对象(箭头函数没有)
// 该对象是一个伪数组,有 `length` 属性且可以通过下标访问元素
// 该对象中的 `callee` 属性代表函数本身
// `caller` 属性代表函数的调用者
对于作用域链,可以把它理解成包含自身变量对象和上级变量对象的列表,通过 [[Scope]]属性查找上级变量
fooContext.[[Scope]] = [
globalContext.VO
]
fooContext.Scope = fooContext.[[Scope]] + fooContext.VO
fooContext.Scope = [
fooContext.VO,
globalContext.VO
]
接下来让我们看一个老生常谈的例子,var
b() // call b
console.log(a) // undefined
var a = 'Hello world'
function b() {
console.log('call b')
}
想必以上的输出大家肯定都已经明白了,这是因为函数和变量提升的原因。通常提升的解释是说将声明的代码移动到了顶部,这其实没有什么错误,便于大家理解。但是更准确的解释应该是:在生成执行上下文时,会有两个阶段。第一个阶段是创建的阶段(具体步骤是创建 VO),JS 解释器会找出需要提升的变量和函数,并且给他们提前在内存中开辟好空间,函数的话会将整个函数存入内存中,变量只声明并且赋值为 undefined,所以在第二个阶段,也就是代码执行阶段,我们可以直接提前使用。
- 在提升的过程中,相同的函数会覆盖上一个函数,并且函数优先于变量提升
b() // call b second
function b() {
console.log('call b fist')
}
function b() {
console.log('call b second')
}
var b = 'Hello world'
var会产生很多错误,所以在 ES6中引入了 let。let不能在声明前使用,但是这并不是常说的 let 不会提升,let 提升了声明但没有赋值,因为临时死区导致了并不能在声明前使用。
- 对于非匿名的立即执行函数需要注意以下一点
var foo = 1
(function foo() {
foo = 10
console.log(foo)
}()) // -> ƒ foo() { foo = 10 ; console.log(foo) }
因为当 JS 解释器在遇到非匿名的立即执行函数时,会创建一个辅助的特定对象,然后将函数名称作为这个对象的属性,因此函数内部才可以访问到 foo,但是这个值又是只读的,所以对它的赋值并不生效,所以打印的结果还是这个函数,并且外部的值也没有发生更改。
specialObject = {};
Scope = specialObject + Scope;
foo = new FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}
delete Scope[0]; // remove specialObject from the front of scope chain
小结
执行上下文可以简单理解为一个对象:
它包含三个部分:
- 变量对象(
VO) - 作用域链(词法作用域)
this指向
它的类型:
- 全局执行上下文
- 函数执行上下文
eval执行上下文
代码执行过程:
- 创建 全局上下文 (
global EC) - 全局执行上下文 (
caller) 逐行 自上而下 执行。遇到函数时,函数执行上下文 (callee) 被push到执行栈顶层 - 函数执行上下文被激活,成为
active EC, 开始执行函数中的代码,caller被挂起 - 函数执行完后,
callee被pop移除出执行栈,控制权交还全局上下文 (caller),继续执行
调用栈
JavaScript引擎正是利用栈的这种结构来管理执行上下文的。在执行上下文创建好后,JavaScript引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈。
为便于你更好地理解调用栈,下面我们再来看段稍微复杂点的示例代码:
var a = 2
function add(b,c){
return b+c
}
function addAll(b,c){
var d = 10
result = add(b,c)
return a+result+d
}
addAll(3,6)
在上面这段代码中,你可以看到它是在addAll函数中调用了add函数,那在整个代码的执行过程中,调用栈是怎么变化的呢?
下面我们就一步步地分析在代码的执行过程中,调用栈的状态变化情况。
第一步,创建全局上下文,并将其压入栈底。如下图所示
从图中你也可以看出,变量a、函数add和addAll都保存到了全局上下文的变量环境对象中。
全局执行上下文压入到调用栈后,JavaScript引擎便开始执行全局代码了。首先会执行a=2的赋值操作,执行该语句会将全局上下文变量环境中a的值设置为2。设置后的全局上下文的状态如下图所示:
接下来,第二步是调用addAll函数。当调用该函数时,JavaScript引擎会编译该函数,并为其创建一个执行上下文,最后还将该函数的执行上下文压入栈中,如下图所示:
addAll函数的执行上下文创建好之后,便进入了函数代码的执行阶段了,这里先执行的是d=10的赋值操作,执行语句会将addAll函数执行上下文中的d由undefined变成了10。
然后接着往下执行,第三步,当执行到add函数调用语句时,同样会为其创建执行上下文,并将其压入调用栈,如下图所示:
当add函数返回时,该函数的执行上下文就会从栈顶弹出,并将result的值设置为add函数的返回值,也就是9。如下图所示:
紧接着addAll执行最后一个相加操作后并返回,addAll的执行上下文也会从栈顶部弹出,此时调用栈中就只剩下全局上下文了。最终如下图所示:
至此,整个JavaScript流程执行结束了。
好了,现在你应该知道了调用栈是JavaScript引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈就能够追踪到哪个函数正在被执行以及各函数之间的调用关系。
在开发中,如何利用好调用栈
鉴于调用栈的重要性和实用性,那么接下来我们就一起来看看在实际工作中,应该如何查看和利用好调用栈。
如何利用浏览器查看调用栈的信息
当你执行一段复杂的代码时,你可能很难从代码文件中分析其调用关系,这时候你可以在你想要查看的函数中加入断点,然后当执行到该函数时,就可以查看该函数的调用栈了。
这么说可能有点抽象,这里我们拿上面的那段代码做个演示,你可以打开“开发者工具”,点击“Source”标签,选择JavaScript代码的页面,然后在第3行加上断点,并刷新页面。你可以看到执行到add函数时,执行流程就暂停了,这时可以通过右边“call stack”来查看当前的调用栈的情况,如下图:
从图中可以看出,右边的“call stack”下面显示出来了函数的调用关系:栈的最底部是anonymous,也就是全局的函数入口;中间是addAll函数;顶部是add函数。这就清晰地反映了函数的调用关系,所以在分析复杂结构代码,或者检查Bug时,调用栈都是非常有用的。
除了通过断点来查看调用栈,你还可以使用console.trace()来输出当前的函数调用关系,比如在示例代码中的add函数里面加上了console.trace(),你就可以看到控制台输出的结果,如下图:
栈溢出(Stack Overflow)
现在你知道了调用栈是一种用来管理执行上下文的数据结构,符合后进先出的规则。不过还有一点你要注意,调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript引擎就会报错,我们把这种错误叫做栈溢出。
特别是在你写递归代码的时候,就很容易出现栈溢出的情况。比如下面这段代码:
function division(a,b){
return division(a,b)
}
console.log(division(1,2))
当执行时,就会抛出栈溢出错误,如下图:
从上图你可以看到,抛出的错误信息为:超过了最大栈调用大小(Maximum call stack size exceeded)。
那为什么会出现这个问题呢?这是因为当JavaScript引擎开始执行这段代码时,它首先调用函数division,并创建执行上下文,压入栈中;然而,这个函数是递归的,并且没有任何终止条件,所以它会一直创建新的函数执行上下文,并反复将其压入栈中,但栈是有容量限制的,超过最大数量后就会出现栈溢出的错误。
理解了栈溢出原因后,你就可以使用一些方法来避免或者解决栈溢出的问题,比如把递归调用的形式改造成其他形式,或者使用加入定时器的方法来把当前任务拆分为其他很多小任务。
作用域
- 作用域: 作用域是定义变量的区域,它有一套访问变量的规则,这套规则来管理浏览器引擎如何在当前作用域以及嵌套的作用域中根据变量(标识符)进行变量查找
- 作用域链: 作用域链的作用是保证对执行环境有权访问的所有变量和函数的有序访问,通过作用域链,我们可以访问到外层环境的变量和 函数。
作用域链的本质上是一个指向变量对象的指针列表。变量对象是一个包含了执行环境中所有变量和函数的对象。作用域链的前 端始终都是当前执行上下文的变量对象。全局执行上下文的变量对象(也就是全局对象)始终是作用域链的最后一个对象。
- 当我们查找一个变量时,如果当前执行环境中没有找到,我们可以沿着作用域链向后查找
- 作用域链的创建过程跟执行上下文的建立有关....
作用域可以理解为变量的可访问性,总共分为三种类型,分别为:
- 全局作用域
- 函数作用域
- 块级作用域,ES6 中的
let、const就可以产生该作用域
其实看完前面的闭包、this 这部分内部的话,应该基本能了解作用域的一些应用。
一旦我们将这些作用域嵌套起来,就变成了另外一个重要的知识点「作用域链」,也就是 JS 到底是如何访问需要的变量或者函数的。
- 首先作用域链是在定义时就被确定下来的,和箭头函数里的 this 一样,后续不会改变,JS 会一层层往上寻找需要的内容。
- 其实作用域链这个东西我们在闭包小结中已经看到过它的实体了:
[[Scopes]]
图中的 [[Scopes]] 是个数组,作用域的一层层往上寻找就等同于遍历 [[Scopes]]。
1. 全局作用域
全局变量是挂载在 window 对象下的变量,所以在网页中的任何位置你都可以使用并且访问到这个全局变量
var globalName = 'global';
function getName() {
console.log(globalName) // global
var name = 'inner'
console.log(name) // inner
}
getName();
console.log(name); //
console.log(globalName); //global
function setName(){
vName = 'setName';
}
setName();
console.log(vName); // setName
- 从这段代码中我们可以看到,globalName 这个变量无论在什么地方都是可以被访问到的,所以它就是全局变量。而在 getName 函数中作为局部变量的 name 变量是不具备这种能力的
- 当然全局作用域有相应的缺点,我们定义很多全局变量的时候,会容易引起变量命名的冲突,所以在定义变量的时候应该注意作用域的问题。
2. 函数作用域
函数中定义的变量叫作函数变量,这个时候只能在函数内部才能访问到它,所以它的作用域也就是函数的内部,称为函数作用域
function getName () {
var name = 'inner';
console.log(name); //inner
}
getName();
console.log(name);
除了这个函数内部,其他地方都是不能访问到它的。同时,当这个函数被执行完之后,这个局部变量也相应会被销毁。所以你会看到在 getName 函数外面的 name 是访问不到的
3. 块级作用域
ES6 中新增了块级作用域,最直接的表现就是新增的 let 关键词,使用 let 关键词定义的变量只能在块级作用域中被访问,有“暂时性死区”的特点,也就是说这个变量在定义之前是不能被使用的。
在 JS 编码过程中 if 语句及 for 语句后面 {...} 这里面所包括的,就是块级作用域
console.log(a) //a is not defined
if(true){
let a = '123';
console.log(a); // 123
}
console.log(a) //a is not defined
从这段代码可以看出,变量 a 是在
if 语句{...}中由let 关键词进行定义的变量,所以它的作用域是 if 语句括号中的那部分,而在外面进行访问 a 变量是会报错的,因为这里不是它的作用域。所以在 if 代码块的前后输出 a 这个变量的结果,控制台会显示 a 并没有定义
this
我们先来看几个函数调用的场景
function foo() {
console.log(this.a)
}
var a = 1
foo()
const obj = {
a: 2,
foo: foo
}
obj.foo()
const c = new foo()
- 对于直接调用
foo来说,不管foo函数被放在了什么地方,this一定是window - 对于
obj.foo()来说,我们只需要记住,谁调用了函数,谁就是this,所以在这个场景下foo函数中的this就是obj对象 - 对于
new的方式来说,this被永远绑定在了c上面,不会被任何方式改变this
说完了以上几种情况,其实很多代码中的 this 应该就没什么问题了,下面让我们看看箭头函数中的 this
function a() {
return () => {
return () => {
console.log(this)
}
}
}
console.log(a()()())
- 首先箭头函数其实是没有
this的,箭头函数中的this只取决包裹箭头函数的第一个普通函数的this。在这个例子中,因为包裹箭头函数的第一个普通函数是a,所以此时的this是window。另外对箭头函数使用bind这类函数是无效的。 - 最后种情况也就是
bind这些改变上下文的API了,对于这些函数来说,this取决于第一个参数,如果第一个参数为空,那么就是window。 - 那么说到
bind,不知道大家是否考虑过,如果对一个函数进行多次bind,那么上下文会是什么呢?
let a = {}
let fn = function () { console.log(this) }
fn.bind().bind(a)() // => ?
如果你认为输出结果是 a,那么你就错了,其实我们可以把上述代码转换成另一种形式
// fn.bind().bind(a) 等于
let fn2 = function fn1() {
return function() {
return fn.apply()
}.apply(a)
}
fn2()
可以从上述代码中发现,不管我们给函数 bind 几次,fn 中的 this 永远由第一次 bind 决定,所以结果永远是 window
let a = { name: 'poetries' }
function foo() {
console.log(this.name)
}
foo.bind(a)() // => 'poetries'
以上就是 this 的规则了,但是可能会发生多个规则同时出现的情况,这时候不同的规则之间会根据优先级最高的来决定 this 最终指向哪里。
首先,new 的方式优先级最高,接下来是 bind 这些函数,然后是 obj.foo() 这种调用方式,最后是 foo 这种调用方式,同时,箭头函数的 this 一旦被绑定,就不会再被任何方式所改变。
总结
this执行主体,谁把它执行的和在哪创建的在哪执行的都没有必然的关系
- 函数执行,看方法前面是否有点,没有点
this是window(严格模式下是undefined),有点,点前面是谁·this·就是谁 - 给当前元素的某个事件行为绑定方法,当事件行为触发,方法中的this是当前元素本身(排除
attachEvent) - 构造函数体中
this是当前类的实例 - 箭头函数中没有执行主体,所用到的this都是所处上下文中的
this - 可以基于
Function.prototype上的call/apply/bind改变this指向
内存机制
网上的资料基本是这样说的: 基本数据类型用栈存储,引用数据类型用堆存储。
看起来没有错误,但实际上是有问题的。可以考虑一下闭包的情况,如果变量存在栈中,那函数调用完栈顶空间销毁,闭包变量不就没了吗?
其实还是需要补充一句: 闭包变量是存在堆内存中的。
具体而言,以下数据类型存储在栈中:
booleannullundefinednumberstringsymbolbigint
而所有的对象数据类型存放在堆中。
值得注意的是,对于赋值操作,原始类型的数据直接完整地复制变量值,对象数据类型的数据则是复制引用地址。
因此会有下面的情况:
let obj = { a: 1 };
let newObj = obj;
newObj.a = 2;
console.log(obj.a);//变成了2
- 之所以会这样,是因为
obj和newObj是同一份堆空间的地址,改变newObj,等于改变了共同的堆内存,这时候通过 obj 来获取这块内存的值当然会改变。 当然,你可能会问: 为什么不全部用栈来保存呢? - 首先,对于系统栈来说,它的功能除了保存变量之外,还有创建并切换函数执行上下文的功能。举个例子:
当然,你可能会问: 为什么不全部用栈来保存呢?
首先,对于系统栈来说,它的功能除了保存变量之外,还有创建并切换函数执行上下文的功能。举个例子:
function f(a) {
console.log(a);
}
function func(a) {
f(a);
}
func(1);
- 假设用ESP指针来保存当前的执行状态,在系统栈中会产生如下的过程:
- 调用func, 将 func 函数的上下文压栈,ESP指向栈顶。
- 执行func,又调用f函数,将 f 函数的上下文压栈,ESP 指针上移。
- 执行完 f 函数,将ESP 下移,f函数对应的栈顶空间被回收。
- 执行完 func,ESP 下移,func对应的空间被回收。
图示如下:
- 因此你也看到了,如果采用栈来存储相对基本类型更加复杂的对象数据,那么切换上下文的开销将变得巨大!
- 不过堆内存虽然空间大,能存放大量的数据,但与此同时垃圾内存的回收会带来更大的开销
闭包
闭包是指有权访问另外一个函数作用域中的变量的函数,
闭包产生的原因
首先要明白作用域链的概念,其实很简单,在ES5中只存在两种作用域————全局作用域和函数作用域,当访问一个变量时,解释器会首先在当前作用域查找标示符,如果没有找到,就去父作用域找,直到找到该变量的标示符或者不在父作用域中,这就是作用域链,值得注意的是,每一个子函数都会拷贝上级的作用域,形成一个作用域的链条。 比如:
var a = 1;
function f1() {
var a = 2
function f2() {
var a = 3;
console.log(a);//3
}
}
在这段代码中,f1的作用域指向有全局作用域(window)和它本身,而f2的作用域指向全局作用域(window)、f1和它本身。而且作用域是从最底层向上找,直到找到全局作用域window为止,如果全局还没有的话就会报错。就这么简单一件事情
闭包产生的本质就是,当前环境中存在指向父级作用域的引用。还是举上面的例子:
function f1() {
var a = 2
function f2() {
console.log(a);//2
}
return f2;
}
var x = f1();
x();
这里x会拿到父级作用域中的变量,输出2。因为在当前环境中,含有对f2的引用,f2恰恰引用了window、f1和f2的作用域。因此f2可以访问到f1的作用域的变量。
- 那是不是只有返回函数才算是产生了闭包呢?
- 回到闭包的本质,我们只需要让父级作用域的引用存在即可,因此我们还可以这么做:
var f3;
function f1() {
var a = 2
f3 = function() {
console.log(a);
}
}
f1();
f3();
- 让
f1执行,给f3赋值后,等于说现在f3拥有了window、f1和f3本身这几个作用域的访问权限,还是自底向上查找,最近是在f1中找到了a,因此输出2。 - 在这里是外面的变量
f3存在着父级作用域的引用,因此产生了闭包,形式变了,本质没有改变
闭包有哪些表现形式
明白了本质之后,我们就来看看,在真实的场景中,究竟在哪些地方能体现闭包的存在?
- 返回一个函数。刚刚已经举例。
- 作为函数参数传递
var a = 1;
function foo(){
var a = 2;
function baz(){
console.log(a);
}
bar(baz);
}
function bar(fn){
// 这就是闭包
fn();
}
// 输出2,而不是1
foo();
- 在定时器、事件监听、Ajax请求、跨窗口通信、
Web Workers或者任何异步中,只要使用了回调函数,实际上就是在使用闭包
以下的闭包保存的仅仅是window和当前作用域。
// 定时器
setTimeout(function timeHandler(){
console.log('111');
},100)
// 事件监听
$('#app').click(function(){
console.log('DOM Listener');
})
IIFE(立即执行函数表达式)创建闭包, 保存了全局作用域window和当前函数的作用域,因此可以访问全局的变量
var a = 2;
(function IIFE(){
// 输出2
console.log(a);
})();
如何解决下面的循环输出问题
for(var i = 1; i <= 5; i ++){
setTimeout(function timer(){
console.log(i)
}, 0)
}
为什么会全部输出6?如何改进,让它输出1,2,3,4,5?(方法越多越好) 因为setTimeout为宏任务,由于JS中单线程eventLoop机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后setTimeout中的回调才依次执行,但输出i的时候当前作用域没有,往上一级再找,发现了i,此时循环已经结束,i变成了6。因此会全部输出6。
解决方法:
- 利用IIFE(立即执行函数表达式)当每次for循环时,把此时的i变量传递到定时器中
for(var i = 1;i <= 5;i++){
(function(j){
setTimeout(function timer(){
console.log(j)
}, 0)
})(i)
}
- 给定时器传入第三个参数, 作为
timer函数的第一个函数参数
for(var i=1;i<=5;i++){
setTimeout(function timer(j){
console.log(j)
}, 0, i)
}
- 使用ES6中的let
for(let i = 1; i <= 5; i++){
setTimeout(function timer(){
console.log(i)
},0)
}
let使JS发生革命性的变化,让JS有函数作用域变为了块级作用域,用let后作用域链不复存在。代码的作用域以块级为单位,以上面代码为例:
// i = 1
{
setTimeout(function timer(){
console.log(1)
},0)
}
// i = 2
{
setTimeout(function timer(){
console.log(2)
},0)
}
// i = 3
...
原型
实例.__proto__ === 原型
原型.constructor === 构造函数
构造函数.prototype === 原型
// 这条线其实是是基于原型进行获取的,可以理解成一条基于原型的映射线
// 例如:
// const o = new Object()
// o.constructor === Object --> true
// o.__proto__ = null;
// o.constructor === Object --> false
实例.constructor === 构造函数
js 获取原型的方法
p.protop.constructor.prototypeObject.getPrototypeOf(p)
原型链
原型链是由原型对象组成,每个对象都有 __proto__ 属性,指向了创建该对象的构造函数的原型,__proto__ 将对象连接起来组成了原型链。是一个用来实现继承和共享属性的有限的对象链
- 属性查找机制: 当查找对象的属性时,如果实例对象自身不存在该属性,则沿着原型链往上一级查找,找到时则输出,不存在时,则继续沿着原型链往上一级查找,直至最顶级的原型对象
Object.prototype,如还是没找到,则输出undefined; - 属性修改机制: 只会修改实例对象本身的属性,如果不存在,则进行添加该属性,如果需要修改原型的属性时,则可以用:
b.prototype.x = 2;但是这样会造成所有继承于该对象的实例的属性发生改变。 总结 - 每个函数都有
prototype属性,除了Function.prototype.bind(),该属性指向原型。 - 每个对象都有
__proto__属性,指向了创建该对象的构造函数的原型。其实这个属性指向了[[prototype]],但是[[prototype]]是内部属性,我们并不能访问到,所以使用_proto_来访问。 - 对象可以通过
__proto__来寻找不属于该对象的属性,__proto__将对象连接起来组成了原型链。 constructor是一个公有且不可枚举的属性。一旦我们改变了函数的prototype,那么新对象就没有这个属性了,如果想给某些类库中的构造函数增加一些自定义的方法,就可以通过xx.constructor.method来扩展
继承
方式1: 借助call
function Parent1(){
this.name = 'parent1';
}
function Child1(){
Parent1.call(this);
this.type = 'child1'
}
console.log(new Child1);
这样写的时候子类虽然能够拿到父类的属性值,但是问题是父类原型对象中一旦存在方法那么子类无法继承。那么引出下面的方法。
方式2: 借助原型链
function Parent2() {
this.name = 'parent2';
this.play = [1, 2, 3]
}
function Child2() {
this.type = 'child2';
}
Child2.prototype = new Parent2();
console.log(new Child2());
看似没有问题,父类的方法和属性都能够访问,但实际上有一个潜在的不足。举个例子:
var s1 = new Child2();
var s2 = new Child2();
s1.play.push(4);
console.log(s1.play, s2.play);
可以看到控制台:
明明我只改变了s1的play属性,为什么s2也跟着变了呢?很简单,因为两个实例使用的是同一个原型对象。
那么还有更好的方式么?
方式3:将前两种组合
function Parent3 () {
this.name = 'parent3';
this.play = [1, 2, 3];
}
function Child3() {
Parent3.call(this);
this.type = 'child3';
}
Child3.prototype = new Parent3();
var s3 = new Child3();
var s4 = new Child3();
s3.play.push(4);
console.log(s3.play, s4.play);
可以看到控制台:
之前的问题都得以解决。但是这里又徒增了一个新问题,那就是
Parent3的构造函数会多执行了一次(Child3.prototype = new Parent3();)。这是我们不愿看到的。那么如何解决这个问题?
方式4: 组合继承的优化1
function Parent4 () {
this.name = 'parent4';
this.play = [1, 2, 3];
}
function Child4() {
Parent4.call(this);
this.type = 'child4';
}
Child4.prototype = Parent4.prototype;
这里让将父类原型对象直接给到子类,父类构造函数只执行一次,而且父类属性和方法均能访问,但是我们来测试一下:
var s3 = new Child4();
var s4 = new Child4();
console.log(s3)
子类实例的构造函数是Parent4,显然这是不对的,应该是Child4。
方式5(最推荐使用): 组合继承的优化2
function Parent5 () {
this.name = 'parent5';
this.play = [1, 2, 3];
}
function Child5() {
Parent5.call(this);
this.type = 'child5';
}
Child5.prototype = Object.create(Parent5.prototype);
Child5.prototype.constructor = Child5;
这是最推荐的一种方式,接近完美的继承,它的名字也叫做寄生组合继承。
ES6的extends被编译后的JavaScript代码
ES6的代码最后都是要在浏览器上能够跑起来的,这中间就利用了babel这个编译工具,将ES6的代码编译成ES5让一些不支持新语法的浏览器也能运行。
那最后编译成了什么样子呢?
function _possibleConstructorReturn(self, call) {
// ...
return call && (typeof call === 'object' || typeof call === 'function') ? call : self;
}
function _inherits(subClass, superClass) {
// ...
//看到没有
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}
var Parent = function Parent() {
// 验证是否是 Parent 构造出来的 this
_classCallCheck(this, Parent);
};
var Child = (function (_Parent) {
_inherits(Child, _Parent);
function Child() {
_classCallCheck(this, Child);
return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));
}
return Child;
}(Parent));
核心是
_inherits函数,可以看到它采用的依然也是第五种方式————寄生组合继承方式,同时证明了这种方式的成功。不过这里加了一个Object.setPrototypeOf(subClass, superClass),这是用来干啥的呢?
答案是用来继承父类的静态方法。这也是原来的继承方式疏忽掉的地方。
追问: 面向对象的设计一定是好的设计吗?
不一定。从继承的角度说,这一设计是存在巨大隐患的。
从设计思想上谈谈继承本身的问题
假如现在有不同品牌的车,每辆车都有drive、music、addOil这三个方法。
class Car{
constructor(id) {
this.id = id;
}
drive(){
console.log("wuwuwu!");
}
music(){
console.log("lalala!")
}
addOil(){
console.log("哦哟!")
}
}
class otherCar extends Car{}
现在可以实现车的功能,并且以此去扩展不同的车。
但是问题来了,新能源汽车也是车,但是它并不需要addOil(加油)。
如果让新能源汽车的类继承Car的话,也是有问题的,俗称"大猩猩和香蕉"的问题。大猩猩手里有香蕉,但是我现在明明只需要香蕉,却拿到了一只大猩猩。也就是说加油这个方法,我现在是不需要的,但是由于继承的原因,也给到子类了。
继承的最大问题在于:无法决定继承哪些属性,所有属性都得继承。
当然你可能会说,可以再创建一个父类啊,把加油的方法给去掉,但是这也是有问题的,一方面父类是无法描述所有子类的细节情况的,为了不同的子类特性去增加不同的父类,代码势必会大量重复,另一方面一旦子类有所变动,父类也要进行相应的更新,代码的耦合性太高,维护性不好。
那如何来解决继承的诸多问题呢?
用组合,这也是当今编程语法发展的趋势,比如golang完全采用的是面向组合的设计方式。
顾名思义,面向组合就是先设计一系列零件,然后将这些零件进行拼装,来形成不同的实例或者类。
function drive(){
console.log("wuwuwu!");
}
function music(){
console.log("lalala!")
}
function addOil(){
console.log("哦哟!")
}
let car = compose(drive, music, addOil);
let newEnergyCar = compose(drive, music);
代码干净,复用性也很好。这就是面向组合的设计方式。
继承-简版
在 ES5 中,我们可以使用如下方式解决继承的问题
function Super() {}
Super.prototype.getNumber = function() {
return 1
}
function Sub() {}
let s = new Sub()
Sub.prototype = Object.create(Super.prototype, {
constructor: {
value: Sub,
enumerable: false,
writable: true,
configurable: true
}
})
- 以上继承实现思路就是将子类的原型设置为父类的原型
- 在
ES6中,我们可以通过class语法轻松解决这个问题
class MyDate extends Date {
test() {
return this.getTime()
}
}
let myDate = new MyDate()
myDate.test()
- 但是
ES6不是所有浏览器都兼容,所以我们需要使用Babel来编译这段代码。 - 如果你使用编译过得代码调用
myDate.test()你会惊奇地发现出现了报错
因为在
JS底层有限制,如果不是由Date构造出来的实例的话,是不能调用Date里的函数的。所以这也侧面的说明了:ES6中的class继承与ES5中的一般继承写法是不同的。
- 既然底层限制了实例必须由
Date构造出来,那么我们可以改变下思路实现继承
function MyData() {
}
MyData.prototype.test = function () {
return this.getTime()
}
let d = new Date()
Object.setPrototypeOf(d, MyData.prototype)
Object.setPrototypeOf(MyData.prototype, Date.prototype)
- 以上继承实现思路:先创建父类实例 => 改变实例原先的
_proto__转而连接到子类的prototype=> 子类的prototype的__proto__改为父类的prototype。 - 通过以上方法实现的继承就可以完美解决
JS底层的这个限制
面向对象
编程思想
-
基本思想是使用对象,类,继承,封装等基本概念来进行程序设计
-
优点
-
易维护
- 采用面向对象思想设计的结构,可读性高,由于继承的存在,即使改变需求,那么维护也只是在局部模块,所以维护起来是非常方便和较低成本的
-
易扩展
-
开发工作的重用性、继承性高,降低重复工作量。
-
缩短了开发周期
-
一般面向对象包含:继承,封装,多态,抽象
1. 对象形式的继承
浅拷贝
var Person = {
name: 'poetry',
age: 18,
address: {
home: 'home',
office: 'office',
}
sclools: ['x','z'],
};
var programer = {
language: 'js',
};
function extend(p, c){
var c = c || {};
for( var prop in p){
c[prop] = p[prop];
}
}
extend(Person, programer);
programer.name; // poetry
programer.address.home; // home
programer.address.home = 'house'; //house
Person.address.home; // house
从上面的结果看出,浅拷贝的缺陷在于修改了子对象中引用类型的值,会影响到父对象中的值,因为在浅拷贝中对引用类型的拷贝只是拷贝了地址,指向了内存中同一个副本
深拷贝
function extendDeeply(p, c){
var c = c || {};
for (var prop in p){
if(typeof p[prop] === "object"){
c[prop] = (p[prop].constructor === Array)?[]:{};
extendDeeply(p[prop], c[prop]);
}else{
c[prop] = p[prop];
}
}
}
利用递归进行深拷贝,这样子对象的修改就不会影响到父对象
extendDeeply(Person, programer);
programer.address.home = 'poetry';
Person.address.home; // home
利用call和apply继承
function Parent(){
this.name = "abc";
this.address = {home: "home"};
}
function Child(){
Parent.call(this);
this.language = "js";
}
ES5中的Object.create()
var p = { name : 'poetry'};
var obj = Object.create(p);
obj.name; // poetry
Object.create()作为new操作符的替代方案是ES5之后才出来的。我们也可以自己模拟该方法:
//模拟Object.create()方法
function myCreate(o){
function F(){};
F.prototype = o;
o = new F();
return o;
}
var p = { name : 'poetry'};
var obj = myCreate(p);
obj.name; // poetry
目前,各大浏览器的最新版本(包括IE9)都部署了这个方法。如果遇到老式浏览器,可以用下面的代码自行部署
if (!Object.create) {
Object.create = function (o) {
function F() {}
F.prototype = o;
return new F();
};
}
2. 类的继承
Object.create()
function Person(name, age){}
Person.prototype.headCount = 1;
Person.prototype.eat = function(){
console.log('eating...');
}
function Programmer(name, age, title){}
Programmer.prototype = Object.create(Person.prototype); //建立继承关系
Programmer.prototype.constructor = Programmer; // 修改constructor的指向
调用父类方法
function Person(name, age){
this.name = name;
this.age = age;
}
Person.prototype.headCount = 1;
Person.prototype.eat = function(){
console.log('eating...');
}
function Programmer(name, age, title){
Person.apply(this, arguments); // 调用父类的构造器
}
Programmer.prototype = Object.create(Person.prototype);
Programmer.prototype.constructor = Programmer;
Programmer.prototype.language = "js";
Programmer.prototype.work = function(){
console.log('i am working code in '+ this.language);
Person.prototype.eat.apply(this, arguments); // 调用父类上的方法
}
3. 封装
-
命名空间
- js是没有命名空间的,因此可以用对象模拟
var app = {}; // 命名空间app
//模块1
app.module1 = {
name: 'poetry',
f: function(){
console.log('hi robot');
}
};
app.module1.name; // "poetry"
app.module1.f(); // hi robot
对象的属性外界是可读可写 如何来达到封装的额目的?答:可通过
闭包+局部变量来完成
- 在构造函数内部声明局部变量 和普通方法
- 因为作用域的关系 只有构造函数内的方法
- 才能访问局部变量 而方法对于外界是开放的
- 因此可以通过方法来访问 原本外界访问不到的局部变量 达到函数封装的目的
function Girl(name,age){
var love = '小明';//love 是局部变量 准确说不属于对象 属于这个函数的额激活对象 函数调用时必将产生一个激活对象 love在激活对象身上 激活对象有作用域的关系 有办法访问 加一个函数提供外界访问
this.name = name;
this.age = age;
this.say = function () {
return love;
};
this.movelove = function (){
love = '小轩'; //35
}
}
var g = new Girl('yinghong',22);
console.log(g);
console.log(g.say());//小明
console.log(g.movelove());//undefined 因为35行没有返回
console.log(g.say());//小轩
function fn(){
function t(){
//var age = 22;//声明age变量 在t的激活对象上
age = 22;//赋值操作 t的激活对象上找age属性 ,找不到 找fn的激活对象....再找到 最终找到window.age = 22;
//不加var就是操作window全局属性
}
t();
}
console.log(fn());//undefined
4. 静态成员
面向对象中的静态方法-静态属性:没有new对象 也能引用静态方法属性
function Person(name){
var age = 100;
this.name = name;
}
//静态成员
Person.walk = function(){
console.log('static');
};
Person.walk(); // static
5. 私有与公有
function Person(id){
// 私有属性与方法
var name = 'poetry';
var work = function(){
console.log(this.id);
};
//公有属性与方法
this.id = id;
this.say = function(){
console.log('say hello');
work.call(this);
};
};
var p1 = new Person(123);
p1.name; // undefined
p1.id; // 123
p1.say(); // say hello 123
6. 模块化
var moduleA;
moduleA = function() {
var prop = 1;
function func() {}
return {
func: func,
prop: prop
};
}(); // 立即执行匿名函数
7. 多态
多态:同一个父类继承出来的子类各有各的形态
function Cat(){
this.eat = '肉';
}
function Tiger(){
this.color = '黑黄相间';
}
function Cheetah(){
this.color = '报文';
}
function Lion(){
this.color = '土黄色';
}
Tiger.prototype = Cheetah.prototype = Lion.prototype = new Cat();//共享一个祖先 Cat
var T = new Tiger();
var C = new Cheetah();
var L = new Lion();
console.log(T.color);
console.log(C.color);
console.log(L.color);
console.log(T.eat);
console.log(C.eat);
console.log(L.eat);
8. 抽象类
在构造器中
throw new Error(''); 抛异常。这样防止这个类被直接调用
function DetectorBase() {
throw new Error('Abstract class can not be invoked directly!');
}
DetectorBase.prototype.detect = function() {
console.log('Detection starting...');
};
DetectorBase.prototype.stop = function() {
console.log('Detection stopped.');
};
DetectorBase.prototype.init = function() {
throw new Error('Error');
};
// var d = new DetectorBase();
// Uncaught Error: Abstract class can not be invoked directly!
function LinkDetector() {}
LinkDetector.prototype = Object.create(DetectorBase.prototype);
LinkDetector.prototype.constructor = LinkDetector;
var l = new LinkDetector();
console.log(l); //LinkDetector {}__proto__: LinkDetector
l.detect(); //Detection starting...
l.init(); //Uncaught Error: Error
事件机制
涉及面试题:事件的触发过程是怎么样的?知道什么是事件代理嘛?
1. 简介
事件流是一个事件沿着特定数据结构传播的过程。冒泡和捕获是事件流在
DOM中两种不同的传播方法
事件流有三个阶段
- 事件捕获阶段
- 处于目标阶段
- 事件冒泡阶段
事件捕获
事件捕获(
event capturing):通俗的理解就是,当鼠标点击或者触发dom事件时,浏览器会从根节点开始由外到内进行事件传播,即点击了子元素,如果父元素通过事件捕获方式注册了对应的事件的话,会先触发父元素绑定的事件
事件冒泡
事件冒泡(dubbed bubbling):与事件捕获恰恰相反,事件冒泡顺序是由内到外进行事件传播,直到根节点
无论是事件捕获还是事件冒泡,它们都有一个共同的行为,就是事件传播
2. 捕获和冒泡
<div id="div1">
<div id="div2"></div>
</div>
<script>
let div1 = document.getElementById('div1');
let div2 = document.getElementById('div2');
div1.onClick = function(){
alert('1')
}
div2.onClick = function(){
alert('2');
}
</script>
当点击
div2时,会弹出两个弹出框。在ie8/9/10、chrome浏览器,会先弹出”2”再弹出“1”,这就是事件冒泡:事件从最底层的节点向上冒泡传播。事件捕获则跟事件冒泡相反
W3C的标准是先捕获再冒泡,
addEventListener的第三个参数决定把事件注册在捕获(true)还是冒泡(false)
3. 事件对象
4. 事件流阻止
在一些情况下需要阻止事件流的传播,阻止默认动作的发生
event.preventDefault():取消事件对象的默认动作以及继续传播。event.stopPropagation()/ event.cancelBubble = true:阻止事件冒泡。
事件的阻止在不同浏览器有不同处理
- 在
IE下使用event.returnValue= false, - 在非
IE下则使用event.preventDefault()进行阻止
preventDefault与stopPropagation的区别
preventDefault告诉浏览器不用执行与事件相关联的默认动作(如表单提交)stopPropagation是停止事件继续冒泡,但是对IE9以下的浏览器无效
5. 事件注册
- 通常我们使用
addEventListener注册事件,该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值useCapture参数来说,该参数默认值为false。useCapture决定了注册的事件是捕获事件还是冒泡事件 - 一般来说,我们只希望事件只触发在目标上,这时候可以使用
stopPropagation来阻止事件的进一步传播。通常我们认为stopPropagation是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件。stopImmediatePropagation同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件
node.addEventListener('click',(event) =>{
event.stopImmediatePropagation()
console.log('冒泡')
},false);
// 点击 node 只会执行上面的函数,该函数不会执行
node.addEventListener('click',(event) => {
console.log('捕获 ')
},true)
6. 事件委托
- 在
js中性能优化的其中一个主要思想是减少dom操作。 - 节省内存
- 不需要给子节点注销事件
假设有
100个li,每个li有相同的点击事件。如果为每个Li都添加事件,则会造成dom访问次数过多,引起浏览器重绘与重排的次数过多,性能则会降低。 使用事件委托则可以解决这样的问题
原理
实现事件委托是利用了事件的冒泡原理实现的。当我们为最外层的节点添加点击事件,那么里面的
ul、li、a的点击事件都会冒泡到最外层节点上,委托它代为执行事件
<ul id="ul">
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
window.onload = function(){
var ulEle = document.getElementById('ul');
ul.onclick = function(ev){
//兼容IE
ev = ev || window.event;
var target = ev.target || ev.srcElement;
if(target.nodeName.toLowerCase() == 'li'){
alert( target.innerHTML);
}
}
}
JavaScript对象的几种创建方式
工厂模式,创建方式
function createPerson(name,age,job){
var o = new Object();
o.name=name;
o.age=age;
o.job=job;
o.sayName = function(){
alert(this.name);
}
}
var person1 = createPerson("da",1,"it");
var person2 = createPerson("dada",2,"it");
构造函数模式
function Person(name,age,ob){
this.name=name;
this.age=age;
this.job=job;
this.sayName = function(){
alert(this.name);
}
var person1 = new Person("dada",1,"web");
var person2 = new Person("dada",2,"web");
}
使用原型模式
function Person(){
}
Person.prototype.name = "da";
Person.prototype.age = 1;
Person.prototype.job = "web";
Person.prototype.sayName = function(){
alert(this.name);
}
var person1 = new Person();
person1.sayName(); //"dada"
var person2 = new Person();
person2.sayName(); //"dada"
alert(person1.sayName == person2.sayName); //true
组合使用构造函数模式和原型模式
function Person(name,age){
this.name = name;
this.age = age;
this.friends = ["da","dada"];
}
Person.prototype = {
constructor:Person,
sayName:function(){
alert(this.name);
}
}
var person1 = new Person("da1",1);
var person2 = new Person("da2",2);
person1.friends.push("dadada");
console.log(person1.friends); //["da","dada","dadada"]
console.log(person2.friends); //["da","dada"]
console.log(person1.friends === person2.friends); //false
console.log(person1.sayName === person2.sayName); //true
动态原型模式
function Person(name,age,job){
this.name=name;
this.age=age;
this.job=job;
if(typeof this.sayName!="function"){
Person.prototype.sayName=function(){
alert(this.name);
};
}
}
New的原理
new 关键词的主要作用就是执行一个构造函数、返回一个实例对象,在 new 的过程中,根据构造函数的情况,来确定是否可以接受参数的传递。下面我们通过一段代码来看一个简单的 new 的例子
function Person(){
this.name = 'Jack';
}
var p = new Person();
console.log(p.name) // Jack
这段代码比较容易理解,从输出结果可以看出,p 是一个通过 person 这个构造函数生成的一个实例对象,这个应该很容易理解。
new 操作符可以帮助我们构建出一个实例,并且绑定上 this,内部执行步骤可大概分为以下几步:
- 创建一个新对象
- 对象连接到构造函数原型上,并绑定
this(this 指向新对象) - 执行构造函数代码(为这个新对象添加属性)
- 返回新对象
在第四步返回新对象这边有一个情况会例外:
那么问题来了,如果不用 new 这个关键词,结合上面的代码改造一下,去掉 new,会发生什么样的变化呢?我们再来看下面这段代码
function Person(){
this.name = 'Jack';
}
var p = Person();
console.log(p) // undefined
console.log(name) // Jack
console.log(p.name) // 'name' of undefined
- 从上面的代码中可以看到,我们没有使用
new这个关键词,返回的结果就是undefined。其中由于JavaScript代码在默认情况下this的指向是window,那么name的输出结果就为Jack,这是一种不存在new关键词的情况。 - 那么当构造函数中有
return一个对象的操作,结果又会是什么样子呢?我们再来看一段在上面的基础上改造过的代码。
function Person(){
this.name = 'Jack';
return {age: 18}
}
var p = new Person();
console.log(p) // {age: 18}
console.log(p.name) // undefined
console.log(p.age) // 18
通过这段代码又可以看出,当构造函数最后 return 出来的是一个和 this 无关的对象时,new 命令会直接返回这个新对象,而不是通过 new 执行步骤生成的 this 对象
但是这里要求构造函数必须是返回一个对象,如果返回的不是对象,那么还是会按照 new 的实现步骤,返回新生成的对象。接下来还是在上面这段代码的基础之上稍微改动一下
function Person(){
this.name = 'Jack';
return 'tom';
}
var p = new Person();
console.log(p) // {name: 'Jack'}
console.log(p.name) // Jack
可以看出,当构造函数中 return 的不是一个对象时,那么它还是会根据 new 关键词的执行逻辑,生成一个新的对象(绑定了最新 this),最后返回出来
因此我们总结一下:new 关键词执行之后总是会返回一个对象,要么是实例对象,要么是 return 语句指定的对象
手工实现New的过程
function create(fn, ...args) {
if(typeof fn !== 'function') {
throw 'fn must be a function';
}
// 1、用new Object() 的方式新建了一个对象obj
// var obj = new Object()
// 2、给该对象的__proto__赋值为fn.prototype,即设置原型链
// obj.__proto__ = fn.prototype
// 1、2步骤合并
// 创建一个空对象,且这个空对象继承构造函数的 prototype 属性
// 即实现 obj.__proto__ === constructor.prototype
var obj = Object.create(fn.prototype);
// 3、执行fn,并将obj作为内部this。使用 apply,改变构造函数 this 的指向到新建的对象,这样 obj 就可以访问到构造函数中的属性
var res = fn.apply(obj, args);
// 4、如果fn有返回值,则将其作为new操作返回内容,否则返回obj
return res instanceof Object ? res : obj;
};
- 使用
Object.create将obj 的proto指向为构造函数的原型; - 使用
apply方法,将构造函数内的this指向为obj; - 在
create返回时,使用三目运算符决定返回结果。
我们知道,构造函数如果有显式返回值,且返回值为对象类型,那么构造函数返回结果不再是目标实例
如下代码:
function Person(name) {
this.name = name
return {1: 1}
}
const person = new Person(Person, 'lucas')
console.log(person)
// {1: 1}
测试
//使用create代替new
function Person() {...}
// 使用内置函数new
var person = new Person(1,2)
// 使用手写的new,即create
var person = create(Person, 1,2)
new 被调用后大致做了哪几件事情
- 让实例可以访问到私有属性;
- 让实例可以访问构造函数原型(
constructor.prototype)所在原型链上的属性; - 构造函数返回的最后结果是引用数据类型。