前端(vue)入門到精通課程:進入學習
API 檔案、設計、偵錯、自動化測試一體化共同作業工具:
【相關推薦:】
骨架屏在SPA應用中有兩個顯著提升使用者體驗的作用
骨架屏會給使用者一種內容已經返回的錯覺,只要稍加等待就能看見完整內容了,因此骨架屏的定位就是真實內容準備好之前的替身。
之前研究過一種快速生成骨架屏的想法:使用Chrome擴充套件程式生成網頁骨架屏,大概原理是通過Chrome擴充套件程式注入content.js
修改頁面DOM介面,最終匯出帶有骨架屏樣式的HTML程式碼。
當時的這個想法並沒有在生產中落地,最近在折騰使用者體驗相關的功能,發現還是有必要繼續完善一下骨架屏相關的東西。
業界對於骨架屏的應用,也有好幾種方案
svg
或base64
圖片嵌入程式碼中,比較影響專案體積svg
快速編寫骨架屏內容,但輸出的產物與真實頁面有一定差距,不容易實現客製化化骨架屏需求。skeleton
元件,通過設定引數的形式控制生成骨架屏內容,其缺點也是客製化化程度較差 page-skeleton-webpack-plugin
等比較成熟的自動骨架屏方案,甚至有專門的UI介面來控制生成不同一面的骨架屏,缺點是生成的骨架屏程式碼較大,影響專案體積puppeteer
無頭瀏覽器渲染出頁面對應的骨架屏內容,依賴較大骨架屏屬於錦上添花的功能,理想狀態下開發者應該是不需要過分關注的,因此從開發體驗上來看,手動編寫骨架屏並不是很好的解決方案。因此本文主要研究另外一種骨架屏自動生成方案:通過vite外掛自動注入骨架屏。
先預覽一下效果
點選生成骨架屏
首屏存取
參考
首先需要探尋一種自動能夠將設計圖或真實頁面轉成骨架屏的方案。大概有下面幾個思路
看起來第三種思路的實現成本最低,也最為熟悉。這也是使用Chrome擴充套件程式生成網頁骨架屏這個方案中採用的方案,因此具體的實現細節這裡不再贅述,簡單總結一下
核心API只有一個,傳入對應的入口節點,輸出轉換後的骨架屏程式碼
const {name, content} = renderSkeleton(sel, defaultConfig)
登入後複製
比如下面這段結構
卡片標題
卡片內容卡片內容
登入後複製
生成的骨架屏程式碼是
卡片標題
卡片內容卡片內容
登入後複製
其中sk-block
、sk-text
等樣式類都是在生成時追加上去的,用於覆蓋原本的樣式,從而展示骨架屏的灰色背景,但同時保留原本的佈局樣式。
renderSkeleton
的呼叫時機由開發者自己控制,我們可以向頁面注入一個按鈕,點選時呼叫
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()
}
登入後複製
在得到骨架屏程式碼之後,在業務程式碼中通過一個loading
標誌位控制展示的是骨架屏還是真實內容
卡片標題
卡片內容卡片內容
卡片標題
卡片內容卡片內容
登入後複製
可以看到,v-if="loading"
標籤內部的程式碼,就是生成的骨架屏內容。需要注意的是,既然骨架屏與業務程式碼在一起,也會參與Vue的SFC編譯,因此骨架屏標籤上面的一些動態屬性如scopeid
等,需要移除。關於scopeid帶來的其他問題,後面的篇幅會提到,這也會影響整個renderSkeleton
的實現。
如果每次在呼叫renderSkeleton
拿到骨架屏程式碼之後,手動修改業務程式碼替換loading展示的內容,無疑非常麻煩,現在來研究一下如何自動化解決這個問題。
前面提到,骨架屏主要應用在首屏渲染需要和路由頁面切換時
接下來看看這兩種場景下如何自動注入骨架屏程式碼
我們可以通過預留位置來宣告當前元件對應骨架屏程式碼的地方,比如
__SKELETON_APP_CONTENT__
真實業務程式碼
登入後複製
在獲得骨架屏程式碼之後,將__SKELETON_APP_CONTENT__
這裡的內容替換成真實的骨架屏程式碼即可。
如何替換呢?vite外掛提供了一個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)
// 約定對應的骨架屏預留位置
let code = src.replace(/__SKELETON_(.*?)_CONTENT__/igm, function (match) {
return content
})
return {
code,
}
}
return src
},
} as Plugin
}
登入後複製
其中./skeleton.txt
中的內容,就是在呼叫renderSkeleton
後生成的骨架屏程式碼,通過transform
和pre
,我們就可以在vue外掛解析SFC之前,先將骨架屏預留位置替換成真正的程式碼,再參與後續的編譯流程。
這裡還需要解決一個問題:renderSkeleton
是在使用者端觸發的,而skeleton.txt
是在開發伺服器環境下的,需要有一個通訊的機制將使用者端生成的骨架屏程式碼傳送到專案目錄下面。
vite外掛提供了一個configureServer
勾點,用來設定vite開發伺服器,我們可以加一箇中介軟體,用來提供一個儲存骨架屏程式碼的介面
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)
// 骨架屏程式碼更新之後,重新啟動服務
server.restart()
res.end('success')
})
},
}
}
登入後複製
然後在renderSkeleton
之後,呼叫這個介面上傳生成的骨架屏程式碼即可
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!大功告成。梳理一下流程
開發者在某個時候手動呼叫__renderSkeleton
,就會自動生成當前頁面的骨架屏
將骨架屏程式碼傳送給vite介面,更新本地skeleton/content.json
中的骨架屏程式碼,
vite重新啟動服務後,重新觸發pre
佇列中的skeleton-content-component
外掛,替換骨架屏預留位置,注入骨架屏程式碼,完成整個骨架屏的插入流程。
整個過程中,開發者只需要完成下面兩步操作即可
宣告骨架屏在業務程式碼中的預留位置
點選按鈕,觸發生成骨架屏程式碼
路由切換的骨架屏大多應用在路由元件上,可以考慮進一步封裝,統一管理loading和骨架屏展示,這裡比較細節,就不再一一展開了。
骨架屏對於SPA首屏渲染優化,需要在應用初始化之前就開始渲染,即需要在id="app"
的元件內植入初始化的骨架屏程式碼
如果是伺服器端預渲染,可以直接返回填充後的程式碼;如果是使用者端處理,可以通過document.write
處理,我們這裡只考慮純SPA參照,由前端處理骨架屏的插入。
我們可以通過vite外掛提供的transformIndexHtml
勾點注入這段邏輯
function SkeletonApiPlugin() {
return {
name: 'skeleton-aip-plugin',
transformIndexHtml(html) {
let {content} = fs.readJsonSync(filename)
const code = `
`
return html.replace(/__SKELETON_CONTENT__/, code)
}
}
}
登入後複製
對應的index.html
程式碼為
__SKELETON_CONTENT__
登入後複製
根據使用者當前存取的url,讀取該url對應的骨架屏程式碼,然後通過document.write
寫入骨架屏程式碼。這裡可以看出,在生成骨架屏程式碼時,我們還需要保留對應頁面url的對映,甚至需要考慮動態化路由的匹配問題。這個也比較簡單,在提交到伺服器端儲存時,加個當前頁面的路徑引數就行了
const {name, content} = renderSkeleton(sel, defaultConfig)
// 如果是hash路由,就替換成fragment
const {pathname} = window.location
await sendContent({
name,
content,
pathname // 儲存骨架屏程式碼的時候順道把pathname也儲存了
})
登入後複製
整理一下流程
開發者在點選生成當前頁面的骨架屏時,儲存的骨架屏程式碼,既可以用在路由元件切換時的骨架屏,也可以用在首屏渲染時的骨架屏,Nice~
利用vite外掛注入骨架屏的程式碼,看起來是可行的,但在方案落地時,發現了一些需要解決的問題。
由於生成的骨架屏程式碼是依賴原始樣式的,
登入後複製
對應的骨架屏程式碼
登入後複製
其中的sk-block
只會新增一些灰色背景和動畫,至於整體的尺寸和佈局,還是card
這個類來控制的。
這麼設計的主要原因是:即使card
的尺寸佈局發生了變化,對應的骨架屏樣式也會一同更新。
但在某些場景下,原始樣式類無法生效,最具有代表性的問題就Vue專案的的scoped css
。
我們知道,vue-loader
、@vitejs/plugin-vue
等工具解析SFC檔案時,會為對應元件生成scopeId(參考之前的原始碼分析:從vue-loader原始碼分析CSS-Scoped的實現),然後通過postcss
外掛,通過組合選擇器實現了類似於css作用域的樣式表
.card[data-v-xxx] {}
登入後複製
我們的生成骨架屏的時機是在開發環境下進行的,這就導致在生產環境下,看到的骨架屏並沒有原始樣式類對應的尺寸和佈局。
下面是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。
因此對於同一個檔案而言,生產環境和非生產環境參與生產hash的引數是不一樣的,導致最後得到的scopeid 也不一樣。
對於元件內渲染骨架屏這種場景,我們也許可以不考慮scopeid,因為在SFC編譯之前,我們就已經通過transform
勾點注入了對應的骨架屏模板,對於SFC編譯器而言,骨架屏程式碼和業務程式碼都在同一個元件內,也就是說他們最後都會獲得相同的scopeid,這也是為什麼生成的骨架屏程式碼,要擦除HTML標籤上面的scopeid的原因。
但如果骨架屏依賴的外部樣式並不在同一個SFC檔案內,也會導致原始的骨架屏樣式不生效。
卡片標題
卡片內容卡片內容
登入後複製
此外,對於首屏渲染骨架屏這種場景,就不得不考慮scopeid了。如果骨架屏依賴的原始樣式是攜帶作用域的,那就必須要保證骨架屏程式碼與生產環境的樣式表一致
.card[data-v-xxx] {}
登入後複製
登入後複製
這樣,首屏渲染依賴的骨架屏和元件內渲染的骨架屏就產生了衝突,前者需要攜帶scopeid,而後者又需要擦除scopeid。
為了解決這個衝突,有兩種辦法
但不論通過何種方式保證兩個環境下生成的scopeid 一致(甚至是通過修改外掛原始碼的方式),可能也會存在舊版本的骨架屏攜帶的scopeid和新版本對應的scopeid 不一致的問題,即舊版本的class和新版本的class不一致。
要解決這個問題,除非在每次修改原始碼之後,都更新一下骨架屏,由於生成骨架屏這一步是手動的,這與自動化的目的背道而馳了。
因此,看起來利用原始類同步真實DOM的佈局和尺寸,在scoped css
中並不是一個十分完善的設計。
第二個不是那麼重要的問題是生成的骨架屏程式碼,相較於手動編寫,不夠精簡。
雖然在原始碼中,骨架屏程式碼被預留位置替代,但在編譯階段,骨架屏會編譯到render函數中,可能造成程式碼體積較大,甚至影響頁面效能的問題。
這個問題並不是一個阻塞性問題,可以後面考慮如何優化,比如骨架屏仍舊保留v-for等指令,元件可以正常編譯,而首屏渲染的骨架屏需要通過自己解析生成完整的HTML程式碼。
上面這兩個問題的本質都是因為骨架屏生成方案導致的,跟後續儲存骨架屏程式碼並自動替換並沒有多大關係,因此我們只需要優化骨架屏生成方案即可。
既然依賴於原始樣式生成的骨架屏程式碼存在這些缺點,有沒有什麼解決辦法呢?
事實上,我們對於骨架屏是否更真實內容結構的還原程度並沒有那麼高的要求,也並沒有要求骨架屏要跟業務程式碼一直保持一致,既然匯出HTML骨架屏程式碼比較冗餘和繁瑣,我們可以換一換思路。
其他比較常用的CSS方案如css moudle
、css-in-js
或者是全域性原子類css如tailwind
、windicss
等,如果輸出的是純粹的CSS程式碼,且生產環境和線上保持一致,理論上是不會出現scopeid這個問題的。
但Vue專案中,scoped css
方案應該佔據了半壁江山,加上我自己也比較喜歡scoped css
,因此這是一個繞不過去的問題。
第一種思路將骨架屏頁面儲存為圖片,這樣就不用再依賴原始樣式了。
大概思路就是:在解析當前頁面獲得骨架屏程式碼之後,再通過html2canvas
等工具,將已經渲染的HTML內容轉成canvas,再匯出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()
// 儲存作為骨架屏程式碼
}
登入後複製
這種通過圖片替代HTML骨架屏程式碼的優點在於相容性好(對應的頁面骨架屏甚至可以用在App或小程式中),容易遷移,不需要依賴專案程式碼中的樣式類。
但是html2canvas
生成的圖片也不是百分百還原UI,需要足夠純淨的程式碼原始結構才能生成符合要求的圖片。此外圖片也存在解析度和清晰度等問題。
也許又要回到最初的起點,讓設計大佬直接匯出一張SVG?(開個玩笑,我們還是要走自動化的道路
如果能夠找到骨架屏程式碼中每個標籤對應的class
在樣式表中定義的樣式,類似於Chrome dev tools中的Elements Styles
面板,我們就可以將這些樣式複製一份,然後將scopeid替換成其他的選擇器
開發環境下的樣式都是通過style標籤引入,因此可以拿到頁面上所有的樣式表物件,提取符合對應選擇器的樣式,包括.className
和.className[scopeId]
這兩類
寫一個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}`);
// 這裡沒有再考慮兩個類.A.B之類的組合樣式了,排列組合比較多
return acc
.concat(getClassStyle(`.${row}`))
.concat(getClassStyle(`.${row}[${scopeId}]`));
}, []);
};
const rules = parseNodeStyle(myBox);
console.log(rules);
登入後複製
這樣就可以得到每個節點在scoped css的樣式,然後替換成骨架屏依賴的樣式。
但現在要儲存的骨架屏程式碼的HTML結構之外,還需要儲存對應的那份CSS程式碼,十分繁瑣
能否像html2canvas的思路一樣,重新繪製一份骨架屏頁面出來呢
通過getComputedStyle
可以獲取骨架屏每個節點的計算樣式
const width = getComputedStyle(myBox,null).getPropertyValue('width');
登入後複製
複用頁面結構,把所有佈局和尺寸相關的屬性都列舉出來,一一獲取然後轉成行內樣式,看起來也是可行的。
但這個方案需要逐步嘗試完善對應的屬性列表,相當於復刻一下瀏覽器的佈局規則,工作量較大,此外還需要考慮rem、postcss等問題,看起來也不是一個明智的選擇。
既然scopeid是通過postcss插入的,能不能在對應的樣式規則裡面加一個分組選擇器,額外支援一下骨架屏的呢
比如
.card[data-v-xxx] {}
登入後複製
修改為
.card[data-v-xxx], .sk-wrap .card {}
登入後複製
這樣,只要解決生產環境和開發環境scopeid不一致的問題就可以了。
編寫postcss外掛可以參考官方檔案:編寫一個postcss 外掛。
在vue/compuler-sfc
原始碼中發現,scopedPlugin
外掛位於傳入的postcssPlugins
之後,而我們編寫的外掛需要位於scopedPlugin
之後才行,
如果不能修改原始碼,只有繼續從vite 外掛的transform
勾點入手了,在transform中手動執行postcss進行編譯
繼續編寫一個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
外掛後面執行,因為此時得到的內容才是經過vue-compiler編譯後的攜帶有scopeid 的樣式。
其中cssSkeletonGroupPlugin
是一個postcss外掛
import {Rule} from 'postcss'
const processedRules = new WeakSet()
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
登入後複製
這個外掛寫的比較粗糙,只考慮了常規的選擇器,並依次追加分組選擇器。測試一下
.test1[data-v-xxx] {}
登入後複製
成功編譯成了
.test1[data-v-xxx], .sk-wrap .test1 {}
登入後複製
這樣,只需要將骨架屏程式碼外邊包一層sk-wrap
,骨架屏中的樣式就可以正常生效了!
content && document.write('' +content+'')
登入後複製
看起來解決了一個困擾我很久的問題。
至此,一個藉助於Vite外掛實現自動骨架屏的方案就實現了,總結一下整體流程
首先初始化外掛
import {SkeletonPlaceholderPlugin, SkeletonApiPlugin} from '../src/plugins/vitePlugin'
export default defineConfig({
plugins: [
SkeletonPlaceholderPlugin(),
vue(),
SkeletonApiPlugin(),
],
build: {
cssCodeSplit: false
}
})
登入後複製
然後填寫預留位置,對於首屏渲染的骨架屏
__SKELETON_CONTENT__
登入後複製
對於元件內的骨架屏
__SKELETON_APP_CONTENT__
登入後複製
接著初始化使用者端觸發器,同時向頁面插入一個可以點選生成骨架屏的按鈕
import '../../src/style/skeleton.scss'
import {initInject} from '../../src/inject'
createApp(App).use(router).mount('#app')
// 開發環境下才注入
if (import.meta.env.DEV) {
setTimeout(initInject)
}
登入後複製
點選觸發器,自動將當前頁面轉換成骨架屏
通過HTTP將骨架屏程式碼傳送到外掛介面,通過fs寫入本地檔案./src/skeleton/content.json
中,然後自動重新啟動vite server
頁面內元件的預留位置會通過SkeletonPlaceholderPlugin
替換對應預留位置的骨架屏程式碼,loading生效時展示骨架屏
首屏渲染頁面時,通過location.pathname插入當前路徑對應的骨架屏程式碼,直接看見骨架屏程式碼
所有骨架屏依賴的當前樣式通過cssSkeletonGroupPlugin
解析,通過分組選擇器輸出在css檔案,不再依賴scopeid。
這樣,一個基本自動的骨架屏工具就整合到專案中,需要進行的手動工作包括
data-skeleton-root="APP"
data-skeleton-type
,客製化骨架屏節點整個專案比較依賴vite外掛開發知識,也參考了vite
、@vitejs/plugin-vue
、@vue/compile-sfc
等原始碼的實現細節。
所有Demo已經放在github上面了,剩下要解決的就是優化生成骨架屏的效果和品質了,期待後續吧
(學習視訊分享:、)
以上就是聊聊怎麼利用vite外掛實現骨架屏自動化的詳細內容,更多請關注TW511.COM其它相關文章!