框架里用过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。
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和事件类型找到对应的事件函数,模拟捕获流程,然后依次触发对应的函数。
- 出于跨浏览器和跨平台兼容等思想,通过将事件监听挂载在document上,并构造合成事件。
- 为了性能和复用考虑,采用了事件代理池,批量更新。
- 并且在内部模拟了一套捕获和冒泡并触发回调函数的机制,实现了自己一套事件系统。
React事件和原生事件
- React事件使用小驼峰命名,与原生事件的全部小写作区分;
- React事件不能通过return false去阻止默认行为,必须明确调用event.preventDefault()去阻止浏览器的默认响应;
React事件为什么要绑定this或者用箭头函数?
因为箭头函数中this指向的是定义时的所在的对象,且不可更改函数内部执行中的this指向。 可以使用 bind 绑定到组件实例上,而不用担心它的上下文。
JSX
自定义React组件名为什么必须首字母大写?
Babel在编译的过程中,JSX组件的首字母如果是小写,会被判断成原生DOM标签,则会被编译成字符串;大写则会被认为是自定义组件,编译成对象。
Vue
生命周期
开始创建,初始化数据,编译模板,挂载DOM->渲染,更新->渲染,卸载。
- beforeCreate()
Vue实例$el和数据对象data都是undefined。 使用:加载实例时触发loading事件。
- created()
data初始化完成,实例$el还未初始化。 使用:结束loading事件,调用异步请求。
- beforeMount()
data和$el都初始化完成,render()函数首次被调用,编译模板。
- mounted()
挂载完成,可以获取DOM节点(DOMContentLoaded)。 AJAX请求一般放在
mounted()中,也可以放在created(),beforeMount()里(data已经创建),但需要注意的是服务端渲染时不支持mounted(),需要放到created()中。
- beforeUpdate()
发生在虚拟DOM更新和打补丁之前,可以阻止更新。
-
updated()
-
beforeDestroy()
使用:可以添加确认停止事件的确认框,清除掉未完成的异步请求和定时器等。
利用状态管理和路由导航守卫,创建一个Map,发起请求时将请求保存,每次请求完成后将请求移除,切换路由时,将未请求完成的请求终止。
XMLHttpRequest.abort(), axios.CancelToken.source()
- destroyed()
实例销毁,所有事件监听器会移除。
父子组件调用顺序
组件的挂载顺序是先父后子,渲染完成的顺序是先子后父。 组件的销毁操作是先父后子,销毁完成的顺序是先子后父。
- 挂载渲染
父beforeCreate => 父Created => 父beforeMount => 子beforeCreate => 子Created => 子beforeMount =》子Mounted => 父Mounted
- 子组件更新
父beforeUpdate =>子beforeUpdate => 子updated => 父updated
- 父组件更新
父beforeUpdate => 父updated
- 销毁
父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,并将重写后的原型暴露出去。
- 先获取原生数组的原型方法;
- 使用
Object.defineProperty对数组的原型方法做一些拦截操作;- 把需要被拦截的数组的数据原型指向改造后原型;
在 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的作用
- 获取dom元素
this.$refs.box - 获取子组件中的data
this.$refs.box.msg - 调用子组件中的方法
this.$refs.box.open()
组件
Slot插槽
分发组件
keep-alive
<keep-alive>可以实现组件缓存,当组件动态切换时不会卸载当前组件,避免重复渲染DOM。
两个属性: include(包含的组件缓存) 与 exclude(排除的组件不缓存,优先级大于include) 。
两个生命周期:activated/deactivated,用来得知当前组件是否处于活跃状态。
和 <transition> 相似, <keep-alive> 是内置的抽象组件:自身不会渲染一个 DOM 元素,也不会出现在父组件链中。
运用了LRU算法。
可复用性与组合
混入
Vue.extend() 对象覆盖、合并
状态管理
把组件之间需要共享的状态抽取出来,遵循特定的约定,统一来管理,让状态的变化可以预测。
为什么需要状态管理?
通过props共享父子组件的状态,需要将共享状态提升至公共的父组件(若无公共的父组件,需要自己构造);而且状态由上而下逐层传递,若层级过多数据传递会变得很冗杂。状态变化过程不方便追踪。
Store模式
- 状态存储在外部变量store里;
- store中的state用于存储数据,由store实例维护;
- store中的actions封装了改变state的逻辑;
Flux
Flux是一种类似于MVC、MVVM等的架构思想。
组成
- View
- Action
Store的改变只能通过action 具体action的处理逻辑一般放在store里 action对象包含type与payload
- Dispatcher
接收actions,派发给所有的store
- Store
存放状态与更新状态的方法,一旦发生变动,就触发View更新页面。
特点
- 单向数据流
View =>Action => Dispatcher => Store(更新state) => View
- 可以有多个Store
- Store不仅存放数据,还封装了处理数据的方法
Redux
组成
- Store
存储状态state,以及触发state更新的dispatch方法
整个应用只有单一的Store
store.getState()
// 触发state改变
store.dispatch(action)
// 设置state变化的监听函数(若把视图更新函数作为listener传入,则可触发视图自动更新渲染)
store.subscribe(listener)
- Action
用于更新state的消息对象,一般由View发出;
- Reducer
根据action.type更新state,并返回nextState替换原来的state,同步的纯函数。 即
(previousState, action) => newState
Middleware
Redux支持用中间件管理异步数据流。
Middleware是对 store.dispatch()封装之后的方法,可以使dispatch传递action之外的函数或者promise。
特点
- 单向数据流
View通过store.dispatch(action)发出action,Store调用Reducer计算出新的state,若state产生变化,则调用监听函数重渲染View。
- 单一数据源(store)
- 只读state,每次状态更新返回一个新的state
- 没有dispatcher
在store里继承了dispatch方法,
store.dispatch()是View发出Action的唯一途径。
- 支持使用中间件管理异步数据流
VueX
VueX是Vue的状态管理模式。
核心概念
- Store
每个应用只有一个store实例,实例包含state,actions,mutations, getters, modules
- State 状态中心
通过mapState复杂函数将state作为计算属性访问;或者通过Store将state注入全局后使用this.$store.state访问。 State更新视图是通过vue的双向绑定机制实现。
- Getter
可以将State过滤后输出
- Mutation 更改状态
Mutation 是同步更新。
严格模式下,Mutation是改变state的唯一途径。
在Action里通过store.commit()调用Mutation,同步操作。
- Action 异步更改状态
对State的异步操作,在Action里提交Mutation变更状态; 在View里通过store.dispatch()方法触发action;
- Module
当Store对象过于庞大,可划分为多个module,便于管理;
特点
- 单向数据流
View通过
store.dispatch()调用Action,在Action执行完异步操作后通过store.commit()调用Mutation同步更新 State,再通过Vue的响应机制更新视图。
- 单一数据源(store)
- 只能应用于Vue
MobX
当状态改变时,所有应用到状态的地方都会自动更新。
概念
- State
- Computed values
- Reactions
当状态改变时自动发生的反应
- Actions
用于改变state
- autoRun
MobX中的数据基于观察者模式,通过autoRun方法添加观察者
特点
- 只有用到的数据才会引发绑定,局部精确更新;
- 没有时间回溯能力(因为数据只有一份引用);
- 基于面向对象;
- 往往是多个Store;
- 代码侵入性小;
- 简单可扩展;
- 大型项目的可维护性差;
路由管理
Hash模式
window对象提供了hashchange事件来监听hash值的改变,一旦 URL 中的 hash 值发生改变,便会触发该事件。
- 改变hash值,浏览器不会重载页面,但会在历史访问里增加一条纪录。
- 刷新重载页面时,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)
编程式导航
- 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' }})
- router.replace(location, onComplete, onAbort)
不会向History对象里添加记录,而是替换当前的记录。
- router.go(n)
相当于window.history.go(n)
导航守卫
- 全局前置守卫
router.beforeEach(to, from, next)
- 全局解析守卫
router.beforeResolve()和router.beforeEach()类似,区别是在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后,解析守卫就被调用。
- 全局后置钩子
router.afterEach(to, from)
- 路由独享的守卫
beforeEnter,在某个路由path下设置
- 组件内的守卫
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
常用的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 的运行结果。
- 初始化参数
从命令行和配置文件中读取并合并参数,环境为 development 和 production 时,编译的目标不完全相同,对应的配置参数也不完全相同。
- 确定编译入口,开始编译
单页面应用有单个入口,多页面应用有多个入口。
从入口开始,根据模块的依赖关系确定编译顺序(是一个有向无环图,拓扑排序,可以用队列和邻接表求解)。
- 编译模块
根据配置的解析规则,调用相应的 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 的支持更好。
- 完成模块编译并输出
webpack.config.output(filename, chunkFilename), 根据步骤 2 里的打包入口,编译后的Module(模块)组合成 Chunk(块),通常每个入口输出一个单独的 Chunk 文件。
根据业务情况,通过更改 output 配置项,一个入口也可以拆包输出多个文件。比如,split-chunk-plugin 可以根据模块的动态引入分块打包,mini-css-extract-plugin 可以把 JS 和 CSS 分开打包。
通常,打包完成的代码是经过压缩的,不具备可读性,为了方便定位源码调试,可以配置输出 sourcemap 文件。
- 输出到文件系统
缓存与版本控制:对于浏览器来说,如果资源的路径和文件名不改变,缓存未失效的情况下,会优先使用缓存的文件,不是所有用户都会清缓存,这样用户访问到的还是之前的版本。同路径同名的新版本文件会覆盖上一版本的文件,由于发布耗时可能会出现不同步的问题。
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 现象。
热更新
webpack监听文件的变化(包括代码文件和静态文件);webpack-dev-middleware调用 Webpack 的 API监控代码变化,当开发者修改代码并保存后,webpack重新编译打包代码,并将代码以对象的形式保存在内存中;devServer.watchContentBase为 true 的时候,Server 会监听这些静态文件的变化,变化后会通知浏览器端直接进行live reload(页面刷新);- 浏览器通过
WebSocket和webpack-dev-server进行通信,可以获取新模块的hash值,静态文件的变化信息; - 客户端 HMR接收到新的hash值后,向server端发送请求,server端返回所有更新模块的hash值,客户端再通过JSONP请求获取最新的模块代码;
- 客户端比较新旧模块,决定是否热更新,并检查模块间的依赖引用;
- 当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。
文件指纹
- hash
只要项目文件修改,整个项目构建的Hash值会改变。
一般是图片文件的指纹, file-loader里配置。
- chunkHash
和打包生成的Chunk相关,不同的编译入口会生成不同的chunkHash。
一般是JS文件的指纹, webpack.config.output的filename里配置。
- contentHash
和文件内容相关,文件内容不变,则hash值不变。
一般是CSS文件的指纹,mini-css-extract-plugin里配置。
优化
打包优化主要是从打包速度和打包体积两个指标来说,另外还可以考虑到利用文件缓存、减少请求。
提高打包速度
- 多进程打包
HappyPack thread-loader
- 缩小打包作用域
文件查找的速度,避免不必要的查找。
- exclude/include (确定 loader 规则范围)
- resolve.modules 指明第三方模块的绝对路径 (减少不必要的查找)
- resolve.extensions 减少后缀尝试的可能性
- noParse 对完全不需要解析的库进行忽略 (不去解析但仍会打包到 bundle 中,注意被忽略掉的文件里不应该包含 import、require、define 等模块化语句)
- IgnorePlugin (完全排除模块)
- 合理使用alias
-
cache-loader
在磁盘(或数据库)上缓存之前的处理结果,这意味着下次运行Webpack时效率会得到较大的提升,在使用时需要放在babel-loader 和 css-loader 之前。
-
source-map
开发模式下,source-map调试可能是最慢的,可以更改为 cheap-module-source-map
减小打包体积
- 代码压缩
optmization.minimizer -> UglifyJSPlugin
- 去除多余字符(空格,换行及注释);
- 通过AST(抽象语法树)压缩变量名(变量名,函数名,属性名);
- 更简单的表达(合并声明以及布尔值简化);
- 图片压缩
此外,针对模块体积优化还可以:
按需引入模块,如大型的组件库。
移除不必要的模块,可通过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属性指向对应的文件路径。但是在实现过程中,存在下面问题:
- 怎么保证相同的文件只加载一次?
- 怎么判断文件加载完成?
- 文件加载完成后,怎么通知所有引入文件的地方?
解决思路:
- 根据 installedChunks 检查是否加载过该 chunk,如果加载过,返回一个空数组的promise.all().
- 如果正在加载中,则返回存储过的此文件对应的promise.
- 如果没加载过,则发起一个 JSONP 请求去加载 chunk,返回一个 Promise,并定义成功和失败的回调