0%

前言

2022年3月29号,React 18正式版发布,小明也兴冲冲地开始hooks学习之旅。某天他使用codesandbox写了一个小demo时,他发现组件诡异地渲染了多次,函数组件代码如下:

count.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default function Count() {
const [counter, setCounter] = useState(0);
useEffect(() => {
console.log("effect");
setTimeout(() => {
setCounter(counter + 1);
}, 3000);
});
console.log("before render");

return (
<div className="container">
<div className="el">{counter}</div>
</div>
);
}

实现的效果很简单,每隔三秒将count加1,很简单对不对?

打开这个 demo,同时打开控制台,你就可以看到如下输入:

CPT2204012224-580x267.gif

before rendereffect一开始都打印了两次,之后before render每次都诡异地打印了两次

React.StrictMode

这是为什么呢?经过一番查找,发现是React.StrictMode的锅。React 17文档中是这样描述 React.StrictMode:

StrictMode 是一个用来突出显示应用程序中潜在问题的工具。与 Fragment 一样,StrictMode 不会渲染任何可见的 UI。它为其后代元素触发额外的检查和警告。

React 17文档中关于它的作用大概可以归为两类:

  • 检测副作用
  • 对于在应用中使用已经废弃、过时的方法会发出警告

对于第二点,相信大家都可以理解,毕竟React现在都发布18版了,不少以前的方法已经过时或者废弃了,在较新版本的React中再使用这些方法肯定时不安全的。

那么对于第一点呢?

这不得不提React 18文档对于StrictMode的描述:

React offers a “Strict Mode” in which it calls each component’s function twice during development.

大意就是在开发者模式中,StrictMode会将相应组件执行两次,这下重复执行的疑惑解决了。

但新的疑问产生了,文档中一直提的副作用又是啥?副作用这个词在React 17文档中提到的次数很多,如何理解它呢?

在计算机科学中,函数副作用指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响 – 维基百科

举个通俗易懂的例子:张三不小心感冒了,鼻塞流涕,医生给他开了感冒药,感冒药的作用就是让我们的身体恢复健康,但是服用的过程中,张三感冒的症状的确是减轻了,但同时他感到浑身乏力、嗜睡,这就副作用,即意料之外的结果。

事实上,React 18的文档提到的更多的是purity,即纯度,这其实是函数式编程的理念,这与React 17文档中提到的无副作用是一个意思,react hooks函数式组件实际上就是函数式编程理念的体现。编写纯函数带来了一定的心智负担,但随着开发者对其接受度的提高,新文档中大量使用了purity进行相关描述。文档中提到,纯函数带来了以下优势:

  • 多环境运行。例如可以运行在服务端,因为同样的输入,总是对应同样的输出,因此组件可以被其他人复用;
  • 减少重复渲染。如果函数组件的输入没有改变,直接复用就好啦,不需要重复渲染。
  • 随时中断渲染。在渲染层级较深的组件树时,数据发生了改变,那么React可以马上重新开始渲染,而不用等待过时的渲染完成。

因此StrictMode就是在开发中帮助我们进行检测,保证我们编写的函数组件都是 '纯' 的,这也就解释了为什么开头提到的为什么组件会执行两次,StrictMode会多执行一次,两次执行的结果相同,证明我们编写的的确是纯函数。

实例

以下列举了一些引发StrictMode的其他例子

  1. 在函数内部修改一个已经存在的变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let guest = 0;

