前端--框架

566 阅读25分钟

框架里用过React 和 Vue,面试里经常会被问到,但对源码不是很熟,只能是结合前人分享和项目经历总结了一下。

React & Vue

同:

  • 虚拟DOM

    减少对真实 DOM 的频繁操作。

    虚拟 DOM 并不总是比操作原生 DOM 快,在牺牲部分性能的前提下,抽象了原本的渲染过程,实现了跨平台的能力,增加了可维护性,这也是很多框架的通性。

  • 前端工程化

    组件复用

    关注点分离,逻辑复用

异:

  • 核心思想和语法差异

    React更接近函数式编程,使用声明式设计 JSX;Vue的模板语法更接近传统的web开发。

    React提供API供发挥,Vue 提供语法糖,例如 v-model v-for v-if 双向绑定、计算属性等使开发更方便。

  • 数据管理

    React,state 相当于函数的内部参数,只在内部改变;props相当于组件函数接受的参数,一般是不可变的。

    Vue,data 和 props 都相当于模板指令接收的参数,都可变。

  • 响应更新

    React 需要主动调用 setState 更新 state,从而触发 View 的更新,pull-based。

    Vue 的 model 和 View 是双向绑定,data 改动时页面会自动响应更新,push-based。

  • DOM 更新

    React 是自顶向下递归更新,效率较低。可以通过 shouldComponentUpdate() 函数 或 PureCopponent 控制是否更新。

    Vue 的更新粒度是组件级别(props 传递到子组件,被子组件劫持)。

  • 逻辑复用

    React 的函数式编程天然有着逻辑复用的基础,比如 HOC、 Hooks,所以有开发人员说大型项目更适合用React。

    Vue 的 mixin。

  • React的生态支持相对较好

    像 React-Native, GraphQL 很多创造性的新东西都是基于 React 的。

React

生命周期

类调用

  • getDefaultProps(),只调用一次

挂载Mount

  • constructor()

初始化状态及绑定方法; 不能调用setState(); 子组件继承,要调用super(props);

  • componentWillMount() -> getDerivedStateFromProps(nextProps, prevState)
  • render()

性能消耗严重,减少非必要re-render;

  • componentDidMount()

组件挂载完成,添加事件监听、消息订阅,请求异步数据; 不能调用setState()

变更Update(props或state变更)

  • componentWillReeivedProps(nextProps) -> getDerivedStateFromProps(nextProps, prevState)

基于props判断是否更新state,返回对象或null。

  • shouldComponentUpdate(nextProps, nextState)

默认props或state改变会更新,可return false强制不更新

  • componentWillUpdate(nextProps, nextState) -> getSnapshotBeforeUpdate(prevProps, prevState)

获取组件更新前的值,返回结果传入componentDidUpdate()

  • render()
  • componentDidUpdate(prevProps, prevState, snapshot)

卸载Unmount

  • componentWillUnmount()

执行必要的清理操作,如定时器、网络请求、componentDidUpdate 中添加的时间和方法等。

setState

setState的异步是指调用setState改变状态之后,立刻通过this.state去拿最新的state 往往是拿不到的,建议在回调函数里操作。

因为React会先收集一批变更存储在队列里,再进行统一的更新,以避免频繁更新,来保证渲染性能。

如果组件正处于创建或更新状态,就不会立刻去更新组件,而是先把当前的组件放在dirtyComponent里;否则,就进行更新。

根据 isBatchingUpdates 判断是直接更新还是稍后更新,默认值是 false。React 在调用事件处理函数之前会先调用 batchedUpdates 函数,将 isBatchingUpdates 设为 true,由此变成了批量(异步)更新。

  • 异步,合成事件,生命周期的钩子函数。
  • 同步,在原始机制下比如addEventListener原始事件机制下,setTimeout,promise等是同步的!

React新版本(16.x)

  • 生命周期(与Fiber相关)

不安全:

componentWillMount(nextProps, nextState)

在 componentWillMount 中获取异步数据或进行事件订阅等操作会产生诸如延时、重复的问题。

componentWillReceiveProps(nextProps)

因为操作props或更新DOM可能引发重复渲染问题。

componentWillUpdate(nextProps, nextState) 同上。

新增:

static getDerivedStateFromProps(nextProps, prevState)

根据传递的 props 来更新 state;静态方法,无法无法访问实例、无法通过 ref 访问到 DOM 对象。

getSnapshotBeforeUpdate(prevProps, prevState)

在组件更新之前获取一个 snapshot,常常用于 scroll 位置定位等场景。

componentDidCatch(error, info)

让开发者可以自主处理错误信息,用户可以创建自己的 Error Boundary 来捕获错误。

更改:

componentDidUpdate (prevProps, prevState, snapshot)

增加第三个参数

  • render 支持返回数组和字符串
  • createPortal
  • 支持自定义 DOM 属性
  • Fiber

在 React16 之前,计算DOM diff、更新 DOM、重渲染整个过程是同步进行,不能中断。当组件较大时更新耗时较长,无法响应用户的交互操作,用户体验不好。 Fiber,即分片按照优先级处理任务,唯一的线程就不会一直被独占。

  • Hooks

