深入解析 Nue JS 的响应式原理:从 2.1kb 的 "Just HTML" 到原地 DOM 更新
【免费下载链接】nueFastest way to build modern websites项目地址: https://gitcode.com/GitHub_Trending/nu/nue
导读
Nue JS 是 Nue 生态中构建用户界面的核心运行时:一个压缩后仅约 2.1kb(min-brotli)的迷你库,主张"HTML first"而非"JavaScript first"。本文以官方技术博文 rethinking-reactivity 为骨架,结合当前仓库nuedom包的真实源码,完整讲解 Nue 的组件写法、响应式更新模型、编译产物形态与底层 DOM 更新机制,并给出 React / Vue / Svelte 的横向对比。读完本文,你将理解 Nue 与主流框架在"响应式"这件事上的本质差异,以及为什么它的产物可以如此之小。
Nue JS 是什么
Nue JS是一个用于构建用户界面的微型库,是 Vue、React、Svelte 之外的另一条技术路线,也是 Nue 生态的核心组成。Nue 生态的目标是成为比 Next.js 和 Astro 更简单的内容网站与 Web 应用构建方案。
与 React 的"一切都是 JavaScript"相反,Nue 的口号是"Just HTML":UI 是一棵带有数据绑定和事件监听的文档树,而不是返回对象的 JavaScript 函数。正如 nuedom 的 README 所描述的:
Nue 是一种以足够少的语法扩展 HTML、用来构建网站、应用和 SVG 图像的标记语言。它基于文档结构而非程序化组合,是一种不同的开发模型。
这种差异不仅是语法层面的,更改变了开发者思考应用结构的方式。下面用一个完全相同的计数器组件来看清两者的区别。
React 版计数器:"It's Just JavaScript"
/** * React counter: "It's Just JavaScript" */ import React, { useState } from "react"; function App() { const [count, setCount] = useState(0); return ( <div> <h2>You clicked {count} times!</h2> <button onClick={() => setCount(count - 1)}> Decrement </button> <button onClick={() => setCount(count + 1)}> Increment </button> </div> ); }Nue 版计数器:"It's Just HTML"
<!-- Nue counter: "It's just HTML" --> <div> <h2>You clicked {count} times!</h2> <button @click="count--">Decrement</button> <button @click="count++">Increment</button> <script>count = 0</script> </div>(注:原博文发布于 2023 年,页面展示的是较旧版本的 Nue 语法,但核心概念至今一致。当前仓库中nuedom是它的演进实现,语法细节可对照 nuedom 文档 与源码。)
Nue 版本中,HTML 本身就是组件:@click直接写事件表达式,{ count }做插值,<script>count = 0</script>声明组件实例的初始状态。JSX 中的onClick={() => setCount(count - 1)}被简化为字符串表达式count--,状态声明useState(0)变成一行count = 0。
为什么是 HTML first:UX 开发与 JS 开发的分野
要理解这个选择,需要回到 React 流行之前。那时的前端技能天然分成两类:
| UX 开发 | JS 开发 |
|---|---|
| HTML + CSS | 高级 TypeScript/JS |
| 轻量 JavaScript | 业务逻辑 |
| 视觉与体验(look and feel) | 后端集成 |
| 响应式设计 | 端到端测试 |
| 无障碍(Accessibility) | 维护 JS 基础设施 |
| UI 库 | 性能优化 |
关心 UX 的人专注"前端的前端",JS 开发者专注"前端的内层",人才自然分工、各司其职。而今天,前端开发由 JS 开发者主导,布局、样式、图片、内容全部用 TypeScript/JavaScript 组装。
Nue 想扭转这一点,把 UX 开发者重新带回核心位置:从 HTML 开始(内容优先),再添加样式,最后按需加入 JavaScript。在内容密集型网站中,JavaScript 是可选项——这正是渐进增强(progressive enhancement)、关注点分离(separation of concerns)、语义化网页设计这些"老派"理念的回归。因此:Nue 是 HTML first。
Instances:用 ES6 class 打造组件实例
从 DHTML 说起
1998 年 O'Reilly 出版了 Danny Goodman 的《Dynamic HTML: The Definitive Reference》,它让无数开发者第一次意识到网页是可编程的——用 JavaScript 而不是 Java。那时人们可以写出这样的代码:
<FORM ACTION="/cgi-bin/form.cgi" ONSUBMIT="return isValidEmail(this.email.value)"> <INPUT TYPE="text" NAME="email"> <INPUT TYPE="submit" VALUE="Submit"> </FORM> <SCRIPT TYPE="text/javascript"> function isValidEmail(email) { return /^\S+@\S+$/.test(email) } </SCRIPT>那个时代 HTML 全大写、没有type="email"、没有<button>标签,也不能用 JavaScript 提交数据(AJAX 七年后才诞生),但 HTML 已经可以动态化,部分逻辑可以从后端搬到前端。
如今 HTML、CSS、JavaScript 的能力早已天翻地覆,尤其值得注意的是一等公民:ES6 class(ECMAScript 2015 引入,所有主流浏览器均支持)。Nue 用 class 把当年的 DHTML 体验带回现代的组件化开发。把上面的表单用 Nue 重写:
<form @submit.prevent="submit"> <input type="email" name="email" placeholder="your@address.com" required> <button>Submit</button> <script> // 输入校验由浏览器原生完成 async submit({ target }) { await fetch(`/api/leads?email=${target.email.value}`) location.href = '/thank-you' } </script> </form>最显著的变化是<script>标签被嵌套进组件内部,这里可以放置 ES6 class 的变量和方法。这个设计在当前源码中有完整落地:解析阶段,ast.js 会从组件子节点中找出<script>块并提取其内容作为组件脚本;运行阶段,node.js 在创建节点时把脚本以组件实例为this执行:
if (script) { try { if (typeof script == 'string') new Function(script).call(self) else script.call(self) } catch (e) { console.error('<script> error:', script, e) } }也就是说,组件内部的方法会被绑定到实例self上,供模板表达式调用。
紧凑的 class 语法:方法、getter、setter
ES6 class 让代码看起来异常紧凑整洁:变量、方法、getter、setter、async 方法一应俱全。下面是原博文中 Todo MVC 示例的片段:
<script> clearCompleted() { this.items.forEach(item => delete item.done) this.save() } get numActive() { return this.items.filter(item => !item.done).length } get hasCompleted() { return this.items.find(item => item.done) } set filter(name) { history.replaceState('', '', '#' + name) this.filter = name } // ... 其余省略 ... </script>这些看似"原生 class 语法"的写法,在编译期会被转换成真实可运行的代码。查看 ast.js 的convertFunctions:
// foo() {} --> this.foo = function() { } export function convertFunctions(script) { return script.replace( /^( *)(async\s+)?(\w+)\s*\(([^)]*)\)\s*{/gm, (_, indent, asy, name, args) => { if (_.includes('function') || ['for', 'if'].includes(name)) return _ return `${indent}this.${name} = ${asy ? 'async ' : ''}function(${args.trim()}) {` } ) }组件脚本中的clearCompleted() { ... }会被改写为this.clearCompleted = function() { ... },async submit(...)改写为this.submit = async function(...)。同理,convertGetters 把get numActive() {...}转换为Object.defineProperty(this, 'numActive', { get() {...} })。这正是博文中"ES6 classes 使代码紧凑"的底层实现:作者把 class 语法糖翻译成语义等价的实例属性和访问器属性。
响应式模型:自动更新 + 手动更新
响应式意味着组件状态变化时,组件会自动重渲染到新状态。这一点 Nue 与其他框架并无二致:
<button @click="count++"> Clicked { count } { count == 1 ? 'time' : 'times' } <script>count = 0</script> </button>点击事件处理器触发后,Nue 自动更新视图。数组操作同样自动触发重渲染:
<div> <p> <button @click="addFruit">Add</button> <button @click="images.pop()" :disabled="!images[4]">Remove</button> </p> <img :for="img in images" :src="/demo/img/{img}.jpg"> <script> images = ['popcorn', 'peas', 'lemons', 'tomatoes'] addFruit() { const img = this.images[Math.floor(Math.random() * 4)] this.images.push(img) } </script> </div>push()和pop()都会自动更新视图,sort()、unshift()、reverse()、splice()等其他数组方法同理。在 node.js 中可以看到这一机制的落点:模板上的事件处理器被addEventListener绑定,回调执行表达式后立即调用update():
ast.handlers?.forEach(h => { const name = h.name.slice(2) // onclick --> click tag.addEventListener(name, function(e) { if (name == 'submit') e.preventDefault() exec(h.h_fn, self, e) update() }) })事件一发生,表达式一执行,update()随即触发整棵组件的重新渲染。
需要手动更新的场景:this.update()
有些更新只有你本人知道该在何时发生——比如从服务器取回数据之后,需要手动调用实例方法this.update():
<div class="user"> <img :src="user.avatar"> <h3>{ user.name }</h3> <p>{ user.email }</p> <button @click="loadJane" :disabled="is_loaded">Load Jane</button> <script> user = { avatar: 'avatars/john.jpg', email: 'john@acme.org', name: 'John Doe', } async loadJane() { const req = await fetch('jane.json') this.user = await req.json() this.is_loaded = true this.update() } </script> </div>对于 Nue JS 的使用者来说,update()几乎是响应式方面唯一需要额外记住的东西。你不需要关心框架特有的抽象,思考负担显著降低。例如,初始化一个响应式变量在各框架中的写法对比:
| 框架 | 代码 |
|---|---|
| React | const [count, setCount] = useState(0) |
| Vue | const count = ref(0) |
| Svelte / "Runes" | let count = $state(0) |
| Nue | count = 0 |
update()在源码中的实现见 node.js:它先把传入的值Object.assign到实例上,触发onupdate钩子后,基于当前 AST 与实例重新渲染出新的 DOM 树,再交给domdiff做原地更新:
function update(values) { if (values) Object.assign(self, values) if (fire('onupdate') !== false) { const next = render(ast, self).firstChild // Domino: cannot be swapped (TODO: check with fakedom) if (is_browser || parent) domdiff(root, next, root.parentNode) else domdiff(root.firstChild, next, root) fire('updated') } }注意self就是组件实例本身,它由数据、全局变量、属性绑定和update方法组装而成(node.js),这也是模板中能直接写count++、this.update()的原因。
响应式原理:编译、执行数组与原地 DOM 更新
下面进入最核心的部分:Nue JS 到底是怎么工作的。
第一步:把组件编译成普通 JavaScript
一个 Nue 组件先被编译(transpile)成纯 JavaScript,浏览器才能运行。以计数器为例:
<button :is="counter" @click="count++"> Clicked { count } { count == 1 ? 'time' : 'times' } <script>count = 0</script> </button>编译后的产物是一个包含四个属性的对象:
{ name: 'counter', tmpl: '<button @click="0">:1:</button>', Impl: class { count = 0 }, fns: [ (_,e) => { _.count++ }, _ => ['Clicked ',_.count,' ',_.count == 1 ? 'time' : 'times'] ] }四个属性的含义:
name—— 组件名称;tmpl—— 组件的 HTML/模板代码;Impl—— 用于创建组件实例的 ES6 class;fns—— 模板表达式转换成的CSP 合规(Content Security Policy 兼容)JavaScript 函数数组。
模板上的所有表达式都被替换为索引数字,用来对应fns数组中的函数。例如点击处理器@click="0"指向fns[0],即(_,e) => { _.count++ };文本插值:1:指向fns[1],返回一个文本片段数组。这里的下划线_就是组件实例,所有变量和方法都挂载在它上面。
表达式编译的具体规则在 compiler.js 中:
// compiler.test.js: _.foo + 1 --> _=>(_.foo + 1) export function compileFn(str, is_event) { str = str.trim() const word = str.startsWith('_.') ? str.slice(2) : str const is_simple = !/\W/.test(word) if (is_event) { return '(_,$e)=>' + (is_simple ? `${str}($e)` : str.includes(';') ? `{${str}}` : str ) } return '_=>' + (is_simple ? str : `(${str})`) }事件函数(_,$e)=>接收实例与事件对象;简单标识符(如方法名)会直接以$e为参数调用;多语句事件用{...}包裹;普通表达式统一包装成_=>...。这也是整个编译过程的"压缩"所在——表达式以函数形式保存,模板本身保持极简。
第二步:挂载与执行数组
组件挂载到页面时,Nue 从模板创建 DOM 树,并建立表达式/函数的映射关系。每个 Nue 组件持有一个表达式数组,每当组件状态变化就重新执行这些表达式。Nue 同时追踪所有子组件,父组件状态变化时子组件也会一起重渲染。
从渲染入口 node.js 可以看到,渲染是一个根据 AST 形态分发的递归过程:
function render(_ast=ast, data=self) { return _ast.text || _ast.fn ? renderText(_ast, data) : _ast.some ? renderIf(_ast, data) : _ast.for ? renderLoop(_ast, data) : _ast.is_custom ? renderComponent(_ast, data) : _ast.tag ? renderTag(_ast, data) : createFragment() }- 文本/表达式 →
renderText({ expr }求值后写为文本节点,{{ html }}写为 HTML,参见 render.test.js); - 条件分支 →
renderIf(遍历:if/:else-if/:else找到第一个为真的分支); - 循环 →
renderLoop(解构:for的keys、index、fn,为每个条目创建带循环变量的子实例,支持key索引变量); - 自定义组件 →
renderComponent(查找依赖中的组件定义并创建子节点实例); - 普通标签 →
renderTag(创建元素、设置属性、绑定事件、递归渲染子节点)。
:if与:for表达式同样被放入执行数组,但它们的逻辑更复杂,因此由专门的处理器函数执行(见 renderIf 与 renderLoop 的实现)。
第三步:原地 DOM 更新,无需 diff
重渲染会就地修改(mutate)属性、元素和文本节点,不需要任何 DOM diff。这是 Nue 响应式性能的关键卖点。
实际工程中,更新路径上用到的是一个被刻意做得很薄的 diff:diff.js 的domdiff直接比较真实 DOM 节点:
export function domdiff(prev, next) { const parent = prev.parentNode if (prev == next) return prev if (!prev && next) return parent?.appendChild(next) if (!next && prev) return parent?.removeChild(prev) if (prev.nodeType == 3 && next.nodeType == 3) { prev.textContent = next.textContent return prev } if (prev.nodeType != next.nodeType || prev.tagName != next.tagName) { return parent.replaceChild(next, prev) } updateAttributes(prev, next) const kids = Array.from(next.childNodes) kids[0]?.getAttribute?.('key') ? diffChildrenByKey(prev, kids) : diffChildren(prev, kids) return prev }文件头部的注释总结了它的设计原则:
- 无 VDOM 开销—— 直接 diff 真实 DOM 节点,跳过虚拟树创建;
- 定向更新—— 只修改有差异的属性和子节点;
- 键控 diff—— 用
key复用节点(类似 React),但没有组件生命周期包袱; - 位置回退—— 无 key 时用简单的线性遍历;
- 极低内存—— 只有两次
Array.from(),没有深克隆或状态追踪; - 纯 JavaScript—— 低层控制,无额外转译步骤。
也就是说,即便到了工程实现层面,"无虚拟 DOM、无 reconciliation"的承诺依然成立。测试 domdiff.test.js 对属性增删、文本节点更新、按 key 复用等场景都有覆盖,可以作为阅读该模块的入口。
保持小巧:编译产物的体积优势
编译后的 Nue 代码非常小,只有 HTML 源码的约 1.2 倍。这使得 Nue 应用成为同类中体积最小的方案之一。
Vue 作者 Evan You 曾比较过 Vue 与 Svelte 组件生成代码的体积(以 TodoMVC 为单个组件的度量标准)。把 Nue TodoMVC 加入对比后得到以下数据(数据来自原博文,单位为压缩后大小):
| 度量项 | Vue | Svelte | Nue |
|---|---|---|---|
| 框架体积 | 16.89kb | 1.85kb | 2.13 kb |
| Todo MVC 体积 | 1.10kb | 1.88kb | 0.96 kb |
| 框架 + 1 个 Todo | 17.99kb | 3.73kb | 3.09 kb |
| 框架 + 10 个 Todos | 27.89kb | 20.65kb | 11.73 kb |
Nue 的足迹最小:单个组件体积不到 1kb,叠加 10 个 Todo 后总量约 11.73kb,远低于 Vue(27.89kb)和 Svelte(20.65kb)。这与 Nue "HTML first、表达式编译为函数数组"的设计直接相关:模板本身几乎就是最终产物,而响应式机制依赖的是一个约 2.1kb 的轻量运行时(当前仓库中对应 nuedom 包,浏览器入口为 src/nue-jit.js)。
预测未来:Nue 的设计坐标
原博文(2023 年)还给出了对前端发展趋势的判断,Nue 正是为此从零设计的:
多页应用(MPA)正在崛起。随着服务器组件以及 Astro、Nue 这类工具的出现,人们会逐渐认识到 SPA(单页应用)模式并不适合"普通"的内容密集型网站。
UX 开发重新成为一门手艺。并非一切都该由 JavaScript 和 JS 工程师掌控。用户体验优化需要不同的目标、技能和兴趣;MPA 趋势放大了这一需求——在内容密集型网站的开发中,JS 开发者的参与度在下降。
更多基于标准的编码。随着开发者转向多页应用,JavaScript 在服务端渲染,客户端 JavaScript 变成可选,这迫使 SPA 之前的优秀实践回归:关注点分离、渐进增强、语义化网页设计。
Nue 从第一天起就为上述趋势而设计:HTML first 的组件模型天然适配服务端渲染与 MPA;JavaScript 只在需要交互时才出现(参见 nuedom README 中"Standards first"的论述——优先使用<dialog>、<details>、<popover>、原生表单校验等现代 HTML 能力);无虚拟 DOM 的原地更新机制保证在小体积的同时保持性能。
小结
Nue JS 的响应式体系可以概括为三层:
- 写法层:组件是带
<script>的 HTML 文档,状态就是实例变量,@click直接写表达式,数组方法与事件处理器自动触发update(),仅在少数场景需要手动调用this.update(); - 编译层:组件被编译为
name / tmpl / Impl / fns四元组,模板表达式全部变成 CSP 合规的 JavaScript 函数,索引号与模板占位一一对应(compiler.js、ast.js); - 运行层:渲染按 AST 形态递归分发(文本/条件/循环/组件/标签),更新时基于最新状态重渲染并通过极薄的
domdiff原地修改真实 DOM,全程无虚拟 DOM 与 reconciliation(node.js、diff.js)。
如果想深入验证本文中的每个机制,建议直接阅读 nuedom 测试目录:render.test.js覆盖表达式、属性、条件、循环、SVG 等渲染行为;component.test.js覆盖组件嵌套、:is别名、参数传递与脚本执行;domdiff.test.js覆盖原地 DOM 更新细节。结合源码与测试,你将对这套 2.1kb 的响应式方案有完整的把握。
【免费下载链接】nueFastest way to build modern websites项目地址: https://gitcode.com/GitHub_Trending/nu/nue
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考