1KB前端库
今天的挑战:构建一个 1KB 的前端库
今天,我们来挑战一个激动人心的挑战:创建一个只有1KB大小的前端库。我说的是一个“消失的框架”——不像 Svelte,它只会在编译后“消失”。无需构建工具,也无需node_modules
占用 SSD 的臃肿文件夹。只需几个轻量级的 JavaScript 函数,即可立即复制、粘贴和使用。
系好安全带!
信号反应
到 2025 年,前端世界将在一件事上达成共识:信号驱动响应式设计。几乎每个主流框架都有自己的信号版本——比如 Vue 的 Runeref()
和 Svelte 的$state
Rune。
如果你对信号不熟悉,不用担心。只需记住两个关键概念:
- 信号:可以读取和更新的反应值。
- 效果:依赖于信号的函数。当信号发生变化时,其依赖的效果会自动重新运行。
微小信号的实现
我们的紧凑信号实现灵感来自 Andrea Giammarchi 的优秀文章《信号》。如果你对细节感兴趣,我强烈建议你读一读这篇文章。
{
const effects = [Function.prototype];
const disposed = new WeakSet();
function signal(value) {
const subs = new Set();
return (newVal) => {
if (newVal === undefined) {
subs.add(effects.at(-1));
return value;
}
if (newVal !== value) {
value = newVal?.call ? newVal(value) : newVal;
for (let eff of subs) disposed.has(eff) ? subs.delete(eff) : eff();
}
};
}
function effect(fn) {
effects.push(fn);
try {
fn();
return () => disposed.add(fn);
} finally {
effects.pop();
}
}
}
function computed(fn) {
const s = signal();
s.dispose = effect(() => s(fn()));
return s;
}
工作原理:
- 我们使用块作用域(
{}
) 将变量保持在全局命名空间之外。当无法使用模块时,这非常方便。 - 该
signal
函数创建一个响应式值。它返回一个同时充当 getter 和 setter 的函数:- 如果不带参数调用,它将返回当前值并订阅信号的活动效果。
- 如果使用新值调用,它会更新信号并触发所有订阅的效果(除非它们被处置)。
- 该
effect
函数注册一个立即运行的回调,并在其依赖的任何信号发生变化时重新运行。 - 该
computed
函数创建一个派生信号——每次依赖项发生变化时都会重新计算的反应值。
示例用法:
const count = signal(0); // Create a signal with initial value 0
effect(() => {
console.log(`Count is: ${count()}`); // Log the current value of the signal
});
count(1); // Update the signal, which triggers the effect and logs "Count is: 1"
count(2); // Update again, logs "Count is: 2"
响应式 HTML 模板
现在,让我们添加一些模板和渲染魔法。我们将创建一个带标签的模板函数,html
它解析 HTML 字符串并将响应式值动态绑定到 DOM。
{
function html(tpl, ...data) {
const marker = "\ufeff";
const t = document.createElement("template");
t.innerHTML = tpl.join(marker);
if (tpl.length > 1) {
const iter = document.createNodeIterator(t.content, 1 | 4);
let n,
idx = 0;
while ((n = iter.nextNode())) {
if (n.attributes) {
if (n.attributes.length)
for (let attr of [...n.attributes])
if (attr.value == marker) render(n, attr.name, data[idx++]);
} else {
if (n.nodeValue.includes(marker)) {
let tmp = document.createElement("template");
tmp.innerHTML = n.nodeValue.replaceAll(marker, "<!>");
for (let child of tmp.content.childNodes)
if (child.nodeType == 8) render(child, null, data[idx++]);
n.replaceWith(tmp.content);
}
}
}
}
return [...t.content.childNodes];
}
const render = (node, attr, value) => {
const run = value?.call
? (fn) => {
let dispose;
dispose = effect(() =>
dispose && !node.isConnected ? dispose() : fn(value())
);
}
: (fn) => fn(value);
if (attr) {
node.removeAttribute(attr);
if (attr.startsWith("on")) node[attr] = value;
else
run((val) => {
if (attr == "value" || attr == "checked") node[attr] = val;
else
val === false
? node.removeAttribute(attr)
: node.setAttribute(attr, val);
});
} else {
const key = Symbol();
run((val) => {
const upd = Array.isArray(val)
? val.flat()
: val !== undefined
? [document.createTextNode(val)]
: [];
for (let n of upd) n[key] = true;
let a = node,
b;
while ((a = a.nextSibling) && a[key]) {
b = upd.shift();
if (a !== b) {
if (b) a.replaceWith(b);
else {
b = a.previousSibling;
a.remove();
}
a = b;
}
}
if (upd.length) (b || node).after(...upd);
});
}
}
}
主要特点:
- 该
html
函数返回一个 DOM 节点数组。 - 它使用语法支持动态属性、文本内容、子节点和事件监听器
on*
。 - 如果提供的值是一个函数(或信号本身),它会设置一个重新运行的效果来更新 DOM。
示例用法:
// Reactive state
const count = signal(0);
// Render the app
const app = html`<div>
<h1>Counter: ${count}</h1>
<button onclick=${() => count((val) => val + 1)}>Increment</button>
<button onclick=${() => count((val) => val - 1)}>Decrement</button>
</div>`;
// Mount the app to the DOM
document.body.append(...app);
更复杂的例子:Todo 应用
看看这个用我们小巧的库构建的交互式 Todo 应用吧。它是一个很好的例子,展示了只需几行代码就能实现的功能。
下一步是什么?
在下一篇文章中,我们将添加一个函数来实现高效的列表重新渲染。敬请期待!🚀
文章来源:https://dev.to/fedia/1kb-frontend-library-5ef1