前端基础知识总结(三)

时倾

react 生命周期

React v16.0前的生命周期

初始化(initialization)阶段

此阶段只有一个生命周期方法:constructor。

constructor()

用来做一些组件的初始化工作,如定义this.state的初始内容。如果不初始化 state 或不进行方法绑定,则不需要为 React 组件实现构造函数。

为什么必须先调用super(props)?

因为子类自己的this对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用super方法,子类就得不到this对象。

class Checkbox extends React.Component {
  constructor(props) {
    // 🔴 这时候还不能使用this
    super(props);
    // ✅ 现在开始可以使用this
    console.log(props);      // ✅ {}
    console.log(this.props); // ✅ {}
    this.state = {}; 
  }
}

为什么super要传 props?

props 传进 super 是必要的,这使得基类 React.Component 可以初始化 this.props。然而,即便在调用 super() 时没有传入 props 参数,你依然能够在 render 和其它方法中访问 this.props。其实是 React 在调用你的构造函数之后,马上又给实例设置了一遍 props

// React 内部
class Component {
  constructor(props) {
    this.props = props; // 初始化 this.props
    // ...
  }
}

// React 内部
const instance = new Button(props);
instance.props = props; // 给实例设置 props

// Button类组件
class Button extends React.Component {
  constructor(props) {
    super(); // 😬 我们忘了传入 props
    console.log(props);      // ✅ {}
    console.log(this.props); // 😬 undefined
  }
}

挂载(Mounting)阶段

此阶段生命周期方法:componentWillMount => render => componentDidMount

1. componentWillMount():

在组件挂载到DOM前调用,且只会被调用一次。
每一个子组件render之前立即调用;
在此方法调用this.setState不会引起组件重新渲染,也可以把写在这边的内容提前到constructor()中。

2. render(): class 组件唯一必须实现的方法

render 被调用时,它会检查 this.propsthis.state 的变化并返回以下类型之一:

  • React 元素。通常通过 JSX 创建。例如,<div /> 会被 React 渲染为 DOM 节点,<MyComponent /> 会被 React 渲染为自定义组件,无论是 <div /> 还是 <MyComponent /> 均为 React 元素。
  • 数组或 fragments。 使得 render 方法可以返回多个元素。Fragments 允许你将子列表分组,而无需向 DOM 添加额外节点。
  • Portals。可以渲染子节点到不同的 DOM 子树中。Portal 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。
  • 字符串或数值类型。它们在 DOM 中会被渲染为文本节点。
  • 布尔类型或 null。什么都不渲染。

render() 函数应该为纯函数,这意味着在不修改组件 state 的情况下,每次调用时都返回相同的结果,并且它不会直接与浏览器交互。不能在里面执行this.setState,会有改变组件状态的副作用。

3. componentDidMount

会在组件挂载后(插入 DOM 树中)立即调用, 且只会被调用一次。依赖于 DOM 节点的初始化应该放在这里。
render之后并不会立即调用,而是所有的子组件都render完之后才会调用。

更新(update)阶段

此阶段生命周期方法:componentWillReceiveProps => shouldComponentUpdate => componentWillUpdate => render => componentDidUpdate。

react组件更新机制

setState引起的state更新或父组件重新render引起的props更新,更新后的state和props相对之前无论是否有变化,都将引起子组件的重新render。

1. 父组件重新render

  1. 直接重新渲染。每当父组件重新render导致的重传props,子组件将直接跟着重新渲染,无论props是否有变化。可通过shouldComponentUpdate方法优化。
  2. 更新state再渲染。在componentWillReceiveProps方法中,将props转换成自己的state,调用 this.setState() 将不会引起第二次渲染。

    因为componentWillReceiveProps中判断props是否变化了,若变化了,this.setState将引起state变化,从而引起render,此时就没必要再做第二次因重传props引起的render了,不然重复做一样的渲染了。

2. 自身setState

组件本身调用setState,无论state有没有变化。可通过shouldComponentUpdate方法优化。

生命周期分析

1. componentWillReceiveProps(nextProps)

此方法只调用于props引起的组件更新过程中,响应 Props 变化之后进行更新的唯一方式。
参数nextProps是父组件传给当前组件的新props。根据nextProps和this.props来判断重传的props是否改变,以及做相应的处理。

2. shouldComponentUpdate(nextProps, nextState)

根据 shouldComponentUpdate() 的返回值,判断 React 组件的输出是否受当前 state 或 props 更改的影响。默认行为是 state 每次发生变化组件都会重新渲染。

当 props 或 state 发生变化时,shouldComponentUpdate() 会在渲染执行之前被调用。返回值默认为 true。首次渲染或使用 forceUpdate() 时不会调用该方法。

此方法可以将 this.propsnextProps 以及 this.statenextState 进行比较,返回true时当前组件将继续执行更新过程,返回false则跳过更新,以此可用来减少组件的不必要渲染,优化组件性能。请注意,返回 false 并不会阻止子组件在 state 更改时重新渲染。

  • 如果在componentWillReceiveProps()中执行了this.setState,更新state,但在render前(如shouldComponentUpdate,componentWillUpdate),this.state依然指向更新前的state,不然nextState及当前组件的this.state的对比就一直是true了。
  • 应该考虑使用内置的 PureComponent 组件,而不是手动编写 shouldComponentUpdate()PureComponent 会对 props 和 state 进行浅层比较,并减少了跳过必要更新的可能性。

3. componentWillUpdate(nextProps, nextState)

此方法在调用render方法前执行,在这边可执行一些组件更新发生前的工作,一般较少用。

4. render

render同上

5. componentDidUpdate(prevProps, prevState)

此方法在组件更新后立即调用,可以操作组件更新的DOM。
prevProps和prevState这两个参数指的是组件更新前的props和state。

卸载阶段

此阶段只有一个生命周期方法:componentWillUnmount

componentWillUnmount

此方法在组件被卸载前调用,可以在这里执行一些清理工作,比如清楚组件中使用的定时器,清楚componentDidMount中手动创建的DOM元素等,以避免引起内存泄漏。
componentWillUnmount()不应调用 setState(),因为该组件将永远不会重新渲染。组件实例卸载后,将永远不会再挂载它。

