前言

作为一名朴实无华的前端开发,记忆能力是有限的,所以每年我都会对前端基础知识做一个复习和巩固。所以我推荐掘金上一位小哥哥写的《前端面试之道》小册作为复习的路线。废话不多说,接下来就是我结合自己理解的读书笔记。

JS 基础知识

  • JS 基础类型
    string、number、undefined、null、boolean、symbol。
    虽然 type of null 的值为 object ,但是他是基础类型,因为 JS 历史原因,一开始是 32 位系统,000 开头代表对象,而 null 为全 0,所以它被错误的判断为 object
  • JS 对象类型
    除了基础类型,便都是变量类型。
    它们的区别在于,基础类型存储在栈内存,对象类型存储在堆内存。听起来好像挺复杂的,其实很简单。
    基础类型存储的就是一个值,而对象类型存储是地址,创建一个对象类型,内存会帮我们开辟一个空间来存放值,我们要找到这个空间,就需要一个地址,这个地址就会被赋值给变量。
  1. const a = []
  2. const b = a
  3. b.push(1)

此时 a 和 b 指向的是同一个地址,所以值都被改变了

我们再来看看下面这种情况:

  1. function test(person) {
  2. person.age = 30
  3. person = {
  4. name: 'nick',
  5. age: 27
  6. }
  7. return person
  8. }
  9. const p1 = {
  10. name: 'cxy',
  11. age: 27
  12. }
  13. const p2 = test(p1)
  14. console.log(p1)
  15. 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 相对来说会准确一下,但是遇到基础类型也是判断不出来,如下:

  1. const Person = function() {}
  2. const p1 = new Person()
  3. p1 instanceof Person // true
  4. var str = 'hello world'
  5. str instanceof String // false
  6. var str1 = new String('hello world')
  7. str1 instanceof String // true

它的原理是通过原型链来判断。

Symbol.hasInstance 可以自定义 instanceof 的行为。

类型转化

类型转化在 JS 中只有三种情况:

  • 转换为布尔值
  • 转换为数字(Number())
  • 转换为字符串(String())

this 指向问题

先来一个场景

  1. function foo() {
  2. console.log(this.a)
  3. }
  4. var a = 1
  5. foo()
  6. const obj = {
  7. a: 2,
  8. foo: foo
  9. }
  10. obj.foo()
  11. 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

用一张图来加深记忆:
前端面试之道读书笔记 - 图1

== 和 === 的比较

== 比较的是类型,在类型不同的情况下,会转化类型,大致流程如下:
前端面试之道读书笔记 - 图2

例题:[] == ![] 答案是 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
  • 不能序列化函数
  • 不能解决循环引用的对象

但是它还是能解决大部分问题。
手动实现简易版深拷贝:

  1. function deepClone(obj) {
  2. function isObject(o) {
  3. return (typeof o === 'object' || typeof o === 'function') && o !== null
  4. }
  5. if (!isObject(obj)) {
  6. throw new Error('非对象')
  7. }
  8. let isArray = Array.isArray(obj)
  9. let newObj = isArray ? [...obj] : { ...obj }
  10. Reflect.ownKeys(newObj).forEach(key => {
  11. newObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
  12. })
  13. return newObj
  14. }
  15. let obj = {
  16. a: [1, 2, 3],
  17. b: {
  18. c: 2,
  19. d: 3
  20. }
  21. }
  22. let newObj = deepClone(obj)
  23. newObj.b.c = 1
  24. console.log(obj.b.c) // 2

说实话,别跟自己过不去,作者也推荐使用 lodash 的 cloneDeep 函数,它不香吗?

原型

如何理解原型?如何理解原型链?

说说我的个人理解,原型有下面几个原则:

  • 所有的引用类型,都具有对象特性,即可自由扩展属性(除了“null”以外)
  • 所有的引用类型,都有一个隐式原型 __proto__ 属性,属性值是一个普通的对象
  • 所有的引用类型,隐式原型 __proto__ 属性值指向它的构造函数的显式原型 prototype 属性值
  • 当试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么它会去它的隐式原型 __proto__(也就是它的构造函数的显式原型 prototype )中寻找。

那么我们来一一的验证上面几个原则,就会慢慢的理解原型和原型链。

