普通视图

发现新文章,点击刷新页面。
今天 — 2025年10月23日掘金 前端

深度定制:在 Vue 3.5 应用中集成流式 AI 写作助手的实践

2025年10月22日 22:29

在这里插入图片描述

最近在开发一个多模态AI项目,里面有一个AI写作功能,就是将AI写作辅助功能集成到富文本编辑器中,该功能的交互方式方式灵活多变,需要思考清楚不同的使用场景和提升用户体验,这是实现该功能的难点。本文将深入探讨如何基于 Vue 3.5 和 wangEditor 富文本编辑器,实现一套高度定制化的流式 AI 写作助手。不仅会展示如何定义和注册一个专用的 AI 工具栏菜单,更会详细解析如何实现全局快捷键唤醒选中文本高亮标记流式请求处理以及打字机效果等核心功能,最终打造出无缝、高效的 AI 辅助写作体验。

1. AI 写作助手的核心需求与技术选型

我们的目标是提供一个非侵入式、随用随取的 AI 助手,它能够根据用户的输入或选中的文本,执行续写、总结、润色和翻译等操作,并将结果以实时流式的方式展示给用户。

  • 富文本编辑器: 选择了 wangEditor,它提供灵活的模块注册机制和丰富的 API 接口,便于我们深度定制工具栏和内容操作。
  • 前端框架: 采用 Vue 3 (Composition API),配合 <script setup> 简化组件逻辑和状态管理。
  • AI 交互模式: 采用**弹出式(Popup)**设计,在光标位置或编辑器中心出现,提供极佳的上下文感知体验。

2. 定制 AI 工具栏菜单 (definedMenu.js)

首先,我们需要在 wangEditor 的工具栏中添加一个“AI 工具”下拉菜单。这通过实现一个自定义的菜单类来完成。

核心代码解析

// definedMenu.js

import { Boot } from '@wangeditor/editor'

class MyselectAiBar {
    constructor() {
        this.title = 'AI 工具'
        this.tag = 'button'
        this.showDropPanel = true
    }
    // ... 其他方法(getValue, isActive, isDisabled, exec 保持默认)
    getPanelContentElem() {
        const ul = document.createElement('ul')
        // ... 省略 ul 和 li 的创建和样式设置

        const items = [
            { label: 'AI 续写', value: 'continue' },
            // ... 其他 AI 动作
        ]

        items.forEach((item) => {
            const li = document.createElement('li')
            li.textContent = item.label
            // 核心:点击并触发自定义全局事件
            li.addEventListener('click', (e) => {
                const event = new CustomEvent('askAiClick', {
                    detail: {
                        value: item.value, // continue, summary 等
                        type: 'toolbar',
                    },
                })
                document.dispatchEvent(event)
            })
            ul.appendChild(li)
        })
        return ul
    }
}

// 注册配置
const myselectAiConf = { key: 'myselectAiBar', factory() { return new MyselectAiBar() } }

function registerMenusOnce() {
    // 避免 Vite HMR 导致的重复注册
    if (globalThis.__aiMenusRegistered) return
    const module = { menus: [myselectAiConf] }
    Boot.registerModule(module)
    globalThis.__aiMenusRegistered = true
}

export class AIToolManager {
    init(editor) {
        registerMenusOnce()
        this.editor = editor
    }
    // ... destroy 方法
}

设计思路

  1. 菜单类型: this.showDropPanel = true 声明这是一个下拉面板菜单。
  2. 事件机制: 菜单项的点击事件不直接操作编辑器,而是通过 document.dispatchEvent(new CustomEvent('askAiClick', ...)) 触发一个自定义全局事件。这种解耦方式非常关键,它使得 AI 菜单的逻辑可以在主组件 (NoteEditor.vue) 中集中处理,避免了在编辑器模块内部处理复杂的 Vue 组件逻辑。
  3. 单次注册: 使用 globalThis.__aiMenusRegistered 标记确保在 Vue 的热重载(HMR)机制下,编辑器模块只被注册一次。

3. 主编辑器组件 (NoteEditor.vue):集成与控制

NoteEditor.vue 负责初始化编辑器、注册 AI 菜单、处理快捷键,以及管理 AI 弹窗的显示状态和位置。

3.1 菜单与事件集成

NoteEditor.vue 中,我们首先将自定义菜单键 myselectAiBar 插入到工具栏配置中,并监听全局自定义事件。

// NoteEditor.vue <script setup> 部分

// 工具栏配置
const toolbarConfig = {
    // ...
    insertKeys: {
        index: 0, // 插入到最前面
        keys: ['myselectAiBar']
    }
}

// 监听 AI 菜单点击事件
const handleAskAiClick = async (e) => {
    const detail = e?.detail || {}
    const action = detail.value || '' // continue/summary | polish | translate

    const editor = editorRef.value
    // ... 检查内容是否为空,弹出 ElMessage 提示

    // 1. 显示AI弹窗
    showAIPopup()

    // 2. 调用子组件处理(传入 AI 动作和全文内容)
    if (aiPopupRef.value) {
        // AI 菜单通常用于全文操作(如总结、续写),故传入全文
        const allContent = editor.getText() || ''
        aiPopupRef.value.handleSubmit(action, allContent)
    }
}

onMounted(() => {
    document.addEventListener('askAiClick', handleAskAiClick)
    // ... 监听其他事件
})

3.2 快捷键与光标定位

我们实现了按下 Ctrl + Alt 组合键来唤醒 AI 弹窗,并精确地将其定位到光标附近。

// NoteEditor.vue <script setup> 部分

// 处理全局快捷键
const handleGlobalKeydown = (event) => {
    // 检测Ctrl+Alt组合键
    if (event.ctrlKey && event.altKey) {
        event.preventDefault()
        const editor = editorRef.value
        if (editor && editor.isFocused()) {
            showAIPopup() // 编辑器内,显示弹窗
        } else {
            showAITip() // 编辑器外,显示提示
        }
    }
}

// 获取光标像素坐标(考虑页面滚动)
const getCaretPixelPosition = () => {
    const selection = window.getSelection()
    if (!selection || selection.rangeCount === 0) return null
    const rect = selection.getRangeAt(0).getBoundingClientRect()
    // 关键:基于视口坐标加上滚动量,得到文档坐标
    return {
        x: rect.left + window.scrollX,
        y: rect.bottom + window.scrollY + 10, // 略微向下偏移
    }
}

// 缓存光标位置:在编辑器聚焦且选择变化时记录像素坐标
const updateCaretPosition = () => {
    // ... 省略逻辑
    const position = getCaretPixelPosition()
    if (position) lastCaretPosition.value = position
}

3.3 选中文本高亮与恢复

为了给用户清晰的视觉反馈,当用户选中一段文本唤出 AI 弹窗时,需要对该文本进行临时的高亮标记。

// NoteEditor.vue <script setup> 部分

// 标记选中文本
const markSelectedText = () => {
    const selection = window.getSelection()
    if (!selection?.rangeCount || selection.getRangeAt(0).collapsed) return

    const range = selection.getRangeAt(0)
    selectedRange.value = range.cloneRange() // 缓存原始范围

    // 创建高亮包装元素 (span)
    const highlight = document.createElement('span')
    // ... 样式设置(背景色、边框等)
    
    try {
        // 提取选中内容并包装
        const contents = range.extractContents() // 移除选中内容
        highlight.appendChild(contents)
        range.insertNode(highlight) // 插入包装后的内容

        boldWrapper.value = highlight
        selection.removeAllRanges() // 清除选择,避免干扰
    } catch (error) {
        // ... 警告处理
    }
}

// 移除标记并恢复选中
const unmarkSelectedText = () => {
    if (!boldWrapper.value) return

    try {
        const strong = boldWrapper.value
        const parent = strong.parentNode

        // 将高亮元素的内容移回父节点
        while (strong.firstChild) {
            parent.insertBefore(strong.firstChild, strong)
        }

        // 移除高亮元素
        parent.removeChild(strong)

        // 恢复选中状态 (如果需要)
        // ...
        
        // 清理引用
        boldWrapper.value = null
        selectedRange.value = null
    } catch (error) {
        // ... 警告处理
    }
}

4. AI 弹窗组件 (AIWritingPopup.vue):流式交互实现

AIWritingPopup.vue 是 AI 助手的核心交互界面,负责用户输入、动作选择、调用 AI API 以及展示流式结果。

4.1 动作选择与输入逻辑

弹窗在初始状态会显示快捷动作菜单(续写、总结等)。用户可以选择一个动作,或直接输入指令。

<div v-if="dropdownVisible" class="dropdown-menu">
    <div v-for="action in quickActions" :key="action.value" class="dropdown-item"
        @click="handleActionSelect(action.value)">
        {{ action.label }}
    </div>
</div>

4.2 流式请求与打字机效果

为了提供更好的实时体验,AI 结果采用了流式传输,并配合打字机(Typewriter)效果模拟人打字的过程。

// AIWritingPopup.vue <script setup> 部分

// 状态
const aiResult = ref('')       // 实时累积的 AI 完整结果
const displayText = ref('')    // 用于打字机效果显示的文本
const typewriterInterval = ref(null) // 定时器
const streamController = ref(null) // 流式请求的控制器

// 打字机效果
const startTypewriter = () => {
    // ... 确保 displayText 为空,重置 index
    typewriterInterval.value = setInterval(() => {
        const currentTargetText = aiResult.value

        if (index < currentTargetText.length) {
            // 实时追加字符
            displayText.value += currentTargetText[index]
            index++
        } else {
            // 检查是否有新流式内容到达,若没有则停止
            if (aiResult.value.length > index) {
                // 有新内容,继续
            } else {
                clearInterval(typewriterInterval.value)
                typewriterInterval.value = null
            }
        }
    }, 30) // 每 30ms 追加一个字符
}


// 核心:调用流式 API
const handleSubmit = async (externalAction = null, externalContent = null) => {
    // ... 参数检查和状态重置 (isLoading = true)

    try {
        streamController.value = aiAssistStream(
            requestParams,
            {
                onMessage: (content) => {
                    // 1. 实时更新完整结果
                    aiResult.value += content
                    // 2. 启动打字机
                    if (!typewriterInterval.value) {
                        startTypewriter()
                    }
                    isLoading.value = false // 收到内容后隐藏加载动画
                },
                onError: (error) => {
                    // ... 错误处理
                    isLoading.value = false
                    stopTypewriter()
                },
                onComplete: () => {
                    isLoading.value = false
                    displayText.value = aiResult.value // 确保完整显示
                    stopTypewriter()
                }
            }
        )

    } catch (error) {
        // ... 异常处理
    }
}

// 中断 AI 处理
const handleInterrupt = () => {
    if (streamController.value) {
        streamController.value.close() // 调用控制器中断流
        streamController.value = null
    }
    // ... 清理状态
}

4.3 结果插入与清理

AI 结果生成后,用户可以选择“插入到编辑器”或“复制结果”。插入时,需要将纯文本内容通过 NoteEditor.vue 暴露的事件接口 (insert-text) 传回主组件。

// AIWritingPopup.vue <script setup> 部分

const handleInsertResult = () => {
    const content = aiResult.value || displayText.value
    if (content) {
        // 移除HTML标签,只保留纯文本
        const textContent = content.replace(/<[^>]*>/g, '')

        // 触发父组件的插入事件
        emit('insert-text', textContent)
        // ... 成功提示
    }
    handleClose() // 关闭弹窗
}

5. 总结

通过上述定制化方案,我们成功地将 AI 写作能力深度集成到了富文本编辑器中。关键的设计点在于:

  1. 解耦: 通过自定义全局事件 (askAiClick) 解耦了 wangEditor 菜单和 Vue 组件的业务逻辑。
  2. 用户体验: 实现了 Ctrl + Alt 快捷键唤醒光标定位,以及选中文本高亮,提供了强大的上下文感知能力。
  3. 实时性: 结合流式 API 请求打字机效果,极大地优化了 AI 结果的等待体验。

这一整套实现方案不仅提升了写作效率,也为后续更复杂的 AI 功能集成奠定了坚实的基础。

Lua 模块的完整入门指南

作者 烛阴
2025年10月22日 22:23

一、什么是模块?

从本质上讲,Lua 模块就是存储在自己文件中的一段可复用代码。

二、创建你的第一个模块

黄金法则:模块就是一个返回(return)表的 Lua 文件。

让我们创建一个简单的数学工具模块。

  1. 创建一个名为 mymath.lua 的新文件。
  2. 在其中放入以下代码:
-- 文件:mymath.lua
-- 1. 创建一个局部表来保存我们模块的函数和数据。
--    使用 'M' 代表 'module' 是一个常见的约定。
local M = {}

-- 2. 定义函数并将它们添加到我们的表中。
--    这些函数现在是我们模块表的"方法"。
function M.add(a, b)
    return a + b
end

function M.subtract(a, b)
    return a - b
end

function M.multiply(a, b)
    return a * b
end

function M.divide(a, b)
    if b == 0 then
        return nil, "除数不能为零"
    end
    return a / b
end

-- 模块中可用的常量
M.PI = 3.14159
M.VERSION = "1.0.0"

-- 3. 最重要的步骤:在文件末尾返回这个表。
--    这使得其中的所有函数和数据对其他脚本可用。
return M

就是这样!你已经创建了一个模块。注意所有东西都整洁地保存在 M 表内。

三、使用 require 来使用你的模块

现在,我们使用内置的 require 函数在另一个文件中使用我们这个 mymath

  1. 同一目录中创建另一个名为 main.lua 的文件。
  2. 在其中放入以下代码:
-- 文件:main.lua

-- 使用 'require' 来加载我们的模块。
-- 注意:你不需要包含 '.lua' 扩展名。
-- 'require' 返回 mymath.lua 返回的表。
local mymath = require("mymath")

-- 现在我们可以使用模块中的函数了!
local sum = mymath.add(10, 5)
print("和:", sum) -- 输出: 和: 15

local difference = mymath.subtract(10, 5)
print("差:", difference) -- 输出: 差: 5

local product = mymath.multiply(10, 5)
print("积:", product) -- 输出: 积: 50

local quotient, err = mymath.divide(10, 0)
if quotient then
    print("商:", quotient)
else
    print("错误:", err) -- 输出: 错误: 除数不能为零
end

-- 我们也可以访问模块中的数据。
print("圆周率约等于:", mymath.PI) -- 输出: 圆周率约等于: 3.14159
print("模块版本:", mymath.VERSION) -- 输出: 模块版本: 1.0.0

require 的工作原理:

  • 它搜索指定的模块文件(例如,mymath.lua)。
  • 它运行该文件内的代码只执行一次
  • 它存储(缓存)该模块文件 return 的值。
  • 如果你在其他地方再次 require 同一个模块,Lua 不会重新运行文件;它会立即给你缓存的返回值。这既智能又高效!

结语

点个赞,关注我获取更多实用 Lua 技术干货!如果觉得有用,记得收藏本文!

性能狂飙!Next.js 16 重磅发布:Turbopack 稳定、编译提速 10 倍!🚀🚀🚀

作者 Moment
2025年10月22日 20:13

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

Next.js 团队于 2025 年 10 月 21 日星期二,在即将举行的 Next.js Conf 2025 之前,正式发布了 Next.js 16。

此版本提供了对 Turbopack、缓存和 Next.js 架构的最新改进。自上一个 Beta 版本以来,Next.js 16 新增了以下功能和改进:

  • 缓存组件(Cache Components): 使用局部预渲染(Partial Pre-Rendering, PPR)的新模型和 use cache 实现即时导航。
  • Next.js Devtools MCP: 集成模型上下文协议(Model Context Protocol)以改进调试和工作流程。
  • 代理(Proxy): middlewareproxy.ts 替代,以明确网络边界。
  • 开发体验(DX): 改进了构建和开发请求的日志记录。

此外,以下功能已在之前的 Beta 版本中提供:

  • Turbopack (稳定版): 所有应用程序的默认打包工具,Fast Refresh 速度提升 5-10 倍,构建速度提升 2-5 倍。
  • Turbopack 文件系统缓存 (Beta): 针对大型应用程序,启动和编译时间更快。
  • React Compiler 支持 (稳定版): 内置集成,用于自动记忆化(memoization)。
  • Build Adapters API (Alpha): 创建自定义适配器以修改构建过程。
  • 增强路由: 通过布局去重(layout deduplication)和增量预取(incremental prefetching)优化导航和预取。
  • 改进的缓存 API: 新增 updateTag() 和改进的 revalidateTag()
  • React 19.2: 视图转换(View Transitions)、useEffectEvent()<Activity/>
  • 破坏性变更: 异步参数、next/image 默认设置等。

升级到 Next.js 16:

用户可以通过以下方式升级到 Next.js 16:

# 使用自动升级 CLI
npx @next/codemod@canary upgrade latest
# ...或者手动升级
npm install next@latest react@latest react-dom@latest
# ...或者启动一个新项目
npx create-next-app@latest

对于 codemod 无法完全迁移代码的情况,请阅读升级指南


新功能和改进

缓存组件(Cache Components)

缓存组件是一组新功能,旨在使 Next.js 中的缓存更加明确和灵活。它们围绕新的 "use cache" 指令展开,该指令可用于缓存页面、组件和函数,并利用编译器在使用时自动生成缓存键。

与以前版本的 App Router 中隐式缓存不同,缓存组件的缓存是完全可选加入的。任何页面、布局或 API 路由中的所有动态代码默认都在请求时执行,这使得 Next.js 的开箱即用体验更好地与开发人员对全栈应用框架的期望保持一致。

缓存组件也完善了局部预渲染(Partial Prerendering, PPR)的故事,PPR 最早于 2023 年引入。在 PPR 之前,Next.js 必须选择将每个 URL 静态渲染还是动态渲染,没有中间地带。PPR 消除了这种二分法,允许开发人员通过 Suspense 将静态页面的部分内容选择性地进行动态渲染,同时又不牺牲完全静态页面的快速初始加载。

用户可以在 next.config.ts 文件中启用缓存组件:

next.config.ts

const nextConfig = {
  cacheComponents: true,
};
export default nextConfig;

Next.js 团队将在 2025 年 10 月 22 日的 Next.js Conf 2025 上分享更多关于缓存组件及其使用方法,并在未来几周内通过博客和文档分享更多内容。

注意: 如 Beta 版本中先前宣布的那样,以前的实验性 experimental.ppr 标志和配置选项已被删除,转而使用缓存组件配置。

在此文档中了解更多信息。

Next.js Devtools MCP

Next.js 16 引入了 Next.js DevTools MCP,它是一个模型上下文协议(Model Context Protocol)集成,用于 AI 辅助调试,可提供对应用程序的上下文洞察。

Next.js DevTools MCP 为 AI 代理提供:

  • Next.js 知识: 路由、缓存和渲染行为。
  • 统一日志: 无需切换上下文即可查看浏览器和服务器日志。
  • 自动错误访问: 无需手动复制即可获取详细堆栈跟踪。
  • 页面感知: 对活动路由的上下文理解。

这使得 AI 代理能够在开发工作流程中直接诊断问题、解释行为并建议修复方案。

在此文档中了解更多信息。

proxy.ts (原 middleware.ts)

proxy.ts 替换了 middleware.ts,并明确了应用程序的网络边界。proxy.ts 在 Node.js 运行时上运行。

迁移步骤: 将 middleware.ts 重命名为 proxy.ts,并将导出的函数重命名为 proxy。逻辑保持不变。

原因: 更清晰的命名和用于请求拦截的单一、可预测的运行时。

proxy.ts

export default function proxy(request: NextRequest) {
  return NextResponse.redirect(new URL("/home", request.url));
}

注意: middleware.ts 文件仍然可用于 Edge 运行时的用例,但它已被弃用,并将在未来版本中移除。

在此文档中了解更多信息。

日志改进

在 Next.js 16 中,开发请求日志得到了扩展,显示了时间花费在哪里:

  • Compile(编译): 路由和编译。
  • Render(渲染): 运行您的代码和 React 渲染。

构建日志也得到了扩展,显示了时间花费在哪个步骤。构建过程中的每一步现在都显示了完成所需的时间。

▲ Next.js 16 (Turbopack)
✓ Compiled successfully in 615ms
✓ Finished TypeScript in 1114ms
✓ Collecting page data in 208ms
✓ Generating static pages in 239ms
✓ Finalizing page optimization in 5ms

之前在 Beta 版本中宣布的功能:

开发者体验

Turbopack (稳定版)

Turbopack 的开发和生产构建均已达到稳定,现在是所有新 Next.js 项目的默认打包工具。自今年夏天早些时候发布 Beta 版以来,采用率迅速扩大:Next.js 15.3+ 上超过 50% 的开发会话和 20% 的生产构建已在使用 Turbopack。

使用 Turbopack,用户可以期待:

  • 生产构建速度提升 2–5 倍
  • Fast Refresh 速度提升高达 10 倍

Next.js 团队将 Turbopack 设为默认配置,以便让每个 Next.js 开发人员都能获得这些性能提升,无需任何配置。对于使用自定义 webpack 设置的应用程序,用户仍然可以通过运行以下命令来继续使用 webpack:

next dev --webpack
next build --webpack

Turbopack 文件系统缓存 (Beta)

Turbopack 现在支持开发环境中的文件系统缓存,在两次运行之间将编译器工件存储在磁盘上,从而显著加快重启时的编译时间,尤其是在大型项目中。

在配置中启用文件系统缓存:

next.config.ts

const nextConfig = {
  experimental: {
    turbopackFileSystemCacheForDev: true,
  },
};
export default nextConfig;

所有 Vercel 内部应用都已在使用此功能,团队观察到大型仓库的开发人员生产力有了显著提升。

Next.js 团队欢迎用户尝试并分享关于文件系统缓存的反馈。

简化的 create-next-app

create-next-app 经过重新设计,具有简化的设置流程、更新的项目结构和改进的默认设置。新模板默认包含 App Router、TypeScript-优先配置、Tailwind CSS 和 ESLint。

Build Adapters API (Alpha)

Build Adapters RFC 之后,Next.js 团队与社区和部署平台合作,发布了 Build Adapters API 的第一个 Alpha 版本。

Build Adapters 允许用户创建自定义适配器,以挂钩到构建过程,使部署平台和自定义构建集成能够修改 Next.js 配置或处理构建输出。

next.config.js

const nextConfig = {
  experimental: {
    adapterPath: require.resolve("./my-adapter.js"),
  },
};
module.exports = nextConfig;

用户可以在 RFC 讨论中分享您的反馈。

