骨架屏属于锦上添花的功能,理想状态下开发者应该是不需要过分关注的,因此从开发体验上来看,手动编写骨架屏并不是很好的解决方案。因此本文主要研究另外一种骨架屏自动生成方案:通过vite插件自动注入骨架屏。

前端(vue)入门到精通课程:进入学习
API 文档、设计、调试、自动化测试一体化协作工具:点击使用

【相关推荐:vuejs视频教程】

骨架屏在SPA应用中有两个显著提升用户体验的作用

  • 避免页面初始化加载时的空白,体验介于SSR和完全等待页面初始化完成之间
  • 避免部分路由组件需要加载数据完成之后才渲染的空白

骨架屏会给用户一种内容已经返回的错觉,只要稍加等待就能看见完整内容了,因此骨架屏的定位就是真实内容准备好之前的替身。

之前研究过一种快速生成骨架屏的想法:使用Chrome扩展程序生成网页骨架屏,大概原理是通过Chrome扩展程序注入content.js修改页面DOM接口,最终导出带有骨架屏样式的HTML代码。

当时的这个想法并没有在生产中落地,最近在折腾用户体验相关的功能,发现还是有必要继续完善一下骨架屏相关的东西。

业界对于骨架屏的应用,也有好几种方案

  • 直接让设计师提供页面对应的骨架屏设计图
    • 导出svgbase64图片嵌入代码中,比较影响项目体积
    • 开发手动编写样式,工作量较大
  • 通过组件编写骨架屏
    • 诸如vue-content-loader、react-content-loader等组件,可以通过svg快速编写骨架屏内容,但输出的产物与真实页面有一定差距,不容易实现定制化骨架屏需求。
    • 一些组件库,如vant、varlet也提供了skeleton组件,通过配置参数的形式控制生成骨架屏内容,其缺点也是定制化程度较差
  • 自动生成骨架屏
    • page-skeleton-webpack-plugin等比较成熟的自动骨架屏方案,甚至有专门的UI界面来控制生成不同一面的骨架屏,缺点是生成的骨架屏代码较大,影响项目体积
    • 借助puppeteer无头浏览器渲染出页面对应的骨架屏内容,依赖较大
    • 借助Chrome扩展程序生成骨架屏内容,本质上和无头浏览器原理相似

骨架屏属于锦上添花的功能,理想状态下开发者应该是不需要过分关注的,因此从开发体验上来看,手动编写骨架屏并不是很好的解决方案。因此本文主要研究另外一种骨架屏自动生成方案:通过vite插件自动注入骨架屏。

先预览一下效果

点击生成骨架屏

1.gif

首屏访问

2.gif

vite插件生成骨架屏

参考

  • 前端智能化探索,骨架屏低代码自动生成方案实践
  • vite-plugin-vue-inspector这个插件的实现,将源代码的一些信息注入到页面上
  • 骨架屏 - 微信小程序开发文档,小程序开发者工具提供了类似快速生成当前页骨架屏的方案

首先需要探寻一种自动能够将设计图或真实页面转成骨架屏的方案。大概有下面几个思路

  • 通过编译工具,解析代码中编写的HTML模板,生成骨架屏
  • 从设计图来源出发,比如sketch、figma等,通过插件导出可以用骨架屏内容
  • 直接操作真实页面的DOM,然后生成骨架屏内容

利用现有样式

看起来第三种思路的实现成本最低,也最为熟悉。这也是使用Chrome扩展程序生成网页骨架屏这个方案中采用的方案,因此具体的实现细节这里不再赘述,简单总结一下

  • 在开发环境下,通过手动触发某个开关,开始生成某个页面对应的骨架屏内容
  • 将页面按节点类型拆分成不同区块
  • 支持自定义节点类型、忽略或隐藏节点
  • 最后导出的是一段HTML代码,复用原始页面的结构和CSS布局代码

核心API只有一个,传入对应的入口节点,输出转换后的骨架屏代码

const {name, content} = renderSkeleton(sel, defaultConfig)
登录后复制

比如下面这段结构

卡片标题
卡片内容卡片内容
登录后复制

生成的骨架屏代码是

