通过Chrome插件自动生成骨架屏

avatar
大前端 @阿里巴巴

文/墨筝

背景

目前大多数前端应用其渲染方式主要还是客户端渲染,渲染的大致过程如下图所示:

通过这个渲染过程我们可以看出,在js加载完成前如果页面HTML的body中没有dom元素存在,则页面会有一段时间的白屏。并且如果这段渲染内容还依赖于后端数据,那么白屏时间还会更长。目前业界针对这种白屏的处理主要有添加loading和添加骨架屏两种技术方案。

loading方案的主要优点在于技术实现简单,但是对于用户的加载体验仍然不够顺滑。骨架屏方案的主要优点是加载体验对于用户感知来说更加友好和流畅,但是具备更高的技术实现成本,通常需要开发者手动编写代码。关于什么是骨架屏以及为什么要使用骨架屏可以参考 medium 上的这篇文章(需要翻墙):medium.com/better-prog…

目前业界也有一些自动生成页面骨架屏的方案,其核心实现原理是在页面运行起来后,通过无头浏览器 puppeteer 在node.js服务中来运行该页面并获取其dom结构然后生成页面的骨架屏html与css。这种方案的缺点在于:

  • 难以对页面中某一个区块单独生成骨架屏,在目前大行其道的单页应用(spa)或者各种微前端框架的场景下就更难落地。
  • 存在比较高的工程化成本,需要部署和维护相应的node服务或者在本地调试服务中添加相应的webpack插件
  • puppeteer包本身是很大的,安装和更新过程非常耗时,如果是在本地调试过程中添加也会一定程度降低开发效率。

本篇文章介绍的自动生成页面骨架屏的方案主要通过Chrome插件来实现,使用体验上整体更加轻量化和定制化,能完美解决上述问题,可以先来看看具体效果。

效果展示

以我的个人GitHub主页为例

该Chrome插件在浏览器开发者工具栏中增加了一个skeleton的面板用于控制骨架屏生成,填写某个区块的样式选择器后即可为这个区块生成对应的骨架屏代码,并且可以在控制台中一键进行效果预览。

使用姿势

由于chrome插件的发布代价不菲,因此目前我暂且没有将其发布到chrome插件市场,开发者如果想要使用,可以通过clone我在GitHub上的代码仓库到本地,然后在本地安装扩展即可。步骤如下:

第一步,完成Chrome插件的本地安装

  1. clone插件仓库克隆(github.com/NealST/skel…)

    git clone github.com/NealST/skel…

2, 在Chrome地址栏中输入地址 chrome://extensions, 进入扩展程序管理页面

3,开启开发者模式,然后点击左上角的加载已解压的扩展程序按钮,选择chrome-skeleton-extension所在目录下的build文件夹,点击选择按钮之后便安装完成。

第二步,选择页面,打开控制台进入使用

还是以刚才的GitHub页面作为示例

第三步,选择元素,生成骨架屏,拷贝骨架屏代码

按照面板表单中提示的输入容器元素的选择器,然后点击生成按钮就可以生成该元素的骨架屏HTML和css,同时面板中同时也提供了react组件的封装。除了容器元素可以自由选择,你还可以定制骨架屏的颜色。

骨架屏生成之后你需要做的就是拷贝HTML和css代码,然后放入到该页面的HTML模板中. 或者直接使用其react组件。

实现原理

整个插件的实现原理其核心在于三个部分,分别是浏览器devtool中的面板开发,devtool面板与内容脚本(即插件的content-script)之间的通信以及通过内容脚本生成指定dom的骨架屏结构和样式。

Devtool面板的开发

Chrome插件提供了devtool 面板创建的API,你需要做的是为面板生成一个HTML进行渲染,核心代码如下所示:

// 创建 devtool 面板 
chrome.devtools.panels.create("Skeleton", "icon-34.png", "devtools-panel.html", (panel) => {
  panel.onShown.addListener(onPanelShown);
  panel.onHidden.addListener(onPanelHidden);
});

function onPanelShown () {
  chrome.runtime.sendMessage('skeleton-panel-shown');
}

