第一次经历春招(暑期实习)后,也算是见识到了大厂对 JS 手写题的偏爱,也认识到了自己对 JS 的认识的浅薄,甚至连 API 工程师都不配叫。于是我选择回炉重造,跟着掘金上的手写教程 写一遍 36 道手写题。

数据类型

对于原生的 typeof 而言,它能够正确识别的有:undefined, boolean, number, string, symbol, function。但无法识别其他的类型,包括:null, date

每个对象都有一个 toString() 方法,如果是默认继承自 ObjecttoString() 方法,则最后会输出 [object type] ,其中的 type 是对象的类型,也是我们可以更加准确的识别数据类型的原理。

由于对象可能重写过这个方法(和 Object 自带的 toString() 返回值不同),所以我们通过调用原版的方法 Object.prototype.toString 来获取对象的类型。

1
2
const arr = new Array()
console.log(Object.prototype.toString.call(arr)) // '[object Array]'

返回的类型是一个字符串,所以通过 split(' '),先将字符串转成数组。['[object', 'Array]']

选取后面一个字符串,去除括号并转换为小写。

1
2
3
4
5
6
7
8
function typeOf(obj) {
let res = Object.prototype.toString.call(obj).split(' ')[1]
res = res.substring(0, res.length - 1).toLowerCase()
return res
}
console.log(typeOf([])) // array
console.log(typeOf({})) // object
console.log(typeOf(new Date())) // date

继承

原型链实现继承

1
2
3
4
5
6
7
8
9
10
11
12
function Person() {
this.colors = ['black', 'white']
}
Person.prototype.getColor = function () {
return this.colors
}
function Asian() {}
Asian.prototype = new Person()
let asian1 = new Asian()
asian1.colors.push('yellow')
let asian2 = new Asian()
console.log(asian2.colors) // ['black', 'white', 'yellow']

缺陷:

  • 属性中的引用类型被所有实例共享
  • 子类在实例化的时候不能给父类构造函数传参

构造函数实现继承

1
2
3
4
5
6
7
8
9
10
function Person(name) {
this.name = name
this.getName = function () {
return this.name
}
}
function Asian(name) {
Person.call(this, name)
}
Asian.prototype = new Person()

解决了原型链继承的问题,但是还有缺陷:

  • 方法定义在构造函数中,创建子类实例时都会创建一次方法

组合继承

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person(name) {
this.name = name
}
Person.prototype.getName = function () {
return this.name
}
function Asian(name, age) {
Person.call(this, name)
this.age = age
}
Asian.prototype = new Person()
Asian.prototype.constructor = Person
const p = new Asian('ethan', 21)

缺陷:

  • 调用了两次父类的构造函数,包括:new Asian(), Person.call(this, name)

寄生式组合继承

为了不调用两次构造函数,选择使用通过创建空函数获取父类的副本。

1
2
3
4
5
6
7
8
- Asian.prototype = new Person()
- Asian.prototype.constructor = Person

+ function F() {}
+ F.prototype = Person.prototype
+ let f = new F()
+ f.constructor = Asian
+ Asian.prototype = f

封装:

1
2
3
4
5
6
7
8
9
10
11
function object(o) {
function F() {}
F.prototype = o
return new F()
}
function inheritPrototype(child, parent) {
let prototype = object(parent.prototype)
prototype.constructor = child
child.prototype = prototype
}
inheritPrototype(Asian, Person)

简单版:

1
2
Asian.prototype = Object.create(Person.prototype)
Asian.prototype.constructor = Asian

class 继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person {
constructor(name) {
this.name = name
}
getName() {
return this.name
}
}

class Asian extends Person {
constructor(name, age) {
super(name)
this.age = age
}
}

数组去重

1
2
3
4
5
6
7
8
9
// ES5 语法
function unique(arr) {
var res = arr.filter(function (item, index, array) {
return array.indexOf(item) === index
})
return res
}
// ES6 语法
var unique = arr => [...new Set(arr)]

数组扁平化

对高维的数组进行降维打击,变成一维数组。[1, [2, [3]]] => [1, 2, 3]

使用 JS 的 Array.prototype.flat([depth]) 方法,可以对数组进行降维,其中参数 depth 表示最大递归深度。

使用 Infinity,可以展开任意深度的嵌套数组;该方法还会移除数组中的空项。不支持 IE

使用 reduceconcat 可以代替该方法。

x.concat(y, z) 的特性是把数组 x 和对象 y,z 拼接到一起,形成一个新的数组。

