普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月15日首页

企业级全栈项目(14) winston记录所有日志

作者 小胖霞
2025年12月15日 17:41

winston 是 Node.js 生态中最流行的日志库,通常配合 winston-daily-rotate-file 使用,以实现按天切割日志文件(防止一个日志文件无限膨胀到几个GB)。 我们将实现以下目标:

  1. 访问日志:记录所有 HTTP 请求(时间、IP、URL、Method、状态码、耗时)。
  2. 错误日志:记录所有的异常和报错堆栈。
  3. 日志切割:每天自动生成新文件,并自动清理旧日志(如保留30天)。
  4. 分环境处理:开发环境在控制台打印彩色日志,生产环境写入文件。

第一步:安装依赖

npm install winston winston-daily-rotate-file

第二步:封装 Logger 工具类 (src/utils/logger.js)

我们需要创建一个全局单例的 Logger 对象。

import winston from 'winston'
import 'winston-daily-rotate-file'
import path from 'path'

// 定义日志目录
const logDir = 'logs'

// 定义日志格式
const { combine, timestamp, printf, json, colorize } = winston.format

// 自定义控制台打印格式
const consoleFormat = printf(({ level, message, timestamp, ...metadata }) => {
  let msg = `${timestamp} [${level}]: ${message}`
  if (Object.keys(metadata).length > 0) {
    msg += JSON.stringify(metadata)
  }
  return msg
})

// 创建 Logger 实例
const logger = winston.createLogger({
  level: 'info', // 默认日志级别
  format: combine(
    timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
    json() // 文件中存储 JSON 格式,方便后续用 ELK 等工具分析
  ),
  transports: [
    // 1. 错误日志:只记录 error 级别的日志
    new winston.transports.DailyRotateFile({
      dirname: path.join(logDir, 'error'),
      filename: 'error-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      level: 'error',
      zippedArchive: true, // 压缩旧日志
      maxSize: '20m',      // 单个文件最大 20MB
      maxFiles: '30d'      // 保留 30 天
    }),
    
    // 2. 综合日志:记录 info 及以上级别的日志 (包含访问日志)
    new winston.transports.DailyRotateFile({
      dirname: path.join(logDir, 'combined'),
      filename: 'combined-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      zippedArchive: true,
      maxSize: '20m',
      maxFiles: '30d'
    })
  ]
})

// 如果不是生产环境,也在控制台打印,并开启颜色
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: combine(
      colorize(),
      timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
      consoleFormat
    )
  }))
}

export default logger

第三步:编写 HTTP 访问日志中间件 (src/middleware/httpLogger.js)

我们需要一个中间件,像保安一样,记录进出的每一个请求。

import logger from '../utils/logger.js'

export const httpLogger = (req, res, next) => {
  // 1. 记录请求开始时间
  const start = Date.now()

  // 2. 监听响应完成事件 (finish)
  res.on('finish', () => {
    // 计算耗时
    const duration = Date.now() - start
    
    // 获取 IP (兼容 Nginx 代理)
    const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress
    
    // 组装日志信息
    const logInfo = {
      method: req.method,
      url: req.originalUrl,
      status: res.statusCode,
      duration: `${duration}ms`,
      ip: ip,
      userAgent: req.headers['user-agent'] || ''
    }

    // 根据状态码决定日志级别
    if (res.statusCode >= 500) {
      logger.error('HTTP Request Error', logInfo)
    } else if (res.statusCode >= 400) {
      logger.warn('HTTP Client Error', logInfo)
    } else {
      logger.info('HTTP Access', logInfo)
    }
  })

  next()
}

第四步:集成到入口文件 (app.js)

我们需要把 httpLogger 放在所有路由的最前面,把错误记录放在所有路由的最后面

import express from 'express'
import logger from './utils/logger.js'         // 引入 logger
import { httpLogger } from './middleware/httpLogger.js' // 引入中间件
import HttpError from './utils/HttpError.js'

// ... 其他引入 (helmet, cors 等)

const app = express()

// ==========================================
// 1. 挂载访问日志中间件 (必须放在最前面)
// ==========================================
app.use(httpLogger)

// ... 其他中间件 (json, cors, helmet) ...

// ... 你的路由 (routes) ...
// app.use('/api/admin', adminRouter)
// app.use('/api/app', appRouter)


// ==========================================
// 2. 全局错误处理中间件 (必须放在最后)
// ==========================================
app.use((err, req, res, next) => {
  // 记录错误日志到文件
  logger.error(err.message, {
    stack: err.stack, // 记录堆栈信息,方便排查 Bug
    url: req.originalUrl,
    method: req.method,
    ip: req.ip
  })

  // 如果是我们自定义的 HttpError,返回对应的状态码
  if (err instanceof HttpError) {
    return res.status(err.code).json({
      code: err.code,
      message: err.message
    })
  }

  // 其它未知错误,统一报 500
  res.status(500).json({
    code: 500,
    message: '服务器内部错误,请联系管理员'
  })
})

const PORT = 3000
app.listen(PORT, () => {
  logger.info(`Server is running on port ${PORT}`) // 使用 logger 打印启动信息
})

第五步:效果演示

1. 启动项目

nodemon app.js

会发现项目根目录下多了一个 logs 文件夹,里面有 combined 和 error 两个子文件夹。