function onPanelHidden() {
  chrome.runtime.sendMessage('skeleton-panel-hidden');
}

// 面板HTML内容的渲染
import React from 'react';
import { render } from 'react-dom';
import { processMessageFromBg, buildConnectionWithBg } from './utils'
import App from './app';

// 建立通信连接
const connection = buildConnectionWithBg();
connection.onMessage.addListener((message) => processMessageFromBg(message));

render(<App />, document.getElementById('app-container'));

// app.jsx的实现
import React, { useState } from 'react';
// 输入表单
import SkeletonForm from './components/skeleton-form';
// 骨架屏HTML展示
import SkeletonHtml from './components/skeleton-html';
// 骨架屏样式展示
import SkeletonCss from './components/skeleton-css';
import './app.css';
export default function() {
  const [ codeInfo, setCodeInfo ] = useState({
    html: '',
    css: '',
    isMobile: false
  });
  function getSkeletonCode(retCode) {
    console.log("code info", retCode);
    setCodeInfo(retCode);
  }

  return (
    <div className="devtools-panel">
      <div className="panel-header">
        <SkeletonForm getSkeletonCode={getSkeletonCode} />
      </div>
      <div className="panel-code">
        <div className="code-html">
          <SkeletonHtml code={codeInfo.html} isMobile={codeInfo.isMobile}/>
        </div>
        <div className="code-css">
          <SkeletonCss code={codeInfo.css} />
        </div>
      </div>
    </div>
  )
}

Devtool面板与内容脚本的通信

devtool面板与内容脚本之间的通信主要通过中间商Chrome插件的background来进行,background运行在Chrome后台,会贯穿插件的整个生命周期,且拥有最高的权限,几乎可以调用一切Chrome插件的API。通信方式的核心实现代码如下所示:

// devtool-panel.js 部分
let processFnMap = {};
// 建立与background页面的链接
export const buildConnectionWithBg = function () {
  const connection = chrome.runtime.connect({
    name: `skeleton-panel-${tabId}`,
  });
  connection.postMessage({
    type: "init",
    tabId,
  });
  return connection;
};

// 处理来自后台网页的响应
export const processMessageFromBg = function (message) {
  console.log("get message", message);
  const processFn = processFnMap[message.type];
  processFn && processFn(message.info);
};

// 与content脚本进行通信
export const sendMsgToContent = function (info, cb) {
  processFnMap[info.type] = processFnMap[info.type] || cb;
  chrome.runtime.sendMessage({
    tabId,
    isToContent: true,
    info,
  });
};

// background.js 部分
// 通过connections建立长连接
let connections = {};

chrome.runtime.onConnect.addListener(function (port) {
  var extensionListener = function (message, sender, sendResponse) {
    // 原始的连接事件不包含开发者工具网页的标签页标识符,
    // 所以我们需要显式发送它。
    const { type, tabId } = message || {};
    if (type == "init") {
      connections[tabId] = port;
      return;
    }
    
  };

  // 监听开发者工具网页发来的消息
  port.onMessage.addListener(extensionListener);

  port.onDisconnect.addListener(function (port) {
    port.onMessage.removeListener(extensionListener);
    var tabs = Object.keys(connections);
    for (var i = 0, len = tabs.length; i < len; i++) {
      if (connections[tabs[i]] == port) {
        delete connections[tabs[i]];
        break;
      }
    }
  });
});

// 接收消息
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
  console.log("request", request);
  const { isToContent, tabId, info = {} } = request;
  if (isToContent) {
    // 如果是传给内容脚本
    chrome.tabs.sendMessage(tabId, info, function (response) {
      console.log("response from content", response);
      const thePort = connections[tabId];
      thePort.postMessage({
        type: info.type,
        info: response
      });
    });
  }
  
  // 实现文本复制能力
  if (info.type === 'copy') {
    const copyTextarea = document.getElementById('app-textarea');
    copyTextarea.value = info.data;
    copyTextarea.select();
    document.execCommand( 'copy');
    const thePort = connections[tabId];
    thePort.postMessage({
      type: info.type,
      info: ''
    });
  }
});