卡片标题
卡片内容卡片内容
登录后复制

其中sk-blocksk-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展示的内容,无疑非常麻烦,现在来研究一下如何自动化解决这个问题。

前面提到,骨架屏主要应用在首屏渲染需要和路由页面切换时

  • SPA首屏渲染优化
  • 路由组件切换时的占位内容

接下来看看这两种场景下如何自动注入骨架屏代码

组件内渲染骨架屏

我们可以通过占位符来声明当前组件对应骨架屏代码的地方,比如

__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后生成的骨架屏代码,通过transformpre,我们就可以在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也保存了
  })
登录后复制

整理一下流程

  • 用户访问url
  • 根据页面url,加载对应的骨架屏代码,填充在根节点下
    • 如果是服务端预渲染,可以直接返回填充后的代码
    • 如果是客户端处理,可以通过document.write处理
  • 用户看见渲染的骨架屏内容
  • 初始化应用,加载页面数据,渲染出真实页面

开发者在点击生成当前页面的骨架屏时,保存的骨架屏代码,既可以用在路由组件切换时的骨架屏,也可以用在首屏渲染时的骨架屏,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,而在替换组件内的骨架屏占位符时再擦除scopeid

但不论通过何种方式保证两个环境下生成的scopeid 一致(甚至是通过修改插件源码的方式),可能也会存在旧版本的骨架屏携带的scopeid和新版本对应的scopeid 不一致的问题,即旧版本的class和新版本的class不一致。

要解决这个问题,除非在每次修改源码之后,都更新一下骨架屏,由于生成骨架屏这一步是手动的,这与自动化的目的背道而驰了。

因此,看起来利用原始类同步真实DOM的布局和尺寸,在scoped css中并不是一个十分完善的设计。

骨架屏代码质量

第二个不是那么重要的问题是生成的骨架屏代码,相较于手动编写,不够精简。

虽然在源代码中,骨架屏代码被占位符替代,但在编译阶段,骨架屏会编译到render函数中,可能造成代码体积较大,甚至影响页面性能的问题。

这个问题并不是一个阻塞性问题,可以后面考虑如何优化,比如骨架屏仍旧保留v-for等指令,组件可以正常编译,而首屏渲染的骨架屏需要通过自己解析生成完整的HTML代码。

解决方案

上面这两个问题的本质都是因为骨架屏生成方案导致的,跟后续保存骨架屏代码并自动替换并没有多大关系,因此我们只需要优化骨架屏生成方案即可。

既然依赖于原始样式生成的骨架屏代码存在这些缺点,有没有什么解决办法呢?

事实上,我们对于骨架屏是否更真实内容结构的还原程度并没有那么高的要求,也并没有要求骨架屏要跟业务代码一直保持一致,既然导出HTML骨架屏代码比较冗余和繁琐,我们可以换一换思路。

不使用scoped css

其他比较常用的CSS方案如css moudlecss-in-js或者是全局原子类css如tailwindwindicss等,如果输出的是纯粹的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替换成其他的选择器

3.gif

开发环境下的样式都是通过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等问题,看起来也不是一个明智的选择。

postcss插件

既然scopeid是通过postcss插入的,能不能在对应的样式规则里面加一个分组选择器,额外支持一下骨架屏的呢

比如

.card[data-v-xxx] {}
登录后复制

修改为

.card[data-v-xxx], .sk-wrap .card {}
登录后复制

这样,只要解决生产环境和开发环境scopeid不一致的问题就可以了。

编写postcss插件可以参考官方文档:编写一个postcss 插件。

vue/compuler-sfc源码中发现,scopedPlugin插件位于传入的postcssPlugins之后,而我们编写的插件需要位于scopedPlugin之后才行,

4.gif

如果不能修改源码,只有继续从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上面了,剩下要解决的就是优化生成骨架屏的效果和质量了,期待后续吧

(学习视频分享:web前端开发、编程基础视频)

以上就是聊聊怎么利用vite插件实现骨架屏自动化的详细内容,转载自php中文网

点赞(672) 打赏

评论列表 共有 0 条评论

暂无评论

微信小程序

微信扫一扫体验

立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部