<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          使用Vite插件自動(dòng)化實(shí)現(xiàn)骨架屏 | 文末送書

          共 23005字,需瀏覽 47分鐘

           ·

          2022-10-12 19:41

          作者:橙紅年代 原文:https://juejin.cn/post/7152406737100734495

          骨架屏在SPA應(yīng)用中有兩個(gè)顯著提升用戶體驗(yàn)的作用

          • 避免頁面初始化加載時(shí)的空白,體驗(yàn)介于SSR和完全等待頁面初始化完成之間
          • 避免部分路由組件需要加載數(shù)據(jù)完成之后才渲染的空白

          骨架屏?xí)o用戶一種內(nèi)容已經(jīng)返回的錯(cuò)覺,只要稍加等待就能看見完整內(nèi)容了,因此骨架屏的定位就是真實(shí)內(nèi)容準(zhǔn)備好之前的替身。

          之前研究過一種快速生成骨架屏的想法:使用Chrome擴(kuò)展程序生成網(wǎng)頁骨架屏[1],大概原理是通過Chrome擴(kuò)展程序注入content.js修改頁面DOM接口,最終導(dǎo)出帶有骨架屏樣式的HTML代碼。

          當(dāng)時(shí)的這個(gè)想法并沒有在生產(chǎn)中落地,最近在折騰用戶體驗(yàn)相關(guān)的功能,發(fā)現(xiàn)還是有必要繼續(xù)完善一下骨架屏相關(guān)的東西。

          業(yè)界對(duì)于骨架屏的應(yīng)用,也有好幾種方案

          • 直接讓設(shè)計(jì)師提供頁面對(duì)應(yīng)的骨架屏設(shè)計(jì)圖
            • 導(dǎo)出svgbase64圖片嵌入代碼中,比較影響項(xiàng)目體積
            • 開發(fā)手動(dòng)編寫樣式,工作量較大
          • 通過組件編寫骨架屏
            • 諸如vue-content-loader[2]、react-content-loader[3]等組件,可以通過svg快速編寫骨架屏內(nèi)容,但輸出的產(chǎn)物與真實(shí)頁面有一定差距,不容易實(shí)現(xiàn)定制化骨架屏需求。
            • 一些組件庫,如vant[4]、varlet[5]也提供了skeleton組件,通過配置參數(shù)的形式控制生成骨架屏內(nèi)容,其缺點(diǎn)也是定制化程度較差
          • 自動(dòng)生成骨架屏
            • page-skeleton-webpack-plugin等比較成熟的自動(dòng)骨架屏方案,甚至有專門的UI界面來控制生成不同一面的骨架屏,缺點(diǎn)是生成的骨架屏代碼較大,影響項(xiàng)目體積
            • 借助puppeteer無頭瀏覽器渲染出頁面對(duì)應(yīng)的骨架屏內(nèi)容,依賴較大
            • 借助Chrome擴(kuò)展程序生成骨架屏內(nèi)容,本質(zhì)上和無頭瀏覽器原理相似

          骨架屏屬于錦上添花的功能,理想狀態(tài)下開發(fā)者應(yīng)該是不需要過分關(guān)注的,因此從開發(fā)體驗(yàn)上來看,手動(dòng)編寫骨架屏并不是很好的解決方案。因此本文主要研究另外一種骨架屏自動(dòng)生成方案:通過vite插件自動(dòng)注入骨架屏。

          本文所涉及的項(xiàng)目代碼均上傳至github[6]上面了。

          先預(yù)覽一下效果

          點(diǎn)擊生成骨架屏

          992bd9592fbcf3eb45b068082267fddd.webp

          首屏訪問

          2a70e9eef492e48ee04f6e054395ef06.webp

          vite插件生成骨架屏

          參考

          • 前端智能化探索,骨架屏低代碼自動(dòng)生成方案實(shí)踐 [7]
          • vite-plugin-vue-inspector[8]這個(gè)插件的實(shí)現(xiàn),將源代碼的一些信息注入到頁面上
          • 骨架屏 - 微信小程序開發(fā)文檔[9],小程序開發(fā)者工具提供了類似快速生成當(dāng)前頁骨架屏的方案

          首先需要探尋一種自動(dòng)能夠?qū)⒃O(shè)計(jì)圖或真實(shí)頁面轉(zhuǎn)成骨架屏的方案。大概有下面幾個(gè)思路

          • 通過編譯工具,解析代碼中編寫的HTML模板,生成骨架屏
          • 從設(shè)計(jì)圖來源出發(fā),比如sketch、figma等,通過插件導(dǎo)出可以用骨架屏內(nèi)容
          • 直接操作真實(shí)頁面的DOM,然后生成骨架屏內(nèi)容

          利用現(xiàn)有樣式

          看起來第三種思路的實(shí)現(xiàn)成本最低,也最為熟悉。這也是使用Chrome擴(kuò)展程序生成網(wǎng)頁骨架屏[10]這個(gè)方案中采用的方案,因此具體的實(shí)現(xiàn)細(xì)節(jié)這里不再贅述,簡單總結(jié)一下

          • 在開發(fā)環(huán)境下,通過手動(dòng)觸發(fā)某個(gè)開關(guān),開始生成某個(gè)頁面對(duì)應(yīng)的骨架屏內(nèi)容
          • 將頁面按節(jié)點(diǎn)類型拆分成不同區(qū)塊
          • 支持自定義節(jié)點(diǎn)類型、忽略或隱藏節(jié)點(diǎn)
          • 最后導(dǎo)出的是一段HTML代碼,復(fù)用原始頁面的結(jié)構(gòu)和CSS布局代碼

          核心API只有一個(gè),傳入對(duì)應(yīng)的入口節(jié)點(diǎn),輸出轉(zhuǎn)換后的骨架屏代碼

                
                const?{name,?content}?=?renderSkeleton(sel,?defaultConfig)

          比如下面這段結(jié)構(gòu)

                
                <div?class="card"?data-skeleton-type="block">
          ??<div?class="card_tt"?data-skeleton-type="text">卡片標(biāo)題</div>
          ???<div?class="card_ct"?data-skeleton-type="text">卡片內(nèi)容卡片內(nèi)容</div>
          </div>

          生成的骨架屏代碼是

                
                <div?class="sk-block?card">
          ??<div?class="sk-text?card_tt">卡片標(biāo)題</div>
          ???<div?class="sk-text?card_ct">卡片內(nèi)容卡片內(nèi)容</div>
          </div>

          其中sk-block、sk-text等樣式類都是在生成時(shí)追加上去的,用于覆蓋原本的樣式,從而展示骨架屏的灰色背景,但同時(shí)保留原本的布局樣式。

          renderSkeleton的調(diào)用時(shí)機(jī)由開發(fā)者自己控制,我們可以向頁面注入一個(gè)按鈕,點(diǎn)擊時(shí)調(diào)用

                
                function?createTrigger()?{
          ??const?div:?HTMLDivElement?=?document.createElement('div')
          ??div.setAttribute('style',?'position:fixed;right:0;bottom:20px;width:50px;height:50px;background:red;')
          ??div.addEventListener('click',?function?()?{
          ????renderSkeleton('[data-skeleton-root]')
          ??})
          ??document.body.appendChild(div)
          }

          if(process.end.NODE_ENV?==='development'){
          ?createTrigger()
          }

          在得到骨架屏代碼之后,在業(yè)務(wù)代碼中通過一個(gè)loading標(biāo)志位控制展示的是骨架屏還是真實(shí)內(nèi)容

                
                <script?setup?lang="ts">
          import?{ref,?onMounted}?from?"vue";

          const?loading?=?ref(true);
          const?list?=?ref<number>([]);

          async?function?fetchList()?{
          ??await?sleep(1000)
          ??list.value?=?[1,?2,?3,?4,?5]
          ??loading.value?=?false
          }

          onMounted(()?=>?{
          ??fetchList()
          })

          </script>

          <template>
          ??<div?class="page">
          ????<div?v-if="loading">
          ??????<!--這里的都是骨架屏代碼-->
          ??????<div?class="sk-block?card"?v-for="item?in?5"?:key="item">
          ??????????<div?class="sk-text?card_tt">卡片標(biāo)題</div>
          ??????????<div?class="sk-text?card_ct">卡片內(nèi)容卡片內(nèi)容</div>
          ??????</div>
          ???</div>
          ????<div?v-else?class="card-list?card-list-knowledge"?data-skeleton-root="APP"?data-skeleton-type="list">
          ?????<div?class="card"?v-for="item?in?list"?:key="item"?data-skeleton-type="block">
          ??????????<div?class="card_tt">卡片標(biāo)題</div>
          ??????????<div?class="card_ct">卡片內(nèi)容卡片內(nèi)容</div>
          ??????</div>
          ????</div>
          ??</div>

          </template>

          <style?lang="scss"?scoped>
          //?相關(guān)的樣式
          </style>

          可以看到,v-if="loading"標(biāo)簽內(nèi)部的代碼,就是生成的骨架屏內(nèi)容。需要注意的是,既然骨架屏與業(yè)務(wù)代碼在一起,也會(huì)參與Vue的SFC編譯,因此骨架屏標(biāo)簽上面的一些動(dòng)態(tài)屬性如scopeid等,需要移除。關(guān)于scopeid帶來的其他問題,后面的篇幅會(huì)提到,這也會(huì)影響整個(gè)renderSkeleton的實(shí)現(xiàn)。

          如果每次在調(diào)用renderSkeleton拿到骨架屏代碼之后,手動(dòng)修改業(yè)務(wù)代碼替換loading展示的內(nèi)容,無疑非常麻煩,現(xiàn)在來研究一下如何自動(dòng)化解決這個(gè)問題。

          前面提到,骨架屏主要應(yīng)用在首屏渲染需要和路由頁面切換時(shí)

          • SPA首屏渲染優(yōu)化
          • 路由組件切換時(shí)的占位內(nèi)容

          接下來看看這兩種場(chǎng)景下如何自動(dòng)注入骨架屏代碼

          組件內(nèi)渲染骨架屏

          我們可以通過占位符來聲明當(dāng)前組件對(duì)應(yīng)骨架屏代碼的地方,比如

                
                <div?v-if="loading">__SKELETON_APP_CONTENT__</div>
          <div?v-else>真實(shí)業(yè)務(wù)代碼</div>

          在獲得骨架屏代碼之后,將__SKELETON_APP_CONTENT__這里的內(nèi)容替換成真實(shí)的骨架屏代碼即可。

          如何替換呢?vite插件提供了一個(gè)transform的鉤子

                
                const?filename?=?'./src/skeleton/content.json'

          function?SkeletonPlaceholderPlugin()?{
          ??return?{
          ????name:?'skeleton-placeholder-plugin',
          ????enforce:?'pre',
          ????transform(src,?id)?{
          ??????if?(/\.vue$/.test(id))?{
          ????????const?{content}?=?fs.readJsonSync(filename)
          ????????//?約定對(duì)應(yīng)的骨架屏占位符
          ????????let?code?=?src.replace(/__SKELETON_(.*?)_CONTENT__/igm,?function?(match)?{
          ??????????return?content
          ????????})

          ????????return?{
          ??????????code,
          ????????}
          ??????}
          ??????return?src
          ????},
          ??}?as?Plugin
          }

          其中./skeleton.txt中的內(nèi)容,就是在調(diào)用renderSkeleton后生成的骨架屏代碼,通過transformpre,我們就可以在vue插件解析SFC之前,先將骨架屏占位符替換成真正的代碼,再參與后續(xù)的編譯流程。

          這里還需要解決一個(gè)問題:renderSkeleton是在客戶端觸發(fā)的,而skeleton.txt是在開發(fā)服務(wù)器環(huán)境下的,需要有一個(gè)通信的機(jī)制將客戶端生成的骨架屏代碼發(fā)送到項(xiàng)目目錄下面。

          vite插件提供了一個(gè)configureServer鉤子,用來配置vite開發(fā)服務(wù)器,我們可以加一個(gè)中間件,用來提供一個(gè)保存骨架屏代碼的接口

                
                function?SkeletonApiPlugin()?{
          ??async?function?saveSkeletonContent(name,?content)?{
          ????await?fs.ensureFile(filename)
          ????const?file?=?await?fs.readJson(filename)
          ????file[name]?=?{
          ??????content,
          ????}
          ????await?fs.writeJson(filename,?file)
          ??}

          ??return?{
          ????name:?'skeleton-api-plugin',
          ????configureServer(server)?{
          ??????server.middlewares.use(bodyParser())
          ??????server.middlewares.use('/update_skeleton',?async?(req,?res,?next)?=>?{
          ????????const?{name,?content,?pathname}?=?req.body
          ????????await?saveSkeletonContent(name,?content,?pathname)
          ????????//?骨架屏代碼更新之后,重啟服務(wù)
          ????????server.restart()
          ????????res.end('success')
          ??????})
          ????},
          ??}
          }

          然后在renderSkeleton之后,調(diào)用這個(gè)接口上傳生成的骨架屏代碼即可

                
                async?function?sendContent(body:?any)?{
          ??const?response?=?await?fetch('/update_skeleton',?{
          ????method:?'POST',
          ????headers:?{
          ??????'Content-Type':?'application/json'
          ????},
          ????body:?JSON.stringify(body)
          ??})
          ??const?data?=?await?response.text()
          }

          const?__renderSkeleton?=?async?function?()?{
          ??const?{name,?content}?=?renderSkeleton(".card-list",?{})

          ??await?sendContent({
          ????name,
          ????content
          ??})
          }

          bingo!大功告成。梳理一下流程

          • 開發(fā)者在某個(gè)時(shí)候手動(dòng)調(diào)用__renderSkeleton,就會(huì)自動(dòng)生成當(dāng)前頁面的骨架屏

          • 將骨架屏代碼發(fā)送給vite接口,更新本地skeleton/content.json中的骨架屏代碼,

          • vite重啟服務(wù)后,重新觸發(fā)pre隊(duì)列中的skeleton-content-component插件,替換骨架屏占位符,注入骨架屏代碼,完成整個(gè)骨架屏的插入流程。

          整個(gè)過程中,開發(fā)者只需要完成下面兩步操作即可

          • 聲明骨架屏在業(yè)務(wù)代碼中的占位符
          • 點(diǎn)擊按鈕,觸發(fā)生成骨架屏代碼

          路由切換的骨架屏大多應(yīng)用在路由組件上,可以考慮進(jìn)一步封裝,統(tǒng)一管理loading和骨架屏展示,這里比較細(xì)節(jié),就不再一一展開了。

          首屏渲染骨架屏

          骨架屏對(duì)于SPA首屏渲染優(yōu)化,需要在應(yīng)用初始化之前就開始渲染,即需要在id="app"的組件內(nèi)植入初始化的骨架屏代碼

          如果是服務(wù)端預(yù)渲染,可以直接返回填充后的代碼;如果是客戶端處理,可以通過document.write處理,我們這里只考慮純SPA引用,由前端處理骨架屏的插入。

          我們可以通過vite插件提供的transformIndexHtml鉤子注入這段邏輯

                
                function?SkeletonApiPlugin()?{
          ??return?{
          ????name:?'skeleton-aip-plugin',
          ????transformIndexHtml(html)?{
          ??????let?{content}?=?fs.readJsonSync(filename)
          ??????const?code?=?`
          <script>
          var?map?=?${JSON.stringify(content)}
          var?pathname?=?window.location.pathname
          var?target?=?Object.values(map).find(function?(row){
          ??return?row.pathname?===?pathname
          })
          var?content?=?target?&&?target.content?||?''
          document.write(content)
          </script>
          ??????`

          ??????return?html.replace(/__SKELETON_CONTENT__/,?code)
          ????}

          ??}
          }

          對(duì)應(yīng)的index.html代碼為

                
                <div?id="app">__SKELETON_CONTENT__</div>

          根據(jù)用戶當(dāng)前訪問的url,讀取該url對(duì)應(yīng)的骨架屏代碼,然后通過document.write寫入骨架屏代碼。這里可以看出,在生成骨架屏代碼時(shí),我們還需要保留對(duì)應(yīng)頁面url的映射,甚至需要考慮動(dòng)態(tài)化路由的匹配問題。這個(gè)也比較簡單,在提交到服務(wù)端保存時(shí),加個(gè)當(dāng)前頁面的路徑參數(shù)就行了

                
                ??const?{name,?content}?=?renderSkeleton(sel,?defaultConfig)
          ??//?如果是hash路由,就替換成fragment
          ??const?{pathname}?=?window.location
          ??await?sendContent({
          ????name,
          ????content,
          ????pathname?//?保存骨架屏代碼的時(shí)候順道把pathname也保存了
          ??})

          整理一下流程

          • 用戶訪問url
          • 根據(jù)頁面url,加載對(duì)應(yīng)的骨架屏代碼,填充在根節(jié)點(diǎn)下
            • 如果是服務(wù)端預(yù)渲染,可以直接返回填充后的代碼
            • 如果是客戶端處理,可以通過document.write處理
          • 用戶看見渲染的骨架屏內(nèi)容
          • 初始化應(yīng)用,加載頁面數(shù)據(jù),渲染出真實(shí)頁面

          開發(fā)者在點(diǎn)擊生成當(dāng)前頁面的骨架屏?xí)r,保存的骨架屏代碼,既可以用在路由組件切換時(shí)的骨架屏,也可以用在首屏渲染時(shí)的骨架屏,Nice~

          存在的一些問題

          利用vite插件注入骨架屏的代碼,看起來是可行的,但在方案落地時(shí),發(fā)現(xiàn)了一些需要解決的問題。

          存在原始樣式不生效的場(chǎng)景

          由于生成的骨架屏代碼是依賴原始樣式的,

                
                
                  <div?class="card"?data-skeleton-type="block">
                  </div>
                  

          對(duì)應(yīng)的骨架屏代碼

                
                
                  <div?class="sk-block?card">
                  </div>
                  

          其中的sk-block只會(huì)添加一些灰色背景和動(dòng)畫,至于整體的尺寸和布局,還是card這個(gè)類來控制的。

          這么設(shè)計(jì)的主要原因是:即使card的尺寸布局發(fā)生了變化,對(duì)應(yīng)的骨架屏樣式也會(huì)一同更新。

          但在某些場(chǎng)景下,原始樣式類無法生效,最具有代表性的問題就Vue項(xiàng)目的的scoped css。

          我們知道,vue-loader、@vitejs/plugin-vue等工具解析SFC文件時(shí),會(huì)為對(duì)應(yīng)組件生成scopeId(參考之前的源碼分析:從vue-loader源碼分析CSS-Scoped的實(shí)現(xiàn)[11]),然后通過postcss插件,通過組合選擇器實(shí)現(xiàn)了類似于css作用域的樣式表

                
                .card[data-v-xxx]?{}

          我們的生成骨架屏的時(shí)機(jī)是在開發(fā)環(huán)境下進(jìn)行的,這就導(dǎo)致在生產(chǎn)環(huán)境下,看到的骨架屏并沒有原始樣式類對(duì)應(yīng)的尺寸和布局。

          下面是vite vue插件的源碼

                
                export?function?createDescriptor(
          ??filename:?string,
          ??source:?string,
          ??{?root,?isProduction,?sourceMap,?compiler?}:?ResolvedOptions
          ):?SFCParseResult?
          {
          ??const?{?descriptor,?errors?}?=?compiler.parse(source,?{
          ????filename,
          ????sourceMap
          ??})

          ??const?normalizedPath?=?slash(path.normalize(path.relative(root,?filename)))
          ??descriptor.id?=?hash(normalizedPath?+?(isProduction???source?:?''))

          ??cache.set(filename,?descriptor)
          ??return?{?descriptor,?errors?}
          }

          vue-loader中生成scopeid的方法類似,看了一下貌似并沒有提供自定義scopeid的API。

          因此對(duì)于同一個(gè)文件而言,生產(chǎn)環(huán)境和非生產(chǎn)環(huán)境參與生產(chǎn)hash的參數(shù)是不一樣的,導(dǎo)致最后得到的scopeid 也不一樣。

          對(duì)于組件內(nèi)渲染骨架屏這種場(chǎng)景,我們也許可以不考慮scopeid,因?yàn)樵赟FC編譯之前,我們就已經(jīng)通過transform鉤子注入了對(duì)應(yīng)的骨架屏模板,對(duì)于SFC編譯器而言,骨架屏代碼和業(yè)務(wù)代碼都在同一個(gè)組件內(nèi),也就是說他們最后都會(huì)獲得相同的scopeid,這也是為什么生成的骨架屏代碼,要擦除HTML標(biāo)簽上面的scopeid的原因。

          但如果骨架屏依賴的外部樣式并不在同一個(gè)SFC文件內(nèi),也會(huì)導(dǎo)致原始的骨架屏樣式不生效。

                
                <template>
          ??<div?class="page">
          ????<div?v-if="loading">
          ??????<div?class="sk-block?card"?v-for="item?in?5"?:key="item">
          ??????????<div?class="sk-text?card_tt">卡片標(biāo)題</div>
          ??????????<div?class="sk-text?card_ct">卡片內(nèi)容卡片內(nèi)容</div>
          ??????</div>
          ???</div>
          ????<div?v-else?class="card-list?card-list-knowledge"?data-skeleton-root="APP"?data-skeleton-type="list">
          ?????<Card?v-for="item?in?list"?:key="item"?data-skeleton-type="block"/>
          ????</div>
          ??</div>

          </template>

          <style?lang="scss"?scoped>
          //?card?樣式不在這個(gè)SFC下面,但是插入的骨架屏代碼卻在這個(gè)SFC文件下
          </style>

          此外,對(duì)于首屏渲染骨架屏這種場(chǎng)景,就不得不考慮scopeid了。如果骨架屏依賴的原始樣式是攜帶作用域的,那就必須要保證骨架屏代碼與生產(chǎn)環(huán)境的樣式表一致

                
                .card[data-v-xxx]?{}
                
                <div?id="app">
          ?<div?class="sk-block?card"?data-v-xxx></div>
          </div>

          這樣,首屏渲染依賴的骨架屏和組件內(nèi)渲染的骨架屏就產(chǎn)生了沖突,前者需要攜帶scopeid,而后者又需要擦除scopeid。

          為了解決這個(gè)沖突,有兩種辦法

          • 在保存骨架屏代碼時(shí),同時(shí)保存對(duì)應(yīng)的scopeid,并在首屏渲染時(shí),為每個(gè)標(biāo)簽上手動(dòng)添加scopeid。
          • 原始骨架屏代碼就攜帶scopeid,而在替換組件內(nèi)的骨架屏占位符時(shí)再擦除scopeid

          但不論通過何種方式保證兩個(gè)環(huán)境下生成的scopeid 一致(甚至是通過修改插件源碼的方式),可能也會(huì)存在舊版本的骨架屏攜帶的scopeid和新版本對(duì)應(yīng)的scopeid 不一致的問題,即舊版本的class和新版本的class不一致。

          要解決這個(gè)問題,除非在每次修改源碼之后,都更新一下骨架屏,由于生成骨架屏這一步是手動(dòng)的,這與自動(dòng)化的目的背道而馳了。

          因此,看起來利用原始類同步真實(shí)DOM的布局和尺寸,在scoped css中并不是一個(gè)十分完善的設(shè)計(jì)。

          骨架屏代碼質(zhì)量

          第二個(gè)不是那么重要的問題是生成的骨架屏代碼,相較于手動(dòng)編寫,不夠精簡。

          雖然在源代碼中,骨架屏代碼被占位符替代,但在編譯階段,骨架屏?xí)幾g到render函數(shù)中,可能造成代碼體積較大,甚至影響頁面性能的問題。

          這個(gè)問題并不是一個(gè)阻塞性問題,可以后面考慮如何優(yōu)化,比如骨架屏仍舊保留v-for等指令,組件可以正常編譯,而首屏渲染的骨架屏需要通過自己解析生成完整的HTML代碼。

          解決方案

          上面這兩個(gè)問題的本質(zhì)都是因?yàn)楣羌芷辽煞桨笇?dǎo)致的,跟后續(xù)保存骨架屏代碼并自動(dòng)替換并沒有多大關(guān)系,因此我們只需要優(yōu)化骨架屏生成方案即可。

          既然依賴于原始樣式生成的骨架屏代碼存在這些缺點(diǎn),有沒有什么解決辦法呢?

          事實(shí)上,我們對(duì)于骨架屏是否更真實(shí)內(nèi)容結(jié)構(gòu)的還原程度并沒有那么高的要求,也并沒有要求骨架屏要跟業(yè)務(wù)代碼一直保持一致,既然導(dǎo)出HTML骨架屏代碼比較冗余和繁瑣,我們可以換一換思路。

          不使用scoped css

          其他比較常用的CSS方案如css moudle、css-in-js或者是全局原子類css如tailwind、windicss等,如果輸出的是純粹的CSS代碼,且生產(chǎn)環(huán)境和線上保持一致,理論上是不會(huì)出現(xiàn)scopeid這個(gè)問題的。

          但Vue項(xiàng)目中,scoped css方案應(yīng)該占據(jù)了半壁江山,加上我自己也比較喜歡scoped css,因此這是一個(gè)繞不過去的問題。

          將骨架屏頁面自動(dòng)轉(zhuǎn)成圖片

          第一種思路將骨架屏頁面保存為圖片,這樣就不用再依賴原始樣式了。

          大概思路就是:在解析當(dāng)前頁面獲得骨架屏代碼之后,再通過html2canvas等工具,將已經(jīng)渲染的HTML內(nèi)容轉(zhuǎn)成canvas,再導(dǎo)出base64圖片。

                
                import?html2canvas?from?'html2canvas'

          const?__renderSkeleton?=?async?function?(sel?=?'body')?{
          ??const?{name,?content}?=?renderSkeleton(sel,?defaultConfig)
          ??const?canvas?=?await?html2canvas(document.querySelector(sel)!)
          ??document.body.appendChild(canvas);

          ??const?imgData?=?canvas.toDataURL()
          ??//?保存<img?src="${imgData}"?alt="">作為骨架屏代碼
          }

          這種通過圖片替代HTML骨架屏代碼的優(yōu)點(diǎn)在于兼容性好(對(duì)應(yīng)的頁面骨架屏甚至可以用在App或小程序中),容易遷移,不需要依賴項(xiàng)目代碼中的樣式類。

          但是html2canvas生成的圖片也不是百分百還原UI,需要足夠純凈的代碼原始結(jié)構(gòu)才能生成符合要求的圖片。此外圖片也存在分辨率和清晰度等問題。

          也許又要回到最初的起點(diǎn),讓設(shè)計(jì)大佬直接導(dǎo)出一張SVG?(開個(gè)玩笑,我們還是要走自動(dòng)化的道路

          復(fù)制一份獨(dú)立的樣式表

          如果能夠找到骨架屏代碼中每個(gè)標(biāo)簽對(duì)應(yīng)的class在樣式表中定義的樣式,類似于Chrome dev tools中的Elements Styles面板,我們就可以將這些樣式復(fù)制一份,然后將scopeid替換成其他的選擇器

          378ce5ecaad1a61d5bd12d8ee7e1e84e.webp

          開發(fā)環(huán)境下的樣式都是通過style標(biāo)簽引入,因此可以拿到頁面上所有的樣式表對(duì)象,提取符合對(duì)應(yīng)選擇器的樣式,包括.className.className[scopeId]這兩類

          寫一個(gè)Demo

                
                const?{?getClassStyle?}?=?(()?=>?{
          ????const?styleNodes?=?document.querySelectorAll("style");
          ????const?allRules?=?Array.from(styleNodes).reduce(
          ????????(acc,?styleNode)?=>?{
          ????????????const?rules?=?styleNode.sheet.cssRules;
          ????????????acc?=?acc.concat(Array.from(rules));
          ????????????return?acc;
          ????????},
          ????????[]
          ????);
          ????const?getClassStyle?=?(selectorText)?=>?{
          ????????return?allRules.filter(
          ????????????(row)?=>?row.selectorText?===?selectorText
          ????????);
          ????};

          ????return?{
          ????????getClassStyle,
          ????};
          })();

          const?getNodeAttrByRegex?=?(node,?re)?=>?{
          ????const?attr?=?Array.from(node.attributes).find((row)?=>?{
          ????????return?re.test(row.name);
          ????});
          ????return?attr?&&?attr.name;
          };

          const?parseNodeStyle?=?(node)?=>?{
          ????const?scopeId?=?getNodeAttrByRegex(node,?/^data-v-/);
          ????return?Array.from(myBox.classList).reduce((acc,?row)?=>?{
          ????????const?rules?=?getClassStyle(`.${row}`);

          ???????/
          /?這里沒有再考慮兩個(gè)類.A.B之類的組合樣式了,排列組合比較多
          ????????return?acc
          ????????????.concat(getClassStyle(`.${row}`))
          ????????????.concat(getClassStyle(`.${row}[${scopeId}]`));
          ????},?[]);
          };
          const?rules?=?parseNodeStyle(myBox);
          console.log(rules);

          這樣就可以得到每個(gè)節(jié)點(diǎn)在scoped css的樣式,然后替換成骨架屏依賴的樣式。

          但現(xiàn)在要保存的骨架屏代碼的HTML結(jié)構(gòu)之外,還需要保存對(duì)應(yīng)的那份CSS代碼,十分繁瑣

          提取必要的布局信息生成骨架屏

          能否像html2canvas的思路一樣,重新繪制一份骨架屏頁面出來呢

          通過getComputedStyle可以獲取骨架屏每個(gè)節(jié)點(diǎn)的計(jì)算樣式

                
                const?width?=?getComputedStyle(myBox,null).getPropertyValue('width');

          復(fù)用頁面結(jié)構(gòu),把所有布局和尺寸相關(guān)的屬性都枚舉出來,一一獲取然后轉(zhuǎn)成行內(nèi)樣式,看起來也是可行的。

          但這個(gè)方案需要逐步嘗試完善對(duì)應(yīng)的屬性列表,相當(dāng)于復(fù)刻一下瀏覽器的布局規(guī)則,工作量較大,此外還需要考慮rem、postcss等問題,看起來也不是一個(gè)明智的選擇。

          postcss插件

          既然scopeid是通過postcss插入的,能不能在對(duì)應(yīng)的樣式規(guī)則里面加一個(gè)分組選擇器,額外支持一下骨架屏的呢

          比如

                
                .card[data-v-xxx]?{}

          修改為

                
                .card[data-v-xxx],?.sk-wrap?.card?{}

          這樣,只要解決生產(chǎn)環(huán)境和開發(fā)環(huán)境scopeid不一致的問題就可以了。

          編寫postcss插件可以參考官方文檔:編寫一個(gè)postcss 插件[12]。

          vue/compuler-sfc源碼中發(fā)現(xiàn),scopedPlugin插件位于傳入的postcssPlugins之后,而我們編寫的插件需要位于scopedPlugin之后才行,

          601468b78815b44421ce7e8b68681411.webp

          如果不能修改源碼,只有繼續(xù)從vite 插件的transform鉤子入手了,在transform中手動(dòng)執(zhí)行postcss進(jìn)行編譯

          繼續(xù)編寫一個(gè)SkeletonStylePlugin插件

                
                const?wrapSelector?=?'.sk-wrap'
          export?function?SkeletonStylePlugin()?{
          ??return?{
          ????name:?'skeleton-style-plugin',
          ????transform(src:?string,?id:?string)?{
          ??????const?{query}?=?parseVueRequest(id)

          ??????if?(query.type?===?'style')?{
          ????????const?result?=?postcss([cssSkeletonGroupPlugin({wrapSelector})]).process(src)
          ????????return?result.css
          ??????}
          ??????return?src
          ????}
          ??}
          }

          注意該插件要放在vue插件后面執(zhí)行,因?yàn)榇藭r(shí)得到的內(nèi)容才是經(jīng)過vue-compiler編譯后的攜帶有scopeid 的樣式。

          其中cssSkeletonGroupPlugin是一個(gè)postcss插件

                
                import?{Rule}?from?'postcss'

          const?processedRules?=?new?WeakSet<Rule>()

          type?PluginOptions?=?{
          ??wrapSelector:?string
          }
          const?plugin?=?(opts:?PluginOptions)?=>?{
          ??const?{wrapSelector}?=?opts

          ??function?processRule(rule:?Rule)?{
          ????if?(processedRules.has(rule))?{
          ??????return
          ????}
          ????processedRules.add(rule)
          ????rule.selector?=?rewriteSelector(rule)
          ??}

          ??function?rewriteSelector(rule:?Rule):?string?{
          ????const?selector?=?rule.selector?||?''

          ????const?group:?string[]?=?[]
          ????selector.split(',').forEach(sel?=>?{
          ??????//?todo?這里需要排除不在骨架屏中使用的樣式
          ??????const?re?=?/\[data-v-.*?\]/igm
          ??????if?(re.test(sel))?{
          ????????group.push(wrapSelector?+?'?'?+?sel.replace(re,?''))
          ??????}
          ????})

          ????if(!group.length)?return?selector
          ????return?selector?+?',?'?+?group.join(',')
          ??}

          ??return?{
          ????postcssPlugin:?'skeleton-group-selector-plugin',
          ????Rule(rule:?Rule)?{
          ??????processRule(rule)
          ????},
          ??}
          }
          plugin.postcss?=?true

          export?default?plugin

          這個(gè)插件寫的比較粗糙,只考慮了常規(guī)的選擇器,并依次追加分組選擇器。測(cè)試一下

                
                .test1[data-v-xxx]?{}

          成功編譯成了

                
                .test1[data-v-xxx],?.sk-wrap?.test1?{}

          這樣,只需要將骨架屏代碼外邊包一層sk-wrap,骨架屏中的樣式就可以正常生效了!

                
                content?&&?document.write('<div?class="${wrapSelector.substr(1)}">'?+content+'</div>')

          看起來解決了一個(gè)困擾我很久的問題。

          小結(jié)

          至此,一個(gè)借助于Vite插件實(shí)現(xiàn)自動(dòng)骨架屏的方案就實(shí)現(xiàn)了,總結(jié)一下整體流程

          首先初始化插件

                
                import?{SkeletonPlaceholderPlugin,?SkeletonApiPlugin}?from?'../src/plugins/vitePlugin'

          export?default?defineConfig({
          ??plugins:?[
          ????SkeletonPlaceholderPlugin(),
          ????vue(),
          ????SkeletonApiPlugin(),
          ??],
          ??build:?{
          ????cssCodeSplit:?false
          ??}
          })

          然后填寫占位符,對(duì)于首屏渲染的骨架屏

                
                <div?id="app">__SKELETON_CONTENT__</div>

          對(duì)于組件內(nèi)的骨架屏

                
                <!--其中占位符格式為`__SKELETON_${data-skeleton-root}_CONTENT__`-->
          <div?v-if="loading">__SKELETON_APP_CONTENT__</div>
          <div?class="card-list"?data-skeleton-root="APP"?data-skeleton-type="list"></div>

          接著初始化客戶端觸發(fā)器,同時(shí)向頁面插入一個(gè)可以點(diǎn)擊生成骨架屏的按鈕

                
                import?'../../src/style/skeleton.scss'
          import?{initInject}?from?'../../src/inject'

          createApp(App).use(router).mount('#app')

          //?開發(fā)環(huán)境下才注入
          if?(import.meta.env.DEV)?{
          ??setTimeout(initInject)
          }

          點(diǎn)擊觸發(fā)器,自動(dòng)將當(dāng)前頁面轉(zhuǎn)換成骨架屏

          通過HTTP將骨架屏代碼發(fā)送到插件接口,通過fs寫入本地文件./src/skeleton/content.json中,然后自動(dòng)重啟vite server

          頁面內(nèi)組件的占位符會(huì)通過SkeletonPlaceholderPlugin替換對(duì)應(yīng)占位符的骨架屏代碼,loading生效時(shí)展示骨架屏

          首屏渲染頁面時(shí),通過location.pathname插入當(dāng)前路徑對(duì)應(yīng)的骨架屏代碼,直接看見骨架屏代碼

          所有骨架屏依賴的當(dāng)前樣式通過cssSkeletonGroupPlugin解析,通過分組選擇器輸出在css文件,不再依賴scopeid。

          這樣,一個(gè)基本自動(dòng)的骨架屏工具就集成到項(xiàng)目中,需要進(jìn)行的手動(dòng)工作包括

          • 配置插件
          • 定義組件的骨架屏占位符,以及骨架屏入口data-skeleton-root="APP"
          • 必要時(shí)在標(biāo)簽上聲明data-skeleton-type,定制骨架屏節(jié)點(diǎn)

          整個(gè)項(xiàng)目比較依賴vite插件開發(fā)知識(shí),也參考了vite、@vitejs/plugin-vue、@vue/compile-sfc等源碼的實(shí)現(xiàn)細(xì)節(jié)。

          所有Demo已經(jīng)放在github[13]上面了,剩下要解決的就是優(yōu)化生成骨架屏的效果和質(zhì)量了,期待后續(xù)吧

          參考資料

          [1]

          https://www.shymean.com/article/%E4%BD%BF%E7%94%A8Chrome%E6%89%A9%E5%B1%95%E7%A8%8B%E5%BA%8F%E7%94%9F%E6%88%90%E7%BD%91%E9%A1%B5%E9%AA%A8%E6%9E%B6%E5%B1%8F: https://link.juejin.cn?target=https%3A%2F%2Fwww.shymean.com%2Farticle%2F%25E4%25BD%25BF%25E7%2594%25A8Chrome%25E6%2589%25A9%25E5%25B1%2595%25E7%25A8%258B%25E5%25BA%258F%25E7%2594%259F%25E6%2588%2590%25E7%25BD%2591%25E9%25A1%25B5%25E9%25AA%25A8%25E6%259E%25B6%25E5%25B1%258F

          [2]

          https://www.npmjs.com/package/vue-content-loader: https://link.juejin.cn?target=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2Fvue-content-loader

          [3]

          https://www.npmjs.com/package/react-content-loader: https://link.juejin.cn?target=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2Freact-content-loader

          [4]

          https://youzan.github.io/vant/#/zh-CN/skeleton: https://link.juejin.cn?target=https%3A%2F%2Fyouzan.github.io%2Fvant%2F%23%2Fzh-CN%2Fskeleton

          [5]

          https://varlet.gitee.io/varlet-ui/#/zh-CN/skeleton: https://link.juejin.cn?target=https%3A%2F%2Fvarlet.gitee.io%2Fvarlet-ui%2F%23%2Fzh-CN%2Fskeleton

          [6]

          github: https://link.juejin.cn?target=

          [7]

          https://cloud.tencent.com/developer/article/1876082: https://link.juejin.cn?target=https%3A%2F%2Fcloud.tencent.com%2Fdeveloper%2Farticle%2F1876082

          [8]

          https://juejin.cn/post/7077347158545924127: https://juejin.cn/post/7077347158545924127

          [9]

          https://developers.weixin.qq.com/miniprogram/dev/devtools/skeleton.html: https://link.juejin.cn?target=https%3A%2F%2Fdevelopers.weixin.qq.com%2Fminiprogram%2Fdev%2Fdevtools%2Fskeleton.html

          [10]

          https://www.shymean.com/article/%E4%BD%BF%E7%94%A8Chrome%E6%89%A9%E5%B1%95%E7%A8%8B%E5%BA%8F%E7%94%9F%E6%88%90%E7%BD%91%E9%A1%B5%E9%AA%A8%E6%9E%B6%E5%B1%8F: https://link.juejin.cn?target=https%3A%2F%2Fwww.shymean.com%2Farticle%2F%25E4%25BD%25BF%25E7%2594%25A8Chrome%25E6%2589%25A9%25E5%25B1%2595%25E7%25A8%258B%25E5%25BA%258F%25E7%2594%259F%25E6%2588%2590%25E7%25BD%2591%25E9%25A1%25B5%25E9%25AA%25A8%25E6%259E%25B6%25E5%25B1%258F

          [11]

          https://www.shymean.com/article/%E4%BB%8Evue-loader%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90CSS-Scoped%E7%9A%84%E5%AE%9E%E7%8E%B0: https://link.juejin.cn?target=https%3A%2F%2Fwww.shymean.com%2Farticle%2F%25E4%25BB%258Evue-loader%25E6%25BA%2590%25E7%25A0%2581%25E5%2588%2586%25E6%259E%2590CSS-Scoped%25E7%259A%2584%25E5%25AE%259E%25E7%258E%25B0

          [12]

          https://github.com/postcss/postcss/blob/main/docs/writing-a-plugin.md: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fpostcss%2Fpostcss%2Fblob%2Fmain%2Fdocs%2Fwriting-a-plugin.md

          [13]

          https://github.com/tangxiangmin/vite-plugin-auto-skeleton: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Ftangxiangmin%2Fvite-plugin-auto-skeleton



          感謝 清華大學(xué)出版社 對(duì)前端下午茶公眾號(hào)的支持,給本公眾號(hào)粉絲送出 《Vue.js3應(yīng)用開發(fā)與核心源碼解析》*3本 ,通過下面的抽獎(jiǎng)鏈接進(jìn)入抽獎(jiǎng)

          本書在講解Vue 3基礎(chǔ)內(nèi)容的基礎(chǔ)上也會(huì)圍繞這些新的變化和特性進(jìn)行講解和應(yīng)用,同時(shí)詳細(xì)介紹了Vue.js相關(guān)的生態(tài),包括Vuex、Vue Router、Vue Cli、Vue動(dòng)畫、Vite、Vue Cli工具等, 還涉及Vue服務(wù)端渲染(Node.js、Express)的相關(guān)內(nèi)容 。

          本書的一大特色是對(duì)Vue 3.x的核心源碼(響應(yīng)式原理、雙向綁定實(shí)現(xiàn)、虛擬DOM、原理和實(shí)現(xiàn))進(jìn)行了分析和講解,這不僅有利于讀者掌握Vue.js的設(shè)計(jì)思想,也能提升讀者對(duì)Vue.js框架的熟練度,同時(shí)Vue.js源碼知識(shí)也是近年來前端面試經(jīng)常被問到的內(nèi)容,學(xué)習(xí)和掌握這些內(nèi)容是非常必要的。 在本書的最后會(huì)應(yīng)用所講解的Vue.js相關(guān)內(nèi)容來開發(fā)一個(gè)實(shí)戰(zhàn)項(xiàng)目,以幫助讀者完整地體驗(yàn)從0到1的開發(fā)過程,還包括Vite工具的構(gòu)建配置和模擬請(qǐng)求后端數(shù)據(jù)等只會(huì)在真實(shí)項(xiàng)目中才會(huì)用的技能。

          感興趣的小伙伴也可以直接點(diǎn)擊下方購買鏈接購買:

          最后



          如果你覺得這篇內(nèi)容對(duì)你挺有啟發(fā),我想邀請(qǐng)你幫我個(gè)小忙:

          1. 點(diǎn)個(gè)「喜歡」或「在看」,讓更多的人也能看到這篇內(nèi)容

          2. 我組建了個(gè)氛圍非常好的前端群,里面有很多前端小伙伴,歡迎加我微信「sherlocked_93」拉你加群,一起交流和學(xué)習(xí)

          3. 關(guān)注公眾號(hào)「前端下午茶」,持續(xù)為你推送精選好文,也可以加我為好友,隨時(shí)聊騷。



          4e82d862c851df44f8995c5cab716381.webp點(diǎn)個(gè)喜歡支持我吧,在看就更好了


          瀏覽 122
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  最新国产大屌 | 欧美麻豆一区 | 免费看欧美一级片 | 激情网页 | 日本A黄色|