前言
作为一名朴实无华的前端开发,记忆能力是有限的,所以每年我都会对前端基础知识做一个复习和巩固。所以我推荐掘金上一位小哥哥写的《前端面试之道》小册作为复习的路线。废话不多说,接下来就是我结合自己理解的读书笔记。
JS 基础知识
- JS 基础类型
string、number、undefined、null、boolean、symbol。
虽然type of null的值为object,但是他是基础类型,因为 JS 历史原因,一开始是 32 位系统,000开头代表对象,而 null 为全 0,所以它被错误的判断为object。 - JS 对象类型
除了基础类型,便都是变量类型。
它们的区别在于,基础类型存储在栈内存,对象类型存储在堆内存。听起来好像挺复杂的,其实很简单。
基础类型存储的就是一个值,而对象类型存储是地址,创建一个对象类型,内存会帮我们开辟一个空间来存放值,我们要找到这个空间,就需要一个地址,这个地址就会被赋值给变量。
const a = []const b = ab.push(1)
此时 a 和 b 指向的是同一个地址,所以值都被改变了
我们再来看看下面这种情况:
function test(person) {person.age = 30person = {name: 'nick',age: 27}return person}const p1 = {name: 'cxy',age: 27}const p2 = test(p1)console.log(p1)console.log(p2)
答案是 p1 输出 { name: ‘cxy’, age: 30 } p2 输出 { name: ‘nick’, age: 27 }
一开始我以为 p1 也会被篡改,因为它们的地址指向的是同一个内存空间,但是在 person 被重新赋值 { name: 'nick', age: 27 } 的时候,内存又重新分配了一个新的地址给 person。
typeof 和 instanceof
这俩大爷使用的频率还是很高的,但是从准确度来说,两个都不是完全准确。typeof 可以判断基础类型,但是遇到数组和对象都会返回 object 。instanceof 相对来说会准确一下,但是遇到基础类型也是判断不出来,如下:
const Person = function() {}const p1 = new Person()p1 instanceof Person // truevar str = 'hello world'str instanceof String // falsevar str1 = new String('hello world')str1 instanceof String // true
它的原理是通过原型链来判断。
Symbol.hasInstance 可以自定义 instanceof 的行为。
类型转化
类型转化在 JS 中只有三种情况:
- 转换为布尔值
- 转换为数字(Number())
- 转换为字符串(String())
this 指向问题
先来一个场景
function foo() {console.log(this.a)}var a = 1foo()const obj = {a: 2,foo: foo}obj.foo()const c = new foo()
分析一下上面的几种场景:
1、直接调用 foo() ,不管 foo 函数放在那里,this 一定是 window;这个叫谁调用它,this 就指向谁。
2、同理,obj.foo() 是 obj 在调用,所以此时 foo 函数中的 this 指向的是 obj 对象。
3、对于 new 的方式来首, this 永远绑定在 c 上面,不会被任何方式改变。
4、尖头函数本身是没有 this,尖头函数中的 this 只取决包裹尖头函数的第一个普通函数的 this。
用一张图来加深记忆:
== 和 === 的比较
== 比较的是类型,在类型不同的情况下,会转化类型,大致流程如下:
例题:[] == ![] 答案是 true
分析:![] 为 false,因为 ! 只有三种情况为 true,分别是 !0、!-0、!NaN,其他情况都为 true(我个人是这么理解的);然后 [] 为数组,属于对象类型,所以需要转化为 string 类型,所以 String([]) = "",所以最终的对比是这样的 "" == false,答案为 true。
=== 则比较简单,类型和值必须相等。
闭包
什么是闭包?
不得不说,这个问题面试必问。
闭包的定义其实很简单:函数 A 内部有一个函数 B,函数 B 可以访问到函数 A 中的变量,那么函数 B 就是闭包。
闭包存在的意义:让我们可以间接访问函数内部的变量
深浅拷贝
浅拷贝
早些年也是被这个知识点坑过。
首先前拷贝有两种方式:
1、Object.assign({}, a)
2、ES6 的 {...a}
浅拷贝的特点,第一级若是基础类型,会将值拷贝到新的变量中去,改变旧值不会影响新变量的值。
但是一旦旧值第N级有对象类型的话,还是回到老问题,拷贝去的是地址,改变对象值会互相影响。
深拷贝
第一个想到的便是 JSON.parse(JSON.stringify(a))。
此方法有几个缺点:
- 会忽略 undefined
- 会忽略 symbol
- 不能序列化函数
- 不能解决循环引用的对象
但是它还是能解决大部分问题。
手动实现简易版深拷贝:
function deepClone(obj) {function isObject(o) {return (typeof o === 'object' || typeof o === 'function') && o !== null}if (!isObject(obj)) {throw new Error('非对象')}let isArray = Array.isArray(obj)let newObj = isArray ? [...obj] : { ...obj }Reflect.ownKeys(newObj).forEach(key => {newObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]})return newObj}let obj = {a: [1, 2, 3],b: {c: 2,d: 3}}let newObj = deepClone(obj)newObj.b.c = 1console.log(obj.b.c) // 2
说实话,别跟自己过不去,作者也推荐使用 lodash 的 cloneDeep 函数,它不香吗?
原型
如何理解原型?如何理解原型链?
说说我的个人理解,原型有下面几个原则:
- 所有的引用类型,都具有对象特性,即可自由扩展属性(除了“null”以外)
- 所有的引用类型,都有一个隐式原型
__proto__属性,属性值是一个普通的对象 - 所有的引用类型,隐式原型
__proto__属性值指向它的构造函数的显式原型prototype属性值 - 当试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么它会去它的隐式原型
__proto__(也就是它的构造函数的显式原型prototype)中寻找。
那么我们来一一的验证上面几个原则,就会慢慢的理解原型和原型链。
1、所有的引用类型,都具有对象特性,即可自由扩展属性(除了“null”以外)
var obj = {};var arr = [];var fn = function() {}obj.a = 1;arr.a = 2fn.a = 3
这个规则应该比较好理解,额外介绍一个小知识点,var obj ={}; 相当于 var obj = new Object(); 不过在正常的工作业务中,不会去这么定义一个对象,因为不仅麻烦,可读性也会变差。
2.所有的引用类型,都有一个隐式原型proto属性,属性值是一个普通的对象
var obj = {};var arr = [];function fn() {}console.log(obj.__proto__);console.log(arr.__proto__);console.log(fn.__proto__);

