前端核心基础知识总结

一. HTML标签知多少?

1. meta标签:自动刷新/跳转

1
2
3
4
5
<meta http-equiv="Refresh" content="5; URL=page2.html">
<!-- 上面的代码会在 5s 之后自动跳转到同域下的 page2.html 页面。 -->

<meta http-equiv="Refresh" content="60">
<!-- 间隔60s刷新一次页面 -->

2. 运用title标签实现消息提醒

消息提醒功能实现则比较困难,HTML5 标准发布之前,浏览器没有开放图标闪烁、弹出系统消息之类的接口,只能借助一些 Hack 的手段,比如修改 title 标签来达到类似的效果(HTML5 下可使用Web Notifications API弹出系统消息)。

1
2
3
4
5
6
7
8
9
10
11
12
13
let msgNum = 1 // 消息条数
let cnt = 0 // 计数器
const inerval = setInterval(() => {
cnt = (cnt + 1) % 2
if(msgNum===0) {
// 通过DOM修改title
document.title += `聊天页面`
clearInterval(interval)
return
}
const prefix = cnt % 2 ? `新消息(${msgNum})` : ''
document.title = `${prefix}聊天页面`
}, 1000)

关于Web Notifications API的基本方法可以在Web Notifications API MDN上浏览。

PS:消息通知只有通过Web服务访问该页面时才会生效,如果直接双击打开本地文件,是没有任何效果的。也就是说你的文件需要使用服务器的形式打开,而不是直接使用浏览器打开本地文件。

3. 通过标签实现的性能优化

性能问题无外乎两方面原因:渲染速度慢、请求时间长。性能优化虽然涉及很多复杂的原因和解决方案,但其实只要通过合理地使用标签,就可以在一定程度上提升渲染速度以及减少请求时间。

a. script 标签:调整加载顺序提升渲染速度

由于浏览器的底层运行机制,渲染引擎在解析HTML时,若遇到script标签引用文件,则会暂停解析过程,同时通知网络线程加载文件,文件加载后会切换至JavaScript引擎来执行对应代码,代码执行完成之后切换至渲染引擎继续渲染页面。

在这一过程中可以看到,页面渲染时间 = 请求文件 + 执行文件的时间,但页面的首次渲染可能并不依赖这些文件,这些请求和执行文件的动作反而延长了用户看到页面的时间,从而降低了用户体验(比如白屏)。
为了减少这些时间损耗,可以借助 script 标签的 3 个属性来实现。

  • async 属性。立即请求文件,但不阻塞渲染引擎,而是文件加载完毕后阻塞渲染引擎并立即执行文件内容。
  • defer 属性。立即请求文件,但不阻塞渲染引擎,等到解析完 HTML 之后再执行文件内容。
  • HTML5 标准 type 属性,对应值为“module”。让浏览器按照 ECMA Script 6 标准将文件当作模块进行解析,默认阻塞效果同 defer,也可以配合 async 在请求完成后立即执行。

PS: 除此之外还应当注意,当渲染引擎解析 HTML 遇到 script 标签引入文件时,会立即进行一次渲染。所以这也就是为什么构建工具会把编译好的引用 JavaScript 代码的 script 标签放入到 body 标签底部,因为当渲染引擎执行到 body 底部时会先将已解析的内容渲染出来,然后再去请求相应的 JavaScript 文件。如果是内联脚本(即不通过 src 属性引用外部脚本文件直接在 HTML 编写 JavaScript 代码的形式),渲染引擎则不会渲染。

b. link 标签:通过预处理提升渲染速度

  • dns-prefetch。当 link 标签的 rel 属性值为“dns-prefetch”时,浏览器会对某个域名预先进行 DNS 解析并缓存。这样,当浏览器在请求同域名资源的时候,能省去从域名查询 IP 的过程,从而减少时间损耗。
  • preconnect。让浏览器在一个 HTTP 请求正式发给服务器前预先执行一些操作,这包括 DNS 解析、TLS 协商、TCP 握手,通过消除往返延迟来为用户节省时间。
  • prefetch/preload。两个值都是让浏览器预先下载并缓存某个资源,但不同的是,prefetch 可能会在浏览器忙时被忽略,而 preload 则是一定会被预先下载。
  • prerender。浏览器不仅会加载资源,还会解析执行页面,进行预渲染

以上特性也反映出了浏览器获取资源文件的流程:

浏览器处理资源引用 -> DNS解析 -> 建立TCP连接 -> 获取HTTP请求内容 -> 渲染页面

