
深入解析 Nue JS 的响应式原理从 2.1kb 的 Just HTML 到原地 DOM 更新【免费下载链接】nueFastest way to build modern websites项目地址: https://gitcode.com/GitHub_Trending/nu/nue导读Nue JS 是 Nue 生态中构建用户界面的核心运行时一个压缩后仅约 2.1kbmin-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 HTMLUI 是一棵带有数据绑定和事件监听的文档树而不是返回对象的 JavaScript 函数。正如 nuedom 的 README 所描述的Nue 是一种以足够少的语法扩展 HTML、用来构建网站、应用和 SVG 图像的标记语言。它基于文档结构而非程序化组合是一种不同的开发模型。这种差异不仅是语法层面的更改变了开发者思考应用结构的方式。下面用一个完全相同的计数器组件来看清两者的区别。React 版计数器Its Just JavaScript/** * React counter: Its Just JavaScript */ import React, { useState } from react; function App() { const [count, setCount] useState(0); return ( div h2You clicked {count} times!/h2 button onClick{() setCount(count - 1)} Decrement /button button onClick{() setCount(count 1)} Increment /button /div ); }Nue 版计数器Its Just HTML!-- Nue counter: Its just HTML -- div h2You clicked {count} times!/h2 button clickcount--Decrement/button button clickcountIncrement/button scriptcount 0/script /div注原博文发布于 2023 年页面展示的是较旧版本的 Nue 语法但核心概念至今一致。当前仓库中nuedom是它的演进实现语法细节可对照 nuedom 文档 与源码。Nue 版本中HTML 本身就是组件click直接写事件表达式{ count }做插值scriptcount 0/script声明组件实例的初始状态。JSX 中的onClick{() setCount(count - 1)}被简化为字符串表达式count--状态声明useState(0)变成一行count 0。为什么是 HTML firstUX 开发与 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 年 OReilly 出版了 Danny Goodman 的《Dynamic HTML: The Definitive Reference》它让无数开发者第一次意识到网页是可编程的——用 JavaScript 而不是 Java。那时人们可以写出这样的代码FORM ACTION/cgi-bin/form.cgi ONSUBMITreturn isValidEmail(this.email.value) INPUT TYPEtext NAMEemail INPUT TYPEsubmit VALUESubmit /FORM SCRIPT TYPEtext/javascript function isValidEmail(email) { return /^\S\S$/.test(email) } /SCRIPT那个时代 HTML 全大写、没有typeemail、没有button标签也不能用 JavaScript 提交数据AJAX 七年后才诞生但 HTML 已经可以动态化部分逻辑可以从后端搬到前端。如今 HTML、CSS、JavaScript 的能力早已天翻地覆尤其值得注意的是一等公民ES6 classECMAScript 2015 引入所有主流浏览器均支持。Nue 用 class 把当年的 DHTML 体验带回现代的组件化开发。把上面的表单用 Nue 重写form submit.preventsubmit input typeemail nameemail placeholderyouraddress.com required buttonSubmit/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、setterES6 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 clickcount Clicked { count } { count 1 ? time : times } scriptcount 0/script /button点击事件处理器触发后Nue 自动更新视图。数组操作同样自动触发重渲染div p button clickaddFruitAdd/button button clickimages.pop() :disabled!images[4]Remove/button /p img :forimg 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 /divpush()和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 classuser img :srcuser.avatar h3{ user.name }/h3 p{ user.email }/p button clickloadJane :disabledis_loadedLoad Jane/button script user { avatar: avatars/john.jpg, email: johnacme.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 / Runeslet count $state(0)Nuecount 0update()在源码中的实现见 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 :iscounter clickcount Clicked { count } { count 1 ? time : times } scriptcount 0/script /button编译后的产物是一个包含四个属性的对象{ name: counter, tmpl: button click0:1:/button, Impl: class { count 0 }, fns: [ (_,e) { _.count }, _ [Clicked ,_.count, ,_.count 1 ? time : times] ] }四个属性的含义name—— 组件名称tmpl—— 组件的 HTML/模板代码Impl—— 用于创建组件实例的 ES6 classfns—— 模板表达式转换成的CSP 合规Content Security Policy 兼容JavaScript 函数数组。模板上的所有表达式都被替换为索引数字用来对应fns数组中的函数。例如点击处理器click0指向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(_astast, dataself) { 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 响应式性能的关键卖点。实际工程中更新路径上用到的是一个被刻意做得很薄的 diffdiff.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 加入对比后得到以下数据数据来自原博文单位为压缩后大小度量项VueSvelteNue框架体积16.89kb1.85kb2.13 kbTodo MVC 体积1.10kb1.88kb0.96 kb框架 1 个 Todo17.99kb3.73kb3.09 kb框架 10 个 Todos27.89kb20.65kb11.73 kbNue 的足迹最小单个组件体积不到 1kb叠加 10 个 Todo 后总量约 11.73kb远低于 Vue27.89kb和 Svelte20.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 的组件模型天然适配服务端渲染与 MPAJavaScript 只在需要交互时才出现参见 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 与 reconciliationnode.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),仅供参考