一、兴起
2014年10月28日,W3C 正式发布 HTML5 规范。
HTML5 相比较于 XHTML 2.0 和相似规范带来了极大的自由度和灵活度,于 Javascript 和 AJAX 一起促进了移动端网页,SPA(单页面 Web 应用)、前后端分离。
因为手里没服务器,我当时使用原生 HTML5 + Javascript 搭配 Github Pages 编写了一个纯前端的博客网站,甚至有一个简易的“登录系统”(当然,没有后端也称不上是登陆系统。登录是随便破解就能破掉的,其实是使用前端代码反复加密用户密码,有点类似于现代游戏防破解)。
这个网站的绝大部分内容都是我在学校内完成的,当时已经意识到如果想要更改的顶部菜单栏和底部的内容就需要打开每一个文件手动替换,于是写了一个“pageFill”脚本:
function fill_menuBar(this_element) {
fillContent = `
<li id="menuBar_Home" class="layui-nav-item"><a href="/">主页</a></li>
<li id="menuBar_Article" class="layui-nav-item"><a href="/article/">文章</a></li>
<!--<li id="menuBar_Studio" class="layui-nav-item">
<a href="javascript:;">工作室</a>
<dl class="layui-nav-child">
<dd><a href="/studio/powercode">PowerCode Studio</a></dd>
</dl>
</li>-->
<li id="menuBar_Codespace" class="layui-nav-item"><a href="/apps/PowerStore">应用</a></li>
<li id="menuBar_Downloads" class="layui-nav-item"><a href="/downloads">下载</a></li>
<li id="menuBar_About" class="layui-nav-item"><a href="/about">关于</a></li>
`
document.getElementById("menuBar").innerHTML = fillContent;
document.getElementById("menuBar_" + this_element).className += " layui-this";
}
function fill_footBlock() {
fillContent = `
<div class="footblock">
<div class="acrylic">
<p>Copyright(c) CodeZhangBorui & PowerCode 2022, All Right Reserved.</p>
<p>由 Netlify & Cloudflare 提供Web技术支持</p>
<p>联系站长:[邮箱] zbr.2008@qq.com</p>
<p>
主页:
<a href="https://codezhangborui.eu.org/" target="_blank">
https://codezhangborui.eu.org/
</a>
</p>
<p>我们的服务状态:<a href="/about/status">CodeZhangBorui Web Status</a></p>
</div>
</div>
`
document.getElementById("footblock").innerHTML = fillContent;
}
JavaScript又比如,所谓“注册”其实是将用户信息加密以后让用户填写在线问卷或者发邮件进行注册。
当时觉得 HTML5 和 Javascript 简单易学,并且不需要搭建复杂的环境就可以进行编写(这一点对在校学生十分友好),并且确实没时间深研新的技术栈比如 Vue.js、React 等。搭配 CSS3 可以随时随地写出简易的 Web 应用程序。因此,直到现在我的很多项目仍然是 HTML5 + Javascript 传统组合。
二、困境
高中参加了学校的英语报社。任务大概就是在网上搜索与本期报纸主题契合的文章并发到群中,由另外一个同学进行审核,最后由高三的同学进行选稿并排版。
2024年初报社进行了第二次纳新,为了更好的组织投稿、审稿工作,我开发了一个投稿、审稿、录稿的在线平台,供所有社员使用。
前端仍然是 HTML5 + Javascript,唯一先进的可能是用了 TailwindCSS 和 axios,后端使用了 Python Flask。由于是前后端分离设计,在稿件列表界面,使用 axios 向后端 API 发送请求并获取 json 格式的稿件数据,再遍历后端返回的整个稿件列表对象,渲染 DOM 元素。
对于可复用的组件,使用纯 Javascript 就显得有点力不足了,最初的时候所有 Javascript 脚本都在一个 html 文件内,为了不让 html 文件变成一个屎山(最多的时候单个文件 600+ 行),我将稿件列表的多个 Javascript 模块分为了多个 js 文件,比如这是渲染稿件的 Javascript:
var token = Cookies.get("token");
axios
.get(`/api/entries/listissue/${window.issue_id}`, {
headers: {
Authorization: token,
},
})
.then((result) => {
var data = result.data;
console.log(data);
if (data.code === 200) {
// Render statical data
var statical = data.data.count;
var created_total = 0;
var reviewed_total = 0;
var selected_total = 0;
for (let i = 0; i < statical.length; i++) {
var dom = document.querySelector(`[data-statical-render="${i + 1}"]`);
var pending_dom = document.createElement("span");
pending_dom.classList.add(
"h-5",
"w-2",
"rounded",
"shadow",
"bg-gray-300",
"mr-1"
);
var created_dom = document.createElement("span");
created_dom.classList.add(
"h-5",
"w-2",
"rounded",
"shadow",
"bg-green-200",
"mr-1"
);
var reviewed_dom = document.createElement("span");
reviewed_dom.classList.add(
"h-5",
"w-2",
"rounded",
"shadow",
"bg-green-500",
"mr-1"
);
var selected_dom = document.createElement("span");
selected_dom.classList.add(
"h-5",
"w-2",
"rounded",
"shadow",
"bg-blue-600",
"mr-1"
);
for (let j = 0; j < statical[i].selected; j++) {
dom.appendChild(selected_dom.cloneNode());
}
for (let j = 0; j < statical[i].reviewed; j++) {
dom.appendChild(reviewed_dom.cloneNode());
}
for (let j = 0; j < statical[i].created; j++) {
dom.appendChild(created_dom.cloneNode());
}
for (let j = 0; j < statical[i].pending; j++) {
dom.appendChild(pending_dom.cloneNode());
}
created_total += statical[i].created;
reviewed_total += statical[i].reviewed;
selected_total += statical[i].selected;
}
document.querySelector("#created-total").innerText = created_total;
document.querySelector("#reviewed-total").innerText = reviewed_total;
document.querySelector("#selected-total").innerText = selected_total;
// Render entries
var entries = data.data.list;
var icon_type = {
created: "icon-ic_fluent_circle_32_regular text-blue-400",
reviewed: "icon-ic_fluent_checkmark_circle_32_regular text-green-400",
selected: "icon-ic_fluent_merge_24_regular text-blue-600",
};
var page_dom = [
document.querySelector("#page-1-container"),
document.querySelector("#page-2-container"),
document.querySelector("#page-3-container"),
document.querySelector("#page-4-container"),
];
entries.forEach((entry) => {
var entry_template = `
<div class="mr-3">
<i
class="${icon_type[entry.status]} text-xl"
></i>
</div>
<div class="flex-1 flex-col">
<h2 class="font-bold text-lg">${entry.title}</h2>
<p><span class="font-bold">来源:</span>${entry.origin}</p>
<p><span class="font-bold">词数:</span>${entry.wordcount}</p>
<p><span class="font-bold">描述:</span></p>
<p>${entry.description}</p>
<p><span class="font-bold">选稿人:</span>${entry.selector}</p>
<p><span class="font-bold">审稿人:</span>${entry.reviewer}</p>
</div>
<div data-with-permission="entries.review.${
window.issue_id
}" class="hidden">
<button
data-href="/api/entries/getasset/${entry.uuid}"
class="px-3 py-1.5 rounded hover:bg-gray-100"
>
下载
</button>
<button
data-with-permission="entries.remove.${window.issue_id}"
data-remove-uuid="${entry.uuid}"
class="hidden px-3 py-1.5 rounded hover:bg-gray-100"
>
删除
</button>
<button
data-review-uuid="${entry.uuid}"
class="px-3 py-1.5 rounded hover:bg-gray-100"
>
审稿
</button>
</div>
<div data-with-permission="entries.select.${
window.issue_id
}" class="hidden">
<button
data-select-uuid="${entry.uuid}"
class="px-3 py-1.5 rounded hover:bg-gray-100"
>
选录
</button>
</div>
`;
var entryItem = document.createElement("div");
entryItem.innerHTML = entry_template;
entryItem.classList.add(
"flex",
"flex-col",
"lg:flex-row",
"flex-nowrap",
"bg-white",
"rounded-lg",
"border",
"shadow",
"mb-3",
"p-5",
"w-full"
);
if (entry.status === "created" || entry.status === "pending") {
entryItem.querySelector("[data-select-uuid]").classList.add("hidden");
} else if (entry.status === "reviewed") {
entryItem.querySelector("[data-review-uuid]").innerText = "重新审核";
} else if (entry.status === "selected") {
entryItem.querySelector("[data-review-uuid]").classList.add("hidden");
entryItem.querySelector("[data-select-uuid]").innerText = "取消选录";
}
page_dom[entry.page - 1].appendChild(entryItem);
});
processDataHref();
processPermission();
processRemoveButton();
processReviewButton();
processSelectButton();
} else {
sendToast.error(data.message);
}
});
JavaScript可以看出,由于原生 Javascript 不支持直接返回类似于 JSX 的 HTML DOM 代码,所以只能使用字符串 + 替换 + document.createElement
或者大量的 document.createElement
嵌套的形式进行渲染。这会导致 Javascript 代码十分臃肿,并且 DOM 元素创建完后还需要单独为其中的按钮内容创建点击事件。每次应社长要求添加新功能的时候都会让我很头疼。
另外还有:
- 一些常用的组件,比如日期选择框、模态弹出窗口,现在网上的现成库很难能找到纯 js 版本的。
- 手动管理依赖项而不使用 npm 等包管理器会十分麻烦。
- 依旧是复用组件的问题,虽然可以使用 Ninja Template,但是还是没有做到十全十美(比如参数支持不好)。
- HTML 文件经常能够达到 400+ 行,内容过多,这已经严重影响快速跳转所需代码片段的时间了。
三、转变
准确来说,我是 2024 年夏天初步学习了 React + Next.js 的技术栈。当时看广东的一位朋友使用 React 为 Minecraft 服务器制作的官网,想为其添加新功能的时候发现 React 其实并不像我之前想象的那么难(难度还是有一点的)。于是跟着 ChatGPT 学了 React 的基础知识(真的),顺便重构了官网的整个代码积攒经验。
尤其是 React 原生的 useEffect
、useState
方法,可以在元素更新的同时刷新与元素相关的 DOM 内容。一个很简单的例子:
const [passages, setPassages] = useState([]);
const [loadStat, setLoadStat] = useState("loading");
useEffect(() => {
fetch("/wp-json/wp/v2/posts?per_page=20")
.then((response) => response.json())
.then((data) => {
setPassages(data);
setLoadStat("loaded");
})
.catch(() => {
setLoadStat("error");
});
}, []);
TypeScript向 WordPress RestAPI 请求文章列表,如果请求成功就将 passages
列表填充,并将 loadStat
标记为加载完毕,失败则标记为加载失败。
使用 setXXX 的方法可以再更新值的同时触发一次 DOM 重新计算。因此 DOM 代码只需要这样写:
return (
<DefaultLayout title="最近新闻">
<section className="max-w-6xl mx-auto px-10 py-5 my-8">
<h1 className="text-lg font-bold">最近新闻</h1>
{loadStat == "loading" ? (
<Card className="mt-5 p-5">
<Skeleton className="rounded-lg mb-2">
<h2>Skeleton</h2>
</Skeleton>
<Skeleton className="rounded-lg">
<h2>
Skeleton
<br />
Skeleton
<br />
Skeleton
<br />
Skeleton
</h2>
</Skeleton>
</Card>
) : loadStat == "loaded" ? (
passages.map((passage) => (
<Passage
key={passage["id"]}
description={passage["excerpt"]["rendered"]}
publishtime={passage["date"]}
title={passage["title"]["rendered"]}
// url={`/post/${passage["slug"]}`}
url={`/viewpassage?id=${passage["id"]}`}
/>
))
) : (
<Card className="mt-5 p-8">
<h2 className="text-lg font-bold text-center">加载失败</h2>
</Card>
)}
</section>
</DefaultLayout>
);
TypeScript当请求完毕时就自动渲染好了页面,不需要其他额外的操作。这相比于原生 Javascript 的体验要好不少。
四、展望
未来我可能会将我当前活跃的项目全部重构一遍,至少不会再用字符串渲染 DOM 节点了(难蚌),毕竟拥抱新技术栈意味着有更多活跃的作者和文档可以参考。现在也有可靠的编程环境了,也不太再有开一个记事本就写代码的局面。
没有什么理由再使用传统的原生 HTML5 + Javascript 编写大型项目了,所以,是时候转向 React 了。