9
头图

Collection of personal articles: Nealyang/PersonalBlog

The main author of the official account: full stack front-end selection

background

Performance optimization and reducing page load waiting time have always been an eternal topic in the front-end field. Nowadays, most of the business cooperation models are front-end and back-end separation solutions. The convenience also brings a lot of drawbacks. For example, the FCP time is significantly increased (more HTTP request round-trip time consumption), which also causes What we call a situation where the white screen time is longer and the user experience is poor.

Of course, we can have many optimization methods for this. Even the skeleton screen introduced in this article is only the optimization of the user experience, and there is no improvement in the performance optimization data, but its necessity is still self-evident.

This article mainly introduces the automatic skeleton screen generation scheme used in the BeeMa architecture of the auction source code workbench. There are certain customized models, but the basic principles are the same.

Skeleton screen

Skeleton

The skeleton screen actually shows the user the general structure of the page before loading the content on the page, and then replaces the content after getting the interface data. The more traditional chrysanthemum loading effect will give the user a "part of it has been rendered". The illusion, in effect, can improve the user experience to a certain extent. In essence, it is an effect of visual transition to reduce the anxiety of the user while waiting.

Program research

In terms of implementation, the technical scheme of the skeleton screen can be roughly divided into three categories:

  • Manually maintain the code of the skeleton screen ( HTML , css or vue , React )
  • Use pictures as a skeleton screen
  • Automatically generate skeleton screen

For the first two schemes, there is a certain maintenance cost and labor-intensive. Here we mainly introduce the scheme of automatically generating skeleton screens.

webpack open source 061970c41eaeeb plug-in: page-skeleton-webpack-plugin is mainly used on the market. It generates corresponding skeleton screen pages according to different routing pages in the project, and packs the skeleton screen pages into the corresponding static routing pages webpack This method isolates the skeleton screen code from the business code, and injects the skeleton screen code (picture) into the project webpack The advantages are obvious but the disadvantages are also obvious: webpack configuration cost (also dependent on html-webpack-plugin ).

Technical solutions

Based on the above technical research, we still decided to adopt a scheme of automatic generation of skeleton screens with the lowest intrusion into business code and lower configuration costs. Refer to the design idea of BeeMa , based on the 061970c41eaf56 architecture and the vscode plug-in to implement a new skeleton screen generation scheme.

Design Principles

Referring to the current business team using skeleton screens, we must first clarify some principles that our skeleton screens need to have:

  • The skeleton screen is based on the BeeMa architecture
  • Automatic generated
  • Low maintenance cost
  • Configurable
  • High degree of reduction (strong adaptability)
  • Low performance impact
  • Support users for second revision

Based on the above principles and the characteristics of the beema architecture vscode plug-in, the following makes our final technical solution design:

  • Based on BeeMa framework 1 plug-in, providing skeleton screen generation configuration interface
  • Choose a page based on BeeMa architecture, support SkeletonScreen height, ignoreHeight/width , general header and background color retention, etc.
  • Get pre-release page based on Puppeteer
  • The function is encapsulated in the BeeMa Framework plug-in
  • The skeleton screen only spit out the HTML , and the style is based on the style CSSInModel
  • Skeleton screen style, settled into project global.scss , avoiding repeated volume increase of in-line style

flow chart

流程图

technical details

Verify 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('');
    }
  });
};

After webView is opened, verify the local Puppeteer immediately

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

Puppeteer is installed into the project. The webpack package does not process Chromium binary files. You can copy Chromium to the build of the vscode extension.

But! ! ! As a result, the build is too large and the download of the plug-in will time out! ! ! So you can only consider requiring Puppeteer to be installed locally and globally.

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);
    }
  });
};

The configuration in vscode needs to be written to be injected into Chromium.

In the js loaded by age, the solution used here is to write the configuration information into the query parameters of the url of the page to be opened

scriptIndex

webView & vscode communication (configuration)

For details, see based vscode plug-in and related packages development architecture practice summary

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,
  );
}

Call callService in Webview

// @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

Local js is packaged through rollup

src

rollupConfig

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

addScriptTag

Text processing

Here we uniformly treat inline elements as text processing methods
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 style as beema architecture global.scss in.

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' });
    }
  }
};

If global.scss is no style class of the corresponding skeleton screen in 061970c41eb7fd, it will be injected into it automatically

This is because if it is used as an in-line element, the generated skeleton screen code will be relatively large, and the code will be repeated. This is to mention the optimization done

Picture processing

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';

Hyperlink handling

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

export default aHandler;

Pseudo-element processing

// 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;
The style code of the pseudo-element has been shown in global.scss above

General handling

  // 移除不需要的元素
  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);
Here is a calculation of the node outside the screen, that is, through the user-defined maximum height, the height of each module in BeeMa is taken, and then added together. If it exceeds this height, the subsequent modules will be directly removed and reduced at one time. The size of the generated HTML code

use

Basic use

beema

使用

constraint

Need to install globally: tnpm i puppeteer@10.4.0 --g

local Puppeteer

After the global installation, the plug-in will automatically search for the local puppeteer path. If the plug-in is found, copy it to the plug-in process, otherwise the user needs to manually fill in the path puppeteer address. (Once the search is successful, there is no need to fill in the address later, and the global puppeteer package can also be deleted)

Currently only supports beema architecture source code development

VSCode 插件

Attention⚠️

If the code is generated out of the larger fragment, the following two optimization

1. Reduce the height of the skeleton screen (the maximum height in the configuration interface)

beema-skeleton-ignore the elements that are displayed on the first screen but not the first screen code (for example, the next few pictures of the carousel and even the video)

Effect demonstration

Normal effect

Generated code size:

5.37kb

With universal header and gradient background color

General design elements of the auction, you can see the configuration in the new empty page configuration on the page

通用配置

The effect is as follows:

带头部和背景色

6.93

Page effect display of complex elements

Default full screen skeleton screen

fullSkeletonScreen

Generated code size

20kb

skeleton-ignore not been invasively optimized, slightly larger 🥺

Another optimization method is to reduce the height of the generated skeleton screen!

Half screen skeleton screen

半屏

Fast 3G case of 061970c41ec056 and no throttling

Generated code size

7kb

Follow-up optimization

  • Add custom head styles
  • Support skeleton screen style configuration (color, etc.)
  • Reduce the mention size of generated code
  • ...
  • Continuously resolve usage feedback within the team

Reference


isNealyang
2k 声望346 粉丝