官网技术选型与性能优化:探索Islands架构与Qwik的奥秘

4种渲染策略

先简单介绍下Web开发中四种常见的渲染策略,它们分别是:

  1. 服务器端渲染(Server-side Rendering,SSR):这种策略是在服务器端将网页内容完全渲染好,然后将渲染好的HTML发送给客户端。客户端收到HTML后,只需要简单的展示即可。这种方式的好处是可以提供更好的SEO(搜索引擎优化)和首次加载性能,但在交互性方面相对较弱。Java的JSP、PHP都属于这种,现在主流是使用Node.js的框架,因为前后端都是JavaScript,易于前后端同构,在某些方面极致优化,这是其它开发语言做不到的。
  2. 客户端渲染(Client-side Rendering,CSR):这种策略是在客户端使用JavaScript的前端框架来渲染页面内容。客户端通常先拿到一个空的页面,再通过AJAX向服务器请求数据,并在客户端动态生成和渲染页面。这种方式的好处是可以提供更好的交互性和用户体验,但首次加载性能相对较差。自从前后端分离后,大部分工程都是开发阶段使用Node.js,生产阶段使用构建出的dist目录,部署在Nginx这类服务器上。
  3. 静态网站生成器(Static Site Generator,SSG):这种策略是在构建过程中,将所有页面提前渲染为静态HTML文件,并存储在服务器上。客户端请求页面时,服务器直接返回对应的静态HTML文件,无需进行动态渲染。这种方式适用于内容不经常变动的网站,可以提供非常快速的加载速度和安全性。它与CSR在生产部署时别无二致,理论上多页面应用打包产物与这是一样的。
  4. 增量静态再生(Incremental Static Regeneration,ISR):这种策略结合了SSR和SSG的优点,它允许在构建过程中选择性地预渲染某些页面,而不是全部页面。当某个页面被请求时,如果它已经被预渲染过,服务器会立即返回该页面的静态HTML。同时,服务器会在后台重新生成该页面的静态HTML,以确保下一次请求时能够返回最新的内容。 这样,ISR在保持静态网站的优势(快速加载、高性能)的同时,也能够实现动态内容的更新。它适用于需要频繁更新的网站,如新闻网站、博客等。

历史

旧版官网是用另一个团队使用Nuxt开发的,SSG生成HTML页面,部署在一台windows机器上,每次build需要花费30分钟。即使是在一台性能不错的Linux机器上,也需要20分钟。

我们团队在接手官网后,因为有了全新的UI设计,肯定要另起炉灶,就没有对原来的代码进行研究,正常来说Nuxt也不该这么不堪。

出于以下考虑,选择了NestJS+EJS+YAML+Sass方案:

  1. 为支持SEO,必须选择SSR
  2. Nuxt、Next这类全栈框架有一定心智成本,容易分不清什么是前端,什么是后端(其实有些多虑了)
  3. 官网的业务几乎全是静态页面,用不着那么复杂的功能
  4. 团队建立之初,我们就决定全面拥抱TypeScript,而NestJS对TS支持友好(其实还有非常强大的MVC,这个项目没有用到)
  5. 官网的元数据如文字、图片等可能是不定期变化的,由专门的官网维护人员修改,正好我们有个CDN平台,就让他们维护CDN的YAML文件,省得再开发一个管理页面
  6. 预处理CSS工具选择了Sass(当时对Tailwind CSS了解不多,而Tailwind CSS的评价迄今依然是两极分化)

但两年后,我们为什么要重构呢?原因有以下几点:

  1. 随着业务的发展,官网加入了登陆、留资、埋点、表单提交等功能,原来的客户端TS代码没有模块化的组织,逐渐变得臃肿难以维护
  2. EJS只是个模板引擎,太灵活了,没有用到TypeScript的优势,仍然存在单词拼错的低级错误的可能性
  3. 官网原有的服务端逻辑比较简单,只用了中间件来处理业务(读取CDN的YAML文件,找到对应的EJS组件,渲染出对应页面),重构成本较低

关于第一点,其实可以靠Gulp编译阶段使用插件来解决。为什么这么复杂呢?原因还是TypeScript。我们在浏览器中如果要直接使用ESM的话,代码是这样的:

import { test } from "aa.js";

必须加有.js后缀,而TypeScript的编译器tsc编译后,并没有后缀。所以我写了个插件,在编译后再把.js额外添加上。

