摘要:Vue的相關(guān)技術(shù)原理成為了前端崗位面試中的必考知識點,掌握 Vue 對于前端工程師來說更像是一門“必修課”。 本文原作者為尹婷,擅長前端組件庫研發(fā)和微信機器人。 我們發(fā)現(xiàn), Vue 越來越受歡迎了。 不管是BAT大廠,還是創(chuàng)業(yè)公司,Vue都被廣泛的應(yīng)用。對比Angular 和 React,三者都是非常優(yōu)秀的前端框架,但從 GitHub 上來看,Vue 已經(jīng)達(dá)到了 170 萬的 Star。Vue的相關(guān)技術(shù)原理也成為了前端崗位面試中的必考知識點,掌握 Vue 對于前端工程師來說更像是一門“必修課”。為此,華為云社區(qū)邀請了90后前端開發(fā)工程師尹婷帶來了《Vue3.0新特性介紹以及搭建一個vue組件庫》的分享。 了解Vue3.0先從六大特性說起Vue.js 是一個JavaScriptMVVM庫,是一套構(gòu)建用戶界面的漸進(jìn)式框架。在2019年10月05日凌晨,Vue3的源代碼alpha。目前已經(jīng)發(fā)布正式版,作者表示, Vue 3.0具有六大特性:Tree Shaking;Composition;Fragment;Teleport;Suspense;渲染Performance。渲染Performance主要是框架內(nèi)部的性能優(yōu)化,相對比較底層,本文會主要為大家介紹前四個特性的解讀。 Tree Shaking大多數(shù)編譯器都會為我們的代碼進(jìn)行一個死代碼的去除工作。首先我們要了解一下,什么是死代碼呢? 以下幾個特性的代碼,我們把它稱之為死代碼:代碼不會被執(zhí)行,不可到達(dá);代碼執(zhí)行的結(jié)果不會被用到;代碼只會影響死變量(只寫不讀)。比如我們給一個變量賦值,但是并沒有去用這個變量,那么這就是一個死變量。這就是在我們定義階段會把它去除的一部分,比如說roll up消除死代碼的工作。 如上圖示例,左邊是開發(fā)的源碼提供的兩個函數(shù),但最終只用到了baz函數(shù)。在最后打包的時候,會把foo函數(shù)去除掉,只把baz這個函數(shù)打包進(jìn)瀏覽器里面運行。Tree Shaking是消除死代碼的一種方式,更關(guān)注于無用模塊的消除,消除那些引用了但并沒有被使用的模塊。 左邊這塊代碼,export有兩個函數(shù),一個是post,一個是get,但是在我們生產(chǎn)里邊真正使用到只有post。那么rollup在打包之后,就會直接消除掉get的函數(shù),然后只把post的函數(shù)打包進(jìn)入我們的生產(chǎn)里。除了rollup支持這個特性外,webpack也支持。 接下來,我們看一下VUE3.0對Tree Shaking的支持都做了哪些事情? 首先以VUE2和VUE3對nextTick的使用進(jìn)行對比:VUE2把nextTick掛載到VUE實例上的一個global API式;VUE3先把nextTick模塊剔除,在要使用的時候,再把這個模塊引入。 通過這個對比,我們可以看到使用VUE2的時候,即使沒有nextTick或者其他方法,但由于它是一個GLOBA API,它一定會被掛載到一個實例上,最后打包生產(chǎn)代碼的時候,會把這個函數(shù)給打包進(jìn)去,這一段代碼進(jìn)而也會影響到文件體積。在VUE3.0如果不需要這個模塊的話,最后打包的這個文件里邊就不會有這一塊代碼。通過這種方式就減少了最后生產(chǎn)代碼的體積。 當(dāng)然,不只是nextTick,在VUE3.0內(nèi)部也做了其他很多tree-shaking。例如:如果不使用keep-alive組件或v-show指令,它會少引入很多跟keep-alive或者v-show不相關(guān)的包。 上圖為Vue2.0的這段代碼,左邊是引入utils函數(shù),然后把這個函數(shù)指為mixins。這一段代碼是在Vue2里邊是最常用到的,但這段代碼是有問題的。 Composition
如果使用的是Vue3.0 的Composition,該怎么規(guī)避這個問題呢?如上圖所示,假設(shè)它是一個組件實例,我們使用useMouse函數(shù)并返回了X和Y兩個變量。從左邊代碼可以看到useMouse函數(shù)就是根,它監(jiān)聽了鼠標(biāo)的移動事件之后,返回了鼠標(biāo)的XY坐標(biāo)。通過這種方式來組織代碼,就可以很明確的知道這個函數(shù)返回的變量和改變的值。
接下來我們再看一個Composition的例子:左邊是在Vue2中最常用的一段代碼,首先在data里邊聲明first name和last name,然后在回帖的時候去請求接口,拿到接口返回到值,在computed之后獲取他的full Name。那么,這段代碼的問題是什么呢? 這里的computed,因為我們不知道返回的full Name的邏輯是什么。在獲取了data之后,是希望通過data的返回值來拿到它的first name和last name,然后來獲取它的full name。但是這一段代碼的邏輯在獲取接口之后就已經(jīng)斷掉,這就是Vue2.0 設(shè)計不合理的一個地方,導(dǎo)致我們的邏輯是分裂派的,分裂在個配置下。那么,如果用Composition的話,怎么樣實現(xiàn)呢? 請求接口之后,直接拿到它的返回數(shù)據(jù),然后把這個返回數(shù)據(jù)的值賦給computed函數(shù)里,這里就可以拿到full Name。通過這段代碼可以看到,邏輯是更加的聚合了。
如何做到使用useMouse函數(shù),里邊的變量也是可響應(yīng)的。在Vue 3.0中提供了兩個函數(shù):reactive和ref。reactive可以傳一個對象進(jìn)去,然后這個函數(shù)返回之后的state,是可響應(yīng)的;ref是直接傳一個值進(jìn)去,然后返回到看法對象,它也是可響應(yīng)的。如果我們在setup函數(shù)里邊返回一個可響應(yīng)值的對象,是可以在字符串模板渲染的時候使用。比如,有時候我們直接在修改data的時候,視圖也會相應(yīng)的改變。 Vue2中,一般會采用mixins來復(fù)用邏輯代碼,但存在一些問題:例如代碼來源不清晰、方法屬性等沖突?;诖?,在vue3中引入了Composition API(組合API),使用純函數(shù)分隔復(fù)用代碼,和React中的hooks的概念很相似。 Composition的優(yōu)點是暴露給模板的屬性來源清晰,它是從函數(shù)返回的;第二,可以進(jìn)行邏輯重用;第三,返回值可以被任意的命名,不存在秘密空間的沖突;第四,沒有創(chuàng)建額外的組件實力帶來的性能損耗。 以前我們?nèi)绻胍@取一個響應(yīng)式的data,我們必須要把這個data放在component里邊,然后在data里邊進(jìn)行聲明,這樣的話才能使這個對象是可響應(yīng)的,現(xiàn)在可直接使用reactive和ref函數(shù)就可以使被保變成可響應(yīng)的。 Fragment
在書寫vue2時,由于組件必須只有一個根節(jié)點,很多時候會添加一些沒有意義的節(jié)點用于包裹。Fragment組件就是用于解決這個問題的(這和React中的Fragment組件是一樣的)。 Fragment其實就是在Vue2的一個組間里邊,它的template必須要有一個根的DIV把它包住,然后再寫里邊的you。在Vue3,我們就不需要這個根的DIV來把這個組件包住了。上圖就是2和3的對比。 Teleport
Teleport其實就是React中的Portal。Portal 提供了一種將子節(jié)點渲染到存在于父組件以外的 DOM 節(jié)點的優(yōu)秀的方案。Teleport提供一個Teleport的組件,會指定一個目標(biāo)的元素,比如說這里指定的是body,然后Teleport任何的內(nèi)容都會渲染到這個目標(biāo)元素中,也就是說下面的這一部分Teleport代碼,它會直接渲染到body。 那么關(guān)于Teleport應(yīng)用的位置,我們可以為大家舉個例子來說明一下。比如說我們在做組件的時候,經(jīng)常會實現(xiàn)一個dialog。dialog的背景是一個黑的鋪滿全屏DIV,我們對它的布局是position: absolute。如果父級元素是relative布局,我們的這個背景層就會受它的父元素的影響。那么此時,如果用Teleport直接把父組件定為body,這樣它就不會再受到副組件元素樣式的影響,就可以確認(rèn)一個我們想要的黑色背景畫。 下面我寫一下react和vue的diff算法的比對,我是一邊寫代碼,一邊寫文章,整理一下思路。注:這里只討論tag屬性相同并且多個children的情況,不相同的tag直接替換,刪除,這沒啥好寫的。 用這個例子來說明:
簡單diff,把原有的刪掉,把更新后的插入。
變化前后的標(biāo)簽都是li,所以只用比對vnodeData和children即可,復(fù)用原有的DOM。 先只從這個例子出發(fā),我只用遍歷舊的vnode,然后把舊的vnode和新的vnode patch就行。
這樣就省掉移除和新增dom的開銷,現(xiàn)在的問題是,我的例子剛好是新舊vnode數(shù)量一樣,如果不一樣就有問題,示例改成這樣:
實現(xiàn)思路改成:先看看是舊的長度長,還是新的長,如果舊的長,我就遍歷新的,然后把多出來的舊節(jié)點刪掉,如果新的長,我就遍歷舊的,然后多出來的新vnode加上。
仍然有可優(yōu)化的空間,還是下面這幅圖:
通過我們上面的diff算法,實現(xiàn)的過程會比對 preve vnode和next vnode,標(biāo)簽相同,則只用比對vnodedata和children。發(fā)現(xiàn) ? 標(biāo)簽的子節(jié)點(文本節(jié)點a,b,c)不同,于是分別刪除文本節(jié)點a,b,c,然后重新生成新的文本節(jié)點c,b,a。但是實際上這幾個 ? 只是位置不同,那優(yōu)化的方案就是復(fù)用已經(jīng)生成的dom,把它移動到正確的位置。 怎么移動?我們使用key來將新舊vnode做一次映射。 首先我們找到可以復(fù)用的vnode,可以做兩次遍歷,外層遍歷next vnode,內(nèi)層遍歷prev vnode
如果next vnode和prev vnode只是位置移動,vnodedata和children沒有任何變動,調(diào)用patchVnode之后不會有任何dom操作。 首先需要知道兩個事情:
還是拿上面的例子,外層遍歷next vnode,遍歷第一個元素的時候, 第一個vnode是li?,然后去prev vnode里找,在最后一個節(jié)點找到了,這里外層是第一個元素,不做任何移動的操作,我們記錄一下這個vnode在prevVnode中的索引位置lastIndex,接下來在遍歷的時候,如果j<lastIndex,說明原本prevVnode在前面的元素,在nextVnode中變到了后面來了,那么我們就把prevVnode[j]放到nextVnode[i-1]的后面。 這里多說一句,dom操作的api里,只有insertBefore(),沒有insertAfter()。也就是說只有把某個dom插入到某個元素前面這個方法,沒有插入到某個元素后面這個方法,所以我們只能用insertBefore()。那么思路就變成了,當(dāng)j<lastIndex的時候,把prevChildren[j]插入到nextVnode[i-1]的真實dom的后面元素的前面。 當(dāng)j>=lastIndex的時候,說明這個順序是正確的的,不用移動,然后把lastIndex = j;
同樣的問題,如果新舊vnode的元素數(shù)量一樣,那就已經(jīng)可以工作了。接下來要做的就是新增節(jié)點和刪除節(jié)點。 首先是新增節(jié)點,整個框架中將vnode掛載到真實dom上都調(diào)用patch函數(shù),patch里調(diào)用createElm來生成真實dom。按照上面的實現(xiàn),如果nextVnode中有一個節(jié)點是prevVnode中沒有的,就有問題:
在prevVnode中找不到li(d),那我們需要調(diào)用createElm掛在這個新的節(jié)點,因為這里的節(jié)點需要超入到li(b)和li?之間,所以需要用insertBefore()。在每次遍歷nextVnode的時候用一個變量find=false表示是否能夠在prevVnode中找到節(jié)點,如果找到了就find=true。如果內(nèi)層遍歷后find是false,那說明這是一個新的節(jié)點。
我們的createElm函數(shù)需要判斷一下第四個參數(shù),如果沒有就是用appendChild直接把元素放到父節(jié)點的最后,如果有第四個參數(shù),則需要調(diào)用insertBefore來插入到正確的位置。 接下來要做的是刪除prevVnode多余節(jié)點:
在nextVnode中已經(jīng)沒有l(wèi)i(d)了,我們需要在執(zhí)行完上面所講的所有流程后在遍歷一次prevVnode,然后拿到nextVnode里去找,如果找不到相同key的節(jié)點,那就說明這個節(jié)點已經(jīng)被刪除了,我們直接用removeChild方法刪除Dom。
完整的代碼:https://github.com/TingYinHelen/tempo/blob/main/src/platforms/web/patch.js在react-diff分支(目前有可能代碼倉庫還沒有開源,等我實現(xiàn)更完善的時候會開源出來,項目結(jié)構(gòu)可能有變化,看tempo倉庫就行) 這里我的代碼實現(xiàn)的diff算法很明顯看出來時間復(fù)雜度是O(n2)。那么這里在算法上依然又可以優(yōu)化的空間,這里我把nextChildren和prevChildren都設(shè)計成了數(shù)組的類型,這里可以把nextChildren、prevChildren設(shè)計成對象類型,用戶傳入的key作為對象的key,把vnode作為對象的value,這樣就可以只循環(huán)nextChildren,然后通過prevChildren[key]的方式找到prevChidren中可復(fù)用的dom。這樣就可以把時間復(fù)雜度降到O(n)。 以上就是react的diff算法的實現(xiàn)。 vue的diff算法先說一下上面代碼的問題,舉個例子,下面這個情況:
如果按照react的方法,整個過程會移動2次: 但是通過肉眼來看,其實只用把li?移動到第一個就行,只需要移動1一次。
首先找到四個節(jié)點vnode:prev的第一個,next的第一個,prev的最后一個,next的最后一個,然后分別把這四個節(jié)點作比對:1. 把prev的第一個節(jié)點和next的第一個比對;2. 把prev的最后一個和next的最后一個比對;3.prev的第一個和next的最后一個;4. next的第一個和prev的最后一個。如果找到相同key的vnode,就做移動,移動后把前面的指針往后移動,后面的指針往前移動,直到前后的指針重合,如果key不相同就只patch更新vnodedata和children。下面來走一下流程:
現(xiàn)在比對的圖變成了下面這樣:
這個時候的真實DOM:
繼續(xù)比對
這個時候真實DOM:
現(xiàn)在最新的比對圖:
繼續(xù)比對
真實的DOM變成了這樣:
比對的圖變成這樣:
繼續(xù)比對:
這就完成了常規(guī)的比對,還有不常規(guī)的,如下圖:
經(jīng)過1,2,3,4次比對后發(fā)現(xiàn),沒有相同的key值能夠移動。 這種情況我們沒有辦法,只有用老辦法,用newStartIndex的key拿去依次到prev里的vnode,直到找到相同key值的老的vnode,先patch,然后獲取真實dom移動到正確的位置(放到oldStartIndex前面),然后在prevChildren中把移動過后的vnode設(shè)置為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++; } } } } 接下來就是新增節(jié)點: 這種排列方法,按照上面的方法,經(jīng)過1,2,3,4比對后找不到相同key,然后然后用newStartIndex到老的vnode中去找,仍然找不著,這個時候說明是一個新節(jié)點,把它插入到oldStartIndex前面 最后是刪除節(jié)點,我把他作為課后作業(yè),同學(xué)可以自己實現(xiàn)最后的刪除的算法。 完整代碼在 https://github.com/TingYinHelen/ tempo的vue分支。 PS.本文部分內(nèi)容參考自《比對一下react,vue2.x,vue3.x的diff算法》。 |
|