解决状态逻辑复用的问题。 多个状态不会产生嵌套,依然是平铺写法 Hooks 可以引用其他 Hooks 更容易将组件的 UI 与状态分离。

。。。

virtual DOM

虚拟 DOM 是模拟表示真实 DOM 结构和属性的 JavaScript 对象。

  • 虚拟 DOM 不会立即执行重排与重绘(合成事件,异步更新),批量更新,减少频繁的DOM操作。
  • 虚拟 DOM 通过 diff 算法只需要局部更新 DOM,有效降低真实 DOM 大面积的重排与重绘。

虚拟 DOM 并不总是比操作真实 DOM 快,优势在于在牺牲部分性能的前提下抽象了原本的渲染过程,实现了跨平台的能力。

如何获取真实DOM:

  • ReactDOM.findDOMNode()
  • this.refs

diff DOM

在开发组件时,保持稳定的 DOM 结构会有助于性能的提升。

  • tree diff

遍历(DFS)新旧两棵虚拟DOM树,只对同一层级的节点进行比较。 如果旧节点不存在,插入新节点;如果新节点不存在,删除旧节点及其子节点。

  • component diff

如果组件类型相同,按照原策略继续比较虚拟DOM树,可能虚拟DOM并未发生变化,因此 React 允许用户通过 shouldComponentUpdate() 来判断该组件是否需要进行 diff。 如果组件类型不相同,删除旧组件,插入新组件。

  • element diff

对于同一层的一组子节点,可能顺序发生改变但内容并未变化,React根据key值区分,一旦key值相同,就返回之前的组件。

树的最小编辑距离算法的时间复杂度是O(n^2m(1+logmn)),但React只比较同一层级的节点,这样算法的时间复杂度变成了O(n),因此说React实现了算法时间复杂度从从O(n^3)O(n)的优化。

响应更新

React 遵从Immutable的设计思想,永远不在原对象上修改属性,而是返回一个新对象,因此是自顶向下递归更新。

引用数据类型指向同一个内存地址,在比较时会返回 false,解决方案:

  • 深拷贝,消耗内存。
  • Object()解构赋值,但是属性是引用类型时也检测不到。
  • 第三方库,Immutable。

Fiber

在 React16 之前,计算DOM diff、更新DOM、重渲染整个过程是同步进行,自顶向下递归更新且不能中断。当组件较大时更新耗时较长,无法响应用户的交互操作,用户体验不好。

Fiber,即分片按照优先级处理任务,唯一的线程就不会一直被独占。 Fiber 将组件更新分为两个阶段:Reconciliation 和 Commit。

dive-into-react-fiber

render / reconciliation 阶段,可中断:

componentWillMount()

getDerivedStateFromProps(nextProps, prevState)

shouldComponentUpdate()

getSnapshotBeforeUpdate(prevProps, prevState)

** commit阶段,不可中断**:

componentDidMount()

componentDidUpdate(prevProps, prevState, snapShot)

componentWillUnmount()

组件通信

  • 父子组件

父组件 -> 子组件:props。

子组件 -> 父组件:子组件通过控制内部的state,在父组件中展示;子组件可以调用父组件通过props传来的方法。

  • 兄弟组件

父组件作为中转站。

  • 多层级组件

利用 React.createContext的功能,将非父子关系装换成多维度的父子关系。

Provider(设置共享状态及方法),Consumer接收共享状态及方法,被Provider组件包围的组件都可以通过Consumer函数接收。

  • 任意组件

状态管理 Redux

  • ref

HOC/Render props/hooks

  • 高阶组件HOC

创建了一个函数,该函数接收一个组件作为输入(也可以接收其他参数),基于该组件返回了一个新组件。

优点:不影响内层组件的状态,降低耦合度。

缺点:可能会被覆盖props;不方便追溯来源。

  • Render props

接收一个外部传递进来的 props 属性,将内部的 state 作为参数传递给调用组件的 props 属性方法。

缺点:无法在 return 语句外访问数据,容易导致嵌套地狱。

  • hooks

hooks 解决了 HOC 和 render props 的缺点。

事件机制

事件代理:在组件挂载阶段,根据组件的 React 事件,给 document 添加事件 addEventListener,并添加统一的事件处理函数 dispatchEvent。

将所有事件和事件类型以及 React 组件进行关联,将关系保存在 map 里,当事件触发的时候首先合成事件,根据组件ID和事件类型找到对应的事件函数,模拟捕获流程,然后依次触发对应的函数。

  1. 出于跨浏览器和跨平台兼容等思想,通过将事件监听挂载在document上,并构造合成事件。
  2. 为了性能和复用考虑,采用了事件代理池,批量更新。
  3. 并且在内部模拟了一套捕获和冒泡并触发回调函数的机制,实现了自己一套事件系统。

React事件和原生事件

  1. React事件使用小驼峰命名,与原生事件的全部小写作区分;
  2. React事件不能通过return false去阻止默认行为,必须明确调用event.preventDefault()去阻止浏览器的默认响应;

React事件为什么要绑定this或者用箭头函数?

因为箭头函数中this指向的是定义时的所在的对象,且不可更改函数内部执行中的this指向。 可以使用 bind 绑定到组件实例上,而不用担心它的上下文。

