关于前端:自动化生成骨架屏的技术方案设计与落地

8次阅读

共计 13211 个字符,预计需要花费 34 分钟才能阅读完成。

集体文章集:Nealyang/PersonalBlog

主笔公众号:全栈前端精选

背景

性能优化,缩小页面加载等待时间始终是前端畛域永恒的话题。现在大部分业务单干模式都是前后端拆散计划,便利性的同时也带来了十分多的弊病,比方 FCP 工夫显著减少(多了更多的 HTTP 申请往返的工夫耗费),这也就造成了咱们所说的白屏工夫较长,用户体验较差的状况。

当然,对此咱们能够有很多种优化伎俩,即使是此文介绍的骨架屏也只是用户体验的优化而已,对性能优化的数据没有任何晋升,然而其必要性,仍然是显而易见的。

本文次要介绍利用在拍卖源码工作台 BeeMa 架构中的骨架屏主动生成计划。有肯定的定制型,然而基本原理是相通的。

骨架屏 Skeleton

骨架屏其实就是在页面加载内容之前,先给用户展现出页面的大抵构造,再等拿到接口数据后在将内容替换,较传统的菊花 loading 成果会给用户一种“曾经渲染一部分进去了”的错觉,在成果上能够肯定水平的晋升用户体验。实质上就是视觉过渡的一个成果,以此来升高用户在期待时候的焦灼情绪。

计划调研

