Skeleton Page is when you open a mobile Web Page and show the user a rough look of the Page before the Page is parsed and the data loaded. In the skeleton page, images, text, and ICONS are displayed in gray rectangular blocks, and the user can sense the basic CSS style and layout of the page that is about to load before the actual page is displayed. The skeleton page of Ele. me mobile Web terminal is shown in the picture.

This article will show you a way to automate the generation of the skeleton page shown above, which can be integrated into your development process.

Why do we need skeleton pages

First we break this down into two sub-questions. First, why do we need skeleton pages?

  • As mentioned above, skeleton pages show the user the CSS style and layout of the page before the page is actually parsed and the application is launched, and through the light and shade changes of the skeleton page, the user is informed that the page is trying to load, and the user feels that the page is loading faster than before. When the application starts and the data is retrieved, the skeleton page is replaced with a page rendered with real data.
  • Before the skeleton page appeared, many applications used Loading ICONS to tell users that data was being loaded and to wait before real data was obtained. However, users could not perceive the upcoming page or determine the waiting time. The monotonous Loading ICONS caused aesthetic fatigue. The long wait causes users to have waiting anxiety. According to the Research of Google Research, 53% of users choose to close the Web page or application after waiting for 3s loading, resulting in user loss. The skeleton page gives the user the impression that the data is already loaded but still in the rendering process, which is why the user feels the page is loading faster than before. At the same time, because the skeleton page and the real page are exactly the same style layout, in the user’s visual perception, the skeleton page can smoothly switch to the real data rendering page. If the Loading icon is used to switch to the final page, the user will feel awkward.
  • Looking at the current front-end framework, it is alreadyReact,Vue,AngularAs a result, most front-end applications on the market are based on these three frameworks or libraries and the corresponding ecosystem, and the front-end project of Ele. me is no exception, for exampleThe mobile end H5Is usedVueLibrary. One common feature of all three frameworks is that they are JS-driven, and the page does not display any content until the JS code has been parsed, which is called the white screen. Users hate to see a blank screen that shows nothing, and they are likely to suspect that something is wrong with the network or application. For Vue, when bootstrap is applied, Vue passes state values in data and computed in the componentObject.definePropertyMethods are converted to set and GET access properties to listen for data changes. All of this is done at startup time, which inevitably results in a slower page startup than a non-JS-driven (such as jQuery) page.

The second sub-question is why you need to automate the generation of skeleton pages.

In fact, the reason is very simple, programmers are “lazy”, no programmer is willing to do the same or similar work repeatedly, “not even for money”. Writing skeleton pages by hand is exactly that, repetition without innovation. Since the skeleton page style and layout are the same as the real data-rendered page, but without the padding of images, text, and images, why not reuse the page style and layout? Why not use tools to automate skeleton pages from real pages? In this way to save their own time at the same time, but also for the company to save human costs, why not!

Generate skeleton pages using puppeteer

The basic scheme for generating skeleton pages

through
puppeteerOn the server side
headlessChrome open development need to generate the skeleton of the page to page, after waiting for the page loading rendering, on the premise of keep the page layout style, through to the page elements to omit, or add to existing elements covered by cascading style, so that, under without changing the page layout, hidden pictures, words and images show, through style cover, Make it appear as a gray block. Then extract the modified HTML and CSS styles, and you have a skeleton page.

While the above description of how skeleton pages are generated may sound vague, here are some code snippets from the Page-skeleton-webpack-Plugin (PSWP) to illustrate skeleton page generation. PSWP is an internal skeleton page generating tool for ele. me big front end. The internal team has been using it and the project is under active development.

Before we go into the details of the skeleton generation page, let’s take a look at puppeteer, as it’s described on GitHub.


Puppeteer is a Node library which provides a high-level API to control
headless Chrome or Chromium over the
DevTools Protocol. It can also be configured to use full (non-headless) Chrome or Chromium.

If you are still new to Puppeteer, it is recommended that you check out the Puppeteer API before continuing with this article. I will be waiting for you to return to ⏰.

Step 1: Launch a page through Puppeteer

Before the Skeleton page can be generated, a page needs to be launched through Puppeteer. In PSWP, a Skeleton class encapsulates the methods used to generate Skeleton pages. The code is as follows:

// ./skeleton.js class Skeleton { constructor(options = {}) { this.options = options this.browser = null this.page = Null} async initPage() {// Async makeSkeleton() {// Async genHtml(url) {// Async genHtml(url) {// Get HTML and CSS}} module.exports = Skeleton from the built Skeleton pageCopy the code