JSX

自定义React组件名为什么必须首字母大写?

Babel在编译的过程中,JSX组件的首字母如果是小写,会被判断成原生DOM标签,则会被编译成字符串;大写则会被认为是自定义组件,编译成对象。

Vue

生命周期

开始创建,初始化数据,编译模板,挂载DOM->渲染,更新->渲染,卸载。

  1. beforeCreate()

Vue实例$el和数据对象data都是undefined。 使用:加载实例时触发loading事件。

  1. created()

data初始化完成,实例$el还未初始化。 使用:结束loading事件,调用异步请求。

  1. beforeMount()

data和$el都初始化完成,render()函数首次被调用,编译模板。

  1. mounted()

挂载完成,可以获取DOM节点(DOMContentLoaded)。 AJAX请求一般放在mounted()中,也可以放在 created(), beforeMount()里(data已经创建),但需要注意的是服务端渲染时不支持 mounted(),需要放到created()中。

  1. beforeUpdate()

发生在虚拟DOM更新和打补丁之前,可以阻止更新。

  1. updated()

  2. beforeDestroy()

使用:可以添加确认停止事件的确认框,清除掉未完成的异步请求和定时器等。

利用状态管理和路由导航守卫,创建一个Map,发起请求时将请求保存,每次请求完成后将请求移除,切换路由时,将未请求完成的请求终止。

XMLHttpRequest.abort(), axios.CancelToken.source()

  1. destroyed()

实例销毁,所有事件监听器会移除。

父子组件调用顺序

组件的挂载顺序是先父后子,渲染完成的顺序是先子后父。 组件的销毁操作是先父后子,销毁完成的顺序是先子后父。

  1. 挂载渲染

父beforeCreate => 父Created => 父beforeMount => 子beforeCreate => 子Created => 子beforeMount =》子Mounted => 父Mounted

  1. 子组件更新

父beforeUpdate =>子beforeUpdate => 子updated => 父updated

  1. 父组件更新

父beforeUpdate => 父updated

  1. 销毁

父beforeDestroy =>子beforeDestroy => 子destroyed => 父destroyed

nextTick()

确保回调函数的执行在DOM更新完成之后。

Vue的DOM是异步更新,只要侦听到数据变化,Vue 将开启一个队列,存储在同一事件循环中发生的所有数据变更,为了避免频繁的DOM更新。

nextTick 使用的微任务是由Promise.then().resolve()生成的。

优先级:Promise > MutationObserver > setImmediate > setTimeout

在数据变化之后立即使用 Vue.nextTick(callback) ,这样回调函数在 DOM 更新完成后就会调用(即把回调放到下一轮事件循环里),尤其是在生命周期的钩子函数内对DOM进行操作。

virtual DOM

Vue 的更新粒度是组件级别,对于响应式属性的更新,每个组件都有自己的渲染 watcher,只会精确更新依赖收集的当前组件,而不会递归的去更新子组件的视图,这也是它性能强大的原因之一。

VNode

constructor (
  tag?: string,
  data?: VNodeData,
  children?: ?Array<VNode>,
  text?: string,
  elm?: Node,
  context?: Component
) {
  this.tag = tag  // 节点的标签名
  this.data = data // 节点的数据信息,如 props,attrs,key,class,directives 等
  this.children = children // 节点的子节点
  this.text = text // 节点对应的文本
  this.elm = elm  // 节点对应的真实节点
  this.context = context // 节点上下文,为 Vue Component 的定义
  this.key = data && data.key // 节点用作 diff 的唯一标识
}

虚拟DOM用VNode映射到真实DOM,要经历create、diff、patch阶段。

  • create

用JS对象模拟真实DOM。

  • diff

比较两棵虚拟DOM树的差异,在diff算法的过程中,只会同级比较新旧节点。

如果旧节点不存在,直接创建新节点;

如果旧节点存在而新节点不存在,直接移除旧节点及其子节点。

如果新旧节点相同,进行具体的patch操作;否则,创建新节点,移除老节点。

  • patch

把差异应用到真实DOM树上。

如果新旧节点被标注为静态节点,且key值相同,则直接复用老节点的componentInstance(key就是children中节点的唯一标识)。

如果vnode是文本节点,且新旧节点文本不同,直接设置新节点的文本内容; 如果vnode是非文本节点。。。

key的作用

key可以作为每个vnode的唯一标识,在diff算法的过程中,会同级比较新旧节点,当匹配不到可以用新节点的key值与旧节点匹配,从而可以更准确、快捷地找到旧节点。

准确:避免就地复用的情况,要key值相同才能复用,否则可能会产生一系列bug。

快捷:由于Map使用的是哈希表数据结构,查询的时间复杂度为常数级。 对于简单列表渲染来说,diff节点也更快速。

但是可能会产生一些隐藏的副作用,比如可能不会产生过渡效果,或者状态错位。

响应原理

Vue采用MVVM的架构,通过compile解析编译模板指令,将模型model与视图view做了一层绑定关系。通过Observer劫持监听data的所有自身属性 data.hasOwnProperty(key),当属性被读,Dep负责收集依赖,当属性被写,Dep通知变化给Watcher,Watcher作为负责派发更新,订阅者执行相应的update()方法更新视图,达到数据变化-》视图更新,视图交互变化-》数据model变更的双向绑定。

