用 JAMStack 重写网站:一次不成功的尝试

版权声明:所有博客文章除特殊声明外均为原创,允许转载,但要求注明出处。

网站很长一段时间没有更新了,在此向关注的朋友说声抱歉。至于原因,首先是自去年底防疫解封以来,前两年积压的任务突然释放出来,工作任务较为繁重;其次则是自己的问题。概括说来,我从去年下半年开始就尝试重新编写网站,但在使用多种不同的技术路线、走了不少弯路之后,仍然没有找到一个较为满意的新架构。接下来这段时间工作仍然会比较紧张,重写方面的尝试不得不暂时放一放,比较遗憾。不过借着这个机会,我也得以刷新了一下前端方面的知识技能。因此还是把本次尝试过程做个总结,算是对这大半年消耗掉的时间有个交代吧。

起因

本站目前还是用“古老”的 Flask + jQuery 编写的,这在日新月异的前端圈子里基本可以算是活化石了吧。这一点我自己也是清楚的,只是前几年忙于增加文章,鉴于网站以静态内容为主,总体上运行良好,所以对于用新技术改写网站也比较缺乏动力。几年来偶尔也会遇到过一些性能上的问题,因此也有过用高性能、低消耗的语言——比如 GoLang 之类——重写后端的想法,但在尝试之后,对这个语言还是喜欢不起来(其中部分想法记录在文章 《放弃了用 Go 重写网站的决定》 中),遂没有再往这个方向进行下去。

去年下半年有段时间比较空闲,我开始考虑:目前的使用的网站前端技术和时代已经有些脱节,是否可以从前端开始去重构网站?以此为出发点,研究了一些可能的方向。我个人曾在 2016 年的时候参与了一些前端方面的工作,那时候也正是 NodeJS 在国内刚刚兴起的时间点,我还在公司内部做过尝试用 NodeJS 做实验性网站的工作。但之后由于公司业务方向又回到了后端的老本行,前端方面的内容只是偶尔才去看一眼。现在重新捡起来,发现果然还是比较吃力的,记忆中的知识很多已经严重过时了。

JAMStack

开始探索前端方向时首先注意到的一个技术趋势是 JAMStack。在社区维护的 jamstack.org 上给该技术下了一个定义,不过说实话,这个定义过于抽象,读过有点不知所云的感觉。JAMStack 这个名词里的几个大写字母分别代表 JavaScript + API + Markup,它描述的是一种基于上述技术建立网站的架构模型。更具体地说,它关注的主要是把网站内容静态化,至于页面交互的动态性则交给 JS 来完成(以区别于服务端动态生成的 HTML),而内容方面的变化由外部 API 来驱动(区别于一体化的 Web 服务器设计思路)。

以上也是 JAMStack 这个概念比较容易让人感觉困惑的地方,因为这三种技术都已经不是什么新东西了,那么它的革新性何在?的确,stack 这个词的存在似乎暗示着它是某种技术框架的组合,类似于 LAMPLinux+Apache+Mysql+PHP) 或者 MEANMongoDB+Express+Angular+Nodejs)那样。但实际上 JAMStack 除了要求使用 JavaScript 之外(实际上 TypeScript/Deno/WASM 等等也是可以的),其他方面并未规定具体应采用何种技术,它更多的是一种关于如何设计、部署与管理网站的思想。

从历史上讲,第一代 Web 技术基本上是纯粹的静态 HTML 页面;第二代则是主要关注于为网站提供动态能力,以 CGI 为开端,到 ASP/JSP/PHP 等等百花齐放;到目前动态网站仍然是最主流的技术,只是在深度和广度上都得到了极大的扩展。

动态网站本身并不是什么问题。它赋予了 Web 开发者极大的权力,上限完全取决于程序员自身的能力。然而从现实看,动态网站的门槛比较高:它需要专职的后端去创建和维护 Web 服务器,做性能优化,管理权限,保护安全......这也就是为什么现代人都喜欢用“只差一个程序员”的梗,因为没有程序员的话,啥啥都干不成。

我们需要认识到这样一个现实:并不是所有公司都养得起专职的程序员,但绝大部分公司以及许多个人都有发布内容的需要。所以像 Wordpress 这样的平台才会如此流行,它使得普通人不需要懂得 PHP 也可以创建网站或博客。但 Wordpress 仍然需要一个服务器(虽然可以托管)。而 JAMStack 则转向另外一种思路:如果网站只是一些 HTML + JavaScript,那就只需要一个 Apache/Nginx 之类的静态文件服务器就可以解决问题,并且性能非常高。想要更好的访问体验的话,还可以把这些静态内容加载到 CDN。此外,没有动态服务器也就意味着无需担心注入攻击的危险,网站天生就非常安全。