✨值得注意的是,如果待拼接的元素是数组,就会把数组中的所有元素依次放到最终数组中;如果带拼接的元素不是数组,那么就直接放到最终数组中。

1
console.log([1].concat([2,3], 4)) // [1, 2, 3, 4]

展开一层数组:

1
2
3
4
const arr = [1, 2, [3, 4]]
arr.reduce((acc, val) => acc.concat(val), [])
// 或者使用扩展运算符
const flattened = arr => [].concat(...arr)

无限维的展开:

1
2
3
4
5
6
7
8
9
10
11
12
13
const nums = [1, [2, [3, [4]]]]
function flatter(arr, d = 1) {
if (d > 0) {
return arr.reduce(
(acc, val) =>
acc.concat(Array.isArray(val) ? flatter(val, d - 1) : val),
[]
)
} else {
return arr.slice()
}
}
console.log(flatter(nums, Infinity)) // [1, 2, 3, 4]

ES5 语法:

1
2
3
4
5
6
7
8
9
10
11
function flatter(arr) {
var res = []
for (var i = 0; i < arr.length; i++) {
if (Array.isArray(arr[i])) {
res = res.concat(flatter(arr[i]))
} else {
res.push(arr[i])
}
}
return res
}

ES6 非递归:

1
2
3
4
5
6
const flatter = (arr) => {
while (arr.some(item => Array.isArray(item))) {
arr = [].concat(...arr)
}
return arr
}

深浅拷贝

浅拷贝:

1
2
3
4
5
6
7
8
9
10
const deepClone = (target) => {
if (typeof target !== 'object') return target
const cloneTarget = Array.isArray(target) ? [] : {}
for (let prop in target) {
if (target.hasOwnProperty(prop)) {
cloneTarget[prop] = target[prop]
}
}
return cloneTarget
}

简易版深拷贝:

1
2
3
4
5
6
7
8
9
10
const deepClone = (target) => {
if (typeof target !== 'object') return target
const cloneTarget = Array.isArray(target) ? [] : {}
for (let prop in target) {
if (target.hasOwnProperty(prop)) {
cloneTarget[prop] = deepClone(target[prop])
}
}
return cloneTarget
}

简易版深拷贝的缺陷:

  • 只考虑了普通的对象属性,不考虑内置对象(Date, RegExp)和函数
  • 循环引用

完整版深拷贝:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const isObject = (target) =>
(typeof taget === 'object' || typeof target === 'function') &&
target !== null

const deepClone = (target, map = new WeakMap()) => {
if (map.get(target)) {
// 解决循环引用问题
return target
}
// 根据当前对象的构造函数获取它的类型
let constructor = target.constructor
// 检测当前对象是否是正则或日期对象
if (/^(RegExp|Date)$/i.test(constructor.name)) {
// 创建新的特殊对象实例
return new constructor(target)
}
if (isObject(target)) {
map.set(target, true) // 标记已经被复制
const cloneTarget = Array.isArray(target) ? [] : {}
for (let prop in target) {
if (target.hasOwnProperty(prop)) {
// 判断当前属性是否是对象自身的属性,不是原型链上的属性
cloneTarget[prop] = deepClone(target[prop], map)
}
}
} else {
return target
}
}

发布订阅模式

发布订阅模式描述的是对象间一对多的依赖关系,当一个对象的状态发生变化,所有依赖它的对象都得到状态改变的通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class EventEmitter {
constructor() {
this.cache = {}
}
on(name, fn) {
// 增加订阅者
if (this.cache[name]) {
this.cache[name].push(fn)
} else {
this.cache[name] = [fn]
}
}

off(name, fn) {
// 关闭订阅
let tasks = this.cache[name]
if (tasks) {
// 找到对应的订阅者的回调函数
const index = tasks.findIndex((f) => f === fn || f.callback === fn)
if (index >= 0) {
tasks.splice(index, 1)
}
}
}

emit(name, once = false, ...args) {
// 广播消息
if (this.cache[name]) {
// 避免回调函数内继续注册相同事件造成死循环
let tasks = this.cache[name].slice()
for (let fn of tasks) {
fn(...args)
}
if (once) {
delete this.cache[name]
}
}
}
}

const eventBus = new EventEmitter()
const fn1 = (name, age) => {
console.log(`hello, ${name}, ${age}`)
}
const fn2 = (name, age) => {
console.log(`bye, ${name}, ${age}`)
}

