写在前面:

这篇博文偏应用,改天再写篇概念性的。该篇幅内容包括:减少 DOM 操作,时间委托,冒泡事件,Promise 相关, 微任务宏任务,作用域,变量提升,闭包,基本数据类型和检测方法,深浅拷贝,原型和作用域链后续争取把 js 重点都记录上,深入浅出。

DOM 操作

  1. 使用文档碎片减少 DOM 操作
  2. 冒泡事件:stopPropagation();阻止向上冒泡
  3. 事件委托 :减少 DOM 请求次数

使用文档碎片减少 DOM 操作

假设在一个 ul 中,如果持续添加一百次插入 li 的 DOM 操作,那这个过程无疑是非常消耗内存的,这时不妨使用文档碎片 fragment ,将插入的 100 次操作存储在这,结束后在一并添加到 ul 中,这样 DOM 操作从原本的 100 次变为一次。

1
2
<ul id="list"></ul>
<script src="index.js"></script>
1
2
3
4
5
6
7
8
9
10
11
12
13
const list = document.getElementById('list')
// 创建文档碎片,内容放在内存里,最后将结果一次插入list,减少DOM操作
const fragment = document.createDocumentFragment()

for (let i = 0; i < 5; i++) {
const item = document.createElement('li')
item.innerHTML = `事件${i}`
// 每次插入都是一次DOM操作,消耗性能,所以先往文档碎片放入li,循环完后在插入list
// list.appendChild(item);
fragment.appendChild(item)
}

list.appendChild(fragment)

阻止冒泡事件

依旧举个栗子:当点击一个 li , 他没有绑定事件处理函数,那么他会往父级元素(一直往上找直到 body)看有没有事件处理函数,有的话同样触发。

这样可以简便的为子元素绑定处理事件,但弊端也很明显:如果父元素有处理函数,即使你给子元素绑定了处理事件,那么在触发子元素的处理事件同时,还会向上冒泡在触发父元素的处理函数!!这就需要到 stopPropagation(); 阻止冒泡行为。

1
2
3
4
5
6
7
8
<div id="one">
<p id="p1">冒泡</p>
</div>
<hr />
<div id="two">
<p id="p4">冒泡</p>
<p id="p5">阻止</p>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const p3 = document.getElementById('p3');
const one = document.getElementById('one');
const body = document.body;

function bindEvent (elem, type, fn) {
elem.addEventListener(type, fn);
}

bindEvent(one, 'click', function () {
console.log("one的click");
})
bindEvent(body, 'click', function () {
console.log("body的click");
})
bindEvent(p3, 'click', function () {
console.log("p5的click");
})**

在这里插入图片描述
在没有绑定阻止冒泡时,给了 body,第一个父元素和第三个子元素绑定处理事件,点击第三个会触发第三个和 body 的,点击第一个会触发父元素和 body 的。如果只想触发第三个事件绑定,可将 p3 的触发函数修改为:传入 event,利用 event 下的 stopPropagation(); 方法。

1
2
3
4
bindEvent(p3, 'click', function (event) {
event.stopPropagation()
console.log('阻止冒泡')
})

事件委托

事件指的是:不在要发生的事件(直接 dom)上设置监听函数,而是在其父元素上设置监听函数,通过事件冒泡,监听子元素,来做出不同的响应。

例如在 ul 中有这么五个小 li ,要给他们绑定点击事件,可获取他们标签名后,利用 slice 方法对 li 的类数组转化为数组,在对其中的 li 添加注册事件。

1
2
3
4
5
6
7
8
<ul id="list">
<li>事件1</li>
<li>事件2</li>
<li>事件3</li>
<li>事件4</li>
<li>事件5</li>
<button id="btn">点击添加委托事件</button>
</ul>
1
2
3
4
5
6
7
const lis = document.getElementsByTagName('li')
listAray = Array.prototype.slice.call(lis)
listAray.forEach((li) => {
addEvent(li, 'click', () => {
alert(li.innerHTML)
})
})

要是有 500 个 li 呢,那岂不是遍历绑定 500 次,这产生的事件监听器非常消耗内存。这时可以找到其父元素,设置事件监听器,利用 target 给 li 绑定事件监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 绑定事件处理函数
function addEvent(elem, type, fn) {
elem.addEventListener(type, fn)
}

addEvent(list, 'click', (e) => {
const target = e.target
if (target.nodeName === 'LI') {
alert(target.innerHTML)
}
})

btn.addEventListener('click', () => {
const li = document.createElement('li')
li.innerHTML = '新增绑定事件'
list.insertBefore(li, btn)
})