2. 发起一个正常请求 (GET /api/app/product/list)

  • 控制台:显示绿色的日志 [info]: HTTP Access {"method":"GET", "status": 200 ...}
  • 文件 (logs/combined/combined-2023-xx-xx.log):写入了一行 JSON 记录。

3. 发起一个错误请求 (密码错误 400 或 代码报错 500)

  • 文件 (logs/error/error-2023-xx-xx.log):会自动记录下详细的错误堆栈 stack,这对于排查线上问题至关重要,你再也不用盯着黑乎乎的控制台或者猜测报错原因了。

总结

通过引入 winston:

  1. 自动化:日志自动按天分割,自动压缩,不用担心磁盘写满。
  2. 结构化:日志以 JSON 格式存储,方便以后接入 ELK (Elasticsearch, Logstash, Kibana) 做可视化监控。
  3. 可追溯:任何报错都有时间、堆栈和请求参数,运维和排查效率提升 10 倍。

TinyEngine2.9版本发布:更智能,更灵活,更开放!

2025年12月15日 17:25

前言

TinyEngine 是一款面向未来的低代码引擎底座,致力于为开发者提供高度可定制的技术基础设施——不仅支持可视化页面搭建等核心能力,更可通过 CLI 工程化方式实现深度二次开发,帮助团队快速构建专属的低代码平台。

无论是资源编排、服务端渲染、模型驱动应用,还是移动端、大屏端、复杂页面编排场景,TinyEngine 都能灵活适配,成为你构建低代码体系的坚实基石。

最近我们正式发布 TinyEngine v2.9 版本,带来多项功能升级与体验优化,在增强平台智能化能力的同时,进一步降低配置复杂度,让“定制化”变得更简单、更高效。

本次版本迭代中,我们欣喜地看到越来越多开发者加入开源共建行列。特别感谢@fayching @LLDLLY 等社区伙伴积极参与功能贡献与问题反馈。正是这些点滴汇聚的力量,推动着 TinyEngine 不断前行。我们也诚挚邀请更多热爱技术、追求创新的朋友加入社区,一起打造更强大、更开放的低代码生态。

v2.9.0 变更特性概览

  • 【增强】全新版本AI助手,智能搭建能力升级
  • 【新特性】添加资源管理插件和资源选择配置器
  • 【增强】预览插件支持应用预览
  • 【增强】Tailwindcss支持
  • 【增强】支持静态数据源
  • 【增强】组件物料更新
  • 【增强】MCP工具更新
  • 【其他】功能细节优化与bug修复。

TinyEngine v2.9.0 新特性解读

1. 【增强】全新版本AI助手,智能搭建能力升级(体验版本)

在TinyEngine v2.9版本中,我们对AI搭建页面能力进行全新升级,下面是主要功能的介绍与快速上手:

1)全新 Agent 模式

新增的 Agent 模式支持自然语言或图片生成页面,借助AI大模型强大的能力,让您告别繁琐的手动拖拽,让 AI 辅助开发更加智能、强大。

  • 全新 Agent 智能搭建模式,自然语言描述需求,由AI直接返回页面Schema
  • 画布采用流式渲染,能够实时看到页面生成效果
  • 生成页面后支持继续对话二次修改,使用增量返回修改速度更快 1.gif
  • 支持上传设计图或手绘草图,AI 识别并还原为可编辑的页面(需要先选择视觉模型) 2.gif

2)基础能力升级

  • 现代化界面:全新的聊天界面,支持 Markdown 渲染、代码高亮
    全屏模式: 3.png
  • 会话管理:支持查看管理多个历史对话,自动保存历史记录思考模式:支持推理模型的深度思考,提供更准确的解决方案
  • 多模型支持:兼容各种OpenAI兼容格式 AI 模型,提供模型设置界面自由添加选择模型服务
  • 集成平台更多的MCP工具(Chat模式) 工具调用: 4.png

3)简单配置,快速上手