观察者模式

vue采取发布-订阅者模式,在初始化Vue 实例时,通过Object.defineProperty对 data 和 props 中的每项属性进行劫持,除了enumerable和configurable属性,还有set()和get()方法。在get()方法内部,当数据被读,Dep 实例会收集依赖该数据的Watcher对象,也就是添加订阅;在set() 方法内,当数据被写时,Dep类通知所有依赖该数据的Watcher对象,也就是发布更新,这样订阅者会执行相应的update()方法更新视图。

  • Object.defineProperty 简单实现
// 监听回调函数
function obsserver (oldVal, newVal) {
  consolog.log('oldVal = ', oldval, ', newVal = ', newVal);
}

var targetObj = {
  age: 1
}

Object.defineProperty(targetObj, 'name', {
  enumerbale: true,
  configurable: true,
  get: function() {
    return name;
  },
  set: function(newVal) {
    observer(name, newVal);
    name = val;
  }
});
targetObj.name = 'Leona';
targetObj.name = 'Jerry';
  • ES6的Proxy和Reflect的简单实现

在 Vue3.0 中通过使用 Proxy 在原对象操作前进行拦截、检查和代理,从而实现数据劫持。使用 Proxy 的好处是可以监听到任何方式的数据改变,唯一的缺点是兼容性的问题,因为这是 ES6 的语法。(IE带不动!)

// 监听回调函数
function obsserver (oldVal, newVal) {
  consolog.log('oldVal = ', oldval, ', newVal = ', newVal);
}

class TargetObj {
  constructor(age, name) {
    this.name = name;
    this.age = age;
  }
}
let targetObj = new TargetObj(1, 'Leona');

let observerProxy = new Proxy(targetObj, {
  get(target, property) {
    if (property == 'age') {
      console.log('Age is private');
      return '*';
    }
  }
  set(target, property, value, receiver) {
    if (property == 'name') {
      observer(target[property], value);
    }
    Reflect.set(target, property, value, receiver);
  }
});

observerProxy.name = 'Lucas';

监听数组

有一些对属性的操作,使用Object.defineProperty() 方法无法拦截,比如说通过下标方式修改数组数据、修改数组的length属性、对象属性的添加或删除。

Vue2.x 在Observer/ array.js里重写了methodsToPatch中7个方法push, pop, shift, unshift, splice, sort, reverse,并将重写后的原型暴露出去。

  1. 先获取原生数组的原型方法;
  2. 使用 Object.defineProperty 对数组的原型方法做一些拦截操作;
  3. 把需要被拦截的数组的数据原型指向改造后原型;

在 Vue3.0 中通过使用 Proxy 在原对象操作前进行拦截、检查和代理,从而实现数据劫持。

// Vue.set(object, propertyName, value)向嵌套对象添加响应式属性
Vue.set(vm.items, indexOfItem, newValue)
// vm.$set,Vue.set的一个别名
vm.$set(vm.items, indexOfItem, newValue)
// Array.prototype.splice
vm.items.splice(indexOfItem, 1, newValue)

// Array.prototype.splice
vm.items.splice(newLength)

data函数

组件中的 data 写成一个函数,数据以函数返回值形式定义。 这样每复用一次组件,就会返回一份新的 data。如果单纯的写成对象形式,就使得所有组件实例共享一份 data 实例,引用数据类型会指向同一个内存地址,造成了数据污染(原型继承的缺陷)。

watcher的产生

  • Vue实例对象上的watcher
  • watch 属性
  • computed 属性
  • $vm.watch

watch / computed /method

  • computed 计算属性

适用于某些属性依赖于其他属性的情况。 计算属性是基于它们的响应式依赖进行缓存的,依赖属性发生改变时才会重新求值。 事实上computed会拥有自己的watcher,内部有个属性dirty来决定是需要重新计算还是直接复用之前的值。

  • watch 侦听器

watch 监听到值的变化就会执行回调,适用于在数据变化的同时进行一些逻辑操作。 最初绑定的时候是不会执行的,可添加immediate属性和handler方法。

深度监听对象(数组),用deep: true 对属性递归收集依赖,性能消耗大,可以采用字符串形式监听。

  • methods 方法

不管依赖的数据是否变化,都会重新计算。

语法指令

v-if / v-show / v-once

v-if 真正的条件渲染,为false时不会渲染DOM元素,更高的切换开销。

v-show 其实已经渲染了,只是display属性的改变,适合频繁切换展示状态的场景。 v-once 只渲染一次然后缓存起来,适合静态组件的展示(重渲染会跳过)。

v-for / v-if

v-for 和 v-if 不能连用,当 Vue 处理指令时,v-for 比 v-if 具有更高的优先级,可能会造成不必要的遍历。

使用v-for给每项元素绑定事件时使用事件代理,提高事件处理速度。

v-model

text 和 textarea 元素使用 value 属性和 input 事件。

checkbox 和 radio 使用 checked 属性和 change 事件。

select 将value 作为 prop 并将 change 作为事件。

v-html

