古法手做网页前端项目
by SouthFox
2025-12-21
马上就是 2026 年了,我仍然用着最基本的浏览器原生 ECMAScript 和 DOM 操作搓一个 前端项目,这是怎么样的匠人精神(并不)啊!
硬造需求
又是到了一年的年尾, curiosity stream 这个聚焦纪录片的流媒体平台发了一封邮件说要到了续费的时候了。 恍然发现这个流媒体平台我这一年并没有看多少,主要是官方前端太莫名奇妙了: 用了 DASH 1 这个会根据网络状况动态切换视频清晰度的技术, 但是所用的 dash 播放器不会遵守用户的清晰度设定一直锁定在低清晰度。
一开始我感到奇怪,明明我看油管的 1080P 也是没什么压力怎么到你这就一直在看糊团块呢?安卓 app 端上 看 1080P 也是可以看的,然后我就想着要不离线下载到本地看也不是不行吧。 搜了下发现 yt-dlp 项目支持下载 curiosity stream 的视频。直接下载发现可以很快的速度下载视频, 这下动了心思想着如果直接拿到视频地址放到一个我自己能控制的 dash 播放器会怎么样?
然后找到了 dash.js 这个项目跟着文档立起了最小能工作的项目个最基本的 html 文件: 引入 dashjs 库后在 body 标签里新开一个 script 标签写一点 Javscript 代码,带入到从控制台网络标签中 api 返回结果挖出的 mpd 播放地址。效果令人震惊,dashjs 播放器解析完地址后直接就流畅播起了最高的 2160p 清晰度的视频流!
这可就有点古怪了,我的网络在 curiosity stream 确实能够跑满但是为什么在官方的网页端只能看低清晰度呢?我 推测可能是官方的网页前端塞满了很多追踪服务,监视着我包括鼠标移动在内的一举一动。这些大批量的网络请求对我这个没做什么优化 的代理服务队列来说是个挑战,然后就拖慢了官方 dash 播放器对网络状况「纸面数据」上的判断,加上无法锁定清晰度的助攻导致我只能 看糊团块了。 我自己手动拿出 mpd 播放地址放到 dashjs 播放器则能够独占所有网络而跑满速度,而且因为用得是最新的 dashjs 播 放器,试了下发现是能够关掉自动切换清晰度设置实现真正锁定清晰度。
好吧,那么我能够直接从 api 挖出播放地址同时这个由 cloudflare 之类的 CDN 服务托管的播放地址并没有做什么跨域限制, 让我能够随便从什么域名播放的话……那就干脆自己造个代替前端吧。稍微判断了一下需求:播放视频、展示一些推荐条目、点击条目可 以播放这个条目下的视频。这个很简单的场景好像就没必要 npm 或者 react 之类的工具了。
古法手做
那么就开始以原生的 ECMAScript 为基础做一个简易前端出来吧。挑了 MDN 的 Javascript 教 程开始看起,走马观花的的浏览了下,有点像是什么在洗手间忘带手机开始百无聊赖读起沐浴露配料表 一样,读了几章后觉得还是直接上手吧。
当然就算看起 Javascript 语法教程我也没打算用 Javscript 写,因为半年前知道了一个在 Javascript 上构建 Scheme 语言的库, LIPS ,所以这次想着写前端也是有点为了醋而去包饺子。
古法手做前端但古得是 Lisp 之法 2 。
首先是……导包
参照 lips 的文档用 script 标签直接导入就能使用了,不过马上就遇到坑
了: scheme 标准里 import 已经有实际用途了所以 import 函数是用来
导 scheme 相关的包的,不太清楚如何在 lips 的脚本里使用其它导入的 ESM 包。最后翻找
文档翻找 issues 终于找到了能够凑合的实现:
(define js-import (self.eval "(x) => import(x)"))
(define Dashjs (js-import "https://cdn.jsdelivr.net/npm/[email protected]/dist/modern/esm/dash.all.min.js"))
(define MediaPlayer (Dashjs.MediaPlayer))
(define player (MediaPlayer.create))这样通过调用 eval 这个「逃生门」也是勉强实现了在 lips 下导入并使用模块。
这何尝不是一种……
然后就是进行一些登录鉴权然后通过 fetch 去请求 api 操作获取到 token 之类的操作。 在 lips 里调用相关函数真得没什么不同,无非就是 f(x) 变成了 (f x) ,例如 JavaScript 里的:
localStorage.setItem("token", ...);在 lips 里就成为了:
(localStorage.setItem "token" ...);要用这种形式表达的理由在这篇文章也是讲得很清楚: 直观理解 Lisp 语法
lisper 这种将原来在宿主语言上构建一套 lisp 的做法总感觉有点,额,这何尝不是一种 NTR 呢。
不是 jsx 而是 sxml
react 搭配 jsx 这种以比较贴近 HTML 的形式去建立节点元素的方式确实比较直观,但是要配上转译器之类的操作 jsx 最后生成 点 ECMAScript 代码的形式还是有点……吓人 3 。
循其本,如果将 DOM 元素用列表表达,然后里面分成元素节点名字、属性列表、子节点。那么不难注意 4 到这个列表是一个递归 的表现形式,所以之后也是当然的用一个递归函数去处理这个结构,最后转换成一条条对应语句类似:
const element = ["div", {id: "foo", class: "demo"},
[["h2", {}, "Hello"],
["p", {}, "Hello, World!"]]];
// 最后操作成……
let element = document.createElement("div");
element.setAttribute("id", "foo");
element.setAttribute("class", "demo");
element.appendChild(
// let element_1 = document.createElement("h2");
// element_1.appendChild(document.createTextNode("h2"));
);
element.appendChild(
// let element_2 = document.createElement("p");
// element_2.appendChild(document.createTextNode("Hello, World!"));
);用 lisp 的语法表示相同的元素可以表示成这样的 sxml 5 :
(define element '(div (@ (id "foo") (class "demo"))
(h2 "hello")
(p "Hello, World!")))配上这样的解析函数:
(define (make-element elem)
(document.createElement elem))
(define (make-text-node text)
(document.createTextNode text))
(define (add-event-listener! elem type proc)
(elem.addEventListener type proc))
(define (append-child! elem child)
(elem.appendChild child))
(define (set-attribute! elem attr val)
(elem.setAttribute (symbol->string attr) val))
(define (sxml->dom expr)
(let* ((have-attrs (and (not (null? (cdr expr)))
(pair? (cadr expr))
(eq? (caadr expr) '@)))
(attrs (if have-attrs
(cdadr expr)
'()))
(rest (if have-attrs
(cddr expr)
(cdr expr)))
(symbol (car expr))
(name (symbol->string symbol))
(elem (make-element (if (char-lower-case? (car (string->list name)))
name
symbol))))
(for-each (lambda (attr val)
(if (procedure? val)
(add-event-listener! elem attr val)
(set-attribute! elem attr val)))
(map car attrs)
(map cadr attrs))
(if (null? rest)
'()
(let ((first (car rest)))
(if (pair? first)
(map (lambda (expr)
(append-child! elem (sxml->dom expr)))
rest)
(append-child! elem (make-text-node first)))))
elem))就能实现通过 sxml 创建出一个 DOM 了!这个解析函数对于没有 lisp 经验的人来说可能有点吓人,不过实际是和上面 JavaScript 语法
的例子是一样的,无非就是用接收的列表第一个元素的符号去 document.createElement ;然后判断列表第二项是否是列表第一个
元素是不是 @ (是的话表示有参数)然后拿剩下列表的键值对做循环调用 setAttribute 来处理元素的
属性;然后将第三项剩余节点递归地调用 appendChild 添加到一开始的元素上。
这个 sxml->dom 函数我是从 lips 标准库拿出来改成这样的, lips 标准库内的 sxml 相关函数是
为 React.createElement 之类的框架准备的,我将其改成了使用原生的 document.createElement 之类
内建函数来创建一个节点。
列表中的模板
现在有了 sxml->dom 函数能够生成一个 DOM ,那么还能做到更多吗?例如一个经典的按钮点击应用:
(define (click-app)
(define *click* 0)
(sxml->dom `(div (@ (id "container"))
(p (@ (id "click")) ,(number->string *click*))
(button
(@ (click
,(lambda (event)
(set! *click* (+ 1 *click*))
(element-replace-with!
(get-element-by-id "click")
(sxml->dom `(p (@ (id "click")) ,(number->string *click*)))))))
"click!"))))可以看到里面有一些很奇怪的东西,首先就是 sxml->dom 的参数里列表以 ` 开头,而里面的一些列表元素又以 , 开头。
这其实是很多 lisp 里常有的 准引用 ,可以类比成各类模板中的插值。 ` 里所有元素都保持不变,但 , 开头的会被
执行,例如:
`(+ 1 2 ,(+ 1 2))
;; => (+ 1 2 3)
(define *click* 2)
`(p (@ (id "click")) ,(number->string *click*))
;; => (p (@ (id "click")) 2))同时针对 @ 属性列表里面的, sxml->dom 会有这样一段:
(if (procedure? val)
(add-event-listener! elem attr val)
(set-attribute! elem attr val))就是当值是一个过程(Scheme 里将函数称为过程)的话那么就用 add-event-listerner! 这个函数设置监听而不是用属性
设置函数设置键值,这样就达成了在属性列表分派 setAttribute 或 addEventListener 函数的效果。
因为是自己造的轮子所以 render 更新流程就直接写在监听事件的回调函数里面了,
用 get-element-by-id 手动选择元素然后就地更新回去,虽然比较麻烦但至少能用。
总结
- 项目仓库:southfox/curioda - Codeberg.org
- cloudflare pages 部署地址: https://curioda.pages.dev/
有了上述说的一些设置也是慢慢磨出了个能用的前端出来,无非就是做点将 token 登录凭证存到 localStorage 啦、api 请求封装啦、 一些 dom 元素操作啦等等,现在是暂时搓出了个能用的项目出来。简陋但是能用,而且播放器全屏后基本都是面对视频所以没必要太在乎 外面的外观……吧。
这个前端能不能用 react 写?能。这个前端就算用 lips 库写但是可不可以结合 react ?能,甚至标准库就有
将 sxml 配合 React.createElement 函数的支持。
但我还是对在这个浏览器就能直接加载的流程感到满意,也实在体会到了前端开发的「乐趣」,一种修改马上就能得到
反馈的愉悦,不过转念一想,好像跟 REPL 6 差不多啊。
Bonus
lips 是一个可以从外部导入的 js 库,那么它能不能在一些更……有趣的地方运行呢?例如 Tampermonkey 这种运行用户自定 义脚本的地方,捣鼓了一下发现真行。
// ==UserScript==
// @name New Userscript
// @namespace http://tampermonkey.net/
// @version 2025-12-20
// @description try to take over the world!
// @author You
// @match https://*/*
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @require https://cdn.jsdelivr.net/npm/lips@beta/dist/lips.min.js
// @grant none
// ==/UserScript==
const script = `
(alert "Hello World")
`;
(async function() {
'use strict';
const exec = lips.exec;
await exec(script);
})();以上就是一个最小 Tampermonkey 里的运行 lips 最小概念验证脚本了,不过试了试
因为 lips 里面的 new Function() 黑魔法,在一些有特别严格设置 CSP 地方是无法运行的,不过
依然也是解锁了新的应用场景,不清楚之后我能不能用上呢。
脚注
1 基于HTTP的动态自适应流 - 维基百科,自由的百科全书
2 笑点解析:哪怕是 Scheme 也是 1975 年就发布了 LISP - 维基百科,自由的百科全书
4 真不难注意!因为 HTML 文档本身就是这样的嵌套的列表结构
5 之前写的文章也提到了 sxml ,本身也有点 lisp 教学的意思在里面:Hello Haunt, 又一次换了博客框架