平台设置:

  • 设置模型服务: 

    支持通过AI插件的customCompatibleAIModels选项自定义添加OpenAI兼容格式大模型(使用MCP功能需要使用支持tools的大模型),建议使用DeepSeek R1/V3、Qwen3、Gemini等对视觉/工具支持良好的模型,优先使用满血模型、推理类型模型效果更好。

    // registry.js
    export default {
      // ......
      [META_APP.Robot]: {
        options: {
          // encryptServiceApiKey: false, // 是否加密服务API密钥, 默认为false
          // enableResourceContext: false, // 提示词上下文携带资源插件图片,默认true
          // enableRagContext: true, // 提示词上下文携带查询到的知识库内容,默认false
          customCompatibleAIModels: [{ // 自定义AI模型(OpenAI兼容格式模型), 下面以智谱模型服务为例
            provider: 'GLM',
            label: '智谱模型',
            baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
            models: [
              {
                label: 'GLM视觉理解模型',
                name: 'glm-4.5v',
                capabilities: {
                  vision: true, // 是否支持视觉理解能力
                  reasoning: { extraBody: { enable: { thinking: { type: 'enabled' } }, disable: null } } // 是否支持深度思考及深度思考打开与关闭额外的body字段
                }
              },
              {
                label: 'GLM-4.5推理模型',
                name: 'glm-4.5',
                capabilities: {
                  toolCalling: true,
                  reasoning: { extraBody: { enable: { thinking: { type: 'enabled' } }, disable: null } }
                }
              }
            ]
          }]
        }
      }
      // ......
    }
    

    可以通过对接最新后端服务使用完整的AI插件能力,或者也可以在前端项目配置AI模型接口Proxy来使用, 这里以本地转发到百炼模型为例:

    // vite.config.js
    const originProxyConfig = baseConfig.server.proxy
    baseConfig.server.proxy = {
      '/app-center/api/chat/completions': {
        target: 'https://dashscope.aliyuncs.com',
        changeOrigin: true,
        rewrite: path => path.replace('/app-center/api/', '/compatible-mode/v1/'),
      },
      '/app-center/api/ai/chat': {
        target: 'https://dashscope.aliyuncs.com',
        changeOrigin: true,
        rewrite: path => path.replace('/app-center/api/ai/chat', '/compatible-mode/v1/chat/completions'),
      },
      ...originProxyConfig,
    }
    

    补充说明:截图生成UI能力由于依赖上传图片接口,需要启动后端服务,且需要使用支持视觉理解能力的模型,如qwen-vl系列模型  

  • 插件配置:

    在插件中也提供了对部分功能的自定义能力,包括是否启用加密API Key解决安全风险问题、是否使用知识库RAG能力提供额外的知识背景提升问答对话效果、是否允许使用资源管理插件中的图片等:

       // registry.js
    export default {
      [META_APP.Robot]: {
        options: {
          // encryptServiceApiKey: false, // 是否加密服务API密钥, 默认为false
          // enableResourceContext: false, // 提示词上下文携带资源插件图片,默认true
          // enableRagContext: true, // 提示词上下文携带查询到的知识库内容,默认false
          // modeImplementation: { // 支持通过注册表传入chat和agent模式的实现
          //   chat: useCustomChatMode
          //   agent: useCustomAgentMode
          // }
        }
      }
    }
    

 

用户设置:

  • 配置服务与密钥:在设置面板编辑内置服务添加API Key或者添加自定义的模型服务 5.gif
  • 选择模型:可以从内置百炼、DeepSeek 或者自定义的模型服务中选择模型(图片生成UI需要多模态模型,MCP工具调用需要支持工具调用模型)
  • 开始使用:在输入框输入问题或者上传图片问答,同时可以自由切换 Agent/Chat 模式,配置MCP工具,开启深度思考等,从智能搭建到深度辅助,全方位提升您的开发效率。快来体验,释放您的创造力!

2.【新特性】添加资源管理插件和资源选择配置器

在应用开发中,通常会需要引用图片等资源,资源管理插件主要满足这类场景需求,可以上传项目中用到的静态资源,在编排页面或AI生成页面时引用(当前仅支持图片格式附件)。

2.1 资源管理

1)资源分组:资源管理插件通过分组管理资源,上传资源之前需要先创建分组,可以为不同场景的静态资源进行分组,比如基础图标库,或者也可以按模块分类 6.png 创建好分组后,点击分组名可以管理当前资源分组 7.png

2)添加资源
添加资源分为两种方式,输入URL和名称添加网络资源,上传图片或图标资源。 其中资源名称必填,通过url添加的话url也必填,如果是上传的,则不能输入url,支持上传png、jpg、svg文件,支持批量上传 8.png

3)修改资源
已添加资源的管理,hover时显示名称,操作包括复制和删除,复制是复制添加完成后在用户服务器上的url地址 9.png 也支持批量操作,点击批量操作后,出现删除图标(后续还会扩展其他批量操作),且资源变为可多选的状态 10.png

 

2.2 资源使用

1)在画布中使用

可以通过图片组件使用资源,选中图片组件后在图片的属性设置处,点击选择资源可以设置为资源管理中的图片

效果

11.png

2)在AI插件中使用

在AI插件Agent模式生成页面时,页面中经常会需要使用到图片资源,AI无法直接生成这些图片,默认会将当前资源管理插件的图片作为备用资源引入使用(仅使用带有描述介绍的图片)。例如“生成登录页面”自动引用背景图与Logo:

12.png

如果不希望在AI助手插件中使用,可以通过修改注册表关闭

// registry.js
export default {
  [META_APP.Robot]: {
    options: {
      enableResourceContext: false, // 提示词上下文携带资源插件图片,默认true
    }
  }
}

3. 【增强】预览插件支持应用预览

在之前的预览插件中只能够实现单页面的预览,对于需要在多个页面中交互跳转的场景无法满足。
在v2.9 版本中,TinyEngine支持了应用的全局预览,能够预览完整项目的效果,并且支持手动路由切换,也能够在调试模式下查看整个应用的源码。 1)入口:

工具栏的预览图标进行了调整,直接点击图标与之前逻辑一致为页面预览,点击后面的箭头可打开下拉列表,可以选择应用预览

13.png

2)预览效果

打开预览页面后,可以看到应用预览与页面预览相比添加了路由切换栏,可以选择路由进行切换。

14.png

4. 【增强】Tailwindcss支持

Tailwind CSS 是一种实用优先的 CSS 框架,提供丰富的原子类,如 text-centerp-4bg-blue-500 等,可快速构建定制化、响应式界面。

低代码平台支持 Tailwind 后,用户在可视化搭建的同时,能直接通过类名精细控制样式,无需编写或配置大量样式即可实现高效美观的前端开发,提升灵活性与开发速度。

