越來越受歡迎的Vue想學么,90後小姐姐今兒來教你

摘要:Vue的相關技術原理成為了前端崗位面試中的必考知識點,掌握 Vue 對於前端工程師來說更像是一門「必修課」。

本文原作者為尹婷,擅長前端組件庫研發和微信機器人。

我們發現, Vue 越來越受歡迎了。

不管是BAT大廠,還是創業公司,Vue都被廣泛的應用。對比Angular 和 React,三者都是非常優秀的前端框架,但從 GitHub 上來看,Vue 已經達到了 170 萬的 Star。Vue的相關技術原理也成為了前端崗位面試中的必考知識點,掌握 Vue 對於前端工程師來說更像是一門「必修課」。為此,華為雲社區邀請了90後前端開發工程師尹婷帶來了《Vue3.0新特性介紹以及搭建一個vue組件庫》的分享。

了解Vue3.0先從六大特性說起

Vue.js 是一個JavaScriptMVVM庫,是一套構建用戶介面的漸進式框架。在2019年10月05日凌晨,Vue3的源程式碼alpha。目前已經發布正式版,作者表示, Vue 3.0具有六大特性:Tree Shaking;Composition;Fragment;Teleport;Suspense;渲染Performance。渲染Performance主要是框架內部的性能優化,相對比較底層,本文會主要為大家介紹前四個特性的解讀。

Tree Shaking

大多數編譯器都會為我們的程式碼進行一個死程式碼的去除工作。首先我們要了解一下,什麼是死程式碼呢?

以下幾個特性的程式碼,我們把它稱之為死程式碼:程式碼不會被執行,不可到達;程式碼執行的結果不會被用到;程式碼只會影響死變數(只寫不讀)。比如我們給一個變數賦值,但是並沒有去用這個變數,那麼這就是一個死變數。這就是在我們定義階段會把它去除的一部分,比如說roll up消除死程式碼的工作。

如上圖示例,左邊是開發的源碼提供的兩個函數,但最終只用到了baz函數。在最後打包的時候,會把foo函數去除掉,只把baz這個函數打包進瀏覽器裡面運行。Tree Shaking是消除死程式碼的一種方式,更關注於無用模組的消除,消除那些引用了但並沒有被使用的模組。

左邊這塊程式碼,export有兩個函數,一個是post,一個是get,但是在我們生產裡邊真正使用到只有post。那麼rollup在打包之後,就會直接消除掉get的函數,然後只把post的函數打包進入我們的生產里。除了rollup支援這個特性外,webpack也支援。

接下來,我們看一下VUE3.0對Tree Shaking的支援都做了哪些事情?

首先以VUE2和VUE3對nextTick的使用進行對比:VUE2把nextTick掛載到VUE實例上的一個global API式;VUE3先把nextTick模組剔除,在要使用的時候,再把這個模組引入。

通過這個對比,我們可以看到使用VUE2的時候,即使沒有nextTick或者其他方法,但由於它是一個GLOBA API,它一定會被掛載到一個實例上,最後打包生產程式碼的時候,會把這個函數給打包進去,這一段程式碼進而也會影響到文件體積。在VUE3.0如果不需要這個模組的話,最後打包的這個文件裡邊就不會有這一塊程式碼。通過這種方式就減少了最後生產程式碼的體積。

當然,不只是nextTick,在VUE3.0內部也做了其他很多tree-shaking。例如:如果不使用keep-alive組件或v-show指令,它會少引入很多跟keep-alive或者v-show不相關的包。

上圖為Vue2.0的這段程式碼,左邊是引入utils函數,然後把這個函數指為mixins。這一段程式碼是在Vue2裡邊是最常用到的,但這段程式碼是有問題的。
如果對這個項目不熟悉,第一次看到這個程式碼的時候,由於不知道這個utils裡邊有哪些屬性和方法,也就是說這個mixins對於開發者就是個黑盒。很容易遇到一種場景:在開發組件初期,應用了mixins的一個方法,現在不需要使用該方法了,在刪除的過程發現不知道其他的地方是否引用過mixins其他的屬性和方法。

Composition

如果使用的是Vue3.0 的Composition,該怎麼規避這個問題呢?如上圖所示,假設它是一個組件實例,我們使用useMouse函數並返回了X和Y兩個變數。從左邊程式碼可以看到useMouse函數就是根,它監聽了滑鼠的移動事件之後,返回了滑鼠的XY坐標。通過這種方式來組織程式碼,就可以很明確的知道這個函數返回的變數和改變的值。

