比较符合我需求的是 Ant Design X 的 @ant-design/x-markdown。它解决了“流式渲染”和“自定义组件”的问题,设计得很干净。比较符合我需求的是 Ant Design X 的 @ant-design/x-markdown。它解决了“流式渲染”和“自定义组件”的问题,设计得很干净。
但有一个致命点:它是 React + Ant Design 深度绑定。我的项目是 Vue 2.5 + Element UI,在工程形态和渲染方式上完全不匹配。要硬用就得包一层 React 运行时,成本太高。但有一个致命点:它是 React + Ant Design 深度绑定。我的项目是 Vue 2.5 + Element UI,在工程形态和渲染方式上完全不匹配。要硬用就得包一层 React 运行时,成本太高。
x-langjs 就是想让 AI 的输出从“只能看”变成“可以点”。它通过一个极简的 DSL 把组件的“声明”和“实现”分开,AI 只负责声明,开发者负责实现。DSL 本身支持简单的表达式,但真正的计算能力都留给 setup 函数,这样既灵活又安全。x-langjs 就是想让 AI 的输出从“只能看”变成“可以点”。它通过一个极简的 DSL 把组件的“声明”和“实现”分开,AI 只负责声明,开发者负责实现。DSL 本身支持简单的表达式,但真正的计算能力都留给 setup 函数,这样既灵活又安全。
如果你也在做 AI 对话类的应用,想让 AI 能动态生成可交互的 UI,不妨试试 x-langjs。项目完全开源,仓库里有详细的文档和可运行的 playground 示例,上手很容易。如果你也在做 AI 对话类的应用,想让 AI 能动态生成可交互的 UI,不妨试试 x-langjs。项目完全开源,仓库里有详细的文档和可运行的 playground 示例,上手很容易。
先聊个场景:前段时间体验千问免费请客的功能,真的被那种丝滑的交互惊艳到了——聊着聊着,它直接甩出一个商品卡片,图片、价格、购买按钮一应俱全,点一下就能下单,整个过程行云流水。当时我就想:这到底是怎么做到的?先聊个场景:前段时间体验千问免费请客的功能,真的被那种丝滑的交互惊艳到了——聊着聊着,它直接甩出一个商品卡片,图片、价格、购买按钮一应俱全,点一下就能下单,整个过程行云流水。当时我就想:这到底是怎么做到的?
于是我去 GitHub 上翻了翻,想看看有没有现成的轮子。还真让我找到了——Ant Design X 的 @ant-design/x-markdown,它正好解决了“流式渲染”和“自定义组件”的问题,设计得很干净。但仔细一看,它是 React + Ant Design 深度绑定的,而我的项目是 Vue 2.5 + Element UI,完全没法直接用。要硬用就得包一层 React 运行时,成本太高。于是我去 GitHub 上翻了翻,想看看有没有现成的轮子。还真让我找到了——Ant Design X 的 @ant-design/x-markdown,它正好解决了“流式渲染”和“自定义组件”的问题,设计得很干净。但仔细一看,它是 React + Ant Design 深度绑定的,而我的项目是 Vue 2.5 + Element UI,完全没法直接用。要硬用就得包一层 React 运行时,成本太高。
既然现成的轮子用不上,我就决定读它的源码,复用它的“解析思路”,再做一个真正通用的方案。于是就有了 x-langjs——一个让AI能在回复里“画”出UI组件的小脚本语言。既然现成的轮子用不上,我就决定读它的源码,复用它的“解析思路”,再做一个真正通用的方案。于是就有了 x-langjs——一个让AI能在回复里“画”出UI组件的小脚本语言。
先看开源:@ant-design/x-markdown先看开源:@ant-design/x-markdown
比较符合我需求的是 Ant Design X 的 @ant-design/x-markdown。它解决了“流式渲染”和“自定义组件”的问题,设计得很干净。比较符合我需求的是 Ant Design X 的 @ant-design/x-markdown。它解决了“流式渲染”和“自定义组件”的问题,设计得很干净。
但有一个致命点:它是 React + Ant Design 深度绑定。我的项目是 Vue 2.5 + Element UI,在工程形态和渲染方式上完全不匹配。要硬用就得包一层 React 运行时,成本太高。但有一个致命点:它是 React + Ant Design 深度绑定。我的项目是 Vue 2.5 + Element UI,在工程形态和渲染方式上完全不匹配。要硬用就得包一层 React 运行时,成本太高。
所以我决定读它的源码,复用它的“解析思路”,再做一个真正通用的方案。所以我决定读它的源码,复用它的“解析思路”,再做一个真正通用的方案。
读源码:核心解析思路是什么?读源码:核心解析思路是什么?
下面这段代码来自 @ant-design/x-markdown 的真实源码,它的解析思路非常值得参考。下面这段代码来自 @ant-design/x-markdown 的真实源码,它的解析思路非常值得参考。
Parser.ts(https://github.com/ant-design/x/blob/main/packages/x-markdown/src/XMarkdown/core/Parser.ts) 里通过 marked 统一解析,然后在 code 渲染中埋入流式状态:Parser.ts(https://github.com/ant-design/x/blob/main/packages/x-markdown/src/XMarkdown/core/Parser.ts) 里通过 marked 统一解析,然后在 code 渲染中埋入流式状态:
const renderer = { code({ text, raw, lang, escaped, codeBlockStyle }: Tokens.Code): string { const infoString = (lang || '').trim(); const langString = infoString.match(other.notSpaceStart)?.[0]; const code = `${text.replace(other.endingNewline, '')}\n`; const isIndentedCode = codeBlockStyle === 'indented'; const streamStatus = isIndentedCode || other.completeFencedCode.test(raw) ? 'done' : 'loading'; const escapedCode = escaped ? code : escapeHtml(code, true); const classAttr = langString ? ` class="language-${escapeHtml(langString)}"` : ''; const dataAttrs = ` data-block="true" data-state="${streamStatus}"` + (infoString ? ` data-lang="${escapeHtml(infoString)}"` : ''); return `<pre><code${dataAttrs}${classAttr}>${escapedCode}</code></pre>\n`; }, };这里的关键是:流式状态不是靠“外部判断”,而是直接体现在 HTML 里。如果 fenced code 还没闭合,data-state="loading" 会被写进去,后续渲染时就能识别“半成品”。这里的关键是:流式状态不是靠“外部判断”,而是直接体现在 HTML 里。如果 fenced code 还没闭合,data-state="loading" 会被写进去,后续渲染时就能识别“半成品”。
另一个很实用的小技巧:在 protectCustomTags 里处理自定义组件内的空行,防止被 Markdown 解析器拆段。另一个很实用的小技巧:在 protectCustomTags 里处理自定义组件内的空行,防止被 Markdown 解析器拆段。
private protectCustomTags(content: string): { protected: string; placeholders: Map<string, string>; } { const placeholders = new Map<string, string>(); const customTagNames = Object.keys(this.options.components || {}); // ... if (innerContent.includes('\n\n')) { const protectedInner = innerContent.replace(/\n\n/g, () => { const ph = `__X_MD_PLACEHOLDER_${placeholderIndex++}__`; placeholders.set(ph, '\n\n'); return ph; }); result.push(openTag + protectedInner + closeTag); } // ... }思路总结:思路总结:
- Markdown 解析交给成熟工具(marked)。- Markdown 解析交给成熟工具(marked)。
- 流式状态用 data-state 持久化到输出 HTML。- 流式状态用 data-state 持久化到输出 HTML。
- 自定义组件的内容要“保护”,避免被 Markdown 拆解。- 自定义组件的内容要“保护”,避免被 Markdown 拆解。
2. 渲染层:DOM 清洗 + 组件替换 + 识别未闭合标签2. 渲染层:DOM 清洗 + 组件替换 + 识别未闭合标签
渲染层在 Renderer.ts(https://github.com/ant-design/x/blob/main/packages/x-markdown/src/XMarkdown/core/Renderer.ts),核心三步:渲染层在 Renderer.ts(https://github.com/ant-design/x/blob/main/packages/x-markdown/src/XMarkdown/core/Renderer.ts),核心三步:
1. 检测未闭合标签,用于流式 loading;1. 检测未闭合标签,用于流式 loading;
2. DOMPurify 清洗;2. DOMPurify 清洗;
3. 把自定义标签替换成 React 组件。3. 把自定义标签替换成 React 组件。
private detectUnclosedTags(htmlString: string): Set<string> { const unclosedTags = new Set<string>(); const stack: string[] = []; const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:\s[^>]*)?>/g; let match = tagRegex.exec(htmlString); while (match !== null) { const [fullMatch, tagName] = match; const isClosing = fullMatch.startsWith('</'); const isSelfClosing = fullMatch.endsWith('/>'); if (this.options.components?.[tagName.toLowerCase()]) { if (isClosing) { const lastIndex = stack.lastIndexOf(tagName.toLowerCase()); if (lastIndex !== -1) { stack.splice(lastIndex, 1); } } else if (!isSelfClosing) { stack.push(tagName.toLowerCase()); } } match = tagRegex.exec(htmlString); } stack.forEach((tag) => { unclosedTags.add(tag); }); return unclosedTags; }这段逻辑很好用:流式输出时自定义标签没闭合也不崩,而是标记成 loading,再交给组件处理。这段逻辑很好用:流式输出时自定义标签没闭合也不崩,而是标记成 loading,再交给组件处理。
思路总结:思路总结:
- HTML 进入 React 之前先用 DOMPurify 过滤。- HTML 进入 React 之前先用 DOMPurify 过滤。
- 自定义标签用 replace 机制“换成组件”。- 自定义标签用 replace 机制“换成组件”。
- 未闭合标签识别后可用于“占位渲染”。- 未闭合标签识别后可用于“占位渲染”。
这些思路非常清晰,但它仍然是 React 的思路:domToReact + createElement。我的 Vue 项目很难复用这套逻辑。于是就有了 x-langjs。这些思路非常清晰,但它仍然是 React 的思路:domToReact + createElement。我的 Vue 项目很难复用这套逻辑。于是就有了 x-langjs。
x-langjs 到底是什么x-langjs 到底是什么
简单说,就是让 AI 在 Markdown 里写类似 button("点我") 这样的代码,然后你提前用 JavaScript 定义好 button 长啥样、怎么渲染。前端拿到 AI 流式吐出来的内容,解析这些代码块,找到对应的组件,渲染到页面上。简单说,就是让 AI 在 Markdown 里写类似 button("点我") 这样的代码,然后你提前用 JavaScript 定义好 button 长啥样、怎么渲染。前端拿到 AI 流式吐出来的内容,解析这些代码块,找到对应的组件,渲染到页面上。
整个过程对用户来说完全无感,他们只看到 AI 回复里多了个能点的按钮。整个过程对用户来说完全无感,他们只看到 AI 回复里多了个能点的按钮。
先展示神力先展示神力
安装:安装:
npm install @x-langjs/core # 或者用 pnpm pnpm add @x-langjs/core然后注册一个按钮组件:然后注册一个按钮组件:
import { XLangApp, defineComponent } from '@x-langjs/core'; const app = new XLangApp(); app.use(defineComponent('button', { // setup 接收 DSL 里的参数,返回组件需要的 props setup: (args) => ({ text: String(args[0] ?? '默认按钮'), type: String(args[1] ?? 'primary') }), // render 负责把 props 渲染到容器里 render(data, container) { const btn = document.createElement('button'); btn.textContent = data.text; btn.className = `btn-${data.type}`; container.appendChild(btn); // 可选:返回 dispose 函数清理资源 return { dispose: () => btn.remove() }; } }));现在,让 AI 输出这样一段 Markdown:现在,让 AI 输出这样一段 Markdown:
普通文字... ```x-langjs button("立即购买", "danger") ``` 后面还有文字。然后你调用:然后你调用:
app.run(markdown, document.getElementById('root'));页面上就会渲染出一个红色(danger)的“立即购买”按钮。页面上就会渲染出一个红色(danger)的“立即购买”按钮。
code.juejin.cnjcodejcode打开在线代码示例。打开在线代码示例。复杂玩法:构建一个订单卡片组件(Vue + Element UI)复杂玩法:构建一个订单卡片组件(Vue + Element UI)
光看按钮不过瘾,我们来点真东西:用 x-langjs 配合 Vue 2 和 Element UI,让 AI 输出一个带交互的订单卡片。光看按钮不过瘾,我们来点真东西:用 x-langjs 配合 Vue 2 和 Element UI,让 AI 输出一个带交互的订单卡片。
完整 HTML 代码(可直接运行)完整 HTML 代码(可直接运行)
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>x-langjs 订单卡片示例</title> <link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css"> <style> body { font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif; padding: 20px; background: #f0f2f5; } #output { margin: 20px 0; } .log-panel { background: #fff; padding: 15px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .log-panel pre { margin: 10px 0 0; padding: 10px; background: #f8f8f8; border-radius: 4px; } </style> </head> <body> <h2>x-langjs 复杂 demo - 订单卡片(Element UI)</h2> <p>AI 输出 <code>```x-langjs orderCard("ORD-123", 299.99, "已发货") ```</code>,动态渲染如下:</p> <div id="output"></div> <div class="log-panel"> <strong>事件日志:</strong> <pre id="event-log">等待卡片交互...</pre> </div> <!-- 引入依赖 --> <script src="https://unpkg.com/vue@2.7.16/dist/vue.js"></script> <script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script> <script src="https://unpkg.com/@x-langjs/core@0.0.5/dist/x-langjs.min.js"></script> <script> (function() { const outputEl = document.getElementById('output'); const logEl = document.getElementById('event-log'); const { XLangApp, defineComponent } = XLang; const app = new XLangApp(); // 注册 orderCard 组件 app.use(defineComponent('orderCard', { setup: (args) => ({ orderId: String(args[0] || '未知订单'), amount: Number(args[1] || 0), status: String(args[2] || '待处理') }), render(data, container, ctx) { // 创建 Vue 实例,用 render 函数生成 Element UI 卡片 const vm = new Vue({ data: { orderId: data.orderId, amount: data.amount, status: data.status }, methods: { handleConfirm() { ctx.emit('confirm', { orderId: this.orderId, message: `订单 ${this.orderId} 确认收货` }); } }, render(h) { return h('el-card', { props: { shadow: 'hover' } }, [ h('div', { slot: 'header' }, [ h('span', { style: { fontWeight: 'bold' } }, '订单详情'), h('el-tag', { props: { type: this.status === '已发货' ? 'success' : 'warning', size: 'small', style: 'float: right;' } }, this.status) ]), h('div', { style: 'margin-bottom: 10px;' }, [ h('p', { style: 'margin: 5px 0;' }, `订单号:${this.orderId}`), h('p', { style: 'margin: 5px 0;' }, `金额:¥${this.amount.toFixed(2)}`) ]), h('div', { style: 'text-align: right;' }, [ h('el-button', { props: { type: 'primary', size: 'small', disabled: this.status !== '已发货' }, on: { click: this.handleConfirm } }, '确认收货') ]) ]); } }); vm.$mount(container); return { dispose: () => { vm.$destroy(); container.innerHTML = ''; } }; } })); // 监听 confirm 事件 app.on('orderCard', 'confirm', (payload) => { logEl.textContent = `✅ ${payload.message} (时间: ${new Date().toLocaleTimeString()})`; console.log('事件收到:', payload); }); // Markdown 内容 const markdownContent = ` ### 这里是 markdown 渲染逻辑 \`\`\`x-langjs orderCard("ORD-123", 299.99, "已发货") \`\`\` 点击“确认收货”按钮,下方日志会记录事件。 `; app.run(markdownContent, outputEl); })(); </script> </body> </html>在上面的例子里,render 函数里用了很多 Vue 的 h 方法(也就是 createElement),这确实会让人觉得“这么复杂,正常项目也要这么写吗?”在上面的例子里,render 函数里用了很多 Vue 的 h 方法(也就是 createElement),这确实会让人觉得“这么复杂,正常项目也要这么写吗?”
其实不是的。h 函数只是 Vue 提供的一种灵活构建虚拟 DOM 的方式,但绝不是唯一的方式。 x-langjs 的 render 函数只要求你把内容渲染到传入的 container 这个 DOM 元素里,至于你怎么渲染,完全自由。其实不是的。h 函数只是 Vue 提供的一种灵活构建虚拟 DOM 的方式,但绝不是唯一的方式。 x-langjs 的 render 函数只要求你把内容渲染到传入的 container 这个 DOM 元素里,至于你怎么渲染,完全自由。
你可以用任何你喜欢的方式你可以用任何你喜欢的方式
1. 直接用原生 DOM 操作1. 直接用原生 DOM 操作
如果组件很简单,直接用 document.createElement 和 appendChild 就行,就像最开始的按钮示例那样:如果组件很简单,直接用 document.createElement 和 appendChild 就行,就像最开始的按钮示例那样:
render(data, container) { const btn = document.createElement('button'); btn.textContent = data.text; container.appendChild(btn); }2. 使用 Vue 的单文件组件(.vue)2. 使用 Vue 的单文件组件(.vue)
在真实项目中,你大概率会把组件写成 .vue 文件(模板 + 脚本 + 样式)。然后在 render 里动态创建这个组件的实例并挂载:在真实项目中,你大概率会把组件写成 .vue 文件(模板 + 脚本 + 样式)。然后在 render 里动态创建这个组件的实例并挂载:
import MyOrderCard from './components/MyOrderCard.vue'; render(data, container, ctx) { const ComponentClass = Vue.extend(MyOrderCard); const instance = new ComponentClass({ propsData: { ...data }, // 可以监听事件 listeners: { confirm: (payload) => ctx.emit('confirm', payload) } }); instance.$mount(container); return { dispose: () => instance.$destroy() }; }这样你的组件代码就和普通 Vue 组件完全一样,不需要写 h 函数。这样你的组件代码就和普通 Vue 组件完全一样,不需要写 h 函数。
3. 使用 JSX(如果项目配置了)3. 使用 JSX(如果项目配置了)
如果你喜欢 JSX 的写法,也可以在 render 里直接用:如果你喜欢 JSX 的写法,也可以在 render 里直接用:
render(data, container) { const element = <el-card shadow="hover">...</el-card>; // 需要借助 Vue 的渲染函数把 JSX 转成真实 DOM const vm = new Vue({ render: h => element }); vm.$mount(container); }4. 甚至可以用其他框架4. 甚至可以用其他框架
x-langjs 不挑食,你想用 React、Preact 甚至 jQuery 都行,只要最后把内容放进 container 即可。x-langjs 不挑食,你想用 React、Preact 甚至 jQuery 都行,只要最后把内容放进 container 即可。
核心逻辑拆解核心逻辑拆解
1. 注册组件:通过 defineComponent 定义 orderCard,setup 将 DSL 参数转为 props,render 中动态创建 Vue 实例,使用 Vue 的 render 函数生成 Element UI 卡片。1. 注册组件:通过 defineComponent 定义 orderCard,setup 将 DSL 参数转为 props,render 中动态创建 Vue 实例,使用 Vue 的 render 函数生成 Element UI 卡片。
2. 事件传递:卡片内的“确认收货”按钮点击时,调用 ctx.emit('confirm', payload) 将事件发送到 x-langjs 应用层。2. 事件传递:卡片内的“确认收货”按钮点击时,调用 ctx.emit('confirm', payload) 将事件发送到 x-langjs 应用层。
3. 监听事件:app.on('orderCard', 'confirm', ...) 接收事件并更新页面日志,实际开发中可以在这里调用后端 API。3. 监听事件:app.on('orderCard', 'confirm', ...) 接收事件并更新页面日志,实际开发中可以在这里调用后端 API。
4. DSL 调用:Markdown 中包含 x-langjs orderCard("ORD-123", 299.99, "已发货") ,运行后渲染出卡片。4. DSL 调用:Markdown 中包含 x-langjs orderCard("ORD-123", 299.99, "已发货") ,运行后渲染出卡片。
这个示例展示了 x-langjs 真正的威力:AI 动态生成 UI,而你用自己熟悉的框架(Vue)来实现这些 UI,事件也能双向沟通。不需要改造现有项目,只需要写几行胶水代码,就能让 AI 输出活起来的组件。这个示例展示了 x-langjs 真正的威力:AI 动态生成 UI,而你用自己熟悉的框架(Vue)来实现这些 UI,事件也能双向沟通。不需要改造现有项目,只需要写几行胶水代码,就能让 AI 输出活起来的组件。
核心玩法核心玩法
1. 用任何框架渲染组件1. 用任何框架渲染组件
x-langjs 的 render 函数只给一个 DOM 容器,你爱用啥框架就用啥。x-langjs 的 render 函数只给一个 DOM 容器,你爱用啥框架就用啥。
用 Vue:用 Vue:
import { h, render as vueRender } from 'vue'; import MyButton from './MyButton.vue'; defineComponent('button', { setup: (args) => ({ text: String(args[0]) }), render(data, container) { // Vue 的 render 会替换 container 的内容 vueRender(h(MyButton, { text: data.text }), container); } });用 React:用 React:
import { createRoot } from 'react-dom/client'; import { Button } from 'antd'; defineComponent('button', { setup: (args) => ({ text: String(args[0]) }), render(data, container) { const root = createRoot(container); root.render(<Button>{data.text}</Button>); // React 18 需要手动 unmount return { dispose: () => root.unmount() }; } });2. 监听 UI 事件2. 监听 UI 事件
组件可以往回发消息,用 ctx.emit。组件可以往回发消息,用 ctx.emit。
组件定义:组件定义:
defineComponent('button', { setup: (args) => ({ text: String(args[0]) }), render(data, container, ctx) { const btn = document.createElement('button'); btn.textContent = data.text; btn.onclick = () => ctx.emit('click', { text: data.text }); container.appendChild(btn); } });外面监听:外面监听:
app.on('button', 'click', (payload) => { console.log('按钮点了:', payload.text); // 可以调接口、更新状态等 });3. 流式渲染3. 流式渲染
AI 是一点一点往外吐的,你只需要不断调用 app.run 传入累加的内容,引擎会自动对比差异,只更新变化的部分。AI 是一点一点往外吐的,你只需要不断调用 app.run 传入累加的内容,引擎会自动对比差异,只更新变化的部分。
let accumulated = ''; for await (const chunk of aiStream) { accumulated += chunk; app.run(accumulated, container); }如果某个组件还没闭合(比如还在接收参数),引擎会显示一个默认的“加载中...”占位,等完整了再替换成真正的组件。如果某个组件还没闭合(比如还在接收参数),引擎会显示一个默认的“加载中...”占位,等完整了再替换成真正的组件。
4. 多轮对话管理4. 多轮对话管理
每个 AI 回复应该是独立的,避免互相干扰。x-langjs 提供了一个 ConversationRenderer 来帮你管理多条消息。每个 AI 回复应该是独立的,避免互相干扰。x-langjs 提供了一个 ConversationRenderer 来帮你管理多条消息。
import { ConversationRenderer } from './conversation-renderer'; // playground 里有现成的 const renderer = new ConversationRenderer(chatContainer, { components: [buttonComponent, cardComponent] }); // AI 开始回复新消息 const msg = renderer.addMessage('msg-1'); msg.update(partialMarkdown); // 流式更新 msg.finalize(); // 完成 // 下一条消息 const msg2 = renderer.addMessage('msg-2'); ...每条消息背后都是一个独立的 XLangApp 实例,组件定义是共享的,但执行环境隔离。每条消息背后都是一个独立的 XLangApp 实例,组件定义是共享的,但执行环境隔离。
5. 覆盖默认渲染器5. 覆盖默认渲染器
x-langjs 内置了 Markdown 渲染(简单的 innerHTML)和占位渲染。你可以替换成自己喜欢的。x-langjs 内置了 Markdown 渲染(简单的 innerHTML)和占位渲染。你可以替换成自己喜欢的。
比如用 marked 来渲染 Markdown:比如用 marked 来渲染 Markdown:
import { marked } from 'marked'; import { XLangApp } from '@x-langjs/core'; const app = new XLangApp({ createMarkdownRenderer() { return { render(content, container) { container.innerHTML = marked.parse(content); return { dispose: () => { container.innerHTML = ''; } }; } }; } });计算逻辑:DSL 能算,真正的计算在 setup计算逻辑:DSL 能算,真正的计算在 setup
x-langjs 的 DSL 语法目前支持函数调用和字面量(字符串、数字、布尔值),同时也支持一些简单的表达式运算,比如数学计算、字符串拼接等。这些运算会在解释器里直接求值,然后把结果作为参数传给组件的 setup。x-langjs 的 DSL 语法目前支持函数调用和字面量(字符串、数字、布尔值),同时也支持一些简单的表达式运算,比如数学计算、字符串拼接等。这些运算会在解释器里直接求值,然后把结果作为参数传给组件的 setup。
例如,DSL 里可以写:例如,DSL 里可以写:
解释器会先算出 19.99 * 2 得 39.98,再和字符串拼接成 "总价: 39.98",最后作为第一个参数传给 button 的 setup。解释器会先算出 19.99 * 2 得 39.98,再和字符串拼接成 "总价: 39.98",最后作为第一个参数传给 button 的 setup。
但真正灵活的是 setup 本身——它就是一个普通的 JavaScript 函数,你可以在里面做任何计算。比如根据用户权限决定按钮是否禁用:但真正灵活的是 setup 本身——它就是一个普通的 JavaScript 函数,你可以在里面做任何计算。比如根据用户权限决定按钮是否禁用:
defineComponent('actionButton', { setup: (args) => { const action = String(args[0]); const hasPermission = checkPermission(action); // 调用外部函数 return { action, disabled: !hasPermission }; }, render(data, container) { const btn = document.createElement('button'); btn.textContent = data.action; btn.disabled = data.disabled; container.appendChild(btn); } });再比如,你可以在 setup 里根据参数组合计算出新的 props,甚至调用工具函数格式化数据:再比如,你可以在 setup 里根据参数组合计算出新的 props,甚至调用工具函数格式化数据:
defineComponent('price', { setup: (args) => { const value = Number(args[0]); const currency = String(args[1] ?? 'CNY'); const symbol = currency === 'USD' ? '$' : '¥'; return { formatted: `${symbol}${value.toFixed(2)}`, raw: value, currency }; }, render(data, container) { const span = document.createElement('span'); span.className = 'price'; span.textContent = data.formatted; container.appendChild(span); } });DSL 调用:DSL 调用:
```x-langjs price(19.99, "USD") ```渲染结果:$19.99渲染结果:$19.99
这样设计的好处是:DSL 保持简洁,只负责声明要调用什么组件、传什么参数;而真正的业务逻辑、数据转换都放在开发者可控的 JavaScript 代码里,安全又灵活。这样设计的好处是:DSL 保持简洁,只负责声明要调用什么组件、传什么参数;而真正的业务逻辑、数据转换都放在开发者可控的 JavaScript 代码里,安全又灵活。
项目设计:拆成多个小包,各干各的项目设计:拆成多个小包,各干各的
x-langjs 用了 monorepo 结构,分成好几个包,每个包有独立的职责:x-langjs 用了 monorepo 结构,分成好几个包,每个包有独立的职责:
每个包都可以独立替换。比如你觉得默认的 Markdown 渲染太简单,想换成 marked,那就自己实现个渲染器传进去;甚至你可以重写整个解释器,只要接口对得上。每个包都可以独立替换。比如你觉得默认的 Markdown 渲染太简单,想换成 marked,那就自己实现个渲染器传进去;甚至你可以重写整个解释器,只要接口对得上。
这种分层设计也让代码更容易测试和维护。每个包只关注一件事,比如 parser 只负责解析语法,interpreter 只负责执行,render 只负责渲染。这种分层设计也让代码更容易测试和维护。每个包只关注一件事,比如 parser 只负责解析语法,interpreter 只负责执行,render 只负责渲染。
总结总结
x-langjs 就是想让 AI 的输出从“只能看”变成“可以点”。它通过一个极简的 DSL 把组件的“声明”和“实现”分开,AI 只负责声明,开发者负责实现。DSL 本身支持简单的表达式,但真正的计算能力都留给 setup 函数,这样既灵活又安全。x-langjs 就是想让 AI 的输出从“只能看”变成“可以点”。它通过一个极简的 DSL 把组件的“声明”和“实现”分开,AI 只负责声明,开发者负责实现。DSL 本身支持简单的表达式,但真正的计算能力都留给 setup 函数,这样既灵活又安全。
如果你也在做 AI 对话类的应用,想让 AI 能动态生成可交互的 UI,不妨试试 x-langjs。项目完全开源,仓库里有详细的文档和可运行的 playground 示例,上手很容易。如果你也在做 AI 对话类的应用,想让 AI 能动态生成可交互的 UI,不妨试试 x-langjs。项目完全开源,仓库里有详细的文档和可运行的 playground 示例,上手很容易。
github.comhttps://github.com/chesongsong/x-langjshttps://github.com/chesongsong/x-langjsGitHub 地址GitHub 地址评论
评论数量暂时没有读取成功,你仍然可以先留下想法。
关于整篇文章
0 条