4. 搜索优化

  • meta标签:提取关键信息

为了让搜索引擎更好的识别页面,最好是给网站添加合适的搜索关键词

1
<meta content="搜索关键词,用逗号隔开" name="keywords">

在实际工作中,推荐使用一些关键字工具来挑选,比如 、站长工具、Google Trends。

那么在这些页面中可以这样设置:

1
<link href="https://xx.com/a.html" rel="canonical">

这样可以让搜索引擎避免花费时间抓取重复网页。不过需要注意的是,它还有个限制条件,那就是指向的网站不允许跨域。

当然,要合并网址还有其他的方式,比如使用站点地图。

二. 关于DOM元素

1. DOM

大部分前端功能需要借助DOM来实现,比如监听点击事件,动态渲染列表,懒加载脚本或样式。根据DOM V3标准,会发现包含多个内容,归纳起来由3大部分的内容组成:

  • DOM 节点
  • DOM 事件
  • 选择区域

a. DOM 节点
对于 DOM 节点,需与另外两个概念标签和元素进行区分:

标签是 HTML 的基本单位,比如 p、div、input;
节点是 DOM 树的基本单位,有多种类型,比如注释节点、文本节点;
元素是节点中的一种,与 HTML 标签相对应,比如 p 标签会对应 p 元素。

1
2
3
4
5
<!--
"p" 是标签,
生成 DOM 树的时候会产生两个节点,一个是元素节点 p,另一个是字符串为"苹果苹果"的文本节点。
-->
<p>苹果苹果</p>

b. DOM 操作

DOM频繁的操作其实对浏览器的性能来说很不友好,有很大的性能损耗问题。这其中的原因,就要先了解一下浏览器的工作机制

  • 线程切换
    浏览器包含渲染引擎(也称浏览器内核)和 JavaScript引擎,它们都是单线程运行。单线程的优势是开发方便,避免多线程下的死锁、竞争等问题,劣势是失去了并发能力。

浏览器为了避免两个引擎同时修改页面而造成渲染结果不一致的情况,增加了另外一个机制,这两个引擎具有互斥性,也就是说在某个时刻只有一个引擎在运行,另一个引擎会被阻塞。操作系统在进行线程切换的时候需要保存上一个线程执行时的状态信息并读取下一个线程的状态信息,俗称上下文切换。而这个操作相对而言是比较耗时的。

每次 DOM 操作就会引发线程的上下文切换——从 JavaScript引擎切换到渲染引擎执行对应操作,然后再切换回 JavaScript 引擎继续执行,这就带来了性能损耗。单次切换消耗的时间是非常少的,但是如果频繁的大量切换,那么就会产生性能问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 测试次数:一百万次
const times = 1000000
// 缓存body元素
let body = document.body
// 循环赋值对象作为对照参考
for(let i=0;i<times;i++) {
let tmp = body
}
console.timeEnd('object')// object: 1.77197265625ms

// 循环读取body元素引发线程切换
for(let i=0;i<times;i++) {
let tmp = document.body
}
console.timeEnd('dom')// dom: 18.302001953125ms
  • 重排和重绘

另一个更加耗时的因素是元素及样式变化引起的再次渲染,在渲染过程中最耗时的两个步骤为重排(Reflow)与重绘(Repaint)。

浏览器在渲染页面时会将 HTMLCSS 分别解析成 DOM 树CSSOM 树,然后合并进行排布,再绘制成我们可见的页面。如果在操作 DOM 时涉及到元素、样式的修改,就会引起渲染引擎重新计算样式生成 CSSOM 树,同时还有可能触发对元素的重排重绘

  • 如何高效操作DOM

    • 在循环外操作元素
    • 批量操作元素
      比如说要创建 1 万个 div 元素,在循环中直接创建再添加到父元素上耗时会非常多。如果采用字符串拼接的形式,先将 1 万个 div 元素的 html 字符串拼接成一个完整字符串,然后赋值给 body 元素的 innerHTML 属性就可以明显减少耗时。
    • 缓存元素集合
      比如将通过选择器函数获取到的 DOM 元素赋值给变量,之后通过变量操作而不是再次使用选择器函数来获取。
      假设我们现在要将上面代码所创建的 1 万个 div 元素的文本内容进行修改。每次重复使用获取选择器函数来获取元素。
      1
      2
      3
      4
      for (let i = 0; i < document.querySelectorAll('div').length; i++) {
      document.querySelectorAll(`div`)[i].innerText = i
      }
      //21965ms
      如果能够将元素集合赋值给 JavaScript 变量,每次通过变量去修改元素,那么性能将会得到不小的提升。
      1
      2
      3
      4
      5
      const divs = document.querySelectorAll('div')
      for (let i = 0; i < divs.length; i++) {
      divs[i].innerText = i
      }
      //211ms
      c. 总结
  • 尽量不要使用复杂的匹配规则和复杂的样式,从而减少渲染引擎计算样式规则生成 CSSOM 树的时间;

  • 尽量减少重排和重绘影响的区域;

  • 使用 CSS3 特性来实现动画效果。