接下來我們再看一個Composition的例子:左邊是在Vue2中最常用的一段程式碼,首先在data裡邊聲明first name和last name,然後在回帖的時候去請求介面,拿到介面返回到值,在computed之後獲取他的full Name。那麼,這段程式碼的問題是什麼呢?

這裡的computed,因為我們不知道返回的full Name的邏輯是什麼。在獲取了data之後,是希望通過data的返回值來拿到它的first name和last name,然後來獲取它的full name。但是這一段程式碼的邏輯在獲取介面之後就已經斷掉,這就是Vue2.0 設計不合理的一個地方,導致我們的邏輯是分裂派的,分裂在個配置下。那麼,如果用Composition的話,怎麼樣實現呢?

請求介面之後,直接拿到它的返回數據,然後把這個返回數據的值賦給computed函數里,這裡就可以拿到full Name。通過這段程式碼可以看到,邏輯是更加的聚合了。

如何做到使用useMouse函數,裡邊的變數也是可響應的。在Vue 3.0中提供了兩個函數:reactive和ref。reactive可以傳一個對象進去,然後這個函數返回之後的state,是可響應的;ref是直接傳一個值進去,然後返回到看法對象,它也是可響應的。如果我們在setup函數裡邊返回一個可響應值的對象,是可以在字元串模板渲染的時候使用。比如,有時候我們直接在修改data的時候,視圖也會相應的改變。

Vue2中,一般會採用mixins來複用邏輯程式碼,但存在一些問題:例如程式碼來源不清晰、方法屬性等衝突。基於此,在vue3中引入了Composition API(組合API),使用純函數分隔復用程式碼,和React中的hooks的概念很相似。

Composition的優點是暴露給模板的屬性來源清晰,它是從函數返回的;第二,可以進行邏輯重用;第三,返回值可以被任意的命名,不存在秘密空間的衝突;第四,沒有創建額外的組件實力帶來的性能損耗。

以前我們如果想要獲取一個響應式的data,我們必須要把這個data放在component裡邊,然後在data裡邊進行聲明,這樣的話才能使這個對象是可響應的,現在可直接使用reactive和ref函數就可以使被保變成可響應的。

Fragment

在書寫vue2時,由於組件必須只有一個根節點,很多時候會添加一些沒有意義的節點用於包裹。Fragment組件就是用於解決這個問題的(這和React中的Fragment組件是一樣的)。

Fragment其實就是在Vue2的一個組間裡邊,它的template必須要有一個根的DIV把它包住,然後再寫裡邊的you。在Vue3,我們就不需要這個根的DIV來把這個組件包住了。上圖就是2和3的對比。

Teleport

Teleport其實就是React中的Portal。Portal 提供了一種將子節點渲染到存在於父組件以外的 DOM 節點的優秀的方案。Teleport提供一個Teleport的組件,會指定一個目標的元素,比如說這裡指定的是body,然後Teleport任何的內容都會渲染到這個目標元素中,也就是說下面的這一部分Teleport程式碼,它會直接渲染到body。

那麼關於Teleport應用的位置,我們可以為大家舉個例子來說明一下。比如說我們在做組件的時候,經常會實現一個dialog。dialog的背景是一個黑的鋪滿全螢幕DIV,我們對它的布局是position: absolute。如果父級元素是relative布局,我們的這個背景層就會受它的父元素的影響。那麼此時,如果用Teleport直接把父組件定為body,這樣它就不會再受到副組件元素樣式的影響,就可以確認一個我們想要的黑色背景畫。

下面我寫一下react和vue的diff演算法的比對,我是一邊寫程式碼,一邊寫文章,整理一下思路。註:這裡只討論tag屬性相同並且多個children的情況,不相同的tag直接替換,刪除,這沒啥好寫的。

用這個例子來說明:

簡單diff,把原有的刪掉,把更新後的插入。

變化前後的標籤都是li,所以只用比對vnodeData和children即可,復用原有的DOM。

先只從這個例子出發,我只用遍歷舊的vnode,然後把舊的vnode和新的vnode patch就行。

這樣就省掉移除和新增dom的開銷,現在的問題是,我的例子剛好是新舊vnode數量一樣,如果不一樣就有問題,示例改成這樣:

實現思路改成:先看看是舊的長度長,還是新的長,如果舊的長,我就遍歷新的,然後把多出來的舊節點刪掉,如果新的長,我就遍歷舊的,然後多出來的新vnode加上。

仍然有可優化的空間,還是下面這幅圖:

通過我們上面的diff演算法,實現的過程會比對 preve vnode和next vnode,標籤相同,則只用比對vnodedata和children。發現

 標籤的子節點(文本節點a,b,c)不同,於是分別刪除文本節點a,b,c,然後重新生成新的文本節點c,b,a。但是實際上這幾個

 只是位置不同,那優化的方案就是復用已經生成的dom,把它移動到正確的位置。