3.所有函数都有一个显式原型prototype
4.所有的引用类型,隐式原型proto属性值指向它的构造函数的显式原型“prototype”属性值
var obj = {};var arr = [];function fn() {}console.log(obj.__proto__ === Object.prototype) //trueconsole.log(arr.__proto__ === Array.prototype) // trueconsole.log(fn.__proto__ === Function.prototype) // true
4.当试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么它会去它的proto(也就是它的构造函数的显式原型prototype)中寻找
var obj = {a:1};obj.toString(); // [object, object]
obj 本身是没有toString方法属性的,之所以能获取到这个方法,其实就是遵循了第四条规则,从他的构造函数Object的prototype中去拿到了这个方法。
举个原型链的例子:
function Person(name) {this.name = name;return this; // 其实这行可以不写,默认不认返回this对象}var nick = new Person("nick");nick.toString(); // [object, object]
按理说,nick 是构造函数
Person生成的实例,而它的prototype并没有toString方法,那为什么 nick 对象能获取到toString方法呢?其实这就涉及到原型链了,nick 先去找自身有无toString方法属性,找不到那就往上走,找构造函数的prototype,还是没找到,那么就继续往上,构造函数的prototype其实也就是一个对象(如下图所示),那么对象的构造函数就是Object,所以就找到了Object.prototype下的toString方法

总结:
最后一个 null,设计上是为了避免死循环而设置的, Object 的隐式原型指向 null。
既然说到了原型,那就说说判断一个引用类型的变量。如下代码:
function Foo(name) {this.name = name;}var f = new Foo('nick')f instanceof Foo // truef instanceof Object // true
instanceof 是通过原型去进行比较对象是否属于当前比较的构造函数,f instanceof Foo 的判断逻辑: f 的隐式原型 __proto__ 一层一层往上,能否对应到 Foo.prototype 同理 f instanceof Object 也是为 true ,因为往上找,也能找到 Object.prototype。
ES6 的一些常见考点
var、let、const 的区别
说到它们仨,就得想起变量提升。那么何为变量提升,就是在申明变量之前就去使用它,却不会报错。
console.log(a) // undefinedvar a = 1
上述代码其实可以被看作是:
var aconsole.log(a)a = 1
函数同样也会被提升:
console.log(a) // ƒ a() {}function a() {}var a = 1
它们的区别在与:
- 全局状态下用
var声明变量,变量会被挂在到window上,而let和const不会。 - 用
let和const声明变量,会存在暂时性死区,必须在声明后才能使用。 - 函数提升优先于变量提升,函数提升会把整个函数挪到作用域顶部,变量提升只会把声明挪到作用域顶部
重新认识 reduce
正常实现一个数组内元素的累加:
const arr = [1,2,3]let total = 0for (let i = 0; i < arr.length; i++) {total += arr[i]}console.log(total) // 6
使用 reduce 实现:
const arr = [1,2,3]const sum = arr.reduce((acc, current) => (acc + current), 0)console.log(sum) // 6
reduce 的第一个参数为函数,函数接受四个参数,分别是累计值、当前值,当前值索引,原数组。 reduce 的第二个参数为初始值,初始值无论是什么类型,都会体现在第一个参数(函数)的第一个参数。
reduce 模拟 map
const arr = [1, 2, 3]const mapArray = arr.map(value => value * 2)const reduceArray = arr.reduce((acc, current) => {acc.push(current * 2)return acc}, [])console.log(mapArray, reduceArray) // [2, 4, 6]
JS 异步编程及常考面试题
并发和并行的区别?
- 并发是宏观的概念,假设我有两个任务 A 和 B,在某段时间内通过任务间的切换完成这两个任务,这叫并发
- 并行是微观概念,假设我还是有两个任务,在多核 CPU 的情况下,同时跑两个任务,这叫做并行。
你理解的
Generator是什么?
Generator (生成器)是 ES6 引入的一个新的数据类型,它最大的特点就是控制函数的执行,可多次返回。
举个例子:
function *foo(x) {let y = 2 * (yield (x + 1))let z = yield (y / 3)return (x + y + z)}let it = foo(5)console.log(it.next()) // => {value: 6, done: false}console.log(it.next(12)) // => {value: 8, done: false}console.log(it.next(13)) // => {value: 42, done: true}
分析:
- 首先
generator函数的调用和普通函数不同,它会返回一个迭代器,英文名叫iterator,所以很多教程里generator函数返回的值都叫it。 - 第一次执行的时候,传递的参数会被忽略,并且函数暂停在
yield(x + 1)处,所以返回了 6 - 当执行第二次
next时,传入的参数就是上一个yeild的返回值,如果不传参,yeild永远返回undefined。此时let y = 2 * 12,所以第二个yeild等于2 * 12 / 3 = 8 - 当执行第三次
next时,传入的参数会传递给 z,所以z = 13, x = 5, y = 24,相加等于 42
Generator 函数一般见到的不多,其实也于他有点绕有关系,并且一般会配合 co 库去使用。当然,我们可以通过 Generator 函数解决回调地狱的问题,可以把之前的回调地狱例子改写为如下代码:
function *fetch() {yield ajax(url, () => {})yield ajax(url1, () => {})yield ajax(url2, () => {})}let it = fetch()let result1 = it.next()let result2 = it.next()let result3 = it.next()
Promise
关于 Promise 的解释,这边推荐一篇好文,吕大豹的 大白话讲 Promise。
手写一个 Promise
先来来一个简单同步版本的,发现问题之后,才能更好的理解异步版本的。
// Promise/A+ 规范规定,Promise 有三个状态const PENDING = 'PENDING'const FULLFILLED = 'FULLFILLED'const REJECTED = 'REJECTED'class MyPromise {constructor (executor) {this.value = nullthis.reason = nullthis.status = PENDING // 默认 pending 状态let resolve = (value) => {if (this.status === PENDING) {this.status = FULLFILLEDthis.value = value}}let reject = (value) => {if (this.status === PENDING) {this.status = REJECTEDthis.value = value}}try {executor(resolve, reject)} catch (e) {reject(e)}}// then 方法接收两个参数 onFulfilled 和 onRejectedthen(onFulfilled, onRejected) {if (this.status == FULLFILLED) {onFulfilled(this.value)}if (this.status == REJECTED) {onRejected(this.value)}}}
我们来实验一下运行下面代码:
const promise = new MyPromise((resolve, reject) => {resolve('这个是同步的请求')})promise.then(res => {console.log(res)})

上面这个版本是同步的,意思就是当我在调用 resolve 的时候,没有等待的情况,直接就是返回一个文本,那样会造成一个问题,当我需要等待异步结果的时候,this.status 是 pending 状态,then 马上执行的话, 内的 onFulfilled 是拿不到 this.value 值,所以就这么卡死在这儿了,如下所示:
const promise = new MyPromise((resolve, reject) => {setTimeout(() => {resolve('异步结果')}, 2000)}).then(res => { console.log(res) })
打印不出结果,但是你等 2 秒后,再去执行 promise.then 方法,是可以拿到数据的,但是真实代码环境哪里会等个 2 秒,再去拿接口的数据,这不是吹牛逼吗?
所以这里就涉及到一个设计模式 —— 发布订阅模式。大致就是 收集依赖 ——> 触发通知 ——> 执行收集的依赖。
我们来看看怎么完善上述源码:
// Promise/A+ 规范规定,Promise 有三个状态const PENDING = 'PENDING'const FULLFILLED = 'FULLFILLED'const REJECTED = 'REJECTED'class MyPromise {constructor (executor) {this.value = nullthis.reason = nullthis.status = PENDING // 默认 pending 状态this.FulfilledCallback = [] // 成功回调栈this.RejectedCallback = [] // 失败回调栈let resolve = (value) => {if (this.status === PENDING) {this.status = FULLFILLEDthis.value = value// 等异步的 resolve 执行的时候,批量运行this.FulfilledCallback.forEach(fn => fn())}}let reject = (value) => {if (this.status === PENDING) {this.status = REJECTEDthis.value = value// 等异步的 reject 执行的时候,批量运行this.RejectedCallback.forEach(fn => fn())}}try {executor(resolve, reject)} catch (e) {reject(e)}}// then 方法接收两个参数 onFulfilled 和 onRejectedthen(onFulfilled, onRejected) {// 如果 resolve 方法没有及时的返回数据的话,这边还要加一个 pending 状态的判断if (this.status === PENDING) {// 把方法放在全局变量里,等到 resolve 回调的时候,批量执行this.FulfilledCallback.push(() => {onFulfilled(this.value)})this.RejectedCallback.push(() => {onRejected(this.value)})}if (this.status === FULLFILLED) {onFulfilled(this.value)}if (this.status === REJECTED) {onRejected(this.value)}}}
Event Loop 知识点
进程与线程的区别?以及 JS 单线程的好处是什么?
进程和线程都是 CPU 工作时间片的一个描述。
进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。线程是进程中的更小单位,描述了执行一段指令所需的时间。
拿到浏览器上就是打开一个 Tab 就是一个进程,每个 Tab 内部有渲染线程、JS 引擎线程、HTTP 请求线程等等,也就是说一个进程可以有多个线程。
JS 单线程的好处:
1、让渲染更加安全,因为 JS 会阻塞 UI 的渲染,JS 能修改 DOM 操作,这使得多线程执行会带来不可控的渲染结果。
2、节省内存、上下文切换的时间、没有锁的问题。
锁:当我去读取一个数据的时候,两个地方在修改这个数据,则取到的数据不一定是最终想要的结果,这时候会加一个锁的概念,在读取完数据之后,才能修改这个数据。
什么是执行栈
简单理解它就是一个存储函数调用的栈结构,是一种数据结构,它遵循先进后出的原则。
执行栈与作用域、作用域链、执行上下文、变量对象/活动对象的联系都非常紧密。
js 有三种执行上下文(作用域):
1、全局执行上下文(作用域),非严格模式下 this 指向 window。
2、函数执行上下文(作用域),js 函数每次被调用都会创建一个上下文。
3、Eval 执行上下文,这边不做深究。
用例子来理解执行栈和执行上下文的关系:
var count = 0function add(count) {count += 1console.log(count)}add(count) // 1add(count) // 1
函数的形参属于函数上下文,每当函数被调用时创建,上下文随着函数的销毁而销毁,所以每次执行 add 函数的时候,都是取全局变量的 count = 0。函数每次被调用都会产生新的执行上下文,并被压入执行栈,执行完毕后当前上下文就会被弹出执行栈。所以第一次调用应该返回 1,第二次调用也应该返回 1,第 n 次调用都应该返回 1。
var count = 0function add() {count += 1console.log(count)}add(count) // 1add(count) // 2
函数内没有用 var 声明的变量,沿着作用域链指向了全局上下文,所以 count 的执行上下文就是全局上下文。
用栈的概念解释,每个 add 函数调用后,给 count 加 1,然后被弹出执行栈,而全局执行上下文的生命周期将伴随着整个程序,所以第一次调用打印 1,第二次调用打印 2,第 n 次调用打印 n。