React Compiler 支持 (稳定版)

在 React Compiler 1.0 版本发布之后,Next.js 16 中对 React Compiler 的内置支持现已稳定。React Compiler 自动记忆化组件,减少不必要的重新渲染,无需手动更改代码。

reactCompiler 配置选项已从实验性升级为稳定版。它不是默认启用的,因为团队仍在收集不同应用类型的构建性能数据。请注意,启用此选项后,由于 React Compiler 依赖于 Babel,开发和构建期间的编译时间可能会更高。

next.config.ts

const nextConfig = {
  reactCompiler: true,
};
export default nextConfig;

用户需要安装最新版本的 React Compiler 插件:

npm install babel-plugin-react-compiler@latest

核心功能与架构

增强路由和导航

Next.js 16 彻底改进了路由和导航系统,使页面过渡更精简、更快。

  • 布局去重(Layout deduplication): 当预取多个具有共享布局的 URL 时,布局只下载一次,而不是为每个 Link 单独下载。例如,一个包含 50 个产品链接的页面现在只需下载一次共享布局,而不是 50 次,从而大大减少了网络传输大小。
  • 增量预取(Incremental prefetching): Next.js 只预取缓存中尚不存在的部分,而不是整个页面。预取缓存现在:
    • 当链接离开视口时取消请求。
    • 在悬停或重新进入视口时优先预取链接。
    • 当其数据失效时重新预取链接。
    • 与即将推出的功能(如缓存组件)无缝协作。

权衡: 用户可能会看到更多的单个预取请求,但总传输大小会大大降低。Next.js 团队认为这对于几乎所有应用程序来说都是正确的权衡。如果请求数量增加导致问题,团队正在努力进行额外的优化,以更有效地内联数据块。

这些更改无需修改代码,旨在提高所有应用程序的性能。

改进的缓存 API

Next.js 16 引入了改进的缓存 API,以便更明确地控制缓存行为。

revalidateTag() (已更新)

revalidateTag() 现在需要一个 cacheLife 配置文件作为第二个参数,以启用陈旧时重新验证(stale-while-revalidate, SWR)行为:

import { revalidateTag } from "next/cache";
// ✅ 使用内置的 cacheLife 配置文件(对于大多数情况,团队推荐 'max')
revalidateTag("blog-posts", "max");
// 或者使用其他内置配置文件
revalidateTag("news-feed", "hours");
revalidateTag("analytics", "days");
// 或者使用带有自定义重新验证时间的内联对象
revalidateTag("products", { revalidate: 3600 });
// ⚠️ 已弃用 - 单参数形式
revalidateTag("blog-posts");

profile 参数接受内置的 cacheLife 配置文件名称(例如 'max''hours''days')或在 next.config 中定义的自定义配置文件。用户也可以传递一个内联的 { expire: number } 对象。团队建议在大多数情况下使用 'max',因为它为长寿命内容启用后台重新验证。当用户请求带标签的内容时,他们会立即收到缓存的数据,同时 Next.js 在后台进行重新验证。

当用户只想使带有 SWR 行为的正确标记的缓存条目失效时,请使用 revalidateTag()。这对于可以容忍最终一致性的静态内容来说是理想的选择。

迁移指南: 添加带有 cacheLife 配置文件(推荐 'max')的第二个参数以实现 SWR 行为,或者如果需要读后写语义,请在 Server Actions 中使用 updateTag()

updateTag() (新增)

updateTag() 是一个新的仅限 Server Actions 的 API,它提供读后写语义,在同一请求中使缓存失效并立即读取新鲜数据:

"use server";
import { updateTag } from "next/cache";
export async function updateUserProfile(userId: string, profile: Profile) {
  await db.users.update(userId, profile);
  // 使缓存失效并立即刷新 - 用户会立即看到他们的更改
  updateTag(`user-${userId}`);
}

这确保了交互式功能立即反映更改。非常适合表单、用户设置以及用户期望立即看到其更新的任何工作流程。

refresh() (新增)

refresh() 是一个新的仅限 Server Actions 的 API,用于仅刷新未缓存的数据。它根本不触及缓存:

"use server";
import { refresh } from "next/cache";
export async function markNotificationAsRead(notificationId: string) {
  // 更新数据库中的通知
  await db.notifications.markAsRead(notificationId);
  // 刷新标题中显示的通知计数
  // (这是单独获取且未缓存的)
  refresh();
}

此 API 是客户端 router.refresh() 的补充。当用户在执行操作后需要刷新页面其他位置显示的未缓存数据时,请使用它。您的缓存页面外壳和静态内容保持快速,而动态数据(如通知计数、实时指标或状态指示器)则会刷新。

React 19.2 和 Canary 功能

Next.js 16 中的 App Router 使用最新的 React Canary 版本,其中包括新发布的 React 19.2 功能和正在逐步稳定的其他功能。亮点包括:

  • View Transitions(视图转换): 对在 Transition 或导航内部更新的元素进行动画处理。
  • useEffectEvent: 将非反应式逻辑从 Effects 中提取到可重用的 Effect Event 函数中。
  • Activity: 渲染“后台活动”,通过 display: none 隐藏 UI,同时保持状态并清理 Effects。

在 React 19.2 公告中了解更多信息。


破坏性变更和其他更新

版本要求

变更 详细信息
Node.js 20.9+ 最低版本现为 20.9.0 (LTS);不再支持 Node.js 18。
TypeScript 5+ 最低版本现为 5.1.0。
浏览器 Chrome 111+、Edge 111+、Firefox 111+、Safari 16.4+。

移除项

以下功能先前已被弃用,现已移除:

移除项 替换项
AMP 支持 所有 AMP API 和配置已移除 (useAmp, export const config = { amp: true })。
next lint 命令 直接使用 Biome 或 ESLint;next build 不再运行 linting。提供了 codemod:npx @next/codemod@canary next-lint-to-eslint-cli
devIndicators 选项 appIsrStatusbuildActivitybuildActivityPosition 已从配置中移除。指示器仍然存在。
serverRuntimeConfigpublicRuntimeConfig 使用环境变量 (.env 文件)。
experimental.turbopack 位置 配置已移至顶层 turbopack (不再位于 experimental 中)。
experimental.dynamicIO 标志 重命名为 cacheComponents
experimental.ppr 标志 PPR 标志已移除;演变为缓存组件编程模型。
export const experimental_ppr 路由级别 PPR 导出已移除;演变为缓存组件编程模型。
自动 scroll-behavior: smooth data-scroll-behavior="smooth" 添加到 HTML 文档以重新选择加入。
unstable_rootParams() 团队正在开发替代 API,将在即将发布的次要版本中推出。
同步 paramssearchParams props 访问 必须使用异步:await paramsawait searchParams
同步 cookies()headers()draftMode() 访问 必须使用异步:await cookies()await headers()await draftMode()
Metadata image 路由 params 参数 更改为异步 paramsgenerateImageMetadata 中的 id 现在是 Promise<string>
next/image 带有查询字符串的本地 src 现在需要 images.localPatterns 配置以防止枚举攻击。

行为变更

以下功能在 Next.js 16 中具有新的默认行为:

变更行为 详细信息
默认打包工具 Turbopack 现在是所有应用的默认打包工具;使用 next build --webpack 退出。
images.minimumCacheTTL 默认值 从 60 秒更改为 4 小时 (14400 秒);减少了没有 cache-control 头的图片的重新验证成本。
images.imageSizes 默认值 从默认尺寸中移除了 16 (仅被 4.2% 的项目使用);减少了 srcset 大小和 API 变化。
images.qualities 默认值 [1..100] 更改为 [75]quality 属性现在被强制转换为 images.qualities 中最接近的值。
images.dangerouslyAllowLocalIP 新的安全限制默认阻止本地 IP 优化;仅对私有网络设置为 true
images.maximumRedirects 默认值 从无限制更改为最多 3 个重定向;设置为 0 以禁用或增加以应对罕见边缘情况。
@next/eslint-plugin-next 默认值 现在默认使用 ESLint Flat Config 格式,与将放弃旧版配置支持的 ESLint v10 对齐。
预取缓存行为 完全重写,具有布局去重和增量预取。
revalidateTag() 签名 现在需要 cacheLife 配置文件作为第二个参数以实现陈旧时重新验证行为。
Turbopack 中的 Babel 配置 如果找到 babel 配置,则自动启用 Babel (以前会以硬错误退出)。
终端输出 重新设计,具有更清晰的格式、更好的错误消息和改进的性能指标。
开发和构建输出目录 next devnext build 现在使用单独的输出目录,从而可以并行执行。
锁文件行为 添加了锁文件机制,以防止同一项目上出现多个 next devnext build 实例。
并行路由 default.js 所有并行路由槽现在都需要明确的 default.js 文件;没有它们构建将失败。创建调用 notFound() 或返回 nulldefault.js 以获得以前的行为。
Modern Sass API sass-loader 提升到 v16,支持现代 Sass 语法和新功能。

弃用项

以下功能在 Next.js 16 中已弃用,并将在未来版本中移除:

弃用项 详细信息
middleware.ts 文件名 重命名为 proxy.ts 以阐明网络边界和路由焦点。
next/legacy/image 组件 改用 next/image 以获得改进的性能和功能。
images.domains 配置 改用 images.remotePatterns 配置以增强安全限制。
revalidateTag() 单参数 使用 revalidateTag(tag, profile) 进行 SWR,或在 Actions 中使用 updateTag(tag) 进行读后写。

其他改进

  • 性能改进: 对 next devnext start 命令进行了显著的性能优化。
  • next.config.ts 的 Node.js 原生 TypeScript: 运行带有 --experimental-next-config-strip-types 标志的 next devnext buildnext start 命令,以启用 next.config.ts 的原生 TypeScript。

Next.js 团队计划在稳定版本发布之前,在文档中分享更全面的迁移指南。

React Fiber:从“递归地狱”到“时间切片”的重生之路

作者 DoraBigHead
2025年10月22日 20:03

前情回顾:
在上一篇中,我们聊到 React 的理念——“快速响应”。
可 React15 的老架构在深层组件更新时,却经常卡到让人想拔网线。
原因就是它那套不能中断的递归调和机制
一旦开始递归,就像打开了 Photoshop 的无限滤镜叠加,停不下来。

于是,React 团队痛定思痛,决定造一套能“中断更新”的新架构。
这,就是 Fiber 登场的时刻。


🧠 一、Fiber 是什么?为什么它能解决卡顿?

Fiber 的中文叫 纤程(Fiber) ,听上去像健身食品,其实是计算机里的一种概念。
它和进程(Process)、线程(Thread)、协程(Coroutine)一样,都是程序执行的过程单位

在很多文章中,会把“纤程”看作“协程”的一种实现。
而在 JavaScript 世界里,协程的实现方式就是——Generator 函数

也就是说,Fiber = React 内部的协程机制。

它让 React 更新变得可以:

  • 暂停(yield)
  • 恢复(resume)
  • 并根据优先级切片执行(priority scheduling)

如果用一句话总结 Fiber 的思想:

Fiber = React 内部实现的一套状态更新机制,支持任务的中断、恢复和复用中间状态。


🏗️ 二、Fiber 的诞生动机:递归的噩梦

让我们先回忆一下 React15 是怎么更新视图的。
它的调和器是递归实现的:

function updateComponent(component) {
  component.render();
  component.children.forEach(updateComponent);
}

听起来没毛病,对吧?
但问题在于:递归是同步执行的
假如组件树很深,或者子组件太多,主线程就会被 React 独占。

于是出现了这种尴尬场景👇

我输入一个字母 → 页面两秒没反应 → 再输入的时候,浏览器直接假死。

这时,React 团队意识到:
“不能再让递归绑架线程了,我们得改造整套调和机制!”

于是他们重写了整个核心,用循环 + 可中断单元任务取代了递归。
那套循环任务系统,就是 Fiber 架构。


🧩 三、Fiber 架构三重含义

Fiber 既是架构、也是节点,更是一种“任务思维”的体现。
我们可以从三层理解它:

层级 含义 比喻
架构 React16 的调和机制,支持可中断更新 从“递归调用栈”转成“任务队列”
静态结构 描述组件类型、DOM 节点等信息 元数据
动态工作单元 存放本次更新要做的任务 工作线程

🧬 四、Fiber 节点结构源码揭秘

Fiber 的定义在源码里其实很清晰(节选自 ReactFiber.new.js):

function FiberNode(tag, pendingProps, key, mode) {
  // 静态结构
  this.tag = tag;            // 类型:FunctionComponent / HostComponent 等
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null;     // 对应的真实DOM节点

  // 树关系
  this.return = null;        // 父节点
  this.child = null;         // 第一个子节点
  this.sibling = null;       // 右边兄弟节点

  // 动态工作单元
  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;

  // 调度
  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  // 双缓冲机制
  this.alternate = null;
}

每个 Fiber 节点就是 React 更新的最小单元。
它知道:

  • 自己是谁(tag、type)
  • 自己的家人是谁(return、child、sibling)
  • 自己当前在做什么(pendingProps、lanes)
  • 自己上一次的状态在哪(alternate)

是不是像一个会“记仇”的任务机器人?
每次更新,它都会记下自己上次干了啥,下次再干能不能快点。


🌲 五、Fiber 树的长相

来看这段简单的组件代码:

function App() {
  return (
    <div>
      i am
      <span>KaSong</span>
    </div>
  );
}

编译后,React 会为每个节点生成一个对应的 Fiber 节点。
它们的关系如下:

App (FunctionComponent)
 └── div (HostComponent)
       ├── "i am" (HostText)
       └── span (HostComponent)

可以想象成下面这张“Fiber 家谱图”👇


🖼️ Fiber 树结构图

exported_image.png

每个 Fiber 节点的关系通过这三个指针维护:

属性 含义
child 第一个子节点
sibling 兄弟节点
return 父节点(执行完要返回)

🤔 为什么父指针叫 return

别急,这不是 React 开发者命名癖好。
而是因为 Fiber 是工作单元(Work Unit)

执行完当前节点后,程序会“return”回父节点继续处理。
这就像函数调用栈,只不过现在是我们手动维护的“链表版调用栈”。


⚙️ 六、Fiber 的双缓冲机制

每个 Fiber 节点都有个 alternate 指针,用于指向上一次的版本。
这样 React 就维护了两棵树:

  • 一棵是正在屏幕上展示的 current 树
  • 另一棵是正在计算中的 workInProgress 树

当更新完成后,React 只需轻轻一换指针:

current = workInProgress;

整个应用就完成了一次“无感更新”。
这种机制被称为 双缓冲(Double Buffering) ,类似显卡渲染帧切换,流畅无比。


🔧 七、手写一个迷你版 Fiber 执行器

我们用极简代码感受一下 Fiber 的执行逻辑:

class FiberNode {
  constructor(tag, parent = null) {
    this.tag = tag;
    this.return = parent;
    this.child = null;
    this.sibling = null;
  }
}

// 构建 Fiber 树
const App = new FiberNode("App");
const Div = new FiberNode("div", App);
const Span = new FiberNode("span", Div);
App.child = Div;
Div.child = Span;

// 模拟 Fiber 循环
function performUnitOfWork(fiber) {
  console.log("Work on:", fiber.tag);
  if (fiber.child) return fiber.child;
  let next = fiber;
  while (next) {
    if (next.sibling) return next.sibling;
    next = next.return;
  }
  return null;
}

let nextUnitOfWork = App;
while (nextUnitOfWork) {
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}

输出:

Work on: App
Work on: div
Work on: span

这就是 Fiber 的执行本质:

它把原本“整棵树的递归更新”,拆成了“一个节点一个节点的任务循环”,
每次可以中断,浏览器空闲时再继续。

这也正是 React 能实现 时间切片(Time Slicing) 的底层基础。


🧭 八、小结与展望

项目 React15 React16(Fiber)
调和方式 递归栈 链表循环
可中断性 ❌ 不可中断 ✅ 可中断恢复
更新粒度 整棵树 单个节点(Fiber)
状态保存 调用栈 Fiber 节点内存
渲染机制 一次性 分阶段(优先级调度)

Fiber 的出现,让 React 从“函数式渲染库”进化为“可调度的 UI 引擎”。

九、参考资料

  • 卡颂:《React 技术揭秘》
  • Acdlite - React Fiber Architecture (2016)
  • Lin Clark - A Cartoon Intro to Fiber (React Conf 2017)
  • React 源码(v18.2)
  • React RFC: Time Slicing and Scheduling

🪄 下一章预告:
我们将深入 Fiber 的执行流程,看看它是如何从 “beginWork → completeWork” 一步步构建出界面的。
就像流水线造车一样,每个 Fiber 都要经过一段工序,最终被打磨成真实 DOM。

ComposeView 的上下游继承关系及相关类/接口分析

作者 风冷
2025年10月22日 19:59

ComposeView 的上下游继承关系及相关类/接口分析

一、ComposeView 的完整继承层次结构

1. 上游继承链(从基类到ComposeView)

BaseObject
├── AbstractBaseView (实现 IViewPublicApi, IModuleAccessor, IPagerId 接口)
│   └── DeclarativeBaseView
│       └── ViewContainer<A : ContainerAttr, E : Event>
│           └── ComposeView<A : ComposeAttr, E : ComposeEvent>
├── BaseEvent (实现 IEvent, IPagerId 接口)
│   └── Event
│       └── ComposeEvent
└── Props
    └── Attr (实现 IStyleAttr, ILayoutAttr 接口)
        └── ContainerAttr (实现 IContainerLayoutAttr, IEventCaptureAttr 接口)
            └── ComposeAttr

2. 各层核心功能职责

BaseObject
  • 所有对象的最基础父类,提供属性扩展和响应式监听机制
  • 核心功能:
    • extProps:动态扩展字段存储
    • bindValueChange():绑定表达式变化监听
    • unbindAllValueChange():移除所有绑定监听
AbstractBaseView
  • 继承 BaseObject 并实现以下接口:
    • IViewPublicApi:视图公共API接口
    • IModuleAccessor:模块访问器接口
    • IPagerId:持有pagerId的接口(已被 PagerScope 替代)
  • 核心功能:
    • 视图引用管理
    • Flex节点管理
    • 属性和事件处理
    • 生命周期方法
DeclarativeBaseView
  • 继承 AbstractBaseView
  • 核心功能:
    • 父视图和DOM父视图管理
    • ReactiveObserver集成,响应属性变化
    • 生命周期方法
    • 渲染视图管理和坐标转换