从运维角度看,JAMStack 也有一个明显的优势:基于它的网站很容易将部署流程标准化、自动化,可以无需让专职的程序员去创建和维护动态和管理 Web 服务器。也正是由于这个原因,最近涌现出不少为 JAMStack 提供支持的服务商,比较有名的包括 NetlifyVercel 等。它们大多为纯粹的内容网站提供了免费的内容托管服务,这对很多小型站长或个人发布者是非常友好的。这也是因为 JAMStack 架构使得它只要少量服务器就足以支持大量静态内容访问请求,对公司来说成本非常低。顺便说下,Vercel 这个公司近来风头很劲,在不少新型的前端技术上都能看到它的身影。遗憾的是受政策限制,在中国大陆恐怕很难找到类似的服务了。

当然,JAMStack 架构也存在它自身的限制:它只关注静态内容的托管,至于动态部分则只给出了一个大概的方向,没有提供多少实质性的规范。而现实中的网站,即便以静态内容为主,多少也会有一些动态性方面的要求,而 JAMStack 则基本上把这个问题完全留给了开发者。在理想的世界中,我们可以寄希望于一些公共 API 接口提供了网站所需的全部服务,我们只需要做一些集成性的工作即可。但很多时候这是不可能的,于是很多时候我们还是不得不聘请一个程序员来架设一个网站专用的 API Server。从另一个角度说,这也在客观上促成了 “内容创作者” 和 “服务提供者” 这两种角色的分工协作。

按照最初的设想,JAMStack 架构主要应用于以静态内容为主的站点,主要场景包括个人网站、博客、新闻、文档等等,这些已经足以全世界覆盖相当一部分 Web 内容了。至于那些以动态交互为主的网站,比如即时消息和电商等,用 JAMStack 则不太适合————也不是说完全不能,只是能够从该架构得到的益处就比较有限了。不过最近几年在前端方面也有一些新的探索,使得该架构的外延有所扩大。我相信至少在有限的将来,JAMStack 应该还是主要用于内容为主的站点,在电商等领域不太可能得到广泛应用。

既然博客是 JAMStack 的主要用例之一,那么本网站采用该技术应该是一个合理的选择。因此我开始考察各种在 JAMStack 思想指导下发明出来的技术框架,看哪种最符合我的要求(或者说口味)。

Hugo

JAMStack 框架也历经了多次发展。第一代基本上只能算静态内容生成器,主要代表包括 JekyllHexoHugo 等等。我曾经用过基于 NodeJSHexo,它能对网站内容做一些简单的配置,再复杂的修改就要手动修改模板了。

虽然知道这些框架的功能较为有限,只能生成静态 HTML,对 JavaScript 的支持全靠自己。不过按照资料介绍,其中 Hugo 算是一个相对来说较新的框架,用 Go 语言编写,编译很快,也有不少流行的网站内容是用它生成的。因此我还是花了一些时间去了解它。

Hugo 和其他同时代的框架类似,使用一个中央配置以及页面模板去生成页面。这个过程并不需要了解 Go 语言,不过它的模板使用了 Go 专有的 Go Template 语法。在简单了解之后我感觉到它在设计上的不协调,主要是因为 Go 的语言强制规范要求公开属性必须以大写字母开头,但配置文件中使用却是小写开头的 smallCamelCase 规范,这使得代码中经常大小写混杂,显得相当混乱。此外,它的函数调用使用空格分割参数,而不是像普通编程语言那样使用括号,看起来也比较别扭。

如果说语法只是口味问题的话,那么这一代框架还有一个共同的痛点:它们没有为 JavaScript 提供足够的支持。如果想生成压缩后的 JS/CSS 文件,就要引入 NodeJS/Webpack,或者使用第三方的辅助模块,这就挺尴尬的。总之,Hugo 这一类的框架如果配合传统的 jQuery 还是可以一用的,如果客户端决定使用 ReactVue 之类的话,它们的意义就很有限了。

Gabsty

跳过 Hugo 之后,接下来我看向 Gatsby,这算是 JAMStack 社区中比较推荐的第二代技术,和后续的框架比起来,它的功能没那么强大和完善,但也相对的关注点比较纯粹,涉及的知识概念较少,更加容易上手。

