笔记整理,看前方-框架

924 阅读26分钟

Vue

Vue响应式原理

说太多没啥用,还是要看源码。

核心是 defineProperty这个方法,它可以 重写属性的 getset 方法,从而完成监听数据的改变。

data进行observe,调用new Observe遍历data进行settergetter绑定。

如果数组,会调用ObserveArray的方法,对数组的值进行循环遍历然后observe,这里vue改写了array的八个原型方法去触发响应。

非数组会调用defineReactive函数,defineReactive会在闭包内部定义一个dep对象,对对象的子对象递归进行observe,并且返回Observer对象。

Observer阶段,会为每个 key 都创建一个 dep 实例。并且,如果该 key 被某个 watcher 实例 get, 把该 watcher 实例加入 dep 实例的队列里。如果该 keyset, 则通知该 key 对应的 dep 实例, 然后 dep 实例会将依次通知队列里的 watcher 实例, 让它们去执行自身的回调方法 dep 实例是收集该 key 所有 watcher 实例的地方。

watcher 实例用来监听某个 key ,如果该 key 产生变化,便会执行 watcher 实例自身的回调

watcher维护着每一次更新之前的数据,当watcher收到订阅的消息后,将其和之前的数据进行对比,如果发生了变化,则执行相应的业务逻辑,并更新订阅者中维护的数据的值会执行。

watcher触发的变化不会立即执行,而是放到一个执行队列里去,如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。

最终执行的时候会调用updateComponent这个方法,这个方法的核心是patch方法进行比对,先判断新旧节点是否值得比较,比如说如果标签不同,属性不同,就是不值得比较,会直接用新节点替代老的节点,如果是值得比较,会比较引用是否相同,文本是否相同,子节点是否相同等等。最终比对完成开始渲染dom。

虚拟DOM实现

Virtual DOM 是什么?

Virtual DOM 其实就是一棵以 JavaScript 对象( VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。 简单来说,可以把Virtual DOM 理解为一个简单的JS对象,并且最少包含标签名( tag)、属性(attrs)和子元素对象( children)三个属性。不同的框架对这三个属性的命名会有点差别。

Virtual DOM 作用是什么?

虚拟DOM的最终目标是将虚拟节点渲染到视图上。但是如果直接使用虚拟节点覆盖旧节点的话,会有很多不必要的DOM操作。例如,一个ul标签下很多个li标签,其中只有一个li有变化,这种情况下如果使用新的ul去替代旧的ul,因为这些不必要的DOM操作而造成了性能上的浪费。 为了避免不必要的DOM操作,虚拟DOM在虚拟节点映射到视图的过程中,将虚拟节点与上一次渲染视图所使用的旧虚拟节点(oldVnode)做对比,找出真正需要更新的节点来进行DOM操作,从而避免操作其他无需改动的DOM。 其实虚拟DOM在Vue.js主要做了两件事:

  • 提供与真实DOM节点所对应的虚拟节点vnode
  • 将虚拟节点vnode和旧虚拟节点oldVnode进行对比,然后更新视图

为何需要Virtual DOM?

  • 具备跨平台的优势 由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。
  • 操作 DOM 慢,js运行效率高。我们可以将DOM对比操作放在JS层,提高效率。 因为DOM操作的执行速度远不如Javascript的运算速度快,因此,把大量的DOM操作搬运到Javascript中,运用patching算法来计算出真正需要更新的节点,最大限度地减少DOM操作,从而显著提高性能。 Virtual DOM 本质上就是在 JS 和 DOM 之间做了一个缓存。可以类比 CPU 和硬盘,既然硬盘这么慢,我们就在它们之间加个缓存:既然 DOM 这么慢,我们就在它们 JS 和 DOM 之间加个缓存。CPU(JS)只操作内存(Virtual DOM),最后的时候再把变更写入硬盘(DOM)
  • 提升渲染性能 Virtual DOM的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。 为了实现高效的DOM操作,一套高效的虚拟DOM diff算法显得很有必要。我们通过patch 的核心----diff 算法,找出本次DOM需要更新的节点来更新,其他的不更新。比如修改某个model 100次,从1加到100,那么有了Virtual DOM的缓存之后,只会把最后一次修改patch到view上。

虚拟dom的实现

/ 虚拟DOM元素的类,构建实例对象,用来描述DOM
class Element {
    constructor(type, props, children) {
        this.type = type;
        this.props = props;
        this.children = children;
    }
}
// 创建虚拟DOM,返回虚拟节点(object)
function createElement(type, props, children) {
    return new Element(type, props, children);
}


export {
    Element,
    createElement
}

// 首先引入对应的方法来创建虚拟DOM
import { createElement } from './element';


let virtualDom = createElement('ul', {class: 'list'}, [
    createElement('li', {class: 'item'}, ['周杰伦']),
    createElement('li', {class: 'item'}, ['林俊杰']),
    createElement('li', {class: 'item'}, ['王力宏'])
]);


console.log(virtualDom);

diff算法分析

diff的过程就是调用patch函数,就像打补丁一样修改真实dom。

patch

diff时调用patch函数,patch接收两个参数vnode,oldVnode,分别代表新旧节点。

function patch (oldVnode, vnode) {
    if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode)
    } else {
        const oEl = oldVnode.el
        let parentEle = api.parentNode(oEl)
        createEle(vnode)
        if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
            api.removeChild(parentEle, oldVnode.el)
            oldVnode = null
        }
    }
    return vnode
}

