自動化生成骨架屏的技術方案設計與落地

isNealyang發表於2021-11-19

個人文章集:Nealyang/PersonalBlog

主筆公眾號:全棧前端精選

背景

效能優化,減少頁面載入等待時間一直是前端領域永恆的話題。如今大部分業務合作模式都是前後端分離方案,便利性的同時也帶來了非常多的弊端,比如 FCP 時間顯著增加(多了更多的 HTTP 請求往返的時間消耗),這也就造成了我們所說的白屏時間較長,使用者體驗較差的情況。

當然,對此我們可以有很多種優化手段,即便是此文介紹的骨架屏也只是使用者體驗的優化而已,對效能優化的資料沒有任何提升,但是其必要性,依然是不言而喻的。

本文主要介紹應用在拍賣原始碼工作臺BeeMa 架構中的骨架屏自動生成方案。有一定的定製型,但是基本原理是相通的。

骨架屏 Skeleton

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 的查詢引數中

scriptIndex

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 打包

src

rollupConfig

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

addScriptTag

文字處理

這裡我們統一將行內元素作為文字處理方式
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 程式碼的大小問題

使用

基本使用

beema

使用

約束

需全域性安裝 puppeteer@10.4.0 : tnpm i puppeteer@10.4.0 --g

local Puppeteer

全域性安裝後,外掛會自動查詢本地的 puppeteer 路徑,如果找到外掛,則進行 copy 到外掛內的過程,否則需要使用者自己手動填寫路徑puppeteer地址。(一旦查詢成功後,後續則無需填寫地址,全域性 puppeteer 包也可刪除)

目前僅支援 beema 架構原始碼開發

VSCode 外掛

注意⚠️

如果生成出來的程式碼片段較大,如下兩種優化方案

1、減少骨架屏的高度(配置介面中最大高度)

2、在原始碼開發中,對於首屏程式碼但是非首屏展示的元素新增beema-skeleton-ignore的類名(例如輪播圖的後面幾張圖甚至視訊)

效果演示

普通效果

生成的程式碼大小:

5.37kb

帶有通用頭和漸變背景色

拍賣通用設計元素,在頁面新建空頁面配置中即可看到配置

通用配置

效果如下:

帶頭部和背景色

6.93

複雜元素的頁面效果展示

預設全屏骨架屏

fullSkeletonScreen

生成程式碼大小

20kb

未做 skeleton-ignore 侵入式優化,略大?

另一種優化手段是減小生成骨架屏的高度!

半屏骨架屏

半屏

Fast 3Gno throttling的網路情況下

生成程式碼大小

7kb

後續優化

  • 增加通用頭樣式定製型
  • 支援骨架屏樣式配置(顏色等)
  • 減少生成程式碼的提及大小
  • ...
  • 持續解決團隊內使用反饋

參考資料

相關文章