// content-script.js部分
chrome.runtime.onMessage.addListener(async function(req, sender, sendRes) {
  switch (req.type) {
    case 'generate':
      const { containerId } = req.data;
      queryInfo = req.data;
      containerEl = document.querySelector(containerId);
      // 如果找不到元素,就返回null
      if (!containerEl) {
        sendRes(null);
        return
      }
      displayStyle = window.getComputedStyle(containerEl).display;
      clonedContainerEl = getClonedContainerEl(containerEl, containerId);
      await genSkeletonCss(clonedContainerEl, req.data);
      const { style, cleanedHtml } = await getHtmlAndStyle(clonedContainerEl);
      const isMobile = window.navigator.userAgent.toLowerCase().indexOf('mobile') > 0;
      skeletonInfo = {
        html: htmlFilter(cleanedHtml),
        css: style,
        isMobile
      };
      sendRes(skeletonInfo);
      break;
    case 'show':
      containerEl.style.display = 'none';
      clonedContainerEl.style.display = displayStyle;
      break;
    case 'hide':
      containerEl.style.display = displayStyle;
      clonedContainerEl.style.display = 'none';
      break;
    case 'query':
      sendRes({
        isInPreview: clonedContainerEl && clonedContainerEl.style.display !== 'none',
        queryInfo,
        skeletonInfo
      });
      break;
  }
});

ContentScript的骨架屏生成

Chrome插件的content-script可以获取并操作当前页面的dom结构,因此在获取到devtool面板发送过来的选择器信息后,所需要做的工作是根据选择器搜索到对应dom并遍历该dom节点下的子元素内容,根据dom节点的不同类型执行对应的骨架屏样式处理算法即可。核心代码如下所示:

import svgHandler from "./svg-handler";
import textHandler from "./text-handler";
import listHandler from "./list-handler";
import buttonHandler from "./button-handler";
import backgroundHandler from "./background-handler";
import imgHandler from "./img-handler";
import pseudosHandler from "./pseudos-handler";
import grayHandler from "./gray-handler";
import blockTextHandler from './block-text-handler';
import inputHandler from "./input-handler";
import {
  getComputedStyle,
  checkHasPseudoEle,
  checkHasBorder,
  checkHasTextDecoration,
  isBase64Img,
  $$,
  $,
  checkIsTextEl,
  checkIsBlockEl
} from "./utils";
import { DISPLAY_NONE, EXT_REG, GRADIENT_REG, MOCK_TEXT_ID, Node, DEFAULT_COLOR } from "./constants";
import { transparent, removeElement, styleCache } from "./dom-action";

