关于前端:什么都1202年了你还在手写骨架屏

68次阅读

共计 8549 个字符,预计需要花费 22 分钟才能阅读完成。

前言

小伟是个特地负责的切图崽,最近他恋爱了。

风和日丽的一天,离上班还有半个小时,小伟神往着上班和小美的约会,这时产品经理大明找到小伟: 竞品 app 的 H5 页面都有占位符(骨架屏),而咱们的只有一个菊花图,在网络不好的状况下,用户看到菊花图都不违心期待就敞开页面了,很影响咱们的页面的 uv,小伟你赶快安顿一下,他人有的咱们也不能少(🙄️)。

于是,在大明义正严辞 (威逼利诱) 的要求下,负(卑) 责(微)的小伟开始了他的骨架屏开发之旅。

计划调研

用菊花图的页面有 10 多个,并且根本都是多路由页面,一个一个写?显然不是一个好的计划。

聪慧 (懈怠) 的小伟本着不反复造轮子 (白嫖🙄️) 的思维,调研了以下几个 骨架屏自动化计划.

百度 – vue-skeleton-webpack-plugin

实现原理

通过 vueSSR (vue 服务端渲染)联合 webpack 在构建时渲染写好的 vue 骨架屏组件,将预渲染生成的 DOM 节点和相干款式插入到最终输入的 html 中

有余

  1. 预渲染的骨架屏组件须要开发者编写(对于想偷懒的小伟来说显著不是最优解🙄️)
  2. 计划只实用于 vue 我的项目(小伟的 H5 我的项目既有 react 也有 vue)

京东 – dps

实现原理

通过 puppeteer 在服务端操控 headless Chrome 关上开发中的须要生成骨架页面的页面,在期待页面加载渲染实现之后, 执行遍历 dom 树的脚本代码,通过单纯的 DOM 操作,筛选指标节点,生成骨架屏 html 和 css 代码

有余

  1. 无奈抉择生成骨架屏的机会。当页面存在着重定向 (H5 须要鉴权) 的时候,生成的骨架屏和预期相差比拟大
  2. 外部实现并不欠缺,某些元素比方伪元素等无奈生成骨架屏
  3. 某些依赖浏览器 jsbridge 接口的页面,工具无奈应用

饿了么 – page-skeleton-webpack-plugin

实现原理

通过 puppeteer 在服务端操控 headless Chrome 关上开发中的须要生成骨架页面的页面,在期待页面加载渲染实现之后,在保留页面布局款式的前提下,通过对页面中元素进行删减或削减,对已有元素通过层叠款式进行笼罩,这样达到在不扭转页面布局下,暗藏图片、文字和图片的展示,通过款式笼罩,使得其展现为灰色块。并且将批改后的 HTML 和 CSS 款式提取进去,通过 webpack 插件的模式注入最初生成的 html 中,并且还能够启动 UI 界面专门调整骨架屏代码。

有余

  1. 因为生成的骨架屏节点是基于页面自身的构造和款式,在某些嵌套比拟深的页面,骨架屏代码体积不会很小,并且对于多路由的页面,生成的代码就更加宏大了
  2. 无奈抉择生成骨架屏的机会。当页面存在着重定向 (H5 须要鉴权) 的时候,生成的骨架屏和预期相差比拟大
  3. 某些依赖浏览器 jsbridge 接口的页面,工具无奈应用
  4. 只反对 history 路由

插曲

调研之后小伟很苦恼,业界计划或多或少都有些问题。低头一看曾经是早晨 11 点了,小伟曾经错过和小美的晚餐,而且看起来也没有现成的计划能够齐全放心使用。对于认真负责的小伟来说,这件事就像一根刺一样扎在小伟的心上。于是小伟心一横,开始了工具的自研之路。

需要收集

1. 用户能够掌控骨架屏的生成机会(保障以后页面为指标页面)

2. 不和某种框架强耦合

3. 生成的骨架屏代码要尽可能小

4. 主动集成(生成的骨架屏代码不须要手动复制到 html 文件中)

5. 反对页面多路由(包含 hash 路由和 history 路由)

6. 可在真机上触发生成开关(服务于某些重大依赖服务端接口或者客户端 jsbridge 环境的页面)

计划实现

我的项目如何接入工具

要主动生成骨架屏,就必须拿到实在的 dom 构造 ,并且要让开发者能够自由选择生成机会,就须要有一个 ” 开关 “, 最好这个开关是 可见的。这样就波及到开关和骨架屏生成脚本如何集成到用户我的项目中。

非侵入式的接入 最好的形式必定还是和构建工具联合,这样能够保障开发代码和工具代码不耦合在一起。

