
我修好了我的博客——如何设计一个全新的博客框架
作者分享了个人博客框架迭代历程:从Hexo迁移到Astro的尝试,到发现Elog平台带来的启发,最终思考将Notion作为内容管理系统的新方案。文章重点描述了作者对博客系统的核心需求——解决创作同步问题,以及在探索NotionNext等解决方案过程中遇到的技术限制和思考。
本文探讨了前端开发的复杂性,提倡回归以后端为中心的架构,强调使用简单的工具和技术,如模板引擎、jQuery、alpine.js和htmx,来简化开发流程。作者认为,现代前端工具链的复杂性逐渐偏离了简化开发的初衷,建议在不引入过多复杂性的情况下,利用Web Components等标准技术来实现可复用组件,从而提高开发效率。
这个标题多少有些标题党了,毕竟我作为前端在想做快速原型的时候肯定会优先考虑Next,但这两天写外包项目的途中,我突然激发了一些思考:
虽然我自己是干前端的,但之前对前后端分离一直持怀疑态度,因为前端本身就是从Web后端拆分出来的工种,所谓分分合合,分开了不久现在又有了合起来的趋势。
以前,是后端兼职写写HTML,后来被特化为了前端;现在是写JS的人越来越多,在哪都想用JS一把梭,狭义上的“全栈”其实就是指的是用JS同时完成前后端的人(毕竟在古老的年代没有“全栈”一说,大家都是全栈)。
看法发生改变是在接下某个外包项目后:这个外包项目是JS全栈项目,但是并没有前后端分离,而且是以后端(Koa.js)为主导的,前端则大部分是使用模板语言实现,辅以少量的JS脚本(使用jQuery)。
更恐怖的是,这个项目大部分脚手架如ORM、后端框架(可以理解自己在Koa基础上写了个类NestJS的框架)和前端构建逻辑都是原作自己写的,我自己花了大量的时间去理解整个项目的结构。
由于工期紧,我急需一些人来帮我分担简单的前端页面设计,为此,我基于原有框架为其加上了React和Vue的支持,试图让外包能够快速上手添加页面。
然而事与愿违,我发现大部分外包虽然能比较娴熟地出页面(但其实大部分都没考虑到项目的整体可维护性),但他面对我已经搭好的架子仍然无从下手,因为这不是他们熟悉的前端架构,他们不知道他们写的SPA应该加在哪里,每次都要花时间去解释或者自己修改接入。
我陷入了僵局,要找会后端的人简单,会前端的人也简单,但要找到这样一个能熟悉架构、既能改前端又能改后端的人,很难,也很贵。
所以在这里,我第一次体会到了前后端分离的意义,不是技术上的,而是项目管理意义上的,前后端分离的项目从更高的层面来看维护成本更低、长期来看也更能持续发展。
其实前端开发相较Web开发而言应该算比较年轻的职业,我记得我小学的时候就玩过WordPress,那时PHP还在大行其道(虽然现在WP也还在用PHP),HTML5还没落地,Flash尚在巅峰时期,jQuery都是新鲜事物。
彼时国内Web开发可能都还是一片混沌,更罔论“前端工程师”作为一个专门的职业出现。
再后来,我们熟悉的Web标准逐渐成型,HTML5、CSS3和ES6的出现,为当下纷繁复杂的Web世界打下了牢固(?)的地基。
我们也迎来了前端开发的第一个时代——jQuery的时代,它是一个对JS DOM操作的极简封装,降低了开发者动态操作DOM的开发成本,促使了第一批动态网页的诞生。
然而,jQuery仍采取了传统的面向过程编程思想,形象地说是控制流决定了数据流,而非数据流决定了控制流。
在UI开发中,数据流决定控制流才是更自然的形式,在桌面端有WinForm向WPF的转变,在Web端自然也会有相似的演变,于是Meta/Facebook便带着React轰轰烈烈地来了。
React虽然经历了一系列编程范式的摇摆变化,如从最初的OOP思想(Class
组件)到现在的函数式编程(Function
组件),其竞争者也在源源不断地解决React本身的问题。但它们的本质都是想要提出一种构建UI和逻辑的更好方式,解决以下痛点问题:
对于久旱逢甘霖的前端开发者而言,上面的糖果实在太诱人了,迫不及待地想要成为这一批吃上螃蟹的人。
然而,凡事总有代价,首当其冲的就是数据绑定所依赖的JSX语法并不被浏览器所支持,这就意味着基于React编写的代码需要再经过一步额外的“编译”才能在浏览器中直接运行,这就是“前端工具链”梦开始的地方。
以上三大件(运行时、包管理器和构建平台)构成了整个庞大前端工具链的地基,但这还远远不够,前端开发在这条路上一直越卷越远:
随着前端生态链在NodeJS的基础上不断开枝散叶,前端开发变得越来越复杂,边界也不断拓展,但大家可能并没有注意到**“前端”代码跑在浏览器上的部分越来越少,跑在本机(Node)上的部分越来越多**。
这对于专职于前端开发的人来说可能并不是什么问题,甚至可能是好事(技术深度更深了,可替代性就弱了),但对于做全栈的独立开发者来说,就并非如此了,特别是对于后端开发而言,需要维护两个项目,并理解两套完全不同的开发体系。
前端生态的蓬勃发展,随之而来的就是后端向Web UI开发的近乎停滞。几乎所有做UI的人都在考虑前端,如何让前端能做更多事,如何让写JS的人也能做整个App,而没有人关心如何让写CPP/Python/Go的人能写出更好看、交互性更强的UI。
诚然,现在Node生态的蓬勃发展已经让JS成为了全栈开发者的第一选择,像Next这样的元框架更是开发者快速实现原型的第一选择。
然而,面向后端的Web UI开发这个市场始终存在,并不会因为市场的忽视就消失,就像WASM(如微软的Blazor,支持将C#代码编译为可执行的Web代码)和HTMX库的爆火就证明了这一市场始终存在。
“元框架”(meta-framework)的概念是前端圈的概念,因为一些用于提供基础功能的视图库(ui library,如React)等占据了“框架”这一生态位,因此像Next、Remix这样提供全套解决方案的真正“框架”(类比Java中的SpringBoot)就只能被叫做“元框架”。
目前来看,所谓“全栈”的流行方案是以前端为主导的元框架,如前段时间因Server Action长得像PHP爆火出圈的NextJS。
这些元框架的特点是围绕前端进行构建:
以前端页面和组件为核心,将组件生命周期拆成服务端和客户端两部分
但是,在这个项目里,如果给我一次从头再来的机会,我会想继续以后端为中心的架构,尽可能减少对原框架的修改。
那么,如果我们以Express为核心构建一个网站,在2024年的体验如何呢?
要想直接在后端框架渲染HTML,一般少不了模板引擎,模板引擎在传统HTML的基础上增加了如组件复用、数据插入等重要功能。
一般来说,我们根据后端语言来选择合适的模板引擎,如Python一般选择jinjia2。
在JS的世界里,我们的可选项有很多,如EJS、Jade/Pug、nunjucks等。这里我们参考express官方的template engine推荐。
这里是我个人比较熟悉或推荐的选择:
想到编写“简单”的页面逻辑,大家想到的除了原生JS应该就是jQuery了吧,JQ帮我们封装了常用的DOM操作以提高API的易用性。
趁着jQuery 4.0发布第一版之际,51cto也出来蹭了波大热度:
https://www.51cto.com/article/783916.html
这种小丑文章我们姑且不谈,但JQ的面向过程语法在写交互时属实还有些心智负担。
比如,我们使用JQ实现一个根据状态显隐的组件:
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<body>
<button>Expand</button>
<span style="display: none;">Content...</span>
<script>
$(document).ready(function() {
// 定义一个变量来控制内容的显示与隐藏
let isOpen = false;
// 绑定点击事件到按钮
$('button').click(function() {
// 切换isOpen的值
isOpen = !isOpen;
// 根据isOpen的值来显示或隐藏内容
if (isOpen) {
$('span').show();
} else {
$('span').hide();
}
});
});
</script>
</body>
可以看到,我们需要添加<script>
标签,并在其中操作DOM进行事件绑定,相比原生JS会简单一些,但总体代码量还挺多的。
国内很多人可能还对alpine和htmx比较陌生,这两位在国外已经是反react急先锋的心头好了。
可以看看下面这个Live Demo,就是用Django + alpine.js + htmx完成的:
Live Demo地址:
他们以“复古”闻名,用最简单的CDN引入即可,语法也只需要最质朴的HTML Attribute和<script>
标签,完全无需任何构建步骤,堪称在全栈极速出活领域面向后端的的另一个极端(完全绕靠前端工具链,就像Next通过Server Action可以完全绕开API层一样)。
你可以继续操作 DOM,并在需要的时候使用 Alpine.js,就像JavaScript世界里的tailwind一样。
我看到alpine.js的极简语法指导的时候,我的第一反应就是“怎能如此相像”。是的,它提供了很多Vue-like的语法糖,如x-data
, x-on
, x-bind
等。
可以看出,它瞄准的点就是传统视图库想要解决的点——声明式的数据绑定,而它的优点就是无需构建,直接可用,也不需要引入任何前端工具链。
我们使用alpine.js再实现一次上面的例子,可以看见<script>
标签和DOM操作完全消失了。
<script src="//unpkg.com/alpinejs" defer></script>
<div x-data="{ open: false }">
<button @click="open = true">Expand</button>
<span x-show="open">
Content...
</span>
</div>
可以看出,相较于jQuery,alpine.js的体验确实好了不少,省去了繁琐的DOM获取和操作。
如果说alpine.js是处理客户端的页面交互逻辑,htmx则专注于尽量不写JS代码就能处理与服务端的交互逻辑,包括AJAX请求,Web Socket和SSE(服务端推送事件)等。
同样的,它也使用CDN + Attribute语法,如下面这段代码的含义是当按钮被点击时会向/clicked
URL发送POST请求,并将整个按钮的HTML替换为请求结果。
<script src="https://unpkg.com/[email protected]"></script>
<!-- have a button POST a click via AJAX -->
<button hx-post="/clicked" hx-swap="outerHTML">
Click Me
</button>
可以看到,我们也没有写任何JS代码、进行任何DOM操作就完成了AJAX请求的发起和结果替换。
解决了交互逻辑的问题,下一步就是样式问题了~~,众所周知前端都是切图仔所以样式很重要~~。
目前上已有的样式解决方案除了传统的CSS写法,其他方案如预处理器(Sass/Less/PostCSS)、CSS Module和css-in-js等大多都与前端工具链深度绑定。
好巧不巧,趋势永远是一个环,最近大火的原子化(atomic)和工具化(utilized)CSS就强调提供一系列预设的CSS类名(如columns
, color-white
等),由用户自行组合来替代语义化CSS带来的一系列如类名和继承关系难设计等困难,也可以避免CSS预处理额外引入构建工具的问题。
想必读到这里的人都能猜到,这肯定也是一个以CDN形式形式引入的CSS库,和Tailwind生态依赖PostCSS(来实现Tree-Shaking、语法转化等)不同,Bulma仅仅只需要引入一个几百K的CSS文件。
// CSS Import
@import "https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css";
// HTTP Link Tag
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css"
>
Bulma提供了一套包含布局系统(基于Flex Box)、预设组件和自定义CSS变量的极简CSS设计系统,其效果类似于tailwind+daisy UI,最大的优势就是开箱即用,无需引入任何构建工具。
其效果图如下(截图来自官网):
CSS每年都在增加大量的新特性,对于我个人来说最炸裂或者说最有用的就以下两个:
&
占位符):has
伪选择器,根据子元素状态修改父元素样式个人感觉以上两个现代特性都是足以改变Web开发流程的BREAKING CHANGE。
虽然大部分新特性我们都能依赖PostCSS来无痛语法降级,让我们鱼和熊掌都能兼得,但奈何本文的主旨是No Build,因此我也查阅了其兼容性。
很遗憾,这两个特性的覆盖范围均没有很理想,均是2023 Baseline
,如果是面向生产的项目还是建议不要直接使用,可能会产生恶性影响。
到这里,我们已经在纯浏览器环境中完成了响应式式视图绑定、AJAX请求、使用组件库等工作,但万一我们想要创建一个可复用的组件(如模态框),做一次一本万利的生意,能怎么做呢?
首先,我们的底线还是在标准Web规范下,不引入任何Node上的前端工具链。那除了借助模板语言的功能,还有没有其他方法呢?
Web Components已经是标准的一部分,被所有主流浏览器所支持。它使用一系列技术,使我们可以创建自己的可重用定制元素(如<my-custom-element />
),并使之与外界隔离开来。
除了老顽固React和Vue外,许多框架已经拥抱了Web Component标准,如Angular和Lit。
因为Web Component本身也是标准的一部分,因此编写和使用Web Component也是不需要任何构建工具,只需要CDN引入即可,而Lit本身也只是Web Components的语法糖,一切的一切都是标准的。
<one-dialog >
<span slot="heading">Hello world</span>
<div>
<p>Lorem ipsum dolor amet tilde bicycle rights affogato brooklyn. Whatever lomo subway tile sriracha gastropub edison bulb shabby chic tumeric meditation mustache raw denim.</p>
<p>reegan ugh bespoke you probably haven't heard of them godard crucifix pabst. Selvage biodiesel vice copper mug lumbersexual cred plaid. Skateboard pitchfork listicle fashion axe. Chillwave viral butcher vegan wolf.</p>
</div>
</one-dialog>
<button id="launch-dialog">Launch dialog</button>
这里个人建议使用Typescript来编写Lit,借助装饰器(Decorator)语法来获得更好的开发体验,一步简单的转译即可完成。
接下来编写JS代码来注册HTML Element,这里使用了HTTP Import,因此只需要保证该JS执行即可,不需要额外引用CDN了。
// ./one-dialog.js
import { LitElement, html, css } from 'https://unpkg.com/@polymer/lit-element@latest/lit-element.js?module';
const privateOpen = Symbol('open');
class OneDialog extends LitElement {
static get properties() {
return {
open: { type: Boolean, attribute: 'open', reflect: true }
};
}
static get styles() {
return [css`.wrapper {
opacity: 0;
transition: visibility 0s, opacity 0.25s ease-in;
}
.wrapper:not(.open) {
visibility: hidden;
}
.wrapper.open {
align-items: center;
display: flex;
justify-content: center;
height: 100vh;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 1;
visibility: visible;
}
.overlay {
background: rgba(0, 0, 0, 0.8);
height: 100%;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
}
.dialog {
background: #ffffff;
max-width: 600px;
padding: 1rem;
position: fixed;
}
button {
all: unset;
cursor: pointer;
font-size: 1.25rem;
position: absolute;
top: 1rem;
right: 1rem;
}
button:focus {
border: 2px solid blue;
}`];
}
firstUpdated() {
this._watchEscape = this._watchEscape.bind(this);
}
render() {
return html`
<div class="wrapper ${this.open ? 'open' : ''}" aria-hidden="${!this.open}">
<div class="overlay" @click="${this.close}"></div>
<div class="dialog" role="dialog" aria-labelledby="title" aria-describedby="content">
<button class="close" aria-label="Close" @click=${this.close}>✖️</button>
<h1 id="title"><slot name="heading"></slot></h1>
<div id="content" class="content">
<slot></slot>
</div>
</div>
</div>`;
}
get open() { return this[privateOpen]; }
set open(isOpen) {
this[privateOpen] = isOpen;
const { shadowRoot } = this;
const { activeElement } = document;
if (isOpen) {
setTimeout(() =>
shadowRoot.querySelector('button').focus());
if (activeElement) {
this._wasFocused = activeElement;
}
document.addEventListener('keydown', this._watchEscape);
} else {
this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
document.removeEventListener('keydown', this._watchEscape);
}
}
close() {
this.open = false
const closeEvent = new CustomEvent('dialog-closed');
this.dispatchEvent(closeEvent);
}
_watchEscape(event) {
if (event.key === 'Escape') {
this.close();
}
}
}
customElements.define('one-dialog', OneDialog);
const button = document.getElementById('launch-dialog');
button.addEventListener('click', () => {
document.querySelector('one-dialog').open = true;
});
至此,一个可重用的Dialog组件就设计完毕了,在任何地方(不管什么框架)只需要保证这段JS代码执行,就可以使用<one-dialog>
标签来使用我们的组件。
前端工具链经过好几年的内卷,现在的复杂度越来越高,慢慢脱离了keep web simple的本意。
在各种复杂的构建工具、设计模式、元框架背后,其本质都是提高DX和提高效率,我们似乎有些忘记了很多东西可以更简单。
就像大部分的服务都不必整微服务一样,单体架构就能通吃;大部分的小项目也完全不用做复杂的前后端架构拆分,只需要看团队主导是前端还是后端,然后选择对应的最优开发模式。
当然,这并不是否认大型框架存在的必要性,毕竟很多复杂的设计是为了过滤掉很多超过一定边界才会产生的麻烦,它们在那种场景下也是必要的。
还是那句话,工程本身的复杂性是无法消除的,软件开发没有银弹,但是不要为了技术去引入不必要的复杂性,毕竟打工人不要和自己过意不去。
作者: ChlorineC
创建于: 2024-03-01 00:00:00
更新于: 2025-01-19 05:25:00
版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
作者分享了个人博客框架迭代历程:从Hexo迁移到Astro的尝试,到发现Elog平台带来的启发,最终思考将Notion作为内容管理系统的新方案。文章重点描述了作者对博客系统的核心需求——解决创作同步问题,以及在探索NotionNext等解决方案过程中遇到的技术限制和思考。
本文总结了作者在前端开发领域的学习与成长历程,从大二的项目学习到2023年的实习经历,强调了通过项目实践和面试驱动学习的重要性。作者探讨了前端技术的广度与深度,认为前端并未“死亡”,而是面临着技术的两极分化,未来的发展方向包括拓展Web技术栈和探索全栈开发。文章还提出了大前端技术栈的横向与纵向拓展策略,鼓励前端工程师不断学习与适应新技术。
在React中,useRef和useEffect常用于管理组件的可变值和副作用,但在响应DOM元素挂载时,使用useCallback作为ref的回调函数更为有效。useCallback确保在元素挂载和卸载时被调用,避免了因useRef导致的生命周期回调未触发的问题。文中还介绍了其他几种优化Node操作的方法,如使用useState、useStateRef和useRefWithCallback,以提高性能和管理复杂性。
本文探讨了redux的发布-订阅模式的实现,强调其与原生useContext和useReducer的区别。redux通过subscription实例实现按需刷新,避免无关组件的更新。文章详细介绍了发布-订阅模式的基本概念、与观察者模式的对比,以及redux中的实现,包括Provider组件和Hook API的用法,如useSelector和useDispatch等,展示了redux如何解耦组件与状态更新的过程。