2. DOM事件

a. 防抖
对于一些连续触发的事件,有时候并不需要那么频繁去触发,需要添加一个”防抖”功能,为函数的执行设置一个合理的时间间隔,避免事件在时间间隔内频繁触发,同时又保证事件的正常使用功能。
要实现防抖,自然而然就是想到使用定时器来延迟执行。比如:输入搜索的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const ipt = document.querySelector('input')
let timeout = null; //存储计时器
ipt.addEventListener('input', e => {
if(timeout) {
clearTimeout(timeout)
timeout = null
}
timeout = setTimeout(() => {
search(e.target.value).then(resp => {
// 搜索回调
}, e => {
// ...
})
}, 500)
})

问题确实是解决了,但这并不是最优答案,或者说我们需对这个防抖操作进行一些“优化”。

试想一下,如果另一个搜索框也需要添加防抖,是不是也要把 timeout 相关的代码再编写一次?而其实这个操作是完全可以抽取成公共函数的。

在抽取成公共函数的同时,还需要考虑更复杂的情况:

  • 参数和返回值如何传递?
  • 防抖化之后的函数是否可以立即执行?
  • 防抖化的函数是否可以手动取消?

具体代码如下所示,首先将原函数作为参数传入 debounce() 函数中,同时指定延迟等待时间,返回一个新的函数,这个函数包含 cancel 属性,用来取消原函数执行。flush 属性用来立即调用原函数,同时将原函数的执行结果以 Promise 的形式返回。

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
// 封装API
const debounce = (func, wait = 0) => {
let timeout = null
let args
function debounced(...arg) {
args = arg
if(timeout) {
clearTimeout(timeout)
timeout = null
}
// 以Promise的形式返回函数执行结果
return new Promise((res, rej) => {
timeout = setTimeout(async () => {
try {
const result = await func.apply(this, args)
res(result)
} catch(e) {
rej(e)
}
}, wait)
})
}
// 允许取消
function cancel() {
clearTimeout(timeout)
timeout = null
}
// 允许立即执行
function flush() {
cancel()
return func.apply(this, args)
}
debounced.cancel = cancel
debounced.flush = flush
return debounced
}

也可以参考lodsh的debounce()函数

b. 节流
一般监听滚动事件的时候需要考虑节流,我们可以设置在指定一段时间内只调用一次函数,从而降低函数调用频率,这种方式我们称之为“节流”。

实现节流函数的过程和防抖函数有些类似,只是对于节流函数而言,有两种执行方式,在调用函数时执行最先一次调用还是最近一次调用,所以需要设置时间戳加以判断。我们可以基于 debounce() 函数加以修改。

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
const throttle = (func, wait = 0, execFirstCall) => {
let timeout = null
let args
let firstCallTimestamp

function throttled(...arg) {
if (!firstCallTimestamp) firstCallTimestamp = new Date().getTime()
if (!execFirstCall || !args) {
console.log('set args:', arg)
args = arg
}
if (timeout) {
clearTimeout(timeout)
timeout = null
}
// 以Promise的形式返回函数执行结果
return new Promise(async(res, rej) => {
if (new Date().getTime() - firstCallTimestamp >= wait) {
try {
const result = await func.apply(this, args)
res(result)
} catch (e) {
rej(e)
} finally {
cancel()
}
} else {
timeout = setTimeout(async () => {
try {
const result = await func.apply(this, args)
res(result)
} catch (e) {
rej(e)
} finally {
cancel()
}
}, firstCallTimestamp + wait - new Date().getTime())
}
})
}
// 允许取消
function cancel() {
clearTimeout(timeout)
args = null
timeout = null
firstCallTimestamp = null
}
// 允许立即执行
function flush() {
cancel()
return func.apply(this, args)
}
throttled.cancel = cancel
throttled.flush = flush
return throttle
}