具体实现代码(以 webpack plugin 为例子)

// webpack v4/v5 compatibility:
// https://github.com/webpack/webpack/issues/11425#issuecomment-690387207
if (webpack.version.startsWith('5.')) { // 如果是 webpack 5
  compiler.hooks.compilation.tap(TAP_NAME, (compilation) => {
    compilation.hooks.processAssets.tapAsync(
      {
        name: TAP_NAME,
        stage: webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
      },
      (assets, callback) => {this.replaceCode(compilation, assets);
        callback();},
    );
  });
} else { // webpack 4
  compiler.hooks.emit.tapAsync(TAP_NAME, (compilation, callback) => {this.replaceCode(compilation, compilation.assets);
    callback();});
}

replaceCode(compilation, assets) {const { options = {} } = this;
  const {htmlName = DEFAULT_HTML_NAME} = options;
  Object.keys(assets).forEach((name) => {if (name === htmlName) { // 发现是指标 html,执行脚本插入
      const content = this.getReplaceCode(compilation, name);
      updateAsset(compilation, name, content);
    }
  });
}

骨架屏代码如何生成

在咱们拿到残缺 dom 构造之后,接下来须要思考的点是骨架屏幕代码如何生成。

生成的步骤不外乎一下两步:

1. 筛选指标 dom 节点

2. 将指标 dom 节点转化成骨架屏代码

如何筛选指标节点

筛选指标节点要遵循两个准则

  1. 精准筛选节点 ( 要保障骨架屏代码要尽可能小,咱们只筛选用户首屏可见的节点)
  2. 节点用户可自定义 ( 要保障最初生成都骨架屏代码是用户想要的)

骨架屏代码要尽可能小

  1. 只遍历首屏 可见的 dom 节点
/**
 * 元素是否暗藏
 */
function isHidden(node) {const computedStyle = getComputedStyle(node);
  const {display, visibility, opacity} = computedStyle;
  return display === 'none' || visibility === 'hidden' || opacity === '0' || node.hidden;
}

/**
 * 元素是否呈现在可视窗口中
 * @param {Object} node HTML element 节点
 * @return {Boolean} 元素是否呈现在可视窗口中
 */
function isInViewPort(node) {const { top, right, bottom, left} = node.getBoundingClientRect();

  return !isHidden(node) && bottom >= 0 && right >= 0 && left <= WINDOW_WIDTH && top <= WINDOW_HEIGHT;
}
  1. 只筛选指标节点
    只筛选无效内容节点:(背景)图片、文字、表单项、音频视频、Canvas、伪元素
const TARGET_TAG_NAME = [
  'audio',
  'button',
  'canvas',
  'code',
  'img',
  'input',
  'pre',
  'svg',
  'textarea',
  'video',
  'xmp',
];

/**
 * dom 节点是否蕴含某个标签
 * @param {Object} node HTML Node 节点
 */
const hasTargetLabel = (node) => TARGET_TAG_NAME.includes(node.tagName.toLowerCase());

/**
 * 判断 dom 节点 css 属性 backgroundImage 中是否有 url 参数, 并且作为全局占满全屏
 * @param {Object} node HTML Node 节点
 */
const backgroundHasurl = (node) => {const hasBackgroundImage = /url\(.+?\)/.test(getComputedStyle(node).backgroundImage);
  const {width, height} = node.getBoundingClientRect();

  return hasBackgroundImage && !(width === WINDOW_WIDTH && height === WINDOW_HEIGHT);
};

/**
 * 判断 dom 节点子节点中是否有无效内容节点
 * @param {Object} node HTML Node 节点
 */
const hasTextNode = (node) => Array.prototype.some.call(node.childNodes, (v) => isTextNode(v));

指标节点用户可自定义

任何工具不论多完满,在简单的页面场景中都可能有某些 缺点。所以须要留一个入口让用户可本人新增或者删除指标节点。

设定黑名单和白名单

/**
* @param {String} attName
* @param {Object} node node 节点
* @return {Boolean} node 节点中是否蕴含 attName
*/
const curryCheckNode = (attName) => (node) => node.hasAttribute(attName);

/**
 * 是否在黑名单中
*/
const isInBlackList = curryCheckNode('unneed-node');

/**
 * 是否在白名单中
*/
const isInWhiteList = curryCheckNode('need-node');

dom 节点转化成骨架屏代码

这里咱们借鉴了京东 – dps 的生成形式。<br/>