React v16.0 后的生命周期

React v16.0刚推出的时候,增加了一个componentDidCatch生命周期函数,这只是一个增量式修改,完全不影响原有生命周期函数;

React v16.3,引入了两个新的生命周期:getDerivedStateFromProps,getSnapshotBeforeUpdate, 废弃掉componentWillMount、componentWillReceiveProps 以及 componentWillUpdate 三个周期(直到React 17前还可以使用,只是会有一个警告)。

为什么要更改生命周期?

生命周期函数的更改是因为 16.3 采用了 Fiber 架构,在新的 Fiber 架构中,组件的更新分为了两个阶段:

  1. render phase:这个阶段决定究竟哪些组件会被更新。
  2. commit phase:这个阶段是 React 开始执行更新(比如插入,移动,删除节点)。

commit phase 的执行很快,但是真实 DOM 的更新很慢,所以 React 在更新的时候会暂停再恢复组件的更新以免长时间的阻塞浏览器,这就意味着 render phase 可能会被执行多次(因为有可能被打断再重新执行)。

  • constructor
  • componentWillMount
  • componentWillReceiveProps
  • componentWillUpdate
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • render

这些生命周期都属于 render phase,render phase 可能被多次执行,所以要避免在 render phase 中的生命周期函数中引入副作用。在 16.3 之前的生命周期很容易引入副作用,所以 16.3 之后引入新的生命周期来限制开发者引入副作用。

getDerivedStateFromProps(nextProps, prevState)

React v16.3中,static getDerivedStateFromProps只在组件创建和由父组件引发的更新中调用。如果不是由父组件引发,那么getDerivedStateFromProps也不会被调用,如自身setState引发或者forceUpdate引发。

在React v16.4中改正了这一点,static getDerivedStateFromProps会在调用 render 方法之前调用,并且在初始挂载及后续更新时都会被调用。

特点:

  • 无副作用 。因为是处于 Fiber 的 render 阶段,所以有可能会被多次执行。所以 API 被设计为了静态函数,无法访问到实例的方法,也没有 ref 来操作 DOM,这就避免了实例方法带来副作用的可能性。但是依旧可以从 props 中获得方法触发副作用,所以在执行可能触发副作用的函数前要三思。
  • 只用来更新 state 。其这个生命周期唯一的作用就是从 nextProps 和 prevState 中衍生出一个新的 state。它应返回一个对象来更新 state,或者返回null来不更新任何内容。
  • getDerivedStateFromProps前面要加上static保留字,声明为静态方法,不然会被react忽略掉。
  • getDerivedStateFromProps里面的this为undefined。

    static静态方法只能Class来调用,而实例是不能调用,所以React Class组件中,静态方法getDerivedStateFromProps无权访问Class实例的this,即this为undefined。

getSnapshotBeforeUpdate()

getSnapshotBeforeUpdate() 只会调用一次,在最近一次渲染输出(提交到 DOM 节点)之前调用,,所以在这个生命周期能够获取这一次更新前的 DOM 的信息。此生命周期的任何返回值将作为 componentDidUpdate() 的第三个参数 “snapshot” 参数传递, 否则componentDidUpdate的第三个参数将为 undefined。应返回 snapshot 的值(或 null)。

错误处理

当渲染过程,生命周期,或子组件的构造函数中抛出错误时,会调用如下方法:

  • static getDerivedStateFromError():此生命周期会在后代组件抛出错误后被调用。 它将抛出的错误作为参数,并返回一个值以更新 state
  • componentDidCatch():此生命周期在后代组件抛出错误后被调用,它应该用于记录错误之类的情况。

    它接收两个参数:

    1. error —— 抛出的错误。
    2. info —— 带有 componentStack key 的对象

生命周期比较

16.0 前生命周期

image

16.0 后生命周期:
image

参考:
浅析 React v16.3 新生命周期函数

react 16做了哪些更新

  1. react作为一个ui库,将前端编程由传统的命令式编程转变为声明式编程,即所谓的数据驱动视图。如果直接更新真实dom,比如将生成的html直接采用innerHtml替换,会带来重绘重排之类的性能问题。为了尽量提高性能,React团队引入了虚拟dom,即采用js对象来描述dom树,通过对比前后两次的虚拟对象,来找到最小的dom操作(vdom diff),以此提高性能。
  2. 上面提到的reactDom diff,在react 16之前,这个过程我们称之为stack reconciler,它是一个递归的过程,在树很深的时候,单次diff时间过长会造成JS线程持续被占用,用户交互响应迟滞,页面渲染会出现明显的卡顿,这在现代前端是一个致命的问题。所以为了解决这种问题,react 团队对整个架构进行了调整,引入了fiber架构,将以前的stack reconciler替换为fiber reconciler。采用增量式渲染。引入了任务优先级(expiration)requestIdleCallback的循环调度算法,简单来说就是将以前的一根筋diff更新,首先拆分成两个阶段:reconciliationcommit;第一个reconciliation阶段是可打断的,被拆分成一个个的小任务(fiber),在每一侦的渲染空闲期做小任务diff。然后是commit阶段,这个阶段是不拆分且不能打断的,将diff节点的effectTag一口气更新到页面上。
  3. 由于reconciliation是可以被打断的,且存在任务优先级的问题,所以会导致commit前的一些生命周期函数多次被执行, 如componentWillMount、componentWillReceiveProps 和 componetWillUpdate,但react官方已声明,在React17中将会移除这三个生命周期函数。
  4. 由于每次唤起更新是从根节点(RootFiber)开始,为了更好的节点复用与性能优化。在react中始终存workInprogressTree(future vdom) 与 oldTree(current vdom)两个链表,两个链表相互引用。这无形中又解决了另一个问题,当workInprogressTree生成报错时,这时也不会导致页面渲染崩溃,而只是更新失败,页面仍然还在。

React hooks原理

在React 16前,函数式组件不能拥有状态管理?因为16以前只有类组件有对应的实例,而16以后Fiber 架构的出现,让每一个节点都拥有对应的实例,也就拥有了保存状态的能力。