节流与防抖都是通过延迟执行,减少调用次数,来优化频繁调用函数时的性能。不同的是,对于一段时间内的频繁调用,防抖是延迟执行后一次调用,节流是延迟定时多次调用。

c. 代理

下面的 HTML 代码是一个简单的无序列表,现在希望点击每个项目的时候调用 getInfo() 函数,当点击“编辑”时,调用一个 edit() 函数,当点击“删除”时,调用一个 del() 函数。

1
2
3
4
5
6
<ul class="list">
<li class="item" id="item1">项目1<span class="edit">编辑</span><span class="delete">删除</span></li>
<li class="item" id="item2">项目2<span class="edit">编辑</span><span class="delete" >删除</span></li>
<li class="item" id="item3">项目3<span class="edit">编辑</span><span class="delete">删除</span></li>
...
</ul>

要实现这个功能并不难,只需要对列表中每一项,分别监听 3 个元素的 click 事件即可。

但如果数据量一旦增大,事件绑定占用的内存以及执行时间将会成线性增加,而其实这些事件监听函数逻辑一致,只是参数不同而已。此时我们可以以事件代理或事件委托来进行优化。

  • DOM事件触发流程(三个阶段)
    • 捕获:事件对象 Window 传播到目标的父对象,如图红色线
    • 目标:事件对象到达事件对象的事件目标,如图蓝色过程
    • 冒泡:事件对象从目标的父节点开始传播到 Window,如图绿色线

冒泡流程图

我们上面提到的给元素的事件行为绑定方法都是在当前元素事件行为的冒泡阶段(或者目标阶段)执行的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<body>
<button>click</button>
</body>
<script>
document.querySelector('button').addEventListener('click', function () {
console.log('bubble')
})
document.querySelector('button').addEventListener('click', function () {
console.log('capture')
}, true)
// 执行结果
// buble
// capture
</script>

例如,在上面面的代码中,虽然我们第二次进行事件监听时设置为捕获阶段,但点击事件时仍会按照监听顺序进行执行。(若是调换两个监听函数的顺序,则输出相反的结果)

我们再回到事件代理,事件代理的实现原理就是利用上述 DOM 事件的触发流程来对一类事件进行统一处理。比如对于上面的列表,我们在 ul 元素上绑定事件统一处理,通过得到的事件对象来获取参数,调用对应的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const ul = document.querySelector('.list')
ul.addEventListener('click', e => {
const t = e.target || e.srcElement
if (t.classList.contains('item')) {
getInfo(t.id)
} else {
id = t.parentElement.id
if (t.classList.contains('edit')) {
edit(id)
} else if (t.classList.contains('delete')) {
del(id)
}
}
})

虽然这里我们选择了默认在冒泡阶段监听事件,但和捕获阶段监听并没有区别。
对于其他情况还需要具体情况具体细分析,比如有些列表项目需要在目标阶段进行一些预处理操作,那么可以选择冒泡阶段进行事件代理。

事件监听方式的区别