对于符合条件的区域,”厚此薄彼”生成相应区域的色彩块。”厚此薄彼”即对于符合条件的区域不辨别具体元素、不思考构造层级、不思考款式,对立依据该区域与视口的相对间隔值生成 div 的色彩块。

只须要获取到 dom 节点到视口的间隔,元素的宽高和圆角,即可生成骨架屏代码。并且将间隔和宽高都转化成百分比,这样也可解决骨架屏代码在不同机型下的兼容问题。

  /**
   * 依据 node 节点生成 html
   * @param {Object} node
   */
  generateHtml(node) {const computedStyle = getComputedStyle(node);
    let {top, left, width, height} = node.getBoundingClientRect();
    const {boxSizing, paddingTop, paddingLeft, paddingBottom, paddingRight, borderRadius} = computedStyle;
    const isStandardBoxModel = boxSizing === 'border-box';
    width = isStandardBoxModel ? width : width - parseInt(paddingLeft, 10) - parseInt(paddingRight, 10);

    height = isStandardBoxModel ? height : height - parseInt(paddingTop, 10) - parseInt(paddingBottom, 10);

    top = isStandardBoxModel ? top : top + parseInt(paddingTop, 10);
    left = isStandardBoxModel ? left : left + parseInt(paddingLeft, 10);

    this.htmlQueue.push(drawBlock(width, height, top, left, borderRadius));
  }

遍历形式

树的遍历形式抉择抉择有两种:

  1. bfs(广度优先算法)
  2. dfs(深度优先算法)

这个时候可能有小伙伴会有疑难,这两种形式对最终生成对代码有什么区别嘛 ?反正在用户视角上看起来其实都是一样的, 因为只有指标节点确定了,对于单个 dom 节点来说生成的骨架屏代码是一样的。

对于单个指标节点来说,生成的骨架屏代码的确是一样的,然而这两种形式对于节点和节点之间组合的程序是有很大区别的。

因为咱们是以 pick 的模式去筛选节点,所以生成的骨架屏代码和之前的页面代码的构造是会有很大差别。

应用 dfs 深度遍历能够最大可能的保障生成的节点的在 dom 树的高低程序和之前页面的构造统一

traversal() {while (this.queue.length) {const node = this.queue.shift();

    if (isTextNode(node) || node.id === INSERT_IMG_ID) {continue;}

     // 非指标节点或者非可视窗口可见元素不做解决
    if ((node.nodeType === 3 && node.textContent.trim().length === 0) || !isTargetNode(node) || !isInViewPort(node))
      continue;
      
    // 指标节点
     if (isAppointed(node) || hasTargetLabel(node) || backgroundHasurl(node) || hasTextNode(node)) {this.generateHtml(node);
      continue;
    }

    this.queue.unshift(...Array.from(node.childNodes));
  }
}

如何反对多路由

当用户本人能够掌控骨架屏的生成机会,多路由就不算问题了。只有用户在不同路由中点击生成开关,工具通过 url 链接获取以后路由,执行骨架屏生成脚本之后,再把路由标识一起传给工具的 server,工具再依据标识作为文件名保留下来即可。

如何将生成的骨架屏代码插件集成到我的项目中

当咱们把骨架屏代码保留到用户我的项目之后,咱们一样能够借用构建工具插件的性能,遍历骨架屏文件夹,获取所有骨架屏代码,再插入到我的项目的 html 中,其实就是一个简版的 HtmlWebpackPlugin。

工具插件主流程代码如下(webpack-plugin):

  apply(compiler) {assert(compiler.hooks, 'Please upgrade the webpack version to 4 or above!');

    // 生产环境 -> 插入骨架屏幕,开发环境 -> 启动服务,插入生成骨架屏所需代码
    if (isProd(compiler)) {this.insertSkeleton(compiler);
      return;
    }

    // 启动服务
    this.startServer(compiler);

    // 编译出错或者 watch 实现之后敞开服务
    this.watchCompile(compiler);

    // 替换资源
    this.replaceSource(compiler);
  }

圆满结束?

小伟在实现骨架屏自动化工具之后,乐不可支的开始了工具的 实际 ,随着 测试用例 (H5 页面) 的减少,小伟发现了新的问题:

  1. 生成的骨架屏尽管很还原实在的页面构造,然而不够难看
  2. 骨架屏动画的切换也很麻烦,每次切换都须要配置插件中的动画参数从新生成
  3. 批改生成的骨架屏代码之后都须要刷新浏览器能力看到成果
  4. 比拟骨架屏页面和实在的 H5 页面须要来回切换着看,并且对于半屏 H5 页面来说,也很难还原场景
  5. 开发者不称心的 dom 节点,须要通过增加节点黑名单从新生成,操作很繁琐