在v2.9以上版本,已默认支持Tailwind CSS框架。

 启用后的行为

  • 设计态:画布支持直接加载Tailwind样式类

  • 预览态:自动按需加载  @tailwindcss/browser,使画布/预览中可直接使用 Tailwind 原子类。

  • 出码生成:生成的应用将自动完成以下配置(基于 Tailwind CSS v4 零配置方案):

    • 在依赖中添加  tailwindcss,并在开发依赖中添加  @tailwindcss/vite
    • 在 Vite 配置中注册  tailwindcss()  插件;
    • 生成  src/style.css,内容包含  @import "tailwindcss";
    • 在  src/main.js 自动引入  ./style.css

以上步骤由引擎/出码器自动完成,无需手动干预。

效果

选中节点后在属性配置面板样式类中直接填写Tailwind样式类名,即可看到画布Tailwind样式生效:

15.png

关闭 Tailwind

可以通过注册表关闭Tailwind功能:

// registry.js
export default {
  'engine.config': {
    // ...其他配置
    enableTailwindCSS: true, // 开启(默认即为 true);设为 false 可关闭
  },
};

当配置为 enableTailwindCSS: false 时:

  • 预览态不会加载  @tailwindcss/browser
  • 出码时不会注入与 Tailwind 相关的依赖、Vite 插件及样式文件导入。

注意事项

  • 预览依赖解析:内置 import-map 已包含 @tailwindcss/browser 映射;如使用自定义 CDN/离线环境,请确保该映射可用。
  • 自定义样式:可在生成的 src/style.css 中追加自定义样式,或在项目中新增样式文件后自行引入。
  • 运行时渲染:如果您自定义了运行时渲染引擎,请确保在运行时渲染中增加对 Tailwind CSS 的支持。

5.【增强】支持静态数据源

设计器提供数据源来配合画布上的组件/区块渲染,之前版本只支持采取远程API请求JSON数据动态获取的方式,自TinyEngine v2.9+版本开始,支持静态数据源配置。

使用步骤

1)创建数据源,数据源类型选择静态数据源,配置数据源名称以及数据源字段,根据配置的数据源字段新增静态数据。

16.gif

2)使用数据源Mock数据(数据源使用方式与远程数据源相同)

17.gif

6.【增强】组件物料更新

  • 修改路由选择配置器,添加标签栏配置器和导航组件

拖拽一个导航条组件到画布,可以更改导航条为横向或者纵向,导航菜单项支持增删改,菜单项支持配置跳转页面

18.gif

  • 更新物料Icon(设计稿换新风格后,原物料图标跟页面风格不匹配,更换所有的物料图标)

  • 添加TinyVue图表组件

物料面板新增TinyVue图表组件,主要包括折线图、柱状图、条形图、圆盘图、环形图、雷达图、瀑布图、漏斗图、散点图 等

19.png

  • 添加TinyVue基础组件

  • 表单类型中新增单选组、评分、滑块、级联选择器 组件

20.png

  • 数据展示中新增骨架屏、卡片、日历、进度条、标记、标签、统计数值 组件

21.png

  • 导航类型中新增步骤条和树形菜单组件

22.png

7. 【增强】MCP工具更新

AI 助手除了新增的搭建模式,原有的对话模式也进行了增强,增加了若干个插件的 mcp 工具:

  • 国际化(i18n) 相关 mcp 工具
  • 应用状态、页面状态相关 mcp 工具
  • 页面增删查改工具
  • 节点操作相关 mcp 工具(节点选中、属性修改、增删节点等等)

如何使用

当前可以升级到 v2.9 版本,切换到 chat 模式,即可在对话中使用MCP工具,AI会自动调用相应工具。用户也可以手动点击关闭某个 mcp 工具。

示例图: 23.png

二次开发 TinyEngine 时,如何修改/添加/删除 mcp 工具?

当前 mcp 工具都默认随着插件的注册表导出(因为依赖插件的相关能力),所以如果需要修改/添加/删除 mcp 工具,修改注册表即可。

默认的插件注册表导出:

// mcp 工具 mcp/index.js
export const mcp = {
  tools: [getGlobalState, addOrModifyGlobalState, deleteGlobalState]
}


// 插件注册表导出 index.js
export default {
  ...metaData,
  entry,
  metas: [globalStateService],
  // mcp 的相关导出
  mcp
}

在二次开发工程中修改/添加 mcp 工具,同自定义注册表,请参考注册表相关文档

未来优化

  • 添加、调优 mcp 工具
  • 添加 chat 模式的系统提示词,让 AI 工具调用效果更好

8. 【其他】功能细节优化&bug修复

以上是此次更新的主要内容

如需了解更多可以查看:v2.9.0 所有 changelog

结语

TinyEngine v2.9 的发布,不仅是功能层面的一次全面跃迁——从 AI 助手的能力增强、Tailwind CSS 的原生支持,到资源管理插件的引入、应用预览能力的落地——更是我们对“极致可定制”理念的又一次深化实践。每一个细节的打磨,每一次架构的演进,都旨在让开发者以更低的成本、更高的自由度,构建真正属于自己的低代码世界。

这不仅仅是一个版本的更新,更是社区共建成就的见证。我们相信,开源的意义不仅在于代码共享,更在于思想碰撞与协作共创。正是每一位用户的使用、反馈与贡献,让 TinyEngine 在真实场景中不断淬炼成长。

未来之路,我们继续同行。 欢迎你持续关注 TinyEngine 的演进,参与社区讨论,提交你的想法与代码。让我们携手,把低代码的可能性推向更远的地方。

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

Vue3 Patch 全过程