这次重构为什么用Deno的Fresh框架,而不是Next或Nuxt呢?

  1. 我们团队在Deno上已经有了一年多的技术积累,U知(一个Wiki平台)已经稳定运行一年多没有出过问题,可以放心使用在官网上了
  2. Fresh框架有以下几个优点:
    1. 无构建步骤。得益于Esbuild的高性能,可以毫秒级转义tsx,不需要预先构建步骤。
    2. islands架构。islands架构是比较先进的技术理念。后来才发现Qwik的理念更先进和完备,但它是基于Node.js的,而且这时候我们已经迁移完成了,islands架构在实际体验中相差也不会太多,就不再折腾了。下文还会具体介绍islands架构与Qwik。
    3. 零运行时开销。默认情况下没有JS发送到前端。只有需要交互时(即用到island),前端才会用到JS。
    4. 开箱即用的 TypeScript。
    5. Preact非常小巧,只有3KB,而且加入了Signals,可以在不引入状态库的情况下跨组件共享状态
    6. TWind(在JS中使用Tailwind CSS的一种解决方案)在关键CSS的提取上更有天然的优势,非常适合官网这种场景(我们团队小伙伴在其它项目中体验到了Tailwind CSS的便捷,用了都说真香)
  3. 我们只有新页面使用Preact开发,旧的页面仍是原来的EJS渲染。关于客户端用到的TS模块化,我魔改了Fresh框架,使用Esbuild将之编译,省了原来基于Gulp的构建步骤。

优化

针对官网,我们做了以下优化处理。有些细节为什么要这么做,可以详见《Web性能优化》。

  1. 网络
    • HTTP2。使用HTTP2之前,JS、CSS和图片分别配置不同的域名。
    • 网络压缩:gzip或br压缩。
    • DNS预解析。用到的第三方网站
  2. 资源压缩
    • JS
    • CSS
    • HTML
    • 字体文件选用WOFF2格式
    • 图片转换为WebP格式,至少50%以上的压缩率
  3. 调整资源优先级。
    • JS文件,加async或defer
    • CSS文件,提取关键CSS,将关键CSS内嵌到HTML中。也就是说与首屏无关的,都异步加载。
    • 图片、视频懒加载(其实浏览器的预加载扫描器可以提前下载需要的图片,如果都放data-src的话,会损失这部分优化;忘了哪篇文章里看可能会加入data-src的扫描,但从实现上有些不合理,因为这样如果把图片都下载了,会浪费用户的带宽啊)
  4. 代码
    • 服务端优先响应缓存,后台更新缓存。因为用的EJS渲染,动态生成HTML,这个HTML的压缩过程耗时严重,以前为了及时更新YAML文件,对缓存设置时间太短,只有5分钟,改为现在的策略后,就只有少数用户会请求到旧的内容,大部分用户在缓存更新后就能拿到新的页面了(有点ISR的意思)
    • 视频改用我们团队的视频服务,这样不会下载完整的视频了(不过对性能优化不大,因为视频本身优先级就低,不会影响首屏渲染与页面交互)
  5. CDN
    • 使用CDN缓存页面一小时,但奇怪的是在阿里云的监控平台上,指标比不用时要差200ms以上。简直有点儿颠覆认知,暂时去掉了,后面观测如果流量上来了,考虑再加回来
  6. 使用百度、360埋点统计,加入阿里云平台性能分析(这点与优化无关,但方便我们排查问题和优化性能)

问题

因为加了阿里云的性能分析,所以可以很方便地在平台上看到JS错误率与具体错误信息,对各个性能指标针对性地进行优化。

除了上面提到的CDN缓存,还有个高频的JS错误引起我们的关注。

SSR渲染的页面,click事件写在EJS里,如果JS是异步加载,则有一种可能——用户看到了页面,但是click事件的方法还没有加载完成,这时用户点击按钮,就报错了。

<a alt="免费试用" onclick="openFile('免费试用','https://xxx')">
  <div class="btn button">免费试用
  </div>
</a>

以上面代码为例,报错信息自然是openFile未定义了。

为什么要在EJS中写这种原生的onclick呢?原因是元数据是在服务端取得的,在客户端JS中监听事件无法直接拿到对应的原数据。

解决方案

这时有多种解决方案:

  1. 将click事件的JS代码内嵌到EJS中。缺点是不利于JS代码复用。
  2. 将JS的click函数修改为将数据(来自服务端)挂载到DOM上,在外部JS中添加监听事件
  3. 在每个带click的DOM中添加disable属性禁用点击事件,在外部JS中放开
  4. 将现有所有click事件禁用,当文档加载完成后再放开

从修改成本上看,第4种是最简单的,所以在header中注入了一段简短的代码:

const stopClick = (event) => {
  event = event || window.event; //设置兼容性
  event.preventDefault ? event.preventDefault() : (event.returnValue = false); //设置兼容性
  event.stopPropagation ? event.stopPropagation() : (event.cancelBubble = true); //设置兼容性
};
document.addEventListener("click", stopClick, true);


document.addEventListener("readystatechange", () => {
  if (document.readyState === "complete") {
    document.removeEventListener("click", stopClick, true);
  }
});

这样虽然解决了JS报错的问题,但仍有一个弊端——用户如果在JS加载完成前,点击了按钮,但是并没有反应,虽然不报错,这一行为却也没有生效,会给用户带来误解。

所以,最好在页面上对禁止状态能有明显的区分。

Fresh的处理

从这个角度讲,Fresh框架也有这个问题,不过Preact与React对DOM上事件的处理是一样的,都是基于虚拟DOM,而不是在原生DOM节点监听的DOM事件。

function handleClick() {
  console.log('Button clicked');
}


function MyButton() {
  return <button onClick={handleClick}>Click me</button>;
}

所以,即使不做处理,用户在点击按钮后也不会报错,后续仍然可以工作。
就像上面说的,在JS加载完成前的空窗期,页面不能工作,所以Fresh的官方样例中是这样的:

import { useSignal } from "@preact/signals";
import { IS_BROWSER } from "$fresh/runtime.ts";
import { RoundedButton } from "../components/Button.tsx";
import { IconMinus, IconPlus } from "../components/Icons.tsx";

interface CounterProps {
  start: number;
}

export default function Counter(props: CounterProps) {
  const count = useSignal(props.start);
  return (
    <div class="bg-gray-100 p-4 border border-gray-200 flex items-center justify-around">
      <RoundedButton
        title="Subtract 1"
        onClick={() => count.value -= 1}
        disabled={!IS_BROWSER || count.value <= 0}
        >
        <IconMinus />
      </RoundedButton>
      <div class="text-3xl tabular-nums">{count}</div>
      <RoundedButton
        title="Add 1"
        onClick={() => count.value += 1}
        disabled={!IS_BROWSER}
        >
        <IconPlus />
      </RoundedButton>
    </div>
  );
}

IS_BROWSER是Fresh内置的,用来区分是执行环境是浏览器还是服务端,很简单:

export const IS_BROWSER = typeof document !== "undefined";

在JS加载完成前页面是这样的:
image.png
加载完成后变成了:
image.png
这样对用户的体验就比较友好了。
但不管怎样,终归不是完美的方案。这差不多就是islands架构的缺点了:

在JS加载完成的空窗期,用户无法点击页面。用专业的说法,就是页面的首次可交互时间(Time to Interactive,TTI)过长了。又或者说是首次输入延迟 (First Input Delay,FID)过长。

image.png

islands架构

说到了这里,就顺便一提什么是islands架构。

islands翻译过来就是岛屿或孤岛,从语义上讲,如果将HTML比作大海,那么岛屿就是连接两个世界(浏览器与服务器)的通道。

它是SSR(服务端渲染)的特有架构,islands与普通组件的区别在于,普通组件只是纯粹的静态DOM,而islands则要引入前端框架来维护状态(比如Fresh用的是Preact),在页面渲染完成后开始工作。

islands的代码会在两个域中运行,因此在开发时需要注意,不能直接使用浏览器特有的API(如document),也不能直接使用服务端特有的API(如Deno)。开发者需要知道代码块将在哪个域中运行。通常来说,像useEffect这样的Preact Hooks代码只会在浏览器中运行。

这种架构的优势在哪里呢?

  1. 轻量加载:如果某个页面不需要使用islands,前端框架(如Preact、React、Vue等)能够轻松分析出页面无需加载JavaScript的部分。这样一来,在页面加载时就无需包含核心框架的代码,从而减小了页面的加载大小。
  2. 按需加载:通过将业务逻辑细化到组件(即islands)级别,可以按需加载相应的JavaScript代码。举例来说,如果组件A和组件B都包含点击事件,并且组件A位于组件B上方,那么组件A所依赖的JavaScript代码必然会优先加载。一旦加载完成,就可以立即添加事件处理程序(这个过程通常被称为hydration,译为水合或注水,你可以想象为三体人泡水后复活的场景)。这样就减少了用户等待交互的时间,也就是上面说的TTI和FID。

完美方案Qwik

如果你是一个完美主义者,可能对这种现状并不满意。你可能希望实现与CSR(客户端渲染)类似的体验,即用户在看到页面时就能够直接进行交互。