怎麼移動?我們使用key來將新舊vnode做一次映射。

首先我們找到可以復用的vnode,可以做兩次遍歷,外層遍歷next vnode,內層遍歷prev vnode

如果next vnode和prev vnode只是位置移動,vnodedata和children沒有任何變動,調用patchVnode之後不會有任何dom操作。
接下來只需要把這個key相同的vnode移動到正確的位置即可。我們的問題變成了怎麼移動。

首先需要知道兩個事情:

  • 每一個prev vnode都引用了一個真實dom節點,每個next vnode這個時候都沒有真實dom節點。
  • 調用patchVnode的時候會把prevVnode引用的真實Dom的引用賦值給nextVnode,就像這樣:

還是拿上面的例子,外層遍歷next vnode,遍歷第一個元素的時候, 第一個vnode是li©,然後去prev vnode里找,在最後一個節點找到了,這裡外層是第一個元素,不做任何移動的操作,我們記錄一下這個vnode在prevVnode中的索引位置lastIndex,接下來在遍歷的時候,如果j<lastIndex,說明原本prevVnode在前面的元素,在nextVnode中變到了後面來了,那麼我們就把prevVnode[j]放到nextVnode[i-1]的後面。

這裡多說一句,dom操作的api里,只有insertBefore(),沒有insertAfter()。也就是說只有把某個dom插入到某個元素前面這個方法,沒有插入到某個元素後面這個方法,所以我們只能用insertBefore()。那麼思路就變成了,當j<lastIndex的時候,把prevChildren[j]插入到nextVnode[i-1]的真實dom的後面元素的前面。

當j>=lastIndex的時候,說明這個順序是正確的的,不用移動,然後把lastIndex = j;
也就是說,只把prevVnode中後面的元素往前移動,原本順序是正確的就不變。
現在我們的diff的程式碼變成了這樣:

同樣的問題,如果新舊vnode的元素數量一樣,那就已經可以工作了。接下來要做的就是新增節點和刪除節點。

首先是新增節點,整個框架中將vnode掛載到真實dom上都調用patch函數,patch里調用createElm來生成真實dom。按照上面的實現,如果nextVnode中有一個節點是prevVnode中沒有的,就有問題:

在prevVnode中找不到li(d),那我們需要調用createElm掛在這個新的節點,因為這裡的節點需要超入到li(b)和li©之間,所以需要用insertBefore()。在每次遍歷nextVnode的時候用一個變數find=false表示是否能夠在prevVnode中找到節點,如果找到了就find=true。如果內層遍歷後find是false,那說明這是一個新的節點。

我們的createElm函數需要判斷一下第四個參數,如果沒有就是用appendChild直接把元素放到父節點的最後,如果有第四個參數,則需要調用insertBefore來插入到正確的位置。

接下來要做的是刪除prevVnode多餘節點:

在nextVnode中已經沒有li(d)了,我們需要在執行完上面所講的所有流程後在遍歷一次prevVnode,然後拿到nextVnode里去找,如果找不到相同key的節點,那就說明這個節點已經被刪除了,我們直接用removeChild方法刪除Dom。

完整的程式碼://github.com/TingYinHelen/tempo/blob/main/src/platforms/web/patch.js在react-diff分支(目前有可能程式碼倉庫還沒有開源,等我實現更完善的時候會開源出來,項目結構可能有變化,看tempo倉庫就行)

這裡我的程式碼實現的diff演算法很明顯看出來時間複雜度是O(n2)。那麼這裡在演算法上依然又可以優化的空間,這裡我把nextChildren和prevChildren都設計成了數組的類型,這裡可以把nextChildren、prevChildren設計成對象類型,用戶傳入的key作為對象的key,把vnode作為對象的value,這樣就可以只循環nextChildren,然後通過prevChildren[key]的方式找到prevChidren中可復用的dom。這樣就可以把時間複雜度降到O(n)。

以上就是react的diff演算法的實現。

vue的diff演算法

先說一下上面程式碼的問題,舉個例子,下面這個情況:

如果按照react的方法,整個過程會移動2次:
li©是第一個節點,不需要移動,lastIndex=2
li(b), j=1, j<lastIndex, 移動到li©後面 (第1次移動)
li(a), j=0, j<lastIndex, 移動到li(b)後面 (第2次移動)

但是通過肉眼來看,其實只用把li©移動到第一個就行,只需要移動1一次。
於是vue2這麼來設計的:

首先找到四個節點vnode:prev的第一個,next的第一個,prev的最後一個,next的最後一個,然後分別把這四個節點作比對:1. 把prev的第一個節點和next的第一個比對;2. 把prev的最後一個和next的最後一個比對;3.prev的第一個和next的最後一個;4. next的第一個和prev的最後一個。如果找到相同key的vnode,就做移動,移動後把前面的指針往後移動,後面的指針往前移動,直到前後的指針重合,如果key不相同就只patch更新vnodedata和children。下面來走一下流程:

  1. li(a)和li(b),key不同,只patch,不移動
  2. li(d)和li©,key不同,只patch,不移動
  3. li(a)和li©,key不同,只patch,不移動
  4. li(d)和li(d),key相同,先patch,需要移動移動,移動的方法就是把prev的li(d)移動到li(a)的前面。然後移動指針,因為prev的最後一個做了移動,所以把prev的指向後面的指針往前移動一個,因為next的第一個vnode已經找到了對應的dom,所以next的前面的指針往後移動一個。

現在比對的圖變成了下面這樣:

這個時候的真實DOM:

繼續比對

  1. li(a)和li(b),key不同,只patch,不移動。
  2. li©和li©,相同相同,先patch,因為next的最後一個元素也剛好是prev的最後一個,所以不移動,prev和next都往前移動指針。

這個時候真實DOM:

現在最新的比對圖:

繼續比對

  1. li(a)和li(b),key不同,只patch,不移動。
  2. li(b)和li(a),key不同,只patch,不移動。
  3. li(a) 和li (a),key相同,patch,把prev的li(a)移動到next的後面指針的元素的後面。

真實的DOM變成了這樣:

比對的圖變成這樣:

繼續比對:
li(b)和li(b)的key相同,patch,都是前指針相同所以不移動,移動指針
這個時候前指針就在後指針後面了,這個比對就結束了。

這就完成了常規的比對,還有不常規的,如下圖:

經過1,2,3,4次比對後發現,沒有相同的key值能夠移動。

這種情況我們沒有辦法,只有用老辦法,用newStartIndex的key拿去依次到prev里的vnode,直到找到相同key值的老的vnode,先patch,然後獲取真實dom移動到正確的位置(放到oldStartIndex前面),然後在prevChildren中把移動過後的vnode設置為undefined,在下次指針移動到這裡的時候直接跳過,並且next的start指針向右移動。

function updateChildren (elm, prevChildren, nextChildren) {
  let oldStartIndex = 0;
  let oldEndIndex = prevChildren.length - 1;
  let newStartIndex = 0;
  let newEndIndex = nextChildren.length - 1;

  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    let oldStartVnode = prevChildren[oldStartIndex];
    let oldEndVnode = prevChildren[oldEndIndex];
    let newStartVnode = nextChildren[newStartIndex];
    let newEndVnode = nextChildren[newEndIndex];

    if (oldStartVnode === undefined) {
      oldStartVnode = prevChildren[++oldStartIndex];
    }
    if (oldEndVnode === undefined) {
      oldEndVnode = prevChildren[--oldEndIndex];
    }

    if (oldStartVnode.key === newStartVnode.key) {
      patchVnode(newStartVnode, oldStartVnode);
      oldStartIndex++;
      newStartIndex++;
    } else if (oldEndVnode.key === newEndVnode.key) {
      patchVnode(newEndVnode, oldEndVnode);
      oldEndIndex--;
      newEndIndex--;
    } else if (oldStartVnode.key === newEndVnode.key) {
      patchVnode(newEndVnode, oldStartVnode);
      elm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
      newEndIndex--;
      oldStartIndex++;
    } else if (oldEndVnode.key === newStartVnode.key) {
      patchVnode(newStartVnode, oldEndVnode);
      elm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
      oldEndIndex--;
      newStartIndex++;
    } else {
      const idxInOld = prevChildren.findIndex(child => child.key === newStartVnode.key);
      if (idxInOld >= 0) {
        elm.insertBefore(prevChildren[idxInOld].elm, oldStartVnode.elm);
        prevChildren[idxInOld] = undefined;
        newStartIndex++;
      }
    }
  }
}

接下來就是新增節點:

這種排列方法,按照上面的方法,經過1,2,3,4比對後找不到相同key,然後然後用newStartIndex到老的vnode中去找,仍然找不著,這個時候說明是一個新節點,把它插入到oldStartIndex前面

最後是刪除節點,我把他作為課後作業,同學可以自己實現最後的刪除的演算法。

完整程式碼在 //github.com/TingYinHelen/ tempo的vue分支。

PS.本文部分內容參考自《比對一下react,vue2.x,vue3.x的diff演算法》。

 

點擊關注,第一時間了解華為雲新鮮技術~