• 浅谈前端骨架屏方案


    在图片与前端体验优化中,最重要的莫过于「骨架屏」了,因为它和“首屏体验”息息相关。

    目前来说骨架屏基本上有两种方式:

    1. HTML + CSS:主流。基本是自己在项目中以侵入式方式围绕html“定制”;微信小程序的骨架屏生成方案本质上也是这种。
    2. 自动生成。利用一些手段在业务代码之外生成骨架屏,但最终还要依托架构插入到业务中。

    CSS实现骨架屏

    在近期的业务中,我遇到了一个场景:
    说明

    图中红色框内容在接口中分为三种级别。首先每个级别的活动都是固定的,后端只返回状态值。所以前端是三个数组。
    其次需要考虑一个问题:是默认展示第一级别,如果状态发生改变,再切换到第二/三级别?还是默认空白,等到接口拿到数据后根据状态展示级别?

    需要明确的是,这个页面并不只有这一个接口。而且这个接口的“优先级”是低级别的。

    后者效果展示:
    先空白拿到数据后再展示效果

    不管是从视觉上还是我想采用的技术手段上,我都认为这个场景应该选择前者 —— 这样的话,骨架屏就有了“基准”。我就不需要采用额外的元素去实现,只需要用伪元素覆盖默认文案并展示动效即可:

    /** 给所有需要展示骨架的元素都添加这行代码,变量默认为false,待接口拿到数据后变为true */
    :class="{'cate-skeleton': !showPOSTData}"
    
    • 1
    • 2
    .cate-skeleton {
        position: relative;
    
        &::after {
            content: "";
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: lightgray;
            background: linear-gradient(100deg, rgba(220, 220, 220, 0) 40%, rgba(255, 255, 255, 0.2) 50%, rgba(220, 220, 220, 0) 60%) gainsboro;
            background-size: 200% 100%;
            background-position-x: 150%;
            animation: 1.5s loading ease-in-out infinite;
            opacity: 1 !important;
            z-index: 2;
        }
    }
    
    @keyframes loading {
        to {
            background-position-x: -50%;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    这段代码中最重要的就是linear-gradient了。它其实就是将 background 分为三段。然后延长其 width ,并不断改变位置 position-x
    骨架屏展示数据

    因为有了骨架屏,用户就知道这段时间内页面并不是什么也没做。体验也就提升了。对我们来说,我们甚至可以把接口放到组件created里面处理(笔者所在组已然按照笔者之前提的“大小组件原则”封装业务代码) —— 这里还会有一个问题:如果网络和接口实在给力,而你什么也不做,可能会出现骨架闪动的效果。这可不是什么小问题,它甚至比“从空白到数据突然展示”更加令人难受。

    let res = await this.$http({
        //参数
    })
    if(!res.data && !res.result) {
        // 兜底
    }
    // 延时300ms,不然瞬间灰色闪动更难受了
    await this.promiseTimeout(300);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    为此,笔者决定故意延长loading的时间,给用户更好的体验:

    async promiseTimeout (time) {
        return await new Promise(function(resolve,reject){
            setTimeout(function(){
                console.log('骨架屏加载ing');
                resolve(time);
            },time);
        });
    },
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    setTimeout 微任务的异步和请求的异步不同(机制就不一样)。setTimeout 不能直接触发async-await

    node实现非侵入式骨架屏生成

    在复杂场景下,我们可以把业务和骨架屏分离。比如在某种身份下其实进来是B布局,如果你在开发业务时采用第一种方案的话要么骨架固定,要么CV两份 HTML 代码去书写样式。
    这好么?这不好。

    我们可以以页面为基准“自动”生成骨架屏,然后通过配置注入到项目源码中。
    这样就可以在页面生成之后再去对指定class/id进行骨架样式生成。对其余元素可以采取定制化生成,或是直接隐藏。

    这是一种“后处理”。

    既如此,我们应当要求:

    • 使用和维护成本低
    • 配置灵活
    • 还原度高
    • 尽量不影响加载性能

    node中的puppeteer给我们提供了很好的方案:通过 puppeteer 获取页面、做骨架处理、截屏或获取源码、默认采用 base64 输出。

    Puppeteer 是一个控制 headless Chrome 的 Node.js API 。它是一个 Node.js 库,通过 DevTools 协议提供了一个高级的 API 来控制 headless Chrome。它还可以配置为使用完整的(非 headless)Chrome。
    我们可以通过 puppeteer 操作网页:触发事件、截屏、爬取数据、检索 SPA 并生成预渲染内容(即 “SSR”)、甚至是创建一个能运行最新js特性的自动测试环境(浏览器)。

    npm install puppeteer
    
    • 1
    const puppeteer = require('puppeteer');
    
    (async () => {
      const browser = await puppeteer.launch();
      const page = await browser.newPage();
    
      await page.setViewport({width: 骨架屏宽, height: 骨架屏高});
    
      // 事件监听,可用于事件通信
      page.on('console', msg => console.log('PAGE LOG:', msg.text()));
      page.on('warning', msg => console.log('PAGE WARN:', JSON.stringify(msg)));
      page.on('error', msg => console.log('PAGE ERR:', ...msg.args));
      
      // waitUntil:load/domcontentload/networkidle0/networkidle2
      await page.goto('页面的url!!!', {waitUntil: 'networkidle2'});
    
      // 对打开的页面进行操作
    
      // 将页面截图,输出为 pdf 或 图片
      await page.pdf({path: 'hn.pdf', format: 'A4'});
      await page.screenshot({path: 'example.png'});
    
      await browser.close();
    })();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    这种方案简化的并不是代码层面 —— 当然,你也可以封装成可视化。我们的处理思路和上面大致相同 —— 因为不是操作原页面,这里直接替换即可。以 Img 为例:

    Array.from(document.body.querySelectorAll('img')).map(img => {
      img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
      img.style.backgroundColor = '#EEEEEE';
    });
    
    • 1
    • 2
    • 3
    • 4

    对于文字来说,也是如此:

    await page.$eval('.xxx/#xxx(按class/id查找)',
    	(el, value)=> el.setAttribute('style', value),
    	'backgroundImage: linear-gradient(to bottom, #070b21, rgba(7, 11, 33, 0.5))'
    )
    
    • 1
    • 2
    • 3
    • 4

    或插入指定文案:

    await page.$$eval('nav>ul>li>.wired-rendered',
    	nodes => nodes.map(n => {
    		n.innerHTML = `${n.innerHTML}`
    	// return n;
    	}))
    
    • 1
    • 2
    • 3
    • 4
    • 5

    因为骨架屏主要目标是“首屏”,我们就可以移除非首屏节点:

    function inViewPort(ele) {
        try {
          const rect = ele.getBoundingClientRect()
          return rect.top < window.innerHeight &&
            rect.left < window.innerWidth
    
        } catch (e) {
          return true;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    style也是如此:

    const styles = Array.from(document.querySelectorAll('style')).map(style => style.innerHTML || style.innerText);
    // 移除非首屏样式
    function handleStyles(styles, html) {
        const ast = cssTree.parse(styles);
        const dom = new JSDOM(html);
        const document = dom.window.document;
        const cleanedChildren = [];
        let index = 0;
        ast && ast.children && ast.children.map((style) => {
            let slectorExisted = false,
                selector;
            switch (style.prelude && style.prelude.type) {
                case 'Raw':
                    selector = style.prelude.value && style.prelude.value.replace(/,|\n/g, '');
                    slectorExisted = selectorExistedInHtml(selector, document);
                    break;
                case 'SelectorList':
                    style.prelude.children && style.prelude.children.map(child => {
                        const children = child && child.children;
                        selector = getSelector(children);
                        if (selectorExistedInHtml(selector, document)) {
                            slectorExisted = true;
                        }
                    });
                    break;
            }
            if (slectorExisted) {
                cleanedChildren.push(style);
            }
        });
        ast.children = cleanedChildren;
        let outputStyles = cssTree.generate(ast);
        outputStyles = outputStyles.replace(/},+/g, '}');
        return outputStyles;
    }
    
    function selectorExistedInHtml(selector, document) {
    
        if (!selector) {
          return false;
        }
    
        // 查询当前样式在 html 中是否用到
        let selectorResult, slectorExisted = false;
        try {
          selectorResult = document.querySelectorAll(selector);
    
        } catch (e) {
          console.log('selector query error: ' + selector);
        }
    
        if (selectorResult && selectorResult.length) {
          slectorExisted = true;
        }
    
        return slectorExisted;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
  • 相关阅读:
    安装semantic segmentation editor
    花一天时间做一个高质量飞机大战游戏,过万字Unity完整教程!漂亮学妹看了直呼666!
    Angular 应用开发里使用 ForRoot 解决 Lazy Loaded Module 里单例行为丢失的问题
    Maven配置
    C语言刷题(一)
    01,完全,多重,混合,分组背包相关题目
    PMP提分练习
    Spring系列18:Resource接口及内置实现
    iNFTnews | 佳士得推出风险投资部门,瞄准Web3和元宇宙产业
    【巧立名目】利用IDEA工具修改Maven多模块项目标识包名全过程
  • 原文地址:https://blog.csdn.net/qq_43624878/article/details/126777479