前沿
最近在学webpack,想实操又不知道做什么,突然发现了骨架屏也能用webpack实现,赶紧看了实现原理,也打算实现一次。
什么是骨架屏
骨架屏是页面的一个空白版本,通常会在页面完全渲染之前,通过一些灰色的区块大致勾勒出轮廓,待数据加载完成后,再替换成真实的内容。
为什么要用骨架屏
在页面数据尚未请求回来前,页面都是一片空白,这样给用户体验感很差,所以需要在数据加载前先给用户展示出页面的大致结构,进而等到数据请求返回后再显示页面。
实现骨架屏的方式
我找了一些实现骨架屏的方法:
参考地址(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…
骨架屏的实现步骤
-
监听打包完成的事件
-
当webpack编译完成后,启动node服务,让无头浏览器去访问这个地址
-
通过注入script脚本,进行对页面dom的操作,获取操作后的dom结构
-
将操作后的dom结构替换到root中的占位符
-
用户访问的时候首先呈现的是骨架屏页面
-
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 };
})();
复制代码
最后的成果
完成后的效果感觉还是不错的。
这个简易小demo基础的功能算是做完了,还是有很大的提升空间,比如指定路由,多个路由展示,或者只对某一块进行展示,以后有时间再继续完善。
最后提供这个项目的地址:github.com/Learning-sn…
觉得有帮助的朋友们希望能给我这个项目点个赞。