数据结构和算法、项目实战

340 阅读29分钟

一、数据结构和算法

一、复杂度

我们对一个算法进行评价,一般有 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斐波那契数列
  1. O(1)

算法所执行的时间不会随着 n 的大小变化,不管 n 是什么,我们都称为 O(1)。

const a = 1
  1. O(n)

下面的 for 循环,会执行 n 次,不管 n 是几,都称做 O(n)。

for (let i = 0; i < n; i++) {}
  1. O(n2)

2 层嵌套循环,内层循环会执行 n*n 次;也就是 n^2,随着 n 的增大,复杂度会随着 n 的增大,复杂度会随着平方级增加。

for (let i = 0; i < n; i++) {
  for (let j = 0; j < n; i++) {}
}
  1. 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))
  1. 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]

  1. 双指针+遍历

窗口的本质就是约定范围,如果想约定范围我们就应该想到双指针;我们可以定义一个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 位数; 在当前窗口找最大值时候,上一次比较过的数在本次窗口还要比较,那是不是重复比较了?

  1. 双端队列

每次我们滑动窗口,此时滑动窗口少了一个元素,又增加了一个元素;每次窗口移动时,只根据发生变化的元素对最大值进行更新,那么复杂度是不是降低了?

双端队列可以完美解决这个问题,双端队列的核心就是维护一个有效递减的队列,在遍历的时候尝试将每个元素推入队列中,要求必须递减,如果当前要推入的元素比队列的最后一个元素大,就移除最后一个元素,然后当前元素在跟队列的最后一个元素再次比较;比最后一个元素大就移除最后一个元素,最终当前元素小于最后元素为止,停下并将当前元素放入队列中。

需要注意的是最大元素不在当前窗口内要及时清除掉。

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
  1. 用迭代的方法实现。
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判圈算法,这个其实就是在算法的设计中会设计快慢两个指针;也可以假设乌龟和兔子进行赛跑,如果他们相遇的话就代表环存在的,还因为这个像跑步比赛的过程中,那个跑的快的肯定会在跑环的时候反超那个跑得慢的人。

#🍅 示例:

  1. 假设现在有两个指针,一个快指针和一个慢指针,然后快指针以2倍的速度推进,慢指针以1倍的速度推进;如果链表结构存在环形(就是循环依赖)的话,我们现在假设绿色是循环依赖的部分。 
  2. 标交点的部分就是2个指针相遇的地方,在顺时针跑的过程中,橘黄色就是快指针移动的距离,黄色部分就是慢指针移动的距离,可以看出快指针比慢指针多跑了一圈,我们设计一个算法的话,其实要判断 是否有圈出现,就是判断快慢指针是否有重叠,也就是最后指向了同一个对象,那其实就是他们之间出现了循环依赖的过程。 
  3. 下图我们用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)

动态规划的特点:

  1. 最优子结构:一个规模为n的问题可以转换成比他小的子问题来求解,最优解肯定是由最优的子解推导出来的
  2. 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响
  3. 子问题重叠性:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到(并非必要性质)

最优子结构就比方说 "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)
  1. 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
  1. 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 会尝试在已有标记上绑定事件监听器
  1. ReactDOM.render
import ReactDOM from 'react-dom'
const App= ()=>(<h1>Hello</h1>)
const root = doucment.getElementById('root')
ReactDOM.render(<App/>,root)
  1. 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同构渲染的过程:

  1. 服务端用ReactDOM.renderToString渲染出html字符串
  2. 客户端用首次用ReactDOM.hydrate为其绑定事件
  3. 下次再次更新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

为什么在服务端不能绑定事件?

  1. 服务端没有dom,不能绑定事件
  2. 服务端返回的是字符串
  3. 服务端没有script
  4. 浏览器只加载了html,没有加载任何script去加载执行js

2.1.3 有交互事件的同构渲染

源码地址

  1. 新建app.js
import React from 'react';

const App = () => {
    return (<div onClick={() => alert('hello')}>
        client
    </div> );
}
 
export default App;
  1. 新建client.js
import React from 'react'
import ReactDOM from 'react-dom'
import App from './components/app'

// hydrate渲染,看见服务端已经渲染好的dom,就不会再次渲染
ReactDOM.hydrate(<App />, document.getElementById('root'))
  1. 我们用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/,
      }
    ]
  }
};

我们客户端渲染已经结束,接下来看看服务端怎么做

  1. 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
  1. 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

spa.png

到此为止我们用react-router-dom实现了服务端路由,客户端路由的使用

2.3 何时请求异步数据

源码地址

#2.3.1 客户端请求的时机和实现

推荐:componentDidmount、useEffect中发送请求

不推荐:componentWillmount、componentWillReceiveProps、componentWillUpdate

#为什么不在componentWillmount请求数据?

  1. 执行完componentWillmount后,会立即执行render方法,这时候接口数据还没有返回,提前请求并没有减少render方法的调用
  2. 过期警告componentWillmount、componentWillReceiveProps、componentWillUpdate,在新版本的react将移除这些生命周期; 在新的版本中将采用fiber架构:fiber架构将导致这些生命周期多次执行。

fiber-line.png

同步:是一次性渲染全部组件

异步:分片多次渲染,高优先级任务可以打断渲染(遇到点击,滚动这样的任务把它作为高优先级任务优先响应用户,浏览器空闲时间再次接着渲染,所以会导致上3个生命周期多次执行)

#2.3.2 服务端请求的时机和实现

服务端不会执行componentDidmount、useEffect,所以服务端要在渲染组件之前要拿到数据

axios发送请求(支持服务端和客户端)

yarn add axios
  1. 新建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
  1. 改写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)
  1. 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')
  1. 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
  1. 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

#总结:

  1. 服务端渲染是在渲染组件之前请求数据,然后利用context把值传到对应组件,这样就渲染出了有数据的组件。
  2. 客户端渲染可以在componentDidmount、useEffect中请求数据进行客户端渲染。

2.4 客户端复用服务端数据

源码地址

#服务端怎样向客户端传递数据

  • 通过window全局变量

#利用window全局变量传递数据

  1. 改写serverRouter.js
  const html = ReactDOMServer.renderToStaticMarkup(<Document data={data}>
    {appString}
  </Document>)
  1. 改写 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
  1. 改写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__ 只能应用于首屏获取数据。
  1. 新建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
  1. 改写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"
  },