// 查找并处理元素
function traverse(containerEl, options) {
  const { excludes = [], cssUnit = "px", containerId, color } = options;
  const themeColor = color || DEFAULT_COLOR;
  const excludesEle =
    excludes && excludes.length ? Array.from($$(excludes.join(","))) : [];
  const svg = {
    color: themeColor,
    shape: "circle",
    shapeOpposite: [],
  };
  const text = themeColor;
  const button = {
    color: themeColor
  };
  const image = {
    shape: "rect",
    color: themeColor,
    shapeOpposite: [],
  };
  const pseudo = {
    color: themeColor,
    shape: "circle",
    shapeOpposite: [],
  };
  const decimal = 4;
  const texts = [];
  const buttons = [];
  const hasImageBackEles = [];
  let toRemove = [];
  const imgs = [];
  const svgs = [];
  const inputs = [];
  const pseudos = [];
  const gradientBackEles = [];
  const grayBlocks = [];
  (function preTraverse(ele) {
    const styles = getComputedStyle(ele);
    const hasPseudoEle = checkHasPseudoEle(ele);
    if (
      !ele.classList.contains(`mz-sk-${containerId}-clone`) &&
      DISPLAY_NONE.test(ele.getAttribute("style"))
    ) {
      return toRemove.push(ele);
    }

    if (~excludesEle.indexOf(ele)) return false; // eslint-disable-line no-bitwise

    if (hasPseudoEle) {
      pseudos.push(hasPseudoEle);
    }

    if (checkHasBorder(styles)) {
      ele.style.border = "none";
    }
    let styleAttr = ele.getAttribute("style");
    if (styleAttr) {
      if (/background-color/.test(styleAttr)) {
        styleAttr = styleAttr.replace(
          /background-color:([^;]+);/,
          "background-color:#fff;"
        );
        ele.setAttribute("style", styleAttr);
      }
      if (/background-image/.test(styleAttr)) {
        styleAttr = styleAttr.replace(/background-image:([^;]+);/, "");
        ele.setAttribute("style", styleAttr);
      }
    }

    if (ele.children && ele.children.length > 0 && /UL|OL|TBODY/.test(ele.tagName)) {
      listHandler(ele);
    }
    
    // 如果是块级文本元素
    if (checkIsTextEl(ele) && checkIsBlockEl(ele)) {
      blockTextHandler(ele)
    }

    if (ele.children && ele.children.length > 0) {
      Array.from(ele.children).forEach((child) => preTraverse(child));
    }

    // 将所有拥有 textChildNode 子元素的元素的文字颜色设置成背景色,这样就不会在显示文字了。
    if (
      ele.childNodes &&
      Array.from(ele.childNodes).some((n) => n.nodeType === Node.TEXT_NODE)
    ) {
      transparent(ele);
    }
    if (checkHasTextDecoration(styles)) {
      ele.style.textDecorationColor = TRANSPARENT;
    }
    // 隐藏所有 svg 元素
    if (ele.tagName === "svg") {
      return svgs.push(ele);
    }

    // 输入框元素
    if (ele.tagName === "INPUT") {
      return inputs.push(ele);
    }

    if (
      EXT_REG.test(styles.background) ||
      EXT_REG.test(styles.backgroundImage)
    ) {
      return hasImageBackEles.push(ele);
    }
    if (
      GRADIENT_REG.test(styles.background) ||
      GRADIENT_REG.test(styles.backgroundImage)
    ) {
      return gradientBackEles.push(ele);
    }
    if (ele.tagName === "IMG" || isBase64Img(ele) || ele.tagName === "FIGURE") {
      return imgs.push(ele);
    }
    if (
      ele.nodeType === Node.ELEMENT_NODE &&
      (ele.tagName === "BUTTON" ||
        (ele.tagName === "A" && ele.getAttribute("role") === "button"))
    ) {
      return buttons.push(ele);
    }
    if (checkIsTextEl(ele)) {
      return texts.push(ele);
    }
  })(containerEl);

  svgs.forEach((e) => svgHandler(e, svg, cssUnit, decimal));
  inputs.forEach(e => inputHandler(e, themeColor));
  texts.forEach((e) => textHandler(e, text, cssUnit, decimal));
  buttons.forEach((e) => buttonHandler(e, button));
  hasImageBackEles.forEach((e) => backgroundHandler(e, image));
  imgs.forEach((e) => imgHandler(e, image));
  pseudos.forEach((e) => pseudosHandler(e, pseudo));
  gradientBackEles.forEach((e) => backgroundHandler(e, image));
  grayBlocks.forEach((e) => grayHandler(e, button));
  // remove mock text wrapper
  const offScreenParagraph = $(`#${MOCK_TEXT_ID}`);
  if (offScreenParagraph && offScreenParagraph.parentNode) {
    toRemove.push(offScreenParagraph.parentNode);
  }
  toRemove.forEach((e) => removeElement(e));
}

结语

目前该插件已经在我们内部的工作台应用中完成落地实践,以肉眼可见的效果提升了页面的加载体验。欢迎你在项目中使用它,如果你对该插件或骨架屏有兴趣,或者在使用上有任何的问题和新需求的反馈都可以随时联系我。

公司邮箱:mozheng.sh@alibaba-inc.com

个人邮箱:m13710224694@163.com

ps:我们业务平台-体验技术星环团队正在广招前端和客户端的人才,团队和谐友爱,技术氛围浓厚(leader们倡导no code, no bb),业务和技术场景也都非常广阔,欢迎与我联系。