骨架屏属于锦上添花的功能,理想状态下开发者应该是不需要过分关注的,因此从开发体验上来看,手动编写骨架屏并不是很好的解决方案。因此本文主要研究另外一种骨架屏自动生成方案:通过vite插件自动注入骨架屏。
【相关推荐:vuejs视频教程】
骨架屏在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)
比如下面这段结构
<div class="card" data-skeleton-type="block"> <div class="card_tt" data-skeleton-type="text">卡片标题</div> <div class="card_ct" data-skeleton-type="text">卡片内容卡片内容</div> </div>
生成的骨架屏代码是
<div class="sk-block card"> <div class="sk-text card_tt">卡片标题</div> <div class="sk-text card_ct">卡片内容卡片内容</div> </div>
其中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
标志位控制展示的是骨架屏还是真实内容
<script setup> 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> <div v-if="loading"> <!--这里的都是骨架屏代码--> <div class="sk-block card" v-for="item in 5" :key="item"> <div class="sk-text card_tt">卡片标题</div> <div class="sk-text card_ct">卡片内容卡片内容</div> </div> </div> <div v-else class="card-list card-list-knowledge" data-skeleton-root="APP" data-skeleton-type="list"> <div v-for="item in list" :key="item" data-skeleton-type="block"> <div>卡片标题</div> <div>卡片内容卡片内容</div> </div> </div> </div> </template> <style scoped> // 相关的样式 </style>
可以看到,v-if="loading"
标签内部的代码,就是生成的骨架屏内容。需要注意的是,既然骨架屏与业务代码在一起,也会参与Vue的SFC编译,因此骨架屏标签上面的一些动态属性如scopeid
等,需要移除。关于scopeid带来的其他问题,后面的篇幅会提到,这也会影响整个renderSkeleton
的实现。
如果每次在调用renderSkeleton
拿到骨架屏代码之后,手动修改业务代码替换loading展示的内容,无疑非常麻烦,现在来研究一下如何自动化解决这个问题。
前面提到,骨架屏主要应用在首屏渲染需要和路由页面切换时
接下来看看这两种场景下如何自动注入骨架屏代码
我们可以通过占位符来声明当前组件对应骨架屏代码的地方,比如
<div v-if="loading">__SKELETON_APP_CONTENT__</div> <div v-else>真实业务代码</div>
在获得骨架屏代码之后,将__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 = ` <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) } } }
对应的index.html
代码为
<div id="app">__SKELETON_CONTENT__</div>
根据用户当前访问的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插件注入骨架屏的代码,看起来是可行的,但在方案落地时,发现了一些需要解决的问题。
由于生成的骨架屏代码是依赖原始样式的,
<div class="card" data-skeleton-type="block"></div>
对应的骨架屏代码
<div class="sk-block card"></div>
其中的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文件内,也会导致原始的骨架屏样式不生效。
<template> <div> <div v-if="loading"> <div class="sk-block card" v-for="item in 5" :key="item"> <div class="sk-text card_tt">卡片标题</div> <div class="sk-text card_ct">卡片内容卡片内容</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 scoped> // card 样式不在这个SFC下面,但是插入的骨架屏代码却在这个SFC文件下 </style>
此外,对于首屏渲染骨架屏这种场景,就不得不考虑scopeid了。如果骨架屏依赖的原始样式是携带作用域的,那就必须要保证骨架屏代码与生产环境的样式表一致
.card[data-v-xxx] {}
<div id="app"> <div class="sk-block card" data-v-xxx></div> </div>
这样,首屏渲染依赖的骨架屏和组件内渲染的骨架屏就产生了冲突,前者需要携带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() // 保存<img src="${imgData}" alt="">作为骨架屏代码 }
这种通过图片替代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<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
这个插件写的比较粗糙,只考虑了常规的选择器,并依次追加分组选择器。测试一下
.test1[data-v-xxx] {}
成功编译成了
.test1[data-v-xxx], .sk-wrap .test1 {}
这样,只需要将骨架屏代码外边包一层sk-wrap
,骨架屏中的样式就可以正常生效了!
content && document.write('<div class="${wrapSelector.substr(1)}">' +content+'</div>')
看起来解决了一个困扰我很久的问题。
至此,一个借助于Vite插件实现自动骨架屏的方案就实现了,总结一下整体流程
首先初始化插件
import {SkeletonPlaceholderPlugin, SkeletonApiPlugin} from '../src/plugins/vitePlugin' export default defineConfig({ plugins: [ SkeletonPlaceholderPlugin(), vue(), SkeletonApiPlugin(), ], build: { cssCodeSplit: false } })
然后填写占位符,对于首屏渲染的骨架屏
<div id="app">__SKELETON_CONTENT__</div>
对于组件内的骨架屏
<!--其中占位符格式为`__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>
接着初始化客户端触发器,同时向页面插入一个可以点击生成骨架屏的按钮
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插件实现骨架屏自动化的详细内容。更多信息请关注PHP中文网其他相关文章!