v-html指令最终调用的是innerHTML方法将指令的value插入到对应的元素里。这可能造成XSS攻击漏洞。因此建议只在可信内容上使用v-html,不要用在用户提交的内容上,或做XSS封装。

组件通信

  • 父子组件

父组件给子组件传值通过 props,子组件给父组件传值通过 $emit, 触发回调。

通过 $parent, $children获取父子组件实例,通过 $ref获取实例的方式调用组件的属性或方法。

  • 兄弟组件

通过 this.$parent.$children获取兄弟组件实例,通过$ref 获取实例的方式调用组件的属性或方法。

通过vue实例const eventBus = new Vue() 作为媒介,要相互通信的兄弟组件之中,都引入eventBus,通过 $emit更新值, $on 订阅值的更新 。

  • 跨级组件通信

Vuex

Provide / inject,允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效。(常在组件库中使用)

attrs / listeners

ref的作用

  1. 获取dom元素 this.$refs.box
  2. 获取子组件中的data this.$refs.box.msg
  3. 调用子组件中的方法 this.$refs.box.open()

组件

Slot插槽

分发组件

keep-alive

<keep-alive>可以实现组件缓存,当组件动态切换时不会卸载当前组件,避免重复渲染DOM。

两个属性: include(包含的组件缓存) 与 exclude(排除的组件不缓存,优先级大于include) 。

两个生命周期:activated/deactivated,用来得知当前组件是否处于活跃状态。

<transition> 相似, <keep-alive> 是内置的抽象组件:自身不会渲染一个 DOM 元素,也不会出现在父组件链中。

运用了LRU算法。

可复用性与组合

混入

Vue.extend() 对象覆盖、合并

状态管理

把组件之间需要共享的状态抽取出来,遵循特定的约定,统一来管理,让状态的变化可以预测。

为什么需要状态管理?

通过props共享父子组件的状态,需要将共享状态提升至公共的父组件(若无公共的父组件,需要自己构造);而且状态由上而下逐层传递,若层级过多数据传递会变得很冗杂。状态变化过程不方便追踪。

Store模式

  1. 状态存储在外部变量store里;
  2. store中的state用于存储数据,由store实例维护;
  3. store中的actions封装了改变state的逻辑;

Flux

Flux是一种类似于MVC、MVVM等的架构思想。

组成

  1. View
  2. Action

Store的改变只能通过action 具体action的处理逻辑一般放在store里 action对象包含type与payload

  1. Dispatcher

接收actions,派发给所有的store

  1. Store

存放状态与更新状态的方法,一旦发生变动,就触发View更新页面。

特点

  1. 单向数据流

View =>Action => Dispatcher => Store(更新state) => View

  1. 可以有多个Store
  2. Store不仅存放数据,还封装了处理数据的方法

Redux

组成

  1. Store

存储状态state,以及触发state更新的dispatch方法

整个应用只有单一的Store

store.getState()
// 触发state改变
store.dispatch(action)
// 设置state变化的监听函数(若把视图更新函数作为listener传入,则可触发视图自动更新渲染)
store.subscribe(listener)
  1. Action

用于更新state的消息对象,一般由View发出;

  1. Reducer

根据action.type更新state,并返回nextState替换原来的state,同步的纯函数。 即 (previousState, action) => newState

Middleware

Redux支持用中间件管理异步数据流。

Middleware是对 store.dispatch()封装之后的方法,可以使dispatch传递action之外的函数或者promise。

特点

  1. 单向数据流

View通过store.dispatch(action)发出action,Store调用Reducer计算出新的state,若state产生变化,则调用监听函数重渲染View。

  1. 单一数据源(store)
  2. 只读state,每次状态更新返回一个新的state
  3. 没有dispatcher

在store里继承了dispatch方法,store.dispatch()是View发出Action的唯一途径。

  1. 支持使用中间件管理异步数据流

VueX

VueX是Vue的状态管理模式。

核心概念

  1. Store

每个应用只有一个store实例,实例包含state,actions,mutations, getters, modules

  1. State 状态中心

通过mapState复杂函数将state作为计算属性访问;或者通过Store将state注入全局后使用this.$store.state访问。 State更新视图是通过vue的双向绑定机制实现。

  1. Getter

可以将State过滤后输出

  1. Mutation 更改状态

Mutation 是同步更新。

严格模式下,Mutation是改变state的唯一途径。

在Action里通过store.commit()调用Mutation,同步操作。

  1. Action 异步更改状态

对State的异步操作,在Action里提交Mutation变更状态; 在View里通过store.dispatch()方法触发action;

  1. Module

当Store对象过于庞大,可划分为多个module,便于管理;

特点

  1. 单向数据流

View通过 store.dispatch()调用Action,在Action执行完异步操作后通过 store.commit()调用Mutation同步更新 State,再通过Vue的响应机制更新视图。

  1. 单一数据源(store)
  2. 只能应用于Vue

MobX

当状态改变时,所有应用到状态的地方都会自动更新。

概念

  1. State
  2. Computed values
  3. Reactions

当状态改变时自动发生的反应

  1. Actions

用于改变state

  1. autoRun

MobX中的数据基于观察者模式,通过autoRun方法添加观察者