ViewContainer
  • 继承 DeclarativeBaseView,泛型类 ViewContainer<A : ContainerAttr, E : Event>
  • 核心功能:
    • 子视图管理(addChild, removeChild
    • 模板子视图访问(templateChildren
    • DOM操作
    • Flex布局管理
ComposeView
  • 继承 ViewContainer,泛型类 ComposeView<A : ComposeAttr, E : ComposeEvent>
  • 核心功能:
    • 组合组件生命周期管理(created, viewWillLoad, viewDidLoad, viewDidLayout等)
    • body():抽象方法,定义视图内容构建器
    • pagerData:获取页面数据
    • emit():触发事件

二、属性体系继承链

Props
  • 属性基类,提供基本属性管理功能
Attr
  • 继承 Props 并实现以下接口:
    • IStyleAttr:样式属性接口
    • ILayoutAttr:布局属性接口
  • 核心功能:
    • Flex节点管理
    • 动画管理
    • 属性任务管理
ContainerAttr
  • 继承 Attr 并实现以下接口:
    • IContainerLayoutAttr:容器布局属性接口
    • IEventCaptureAttr:事件捕获属性接口
  • 核心功能:
    • Flex布局方向设置
    • 对齐方式设置
    • 玻璃效果设置(iOS)
ComposeAttr
  • 继承 ContainerAttr
  • 为组合组件提供基础属性支持

三、事件体系继承链

BaseEvent
  • 继承 BaseObject 并实现以下接口:
    • IEvent:事件接口
    • IPagerId:持有pagerId的接口
  • 核心功能:
    • 事件注册和取消注册
    • 事件触发
Event
  • 继承 BaseEvent
  • 核心功能:
    • 插件事件管理
    • 同步事件支持
    • 扩展事件中心
ComposeEvent
  • 继承 Event
  • 核心功能:
    • 组合事件映射管理
    • 事件注册和触发

四、ComposeView 的下游子类

1. Pager

  • 继承 ComposeView<ComposeAttr, ComposeEvent> 并实现 IPager 接口
  • 页面容器类,管理整个页面的生命周期和状态

2. SliderPageView

  • 继承 ComposeView<SliderPageAttr, SliderPageEvent>
  • 滑块页面视图组件

3. CheckBoxView

  • 继承 ComposeView<CheckBoxAttr, CheckBoxEvent>
  • 复选框组件

4. ButtonView

  • 继承 ComposeView<ButtonAttr, ButtonEvent>
  • 按钮组件

5. SwitchView

  • 继承 ComposeView<SwitchAttr, SwitchEvent>
  • 开关组件

6. SliderView

  • 继承 ComposeView<SliderAttr, SliderEvent>
  • 滑块组件

五、相关接口详解

1. IViewPublicApi

  • 视图公共API接口,定义视图引用、属性/事件设置、坐标转换、动画等方法

2. IModuleAccessor

  • 模块访问器接口
  • 提供方法:
    • getModule(name: String): T?:获取模块,不存在返回null
    • acquireModule(name: String): T:获取或创建模块

3. IPagerId / PagerScope

  • IPagerId:已被废弃,由 PagerScope 替代
  • PagerScope:持有pagerId的接口
    • 属性:pagerId: String
    • 方法:getPager(): IPager

4. ILayoutAttr

  • 布局属性接口,用于设置视图的布局属性
  • 提供方法:width(), height()

5. IContainerLayoutAttr

  • 容器布局属性接口,扩展自 ILayoutAttr
  • 提供方法:flexDirection(), justifyContent(), alignItems(), flexWrap(), padding()

6. IEventCaptureAttr

  • 事件捕获属性接口
  • 提供方法:capture(),支持设置点击、双击、长按、拖拽等捕获规则

7. IEvent

  • 事件接口,定义事件的基本操作

8. IPager

  • 页面接口,定义页面的生命周期和操作方法
  • 提供方法:onCreatePager(), onDestroyPager(), pageDidAppear(), pageDidDisappear()

六、ComposeView 的设计特点

  1. 组合式API设计:通过 body() 函数构建视图,支持声明式UI
  2. 完整生命周期:提供从创建到销毁的完整生命周期钩子
  3. 泛型支持:通过泛型参数支持不同类型的属性和事件
  4. 组件化架构:作为UI组件的基类,方便组件的组合和复用
  5. 响应式集成:继承自响应式系统,支持数据流驱动UI更新
  6. 事件系统:完善的事件注册、分发和处理机制
  7. 布局系统:基于Flexbox的灵活布局能力

七、总结

ComposeView 是 KuiklyUI 框架中组合式UI的核心基类,通过多层继承体系实现了从基础对象到复杂UI组件的功能扩展。它不仅继承了视图容器的全部能力,还提供了组合式API和完整的生命周期管理,为框架中的各种UI组件(如按钮、开关、滑块等)提供了统一的基础架构。

整个继承体系设计合理,职责分明,通过泛型、接口和抽象类的组合使用,实现了高内聚、低耦合的架构设计,充分体现了面向对象编程的封装、继承和多态特性。

KuiklyUI声明式组件体系的实现分析

作者 风冷
2025年10月22日 19:58

KuiklyUI声明式组件体系的实现分析

KuiklyUI的声明式组件体系是其跨平台UI框架的核心,通过优雅的设计实现了声明式UI的表达和高效渲染。下面深入分析其实现原理:

1. 组件继承体系

KuiklyUI的声明式组件体系建立在清晰的继承层次结构上:

// 核心继承关系
base class BaseObjectabstract class AbstractBaseView<A : Attr, E : Event> : BaseObject(), IViewPublicApi<A, E>, IModuleAccessor, IPagerId
    ↑
abstract class DeclarativeBaseView<A : Attr, E : Event> : AbstractBaseView<A, E>()
    ↑
abstract class ViewContainer<A : ContainerAttr, E : Event> : DeclarativeBaseView<A, E>()

这种层次设计实现了关注点分离,每个层级负责不同的核心功能:

  • BaseObject:提供基础对象生命周期管理
  • AbstractBaseView:定义视图的公共API和核心属性(Attr)、事件(Event)管理
  • DeclarativeBaseView:实现声明式特性,包括响应式数据绑定
  • ViewContainer:增加子组件管理能力,构建组件树

2. 属性系统设计

属性系统是声明式UI的核心,KuiklyUI通过Attr类实现了灵活而强大的属性管理:

open class Attr : Props(), IStyleAttr, ILayoutAttr {
    var flexNode: FlexNode? = null
    var keepAlive: Boolean = false
    internal var isStaticAttr = true
    private var animationMap: MutableMap<String, Animation>? = null
    internal var isBeginApplyAttrProperty = false
    internal var propSetByFrameTasks: MutableMap<String, FrameTask>? = null
    
    // 属性应用相关方法
    fun beginApplyAttrProperty() { /* 处理动画和属性更新 */ }
    fun endApplyAttrProperty() { /* 完成属性更新,触发布局 */ }
    
    // 样式属性设置方法
    fun size(width: Float, height: Float): Attr { /* 设置尺寸 */ }
    fun backgroundColor(hexColor: Long): Attr { /* 设置背景色 */ }
    // 更多样式和布局属性方法...
}

属性系统的特点:

  1. 链式调用:所有属性设置方法都返回this,支持流畅的链式调用
  2. 类型安全:通过泛型<A : Attr, E : Event>确保类型安全
  3. 统一管理:通过Props基类统一管理属性的存储和访问
  4. 布局集成:与Flexbox布局系统紧密集成

3. 声明式语法实现

KuiklyUI的声明式语法主要通过以下机制实现:

3.1 函数式属性设置

override fun attr(init: A.() -> Unit) {
    if (pagerId.isEmpty()) {
        return
    }
    val observable = ReactiveObserver.bindValueChange(this) { isFirst ->
        attr.apply {
            if (isFirst) {
                apply(init)
            } else {
                beginApplyAttrProperty()
                apply(init)
                endApplyAttrProperty()
            }
        }
    }
    attr.isStaticAttr = !observable
}

这个方法是声明式UI的核心,它:

  • 接收一个函数参数 init: A.() -> Unit,这是一个接收A类型接收者的lambda函数
  • 使用ReactiveObserver.bindValueChange实现响应式绑定
  • 根据是否首次调用采用不同的属性应用策略

3.2 组件树构建

open fun <T : DeclarativeBaseView<*, *>> addChild(child: T, init: T.() -> Unit, index: Int) {
    internalAddChild(child, index)
    child.willInit()
    child.init()
    child.didInit()
}

这个方法允许在父组件中以声明式方式添加和配置子组件,实现组件树的构建。

4. 响应式更新机制

KuiklyUI的响应式更新机制是其声明式组件体系的关键部分:

4.1 数据绑定

通过ReactiveObserver.bindValueChange方法,框架能够检测数据变化并自动更新UI:

val observable = ReactiveObserver.bindValueChange(this) { isFirst ->
    // 当数据变化时,重新应用属性
    attr.apply {
        if (isFirst) {
            apply(init)
        } else {
            beginApplyAttrProperty()
            apply(init)
            endApplyAttrProperty()
        }
    }
}

4.2 性能优化

框架实现了多种性能优化策略:

  1. 静态属性标记attr.isStaticAttr = !observable,避免不必要的更新
  2. 批量属性更新:通过beginApplyAttrProperty()endApplyAttrProperty()批量处理属性更新
  3. 布局优化getPager().onLayoutView()在属性更新后触发布局,避免频繁布局计算

5. 组件生命周期管理

KuiklyUI为声明式组件提供了完整的生命周期管理:

open fun willMoveToParentComponent() { /* 即将添加到父组件 */ }
open fun didMoveToParentView() { /* 已添加到父组件 */ }
open fun willRemoveFromParentView() { /* 即将从父组件移除 */ }
open fun didRemoveFromParentView() { /* 已从父组件移除 */ }

这些生命周期方法确保了组件在不同阶段能够执行必要的初始化、清理和资源管理操作。

6. 平台渲染桥接

声明式组件体系与平台渲染系统通过RenderView类进行桥接:

protected fun createComponentRenderViewIfNeed() {
    if (renderView !== null) {
        return;
    }
    renderView = RenderView(pagerId, nativeRef, viewName())
    attr.also {
        it.setPropsToRenderView()
    }
    event.onRenderViewDidCreated()
    // ...
}

这种设计实现了UI描述与平台渲染的解耦,使同一套声明式代码能够在不同平台上渲染为原生视图。

7. 组件扩展性设计

KuiklyUI的声明式组件体系具有极强的扩展性:

  1. 泛型支持:通过泛型<A : Attr, E : Event>支持不同类型的属性和事件
  2. 接口分离:使用IViewPublicApiIModuleAccessor等接口实现功能分离
  3. 继承扩展:允许通过继承现有组件创建自定义组件

总结

KuiklyUI的声明式组件体系通过以下核心机制实现:

  1. 优雅的继承层次:从BaseObject到ViewContainer的清晰层次结构
  2. 函数式属性设置:通过lambda函数实现声明式语法
  3. 响应式数据绑定:自动检测数据变化并更新UI
  4. 完整的生命周期:管理组件的创建、更新和销毁
  5. 平台渲染桥接:连接声明式描述和平台原生渲染
  6. 高性能优化:批量更新、静态标记等性能优化策略

这种设计使KuiklyUI能够提供既符合声明式编程范式,又保持原生性能的跨平台UI开发体验。

面试常见问题 TS 的 infer 你会用吗?对象如何转 snake_case

作者 Legend80s
2025年10月22日 19:37

the-nutcracker-ballet.jpeg

我们将实现一个 TS 类型转换:将对象的 key 转成 snake_case,期间会使用到 infer,通过实际操作来学概念才是最牢固的。

假设我们在某个系统中已经存在 camelCase 的 key,类型如下:

type IOpenSourceModel = {
  id: number;
  description: string;
  status: number;
  
  gmtModified: string;
  chatShell: 'foo' | 'bar';
  highPerformance: boolean;
  modelName: string;
}

但是另一个服务使用 python 写的,返回值改成了 snake_case,我们当然可以手动改:

type IOpenSourceModel = {
  id: number;
  description: string;
  status: number;
  
  gmt_modified: string;
  chat_shell: 'foo' | 'bar';
  high_performance: boolean;
  model_name: string;
}

题外话如果手动改也要充分利用已有类型,所以我们应该这样写:

type IOpenSourceModel = {
  id: number;
  description: string;
  status: number;
  
  gmt_modified: IOpenSourceModel['gmt_mogmtModifieddified'];
  chat_shell: IOpenSourceModel['chatShell'];
  high_performance: IOpenSourceModel['highPerformance'];
  model_name: IOpenSourceModel['modelName'];
}

但这仅仅是部分复用类型,如果想完全复用又该如何做呢?

如果有一个 TS 类型工具,自动将 key 转成 snake_case 就好了:

type ISnakeCasedModel = KeyToSnakeCase<IOpenSourceModel>

实现 KeyToSnakeCase

复杂问题简单化,我们可以拆分成更简单的两步:

  1. 第一步,将字符串类型转成 snake_case
  2. 第二步,递归遍历 key 做转换。

关键在第一步。

一、字符串转 snake_case

我们先将预期想要达到的结果写出来,即 TDD(Test Driven Development):

type ToSnakeCase<T extends string> = never // TODO

type t1 = ToSnakeCase<'name'>; // name -> name
type t2 = ToSnakeCase<'modelName'>; // modelName -> model_name
type t3 = ToSnakeCase<'model_name'>; // model_name -> model_name
type t4 = ToSnakeCase<'thisIsATest'>; // thisIsATest => this_is_a_test
type t5 = ToSnakeCase<'aaaa'>; // aaaa
type t6 = ToSnakeCase<'aAAA'>; // a_a_a_a

然后实现 ToSnakeCase,大家思考一下……







如果用 JS 的话,我们可能会使用正则匹配替换,或者干脆使用 lodash/snakeCase,但是 TS 类型就没法这么干。

不过 TS 类型系统有类似正则表达式的 Template Literal Type 和 infer,前者用来遍历或者说匹配,后者用来在条件语句中赋值变量以便下次操作。

TypeScript 中的 infer 关键字是一个强大的工具,用于在条件类型中声明一个可以动态捕获类型的类型变量。它允许开发者以更具表达力且类型安全的方式提取和操作类型。

思路大概是:逐个遍历字符,如果发现是大写,则在其前面增加 _ 并且将大写改成小写。目前我们不必考虑单词头部出现大写情况。

经过一番折腾和测试后代码写完了:

type ToSnakeCase<T extends string> = T extends `${infer F}${infer Rest}`
  ? IsUppercase<F> extends true
    ? `_${Lowercase<F>}${ToSnakeCase<Rest>}`
    : `${F}${ToSnakeCase<Rest>}`
  : T;
  • F 表示第一个字符,Rest 表示剩余字符(取名字很重要的,不要取 T、K、V、P 这种单字符变量,尤其是在复杂表达式中)
  • 判断 F 是否大写(IsUppercase 干的事情我们先不管,将其当做黑盒或函数理解,对了!类型工具其实就是输入类型 → 输出新类型的『函数』
  • 如果 F 是大写,前面加上 _ 且将自身小写(Lowercase 是 TS 内置类型工具)然后递归 Rest
  • 如果是小写,拼接到结果中继续递归 Rest
  • 当递归到尽头只有一个字符则直接返回 T(即第一个判断的 else 分支)
graph TD
    A[开始 ToSnakeCase T] --> B{T 是否匹配<br> infer F infer Rest?}
    
    B -->|否| C[返回 T 本身]
    B -->|是| D[调用 IsUppercase]
    
    D --> E{大写字母?}
    
    E -->|是| F[_ + Lowercase F: 重要处理步骤!]
    E -->|否| G[F 不变]
    
    F --> H[递归处理 ToSnakeCase Rest]
    G --> H
    
    H --> I[返回最终蛇形命名字符串]
    
    C --> J[结束]
    I --> J

可以看出 TS 类型工具限制我们只能使用有限的分支结构 extends ? : 和递归的遍历方式,有点『带着镣铐跳舞』的感觉。

接下来实现更容易的部分 IsUppercase

  • 先排除特殊字符 _,否则会出现 __ 的情况
  • 然后让自己和自己的大写比较,如果相等则认为是大写
  • 否则是小写
type IsUppercase<S extends string> = S extends '_'
  ? false
  : S extends Uppercase<S>
    ? true
    : false;
graph TD
    subgraph IsUppercase 子流程
        K[开始: IsUppercase S] --> L{S 是 '_' ?}
        L -->|是| M[返回 false]
        L -->|否| N{S == Uppercase S ?}
        N -->|是| O[返回 true]
        N -->|否| P[返回 false]
        M --> Q[结束]
        O --> Q
        P --> Q
    end

我们将上述类型转换用 JS 实现一遍方便大家理解:

function toSnakeCase(t: string | string[]): string {
  const [first, ...rest] = t

  return t.length !== 0 
    ? isUpperCase(first)
      ? `_${first.toLowerCase()}${toSnakeCase(rest)}`
      : `${first}${toSnakeCase(rest)}`
    : ''
}

function isUpperCase(t: string): boolean {
  return t !== '_' && t.toUpperCase() === t
}

测试下:

console.log('name ->', toSnakeCase('name')); // name -> name
console.log('modelName ->', toSnakeCase('modelName')); // modelName -> model_name
console.log('model_name ->', toSnakeCase('model_name')); // model_name -> model_name
console.log('thisIsATest ->', toSnakeCase('thisIsATest')); // thisIsATest => this_is_a_test
console.log('aaaa ->', toSnakeCase('aaaa')); // aaaa
console.log('aAAA ->', toSnakeCase('aAAA')); // a_a_a_a

最后我们考虑首字母是大写情况,我们之前的写法会导致出现前缀 _ 问题:

type t4 = ToSnakeCase<'AAAA'>; // _a_a_a_a

很简单还是通过 infer 匹配去除头部多余的 _,为了可读性,还是封装成『函数』把,函数式编程思维在哪都适用!

type TrimStartingUnderscores<T extends string> = 
  T extends `_${infer Rest}` ? TrimStartingUnderscores<Rest> : T;

这里我们同样用到了递归,因为可能头部存在多个下划线,当然在本文情况下不会出现,故只需剔除第一个 _ 即可:

type TrimStartingOneUnderscore<T extends string> = 
  T extends `_${infer Rest}` ? Rest : T;

这样我们最终版的 ToSnakeCase 就『出道』了!

type ToSnakeCaseCore<T extends string> = T extends `${infer F}${infer Rest}`
  ? IsUppercase<F> extends true
    ? `_${Lowercase<F>}${ToSnakeCaseCore<Rest>}`
    : `${F}${ToSnakeCaseCore<Rest>}`
  : T;

type ToSnakeCase<T extends string> = TrimStartingUnderscores<ToSnakeCaseCore<T>>;

有人会说如果结尾出现 _ 怎么办?很简单一样通过 infer 和递归去掉即可

type TrimEndingUnderscores<T extends string> = 
  T extends `${infer Rest}_` ? TrimEndingUnderscores<Rest> : T

测试:

// AAAA___
type t13 = TrimEndingUnderscores<'___AAAA___'>;

组合起来:

// AAAA
type t14 = TrimStartingUnderscores<TrimEndingUnderscores<'___AAAA___'>>;

二、遍历对象做转换

JS 中遍历对象有很多方法,但是对 TS 的 Record 类型遍历只有一种方法,倒也省事了。

很简单,用 K in keyof T 当做 key,T[K] 当做 value 即可遍历:

type Mapper<T> = {
  [K in keyof T]: T[K]
};

上述方法只是原封不动并未做任何转换:

// { a: string; b: number[] }
type r1 = Mapper<{ a: string; b: number[] }>;
// string[]
type r3 = Mapper<string[]>;

针对本文我们很容易写出下面将对象 key 转 snake_case 的写法:

type KeyToSnakeCase<T> = {
  [ToSnakeCase<K in keyof T>]: T[K]
};

但是 TS 语法过不去。我们得用 as 语法将捕获的类型 K 进一步重(chóng)处理。

type KeyToSnakeCase<T> = {
  [K in keyof T as ToSnakeCase<K>]: T[K]
};

距离最后一步就剩一个挑战了:

Type 'K' does not satisfy the constraint 'string'.  
  Type 'keyof T' is not assignable to type 'string'.  
    Type 'string | number | symbol' is not assignable to type 'string'. > 
      Type 'number' is not assignable to type 'string'.ts(2344)

因为 keyof Tstring | number | symbol,而 ToSnakeCase 仅接受字符串,即使你将 T 限制为 T extends Record<string, any>,其依然是三种类型的 union。

怎么办?K & string 交集大法,string | number | symbol & stringstring

故变成:

type KeyToSnakeCase<T extends Record<string, unknown>> = {
  [K in keyof T as ToSnakeCase<K & string>]: T[K]
};

我们试一试:

type IOpenSourceModel = {
  id: number;
  description: string;
  status: number;
  
  gmtModified: string;
  chatShell: 'foo' | 'bar';
  highPerformance: boolean;
  modelName: string;
}

type ISnakeCasedModel = KeyToSnakeCase<IOpenSourceModel>;

输出:

type ISnakeCasedModel = {  
    id: number;  
    description: string;  
    status: number;  
    gmt_modified: string;  
    chat_shell: "foo" | "bar";  
    high_performance: boolean;  
    model_name: string;  
}

完美 🎉!

如果要让嵌套对象内的 key 也变成 snake_case 呢?很简单针对嵌套对象继续递归:

type KeyToSnakeCase<T extends Record<string, unknown>> = {
  [K in keyof T as ToSnakeCase<K & string>]: T[K] extends Record<string, unknown>
    ? KeyToSnakeCase<T[K]>
    : T[K];
};

也就是加了一段 T[K] extends Record<string, unknown> ? KeyToSnakeCase<T[K]> : T[K]:如果是对象则继续 KeyToSnakeCase

测试下:

type INestedOpenSourceModel = {
  id: number;
  description: string;
  status: number;

  gmtModified: string;
  fooBar: {
    chatShell: 'foo' | 'bar';
    bar: {
      highPerformance: boolean;
      modelName: string;
    }
  };
};

type ISnakeCasedModel2 = KeyToSnakeCase<INestedOpenSourceModel>;

光标 hover ISnakeCasedModel2

type ISnakeCasedModel2 = {
    id: number;
    description: string;
    status: number;
    gmt_modified: string;
    foo_bar: KeyToSnakeCase<{
        chatShell: "foo" | "bar";
        bar: {
            highPerformance: boolean;
            modelName: string;
        };
    }>;
}

以及:

type ChatShell = ISnakeCasedModel2['foo_bar']['chat_shell']
type HighPerformance = ISnakeCasedModel2['foo_bar']['bar']['high_performance']
type ModelName = ISnakeCasedModel2['foo_bar']['bar']['model_name']

都说明我们成功将嵌套对象的 key 也给转成了 snake_case

但是还有一丢丢美观度上面的问题光标 hover ISnakeCasedModel2 并不能完整展现所有 snake_case key。会展示成:

foo_bar: KeyToSnakeCase<{
    chatShell: "foo" | "bar";
    bar: {
        highPerformance: boolean;
        modelName: string;
    };
}>;

还能优化吗?可以使用 Expand

// https://github.com/type-challenges/type-challenges/issues/37240
type Expand<T> = T extends infer O 
  ? { [K in keyof O]: Expand<O[K]> }
  : never

type E1 = Expand<ISnakeCasedModel3>

现在在用光标 hover 下,是否看到了展开后的所有 key,是否特别直观了?

type E1 = {
    id: number;
    description: string;
    status: number;
    gmt_modified: string;
    foo_bar: {
        chat_shell: "foo" | "bar";
        bar: {
            high_performance: boolean;
            model_name: string;
        };
    };
}

注意下面写法无效

type Expand<T> = { [K in keyof T]: Expand<T[K]> }

这是为什么?

第一种写法有效的原因是 条件类型触发了取值导致类型展开

type Expand<T> = T extends infer O  // 这里创建了新的类型实例
  ? { [K in keyof O]: Expand<O[K]> }
  : never

而第二种写法直接进行映射。

  1. 直接对 T 进行映射,TypeScript 会保持映射类型的"惰性求值"
  2. 递归调用时,T[K] 仍然是原始的类型引用
  3. 智能提示显示时,TypeScript 不会深度展开这种结构

还有一种写法也行:

type Expand<T> = T extends object
  ? { [K in keyof T]: Expand<T[K]> }
  : T;

两种可行写法的共同点都具备 extends 即『条件类型』

条件类型的求值机制

当 TypeScript 遇到条件类型 T extends object ? A : B 时:

  • 它必须先判断条件是否成立
  • 这个判断过程需要对 T 进行求值
  • 一旦进入某个分支,该分支内的类型表达式会被进一步求值
对比三种写法
// 写法A:直接映射(无效)
type ExpandA<T> = { [K in keyof T]: ExpandA<T[K]> }
// ❌ 没有触发点,保持惰性

// 写法B:条件类型 + infer(有效)
type ExpandB<T> = T extends infer O 
  ? { [K in keyof O]: ExpandB<O[K]> }
  : never
// ✅ infer 创建实例 + 条件类型触发求值

// 写法C:简单条件类型(有效)  
type ExpandC<T> = T extends object
  ? { [K in keyof T]: ExpandC<T[K]> }
  : T
// ✅ 条件类型本身触发求值
为什么条件类型能展开而直接映射不能?

根本原因:TypeScript 的类型系统有不同的求值策略

  1. 映射类型:设计为"模板",保持引用关系
  2. 条件类型:设计为"决策",需要实际求值来决定走哪个分支

这种差异是 TypeScript 类型系统有意为之的设计选择,目的是:

  • 性能优化:避免不必要的深度求值
  • 清晰度:在工具类型和展开显示之间取得平衡
  • 可控性:让开发者选择何时需要深度展开
Expand 使用场景与注意事项

最后再念叨一句,虽然 Expand 能让类型提示更清晰,但在使用时需要注意:

  • 性能:对于非常深或非常宽(属性极多)的对象类型,深度递归可能会增加 TypeScript 编译器的负担,影响类型检查速度。
  • 必要性:并非所有情况都需要完全展开。有时保留一层工具类型(如我们的 KeyToSnakeCase)反而能让焦点更突出。VSCode 等编辑器也支持点击展开嵌套的类型。

总结

本文我们不仅学会了 infer,更重要是学会了将复杂问题拆解成一个个简单任务,同时体会到了函数组合的魅力。

参考

Bun 1.2.23发布:119个问题修复,性能飙升!

作者 Legend80s
2025年10月22日 19:34

译自 bun.com/blog/bun-v1…

作者 Jarred Sumner · 2025年9月28日

升级 Bun

bun upgrade

bun install 中的 pnpm-lock.yaml 支持

bun install 现在会自动将 pnpm-lock.yamlpnpm-workspace.yaml 文件迁移为 bun.lock,并保留解析后的依赖版本。这包括对 pnpm 工作区和 catalog: 依赖的支持。

只需一条命令即可从 pnpm install 切换到 bun install

# 在 pnpm 项目中
bun install

bun install 旨在与现有的 Node.js 项目协同工作。这意味着您可以在继续使用 Node.js 作为运行时的同时,利用 bun install 惊人的性能。

如果您的项目使用 pnpm-workspace.yaml,您的 package.json 将被更新以包含 "workspaces": ["<每个包的名称>"]

使用 --cpu--os 标志过滤可选依赖

您现在可以使用 bun install 中新的 --cpu--os 标志来控制安装哪些特定于平台的可选依赖。当您为不同的目标环境(例如 Docker 容器或 CI/CD 流水线)安装依赖时,这非常有用。

您可以提供多个值来同时为多个目标安装依赖。

# 为 Linux ARM64 目标安装可选依赖
bun install --os linux --cpu arm64

# 为 x64 架构的 macOS 和 Linux 安装
bun install --os darwin --os linux --cpu x64

# 为所有支持的平台安装
bun install --os '*' --cpu '*'

Bun 的 Redis 客户端现在支持发布/订阅

Bun 内置的 RedisClient 现在支持发布/订阅消息模式。您可以使用新的 .subscribe() 方法来监听特定频道上的消息,并使用 .publish() 方法来发送消息。

这使您可以直接在 Bun 应用程序中实现实时、事件驱动的通信模式。

subscriber.ts

import { RedisClient } from "bun";

const subscriber = new RedisClient("redis://localhost:6379");
await subscriber.connect();

await subscriber.subscribe("my-channel", (message, channel) => {
  console.log(`收到消息: "${message}" 来自频道: "${channel}"`);
  // 收到消息: "Hello from Bun!" 来自频道: "my-channel"
});

publisher.ts

import { RedisClient } from "bun";

const publisher = new RedisClient("redis://localhost:6379");
await publisher.connect();

// 短暂延迟以确保订阅者已准备就绪
setTimeout(() => {
  publisher.publish("my-channel", "Hello from Bun!");
}, 100);

并发 bun test

bun test 现在支持使用 test.concurrent 在同一文件内并发运行多个 async 测试。这可以显著加速受 I/O 限制的测试套件,例如那些发起网络请求或与数据库交互的测试。

concurrent.test.ts

import { test, expect } from "bun:test";

// 这三个测试将并行运行。
// 总执行时间将约为 1 秒,而不是 3 秒。
test.concurrent("向服务器 1 发送请求", async () => {
  const response = await fetch("https://example.com/server-1");
  expect(response.status).toBe(200);
});

test.concurrent("向服务器 2 发送请求", async () => {
  const response = await fetch("https://example.com/server-2");
  expect(response.status).toBe(200);
});

// 可以与 .each、.only 或其他修饰符链式调用:
test.concurrent.each([
  "https://example.com/server-4",
  "https://example.com/server-5",
  "https://example.com/server-6",
])("向服务器 %s 发送请求", async (url) => {
  const response = await fetch(url);
  expect(response.status).toBe(200);
});

使用 describe.concurrent 并发运行测试组。

describe.concurrent.test.ts

import { describe, test, expect } from "bun:test";

describe.concurrent("服务器测试", () => {
  test("向服务器 1 发送请求", async () => {
    const response = await fetch("https://example.com/server-1");
    expect(response.status).toBe(200);
  });
});

test("串行测试", () => {
  expect(1 + 1).toBe(2);
});

默认情况下,最多会同时运行 20 个测试。您可以使用 --max-concurrency 标志来更改此设置。

concurrentTestGlob 使特定文件并发运行

要使特定文件并发运行,您可以在 bunfig.toml 中使用 concurrentTestGlob 选项。

bunfig.toml

[test]
concurrentTestGlob = "**/integration/**/*.test.ts"

# 您也可以提供一个模式数组。
# concurrentTestGlob = [
#   "**/integration/**/*.test.ts",
#   "**/*-concurrent.test.ts",
# ]

当使用 concurrentTestGlob 时,所有匹配 glob 的文件中的测试都将并发运行。

test.serial 使特定测试顺序执行

当您使用 describe.concurrent--concurrentconcurrentTestGlob 时,您可能仍然希望某些测试顺序执行。您可以使用新的 test.serial 修饰符来实现这一点。

test.serial.test.ts

import { test, expect } from "bun:test";

describe.concurrent("并发测试", () => {
  test("异步测试", async () => {
    await fetch("https://example.com/server-1");
    expect(1 + 1).toBe(2);
  });

  test("异步测试 #2", async () => {
    await fetch("https://example.com/server-2");
    expect(1 + 1).toBe(2);
  });

  test.serial("串行测试", () => {
    expect(1 + 1).toBe(2);
  });
});

使用 --randomize 随机顺序运行测试

并发测试有时会暴露出测试对执行顺序或共享状态的意外依赖。您可以使用 --randomize 标志以随机顺序运行测试,以便更容易发现这些依赖关系。

当您使用 --randomize 时,Bun 将输出该次运行的种子。为了在调试时重现完全相同的测试顺序,您可以使用 --seed 标志并附上打印的值。使用 --seed 会自动启用随机化。

# 以随机顺序运行测试
bun test --randomize

# 种子会在测试摘要中打印
# ... 测试输出 ...
#  --seed=12345
# 2 通过
# 8 失败
# 在 2 个文件中运行了 10 个测试。 [50.00ms]

# 使用种子重现相同的运行顺序
bun test --seed 12345

bun test 现在支持链式修饰符

您现在可以在 testdescribe 上链式调用修饰符,如 .failing.skip.only.each。以前,这会导致错误。

import { test, expect } from "bun:test";

// 这个测试预期会失败,并且它为数组中的每个项目运行。
test.failing.each([1, 2, 3])("每个 %i", (i) => {
  if (i > 0) {
    throw new Error("此测试预期失败。");
  }
});

CI 环境中更严格的 bun test

为防止意外提交,bun test 现在在 CI 环境中(即 CI=true 时)会在两种新情况下抛出错误:

  • 如果测试文件包含 test.only()
  • 如果快照测试(.toMatchSnapshot().toMatchInlineSnapshot())尝试在没有 --update-snapshots 标志的情况下创建新快照。

这有助于防止临时聚焦的测试或无意的快照更改被合并。要禁用此行为,您可以设置环境变量 CI=false

测试执行顺序改进

测试运行器的执行逻辑已被重写,以提高可靠性和可预测性。这解决了大量关于 describe 块和钩子(beforeAllafterAll 等)执行顺序略有意外的问题。新的行为与 Vitest 等测试运行器更加一致。

并发测试限制

  • 当使用 test.concurrentdescribe.concurrent 时,不支持 expect.assertions()expect.hasAssertions()
  • 不支持 toMatchSnapshot(),但支持 toMatchInlineSnapshot()
  • beforeAllafterAll 钩子不会并发执行。

bun feedback

在下一个 Bun 版本中

bun feedback <文件或文本> 让向 Bun 团队发送关于 Bun 的反馈变得更加容易 pic.twitter.com/CkJAKwllzU

— Jarred Sumner (@jarredsumner) 2025年9月16日

Node.js 兼容性改进

node:http

http.createServer 现在支持 CONNECT 方法。这允许使用 Bun 创建 HTTP 代理。

node:dns

dns.resolve 的回调现在与 Node.js 匹配,不再传递额外的 hostname 参数。dns.promises.resolve 现在对于 A/AAAA 记录正确返回字符串数组而不是对象。

node:worker_threads

修复了一个在使用 port.on('message', ...)port.addEventListener('message', ...) 时,MessagePort 通信在转移到 Worker 后会失败的 bug。这是由于 Web Workers 和 worker_threads 之间的差异导致的。

node:crypto

修复了在使用 JWK 格式的椭圆曲线密钥且 dsaEncoding: 'ieee-p1363' 时,crypto.createSign().sign() 可能发生的假设性崩溃。

node:http2

修复了关闭套接字时的内存泄漏问题。

node:net

修复了 net.connect() 中的句柄泄漏问题,该问题导致在建立大量连接时内存使用量增长。

node:tty

在 Windows 上,TTY 原始模式 (process.stdin.setRawMode(true)) 现在能正确处理终端 VT 控制序列,提高了与 Node.js 的兼容性,并支持诸如带括号的粘贴模式等功能。

使用 --use-system-ca 来使用系统的受信任证书

Bun 现在可以配置为使用操作系统的受信任根证书来建立 TLS 连接,此外还可以使用内置的 Mozilla CA 存储。这在具有自定义证书颁发机构的企业环境中或用于信任本地安装的自签名证书时非常有用。

要启用此功能,可以使用新的 --use-system-ca 命令行标志,或者设置 NODE_USE_SYSTEM_CA=1 环境变量,这使得 Bun 与 Node.js 的行为保持一致。

# 此命令将使用操作系统的受信任 CA 来验证
# "internal.service.corp" 的证书。

bun run --use-system-ca index.js
// index.js
const response = await fetch("https://internal.service.corp");
console.log(response.status);

process.report.getReport() 现已在 Windows 上实现

与 Node.js 兼容的 API process.report.getReport() 现已在 Windows 上完全实现。以前,这会抛出“未实现”错误。此函数生成关于当前进程的全面诊断报告,包括系统信息、JavaScript 堆统计信息、堆栈跟踪和已加载的共享库。

此更改提高了依赖此 API 进行诊断或环境检测的工具的兼容性。

// 在 Windows 上,现在这会返回一个详细的报告对象。
const report = process.report.getReport();

console.log(report.header.osVersion); // 例如:"Windows 11 Pro"
console.log(report.header.cpus.length > 0); // true
console.log(report.javascript.heap.heapSpaces.length > 0); // true

bun build --compile 中的 Windows 代码签名

bun build --compile 可以从 JavaScript 或 TypeScript 入口点创建独立的可执行文件。在 Windows 上,这些可执行文件通常基于预先签名的 bun.exe 二进制文件。以前,当 Bun 嵌入应用程序代码和资源时,它会使原始签名失效,从而阻止开发人员应用他们自己的代码签名证书。

此版本引入了自动的“Authenticode”签名剥离。当您创建可执行文件时,Bun 现在会移除原始签名,允许您使用标准的 Windows 工具(如 signtool.exe)使用自己的证书对编译后的二进制文件进行签名。这对于向 Windows 用户分发受信任的应用程序至关重要。

# 1. 将您的应用程序编译为独立可执行文件
bun build ./index.ts --compile --outfile my-app.exe

# 2. 使用您自己的证书对生成的可执行文件进行签名
signtool.exe sign /f MyCert.pfx /p MyPassword my-app.exe

Bun.build 新增 jsx 配置对象

Bun.build 中 JSX 转换的配置现在集中在一个新的 jsx 对象中。这包括先前通过 tsconfig.json 配置的选项,如 jsxFactoryjsxFragmentjsxImportSource

await Bun.build({
  entrypoints: ["./index.jsx"],
  outdir: "./dist",
  jsx: {
    runtime: "automatic", // "automatic" 或 "classic"
    importSource: "preact", // 默认为 "react"
    factory: "h", // 默认为 "React.createElement"
    fragment: "Fragment", // 默认为 "React.Fragment"
    development: false, // 使用 `jsx-dev` 运行时,默认为 `false`
    sideEffects: false,
  },
});

Bun.SQL 中的 sql.array 辅助函数

Bun.SQL 中的 sql.array 辅助函数使得处理 PostgreSQL 数组类型变得容易。您可以将数组插入到数组列中,并指定 PostgreSQL 数据类型以进行适当的类型转换。

import { sql } from "bun";

// 插入一个文本值数组
await sql`
  INSERT INTO users (name, roles)
  VALUES (${"Alice"}, ${sql.array(["admin", "user"], "TEXT")})
`;

// 使用 sql 对象表示法更新数组值
await sql`
  UPDATE users
  SET ${sql({
    name: "Bob",
    roles: sql.array(["moderator", "user"], "TEXT"),
  })}
  WHERE id = ${userId}
`;

// 与 JSON/JSONB 数组一起使用
const jsonData = await sql`
  SELECT ${sql.array([{ a: 1 }, { b: 2 }], "JSONB")} as data
`;

// 支持各种 PostgreSQL 类型
await sql`SELECT ${sql.array([1, 2, 3], "INTEGER")} as numbers`;
await sql`SELECT ${sql.array([true, false], "BOOLEAN")} as flags`;
await sql`SELECT ${sql.array([new Date()], "TIMESTAMP")} as dates`;

sql.array 辅助函数支持所有主要的 PostgreSQL 数组类型,包括 TEXTINTEGERBIGINTBOOLEANJSONJSONBTIMESTAMPUUIDINET 等等。

顶层 await 改进

Bun 的打包器现在对顶层 await 有了更好的支持。

当存在使用顶层 await 的循环依赖时,Bun 现在会将模块包装在 await Promise.all 中以确保它们全部加载。我们还修复了一些边缘情况,这些情况可能导致在某些情况下打包的模块中缺少 async 关键字。

深入理解 JavaScript 的对象与代理模式(Proxy)

作者 gustt
2025年10月22日 18:48

深入理解 JavaScript 的对象与代理模式(Proxy)

在编程语言的世界中,JavaScript 一直以其灵活性与动态特性著称。与 C++、Java 这些强类型语言不同,JavaScript 在设计之初就是一门“弱类型、基于对象(object-based)”的脚本语言,它允许开发者以极其简洁的方式创建对象、操作属性和动态扩展功能。要理解 JavaScript 的“面向对象编程”,我们首先要从对象字面量(Object Literal)开始谈起。


一、对象字面量:JavaScript 的灵魂

在 JavaScript 中,我们不需要像 Java 那样通过定义类(class)再去实例化对象。相反,JS 允许我们直接通过一对花括号 {} 创建一个对象,这种写法就被称为 对象字面量(Object Literal)

例如:

let person = {
  name: "Alice",
  age: 20,
  sayHi: function () {
    console.log(`Hi, I'm ${this.name}`);
  }
};

在这短短几行代码中,我们就完成了一个拥有属性和方法的对象。
对象中存放的键值对(key-value pair)就是“属性”;若某个属性的值是函数,那么它就成了对象的方法。

对象字面量语法极具表现力,它直接把数据与行为组织到一起,使得创建一个逻辑完整的实体变得非常自然。

而当我们需要创建一个“数组”时,JS 则提供了类似的语法糖:

let arr = [1, 2, 3];

方括号 [] 是数组字面量。
这种简洁、直接的语法,是 JavaScript 与生俱来的优势。


二、JavaScript 的数据类型与对象的特殊地位

JS 的基本数据类型分为六种:

  • 字符串(String)
  • 数字(Number)
  • 布尔值(Boolean)
  • 空值(null)
  • 未定义(undefined)
  • 对象(Object)

从 ES6 开始,又新增了 Symbol 与 BigInt,但核心思想没有变——对象是一切的基础。

在 JS 中,几乎所有东西都可以被看作对象。数组是对象、函数是对象,甚至正则表达式、日期等特殊结构也都是对象。
这让 JavaScript 的编程范式更偏向“万物皆对象(Everything is an Object)”。


三、面向对象:从属性与方法说起

所谓“面向对象编程(OOP)”,本质上是以对象为核心组织代码。对象中既包含数据(属性),又包含行为(方法)。这种思想使得代码更加符合人类的思维方式。

例如,我们可以用对象来描述一个“人”:

let xm = {
  name: "小美",
  receiveFlower: function (flower) {
    console.log(`小美收到了${flower}`);
  }
};

然后另一个对象来代表“送花的人”:

let ggg = {
  sendFlower: function (target) {
    target.receiveFlower("一束玫瑰花");
  }
};

调用:

ggg.sendFlower(xm);

输出:

小美收到了玫瑰花

这就是最基础的对象交互。
但在现实生活中,事情往往没有这么简单——如果小美不想直接面对送花的人,会怎样呢?
这时候,就轮到“代理模式”登场了。


四、代理模式(Proxy Pattern):灵活的中介机制

代理模式(Proxy Pattern)是一种非常经典的设计模式,它的核心思想是:为其他对象提供一种代理,以控制对这个对象的访问。

换句话说,我们并不总是直接与目标对象交互,而是通过一个“中间人”——代理对象——来完成间接调用。
这样可以在不改变原对象的前提下,增强或控制其行为。


举个生活化的例子

继续我们的送花故事:

ggg 想送花给 xm,但 xm 是个很高冷的人。
所以 ggg 找到了 xm 的好朋友 xh,请她帮忙转交。

代码如下:

let xm = {
  name: "小美",
  receiveFlower: function (flower) {
    console.log(`小美收到了${flower}`);
  }
};

let xh = {
  name: "小红",
  receiveFlower: function (flower) {
    // 小红代收,但可以决定是否要转交
    if (new Date().getHours() < 12) {
      console.log("小红说:小美现在心情不好,等会再给她吧");
    } else {
      xm.receiveFlower(flower);
    }
  }
};

let ggg = {
  sendFlower: function (target) {
    target.receiveFlower("一束玫瑰花");
  }
};

// ggg 把花送给小红,小红代理转交
ggg.sendFlower(xh);

这段代码中,小红(xh)是代理,她帮小美(xm)“代收”花。
代理可以根据具体情况决定是否、何时、怎样转交目标对象。
这就是典型的代理模式。


五、代理模式的本质

代理模式的关键点在于:接口的一致性
也就是说,代理对象和被代理对象必须实现相同的接口(在 JS 中通常体现为具有相同的方法名)。

在上面的例子中,无论 ggg 把花送给谁,只要对象拥有 receiveFlower 方法,这个交互流程就能顺利执行:

ggg.sendFlower(xm); // 直接送给小美
ggg.sendFlower(xh); // 通过小红代理送花

这就是“面向接口编程”的思想。
它让系统具有高度的灵活性与可扩展性。


六、ES6 的 Proxy 对象:语言层面的代理

在 ES6 之后,JavaScript 提供了一个内置的 Proxy 构造函数,让代理模式成为语言原生特性。
通过 Proxy,我们可以在对象被访问或修改时自动执行拦截逻辑。

来看一个例子:

let person = {
  name: "Alice",
  age: 20
};

let proxy = new Proxy(person, {
  get(target, prop) {
    console.log(`访问了属性:${prop}`);
    return target[prop];
  },
  set(target, prop, value) {
    console.log(`设置属性 ${prop}${value}`);
    target[prop] = value;
    return true;
  }
});

proxy.name;      // 输出:访问了属性:name
proxy.age = 22;  // 输出:设置属性 age 为 22

在这个例子中,我们定义了一个代理对象 proxy,它包裹了原始对象 person
当我们访问或修改 proxy 的属性时,getset 拦截器会自动触发。
这样就能在对象访问的前后添加额外的逻辑,比如打印日志、权限检查、懒加载、性能统计等。


七、代理模式的应用场景

代理模式在实际开发中用途极广,下面列出几种常见场景:

1. 数据拦截与校验

可以通过 Proxy 实现输入数据的校验逻辑:

let user = new Proxy({}, {
  set(target, prop, value) {
    if (prop === 'age' && value < 0) {
      throw new Error("年龄不能为负数");
    }
    target[prop] = value;
    return true;
  }
});

user.age = 18; // 正常
user.age = -5; // 抛出异常

2. 懒加载(Lazy Loading)

当访问某个属性时才加载对应数据,例如从服务器拉取信息。

3. 安全控制(访问权限)

通过代理控制某些属性是否可读、可写。

4. 日志与性能监控

记录函数调用次数或执行时间,用于调试或性能分析。

5. Vue3 的响应式系统

Vue3 的响应式数据核心 reactive() 就是通过 Proxy 实现的。
当数据变化时,Vue 自动追踪依赖并触发界面更新。
可以说,Proxy 是现代前端响应式系统的基石。


八、代理模式与装饰器模式的区别

有些同学会把“代理模式”和“装饰器模式”混淆。两者确实相似,都是在不修改原对象的情况下增强功能,但核心区别在于目的:

  • 代理模式:侧重于控制访问,决定是否、何时、如何调用目标对象。
  • 装饰器模式:侧重于扩展功能,在调用前后附加额外行为。

如果说代理是“把门的保安”,那装饰器更像是“化妆师”——都在中间层动手,但意图不同。


九、总结:代理模式的价值

代理模式是一种思想,而不仅仅是一种语法技巧。
它让我们能在不改变原有对象逻辑的情况下,灵活地控制访问、增强功能、实现解耦。

在 JavaScript 中,这种模式尤其自然,因为对象本身就是动态的,属性可以随时添加或替换。
从最初的“朋友代送花”的生活比喻,到 ES6 Proxy 的底层机制,代理模式都体现了一种间接性控制力——在复杂系统中,它能有效地隔离变化,提升系统的灵活性与可维护性。


十、结语

从对象字面量到设计模式,JavaScript 用极为优雅的方式演绎了“万物皆对象”的哲学。
在理解代理模式的过程中,我们不仅学习了一种编程技巧,更是理解了一种“设计思想”——
程序的可扩展性,往往来自于良好的抽象与适度的间接性。

JavaScript 对象:从字面量到代理模式的灵活世界

作者 3秒一个大
2025年10月22日 18:39

JavaScript 对象:从字面量到代理模式的灵活世界

在 JavaScript 的世界里,对象是构建一切的基石。作为最具表现力的脚本语言之一,JavaScript 对对象的处理方式展现出了独特的灵活性 —— 无需像 Java 或 C++ 那样先定义类(早期版本甚至没有 class 关键字),只需通过简洁的语法就能创建功能完备的对象。这种特性让 JavaScript 在面向对象编程领域独树一帜。

对象的本质:属性与方法的集合

从本质上讲,JavaScript 对象是由属性(properties)和方法(methods)构成的集合。属性是对象的状态描述,如一个人的姓名、年龄;方法则是对象的行为能力,如说话、行走。这种结构完美契合了面向对象编程的核心思想 —— 将数据与操作数据的函数封装在一起。

无论是简单的信息载体(如{name: "张三", age: 20}),还是复杂的人际关系系统(如描述家庭关系、社交网络的对象体系),JavaScript 对象都能轻松应对。这种灵活性使得开发者能够快速构建从简单到复杂的各种数据模型。

简洁的创建方式:对象字面量

JavaScript 提供了直观的对象字面量语法,用一对花括号{}就能创建对象,这也是 "字面量" 名称的由来 —— 从字面上就能清晰地看出对象的结构和内容。例如:

 

const person = {
  name: "张三",
  age: 30,
  sayHello: function() {
    console.log(`你好,我是${this.name}`);
  }
};

这种语法省略了类定义的中间环节,让开发者能够直接创建对象实例,大大提高了编码效率。类似地,数组也可以通过字面量[]快速创建,与对象字面量共同构成了 JavaScript 中最常用的数据结构基础。

JavaScript 数据类型中的对象

在 JavaScript 的六种基本数据类型中,对象(object)占据着核心地位:

· 字符串(string):文本数据

· 数值(number):数字数据

· 布尔值(boolean):true/false

· 对象(object):复杂数据结构

· 空值(null):表示 "无" 的特殊值

· 未定义(undefined):表示未初始化的变量

其中,对象是唯一的引用类型,其他五种均为基本类型。这意味着对象可以包含其他数据类型(包括其他对象),从而构建出复杂的数据结构和功能模块。

代理模式:接口导向的灵活编程

当对象系统变得复杂时,设计模式能帮助我们构建更灵活、更具扩展性的代码。代理模式(Proxy)就是其中的典型代表,它体现了 "面向接口编程" 的思想 —— 通过定义统一接口,让不同对象可以互相替代,从而增强代码的灵活性和复用性。

举个生活中的例子:假设 "zzp" 想给 "xm" 送花,但直接送花很可能被拒绝。这时可以引入 "xh" 作为代理,只要 "xh" 和 "xm" 都实现了相同的receiveFlower方法(即遵循相同的接口),"zzp" 就可以通过 "xh" 间接送花给 "xm":

// xm的对象定义
const xm = {
  receiveFlower: function() {
    console.log("xm收到了花");
  }
};

// xh的对象定义(实现了与xm相同的接口)
const xh = {
  receiveFlower: function() {
    console.log("xh代为收到了花");
    xm.receiveFlower(); // 转发给xm
  }
};

// zzp送花的函数(依赖于receiveFlower接口)
function sendFlower(person) {
  person.receiveFlower();
}

// 既可以直接送给xm,也可以通过xh代理
sendFlower(xm);  // 输出:"xm收到了花"
sendFlower(xh);  // 输出:"xh代为收到了花"  followed by "xm收到了花"

在这个例子中,xm和xh通过实现相同的receiveFlower方法,形成了统一的接口。sendFlower函数只需依赖这个接口,而不必关心具体是哪个对象接收花,这就是代理模式的核心价值 —— 通过接口抽象,实现了调用者与接收者之间的解耦。

结语

JavaScript 对象从简单的字面量语法,到复杂的设计模式应用,展现了这门语言在面向对象编程方面的强大能力。无论是快速创建简单对象,还是构建复杂的系统架构,JavaScript 的对象模型都能提供灵活而高效的支持。理解对象的本质、掌握对象的用法,是每个 JavaScript 开发者进阶之路上的重要一步。

uniapp AI聊天应用技术解析:实现流畅的Streaming聊天体验(基础版本)

作者 BumBle
2025年10月22日 18:01

本文将深入解析一个基于uni-app的AI聊天应用,重点讲解其如何通过Streaming技术实现流畅的聊天体验。这个应用不仅支持基本的文本对话,还实现了流式响应、实时暂停、内容复制等高级功能。

本文用的AI模型是联通,博主还有一篇业务逻辑更复杂的(包含思考内容、多种聊天模式)AI模型为DeepSeek的博客 快速前往

实现效果

image.png

技术亮点

  1. 流式数据处理:通过SSE实现实时数据流接收,提升用户体验
  2. 内存优化:及时清理请求和监听器,避免内存泄漏
  3. 用户体验:自动滚动、加载状态、暂停功能、复制内容等细节处理
  4. 错误处理:完善的异常捕获和错误提示机制
  5. 响应式设计:智能输入框根据内容动态调整高度

1. 核心流程解析

1.1 整体交互流程

  • 用户输入:用户在底部输入框输入问题,点击发送或按回车
  • 消息展示:用户消息立即显示在聊天区域,AI回复区域显示"加载中..."
  • 流式请求:向服务器发送SSE(Server-Sent Events)请求,接收分块数据
  • 实时渲染:逐步接收并显示AI回复内容
  • 交互控制:支持暂停回答、复制内容等操作

1.2 数据流处理

用户输入 → 本地显示 → 创建SSE连接 → 分块接收数据 → 实时渲染 → 完成/暂停

1.3 关键状态管理

  • isListening: 控制是否正在接收AI回复
  • sessionId: 维持对话会话
  • chatArr: 存储所有聊天记录
  • requestTask: 管理HTTP请求,支持中止

2. 核心代码详解

2.1 Streaming数据接收处理

// 分块数据接收处理
this.chunkListener = res => {
  if (!this.isListening) {
    requestTask.abort()
    return
  }
  
  const uint8Array = new Uint8Array(res.data)
  let text = String.fromCharCode.apply(null, uint8Array)
  text = decodeURIComponent(escape(text)) // 处理中文乱码
  
  const messages = text.split('data:')
  messages.forEach(message => {
    if (!message.trim()) return
    
    try {
      const data = JSON.parse(message)
      
      // 结束标识处理
      if (data.execute.finished === true) {
        this.pauseAnswer(chatIndex)
        return
      }
      
      // 实时内容拼接
      if (data.result && data.result.richList && data.result.richList.length) {
        const lastChat = this.chatArr[this.chatArr.length - 1]
        const answer = data.result.richList.map(item => item.text).join('')
        
        if (lastChat && lastChat.type === 'robot' && answer) {
          lastChat.content += answer
          this.scrollToLower() // 自动滚动到底部
        }
      }
    } catch (error) {
      console.error('JSON解析错误:', error)
    }
  })
}

2.2 请求管理与暂停功能

// 暂停回答功能
pauseAnswer(index) {
  if (this.requestTask) {
    this.requestTask.abort() // 中止请求
    this.requestTask.offChunkReceived(this.chunkListener) // 移除监听器
    this.requestTask = null
  }
  this.isListening = false
  this.chatArr[index].isListening = false // 更新UI状态
}

2.3 智能输入框控制

// 动态调整输入框高度
computed: {
  getExpandStyle() {
    if (this.isExpand) {
      return 'height: 85vh;' // 展开模式
    } else {
      return 'max-height: 170rpx;' // 普通模式
    }
  }
},
methods: {
  setShowExpand(e) {
    this.showExpand = e.detail?.lineCount >= 4 || false
  }
}

2.4 消息发送流程

generate() {
  if (this.isListening) {
    this.$alert(this.sessionId ? '当前会话未结束' : '服务器繁忙,请稍后再试')
    return
  }
  
  if (!this.content.trim()) {
    return uni.showToast({ title: '请描述需求', icon: 'none' })
  }
  
  // 添加用户消息到聊天记录
  this.chatArr.push({
    type: 'self',
    content: this.content
  })
  
  this.scrollToLower()
  this.content = ''
  this.isListening = true
  
  // 发送请求
  this.sendChats({
    content: this.content,
    sessionId: this.sessionId
  })
}

3. 完整代码实现

<template>
  <view class="ai">
    <scroll-view class="ai-scroll" :scroll-into-view="scrollIntoView" scroll-y scroll-with-animation>
      <view class="ai-chat" v-for="(item, index) in chatArr" :key="index">
        <view class="ai-chat-item self" v-if="item.type === 'self'">
          <view class="ai-chat-content" style="max-width: 520rpx">{{ item.content }}</view>
        </view>
        <view class="ai-chat-item robot" v-if="item.type === 'robot'">
          <view class="ai-chat-content" style="width: 520rpx">
            <view class="ai-chat-content-box flex-c content-think" v-if="!item.content && item.isListening">加载中...</view>
            <text class="ai-chat-content-box">{{ item.content }}</text>
            <view class="ai-chat-opt flex-c">
              <template v-if="item.isListening">
                <view class="ai-chat-opt-btn pause-btn flex-c-c" hover-class="h-c" @click="pauseAnswer(index)">
                  <image class="ai-chat-opt-icon" :src="`${mdFileBaseUrl}/stand/emp/AI/icon_pause.png`"></image>
                  暂停回答
                </view>
              </template>
              <template v-else>
                <view class="ai-chat-opt-btn flex-c-c" hover-class="h-c" @click="copyAnswer(item)">复制</view>
              </template>
            </view>
          </view>
        </view>
      </view>
      <view id="lower" class="lower"></view>
    </scroll-view>
    <view class="ai-footer flex-c">
      <view class="ai-footer-keyboard">
        <view class="keyboard-clear" hover-class="h-c" @click="content = ''" v-if="isExpand">清空</view>
        <view class="flex-c">
        <textarea
          v-model="content"
          class="keyboard-inp"
          :style="getExpandStyle"
          :auto-height="!isExpand"
          cursor-spacing="30"
          :show-confirm-bar="false"
          maxlength="-1"
          placeholder="请描述您的需求"
          placeholder-style="font-size: 28rpx; color: #CCCCCC; line-height: 45rpx;"
          @confirm="generate()"
          @linechange="setShowExpand"
        ></textarea>
        <image class="btns-icon expand" :src="`${mdFileBaseUrl}/stand/emp/intelligent/${ !isExpand ? 'expand' : 'collapse' }_icon.png`" @click="toggleExpand()" v-if="showExpand || isExpand"></image>
        <image class="btns-icon send" :src="`${mdFileBaseUrl}/stand/emp/intelligent/send_${ !content.trim() ? 'disabled' : 'primary' }.png`" @click="generate()"></image>
        </view>
      </view>
    </view>
  </view>
</template>
<script>
import { empInterfaceUrl } from '@/config'
import { getUuid } from '@/common/util'

export default {
  data() {
    return {
      content: '', // 内容
      requestTask: null,
      sessionId: '',
      isListening: false, // 添加状态变量
      chatArr: [],
      scrollIntoView: 'lower',
      chunkListener: null,
      filterInfo: {},
      showExpand: false,
      isExpand: false
    }
  },
  computed: {
    getExpandStyle() {
      if (this.isExpand) {
        return 'height: 85vh;'
      } else {
        return 'max-height: 170rpx;'
      }
    }
  },
  methods: {
    toggleExpand() {
      this.isExpand = !this.isExpand
    },
    setShowExpand(e) {
      this.showExpand = e.detail?.lineCount >= 4 || false
    },
    // 自动滚动到底部
    scrollToLower() {
      this.scrollIntoView = ''
      setTimeout(() => {
        this.scrollIntoView = 'lower'
      }, 250)
    },
    copyAnswer(item) {
      uni.setClipboardData({
        data: item.content,
        success: () => {
          uni.hideLoading()
          uni.showModal({
            title: '提示',
            content: '话术已复制到粘贴板,请进入好友聊天对话框,粘贴发送',
            showCancel: false,
            confirmText: '我知道了'
          })
        }
      })
    },
    // 发送(开始生成)
    generate() {
      if (this.isListening) {
        let msg = this.sessionId ? '当前会话未结束' : '服务器繁忙,请稍后再试'
        this.$alert(msg)
        return
      }
      if (!this.content.trim()) {
        return uni.showToast({ title: '请描述需求', icon: 'none' })
      }
      this.chatArr.push({
        type: 'self',
        content: this.content
      })
      this.scrollToLower()
      this.content = ''
      this.isListening = true
      this.sendChats({
        content: this.content,
        sessionId: this.sessionId
      })
    },
    sendChats(params) {
      let chatIndex // 获取新添加的robot消息的索引
      // 取消之前的请求
      if (this.requestTask) {
        this.requestTask.abort()
        this.requestTask = null
      }
      this.chatArr.push({
        type: 'robot',
        content: '',
        isListening: true
      })
      chatIndex = this.chatArr.length - 1
      this.scrollToLower()
      // 发起请求
      const requestTask = wx.request({
        url: `${empInterfaceUrl}/gateway/helper/emp/aiSaleTechniques/sendMsg`,
        timeout: 60000,
        responseType: 'text',
        method: 'POST',
        enableChunked: true,
        header: {
          Accept: 'text/event-stream',
          'Content-Type': 'application/json',
          'root-shop-id': this.empShopInfo.rootShop,
          Authorization: this.$store.getters.empBaseInfo.token
        },
        data: params,
        fail: () => {
          this.isListening = false
          if (chatIndex !== undefined) {
            this.chatArr[chatIndex].isListening = false
          }
        }
      })
      // 移除之前的监听器
      if (this.chunkListener && this.requestTask) {
        this.requestTask.offChunkReceived(this.chunkListener)
      }
      // 添加新的监听器
      this.chunkListener = res => {
        // 如果已经停止监听,则中止请求
        if (!this.isListening) {
          requestTask.abort()
          return
        }
        // 处理流数据
        const uint8Array = new Uint8Array(res.data)
        // 将Uint8Array转换为字符串
        let text = String.fromCharCode.apply(null, uint8Array)
        // 处理中文乱码问题
        text = decodeURIComponent(escape(text))
        // 按data:分割消息
        const messages = text.split('data:')
        messages.forEach(message => {
          if (!message.trim()) {
            return
          }
          // 流返回的数据结构有可能不是完整的JSON,需要捕获解析错误
          try {
            // 解析JSON数据
            const data = JSON.parse(message)
            // 结束标识(这里的结束标识是 data.execute.finished === true)
            if (data.execute.finished === true) {
              this.pauseAnswer(chatIndex)
              return
            }
            // 追加回答内容(回答内容的数据在result.richList数组中的每个text)
            if (data.result && data.result.richList && data.result.richList.length) {
              // 获取最后一条聊天记录
              const lastChat = this.chatArr[this.chatArr.length - 1]
              // 拼接回答内容
              const answer = data.result.richList.map(item => item.text).join('')
              // 追加内容到最后一条聊天记录
              if (lastChat && lastChat.type === 'robot' && answer) {
                lastChat.content += answer
                this.scrollToLower()
              }
            }
          } catch (error) {
            console.error('JSON解析错误:', error)
            console.error('解析失败的数据:', message)
          }
        })
      }
      // 绑定监听器
      requestTask.onChunkReceived(this.chunkListener)
      this.requestTask = requestTask
    },
    // 暂停回答
    pauseAnswer(index) {
      if (this.requestTask) {
        // 中止请求
        this.requestTask.abort()
        // 移除监听器
        this.requestTask.offChunkReceived(this.chunkListener)
        this.requestTask = null
      }
      this.isListening = false
      // 更新对应的聊天记录状态
      this.chatArr[index].isListening = false
    }
  },
  onLoad(options) {
    this.$store.dispatch('checkLoginHandle').then(() => {
      // 每次进入页面都生成一个新的sessionId(在这个需求中要求前端生成30位的随机数即可)
      this.sessionId = getUuid().substring(0, 30)
    })
  },
  beforeDestroy() {
    // 移除之前的监听器
    if (this.requestTask) {
      this.requestTask.abort()
      if (this.chunkListener) {
        this.requestTask.offChunkReceived(this.chunkListener)
      }
      this.requestTask = null
    }
  }
}
</script>

<style lang="scss">
page {
  background: #f5f5f5;
}
.ai {
  padding-top: 20rpx;
  &-scroll {
    height: calc(100vh - 120rpx);
    overflow: auto;
  }
  &-chat {
    padding: 0 20rpx;
    &-item {
      margin-top: 40rpx;
      display: flex;
      &.self {
        .ai-chat-content {
          background: $uni-base-color;
          color: #ffffff;
          margin-right: 10rpx;
          margin-left: auto;
        }
      }
      &.robot {
        .ai-chat-content {
          margin-right: auto;
        }
      }
    }
    &-content {
      background: #fff;
      border-radius: 14rpx;
      padding: 27rpx 20rpx;
      font-size: 28rpx;
      color: #666666;
      line-height: 33rpx;
      word-break: break-all;
      margin-left: 10rpx;
      .content-think {
        color: #919099;
        margin-bottom: 8rpx;
      }
    }
    &-opt {
      justify-content: flex-end;
      margin-top: 40rpx;
      border-top: 1px solid #eeeeee;
      padding-top: 20rpx;
      &-btn {
        padding: 0 16rpx;
        height: 64rpx;
        border-radius: 8rpx;
        border: 2rpx solid #cccccc;
        font-size: 24rpx;
        color: #666666;
        min-width: 96rpx;
        &:last-child {
          background: rgba(56, 116, 246, 0.1);
          margin-left: 20rpx;
          color: #3874f6;
          border: none;
        }
        &.pause-btn {
          border: 2rpx solid $uni-base-color;
          color: $uni-base-color;
          background: none;
        }
      }
      &-icon {
        width: 32rpx;
        height: 32rpx;
        margin-right: 8rpx;
      }
    }
  }
  &-footer {
    min-height: 120rpx;
    position: fixed;
    bottom: 0;
    background: #fff;
    left: 0;
    right: 0;
    z-index: 1;
    padding: 20rpx;
    &-keyboard {
      background: #F1F1F1;
      border-radius: 8rpx 8rpx 8rpx 8rpx;
      padding: 20rpx 20rpx 20rpx 40rpx;
      width: 100%;
      position: relative;
      .keyboard-inp {
        box-sizing: border-box;
        display: block;
        width: 100%;
        font-size: 28rpx;
        color: #666666;
        line-height: 45rpx;
        overflow: auto;
        padding-right: 90rpx;
      }
      .btns-icon {
        width: 45rpx;
        height: 45rpx;
        position: absolute;
        right: 20rpx;
        &.expand {
          top: 20rpx;
        }
        &.send {
          bottom: 20rpx;
        }
      }
      .keyboard-clear {
        font-size: 28rpx;
        color: #3874F6;
        line-height: 45rpx;
        margin-bottom: 40rpx;
        display: inline-block;
      }
    }
  }
  .lower {
    height: 350rpx;
    width: 750rpx;
  }
}
</style>

使用阿里lowcode,封装SearchDropdown 搜索下拉组件

作者 HHHHHY
2025年10月22日 17:57

组件本身代码逻辑并不复杂,主要记录踩坑点和思路~~

1、先做记录吧

由于alilowcode和alifd/next深度集成,做之前也是先看了下alifd/next的组件库,发现有现成的可以直接用,这不cv就完s事了(暗爽~),也是做出第一版了。
官方案例: image.png

第一版

SearchDropdown.tsx

import React, { createElement, useState } from 'react';
import { Search } from '@alifd/next';
import './index.scss';

export interface SearchDropdownProps {
  placeholder?: string;
  data?: { label: string; value: string }[];
  onSelect?: (value: string) => void;
  onSearch?: (value: string) => void;
  buttonText?: string;
}

const SearchDropdown: React.FC<SearchDropdownProps> = ({
  placeholder = '请输入搜索内容',
  data = [],
  onSelect,
  onSearch,
}) => {
  const [searchValue, setSearchValue] = useState('');

  const handleSearch = (value: string) => {
    onSearch?.(value);
  };
  
  const handleChange = (value: string) => {
    onSelect?.(value);
    setSearchValue(value)
  };

  return (
    <div className="search-dropdown">
      <Search
        placeholder={placeholder}
        value={searchValue}
        autoHighlightFirstItem={false}
        fillProps="label"
        dataSource={data}
        onChange={handleChange}
        onSearch={handleSearch}
        shape="simple"
        hasClear
      />
    </div>
  );
};

export default SearchDropdown;

meta.ts

import { IPublicModelComponentMeta } from '@alilc/lowcode-types';

const SearchDropdownMeta: IPublicModelComponentMeta = {
  componentName: 'SearchDropdown',
  title: '搜索下拉弹层',
  group: '组件',
  category: 'xxxx',
  devMode: 'proCode',
  npm: {
    package: 'xxx',
    version: '0.1.0',
    exportName: 'SearchDropdown',
    main: 'src/index.tsx',
    destructuring: true,
  },

  props: [
    {
      name: 'placeholder',
      title: {
        label: '占位文字',
        tip: '输入框未输入时的提示文字',
      },
      setter: 'StringSetter',
      defaultValue: '请输入搜索内容',
    },
    {
      name: 'data',
      setter: {
        componentName: 'JsonSetter',
        isRequired: true,
        props: {},
      },
    },
    {
      name: 'onSearch',
      title: {
        label: '搜索事件',
        tip: '当搜索框输入或回车时触发',
      },
      setter: 'FunctionSetter',
    },
    {
      name: 'onSelect',
      title: {
        label: '选中事件',
        tip: '当选择下拉项时触发',
      },
      setter: 'FunctionSetter',
    },
  ],

  supports: {
    style: true,
    events: ['onSearch', 'onSelect'],
  },


  snippets: [
    {
      title: '搜索下拉弹层',
      schema: {
        componentName: 'SearchDropdown',
        props: {
          placeholder: '请输入搜索内容',
          buttonText: 'Delete',
          data: [
            { label: '选项1', value: '1' },
            { label: '选项2', value: '2' },
            { label: '选项3', value: '3' },
          ],
        }
      },
    },
  ],
};

export default SearchDropdownMeta;

坑与思路

坑:

第一版写出来效果和功能初步来看还是很好的,但是自己一番测试下来发现这个handleChange每次都会触发onselect事件,但是看了官方文档Search组件是没有onSelect事件的,所以每次都会触发onselect的逻辑是不对的,而我要的需求是:点击select下拉框里面的才去触发onSelect事件

原始思路:

因为我知道dataSource的数据和数据结构,这个就很简单了我在onSelect逻辑里面去做判断每次输入的值和dataSource里面的label是否一致,再去判断是否触发对应业务逻辑就行。

image.png

这么做可以解决用户在模糊输入时去点击选择精确对象时触发onSelect事件的问题,但是用户要是精确输入的话之前的问题还是存在的

最终思路:

仔细观察官方demo里面发现onChange里面有三个入参,通过看控制台发现输入内容时打印结果是:

image.png
但是选中下拉时打印结果是:

image.png

那么问题就解决了

最终版SearchDropdown.tsx

import React, { createElement, useState, useRef } from 'react';
import { Search, Overlay, Menu, Button } from '@alifd/next';
import './index.scss';

export interface SearchDropdownProps {
  placeholder?: string;
  data?: { label: string; value: string }[];
  onSelect?: (value: string) => void;
  onSearch?: (value: string) => void;
  buttonText?: string;
}

const SearchDropdown: React.FC<SearchDropdownProps> = ({
  placeholder = '请输入搜索内容',
  data = [],
  onSelect,
  onSearch,
}) => {
  const [searchValue, setSearchValue] = useState('');

  const handleSearch = (value: string) => {
    onSearch?.(value);
  };
  
  const handleChange = (value: string, type: any, e: any) => {
    // console.log(value, type, e);
    if(type === 'itemClick') { // 这里判断下就能完美解决
      onSelect?.(value);
    }
    setSearchValue(value)
  };

  return (
    <div className="search-dropdown">
      <Search
        placeholder={placeholder}
        value={searchValue}
        autoHighlightFirstItem={false}
        fillProps="label"
        dataSource={data}
        onChange={handleChange}
        onSearch={handleSearch}
        shape="simple"
        hasClear
      />
    </div>
  );
};

export default SearchDropdown;

总结:组件代码逻辑其实真的很简单,alifd/next的文档说实话一言难尽,用起来也是,说白了我也白说,写个博客做个记录 大家看个乐子就好。

万事从 todolist 开始

作者 前端大付
2025年10月22日 17:20

学习 vue3 从最基础开始,步步完善,最终完成✅

实现简单的todolist

jQuery思维类似这样

<div>
<h2 id="app"></h2>
<input type="text" id="todo-input">
</div>
<script src="jquery.min.js"></script>
<script>
// 找到输入框,监听输入
$('#todo-input').on('input',function(){
let val = $(this).val() // 获取值
$('#app').html(val) // 找到标签,修改内容
})
</script>

Vue 思维

<body>
<div id="app">
  <h2>{{title}}</h2>
  <input type="text" v-model="title" />
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
  const App = {
    data() {
      return {
        title: '', // 定义一个数据
      }
    },
  }
  // 启动应用
  Vue.createApp(App).mount('#app')
</script>
</body>

image.png

区别在于 jQuery 时代的开发逻辑,就是我们先要找到目标元素,然后再进行对应的修改。Vue 时代不要再思考页面的元素怎么操作,而是要思考数据是怎么变化的。

如果有多个想做的事

使用 v-for 循环展示数据

<body>
  <div id="app">
    <h2>{{ title }}</h2>
    <input type="text" v-model="title" />
    <ul>
      <li v-for="todo in todos">{{ todo }}</li>
    </ul>
  </div>

  <!-- 使用完整版 Vue(带模板编译器) -->
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script>
    const App = {
      data() {
        return {
          title: '',
          todos: ['赖床', '熬夜', '要不改改?'],
        }
      },
    }
    Vue.createApp(App).mount('#app')
  </script>
</body>

image.png

添加交互

<body>
  <div id="app">
    <input type="text" v-model="title" @keydown.enter="addTodo" />
    <ul>
      <li v-for="todo in todos">{{todo}}</li>
    </ul>
  </div>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script>
    const App = {
      data() {
        return {
          title: '', // 定义一个数据
          todos: ['赖床', '熬夜', '要不改改?'],
        }
      },
      methods: {
        addTodo() {
          this.todos.push(this.title)
          this.title = ''
        },
      },
    }
    // 启动应用
    Vue.createApp(App).mount('#app')
  </script>
</body>

image.png

勾选完成并置灰

需要加一个 是否完成状态

<body>
  <div id="app">
    <input type="text" v-model="title" @keydown.enter="addTodo" />
    <ul>
      <li v-for="todo in todos">
        <input type="checkbox" v-model="todo.done" />
        <span :class="{done:todo.done}"> {{todo.title}}</span>
      </li>
    </ul>
  </div>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script>
    const App = {
      data() {
        return {
          title: '', // 定义一个数据
          todos: [
            { title: '赖床', done: true },
            { title: '熬夜', done: true },
            { title: '要不改改?', done: false },
          ],
        }
      },
      methods: {
        addTodo() {
          this.todos.push({
            title: this.title,
            done: false,
          })
          this.title = ''
        },
      },
    }
    // 启动应用
    Vue.createApp(App).mount('#app')
  </script>

  <style>
    .done {
      color: gray;
      text-decoration: line-through;
    }
  </style>
</body>

image.png

添加已完成/未完成数量

<body>
  <div id="app">
    <input type="text" v-model="title" @keydown.enter="addTodo" />
    <ul>
      <li v-for="todo in todos">
        <input type="checkbox" v-model="todo.done" />
        <span :class="{done:todo.done}"> {{todo.title}}</span>
      </li>
    </ul>

    <div>
      全选<input type="checkbox" v-model="allDone" />
      <span> {{active}} / {{all}} </span>
    </div>
  </div>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script>
    const App = {
      data() {
        return {
          title: '', // 定义一个数据
          todos: [
            { title: '赖床', done: true },
            { title: '熬夜', done: true },
            { title: '要不改改?', done: false },
          ],
        }
      },
      computed: {
        active() {
          return this.todos.filter((v) => !v.done).length
        },
        all() {
          return this.todos.length
        },
        allDone: {
          get: function () {
            return this.active === 0
          },
          set: function (val) {
            this.todos.forEach((todo) => {
              todo.done = val
            })
          },
        },
      },
      methods: {
        addTodo() {
          this.todos.push({
            title: this.title,
            done: false,
          })
          this.title = ''
        },
      },
    }
    // 启动应用
    Vue.createApp(App).mount('#app')
  </script>

  <style>
    .done {
      color: gray;
      text-decoration: line-through;
    }
  </style>
</body>

image.png

image.png

条件渲染 清理按钮

<button v-if="active<all" @click="clear">清理</button>
<script>
methods:{
clear(){
this.todos = this.todos.filter(v=>!v.done)
}
}
</script>

image.png

点击清理🧹后

image.png

如果全部都清理 展示暂无数据

    <ul v-if="todos.length">
      <li v-for="todo in todos">
        <input type="checkbox" v-model="todo.done" />
        <span :class="{done:todo.done}"> {{todo.title}}</span>
      </li>
    </ul>
    <div v-else>暂无数据</div>

image.png

缓存数据 刷新依旧有效

const STORAGE_KEY = 'todos_v1'

      mounted() {
        try {
          const saved = localStorage.getItem(STORAGE_KEY)
          if (saved) this.todos = JSON.parse(saved)
        } catch (e) {
          console.warn('restore failed', e)
        }
      },
      watch: {
        todos: {
          handler(list) {
            localStorage.setItem(STORAGE_KEY, JSON.stringify(list))
          },
          deep: true,
        },
      },

image.png

image.png

NestJS 在 2025 年:对于后端开发者仍然值得吗 😕😕😕

作者 Moment
2025年10月21日 14:59

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

在 2025 年,在日益繁多的 JavaScript 后端框架中,NestJS 仍然是企业级应用开发中无可争议的佼佼者。自 2017 年首次发布以来,这个基于 Node.js 的框架不仅经受住了 Express、Koa 等前辈的冲击,也成功应对 Fastify、Adonis 等新兴框架的挑战。 它已经在 GitHub 上获得超过 60k 的星标,跻身全球前五大后端框架之列。

1. 架构理念:从“混乱自由”到“结构优雅”

NestJS 的核心竞争力在于,它为 Node.js 后端开发中“架构失控”这一常见问题提供了一套完整的解决方案。 在早期的 Express 中,虽然灵活,但缺乏内建架构标准,导致团队协作时代码风格各异。

示例:Express 项目中的常见混乱写法:

app.post("/login", (req, res) => {
  const { username, password } = req.body;
  db.query(
    "SELECT * FROM users WHERE username=?",
    [username],
    (err, result) => {
      if (err) return res.status(500).send("DB error");
      if (!result[0]) return res.status(401).send("User not found");
      if (result[0].password !== password)
        return res.status(401).send("Wrong password");

      const token = jwt.sign({ id: result[0].id }, "secret");
      res.send({ token });
    }
  );
});

NestJS 模块化 + 依赖注入的规范写法:

@Module({
  controllers: [UserController],
  providers: [UserService, AuthService],
})
export class UserModule {}

@Controller("users")
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post("login")
  async login(@Body() loginDto: LoginDto) {
    return this.userService.validateUser(loginDto);
  }
}

@Injectable()
export class UserService {
  constructor(
    private readonly authService: AuthService,
    @InjectRepository(User)
    private readonly userRepo: Repository<User>
  ) {}

  async validateUser(loginDto: LoginDto) {
    const user = await this.userRepo.findOneBy({ username: loginDto.username });
    if (!user) throw new UnauthorizedException("User not found");
    if (!(await bcrypt.compare(loginDto.password, user.password))) {
      throw new UnauthorizedException("Wrong password");
    }
    return this.authService.generateToken(user);
  }
}

根据 2024 年官方开发者调查,NestJS 项目在代码可维护性方面提升超过 40%,新成员上手时间平均减少 50%

2. 深度 TypeScript 集成:类型安全的终极解

在 2025 年,TypeScript 已成为企业级开发的标准。 NestJS 从一开始就以 TypeScript 为核心设计,而非后期改造。

2.1 自动类型推断

@Get()
findAll(@Query() query: { page: number; limit: number }) {
  // query.page 自动识别为 number 类型
}

2.2 装饰器元数据

export class CreateUserDto {
  @IsString()
  @MinLength(3)
  username: string;

  @IsEmail()
  email: string;
}

2.3 依赖注入的类型绑定

constructor(private readonly userService: ProductService) {
  // 类型不匹配时直接编译错误
}

与 Express + TS 组合相比,NestJS 能减少 35% 的样板代码,并降低 60% 的类型相关错误

3. 生态系统:一站式企业级解决方案

NestJS 的生态堪称 Node.js 后端的“瑞士军刀”,几乎涵盖数据库、认证、文档、微服务等各个层面。

3.1 数据库集成

@Injectable()
export class PostService {
  constructor(private readonly prisma: PrismaService) {}
  async getPost(id: number) {
    return this.prisma.post.findUnique({
      where: { id },
      include: { author: true },
    });
  }
}

支持:

  • TypeORM(官方推荐)
  • Prisma(2024 年加入官方适配器)
  • Mongoose(封装最佳实践)

3.2 身份验证与授权系统

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get("JWT_SECRET"),
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}

支持:

  • PassportModule(JWT、OAuth2、Local 等策略)
  • CASL 等权限控制框架

3.3 自动 API 文档生成

@ApiOperation({ summary: 'Create user' })
@ApiResponse({ status: 201, description: 'User created successfully' })
@Post()
create(@Body() createUserDto: CreateUserDto) {
  return this.usersService.create(createUserDto);
}

使用 SwaggerModule 可自动生成 OpenAPI 文档。

3.4 微服务与消息队列

@MessagePattern('user_created')
handleUserCreated(data: User) {
  console.log('New user:', data);
  return { status: 'received' };
}

内建支持 RabbitMQ、Kafka、Redis 等。 平均可节省 30% 配置时间。(来源:[Leapcell][1])

4. 与其他框架的核心差异

框架 适用场景 相比 NestJS 的缺点
Express 小项目、快速原型 无架构约束、TypeScript 支持弱
Fastify 极致性能要求 企业级功能需依赖第三方库
AdonisJS 全栈开发 社区体量小,微服务支持较弱
Koa 中间件灵活性强 架构松散,缺乏现代特性支持

在 2024 年性能测试中:

  • 单实例 QPS:NestJS ≈ 8,500,Express ≈ 9,200(仅差约 8%)
  • 内存占用低于 Spring Boot 的 1/5
  • 集群与水平扩展性能几乎线性增长

5. 企业级应用案例

全球顶级公司纷纷采用 NestJS:

  • Autodesk:12 个产品后端重构,每日处理超十亿请求
  • Adidas:电商核心服务采用 NestJS 微服务架构
  • Roche:医学数据分析后端,保证类型安全
  • Netflix:部分边缘服务采用 NestJS
  • 腾讯云、字节跳动:Serverless 与教育业务后端推荐框架

这些案例表明,NestJS 能支撑从初创到大型企业的全生命周期需求。

6. 2025 年选择 NestJS 的十大理由

  1. 长期支持(维护至 2030 年)

  2. 持续迭代(v10 原生支持 Prisma、GraphQL Federation 2.0)

  3. AI 模块适配

    @Injectable()
    export class AiService {
      constructor(private readonly aiClient: AiClient) {}
      async generateSummary(text: string) {
        return this.aiClient.complete({
          model: "gpt-4",
          prompt: `Summarize: ${text}`,
        });
      }
    }
    
  4. 云原生友好(K8s、Serverless、Docker)

  5. 丰富学习资源(500+ 官方课程)

  6. 人才市场增长(LinkedIn 职位年增长 45%)

  7. 渐进式迁移(Express 兼容适配)

  8. 测试体验优异(内建 Jest)

  9. GraphQL 双模式支持(code-first / schema-first)

  10. 活跃社区(npm 周下载 300 万+)

7. 未来展望:NestJS 的下一个五年

根据 2024 年开发者大会路线图:

  • 2025 年:Server Components 支持
  • 2026 年:原生 WebAssembly 提升性能
  • 2027 年:AI 辅助开发工具链

这些计划表明,NestJS 不仅满足当前需求,还在积极布局未来。

8. 结语

在快速变化的 JavaScript 世界中,NestJS 的持久热度绝非偶然。 它通过结构化架构、TypeScript 深度集成、丰富生态与企业级基因,成为 2025 年最值得信赖的后端框架之一。

无论是初创项目的 MVP 开发,还是大型企业的系统重构,选择 NestJS,意味着选择一种成熟、可持续的后端开发方式。 它或许会成为你职业生涯中用得最久的框架之一。

KuiklyUI利用Kotlin Lambda函数实现声明式UI系统的深入分析

作者 风冷
2025年10月21日 09:59

KuiklyUI利用Kotlin Lambda函数实现声明式UI系统的深入分析

KuiklyUI通过巧妙地利用Kotlin的lambda函数特性,构建了一套灵活、高效的声明式UI系统。本文将深入分析其实现机制和核心技术点。

一、Lambda函数在声明式UI中的核心应用

1. 接收器作用域函数的巧妙运用

KuiklyUI的声明式语法核心基于Kotlin的接收器作用域函数。在按钮组件ButtonView中,我们可以看到典型的实现:

class ButtonView : ComposeView<ButtonAttr, ButtonEvent>() {
    // ...
    override fun attr(init: ButtonAttr.() -> Unit) {
        super.attr(init)
        attr.highlightBackgroundColor?.also {
            // 处理按钮按下高亮效果
        }
    }
}

class ButtonAttr : ComposeAttr() {
    // ...
    fun titleAttr(init:TextAttr.()->Unit) {
        titleAttrInit = init
    }
    fun imageAttr(init: ImageAttr.() -> Unit) {
        imageAttrInit = init
        // ...
    }
}

fun ViewContainer<*, *>.Button(init: ButtonView.() -> Unit) {
    addChild(ButtonView(), init)
}

这里使用了扩展函数带接收者的lambda语法,使开发者可以在lambda体内直接调用接收者对象的方法和属性,而无需显式引用接收者。这是实现声明式语法的关键机制。

2. 类型安全的属性设置系统

KuiklyUI通过泛型和具体化类型参数实现了类型安全的属性设置:

abstract class AbstractBaseView<A : Attr, E : Event> : BaseObject(), IViewPublicApi<A, E> {
    // ...
    protected val attr: A by lazy(LazyThreadSafetyMode.NONE) {
        internalCreateAttr()
    }
    // ...
    abstract fun createAttr(): A
    abstract fun createEvent(): E
    // ...
}

interface IViewPublicApi<A : Attr, E : Event> {
    // ...
    fun attr(init: A.() -> Unit)
    fun getViewAttr(): A
    // ...
}

通过泛型约束,KuiklyUI确保每个视图只能设置其对应类型的属性,提供了编译时类型检查,避免了运行时错误。

二、组件树构建与布局系统

1. 基于lambda的组件树声明

KuiklyUI使用lambda函数来声明组件树结构。以ButtonView为例,其内部结构通过body()方法返回的ViewBuilder定义:

override fun body(): ViewBuilder {
    val ctx = this
    return {
        attr {
            justifyContentCenter()
            alignItemsCenter()
        }
        // 高亮背景view
        ctx.attr.highlightBackgroundColor?.also { color ->
            vif({ctx.highlightViewBgColor != Color.TRANSPARENT}) {
                View { /* ... */ }
            }
        }
        // 图片和文本子组件
        ctx.attr.imageAttrInit?.also { imageAttr ->
            Image { attr(imageAttr) }
        }
        ctx.attr.titleAttrInit?.also { textAttr ->
            Text { /* ... */ }
        }
    }
}

这种方式允许开发者以声明式的方式描述UI结构,而不是命令式地构建它。

2. 容器组件的addChild机制

组件树的构建核心在于ViewContaineraddChild方法,这在Button的扩展函数中可以看到:

fun ViewContainer<*, *>.Button(init: ButtonView.() -> Unit) {
    addChild(ButtonView(), init)
}

这种扩展函数模式使得开发者可以以流畅的方式构建组件树:

Container {
    Button {
        titleAttr { text("Click me") }
        event {
            touchDown { /* 处理按下事件 */ }
            touchUp { /* 处理抬起事件 */ }
        }
    }
}

三、响应式更新机制

1. 可观察属性系统

KuiklyUI通过自定义的响应式系统来实现数据与视图的绑定。在ButtonView中,我们可以看到使用observable委托的示例:

class ButtonView : ComposeView<ButtonAttr, ButtonEvent>() {
    private var highlightViewBgColor by observable(Color.TRANSPARENT)
    // ...
}

class ButtonAttr : ComposeAttr() {
    // ...
    var foregroundPercent by observable(0f)
}

当被observable委托的属性值发生变化时,UI会自动更新,而无需手动操作DOM。

2. 事件处理的lambda化

KuiklyUI也将事件处理lambda化,使事件处理更加直观:

class ButtonEvent : ComposeEvent() {
    private val touchDownHandlers = fastArrayListOf<TouchEventHandlerFn>()
    private val touchUpHandlers = fastArrayListOf<TouchEventHandlerFn>()
    
    fun touchDown(handler: TouchEventHandlerFn) {
        if (touchDownHandlers.isEmpty()) {
            register(EventName.TOUCH_DOWN.value) { /* ... */ }
        }
        touchDownHandlers.add(handler)
    }
    
    fun touchUp(handler: TouchEventHandlerFn) { /* 类似实现 */ }
    fun touchMove(handler: TouchEventHandlerFn) { /* 类似实现 */ }
}

这种方式使事件处理代码与UI声明代码紧密结合,提高了代码的可读性和可维护性。

四、两种DSL实现方式的对比

1. 自定义DSL实现

KuiklyUI的自定义DSL是基于其核心组件体系构建的:

  • 通过扩展函数为容器组件添加子组件创建能力
  • 使用带接收者的lambda函数进行属性设置
  • 基于observable委托实现响应式更新

这种实现方式更加轻量,与KuiklyUI的核心渲染系统结合更紧密。

2. Compose DSL实现

同时,KuiklyUI也提供了基于Jetpack Compose的DSL支持:

  • 通过ComposeContainer作为Compose内容的容器
  • 使用@Composable注解的函数构建UI
  • 利用Compose的状态管理系统(如remembermutableStateOf

Compose DSL实现提供了与Android Compose一致的开发体验,对于熟悉Compose的开发者更加友好。

五、lambda函数实现声明式UI的技术优势

  1. 代码简洁性:lambda函数消除了样板代码,使UI声明更加简洁明了
  2. 类型安全:通过泛型和类型约束,在编译时捕获错误
  3. 函数式编程:促进了不可变数据和单向数据流的应用
  4. 高度可组合:小而专注的组件可以轻松组合成复杂UI
  5. 响应式更新:数据变化自动反映到UI上,无需手动操作

六、代码优化建议

  1. 避免深层嵌套lambda:过深的lambda嵌套会降低代码可读性,建议拆分为多个小型、专注的组件
// 优化前
Container {
    Column {
        Row {
            // 深层嵌套的UI结构
        }
    }
}

// 优化后
Container {
    UserProfileSection()
}

fun ViewContainer<*, *>.UserProfileSection() {
    Column { /* ... */ }
}
  1. 使用remember优化状态管理:在Compose DSL中,对于计算成本高的状态,使用remember避免不必要的重复计算

  2. 合理使用vif指令:条件渲染应避免过于复杂的逻辑判断,保持UI声明的清晰性

  3. 组件化设计:将复杂UI拆分为可复用的小组件,提高代码可维护性

总结

KuiklyUI通过巧妙利用Kotlin的lambda函数特性,特别是带接收者的lambda扩展函数,构建了一套既强大又易用的声明式UI系统。它提供了两种DSL实现方式,满足不同开发者的需求,并通过响应式系统确保了UI与数据的同步。这种设计使开发者能够以更加声明式、组合式和类型安全的方式构建跨平台UI界面。

10 个能帮你节省大量开发时间的低估 Flutter 组件

作者 JarvanMo
2025年10月21日 09:13

当我们开始接触 Flutter 时,大多数人都会紧紧抓住那些经典组件ContainerRowColumn,也许还有 ListView。但是 Flutter 的组件目录非常庞大,许多强大的组件却经常被人们忽视。

欢迎微信公众号:OpenFlutter,感谢

在这篇文章中,我将带你了解 10 个被低估的 Flutter 组件,它们能够显著加快你的工作流程减少样板代码,并让你的应用更加精致。我会保持讲解友好且实用,并提供你可以直接粘贴到代码中的小示例。


1. FractionallySizedBox

你是否曾在响应式布局中挣扎过,想要让一个组件占据例如 70% 的宽度?这个组件就能帮你解决这个问题。

FractionallySizedBox(
  widthFactor: 0.7,
  child: ElevatedButton(
    onPressed: () {},
    child: Text("70% Width Button"),
  ),
)

取代处理 MediaQuery 和手动计算的麻烦,这个组件可以干净利落地完成这一工作。


2. AspectRatio

非常适合保持一致的比例(例如视频、卡片或图片)。

AspectRatio(
  aspectRatio: 16 / 9,
  child: Container(
    color: Colors.blue,
    child: Center(child: Text("16:9 Box")),
  ),
)

不必再在已经知道宽度的情况下去猜测高度了。


3. ClipRRect

轻松实现圆角——无需在每个地方都手动设置 borderRadius

ClipRRect(
  borderRadius: BorderRadius.circular(20),
  child: Image.network("https://via.placeholder.com/150"),
)

这是美化你的 UI 最简单的方法之一。


4. LimitedBox

你是否遇到过 ListView 的子组件因为没有给定约束而无限增长的情况? LimitedBox 可以帮你控制住局面。

ListView(
  children: [
    LimitedBox(
      maxHeight: 200,
      child: Container(color: Colors.red),
    ),
  ],
)

尤其在混合使用可滚动(scrollable)和不可滚动(non-scrollable)组件时非常方便。


5. Expanded + Spacer

是的,你可能知道 Expanded,但你是否使用过 Spacer?它是一种干净利落地添加灵活空白空间的方法。

Row(
  children: [
    Text("Left"),
    Spacer(),
    Text("Right"),
  ],
)

比添加一个 Expanded(child: Container()) 更简洁。


6. Wrap

厌倦了 RowColumn 中组件溢出的问题? Wrap 能让项目自动换到下一行

Wrap(
  spacing: 8,
  runSpacing: 4,
  children: List.generate(10, (i) => Chip(label: Text("Item $i"))),
)

当文本或**标签(chips)**无法容纳时,你的 UI 会感谢你使用了它。


7. ReorderableListView

需要拖放重新排序功能?Flutter 已经直接为你提供了这个组件。

ReorderableListView(
  onReorder: (oldIndex, newIndex) {},
  children: List.generate(
    5,
    (i) => ListTile(
      key: ValueKey(i),
      title: Text("Item $i"),
    ),
  ),
)

它可以让你避免重复发明轮子,无需编写自定义的拖动逻辑。


8. AnimatedSwitcher

想要在切换组件时实现平滑的过渡效果吗?试试这个。

int _count = 0; 

AnimatedSwitcher(
  duration: Duration(milliseconds: 300),
  child: Text(
    '$_count',
    key: ValueKey(_count),
    style: TextStyle(fontSize: 30),
  ),
)

每当 _count 改变时,组件就会漂亮地淡入淡出


9. DraggableScrollableSheet

想象一下 Google 地图中那个可以展开的底部工作表(bottom sheet)——这就是这个组件。

DraggableScrollableSheet(
  builder: (context, controller) {
    return Container(
      color: Colors.white,
      child: ListView.builder(
        controller: controller,
        itemCount: 30,
        itemBuilder: (_, i) => ListTile(title: Text("Item $i")),
      ),
    );
  },
)

它以最少的代码添加了流畅的用户体验(slick UX)


10. FittedBox

告别文本或图标溢出——这个组件会自动缩小它们。

FittedBox(
  child: Text(
    "This is a very very very long text",
    style: TextStyle(fontSize: 30),
  ),
)

在为多种屏幕尺寸进行设计时,它简直是救星

Flutter 的魅力在于其组件生态系统。一旦你探索了基础组件之外的世界,你就会发现一些感觉像是“秘密武器”的组件——它们能让你用更少的精力,打造出更具响应性、交互性和优雅性的 UI。

下次当你为布局或动画绞尽脑汁时,不妨试试这些被低估的瑰宝之一。它们很可能会为你节省数小时的开发时间。


👉 现在轮到你了:这些组件中,你最常用的是哪一个?哪一个又让你感到惊喜呢?

公司前端项目ESLint规则集统一化

作者 去伪存真
2025年10月21日 09:00

背景

公司前端目前有 30 多个项目,其中一部分采用 React 技术栈,另一部分采用 Vue 技术栈。由于这些项目最初由不同的团队成员分别创建,导致即便是同一技术栈,不同项目的 ESLint 规则配置差异比较大。

比如:某些 React 项目的 ESLint 配置规则非常严格,包含大量规则集;而另一些 React 项目仅配置了最基础的规则,只是为了应付公司检查。于是就出现了这样的情况:

  • 在 A 项目提交代码时,husky 触发的 ESLint 检查很宽松,几乎不用改多少代码。
  • 在 B 项目提交代码时,ESLint 检查非常严格,往往需要花很长时间修改。

这种差异让部分成员觉得缺乏公平性:同样写代码,有人几乎没成本,有人却要反复调整。为了兼顾 公平性和代码质量的有效检查,有必要在公司内部统一 ESLint 配置规则。

那么问题来了:如何统一? 思路并不唯一,本文采用的方案是:针对 Vue 和 React 项目分别定制一套 ESLint 规则集,并发布到公司私有 npm 仓库,在各项目中统一安装和使用。下面我们分步骤介绍这一方案的实现

第一步 创建私有npm仓库

一说起创建私有npm仓库, 一搜技术文章,几乎清一色的都会说用Verdaccio实现。其实gitlab也能做。这里有极狐gitlab为例,说一下如何创建公司私有的npm仓库。在要开发和使用公司私有npm包的项目下, 新建一个.npmrc文件, 配置这两行内容,这两行内容的含义是,分别指明去哪下载/发布 和 怎么鉴权。

## 指定 @{公司英文名} 作用域的仓库地址
@{公司名}:registry=https://{gitlab仓库域名}/api/v4/projects/project/{项目id}/packages/npm/

# 使用 token 自动认证
//{gitlab仓库域名}/api/v4/projects/{项目id}/packages/npm/:_authToken={gitlab中配置的个人访问凭证}
  • 第一行:仓库地址
    指定当安装/发布 @公司名/* 的包时,不去默认的 npmjs.org,而是去 GitLab 中对应项目下的 npm Registry。
  • 第二行:鉴权信息
    这里的写法只能是 //host/path,而不能写成 https://,因为 .npmrc 的语法要求协议在 registry 中定义,鉴权部分只写 //... 即可。

配置完成后,执行 npm publishpnpm add 时,就会自动完成鉴权。

接下来,可以写一个简单的 npm 包进行验证,确认上传和安装都正常。

npm测试包目录结构如下:

image.png

  • .npmrc里面配置的内容就是上面的两行
  • index.js中写了一个简单的hello,world导出函数
  • package.json中定义了包名和入口文件
{
  "name": "@juejin/my-test-package",
  "version": "0.0.1",
  "description": "A simple test npm package",
  "main": "index.js",
  "scripts": {
    "test": "node index.js"
  },
  "author": "Your Name",
  "license": "MIT"
}

执行npm publish之后,去极狐Gitlab特定仓库的部署==>软件包库菜单查看,可以看到包已经成功上传。

image.png

然后把.npmrc的配置添加到业务项目的.npmrc文件中,在业务项目下执行

pnpm add @xxx/my-test-package

在node_modules文件夹下查看, 可以发现正常安装。

第二步 制作ESLint规则包

首先ESLint该选择ESLint8还是ESLint9呢?

ESLint v8

  • 稳定性:目前大多数项目(特别是 Vue CLI、Create React App、Vite 脚手架)默认还是用 v8,生态支持最好。
  • 插件兼容性:几乎所有插件(eslint-plugin-vueeslint-plugin-react@typescript-eslint 等)都第一时间支持 v8。
  • 配置方式:传统的 .eslintrc.* 文件(JSON/JS/YAML)为主,比较熟悉。
  • 生命周期:虽然 v9 已经发布,但 v8 仍然会维护一段时间(安全补丁)。

适合:已有项目、依赖多、团队追求稳定性。

ESLint v9

  • 新配置系统:完全切换到 Flat Config,用 eslint.config.js 代替 .eslintrc.*,更清晰、支持按文件匹配配置。
  • 内置配置:提供 @eslint/js,替代 eslint:recommended
  • 更现代的设计:从根本上解决了 .eslintrc 扩展层层嵌套、难以管理的问题。
  • 生态迁移中:多数插件已支持 v9,但仍有一些插件和脚手架没完全更新。比如老项目如果依赖某些工具链,可能会遇到兼容性问题。

适合:新项目、想尝鲜或愿意跟进最新标准的团队。

考虑了一下,还是要着眼未来, 选择ESLint9,好处是配置更清晰、未来趋势(避免未来二次迁移),而且最新的eslint v9.34.0版本, 支持多线程linting, 执行速度比较快。对同一项目, 使用单线程,和开启2核和4核的平均耗时分别是

  • 单线程: 45.234 s ± 1.123 s
  • 多线程(2核): 24.567 s ± 0.891 s
  • 多线程(4核): 15.123 s ± 0.567 s
Benchmark 1: npx eslint src/ --concurrency off
  Time (mean ± σ):     45.234 s ±  1.123 s    [User: 44.1 s, System: 1.2 s]
  Range (min … max):   43.891 s … 47.234 s    10 runs

Benchmark 2: npx eslint src/ --concurrency 2  
  Time (mean ± σ):     24.567 s ±  0.891 s    [User: 47.2 s, System: 1.8 s]
  Range (min … max):   23.234 s … 25.987 s    10 runs

Benchmark 3: npx eslint src/ --concurrency 4
  Time (mean ± σ):     15.123 s ±  0.567 s    [User: 58.4 s, System: 2.1 s]
  Range (min … max):   14.234 s … 16.123 s    10 runs

因为项目有两种技术栈, 所以对每种技术栈, 各自搜集一套ESLint规则

React版:

选择一些社区与生态认可度比较高的规则集

  • eslint:recommended / @eslint/js
    → ESLint 官方团队提供,保证基础语法错误都能被检测(未定义变量、重复定义等)。
    📊 NPM 下载量:超过 每周 3000 万,几乎所有项目必备。
  • @typescript-eslint/recommended
    → TypeScript 官方维护的 ESLint 插件,专门覆盖 TS 类型相关的最佳实践。
    📊 NPM 下载量:每周 1500 万+ ,在所有 TS 项目中广泛使用。
  • plugin:react/recommended
    → 由 React 官方团队成员维护(eslint-plugin-react),包含 propTypes、JSX 语法错误等检测。
    📊 下载量:每周 1700 万+ 。几乎所有 React 项目都会装。
  • plugin:react-hooks/recommended
    → Facebook React 团队自己写的规则集,强制遵守 Hooks 规则(useEffect 依赖完整性、Hook 调用顺序)。
    📊 React 官方文档明确要求安装这个插件。
  • plugin:import/recommended
    → 解决 import/export 顺序、模块未解析的问题,保证跨项目依赖更稳健。
    📊 在 Node.js + React 项目里极常见。
  • plugin:prettier/recommended
    → 结合 Prettier 格式化,避免 ESLint 与 Prettier 冲突。社区里几乎是 标配

这些插件和规则集由 React 官方 或 业界主流公司维护的,不是个人爱好,所以可靠性、流行度都比较高。

import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import importPlugin from "eslint-plugin-import";
import prettier from "eslint-plugin-prettier";

/**
 * 配置全局变量
 */
const browserGlobalsConfig = {
  languageOptions: {
    globals: {
      ...globals.browser,

      /** 追加一些其他自定义全局规则 */
      wx: true
    }
  }
};

// TypeScript 文件配置
const tsFiles = {
  files: ["src/**/*.{ts,tsx}"],
  languageOptions: {
    parser: tseslint.parser,
    parserOptions: {
      ecmaVersion: 2024,
      sourceType: "module",
      ecmaFeatures: { jsx: true }
    }
  },
  rules: {
    "no-undef": "off"
  }
};

// React 配置
const reactConfig = {
  files: ["src/**/*.tsx", "src/**/*.jsx"],
  plugins: { react, "react-hooks": reactHooks }, // 应用全局规则,并覆盖特定的规则
  rules: {
    ...reactHooks.configs.recommended.rules,
    ...react.configs.recommended.rules,
    "react/react-in-jsx-scope": "off",
    "no-undef": "off"
  },
  settings: {
    react: { version: "detect" }
  }
};

const importConfig = {
  files: ["src/**/*.{ts,tsx,js,jsx}"],
  plugins: { import: importPlugin },
  rules: {
    ...importPlugin.configs.recommended.rules,
    "@typescript-eslint/no-require-imports": "off"
  },
  settings: {
    "import/resolver": {
      alias: {
        map: [
          ["@", "./src"],
          ["@@", "./src/.umi"],
          ["@@test", "./src/.umi-test"]
        ],
        extensions: [".ts", ".tsx", ".js", ".jsx", ".json", ".less"]
      }
    }
  }
};

const prettierConfig = {
  files: ["src/**/*.{ts,tsx,js,jsx}"],
  plugins: { prettier },
  rules: { "prettier/prettier": "warn" }
};

// 全局规则
const commonRules = {
  rules: {
    "no-unused-vars": "off",
    "react/prop-types": "off",
    "no-useless-escape": "off",
    "react/display-name": "off",
    "react-hooks/exhaustive-deps": "off",
    "@typescript-eslint/no-unused-vars": "off",
    "@typescript-eslint/no-explicit-any": "off",
    "@typescript-eslint/ban-ts-comment": "off"
  }
};

// 导出最终配置
export default [
  // 全局忽略
  {
    ignores: [
      "**/node_modules/**",
      "**/.umi*/**",
      "**/dist/**",
      "**/build/**",
      "scripts/**",
      "config/**",
      "public/**",
      "mock/**",
      "**/*.d.ts"
    ]
  },
  // js推荐配置
  js.configs.recommended,
  // ts推荐配置
  ...tseslint.configs.recommended,
  // 浏览器全局变量
  browserGlobalsConfig,
  tsFiles,
  reactConfig,
  importConfig,
  prettierConfig, // 将通用规则放在最后,确保它能覆盖之前的所有配置
  commonRules
];

Vue3版

1. eslint-plugin-vue: flat/recommended

推荐理由:

  • Vue 官方维护,覆盖最核心的 Vue 3 代码风格、语法规则。
  • 包含对 script setup、组合式 API 的支持。
  • 能帮你避免常见的坑,比如未注册的组件、未使用的 ref、无效的 v-for key 等。

2. @vue/eslint-config-typescript

推荐理由:

  • Vue 官方团队维护,专门适配 TypeScript + Vue 3
  • 内置了 @typescript-eslint,并帮你处理 .vue 文件里 <script lang="ts"> 的解析。
  • 避免了你手动折腾 parser 配置,拿来即用。

3. @vue/eslint-config-prettier/skip-formatting

推荐理由:

  • 禁用掉和 Prettier 冲突的风格类规则(比如缩进、换行)。
  • 避免 ESLint 和 Prettier 打架,让 ESLint 专注语法/逻辑问题,Prettier 专注格式化。
  • 如果你团队里已经统一用 Prettier,就必加。

4. eslint-plugin-import (可选)

推荐理由:

  • 帮你检查 import/export 是否正确。
  • 避免未使用的 import、重复导入、错误的相对路径。
  • 在 Vue3 + Vite 项目里尤其有用,结合 alias (@/) 可以避免路径拼错。
import js from "@eslint/js";
import globals from "globals";
import vuePlugin from "eslint-plugin-vue";
import skipFormatting from "@vue/eslint-config-prettier/skip-formatting";
import { defineConfigWithVueTs, vueTsConfigs } from "@vue/eslint-config-typescript";

/**
 * 配置全局变量
 */
const browserGlobalsConfig = {
  languageOptions: {
    globals: {
      ...globals.browser,

      /** 追加一些其他自定义全局规则 */
      wx: true
    }
  }
};
export default defineConfigWithVueTs(
  js.configs.recommended,
  // TypeScript 推荐规则
  vueTsConfigs.recommended,
  browserGlobalsConfig,
  ...vuePlugin.configs["flat/recommended"],
  {
    files: ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
    languageOptions: {
      parserOptions: {
        parser: "@typescript-eslint/parser"
      }
    },
    rules: {
       "vue/event-name-casing": "off",
      "vue/prop-name-casing": "off",
      "vue/singleline-html-element-content-newline": "off",
      "vue/max-attributes-per-line": "off",
      "vue/multi-word-component-names": "off",
      "vue/html-closing-bracket-newline": [
        "warn",
        {
          singleline: "never",
          multiline: "never"
        }
      ],
      "vue/first-attribute-linebreak": [
        "warn",
        {
          singleline: "ignore",
          multiline: "ignore"
        }
      ],
      "vue/html-self-closing": [
        "error",
        {
          html: { void: "always", normal: "never", component: "always" }
        }
      ]
    }
  },
  skipFormatting
);

规则集ts类型定义文件types.d.ts内容如下:

// 从 eslint 包导入官方的 Config 类型
type EslintFlatConfig = import("eslint").Config;

// 声明一个常量,它的类型是 EslintFlatConfig 类型的数组
declare const createVue3TsEslintConfig: EslintFlatConfig[];

// 将这个常量作为模块的默认导出
export default createVue3TsEslintConfig;

第三步 一键发版

我们采用 pnpm workspace + Changesets 来管理和发布多个 ESLint 配置包。

目录结构大致如下:

repo-root/
├─ package.json            # 根配置(workspace、脚本)
├─ pnpm-workspace.yaml
├─ .npmrc                  # 指定 scope 的 registry + 鉴权(本地或 CI)
├─ .changeset/             # changesets 配置 & 变更文件
└─ npm-packages/
   ├─ eslint-config-vue3/
   │  ├─ package.json
   │  └─ index.js
   └─ eslint-config-react/
      ├─ package.json
      └─ index.js

3.1 根配置

  1. pnpm-workspace.yaml 采用pnpm的workspace方式对多个npm包进行管理。
packages:
  - 'npm-packages/*'
  1. 根 package.json
  • version-packages:根据 changeset 生成版本号并更新依赖与 lockfile
  • release:按依赖拓扑顺序构建并发布所有变更包(Changesets 会自动先发被依赖的包)
{
  "name": "eslint-configs-monorepo",
  "private": true,
  "packageManager": "pnpm@10.12.4",
  "scripts": {
    "changeset": "changeset",
    "version-packages": "changeset version && pnpm install --lockfile-only",
    "release": "pnpm -r build --if-present && changeset publish"
  }
}

pnpm -r build --if-present的含义是给每个子包跑build(如果有)

  1. 原则上,根 .npmrc不能把含 token 的 .npmrc 提交到仓库,但考虑到公司的gitlab仓库只能内网访问, 为了使用方便,提交上去也无妨。
@公司名:registry=https://git.example.com/api/v4/projects/项目ID/packages/npm/
//git.example.com/api/v4/projects/项目ID/packages/npm/:_authToken=YOUR_PERSONAL_ACCESS_TOKEN

3.2 包配置

  • "main": "index.mjs"
    包的入口文件路径。当别人 importrequire 这个包时,会默认从这里加载。

  • "types": "types.d.ts"
    TypeScript 类型声明文件路径。用于在编辑器/IDE 中获得类型提示。

  • "files": [ "index.mjs", "types.d.ts", "README.md" ]
    指定 发布到 npm 仓库时包含的文件。这样可以避免把无关文件(如源码、测试文件)上传,保持包体积小。只会发布列出的文件。

  • "dependencies": 运行这些规则,就需要这些依赖, 将所有用的依赖包,都列举出来

  • peerDependencies: 明确工具包的依赖关系, 避免与业务项目的ESLint版本冲突

  • "publishConfig": { "access": "restricted" }
    控制包发布的访问级别: "public" → 可公开发布到 npmjs.org(所有人可安装);"restricted" → 只能发布到私有仓库(如 GitLab npm Registry / npm 企业版),不能上传到公网。在公司私有仓库场景下,通常用 "restricted",避免意外泄露。

1. vue3规则集的package.json配置

npm-packages/eslint-config-vue3/package.json 内容如下:

{
  "name": "@company/eslint-config-vue3",
  "version": "1.7.6",
  "description": "vue3 eslint config",
  "author": "xxx Frontend Team",
  "license": "MIT",
  "main": "index.mjs",
  "types": "types.d.ts",
  "files": [
    "index.mjs",
    "types.d.ts",
    "README.md"
  ],
  "dependencies": {
    "@eslint/js": "^9.34.0",
    "@vue/eslint-config-prettier": "^10.2.0",
    "@vue/eslint-config-typescript": "^14.6.0",
    "eslint-plugin-vue": "^10.4.0",
    "globals": "^16.3.0"
  },
  "peerDependencies": {
    "eslint": ">=9.34.0"
  },
  "keywords": [
    "eslint9+",
    "eslint-config",
    "vue3"
  ],
  "publishConfig": {
    "access": "restricted"
  }
}

2. react规则集package.json

npm-packages/eslint-config-react/package.json的内容如下

{
  "name": "@company/eslint-config-react",
  "version": "1.4.0",
  "main": "index.mjs",
  "types": "types.d.ts",
  "files": [
    "index.mjs",
    "types.d.ts"
  ],
  "license": "MIT",
  "publishConfig": {
    "access": "restricted"
  },
  "dependencies": {
    "@eslint/js": "^9.34.0",
    "eslint-import-resolver-alias": "^1.1.2",
    "eslint-import-resolver-typescript": "^4.4.4",
    "eslint-plugin-import": "^2.32.0",
    "eslint-plugin-prettier": "^5.5.4",
    "eslint-plugin-react": "^7.37.5",
    "eslint-plugin-react-hooks": "^5.2.0",
    "globals": "^16.3.0",
    "prettier": "^3.6.2",
    "typescript-eslint": "^8.42.0"
  },
  "peerDependencies": {
    "eslint": ">=9.34.0"
  }
}

3.3 初始化 Changesets(一次性)

pnpm add -D @changesets/cli
pnpm changeset init

会生成 .changeset/config.json,可以保持默认;如果你希望内部依赖只要上游变更就给下游自动打补丁号,可加入:updateInternalDependencies: "patch":当 eslint-config-base 升级时,eslint-config-react 也会自动至少 bump 一个 patch,避免依赖范围不匹配。

{
  "$schema": "https://unpkg.com/@changesets/config/schema.json",
  "changelog": "@changesets/changelog-github",
  "commit": false,
  "linked": [],
  "access": "restricted",
  "baseBranch": "main",
  "updateInternalDependencies": "patch"
}

3.4.写变更(每次发版)

当你修改了包后,创建一个 changeset:

pnpm changeset

交互式选择受影响的包(先按空格再按回车),选择变更等级(patch/minor/major),写一句描述。

3.5 一键发布

  • version-packages:会把两个包的 version 改掉,并把 workspace:^ 解析为实际 semver。

  • releasechangeset publish):自动安装依赖顺序排序,成功后会打印每个包的发布结果。

# 生成新版本号 & 更新依赖与 lockfile
pnpm version-packages

# 构建并发布(会按顺序先发 base 后发 react)
pnpm release

单独指定子包发布的命令是:

cd npm-packages/eslint-config-vue3
pnpm publish --access public --registry=https://gitlab.xxx.com/api/v4/projects/xxx/packages/npm/

cd ../eslint-config-react
pnpm publish --access public --registry=https://gitlab.xxx.com/api/v4/projects/xxx/packages/npm/

第四步 安装使用

1. 配置项目使用私有仓库

在你要使用这些 ESLint 配置的项目根目录下创建或修改 .npmrc 文件:

@公司名:registry=https://git.example.com/api/v4/projects/项目ID/packages/npm/
//git.example.com/api/v4/projects/项目ID/packages/npm/:_authToken=YOUR_PERSONAL_ACCESS_TOKEN

这样 npm / pnpm 就会从你的 GitLab 私有仓库拉取 @公司名 作用域下的包。

2. 安装 ESLint 规则包

假设你已经发布了:

  • @公司名/eslint-config-vue3
  • @公司名/eslint-config-react

要配置一个 Vue3 项目规则集, 在项目中执行:

pnpm add -D @公司名/eslint-config-vue3

3. 配置 ESLint 使用这些规则集

在你的项目根目录下 eslint.config.ts 中:

import vue3ESLintRules from "@公司名/eslint-config-vue3";

export default [
  ...vue3ESLintRules,
  {
    rules: {
      "vue/no-v-html": "off",
      "vue/no-unused-vars": "off",
      "@typescript-eslint/no-unused-vars": "off",
      "@typescript-eslint/no-explicit-any": "off",
      "vue/singleline-html-element-content-newline": "off",
      "vue/html-self-closing": [
        "error",
        {
          html: { void: "always", normal: "never", component: "always" }
        }
      ]
    }
  }
];


如果是 React 项目,使用 @公司名/eslint-config-react,配置方法类似。

最后

文章末尾,对本文做一下总结,本文介绍了一种在公司内统一 ESLint 规则的完整方案:

  1. 使用 GitLab npm Registry 搭建私有仓库。
  2. 基于 ESLint v9,分别为 React 和 Vue3 项目设计规则集。
  3. 借助 pnpm workspace + Changesets,实现规则包的管理和一键发布。
  4. 在业务项目中通过安装和引入统一规则包,最终实现公司内部的 ESLint 配置统一。

这样一来,不同团队成员在不同项目中都能遵循相同的规则,不仅避免了“不公平”的问题,也能整体提升公司代码质量和一致性。如果你有同样的业务需求,可以参考一下本文的技术思路和实现方案。本文完

这5个DOM操作技巧让你的网站活起来

2025年10月21日 07:44

你是不是经常遇到这样的情况:精心设计的页面看起来很美,但用户操作起来却毫无反应?点击按钮没反馈,表单提交没提示,页面切换生硬得像在翻纸质书?

这就像给用户端上了一盘色香味俱全的菜,结果吃起来却发现是冷的。问题就出在——你还没有掌握DOM操作的真正精髓。

今天,我就带你彻底搞懂JavaScript DOM操作,从基础到实战,让你的网页真正“活”起来。读完这篇文章,你不仅能理解DOM的工作原理,还能掌握5个让用户体验飙升的实用技巧。

什么是DOM?它为什么如此重要?

简单来说,DOM就是连接HTML和JavaScript的桥梁。当浏览器加载网页时,它会将HTML文档解析成一个树状结构,这就是DOM树。

想象一下,你的HTML代码是建筑设计图,而DOM就是按照这个图纸建造出来的真实大楼。JavaScript就是大楼的管理系统,通过DOM来操控大楼里的每一个房间、每一扇门窗。

// 举个例子:获取页面上的一个按钮
const button = document.getElementById('myButton');

// 给按钮添加点击事件
button.addEventListener('click', function() {
    alert('按钮被点击了!');
});

这段代码做了什么呢?首先通过getElementById找到了ID为'myButton'的按钮,然后给它添加了一个点击事件监听器。当用户点击按钮时,就会弹出提示框。

DOM操作的核心四步法

想要熟练操作DOM,记住这四个步骤就够了:

第一步:找到你要操作的元素 就像你要整理房间,得先知道要整理哪里一样。

// 通过ID查找
const header = document.getElementById('header');

// 通过类名查找(返回的是数组)
const buttons = document.getElementsByClassName('btn');

// 通过标签名查找
const paragraphs = document.getElementsByTagName('p');

// 现代推荐的方式:querySelector
const mainTitle = document.querySelector('.main-title');
const allButtons = document.querySelectorAll('.btn');

第二步:理解你要操作什么 找到元素后,你可以修改它的内容、样式、属性,或者添加事件监听。

const demoElement = document.querySelector('#demo');

// 修改内容
demoElement.textContent = '新的文本内容';
demoElement.innerHTML = '<strong>带格式的内容</strong>';

// 修改样式
demoElement.style.color = 'red';
demoElement.style.fontSize = '20px';

// 修改属性
demoElement.setAttribute('data-info', '重要信息');

第三步:学会创建新元素 有时候我们需要动态添加内容,而不是修改现有的。

// 创建一个新的div元素
const newDiv = document.createElement('div');

// 设置这个div的内容和样式
newDiv.textContent = '我是新创建的元素';
newDiv.className = 'new-element';

// 找到要插入的位置
const container = document.querySelector('.container');

// 将新元素插入到容器中
container.appendChild(newDiv);

第四步:处理用户交互 这是让页面有灵魂的关键——响应用户的操作。

const interactiveBtn = document.querySelector('#interactiveBtn');

interactiveBtn.addEventListener('click', function(event) {
    // 阻止默认行为(如表单提交、链接跳转)
    event.preventDefault();
    
    // 这里是点击后要执行的代码
    this.style.backgroundColor = 'blue';
    this.textContent = '已点击!';
});

5个让用户体验飙升的DOM技巧

现在来到最实用的部分!这些技巧都是我在实际项目中总结出来的,能立即提升你的页面交互体验。

技巧一:智能表单验证

别再让用户填完所有内容才发现有错误。实时验证才是王道。

const emailInput = document.querySelector('#email');

emailInput.addEventListener('input', function() {
    const email = this.value;
    const errorElement = document.querySelector('#emailError');
    
    // 简单的邮箱格式验证
    if (!email.includes('@')) {
        errorElement.textContent = '请输入有效的邮箱地址';
        this.style.borderColor = 'red';
    } else {
        errorElement.textContent = '';
        this.style.borderColor = 'green';
    }
});

技巧二:平滑的页面滚动

突然的跳转会吓到用户,平滑滚动让体验更舒适。

const smoothScrollBtn = document.querySelector('#scrollToTop');

smoothScrollBtn.addEventListener('click', function() {
    window.scrollTo({
        top: 0,
        behavior: 'smooth'  // 关键就在这里!
    });
});

// 或者滚动到特定元素
function scrollToElement(elementId) {
    const element = document.getElementById(elementId);
    element.scrollIntoView({
        behavior: 'smooth',
        block: 'start'
    });
}

技巧三:智能加载更多内容

无限滚动是现代网站的标配,实现起来并不复杂。

let currentPage = 1;
const loadMoreBtn = document.querySelector('#loadMore');
const contentContainer = document.querySelector('#contentContainer');

loadMoreBtn.addEventListener('click', async function() {
    // 显示加载状态
    this.textContent = '加载中...';
    this.disabled = true;
    
    try {
        // 模拟从服务器获取数据
        const newContent = await fetchMoreData(currentPage);
        
        // 创建新内容元素
        const newElement = document.createElement('div');
        newElement.className = 'content-item';
        newElement.innerHTML = newContent;
        
        // 插入到容器中
        contentContainer.appendChild(newElement);
        
        currentPage++;
        this.textContent = '加载更多';
        this.disabled = false;
        
    } catch (error) {
        this.textContent = '加载失败,点击重试';
        this.disabled = false;
    }
});

技巧四:优雅的交互动画

适当的动画能让用户感知到状态变化,提升体验。

const toggleButton = document.querySelector('#togglePanel');
const panel = document.querySelector('#slidePanel');

toggleButton.addEventListener('click', function() {
    // 检查面板当前状态
    const isVisible = panel.classList.contains('visible');
    
    if (isVisible) {
        // 隐藏面板
        panel.style.transform = 'translateX(100%)';
        panel.classList.remove('visible');
    } else {
        // 显示面板
        panel.style.transform = 'translateX(0)';
        panel.classList.add('visible');
    }
});

// CSS中需要对应的过渡效果
// .slide-panel {
//     transition: transform 0.3s ease-in-out;
// }

技巧五:智能搜索提示

帮助用户更快找到他们想要的内容。

const searchInput = document.querySelector('#search');
const suggestionsContainer = document.querySelector('#suggestions');

// 防抖函数,避免频繁请求
function debounce(func, wait) {
    let timeout;
    return function executedFunction(...args) {
        const later = () => {
            clearTimeout(timeout);
            func(...args);
        };
        clearTimeout(timeout);
        timeout = setTimeout(later, wait);
    };
}

searchInput.addEventListener('input', debounce(function() {
    const query = this.value.trim();
    
    if (query.length < 2) {
        suggestionsContainer.innerHTML = '';
        return;
    }
    
    // 获取搜索建议
    fetchSearchSuggestions(query).then(suggestions => {
        suggestionsContainer.innerHTML = '';
        
        suggestions.forEach(suggestion => {
            const suggestionElement = document.createElement('div');
            suggestionElement.className = 'suggestion-item';
            suggestionElement.textContent = suggestion;
            suggestionElement.addEventListener('click', () => {
                searchInput.value = suggestion;
                suggestionsContainer.innerHTML = '';
                // 执行搜索
                performSearch(suggestion);
            });
            
            suggestionsContainer.appendChild(suggestionElement);
        });
    });
}, 300)); // 300毫秒延迟

常见陷阱与性能优化

DOM操作虽然强大,但使用不当会严重影响性能。这里有几个必须注意的点:

避免频繁的重排和重绘 每次修改DOM都可能引发浏览器的重新布局和绘制,频繁操作会导致页面卡顿。

// 不好的做法:频繁修改样式
const element = document.querySelector('.animated');
element.style.left = '100px';
element.style.top = '200px';
element.style.width = '300px';

// 好的做法:一次性修改
element.style.cssText = 'left: 100px; top: 200px; width: 300px;';

// 或者使用class
element.classList.add('new-position');

使用事件委托处理大量元素 当有很多相似元素需要添加事件时,不要在每一个上都绑定监听器。

// 不好的做法
const listItems = document.querySelectorAll('.list-item');
listItems.forEach(item => {
    item.addEventListener('click', handleClick);
});

// 好的做法:事件委托
const list = document.querySelector('.list');
list.addEventListener('click', function(event) {
    if (event.target.classList.contains('list-item')) {
        handleClick(event);
    }
});

合理使用文档片段 当需要添加大量DOM元素时,使用文档片段可以减少重排次数。

// 创建文档片段
const fragment = document.createDocumentFragment();

// 在片段中添加多个元素
for (let i = 0; i < 100; i++) {
    const newItem = document.createElement('div');
    newItem.textContent = `项目 ${i}`;
    fragment.appendChild(newItem);
}

// 一次性添加到DOM中
document.querySelector('.container').appendChild(fragment);

实战:构建一个交互式待办清单

让我们用今天学到的知识,构建一个完整的待办清单应用。

class TodoApp {
    constructor() {
        this.todos = [];
        this.init();
    }
    
    init() {
        this.bindEvents();
        this.loadFromStorage();
        this.render();
    }
    
    bindEvents() {
        const addBtn = document.querySelector('#addTodo');
        const input = document.querySelector('#todoInput');
        
        addBtn.addEventListener('click', () => this.addTodo());
        input.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') this.addTodo();
        });
        
        // 使用事件委托处理动态生成的元素
        document.querySelector('#todoList').addEventListener('click', (e) => {
            if (e.target.classList.contains('delete-btn')) {
                this.deleteTodo(e.target.dataset.id);
            } else if (e.target.classList.contains('toggle-btn')) {
                this.toggleTodo(e.target.dataset.id);
            }
        });
    }
    
    addTodo() {
        const input = document.querySelector('#todoInput');
        const text = input.value.trim();
        
        if (text) {
            const todo = {
                id: Date.now().toString(),
                text: text,
                completed: false,
                createdAt: new Date()
            };
            
            this.todos.push(todo);
            input.value = '';
            this.saveToStorage();
            this.render();
        }
    }
    
    deleteTodo(id) {
        this.todos = this.todos.filter(todo => todo.id !== id);
        this.saveToStorage();
        this.render();
    }
    
    toggleTodo(id) {
        const todo = this.todos.find(todo => todo.id === id);
        if (todo) {
            todo.completed = !todo.completed;
            this.saveToStorage();
            this.render();
        }
    }
    
    render() {
        const list = document.querySelector('#todoList');
        list.innerHTML = '';
        
        this.todos.forEach(todo => {
            const item = document.createElement('li');
            item.className = `todo-item ${todo.completed ? 'completed' : ''}`;
            item.innerHTML = `
                <span class="todo-text">${todo.text}</span>
                <div class="todo-actions">
                    <button class="toggle-btn" data-id="${todo.id}">
                        ${todo.completed ? '取消完成' : '标记完成'}
                    </button>
                    <button class="delete-btn" data-id="${todo.id}">删除</button>
                </div>
            `;
            list.appendChild(item);
        });
        
        this.updateStats();
    }
    
    updateStats() {
        const total = this.todos.length;
        const completed = this.todos.filter(todo => todo.completed).length;
        
        document.querySelector('#todoStats').textContent = 
            `总计: ${total} | 已完成: ${completed} | 未完成: ${total - completed}`;
    }
    
    saveToStorage() {
        localStorage.setItem('todos', JSON.stringify(this.todos));
    }
    
    loadFromStorage() {
        const stored = localStorage.getItem('todos');
        if (stored) {
            this.todos = JSON.parse(stored);
        }
    }
}

// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
    new TodoApp();
});

这个待办清单应用用到了我们今天学的所有重要概念:元素查找、事件处理、动态创建元素、本地存储等等。

进阶之路:下一步该学什么?

掌握了基础DOM操作后,你可以继续深入学习:

现代框架的DOM操作 像React、Vue这样的框架在底层也使用DOM操作,但它们提供了更高效的更新机制。理解原生DOM操作能帮你更好地理解这些框架的工作原理。

性能监控与优化 学习如何使用浏览器开发者工具分析DOM性能,识别瓶颈并进行优化。

Web组件 这是现代Web开发的重要方向,让你能够创建可重用的自定义元素。

动画与交互设计 深入学习CSS动画、Web Animations API,创造更丰富的用户体验。

写在最后

DOM操作就像是给网页注入了灵魂。从静态的展示到动态的交互,从冰冷的代码到有温度的用户体验,这中间的桥梁就是DOM操作。

记住,好的交互设计应该是无形的——用户感觉不到技术的存在,只觉得一切都很自然、流畅。这需要你不仅掌握技术,更要理解用户的心理和需求。

现在,打开你的代码编辑器,开始实践这些技巧吧!试着改造你之前做过的项目,看看能否用今天学到的知识让它们变得更加生动。

如果觉得这篇文章对你有帮助,记得点赞收藏,转发给更多需要的小伙伴~