骨架屏技术计划上从实现上来说大抵能够三类:

  • 手动保护骨架屏的代码(HTMLcss or vueReact
  • 应用图片作为骨架屏
  • 主动生成骨架屏

对于前两种计划有肯定的保护老本比拟费人力,这里次要介绍下主动生成骨架屏的计划。

目前市面上次要应用的是饿了么开源的 webpack 插件:page-skeleton-webpack-plugin。它依据我的项目中不同的路由页面生成相应的骨架屏页面,并将骨架屏页面通过 webpack 打包到对应的动态路由页面中。这种形式将骨架屏代码与业务代码隔离,通过 webpack 注入的形式骨架屏代码(图片)注入到我的项目中。劣势非常明显然而毛病也不言而喻:webpack配置老本(还依赖html-webpack-plugin)。

技术计划

综合如上的技术调研,咱们还是决定采纳最低侵入业务代码且升高配置老本的骨架屏主动生成的计划。参考饿了么的设计思路,基于 BeeMa 架构和 vscode 插件来实现一个新的骨架屏生成计划。

设计准则

参考目前应用骨架屏的业务团队,咱们首先要明确下咱们的骨架屏须要具备的一些准则:

  • 骨架屏基于 BeeMa 架构
  • 主动生成
  • 保护成本低
  • 可配置
  • 还原度高(适配能力强)
  • 性能影响低
  • 反对用户二次订正

基于如上准则和 beema 架构 vscode 插件的个性,如下使咱们最终的技术方案设计:

  • 基于 BeeMa framework1 插件,提供骨架屏生成配置界面
  • 抉择基于 BeeMa 架构的页面,反对 SkeletonScreen height、ignoreHeight/width、通用头和背景色保留等
  • 基于 Puppeteer 获取预发页面(反对登陆)
  • 性能封装到 BeeMa Framework 插件中
  • 骨架屏只吐出 HTML 构造,款式基于用户主动以的 CSSInModel 的款式
  • 骨架屏款式,积淀到我的项目 global.scss中,防止行内款式反复体积增大

流程图

技术细节

校验 Puppeteer、


/**
 * 查看本地 puppeteer
 * @param localPath 本地门路
 */
export const checkLocalPuppeteer = (localPath: string): Promise<string> => {
  const extensionPuppeteerDir = 'mac-901912';
  return new Promise(async (resolve, reject) => {
    try {
      // /puppeteer/.local-chromium
      if (fse.existsSync(path.join(localPath, extensionPuppeteerDir))) {
        // 本地存在 mac-901912
        console.log('插件内存在 chromium');
        resolve(localPath);
      } else {
        // 本地不存在,找全局 node 中的 node_modules
        nodeExec('tnpm config get prefix', function (error, stdout) {
          // /Users/nealyang/.nvm/versions/node/v16.3.0
          if (stdout) {console.log('globalNpmPath:', stdout);
            stdout = stdout.replace(/[\r\n]/g, '').trim();
            let localPuppeteerNpmPath = '';
            if (fse.existsSync(path.join(stdout, 'node_modules', 'puppeteer'))) {
              // 未应用 nvm,则全局包就在 prefix 下的 node_modules 内
              localPuppeteerNpmPath = path.join(stdout, 'node_modules', 'puppeteer');
            }
            if (fse.existsSync(path.join(stdout, 'lib', 'node_modules', 'puppeteer'))) {
              // 应用 nvm,则全局包就在 prefix 下的 lib 下的 node_modules 内
              localPuppeteerNpmPath = path.join(stdout, 'lib', 'node_modules', 'puppeteer');
            }
            if (localPuppeteerNpmPath) {const globalPuppeteerPath = path.join(localPuppeteerNpmPath, '.local-chromium');
              if (fse.existsSync(globalPuppeteerPath)) {console.log('本地 puppeteer 查找胜利!');
                fse.copySync(globalPuppeteerPath, localPath);
                resolve(localPuppeteerNpmPath);
              } else {resolve('');
              }
            } else {resolve('');
            }
          } else {resolve('');
            return;
          }
        });
      }
    } catch (error: any) {showErrorMsg(error);
      resolve('');
    }
  });
};

webView 关上后,立刻校验本地 Puppeteer

  useEffect(() => {(async () => {const localPuppeteerPath = await callService('skeleton', 'checkLocalPuppeteerPath');
      if(localPuppeteerPath){setState("success");
        setValue(localPuppeteerPath);
      }else{setState('error')
      }
    })();}, []);

Puppeteer 装置到我的项目内,webpack 打包并不会解决 Chromium 的二进制文件,能够将 Chromium copy 到 vscode extension 的 build 中。

然而!!!导致 build 过大,下载插件会超时!!!所以只能思考将 Puppeteer 要求在用户本地全局装置。

puppeteer


/**
 * 获取骨架屏 HTML 内容
 * @param pageUrl 须要生成骨架屏的页面 url
 * @param cookies 登陆所需的 cookies
 * @param skeletonHeight 所需骨架屏最大高度(高度越大,生成的骨架屏 HTML 大小越大)* @param ignoreHeight 疏忽元素的最大高度(高度低于此则从骨架屏中删除)* @param ignoreWidth 疏忽元素的最大宽度(宽度低于此则从骨架屏中删除)* @param rootSelectId  beema 架构中 renderID,默认为 root
 * @param context vscode Extension context
 * @param progress 进度实例
 * @param totalProgress 总进度占比
 * @returns
 */
export const genSkeletonHtmlContent = (
  pageUrl: string,
  cookies: string = '[]',
  skeletonHeight: number = 800,
  ignoreHeight: number = 10,
  ignoreWidth: number = 10,
  rootId: string = 'root',
  retainNav: boolean,
  retainGradient: boolean,
  context: vscode.ExtensionContext,
  progress: vscode.Progress<{
    message?: string | undefined;
    increment?: number | undefined;
  }>,
  totalProgress: number = 30,
): Promise<string> => {const reportProgress = (percent: number, message = '骨架屏 HTML 生成中') => {progress.report({ increment: percent * totalProgress, message});
  };
  return new Promise(async (resolve, reject) => {
    try {
      let content = '';
      let url = pageUrl;
      if (skeletonHeight) {url = addParameterToURL(`skeletonHeight=${skeletonHeight}`, url);
      }
      if (ignoreHeight) {url = addParameterToURL(`ignoreHeight=${ignoreHeight}`, url);
      }
      if (ignoreWidth) {url = addParameterToURL(`ignoreWidth=${ignoreWidth}`, url);
      }
      if (rootId) {url = addParameterToURL(`rootId=${rootId}`, url);
      }
      if (isTrue(retainGradient)) {url = addParameterToURL(`retainGradient=${'true'}`, url);
      }
      if (isTrue(retainNav)) {url = addParameterToURL(`retainNav=${'true'}`, url);
      }
      const extensionPath = (context as vscode.ExtensionContext).extensionPath;
      const jsPath = path.join(extensionPath, 'dist', 'skeleton.js');
      const browser = await puppeteer.launch({
        headless: true,
        executablePath: path.join(
          extensionPath,
          '/mac-901912/chrome-mac/Chromium.app/Contents/MacOS/Chromium',
        ),
        // /Users/nealyang/Documents/code/work/beeDev/dev-works/extensions/devworks-beema/node_modules/puppeteer/.local-chromium/mac-901912/chrome-mac/Chromium.app/Contents/MacOS/Chromium
      });
      const page = await browser.newPage();
      reportProgress(0.2, '启动 BeeMa 内置浏览器');
      page.on('console', (msg: any) => console.log('PAGE LOG:', msg.text()));
      page.on('error', (msg: any) => console.log('PAGE ERR:', ...msg.args));
      await page.emulate(iPhone);
      if (cookies && Array.isArray(JSON.parse(cookies))) {await page.setCookie(...JSON.parse(cookies));
        reportProgress(0.4, '注入 cookies');
      }
      await page.goto(url, { waitUntil: 'networkidle2'});
      reportProgress(0.5, '关上对应页面');
      await sleep(2300);
      if (fse.existsSync(jsPath)) {const jsContent = fse.readFileSync(jsPath, { encoding: 'utf-8'});
        progress.report({increment: 50, message: '注入内置 JavaScript 脚本'});
        await page.addScriptTag({content: jsContent});
      }
      content = await page.content();
      content = content.replace(/<!---->/g, '');
      // fse.writeFileSync('/Users/nealyang/Documents/code/work/beeDev/dev-works/extensions/devworks-beema/src/index.html', content, { encoding: 'utf-8'})
      reportProgress(0.9, '获取页面 HTML 架构');
      await browser.close();
      resolve(getBodyContent(content));
    } catch (error: any) {showErrorMsg(error);
    }
  });
};

vscode 中的配置,须要写入到行将注入到 Chromium 中 p

age 加载的 js 中,这里采纳的计划是将配置信息写入到要关上页面的 url 的查问参数中

webView & vscode 通信(配置)

详见基于 monorepo 的 vscode 插件及其相干 packages 开发架构实际总结

vscode

export default (context: vscode.ExtensionContext) => () => {const { extensionPath} = context;
  let pageHelperPanel: vscode.WebviewPanel | undefined;
  const columnToShowIn = vscode.window.activeTextEditord
    ? vscode.window.activeTextEditor.viewColumn
    : undefined;

  if (pageHelperPanel) {pageHelperPanel.reveal(columnToShowIn);
  } else {
    pageHelperPanel = vscode.window.createWebviewPanel(
      'BeeDev',
      '骨架屏',
      columnToShowIn || vscode.ViewColumn.One,
      {
        enableScripts: true,
        retainContextWhenHidden: true,
      },
    );
  }
  pageHelperPanel.webview.html = getHtmlFroWebview(extensionPath, 'skeleton', false);
  pageHelperPanel.iconPath = vscode.Uri.parse(DEV_WORKS_ICON);
  pageHelperPanel.onDidDispose(() => {pageHelperPanel = undefined;},
    null,
    context.subscriptions,
  );
  connectService(pageHelperPanel, context, { services});
};

connectSeervice

export function connectService(
  webviewPanel: vscode.WebviewPanel,
  context: vscode.ExtensionContext,
  options: IConnectServiceOptions,
) {const { subscriptions} = context;
  const {webview} = webviewPanel;
  const {services} = options;
  webview.onDidReceiveMessage(async (message: IMessage) => {const { service, method, eventId, args} = message;
      const api = services && services[service] && services[service][method];
      console.log('onDidReceiveMessage', message, { api});
      if (api) {
        try {
          const fillApiArgLength = api.length - args.length;
          const newArgs =
            fillApiArgLength > 0 ? args.concat(Array(fillApiArgLength).fill(undefined)) : args;
          const result = await api(...newArgs, context, webviewPanel);

          console.log('invoke service result', result);
          webview.postMessage({eventId, result});
        } catch (err) {console.error('invoke service error', err);
          webview.postMessage({eventId, errorMessage: err.message});
        }
      } else {vscode.window.showErrorMessage(`invalid command ${message}`);
      }
    },
    undefined,
    subscriptions,
  );
}

Webview 中调用 callService

// @ts-ignore
export const vscode = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : null;

export const callService = function (service: string, method: string, ...args) {return new Promise((resolve, reject) => {const eventId = setTimeout(() => {});

    console.log(`WebView call vscode extension service:${service} ${method} ${eventId} ${args}`);

    const handler = (event) => {
      const msg = event.data;
      console.log(`webview receive vscode message:}`, msg);
      if (msg.eventId === eventId) {window.removeEventListener('message', handler);
        msg.errorMessage ? reject(new Error(msg.errorMessage)) : resolve(msg.result);
      }
    };

    // webview 承受 vscode 发来的音讯
    window.addEventListener('message', handler);

    // WebView 向 vscode 发送音讯
    vscode.postMessage({
      service,
      method,
      eventId,
      args,
    });
  });
};
 const localPuppeteerPath = await callService('skeleton', 'checkLocalPuppeteerPath');        

launchJs

本地 js 通过 rollup 打包

rollupConfig

export default {
  input: 'src/skeleton/scripts/index.js',
  output: {
    file: 'dist/skeleton.js',
    format: 'iife',
  },
};

文本处理

这里咱们对立将行内元素作为文本处理形式

import {addClass} from '../util';
import {SKELETON_TEXT_CLASS} from '../constants';

export default function (node) {let { lineHeight, fontSize} = getComputedStyle(node);
  if (lineHeight === 'normal') {lineHeight = parseFloat(fontSize) * 1.5;
    lineHeight = isNaN(lineHeight) ? '18px' : `${lineHeight}px`;
  }
  node.style.lineHeight = lineHeight;
  node.style.backgroundSize = `${lineHeight} ${lineHeight}`;
  addClass(node, SKELETON_TEXT_CLASS);
}

SKELETON_TEXT_CLASS的款式作为 beema 架构中的 global.scss 中。

const SKELETON_SCSS = `

// beema skeleton
.beema-skeleton-text-class {
  background-color: transparent !important;
  color: transparent !important;
  background-image: linear-gradient(transparent 20%, #e2e2e280 20%, #e2e2e280 80%, transparent 0%) !important;
}
.beema-skeleton-pseudo::before,
.beema-skeleton-pseudo::after {
  background: #f7f7f7 !important;
  background-image: none !important;
  color: transparent !important;
  border-color: transparent !important;
  border-radius: 0 !important;
}
`;

/**
 *
 * @param proPath 我的项目门路
 */
export const addSkeletonSCSS = (proPath: string) => {const globalScssPath = path.join(proPath, 'src', 'global.scss');
  if (fse.existsSync(globalScssPath)) {let fileContent = fse.readFileSync(globalScssPath, { encoding: 'utf-8'});
    if (fileContent.indexOf('beema-skeleton') === -1) {
      // 本地没有骨架屏的款式
      fileContent += SKELETON_SCSS;
      fse.writeFileSync(globalScssPath, fileContent, { encoding: 'utf-8'});
    }
  }
};

如果 global.scss 中没有相应骨架屏的款式 class,则主动注入进去

这是因为如果作为行内元素的话,生成的骨架屏代码会比拟大,反复代码多,这里是为了提及优化做的事件

图片解决

import {MAIN_COLOR, SMALLEST_BASE64} from '../constants';

import {setAttributes} from '../util';

function imgHandler(node) {const { width, height} = node.getBoundingClientRect();

  setAttributes(node, {
    width,
    height,
    src: SMALLEST_BASE64,
  });

  node.style.backgroundColor = MAIN_COLOR;
}

export default imgHandler;
export const SMALLEST_BASE64 =
  'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';

超链接解决

function aHandler(node) {node.href = 'javascript:void(0);';
}

export default aHandler;

伪元素解决

// Check the element pseudo-class to return the corresponding element and width
export const checkHasPseudoEle = (ele) => {if (!ele) return false;

  const beforeComputedStyle = getComputedStyle(ele, '::before');
  const beforeContent = beforeComputedStyle.getPropertyValue('content');
  const beforeWidth = parseFloat(beforeComputedStyle.getPropertyValue('width'), 10) || 0;
  const hasBefore = beforeContent && beforeContent !== 'none';

  const afterComputedStyle = getComputedStyle(ele, '::after');
  const afterContent = afterComputedStyle.getPropertyValue('content');
  const afterWidth = parseFloat(afterComputedStyle.getPropertyValue('width'), 10) || 0;
  const hasAfter = afterContent && afterContent !== 'none';

  const width = Math.max(beforeWidth, afterWidth);

  if (hasBefore || hasAfter) {return { hasBefore, hasAfter, ele, width};
  }
  return false;
};
import {checkHasPseudoEle, addClass} from '../util';

import {PSEUDO_CLASS} from '../constants';

function pseudoHandler(node) {if (!node.tagName) return;

  const pseudo = checkHasPseudoEle(node);

  if (!pseudo || !pseudo.ele) return;

  const {ele} = pseudo;
  addClass(ele, PSEUDO_CLASS);
}

export default pseudoHandler;

伪元素的款式代码曾经在下面 global.scss 中展现了

通用解决

  // 移除不须要的元素
  Array.from($$(REMOVE_TAGS.join(','))).forEach((ele) => removeElement(ele));

  // 移除容器外的所有 dom
  Array.from(document.body.childNodes).map((node) => {if (node.id !== ROOT_SELECTOR_ID) {removeElement(node);
    }
  });

  // 移除容器内非模块 element
  Array.from($$(`#${ROOT_SELECTOR_ID} .contentWrap`)).map((node) => {Array.from(node.childNodes).map((comp) => {if (comp.classList && Array.from(comp.classList).includes('compContainer')) {
        // 模块设置红色背景色
        comp.style.setProperty('background', '#fff', 'important');
      } else if (
        comp.classList &&
        Array.from(comp.classList).includes('headContainer') &&
        RETAIN_NAV
      ) {console.log('保留通用头');
      } else if (
        comp.classList &&
        Array.from(comp.classList).join().includes('gradient-bg') &&
        RETAIN_GRADIENT
      ) {console.log('保留了突变背景色');
      } else {removeElement(comp);
      }
    });
  });

  // 移除屏幕外的 node
  let totalHeight = 0;
  Array.from($$(`#${ROOT_SELECTOR_ID} .compContainer`)).map((node) => {const { height} = getComputedStyle(node);
    console.log(totalHeight);
    if (totalHeight > DEVICE_HEIGHT) {
      // DEVICE_HEIGHT 高度当前的 node 全副删除
      console.log(totalHeight);
      removeElement(node);
    }
    totalHeight += parseFloat(height);
  });

  // 移除 ignore 元素
  Array.from($$(`.${IGNORE_CLASS_NAME}`)).map(removeElement);

这里有个计算屏幕外的 node,也就是通过用户自定义的最大高度,取到 BeeMa 中每一个模块的高度,而后相加计算,如果超过这个高度,则后续的模块间接 remove 掉,一次来缩小生成出的 HTML 代码的大小问题

应用

根本应用

束缚

需全局装置 puppeteer@10.4.0 : tnpm i puppeteer@10.4.0 –g

全局装置后,插件会主动查找本地的 puppeteer 门路,如果找到插件,则进行 copy 到插件内的过程,否则须要用户本人手动填写门路 puppeteer 地址。(一旦查找胜利后,后续则无需填写地址,全局 puppeteer 包也可删除)

目前仅反对 beema 架构源码开发

留神⚠️

如果生成进去的代码片段较大,如下两种 优化计划

1、缩小骨架屏的高度(配置界面中最大高度)

2、在源码开发中,对于首屏代码然而非首屏展现的元素增加 beema-skeleton-ignore 的类名(例如轮播图的前面几张图甚至视频)

成果演示

一般成果

生成的代码大小:

带有通用头和突变背景色

拍卖通用设计元素,在页面新建空页面配置中即可看到配置

成果如下:

简单元素的页面成果展现

默认全屏骨架屏

生成代码大小

未做 skeleton-ignore 侵入式优化,略大🥺

另一种优化伎俩是减小生成骨架屏的高度!

半屏骨架屏

Fast 3Gno throttling的网络状况下

生成代码大小

后续优化

  • 减少通用头款式定制型
  • 反对骨架屏款式配置(色彩等)
  • 缩小生成代码的提及大小
  • 继续解决团队内应用反馈

参考资料

  • page-skeleton-webpack-plugin
  • awesome-skeleton
  • Building Skeleton Screens with CSS Custom Properties
  • Vue 页面骨架屏注入实际
  • <div id=”refer-anchor-1″>BeeMa</div>
正文完
 0