1、所有的引用类型,都具有对象特性,即可自由扩展属性(除了“null”以外)

  1. var obj = {};
  2. var arr = [];
  3. var fn = function() {}
  4. obj.a = 1;
  5. arr.a = 2
  6. fn.a = 3

这个规则应该比较好理解,额外介绍一个小知识点,var obj ={}; 相当于 var obj = new Object(); 不过在正常的工作业务中,不会去这么定义一个对象,因为不仅麻烦,可读性也会变差。

2.所有的引用类型,都有一个隐式原型proto属性,属性值是一个普通的对象

  1. var obj = {};
  2. var arr = [];
  3. function fn() {}
  4. console.log(obj.__proto__);
  5. console.log(arr.__proto__);
  6. console.log(fn.__proto__);

前端面试之道读书笔记 - 图3
3.所有函数都有一个显式原型prototype
前端面试之道读书笔记 - 图4
4.所有的引用类型,隐式原型proto属性值指向它的构造函数的显式原型“prototype”属性值

  1. var obj = {};
  2. var arr = [];
  3. function fn() {}
  4. console.log(obj.__proto__ === Object.prototype) //true
  5. console.log(arr.__proto__ === Array.prototype) // true
  6. console.log(fn.__proto__ === Function.prototype) // true

4.当试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么它会去它的proto(也就是它的构造函数的显式原型prototype)中寻找

  1. var obj = {a:1};
  2. obj.toString(); // [object, object]

obj 本身是没有toString方法属性的,之所以能获取到这个方法,其实就是遵循了第四条规则,从他的构造函数Object的prototype中去拿到了这个方法。

举个原型链的例子:

  1. function Person(name) {
  2. this.name = name;
  3. return this; // 其实这行可以不写,默认不认返回this对象
  4. }
  5. var nick = new Person("nick");
  6. nick.toString(); // [object, object]

按理说,nick 是构造函数 Person 生成的实例,而它的prototype 并没有 toString 方法,那为什么 nick 对象能获取到 toString 方法呢?其实这就涉及到原型链了,nick 先去找自身有无 toString 方法属性,找不到那就往上走,找构造函数的 prototype,还是没找到,那么就继续往上,构造函数的 prototype 其实也就是一个对象(如下图所示),那么对象的构造函数就是Object,所以就找到了 Object.prototype 下的toString 方法

前端面试之道读书笔记 - 图5

总结:
前端面试之道读书笔记 - 图6
最后一个 null,设计上是为了避免死循环而设置的, Object 的隐式原型指向 null。

既然说到了原型,那就说说判断一个引用类型的变量。如下代码:

  1. function Foo(name) {
  2. this.name = name;
  3. }
  4. var f = new Foo('nick')
  5. f instanceof Foo // true
  6. f instanceof Object // true

instanceof 是通过原型去进行比较对象是否属于当前比较的构造函数,f instanceof Foo 的判断逻辑: f 的隐式原型 __proto__ 一层一层往上,能否对应到 Foo.prototype 同理 f instanceof Object 也是为 true ,因为往上找,也能找到 Object.prototype

ES6 的一些常见考点

var、let、const 的区别

说到它们仨,就得想起变量提升。那么何为变量提升,就是在申明变量之前就去使用它,却不会报错。

  1. console.log(a) // undefined
  2. var a = 1

上述代码其实可以被看作是:

  1. var a
  2. console.log(a)
  3. a = 1

函数同样也会被提升:

  1. console.log(a) // ƒ a() {}
  2. function a() {}
  3. var a = 1

它们的区别在与:

  • 全局状态下用 var 声明变量,变量会被挂在到 window 上,而 letconst 不会。
  • letconst 声明变量,会存在暂时性死区,必须在声明后才能使用。
  • 函数提升优先于变量提升,函数提升会把整个函数挪到作用域顶部,变量提升只会把声明挪到作用域顶部

重新认识 reduce
正常实现一个数组内元素的累加:

  1. const arr = [1,2,3]
  2. let total = 0
  3. for (let i = 0; i < arr.length; i++) {
  4. total += arr[i]
  5. }
  6. console.log(total) // 6

使用 reduce 实现:

  1. const arr = [1,2,3]
  2. const sum = arr.reduce((acc, current) => (acc + current), 0)
  3. console.log(sum) // 6

reduce 的第一个参数为函数,函数接受四个参数,分别是累计值、当前值,当前值索引,原数组。 reduce 的第二个参数为初始值,初始值无论是什么类型,都会体现在第一个参数(函数)的第一个参数。