Before launching the page, we can configure the mobile device that we want to generate the skeleton page, optionally through DeviceDescriptors in the Puppeteer project. The default for PSWP is iPhone 6 Plus, but you can choose it based on which device your target users use the most. PSWP currently supports only a single device configuration. The startup page code is as follows:

// ./skeleton.js async initPage() { const { device, headless, debug } = this.options const browser = await puppeteer.launch({ headless }) const page = await browser.newPage() // Emulate (devices[device]) this.browser = browser this.page = Page if (debug) {page. On ('console', (... args) => { // do something with args }) } return this.page }Copy the code

As you can see from the code above, we can select whether to open Headless Chrome by passing in the Headless configuration and the Debug configuration for whether to print error messages on the terminal.

Step 2: Build the skeleton page

In this step, our main work is to open the development page to CSS style overlay, add and subtract elements to generate the skeleton page. Thanks to Puppeteer for providing a nice API called Page.addScriptTag, which inserts JavaScript code into the previous page using Script tags, allowing us to call method properties directly from the BOM object. The code is as follows:

// ./skeleton.js async makeSkeleton() { const { defer } = this.options const content = await genScriptContent() // Insert the JS code of the production skeleton page into the page await this.page. AddScriptTag ({content}) await sleep(defer) await this.page. Evaluate (async) (options) => { const { genSkeleton } = Skeleton genSkeleton(options) }, this.options) }Copy the code

The genScriptContent method is used to retrieve the JS source code inserted into the page. Note also that there is a defer configuration in THE PSWP that tells the Puppeteer how long to wait after opening the page, because after opening the page in development, Some of the content in the page is not actually loaded, and if the skeleton page is generated before then, it is very likely that the resulting skeleton page will not look like the real page. Failed to generate the skeleton page.

In fact, the core of the entire skeleton page is inserted into the PAGE of the JS script, the author will focus on how to build the skeleton page

Skeleton page generation occurs primarily in the genSkeleton method, which is written in the script to insert the page and bound to the Window object so that we can call it directly.

In the scheme of generating skeleton page, the page is first divided into different blocks according to different elements, and the block rules are as follows:

  • Text blocks: DOM elements that contain a unique text node are considered text blocks
  • Image blocks: An IMG element or an element with a background image is considered an image block
  • SVG blocks: SVG elements are treated as SVG blocks
  • Pseudo element piece:::before::afterPseudo-element blocks are treated as pseudo-element blocks because they are also displayed on the page
  • Button to block: BUTTON, INPUT [type= BUTTON], A [role= BUTTON] and other elements are treated as BUTTON blocksrole=buttonIf you need to treat an ELEMENT as A button, add one to itrole=buttonIs necessary to meet front-end accessibility requirements.

Element is divided into different blocks, the next step is to deal with these blocks respectively, including the addition and subtraction of the element and style, only one purpose, is to convert these blocks to frame the style of the page, which is in the illustration to the right of the appearance, due to the limited space, this article only the text and image blocks to show the generated by a specific algorithm frame style.

Text block generation algorithm

In order to generate the gray stripe of the text block, first we need to know the height of the text block so that we can draw the height of the gray stripe. The height of the gray stripe in the text block can be obtained by fontSize. Also, if the text block is generated from multiple lines of text, the text block should also be multiple lines. We also need to know the line spacing in the text block, which fortunately is also easy to get.

Lineheight-fontsize is the line spacing


In multi-line text, to draw gray stripes, we also need to know how many lines of text there are, so that we know how many gray stripes we need to draw. The number of lines of text can be calculated by the following formula:

contentHeight = ClientHeight – paddingTop – paddingBottom


lineNumber = contentHeight / lineHeight

In the formula above, we first calculate the height of the text block content by subtracting paddingTop and paddingBottom from the ClientHeight, The ClientHeight is obtained via the getBoundingClientRect API. PaddingTop, paddingBottom, and lineHeight can be obtained via getComputedStyle. Finally, we can calculate how many lines of text there are in a block of text by dividing contentHeight by lineHeight.

With line spacing, line height, and the number of lines in the text block we can draw our gray stripes.

In the book, there is a special article about how to create a stripe background through linear gradient. In this article, the gray stripe in the text block is inspired by CSS Secrets. Use a linear gradient to draw gray text stripes. The code is as follows:

const comStyle = window.getComputedStyle(ele) const text = ele.textContent let { lineHeight, paddingTop, paddingRight, paddingBottom, paddingLeft, position: pos, fontSize, textAlign, wordSpacing, WordBreak} = comStyle const lineCount = (height-parseint (paddingTop, 10) - parseInt(paddingBottom, 10)) / parseInt(lineHeight, 10) | 0 let textHeightRatio = parseInt(fontSize, 10) / parseInt(lineHeight, 10) Object.assign(ele.style, { backgroundImage: `linear-gradient( transparent ${(1 - textHeightRatio) / 2 * 100}%, ${color} 0%, ${color} ${((1 - textHeightRatio) / 2 + textHeightRatio) * 100}%, transparent 0%)`, backgroundOrigin: 'content-box', backgroundSize: `100% ${lineHeight}`, backgroundClip: 'content-box', backgroundColor: 'transparent', position, color: 'transparent', backgroundRepeat: 'repeat-y' })Copy the code

As mentioned above, we first calculate the number of lines lineCount, and then calculate the ratio of the text to the total lineHeight using fontSize and lineHeight, textHeightRatio, so that we know the gradient of the grey stripe. As @lea Verou said:

From: CSS Secrets


“If a color stop has a position that is less than the specied position of any color stop before it in the list, Set its position to be equal to the largest speci Ed position of any color stop before it.”


— CSS Images Level 3 (
W3.org/TR/css3-ima…)

That is, in a linear gradient, if we set the starting point of the linear gradient to be less than the starting point of the previous color, or 0 %, then the linear gradient will disappear and will be replaced by two distinct colored stripes, that is, there will be no linear gradient.

When we draw the text block, the backgroundSize width is 100% and the height is lineHeight, that is, the gray stripe plus the transparent stripe is lineHeight. Although we have drawn the gray stripe, our text is still displayed. Until the final skeleton style effect appears, we still need to hide the text and set color: ‘transparent’ so that our text matches the background color and the final gray stripe appears.

When dealing with a single line of text, the width of the text is not the width of the entire line. Therefore, for a single line of text, we also need to calculate the width of the text, and then set the width of the gray stripe to the width of the text, so that the skeleton style can be more similar to the text style.

The code for calculating the text width is as follows:

const getTextWidth = (text, style) => { let offScreenParagraph = document.querySelector(`#${MOCK_TEXT_ID}`) if (! offScreenParagraph) { const wrapper = document.createElement('p') offScreenParagraph = document.createElement('span') Object.assign(wrapper.style, { width: '10000px' }) offScreenParagraph.id = MOCK_TEXT_ID wrapper.appendChild(offScreenParagraph) document.body.appendChild(wrapper) } Object.assign(offScreenParagraph.style, style) offScreenParagraph.textContent = text return offScreenParagraph.getBoundingClientRect().width }Copy the code

Here’s a trick: We create a SPAN element on the page, apply the style of the original text to the SPAN element, and place the text content inside the SPAN element so that the width of the SPAN element is the width of the text. Finally, we draw the width of the gray stripe according to the width of the text.

const textWidth = getTextWidth(text, { fontSize, lineHeight, wordBreak, wordSpacing })
const textWidthPercent = textWidth / (width - parseInt(paddingRight, 10) - parseInt(paddingLeft, 10))
ele.style.backgroundSize = `${textWidthPercent * 100}% ${px2rem(lineHeight)}`
switch (textAlign) {
   case 'left': // do nothing
      break
   case 'center':
      ele.style.backgroundPositionX = '50%'
      break
   case 'right':
      ele.style.backgroundPositionX = '100%'
      break
 }
Copy the code

Based on the text width, calculate a ratio of the text to the content width of the entire element. Based on this ratio, we can set a width of the gray stripe. There is another special treatment, we need to set the X-axis offset of the background stripe according to the different textAlign, so that the gray stripe is exactly the same as the original text. This is the entire algorithm for drawing the entire text block, leaving out some details, such as the REM units we use in the real project, so we also need to convert PX to REM. This is the Px2REM method in the code above.

Image block generation algorithm

The drawing of picture block is much simpler than that of text block, but in the process of making the scheme, we also stepped on some pits, here simply share the experience of pit mining.

The original plan was to replace the IMG element with a DIV element, and then set the background of the DIV element to gray. The width of the DIV element was higher than the width and height of the IMG element. This plan had a serious drawback. The styles originally applied to the IMG element through the element selector do not apply to the DIV element, resulting in the skeleton of the final image block looking different from the actual image on the page style, especially for different mobile devices because the width and height of the DIV are hard-coded.