Gatsby 是一个基于 React 的静态网站生成器(Static Site Generator, 简称 SSG)。基于 React 就意味着它要求以组件化的方式开发页面,这也是它和第一代 SSG 最大的区别。此外,Gatsby 很大的一个特点是它在内部数据通信方面强制使用 GraphQL 作为通信协议,为此它还内置了一个专为调试 GraphQL 而创建的控制台。

我花了一些时间去了解 Gatsby 的概念以及具体用法,并自己尝试写了个简单的 Demo 来实际体验一下。写完的感觉并不算理想。最主要的体会是,这个框架有点过度工程了,它非常追求灵活性,把很多功能设计成插件形式,于是你要了解一堆概念和接口,比如 Source pluginTransformer Plugin,然后才能开始干活。而很多插件是第三方提供的,它们的实现五花八门,有的还缺乏维护,有的文档语焉不详,开发起来比较痛苦。写到后来,gatsby-config.js 几乎肯定会变成一堆插件配置的大杂烩。

另一方面,Gatsby 要求所有数据使用 GraphQL 通信,为了满足接口,在许多页面中要写额外的代码去创建查询,查询完又要写另外一些代码把返回数据(也是 GraphQL)再转换回普通的 JavaScript 对象,这简直是多此一举。实际上对于我来说,大部分情况只需要普通的平面数据就够了。虽然 Gatsby 试图通过调试控制台来简化工作,但复杂的查询条件点来点去还不如手写来的快,来回复制粘贴也比较烦人,总之编写起来并不像宣传的那样友好。

顺便说下,目前网络上介绍 Gatsby 的资料大多是以 Gatsby 4.x 为基础的,在我测试的时候它已经升级到了 Gatsby 5,部分写法有变化,尤其是 GraphQL 的查询条件写法不能向下兼容,因此以前的例子有些已经无法通过了。而 Gatsby 给出的错误信息并不明确,可能会误导你向错误的方向去排查问题。我也是踩了一些坑,结合网上搜索再加上反复尝试才定位到真正的原因。

总之,我觉得以一个 SSG 而言,Gatsby 设计得过于复杂,用起来并不顺手。我猜测作者的意图是想以它为中心构筑一个插件市场,让一般创作者找到合适的插件就能完成完整的网站。然而从效果看,它仍然需要 npm 为发布渠道,引入插件需要修改代码和修改配置,并且需要理解 GraphQL,这个门槛仍然很高,设计体验也算不上愉快。

在否定 Gatsby 之后,我也顺便考察了和它类似、只是以 VueJS 作为基础的竞争对手,主要是 Gridsome。令人惊讶的是 Gridsome 主站使用 .cn 域名,应该是国人创建的。从特性看,GridsomeGatsby 非常相似,但是版本停留在 v0.7.23,似乎很长一段时间没有更新了,使用的底层视图还是 Vue2,对于在 Github issue 上询问版本更新问题的用户也很少回复,看起来好像是个被放弃的项目。所以我没有再进一步去尝试。

NextJS

放弃 Gatsby 之后,下一个尝试的的是同样基于 ReactNextJS。这是一个混合型框架,并且已经几乎成为该圈子的“标配”,以至于连 React 官方都推荐它作为默认的项目生成器来取代 create-react-app。也由于它是一个混合型框架,所以功能方面覆盖范围更大,要了解的概念也比较多。

这里要先偏个题:前端的变化趋势感觉似乎让人捉摸不定。虽然目前最主流的三大框架(Angular/React/Vue)努力把开发方式从动态网页变成组件式的客户端开发,而近来却有走回头路的趋势,设法把基于客户端的渲染(CSR)转回到服务端渲染(SSR)上来,甚至进一步走到静态生成(SSG),理由主要是为了更好的 SEO 和首屏性能。单一的客户端框架并不能满足 SSR/SSG 的要求,为了适应发展趋势,最近涌现出一大批把 CSR/SSR/SSG 捆绑在一起加以支持的混合型框架,其中 NextJS 就是在 React 社区中最流行的混合型框架。

对于这种新的开发趋势,老实说,我的看法是有保留的。因为这样前后端都要用 NodeJS 开发,等于是限制了服务端可选择的语言和技术范围,在我看来是有悖于 Web 开放性的。不过进来 GoRust 等传统上认为是后端开发的语言也有渗透到前端开发工具链的趋势,你中有我,我中有你,让人难以看清到底哪个才是未来。