昨天 — 2025年10月22日掘金 前端

赋能工业 / 商业 / 公共机构:开源 MyEMS,让能源管理 “人人可及”

2025年10月22日 15:31

在 “双碳” 目标推进与能源成本持续攀升的背景下,能源管理已从 “可选动作” 变为工业、商业、公共机构的 “必答题”。然而,传统能源管理系统往往面临 “高成本门槛”“定制化难”“技术依赖强” 三大困境 —— 工业企业需应对多产线能耗数据孤岛,商业场所受限于运营成本难以部署复杂系统,公共机构则因预算有限难以获取专业技术支持。开源能源管理系统 MyEMS 的出现,正以 “零授权成本”“全流程可控”“社区化协作” 的特性,打破能源管理的技术与成本壁垒,让高效能源管理真正 “人人可及”。

一、破局痛点:三大领域的能源管理困境

从车间流水线到商场中央空调,从学校教学楼到医院配电系统,不同场景的能源消耗逻辑差异显著,但核心痛点高度趋同:

  • 工业领域: 多设备、多产线的能耗数据分散在 PLC、传感器等不同终端,传统系统需高额接口费用才能整合数据,且难以根据生产计划动态调整节能策略,导致 “能耗监测滞后、节能方案僵化”;部分中小型制造企业因年授权费超 10 万元,被迫放弃系统化管理,陷入 “浪费难察觉、优化无方向” 的被动局面。
  • 商业领域: 商场、写字楼的空调、照明、电梯能耗占比超 70%,但传统系统多依赖固定阈值控制(如统一设定 26℃空调温度),无法根据人流变化、日照强度动态调节,造成 “高峰时段能耗过载、低谷时段能源空耗”;同时,商业运营方对系统响应速度要求高,传统厂商的定制化周期常达 3-6 个月,难以匹配商业场景的灵活需求。
  • 公共机构: 学校、医院、政务大厅等场景的能源管理预算有限,且多需对接财政监管系统,传统商业系统不仅授权费用高,还存在 “数据不透明、对接难度大” 问题 —— 某县级医院曾因系统无法与地方能耗监管平台兼容,导致半年能耗数据无法上报,影响绿色机构评级。