这样,即使不循环遍历 ul 下的 li ,也能通过对 ul 的绑定打印出子元素 li 的信息。


异步

  1. 使用 promise 加载一张图片
  2. then,catch 改变 promise 的状态
  3. async , await , 和 Promise
  4. 微任务和宏任务

使用 promise 加载一张图片

考验对 promise 的基本使用情况。思路:实例化一个 promise 对象,对图片的加载分别成功与否用resolve, reject 处理。

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
function loadImage(src) {
// 创建并实例化一个promise对象
const promise = new Promise((resolve, reject) => {
const img = document.createElement('img')
// 如果图片加载成功,就传入图片进resolve
img.onload = function () {
resolve(img)
}
img.onerror = function () {
const err = new Error(`图片加载失败, url:${{ src }}`)
reject(err)
}
img.src = src
})
return promise
}

const url =
'https://cn.bing.com/th?id=OHR.WalhallaOverlook_ZH-CN1059655401_1920x1080.jpg&rf=LaDigue_1920x1080.jpg'
loadImage(url)
.then((img) => {
console.log('img', img)
})
.catch((e) => {
console.log('err', e)
})

在这里插入图片描述

使用 then,catch 改变 promise 的状态

先说结论:

then 和 catch 正常返回的时候,Promise 状态是 fulfilled ,抛出错误的时候,Promise 状态是 rejected。
fulfilled 状态的 Promise 执行 then 的回调,而 rejected 则执行 catch 的回调。

怎么理解?看下面例子:

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
const p1 = Promise.resolve()

console.log('p1', p1)

const p1Then = p1.then(() => {
console.log('p1Then', p1Then)
})

const p1Error = p1.then(() => {
throw new Error('p1 then error')
})
console.log('p1Error', p1Error)

p1Then
.then(() => {
console.log('fulfilled状态走then回调')
})
.catch(() => {
console.log('fulfilled状态走catch回调')
})

p1Error
.then(() => {
console.log('p1Error走then回调')
})
.catch(() => {
console.log('p1Error走catch回调')
})

首先定义一个 resolve 返回值的 promise 对象,打印显示状态为 fulfilled ,保存当前信息在打印走 catch 还是 then;然后在手动跑出个错误,打印当前状态,在判断走 catch 还是 then。结果显示:
在这里插入图片描述
再回过头看先前的结论,发现也不难理解。reject 类型的演示也差不多,结论见上。

async , await , 和 Promise

这里也先说这三者的结论:
执行 async 函数返回的都是 Promise 对象;promise.then 的情况对应 await ;promise.catch 异常情况对应 try…catch。

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
// async结论 :async返回的都是Promise对象
async function test1() {
return 1
}
const result1 = test1()
console.log('result1', result1)

// await 结论:promise.then成功情况对应await
async function test2() {
const p2 = Promise.resolve(2)
p2.then((data) => {
console.log('promise情况:', data)
})
const data = await p2
console.log('await情况:', data)
}
test2()

// promise.catch异常情况对应try...catch
async function test3() {
const p3 = Promise.reject(6)
try {
const data3 = await p3
console.log('data3', data3)
} catch (e) {
console.log('try-catch捕捉的promise异常:', e)
}
}
test3()

在这里插入图片描述例子比较通俗易懂,配合结果应该能更清晰的理解结论。

微任务和宏任务

宏任务有:setTimeout , setInterval , Dom 事件,AJAX 请求
微任务有:Promise ,async / await
微任务会比宏任务先执行,具体可搜该类型的答应题目进行深入理解。


作用域,变量提升,和闭包

关于作用域,有全局和局部(函数作用域,块级作用域 ES6 才有),局部能访问全局,全局不能访问局部。在查找变量的时候,变量的父子级关系或者查找的过程就是一条作用域链。

来个设计之处的小 BUG:按理来说全局作用下不能访问局部变量,但事实好像是:JS 在设计之初的好像压根就没把块级作用域的事情考虑进去,直到后来 ES6 出 let 和 const 代替 var 关键字来解决这一现象。

1
2
3
4
5
6
7
8
9
10
11
if (true) {
var a = 1
}
console.log(a) // 1

//解决方式,函数声明作用域,解决了全局能访问局部作用域情况
var b = () => {
var a = 1
}
b()
console.log(a) // a is not defined

上述乱使用 var 会找出变量污染,在 let 和 const 没出之前,用函数声明作用域可解决上述现象。实际业务中基本不会使用 var,取而代之的 const 和 let。这也是JS 模块化的体现。

问题来了,如果业务中就是要引用某局部作用域的变量呢,那怎么办?这时候闭包的作用就体现了,往下看