eventBus.on('a', fn1)
eventBus.on('a', fn2)
eventBus.emit('a', false, 'Ethan', 21)
// hello, Ethan, 21
// bye, Ethan, 21

URL 解析

要知道中文是无法直接作为统一资源定位符的,所以需要使用 encodeURIComponent() 对其进行编码,变成英文和数字组成的字符串。

同样的,为了正确获得这个 URI 所代表的意义,使用 decodeURIComponent() 来进行解码。

1
2
3
4
console.log(encodeURIComponent('苏州大学')) 
// %E8%8B%8F%E5%B7%9E%E5%A4%A7%E5%AD%A6
console.log(decodeURIComponent('%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E7%9A%84%E5%89%8D%E7%AB%AF%E5%B7%A5%E7%A8%8B%E5%B8%88%E7%94%9F%E6%B4%BB'))
// 从零开始的前端工程师生活

了解完这两个函数之后之后开始正式手写 URL 解析为对象的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 将 URL 字符串解析为对象
const parseParam = (url) => {
const paramsStr = /.+\?(.+)$/.exec(url)[1] // 提取?后面的字符串
const paramsArr = paramsStr.split('&') // 分割变量
const paramsObj = {}
paramsArr.forEach((param) => {
if (/=/.test(param)) {
// 解析键值对
let [key, val] = param.split('=')
val = decodeURIComponent(val) // 解码
val = /^\d+$/.test(val) ? parseFloat(val) : val
if (paramsObj.hasOwnProperty(key)) {
// 如果对象已经由了对应key,就追加一个value
paramsObj[key] = [].concat(paramsObj[key], val)
} else {
// 没有对应key,创建键值对
paramsObj[key] = [val]
}
} else {
paramsObj[param] = true
}
})
return paramsObj
}

模板字符串

我们知道 ES6 中提供了一种语法可以更加优雅地填充字符串中的变量。

1
2
const personName = 'ethan', age = 21
const str = `I am ${personName}, ${age} years old` // I am ethan, 21 years old

我们可以手写一个函数来模仿这种往字符串中填充变量的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const render = (template, data) => {
const reg = /\{\{(\w+)\}\}/
if (reg.test(template)) {
// 如果字符串中包含了模板字符串
const name = reg.exec(template)[1] // 获取对应的名字
template = template.replace(reg, data[name])
return render(template, data)
}
return template
}

const template = 'I am {{name}}, {{age}} years old.'
const person = {
name: 'ethan',
age: 21,
}
console.log(render(template, person))

图片懒加载

为了加快首屏加载的速度,通常我们会对网页上的图片实行懒加载,即只加载可视区域的图片。

网上开源的懒加载库已经很多了,现在我们来手写一个。

基本原理:

  • 在 HTML 中把图片地址属性从 src 改成 data-src,避免了直接加载图片。
  • 监听网页的滚动事件,每次加载视口内的图片。
  • 使用 getBoundingClientRect() 来获取标签相对于网站的高度,通过和 视口的高度相比,确认图片是否在视口内。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let imgList = [...document.querySelectorAll('img')]
const length = imgList.length

const lazyLoad = () => {
let count = 0
return (() => {
const deleteList = []
imgList.forEach((img, index) => {
const rect = img.getBoundingClientRect()
if (rect.top < window.innerHeight) {
// 图片在视口内
img.src = img.dataset.src
deleteList.push(index)
count++
if (count == length) {
document.removeEventListener('scroll', lazyLoad)
}
}
})
imgList = imgList.filter((_, index) => !deleteList.includes(index))
})()
}

document.addEventListener('scroll', lazyLoad)

防抖

上面一个懒加载其实有一个很严重的性能缺陷,我们对 document 的滚动事件进行了监听去出发懒加载的函数。事实上当我们滚动滑轮的时候,这个监听的函数会一直触发,导致函数的执行频率太高了。

为了对这个场景进行优化,实现在事件触发n秒后在执行回调,如果在这n秒内又被触发则重新计时,这种效果就是“防抖”。这里考虑使用 setTimeout() 函数和闭包来实现防抖效果。

掘金上一个博主的比喻我觉得很好,防抖就像是法师施法需要读条,如果读条中断就需要重新读条,读完条才能释放技能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const debounce = (fn, time) => {
// fn 是一个回调函数,time 是触发回调函数的冷却时间
let timeout
return function () {
const context = this
const args = arguments
clearTimeout(timeout)
timeout = setTimeout(() => {
fn.apply(context, args)
}, time)
}
}