reduce 模拟 map

  1. const arr = [1, 2, 3]
  2. const mapArray = arr.map(value => value * 2)
  3. const reduceArray = arr.reduce((acc, current) => {
  4. acc.push(current * 2)
  5. return acc
  6. }, [])
  7. console.log(mapArray, reduceArray) // [2, 4, 6]

JS 异步编程及常考面试题

并发和并行的区别?

  • 并发是宏观的概念,假设我有两个任务 A 和 B,在某段时间内通过任务间的切换完成这两个任务,这叫并发
  • 并行是微观概念,假设我还是有两个任务,在多核 CPU 的情况下,同时跑两个任务,这叫做并行。

你理解的 Generator 是什么?

Generator (生成器)是 ES6 引入的一个新的数据类型,它最大的特点就是控制函数的执行,可多次返回。

举个例子:

  1. function *foo(x) {
  2. let y = 2 * (yield (x + 1))
  3. let z = yield (y / 3)
  4. return (x + y + z)
  5. }
  6. let it = foo(5)
  7. console.log(it.next()) // => {value: 6, done: false}
  8. console.log(it.next(12)) // => {value: 8, done: false}
  9. 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 函数解决回调地狱的问题,可以把之前的回调地狱例子改写为如下代码:

  1. function *fetch() {
  2. yield ajax(url, () => {})
  3. yield ajax(url1, () => {})
  4. yield ajax(url2, () => {})
  5. }
  6. let it = fetch()
  7. let result1 = it.next()
  8. let result2 = it.next()
  9. let result3 = it.next()

Promise

关于 Promise 的解释,这边推荐一篇好文,吕大豹的 大白话讲 Promise

手写一个 Promise

先来来一个简单同步版本的,发现问题之后,才能更好的理解异步版本的。

  1. // Promise/A+ 规范规定,Promise 有三个状态
  2. const PENDING = 'PENDING'
  3. const FULLFILLED = 'FULLFILLED'
  4. const REJECTED = 'REJECTED'
  5. class MyPromise {
  6. constructor (executor) {
  7. this.value = null
  8. this.reason = null
  9. this.status = PENDING // 默认 pending 状态
  10. let resolve = (value) => {
  11. if (this.status === PENDING) {
  12. this.status = FULLFILLED
  13. this.value = value
  14. }
  15. }
  16. let reject = (value) => {
  17. if (this.status === PENDING) {
  18. this.status = REJECTED
  19. this.value = value
  20. }
  21. }
  22. try {
  23. executor(resolve, reject)
  24. } catch (e) {
  25. reject(e)
  26. }
  27. }
  28. // then 方法接收两个参数 onFulfilled 和 onRejected
  29. then(onFulfilled, onRejected) {
  30. if (this.status == FULLFILLED) {
  31. onFulfilled(this.value)
  32. }
  33. if (this.status == REJECTED) {
  34. onRejected(this.value)
  35. }
  36. }
  37. }

我们来实验一下运行下面代码:

  1. const promise = new MyPromise((resolve, reject) => {
  2. resolve('这个是同步的请求')
  3. })
  4. promise.then(res => {
  5. console.log(res)
  6. })

前端面试之道读书笔记 - 图7

上面这个版本是同步的,意思就是当我在调用 resolve 的时候,没有等待的情况,直接就是返回一个文本,那样会造成一个问题,当我需要等待异步结果的时候,this.statuspending 状态,then 马上执行的话, 内的 onFulfilled 是拿不到 this.value 值,所以就这么卡死在这儿了,如下所示:

  1. const promise = new MyPromise((resolve, reject) => {
  2. setTimeout(() => {
  3. resolve('异步结果')
  4. }, 2000)
  5. }).then(res => { console.log(res) })

打印不出结果,但是你等 2 秒后,再去执行 promise.then 方法,是可以拿到数据的,但是真实代码环境哪里会等个 2 秒,再去拿接口的数据,这不是吹牛逼吗?

所以这里就涉及到一个设计模式 —— 发布订阅模式。大致就是 收集依赖 ——> 触发通知 ——> 执行收集的依赖

