写过最多的是 JS 相关的文章,做过最完整的是 JS 的思维导图,敲过最多的依然是 JS 代码,我觉得自己的 JS 还算可以了。写到这里的时候,我已经离职一周,也参加了几次面试,大多数问题都能按自己的理解回答上来,同时,也让我意识到,那只是我以为的可以,只是没有遇到真正厉害的面试官罢了 ~写 JS 面试题之前,我纠结了好多次,我已经看过很多优秀的相关文章了,也写过很多各种各样的笔记了,还有没有必要再重复写。面完几次之后,有过正确解答,也有一知半解。有些问题让我意识到,这不是一次重复,而是一次重新认识,一次对原有认知的升华。同时,要时刻提醒自己,不能局限于你知道的原理,局限于我能说出这个概念那个定义,而是能够熟稔的把每个简单的知识点融会贯通于我们开发的实际过程中(快速上手也是如此),能够在贴合实际的情况下,从容应对变幻莫测的面试官。
内容可能比较多,也免不了一些错误,这也很让自己庆幸,这些认知错误,帮我不断刷新自己的知识上限。来吧,我是 huohuo ,一起加油!
注:这里的内容是由 曾经的笔记 + 开发经验 + 新的理解 总结而成,持续更新ing......
关于JS 的历史发展,JS 的基本语法,大家自行上 W3C 即可,这里不做赘述。
与 JS 的初遇
想必大家与 JS 牵手的次数早已不下千遍万遍,你熟悉了她春夏秋冬的手心温度,抑或是她平静或激动时的呼吸频率,但,你是否还记得,第一次与她相见的,那份心动 ~
一切开始于,为什么要接触 JS ,显然我们需要 JS 帮助我们完成什么
JS 的作用
- 表单动态校验(密码强度检测) ( JS 产生最初的目的 )
- 网页特效
- 服务端开发(Node.js)
- 桌面程序(Electron)
- App(Cordova)
- 控制硬件-物联网(Ruff)
- 游戏开发(cocos2d-js)
我们都知道三大前端语言,HTML/CSS/JS,密不可分,你是如何理解他们之间的关系呢
HTML/CSS/JS 的关系
一句话概括,很好理解
HTML 就是我们的身体结构(决定网页结构和内容),CSS 就是我们穿的衣服和化的妆(页面如何呈现),而 JS 就是我们的各种行为动作(页面控制和业务逻辑)
说到关系,当然少不了浏览器跟 JS 的关系啦!
浏览器执行 JS 简介
浏览器分成两部分:渲染引擎和 JS 引擎
- 渲染引擎:用来解析 HTML与CSS,俗称内核(如 chrome 浏览器的 blink、 webkit)
- JS 引擎:也称为 JS 解释器。 用来读取网页中的 JS 代码,对其处理后运行(如 chrome 浏览器的 V8)
浏览器本身并不会执行JS代码,而是通过内置 JavaScript 引擎来解析 (逐行解析)JS 代码 ,然后由计算机去执行,所以 JavaScript 语言归为脚本语言,会逐行解释执行。
JS 的组成
(1)ECMAScript
ECMAScript:ECMAScript 规定了JS的编程语法和基础核心知识,是所有浏览器厂商共同遵守的一套JS语法工业标准。(参考链接)
(2)DOM ——文档对象模型
文档对象模型(Document Object Model,简称DOM),是W3C组织推荐的处理可扩展标记语言的标准编程接口。
通过 DOM 提供的接口可以对页面上的各种元素进行操作(大小、位置、颜色等)。
(3)BOM ——浏览器对象模型
BOM (Browser Object Model,简称BOM) 是指浏览器对象模型,它提供了独立于内容的、可以与浏览器窗口进行互动的对象结构。通过 BOM 可以操作浏览器窗口,比如弹出框、控制浏览器跳转、获取分辨率等。
输入输出
- alert(msg):浏览器弹出消息框给用户
- console.log(msg):开发时控制台打印运行信息
- prompt(info):浏览器弹出的输入框,可供用户做输入操作
我们的初遇看起来是如此的简单,而她的美妙也让我们心里一眼定终身的感觉焕然升起。在彼此甜蜜无间的时候,请不要忘记最初的衷心。让我们一起慢慢回味......
数据类型
说到数据类型,想必大家脑海中马上会涌现出这类常见面试题:JS有哪些数据类型?怎么判断这些数据类型?假如,我起手问你的是这样,你会如何回答呢。
为什么需要数据类型
在计算机中,不同的数据所需占用的存储空间是不同的,为了便于把数据分成所需内存大小不同的数据,充分利用存储空间,于是定义了不同的数据类型
有同学听到存储空间,可能会马上想到——变量,跟存储的关系
JS 数据类型分类
JS 的数据类型到底有哪些?我们都知道有两种,有人说是简单数据类型和复杂数据类型,有人说是基本类型和引用类型,但是我更认同:原始类型 和 对象类型,这些名词你都可以在红宝书中找到他们的身影,但是我们只需要牢记这两类就可以啦!
6 种原始类型:
- Null、Undefined、Boolean、Number、String、Symbol
对象(Object )类型:
- Object、Array、RegExp、Date、Function
note:Null(空),代表此处不该有值得存在。Undefined(不存在),运行期才知道是否存在
JS 中的变量与数据类型
变量是用来存储值的所在处,它们有名字和数据类型。变量的数据类型决定了如何将代表这些值的位存储到计算机的内存中。JavaScript 是一种弱类型或者说动态语言。这意味着不用提前声明变量的类型,在程序运行过程中,类型会被自动确定。
var age = 10; // 这是一个数字型
var name= 'huohuoit'; // 这是一个字符串
在代码运行时,变量的数据类型是由 JS引擎 根据 = 右边变量值的数据类型来判断 的,运行完毕之后, 变量就确定了数据类型。也意味着相同的变量可用作不同的数据类型。
如果有人问你,你是如何理解 JS 是一门动态类型语言或者 JS 变量可以用作不同类型的,上面的解释或许可以帮助到你。
另外,我们把变量的数据分为 原始值 和 引用值
原始值
- 原始类型
- 按值访问
- 操作变量实际值
引用值
- 对象类型
- 按引用访问
- 操作对象的引用(指针)
不同数据类型的坑
1、原始类型存储的都是值,是没有函数可以调用的
这个问题最开始出现在我操作数据的时候发生的,如 a.toString(),我对数据 a 调用了 toString() 方法,但是有时候会报如下错误,我想你可能也在哪见过
这个报错是不是很眼熟呢?a 虽然被定义了,但是没有初始化,所以值为 Undefined ,Undefined 是原始类型哦,只是个值,它可没有函数可调用。
2、类型强制转换
我们再来看一个例子
为什么这里不会报错呢?因为发生了类型强制转换,'1' 在这里已经不是原始类型了,而是被转换成了 String 类型(对象类型),所以可以调用 toString() 函数。
Why ?因为 JS 是一门动态类型语言,JS 引擎会在运算时根据运算情景为变量设定类型。
我们把类型转换分为显式和隐式。
- 显式就是光明正大的转换啦,比如toString()、String()、parseInt()、parseFloat()、Number()
- 隐式就可能发生在 if 语句、逻辑语句、数学运算逻辑、== 等情况下。
了解就好,切记不要钻牛角尖哇,毕竟 JS 还有有些坑的!比如说下面这个
3、0.1 + 0.2 != 0.3
简单来说,就是 JS 中 Number 类型的精度问题。 计算机都是通过二进制来存储的,而 0.1 在二进制中是无限循环小数,而JS 采用 IEEE 754双精度版本(64位),且 JS 的浮点数标准会截断数字(如截断后为 0.100000000000000002),所以 0.1 + 0.2 经过转换是不等于 0.3 的。
那怎么解决呢?这里采用原生方式做处理
浮点数转整数的思路:0.1+0.2 => (0.1*10+0.2*10)/10
parseFloat((0.1 + 0.2).toFixed(10)) === 0.3 // true
永远不要测试某个特定的浮点值!
如何判断 JS 数据类型
typeof 操作符
- 可判断原始类型、Object、Function、Symbol(主要用来判断原始类型)
- 注:这个方法判断对象类型和 Null 返回的都是 Object
instanceof 操作符
- 可以比较方便地判断具体的对象类型(也即判断一个引用类型是否属于某构造函数)
- 在继承关系中判断一个实例是否属于它的父类
如果你对它的底层原理感兴趣:
从 L 的 __proto__顺着原型链查找,看是否能找到对应 R 的 prototype,找到了就返回 true
function instance_of(L, R) { // L (a),R (Array)
var O = R.prototype; // 取 R 的显示原型
L = L.__proto__; // 取 L 的隐式原型
while (true) {
if (L === null)
return false;
if (O === L) // 当 O显式原型 严格等于 L隐式原型 时,返回true
return true;
L = L.__proto__;
}
}
Object.prototype.toString.call (数据)
- 原始类型和各种对象类型都能判断
我们常为此做一个方法封装
function getType (obj) {
const type = typeof obj;
// 判断是否是基础数据类型
if (type !== 'object') {
// 是的话直接返回
return type;
}
// 是复杂数据类型:通过 Object.prototype.toString.call(obj) 得到 [object obj],
// 使用正则拿到 obj 的值即为该数据的类型
return Object.prototype.toString.call(obj).replace(/^\[object (\S+)\]$/, '$1'); // 注意正则中间有个空格
}
经典面试题1:如何判断一个数据是数组
- 如上,instanceof
- 如上,Object.prototype.toString.call()
- ES6 的 Array.isArray()
经典面试题2:如何判断一个对象是否为空对象
首先要区分一个概念,空对象 和 空引用
- 空对象:简单理解就是不含任何属性的对象,{ }
- 空引用:变量值指向 null(obj = null)
① JSON 字符串判断(一般用在接收处理后台数据)
let data = {}; // 拿到的数据
let b = (JSON.stringify(data) == "{}"); 转字符串判断是否为 {}
console.log(b); //true
② for in 循环判断 (遍历原型及自身上的可枚举属性,需要结合hasOwnProperty去除原型上的可枚举属性)
let data = {};
function isEmptyObj(obj) {
for (let key in obj) {
if ({}.hasOwnProperty.call(obj, key)) return false;
}
return true;
}
console.log(isEmptyObj(data));
③ Object.getOwnPropertyNames() (获取对象属性名,来判断是否有属性)
let data = {};
let arr = Object.getOwnPropertyNames(data); // 拿到数据的属性名并以数组对象的形式返回
console.log(arr.length == 0); // true (根据数组长度判断是否存在属性)
④ ES6 的 Object.keys() (同上,返回属性名数组)
let data = {};
let arr = Object.keys(data);
console.log(arr.length == 0); // true
一个特殊的 Number
NaN(Not a Number),不是数值,意思是本来要返回数值的操作失败了(非抛错)
特性:
- 任何涉及 NaN 的操作始终返回 NaN
- NaN 不等于 NaN 在内的任何值
isNaN() 函数:任何不能转换为数值的值都被导致这个函数返回 true
一个有趣的题目
唔,是不是看累了,来点好玩的,放松下哈! ~~
[] == false; //true 对象 => 字符串 => 数值0 false转换为数字0,
![] == false; //true
第二个运算前边多了个 !,我们要先将 [] 转换为布尔值再取反,转换为布尔值时,空字符串('')、NaN、0、null、undefined 这几个外返回的都是true, 所以 [] => true, 取反为false,故 ![] == false为 true。
一个 JS 运算符优先级问题
有一次在群里遇到群友问的一个问题,有不少同志都不明所以,我们来看看
let num = 10;
const sub = ++num + (--num);
console.log(num) // ?
console.log(sub) // ?
打印出来是多少呢?先想一想
这里我再先放出 JS 运算符优先级的一张图
可以明显看到,优先级最高的是 (),所以你可以很快的想到这样计算(以下计算按顺序进行)
(--num) : num = 10 - 1 = 9, return 9
++num : num = 9 + 1, return 10
last return 9 + 10 = 19,
// 打印 num = 10, sub = 19
又或者你这样计算
++num : num = 10 + 1, return 11
(--num) : num = 10 - 1 = 9, return 9
last return 9 + 11 = 20,
// 打印 num = 9, sub = 20
很不幸,都错了。纳尼 (ÒωÓױ)!
这里大家很容易被运算符的优先级误导(你可能没有真正理解优先级运算)
- JS 运算是从左向右的
- 产生优先级高低比较的前提是值两边都有运算符
- ++val ==》val + 1,val = val + 1
现在我们按着这个规则再试试
++num : num = 11, return 11 // ++ 优先级高于 +
(--num) : num = 11 - 1 = 10, return 10 // () 优先级高于 +
last return 11 + 10, // 最后再 +
// 打印 num = 10, sub = 21
作用域
这一块的经典面试题还真不少,在这我主要想讲的是这三大块:执行上下文、作用域链、闭包
JS 执行上下文
JS 可执行代码包含了全局代码(全局上下文)、函数代码(函数上下文)、eval代码(eval 上下文),而执行上下文(简称上下文)就是代码执行过程中非常重要的概念。
概念:当 JS 代码执行一段可执行代码时,就会进行准备工作,这里的“准备工作”,就叫做执行上下文,它决定了我们的变量或函数可以访问哪些数据以及他们的行为
上下文的重要属性有三个:变量对象、作用域链、this
这里我们先关注 变量对象(Variable object,VO)
- 变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。
- 全局上下文中的变量对象就是全局对象(AO)。全局上下文的变量对象初始化是全局对象。
- 函数上下文的变量对象(VO)初始化只包括 Arguments 对象。
- 在浏览器中,全局对象就是 window 对象,是由 Object 构造函数实例化的一个对象,预定义了一堆函数和属性,且有 window 属性指向自身。
- AO = VO + function parameters + arguments
代码的执行必定有开始与结束,所以执行上下文也有个 生命周期
- 创建阶段:创建变量对象,建立作用域链,以及确定this的指向(注意未进入执行阶段之前,变量对象中的属性都不能访问!)
- 代码执行阶段:完成变量赋值(会再次修改变量对象的属性值),函数引用,以及执行其他代码
- 执行完后出栈,等待被回收
上下文中的代码在执行的时候,会创建变量对象的一个作用域链,这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。
作用域链
我们先来看看作用域(变量和函数才有)
- 概念:指程序源代码中定义变量的区域
- 作用:规定了如何查找变量,也就是确定当前执行代码对变量的访问权限
- JS 采用词法作用域,也就是静态作用域。函数的作用域在函数定义的时候就决定了
作用域链
当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。
梳理一下:JS 如何查找变量?
当前上下文的 VO ——>父级上下文的 VO——>全局对象 AO,这个查找链就是作用域链
this (这一块我们放到后面专门讲)
闭包
很多人都知道闭包,但是对它的概念有点模糊不清,其实就是:
闭包 = 函数 + 函数能够访问的自由变量
注:自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。
闭包是可以访问外部作用域的内部函数(延伸变量的作用范围),即使这个外部作用域已经执行结束。闭包就是内部函数,我们可以通过在一个函数内部或者 {} 块里面定义一个函数来创建闭包。
其中我们需要注意以下几点:
- 闭包的外部作用域是在其定义的时候已决定,而不是执行的时候。
- 变量的生命周期取决于闭包的生命周期
- 闭包只存储外部变量的引用,而不会拷贝这些外部变量的值
应用场景(这些我会在后面分别展开详细介绍):
- 在异步任务例如 timer 定时器,事件处理,Ajax 请求中被作为回调,HTML5 Geolocation,WebSockets , requestAnimationFrame()
- 被外部函数作为返回结果返回,或者返回结果对象中引用该内部函数
- 封装性
- 在函数式编程中的应用
- 装饰器函数
- 垃圾回收
面试官发难:请用闭包实现说明一个私有变量
实现私有变量,我们可以靠约定(变量名前加_)、Proxy代理、Symbol 数据结构,而最流行的当然是闭包啦,我们来举例看看吧 ~
function fun() {
let id = 'huohuo';
this.getId = function () {
return id;
}
this.setId = function (val) {
id = val;
}
}
const wg = new fun();
console.log(wg.getId()); // huohuo
wg.setId('huohuoNB');
console.log(wg.getId()); // huohuoNB
console.log(id); // id is not defined
通过打印我们可以看到,函数 fun (闭包)内的变量 id 只有 getId函数 和 setId函数 能访问到,外部无法访问
上面我们说了作用域链,JS 中还有一条有名的链——原型链,它们有什么区别呢?
注意到了吗,这条作用域链的形成源于 对变量的查找。
温馨提示:JS 变量是存储任意数据值的命名占位符(一个存储空间的名字!)
所以作用域链就是查找变量或者说标识符的,而原型链是用来查找对象的属性的
在着手原型链之前,我们先来做一些准备工作,方便更清晰地了解它
构造函数和原型
JS 中,万物皆对象,而对象,皆出自构造(构造函数),而所有的函数,都有一个特殊的属性 prototype(原型)
在 ES6之前 ,对象不是基于类创建的,而是用一种称为构建函数的特殊函数来定义的
构造函数
开发中,我们会把一个对象中的可共用的属性或方法提取出来,并为这些成员赋初始值。通常我们还要保证在使用他们的时候,不会对原有的成员产生修改。这就产生了构造函数(用来创建新对象的函数)。
先来看看开发时我们编写的构造函数是个什么样子
// 函数声明形式
function Person () {}
// 函数表达式形式
let Person = function () {}
关于它的使用:
- 用于创建某一类对象,其首字母要大写
- 要和 new 一起使用才有意义(new + 构造函数)
构造函数与函数的区别?
唯一区别:调用方式不同,一个是通过 new 调用,一个直接调用
这时候,残忍的面试官又蹦出来了,哈哈哈哈hiahiahiahia!
new 操作符在执行时做了哪些事情,你可以手写一个 简单的 new 吗?
new 操作符会创建一个被定义的对象类型的实例或具有构造函数的内置对象类型
new 在执行时会做四件事情:
- 在内存中创建一个新的空对象
- 设置原型链,将构造函数的原型对象设为新对象的原型
- 让构造函数中的 this 指向新对象,并执行构造函数(给新对象添加属性)
- 返回这个新对象(这就是构造函数里面不需要 return 的原因)
function myNew (constrc, ...args) {
let obj = {}; // 1. 创建一个空对象
obj.__proto__ = constrc.prototype; // 2. 将obj的__proto__属性指向构造函数的原型对象
let res = constrc.apply(obj, args); // 3. 将构造函数constrc执行的上下文this指向obj,并执行
return res instanceof Object ? res : obj; // 4. 确保返回一个对象(res为空则返回新对象)
}
上面是为了我们充分理解原理,其实第二步的写法有问题(__proto__ 本质上是个getter/setter)
我们也可以将第一跟第二步代码简化
function myNew (constrc, ...args) {
let obj = Object.create(constrc.prototype); // 以构造函数的prototype属性为原型,创建新对象
let res = constrc.apply(obj, args); // 将构造函数constrc执行的上下文this指向obj,并执行
return res instanceof Object ? res : obj; // 确保返回一个对象(res为空则返回新对象)
}
上面我们使用了一个 Object.create() 方法,常用来创建一个新对象,或者做对象的浅拷贝
// 模拟实现一下
function create(prototype) {
function F() {};
F.prototype = prototype;
return new F();
}
note:有一种设计模式叫工厂模式(一个可添加属性和方法的函数,返回一个对象),用于抽象创建多个类似对象。通过上面的分析,我们可以知道,构造函数一样可以创建多个类似对象,而且,还可以(通过consturctor)确保实例对象的类型
好了,我们 new 完一个女朋友(对象)后,就跟看看我们这个女朋友到底是咋样的,好不好看,有没有才,家庭背景又是如何?那就得问问第二步中这个关键的牵线媒婆(prototype)了。
prototype(原型)
prototype 属性其实是一个对象(原型对象),它的方法和属性都可以被函数的实例所共享。所谓的函数实例是指以函数作为构造函数创建的对象(就是 new 出来的女朋友没错啦),这些对象实例都可以共享构造函数的原型的方法和属性(比如女娲捏的女朋友们,都有固定的女性特点)。
想想看,我们辛苦的女娲娘娘,给我们捏那么多女朋友,多累啊(还很费内存哇)。聪明的你肯定会想到,捏一个模板,然后基于这个模板一直复制共享的东西过去就方便多啦。呐,你要的 prototype 就出来啦!通过它,我们找到模板所在(原型对象),就可以知道女朋友的身体组织器官(属性)和她们特有的女性性格魅力(方法)
另外我们还需要知道三个关键概念:
- prototype:让函数实例化的对象都可以找到共享的属性和方法
- __proto__:指向构造函数的 prototype 指向的原型对象
- constructor:原型对象的属性,指回构造函数
接下来,我们再通过一张图来理清构造函数、实例对象、原型对象三者之间的关系
原型链
原型链就是一条实例与原型之间的关系路线。具体查找机制如下:
- 当访问一个对象的属性或方法时,首先查找这个实例对象自身(ldh)有没有该属性。
- 如果没有就查找它的原型(也就是它的 __proto__指向的 Star 的原型对象 prototype)。
- 如果还没有就查找原型对象的原型( __proto__指向的Object的原型对象 prototype)。
- 如果还是找不到,就会返回 null ( __proto__指向的Object的原型对象的原型是 null)
__proto__ 的意义就在于为对象成员查找机制提供一个方向,看图
做一个面试时的简要回答:
原型链的原型搜索机制:对象自身,到该对象的原型,到 Object 的原型,再到 null。
当然,如果你能结合 prototype、__proto__ 和 constructor 来做更细致的回答,那一定是个加分项!(图已经给你了,我相信你一定没问题的!)
more(了解):
- __proto__和 constructor 属性是对象所独有的;
- prototype 属性是函数所独有的,因为函数也是一种对象,所以函数也拥有 __proto__ 和constructor 属性。
- 我们可以从图中发现,查找路线都是通过 __proto__ 这个属性,它的作用就是为这条查找路线提供方向。
修改原生对象原型
在实际的开发过程中,我们有一个封装过的 JS 文件,里面写了一个构造函数(作为一个对象原型),为了给它定义新的方法,我们可以修改它的原生对象原型。如下:
// 构造函数
function Huohuo (opt) {
const opt = opt || {};
this.name = opt.name || '';
this.age = opt.age || 3
}
// 修改对象原型,添加新方法
Huohuo.prototype.huoFun = function () {
const that = this;
console.log('I am ' + that.name);
}
开发环境下我们做封装可以这么写,但是在生产环境下修改原生对象原型,可能会造成误会喔!(怎么写着写着把我原型都给改了?)还可能会引发命名冲突(你这个名字我取过了,你还取?),同样可能会意外重写原生的方法(啥意思啊,农民把我地主给干了?)。
那可怎么办呢:创建一个自定义的类,继承原生类型
继承
来自红宝书:实现继承是 ES 唯一支持的继承方式,而这主要是通过原型链实现的。
继承的方式不少,比如原型链继承、构造函数继承、组合继承、寄生式继承、寄生式组合继承以及上个问题最后提到的类实现继承。这部分内容其实在一年前已经写过,由于篇幅原因,这里就不重复了。所以在原有文章基础上,做了一些修改。(请阅读:JS-继承)
这一块我们能够理解原理最好了,实际上可能不会用到。因为我们有一个众所周知的软件设计原则:“Composition over Inheritance(复合胜过继承)”,复合模式的代码实际能给我们提供极大的灵活性。
谈谈你对 this 的了解
对于 this 关键字,在 OOP 编程语言中我们再熟悉不过了,在 C++、C#、Java 中,或许你可以使用得顺手捏来,但是在 JS 中,用起来可是要混乱的多了喔!
this 指向问题
我相信这个问题给大家带来过不少的困扰,可能当面试官问你,这个时候或那个时候, this 指向哪,你都能正确说出,但是当面试官把问题包装下,不直接问你,很多人就摸不着头脑了
要判断 this 的指向,我们首先要明确一点:
this 的指向不是在编写时确定的,而是在执行时确定的,且遵循了一定的规则
- 默认情况下:this 指向全局对象(浏览器中为 window,node.js 中为global),比如直接调用一个普通函数,它的 this 指向全局对象
- 隐式绑定:函数被调用的位置存在上下文对象(obj.foo()),this 指向被调用的位置(obj对象)
- 显式绑定:使用 call、apply、bind 改变 this 指向,this 指向第一个参数,无参数为全局对象
- new:会调用一个构造函数,创建新对象,新对象的 this 会被重新绑定到新对象上(实例化对象上)
- 箭头函数:没有自己的 this,以其所在上下文的 this 作为自己的 this(不可改变)
如果多个规则同时出现,我们就要像判断 CSS 选择器优先级一样做优先级的判断了:
new(调用构造函数) >显示绑定( call、apply、bind) > 隐式绑定(obj.foo())>默认全局对象
如果你依然有些困惑,拿在送你一张图叭!
或许这些你早就熟稔于心,咳咳,我也一样。但,只是你没有遇到像这样的面试官而已 ~
面试官追问 this
1、你刚刚说可以使用 call、apply、bind 来改变 this 指向,那么我如果对一个函数进行多次 bind,那这个 this 指向哪里呢?
我们先举个例子看看
const a = {};
const foo = function () {
console.log(this);
};
foo.bind().bind(a)(); // window
foo.bind(a).bind()(); // a
这么看不太好观察出来,我们换个形式
// fn.bind().bind(a) 等于
const fn2 = function fn1() {
return function () {
return foo.apply();
}.apply(a)
};
fn2();
不难看出,不管我们 bind 多少次,foo 中的 this 指向始终由第一次 bind 决定。
2、我们在 html 标签中做事件绑定的时候,绑定的事件中的 this 指向哪里?
同样做个简单举例
<button id="btn" onclick="foo()"></button>
<script>
function foo() {
console.log(this); // window
}
</script>
这里只是点击触发了一个函数,这个函数的 this 没有指定方向,所以默认指向全局对象(window)
相信你一定用到过事件绑定同时传值的情况把,我们来看看这又会发生什么
<button id="btn" onclick="foo(this)"></button>
<script>
function foo(arg) {
console.log(this); // window
console.log(arg); // button 节点
console.log(arg.id); // btn
}
</script>
打印出的 this 依然指向 window(这个函数的指向当然还是默认的啦),但是,我们可以通过传进来的 arg 拿到 onclick 所在 DOM 节点。其实这里可以理解为 this 指向绑定的事件对象。
3、不错不错,那我再给你出道代码题,看看你能答对不
const a = function () {
b();
};
const b = function () {
console.log(this);
}
window.setTimeout('a()', 3000);
打印的 this 指向哪里?哈哈,千万不要被吓到了,尽管 b 被嵌套了几层,但 b 始终是 b,整个过程只是函数之间的普通调用与执行,并不存在 this 方向的改变,所以这个 this 依然还是 b 的,也即普通函数的 this 指向全局对象(window)
另外,定时器函数和立即执行函数, this 都是指向 window 哦!
4、在写函数部分的时候,遇到了一个 this 的问题, 回到这里我们一起来看看
let name = 'cc'; // 定义一个作用域内的变量
function foo () {
this.name = '520';
setTimeout(function () { console.log(this.name); }, 3000); // 回调函数直接调用函数
};
new foo(); // cc
function fun () {
this.name = 'huohuo';
setTimeout(() => console.log(this.name), 3000); // 回调执行箭头函数
};
new fun(); // huohuo
我们发现 foo 中打印的是 cc 而不是 520,说明回调函数中的 this 并不是指向函数体内的 name,而是全局中的 name。而 fun 中,打印的是函数体内的 name。这是因为,箭头函数中的 this 会保留定义该函数(回调函数)时的上下文(fun 函数的内部作用域)。
针对以上这些 this 问题,首先,我们要清晰上面提到的 5 个规则和优先级排列,并时刻关注是否在哪有发生 this 指向的改变,这样就不会出错啦!
刚刚我们一直都在说 this 指向的改变,那我们主要有哪些方法呢?
改变 this 指向
主要通过三个 API 来实现,call、apply、bind。它们都可以改变函数内部的 this 指向。
面试时一般都会问你,它们的区别是什么?
- call 和 apply 都会调用原函数,bind 不会
- call 和 bind 都是以逗号的形式传递参数,apply 除第一个参数外的值都包含在一个数组里
我们如何选择使用呢?
- call :想改变 this 指向,又想调用原函数。(如继承)
- apply:跟数组有关系
- bind:只想改变 this 指向
手写 call/apply/bind
这里主要参考了冴羽大佬的文章分享,建议大家点进去阅读完后再回来看(中间加了一点自己的改改进跟理解)。重新研究大佬的分析思路,仍然受益匪浅,感谢!
- 分析该函数内部做了什么(功能列举)
- 传入函数的参数在函数内部会做哪些处理(传参)
- 函数内部语句的执行情况是否会受哪些限制(分情况讨论)
call:使用一个指定的 this 值和若干个指定的参数值的前提下调用某个函数或方法。
call 函数内部做了什么?
-
创建一个函数并将函数设为对象的属性
-
指定 this 到函数
-
传入给定参数执⾏函数
-
删除这个函数
-
如果不传入参数,默认指向为 window
-
返回值
Function.prototype.myCall = function (context) {
// 如果 context 为 null/undefined,context 指向 window(或global)
context = Object(context) || window; // 装箱操作将原始类型 context 包装成对象
context.fn = this; // 通过 this 获取到调用 call 的函数,并给context 添加一个属性 fn
let args = [];
// 将 arguments 对象(类数组)转为数组
for (let i = 1, len = arguments.length; i < len; i++) {
args.push(arguments[i]); // 取出第二个到最后一个参数
}
// 其实上面这一步也可以用 let args = [...arguments].slice(1);
let result = context.fn(...args); // 将参数传入调用函数执行
delete context.fn; // 删除这个中间函数
return result; // 返回调用函数后的返回值
}
apply:使用一个指定的 this 值和一个数组的前提下调用某个函数或方法。
跟 call 类似,但是要注意传入的第二个参数是个数组,处理也就更简单了
Function.prototype.myApply = function (context, arr) {
// 如果 context 为 null/undefined,context 指向 window(或global)
context = Object(context) || window; // 装箱操作将原始类型 context 包装成对象
context.fn = this; // 通过 this 获取到调用 call 的函数,并给context 添加一个属性 fn
let result;
if(!arr) {
result = context.fn; // 没有传数组参数的情况下
} else {
result = context.fn(...arr); // 将参数传入调用函数执行
}
delete context.fn; // 删除这个中间函数
return result; // 返回调用函数后的返回值
}
bind:bind 方法会创建一个新函数。当这个新函数被调用时,bind 的第一个参数将作为它运行时的 this,之后的一序列参数将会在传递的实参前传入作为它的参数
bind 函数内部做了什么?
- 返回⼀个函数,绑定this,
- 参数可以在 bind 的时候传,还可以在执行返回的函数的时候传其他参数
- bind 返回的函数可以作为构造函数使用,bind 时指定的 this 值会失效,但传入的参数依然生效(修改函数原型,构造实例关系)
Function.prototype.myBind = function (context) {
if (typeof this !== 'function') { // 如果调用的 bind 不是函数,就报错
throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
}
let args = Array.prototype.slice.call(arguments, 1), // 获取 bind 函数从第二个参数到最后一个参数
that = this,
fNOP = function () {}, // 使用一个空函数,帮助修改原型
fBound = function () {
// 这个时候的arguments是指bind返回的函数传入的参数
let bindArgs = Array.prototype.slice.call(arguments);
// this instanceof fBound === true 时,说明返回的 fBound 被当做 new 的构造函数调⽤
// 获取调⽤时(fBound)的传参, bind返回的函数⼊参往往是这么传递的
return that.apply(this instanceof fBound ? this : context, args.concat(bindArgs));
};
// 维护原型关系
if (this.prototype) {
// fNOP 函数的原型没有 prototype 属性
fNOP.prototype = this.prototype;
}
// 使 fBound.prototype 是 fNOP 的实例
// 因此,返回的 fBound 若作为 new 的构造函数,new ⽣成的新对象作为 this 传⼊ fBound, 新对象的__proto__就是 fNOP的实例
fBound.prototype = new fNOP();
return fBound;
};
上面我们提到了很多数据,比如对象、类、函数,接下来做个关于它们的梳理。可能面试中不太会问到,但是我觉得非常有必要理解它们,帮助我们在实际敲代码的时候减少知道这样用但是不知道为什么的疑惑。
对象
什么是对象:一组属性(数据或函数)的无序集合,每个属性都由一个名称来标识
创建对象的方式
- new Object():创建 Object 的一个实例对象,再给它提那家属性和方法
- 对象字面量:花括号 { } 里面包含了表达这个具体事物(对象)的属性和方法(开发常用)
- new + 构造函数:使用 new 来操作构造函数返回一个实例化对象
- new + 类:使用 new 来操作 Class 类 返回一个实例化对象
对象属性(了解)
我们都会给对象做一些添加、修改、删除属性的操作,这些属性到底有什么样的规则呢?我觉得很有必要了解一下了。
1、对象属性类型(两种)
数据属性,有四个特性:
- [Configurable]:属性是否可以 delete、修改特性、改为访问器属性。默认 true
- [Enumberable]:属性是否可以 for in 循环,默认为 true
- [Writable]:属性值是否可以修改,默认为 true
- [Value]:属性实际的值,默认undefined
访问器属性(必须使用 Object.defineProperty 定义):
- [Configurable]:属性是否可以 delete、修改特性、改为访问器属性。默认 true
- [Enumberable]:属性是否可以 for in 循环,默认为 true
- [Get]:获取函数,读取属性时调用,默认undefined(非必须)
- [Set]:设置函数,写入属性时调用,默认undefined(非必须)
more:读取属性的特性可以通过 Object.getOwnPropertyDescriptor()
看起来远没有那么简单哇,所以 JS 又引入了几个新语法特性来帮助我们
2、对象语法的增强
属性值简写:属性名跟变量名相同
let person = {
name: name
}
// 简写
let person = {
name
}
可计算属性:动态属性赋值(运行时作为 JS表达式而不是字符串求值)
const name = 'cc';
let person = {
[name]: 'huohuo'
};
console.log(person.name); // huohuo
简写方法名:缩短方法声明
let person = {
myFun: function () {
console.log('huohuoit');
}
};
// 简写
let person = {
myFun() {
console.log('huohuoit');
}
};
// 与计算属性兼容
const myFun= 'hisName ';
let person = {
[myFun]() {
console.log('huohuoit');
}
};
person.hisName(); // huouhuoit
对象解构(解构赋值):在一条语句中使用嵌套数据实现声明多个变量,同时指向多个赋值操作
let person = {
name: 'huohuo',
age: 3
};
let {name: huohuoName, age: huohuoAge} = person;
console.log(huohuoName); // huohuo
console.log(huohuoAge); // 3
使用解构赋值时,可能会发生引用的值可能不存在的情况,比如上面的 age 在 person 对象中不存在的时候,打印出的 huohuoAge 就是 undefined。
不过这个时候有个小技巧,我们可以在解构赋值的同时定义默认值
let {name, age = '18'} = person;
注意:null 、undefined 不能被解构哦!(具体原因在于解构内部的 ToObject 函数)
当然你也可以在函数中使用解构赋值
let person = {
name: 'huohuo',
age: 3
};
function fun (foo, {name: huohuoName, age: huohuoAge}, bar) {
console.log(huohuoName); // huohuo
console.log(huohuoAge); // 3
};
另外 JS 对象还有很多方便的静态方法,可以帮助我们快速开发,以及解决一些头疼的算法问题。它们都静静地躺在迷人的 MDN ,期待你的光顾!
类
在 ES6 中新增加了类的概念,可以使用 class 关键字声明一个类,以这个类来实例化对象。
- 类 抽象了对象的公共部分,它泛指某一大类(class)
- 对象特指某一个,可以通过 new + 类 的形式实例化出的一个具体的对象
或许你会稳类到底是个什么?我们打印下就知道了
类 就是一个(特殊的)函数
创建类的方式
//语法
class Name {
// class body
}
//创建实例
let cc = new Name();
// 也可以立即实例化
let hh = new class Foo {
constructor(e) {
console.log(e);
}
} ('huohuo')
// huohuo
console.log(hh); // Foo {}
关于类的一些注意点:
- 类没有变量提升,所以必须先定义类,才能通过类实例化对象
- 方法之间不能加逗号分隔,同时方法不需要添加 function 关键字
- constructor:是类的构造函数, 用于传递参数,返回实例对象 ,通过 new 命令生成对象实例时 ,自动调用该方法。如果没有显示定义, 类内部会自动给我们创建一个 constructor()
- super 关键字:用于访问和调用对象父类上的函数。(包括构造函数和普通函数)
- 类里面的共有属性和方法一定要加 this 来使用,且子类在构造函数中使用 super, 必须放到 this 前面 (必须先调用父类的构造函数,再使用子类继承得到的属性和方法)
为了方便理解,这里给出一个使用类实现的继承代码
// Class 类继承
class Father {
constructor(surname) { // 创建类的构造函数(告诉解析器跟 new 搭配使用。非必须)
this.surname = surname; // 类的属性声明用 this 即可
}
saySurname () {
console.log('My surname is ' + this.surname); // 访问类的属性要用 this
}
}
class Son extends Father { // 这样子类就继承了父类的属性和方法
constructor(surname, firstname) {
super(surname); // 通过调用 super 来调用父类的构造函数,并初始化父类的属性
this.firstname = firstname; // 初始化一个子类属性
}
sayFirstname () {
console.log('My firstname is ' + this.firstname);
}
}
const cc = new Son('ai', 'huohuo');
cc.saySurname(); // My surname is ai
cc.sayFirstname(); // My firstname is huohuo
函数
每个函数都是 Funtion 类型的实例,即函数就是对象。而函数名就是指向函数对象的指针,也就是 JS 中的变量,所以函数可以用在任何可以使用变量的地方(作为方法、作为参数、作为返回值等)。
note:函数名只是保存指针的变量,全局定义的函数(foo())和对象调用的函数(obj.foo())是同一个函数,但是它们执行的上下文不一样
函数的定义方式(4种)
函数声明(JS 引擎在执行代码前会先读取函数声明(函数声明提升),并在执行上下文中生成函数定义)
function foo () {}
函数表达式(只有到代码执行时才会在执行上下文中生成函数定义)
let foo = function () {}
箭头函数(简洁的语法非常适合嵌入函数的场景)
let foo = () => {}
Function 构造函数(Out)
let foo = new Function('a', 'b', 'return a + b')
注:不推荐使用 Function 构造函数的方式来定义函数,因为这段代码会被解释两次(一次是作为常规 JS 代码执行,一次是解释传给构造函数的字符串),会影响性能。
几个脑瓜疼的区别问题
构造函数与普通函数的区别?
- 唯一区别:调用方式不同,一个是通过 new 调用,一个直接调用(pass)
类构造函数和普通构造函数的区别?
- 主要区别:调用类构造函数必须使用 new 操作符(否则会报错)。而普通构造函数如果不用 new 调用,就会以全局的 this 作为内部对象。
普通函数与类的区别?
- 函数声明会被提升而类声明不被提升。首先需要声明您的类然后访问它
- 类具有特殊的关键字 构造函数(只能有一个),或者抛出错误。函数可以有多个名为constructor 的函数变量定义
- 类具有特殊关键字 super,可以调用它来调用父类构造函数
- 类中可以使用关键字 static 定义函数
箭头函数与普通函数的区别?
- 没有自己的 this,且不可以改变 this 的绑定。箭头函数中没有this绑定,必须通过查找作用域链来决定其值。 如果箭头函数被非箭头函数包含,则this绑定的是最近一层非箭头函数的this,否则this的值则被设置为全局对象
- 不能用作构造函数
- 没有原型对象 prototype
- 不能使用 super、arguments 和 new.target
- 形参名称不能重复
函数参数
JS 的函数不关心传入参数的个数或数据类型,因为函数参数在内部表现为一个数组。(函数的参数只是为了方便才写出来的)
比如可以拿到参数值的 arguments 类数组对象,第一个参数为 arguments[0],第二个参数为 arguments[1],同时可以通过 arguments 对象的 length 属性检查传入参数个数(arguments 只受传入参数影响)。(严格模式下不能修改 arguments 对象)
注意:
- 箭头函数中,参数不能使用 arguments 访问,但可以在包装函数中传给箭头函数。(JS 中所有参数都是按值传递的)
- 函数的默认参数只有在函数被调用时才会求值(因为此时计算默认值的函数才会执行)
- 参数初始化顺序遵循暂时性死区原则(即前面定义的不能引用后面定义的)
- 参数也存在于自己的作用域中,不能引用函数体的作用域
参数扩展与收集
场景1(参数扩展):给函数传参时,有时可能不需要传一个数组,而是分别传入数组的元素
举例实现如下:
const arr = [1, 2, 3, 4];
function getSum () {
let sum = 0;
for (let i = 0; i < arguments.length; i++) {
sum += arguments[i];
}
return sum;
}
getSum(arr); // "01,2,3,4"
getSum.apply(null, arr); // 10 正确输出
从上我们可以看到,getSum(arr) 是无法正常输出的,因为我们不是传入一个数组,而是一个元素值,这个时候我们传入的是字符串‘1,2,3,4’,与 sum(此时为0)做加法运算,就得出‘01,2,3,4’。这样我们就没有实现将数组值一个一个元素地传进去。
0 + 1,2,3,4 = 01,2,3,4
所以我们可以想到用 apply 来实现这个操作(使用一个指定的 this 值和一个数组的前提下调用某个函数或方法。),apply 内部会依次把数组元素传入 getSum 并执行返回最后的值
0 + 1 = 1 // sum
1 + 2 = 3 // sum
3 + 3 = 6 // sum
6 + 4 = 10 // sum
幸运的是,在 ES6 中,扩展运算符帮助我们简化了这个操作,它可以作为参数传入,将可迭代对象(数组 arr)拆分,并将迭代返回的每个值单独传入( getSum 执行)
getSum(...arr); // 10
// 甚至可以这样(因为数组长度已知,不影响前后传参)
getSum(-1, ...arr); //9
getSum(...arr, 5); // 15
getSum(-1, ...arr, 5); // 14
getSum(...arr, ...[5,6,7]); // 28
注:函数中的 arguments 并不关心你是否使用了扩展运算符,它只忠心耿耿地关注调用函数时传入的参数(可以理解为两者互不影响)
arguments 都能这样利用扩展运算符了,那普通函数和箭头函数中一样可以使用啦!
场景2(参数收集):定义函数时,使用扩展运算符把不同的参数组合(收集)为一个数传入函数中
举例:
function getSum (...arr) {
// 按顺序累加 arr 中的值。且初始值总和为 0
return arr.reduce((x, y) => x + y, 0);
}
getSum(1,2,3,4); // "10"
注意:收集参数时,该方式是收集命名参数以外的所有参数(没有则为空数组),所以只能把它作为最后一个参数哦
function getSum (real, ...arr) {}
let getSum = (prev, ...arr, next) => {}
上面的分析主要是为了帮助我们加快实际开发的效率及对 ES6 的使用与理解,相信你一定会爱上它的。不信?那你是否记得数组去重呢?
const newArr = [...new Set(arr)]; // 嗯?快不快?
函数内部的特殊对象(了解)
arguments
它是一个类数组对象,包含调用函数时传入的所以参数。且这个对象只有以 function 关键字定义函数时才会有。
arguments.callee(严格模式下访问会报错) :指向 arguments 对象所在函数的指针
怎么理解 callee 呢?比如我们有一个计算阶乘的递归函数
function factorial (num) {
if(num <= 1) {
return 1;
} else {
return num * factorial(num -1);
}
}
我们可以注意到,这个函数的正确执行必须保证函数名统一(函数名跟函数逻辑都是 factorial),这就是高耦合的一种体现。那么如何优化呢?
function factorial (num) {
if(num <= 1) {
return 1;
} else {
return num * arguments.callee(num -1);
}
}
new target()
ES6 新增,用于检测函数是否使用new 关键字调用。正常调用则返回 undefiend,new 调用返回被调用的构造函数。