Nue HTML 语法深度指南:用表达式、控制流与组件扩展标准 HTML
【免费下载链接】nueFastest way to build modern websites项目地址: https://gitcode.com/GitHub_Trending/nu/nue
Nue 在标准 HTML 之上增加了一套声明式模板语法:{ }表达式、:each循环、:if条件、组件、事件处理与生命周期钩子,让静态标记与动态逻辑在同一份文档中自然共存。本文以 html-syntax.md 为骨架,结合 nuedom 包的编译器与渲染器源码(tokenizer.js、attributes.js、node.js)以及测试用例(render.test.js),逐条讲解语法规则、底层实现与可运行的完整示例。读完本文,你将能够独立编写服务端渲染的静态页面、客户端交互组件以及可复用的 UI 组件库。
标准 HTML 就是 Nue
Nue 的核心设计原则是“不发明新语言,只扩展标准”。任何一份合法的 HTML 文档,同时也就是一份合法的 Nue 文档:
<!doctype html> <html> <head>...</head> <body> <article> <button onclick="history.go(-1)">Back</button> <button popovertarget="confirm-delete">Delete</button> </article> <dialog id="confirm-delete"> <h2>Delete user?</h2> </dialog> </body> </html>这份代码不需要任何改动即可被 Nue 原样处理。这与 html-file-types.md 中描述的文档类型体系一致:以<!doctype html>开头的文件按服务端静态页面处理,构建时生成完整的 HTML 文档;不带动态特性的内容无需任何特殊声明。
从源码看,Nue 的模板解析从 tokenizer.js 开始:它按<script、普通标签、{表达式、纯文本四种情况切分模板,随后由 document.js 组装成文档对象,再经 ast.js 生成 AST,最终由 node.js 渲染为 DOM。整个过程对标准 HTML 零侵入。
表达式:{ }插值与转义控制
用花括号在文本内容中插入动态值:
<!-- text content --> <span>{ username }</span> <!-- JavaScript expressions --> <p>{ username.toUpperCase() }</p> <!-- unescaped HTML --> <div>{{ markdown(description) }}</div> <div>{{ renderContent(article) }}</div> <!-- triple brackets also supported --> <div>{{{ userSubmittedContent }}}</div>要点说明:
- 单花括号
{ }:求值后作为纯文本插入,值会被转义(text node 方式写入),undefined、null会被清空,NaN显示为N/A,表达式抛错时渲染为[Error]——这些行为都有 render.test.js 中的测试用例支撑。 - 双花括号
{{ }}:按 HTML 解析插入,用于渲染 Markdown、富文本等已信任的 HTML 内容;值为false时渲染为空(见 render.test.js)。 - 三花括号
{{{ }}}:双花括号的等价写法。tokenizer 在解析时会先把{{{ foo }}}规范化为{{ foo }}(见 tokenizer.js),三种写法最终走同一条渲染路径。
表达式的“上下文注入”原理
表达式之所以能直接写username、count这类裸变量,是因为编译器会做上下文注入。在 context.js 中,addContext会把非保留字标识符改写为_.xxx(_代表组件实例),同时:
this会被改写为_,所以组件脚本里this.count++与模板里的{ count }指向同一数据;- 字符串字面量、对象属性键、
.或/之后的标识符跳过改写; $event会被统一替换为$e,作为事件对象占位符;document、window、Math、JSON、console等 Web 平台保留字(定义在 html5.js)保持原样,模板里可以直接调用。
属性:动态值、布尔属性与 class 处理
属性使用与文本相同的表达式语法,并在编译期被 parseAttributes 分类处理:
<!-- attribute values --> <time datetime="{ date.toISOString() }"> <!-- boolean attributes (falsy values remove the attribute) --> <button disabled="{ is_disabled }"> <!-- class name interpolation --> <div class="gallery { type }"> <!-- conditional classes --> <div class="[ is-active: isActive, has-error: hasError ]"> <!-- combine static and dynamic --> <div class="gallery { type } [ is-active: isActive ]">行为细节:
- 属性值表达式:
{ }内为任意 JavaScript 表达式,编译后被包裹为(expr)形式参与字符串拼接(见 parseExpression)。 - 布尔属性:
disabled、checked、selected、hidden、required、readonly等被列入 BOOLEAN 常量。真值时设置属性(setAttribute(name, '')),假值时整个移除。渲染端在 setAttributes 中实现这一逻辑。 - 条件 class:
[ 类名: 条件, ... ]语法会被编译为$concat({...})对象映射(见 parseClassHelper),$concat只保留条件为真的类名(node.js)。含连字符的类名自动加引号。 - 类名安全校验:渲染器会检查 class 中是否存在
:、[、]等非法字符,并提示 class 数量过多(node.js),避免调试条件 class 时留下难排查的脏数据。
render.test.js 验证了 class 映射与函数条件([ active: isActive(), error: hasError() ])都能正确渲染。
循环::each渲染列表
:each指令把数组、对象条目渲染为重复元素:
<!-- basic loop --> <li :each="item in items">{ item.name }</li> <!-- with index --> <li :each="item, i in items"> { i }: { item.name } </li> <!-- destructuring --> <li :each="{ name, price } in products"> { name } costs { price } </li> <!-- loop objects --> <li :each="[key, val] in Object.entries(data)"> { key } = { val } </li> <!-- template loops (no wrapper element) --> <dl> <template :each="term in glossary"> <dt>{ term.word }</dt> <dd>{ term.definition }</dd> </template> </dl>底层解析见 parseFor / parseForArgs:支持in/of两种分隔符;item, i形式中最后一个变量作为索引;{ name, price }与[key, val]分别触发对象解构与 entries 模式。渲染循环在 renderLoop 中完成:每次迭代克隆一份组件数据、写入循环变量(含索引i),再逐个渲染。
<template>循环是免包装元素的关键——它不产生额外标签,直接把内部节点平铺到父容器,适合<dl>、<table>等不允许任意包裹元素的场景。
条件::if/:else-if/:else
<p :if="count > 100">Too many!</p> <p :else-if="count > 10">Getting there</p> <p :else>{ count } items</p> <!-- combine with loops (condition evaluated first) --> <ul :if="items.length"> <li :each="item in items">{ item }</li> </ul> <p :else>No items</p>编译期,相邻的条件元素会被合并为一个some分支组(见 mergeConditionals),保证:else永远挂在最近的:if兄弟上;运行期 renderIf 从上到下求值,命中第一个为真的分支渲染,[Error]视为假。条件与循环组合时,条件优先求值,未命中则整块不渲染。
组件:可复用的 UI 单元
组件即“带<script>自定义标签的文档片段”。定义、使用与传参:
<!-- define a component --> <product-card> <h3>{ name }</h3> <p>{ price }</p> <script> // default values this.name = 'Untitled' this.price = 0 </script> </product-card> <!-- use the component --> <product-card/> <!-- pass properties --> <product-card :name="Coffee" :price="12"/> <!-- pass data variables --> <product-card :name="productName" :price="productPrice"/> <!-- shorthand (passes the name and price variables) --> <product-card :name :price/> <!-- regular attributes (no colon prefix) are rendered --> <product-card id="featured" class="highlight"/> <!-- loop components --> <product-card :each="item in products" :bind="item"/>规则梳理:
- 属性传递:
:name="Coffee"传字面量;:name="productName"传变量;:name简写等价于:name="name"(源码见 attributes.js,带冒号且无值时会自动补为同名变量)。这些属性以is_data标记,只进入组件数据、不渲染为 DOM 属性(node.js)。 - 普通属性:不带冒号的
id、class等原样渲染在组件根元素上。 :bind="item":把对象整体展开合并进组件数据(getAttrData),配合:each可把列表项整体注入组件。- 组件默认值:
<script>里this.xxx = ...在组件实例化时执行(node.js),未传入的属性回落到默认值。
组件根元素::is
组件默认渲染为<div>包裹;用:is换成任意标签:
<!-- this component renders as <figure> --> <image-card :is="figure"> <img src="{ url }"> <figcaption>{ caption }</figcaption> </image-card>从渲染路径看,renderComponent 会优先采用:is指定的标签名作为根元素;:is也用于把原生标签升级为交互组件(如<form :is="member-form">,见 html-file-types.md 的 DHTML 库示例)。自定义标签(含连字符或非 HTML5 标签)由 ast.js 判定为is_custom,触发组件渲染逻辑。
事件处理(客户端)
客户端专属——:on前缀为任意受支持事件绑定处理逻辑:
<counter> <button :onclick="count++">{ count }</button> <script> this.count = 0 </script> </counter> <!-- method handlers --> <counter> <button :onclick="increment">+</button> <button :onclick="decrement">-</button> <p>Count: { count }</p> <p>Double: { double }</p> <script> this.count = 0 increment() { this.count++ } decrement() { if (this.count > 0) this.count-- } // getter methods are supported get double() { return this.count * 2 } </script> </counter> <!-- event object --> <form :onsubmit="handleSubmit"> <script> handleSubmit(e) { // forms automatically call e.preventDefault() console.log('Submitted:', e.target) } </script> </form>实现要点:
- 可绑定的事件集合定义在 EVENTS 常量(click、submit、input、keydown、pointerdown、transitionend 等数十种)。
:onclick解析为click事件监听(attributes.js)。 - 内联表达式:
:onclick="count++"直接写语句;方法名::onclick="increment"会自动补全为increment($e)调用(同一段源码)。 - 脚本中的
increment() { ... }方法写法会被编译器转换为this.increment = function(...)(见 convertFunctions);get double()则被转换为Object.defineProperty(this, 'double', { get() {...} })(convertGetters),因此 getter 也能直接在模板与事件中读取。 - 表单事件:
submit事件会自动调用e.preventDefault(),避免页面刷新(node.js),配合e.target可取回 FormData。 - 每次事件触发后组件自动
update()重渲染(node.js),数据变更即时反映到视图。
生命周期方法(客户端)
客户端专属——在关键时间点执行逻辑:
<user-profile> <h2>{ user.name }</h2> <script> // before mounting to DOM onmount() { console.log('About to mount') } // after mounting to DOM mounted() { console.log('Mounted!') } // before updating onupdate() { console.log('About to update') } // after updating updated() { console.log('Updated!') } </script> </user-profile>这些钩子在 node.js 中被显式调用:onmount在插入 DOM 前触发、mounted在挂载后触发(mount,见 L47-L54);onupdate在重渲染前触发,若返回false可取消本次更新,updated在更新完成后触发(update,见 L13-L25)。组件作为子组件被渲染时同样会依次触发(renderComponent)。
手动更新:this.update(data)
客户端专属——事件处理器之外的场景需要手动触发重渲染:
this.update(data)事件处理器触发后会自动更新视图;但异步操作(如fetch)或外部事件(如 WebSocket 消息)结束后,需要显式调用this.update()把新数据合并进组件并重渲染:
<script> async mounted() { const data = await fetch('/api/user') const user = await data.json() // Manual update required after async operations this.update({ user }) } </script>update(values)先Object.assign合并数据,再执行 onupdate → 渲染 → domdiff → updated 全流程(node.js),差异更新复用 diff.js 的 DOM 比对算法。
动态挂载:this.mount(name, target, data)
客户端专属——在单页应用中按需把组件挂载到指定位置:
this.mount(name, target, data)参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
name | string | 组件名 |
target | DOM 元素或 CSS 选择器 | 挂载目标,字符串会被querySelector解析 |
data | object(可选) | 传给组件的数据 |
<my-app> <article/> <script> state.on('id', ({ id }) => { this.mount(id ? 'user-details' : 'user-list', 'article') }) </script> </my-app>实现上,this.mount会在已加载的组件依赖(opts.deps)中按名字查找组件,动态创建实例并挂载(node.js)。路由切换等场景的配套模式见 single-page-apps。
共享脚本:跨组件复用函数与常量
顶层<script>中定义的函数、常量对所有组件可见:
<!-- top-level script --> <script> // available to all components const TAX_RATE = 0.08 function formatPrice(num) { return '$' + num.toFixed(2) } </script> <!-- use in components --> <product-card> <p>{ formatPrice(price) }</p> <p>Tax: { formatPrice(price * TAX_RATE) }</p> <script> this.price = 10 </script> </product-card> <!-- another component definition --> <shopping-cart> <p>{ formatPrice(price) }</p> <script> // ... </script> </shopping-cart>编译期 document.js 会把文档中的所有<script>内容合并到page.script,再通过 parseNames 提取其中声明的变量与函数名,作为后续表达式注入的“已知标识符”,因此共享函数在任意组件模板中都能被直接调用。
JavaScript 导入(客户端)
客户端专属——导入外部模块,并在模板中使用其导出:
<script> import { formatDistance } from './utils.js' import { store } from './store.js' </script> <!-- imported functions available in templates --> <article> <time>{ formatDistance(date) }</time> <p>Cart items: { store.cart.length }</p> </article>含import语句的文档会被 document.js 自动判定为动态 HTML(is_dhtml),导入的名字同样进入parseNames的已知标识符列表,模板可直接引用。这也是 html-file-types.md 中“自动检测 DHTML”的依据之一。
Passthrough scripts(服务端)
服务端专属——带type或src属性的<script>不参与 Nue 处理,原样输出到客户端:
<!-- these render as-is to the client --> <script src="/analytics.js"></script> <script type="module"> console.log('This runs on the client') </script>区分逻辑在 tokenizer 中:<script>标签连同属性整体被保留为原始 token(tokenizer.js),而真正承载组件逻辑的裸<script>才会被提取执行。
插槽:<slot/>组合模式
组件通过<slot/>声明内容插槽,使用方填入的内容会被渲染到插槽位置:
<!-- component with slot --> <card> <div class="card"> <slot/> </div> </card> <!-- using the slot --> <card> <h2>This goes inside the card</h2> <p>So does this</p> </card> <!-- multiple instances --> <card :each="post in posts"> <h2>{ post.title }</h2> <p>{ post.excerpt }</p> </card>渲染端遇到slot节点时,会回填父组件的子内容(node.js)。插槽与:each天然组合:循环创建的每个<card>实例都会收到各自 posts 项的内容。这是 Nue 实现卡片、弹层、布局组件等组合型 UI 的基础,配合 layout-system 可搭建完整的布局体系。
CSS 变量:设计令牌直通样式
用--前缀属性把值写入元素的 CSS 变量,无需内联样式:
<!-- renders as style="--spacing: 2rem" --> <section --spacing="2rem"> <style> section { padding: var(--spacing); } </style> </section> <!-- dynamic values --> <div --columns="{ columnCount }">编译期,--xxx属性被标记为is_var且剥掉前缀(attributes.js);渲染时收集所有 CSS 变量属性,统一合并为一条style="--name:value;..."(setAttributes)。这样设计系统(设计令牌)可以保持单一事实来源:不在标记里塞内联样式、不靠 class 堆叠覆盖,主题、间距、栅格等令牌通过组件属性直接注入样式。
与文档类型体系的衔接
Nue 语法本身是“服务端与客户端同构”的:{ }表达式、:each、:if、组件、插槽、CSS 变量在服务端构建与客户端运行时都可用;事件处理、生命周期、this.update()、this.mount()、JS 导入则标记为客户端专属。页面的最终行为由文档类型决定:
<!doctype html>:服务端渲染的静态页面;<!doctype dhtml>:客户端组件,挂载后交互;<!html lib>/<!dhtml lib>/<!html+dhtml>:可复用组件库(含同构组件)。
完整说明见 html-file-types.md。值得一提的是,即使不写文档类型,document.js 也会根据是否存在:on*事件处理器、import语句等自动检测 DHTML,但显式声明<!dhtml>更为稳健清晰。
小结
Nue 的 HTML 语法是对标准 HTML 的“增量扩展”:表达式与控制流解决数据渲染,组件与插槽解决复用与组合,事件与生命周期解决交互,CSS 变量解决设计令牌传递。每条语法规则都能在 nuedom 的编译管线(tokenize → parse → AST → render)中找到对应实现,并有 render.test.js 等测试用例背书。以此为起点,你可以继续阅读 html-file-types.md 了解文档类型体系,或通过 examples/nue-counter.html 看到一个最小的计数器组件示例。
【免费下载链接】nueFastest way to build modern websites项目地址: https://gitcode.com/GitHub_Trending/nu/nue
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考