特点

  1. 只有用到的数据才会引发绑定,局部精确更新;
  2. 没有时间回溯能力(因为数据只有一份引用);
  3. 基于面向对象;
  4. 往往是多个Store;
  5. 代码侵入性小;
  6. 简单可扩展;
  7. 大型项目的可维护性差;

路由管理

Hash模式

window对象提供了hashchange事件来监听hash值的改变,一旦 URL 中的 hash 值发生改变,便会触发该事件。

  1. 改变hash值,浏览器不会重载页面,但会在历史访问里增加一条纪录。
  2. 刷新重载页面时,hash 值不会传给服务器端。

History 模式

HTML5的History对象API

  • popstate()监听历史栈信息变化,变化时重新渲染。
  • 使用 pushState() 和 replaceState()方法实现URL的变化。
  • history.pushState() 或 history.replaceState() 不会触发 popstate 事件,这时我们需要手动触发页面跳转(渲染)。

React-router

Vue-router

动态路由匹配

SPA的优点:路由切换页面和跳转无刷新,不必经过服务器;不同路由复用组件。

SPA的缺点:首次加载时间长,搜索引擎优化。

复用组件,即组件的生命周期钩子函数不会被重复调用,可以在组件内watch $route(to, from)的变化,或使用导航守卫 beforeRouteUpdate(to, from, next)

编程式导航

  1. router.push
// 字符串
router.push('home')
// 对象
router.push({ path: 'home' })
// 命名的路由
router.push({ name: 'user', params: { userId: '123' }})
// 带查询参数,变成 /register?plan=private
router.push({ path: 'register', query: { plan: 'private' }})
  1. router.replace(location, onComplete, onAbort)

不会向History对象里添加记录,而是替换当前的记录。

  1. router.go(n)

相当于window.history.go(n)

导航守卫

  1. 全局前置守卫

router.beforeEach(to, from, next)

  1. 全局解析守卫

router.beforeResolve()router.beforeEach()类似,区别是在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后,解析守卫就被调用。

  1. 全局后置钩子

router.afterEach(to, from)

  1. 路由独享的守卫

beforeEnter,在某个路由path下设置

  1. 组件内的守卫
const Foo = {
  template: `...`,
  beforeRouteEnter(to, from, next) {
    // 在渲染该组件的对应路由被 confirm 前调用
    // 不!能!获取组件实例 `this`
    // 因为当守卫执行前,组件实例还没被创建
  },
  beforeRouteUpdate(to, from, next) {
    // 在当前路由改变,但是该组件被复用时调用
    // 举例来说,对于一个带有动态参数的路径 /foo/:id,在 /foo/1 和 /foo/2 之间跳转的时候,
    // 由于会渲染同样的 Foo 组件,因此组件实例会被复用。而这个钩子就会在这个情况下被调用。
    // 可以访问组件实例 `this`
  },
  beforeRouteLeave(to, from, next) {
    // 导航离开该组件的对应路由时调用
    // 通常用来禁止用户在还未保存修改前突然离开,可以通过 next(false) 来取消离开
    // 可以访问组件实例 `this`
  }
}

过渡特效

<transition>

滚动行为

当切换到新路由时,想要页面滚到顶部,或者是保持原先的滚动位置,就像重新加载页面那样。 vue-router 能做到,而且更好,它让你可以自定义路由切换时页面如何滚动。 这个功能只在支持 history.pushState() 的浏览器中可用。

const router = new VueRouter({
  routes: [...],
  scrollBehavior (to, from, savedPosition) {
    // return 期望滚动到哪个的位置
  }
})

路由懒加载

结合异步组件和WebPack的代码分割功能

// Promise 封装
const Foo = () => Promise.resolve('./Foo.vue');

// 动态import()
const Foo = () => import('./Foo.vue');

// 使用命名chunk把某个路由下的所有组件都打包在同个异步块 (chunk) 中 
// 个人觉得没必要
const Foo = () => import(/* webpackChunkName: "group-foo" */ './Foo.vue')
const Bar = () => import(/* webpackChunkName: "group-foo" */ './Bar.vue')
const Baz = () => import(/* webpackChunkName: "group-foo" */ './Baz.vue')

Webpack

webpack 解析

常用的Loader

Loader本质上是函数,对参数内容进行转换,返回转换后的结果。

在module.rules中配置,作为模块的解析规则,每一项都是一个对象,包含解析的目标文件,使用的Loader和options属性。

Webpack是支持loader的链式调用的,即一个文件可以经多个loader处理。当一个文件使用多个loader处理时,他的处理顺序是倒序,即传入loader数组的从右到左执行。

  • 编译

babel-loader, coffee-loader, ts-loader

  • 样式处理

style-loader(把CSS代码注入到JS中,通过DOM操作加载CSS)

css-loader(加载CSS,支持模块化、压缩、文件导入等)

less-loader, sass-loader, 预处理器,优化了CSS的编码体验,如嵌套书写、变量命名、变量计算等

postcss-loader, 后处理器,配合 stylelint 校验 css 语法,自动增加浏览器前缀 autoprefixer,编译 css next 的语法等。

  • 文件处理

raw-loader(文件原始内容UTF-8)

file-loader

url-loader

