能大家在看到这个标题的时候,会觉得,只不过又是一篇烂大街的 SSR 从零入门的教程而已。别急,往下看,相信你或多或少会有一些不一样的收获呢。
在落地一种技术的时候,我们首先要想一想:
上面三个问题思考清楚之后,才能真正地去落地。上面三个问题思考清楚之后,才能真正地去落地。而有赞教育接入服务端渲染,正是为了优化 H5 页面的首屏内容到达时间,带来更好的用户体验(顺便利于 SEO)。
说了这么多,以下开始正文。
一、后端模版引擎时代
在较早时期,前后端的配合模式为:后端负责服务层、业务逻辑层和模版渲染层(表现层);前端只是实现页面的交互逻辑以及发送 AJAX。比较典型的例子就是 JSP 或 FreeMarker 模板引擎负责渲染出 html 字符串模版,字符串模版里的 js 静态资源才是真正前端负责的东西。
而这种形式,就是天然的服务端渲染模式:用户请求页面 -> 请求发送到应用服务器 -> 后端根据用户和请求信息获取底层服务 -> 根据服务返回的数据进行组装,同时 JSP 或 FreeMarker 模版引擎根据组装的数据渲染为 html 字符串 -> 应用服务器讲 html 字符串返回给浏览器 -> 浏览器解析 html 字符串渲染 UI 及加载静态资源 -> js 静态资源加载完毕界面可交互。
那么既然后端模版引擎时代带来的效果就是我们想要的,那为啥还有以后让前端发展服务端渲染呢?因为很明显,这种模式从开发角度来讲还有挺多的问题,比如:
二、SPA 时代
后来,诞生了 SPA(Single Page Application),解决了上面说的部分问题:
但同时,也带来了一些问题:
三、服务端渲染
正因为 SPA 带来的一些问题(尤其是首屏白屏的问题),接入服务端渲染显得尤为必要。// 终于讲到服务端渲染这个重点了。
而正是 Node 的发展和基于 Virtual DOM 的前端框架的出现,使得用 js 实现服务端渲染成为可能。因此在 SPA 的优势基础上,我们顺便解决了因为 SPA 引入的问题:
3.1 实现
既然服务端渲染能带来这么多好处,那具体怎么实现呢?从官网给出的原理图,我们可以清晰地看出:
3.2 优化
按照 Vue SSR 官方文档建立起一个服务端渲染的工程后,是否就可以直接上线了呢?别急,我们先看看是否有什么可以优化的地方。
3.2.1 路由和代码分割
一个大的 SPA,主文件 js 往往很大,通过代码分割可以将主文件 js 拆分为一个个单独的路由组件 js 文件,可以很大程度上减小首屏的资源加载体积,其他路由组件可以预加载。
复制代码
// router.js constIndex=()=>import(/* webpackChunkName: "index" */'./pages/Index.vue'); constDetail=()=>import(/* webpackChunkName: "detail" */'./pages/Detail.vue'); constroutes=[ { path:'/', component: Index }, { path:'/detail', component: Detail } ]; constrouter=newRouter({ mode:'history', routes });
3.2.2 部分模块(不需要 SSR 的模块)客户端渲染
因为服务端渲染是 CPU 密集型操作,非首屏的模块或者不重要的模块(比如底部的推荐列表)完全可以采用客户端渲染,只有首屏的核心模块采用服务端渲染。这样做的好处是明显的:1. 较大地节省 CPU 资源;2. 减小了服务端渲染直出的 html 字符串长度,能够更快地响应给浏览器,减小白屏时间。
复制代码
// Index.vue asyncData({ store }) { returnthis.methods.dispatch(store);// 核心模块数据预取,服务端渲染 } mounted() { this.initOtherModules();// 非核心模块,客户端渲染,在 mounted 生命周期钩子里触发 }
3.2 3 页面缓存 / 组件缓存
页面缓存一般适用于状态无关的静态页面,命中缓存直接返回页面;组件缓存一般适用于纯静态组件,也可以一定程度上提升性能。
复制代码
// page-level caching constmicroCache=LRU({ max:100, maxAge:1000// 重要提示:条目在 1 秒后过期。 }) server.get('*', (req, res)=> { consthit=microCache.get(req.url) if(hit) {// 命中缓存,直接返回页面 returnres.end(hit) } // 服务端渲染逻辑 ... })
复制代码
// component-level caching // server.js constLRU=require('lru-cache') constrenderer=createRenderer({ cache: LRU({ max:10000, maxAge: ... }) }); // component.js exportdefault{ name:'item',// 必填选项 props: ['item'], serverCacheKey:props=>props.item.id, render (h) { returnh('div',this.item.id) } };
3.2.4 页面静态化
如果工程中大部分页面都是状态相关的,所以技术选型采用了服务端渲染,但有部分页面是状态无关的,这个时候用服务端渲染就有点浪费资源了。像这些状态无关的页面,完全可以通过 Nginx Proxy Cache 缓存到 Nginx 服务器,可以避免这些流量打到应用服务器集群,同时也能减少响应的时间。
3.3 降级
进行优化之后,是否就可以上线了呢?这时我们想一想,万一服务端渲染出错了怎么办?万一服务器压力飙升了怎么办(因为服务端渲染是 CPU 密集型操作,很耗 CPU 资源)?为了保证系统的高可用,我们需要设计一些降级方案来避免这些。具体可以采用的降级方案有:
3.4 上线前准备
3.4.1 压测
压测可以分为多个阶段:本地开发阶段、QA 性能测试阶段、线上阶段。
3.4.2 日志
作为生产环境的应用,肯定不能“裸奔”,必须接入日志平台,将一些报错信息收集起来,以便之后问题的排查。
3.4.3 灰度
如果上线服务端渲染的工程是提供核心服务的应用,应该采用灰度发布的方式,避免全量上线。一般灰度方案可以采用:百分比灰度、白名单灰度、自定义标签灰度。具体采用哪种灰度方式看场景自由选择,每隔一段时间观察灰度集群没有问题,所以渐渐增大灰度比例 / 覆盖范围,直到全量发布。
3.5 落地
在有赞电商的服务端渲染的落地场景中,我们抽离了单独的依赖包,提供各个能力。
3.6 效果
从最终的上线效果来看,相同功能的页面,服务端渲染的首屏内容时间比客户端渲染提升了 300%+。
3.7 Q & A
Q1:为什么服务端渲染就比客户端渲染快呢?
A:首先我们明确一点,服务端渲染比客户端渲染快的是首屏的内容到达时间(而非首屏可交互时间)。至于为什么会更快,我们可以从两者的 DOM 渲染过程来对比:
客户端渲染:浏览器发送请求 -> CDN / 应用服务器返回空 html 文件 -> 浏览器接收到空 html 文件,加载的 css 和 js 资源 -> 浏览器发送 css 和 js 资源请求 -> CDN / 应用服务器返回 css 和 js 文件 -> 浏览器解析 css 和 js -> js 中发送 ajax 请求到 Node 应用服务器 -> Node 服务器调用底层服务后返回结果 -> 前端拿到结果 setData 触发 vue 组件渲染 -> 组件渲染完成
服务端渲染:浏览器发送请求 -> Node 应用服务器匹配路由 -> 数据预取:Node 服务器调用底层服务拿到 asyncData 存入 store -> Node 端根据 store 生成 html 字符串返回给浏览器 -> 浏览器接收到 html 字符串将其激活:
我们可以很明显地看出,客户端渲染的组件渲染强依赖 js 静态资源的加载以及 ajax 接口的返回时间,而通常一个 page.js 可能会达到几十 KB 甚至更多,很大程度上制约了 DOM 生成的时间。而服务端渲染从用户发出一次页面 url 请求之后,应用服务器返回的 html 字符串就是完备的计算好的,可以交给浏览器直接渲染,使得 DOM 的渲染不再受静态资源和 ajax 的限制。
Q2:服务端渲染有哪些限制?
A:比较常见的限制比如:
Q3:如果我的需求只是生成文案类的静态页面,需要用到服务端渲染吗?
A:像这些和用户状态无关的静态页面,完全可以采用预渲染的方式(具体见 Vue SSR 官方指南),服务端渲染适用的更多场景会是状态相关的(比如用户信息相关),需要经过 CPU 计算才能输出完备的 html 字符串,因此服务端渲染是一个 CPU 密集型的操作。而静态页面完全不需要涉及任何复杂计算,通过预渲染更快且更节省 CPU 资源。
、为什么xml
需要服务器端返回少量的、单一的数据
用户名是否可用 1 / 0
返回两个数的和 400
登录是否成功 true/false
数据插是否成功 true/false
如果我们需要从服务器端返回大量、复杂的数据,如何实现?
xml:服务器端返回xml数据
json:服务器端返回json数据
2、格式:
(1)php解析xml
$dom=new DOMDocument();
$dom->loadXML($str);
$nd=$dom->getElementsByTagName("TagName");
$value=$nd->item(0)->nodeValue
$xml=simplexml_load_string($str);
$first=$xml->first;
$second=$xml->second;
(2)js解析xml
var xml=xmlHttp.responseXML;
node=xml.getElementsByTagName("TagName");
node[0].childNodes[0].nodeValue;
3 、案例1:
实现两个数的四则运算
HTML代码:
<script language="javascript" src="public.js"></script>
<script>
window.onload=function(){
$('btnOk').onclick=function(){
var f=$('first').value;
var s=$('second').value;
var data='first='+f+'&second='+s;
var xhr=createxhr();
xhr.open('post','demo01.php');
xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
xhr.onreadystatechange=function(){
if(xhr.readyState==4 && xhr.status==200){
//xml --->xml dom对象
var xml=xhr.responseXML;
var str=xml.getElementsByTagName('jia')[0].childNodes[0].nodeValue;
str+='|'+xml.getElementsByTagName('jian')[0].childNodes[0].nodeValue;
str+='|'+xml.getElementsByTagName('cheng')[0].childNodes[0].nodeValue;
str+='|'+xml.getElementsByTagName('chu')[0].childNodes[0].nodeValue;
$('result').innerHTML=str;
}
};
xhr.send(data);
};
};
</script>
<input type="text" id="first" /><br>
<input type="text" id='second' /><br>
<div id='result'></div>
<input type="button" id="btnOk" value="计算" />
理解:
var xml=xhr.responseXML; 得到ajax返回的xmldom对象
xml.getElementsByTagName('jia')[0] :是表示获取jia这个元素
xml.getElementsByTagName('jia')[0].childNodes:表示获取jia元素下的所有子节点
xml.getElementsByTagName('jia')[0].childNodes[0] :表示获取jia元素下的唯一文本节点
xml.getElementsByTagName('jia')[0].childNodes[0].nodeValue:文本节点的值
php代码:
<?php
$first=$_POST['first'];
$second=$_POST['second'];
$result1=$first+$second;
$result2=$first-$second;
$result3=$first*$second;
$result4=$first/$second;
//要想返回xml,首先连接一个xml格式的字符串
$str='<root>';
$str.='<jia>'.$result1.'</jia>';
$str.='<jian>'.$result2.'</jian>';
$str.='<cheng>'.$result3.'</cheng>';
$str.='<chu>'.$result4.'</chu>';
$str.='</root>';
/*$str=<<<str
<root>
<jia>$result1</jia>
</root>
str;*/
header('Content-type:text/xml');
echo $str;
理解:
得到结果后,需要使用字符串连接成一个xml格式的字符串,如:需要一个根元素,下面子元素,最后是具体的值,
连接时也可以使用<<<str创建xml字符串
str;
输出这个字符串时,默认的响应内容类型:text/html,也就是说客户端仍把代码当做html来进行解析,
ajax对象的responeXML是不能得到一个xmldom对象,必须设置响应头类型为:text/xml,其代码:header('Content-type:text/xml');
public.js:
function createxhr() {
/*var xhr;
var str=window.navigator.userAgent;
if (str.indexOf('MSIE') > 0) {
xhr=new ActiveXObject('Microsoft.XMLHTTP');
} else {
xhr=new XMLHttpRequest();
}
return xhr;*/
try{return new XMLHttpRequest();}catch(e){}
try{return new ActiveXObject('Microsoft.XMLHTTP'); }catch(e){}
alert('请更换浏览器!');
}
function $(id){
return document.getElementById(id);
}
4、案例2
在页面加载之后,将mysql数据库goods表中所有数据显示在表格中
<root>
<goods>
<name>222</name>
<price>55.00</price>
</goods>
<goods>
<name>诺 E661</name>
<price>205.00</price>
</goods>
<goods>
<name>诺 E661</name>
<price>200.00</price>
</goods>
</root>
HTML代码:
<style>
tr{
background-color:#ffffff;
height:30px;
font-size:12px;
}
</style>
<script language="javascript" src='public.js'></script>
<script>
window.onload=function(){
var xhr=createxhr();
xhr.open('post','demo02.php');
xhr.setRequestHeader('Content-type','application/x-www-form-urlencoded');
xhr.onreadystatechange=function(){
if(xhr.readyState==4 && xhr.status==200)
var xml=xhr.responseXML;
var goods=xml.getElementsByTagName('goods');
for(var i=0;i<goods.length;i++){
//创建行元素
var tr=document.createElement('tr');
//创建序号td元素
var tdID=document.createElement('td');
tdID.innerHTML=i+1;
//创建名称td元素
var tdName=document.createElement('td');
tdName.innerHTML=goods[i].childNodes[0].childNodes[0].nodeValue;
//创建价格td元素
var tdPrice=document.createElement('td');
tdPrice.innerHTML=goods[i].childNodes[1].childNodes[0].nodeValue;
//将三个td追加到tr元素
tr.appendChild(tdID);
tr.appendChild(tdName);
tr.appendChild(tdPrice);
document.getElementsByTagName('TBODY')[0].appendChild(tr);
}
};
xhr.send(null);
}
</script>
<table id='tbData' width="800" cellspacing="1" cellpadding="4" bgcolor="#336699">
<tr>
<td>序号</td>
<td>商品名称</td>
<td>商品价格</td>
</tr>
</table>
理解:
创建行元素,
创建单元格元素
将单元格元素追加到行元素中
将行元素追加到表格元素中
php代码:
<?php
//查询goods表中所有数据并返回
$sql="select name,price from goods order by id desc";
mysql_connect('localhost','root','111111');
mysql_select_db('shop');
mysql_query('set names gb2312');
$result=mysql_query($sql); //发送sql语句
$num=mysql_num_rows($result); //总行数
$str='<root>';
for($i=0;$i<$num;$i++){
$row=mysql_fetch_assoc($result);
$str.='<goods>';
$str.='<name>'.iconv('gb2312','utf-8',$row['name']).'</name>';
$str.='<price>'.$row['price'].'</price>';
$str.='</goods>';
}
$str.='</root>';
header('Content-Type:text/xml');
echo $str;
?>
理解:
查询goods表中所有数据
连接xml格式的字符串
表中有多少条数据
xml字符串就有几对goods标签
其中, name字段出现中文,所以需要进行转码 gb2312--utf-8
最后, 输出xml字符串
译 | 核子可乐、Tina
技术和软件开发领域存在一种有趣的现象,就是同样的模式迭起兴衰、周而复始。
过去Web非常简单。URL 指向服务器,服务器将数据混合成 html,然后在浏览器上呈现该响应。围绕这种简单范式,诞生了各种Javascript框架,以前可能需要数月时间完成的一个应用程序基本功能,现在借助这些框架创建相对复杂的项目却只需要数小时,我们节省了很多时间,从而可以将更多精力花在业务逻辑和应用程序设计上。
但随着 Web 不断地发展,Javascript 失控了。不知何故,我们决定向用户抛出大量 App,并在使用时发出不断增加的网络请求;不知何故,为了生成 html,我们必须使用 JSON,发出数十个网络请求,丢弃我们在这些请求中获得的大部分数据,用一个越来越不透明的 JavaScript 框架黑匣子将 JSON 转换为 html,然后将新的 html 修补到 DOM 中......
难道大家快忘记了我们可以在服务器上渲染 html 吗?更快、更一致、更接近应用程序的实际状态,并且不会向用户设备发送任何不必要的数据?但是如果没有 Javascript,我们必须在每次操作时重新加载页面。
现在,有一个新的库出现了,摒弃了定制化的方法,这就是 htmx。作为 Web 开发未来理念的一种实现,它的原理很简单:
htmx 出现在 2020 年,创建者 Carson Gross 说 htmx 来源自他于 2013 年研究的一个项目 intercooler.js。2020 年,他重写了不依赖 jQuery 的 intercooler.js,并将其重命名为 htmx。然后他惊讶的发现 Django 社区迅速并戏剧性地接受了它!
图片来源:https://lp.jetbrains.com/django-developer-survey-2021-486/
Carson Gross 认为 htmx 设法抓住了开发者对现有 Javascript 框架不满的浪潮,“这些框架非常复杂,并且经常将 Django 变成一个愚蠢的 JSON 生产者”,而 htmx 与开箱即用的 Django 配合得更好,因为它通过 html 与服务器交互,而 Django 非常擅长生成 html。
对于 htmx 的迅速走红,Carson Gross 发出了一声感叹:这真是“十年窗下无人问,一举成名天下知(this is another example of a decade-long overnight success)”。
可以肯定的一点是 htmx 绝对能用,单从理论上讲,这个方法确实值得称道。但软件问题终究要归结于实践效果:效果好吗,能不能给前端开发带来改善?
在 DjangoCon 2022 上,Contexte 的 David Guillot 演示了他们在真实 SaaS 产品上实现了从 React 到 htmx 的迁移,而且效果非常好,堪称“一切 htmx 演示之母”(视频地址:https://www.youtube.com/watch?v=3GObi93tjZI)。
Contexte 的项目开始于 2017 年,其后端相当复杂,前端 UI 也非常丰富,但团队非常小。所以他们在一开始的时候跟随潮流选择了 React 来“构建 API 绑定 SPA、实现客户端状态管理、前后端状态分离”等。但实际应用中,因为 API 设计不当,DOM 树太深,又需要加载很多信息,导致 UI“非常非常缓慢”。在敏捷开发的要求下,团队里唯一的 Javascript 专家对项目的复杂性表现得一无所措,因此他们决定试试 htmx。
于是我们决定大胆尝试,花几个月时间用简单的 Django 模板和 htmx 替换掉了 SaaS 产品中已经使用两年的 React UI。这里我们分享了一些相关经验,公布各项具体指标,希望能帮同样关注 htmx 的朋友们找到说服 CTO 的理由!
6. 将 Web 构建时间缩短了 88%(由 40 秒缩短至 5 秒)
7. 首次加载交互时间缩短了 50% 至 60%(由 2 到 6 秒,缩短至 1 到 2 秒)
8. 使用 htmx 时可以配合更大的数据集,超越 React 的处理极限
9. Web 应用程序的内存使用量减少了 46%(由 75 MB 降低至 40 MB)
这些数字令人颇为意外,也反映出 Contexte 应用程序高度契合超媒体的这一客观结果:这是一款以内容为中心的应用程序,用于显示大量文本和图像。很明显,其他 Web 应用程序在迁移之后恐怕很难有同样夸张的提升幅度。
但一些开发者仍然相信,大部分应用程序在采用超媒体 /htmx 方法之后,肯定也迎来显著的改善,至少在部分系统中大受裨益。
可能很多朋友没有注意,移植本身对团队结构也有直接影响。在 Contexte 使用 React 的时候,后端与前端之间存在硬性割裂,其中两位开发者全职管理后端,一位开发者单纯管理前端,另有一名开发者负责“全栈”。(这里的「全栈」,代表这位开发者能够轻松接手前端和后端工作,因此能够在整个「栈」上独立开发功能。)
而在移植至 htmx 之后,整个团队全都成了“全栈”开发人员。于是每位团队成员都更高效,能够贡献出更多价值。这也让开发变得更有乐趣,因为开发人员自己就能掌握完整功能。最后,转向 htmx 也让软件优化度上了一个台阶,现在开发人员可以在栈内的任意位置进行优化,无需与其他开发者提前协调。
如今,单页应用(SPA)可谓风靡一时:配合 React、Redux 或 Angular 等库的 JS 或 TS 密集型前端,已经成为创建 Web 应用程序的主流方式。以一个需要转译成 JS 的 SPA 应用为例:
但 htmx 风潮已经袭来,人们开始强调一种“傻瓜客户端”方法,即由服务器生成 html 本体并发送至客户端,意味着 UI 事件会被发送至服务器进行处理。
用这个例子进行前后对比,我们就会看到前者涉及的活动部件更多。从客户端角度出发,后者其实回避了定制化客户端技术,采取更简单的方法将原本只作为数据引擎的服务器变成了视图引擎。
后一种方法被称为 AJAX(异步 JavaScript 与 XML)。这种简单思路能够让 Web 应用程序获得更高的响应性体验,同时消除了糟糕的“回发”(postback,即网页完全刷新),由此回避了极其低效的“viewstate”等.NET 技术。
htmx 在很多方面都体现出对 AJAX 思路的回归,最大的区别就是它仅仅作为新的声明性 html 属性出现,负责指示触发条件是什么、要发布到哪个端点等。
另一个得到简化的元素是物理应用程序的结构与构建管道。因为不再涉及手工编写 JS,而且整个应用程序都基于服务器,因此不再对 JS 压缩器、捆绑器和转译器做(即时)要求。就连客户端项目也能解放出来,一切都由 Web 服务器项目负责完成,所有应用程序代码都在.NET 之上运行。从这个角度来看,这与高度依赖服务器的 Blazor Server 编程模型倒是颇有异曲同工之妙。
技术和软件开发领域存在一种有趣的现象,就是同样的模式迭起兴衰、周而复始。随着 SPA 的兴起,人们一度以为 AJAX 已经过气了,但其基本思路如今正卷土重来。这其中当然会有不同的权衡,例如更高的服务器负载和网络流量(毕竟现在我们发送的是数据视图,而不只是数据),但能让开发者多个选择肯定不是坏事。
虽然不敢确定这种趋势是否适用于包含丰富用户体验的高复杂度应用程序,但毫无疑问,相当一部分 Web 应用程序并不需要完整的 SPA 结构。对于这类用例,简单的 htmx 应用程序可能就是最好的解决方案。
参考链接:
https://news.ycombinator.com/item?id=33218439
https://www.reddit.com/r/django/comments/rxjlc6/htmx_gaining_popularity_rapidly/
https://mekhami.github.io/2021/03/26/htmx-the-future-of-web/
https://www.compositional-it.com/news-blog/more-on-htmx-back-to-the-future/
声明:本文为InfoQ编译,未经许可禁止转载。
*请认真填写需求信息,我们会在24小时内与您取得联系。