二、精准赋能:MyEMS 的场景化解决方案

作为专注于能源管理的开源系统,MyEMS 依托 “数据采集 - 分析优化 - 智能控制 - 报表输出” 的全流程功能,为三大领域提供 “低成本适配、高灵活定制” 的解决方案:

  • 工业场景:聚焦 “产耗协同”

MyEMS 支持对接 Modbus、OPC UA 等工业通用协议,无需额外付费即可整合产线设备、电表、水表等终端数据,实时生成 “设备能耗看板”“产线能耗对比图”。针对某中型汽车零部件厂的需求,技术团队基于 MyEMS 开源代码,仅用 2 周就开发出 “生产负荷 - 能耗联动模块”—— 当产线处于半负荷状态时,系统自动下调非核心设备功率,半年内实现能耗降低 13%,节省成本超 20 万元。

  • 商业场景:主打 “动态节能”

在商场场景中,MyEMS 可接入人流统计摄像头、光照传感器数据,构建 “人流密度 - 空调负荷 - 照明亮度” 联动模型。例如,当商场某区域人流低于阈值时,系统自动关闭 1/3 照明回路,并将空调温度上调 1-2℃;通过分析历史数据,还能预测周末、节假日的能耗高峰,提前制定预调节策略。某连锁超市引入 MyEMS 后,门店月度能耗平均下降 8%,空调电费节省尤为显著。

  • 公共机构:侧重 “合规与低成本”