const ele = document.getElementById('my-input')
ele.addEventListener(
'keypress',
debounce(() => console.log(1), 1000)
)

通过给输入框的 keypress 事件增加防抖处理,实现了在用户结束输入后 1s,才会触发回调函数。

节流

规定在一定时间内,只能触发一次函数,如果这段时间内多次尝试触发,则只有一次生效。

这种方法也可以解决之前的滚动事件触发频率过高的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 利用时间戳实现节流
const throttle = (fn, time) => {
let context, args
let pre = 0
return function () {
const now = new Date()
context = this
args = arguments
if (now - pre > time) {
// 对距离上次的时间进行限制
fn.apply(context, args)
pre = now
}
}
}

这里写的是初级版,高级版见 JavaScript专题之跟着 underscore 学节流

函数柯里化

我们的目标是实现一个函数,这个函数的作用如下:

1
2
3
4
5
6
function add(a, b, c) {
return a + b + c
}

let addCurry = curry(add)
console.log(addCurry(1)(2)(3)) // 6

可以看出来,正常我们调用 add,应该传三个参数。

但是在柯里化之后,我们可以通过多次每次传一个参数来实现分步的调用。

1
2
3
4
5
6
7
function curry(fn) {
let judge = (...args) => {
if (args.length == fn.length) return fn(...args)
return (...arg) => judge(...args, ...arg)
}
return judge
}

偏函数

柯里化的低阶版,效果如下:

1
2
3
4
5
6
7
function add(a, b, c) {
return a + b + c
}

let partialAdd = partial(add, 1)

console.log(partialAdd(2, 3)) // 6

允许我们再调用函数的时候先传入一些参数,下次再传剩下的参数,实现也比柯里化简单。

有点像是工厂函数的味道,对一个现有的函数进行二次包装

1
2
3
4
5
function partial(fn, ...args) {
return (...arg) => {
return fn(...args, ...arg)
}
}

JSONP

在写 JSONP 之前,先对跨域操作有一个初步的了解。

出于安全性考虑,浏览器限制了 JS 的跨源 HTTP 请求。

例如我为了获取数据,尝试在我的主页https://blog.ethanloo.top 调一个接口 https://api.ethanloo.top/todos,这就是一个跨域请求,因为请求的地址和当前的域名不同。

JSONP 就是为了解决跨域请求资源而产生的解决方案,本质利用了 <script> 标签不受跨域限制。

JSONP 属于古老但成熟的解决方案,只能进行 GET 请求;CORS 是另一个解决跨域问题的方案,复杂但更加强大。

JSONP 实现流程:

  1. 利用 script 标签规避跨域:<script src="url">
  2. 客户端声明一个函数:function jsonCallback() {}
  3. 在服务端根据客户端传的信息,查找数据库,返回字符串。
  4. 客户端利用 script 标签解析为可运行的 JavaScript 代码,调用 jsonCallback() 函数。

客户端代码 🍣 :

1
2
3
4
5
6
7
<script>
function jsonCallback(data) {
console.log(data);
}
</script>
<!-- 发送跨域请求 -->
<script src="http://localhost:3000/todos"></script>

服务端代码 🍣 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const http = require('http')

let data = {
id: 1,
date: '2021-04-17',
desc: 'learn jsonp',
}

const server = http.createServer((request, response) => {
if (request.url == '/todos') {
response.writeHead(200, {
'Content-Type': 'application/json;charset=utf-8',
})

response.end(`jsonCallback(${JSON.stringify(data)})`)
}
})

server.listen(3000, () => {
console.log('server is running on http://localhost:3000')
})

image-20210417165212711

上面这个方法把回调函数和请求都在客户端里写死了,接下来手写一个更灵活通用的 JSONP 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const jsonp = ({ url, params, callbackName }) => {
const generateUrl = () => {
// 首先根据 url 和 params 生成最终请求的 url
let src = ''
for (let key in params) {
if (params.hasOwnProperty(key)) {
src += `${key}=${params[key]}&`
}
}
// 最后加上回调函数的名字
src += `callback=${callbackName}`
return `${url}?${src}`
}

return new Promise((resolve, reject) => {
// 创建用于跨域请求的 script 标签
const scriptEle = document.createElement('script')
scriptEle.src = generateUrl()
// 将 script 标签写入 body,浏览器会自动加载
document.body.appendChild(scriptEle)
// 包装回调函数,使其在执行完成后移除 script 标签
window[callbackName] = (data) => {
resolve(data)
document.removeChild(scriptEle)
}
})
}

