我们希望我们的Web应用程序感觉“即时”,页面之间没有任何丑陋的空白屏幕提醒我们,我们的应用程序并不是真正的应用程序。
空白屏幕会造成糟糕的用户体验。用户不想在单击链接或按钮时等待来自服务器的内容。他们希望网站能像本地应用程序一样快速。
因此,我们构建了单页面应用程序,其中只替换页面中更改的内容,避免重新加载整个页面,因此导航到另一个页面感觉很即时。
这样做的另一个好处是,现在我们只需要从服务器获取更改的内容,而不是整个新页面。这减少了我们需要从网络获取的数据量,使我们的应用程序速度更快。这是我们构建单页面应用程序的第二个主要原因。
我们现在绕过浏览器的路由,转而在客户机上自己处理。大多数情况下,还会添加一个前端框架来处理这些页面的呈现,这进一步增加了复杂性。
当然,现在框架可以做的更多,但这一切都始于消除页面之间的空白屏幕和减小有效负载大小的愿望。
如果我告诉你,你也可以有一个速度极快的多页应用程序,而页面之间没有任何空白屏幕,会怎么样?
这是一个不需要任何客户端路由的多页面应用程序,其中每个新页面都是重新加载整个页面,但只从服务器获取更改的内容。
让多页应用快速运行的诀窍实际上非常简单:我们利用浏览器的流式HTML解析器。
问题是浏览器在下载时呈现HTML。它不需要等待整个响应到达,但是一旦内容可用,它就可以开始呈现内容。
FETCH返回的响应对象在其Body属性中公开响应内容的ReadableStream,因此我们可以访问该响应并开始流式传输响应:
FETCH(';/ome/url';).Then(Response=>;response.body).Then(Body=>;{const read=body.getReader();//我们现在可以读取流了!}。
典型的单页面应用程序使用应用程序外壳,它实际上是注入内容的单个页面。它通常由页眉、页脚和放置每页内容的中间内容区域组成。
问题是,加载HTML页面后添加到HTML页面的任何内容都绕过了流HTML解析器,因此呈现速度较慢。
但是,我们可以通过让服务工作者获取我们需要的所有内容,并让它将所有内容流式传输到浏览器,从而从浏览器流中获益。
为此,我们需要将所有页面拆分为页眉和页脚,缓存这些模板,然后根据需要从网络获取正文内容。
服务工作者将拦截任何传出请求,获取页眉和页脚,然后确定需要获取哪些正文内容。这可以只是一个简单的HTML模板,也可以是模板和从网络获取的一些数据的组合。
然后,服务人员会将这些部分组合成一个完整的HTML页面,并将其返回给浏览器。它类似于服务器端呈现,但都是在客户端使用ReadableStream以流方式完成的。
这意味着它可以在内容和页脚仍在下载时开始呈现页面的页眉,从而带来巨大的性能优势。
让我们来看一下代码,特别是每当服务工作者截获传出请求时调用的FETCH事件处理程序:
Const fetchHandler=异步e=>;{const{request}=e;const{url,method}=request;const{pathname}=new URL(Url);const RouteMatch=routes.find(({url})=>;url=路径名);if(RouteMatch){e.respondWith(getStreamedHtmlResponse(url,RouteMatch));}Else{e.respondWith(caches.Match(Request).Then(Response=>;Response?返回:FETCH(REQUEST));}};self.addEventListener(';FETCH';,FETchHandler);
FetchHandler函数检查传入的请求,并尝试根据请求的url在routes数组中查找匹配的路由:
对于Home路由(‘/’),它将在home.js.html中的script标记内找到home.html模板和附带的JavaScript。
然后,服务工作者将获取模板header.html和footer.html,将它们与home.html和home.js.html组合成一个完整的HTML页面,并将其流回浏览器。
在前面的示例中,这是在getStreamedHtmlResponse函数中处理的。我们来看一下,
Const getStreamedHtmlResponse=(url,RouteMatch)=>;{const stream=new ReadableStream({async start(Controller){const push ToStream=stream=>;{const read=stream.getReader();return reader.read().Then(function process(Result){if(result t.do){return;}control er.enqueue(result t.value);return reader.read().Then(Process);});};Const[Header,Footer,Content,Script]=aWait Promise.all([caches.match(';/src/templates/header.html';),caches.match(';/src/templates/footer.html';),caches.Match.Match(routeMatch.Template),caches.Match(routeMatch.script)]);aWait Push ToStream(header.body);等待Push ToStream(content.body);等待Push ToStream(footer.body);Await Push ToStream(script.body);Controler.Close();}});//这里返回主体为流的响应返回新响应(stream,{Headers:{';Content-Type';:';text/html;charset=utf-8';}});};
在getStreamedHtmlResponse内部,我们构造一个新的ReadableStream,该新的ReadableStream将传递一个underlyingSource对象,其中包含在构造流之后立即调用的start方法。
向Start传递一个控制器参数,该参数是一个ReadableStreamDefaultController,它允许控制ReadableStream的内部状态和队列。
在start方法内部,我们获取HTML页面的模板,并使用push ToStream函数将模板内容作为单个流推送到主流中。
此函数逐个块地从模板块中读取各个流,并使用Controler.enqueue()将它们入队。
因为Start函数是异步的,所以立即返回一个新的响应,并将ReadableStream作为响应的主体。
浏览器现在可以流式传输响应,页面几乎立即出现在屏幕上。
让我们静下心来:我们现在可以像单页面应用程序一样,提供即时响应,但不会像单页面应用程序那样复杂。
这基本上将单页应用程序减少为多页填充,这是一个相当大胆的说法,但原因如下:
这个多页面应用程序的渲染速度与单页面应用程序一样快,甚至在页面大小增加时更快,因为我们使用了浏览器的流式HTML解析器。单页面应用程序绕过流解析器,无法利用它。
就像在单页应用程序中一样,只从网络获取更改的内容。但是,因为服务工作者缓存所有资产并在本地为它们提供服务,所以网络流量被限制在绝对最低限度。
您的应用程序的复杂性将大大降低。不再需要客户端路由,也不需要框架来呈现页面。服务工作器负责所有呈现,并在独立于主UI线程的自己的线程中操作。
服务器端渲染是免费的,只需将单独的模板缝合在一起,并像任何其他HTML页面一样提供服务。当服务工作者尚未控制页面时,这些将在第一次呈现时提供。在随后的呈现中,服务工作者将无缝地为缓存的页面提供服务,因为没有客户端路由需要处理。
现在你可能会想,当谈到速度和性能时,像这样的多页面应用程序是否真的能胜过单页面应用程序。
我创建了一个演示,这样您就可以亲身体验一下使用流HTML的多页面应用程序可以有多快。您可以在Github上找到源代码。
如果您在周围单击,您会注意到页面的页眉仍然牢牢地保持在原位,即使每个页面都需要重新加载整个页面,而且有些页面相当重。
如果我们使用流HTML解析器,这就是浏览器呈现DOM的能力。
浏览器并不慢。DOM并不慢。我们试图将单页面应用程序模型硬塞到一个本质上是多页的媒介中,通过向它扔框架和大量的库,这让它变得很慢。
使用服务工作者并正确使用浏览器的流式HTML解析器可以极大地提高Web应用程序的性能,并且通常完全违背了拥有单页面应用程序的目的。
与其向你的应用程序扔一个框架和十几个库,不如保持它的简单性,并使用这个平台。
你可以在Twitter上关注我,在那里我经常写关于PWAS、Web组件和现代Web功能的文章。