Hooks的本质就是闭包两级链表

闭包是指有权访问另一个函数作用域中变量或方法的函数,创建闭包的方式就是在一个函数内创建闭包函数,通过闭包函数访问这个函数的局部变量, 利用闭包可以突破作用链域的特性,将函数内部的变量和方法传递到外部。

hooks 链表

一个组件包含的hooks 以链表的形式存储在fiber节点的memoizedState属性上,currentHook链表就是当前正在遍历的fiber节点的。nextCurrentHook 就是即将被添加到正在遍历fiber节点的hooks的新链表。

let currentHook: Hook | null = null;
let nextCurrentHook: Hook | null = null;

type Hooks = {
  memoizedState: any, // 指向当前渲染节点 Fiber
  baseState: any, // 初始化 initialState, 最新的state
  baseUpdate: Update<any> | null,
  // 当前需要更新的 Update ,每次更新完之后,会赋值上一个 update,方便 react 在渲染错误的边缘,数据回溯
  queue: UpdateQueue<any> | null,// 可以让state变化的,即update或dispach产生的update
  next: Hook | null, // link 到下一个 hooks
}

state

其实state不是hooks独有的,类操作的setState也存在。

memoizedState,cursor 是存在哪里的?如何和每个函数组件一一对应的?
react 会生成一棵组件树(或Fiber 单链表),树中每个节点对应了一个组件,hooks 的数据就作为组件的一个信息,存储在这些节点上,伴随组件一起出生,一起死亡。

为什么只能在函数最外层调用 Hook?
memoizedState 是按 hook定义的顺序来放置数据的,如果 hook 顺序变化,memoizedState 并不会感知到。

自定义的 Hook 是如何影响使用它的函数组件的?
共享同一个 memoizedState,共享同一个顺序。

“Capture Value” 特性是如何产生的?
每一次 ReRender 的时候,都是重新去执行函数组件了,对于之前已经执行过的函数组件,并不会做任何操作。

react setState 异步更新

setState 实现原理

setState 通过一个队列机制来实现 state 更新,当执行 setState() 时,会将需要更新的 state 浅合并后放入 状态队列,而不会立即更新 state,队列机制可以高效的批量更新 state。如果不通过setState,直接修改this.state 的值,则不会放入状态队列,当下一次调用 setState 对状态队列进行合并时,之前对 this.state 的修改将会被忽略,造成无法预知的错误。

setState()有的同步有的异步?

在React中, 如果是由React引发的事件处理(比如通过onClick引发的事件处理),调用setState不会同步更新this.state,除此之外的setState调用会同步执行this.state 。所谓“除此之外”,指的是绕过React通过addEventListener直接添加的事件处理函数,还有通过setTimeout/setInterval产生的异步调用。

原因: 在React的setState函数实现中,会根据一个变量isBatchingUpdates判断是直接更新this.state还是放到队列中回头再说,而isBatchingUpdates默认是false,也就表示setState会同步更新this.state,但是,有一个函数batchedUpdates,这个函数会把isBatchingUpdates修改为true,而当React在调用事件处理函数之前就会调用这个batchedUpdates,造成的后果,就是由React控制的事件处理过程setState不会同步更新this.state

setState的“异步”并不是说内部由异步代码实现,其实本身执行的过程和代码都是同步的,只是合成事件和钩子函数的调用顺序在更新之前,导致在合成事件和钩子函数中没法立马拿到更新后的值,形式了所谓的“异步”,可以通过第二个参数 setState(partialState, callback) 中的callback拿到更新后的结果。

调用风险

当调用 setState 时,实际上是会执行 enqueueSetState 方法,并会对 partialState_pendingStateQueue 队列进行合并操作,最终通过 enqueueUpdate 执行 state 更新。

performUpdateIfNecessary 获取 _pendingElement _pendingStateQueue_pendingForceUpdate,并调用 reaciveComponentupdateComponent 来进行组件更新。

但,如果在 shouldComponentUpdatecomponentWillUpdate 方法里调用 this.setState 方法,就会造成崩溃。 这是因为在 shouldComponentUpdatecomponentWillUpdate 方法里调用 this.setState 时,this._pendingStateQueue!=null,则 performUpdateIfNecessary 方法就会调用 updateComponent 方法进行组件更新,而 updateComponent 方法又会调用 shouldComponentUpdatecomponentWillUpdate 方法,因此造成循环调用,使得浏览器内存占满后崩溃。

React Fiber

掉帧:在页面元素很多,且需要频繁刷新的场景下,React 15 会出现掉帧的现象,其根本原因,是大量的同步计算任务阻塞了浏览器的 UI 渲染。默认情况下,JS 运算、页面布局和页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系。如果 JS 运算持续占用主线程,页面就没法得到及时的更新。当我们调用setState更新页面的时候,React 会遍历应用的所有节点,计算出差异,然后再更新 UI,整个过程不能被打断。如果页面元素很多,整个过程占用的时机就可能超过 16 毫秒,就容易出现掉帧的现象。

如何解决主线程长时间被 JS 运算?将JS运算切割为多个步骤,分批完成。在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间进行页面的渲染。等浏览器忙完之后,再继续之前未完成的任务。

React 15 及以下版本通过递归的方式进行渲染,使用的是 JS 引擎自身的函数调用栈,它会一直执行到栈空为止。而Fiber实现了自己的组件调用栈,它以链表的形式遍历组件树,可以灵活的暂停、继续和丢弃执行的任务。实现方式是使用了浏览器的requestIdleCallback

window.requestIdleCallback()会在浏览器空闲时期依次调用函数,这就可以让开发者在主事件循环中执行后台或低优先级的任务,而且不会对像动画和用户交互这些延迟触发但关键的事件产生影响。函数一般会按先进先调用的顺序执行,除非函数在浏览器调用它之前就到了它的超时时间。

React 框架内部的运作可以分为 3 层:

  • Virtual DOM 层,描述页面长什么样。
  • Reconciler 层,负责调用组件生命周期方法,进行 Diff 运算等。
  • Renderer 层,根据不同的平台,渲染出相应的页面,比较常见的是 ReactDOM 和 ReactNative。