1
2
3
4
5
6
// 方式1
<input type="text" onclick="click()"/>
// 方式2
document.querySelector('input').onClick = function(e) {// ...}
// 方式3
document.querySelector('input').addEventListener('click', function(e) {//...})

方式 1 和方式 2 同属于 DOM0 标准,通过这种方式进行事件监会覆盖之前的事件监听函数。
方式 3 属于 DOM2 标准,推荐使用这种方式。同一元素上的事件监听函数互不影响,而且可以独立取消,调用顺序和监听顺序一致。

3.浏览器渲染页面的过程:字节 → 字符 → 令牌 → 树 → 页面

假如我们在浏览器中输入了一个网址,得到了下面的 html 文件,渲染引擎是怎样通过解析代码生成页面的呢?

1
2
3
4
5
6
7
<html>
  <head>
</head>
  <body>
    文本
  </body>
</html>

1.字符流解码
对于上面的代码,我们看到的是它的字符形式。而浏览器通过 HTTP 协议接收到的文档内容是字节数据,。当浏览器得到字节数据后,通过“编码嗅探算法”来确定字符编码,然后根据字符编码将字节流数据进行解码,也就是我们编写的代码。

1
2
3
69 6f 6e 23 2b 65  |<html> <head></head>...
0a 43 52 45 4a 66 |
6c 37 77 41 3d 0a |

这个把字节数据解码成字符数据的过程称之为“字节流解码”。

2.输入流预处理
通过上一步解码得到的字符流数据在进入解析环节之前还需要进行一些预处理操作。比如将换行符转换成统一的格式,最终生成规范化的字符流数据,这个把字符数据进行统一格式化的过程称之为“输入流预处理”。

3.令牌化
经过前两步的数据解码和预处理,下面就要进入重要的解析步骤了。

解析包含两步,第一步是将字符数据转化成令牌(Token),第二步是解析 HTML 生成DOM 树。先来说说令牌化,其过程是使用了一种类似状态机的算法,即每次接收一个或多个输入流中的字符;然后根据当前状态和这些字符来更新下一个状态,也就是说在不同的状态下接收同样的字符数据可能会产生不同的结果,比如当接收到body字符串时,在标签打开状态会解析成标签,在标签关闭状态则会解析成文本节点。
最后生成的令牌结构类似如下:

1
2
3
4
5
6
7
开始标签:html
开始标签:head
结束标签:head
开始标签:body
字符串:文本
结束标签:body
结束标签:html

4.构建DOM树
浏览器在创建解析器的同时会创建一个 Document 对象。在树构建阶段,Document 会作为根节点被不断地修改和扩充。标记步骤产生的令牌会被送到树构建器进行处理。HTML 5 标准中定义了每类令牌对应的 DOM 元素,当树构建器接收到某个令牌时就会创建该令牌对应的 DOM 元素并将该元素插入到 DOM 树中。
为了纠正元素标签嵌套错位的问题和处理未关闭的元素标签,树构建器创建的新 DOM 元素还会被插入到一个开放元素栈中。
最终生成下面的 DOM 树结构:

1
2
3
4
5
6
7
      Document
/ \
DocumentType HTMLHtmlElement
/ \
HTMLHeadElement HTMLBodyElement
|
TextNode

5.构建渲染树
有了 DOM 树和 CSSOM 树之后,渲染引擎就可以开始生成页面了。
DOM 树包含的结构内容与 CSSOM 树包含的样式规则都是独立的,为了更方便渲染,先需要将它们合并成一棵渲染树。
这个过程会从 DOM 树的根节点开始遍历,然后在 CSSOM 树上找到每个节点对应的样式。
遍历过程中会自动忽略那些不需要渲染的节点(比如脚本标记、元标记等)以及不可见的节点(比如设置了“display:none”样式)。同时也会将一些需要显示的伪类元素加到渲染树中。
对于上面的 HTML 和 CSS 代码,最终生成的渲染树就只有一个 body 节点,样式为 font-size:12px(浏览器默认css属性)。

6.布局
生成了渲染树之后,就可以进入布局阶段了,布局就是计算元素的大小及位置。
计算元素布局是一个比较复杂的操作,因为需要考虑的因素有很多,包括字体大小、换行位置等,这些因素会影响段落的大小和形状,进而影响下一个段落的位置。
布局完成后会输出对应的“盒模型”,它会精确地捕获每个元素的确切位置和大小,将所有相对值都转换为屏幕上的绝对像素。(关于像素方面的知识可以参考css布局心得)

7.绘制
绘制就是将渲染树中的每个节点转换成屏幕上的实际像素的过程。得到布局树这份“施工图”之后,渲染引擎并不能立即绘制,因为还不知道绘制顺序,如果没有弄清楚绘制顺序,那么很可能会导致页面被错误地渲染。例如,对于使用 z-index 属性的元素(如遮罩层)如果未按照正确的顺序绘制,则将导致渲染结果和预期不符(失去遮罩作用)。
所以绘制过程中的第一步就是遍历布局树,生成绘制记录,然后渲染引擎会根据绘制记录去绘制相应的内容

对于无动画效果的情况,只需要考虑空间维度,生成不同的图层,然后再把这些图层进行合成,最终成为我们看到的页面。当然这个绘制过程并不是静态不变的,会随着页面滚动不断合成新的图形。

三. 关于JS的小事

1. 数据类型的理解

JavaScript 的数据类型可以分为 7 种:空(Null)、未定义(Undefined)、数字(Number)、字符串(String)、布尔值(Boolean)、符号(Symbol)、对象(Object)。

  • 1.undefined
    Undefined 是一个很特殊的数据类型,它只有一个值,也就是 undefined。
    可以通过下面几种方式来得到 undefined:
    • 引用已声明但未初始化的变量;
    • 引用未定义的对象属性;
    • 执行无返回值函数;
    • 执行 void 表达式;
    • 全局常量 window.undefined 或 undefined。

推荐通过 void 表达式void 0来得到 undefined 值,因为这种方式既简便(window.undefined 或 undefined 常量的字符长度都大于 “void 0” 表达式)又不需要引用额外的变量和属性;同时它作为表达式还可以配合三目运算符使用,代表不执行任何操作
如何判断一个变量的值是否为 undefined 呢?

1
typeof x === 'undefined'
  • 2.null
    Null 数据类型和 Undefined 类似,只有唯一的一个值null,都可以表示空值,甚至我们通过 “==” 来比较它们是否相等的时候得到的结果都是 true,但 null 是 JavaScript 保留关键字,而 undefined 只是一个常量。也就是说可以声明名称为 undefined 的变量(虽然只能在老版本的 IE 浏览器中给它重新赋值),但将 null 作为变量使用时则会报错。

  • 3.Boolean
    Boolean数据类型只有两个值:true 和 false。但是常常会将各种表达式和变量转换成 Boolean 数据类型来当作判断条件。

  • 4.number
    数值类型,有 2 个特殊数值需要注意一下,即 NaN 和 Infinity。

    • NaN(Not a Number)通常在计算失败的时候会得到该值。要判断一个变量是否为 NaN,则可以通过 Number.isNaN 函数进行判断。
    • Infinity 是无穷大,加上负号 “-” 会变成无穷小,在某些场景下比较有用,比如通过数值来表示权重或者优先级,Infinity 可以表示最高优先级或最大权重。
  • 精度转换*
    在进行浮点数运算时。比如我们执行简单的运算 0.1 + 0.2,得到的结果是 0.30000000000000004,如果直接和 0.3 作相等判断时就会得到 false。
    出现这种情况的原因在于计算的时候,JavaScript 引擎会先将十进制数转换为二进制,然后进行加法运算,再将所得结果转换为十进制。在进制转换过程中如果小数位是无限的,就会出现误差。同样的,将数字 5 开方后再平方得到的结果也和数字 5 不相等。

  • 解决办法*

    • 先转换成整数进行计算,然后再转换回小数,这种方式适合在小数位不是很多的时候。比如一些程序的支付功能 API 以“分”为单位,从而避免使用小数进行计算。
    • 舍弃末尾的小数位。比如对上面的加法就可以先调用 toPrecision 截取 12 位,然后调用 parseFloat 函数转换回浮点数。
    1
    parseFloat((0.1 + 0.2).toPrecision(12)) // 0.3
  • 5.string

千位分隔符是指为了方便识别较大数字,每隔三位数会加入一个逗号,该逗号就是千位分隔符。如果要编写一个函数来为输入值的数字添加千分位分隔符,该怎么实现呢?
一种实现方式是通过 for 循环的索引值找到对应的字符;而另一种方式是通过数组反转,从而变成从左到右操作。

1
2
3
4
5
6
7
8
9
10
11
12
// 将字符串数据转化成引用类型数据,即用数组来实现
function sep(n) {
  let [i, c] = n.toString().split(/(\.\d+)/)//将数值转换成字符数组
// 通过数组反转,从而变成从左到右遍历数值每一位,每隔 3 位添加分隔符
  return i.split('').reverse().map((c, idx) => (idx+1) % 3 === 0 ? ',' + c: c).reverse().join('').replace(/^,/, '') + c
}
// 通过引用类型,即用正则表达式对字符进行替换来实现
function sep2(n){
  let str = n.toString()
  str.indexOf('.') < 0 ? str+= '.' : void 0
  return str.replace(/(\d)(?=(\d{3})+\.)/g, '$1,').replace(/\.$/, '')
}
  • 6.symbol
    symbol 是 ES6 中引入的新数据类型,它表示一个唯一的常量,通过 Symbol 函数来创建对应的数据类型,创建时可以添加变量描述,该变量描述在传入时会被强行转换成字符串进行存储。
    1
    2
    3
    4
    5
    6
    7
    var a = Symbol('1')
    var b = Symbol(1)
    a.description === b.description // true
    var c = Symbol({id: 1})
    c.description // [object Object]
    var _a = Symbol('1')
    _a == a // false
    symbol 属性类型比较适合用于两类场景中:常量值和对象属性
    • 避免常量值重复
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function getValue(key) {
      switch(key){
        case 'A':
          //...
        ...
        case 'B':
    //...
      }
    }
    getValue('B');
    这段代码对调用者而言非常不友好,因为代码中使用了魔术字符串(魔术字符串是指在代码之中多次出现、与代码形成强耦合的某一个具体的字符串或者数值),导致调用 getValue 函数时需要查看函数源码才能找到参数 key 的可选值。所以可以将参数 key 的值以常量的方式声明出来。
    改进:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    const KEY = {
      apple: 'A',
      banana: 'B',
      ...
    }
    function getValue(key) {
      switch(key){
        case KEY.apple:
          ...
        ...
        case KEY.banana:
          ...
      }
    }
    getValue(KEY.baidu);
    但这样也并非完美,假设现在我们要在 KEY 常量中加入一个 key,根据对应的规则,很有可能会出现值重复的情况:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const KEY = {
    apple: 'A',
    banana: 'B',
    //...
    bear: 'B'
    }
    //因此可以使用symbol,不关心值本身,只关心值得唯一性
    const KEY = {
      apple: Symbol(),
      banana: Symbol(),
      //...
      bear: Symbol()
    }
    • 避免对象属性覆盖
      假设有这样一个函数 fn,需要对传入的对象参数添加一个临时属性 user,但可能该对象参数中已经有这个属性了,如果直接赋值就会覆盖之前的值。此时就可以使用 Symbol 来避免这个问题。
    1
    2
    3
    4
    5
    function fn(o) { // {user: {id: xx, name: yy}}
      const s = Symbol()
      o[s] = 'zzz'
      ...
    }
  • 7.Object
    相对于基础类型,引用类型 Object 则复杂很多。简单地说,Object 类型数据就是键值对的集合,键是一个字符串(或者 Symbol) ,值可以是任意类型的值; 复杂地说,Object 又包括很多子类型,比如 Date、Array、Set、RegExp。

由于引用类型在赋值时只传递指针,这种拷贝方式称为浅拷贝
而创建一个新的与之相同的引用类型数据的过程称之为深拷贝
现在我们来实现一个拷贝函数,支持上面 7 种类型的数据拷贝。

对于 6 种基础类型,我们只需简单的赋值即可,而 Object 类型变量需要特殊操作。因为通过等号“=”赋值只是浅拷贝,要实现真正的拷贝操作则需要通过遍历键来赋值对应的值,这个过程中如果遇到 Object 类型还需要再次进行遍历。

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
// 为了准确判断每种数据类型,我们可以先通过 typeof 来查看每种数据类型的描述
[undefinednulltrue''0Symbol(), {}].map(it => typeof it)// ["undefined", "object", "boolean", "string", "number", "symbol", "object"]
function clone(data{
  let result = {}
// 通过 getOwnPropertyNames 和 getOwnPropertySymbols 函数将键名组合成数组
  const keys = [...Object.getOwnPropertyNames(data), ...Object.getOwnPropertySymbols(data)]
  if(!keys.length) return data //判断是否为null
  keys.forEach(key => {
    let item = data[key]
    if (typeof item === 'object' && item) {
      result[key] = clone(item)//递归
    } else {
      result[key] = item
    }
  })
  return result
}
//为了避免数据嵌套陷入递归死循环,需要把已添加的对象记录下来
function clone(obj{
  let map = new WeakMap()
  function deep(data{
    let result = {}
    const keys = [...Object.getOwnPropertyNames(data), ...Object.getOwnPropertySymbols(data)]
    if(!keys.length) return data
    const exist = map.get(data)
    if (exist) return exist
    map.set(data, result)
    keys.forEach(key => {
      let item = data[key]
      if (typeof item === 'object' && item) {
        result[key] = deep(item)
      } else {
        result[key] = item
      }
    })
    return result
  }
  return deep(obj)
}

2. 原型和原型链的理解

  • 什么是原型和原型链?
    简单地理解,原型就是对象的属性,包括被称为隐式原型的proto属性和被称为显式原型的prototype属性
  • 隐式原型通常在创建实例的时候就会自动指向构造函数的显式原型*
    1
    2
    3
    4
    5
    var a = {}
    a.__proto__ === Object.prototype // true
    var b= new Object()
    b.__proto__ === a.__proto__ // true
    // 当创建对象 a 时,a 的隐式原型会指向构造函数 Object() 的显式原型。
    显式原型是内置函数(比如 Date() 函数)的默认属性,在自定义函数时(箭头函数除外)也会默认生成,生成的显式原型对象只有一个属性 constructor ,该属性指向函数自身。通常配合 new 关键字一起使用,当通过 new 关键字创建函数实例时,会将实例的隐式原型指向构造函数的显式原型。说人话就是显式原型对象在使用 new 关键字的时候会被自动创建
  • new 操作符实现了什么?
    1
    2
    function F(init) {}
    var f = new F(args)
    其中主要包含了 3 个步骤:
  • 1.创建一个临时的空对象,为了表述方便,我们命名为 fn,让对象 fn 的隐式原型(_proto_)指向函数 F 的显式原型(prototype)
  • 2.执行函数 F(),将 this 指向对象 fn,并传入参数 args,得到执行结果 result;
  • 3.判断上一步的执行结果 result,如果 result 为非空对象,则返回 result,否则返回 fn。
    1
    2
    3
    4
    // 即执行了下面代码
    var fn = Object.create(F.prototype)
    var obj = F.apply(fn, args)
    var f = obj && typeof obj === 'object' ? obj : fn;
  • 怎么通过原型链实现多层继承?
    假设构造函数 B() 需要继承构造函数 A(),就可以通过将函数 B() 的显式原型指向一个函数 A() 的实例,然后再对 B 的显式原型进行扩展。那么通过函数 B() 创建的实例,既能访问用函数 B() 的属性 b,也能访问函数 A() 的属性 a,从而实现了多层继承。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function A() {}
    A.prototype.a = function() {
      return 'a';
    }
    function B() {}
    B.prototype = new A(); //B继承了A
    B.prototype.b = function() {
      return 'b';
    }
    var c = new B()
    c.b() // 'b'
    c.a() // 'a'

四. 关于浏览器我们要注意什么

1. 浏览器加载网络资源的速度提升

可以通过减少响应内容大小,比如使用 gzip 算法压缩响应体内容和 HTTP/2 的压缩头部功能;另一种更通用也更为重要的技术就是使用缓存

  • HTTP缓存
    使用缓存最大的问题往往不在于将资源缓存在什么位置或者如何读写资源,而在于如何保证缓存与实际资源一致的同时,提高缓存的命中率。也就是说尽可能地让浏览器从缓存中获取资源,但同时又要保证被使用的缓存与服务端最新的资源保持一致。

为了达到这个目的,需要制定合适的缓存过期策略(简称“缓存策略”),HTTP 支持的缓存策略有两种:强制缓存和协商缓存。
强制缓存:强制缓存是在浏览器加载资源的时候,先直接从缓存中查找请求结果,如果不存在该缓存结果,则直接向服务端发起请求。
协商缓存:协商缓存的更新策略是不再指定缓存的有效时间了,而是浏览器直接发送请求到服务端进行确认缓存是否更新,如果请求响应返回的 HTTP 状态为 304,则表示缓存仍然有效。控制缓存的难题就是从浏览器端转移到了服务端。

2. 手写promise、async/await

Promise 状态

Promise 的 3 个状态分别为 pending、fulfilled 和 rejected。

  • pending:“等待”状态,可以转移到 fulfilled 或者 rejected 状态
  • fulfilled:“执行”(或“履行”)状态,是 Promise 的最终态,表示执行成功,该状态下不可再改变。
  • rejected:“拒绝”状态,是 Promise 的最终态,表示执行失败,该状态不可再改变。

Promise 解决过程

Promise 解决过程是一个抽象的操作,即接收一个 promise 和一个值 x,目的就是对 Promise 形式的执行结果进行统一处理。需要考虑以下 4 种情况。

  • 情况 1: x 等于 promise
    抛出一个 TypeError 错误,拒绝 promise。

  • 情况 2:x 为 Promise 的实例
    如果 x 处于等待状态,那么 promise 继续等待至 x 执行或拒绝,否则根据 x 的状态执行/拒绝 promise。

  • 情况 3:x 为对象或函数
    该情况的核心是取出 x.then 并调用,在调用的时候将 this 指向 x。将 then 回调函数中得到结果 y 传入新的 Promise 解决过程中,形成一个递归调用。其中,如果执行报错,则以对应的错误为原因拒绝 promise。
    这一步是处理拥有 then() 函数的对象或函数,这类对象或函数我们称之为“thenable”。注意,它只是拥有 then() 函数,并不是 Promise 实例。

  • 情况 4:如果 x 不为对象或函数
    以 x 作为值,执行 promise。

Promise 实现


前端核心基础知识总结
https://appleking10.github.io/2021/01/28/前端核心基础知识总结/
Author
金依妮
Posted on
January 28, 2021
Licensed under