这些问题的呈现让小伟这个 完满主义者 陷入了深思,他抬了低头看了看工夫,曾经是夜里的 12 点。小伟因为工具的研发曾经一周没有和小美一起吃晚饭了,想到此处,一股淡淡的哀伤袭上他的心头。

等等,如果工具有一个 编辑器 ,那么是不是就能够很快的实现这些 调优操作 了?

想到此处,小伟开始了他的编辑器开发之旅。

骨架屏编辑器

需要收集

  1. 能够对生成的骨架屏节点进行拖拽,删除,和复制。
  2. 一键切换动画
  3. 一键保留
  4. 实时预览: 反对 hot reload,保留骨架屏幕代码之后编辑器页面立马刷新
  5. 提供比照窗口: 可直观的看出原页面和骨架屏页面的差别
  6. 成果预览
  7. 可间接通过浏览器提供的 devtool 批改款式代码,点击保留之后款式代码会同步更新到我的项目中

计划实现

如何对骨架屏 dom 节点进行拖拽

拖拽很容易实现,不外乎对 dom 元素绑定 mouse 事件,计算挪动间隔,实现元素的挪动。
这里咱们要留神两个点:

  1. 因为骨架屏节点可能会有很多,所以咱们采取事件委托的形式解决 mouse 事件。
  2. 后面咱们说过: 骨架屏 dom 节点间隔视口的长度是是百分比为单位。所以咱们在拖拽过程中转化的转化的单位也应该是百分比。咱们在窗口内挪动节点,计算离视口的间隔也应该是间隔预览窗口的间隔,所以咱们须要应用 iframe 作为骨架屏预览窗口。
  <div
    class="edit-wrap"
    :style="{width: width +'px', height: height +'px'}"
  >
    <iframe
      ref="code"
      id="code"
      :width="width"
      :height="height"
      frameborder="0"
      scrolling="no"
      marginheight="0"
      marginwidth="0"
      :src="iframeUrl"
    ></iframe>
  </div>

如何实现比照窗口

先来看一张效果图


在上图中咱们能够清晰的看到右边的实在页面。咱们在用户点击开关生成骨架屏的时候,也同时对以后页面进行了截图,并且将图片转化成 base64 传给工具 server,工具 server 再把图片保留到用户工程项目中,具体流程如下:

截图咱们采纳的是 html2canvas(站在伟人的肩膀上😄),然而在应用的过程中咱们发现一个问题: 图片跨域

最初咱们在工具 server 中提供了一层 proxy,才解决了这个问题。

如何实时预览

在编辑器启动的时候,工具 server 会和编辑器建设长连贯,并且会实时监听用户我的项目中的骨架屏文件中文件的变动,当文件扭转时,push 音讯到编辑器,编辑器刷新页面。

  // 工具 server 代码
  initSocket(server) {const { log, pathname} = this;
    const io = socketIo(server);

    io.on('connection', (socket) => {socket.emit('open');
    });

    chokidar.watch(pathname).on('change', (path) => {log.info(`${path} is change!`);
      io.sockets.emit('reload');
    });
  }
  
  // 编辑器代码
  socket.on('reload', () => {window.location.reload();
  });

在浏览器的 devtool 中批改代码,如何同步批改后果 <br/>
做为前端小伙伴,对 devtool 必定是再相熟不过了, 那么 编辑器是怎么做到能够同步 devtool 中的批改?

这个和咱们骨架屏代码特点有关系,后面咱们也说了咱们骨架屏的 dom 节点的款式中有一个 白名单: width, height, top, left, borderRadius,background, animation.

当用户在 devtool 批改完代码款式之后,咱们只须要遍历 iframe 中的骨架屏节点,通过 getComputedStyle 获取白名单款式,生成批改后的代码,通过工具 server 保留到我的项目中即可。

整体架构图

编辑器总览图

无痛接入 smart-skeleton-screen

装置插件包 (依据我的项目构建工具抉择相干的插件包,以 webpack 为例)

tnpm install @tencent/smart-skeleton-screen

构建工具引入

const SmartSkeletonScreen = require('@tencent/smart-skeleton-screen').plugin;

new SmartSkeletonScreen({
   background: '#33333324',
   serverUrl: 'https://server.qq.com',
   port: 4001,
   pathname: path.join(__dirname, 'src/pages/fans/skeleton'),
}),

html 文件中插入替换标识符

<div id="app"><% smart-skeleton %></div>

写在最初

工具近期会开源,大家感兴趣的话能够珍藏我这篇文章,我代表小伟谢谢大家😁

正文完
 0