Fiber 表征reconciliation阶段所能拆分的最小工作单元,其实指的是一种链表树,它可以用一个纯 JS 对象来表示:

const fiber = {
  stateNode: {},    // 节点实例
  child: {},        // 子节点
  sibling: {},      // 兄弟节点
  return: {},       // 表示处理完成后返回结果所要合并的目标,通常指向父节点
};

Reconciler区别

  • 以前的 Reconciler 被命名为Stack Reconciler。Stack Reconciler 运作的过程是不能被打断的,必须一条道走到黑;
  • Fiber Reconciler 每执行一段时间,都会将控制权交回给浏览器,可以分段执行;

Stack ReconcilerFiber Reconciler,源码层面其实就是干了一件递归改循环的事情。

scheduling(调度)

scheduling(调度)是fiber reconciliation的一个过程,主要是进行任务分配,达到分段执行。任务的优先级有六种:

  • synchronous,与之前的Stack Reconciler操作一样,同步执行
  • task,在next tick之前执行
  • animation,下一帧之前执行
  • high,在不久的将来立即执行
  • low,稍微延迟执行也没关系
  • offscreen,下一次render时或scroll时才执行

优先级高的任务(如键盘输入)可以打断优先级低的任务(如Diff)的执行,从而更快的生效。

Fiber Reconciler 在执行过程中,会分为 2 个阶段:

  • 阶段一,生成 Fiber 树,得出需要更新的节点信息。这一步是一个渐进的过程,可以被打断。
  • 阶段二,将需要更新的节点一次过批量更新,这个过程不能被打断。

阶段一可被打断的特性,让优先级更高的任务先执行,从框架层面大大降低了页面掉帧的概率。

参考:
React Fiber 原理介绍
React Fiber

HOC 与render props区别

Render Props: 把将要包裹的组件作为props属性传入,然后容器组件调用这个属性,并向其传参。

实现方式:

1.通过props.children(props),props.children返回的是UI元素。<RenderProps> JSX 标签中的所有内容都会作为一个 children prop 传递给 RenderProps组件。因为 RenderProps{props.children} 渲染在一个 <div> 中,被传递的这些子组件最终都会出现在输出结果中。

// 定义
const RenderProps = props => <div>
   {props.children(props)}
</div>

// 调用
<RenderProps>
    {() => <>Hello RenderProps</>}
</RenderProps>

2.通过props中的任何函数, 自行定义传入内容

// 定义
const LoginForm = props => {
  const flag = false;
  const allProps = { flag, ...props };
  
  if (flag) {
    return <>{props.login(allProps)}</>
  } else {
    return <>{props.notLogin(allProps)}</>
  }
}

// 调用
<LoginForm
  login={() => <h1>LOGIN</h1>}
  noLogin={() => <h1>NOT LOGIN</h1>}
/>
优点:
1、支持ES6
2、不用担心props命名问题,在render函数中只取需要的state
3、不会产生无用的组件加深层级
4、render props模式的构建都是动态的,所有的改变都在render中触发,可以更好的利用组件内的生命周期。

HOC: 接受一个组件作为参数,返回一个新的组件的函数。

class Home extends React.Component {
  // UI
}

export default Connect()(Home);

高阶组件由于每次都会返回一个新的组件,对于react来说,这是不利于diff和状态复用的,所以高阶组件的包装不能在render 方法中进行,而只能像上面那样在组件声明时包裹,这样也就不利于动态传参。

优点:
1、支持ES6
2、复用性强,HOC为纯函数且返回值为组件,可以多层嵌套
3、支持传入多个参数,增强了适用范围
缺点:
1、当多个HOC一起使用时,无法直接判断子组件的props是哪个HOC负责传递的
2、多个组件嵌套,容易产生同样名称的props
3、HOC可能会产生许多无用的组件,加深了组件的层级

总的来说,render props其实和高阶组件类似,就是在puru component上增加state,响应react的生命周期。

React 通信

react的数据流是单向的,最常见的就是通过props由父组件向子组件传值。

  • 父向子通信: 传入props
  • 子向父通信:父组件向子组件传一个函数,然后通过这个函数的回调,拿到子组件传过来的值
  • 父向孙通信:利用context传值。React.createContext()
  • 兄弟间通信:

​ 1、找一个相同的父组件,既可以用props传递数据,也可以用context的方式来传递数据。
​ 2、用一些全局机制去实现通信,比如redux等
​ 3、发布订阅模式

react合成事件

React 合成事件(SyntheticEvent)是 React 模拟原生 DOM 事件所有能力的一个事件对象,即浏览器原生事件的跨浏览器包装器。

为什么要使用合成事件?

  1. 进行浏览器兼容,实现更好的跨平台
    React 采用的是顶层事件代理机制,能够保证冒泡一致性,可以跨浏览器执行。React 提供的合成事件用来抹平不同浏览器事件对象之间的差异,将不同平台事件模拟合成事件。
  2. 避免垃圾回收
    事件对象可能会被频繁创建和回收,因此 React 引入事件池,在事件池中获取或释放事件对象。即 React 事件对象不会被释放掉,而是存放进一个数组中,当事件触发,就从这个数组中弹出,避免频繁地去创建和销毁(垃圾回收)
  3. 方便事件统一管理和事务机制

实现原理
在 React 中,“合成事件”会以事件委托方式绑定在 document 对象上,并在组件卸载(unmount)阶段自动销毁绑定的事件。

合成事件和原生事件
当真实 DOM 元素触发事件,会冒泡到 document 对象后,再处理 React 事件;所以会先执行原生事件,然后处理 React 事件;最后真正执行 document 上挂载的事件。
合成事件和原生事件最好不要混用。 原生事件中如果执行了stopPropagation方法,则会导致其他React事件失效。因为所有元素的事件将无法冒泡到document上,所有的 React 事件都将无法被注册。

合成事件的事件池
合成事件对象池,是 React 事件系统提供的一种性能优化方式合成事件对象在事件池统一管理不同类型的合成事件具有不同的事件池