image-loader

svg-inline-loader

source-map-loader(将编译、打包、压缩后的代码映射回源代码,方便断点调试)

  • 校验

eslint-loader, tslint-loader

  • 测试

mocha-loader, jshint-loader

  • 其他

vue-loader (加载单文件组件), i18n-loader (国际化)

常用的Plugin

Plugin是插件,通过Webpack暴露出来的接口可以扩展功能。

在plugins中单独配置,每一项是一个Plugin的实例,参数通过构造函数传入。

  • 压缩代码

uglifyjs-webpack-plugin

terser-webpack-plugin

webpack-parallel-uglify-plugin 多进程代码压缩

  • 分割代码(第三方库通常比较稳定,业务代码经常迭代)

common-chunks-plugin =》 split-chunks-plugin

  • 分离CSS代码(只改一个样式,不用重新打包整个组件)

extract-text-webpackPlugin=》mini-css-extract-plugin

optimize-css-assets-webpack-pluginCSS代码去重

  • 根据模板自动生成html文件,并自动引用CSS和JS文件

根据模板自动生成入口对应html文件,并自动引用 title、CSS 和 JS 文件等,也支持传入 minify 参数,如collapseWhitespace, preserveLineBreaks, minifycss, minifijs, removeComments等,从而达到压缩效果。

html-webpack-plugin, web-webpack-plugin

  • 开发,热更新

webpack-dev-server, hot-module-replacement-plugin

  • 文件体积分析

webpack-bundle-analyzer

  • 离线缓存

serviceworker-webpack-plugin

构建流程

在以下过程中,Webpack会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

  1. 初始化参数

从命令行和配置文件中读取并合并参数,环境为 development 和 production 时,编译的目标不完全相同,对应的配置参数也不完全相同。

  1. 确定编译入口,开始编译

单页面应用有单个入口,多页面应用有多个入口。

从入口开始,根据模块的依赖关系确定编译顺序(是一个有向无环图,拓扑排序,可以用队列和邻接表求解)。

  1. 编译模块

根据配置的解析规则,调用相应的 Loader 对不同的后缀名类型文件进行编译。比如 vue-loader 解析 .vue 文件,babel-loader 解析 .js 文件,ts-loader 解析 .ts 文件, 直到所有入口依赖的文件都完成编译。

module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: vueLoaderConfig
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')] // 用 include exclude 指定解析范围,通常只针对项目自身代码,不解析 node_modules 下的文件
      }
      ...
   ]
}

与此同时,Webpack 提供了很多钩子函数,插件可以在合适的时机介入,比如在解析完成后, uglifyjs-webpack-plugin 可以对解析后的代码进行压缩,生成新的代码。

代码压缩涉及到 AST 的转换过程,除了移除注释、空行、简化变量名等常见压缩方式,难点是如何通过 tree-shaking 清除无效代码(dead code elimination),包括无效的模块引用。

Webpack 的 tree-shaking ,依赖于 ES6 的模块规范,编译时加载,通过静态分析清除无效的模块引用。CommonJS 的模块规范,通过 require 动态引用模块,运行时才加载,不能通过简单的静态分析去做优化,这方面目前 Rollup 和 Parcel 的支持更好。

  1. 完成模块编译并输出

webpack.config.output(filename, chunkFilename), 根据步骤 2 里的打包入口,编译后的Module(模块)组合成 Chunk(块),通常每个入口输出一个单独的 Chunk 文件。

根据业务情况,通过更改 output 配置项,一个入口也可以拆包输出多个文件。比如,split-chunk-plugin 可以根据模块的动态引入分块打包,mini-css-extract-plugin 可以把 JS 和 CSS 分开打包。

通常,打包完成的代码是经过压缩的,不具备可读性,为了方便定位源码调试,可以配置输出 sourcemap 文件。

  1. 输出到文件系统

缓存与版本控制:对于浏览器来说,如果资源的路径和文件名不改变,缓存未失效的情况下,会优先使用缓存的文件,不是所有用户都会清缓存,这样用户访问到的还是之前的版本。同路径同名的新版本文件会覆盖上一版本的文件,由于发布耗时可能会出现不同步的问题。

Webpack 可以配置文件指纹(包括hash、chunkhash 和 contenthash), 当生成的文件内容相比上一次打包生成的文件有改动时,hash 值会自动改变。 文件名改变后,不会覆盖上一版本的文件,可以实现非覆盖发布。

Webpack 的 hash 是通过 crypto 加密和哈希算法实现的,hashDigest(在生成 hash 时使用的编码方式,默认为 'hex')、hashDigestLength(散列摘要的前缀长度,默认为 20)、hashFunction(散列算法,默认为 'md5')、hashSalt(一个可选的加盐值)等参数来实现自定义hash。

根据文件的内容和文件的绝对路径,每个 module 生成一个 _buildHash,在计算生成 hash、chunkhash 和 contenthash 时,就根据所依赖的 _buildHash 是否有改变。

Webpack 的这三种 hash 策略都依赖 module 的 _buildHash,而 _buildHash 值又依赖 module 的源文件内容和绝对路径。所以同一份源码在不同的机器上构建出来的 hash 值不一定一致,除非两台机器上的项目路径完全相同;若线上存在多机器构建部署同一个项目时,可能会由于 hash 值不同而导致访问 js 或者 css 时出现 404 现象。