混合框架带来了一个新的概念叫做水合(Hydration)。这主要是由于 SSG 在服务端执行,它只能生成的静态 HTML,并不具有交互能力,也并不是客户端框架需要的那种真正的组件。因此,框架必须执行一个复杂的过程:首先在服务器上生成基本的 HTML;其次在推送到客户端之后,在网页中注入一些脚本来将这些静态部分“活化”,让它们变成客户端框架可管理的、实际的组件。

作为混合型框架,NextJS 的主要特点是提供了一些页面级别的“钩子”,比如说,如果页面实现了 getStaticProps(),则框架把它视为 SSG 对象(如果要求动态路由的话还要实现 getStaticPaths())。需要在运行期动态访问数据的页面则需要实现 getServerSideProps()

理论上并不难理解,不过在我尝试编写一个 Demo 的时候还是遇到了一些问题。其中比较头疼的是 Hydration 相关的报错,它告诉我,NextJS 要求服务端和客户端必须生成相同的页面结构,否则框架无法处理。这在很多情况下是过于严格了,毕竟服务器和浏览器运行环境还是存在很多差别的,并且博客内容使用的 Markdown 生成的是动态 HTML,无法定义成编译时期的组件,必须另写代码在运行期创建。这使得程序代码比预期的要麻烦的多。

在排查 Hydration 相关问题时,我发现这不单是我个人遇到的麻烦,很多其他开发者对此也感到头疼。因此业界目前的一个努力方向是设法让服务器和客户端实现“同构”,让它们尽可能支持相同的 API 接口,以简化程序的工作。这个工作目前已经取得了一些进展,但毕竟客户端和服务器环境存在的物理差别是无法完全消除的。NextJS 这种混合型框架在实践上其实还是限制蛮多的,尽管都是用 JavaScript,但实际上一部分代码运行在客户端,一部分运行在服务端,如果客户端代码调用了服务器才支持的接口,就会出错。这经常迫使我们在开发过程中需要时刻提醒自己把客户端部分和服务器部分区分开,以免误用。我已经看到好些示例在代码文件后缀后面明确添加 client.js/server.js,说明这确实是个普遍问题。

其实在开发过程中我已经在想,如果开发过程中必须小心翼翼地做这种区分,那么重用性势必受到极大的限制,混合型框架的优势还在吗?这个趋势的方向是正确的吗?目前我只是自己感觉到心里有一些模糊的问号,但还没有得到一个确定的答案。

此外,开发过程中还遇到了一些和 Webpack 编译器以及 React Hook 相关的错误。比较头疼的是这些问题的错误信息往往晦涩难懂,很多时候给出的代码位置也并不是真正导致问题的地方,只能通过线索逐步排查,而最终的解决办法经常是要修改自己的代码结构来适应框架的要求,感觉开发自由度收到了很大限制。当然这其中也有我自己对前端技术的细节了解不够深入的原因。

包括 NextJS 在内的混合型框架似乎还有这样一个趋势,就是使用基于文件路径来创建动态路由,这似乎也回到了传统的 PHP 模式,而不同于 MVC 的思路。在开发过程中发现,如果 URL 和文件路径存在直接对应关系,那么这种方式是很直观的,但是当路径层次较深、或者存在多个参数时,看起来就比较怪异了,并且过深的层次也会导致在 IDE 中处理文件更加费劲。总之有利有弊吧。

尽管说了它不少坏话,不过客观的说,NextJS 这个框架实现程度还是非常高的,热重载也非常顺畅,如果不出错的话开发体验是相当愉快的。在克服众多问题后,我已经做出了一个基本可用的网站原型,没想到最后一步又被新的问题卡住了:最终编译生成的文件相当大,部分文件达到 400 KB 以上。对我来说这个大小有点难以接受了。

查找问题发现,对于以 SSG 方式生成的内容,NextJS 是把请求到的数据和相关的库都打包到了页面中。页面用到的数据我可以通过优化 getStaticProps() 方法的返回数据来减少,但一些体积较大的第三方库就不知道该怎么办了。当然,一个可能的优化思路是改用 API 动态请求的方式,避免将较大的脚本加载到客户端,但是这样的话等于又回到了动态网站的老路子上,改写就没什么意义了。

因为暂时没有好的思路来解决这个问题,所以我还是继续探索其他方向。

Nuxt Content

NextJS 是基于 React 的。目前 React 的主要竞争对手是 Vue.js,而该社区中对标 NextJS 的就是 NuxtJS 了。在使用 NextJS 受挫后,我转而尝试 NuxtJS,看它是否能得到更好的结果。