react 虚拟dom

什么是虚拟dom?

在 React 中,render 执行的结果得到的并不是真正的 DOM 节点,而是轻量级的 JavaScript 对象,我们称之为 virtual DOM。它通过JS的Object对象模拟DOM中的节点,然后再通过特定的render方法将其渲染成真实的DOM节点。

虚拟 DOM 是 React 的一大亮点,具有batching(批处理) 和高效的 Diff 算法。batching 把所有的 DOM 操作搜集起来,一次性提交给真实的 DOM。diff 算法时间复杂度也从标准的的 Diff 算法的 O(n^3) 降到了 O(n)。

batching(批处理)
主要思想是,无论setState您在React事件处理程序同步生命周期方法中进行多少次调用,它都将被批处理成一个更新, 最终只有一次重新渲染。

虚拟 DOM 与 原生 DOM

如果没有 Virtual DOM,就需要直接操作原生 DOM。在一个大型列表所有数据都变了的情况下,直接重置 innerHTML还算合理,但是,只有一行数据发生变化时,它也需要重置整个 innerHTML,这就造成了大量浪费。

innerHTML 和 Virtual DOM 的重绘性能消耗:
innerHTML: render html string + 重新创建所有 DOM 元素
Virtual DOM: render Virtual DOM + diff + 必要的 DOM 更新

Virtual DOM render + diff 显然比渲染 html 字符串要慢,但是它依然是纯 js 层面的计算,比起后面的 DOM 操作来说,依然便宜了太多。innerHTML 的总计算量不管是 js 计算还是 DOM 操作都是和整个界面的大小相关,但 Virtual DOM 的计算量只有 js 计算和界面大小相关,DOM 操作是和数据的变动量相关。

Real DOMVirtual DOM
1. 更新缓慢。1. 更新更快。
2. 可以直接更新 HTML。2. 无法直接更新 HTML。
3. 如果元素更新,则创建新DOM。3. 如果元素更新,则更新 JSX 。
4. DOM操作代价很高。4. DOM 操作非常简单。
5. 消耗的内存较多。5. 很少的内存消耗。

虚拟 DOM 与 MVVM

相比起 React,其他 MVVM 系框架比如 Angular, Knockout , Vue ,Avalon 采用的都是数据绑定。通过 Directive/Binding 对象,观察数据变化并保留对实际 DOM 元素的引用,当有数据变化时进行对应的操作。MVVM 的变化检查是数据层面的,而 React 的检查是 DOM 结构层面的。

MVVM 的性能也根据变动检测的实现原理有所不同:Angular 依赖于脏检查;Knockout/Vue/Avalon 采用了依赖收集。

  • 脏检查:scope digest(watcher count) ) + 必要 DOM 更新
  • 依赖收集:重新收集依赖(data change) ) + 必要 DOM 更新

Angular 最不效率的地方在于任何小变动都有的和 watcher 数量相关的性能代价,当所有数据都变了的时候,Angular更有效。依赖收集在初始化和数据变化的时候都需要重新收集依赖,这个代价在小量更新的时候几乎可以忽略,但在数据量庞大的时候也会产生一定的消耗。

性能比较

在比较性能的时候,要分清楚初始渲染、小量数据更新、大量数据更新这些不同的场合。Virtual DOM、脏检查 MVVM、数据收集 MVVM 在不同场合各有不同的表现和不同的优化需求。Virtual DOM 为了提升小量数据更新时的性能,也需要针对性的优化,比如 shouldComponentUpdate 或是 immutable data。

  • 初始渲染:Virtual DOM > 脏检查 >= 依赖收集
  • 小量数据更新:依赖收集 >> Virtual DOM + 优化 > 脏检查(无法优化)> Virtual DOM 无优化
  • 大量数据更新:脏检查 + 优化 >= 依赖收集 + 优化 > Virtual DOM(无法/无需优化)>> MVVM 无优化

diff 算法

传统 diff 算法通过循环递归对节点进行依次对比,算法复杂度达到 O(n^3),其中 n 是树中节点的总数。O(n^3) 意味着如果要展示1000个节点,就要依次执行上十亿次的比较, 这是无法满足现代前端性能要求的。

diff 算法主要包括几个步骤:

  • 用 JS 对象的方式来表示 DOM 树的结构,然后根据这个对象构建出真实的 DOM 树,插到文档中。
  • 当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树的差异, 最后把所记录的差异应用到所构建的真正的DOM树上,视图更新。

diff 策略

React 通过制定大胆的diff策略,将diff算法复杂度从 O(n^3) 转换成 O(n)

  • React 通过分层求异的策略,对 tree diff 进行算法优化;
  • React 通过相同类生成相似树形结构,不同类生成不同树形结构的策略,对 component diff 进行算法优化;
  • React 通过设置唯一 key的策略,对 element diff 进行算法优化;

tree diff(层级比较)

React 对树进行分层比较,两棵树只会对同一层次的节点进行比较。
当发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会进行进一步的比较。这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。
当出现节点跨层级移动时,并不会出现移动操作,而是以该节点为根节点的树被重新创建,这是一种影响 React 性能的操作,因此 React 官方建议不要进行 DOM 节点跨层级的操作

  • 先进行树结构的层级比较,对同一个父节点下的所有子节点进行比较;
  • 接着看节点是什么类型的,是组件就做 Component Diff;
  • 如果节点是标签或者元素,就做 Element Diff;
注意:在开发组件时,保持稳定的 DOM 结构会有助于性能的提升。例如,可以通过 CSS 隐藏或显示节点,而不是真的移除或添加 DOM 节点。

component diff(组件比较)

  • 如果是同一类型的组件,按照原策略继续比较 virtual DOM tree。
  • 如果不是,则将该组件判断为 dirty component,替换整个组件下的所有子节点。举个例子,当一个元素从 <Article> 变成 <Comment>会触发一个完整的重建流程。

对于同一类型的组件,有可能其 Virtual DOM 没有任何变化,如果能够确切的知道这点那可以节省大量的 diff 运算时间。因此 React 允许用户通过 shouldComponentUpdate() 来判断该组件是否需要进行 diff。