AJAX

异步的数据请求,实现无刷新加载数据。JS 基础面试题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const getJSON = (url) => {
return new Pormise((resolve, reject) => {
// 考虑兼容 IE6 的写法
const xhr = XMLHttpRequest
? new XMLHttpRequest()
: new ActiveXObject('Microsoft.XMLHttp')
// HTTP 方法,要发送的 URL,是否开启异步
xhr.open('GET', url, false)
xhr.setRequestHeader('Accept', 'application/json')
xhr.onreadystatechange = function () {
if (xhr.readState !== 4) return
if (xhr.status === 200 || xhr.status === 304) {
resolve(xhr.responseText)
} else {
reject(new Error(xhr.responseText))
}
}
})
}

forEach

ES5 中实现的遍历数组的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Array.prototype.forEach = function (callback, thisArg) {
if (this == null) {
throw new TypeError('this is null')
}
if (typeof callback !== 'function') {
throw new TypeError(callback + ' is not a fucntion')
}
const O = Object(this) // 绑定 this
const len = O.length >>> 0
let k = 0
while (k < len) {
if (k in O) {
// 额外参数, value, index, arr
callback.call(thisArg, O[k], k, O)
}
k++
}
}

map

和上面 forEach 原理类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Array.prototype.map = function (callback, thisArg) {
if (this == null) {
throw new TypeError('this is null or undefined!')
}
if (typeof callback !== 'function') {
throw new TypeError(callback + ' is not a function')
}
const O = Object(this)
const len = O.length >>> 0
let k = 0
let res = []
while (k < len) {
if (k in O) {
res[k] = callback.call(thisArg, O[k], k, O)
}
k++
}
return res
}

filter

本质也类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Array.prototype.filter = function (callback, thisArg) {
if (this == null) {
throw new TypeError('this is null or undefined!')
}
if (typeof callback !== 'function') {
throw new TypeError(callback + ' is not a function')
}
const O = Object(this)
const len = O.length >>> 0
let k = 0
let res = []
while (k < len) {
if (callback.call(thisArg, O[k], k, O)) {
res.push(O[k])
}
k++
}
return res
}

some

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Array.prototype.some = function (callback, thisArg) {
if (this == null) {
throw new TypeError('this is null or undefined!')
}
if (typeof callback !== 'function') {
throw new TypeError(callback + ' is not a function')
}
const O = Object(this)
const len = O.length >>> 0
let k = 0
while (k < len) {
if (callback.call(thisArg, O[k], k, O)) {
return true
}
k++
}
return false
}

reduce

一个很酷的累加器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Array.prototype.reduce = function (callback, initialValue) {
if (this == null) {
throw new TypeError('this is null or undefined!')
}
if (typeof callback !== 'function') {
throw new TypeError(callback + ' is not a function.')
}
const O = Object(this)
const len = O.length >>> 0
let acc
let k = 0
if (arguments.length > 1) {
acc = initialValue
} else {
// 取数组中第一个非空值
while (k < len && !(k in O)) {
k++
}
if (k > len) {
throw new TypeError('Reduce of empty array with no initial value')
}
acc = O[k++]
}
while (k < len) {
if (k in O) {
acc = callback(acc, O[k], k, 0)
}
k++
}
return acc
}

call

一个用于改变函数中 this 指向的方法。

调用形式如:

1
2
3
4
5
6
7
8
9
10
11
12
13
function eat(k) {
this.weight += k
}

person = {
name: 'ethan',
weight: 100,
}

// call(context, ...args) 后面可以传任意多个参数
eat.call(person, 10)

console.log(person.weight) // 110

eat 方法本身是不知道 this 是谁的,通过 call,我们就能让这个方法中的 this 指到我们想要的对象上去。

同时由于 eat 方法本身还需要一个参数 k,于是在调用 call 的时候就顺便也传给他。

代码 🍣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Function.prototype.call = function (context, ...args) {
// 防止没有传参
context = context || window

// 防止 context 对象中出现过 fn 属性
const fn = Symbol('fn')

// 方法中的 this 指向的是调用该方法的对象,调用 call 的是一个函数
// sayHi.call(person),this === sayHi 方法
context[fn] = this

// 执行函数
const res = context[fn](...args)

delete context.fn
return res
}

这个手写是 ES6 语法,除开用了 constSymbol 以外,展开运算符 ... 也是 ES6 语法。

apply

