本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
SPA(单页应用)在初次加载时,由于需要加载所有必要的 JavaScript 和 CSS 文件,以及应用的主 HTML 文件,因此可能会产生白屏时间较长的问题,对用户体验而言是非常糟
其中白屏时间主要影响因素之一:SPA 应用在加载完成后,需要再进行一次 DOM 渲染才能显示页面内容。在渲染过程中,可能需要加载大量的 JavaScript 文件、CSS 文件或网络请求,这些操作都需要耗费时间,从而导致白屏时间变长。
对单页面应用进行预渲染,将页面在打包期间渲染成静态 HTML 文件,可以很好的解决白屏时间过长问题
预渲染的几个优势:
- 优化 SEO
由于单页面应用通常只有一个入口 HTML 文件,因此其页面内容无法被搜素引擎爬虫捕获到。而使用预渲染功能,可以让项目构建出包含所有动态内容的静态 HTML 页面,从而被搜索引擎爬虫作为内容来源,提高 SEO 优化效果。
- 更快的加载速度
使用预渲染功能,可以将动态生成的部分预先生成静态文件,无需等到页面加载完成后再生成,从而提高网站的加载速度。
- 更好的用户体验
预渲染后,用户进入网站时可以更快地获取到内容,可以提高用户的体验。
- 减轻服务端压力
使用静态资源替代计算资源,可以减轻服务端的压力。预渲染后的页面不需要借助服务器的计算资源,减轻了服务器的压力,提高了页面处理效率。
核心流程
社区也提供了prerender-spa-plugin 这类插件,可以直接集成到项目中使用,由于得物预发、正式环境静态资源都是应用 cdn 的,会导致预渲染异常。本地启动服务,cdn 上无对应资源。最终由团队内手动实现一款具备相同功能的static-generator插件
核心流程
接下来通过代码简单的看一下其各个环节是如何做的
首先需要做的,定义一个 gererate 函数和一个 Renderer 类
const generate = () => {}class Renderer {}gererate 主要是用于处理参数和流程处理
Renderer 主要是用于启动无头浏览器生成 HTML
首先看一下 Renderer 是如何生产 HTML 的:
核心是使用 puppeteer
Puppeteer 是一个由 Google 推出的 Node.js 库,它提供了一个高级 API ,可以使用 Headless Chrome 或 Chromium 来控制 Chrome 或 Chromium 的行为,用于测试、屏幕截图和数据爬取等。
Puppeteer 可以模拟人类的操作,比如点击、填充表单、下拉、切换页面、截图等,同时还可以拦截网络请求和处理 Cookies 等功能。由于其灵活性和易用性,许多开发者使用它进行爬虫、测试、数据分析等任务。
相关代码:利用 puppeteer 启动一个无头浏览器获取页面的 HTML
const getHtml = async ({ userAgent, onRequest, url }) => { const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }) // 启动无头浏览器 const page = await browser.newPage() await page.setViewport({ width: 375, height: 812 }) if (userAgent) await page.setUserAgent(userAgent) // 页面实例上下文中执行的方法 await page.evaluateOnNewDocument(() => (window['_prerender_'] = 'prerender')) // 启用请求拦截器 await page.setRequestInterception(true) // 监听请求 page.on('request', (request) => onRequest?.({ request, pageUrl: url, page })) await page.goto(url) // 页面实例上下文中执行的方法 await page.evaluate(observeRender) // 默认5000ms页面加载完成 const content = await page.content() await page.close() return content}上述代码除了生成 HTML 外,还有一个十分重要的事情 page.on('request',(request) => onRequest),其拦截了页面的所有请求,将所有的 CSS 资源进行了缓存
onRequest实现
const cssContent = {}const onRequest = asycn ({ request }) => { const body = await read(url) if (cssInline && filename.endsWith('.css')) { const byteLength = body.byteLength const { maxSize = Infinity, exclude, include } = cssInline if (!(byteLength > maxSize || exclude?.test(filename)) && (!include || include.test(filename))) { // 把css 先存起来 cssContent[filename] = body.toString() } } return request.respond({ status: 200, body, })}其核心就做了两个事情
获取 css 文件的内容
缓存到 cssContent 中,后面生成 html 时使用
至此已经可以获取到 HTML 和所有的 CSS 了,那么接下来要做的便是将新的 HTML 替换老的 HTML,并将所有通过 link 标签引入的 css 资源移除,换成 style 标签包裹的内联 CSS
再看一下 gererate 函数,内部首先是对参数进行格式化处理
const generate = async (config: GenerateConfigParams) => { const generate = async (config: GenerateConfigParams) => { const { sourceDir, //源文件目录 staticDir = sourceDir, // 静态资源打包目录 pages, // 页面路由 baseUrl = 'https://m.dewu.com', cdn = '', userAgent = DWUA, postProcess, // 处理生成后的html inject, } = config const mutation = typeof config.mutation === 'string' ? [config.mutation] : config.mutation const renderer = new Renderer() renderer.pages = pages renderer.baseUrl = baseUrl renderer.userAgent = userAgent const sourceHTMLPath = join(sourceDir, 'index.html') renderer.postProcess = ({ html, page }) => { if (mutation) { const dom = new JSDOM(sourceHTML) const newDom = new JSDOM(html) // 将新的HTML替换老的HTML mutation.forEach((mutation) => { const oldNode = dom.window.document.querySelector(mutation) as HTMLElement const newNode = newDom.window.document.querySelector(mutation) as HTMLElement if (oldNode) { oldNode.innerHTML = newNode?.innerHTML ?? '' } }) // 将所有通过link标签引入的css资源移除,换成style标签包裹的内联CSS const links = newDom.window.document.querySelectorAll('link') for (const link of links) { const originHref = link.getAttribute('href') ?? '' const content = cssContent[originHref.split('/').pop() ?? ''] if (content) { const newStyle = dom.window.document.createElement('style') newStyle.innerHTML = content newStyle.setAttribute('data-href', originHref) newStyle.setAttribute('data-css', 'inline') dom.window.document.head.appendChild(newStyle) const link = dom.window.document.querySelector(`link[href="${originHref}"]`) if (link) { link.parentElement?.removeChild(link) } } } html = dom.serialize() } return { html, page, } } renderer.onRequest = () => { // 上面的 onRequest 函数 .... } }}预渲染核心的三部分便大致如上述代码,
需要注意的是:接入预渲染的时候,需要找运维同学配合修改一些 Nginx 的配置,
主要是对路由 进行 文件重定向
慎用三方库
业务中存在一些简单的校验、转换和动效并不需要引入三方库,尤其是因为一个较为简单的功能引入了一个较为大且冷门的库时,不仅会增加项目的打包体积,还会增加项目后续维护的沟通、学习成本。
例如下面一个简单切换动效
img
是一个比较常规的切换动效,却在项目中引入了一个 62.6kb 大小的第三方库
img
该库的使用也是有一些学习成本,因为其具备实现比较复杂的动效能力,在业务动效具备一定复杂度且非首屏的场景下,是可以考虑引入使用的,否则类似这种首屏便需要加载的动效,还是慎重
上述的切换动效 CSS 实现代码如下
@keyframes bigScale { 0% { opacity: 0; transform: scale(0.95); } to { transform: scale(1); opacity: 1; }}@keyframes smallScale { 0% { transform: scale(1); opacity: 1; } to { transform: scale(0.95); opacity: 0; }}.squareInCenter { animation: 0.3s linear 0s 1 normal forwards running bigScale;}.squareOutCenter { animation: 0.3s linear 0s 1 normal forwards running smallScale;}在业务开发的过程中,尤其是 C 端的页面,在实现功能时对于引入额外的库是一件需要十分谨慎的事情,在内部就看到不少项目在引入关于日期处理方面的库时,dayjs、momentJS 同时都会引用到项目中,B 端项目都不能忍,更何况 C 端项目
字体使用和优化
字体加载和优化是前端开发中的一个重要问题,特别是在移动端和低网络状况下。下面是一些字体加载和优化的技巧
FOUT 问题
通过设置 font-display 属性可以控制字体加载时的显示效果,包括 auto、swap、block、fallback 和 optional 几种模式,可以减少字体加载时间和防止文本闪烁
设置属性为 fallback 时效果
img
可以看到日期存在明显的 FOUT(无样式文本闪现)问题,设置 swap 也是类似效果,并不符合预期
设置属性为 block 时效果
img
可以看到第一时间并没有渲染日期,而是有点的短暂空白,因为其可以避免 FOUT,字体文件必须在后台下载完全后,文本才能显示
最终选择了font-display: block;效果会更好一些
注意,并不是整个页面都使用 block 属性,对于一些非首屏关键渲染的样式,使用fallback更为合适一些,因为其会使用浏览器默认字体,所以还是需要结合业务、场景合理使用
字体 **** 库大小,你得懂
先看一个 gpt 对于签到业务常用字体库打下的统计
DIN Condensed 字体库的大小在几百 KB 到几 MB 之间
Helvetica Neue 字体库的大小在几 MB 到十几 MB 之间
也就是这两种字体的大小,如果不加以处理,全部加载的大小在几 MB 到十几 MB 之间,对于前端项目而言,这是挺夸张的一件事
可以和设计人员沟通,将字体库中常用的字体导出,前端项目仅仅引入需要的字体就好,比如 DIN Condensed 字体都是使用在阿拉伯数字上,并不会在其他字上使用,那么只需要将阿拉伯数字导出即可。比如汉字,根据《现代汉语通用字表》(GB/T 13000-2018),常用汉字(包括简体字和繁体字)共计 3500 个,其中常用的一般是指前 1000 个左右的汉字,那么在使用字体库的时候,是不是可以默认只需要导出部分即可。
经过处理后的字体库大小如下图
img
字体 **** 库数量,你得控制
上面说了一个字体库的大小是多大,就算是经过处理,最少也会有 30KB 大小,所以项目引入的字体种类是需要控制的,不能设计同学使用了多少种类字体设计,我们就要照单全收
当设计同学新增字体库时,如果字体使用在 3 次以内,是不是可以使用图片来代替文字,或者使用现有的字体库来平替
提高稳定性
在优化的过程中,移除了大量的废弃接口、ab 和代码逻辑,这样做的代价必然是会造成一些问题,毕竟不管代码现状是怎样,只要线上能跑起来就是可以的,一般也不会大刀阔斧的去改造原有的代码,本着代码可维护性 (避免日后接手代码的人内心🐶) 的原则,最终还是对其动了手
既然已经选择了动手,那么就要思考一下如何确保稳定性,毕竟生产还是需要敬畏的,否则造成什么比较阻塞性的 bug,那可真的是好心办坏事了,关于如何保证稳定性,我是从下面这几方面入手
可行性和风险评估
每次改动和优化代码之前,我会先对功能进行整体的回归一下,再查看对应的代码,在查看代码的过程中,我会确定几个事情
页面的功能是否存在多种业务逻辑判断的情况,比如符合条件 A,执行弹框的逻辑,符合添加 B,执行接口调用逻辑。若是业务逻辑较为复杂,那么改造和优化的成本会很大,ROI 会比较低
是否为阻塞性的功能,比如新人引导流程的功能,这个流程出现问题则是非常严重的,这类代码保持能不动则不动
该功能模块迭代的频率,比如像商品流这块功能,仅近半年就经历过 3 次大的功能改版,每一次改版都是基于老的业务进行修修补补,导致代码就很难维护且这个痛经常存在,这种改造一下的 ROI 就会高一些
功能模块的大小和耦合度,比如下文说的 MallScrollShowMore 组件,其就是一个单独且较小的功能模块,改动速度快,且改造过程中关注点更多的只需要放在组件内部就好,这种改动 ROI 也会较高
结合上面的几点,我会综合考虑我接下来要做的事情可行性是怎样的,这么做带来的风险有多大,个人是的风格是,例如 MallScrollShowMore 组件,其就是一个非常独立的、功能小且非阻塞性功能的组件,可行性高,并不具备太多的改造风险,那么我就会撸起袖子干
做好记录和改动点
由于是优化中携带的代码改动,需要自己做好改动点的沉淀,不仅方便测试回归对应的地方,也便于自测
当发现组件设计或者实现有问题时,我会作记录
严格执行自测
记录只是第一步,测试周开始前,需要针对本次的所有改动进行自测,因为很多改动在这个时候,作为研发的我才是最熟悉整个项目的了,这个时候其实个人才是测试主力,测试同学帮忙回归和验证核心流程。在改造过程中记录的改动点就是测试用例,需要严格执行和回归,毕竟功能优化、改造时,自己才是第一责任人。