古法手做网页前端项目

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! 这个函数设置监听而不是用属性 设置函数设置键值,这样就达成了在属性列表分派 setAttributeaddEventListener 函数的效果。

因为是自己造的轮子所以 render 更新流程就直接写在监听事件的回调函数里面了, 用 get-element-by-id 手动选择元素然后就地更新回去,虽然比较麻烦但至少能用。

总结

有了上述说的一些设置也是慢慢磨出了个能用的前端出来,无非就是做点将 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 - 维基百科,自由的百科全书

3 Build your own React

4 真不难注意!因为 HTML 文档本身就是这样的嵌套的列表结构

5 之前写的文章也提到了 sxml ,本身也有点 lisp 教学的意思在里面:Hello Haunt, 又一次换了博客框架

6 读取-求值-输出循环 - 维基百科,自由的百科全书

如不想授权 Giscus 应用,也可以点击下方左上角数字直接跳转到 Github Discussions 进行评论。