2025年12月15日 15:36

1. 完整流程图概览

graph TD
    A[响应式数据变更] --> B[触发组件更新]
    B --> C{是首次渲染?}
    
    C -->|是| D[执行 mount 流程]
    D --> E[创建 Block Tree]
    E --> F[递归创建 VNode]
    F --> G[转换为真实 DOM]
    G --> H[完成挂载]
    
    C -->|否| I[执行 patch 流程]
    I --> J{新旧 VNode 类型相同?}
    
    J -->|否| K[卸载旧节点<br>挂载新节点]
    K --> L[结束]
    
    J -->|是| M[进入核心 Patch 逻辑]
    
    subgraph M [Patch 核心流程]
        M1[检查 PatchFlag] --> M2{有 dynamicChildren?}
        M2 -->|有| M3[Block Tree Diff<br>只比较动态节点]
        M2 -->|无| M4{有 PatchFlag?}
        M4 -->|有| M5[靶向更新<br>根据标记更新]
        M4 -->|无| M6[全量 Diff<br>Vue2 方式]
    end
    
    M3 --> N[执行子节点 Diff]
    M5 --> N
    M6 --> N
    
    subgraph N [子节点 Diff 流程]
        N1[预处理:<br>跳过相同首尾] --> N2{还有剩余节点?}
        N2 -->|无| N3[结束]
        N2 -->|有| N4[复杂 Diff 流程]
        
        N4 --> N5[建立 key-index 映射]
        N5 --> N6[创建新旧索引映射表]
        N6 --> N7[计算最长递增子序列 LIS]
        N7 --> N8[移动/创建/删除节点]
    end
    
    N8 --> O[更新 DOM]
    N3 --> O
    O --> P[完成更新]
    
    style A fill:#f9f,stroke:#333,stroke-width:2px
    style H fill:#ccf,stroke:#333,stroke-width:2px
    style P fill:#ccf,stroke:#333,stroke-width:2px
    style M3 fill:#9f9,stroke:#333
    style M5 fill:#9f9,stroke:#333
    style M6 fill:#f99,stroke:#333
    style N7 fill:#ff9,stroke:#333

2. 详细步骤分解表

阶段 1:触发更新

步骤 输入 输出 关键逻辑
1.1 响应式变更 组件状态变化 触发副作用 effect.run()
1.2 调度更新 组件实例 更新任务 queueJob(update)
1.3 执行更新 组件新旧 VNode Patch 调用 patch(n1, n2, container)

阶段 2:Patch 入口决策

graph LR
    A[Patch 开始] --> B{新旧节点类型相同?}
    B -->|否| C[卸载旧节点<br>类型: n1.type]
    C --> D[挂载新节点<br>类型: n2.type]
    D --> Z[结束]
    
    B -->|是| E[检查 ShapeFlag<br>确定节点类型]
    
    E --> F{节点类型判断}
    F -->|元素节点| G[patchElement]
    F -->|组件节点| H[patchComponent]
    F -->|文本节点| I[patchText]
    F -->|Fragment| J[patchFragment]
    F -->|其他类型| K[对应处理]
    
    G --> L[继续元素 Diff]
    H --> M[继续组件更新]
    
    style G fill:#9cf
    style H fill:#9cf

阶段 3:元素节点 Diff (patchElement)

// 实际执行流程
function patchElement(n1, n2, container) {
  // 3.1 复用 DOM 元素
  const el = (n2.el = n1.el)
  
  // 3.2 检查 PatchFlag
  const { patchFlag, dynamicChildren } = n2
  
  // 决策路径
  if (dynamicChildren) {
    // 🟢 情况1:有 Block 优化
    patchBlockChildren(n1.dynamicChildren, dynamicChildren, el)
  } else if (patchFlag) {
    // 🟡 情况2:有 PatchFlag,靶向更新
    if (patchFlag & PatchFlags.TEXT) {
      // 只更新文本
      hostSetElementText(el, n2.children)
    }
    if (patchFlag & PatchFlags.CLASS) {
      // 只更新 class
      hostPatchClass(el, n2.props.class)
    }
    // ... 其他标志检查
  } else {
    // 🔴 情况3:全量 Diff(Vue2 方式)
    fullDiffElement(n1, n2, el)
  }
}

阶段 4:子节点 Diff 详细流程

graph TD
    A[开始子节点 Diff] --> B[预处理阶段]
    
    subgraph B [预处理 - 跳过相同首尾]
        B1[指针: i=0, e1=旧尾, e2=新尾] --> B2{头头相同?}
        B2 -->|是| B3[i++, 继续比较]
        B3 --> B2
        
        B2 -->|否| B4{尾尾相同?}
        B4 -->|是| B5[e1--, e2--, 继续比较]
        B5 --> B4
        
        B4 -->|否| C[进入核心 Diff]
    end
    
    C --> D{判断剩余情况}
    
    D -->|新节点有剩余| E[挂载新节点<br>位置: i 到 e2]
    D -->|旧节点有剩余| F[卸载旧节点<br>位置: i 到 e1]
    D -->|双方都有剩余| G[复杂 Diff]
    
    subgraph G [复杂 Diff 流程]
        G1[建立 key-to-index 映射] --> G2[遍历旧节点]
        G2 --> G3{key 存在?}
        G3 -->|是| G4[更新节点<br>记录新索引位置]
        G3 -->|否| G5[卸载旧节点]
        
        G4 --> G6[构建 newIndexToOldIndexMap]
        G6 --> G7[计算最长递增子序列 LIS]
        G7 --> G8[从后向前遍历]
        
        G8 --> G9{当前位置在 LIS 中?}
        G9 -->|是| G10[保持不动]
        G9 -->|否| G11[需要移动]
        
        G11 --> G12[确定插入位置]
        G12 --> G13[执行 DOM 移动]
    end
    
    E --> H[完成更新]
    F --> H
    G13 --> H
    
    style G7 fill:#ff9
    style G13 fill:#9f9

