摘要:鉴于目前通行的做法就是在所有浏览器中一致同仁地加载,相比而言条件可以让大部分现代浏览器用户避免加载代码。
原文地址: Modern Script Loading, 文章作者是Preact作者Jason Miller
背景知识先简单介绍一下模块script(Module script), 它指的是现代浏览器支持通过来加载现代的ES6模块. 现代浏览器对ES6现代语法有良好的支持,这意味着我们可以给这些现代浏览器提供更紧凑的‘现代代码’,一方面可以减小打包的体积,减少网络传输的带宽,另外还可以提高脚本解析的效率和运行效率.
下图来源于module/nomodule pattern, 对比了模块script和传统(legacy) script的性能:
体积对比:
Version | Size (minified) | Size (minified + gzipped) |
---|---|---|
ES2015+ (main.mjs) | 80K | 21K |
ES5 (main.es5.js) | 175K | 43K |
解析效率:
Version | Parse/eval time (individual runs) | Parse/eval time (avg) |
---|---|---|
ES2015+ (main.mjs) | 184ms, 164ms, 166ms | 172ms |
ES5 (main.es5.js) | 389ms, 351ms, 360ms | 367ms |
Ok,为了兼容旧浏览器, module/nomodule pattern这篇文章介绍了一种module/nomodule 模式, 简单说就是同时提供两个script, 由浏览器来决定加载哪个文件:
看起来很美好是吧? 现实是:中间存在一些浏览器,它们可以识别模块script但是不认识nomodule属性, 这就导致了这些浏览器会同时加载这两个文件(下文统一称为‘双重加载’(over-fetching)).
OK,正式进入正文. 给正确的浏览器交付正确代码是一件棘手的事情。本文会介绍几种方式, 来解决上述的问题:
给现代浏览器伺服"现代的代码"对性能有很大的帮助。所以你应该针对现代浏览器提供包含更紧凑和优化的现代语法的Javascript包,同时又可以保持对旧浏览器的支持
现有的工具链的生态系统基本都是在module/nomodule模式上整合的,它声明式加载现代和传统代码(legacy code),即给浏览器提供两个源代码,让它来自己来决定用哪个:
然而现实总是给你当头一棒,它没我们期望的那么简单直接。上述基于HTML的加载方式在Edge和Safari中会被同时加载!
怎么办?怎么办?我们想依赖浏览器来交付不同的编译目标,但是一些旧浏览器并不能优雅地支持这种简洁的写法。
首先,Safari 在10.1开始支持JS模块, 但不支持nomodule属性。值得庆幸的是,Sam找到了一种方法,可以通过Safari 10和11中非标准的beforeload事件来模拟 nomodule, 也就是可以认为Safari 10.1开始是可以支持module/nomodule模式
选项1: 动态加载我们可以实现一个小型script加载器来规避这个问题,工作原理类似于LoadCSS。只不过这里需要依靠浏览器的来实现ES模块和nomodule属性.
我们首先尝试执行一个模块script进行"石蕊试验"(litmus test), 然后由这个试验的结果来决定加载现代代码还是传统代码:
然而,这个解决方案必须等待进行‘石蕊试验’模块script执行完成, 才能开始注入script。这是因为始终是异步的,所以别无它法(延迟到load事件后)。
另一种实现方式是检查浏览器是否支持nomodule, 这是方式可以避免上述的延迟加载问题, 只不过这意味着像Safari 10.1这些支持模块, 却不支持nomodule的浏览器也会被当做传统浏览器,这也许可能是好事(相对于两个脚本都加载以及有一些bug),代码如下:
var s = document.createElement("script") if ("noModule" in s) { // 注意这里的大小写 s.type = "module" s.src = "/modern.js" } else s.src = "/legacy.js" } document.head.appendChild(s)
现在把它们封装成函数,并确保两种方式都统一使用异步的方式加载(上文提到模块script是异步的,而传统script不是):
看起来已经很完美了,还有什么问题呢?我们还没考虑预加载(preloading)
这个有点蛋疼, 因为一般浏览器只会静态地扫描HTML,然后查找它可以预加载的资源。 我们上面介绍的模块加载器是完全动态的,所以浏览器在没有运行我们的代码之前,是没办法发现我们要预加载现代还是传统的Javascript资源的。
不过有一个解决办法,就是不完美:就是使用来预加载现代版本的包, 旧浏览器会忽略这条规则,然而目前只有Chrome支持这么做:
其实预加载这种技术是否有效,取决于嵌入你的脚本的HTML文档的大小。
如果你的HTML载荷很小, 比如只是一个启动屏或者只是简单启动客户端应用,那么放弃预加载扫描对你的应用性能影响很小。
如果你的应用使用服务器渲染大量有意义的HTML, 并以流(stream)的方式传输给浏览器,那么预加载扫描就是你的朋友,但这也未必是最佳方法。
译注: 现代浏览器都支持分块编码传输,等服务端完全输出html可能有一段空闲时间,这时候可以通过预加载技术,让浏览器预先去请求资源
大概代码如下:
还要指出的是,支持JS模块的浏览器一般也支持。对于某些网站,相比依靠modulepreload, 使用可能更有意义。不过性能上面可能欠点,因为传统的脚本预加载不会像modulepreload一样随着时间的推移而去展开解析工作(rel=preload只是下载,不会尝试去解析脚本)。
选项2: 用户代理嗅探我办法拿出一个简洁的代码示例,因为用户代理检测不在本文的范围之内,推荐阅读这篇Smashing Magazine文章
本质上,这种技术在每个浏览器上都使用来加载代码,当bundle.js被请求时,服务器会解析浏览器的用户代理,并选择返回现代代码还是传统代码,取决于浏览器是否能被识别为现代浏览器.
尽管这种方法比较通用,但它也有一些严重的缺点:
因为依赖于服务端实现,所以前端资源不能被静态部署(例如静态网站生成器(如github page),Netlify等等)
很难进行有效的缓存. 现在这些JavaScript URL的缓存会因用户代理而异,这是非常不稳定的, 而很多缓存机制只是将URL作为缓存键,现在这些缓存中间件可能就没办法工作了。
UA检测很难,容易出现误报
用户代理字符串容易被篡改,而且每天都有新的UA出现
解决这些限制的一种方法就是将module/nomodule模式与"用户代理区分"结合起来,首先这可以避免单纯的module/nomodule模式需要发送多个软件包问题,尽管这种方法仍然会降低页面(这时候指HTML,而不是Javascript包)的可缓存性,但是它可以有效地触发预加载,因为生成HTML的服务器根据用户代理知道应该使用modulepreload还是preload:
function renderPage(request, response) { let html = `...`; const agent = request.headers.userAgent; const isModern = userAgent.isModern(agent); if (isModern) { html += ` `; } else { html += ` `; } response.end(html); }
对于那些已经在使用服务端渲染的网站来说,用户代理嗅探是一个比较有效的解决方案
选项 3:不考虑旧版本浏览器注意这里的‘旧版本浏览器’特指那些出现双重加载的浏览器. 对于module/nomodule模式支持比较差(即双重加载)的主要是一些旧版本的Chrome、Firefox和Safari. 幸运的是这部分浏览器的市场范围通常是比较窄,因为用户会自动升级到最新的版本。Edge 16-18是例外, 但还有希望: 新版本的Edge会使用基于Chromium的渲染器,可以不受该问题的影响.
对于某些应用程序来说,接受这一点妥协是完全合理的:你可以给90%的浏览器中提供现代代码,让他们获得更好的体验,而极少数旧浏览器不得不抛弃它们,它们只是付出的额外带宽(即双重加载),并不影响功能。值得注意的是,占据移动端主要市场份额的用户代理不会有双重加载问题,所以这些流量不太可能来自于低速或者高昂流量费的手机。
如果你的网站用户主要使用移动设备或较新版本的浏览器,那么最简单的module/nomodule模式将适用于你的绝大多数用户, 其他用户就不考虑了,反正也是可以跑起来的, 优先考虑大多数用户的体验。
选项 4: 使用条件包
nomodule可以巧妙地用来条件加载那些现代浏览器不需要的代码, 例如polyfills。通过这种方法,最坏的情况就是polyfill和bundle都会被加载(例如Safari 10.1),但这毕竟是少数。鉴于目前通行的做法就是在所有浏览器中一致同仁地加载polyfills,相比而言, 条件polyfills可以让大部分现代浏览器用户避免加载polyfill代码。
Angular CLI支持配置这种方式来加载polyfill, 查看Minko Gechev的代码示例.
了解了这种方式之后,我决定在preact-cli中支持自动polyfill注入,你可以查看这个PR
如果你使用Webpack,这里有一个html-webpack-plugin插件可以方便地为polyfill包添加nomodule属性.
你应该怎么做?答案取决于你的使用场景, 选择和你们的架构匹配的选项:
如果你的应用只是客户端渲染, 而且你的HTML不超过一个,选项1比较合适;
如果你的应用使用服务端渲染,而且可以接受缓存问题,那么可以选择选项2;
如果你开发的是同构应用,预加载的功能可能对你很重要,这时你可以考虑选项3和4.
就我个人而言,相比考虑桌面端浏览器资源下载成本,我更倾向于优化移动设备解析时间. 移动用户体验会受到数据解析、流量费用,电池消耗等因素的影响,而桌面用户往往不需要考虑这些因素。
另外这些优化适用于90%的用户,比如我工作面对的大部分用户都是使用现代或移动浏览器的。
有兴趣继续深入?可以从下面的文章开始挖掘:
Phil的webpack-esnext-boilerplate的一些附加的背景.
Ralph在Next.js中实现了module/nomodule, 并努力解决了上面的问题.
感谢Phil, Shubhie, Alex, Houssein, Ralph 以及 Addy 的反馈.
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/105816.html
摘要:异步请求线程在在连接后是通过浏览器新开一个线程请求,将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到引擎的处理队列中等待处理。 浏览器的主要功能是将用户选择的 web 资源呈现出来,它需要从服务器请求资源,并将其显示在浏览器窗口中,资源的格式通常是 HTML,也包括 PDF、image 及其他格式。 浏览器的线程 浏览器是多线程的,它们在内核制控下相互配合以保持同...
摘要:异步请求线程在在连接后是通过浏览器新开一个线程请求,将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到引擎的处理队列中等待处理。 浏览器的主要功能是将用户选择的 web 资源呈现出来,它需要从服务器请求资源,并将其显示在浏览器窗口中,资源的格式通常是 HTML,也包括 PDF、image 及其他格式。 浏览器的线程 浏览器是多线程的,它们在内核制控下相互配合以保持同...
摘要:启动性能瓶颈分析与解决方案翻译自的,从属于笔者的前端入门与工程实践。我们必须要清醒地认识到全面评测以挖掘出真正性能瓶颈的重要性。这可能是最佳的方式了,类似于这样的模式鼓励基于路由的分组,目前被与广泛使用。 JavaScript 启动性能瓶颈分析与解决方案 翻译自 Addy Osmani 的 JavaScript Start-up Performance,从属于笔者的Web 前端入门与工...
摘要:而且默认带有执行的顺序是,,即便是内联的,依然具有属性。模块脚本只会执行一次必须符合同源策略模块脚本在跨域的时候默认是不带的。通常被用作脚本被禁用的回退方案。最后标签真的令人感到兴奋。 窥探 Script 标签 0x01 什么是 script 标签? script 标签允许你包含一些动态脚本或数据块到文档中,script 标签是非闭合的,你也可以将动态脚本或数据块当做 script 的...
摘要:而且默认带有执行的顺序是,,即便是内联的,依然具有属性。模块脚本只会执行一次必须符合同源策略模块脚本在跨域的时候默认是不带的。通常被用作脚本被禁用的回退方案。最后标签真的令人感到兴奋。 窥探 Script 标签 0x01 什么是 script 标签? script 标签允许你包含一些动态脚本或数据块到文档中,script 标签是非闭合的,你也可以将动态脚本或数据块当做 script 的...
阅读 3169·2021-09-10 10:51
阅读 3350·2021-08-31 09:38
阅读 1638·2019-08-30 15:54
阅读 3128·2019-08-29 17:22
阅读 3213·2019-08-26 13:53
阅读 1959·2019-08-26 11:59
阅读 3283·2019-08-26 11:37
阅读 3307·2019-08-26 10:47