我也来实现一个实现骨架屏

前沿

最近在学webpack,想实操又不知道做什么,突然发现了骨架屏也能用webpack实现,赶紧看了实现原理,也打算实现一次。

什么是骨架屏

骨架屏是页面的一个空白版本,通常会在页面完全渲染之前,通过一些灰色的区块大致勾勒出轮廓,待数据加载完成后,再替换成真实的内容。

image.png

为什么要用骨架屏

在页面数据尚未请求回来前,页面都是一片空白,这样给用户体验感很差,所以需要在数据加载前先给用户展示出页面的大致结构,进而等到数据请求返回后再显示页面。

实现骨架屏的方式

我找了一些实现骨架屏的方法:

image.png 参考地址(t.zoukankan.com/sexintercou…

本文的实现方法

本文实现的方法主要是写了一个plugin,使用webpack里的一些方法,在webpack执行结束前,dist生成后使用Puppeteer去修改页面元素,再获取页面元素,最后将这些元素替换到dist目录下的index.html里。

Puppeteer是什么

Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过DevTools 协议控制 Chrome 或 Chromium 。Puppeteer 默认运行无头,但可以配置为运行完整(非无头)Chrome 或 Chromium。

Puppeteer能做些什么?

您可以在浏览器中手动执行的大多数操作都可以使用 Puppeteer 完成!以下是一些帮助您入门的示例:

  • 生成页面的屏幕截图和 PDF。
  • 抓取 SPA(单页应用程序)并生成预渲染内容(即“SSR”(服务器端渲染))。
  • 自动化表单提交、UI 测试、键盘输入等。
  • 创建最新的自动化测试环境。使用最新的 JavaScript 和浏览器功能直接在最新版本的 Chrome 中运行测试。
  • 捕获您网站的时间线轨迹以帮助诊断性能问题。
  • 测试 Chrome 扩展程序。

github地址:github.com/puppeteer/p…

骨架屏的实现步骤

  1. 监听打包完成的事件

  2. 当webpack编译完成后,启动node服务,让无头浏览器去访问这个地址

  3. 通过注入script脚本,进行对页面dom的操作,获取操作后的dom结构

  4. 将操作后的dom结构替换到root中的占位符

  5. 用户访问的时候首先呈现的是骨架屏页面

  6. js加载完后会重新render掉root

实现代码

这次编写plugin需要的参数:

chainWebpack: (webpackConfig) => {
    webpackConfig
      .plugin('vue-skeleton-plugin')
        .use(VueSkeletonPlugin, [{
          //我们要启动一个静态文件服务器,去显示dist目录里的页面。
          staticDir:resolve(__dirname,'dist'),
          // 启动的node端口
          port:3000,
          // puppeteer打开的node端口的链接,与port端口需要一致
          origin:'http://localhost:3000',
          button:{
              color:'#111'
          },
          image:{
            color:'#EFEFEF'
          },
          font: {
            color:'#EFEFEF'
          }
        }])
    webpackConfig.plugin('html').tap(config => {
      config[0].minify = {
        removeComments: false
      }
      return config;
    })
  }
复制代码

将HtmlWebpackPlugin的removeComments设置为false的原因,是因为之后需要将代码替换掉原先在dom里写好的注释<!--shell-->,所以不能在打包html时将注释删除。(当然你也可以使用其他的标识)

1. 监听打包完成后的事件

class VueSkeletonPlugin {
  constructor(options) {
    this.options = options;
  }
  apply(compiler) {
    // 在webpack将文件打包到dist后在执行操作
    compiler.hooks.done.tapAsync("VueSkeletonPlugin", async () => {
      // 启动一个node服务器,用于puppeteer可以打开一个无头浏览器
      await this.serverStatrt();
      // Skeleton里是对于开启无头浏览器后对dom元素进行操作
      const skeleton = new Skeleton(this.options);
      await skeleton.initialize()
      let htmlContent = await skeleton.genHTML();
      // 销毁无头浏览器
      skeleton.destroy();
      await fs.writeFile(resolve(__dirname,'../dist/index.html'),htmlContent,function() {
        // 重写完成后退出编译状态
        process.exit()
      })
    });
  }
  serverStatrt() {
    this.serve = new Serve(this.options);
    this.serve.listen();
  }
}
复制代码

2. 启动node服务,这里我使用了koa作为启动node服务的框架

class Server {
  constructor(options) {
    this.options = options;
    this.port = this.options.port || 3000;
  }
  listen() {
    const app = new Koa();
    // 访问静态文件
    app.use(Static(this.options.staticDir, "/dist")));
    app.use(async (ctx) => {
      ctx.response.body = fs.createReadStream(
        resolve(
          this.options.staticDir,
          "/index.html"
        )
      );
    });
    app.listen(this.port, () => {
      console.log("服务器正常启动");
    });
  }
  close() {
    this.httpServer.close(() => {
      console.log(`${this.port}端口服务器已经关闭了`);
    });
  }
}
复制代码

3.使用Puppeteer