对于两个不同类型但结构相似的组件,不会比较二者的结构,而且替换整个组件的所有内容。不同类型的 component 是很少存在相似 DOM tree 的机会,因此这种极端因素很难在实现开发过程中造成重大影响的。

element diff (元素比较)

当节点处于同一层级时,React diff 提供了三种节点操作,分别为:INSERT_MARKUP(插入)、MOVE_EXISTING(移动)和 REMOVE_NODE(删除)。

  • INSERT_MARKUP,新的 component 类型不在老集合里, 即是全新的节点,需要对新节点执行插入操作。
  • MOVE_EXISTING,在老集合有新 component 类型,且 element 是可更新的类型,这种情况下需要做移动操作,可以复用以前的 DOM 节点。
  • REMOVE_NODE,老 component 类型,在新集合里也有,但对应的 element 不同则不能直接复用和更新,需要执行删除操作,或者老 component 不在新集合里的,也需要执行删除操作。
<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

React 并不会意识到应该保留<li>Duke</li><li>Villanova</li>,而是会重建每一个子元素,不会进行移动 DOM 操作。

key 优化

为了解决上述问题,React 引入了 key 属性, 对同一层级的同组子节点,添加唯一 key 进行区分。

当子元素拥有 key 时,React 使用 key 来匹配原有树上的子元素以及最新树上的子元素。如果有相同的节点,无需进行节点删除和创建,只需要将老集合中节点的位置进行移动,更新为新集合中节点的位置。

  • 在开发过程中,尽量减少类似将最后一个节点移动到列表首部的操作,当节点数量过大或更新操作过于频繁时,在一定程度上会影响 React 的渲染性能。
  • key 不需要全局唯一,但在列表中需要保持唯一。
  • Key 应该具有稳定,可预测,以及列表内唯一的特质。不稳定的 key(比如通过 Math.random() 生成的)会导致许多组件实例和 DOM 节点被不必要地重新创建,这可能导致性能下降和子组件中的状态丢失。

react与vue区别

1. 监听数据变化的实现原理不同

Vue通过 getter/setter以及一些函数的劫持,能精确知道数据变化。
React默认是通过比较引用的方式(diff)进行的,如果不优化可能导致大量不必要的VDOM的重新渲染。

2. 数据流不同

Vue1.0中可以实现两种双向绑定:父子组件之间props可以双向绑定;组件与DOM之间可以通过v-model双向绑定。
Vue2.x中父子组件之间不能双向绑定了(但是提供了一个语法糖自动帮你通过事件的方式修改)。
React一直不支持双向绑定,提倡的是单向数据流,称之为onChange/setState()模式。

3. HoC和mixins

Vue组合不同功能的方式是通过mixin,Vue中组件是一个被包装的函数,并不简单的就是我们定义组件的时候传入的对象或者函数。
React组合不同功能的方式是通过HoC(高阶组件)。

4. 模板渲染方式的不同

模板的语法不同,React是通过JSX渲染模板, Vue是通过一种拓展的HTML语法进行渲染。
模板的原理不同,React通过原生JS实现模板中的常见语法,比如插值,条件,循环等。而Vue是在和组件JS代码分离的单独的模板中,通过指令来实现的,比如 v-if 。

举个例子,说明React的好处:react中render函数是支持闭包特性的,所以我们import的组件在render中可以直接调用。但是在Vue中,由于模板中使用的数据都必须挂在 this 上进行一次中转,所以我们import 一个组件完了之后,还需要在 components 中再声明下。

5. 渲染过程不同

Vue可以更快地计算出Virtual DOM的差异,这是由于它会跟踪每一个组件的依赖关系,不需要重新渲染整个组件树。
React当状态被改变时,全部子组件都会重新渲染。通过shouldComponentUpdate这个生命周期方法可以进行控制,但Vue将此视为默认的优化。

6. 框架本质不同

Vue本质是MVVM框架,由MVC发展而来;
React是前端组件化框架,由后端组件化发展而来。

性能优化

1. 静态资源使用 CDN

CDN是一组分布在多个不同地理位置的 Web 服务器。当服务器离用户越远时,延迟越高。

2. 无阻塞

头部内联的样式和脚本会阻塞页面的渲染,样式放在头部并使用link方式引入,脚本放在尾部并使用异步方式加载

3. 压缩文件

压缩文件可以减少文件下载时间。

  1. 在 webpack 可以使用如下插件进行压缩:
    JavaScript:UglifyPlugin
    CSS :MiniCssExtractPlugin
    HTML:HtmlWebpackPlugin
  2. 使用 gzip 压缩。通过向 HTTP 请求头中的 Accept-Encoding 头添加 gzip 标识来开启这一功能。

4. 图片优化

  1. 图片懒加载
  2. 响应式图片:浏览器根据屏幕大小自动加载合适的图片。
  3. 降低图片质量:方法有两种,一是通过 webpack 插件 image-webpack-loader,二是通过在线网站进行压缩。

5. 减少重绘重排

  • 降低 CSS 选择器的复杂性
  • 使用 transform 和 opacity 属性更改来实现动画
  • 用 JavaScript 修改样式时,最好不要直接写样式,而是替换 class 来改变样式。
  • 如果要对 DOM 元素执行一系列操作,可以将 DOM 元素脱离文档流,修改完成后,再将它带回文档。推荐使用隐藏元素(display:none)或文档碎片(DocumentFragement),都能很好的实现这个方案。

6. 使用 requestAnimationFrame 来实现视觉变化

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行

7. webpack 打包, 添加文件缓存

index.html 设置成 no-cache,这样每次请求的时候都会比对一下 index.html 文件有没变化,如果没变化就使用缓存,有变化就使用新的 index.html 文件。
其他所有文件一律使用长缓存,例如设置成缓存一年 maxAge: 1000 * 60 * 60 * 24 * 365
前端代码使用 webpack 打包,根据文件内容生成对应的文件名,每次重新打包时只有内容发生了变化,文件名才会发生变化。

  • max-age: 设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。在这个时间前,浏览器读取文件不会发出新请求,而是直接使用缓存。
  • 指定 no-cache 表示客户端可以缓存资源,每次使用缓存资源前都必须重新验证其有效性