作用域声明里,还有个知识点:变量提升,这有关 JS 编译执行过程,挺有意思,要理解这得多看几道作用域输出变量的题(下面这张我是去牛客网刷 js 专项训练截来的)
在这里插入图片描述闭包和匿名函数不要搞混!那么什么是闭包,红宝书说:闭包是有权访问另一个函数作用域的变量函数。也就是说,局部作用域之间并非是不能互相访问的,可以通过闭包来访问局部的变量。下面举俩个小例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 函数作为返回值,在定义的地方向上找变量,而不是再调用的地方
function test1() {
const a = 1
return function () {
console.log('a', a) //输出:a 1
}
}
const a1 = test1()
const a = 2
a1()

// 函数作为参数
function test2(fn) {
const b = 6
fn()
}

const b = 7
function fn() {
console.log('b', b) //输出:b 7
}
test2(fn)

就一句话,闭包调用的函数,变量在定义的地方向上找,而不是再调用的地方

为什么要使用闭包: 闭包可以缓存结果不释放变量内存,方便下次调用直接拿取。 因为正常情况下,函数执行完存储在内存的变量会被销毁,再次调用该函数需要重新计算。假设这函数计算量大且耗时,每次调用都得重新计算,那将会浪费内存,影响用户体验,这时就需要道闭包。

闭包的弊端:由于闭包的变量被保存在内存中,所以内存消耗很大,容易导致内存泄漏;容易改变父函数内部变量的值。


变量类型,深浅拷贝

变量类型有:基本类型(String、Number、Boolean、Symbol、Undefined、Null,BigInt),和引用类型(object , array ,function )。原始类型的存储改变发生在桟中执行,使用具体数值直接存储;引用类型则在堆中,且存储的数据是地址而非具体数值。

顺带说下类型的判断:

  • typeof:分不清普通对象(typeof null =object)还是数组对象( typeof []=object)
  • instanceof:用来测 A 是不是 B 的原型的,能清楚判断对象类型但也只能测对象类型
  • constructor: 利用创建时产生的原型链追溯类型,null 和 undefined 是无效的对象,所以测不了
  • Object.prototype.toString.call():都能测
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
//类型判断
typeof("")

[] instanceof Array;// true
{} instanceof Object;// true
newDate() instanceof Date;// true

let arr=[];
console.log(b.constructor === Array);
console.log("".constructor === String);

Object.prototype.toString.call(newDate()) ;// [object Date]
Object.prototype.toString.call([]) ;// [object Array]

// 基础类型
let a = 10;
let b = a;
a = 20;
console.log('b:', b); //输出b: 10

// 引用类型
let c = { age: 20 };
let d = c;
c.age = 21;
console.log('d:', d); //输出d: { age: 21 }

在上面的例子中,原始类型的变量有私有内存各自存储数值,一方的改变不会牵动自身;引用类型 d 指向 c 的存储地址,c 值改变后,d 用的依旧是 c 的地址所以输出 21。

上面其实引出了深拷贝和浅拷贝的概念,浅拷贝:复制的同时一方变另一放跟着变(修改的是堆内存中的同一个值),深拷贝,完全复制了一个对象,一方的改变不会牵动另一方(修改堆的不同的值)。

如何实现浅拷贝?
如果是数组,可以使用slice,concat,Array.from , 展开运算符 ,来返回一个新数组的特性实现拷贝。但是如果数组嵌套了其他引用类型, concat 方法将复制不完整。

原理:

1
2
3
4
5
6
7
8
9
10
11
function copy(obj) {
if (typeof obj === 'object' && obj !== '') {
let copy = Array.isArray(obj) ? [] : {}
for (var p in obj) {
copy[p] = obj[p]
}
return copy
} else {
return obj
}
}

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var obj = {
A: '1',
B: {
b: '12',
c: 28,
},
}

var copy1 = []
Object.assign(copy, obj) //或者 var copy = Object.assign({},obj);
var copy2 = [].concat(obj)
var copy3 = { ...obj }

copy.color = '33' // 改变原数据第一层属性 A (基本数据类型)
copy.person.name = '44' // 改变原数据第一层属性 B(引用数据类型)

console.log(obj, copy) // 原数据的引用类型会改变,copy全变

深拷贝才是重点:
JSON.parse(JSON.stringify()) (如果原对象中有undefined、Symbol、函数时,会导致该键值被丢失,有正则,会被转换为空对象{},有Date,会被转换成字符串)

_.cloneDeep: lodash 的 Api,直接克隆

1
var new_arr = JSON.parse(JSON.stringify(old_arr))