Next, we tried a seemingly “advanced” method, using Canvas to draw a gray block with the same size as the original image, and then converting Canvas to SRC feature assigned to IMG element by dataUrl, so IMG element is displayed as a gray block, seemingly perfect. When we generate the skeleton page to generate HTML files, we are stunned, the file size is more than 200 KB, one of the main reasons we do skeleton page rendering is to want users to feel that the page load faster, if the skeleton page is more than 200 KB, will lead to the page load slower than before. It goes against our purpose, so it can only be abandoned.

In the final scheme, we choose to convert a transparent GIF image of 1 * 1 pixel into dataUrl, and then assign it to the SRC feature of IMG element. Meanwhile, we set the width and height features of the image to the width and height of the previous image. This solution perfectly solves the above problem by adjusting the background tone to the color value configured for the skeleton style.

// At least 1 x 1 pixel transparent GIF image


‘data:image/gif; base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7’

Above is a base64 format of 1 by 1 pixels, which is significantly smaller than the previous Canvas drawing.

SVG block, pseudoclass element block and button block drawing algorithm will not be described, if you are interested in the page-skeleton-webpack-plugin to read the source code.

Step 3: Get the HTML and CSS from the Puppeteer rendered skeleton page

In the second step, we finished drawing the skeleton page, and then how to get the HTML and CSS and write them to the shell.html file.

function getHtmlAndStyle() {
    const root = document.documentElement
    const rawHtml = root.outerHTML
    const styles = Array.from(?('style')).map(style => style.innerHTML || style.innerText)
    // ohter code
    const cleanedHtml = document.body.innerHTML
    return { rawHtml, styles, cleanedHtml }
}
Copy the code

Not all HTML and CSS styles are required for skeleton pages. For example, elements outside the first screen are not required for skeleton pages. The key in this step is to strip out irrelevant elements and CSS styles, which is called extracting key CSS.

Delete elements outside the first screen

const inViewPort = (ele) => { const rect = ele.getBoundingClientRect() return rect.top < window.innerHeight && rect.left  < window.innerWidth }Copy the code

Check whether the element is in the first screen according to the above method. If it is in the first screen, keep it; otherwise, delete it.

Extracting key CSS

There is a lot of code in this section, so I won’t post the whole code, but just brief the implementation details.

First, get the CSS style from the style element, pull the style from the link element, then parse the extracted style through the CSS-tree, parse all the CSS selectors and Rules. The CSS selector extracted above is selected using the querySelector method. If the querySelector result is null, the Rule is removed, and if it can be selected, the Rule is retained. The code is as follows:

Const checker = (selector) = > {/ / other code if (/ : {1, 2} (before | after)/test (the selector)) {return true} try {const keep = !! document.querySelector(selector) return keep } catch (err) { const exception = err.toString() console.log(`Unable to querySelector('${selector}') [${exception}]`, 'error') return false } }Copy the code

This code is used to determine whether CSS styles are retained. Note that all pseudo-class elements are retained because querySelector does not select pseudo-class elements, and all pseudo-class elements are retained during skeleton page generation.

A perfect fit with Webpack

To realize automatic generation framework page, also need to combine the development process of the above steps and we together, we can take the initiative to trigger in the process of development skeleton of the page, in the packaging release phase generated skeleton pages can be packaged into the final project, and with excellent webpack make it a lot easier to the above two steps. This is one of the reasons why page Skeleton was made a Webpack plugin.

PSWP relies on the HTML-webpack-Plugin, which is currently used in most front-end projects, not least because it saves us the repetitive work of manually inserting JS and CSS into HTML. Before generating the project index.html, PSWP inserts the skeleton page into index.html as follows:

compilation.plugin('html-webpack-plugin-before-html-processing', async (htmlPluginData, callback) => { // replace `<! -- shell -->` with `shell code` try { const code = await getShellCode(this.options.pathname) htmlPluginData.html = htmlPluginData.html.replace('<! -- shell -->', code) } catch (err) { log(err.toString(), 'error') } callback(null, htmlPluginData) })Copy the code

As you can see from the code above, in the final packaging phase, the
comments so that when we open the page again, we can see the skeleton page.

Final thoughts

In the process of doing PSWP project, after some stumbles, we concluded that when we write HTML, we try to choose semantic tags, add accessibility features, and write HTML according to THE HTML specification. Because HTML is a markup language, it can also convey some extended meanings of the wrapped text content through different semantic tags, such as the LI tag to identify the contents of the list, and the role=button feature to indicate that the element is a button. This allows us to draw the skeleton style according to the specification during the skeleton page generation process.

This article does not cover all the details of PSWP due to limited space. If you are interested, please feel free to read the source code directly. PSWP is an experimental project.