那么,如何才能达到这个目标呢?一种可能的处理方式是在页面的header中注入一段代码,用于监听所有的DOM事件,并将用户的点击等操作记录下来。然后,在JavaScript加载完毕后,自动触发相应的记录下来的事件,以实现用户在页面加载完成后立即能够进行交互。

然而,要实现这样的功能,如果没有一个框架来处理,可能并不太优雅。更重要的是,用户的交互方式不仅仅局限于点击按钮,还可能涉及鼠标操作、键盘输入、滚动、屏幕触摸、进度条拖动等等一系列操作,这并不是一个简单的任务。

为了实现这样的目标,你可能需要一个现成的框架来帮助处理这些复杂的交互场景。正如前面提到的,Qwik就是一个可以考虑的框架选择。

来段代码show下:

import { component$, useSignal, $ } from '@builder.io/qwik';
import styles from './counter.module.css';
import Gauge from '../gauge';


export default component$(() => {
  const count = useSignal(0);


  const setCount = $((newValue: number) => {
    if (newValue < 0 || newValue > 100) {
      return;
    }
    count.value = newValue;
  });

  return (
    <div class={styles['counter-wrapper']}>
      <button class="button-dark button-small" onClick$={() => setCount(count.value - 1)}>
        -
      </button>
      <Gauge value={count.value} />
      <button class="button-dark button-small" onClick$={() => setCount(count.value + 1)}>
        +
      </button>
    </div>
  );
});

生成的HTML页面是这样的(开发阶段,生产也差不多):

<button class="button-dark button-small" on:click="/src/counter_component_div_button_onclick_1_lkcvrojx09y.js#counter_component_div_button_onClick_1_LkCVrojX09Y[0 1]" q:id="g" data-qwik-inspector="components/starter/counter/counter.tsx:21:7">+</button>

注意看,它的on:click的内容是/src/counter_component_div_button_onclick_1_lkcvrojx09y.js#counter_component_div_button_onClick_1_LkCVrojX09Y[0 1]

于是,我们可以想象它的工作原理了,很明显这个属性记录了JS所在的路径,和点击后需要触发的方法。当用户点击这个按钮时,浏览器开始下载JS文件,下载完成后,执行对应的方法。

这种处理方式,差不多是SSR框架能做到的极致方案了:用户可以立即与页面进行交互,大大缩短了TTI(可交互时间),是不是很棒?如果用户没有点击按钮,页面甚至不需要加载相应的JavaScript,实现了按需加载(懒加载)的极致效果。

image.png

用Qwik官网的宣传图来说,就是节省了传统SSR同构方案的水合阶段,无需为组件补水。因为水合作用是需要成本的,它一是下载组件模板,二是执行代码,三是绑定事件处理。详见Resumable vs. Hydration

你可能会说,那也不行啊,用户每次点击才下载,不是等待时间变长了吗?

这当然不是问题,现在都是工程化的时代,可以分析出来相应的代码,也可以经过配置,相当于在页面中加入prefetch进行下载。而且从组件的颗粒度来看,JS通常不会大到哪里去,就现今的网络发展来看,完全是绰有余裕的。

从下面的图片来看,Qwik生产时还加入了service worker优化,就是说用户第二次请求页面时会读取缓存。

image.png

所以,如果你对TTI有执念的话,那么Qwik框架将是你的不二选择。

总结

本文介绍了Web开发中的四种常见渲染策略:服务器端渲染(SSR)、客户端渲染(CSR)、静态网站生成器(SSG)和增量静态再生(ISR)。每种策略都有其适用场景和优劣势。

由于官网有SEO的要求,但是其元数据要求可以动态修改,所以SSG并不适用,比较适合我们的方案是SSR和ISR。

我们从当时的实际出发,选择了NestJS+EJS+YAML方案。但这种原始的开发模式随着业务的发展,逐渐变得不再适宜,越来越多的客户端TS代码没有模块化的组装导致逻辑难以复用和维护,于是在两年后进行了重构。

重构选择了Deno的Fresh框架,因为我们团队已经有了Deno的技术积累,并且Fresh框架的islands架构、Preact和Twind等特性更适合官网的需求。

本文还介绍了对官网进行的优化处理,包括网络优化、资源压缩、调整资源优先级、代码优化和使用CDN等。

最后,重点提到SSR渲染中的click事件在JS加载空窗期代码报错的问题,提出了解决方案。由此引出islands架构和这个问题的最终解决方案Qwik框架。

总的来说,本文全面介绍了Web开发中的渲染策略和官网开发中的选择和优化,为读者提供了有关渲染策略和官网开发的深入了解和实践经验。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYTWsXDh' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片