在搜索资料的过程中我意外发现一个由官方创建的模块 Nuxt Content。它能够从 Markdown 等格式的数据文件来源转换成类似 MongoDB 那样的查询接口,以方便生成内容站点。看起来这个模块简直就是为博客网站而生的,这引起了我的兴趣,决定试一下。

在简单阅读教程后,我也用它写出了一个网站原型,这个过程总体还算比较顺利。但在进一步深入开发的时候我发现某些页面不知为何无法获取到数据,再跟踪下去,发现一个重大问题:Nuxt Content 提供的主要查询接口 queryContent() 不允许在一次请求中多次顺序调用,而必须使用 Promise.all() 来统一调度,这是该模块实现方式带来的限制。对于该问题的讨论可参见 Second call queryContent in useAsyncData never resolve

由于网站中许多页面的布局分为主体内容和侧边栏,它们需要的数据是不同的。所以我必须把多个查询统一到 Promise.all,然后再分别处理后加载到不同的组件,这样导致代码被分得支离破碎,而且难以重用。在勉强写完几个页面之后,我终于难以忍受这种蹩脚的组织方式,因此也暂停了对 Nuxt Content 的尝试。

在开发过程中另一个体会是,Nuxt 目前已经使用 Vite 作为构建工具,它在正常使用情况下编译还是很快的,但似乎不太稳定,偶尔会让页面卡住不动,只有重启 Dev Server 才恢复正常。相比之下,NextJS 在这方面似乎更为稳定,开发过程中基本上没有出过岔子(不过 NextJS 中各种各样的编译错误比较让人头疼)。

虽然暂时放弃了 Nuxt Content,但值得肯定的是,它的涉及思路的确比较符合我的需求,接口设计得也还不错,只是在实现层面似乎有些一言难尽。如果我今后不借助类似框架而是自己来实现网站架构的话,或者会参考它的接口来设计吧。

结论

经过这段时间对多种技术的尝试,我还是没有找到令人满意的方案。不过经过这段时间的探索,还是学到了不少知识,对前端倒是有了一些自己的看法。

前端和后端真的差异很大,这种差异首先是在思想上的。后端程序员总喜欢说“不要重新发明轮子”。而前端的风气则完全不同,非常喜欢造轮子,每隔几个月都有一批新的轮子出来。这一方面证明前端的圈子很有活力,这个领域的同学非常富有创造性;另一方面,则也表明前端开发的范围日益扩大之后,JavaScript 语言及其生态逐步暴露出它自己的一些短板,需要更多的技术来不足。因此我们看到 TypeScriptDeno 试图以局部修正的方式来完善语言层面的缺陷;yarn/pnpm 尝试进行更友好的包管理;更有其他层出不穷的 Web 框架来支持业务开发。值得注意的一个新趋势是 Rust/GoLang 等新兴语言在尝试渗透 NodeJS 的编译工具链,这也是因为以 Webpack 为代表的编译工具虽然逐步退居幕后,但在使用层面仍然问题多多,并且在大规模开发时性能也比较差,影像开发体验,版本问题更是让人头疼。

另一个想法则有关于函数式编程。以前在范式刚刚开始流行的时候,就有很多人质疑不传递状态的纯函数式应用是否能够用来开发真正严肃的程序。现在看来,以 React 为代表的前端的确在这方面趟出了一条路子,当然它的架构也在不停变化,从类组件一直演变到今天的函数组件,算是把函数式思想发扬到一定高度了。但从另一个角度看,函数式编程并未从真正意义上消除状态的存在,现在官方所使用的一大堆名为 useXXXXHook,本质上仍然是在幕后管理状态,并且使用不当还容易出现问题。除了官方推荐的方案之外,社区在“到底使用哪个状态管理库”方面的争论也从来停止过。从 Svelte 等新兴技术的兴起来看,前端至少有一部分同学是不认同 React 发展方向的。那么前端未来到底会往哪里走,什么方向才是正确的,相信很多人会和我一样感觉困惑。在我认为,就函数式编程这个问题来说,应该是在合适的场景下有限制的使用它,如果不分场合地强调“一切皆函数”,就有点过分了。

至于本网站......抱歉,暂时还没有什么明确的思路。网站内容会努力更新,我也会继续探索其他可能的方向。上面提到的一些框架,我也不打算彻底否定它们,如果对于遇到的问题能够找到理想的解决方案,重新采用它们也是完全可能的。