news 2026/9/16 14:43:04

深入解析 Nue JS 的响应式原理:从 2.1kb 的 “Just HTML“ 到原地 DOM 更新

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入解析 Nue JS 的响应式原理:从 2.1kb 的 “Just HTML“ 到原地 DOM 更新

深入解析 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()几乎是响应式方面唯一需要额外记住的东西。你不需要关心框架特有的抽象,思考负担显著降低。例如,初始化一个响应式变量在各框架中的写法对比:

框架代码
Reactconst [count, setCount] = useState(0)
Vueconst count = ref(0)
Svelte / "Runes"let count = $state(0)
Nuecount = 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'] ] }

四个属性的含义:

  1. name—— 组件名称;
  2. tmpl—— 组件的 HTML/模板代码;
  3. Impl—— 用于创建组件实例的 ES6 class;
  4. 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(解构:forkeysindexfn,为每个条目创建带循环变量的子实例,支持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 }

文件头部的注释总结了它的设计原则:

  1. 无 VDOM 开销—— 直接 diff 真实 DOM 节点,跳过虚拟树创建;
  2. 定向更新—— 只修改有差异的属性和子节点;
  3. 键控 diff—— 用key复用节点(类似 React),但没有组件生命周期包袱;
  4. 位置回退—— 无 key 时用简单的线性遍历;
  5. 极低内存—— 只有两次Array.from(),没有深克隆或状态追踪;
  6. 纯 JavaScript—— 低层控制,无额外转译步骤。

也就是说,即便到了工程实现层面,"无虚拟 DOM、无 reconciliation"的承诺依然成立。测试 domdiff.test.js 对属性增删、文本节点更新、按 key 复用等场景都有覆盖,可以作为阅读该模块的入口。

保持小巧:编译产物的体积优势

编译后的 Nue 代码非常小,只有 HTML 源码的约 1.2 倍。这使得 Nue 应用成为同类中体积最小的方案之一。

Vue 作者 Evan You 曾比较过 Vue 与 Svelte 组件生成代码的体积(以 TodoMVC 为单个组件的度量标准)。把 Nue TodoMVC 加入对比后得到以下数据(数据来自原博文,单位为压缩后大小):

度量项VueSvelteNue
框架体积16.89kb1.85kb2.13 kb
Todo MVC 体积1.10kb1.88kb0.96 kb
框架 + 1 个 Todo17.99kb3.73kb3.09 kb
框架 + 10 个 Todos27.89kb20.65kb11.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 的响应式体系可以概括为三层:

  1. 写法层:组件是带<script>的 HTML 文档,状态就是实例变量,@click直接写表达式,数组方法与事件处理器自动触发update(),仅在少数场景需要手动调用this.update()
  2. 编译层:组件被编译为name / tmpl / Impl / fns四元组,模板表达式全部变成 CSP 合规的 JavaScript 函数,索引号与模板占位一一对应(compiler.js、ast.js);
  3. 运行层:渲染按 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 14:41:06

GC3909S一芯双驱:中小功率运动控制的集成化新范式

1. 为什么这颗“一芯双驱”的GC3909S正在悄悄改变中小功率运动控制的底层逻辑你有没有遇到过这样的场景&#xff1a;给一台桌面级3D打印机加装第二路Z轴同步升降&#xff0c;结果发现主控板上那块DRV8825已经占满IO口&#xff0c;再塞一块就得改PCB&#xff1b;或者调试一台轻型…

作者头像 李华
网站建设 2026/9/16 14:39:21

小米5刷LineageOS 18.1:Android 11老旗舰刷机实践与避坑指南

简介&#xff1a;小米5安卓11最新系统lineage-18.1小米5.zip是一份为小米5机型定制的第三方系统固件&#xff0c;基于Android 11稳定分支的LineageOS 18.1&#xff0c;适合希望突破官方更新限制、自行刷入新系统以获得性能提升和个性化体验的刷机用户。压缩包共13个文件&#x…

作者头像 李华