功能和 call 类似,就是传参的方式从展开的一个个参数变成了传入一个参数的数组。

1
2
3
4
5
6
7
8
9
Function.prototype.apply = function (context, args) {
context = context || window
const fn = Symbol('fn')
context[fn] = this

const res = context[fn](...args)
delete context[fn]
return res
}

bind

也是用来改变函数中的 this 指向,和上面的 callapply 相比区别在于不会直接调用函数,返回值是一个函数。

表面上看,包一层 apply 即可。

1
2
3
4
5
Function.prototype.bind = function (context, ...args) {
return () => {
this.apply(context, args)
}
}

这么写的问题在于:

  • 除了在调用 bind() 的时候可以传参之外,bind() 返回的函数应该也可以接收参数
  • 如果 bind 绑定过的函数被 new 了,那么 this 的指向又会改变
  • 没有保留原函数在原型链上的属性和方法

升级版:

1
2
3
4
5
6
7
8
9
10
11
12
Function.prototype.bind = function (context, ...args) {
var self = this
var fn = function () {
self.apply(
this instanceof self ? this : context,
args.concat(Array.prototype.slice.call(arguments))
)
}

fn.prototype = Object.create(self.prototype)
return fn
}

new

new 是一个用来实例化对象的关键字,比较简单,就不举例子说明了。

实现这个关键字的几个要点:

  • 实例可以访问私有属性
  • 实例可以访问构造函数原型所在的原型链
  • 需要判断构造函数执行完之后的返回值是否是对象

之所以需要判断返回值类型的原因是因为 new 本身的性质。

引自 MDN 文档

new 关键字会进行如下的操作:

  1. 创建一个空的简单JavaScript对象(即{});
  2. 链接该对象(设置该对象的constructor)到另一个对象 ;
  3. 将步骤1新创建的对象作为this的上下文 ;
  4. 如果该函数没有返回对象,则返回this

🍣 代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
function newOperator(cst, ...args) {
// cst 是构造函数
if (typeof cst !== 'function') {
throw 'newOperator function the first param must be a function'
}
// 让实例可以访问构造函数原型所在的原型链
let obj = Object.create(cst.prototype)
let res = cst.apply(obj, args)
// 判断构造函数返回的是否是引用类型
let isObject = typeof res === 'object' && res !== null
let isFunction = typeof res === 'function'
return isObject || isFunction ? res : obj
}

检验实例:

1
2
3
4
5
6
7
8
9
10
function Person(name) {
this.name = name
}
Person.prototype.sayHi = function () {
console.log(this.name + ': hello!')
}

const person = newOperator(Person, 'ethan')
console.log(person)
person.sayHi()

instanceof

该关键字用于检查某个对象是否是由某个构造函数实例化生成的。

我们利用原型链的知识来实现 🍣 这个关键词。

img

1
2
3
4
5
6
7
8
9
10
function instanceOf(obj, cst) {
// obj 是待检查的对象,cst 是待检查的构造函数
let proto = obj.__proto__
while (proto) {
if (proto === null) return false
if (proto === cst.prototype) return true
proto = proto.__proto__
}
return false
}

直接使用 __proto__ 属性来获取对象原型的方法并不好,更应该提倡的是使用 Object.getPrototype() 方法来获取原型对象。

1
2
3
4
5
6
7
8
9
10
11
12
function instanceOf(obj, cst) {
// obj 是待检查的对象,cst 是待检查的构造函数
// let proto = obj.__proto__ // 避免这种写法
let proto = Object.getPrototypeOf(obj)
while (proto) {
if (proto === null) return false
if (proto === cst.prototype) return true
// proto = proto.__proto__
proto = Object.getPrototypeOf(proto)
}
return false
}

检验:

1
2
3
4
5
6
7
8
9
function Person(name) {
this.name = name
}

const person = new Person('ethan')

console.log(instanceOf(person, Person)) // true
console.log(instanceOf(person, Object)) // true
console.log(instanceOf(person, Array)) // false

Object.create

完整的写法是 Object.create(proto, [propertiesObject]),允许指定一个原型和给定一个参数对象,来创建一个新的对象。

🍣 代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Object.create = function (proto, propertiesObject = undefined) {
if (typeof proto !== 'object' && typeof proto !== 'function') {
throw new TypeError('Object prototype may only be an Object or null')
}
if (propertiesObject === null) {
throw new TypeError('Cannot convert undefined or null to object')
}

// 新建一个构造函数
function f() {}
// 将构造函数的原型指向给定的原型对象
f.prototype = proto
const obj = new f()
if (propertiesObject !== undefined) {
Object.defineProperties(obj, propertiesObject)
}
if (proto === null) {
// 允许给定的原型为 null
Object.setPrototypeOf(obj, null)
}
return obj
}