针对公共机构的预算限制,MyEMS 提供 “零授权费 + 轻量化部署” 方案 —— 无需购置昂贵服务器,可依托现有办公电脑搭建基础管理平台;同时,系统内置国家《公共机构能源资源计量器具配备和管理要求》等合规模板,自动生成符合监管要求的能耗报表。某地级市教育局通过 MyEMS 整合辖区 50 所学校的能耗数据,不仅实现数据实时上报,还通过 “错峰用电提醒”“老旧灯具替换建议”,推动年度总能耗降低 9%,且整体部署成本不足万元。

三、开源之力:让 “人人可及” 落地生根

MyEMS 之所以能打破能源管理的壁垒,核心在于 “开源” 模式赋予的三大优势:

  • 成本可及:零门槛入门

与传统系统年均 5-20 万元的授权费不同,MyEMS 的核心代码完全开源,企业、机构可免费下载使用,仅需承担少量技术开发或运维成本。对于技术能力有限的中小组织,社区还提供 “基础部署指南”“常见问题手册”,甚至有志愿者团队提供免费技术咨询,彻底消除 “因成本望而却步” 的问题。

  • 技术可及:全流程可控

开源意味着代码透明 —— 用户可根据自身需求修改功能模块,无需依赖厂商定制。例如,某化工企业为满足防爆区域的特殊要求,基于 MyEMS 代码优化了数据传输加密协议;某高校则在系统中增加 “学生宿舍用电安全预警” 功能,当检测到违规电器接入时自动断电。这种 “自主可控” 的特性,让不同场景的个性化需求得以快速满足。

  • 生态可及:社区化共成长