输入url后发生了什么

  1. DNS域名解析;
  2. 建立TCP连接(三次握手);
  3. 发送HTTP请求;
  4. 服务器处理请求;
  5. 返回响应结果;
  6. 关闭TCP连接(四次握手);
  7. 浏览器解析HTML;
  8. 浏览器布局渲染;

1. DNS域名解析: 拿到服务器ip

客户端收到你输入的域名地址后,它首先去找本地的hosts文件,检查在该文件中是否有相应的域名、IP对应关系,如果有,则向其IP地址发送请求,如果没有,再去找DNS服务器。

2. 建立TCP链接: 客户端链接服务器

TCP提供了一种可靠、面向连接、字节流、传输层的服务。对于客户端与服务器的TCP链接,必然要说的就是『三次握手』。“3次握手”的作用就是双方都能明确自己和对方的收、发能力是正常的

客户端发送一个带有SYN标志的数据包给服务端,服务端收到后,回传一个带有SYN/ACK标志的数据包以示传达确认信息,最后客户端再回传一个带ACK标志的数据包,代表握手结束,连接成功。

SYN —— 用于初如化一个连接的序列号。
ACK —— 确认,使得确认号有效。
RST —— 重置连接。
FIN —— 该报文段的发送方已经结束向对方发送数据。

客户端:“你好,在家不。” -- SYN
服务端:“在的,你来吧。” -- SYN + ACK
客户端:“好嘞。” -- ACK

3. 发送HTTP请求

4. 服务器处理请求

5. 返回响应结果

6. 关闭TCP连接(需要4次握手)

为了避免服务器与客户端双方的资源占用和损耗,当双方没有请求或响应传递时,任意一方都可以发起关闭请求。

关闭连接时,服务器收到对方的FIN报文时,仅仅表示客户端不再发送数据了但是还能接收数据,而服务器也未必全部数据都发送给客户端,所以服务器可以立即关闭,也可以发送一些数据给对方后,再发送FIN报文给对方来表示同意现在关闭连接,因此,己方ACK和FIN一般都会分开发送,从而导致多了一次。

客户端:“兄弟,我这边没数据要传了,咱关闭连接吧。” -- FIN + seq
服务端:“收到,我看看我这边有木有数据了。” -- ACK + seq + ack
服务端:“兄弟,我这边也没数据要传你了,咱可以关闭连接了。” - FIN + ACK + seq + ack
客户端:“好嘞。” -- ACK + seq + ack

7. 浏览器解析HTML

浏览器需要加载解析的不仅仅是HTML,还包括CSS、JS,以及还要加载图片、视频等其他媒体资源。

浏览器通过解析HTML,生成DOM树,解析CSS,生成CSSOM树,然后通过DOM树和CSSPOM树生成渲染树。渲染树与DOM树不同,渲染树中并没有head、display为none等不必显示的节点。

浏览器的解析过程并非是串连进行的,比如在解析CSS的同时,可以继续加载解析HTML,但在解析执行JS脚本时,会停止解析后续HTML,会出现阻塞问题。

8. 浏览器渲染页面

根据渲染树布局,计算CSS样式,即每个节点在页面中的大小和位置等几何信息。HTML默认是流式布局的,CSS和js会打破这种布局,改变DOM的外观样式以及大小和位置。最后浏览器绘制各个节点,将页面展示给用户。

replaint:屏幕的一部分重画,不影响整体布局,比如某个CSS的背景色变了,但元素的几何尺寸和位置不变。

reflow: 意味着元素的几何尺寸变了,需要重新计算渲染树。

参考:
细说浏览器输入URL后发生了什么
浏览器输入 URL 后发生了什么?

前端路由

什么是路由

路由是用来跟后端服务器进行交互的一种方式,通过不同的路径请求不同的资源。
路由这概念最开始是在后端出现, 在前后端不分离的时期, 由后端来控制路由, 服务器接收客户端的请求,解析对应的url路径, 并返回对应的页面/资源。

前端路由

Ajax,全称 Asynchronous JavaScript And XML,是浏览器用来实现异步加载的一种技术方案。

在Ajax没有出现时期,大多数的网页都是通过直接返回 HTML,用户的每次更新操作都需要重新刷新页面,及其影响交互体验。为了解决这个问题,提出了Ajax(异步加载方案), 有了 Ajax 后,用户交互就不用每次都刷新页面。后来出现SPA单页应用。

SPA 中用户的交互是通过 JS 改变 HTML 内容来实现的,页面本身的 url 并没有变化,这导致了两个问题:

  • SPA 无法记住用户的操作记录,无论是刷新、前进还是后退,都无法展示用户真实的期望内容。
  • SPA 中虽然由于业务的不同会有多种页面展示形式,但只有一个 url,对 SEO 不友好,不方便搜索引擎进行收录。

前端路由就是为了解决上述问题而出现的。

前端路由的实现方式

前端路由的实现实际上是检测 url 的变化,截获 url 地址,解析来匹配路由规则。有下面两种实现方式:

1. Hash模式

hash 就是指 url 后的 # 号以及后面的字符。 #后面 hash 值的变化,并不会导致浏览器向服务器发出请求,浏览器不发请求,也就不会刷新页面。

hash 的改变会触发 hashchange 事件,可以用onhashchange事件来监听hash值的改变。

// 监听hash变化,点击浏览器的前进后退会触发
window.onhashchange = function() { ... }

window.addEventListener('hashchange', function(event) { ...}, false);

2.History 模式

在 HTML5 之前,浏览器就已经有了 history 对象。但在早期的 history 中只能用于多页面的跳转:

history.go(-1);       // 后退一页
history.go(2);        // 前进两页
history.forward();    // 前进一页
history.back();       // 后退一页

在 HTML5 的规范中,history 新增了几个 API:

history.pushState();   // 向当前浏览器会话的历史堆栈中添加一个状态
history.replaceState();// 修改了当前的历史记录项(不是新建一个)
history.state          // 返回一个表示历史堆栈顶部的状态的值

