一、数据结构和算法
一、复杂度
我们对一个算法进行评价,一般有 2 个重要依据:时间复杂度 与 空间复杂度。
#1.1 时间复杂度
| 名称 | 运行时间 T(n) | 时间举例 | 算法举例 |
|---|---|---|---|
| 常数 | O(1) | 3 | - |
| 线性 | O(n) | n | 遍历数组 |
| 平方 | O(n^2) | n^2 | 冒泡排序 |
| 对数 | O(log(n)) | log(n) | 二分查找 |
| 指数 | O(2^n) | 2^n | 斐波那契数列 |
- O(1)
算法所执行的时间不会随着 n 的大小变化,不管 n 是什么,我们都称为 O(1)。
const a = 1
- O(n)
下面的 for 循环,会执行 n 次,不管 n 是几,都称做 O(n)。
for (let i = 0; i < n; i++) {}
- O(n2)
2 层嵌套循环,内层循环会执行 n*n 次;也就是 n^2,随着 n 的增大,复杂度会随着 n 的增大,复杂度会随着平方级增加。
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; i++) {}
}
- O(log(n)) 对数复杂度
高中数学知识, y = loga x 叫做对数函数,a 是对数;y 就是以 a 为底 x 的对数。
如果,即 a 的 x 次方等于 N(a > 0,且 a ≠ 1),那么数 x 叫做以 a 为底 N 的对数(logarithm),其中,a 叫做对数的底数,N 叫做真数,x 叫做 “以 a 为底 N 的对数”。
for (let i = 1; i <= n; i *= 2) {
console.log(i)
}
我们可以看出,这个算法对元素进行跳跃式输出;就是数组从下标开始,每次都会乘以 2,直到 i 小于 n 的时候结束循环。
12 -> 22 -> 42 -> 82 ....
2^1 -> 2^2 -> 2^3 -> 2^4 ....
假设 i 在 i = i*2 的规则递增了 x 次之后,i <= n 开始不成立;那么我们可以推算出下面一个数学方程式:
2 ^ (x >= n)
x 解出来,就是大于等于以 2 为底 n 的对数。
x>= log2 n
只有当 x>= log2 n 循环才成立;才会执行循环体。
如果把上面的 i*= 2 改为 i*= 3,那么这段代码的时间复杂度就是 log3 n 。
注意涉及到对数的时间复杂度,底数和系数都是要被简化掉的。那么这里的 O(n) 就可以表示为:
O(log(n))
- O(2^n)
我们常见的斐波那契数列,F (0) = 1,F (2) = 1, F (n) = F (n − 1) + F (n − 2);时间复杂度该如何考虑?
function fibonacci(n){
if (n === 0 || n === 1) {
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
假如 n=5,那么递归的的时间复杂度该怎么算?
在 n 层的完全二叉树中,节点的总数为 2n − 1 ,所以得到 F(n) 中递归数目的上限为 2n − 1 。因此我们可以大概估出 F(n) 的时间复杂度为 O(2^n)。在斐波那契中有大量重复的计算,我们可以把已经计算过的存起来,同样的计算再取出来。
1.3 总结
#时间空间相互转换
对于一个算法来说,它的时间复杂度和空间复杂度往往是相互影响的。 那我们熟悉的 Chrome 来说,流畅性方面比其他厂商好了多人,但是占用的内存空间略大。 当追求一个较好的时间复杂度时,可能需要消耗更多的储存空间。 反之,如果追求较好的空间复杂 度,算法执行的时间可能就会变长。
常见的复杂度不多,从低到高排列就这么几个: O(1) 、 O(log(n)) 、 O(n) 、 O(n^2) 、O(2^n)。
二、数据结构
JavaScript 常用的数据结构:
#2.1 字符串
字符串是由零个和多个字符组成的有序序列,是 JavaScript 最基础的数据结构,也是学习编程的基础。
#2.1.1 翻转整数
示例:
输入: 123
输出: 321
输入: -123
输出: -321
function reverse(params) {
if (typeof params !== 'number') return
const value =
params > 0
? String(params)
.split('')
.reverse()
.join('')
: String(params)
.slice(1)
.split('')
.reverse()
.join('')
const result = value > 0 ? parseInt(value, 10) : 0 - parseInt(value, 10);
return result
}
复杂度分析
- 时间复杂度: O(n) reverse 函数时间复杂度为 O(n),n 为整数长度,最好的情况为 O(1)。
- 空间复杂度: O(n) 代码中创建临时对 value 象, n 为整数长度,因此空间复杂度为 O(n),最好的情况为 O(1)。
#2.1.2 反转字符串
示例:
输入: china
输出: anihc
#方法 1
function reverse(params) {
if (typeof params !== 'string') return
// 反转字符串
return params.split('').reverse().join('')
}
方法 2 首尾替换法
如果在面试过程中回答出第一种可能不是面试官想要的,就像排序问题,你回答 sort api,面试官不需要你去回答 api。
function reverse(str) {
const params = str.split('')
const n = params.length
for (let i = 0; i < n / 2; i++) {
[params[i], params[n - i - 1]] = [params[n - i - 1], params[i]]
}
return params.join('')
}
复杂度分析
时间复杂度: O(n)
空间复杂度: O(1)
reverse 中没有新开辟的内存空间
2.1.3 验证回文字符串
回文字符串就是从中间分开,2 边完全对称;顺读和倒读都一样的字符串。
'youuoy'
1\
#方法 1
function isPalindrome(params) {
//去除 非单词字符、非数字
const arr = params.toLowerCase().replace(/[^A-Za-z0-9]/g, '')
// 反转
const reverseStr = arr.split('').reverse().join('')
return reverseStr === params
}
#复杂度分析
时间复杂度: O(n) 该解法中, toLowerCase() , replace() , split() , reverse() , join() 的时间复杂度都为 O(n),且都在独立的循环中执行,因此,总的时间复杂度依然为 O(n)。
空间复杂度: O(n) 该解法中,申请了 1 个大小为 n 的字符串和 1 个大小为 n 的数组空间,因此,空间复杂度 为 O(n∗2) ,即 O(n)。
方法 2
代码:
function isPalindrome(params) {
//去除 非单词字符、非数字
const arr = params
.toLowerCase()
.replace(/[^A-Za-z0-9]/g, '')
.split('')
// 双指针
let i = 0
let j = arr.length - 1
while (i < j) {
// 首尾是否相等
if (arr[i] === arr[j]) {
i++
j--
} else {
return false
}
}
return true
}
#复杂度分析
时间复杂度: O(n) 该解法中 while 循环最多执行 n/2 次,即回文时,因此,时间复杂度为 O(n)。
空间复杂度: O(n) 该解法中,申请了 1 个大小为 n 的数组空间,因此,空间复杂度为 O(n)。
2.2 数组
#2.2.1 找出出现一次的数字
描述:给一非空数组,某个元素只出现一次,其他元素都均出现 2 次;找出出现一次的那个元素?
示例:
输入: [1,6,3,3,1,]
输出: 6
#方法 1: 分组法
用分组法,时间和空间的复杂度都偏高,理解分组的思想才是重点。
function singleNumber(arr) {
const arrGroups = arr.map((item) => {
return arr.filter((ele) => item === ele)
})
return arrGroups.find((item) => item.length === 1)[0]
}
复杂度分析:
- 时间复杂度: O(n2)
使用了 map 和 filter ,嵌套遍历,故为 O(n2) 。
- 空间复杂度: O(n)
map 方法创建了一个长度为 n 的数组,占用了 n 大小的空间。
方式 2: 异或比较法
异或运算符可以将两个数字比较,由于有一个数只出现了一次,其他数皆出现了两次,类似乘法 则无论先后顺序,最后相同的数都会异或成 0,唯一出现的数与 0 异或就会得到其本身,该方法是最优解,直接通过比较的方式即可得到只出现一次的数字。
function singleNumber(arr) {
return arr.reduce((accumulator, currentValue) => accumulator ^ currentValue)
}
复杂度分析:
时间复杂度: O(n)
仅用 reduce 方法遍历,一层遍历,故为 O(n) 。
空间复杂度: O(1)
空间复杂度为常量,占用空间没有随数据量 n 的大小发生改变,故为 O(1)。
.2.2 两数求和的问题
描述:给定一个整数数组 nums 和一个目标值 target,在数组中找出和为目标值 target 的两个整数?
示例:
输入: num1 [1,6,3,4,7] target 9
输出: [6,3]
2 层循环的时间复杂度是 O(n^2),O(1)没有开启新的空间。
遇到 2 层循环,我们就应该反思一下,能不能空间换时间,把它换成一层循环。
#方式 1:利用 map
几乎所有求和的问题,我们都可以转化为求差的问题,这道题就是典型的例子;通过求差使问题变的更简单。
我们用 target 减当前元素,得到差值,然后去 map 对象中找差值;没有就存下当前元素,每遍历一个新数字都去 map 对象中查找;直到找到目标元素为止。我们把 2 层循环简化到一层循环,可以说是空间换时间。
const nums = [5, 7, 8, 2, 4]
const target = 9
const res = {}
let lookup = []
function toSum(list) {
list.find((item, i) => {
// 查看当前元素所对应的目标元素是否存在map对象中
if (res[target - item]) {
lookup = [item, target - item]
return true
} else {
res[item] = i
return false
}
})
}
复杂度分析:
时间复杂度: O(n)
我们只遍历了包含有 n 个元素的列表一次,在 map 中进行的每次查找只花费 O(1) 的时间, 因此总的复杂度为 O(n)
空间复杂度: O(n)
2.2.3 合并 2 个有序数组
描述:给两个有序数组 num1 和 num2,把 num2 合并到 num1 中。
示例:
输入: num1 [1,3,5,8] num2 [2,4,5,6,7]
输出: num1 [1,2,3,4,5,5,6,7,8]
#方式 1: 双指针
用 2 个指针指向数组的末尾,每次只对指针指向的元素进行比较,取出较大的元素放在 num1 的末尾往前补。
为什么从后往前补?
因为 num1 前面有元素,从前往后补,会替换掉原来的元素。
// 2个有序数组
const num1 = [1, 3, 5, 8]
const num2 = [2, 4, 5, 6, 7]
let k = num1.length - 1
let j = num2.length - 1
// m是num1和num2合并后长度
let m = k + j + 1
while (k >= 0 && j >= 0) {
if (num2[j] > num1[k]) {
num1[m] = num2[j]
j--
} else {
num1[m] = num1[k]
k--
}
m--
}
// 特殊情况:1.num1先遍历完,2.num2先遍历完。
// num2先遍历完,我们不用处理,因为我们就是把num2合并到num1。
// num1先遍历完,我们把num2全部复制到num1中。
while (j >= 0) {
num1[m] = num2[j]
j--
m--
}
复杂度分析:
时间复杂度: O(n)
我们只遍历了包含有 n 个元素的列表一次
空间复杂度: O(n)
有 2 个数组,有一个数组在不断增加
2.2.4 三数之和
描述:给定一个包含 n 个整数的数组 nums ,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?找出所有满足条件且不重复的三元组。
示例:
输入: num1 [-1, 0, 1, 2, -1, -4]
输出: 满足要求的三元组集合:[ [ -1, -1, 2 ], [ -1, 0, 1 ] ]
三数求和问题,固定其中一个数,在剩下的数中寻找两个数和这个固定数相加是等于 0。
似乎需要三层循环才能解决,不过现在我们有双指针,定位效率大大提高;双指针可以做到空间换时间,可以帮助我们降低问题的复杂度。
#方式 1: 双指针
const nums = [-1, 0, 1, 2, -1, -4]
function threeNum(list) {
list.sort()) // 双指针需要有序数组
const uniqueMap = []
for (let k = 0; k < list.length; k++) {
let m = k + 1
let n = list.length - 1
while (m < n) {
const sum = list[k] + list[m] + list[n]
// 三数之和大于0,说明右边的数过大,需要左移
if (sum > 0) {
n--
} else if (sum < 0) {
// 三数之和小于0,说明左边的数过小,需要右移
m++
} else {
// 符合条件存起来
uniqueMap.push([list[k], list[m], list[n]])
//下面是为了去重,当前m指向的数和右边的数相等,就往右移动
const leftVaule = list[m]
while (m < n && leftVaule === list[m]) {
m++
}
//下面是为了去重,当前n指向的数和左边的数相等,就往左移动
const rightVaule = list[n]
while (n > m && rightVaule === list[n]) {
n--
}
}
}
// 去重,k和右边的数相等了,就往右移动
while (k < list.length - 1 && nums[k] === nums[k + 1]) {
k++
}
}
return uniqueMap
}
threeNum(nums)
在上面这道题中,左右指针一起从两边往中间位置相互迫近,这样的特殊双指针形态,被称为“对撞指针”。
什么时候你需要联想到对撞指针?
这里我给大家两个关键字——“有序”和“数组”。 没错,见到这两个关键字,立刻把双指针法调度进你的大脑内存。普通双指针走不通,立刻想对撞指针!
复杂度分析:
时间复杂度: O(n^2)
数组遍历 O(n) ,双指针遍历 O(n) ,因此复杂度为 O(n) ∗ O(n) 为 O(n2)
空间复杂度: O(n)
uniqueMap 可能在不断的开启新空间
2.3 栈和队列
#栈(stack)
栈是一种特殊的列表,它按照先进后出的原则存储数据;先进入的数据被压在栈底,后进去的数据在栈顶。需要读取数据的时候需要从栈顶开始。
我们可以想象一下,我们放盘子,先放入下面盘子,拿盘子的时候最后才能拿到。
栈的主要操作就是入栈、出栈,在 js 中栈和队列的实现一般都依赖数组;可以看做栈和队列是特别的数组。(用链表来实现也是可以的,用链表来实现会比数组麻烦很多)
#2.3.1 栈的实现
class Stack {
constructor() {
this.data = []
}
push(value) {
this.data.push(value)
}
pop() {
return this.data.pop()
}
}
const stack = new Stack()
// 入栈
stack.push(1)
stack.push(2)
while (stack.data.length) {
console.log('出栈', stack.pop())
}
队列(queue)
队列是先进先出的数据结构,跟我们的栈不一样,队列的概念比较好理解;它就像我们去食堂买饭一样,先去的先打到饭;后去的后打到饭。
队列的操作有 2 种:插入元素、删除元素。
- 只可以向尾部插入元素
- 只可以头部移除元素
#2.3.2 队列的实现
class Queue {
constructor() {
this.data = []
}
unQueue(value) {
this.data.push(value)
}
deQueue() {
return this.data.shift()
}
}
const stack = new Queue()
// 入队
stack.unQueue(1)
stack.unQueue(2)
while (stack.data.length) {
console.log('出对', stack.deQueue())
}
2.3.3 有效括号
给定一个只包括 '(',')','{','}','[',']'的字符串,判断字符串是否有效
示例:
输入: 输入: '()';
输出: true;
输入: '()[]{}';
输出: true;
输入: '()]{';
输出: false;
遇见匹配的问题,最好的解决方案就是stack结构,js 中没有栈结构,可以用数组来模拟。
此题的解决方案就是遇到左括号push 到数组里(数组后面回说是栈),遇到右括号可以从栈顶取出跟右括号对比,匹配成功执行出栈操作,遍历完毕;栈中无元素,说明是有效字符串。
const brackets = '([{}])'
function isValid(b) {
const stack = []
// 也可以用对象模拟,map对象是括号的匹配规则
const map = new Map([
['}', '{'],
[']', '['],
[')', '('],
])
for (let item of b) {
if (!map.has(item)) {
stack.push(item)
} else if (!stack.length || stack.pop() !== map.get(item)) {
return false
}
}
return !stack.length
}
isValid(brackets) // true
复杂度分析:
时间复杂度: O(n) 遍历了 1 次 有 n 个元素的空间
空间复杂度: O(n)
2.3.4 缺失的数字
给定一个包含 0, 1, 2, ..., n 中 n 个数的序列,找出 0 .. n 中没有出现在序列中的那个数。
示例:
输入: [3,5,4,6,8,9,1,2,0];
输出: 7;
#方法 1: 分组法计算法
通过计算如果不缺少一个数字的情况下总和应该多少,缺少一个数字总合多少;它们之差就是缺少的那个数字。
const lostArr = [3, 5, 4, 6, 8, 9, 1, 2, 0, 2]
function lostNumber(arr) {
const total = arr.reduce((total, num) => {
return total + num
}, 0)
const length = arr.length
const termial = ((1 + length) * length) / 2
return termial - total
}
lostNumber(lostArr) //7
复杂度分析:
时间复杂度: O(n) ,2 次遍历数组,所以最终的时间复杂度是 O(n)
空间复杂度: O(1),会有 3 个临时变量,不会随着入参数组的增加而增加,所以空间复杂度是 O(1)
2.3.5 滑动窗口问题
给定一个数组 nums 和滑动窗口的大小 k,请找出所有滑动窗口里的最大值。
示例:
输入: nums = [3,5,4,-6,8,9,-1,2,0] 和k=3
输出: [5,5,8,9,9,9,2]
什么是滑动窗口?
k 代表窗口的范围,每次滑动窗口往前进一步,直到窗口无法前进。
下面是窗口滑动的过程:
[3,5,4],-6,8,9,-1,2,0
3,[5,4,-6],8,9,-1,2,0
3,5,[4,-6,8],9,-1,2,0
3,5,4,[-6,8,9],-1,2,0
3,5,4,-6,[8,9,-1],2,0
3,5,4,-6,8,[9,-1,2],0
3,5,4,-6,8,9,[-1,2,0]
- 双指针+遍历
窗口的本质就是约定范围,如果想约定范围我们就应该想到双指针;我们可以定义一个left左指针,定义一个right右指针;分别指向窗口的两端,通过不断移动左右指针来达到移动窗口的目的。
function maxSlidingWindow(nums, k) {
const maxArr = []
for (let i = 0; i <= nums.length - k; i++) {
const left = i
const right = k + i
const kNums = nums.slice(left, right)
const maxNum = Math.max(...kNums)
maxArr.push(maxNum)
}
return maxArr
}
上面的解法其实还可以优化,每次我们滑动窗口都需要找出最大值,每次滑动窗口只移动一位;其实当前窗口前 2 位数是上一次后 2 位数; 在当前窗口找最大值时候,上一次比较过的数在本次窗口还要比较,那是不是重复比较了?
- 双端队列
每次我们滑动窗口,此时滑动窗口少了一个元素,又增加了一个元素;每次窗口移动时,只根据发生变化的元素对最大值进行更新,那么复杂度是不是降低了?
双端队列可以完美解决这个问题,双端队列的核心就是维护一个有效递减的队列,在遍历的时候尝试将每个元素推入队列中,要求必须递减,如果当前要推入的元素比队列的最后一个元素大,就移除最后一个元素,然后当前元素在跟队列的最后一个元素再次比较;比最后一个元素大就移除最后一个元素,最终当前元素小于最后元素为止,停下并将当前元素放入队列中。
需要注意的是最大元素不在当前窗口内要及时清除掉。
function maxSlidingWindow(nums, k) {
const maxArr = []
const doubleEndedQueue = []
const len = nums.length
for (let i = 0; i < len; i++) {
while (
doubleEndedQueue.length &&
nums[i] > nums[doubleEndedQueue[doubleEndedQueue.length - 1]]
) {
doubleEndedQueue.pop()
}
doubleEndedQueue.push(i)
while (doubleEndedQueue.length && doubleEndedQueue[0] <= i - k) {
doubleEndedQueue.shift()
}
if (i >= k - 1) {
maxArr.push(nums[doubleEndedQueue[0]])
}
}
return maxArr
}
2.4 链表
相对于数组,链表是一种稍微复杂的数据结构,掌握起来也要比数组稍微难一些。链表通过指针将不连续的内存串联起来。 数组的线性序是由数组的下标来决定的,而 链表的的顺序是由各个对象中的指针来决定。
在多数编程语言中,数组的长度是固定的,一旦被填满,要再加入数据将会变得非常困难。在数组 中,添加和删除元素也比较麻烦,因为需要把数组中的其他元素向前或向后移动。
JavaScript 的数组被实现成了对象,与 Java 相比,效率偏低。 在实际开发中,不能单靠复杂度就决定使用哪个数据结构,没有一种数据结构是完美的,否则其他的数据结构不都被淘汰了。
链表的结构可以由很多种,它可以是单链表或双链表,也可以是已排序的或未排序的,环形的或非环形的。如果一个链表是单向的,那么链表中的每个元素没有指向前一个元素的指针。已排序的和 未排序的链表较好理解
由于链表是非连续的,想要访问第 i 个元素就没数组那么方便了,需要根据指针一个结点一个结点 地依次遍历,直到找到相应的结点。 数组在插入或删除元素时,为了保证数据的连续性,需要对原有的数据进行挪动。然而链表在插入 或删除时,不要挪动原来的数据,因为链表的数据本身就是非连续的空间,因此在链表中插入、删 除数据是非常快的。
2.4.1 设计一个链表
class Node {
constructor(value) {
this.value = value
this.next = null
}
}
class linkedList {
constructor() {
this.head = new Node('A')
}
// 查找节点
find(item) {
let node = this.head
while (node !== item && node !== null) {
node = node.next
}
return node
}
// 移除节点
remove(item) {
const prevNode = this.findPrev(item)
if (prevNode.next !== null) {
prevNode.next = prevNode.next.next
}
}
// 插入节点
insert(el, item) {
const newNode = new Node(el)
const currentNode = this.find(item)
newNode.next = currentNode.next
currentNode.next = newNode
}
// 找当前节点上一个节点
findPrev(item) {
let node = this.head
while (node.next !== null && node.next.value !== item) {
node = node.next
}
return node
}
}
2.4.2 反转一个链表
示例:
输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL
- 用迭代的方法实现。
const linked={
head:{
value:1,
next:{
value:3,
next:{
value:5,
next:null
}
}
}
}
let head = linked.head
let next = null
let pre= null
while(head){
next = head.next // 先存下后面的链表
head.next = pre // 当前的指针指向上一个
pre =head // 把反转的链表存起来
head =next // 取出存下来的链表,继续遍历
}
// pre 就是最终反转的链表
三、解题思路
1. 回溯算法
回溯算法是一种尝试算法,按选优条件进行搜索;主要是在搜索尝试过程中找问题的解,当发现不满足条件时,就返回,尝试别的路径。
找一条路往前走,能走就继续往前走,走不通就掉头换条路。
#2. 贪心算法
所谓贪心算法,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,它所做的仅仅是在某种意义上的局部最优解
#3. 分治法
对于一个规模为n的问题,若该问题可以容易地解决(比如说规模n较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解。这种算法设计策略叫做分治法。
#4. 动态规划
通过组合子问题的解决,从而解决整体问题的算法
四、排序算法专题
3.1 冒泡排序
#个人理解:
比较 2 个元素,如果顺序错误就把他们交换过来,这个名字的由来就是较小的元素由于交换慢慢“浮”到数列的顶端,它的特点是每一次排序完;右边的总是最大的数值。
#大佬理解:
冒泡 排序 是比较形象的 一种排序算法, 就像小气泡在水底不断往上冒泡,直到变大。那他的算法过程就是这样的,依次比较俩个相邻的节点,然后将较大的放置在后,较小的放置在前,直到排序完成
#算法步骤:
- 比较相邻的元素,如果第一个比第二个大,就交换它们的位置。
- 对每对相邻元素作同样的工作,这步做完后,最后的元素会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个。直到没有任何一对数字需要比较。
#菜鸟教程给出生动的展示图:点击我
#案例: 给数组[2,4,3,5,1,5]进行排序
let arr = [2, 4, 3, 5, 1, 5]
// 正向遍历
function bubbleSort1(src) {
let arr = [...src] // 做浅拷贝,避免影响原数组
let len = arr.length
let current
for (let i = 0; i < len - 1; i++) {
//为什么arr.length-1-i?因为每次遍历完后最大值肯定在最右边,数组的后面的那段其实已经是排序好,无需在排序
for (let j = 0; j < len - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
current = arr[j]
arr[j] = arr[j + 1]
arr[j + 1] = current
}
}
}
return arr
}
/*
反向遍历实现
- 冒泡排序第一次遍历后会将最大值放到最右边,这个值是全局的最大值
- 标准的冒泡排序的每次遍历都会比较全部元素,虽然右侧的值以及是最大值了
- 改进之后,每次遍历后的最大值,次大值,等等都会固定在右侧,避免的重复比较
*/
function bubbleSort2(src) {
let arr = [...src] //做浅拷贝,避免影响原数组
for (let i = arr.length - 1; i > 0; i--) {
for (let j = 0; j < i; j++) {
if (arr[j] > arr[j + 1]) {
;[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
}
}
}
return arr
}
console.log(bubbleSort1(arr)) // [ 1, 2, 3, 4, 5, 5 ]
console.log(bubbleSort2(arr)) // [ 1, 2, 3, 4, 5, 5 ]
//2个方法都会循环10次
3.2 插入排序
#个人理解:
先把第2元素存起来,然后跟第1个的元素进行比较,如果小于第1个元素,那么第1个元素往后挪一个位置;第二个元素放在第一个元素的位置上,如果大于前面的数值,就不用动。
在把第 3 个元素存起来,跟第2个的元素进行比较,如果符合规则(小于前面的元素),第2个元素往后挪一位。 存起来的元素在跟第1个元素比较,如果符合规则,第1个元素就往后挪一位,这时候前面没有元素可比,就把第3个元素放在第1个元素的位置上。需要注意的是如果不符合规则了,就是存的元素比比较元素大的时候,就不用往前比较了,可以插入当前的位置;在比较的过程中,被比较的元素在比较中符合规则就需要往后挪一位,这是给存起来的元素腾位置。
以此慢慢递进完成排序。
(正序:插入就是每次新取一个数,然后倒序地往前找,找到比它小的就插入后面)
#大佬理解:
其实插入排序就和打扑克的时候抓牌一样,新摸一张,然后再已排好的队列里面去插入它。
#算法步骤:
将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。
#菜鸟教程给出生动的展示图:点击我
#案例: 给数组[2,4,3,5,1,5]进行排序
function insertionSort(src) {
let arr = [...src]
for (let i = 1; i < arr.length; i++) {
let current = arr[i]
let preIndex = i - 1
while (preIndex >= 0 && current < arr[preIndex]) {
// 如果current小于前面的值,那么current肯定需要往前插入,具体插入前面的哪个位置,需要跟前面的数进行对比
// 在对比的过程中,如果current小于前面的值;那么对比的数肯定往后挪一位
arr[preIndex + 1] = arr[preIndex]
preIndex--
}
arr[preIndex + 1] = current
}
return arr
}
console.log(insertionSort(arr)) // [ 1, 2, 3, 4, 5, 5 ]
3.3 选择排序
个人理解:寻找最小值,第一次枚举会找到当前数组的最小的一个值,放在首位;就是首位跟最小值交换位置。 第二次枚举会找到数组第二小值,然后第二位置的值跟第二小值交换位置,以此内推。 由此可见每次枚举完,左边是排序好的,右边是待排序的。
#菜鸟教程给出生动的展示图:点击我
function selectionSort(arr) {
var len = arr.length;
var minIndex, temp;
for (var i = 0; i < len - 1; i++) {
minIndex = i;
for (var j = i + 1; j < len; j++) {
if (arr[j] < arr[minIndex]) { // 寻找最小的数
minIndex = j; // 将最小数的索引保存
}
}
temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
return arr;
}
3.4 快速排序
个人理解:快速排序主要采用分治法,定义基准值;左边所有都比基准值小,右边所有的都比基准值大;然后左右两边在定义基准值再次重复上次动作。
#菜鸟教程给出生动的展示图:点击我
function quickSort(arr, left = 0, right = arr.length - 1) {
// 定义递归边界,若数组只有一个元素,则没有排序必要
if(arr.length > 1) {
// lineIndex表示下一次划分左右子数组的索引位
const lineIndex = partition(arr, left, right)
// 如果左边子数组的长度不小于1,则递归快排这个子数组
if(left < lineIndex-1) {
// 左子数组以 lineIndex-1 为右边界
quickSort(arr, left, lineIndex-1)
}
// 如果右边子数组的长度不小于1,则递归快排这个子数组
if(lineIndex<right) {
// 右子数组以 lineIndex 为左边界
quickSort(arr, lineIndex, right)
}
}
return arr
}
// 以基准值为轴心,划分左右子数组的过程
function partition(arr, left, right) {
// 基准值默认取中间位置的元素
let pivotValue = arr[Math.floor(left + (right-left)/2)]
// 初始化左右指针
let i = left
let j = right
// 当左右指针不越界时,循环执行以下逻辑
while(i<=j) {
// 左指针所指元素若小于基准值,则右移左指针
while(arr[i] < pivotValue) {
i++
}
// 右指针所指元素大于基准值,则左移右指针
while(arr[j] > pivotValue) {
j--
}
// 若i<=j,则意味着基准值左边存在较大元素或右边存在较小元素,交换两个元素确保左右两侧有序
if(i<=j) {
swap(arr, i, j)
i++
j--
}
}
// 返回左指针索引作为下一次划分左右子数组的依据
return i
}
// 快速排序中使用 swap 的地方比较多,我们提取成一个独立的函数
function swap(arr, i, j) {
[arr[i], arr[j]] = [arr[j], arr[i]]
}
总结:现在可以用 sort 排序,可以看 v8 的源码去了解它点击我
五、真题
4.1 遍历链表节点
链表:在React中的Fiber中采用链表树的数据结构来解决主线程阻塞的问题,我们一起来试着遍历一个简单的链表结构试试
#🍅 案例:遍历链表节点并对每个节点的value值求和
// 链表
const NodeD = {
value: 4,
next: null
};
const NodeC = {
value: 3,
next: NodeD
};
const NodeB = {
value: 2,
next: NodeC
};
const NodeA = {
value: 1,
next: NodeB
};
const LinkedList = {
head: NodeA
};
// 以下是解题答案
let num = 0;
// 缓存函数
let momoize=(func,hasher)=>{
let cache ={}
return function (...args) {
let key= ""+(hasher?hasher.apply(this,args):args[0])
if(!cache[key]){
cache[key]=func.apply(this,args)
}
return cache[key]
}
}
// 值相加函数
let run =(linkedList, callback)=>{
let head=linkedList.head
while(head){
callback(head.value)
head=head.next
}
return num
}
var _momoize=momoize(run)
function traversal(linkedList, callback) {
_momoize(linkedList, callback)
}
// 调用2次,第二次会读取缓存函数
traversal(LinkedList, current => (num += current));
traversal(LinkedList, current => (num += current));
4.2 Floyd判圈算法
含义: Floyd判圈算法(Floyd Cycle Detection Algorithm),又称龟兔赛跑算法(Tortoise and Hare Algorithm),是一个可以在有限状态机、迭代函数或者 链表上判断是否存在环,求出该环的起点与长度的算法。 在图和树的数据结构在具体使用中,可能会出现循环依赖的情况,如何自动判断,是否存在循环,可以使用Floyd判圈算法
#🍅 通俗讲解:Floyd判圈算法,这个其实就是在算法的设计中会设计快慢两个指针;也可以假设乌龟和兔子进行赛跑,如果他们相遇的话就代表环存在的,还因为这个像跑步比赛的过程中,那个跑的快的肯定会在跑环的时候反超那个跑得慢的人。
#🍅 示例:
- 假设现在有两个指针,一个快指针和一个慢指针,然后快指针以2倍的速度推进,慢指针以1倍的速度推进;如果链表结构存在环形(就是循环依赖)的话,我们现在假设绿色是循环依赖的部分。
- 标交点的部分就是2个指针相遇的地方,在顺时针跑的过程中,橘黄色就是快指针移动的距离,黄色部分就是慢指针移动的距离,可以看出快指针比慢指针多跑了一圈,我们设计一个算法的话,其实要判断 是否有圈出现,就是判断快慢指针是否有重叠,也就是最后指向了同一个对象,那其实就是他们之间出现了循环依赖的过程。
- 下图我们用x、y、z标识了3段距离,慢指针走的距离是x+y;快指针是x+2y+z,我们假设快指针的速度是慢指针的2倍;可以得出公式2(x+y)=x+2y+z,解题得出x=z,也就是说x的距离等于z的距离。
#🍅 案例: 判断对象是否存在循环引用
const c = {
value: -4
};
const b = {
value: 0
};
const a = {
value: 2
};
const head = {
value: 3
};
head.dep = a;
a.dep = b;
b.dep = c;
c.dep = a;
// 解答1,判断是否存在环
const floyd1 = head => {
try {
let clone = JSON.parse(JSON.stringify(head));
if (clone) return -1;
} catch (err) {
return 1;
}
};
// 解答2 判断是否存在环,如果存在,环从哪开始
const floyd2 = head => {
//第一步判断是否有环
let fast= head //快指针
let slow= head //慢指针
while(fast && fast.dep){
fast=fast.dep.dep
slow=slow.dep
// 相等后,说明2者相遇了,说明存在循环
if(fast===slow){
break
}
}
if(!fast || !fast.dep) return -1
/**
* 第二步判断环从哪开始,当快慢指针在交点相遇后,假设快指针是慢指针的2倍,
快指针在往前走,同时一个指针从开始位置走
* 他们相遇后,就是环开始的位置,可以参照图3,最后得出的x=z
*/
let start=head
let pos=0
while(start!==fast){
pos++
start = start.dep
fast = fast.dep
}
return pos
};
4.3 字符串算法(最长公共子序列)
#🍅 字符串算法?
在virtual DOM做Diff Patch操作中,有一条准则就是同一层的节点进行diff patch,从一个dom节点转换成另一个dom节点的过程我们可以 抽象的看成从字符串ABCDEGFG切换成ACDFG,如何保证在操作过程中尽量只做节点移动,减少插入和删除的操作是我们的目标。 简化来看就是要以最小的开销从ABCDEGFG切换成ACDFG。
#🍅 什么是子序列?
一个字符串的子序列是指一个新的字符串,在不改变原序列相对位置的情况下删除原序列若干个元素(也可以不删除)之后得到的新序列,这个序列就原序列的子序列 例如:abcde的子序列有abcd、ace等,像aec不是该序列的子序列。
#🍅 什么是最长公共子序列?(最长公共子序列简称lcs)
给定两个序列X和Y,这2个序列共同拥有最长的那个子序列,就是2个序列的最长公共子序列 例如:abbcbd和dbbceb最长公共子序列是bbcb。
应用场景:字符串相似度对比
#🍅 参考文档:点击我
#🍅 案例: 求最长公共子序列
一般在解决算法的时候,一般有四种算法思想,分治法、动态规划、回溯法、贪心算法,这一题适合动态规划来做,因为这题符合动态规划的特点。
动态规划(英语:Dynamic programming,简称 DP)
动态规划的特点:
- 最优子结构:一个规模为n的问题可以转换成比他小的子问题来求解,最优解肯定是由最优的子解推导出来的
- 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响
- 子问题重叠性:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到(并非必要性质)
最优子结构就比方说 "abcde" 和 "ace" 的最长公共子序列 因为俩个字符串最后的e都相同 那么他们的公共子序列 肯定是 “abcd”和 “ac” 的公共子序列数值上加1
其实动态规划的难点是归纳出递推式,在斐波那契数列中,递推式是已经给出的
动态规划我们拿笔画一画,一个作为横轴一个作为纵轴,我们以2个字符串为例子,那么abcde作为横轴,ace作为纵轴,先初始化第一行和第一列;因为空字符串无论和 abcde 和 ace比,没有公共的子序列,所以都是0,在一个二维数组里存放的格式dp[[0,0,0,0,0],[0],[0]] a和a比有公共子序列a,那么这里就拿他们前面最优子解加上1,这个0加1等于1,所以这里填1。
abcde的第i个字符和ace的第j个字符相等了,说明又多了一个相同的的字符,那么肯定拿他们前面的一个字符i-1和j-1的lcs上加1才是第i个字符和第j个字符的lcs a和b比不同,那么a和ab的公共子序列还是a;假如当前的a和ab的lcs的值存放再dp[i][j]中,那么我们要取dp[i-1][j]、dp[i][j-1]、dp[i-1][j-1]中最大的值存放在dp[i][j]中,dp[i-1][j-1]肯定是3个值最小值,所以可以忽略
a和bcde比没有公共部分,所以一直是1
我们存放在最后一行最后一列就是这2个字符串的最长的公共子序列
推导出公式
word1[i]==word2[j]: dp[i+1][j+1]=dp[i][j]+1
word1[i]!=word2[j]: dp[i+1][j+1]=Math.max(dp[i][j+1],dp[i+1][j])
const lcsamples = {
string1: "abcde",
string2: "ace",
count: 3
}
const longestCommonSubsequence = (word1,word2) => {
var n=word1.length
var m=word2.text1
// 如果有一个空字符串,就返回0
if(n*m===0){
return 0
}
let dp=[(new Array(m+1)).fill(0)] //初始化第一行[[0, 0, 0, 0, 0, 0]]
for(let i=0;i<n;i++){ //两个for循环遍历
dp[i+1]=[0] //第一列
for(let j=0;j<m;j++){
// text1第i个字母和text2第j个字母相等了,在前面最优子结构上加1,就是现在的最长公共子序列,然后存在dp[i+1][j+1]的位置上
if(word1[i]==word2[j]){
dp[i+1][j+1]=dp[i][j]+1
}else{
dp[i+1][j+1]=Math.max(dp[i][j+1],dp[i+1][j])
}
}
}
return dp[dp.length-1][dp[0].length-1]
}
const count=longestCommonSubsequence(lcsamples.string1,lcsamples.string1)
console.log(count) //3
4.4 莱温斯坦距离问题
含义:莱文斯坦距离,又称Levenshtein距离,是编辑距离的一种,指两个字串之间,由一个转成另一个所需最少编辑操作次数
#🍅 参考文档:点击我
#🍅 案例:
假设有2个字符串,第一行从空到s过程是增,E到空是删,E到s是改,这是编辑的3种情况 下图黑框代表任意的字符串,前面不管是什么,我们先比较最后一个,如果最后一个字符串不相等,在去比较前面的最优子结构加1,相等不加1,按照最优的子结构不断的迭代下去
最后一行最后一个字符相等情况,说明没有进行改变
计算两个单词horse和ros之间的编辑距离D,容易发现把单词变短会让问题变的简单,很自然利用D[n][m],表示单词长度n和m的编辑距离
具体来说D[i][j],表示horse前i个字母和ros的前j个字母的编辑距离
按照动态规划,横坐标是HORSE,纵坐标是ROS进行展开,第一行第一列是0,空字符串到空字符串不需要操作,所以是0,空字符串跟HORSE相比,不相同所以一直加1,空字符串到ROS相比不相同所以一直加1,这就是初始化了,下图我们可以看作一个棋盘
推导出公式 如果两个子串的最后一个字母相同的情况下 D[i][j]=(D[i−1][j−1]
否则我们将考虑替换最后一个字符使得他们相同 D[i][j]=1+min(D[i−1][j],D[i][j−1],D[i−1][j−1])
// 莱文斯坦距离问题
const lsamples = [
{
string1: "horse",
string2: "ros",
count: 3
},
{
string1: "intention",
string2: "execution",
count: 5
}
];
//用一个二维数组d存储动态规划比较的值
const Levenshtein = (word1, word2) => {
var n=word1.length
var m=word2.length
let dp=[]
// 如果有一个空字符串,就返回非空字符串长度
if(n*m===0){
return n+m
}
for(let i=0;i<n+1;i++){
dp.push([])
for(let j=0;j<m+1;j++){
if(i===0){
// 初始化第一行
dp[i][j]=j
}else if(j===0){
// 初始化第一列
dp[i][j]=i
}else if(word1[i-1]===word2[j-1]){
dp[i][j]=d[i-1][j-1]
}else {
dp[i][j]=Math.min(dp[i-1][j-1],dp[i][j-1],dp[i-1][j])+1
}
}
}
console.log(d)
return d[n][m]
}
lsamples.forEach(({string1,string2,count})=>{
console.log(Levenshtein(string1,string2),count)
})
二、项目实战
一、认识同构和原理
.1 认识同构
#1.1.1 前后端分离的历史与发展
前后端不分离(JSP MVC)-> 前后端分离(AJAX)-> SPA(前端路由)-> SSR(前端后端渲染同构)
#1.1.2 同构渲染的出现
#问题和背景
- SEO问题
- 首屏白屏
- Nodejs
- mvvm ssr
#同构 CSR+SSR
- 同构:同一套js代码运行在不同的环境
- CSR:Client-Side Rendering
- SSR:Server-Side Rendering
- Node中间层 用数据渲染动态页面
#优点
- 首屏快
服务端内网接口数据渲染页面,无需等待js执行完毕
- SEO
首屏页面丰富,方便爬虫
- 保留SPA优点
只有首屏是服务端渲染,之后还是走前端路由,无需刷新切换内容
#缺点
- 门槛高 需要理解服务端渲染,兼容服务端和客户端差异
- 难以改造
旧SPA项目难以改造成服务端同构渲染
- 占用服务器资源
动态页面的生成在服务端
同构是唯一方案吗?
也可以尝试预渲染技术,适合每个用户都会返回相同的内容
1.2 同构的实现原理
#1.2.1 客户端渲染
简单页面客户端渲染
impot React from 'react'
import ReactDoM from 'react-dom'
ReactDoM.render(
<h1>Hello world<h1>,
document.getElementById('root')
)
SPA客户端渲染
加载HTML->js->请求数据->render
加载js到render的过程就是白屏时间
1.2.2 服务端渲染
HTML->js->render的过程在服务端完成
服务端不能访问dom,所以会返回创建好的字符串给浏览器;服务端渲染的优势是让用户更快的看见内容;由于服务端渲染是耗性能的,所以不能每个页面都去这么做,所以接下来我们看看同构渲染。
#1.2.3 SSR同构渲染原理
服务端渲染 + SPA = Server-side rendering
用户首次请求会向node服务器去发送请求,node服务收到请求后再去请求数据,做首屏的渲染,渲染以后返回给浏览器,用户就会看到首屏内容; 页面加载js给dom绑定事件,并接管了路由操作和其他操作,这时候就变成了我们熟悉的SPA;这时候我们即消除了SPA的白屏时间,这时候又可以在客户端无刷新的切换页面。在这个过程中得益于虚拟dom的mvvm框架提供的服务端渲染能力;在服务端虚拟dom转换的是字符串,在客户端转换的真实的dom。
#优点
- SEO:首屏HTML内容丰富
- 白屏时间:没有白屏时间,页面内容直接可见
- 无刷新路由:继承SAP的优点
- 同构:一套代码,两端运行
SSR同构难点
- 服务端开发:Node开发能力和掌握框架提供的服务端渲染技术
- 性能和监控:服务端渲染性能,服务端异常监控和处理
- 路由同构:如何同一套路由兼容Node环境和浏览器环境
- 请求和cookie:如何兼容两端请求,服务端缓存请求用户身份以及cookie的转发
- 状态数据共享:服务端store的如何共享给客户端
- 构建和部署:两端js的构建,Node服务的部署和客户端js的部署
1.3 React同构
#两端渲染方法概述
// client
import ReactDOM from 'react-dom'
// server
import ReactDOMServer from 'react-dom/server'
ReactDOM 提供客户端渲染方法,将组件渲染为真实DOM
ReactDOMServer 提供服务端渲染方法,这些方法将组件渲染成为静态标记
#1.3.1 React服务端渲染方法
基本API
// 参数都传入组件,返回string
ReactDOMServer.renderToStaticMarkup(element);
ReactDOMServer.renderToString(element)
- renderToStaticMarkup(适用于纯静态页面)
import ReactDOMServer from 'react-dom/server'
const App= ()=>(<h1>Hello</h1>)
const str = ReactDOMServer.renderToStaticMarkup(<App/>)
console.log(str)
// <h1>Hello<h1>
- 将React 元素渲染为HTML字符串
- 不会在React 内部创建的额外DOM属性,例如:data-reactroot
- renderToString(适用于可交互页面)
import ReactDOMServer from 'react-dom/server'
const App= ()=>(<h1>Hello</h1>)
const str = ReactDOMServer.renderToString(<App/>)
console.log(str)
// <h1 data-reactroot>Hello<h1>
- 将React 元素渲染为HTML字符串
- 并在React 内部创建的额外DOM属性data-reactroot
- 作用:告诉客户端复用页面提升性能,data-reactroot这个属性就是告诉客户端,服务端已经渲染过了,那么客户端直接可以复用这个组件,然后只绑定事件就可以了。
1.3.2 React客户端渲染方法
基本API
// 两个渲染方法
import ReactDOM from 'react-dom'
// 1
ReactDOM.render(
element,
container[,callback]
)
// 回调:在组件被渲染或更新之后被执行,react>15
// 2
ReactDOM.hydrate(
element,
container[,callback]
)
// 在ReactDOMServer渲染的容器中对HTML的内容进行hydrate操作。
// React 会尝试在已有标记上绑定事件监听器
- ReactDOM.render
import ReactDOM from 'react-dom'
const App= ()=>(<h1>Hello</h1>)
const root = doucment.getElementById('root')
ReactDOM.render(<App/>,root)
- ReactDOM.hydrate
ReactDOM.hydrate配合ssr首次渲染,如果用render会重复渲染,hydrate只用于首次渲染,为服务端渲染的html绑定事件。
import ReactDOM from 'react-dom'
const App= ()=>(<h1>Hello</h1>)
const root = doucment.getElementById('root')
ReactDOM.hydrate(<App/>,root)
React 两端渲染差异
suppressHydrationWarning
下面的案例就是在服务端渲染的时间,在客户端渲染的时候已经过去一段时间了,那怎样解决这个问题呢?
单个元素的文本两端渲染有差异,可以使用suppressHydrationWarning这个属性来解决,文本差异可以解决,属性差异不能保证解决。
// 组件
const App=()=>{
<h1 suppressHydrationWarning>
{new Date().getTime()}
</h1>
}
// 服务端渲染
ReactDOMServer.renderToString(<App/>)
// 首次客户端渲染
const root = document.getElementById('root')
ReactDOM.hydrate(<App/>,root)
两端渲染
当有大段文本差异,可以使用以下方法,componentDidMount这个钩子只会在客户端渲染的时候才会执行;在服务端的时候只会执行constructor;所以可以利用在componentDidMount钩子渲染差异内容。
class App extends React.PureComponent{
constructor(props){
super(props)
this.state={mounted:false}
}
componentDidMount(){
this.setState({mounted:true})
}
return (
<div>
hello:
{mounted && <Todo>}
</div>
)
}
总结: react同构渲染的过程:
- 服务端用ReactDOM.
renderToString渲染出html字符串 - 客户端用首次用ReactDOM.
hydrate为其绑定事件 - 下次再次更新dom就用ReactDOM.
render
二、实现一个同构demo
2.1 实现一个简单的同构渲染页面
#2.1.1 使用express启动Node服务器
const express = require('express')
const app = express()
app.get('/',(req,res)=>{
res.send('hello world')
})
app.listen(3001)
启动服务:nodemon ./server.js
2.1.2 在服务端使用React组件和API渲染
#1. 新建document.js 文件
import React from 'react'
const Document = () => {
return (
<html>
<head></head>
<body>
<h1>hello ssr</h1>
</body>
</html>
)
}
export default Document
#2. server.js
const express = require('express')
const ReactDOMserver=require('react-dom/server')
const Document = require('./documnet')
const app = express()
// renderToStaticMarkup 适用于纯静态页面
const html = ReactDOMserver.renderToStaticMarkup(<Document/>)
app.get('/',(req,res)=>{
res.send(html)
})
app.listen(3001)
运行server.js 文件发现报以下错误,这是因为不支持jsx语法
const html = ReactDOMserver.renderToStaticMarkup(<Document/>)
^
SyntaxError: Unexpected token '<'
at wrapSafe (internal/modules/cjs/loader.js:915:16)
at Module._compile (internal/modules/cjs/loader.js:963:27)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)
at Module.load (internal/modules/cjs/loader.js:863:32)
at Function.Module._load (internal/modules/cjs/loader.js:708:14)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)
at internal/main/run_main_module.js:17:47
解决Node jsx报错
- 安装babel yarn add @babel/core @babel/register @babel/preset-env @babel/preset-react -D
- babel有效范围,当前引入babel的文件无效
- 拆分router 把expres的router拆分独立文件,在router中执行React服务端渲染API
#3. 新建 serverRouter.js
const express = require('express')
import React from 'react'
import ReactDOMserver from 'react-dom/server'
import Document from './documnet'
const router = express.Router()
const html = ReactDOMserver.renderToStaticMarkup(<Document/>)
router.get('/',(req,res)=>{
res.send(html)
})
module.exports=router
#4. 改写 server.js
require('@babel/register')({
presets:['@babel/preset-env','@babel/preset-react']
})
const express = require('express')
const app = express()
const serverRouter = require('./serverRouter')
app.use('/',serverRouter)
app.listen(3001)
启动服务,打开http://localhost:3001/ 可以看见react渲染出来的内容hello ssr
虽然服务端返回了字符串,显示了内容,但是没有任何交互事件,也就是没有加载js
为什么在服务端不能绑定事件?
- 服务端没有dom,不能绑定事件
- 服务端返回的是字符串
- 服务端没有script
- 浏览器只加载了html,没有加载任何script去加载执行js
2.1.3 有交互事件的同构渲染
- 新建app.js
import React from 'react';
const App = () => {
return (<div onClick={() => alert('hello')}>
client
</div> );
}
export default App;
- 新建client.js
import React from 'react'
import ReactDOM from 'react-dom'
import App from './components/app'
// hydrate渲染,看见服务端已经渲染好的dom,就不会再次渲染
ReactDOM.hydrate(<App />, document.getElementById('root'))
- 我们用webpack构建我们的客户端渲染组件,打包成main.js
// 下载webpack、webpack-cli
const path = require('path')
const CopyPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin =require('html-webpack-plugin')
module.exports = {
entry: './src/client.js',
output: {
// 打包后的main.js放到build文件下
path: path.resolve(__dirname, 'build'),
filename: 'main.js'
},
module: {
rules: [
{
test: /.(js|jsx)$/,
use: 'babel-loader',
exclude: /node_modules/,
}
]
}
};
我们客户端渲染已经结束,接下来看看服务端怎么做
- document.js
import React from 'react'
const Document = ({ children }) => (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>simple-ssr</title>
</head>
<body>
// dangerouslySetInnerHTML 用于在dom中插入字符串,跟vue的v-html类似
<div id="root" dangerouslySetInnerHTML={{ __html: children }} />
</body>
// 加载客户端打包后的main.js
<script src="./main.js"></script>
</html>
)
export default Document
- serverRouter.js
const express = require('express')
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import Document from './components/Document'
import App from './components/App'
const router = express.Router();
// 渲染app.js ,服务端负责渲染,客户端负责绑定事件
/*
renderToString 主要用于需要交互的页面
renderToStaticMarkup 主要用于单纯的展示页面
*/
const appString = ReactDOMServer.renderToString(<App/>)
const html = ReactDOMServer.renderToStaticMarkup(<Document>
{appString}
</Document>)
router.get("/", function (req, res, next) {
res.status(200).send(html);
});
module.exports = router
nodemon ./src/server.js 启动服务,可以看见页面用了ssr渲染,又有了点击事件
2.2 实现SPA同构渲染
- react-router 基本的客户端路由实现
- 理解无状态组件
- 利用react-router 实现服务端路由
#2.2.1 客户端路由
react-router-dom:客户端、服务端都可以用
yarn add react-router-dom
App.js
import React from 'react'
import { Route, Switch, NavLink } from 'react-router-dom';
import routes from '../core/routes.js'
const App = () => {
return (
<div>
<ul>
<li>
<NavLink to="/">to Home</NavLink>
</li>
<li>
<NavLink to="/user">to User</NavLink>
</li>
</ul>
<Switch>
{routes.map(route => (
<Route key={route.path} exact={route.path === '/'} {...route} />
))}
</Switch>
</div>
)
}
export default App
routes.js
import Home from '../components/Home'
import User from '../components/User'
import NotFound from '../components/NotFound'
const routes = [
{
path: "/",
component: Home,
},
{
path: "/user",
component: User,
},
{
path: "",
component: NotFound,
},
];
export default routes
#2.2.2 服务端路由
StaticRouter
- 无状态组件
- 什么是无状态:它永远不会更改位置,服务端不会有用户点击切换路由,已经渲染的路由组件不会在更改
- location: string | object
- context: object
<StaticRouter
location={req.url}
context={context}
>
<App/>
</StaticRouter>
serverRouter.js
const express = require('express')
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import Document from './components/documnet'
import App from './components/app'
import { StaticRouter } from 'react-router-dom'
const router = express.Router()
router.get("*", function (req, res, next) {
// 第一次加载或者刷新页面都有服务端渲染,然后客户端接管路由跳转渲染页面
const appString = ReactDOMServer.renderToString(
<StaticRouter
location={req.url}
>
<App />
</StaticRouter>)
const html = ReactDOMServer.renderToStaticMarkup(<Document>
{appString}
</Document>)
console.log('html', html)
res.status(200).send(html);
});
module.exports = router
到此为止我们用react-router-dom实现了服务端路由,客户端路由的使用
2.3 何时请求异步数据
#2.3.1 客户端请求的时机和实现
推荐:componentDidmount、useEffect中发送请求
不推荐:componentWillmount、componentWillReceiveProps、componentWillUpdate
#为什么不在componentWillmount请求数据?
- 执行完componentWillmount后,会立即执行render方法,这时候接口数据还没有返回,提前请求并没有减少render方法的调用
- 过期警告componentWillmount、componentWillReceiveProps、componentWillUpdate,在新版本的react将移除这些生命周期; 在新的版本中将采用fiber架构:fiber架构将导致这些生命周期多次执行。
同步:是一次性渲染全部组件
异步:分片多次渲染,高优先级任务可以打断渲染(遇到点击,滚动这样的任务把它作为高优先级任务优先响应用户,浏览器空闲时间再次接着渲染,所以会导致上3个生命周期多次执行)
#2.3.2 服务端请求的时机和实现
服务端不会执行componentDidmount、useEffect,所以服务端要在渲染组件之前要拿到数据
axios发送请求(支持服务端和客户端)
yarn add axios
- 新建apiRouter.js
模拟一些接口,并返会一些数据
const express = require('express')
const router = express.Router();
router.get("/home", function (req, res, next) {
res.json({ title: 'Home', desc: '这是home页面' })
});
router.get("/user", function (req, res, next) {
res.json({ name: '张三', age: '21', id: '1' })
});
module.exports = router
- 改写server.js
require('@babel/register')({
presets:['@babel/preset-env','@babel/preset-react']
})
const express = require('express')
const app = express()
const serverRouter = require('./server/serverRouter')
const apiRouter = require('./server/apiRouter')
// api接口
+ app.use("/api/", apiRouter);
// 用于加载静态资源
app.use("/build", express.static('build'));
// 服务端渲染
app.use('/',serverRouter)
app.listen(3003)
- api.js
请求数据的封装
import axios from 'axios'
const req = axios.create({
baseURL:'http://localhost:3003/api',
});
req.interceptors.response.use(function (response) {
return response.data;
});
// 请求首页
export const fetchHome = () => req.get('/home')
// 请求用户信息
export const fetchUser = () => req.get('/user')
- user组件
import React,{useEffect} from 'react';
import { fetchUser } from '../core/api'
const User = ({staticContext}) => {
// staticContext 用于服务端渲染,staticContext是请求接口返回的值,具体可以看serverRouter.js
console.log('staticContext',staticContext)
// 客户端请求的时机,在服务端渲染的时候,useEffect并不会执行
useEffect(()=>{
fetchUser().then(data=>console.log('User data:',data))
},[])
return (
<main>
<h1>User</h1>
<button onClick={()=>{alert('user!')}}>click me</button>
</main>
)
}
User.getData = fetchUser
export default User
- serverRouter.js
const express = require('express')
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import Document from '../components/documnet'
import App from '../components/app'
import { StaticRouter,matchPath } from 'react-router-dom'
import routes from '../core/routes'
const router = express.Router()
router.get("*", async function (req, res, next) {
let data = {}
let getData = null
// 匹配当前路由,然后拿到当前要渲染组件的静态属性getData;getData就是请求的接口函数
routes.some(route => {
const match = matchPath(req.path, route);
if (match) {
getData = (route.component || {}).getData
}
return match
});
if (typeof getData === 'function') {
try {
data = await getData()
} catch (error) { }
}
const appString = ReactDOMServer.renderToString(
<StaticRouter
location={req.url}
// context传的值,在组件中staticContext可以获取到对应的值
context={data}
>
<App />
</StaticRouter>)
const html = ReactDOMServer.renderToStaticMarkup(<Document data={data}>
{appString}
</Document>)
res.status(200).send(html);
});
module.exports = router
#总结:
- 服务端渲染是在渲染组件之前请求数据,然后利用
context把值传到对应组件,这样就渲染出了有数据的组件。 - 客户端渲染可以在
componentDidmount、useEffect中请求数据进行客户端渲染。
2.4 客户端复用服务端数据
#服务端怎样向客户端传递数据
- 通过window全局变量
#利用window全局变量传递数据
- 改写serverRouter.js
const html = ReactDOMServer.renderToStaticMarkup(<Document data={data}>
{appString}
</Document>)
- 改写 doucment.js
我们可以将传递过来的数据转换成JSON字符串,赋值给window.__APP_DATA;然后放到script标签中,在客户端就会执行以下代码。
import React from 'react'
const Document = ({ children ,data}) => {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>simple-ssr</title>
</head>
<body>
<div id="root" dangerouslySetInnerHTML={{ __html: children }}></div>
+ <script
+ dangerouslySetInnerHTML={{
+ __html: `window.__APP_DATA__=${JSON.stringify(data)}`,
+ }}
+ />
<script src="/build/main.js"></script>
</body>
</html>
)
}
export default Document
- 改写home.js
import React, { useState } from 'react';
import {fetchHome} from '../core/api'
const Home = ({staticContext}) => {
console.log('staticContext',staticContext)
const getInitialData = () => {
// 服务端渲染拿到的数据
if (staticContext) {
return staticContext
}
// 客户端渲染,拿到服务端传递过来的数据
if (window.__APP_DATA__) {
return window.__APP_DATA__
}
return {}
}
const [data, setData] = useState(getInitialData())
return (
<main>
<div>{data.title}</div>
<div>{data.desc}</div>
</main>
)
}
Home.getData = fetchHome
export default Home
#客户端路由跳转数据获取
上面home.js的写法有一定问题?
- home.js客户端渲染从
window.__APP_DATA__上获取数据,如果home跳转到user,那么user.js数据从哪获取呢?不能从window.__APP_DATA__获取了,user.js需要不同的数据。 window.__APP_DATA__只能应用于首屏获取数据。
- 新建useData.js
useData.js 是封装的一个hooks,用于处理数据
import { useState, useEffect } from 'react'
const useData = (staticContext, initial, getData) => {
// 初始化数据
const getInitialData = () => {
// server render
if (staticContext) {
return staticContext
}
// client first render
if (window.__APP_DATA__) {
return window.__APP_DATA__
}
return initial
}
const [data, setData] = useState(getInitialData())
useEffect(() => {
// 客户端首次执行完以后,把window.__APP_DATA__清除掉;下个路由就可以请求数据了
if (window.__APP_DATA__) {
window.__APP_DATA__ = undefined
return
}
if (typeof getData === 'function') {
console.log('spa render')
getData().then(res => setData(res)).catch()
}
}, [])
return [data, setData]
}
export default useData
- 改写home.js
import React, { useState } from 'react';
import {fetchHome} from '../core/api'
import useData from '../core/useData'
const Home = ({staticContext}) => {
const [data, setData] = useData(staticContext, { title: '', desc: ''}, fetchHome)
return (
<main>
<h1>{data.title}</h1>
<p>{data.desc}</p>
</main>
)
}
Home.getData = fetchHome
export default Home
package.json
"scripts": {
"test": "echo "Error: no test specified" && exit 1",
"build": "rm -rf build && webpack --config ./webpack.config.js",
"start": "npm run build && nodemon ./src/server.js"
},