我们来看看怎么完善上述源码:

  1. // Promise/A+ 规范规定,Promise 有三个状态
  2. const PENDING = 'PENDING'
  3. const FULLFILLED = 'FULLFILLED'
  4. const REJECTED = 'REJECTED'
  5. class MyPromise {
  6. constructor (executor) {
  7. this.value = null
  8. this.reason = null
  9. this.status = PENDING // 默认 pending 状态
  10. this.FulfilledCallback = [] // 成功回调栈
  11. this.RejectedCallback = [] // 失败回调栈
  12. let resolve = (value) => {
  13. if (this.status === PENDING) {
  14. this.status = FULLFILLED
  15. this.value = value
  16. // 等异步的 resolve 执行的时候,批量运行
  17. this.FulfilledCallback.forEach(fn => fn())
  18. }
  19. }
  20. let reject = (value) => {
  21. if (this.status === PENDING) {
  22. this.status = REJECTED
  23. this.value = value
  24. // 等异步的 reject 执行的时候,批量运行
  25. this.RejectedCallback.forEach(fn => fn())
  26. }
  27. }
  28. try {
  29. executor(resolve, reject)
  30. } catch (e) {
  31. reject(e)
  32. }
  33. }
  34. // then 方法接收两个参数 onFulfilled 和 onRejected
  35. then(onFulfilled, onRejected) {
  36. // 如果 resolve 方法没有及时的返回数据的话,这边还要加一个 pending 状态的判断
  37. if (this.status === PENDING) {
  38. // 把方法放在全局变量里,等到 resolve 回调的时候,批量执行
  39. this.FulfilledCallback.push(() => {
  40. onFulfilled(this.value)
  41. })
  42. this.RejectedCallback.push(() => {
  43. onRejected(this.value)
  44. })
  45. }
  46. if (this.status === FULLFILLED) {
  47. onFulfilled(this.value)
  48. }
  49. if (this.status === REJECTED) {
  50. onRejected(this.value)
  51. }
  52. }
  53. }

Event Loop 知识点

进程与线程的区别?以及 JS 单线程的好处是什么?

进程和线程都是 CPU 工作时间片的一个描述。
进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。线程是进程中的更小单位,描述了执行一段指令所需的时间。
拿到浏览器上就是打开一个 Tab 就是一个进程,每个 Tab 内部有渲染线程、JS 引擎线程、HTTP 请求线程等等,也就是说一个进程可以有多个线程。

JS 单线程的好处:
1、让渲染更加安全,因为 JS 会阻塞 UI 的渲染,JS 能修改 DOM 操作,这使得多线程执行会带来不可控的渲染结果。
2、节省内存、上下文切换的时间、没有锁的问题。

锁:当我去读取一个数据的时候,两个地方在修改这个数据,则取到的数据不一定是最终想要的结果,这时候会加一个锁的概念,在读取完数据之后,才能修改这个数据。

什么是执行栈

简单理解它就是一个存储函数调用的栈结构,是一种数据结构,它遵循先进后出的原则。
执行栈与作用域、作用域链、执行上下文、变量对象/活动对象的联系都非常紧密。
js 有三种执行上下文(作用域):
1、全局执行上下文(作用域),非严格模式下 this 指向 window
2、函数执行上下文(作用域),js 函数每次被调用都会创建一个上下文。
3、Eval 执行上下文,这边不做深究。

用例子来理解执行栈和执行上下文的关系:

  1. var count = 0
  2. function add(count) {
  3. count += 1
  4. console.log(count)
  5. }
  6. add(count) // 1
  7. add(count) // 1

函数的形参属于函数上下文,每当函数被调用时创建,上下文随着函数的销毁而销毁,所以每次执行 add 函数的时候,都是取全局变量的 count = 0。函数每次被调用都会产生新的执行上下文,并被压入执行栈,执行完毕后当前上下文就会被弹出执行栈。所以第一次调用应该返回 1,第二次调用也应该返回 1,第 n 次调用都应该返回 1。

  1. var count = 0
  2. function add() {
  3. count += 1
  4. console.log(count)
  5. }
  6. add(count) // 1
  7. add(count) // 2

函数内没有用 var 声明的变量,沿着作用域链指向了全局上下文,所以 count 的执行上下文就是全局上下文。
用栈的概念解释,每个 add 函数调用后,给 count 加 1,然后被弹出执行栈,而全局执行上下文的生命周期将伴随着整个程序,所以第一次调用打印 1,第二次调用打印 2,第 n 次调用打印 n。