Open chiyan-lin opened 3 years ago
作为使用 vue 这种 SPA 框架的我们来说,最大的难受点莫过于每次都得等 js 逻辑执行完成再渲染页面
对 seo 是极度不友好,而且首屏时间也会有一定的影响
解决方案有两个 1、使用预渲染,2、使用 ssr
预渲染就是使用一个无头浏览器,把页面在浏览器里面跑一下,然后把页面拿出来,生成出 html string
核心思路如下
async renderRoutes (routes, Prerenderer) { const rootOptions = Prerenderer.getOptions() const options = this._rendererOptions const limiter = promiseLimit(this._rendererOptions.maxConcurrentRoutes) const pagePromises = Promise.all( routes.map( // 限制 promise 数量的一个小方法,防止太多页面导致内存爆炸 (route, index) => limiter( async () => { const page = await this._puppeteer.newPage() // 注入 window 全局对象,标记当前处于预渲染环境 if (options.inject) { await page.evaluateOnNewDocument(`(function () { window['${options.injectProperty}'] = ${JSON.stringify(options.inject)}; })();`) } const baseURL = `http://localhost:${rootOptions.server.port}` // 设置视窗大小 if (options.viewport) await page.setViewport(options.viewport) // 由使用方配置 renderAfterDocumentEvent 并在合适的时候 new Event(renderAfterDocumentEvent) if (options.renderAfterDocumentEvent) { page.evaluateOnNewDocument(function (options) { window['__PRERENDER_STATUS'] = {} // https://developer.mozilla.org/zh-CN/docs/Web/API/Event // 使用浏览器的原生事件方式, document.addEventListener(options.renderAfterDocumentEvent, () => { // 可以准备把内容导出了 window['__PRERENDER_STATUS'].__DOCUMENT_EVENT_RESOLVED = true }) }, this._rendererOptions) } // 设置一下网络配置 const navigationOptions = (options.navigationOptions) ? { waituntil: 'networkidle0', ...options.navigationOptions } : { waituntil: 'networkidle0' } // 打开页面 await page.goto(`${baseURL}${route}`, navigationOptions) // Wait for some specific element exists const { renderAfterElementExists } = this._rendererOptions if (renderAfterElementExists && typeof renderAfterElementExists === 'string') { await page.waitForSelector(renderAfterElementExists) } // 在页面注入一个方法,因为在前面注入了一个 // 当触发生成的时候,window['__PRERENDER_STATUS'].__DOCUMENT_EVENT_RESOLVED // 会被标记为 true ,然后会触发下面这个 promise ,在这个页面执行完成,就有页面内容了 await page.evaluate(function (options) { options = options || {} return new Promise((resolve, reject) => { if (options.renderAfterDocumentEvent) { // 如果很快就产生了文件,就可以直接 resolve 了 if (window['__PRERENDER_STATUS'] && window['__PRERENDER_STATUS'].__DOCUMENT_EVENT_RESOLVED){ resolve() } // 不然就自己再监听一遍 document.addEventListener(options.renderAfterDocumentEvent, () => resolve()) } }) }, this._rendererOptions) // 产出页面的基本内容 const result = { originalRoute: route, route: await page.evaluate('window.location.pathname'), html: await page.content() } await page.close() return result } ) ) ) return pagePromises }
配合 webpack 在产出文件里面加上 输出文件 就行了
PrerenderSPAPlugin.prototype.apply = function (compiler) { const compilerFS = compiler.outputFileSystem // From https://github.com/ahmadnassri/mkdirp-promise/blob/master/lib/index.js const mkdirp = function (dir, opts) { return new Promise((resolve, reject) => { compilerFS.mkdirp(dir, opts, (err, made) => err === null ? resolve(made) : reject(err)) }) } const afterEmit = (compilation, done) => { const PrerendererInstance = new Prerenderer(this._options) PrerendererInstance.initialize() .then(() => { return PrerendererInstance.renderRoutes(this._options.routes || []) }) // 设置导出文件路径,这里应该是跟生成页面 html plugin 里面设置的地址一致 .then(renderedRoutes => { renderedRoutes.forEach(rendered => { if (!rendered.outputPath) { rendered.outputPath = path.join(this._options.outputDir || this._options.staticDir, rendered.route, 'index.html') } }) return renderedRoutes }) // 准备文件写入,这个过程应该是用了 webpack 的内存文件系统 .then(processedRoutes => { const promises = Promise.all(processedRoutes.map(processedRoute => { return mkdirp(path.dirname(processedRoute.outputPath)) .then(() => { return new Promise((resolve, reject) => { compilerFS.writeFile(processedRoute.outputPath, processedRoute.html.trim(), err => { if (err) reject(`[prerender-spa-plugin] Unable to write rendered route to file "${processedRoute.outputPath}" \n ${err}.`) else resolve() }) }) }) .catch(err => { if (typeof err === 'string') { err = `[prerender-spa-plugin] Unable to create directory ${path.dirname(processedRoute.outputPath)} for route ${processedRoute.route}. \n ${err}` } throw err }) })) return promises }) // 最后清掉文件,关闭一切 .then(r => { PrerendererInstance.destroy() done() }) .catch(() => { PrerendererInstance.destroy() const msg = '[prerender-spa-plugin] Unable to prerender all routes!' console.error(msg) compilation.errors.push(new Error(msg)) done() }) } if (compiler.hooks) { const plugin = { name: 'PrerenderSPAPlugin' } compiler.hooks.afterEmit.tapAsync(plugin, afterEmit) } else { compiler.plugin('after-emit', afterEmit) } }
官方推荐的形式大概就是这个样子了,依赖 poputeer 来做这种操作
但是存在的问题是,页面上的数据是需要自己构造的,比如说一个列表
一般来说,我们的页面在请求接口的时候,是需要做 cookie 校验,那么在执行页面打开这个操作的时候
最好是要进行一次登录,或者通过配置或者在页面检测是预渲染的时候做一些有意义内容的展示
骨架页面是在页面真正解析和应用启动之前给用户展示页面的 CSS 样式和页面布局,并通过骨架页面的明暗变化, 告知用户页面正在努力加载中,让用户感知页面似乎加载得比以前快了。
简单的 loading 对用户的感知太弱了,骨架页面和真实页面样式布局完全一致, 在用户视觉感知上,骨架页面可以平滑的切换到真实数据渲染的页面。
通过 puppeteer 在服务端操控 headless Chrome 打开开发中的需要生成骨架页面的页面, 在等待页面加载渲染完成之后,在保留页面布局> 样式的前提下,通过对页面中元素进行删减或增添, 对已有元素通过层叠样式进行覆盖,这样达到在不改变页面布局下,隐藏图片、文字和图片的展现, 通过样式覆盖,使得其展示为灰色块。然后将修改后的 HTML 和 CSS 样式提取出来,这样就是骨架页面了。
我们觉得一个比较好的接入方案应该是:
骨架屏可以自动生成,使用、维护和调整成本低,在不影响性能的前提下提升用户体验
所以最终决定使用 非侵入式骨架屏代码自动生成,生成对应 vue 组件形态的骨架屏
通过 slot 的方式将需要骨架屏渲染的部分传入,全面去除 loading 转圈圈这种用户体验较差的等待方式
所以其实本方案是对 ElemeFE/page-skeleton 的一个魔改
不得不说,这个项目是真的 kpi 项目,连 demo 都跑不起来。。。
流程上还是清晰的
// 处理 htmlWebpackPlugin 在 htmlWebpackPlugin.getHooks(compilation).afterTemplateExecution 之后作一次注入 htmlWebpackPluginBeforeHtmlProcessing.tapAsync(PLUGIN_NAME, (htmlPluginData, callback) => { const clientEntry = `http://localhost:${port}/${staticPath}/index.bundle.js` const oldHtml = htmlPluginData.html htmlPluginData.html = addScriptTag(oldHtml, clientEntry, port) callback(null, htmlPluginData) }) // 引入 SockJS,并且启动一个后台服务来通信 import SockJS from 'sockjs-client' const port = window._pageSkeletonSocketPort // eslint-disable-line no-underscore-dangle const sock = new SockJS(`http://localhost:${port}/socket`) window.sock = sock function createView(sock) { return new Vue({ created() { this.$nextTick(() => { Object.defineProperty(window, 'toggleBar', { get() { log('toggle the preview control bar.') } }) }) }, methods: { handleClick() { // 触发之后,告诉后台可以生成啦,并且把页面发过去 sock.send(JSON.stringify({ type: 'generate', data: window.location.origin })) } } }) }
if (!msg.data) return log.info(msg) sockWrite(this.sockets, 'console', preGenMsg) try { const skeletonScreens = await this.skeleton.renderRoutes(msg.data.origin) this.routesData = {} /* eslint-disable no-await-in-loop */ for (const { route, html } of skeletonScreens) { const fileName = await this.writeMagicHtml(html) const skeletonPageUrl = `http://${this.host}:${this.port}/${fileName}` this.routesData[route] = { url: origin + route, skeletonPageUrl, qrCode: await generateQR(skeletonPageUrl), html } } sockWrite(this.sockets, 'console', afterGenMsg) if (this.previewSocket) { sockWrite([this.previewSocket], 'url', JSON.stringify(this.routesData)) } else { const openMsg = 'Browser open another page...' open(this.previewPageUrl, { app: [appName, '--incognito'] }) } }
// 核心逻辑 genHtml 【 劫持 css -> css-tree parse to cssTree -> 在页面注入一段新的js用于处理dom&css -> getHtmlAndStyle 获取生成的html和css 】 // css-tree parse to cssTree const ast = parse(text, { parseValue: false, parseRulePrelude: false }) stylesheetAstObjects[requestUrl] = toPlainObject(ast) stylesheetContents[requestUrl] = text
关于整个处理我贴出了比较重要的部分 就是对文本进行处理
// 对标签进行分类处理 if (ele.tagName === 'svg') { return svgs.push(ele) } if (EXT_REG.test(styles.background) || EXT_REG.test(styles.backgroundImage)) { return hasImageBackEles.push(ele) } if (GRADIENT_REG.test(styles.background) || GRADIENT_REG.test(styles.backgroundImage)) { return gradientBackEles.push(ele) } if (ele.tagName === 'IMG' || isBase64Img(ele)) { return imgs.push(ele) } if ( ele.nodeType === Node.ELEMENT_NODE && (ele.tagName === 'BUTTON' || (ele.tagName === 'A' && ele.getAttribute('role') === 'button')) ) { return buttons.push(ele) } // 对各个标签进行分类处理 svgs.forEach(e => svgHandler(e, svg, cssUnit, decimal)); texts.forEach(e => textHandler(e, text, cssUnit, decimal)); buttons.forEach(e => buttonHandler(e, button)); hasImageBackEles.forEach(e => backgroundHandler(e, image)); imgs.forEach(e => imgHandler(e, image)); pseudos.forEach(e => pseudosHandler(e, pseudo)); gradientBackEles.forEach(e => backgroundHandler(e, image)); grayBlocks.forEach(e => grayHandler(e, button));
function textHandler(ele, { color }, cssUnit, decimal) { const { width } = ele.getBoundingClientRect() // if the text block's width is less than 50, just set it to transparent. if (width <= 50) { return setOpacity(ele) } const comStyle = getComputedStyle(ele) const text = ele.textContent let { lineHeight, paddingTop, paddingRight, paddingBottom, paddingLeft, position: pos, fontSize, textAlign, wordSpacing, wordBreak } = comStyle // 没有设置 line-height 的情况,后续的计算需要用到这个 if (!/\d/.test(lineHeight)) { const fontSizeNum = parseFloat(fontSize, 10) || 14 lineHeight = `${fontSizeNum * 1.4}px` } // 防止设置了其他奇怪的属性 position 比如 sticky const position = ['fixed', 'absolute', 'flex'].find(p => p === pos) ? pos : 'relative' const height = ele.offsetHeight // 计算行数,高 / 行高 const lineCount = (height - parseFloat(paddingTop, 10) - parseFloat(paddingBottom, 10)) / parseFloat(lineHeight, 10) | 0 // eslint-disable-line no-bitwise let textHeightRatio = parseFloat(fontSize, 10) / parseFloat(lineHeight, 10) if (Number.isNaN(textHeightRatio)) { textHeightRatio = 1 / 1.4 // default number } /* eslint-disable no-mixed-operators */ const firstColorPoint = ((1 - textHeightRatio) / 2 * 100).toFixed(decimal) const secondColorPoint = (((1 - textHeightRatio) / 2 + textHeightRatio) * 100).toFixed(decimal) const backgroundSize = `100% ${px2relativeUtil(lineHeight, cssUnit, decimal)}` const className = CLASS_NAME_PREFEX + 'text-' + firstColorPoint.toString(32).replace(/\./g, '-') const rule = `{ background-image: linear-gradient(transparent ${firstColorPoint}%, ${color} 0%, ${color} ${secondColorPoint}%, transparent 0%) !important; background-size: ${backgroundSize}; position: ${position} !important; }` const invariableClassName = CLASS_NAME_PREFEX + 'text' const invariableRule = `{ background-origin: content-box !important; background-clip: content-box !important; background-color: transparent !important; color: transparent !important; background-repeat: repeat-y !important; }` addStyle(`.${className}`, rule) addStyle(`.${invariableClassName}`, invariableRule) addClassName(ele, [className, invariableClassName]) // 对于超过两行的文本元素需要特殊处理 // background-image: linear-gradient 产生作用的,span 是文字的区域,div 是文字所在的行 // 所以 addTextMask 就是给 div 加上一个偏移的白色的 span if (lineCount > 1) { addTextMask(ele, comStyle) } else { const textWidth = getTextWidth(text, { fontSize, lineHeight, wordBreak, wordSpacing }) const textWidthPercent = textWidth / (width - parseInt(paddingRight, 10) - parseInt(paddingLeft, 10)) ele.style.backgroundSize = `${(textWidthPercent > 1 ? 1 : textWidthPercent) * 100}% ${px2relativeUtil(lineHeight, cssUnit, decimal)}` switch (textAlign) { case 'left': // do nothing break case 'center': ele.style.backgroundPositionX = '50%' break case 'right': ele.style.backgroundPositionX = '100%' break } } }
<div class="wrap">文本文本文本文本文本文本<span class="wrap-mask"></span></div>
.wrap{ background-image: linear-gradient(transparent 14.2857%, #EFEFEF 0%, #EFEFEF 85.7143%, transparent 0%) !important; background-size: 100% 0.4200rem; position: relative !important; } .wrap-mask{ display: inline-block; width: 50%; height: 21px; background: rgb(255, 255, 255); position: absolute; bottom: 14px; right: 14px; }
上面展示了这个工具基本脉络,说说一些有问题的地方
一切从用户出发,我们想做的事情是,把loading干掉,并且让用户产生页面已经在生成的感觉
并且不能过分影响效率
最后决定使用 chrome插件 + prerender + ElemeFE/page-skeleton的生成skeleton的思路
window.$0 通过这个属性就可以获取到在浏览器的选择的dom节点
window.$0
然后将选择的dom配合上上述的骨架屏生成就可以生成骨架屏了
// 设置 content script 监听命令,收到命令将结果发送出去 document.addEventListener('output', async ({ detail }) => { if (!isDOM(window.$0)) { chrome.runtime.sendMessage({ name: 'log', data: 'Page elements has not selected' }) return } const { html, style } = await outputSkeleton(window.$0, option) SKELETON_CACHE.html = html SKELETON_CACHE.style = style SKELETON_CACHE['currentSkeletonNode'] = html chrome.runtime.sendMessage({ name: 'getSkeletonInfo', data: { html: html.outerHTML, style: style.outerHTML } }) })
// devtool script 通过 background 转接过来的数据再通过调用 devtool 页面上的方法 contentScriptReceiver 把数据转发到页面 port.onMessage.addListener(message => { _window.contentScriptReceiver(message) })
// devtool 页面的代码 window.contentScriptReceiver = (data: any) => { // data 就是就是获取上面第一步生成出来的数据 }
// 将 html 和 style 装进 函数式 vue 容器里面 // 使用时,将对应组件作为slot传进骨架组件 // 在恰当的时间,把骨架组件的show这个prop设置为false // 从此loading是路人 <script> import Vue from 'vue' const skeletonLoader = { name: 'skeletocnLoader', functional: true, props: { show: { type: Boolean, default: false } }, render(h, context) { // 定义一个 props 当 show 为 false 的时候 // 展示 slot 里面的节点 const { show } = context.props if (!show) { const component = Vue.compile(html) return h(component) } else { return context.children[0] } } } export default skeletonLoader </script> <style> ${styles} </style>
以上就是整个预渲染和骨架屏的优化和总结
最后再复述下整个流程
js disable skeleton page
puppeteer-renderer
prerender-spa-plugin
自动化生成 H5 骨架页面
chrome 插件
函数式组件
yyds
牛
通过预渲染和骨架屏提神提升用户体验
作为使用 vue 这种 SPA 框架的我们来说,最大的难受点莫过于每次都得等 js 逻辑执行完成再渲染页面
对 seo 是极度不友好,而且首屏时间也会有一定的影响
解决方案有两个 1、使用预渲染,2、使用 ssr
预渲染
预渲染就是使用一个无头浏览器,把页面在浏览器里面跑一下,然后把页面拿出来,生成出 html string
核心思路如下
配合 webpack 在产出文件里面加上 输出文件 就行了
官方推荐的形式大概就是这个样子了,依赖 poputeer 来做这种操作
但是存在的问题是,页面上的数据是需要自己构造的,比如说一个列表
一般来说,我们的页面在请求接口的时候,是需要做 cookie 校验,那么在执行页面打开这个操作的时候
最好是要进行一次登录,或者通过配置或者在页面检测是预渲染的时候做一些有意义内容的展示
带着骨架屏的预渲染
why
目的
骨架页面是在页面真正解析和应用启动之前给用户展示页面的 CSS 样式和页面布局,并通过骨架页面的明暗变化, 告知用户页面正在努力加载中,让用户感知页面似乎加载得比以前快了。
简单的 loading 对用户的感知太弱了,骨架页面和真实页面样式布局完全一致, 在用户视觉感知上,骨架页面可以平滑的切换到真实数据渲染的页面。
原理
通过 puppeteer 在服务端操控 headless Chrome 打开开发中的需要生成骨架页面的页面, 在等待页面加载渲染完成之后,在保留页面布局> 样式的前提下,通过对页面中元素进行删减或增添, 对已有元素通过层叠样式进行覆盖,这样达到在不改变页面布局下,隐藏图片、文字和图片的展现, 通过样式覆盖,使得其展示为灰色块。然后将修改后的 HTML 和 CSS 样式提取出来,这样就是骨架页面了。
具体方案
选择方案
我们觉得一个比较好的接入方案应该是:
骨架屏可以自动生成,使用、维护和调整成本低,在不影响性能的前提下提升用户体验
所以最终决定使用 非侵入式骨架屏代码自动生成,生成对应 vue 组件形态的骨架屏
通过 slot 的方式将需要骨架屏渲染的部分传入,全面去除 loading 转圈圈这种用户体验较差的等待方式
所以其实本方案是对 ElemeFE/page-skeleton 的一个魔改
介绍下 ElemeFE/page-skeleton
流程上还是清晰的
关于整个处理我贴出了比较重要的部分 就是对文本进行处理
核心 traverse 处理
核心 text 处理
example
缺陷:
上面展示了这个工具基本脉络,说说一些有问题的地方
思考
一切从用户出发,我们想做的事情是,把loading干掉,并且让用户产生页面已经在生成的感觉
并且不能过分影响效率
落地
最后决定使用 chrome插件 + prerender + ElemeFE/page-skeleton的生成skeleton的思路
chrome 浏览器基本介绍
获取在浏览器开发者模式下的节点选择
window.$0
通过这个属性就可以获取到在浏览器的选择的dom节点然后将选择的dom配合上上述的骨架屏生成就可以生成骨架屏了
基础代码
总结
以上就是整个预渲染和骨架屏的优化和总结
最后再复述下整个流程
js disable skeleton page
puppeteer-renderer
prerender-spa-plugin
自动化生成 H5 骨架页面
chrome 插件
函数式组件