检测:

1
2
3
4
5
6
7
8
9
10
11
12
const person = Object.create(
{ name: 'ethan' },
{
age: {
value: 3,
writable: true,
enumerable: true,
configurable: true,
},
}
)
console.log(person)

image-20210421164832738

Object.assign

Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象分配到目标对象,它将返回目标对象。

MDN demo:

1
2
3
4
5
6
7
8
9
10
const target = { a: 1, b: 2 };
const source = { b: 4, c: 5 };

const returnedTarget = Object.assign(target, source);

console.log(target);
// expected output: Object { a: 1, b: 4, c: 5 }

console.log(returnedTarget);
// expected output: Object { a: 1, b: 4, c: 5 }

🍣 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Object.assign = function (target, ...sources) {
if (target == null) {
throw new TypeError('Cannot conver undefined or null to object')
}
let ret = Object(target)
sources.forEach((obj) => {
if (obj != null) {
for (let key in obj) {
// 遍历对象并赋值
if (obj.hasOwnProperty(key)) {
ret[key] = obj[key]
}
}
}
})
return ret
}

JSON.stringfy

JSON.stringfy(value, [replacer], [space])

这个方法可以将一个 JavaScript 对象或值转换为 JSON 字符串。可以使用 replacer 参数选择对序列化对象进行处理,也可以使用 space 参数美化输出的字符串中的空格数。

这里不考虑实现后面两个参数

由于 value 的变化性非常强,所以我们对其进行分类讨论。

  1. 基本数据类型

    • undefined/symbol => undefined
    • boolean => 'true'/'false'
    • NaN/Infinity/null => 'null'
    • number => '数字'
    • string => 字符串
  2. 对象类型

    • function => undefined

    • array,如果出现了 undefined, function, symbol,则=> 'null'

    • RegExp => '{}'

    • Date => toJSON()

    • Object => toJSON(),忽略为 undefined, function, symbol 的属性,忽略键为 symbol 的属性。

  3. 对包含循环引用的对象报错