阶段 5:最长递增子序列 (LIS) 计算过程

原始数组: [2, 4, 3, 5, 1, 6]

步骤1: 初始化
result = [0]       # 存储索引,值: [2]
p = [0,0,0,0,0,0]  # 前驱数组

步骤2: 处理索引1 (值4)
4 > 2, 所以 push: result = [0,1]
p[1] = 0

步骤3: 处理索引2 (值3)
3 < 4, 二分查找找到位置1替换: result = [0,2]
p[2] = 0

步骤4: 处理索引3 (值5)
5 > 3, push: result = [0,2,3]
p[3] = 2

步骤5: 处理索引4 (值1)
1 < 5, 二分查找找到位置0替换: result = [4,2,3]
p[4] = -1 (无前驱)

步骤6: 处理索引5 (值6)
6 > 5, push: result = [4,2,3,5]
p[5] = 3

步骤7: 回溯得到 LIS
从后向前: result = [2,3,5]
对应值: [3,5,6]

最终 LIS 长度: 3
需要移动的节点: 不在 [2,3,5] 中的索引

阶段 6:DOM 操作执行

graph TD
    A[开始 DOM 操作] --> B{操作类型判断}
    
    B -->|创建节点| C[createElement]
    C --> D[设置属性/事件]
    D --> E[插入到容器]
    
    B -->|移动节点| F[获取参考节点]
    F --> G[insertBefore 移动]
    
    B -->|更新节点| H[根据 PatchFlag 更新]
    H --> I[文本更新]
    H --> J[属性更新]
    H --> K[样式更新]
    
    B -->|卸载节点| L[移除事件监听]
    L --> M[递归卸载子节点]
    M --> N[removeChild]
    
    E --> O[完成操作]
    G --> O
    I --> O
    J --> O
    K --> O
    N --> O

3. 性能优化决策矩阵

场景特征 Vue3 选择策略 复杂度 优化效果
全静态内容 静态提升,跳过整个子树 O(1) 99%+ 跳过
少量动态属性 PatchFlag 靶向更新 O(动态属性数) 80-90% 跳过
顺序稳定的列表 Block + LIS 优化 O(n log n) 最少 DOM 移动
顺序完全打乱 全量 Keyed diff O(n) 类似 Vue2
混合静态/动态 Block Tree 隔离 O(动态节点数) 跳过静态节点

4. 关键数据结构示例

VNode 结构

const vnode = {
  type: 'div',                    // 节点类型
  el: null,                       // 对应的 DOM 元素
  children: [],                   // 子节点
  dynamicChildren: null,          // 动态子节点(Block 优化)
  patchFlag: 9,                   // 补丁标志:TEXT(1) + PROPS(8)
  shapeFlag: 17,                  // 形状标志:ELEMENT(1) + TEXT_CHILDREN(16)
  key: 'item-1',                  // 用于 diff 的 key
  props: {                        // 属性
    class: 'container',
    onClick: handler
  }
}

新旧节点映射表

旧节点: [A, B, C, D, E]
      索引:  0  1  2  3  4

新节点: [A, D, C, B, F]
      索引:  0  1  2  3  4

映射表: [0, 3, 2, 1, -1]
解释: 
  新节点0(A) -> 旧索引0
  新节点1(D) -> 旧索引3
  新节点2(C) -> 旧索引2
  新节点3(B) -> 旧索引1
  新节点4(F) -> 旧索引-1(新增)

LIS计算: [0, 2, 4] 对应值 [0, 2, -1]
最长递增子序列: [0, 2](因为-1不是递增)
实际保持位置: 新节点0(A)和2(C)不动
需要移动: 新节点1(D)和3(B)
需要创建: 新节点4(F)

5. 实际代码执行流程

// 示例:更新一个列表组件
const oldVNode = {
  type: 'ul',
  children: [
    { type: 'li', key: 'a', children: 'A' },
    { type: 'li', key: 'b', children: 'B' },
    { type: 'li', key: 'c', children: 'C' },
    { type: 'li', key: 'd', children: 'D' }
  ]
}

const newVNode = {
  type: 'ul',
  children: [
    { type: 'li', key: 'a', children: 'A' },
    { type: 'li', key: 'c', children: 'C Updated' },
    { type: 'li', key: 'b', children: 'B Updated' },
    { type: 'li', key: 'e', children: 'E New' }
  ]
}

// 执行过程:
1. patch(oldVNode, newVNode)
2. 类型相同,进入 patchElement
3. 子节点 diff:
   - 预处理:头头相同 (key='a' 相同)
   - 剩余:旧 [b,c,d] vs 新 [c,b,e]
   - 建立映射:{'c':2, 'b':3, 'e':4}
   - 遍历旧节点:
     * b: 存在,新位置1,更新文本
     * c: 存在,新位置0,更新文本  
     * d: 不存在,卸载
   - newIndexToOldIndexMap: [2, 1, -1]
   - LIS: [0, 1] (值 [2, 1])
   - 从后向前处理:
     * e: 位置2,不在LIS,插入到b之前
     * b: 位置1,在LIS,不动
     * c: 位置0,在LIS,不动