MyEMS 拥有活跃的开发者社区,涵盖能源管理专家、IT 工程师、企业用户等群体。社区定期举办线上分享会,交流 “工业节能最佳实践”“商业楼宇能耗优化技巧”;用户遇到技术问题时,通常 24 小时内就能获得社区响应。这种 “共享共建” 的生态,让中小机构也能获取顶尖的能源管理经验,避免 “单打独斗” 的技术困境。

四、实践见证:从 “能用” 到 “好用” 的跨越

在江苏某机械制造企业,MyEMS 的落地经历颇具代表性:此前,该企业使用传统系统,每年授权费 15 万元,但仅能实现基础能耗统计;引入 MyEMS 后,技术团队用 1 个月完成代码二次开发,新增 “设备能耗异常预警” 功能 —— 当某台机床能耗突然超出历史均值 15% 时,系统立即推送提醒,帮助企业及时发现并修复了设备故障,避免了因停机造成的 10 万元损失。一年后,企业能耗降低 18%,综合成本节省超 40 万元。

类似的案例正在各地涌现:浙江某商场通过 MyEMS 实现空调能耗动态调节,夏季电费每月减少 3 万元;某县级政务中心依托 MyEMS 完成能耗数据与省级监管平台对接,一次性通过绿色公共机构认证…… 这些实践证明,开源 MyEMS 不仅让能源管理 “能用”,更能 “好用”“管用”。

五、结语:开源模式加速能源管理普惠化

能源管理的本质,是让每一度电、每一方水都用在 “刀刃上”。过去,技术壁垒与成本门槛让许多组织错失了高效管理的机会;而 MyEMS 的开源之路,正以 “去中心化”“低成本”“高灵活” 的特性,将能源管理的主动权交还给用户 —— 无论是千人规模的工厂,还是几十人的社区医院,都能借助开源力量搭建适配自身的系统。

当能源管理不再是 “少数企业的专利”,而是 “人人可及的工具”,不仅能帮助更多组织降本增效,更能汇聚起千万个 “节能单元” 的力量,为 “双碳” 目标的实现注入源源不断的动力。开源 MyEMS 的探索,或许正是能源管理行业从 “精英化” 走向 “普惠化” 的重要一步。

❌
❌