function Cup() {
// Bad: changing a preexisting variable!
guest = guest + 1;
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaSet() {
return (
<>
<Cup />
<Cup />
<Cup />
</>
)
}

很明显,当这个组件执行多次,guest的值是逐渐增长的,回想一下纯函数的定义,上述函数组件不是纯函数,因此StrictMode会进行警告。

  1. useState

下面的组件定义了counter变量,我们给useState传入了初始化函数,组件首次运行时会执行一次这个函数,返回的值作为counter的初始值,在之后的执行中这个初始化函数会被忽略。

此外,在使用setState改变counter的值时,我们为其提供了一个updater函数,当点击按钮时会更新counter的值。

因此,理论上首先会打印一次initializer,然后每按一次按钮,都会打印一次updater

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React, { useState } from "react";

export default function Count2() {
const [counter, setCounter] = useState(() => {
console.log('initializer')
return 0
});

const handleClick = function() {
setCounter(() => {
console.log('updater')
return counter+1
})
}

return (
<div className="container">
<div className="el">{counter}</div>
<button onClick={handleClick}>增加</button>
</div>
);
}

demo

而事实上,StrictMode会帮我们把这两个函数都调用两次,保证其为纯函数。

总结

React.StrictMode在开发模式下会重复调用组件,保证我们编写的组件

  • 检测意外的副作用,确保函数组件时纯函数
  • 对于在应用中使用已经废弃、过时的方法会发出警告
  • 仅在开发者模式下运行,不影响生产构建

    参考

    Side Effects: (un)intended consequences

# 略微探究React StrictMode两次渲染的问题

严格模式


什么是图片懒加载?

如今一张图片的大小可以轻松达到几M的大小,如果一个页面中同时有比较多这样的图片,同时加载的话会造成页面加载缓慢,当目标图片未加载成功时,通常会使用一个小图片占位,例如一个转圈圈的gif来表示图片正处于loading状态,但这改变不了很多图片同时加载带来的加载缓慢的问题。

因此很多时候我们需要将页面内未出现在可视区域内的图片先不做加载, 等到滚动到可视区域后再去加载,这样对页面的加载性能有较大提升,同时也改善了用户体验。


主要实现思路如下:

1、img元素的自定义属性上data-xxx上挂载目标图片url, src属性指向默认图片地址。

2、监听图片是否出现在用户的可视区域内。

3、出现在可视区域内后更新src属性为目标图片url

以下两种实现方式的区别主要体现在第二步。

监听scroll事件+节流 实现

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div></div>
</body>
<style>
div {
display: flex;
justify-content: center;
flex-direction: column;
}
.img {
width: 50%;
height: 50%;
}
</style>
<script>
const loadingSrc = 'https://c.tenor.com/j-sGh5ZtdaEAAAAi/hongfei-fei.gif'
const srcs = [
'https://w.wallhaven.cc/full/wq/wallhaven-wq855r.png',
'https://w.wallhaven.cc/full/6o/wallhaven-6ooo66.png',
'https://w.wallhaven.cc/full/eo/wallhaven-eoqk88.png',
'https://w.wallhaven.cc/full/8x/wallhaven-8xlx2o.png',
'https://w.wallhaven.cc/full/q2/wallhaven-q2drrl.jpg',
'https://w.wallhaven.cc/full/ym/wallhaven-ymrrex.png',
'https://w.wallhaven.cc/full/mp/wallhaven-mp8wkm.png',
'https://w.wallhaven.cc/full/dg/wallhaven-dgzp2m.jpg',
'https://w.wallhaven.cc/full/lq/wallhaven-lqek12.png',
'https://w.wallhaven.cc/full/r7/wallhaven-r7mkzq.jpg',
'https://w.wallhaven.cc/full/qd/wallhaven-qd12jl.png',
'https://w.wallhaven.cc/full/0q/wallhaven-0q23r5.jpg',
'https://w.wallhaven.cc/full/76/wallhaven-76wj9o.png',
'https://w.wallhaven.cc/full/5d/wallhaven-5d6gd5.jpg',
'https://w.wallhaven.cc/full/gj/wallhaven-gjmd9q.png',
'https://w.wallhaven.cc/full/pk/wallhaven-pk91m3.png',
'https://w.wallhaven.cc/full/pk/wallhaven-pk9zm9.png',
'https://w.wallhaven.cc/full/j8/wallhaven-j85wv5.png',
]

for (let src of srcs) {
let imgNode = document.createElement('img')
document.body.appendChild(imgNode)
imgNode.src = loadingSrc
imgNode.className = 'img'
imgNode.setAttribute('data-src', src)
}

let imgList = [...document.querySelectorAll('img')]
let length = imgList.length
</script>
</html>

渲染后的结构如下图所示,src属性指向一张默认加载图片的地址,data-src属性上是目标图片的地址
image.png
在此方法中懒加载的核心逻辑为:监听scroll事件,对比容器的高度、滚动高度、和图片距离容器顶部的高度,判断是否滚动到可视区域

主要实现如下:

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
const imgLazyLoad = (function () {
let count = 0
return function () {
let deleteIndexList = []
imgList.forEach((img, index) => {
let rect = img.getBoundingClientRect()
if (rect.top < window.innerHeight) {
console.log(`加载第${index}张图片`)
img.src = img.dataset.src
deleteIndexList.push(index)
count++
}
// 说明图片已经全部加载完成了,移除监听事件
if (count === length) {
document.removeEventListener('scroll', imgLazyLoad)
}
})
// 剔除已经完成加载的图片的地址
imgList = imgList.filter(
(img, index) => !deleteIndexList.includes(index)
)
}
})()
// 监听鼠标滚动事件
document.addEventListener('scroll', imgLazyLoad)

Element.getBoundingClientRect() 返回元素的大小以及相对于视口的位置.
window.innerheight 是视口的高度.
img.top < window.innerHeight时说明该图片元素正处于视口之内,应该进行加载,将img.src的值更换为img.dataset.src的值,加载目标图片。

让我们来看一下使用效果:
CPT2204051626-585x381.gif
可以看出,当鼠标滚动时,首先加载在当前视口内的图片,当新的图片进入视口内时,加载新的图片,所有图片并没有同时进行加载,达到了预期效果。
但是,直接将函数绑定在scroll事件上,当页面滚动时,函数会被高频触发。因此我们再使用节流函数优化一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function throttle(func, wait) {
let prev = 0,
context,
args
return function () {
let now = +new Date()
context = this
args = arguments
if (now - prev > wait) {
func.apply(context, args)
prev = now
}
}
}
// 监听鼠标滚动事件
document.addEventListener('scroll', throttle(imgLazyLoad, 500))

在线地址

但是!!!
imag

CPT2204051626-585x381.gif
getBoundingClientRect返回最新的位置信息,会触发回流重绘以返回正确值

getBoundingClientRect一样会触发回流重绘的还有:

  • offsetTop、offsetLeft、offsetWidth、offsetHeight
  • scrollTop、scrollLeft、scrollWidth、scrollHeight
  • clientTop、clientLeft、clientWidth、clientHeight
  • getComputedStyle() 具体请看这里

这里使用了节流减少了回调函数的调用次数,但有没有办法不触发回流重绘也能检测到图片处于当前视口呢?这就是接下来要介绍的intersectionObserver API

使用 intersectionObserver API 实现

IntersectionObserver 是浏览器原生提供的构造函数,接受两个参数:callback 是可见性变化时的回调函数,option 是配置对象(该参数可选)。
callback 函数的参数(entries)是一个数组,每个成员都是一个 IntersectionObserverEntry 对象
其中:isIntersecting表示目标是否可见,即是否在视口中
具体可见MDN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function imgLazyLoad() {
const io = new IntersectionObserver((inters) => {
inters.forEach((item, index) => {
// 进入可视区域
if (item.isIntersecting) {
item.target.src = item.target.dataset.src
//停止监听该元素
io.unobserve(item.target)
}
})
})
// 监听每一个元素
imgList.forEach((el) => io.observe(el))
}

相比于使用getBoundingClientRect,实现了相同的懒加载效果,但避免了不必要的回流重绘。

在线地址

参考

前端性能优化之图片懒加载

实现图片懒加载(Lazyload)

MDN - Intersection Observer

开篇

image.png

stopImmediatePropagationstopPropagation是一对亲兄弟,他们两实现的功能是相似的,那么这两个函数的区别在哪里呢?如果你对于事件传播已经很了解,那么可以直接跳到总结部分,反之可以按照顺序阅读。

什么是DOM事件流?

早上起床你都会做些什么呢?闹钟响了,你摸索着掐掉闹钟,再眯一会,然后起床,刷牙,吃早餐……..,这一系列事件连接起来就构成了你起床的事件流。而DOM事件也是有一个流程的,DOM 事件标准描述了事件传播的 3 个阶段:

  1. 捕获阶段(Capturing phase)—— 事件(从 Window)向下走近元素。
  2. 目标阶段(Target phase)—— 事件到达目标元素。
  3. 冒泡阶段(Bubbling phase)—— 事件从元素上开始冒泡。

    事件捕获

    DOM事件触发时(被触发DOM事件的这个元素被叫作事件源),浏览器会从根节点开始 由外到内 进行事件传播。即事件从文档的根节点流向目标对象节点,途中经过各个层次的DOM节点,最终到目标节点,完成事件捕获,

    事件冒泡

    事件冒泡与事件捕获顺序相反。事件捕获的顺序是从外到内,事件冒泡是从内到外。
    当事件传播到了目标阶段后,处于目标阶段的元素就会将接收到的时间向上传播,就是顺着事件捕获的路径,反着传播一次,逐级的向上传播到该元素的祖先元素,直到window对象。

    引发事件的那个嵌套层级最深的元素被称为目标元素,可以通过 event.target 访问。注意与 this(=event.currentTarget)之间的区别:

    • event.target —— 是引发事件的“目标”元素,它在冒泡过程中不会发生变化。
    • this —— 是“当前”元素,其中有一个当前正在运行的处理程序。

    使用addEventListener监听事件

    使用EventTarget.addEventListeners可以指定的侦听器注册到EventTarget上,我们可以监听页面上发生的诸多事件(mousedown,click, scroll等),当事件发生时就可以调用相应的函数进行事件处理。

它接收三个参数:分别是type, listener, options, 其中type表示监听事件类型的字符串,listener通常传入的是一个函数,在相应事件发生时调用它进行事件处理,options是一个与listener有关的可选参数对象,可用的参数有capture,once,passive, signal,
在本文中比较关注的是capture, 默认为false, 代表该侦听器在时间冒泡阶段触发,反之在事件捕获阶段触发。

如何阻止事件传播?

很经典的一个问题是如何阻止事件冒泡,很多人应该马上就能想到使用event.cancleBuble = true或者使用event.stopPropagation(), 这的确能够阻止事件冒泡。其实更准确的说,应该是阻止了事件传播, 因为这两者不仅仅可以在冒泡阶段使用,也可以在捕获阶段使用。看下面这一个例子。

Event.cancelBubble 属性是 Event.stopPropagation()的一个曾用名。在从事件处理程序返回之前将其值设置为true可阻止事件的传播。

dom结构如下:

1
2
3
4
5
6
7
8
9
<div class="A" style="font-size:30px">
A
<div class="B" style="font-size:30px">
B
<p style="font-size:30px">
&#128513;P
</p>
</div>
</div>

image.png

script部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const p = document.querySelector('p')
p.addEventListener("mousedown", (event) => {
// event.cancelBubble = true
event.stopPropagation()
console.log("我是p元素上被绑定的第一个监听函数: mousedown");
}, false);

p.addEventListener("mousedown", (event) => {
console.log("我是p元素上被绑定的第二个监听函数: mousedown");
}, false);
document.querySelector(".A").addEventListener("mousedown", (event) => {
event.cancelBubble = true
console.log("我是div & class A元素");
}, false);
document.querySelector(".B").addEventListener("mousedown", (event) => {
console.log("我是div & class B元素");
}, false);

上述事件传播的路径是:A -> B -> P -> B -> A, 选定<p></p>作为目标元素,每次试验时点击图中的笑脸所在的绿色区域。
image.png
我们在P元素上绑定了两个事件侦听器,并使用了event.cancleBuble = true(event.stopPropagation())成功阻止了事件冒泡。如果我在事件捕获阶段使用会怎么样呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const p = document.querySelector('p')
p.addEventListener("mousedown", (event) => {
// event.cancelBubble = true
event.stopPropagation()
console.log("我是p元素上被绑定的第一个监听函数: mousedown");
}, false);

p.addEventListener("mousedown", (event) => {
console.log("我是p元素上被绑定的第二个监听函数: mousedown");
}, false);
document.querySelector(".A").addEventListener("mousedown", (event) => {
// event.cancelBubble = true
event.stopPropagation()
console.log("我是div & class A元素");
}, true);
document.querySelector(".B").addEventListener("mousedown", (event) => {
console.log("我是div & class B元素");
}, true);

image.png
从结果可以清楚地看到成功地阻止了事件传播。

那么现在让我们回到最初的问题:stopImmediatePropagation与stopPropagation有什么区别?
可以这样简单第理解:stopImmediatePropagationstopPropagation的加强版,前者不仅仅可以阻止事件传播,它还可以在多个事件监听器被附加到相同元素相同事件类型上时保证只有一个事件侦听器被调用。换句话说,当同一个元素上有多个事件侦听器侦听同一事件时,这些事件侦听器都会被按照初始添加顺序被调用,如上文中p元素就绑定了两个监听mousedown事件的侦听器;但如果在其中一个事件侦听器中使用了event.stopImmediatePropagation()的话,在该侦听器之后的侦听器就不会被调用了。
将代码改造成如下:

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 p = document.querySelector('p')
p.addEventListener("mousedown", (event) => {
console.log("我是p元素上被绑定的第一个监听函数: mousedown");
}, false);

p.addEventListener("mousedown", (event) => {
event.stopImmediatePropagation()
console.log("我是p元素上被绑定的第二个监听函数: mousedown");
}, false);

p.addEventListener("mousedown",(event) => {
console.log("我是p元素上被绑定的第三个监听函数: mousedown");
// 该监听函数排在上个函数后面,该函数不会被执行
}, false);

p.addEventListener('click', (event) => {
// 嘿嘿嘿,虽然监听的是同一元素,但却是不同事件!
console.log('我是p元素上被绑定的第四个监听函数: click')
}, false)

document.querySelector(".A").addEventListener("mousedown", (event) => {
console.log("我是div & class A元素");
}, false);

document.querySelector(".B").addEventListener("mousedown", (event) => {
console.log("我是div & class B元素");
}, false);

image.png
可以看到stopImmediatePropagation成功阻止了事件冒泡,A元素,B元素的侦听器都没有被调用,此外元素p上绑定了四个侦听器,其中三个侦听mousedown事件,剩下一个侦听click事件,我们在第二个侦听器中调用stopImmediatePropagation,前两个侦听器顺利调用,此外,由于最后一个侦听器侦听click事件,不受影响。

总结

  • 事件传播: 捕获 -> 目标元素 -> 冒泡
  • event
    • event.target —— 引发事件的层级最深的元素。
    • event.currentTarget(=this)—— 处理事件的当前元素(具有处理程序的元素)
    • event.eventPhase —— 当前阶段(capturing=1,target=2,bubbling=3)。
  • 阻止事件传播的方法:
    • event.stopPropagation()
    • event.cancelBubble = true
    • event.stopImmediatePropagation()
  • event.stopImmediatePropagation()
    • 可以阻止事件传播
    • 多个事件侦听器侦听同一元素同一事件时,执行event.stopImmediatePropagation()的侦听器之后的侦听器被忽略
    • 侦听同一元素的不同事件的侦听器不受影响
      最好自己动手试一试,加深理解。
      源代码在此处

    参考

    Event - MDN

冒泡和捕获

JavaScript事件捕获冒泡与捕获

校验原理

baidu

循环冗余校验(Cyclic Redundancy Check, CRC)是一种根据网络数据包或计算机文件等数据产生简短固定位数校验码的一种信道编码技术,主要用来检测或校验数据传输或者保存后可能出现的错误。 它是利用除法及余数的原理来作错误侦测的。

简单的说就是在发送帧后附加一个数(用来校验的校验码),生成新帧,发送给接收端,接收端收到之后除以某个特定的数(接收方和发送方共同选定)如果能够整除,说明传输正确;如果不能整除,说明传输过程中发生了差错。

​ 以上只是一个通俗的解释,有几个问题需要解决,这里的’除‘是通常意义上的除法吗?校验码是如何生成的?发送端如何生成新帧?接下来一个个解决这些问题。

:sparkler: 这里的’除’指的是’模2除法‘,既不向上位借位,也不比较除数和被除数的相同位数值的大小,只要以相同位数进行相除即可,相当于异或运算

image.png

:sparkler:校验码是如何生成的?

假定所选定的除数二进制位数(假设为k位),然后在要发送的数据帧(假设为m位)后面加上k-1位“0”,然后以这个加了k-1个“0“的新帧(一共是m+k-1位)以“模2除法”方式除以上面这个除数,所得到的余数(也是二进制的比特串)就是该帧的CRC校验码,也称之为FCS(帧校验序列)

但要注意的是,余数的位数一定要是比除数位数只能少一位,哪怕前面位是0,甚至是全为0(附带好整除时)也都不能省略。

:sparkler: 如何生成新帧?

将生成的校验码直接添加在原数据帧(注意是原数据帧)后面


接收端接收之后,接收端会把这个新帧再用上面选定的除数以“模2除法”方式去除,验证余数是否为0,如果为0,则证明该帧数据在传输过程中没有出现差错,否则出现了差错。

以上就是CRC校验的基本原理,此外上文提到除数是发送端与接收端约定得到的,具体是如何得到的呢?:question:

实际上是通过crc多项式得到的,假如选取的多项式为 x^5+x^2+1(CRC-5-USB),那么除数的二级制位数就有6位,即总位数等于最高位的幂次加1,其二进制比特串为 100101。

示例

假设多项式为G(X) = X^4 + X^3 + 1,要求出二进制序列10110011的CRC校验码

按照上文提到的方法,除数有五位:11001,crc校验码有4位,用来求crc校验码的新帧为101100110000(补4个0),使用模2除法相除,得到余数0100。

image.png

原型链继承

将父类的实例作为子类实例的原型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Parent () {
this.names = ['kevin', 'daisy'];
}

function Child () {

}

Child.prototype = new Parent();

var child1 = new Child();

child1.names.push('yayu');

console.log(child1.names); // ["kevin", "daisy", "yayu"]

var child2 = new Child();

console.log(child2.names); // ["kevin", "daisy", "yayu"]

问题:

  1. 父类引用类型的属性被所有实例共享

  2. 创建子类型实例时不能给父类型构造函数传参

    可以看到Child其实是 ‘’空’‘ 的,自然无法传参

借用构造函数

在子类构造函数中调用父类构造函数,可以在子类构造函数中使用call()apply()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Parent () {
this.names = ['kevin', 'daisy'];
}

function Child () {
Parent.call(this);
}

var child1 = new Child();

child1.names.push('yayu');

console.log(child1.names); // ["kevin", "daisy", "yayu"]

var child2 = new Child();

console.log(child2.names); // ["kevin", "daisy"]

优点:

  1. 避免了引用类型的属性被所有实例共享

  2. 可以在 Child 中向 Parent 传参

缺点:

  1. 子类不能访问父类prototype(这里即Parent.prototype)上的方法
  2. 所有方法属性都写在构造函数中,每次创建实例都会初始化

组合继承 *

基本的思路就是使用原型链继承父类prototype上的属性和方法,而通过构造函数继承实例属性,这样既可以实现方法重用,又可以让每个实例都有自己的属性

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
function Parent (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function () {
console.log(this.name)
}

function Child (name, age) {

Parent.call(this, name);

this.age = age;

}

Child.prototype = new Parent();
Child.prototype.constructor = Child;

var child1 = new Child('kevin', '18');

child1.colors.push('black');

console.log(child1.name); // kevin
console.log(child1.age); // 18
console.log(child1.colors); // ["red", "blue", "green", "black"]

var child2 = new Child('daisy', '20');

console.log(child2.name); // daisy
console.log(child2.age); // 20
console.log(child2.colors); // ["red", "blue", "green"]

组合继承结合了原型链继承和借用构造函数继承的优点,这很棒,但是也存在一个小问题,在上述过程中父类构造函数执行了两次,带来的后果是什么呢?后果是子类实例和其原型上存在同名属性,最后子类属性会屏蔽原型上的属性,这虽然不影响使用,但却不够优雅,后面的寄生式组合继承会解决这个问题。

原型式继承

就是 ES5 Object.create 的模拟实现,将传入的对象作为创建的对象的原型

1
2
3
4
5
function createObj(o) {
function F(){}
F.prototype = o;
return new F();
}

缺点: 与原型链继承一样, 引用类型的属性会被所有实例共享,同时不能

寄生式继承

创建一个仅用于封装继承过程的函数,该函数在内部以某种形式来做增强对象,最后返回对象。可以看到内部使用了Object.create(),因此其本质上是在原型式继承返回的新对象上增加了新的属性和方法,实现增强效果。

1
2
3
4
5
6
7
function createObj (o) {
var clone = Object.create(o);
clone.sayName = function () {
console.log('hi');
}
return clone;
}

缺点: 同借用构造函数继承,每次都会重新创建方法,且Object.create()执行浅复制,多个实例的引用类型指向相同,造成污染。

寄生组合式继承 *

组合式继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Parent (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function () {
console.log(this.name)
}

function Child (name, age) {
Parent.call(this, name);
this.age = age;
}

Child.prototype = new Parent();
Child.prototype.constructor = Child;

组合式继承中父类构造函数Parent()调用了两次

一次是设置子类实例的原型,

1
Child.prototype = new Parent()

一次是调用子类构造函数,为什么在这里也调用了一次Parent()呢?

1
let child1 = new Child('kevin', '18');

因为首先Child是这样的:

1
2
3
4
function Child (name, age) {
Parent.call(this, name);
this.age = age;
}

在用new创建Child实例的过程中会经历如下阶段:

  • 生成一个以Child.prototype为原型的对象
  • 使用给定的参数调用构造函数Child, 然后将this指向新创建的对象
  • 返回新创建的对象(Child中并没有return语句指定返回哪个对象,因此默认返回新创建的对象)

因此在第二步调用构造函数的过程中又调用了一次父类构造函数Parent

寄生组合式继承 就是为了避免重复调用父类构造函数:

1
2
3
4
Child.prototype = Object.create(Parent.prototype);
// Child.prototype = Parent.prototype
// 直接这样的话会存在隐患,如果修改Child.prototype的话会影响Parent.prototype
Child.prototype.constructor = Child;

这样就避免了在设置子类实例的原型时调用父类构造函数。

Object.create(o)的作用是返回一个新对象,该对象以给定对象o为原型

其实现类似:

1
2
3
4
5
function createObj(o) {
function F(){}
F.prototype = o;
return new F();
}

可以理解为原型式继承

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
function Parent (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function () {
console.log(this.name)
}

function Child (name, age) {
Parent.call(this, name);
this.age = age;
}

Child.prototype = Object.create(Parent.prototype);
// Child.prototype = Parent.prototype
// 直接这样的话会存在隐患,如果修改Child.prototype的话会影响Parent.prototype
Child.prototype.constructor = Child;



// 可以封装一下
// function prototype(child, parent) {
// var prototype = Object.create(parent.prototype);
// prototype.constructor = child;
// child.prototype = prototype;
// }

// // 当我们使用的时候:
// prototype(Child, Parent);

《JavaScript高级程序设计》

JavaScript深入之继承

JS原型链与继承别再被问倒了

目的

​ 最终希望达到的一个效果就是通过clash自定义规则配置,使得在clash处于工作状态时访问国内网站可以直接使用国内网络连接。

步骤

1.配置Profiles下的Parsers

点击Settings,点击Profiles下的Parsers那一栏右侧对应的edit,打开配置页面

image.png

打开后使用下方代码覆盖原有代码,然后退出

1
2
3
4
5
6
7
parsers: # array
- url: xxxxxxxxx
yaml:
prepend-rules:
- DOMAIN-SUFFIX,hdbits.org,🎯 国内网络
- DOMAIN-SUFFIX,xthor.tk,🎯 国内网络
- DOMAIN-SUFFIX,filelist.io,🎯 国内网络

2 .复制订阅地址

点击小齿轮

image.png

复制订阅地址

image.png

3. 更换url字段

按照步骤1打开对应页面,将其中的url字段替换为刚才复制的订阅地址

image.png

4.检测

点击对应图标

image.png

可以看到新的规则已经添加,像taobao, baidu, zhihu等网站等都采用的是国内网站直连的方式进行连接:sparkler:

image.png

更多

还有其他的自定义选项

1
2
3
4
5
6
7
8
9
10
11
DOMAIN-SUFFIX:域名后缀匹配
DOMAIN:域名匹配
DOMAIN-KEYWORD:域名关键字匹配
IP-CIDR:IP段匹配
SRC-IP-CIDR:源IP段匹配
GEOIP:GEOIP数据库(国家代码)匹配
DST-PORT:目标端口匹配
SRC-PORT:源端口匹配
PROCESS-NAME:源进程名匹配
RULE-SET:Rule Provider规则匹配
MATCH:全匹配

参考

clash文档

简易教程 | Clash | 自定义在线分流规则策略组

parseInt

1
2
3
parseInt(021)  //17
parseInt('021') //21
parseInt('021', 8) //17

定义

parseInt(string, radix) 解析一个字符串并返回指定基数十进制整数radix2-36之间的整数,表示被解析字符串的基数。

注意点

  • 解析的目标值是字符串,如果不是的话,会先进行toString转换(默认转换为十进制),如果不能转换为数字,返回NaN

  • 返回值是一个十进制整数 或者 NaN

  • 在任何情况下都最好指定基数,范围是2-36

  • 如果 parseInt 遇到的字符不是指定 radix 参数中的数字,它将忽略该字符以及所有后续字符,并返回到该点为止已解析的整数值。 parseInt 将数字截断为整数值。 允许前导和尾随空格。

    1
    2
    3
    4
    5
    parseInt("15e2", 10); //15
    parseInt("15px", 10); //15
    parseInt(4.7, 10); //4
    parseInt(4.7 * 1e22, 10); // 非常大的数值变成 4
    parseInt(0.00000000000434, 10); // 非常小的数值变成 4

    如果 radixundefined0或未指定的

    1. 在解析以0开头的字符串时,有很多实现环境仍然把以 0 开头的数值字符串(numeric string)解释为一个八进制数,但ECMAScript 5 已经禁止了这种做法
    1
    2
    3
    4
    5
    parseInt("0e0");
    // 0

    parseInt("08");
    // 8
    1. 如果输入的 string以 “0x“或 “0x“(一个0,后面是小写或大写的X)开头,那么radix被假定为16,字符串的其余部分被当做十六进制数去解析

有趣的例子

1
2
['10','10','10','10','10'].map(parseInt);
// [10, NaN, 2, 3, 4]

了解了上述知识,再来看这个例子其实会容易很多,map中的回调函数需要三个参数,currentValue 是callback 数组中正在处理的当前元素。
index可选, 是callback 数组中正在处理的当前元素的索引。
array可选, 是callback map 方法被调用的数组。

而正好parseInt函数也可以接收两个参数,一个是目标值string, 另一个是基数radix,也就是说对于目标数组中的每一个数,其本身被当作parseInt中的第一个参数,其索引值被当作了parseInt的第二个参数,也就是基数radix.

上面那个例子其实可以写成这样:

1
2
3
4
5
6
7
8
['10','10','10','10','10'].map((num, index) => {
return parseInt(num, index)
});
// [10, NaN, 2, 3, 4]
// 对于第一个数: 基数为0,将num当成十进制解析
// 对于第二个数, 基数为1, 返回NaN
// 对于第三个数, 基数为2, 将num当成二进制解析
// ..... 同上

自测

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
parseInt(021)  //17
// (021).toString() --> '17' --> 17
parseInt('021') //21
//未指定radix, 默认radix=10
parseInt('021', 8) //17
//指定 radix=8


// 返回15
parseInt("0xF", 16);
parseInt("17", 8);
parseInt(021, 8);
parseInt("015", 10); // parseInt(015, 8); 返回 13
parseInt(15.99, 10);
parseInt("15,123", 10);
parseInt("FXX123", 16);
parseInt("1111", 2);
parseInt("15 * 3", 10);
parseInt("15e2", 10);
parseInt("15px", 10);

//返回NaN
parseInt("Hello", 8); // 根本就不是数值
parseInt("546", 2); // 除了“0、1”外,其它数字都不是有效二进制数字

//返回-15
parseInt("-F", 16);
parseInt("-0XF", 16);
parseInt(-15.1, 10);
parseInt(" -17", 8);
parseInt("-1111", 2);
parseInt("-15e1", 10);
parseInt("-12", 13);

一个更严格的解析函数

有时采用一个更严格的方法来解析整型值很有用。此时可以使用正则表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
filterInt = function (value) {
if(/^(\-|\+)?([0-9]+|Infinity)$/.test(value))
return Number(value);
return NaN;
}

console.log(filterInt('421')); // 421
console.log(filterInt('-421')); // -421
console.log(filterInt('+421')); // 421
console.log(filterInt('Infinity')); // Infinity
console.log(filterInt('421e+0')); // NaN
console.log(filterInt('421hop')); // NaN
console.log(filterInt('hop1.61803398875')); // NaN
console.log(filterInt('1.61803398875')); // NaN

参考

parseInt

Why is parseInt(021, 8) === 15?

Morris遍历

介绍

morris遍历是利用二叉树本身空闲出来的指针(n个节点的二叉树有n+1个指针空闲)来作为辅助变量完成二叉树的遍历的一种方法。Morris遍历法能以O(1)的空间复杂度和O(n)的时间复杂度实现二叉树的三种遍历,其中不使用栈或额外空间。

Morris中序遍历过程

记当前节点为root, 设置一个predecessor指针用来指向当前节点左子树的最右边节点(中序遍历的前驱节点)

  1. 如果root无左孩子,root向右移动

  2. 如果root有左孩子,predecessor指向root左子树的最右边节点

    1. 如果predecessor的right指针指向空,让其指向root,root向左移动
    2. 如果predecessor的right指针指向root,让其指向null,root向右移动
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
var inorderTraversal = function(root) {
let res = []
let cur = root
let mosRight = null
while(cur !== null) {
//有左子树,找前驱节点,判断是第一次访问还是第二次访问
if(cur.left !== null) {
mosRight = cur.left
while(mosRight.right !== null && mosRight.right !== cur) {
mosRight = mosRight.right
}
//是第一次访问,访问左子树//是第一次访问,访问左子树
if(mosRight.right === null) {
mosRight.right = cur
cur = cur.left
} else {
//第二次访问了,那么应当消除链接
//该节点访问完了,接下来应该访问其右子树
mosRight.right = null
res.push(cur.val)
cur = cur.right
}
} else { //没有左子树,直接访问该节点,再访问右子树
res.push(cur.val)
cur = cur.right
}
}
return res
}

前序遍历

后序遍历

复杂度分析

对于二叉树来说,传统的遍历方式,一个节点最多只可能被访问两次,因此其时间复杂度均为O(n)。而由于借助了栈和队列这样的辅助数据结构,无论哪种方式,其空间复杂度与树高有直接关系,因此传统遍历方式空间复杂度为平均O(logn),最差O(n)

而morris遍历对于每一个有左子树的节点,其寻找前驱的过程只会执行两次,一次是建立前驱-后继关系的时候,一次是恢复树结构的时候。因此事实上,二叉树的每条路最多只可能被循环访问两次,其时间复杂度必然为O(n),空间复杂度O(1)

99. 恢复二叉搜索树

给你二叉搜索树的根节点 root ,该树中的两个节点被错误地交换。请在不改变其结构的情况下,恢复这棵树。

进阶:使用 O(n) 空间复杂度的解法很容易实现。你能想出一个只使用常数空间的解决方案吗?

示例 1:

输入:root = [1,3,null,null,2]
输出:[3,1,null,null,2]
解释:3 不能是 1 左孩子,因为 3 > 1 。交换 1 和 3 使二叉搜索树有效。
示例 2:

输入:root = [3,1,4,null,null,2]
输出:[2,1,4,null,null,3]
解释:2 不能在 3 的右子树中,因为 2 < 3 。交换 2 和 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
var recoverTree = function(root) {
let x = null, y = null, pred = null, predecessor = null
while(root !== null) {
if(root.left !== null) {
// 首先让predecessor指针指向root.left,然后一直向右走到尽头
predecessor = root.left
while(predecessor.right !== null && predecessor.right !== root) {
predecessor = predecessor.right
}
// 走到root节点左子树的最右边节点后,将其右指针指向root,然后将root指针指向其左节点,开始新一轮遍历
if(predecessor.right === null) {
predecessor.right = root
root = root.left
}
// predecessor指针不为空,说明左子树已经访问完了,需要断开连接,也就是对子树进行恢复,不然就成为了一个图
else {
if(pred !== null && root.val < pred.val) {
y = root
if(x === null) {
x = pred
}
}
pred = root
predecessor.right = null
// 左边找完了,找右边
root = root.right
}
}
else {
// 如果没有左孩子,则访问右孩子
if(pred !== null && root.val < pred.val) {
y = root
if(x === null) {
x = pred
}
}
pred = root
root = root.right
}
}
[x.val, y.val] = [y.val, x.val]
}

二叉树.png

题目

124. 二叉树中的最大路径和

路径 被定义为一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。

路径和 是路径中各节点值的总和。

给你一个二叉树的根节点 root ,返回其 最大路径和

示例 1:

img

1
2
3
输入:root = [1,2,3]
输出:6
解释:最优路径是 2 -> 1 -> 3 ,路径和为 2 + 1 + 3 = 6

示例 2:

img

1
2
3
输入:root = [-10,9,20,null,null,15,7]
输出:42
解释:最优路径是 15 -> 20 -> 7 ,路径和为 15 + 20 + 7 = 42

提示:

  • 树中节点数目范围是 [1, 3 * 104]
  • -1000 <= Node.val <= 1000

思路

树的题目大多涉及到递归,需要思考如何设计递归函数
dfs函数的作用:返回以给定节点为根节点的二叉树最大路径和
需要注意:

  • 最大路径可以从树中任意节点出发
  • 路径至少包含一个节点
  • 以某个节点为根节点的最大路径和有可能为负数。因为-1000 <= Node.val <= 1000

解法

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
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {number}
*/
// 从任意节点出发 => 选择记录max全局最大值
/* dfs记录以当前节点为根节点的最大路径和,递归左右子节点,
更新全局最大值的同时返回以当前节点为根节点的最大路径和 */
var maxPathSum = function(root) {
let max = -Infinity
const dfs = (root) => {
if(!root) return 0
let left = dfs(root.left) //以左节点为根节点的二叉树最大路径和
let right = dfs(root.right) // 以右节点为根节点的二叉树最大路径和
// 递归的过程中可能已经出现了原二叉树的最大路径和,需要更新最大路径和max
// 为什么要加入0进行比较?因为最大路径和有可能为负数
//为负数的话就返回Math.max就返回0,代表不选择该节点
max = Math.max(max, Math.max(left, 0) + Math.max(right, 0)+root.val)
/*此外,dfs的功能就是计算以当前节点为根节点的最大路径和,因此将 当前节点的值(即root.val)
与 以当前节点的子节点为根节点的子树的最大路径和(即 Math.max(left, right, 0))的和
作为以当前节点为根节点的最大路径和
为什么加入0进行比较的原因同上,因为最大路径和有可能为负数 */
return Math.max(left, right, 0) + root.val
}
dfs(root)
return max
};

关键概念

前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串

模式串与前缀表对应位置的数字表示的就是:下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。

next数组就可以是前缀表,但是很多实现都是把前缀表统一减一(右移一位,初始位置为-1)之后作为next数组。

j和next[0]初始化为-1,整个next数组是以 前缀表减一之后的效果来构建的

时间复杂度

生成next数组,时间复杂度是O (m), 后续匹配过程时间复杂度是O(n),时间复杂度是O(m+n)

暴力解法是O(m*n)

构建next数组

  1. 初始化

定义两个指针i和j,j指向前缀末尾位置,i指向后缀末尾位置。

  1. 处理前后缀不相同的情况: 回退
  2. 处理前后缀相同的情况: j++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const getNext = function(s) {
let next = []
let j = -1
next[0] = j
for(let i = 1; i < s.length; i++) {
while(j >= 0 && s[j+1] !== s[i]){ // s[j + 1] 与 s[i] 不相等
j = next[j] // 回退
// next[j]就是记录着j(包括j)之前的子串的最长相同前后缀的长度。
}
if(s[j+1] === s[i]) j++ //找到了相同的前后缀,同时向后移动i、j
next[i] = j // 将j(前缀的长度)赋给next[i],next[i]记录最长相同前后缀的长度
}
return next //f
}
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
let strStr = function(haystack, needle) {
if(needle.length === 0) return 0
const getNext = function(s) {
let next = []
let j = -1
next[0] = j
for(let i = 1; i < s.length; i++) {
while(j >= 0 && s[j+1] !== s[i]){
j = next[j]
}
if(s[j+1] === s[i]) j++
next[i] = j
}
return next
}

let next = getNext(next, needle)
let j = -1
for(let i = 0; i < haystack.length; i++) {
while(j >= 0 && haystack[i] !== needle[j+1]){
j = next[j]
}
if(haystack[i] === needle[j+1]) j++
if(j === needle.length - 1) {
return i - needle.length + 1
}
}
return -1
}

leetcode

459. 重复的子字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function repeatedSubstringPattern(s: string): boolean {
const getNext = (s) => {
let j = -1
let next = [j]
for(let i = 1; i < s.length; i++) {
while(j >= 0 && s[j+1] !== s[i]) {
j = next[j]
}
if(s[j+1] === s[i]){
j++
}
next[i] = j
}
return next
}
if(s.length === 0) return false
let next = getNext(s)
if(next[s.length - 1] !== -1 && s.length % (s.length - (next[s.length - 1] + 1)) === 0) {
return true
}
return false
};