patch函数内第一个if判断sameVnode(oldVnode, vnode)就是判断这两个节点是否为同一类型节点,以下是它的实现:

function sameVnode(oldVnode, vnode){
  //两节点key值相同,并且sel属性值相同,即认为两节点属同一类型,可进行下一步比较
    return vnode.key === oldVnode.key && vnode.sel === oldVnode.sel
}

两个vnode的key和sel相同才去比较它们,比如p和span,div.classA和div.classB都被认为是不同结构而不去比较它们。如果值得比较会执行patchVnode(oldVnode, vnode)

也就是说,即便同一个节点元素比如div,他的className不同,Vue就认为是两个不同类型的节点,执行删除旧节点、插入新节点操作。这与react diff实现是不同的,react对于同一个节点元素认为是同一类型节点,只更新其节点上的属性。

当两个节点不值得比较或进入到else中,过程如下:

  • 取得oldvnode.el的父节点,parentEle是真实dom
  • createEle(vnode)会为vnode创建它的真实dom,令vnode.el =真实dom
  • parentEle将新的dom插入,移除旧的dom

当不值得比较时,新节点直接把老节点整个替换了 patch最后会返回vnode,vnode和进入patch之前的不同在哪? 没错,就是vnode.el,唯一的改变就是之前vnode.el = null, 而现在它引用的是对应的真实dom。

var oldVnode = patch (oldVnode, vnode)

patchVnode

两个节点值得比较时,会调用patchVnode(oldVnode, vnode)进一步比较:

patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el  //让vnode.el引用到现在的真实dom,当el修改时,vnode.el会同步变化。
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return  //新旧节点引用一致,认为没有变化
    //文本节点的比较
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text)
    }else {
        updateEle(el, vnode, oldVnode)
        //对于拥有子节点(两者的子节点不同)的两个节点,调用updateChildren
        if (oldCh && ch && oldCh !== ch) {
            updateChildren(el, oldCh, ch)
        }else if (ch){  //只有新节点有子节点,添加新的子节点
            createEle(vnode) //create el's children dom
        }else if (oldCh){  //只有旧节点内存在子节点,执行删除子节点操作
            api.removeChildren(el)
        }
    }
}
const el = vnode.el = oldVnode.el

这是很重要的一步,让vnode.el引用到现在的真实dom,当el修改时,vnode.el会同步变化。 节点的比较有5种情况

  1. if (oldVnode === vnode),他们的引用一致,可以认为没有变化。
  2. if(oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text),文本节点的比较,需要修改,则会调用Node.textContent = vnode.text
  3. if( oldCh && ch && oldCh !== ch ), 两个节点都有子节点,而且它们不一样,这样我们会调用updateChildren函数比较子节点,这是diff的核心,后边会讲到。
  4. else if (ch),只有新的节点有子节点,调用createEle(vnode),vnode.el已经引用了老的dom节点,createEle函数会在老dom节点上添加子节点。
  5. else if (oldCh),新节点没有子节点,老节点有子节点,直接删除老节点。

updateChildren

patchVnode中有一个重要的概念updateChildren,这是Vue diff实现的核心:

updateChildren (parentElm, oldCh, newCh) {
    let oldStartIdx = 0, newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx
    let idxInOld
    let elmToMove
    let before
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
            if (oldStartVnode == null) {   //对于vnode.key的比较,会把oldVnode = null
                oldStartVnode = oldCh[++oldStartIdx] 
            }else if (oldEndVnode == null) {
                oldEndVnode = oldCh[--oldEndIdx]
            }else if (newStartVnode == null) {
                newStartVnode = newCh[++newStartIdx]
            }else if (newEndVnode == null) {
                newEndVnode = newCh[--newEndIdx]
            }else if (sameVnode(oldStartVnode, newStartVnode)) {
                patchVnode(oldStartVnode, newStartVnode)
                oldStartVnode = oldCh[++oldStartIdx]
                newStartVnode = newCh[++newStartIdx]
            }else if (sameVnode(oldEndVnode, newEndVnode)) {
                patchVnode(oldEndVnode, newEndVnode)
                oldEndVnode = oldCh[--oldEndIdx]
                newEndVnode = newCh[--newEndIdx]
            }else if (sameVnode(oldStartVnode, newEndVnode)) {
                patchVnode(oldStartVnode, newEndVnode)
                api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
                oldStartVnode = oldCh[++oldStartIdx]
                newEndVnode = newCh[--newEndIdx]
            }else if (sameVnode(oldEndVnode, newStartVnode)) {
                patchVnode(oldEndVnode, newStartVnode)
                api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
                oldEndVnode = oldCh[--oldEndIdx]
                newStartVnode = newCh[++newStartIdx]
            }else {
               // 使用key时的比较
                if (oldKeyToIdx === undefined) {
                    oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
                }
                idxInOld = oldKeyToIdx[newStartVnode.key]
                if (!idxInOld) {
                    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                    newStartVnode = newCh[++newStartIdx]
                }
                else {
                    elmToMove = oldCh[idxInOld]
                    if (elmToMove.sel !== newStartVnode.sel) {
                        api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                    }else {
                        patchVnode(elmToMove, newStartVnode)
                        oldCh[idxInOld] = null
                        api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
                    }
                    newStartVnode = newCh[++newStartIdx]
                }
            }
        }
        if (oldStartIdx > oldEndIdx) {
            before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
            addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
        }else if (newStartIdx > newEndIdx) {
            removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
        }
}

过程可以概括为:oldCh和newCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和newCh至少有一个已经遍历完了,就会结束比较。