由于 history.pushState() 和 history.replaceState() 可以改变 url 同时,不会刷新页面,所以在 HTML5 中的 histroy 具备了实现前端路由的能力。
window对象提供了onpopstate事件来监听历史栈的改变,一旦历史栈信息发生改变, 便会触发该事件。

调用history.pushState()或history.replaceState()不会触发popstate事件。只有在做出浏览器动作时,才会触发该事件,例如执行history.back()history.forward()后触发 window.onpopstate事件。
// 历史栈改变
window.onpopstate = function() { ... }

注意: pushState() 不会造成 hashchange 事件调用, 即使新的URL和之前的URL只是锚的数据不同。

两种模式对比

对比HashHistory
路径#, 路径丑正常路径
兼容性>=ie8>=ie10
实用性直接使用,无需服务端配合处理。需服务端配合处理
命名空间同一document同源
锚点导致锚点功能失效锚点功能正常

前端路由实践

vue-router/react-router 都是基于前端路由的原理实现的~
react-router常用的 history 有三种形式:

  • browserHistory: 使用浏览器中的History API 用于处理 URL。history 在 DOM 上的实现,用于支持 HTML5 history API 的浏览器。
  • hashHistory: 使用 URL 中的 hash(#)部分去创建路由。history 在 DOM 上的实现,用于旧版浏览器。
  • createMemoryHistory: 不会在地址栏被操作或读取,history 在内存上的实现,用于测试或非 DOM 环境(例如 React Native)。

Babel Plugin与preset区别

Babel是代码转换器,比如将ES6转成ES5,或者将JSX转成JS等。借助Babel,开发者可以提前用上新的JS特性。

原始代码 --> [Babel Plugin] --> 转换后的代码

Plugin

实现Babel代码转换功能的核心,就是Babel插件(plugin)。Babel插件一般尽可能拆成小的力度,开发者可以按需引进, 既提高了性能,也提高了扩展性。比如对ES6转ES5的功能,Babel官方拆成了20+个插件。开发者想要体验ES6的箭头函数特性,那只需要引入transform-es2015-arrow-functions插件就可以,而不是加载ES6全家桶。

Preset

可以简单的把Babel Preset视为Babel Plugin的集合。想要将所有ES6的代码转成ES5,逐个插件引入的效率比较低下, 就可以采用Babel Preset。比如babel-preset-es2015就包含了所有跟ES6转换有关的插件。

Plugin与Preset执行顺序

可以同时使用多个Plugin和Preset,此时,它们的执行顺序非常重要。

  1. 先执行完所有Plugin,再执行Preset。
  2. 多个Plugin,按照声明次序顺序执行。
  3. 多个Preset,按照声明次序逆序执行。

比如.babelrc配置如下,那么执行的顺序为:

  1. Plugin:transform-react-jsx、transform-async-to-generator
  2. Preset:es2016、es2015
{
  "presets": [ 
    "es2015",
    "es2016"    
  ],
  "plugins": [ 
    "transform-react-jsx",
    "transform-async-to-generator"
  ]
}

怎样开发和部署前端代码

为了进一步提升网站性能,会把静态资源和动态网页分集群部署,静态资源会被部署到CDN节点上,网页中引用的资源也会变成对应的部署路径。当需要更新静态资源的时候,同时也会更新html中的引用。

如果同时改了页面结构和样式,也更新了静态资源对应的url地址,现在要发布代码上线,是先上线页面,还是先上线静态资源?

  1. 先部署页面,再部署资源:在二者部署的时间间隔内,如果有用户访问页面,就会在新的页面结构中加载旧的资源,并且把这个旧版本的资源当做新版本缓存起来,其结果就是:用户访问到了一个样式错乱的页面,除非手动刷新,否则在资源缓存过期之前,页面会一直执行错误。
  2. 先部署资源,再部署页面:在部署时间间隔之内,有旧版本资源本地缓存的用户访问网站,由于请求的页面是旧版本的,资源引用没有改变,浏览器将直接使用本地缓存,这种情况下页面展现正常;但没有本地缓存或者缓存过期的用户访问网站,就会出现旧版本页面加载新版本资源的情况,导致页面执行错误,但当页面完成部署,这部分用户再次访问页面又会恢复正常了。

这个奇葩问题,起源于资源的 覆盖式发布,用 待发布资源 覆盖 已发布资源,就有这种问题。解决它也好办,就是实现 非覆盖式发布。用文件的摘要信息来对资源文件进行重命名,把摘要信息放到资源文件发布路径中,这样,内容有修改的资源就变成了一个新的文件发布到线上,不会覆盖已有的资源文件。上线过程中,先全量部署静态资源,再灰度部署页面,整个问题就比较完美的解决了。

大公司的静态资源优化方案,基本上要实现这么几个东西:

  1. 配置超长时间的本地缓存 —— 节省带宽,提高性能
  2. 采用内容摘要作为缓存更新依据 —— 精确的缓存控制
  3. 静态资源CDN部署 —— 优化网络请求
  4. 更改资源发布路径实现非覆盖式发布 —— 平滑升级

大数相加

function add(a, b){
   const maxLength = Math.max(a.length, b.length);
   a = a.padStart(maxLength, 0);
   b = b.padStart(maxLength, 0);
   let t = 0;
   let f = 0; 
   let sum = "";
  
   for (let i = maxLength - 1; i >= 0; i--) {
      t = parseInt(a[i]) + parseInt(b[i]) + f;
      f = Math.floor(t / 10);
      sum = `${t % 10}${sum}`;
   }
   if (f === 1){
      sum = "1" + sum;
   }
   return sum;
}

斐波那契数列求和

function fib(n) {
    if (n <= 0) {
        return 0;
    }
    let n1 = 1;
    let n2 = 1;
    let sum = 1;
    for(let i = 3; i <= n; i++) {
        [n1, n2] = [n2, sum];
        sum = n1 + n2;
    }
    return sum;
};
阅读 853

把梦想放在心中

335 声望
2.3k 粉丝
0 条评论
你知道吗?

把梦想放在心中

335 声望
2.3k 粉丝
宣传栏