🍣 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
JSON.stringify = function (value) {
let type = typeof value
if (type !== 'object') {
// string/number/null/undefined/boolean
let res = value
if (Number.isNaN(value) || value === Infinity) {
result = 'null'
} else if (
type === 'function' ||
type === 'undefined' ||
type === 'symbol'
) {
return undefined
} else if (type === 'string') {
res = '"' + res + '"'
}
return String(res)
} else if (type === 'object') {
if (value === null) {
return 'null'
} else if (value.toJSON && typeof value.toJSON === 'function') {
return JSON.stringify(value.toJSON())
} else if (value instanceof Array) {
// 检查是否有非法值
let res = []
value.forEach((item, index) => {
if (
typeof item === 'undefined' ||
typeof item === 'function' ||
typeof item === 'symbol'
) {
res[index] = 'null'
} else {
res[index] = JSON.stringify(item)
}
})
res = '[' + res + ']'
// 将字符串中的单引号全部换成双引号
return res.replace(/'/g, '"')
} else {
// 普通对象
let res = []
Object.keys(value).forEach((item, _) => {
// 忽略 key 为 symbol
if (typeof item !== 'symbol') {
if (
value[item] !== undefined &&
typeof value[item] !== 'function' &&
typeof value[item] !== 'symbol'
) {
res.push('"' + item + '":' + JSON.stringify(value[item]))
}
}
})
res = '{' + res + '}'
return res.replace(/'/g, '"')
}
}
}

JSON.parse

该函数正好和 JSON.stringfy() 倒过来,用于将 JSON 格式的字符串解析成一个对象。

第一种方式,eval 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 防止 xss 攻击,调用 eval 之前对字符串进行校验
var rx_one = /^[\],:{}\s]*$/;
var rx_two = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g;
var rx_three = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g;
var rx_four = /(?:^|:|,)(?:\s*\[)+/g;

if (
rx_one.test(
json.replace(rx_two, "@")
.replace(rx_three, "]")
.replace(rx_four, "")
)
) {
var obj = eval("(" +json + ")");
}

第二种方式,new Function 实现:

1
2
var json = '{"name":"ethan", "age":20}';
var obj = (new Function('return ' + json))();

Promise

来了,来了,Promise 它来了!

因为篇幅比较长,就新开了一篇文章

这边手写另一篇中没写到的几个 Promise 的类方法。

Promise.resolve

该方法允许将传入的一个 value 转换为一个 fulfilled 状态的 Promise 对象。

如果传入的 value 本身已经是一个 Promise 对象,就直接返回。

1
2
3
4
5
6
Promise.resolve = function (value) {
if (value instanceof Promise) {
return value
}
return new Promise((resolve) => resolve(value))
}

Promise.reject

该方法会将传入的 reason 实例化为一个 rejected 状态的 Promise 对象,无论这个 reason 是否本身就是一个 Promise。

1
2
3
Promise.reject = function (reason) {
return new Promise((resolve, reason) => reject(reason))
}

Promise.all

传入一个由 Promise 对象构成的数组(严格意义上只要是可迭代对象):

  • 如果都是 fulfilled,就返回一个状态为 fulfilled 的新 Promise 对象,它的值由原数组内所有的值组成。
  • 只要有一个是 rejected,就返回一个状态为 rejected 的新 Promise 对象,它的值是原数组内第一个 rejected 的 Promise 对象的值。
  • 只要有一个是 pending,就返回一个状态为 pending 的新 Promise 对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Promise.all = function (promiseArr) {
let cnt = 0
let res = []
return new Promise((resolve, reject) => {
promiseArr.forEach((promise, i) => {
Promise.resolve(promise).then(
// 如果有一个 pending,就不会 resolve,也不会 reject
(val) => {
cnt++
res[i] = val
if (cnt === promiseArr.length) {
// 都是 fulfilled,返回 res 值数组
resolve(res)
}
},
(error) => {
// 有一个 rejcted,直接返回
reject(error)
}
)
})
})
}

Promise.race

返回一个数组中第一个 fulfilledrejected 的 Promise 实例,并重新包装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Promise.race = function (promiseArr) {
return new Promise((resolve, reject) => {
promiseArr.forEach((promise) => {
Promise.resolve(promise).then(
(value) => {
resolve(value)
},
(error) => {
reject(error)
}
)
})
})
}

Promise.allSettled

all 方法类似,两者区别在于,allSettled 认为无论是 fulfilled 还是 rejected 都是 settled

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Promise.allSettled = function (promiseArr) {
let res = []
return new Promise((resolve, reject) => {
promiseArr.forEach((promise) => {
Promise.resolve(promise).then(
(value) => {
res.push({
status: 'fulfilled',
value,
})
if (res.length === promiseArr.length) {
resolve(res)
}
},
(error) => {
res.push({
status: 'rejected',
reason: error,
})
if (res.length === promiseArr.length) {
resolve(res)
}
}
)
})
})
}

Promise.any

all 也很像,区别在于该方法只要有一个 fulfilled 就会返回 fulfilled 的新 Promise 对象,只有当全部 rejected 的时候,才会返回一个 rejected 的新 Promise 对象,且值为 AggregateError

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Promise.any = function (promiseArr) {
let cnt = 0
return new Promise((resolve, reject) => {
if (promiseArr.length === 0) {
reject(new AggregateError())
}
promiseArr.forEach((promise) => {
Promise.resolve(promise).then(
(value) => {
resolve(value)
},
(error) => {
cnt++
if (cnt === promiseArr.length) {
reject(new AggregateError('all promises were rejected'))
}
}
)
})
})
}

小结

持续了半个月,陆陆续续终于是吃完了这 36 个 🍣 ,整个手撕过程基本就是跟着掘金的文章走的,也有一部分参考的 MDN 文档。在一天吃几个 🍣 的同时,我也重新学习 JavaScript 的语法,看的是一本在线的教程,目前 JS 语言相关的看得差不多了。

在这里强推这个在线教程,原型链的部分写得真的超级棒 👍!每一章还有很多配套的练习题(刷题怪的天堂)。

image-20210424144437319

说是查漏补缺可能不太准确,严格意义上就跟我开篇说的一样,这次属于回炉重造。收获还是很多的,不仅熟悉了各种 API,也把我之前不太理解的原型链和 event loop 的知识弄清楚了,还有困扰了我很久的 Promise 💫!

不过我就不写自己对这些知识点的理解啦,因为作者已经写的很棒了(译者也很负责!),我的拙见写出来就成了班门弄斧了。

未来学习 JS 的路还很长,stay hungry and eat 🍣!