热更新

  1. webpack监听文件的变化(包括代码文件和静态文件);
  2. webpack-dev-middleware 调用 Webpack 的 API监控代码变化,当开发者修改代码并保存后,webpack重新编译打包代码,并将代码以对象的形式保存在内存中;
  3. devServer.watchContentBase为 true 的时候,Server 会监听这些静态文件的变化,变化后会通知浏览器端直接进行live reload(页面刷新);
  4. 浏览器通过 WebSocketwebpack-dev-server 进行通信,可以获取新模块的hash值,静态文件的变化信息;
  5. 客户端 HMR接收到新的hash值后,向server端发送请求,server端返回所有更新模块的hash值,客户端再通过JSONP请求获取最新的模块代码;
  6. 客户端比较新旧模块,决定是否热更新,并检查模块间的依赖引用;
  7. 当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。

文件指纹

  1. hash

只要项目文件修改,整个项目构建的Hash值会改变。

一般是图片文件的指纹, file-loader里配置。

  1. chunkHash

和打包生成的Chunk相关,不同的编译入口会生成不同的chunkHash。

一般是JS文件的指纹, webpack.config.output的filename里配置。

  1. contentHash

和文件内容相关,文件内容不变,则hash值不变。

一般是CSS文件的指纹,mini-css-extract-plugin里配置。

优化

打包优化主要是从打包速度和打包体积两个指标来说,另外还可以考虑到利用文件缓存、减少请求。

提高打包速度

  • 多进程打包

HappyPack thread-loader

  • 缩小打包作用域

文件查找的速度,避免不必要的查找。

  1. exclude/include (确定 loader 规则范围)
  2. resolve.modules 指明第三方模块的绝对路径 (减少不必要的查找)
  3. resolve.extensions 减少后缀尝试的可能性
  4. noParse 对完全不需要解析的库进行忽略 (不去解析但仍会打包到 bundle 中,注意被忽略掉的文件里不应该包含 import、require、define 等模块化语句)
  5. IgnorePlugin (完全排除模块)
  6. 合理使用alias
  • cache-loader

    在磁盘(或数据库)上缓存之前的处理结果,这意味着下次运行Webpack时效率会得到较大的提升,在使用时需要放在babel-loader 和 css-loader 之前。

  • source-map

    开发模式下,source-map调试可能是最慢的,可以更改为 cheap-module-source-map

减小打包体积

  • 代码压缩

optmization.minimizer -> UglifyJSPlugin

  1. 去除多余字符(空格,换行及注释);
  2. 通过AST(抽象语法树)压缩变量名(变量名,函数名,属性名);
  3. 更简单的表达(合并声明以及布尔值简化);
  • 图片压缩

此外,针对模块体积优化还可以:

按需引入模块,如大型的组件库。

移除不必要的模块,可通过eslint检查。

选择可替代的体积较小的模块,比如“臭名昭著”的moment.js,gzip 后仍然有 69kb。

分块打包

充分利用缓存,根据模块更改频率分层次打包,比如第三方模块和业务代码模块。

  • vendor

大部分来自/node_modules,体积较大但当项目成熟后通常比较稳定(如webpack-runtime和react-runtime可永久缓存);

  • app

经常变更的业务逻辑代码; 可进一步分隔代码,如按需加载;

  • manifest

mapping relation,manifest.json中存储chunk更改前后的chunkHash值。

随着HTTP2.0的发展,特别是多路复用,初始页面的静态资源不受资源数量的影响。因此为了更好的缓存效果以及按需加载,也有很多方案建议把所有的第三方模块进行单模块打包。

按需加载

优化首屏加载时间,某些功能组件只需要在特定的条件下加载,如根据路由按需加载,根据是否可见按需加载,图片懒加载。

  • 使用 import() 动态加载模块,之前是 require-ensure
  • 使用 React.lazy() 动态加载组件
  • 使用 lodable-component 动态加载路由,组件或者模块

ES6的import(webpackChunkName: " ", webpackPreload: true)是异步操作,拆分代码的决定因素在import语法上,webpack在扫描到代码中有import语法的时候,才决定执行拆分代码。

function getComponent() {
   return import(/* webpackChunkName: "lodash" */ 'lodash').then(({ default: _ }) => {
     const element = document.createElement('div');
     element.innerHTML = _.join(['Hello', 'webpack'], ' ');
     return element;
   }).catch(error => 'An error occurred while loading the component');
}

如何实现:动态创建script标签,并将src属性指向对应的文件路径。但是在实现过程中,存在下面问题:

  1. 怎么保证相同的文件只加载一次?
  2. 怎么判断文件加载完成?
  3. 文件加载完成后,怎么通知所有引入文件的地方?

解决思路:

  1. 根据 installedChunks 检查是否加载过该 chunk,如果加载过,返回一个空数组的promise.all().
  2. 如果正在加载中,则返回存储过的此文件对应的promise.
  3. 如果没加载过,则发起一个 JSONP 请求去加载 chunk,返回一个 Promise,并定义成功和失败的回调