computed原理

  1. 当组件初始化的时候,computeddata 会分别建立各自的响应系统,Observer遍历 data 中每个属性设置 get/set 数据拦截
  2. 初始化 computed 会调用 initComputed 函数
    1. 注册一个 watcher 实例,并在内实例化一个 Dep 消息订阅器用作后续收集依赖(比如渲染函数的 watcher 或者其他观察该计算属性变化的 watcher
    2. 调用计算属性时会触发其Object.definePropertyget访问器函数
    3. 调用 watcher.depend() 方法向自身的消息订阅器 depsubs 中添加其他属性的 watcher
    4. 调用 watcherevaluate 方法(进而调用 watcherget 方法)让自身成为其他 watcher 的消息订阅器的订阅者,首先将 watcher 赋给 Dep.target,然后执行 getter 求值函数,当访问求值函数里面的属性(比如来自 dataprops 或其他 computed)时,会同样触发它们的 get 访问器函数从而将该计算属性的 watcher 添加到求值函数中属性的 watcher 的消息订阅器 dep 中,当这些操作完成,最后关闭 Dep.target 赋为 null 并返回求值函数结果。
  3. 当某个属性发生变化,触发 set 拦截函数,然后调用自身消息订阅器 depnotify 方法,遍历当前 dep 中保存着所有订阅者 wathcersubs 数组,并逐个调用 watcherupdate 方法,完成响应更新。

nextTick原理

Vue通过callback数组来模拟事件队列。

  1. vue用异步队列的方式来控制DOM更新和nextTick回调先后执行
  2. microtask因为其高优先级特性,能确保队列中的微任务在一次事件循环前被执行完毕
  3. 因为兼容性问题,vue不得不做了microtask向macrotask的降级方案,macrotask降级的方案依次是:setImmediateMessageChannelPromisesetTimeout.

vuex原理

vuex利用了vue的mixin机制,混合 beforeCreate 钩子 将store注入至vue组件实例上,并注册了 vuex store的引用属性 $store

vue的中央事件总线的实现 简单讲就是新建了一个vue对象,借助vue对象的特性(emit on) 作为其他组件的通信桥梁,实现组件间的通信 以及数据共享!

vuexstate是借助vue的响应式data实现的。设计思想与vue中央事件总线如出一辙。

getter的实现借助了vue的computed的特性而实现。

keep-alive原理

keep-alive是一个抽象组件: 它自身不会渲染一个 DOM 元素,也不会出现在父组件链中; 使用keep-alive包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。

当然keep-alive不仅仅是能够保存页面/组件的状态这么简单,它还可以避免组件反复创建和渲染,有效提升系统性能。 总的来说,keep-alive用于保存组件的渲染状态。

keep-alive 组件render过程

  • 第一步:获取keep-alive包裹着的第一个子组件对象及其组件名;
  • 第二步:根据设定的黑白名单(如果有)进行条件匹配,决定是否缓存。不匹配,直接返回组件实例(VNode),否则执行第三步;
  • 第三步:根据组件ID和tag生成缓存Key,并在缓存对象中查找是否已缓存过该组件实例。如果存在,直接取出缓存值并更新该key在this.keys中的位置(更新key的位置是实现LRU置换策略的关键),否则执行第四步;
  • 第四步:在this.cache对象中存储该组件实例并保存key值,之后检查缓存的实例数量是否超过max的设置值,超过则根据LRU置换策略删除最近最久未使用的实例(即是下标为0的那个key)。
  • 第五步:最后并且很重要,将该组件实例的keepAlive属性值设置为true。

keep-alive如何使用缓存

  • 在首次加载被包裹组件时,由keep-alive.js中的render函数可知,vnode.componentInstance的值是undefinedkeepAlive的值是true,因为keep-alive组件作为父组件,它的render函数会先于被包裹组件执行;那么就只执行到i(vnode, false /* hydrating */),后面的逻辑不再执行;
  • 再次访问被包裹组件时,vnode.componentInstance的值就是已经缓存的组件实例,那么会执行insert(parentElm, vnode.elm, refElm)逻辑,这样就直接把上一次的DOM插入到了父元素中。

vue-router

“更新视图但不重新请求页面”是前端路由原理的核心之一,目前在浏览器环境中这一功能的实现主要有两种方式:

  • 利用URL中的hash(“#”)

  • 利用History interface在 HTML5中新增的方法

HashHistory

#符号本身以及它后面的字符称之为hash,可通过window.location.hash属性读取。它具有如下特点:

  • hash虽然出现在URL中,但不会被包括在HTTP请求中。它是用来指导浏览器动作的,对服务器端完全无用,因此,改变hash不会重新加载页面

  • 可以为hash的改变添加监听事件:

window.addEventListener("hashchange", funcRef, false)
  • 每一次改变hash(window.location.hash),都会在浏览器的访问历史中增加一个记录

利用hash的以上特点,就可以来实现前端路由“更新视图但不重新请求页面”的功能了。

过程

$router.push() --> HashHistory.push() --> History.transitionTo() --> History.updateRoute() --> {app._route = route} --> vm.render()

HTML5History

History interface是浏览器历史记录栈提供的接口,通过back(), forward(), go()等方法,我们可以读取浏览器历史记录栈的信息,进行各种跳转操作。

从HTML5开始,History interface提供了两个新的方法:pushState(), replaceState()使得我们可以对浏览器历史记录栈进行修改:

window.history.pushState(stateObject, title, URL)
window.history.replaceState(stateObject, title, URL)
  • stateObject: 当浏览器跳转到新的状态时,将触发popState事件,该事件将携带这个stateObject参数的副本

  • title: 所添加记录的标题

  • URL: 所添加记录的URL

这两个方法有个共同的特点:当调用他们修改浏览器历史记录栈后,虽然当前URL改变了,但浏览器不会立即发送请求该URL(the browser won't attempt to load this URL after a call to pushState()),这就为单页应用前端路由“更新视图但不重新请求页面”提供了基础。

两种模式比较

在一般的需求场景中,hash模式与history模式是差不多的,但几乎所有的文章都推荐使用history模式,理由竟然是:"#" 符号太丑...0_0 "

如果不想要很丑的 hash,我们可以用路由的 history 模式 ——官方文档

当然,严谨的我们肯定不应该用颜值评价技术的好坏。根据MDN的介绍,调用history.pushState()相比于直接修改hash主要有以下优势:

  • pushState设置的新URL可以是与当前URL同源的任意URL;而hash只可修改#后面的部分,故只可设置与当前同文档的URL

  • pushState设置的新URL可以与当前URL一模一样,这样也会把记录添加到栈中;而hash设置的新值必须与原来不一样才会触发记录添加到栈中

  • pushState通过stateObject可以添加任意类型的数据到记录中;而hash只可添加短字符串

  • pushState可额外设置title属性供后续使用

组件通讯

在使用vue的过程中,需要频繁的进行组件间通信!通信的主体之间的关系可以是 父子组件,也可以是 类似 兄弟组件 或者是 无关组件 等非父子组件。 总的来说 有如下几种方式:

  1. 通过props向子组件传递数据:父 -> 子
  2. 通过事件向父组件发送消息:子 -> 父,使用$emit发送事件
  3. 父链 和 子索引:this.parent 与 this.children
  4. 依赖注入:provide 和 inject
  5. 子组件引用: ref与$refs
  6. 特性绑定:v-bind="attrs" 和 v-on="listeners"
  7. event bus
  8. 利用全局变量、storage、cookie、query、hash等传递数据: 非vue特性,不做赘述。
  9. 全局事件广播

mixin机制

vue中提供了一种混合机制–mixins,用来更高效的实现组件内容的复用。混入对象可以包含任意组件选项。当组件使用混入对象时,所有混入对象的选项将被混入该组件本身的选项中。

简单的说,组件在引用之后就相当于在父组件内开辟了一块单独的空间,然后根据父组件props过来的值进行相应的操作。而使用mixins机制的组件则是在引入组件之后,则是将组件内部的内容如data等方法、method等属性与父组件相应内容进行合并,然后再执行渲染。即

单纯组件引用

父组件 + 子组件 >>> 父组件 + 子组件

mixins组件

父组件 + 子组件 >>> new父组件

同时,使用mixins机制的组件,多个组件之间可以共享数据和方法,在使用mixin的组件中引入后,mixin中的方法和属性也就并入到该组件中,可以直接使用。如果项目中有使用vue-router,那么组件将自动使用mixins机制。

父组件和子组件生命周期钩子执行顺序

加载渲染过程

父beforeCreate->父created->父beforeMount->子beforeCreate->子created->子beforeMount->子mounted->父mounted

子组件更新过程

父beforeUpdate->子beforeUpdate->子updated->父updated

父组件更新过程

父beforeUpdate->父updated

销毁过程

父beforeDestroy->子beforeDestroy->子destroyed->父destroyed

Vue3.0

vue进阶之路 —— vue3.0新特性

2.0

  • 基于Object.defineProperty,不具备监听数组的能力,需要重新定义数组的原型来达到响应式。
  • Object.defineProperty 无法检测到对象属性的添加和删除 。
  • 由于Vue会在初始化实例时对属性执行getter/setter转化,所有属性必须在data对象上存在才能让Vue将它转换为响应式。
  • 深度监听需要一次性递归,对性能影响比较大。

Vue3.0

  • 基于Proxy和Reflect,可以原生监听数组,可以监听对象属性的添加和删除。
  • 不需要一次性遍历data的属性,可以显著提高性能。
  • 因为Proxy是ES6新增的属性,有些浏览器还不支持,只能兼容到IE11 。

React和Vue的区别

webpack

基本概念

在了解 Webpack 原理前,需要掌握以下几个核心概念,以方便后面的理解:

  • Entry:入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
  • Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。
  • Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。
  • Loader:模块转换器,用于把模块原内容按照需求转换成新内容。
  • Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情。

流程概括

Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:

  • 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
  • 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
  • 确定入口:根据配置中的 entry 找出所有的入口文件;
  • 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
  • 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
  • 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
  • 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

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

流程细节

Webpack 的构建流程可以分为以下三大阶段:

  • 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler。
  • 编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理。
  • 输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统。

如果只执行一次构建,以上阶段将会按照顺序各执行一次。但在开启监听模式下,流程将变为如下:

初始化阶段

编一阶段

在编译阶段中,最重要的要数 compilation 事件了,因为在 compilation 阶段调用了 Loader 完成了每个模块的转换操作,在 compilation 阶段又包括很多小的事件,它们分别是:

输出阶段

在输出阶段已经得到了各个模块经过转换后的结果和其依赖关系,并且把相关模块组合在一起形成一个个 Chunk。 在输出阶段会根据 Chunk 的类型,使用对应的模版生成最终要要输出的文件内容。

webpack打包优化

优化开发体验的目的是为了提升开发时的效率,其中又可以分为以下几点:

优化构建速度。

在项目庞大时构建耗时可能会变的很长,每次等待构建的耗时加起来也会是个大数目。

  • 缩小文件搜索范围
    • 优化loader配置,可以通过 testincludeexclude 三个配置项来命中 Loader 要应用规则的文件,减少处理文件的数量
    • resolve.modules,默认是从当前层级的node_modules文件夹开始查找三方模块,找不到会接着去查找上一级,我们可以指明存放第三方模块的绝对路径,减少查找
module.exports = {
  resolve: {
    // 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤
    // 其中 __dirname 表示当前工作目录,也就是项目根目录
    modules: [path.resolve(__dirname, 'node_modules')]
  },
};

    • 优化 resolve.alias 配置,resolve.alias 配置项通过别名来把原导入路径映射成一个新的导入路径,使用alias配置,直接指定使用文件,可以减少查找
    • 优化 resolve.extensions 配置,减少查找
      • 后缀尝试列表要尽可能的小,不要把项目中不可能存在的情况写到后缀尝试列表中。
      • 频率出现最高的文件后缀要优先放在最前面,以做到尽快的退出寻找过程。
      • 在源码中写导入语句时,要尽可能的带上后缀,从而可以避免寻找过程。例如在你确定的情况下把 require('./data') 写成 require('./data.json')。
  • 使用 DllPlugin

  • 使用 HappyPack

    • 运行在 Node.js 之上的 Webpack 是单线程模型的,也就是说 Webpack 需要处理的任务需要一件件挨着做,不能多个事情一起做。而HappyPack可以将部分任务分解到多个子进程中去并行处理,子进程处理完成后把结果发送到主进程中,从而减少总的构建时间
  • 使用 ParallelUglifyPlugin

    • 当 Webpack 有多个 JavaScript 文件需要输出和压缩时,原本会使用 UglifyJS 去一个个挨着压缩再输出, 但是 ParallelUglifyPlugin 则会开启多个子进程,把对多个文件的压缩工作分配给多个子进程去完成,每个子进程其实还是通过 UglifyJS 去压缩代码,但是变成了并行执行。 所以 ParallelUglifyPlugin 能更快的完成对多个文件的压缩工作。

优化使用体验。

通过自动化手段完成一些重复的工作,让我们专注于解决问题本身。

  • 使用自动刷新
  • 开启模块热替换
    • 实时预览反应更快,等待时间更短。
    • 不刷新浏览器能保留当前网页的运行状态,例如在使用 Redux 来管理数据的应用中搭配模块热替换能做到代码更新时 Redux 中的数据还保持不变。

总的来说模块热替换技术很大程度上的提高了开发效率和体验。

优化输出质量

优化输出质量的目的是为了给用户呈现体验更好的网页,例如减少首屏加载时间、提升性能流畅度等。 这至关重要,因为在互联网行业竞争日益激烈的今天,这可能关系到你的产品的生死。

优化输出质量本质是优化构建输出的要发布到线上的代码,分为以下几点:

减少用户能感知到的加载时间,也就是首屏加载时间。

  • 区分环境

  • 压缩代码
    要在 Webpack 中接入 UglifyJS 需要通过插件的形式,目前有两个成熟的插件,分别是:

    • UglifyJsPlugin:通过封装 UglifyJS 实现压缩。
    • ParallelUglifyPlugin:多进程并行处理压缩。
  • CDN 加速

  • 使用 Tree Shaking

  • 提取公共代码
    根据你网站所使用的技术栈,找出网站所有页面都需要用到的基础库,以采用 React 技术栈的网站为例,所有页面都会依赖 react、react-dom 等库,把它们提取到一个单独的文件。 一般把这个文件叫做 base.js,因为它包含所有网页的基础运行环境;
    在剔除了各个页面中被 base.js 包含的部分代码外,再找出所有页面都依赖的公共部分的代码提取出来放到 common.js 中去。 再为每个网页都生成一个单独的文件,这个文件中不再包含 base.js 和 common.js 中包含的部分,而只包含各个页面单独需要的部分代码。

  • 按需加载

    • 把整个网站划分成一个个小功能,再按照每个功能的相关程度把它们分成几类。
    • 把每一类合并为一个 Chunk,按需加载对应的 Chunk。
    • 对于用户首次打开你的网站时需要看到的画面所对应的功能,不要对它们做按需加载,而是放到执行入口所在的 Chunk 中,以降低用户能感知的网页加载时间。
    • 对于个别依赖大量代码的功能点,例如依赖 Chart.js 去画图表、依赖 flv.js 去播放视频的功能点,可再对其进行按需加载。

提升流畅度

也就是提升代码性能。

  • 使用 Prepack
  • 开启 Scope Hoisting
    Scope Hoisting 可以让 Webpack 打包出来的代码文件更小、运行的更快, 它又译作 "作用域提升"。
    Scope Hoisting 的实现原理其实很简单:分析出模块之间的依赖关系,尽可能的把打散的模块合并到一个函数中去,但前提是不能造成代码冗余。 因此只有那些被引用了一次的模块才能被合并。

输出分析

  • Webpack Analyse
  • webpack-bundle-analyser

webpack如何抽取CSS

使用mini-css-extract-plugin

chunk是什么

Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。

a chunk is a group of modules within the webpack process, a bundle is an emitted chunk or set of chunks.
一个chunk是webpack进程中的一组模块,一个bundle是一个发出的chunk或一组chunk。

splitCHunk是做什么的

SplitChunks插件允许我们将公共依赖项提取到现有的entry chunk或全新的代码块中。

webpack里的代码分割是个什么鬼?

它允许你将一个文件分割成多个文件。如果使用的好,它能大幅提升你的应用的性能。其原因是基于浏览器会缓存你的代码这一事实。每当你对某一文件做点改变,访问你站点的人们就要重新下载它。然而依赖却很少变动。如果你将(这些依赖)分离成单独的文件,访问者就无需多次重复下载它们了。

使用webpack生成一个或多个包含你源代码最终版本的“打包好的文件”(bundles),(概念上我们当作)它们由(一个一个的)chunks组成。

webpack 总共提供了三种办法来实现 Code Splitting,如下:

  • 入口配置:entry 入口使用多个入口文件;
  • 抽取公有代码:使用 SplitChunks 抽取公有代码;
  • 动态加载 :动态加载一些代码。

SplitChunks插件可以通过配置,把一些在配置范围内的模块打包到一个chunk里去,减少资源浪费

loader

Loader 可以看作具有文件转换功能的翻译员,配置里的 module.rules 数组配置了一组规则,告诉 Webpack 在遇到哪些文件时使用哪些 Loader 去加载和转换。

一个 Loader 的职责是单一的,只需要完成一种转换。 如果一个源文件需要经历多步转换才能正常使用,就通过多个 Loader 去转换。 在调用多个 Loader 去转换一个文件时,每个 Loader 会链式的顺序执行, 第一个 Loader 将会拿到需处理的原内容,上一个 Loader 处理后的结果会传给下一个接着处理,最后的 Loader 将处理后的最终结果返回给 Webpack。

plugin

Plugin 是用来扩展 Webpack 功能的,通过在构建流程里注入钩子实现,它给 Webpack 带来了很大的灵活性。

Webpack 通过 Plugin 机制让其更加灵活,以适应各种应用场景。 在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。

sourceMap

SourceMap是一种映射关系。当项目运行后,如果出现错误,错误信息只能定位到打包后文件中错误的位置。如果想查看在源文件中错误的位置,则需要使用映射关系,找到对应的位置。

热更新原理

整个的过程我们可以简化一下, Webpack Compile打包文件传输给Bundle Server,Bundle Server就是一个服务器,然后会执行这些编译后的文件,让浏览器可以访问到。当文件产生变化时,Webpack Compile编译之后会通知到HMR Server,HMR Server就会通知浏览器端的HMR Runtime做出修改。 HMR Runtime是会被打包到编译后的js文件内,然后和HMR Server建立websocket通信关系,这样就可以实时更新修改。

axios

原理

createInstance底层根据默认设置 新建一个Axios对象, axios中所有的请求[axios, axios.get, axios. post等...]内部调用的都是Axios.prototype.request,将Axios.prototype.request的内部this绑定到新建的 Axios对象上,从而形成一个axios实例。新建一个Axios对象时,会有两个拦截器,request拦截器,response拦 截器。

特点

  • Axios 是一个基于 promise 的 HTTP 库,支持promise所有的API
  • 它可以拦截请求和响应
  • 它可以转换请求数据和响应数据,并对响应回来的内容自动转换成 JSON类型的数据
  • 安全性更高,客户端支持防御 XSRF