class Skeleton {
  constructor(options) {
    this.options = options;
  }
  // 创建无头浏览器的配置
  async initialize() {
    this.browser = await puppeteer.launch({ headless: false });
  }
  async newPage() {
    let page = await this.browser.newPage();
    return page;
  }
  async genHTML() {
    let page = await this.newPage();
    // 打开一个无头浏览器,地址与node服务启动的地址一样
    let response = await page.goto(`${this.options.origin}/index.html`, {
      waitUntil: "networkidle2",
    });
    if (response && response._status == "200") {
      await this.makeSkeleton(page);
      const { style, html } = await page.evaluate(() =>
        skeleton.getHtmlAndStyle()
      );
      const result = `
        <style>${style.join()}</style>
        ${html}
      `;
      // 重写dist下的index.html文件
      let htmlContent = readFileSync(
        resolve(__dirname, "../dist/index.html"),
        "utf8"
      );
      // 提前在pubilc下的index.html文件中写上<!--shell-->用于元素替换
      htmlContent = htmlContent.replace('<!--shell-->', result);
      return htmlContent;
    }
  }
  async makeSkeleton(page) {
    // 先读取脚本内容
    let scriptContent = await readFileSync(
      resolve(__dirname, "../skeletonScript/index.js"),
      "utf8"
    );
    // 通过addScriptTag方法向页面里注入这个脚本,将skeleton方法注入到window上
    await page.addScriptTag({ content: scriptContent });
    page.evaluate((options) => {
      // 对dom元素进行操作
      skeleton.genSkeleton(options);
    },this.options);
  }
  // 销毁无头浏览器
  async destroy() {
    if (this.browser) {
      await this.browser.close();
      this.browser = null;
    }
  }
}
复制代码

4.向使用Puppeteer打开的页面注入的script脚本

window.skeleton = (function () {
  const SMALLEST_BASE64 =
    "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
  const CLASS_NAME_PREFIX = "sk-";
  const $$ = document.querySelectorAll.bind(document);
  const REMOVE_TAGS = ["title", "meta", "script"];
  const Font_TAGS = ["p", "h1", "h2", "h3", "h4", "a"];
  const styleCache = new Map();
  let buttonList = [];
  let imgList = [];
  let fontList = [];
  function addButtonClass(element, {color}) {
    const className = CLASS_NAME_PREFIX + "button"; // sk-button
    const rule = `{
          background:${color || '#EFEFEF'} !important;
          border:none;
          box-shadow:none;
      }`;
    element.classList.add(className);
    addStyle("." + className, rule);
  }
  function addImgClass(element, {color}) {
    const className = CLASS_NAME_PREFIX + "img"; // sk-img
    const { width, height } = element.getBoundingClientRect();
    const attr = {
      width,
      height,
      src: SMALLEST_BASE64,
    };
    const rule = `{
          background:${color || '#EFEFEF'} !important;
      }`;
    element.classList.add(className);
    addStyle("." + className, rule);
    setAttrHandle(element, attr);
  }
  function addFontClass(element, {color}) {
    const className = CLASS_NAME_PREFIX + "font"; // sk-button
    const { height } = element.getBoundingClientRect();
    const rule = `{
        height:${height}px;
        background:${color || '#EFEFEF'} !important;
    }`;
    element.textContent = "";
    element.classList.add(className);
    addStyle("." + className, rule);
  }
  // 设置属性
  function setAttrHandle(element, attr) {
    Object.keys(attr).forEach((item) => {
      element.setAttribute(item, attr[item]);
    });
  }
  function addStyle(selector, rule) {
    if (!styleCache.has(selector)) {
      //一个类名sk-button只会在缓存中出现一次
      styleCache.set(selector, rule);
    }
  }
  // 获取dom元素,并进行分类和添加样式
  function genSkeleton(options) {
    const rootElement = document.documentElement;
    const { button, image, font } = options;
    (function traverse(element) {
      if (element.children) {
        Array.from(element.children).forEach((item) => {
          traverse(item);
        });
      }
      const elementName = element.tagName;
      if (elementName.toLowerCase() == "button") {
        buttonList.push(element);
      }
      if (elementName.toLowerCase() == "img") {
        imgList.push(element);
      }
      if (Font_TAGS.includes(elementName.toLowerCase())) {
        fontList.push(element);
      }
    })(rootElement);
    buttonList.forEach((item) => {
      addButtonClass(item,button);
    });
    imgList.forEach((item) => {
      addImgClass(item,image);
    });
    fontList.forEach((item) => {
      addFontClass(item,font);
    });
    let style = "";
    for (const [name, rule] of styleCache) {
      style += `${name} ${rule}\n`;
    }
    const styleElement = document.createElement("style");
    styleElement.innerHTML = style;
    $$("head")[0].appendChild(styleElement);
  }
  // 返回页面中处理后的html元素
  function getHtmlAndStyle() {
    Array.from($$(REMOVE_TAGS.join(","))).forEach((item) => {
      item.parentNode.removeChild(item);
    });
    const style = Array.from($$("style")).map((item) => {
      return item.innerHTML || item.innerText;
    });
    const html = $$("body")[0].innerHTML;
    return { html, style };
  }
  return { genSkeleton, getHtmlAndStyle };
})();
复制代码

最后的成果

骨架屏.gif

完成后的效果感觉还是不错的。
这个简易小demo基础的功能算是做完了,还是有很大的提升空间,比如指定路由,多个路由展示,或者只对某一块进行展示,以后有时间再继续完善。
最后提供这个项目的地址:github.com/Learning-sn…
觉得有帮助的朋友们希望能给我这个项目点个赞。

猜你喜欢

转载自juejin.im/post/7108348738296512526
今日推荐