4. DOM操作:
   - 更新c和b的文本
   - 在b之前插入e
   - 移除d

6. 总结流程图

graph TB
    Start[Patch 开始] --> Decision1{首次渲染?}
    
    Decision1 -->|是| Mount[挂载流程]
    Decision1 -->|否| Update[更新流程]
    
    Mount --> CreateVNode[创建 VNode] 
    CreateVNode --> BuildDOM[构建 DOM]
    BuildDOM --> End1[完成]
    
    Update --> TypeCheck{类型相同?}
    TypeCheck -->|否| Replace[替换节点]
    TypeCheck -->|是| PatchCore[核心 Patch]
    
    subgraph PatchCore [优化决策]
        PC1{有 dynamicChildren?} -->|是| Block[Block 优化]
        PC1 -->|否| PC2{有 patchFlag?}
        PC2 -->|是| Targeted[靶向更新]
        PC2 -->|否| Full[全量 Diff]
    end
    
    Block --> ChildDiff[子节点 Diff]
    Targeted --> ChildDiff
    Full --> ChildDiff
    
    subgraph ChildDiff [子节点 Diff 算法]
        CD1[预处理] --> CD2{剩余节点?}
        CD2 -->|新节点有剩余| MountNew[挂载新节点]
        CD2 -->|旧节点有剩余| UnmountOld[卸载旧节点]
        CD2 -->|双方都有| KeyedDiff[Keyed Diff]
        
        KeyedDiff --> BuildMap[建立映射]
        BuildMap --> CalcLIS[计算 LIS]
        CalcLIS --> MoveNodes[移动节点]
    end
    
    MountNew --> DOMOps[DOM 操作]
    UnmountOld --> DOMOps
    MoveNodes --> DOMOps
    
    DOMOps --> End2[完成更新]
    Replace --> End2
    
    style Block fill:#9f9
    style Targeted fill:#9f9
    style Full fill:#f99
    style CalcLIS fill:#ff9

前端权限系统的“断舍离”:从安全防线到体验向导的架构演进

2025年12月15日 12:48

摘要:在企业级中后台应用中,前端权限控制往往容易陷入“过度设计”的误区。本文复盘了我们如何将一个原本计划投入 30 人天的“全栈级前端鉴权方案”,通过架构思维的转变,重构为仅需 5 人天的“体验导向型方案”。我们放弃了在浏览器端构建虚假的“马其诺防线”,转而利用验证中心(Verification Center)模式和 TypeScript 类型系统,打造了极致的用户体验。


一、 背景:一场关于“安全感”的博弈

在最近的 IBS Web 内测迭代中,我们面临一个经典的安全审计问题:“用户可以通过直接修改 URL 访问无权限的页面。”

面对这个问题,技术团队的第一反应是构建一套严密的“前端防线”:

  1. 路由层:在 beforeEach 中拦截所有未授权访问。
  2. 视图层:封装 v-permission 指令移除 DOM 元素。
  3. 数据层:在 Store 中维护一份庞大的权限映射表,甚至试图在前端过滤列表数据。

然而,在深入评估后,我们发现这种“重前端、轻后端”的策略存在巨大的 ROI(投入产出比)陷阱

1.1 误区分析

  • 重复建设:后端 API 已经实现了完善的数据级权限控制(Data Scope),前端再做一遍数据过滤是纯粹的冗余。
  • 维护噩梦:前后端权限逻辑必须时刻保持 1:1 同步,一旦后端调整粒度(如新增一个“导出”权限),前端必须发版,否则就会出现“后端允许但前端拦截”的 False Positive
  • 伪安全:前端的所有代码对用户都是透明的。熟练的攻击者可以直接通过 Postman 绕过前端路由调用 API。前端永远不是安全防线,后端才是。

二、 架构重构:Verification Center 模式

基于“前端负责体验,后端负责安全”的原则,我们重新设计了权限架构。核心组件是 验证中心(Verification Center)

2.1 架构设计图

graph TD
    User[用户行为] --> Router[路由导航]
    Router --> VC[验证中心 (Verification Center)]
    
    subgraph Frontend Logic
        VC -- 触发检查 --> Rules[验证规则链]
        Rules --> R1[登录态校验]
        Rules --> R2[用户类型校验]
        Rules --> R3[企业认证校验]
        Rules --> R4[密码过期校验]
        
        R1 & R2 & R3 & R4 -- 校验通过 --> Next[放行 / 渲染页面]
        R1 & R2 & R3 & R4 -- 校验失败 --> Actions[引导行为]
        Actions --> A1[跳转登录]
        Actions --> A2[显示 403 提示]
        Actions --> A3[弹出强制认证弹窗]
    end
    
    subgraph Backend Security
        API[后端 API] -- 数据请求 --> AuthGuard[后端鉴权层]
        AuthGuard -- 有权限 --> Data[返回业务数据]
        AuthGuard -- 无权限 --> Error[返回 403/空数据]
    end
    
    Next --> API

2.2 核心代码实现:可插拔的验证规则

为了解决“不同场景需要触发不同验证”的问题(例如:F5 刷新时需要重新校验,但路由跳转时可以复用缓存),我们设计了 VerificationRule 接口,并引入了 noCache 机制。

