手撸一个webpack骨架屏插件

779 阅读5分钟

一直在做移动端开发, 用到了骨架屏, 发现公司的骨架屏是基于chrom插件生成的, 即在浏览器中运行网页地址, 然后基于插件生成(前人实现的, 具体的逻辑没有深入), 那么我们可不可以在代码打包的时候就动态的生成骨架屏呢?方案肯定是可行的, 说干就干,于是就开始了本次的倒腾之旅......其实市面上的骨架屏技术还是挺多的,本次只是一个学习过程,希望能带给大家一定的收获.

实现思路

  • webpack插件钩子,获取编译完成之后的回调
  • 本地启一个服务器,运行npm run build之后的本次编译的代码
  • 通过puppeteer去访问生成的页面,抓取内容
  • 解析抓取的内容, 生成骨架屏元素

经过了不断的调研和研究,总结了以上四步的实现思路,下面就来一步一步的实现吧(过程中总是磕磕绊绊, 生活就是这样,坚持了, 就能有一点收获)

1. webpack插件钩子函数

webpack官网提供了compiler钩子, 来监听webpack打包编译时的一系列动作,语法如下:

compiler.hooks.someHook.tap('MyPlugin', (params) => {
  /* ... */
});

someHook是webpack官网提供的一系类hook,通过tap来注册, 例如:

  • environment 在编译器准备环境时调用,时机就在配置文件中初始化插件之后。

  • afterEnvironment 当编译器环境设置完成后,在 environment hook 后直接调用。

  • done 在 compilation 完成时执行,即webpack编译完成之后执行。(本次主要用到的hook)
    ......


看了官网的示例,好, 那我们来手动实现一下:
//注意: 要与注册的插件名一样
const PLUGIN_NAME = 'SkeletonPlugin';//插件的名称
class SkeletonPlugin {
  //代表webpack编译对象
  apply(compiler) {
    //webpack 插件钩子, 可以通过tap来注册这些钩子函数的监听
    // done 表示整个编译流程都走完了, dist目录下的文件生成了就可以触发done的回调
    compiler.hooks.done.tap(PLUGIN_NAME, async () => {
      console.log('webpack 编译结束')
    })
  }
}
module.exports = SkeletonPlugin;

// webpack.config.js文件中注册插件
const {SkeletonPlugin} = require('./skeleton');
plugins:[
        new HtmlWebpackPlugin({
            template:'./src/index.html'
        }),
        new SkeletonPlugin()
    ]

运行结果

学习到了这里,就已经掌握了webpack编写一个插件的基本语法了,如需更深层次的掌握,可以移步( webpack官网插件学习 )

2. 本地起一个服务

为什么本地要启一个服务? 我想这是很多读者的困惑,主要是结合puppeteer去抓取网页的内容, 当我们去 npm run build的时候, 启一个服务,模拟本次编译运行的url, 简单理解为npm run dev的时候打开网页,只是把这个过程放在了npm run build的时候

3. Puppeteer去访问这个生成的页面, 抓取内容

  • Puppeteer简介: Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome
    怎么理解? 就是我们可以通过Puppeteer去控制Chrome, 模拟用户行为, 比如说元素点击, 爬虫,抓取网页内容等等, 我们来来简单看看Puppeteer的使用:
//安装Puppeteer
npm i puppeteer
// 引入
const puppeteer = require('puppeteer');

(async () => {
  //创建一个浏览器
  const browser = await puppeteer.launch();
  //创建一个网页
  const page = await browser.newPage();
  //打开网页 www.baidu.com
  await page.goto('https://www.baidu.com');
  //要在页面实例上下文中执行的方法
  await page.evaluate(x => {
    return Promise.resolve(8 * x);
  }, 7);
  //关闭浏览器
  await browser.close();
})();

示例结果

从上图我们可以看到,Puppeteer打开了百度的网页,并执行了指定的方法.

4. 解析抓取的内容, 生成骨架屏元素

这个实现思路主要是拿到页面上面所有的dom元素, 然后遍历, 获取元素类型tagName, 然后对每个元素类分类处理,动态添加类名和样式,核心代码实现如下:

const styleCache = new Map()
//转换原始元素为骨架屏元素
  //遍历整个dom树,获取每一个节点或者元素,根据元素类型进行转换
  function genSkeleton(options) {
    let rootElement = document.documentElement;
    ; (function traverse(options) {
      let { button, image } = options
      const buttons = []  //所有的按钮
      const imgs = []  //所有的图片
        ; (function preTraverse(element) {
          if (element.children && element.children.length > 0) {
            //如果有子元素, 遍历子元素
            Array.from(element.children).forEach(children => preTraverse(children))
          }
          if (element.tagName == 'BUTTON') {
            buttons.push(element)
          } else if (element.tagName == 'IMG') {
            imgs.push(element)
          }
        })(rootElement)
      buttons.forEach(item => buttonHandler(item, button))
      imgs.forEach(item => imageHandler(item, image))
    })(options)
    let rules = '';
    for (const [selector, rule] of styleCache){
      rules += `${selector} ${rule}\n`
    }
    console.log('rules ==>', rules)
    //创建style元素, 注入样式
    const styleElement = document.createElement('style');
    styleElement.innerHTML= rules;
    document.head.appendChild(styleElement);
  }
//处理按钮
function buttonHandler(element, options = {}) {
    const className = CLASS_NAME_PREFIX + 'button';  // sk-button
    const rule = `{
      color: ${options.color} !important;
      background:  ${options.color} !important;
      border:  none !important;
      box-shadow: none !important;
    }`

    addStyle(`.${className}`, rule)
    element.classList.add(className)
  }
  
//处理图片
function imageHandler(element, options = {}) {
    const { height, width } = element.getBoundingClientRect()
    const attrs = {
      width, height, src: SMALLEST_BASE64
    }
    setAttributes(element, attrs)
    const className = CLASS_NAME_PREFIX + 'image';  // sk-image
    const rule = `{
      background:  ${options.color} !important;
    }`

    addStyle(`.${className}`, rule)
    element.classList.add(className)
  }

//dom上设置元素属性 height with src等
function setAttributes(element, attrs) {
    Object.keys(attrs).forEach(key => element.setAttribute(key, attrs[key]))
  }
  
//把对应的类名和样式 一一对应存起来
function addStyle(selector, rule) {
    if (!styleCache.has(selector)) {
      styleCache.set(selector, rule)
    }
  }

到这里基本上就完成了dom的操作,本次值处理了buttonimage 两种情况,后续会继续升级迭代

插件配置文件如下:

new SkeletonPlugin({
          staticDir: resolve(__dirname, 'dist'),
          port: '8002', //本地服务端口
          origin: 'http://localhost:8002', // Puppeteer打开地址
          device: 'iPhone 6',  //Puppeteer模拟的分辨率
          defer: 5000, //脚本执行时间
          button: { //按钮配置
            color: '#EFEFEF'
          },
          image: { //图片配置
            color: '#EFEFEF'
          }
        })

- 疑问一: defer的作用是啥?

上文已经提到过了dom解析成骨架屏的流程, 这里是把这段js挂在Windows上面执行的, 相当于增加了一个全局变量, 这个js文件执行是要时间的,于是就有了这个变量, 结构如下:

//增加一个全局变量 名字叫做Skeleton
window.Skeleton = (function () {
  function genSkeleton(options) {
    //TODO
  }
})()

async makeSkeleton(page) {
    const { defer = 5000 } = this.options
    //先读取脚本内容
    let scriptContent = await readFileSync(resolve(__dirname, 'skeletonScript.js'), 'utf8')
    //通过addScriptTag 向页面中注入这个脚本
    await page.addScriptTag({ content: scriptContent })
    //推迟时间
    await sleep(defer)
    await page.evaluate((options) => {
      //调用window上面的方法
      Skeleton.genSkeleton(options)
    }, this.options)
  }

- 疑问二: 生成的骨架屏是如何替换的原有dom的?

在原有dom上我们是有一个shell占位符的

<div id="root"><!-- shell--></div>

我们只需要把生成的dom和样式替换掉这个占位符就好了

const skeletonHTML = await this.skeleton.genHTML(this.options.origin);
     //console.log('skeletonHTML', skeletonHTML)
      const originPath = resolve(this.options.staticDir, 'index.html');
      const originHTML = await readFileSync(originPath, 'utf8');
      const finalHTML = originHTML.replace('<!-- shell-->', skeletonHTML);
      await writeFileSync(originPath, finalHTML);

实现效果, 打包之后, 发现dist/index.html文件已经有个骨架屏相关的dom和样式了

<div id="root">
    <style>
      .sk-button {
        color: #EFEFEF !important;
        background: #EFEFEF !important;
        border: none !important;
        box-shadow: none !important;
      }

      .sk-image {
        background: #EFEFEF !important;
      }
    </style>

    <div id="root">
      <div><img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" width="359"
          height="201.984375" class="sk-image"><button class="sk-button">点我</button></div>
    </div>
  </div>

以上, 一个简易版本的骨架屏就已经完成了, 当然这只是生成了两种元素,后面会继续完善.

参考链接
[1]webpack官网插件地址: webpack.docschina.org/api/compile…

[2]puppeteer官网地址: github.com/puppeteer/p…