原理是 JOSN 对象中的 stringify 可以把一个 js 对象序列化为一个 JSON 字符串,parse 可以把 JSON 字符串反序列化为一个 js 对象,通过这两个方法,也可以实现对象的深复制,但是这个方法不能够拷贝函数 。

手写一个深拷贝(巩固理解):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function cloneDeep(obj){
if(typeof obj === "object" && obj !== ''){
return shenCopy(obj);
}else{
return obj;
}
}
function shenCopy(obj){
let copy =Array.isAraray(obj) ? [] : {};
for(let p in obj){
copy[p]=shenCopy(obj[p]);
}else{
copy[p]=obj[p]
}
return copy;
}

在这里插入图片描述深拷贝是拷贝对象各个层级的属性,看对象的属性是否是对象或数组类型,进行相应属性的一一复制。

原型和原型链

原型:每一个对象都会从原型继承属性或方法

官话:每一个 JavaScript 对象在创建的时候就会预制管理另一个对象,这个对象就是我们所说的原型
大白话:
构造函数内部有个 prototype 属性,通过这个属性能访问到原型,这里的原型就是指构造函数.prototype 。
前面也说了,原型是预制管理一个对象,那么那个对象是谁呢?其实是构造函数 new 出来的实例对象,好了,现在有三个角色:构造函数,原型,实例。

它们是怎么联系的呢?官方设定,实例不能直接访问构造函数,要不设计原型干嘛。

1
2
3
4
5
6
7
8
function Person() {}
Person.prototype.name = '南'
const nan = new Person()
console.log(nan.name) //南
console.log(nan instanceof Person) //True
console.log(nan.__proto__ === Person.prototype) //True 实例通过__proto__访问原型
console.log(nan.__proto__.constructor === Person) //True 实例通过__proto__访问原型后在通过constructor 访问构造函数
console.log(Person.prototype.constructor === Person) //True 原型通过constructor 访问构造函数

在这里插入图片描述
说的再多不如结合图看遍代码。

构造函数通过原型的方式添加属性,成功调用实例,说明 1.实例确实是通过原型访问到构造函数的属性或方法的。2.通过 new 出来的实例可以继承原型方法,instanceof 足以说明两者关系。

现在,你知道上述基础类型判断里的instanceofconstructor为什么能判断对象类型了吧,还是因为原型!

清楚了原型和实例的关系后,更能透彻理解 new 实例化的过程中发生了什么,具体可见我的 js 复习下篇。

原型链

每个对象都有属于自己的隐式原型 __proto__,每个函数或方法都有自己的显示原型 prototype 。实例对象继承使用原型方法的时候通过隐式原型向上查找,如果在原型的显示原型中找到,就不用在通过原型的隐式原型再向上找。

这么说有点拗口,其实蛮好理解的。下面通过一张图说明:

在这里插入图片描述
在这里插入图片描述

如图所示,实例 teacher 要想调用 teach 方法,首先通过显示原型查找有没有 teach 这个方法,发现没有就通过 _proto_ 隐式原型查找他的上一级原型(Teacher.prototype),看他有没有。Teacher 同样先看自己的显示原型,发现有就返回。同理,如果要找 drink 方法,则还要通过 _proto_ 再向上找原型(Person.prototype)看他有没有,找到就层层返回。

如果找到尽头都没有那个方法,就回返回 null。这样,一条完整的方法查找路径就是原型链。

ES6 新增特性

  • let 和 const(看变量提升,块级作用域,能否重定义),以及 Symbol,Bigint 变量。

  • 解构赋值:let arr = [1,2,3,4,5]; const [a,b,c,d,e] = arr; 业务中很常用

  • 模块化:export default { 暴露的对象 } 导出; import { a } from ‘暴露的对象’ 导入

  • 拓展运算符(…):可以将多个数组或对象解构形成一个新元素(在去重,浅拷贝,解构很常用) :
    const c = [...new Set([...a,...b])];

  • 箭头函数:()=>{} ,箭头函数和普通函数的区别(this,实例化,argument)

  • 扁平化数组:**Object.values(数组).flat(Infinity)**,Infinity 为数组的维度,flat 不支持 IE 浏览器

  • 模版字符串:let str3=我爱中国的${str2}

  • 数组新增的 api:原来的:传送门 新增:(includes,startsWith,endsWith)

  • 双问号??:作用就是判断该对象有没有默认值,没有就采取问号后面的值,比如输入框非空判断if((value??'') !== '')?.则是判断该对象有无该属性或方法 obj?.eat,没有就返回 undefined,不会报错,在业务中算是常用),(在 TS 中,还有!.就告诉编译器,一定有这属性,别给我报这属性可能不存在的错)