// src/services/verification/index.ts (精简版)

export type When = 'login' | 'appReady' | 'routeChange' | 'manual'

export interface VerificationRule {
  id: string
  when: When[]
  // 核心特性:控制是否跳过会话级缓存
  // F5 刷新或强制重校验时,此标志决定是否再次弹出认证窗口
  noCache?: boolean 
  shouldRun: (ctx: VerificationContext, when?: When) => boolean | Promise<boolean>
  run: (ctx: VerificationContext, when?: When) => void | Promise<void>
}

// 验证执行引擎
async function run(when: When) {
  const list = rules.filter(r => r.when.includes(when))
  for (const rule of list) {
    // 智能缓存策略:除非规则明确要求 noCache,否则同一会话仅执行一次
    if (!rule.noCache && sessionSeen[rule.id])
      continue
      
    if (await rule.shouldRun(ctx, when)) {
      if (!rule.noCache) sessionSeen[rule.id] = true
      await rule.run(ctx, when)
    }
  }
}

设计亮点

  • 解耦:路由守卫不再关心具体的业务逻辑(如“密码是否过期”),只负责触发 VerificationCenter.run('routeChange')
  • 性能:通过 sessionSeen 缓存机制,避免了每次路由切换都重复执行昂贵的校验逻辑。
  • 灵活:针对关键操作(如“用户类型变更”),通过配置 noCache: true 即可强制每次刷新页面时重新校验,完美解决了“F5 刷新后弹窗不复现”的顽疾。

三、 TypeScript 与 Pinia 的类型体操

在重构 Permission Store 时,我们遇到了 Pinia 在复杂类型推断下的一个经典问题:ts(2742)

3.1 问题复现

当我们尝试在 setup 语法中使用复杂的嵌套类型(如递归的菜单树)并隐式推断返回类型时,TypeScript 编译器抛出了错误:

The inferred type of 'usePermissionStore' cannot be named without a reference to '.pnpm/.../node_modules/@intlify/core-base'. This is likely not portable. A type annotation is necessary.

这是因为推断出的类型包含了一些不仅不可见、而且路径极深的第三方内部类型。

3.2 解决方案:显式接口定义

为了解决这个问题,并遵循“高内聚”的设计原则,我们放弃了隐式推断,转而定义明确的 Store 接口。

// src/store/core/permission.ts

// 1. 明确定义路由类型(解决递归类型推断问题)
export type AppRouteRecordRaw = RouteRecordRaw & {
  hidden?: boolean
  children?: AppRouteRecordRaw[]
}

// 2. 定义 Store 公开接口(Contract)
export interface PermissionStoreAPI {
  routes: Ref<AppRouteRecordRaw[]>
  generateRoutesFromMenu: (menuList: MenuItem[]) => MenuItem[]
  restoreRoutes: () => boolean
}

// 3. 在 defineStore 中显式应用接口
export const usePermissionStore = defineStore('permission', (): PermissionStoreAPI => {
  const routes = ref<AppRouteRecordRaw[]>([])
  
  function generateRoutesFromMenu(menuList: MenuItem[]) {
    // ... 具体的业务逻辑
    return []
  }

  function restoreRoutes() {
    // ... 恢复逻辑
    return true
  }

  return {
    routes,
    generateRoutesFromMenu,
    restoreRoutes,
  }
})

这种做法虽然多写了几行代码,但带来了显著的收益:

  • 类型稳定:切断了对第三方私有类型的依赖。
  • 文档化PermissionStoreAPI 接口本身就是最好的文档,开发者一眼就能看出这个 Store 提供了哪些能力。

四、 路由层的“软拦截”策略

在路由层面,我们放弃了传统的“硬拦截”(即检测到无权限直接 next(false) 或重定向),转而采用“软拦截”策略。

4.1 为什么要软拦截?

在内测阶段,如果用户通过 URL 访问了一个尚未在菜单配置的页面,硬拦截会直接导致 404 或死循环。而软拦截允许页面加载,但通过后端 API 的 403 响应来驱动 UI 展示。

4.2 实现方式

// src/router/index.ts

router.beforeEach(async (to, from, next) => {
  // 1. 启动进度条,提升感知
  nprogressManager.start()

  // 2. 核心:不在此处做复杂的权限比对
  // 我们信任后端数据安全,这里只做基础的登录态检查
  // 如果用户已登录但无权限,让他进入页面,看到“无数据”或“无权限”的空状态组件
  
  // 3. 触发验证中心(异步,不阻塞路由跳转)
  VerificationCenter.run('routeChange')

  next()
})

这种策略将“权限不足”的处理权交还给了页面组件(配合 <el-empty description="无权访问" />),既保证了系统的鲁棒性,又提升了用户体验。


五、 总结与思考

这次重构不仅仅是代码层面的修改,更是技术价值观的校准。

  1. 分层治理:后端守住安全底线,前端负责交互上限。
  2. 体验优先:权限控制的目的是“引导用户”,而不是“防御用户”。
  3. 极简主义:用 20% 的代码解决 80% 的核心体验问题,剩下的 20% 极端场景交给后端兜底。

通过这套架构,我们将原本需要 30 人天的庞大工程,精简为 5 人天的高效迭代,同时彻底解决了 F5 刷新、类型推断错误等技术债。这或许才是架构设计的魅力所在:在约束中寻找最优解。

❌
❌